Compare commits

...

1 Commits

Author SHA1 Message Date
Naiyuan Qing
f575677514 fix: use mentions for chat context 2026-06-04 16:32:29 +08:00
38 changed files with 839 additions and 601 deletions

View File

@@ -1,5 +1,5 @@
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 type { ChatStoreOptions, ChatState, ChatTimelineItem } from "./store";
export { useRecentContextStore, selectRecentContexts } from "./recent-context-store";
export type { RecentContextEntry, RecentContextType } from "./recent-context-store";

View File

@@ -33,6 +33,29 @@ describe("useRecentContextStore.recordVisit", () => {
for (let i = 0; i < 25; i++) recordVisit("ws-a", { type: "issue", id: `issue-${i}` });
expect(useRecentContextStore.getState().byWorkspace["ws-a"]).toHaveLength(20);
});
it("stores a local display snapshot for recent entries", () => {
const { recordVisit } = useRecentContextStore.getState();
recordVisit("ws-a", {
type: "issue",
id: "issue-1",
label: "MUL-1",
subtitle: "Fix login redirect",
status: "todo",
projectStatus: "in_progress",
icon: "🚀",
});
expect(useRecentContextStore.getState().byWorkspace["ws-a"]?.[0]).toMatchObject({
type: "issue",
id: "issue-1",
label: "MUL-1",
subtitle: "Fix login redirect",
status: "todo",
projectStatus: "in_progress",
icon: "🚀",
});
});
});
describe("useRecentContextStore.forgetContext", () => {

View File

@@ -3,6 +3,7 @@
import { create } from "zustand";
import { createJSONStorage, persist } from "zustand/middleware";
import { defaultStorage } from "../platform/storage";
import type { IssueStatus, ProjectStatus } from "../types";
const MAX_RECENT_CONTEXTS = 20;
const MAX_WORKSPACES = 50;
@@ -13,12 +14,17 @@ export type RecentContextType = "issue" | "project";
export interface RecentContextEntry {
type: RecentContextType;
id: string;
label?: string;
subtitle?: string;
status?: IssueStatus;
projectStatus?: ProjectStatus;
icon?: string | null;
visitedAt: number;
}
interface RecentContextState {
byWorkspace: Record<string, RecentContextEntry[]>;
recordVisit: (wsId: string, entry: Pick<RecentContextEntry, "type" | "id">) => void;
recordVisit: (wsId: string, entry: Pick<RecentContextEntry, "type" | "id"> & Partial<Pick<RecentContextEntry, "label" | "subtitle" | "status" | "projectStatus" | "icon">>) => void;
forgetContext: (wsId: string, entry: Pick<RecentContextEntry, "type" | "id">) => void;
pruneWorkspaces: (activeWsIds: string[]) => void;
}
@@ -39,6 +45,11 @@ export const useRecentContextStore = create<RecentContextState>()(
const updated: RecentContextEntry = {
type: entry.type,
id: entry.id,
label: entry.label,
subtitle: entry.subtitle,
status: entry.status,
projectStatus: entry.projectStatus,
icon: entry.icon,
visitedAt: Date.now(),
};
const nextBucket = [updated, ...filtered].slice(0, MAX_RECENT_CONTEXTS);

View File

@@ -14,7 +14,6 @@ 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";
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 =
@@ -48,30 +47,6 @@ 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;
@@ -91,32 +66,12 @@ export interface ChatTimelineItem {
output?: string;
}
/**
* A derived "where I am" pointer — not stored, recomputed each render from
* the current route + react-query cache. The type is exported because
* consumers (buildAnchorMarkdown, chip props) share the same shape.
*/
export interface ContextAnchor {
type: "issue" | "project";
/** UUID for `issue`, UUID for `project`. */
id: string;
/** Human-readable label: issue identifier (MUL-1) or project title. */
label: string;
/** Optional secondary text — issue title for issue anchors. */
subtitle?: string;
}
export interface ChatState {
isOpen: boolean;
activeSessionId: string | null;
selectedAgentId: string | null;
/** Drafts per session: sessionId (or DRAFT_NEW_SESSION) → markdown text. */
inputDrafts: Record<string, string>;
/**
* Explicitly selected send context. Persisted per workspace, shared across
* chat sessions in that workspace, and intentionally not cleared after send.
*/
selectedContext: ContextAnchor | null;
/** Raw user-chosen size — no clamp applied. UI layer clamps at render time. */
chatWidth: number;
chatHeight: number;
@@ -128,7 +83,6 @@ export interface ChatState {
/** sessionId accepts a real session UUID or DRAFT_NEW_SESSION. */
setInputDraft: (sessionId: string, draft: string) => void;
clearInputDraft: (sessionId: string) => void;
setSelectedContext: (context: ContextAnchor | null) => void;
/** Persist raw size and auto-exit expanded mode. */
setChatSize: (width: number, height: number) => void;
setExpanded: (expanded: boolean) => void;
@@ -157,7 +111,6 @@ 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)),
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",
@@ -193,12 +146,6 @@ export function createChatStore(options: ChatStoreOptions) {
writeDrafts(storage, wsKey(DRAFTS_KEY), next);
set({ inputDrafts: next });
},
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;
if (!(sessionId in current)) {
@@ -234,7 +181,6 @@ 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,
@@ -246,7 +192,6 @@ export function createChatStore(options: ChatStoreOptions) {
activeSessionId: nextSession,
selectedAgentId: nextAgent,
inputDrafts: nextDrafts,
selectedContext: nextSelectedContext,
});
});

View File

@@ -26,6 +26,7 @@ import {
pruneDeletedIssueFromParentChildrenCaches,
} from "./delete-cache";
import { useWorkspaceId } from "../hooks";
import { useRecentContextStore } from "../chat/recent-context-store";
import { useRecentIssuesStore } from "./stores";
import type { GroupedIssuesResponse, Issue, IssueAssigneeGroup, IssueReaction, IssueStatus } from "../types";
import type {
@@ -381,6 +382,7 @@ export function useDeleteIssue() {
}
},
onSuccess: (_data, id, ctx) => {
useRecentContextStore.getState().forgetContext(wsId, { type: "issue", id });
cleanupDeletedIssueCaches(qc, wsId, id, ctx?.metadata);
},
onSettled: (_data, _err, _id, ctx) => {
@@ -538,7 +540,9 @@ export function useBatchDeleteIssues() {
},
onSuccess: (data, ids, ctx) => {
if (data.deleted === ids.length) {
const { forgetContext } = useRecentContextStore.getState();
for (const id of ids) {
forgetContext(wsId, { type: "issue", id });
cleanupDeletedIssueCaches(qc, wsId, id, ctx?.metadataById.get(id));
}
return;

View File

@@ -2,6 +2,7 @@ import { useMutation, useQueryClient } from "@tanstack/react-query";
import { api } from "../api";
import { projectKeys } from "./queries";
import { useWorkspaceId } from "../hooks";
import { useRecentContextStore } from "../chat/recent-context-store";
import type { Project, CreateProjectRequest, UpdateProjectRequest, ListProjectsResponse } from "../types";
export function useCreateProject() {
@@ -68,6 +69,9 @@ export function useDeleteProject() {
onError: (_err, _id, ctx) => {
if (ctx?.prevList) qc.setQueryData(projectKeys.list(wsId), ctx.prevList);
},
onSuccess: (_data, id) => {
useRecentContextStore.getState().forgetContext(wsId, { type: "project", id });
},
onSettled: () => {
qc.invalidateQueries({ queryKey: projectKeys.list(wsId) });
},

View File

@@ -179,9 +179,9 @@ function createComponents(
},
// Links: Make clickable with callbacks, or render as mention
a: ({ href, children }) => {
// Mention links: mention://member/id, mention://agent/id, mention://issue/id, mention://all/all
// Mention links: mention://member/id, mention://agent/id, mention://issue/id, mention://project/id, mention://all/all
if (href?.startsWith('mention://')) {
const mentionMatch = href.match(/^mention:\/\/(member|agent|issue|all)\/(.+)$/)
const mentionMatch = href.match(/^mention:\/\/(member|agent|issue|project|all)\/(.+)$/)
if (mentionMatch?.[1] && mentionMatch[2]) {
const type = mentionMatch[1]
const id = mentionMatch[2]

View File

@@ -30,6 +30,9 @@ const TEST_RESOURCES = { en: { common: enCommon, chat: enChat } };
const dropHandlers = vi.hoisted(() => ({
onDrop: null as null | ((files: File[]) => void),
}));
const editorProps = vi.hoisted(() => ({
last: null as null | Record<string, unknown>,
}));
vi.mock("../../editor", () => ({
useFileDropZone: ({ onDrop }: { onDrop: (files: File[]) => void }) => {
@@ -38,19 +41,23 @@ vi.mock("../../editor", () => ({
},
FileDropOverlay: () => null,
ContentEditor: forwardRef(function MockContentEditor(
{
defaultValue,
onUpdate,
placeholder,
onUploadFile,
}: {
props: {
defaultValue?: string;
onUpdate?: (md: string) => void;
placeholder?: string;
onUploadFile?: (file: File) => Promise<UploadResult | null>;
mentionMode?: string;
mentionContextItems?: unknown[];
},
ref: React.Ref<unknown>,
) {
const {
defaultValue,
onUpdate,
placeholder,
onUploadFile,
} = props;
editorProps.last = props as unknown as Record<string, unknown>;
const valueRef = useRef<string>(defaultValue ?? "");
const uploadingRef = useRef(0);
useImperativeHandle(ref, () => ({
@@ -124,6 +131,19 @@ function renderInput(props: Partial<React.ComponentProps<typeof ChatInput>> = {}
return { onSend, onUploadFile };
}
describe("ChatInput @ context wiring", () => {
it("configures chat @ with current/recent issue/project context", () => {
const contextItems = [
{ id: "issue-1", label: "MUL-1", type: "issue" as const, group: "current" as const },
];
renderInput({ contextItems });
expect(editorProps.last?.mentionMode).toBe("context");
expect(editorProps.last?.mentionContextItems).toBe(contextItems);
});
});
describe("ChatInput attachment wiring", () => {
it("routes dropped files through the editor's upload handler", async () => {
const { onUploadFile } = renderInput();
@@ -224,7 +244,7 @@ describe("ChatInput attachment wiring", () => {
// only. Probe by counting buttons: with no upload, only the submit
// button is in the action row.
const buttons = screen.getAllByRole("button");
// The agent picker / context anchor adornments may render zero buttons
// The agent picker may render zero buttons
// in this test (no leftAdornment passed). So a single button = submit.
expect(buttons.length).toBe(1);
});

View File

@@ -15,6 +15,7 @@ import { useChatStore, DRAFT_NEW_SESSION } from "@multica/core/chat";
import { createLogger } from "@multica/core/logger";
import { enterKey, formatShortcut, modKey } from "@multica/core/platform";
import type { UploadResult } from "@multica/core/hooks/use-file-upload";
import type { MentionItem } from "../../editor/extensions/mention-suggestion";
import { useT } from "../../i18n";
const logger = createLogger("chat.ui");
@@ -39,11 +40,8 @@ interface ChatInputProps {
agentName?: string;
/** Rendered at the bottom-left of the input bar — typically the agent picker. */
leftAdornment?: ReactNode;
/** Rendered just before the submit button — used for context-anchor action. */
rightAdornment?: ReactNode;
/** Rendered inside the rounded container, above the editor — attached
* context cards, drafts, etc. */
topSlot?: ReactNode;
/** Chat @ suggestions: current/recent issue/project entries. */
contextItems?: MentionItem[];
}
export function ChatInput({
@@ -55,8 +53,7 @@ export function ChatInput({
noAgent,
agentName,
leftAdornment,
rightAdornment,
topSlot,
contextItems,
}: ChatInputProps) {
const { t } = useT("chat");
const editorRef = useRef<ContentEditorRef>(null);
@@ -214,7 +211,6 @@ export function ChatInput({
)}
aria-disabled={noAgent || undefined}
>
{topSlot}
<div className="flex-1 min-h-0 overflow-y-auto px-3 py-2">
<ContentEditor
// See the editorKey / draftKey split note above — editorKey
@@ -230,6 +226,8 @@ export function ChatInput({
onSubmit={handleSend}
onUploadFile={uploadEnabled ? handleUpload : undefined}
debounceMs={100}
mentionMode={contextItems ? "context" : "default"}
mentionContextItems={contextItems}
enableSlashCommands
// Chat is short-form — the floating formatting toolbar is
// more distraction than feature here.
@@ -247,7 +245,6 @@ export function ChatInput({
</div>
)}
<div className="absolute bottom-1 right-1.5 flex items-center gap-1">
{rightAdornment}
{uploadEnabled && (
<FileUploadButton
size="sm"

View File

@@ -1,22 +0,0 @@
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,15 +42,11 @@ import {
useMarkChatSessionRead,
useUpdateChatSession,
} from "@multica/core/chat/mutations";
import { useChatStore, type ContextAnchor } from "@multica/core/chat";
import { useChatStore } from "@multica/core/chat";
import { ChatMessageList, ChatMessageSkeleton } from "./chat-message-list";
import { ChatInput } from "./chat-input";
import {
ContextAnchorButton,
ContextAnchorCard,
buildAnchorMarkdown,
} from "./context-anchor";
import { ChatResizeHandles } from "./chat-resize-handles";
import { useChatContextItems } from "./use-chat-context-items";
import { useChatResize } from "./use-chat-resize";
import { createLogger } from "@multica/core/logger";
import type { Agent, ChatMessage, ChatMessagesPage, ChatPendingTask, ChatSession, PendingChatTasksResponse } from "@multica/core/types";
@@ -60,12 +56,6 @@ 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,
@@ -218,8 +208,6 @@ export function ChatWindow() {
// eslint-disable-next-line react-hooks/exhaustive-deps -- markRead ref stable
}, [isOpen, activeSessionId, currentHasUnread]);
const selectedContext = useChatStore((s) => s.selectedContext);
const { uploadWithToast } = useFileUpload(api);
// Lazy-creates a chat_session the first time the user needs an id —
@@ -296,7 +284,7 @@ export function ChatWindow() {
return;
}
const finalContent = buildOutgoingChatContent(content, selectedContext);
const finalContent = content;
const isNewSession = !activeSessionId;
@@ -305,7 +293,6 @@ export function ChatWindow() {
isNewSession,
agentId: activeAgent.id,
contentLength: finalContent.length,
hasAnchor: !!selectedContext,
attachmentCount: attachmentIds?.length ?? 0,
});
@@ -375,7 +362,6 @@ export function ChatWindow() {
[
activeSessionId,
activeAgent,
selectedContext,
ensureSession,
qc,
setActiveSession,
@@ -478,6 +464,8 @@ export function ChatWindow() {
pointerEvents: isOpen ? "auto" : "none",
};
const contextItems = useChatContextItems(wsId);
return (
<motion.div
ref={windowRef}
@@ -586,8 +574,8 @@ export function ChatWindow() {
{/* Status banner above the input — single mutually-exclusive slot.
* Priority: no-agent > offline / unstable. Agent presence is the
* hard prerequisite (you can't send anything without one), so it
* always wins over a presence hint. ContextAnchorCard stays in
* topSlot because that's per-message context, not session state.
* always wins over a presence hint. Recent issue/project navigation
* lives in the input action row; it is not message/session state.
*
* We key off `noAgent` (the resolved-empty state) rather than
* `!activeAgent`, so the loading window between mount and the
@@ -608,7 +596,6 @@ export function ChatWindow() {
disabled={isSessionArchived}
noAgent={noAgent}
agentName={activeAgent?.name}
topSlot={<ContextAnchorCard />}
leftAdornment={
<AgentDropdown
agents={availableAgents}
@@ -617,7 +604,7 @@ export function ChatWindow() {
onSelect={handleSelectAgent}
/>
}
rightAdornment={<ContextAnchorButton />}
contextItems={contextItems}
/>
</motion.div>
);

View File

@@ -1,63 +0,0 @@
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,34 +0,0 @@
import { describe, it, expect } from "vitest";
import { buildAnchorMarkdown } from "./context-anchor";
describe("buildAnchorMarkdown", () => {
it("formats an issue anchor as a mention link with title subtitle", () => {
const md = buildAnchorMarkdown({
type: "issue",
id: "uuid-123",
label: "MUL-42",
subtitle: "Fix login redirect",
});
expect(md).toBe(
'Context: [MUL-42](mention://issue/uuid-123) — "Fix login redirect"',
);
});
it("omits the subtitle clause when none is provided", () => {
const md = buildAnchorMarkdown({
type: "issue",
id: "uuid-x",
label: "MUL-7",
});
expect(md).toBe("Context: [MUL-7](mention://issue/uuid-x)");
});
it("formats a project anchor as plain text (no mention type)", () => {
const md = buildAnchorMarkdown({
type: "project",
id: "proj-uuid",
label: "Authentication",
});
expect(md).toBe('Context: Project "Authentication"');
});
});

View File

@@ -1,195 +0,0 @@
"use client";
import { useMemo, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { Link2, Search, X } from "lucide-react";
import type { ContextAnchor } 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 { 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";
export function buildAnchorMarkdown(anchor: ContextAnchor): string {
if (anchor.type === "issue") {
const base = `Context: [${anchor.label}](mention://issue/${anchor.id})`;
return anchor.subtitle ? `${base} — "${anchor.subtitle}"` : base;
}
return `Context: Project "${anchor.label}"`;
}
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]!) : 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;
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,
});
if (issueIdToFetch) {
if (!issue) return { candidate: null, isResolving: issueLoading };
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: null, isResolving: false };
}
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 } = 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 { 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 { 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 (
<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>
);
}
export function ContextAnchorCard() {
const { t } = useT("chat");
const paths = useWorkspacePaths();
const selectedContext = useChatStore((s) => s.selectedContext);
const setSelectedContext = useChatStore((s) => s.setSelectedContext);
if (!selectedContext) 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 });
return (
<div className="mx-2 mt-2 flex items-center gap-1">
<Tooltip>
<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

@@ -0,0 +1,29 @@
import { describe, expect, it } from "vitest";
import { parseCurrentContextRoute } from "./use-chat-context-items";
describe("parseCurrentContextRoute", () => {
it("detects issue detail pages", () => {
expect(parseCurrentContextRoute("/acme/issues/issue-1", new URLSearchParams())).toEqual({
type: "issue",
id: "issue-1",
});
});
it("detects project detail pages", () => {
expect(parseCurrentContextRoute("/acme/projects/project-1", new URLSearchParams())).toEqual({
type: "project",
id: "project-1",
});
});
it("uses the inbox issue query param as the current issue id", () => {
expect(parseCurrentContextRoute("/acme/inbox", new URLSearchParams("issue=issue-42"))).toEqual({
type: "issue",
id: "issue-42",
});
});
it("does not treat the bare inbox route as current issue context", () => {
expect(parseCurrentContextRoute("/acme/inbox", new URLSearchParams())).toBeNull();
});
});

View File

@@ -0,0 +1,117 @@
"use client";
import { useMemo } from "react";
import { useQueries, useQuery } from "@tanstack/react-query";
import { selectRecentContexts, useRecentContextStore, type RecentContextEntry } from "@multica/core/chat";
import { issueDetailOptions } from "@multica/core/issues/queries";
import { projectDetailOptions } from "@multica/core/projects/queries";
import type { Issue, Project } from "@multica/core/types";
import type { MentionItem } from "../../editor/extensions/mention-suggestion";
import { useNavigation } from "../../navigation";
const MAX_RECENT_MENTION_ITEMS = 8;
function mentionKey(item: Pick<MentionItem, "type" | "id">): string {
return `${item.type}:${item.id}`;
}
function issueToMentionItem(issue: Pick<Issue, "id" | "identifier" | "title" | "status">, group: "current" | "recent"): MentionItem {
return {
id: issue.id,
label: issue.identifier,
type: "issue",
description: issue.title,
status: issue.status,
group,
};
}
function projectToMentionItem(project: Pick<Project, "id" | "title" | "description" | "icon" | "status">, group: "current" | "recent"): MentionItem {
return {
id: project.id,
label: project.title,
type: "project",
description: project.description ?? undefined,
icon: project.icon,
projectStatus: project.status,
group,
};
}
function recentEntryToMentionItem(entry: RecentContextEntry): MentionItem {
return {
id: entry.id,
label: entry.label ?? entry.id,
type: entry.type,
description: entry.subtitle,
status: entry.status,
projectStatus: entry.projectStatus,
icon: entry.icon,
group: "recent",
};
}
function hydrateRecentEntry(entry: RecentContextEntry, data: Issue | Project | undefined): MentionItem {
if (!data) return recentEntryToMentionItem(entry);
return entry.type === "issue"
? issueToMentionItem(data as Issue, "recent")
: projectToMentionItem(data as Project, "recent");
}
export function parseCurrentContextRoute(pathname: string, searchParams: URLSearchParams): { type: "issue" | "project"; id: string } | null {
const issueMatch = pathname.match(/^\/[^/]+\/issues\/([^/]+)$/);
if (issueMatch?.[1]) return { type: "issue", id: decodeURIComponent(issueMatch[1]) };
const projectMatch = pathname.match(/^\/[^/]+\/projects\/([^/]+)$/);
if (projectMatch?.[1]) return { type: "project", id: decodeURIComponent(projectMatch[1]) };
const inboxMatch = pathname.match(/^\/[^/]+\/inbox$/);
const inboxIssueId = searchParams.get("issue");
if (inboxMatch && inboxIssueId) return { type: "issue", id: inboxIssueId };
return null;
}
export function useChatContextItems(wsId: string): MentionItem[] {
const { pathname, searchParams } = useNavigation();
const currentRoute = parseCurrentContextRoute(pathname, searchParams);
const recentEntries = useRecentContextStore(selectRecentContexts(wsId));
const visibleRecentEntries = useMemo(
() => recentEntries.slice(0, MAX_RECENT_MENTION_ITEMS),
[recentEntries],
);
const { data: currentIssue } = useQuery({
...issueDetailOptions(wsId, currentRoute?.type === "issue" ? currentRoute.id : ""),
enabled: currentRoute?.type === "issue",
});
const { data: currentProject } = useQuery({
...projectDetailOptions(wsId, currentRoute?.type === "project" ? currentRoute.id : ""),
enabled: currentRoute?.type === "project",
});
const recentQueries = useQueries({
queries: visibleRecentEntries.map((entry) => ({
...(entry.type === "issue"
? issueDetailOptions(wsId, entry.id)
: projectDetailOptions(wsId, entry.id)),
staleTime: 30_000,
})),
});
return useMemo(() => {
const currentItems: MentionItem[] = [];
if (currentIssue) currentItems.push(issueToMentionItem(currentIssue, "current"));
if (currentProject) currentItems.push(projectToMentionItem(currentProject, "current"));
const hidden = new Set(currentItems.map(mentionKey));
const recentItems = visibleRecentEntries
.map((entry, index) => hydrateRecentEntry(entry, recentQueries[index]?.data as Issue | Project | undefined))
.filter((item) => !hidden.has(mentionKey(item)))
.slice(0, MAX_RECENT_MENTION_ITEMS);
return [...currentItems, ...recentItems];
}, [currentIssue, currentProject, recentQueries, visibleRecentEntries]);
}

View File

@@ -1,3 +1,4 @@
import type { ReactNode } from "react";
import { render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { Markdown } from "./markdown";
@@ -13,6 +14,41 @@ vi.mock("../issues/components/issue-mention-card", () => ({
),
}));
vi.mock("@multica/core/paths", async (importOriginal) => {
const actual = await importOriginal<typeof import("@multica/core/paths")>();
return {
...actual,
useWorkspaceSlug: () => "acme",
useRequiredWorkspaceSlug: () => "acme",
useWorkspacePaths: () => ({
...actual.paths.workspace("acme"),
projectDetail: (projectId: string) => `/projects/${projectId}`,
}),
};
});
vi.mock("../navigation", () => ({
AppLink: ({
href,
children,
className,
}: {
href: string;
children: ReactNode;
className?: string;
}) => (
<a href={href} className={className}>
{children}
</a>
),
}));
vi.mock("../projects/components/project-chip", () => ({
ProjectChip: ({ projectId }: { projectId: string }) => (
<span data-testid="project-chip">{projectId}</span>
),
}));
const ligatureClasses = [
"[font-variant-ligatures:none]",
"[font-feature-settings:'liga'_0]",
@@ -46,4 +82,11 @@ describe("Markdown", () => {
expect(pill).not.toBeNull();
expect(pill?.textContent).toBe("/deploy");
});
it("renders project mention links as project chips", () => {
render(<Markdown>{"[Roadmap](mention://project/project-123)"}</Markdown>);
expect(screen.getByTestId("project-chip")).toHaveTextContent("project-123");
expect(screen.getByRole("link")).toHaveAttribute("href", "/projects/project-123");
});
});

View File

@@ -8,7 +8,10 @@ import {
} from "@multica/ui/markdown";
import { useConfigStore } from "@multica/core/config";
import type { Attachment as AttachmentRecord } from "@multica/core/types";
import { useWorkspacePaths } from "@multica/core/paths";
import { IssueMentionCard } from "../issues/components/issue-mention-card";
import { ProjectChip } from "../projects/components/project-chip";
import { AppLink } from "../navigation";
import {
Attachment as AttachmentRenderer,
AttachmentDownloadProvider,
@@ -28,9 +31,21 @@ export interface MarkdownProps extends MarkdownBaseProps {
}
/**
* Default renderMention that delegates to IssueMentionCard for issue mentions
* Default renderMention that delegates to entity chips for issue/project mentions
* and renders a styled span for other mention types.
*/
function ProjectMentionCard({ projectId }: { projectId: string }): React.ReactNode {
const p = useWorkspacePaths();
return (
<AppLink href={p.projectDetail(projectId)} className="project-mention not-prose inline-flex">
<ProjectChip
projectId={projectId}
className="cursor-pointer hover:bg-accent transition-colors"
/>
</AppLink>
);
}
function defaultRenderMention({
type,
id,
@@ -41,6 +56,9 @@ function defaultRenderMention({
if (type === "issue") {
return <IssueMentionCard issueId={id} />;
}
if (type === "project") {
return <ProjectMentionCard projectId={id} />;
}
return null;
}
@@ -76,7 +94,7 @@ function renderFileCard({
/**
* App-level Markdown wrapper. Injects:
* - IssueMentionCard for issue mentions
* - entity chips for issue/project mentions
* - cdnDomain from the config store (drives fileCard preprocessing)
* - unified <Attachment> as the image / file-card renderer
* - AttachmentDownloadProvider so url → record resolution works inside

View File

@@ -43,6 +43,7 @@ import type { UploadResult } from "@multica/core/hooks/use-file-upload";
import { useWorkspaceSlug } from "@multica/core/paths";
import { useQueryClient } from "@tanstack/react-query";
import type { Attachment } from "@multica/core/types";
import type { MentionItem } from "./extensions/mention-suggestion";
import { createEditorExtensions } from "./extensions";
import { uploadAndInsertFile } from "./extensions/file-upload";
import { preprocessMarkdown } from "./utils/preprocess";
@@ -96,6 +97,9 @@ interface ContentEditorProps {
* prompts) but *preserving* an existing one still matters.
*/
disableMentions?: boolean;
/** Chat can surface current/recent issue/project suggestions. Other editors use default mention behavior. */
mentionMode?: "default" | "context";
mentionContextItems?: MentionItem[];
/** Enable the chat-only `/` skill picker. Defaults false. */
enableSlashCommands?: boolean;
/**
@@ -141,6 +145,8 @@ const ContentEditor = forwardRef<ContentEditorRef, ContentEditorProps>(
submitOnEnter = false,
currentIssueId,
disableMentions = false,
mentionMode = "default",
mentionContextItems,
enableSlashCommands = false,
attachments,
},
@@ -151,6 +157,7 @@ const ContentEditor = forwardRef<ContentEditorRef, ContentEditorProps>(
const onSubmitRef = useRef(onSubmit);
const onBlurRef = useRef(onBlur);
const onUploadFileRef = useRef(onUploadFile);
const mentionContextItemsRef = useRef<MentionItem[]>(mentionContextItems ?? []);
const lastEmittedRef = useRef<string | null>(null);
// Current workspace slug kept in a ref so the click handler always sees the
@@ -165,6 +172,7 @@ const ContentEditor = forwardRef<ContentEditorRef, ContentEditorProps>(
onSubmitRef.current = onSubmit;
onBlurRef.current = onBlur;
onUploadFileRef.current = onUploadFile;
mentionContextItemsRef.current = mentionContextItems ?? [];
const queryClient = useQueryClient();
@@ -185,6 +193,8 @@ const ContentEditor = forwardRef<ContentEditorRef, ContentEditorProps>(
onUploadFileRef,
submitOnEnter,
disableMentions,
mentionMode,
getMentionContextItems: () => mentionContextItemsRef.current,
enableSlashCommands,
}),
onUpdate: ({ editor: ed }) => {

View File

@@ -37,7 +37,7 @@ import type { AnyExtension } from "@tiptap/core";
import type { UploadResult } from "@multica/core/hooks/use-file-upload";
import { escapeMarkdownLabel } from "../utils/escape-markdown-label";
import { BaseMentionExtension } from "./mention-extension";
import { createMentionSuggestion } from "./mention-suggestion";
import { createMentionSuggestion, type MentionItem } from "./mention-suggestion";
import { SlashCommandExtension } from "./slash-command-extension";
import { createSlashCommandSuggestion } from "./slash-command-suggestion";
import { CodeBlockView } from "./code-block-view";
@@ -108,6 +108,9 @@ export interface EditorExtensionsOptions {
* system prompts) but *preserving* an existing one still matters.
*/
disableMentions?: boolean;
/** Override @ behavior for chat context suggestions. */
mentionMode?: "default" | "context";
getMentionContextItems?: () => MentionItem[];
/** When true, attach the `/` skill picker. Default false. */
enableSlashCommands?: boolean;
}
@@ -157,7 +160,7 @@ export function createEditorExtensions(
...(options.disableMentions
? { suggestion: { allow: () => false } }
: options.queryClient
? { suggestion: createMentionSuggestion(options.queryClient) }
? { suggestion: createMentionSuggestion(options.queryClient, { mode: options.mentionMode, getContextItems: options.getMentionContextItems }) }
: {}),
}),
SlashCommandExtension.configure({

View File

@@ -9,7 +9,7 @@ export const BaseMentionExtension = Mention.extend({
},
renderHTML({ node, HTMLAttributes }) {
const type = node.attrs.type ?? "member";
const prefix = type === "issue" ? "" : "@";
const prefix = type === "issue" || type === "project" ? "" : "@";
return [
"span",
mergeAttributes(
@@ -66,7 +66,7 @@ export const BaseMentionExtension = Mention.extend({
},
renderMarkdown: (node: any) => {
const { id, label, type = "member" } = node.attrs || {};
const prefix = type === "issue" ? "" : "@";
const prefix = type === "issue" || type === "project" ? "" : "@";
// Escape square brackets in the label so the markdown link syntax
// is not broken when the name contains [ or ] (e.g. "David[TF]").
const safeLabel = (label ?? id).replace(/\[/g, "\\[").replace(/\]/g, "\\]");

View File

@@ -28,13 +28,17 @@ vi.mock("@multica/core/platform", () => ({
getCurrentWsId: () => "ws-1",
}));
// Mock the API so we control searchIssues responses + observe calls.
// Mock the API so we control search responses + observe calls.
const searchIssuesMock = vi.fn();
const searchProjectsMock = vi.fn();
vi.mock("@multica/core/api", () => ({
api: {
get searchIssues() {
return searchIssuesMock;
},
get searchProjects() {
return searchProjectsMock;
},
},
}));
@@ -100,6 +104,8 @@ function fakeQc(data: {
describe("createMentionSuggestion", () => {
beforeEach(() => {
searchIssuesMock.mockReset();
searchProjectsMock.mockReset();
Element.prototype.scrollIntoView = vi.fn();
});
it("returns members and agents synchronously without waiting for the server search", () => {
@@ -158,10 +164,39 @@ describe("createMentionSuggestion", () => {
);
});
it("loads server issue and project matches when project search is enabled", async () => {
searchIssuesMock.mockResolvedValue({ issues: [], total: 0 });
searchProjectsMock.mockResolvedValue({
projects: [
{
id: "p-roadmap",
title: "Roadmap",
description: "Q3 planning",
icon: null,
status: "active",
},
],
total: 1,
});
render(
<I18nWrapper>
<MentionList items={[]} query="road" command={vi.fn()} includeProjectSearch />
</I18nWrapper>,
);
await waitFor(() => {
expect(screen.getByText("Roadmap")).toBeInTheDocument();
});
expect(searchIssuesMock).toHaveBeenCalledWith(expect.objectContaining({ q: "road", limit: 8 }));
expect(searchProjectsMock).toHaveBeenCalledWith(expect.objectContaining({ q: "road", limit: 8 }));
});
it("does not call searchIssues for an empty query", () => {
render(<I18nWrapper><MentionList items={[]} query="" command={vi.fn()} /></I18nWrapper>);
expect(searchIssuesMock).not.toHaveBeenCalled();
expect(searchProjectsMock).not.toHaveBeenCalled();
});
it("captures Enter while the popup has no selectable items", () => {
@@ -262,6 +297,85 @@ describe("createMentionSuggestion", () => {
expect(items.some((i) => i.type === "issue" && i.id === "i1")).toBe(true);
});
it("does not inject current/recent chat context into the normal @ results", () => {
const qc = fakeQc({
members: [{ user_id: "u1", name: "Alice", role: "member" }],
issues: [{ id: "i1", identifier: "MUL-1", title: "Login bug", status: "todo" }],
});
searchIssuesMock.mockReturnValue(new Promise(() => {}));
const config = createMentionSuggestion(qc);
const result = config.items!({ query: "", editor: {} as never }) as MentionItem[];
expect(result.some((item) => item.group === "current" || item.group === "recent")).toBe(false);
expect(result.map((item) => `${item.type}:${item.id}`)).toContain("member:u1");
expect(result.map((item) => `${item.type}:${item.id}`)).toContain("issue:i1");
});
it("shows only current/recent chat context before the user types a query", () => {
const qc = fakeQc({
members: [{ user_id: "u1", name: "Alice", role: "member" }],
agents: [{ id: "a1", name: "Aegis", archived_at: null, visibility: "workspace", owner_id: null }],
issues: [{ id: "i-cache", identifier: "MUL-9", title: "Cached", status: "todo" }],
});
searchIssuesMock.mockReturnValue(new Promise(() => {}));
const config = createMentionSuggestion(qc, {
mode: "context",
getContextItems: () => [
{ id: "i1", label: "MUL-1", type: "issue", description: "Alpha issue", status: "todo", group: "current" },
{ id: "p1", label: "Roadmap", type: "project", description: "Q3", group: "recent" },
],
});
const result = config.items!({ query: "", editor: {} as never }) as MentionItem[];
expect(result.map((item) => `${item.type}:${item.id}`)).toEqual(["issue:i1", "project:p1"]);
expect(result.some((item) => item.type === "member" || item.type === "agent")).toBe(false);
});
it("prepends current/recent chat context without removing normal mention targets after the user types", () => {
const qc = fakeQc({
members: [{ user_id: "u1", name: "Alice", role: "member" }],
agents: [{ id: "a1", name: "Aegis", archived_at: null, visibility: "workspace", owner_id: null }],
issues: [{ id: "i-cache", identifier: "MUL-9", title: "Cached", status: "todo" }],
});
searchIssuesMock.mockReturnValue(new Promise(() => {}));
const config = createMentionSuggestion(qc, {
mode: "context",
getContextItems: () => [
{ id: "i1", label: "MUL-1", type: "issue", description: "Alpha issue", status: "todo", group: "current" },
{ id: "p1", label: "Roadmap", type: "project", description: "Q3", group: "recent" },
],
});
const result = config.items!({ query: "a", editor: {} as never }) as MentionItem[];
expect(result.map((item) => `${item.type}:${item.id}`).slice(0, 2)).toEqual(["issue:i1", "project:p1"]);
expect(result.some((item) => item.type === "member" && item.label === "Alice")).toBe(true);
expect(result.some((item) => item.type === "agent" && item.label === "Aegis")).toBe(true);
});
it("renders current and recent sections for injected object mentions", () => {
render(
<I18nWrapper>
<MentionList
items={[
{ id: "i1", label: "MUL-1", type: "issue", description: "Login bug", group: "current" },
{ id: "p1", label: "Roadmap", type: "project", description: "Q3", group: "recent" },
]}
query=""
command={vi.fn()}
/>
</I18nWrapper>,
);
expect(screen.getByText("Current page")).toBeInTheDocument();
expect(screen.getByText("Recently viewed")).toBeInTheDocument();
expect(screen.getByText("MUL-1")).toBeInTheDocument();
expect(screen.getByText("Roadmap")).toBeInTheDocument();
});
it("includes all non-archived squads in the mention list", () => {
const qc = fakeQc({
members: [{ user_id: "u1", name: "Alice", role: "member" }],

View File

@@ -8,6 +8,7 @@ import {
useMemo,
useRef,
useState,
type ReactNode,
} from "react";
import type { QueryClient } from "@tanstack/react-query";
import { getCurrentWsId } from "@multica/core/platform";
@@ -24,11 +25,14 @@ import type {
Agent,
Squad,
} from "@multica/core/types";
import { ListTodo } from "lucide-react";
import { ActorAvatar } from "../../common/actor-avatar";
import { StatusIcon } from "../../issues/components/status-icon";
import { ProjectIcon } from "../../projects/components/project-icon";
import { useT } from "../../i18n";
import { Badge } from "@multica/ui/components/ui/badge";
import type { IssueStatus } from "@multica/core/types";
import type { IssueStatus, ProjectStatus } from "@multica/core/types";
import { PROJECT_STATUS_CONFIG } from "@multica/core/projects/config";
import type { SuggestionOptions } from "@tiptap/suggestion";
import { PluginKey } from "@tiptap/pm/state";
import {
@@ -46,17 +50,24 @@ import { createSuggestionPopupRender } from "./suggestion-popup";
export interface MentionItem {
id: string;
label: string;
type: "member" | "agent" | "squad" | "issue" | "all";
type: "member" | "agent" | "squad" | "issue" | "project" | "all";
/** Optional grouping hint for injected context items. */
group?: "current" | "recent" | "search";
/** Secondary text shown beside the label (e.g. issue title) */
description?: string;
/** Issue status for StatusIcon rendering */
status?: IssueStatus;
/** Project emoji/icon snapshot for ProjectIcon rendering */
icon?: string | null;
/** Project status snapshot for recent/current project rendering */
projectStatus?: ProjectStatus;
}
interface MentionListProps {
items: MentionItem[];
query: string;
command: (item: MentionItem) => void;
includeProjectSearch?: boolean;
}
export interface MentionListRef {
@@ -73,11 +84,20 @@ interface MentionGroup {
}
function groupItems(items: MentionItem[]): MentionGroup[] {
const current: MentionItem[] = [];
const recent: MentionItem[] = [];
const search: MentionItem[] = [];
const users: MentionItem[] = [];
const issues: MentionItem[] = [];
for (const item of items) {
if (item.type === "issue") {
if (item.group === "current") {
current.push(item);
} else if (item.group === "recent") {
recent.push(item);
} else if (item.group === "search") {
search.push(item);
} else if (item.type === "issue" || item.type === "project") {
issues.push(item);
} else {
users.push(item);
@@ -85,6 +105,9 @@ function groupItems(items: MentionItem[]): MentionGroup[] {
}
const groups: MentionGroup[] = [];
if (current.length > 0) groups.push({ label: "Current", items: current });
if (recent.length > 0) groups.push({ label: "Recent", items: recent });
if (search.length > 0) groups.push({ label: "Search", items: search });
if (users.length > 0) groups.push({ label: "Users", items: users });
if (issues.length > 0) groups.push({ label: "Issues", items: issues });
return groups;
@@ -96,6 +119,7 @@ function groupItems(items: MentionItem[]): MentionGroup[] {
const MAX_ITEMS = 20;
const SERVER_ISSUE_SEARCH_LIMIT = 20;
const SERVER_CONTEXT_SEARCH_LIMIT = 8;
const SERVER_SEARCH_DEBOUNCE_MS = 150;
function mentionItemKey(item: MentionItem): string {
@@ -103,13 +127,12 @@ function mentionItemKey(item: MentionItem): string {
}
function mergeMentionItems(
syncItems: MentionItem[],
serverIssueItems: MentionItem[],
...itemGroups: MentionItem[][]
): MentionItem[] {
const seen = new Set<string>();
const merged: MentionItem[] = [];
for (const item of [...syncItems, ...serverIssueItems]) {
for (const item of itemGroups.flat()) {
const key = mentionItemKey(item);
if (seen.has(key)) continue;
seen.add(key);
@@ -120,54 +143,77 @@ function mergeMentionItems(
}
export const MentionList = forwardRef<MentionListRef, MentionListProps>(
function MentionList({ items, query, command }, ref) {
function MentionList({ items, query, command, includeProjectSearch = false }, ref) {
const { t } = useT("editor");
const [selectedIndex, setSelectedIndex] = useState(0);
const [serverIssueItems, setServerIssueItems] = useState<MentionItem[]>([]);
const [isSearchingIssues, setIsSearchingIssues] = useState(false);
const [searchedIssueQuery, setSearchedIssueQuery] = useState("");
const [serverItems, setServerItems] = useState<MentionItem[]>([]);
const [isSearching, setIsSearching] = useState(false);
const [searchedQuery, setSearchedQuery] = useState("");
const itemRefs = useRef<(HTMLButtonElement | null)[]>([]);
const normalizedQuery = query.trim();
useEffect(() => {
const q = normalizedQuery;
setServerIssueItems([]);
setServerItems([]);
if (!q) {
setIsSearchingIssues(false);
setSearchedIssueQuery("");
setIsSearching(false);
setSearchedQuery("");
return;
}
const wsId = getCurrentWsId();
if (!wsId) {
setIsSearchingIssues(false);
setSearchedIssueQuery(q);
setIsSearching(false);
setSearchedQuery(q);
return;
}
let cancelled = false;
const controller = new AbortController();
setIsSearchingIssues(true);
setIsSearching(true);
const timer = setTimeout(() => {
void (async () => {
try {
const res = await api.searchIssues({
q,
limit: SERVER_ISSUE_SEARCH_LIMIT,
include_closed: true,
signal: controller.signal,
});
if (!cancelled && !controller.signal.aborted) {
setServerIssueItems(res.issues.map(issueToMention));
if (includeProjectSearch) {
const [issues, projects] = await Promise.all([
api.searchIssues({
q,
limit: SERVER_CONTEXT_SEARCH_LIMIT,
include_closed: true,
signal: controller.signal,
}),
api.searchProjects({
q,
limit: SERVER_CONTEXT_SEARCH_LIMIT,
include_closed: true,
signal: controller.signal,
}),
]);
if (!cancelled && !controller.signal.aborted) {
setServerItems([
...issues.issues.map((issue) => ({ ...issueToMention(issue), group: "search" as const })),
...projects.projects.map((project) => ({ ...projectToMention(project), group: "search" as const })),
]);
}
} else {
const res = await api.searchIssues({
q,
limit: SERVER_ISSUE_SEARCH_LIMIT,
include_closed: true,
signal: controller.signal,
});
if (!cancelled && !controller.signal.aborted) {
setServerItems(res.issues.map(issueToMention));
}
}
} catch {
// Aborted or network error: keep the synchronous cache results.
} finally {
if (!cancelled && !controller.signal.aborted) {
setSearchedIssueQuery(q);
setIsSearchingIssues(false);
setSearchedQuery(q);
setIsSearching(false);
}
}
})();
@@ -178,13 +224,12 @@ export const MentionList = forwardRef<MentionListRef, MentionListProps>(
clearTimeout(timer);
controller.abort();
};
}, [normalizedQuery]);
}, [includeProjectSearch, normalizedQuery]);
const displayItems = useMemo(() => {
const currentServerIssueItems =
searchedIssueQuery === normalizedQuery ? serverIssueItems : [];
return mergeMentionItems(items, currentServerIssueItems).slice(0, MAX_ITEMS);
}, [items, normalizedQuery, searchedIssueQuery, serverIssueItems]);
const currentServerItems = searchedQuery === normalizedQuery ? serverItems : [];
return mergeMentionItems(items, currentServerItems).slice(0, MAX_ITEMS);
}, [items, normalizedQuery, searchedQuery, serverItems]);
useEffect(() => {
setSelectedIndex(0);
@@ -234,7 +279,7 @@ export const MentionList = forwardRef<MentionListRef, MentionListProps>(
if (displayItems.length === 0) {
const isWaitingForServer =
normalizedQuery !== "" &&
(isSearchingIssues || searchedIssueQuery !== normalizedQuery);
(isSearching || searchedQuery !== normalizedQuery);
return (
<div className="rounded-md border bg-popover p-2 text-xs text-muted-foreground shadow-md">
@@ -246,7 +291,12 @@ export const MentionList = forwardRef<MentionListRef, MentionListProps>(
}
const groups = groupItems(displayItems);
const hasContextGroups = displayItems.some((item) => item.group === "current" || item.group === "recent");
const contextLayout = hasContextGroups;
const groupLabel = (label: string): string => {
if (label === "Current") return t(($) => $.mention.group_current);
if (label === "Recent") return t(($) => $.mention.group_recent);
if (label === "Search") return t(($) => $.mention.group_search);
if (label === "Users") return t(($) => $.mention.group_users);
if (label === "Issues") return t(($) => $.mention.group_issues);
return label;
@@ -255,25 +305,48 @@ export const MentionList = forwardRef<MentionListRef, MentionListProps>(
// Build a flat index mapping: globalIndex → item
let globalIndex = 0;
const renderRows = (group: MentionGroup): ReactNode =>
group.items.map((item) => {
const idx = globalIndex++;
return (
<MentionRow
key={`${item.type}-${item.id}`}
item={item}
selected={idx === selectedIndex}
onSelect={() => selectItem(idx)}
buttonRef={(el) => { itemRefs.current[idx] = el; }}
/>
);
});
if (contextLayout) {
return (
<div className="flex max-h-[420px] w-96 flex-col overflow-hidden rounded-lg border bg-popover py-1 shadow-xl">
{groups.map((group) => {
const isRecent = group.label === "Recent";
return (
<section key={group.label} className={isRecent ? "min-h-0" : "shrink-0"}>
<div className="shrink-0 px-3 py-2 text-[11px] font-semibold uppercase tracking-wide text-muted-foreground/80">
{groupLabel(group.label)}
</div>
<div className={isRecent ? "max-h-64 overflow-y-auto overscroll-contain" : undefined}>
{renderRows(group)}
</div>
</section>
);
})}
</div>
);
}
return (
<div className="rounded-md border bg-popover py-1 shadow-md w-72 max-h-[300px] overflow-y-auto">
<div className="w-72 max-h-[300px] overflow-y-auto rounded-md border bg-popover py-1 shadow-md">
{groups.map((group) => (
<div key={group.label}>
<div className="px-3 py-1.5 text-xs font-medium text-muted-foreground">
<div className="px-3 py-2 text-[11px] font-semibold uppercase tracking-wide text-muted-foreground/80">
{groupLabel(group.label)}
</div>
{group.items.map((item) => {
const idx = globalIndex++;
return (
<MentionRow
key={`${item.type}-${item.id}`}
item={item}
selected={idx === selectedIndex}
onSelect={() => selectItem(idx)}
buttonRef={(el) => { itemRefs.current[idx] = el; }}
/>
);
})}
{renderRows(group)}
</div>
))}
</div>
@@ -305,21 +378,58 @@ function MentionRow({
<button
type="button"
ref={buttonRef}
className={`flex w-full items-center gap-2.5 px-3 py-1.5 text-left text-xs transition-colors ${
className={`flex w-full items-center gap-2.5 px-3 py-2 text-left text-xs transition-colors ${
selected ? "bg-accent" : "hover:bg-accent/50"
} ${isClosed ? "opacity-60" : ""}`}
onClick={onSelect}
>
{item.status && (
<StatusIcon status={item.status} className="h-3.5 w-3.5 shrink-0" />
)}
<span className="shrink-0 text-muted-foreground">{item.label}</span>
{item.description && (
<span
className={`truncate text-muted-foreground ${isClosed ? "line-through" : ""}`}
>
{item.description}
<span className="flex h-7 w-7 shrink-0 items-center justify-center">
{item.status ? (
<StatusIcon status={item.status} className="h-3.5 w-3.5" />
) : (
<ListTodo className="h-3.5 w-3.5 text-muted-foreground" />
)}
</span>
<span className="min-w-0 flex-1">
<span className="flex min-w-0 items-center gap-2">
<span className="shrink-0 font-medium text-muted-foreground">{item.label}</span>
{item.description && (
<span
className={`truncate text-foreground ${isClosed ? "line-through" : ""}`}
>
{item.description}
</span>
)}
</span>
</span>
</button>
);
}
if (item.type === "project") {
const projectStatusCfg = item.projectStatus ? PROJECT_STATUS_CONFIG[item.projectStatus] : null;
return (
<button
type="button"
ref={buttonRef}
className={`flex w-full items-center gap-2.5 px-3 py-2 text-left text-xs transition-colors ${
selected ? "bg-accent" : "hover:bg-accent/50"
}`}
onClick={onSelect}
>
<span className="flex h-7 w-7 shrink-0 items-center justify-center">
<ProjectIcon project={{ icon: item.icon ?? null }} size="sm" />
</span>
<span className="min-w-0 flex-1">
<span className="block truncate font-medium text-foreground">{item.label}</span>
{item.description && (
<span className="block truncate text-muted-foreground">
{item.description}
</span>
)}
</span>
{projectStatusCfg && (
<span className={`${projectStatusCfg.dotColor} ml-auto size-1.5 shrink-0 rounded-full`} />
)}
</button>
);
@@ -371,7 +481,37 @@ function issueToMention(i: Pick<Issue, "id" | "identifier" | "title" | "status">
};
}
export function createMentionSuggestion(qc: QueryClient): Omit<
function projectToMention(p: { id: string; title: string; description?: string | null; icon?: string | null; status?: ProjectStatus }): MentionItem {
return {
id: p.id,
label: p.title,
type: "project" as const,
description: p.description ?? undefined,
icon: p.icon ?? null,
projectStatus: p.status,
};
}
function matchesMentionQuery(item: MentionItem, query: string): boolean {
const q = query.trim().toLowerCase();
if (!q) return true;
return (
item.label.toLowerCase().includes(q) ||
item.description?.toLowerCase().includes(q) === true ||
matchesPinyin(item.label, q) ||
(item.description ? matchesPinyin(item.description, q) : false)
);
}
interface MentionSuggestionOptions {
mode?: "default" | "context";
getContextItems?: () => MentionItem[];
}
export function createMentionSuggestion(
qc: QueryClient,
options: MentionSuggestionOptions = {},
): Omit<
SuggestionOptions<MentionItem>,
"editor"
> {
@@ -455,8 +595,13 @@ export function createMentionSuggestion(qc: QueryClient): Omit<
return {
pluginKey,
items: ({ query }) => {
const syncItems = buildSyncItems(query);
return syncItems;
if (options.mode === "context") {
const normalizedQuery = query.trim();
const contextItems = (options.getContextItems?.() ?? []).filter((item) => matchesMentionQuery(item, query));
if (!normalizedQuery) return contextItems;
return mergeMentionItems(contextItems, buildSyncItems(query));
}
return buildSyncItems(query);
},
render: createSuggestionPopupRender<MentionItem, MentionItem, MentionListRef, MentionListProps>({
@@ -466,6 +611,7 @@ export function createMentionSuggestion(qc: QueryClient): Omit<
items: props.items,
query: props.query,
command: props.command,
includeProjectSearch: options.mode === "context",
}),
onKeyDown: (ref, props) => ref?.onKeyDown(props) ?? false,
}),

View File

@@ -19,6 +19,7 @@ import type { NodeViewProps } from "@tiptap/react";
import { useWorkspacePaths } from "@multica/core/paths";
import { useNavigation } from "../../navigation";
import { IssueChip } from "../../issues/components/issue-chip";
import { ProjectChip } from "../../projects/components/project-chip";
export function MentionView({ node }: NodeViewProps) {
const { type, id, label } = node.attrs;
@@ -31,6 +32,14 @@ export function MentionView({ node }: NodeViewProps) {
);
}
if (type === "project") {
return (
<NodeViewWrapper as="span" className="inline">
<ProjectMention projectId={id} fallbackLabel={label} />
</NodeViewWrapper>
);
}
return (
<NodeViewWrapper as="span" className="inline">
<span className="mention">@{label ?? id}</span>
@@ -38,6 +47,38 @@ export function MentionView({ node }: NodeViewProps) {
);
}
function ProjectMention({
projectId,
fallbackLabel,
}: {
projectId: string;
fallbackLabel?: string;
}) {
const p = useWorkspacePaths();
const { push, openInNewTab } = useNavigation();
const projectPath = p.projectDetail(projectId);
const handleClick = (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
if (e.metaKey || e.ctrlKey || e.shiftKey) {
if (openInNewTab) openInNewTab(projectPath, fallbackLabel);
return;
}
push(projectPath);
};
return (
<a href={projectPath} onClick={handleClick} className="project-mention inline-flex">
<ProjectChip
projectId={projectId}
fallbackLabel={fallbackLabel}
className="cursor-pointer hover:bg-accent transition-colors"
/>
</a>
);
}
function IssueMention({
issueId,
fallbackLabel,

View File

@@ -1,7 +1,7 @@
"use client";
import type { ComponentType } from "react";
import { computePosition, flip, offset, shift } from "@floating-ui/dom";
import { autoUpdate, computePosition, flip, offset, shift, size } from "@floating-ui/dom";
import { ReactRenderer } from "@tiptap/react";
import { exitSuggestion, type SuggestionKeyDownProps, type SuggestionProps } from "@tiptap/suggestion";
import type { PluginKey } from "@tiptap/pm/state";
@@ -36,10 +36,13 @@ export function createSuggestionPopupRender<
let renderer: ReactRenderer<TRef> | null = null;
let popup: HTMLDivElement | null = null;
let removeOutsideListeners: (() => void) | null = null;
let removeAutoUpdate: (() => void) | null = null;
const cleanup = () => {
removeOutsideListeners?.();
removeOutsideListeners = null;
removeAutoUpdate?.();
removeAutoUpdate = null;
renderer?.destroy();
renderer = null;
popup?.remove();
@@ -99,11 +102,40 @@ export function createSuggestionPopupRender<
computePosition(virtualEl, el, {
placement: "bottom-start",
strategy: "fixed",
middleware: [offset(4), flip(), shift({ padding: 8 })],
}).then(({ x, y }) => {
middleware: [
offset(6),
flip({ padding: 8 }),
shift({ padding: 8 }),
size({
padding: 8,
apply({ availableHeight }) {
el.style.maxHeight = `${Math.max(120, availableHeight)}px`;
},
}),
],
}).then(({ x, y, placement }) => {
if (popup !== el) return;
el.style.left = `${x}px`;
el.style.top = `${y}px`;
el.dataset.side = placement.startsWith("top") ? "top" : "bottom";
});
};
const trackPosition = (
el: HTMLDivElement,
clientRect: (() => DOMRect | null) | null | undefined,
) => {
removeAutoUpdate?.();
removeAutoUpdate = null;
if (!clientRect) return;
const virtualEl = {
getBoundingClientRect: () => clientRect() ?? new DOMRect(),
};
removeAutoUpdate = autoUpdate(virtualEl, el, () => updatePosition(el, clientRect), {
ancestorResize: true,
ancestorScroll: true,
elementResize: true,
layoutShift: true,
});
};
@@ -122,12 +154,16 @@ export function createSuggestionPopupRender<
doc.body.appendChild(popup);
installOutsideListeners(props);
trackPosition(popup, props.clientRect);
updatePosition(popup, props.clientRect);
},
onUpdate: (props: SuggestionProps<TItem, TSelected>) => {
renderer?.updateProps(getProps(props));
if (popup) updatePosition(popup, props.clientRect);
if (popup) {
trackPosition(popup, props.clientRect);
updatePosition(popup, props.clientRect);
}
},
onKeyDown: (props: SuggestionKeyDownProps) => {

View File

@@ -34,6 +34,7 @@ import { useWorkspacePaths, useWorkspaceSlug } from "@multica/core/paths";
import type { Attachment } from "@multica/core/types";
import { useNavigation } from "../navigation";
import { IssueMentionCard } from "../issues/components/issue-mention-card";
import { ProjectChip } from "../projects/components/project-chip";
import { useLinkHover, LinkHoverCard } from "./link-hover-card";
import { openLink, isMentionHref } from "./utils/link-handler";
import { isAllowedFileCardHref } from "@multica/ui/markdown";
@@ -131,6 +132,30 @@ function IssueMentionLink({ issueId, label }: { issueId: string; label?: string
);
}
function ProjectMentionLink({ projectId, label }: { projectId: string; label?: string }) {
const { push, openInNewTab } = useNavigation();
const p = useWorkspacePaths();
const path = p.projectDetail(projectId);
return (
<span
className="inline align-middle"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
if (e.metaKey || e.ctrlKey || e.shiftKey) {
if (openInNewTab) {
openInNewTab(path, label);
}
return;
}
push(path);
}}
>
<ProjectChip projectId={projectId} fallbackLabel={label} className="cursor-pointer hover:bg-accent transition-colors" />
</span>
);
}
// Named component so it can call useWorkspaceSlug() — arrow function inlined
// inside `components` below would still work, but extracting it keeps the
// hook usage explicit and avoids hook-in-object-literal surprises.
@@ -148,7 +173,7 @@ function ReadonlyLink({
}
if (isMentionHref(href)) {
const match = href.match(/^mention:\/\/(member|agent|issue|all)\/(.+)$/);
const match = href.match(/^mention:\/\/(member|agent|issue|project|all)\/(.+)$/);
if (match?.[1] === "issue" && match[2]) {
const label =
typeof children === "string"
@@ -158,6 +183,15 @@ function ReadonlyLink({
: undefined;
return <IssueMentionLink issueId={match[2]} label={label} />;
}
if (match?.[1] === "project" && match[2]) {
const label =
typeof children === "string"
? children
: Array.isArray(children)
? children.join("")
: undefined;
return <ProjectMentionLink projectId={match[2]} label={label} />;
}
// Member / agent / all mentions
return <span className="mention">{children}</span>;
}

View File

@@ -811,7 +811,13 @@ export function IssueDetail({ issueId, onDelete, onDone, defaultSidebarOpen = tr
useEffect(() => {
if (issue) {
recordVisit(wsId, issue.id);
recordRecentContext(wsId, { type: "issue", id: issue.id });
recordRecentContext(wsId, {
type: "issue",
id: issue.id,
label: issue.identifier,
subtitle: issue.title,
status: issue.status,
});
}
}, [issue?.id, wsId]); // eslint-disable-line react-hooks/exhaustive-deps

View File

@@ -8,8 +8,8 @@
"input": {
"placeholder_no_agent": "Create an agent to start chatting",
"placeholder_archived": "This session is archived",
"placeholder_named": "Tell {{name}} what to do…",
"placeholder_default": "Tell me what to do…",
"placeholder_named": "Message {{name}}…",
"placeholder_default": "Start a message…",
"send_tooltip": "Send",
"stop_tooltip": "Stop"
},
@@ -95,21 +95,6 @@
"summarize_today": "Summarize what I did today",
"plan_next": "Plan what to work on next"
},
"context_anchor": {
"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": {
"fallback_name": "the agent",

View File

@@ -56,7 +56,10 @@
"group_issues": "Issues",
"all_members": "All members",
"searching": "Searching...",
"no_results": "No results"
"no_results": "No results",
"group_current": "Current page",
"group_recent": "Recently viewed",
"group_search": "Search results"
},
"slash_command": {
"no_skills_configured": "No skills configured",

View File

@@ -7,8 +7,8 @@
"input": {
"placeholder_no_agent": "チャットを始めるにはエージェントを作成してください",
"placeholder_archived": "このセッションはアーカイブされています",
"placeholder_named": "{{name}} に指示してください…",
"placeholder_default": "やってほしいことを入力してください…",
"placeholder_named": "{{name}} にメッセージ…",
"placeholder_default": "メッセージを入力…",
"send_tooltip": "送信",
"stop_tooltip": "停止"
},
@@ -92,21 +92,6 @@
"summarize_today": "今日やったことを要約して",
"plan_next": "次に取り組むことを計画して"
},
"context_anchor": {
"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": {
"fallback_name": "エージェント",

View File

@@ -56,7 +56,10 @@
"group_issues": "イシュー",
"all_members": "すべてのメンバー",
"searching": "検索中...",
"no_results": "結果なし"
"no_results": "結果なし",
"group_current": "現在のページ",
"group_recent": "最近見た項目",
"group_search": "検索結果"
},
"code_block": {
"copy_code": "コードをコピー",

View File

@@ -8,8 +8,8 @@
"input": {
"placeholder_no_agent": "채팅을 시작하려면 에이전트를 만드세요",
"placeholder_archived": "보관된 세션입니다",
"placeholder_named": "{{name}}에게 할 일을 알려주세요...",
"placeholder_default": "할 일을 알려주세요...",
"placeholder_named": "{{name}}에게 메시지 보내기…",
"placeholder_default": "메시지 입력…",
"send_tooltip": "보내기",
"stop_tooltip": "중지"
},
@@ -95,21 +95,6 @@
"summarize_today": "오늘 내가 한 일을 요약해 줘",
"plan_next": "다음에 할 일을 계획해 줘"
},
"context_anchor": {
"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": {
"fallback_name": "에이전트",

View File

@@ -56,7 +56,10 @@
"group_issues": "이슈",
"all_members": "모든 멤버",
"searching": "검색 중...",
"no_results": "결과 없음"
"no_results": "결과 없음",
"group_current": "현재 페이지",
"group_recent": "최근 본 항목",
"group_search": "검색 결과"
},
"code_block": {
"copy_code": "코드 복사",

View File

@@ -7,8 +7,8 @@
"input": {
"placeholder_no_agent": "创建一个智能体后才能开始对话",
"placeholder_archived": "此会话已归档",
"placeholder_named": "告诉 {{name}} 该做什么...",
"placeholder_default": "告诉我该做什么...",
"placeholder_named": " {{name}} 发消息…",
"placeholder_default": "输入消息…",
"send_tooltip": "发送",
"stop_tooltip": "停止"
},
@@ -92,21 +92,6 @@
"summarize_today": "总结一下我今天做了什么",
"plan_next": "规划接下来该做什么"
},
"context_anchor": {
"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": {
"fallback_name": "智能体",

View File

@@ -56,7 +56,10 @@
"group_issues": "Issues",
"all_members": "所有成员",
"searching": "搜索中...",
"no_results": "无结果"
"no_results": "无结果",
"group_current": "当前页面",
"group_recent": "最近浏览",
"group_search": "搜索结果"
},
"slash_command": {
"no_skills_configured": "暂无配置的技能",

View File

@@ -399,8 +399,17 @@ export function ProjectDetail({ projectId }: { projectId: string }) {
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
if (project) {
recordRecentContext(wsId, {
type: "project",
id: project.id,
label: project.title,
subtitle: project.description ?? undefined,
icon: project.icon,
projectStatus: project.status,
});
}
}, [project?.id, project?.title, project?.description, project?.icon, project?.status, recordRecentContext, wsId]);
const projectScope = `project:${projectId}`;
const projectFilter = useMemo<MyIssuesFilter>(
() => ({ project_id: projectId }),

View File

@@ -0,0 +1,47 @@
"use client";
import { useMemo } from "react";
export function HighlightText({ text, query }: { text: string; query: string }) {
const parts = useMemo(() => {
if (!query.trim()) return [{ text, highlight: false }];
const terms = query.trim().split(/\s+/).filter(Boolean);
const escaped = query.trim().replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const patterns: string[] = [escaped];
if (terms.length > 1) {
for (const term of terms) {
const e = term.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
if (e && !patterns.includes(e)) patterns.push(e);
}
}
const regex = new RegExp(`(${patterns.join("|")})`, "gi");
const result: { text: string; highlight: boolean }[] = [];
let lastIndex = 0;
let match: RegExpExecArray | null;
while ((match = regex.exec(text)) !== null) {
if (match.index > lastIndex) {
result.push({ text: text.slice(lastIndex, match.index), highlight: false });
}
result.push({ text: match[0], highlight: true });
lastIndex = regex.lastIndex;
}
if (lastIndex < text.length) {
result.push({ text: text.slice(lastIndex), highlight: false });
}
return result.length > 0 ? result : [{ text, highlight: false }];
}, [text, query]);
return (
<>
{parts.map((part, i) =>
part.highlight ? (
<mark key={i} className="bg-yellow-200 dark:bg-yellow-900/60 text-inherit rounded-sm">
{part.text}
</mark>
) : (
part.text
),
)}
</>
);
}

View File

@@ -61,53 +61,9 @@ import { useTheme } from "@multica/ui/components/common/theme-provider";
import { useNavigation } from "../navigation";
import { useT } from "../i18n";
import { matchesPinyin } from "../editor/extensions/pinyin-match";
import { HighlightText } from "./highlight-text";
import { useSearchStore } from "./search-store";
function HighlightText({ text, query }: { text: string; query: string }) {
const parts = useMemo(() => {
if (!query.trim()) return [{ text, highlight: false }];
// Build regex that matches the full phrase OR individual terms
const terms = query.trim().split(/\s+/).filter(Boolean);
const escaped = query.trim().replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const patterns: string[] = [escaped];
if (terms.length > 1) {
for (const term of terms) {
const e = term.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
if (e && !patterns.includes(e)) patterns.push(e);
}
}
const regex = new RegExp(`(${patterns.join("|")})`, "gi");
const result: { text: string; highlight: boolean }[] = [];
let lastIndex = 0;
let match: RegExpExecArray | null;
while ((match = regex.exec(text)) !== null) {
if (match.index > lastIndex) {
result.push({ text: text.slice(lastIndex, match.index), highlight: false });
}
result.push({ text: match[0], highlight: true });
lastIndex = regex.lastIndex;
}
if (lastIndex < text.length) {
result.push({ text: text.slice(lastIndex), highlight: false });
}
return result.length > 0 ? result : [{ text, highlight: false }];
}, [text, query]);
return (
<>
{parts.map((part, i) =>
part.highlight ? (
<mark key={i} className="bg-yellow-200 dark:bg-yellow-900/60 text-inherit rounded-sm">
{part.text}
</mark>
) : (
part.text
),
)}
</>
);
}
// Nav items reference WorkspacePaths method names so they can be resolved
// against the current workspace slug at render time (see SearchCommand body).
// Only parameterless paths are valid nav destinations.