mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-24 11:10:25 +02:00
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:
@@ -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) {
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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 ??
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user