diff --git a/.changeset/quiet-archived-session-selection.md b/.changeset/quiet-archived-session-selection.md new file mode 100644 index 0000000..cca770a --- /dev/null +++ b/.changeset/quiet-archived-session-selection.md @@ -0,0 +1,5 @@ +--- +"@jmfederico/pi-web": patch +--- + +Avoid automatically reselecting archived-only sessions unless an archived session was explicitly selected, and let closing the archived section clear archived session selection. diff --git a/src/client/src/components/PiWebApp.ts b/src/client/src/components/PiWebApp.ts index 592ec4a..602e50b 100644 --- a/src/client/src/components/PiWebApp.ts +++ b/src/client/src/components/PiWebApp.ts @@ -405,6 +405,7 @@ export class PiWebApp extends LitElement { .collapsible=${this.isMobileNavigationLayout} .collapsed=${this.isNavigationSectionCollapsed("sessions")} .onToggleCollapsed=${() => { this.toggleNavigationSection("sessions"); }} + .onArchivedCollapsed=${() => { this.sessions.clearSelectionAfterArchivedCollapse(); }} .onStart=${() => openChatAfter(() => this.sessions.startSession())} .onSelect=${(session: SessionInfo) => openChatAfter(() => this.sessions.selectSession(session))} .onArchive=${(session: SessionInfo) => this.sessions.archiveSession(session)} diff --git a/src/client/src/components/SessionList.ts b/src/client/src/components/SessionList.ts index 5b923cb..f2bb2a8 100644 --- a/src/client/src/components/SessionList.ts +++ b/src/client/src/components/SessionList.ts @@ -30,6 +30,7 @@ export class SessionList extends LitElement { @property({ attribute: false }) onSelect?: (session: SessionInfo) => void; @property({ attribute: false }) onStart?: () => void; @property({ attribute: false }) onToggleCollapsed?: () => void; + @property({ attribute: false }) onArchivedCollapsed?: () => void; @state() private openMenuSessionId: string | undefined; @state() private menuStyle = ""; @state() private archivedExpanded = false; @@ -56,7 +57,8 @@ export class SessionList extends LitElement { if (changed.has("sessions") && this.openMenuSessionId !== undefined && !this.sessions.some((session) => session.id === this.openMenuSessionId)) this.openMenuSessionId = undefined; if (changed.has("collapsed") && this.collapsed) this.openMenuSessionId = undefined; if (changed.has("sessions") && !this.sessions.some((session) => session.archived === true)) this.archivedExpanded = false; - if (this.selected?.archived === true && !this.archivedExpanded) { + const previousSelected = changed.get("selected"); + if (changed.has("selected") && this.selected?.archived === true && (previousSelected?.id !== this.selected.id || previousSelected.archived !== true) && !this.archivedExpanded) { this.archivedExpanded = true; void this.updateComplete.then(() => { this.scrollSelectedIntoView(); }); return; @@ -138,7 +140,10 @@ export class SessionList extends LitElement { private toggleArchived() { this.archivedExpanded = !this.archivedExpanded; - if (!this.archivedExpanded) this.openMenuSessionId = undefined; + if (!this.archivedExpanded) { + this.openMenuSessionId = undefined; + this.onArchivedCollapsed?.(); + } } private scrollSelectedIntoView(): void { diff --git a/src/client/src/controllers/sessionController.test.ts b/src/client/src/controllers/sessionController.test.ts index f8d8947..a59c984 100644 --- a/src/client/src/controllers/sessionController.test.ts +++ b/src/client/src/controllers/sessionController.test.ts @@ -4,6 +4,7 @@ import { loadCachedNewSessions, markCachedNewSessionInfo, rememberCachedNewSessi import { initialAppState, type AppState } from "../appState"; import { loadDraft, saveDraft } from "../promptDraftStorage"; import { SessionController, type SessionEventSocket } from "./sessionController"; +import { InMemorySessionSelectionMemory } from "./sessionSelection"; class MemoryStorage implements Storage { private readonly values = new Map(); @@ -131,4 +132,58 @@ describe("SessionController", () => { expect(loadCachedNewSessions().map((session) => session.id)).toEqual([replacementSession.id]); expect(urlUpdates).toEqual([{ replace: true }]); }); + + it("forgets the selected active session when archiving leaves only archived sessions", async () => { + let state: AppState = { ...initialAppState(), selectedWorkspace: workspace, sessions: [oldSession] }; + const urlUpdates: ({ replace?: boolean | undefined } | undefined)[] = []; + const api: typeof defaultApi = { + ...defaultApi, + archive: () => Promise.resolve({ archived: true }), + messages: () => Promise.resolve(emptyPage), + status: (sessionId) => Promise.resolve(status(sessionId)), + }; + const controller = new SessionController( + () => state, + (patch) => { state = { ...state, ...patch }; }, + (options) => { urlUpdates.push(options); }, + new InMemorySessionSelectionMemory(), + { api, socket: new FakeSocket() }, + ); + + await controller.selectSession(oldSession, { updateUrl: false }); + await controller.archiveSession(); + + expect(state.selectedSession).toBeUndefined(); + expect(state.sessions).toHaveLength(1); + expect(state.sessions[0]).toMatchObject({ ...oldSession, archived: true }); + expect(typeof state.sessions[0]?.archivedAt).toBe("string"); + expect(controller.preferredSession(workspace.path, state.sessions, undefined)).toBeUndefined(); + expect(urlUpdates).toEqual([undefined]); + }); + + it("forgets archived selections when the archived section collapse clears selection", async () => { + const archivedSession = { ...oldSession, archived: true, archivedAt: "later" }; + let state: AppState = { ...initialAppState(), selectedWorkspace: workspace, sessions: [archivedSession] }; + const urlUpdates: ({ replace?: boolean | undefined } | undefined)[] = []; + const api: typeof defaultApi = { + ...defaultApi, + messages: () => Promise.resolve(emptyPage), + }; + const controller = new SessionController( + () => state, + (patch) => { state = { ...state, ...patch }; }, + (options) => { urlUpdates.push(options); }, + new InMemorySessionSelectionMemory(), + { api, socket: new FakeSocket() }, + ); + + await controller.selectSession(archivedSession, { updateUrl: false }); + expect(controller.preferredSession(workspace.path, state.sessions, undefined)).toBe(archivedSession); + + controller.clearSelectionAfterArchivedCollapse(); + + expect(state.selectedSession).toBeUndefined(); + expect(controller.preferredSession(workspace.path, state.sessions, undefined)).toBeUndefined(); + expect(urlUpdates).toEqual([undefined]); + }); }); diff --git a/src/client/src/controllers/sessionController.ts b/src/client/src/controllers/sessionController.ts index 15ebcef..7361572 100644 --- a/src/client/src/controllers/sessionController.ts +++ b/src/client/src/controllers/sessionController.ts @@ -5,7 +5,7 @@ import { clearDraft, moveDraft } from "../promptDraftStorage"; import { ChatTranscriptStore } from "../chatTranscriptStore"; import { isShellInput } from "../inputModes"; import { SessionSocket, type GlobalSessionEvent, type SessionUiEvent } from "../sessionSocket"; -import { InMemorySessionSelectionMemory, markSessionArchived, selectPreferredSession, selectionAfterArchivingSession, type SessionSelectionMemory } from "./sessionSelection"; +import { InMemorySessionSelectionMemory, markSessionArchived, selectPreferredSession, selectionAfterArchivingSession, shouldDeselectAfterArchivedCollapse, type SessionSelectionMemory } from "./sessionSelection"; import type { GetState, SetState, UpdateUrl } from "./types"; const MESSAGE_PAGE_SIZE = 100; @@ -55,12 +55,27 @@ export class SessionController { } clearActiveSession() { + this.selectionSeq += 1; this.socket.close(); this.catchupStreamSessionId = undefined; this.clearPendingTranscriptEvents(); this.setState({ selectedSession: undefined, messages: [], messagePageStart: 0, messagePageTotal: 0, isLoadingEarlierMessages: false, isReceivingPartialStream: false, status: undefined, activity: undefined }); } + deselectSession(options?: { forgetRememberedSelection?: boolean | undefined; updateUrl?: boolean | undefined }) { + const state = this.getState(); + const cwd = state.selectedSession?.cwd ?? state.selectedWorkspace?.path; + if (options?.forgetRememberedSelection === true && cwd !== undefined) this.sessionSelection.forgetWorkspace(cwd); + this.clearActiveSession(); + if (options?.updateUrl !== false) this.updateUrl(); + } + + clearSelectionAfterArchivedCollapse(): void { + const state = this.getState(); + if (!shouldDeselectAfterArchivedCollapse(state.sessions, state.selectedSession)) return; + this.deselectSession({ forgetRememberedSelection: true }); + } + async startSession() { const workspace = this.getState().selectedWorkspace; if (!workspace) return; @@ -217,10 +232,7 @@ export class SessionController { this.setState({ sessions }); if (selectionChange.type === "select") await this.selectSession(selectionChange.session); - else if (selectionChange.type === "clear") { - this.clearActiveSession(); - this.updateUrl(); - } + else if (selectionChange.type === "clear") this.deselectSession({ forgetRememberedSelection: true }); } catch (error) { this.setState({ error: String(error) }); } diff --git a/src/client/src/controllers/sessionSelection.test.ts b/src/client/src/controllers/sessionSelection.test.ts index 2518349..1d656be 100644 --- a/src/client/src/controllers/sessionSelection.test.ts +++ b/src/client/src/controllers/sessionSelection.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; import type { SessionInfo } from "../api"; -import { InMemorySessionSelectionMemory, markSessionArchived, selectPreferredSession, selectionAfterArchivingSession } from "./sessionSelection"; +import { InMemorySessionSelectionMemory, markSessionArchived, selectPreferredSession, selectionAfterArchivingSession, shouldDeselectAfterArchivedCollapse } from "./sessionSelection"; describe("selectPreferredSession", () => { it("prefers an explicit target session by id", () => { @@ -21,12 +21,24 @@ describe("selectPreferredSession", () => { expect(selectPreferredSession(sessions, { latestSessionId: "s2" })?.id).toBe("s2"); }); + it("does not select an archived session without an explicit or remembered selection", () => { + const sessions = [{ ...testSession("s1"), archived: true }]; + + expect(selectPreferredSession(sessions)).toBeUndefined(); + }); + it("can remember an archived selected session", () => { const sessions = [{ ...testSession("s1"), archived: true }, testSession("s2")]; expect(selectPreferredSession(sessions, { latestSessionId: "s1" })?.id).toBe("s1"); }); + it("can remember an archived selected session when only archived sessions remain", () => { + const sessions = [{ ...testSession("s1"), archived: true }]; + + 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")]; @@ -68,6 +80,18 @@ describe("markSessionArchived", () => { }); }); +describe("shouldDeselectAfterArchivedCollapse", () => { + it("deselects archived selections only when no active sessions remain", () => { + const archived = { ...testSession("archived"), archived: true }; + const active = testSession("active"); + + expect(shouldDeselectAfterArchivedCollapse([archived], archived)).toBe(true); + expect(shouldDeselectAfterArchivedCollapse([archived, active], archived)).toBe(false); + expect(shouldDeselectAfterArchivedCollapse([archived], undefined)).toBe(false); + expect(shouldDeselectAfterArchivedCollapse([archived], active)).toBe(false); + }); +}); + describe("selectionAfterArchivingSession", () => { it("leaves selection unchanged when archiving an unselected session", () => { expect(selectionAfterArchivingSession([testSession("s1"), testSession("s2")], "s1", "s2")).toEqual({ type: "unchanged" }); diff --git a/src/client/src/controllers/sessionSelection.ts b/src/client/src/controllers/sessionSelection.ts index a12fe4e..7d199e6 100644 --- a/src/client/src/controllers/sessionSelection.ts +++ b/src/client/src/controllers/sessionSelection.ts @@ -32,6 +32,11 @@ export function selectPreferredSession(sessions: SessionInfo[], options?: { targ return sessions.find((session) => session.archived !== true); } +export function shouldDeselectAfterArchivedCollapse(sessions: SessionInfo[], selectedSession: SessionInfo | undefined): boolean { + if (selectedSession?.archived !== true) return false; + return !sessions.some((session) => session.archived !== true); +} + function sessionByIdOrPrefix(sessions: SessionInfo[], sessionId: string): SessionInfo | undefined { return sessions.find((session) => session.id === sessionId || session.id.startsWith(sessionId)); }