mirror of
https://github.com/multica-ai/multica.git
synced 2026-06-21 14:44:30 +02:00
Compare commits
1 Commits
fix/descri
...
fix/table-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
43d5eaaf40 |
@@ -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  regression)", () => {
|
||||
renderWithQuery(
|
||||
<Attachment
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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",
|
||||
)}
|
||||
/>
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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": "クリックすると今回はトリガーされません。",
|
||||
|
||||
@@ -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": "클릭하면 이번에는 트리거되지 않습니다.",
|
||||
|
||||
@@ -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": "点击后本次不触发。",
|
||||
|
||||
Reference in New Issue
Block a user