mirror of
https://github.com/multica-ai/multica.git
synced 2026-06-27 01:19:26 +02:00
Compare commits
1 Commits
agent/lamb
...
fix/chat-c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f575677514 |
@@ -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";
|
||||
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) });
|
||||
},
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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"');
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
117
packages/views/chat/components/use-chat-context-items.ts
Normal file
117
packages/views/chat/components/use-chat-context-items.ts
Normal 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]);
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 }) => {
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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, "\\]");
|
||||
|
||||
@@ -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" }],
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "エージェント",
|
||||
|
||||
@@ -56,7 +56,10 @@
|
||||
"group_issues": "イシュー",
|
||||
"all_members": "すべてのメンバー",
|
||||
"searching": "検索中...",
|
||||
"no_results": "結果なし"
|
||||
"no_results": "結果なし",
|
||||
"group_current": "現在のページ",
|
||||
"group_recent": "最近見た項目",
|
||||
"group_search": "検索結果"
|
||||
},
|
||||
"code_block": {
|
||||
"copy_code": "コードをコピー",
|
||||
|
||||
@@ -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": "에이전트",
|
||||
|
||||
@@ -56,7 +56,10 @@
|
||||
"group_issues": "이슈",
|
||||
"all_members": "모든 멤버",
|
||||
"searching": "검색 중...",
|
||||
"no_results": "결과 없음"
|
||||
"no_results": "결과 없음",
|
||||
"group_current": "현재 페이지",
|
||||
"group_recent": "최근 본 항목",
|
||||
"group_search": "검색 결과"
|
||||
},
|
||||
"code_block": {
|
||||
"copy_code": "코드 복사",
|
||||
|
||||
@@ -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": "智能体",
|
||||
|
||||
@@ -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": "暂无配置的技能",
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
47
packages/views/search/highlight-text.tsx
Normal file
47
packages/views/search/highlight-text.tsx
Normal 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
|
||||
),
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user