Files
multica/packages/core/chat/store.ts
Naiyuan Qing 77b309a5ac feat(drafts): unified draft lifecycle + upload ownership inversion (MUL-5181) (#5900)
* feat(drafts): unified draft lifecycle + upload ownership inversion (MUL-5181)

Unify how every composer preserves unsent work, sends, and handles uploads.

L1 foundation (packages/core/drafts):
- createDraftStore factory + self-registering cleanup-registry replacing the
  hand-maintained WORKSPACE_SCOPED_KEYS list; register-all-drafts guarantees
  registration completeness. Fixes the confirmed cross-user draft leak
  (persistence + in-memory) on logout / workspace delete.

L3 send paradigm:
- useComposerSubmit: one await-then-render contract (lock/spin, keep-on-fail,
  clear-on-success, single-flight, submit-time upload-gate), adopted by
  comment/reply/edit, create-issue, quick-create, and chat.

Per-surface:
- Comment/Reply/Edit: attachments moved into the persisted draft.
- Create Issue: draft split into shared/manual/agent/activeMode with
  non-destructive mode switching + migration for old flat drafts.
- Chat: optimistic send converted to await-then-render (kept server-driven
  cancel restore_to_input); chat draft keys registered for cleanup.

L2 upload coordinator (ownership inversion, Linear-validated shape):
- upload-coordinator + DraftUpload placeholder: uploads owned by a module
  coordinator that outlives the component, state persisted in the draft;
  AbortController + abort-on-logout; interrupted-on-reload. Comment surface
  fully wired. Create-issue/chat upload wiring is a documented residual.

Verified: core + views typecheck clean; core 1064 + views 2928 tests pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(drafts): close three review gaps in the unified draft lifecycle (MUL-5181)

1. Logout resurrection: reset in-memory draft stores BEFORE removing their
   persisted keys — each reset is a setState and persist writes it straight
   back under the still-active slug, so the old order re-created the deleted
   keys. The issue draft store's reset is now a full reset including
   lastAssignee, which clearDraft deliberately re-seeds and would otherwise
   hand the previous user's last-picked assignee to the next login.

2. Submit gate blind spot: the composer gate now also reads the draft's
   coordinator-owned upload placeholders (hasUploadingDraft). A composer
   reopened over a still-in-flight upload could previously send past the
   editor-only gate, clearing the draft out from under the settling upload.

3. Attachment binding returns to reference-filtering: a submit binds only
   uploads the body references, so deleting an inline image really unbinds
   it. An upload that settles after its mount died gets its markdown link
   written back into the body instead — via the reopened composer's live
   editor (new ContentEditorRef.insertMarkdownAtEnd) or appended to the
   persisted draft (new appendToDraftContent) — so close-surviving files
   stay visible, deletable, and honestly bound.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(drafts): harden upload write-back delivery after independent review

Review of the previous commit (fresh-context reviewer + probe against real
@tiptap/react) found the write-back could still lose a file:

- insertMarkdownAtEnd now returns a boolean: the imperative handle exists
  from first commit but the Tiptap instance arrives in a passive effect, so
  an insert in that window (or after destroy) no-ops. Callers previously
  assumed it landed.
- Write-back is now confirmed delivery (deliverFinishedUpload): insert into
  the live editor and, on success, persist the same body as insurance
  against the debounced emit being dropped by a quick unmount; append to
  the store only when NO composer is mounted (a mounted editor's first emit
  would erase a store-only append); retry while a mounted composer's
  instance is still warming up. Every attempt re-checks the generation
  guard and the body reference.
- mountedRef flips in a layout effect: React nulls the child editor ref in
  the unmount commit, and a settle in the gap before passive cleanup saw
  "mounted" with no editor left to swap.
- uploadAndInsertFile guards editor.isDestroyed after the await: now that
  uploads outlive mounts, the swap/remove paths could dispatch against a
  destroyed EditorView and escape as an unhandled rejection.
- Tests: the reopened-composer test now asserts the editor actually
  received the insert (it previously passed with liveEditors disabled),
  plus a warming-up retry case; the mock editor mirrors isDestroyed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(drafts): roll coordinated uploads out to issue-create and chat (MUL-5181 L2)

Completes the upload-ownership layer for every composer surface. The generic
engine is extracted from the comment implementation into
editor/use-coordinated-uploads (UploadDraftBinding adapter: store-backed
accessors + registry key + body append), and use-comment-uploads becomes a
thin binding over it — behavior unchanged, all comment tests green.

Issue-create (manual + agent panels):
- shared.attachments migrates Attachment[] -> DraftUpload[]; load normalizes
  legacy bare rows to `uploaded` and coerces stale `uploading` to
  `interrupted`.
- Uploads are coordinator-owned: placeholder at pick time, survives dialog
  close, aborts on logout, chips for uploading/failed/interrupted, combined
  gate on Create and both mode-switch actions.
- Write-back targets the body of the MODE that started the upload (manual
  description vs agent prompt); mount-time prune keeps placeholders and drops
  only unreferenced `uploaded` entries.

Chat (tab + floating window):
- inputDraftAttachments migrates to DraftUpload[] with load-time
  normalization; new store ops (add/settle/fail/remove upload, append-to-
  draft) mirror the comment store.
- ChatInput adopts the engine; the upload target is snapshotted at pick time
  via resolveUploadTarget so a file dropped while the editor is pinned to a
  previous session's document files under THAT draft.
- uploadMapRef is gone — the draft's uploads are the single binding source,
  reference-filtered at send. Hosts no longer own transport: onUploadFile
  prop becomes uploadEnabled, and the controller/window drop uploadWithToast.
- commitDraft prunes only `uploaded` entries the body no longer references;
  placeholders survive keystrokes (chips are their only UI).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(drafts): harden L2 rollout after independent review

- attachmentToDraftUpload now strips the response-scoped signed download_url
  before the row is persisted (draft uploads survive restarts; a stale
  signature 403s the preview on reopen). Covers comments, issue-create, and
  chat in one place; issue-create's settle reuses the helper, and the
  Signature assertion the rollout had dropped is restored.
- chat's live-editor registry follows the LOADED draft key (reactive mirror
  of editorDraftKeyRef): a settle for draft B must not insert into an editor
  still pinned to draft A's document.
