mirror of
https://github.com/multica-ai/multica.git
synced 2026-08-05 01:19:42 +02:00
Follow-up to #6025 (MUL-5391), addressing the two non-blocking cleanups raised in review. 1. `CoordinatedUploads.handleUpload` was still typed as a one-arg function while the real `ContentEditor` contract is `(file, uploadId)`. Runtime was already safe (the implementation accepts `uploadId?`), but the exported interface erased the second parameter at the boundary, so a mock or hand-rolled caller could silently drop the editor-minted id and mint a second one — breaking the one-id link between the document node and the draft record. Widened the type and documented why the id must be threaded through. 2. #6025 changed `normalizeStoredUploads` to DROP persisted `uploading` records instead of coercing them to `interrupted`, but eleven comments across core and views still described the old coercion. Corrected them to state what the code does. `interrupted` is now produced by no code path at all; it stays in the union and is still accepted, rendered and dismissable because builds before this change persisted such records. Marked it LEGACY at the type and in the test that pins the behaviour. No runtime behaviour change: comments, one type widening, one test comment. Verified: pnpm typecheck (6/6); packages/core Vitest 1133 tests; packages/views Vitest 3178 tests; git diff --check. Co-authored-by: multica-agent <github@multica.ai>
686 lines
29 KiB
TypeScript
686 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 dropped (bytes are gone, and nothing in the
|
|
// body references it).
|
|
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;
|
|
}
|