Compare commits

...

2 Commits

Author SHA1 Message Date
Naiyuan Qing
7885dad594 fix(chat): address context picker review
Co-authored-by: multica-agent <github@multica.ai>
2026-06-04 09:58:37 +08:00
Naiyuan Qing
0287a6ee77 feat(chat): add explicit context picker
Co-authored-by: multica-agent <github@multica.ai>
2026-06-04 09:30:41 +08:00
15 changed files with 518 additions and 224 deletions

View File

@@ -1,5 +1,7 @@
export { createChatStore, CHAT_MIN_W, CHAT_MIN_H, CHAT_DEFAULT_W, CHAT_DEFAULT_H, DRAFT_NEW_SESSION } from "./store";
export type { ChatStoreOptions, ChatState, ChatTimelineItem, ContextAnchor } from "./store";
export { useRecentContextStore, selectRecentContexts } from "./recent-context-store";
export type { RecentContextEntry, RecentContextType } from "./recent-context-store";
import type { createChatStore as CreateChatStoreFn } from "./store";

View File

@@ -0,0 +1,75 @@
import { beforeEach, describe, expect, it } from "vitest";
import { selectRecentContexts, useRecentContextStore } from "./recent-context-store";
beforeEach(() => {
useRecentContextStore.setState({ byWorkspace: {} });
});
describe("useRecentContextStore.recordVisit", () => {
it("keeps visits namespaced by workspace id", () => {
const { recordVisit } = useRecentContextStore.getState();
recordVisit("ws-a", { type: "issue", id: "issue-1" });
recordVisit("ws-b", { type: "project", id: "project-1" });
const state = useRecentContextStore.getState().byWorkspace;
expect(state["ws-a"]?.map((e) => `${e.type}:${e.id}`)).toEqual(["issue:issue-1"]);
expect(state["ws-b"]?.map((e) => `${e.type}:${e.id}`)).toEqual(["project:project-1"]);
});
it("moves the most recent visit to the front and dedupes by type and id", () => {
const { recordVisit } = useRecentContextStore.getState();
recordVisit("ws-a", { type: "issue", id: "same-id" });
recordVisit("ws-a", { type: "project", id: "same-id" });
recordVisit("ws-a", { type: "issue", id: "same-id" });
const keys = useRecentContextStore
.getState()
.byWorkspace["ws-a"]?.map((e) => `${e.type}:${e.id}`);
expect(keys).toEqual(["issue:same-id", "project:same-id"]);
});
it("caps each workspace bucket at 20 entries", () => {
const { recordVisit } = useRecentContextStore.getState();
for (let i = 0; i < 25; i++) recordVisit("ws-a", { type: "issue", id: `issue-${i}` });
expect(useRecentContextStore.getState().byWorkspace["ws-a"]).toHaveLength(20);
});
});
describe("useRecentContextStore.forgetContext", () => {
it("removes a single context from the workspace bucket", () => {
const { recordVisit, forgetContext } = useRecentContextStore.getState();
recordVisit("ws-a", { type: "issue", id: "issue-1" });
recordVisit("ws-a", { type: "project", id: "project-1" });
recordVisit("ws-a", { type: "issue", id: "issue-2" });
forgetContext("ws-a", { type: "project", id: "project-1" });
const keys = useRecentContextStore
.getState()
.byWorkspace["ws-a"]?.map((e) => `${e.type}:${e.id}`);
expect(keys).toEqual(["issue:issue-2", "issue:issue-1"]);
});
it("does not touch other workspaces' buckets", () => {
const { recordVisit, forgetContext } = useRecentContextStore.getState();
recordVisit("ws-a", { type: "issue", id: "issue-1" });
recordVisit("ws-b", { type: "issue", id: "issue-1" });
forgetContext("ws-a", { type: "issue", id: "issue-1" });
const state = useRecentContextStore.getState().byWorkspace;
expect(state["ws-a"]).toBeUndefined();
expect(state["ws-b"]?.map((e) => e.id)).toEqual(["issue-1"]);
});
});
describe("selectRecentContexts", () => {
it("returns a stable empty array when wsId is null or unknown", () => {
const a = selectRecentContexts(null)(useRecentContextStore.getState());
const b = selectRecentContexts(null)(useRecentContextStore.getState());
const c = selectRecentContexts("missing")(useRecentContextStore.getState());
expect(a).toBe(b);
expect(a).toBe(c);
expect(a).toEqual([]);
});
});

View File

