diff --git a/.changeset/session-start-persistence.md b/.changeset/session-start-persistence.md new file mode 100644 index 0000000..8f31014 --- /dev/null +++ b/.changeset/session-start-persistence.md @@ -0,0 +1,5 @@ +--- +"@jmfederico/pi-web": patch +--- + +Create editable chats immediately when starting sessions, queue sends until the backend session is ready, and use server-backed persistence signals for session archive/delete/reload actions. diff --git a/src/client/src/api/parsers.test.ts b/src/client/src/api/parsers.test.ts index fadfd5e..88c2b85 100644 --- a/src/client/src/api/parsers.test.ts +++ b/src/client/src/api/parsers.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; import { PI_WEB_CAPABILITIES } from "../../../shared/capabilities"; -import { parseCommandResult, parseFileContentResponse, parseFileSuggestion, parseGitStatusResponse, parseMessagePage, parsePiPackageMutationResponse, parsePiPackagesResponse, parsePiWebConfigResponse, parsePiWebPluginsResponse, parsePiWebRuntimeResponse, parseSessionBulkArchiveResponse, parseSessionBulkDeleteArchivedResponse, parseSessionCleanupExecuteResponse, parseSessionCleanupPreviewResponse, parseSessionStatus, parseSlashCommand, parseTerminalCommandRun, parseTerminalInfo, parseWorkspace, parseWorkspaceActivityResponse } from "./parsers"; +import { parseCommandResult, parseFileContentResponse, parseFileSuggestion, parseGitStatusResponse, parseMessagePage, parsePiPackageMutationResponse, parsePiPackagesResponse, parsePiWebConfigResponse, parsePiWebPluginsResponse, parsePiWebRuntimeResponse, parseSessionBulkArchiveResponse, parseSessionBulkDeleteArchivedResponse, parseSessionCleanupExecuteResponse, parseSessionCleanupPreviewResponse, parseSessionInfo, parseSessionStatus, parseSlashCommand, parseTerminalCommandRun, parseTerminalInfo, parseWorkspace, parseWorkspaceActivityResponse } from "./parsers"; describe("API parsers", () => { it("parses PI WEB config responses", () => { @@ -112,9 +112,35 @@ describe("API parsers", () => { expect(() => parseSessionBulkDeleteArchivedResponse({ deleted: true, deletedSessionIds: [1], failures: [], generatedAt: "now" })).toThrow("Expected string array field: deletedSessionIds"); }); + it("parses session info including optional persistence signals", () => { + expect(parseSessionInfo({ + id: "s1", + path: "/sessions/s1.jsonl", + cwd: "/repo", + persisted: false, + name: "Draft session", + created: "2026-01-01T00:00:00.000Z", + modified: "2026-01-01T00:01:00.000Z", + messageCount: 0, + firstMessage: "", + })).toEqual({ + id: "s1", + path: "/sessions/s1.jsonl", + cwd: "/repo", + persisted: false, + name: "Draft session", + created: "2026-01-01T00:00:00.000Z", + modified: "2026-01-01T00:01:00.000Z", + messageCount: 0, + firstMessage: "", + }); + expect(() => parseSessionInfo({ id: "s1", path: "", cwd: "/repo", persisted: "yes", created: "now", modified: "now", messageCount: 0, firstMessage: "" })).toThrow("Expected optional boolean field: persisted"); + }); + it("validates session status including optional model and nullable context usage", () => { expect(parseSessionStatus({ sessionId: "s1", + persisted: true, isStreaming: false, isCompacting: true, isBashRunning: false, @@ -128,6 +154,7 @@ describe("API parsers", () => { thinkingLevel: "medium", })).toEqual({ sessionId: "s1", + persisted: true, isStreaming: false, isCompacting: true, isBashRunning: false, diff --git a/src/client/src/api/parsers.ts b/src/client/src/api/parsers.ts index 299ed4c..5470a8d 100644 --- a/src/client/src/api/parsers.ts +++ b/src/client/src/api/parsers.ts @@ -157,12 +157,14 @@ function optionalWorkspaceEffectiveConfig(value: unknown): Workspace["effectiveC export function parseSessionInfo(value: unknown): SessionInfo { const record = requireRecord(value); const name = optionalString(record, "name"); + const persisted = parseOptionalBoolean(record["persisted"], "persisted"); const parentSessionPath = optionalString(record, "parentSessionPath"); const archivedAt = optionalString(record, "archivedAt"); return { id: requireString(record, "id"), path: requireString(record, "path"), cwd: requireString(record, "cwd"), + ...(persisted === undefined ? {} : { persisted }), ...(name === undefined ? {} : { name }), created: requireString(record, "created"), modified: requireString(record, "modified"), @@ -178,6 +180,7 @@ export function parseSessionStatus(value: unknown): SessionStatus { const record = requireRecord(value); return { sessionId: requireString(record, "sessionId"), + ...optionalField("persisted", parseOptionalBoolean(record["persisted"], "persisted")), isStreaming: requireBoolean(record, "isStreaming"), isCompacting: requireBoolean(record, "isCompacting"), isBashRunning: requireBoolean(record, "isBashRunning"), diff --git a/src/client/src/appState.ts b/src/client/src/appState.ts index e64e726..d6376bd 100644 --- a/src/client/src/appState.ts +++ b/src/client/src/appState.ts @@ -1,4 +1,4 @@ -import type { AuthProviderOption, CommandOption, CommandResult, FileContentResponse, FileTreeEntry, GitDiffResponse, GitStatusResponse, Machine, MachineHealth, MachineRuntime, OAuthFlowState, PiWebStatusResponse, Project, SessionActivity, SessionInfo, SessionStatus, TerminalCommandRun, Workspace, WorkspaceActivity } from "./api"; +import type { AuthProviderOption, CommandOption, CommandResult, FileContentResponse, FileTreeEntry, GitDiffResponse, GitStatusResponse, Machine, MachineHealth, MachineRuntime, OAuthFlowState, PiWebStatusResponse, Project, QueuedSessionMessage, SessionActivity, SessionInfo, SessionStatus, TerminalCommandRun, Workspace, WorkspaceActivity } from "./api"; import type { ChatLine } from "./components/shared"; import type { QualifiedContributionId } from "./plugins/ids"; import type { WorkspaceUploadBatchState } from "./workspaceUploadState"; @@ -20,6 +20,8 @@ export interface AppState { isReceivingPartialStream: boolean; /** Sessions with a prompt upload in flight, keyed by sessionId (client-owned). */ sendingPrompts: Record; + /** Client-side queued sends waiting for a just-created backend session, keyed by sessionId. */ + clientQueuedSessionMessages: Record; /** Client-initiated session creation requests waiting for the server. */ startingSessionCount: number; isLoadingProjects: boolean; @@ -74,6 +76,7 @@ export type AuthDialogState = export type WorkspaceScopedStateReset = Pick { + it("labels client-side pending-start sends separately from server queued messages", () => { + const sections = chatQueuedMessageSections( + [{ kind: "followUp", text: "queued before start" }], + [{ kind: "steer", text: "server queued" }], + ); + + expect(sections).toEqual([ + { + heading: "Queued until session starts", + detail: "Will send once the backend session is ready", + messages: [{ kind: "followUp", text: "queued before start" }], + }, + { + heading: "Queued messages", + detail: "1 pending · Stop clears the queue", + messages: [{ kind: "steer", text: "server queued" }], + }, + ]); + }); +}); diff --git a/src/client/src/components/ChatView.ts b/src/client/src/components/ChatView.ts index e81573a..d998c81 100644 --- a/src/client/src/components/ChatView.ts +++ b/src/client/src/components/ChatView.ts @@ -6,7 +6,7 @@ import { groupChatMessages, summarizeChatGroup, type ChatGroup } from "../chatGr import { capturePrependScrollAnchor, PREPEND_RESTORE_SETTLE_FRAMES, restorePrependScrollAnchor, type PrependScrollAnchor } from "../chatScrollAnchoring"; import { shouldRequestEarlierMessages } from "../chatHistoryLoading"; import { ChatScrollController, distanceFromScrollBottom, findFirstVisibleArticle, isNearScrollBottom, type ChatAnchorScrollPosition, type ChatScrollRestoreResult } from "../chatScrollPosition"; -import type { SessionActivity, SessionStatus } from "../api"; +import type { QueuedSessionMessage, SessionActivity, SessionStatus } from "../api"; import type { ChatLine, ChatPart } from "./shared"; import { chatStyles } from "./shared"; import "./ConversationMeter"; @@ -38,6 +38,19 @@ function clampNumber(value: number, min: number, max: number): number { return Math.min(max, Math.max(min, value)); } +export interface QueuedMessageSection { + heading: string; + detail: string; + messages: QueuedSessionMessage[]; +} + +export function chatQueuedMessageSections(clientQueued: QueuedSessionMessage[], serverQueued: QueuedSessionMessage[]): QueuedMessageSection[] { + return [ + clientQueued.length === 0 ? undefined : { heading: "Queued until session starts", detail: "Will send once the backend session is ready", messages: clientQueued }, + serverQueued.length === 0 ? undefined : { heading: "Queued messages", detail: `${String(serverQueued.length)} pending · Stop clears the queue`, messages: serverQueued }, + ].filter((section): section is QueuedMessageSection => section !== undefined); +} + @customElement("chat-view") export class ChatView extends LitElement { @property({ attribute: false }) messages: ChatLine[] = []; @@ -51,6 +64,7 @@ export class ChatView extends LitElement { @property({ type: Boolean }) isSendingPrompt = false; @property({ type: Boolean }) isCompacting = false; @property({ type: Number }) pendingMessageCount = 0; + @property({ attribute: false }) clientQueuedMessages: QueuedSessionMessage[] = []; @property({ attribute: false }) status?: SessionStatus; @property({ attribute: false }) activity?: SessionActivity; @property({ attribute: false }) onLoadMore?: () => void; @@ -220,15 +234,18 @@ export class ChatView extends LitElement { } private renderQueuedMessages() { - const queued = this.status?.queuedMessages ?? []; - if (queued.length === 0) return null; + const serverQueued = this.status?.queuedMessages ?? []; + return html`${chatQueuedMessageSections(this.clientQueuedMessages, serverQueued).map((section) => this.renderQueuedMessageList(section))}`; + } + + private renderQueuedMessageList(section: QueuedMessageSection) { return html`