From 9159da935376c6f8dca17f6bd7a3bd318590e787 Mon Sep 17 00:00:00 2001 From: Federico Jaramillo Martinez Date: Sun, 14 Jun 2026 14:49:21 +0200 Subject: [PATCH] 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; }