@@ -0,0 +1,104 @@
"use client";
import { create } from "zustand";
import { createJSONStorage, persist } from "zustand/middleware";
import { defaultStorage } from "../platform/storage";
const MAX_RECENT_CONTEXTS = 20;
const MAX_WORKSPACES = 50;
const EMPTY: RecentContextEntry[] = [];
export type RecentContextType = "issue" | "project";
export interface RecentContextEntry {
type: RecentContextType;
id: string;
visitedAt: number;
}
interface RecentContextState {
byWorkspace: Record<string, RecentContextEntry[]>;
recordVisit: (wsId: string, entry: Pick<RecentContextEntry, "type" | "id">) => void;
forgetContext: (wsId: string, entry: Pick<RecentContextEntry, "type" | "id">) => void;
pruneWorkspaces: (activeWsIds: string[]) => void;
}
function entryKey(entry: Pick<RecentContextEntry, "type" | "id">): string {
return `${entry.type}:${entry.id}`;
}
export const useRecentContextStore = create<RecentContextState>()(
persist(
(set) => ({
byWorkspace: {},
recordVisit: (wsId, entry) =>
set((state) => {
const bucket = state.byWorkspace[wsId] ?? EMPTY;
const key = entryKey(entry);
const filtered = bucket.filter((item) => entryKey(item) !== key);
const updated: RecentContextEntry = {
type: entry.type,
id: entry.id,
visitedAt: Date.now(),
};
const nextBucket = [updated, ...filtered].slice(0, MAX_RECENT_CONTEXTS);
let nextByWorkspace = {
...state.byWorkspace,
[wsId]: nextBucket,
};
const ids = Object.keys(nextByWorkspace);
if (ids.length > MAX_WORKSPACES) {
const oldest = ids.reduce((oldestId, candidateId) => {
const a = nextByWorkspace[oldestId]?.[0]?.visitedAt ?? 0;
const b = nextByWorkspace[candidateId]?.[0]?.visitedAt ?? 0;
return b < a ? candidateId : oldestId;
});
const { [oldest]: _, ...rest } = nextByWorkspace;
nextByWorkspace = rest;
}
return { byWorkspace: nextByWorkspace };
}),
forgetContext: (wsId, entry) =>
set((state) => {
const bucket = state.byWorkspace[wsId];
if (!bucket) return state;
const key = entryKey(entry);
const nextBucket = bucket.filter((item) => entryKey(item) !== key);
if (nextBucket.length === bucket.length) return state;
if (nextBucket.length === 0) {
const { [wsId]: _, ...rest } = state.byWorkspace;
return { byWorkspace: rest };
}
return {
byWorkspace: { ...state.byWorkspace, [wsId]: nextBucket },
};
}),
pruneWorkspaces: (activeWsIds) =>
set((state) => {
const allow = new Set(activeWsIds);
let changed = false;
const next: Record<string, RecentContextEntry[]> = {};
for (const [wsId, items] of Object.entries(state.byWorkspace)) {
if (allow.has(wsId)) next[wsId] = items;
else changed = true;
}
return changed ? { byWorkspace: next } : state;
}),
}),
{
name: "multica_recent_contexts",
storage: createJSONStorage(() => defaultStorage),
partialize: (state) => ({ byWorkspace: state.byWorkspace }),
version: 1,
migrate: () => ({ byWorkspace: {} }),
},
),
);
export function selectRecentContexts(wsId: string | null) {
return (state: RecentContextState) =>
wsId ? (state.byWorkspace[wsId] ?? EMPTY) : EMPTY;
}

View File

