mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-01 03:19:13 +02:00
Compare commits
2 Commits
agent/lamb
...
agent/navi
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7885dad594 | ||
|
|
0287a6ee77 |
@@ -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";
|
||||
|
||||
|
||||
75
packages/core/chat/recent-context-store.test.ts
Normal file
75
packages/core/chat/recent-context-store.test.ts
Normal 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([]);
|
||||
});
|
||||
});
|
||||
104
packages/core/chat/recent-context-store.ts
Normal file
104
packages/core/chat/recent-context-store.ts
Normal 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;
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
22
packages/views/chat/components/chat-window.context.test.ts
Normal file
22
packages/views/chat/components/chat-window.context.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
63
packages/views/chat/components/context-anchor.card.test.tsx
Normal file
63
packages/views/chat/components/context-anchor.card.test.tsx
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
Reference in New Issue
Block a user