Archived
Remember selected sessions and reveal selections
This commit is contained in:
@@ -48,6 +48,12 @@ export class SessionList extends LitElement {
|
|||||||
protected override updated(changed: PropertyValues<this>): void {
|
protected override updated(changed: PropertyValues<this>): void {
|
||||||
if (changed.has("sessions") && this.openMenuSessionId !== undefined && !this.sessions.some((session) => session.id === this.openMenuSessionId)) this.openMenuSessionId = undefined;
|
if (changed.has("sessions") && this.openMenuSessionId !== undefined && !this.sessions.some((session) => session.id === this.openMenuSessionId)) this.openMenuSessionId = undefined;
|
||||||
if (changed.has("sessions") && !this.sessions.some((session) => session.archived === true)) this.archivedExpanded = false;
|
if (changed.has("sessions") && !this.sessions.some((session) => session.archived === true)) this.archivedExpanded = false;
|
||||||
|
if (this.selected?.archived === true && !this.archivedExpanded) {
|
||||||
|
this.archivedExpanded = true;
|
||||||
|
void this.updateComplete.then(() => { this.scrollSelectedIntoView(); });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (changed.has("selected") || changed.has("sessions")) this.scrollSelectedIntoView();
|
||||||
}
|
}
|
||||||
|
|
||||||
override render() {
|
override render() {
|
||||||
@@ -113,6 +119,10 @@ export class SessionList extends LitElement {
|
|||||||
if (!this.archivedExpanded) this.openMenuSessionId = undefined;
|
if (!this.archivedExpanded) this.openMenuSessionId = undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private scrollSelectedIntoView(): void {
|
||||||
|
this.renderRoot.querySelector<HTMLElement>(".action-row.selected")?.scrollIntoView({ block: "nearest" });
|
||||||
|
}
|
||||||
|
|
||||||
private renderStatus(session: SessionInfo) {
|
private renderStatus(session: SessionInfo) {
|
||||||
if (session.archived === true) return "read-only · ";
|
if (session.archived === true) return "read-only · ";
|
||||||
const status = this.statuses[session.id];
|
const status = this.statuses[session.id];
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { LitElement, html } from "lit";
|
import { LitElement, html, type PropertyValues } from "lit";
|
||||||
import { customElement, property } from "lit/decorators.js";
|
import { customElement, property } from "lit/decorators.js";
|
||||||
import type { Workspace } from "../api";
|
import type { Workspace } from "../api";
|
||||||
import type { WorkspaceLabelItem } from "../plugins/types";
|
import type { WorkspaceLabelItem } from "../plugins/types";
|
||||||
@@ -13,6 +13,10 @@ export class WorkspaceList extends LitElement {
|
|||||||
@property({ attribute: false }) workspaceLabelItems: (workspace: Workspace) => WorkspaceLabelItem[] = () => [];
|
@property({ attribute: false }) workspaceLabelItems: (workspace: Workspace) => WorkspaceLabelItem[] = () => [];
|
||||||
@property({ attribute: false }) onSelect?: (workspace: Workspace) => void;
|
@property({ attribute: false }) onSelect?: (workspace: Workspace) => void;
|
||||||
|
|
||||||
|
protected override updated(changed: PropertyValues<this>): void {
|
||||||
|
if (changed.has("selected") || changed.has("workspaces")) this.scrollSelectedIntoView();
|
||||||
|
}
|
||||||
|
|
||||||
override render() {
|
override render() {
|
||||||
return html`
|
return html`
|
||||||
<section>
|
<section>
|
||||||
@@ -41,5 +45,9 @@ export class WorkspaceList extends LitElement {
|
|||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private scrollSelectedIntoView(): void {
|
||||||
|
this.renderRoot.querySelector<HTMLElement>(".action-row.selected")?.scrollIntoView({ block: "nearest" });
|
||||||
|
}
|
||||||
|
|
||||||
static override styles = listStyles;
|
static override styles = listStyles;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { readChatHistoryCache, mergeChatHistory, writeChatHistoryCache, type Raw
|
|||||||
import { applyTranscriptEvent } from "../chatTranscript";
|
import { applyTranscriptEvent } from "../chatTranscript";
|
||||||
import { isShellInput } from "../inputModes";
|
import { isShellInput } from "../inputModes";
|
||||||
import { SessionSocket, type GlobalSessionEvent, type SessionUiEvent } from "../sessionSocket";
|
import { SessionSocket, type GlobalSessionEvent, type SessionUiEvent } from "../sessionSocket";
|
||||||
import { markSessionArchived, selectionAfterArchivingSession } from "./sessionSelection";
|
import { InMemorySessionSelectionMemory, markSessionArchived, selectPreferredSession, selectionAfterArchivingSession, type SessionSelectionMemory } from "./sessionSelection";
|
||||||
import type { GetState, SetState, UpdateUrl } from "./types";
|
import type { GetState, SetState, UpdateUrl } from "./types";
|
||||||
|
|
||||||
export class SessionController {
|
export class SessionController {
|
||||||
@@ -16,7 +16,12 @@ export class SessionController {
|
|||||||
private pendingTranscriptEvents: SessionUiEvent[] = [];
|
private pendingTranscriptEvents: SessionUiEvent[] = [];
|
||||||
private pendingTranscriptFrame: number | undefined;
|
private pendingTranscriptFrame: number | undefined;
|
||||||
|
|
||||||
constructor(private readonly getState: GetState, private readonly setState: SetState, private readonly updateUrl: UpdateUrl) {}
|
constructor(
|
||||||
|
private readonly getState: GetState,
|
||||||
|
private readonly setState: SetState,
|
||||||
|
private readonly updateUrl: UpdateUrl,
|
||||||
|
private readonly sessionSelection: SessionSelectionMemory = new InMemorySessionSelectionMemory(),
|
||||||
|
) {}
|
||||||
|
|
||||||
applyGlobalEvent(event: GlobalSessionEvent): void {
|
applyGlobalEvent(event: GlobalSessionEvent): void {
|
||||||
if (event.type === "status.update") this.applyStatus(event.status);
|
if (event.type === "status.update") this.applyStatus(event.status);
|
||||||
@@ -48,7 +53,12 @@ export class SessionController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
preferredSession(cwd: string, sessions: SessionInfo[], targetSessionId: string | undefined): SessionInfo | undefined {
|
||||||
|
return selectPreferredSession(sessions, { targetSessionId, latestSessionId: this.sessionSelection.latestSessionId(cwd) });
|
||||||
|
}
|
||||||
|
|
||||||
async selectSession(session: SessionInfo, options?: { updateUrl?: boolean | undefined }) {
|
async selectSession(session: SessionInfo, options?: { updateUrl?: boolean | undefined }) {
|
||||||
|
this.sessionSelection.rememberSession(session);
|
||||||
const seq = ++this.selectionSeq;
|
const seq = ++this.selectionSeq;
|
||||||
this.socket.close();
|
this.socket.close();
|
||||||
this.catchupStreamSessionId = undefined;
|
this.catchupStreamSessionId = undefined;
|
||||||
|
|||||||
@@ -1,6 +1,61 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import type { SessionInfo } from "../api";
|
import type { SessionInfo } from "../api";
|
||||||
import { markSessionArchived, selectionAfterArchivingSession } from "./sessionSelection";
|
import { InMemorySessionSelectionMemory, markSessionArchived, selectPreferredSession, selectionAfterArchivingSession } from "./sessionSelection";
|
||||||
|
|
||||||
|
describe("selectPreferredSession", () => {
|
||||||
|
it("prefers an explicit target session by id", () => {
|
||||||
|
const sessions = [testSession("s1"), testSession("s2")];
|
||||||
|
|
||||||
|
expect(selectPreferredSession(sessions, { targetSessionId: "s2", latestSessionId: "s1" })?.id).toBe("s2");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("matches explicit target sessions by id prefix", () => {
|
||||||
|
const session = testSession("abcdef");
|
||||||
|
|
||||||
|
expect(selectPreferredSession([session], { targetSessionId: "abc" })).toBe(session);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("remembers the latest selected session when no explicit target is provided", () => {
|
||||||
|
const sessions = [testSession("s1"), testSession("s2")];
|
||||||
|
|
||||||
|
expect(selectPreferredSession(sessions, { latestSessionId: "s2" })?.id).toBe("s2");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("can remember an archived selected session", () => {
|
||||||
|
const sessions = [{ ...testSession("s1"), archived: true }, testSession("s2")];
|
||||||
|
|
||||||
|
expect(selectPreferredSession(sessions, { latestSessionId: "s1" })?.id).toBe("s1");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to the first active session when the remembered session no longer exists", () => {
|
||||||
|
const sessions = [{ ...testSession("s1"), archived: true }, testSession("s2")];
|
||||||
|
|
||||||
|
expect(selectPreferredSession(sessions, { latestSessionId: "old" })?.id).toBe("s2");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns undefined for an invalid explicit target", () => {
|
||||||
|
const sessions = [testSession("s1"), testSession("s2")];
|
||||||
|
|
||||||
|
expect(selectPreferredSession(sessions, { targetSessionId: "old", latestSessionId: "s2" })).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("InMemorySessionSelectionMemory", () => {
|
||||||
|
it("remembers and forgets the latest selected session per cwd", () => {
|
||||||
|
const memory = new InMemorySessionSelectionMemory();
|
||||||
|
|
||||||
|
memory.rememberSession({ ...testSession("s1"), cwd: "/tmp/one" });
|
||||||
|
memory.rememberSession({ ...testSession("s2"), cwd: "/tmp/two" });
|
||||||
|
|
||||||
|
expect(memory.latestSessionId("/tmp/one")).toBe("s1");
|
||||||
|
expect(memory.latestSessionId("/tmp/two")).toBe("s2");
|
||||||
|
|
||||||
|
memory.forgetWorkspace("/tmp/one");
|
||||||
|
|
||||||
|
expect(memory.latestSessionId("/tmp/one")).toBeUndefined();
|
||||||
|
expect(memory.latestSessionId("/tmp/two")).toBe("s2");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("markSessionArchived", () => {
|
describe("markSessionArchived", () => {
|
||||||
it("marks the matching session archived without mutating the original", () => {
|
it("marks the matching session archived without mutating the original", () => {
|
||||||
|
|||||||
@@ -1,5 +1,41 @@
|
|||||||
import type { SessionInfo } from "../api";
|
import type { SessionInfo } from "../api";
|
||||||
|
|
||||||
|
export interface SessionSelectionMemory {
|
||||||
|
latestSessionId(cwd: string): string | undefined;
|
||||||
|
rememberSession(session: SessionInfo): void;
|
||||||
|
forgetWorkspace(cwd: string): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class InMemorySessionSelectionMemory implements SessionSelectionMemory {
|
||||||
|
private readonly sessionIdsByCwd = new Map<string, string>();
|
||||||
|
|
||||||
|
latestSessionId(cwd: string): string | undefined {
|
||||||
|
return this.sessionIdsByCwd.get(cwd);
|
||||||
|
}
|
||||||
|
|
||||||
|
rememberSession(session: SessionInfo): void {
|
||||||
|
this.sessionIdsByCwd.set(session.cwd, session.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
forgetWorkspace(cwd: string): void {
|
||||||
|
this.sessionIdsByCwd.delete(cwd);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function selectPreferredSession(sessions: SessionInfo[], options?: { targetSessionId?: string | undefined; latestSessionId?: string | undefined }): SessionInfo | undefined {
|
||||||
|
const targetSessionId = options?.targetSessionId;
|
||||||
|
if (targetSessionId !== undefined && targetSessionId !== "") return sessionByIdOrPrefix(sessions, targetSessionId);
|
||||||
|
|
||||||
|
const latestSessionId = options?.latestSessionId;
|
||||||
|
if (latestSessionId !== undefined && latestSessionId !== "") return sessions.find((session) => session.id === latestSessionId) ?? sessions.find((session) => session.archived !== true);
|
||||||
|
|
||||||
|
return sessions.find((session) => session.archived !== true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function sessionByIdOrPrefix(sessions: SessionInfo[], sessionId: string): SessionInfo | undefined {
|
||||||
|
return sessions.find((session) => session.id === sessionId || session.id.startsWith(sessionId));
|
||||||
|
}
|
||||||
|
|
||||||
export type ArchiveSelectionChange =
|
export type ArchiveSelectionChange =
|
||||||
| { type: "unchanged" }
|
| { type: "unchanged" }
|
||||||
| { type: "select"; session: SessionInfo }
|
| { type: "select"; session: SessionInfo }
|
||||||
|
|||||||
@@ -43,8 +43,7 @@ export class WorkspaceController {
|
|||||||
try {
|
try {
|
||||||
const sessions = await api.sessions(workspace.path);
|
const sessions = await api.sessions(workspace.path);
|
||||||
this.setState({ sessions });
|
this.setState({ sessions });
|
||||||
const sessionId = target?.sessionId;
|
const session = this.sessions.preferredSession(workspace.path, sessions, target?.sessionId);
|
||||||
const session = sessionId !== undefined && sessionId !== "" ? sessions.find((s) => s.id === sessionId || s.id.startsWith(sessionId)) : sessions.find((s) => s.archived !== true);
|
|
||||||
if (session) await this.sessions.selectSession(session, { updateUrl: target?.updateUrl });
|
if (session) await this.sessions.selectSession(session, { updateUrl: target?.updateUrl });
|
||||||
else if (target?.updateUrl !== false) this.updateUrl();
|
else if (target?.updateUrl !== false) this.updateUrl();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
Reference in New Issue
Block a user