@@ -14,8 +14,7 @@ export const DRAFT_NEW_SESSION = "__new__";
const CHAT_WIDTH_KEY = "multica:chat:width";
const CHAT_HEIGHT_KEY = "multica:chat:height";
const CHAT_EXPANDED_KEY = "multica:chat:expanded";
/** Focus mode is a personal preference — global across workspaces/sessions. */
const FOCUS_MODE_KEY = "multica:chat:focusMode";
const SELECTED_CONTEXT_KEY = "multica:chat:selectedContext";
/**
* Open/closed preference, persisted globally (not per-workspace) — most users
* have one habitual chat-panel preference across workspaces. Missing key =
@@ -49,6 +48,30 @@ function writeDrafts(storage: StorageAdapter, key: string, drafts: Record<string
}
}
function readSelectedContext(storage: StorageAdapter, key: string): ContextAnchor | null {
const raw = storage.getItem(key);
if (!raw) return null;
try {
const parsed = JSON.parse(raw) as Partial<ContextAnchor> | null;
if (
parsed &&
(parsed.type === "issue" || parsed.type === "project") &&
typeof parsed.id === "string" &&
typeof parsed.label === "string"
) {
return {
type: parsed.type,
id: parsed.id,
label: parsed.label,
subtitle: typeof parsed.subtitle === "string" ? parsed.subtitle : undefined,
};
}
} catch {
// Ignore corrupt local storage.
}
return null;
}
export const CHAT_MIN_W = 360;
export const CHAT_MIN_H = 480;
export const CHAT_DEFAULT_W = 380;
@@ -90,11 +113,10 @@ export interface ChatState {
/** Drafts per session: sessionId (or DRAFT_NEW_SESSION) → markdown text. */
inputDrafts: Record<string, string>;
/**
* When on, the chat tracks whatever issue/project/inbox-item the user is
* looking at and prepends it to outgoing messages. Persisted globally so
* the preference survives workspace switches and reloads.
* Explicitly selected send context. Persisted per workspace, shared across
* chat sessions in that workspace, and intentionally not cleared after send.
*/
focusMode: boolean;
selectedContext: ContextAnchor | null;
/** Raw user-chosen size — no clamp applied. UI layer clamps at render time. */
chatWidth: number;
chatHeight: number;
@@ -106,7 +128,7 @@ export interface ChatState {
/** sessionId accepts a real session UUID or DRAFT_NEW_SESSION. */
setInputDraft: (sessionId: string, draft: string) => void;
clearInputDraft: (sessionId: string) => void;
setFocusMode: (on: boolean) => void;
setSelectedContext: (context: ContextAnchor | null) => void;
/** Persist raw size and auto-exit expanded mode. */
setChatSize: (width: number, height: number) => void;
setExpanded: (expanded: boolean) => void;
@@ -135,7 +157,7 @@ export function createChatStore(options: ChatStoreOptions) {
activeSessionId: storage.getItem(wsKey(SESSION_STORAGE_KEY)),
selectedAgentId: storage.getItem(wsKey(AGENT_STORAGE_KEY)),
inputDrafts: readDrafts(storage, wsKey(DRAFTS_KEY)),
focusMode: storage.getItem(FOCUS_MODE_KEY) === "true",
selectedContext: readSelectedContext(storage, wsKey(SELECTED_CONTEXT_KEY)),
chatWidth: Number(storage.getItem(CHAT_WIDTH_KEY)) || CHAT_DEFAULT_W,
chatHeight: Number(storage.getItem(CHAT_HEIGHT_KEY)) || CHAT_DEFAULT_H,
isExpanded: storage.getItem(wsKey(CHAT_EXPANDED_KEY)) === "true",
@@ -171,11 +193,11 @@ export function createChatStore(options: ChatStoreOptions) {
writeDrafts(storage, wsKey(DRAFTS_KEY), next);
set({ inputDrafts: next });
},
setFocusMode: (on) => {
logger.info("setFocusMode", { to: on });
if (on) storage.setItem(FOCUS_MODE_KEY, "true");
else storage.removeItem(FOCUS_MODE_KEY);
set({ focusMode: on });
setSelectedContext: (context) => {
logger.info("setSelectedContext", { context });
if (context) storage.setItem(wsKey(SELECTED_CONTEXT_KEY), JSON.stringify(context));
else storage.removeItem(wsKey(SELECTED_CONTEXT_KEY));
set({ selectedContext: context });
},
clearInputDraft: (sessionId) => {
const current = get().inputDrafts;
@@ -212,6 +234,7 @@ export function createChatStore(options: ChatStoreOptions) {
const nextSession = storage.getItem(wsKey(SESSION_STORAGE_KEY));
const nextAgent = storage.getItem(wsKey(AGENT_STORAGE_KEY));
const nextDrafts = readDrafts(storage, wsKey(DRAFTS_KEY));
const nextSelectedContext = readSelectedContext(storage, wsKey(SELECTED_CONTEXT_KEY));
logger.info("workspace rehydration", {
prevSession: store.getState().activeSessionId,
nextSession,
@@ -223,6 +246,7 @@ export function createChatStore(options: ChatStoreOptions) {
activeSessionId: nextSession,
selectedAgentId: nextAgent,
inputDrafts: nextDrafts,
selectedContext: nextSelectedContext,
});
});

View File

@@ -1,6 +1,6 @@
import { forwardRef, useRef, useImperativeHandle } from "react";
import { describe, it, expect, vi } from "vitest";
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import { act, render, screen, fireEvent, waitFor } from "@testing-library/react";
import { I18nProvider } from "@multica/core/i18n/react";
import type { UploadResult } from "@multica/core/hooks/use-file-upload";
import enCommon from "../../locales/en/common.json";
@@ -94,7 +94,6 @@ vi.mock("@multica/core/chat", () => {
activeSessionId: null as string | null,
selectedAgentId: "agent-1",
inputDrafts: {} as Record<string, string>,
focusMode: false,
setInputDraft: vi.fn(),
clearInputDraft: vi.fn(),
};
@@ -130,10 +129,12 @@ describe("ChatInput attachment wiring", () => {
const { onUploadFile } = renderInput();
expect(dropHandlers.onDrop).not.toBeNull();
const file = new File(["x"], "drop.png", { type: "image/png" });
dropHandlers.onDrop?.([file]);
// Microtask: the mock editor awaits onUploadFile before mutating its value.
await Promise.resolve();
await Promise.resolve();
await act(async () => {
dropHandlers.onDrop?.([file]);
// Microtask: the mock editor awaits onUploadFile before mutating its value.
await Promise.resolve();
await Promise.resolve();
});
expect(onUploadFile).toHaveBeenCalledWith(file);
});
@@ -148,7 +149,11 @@ describe("ChatInput attachment wiring", () => {
// mock editor appends the markdown link into its value and calls
// onUpdate so the input flips out of the empty state.
const file = new File(["x"], "drop.png", { type: "image/png" });
dropHandlers.onDrop?.([file]);
await act(async () => {
dropHandlers.onDrop?.([file]);
await Promise.resolve();
await Promise.resolve();
});
// Wait for the submit button to become enabled (onUpdate has fired and
// React has re-rendered). SubmitButton has no aria-label, so we pick
@@ -181,7 +186,10 @@ describe("ChatInput attachment wiring", () => {
fireEvent.change(screen.getByTestId("editor"), { target: { value: "preview text" } });
const file = new File(["x"], "slow.png", { type: "image/png" });
dropHandlers.onDrop?.([file]);
await act(async () => {
dropHandlers.onDrop?.([file]);
await Promise.resolve();
});
// While the upload is pending the SubmitButton must be disabled.
// Bypassing this would send the message with the attachment id
@@ -192,7 +200,10 @@ describe("ChatInput attachment wiring", () => {
expect(sendButton).toBeDisabled();
});
resolveUpload!(makeUpload({ id: "att-slow", link: "https://cdn.example/att-slow.png", filename: "slow.png" }));
await act(async () => {
resolveUpload!(makeUpload({ id: "att-slow", link: "https://cdn.example/att-slow.png", filename: "slow.png" }));
await Promise.resolve();
});
let sendButton: HTMLElement;
await waitFor(() => {

View File

@@ -0,0 +1,22 @@
import { describe, expect, it, vi } from "vitest";
vi.mock("../../common/actor-avatar", () => ({
ActorAvatar: () => null,
}));
import { buildOutgoingChatContent } from "./chat-window";
describe("buildOutgoingChatContent", () => {
it("prepends the stored selected context to the sent message", () => {
expect(buildOutgoingChatContent("Ship it", {
type: "issue",
id: "issue-1",
label: "MUL-2959",
subtitle: "Chat context behavior",
})).toBe('Context: [MUL-2959](mention://issue/issue-1) — "Chat context behavior"\n\nShip it');
});
it("sends plain content after the selected context is cleared", () => {
expect(buildOutgoingChatContent("Ship it", null)).toBe("Ship it");
});
});

View File

@@ -42,14 +42,13 @@ import {
useMarkChatSessionRead,
useUpdateChatSession,
} from "@multica/core/chat/mutations";
import { useChatStore } from "@multica/core/chat";
import { useChatStore, type ContextAnchor } 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";
@@ -61,6 +60,12 @@ const uiLogger = createLogger("chat.ui");
const apiLogger = createLogger("chat.api");
const CHAT_VIRTUOSO_INITIAL_FIRST_ITEM_INDEX = 1_000_000;
export function buildOutgoingChatContent(content: string, selectedContext: ContextAnchor | null): string {
return selectedContext
? `${buildAnchorMarkdown(selectedContext)}\n\n${content}`
: content;
}
function seedChatMessagesPageCache(
qc: ReturnType<typeof useQueryClient>,
sessionId: string,
@@ -213,10 +218,7 @@ export function ChatWindow() {
// 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 selectedContext = useChatStore((s) => s.selectedContext);
const { uploadWithToast } = useFileUpload(api);
@@ -294,10 +296,7 @@ export function ChatWindow() {
return;
}
const focusOn = useChatStore.getState().focusMode;
const finalContent = focusOn && anchorCandidate
? `${buildAnchorMarkdown(anchorCandidate)}\n\n${content}`
: content;
const finalContent = buildOutgoingChatContent(content, selectedContext);
const isNewSession = !activeSessionId;
@@ -306,7 +305,7 @@ export function ChatWindow() {
isNewSession,
agentId: activeAgent.id,
contentLength: finalContent.length,
hasAnchor: focusOn && !!anchorCandidate,
hasAnchor: !!selectedContext,
attachmentCount: attachmentIds?.length ?? 0,
});
@@ -376,7 +375,7 @@ export function ChatWindow() {
[
activeSessionId,
activeAgent,
anchorCandidate,
selectedContext,
ensureSession,
qc,
setActiveSession,

View File

@@ -0,0 +1,63 @@
import { describe, expect, it, vi } from "vitest";
import { fireEvent, render, screen } from "@testing-library/react";
import { I18nProvider } from "@multica/core/i18n/react";
import enChat from "../../locales/en/chat.json";
const store = vi.hoisted(() => ({
selectedContext: {
type: "issue" as const,
id: "issue-1",
label: "MUL-2959",
subtitle: "Chat context behavior",
},
setSelectedContext: vi.fn(),
}));
vi.mock("@multica/core/chat", () => ({
useChatStore: (selector: (state: typeof store) => unknown) => selector(store),
useRecentContextStore: vi.fn(),
selectRecentContexts: vi.fn(),
}));
vi.mock("@multica/core/paths", () => ({
useWorkspacePaths: () => ({
issueDetail: (id: string) => `/test/issues/${id}`,
projectDetail: (id: string) => `/test/projects/${id}`,
}),
}));
vi.mock("../../navigation", () => ({
AppLink: ({ href, children, className }: { href: string; children: React.ReactNode; className?: string }) => (
<a href={href} className={className}>{children}</a>
),
useNavigation: () => ({ pathname: "/test/issues/issue-1", searchParams: new URLSearchParams() }),
}));
vi.mock("../../issues/components/issue-chip", () => ({
IssueChip: ({ fallbackLabel }: { fallbackLabel: string }) => <span>{fallbackLabel}</span>,
}));
vi.mock("../../projects/components/project-chip", () => ({
ProjectChip: ({ fallbackLabel }: { fallbackLabel: string }) => <span>{fallbackLabel}</span>,
}));
import { ContextAnchorCard } from "./context-anchor";
const TEST_RESOURCES = { en: { chat: enChat } };
describe("ContextAnchorCard", () => {
it("clears the selected context from the visible card", () => {
store.setSelectedContext.mockClear();
render(
<I18nProvider locale="en" resources={TEST_RESOURCES}>
<ContextAnchorCard />
</I18nProvider>,
);
expect(screen.getByText("MUL-2959")).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Clear chat context" }));
expect(store.setSelectedContext).toHaveBeenCalledWith(null);
});
});

View File

@@ -1,31 +1,25 @@
"use client";
import { useMemo, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { Focus } from "lucide-react";
import { Link2, Search, X } from "lucide-react";
import type { ContextAnchor } from "@multica/core/chat";
import { useChatStore } from "@multica/core/chat";
import { selectRecentContexts, useChatStore, useRecentContextStore } from "@multica/core/chat";
import { useWorkspaceId } from "@multica/core/hooks";
import { api } from "@multica/core/api";
import { issueDetailOptions } from "@multica/core/issues/queries";
import { projectDetailOptions } from "@multica/core/projects/queries";
import { inboxListOptions } from "@multica/core/inbox/queries";
import { Button } from "@multica/ui/components/ui/button";
import {
Tooltip,
TooltipTrigger,
TooltipContent,
} from "@multica/ui/components/ui/tooltip";
import { Input } from "@multica/ui/components/ui/input";
import { Popover, PopoverContent, PopoverTrigger } from "@multica/ui/components/ui/popover";
import { Tooltip, TooltipTrigger, TooltipContent } from "@multica/ui/components/ui/tooltip";
import { IssueChip } from "../../issues/components/issue-chip";
import { ProjectChip } from "../../projects/components/project-chip";
import { AppLink, useNavigation } from "../../navigation";
import { useWorkspacePaths } from "@multica/core/paths";
import { useT } from "../../i18n";
/**
* Format a derived ContextAnchor as the markdown prefix prepended to the
* outgoing chat message. Uses the same `mention://issue/<uuid>` scheme as
* the editor's mention extension, so the AI sees an identical token whether
* the user typed `@MUL-1` in-line or focus-mode attached it.
*/
export function buildAnchorMarkdown(anchor: ContextAnchor): string {
if (anchor.type === "issue") {
const base = `Context: [${anchor.label}](mention://issue/${anchor.id})`;
@@ -34,48 +28,25 @@ export function buildAnchorMarkdown(anchor: ContextAnchor): string {
return `Context: Project "${anchor.label}"`;
}
/**
* 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.
*
* `wsId` is passed in (per CLAUDE.md convention) so this hook works outside
* a WorkspaceIdProvider if ever reused elsewhere.
*/
export function useRouteAnchorCandidate(wsId: string): {
candidate: ContextAnchor | null;
isResolving: boolean;
} {
export function useRouteAnchorCandidate(wsId: string): { candidate: ContextAnchor | null; isResolving: boolean } {
const { pathname, searchParams } = useNavigation();
const issueMatch = pathname.match(/^\/[^/]+\/issues\/([^/]+)$/);
const projectMatch = pathname.match(/^\/[^/]+\/projects\/([^/]+)$/);
const isInbox = /^\/[^/]+\/inbox$/.test(pathname);
const routeIssueId = issueMatch ? decodeURIComponent(issueMatch[1]!) : null;
const routeProjectId = projectMatch
? decodeURIComponent(projectMatch[1]!)
const routeProjectId = projectMatch ? decodeURIComponent(projectMatch[1]!) : null;
const { data: inboxItems = [] } = useQuery({ ...inboxListOptions(wsId), enabled: isInbox });
const inboxKey = isInbox ? searchParams.get("issue") : null;
const inboxSelectedIssueId = isInbox && inboxKey
? inboxItems.find((i) => (i.issue_id ?? i.id) === inboxKey)?.issue_id ?? null
: null;
// Inbox: the anchor is the issue behind the currently selected notification.
const { data: inboxItems = [] } = useQuery({
...inboxListOptions(wsId),
enabled: isInbox,
});
const inboxKey = isInbox ? searchParams.get("issue") : null;
const inboxSelectedIssueId =
isInbox && inboxKey
? inboxItems.find((i) => (i.issue_id ?? i.id) === inboxKey)?.issue_id ??
null
: null;
// One issue fetch covers both /issues/:id and inbox-derived anchors.
const issueIdToFetch = routeIssueId ?? inboxSelectedIssueId;
const { data: issue, isLoading: issueLoading } = useQuery({
...issueDetailOptions(wsId, issueIdToFetch ?? ""),
enabled: !!issueIdToFetch,
});
const { data: project, isLoading: projectLoading } = useQuery({
...projectDetailOptions(wsId, routeProjectId ?? ""),
enabled: !!routeProjectId,
@@ -83,143 +54,142 @@ export function useRouteAnchorCandidate(wsId: string): {
if (issueIdToFetch) {
if (!issue) return { candidate: null, isResolving: issueLoading };
return {
candidate: {
type: "issue",
id: issue.id,
label: issue.identifier,
subtitle: issue.title,
},
isResolving: false,
};
return { candidate: { type: "issue", id: issue.id, label: issue.identifier, subtitle: issue.title }, isResolving: false };
}
if (routeProjectId) {
if (!project) return { candidate: null, isResolving: projectLoading };
return {
candidate: {
type: "project",
id: project.id,
label: project.title,
},
isResolving: false,
};
return { candidate: { type: "project", id: project.id, label: project.title }, isResolving: false };
}
return { candidate: null, isResolving: false };
}
/**
* Focus-mode toggle. Disabled whenever the current page has no anchor
* (nothing to share) — focusMode persists across such pages, so returning
* to an anchorable page restores the user's prior on/off choice.
*
* no candidate → disabled
* off + candidate → ghost + muted, clickable (→ turns on)
* on + candidate → secondary (bright), clickable (→ turns off)
*/
function anchorKey(anchor: ContextAnchor): string {
return `${anchor.type}:${anchor.id}`;
}
function ContextRow({ anchor, onSelect }: { anchor: ContextAnchor; onSelect: (anchor: ContextAnchor) => void }) {
return (
<button type="button" className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-xs hover:bg-accent" onClick={() => onSelect(anchor)}>
{anchor.type === "issue" ? <IssueChip issueId={anchor.id} fallbackLabel={anchor.label} /> : <ProjectChip projectId={anchor.id} fallbackLabel={anchor.label} />}
{anchor.subtitle && <span className="min-w-0 flex-1 truncate text-muted-foreground">{anchor.subtitle}</span>}
</button>
);
}
export function ContextAnchorButton() {
const { t } = useT("chat");
const wsId = useWorkspaceId();
const { candidate, isResolving } = useRouteAnchorCandidate(wsId);
const focusMode = useChatStore((s) => s.focusMode);
const setFocusMode = useChatStore((s) => s.setFocusMode);
const { candidate } = useRouteAnchorCandidate(wsId);
const [open, setOpen] = useState(false);
const [query, setQuery] = useState("");
const selectedContext = useChatStore((s) => s.selectedContext);
const setSelectedContext = useChatStore((s) => s.setSelectedContext);
const recentContextRefs = useRecentContextStore(selectRecentContexts(wsId));
const recordVisit = useRecentContextStore((s) => s.recordVisit);
const trimmedQuery = query.trim();
const hasAnchor = !!candidate;
const isDisabled = !hasAnchor && !isResolving;
const isBright = focusMode && hasAnchor;
const { data: recentContexts = [] } = useQuery({
queryKey: ["chat", "recent-contexts", wsId, recentContextRefs],
enabled: open && recentContextRefs.length > 0,
queryFn: async () => {
const resolved: Array<ContextAnchor | null> = await Promise.all(recentContextRefs.map(async (entry) => {
try {
if (entry.type === "issue") {
const issue = await api.getIssue(entry.id);
return { type: "issue" as const, id: issue.id, label: issue.identifier, subtitle: issue.title };
}
const project = await api.getProject(entry.id);
return { type: "project" as const, id: project.id, label: project.title };
} catch {
return null;
}
}));
return resolved.flatMap((item) => (item ? [item] : []));
},
});
const tooltipText = isDisabled
? t(($) => $.context_anchor.tooltip_disabled)
: focusMode && candidate
? candidate.type === "issue"
? t(($) => $.context_anchor.tooltip_on_issue, { label: candidate.label })
: t(($) => $.context_anchor.tooltip_on_project, { label: candidate.label })
: t(($) => $.context_anchor.tooltip_off);
const { data: searchResults = [], isLoading: searching } = useQuery({
queryKey: ["chat", "context-search", wsId, trimmedQuery],
enabled: open && trimmedQuery.length >= 2,
queryFn: async ({ signal }) => {
const [issues, projects] = await Promise.all([
api.searchIssues({ q: trimmedQuery, limit: 8, include_closed: true, signal }),
api.searchProjects({ q: trimmedQuery, limit: 8, include_closed: true, signal }),
]);
const results: ContextAnchor[] = [
...issues.issues.map((issue) => ({ type: "issue" as const, id: issue.id, label: issue.identifier, subtitle: issue.title })),
...projects.projects.map((project) => ({ type: "project" as const, id: project.id, label: project.title })),
];
return results;
},
});
const visibleRecent = useMemo(() => {
const hidden = new Set<string>();
if (candidate) hidden.add(anchorKey(candidate));
return recentContexts.filter((item) => !hidden.has(anchorKey(item))).slice(0, 6);
}, [candidate, recentContexts]);
const visibleSearch = useMemo(() => {
const hidden = new Set<string>();
if (candidate) hidden.add(anchorKey(candidate));
for (const item of visibleRecent) hidden.add(anchorKey(item));
return searchResults.filter((item) => !hidden.has(anchorKey(item))).slice(0, 8);
}, [candidate, visibleRecent, searchResults]);
const select = (anchor: ContextAnchor) => {
setSelectedContext(anchor);
recordVisit(wsId, { type: anchor.type, id: anchor.id });
setOpen(false);
setQuery("");
};
const tooltipText = selectedContext
? selectedContext.type === "issue"
? t(($) => $.context_anchor.tooltip_selected_issue, { label: selectedContext.label })
: t(($) => $.context_anchor.tooltip_selected_project, { label: selectedContext.label })
: t(($) => $.context_anchor.tooltip_picker);
return (
<Tooltip>
<TooltipTrigger
render={
<Button
variant={isBright ? "secondary" : "ghost"}
size="icon-sm"
className={isBright ? undefined : "text-muted-foreground"}
onClick={() => setFocusMode(!focusMode)}
disabled={isDisabled}
aria-label={
focusMode
? t(($) => $.context_anchor.aria_stop)
: t(($) => $.context_anchor.aria_start)
}
aria-pressed={focusMode}
/>
}
>
<Focus />
</TooltipTrigger>
<TooltipContent side="top">{tooltipText}</TooltipContent>
</Tooltip>
<Popover open={open} onOpenChange={setOpen}>
<Tooltip>
<TooltipTrigger render={<PopoverTrigger render={<Button variant={selectedContext ? "secondary" : "ghost"} size="icon-sm" className={selectedContext ? undefined : "text-muted-foreground"} aria-label={t(($) => $.context_anchor.aria_pick)} aria-pressed={!!selectedContext}><Link2 /></Button>} />} />
<TooltipContent side="top">{tooltipText}</TooltipContent>
</Tooltip>
<PopoverContent side="top" align="end" className="w-80 p-2">
<div className="relative mb-2">
<Search className="pointer-events-none absolute left-2 top-1/2 size-3.5 -translate-y-1/2 text-muted-foreground" />
<Input value={query} onChange={(event) => setQuery(event.target.value)} placeholder={t(($) => $.context_anchor.search_placeholder)} className="h-8 pl-7 text-xs" />
</div>
{candidate && <div className="mb-2"><div className="px-2 pb-1 text-[11px] font-medium text-muted-foreground">{t(($) => $.context_anchor.section_current)}</div><ContextRow anchor={candidate} onSelect={select} /></div>}
{visibleRecent.length > 0 && <div className="mb-2"><div className="px-2 pb-1 text-[11px] font-medium text-muted-foreground">{t(($) => $.context_anchor.section_recent)}</div>{visibleRecent.map((item) => <ContextRow key={anchorKey(item)} anchor={item} onSelect={select} />)}</div>}
{(trimmedQuery.length >= 2 || searching) && <div><div className="px-2 pb-1 text-[11px] font-medium text-muted-foreground">{t(($) => $.context_anchor.section_search)}</div>{visibleSearch.map((item) => <ContextRow key={anchorKey(item)} anchor={item} onSelect={select} />)}{!searching && visibleSearch.length === 0 && <div className="px-2 py-2 text-xs text-muted-foreground">{t(($) => $.context_anchor.search_empty)}</div>}</div>}
</PopoverContent>
</Popover>
);
}
/**
* Renders the derived focus target above the input. Shows only when focus
* mode is on *and* the current route resolves to an anchorable object.
* No dismiss affordance — use the button to leave focus mode.
*/
export function ContextAnchorCard() {
const { t } = useT("chat");
const wsId = useWorkspaceId();
const paths = useWorkspacePaths();
const { candidate } = useRouteAnchorCandidate(wsId);
const focusMode = useChatStore((s) => s.focusMode);
const selectedContext = useChatStore((s) => s.selectedContext);
const setSelectedContext = useChatStore((s) => s.setSelectedContext);
if (!selectedContext) return null;
if (!focusMode || !candidate) return null;
const href = selectedContext.type === "issue" ? paths.issueDetail(selectedContext.id) : paths.projectDetail(selectedContext.id);
const tooltipText = selectedContext.type === "issue"
? selectedContext.subtitle
? t(($) => $.context_anchor.card_tooltip_issue_with_subtitle, { label: selectedContext.label, subtitle: selectedContext.subtitle })
: t(($) => $.context_anchor.card_tooltip_issue, { label: selectedContext.label })
: t(($) => $.context_anchor.card_tooltip_project, { label: selectedContext.label });
const href =
candidate.type === "issue"
? paths.issueDetail(candidate.id)
: paths.projectDetail(candidate.id);
const tooltipText =
candidate.type === "issue"
? candidate.subtitle
? t(($) => $.context_anchor.card_tooltip_issue_with_subtitle, {
label: candidate.label,
subtitle: candidate.subtitle,
})
: t(($) => $.context_anchor.card_tooltip_issue, { label: candidate.label })
: t(($) => $.context_anchor.card_tooltip_project, { label: candidate.label });
// Same pattern as IssueMentionCard: wrap the pure chip in an AppLink and
// layer cursor + hover affordance onto the chip. Makes the anchor feel
// alive (text-cursor → pointer, hover background) and behave consistently
// with @mentions — clicking jumps to the entity.
return (
<div className="mx-2 mt-2 flex items-center">
<div className="mx-2 mt-2 flex items-center gap-1">
<Tooltip>
<TooltipTrigger
render={
<AppLink href={href} className="inline-flex">
{candidate.type === "issue" ? (
<IssueChip
issueId={candidate.id}
fallbackLabel={candidate.label}
className="cursor-pointer hover:bg-accent transition-colors"
/>
) : (
<ProjectChip
projectId={candidate.id}
fallbackLabel={candidate.label}
className="cursor-pointer hover:bg-accent transition-colors"
/>
)}
</AppLink>
}
/>
<TooltipTrigger render={<AppLink href={href} className="inline-flex">{selectedContext.type === "issue" ? <IssueChip issueId={selectedContext.id} fallbackLabel={selectedContext.label} className="cursor-pointer hover:bg-accent transition-colors" /> : <ProjectChip projectId={selectedContext.id} fallbackLabel={selectedContext.label} className="cursor-pointer hover:bg-accent transition-colors" />}</AppLink>} />
<TooltipContent side="top">{tooltipText}</TooltipContent>
</Tooltip>
<Button variant="ghost" size="icon-xs" className="text-muted-foreground" onClick={() => setSelectedContext(null)} aria-label={t(($) => $.context_anchor.aria_clear)}><X /></Button>
</div>
);
}

View File

@@ -64,6 +64,7 @@ import { useAuthStore } from "@multica/core/auth";
import { useWorkspacePaths } from "@multica/core/paths";
import { useActorName } from "@multica/core/workspace/hooks";
import { useWorkspaceId } from "@multica/core/hooks";
import { useRecentContextStore } from "@multica/core/chat";
import { issueListOptions, issueDetailOptions, childIssuesOptions, issueUsageOptions, issueAttachmentsOptions } from "@multica/core/issues/queries";
import { projectDetailOptions } from "@multica/core/projects/queries";
import { ProjectIcon } from "../../projects/components/project-icon";
@@ -806,9 +807,11 @@ export function IssueDetail({ issueId, onDelete, onDone, defaultSidebarOpen = tr
// Record recent visit
const recordVisit = useRecentIssuesStore((s) => s.recordVisit);
const recordRecentContext = useRecentContextStore((s) => s.recordVisit);
useEffect(() => {
if (issue) {
recordVisit(wsId, issue.id);
recordRecentContext(wsId, { type: "issue", id: issue.id });
}
}, [issue?.id, wsId]); // eslint-disable-line react-hooks/exhaustive-deps

View File

@@ -96,15 +96,19 @@
"plan_next": "Plan what to work on next"
},
"context_anchor": {
"tooltip_disabled": "Nothing to share with Multica on this page",
"tooltip_off": "Let Multica know what you're viewing",
"tooltip_on_issue": "Multica knows you're viewing {{label}} · Click to turn off",
"tooltip_on_project": "Multica knows you're viewing project \"{{label}}\" · Click to turn off",
"card_tooltip_issue_with_subtitle": "Multica knows you're viewing {{label}} — {{subtitle}}",
"card_tooltip_issue": "Multica knows you're viewing {{label}}",
"card_tooltip_project": "Multica knows you're viewing project \"{{label}}\"",
"aria_stop": "Stop sharing current page",
"aria_start": "Share current page with Multica"
"card_tooltip_issue_with_subtitle": "Selected context: {{label}} — {{subtitle}}",
"card_tooltip_issue": "Selected context: {{label}}",
"card_tooltip_project": "Selected context: project \"{{label}}\"",
"tooltip_picker": "Pick context for this chat",
"tooltip_selected_issue": "Context: {{label}} · Click to change",
"tooltip_selected_project": "Context: project \"{{label}}\" · Click to change",
"aria_pick": "Pick chat context",
"aria_clear": "Clear chat context",
"search_placeholder": "Search issues or projects…",
"section_current": "Current page",
"section_recent": "Recent",
"section_search": "Search",
"search_empty": "No matching issues or projects"
},
"no_agent_banner": "You need an agent to start chatting.",
"offline_banner": {

View File

@@ -93,15 +93,19 @@
"plan_next": "次に取り組むことを計画して"
},
"context_anchor": {
"tooltip_disabled": "このページに Multica へ共有できる内容はありません",
"tooltip_off": "Multica に現在表示中の内容を知らせる",
"tooltip_on_issue": "Multica は {{label}} を表示中だと把握しています · クリックでオフ",
"tooltip_on_project": "Multica はプロジェクト \"{{label}}\" を表示中だと把握しています · クリックでオフ",
"card_tooltip_issue_with_subtitle": "Multica は {{label}} を表示中だと把握しています — {{subtitle}}",
"card_tooltip_issue": "Multica は {{label}} を表示中だと把握しています",
"card_tooltip_project": "Multica はプロジェクト \"{{label}}\" を表示中だと把握しています",
"aria_stop": "現在のページの共有を停止",
"aria_start": "現在のページを Multica に共有"
"card_tooltip_issue_with_subtitle": "選択中のコンテキスト: {{label}} — {{subtitle}}",
"card_tooltip_issue": "選択中のコンテキスト: {{label}}",
"card_tooltip_project": "選択中のコンテキスト: プロジェクト \"{{label}}\"",
"tooltip_picker": "このチャットのコンテキストを選択",
"tooltip_selected_issue": "コンテキスト: {{label}} · クリックして変更",
"tooltip_selected_project": "コンテキスト: プロジェクト「{{label}}」· クリックして変更",
"aria_pick": "チャットコンテキストを選択",
"aria_clear": "チャットコンテキストをクリア",
"search_placeholder": "Issue または Project を検索…",
"section_current": "現在のページ",
"section_recent": "最近",
"section_search": "検索",
"search_empty": "一致する Issue / Project はありません"
},
"no_agent_banner": "チャットを始めるにはエージェントが必要です。",
"offline_banner": {

View File

@@ -96,15 +96,19 @@
"plan_next": "다음에 할 일을 계획해 줘"
},
"context_anchor": {
"tooltip_disabled": "이 페이지에서는 Multica에 공유할 내용이 없습니다",
"tooltip_off": "Multica에 현재 보고 있는 페이지 알려주기",
"tooltip_on_issue": "Multica가 {{label}}을(를) 보고 있음을 알고 있습니다 · 클릭해서 끄기",
"tooltip_on_project": "Multica가 프로젝트 \"{{label}}\"을(를) 보고 있음을 알고 있습니다 · 클릭해서 끄기",
"card_tooltip_issue_with_subtitle": "Multica가 {{label}}을(를) 보고 있음을 알고 있습니다 — {{subtitle}}",
"card_tooltip_issue": "Multica가 {{label}}을(를) 보고 있음을 알고 있습니다",
"card_tooltip_project": "Multica가 프로젝트 \"{{label}}\"을(를) 보고 있음을 알고 있습니다",
"aria_stop": "현재 페이지 공유 중지",
"aria_start": "현재 페이지를 Multica에 공유"
"card_tooltip_issue_with_subtitle": "선택된 컨텍스트: {{label}} — {{subtitle}}",
"card_tooltip_issue": "선택된 컨텍스트: {{label}}",
"card_tooltip_project": "선택된 컨텍스트: 프로젝트 \"{{label}}\"",
"tooltip_picker": "이 채팅의 컨텍스트 선택",
"tooltip_selected_issue": "컨텍스트: {{label}} · 클릭하여 변경",
"tooltip_selected_project": "컨텍스트: 프로젝트 \"{{label}}\" · 클릭하여 변경",
"aria_pick": "채팅 컨텍스트 선택",
"aria_clear": "채팅 컨텍스트 지우기",
"search_placeholder": "Issue 또는 Project 검색…",
"section_current": "현재 페이지",
"section_recent": "최근",
"section_search": "검색",
"search_empty": "일치하는 Issue 또는 Project가 없습니다"
},
"no_agent_banner": "채팅을 시작하려면 에이전트가 필요합니다.",
"offline_banner": {

View File

@@ -93,15 +93,19 @@
"plan_next": "规划接下来该做什么"
},
"context_anchor": {
"tooltip_disabled": "当前页面没有可以共享给 Multica 的内容",
"tooltip_off": "让 Multica 知道你正在看的内容",
"tooltip_on_issue": "Multica 知道你正在看 {{label}},点击关闭",
"tooltip_on_project": "Multica 知道你正在看项目 \"{{label}}\",点击关闭",
"card_tooltip_issue_with_subtitle": "Multica 知道你正在看 {{label}} —— {{subtitle}}",
"card_tooltip_issue": "Multica 知道你正在看 {{label}}",
"card_tooltip_project": "Multica 知道你正在看项目 \"{{label}}\"",
"aria_stop": "停止共享当前页面",
"aria_start": "把当前页面共享给 Multica"
"card_tooltip_issue_with_subtitle": "已选上下文:{{label}} —— {{subtitle}}",
"card_tooltip_issue": "已选上下文:{{label}}",
"card_tooltip_project": "已选上下文:项目 \"{{label}}\"",
"tooltip_picker": "选择这次聊天的上下文",
"tooltip_selected_issue": "上下文:{{label}} · 点击更换",
"tooltip_selected_project": "上下文:项目「{{label}}」· 点击更换",
"aria_pick": "选择聊天上下文",
"aria_clear": "清除聊天上下文",
"search_placeholder": "搜索 Issue 或 Project…",
"section_current": "当前页面",
"section_recent": "最近浏览",
"section_search": "搜索",
"search_empty": "没有匹配的 Issue 或 Project"
},
"no_agent_banner": "需要先有一个智能体才能开始对话。",
"offline_banner": {

View File

@@ -26,6 +26,7 @@ import { useModalStore } from "@multica/core/modals";
import { memberListOptions, agentListOptions } from "@multica/core/workspace/queries";
import { agentTaskSnapshotOptions } from "@multica/core/agents";
import { useWorkspaceId } from "@multica/core/hooks";
import { useRecentContextStore } from "@multica/core/chat";
import { useWorkspacePaths } from "@multica/core/paths";
import { useActorName } from "@multica/core/workspace/hooks";
import { PROJECT_STATUS_ORDER, PROJECT_STATUS_CONFIG, PROJECT_PRIORITY_ORDER } from "@multica/core/projects/config";
@@ -396,6 +397,10 @@ export function ProjectDetail({ projectId }: { projectId: string }) {
const router = useNavigation();
const userId = useAuthStore((s) => s.user?.id);
const { data: project, isLoading } = useQuery(projectDetailOptions(wsId, projectId));
const recordRecentContext = useRecentContextStore((s) => s.recordVisit);
useEffect(() => {
if (project) recordRecentContext(wsId, { type: "project", id: project.id });
}, [project?.id, wsId]); // eslint-disable-line react-hooks/exhaustive-deps
const projectScope = `project:${projectId}`;
const projectFilter = useMemo<MyIssuesFilter>(
() => ({ project_id: projectId }),