From ea1ec1b595e3b28d46e69c3e5be29f6aca6903e8 Mon Sep 17 00:00:00 2001 From: Slava Iumin Date: Tue, 9 Jun 2026 09:45:54 +0300 Subject: [PATCH] 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)));