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
10 changed files with 44 additions and 134 deletions

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

@@ -180,7 +180,12 @@ export function createEditorExtensions(
// markdownPaste's handlePaste is a catch-all that returns true.
LinkExtension,
ImageExtension,
Table.configure({ resizable: false }),
// renderWrapper wraps the table in `<div class="tableWrapper">` (the same
// wrapper the resizable NodeView emits), which prose.css styles with
// `overflow-x: auto`. Without it a wide table is a bare <table> that can't
// shrink below min-content, so the horizontal scrollbar lands on the
// page-level scroll container instead of the table itself.
Table.configure({ resizable: false, renderWrapper: true }),
TableRow,
TableHeader,
TableCell,

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,11 +62,11 @@ describe("CommentTriggerChips", () => {
);
const chip = screen.getByRole("button");
expect(chip).toHaveTextContent("not this time");
expect(chip).toHaveTextContent("Walt won't be triggered this time");
expect(chip).toHaveAttribute("aria-pressed", "true");
});
it("collapses several agents into a stack with the shared sentence", () => {
it("collapses several agents into a stack with an active count", () => {
renderWithI18n(
<CommentTriggerChips
agents={[walt, bob]}
@@ -75,19 +75,19 @@ describe("CommentTriggerChips", () => {
/>,
);
expect(screen.getByRole("button")).toHaveTextContent("Starts working when sent");
expect(screen.getByRole("button")).toHaveTextContent("2 agents will start working");
});
it("switches to the skip state when every agent is suppressed", () => {
it("counts only non-suppressed agents in the sentence", () => {
renderWithI18n(
<CommentTriggerChips
agents={[walt, bob]}
suppressedAgentIds={new Set(["agent-1", "agent-2"])}
suppressedAgentIds={new Set(["agent-2"])}
onToggle={vi.fn()}
/>,
);
expect(screen.getByRole("button")).toHaveTextContent("not this time");
expect(screen.getByRole("button")).toHaveTextContent("1 agent will start working");
});
it("opens the popover on click and toggles a row", () => {

View File

@@ -148,11 +148,9 @@ function SingleTriggerChip({
t: IssuesT;
}) {
const state = suppressed ? t(($) => $.comment.trigger_suppressed) : 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 sentence = suppressed
? t(($) => $.comment.trigger_suppressed)
: t(($) => $.comment.trigger_will_start);
? t(($) => $.comment.trigger_will_skip, { name: agent.name })
: t(($) => $.comment.trigger_will_start, { name: agent.name });
return (
<Tooltip>
@@ -164,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} />
@@ -201,12 +197,10 @@ 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 already shows who and how many — the sentence is the
// same fixed condition + outcome copy as the single chip.
const sentence =
activeCount === 0
? t(($) => $.comment.trigger_suppressed)
: t(($) => $.comment.trigger_will_start);
: t(($) => $.comment.trigger_will_start_count, { count: activeCount });
const popoverTrigger = (
<PopoverTrigger
@@ -216,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",
)}
/>
}

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

@@ -284,7 +284,10 @@
"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": "{{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

@@ -276,7 +276,9 @@
"trigger_reason_mention_agent": "このコメントで {{name}} がメンションされています。",
"trigger_reason_mention_squad_leader": "{{name}} はメンションされた Squad のリーダーです。",
"trigger_reason_unknown": "このコメントで {{name}} がトリガーされます。",
"trigger_will_start": "送信後に作業を開始します",
"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

@@ -284,7 +284,9 @@
"trigger_reason_mention_agent": "이 댓글에서 {{name}}님을 멘션했습니다.",
"trigger_reason_mention_squad_leader": "{{name}}님은 멘션된 Squad의 리더입니다.",
"trigger_reason_unknown": "이 댓글로 {{name}}님이 트리거됩니다.",
"trigger_will_start": "전송 후 작업을 시작합니다",
"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

@@ -281,7 +281,9 @@
"trigger_reason_mention_agent": "这条评论 @ 了 {{name}}。",
"trigger_reason_mention_squad_leader": "{{name}} 是被 @ Squad 的 leader。",
"trigger_reason_unknown": "这条评论会触发 {{name}}。",
"trigger_will_start": "发送后开始工作",
"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": "点击后本次不触发。",