Files
multica/packages/views/chat/components/chat-input.tsx
Jiayuan Zhang 35aca57939 feat(chat): Chat V2 — sidebar entry + main-area page (#1580)
* feat(chat): Chat V2 — sidebar entry + main-area page

Replace the floating drawer + FAB with a first-class workspace route
`/:slug/chat`. Sidebar gets a single `Chat` entry under Inbox with an
unread dot; session history lives inside the Chat tab via a popover
rather than leaking into the global sidebar (keeps Multica's "nouns in
the nav" semantic — Inbox / Issues / Projects are work objects, Chat is
a tool).

- Add `paths.workspace(slug).chat()` + update link-handler route set.
- New `ChatPage` view with PageHeader, history popover, centered
  messages/composer column, and empty-state starter prompts.
- Delete `ChatWindow`, `ChatFab`, resize helpers, and standalone
  `ChatSessionHistory` (history now embedded in the popover).
- Drop `isOpen`/`toggle`/`showHistory`/resize fields from `useChatStore`
  — the page is a route now, not an overlay.
- Wire the new `/chat` route on web (App Router) and desktop
  (react-router + tab-store icon mapping).

Addresses MUL-1322.

* fix(chat): align composer width with message column

The ChatPage wrapper added px-4 on top of ChatInput's own px-5, making
the composer 32px narrower than the messages column. Drop the outer
px-4 so both share the same max-w-3xl outer + px-5 inner padding
provided by ChatMessageList / ChatInput.

* fix(chat): taller default composer (~3 lines visible, 8 max)

min-h 4rem → 7rem, max-h 10rem → 15rem. Empty state previously
showed only 1 text row after pb-9 for the action bar; raise the
floor so there's visible writing room and lift the ceiling so a
longer draft can grow before scrolling kicks in.

* fix(chat): restore anchor + in-flight indicator + cold-start session restore

Three issues surfaced by review:

1. ContextAnchorButton always disabled on /:slug/chat — useRouteAnchorCandidate
   only matches issue/project/inbox pathnames, so moving chat to its own route
   dropped 'bring the page I was on into the conversation'. Track the last
   anchor-eligible location globally (new useAnchorTracker mounted in AppSidebar
   + lastAnchorLocation on useChatStore) and substitute it when on /chat.

2. No global 'Multica is working' cue after ChatFab deletion. Subscribe the
   sidebar Chat entry to pendingChatTasksOptions and swap the unread dot for a
   spinner while any chat task is in flight.

3. ChatPage restore effect latched didRestoreRef before the sessions query
   resolved, so cold-start direct nav to /chat landed on the empty state even
   when the server had an active session. Wait for isSuccess before locking
   the ref.

* fix(chat): clear lastAnchorLocation on workspace rehydration

The pathname captured in workspace A would otherwise be reused against
workspace B's wsId, triggering a cross-workspace issue/project fetch
and silently leaking anchor context into chat messages.

---------

Co-authored-by: Lambda <f252c2c5-7d1d-4f3c-b394-a61abfe673fc@users.noreply.multica.ai>
2026-04-24 01:46:37 +08:00

125 lines
4.5 KiB
TypeScript

"use client";
import type { ReactNode } from "react";
import { useRef, useState } from "react";
import { ContentEditor, type ContentEditorRef } from "../../editor";
import { SubmitButton } from "@multica/ui/components/common/submit-button";
import { useChatStore, DRAFT_NEW_SESSION } from "@multica/core/chat";
import { createLogger } from "@multica/core/logger";
const logger = createLogger("chat.ui");
interface ChatInputProps {
onSend: (content: string) => void;
onStop?: () => void;
isRunning?: boolean;
disabled?: boolean;
/** Name of the currently selected agent, used in the placeholder. */
agentName?: string;
/** Rendered at the bottom-left of the input bar — typically the agent picker. */
leftAdornment?: ReactNode;
/** Rendered just before the submit button — used for context-anchor action. */
rightAdornment?: ReactNode;
/** Rendered inside the rounded container, above the editor — attached
* context cards, drafts, etc. */
topSlot?: ReactNode;
}
export function ChatInput({
onSend,
onStop,
isRunning,
disabled,
agentName,
leftAdornment,
rightAdornment,
topSlot,
}: ChatInputProps) {
const editorRef = useRef<ContentEditorRef>(null);
const activeSessionId = useChatStore((s) => s.activeSessionId);
const selectedAgentId = useChatStore((s) => s.selectedAgentId);
// Scope the new-chat draft by agent:
// 1. Switching agents while composing a brand-new chat gives each
// agent its own draft (no cross-agent leakage).
// 2. Tiptap's Placeholder extension is only applied at mount; this
// key changes on agent switch so the editor remounts and the
// `Tell {agent} what to do…` placeholder refreshes.
const draftKey =
activeSessionId ?? `${DRAFT_NEW_SESSION}:${selectedAgentId ?? ""}`;
// Select a primitive — empty-string fallback keeps referential stability.
const inputDraft = useChatStore((s) => s.inputDrafts[draftKey] ?? "");
const setInputDraft = useChatStore((s) => s.setInputDraft);
const clearInputDraft = useChatStore((s) => s.clearInputDraft);
const [isEmpty, setIsEmpty] = useState(!inputDraft.trim());
const handleSend = () => {
const content = editorRef.current?.getMarkdown()?.replace(/(\n\s*)+$/, "").trim();
if (!content || isRunning || disabled) {
logger.debug("input.send skipped", {
emptyContent: !content,
isRunning,
disabled,
});
return;
}
// Capture draft key BEFORE onSend — creating a new session mutates
// activeSessionId synchronously, so reading it after onSend would point
// at the new session and leave the old draft orphaned.
const keyAtSend = draftKey;
logger.info("input.send", { contentLength: content.length, draftKey: keyAtSend });
onSend(content);
editorRef.current?.clearContent();
clearInputDraft(keyAtSend);
setIsEmpty(true);
};
const placeholder = disabled
? "This session is archived"
: agentName
? `Tell ${agentName} what to do…`
: "Tell me what to do…";
return (
<div className="px-5 pb-3 pt-0">
<div className="relative mx-auto flex min-h-28 max-h-60 w-full max-w-4xl flex-col rounded-lg bg-card pb-9 border-1 border-border transition-colors focus-within:border-brand">
{topSlot}
<div className="flex-1 min-h-0 overflow-y-auto px-3 py-2">
<ContentEditor
// Remount the editor when the active session changes so its
// uncontrolled defaultValue picks up the new session's draft.
key={draftKey}
ref={editorRef}
defaultValue={inputDraft}
placeholder={placeholder}
onUpdate={(md) => {
setIsEmpty(!md.trim());
setInputDraft(draftKey, md);
}}
onSubmit={handleSend}
debounceMs={100}
// Chat is short-form — the floating formatting toolbar is
// more distraction than feature here.
showBubbleMenu={false}
// Enter sends; Shift-Enter inserts a hard break.
submitOnEnter
/>
</div>
{leftAdornment && (
<div className="absolute bottom-1.5 left-2 flex items-center">
{leftAdornment}
</div>
)}
<div className="absolute bottom-1 right-1.5 flex items-center gap-2">
{rightAdornment}
<SubmitButton
onClick={handleSend}
disabled={isEmpty || !!disabled}
running={isRunning}
onStop={onStop}
/>
</div>
</div>
</div>
);
}