Compare commits

..

1 Commits

Author SHA1 Message Date
Naiyuan Qing
43d5eaaf40 fix(editor): wrap tables in tableWrapper so wide tables scroll locally
Table.configure had renderWrapper unset (defaults to false), so tables
rendered as bare <table> elements with no .tableWrapper div. The
overflow-x: auto rule in prose.css targets .tableWrapper and never
matched, so a wide table pushed the horizontal scrollbar onto the
issue detail's page-level scroll container instead of scrolling
within the table itself.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 17:12:09 +08:00
13 changed files with 48 additions and 197 deletions

View File

@@ -73,13 +73,9 @@ export const issueKeys = {
/** Full-issue timeline (single TanStack Query, no cursor). */
timeline: (issueId: string) =>
[...issueKeys.timelineAll(), issueId] as const,
/** Prefix across all issues — WS task lifecycle events invalidate here so
* an open composer's trigger preview refreshes when an agent's queue
* state changes (the dedup guard makes the answer queue-dependent). */
commentTriggerPreviewAll: () => ["issues", "comment-trigger-preview"] as const,
/** PREFIX for invalidation — the composer hook appends parent + content signature. */
commentTriggerPreview: (issueId: string) =>
[...issueKeys.commentTriggerPreviewAll(), issueId] as const,
["issues", "comment-trigger-preview", issueId] as const,
reactionsAll: () => ["issues", "reactions"] as const,
reactions: (issueId: string) =>
[...issueKeys.reactionsAll(), issueId] as const,

View File

@@ -504,12 +504,6 @@ export function useRealtimeSync(
// Squad members-status reads the same task lifecycle to flip
// working ↔ idle for each agent member.
invalidateSquadMemberStatusQueries(qc, wsId);
// Comment trigger previews answer "who would a send wake right
// now" — the pending-task dedup guard makes that answer
// queue-dependent, so any task lifecycle change must refresh an
// open composer's chips (e.g. an agent finishing its run becomes
// triggerable again mid-typing).
qc.invalidateQueries({ queryKey: issueKeys.commentTriggerPreviewAll() });
},
};

View File

