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;