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