@@ -89,31 +89,12 @@ vi.mock("@multica/core/paths", async (importOriginal) => {
// Resolver mock — feeds the test-scoped attachments[] into the
// useAttachmentDownloadResolver hook the component reads.
const resolverState: { attachments: AttachmentRecord[] } = { attachments: [] };
function attachmentIdFromTestDownloadURL(url: string): string | undefined {
const path = /^https?:\/\//i.test(url)
? (() => {
try {
return new URL(url).pathname;
} catch {
return "";
}
})()
: url.split(/[?#]/, 1)[0] ?? "";
const match = path.match(/^\/api\/attachments\/([^/]+)\/download$/);
return match?.[1];
}
vi.mock("./attachment-download-context", () => ({
useAttachmentDownloadResolver: () => ({
resolveAttachmentId: (url: string) =>
resolverState.attachments.find((a) => {
const id = attachmentIdFromTestDownloadURL(url);
return a.url === url || (id !== undefined && a.id === id);
})?.id,
resolverState.attachments.find((a) => a.url === url)?.id,
resolveAttachment: (url: string) =>
resolverState.attachments.find((a) => {
const id = attachmentIdFromTestDownloadURL(url);
return a.url === url || (id !== undefined && a.id === id);
}),
resolverState.attachments.find((a) => a.url === url),
openByUrl: openByUrlMock,
}),
AttachmentDownloadProvider: ({ children }: { children: ReactNode }) =>
@@ -121,7 +102,6 @@ vi.mock("./attachment-download-context", () => ({
}));
import { Attachment } from "./attachment";
import { configStore } from "@multica/core/config";
function makeRecord(overrides: Partial<AttachmentRecord> = {}): AttachmentRecord {
return {
@@ -154,7 +134,6 @@ function renderWithQuery(ui: ReactElement) {
beforeEach(() => {
vi.clearAllMocks();
resolverState.attachments = [];
configStore.setState({ cdnDomain: "" });
// Default to "no proxy override" — site-relative URLs stay as-is, mirroring
// the web app's same-origin proxy. Tests that simulate Desktop / mobile
// webview override per-case via getBaseUrlMock.mockReturnValue(...).
@@ -226,39 +205,6 @@ describe("Attachment — image dispatch", () => {
expect(downloadMock).toHaveBeenCalledWith("att-1");
});
it("renders the configured CDN URL when description markdown stores the stable API URL", () => {
configStore.setState({ cdnDomain: "cdn.example.test" });
const id = "11111111-2222-3333-4444-555555555555";
const markdownUrl = `https://multica-api.copilothub.ai/api/attachments/${id}/download`;
const att = makeRecord({
id,
url: "https://cdn.example.test/uploads/ws/shot.png",
// This is the shape persisted in issue descriptions on deployments
// that keep markdown stable via the API endpoint. Once the URL
// resolves to an attachment record, the rendered <img> must expose the
// CDN URL instead of copying the API endpoint back to the user.
markdown_url: markdownUrl,
download_url: `/api/attachments/${id}/download`,
});
resolverState.attachments = [att];
renderWithQuery(
<Attachment
attachment={{
kind: "url",
url: markdownUrl,
filename: "shot.png",
forceKind: "image",
}}
/>,
);
const img = document.querySelector("img");
expect(img?.getAttribute("src")).toBe(
"https://cdn.example.test/uploads/ws/shot.png",
);
});
it("forceKind=image renders as image even when filename is empty (markdown ![](url) regression)", () => {
renderWithQuery(
<Attachment

View File

@@ -33,7 +33,6 @@ import { toast } from "sonner";
import { cn } from "@multica/ui/lib/utils";
import { copyText } from "@multica/ui/lib/clipboard";
import { api } from "@multica/core/api";
import { useConfigStore } from "@multica/core/config";
import type { Attachment as AttachmentRecord } from "@multica/core/types";
import { useT } from "../i18n";
import { useAttachmentDownloadResolver } from "./attachment-download-context";
@@ -107,14 +106,13 @@ interface Normalized {
function normalize(
input: AttachmentInput,
resolve: (url: string) => AttachmentRecord | undefined,
cdnDomain: string,
): Normalized {
if (input.kind === "record") {
return {
filename: input.attachment.filename,
contentType: input.attachment.content_type,
url: absolutizeMediaURL(
pickInlineMediaURL(input.attachment, input.attachment.url, cdnDomain),
pickInlineMediaURL(input.attachment, input.attachment.url),
),
attachmentId: input.attachment.id,
record: input.attachment,
@@ -147,7 +145,7 @@ function normalize(
// uploaded image URL stayed site-relative and Electron's renderer
// origin (file://) couldn't load it.
url: absolutizeMediaURL(
record ? pickInlineMediaURL(record, input.url, cdnDomain) : input.url,
record ? pickInlineMediaURL(record, input.url) : input.url,
),
attachmentId: record?.id,
record,
@@ -225,23 +223,13 @@ function absolutizeMediaURL(rawUrl: string): string {
// beats `markdown_url` on first paint (no extra hop through the
// API endpoint), and the renderer doesn't persist it so the TTL is
// not a problem.
// 2. Known CDN `record.url` — when `/api/config` exposes the same CDN
// host as the attachment record, the browser can load the object
// directly (public CDN, or CloudFront cookie mode). Prefer it over
// an API-shaped `markdown_url` so the rendered `<img src>` and Copy
// Link affordance expose the CDN URL while the persisted markdown
// can remain the stable attachment endpoint.
// 3. `record.markdown_url` — the durable, server-policy-aligned URL.
// 2. `record.markdown_url` — the durable, server-policy-aligned URL.
// Beats raw `record.url` because it never points at a private
// bucket (must-fix 2 from MUL-3192 review).
// 4. `record.url` — legacy fallback for responses that omit
// 3. `record.url` — legacy fallback for responses that omit
// `markdown_url` (a backend old enough to predate MUL-3192).
// 5. The input URL — when there's no record at all.
function pickInlineMediaURL(
record: AttachmentRecord,
fallback: string,
cdnDomain: string,
): string {
// 4. The input URL — when there's no record at all.
function pickInlineMediaURL(record: AttachmentRecord, fallback: string): string {
const dl = record.download_url ?? "";
if (
/^https?:\/\//i.test(dl) &&
@@ -249,42 +237,11 @@ function pickInlineMediaURL(
) {
return dl;
}
if (storageURLMatchesCdnDomain(record.url, cdnDomain)) return record.url;
if (record.markdown_url) return record.markdown_url;
if (record.url) return record.url;
return fallback;
}
function storageURLMatchesCdnDomain(rawURL: string, cdnDomain: string): boolean {
const expected = normalizeHost(cdnDomain);
if (!rawURL || !expected) return false;
try {
const u = new URL(rawURL);
if (u.protocol !== "http:" && u.protocol !== "https:") return false;
if (normalizeHost(u.hostname) !== expected) return false;
return !hasExpiringSignatureQuery(u.searchParams);
} catch {
return false;
}
}
function normalizeHost(host: string): string {
return host.trim().toLowerCase().replace(/\.$/, "");
}
function hasExpiringSignatureQuery(q: URLSearchParams): boolean {
for (const key of [
"Signature",
"X-Amz-Signature",
"Key-Pair-Id",
"Expires",
"X-Amz-Expires",
]) {
if (q.has(key)) return true;
}
return false;
}
// ---------------------------------------------------------------------------
// Dispatcher
// ---------------------------------------------------------------------------
@@ -297,11 +254,10 @@ export function Attachment({
className,
}: AttachmentProps) {
const { resolveAttachment, openByUrl } = useAttachmentDownloadResolver();
const cdnDomain = useConfigStore((s) => s.cdnDomain);
const download = useDownloadAttachment();
const preview = useAttachmentPreview();
const state = normalize(attachment, resolveAttachment, cdnDomain);
const state = normalize(attachment, resolveAttachment);
const forceKind =
attachment.kind === "url" ? attachment.forceKind : undefined;
const kind =

View File

@@ -45,14 +45,14 @@ describe("CommentTriggerChips", () => {
);
const chip = screen.getByRole("button");
expect(chip).toHaveTextContent("Starts working when sent");
expect(chip).toHaveTextContent("Walt will start working");
expect(chip).toHaveAttribute("aria-pressed", "false");
fireEvent.click(chip);
expect(onToggle).toHaveBeenCalledWith("agent-1");
});
it("dims a suppressed single agent into the skip state", () => {
it("dims a suppressed single agent into the skip sentence", () => {
renderWithI18n(
<CommentTriggerChips
agents={[walt]}
@@ -62,7 +62,7 @@ describe("CommentTriggerChips", () => {
);
const chip = screen.getByRole("button");
expect(chip).toHaveTextContent("Won't be triggered");
expect(chip).toHaveTextContent("Walt won't be triggered this time");
expect(chip).toHaveAttribute("aria-pressed", "true");
});
@@ -75,7 +75,7 @@ describe("CommentTriggerChips", () => {
/>,
);
expect(screen.getByRole("button")).toHaveTextContent("2 agents start working when sent");
expect(screen.getByRole("button")).toHaveTextContent("2 agents will start working");
});
it("counts only non-suppressed agents in the sentence", () => {
@@ -87,19 +87,7 @@ describe("CommentTriggerChips", () => {
/>,
);
expect(screen.getByRole("button")).toHaveTextContent("1 agent starts working when sent");
});
it("switches to the none-will-trigger state when every agent is suppressed", () => {
renderWithI18n(
<CommentTriggerChips
agents={[walt, bob]}
suppressedAgentIds={new Set(["agent-1", "agent-2"])}
onToggle={vi.fn()}
/>,
);
expect(screen.getByRole("button")).toHaveTextContent("No agents will be triggered");
expect(screen.getByRole("button")).toHaveTextContent("1 agent will start working");
});
it("opens the popover on click and toggles a row", () => {

View File

@@ -147,14 +147,10 @@ function SingleTriggerChip({
onToggle: (agentId: string) => void;
t: IssuesT;
}) {
const state = suppressed
? t(($) => $.comment.trigger_skipped_label)
: sourceLabel(agent.source, t);
// The avatar carries "who"; the sentence carries only condition + outcome,
// so it stays fixed-width and never truncates on long agent names.
const state = suppressed ? t(($) => $.comment.trigger_suppressed) : sourceLabel(agent.source, t);
const sentence = suppressed
? t(($) => $.comment.trigger_wont_trigger)
: t(($) => $.comment.trigger_will_start);
? t(($) => $.comment.trigger_will_skip, { name: agent.name })
: t(($) => $.comment.trigger_will_start, { name: agent.name });
return (
<Tooltip>
@@ -166,10 +162,8 @@ function SingleTriggerChip({
aria-label={t(($) => $.comment.trigger_chip_aria, { name: agent.name, state })}
onClick={() => onToggle(agent.id)}
className={cn(
// Sidebar-style resting state: muted until hover so the strip
// reads as metadata, not content (see app-sidebar nav items).
"inline-flex h-6 min-w-0 max-w-full animate-in fade-in cursor-pointer items-center gap-1.5 rounded-md px-1.5 text-[11px] font-medium text-muted-foreground transition-colors duration-200 hover:bg-muted hover:text-foreground",
suppressed && "opacity-60",
"inline-flex h-6 min-w-0 max-w-full animate-in fade-in slide-in-from-bottom-1 cursor-pointer items-center gap-1.5 rounded-md px-1.5 text-[11px] font-medium transition-colors duration-200 hover:bg-muted hover:text-foreground",
suppressed ? "text-muted-foreground opacity-60" : "text-foreground",
)}
>
<TriggerAgentAvatar agent={agent} suppressed={suppressed} />
@@ -203,12 +197,9 @@ function MultiTriggerChip({
// Mirror AgentAvatarStack: ~30% overlap reads as "stacked" without
// obscuring the next avatar.
const overlap = Math.round(AVATAR_SIZE * 0.3);
// The avatar stack shows who; the sentence promises only what WILL happen,
// so the count covers non-suppressed agents — skipped ones read as the
// dimmed heads right next to the number.
const sentence =
activeCount === 0
? t(($) => $.comment.trigger_none_will_trigger)
? t(($) => $.comment.trigger_suppressed)
: t(($) => $.comment.trigger_will_start_count, { count: activeCount });
const popoverTrigger = (
@@ -219,8 +210,8 @@ function MultiTriggerChip({
<button
type="button"
className={cn(
"inline-flex h-6 min-w-0 max-w-full animate-in fade-in cursor-pointer items-center gap-1.5 rounded-md px-1.5 text-[11px] font-medium text-muted-foreground transition-colors duration-200 hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground",
activeCount === 0 && "opacity-60",
"inline-flex h-6 min-w-0 max-w-full animate-in fade-in slide-in-from-bottom-1 cursor-pointer items-center gap-1.5 rounded-md px-1.5 text-[11px] font-medium transition-colors duration-200 hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground",
activeCount === 0 ? "text-muted-foreground opacity-60" : "text-foreground",
)}
/>
}
@@ -273,7 +264,7 @@ function MultiTriggerChip({
{agents.map((agent) => {
const suppressed = suppressedAgentIds.has(agent.id);
const state = suppressed
? t(($) => $.comment.trigger_skipped_label)
? t(($) => $.comment.trigger_suppressed)
: sourceLabel(agent.source, t);
return (
<Tooltip key={agent.id}>

View File

@@ -155,7 +155,7 @@ function ReplyInput({
{...dropZoneProps}
className={cn(
"relative min-w-0 flex-1 flex flex-col",
!isEmpty && "pb-9",
!isEmpty && "pb-7",
)}
>
<div className="flex-1 min-h-0 overflow-y-auto">

View File

@@ -69,14 +69,10 @@ describe("useCommentTriggerPreview", () => {
);
});
it("revalidates when the debounced signature repeats — the answer is queue-state dependent", async () => {
it("uses the TanStack Query cache when the debounced signature repeats", async () => {
const agentA = "00000000-0000-0000-0000-000000000001";
const content = `[@A](mention://agent/${agentA})`;
const agents = [
{ id: agentA, name: "A", source: "mention_agent", reason: "" },
];
previewCommentTriggers.mockResolvedValue({ agents });
const { result, rerender } = renderHook(
const { rerender } = renderHook(
({ content }) => useCommentTriggerPreview({ issueId: "issue-1", content }),
{
wrapper: createWrapper(),
@@ -89,13 +85,9 @@ describe("useCommentTriggerPreview", () => {
rerender({ content: "" });
rerender({ content });
// Cached agents render immediately for the repeated signature (no
// flicker)…
await advancePreviewDebounce();
expect(result.current.agents).toEqual(agents);
// …but a background revalidation still fires: an agent finishing its
// queued task changes the answer for the very same mention set.
expect(previewCommentTriggers).toHaveBeenCalledTimes(2);
expect(previewCommentTriggers).toHaveBeenCalledTimes(1);
});
it("fetches again when routing mention tokens change", async () => {

View File

@@ -1,7 +1,7 @@
"use client";
import { useEffect, useMemo, useRef, useState } from "react";
import { keepPreviousData, useQuery } from "@tanstack/react-query";
import { useQuery } from "@tanstack/react-query";
import { api } from "@multica/core/api";
import { issueKeys } from "@multica/core/issues/queries";
import type { CommentTriggerPreviewAgent } from "@multica/core/types";
@@ -77,15 +77,7 @@ export function useCommentTriggerPreview({
queryFn: () => api.previewCommentTriggers(issueId, contentRef.current, parentId),
enabled: signature !== "empty" && debouncedSignature !== "empty",
retry: false,
// The answer depends on live queue state (pending-task dedup), not just
// the mention set, so a cached result must revalidate when its signature
// reappears — Infinity here once pinned a stale "nobody triggers"
// snapshot taken while the agent was still queued.
staleTime: 0,
// Keep the previous agent list while a new signature is fetching:
// without it the in-flight gap renders as "no agents", flickering the
// chips and wiping the composer's suppressed-id set.
placeholderData: keepPreviousData,
staleTime: Infinity,
});
// Loading and errors intentionally surface as "no agents": the preview is

View File

@@ -279,16 +279,15 @@
"trigger_source_mention_agent": "@mention",
"trigger_source_mention_squad_leader": "squad",
"trigger_source_unknown": "trigger",
"trigger_skipped_label": "Skipped",
"trigger_wont_trigger": "Won't be triggered",
"trigger_none_will_trigger": "No agents will be triggered",
"trigger_suppressed": "not this time",
"trigger_reason_issue_assignee": "{{name}} is assigned here and will be notified by this comment.",
"trigger_reason_mention_agent": "{{name}} is mentioned in this comment.",
"trigger_reason_mention_squad_leader": "{{name}} leads a squad mentioned in this comment.",
"trigger_reason_unknown": "{{name}} will be triggered by this comment.",
"trigger_will_start": "Starts working when sent",
"trigger_will_start_count_one": "{{count}} agent starts working when sent",
"trigger_will_start_count_other": "{{count}} agents start working when sent",
"trigger_will_start": "{{name}} will start working",
"trigger_will_skip": "{{name}} won't be triggered this time",
"trigger_will_start_count_one": "{{count}} agent will start working",
"trigger_will_start_count_other": "{{count}} agents will start working",
"trigger_starts_now": "It will start working right away.",
"trigger_starts_when_online": "It is offline now and will start once online.",
"trigger_click_to_skip": "Click to skip triggering this time.",

View File

@@ -271,15 +271,14 @@
"trigger_source_mention_agent": "@メンション",
"trigger_source_mention_squad_leader": "Squad",
"trigger_source_unknown": "トリガー",
"trigger_skipped_label": "スキップ",
"trigger_wont_trigger": "トリガーされません",
"trigger_none_will_trigger": "いずれもトリガーされません",
"trigger_suppressed": "今回は実行しない",
"trigger_reason_issue_assignee": "{{name}} はこのイシューの担当で、このコメントで通知されます。",
"trigger_reason_mention_agent": "このコメントで {{name}} がメンションされています。",
"trigger_reason_mention_squad_leader": "{{name}} はメンションされた Squad のリーダーです。",
"trigger_reason_unknown": "このコメントで {{name}} がトリガーされます。",
"trigger_will_start": "送信後に作業を開始します",
"trigger_will_start_count_other": "送信後に {{count}} 体のエージェントが作業を開始します",
"trigger_will_start": "{{name}} が作業を開始します",
"trigger_will_skip": "{{name}} は今回トリガーされません",
"trigger_will_start_count_other": "{{count}} 体のエージェントが作業を開始します",
"trigger_starts_now": "すぐに作業を開始します。",
"trigger_starts_when_online": "現在オフラインのため、オンラインになり次第開始します。",
"trigger_click_to_skip": "クリックすると今回はトリガーされません。",

View File

@@ -279,15 +279,14 @@
"trigger_source_mention_agent": "@멘션",
"trigger_source_mention_squad_leader": "Squad",
"trigger_source_unknown": "트리거",
"trigger_skipped_label": "건너뜀",
"trigger_wont_trigger": "트리거되지 않습니다",
"trigger_none_will_trigger": "모두 트리거되지 않습니다",
"trigger_suppressed": "이번에는 실행 안 함",
"trigger_reason_issue_assignee": "{{name}}님은 이 이슈의 담당자이며 이 댓글로 알림을 받습니다.",
"trigger_reason_mention_agent": "이 댓글에서 {{name}}님을 멘션했습니다.",
"trigger_reason_mention_squad_leader": "{{name}}님은 멘션된 Squad의 리더입니다.",
"trigger_reason_unknown": "이 댓글로 {{name}}님이 트리거됩니다.",
"trigger_will_start": "전송 후 작업을 시작합니다",
"trigger_will_start_count_other": "전송 후 에이전트 {{count}}개가 작업을 시작합니다",
"trigger_will_start": "{{name}}이(가) 작업을 시작합니다",
"trigger_will_skip": "{{name}}은(는) 이번에 트리거되지 않습니다",
"trigger_will_start_count_other": "에이전트 {{count}}개가 작업을 시작합니다",
"trigger_starts_now": "바로 작업을 시작합니다.",
"trigger_starts_when_online": "현재 오프라인이며 온라인이 되면 시작합니다.",
"trigger_click_to_skip": "클릭하면 이번에는 트리거되지 않습니다.",

View File

@@ -276,15 +276,14 @@
"trigger_source_mention_agent": "@mention",
"trigger_source_mention_squad_leader": "squad",
"trigger_source_unknown": "将触发",
"trigger_skipped_label": "已跳过",
"trigger_wont_trigger": "不会触发",
"trigger_none_will_trigger": "全部不会触发",
"trigger_suppressed": "本次不触发",
"trigger_reason_issue_assignee": "{{name}} 是当前 issue 的 assignee这条评论会唤醒它。",
"trigger_reason_mention_agent": "这条评论 @ 了 {{name}}。",
"trigger_reason_mention_squad_leader": "{{name}} 是被 @ Squad 的 leader。",
"trigger_reason_unknown": "这条评论会触发 {{name}}。",
"trigger_will_start": "发送后开始工作",
"trigger_will_start_count_other": "发送后 {{count}} 个智能体开始工作",
"trigger_will_start": "{{name}} 将开始工作",
"trigger_will_skip": "{{name}} 本次不触发",
"trigger_will_start_count_other": "{{count}} 个智能体将开始工作",
"trigger_starts_now": "将立即开始工作。",
"trigger_starts_when_online": "当前离线,上线后开始。",
"trigger_click_to_skip": "点击后本次不触发。",