- removeUpload aborts an in-flight request before dropping its placeholder.
- issue-create hasDraft counts only uploaded/uploading entries so a failed
  remnant can't pin the sidebar draft dot forever.
- Tests: mutation-proof coverage for the two placeholder-preservation rules
  (create-issue mount prune, chat commitDraft prune) — both previously
  survived rule inversion; direct core tests for the five new chat store
  upload ops incl. persistence and signed-URL stripping; quick-create test
  gets the editor i18n namespace; dead uploadWithToast scaffolding removed
  from both modal tests; chat-input mock aligned with the real append
  semantics.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(drafts): close third-round review gaps in the upload engine

- The live-editor registry registers in a layout effect: chat's adopt swaps
  the editor's document and loaded key synchronously during commit, and a
  passive re-registration one task later left a settle window where the old
  key mapped to an editor already holding another draft's document. The
  registry key is also built only when a binding exists.
- removeUpload aborts only a request THIS surface tracks as `uploading`
  (guarded before the abort), with the comment now honest about the path
  being defensive — no current chip exposes ✕ mid-upload.
- Mutation-proof test for the loaded-key registry rule: a dead mount's
  settle for a pinned draft must insert into the editor HOLDING it, not the
  selected one (verified to fail with the registry keyed by selection).
- hasDraft upload semantics pinned by tests (uploaded/uploading count;
  failed/interrupted remnants don't pin the sidebar dot).
- Dead scaffolding dropped: identity use-file-upload mocks and a redundant
  assertion in the modal tests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(drafts): stale-submit draft guard + registry layout timing (review BLOCKED items)

Blocker 1 — a submit that outlives its composer may only consume the draft
it submitted (MUL-5181 P0). Every accepted-submit clear is now guarded:
- create-issue / quick-create snapshot the singleton draft's object identity
  at submit; a dead panel clears (and records last-assignee/mode) only if
  the draft is untouched, and never runs close/reset effects. A replaced
  draft B typed after close survives a late success of draft A.
- comment / reply / edit snapshot the per-key draft entry; a dead composer
  clears only the exact entry it submitted.
- chat snapshots the sent slot's value; a dead mount's commitInput clears
  only an unreplaced draft.
Mutation-verified tests for the create panels and comments (guard inverted
=> tests fail), plus untouched-draft control cases.

Blocker 2 — the live-editor registry is now genuinely registered in a
layout effect. The prior commit claimed this fix but a test-time
`git checkout --` discarded the unstaged engine edits before committing;
re-applied: layout registration, binding-gated registry key, and the
tracked-only abort in removeUpload. New registry timing test captures the
registry from a parent layout effect across a key switch — verified to
fail with passive registration.

Also: `multica:chat:selectedProjectId` joins the workspace-scoped cleanup
list (was leaking across logout; flagged as a pre-existing risk).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(drafts): mounted submits also clear only the draft they submitted

The stale-submit snapshot guard previously protected only dead composers;
a mounted one cleared unconditionally on success. But the editor stays
interactive during a request (Tiptap cannot toggle editable post-mount), so
text typed while draft A was in flight was wiped by A's success. The guard
is now unconditional across every surface: success consumes exactly the
submitted snapshot, and any later edit survives.

- create-issue / quick-create: the editor's pending debounce is flushed into
  the store BEFORE snapshotting (a late flush of pre-submit typing must not
  read as a mid-flight edit); a touched draft skips clear AND close/reset —
  the dialog stays open on the newer work. Untouched behavior unchanged.
- comment / reply / edit: same flush + snapshot; a touched entry keeps both
  the store draft and the editor content (edit mode stays open on it).
- chat: commitInput's value compare now applies while mounted too, and the
  editor is scrubbed only for an untouched draft.
- use-composer-submit docs no longer claim "editor locked": they state the
  real contract — send affordance locks, edits after submit survive.

Regression tests: mounted mid-flight-edit cases for manual create (incl.
"dialog must not close over draft B"), quick create, comment, and chat,
plus mounted-untouched controls.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(drafts): idempotent draft writes so a tab switch cannot resurrect a posted comment

Final-review blocker: the comment/reply visibilitychange/pagehide flush
re-writes IDENTICAL content on every tab switch, and writeDraft minted a
new entry object each call — the stale-submit guard's identity compare then
read a mid-flight tab switch as "edited during the request", kept the
posted comment's draft alive, and left Send enabled for a duplicate.

- writeDraft is now a no-op when content and uploads are unchanged (also
  kills a spurious persist write per tab switch). Regression tests: entry
  identity preserved on identical setDraft (core), and the reproduced
  tab-switch-mid-send scenario clears the posted draft (views) — verified
  to fail with the idempotence removed.
- onAccepted now flushes the editor's pending debounce before judging
  `untouched` on every surface, so typing still inside the debounce window
  counts as a mid-flight edit instead of being scrubbed.
- create-issue records last-assignee/mode from the SUBMITTED values,
  outside the untouched gate — a created issue updates the preference even
  when the dialog stays open on newer edits.
- Stale guard comments corrected in both create panels; the
  use-composer-submit docstring no longer claims project/feedback were
  migrated (they still hand-roll await-then-clear; registered debt).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-28 10:10:00 +08:00

685 lines
29 KiB
TypeScript

import { create } from "zustand";
import type { StorageAdapter } from "../types";
import type { Attachment } from "../types/attachment";
import { getCurrentSlug, registerForWorkspaceRehydration } from "../platform/workspace-storage";
import { registerDraftCleanup } from "../drafts/cleanup-registry";
import {
normalizeStoredUploads,
attachmentToDraftUpload,
type DraftUpload,
type PendingDraftUpload,
} from "../drafts/draft-upload";
import { createLogger } from "../logger";
const logger = createLogger("chat.store");
const AGENT_STORAGE_KEY = "multica:chat:selectedAgentId";
const PROJECT_STORAGE_KEY = "multica:chat:selectedProjectId";
const SESSION_STORAGE_KEY = "multica:chat:activeSessionId";
/** Drafts are stored as one JSON blob per workspace: { [sessionId]: text }. */
const DRAFTS_KEY = "multica:chat:drafts";
/** Draft attachment records per workspace: { [sessionId]: Attachment[] }. */
const DRAFT_ATTACHMENTS_KEY = "multica:chat:draft-attachments";
/**
* Ids of durable draft restores (#5219) this client has already written into a
* composer. Persisted, because the server-side consume that follows can be lost
* (retries exhausted, the app closed mid-flight) and the row would then be
* re-offered on the next fetch — re-restoring a prompt the user has since sent.
* The ledger makes the hand-off at-most-once regardless: an id in here is never
* offered again, only reconciled (consumed again) until the row is gone.
*/
const APPLIED_RESTORES_KEY = "multica:chat:applied-draft-restores";
/**
* Local restore requests waiting to reach a composer, queued per session (#5219).
*
* These are the restores with NO server copy — a send that failed, or a cancel
* that answered synchronously. The send already cleared the persisted draft, so
* this queue is the only place their text exists. It is persisted for exactly
* that reason: a request the composer cannot act on yet (the user is looking at
* another session, or has work in progress in this one) must survive an unmount,
* a refresh, or a close, and be re-offered when they come back.
*
* Durable restores (which have a server row) deliberately do NOT go in here —
* they are refetchable, so dropping one loses nothing.
*/
const PENDING_SEND_RESTORES_KEY = "multica:chat:pending-send-restores";
/**
* Draft slot for a chat that hasn't been created yet. There is exactly one per
* workspace: the new-chat composer's identity is "the chat I have not created",
* not "the chat I have not created with agent X". `selectedAgentId` is the send
* target, not draft ownership, so switching agent mid-compose keeps the text
* (MUL-4864). Created sessions keep their own slot, keyed by session id.
*/
export const DRAFT_NEW_SESSION = "__new__";
/** Pre-MUL-4864 per-agent new-chat slots, shaped `__new__:<agentId>`. */
const LEGACY_NEW_SESSION_PREFIX = `${DRAFT_NEW_SESSION}:`;
const CHAT_WIDTH_KEY = "multica:chat:width";
const CHAT_HEIGHT_KEY = "multica:chat:height";
const CHAT_EXPANDED_KEY = "multica:chat:expanded";
/**
* Open/closed preference, persisted globally (not per-workspace) — most users
* have one habitual chat-panel preference across workspaces. Missing key =
* new user (or cleared storage); default to CLOSED so opening a workspace
* never pops the chat window uninvited (the FAB keeps it discoverable).
* Once the user toggles even once, their explicit choice is respected on
* every subsequent reload.
*/
const OPEN_KEY = "multica:chat:isOpen";
/**
* Whether the floating chat window (FAB + overlay) is available at all,
* persisted globally like OPEN_KEY. This is the Settings → Chat preference:
* when off, the FAB/overlay never mount and Chat lives only in its tab.
* Missing key = default ON — the floating window is on by default and can
* be turned off from the Settings → Chat tab.
*/
const FLOATING_KEY = "multica:chat:floatingChatEnabled";
function readDrafts(storage: StorageAdapter, key: string): Record<string, string> {
const raw = storage.getItem(key);
if (!raw) return {};
try {
const parsed = JSON.parse(raw);
return typeof parsed === "object" && parsed !== null ? parsed : {};
} catch {
return {};
}
}
function writeDrafts(storage: StorageAdapter, key: string, drafts: Record<string, string>) {
// Prune empty entries so the blob doesn't grow unbounded.
const pruned: Record<string, string> = {};
for (const [k, v] of Object.entries(drafts)) {
if (v) pruned[k] = v;
}
if (Object.keys(pruned).length === 0) {
storage.removeItem(key);
} else {
storage.setItem(key, JSON.stringify(pruned));
}
}
/** Shape check for server Attachment rows inside persisted restores. */
function isAttachmentDraft(value: unknown): value is Attachment {
return (
typeof value === "object" &&
value !== null &&
typeof (value as { id?: unknown }).id === "string" &&
typeof (value as { filename?: unknown }).filename === "string"
);
}
function readDraftAttachments(storage: StorageAdapter, key: string): Record<string, DraftUpload[]> {
const raw = storage.getItem(key);
if (!raw) return {};
try {
const parsed = JSON.parse(raw);
if (typeof parsed !== "object" || parsed === null) return {};
const out: Record<string, DraftUpload[]> = {};
for (const [draftKey, value] of Object.entries(parsed)) {
if (!Array.isArray(value)) continue;
// Normalize on every load (MUL-5181 L2): bare Attachment rows persisted
// by pre-L2 builds become `uploaded` placeholders, and an upload still
// `uploading` at load time is coerced to `interrupted` (bytes are gone).
const uploads = normalizeStoredUploads(value);
if (uploads.length > 0) out[draftKey] = uploads;
}
return out;
} catch {
return {};
}
}
function readAppliedRestores(storage: StorageAdapter, key: string): string[] {
const raw = storage.getItem(key);
if (!raw) return [];
try {
const parsed = JSON.parse(raw);
if (!Array.isArray(parsed)) return [];
return parsed.filter((id): id is string => typeof id === "string");
} catch {
return [];
}
}
function writeAppliedRestores(storage: StorageAdapter, key: string, ids: string[]) {
if (ids.length === 0) storage.removeItem(key);
else storage.setItem(key, JSON.stringify(ids));
}
function isPendingSendRestore(value: unknown): value is PendingSendRestore {
if (typeof value !== "object" || value === null) return false;
const v = value as { id?: unknown; content?: unknown; sessionId?: unknown };
return (
typeof v.id === "string" && typeof v.content === "string" && typeof v.sessionId === "string"
);
}
function readPendingSendRestores(
storage: StorageAdapter,
key: string,
): Record<string, PendingSendRestore[]> {
const raw = storage.getItem(key);
if (!raw) return {};
try {
const parsed = JSON.parse(raw);
if (typeof parsed !== "object" || parsed === null) return {};
const out: Record<string, PendingSendRestore[]> = {};
for (const [sessionId, value] of Object.entries(parsed)) {
if (!Array.isArray(value)) continue;
const queued = value.filter(isPendingSendRestore).map((r) => ({
...r,
attachments: Array.isArray(r.attachments) ? r.attachments.filter(isAttachmentDraft) : [],
}));
if (queued.length > 0) out[sessionId] = queued;
}
return out;
} catch {
return {};
}
}
function writePendingSendRestores(
storage: StorageAdapter,
key: string,
queues: Record<string, PendingSendRestore[]>,
) {
const pruned: Record<string, PendingSendRestore[]> = {};
for (const [k, v] of Object.entries(queues)) {
if (v.length > 0) pruned[k] = v;
}
if (Object.keys(pruned).length === 0) storage.removeItem(key);
else storage.setItem(key, JSON.stringify(pruned));
}
function writeDraftAttachments(
storage: StorageAdapter,
key: string,
drafts: Record<string, DraftUpload[]>,
) {
const pruned: Record<string, DraftUpload[]> = {};
for (const [k, v] of Object.entries(drafts)) {
if (v.length > 0) pruned[k] = v;
}
if (Object.keys(pruned).length === 0) {
storage.removeItem(key);
} else {
storage.setItem(key, JSON.stringify(pruned));
}
}
/**
* Fold the legacy per-agent new-chat slots into the single DRAFT_NEW_SESSION
* slot, then drop them.
*
* The legacy slots carry no timestamp, so when several exist there is no way to
* tell which one the user typed last — and "keep them all" has nowhere to put
* the losers now that there is one composer. Adopt the slot belonging to the
* persisted `selectedAgentId` (the draft this workspace would have shown on
* open, so the only one the user can be expecting) and discard the rest: those
* extra slots ARE the invisible multi-draft state this change removes.
*
* Both write paths prune empty values, so key presence means content.
* Idempotent: once the legacy keys are gone this is an allocation-free no-op.
*/
function migrateLegacyNewChatSlots<T>(
slots: Record<string, T>,
selectedAgentId: string | null,
): { slots: Record<string, T>; changed: boolean } {
const legacyKeys = Object.keys(slots).filter((k) => k.startsWith(LEGACY_NEW_SESSION_PREFIX));
if (legacyKeys.length === 0) return { slots, changed: false };
const next = { ...slots };
const adopted = next[`${LEGACY_NEW_SESSION_PREFIX}${selectedAgentId ?? ""}`];
// Never clobber the unified slot: whatever is in it was written under the
// current scheme and is therefore newer than any legacy leftover.
if (!(DRAFT_NEW_SESSION in next) && adopted !== undefined) {
next[DRAFT_NEW_SESSION] = adopted;
}
for (const key of legacyKeys) delete next[key];
logger.info("migrating legacy per-agent new-chat drafts", {
legacyCount: legacyKeys.length,
selectedAgentId,
adopted: DRAFT_NEW_SESSION in next,
});
return { slots: next, changed: true };
}
/**
* Read both draft maps and migrate them together, against the same
* `selectedAgentId` — text and attachments must never disagree on which legacy
* new-chat draft survived, or the user gets agent A's words with agent B's
* files.
*/
function loadDraftSlots(
storage: StorageAdapter,
draftsKey: string,
attachmentsKey: string,
selectedAgentId: string | null,
): { inputDrafts: Record<string, string>; inputDraftAttachments: Record<string, DraftUpload[]> } {
const drafts = migrateLegacyNewChatSlots(readDrafts(storage, draftsKey), selectedAgentId);
const attachments = migrateLegacyNewChatSlots(
readDraftAttachments(storage, attachmentsKey),
selectedAgentId,
);
if (drafts.changed) writeDrafts(storage, draftsKey, drafts.slots);
if (attachments.changed) writeDraftAttachments(storage, attachmentsKey, attachments.slots);
return { inputDrafts: drafts.slots, inputDraftAttachments: attachments.slots };
}
export const CHAT_MIN_W = 360;
export const CHAT_MIN_H = 480;
export const CHAT_DEFAULT_W = 380;
export const CHAT_DEFAULT_H = 600;
/**
* Kept as a public type because existing consumers (chat-message-list,
* views/chat types) import it. Items themselves no longer live in the
* store — they flow through the React Query cache keyed by task id.
*/
export interface ChatTimelineItem {
seq: number;
type: "tool_use" | "tool_result" | "thinking" | "text" | "error";
tool?: string;
content?: string;
input?: Record<string, unknown>;
output?: string;
created_at?: string;
}
/**
* A restore with no server copy, waiting for a composer that can take it. Its
* text exists nowhere else, so it lives in persisted storage until it is applied
* (see PENDING_SEND_RESTORES_KEY).
*/
export interface PendingSendRestore {
id: string;
content: string;
attachments?: Attachment[];
/** The session whose composer this belongs to. Never empty. */
sessionId: string;
}
export interface ChatState {
isOpen: boolean;
/** Settings preference: is the floating chat window available at all. */
floatingChatEnabled: boolean;
activeSessionId: string | null;
selectedAgentId: string | null;
/** Project context for the next session. Existing sessions remain bound to
* their server-persisted project_id. */
selectedProjectId: string | null;
/** Drafts per session: sessionId (or DRAFT_NEW_SESSION) → markdown text. */
inputDrafts: Record<string, string>;
/** Attachment rows referenced by each input draft. */
/** Coordinator-owned uploads per draft slot (placeholders + completed). */
inputDraftAttachments: Record<string, DraftUpload[]>;
/** Durable draft restores already written into a composer (#5219). */
appliedDraftRestoreIds: string[];
/** Server-less restores waiting for their session's composer, per session (#5219). */
pendingSendRestores: Record<string, PendingSendRestore[]>;
/** Raw user-chosen size — no clamp applied. UI layer clamps at render time. */
chatWidth: number;
chatHeight: number;
isExpanded: boolean;
setOpen: (open: boolean) => void;
toggle: () => void;
setFloatingChatEnabled: (enabled: boolean) => void;
setActiveSession: (id: string | null) => void;
setSelectedAgentId: (id: string) => void;
setSelectedProjectId: (id: string | null) => void;
/** sessionId accepts a real session UUID or DRAFT_NEW_SESSION. */
setInputDraft: (sessionId: string, draft: string) => void;
/** Append a markdown fragment to a draft slot's text (upload write-back). */
appendToInputDraft: (sessionId: string, markdown: string) => void;
setInputDraftAttachments: (sessionId: string, uploads: DraftUpload[]) => void;
/** Record a completed server row as an uploaded entry (restore paths). */
addInputDraftAttachment: (sessionId: string, attachment: Attachment) => void;
/** Record a placeholder the moment a file is picked (coordinator-owned). */
addInputDraftUpload: (sessionId: string, upload: DraftUpload) => void;
/** Swap a placeholder for its completed attachment. No-op if it's gone. */
settleInputDraftUpload: (sessionId: string, clientUploadId: string, attachment: Attachment) => void;
/** Mark a placeholder failed. No-op if it's gone. */
failInputDraftUpload: (sessionId: string, clientUploadId: string, error?: string) => void;
/** Drop a placeholder (dismiss a failure / interrupted). */
removeInputDraftUpload: (sessionId: string, clientUploadId: string) => void;
clearInputDraft: (sessionId: string) => void;
/** Record that a durable restore reached the composer; survives a reload. */
markDraftRestoreApplied: (restoreId: string) => void;
/** Drop the ledger entry once the server row is confirmed gone. */
forgetDraftRestoreApplied: (restoreId: string) => void;
/** Queue a server-less restore for its session; survives unmount/refresh. */
enqueuePendingSendRestore: (restore: PendingSendRestore) => void;
/** Drop a queued restore once its text is safely in the (persisted) draft. */
dequeuePendingSendRestore: (sessionId: string, restoreId: string) => void;
/** Persist raw size and auto-exit expanded mode. */
setChatSize: (width: number, height: number) => void;
setExpanded: (expanded: boolean) => void;
}
export interface ChatStoreOptions {
storage: StorageAdapter;
}
export function createChatStore(options: ChatStoreOptions) {
const { storage } = options;
const wsKey = (base: string) => {
const slug = getCurrentSlug();
return slug ? `${base}:${slug}` : base;
};
// Resolve initial isOpen from storage. The three-state read (null /
// "true" / "false") keeps the "new user → closed" default while still
// honouring an explicit "I opened it" choice on every reload.
const storedOpen = storage.getItem(OPEN_KEY);
const initialIsOpen = storedOpen === "true";
// Default ON: the floating window is enabled unless the user explicitly
// turned it off ("false") from the Settings → Chat tab. A missing key
// (new user) resolves to enabled.
const initialFloatingEnabled = storage.getItem(FLOATING_KEY) !== "false";
const initialAgentId = storage.getItem(wsKey(AGENT_STORAGE_KEY));
const initialDraftSlots = loadDraftSlots(
storage,
wsKey(DRAFTS_KEY),
wsKey(DRAFT_ATTACHMENTS_KEY),
initialAgentId,
);
const store = create<ChatState>((set, get) => ({
isOpen: initialIsOpen,
floatingChatEnabled: initialFloatingEnabled,
activeSessionId: storage.getItem(wsKey(SESSION_STORAGE_KEY)),
selectedAgentId: initialAgentId,
selectedProjectId: storage.getItem(wsKey(PROJECT_STORAGE_KEY)),
inputDrafts: initialDraftSlots.inputDrafts,
inputDraftAttachments: initialDraftSlots.inputDraftAttachments,
appliedDraftRestoreIds: readAppliedRestores(storage, wsKey(APPLIED_RESTORES_KEY)),
pendingSendRestores: readPendingSendRestores(storage, wsKey(PENDING_SEND_RESTORES_KEY)),
chatWidth: Number(storage.getItem(CHAT_WIDTH_KEY)) || CHAT_DEFAULT_W,
chatHeight: Number(storage.getItem(CHAT_HEIGHT_KEY)) || CHAT_DEFAULT_H,
isExpanded: storage.getItem(wsKey(CHAT_EXPANDED_KEY)) === "true",
setOpen: (open) => {
logger.debug("setOpen", { from: get().isOpen, to: open });
storage.setItem(OPEN_KEY, String(open));
set({ isOpen: open });
},
toggle: () => {
const next = !get().isOpen;
logger.debug("toggle", { to: next });
storage.setItem(OPEN_KEY, String(next));
set({ isOpen: next });
},
setFloatingChatEnabled: (enabled) => {
logger.info("setFloatingChatEnabled", { to: enabled });
storage.setItem(FLOATING_KEY, String(enabled));
// Turning the feature off should also collapse an open overlay so it
// does not linger until the next toggle.
set(enabled ? { floatingChatEnabled: true } : { floatingChatEnabled: false, isOpen: false });
if (!enabled) storage.setItem(OPEN_KEY, "false");
},
setActiveSession: (id) => {
logger.info("setActiveSession", { from: get().activeSessionId, to: id });
if (id) {
storage.setItem(wsKey(SESSION_STORAGE_KEY), id);
} else {
storage.removeItem(wsKey(SESSION_STORAGE_KEY));
}
set({ activeSessionId: id });
},
setSelectedAgentId: (id) => {
logger.info("setSelectedAgentId", { from: get().selectedAgentId, to: id });
storage.setItem(wsKey(AGENT_STORAGE_KEY), id);
set({ selectedAgentId: id });
},
setSelectedProjectId: (id) => {
logger.info("setSelectedProjectId", { from: get().selectedProjectId, to: id });
if (id) storage.setItem(wsKey(PROJECT_STORAGE_KEY), id);
else storage.removeItem(wsKey(PROJECT_STORAGE_KEY));
set({ selectedProjectId: id });
},
// Append-only until the server confirms. There is deliberately no capacity
// cap: every entry in here is an UNconfirmed consume, and evicting one
// silently re-arms the restore it was suppressing — the row is still on the
// server, so the next fetch would offer an already-applied prompt again and
// the user could send it twice. Entries leave only through
// forgetDraftRestoreApplied, i.e. only once the row is provably gone, which
// bounds the ledger by the number of restores whose consume is still failing.
markDraftRestoreApplied: (restoreId) => {
const current = get().appliedDraftRestoreIds;
if (current.includes(restoreId)) return;
const next = [...current, restoreId];
writeAppliedRestores(storage, wsKey(APPLIED_RESTORES_KEY), next);
set({ appliedDraftRestoreIds: next });
},
/** Called only on a confirmed consume: the server row is gone. */
forgetDraftRestoreApplied: (restoreId) => {
const current = get().appliedDraftRestoreIds;
if (!current.includes(restoreId)) return;
const next = current.filter((id) => id !== restoreId);
writeAppliedRestores(storage, wsKey(APPLIED_RESTORES_KEY), next);
set({ appliedDraftRestoreIds: next });
},
// Queued per session, not in one shared slot: a request for a session the
// user is not looking at must never hold the composer's only restore slot
// (it would starve the session they ARE looking at), and it has no server
// copy to fall back on, so it cannot simply be dropped either. FIFO, so two
// failures against the same session are both recovered, oldest first.
enqueuePendingSendRestore: (restore) => {
if (!restore.sessionId || !restore.id) return;
const current = get().pendingSendRestores;
const existing = current[restore.sessionId] ?? [];
if (existing.some((r) => r.id === restore.id)) return;
logger.info("enqueuePendingSendRestore", {
sessionId: restore.sessionId,
restoreId: restore.id,
});
const next = { ...current, [restore.sessionId]: [...existing, restore] };
writePendingSendRestores(storage, wsKey(PENDING_SEND_RESTORES_KEY), next);
set({ pendingSendRestores: next });
},
/** Only after the text has landed in the draft, which is itself persisted. */
dequeuePendingSendRestore: (sessionId, restoreId) => {
const current = get().pendingSendRestores;
const existing = current[sessionId];
if (!existing?.some((r) => r.id === restoreId)) return;
logger.info("dequeuePendingSendRestore", { sessionId, restoreId });
const remaining = existing.filter((r) => r.id !== restoreId);
const next = { ...current };
if (remaining.length > 0) next[sessionId] = remaining;
else delete next[sessionId];
writePendingSendRestores(storage, wsKey(PENDING_SEND_RESTORES_KEY), next);
set({ pendingSendRestores: next });
},
setInputDraft: (sessionId, draft) => {
// Debug level — onUpdate fires on every keystroke.
logger.debug("setInputDraft", { sessionId, length: draft.length });
const next = { ...get().inputDrafts, [sessionId]: draft };
writeDrafts(storage, wsKey(DRAFTS_KEY), next);
set({ inputDrafts: next });
},
appendToInputDraft: (sessionId, markdown) => {
const existing = get().inputDrafts[sessionId] ?? "";
const draft = existing.trim()
? `${existing.replace(/\s+$/, "")}\n\n${markdown}`
: markdown;
logger.debug("appendToInputDraft", { sessionId, length: draft.length });
const next = { ...get().inputDrafts, [sessionId]: draft };
writeDrafts(storage, wsKey(DRAFTS_KEY), next);
set({ inputDrafts: next });
},
setInputDraftAttachments: (sessionId, uploads) => {
logger.debug("setInputDraftAttachments", { sessionId, count: uploads.length });
const next = { ...get().inputDraftAttachments };
if (uploads.length > 0) next[sessionId] = uploads;
else delete next[sessionId];
writeDraftAttachments(storage, wsKey(DRAFT_ATTACHMENTS_KEY), next);
set({ inputDraftAttachments: next });
},
addInputDraftAttachment: (sessionId, attachment) => {
if (!attachment.id) return;
const current = get().inputDraftAttachments;
const existing = current[sessionId] ?? [];
const wrapped = attachmentToDraftUpload(attachment);
const nextForKey = existing.some(
(u) => u.status === "uploaded" && u.attachment.id === attachment.id,
)
? existing.map((u) =>
u.status === "uploaded" && u.attachment.id === attachment.id ? wrapped : u,
)
: [...existing, wrapped];
const next = { ...current, [sessionId]: nextForKey };
writeDraftAttachments(storage, wsKey(DRAFT_ATTACHMENTS_KEY), next);
set({ inputDraftAttachments: next });
},
addInputDraftUpload: (sessionId, upload) => {
const current = get().inputDraftAttachments;
const existing = current[sessionId] ?? [];
if (existing.some((u) => u.clientUploadId === upload.clientUploadId)) return;
const next = { ...current, [sessionId]: [...existing, upload] };
writeDraftAttachments(storage, wsKey(DRAFT_ATTACHMENTS_KEY), next);
set({ inputDraftAttachments: next });
},
settleInputDraftUpload: (sessionId, clientUploadId, attachment) => {
const current = get().inputDraftAttachments;
const existing = current[sessionId] ?? [];
if (!existing.some((u) => u.clientUploadId === clientUploadId)) return;
const nextForKey = existing.map((u) =>
u.clientUploadId === clientUploadId
? { ...attachmentToDraftUpload(attachment), clientUploadId }
: u,
);
const next = { ...current, [sessionId]: nextForKey };
writeDraftAttachments(storage, wsKey(DRAFT_ATTACHMENTS_KEY), next);
set({ inputDraftAttachments: next });
},
failInputDraftUpload: (sessionId, clientUploadId, error) => {
const current = get().inputDraftAttachments;
const existing = current[sessionId] ?? [];
const target = existing.find((u) => u.clientUploadId === clientUploadId);
if (!target) return;
const failed: PendingDraftUpload = {
clientUploadId,
status: "failed",
filename: target.filename,
size: target.size,
contentType: target.contentType,
error,
};
const nextForKey = existing.map((u) => (u.clientUploadId === clientUploadId ? failed : u));
const next = { ...current, [sessionId]: nextForKey };
writeDraftAttachments(storage, wsKey(DRAFT_ATTACHMENTS_KEY), next);
set({ inputDraftAttachments: next });
},
removeInputDraftUpload: (sessionId, clientUploadId) => {
const current = get().inputDraftAttachments;
const existing = current[sessionId] ?? [];
if (!existing.some((u) => u.clientUploadId === clientUploadId)) return;
const remaining = existing.filter((u) => u.clientUploadId !== clientUploadId);
const next = { ...current };
if (remaining.length > 0) next[sessionId] = remaining;
else delete next[sessionId];
writeDraftAttachments(storage, wsKey(DRAFT_ATTACHMENTS_KEY), next);
set({ inputDraftAttachments: next });
},
clearInputDraft: (sessionId) => {
const currentDrafts = get().inputDrafts;
const currentAttachments = get().inputDraftAttachments;
if (!(sessionId in currentDrafts) && !(sessionId in currentAttachments)) {
logger.debug("clearInputDraft skipped (no draft)", { sessionId });
return;
}
logger.info("clearInputDraft", { sessionId });
const nextDrafts = { ...currentDrafts };
const nextAttachments = { ...currentAttachments };
delete nextDrafts[sessionId];
delete nextAttachments[sessionId];
writeDrafts(storage, wsKey(DRAFTS_KEY), nextDrafts);
writeDraftAttachments(storage, wsKey(DRAFT_ATTACHMENTS_KEY), nextAttachments);
set({ inputDrafts: nextDrafts, inputDraftAttachments: nextAttachments });
},
setChatSize: (w, h) => {
logger.debug("setChatSize", { w, h });
storage.setItem(CHAT_WIDTH_KEY, String(w));
storage.setItem(CHAT_HEIGHT_KEY, String(h));
// Dragging = user chose a manual size → exit expanded mode
storage.removeItem(wsKey(CHAT_EXPANDED_KEY));
set({ chatWidth: w, chatHeight: h, isExpanded: false });
},
setExpanded: (expanded) => {
logger.info("setExpanded", { to: expanded });
if (expanded) {
storage.setItem(wsKey(CHAT_EXPANDED_KEY), "true");
} else {
storage.removeItem(wsKey(CHAT_EXPANDED_KEY));
}
set({ isExpanded: expanded });
},
}));
// Self-register the chat draft persist keys so logout / workspace-delete
// clear them like every other draft store (previously leaked — the chat
// drafts, their attachments, and the applied-restore ledger survived a
// client-side logout into the next login on the same tab). All are
// workspace-scoped (persisted through `wsKey`, i.e. `${base}:${slug}`), and
// each entry resets only its own in-memory slice. The server-less restore
// queue is registered too so its recoverable text does not outlive the user.
registerDraftCleanup({
storageKey: DRAFTS_KEY,
workspaceScoped: true,
resetInMemory: () => store.setState({ inputDrafts: {} }),
});
registerDraftCleanup({
storageKey: DRAFT_ATTACHMENTS_KEY,
workspaceScoped: true,
resetInMemory: () => store.setState({ inputDraftAttachments: {} }),
});
registerDraftCleanup({
storageKey: APPLIED_RESTORES_KEY,
workspaceScoped: true,
resetInMemory: () => store.setState({ appliedDraftRestoreIds: [] }),
});
registerDraftCleanup({
storageKey: PENDING_SEND_RESTORES_KEY,
workspaceScoped: true,
resetInMemory: () => store.setState({ pendingSendRestores: {} }),
});
registerForWorkspaceRehydration(() => {
const nextSession = storage.getItem(wsKey(SESSION_STORAGE_KEY));
const nextAgent = storage.getItem(wsKey(AGENT_STORAGE_KEY));
const nextProject = storage.getItem(wsKey(PROJECT_STORAGE_KEY));
// Drafts are namespaced per workspace, so the workspace being switched TO
// has its own legacy slots to fold — migrate against that workspace's own
// persisted agent, not the one we are leaving.
const { inputDrafts: nextDrafts, inputDraftAttachments: nextDraftAttachments } = loadDraftSlots(
storage,
wsKey(DRAFTS_KEY),
wsKey(DRAFT_ATTACHMENTS_KEY),
nextAgent,
);
logger.info("workspace rehydration", {
prevSession: store.getState().activeSessionId,
nextSession,
prevAgent: store.getState().selectedAgentId,
nextAgent,
prevProject: store.getState().selectedProjectId,
nextProject,
draftCount: Object.keys(nextDrafts).length,
draftAttachmentCount: Object.keys(nextDraftAttachments).length,
});
store.setState({
activeSessionId: nextSession,
selectedAgentId: nextAgent,
selectedProjectId: nextProject,
inputDrafts: nextDrafts,
inputDraftAttachments: nextDraftAttachments,
appliedDraftRestoreIds: readAppliedRestores(storage, wsKey(APPLIED_RESTORES_KEY)),
pendingSendRestores: readPendingSendRestores(storage, wsKey(PENDING_SEND_RESTORES_KEY)),
});
});
return store;
}