diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b333b2944..d91f2b1cc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -105,7 +105,7 @@ jobs: # PR #189 — port macOS du compositor (Metal + VideoToolbox). # - # Compile check on a `macos-14` runner (Apple Silicon). Verifies that the + # Compile check on a supported `macos-latest` runner (Apple Silicon). Verifies that the # scaffold + engine-layer code added by #189 actually compiles for # `aarch64-apple-darwin` — bindgen + cc on macOS produce the ffmpeg FFI # bindings + the VideoToolbox context that the Rust crate statically @@ -133,7 +133,7 @@ jobs: # * `shaders.metal` is compiled AT RUNTIME by `new_library_with_source`, so a # whole file of invalid MSL (HLSL's global `cbuffer`/`Texture2D` have no MSL # equivalent) type-checks perfectly — `every_shader_entry_point_compiles` - # catches it. macos-14 runners have a real Metal device, so it runs for real. + # catches it. Apple Silicon runners have a real Metal device, so it runs for real. # * `AVERROR(EAGAIN)` is -11 on Windows/Linux and -35 on macOS. A hardcoded -11 # compiles anywhere and silently decodes zero frames — # `averror_constants_match_the_ffmpeg_macros` confronts it with shim.c. @@ -141,7 +141,9 @@ jobs: # `newRenderPipelineState` time — `the_compositor_builds_on_the_system_device`. rust-macos-compositor-check: name: Rust test (macOS compositor) - runs-on: macos-14 + # Homebrew no longer bottles ffmpeg dependencies for macOS 14. Its source + # build of x265 fails in libtool before our Rust tests even start. + runs-on: macos-latest steps: - uses: actions/checkout@v7 - name: Install Rust toolchain @@ -151,7 +153,7 @@ jobs: - name: Install ffmpeg (homebrew-core) and verify headers/libs run: | brew update - # macos-14 images can retain an orphaned openssl@1.1 symlink. + # Older runner images can retain an orphaned openssl@1.1 symlink. # `brew unlink` removes zero links there, so remove this one stale # symlink only when it still points at 1.1 before installing ffmpeg. if [[ -L /opt/homebrew/bin/openssl && "$(readlink /opt/homebrew/bin/openssl)" == *openssl@1.1* ]]; then @@ -311,8 +313,7 @@ jobs: # real display, a real tap and a real speaker — is `npm run test:sck-audio-timeline:mac`, # which cannot run here and is run by hand. # - # `macos-latest`, matching `build.yml`'s arm64 leg — NOT `macos-14` like the compositor job - # above, and the difference is load bearing. A gate has to compile with the toolchain that + # `macos-latest`, matching `build.yml`'s arm64 leg. A gate has to compile with the toolchain that # actually ships the binary, or it fails on code that is fine and passes on code that is not. # This job was written as `macos-14` by copying the job above and failed immediately: # `ScreenCaptureRecorder.swift` has carried a trailing comma in an argument list since long diff --git a/FORK-CHANGELOG.md b/FORK-CHANGELOG.md index 8022b9f43..ead4d4d40 100644 --- a/FORK-CHANGELOG.md +++ b/FORK-CHANGELOG.md @@ -17,6 +17,17 @@ see [FORK.md](FORK.md). For the dated review of newer upstream changes, see [FORK-UPSTREAM-REVIEW.md](FORK-UPSTREAM-REVIEW.md). +## Unreleased — AI conversation persistence + +- Saves each project's AI conversations, messages, generated-image references, + manual compaction state and rewind checkpoints under the local Studio profile. + Reopening the app restores the transcript and selects the newest conversation. +- Writes a temporary file and atomically replaces the prior history after every + change. An unreadable history file is left untouched instead of being silently + replaced with an empty one. Deleting a project also removes its saved chats. +- Older builds stored conversations only in memory, so chats already lost on + restart before this change cannot be recovered. + ## Unreleased — ChatGPT subscription image scenes - Adds an explicit image-scene action to the AI session whenever ChatGPT diff --git a/electron/ai-edition/chat-service.compaction.test.ts b/electron/ai-edition/chat-service.compaction.test.ts index 2b314d0ce..ffd05cb31 100644 --- a/electron/ai-edition/chat-service.compaction.test.ts +++ b/electron/ai-edition/chat-service.compaction.test.ts @@ -3,7 +3,10 @@ // turn itself (so we can read the history it was given) and the chat model // behind the summarizer, so no test here needs a provider or a key. -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; vi.mock("./deep-agent/service", () => ({ invokeOpenScreenAgent: vi.fn(), @@ -16,6 +19,7 @@ vi.mock("./deep-agent/chat-model", () => ({ import { compactSessionNow, + configureChatSessionStorage, createSession, getSessionContextUsage, runChat, @@ -238,3 +242,35 @@ describe("compaction", () => { }); }); }); + +describe("persisted compaction", () => { + let userDataPath: string; + + beforeEach(async () => { + userDataPath = await mkdtemp(path.join(tmpdir(), "openscreen-chat-compact-")); + configureChatSessionStorage(userDataPath); + }); + + afterEach(async () => { + configureChatSessionStorage(null); + await rm(userDataPath, { recursive: true, force: true }); + }); + + it("keeps the compacted model boundary while restoring the full transcript", async () => { + stubSummarizer("EARLIER CONTEXT"); + const projectId = "proj_compact_restart"; + const session = createSession(projectId); + for (let index = 0; index < 4; index += 1) { + await runChat(projectId, session.id, "Prompt " + index + " ".repeat(300), stubConfig()); + } + const compacted = await compactSessionNow(projectId, session.id, stubConfig()); + expect(compacted).not.toBeNull(); + + configureChatSessionStorage(userDataPath); + expect(selectSession(projectId, session.id)?.messages).toHaveLength(8); + await runChat(projectId, session.id, "Next turn", stubConfig()); + const modelHistory = histories.at(-1) ?? []; + expect(modelHistory[0]?.content).toBe("EARLIER CONTEXT"); + expect(modelHistory.some((message) => message.content.startsWith("Prompt 0 "))).toBe(false); + }); +}); diff --git a/electron/ai-edition/chat-service.test.ts b/electron/ai-edition/chat-service.test.ts index 42bf9526b..8b073afe8 100644 --- a/electron/ai-edition/chat-service.test.ts +++ b/electron/ai-edition/chat-service.test.ts @@ -1,10 +1,13 @@ +import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; -import { beforeEach, describe, expect, it } from "vitest"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { type AxcutDocument, createEmptyDocument } from "../../src/lib/ai-edition/schema"; import { appendGeneratedScene, + configureChatSessionStorage, createSession, + deleteProjectSessions, deleteSession, listSessions, renameSession, @@ -233,6 +236,19 @@ describe("runTimelineOperation", () => { expect(session?.messages[0].role).toBe("assistant"); }); + it("records the summary when a timeline operation creates its first chat", async () => { + const { documents } = makeDocumentsStub(); + const result = await runTimelineOperation( + "proj_run_new", + "missing-session", + { type: "add_trim_range", startSec: 5, endSec: 8 }, + "Trimmed silence", + documents, + ); + expect(result.success).toBe(true); + expect(listSessions("proj_run_new")).toEqual([expect.objectContaining({ messageCount: 1 })]); + }); + it("returns success:false on getProject failure", async () => { const documents = new BrokenDocumentService({ stored: makeDocument() }); const s = createSession("proj_run_err"); @@ -248,3 +264,76 @@ describe("runTimelineOperation", () => { expect(result.error).toBe("disk is dead"); }); }); + +describe("chat session persistence", () => { + let userDataPath: string; + + beforeEach(async () => { + userDataPath = await mkdtemp(path.join(tmpdir(), "openscreen-chat-sessions-")); + configureChatSessionStorage(userDataPath); + }); + + afterEach(async () => { + configureChatSessionStorage(null); + await rm(userDataPath, { recursive: true, force: true }); + }); + + it("restores renamed conversations and generated-image messages after a restart", () => { + const projectId = "proj_saved_chat"; + const session = createSession(projectId); + renameSession(projectId, session.id, "Storyboard"); + const generated = appendGeneratedScene( + projectId, + session.id, + "A violet sphere", + "C:/studio/source.png", + ); + const other = createSession("proj_other_chat"); + + configureChatSessionStorage(userDataPath); + + expect(listSessions(projectId)).toEqual([ + expect.objectContaining({ id: session.id, title: "Storyboard", messageCount: 2 }), + ]); + expect(selectSession(projectId, session.id)?.messages).toEqual([ + generated.userMessage, + generated.assistantMessage, + ]); + expect(selectSession(projectId, session.id)?.messages[1]?.generatedImagePath).toBe( + "C:/studio/source.png", + ); + expect(listSessions("proj_other_chat")[0]?.id).toBe(other.id); + }); + + it("keeps deletion durable and removes all chats when their project is deleted", async () => { + const projectId = "proj_delete_chat"; + const first = createSession(projectId); + const second = createSession(projectId); + expect(deleteSession(projectId, first.id)).toBe(true); + + configureChatSessionStorage(userDataPath); + expect(listSessions(projectId).map((session) => session.id)).toEqual([second.id]); + + deleteProjectSessions(projectId); + configureChatSessionStorage(userDataPath); + expect(listSessions(projectId)).toEqual([]); + await expect( + readFile(path.join(userDataPath, "chat-sessions", projectId + ".json"), "utf8"), + ).rejects.toMatchObject({ code: "ENOENT" }); + }); + + it("does not replace a corrupt history file with an empty conversation", async () => { + const projectId = "proj_corrupt_chat"; + const file = path.join(userDataPath, "chat-sessions", projectId + ".json"); + await mkdir(path.dirname(file), { recursive: true }); + await writeFile(file, "{unfinished", "utf8"); + + expect(() => listSessions(projectId)).toThrow("Cannot load saved conversations"); + expect(() => createSession(projectId)).toThrow("Cannot load saved conversations"); + expect(await readFile(file, "utf8")).toBe("{unfinished"); + }); + + it("rejects a project id that could escape the chat directory", () => { + expect(() => createSession("../outside")).toThrow("Invalid chat project id"); + }); +}); diff --git a/electron/ai-edition/chat-service.toolloop.test.ts b/electron/ai-edition/chat-service.toolloop.test.ts index 3982c901e..6a5511469 100644 --- a/electron/ai-edition/chat-service.toolloop.test.ts +++ b/electron/ai-edition/chat-service.toolloop.test.ts @@ -4,7 +4,10 @@ // tests mock `invokeOpenScreenAgent` directly — that's the seam chat-service // crosses to drive the agentic turn. -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { type AxcutDocument, createEmptyDocument, @@ -15,7 +18,13 @@ vi.mock("./deep-agent/service", () => ({ invokeOpenScreenAgent: vi.fn(), })); -import { createSession, rewindToMessage, runChat } from "./chat-service"; +import { + configureChatSessionStorage, + createSession, + rewindToMessage, + runChat, + selectSession, +} from "./chat-service"; import { invokeOpenScreenAgent } from "./deep-agent/service"; import type { LlmConfigStore } from "./llm-config-store"; @@ -324,6 +333,40 @@ describe("runChat tool loop", () => { }); }); +describe("persisted chat turn", () => { + let userDataPath: string; + + beforeEach(async () => { + userDataPath = await mkdtemp(path.join(tmpdir(), "openscreen-chat-turn-")); + configureChatSessionStorage(userDataPath); + }); + + afterEach(async () => { + configureChatSessionStorage(null); + await rm(userDataPath, { recursive: true, force: true }); + }); + + it("restores the transcript and its rewind checkpoint after a restart", async () => { + const projectId = "proj_restart_turn"; + const session = createSession(projectId); + const document = createEmptyDocument({ projectId, title: "Test" }); + const result = await runChat(projectId, session.id, "Trim the opening", stubConfig(), document); + expect(result.success).toBe(true); + const before = selectSession(projectId, session.id)?.messages; + expect(before?.map((message) => message.role)).toEqual(["user", "assistant"]); + + configureChatSessionStorage(userDataPath); + expect(selectSession(projectId, session.id)?.messages).toEqual(before); + const userMessageId = before?.[0]?.id; + expect(userMessageId).toBeDefined(); + const rewound = rewindToMessage(projectId, session.id, userMessageId!); + expect(rewound.success).toBe(true); + + configureChatSessionStorage(userDataPath); + expect(selectSession(projectId, session.id)?.messages).toHaveLength(1); + }); +}); + // ── D-CONSENT ─────────────────────────────────────────────────────────────── // // `allowAgentEdits` is offered in Settings as "Project edits — when off, the diff --git a/electron/ai-edition/chat-service.ts b/electron/ai-edition/chat-service.ts index 48de88dec..71bf6c8a5 100644 --- a/electron/ai-edition/chat-service.ts +++ b/electron/ai-edition/chat-service.ts @@ -1,5 +1,5 @@ -// In-memory chat service. ponytail: chat sessions live in a nested Map; the -// agentic loop itself lives in `deep-agent/service.ts` and is a port of +// Chat sessions are cached in a nested Map and saved per project to userData. +// The agentic loop itself lives in `deep-agent/service.ts` and is a port of // axcut's AxcutDeepAgentService (LangGraph stateful thread via // `createDeepAgent`). The IPC bridge streams `text` deltas + `toolStart` / // `toolEnd` lifecycle + `error` events into the renderer through the @@ -30,19 +30,28 @@ import { compactionSplitIndex, DEFAULT_BUDGET_TOKENS, } from "./chat-compaction"; +import { ChatSessionStore } from "./chat-session-store"; import type { CursorTelemetryReader } from "./deep-agent/service"; import type { DocumentService } from "./document-service"; import type { LlmConfigStore } from "./llm-config-store"; import { PROVIDER_DEFINITIONS } from "./provider-registry"; const sessionsByProject = new Map>(); +let sessionStore: ChatSessionStore | null = null; + +/** Called once when the main process registers IPC. A null path is useful in tests. */ +export function configureChatSessionStorage(userDataPath: string | null): void { + sessionStore = userDataPath ? new ChatSessionStore(userDataPath) : null; + sessionsByProject.clear(); + messageCheckpointsBySession.clear(); +} // ponytail: per-message checkpoints, stored as an ordered list per session // (insertion order == the session's message order). Each entry captures the // document *right before* the agent runs that user message — same restore // semantics as axcut's per-user-message checkpoint. The renderer surfaces a // ↩ button on every user message that has a checkpoint. -interface MessageCheckpoint { +export interface MessageCheckpoint { userMessageId: string; document: AxcutDocument; createdAt: string; @@ -116,9 +125,8 @@ function dropCheckpointsFrom( // ponytail: what compaction leaves behind. `coveredCount` counts the leading // transcript messages the summary stands in for — the transcript itself is // never rewritten, so the user keeps every message they wrote while the model -// gets the shortened list. Sessions are in-memory only; deleting the messages -// the renderer shows would be unrecoverable, and nothing in the UI would say -// it happened. +// gets the shortened list. The transcript is saved intact; compaction changes +// only what the model receives. interface SessionCompaction { summary: AiEditionChatMessage; coveredCount: number; @@ -182,18 +190,32 @@ function getProjectSessions(projectId: string): Map { let m = sessionsByProject.get(projectId); if (!m) { m = new Map(); + for (const { session, checkpoints } of sessionStore?.loadProject(projectId) ?? []) { + m.set(session.id, session); + if (checkpoints.length > 0) { + messageCheckpointsBySession.set(sessionKey(projectId, session.id), checkpoints); + } + } sessionsByProject.set(projectId, m); } return m; } +function persistProjectSessions(projectId: string): void { + if (!sessionStore) return; + const sessions = Array.from(getProjectSessions(projectId).values()).map((session) => ({ + session, + checkpoints: checkpointsForSession(projectId, session.id), + })); + sessionStore.saveProject(projectId, sessions); +} + function defaultSessionTitle(index: number): string { return `Conversation ${index}`; } export function listSessions(projectId: string): ChatSessionSummary[] { - const m = sessionsByProject.get(projectId); - if (!m) return []; + const m = getProjectSessions(projectId); return Array.from(m.values()) .map(toSummary) .sort((a, b) => a.createdAt.localeCompare(b.createdAt)); @@ -211,12 +233,17 @@ export function createSession(projectId: string, title?: string): ChatSessionSum messages: [], }; m.set(id, session); + try { + persistProjectSessions(projectId); + } catch (error) { + m.delete(id); + throw error; + } return toSummary(session); } export function selectSession(projectId: string, sessionId: string): ChatSession | null { - const m = sessionsByProject.get(projectId); - const s = m?.get(sessionId); + const s = getProjectSessions(projectId).get(sessionId); if (!s) return null; // ponytail: shallow-copy messages so the caller can't mutate the live array. // The compaction boundary stays behind: it is main-process bookkeeping, and @@ -230,14 +257,14 @@ export function selectSession(projectId: string, sessionId: string): ChatSession }; } -/** Record a generated artifact in the current in-memory conversation. */ +/** Record a generated artifact in the project conversation. */ export function appendGeneratedScene( projectId: string, sessionId: string, prompt: string, imagePath: string, ): { userMessage: AiEditionChatMessage; assistantMessage: AiEditionChatMessage } { - const session = sessionsByProject.get(projectId)?.get(sessionId); + const session = getProjectSessions(projectId).get(sessionId); if (!session) throw new Error("Chat session unavailable."); const now = new Date().toISOString(); const userMessage: AiEditionChatMessage = { @@ -255,6 +282,12 @@ export function appendGeneratedScene( generatedImagePath: imagePath, }; session.messages.push(userMessage, assistantMessage); + try { + persistProjectSessions(projectId); + } catch (error) { + session.messages.splice(-2); + throw error; + } return { userMessage, assistantMessage }; } @@ -263,21 +296,48 @@ export function renameSession( sessionId: string, title: string, ): ChatSessionSummary | null { - const m = sessionsByProject.get(projectId); - const s = m?.get(sessionId); + const s = getProjectSessions(projectId).get(sessionId); if (!s) return null; const trimmed = title.trim(); - if (trimmed) s.title = trimmed; + if (trimmed) { + const previous = s.title; + s.title = trimmed; + try { + persistProjectSessions(projectId); + } catch (error) { + s.title = previous; + throw error; + } + } return toSummary(s); } export function deleteSession(projectId: string, sessionId: string): boolean { - const m = sessionsByProject.get(projectId); - if (!m?.has(sessionId)) return false; + const m = getProjectSessions(projectId); + const session = m.get(sessionId); + if (!session) return false; + const key = sessionKey(projectId, sessionId); + const checkpoints = messageCheckpointsBySession.get(key); m.delete(sessionId); + messageCheckpointsBySession.delete(key); + try { + persistProjectSessions(projectId); + } catch (error) { + m.set(sessionId, session); + if (checkpoints) messageCheckpointsBySession.set(key, checkpoints); + throw error; + } return true; } +export function deleteProjectSessions(projectId: string): void { + sessionStore?.deleteProject(projectId); + sessionsByProject.delete(projectId); + for (const key of messageCheckpointsBySession.keys()) { + if (key.startsWith(projectId + "::")) messageCheckpointsBySession.delete(key); + } +} + export interface ChatEventSink { /** Streamed text delta from the model. */ text?: (delta: string) => void; @@ -384,11 +444,20 @@ export async function runChat( ? structuredClone(workingDocument) : null; if (documentForCheckpoint) { - recordMessageCheckpoint(projectId, sessionId, userMessage.id, documentForCheckpoint); + recordMessageCheckpoint(projectId, session.id, userMessage.id, documentForCheckpoint); } userMessage.checkpointId = documentForCheckpoint ? userMessage.id : null; session.messages.push(userMessage); + try { + // Save the submitted prompt before waiting on the provider. A process + // crash or cancelled run must not erase the user's last request. + persistProjectSessions(projectId); + } catch (error) { + dropCheckpointsFrom(projectId, session.id, userMessage.id); + session.messages.pop(); + throw error; + } const editsAllowed = config.allowAgentEdits !== false; @@ -461,6 +530,12 @@ export async function runChat( toolCalls: appliedToolCalls.length ? appliedToolCalls : undefined, }; session.messages.push(assistantMessage); + try { + persistProjectSessions(projectId); + } catch (error) { + session.messages.pop(); + throw error; + } return { success: true, @@ -496,7 +571,7 @@ export function rewindToMessage( success: false; error: string; } { - const session = sessionsByProject.get(projectId)?.get(sessionId); + const session = getProjectSessions(projectId).get(sessionId); if (!session) return { success: false, error: "Chat session not found." }; const messageIndex = session.messages.findIndex((m) => m.id === messageId); if (messageIndex === -1) { @@ -517,6 +592,10 @@ export function rewindToMessage( }; } + const previousMessages = session.messages; + const previousCompaction = session.compaction; + const checkpointKey = sessionKey(projectId, sessionId); + const previousCheckpoints = messageCheckpointsBySession.get(checkpointKey); const survived = session.messages.slice(0, messageIndex + 1).map((m) => ({ ...m, toolCalls: m.toolCalls ? [...m.toolCalls] : undefined, @@ -529,6 +608,15 @@ export function rewindToMessage( session.compaction = undefined; } dropCheckpointsFrom(projectId, sessionId, target.checkpointId); + try { + persistProjectSessions(projectId); + } catch (error) { + session.messages = previousMessages; + session.compaction = previousCompaction; + if (previousCheckpoints) messageCheckpointsBySession.set(checkpointKey, previousCheckpoints); + else messageCheckpointsBySession.delete(checkpointKey); + throw error; + } return { success: true, @@ -558,11 +646,11 @@ export async function runTimelineOperation( conversationMessage: string, documents: DocumentService, ): Promise<{ success: true; result: TimelineOperationResult } | { success: false; error: string }> { - let session = sessionsByProject.get(projectId)?.get(sessionId); + let session = getProjectSessions(projectId).get(sessionId); const created = !session; if (created) { const summary = createSession(projectId); - session = selectSession(projectId, summary.id) ?? undefined; + session = getProjectSessions(projectId).get(summary.id); if (!session) return { success: false, error: "Could not create session." }; } @@ -596,6 +684,13 @@ export async function runTimelineOperation( createdAt: new Date().toISOString(), }; session.messages.push(assistantMessage); + try { + persistProjectSessions(projectId); + } catch (error) { + // The timeline edit is already on disk. Do not report it as failed + // solely because its optional chat summary could not be saved. + console.warn("[ai-edition] could not save timeline chat summary:", error); + } } return { @@ -615,7 +710,7 @@ export async function compactSessionNow( sessionId: string, llmConfig: LlmConfigStore, ): Promise<{ summaryMessageId: string | null; summary: string; session: ChatSession } | null> { - const session = sessionsByProject.get(projectId)?.get(sessionId); + const session = getProjectSessions(projectId).get(sessionId); if (!session) return null; const config = llmConfig.getConfig(); if (!config) return null; @@ -656,7 +751,7 @@ export function getSessionContextUsage( sessionId: string, budgetTokens: number = DEFAULT_BUDGET_TOKENS, ): { usedTokens: number; budgetTokens: number; ratio: number; fillPercent: number } | null { - const session = sessionsByProject.get(projectId)?.get(sessionId); + const session = getProjectSessions(projectId).get(sessionId); if (!session) return null; // Measured on what the model is given, not on the transcript -- after a // compaction those differ. The model-window/reference denominator is selected @@ -697,7 +792,7 @@ export function getSessionBudget( sessionId: string, budgetTokens: number = DEFAULT_BUDGET_TOKENS, ): SessionBudgetSnapshot | null { - const s = sessionsByProject.get(projectId)?.get(sessionId); + const s = getProjectSessions(projectId).get(sessionId); if (!s) return null; // Same window the model is actually sent — see `getSessionContextUsage`. const snap = budgetSnapshot(modelHistory(s), budgetTokens); @@ -718,7 +813,7 @@ export async function compactSession( sessionId: string, llmConfig: LlmConfigStore, ): Promise<{ summaryMessageId: string | null; summary: string } | null> { - const session = sessionsByProject.get(projectId)?.get(sessionId); + const session = getProjectSessions(projectId).get(sessionId); if (!session) return null; const config = llmConfig.getConfig(); if (!config) return null; @@ -829,7 +924,14 @@ async function tryCompactSession(opts: { // which is the user asking again knowingly.) return null; } + const previousCompaction = session.compaction; session.compaction = { summary: summaryMessage, coveredCount: plan.coveredCount }; + try { + persistProjectSessions(session.projectId); + } catch (error) { + session.compaction = previousCompaction; + throw error; + } return { summaryMessageId: summaryMessage.id, summary, diff --git a/electron/ai-edition/chat-session-store.ts b/electron/ai-edition/chat-session-store.ts new file mode 100644 index 000000000..844a2e280 --- /dev/null +++ b/electron/ai-edition/chat-session-store.ts @@ -0,0 +1,185 @@ +// Project-scoped transcript storage. Chat text and document checkpoints stay +// under the app's userData directory; provider credentials are never written here. + +import { randomUUID } from "node:crypto"; +import fs from "node:fs"; +import path from "node:path"; +import { z } from "zod"; +import { documentSchema, migrateRawDocumentToCurrent } from "../../src/lib/ai-edition/schema"; +import type { ChatSession, MessageCheckpoint } from "./chat-service"; + +const messageSchema = z.object({ + id: z.string().min(1), + role: z.enum(["user", "assistant"]), + content: z.string(), + createdAt: z.string(), + toolCalls: z.array(z.object({ name: z.string(), summary: z.string() })).optional(), + checkpointId: z.string().nullable().optional(), + generatedImagePath: z.string().optional(), +}); + +const sessionSchema = z.object({ + id: z.string().min(1), + projectId: z.string(), + title: z.string(), + createdAt: z.string(), + messages: z.array(messageSchema), + compaction: z + .object({ summary: messageSchema, coveredCount: z.number().int().nonnegative() }) + .optional(), + checkpoints: z.array( + z.object({ + userMessageId: z.string(), + createdAt: z.string(), + document: z.unknown(), + }), + ), +}); + +const projectSchema = z.object({ + version: z.literal(1), + projectId: z.string(), + sessions: z.array(sessionSchema), +}); + +export interface StoredChatSession { + session: ChatSession; + checkpoints: MessageCheckpoint[]; +} + +function safeProjectId(projectId: string): string { + if (!/^[A-Za-z0-9_-]+$/.test(projectId)) { + throw new Error("Invalid chat project id."); + } + return projectId; +} + +function renameWithRetry(from: string, to: string): void { + const retryable = new Set(["EPERM", "EACCES", "EBUSY"]); + const sleeper = new Int32Array(new SharedArrayBuffer(4)); + for (let attempt = 0; ; attempt += 1) { + try { + fs.renameSync(from, to); + return; + } catch (error) { + const code = (error as NodeJS.ErrnoException).code ?? ""; + if (attempt >= 5 || !retryable.has(code)) throw error; + Atomics.wait(sleeper, 0, 0, 10 * 2 ** attempt); + } + } +} + +export class ChatSessionStore { + private readonly root: string; + + constructor(userDataPath: string) { + this.root = path.join(userDataPath, "chat-sessions"); + } + + private fileFor(projectId: string): string { + return path.join(this.root, safeProjectId(projectId) + ".json"); + } + + loadProject(projectId: string): StoredChatSession[] { + let raw: string; + try { + raw = fs.readFileSync(this.fileFor(projectId), "utf8"); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return []; + throw error; + } + try { + const parsed = projectSchema.parse(JSON.parse(raw)); + if (parsed.projectId !== projectId) throw new Error("Project id mismatch."); + const seen = new Set(); + return parsed.sessions.map((entry) => { + if (entry.projectId !== projectId || seen.has(entry.id)) { + throw new Error("Invalid or duplicate chat session."); + } + seen.add(entry.id); + const checkpoints: MessageCheckpoint[] = []; + for (const checkpoint of entry.checkpoints) { + try { + const document = documentSchema.parse(migrateRawDocumentToCurrent(checkpoint.document)); + if (document.project.id !== projectId) { + throw new Error("Checkpoint belongs to another project."); + } + checkpoints.push({ + userMessageId: checkpoint.userMessageId, + createdAt: checkpoint.createdAt, + document, + }); + } catch (error) { + console.warn("[ai-edition] skipped an unreadable chat checkpoint:", error); + } + } + const checkpointIds = new Set(checkpoints.map((checkpoint) => checkpoint.userMessageId)); + const messages = entry.messages.map((message) => + message.checkpointId && !checkpointIds.has(message.checkpointId) + ? { ...message, checkpointId: null } + : message, + ); + const compaction = + entry.compaction && entry.compaction.coveredCount <= messages.length + ? entry.compaction + : undefined; + return { + session: { + id: entry.id, + projectId, + title: entry.title, + createdAt: entry.createdAt, + messages, + compaction, + }, + checkpoints, + }; + }); + } catch (error) { + // Never overwrite a file we cannot parse: the owner's conversation + // may still be recoverable from the untouched bytes. + throw new Error( + "Cannot load saved conversations for " + + projectId + + ": " + + (error instanceof Error ? error.message : String(error)), + ); + } + } + + saveProject(projectId: string, sessions: StoredChatSession[]): void { + const file = this.fileFor(projectId); + fs.mkdirSync(this.root, { recursive: true }); + const temp = file + ".tmp-" + process.pid + "-" + randomUUID(); + const payload = JSON.stringify({ + version: 1, + projectId, + sessions: sessions.map(({ session, checkpoints }) => ({ ...session, checkpoints })), + }); + let fd: number | undefined; + try { + fd = fs.openSync(temp, "w"); + fs.writeFileSync(fd, payload, "utf8"); + fs.fsyncSync(fd); + fs.closeSync(fd); + fd = undefined; + renameWithRetry(temp, file); + } catch (error) { + if (fd !== undefined) fs.closeSync(fd); + try { + fs.unlinkSync(temp); + } catch { + // Nothing to remove if the write failed before opening the temp file. + } + throw error; + } + } + + deleteProject(projectId: string): void { + try { + fs.unlinkSync(this.fileFor(projectId)); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; + } + } +} diff --git a/electron/ipc/handlers.ts b/electron/ipc/handlers.ts index aaa6febfe..11fda95fd 100644 --- a/electron/ipc/handlers.ts +++ b/electron/ipc/handlers.ts @@ -40,6 +40,7 @@ import type { } from "../../src/native/contracts"; import { compactSessionNow, + configureChatSessionStorage, createSession, deleteSession, getSessionContextUsage, @@ -4161,6 +4162,7 @@ export function registerIpcHandlers( // race destroyed two real project files), so a second instance means a second // queue racing for the same path: temp+rename still keeps the file valid, but // a save can land under a concurrent one and be silently lost. + configureChatSessionStorage(app.getPath("userData")); const aiEditionDocuments = new DocumentService( path.join(app.getPath("userData"), "projects"), RECORDINGS_DIR, diff --git a/electron/native-bridge/services/aiEditionService.ts b/electron/native-bridge/services/aiEditionService.ts index bab3f6213..9d806a2b0 100644 --- a/electron/native-bridge/services/aiEditionService.ts +++ b/electron/native-bridge/services/aiEditionService.ts @@ -26,7 +26,7 @@ import { translateCaptionSegments, } from "../../ai-edition/caption-translate"; import type { ChatEventSink } from "../../ai-edition/chat-service"; -import { appendGeneratedScene } from "../../ai-edition/chat-service"; +import { appendGeneratedScene, deleteProjectSessions } from "../../ai-edition/chat-service"; import { getClaudeCli } from "../../ai-edition/claude/cli"; import { getCodexAppServer } from "../../ai-edition/codex/app-server"; import type { DocumentService } from "../../ai-edition/document-service"; @@ -156,6 +156,7 @@ export class AiEditionService { async deleteProject(projectId: string): Promise { try { await this.options.documents.deleteProject(projectId); + deleteProjectSessions(projectId); return { success: true }; } catch (error) { return { diff --git a/src/components/ai-edition/LeftPanel.providerRefresh.test.tsx b/src/components/ai-edition/LeftPanel.providerRefresh.test.tsx index 5842010ae..f2646d6ae 100644 --- a/src/components/ai-edition/LeftPanel.providerRefresh.test.tsx +++ b/src/components/ai-edition/LeftPanel.providerRefresh.test.tsx @@ -154,6 +154,38 @@ describe("ChatStripPanel streaming", () => { fireEvent.change(screen.getByRole("textbox"), { target: { value: "My prompt" } }); fireEvent.keyDown(screen.getByRole("textbox"), { key: "Enter" }); } + it("opens the newest restored conversation when a project loads", async () => { + const older = { ...session, id: "session-older", title: "Earlier" }; + const newer = { + ...session, + id: "session-newer", + title: "Latest", + messageCount: 1, + createdAt: "2026-08-28T12:00:00Z", + }; + chatListSessions.mockResolvedValue([older, newer]); + chatSelectSession.mockResolvedValue({ + ...newer, + messages: [ + { + id: "message-newer", + role: "assistant", + content: "Latest reply", + createdAt: "2026-08-28T12:01:00Z", + }, + ], + }); + useProjectStore.setState({ projectId: session.projectId }); + render( + + + , + ); + + await screen.findByText("Latest reply"); + expect(chatSelectSession).toHaveBeenCalledWith(session.projectId, newer.id); + }); + it("streams Markdown, ignores unrelated events and reconciles one final message with a working rewind ID", async () => { let finish!: (result: AiEditionChatResult) => void; chatRun.mockReturnValue( diff --git a/src/components/ai-edition/LeftPanel.tsx b/src/components/ai-edition/LeftPanel.tsx index 947595ac2..a2fe43387 100644 --- a/src/components/ai-edition/LeftPanel.tsx +++ b/src/components/ai-edition/LeftPanel.tsx @@ -632,6 +632,8 @@ function ModelQuickPopover({ function ChatStripPanel() { const t = useScopedT("editor"); + const tRef = useRef(t); + tRef.current = t; const tc = useScopedT("common"); // The Auto-enhance confirmation is timeline-owned copy, fired from here — // see the prompt-bus effect below. @@ -655,9 +657,8 @@ function ChatStripPanel() { // Mirror in a ref so refreshSessions can read the current selection without // listing activeSessionId in its deps — otherwise refreshSessions is recreated // on every selection change, which re-runs the project effect below (with - // preferFirst=true) and forces the selection back to list[0]. That feedback - // loop is what made "new conversation" jump to the oldest chat instead of the - // freshly-created empty one. + // preferNewest=true) and forces the selection back to the newest chat. That + // feedback loop would override a conversation the user just selected. const activeSessionIdRef = useRef(activeSessionId); activeSessionIdRef.current = activeSessionId; const scrollRef = useRef(null); @@ -710,7 +711,7 @@ function ChatStripPanel() { } }, []); - const refreshSessions = useCallback(async (pid: string, preferFirst = false) => { + const refreshSessions = useCallback(async (pid: string, preferNewest = false) => { try { const list = await nativeBridgeClient.aiEdition.chatListSessions(pid); if (projectIdRef.current !== pid) return; @@ -720,11 +721,13 @@ function ChatStripPanel() { setMessages([]); return; } - if (preferFirst || !list.some((s) => s.id === activeSessionIdRef.current)) { - setActiveSessionId(list[0].id); + if (preferNewest || !list.some((s) => s.id === activeSessionIdRef.current)) { + setActiveSessionId(list[list.length - 1].id); } - } catch { - // ponytail: silent — shim mode or missing project + } catch (error) { + toast.error(tRef.current("chat.chatFailed"), { + description: error instanceof Error ? error.message : String(error), + }); } }, []); @@ -829,8 +832,12 @@ function ChatStripPanel() { } else { setMessages([]); } - } catch { - // ponytail: silent — shim mode + } catch (error) { + if (!cancelled) { + toast.error(tRef.current("chat.chatFailed"), { + description: error instanceof Error ? error.message : String(error), + }); + } } })(); return () => {