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>
This commit is contained in:
Jiayuan Zhang
2026-04-24 01:46:37 +08:00
committed by GitHub
parent e0e91fc792
commit 35aca57939
25 changed files with 624 additions and 1110 deletions

View File

@@ -12,7 +12,6 @@ import {
import { ModalRegistry } from "@multica/views/modals/registry";
import { AppSidebar } from "@multica/views/layout";
import { SearchCommand, SearchTrigger } from "@multica/views/search";
import { ChatFab, ChatWindow } from "@multica/views/chat";
import { StarterContentPrompt } from "@multica/views/onboarding";
import { WorkspaceSlugProvider } from "@multica/core/paths";
import { getCurrentSlug, subscribeToCurrentSlug } from "@multica/core/platform";
@@ -124,11 +123,9 @@ export function DesktopShell() {
{/* Right side: header + content container */}
<div className="flex flex-1 min-w-0 flex-col">
<MainTopBar />
{/* Content area with inset styling — relative so ChatWindow/ChatFab are constrained here */}
{/* Content area with inset styling */}
<div className="relative flex flex-1 min-h-0 flex-col overflow-hidden mr-2 mb-2 ml-0.5 rounded-xl shadow-sm bg-background">
<TabContent />
{slug && <ChatWindow />}
{slug && <ChatFab />}
</div>
</div>
</SidebarProvider>

View File

@@ -5,6 +5,7 @@ import {
Bot,
Monitor,
BookOpenText,
MessageSquare,
Settings,
X,
Plus,
@@ -39,6 +40,7 @@ const TAB_ICONS: Record<string, LucideIcon> = {
Bot,
Monitor,
BookOpenText,
MessageSquare,
Settings,
};

View File

@@ -115,10 +115,10 @@ export function DesktopNavigationProvider({
const { tabId: activeTabId } = useActiveTabIdentity();
const router = useActiveTabRouter();
// Mirror the active tab router's full location (pathname + search) so
// shell-level consumers of useNavigation() — ChatWindow in particular —
// can read URL search params. Must stay in sync with TabNavigationProvider
// below; a partial shape here (just pathname) silently broke focus-mode
// anchor resolution on `/inbox?issue=…`.
// shell-level consumers of useNavigation() can read URL search params.
// Must stay in sync with TabNavigationProvider below; a partial shape
// here (just pathname) silently broke focus-mode anchor resolution on
// `/inbox?issue=…`.
const [location, setLocation] = useState<{ pathname: string; search: string }>(
() => ({
pathname: router?.state.location.pathname ?? "/",

View File

@@ -17,6 +17,7 @@ import { SkillsPage } from "@multica/views/skills";
import { DesktopRuntimesPage } from "./components/desktop-runtimes-page";
import { AgentsPage } from "@multica/views/agents";
import { InboxPage } from "@multica/views/inbox";
import { ChatPage } from "@multica/views/chat";
import { SettingsPage } from "@multica/views/settings";
import { Download, Server } from "lucide-react";
import { DaemonSettingsTab } from "./components/daemon-settings-tab";
@@ -119,6 +120,7 @@ export const appRoutes: RouteObject[] = [
{ path: "skills", element: <SkillsPage />, handle: { title: "Skills" } },
{ path: "agents", element: <AgentsPage />, handle: { title: "Agents" } },
{ path: "inbox", element: <InboxPage />, handle: { title: "Inbox" } },
{ path: "chat", element: <ChatPage />, handle: { title: "Chat" } },
{
path: "settings",
element: (

View File

@@ -101,6 +101,7 @@ interface TabStore {
const ROUTE_ICONS: Record<string, string> = {
inbox: "Inbox",
chat: "MessageSquare",
"my-issues": "CircleUser",
issues: "ListTodo",
projects: "FolderKanban",

View File

@@ -0,0 +1 @@
export { ChatPage as default } from "@multica/views/chat";

View File

@@ -3,7 +3,6 @@
import { DashboardLayout } from "@multica/views/layout";
import { MulticaIcon } from "@multica/ui/components/common/multica-icon";
import { SearchCommand, SearchTrigger } from "@multica/views/search";
import { ChatFab, ChatWindow } from "@multica/views/chat";
import { StarterContentPrompt } from "@multica/views/onboarding";
export default function Layout({ children }: { children: React.ReactNode }) {
@@ -14,8 +13,6 @@ export default function Layout({ children }: { children: React.ReactNode }) {
extra={
<>
<SearchCommand />
<ChatWindow />
<ChatFab />
<StarterContentPrompt />
</>
}

View File

@@ -1,6 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/types/routes.d.ts";
import "./.next/dev/types/routes.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.

View File

@@ -1,4 +1,4 @@
export { createChatStore, CHAT_MIN_W, CHAT_MIN_H, CHAT_DEFAULT_W, CHAT_DEFAULT_H, DRAFT_NEW_SESSION } from "./store";
export { createChatStore, DRAFT_NEW_SESSION } from "./store";
export type { ChatStoreOptions, ChatState, ChatTimelineItem, ContextAnchor } from "./store";
import type { createChatStore as CreateChatStoreFn } from "./store";

View File

@@ -11,9 +11,6 @@ const SESSION_STORAGE_KEY = "multica:chat:activeSessionId";
const DRAFTS_KEY = "multica:chat:drafts";
/** Placeholder sessionId for a chat that hasn't been created yet. */
export const DRAFT_NEW_SESSION = "__new__";
const CHAT_WIDTH_KEY = "multica:chat:width";
const CHAT_HEIGHT_KEY = "multica:chat:height";
const CHAT_EXPANDED_KEY = "multica:chat:expanded";
/** Focus mode is a personal preference — global across workspaces/sessions. */
const FOCUS_MODE_KEY = "multica:chat:focusMode";
@@ -41,11 +38,6 @@ function writeDrafts(storage: StorageAdapter, key: string, drafts: Record<string
}
}
export const CHAT_MIN_W = 360;
export const CHAT_MIN_H = 480;
export const CHAT_DEFAULT_W = 420;
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
@@ -76,10 +68,8 @@ export interface ContextAnchor {
}
export interface ChatState {
isOpen: boolean;
activeSessionId: string | null;
selectedAgentId: string | null;
showHistory: boolean;
/** Drafts per session: sessionId (or DRAFT_NEW_SESSION) → markdown text. */
inputDrafts: Record<string, string>;
/**
@@ -88,22 +78,20 @@ export interface ChatState {
* the preference survives workspace switches and reloads.
*/
focusMode: boolean;
/** 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;
/**
* Last location where a context anchor could be derived (issue/project/inbox).
* Updated globally by useAnchorTracker; used as a fallback for the Chat page
* which is its own route and therefore has no anchor of its own.
* Not persisted — resets per session; focus mode itself persists.
*/
lastAnchorLocation: { pathname: string; search: string } | null;
setActiveSession: (id: string | null) => void;
setSelectedAgentId: (id: string) => void;
setShowHistory: (show: boolean) => void;
/** sessionId accepts a real session UUID or DRAFT_NEW_SESSION. */
setInputDraft: (sessionId: string, draft: string) => void;
clearInputDraft: (sessionId: string) => void;
setFocusMode: (on: boolean) => void;
/** Persist raw size and auto-exit expanded mode. */
setChatSize: (width: number, height: number) => void;
setExpanded: (expanded: boolean) => void;
setLastAnchorLocation: (loc: { pathname: string; search: string } | null) => void;
}
export interface ChatStoreOptions {
@@ -119,24 +107,12 @@ export function createChatStore(options: ChatStoreOptions) {
};
const store = create<ChatState>((set, get) => ({
isOpen: false,
activeSessionId: storage.getItem(wsKey(SESSION_STORAGE_KEY)),
selectedAgentId: storage.getItem(wsKey(AGENT_STORAGE_KEY)),
showHistory: false,
inputDrafts: readDrafts(storage, wsKey(DRAFTS_KEY)),
focusMode: storage.getItem(FOCUS_MODE_KEY) === "true",
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 });
set({ isOpen: open });
},
toggle: () => {
const next = !get().isOpen;
logger.debug("toggle", { to: next });
set({ isOpen: next });
},
lastAnchorLocation: null,
setLastAnchorLocation: (loc) => set({ lastAnchorLocation: loc }),
setActiveSession: (id) => {
logger.info("setActiveSession", { from: get().activeSessionId, to: id });
if (id) {
@@ -151,10 +127,6 @@ export function createChatStore(options: ChatStoreOptions) {
storage.setItem(wsKey(AGENT_STORAGE_KEY), id);
set({ selectedAgentId: id });
},
setShowHistory: (show) => {
logger.debug("setShowHistory", { to: show });
set({ showHistory: show });
},
setInputDraft: (sessionId, draft) => {
// Debug level — onUpdate fires on every keystroke.
logger.debug("setInputDraft", { sessionId, length: draft.length });
@@ -180,23 +152,6 @@ export function createChatStore(options: ChatStoreOptions) {
writeDrafts(storage, wsKey(DRAFTS_KEY), next);
set({ inputDrafts: next });
},
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 });
},
}));
registerForWorkspaceRehydration(() => {
@@ -210,10 +165,15 @@ export function createChatStore(options: ChatStoreOptions) {
nextAgent,
draftCount: Object.keys(nextDrafts).length,
});
// lastAnchorLocation is not persisted — reset it here so a pathname
// captured in the previous workspace can't be reused against the new
// workspace's wsId (would trigger a cross-workspace issue/project fetch
// and silently leak context into chat messages).
store.setState({
activeSessionId: nextSession,
selectedAgentId: nextAgent,
inputDrafts: nextDrafts,
lastAnchorLocation: null,
});
});

View File

@@ -22,6 +22,7 @@ describe("paths.workspace() shape", () => {
"autopilots",
"agents",
"inbox",
"chat",
"myIssues",
"runtimes",
"skills",
@@ -40,6 +41,7 @@ describe("paths.workspace() shape", () => {
["autopilots", "autopilots"],
["agents", "agents"],
["inbox", "inbox"],
["chat", "chat"],
["myIssues", "my-issues"],
["runtimes", "runtimes"],
["skills", "skills"],

View File

@@ -26,6 +26,7 @@ function workspaceScoped(slug: string) {
autopilotDetail: (id: string) => `${ws}/autopilots/${encode(id)}`,
agents: () => `${ws}/agents`,
inbox: () => `${ws}/inbox`,
chat: () => `${ws}/chat`,
myIssues: () => `${ws}/my-issues`,
runtimes: () => `${ws}/runtimes`,
skills: () => `${ws}/skills`,

View File

@@ -362,7 +362,7 @@ export function useRealtimeSync(
qc.invalidateQueries({ queryKey: workspaceKeys.myInvitations() });
});
// --- Chat / task events (global, survives ChatWindow unmount) ---
// --- Chat / task events (global, survives chat page unmount) ---
//
// Single source of truth: the Query cache. No Zustand writes here — the
// earlier mirror caused a race where the cache and store disagreed

View File

@@ -1,63 +0,0 @@
"use client";
import { MessageCircle } from "lucide-react";
import { useQuery } from "@tanstack/react-query";
import { cn } from "@multica/ui/lib/utils";
import { useChatStore } from "@multica/core/chat";
import { chatSessionsOptions, pendingChatTasksOptions } from "@multica/core/chat/queries";
import { useWorkspaceId } from "@multica/core/hooks";
import { createLogger } from "@multica/core/logger";
import {
Tooltip,
TooltipTrigger,
TooltipContent,
} from "@multica/ui/components/ui/tooltip";
const logger = createLogger("chat.ui");
export function ChatFab() {
const wsId = useWorkspaceId();
const isOpen = useChatStore((s) => s.isOpen);
const toggle = useChatStore((s) => s.toggle);
const { data: sessions = [] } = useQuery(chatSessionsOptions(wsId));
const { data: pending } = useQuery(pendingChatTasksOptions(wsId));
if (isOpen) return null;
const unreadSessionCount = sessions.filter((s) => s.has_unread).length;
const isRunning = (pending?.tasks ?? []).length > 0;
const handleClick = () => {
logger.info("fab.click (open chat)", { unreadSessionCount, isRunning });
toggle();
};
// Tooltip text communicates the state that isn't carried by the icon/badge.
const tooltip = isRunning
? "Multica is working..."
: unreadSessionCount > 0
? `${unreadSessionCount} unread ${unreadSessionCount === 1 ? "chat" : "chats"}`
: "Ask Multica";
return (
<Tooltip>
<TooltipTrigger
onClick={handleClick}
className={cn(
"absolute bottom-2 right-2 z-50 flex size-10 cursor-pointer items-center justify-center rounded-full ring-1 ring-foreground/10 bg-card text-muted-foreground shadow-sm transition-transform hover:scale-110 hover:text-accent-foreground active:scale-95",
// Impulse the button itself while a chat task is running — no
// outer ring to keep things calm.
isRunning && "animate-chat-impulse",
)}
>
<MessageCircle className="size-5" />
{unreadSessionCount > 0 && (
<span className="pointer-events-none absolute -top-0.5 -right-0.5 flex min-w-4 h-4 items-center justify-center rounded-full bg-brand px-1 text-xs font-semibold leading-none text-background">
{unreadSessionCount > 9 ? "9+" : unreadSessionCount}
</span>
)}
</TooltipTrigger>
<TooltipContent side="top" sideOffset={10}>{tooltip}</TooltipContent>
</Tooltip>
);
}

View File

@@ -81,7 +81,7 @@ export function ChatInput({
return (
<div className="px-5 pb-3 pt-0">
<div className="relative mx-auto flex min-h-16 max-h-40 w-full max-w-4xl flex-col rounded-lg bg-card pb-9 border-1 border-border transition-colors focus-within:border-brand">
<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

View File

@@ -0,0 +1,513 @@
"use client";
import React, { useCallback, useEffect, useMemo, useRef } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { History, Plus, Bot, ChevronDown, Check } from "lucide-react";
import { cn } from "@multica/ui/lib/utils";
import { Avatar, AvatarFallback, AvatarImage } from "@multica/ui/components/ui/avatar";
import { Button } from "@multica/ui/components/ui/button";
import { Tooltip, TooltipTrigger, TooltipContent } from "@multica/ui/components/ui/tooltip";
import { Popover, PopoverContent, PopoverTrigger } from "@multica/ui/components/ui/popover";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@multica/ui/components/ui/dropdown-menu";
import { useWorkspaceId } from "@multica/core/hooks";
import { useAuthStore } from "@multica/core/auth";
import { agentListOptions, memberListOptions } from "@multica/core/workspace/queries";
import { canAssignAgent } from "@multica/views/issues/components";
import { api } from "@multica/core/api";
import {
chatSessionsOptions,
allChatSessionsOptions,
chatMessagesOptions,
pendingChatTaskOptions,
chatKeys,
} from "@multica/core/chat/queries";
import { useCreateChatSession, useMarkChatSessionRead } from "@multica/core/chat/mutations";
import { useChatStore } from "@multica/core/chat";
import { PageHeader } from "../../layout/page-header";
import { ChatMessageList, ChatMessageSkeleton } from "./chat-message-list";
import { ChatInput } from "./chat-input";
import {
ContextAnchorButton,
ContextAnchorCard,
buildAnchorMarkdown,
useRouteAnchorCandidate,
} from "./context-anchor";
import { createLogger } from "@multica/core/logger";
import type { Agent, ChatMessage, ChatSession } from "@multica/core/types";
const uiLogger = createLogger("chat.ui");
const apiLogger = createLogger("chat.api");
export function ChatPage() {
const wsId = useWorkspaceId();
const activeSessionId = useChatStore((s) => s.activeSessionId);
const selectedAgentId = useChatStore((s) => s.selectedAgentId);
const setActiveSession = useChatStore((s) => s.setActiveSession);
const setSelectedAgentId = useChatStore((s) => s.setSelectedAgentId);
const user = useAuthStore((s) => s.user);
const { data: agents = [] } = useQuery(agentListOptions(wsId));
const { data: members = [] } = useQuery(memberListOptions(wsId));
const { data: sessions = [], isSuccess: sessionsLoaded } = useQuery(
chatSessionsOptions(wsId),
);
const { data: allSessions = [] } = useQuery(allChatSessionsOptions(wsId));
const { data: rawMessages, isLoading: messagesLoading } = useQuery(
chatMessagesOptions(activeSessionId ?? ""),
);
const messages = activeSessionId ? rawMessages ?? [] : [];
const showSkeleton = !!activeSessionId && messagesLoading;
const { data: pendingTask } = useQuery(
pendingChatTaskOptions(activeSessionId ?? ""),
);
const pendingTaskId = pendingTask?.task_id ?? null;
const currentSession = activeSessionId
? allSessions.find((s) => s.id === activeSessionId)
: null;
const isSessionArchived = currentSession?.status === "archived";
const qc = useQueryClient();
const createSession = useCreateChatSession();
const markRead = useMarkChatSessionRead();
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),
);
const activeAgent =
availableAgents.find((a) => a.id === selectedAgentId) ??
availableAgents[0] ??
null;
// Restore most recent active session once the session query resolves.
// The ref is set only AFTER we've seen a successful query — setting it
// unconditionally on first render would lose the restore whenever the
// page mounts before the query returns (cold-start / direct navigate).
const didRestoreRef = useRef(false);
useEffect(() => {
if (didRestoreRef.current) return;
if (!sessionsLoaded) return;
didRestoreRef.current = true;
if (activeSessionId) return;
const latest = sessions.find((s) => s.status === "active");
if (latest) setActiveSession(latest.id);
// eslint-disable-next-line react-hooks/exhaustive-deps -- run once when sessions load
}, [sessionsLoaded, sessions]);
// Auto mark-as-read whenever the viewer is on a session with unread.
const currentHasUnread =
sessions.find((s) => s.id === activeSessionId)?.has_unread ?? false;
useEffect(() => {
if (!activeSessionId || !currentHasUnread) return;
markRead.mutate(activeSessionId);
// eslint-disable-next-line react-hooks/exhaustive-deps -- markRead ref stable
}, [activeSessionId, currentHasUnread]);
const { candidate: anchorCandidate } = useRouteAnchorCandidate(wsId);
const handleSend = useCallback(
async (content: string) => {
if (!activeAgent) {
apiLogger.warn("sendChatMessage skipped: no active agent");
return;
}
const focusOn = useChatStore.getState().focusMode;
const finalContent = focusOn && anchorCandidate
? `${buildAnchorMarkdown(anchorCandidate)}\n\n${content}`
: content;
let sessionId = activeSessionId;
const isNewSession = !sessionId;
apiLogger.info("sendChatMessage.start", {
sessionId,
isNewSession,
agentId: activeAgent.id,
contentLength: finalContent.length,
});
if (!sessionId) {
const session = await createSession.mutateAsync({
agent_id: activeAgent.id,
title: finalContent.slice(0, 50),
});
sessionId = session.id;
setActiveSession(sessionId);
}
const optimistic: ChatMessage = {
id: `optimistic-${Date.now()}`,
chat_session_id: sessionId,
role: "user",
content: finalContent,
task_id: null,
created_at: new Date().toISOString(),
};
qc.setQueryData<ChatMessage[]>(
chatKeys.messages(sessionId),
(old) => (old ? [...old, optimistic] : [optimistic]),
);
const result = await api.sendChatMessage(sessionId, finalContent);
qc.setQueryData(chatKeys.pendingTask(sessionId), {
task_id: result.task_id,
status: "queued",
});
qc.invalidateQueries({ queryKey: chatKeys.messages(sessionId) });
},
[activeSessionId, activeAgent, anchorCandidate, createSession, setActiveSession, qc],
);
const handleStop = useCallback(async () => {
if (!pendingTaskId) return;
try {
await api.cancelTaskById(pendingTaskId);
} catch (err) {
apiLogger.warn("cancelTask.error", { taskId: pendingTaskId, err });
}
if (activeSessionId) {
qc.setQueryData(chatKeys.pendingTask(activeSessionId), {});
qc.invalidateQueries({ queryKey: chatKeys.messages(activeSessionId) });
}
}, [pendingTaskId, activeSessionId, qc]);
const handleSelectAgent = useCallback(
(agent: Agent) => {
if (activeAgent && agent.id === activeAgent.id) return;
uiLogger.info("selectAgent", { from: selectedAgentId, to: agent.id });
setSelectedAgentId(agent.id);
setActiveSession(null);
},
[activeAgent, selectedAgentId, setSelectedAgentId, setActiveSession],
);
const handleNewChat = useCallback(() => {
setActiveSession(null);
}, [setActiveSession]);
const handleSelectSession = useCallback(
(session: ChatSession) => {
if (activeAgent && session.agent_id !== activeAgent.id) {
setSelectedAgentId(session.agent_id);
}
setActiveSession(session.id);
},
[activeAgent, setSelectedAgentId, setActiveSession],
);
const hasMessages = messages.length > 0 || !!pendingTaskId;
const activeTitle = currentSession?.title?.trim() || "New chat";
return (
<div className="flex h-full min-h-0 flex-1 flex-col">
<PageHeader className="gap-2">
<span className="text-sm font-medium truncate">{activeTitle}</span>
<div className="ml-auto flex items-center gap-1">
<HistoryPopover
sessions={allSessions}
agents={agents}
activeSessionId={activeSessionId}
onSelectSession={handleSelectSession}
/>
<Tooltip>
<TooltipTrigger
render={
<Button
variant="ghost"
size="icon-sm"
className="text-muted-foreground"
onClick={handleNewChat}
aria-label="New chat"
/>
}
>
<Plus />
</TooltipTrigger>
<TooltipContent side="bottom">New chat</TooltipContent>
</Tooltip>
</div>
</PageHeader>
{/* Body — centered max-width column */}
<div className="relative flex min-h-0 flex-1 flex-col">
{showSkeleton ? (
<div className="mx-auto flex w-full max-w-3xl flex-1 flex-col">
<ChatMessageSkeleton />
</div>
) : hasMessages ? (
<div className="mx-auto flex w-full max-w-3xl flex-1 flex-col min-h-0">
<ChatMessageList
messages={messages}
pendingTaskId={pendingTaskId}
isWaiting={!!pendingTaskId}
/>
</div>
) : (
<EmptyState agentName={activeAgent?.name} onPickPrompt={handleSend} />
)}
<div
className={cn(
"mx-auto w-full max-w-3xl pb-4",
hasMessages ? "" : "pb-8",
)}
>
<ChatInput
onSend={handleSend}
onStop={handleStop}
isRunning={!!pendingTaskId}
disabled={isSessionArchived}
agentName={activeAgent?.name}
topSlot={<ContextAnchorCard />}
leftAdornment={
<AgentDropdown
agents={availableAgents}
activeAgent={activeAgent}
userId={user?.id}
onSelect={handleSelectAgent}
/>
}
rightAdornment={<ContextAnchorButton />}
/>
</div>
</div>
</div>
);
}
/**
* Popover-based history list. Per product direction, session history lives
* inside the Chat tab — not in the global sidebar — so that Multica doesn't
* read as "just another chat app." The trigger is a History icon in the
* page header.
*/
function HistoryPopover({
sessions,
agents,
activeSessionId,
onSelectSession,
}: {
sessions: ChatSession[];
agents: Agent[];
activeSessionId: string | null;
onSelectSession: (session: ChatSession) => void;
}) {
const [open, setOpen] = React.useState(false);
const agentById = useMemo(() => new Map(agents.map((a) => [a.id, a])), [agents]);
return (
<Popover open={open} onOpenChange={setOpen}>
<Tooltip>
<TooltipTrigger
render={
<PopoverTrigger
render={
<Button
variant="ghost"
size="icon-sm"
className="text-muted-foreground"
aria-label="History"
/>
}
/>
}
>
<History />
</TooltipTrigger>
<TooltipContent side="bottom">History</TooltipContent>
</Tooltip>
<PopoverContent align="end" sideOffset={6} className="w-80 p-0">
<div className="px-3 py-2 border-b">
<span className="text-xs font-medium text-muted-foreground">History</span>
</div>
<div className="max-h-96 overflow-y-auto">
{sessions.length === 0 ? (
<div className="px-3 py-6 text-center text-xs text-muted-foreground">
No previous chats
</div>
) : (
sessions.map((session) => {
const isCurrent = session.id === activeSessionId;
const agent = agentById.get(session.agent_id) ?? null;
return (
<button
key={session.id}
type="button"
onClick={() => {
onSelectSession(session);
setOpen(false);
}}
className={cn(
"flex w-full items-start gap-2 px-3 py-2 text-left transition-colors hover:bg-accent/60",
isCurrent && "bg-accent/40",
)}
>
<AgentAvatarSmall agent={agent} />
<div className="min-w-0 flex-1">
<div className="truncate text-sm">
{session.title?.trim() || "New chat"}
</div>
<div className="truncate text-xs text-muted-foreground">
{agent?.name ?? "Unknown agent"}
</div>
</div>
{session.has_unread && (
<span className="mt-1.5 size-1.5 shrink-0 rounded-full bg-brand" />
)}
{isCurrent && (
<Check className="mt-1 size-3.5 shrink-0 text-muted-foreground" />
)}
</button>
);
})
)}
</div>
</PopoverContent>
</Popover>
);
}
function AgentDropdown({
agents,
activeAgent,
userId,
onSelect,
}: {
agents: Agent[];
activeAgent: Agent | null;
userId: string | undefined;
onSelect: (agent: Agent) => void;
}) {
const { mine, others } = useMemo(() => {
const mine: Agent[] = [];
const others: Agent[] = [];
for (const a of agents) {
if (a.owner_id === userId) mine.push(a);
else others.push(a);
}
return { mine, others };
}, [agents, userId]);
if (!activeAgent) {
return <span className="text-xs text-muted-foreground">No agents</span>;
}
return (
<DropdownMenu>
<DropdownMenuTrigger className="flex items-center gap-1.5 rounded-md px-1.5 py-1 -ml-1 cursor-pointer outline-none transition-colors hover:bg-accent aria-expanded:bg-accent">
<AgentAvatarSmall agent={activeAgent} />
<span className="text-xs font-medium max-w-28 truncate">{activeAgent.name}</span>
<ChevronDown className="size-3 text-muted-foreground shrink-0" />
</DropdownMenuTrigger>
<DropdownMenuContent align="start" side="top" className="max-h-80 w-auto max-w-64">
{mine.length > 0 && (
<DropdownMenuGroup>
<DropdownMenuLabel>My agents</DropdownMenuLabel>
{mine.map((agent) => (
<AgentMenuItem
key={agent.id}
agent={agent}
isCurrent={agent.id === activeAgent.id}
onSelect={onSelect}
/>
))}
</DropdownMenuGroup>
)}
{mine.length > 0 && others.length > 0 && <DropdownMenuSeparator />}
{others.length > 0 && (
<DropdownMenuGroup>
<DropdownMenuLabel>Others</DropdownMenuLabel>
{others.map((agent) => (
<AgentMenuItem
key={agent.id}
agent={agent}
isCurrent={agent.id === activeAgent.id}
onSelect={onSelect}
/>
))}
</DropdownMenuGroup>
)}
</DropdownMenuContent>
</DropdownMenu>
);
}
function AgentMenuItem({
agent,
isCurrent,
onSelect,
}: {
agent: Agent;
isCurrent: boolean;
onSelect: (agent: Agent) => void;
}) {
return (
<DropdownMenuItem
onClick={() => onSelect(agent)}
className="flex min-w-0 items-center gap-2"
>
<AgentAvatarSmall agent={agent} />
<span className="truncate flex-1">{agent.name}</span>
{isCurrent && <Check className="size-3.5 text-muted-foreground shrink-0" />}
</DropdownMenuItem>
);
}
function AgentAvatarSmall({ agent }: { agent: Agent | null }) {
return (
<Avatar className="size-6 shrink-0">
{agent?.avatar_url && <AvatarImage src={agent.avatar_url} />}
<AvatarFallback className="bg-purple-100 text-purple-700">
<Bot className="size-3.5" />
</AvatarFallback>
</Avatar>
);
}
const STARTER_PROMPTS: { icon: string; text: string }[] = [
{ icon: "📋", text: "List my open tasks by priority" },
{ icon: "📝", text: "Summarize what I did today" },
{ icon: "💡", text: "Plan what to work on next" },
];
function EmptyState({
agentName,
onPickPrompt,
}: {
agentName?: string;
onPickPrompt: (text: string) => void;
}) {
return (
<div className="flex flex-1 flex-col items-center justify-center gap-6 px-6 py-12">
<div className="text-center space-y-1">
<h3 className="text-xl font-semibold">
{agentName ? `Hi, I'm ${agentName}` : "Welcome to Multica"}
</h3>
<p className="text-sm text-muted-foreground">How can I help?</p>
</div>
<div className="w-full max-w-md space-y-2">
{STARTER_PROMPTS.map((prompt) => (
<button
key={prompt.text}
type="button"
onClick={() => onPickPrompt(prompt.text)}
className="w-full rounded-lg border border-border bg-card px-3 py-2 text-left text-sm text-foreground transition-colors hover:bg-accent hover:border-brand/40"
>
<span className="mr-2">{prompt.icon}</span>
{prompt.text}
</button>
))}
</div>
</div>
);
}

View File

@@ -1,34 +0,0 @@
"use client";
import React from "react";
type DragDir = "left" | "top" | "corner";
interface ChatResizeHandlesProps {
onDragStart: (e: React.PointerEvent, dir: DragDir) => void;
}
export function ChatResizeHandles({ onDragStart }: ChatResizeHandlesProps) {
return (
<>
{/* Left edge — expands width when dragged left */}
<div
aria-hidden
onPointerDown={(e) => onDragStart(e, "left")}
className="absolute left-0 top-4 bottom-0 w-1 z-10 cursor-col-resize"
/>
{/* Top edge — expands height when dragged up */}
<div
aria-hidden
onPointerDown={(e) => onDragStart(e, "top")}
className="absolute top-0 left-4 right-0 h-1 z-10 cursor-row-resize"
/>
{/* Top-left corner — expands both width and height */}
<div
aria-hidden
onPointerDown={(e) => onDragStart(e, "corner")}
className="absolute top-0 left-0 size-4 z-20 cursor-nw-resize"
/>
</>
);
}

View File

@@ -1,148 +0,0 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { ArrowLeft, MessageSquare, Bot } from "lucide-react";
import { cn } from "@multica/ui/lib/utils";
import { Button } from "@multica/ui/components/ui/button";
import { Tooltip, TooltipTrigger, TooltipContent } from "@multica/ui/components/ui/tooltip";
import { Avatar, AvatarFallback, AvatarImage } from "@multica/ui/components/ui/avatar";
import { useWorkspaceId } from "@multica/core/hooks";
import { agentListOptions } from "@multica/core/workspace/queries";
import { allChatSessionsOptions } from "@multica/core/chat/queries";
import { useChatStore } from "@multica/core/chat";
import { createLogger } from "@multica/core/logger";
import type { ChatSession, Agent } from "@multica/core/types";
const logger = createLogger("chat.ui");
export function ChatSessionHistory() {
const wsId = useWorkspaceId();
const setShowHistory = useChatStore((s) => s.setShowHistory);
const setActiveSession = useChatStore((s) => s.setActiveSession);
const activeSessionId = useChatStore((s) => s.activeSessionId);
const { data: sessions = [] } = useQuery(allChatSessionsOptions(wsId));
const { data: agents = [] } = useQuery(agentListOptions(wsId));
const agentMap = new Map(agents.map((a) => [a.id, a]));
const handleSelectSession = (session: ChatSession) => {
logger.info("selectSession", {
from: activeSessionId,
to: session.id,
agentId: session.agent_id,
status: session.status,
});
// Changing activeSessionId flips the query keys for messages +
// pending-task; no manual clear needed.
setActiveSession(session.id);
setShowHistory(false);
};
return (
<div className="flex flex-1 flex-col overflow-hidden">
{/* Header */}
<div className="flex items-center gap-2 border-b px-4 py-2.5">
<Tooltip>
<TooltipTrigger
render={
<Button
variant="ghost"
size="icon-sm"
className="text-muted-foreground"
onClick={() => setShowHistory(false)}
/>
}
>
<ArrowLeft />
</TooltipTrigger>
<TooltipContent side="bottom">Back</TooltipContent>
</Tooltip>
<span className="text-sm font-medium">Chat History</span>
</div>
{/* Session list */}
<div className="flex-1 overflow-y-auto">
{sessions.length === 0 ? (
<div className="flex flex-col items-center justify-center gap-2 py-12 text-muted-foreground">
<MessageSquare className="size-6" />
<span className="text-sm">No chat sessions yet</span>
</div>
) : (
<div>
{sessions.map((session) => (
<SessionItem
key={session.id}
session={session}
agent={agentMap.get(session.agent_id) ?? null}
isActive={session.id === activeSessionId}
onSelect={() => handleSelectSession(session)}
/>
))}
</div>
)}
</div>
</div>
);
}
function SessionItem({
session,
agent,
isActive,
onSelect,
}: {
session: ChatSession;
agent: Agent | null;
isActive: boolean;
onSelect: () => void;
}) {
const timeAgo = formatTimeAgo(session.updated_at);
return (
<button
onClick={onSelect}
className={cn(
"flex w-full items-start gap-3 px-4 py-2.5 text-left transition-colors hover:bg-accent/50",
isActive && "bg-accent/30",
)}
>
<Avatar className="size-6 shrink-0 mt-0.5">
{agent?.avatar_url && <AvatarImage src={agent.avatar_url} />}
<AvatarFallback className="bg-purple-100 text-purple-700">
<Bot className="size-3" />
</AvatarFallback>
</Avatar>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="truncate text-sm font-medium">
{session.title || "Untitled"}
</span>
</div>
<div className="flex items-center gap-1.5 mt-0.5">
{agent && (
<span className="text-xs text-muted-foreground truncate">
{agent.name}
</span>
)}
<span className="text-xs text-muted-foreground/60">{timeAgo}</span>
</div>
</div>
</button>
);
}
function formatTimeAgo(dateStr: string): string {
const date = new Date(dateStr);
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffMins = Math.floor(diffMs / 60000);
const diffHours = Math.floor(diffMs / 3600000);
const diffDays = Math.floor(diffMs / 86400000);
if (diffMins < 1) return "just now";
if (diffMins < 60) return `${diffMins}m ago`;
if (diffHours < 24) return `${diffHours}h ago`;
if (diffDays < 7) return `${diffDays}d ago`;
return date.toLocaleDateString();
}

View File

@@ -1,648 +0,0 @@
"use client";
import React, { useCallback, useEffect, useMemo, useRef } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { Minus, Maximize2, Minimize2, ChevronDown, Bot, Plus, Check } from "lucide-react";
import { Avatar, AvatarFallback, AvatarImage } from "@multica/ui/components/ui/avatar";
import { Button } from "@multica/ui/components/ui/button";
import { Tooltip, TooltipTrigger, TooltipContent } from "@multica/ui/components/ui/tooltip";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@multica/ui/components/ui/dropdown-menu";
import { useWorkspaceId } from "@multica/core/hooks";
import { useAuthStore } from "@multica/core/auth";
import { agentListOptions, memberListOptions } from "@multica/core/workspace/queries";
import { canAssignAgent } from "@multica/views/issues/components";
import { api } from "@multica/core/api";
import {
chatSessionsOptions,
allChatSessionsOptions,
chatMessagesOptions,
pendingChatTaskOptions,
chatKeys,
} from "@multica/core/chat/queries";
import { useCreateChatSession, useMarkChatSessionRead } from "@multica/core/chat/mutations";
import { useChatStore } from "@multica/core/chat";
import { ChatMessageList, ChatMessageSkeleton } from "./chat-message-list";
import { ChatInput } from "./chat-input";
import {
ContextAnchorButton,
ContextAnchorCard,
buildAnchorMarkdown,
useRouteAnchorCandidate,
} from "./context-anchor";
import { ChatResizeHandles } from "./chat-resize-handles";
import { useChatResize } from "./use-chat-resize";
import { createLogger } from "@multica/core/logger";
import type { Agent, ChatMessage, ChatSession } from "@multica/core/types";
const uiLogger = createLogger("chat.ui");
const apiLogger = createLogger("chat.api");
export function ChatWindow() {
const wsId = useWorkspaceId();
const isOpen = useChatStore((s) => s.isOpen);
const activeSessionId = useChatStore((s) => s.activeSessionId);
const selectedAgentId = useChatStore((s) => s.selectedAgentId);
const setOpen = useChatStore((s) => s.setOpen);
const setActiveSession = useChatStore((s) => s.setActiveSession);
const setSelectedAgentId = useChatStore((s) => s.setSelectedAgentId);
const user = useAuthStore((s) => s.user);
const { data: agents = [] } = useQuery(agentListOptions(wsId));
const { data: members = [] } = useQuery(memberListOptions(wsId));
const { data: sessions = [] } = useQuery(chatSessionsOptions(wsId));
const { data: allSessions = [] } = useQuery(allChatSessionsOptions(wsId));
const { data: rawMessages, isLoading: messagesLoading } = useQuery(
chatMessagesOptions(activeSessionId ?? ""),
);
// When no active session, always show empty — don't use stale cache
const messages = activeSessionId ? rawMessages ?? [] : [];
// Skeleton only shows for an un-cached session fetch. Cached switches
// return data synchronously — no flash. `enabled: false` (new chat)
// keeps isLoading false so the starter prompts aren't hidden.
const showSkeleton = !!activeSessionId && messagesLoading;
// Server-authoritative pending task. Survives refresh / reopen / session
// switch because it's keyed on sessionId in the Query cache; WS events
// (chat:message / chat:done / task:*) keep it invalidated in real time.
//
// This is the SOLE source for pendingTaskId — no mirror in the store.
const { data: pendingTask } = useQuery(
pendingChatTaskOptions(activeSessionId ?? ""),
);
const pendingTaskId = pendingTask?.task_id ?? null;
// Check if current session is archived
const currentSession = activeSessionId
? allSessions.find((s) => s.id === activeSessionId)
: null;
const isSessionArchived = currentSession?.status === "archived";
const qc = useQueryClient();
const createSession = useCreateChatSession();
const markRead = useMarkChatSessionRead();
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),
);
// Resolve selected agent: stored preference → first available
const activeAgent =
availableAgents.find((a) => a.id === selectedAgentId) ??
availableAgents[0] ??
null;
// Mount / unmount logging. ChatWindow lives in DashboardLayout, so this
// fires on layout mount (login / workspace switch / fresh page load).
useEffect(() => {
uiLogger.info("ChatWindow mount", {
isOpen,
activeSessionId,
pendingTaskId,
selectedAgentId,
wsId,
});
return () => {
uiLogger.info("ChatWindow unmount", {
activeSessionId,
pendingTaskId,
});
};
// eslint-disable-next-line react-hooks/exhaustive-deps -- once per mount
}, []);
// Auto-restore most recent active session from server (only once on mount)
const didRestoreRef = useRef(false);
useEffect(() => {
if (didRestoreRef.current) return;
didRestoreRef.current = true;
if (activeSessionId || sessions.length === 0) {
uiLogger.debug("restore session skipped", {
reason: activeSessionId ? "already has session" : "no sessions",
activeSessionId,
sessionCount: sessions.length,
});
return;
}
const latest = sessions.find((s) => s.status === "active");
if (latest) {
uiLogger.info("restore session on mount", { sessionId: latest.id });
setActiveSession(latest.id);
} else {
uiLogger.debug("restore session: no active session found");
}
// eslint-disable-next-line react-hooks/exhaustive-deps -- run once when sessions load
}, [sessions]);
// WS events are handled globally in useRealtimeSync — the query cache
// stays current even when this window is closed. See packages/core/realtime/.
// Auto mark-as-read whenever the user is looking at a session with unread
// state: window open + a session active + has_unread → PATCH.
// has_unread comes from the list query; WS handlers invalidate it on
// chat:done so a reply arriving while the user watches triggers this
// effect again and is instantly cleared.
const currentHasUnread =
sessions.find((s) => s.id === activeSessionId)?.has_unread ?? false;
useEffect(() => {
if (!isOpen || !activeSessionId) return;
if (!currentHasUnread) return;
uiLogger.info("auto markRead", { sessionId: activeSessionId });
markRead.mutate(activeSessionId);
// eslint-disable-next-line react-hooks/exhaustive-deps -- markRead ref stable
}, [isOpen, activeSessionId, currentHasUnread]);
// Focus-mode anchor: derived from route each render. Prepended to the
// outgoing message when focus is on; the anchor persists across sends
// (focus mode tracks the user's page, not a per-message attachment).
const { candidate: anchorCandidate } = useRouteAnchorCandidate(wsId);
const handleSend = useCallback(
async (content: string) => {
if (!activeAgent) {
apiLogger.warn("sendChatMessage skipped: no active agent");
return;
}
const focusOn = useChatStore.getState().focusMode;
const finalContent = focusOn && anchorCandidate
? `${buildAnchorMarkdown(anchorCandidate)}\n\n${content}`
: content;
let sessionId = activeSessionId;
const isNewSession = !sessionId;
apiLogger.info("sendChatMessage.start", {
sessionId,
isNewSession,
agentId: activeAgent.id,
contentLength: finalContent.length,
hasAnchor: focusOn && !!anchorCandidate,
});
if (!sessionId) {
const session = await createSession.mutateAsync({
agent_id: activeAgent.id,
title: finalContent.slice(0, 50),
});
sessionId = session.id;
setActiveSession(sessionId);
}
// Optimistic: show user message immediately.
const optimistic: ChatMessage = {
id: `optimistic-${Date.now()}`,
chat_session_id: sessionId,
role: "user",
content: finalContent,
task_id: null,
created_at: new Date().toISOString(),
};
qc.setQueryData<ChatMessage[]>(
chatKeys.messages(sessionId),
(old) => (old ? [...old, optimistic] : [optimistic]),
);
apiLogger.debug("sendChatMessage.optimistic", { sessionId, optimisticId: optimistic.id });
const result = await api.sendChatMessage(sessionId, finalContent);
apiLogger.info("sendChatMessage.success", {
sessionId,
messageId: result.message_id,
taskId: result.task_id,
});
// Seed pending-task optimistically so the spinner shows instantly —
// the WS chat:message handler will invalidate + refetch to confirm.
qc.setQueryData(chatKeys.pendingTask(sessionId), {
task_id: result.task_id,
status: "queued",
});
qc.invalidateQueries({ queryKey: chatKeys.messages(sessionId) });
},
[
activeSessionId,
activeAgent,
anchorCandidate,
createSession,
setActiveSession,
qc,
],
);
const handleStop = useCallback(async () => {
if (!pendingTaskId) {
apiLogger.debug("cancelTask skipped: no pending task");
return;
}
apiLogger.info("cancelTask.start", { taskId: pendingTaskId, sessionId: activeSessionId });
try {
await api.cancelTaskById(pendingTaskId);
apiLogger.info("cancelTask.success", { taskId: pendingTaskId });
} catch (err) {
// Task may already be completed
apiLogger.warn("cancelTask.error (task may have already finished)", { taskId: pendingTaskId, err });
}
if (activeSessionId) {
// Clear pending immediately; WS task:cancelled will confirm.
qc.setQueryData(chatKeys.pendingTask(activeSessionId), {});
qc.invalidateQueries({ queryKey: chatKeys.messages(activeSessionId) });
}
}, [pendingTaskId, activeSessionId, qc]);
const handleSelectAgent = useCallback(
(agent: Agent) => {
// No-op when clicking the already-active agent — don't clobber the
// current session just because the user closed the menu this way.
// Compare against activeAgent (what the UI shows), not selectedAgentId
// (which may be null / point to an archived agent on first load).
if (activeAgent && agent.id === activeAgent.id) return;
uiLogger.info("selectAgent", {
from: selectedAgentId,
to: agent.id,
previousSessionId: activeSessionId,
});
setSelectedAgentId(agent.id);
// Reset session when switching agent
setActiveSession(null);
},
[activeAgent, selectedAgentId, activeSessionId, setSelectedAgentId, setActiveSession],
);
const handleNewChat = useCallback(() => {
uiLogger.info("newChat", {
previousSessionId: activeSessionId,
previousPendingTask: pendingTaskId,
});
setActiveSession(null);
}, [activeSessionId, pendingTaskId, setActiveSession]);
const handleSelectSession = useCallback(
(session: ChatSession) => {
// 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 handleMinimize = useCallback(() => {
uiLogger.info("minimize (close)", {
activeSessionId,
pendingTaskId,
});
setOpen(false);
}, [activeSessionId, pendingTaskId, setOpen]);
const windowRef = useRef<HTMLDivElement>(null);
const { renderWidth, renderHeight, isAtMax, boundsReady, isDragging, toggleExpand, startDrag } = useChatResize(windowRef);
// Show the list (vs empty state) as soon as there's anything to display —
// a real message, or a pending task whose timeline will stream in.
const hasMessages = messages.length > 0 || !!pendingTaskId;
const isVisible = isOpen && boundsReady;
const containerClass = "absolute bottom-2 right-2 z-50 flex flex-col rounded-xl ring-1 ring-foreground/10 bg-sidebar shadow-2xl overflow-hidden";
const containerStyle: React.CSSProperties = {
width: `${renderWidth}px`,
height: `${renderHeight}px`,
opacity: isVisible ? 1 : 0,
transform: isVisible ? "scale(1)" : "scale(0.95)",
transformOrigin: "bottom right",
pointerEvents: isOpen ? "auto" : "none",
transition: isDragging
? "none"
: "width 200ms ease-out, height 200ms ease-out, opacity 150ms ease-out, transform 150ms ease-out",
};
return (
<div ref={windowRef} className={containerClass} style={containerStyle}>
<ChatResizeHandles onDragStart={startDrag} />
{/* Header — ⊕ new + session dropdown | window tools */}
<div className="flex items-center justify-between border-b px-4 py-2.5 gap-2">
<div className="flex items-center gap-1 min-w-0">
<Tooltip>
<TooltipTrigger
render={
<Button
variant="ghost"
size="icon-sm"
className="rounded-full text-muted-foreground"
onClick={handleNewChat}
/>
}
>
<Plus />
</TooltipTrigger>
<TooltipContent side="top">New chat</TooltipContent>
</Tooltip>
<SessionDropdown
sessions={sessions}
// Use the full agent list (incl. archived) so historical
// sessions can still resolve their avatar.
agents={agents}
activeSessionId={activeSessionId}
onSelectSession={handleSelectSession}
/>
</div>
<div className="flex items-center gap-0.5 shrink-0">
<Tooltip>
<TooltipTrigger
render={
<Button
variant="ghost"
size="icon-sm"
className="text-muted-foreground"
onClick={toggleExpand}
/>
}
>
{isAtMax ? <Minimize2 /> : <Maximize2 />}
</TooltipTrigger>
<TooltipContent side="top">
{isAtMax ? "Restore" : "Expand"}
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger
render={
<Button
variant="ghost"
size="icon-sm"
className="text-muted-foreground"
onClick={handleMinimize}
/>
}
>
<Minus />
</TooltipTrigger>
<TooltipContent side="top">Minimize</TooltipContent>
</Tooltip>
</div>
</div>
{/* Messages / skeleton / empty state */}
{showSkeleton ? (
<ChatMessageSkeleton />
) : hasMessages ? (
<ChatMessageList
messages={messages}
pendingTaskId={pendingTaskId}
isWaiting={!!pendingTaskId}
/>
) : (
<EmptyState
agentName={activeAgent?.name}
onPickPrompt={(text) => handleSend(text)}
/>
)}
{/* Input — disabled for archived sessions */}
<ChatInput
onSend={handleSend}
onStop={handleStop}
isRunning={!!pendingTaskId}
disabled={isSessionArchived}
agentName={activeAgent?.name}
topSlot={<ContextAnchorCard />}
leftAdornment={
<AgentDropdown
agents={availableAgents}
activeAgent={activeAgent}
userId={user?.id}
onSelect={handleSelectAgent}
/>
}
rightAdornment={<ContextAnchorButton />}
/>
</div>
);
}
/**
* Agent dropdown: avatar trigger, lists all available agents. Selecting a
* different agent = switch agent + start a fresh chat (session=null).
* The current agent is marked with a check and not clickable.
*/
function AgentDropdown({
agents,
activeAgent,
userId,
onSelect,
}: {
agents: Agent[];
activeAgent: Agent | null;
userId: string | undefined;
onSelect: (agent: Agent) => void;
}) {
// Split into the user's own agents and everyone else so the menu groups
// them — matches the old AgentSelector layout.
const { mine, others } = useMemo(() => {
const mine: Agent[] = [];
const others: Agent[] = [];
for (const a of agents) {
if (a.owner_id === userId) mine.push(a);
else others.push(a);
}
return { mine, others };
}, [agents, userId]);
if (!activeAgent) {
return <span className="text-xs text-muted-foreground">No agents</span>;
}
return (
<DropdownMenu>
<DropdownMenuTrigger className="flex items-center gap-1.5 rounded-md px-1.5 py-1 -ml-1 cursor-pointer outline-none transition-colors hover:bg-accent aria-expanded:bg-accent">
<AgentAvatarSmall agent={activeAgent} />
<span className="text-xs font-medium max-w-28 truncate">{activeAgent.name}</span>
<ChevronDown className="size-3 text-muted-foreground shrink-0" />
</DropdownMenuTrigger>
<DropdownMenuContent align="start" side="top" className="max-h-80 w-auto max-w-64">
{mine.length > 0 && (
<DropdownMenuGroup>
<DropdownMenuLabel>My agents</DropdownMenuLabel>
{mine.map((agent) => (
<AgentMenuItem
key={agent.id}
agent={agent}
isCurrent={agent.id === activeAgent.id}
onSelect={onSelect}
/>
))}
</DropdownMenuGroup>
)}
{mine.length > 0 && others.length > 0 && <DropdownMenuSeparator />}
{others.length > 0 && (
<DropdownMenuGroup>
<DropdownMenuLabel>Others</DropdownMenuLabel>
{others.map((agent) => (
<AgentMenuItem
key={agent.id}
agent={agent}
isCurrent={agent.id === activeAgent.id}
onSelect={onSelect}
/>
))}
</DropdownMenuGroup>
)}
</DropdownMenuContent>
</DropdownMenu>
);
}
function AgentMenuItem({
agent,
isCurrent,
onSelect,
}: {
agent: Agent;
isCurrent: boolean;
onSelect: (agent: Agent) => void;
}) {
return (
<DropdownMenuItem
onClick={() => onSelect(agent)}
className="flex min-w-0 items-center gap-2"
>
<AgentAvatarSmall agent={agent} />
<span className="truncate flex-1">{agent.name}</span>
{isCurrent && <Check className="size-3.5 text-muted-foreground shrink-0" />}
</DropdownMenuItem>
);
}
/**
* Session dropdown: lists ALL sessions across agents. Each row carries the
* owning agent's avatar so the user can tell them apart. Selecting a
* session from a different agent implicitly switches the agent too
* (sessions are bound 1:1 to an agent). "New chat" lives in the header's
* ⊕ button, not inside this dropdown.
*/
function SessionDropdown({
sessions,
agents,
activeSessionId,
onSelectSession,
}: {
sessions: ChatSession[];
agents: Agent[];
activeSessionId: string | null;
onSelectSession: (session: ChatSession) => void;
}) {
const agentById = useMemo(() => new Map(agents.map((a) => [a.id, a])), [agents]);
const activeSession = sessions.find((s) => s.id === activeSessionId);
const title = activeSession?.title?.trim() || "New chat";
const triggerAgent = activeSession ? agentById.get(activeSession.agent_id) ?? null : null;
return (
<DropdownMenu>
<DropdownMenuTrigger className="flex items-center gap-1.5 min-w-0 rounded-md px-1.5 py-1 transition-colors hover:bg-accent aria-expanded:bg-accent">
{triggerAgent && <AgentAvatarSmall agent={triggerAgent} />}
<span className="truncate text-sm font-medium">{title}</span>
<ChevronDown className="size-3 text-muted-foreground shrink-0" />
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="max-h-80 w-auto min-w-56 max-w-80">
{sessions.length === 0 ? (
<div className="px-2 py-1.5 text-xs text-muted-foreground">
No previous chats
</div>
) : (
sessions.map((session) => {
const isCurrent = session.id === activeSessionId;
const agent = agentById.get(session.agent_id) ?? null;
return (
<DropdownMenuItem
key={session.id}
onClick={() => onSelectSession(session)}
className="flex min-w-0 items-center gap-2"
>
{agent ? (
<AgentAvatarSmall agent={agent} />
) : (
<span className="size-6 shrink-0" />
)}
<span className="truncate flex-1 text-sm">
{session.title?.trim() || "New chat"}
</span>
{session.has_unread && (
<span className="size-1.5 shrink-0 rounded-full bg-brand" />
)}
{isCurrent && <Check className="size-3.5 text-muted-foreground shrink-0" />}
</DropdownMenuItem>
);
})
)}
</DropdownMenuContent>
</DropdownMenu>
);
}
function AgentAvatarSmall({ agent }: { agent: Agent }) {
return (
<Avatar className="size-6">
{agent.avatar_url && <AvatarImage src={agent.avatar_url} />}
<AvatarFallback className="bg-purple-100 text-purple-700">
<Bot className="size-3.5" />
</AvatarFallback>
</Avatar>
);
}
/**
* Three starter prompts shown on the empty state. Tapping one sends it
* immediately — ChatGPT-style — because the point is showing users what
* this chat is for: operating on the workspace, not open-ended Q&A.
*/
const STARTER_PROMPTS: { icon: string; text: string }[] = [
{ icon: "📋", text: "List my open tasks by priority" },
{ icon: "📝", text: "Summarize what I did today" },
{ icon: "💡", text: "Plan what to work on next" },
];
function EmptyState({
agentName,
onPickPrompt,
}: {
agentName?: string;
onPickPrompt: (text: string) => void;
}) {
return (
<div className="flex flex-1 flex-col items-center justify-center gap-5 px-6 py-8">
<div className="text-center space-y-1">
<h3 className="text-base font-semibold">
{agentName ? `Hi, I'm ${agentName}` : "Welcome to Multica"}
</h3>
<p className="text-sm text-muted-foreground">Try asking</p>
</div>
<div className="w-full max-w-xs space-y-2">
{STARTER_PROMPTS.map((prompt) => (
<button
key={prompt.text}
type="button"
onClick={() => onPickPrompt(prompt.text)}
className="w-full rounded-lg border border-border bg-card px-3 py-2 text-left text-sm text-foreground transition-colors hover:bg-accent hover:border-brand/40"
>
<span className="mr-2">{prompt.icon}</span>
{prompt.text}
</button>
))}
</div>
</div>
);
}

View File

@@ -1,5 +1,6 @@
"use client";
import { useEffect } from "react";
import { useQuery } from "@tanstack/react-query";
import { Focus } from "lucide-react";
import type { ContextAnchor } from "@multica/core/chat";
@@ -33,11 +34,42 @@ export function buildAnchorMarkdown(anchor: ContextAnchor): string {
return `Context: Project "${anchor.label}"`;
}
/**
* Returns true when the given pathname can resolve to an anchor candidate
* (issue detail, project detail, or inbox). Used by both the resolver and
* the tracker so they agree on which routes are anchor-eligible.
*/
function isAnchorEligiblePath(pathname: string): boolean {
if (/^\/[^/]+\/issues\/[^/]+$/.test(pathname)) return true;
if (/^\/[^/]+\/projects\/[^/]+$/.test(pathname)) return true;
if (/^\/[^/]+\/inbox$/.test(pathname)) return true;
return false;
}
/**
* Runs an effect that remembers the last anchor-eligible location the user
* visited. Mount this in a component that's present on every page (the app
* sidebar) so the chat page — which is its own route and therefore has no
* anchor of its own — can still know what the user was just looking at.
*/
export function useAnchorTracker(): void {
const { pathname, searchParams } = useNavigation();
const setLastAnchorLocation = useChatStore((s) => s.setLastAnchorLocation);
useEffect(() => {
if (!isAnchorEligiblePath(pathname)) return;
setLastAnchorLocation({ pathname, search: searchParams.toString() });
}, [pathname, searchParams, setLastAnchorLocation]);
}
/**
* Resolve the current page into an anchorable candidate, or null if the user
* is somewhere without a natural focus object. Subscribes via react-query so
* the result updates the instant the relevant cache fills.
*
* When the user is on the Chat route (no intrinsic anchor), falls back to
* the last anchor-eligible location remembered by `useAnchorTracker`, so
* "open Chat from an issue → focus mode still attaches that issue" works.
*
* `wsId` is passed in (per CLAUDE.md convention) so this hook works outside
* a WorkspaceIdProvider if ever reused elsewhere.
*/
@@ -46,10 +78,20 @@ export function useRouteAnchorCandidate(wsId: string): {
isResolving: boolean;
} {
const { pathname, searchParams } = useNavigation();
const lastAnchorLocation = useChatStore((s) => s.lastAnchorLocation);
const issueMatch = pathname.match(/^\/[^/]+\/issues\/([^/]+)$/);
const projectMatch = pathname.match(/^\/[^/]+\/projects\/([^/]+)$/);
const isInbox = /^\/[^/]+\/inbox$/.test(pathname);
// On the Chat route there's no intrinsic anchor; substitute the last
// anchor-eligible location the user visited. Anywhere else, use the
// live route directly.
const useFallback = !isAnchorEligiblePath(pathname) && !!lastAnchorLocation;
const effectivePath = useFallback ? lastAnchorLocation!.pathname : pathname;
const effectiveSearch = useFallback
? new URLSearchParams(lastAnchorLocation!.search)
: searchParams;
const issueMatch = effectivePath.match(/^\/[^/]+\/issues\/([^/]+)$/);
const projectMatch = effectivePath.match(/^\/[^/]+\/projects\/([^/]+)$/);
const isInbox = /^\/[^/]+\/inbox$/.test(effectivePath);
const routeIssueId = issueMatch ? decodeURIComponent(issueMatch[1]!) : null;
const routeProjectId = projectMatch
@@ -61,7 +103,7 @@ export function useRouteAnchorCandidate(wsId: string): {
...inboxListOptions(wsId),
enabled: isInbox,
});
const inboxKey = isInbox ? searchParams.get("issue") : null;
const inboxKey = isInbox ? effectiveSearch.get("issue") : null;
const inboxSelectedIssueId =
isInbox && inboxKey
? inboxItems.find((i) => (i.issue_id ?? i.id) === inboxKey)?.issue_id ??

View File

@@ -1,140 +0,0 @@
"use client";
import React, { useRef, useCallback, useState, useEffect } from "react";
import { CHAT_MIN_W, CHAT_MIN_H, useChatStore } from "@multica/core/chat";
type DragDir = "left" | "top" | "corner";
const MAX_RATIO = 0.9;
const FALLBACK_MAX_W = 800;
const FALLBACK_MAX_H = 700;
function clamp(v: number, min: number, max: number) {
return Math.max(min, Math.min(max, v));
}
export function useChatResize(
windowRef: React.RefObject<HTMLDivElement | null>,
) {
const chatWidth = useChatStore((s) => s.chatWidth);
const chatHeight = useChatStore((s) => s.chatHeight);
const isExpanded = useChatStore((s) => s.isExpanded);
const setChatSize = useChatStore((s) => s.setChatSize);
const setExpanded = useChatStore((s) => s.setExpanded);
// ── Container bounds via ResizeObserver ────────────────────────────────
const boundsRef = useRef({ maxW: FALLBACK_MAX_W, maxH: FALLBACK_MAX_H });
const [boundsReady, setBoundsReady] = useState(false);
const [isDragging, setIsDragging] = useState(false);
const [, setRevision] = useState(0);
useEffect(() => {
const el = windowRef.current;
const parent = el?.parentElement;
if (!parent) return;
const update = () => {
const maxW = Math.floor(parent.clientWidth * MAX_RATIO);
const maxH = Math.floor(parent.clientHeight * MAX_RATIO);
setBoundsReady(true); // idempotent once true
// Only trigger a re-render if the bounds actually changed. Without this
// guard, any spurious ResizeObserver notification (including sub-pixel
// layout jitter during mount) schedules a setState that feeds back into
// the observer, producing "Maximum update depth exceeded".
const prev = boundsRef.current;
if (prev.maxW === maxW && prev.maxH === maxH) return;
boundsRef.current = { maxW, maxH };
setRevision((r) => r + 1);
};
// Measure immediately (parent is already in DOM at this point)
update();
const ro = new ResizeObserver(update);
ro.observe(parent);
return () => ro.disconnect();
}, [windowRef]);
// ── Derive rendered size ──────────────────────────────────────────────
const { maxW, maxH } = boundsRef.current;
const renderWidth = isExpanded ? maxW : clamp(chatWidth, CHAT_MIN_W, maxW);
const renderHeight = isExpanded ? maxH : clamp(chatHeight, CHAT_MIN_H, maxH);
// ── Expand / Restore ──────────────────────────────────────────────────
const isAtMax = renderWidth >= maxW && renderHeight >= maxH;
const toggleExpand = useCallback(() => {
if (isExpanded || isAtMax) {
setChatSize(CHAT_MIN_W, CHAT_MIN_H);
} else {
setExpanded(true);
}
}, [isExpanded, isAtMax, setChatSize, setExpanded]);
// ── Drag ──────────────────────────────────────────────────────────────
const dragRef = useRef<{
startX: number;
startY: number;
startW: number;
startH: number;
dir: DragDir;
} | null>(null);
const startDrag = useCallback(
(e: React.PointerEvent, dir: DragDir) => {
e.preventDefault();
(e.target as HTMLElement).setPointerCapture(e.pointerId);
dragRef.current = {
startX: e.clientX,
startY: e.clientY,
startW: renderWidth,
startH: renderHeight,
dir,
};
setIsDragging(true);
const onPointerMove = (ev: PointerEvent) => {
const d = dragRef.current;
if (!d) return;
const { maxW: mw, maxH: mh } = boundsRef.current;
const rawW =
dir === "left" || dir === "corner"
? d.startW - (ev.clientX - d.startX)
: d.startW;
const rawH =
dir === "top" || dir === "corner"
? d.startH - (ev.clientY - d.startY)
: d.startH;
setChatSize(clamp(rawW, CHAT_MIN_W, mw), clamp(rawH, CHAT_MIN_H, mh));
};
const onPointerUp = () => {
dragRef.current = null;
setIsDragging(false);
document.removeEventListener("pointermove", onPointerMove);
document.removeEventListener("pointerup", onPointerUp);
document.body.style.cursor = "";
document.body.style.userSelect = "";
};
document.addEventListener("pointermove", onPointerMove);
document.addEventListener("pointerup", onPointerUp);
const cursorMap: Record<DragDir, string> = {
left: "col-resize",
top: "row-resize",
corner: "nw-resize",
};
document.body.style.cursor = cursorMap[dir];
document.body.style.userSelect = "none";
},
[renderWidth, renderHeight, setChatSize],
);
return { renderWidth, renderHeight, isAtMax, boundsReady, isDragging, toggleExpand, startDrag };
}

View File

@@ -1,2 +1 @@
export { ChatFab } from "./components/chat-fab";
export { ChatWindow } from "./components/chat-window";
export { ChatPage } from "./components/chat-page";

View File

@@ -24,6 +24,7 @@ const WORKSPACE_ROUTE_SEGMENTS = new Set([
"autopilots",
"agents",
"inbox",
"chat",
"my-issues",
"runtimes",
"skills",

View File

@@ -29,6 +29,8 @@ import {
SquarePen,
CircleUser,
FolderKanban,
MessageSquare,
Loader2,
X,
Zap,
} from "lucide-react";
@@ -65,6 +67,8 @@ import { useCurrentWorkspace, useWorkspacePaths, paths } from "@multica/core/pat
import { workspaceListOptions, myInvitationListOptions, workspaceKeys } from "@multica/core/workspace/queries";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { inboxKeys, deduplicateInboxItems } from "@multica/core/inbox/queries";
import { chatSessionsOptions, pendingChatTasksOptions } from "@multica/core/chat/queries";
import { useAnchorTracker } from "../chat/components/context-anchor";
import { api } from "@multica/core/api";
import { useModalStore } from "@multica/core/modals";
import { useMyRuntimesNeedUpdate } from "@multica/core/runtimes/hooks";
@@ -84,12 +88,14 @@ const EMPTY_PINS: PinnedItem[] = [];
const EMPTY_WORKSPACES: Awaited<ReturnType<typeof api.listWorkspaces>> = [];
const EMPTY_INVITATIONS: Awaited<ReturnType<typeof api.listMyInvitations>> = [];
const EMPTY_INBOX: Awaited<ReturnType<typeof api.listInbox>> = [];
const EMPTY_CHAT_SESSIONS: Awaited<ReturnType<typeof api.listChatSessions>> = [];
// Nav items reference WorkspacePaths method names so they can be resolved
// against the current workspace slug at render time (see AppSidebar body).
// Only parameterless paths are valid nav destinations.
type NavKey =
| "inbox"
| "chat"
| "myIssues"
| "issues"
| "projects"
@@ -101,6 +107,7 @@ type NavKey =
const personalNav: { key: NavKey; label: string; icon: typeof Inbox }[] = [
{ key: "inbox", label: "Inbox", icon: Inbox },
{ key: "chat", label: "Chat", icon: MessageSquare },
{ key: "myIssues", label: "My Issues", icon: CircleUser },
];
@@ -321,6 +328,22 @@ export function AppSidebar({ topSlot, searchSlot, headerClassName, headerStyle }
() => deduplicateInboxItems(inboxItems).filter((i) => !i.read).length,
[inboxItems],
);
const { data: chatSessions = EMPTY_CHAT_SESSIONS } = useQuery({
...chatSessionsOptions(wsId ?? ""),
enabled: !!wsId,
});
const hasChatUnread = React.useMemo(
() => chatSessions.some((s) => s.has_unread),
[chatSessions],
);
const { data: pendingChatTasks } = useQuery({
...pendingChatTasksOptions(wsId ?? ""),
enabled: !!wsId,
});
const hasChatRunning = (pendingChatTasks?.tasks.length ?? 0) > 0;
// Track last anchor-eligible route so the Chat page (which is its own route)
// can still resolve focus-mode context from the page the user was just on.
useAnchorTracker();
const hasRuntimeUpdates = useMyRuntimesNeedUpdate(wsId);
const { data: pinnedItems = EMPTY_PINS } = useQuery({
...pinListOptions(wsId ?? "", userId ?? ""),
@@ -575,6 +598,12 @@ export function AppSidebar({ topSlot, searchSlot, headerClassName, headerStyle }
{unreadCount > 99 ? "99+" : unreadCount}
</span>
)}
{item.label === "Chat" && hasChatRunning && (
<Loader2 className="ml-auto !size-3 animate-spin text-muted-foreground" />
)}
{item.label === "Chat" && !hasChatRunning && hasChatUnread && (
<span className="ml-auto size-1.5 rounded-full bg-brand" />
)}
</SidebarMenuButton>
</SidebarMenuItem>
);

View File

@@ -8,7 +8,7 @@ import { DashboardGuard } from "./dashboard-guard";
interface DashboardLayoutProps {
children: ReactNode;
/** Rendered inside SidebarInset (e.g. ChatWindow, ChatFab — absolute-positioned overlays) */
/** Rendered inside SidebarInset — absolute-positioned overlays */
extra?: ReactNode;
/** Rendered inside sidebar header as a search trigger */
searchSlot?: ReactNode;