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)));