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 && (
)}