Remember selected sessions and reveal selections

This commit is contained in:
Federico Jaramillo Martinez
2026-05-13 12:37:01 +02:00
parent cd12850594
commit 2951742c7b
6 changed files with 124 additions and 6 deletions
+10
View File
@@ -48,6 +48,12 @@ export class SessionList extends LitElement {
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.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<HTMLElement>(".action-row.selected")?.scrollIntoView({ block: "nearest" });
}
private renderStatus(session: SessionInfo) {
if (session.archived === true) return "read-only · ";
const status = this.statuses[session.id];
+9 -1
View File
@@ -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<this>): void {
if (changed.has("selected") || changed.has("workspaces")) this.scrollSelectedIntoView();
}
override render() {
return html`
<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;
}
@@ -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;
@@ -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", () => {
@@ -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<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 =
| { type: "unchanged" }
| { type: "select"; session: SessionInfo }
@@ -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) {