From 35aca57939b5f4eae88a24023fa281e6f18c1180 Mon Sep 17 00:00:00 2001 From: Jiayuan Zhang Date: Fri, 24 Apr 2026 01:46:37 +0800 Subject: [PATCH] =?UTF-8?q?feat(chat):=20Chat=20V2=20=E2=80=94=20sidebar?= =?UTF-8?q?=20entry=20+=20main-area=20page=20(#1580)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 --- .../src/components/desktop-layout.tsx | 5 +- .../src/renderer/src/components/tab-bar.tsx | 2 + .../src/renderer/src/platform/navigation.tsx | 8 +- apps/desktop/src/renderer/src/routes.tsx | 2 + .../src/renderer/src/stores/tab-store.ts | 1 + .../[workspaceSlug]/(dashboard)/chat/page.tsx | 1 + .../[workspaceSlug]/(dashboard)/layout.tsx | 3 - apps/web/next-env.d.ts | 2 +- packages/core/chat/index.ts | 2 +- packages/core/chat/store.ts | 70 +- packages/core/paths/consistency.test.ts | 2 + packages/core/paths/paths.ts | 1 + packages/core/realtime/use-realtime-sync.ts | 2 +- packages/views/chat/components/chat-fab.tsx | 63 -- packages/views/chat/components/chat-input.tsx | 2 +- packages/views/chat/components/chat-page.tsx | 513 ++++++++++++++ .../chat/components/chat-resize-handles.tsx | 34 - .../chat/components/chat-session-history.tsx | 148 ---- .../views/chat/components/chat-window.tsx | 648 ------------------ .../views/chat/components/context-anchor.tsx | 50 +- .../views/chat/components/use-chat-resize.ts | 140 ---- packages/views/chat/index.ts | 3 +- packages/views/editor/utils/link-handler.ts | 1 + packages/views/layout/app-sidebar.tsx | 29 + packages/views/layout/dashboard-layout.tsx | 2 +- 25 files changed, 624 insertions(+), 1110 deletions(-) create mode 100644 apps/web/app/[workspaceSlug]/(dashboard)/chat/page.tsx delete mode 100644 packages/views/chat/components/chat-fab.tsx create mode 100644 packages/views/chat/components/chat-page.tsx delete mode 100644 packages/views/chat/components/chat-resize-handles.tsx delete mode 100644 packages/views/chat/components/chat-session-history.tsx delete mode 100644 packages/views/chat/components/chat-window.tsx delete mode 100644 packages/views/chat/components/use-chat-resize.ts diff --git a/apps/desktop/src/renderer/src/components/desktop-layout.tsx b/apps/desktop/src/renderer/src/components/desktop-layout.tsx index 15f488a8f..f8ecfddf9 100644 --- a/apps/desktop/src/renderer/src/components/desktop-layout.tsx +++ b/apps/desktop/src/renderer/src/components/desktop-layout.tsx @@ -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 */}
- {/* Content area with inset styling — relative so ChatWindow/ChatFab are constrained here */} + {/* Content area with inset styling */}
- {slug && } - {slug && }
diff --git a/apps/desktop/src/renderer/src/components/tab-bar.tsx b/apps/desktop/src/renderer/src/components/tab-bar.tsx index 93d139ac6..12ac36393 100644 --- a/apps/desktop/src/renderer/src/components/tab-bar.tsx +++ b/apps/desktop/src/renderer/src/components/tab-bar.tsx @@ -5,6 +5,7 @@ import { Bot, Monitor, BookOpenText, + MessageSquare, Settings, X, Plus, @@ -39,6 +40,7 @@ const TAB_ICONS: Record = { Bot, Monitor, BookOpenText, + MessageSquare, Settings, }; diff --git a/apps/desktop/src/renderer/src/platform/navigation.tsx b/apps/desktop/src/renderer/src/platform/navigation.tsx index 494916ed5..01043331a 100644 --- a/apps/desktop/src/renderer/src/platform/navigation.tsx +++ b/apps/desktop/src/renderer/src/platform/navigation.tsx @@ -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 ?? "/", diff --git a/apps/desktop/src/renderer/src/routes.tsx b/apps/desktop/src/renderer/src/routes.tsx index a6575cd62..931fbd1af 100644 --- a/apps/desktop/src/renderer/src/routes.tsx +++ b/apps/desktop/src/renderer/src/routes.tsx @@ -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: , handle: { title: "Skills" } }, { path: "agents", element: , handle: { title: "Agents" } }, { path: "inbox", element: , handle: { title: "Inbox" } }, + { path: "chat", element: , handle: { title: "Chat" } }, { path: "settings", element: ( diff --git a/apps/desktop/src/renderer/src/stores/tab-store.ts b/apps/desktop/src/renderer/src/stores/tab-store.ts index 6ca66893d..d31454328 100644 --- a/apps/desktop/src/renderer/src/stores/tab-store.ts +++ b/apps/desktop/src/renderer/src/stores/tab-store.ts @@ -101,6 +101,7 @@ interface TabStore { const ROUTE_ICONS: Record = { inbox: "Inbox", + chat: "MessageSquare", "my-issues": "CircleUser", issues: "ListTodo", projects: "FolderKanban", diff --git a/apps/web/app/[workspaceSlug]/(dashboard)/chat/page.tsx b/apps/web/app/[workspaceSlug]/(dashboard)/chat/page.tsx new file mode 100644 index 000000000..dfeb9b95c --- /dev/null +++ b/apps/web/app/[workspaceSlug]/(dashboard)/chat/page.tsx @@ -0,0 +1 @@ +export { ChatPage as default } from "@multica/views/chat"; diff --git a/apps/web/app/[workspaceSlug]/(dashboard)/layout.tsx b/apps/web/app/[workspaceSlug]/(dashboard)/layout.tsx index 43769cc98..2a4a7fe69 100644 --- a/apps/web/app/[workspaceSlug]/(dashboard)/layout.tsx +++ b/apps/web/app/[workspaceSlug]/(dashboard)/layout.tsx @@ -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={ <> - - } diff --git a/apps/web/next-env.d.ts b/apps/web/next-env.d.ts index 9edff1c7c..c4b7818fb 100644 --- a/apps/web/next-env.d.ts +++ b/apps/web/next-env.d.ts @@ -1,6 +1,6 @@ /// /// -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. diff --git a/packages/core/chat/index.ts b/packages/core/chat/index.ts index 9fd534a27..476caac77 100644 --- a/packages/core/chat/index.ts +++ b/packages/core/chat/index.ts @@ -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"; diff --git a/packages/core/chat/store.ts b/packages/core/chat/store.ts index 22dde4116..52d979627 100644 --- a/packages/core/chat/store.ts +++ b/packages/core/chat/store.ts @@ -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; /** @@ -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((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, }); }); diff --git a/packages/core/paths/consistency.test.ts b/packages/core/paths/consistency.test.ts index 3b4bc890a..838c5f2c9 100644 --- a/packages/core/paths/consistency.test.ts +++ b/packages/core/paths/consistency.test.ts @@ -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"], diff --git a/packages/core/paths/paths.ts b/packages/core/paths/paths.ts index 9b5e110f7..64075ba7d 100644 --- a/packages/core/paths/paths.ts +++ b/packages/core/paths/paths.ts @@ -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`, diff --git a/packages/core/realtime/use-realtime-sync.ts b/packages/core/realtime/use-realtime-sync.ts index d73a0adc5..e560f8fab 100644 --- a/packages/core/realtime/use-realtime-sync.ts +++ b/packages/core/realtime/use-realtime-sync.ts @@ -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 diff --git a/packages/views/chat/components/chat-fab.tsx b/packages/views/chat/components/chat-fab.tsx deleted file mode 100644 index 797fca649..000000000 --- a/packages/views/chat/components/chat-fab.tsx +++ /dev/null @@ -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 ( - - - - {unreadSessionCount > 0 && ( - - {unreadSessionCount > 9 ? "9+" : unreadSessionCount} - - )} - - {tooltip} - - ); -} diff --git a/packages/views/chat/components/chat-input.tsx b/packages/views/chat/components/chat-input.tsx index d5f9e5c0c..51745c900 100644 --- a/packages/views/chat/components/chat-input.tsx +++ b/packages/views/chat/components/chat-input.tsx @@ -81,7 +81,7 @@ export function ChatInput({ return (
-
+
{topSlot}
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( + 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 ( +
+ + {activeTitle} +
+ + + + } + > + + + New chat + +
+
+ + {/* Body — centered max-width column */} +
+ {showSkeleton ? ( +
+ +
+ ) : hasMessages ? ( +
+ +
+ ) : ( + + )} + +
+ } + leftAdornment={ + + } + rightAdornment={} + /> +
+
+
+ ); +} + +/** + * 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 ( + + + + } + /> + } + > + + + History + + +
+ History +
+
+ {sessions.length === 0 ? ( +
+ No previous chats +
+ ) : ( + sessions.map((session) => { + const isCurrent = session.id === activeSessionId; + const agent = agentById.get(session.agent_id) ?? null; + return ( + + ); + }) + )} +
+
+
+ ); +} + +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 No agents; + } + + return ( + + + + {activeAgent.name} + + + + {mine.length > 0 && ( + + My agents + {mine.map((agent) => ( + + ))} + + )} + {mine.length > 0 && others.length > 0 && } + {others.length > 0 && ( + + Others + {others.map((agent) => ( + + ))} + + )} + + + ); +} + +function AgentMenuItem({ + agent, + isCurrent, + onSelect, +}: { + agent: Agent; + isCurrent: boolean; + onSelect: (agent: Agent) => void; +}) { + return ( + onSelect(agent)} + className="flex min-w-0 items-center gap-2" + > + + {agent.name} + {isCurrent && } + + ); +} + +function AgentAvatarSmall({ agent }: { agent: Agent | null }) { + return ( + + {agent?.avatar_url && } + + + + + ); +} + +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 ( +
+
+

+ {agentName ? `Hi, I'm ${agentName}` : "Welcome to Multica"} +

+

How can I help?

+
+
+ {STARTER_PROMPTS.map((prompt) => ( + + ))} +
+
+ ); +} diff --git a/packages/views/chat/components/chat-resize-handles.tsx b/packages/views/chat/components/chat-resize-handles.tsx deleted file mode 100644 index df67b60c9..000000000 --- a/packages/views/chat/components/chat-resize-handles.tsx +++ /dev/null @@ -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 */} -
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 */} -
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 */} -
onDragStart(e, "corner")} - className="absolute top-0 left-0 size-4 z-20 cursor-nw-resize" - /> - - ); -} diff --git a/packages/views/chat/components/chat-session-history.tsx b/packages/views/chat/components/chat-session-history.tsx deleted file mode 100644 index fe731785f..000000000 --- a/packages/views/chat/components/chat-session-history.tsx +++ /dev/null @@ -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 ( -
- {/* Header */} -
- - setShowHistory(false)} - /> - } - > - - - Back - - Chat History -
- - {/* Session list */} -
- {sessions.length === 0 ? ( -
- - No chat sessions yet -
- ) : ( -
- {sessions.map((session) => ( - handleSelectSession(session)} - /> - ))} -
- )} -
-
- ); -} - -function SessionItem({ - session, - agent, - isActive, - onSelect, -}: { - session: ChatSession; - agent: Agent | null; - isActive: boolean; - onSelect: () => void; -}) { - const timeAgo = formatTimeAgo(session.updated_at); - - return ( - - ); -} - -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(); -} diff --git a/packages/views/chat/components/chat-window.tsx b/packages/views/chat/components/chat-window.tsx deleted file mode 100644 index 81ba095a0..000000000 --- a/packages/views/chat/components/chat-window.tsx +++ /dev/null @@ -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( - 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(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 ( -
- - {/* Header — ⊕ new + session dropdown | window tools */} -
-
- - - } - > - - - New chat - - -
-
- - - } - > - {isAtMax ? : } - - - {isAtMax ? "Restore" : "Expand"} - - - - - } - > - - - Minimize - -
-
- - {/* Messages / skeleton / empty state */} - {showSkeleton ? ( - - ) : hasMessages ? ( - - ) : ( - handleSend(text)} - /> - )} - - {/* Input — disabled for archived sessions */} - } - leftAdornment={ - - } - rightAdornment={} - /> -
- ); -} - -/** - * 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 No agents; - } - - return ( - - - - {activeAgent.name} - - - - {mine.length > 0 && ( - - My agents - {mine.map((agent) => ( - - ))} - - )} - {mine.length > 0 && others.length > 0 && } - {others.length > 0 && ( - - Others - {others.map((agent) => ( - - ))} - - )} - - - ); -} - -function AgentMenuItem({ - agent, - isCurrent, - onSelect, -}: { - agent: Agent; - isCurrent: boolean; - onSelect: (agent: Agent) => void; -}) { - return ( - onSelect(agent)} - className="flex min-w-0 items-center gap-2" - > - - {agent.name} - {isCurrent && } - - ); -} - -/** - * 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 ( - - - {triggerAgent && } - {title} - - - - {sessions.length === 0 ? ( -
- No previous chats -
- ) : ( - sessions.map((session) => { - const isCurrent = session.id === activeSessionId; - const agent = agentById.get(session.agent_id) ?? null; - return ( - onSelectSession(session)} - className="flex min-w-0 items-center gap-2" - > - {agent ? ( - - ) : ( - - )} - - {session.title?.trim() || "New chat"} - - {session.has_unread && ( - - )} - {isCurrent && } - - ); - }) - )} -
-
- ); -} - -function AgentAvatarSmall({ agent }: { agent: Agent }) { - return ( - - {agent.avatar_url && } - - - - - ); -} - -/** - * 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 ( -
-
-

- {agentName ? `Hi, I'm ${agentName}` : "Welcome to Multica"} -

-

Try asking

-
-
- {STARTER_PROMPTS.map((prompt) => ( - - ))} -
-
- ); -} diff --git a/packages/views/chat/components/context-anchor.tsx b/packages/views/chat/components/context-anchor.tsx index 27c4f39c9..23dc2974f 100644 --- a/packages/views/chat/components/context-anchor.tsx +++ b/packages/views/chat/components/context-anchor.tsx @@ -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 ?? diff --git a/packages/views/chat/components/use-chat-resize.ts b/packages/views/chat/components/use-chat-resize.ts deleted file mode 100644 index 01a53ddeb..000000000 --- a/packages/views/chat/components/use-chat-resize.ts +++ /dev/null @@ -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, -) { - 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 = { - 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 }; -} diff --git a/packages/views/chat/index.ts b/packages/views/chat/index.ts index 04295ff1f..c898be125 100644 --- a/packages/views/chat/index.ts +++ b/packages/views/chat/index.ts @@ -1,2 +1 @@ -export { ChatFab } from "./components/chat-fab"; -export { ChatWindow } from "./components/chat-window"; +export { ChatPage } from "./components/chat-page"; diff --git a/packages/views/editor/utils/link-handler.ts b/packages/views/editor/utils/link-handler.ts index 240aef920..c98decc02 100644 --- a/packages/views/editor/utils/link-handler.ts +++ b/packages/views/editor/utils/link-handler.ts @@ -24,6 +24,7 @@ const WORKSPACE_ROUTE_SEGMENTS = new Set([ "autopilots", "agents", "inbox", + "chat", "my-issues", "runtimes", "skills", diff --git a/packages/views/layout/app-sidebar.tsx b/packages/views/layout/app-sidebar.tsx index c3dbe9a79..751b95e59 100644 --- a/packages/views/layout/app-sidebar.tsx +++ b/packages/views/layout/app-sidebar.tsx @@ -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> = []; const EMPTY_INVITATIONS: Awaited> = []; const EMPTY_INBOX: Awaited> = []; +const EMPTY_CHAT_SESSIONS: Awaited> = []; // 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} )} + {item.label === "Chat" && hasChatRunning && ( + + )} + {item.label === "Chat" && !hasChatRunning && hasChatUnread && ( + + )} ); diff --git a/packages/views/layout/dashboard-layout.tsx b/packages/views/layout/dashboard-layout.tsx index 9ab955e61..b4ce7d397 100644 --- a/packages/views/layout/dashboard-layout.tsx +++ b/packages/views/layout/dashboard-layout.tsx @@ -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;