fix: clear archived-only session selection

This commit is contained in:
Federico Jaramillo Martinez
2026-05-20 20:48:42 +02:00
parent ea5d863297
commit 894c4d092b
7 changed files with 115 additions and 8 deletions
@@ -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.
+1
View File
@@ -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)}
+7 -2
View File
@@ -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 {
@@ -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<string, string>();
@@ -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]);
});
});
@@ -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) });
}
@@ -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" });
@@ -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));
}