diff --git a/src/client/src/components/SessionList.ts b/src/client/src/components/SessionList.ts index a998b24..e8f9a27 100644 --- a/src/client/src/components/SessionList.ts +++ b/src/client/src/components/SessionList.ts @@ -48,6 +48,12 @@ export class SessionList extends LitElement { protected override updated(changed: PropertyValues): void { 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 (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() { @@ -113,6 +119,10 @@ export class SessionList extends LitElement { if (!this.archivedExpanded) this.openMenuSessionId = undefined; } + private scrollSelectedIntoView(): void { + this.renderRoot.querySelector(".action-row.selected")?.scrollIntoView({ block: "nearest" }); + } + private renderStatus(session: SessionInfo) { if (session.archived === true) return "read-only ยท "; const status = this.statuses[session.id]; diff --git a/src/client/src/components/WorkspaceList.ts b/src/client/src/components/WorkspaceList.ts index e02c0de..5a5d0fe 100644 --- a/src/client/src/components/WorkspaceList.ts +++ b/src/client/src/components/WorkspaceList.ts @@ -1,4 +1,4 @@ -import { LitElement, html } from "lit"; +import { LitElement, html, type PropertyValues } from "lit"; import { customElement, property } from "lit/decorators.js"; import type { Workspace } from "../api"; 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 }) onSelect?: (workspace: Workspace) => void; + protected override updated(changed: PropertyValues): void { + if (changed.has("selected") || changed.has("workspaces")) this.scrollSelectedIntoView(); + } + override render() { return html`
@@ -41,5 +45,9 @@ export class WorkspaceList extends LitElement { `; } + private scrollSelectedIntoView(): void { + this.renderRoot.querySelector(".action-row.selected")?.scrollIntoView({ block: "nearest" }); + } + static override styles = listStyles; } diff --git a/src/client/src/controllers/sessionController.ts b/src/client/src/controllers/sessionController.ts index a48256e..475b9b8 100644 --- a/src/client/src/controllers/sessionController.ts +++ b/src/client/src/controllers/sessionController.ts @@ -6,7 +6,7 @@ import { readChatHistoryCache, mergeChatHistory, writeChatHistoryCache, type Raw import { applyTranscriptEvent } from "../chatTranscript"; import { isShellInput } from "../inputModes"; 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"; export class SessionController { @@ -16,7 +16,12 @@ export class SessionController { private pendingTranscriptEvents: SessionUiEvent[] = []; 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 { 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 }) { + this.sessionSelection.rememberSession(session); const seq = ++this.selectionSeq; this.socket.close(); this.catchupStreamSessionId = undefined; diff --git a/src/client/src/controllers/sessionSelection.test.ts b/src/client/src/controllers/sessionSelection.test.ts index cfe527c..2518349 100644 --- a/src/client/src/controllers/sessionSelection.test.ts +++ b/src/client/src/controllers/sessionSelection.test.ts @@ -1,6 +1,61 @@ import { describe, expect, it } from "vitest"; 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", () => { it("marks the matching session archived without mutating the original", () => { diff --git a/src/client/src/controllers/sessionSelection.ts b/src/client/src/controllers/sessionSelection.ts index d1ce117..a12fe4e 100644 --- a/src/client/src/controllers/sessionSelection.ts +++ b/src/client/src/controllers/sessionSelection.ts @@ -1,5 +1,41 @@ 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(); + + 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 = | { type: "unchanged" } | { type: "select"; session: SessionInfo } diff --git a/src/client/src/controllers/workspaceController.ts b/src/client/src/controllers/workspaceController.ts index b20c483..c174ec7 100644 --- a/src/client/src/controllers/workspaceController.ts +++ b/src/client/src/controllers/workspaceController.ts @@ -43,8 +43,7 @@ export class WorkspaceController { try { const sessions = await api.sessions(workspace.path); this.setState({ sessions }); - const sessionId = target?.sessionId; - const session = sessionId !== undefined && sessionId !== "" ? sessions.find((s) => s.id === sessionId || s.id.startsWith(sessionId)) : sessions.find((s) => s.archived !== true); + const session = this.sessions.preferredSession(workspace.path, sessions, target?.sessionId); if (session) await this.sessions.selectSession(session, { updateUrl: target?.updateUrl }); else if (target?.updateUrl !== false) this.updateUrl(); } catch (error) {