Archived
fix: clear archived-only session selection
This commit is contained in:
@@ -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.
|
||||
@@ -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)}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user