diff --git a/.changeset/stream-catchup-on-join.md b/.changeset/stream-catchup-on-join.md new file mode 100644 index 0000000..ea8bb12 --- /dev/null +++ b/.changeset/stream-catchup-on-join.md @@ -0,0 +1,5 @@ +--- +"@jmfederico/pi-web": patch +--- + +Stream in-flight assistant replies immediately when opening or reconnecting to a session mid-turn. The chat now seeds the partial message (text, thinking, and in-progress tool calls) and continues streaming live updates on top of it, replacing the blocking "Catching up…" placeholder and the end-of-turn transcript reload. Sessions still open normally against remote machines or session daemons that predate this feature: the snapshot is fetched as a progressive enhancement and its absence no longer blocks the transcript. diff --git a/src/client/src/api.ts b/src/client/src/api.ts index 3b68515..d2aa74c 100644 --- a/src/client/src/api.ts +++ b/src/client/src/api.ts @@ -2,4 +2,4 @@ export { activityApi, api, configApi, filesApi, gitApi, machinesApi, piPackagesA export { globalSessionEvents, realtimeEvents, sessionEvents, terminalSocket } from "./api/sockets"; export { DEFAULT_WORKSPACE_UPLOADS_FOLDER, effectiveWorkspaceUploadFolder, uploadWorkspaceFile, uploadWorkspaceFiles, workspaceEffectiveUploadFolder, workspaceUploadPath, WorkspaceUploadBatchError, WorkspaceUploadCancelledError } from "./api/workspaceUploads"; export type { UploadWorkspaceFileOptions, UploadWorkspaceFilesOptions, WorkspaceFileUploadProgress, WorkspaceUploadBatchFileProgress, WorkspaceUploadBatchProgress, WorkspaceUploadFileFailure, WorkspaceUploadFileInput, WorkspaceUploadFolderConfig, WorkspaceUploadTask, WorkspaceUploadXhr, WorkspaceUploadXhrFactory } from "./api/workspaceUploads"; -export type { ActiveAgentProfileDescriptor, ArchiveSessionsResponse, AuthProviderOption, AuthProviderStatus, AuthProvidersResponse, AuthStatusSource, AuthType, CommandOption, CommandResult, DeleteWorkspaceFileResponse, FileContentMediaType, FileContentResponse, FileSuggestion, FileTreeEntry, FileTreeResponse, GitDiffResponse, GitFileState, GitStatusFile, GitStatusResponse, Machine, MachineHealth, MachineKind, MachineRuntime, MachineStatus, MessagePage, ModelSelectionResponse, MoveWorkspaceFileOptions, MoveWorkspaceFileResponse, OAuthFlowState, PiPackageInfo, PiPackageInstallRequest, PiPackageMutationAction, PiPackageMutationResponse, PiPackageRemoveRequest, PiPackageScope, PiPackageUpdateRequest, PiPackagesResponse, PiWebAgentDirEnvSource, PiWebCapability, PiWebComponentStatus, PiWebConfigEnvOverrides, PiWebConfigResponse, PiWebConfigValues, PiWebDockerMode, PiWebInstallationInfo, PiWebInstallationKind, PiWebPluginConfig, PiWebPluginConfigMap, PiWebPluginInfo, PiWebPluginsResponse, PiWebPluginScope, PiWebPluginSettings, PiWebReleaseStatus, PiWebRuntimeComponent, PiWebRuntimeResponse, PiWebShortcutConfig, PiWebStatusMessage, PiWebStatusResponse, PiWebUploadsConfig, Project, PromptAttachment, QueuedSessionMessage, RealtimeEvent, RunTerminalCommandInput, SavedPromptAttachment, SessionActivity, SessionBulkArchiveResponse, SessionBulkDeleteArchivedResponse, SessionBulkFailure, SessionBulkMutationRef, SessionBulkMutationRequest, SessionCleanupExecuteResponse, SessionCleanupPreviewResponse, SessionCleanupProjectSummary, SessionCleanupRequest, SessionCleanupThresholds, SessionCleanupTotals, SessionInfo, SessionModel, SessionRef, SessionStatus, SlashCommand, SessionUiEvent, TerminalCommandRun, TerminalCommandRunFilter, TerminalCommandRunHandle, TerminalCommandRunStatus, TerminalInfo, TerminalUiEvent, ThinkingLevel, ThinkingLevelsResponse, WriteWorkspaceFileOptions, WriteWorkspaceFileResponse, Workspace, WorkspaceActivity, WorkspaceActivityResponse, WorkspaceActivityUiEvent } from "../../shared/apiTypes"; +export type { ActiveAgentProfileDescriptor, ArchiveSessionsResponse, AuthProviderOption, AuthProviderStatus, AuthProvidersResponse, AuthStatusSource, AuthType, CommandOption, CommandResult, DeleteWorkspaceFileResponse, FileContentMediaType, FileContentResponse, FileSuggestion, FileTreeEntry, FileTreeResponse, GitDiffResponse, GitFileState, GitStatusFile, GitStatusResponse, Machine, MachineHealth, MachineKind, MachineRuntime, MachineStatus, MessagePage, ModelSelectionResponse, MoveWorkspaceFileOptions, MoveWorkspaceFileResponse, OAuthFlowState, PiPackageInfo, PiPackageInstallRequest, PiPackageMutationAction, PiPackageMutationResponse, PiPackageRemoveRequest, PiPackageScope, PiPackageUpdateRequest, PiPackagesResponse, PiWebAgentDirEnvSource, PiWebCapability, PiWebComponentStatus, PiWebConfigEnvOverrides, PiWebConfigResponse, PiWebConfigValues, PiWebDockerMode, PiWebInstallationInfo, PiWebInstallationKind, PiWebPluginConfig, PiWebPluginConfigMap, PiWebPluginInfo, PiWebPluginsResponse, PiWebPluginScope, PiWebPluginSettings, PiWebReleaseStatus, PiWebRuntimeComponent, PiWebRuntimeResponse, PiWebShortcutConfig, PiWebStatusMessage, PiWebStatusResponse, PiWebUploadsConfig, Project, PromptAttachment, QueuedSessionMessage, RealtimeEvent, RunTerminalCommandInput, SavedPromptAttachment, SessionActivity, SessionBulkArchiveResponse, SessionBulkDeleteArchivedResponse, SessionBulkFailure, SessionBulkMutationRef, SessionBulkMutationRequest, SessionCleanupExecuteResponse, SessionCleanupPreviewResponse, SessionCleanupProjectSummary, SessionCleanupRequest, SessionCleanupThresholds, SessionCleanupTotals, SessionInfo, SessionModel, SessionRef, SessionStatus, SessionStreamSnapshot, SlashCommand, SessionUiEvent, TerminalCommandRun, TerminalCommandRunFilter, TerminalCommandRunHandle, TerminalCommandRunStatus, TerminalInfo, TerminalUiEvent, ThinkingLevel, ThinkingLevelsResponse, WriteWorkspaceFileOptions, WriteWorkspaceFileResponse, Workspace, WorkspaceActivity, WorkspaceActivityResponse, WorkspaceActivityUiEvent } from "../../shared/apiTypes"; diff --git a/src/client/src/api/clients.test.ts b/src/client/src/api/clients.test.ts index 4e6b4ac..b050e5b 100644 --- a/src/client/src/api/clients.test.ts +++ b/src/client/src/api/clients.test.ts @@ -280,6 +280,29 @@ describe("session API compatibility", () => { expect(init?.method).toBe("POST"); expect(JSON.parse(requestBody(init))).toEqual({ cwd: "/repo with spaces" }); }); + + it("reads a session stream snapshot through an encoded machine route with cwd context", async () => { + const fetchMock = stubJsonFetch({ seq: 12, partial: { role: "assistant", content: [{ type: "text", text: "streaming" }] } }); + + await expect(sessionsApi.streamSnapshot({ id: "s /?", cwd: "/repo with spaces" }, "remote /?")).resolves.toEqual({ + seq: 12, + partial: { role: "assistant", content: [{ type: "text", text: "streaming" }] }, + }); + + expect(fetchMock).toHaveBeenCalledOnce(); + const [url, init] = fetchCall(fetchMock, 0); + expect(url).toBe("https://pi.example.test/api/machines/remote%20%2F%3F/sessions/s%20%2F%3F/stream-snapshot?cwd=%2Frepo+with+spaces"); + expect(init?.method ?? "GET").toBe("GET"); + }); + + it("reads a session stream snapshot for a legacy session-id ref without cwd context", async () => { + const fetchMock = stubJsonFetch({ seq: 0, partial: null }); + + await expect(sessionsApi.streamSnapshot("s 1", "remote a")).resolves.toEqual({ seq: 0, partial: null }); + + expect(fetchMock).toHaveBeenCalledOnce(); + expect(fetchCall(fetchMock, 0)[0]).toBe("https://pi.example.test/api/machines/remote%20a/sessions/s%201/stream-snapshot"); + }); }); describe("machine-scoped file suggestion API", () => { diff --git a/src/client/src/api/clients.ts b/src/client/src/api/clients.ts index 493b506..de81a4d 100644 --- a/src/client/src/api/clients.ts +++ b/src/client/src/api/clients.ts @@ -41,6 +41,7 @@ import { parseSessionCleanupPreviewResponse, parseSessionInfo, parseSessionStatus, + parseSessionStreamSnapshot, parseSlashCommand, parseStopped, parseTerminalCommandRun, @@ -209,6 +210,7 @@ export const sessionsApi = { deleteArchivedMany: (sessions: readonly SessionLookup[], machineId = "local") => request(`${machinePrefix(machineId)}/sessions/bulk/delete-archived`, parseSessionBulkDeleteArchivedResponse, { method: "POST", body: sessionBulkMutationBody(sessions) }), messages: (session: SessionLookup, options?: { limit?: number; before?: number }, machineId = "local") => request(messagePath(session, options, machineId), parseMessagePage), status: (session: SessionLookup, machineId = "local") => request(sessionQueryPath(session, "status", machineId), parseSessionStatus), + streamSnapshot: (session: SessionLookup, machineId = "local") => request(sessionQueryPath(session, "stream-snapshot", machineId), parseSessionStreamSnapshot), clearQueue: (session: SessionLookup, machineId = "local") => request(sessionPath(session, "queue/clear", machineId), parseSessionStatus, { method: "POST", body: sessionBody(session) }), models: (session: SessionLookup, machineId = "local") => request(sessionQueryPath(session, "models", machineId), parseModelSelectionResponse), setModel: (session: SessionLookup, provider: string, modelId: string, machineId = "local") => request(sessionPath(session, "model", machineId), parseSessionStatus, { method: "POST", body: sessionBody(session, { provider, modelId }) }), diff --git a/src/client/src/api/federatedRouteContract.test.ts b/src/client/src/api/federatedRouteContract.test.ts index 52a53c2..d8fa3c7 100644 --- a/src/client/src/api/federatedRouteContract.test.ts +++ b/src/client/src/api/federatedRouteContract.test.ts @@ -64,6 +64,7 @@ describe("federated route contract", () => { ignoreParseFailure(sessionsApi.deleteArchivedMany([session], machineId)), ignoreParseFailure(sessionsApi.messages(session, { limit: 20, before: 10 }, machineId)), ignoreParseFailure(sessionsApi.status(session, machineId)), + ignoreParseFailure(sessionsApi.streamSnapshot(session, machineId)), ignoreParseFailure(sessionsApi.clearQueue(session, machineId)), ignoreParseFailure(sessionsApi.models(session, machineId)), ignoreParseFailure(sessionsApi.setModel(session, "openai", "gpt", machineId)), diff --git a/src/client/src/api/parsers.test.ts b/src/client/src/api/parsers.test.ts index 5b687a7..827a729 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, parseMachineRuntime, parseMessagePage, parsePiPackageMutationResponse, parsePiPackagesResponse, parsePiWebConfigResponse, parsePiWebPluginsResponse, parsePiWebRuntimeResponse, parsePiWebStatusResponse, parseSessionBulkArchiveResponse, parseSessionBulkDeleteArchivedResponse, parseSessionCleanupExecuteResponse, parseSessionCleanupPreviewResponse, parseSessionInfo, parseSessionStatus, parseSlashCommand, parseTerminalCommandRun, parseTerminalInfo, parseWorkspace, parseWorkspaceActivityResponse } from "./parsers"; +import { parseCommandResult, parseFileContentResponse, parseFileSuggestion, parseGitStatusResponse, parseMachineRuntime, parseMessagePage, parsePiPackageMutationResponse, parsePiPackagesResponse, parsePiWebConfigResponse, parsePiWebPluginsResponse, parsePiWebRuntimeResponse, parsePiWebStatusResponse, parseSessionBulkArchiveResponse, parseSessionBulkDeleteArchivedResponse, parseSessionCleanupExecuteResponse, parseSessionCleanupPreviewResponse, parseSessionInfo, parseSessionStatus, parseSessionStreamSnapshot, parseSlashCommand, parseTerminalCommandRun, parseTerminalInfo, parseWorkspace, parseWorkspaceActivityResponse } from "./parsers"; describe("API parsers", () => { it("parses PI WEB config responses", () => { @@ -152,6 +152,19 @@ describe("API parsers", () => { expect(parseMessagePage({ messages: ["c"], start: 3, total: 9 })).toEqual({ messages: ["c"], start: 3, total: 9 }); }); + it("parses a session stream snapshot, defaulting a missing partial to null", () => { + expect(parseSessionStreamSnapshot({ seq: 7, partial: { role: "assistant", content: [{ type: "text", text: "hi" }] } })).toEqual({ + seq: 7, + partial: { role: "assistant", content: [{ type: "text", text: "hi" }] }, + }); + expect(parseSessionStreamSnapshot({ seq: 0, partial: null })).toEqual({ seq: 0, partial: null }); + expect(parseSessionStreamSnapshot({ seq: 3 })).toEqual({ seq: 3, partial: null }); + }); + + it("rejects a session stream snapshot without a numeric seq", () => { + expect(() => parseSessionStreamSnapshot({ partial: null })).toThrow("Expected number field: seq"); + }); + it("parses session cleanup preview and execute responses", () => { const preview = { generatedAt: "2026-06-25T12:00:00.000Z", diff --git a/src/client/src/api/parsers.ts b/src/client/src/api/parsers.ts index c2508a6..0aa7fba 100644 --- a/src/client/src/api/parsers.ts +++ b/src/client/src/api/parsers.ts @@ -1,4 +1,4 @@ -import type { ArchiveSessionsResponse, AuthProviderOption, AuthProviderStatus, AuthProvidersResponse, AuthStatusSource, AuthType, CommandOption, CommandResult, DeleteWorkspaceFileResponse, FileContentResponse, FileSuggestion, FileTreeEntry, FileTreeResponse, GitDiffResponse, GitFileState, GitStatusFile, GitStatusResponse, Machine, MachineHealth, MachineKind, MachineRuntime, MachineStatus, MessagePage, ModelSelectionResponse, MoveWorkspaceFileResponse, OAuthFlowState, PiWebAgentDirEnvSource, PiWebCapability, PiWebComponentStatus, PiWebConfigEnvOverrides, PiWebConfigResponse, PiWebConfigValues, PiWebInstallationInfo, PiWebPluginConfigMap, PiWebPluginInfo, PiWebPluginsResponse, PiWebPluginScope, PiWebReleaseStatus, PiWebRuntimeComponent, PiWebRuntimeResponse, PiWebServiceComponent, PiWebShortcutConfig, PiWebStatusMessage, PiWebStatusResponse, PiWebStatusSeverity, Project, QueuedSessionMessage, SavedPromptAttachment, SessionBulkArchiveResponse, SessionBulkDeleteArchivedResponse, SessionBulkFailure, SessionCleanupExecuteResponse, SessionCleanupPreviewResponse, SessionCleanupProjectSummary, SessionCleanupThresholds, SessionCleanupTotals, SessionInfo, SessionModel, SessionStatus, SlashCommand, TerminalCommandRun, TerminalCommandRunStatus, TerminalInfo, ThinkingLevelsResponse, WriteWorkspaceFileResponse, Workspace, WorkspaceActivity, WorkspaceActivityResponse } from "../../../shared/apiTypes"; +import type { ArchiveSessionsResponse, AuthProviderOption, AuthProviderStatus, AuthProvidersResponse, AuthStatusSource, AuthType, CommandOption, CommandResult, DeleteWorkspaceFileResponse, FileContentResponse, FileSuggestion, FileTreeEntry, FileTreeResponse, GitDiffResponse, GitFileState, GitStatusFile, GitStatusResponse, Machine, MachineHealth, MachineKind, MachineRuntime, MachineStatus, MessagePage, ModelSelectionResponse, MoveWorkspaceFileResponse, OAuthFlowState, PiWebAgentDirEnvSource, PiWebCapability, PiWebComponentStatus, PiWebConfigEnvOverrides, PiWebConfigResponse, PiWebConfigValues, PiWebInstallationInfo, PiWebPluginConfigMap, PiWebPluginInfo, PiWebPluginsResponse, PiWebPluginScope, PiWebReleaseStatus, PiWebRuntimeComponent, PiWebRuntimeResponse, PiWebServiceComponent, PiWebShortcutConfig, PiWebStatusMessage, PiWebStatusResponse, PiWebStatusSeverity, Project, QueuedSessionMessage, SavedPromptAttachment, SessionBulkArchiveResponse, SessionBulkDeleteArchivedResponse, SessionBulkFailure, SessionCleanupExecuteResponse, SessionCleanupPreviewResponse, SessionCleanupProjectSummary, SessionCleanupThresholds, SessionCleanupTotals, SessionInfo, SessionModel, SessionStatus, SessionStreamSnapshot, SlashCommand, TerminalCommandRun, TerminalCommandRunStatus, TerminalInfo, ThinkingLevelsResponse, WriteWorkspaceFileResponse, Workspace, WorkspaceActivity, WorkspaceActivityResponse } from "../../../shared/apiTypes"; import type { PiPackageInfo, PiPackageMutationAction, PiPackageMutationResponse, PiPackageScope, PiPackagesResponse } from "../../../shared/apiTypes"; import { parseActiveAgentProfileDescriptor } from "../../../shared/activeAgentProfile"; import { parseKnownPiWebCapabilities } from "../../../shared/capabilities"; @@ -196,6 +196,14 @@ export function parseSessionStatus(value: unknown): SessionStatus { }; } +export function parseSessionStreamSnapshot(value: unknown): SessionStreamSnapshot { + const record = requireRecord(value); + return { + seq: requireNumber(record, "seq"), + partial: record["partial"] ?? null, + }; +} + export function parseSessionCleanupPreviewResponse(value: unknown): SessionCleanupPreviewResponse { const record = requireRecord(value); const skippedBusySessionIds = record["skippedBusySessionIds"] === undefined ? undefined : arrayOfString(record["skippedBusySessionIds"], "skippedBusySessionIds"); diff --git a/src/client/src/appState.ts b/src/client/src/appState.ts index d6376bd..037ba74 100644 --- a/src/client/src/appState.ts +++ b/src/client/src/appState.ts @@ -17,7 +17,6 @@ export interface AppState { messagePageEnd: number; messagePageTotal: number; isLoadingEarlierMessages: boolean; - 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. */ @@ -127,7 +126,6 @@ export function initialAppState(): AppState { messagePageEnd: 0, messagePageTotal: 0, isLoadingEarlierMessages: false, - isReceivingPartialStream: false, sendingPrompts: {}, clientQueuedSessionMessages: {}, startingSessionCount: 0, diff --git a/src/client/src/chatTranscript.test.ts b/src/client/src/chatTranscript.test.ts index af4585d..6e37c98 100644 --- a/src/client/src/chatTranscript.test.ts +++ b/src/client/src/chatTranscript.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from "vitest"; import { groupChatMessages } from "./chatGroups"; import { normalizeMessages, textMessage } from "./chatMessages"; -import { applyTranscriptEvent } from "./chatTranscript"; +import { applyTranscriptEvent, seedStreamingPartial } from "./chatTranscript"; import type { ChatLine } from "./components/shared"; const finalAssistant = { @@ -464,6 +464,42 @@ describe("applyTranscriptEvent", () => { ]); }); + it("seeds a null or undefined partial as a no-op", () => { + const messages = [textMessage("user", "question")]; + expect(seedStreamingPartial(messages, null)).toBe(messages); + expect(seedStreamingPartial(messages, undefined)).toBe(messages); + }); + + it("seeds an in-flight assistant partial with text and thinking so live deltas append onto it", () => { + const seeded = seedStreamingPartial([textMessage("user", "question")], { + role: "assistant", + content: [{ type: "thinking", thinking: "plan" }, { type: "text", text: "partial" }], + }); + + expect(seeded).toEqual([ + textMessage("user", "question"), + { role: "assistant", parts: [{ type: "thinking", text: "plan" }, { type: "text", text: "partial" }] }, + ]); + + // A live delta continues the seeded assistant message rather than starting a new one. + expect(applyTranscriptEvent(seeded, { type: "assistant.delta", text: " answer" })).toEqual([ + textMessage("user", "question"), + { role: "assistant", parts: [{ type: "thinking", text: "plan" }, { type: "text", text: "partial answer" }] }, + ]); + }); + + it("seeds an in-progress tool call from the partial as a tool execution line", () => { + const seeded = seedStreamingPartial([textMessage("user", "run it")], { + role: "assistant", + content: [{ type: "toolCall", id: "tool-1", name: "bash", arguments: { command: "ls" } }], + }); + + expect(seeded).toEqual([ + textMessage("user", "run it"), + { role: "tool", parts: [{ type: "toolExecution", toolCallId: "tool-1", toolName: "bash", summary: "ls", args: { command: "ls" }, status: "pending" }] }, + ]); + }); + it("replaces an optimistic user message when the finalized text matches", () => { const messages = [textMessage("user", "sent prompt")]; diff --git a/src/client/src/chatTranscript.ts b/src/client/src/chatTranscript.ts index 179b84d..a577558 100644 --- a/src/client/src/chatTranscript.ts +++ b/src/client/src/chatTranscript.ts @@ -1,4 +1,4 @@ -import { appendText, appendThinking, normalizeMessage, previewFromDetails, summarizeArgs, textMessage } from "./chatMessages"; +import { appendText, appendThinking, normalizeMessage, normalizeMessages, previewFromDetails, summarizeArgs, textMessage } from "./chatMessages"; import type { ChatLine, ToolExecutionPart } from "./components/shared"; import { appendShellChunk, finalizeShellMessage, shellStartMessage } from "./shellMessages"; import type { SessionUiEvent } from "./sessionSocket"; @@ -20,6 +20,26 @@ interface ToolResultUpdate { presentation: ToolResultPresentation; } +/** + * Seed the in-flight partial assistant message on top of committed history at + * join time. `partial` is the browser-projected `AssistantMessage` from the + * stream snapshot (or `null`/`undefined` when the session is not mid + * assistant-message stream). The partial is normalized the same way a streamed + * message is (text/thinking parts plus any in-progress tool call parts kept on + * the assistant line), so live `assistant.delta`/`assistant.thinking.delta` + * events append onto it and `message.end` reconciles it into the finalized shape. + * This returns a new in-memory message list only; it never writes to the raw + * history cache. + */ +export function seedStreamingPartial(messages: ChatLine[], partial: unknown): ChatLine[] { + if (partial === null || partial === undefined) return messages; + // Normalize the same way committed history is (coalescing an in-progress tool + // call into a `toolExecution` line) so the seeded partial renders identically + // to its finalized form and live `tool.*` events can target it by call id. + const lines = normalizeMessages([partial]); + return lines.length === 0 ? messages : [...messages, ...lines]; +} + export function applyTranscriptEvent(messages: ChatLine[], event: SessionUiEvent): ChatLine[] | undefined { if (event.type === "message.append") return appendNewMessage(messages, event.message); if (event.type === "assistant.delta") return appendText(messages, "assistant", event.text); diff --git a/src/client/src/chatTranscriptStore.ts b/src/client/src/chatTranscriptStore.ts index 097b46b..59e24c9 100644 --- a/src/client/src/chatTranscriptStore.ts +++ b/src/client/src/chatTranscriptStore.ts @@ -1,5 +1,5 @@ import { normalizeMessages } from "./chatMessages"; -import { applyTranscriptEvent } from "./chatTranscript"; +import { applyTranscriptEvent, seedStreamingPartial } from "./chatTranscript"; import { mergeChatHistory, readChatHistoryCache, removeChatHistoryCache, writeChatHistoryCache, type RawMessagePage } from "./chatHistoryCache"; import type { ChatLine } from "./components/shared"; import type { SessionUiEvent } from "./sessionSocket"; @@ -45,6 +45,15 @@ export class ChatTranscriptStore { return applyTranscriptEvent(messages, event); } + /** + * Seed the join-time in-flight partial assistant message on top of the + * committed history view. Returns a new in-memory message list; the raw + * history cache is deliberately untouched so the partial never persists. + */ + seedStreamingPartial(messages: ChatLine[], partial: unknown): ChatLine[] { + return seedStreamingPartial(messages, partial); + } + discard(sessionId: string): void { this.rawHistoryPages.delete(sessionId); this.cache.remove?.(sessionId); diff --git a/src/client/src/components/ChatView.ts b/src/client/src/components/ChatView.ts index 78aea9c..976d8e7 100644 --- a/src/client/src/components/ChatView.ts +++ b/src/client/src/components/ChatView.ts @@ -16,19 +16,6 @@ import "./ToolExecutionView"; const messageTimestampFormatter = new Intl.DateTimeFormat(undefined, { dateStyle: "medium", timeStyle: "medium" }); -const partialStreamNoticeBodies = [ - "You opened this chat while the assistant was already replying. The complete answer will appear shortly.", - "We joined mid-sentence. Holding the curtain until the full reply is ready.", - "The assistant started before this tab arrived. We’ll show the full answer when it lands.", - "Catching the reply in one piece — no spoilers, no half-answers.", - "The tokens are still assembling themselves. Full answer incoming.", - "We arrived fashionably late to this response. The complete version will appear soon.", -] as const; - -function randomPartialStreamNoticeBody(): string { - return partialStreamNoticeBodies[Math.floor(Math.random() * partialStreamNoticeBodies.length)] ?? partialStreamNoticeBodies[0]; -} - function clampPercent(value: number): number { return clampNumber(value, 0, 100); } @@ -83,7 +70,6 @@ export class ChatView extends LitElement { @property({ type: Number }) messageTotal = 0; @property({ type: Boolean }) hasMore = false; @property({ type: Boolean }) loadingMore = false; - @property({ type: Boolean }) isReceivingPartialStream = false; @property({ type: Boolean }) isSendingPrompt = false; @property({ type: Boolean }) isCompacting = false; @property({ type: Number }) pendingMessageCount = 0; @@ -112,7 +98,6 @@ export class ChatView extends LitElement { private groupedMessagesCache: ChatGroup[] = []; private readonly messageMetaCache = new WeakMap(); private readonly messageCopyTextCache = new WeakMap(); - private partialStreamNoticeBody: string | undefined; private lastScrollTop = 0; private lastClientHeight = 0; private touchStartY: number | undefined; @@ -193,7 +178,6 @@ export class ChatView extends LitElement { this.savePreviousSessionScrollPosition(changed.get("sessionId")); this.prepareSessionUiState(); } - if (changed.has("isReceivingPartialStream") || (changed.has("sessionId") && this.isReceivingPartialStream)) this.syncPartialStreamNoticeBody(); if (changed.has("messages")) this.pinnedToBottom = this.pinnedToBottom && (this.didChatHeightChange() || this.isNearBottom()); } @@ -326,12 +310,6 @@ export class ChatView extends LitElement { } private renderSessionActivity() { - if (this.isReceivingPartialStream) return html` - - `; if (!this.isCompacting) return null; return html`