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.
This commit is contained in:
Lambda
2026-04-24 01:30:18 +08:00
parent 87ade200af
commit 94c88bb1bc
4 changed files with 81 additions and 10 deletions

View File

@@ -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) {

View File

@@ -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 =

View File

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

View File

@@ -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}
</span>
)}
{item.label === "Chat" && hasChatUnread && (
{item.label === "Chat" && hasChatRunning && (
<Loader2 className="ml-auto !size-3 animate-spin text-muted-foreground" />
)}
{item.label === "Chat" && !hasChatRunning && hasChatUnread && (
<span className="ml-auto size-1.5 rounded-full bg-brand" />
)}
</SidebarMenuButton>