diff --git a/packages/core/chat/store.ts b/packages/core/chat/store.ts index 9d2bbace2c..8558a45586 100644 --- a/packages/core/chat/store.ts +++ b/packages/core/chat/store.ts @@ -78,12 +78,20 @@ export interface ChatState { * the preference survives workspace switches and reloads. */ focusMode: boolean; + /** + * 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; /** sessionId accepts a real session UUID or DRAFT_NEW_SESSION. */ setInputDraft: (sessionId: string, draft: string) => void; clearInputDraft: (sessionId: string) => void; setFocusMode: (on: boolean) => void; + setLastAnchorLocation: (loc: { pathname: string; search: string } | null) => void; } export interface ChatStoreOptions { @@ -103,6 +111,8 @@ export function createChatStore(options: ChatStoreOptions) { selectedAgentId: storage.getItem(wsKey(AGENT_STORAGE_KEY)), inputDrafts: readDrafts(storage, wsKey(DRAFTS_KEY)), focusMode: storage.getItem(FOCUS_MODE_KEY) === "true", + lastAnchorLocation: null, + setLastAnchorLocation: (loc) => set({ lastAnchorLocation: loc }), setActiveSession: (id) => { logger.info("setActiveSession", { from: get().activeSessionId, to: id }); if (id) { diff --git a/packages/views/chat/components/chat-page.tsx b/packages/views/chat/components/chat-page.tsx index b35828777b..e578d0cce2 100644 --- a/packages/views/chat/components/chat-page.tsx +++ b/packages/views/chat/components/chat-page.tsx @@ -55,7 +55,9 @@ export function ChatPage() { 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: sessions = [], isSuccess: sessionsLoaded } = useQuery( + chatSessionsOptions(wsId), + ); const { data: allSessions = [] } = useQuery(allChatSessionsOptions(wsId)); const { data: rawMessages, isLoading: messagesLoading } = useQuery( chatMessagesOptions(activeSessionId ?? ""), @@ -87,16 +89,20 @@ export function ChatPage() { availableAgents[0] ?? null; - // Restore most recent active session once on mount. + // 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 || sessions.length === 0) return; + 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 - }, [sessions]); + }, [sessionsLoaded, sessions]); // Auto mark-as-read whenever the viewer is on a session with unread. const currentHasUnread = diff --git a/packages/views/chat/components/context-anchor.tsx b/packages/views/chat/components/context-anchor.tsx index 27c4f39c93..23dc2974f1 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/layout/app-sidebar.tsx b/packages/views/layout/app-sidebar.tsx index 6a97ce585e..751b95e593 100644 --- a/packages/views/layout/app-sidebar.tsx +++ b/packages/views/layout/app-sidebar.tsx @@ -30,6 +30,7 @@ import { CircleUser, FolderKanban, MessageSquare, + Loader2, X, Zap, } from "lucide-react"; @@ -66,7 +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 } from "@multica/core/chat/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"; @@ -334,6 +336,14 @@ export function AppSidebar({ topSlot, searchSlot, headerClassName, headerStyle } () => 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 ?? ""), @@ -588,7 +598,10 @@ export function AppSidebar({ topSlot, searchSlot, headerClassName, headerStyle } {unreadCount > 99 ? "99+" : unreadCount} )} - {item.label === "Chat" && hasChatUnread && ( + {item.label === "Chat" && hasChatRunning && ( + + )} + {item.label === "Chat" && !hasChatRunning && hasChatUnread && ( )}