From ea1ec1b595e3b28d46e69c3e5be29f6aca6903e8 Mon Sep 17 00:00:00 2001 From: Slava Iumin Date: Tue, 9 Jun 2026 09:45:54 +0300 Subject: [PATCH 1/3] feat(sessions): add Reload action to refresh session from disk Sessiond caches the in-memory SessionManager and never re-reads the session file. When the same session is also being edited by another process (e.g. the pi CLI), new entries on disk are invisible to the web UI \u2014 the tail of the conversation gets cut. Add a manual Reload action in the session three-dot menu: - Server: PiSessionService.reload(sessionId) closes the active session and re-opens it from disk, then publishes a fresh status. Exposed as POST /api/.../sessions/:sessionId/reload. - Client: api.reloadSession, SessionController.reloadSession which discards the cached transcript and re-runs selectSession so the history page is re-fetched. - ChatTranscriptStore gains discard(sessionId) and the history cache adapter gains optional remove(sessionId). - SessionList shows a Reload entry for non-archived, non-cached sessions; plumbed through AppNavigationPanel and PiWebApp. Note: pi-web-sessiond.service must be restarted manually after this change since the session daemon code path is affected. --- src/client/src/api/clients.ts | 2 ++ src/client/src/api/parsers.ts | 6 ++++++ src/client/src/chatHistoryCache.ts | 8 ++++++++ src/client/src/chatTranscriptStore.ts | 9 ++++++++- src/client/src/components/PiWebApp.ts | 1 + src/client/src/components/SessionList.ts | 2 ++ .../src/components/appShell/AppNavigationPanel.ts | 2 ++ src/client/src/controllers/sessionController.ts | 13 +++++++++++++ src/server/sessions/piSessionService.ts | 7 +++++++ src/server/sessions/sessionRoutes.ts | 9 +++++++++ 10 files changed, 58 insertions(+), 1 deletion(-) diff --git a/src/client/src/api/clients.ts b/src/client/src/api/clients.ts index bfda54a..389f61e 100644 --- a/src/client/src/api/clients.ts +++ b/src/client/src/api/clients.ts @@ -10,6 +10,7 @@ import { parseCommandResult, parseDeleted, parseDetached, + parseReloaded, parseFileContentResponse, parseFileSuggestion, parseFileTreeResponse, @@ -143,6 +144,7 @@ export const sessionsApi = { restore: (session: SessionLookup, machineId = "local") => request(sessionUrl(session, "restore", machineId), parseRestored, { method: "POST", body: sessionBody(session) }), deleteArchived: (session: SessionLookup, machineId = "local") => request(sessionBaseQueryUrl(session, machineId), parseDeleted, { method: "DELETE" }), detachParent: (session: SessionLookup, machineId = "local") => request(sessionUrl(session, "detach-parent", machineId), parseDetached, { method: "POST", body: sessionBody(session) }), + reloadSession: (session: SessionLookup, machineId = "local") => request(sessionUrl(session, "reload", machineId), parseReloaded, { method: "POST", body: sessionBody(session) }), authProviders: (options?: { mode?: "login" | "logout"; authType?: "oauth" | "api_key"; machineId?: string }) => { const params = new URLSearchParams(); if (options?.mode !== undefined) params.set("mode", options.mode); diff --git a/src/client/src/api/parsers.ts b/src/client/src/api/parsers.ts index a9edf01..23250cd 100644 --- a/src/client/src/api/parsers.ts +++ b/src/client/src/api/parsers.ts @@ -717,6 +717,12 @@ export function parseDetached(value: unknown): { detached: true } { return { detached: true }; } +export function parseReloaded(value: unknown): { reloaded: true } { + const record = requireRecord(value); + if (record["reloaded"] !== true) throw new Error("Expected reloaded response"); + return { reloaded: true }; +} + function optionalNumber(record: Record, key: string): number | undefined { const value = record[key]; if (value === undefined) return undefined; diff --git a/src/client/src/chatHistoryCache.ts b/src/client/src/chatHistoryCache.ts index b32cb1e..5e56011 100644 --- a/src/client/src/chatHistoryCache.ts +++ b/src/client/src/chatHistoryCache.ts @@ -35,6 +35,14 @@ export function writeChatHistoryCache(sessionId: string, page: RawMessagePage): } } +export function removeChatHistoryCache(sessionId: string): void { + try { + sessionStorage.removeItem(cacheKey(sessionId)); + } catch { + // Ignore storage access errors; cache may simply be unavailable. + } +} + export function mergeChatHistory(existing: RawMessagePage | undefined, incoming: RawMessagePage): RawMessagePage { if (existing === undefined || !isValidMessagePage(existing)) return incoming; if (!isValidMessagePage(incoming)) return existing; diff --git a/src/client/src/chatTranscriptStore.ts b/src/client/src/chatTranscriptStore.ts index cd8eb95..097b46b 100644 --- a/src/client/src/chatTranscriptStore.ts +++ b/src/client/src/chatTranscriptStore.ts @@ -1,6 +1,6 @@ import { normalizeMessages } from "./chatMessages"; import { applyTranscriptEvent } from "./chatTranscript"; -import { mergeChatHistory, readChatHistoryCache, writeChatHistoryCache, type RawMessagePage } from "./chatHistoryCache"; +import { mergeChatHistory, readChatHistoryCache, removeChatHistoryCache, writeChatHistoryCache, type RawMessagePage } from "./chatHistoryCache"; import type { ChatLine } from "./components/shared"; import type { SessionUiEvent } from "./sessionSocket"; @@ -16,11 +16,13 @@ export interface ChatTranscriptView { export interface ChatHistoryCacheAdapter { read(sessionId: string): RawMessagePage | undefined; write(sessionId: string, page: RawMessagePage): void; + remove?(sessionId: string): void; } const browserChatHistoryCache: ChatHistoryCacheAdapter = { read: readChatHistoryCache, write: writeChatHistoryCache, + remove: removeChatHistoryCache, }; export class ChatTranscriptStore { @@ -43,6 +45,11 @@ export class ChatTranscriptStore { return applyTranscriptEvent(messages, event); } + discard(sessionId: string): void { + this.rawHistoryPages.delete(sessionId); + this.cache.remove?.(sessionId); + } + rawHistoryPage(sessionId: string): RawMessagePage | undefined { const cached = this.rawHistoryPages.get(sessionId) ?? this.cache.read(sessionId); if (cached !== undefined) this.rawHistoryPages.set(sessionId, cached); diff --git a/src/client/src/components/PiWebApp.ts b/src/client/src/components/PiWebApp.ts index b58b1c2..2cb62a8 100644 --- a/src/client/src/components/PiWebApp.ts +++ b/src/client/src/components/PiWebApp.ts @@ -1060,6 +1060,7 @@ export class PiWebApp extends LitElement { .onDeleteArchivedSession=${(session: SessionInfo) => this.sessions.deleteArchivedSessions([session])} .onDeleteArchivedSessions=${(sessions: SessionInfo[]) => this.sessions.deleteArchivedSessions(sessions)} .onDetachParentSession=${(session: SessionInfo) => this.sessions.detachParent(session)} + .onReloadSession=${(session: SessionInfo) => this.sessions.reloadSession(session)} .onFocusNavigationTarget=${(target: NavigationFocusTarget) => { void this.focusNavigationTarget(target); }} .onCancelKeyboardNavigation=${() => { void this.focusChatComposer(); }} > diff --git a/src/client/src/components/SessionList.ts b/src/client/src/components/SessionList.ts index 17fd55d..c0e5785 100644 --- a/src/client/src/components/SessionList.ts +++ b/src/client/src/components/SessionList.ts @@ -49,6 +49,7 @@ export class SessionList extends LitElement implements KeyboardNavigableSection @property({ attribute: false }) onDeleteArchived?: (session: SessionInfo) => void | Promise; @property({ attribute: false }) onDeleteArchivedMany?: (sessions: SessionInfo[]) => void | Promise; @property({ attribute: false }) onDetachParent?: (session: SessionInfo) => void; + @property({ attribute: false }) onReload?: (session: SessionInfo) => void; @state() private openMenuSessionId: string | undefined; @state() private menuStyle = ""; @@ -226,6 +227,7 @@ export class SessionList extends LitElement implements KeyboardNavigableSection ` : html` + ${session.parentSessionPath !== undefined ? html`` : null} ${descendantCount > 0 ? html`` : null} diff --git a/src/client/src/components/appShell/AppNavigationPanel.ts b/src/client/src/components/appShell/AppNavigationPanel.ts index 3bed5f9..242987d 100644 --- a/src/client/src/components/appShell/AppNavigationPanel.ts +++ b/src/client/src/components/appShell/AppNavigationPanel.ts @@ -61,6 +61,7 @@ export class AppNavigationPanel extends LitElement { @property({ attribute: false }) onDeleteArchivedSession?: (session: SessionInfo) => void | Promise; @property({ attribute: false }) onDeleteArchivedSessions?: (sessions: SessionInfo[]) => void | Promise; @property({ attribute: false }) onDetachParentSession?: (session: SessionInfo) => void | Promise; + @property({ attribute: false }) onReloadSession?: (session: SessionInfo) => void | Promise; @property({ attribute: false }) onArchivedCollapsed?: () => void | Promise; @property({ attribute: false }) onSelectMachine?: (machine: Machine) => void | Promise; @property({ attribute: false }) onRemoveMachine?: (machine: Machine) => void | Promise; @@ -171,6 +172,7 @@ export class AppNavigationPanel extends LitElement { .onDeleteArchived=${(session: SessionInfo) => this.onDeleteArchivedSession?.(session)} .onDeleteArchivedMany=${(sessions: SessionInfo[]) => this.onDeleteArchivedSessions?.(sessions)} .onDetachParent=${(session: SessionInfo) => this.onDetachParentSession?.(session)} + .onReload=${(session: SessionInfo) => this.onReloadSession?.(session)} .onFocusPreviousSection=${() => { this.focusPreviousFrom("sessions"); }} .onFocusNextSection=${() => { this.focusNextFrom("sessions"); }} .onCancelKeyboardNavigation=${() => { this.cancelKeyboardNavigation(); }} diff --git a/src/client/src/controllers/sessionController.ts b/src/client/src/controllers/sessionController.ts index 58c5403..ae06bfd 100644 --- a/src/client/src/controllers/sessionController.ts +++ b/src/client/src/controllers/sessionController.ts @@ -379,6 +379,19 @@ export class SessionController { } } + async reloadSession(session = this.getState().selectedSession) { + if (session === undefined) return; + try { + await this.api.reloadSession(session.id, selectedMachineId(this.getState())); + this.transcripts.discard(this.sessionCacheKey(session.id)); + if (this.getState().selectedSession?.id === session.id) { + await this.selectSession(session, { updateUrl: false }); + } + } catch (error) { + this.setState({ error: String(error) }); + } + } + async detachParent(session = this.getState().selectedSession) { if (session?.parentSessionPath === undefined) return; try { diff --git a/src/server/sessions/piSessionService.ts b/src/server/sessions/piSessionService.ts index 2aaa249..75f271c 100644 --- a/src/server/sessions/piSessionService.ts +++ b/src/server/sessions/piSessionService.ts @@ -560,6 +560,13 @@ export class PiSessionService { await this.archiveStore.deleteArchived(record.sessionId); } + async reload(ref: PiSessionLookup): Promise { + const active = await this.getActive(ref); + await this.closeActive(active.runtime.session.sessionId); + const reopened = await this.getActive(ref); + this.publishStatus(reopened.runtime.session); + } + async detachParent(ref: PiSessionLookup): Promise { const session = await this.getOrOpen(ref); const sessionFile = session.sessionFile; diff --git a/src/server/sessions/sessionRoutes.ts b/src/server/sessions/sessionRoutes.ts index 27d1550..22078c8 100644 --- a/src/server/sessions/sessionRoutes.ts +++ b/src/server/sessions/sessionRoutes.ts @@ -230,6 +230,15 @@ export function registerSessionRoutes(app: FastifyInstance, sessions: PiSessionS } }); + app.post<{ Params: { sessionId: string }; Body: { cwd?: unknown } | undefined }>(`${prefix}/sessions/:sessionId/reload`, async (request, reply) => { + try { + await sessions.reload(sessionLookupFromBody(request.params.sessionId, optionalRecord(request.body))); + return { reloaded: true }; + } catch (error) { + return reply.code(mutationErrorStatus(error)).send({ error: errorMessage(error) }); + } + }); + app.post<{ Params: { sessionId: string }; Body: { cwd?: unknown } | undefined }>(`${prefix}/sessions/:sessionId/detach-parent`, async (request, reply) => { try { await sessions.detachParent(sessionLookupFromBody(request.params.sessionId, optionalRecord(request.body))); From 82db15f894adf87c337f583338c9aa1eb4ddfb37 Mon Sep 17 00:00:00 2001 From: Federico Jaramillo Martinez Date: Sun, 14 Jun 2026 14:17:51 +0200 Subject: [PATCH 2/3] fix(sessions): guard, gate, and test session reload Build on the original Reload action with the fixes raised in review: - Server reload() now refuses to run on archived (read-only) sessions and when the session has work in progress, mirroring archive(), so a reload can no longer silently abort an in-flight agent run. - Add a sessions.reload runtime capability; the client gates both the reloadSession call and the Reload menu entry on it so the action only appears for machines whose Pi-Web runtime supports it. - reloadSession ignores cached-new and archived sessions. - Add server (PiSessionService + routes) and client (SessionController) tests covering reload success, the active-work guard, archived rejection, route forwarding, capability gating, and error mapping. - Restore alphabetical parser import ordering in clients.ts. - Add a changeset documenting the feature and the sessiond restart note. Note: touches a session daemon code path, so pi-web-sessiond.service must be restarted manually for the server side to take effect. --- .changeset/session-reload-from-disk.md | 9 +++ src/client/src/api/clients.ts | 2 +- src/client/src/components/PiWebApp.ts | 6 ++ src/client/src/components/SessionList.ts | 3 +- .../components/appShell/AppNavigationPanel.ts | 2 + .../src/controllers/sessionController.test.ts | 67 ++++++++++++++++++ .../src/controllers/sessionController.ts | 10 ++- src/server/sessions/piSessionService.test.ts | 68 +++++++++++++++++++ src/server/sessions/piSessionService.ts | 6 +- src/server/sessions/sessionRoutes.test.ts | 47 +++++++++++++ src/shared/apiTypes.ts | 1 + src/shared/capabilities.ts | 5 +- 12 files changed, 218 insertions(+), 8 deletions(-) create mode 100644 .changeset/session-reload-from-disk.md diff --git a/.changeset/session-reload-from-disk.md b/.changeset/session-reload-from-disk.md new file mode 100644 index 0000000..7f86181 --- /dev/null +++ b/.changeset/session-reload-from-disk.md @@ -0,0 +1,9 @@ +--- +"@jmfederico/pi-web": minor +--- + +Add a **Reload** action to the session three-dot menu that re-reads the session from disk. The session daemon keeps an in-memory `SessionManager` per session and never re-reads the session file, so when the same session is also driven by another process (for example the `pi` CLI), new on-disk entries were invisible to the web UI and the tail of the conversation appeared truncated. Reloading closes the active session, re-opens it from disk, discards the cached transcript, and re-fetches the history. + +Reload refuses to run while the session has work in progress and on archived (read-only) sessions, and is gated behind a new `sessions.reload` runtime capability so it only appears for machines whose Pi-Web runtime supports it. + +Note: this changes a session daemon code path, so `pi-web-sessiond.service` must be restarted manually for the server side of this change to take effect. diff --git a/src/client/src/api/clients.ts b/src/client/src/api/clients.ts index 389f61e..628cdd5 100644 --- a/src/client/src/api/clients.ts +++ b/src/client/src/api/clients.ts @@ -10,7 +10,6 @@ import { parseCommandResult, parseDeleted, parseDetached, - parseReloaded, parseFileContentResponse, parseFileSuggestion, parseFileTreeResponse, @@ -28,6 +27,7 @@ import { parsePiWebRuntimeResponse, parsePiWebStatusResponse, parseProject, + parseReloaded, parseRestored, parseSavedAttachments, parseSessionInfo, diff --git a/src/client/src/components/PiWebApp.ts b/src/client/src/components/PiWebApp.ts index 2cb62a8..db9132e 100644 --- a/src/client/src/components/PiWebApp.ts +++ b/src/client/src/components/PiWebApp.ts @@ -999,6 +999,11 @@ export class PiWebApp extends LitElement { return runtime?.ok === true && supportsPiWebCapability(runtime, PI_WEB_CAPABILITIES.sessionsDeleteArchived); } + private canReloadSessions(): boolean { + const runtime = this.selectedMachineRuntime(); + return runtime?.ok === true && supportsPiWebCapability(runtime, PI_WEB_CAPABILITIES.sessionsReload); + } + private archivedDeleteUnavailableMessage(): string { const machineName = this.state.selectedMachine?.name ?? "this machine"; return `Update and restart Pi-Web on ${machineName} to delete archived sessions.`; @@ -1033,6 +1038,7 @@ export class PiWebApp extends LitElement { .selectedSession=${this.state.selectedSession} .canStartSession=${!!this.state.selectedWorkspace} .canDeleteArchivedSessions=${this.canDeleteArchivedSessions()} + .canReloadSessions=${this.canReloadSessions()} .archivedDeleteUnavailableMessage=${this.archivedDeleteUnavailableMessage()} .collapsible=${true} .compact=${this.appShell.isMobileNavigationLayout} diff --git a/src/client/src/components/SessionList.ts b/src/client/src/components/SessionList.ts index c0e5785..bc78b67 100644 --- a/src/client/src/components/SessionList.ts +++ b/src/client/src/components/SessionList.ts @@ -31,6 +31,7 @@ export class SessionList extends LitElement implements KeyboardNavigableSection @property({ attribute: false }) selected?: SessionInfo; @property({ type: Boolean }) canStart = false; @property({ type: Boolean }) canDeleteArchived = false; + @property({ type: Boolean }) canReload = false; @property({ type: String }) archivedDeleteUnavailableMessage = "Update and restart Pi-Web on this machine to delete archived sessions."; @property({ type: Boolean, reflect: true }) collapsible = false; @property({ type: Boolean, reflect: true }) collapsed = false; @@ -227,7 +228,7 @@ export class SessionList extends LitElement implements KeyboardNavigableSection ` : html` - + ${this.canReload ? html`` : null} ${session.parentSessionPath !== undefined ? html`` : null} ${descendantCount > 0 ? html`` : null} diff --git a/src/client/src/components/appShell/AppNavigationPanel.ts b/src/client/src/components/appShell/AppNavigationPanel.ts index 242987d..4b8bb77 100644 --- a/src/client/src/components/appShell/AppNavigationPanel.ts +++ b/src/client/src/components/appShell/AppNavigationPanel.ts @@ -41,6 +41,7 @@ export class AppNavigationPanel extends LitElement { @property({ type: Boolean }) sessionsCollapsed = false; @property({ type: Boolean }) canStartSession = false; @property({ type: Boolean }) canDeleteArchivedSessions = false; + @property({ type: Boolean }) canReloadSessions = false; @property({ type: String }) archivedDeleteUnavailableMessage = "Update and restart Pi-Web on this machine to delete archived sessions."; @property({ attribute: false }) onShowActions?: () => void; @property({ attribute: false }) onToggleMachines?: () => void; @@ -157,6 +158,7 @@ export class AppNavigationPanel extends LitElement { .selected=${this.selectedSession} .canStart=${this.canStartSession} .canDeleteArchived=${this.canDeleteArchivedSessions} + .canReload=${this.canReloadSessions} .archivedDeleteUnavailableMessage=${this.archivedDeleteUnavailableMessage} .collapsible=${this.collapsible} .collapsed=${this.sessionsCollapsed} diff --git a/src/client/src/controllers/sessionController.test.ts b/src/client/src/controllers/sessionController.test.ts index 52c7224..93a6bf0 100644 --- a/src/client/src/controllers/sessionController.test.ts +++ b/src/client/src/controllers/sessionController.test.ts @@ -487,6 +487,73 @@ describe("SessionController", () => { expect(state.error).toContain("requires an updated Pi-Web runtime"); }); + it("reloads the selected session, discards the cached transcript, and re-fetches history", async () => { + Object.defineProperty(globalThis, "localStorage", { value: new MemoryStorage(), configurable: true }); + const reloadCalls: string[] = []; + const messageCalls: string[] = []; + let state: AppState = { + ...initialAppState(), + selectedWorkspace: workspace, + selectedSession: oldSession, + sessions: [oldSession], + machineRuntimes: { local: { machineId: "local", ok: true, checkedAt: "now", capabilities: [PI_WEB_CAPABILITIES.sessionsReload] } }, + }; + const api: typeof defaultApi = { + ...defaultApi, + reloadSession: (session) => { + reloadCalls.push(sessionLookupId(session)); + return Promise.resolve({ reloaded: true }); + }, + messages: (session) => { + messageCalls.push(sessionLookupId(session)); + return Promise.resolve(emptyPage); + }, + status: (session) => Promise.resolve(status(sessionLookupId(session))), + }; + const controller = new SessionController( + () => state, + (patch) => { state = { ...state, ...patch }; }, + () => undefined, + new InMemorySessionSelectionMemory(), + { api, socket: new FakeSocket() }, + ); + + await controller.reloadSession(oldSession); + + expect(reloadCalls).toEqual([oldSession.id]); + expect(messageCalls).toContain(oldSession.id); + expect(state.error).toBe(""); + }); + + it("does not reload sessions when the selected machine runtime does not support it", async () => { + const reloadCalls: string[] = []; + let state: AppState = { + ...initialAppState(), + selectedWorkspace: workspace, + selectedSession: oldSession, + sessions: [oldSession], + }; + const api: typeof defaultApi = { + ...defaultApi, + reloadSession: (session) => { + reloadCalls.push(sessionLookupId(session)); + return Promise.resolve({ reloaded: true }); + }, + }; + const controller = new SessionController( + () => state, + (patch) => { state = { ...state, ...patch }; }, + () => undefined, + new InMemorySessionSelectionMemory(), + { api, socket: new FakeSocket() }, + ); + + await controller.reloadSession(oldSession); + + expect(reloadCalls).toEqual([]); + expect(state.error).toContain("requires an updated Pi-Web runtime"); + }); + 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] }; diff --git a/src/client/src/controllers/sessionController.ts b/src/client/src/controllers/sessionController.ts index ae06bfd..74ddb30 100644 --- a/src/client/src/controllers/sessionController.ts +++ b/src/client/src/controllers/sessionController.ts @@ -380,9 +380,15 @@ export class SessionController { } async reloadSession(session = this.getState().selectedSession) { - if (session === undefined) return; + if (session === undefined || isCachedNewSessionInfo(session) || session.archived === true) return; + const machineId = selectedMachineId(this.getState()); + const runtime = this.getState().machineRuntimes[machineId]; + if (runtime?.ok !== true || !supportsPiWebCapability(runtime, PI_WEB_CAPABILITIES.sessionsReload)) { + this.setState({ error: "Reloading sessions requires an updated Pi-Web runtime on this machine." }); + return; + } try { - await this.api.reloadSession(session.id, selectedMachineId(this.getState())); + await this.api.reloadSession(session.id, machineId); this.transcripts.discard(this.sessionCacheKey(session.id)); if (this.getState().selectedSession?.id === session.id) { await this.selectSession(session, { updateUrl: false }); diff --git a/src/server/sessions/piSessionService.test.ts b/src/server/sessions/piSessionService.test.ts index a73c808..ed444a0 100644 --- a/src/server/sessions/piSessionService.test.ts +++ b/src/server/sessions/piSessionService.test.ts @@ -425,6 +425,74 @@ describe("PiSessionService", () => { await service.dispose(); }); + it("reloads a session by closing the active runtime and re-opening it from disk", async () => { + const first = fakeRuntime("reload-session"); + const second = fakeRuntime("reload-session"); + const runtimes = [first.runtime, second.runtime]; + let createCalls = 0; + const createAgentRuntime: RuntimeCreator = async () => { + await Promise.resolve(); + const runtime = runtimes[createCalls]; + createCalls += 1; + if (runtime === undefined) throw new Error("unexpected runtime creation"); + return runtime; + }; + const service = new PiSessionService(new CapturingSessionEventHub(), { + createAgentRuntime, + sessionManager: sessionGateway([sessionRecord("reload-session")]), + heartbeatIntervalMs: 60_000, + }); + + // Open once so there is an active runtime to reload. + await service.status(sessionRef("reload-session")); + expect(createCalls).toBe(1); + + await expect(service.reload(sessionRef("reload-session"))).resolves.toBeUndefined(); + + // The original runtime was torn down and a fresh one opened from disk. + expect(first.calls.abort).toBe(1); + expect(first.calls.dispose).toBe(1); + expect(createCalls).toBe(2); + expect(service.activeCount()).toBe(1); + + await service.dispose(); + }); + + it("refuses to reload a session that has active work in progress", async () => { + const fake = fakeRuntime("busy-session", { isStreaming: true }); + const service = new PiSessionService(new CapturingSessionEventHub(), { + createAgentRuntime: runtimeCreator(fake.runtime), + sessionManager: sessionGateway([sessionRecord("busy-session")]), + heartbeatIntervalMs: 60_000, + }); + + await expect(service.reload(sessionRef("busy-session"))).rejects.toThrow("Stop current session activity before reloading"); + expect(fake.calls.abort).toBe(0); + expect(fake.calls.dispose).toBe(0); + + await service.dispose(); + }); + + it("refuses to reload an archived session", async () => { + const service = new PiSessionService(new CapturingSessionEventHub(), { + archiveStore: { + list: () => Promise.resolve([]), + get: (sessionId) => Promise.resolve(sessionId === "archived" || "archived".startsWith(sessionId) + ? { sessionId: "archived", cwd: "/workspace", archivedAt: "2026-01-02T00:00:00.000Z", archivePath: "/archive/archived.jsonl" } + : undefined), + archive: () => Promise.resolve({ sessionId: "archived", cwd: "/workspace", archivedAt: "2026-01-02T00:00:00.000Z" }), + restore: () => Promise.resolve(), + isArchived: () => Promise.resolve(true), + }, + sessionManager: sessionGateway([]), + heartbeatIntervalMs: 60_000, + }); + + await expect(service.reload(sessionRef("archived"))).rejects.toThrow("Archived sessions are read-only"); + + await service.dispose(); + }); + it("reconciles workspace activity when listing only archived sessions", async () => { const reconciliations: { cwd: string; sessionIds: string[] }[] = []; const service = new PiSessionService(new CapturingSessionEventHub(), { diff --git a/src/server/sessions/piSessionService.ts b/src/server/sessions/piSessionService.ts index 75f271c..2049988 100644 --- a/src/server/sessions/piSessionService.ts +++ b/src/server/sessions/piSessionService.ts @@ -561,8 +561,10 @@ export class PiSessionService { } async reload(ref: PiSessionLookup): Promise { - const active = await this.getActive(ref); - await this.closeActive(active.runtime.session.sessionId); + await this.assertWritable(ref); + const session = await this.getOrOpen(ref); + if (this.hasActiveWork(session)) throw new Error("Stop current session activity before reloading"); + await this.closeActive(session.sessionId); const reopened = await this.getActive(ref); this.publishStatus(reopened.runtime.session); } diff --git a/src/server/sessions/sessionRoutes.test.ts b/src/server/sessions/sessionRoutes.test.ts index eda1831..7cc4ca7 100644 --- a/src/server/sessions/sessionRoutes.test.ts +++ b/src/server/sessions/sessionRoutes.test.ts @@ -97,15 +97,62 @@ describe("session routes", () => { await routeApp.close(); } }); + + it("reloads a session through the reload route, forwarding workspace context", async () => { + const routeApp = Fastify({ logger: false }); + await routeApp.register(fastifyWebsocket); + const eventHub = new SessionEventHub(); + const routeService = new CapturingRouteSessionService(eventHub); + registerSessionRoutes(routeApp, routeService, eventHub); + + try { + const requestCwd = resolve("/repo"); + const reloadResponse = await routeApp.inject({ method: "POST", url: "/sessions/session-1/reload", payload: { cwd: requestCwd } }); + + expect(reloadResponse.statusCode).toBe(200); + expect(reloadResponse.json()).toEqual({ reloaded: true }); + expect(routeService.reloadCalls).toEqual([{ id: "session-1", cwd: requestCwd }]); + } finally { + await routeService.dispose(); + await routeApp.close(); + } + }); + + it("maps reload failures to a mutation error status", async () => { + const routeApp = Fastify({ logger: false }); + await routeApp.register(fastifyWebsocket); + const eventHub = new SessionEventHub(); + const routeService = new CapturingRouteSessionService(eventHub); + routeService.reloadError = new Error("Stop current session activity before reloading"); + registerSessionRoutes(routeApp, routeService, eventHub); + + try { + const reloadResponse = await routeApp.inject({ method: "POST", url: "/sessions/session-1/reload", payload: {} }); + + expect(reloadResponse.statusCode).toBe(400); + expect(reloadResponse.json()).toEqual({ error: "Stop current session activity before reloading" }); + } finally { + await routeService.dispose(); + await routeApp.close(); + } + }); }); class CapturingRouteSessionService extends PiSessionService { readonly calls: unknown[] = []; + readonly reloadCalls: (string | PiSessionRef)[] = []; + reloadError: Error | undefined; constructor(eventHub: SessionEventHub) { super(eventHub, { sessionManager: new RejectingSessionManager(), heartbeatIntervalMs: 60_000 }); } + override reload(lookup: string | PiSessionRef): Promise { + this.reloadCalls.push(lookup); + if (this.reloadError !== undefined) return Promise.reject(this.reloadError); + return Promise.resolve(); + } + override status(lookup: string | PiSessionRef) { this.calls.push(lookup); return Promise.resolve({ diff --git a/src/shared/apiTypes.ts b/src/shared/apiTypes.ts index 3b9421c..6ce5db7 100644 --- a/src/shared/apiTypes.ts +++ b/src/shared/apiTypes.ts @@ -3,6 +3,7 @@ export type MachineStatus = "unknown" | "online" | "offline" | "error"; export const PI_WEB_CAPABILITIES = { sessionsDeleteArchived: "sessions.deleteArchived", + sessionsReload: "sessions.reload", promptAttachments: "prompt.attachments", } as const; diff --git a/src/shared/capabilities.ts b/src/shared/capabilities.ts index 4795ed3..8c5a255 100644 --- a/src/shared/capabilities.ts +++ b/src/shared/capabilities.ts @@ -6,11 +6,12 @@ export type { PiWebCapability }; export const KNOWN_PI_WEB_CAPABILITIES = Object.values(PI_WEB_CAPABILITIES); const knownPiWebCapabilities: ReadonlySet = new Set(KNOWN_PI_WEB_CAPABILITIES); -export const WEB_RUNTIME_CAPABILITIES = [PI_WEB_CAPABILITIES.sessionsDeleteArchived, PI_WEB_CAPABILITIES.promptAttachments] as const satisfies readonly PiWebCapability[]; -export const SESSIOND_RUNTIME_CAPABILITIES = [PI_WEB_CAPABILITIES.sessionsDeleteArchived, PI_WEB_CAPABILITIES.promptAttachments] as const satisfies readonly PiWebCapability[]; +export const WEB_RUNTIME_CAPABILITIES = [PI_WEB_CAPABILITIES.sessionsDeleteArchived, PI_WEB_CAPABILITIES.sessionsReload, PI_WEB_CAPABILITIES.promptAttachments] as const satisfies readonly PiWebCapability[]; +export const SESSIOND_RUNTIME_CAPABILITIES = [PI_WEB_CAPABILITIES.sessionsDeleteArchived, PI_WEB_CAPABILITIES.sessionsReload, PI_WEB_CAPABILITIES.promptAttachments] as const satisfies readonly PiWebCapability[]; const EFFECTIVE_CAPABILITY_REQUIREMENTS = { [PI_WEB_CAPABILITIES.sessionsDeleteArchived]: ["web", "sessiond"], + [PI_WEB_CAPABILITIES.sessionsReload]: ["web", "sessiond"], [PI_WEB_CAPABILITIES.promptAttachments]: ["web", "sessiond"], } as const satisfies Record; From 9159da935376c6f8dca17f6bd7a3bd318590e787 Mon Sep 17 00:00:00 2001 From: Federico Jaramillo Martinez Date: Sun, 14 Jun 2026 14:49:21 +0200 Subject: [PATCH 3/3] feat(sessions): expose reload as a command-palette action and disable when busy Add a "Reload Session" core action so reload is keyboard-accessible and can be assigned a custom shortcut, gated by the same guards as the menu item (writable session, sessions.reload capability, not currently busy). Disable the Reload menu entry while the session has active work, mirroring the server guard and the archived-delete control, so users get a clear reason instead of an error toast. Co-authored-by: Claude --- .changeset/session-reload-from-disk.md | 2 +- src/client/src/components/PiWebApp.ts | 1 + src/client/src/components/SessionList.ts | 2 +- src/client/src/plugins/core/actions.ts | 18 +++++++++ src/client/src/plugins/registry.test.ts | 47 +++++++++++++++++++++++- src/client/src/plugins/types.ts | 1 + 6 files changed, 68 insertions(+), 3 deletions(-) diff --git a/.changeset/session-reload-from-disk.md b/.changeset/session-reload-from-disk.md index 7f86181..e0e57ac 100644 --- a/.changeset/session-reload-from-disk.md +++ b/.changeset/session-reload-from-disk.md @@ -4,6 +4,6 @@ Add a **Reload** action to the session three-dot menu that re-reads the session from disk. The session daemon keeps an in-memory `SessionManager` per session and never re-reads the session file, so when the same session is also driven by another process (for example the `pi` CLI), new on-disk entries were invisible to the web UI and the tail of the conversation appeared truncated. Reloading closes the active session, re-opens it from disk, discards the cached transcript, and re-fetches the history. -Reload refuses to run while the session has work in progress and on archived (read-only) sessions, and is gated behind a new `sessions.reload` runtime capability so it only appears for machines whose Pi-Web runtime supports it. +Reload is also available from the command palette as **Reload Session**, so it can be triggered from the keyboard and assigned a custom shortcut. Reload refuses to run while the session has work in progress and on archived (read-only) sessions, and is gated behind a new `sessions.reload` runtime capability so it only appears for machines whose Pi-Web runtime supports it (both the menu item and the palette action are disabled otherwise). Note: this changes a session daemon code path, so `pi-web-sessiond.service` must be restarted manually for the server side of this change to take effect. diff --git a/src/client/src/components/PiWebApp.ts b/src/client/src/components/PiWebApp.ts index db9132e..43dfa04 100644 --- a/src/client/src/components/PiWebApp.ts +++ b/src/client/src/components/PiWebApp.ts @@ -1398,6 +1398,7 @@ export class PiWebApp extends LitElement { deleteWorkspace: (workspace) => this.deleteWorkspace(workspace), startSession: () => this.withChatScrollTransition(() => this.sessions.startSession()), archiveSession: () => this.sessions.archiveSession(), + reloadSession: () => this.sessions.reloadSession(), deleteCachedNewSession: () => this.sessions.deleteCachedNewSession(), stopActiveWork: () => this.sessions.stopActiveWork(), }, createContext); diff --git a/src/client/src/components/SessionList.ts b/src/client/src/components/SessionList.ts index bc78b67..d9229ac 100644 --- a/src/client/src/components/SessionList.ts +++ b/src/client/src/components/SessionList.ts @@ -228,7 +228,7 @@ export class SessionList extends LitElement implements KeyboardNavigableSection ` : html` - ${this.canReload ? html`` : null} + ${this.canReload ? html`` : null} ${session.parentSessionPath !== undefined ? html`` : null} ${descendantCount > 0 ? html`` : null} diff --git a/src/client/src/plugins/core/actions.ts b/src/client/src/plugins/core/actions.ts index e46b84f..90f0337 100644 --- a/src/client/src/plugins/core/actions.ts +++ b/src/client/src/plugins/core/actions.ts @@ -1,6 +1,8 @@ import { isSessionActive } from "../../../../shared/activity"; +import { PI_WEB_CAPABILITIES, supportsPiWebCapability } from "../../../../shared/capabilities"; import type { AppState } from "../../appState"; import { isCachedNewSessionInfo } from "../../cachedNewSessions"; +import { selectedMachineId } from "../../controllers/types"; import { isWorkspaceDeletionPending } from "../../workspaceDeletion"; import type { PluginAction } from "../types"; @@ -173,6 +175,14 @@ export function createCoreActions(): PluginAction[] { enabled: hasArchivableSession, run: (context) => context.archiveSession(), }, + { + id: "session.reload", + title: "Reload Session", + description: "Re-read the selected session from disk to pick up entries written by another process", + group: "Session", + enabled: hasReloadableSession, + run: (context) => context.reloadSession(), + }, { id: "session.delete", title: "Delete New Session", @@ -213,3 +223,11 @@ function hasArchivableSession(context: { state: AppState }): boolean { function hasCachedNewSession(context: { state: AppState }): boolean { return isCachedNewSessionInfo(context.state.selectedSession); } + +function hasReloadableSession(context: { state: AppState }): boolean { + const session = context.state.selectedSession; + if (session === undefined || session.archived === true || isCachedNewSessionInfo(session)) return false; + const runtime = context.state.machineRuntimes[selectedMachineId(context.state)]; + if (runtime?.ok !== true || !supportsPiWebCapability(runtime, PI_WEB_CAPABILITIES.sessionsReload)) return false; + return !isSessionActive(context.state.status, context.state.activity); +} diff --git a/src/client/src/plugins/registry.test.ts b/src/client/src/plugins/registry.test.ts index 3853f5a..7be8258 100644 --- a/src/client/src/plugins/registry.test.ts +++ b/src/client/src/plugins/registry.test.ts @@ -1,8 +1,9 @@ import { html } from "lit"; import { describe, expect, it, vi } from "vitest"; -import type { FileContentResponse, SessionInfo, Workspace } from "../api"; +import type { FileContentResponse, SessionInfo, SessionStatus, Workspace } from "../api"; import { initialAppState, type AppState } from "../appState"; import { markCachedNewSessionInfo } from "../cachedNewSessions"; +import { PI_WEB_CAPABILITIES } from "../../../shared/capabilities"; import { machineScopedPluginId } from "../../../shared/machinePluginIds"; import { corePlugin } from "./core"; import { PluginRegistry } from "./registry"; @@ -42,6 +43,7 @@ function createContext(statePatch: Partial = {}) { deleteWorkspace: vi.fn(() => { calls.push("deleteWorkspace"); }), startSession: vi.fn(() => { calls.push("startSession"); }), archiveSession: vi.fn(() => { calls.push("archiveSession"); }), + reloadSession: vi.fn(() => { calls.push("reloadSession"); }), deleteCachedNewSession: vi.fn(() => { calls.push("deleteCachedNewSession"); }), stopActiveWork: vi.fn(() => { calls.push("stopActiveWork"); }), }; @@ -149,6 +151,35 @@ describe("PluginRegistry", () => { expect(archivedActions.find((action) => action.id === "core:session.delete")?.enabled).toBe(false); }); + it("enables session reload only for a writable session on a capable, idle runtime", () => { + const registry = new PluginRegistry(); + registry.register({ id: "core", plugin: corePlugin }); + const reloadRuntime = { local: { machineId: "local", ok: true as const, checkedAt: "now", capabilities: [PI_WEB_CAPABILITIES.sessionsReload] } }; + + const reloadable = registry.getActions(createContext({ selectedSession: testSession(), machineRuntimes: reloadRuntime }).context); + expect(reloadable.find((action) => action.id === "core:session.reload")?.enabled).toBe(true); + + const noCapability = registry.getActions(createContext({ selectedSession: testSession() }).context); + expect(noCapability.find((action) => action.id === "core:session.reload")?.enabled).toBe(false); + + const archived = registry.getActions(createContext({ selectedSession: { ...testSession(), archived: true, archivedAt: "2026-05-20T00:00:00.000Z" }, machineRuntimes: reloadRuntime }).context); + expect(archived.find((action) => action.id === "core:session.reload")?.enabled).toBe(false); + + const busy = registry.getActions(createContext({ selectedSession: testSession(), machineRuntimes: reloadRuntime, status: testStatus({ isStreaming: true }) }).context); + expect(busy.find((action) => action.id === "core:session.reload")?.enabled).toBe(false); + }); + + it("routes session reload through the runtime context", () => { + const registry = new PluginRegistry(); + registry.register({ id: "core", plugin: corePlugin }); + const { context, calls } = createContext({ selectedSession: testSession(), machineRuntimes: { local: { machineId: "local", ok: true, checkedAt: "now", capabilities: [PI_WEB_CAPABILITIES.sessionsReload] } } }); + const action = registry.getActions(context).find((candidate) => candidate.id === "core:session.reload"); + + if (action !== undefined) void action.run(); + + expect(calls).toEqual(["reloadSession"]); + }); + it("routes browser-cached new session delete through the runtime context", () => { const registry = new PluginRegistry(); registry.register({ id: "core", plugin: corePlugin }); @@ -568,6 +599,20 @@ function testFileContent(path = "README.md"): FileContentResponse { }; } +function testStatus(patch: Partial = {}): SessionStatus { + return { + sessionId: "s1", + isStreaming: false, + isCompacting: false, + isBashRunning: false, + pendingMessageCount: 0, + queuedMessages: [], + tokens: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + cost: 0, + ...patch, + }; +} + function testMachine(id: string) { return { id, name: id, kind: id === "local" ? "local" as const : "remote" as const, createdAt: "2026-05-20T00:00:00.000Z", updatedAt: "2026-05-20T00:00:00.000Z" }; } diff --git a/src/client/src/plugins/types.ts b/src/client/src/plugins/types.ts index 4b954d7..2b44063 100644 --- a/src/client/src/plugins/types.ts +++ b/src/client/src/plugins/types.ts @@ -106,6 +106,7 @@ export interface PluginRuntimeContext { deleteWorkspace: (workspace?: Workspace) => void | Promise; startSession: () => void | Promise; archiveSession: () => void | Promise; + reloadSession: () => void | Promise; deleteCachedNewSession: () => void | Promise; stopActiveWork: () => void | Promise; }