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.
This commit is contained in:
Slava Iumin
2026-06-14 14:11:34 +02:00
committed by Federico Jaramillo Martinez
parent ca30c970a7
commit ea1ec1b595
10 changed files with 58 additions and 1 deletions
+2
View File
@@ -10,6 +10,7 @@ import {
parseCommandResult, parseCommandResult,
parseDeleted, parseDeleted,
parseDetached, parseDetached,
parseReloaded,
parseFileContentResponse, parseFileContentResponse,
parseFileSuggestion, parseFileSuggestion,
parseFileTreeResponse, parseFileTreeResponse,
@@ -143,6 +144,7 @@ export const sessionsApi = {
restore: (session: SessionLookup, machineId = "local") => request(sessionUrl(session, "restore", machineId), parseRestored, { method: "POST", body: sessionBody(session) }), 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" }), 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) }), 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 }) => { authProviders: (options?: { mode?: "login" | "logout"; authType?: "oauth" | "api_key"; machineId?: string }) => {
const params = new URLSearchParams(); const params = new URLSearchParams();
if (options?.mode !== undefined) params.set("mode", options.mode); if (options?.mode !== undefined) params.set("mode", options.mode);
+6
View File
@@ -717,6 +717,12 @@ export function parseDetached(value: unknown): { detached: true } {
return { 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<string, unknown>, key: string): number | undefined { function optionalNumber(record: Record<string, unknown>, key: string): number | undefined {
const value = record[key]; const value = record[key];
if (value === undefined) return undefined; if (value === undefined) return undefined;
+8
View File
@@ -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 { export function mergeChatHistory(existing: RawMessagePage | undefined, incoming: RawMessagePage): RawMessagePage {
if (existing === undefined || !isValidMessagePage(existing)) return incoming; if (existing === undefined || !isValidMessagePage(existing)) return incoming;
if (!isValidMessagePage(incoming)) return existing; if (!isValidMessagePage(incoming)) return existing;
+8 -1
View File
@@ -1,6 +1,6 @@
import { normalizeMessages } from "./chatMessages"; import { normalizeMessages } from "./chatMessages";
import { applyTranscriptEvent } from "./chatTranscript"; 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 { ChatLine } from "./components/shared";
import type { SessionUiEvent } from "./sessionSocket"; import type { SessionUiEvent } from "./sessionSocket";
@@ -16,11 +16,13 @@ export interface ChatTranscriptView {
export interface ChatHistoryCacheAdapter { export interface ChatHistoryCacheAdapter {
read(sessionId: string): RawMessagePage | undefined; read(sessionId: string): RawMessagePage | undefined;
write(sessionId: string, page: RawMessagePage): void; write(sessionId: string, page: RawMessagePage): void;
remove?(sessionId: string): void;
} }
const browserChatHistoryCache: ChatHistoryCacheAdapter = { const browserChatHistoryCache: ChatHistoryCacheAdapter = {
read: readChatHistoryCache, read: readChatHistoryCache,
write: writeChatHistoryCache, write: writeChatHistoryCache,
remove: removeChatHistoryCache,
}; };
export class ChatTranscriptStore { export class ChatTranscriptStore {
@@ -43,6 +45,11 @@ export class ChatTranscriptStore {
return applyTranscriptEvent(messages, event); return applyTranscriptEvent(messages, event);
} }
discard(sessionId: string): void {
this.rawHistoryPages.delete(sessionId);
this.cache.remove?.(sessionId);
}
rawHistoryPage(sessionId: string): RawMessagePage | undefined { rawHistoryPage(sessionId: string): RawMessagePage | undefined {
const cached = this.rawHistoryPages.get(sessionId) ?? this.cache.read(sessionId); const cached = this.rawHistoryPages.get(sessionId) ?? this.cache.read(sessionId);
if (cached !== undefined) this.rawHistoryPages.set(sessionId, cached); if (cached !== undefined) this.rawHistoryPages.set(sessionId, cached);
+1
View File
@@ -1060,6 +1060,7 @@ export class PiWebApp extends LitElement {
.onDeleteArchivedSession=${(session: SessionInfo) => this.sessions.deleteArchivedSessions([session])} .onDeleteArchivedSession=${(session: SessionInfo) => this.sessions.deleteArchivedSessions([session])}
.onDeleteArchivedSessions=${(sessions: SessionInfo[]) => this.sessions.deleteArchivedSessions(sessions)} .onDeleteArchivedSessions=${(sessions: SessionInfo[]) => this.sessions.deleteArchivedSessions(sessions)}
.onDetachParentSession=${(session: SessionInfo) => this.sessions.detachParent(session)} .onDetachParentSession=${(session: SessionInfo) => this.sessions.detachParent(session)}
.onReloadSession=${(session: SessionInfo) => this.sessions.reloadSession(session)}
.onFocusNavigationTarget=${(target: NavigationFocusTarget) => { void this.focusNavigationTarget(target); }} .onFocusNavigationTarget=${(target: NavigationFocusTarget) => { void this.focusNavigationTarget(target); }}
.onCancelKeyboardNavigation=${() => { void this.focusChatComposer(); }} .onCancelKeyboardNavigation=${() => { void this.focusChatComposer(); }}
></app-navigation-panel> ></app-navigation-panel>
+2
View File
@@ -49,6 +49,7 @@ export class SessionList extends LitElement implements KeyboardNavigableSection
@property({ attribute: false }) onDeleteArchived?: (session: SessionInfo) => void | Promise<void>; @property({ attribute: false }) onDeleteArchived?: (session: SessionInfo) => void | Promise<void>;
@property({ attribute: false }) onDeleteArchivedMany?: (sessions: SessionInfo[]) => void | Promise<void>; @property({ attribute: false }) onDeleteArchivedMany?: (sessions: SessionInfo[]) => void | Promise<void>;
@property({ attribute: false }) onDetachParent?: (session: SessionInfo) => void; @property({ attribute: false }) onDetachParent?: (session: SessionInfo) => void;
@property({ attribute: false }) onReload?: (session: SessionInfo) => void;
@state() private openMenuSessionId: string | undefined; @state() private openMenuSessionId: string | undefined;
@state() private menuStyle = ""; @state() private menuStyle = "";
@@ -226,6 +227,7 @@ export class SessionList extends LitElement implements KeyboardNavigableSection
<button class="danger" title=${this.canDeleteArchived ? "Permanently delete archived session" : this.archivedDeleteUnavailableMessage} ?disabled=${!this.canDeleteArchived} @click=${() => { this.openMenuSessionId = undefined; this.confirmDeleteArchived(session); }}>Delete archived session</button> <button class="danger" title=${this.canDeleteArchived ? "Permanently delete archived session" : this.archivedDeleteUnavailableMessage} ?disabled=${!this.canDeleteArchived} @click=${() => { this.openMenuSessionId = undefined; this.confirmDeleteArchived(session); }}>Delete archived session</button>
` `
: html` : html`
<button title="Reload session from disk" @click=${() => { this.openMenuSessionId = undefined; this.onReload?.(session); }}>Reload</button>
${session.parentSessionPath !== undefined ? html`<button title="Detach from parent" @click=${() => { this.openMenuSessionId = undefined; this.onDetachParent?.(session); }}>Detach from parent</button>` : null} ${session.parentSessionPath !== undefined ? html`<button title="Detach from parent" @click=${() => { this.openMenuSessionId = undefined; this.onDetachParent?.(session); }}>Detach from parent</button>` : null}
<button title="Archive session" @click=${() => { this.openMenuSessionId = undefined; this.onArchive?.(session); }}>Archive</button> <button title="Archive session" @click=${() => { this.openMenuSessionId = undefined; this.onArchive?.(session); }}>Archive</button>
${descendantCount > 0 ? html`<button title="Archive this session and its descendants" @click=${() => { this.openMenuSessionId = undefined; this.confirmArchiveWithDescendants(session, descendantCount); }}>Archive with descendants (${descendantCount})</button>` : null} ${descendantCount > 0 ? html`<button title="Archive this session and its descendants" @click=${() => { this.openMenuSessionId = undefined; this.confirmArchiveWithDescendants(session, descendantCount); }}>Archive with descendants (${descendantCount})</button>` : null}
@@ -61,6 +61,7 @@ export class AppNavigationPanel extends LitElement {
@property({ attribute: false }) onDeleteArchivedSession?: (session: SessionInfo) => void | Promise<void>; @property({ attribute: false }) onDeleteArchivedSession?: (session: SessionInfo) => void | Promise<void>;
@property({ attribute: false }) onDeleteArchivedSessions?: (sessions: SessionInfo[]) => void | Promise<void>; @property({ attribute: false }) onDeleteArchivedSessions?: (sessions: SessionInfo[]) => void | Promise<void>;
@property({ attribute: false }) onDetachParentSession?: (session: SessionInfo) => void | Promise<void>; @property({ attribute: false }) onDetachParentSession?: (session: SessionInfo) => void | Promise<void>;
@property({ attribute: false }) onReloadSession?: (session: SessionInfo) => void | Promise<void>;
@property({ attribute: false }) onArchivedCollapsed?: () => void | Promise<void>; @property({ attribute: false }) onArchivedCollapsed?: () => void | Promise<void>;
@property({ attribute: false }) onSelectMachine?: (machine: Machine) => void | Promise<void>; @property({ attribute: false }) onSelectMachine?: (machine: Machine) => void | Promise<void>;
@property({ attribute: false }) onRemoveMachine?: (machine: Machine) => void | Promise<void>; @property({ attribute: false }) onRemoveMachine?: (machine: Machine) => void | Promise<void>;
@@ -171,6 +172,7 @@ export class AppNavigationPanel extends LitElement {
.onDeleteArchived=${(session: SessionInfo) => this.onDeleteArchivedSession?.(session)} .onDeleteArchived=${(session: SessionInfo) => this.onDeleteArchivedSession?.(session)}
.onDeleteArchivedMany=${(sessions: SessionInfo[]) => this.onDeleteArchivedSessions?.(sessions)} .onDeleteArchivedMany=${(sessions: SessionInfo[]) => this.onDeleteArchivedSessions?.(sessions)}
.onDetachParent=${(session: SessionInfo) => this.onDetachParentSession?.(session)} .onDetachParent=${(session: SessionInfo) => this.onDetachParentSession?.(session)}
.onReload=${(session: SessionInfo) => this.onReloadSession?.(session)}
.onFocusPreviousSection=${() => { this.focusPreviousFrom("sessions"); }} .onFocusPreviousSection=${() => { this.focusPreviousFrom("sessions"); }}
.onFocusNextSection=${() => { this.focusNextFrom("sessions"); }} .onFocusNextSection=${() => { this.focusNextFrom("sessions"); }}
.onCancelKeyboardNavigation=${() => { this.cancelKeyboardNavigation(); }} .onCancelKeyboardNavigation=${() => { this.cancelKeyboardNavigation(); }}
@@ -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) { async detachParent(session = this.getState().selectedSession) {
if (session?.parentSessionPath === undefined) return; if (session?.parentSessionPath === undefined) return;
try { try {
+7
View File
@@ -560,6 +560,13 @@ export class PiSessionService {
await this.archiveStore.deleteArchived(record.sessionId); await this.archiveStore.deleteArchived(record.sessionId);
} }
async reload(ref: PiSessionLookup): Promise<void> {
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<void> { async detachParent(ref: PiSessionLookup): Promise<void> {
const session = await this.getOrOpen(ref); const session = await this.getOrOpen(ref);
const sessionFile = session.sessionFile; const sessionFile = session.sessionFile;
+9
View File
@@ -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) => { app.post<{ Params: { sessionId: string }; Body: { cwd?: unknown } | undefined }>(`${prefix}/sessions/:sessionId/detach-parent`, async (request, reply) => {
try { try {
await sessions.detachParent(sessionLookupFromBody(request.params.sessionId, optionalRecord(request.body))); await sessions.detachParent(sessionLookupFromBody(request.params.sessionId, optionalRecord(request.body)));