mirror of
https://github.com/multica-ai/multica.git
synced 2026-08-06 10:50:54 +02:00
* 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>
837 lines
32 KiB
TypeScript
837 lines
32 KiB
TypeScript
"use client";
|
|
|
|
import { useCallback, useEffect, useRef, useState } from "react";
|
|
import {
|
|
useInfiniteQuery,
|
|
useQuery,
|
|
useQueryClient,
|
|
type InfiniteData,
|
|
} from "@tanstack/react-query";
|
|
import { toast } from "sonner";
|
|
import { useWorkspaceId } from "@multica/core/hooks";
|
|
import { useAuthStore } from "@multica/core/auth";
|
|
import { agentListOptions, memberListOptions } from "@multica/core/workspace/queries";
|
|
import { projectListOptions } from "@multica/core/projects/queries";
|
|
import { canAssignAgent } from "@multica/views/issues/components";
|
|
import { api, dispatchReasonCode } from "@multica/core/api";
|
|
import { useAgentPresenceDetail, useWorkspaceAgentAvailability } from "@multica/core/agents";
|
|
import {
|
|
chatSessionsOptions,
|
|
chatMessagesPageOptions,
|
|
pendingChatTaskOptions,
|
|
chatKeys,
|
|
isTaskMessageTaskId,
|
|
sortChatSessions,
|
|
} from "@multica/core/chat/queries";
|
|
import {
|
|
useCreateChatSession,
|
|
useMarkChatSessionRead,
|
|
useSetChatSessionProject,
|
|
useSetChatSessionArchived,
|
|
} from "@multica/core/chat/mutations";
|
|
import { useChatStore } from "@multica/core/chat";
|
|
import { removeChatMessageFromCaches } from "@multica/core/realtime";
|
|
import { useChatDraftRestore } from "./use-chat-draft-restore";
|
|
import { useChatProjectContextSupport } from "./use-chat-project-context-support";
|
|
import { createLogger } from "@multica/core/logger";
|
|
import type {
|
|
Agent,
|
|
Attachment,
|
|
ChatMessage,
|
|
ChatMessagesPage,
|
|
ChatPendingTask,
|
|
} from "@multica/core/types";
|
|
import { useT } from "../../i18n";
|
|
import { useAppForeground } from "../../common/use-app-foreground";
|
|
|
|
const uiLogger = createLogger("chat.ui");
|
|
const apiLogger = createLogger("chat.api");
|
|
|
|
// Derive a concise session title from the first user message: first line,
|
|
// markdown stripped, whitespace collapsed, capped. A deterministic title
|
|
// (no LLM) — the server has no summarization model, so this is the sensible
|
|
// default until a runtime-generated title is wired up.
|
|
const CHAT_TITLE_MAX = 30;
|
|
export function deriveChatTitle(content: string): string {
|
|
const firstLine = (content.split("\n").find((l) => l.trim()) ?? content).trim();
|
|
const cleaned = firstLine
|
|
.replace(/```[\s\S]*?```/g, " ")
|
|
.replace(/[#*`>~_]/g, "")
|
|
.replace(/!?\[([^\]]*)\]\([^)]*\)/g, "$1") // markdown links/images → their text
|
|
.replace(/\s+/g, " ")
|
|
.trim();
|
|
if (cleaned.length <= CHAT_TITLE_MAX) return cleaned;
|
|
return cleaned.slice(0, CHAT_TITLE_MAX - 1).trimEnd() + "…";
|
|
}
|
|
|
|
/**
|
|
* After a send resolves: is the user still composing to the target they sent
|
|
* from? Decides whether to scrub the composer and open the sent session, or
|
|
* treat the send as fire-and-forget (the reply surfaces as unread instead).
|
|
*
|
|
* The active session answers this on its own, deliberately. The new-chat
|
|
* composer is ONE box per workspace (see DRAFT_NEW_SESSION), so moving the
|
|
* agent picker re-points where the next send goes without moving the view or
|
|
* the draft slot — that is not "navigating away" (MUL-4864). Counting it as
|
|
* such would leave a completed send's text sitting in the composer, primed to
|
|
* be sent a second time to the agent just picked.
|
|
*
|
|
* Shared by both send chains — the chat tab's controller and the floating
|
|
* ChatWindow — so the rule cannot drift between the two surfaces.
|
|
*/
|
|
export function isStillOnComposeTarget(
|
|
liveActiveSessionId: string | null,
|
|
sentFromSessionId: string | null,
|
|
): boolean {
|
|
return liveActiveSessionId === sentFromSessionId;
|
|
}
|
|
|
|
/**
|
|
* Decide what a project-context change should do, given the open session.
|
|
*
|
|
* - `awaitSession`: an active session id is set but its row has not loaded
|
|
* yet. Bail so a persisted selection resolving before its sessions query
|
|
* cannot misfile a project change into the new-chat draft.
|
|
* - `detachCurrent`: removing context from the open session — safe in place,
|
|
* it only changes what future turns receive.
|
|
* - `startFreshChat`: switching to a DIFFERENT project. A fresh chat is
|
|
* started so the old project's provider memory / reused workdir cannot
|
|
* bleed in. It must stay bound to the agent whose session we are leaving
|
|
* (`agentId`): clearing the active session otherwise drops selection back
|
|
* to the stored `selectedAgentId`, which can be a stale preference for a
|
|
* different agent, sending the lazily-created session to the wrong agent.
|
|
* - `setDraftProject`: no open session, so this only adjusts the new-chat
|
|
* draft's project.
|
|
*
|
|
* Shared by both send chains — the chat tab's controller and the floating
|
|
* ChatWindow — so the stale-agent rule cannot drift between the two surfaces.
|
|
*/
|
|
export type ProjectContextChange =
|
|
| { kind: "awaitSession" }
|
|
| { kind: "detachCurrent"; sessionId: string }
|
|
| { kind: "startFreshChat"; agentId: string; projectId: string }
|
|
| { kind: "setDraftProject"; projectId: string | null };
|
|
|
|
export function planProjectContextChange(input: {
|
|
targetProjectId: string | null;
|
|
activeSessionId: string | null;
|
|
currentSession: { id: string; agent_id: string } | null;
|
|
}): ProjectContextChange {
|
|
if (input.activeSessionId) {
|
|
if (!input.currentSession) return { kind: "awaitSession" };
|
|
if (input.targetProjectId === null) {
|
|
return { kind: "detachCurrent", sessionId: input.currentSession.id };
|
|
}
|
|
return {
|
|
kind: "startFreshChat",
|
|
agentId: input.currentSession.agent_id,
|
|
projectId: input.targetProjectId,
|
|
};
|
|
}
|
|
return { kind: "setDraftProject", projectId: input.targetProjectId };
|
|
}
|
|
|
|
// True when a session has an in-flight pending task in the cache — the signal
|
|
// of a just-created (or actively-sending) session still awaiting server
|
|
// confirmation, before the sessions-list refetch includes it. `handleSend`
|
|
// seeds this task from the server response the instant the send is accepted, so
|
|
// it is populated before `setActiveSession` publishes the new session.
|
|
// Deliberately NOT "has cached messages": a session deleted elsewhere can still
|
|
// have real cached history, which must not exempt it from the stale-session
|
|
// self-heal.
|
|
export function hasInFlightPendingTask(
|
|
qc: ReturnType<typeof useQueryClient>,
|
|
sessionId: string,
|
|
): boolean {
|
|
const pending = qc.getQueryData<ChatPendingTask>(chatKeys.pendingTask(sessionId));
|
|
return Boolean(pending?.task_id);
|
|
}
|
|
const CHAT_VIRTUOSO_INITIAL_FIRST_ITEM_INDEX = 1_000_000;
|
|
|
|
function appendChatMessageToLatestPageCache(
|
|
qc: ReturnType<typeof useQueryClient>,
|
|
sessionId: string,
|
|
message: ChatMessage,
|
|
) {
|
|
qc.setQueryData<InfiniteData<ChatMessagesPage>>(
|
|
chatKeys.messagesPage(sessionId),
|
|
(old) => {
|
|
if (!old) {
|
|
return {
|
|
pages: [{
|
|
messages: [message],
|
|
limit: 50,
|
|
has_more: false,
|
|
next_cursor: null,
|
|
}],
|
|
pageParams: [null],
|
|
};
|
|
}
|
|
if (old.pages.some((page) => page.messages.some((m) => m.id === message.id))) {
|
|
return old;
|
|
}
|
|
return {
|
|
...old,
|
|
pages: old.pages.map((page, index) =>
|
|
index === 0 ? { ...page, messages: [...page.messages, message] } : page,
|
|
),
|
|
};
|
|
},
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Layout-agnostic chat controller. Holds every piece of chat conversation
|
|
* state and behavior — agent resolution, session lookup, the await-then-render
|
|
* send/stop/cancel flow, message pagination, and auto-mark-read — so that
|
|
* both surfaces render the same conversation logic:
|
|
*
|
|
* - ChatWindow: the floating FAB overlay (adds resize / expand / minimize).
|
|
* - ChatPage: the first-class Chat tab (two-pane thread list + conversation).
|
|
*
|
|
* The only thing the caller supplies is `isActive` — whether its surface is
|
|
* currently on screen — which gates auto-mark-read so a background overlay
|
|
* doesn't silently clear unread state the user hasn't actually seen.
|
|
*/
|
|
export function useChatController(opts?: { isActive?: boolean }) {
|
|
const isActive = opts?.isActive ?? true;
|
|
const { t } = useT("chat");
|
|
const wsId = useWorkspaceId();
|
|
const activeSessionId = useChatStore((s) => s.activeSessionId);
|
|
const selectedAgentId = useChatStore((s) => s.selectedAgentId);
|
|
const selectedProjectId = useChatStore((s) => s.selectedProjectId);
|
|
const setActiveSession = useChatStore((s) => s.setActiveSession);
|
|
const setSelectedAgentId = useChatStore((s) => s.setSelectedAgentId);
|
|
const setSelectedProjectId = useChatStore((s) => s.setSelectedProjectId);
|
|
const user = useAuthStore((s) => s.user);
|
|
const { data: agents = [], isSuccess: agentsLoaded } = useQuery(
|
|
agentListOptions(wsId),
|
|
);
|
|
const { data: members = [], isSuccess: membersLoaded } = useQuery(
|
|
memberListOptions(wsId),
|
|
);
|
|
const { data: sessions = [], isSuccess: sessionsLoaded } = useQuery(
|
|
chatSessionsOptions(wsId),
|
|
);
|
|
const { data: projects = [], isSuccess: projectsLoaded } = useQuery(
|
|
projectListOptions(wsId),
|
|
);
|
|
const {
|
|
data: rawMessagePages,
|
|
isLoading: messagesLoading,
|
|
fetchNextPage: fetchOlderMessages,
|
|
hasNextPage: hasOlderMessages,
|
|
isFetchingNextPage: isFetchingOlderMessages,
|
|
} = useInfiniteQuery(chatMessagesPageOptions(activeSessionId ?? ""));
|
|
|
|
const messagePages = activeSessionId ? rawMessagePages?.pages ?? [] : [];
|
|
const messages = [...messagePages].reverse().flatMap((page) => page.messages);
|
|
const olderMessageCount = messagePages
|
|
.slice(1)
|
|
.reduce((sum, page) => sum + page.messages.length, 0);
|
|
const firstItemIndex =
|
|
messages.length > 0
|
|
? CHAT_VIRTUOSO_INITIAL_FIRST_ITEM_INDEX - olderMessageCount
|
|
: 0;
|
|
const showSkeleton = !!activeSessionId && messagesLoading;
|
|
|
|
const { data: pendingTask } = useQuery(
|
|
pendingChatTaskOptions(activeSessionId ?? ""),
|
|
);
|
|
const pendingTaskId = pendingTask?.task_id ?? null;
|
|
const stopRequestedBeforeTaskRef = useRef(false);
|
|
// Durable deferred-cancellation draft restores (#5219). The whole lifecycle —
|
|
// fetch, offer, skip-and-re-offer, apply, consume, reconcile — lives in this
|
|
// hook, shared with the floating chat window.
|
|
//
|
|
// Gated on isActive AND app foreground: a backgrounded browser tab still renders
|
|
// this controller, and it must not fetch/apply/consume a restore the user is
|
|
// waiting on in a foreground surface. It recovers on its next fetch once the
|
|
// surface is on screen and the app is refocused. (appForeground also gates auto
|
|
// mark-read below.)
|
|
const appForeground = useAppForeground();
|
|
const { restoreDraftRequest, enqueueLocalRestore, handleRestoreDraftApplied } =
|
|
useChatDraftRestore(activeSessionId, isActive && appForeground);
|
|
// Nonce handed to ChatInput to pull focus into the compose box when a new
|
|
// chat starts. Bumped by handleNewChat / handleStartNewChat only, so
|
|
// selecting an existing chat or a deep link never steals focus.
|
|
const [focusInputRequest, setFocusInputRequest] = useState(0);
|
|
const requestInputFocus = useCallback(
|
|
() => setFocusInputRequest((n) => n + 1),
|
|
[],
|
|
);
|
|
|
|
const currentSession = activeSessionId
|
|
? sessions.find((s) => s.id === activeSessionId)
|
|
: null;
|
|
const isSessionArchived = currentSession?.status === "archived";
|
|
const candidateProjectId = currentSession
|
|
? currentSession.project_id ?? null
|
|
: selectedProjectId;
|
|
const activeProjectId = candidateProjectId &&
|
|
(!projectsLoaded || projects.some((project) => project.id === candidateProjectId))
|
|
? candidateProjectId
|
|
: null;
|
|
|
|
// A project may be deleted on another client while this workspace's next
|
|
// chat preference is still persisted locally. Normalize it as soon as the
|
|
// authoritative project list settles so a future send cannot carry a stale
|
|
// selection.
|
|
useEffect(() => {
|
|
if (!projectsLoaded || !selectedProjectId) return;
|
|
if (projects.some((project) => project.id === selectedProjectId)) return;
|
|
setSelectedProjectId(null);
|
|
}, [projectsLoaded, projects, selectedProjectId, setSelectedProjectId]);
|
|
|
|
const qc = useQueryClient();
|
|
const createSession = useCreateChatSession();
|
|
const markRead = useMarkChatSessionRead();
|
|
const setSessionProject = useSetChatSessionProject();
|
|
const setArchived = useSetChatSessionArchived();
|
|
|
|
const currentMember = members.find((m) => m.user_id === user?.id);
|
|
const memberRole = currentMember?.role;
|
|
const availableAgents = agents.filter(
|
|
(a) => !a.archived_at && canAssignAgent(a, user?.id, memberRole),
|
|
);
|
|
// `availableAgents` is only trustworthy once BOTH queries above succeeded:
|
|
// the permission filter reads the member role, so agents-without-members
|
|
// misreports a public_to agent as unavailable. Consumers that must tell
|
|
// "still loading" apart from "settled and not available" (the `?agent=`
|
|
// deep link) gate on this instead of sniffing list emptiness. Query errors
|
|
// deliberately keep this false — a failed fetch is not a permission verdict.
|
|
const agentsSettled = agentsLoaded && membersLoaded;
|
|
|
|
// The agent bound to the OPEN session, resolved from the full agent list
|
|
// (archived included, since agentListOptions passes include_archived). An
|
|
// archived agent is filtered out of `availableAgents`, so resolving the
|
|
// active agent only from that list would make an archived-agent session
|
|
// silently render some *other* available agent — wrong avatar/name/presence
|
|
// in the header, and a send that targets the wrong agent. Binding to the
|
|
// session's real agent keeps the conversation honest; the archived state
|
|
// then makes it read-only (see isAgentArchived).
|
|
const sessionAgent = currentSession
|
|
? agents.find((a) => a.id === currentSession.agent_id) ?? null
|
|
: null;
|
|
const isAgentArchived = !!sessionAgent?.archived_at;
|
|
|
|
// Resolve selected agent: open session's agent → stored preference → first
|
|
// available. New chats have no session, so they fall through to the picker.
|
|
const activeAgent =
|
|
sessionAgent ??
|
|
availableAgents.find((a) => a.id === selectedAgentId) ??
|
|
availableAgents[0] ??
|
|
null;
|
|
|
|
const agentAvailability = useWorkspaceAgentAvailability();
|
|
const noAgent = agentAvailability === "none";
|
|
|
|
const projectContextSupport = useChatProjectContextSupport(wsId, activeAgent);
|
|
|
|
const presenceDetail = useAgentPresenceDetail(wsId, activeAgent?.id);
|
|
const availability =
|
|
presenceDetail === "loading" ? undefined : presenceDetail.availability;
|
|
|
|
// Auto mark-as-read whenever the user is actively looking at a session with
|
|
// unread state. `isActive` lets the caller say "my surface is on screen":
|
|
// the floating overlay passes `isOpen`, the tab passes `true`. `appForeground`
|
|
// additionally requires the window to be visible and focused: a reply landing
|
|
// while the app is backgrounded must stay unread so the sidebar badges it
|
|
// (MUL-4485); it clears the moment the user returns and this effect re-runs.
|
|
//
|
|
// The read is deferred by a tick and cancelled on cleanup, so a session that
|
|
// is only *momentarily* active never gets marked read. This is the fix for
|
|
// MUL-4360's mount race: `activeSessionId` is persisted, so on a bare `/chat`
|
|
// navigation the page restores the last session for one frame before its
|
|
// URL→store effect (which runs AFTER this hook's effects, since the hook is
|
|
// called first) clears it back to null. Without the defer, that restored-but-
|
|
// never-opened session was marked read in that gap — its badge vanished
|
|
// though the user never opened it (right pane still shows "select a chat").
|
|
// Deferring lets the subsequent activeSessionId change cancel the pending
|
|
// read via cleanup; the store re-check is a belt-and-suspenders guard. Only a
|
|
// session that stays active past the tick — a real select, deep link, or
|
|
// refresh — is read.
|
|
const currentHasUnread =
|
|
sessions.find((s) => s.id === activeSessionId)?.has_unread ?? false;
|
|
useEffect(() => {
|
|
if (!isActive || !appForeground || !activeSessionId) return;
|
|
if (!currentHasUnread) return;
|
|
const sessionId = activeSessionId;
|
|
const timer = setTimeout(() => {
|
|
if (useChatStore.getState().activeSessionId !== sessionId) return;
|
|
uiLogger.info("auto markRead", { sessionId });
|
|
markRead.mutate(sessionId);
|
|
}, 0);
|
|
return () => clearTimeout(timer);
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps -- markRead ref stable
|
|
}, [isActive, appForeground, activeSessionId, currentHasUnread]);
|
|
|
|
const sessionPromiseRef = useRef<Promise<string | null> | null>(null);
|
|
const ensureSession = useCallback(
|
|
async (titleSeed: string): Promise<string | null> => {
|
|
// Trust the current session id only when it's real: present in the
|
|
// loaded list, or a just-created one still awaiting the list refetch
|
|
// (has an optimistic write). A dangling id (deleted / no access) must not
|
|
// be treated as an existing session — fall through and create a fresh one
|
|
// so the message lands somewhere instead of POSTing into a 404.
|
|
if (
|
|
activeSessionId &&
|
|
(!sessionsLoaded ||
|
|
sessions.some((s) => s.id === activeSessionId) ||
|
|
hasInFlightPendingTask(qc, activeSessionId))
|
|
) {
|
|
return activeSessionId;
|
|
}
|
|
if (!activeAgent) return null;
|
|
if (sessionPromiseRef.current) return sessionPromiseRef.current;
|
|
|
|
const promise = (async () => {
|
|
try {
|
|
const session = await createSession.mutateAsync({
|
|
agent_id: activeAgent.id,
|
|
title: deriveChatTitle(titleSeed),
|
|
project_id: activeProjectId,
|
|
});
|
|
return session.id;
|
|
} finally {
|
|
sessionPromiseRef.current = null;
|
|
}
|
|
})();
|
|
sessionPromiseRef.current = promise;
|
|
return promise;
|
|
},
|
|
[
|
|
activeSessionId,
|
|
activeAgent,
|
|
activeProjectId,
|
|
createSession,
|
|
sessions,
|
|
sessionsLoaded,
|
|
qc,
|
|
],
|
|
);
|
|
|
|
// Self-heal a dangling `activeSessionId`. Once the sessions list has loaded
|
|
// and it isn't in the list — with no in-flight optimistic write exempting a
|
|
// just-created session — the id was deleted, lost access, or never existed
|
|
// (a stale `?session=` deep link, or a persisted floating-window selection).
|
|
// Clearing it stops BOTH surfaces (the tab and the floating window) from
|
|
// rendering an editable empty chat whose send would POST into a nonexistent
|
|
// session. Lives in the shared controller so every surface self-heals.
|
|
useEffect(() => {
|
|
if (!activeSessionId || !sessionsLoaded) return;
|
|
if (sessions.some((s) => s.id === activeSessionId)) return;
|
|
if (hasInFlightPendingTask(qc, activeSessionId)) return;
|
|
uiLogger.info("clearing dangling activeSessionId", { sessionId: activeSessionId });
|
|
setActiveSession(null);
|
|
}, [activeSessionId, sessionsLoaded, sessions, qc, setActiveSession]);
|
|
|
|
// Upload transport moved into the coordinated-upload engine inside ChatInput
|
|
// (MUL-5181 L2); surfaces only forward whether the affordance exists.
|
|
const uploadEnabled = !!activeAgent;
|
|
|
|
const cancelChatTask = useCallback(
|
|
async (
|
|
taskId: string,
|
|
sessionId: string,
|
|
options: { restoreDraftToInput: boolean; source: string },
|
|
) => {
|
|
apiLogger.info("cancelTask.start", {
|
|
taskId,
|
|
sessionId,
|
|
source: options.source,
|
|
});
|
|
qc.setQueryData(chatKeys.pendingTask(sessionId), {});
|
|
|
|
try {
|
|
const result = await api.cancelTaskById(taskId);
|
|
const restored = result.cancelled_chat_message;
|
|
if (restored?.restore_to_input) {
|
|
removeChatMessageFromCaches(qc, restored.chat_session_id, restored.message_id);
|
|
if (options.restoreDraftToInput && restored.chat_session_id === sessionId) {
|
|
enqueueLocalRestore({
|
|
id: restored.message_id,
|
|
content: restored.content,
|
|
attachments: restored.attachments,
|
|
sessionId: restored.chat_session_id,
|
|
});
|
|
}
|
|
}
|
|
qc.invalidateQueries({ queryKey: chatKeys.messages(sessionId) });
|
|
qc.invalidateQueries({ queryKey: chatKeys.messagesPage(sessionId) });
|
|
apiLogger.info("cancelTask.success", {
|
|
taskId,
|
|
sessionId,
|
|
restoredToInput: !!restored?.restore_to_input && options.restoreDraftToInput,
|
|
});
|
|
return result;
|
|
} catch (err) {
|
|
apiLogger.warn("cancelTask.error (task may have already finished)", {
|
|
taskId,
|
|
sessionId,
|
|
err,
|
|
});
|
|
qc.invalidateQueries({ queryKey: chatKeys.messages(sessionId) });
|
|
qc.invalidateQueries({ queryKey: chatKeys.messagesPage(sessionId) });
|
|
return null;
|
|
}
|
|
},
|
|
[qc, enqueueLocalRestore],
|
|
);
|
|
|
|
const handleSend = useCallback(
|
|
async (
|
|
content: string,
|
|
attachmentIds?: string[],
|
|
commitInput?: (options?: { extraDraftKeys?: string[]; clearEditor?: boolean }) => void,
|
|
draftAttachments: Attachment[] = [],
|
|
): Promise<boolean> => {
|
|
if (!activeAgent) {
|
|
apiLogger.warn("sendChatMessage skipped: no active agent");
|
|
return false;
|
|
}
|
|
// Read-only conversation: the agent is retired and can no longer pick up
|
|
// work, so refuse to enqueue a task that would sit orphaned forever. The
|
|
// input is disabled in this state; this is the belt-and-braces guard.
|
|
if (isAgentArchived) {
|
|
apiLogger.warn("sendChatMessage skipped: agent is archived", {
|
|
sessionId: activeSessionId,
|
|
agentId: activeAgent.id,
|
|
});
|
|
return false;
|
|
}
|
|
|
|
const finalContent = content;
|
|
const isNewSession = !activeSessionId;
|
|
|
|
apiLogger.info("sendChatMessage.start", {
|
|
sessionId: activeSessionId,
|
|
isNewSession,
|
|
agentId: activeAgent.id,
|
|
contentLength: finalContent.length,
|
|
attachmentCount: attachmentIds?.length ?? 0,
|
|
});
|
|
|
|
let sessionId: string | null = null;
|
|
try {
|
|
sessionId = await ensureSession(finalContent);
|
|
} catch (err) {
|
|
apiLogger.error("sendChatMessage.ensureSession.error", err);
|
|
// A revoked invoke permission blocks session create with a structured
|
|
// 403 (MUL-4525) — name the cause instead of a generic failure.
|
|
toast.error(
|
|
dispatchReasonCode(err) === "invocation_not_allowed"
|
|
? t(($) => $.input.send_blocked_toast)
|
|
: t(($) => $.input.send_failed_toast),
|
|
);
|
|
return false;
|
|
}
|
|
if (!sessionId) {
|
|
apiLogger.warn("sendChatMessage aborted: ensureSession returned null");
|
|
return false;
|
|
}
|
|
|
|
// Await-then-render: the composer keeps the user's text and attachments
|
|
// in place (editor locked, button spinning via `submitting`) until the
|
|
// server accepts the send. Nothing is written into the caches, and the
|
|
// draft is never cleared, before the roundtrip settles — a slow send never
|
|
// reads as "posted but the box is still full", and a rejected one keeps
|
|
// the draft for retry (ChatInput never cleared it).
|
|
let result;
|
|
try {
|
|
result = await api.sendChatMessage(sessionId, finalContent, attachmentIds);
|
|
} catch (err) {
|
|
apiLogger.error("sendChatMessage.error", { sessionId, err });
|
|
// Invoke permission can be revoked mid-session; the send is refused with
|
|
// a structured 403 before anything persists (MUL-4525). Surface the
|
|
// specific cause so the user knows it is a permission change, not a
|
|
// transient failure they should retry.
|
|
toast.error(
|
|
dispatchReasonCode(err) === "invocation_not_allowed"
|
|
? t(($) => $.input.send_blocked_toast)
|
|
: t(($) => $.input.send_failed_toast),
|
|
);
|
|
return false;
|
|
}
|
|
apiLogger.info("sendChatMessage.success", {
|
|
sessionId,
|
|
messageId: result.message_id,
|
|
taskId: result.task_id,
|
|
});
|
|
|
|
// Render the accepted message from the server response. Prime the message
|
|
// caches BEFORE publishing the session so the first useQuery read after
|
|
// activeSessionId flips hits data synchronously (no new-chat skeleton
|
|
// flash), and seed the pending task with the server's real id and
|
|
// created_at so the StatusPill mounts anchored to the true clock and the
|
|
// stale-session self-heal exempts this just-created session until the
|
|
// sessions-list refetch includes it.
|
|
const sent: ChatMessage = {
|
|
id: result.message_id,
|
|
chat_session_id: sessionId,
|
|
role: "user",
|
|
content: finalContent,
|
|
task_id: result.task_id,
|
|
created_at: result.created_at,
|
|
attachments: draftAttachments,
|
|
};
|
|
appendChatMessageToLatestPageCache(qc, sessionId, sent);
|
|
qc.setQueryData<ChatMessage[]>(
|
|
chatKeys.messages(sessionId),
|
|
(old) => (old ? [...old, sent] : [sent]),
|
|
);
|
|
qc.setQueryData<ChatPendingTask>(chatKeys.pendingTask(sessionId), {
|
|
task_id: result.task_id,
|
|
status: "queued",
|
|
created_at: result.created_at,
|
|
});
|
|
// Cache primed → publish the new active session, but only if the user
|
|
// hasn't navigated away mid-send. See isStillOnComposeTarget. commitInput
|
|
// clears the sent draft, and scrubs the shared editor only when the user
|
|
// is still on the session they sent from.
|
|
const live = useChatStore.getState();
|
|
const stillOnSourceSession = isStillOnComposeTarget(live.activeSessionId, activeSessionId);
|
|
if (stillOnSourceSession) {
|
|
setActiveSession(sessionId);
|
|
}
|
|
commitInput?.({ extraDraftKeys: [sessionId], clearEditor: stillOnSourceSession });
|
|
|
|
if (stopRequestedBeforeTaskRef.current) {
|
|
stopRequestedBeforeTaskRef.current = false;
|
|
await cancelChatTask(result.task_id, sessionId, {
|
|
restoreDraftToInput: true,
|
|
source: "deferred-send",
|
|
});
|
|
return false;
|
|
}
|
|
if (attachmentIds && attachmentIds.length > 0 && result.attachment_ids) {
|
|
const boundIds = new Set(result.attachment_ids);
|
|
const missing = attachmentIds.filter((id) => !boundIds.has(id));
|
|
if (missing.length > 0) {
|
|
apiLogger.warn("sendChatMessage.attachments missing after send", {
|
|
sessionId,
|
|
messageId: result.message_id,
|
|
missing,
|
|
});
|
|
toast.error(t(($) => $.input.attachment_bind_failed_toast));
|
|
}
|
|
}
|
|
qc.invalidateQueries({ queryKey: chatKeys.messages(sessionId) });
|
|
qc.invalidateQueries({ queryKey: chatKeys.messagesPage(sessionId) });
|
|
return true;
|
|
},
|
|
[
|
|
activeSessionId,
|
|
activeAgent,
|
|
isAgentArchived,
|
|
ensureSession,
|
|
cancelChatTask,
|
|
qc,
|
|
setActiveSession,
|
|
t,
|
|
],
|
|
);
|
|
|
|
const handleStop = useCallback(() => {
|
|
if (!pendingTaskId || !activeSessionId) {
|
|
apiLogger.debug("cancelTask skipped: no pending task");
|
|
return;
|
|
}
|
|
if (!isTaskMessageTaskId(pendingTaskId)) {
|
|
stopRequestedBeforeTaskRef.current = true;
|
|
apiLogger.info("cancelTask.deferred until server task id", {
|
|
taskId: pendingTaskId,
|
|
sessionId: activeSessionId,
|
|
});
|
|
return;
|
|
}
|
|
void cancelChatTask(pendingTaskId, activeSessionId, {
|
|
restoreDraftToInput: true,
|
|
source: "active-input",
|
|
});
|
|
}, [pendingTaskId, activeSessionId, cancelChatTask]);
|
|
|
|
const handleNewChat = useCallback(() => {
|
|
uiLogger.info("newChat", {
|
|
previousSessionId: activeSessionId,
|
|
previousPendingTask: pendingTaskId,
|
|
});
|
|
// A fresh chat has no project unless the user explicitly chooses one.
|
|
// The open session's project is server-owned history, not a default for
|
|
// the next session.
|
|
setSelectedProjectId(null);
|
|
setActiveSession(null);
|
|
requestInputFocus();
|
|
}, [
|
|
activeSessionId,
|
|
pendingTaskId,
|
|
setSelectedProjectId,
|
|
setActiveSession,
|
|
requestInputFocus,
|
|
]);
|
|
|
|
// Start a fresh chat bound to a chosen agent. Unlike handleSelectAgent this
|
|
// does not no-op when the agent is unchanged — "new chat" always clears the
|
|
// active session so the user lands on an empty compose for that agent. The
|
|
// session row is created lazily on the first send (see ensureSession).
|
|
const handleStartNewChat = useCallback(
|
|
(agent: Agent) => {
|
|
uiLogger.info("startNewChat", {
|
|
agentId: agent.id,
|
|
previousSessionId: activeSessionId,
|
|
});
|
|
setSelectedAgentId(agent.id);
|
|
setSelectedProjectId(null);
|
|
setActiveSession(null);
|
|
requestInputFocus();
|
|
},
|
|
[
|
|
activeSessionId,
|
|
setSelectedAgentId,
|
|
setSelectedProjectId,
|
|
setActiveSession,
|
|
requestInputFocus,
|
|
],
|
|
);
|
|
|
|
const handleSelectSession = useCallback(
|
|
(session: { id: string; agent_id: string; project_id?: string | null }) => {
|
|
// Sessions are bound 1:1 to an agent — picking a session from a
|
|
// different agent implicitly switches the agent too.
|
|
if (activeAgent && session.agent_id !== activeAgent.id) {
|
|
uiLogger.info("selectSession (cross-agent)", {
|
|
from: activeAgent.id,
|
|
toAgent: session.agent_id,
|
|
toSession: session.id,
|
|
});
|
|
setSelectedAgentId(session.agent_id);
|
|
}
|
|
setActiveSession(session.id);
|
|
},
|
|
[activeAgent, setSelectedAgentId, setActiveSession],
|
|
);
|
|
|
|
const handleProjectChange = useCallback(
|
|
(projectId: string | null) => {
|
|
if (projectId === activeProjectId) return;
|
|
uiLogger.info("selectProjectContext", {
|
|
from: activeProjectId,
|
|
to: projectId,
|
|
previousSessionId: activeSessionId,
|
|
});
|
|
const plan = planProjectContextChange({
|
|
targetProjectId: projectId,
|
|
activeSessionId,
|
|
currentSession: currentSession ?? null,
|
|
});
|
|
switch (plan.kind) {
|
|
case "awaitSession":
|
|
return;
|
|
case "detachCurrent":
|
|
setSessionProject.mutate({ sessionId: plan.sessionId, projectId: null });
|
|
break;
|
|
case "startFreshChat":
|
|
setSelectedAgentId(plan.agentId);
|
|
setSelectedProjectId(plan.projectId);
|
|
setActiveSession(null);
|
|
break;
|
|
case "setDraftProject":
|
|
setSelectedProjectId(plan.projectId);
|
|
break;
|
|
}
|
|
requestInputFocus();
|
|
}, [
|
|
activeProjectId,
|
|
activeSessionId,
|
|
currentSession,
|
|
setSessionProject,
|
|
setSelectedAgentId,
|
|
setSelectedProjectId,
|
|
setActiveSession,
|
|
requestInputFocus,
|
|
],
|
|
);
|
|
|
|
// Archiving the chat currently in view would otherwise strand the
|
|
// conversation pane on a now read-only, "dangling" session. Mirror the Inbox
|
|
// list: advance selection to the next chat in the (sorted, non-archived)
|
|
// history, fall back to the previous one, and clear only when nothing is
|
|
// left. Routing the non-null advance through handleSelectSession keeps
|
|
// selectedAgentId in sync, so a follow-up "new chat" still defaults to the
|
|
// right agent even when the next chat belongs to a different agent. A no-op
|
|
// when the archived session isn't the open one — that selection stays put.
|
|
const advanceSelectionAfterArchive = useCallback(
|
|
(session: { id: string; agent_id: string }) => {
|
|
if (activeSessionId !== session.id) return;
|
|
const history = sortChatSessions(
|
|
sessions.filter((s) => s.status !== "archived"),
|
|
);
|
|
const idx = history.findIndex((s) => s.id === session.id);
|
|
const next = history[idx + 1] ?? history[idx - 1] ?? null;
|
|
if (next) handleSelectSession(next);
|
|
else setActiveSession(null);
|
|
},
|
|
[activeSessionId, sessions, handleSelectSession, setActiveSession],
|
|
);
|
|
|
|
const archiveSession = useCallback(
|
|
(sessionId: string) => setArchived.mutate({ sessionId, archived: true }),
|
|
[setArchived],
|
|
);
|
|
|
|
const hasMessages = messages.length > 0 || !!pendingTaskId;
|
|
|
|
return {
|
|
// identity / lists
|
|
wsId,
|
|
user,
|
|
agents,
|
|
availableAgents,
|
|
agentsSettled,
|
|
sessions,
|
|
projects,
|
|
activeSessionId,
|
|
selectedAgentId,
|
|
activeProjectId,
|
|
projectContextUnsupported: projectContextSupport === false,
|
|
isProjectUpdating:
|
|
setSessionProject.isPending || (!!activeSessionId && !currentSession),
|
|
currentSession,
|
|
isSessionArchived,
|
|
isAgentArchived,
|
|
activeAgent,
|
|
noAgent,
|
|
availability,
|
|
// messages
|
|
messages,
|
|
pendingTask,
|
|
pendingTaskId,
|
|
showSkeleton,
|
|
hasMessages,
|
|
firstItemIndex,
|
|
hasOlderMessages: !!hasOlderMessages,
|
|
isFetchingOlderMessages,
|
|
fetchOlderMessages,
|
|
// draft restore
|
|
restoreDraftRequest,
|
|
handleRestoreDraftApplied,
|
|
// compose-box focus nonce (bumped on new chat)
|
|
focusInputRequest,
|
|
// actions
|
|
handleSend,
|
|
handleStop,
|
|
uploadEnabled,
|
|
handleNewChat,
|
|
handleStartNewChat,
|
|
handleSelectSession,
|
|
handleProjectChange,
|
|
advanceSelectionAfterArchive,
|
|
archiveSession,
|
|
// store setters (for surfaces that sync selection to the URL, etc.)
|
|
setActiveSession,
|
|
setSelectedAgentId,
|
|
};
|
|
}
|
|
|
|
export type ChatController = ReturnType<typeof useChatController>;
|