Compare commits

..

3 Commits

Author SHA1 Message Date
Lambda
07ba3e440f feat(issues): trim trigger preview popover copy and drop redundant reason lines
The active hover popover stacked header + reason + presence, repeating the
same fact across lines and again against the chip. Tighten it:

- Drop the reason line for assignee / @mention: the header (name · source)
  already conveys why they fire. Keep reason only for squad-leader (the link
  is non-obvious) and the unknown fallback, both trimmed of the duplicated
  name.
- Shorten presence (Starts right away. / Offline now — starts once online.)
  and de-jargon the skip/manage hints (no more 'trigger').
- Align the popover title to the chip wording (Will start when sent).

All four locales updated; removes the two now-unused reason keys.

Refs MUL-3211

Co-authored-by: multica-agent <github@multica.ai>
2026-06-17 14:42:43 +08:00
Lambda
dd148b9b65 feat(issues): align restore hint to will-start phrasing
Carry the trigger-chip copy unification into the suppressed-agent restore
hint (trigger_click_to_restore), the last surface still mixing 'trigger'
with the chip's 'start' wording:

- en: Won't start this time. Click to restore.
- CJK: skip-state term realigned to the 'start' verb, rest unchanged.

Refs MUL-3211

Co-authored-by: multica-agent <github@multica.ai>
2026-06-17 14:41:11 +08:00
Lambda
9e0614612d feat(issues): unify trigger chip copy to will-start phrasing
Make the comment trigger chip's on/off states symmetric around the verb
'start' instead of mixing natural language with the 'trigger' jargon:

- on:           Will start when sent
- skipped:      Won't start this time
- all skipped:  No agents will start
- multi on:     N agents will start when sent

Updates all four locales (en/zh-Hans/ja/ko); CJK on-state copy already
reads as future-conditional so only the skip states are realigned to the
'start' verb. Updates the component test expectations to match.

Refs MUL-3211

Co-authored-by: multica-agent <github@multica.ai>
2026-06-17 14:41:11 +08:00
36 changed files with 217 additions and 1891 deletions

View File

@@ -1,234 +0,0 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { shouldDropException } from "./exception-dedupe";
const STORAGE_KEY = "mc_exc_fp";
// In-memory sessionStorage stand-in. Optional flags let a test force getItem /
// setItem to throw (quota, disabled storage) so we can assert the fail-open
// direction.
function makeStorage(opts: { throwOnGet?: boolean; throwOnSet?: boolean } = {}) {
const data = new Map<string, string>();
return {
data,
getItem(k: string): string | null {
if (opts.throwOnGet) throw new Error("getItem blocked");
return data.has(k) ? data.get(k)! : null;
},
setItem(k: string, v: string): void {
if (opts.throwOnSet) throw new Error("quota exceeded");
data.set(k, v);
},
removeItem(k: string): void {
data.delete(k);
},
clear(): void {
data.clear();
},
key(i: number): string | null {
return Array.from(data.keys())[i] ?? null;
},
get length(): number {
return data.size;
},
};
}
// Build a redacted-shape `$exception` properties object. By the time dedupe
// runs, redactExceptionProperties has already scrubbed value/message.
function exc(o: {
type?: string;
value?: string;
frames?: Array<Record<string, unknown>> | null;
} = {}): Record<string, unknown> {
const entry: Record<string, unknown> = {
type: o.type ?? "TypeError",
value: o.value ?? "boom",
};
if (o.frames !== null) {
entry.stacktrace = {
type: "raw",
frames: o.frames ?? [
{ filename: "app.tsx", function: "render", lineno: 10, colno: 5 },
],
};
}
return { $exception_list: [entry] };
}
afterEach(() => {
vi.unstubAllGlobals();
});
describe("shouldDropException — per-fingerprint limit", () => {
beforeEach(() => {
vi.stubGlobal("sessionStorage", makeStorage());
});
it("keeps the first 3 of a fingerprint and drops from the 4th", () => {
expect(shouldDropException(exc())).toBe(false);
expect(shouldDropException(exc())).toBe(false);
expect(shouldDropException(exc())).toBe(false);
expect(shouldDropException(exc())).toBe(true);
expect(shouldDropException(exc())).toBe(true);
});
it("treats different fingerprints independently — one does not drop the other", () => {
// Exhaust fingerprint A.
const a = () => exc({ type: "TypeError", value: "a" });
const b = () => exc({ type: "RangeError", value: "b" });
shouldDropException(a());
shouldDropException(a());
shouldDropException(a());
expect(shouldDropException(a())).toBe(true); // A fused
// B is untouched.
expect(shouldDropException(b())).toBe(false);
expect(shouldDropException(b())).toBe(false);
expect(shouldDropException(b())).toBe(false);
expect(shouldDropException(b())).toBe(true);
});
it("discriminates on colno (minified bundles collapse statements onto one line)", () => {
const at = (colno: number) =>
exc({ frames: [{ filename: "b.js", function: "x", lineno: 1, colno }] });
// Same file/line/function, different column → distinct fingerprints, so
// each keeps its own first-3 budget.
shouldDropException(at(10));
shouldDropException(at(10));
shouldDropException(at(10));
expect(shouldDropException(at(10))).toBe(true);
expect(shouldDropException(at(20))).toBe(false);
});
it("stores only a hash + counter — no raw value reaches storage", () => {
const storage = makeStorage();
vi.stubGlobal("sessionStorage", storage);
shouldDropException(exc({ value: "secret-marker-12345" }));
const blob = storage.data.get(STORAGE_KEY) ?? "";
expect(blob).not.toContain("secret-marker-12345");
expect(blob).not.toContain("app.tsx");
});
});
describe("shouldDropException — degraded frames", () => {
beforeEach(() => {
vi.stubGlobal("sessionStorage", makeStorage());
});
it("tolerates missing lineno/colno/function and still dedupes", () => {
const partial = () => exc({ frames: [{ filename: "only-file.js" }] });
expect(() => shouldDropException(partial())).not.toThrow();
shouldDropException(partial());
shouldDropException(partial());
expect(shouldDropException(partial())).toBe(true);
});
it("tolerates no stacktrace at all (fingerprints on type + value)", () => {
const noframes = () => exc({ frames: null });
shouldDropException(noframes());
shouldDropException(noframes());
shouldDropException(noframes());
expect(shouldDropException(noframes())).toBe(true);
});
it("keeps events with no usable signal (empty type/value/frames)", () => {
const empty = { $exception_list: [{ type: "", value: "" }] };
expect(shouldDropException(empty)).toBe(false);
expect(shouldDropException(empty)).toBe(false);
expect(shouldDropException(empty)).toBe(false);
expect(shouldDropException(empty)).toBe(false); // never fused — no fingerprint
});
it("is safe on undefined / malformed properties", () => {
expect(shouldDropException(undefined)).toBe(false);
expect(
shouldDropException({ $exception_list: "nope" as unknown as [] }),
).toBe(false);
});
});
describe("shouldDropException — storage fail-open", () => {
it("fails open when sessionStorage is undefined (SSR)", () => {
vi.stubGlobal("sessionStorage", undefined);
expect(shouldDropException(exc())).toBe(false);
expect(shouldDropException(exc())).toBe(false);
expect(shouldDropException(exc())).toBe(false);
expect(shouldDropException(exc())).toBe(false);
});
it("fails open when accessing sessionStorage throws (sandboxed iframe)", () => {
Object.defineProperty(globalThis, "sessionStorage", {
configurable: true,
get() {
throw new Error("blocked by sandbox");
},
});
try {
expect(() => shouldDropException(exc())).not.toThrow();
expect(shouldDropException(exc())).toBe(false);
} finally {
// Remove the throwing getter so it doesn't leak into other tests.
Object.defineProperty(globalThis, "sessionStorage", {
configurable: true,
value: undefined,
});
}
});
it("fails open when getItem throws", () => {
vi.stubGlobal("sessionStorage", makeStorage({ throwOnGet: true }));
expect(() => shouldDropException(exc())).not.toThrow();
expect(shouldDropException(exc())).toBe(false);
});
it("fails open on a corrupted JSON blob and re-seeds clean state", () => {
const storage = makeStorage();
storage.data.set(STORAGE_KEY, "{not valid json");
vi.stubGlobal("sessionStorage", storage);
expect(shouldDropException(exc())).toBe(false);
// Blob is now valid JSON again with this fingerprint counted once.
const reseeded = JSON.parse(storage.data.get(STORAGE_KEY)!);
expect(typeof reseeded).toBe("object");
expect(Object.values(reseeded)).toEqual([1]);
});
it("setItem failure under-counts (fewer drops), never over-drops", () => {
vi.stubGlobal("sessionStorage", makeStorage({ throwOnSet: true }));
// Persisting the increment always fails, so the counter never advances and
// no event is ever dropped — the required "less drop" direction.
for (let i = 0; i < 5; i++) {
expect(shouldDropException(exc())).toBe(false);
}
});
});
describe("shouldDropException — distinct-fingerprint cap", () => {
it("keeps (does not track) a new fingerprint once the cap is reached", () => {
const storage = makeStorage();
// Seed 50 distinct fingerprints already at count 1.
const seed: Record<string, number> = {};
for (let i = 0; i < 50; i++) seed[`seed-${i}`] = 1;
storage.data.set(STORAGE_KEY, JSON.stringify(seed));
vi.stubGlobal("sessionStorage", storage);
// The 51st, brand-new fingerprint is kept and NOT added to the blob.
expect(shouldDropException(exc({ value: "fingerprint-51" }))).toBe(false);
const after = JSON.parse(storage.data.get(STORAGE_KEY)!);
expect(Object.keys(after)).toHaveLength(50);
});
it("still fuses a fingerprint that is already tracked at the cap", () => {
const storage = makeStorage();
const seed: Record<string, number> = {};
for (let i = 0; i < 49; i++) seed[`seed-${i}`] = 1;
vi.stubGlobal("sessionStorage", storage);
// Track a real one to reach 50 distinct, exhausting its budget.
const target = () => exc({ value: "tracked-at-cap" });
storage.data.set(STORAGE_KEY, JSON.stringify(seed));
shouldDropException(target()); // 50th distinct, count 1
shouldDropException(target()); // 2
shouldDropException(target()); // 3
expect(shouldDropException(target())).toBe(true); // fused despite cap
});
});

View File

@@ -1,193 +0,0 @@
// Session-scoped dedupe / throttle for `$exception` events.
//
// Runs in posthog-js `before_send` AFTER `redactExceptionProperties`, so the
// fingerprint is built purely from already-redacted fields — no raw message,
// value, or PII is ever written to storage (only a hash + a small counter).
//
// The fuse: keep the first EXCEPTION_SAMPLE_LIMIT of each (tab-session,
// fingerprint) pair and drop the rest. One runaway error — a render loop, a
// polling fetch that keeps throwing — otherwise emits 100+ identical
// `$exception` events per session (MUL-3331 / MUL-3330). Different fingerprints
// never affect each other.
//
// Safety invariant (load-bearing): `before_send` must never throw — a throw
// there breaks ALL event delivery — and every storage failure must fail OPEN.
// When in doubt we KEEP the event: emitting a duplicate is cheap, silently
// dropping a real first-occurrence error is not. setItem failures therefore
// only ever under-count (fewer drops), never over-drop.
//
// Scope is the browser tab session (`sessionStorage`): cleared when the tab
// closes, isolated per tab. This is intentionally NOT the posthog 30-min
// session — see the dedupe discussion on MUL-3331.
const STORAGE_KEY = "mc_exc_fp";
// Keep the first N of each fingerprint per session, drop from N+1.
const EXCEPTION_SAMPLE_LIMIT = 3;
// Cap distinct fingerprints tracked per session so a session that throws many
// *different* errors can't grow the blob without bound. Past the cap, new
// fingerprints are not tracked and fail open (kept).
const MAX_FINGERPRINTS = 50;
type FingerprintCounts = Record<string, number>;
/**
* Decide whether this already-redacted `$exception` event should be dropped as
* a session-level duplicate. Returns `true` to drop, `false` to keep.
*
* Never throws. Any missing fingerprint signal, unavailable/corrupt storage, or
* unexpected error results in `false` (keep) — the fail-open direction.
*/
export function shouldDropException(
properties: Record<string, unknown> | undefined,
): boolean {
const fingerprint = buildFingerprint(properties);
// Nothing stable to dedupe on → keep.
if (fingerprint === null) return false;
const storage = getSessionStorage();
if (!storage) return false;
// The entire read-decide-write sequence is guarded: a throw anywhere (parse,
// getItem, property access) degrades to keep.
try {
const counts = readCounts(storage);
const current = typeof counts[fingerprint] === "number" ? counts[fingerprint] : 0;
// Already at the limit for this fingerprint → fuse blows, drop.
if (current >= EXCEPTION_SAMPLE_LIMIT) return true;
// A brand-new fingerprint once the cap is reached: don't track it (would
// grow the blob), and keep the event.
if (current === 0 && Object.keys(counts).length >= MAX_FINGERPRINTS) {
return false;
}
counts[fingerprint] = current + 1;
try {
storage.setItem(STORAGE_KEY, JSON.stringify(counts));
} catch {
// Persisting the increment failed (quota / disabled). We still keep this
// event (return false below). The unpersisted increment only means the
// next identical error is also kept — under-counting toward the limit,
// i.e. fewer drops, never more. This is the required failure direction.
}
return false;
} catch {
return false;
}
}
/** Read and validate the counts blob. A corrupt or unexpected payload is
* treated as empty (fail open — this event is kept and re-seeds the blob). */
function readCounts(storage: Storage): FingerprintCounts {
const raw = storage.getItem(STORAGE_KEY);
if (!raw) return {};
try {
const parsed: unknown = JSON.parse(raw);
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
return parsed as FingerprintCounts;
}
} catch {
// Corrupt JSON blob → start fresh.
}
return {};
}
/**
* Build a stable fingerprint from the redacted exception properties. Uses the
* exception type, the redacted message/value, and a single deterministic stack
* frame. Returns `null` when there's nothing stable to key on (keep the event).
*
* Every frame field (`function` / `lineno` / `colno`) is treated as optional
* and degrades to empty — minified or partial stacks must not throw or collapse
* every error into one bucket via an undefined access.
*/
function buildFingerprint(properties: Record<string, unknown> | undefined): string | null {
if (!properties || typeof properties !== "object") return null;
const list = properties.$exception_list;
const entry =
Array.isArray(list) && list.length > 0 && list[0] && typeof list[0] === "object"
? (list[0] as Record<string, unknown>)
: undefined;
const type = readString(entry?.type) ?? readString(properties.$exception_type) ?? "";
const value =
readString(entry?.value) ?? readString(properties.$exception_message) ?? "";
const frame = topFrame(entry);
// No signal at all → don't dedupe.
if (type === "" && value === "" && !frame) return null;
const parts = [type, value];
if (frame) {
// colno is kept (load-bearing): minified bundles collapse many statements
// onto one line, so line alone under-discriminates distinct errors.
parts.push(frame.filename, frame.fn, frame.lineno, frame.colno);
}
return hash(parts.join(""));
}
interface TopFrame {
filename: string;
fn: string;
lineno: string;
colno: string;
}
/**
* Extract a single deterministic stack frame for fingerprinting. We always take
* the LAST frame in the array — a fixed end, with NO engine/order detection.
* The same error within a session yields the same frames array and therefore
* the same chosen frame, which is all the fingerprint needs; we don't care
* which end is semantically "topmost". Missing pieces degrade to "".
*/
function topFrame(entry: Record<string, unknown> | undefined): TopFrame | null {
if (!entry) return null;
const stacktrace = entry.stacktrace;
const frames =
stacktrace && typeof stacktrace === "object"
? (stacktrace as Record<string, unknown>).frames
: undefined;
if (!Array.isArray(frames) || frames.length === 0) return null;
const f = frames[frames.length - 1];
if (!f || typeof f !== "object") return null;
const frame = f as Record<string, unknown>;
return {
filename: readString(frame.filename) ?? "",
fn: readString(frame.function) ?? "",
lineno: readNumberAsString(frame.lineno) ?? "",
colno: readNumberAsString(frame.colno) ?? "",
};
}
function readString(v: unknown): string | undefined {
return typeof v === "string" && v.length > 0 ? v : undefined;
}
function readNumberAsString(v: unknown): string | undefined {
return typeof v === "number" && Number.isFinite(v) ? String(v) : undefined;
}
/** djb2 — a tiny stable string hash. Only used to bound the storage-key length;
* collision risk across a single tab session's exceptions is negligible. */
function hash(input: string): string {
let h = 5381;
for (let i = 0; i < input.length; i++) {
h = ((h << 5) + h) ^ input.charCodeAt(i);
}
return (h >>> 0).toString(36);
}
/** Resolve `sessionStorage`, returning `null` if it is absent (SSR) or throws
* on access (sandboxed iframe, storage disabled). */
function getSessionStorage(): Storage | null {
try {
if (typeof sessionStorage === "undefined") return null;
return sessionStorage;
} catch {
return null;
}
}

View File

@@ -216,75 +216,3 @@ describe("captureException", () => {
expect(posthog.captureException).toHaveBeenCalledWith(err, expect.any(Object));
});
});
describe("before_send $exception pipeline", () => {
// before_send is registered inside posthog.init's config; pull it back out of
// the mock and drive it directly. Dedupe needs a working sessionStorage.
function makeMemoryStorage() {
const data = new Map<string, string>();
return {
getItem: (k: string) => (data.has(k) ? data.get(k)! : null),
setItem: (k: string, v: string) => void data.set(k, v),
removeItem: (k: string) => void data.delete(k),
clear: () => data.clear(),
key: (i: number) => Array.from(data.keys())[i] ?? null,
get length() {
return data.size;
},
};
}
type BeforeSend = (
e: { event: string; properties: Record<string, unknown> } | null,
) => unknown;
function getBeforeSend(posthog: { init: ReturnType<typeof vi.fn> }): BeforeSend {
const config = posthog.init.mock.calls[0]?.[1] as { before_send: BeforeSend };
return config.before_send;
}
function excEvent() {
return {
event: "$exception",
properties: {
$exception_list: [
{
type: "TypeError",
value: "Bad email bob@corp.com",
stacktrace: {
frames: [{ filename: "a.tsx", function: "f", lineno: 1, colno: 2 }],
},
},
],
},
};
}
beforeEach(() => {
vi.stubGlobal("sessionStorage", makeMemoryStorage());
});
it("redacts the message, then drops repeats past the per-fingerprint limit", async () => {
const { analytics, posthog } = await loadModule();
analytics.initAnalytics({ key: "k", host: "" });
const beforeSend = getBeforeSend(posthog);
const first = beforeSend(excEvent()) as { properties: { $exception_list: Array<{ value: string }> } };
// Redaction still runs before the fuse.
expect(first.properties.$exception_list[0]!.value).toBe("Bad email [redacted]");
expect(beforeSend(excEvent())).not.toBeNull();
expect(beforeSend(excEvent())).not.toBeNull();
// 4th identical exception is dropped.
expect(beforeSend(excEvent())).toBeNull();
});
it("passes non-$exception events through untouched", async () => {
const { analytics, posthog } = await loadModule();
analytics.initAnalytics({ key: "k", host: "" });
const beforeSend = getBeforeSend(posthog);
const evt = { event: "$pageview", properties: { $current_url: "/acme/issues" } };
expect(beforeSend(evt)).toBe(evt);
});
});

View File

@@ -14,7 +14,6 @@
import posthog from "posthog-js";
import { redactExceptionProperties } from "./redact-exception";
import { shouldDropException } from "./exception-dedupe";
export const EVENT_SCHEMA_VERSION = 2;
@@ -157,17 +156,10 @@ export function initAnalytics(config: AnalyticsConfig | null | undefined): boole
// typed value, a URL with a token), so `before_send` scrubs the message
// and `$exception_list[].value` before the event leaves the client. Stack
// frames (code locations) are kept. See redact-exception.ts.
//
// After scrubbing, a session-level fuse drops repeats of the same error so
// a render loop or a polling fetch that keeps throwing can't emit 100+
// identical `$exception` events per session (MUL-3331). The fingerprint is
// built only from the already-redacted fields, so no PII reaches storage.
// Order matters: redact first, then fingerprint the redacted shape.
capture_exceptions: true,
before_send: (event) => {
if (event && event.event === "$exception") {
redactExceptionProperties(event.properties);
if (shouldDropException(event.properties)) return null;
}
return event;
},

View File

@@ -467,9 +467,6 @@ const CancelledChatMessageSchema = z.object({
message_id: z.string(),
content: z.string(),
restore_to_input: z.boolean().default(false),
// Attachments detached from the deleted message so a restored draft can
// re-bind them on re-send. Absent on servers that predate the field.
attachments: z.array(AttachmentSchema).optional(),
}).loose();
export const CancelTaskResponseSchema = AgentTaskResponseSchema.extend({

View File

@@ -1,7 +1,6 @@
import { beforeEach, describe, expect, it } from "vitest";
import { createChatStore, newSessionDraftKey } from "./store";
import type { StorageAdapter } from "../types";
import type { Attachment } from "../types";
function memStorage(): StorageAdapter {
const m = new Map<string, string>();
@@ -16,26 +15,6 @@ function memStorage(): StorageAdapter {
};
}
function makeAttachment(id: string): Attachment {
return {
id,
workspace_id: "ws-1",
issue_id: null,
comment_id: null,
chat_session_id: null,
chat_message_id: null,
uploader_type: "member",
uploader_id: "user-1",
filename: `${id}.png`,
url: `/uploads/${id}.png`,
download_url: `/api/attachments/${id}/download`,
markdown_url: `/api/attachments/${id}/download`,
content_type: "image/png",
size_bytes: 1,
created_at: new Date(0).toISOString(),
};
}
describe("newSessionDraftKey", () => {
it("derives a stable per-agent slot for an uncreated chat", () => {
expect(newSessionDraftKey("agent-1")).toBe("__new__:agent-1");
@@ -43,31 +22,38 @@ describe("newSessionDraftKey", () => {
});
});
describe("chat store — draft attachments", () => {
describe("chat store — migrateInputDraft", () => {
let store: ReturnType<typeof createChatStore>;
beforeEach(() => {
store = createChatStore({ storage: memStorage() });
});
it("deduplicates attachment drafts by id", () => {
store.getState().addInputDraftAttachment("draft-1", makeAttachment("att-1"));
store.getState().addInputDraftAttachment("draft-1", {
...makeAttachment("att-1"),
filename: "updated.png",
});
it("moves a draft to the new key and clears the source", () => {
const from = newSessionDraftKey("agent-1");
store.getState().setInputDraft(from, "!file[x.pdf]()");
expect(store.getState().inputDraftAttachments["draft-1"]).toHaveLength(1);
expect(store.getState().inputDraftAttachments["draft-1"]?.[0]?.filename).toBe("updated.png");
store.getState().migrateInputDraft(from, "session-1");
const drafts = store.getState().inputDrafts;
expect(drafts["session-1"]).toBe("!file[x.pdf]()");
// Source slot is cleared so it can't resurface in the next new chat.
expect(from in drafts).toBe(false);
});
it("clearInputDraft clears both text and attachment records", () => {
store.getState().setInputDraft("draft-1", "hello");
store.getState().addInputDraftAttachment("draft-1", makeAttachment("att-1"));
it("is a no-op when the source draft is absent", () => {
store.getState().setInputDraft("session-1", "keep me");
store.getState().clearInputDraft("draft-1");
store.getState().migrateInputDraft(newSessionDraftKey("agent-1"), "session-1");
expect(store.getState().inputDrafts["draft-1"]).toBeUndefined();
expect(store.getState().inputDraftAttachments["draft-1"]).toBeUndefined();
expect(store.getState().inputDrafts["session-1"]).toBe("keep me");
});
it("is a no-op when from === to", () => {
store.getState().setInputDraft("session-1", "keep me");
store.getState().migrateInputDraft("session-1", "session-1");
expect(store.getState().inputDrafts["session-1"]).toBe("keep me");
});
});

View File

@@ -1,6 +1,5 @@
import { create } from "zustand";
import type { StorageAdapter } from "../types";
import type { Attachment } from "../types/attachment";
import { getCurrentSlug, registerForWorkspaceRehydration } from "../platform/workspace-storage";
import { createLogger } from "../logger";
@@ -10,8 +9,6 @@ const AGENT_STORAGE_KEY = "multica:chat:selectedAgentId";
const SESSION_STORAGE_KEY = "multica:chat:activeSessionId";
/** Drafts are stored as one JSON blob per workspace: { [sessionId]: text }. */
const DRAFTS_KEY = "multica:chat:drafts";
/** Draft attachment records per workspace: { [sessionId]: Attachment[] }. */
const DRAFT_ATTACHMENTS_KEY = "multica:chat:draft-attachments";
/** Placeholder sessionId for a chat that hasn't been created yet. */
export const DRAFT_NEW_SESSION = "__new__";
@@ -60,49 +57,6 @@ function writeDrafts(storage: StorageAdapter, key: string, drafts: Record<string
}
}
function isAttachmentDraft(value: unknown): value is Attachment {
return (
typeof value === "object" &&
value !== null &&
typeof (value as { id?: unknown }).id === "string" &&
typeof (value as { filename?: unknown }).filename === "string"
);
}
function readDraftAttachments(storage: StorageAdapter, key: string): Record<string, Attachment[]> {
const raw = storage.getItem(key);
if (!raw) return {};
try {
const parsed = JSON.parse(raw);
if (typeof parsed !== "object" || parsed === null) return {};
const out: Record<string, Attachment[]> = {};
for (const [draftKey, value] of Object.entries(parsed)) {
if (!Array.isArray(value)) continue;
const attachments = value.filter(isAttachmentDraft);
if (attachments.length > 0) out[draftKey] = attachments;
}
return out;
} catch {
return {};
}
}
function writeDraftAttachments(
storage: StorageAdapter,
key: string,
drafts: Record<string, Attachment[]>,
) {
const pruned: Record<string, Attachment[]> = {};
for (const [k, v] of Object.entries(drafts)) {
if (v.length > 0) pruned[k] = v;
}
if (Object.keys(pruned).length === 0) {
storage.removeItem(key);
} else {
storage.setItem(key, JSON.stringify(pruned));
}
}
export const CHAT_MIN_W = 360;
export const CHAT_MIN_H = 480;
export const CHAT_DEFAULT_W = 380;
@@ -129,8 +83,6 @@ export interface ChatState {
selectedAgentId: string | null;
/** Drafts per session: sessionId (or DRAFT_NEW_SESSION) → markdown text. */
inputDrafts: Record<string, string>;
/** Attachment rows referenced by each input draft. */
inputDraftAttachments: Record<string, Attachment[]>;
/** Raw user-chosen size — no clamp applied. UI layer clamps at render time. */
chatWidth: number;
chatHeight: number;
@@ -141,9 +93,15 @@ export interface ChatState {
setSelectedAgentId: (id: string) => void;
/** sessionId accepts a real session UUID or DRAFT_NEW_SESSION. */
setInputDraft: (sessionId: string, draft: string) => void;
setInputDraftAttachments: (sessionId: string, attachments: Attachment[]) => void;
addInputDraftAttachment: (sessionId: string, attachment: Attachment) => void;
clearInputDraft: (sessionId: string) => void;
/**
* Move a draft from one key to another, deleting the source. Used when a
* chat session is lazily created: the `__new__:agent` draft is migrated
* onto the real sessionId so it isn't stranded under the abandoned key
* (which would resurface as a stale draft the next time a new chat opens
* for that agent).
*/
migrateInputDraft: (from: string, to: string) => void;
/** Persist raw size and auto-exit expanded mode. */
setChatSize: (width: number, height: number) => void;
setExpanded: (expanded: boolean) => void;
@@ -172,7 +130,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)),
inputDraftAttachments: readDraftAttachments(storage, wsKey(DRAFT_ATTACHMENTS_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",
@@ -208,40 +165,30 @@ export function createChatStore(options: ChatStoreOptions) {
writeDrafts(storage, wsKey(DRAFTS_KEY), next);
set({ inputDrafts: next });
},
setInputDraftAttachments: (sessionId, attachments) => {
logger.debug("setInputDraftAttachments", { sessionId, count: attachments.length });
const next = { ...get().inputDraftAttachments };
if (attachments.length > 0) next[sessionId] = attachments;
else delete next[sessionId];
writeDraftAttachments(storage, wsKey(DRAFT_ATTACHMENTS_KEY), next);
set({ inputDraftAttachments: next });
},
addInputDraftAttachment: (sessionId, attachment) => {
if (!attachment.id) return;
const current = get().inputDraftAttachments;
const existing = current[sessionId] ?? [];
const nextForKey = existing.some((a) => a.id === attachment.id)
? existing.map((a) => (a.id === attachment.id ? attachment : a))
: [...existing, attachment];
const next = { ...current, [sessionId]: nextForKey };
writeDraftAttachments(storage, wsKey(DRAFT_ATTACHMENTS_KEY), next);
set({ inputDraftAttachments: next });
},
clearInputDraft: (sessionId) => {
const currentDrafts = get().inputDrafts;
const currentAttachments = get().inputDraftAttachments;
if (!(sessionId in currentDrafts) && !(sessionId in currentAttachments)) {
const current = get().inputDrafts;
if (!(sessionId in current)) {
logger.debug("clearInputDraft skipped (no draft)", { sessionId });
return;
}
logger.info("clearInputDraft", { sessionId });
const nextDrafts = { ...currentDrafts };
const nextAttachments = { ...currentAttachments };
delete nextDrafts[sessionId];
delete nextAttachments[sessionId];
writeDrafts(storage, wsKey(DRAFTS_KEY), nextDrafts);
writeDraftAttachments(storage, wsKey(DRAFT_ATTACHMENTS_KEY), nextAttachments);
set({ inputDrafts: nextDrafts, inputDraftAttachments: nextAttachments });
const next = { ...current };
delete next[sessionId];
writeDrafts(storage, wsKey(DRAFTS_KEY), next);
set({ inputDrafts: next });
},
migrateInputDraft: (from, to) => {
if (from === to) return;
const current = get().inputDrafts;
if (!(from in current)) {
logger.debug("migrateInputDraft skipped (no source draft)", { from, to });
return;
}
logger.info("migrateInputDraft", { from, to });
const next = { ...current, [to]: current[from]! };
delete next[from];
writeDrafts(storage, wsKey(DRAFTS_KEY), next);
set({ inputDrafts: next });
},
setChatSize: (w, h) => {
logger.debug("setChatSize", { w, h });
@@ -266,20 +213,17 @@ 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 nextDraftAttachments = readDraftAttachments(storage, wsKey(DRAFT_ATTACHMENTS_KEY));
logger.info("workspace rehydration", {
prevSession: store.getState().activeSessionId,
nextSession,
prevAgent: store.getState().selectedAgentId,
nextAgent,
draftCount: Object.keys(nextDrafts).length,
draftAttachmentCount: Object.keys(nextDraftAttachments).length,
});
store.setState({
activeSessionId: nextSession,
selectedAgentId: nextAgent,
inputDrafts: nextDrafts,
inputDraftAttachments: nextDraftAttachments,
});
});

View File

@@ -45,7 +45,6 @@ beforeEach(() => {
afterEach(() => {
vi.unstubAllGlobals();
vi.clearAllMocks();
vi.useRealTimers();
});
describe("installFreezeWatchdog", () => {
@@ -97,38 +96,4 @@ describe("installFreezeWatchdog", () => {
expect(() => installFreezeWatchdog()).not.toThrow();
});
it("emits at most one client_unresponsive per 60s cooldown window", async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-01-01T00:00:00Z"));
const { installFreezeWatchdog, captureEvent } = await load();
installFreezeWatchdog();
// A sustained freeze arrives as several long-task entries back to back.
fireLongTask(2500);
fireLongTask(2500);
fireLongTask(3000);
expect(captureEvent).toHaveBeenCalledTimes(1);
});
it("emits again only after the cooldown window elapses", async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-01-01T00:00:00Z"));
const { installFreezeWatchdog, captureEvent } = await load();
installFreezeWatchdog();
fireLongTask(2500);
expect(captureEvent).toHaveBeenCalledTimes(1);
// Still inside the window → suppressed.
vi.advanceTimersByTime(59_999);
fireLongTask(2500);
expect(captureEvent).toHaveBeenCalledTimes(1);
// Window elapsed → emits again.
vi.advanceTimersByTime(1);
fireLongTask(2500);
expect(captureEvent).toHaveBeenCalledTimes(2);
});
});

View File

@@ -24,16 +24,6 @@ import { captureEvent } from "../analytics";
// felt a real stall" without flooding on routine heavy renders.
const FREEZE_THRESHOLD_MS = 2000;
// A single sustained freeze is delivered by the browser as several separate
// long-task entries, so emitting per entry makes client_unresponsive volume
// grow without bound with the freeze length (MUL-3331). A global cooldown caps
// it to at most one event per window. Module-level (page-lifetime) state is the
// right scope here — it matches the `installed` singleton and resets on a full
// reload, which is rare and itself a distinct signal. No route bucketing: a
// global window is the most direct cap on volume.
const COOLDOWN_MS = 60_000;
let lastEmitMs = 0;
let installed = false;
/**
@@ -51,11 +41,6 @@ export function installFreezeWatchdog(): void {
const observer = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
if (entry.duration < FREEZE_THRESHOLD_MS) continue;
// Cooldown is checked only against qualifying freezes, so sub-threshold
// long tasks neither emit nor reset the window.
const now = Date.now();
if (now - lastEmitMs < COOLDOWN_MS) continue;
lastEmitMs = now;
captureEvent("client_unresponsive", {
source: "longtask",
duration_ms: Math.round(entry.duration),

View File

@@ -79,13 +79,6 @@ export interface SendChatMessageResponse {
* timer "snaps backwards" later when WS events update the cache.
*/
created_at: string;
/**
* Attachment ids the server actually bound to this message. The client
* diffs these against the ids it requested to warn when an attachment
* silently failed to bind — no extra fetch needed. Optional for forward
* compat with servers that predate the field.
*/
attachment_ids?: string[];
}
export interface CancelledChatMessage {
@@ -93,11 +86,6 @@ export interface CancelledChatMessage {
message_id: string;
content: string;
restore_to_input: boolean;
/**
* Attachments detached from the deleted message so a restored draft can
* re-bind them on re-send. Absent on servers that predate the field.
*/
attachments?: import("./attachment").Attachment[];
}
export interface CancelTaskResponse extends AgentTask {

View File

@@ -39,9 +39,6 @@ const dropHandlers = vi.hoisted(() => ({
const editorProps = vi.hoisted(() => ({
last: null as null | Record<string, unknown>,
}));
// Records imperative editor calls so tests can assert whether a commit
// scrubbed the editor (clearEditor) or left it intact (fire-and-forget).
const editorState = vi.hoisted(() => ({ cleared: 0, blurred: 0 }));
vi.mock("../../editor", () => ({
useFileDropZone: ({ onDrop }: { onDrop: (files: File[]) => void }) => {
@@ -72,12 +69,9 @@ vi.mock("../../editor", () => ({
useImperativeHandle(ref, () => ({
getMarkdown: () => valueRef.current,
clearContent: () => {
editorState.cleared += 1;
valueRef.current = "";
},
blur: () => {
editorState.blurred += 1;
},
blur: () => {},
focus: () => {},
uploadFile: async (file: File) => {
uploadingRef.current += 1;
@@ -122,10 +116,7 @@ vi.mock("@multica/core/chat", () => {
activeSessionId: null as string | null,
selectedAgentId: "agent-1",
inputDrafts: {} as Record<string, string>,
inputDraftAttachments: {} as Record<string, UploadResult[]>,
setInputDraft: vi.fn(),
setInputDraftAttachments: vi.fn(),
addInputDraftAttachment: vi.fn(),
clearInputDraft: vi.fn(),
};
return {
@@ -142,49 +133,21 @@ vi.mock("@multica/core/chat", () => {
import { ChatInput } from "./chat-input";
import { useChatStore } from "@multica/core/chat";
type ChatInputOnSend = React.ComponentProps<typeof ChatInput>["onSend"];
type ChatInputCommit = Parameters<ChatInputOnSend>[2];
beforeEach(() => {
dropHandlers.onDrop = null;
editorProps.last = null;
editorState.cleared = 0;
editorState.blurred = 0;
const state = useChatStore.getState() as unknown as {
activeSessionId: string | null;
selectedAgentId: string;
inputDrafts: Record<string, string>;
setInputDraft: ReturnType<typeof vi.fn>;
clearInputDraft: ReturnType<typeof vi.fn>;
inputDraftAttachments: Record<string, UploadResult[]>;
setInputDraftAttachments: ReturnType<typeof vi.fn>;
addInputDraftAttachment: ReturnType<typeof vi.fn>;
};
state.activeSessionId = null;
state.selectedAgentId = "agent-1";
state.inputDrafts = {};
state.inputDraftAttachments = {};
state.setInputDraft.mockClear();
state.setInputDraft.mockImplementation((key: string, value: string) => {
state.inputDrafts[key] = value;
});
state.setInputDraftAttachments.mockClear();
state.setInputDraftAttachments.mockImplementation((key: string, attachments: UploadResult[]) => {
if (attachments.length > 0) state.inputDraftAttachments[key] = attachments;
else delete state.inputDraftAttachments[key];
});
state.addInputDraftAttachment.mockClear();
state.addInputDraftAttachment.mockImplementation((key: string, attachment: UploadResult) => {
const existing = state.inputDraftAttachments[key] ?? [];
state.inputDraftAttachments[key] = existing.some((a) => a.id === attachment.id)
? existing.map((a) => (a.id === attachment.id ? attachment : a))
: [...existing, attachment];
});
state.clearInputDraft.mockClear();
state.clearInputDraft.mockImplementation((key: string) => {
delete state.inputDrafts[key];
delete state.inputDraftAttachments[key];
});
});
function renderInput(props: Partial<React.ComponentProps<typeof ChatInput>> = {}) {
@@ -260,10 +223,6 @@ describe("ChatInput attachment wiring", () => {
expect(onSend).toHaveBeenCalledTimes(1);
const [, ids] = onSend.mock.calls[0]!;
expect(ids).toEqual(["att-42"]);
expect(useChatStore.getState().addInputDraftAttachment).toHaveBeenCalledWith(
"__draft_new__:agent-1",
expect.objectContaining({ id: "att-42" }),
);
});
it("binds attachment_ids when the upload's markdownLink differs from its link (MUL-3130 regression)", async () => {
@@ -420,12 +379,12 @@ describe("ChatInput async send", () => {
);
});
it("keeps the draft while send is pending until the owner commits the handoff", async () => {
it("keeps the draft while send is pending and clears after acceptance", async () => {
let resolveSend: (accepted: boolean) => void;
const sendPromise = new Promise<boolean>((res) => {
resolveSend = res;
});
const onSend = vi.fn<ChatInputOnSend>(() => sendPromise);
const onSend = vi.fn(() => sendPromise);
renderInput({ onSend });
fireEvent.change(screen.getByTestId("editor"), { target: { value: "slow network" } });
@@ -439,29 +398,18 @@ describe("ChatInput async send", () => {
fireEvent.click(sendButton!);
expect(onSend).toHaveBeenCalledWith(
"slow network",
undefined,
expect.any(Function),
[],
);
expect(onSend).toHaveBeenCalledWith("slow network", undefined);
expect(useChatStore.getState().clearInputDraft).not.toHaveBeenCalled();
await waitFor(() => expect(sendButton!).toBeDisabled());
const commitInput = onSend.mock.calls[0]![2] as ChatInputCommit;
act(() => {
commitInput({ extraDraftKeys: ["session-1"] });
});
expect(useChatStore.getState().clearInputDraft).toHaveBeenCalledWith("__draft_new__:agent-1");
expect(useChatStore.getState().clearInputDraft).toHaveBeenCalledWith("session-1");
await act(async () => {
resolveSend!(true);
await sendPromise;
});
expect(useChatStore.getState().clearInputDraft).toHaveBeenCalledTimes(2);
await waitFor(() => {
expect(useChatStore.getState().clearInputDraft).toHaveBeenCalledWith("__draft_new__:agent-1");
});
});
it("keeps the draft when send is rejected by the owner", async () => {
@@ -482,148 +430,7 @@ describe("ChatInput async send", () => {
await Promise.resolve();
});
expect(onSend).toHaveBeenCalledWith("retry me", undefined, expect.any(Function), []);
expect(onSend).toHaveBeenCalledWith("retry me", undefined);
expect(useChatStore.getState().clearInputDraft).not.toHaveBeenCalled();
});
it("sends attachment ids restored from persisted draft attachments", async () => {
const state = useChatStore.getState() as unknown as {
inputDrafts: Record<string, string>;
inputDraftAttachments: Record<string, UploadResult[]>;
};
const attachment = makeUpload({
id: "att-persisted",
link: "/api/attachments/att-persisted/download",
filename: "persisted.png",
});
state.inputDrafts["__draft_new__:agent-1"] = "see ![](/api/attachments/att-persisted/download)";
state.inputDraftAttachments["__draft_new__:agent-1"] = [attachment];
const onSend = vi.fn<ChatInputOnSend>((_content, _ids, commitInput) => {
commitInput();
return true;
});
renderInput({ onSend });
let sendButton: HTMLElement;
await waitFor(() => {
const buttons = screen.getAllByRole("button");
sendButton = buttons[buttons.length - 1]!;
expect(sendButton).not.toBeDisabled();
});
fireEvent.click(sendButton!);
expect(onSend).toHaveBeenCalledWith(
"see ![](/api/attachments/att-persisted/download)",
["att-persisted"],
expect.any(Function),
[attachment],
);
});
});
// A failed fire-and-forget send must restore into the session it was sent
// FROM, never into whatever session the user navigated to in the meantime.
describe("ChatInput session-aware restore", () => {
function element(props: Partial<React.ComponentProps<typeof ChatInput>>) {
return (
<I18nProvider locale="en" resources={TEST_RESOURCES}>
<ChatInput onSend={vi.fn()} onUploadFile={vi.fn()} agentName="Multica" {...props} />
</I18nProvider>
);
}
it("holds a session-scoped restore until the user returns to the source session", async () => {
const state = useChatStore.getState() as unknown as {
activeSessionId: string | null;
setInputDraft: ReturnType<typeof vi.fn>;
};
// User is viewing session-b; the failed send belongs to session-a.
state.activeSessionId = "session-b";
const onRestoreDraftConsumed = vi.fn();
const props = {
restoreDraftRequest: { id: "r1", content: "from A", sessionId: "session-a" },
onRestoreDraftConsumed,
};
const { rerender } = render(element(props));
// Pending — must NOT dump A's content into session-b.
expect(onRestoreDraftConsumed).not.toHaveBeenCalled();
expect(state.setInputDraft).not.toHaveBeenCalledWith("session-b", "from A");
// User navigates back to the source session → the pending restore fires.
state.activeSessionId = "session-a";
rerender(element(props));
await waitFor(() => {
expect(state.setInputDraft).toHaveBeenCalledWith("session-a", "from A");
expect(onRestoreDraftConsumed).toHaveBeenCalledTimes(1);
});
});
it("consumes a session-scoped restore when already on that session", async () => {
const state = useChatStore.getState() as unknown as {
activeSessionId: string | null;
setInputDraft: ReturnType<typeof vi.fn>;
};
state.activeSessionId = "session-a";
const onRestoreDraftConsumed = vi.fn();
render(
element({
restoreDraftRequest: { id: "r2", content: "hi A", sessionId: "session-a" },
onRestoreDraftConsumed,
}),
);
await waitFor(() => {
expect(state.setInputDraft).toHaveBeenCalledWith("session-a", "hi A");
expect(onRestoreDraftConsumed).toHaveBeenCalledTimes(1);
});
});
});
// commitInput is the handoff: the owner (ChatWindow) decides WHEN and HOW to
// clear the input. clearEditor:false is the fire-and-forget case — the user
// navigated away, so the shared editor now shows another session's draft and
// must not be scrubbed, but the SENT draft's data is still cleared.
describe("ChatInput commit handoff", () => {
async function typeAndSend(onSend: ChatInputOnSend) {
renderInput({ onSend });
fireEvent.change(screen.getByTestId("editor"), { target: { value: "msg" } });
let sendButton: HTMLElement;
await waitFor(() => {
const buttons = screen.getAllByRole("button");
sendButton = buttons[buttons.length - 1]!;
expect(sendButton).not.toBeDisabled();
});
fireEvent.click(sendButton!);
await waitFor(() => expect(onSend).toHaveBeenCalled());
}
it("scrubs the editor and clears the draft on a normal commit", async () => {
const onSend = vi.fn<ChatInputOnSend>((_content, _ids, commitInput) => {
commitInput();
return true;
});
await typeAndSend(onSend);
expect(editorState.cleared).toBeGreaterThan(0);
expect(editorState.blurred).toBeGreaterThan(0);
expect(useChatStore.getState().clearInputDraft).toHaveBeenCalledWith("__draft_new__:agent-1");
});
it("leaves the editor intact on a fire-and-forget commit but still clears the sent draft", async () => {
const onSend = vi.fn<ChatInputOnSend>((_content, _ids, commitInput) => {
commitInput({ clearEditor: false });
return true;
});
await typeAndSend(onSend);
// Editor untouched — it now shows the session the user navigated to.
expect(editorState.cleared).toBe(0);
expect(editorState.blurred).toBe(0);
// …but the sent session's persisted draft is cleared regardless.
expect(useChatStore.getState().clearInputDraft).toHaveBeenCalledWith("__draft_new__:agent-1");
});
});

View File

@@ -16,50 +16,13 @@ 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 type { Attachment } from "@multica/core/types";
import { useT } from "../../i18n";
const logger = createLogger("chat.ui");
const EMPTY_ATTACHMENTS: Attachment[] = [];
function attachmentReferenceUrls(attachment: Attachment): string[] {
const withUploadFields = attachment as Attachment & {
markdownLink?: string;
link?: string;
};
return [
withUploadFields.markdownLink,
attachment.markdown_url,
attachment.download_url,
attachment.url,
withUploadFields.link,
attachment.id ? `/api/attachments/${attachment.id}/download` : "",
].filter((url): url is string => !!url);
}
function isAttachmentReferenced(content: string, attachment: Attachment): boolean {
return attachmentReferenceUrls(attachment).some((url) => content.includes(url));
}
interface ChatInputProps {
onSend: (
content: string,
attachmentIds: string[] | undefined,
commitInput: (options?: { extraDraftKeys?: string[]; clearEditor?: boolean }) => void,
draftAttachments: Attachment[],
) => void | boolean | Promise<void | boolean>;
restoreDraftRequest?: {
id: string;
content: string;
attachments?: Attachment[];
/**
* Draft slot this restore targets. When set, the restore only fires while
* the user is viewing that session — a fire-and-forget send that later
* fails restores into the session it was sent from, not whatever the user
* navigated to. Omit to restore into the current draft (legacy behavior).
*/
sessionId?: string;
} | null;
onSend: (content: string, attachmentIds?: string[]) => void | boolean | Promise<void | boolean>;
restoreDraftRequest?: { id: string; content: string } | null;
onRestoreDraftConsumed?: () => void;
/** Receives a File and returns the attachment row (with id + CDN link).
* The wrapper owner (ChatWindow) lazy-creates a chat_session if needed
@@ -125,12 +88,7 @@ export function ChatInput({
const draftKey = activeSessionId ?? newSessionDraftKey(selectedAgentId);
// Select a primitive — empty-string fallback keeps referential stability.
const inputDraft = useChatStore((s) => s.inputDrafts[draftKey] ?? "");
const draftAttachments = useChatStore(
(s) => s.inputDraftAttachments[draftKey] ?? EMPTY_ATTACHMENTS,
);
const setInputDraft = useChatStore((s) => s.setInputDraft);
const setInputDraftAttachments = useChatStore((s) => s.setInputDraftAttachments);
const addInputDraftAttachment = useChatStore((s) => s.addInputDraftAttachment);
const clearInputDraft = useChatStore((s) => s.clearInputDraft);
const [isEmpty, setIsEmpty] = useState(!inputDraft.trim());
const [isSubmitting, setIsSubmitting] = useState(false);
@@ -139,7 +97,6 @@ export function ChatInput({
content: string;
draftKey: string;
} | null>(null);
const consumedRestoreIdRef = useRef<string | null>(null);
const activeRestore = editorRestore?.draftKey === draftKey ? editorRestore : null;
const editorKey = `${selectedAgentId ?? "no-agent"}:${activeRestore?.id ?? "base"}`;
// Number of in-flight uploads. We track this explicitly (rather than
@@ -165,20 +122,7 @@ export function ChatInput({
const uploadMapRef = useRef<Map<string, string>>(new Map());
useEffect(() => {
if (!restoreDraftRequest) {
consumedRestoreIdRef.current = null;
return;
}
if (consumedRestoreIdRef.current === restoreDraftRequest.id) return;
// Session-scoped restore: if this draft belongs to a specific session,
// wait until the user is actually viewing it. A fire-and-forget send that
// failed after the user navigated away must not dump its content into the
// session they're now looking at — the request stays pending until they
// return to the source session (draftKey then matches).
if (restoreDraftRequest.sessionId && restoreDraftRequest.sessionId !== draftKey) {
return;
}
consumedRestoreIdRef.current = restoreDraftRequest.id;
if (!restoreDraftRequest) return;
if (inputDraft.trim()) {
logger.info("input.restore skipped: draft already has content", {
draftKey,
@@ -188,7 +132,6 @@ export function ChatInput({
return;
}
setInputDraft(draftKey, restoreDraftRequest.content);
setInputDraftAttachments(draftKey, restoreDraftRequest.attachments ?? []);
setIsEmpty(!restoreDraftRequest.content.trim());
setEditorRestore({
id: restoreDraftRequest.id,
@@ -196,14 +139,7 @@ export function ChatInput({
draftKey,
});
onRestoreDraftConsumed?.();
}, [
draftKey,
inputDraft,
onRestoreDraftConsumed,
restoreDraftRequest,
setInputDraft,
setInputDraftAttachments,
]);
}, [draftKey, inputDraft, onRestoreDraftConsumed, restoreDraftRequest, setInputDraft]);
const handleUpload = useCallback(
async (file: File): Promise<UploadResult | null> => {
@@ -214,14 +150,13 @@ export function ChatInput({
if (result) {
const persistedURL = result.markdownLink || result.link;
uploadMapRef.current.set(persistedURL, result.id);
if (result.id) addInputDraftAttachment(draftKey, result);
}
return result;
} finally {
setPendingUploads((n) => Math.max(0, n - 1));
}
},
[addInputDraftAttachment, draftKey, onUploadFile],
[onUploadFile],
);
// Drop zone wraps the rounded card so a drop anywhere on the input
@@ -260,69 +195,38 @@ export function ChatInput({
for (const [url, id] of uploadMapRef.current) {
if (content.includes(url)) activeIds.push(id);
}
for (const attachment of draftAttachments) {
if (isAttachmentReferenced(content, attachment)) activeIds.push(attachment.id);
}
const uniqueActiveIds = Array.from(new Set(activeIds));
// Capture draft key BEFORE onSend — creating a new session mutates
// activeSessionId synchronously, so reading it after onSend would point
// at the new session and leave the old draft orphaned.
const keyAtSend = draftKey;
let committed = false;
const commitInput = (options?: { extraDraftKeys?: string[]; clearEditor?: boolean }) => {
if (committed) return;
committed = true;
// `clearEditor === false` means the owner sent fire-and-forget while the
// user had already navigated to another session. The editor instance is
// shared across sessions, so it now shows (and the user may be typing
// into) a DIFFERENT draft — clearing it or blurring would wipe that
// visible input. Only scrub the editor when the user is still on the
// session they sent from.
if (options?.clearEditor !== false) {
editorRef.current?.clearContent();
// Drop focus so the caret doesn't keep blinking under the StatusPill /
// streaming reply that's about to take over the user's attention. The
// input is also `disabled` once isRunning flips, and a focused-but-
// disabled editor reads as a stale cursor. We deliberately don't auto-
// refocus on completion — that would interrupt the user if they're
// selecting text from the assistant reply; one click to refocus is
// a fair price for not stealing focus mid-action.
editorRef.current?.blur();
setIsEmpty(true);
}
// The sent draft's data is cleared regardless — the message is on its
// way, so its persisted draft must not resurface.
clearInputDraft(keyAtSend);
for (const key of options?.extraDraftKeys ?? []) {
if (key !== keyAtSend) clearInputDraft(key);
}
uploadMapRef.current.clear();
setIsSubmitting(false);
};
logger.info("input.send", {
contentLength: content.length,
draftKey: keyAtSend,
attachmentCount: uniqueActiveIds.length,
attachmentCount: activeIds.length,
});
setIsSubmitting(true);
let accepted: void | boolean;
try {
accepted = await onSend(
content,
uniqueActiveIds.length > 0 ? uniqueActiveIds : undefined,
commitInput,
draftAttachments.filter((attachment) => uniqueActiveIds.includes(attachment.id)),
);
accepted = await onSend(content, activeIds.length > 0 ? activeIds : undefined);
} catch (err) {
logger.warn("input.send failed", err);
if (!committed) setIsSubmitting(false);
setIsSubmitting(false);
return;
}
if (accepted === false) {
if (!committed) setIsSubmitting(false);
return;
}
if (!committed) commitInput();
setIsSubmitting(false);
if (accepted === false) return;
editorRef.current?.clearContent();
// Drop focus so the caret doesn't keep blinking under the StatusPill /
// streaming reply that's about to take over the user's attention. The
// input is also `disabled` once isRunning flips, and a focused-but-
// disabled editor reads as a stale cursor. We deliberately don't auto-
// refocus on completion — that would interrupt the user if they're
// selecting text from the assistant reply; one click to refocus is
// a fair price for not stealing focus mid-action.
editorRef.current?.blur();
clearInputDraft(keyAtSend);
uploadMapRef.current.clear();
setIsEmpty(true);
};
const placeholder = noAgent
@@ -371,18 +275,9 @@ export function ChatInput({
onUpdate={(md) => {
setIsEmpty(!md.trim());
setInputDraft(draftKey, md);
if (draftAttachments.length > 0) {
const referenced = draftAttachments.filter((attachment) =>
isAttachmentReferenced(md, attachment),
);
if (referenced.length !== draftAttachments.length) {
setInputDraftAttachments(draftKey, referenced);
}
}
}}
onSubmit={handleSend}
onUploadFile={uploadEnabled ? handleUpload : undefined}
attachments={draftAttachments}
debounceMs={100}
mentionMode={contextItems ? "context" : "default"}
mentionContextItems={contextItems}
@@ -412,7 +307,6 @@ export function ChatInput({
<SubmitButton
onClick={handleSend}
disabled={isEmpty || isSubmitting || !!disabled || !!noAgent || pendingUploads > 0}
loading={isSubmitting}
running={isRunning}
onStop={onStop}
tooltip={`${t(($) => $.input.send_tooltip)} · ${formatShortcut(modKey, enterKey)}`}

View File

@@ -44,20 +44,39 @@ import {
useMarkChatSessionRead,
useUpdateChatSession,
} from "@multica/core/chat/mutations";
import { useChatStore } from "@multica/core/chat";
import { useChatStore, newSessionDraftKey } from "@multica/core/chat";
import { ChatMessageList, ChatMessageSkeleton } from "./chat-message-list";
import { ChatInput } from "./chat-input";
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, Attachment, ChatMessage, ChatMessagesPage, ChatPendingTask, ChatSession, PendingChatTasksResponse } from "@multica/core/types";
import type { Agent, ChatMessage, ChatMessagesPage, ChatPendingTask, ChatSession, PendingChatTasksResponse } from "@multica/core/types";
import { useT } from "../../i18n";
const uiLogger = createLogger("chat.ui");
const apiLogger = createLogger("chat.api");
const CHAT_VIRTUOSO_INITIAL_FIRST_ITEM_INDEX = 1_000_000;
function seedChatMessagesPageCache(
qc: ReturnType<typeof useQueryClient>,
sessionId: string,
messages: ChatMessage[],
) {
qc.setQueryData<InfiniteData<ChatMessagesPage>>(
chatKeys.messagesPage(sessionId),
(old) => old ?? {
pages: [{
messages,
limit: 50,
has_more: false,
next_cursor: null,
}],
pageParams: [null],
},
);
}
function appendChatMessageToLatestPageCache(
qc: ReturnType<typeof useQueryClient>,
sessionId: string,
@@ -167,6 +186,7 @@ export function ChatWindow() {
const setOpen = useChatStore((s) => s.setOpen);
const setActiveSession = useChatStore((s) => s.setActiveSession);
const setSelectedAgentId = useChatStore((s) => s.setSelectedAgentId);
const migrateInputDraft = useChatStore((s) => s.migrateInputDraft);
const user = useAuthStore((s) => s.user);
const { data: agents = [] } = useQuery(agentListOptions(wsId));
const { data: members = [] } = useQuery(memberListOptions(wsId));
@@ -210,8 +230,6 @@ export function ChatWindow() {
const [restoreDraftRequest, setRestoreDraftRequest] = useState<{
id: string;
content: string;
attachments?: Attachment[];
sessionId?: string;
} | null>(null);
const handleRestoreDraftConsumed = useCallback(() => {
setRestoreDraftRequest(null);
@@ -353,14 +371,40 @@ export function ChatWindow() {
const handleUploadFile = useCallback(
async (file: File) => {
if (!activeAgent) return null;
// Uploads are workspace-scoped drafts. Sending the message is the point
// where we create a chat session (if needed) and bind attachment_ids to
// the persisted chat_message row. This keeps a paste/drop from creating
// an empty chat session the user never sends.
return uploadWithToast(file);
// An upload in a brand-new chat lazily creates the session, flipping the
// draft key from `__new__:agent` to the session id mid-upload. The
// in-progress (empty-href) file-card markdown the editor already wrote
// into the `__new__:agent` draft would otherwise be stranded there and
// resurface as a stale `!file[name]()` the next time a new chat opens for
// this agent. Migrate that draft onto the session id so it travels with
// the session and the `__new__:agent` slot is cleared.
const wasNewSession = !activeSessionId;
const sessionId = await ensureSession("");
if (!sessionId) return null;
if (wasNewSession) {
migrateInputDraft(newSessionDraftKey(selectedAgentId), sessionId);
}
// Prime the messages cache as empty before flipping activeSessionId so
// ChatMessageList mounts directly (no Skeleton frame). Skip the write
// when an entry already exists — a concurrent handleSend may have
// seeded an optimistic message we must not clobber.
seedChatMessagesPageCache(qc, sessionId, []);
qc.setQueryData<ChatMessage[]>(
chatKeys.messages(sessionId),
(old) => old ?? [],
);
setActiveSession(sessionId);
return uploadWithToast(file, { chatSessionId: sessionId });
},
[activeAgent, uploadWithToast],
[
activeSessionId,
ensureSession,
migrateInputDraft,
selectedAgentId,
uploadWithToast,
qc,
setActiveSession,
],
);
const cancelChatTask = useCallback(
@@ -385,8 +429,6 @@ export function ChatWindow() {
setRestoreDraftRequest({
id: restored.message_id,
content: restored.content,
attachments: restored.attachments,
sessionId: restored.chat_session_id,
});
}
}
@@ -413,12 +455,7 @@ export function ChatWindow() {
);
const handleSend = useCallback(
async (
content: string,
attachmentIds?: string[],
commitInput?: (options?: { extraDraftKeys?: string[]; clearEditor?: boolean }) => void,
draftAttachments: Attachment[] = [],
): Promise<boolean> => {
async (content: string, attachmentIds?: string[]): Promise<boolean> => {
if (!activeAgent) {
apiLogger.warn("sendChatMessage skipped: no active agent");
return false;
@@ -462,7 +499,6 @@ export function ChatWindow() {
content: finalContent,
task_id: null,
created_at: sentAt,
attachments: draftAttachments,
};
// Seed cache BEFORE flipping activeSessionId. If we set the active
// session first, useQuery's first subscription to the new key sees no
@@ -485,22 +521,9 @@ export function ChatWindow() {
status: "queued",
created_at: sentAt,
});
// Cache primed → safe to publish the new active session. But only steal
// focus if the user is STILL on the compose target they sent from — if
// they navigated away mid-send, this is fire-and-forget: the reply
// surfaces via the unread dot on the sent session, we don't yank the
// view back. Compare the live store against the closure-captured target.
// For a brand-new chat (activeSessionId === null) the target is keyed by
// the selected agent, so switching agents to start a different new chat
// must also count as "navigated away" even though both sides are null.
const live = useChatStore.getState();
const stillOnSourceSession =
live.activeSessionId === activeSessionId &&
(activeSessionId !== null || live.selectedAgentId === selectedAgentId);
if (stillOnSourceSession) {
setActiveSession(sessionId);
}
commitInput?.({ extraDraftKeys: [sessionId], clearEditor: stillOnSourceSession });
// Cache primed → safe to publish the new active session. Idempotent
// when the session was already active (existing-conversation send).
setActiveSession(sessionId);
apiLogger.debug("sendChatMessage.optimistic", { sessionId, optimisticId: optimistic.id });
let result;
@@ -511,15 +534,6 @@ export function ChatWindow() {
stopRequestedBeforeTaskRef.current = false;
removeChatMessageFromCaches(qc, sessionId, optimistic.id);
qc.setQueryData(chatKeys.pendingTask(sessionId), {});
setRestoreDraftRequest({
id: `send-failed-${optimistic.id}`,
content: finalContent,
attachments: draftAttachments,
// Restore into the session this was sent from. If the user
// navigated away (fire-and-forget) the request waits until they
// return rather than dumping content into another session.
sessionId,
});
toast.error(t(($) => $.input.send_failed_toast));
return false;
}
@@ -545,29 +559,12 @@ export function ChatWindow() {
});
return false;
}
// The server reports which attachment ids it actually bound. Diff
// against what we requested so a silent bind failure surfaces to the
// user — no extra fetch. Skip the check on servers that predate the
// field (attachment_ids undefined) rather than false-alarm.
if (attachmentIds && attachmentIds.length > 0 && result.attachment_ids) {
const boundIds = new Set(result.attachment_ids);
const missing = attachmentIds.filter((id) => !boundIds.has(id));
if (missing.length > 0) {
apiLogger.warn("sendChatMessage.attachments missing after send", {
sessionId,
messageId: result.message_id,
missing,
});
toast.error(t(($) => $.input.attachment_bind_failed_toast));
}
}
qc.invalidateQueries({ queryKey: chatKeys.messages(sessionId) });
qc.invalidateQueries({ queryKey: chatKeys.messagesPage(sessionId) });
return true;
},
[
activeSessionId,
selectedAgentId,
activeAgent,
ensureSession,
cancelChatTask,

View File

@@ -103,7 +103,7 @@ interface CommentCardProps {
* `CommentRow` has to rerun the rule per row.
*/
canModerate?: boolean;
onReply: (parentId: string, content: string, attachmentIds?: string[], suppressAgentIds?: string[]) => Promise<boolean>;
onReply: (parentId: string, content: string, attachmentIds?: string[], suppressAgentIds?: string[]) => Promise<void>;
onEdit: (commentId: string, content: string, attachmentIds: string[], suppressAgentIds?: string[]) => Promise<void>;
onDelete: (commentId: string) => void;
onToggleReaction: (commentId: string, emoji: string) => void;

View File

@@ -88,16 +88,16 @@ function renderWithProviders(ui: ReactNode) {
);
}
function renderCommentInput(onSubmit = vi.fn().mockResolvedValue(true)) {
function renderCommentInput(onSubmit = vi.fn().mockResolvedValue(undefined)) {
const view = renderWithProviders(<CommentInput issueId="issue-1" onSubmit={onSubmit} />);
return { ...view, onSubmit };
}
function renderReplyInput({
onSubmit = vi.fn().mockResolvedValue(true),
onSubmit = vi.fn().mockResolvedValue(undefined),
size = "sm",
}: {
onSubmit?: (content: string, attachmentIds?: string[], suppressAgentIds?: string[]) => Promise<boolean>;
onSubmit?: (content: string, attachmentIds?: string[], suppressAgentIds?: string[]) => Promise<void>;
size?: "sm" | "default";
} = {}) {
const view = renderWithProviders(
@@ -184,42 +184,4 @@ describe("comment composers", () => {
expect(onSubmit).toHaveBeenCalledWith("thread reply", undefined, undefined);
});
});
it("locks the editor while the send is in flight, then clears on success", async () => {
let resolveSubmit: (ok: boolean) => void = () => {};
const onSubmit = vi.fn(
() => new Promise<boolean>((resolve) => { resolveSubmit = resolve; }),
);
const { container } = renderCommentInput(onSubmit);
fireEvent.change(screen.getByTestId("editor"), { target: { value: "sending" } });
fireEvent.click(getSubmitButton(container));
// In flight: text kept, editor wrapper locked (aria-busy), not cleared yet.
await waitFor(() =>
expect(screen.getByTestId("editor").closest("[aria-busy]")).toHaveAttribute(
"aria-busy",
"true",
),
);
expect(onSubmit).toHaveBeenCalledWith("sending", undefined, undefined);
resolveSubmit(true);
// Success: the composer clears (now empty → submit disabled, lock released).
await waitFor(() => expect(getSubmitButton(container)).toBeDisabled());
expect(screen.getByTestId("editor").closest("[aria-busy]")).toBeNull();
});
it("keeps the draft when the send fails (no optimistic clear)", async () => {
const onSubmit = vi.fn().mockResolvedValue(false);
const { container } = renderCommentInput(onSubmit);
fireEvent.change(screen.getByTestId("editor"), { target: { value: "will fail" } });
fireEvent.click(getSubmitButton(container));
await waitFor(() => expect(onSubmit).toHaveBeenCalled());
// Failed send must NOT clear — the box still has content, submit stays live.
await waitFor(() => expect(getSubmitButton(container)).not.toBeDisabled());
});
});

View File

@@ -1,7 +1,6 @@
"use client";
import { useRef, useState, useCallback, useEffect } from "react";
import { cn } from "@multica/ui/lib/utils";
import { ContentEditor, type ContentEditorRef, useFileDropZone, FileDropOverlay } from "../../editor";
import { FileUploadButton } from "@multica/ui/components/common/file-upload-button";
import { SubmitButton } from "@multica/ui/components/common/submit-button";
@@ -17,10 +16,7 @@ import { useCommentTriggerPreview } from "../hooks/use-comment-trigger-preview";
interface CommentInputProps {
issueId: string;
/** Resolves true on success, false on failure. The composer keeps the text
* (editor locked + button spinning) until this settles, then clears only on
* success — a failed send must not silently discard the user's draft. */
onSubmit: (content: string, attachmentIds?: string[], suppressAgentIds?: string[]) => Promise<boolean>;
onSubmit: (content: string, attachmentIds?: string[], suppressAgentIds?: string[]) => Promise<void>;
}
function CommentInput({ issueId, onSubmit }: CommentInputProps) {
@@ -109,26 +105,19 @@ function CommentInput({ issueId, onSubmit }: CommentInputProps) {
const suppressAgentIds = triggerPreview.agents
.filter((agent) => suppressedAgentIds.has(agent.id))
.map((agent) => agent.id);
// Pessimistic submit: keep the text in place (the editor is locked and the
// button spins via `submitting`) until the server actually accepts it, then
// clear. Clearing only on success means a slow send no longer looks like
// "comment posted but the box is still full", and a failed send keeps the
// draft instead of silently dropping it.
setSubmitting(true);
try {
const ok = await onSubmit(
await onSubmit(
content,
activeIds.length > 0 ? activeIds : undefined,
suppressAgentIds.length > 0 ? suppressAgentIds : undefined,
);
if (ok) {
editorRef.current?.clearContent();
setContent("");
setIsEmpty(true);
setSuppressedAgentIds(new Set());
setPendingAttachments([]);
clearDraft(draftKey);
}
editorRef.current?.clearContent();
setContent("");
setIsEmpty(true);
setSuppressedAgentIds(new Set());
setPendingAttachments([]);
clearDraft(draftKey);
} finally {
setSubmitting(false);
}
@@ -139,17 +128,7 @@ function CommentInput({ issueId, onSubmit }: CommentInputProps) {
{...dropZoneProps}
className="relative flex flex-col rounded-lg bg-card pb-8 ring-1 ring-border"
>
{/* Lock the editor while the send is in flight. ContentEditor can't
toggle Tiptap's `editable` post-mount (see its docstring), so the
documented way to make it non-interactive is a pointer-events-none +
dimmed wrapper. */}
<div
className={cn(
"flex-1 min-h-0 overflow-y-auto px-3 py-2",
submitting && "pointer-events-none opacity-60",
)}
aria-busy={submitting || undefined}
>
<div className="flex-1 min-h-0 overflow-y-auto px-3 py-2">
<ContentEditor
ref={editorRef}
defaultValue={initialDraft}

View File

@@ -844,26 +844,6 @@ describe("IssueDetail (shared)", () => {
expect(screen.getByText(/changed priority/i)).toBeInTheDocument();
});
it("renders activity rows with unknown status values without crashing", async () => {
mockApiObj.listTimeline.mockResolvedValue([
{
type: "activity",
id: "act-unknown-status",
actor_type: "member",
actor_id: "user-1",
action: "status_changed",
details: { from: "todo", to: "mystery_status" },
created_at: "2026-01-18T00:00:00Z",
},
] as TimelineEntry[]);
renderIssueDetail();
await waitFor(() => {
expect(screen.getByText(/from Todo to mystery_status/i)).toBeInTheDocument();
});
});
it("truncates the trailing activity block to the most recent 8 entries with a show-more toggle", async () => {
// 10 activities, all in the trailing block (no comment after them, so it's
// the trailing block by definition). Alternating action types so the

View File

@@ -6,14 +6,14 @@ export function PriorityIcon({
className = "",
inheritColor = false,
}: {
priority: IssuePriority | string;
priority: IssuePriority;
className?: string;
inheritColor?: boolean;
}) {
const cfg = priority in PRIORITY_CONFIG ? PRIORITY_CONFIG[priority as IssuePriority] : null;
const cfg = PRIORITY_CONFIG[priority];
// "none" — simple horizontal dashes
if (!cfg || cfg.bars === 0) {
if (cfg.bars === 0) {
return (
<svg
viewBox="0 0 16 16"

View File

@@ -26,9 +26,7 @@ interface ReplyInputProps {
placeholder?: string;
avatarType: string;
avatarId: string;
/** Resolves true on success, false on failure — the reply box keeps its text
* (locked + spinning) until then, clearing only on success. */
onSubmit: (content: string, attachmentIds?: string[], suppressAgentIds?: string[]) => Promise<boolean>;
onSubmit: (content: string, attachmentIds?: string[], suppressAgentIds?: string[]) => Promise<void>;
size?: "sm" | "default";
/** When set, hydrates/persists the in-progress reply via the draft store.
* Required for replies inside virtualized timeline threads, where the
@@ -130,23 +128,19 @@ function ReplyInput({
const suppressAgentIds = triggerPreview.agents
.filter((agent) => suppressedAgentIds.has(agent.id))
.map((agent) => agent.id);
// Pessimistic submit (see CommentInput): keep the text, lock + spin, clear
// only once the server accepts it.
setSubmitting(true);
try {
const ok = await onSubmit(
await onSubmit(
content,
activeIds.length > 0 ? activeIds : undefined,
suppressAgentIds.length > 0 ? suppressAgentIds : undefined,
);
if (ok) {
editorRef.current?.clearContent();
setContent("");
setIsEmpty(true);
setSuppressedAgentIds(new Set());
setPendingAttachments([]);
if (draftKey) clearDraft(draftKey);
}
editorRef.current?.clearContent();
setContent("");
setIsEmpty(true);
setSuppressedAgentIds(new Set());
setPendingAttachments([]);
if (draftKey) clearDraft(draftKey);
} finally {
setSubmitting(false);
}
@@ -169,14 +163,7 @@ function ReplyInput({
!isEmpty && "pb-9",
)}
>
{/* Lock the editor while the reply is in flight — see CommentInput. */}
<div
className={cn(
"flex-1 min-h-0 overflow-y-auto",
submitting && "pointer-events-none opacity-60",
)}
aria-busy={submitting || undefined}
>
<div className="flex-1 min-h-0 overflow-y-auto">
<ContentEditor
ref={editorRef}
defaultValue={initialDraft}

View File

@@ -1,22 +0,0 @@
// @vitest-environment jsdom
import { render } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { PriorityIcon } from "./priority-icon";
import { StatusIcon } from "./status-icon";
describe("issue icons", () => {
it("renders a muted fallback for unknown status values", () => {
const { container } = render(<StatusIcon status="unexpected_status" />);
const icon = container.querySelector("svg");
expect(icon).toHaveClass("text-muted-foreground");
});
it("renders a muted fallback for unknown priority values", () => {
const { container } = render(<PriorityIcon priority="unexpected_priority" />);
const icon = container.querySelector("svg");
expect(icon).toHaveClass("text-muted-foreground");
});
});

View File

@@ -162,19 +162,18 @@ export function StatusIcon({
className = "h-4 w-4",
inheritColor = false,
}: {
status: IssueStatus | string;
status: IssueStatus;
className?: string;
inheritColor?: boolean;
}) {
const knownStatus = status in STATUS_RENDERERS ? (status as IssueStatus) : null;
const cfg = knownStatus ? STATUS_CONFIG[knownStatus] : null;
const Renderer = knownStatus ? STATUS_RENDERERS[knownStatus] : TodoIcon;
const cfg = STATUS_CONFIG[status];
const Renderer = STATUS_RENDERERS[status];
return (
<svg
viewBox="0 0 14 14"
fill="none"
className={`${className} ${inheritColor ? "" : cfg?.iconColor ?? "text-muted-foreground"} shrink-0`}
className={`${className} ${inheritColor ? "" : cfg.iconColor} shrink-0`}
>
<Renderer />
</svg>

View File

@@ -258,31 +258,25 @@ export function useIssueTimeline(issueId: string, userId?: string) {
// --- Mutation functions ---
// Returns true on success, false on failure. The composer keeps the user's
// text (editor locked + button spinning) until this settles and clears only
// on success — so a slow send no longer leaves the box full next to an
// already-posted comment, and a failed send keeps the draft.
const submitComment = useCallback(
async (content: string, attachmentIds?: string[], suppressAgentIds?: string[]): Promise<boolean> => {
if (!content.trim() || !userId) return false;
async (content: string, attachmentIds?: string[], suppressAgentIds?: string[]) => {
if (!content.trim() || !userId) return;
try {
await createComment({ content, attachmentIds, suppressAgentIds });
return true;
} catch (err) {
toast.error(
err instanceof Error && err.message
? err.message
: t(($) => $.comment.send_failed),
);
return false;
}
},
[userId, createComment, t],
);
const submitReply = useCallback(
async (parentId: string, content: string, attachmentIds?: string[], suppressAgentIds?: string[]): Promise<boolean> => {
if (!content.trim() || !userId) return false;
async (parentId: string, content: string, attachmentIds?: string[], suppressAgentIds?: string[]) => {
if (!content.trim() || !userId) return;
try {
await createComment({
content,
@@ -291,14 +285,12 @@ export function useIssueTimeline(issueId: string, userId?: string) {
attachmentIds,
suppressAgentIds,
});
return true;
} catch (err) {
toast.error(
err instanceof Error && err.message
? err.message
: t(($) => $.comment.send_reply_failed),
);
return false;
}
},
[userId, createComment, t],

View File

@@ -12,8 +12,7 @@
"placeholder_default": "Start a message…",
"send_tooltip": "Send",
"stop_tooltip": "Stop",
"send_failed_toast": "Failed to send message",
"attachment_bind_failed_toast": "Message sent, but files were not attached. Please try again in a moment."
"send_failed_toast": "Failed to send message"
},
"message_list": {
"show_details": "Show details",

View File

@@ -11,8 +11,7 @@
"placeholder_default": "メッセージを入力…",
"send_tooltip": "送信",
"stop_tooltip": "停止",
"send_failed_toast": "メッセージを送信できませんでした",
"attachment_bind_failed_toast": "メッセージは送信されましたが、ファイルを添付できませんでした。しばらくしてからもう一度お試しください。"
"send_failed_toast": "メッセージを送信できませんでした"
},
"message_list": {
"show_details": "詳細を表示",

View File

@@ -12,8 +12,7 @@
"placeholder_default": "메시지 입력…",
"send_tooltip": "보내기",
"stop_tooltip": "중지",
"send_failed_toast": "메시지를 보내지 못했습니다",
"attachment_bind_failed_toast": "메시지는 보냈지만 파일이 첨부되지 않았습니다. 잠시 후 다시 시도해 주세요."
"send_failed_toast": "메시지를 보내지 못했습니다"
},
"message_list": {
"show_details": "세부 정보 보기",

View File

@@ -11,8 +11,7 @@
"placeholder_default": "输入消息…",
"send_tooltip": "发送",
"stop_tooltip": "停止",
"send_failed_toast": "发送消息失败",
"attachment_bind_failed_toast": "消息已发送,但文件未能附加。请稍后重试。"
"send_failed_toast": "发送消息失败"
},
"message_list": {
"show_details": "查看详情",

View File

@@ -162,7 +162,6 @@ func init() {
agentCreateCmd.Flags().String("runtime-id", "", "Runtime ID (required)")
agentCreateCmd.Flags().String("runtime-config", "", "Runtime config as JSON string")
agentCreateCmd.Flags().String("model", "", "Model identifier (e.g. claude-sonnet-4-6, openai/gpt-4o). Prefer this over passing --model in --custom-args.")
agentCreateCmd.Flags().String("thinking-level", "", "Reasoning/effort level for the agent's runtime (e.g. Claude: low|medium|high|xhigh|max; Codex: none|minimal|low|medium|high|xhigh). The set is runtime/model-specific and validated server-side — an unknown value is rejected. Empty = runtime default.")
agentCreateCmd.Flags().String("custom-args", "", "Custom CLI arguments as JSON array. For model selection prefer --model; some providers (codex app-server, openclaw) reject --model in custom_args.")
agentCreateCmd.Flags().String("custom-env", "", "Custom environment variables as JSON object, e.g. '{\"KEY\":\"value\"}'. Treated as secret material — never logged by the CLI, but values passed on the command line are visible to shell history and 'ps'; prefer --custom-env-stdin or --custom-env-file for real secrets. Pass '{}' to set an empty map.")
agentCreateCmd.Flags().Bool("custom-env-stdin", false, "Read the --custom-env JSON object from stdin. Keeps secrets out of shell history and 'ps'. Mutually exclusive with --custom-env and --custom-env-file.")
@@ -181,7 +180,6 @@ func init() {
agentUpdateCmd.Flags().String("runtime-id", "", "New runtime ID")
agentUpdateCmd.Flags().String("runtime-config", "", "New runtime config as JSON string")
agentUpdateCmd.Flags().String("model", "", "New model identifier. Pass an empty string to clear and fall back to the runtime default.")
agentUpdateCmd.Flags().String("thinking-level", "", "New reasoning/effort level for the agent's runtime (e.g. Claude: low|medium|high|xhigh|max; Codex: none|minimal|low|medium|high|xhigh). The set is runtime/model-specific and validated server-side. Pass an empty string to clear and fall back to the runtime default.")
agentUpdateCmd.Flags().String("custom-args", "", "New custom CLI arguments as JSON array. For model selection prefer --model; some providers (codex app-server, openclaw) reject --model in custom_args.")
// custom_env is intentionally NOT part of `agent update`. Use
// `multica agent env set <id>` — that path is owner/admin-only,
@@ -471,14 +469,6 @@ func runAgentCreate(cmd *cobra.Command, _ []string) error {
v, _ := cmd.Flags().GetString("model")
body["model"] = v
}
// thinking_level mirrors model: a thin pass-through to the top-level agent
// field the server already accepts and validates (IsKnownThinkingValue).
// The CLI deliberately does not enumerate valid levels — they are
// runtime/model-specific and the server owns the catalog (MUL-2339).
if cmd.Flags().Changed("thinking-level") {
v, _ := cmd.Flags().GetString("thinking-level")
body["thinking_level"] = v
}
if cmd.Flags().Changed("visibility") {
v, _ := cmd.Flags().GetString("visibility")
body["visibility"] = v
@@ -548,13 +538,6 @@ func runAgentUpdate(cmd *cobra.Command, args []string) error {
v, _ := cmd.Flags().GetString("model")
body["model"] = v
}
// thinking_level is a tri-state on the server (omitted = no change, "" =
// clear to runtime default, value = set). Sending the key only when the
// flag was provided produces exactly that, the same way --model behaves.
if cmd.Flags().Changed("thinking-level") {
v, _ := cmd.Flags().GetString("thinking-level")
body["thinking_level"] = v
}
if cmd.Flags().Changed("visibility") {
v, _ := cmd.Flags().GetString("visibility")
body["visibility"] = v
@@ -574,7 +557,7 @@ func runAgentUpdate(cmd *cobra.Command, args []string) error {
}
if len(body) == 0 {
return fmt.Errorf("no fields to update; use --name, --description, --instructions, --runtime-id, --runtime-config, --model, --thinking-level, --custom-args, --mcp-config, --visibility, --status, or --max-concurrent-tasks (env vars now live behind `multica agent env set <id>`)")
return fmt.Errorf("no fields to update; use --name, --description, --instructions, --runtime-id, --runtime-config, --model, --custom-args, --mcp-config, --visibility, --status, or --max-concurrent-tasks (env vars now live behind `multica agent env set <id>`)")
}
ctx, cancel := cli.APIContext(context.Background())

View File

@@ -1284,201 +1284,3 @@ func TestAgentGetTableIncludesAvatarURL(t *testing.T) {
t.Fatalf("table output missing avatar_url value: %s", string(out))
}
}
// TestAgentCreateSendsThinkingLevel verifies `agent create --thinking-level`
// puts the value on the top-level `thinking_level` key of the POST body —
// the same field the web inspector and HTTP API already accept. The value is
// passed through verbatim; provider-level validation is the server's job
// (IsKnownThinkingValue), exactly as `--model` defers model validation.
func TestAgentCreateSendsThinkingLevel(t *testing.T) {
var gotMethod, gotPath string
var gotBody map[string]any
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotMethod = r.Method
gotPath = r.URL.Path
if err := json.NewDecoder(r.Body).Decode(&gotBody); err != nil {
t.Errorf("decode request body: %v", err)
}
json.NewEncoder(w).Encode(map[string]any{"id": "agent-123", "name": "TestAgent", "thinking_level": "high"})
}))
defer srv.Close()
t.Setenv("MULTICA_SERVER_URL", srv.URL)
t.Setenv("MULTICA_WORKSPACE_ID", "ws-1")
t.Setenv("MULTICA_TOKEN", "test-token")
t.Setenv("MULTICA_AGENT_ID", "")
t.Setenv("MULTICA_TASK_ID", "")
cmd := &cobra.Command{Use: "create"}
cmd.Flags().String("name", "", "")
cmd.Flags().String("runtime-id", "", "")
cmd.Flags().String("description", "", "")
cmd.Flags().String("instructions", "", "")
cmd.Flags().String("thinking-level", "", "")
cmd.Flags().String("output", "json", "")
cmd.Flags().String("profile", "", "")
_ = cmd.Flags().Set("name", "TestAgent")
_ = cmd.Flags().Set("runtime-id", "runtime-1")
_ = cmd.Flags().Set("thinking-level", "high")
if err := runAgentCreate(cmd, nil); err != nil {
t.Fatalf("runAgentCreate: %v", err)
}
if gotMethod != http.MethodPost {
t.Fatalf("method = %s, want POST", gotMethod)
}
if gotPath != "/api/agents" {
t.Fatalf("path = %q, want /api/agents", gotPath)
}
if gotBody["thinking_level"] != "high" {
t.Fatalf("thinking_level body = %v, want high", gotBody["thinking_level"])
}
}
// TestAgentCreateOmitsThinkingLevelWhenUnset guards the Changed-gated send:
// an unset --thinking-level must not appear in the body at all, so the server
// applies its default instead of receiving an explicit empty string.
func TestAgentCreateOmitsThinkingLevelWhenUnset(t *testing.T) {
var gotBody map[string]any
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if err := json.NewDecoder(r.Body).Decode(&gotBody); err != nil {
t.Errorf("decode request body: %v", err)
}
json.NewEncoder(w).Encode(map[string]any{"id": "agent-123", "name": "TestAgent"})
}))
defer srv.Close()
t.Setenv("MULTICA_SERVER_URL", srv.URL)
t.Setenv("MULTICA_WORKSPACE_ID", "ws-1")
t.Setenv("MULTICA_TOKEN", "test-token")
t.Setenv("MULTICA_AGENT_ID", "")
t.Setenv("MULTICA_TASK_ID", "")
cmd := &cobra.Command{Use: "create"}
cmd.Flags().String("name", "", "")
cmd.Flags().String("runtime-id", "", "")
cmd.Flags().String("thinking-level", "", "")
cmd.Flags().String("output", "json", "")
cmd.Flags().String("profile", "", "")
_ = cmd.Flags().Set("name", "TestAgent")
_ = cmd.Flags().Set("runtime-id", "runtime-1")
if err := runAgentCreate(cmd, nil); err != nil {
t.Fatalf("runAgentCreate: %v", err)
}
if _, ok := gotBody["thinking_level"]; ok {
t.Fatalf("unset --thinking-level must be omitted from the body; got %v", gotBody)
}
}
// TestAgentUpdateSendsThinkingLevel covers both update modes that mirror
// --model: setting an explicit level, and passing an empty string to clear
// back to the runtime default. In both cases the key must be present in the
// PUT body — the server reads it as a tri-state pointer (omitted = no change,
// "" = clear, value = set), so the CLI's only job is to send the key when the
// flag was provided.
func TestAgentUpdateSendsThinkingLevel(t *testing.T) {
cases := []struct {
name string
value string
}{
{"set explicit level", "xhigh"},
{"empty string clears", ""},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
var gotMethod, gotPath string
var gotBody map[string]any
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotMethod = r.Method
gotPath = r.URL.Path
if err := json.NewDecoder(r.Body).Decode(&gotBody); err != nil {
t.Errorf("decode request body: %v", err)
}
json.NewEncoder(w).Encode(map[string]any{"id": "agent-123", "name": "TestAgent", "thinking_level": tc.value})
}))
defer srv.Close()
t.Setenv("MULTICA_SERVER_URL", srv.URL)
t.Setenv("MULTICA_WORKSPACE_ID", "ws-1")
t.Setenv("MULTICA_TOKEN", "test-token")
t.Setenv("MULTICA_AGENT_ID", "")
t.Setenv("MULTICA_TASK_ID", "")
cmd := &cobra.Command{Use: "update"}
cmd.Flags().String("thinking-level", "", "")
cmd.Flags().String("output", "json", "")
cmd.Flags().String("profile", "", "")
if err := cmd.Flags().Set("thinking-level", tc.value); err != nil {
t.Fatal(err)
}
if err := runAgentUpdate(cmd, []string{"agent-123"}); err != nil {
t.Fatalf("runAgentUpdate: %v", err)
}
if gotMethod != http.MethodPut {
t.Fatalf("method = %s, want PUT", gotMethod)
}
if gotPath != "/api/agents/agent-123" {
t.Fatalf("path = %q, want /api/agents/agent-123", gotPath)
}
v, ok := gotBody["thinking_level"]
if !ok {
t.Fatalf("body missing thinking_level key; got %v", gotBody)
}
if v != tc.value {
t.Fatalf("thinking_level body = %v, want %q", v, tc.value)
}
})
}
}
// TestAgentCreateAndUpdateExposeThinkingLevelFlag guarantees the flag stays
// wired on both write surfaces. The read side (`agent get`) already exposes
// thinking_level; this is the matching write surface (#4170).
func TestAgentCreateAndUpdateExposeThinkingLevelFlag(t *testing.T) {
if agentCreateCmd.Flag("thinking-level") == nil {
t.Error("agent create must expose --thinking-level")
}
if agentUpdateCmd.Flag("thinking-level") == nil {
t.Error("agent update must expose --thinking-level")
}
}
// TestAgentCreateThinkingLevelServerRejectionSurfaces proves the CLI does not
// own thinking-level validation: a runtime whose provider has no thinking
// concept (or an unknown literal) is rejected server-side with a 400, and that
// message must reach the user rather than being swallowed. This is why the CLI
// can stay a thin pass-through — the server already owns the (provider, model)
// catalog (server/pkg/agent/thinking.go).
func TestAgentCreateThinkingLevelServerRejectionSurfaces(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusBadRequest)
io.WriteString(w, `{"error":"thinking_level \"max\" is not a recognised value for runtime \"gemini\""}`)
}))
defer srv.Close()
t.Setenv("MULTICA_SERVER_URL", srv.URL)
t.Setenv("MULTICA_WORKSPACE_ID", "ws-1")
t.Setenv("MULTICA_TOKEN", "test-token")
t.Setenv("MULTICA_AGENT_ID", "")
t.Setenv("MULTICA_TASK_ID", "")
cmd := &cobra.Command{Use: "create"}
cmd.Flags().String("name", "", "")
cmd.Flags().String("runtime-id", "", "")
cmd.Flags().String("thinking-level", "", "")
cmd.Flags().String("output", "json", "")
cmd.Flags().String("profile", "", "")
_ = cmd.Flags().Set("name", "TestAgent")
_ = cmd.Flags().Set("runtime-id", "runtime-gemini")
_ = cmd.Flags().Set("thinking-level", "max")
err := runAgentCreate(cmd, nil)
if err == nil {
t.Fatal("expected error when server rejects thinking_level, got nil")
}
if !strings.Contains(err.Error(), "not a recognised value for runtime") {
t.Fatalf("server thinking_level rejection should surface to the user; got: %v", err)
}
}

View File

@@ -441,162 +441,6 @@ func TestCancelTaskByUser_ChatTaskWithoutTranscript_RestoresUserDraft(t *testing
}
}
// TestCancelTaskByUser_ChatTaskWithBoundAttachment_SurvivesCancelAndRebinds
// guards the data-loss path on the empty-chat cancel: the user message bound to
// an attachment is deleted, and attachment.chat_message_id is ON DELETE CASCADE
// (server/migrations/083_attachment_chat_columns.up.sql), so without the
// detach-before-delete step the cancel would silently destroy the user's
// attachment. The detach (chat_message_id -> NULL, chat_session_id retained) is
// load-bearing, not an optimization; nothing else covered it. This pins:
//
// (a) the attachment row survives the cascade — still present, chat_message_id
// NULL, chat_session_id retained;
// (b) the cancel response returns it via cancelled_chat_message.attachments so
// the restored draft can re-show it;
// (c) re-sending the restored draft re-binds the surviving attachment to the
// new message in the same session.
func TestCancelTaskByUser_ChatTaskWithBoundAttachment_SurvivesCancelAndRebinds(t *testing.T) {
if testHandler == nil {
t.Skip("database not available")
}
agentID := createHandlerTestAgent(t, "CancelChatAttachAgent", []byte("[]"))
sessionID := createHandlerTestChatSession(t, agentID)
var taskID string
if err := testPool.QueryRow(context.Background(), `
INSERT INTO agent_task_queue (agent_id, runtime_id, status, priority, issue_id, chat_session_id)
VALUES ($1, (SELECT runtime_id FROM agent WHERE id = $1), 'running', 0, NULL, $2)
RETURNING id
`, agentID, sessionID).Scan(&taskID); err != nil {
t.Fatalf("create chat task: %v", err)
}
t.Cleanup(func() { testPool.Exec(context.Background(), `DELETE FROM agent_task_queue WHERE id = $1`, taskID) })
var userMessageID string
const userContent = "look at this attachment"
if err := testPool.QueryRow(context.Background(), `
INSERT INTO chat_message (chat_session_id, role, content, task_id)
VALUES ($1, 'user', $2, $3)
RETURNING id
`, sessionID, userContent, taskID).Scan(&userMessageID); err != nil {
t.Fatalf("create linked user chat message: %v", err)
}
// Bind an attachment to that user message, exactly as a real send does:
// workspace-scoped, uploaded by the session creator, pointing at both the
// session and the message.
var attachmentID string
if err := testPool.QueryRow(context.Background(), `
INSERT INTO attachment (workspace_id, uploader_type, uploader_id, filename, url, content_type, size_bytes, chat_session_id, chat_message_id)
VALUES ($1, 'member', $2, 'cancel-survive.png', 'https://cdn.example.com/cancel-survive.png', 'image/png', 9, $3, $4)
RETURNING id::text
`, testWorkspaceID, testUserID, sessionID, userMessageID).Scan(&attachmentID); err != nil {
t.Fatalf("seed bound attachment: %v", err)
}
t.Cleanup(func() { testPool.Exec(context.Background(), `DELETE FROM attachment WHERE id = $1`, attachmentID) })
// Cancel the empty chat task (no transcript) — this deletes the user message.
w := httptest.NewRecorder()
testHandler.CancelTaskByUser(w, cancelTaskByUserRequest(t, testUserID, taskID))
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
}
var resp CancelTaskByUserResponse
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode cancel response: %v", err)
}
if resp.CancelledChatMessage == nil {
t.Fatal("expected restore payload for empty transcript cancel")
}
// (b) The cancel response carries the detached attachment back.
var returned *AttachmentResponse
for i := range resp.CancelledChatMessage.Attachments {
if resp.CancelledChatMessage.Attachments[i].ID == attachmentID {
returned = &resp.CancelledChatMessage.Attachments[i]
break
}
}
if returned == nil {
t.Fatalf("cancel response did not return the detached attachment: %#v", resp.CancelledChatMessage.Attachments)
}
// (a) The row survived the ON DELETE CASCADE: still present, detached from
// the deleted message, but still scoped to the session.
var count int
if err := testPool.QueryRow(context.Background(),
`SELECT count(*) FROM attachment WHERE id = $1`, attachmentID,
).Scan(&count); err != nil {
t.Fatalf("count attachment: %v", err)
}
if count != 1 {
t.Fatalf("attachment was cascade-deleted on cancel: count = %d", count)
}
var dbMessageID, dbSessionID *string
if err := testPool.QueryRow(context.Background(),
`SELECT chat_message_id::text, chat_session_id::text FROM attachment WHERE id = $1`, attachmentID,
).Scan(&dbMessageID, &dbSessionID); err != nil {
t.Fatalf("read attachment after cancel: %v", err)
}
if dbMessageID != nil {
t.Fatalf("expected chat_message_id detached to NULL, got %q", *dbMessageID)
}
if dbSessionID == nil || *dbSessionID != sessionID {
t.Fatalf("expected chat_session_id retained as %q, got %v", sessionID, dbSessionID)
}
// Sanity: the empty-cancel still deleted the user message itself.
if err := testPool.QueryRow(context.Background(),
`SELECT count(*) FROM chat_message WHERE id = $1`, userMessageID,
).Scan(&count); err != nil {
t.Fatalf("count user message: %v", err)
}
if count != 0 {
t.Fatalf("expected linked user message to be deleted, got %d", count)
}
// (c) Re-sending the restored draft re-binds the surviving attachment to a
// fresh message in the same session — the whole reason for detaching.
sendReq := newRequest("POST", "/api/chat-sessions/"+sessionID+"/messages", map[string]any{
"content": userContent,
"attachment_ids": []string{attachmentID},
})
sendReq = withURLParam(sendReq, "sessionId", sessionID)
sendReq = withChatTestWorkspaceCtx(t, sendReq)
sendW := httptest.NewRecorder()
testHandler.SendChatMessage(sendW, sendReq)
if sendW.Code != http.StatusCreated {
t.Fatalf("resend: expected 201, got %d: %s", sendW.Code, sendW.Body.String())
}
var sendResp SendChatMessageResponse
if err := json.Unmarshal(sendW.Body.Bytes(), &sendResp); err != nil {
t.Fatalf("decode resend response: %v", err)
}
t.Cleanup(func() {
testPool.Exec(context.Background(), `DELETE FROM agent_task_queue WHERE id = $1`, sendResp.TaskID)
})
rebound := false
for _, id := range sendResp.AttachmentIDs {
if id == attachmentID {
rebound = true
break
}
}
if !rebound {
t.Fatalf("attachment not re-bound on resend: %#v", sendResp.AttachmentIDs)
}
if err := testPool.QueryRow(context.Background(),
`SELECT chat_message_id::text FROM attachment WHERE id = $1`, attachmentID,
).Scan(&dbMessageID); err != nil {
t.Fatalf("read attachment after resend: %v", err)
}
if dbMessageID == nil || *dbMessageID != sendResp.MessageID {
t.Fatalf("expected attachment re-bound to new message %q, got %v", sendResp.MessageID, dbMessageID)
}
}
// TestCancelTaskByUser_PrivateAgent_PlainMember_Returns403 verifies the cancel
// endpoint mirrors the agent Activity / snapshot visibility gate: a plain
// member who cannot see a private agent's tasks cannot cancel them either.

View File

@@ -386,16 +386,6 @@ type SendChatMessageRequest struct {
type SendChatMessageResponse struct {
MessageID string `json:"message_id"`
TaskID string `json:"task_id"`
// AttachmentIDs are the attachment rows actually bound to this message by
// the server. The client diffs these against the ids it requested so it
// can warn the user when an attachment silently failed to bind — no extra
// round-trip needed. No `omitempty`: a send that requested attachments but
// bound none must serialize `[]` (not be omitted), otherwise the client
// can't tell "all binds failed" from "older server without this field" and
// would silently skip the very warning this exists for. When no
// attachments were requested the value is nil → `null`, which the client's
// guard short-circuits on the requested-ids check.
AttachmentIDs []string `json:"attachment_ids"`
// CreatedAt anchors the chat StatusPill timer the instant the user
// hits send. Without it the front-end falls back to its local clock
// and the timer "snaps backwards" later when WS events deliver the
@@ -459,31 +449,20 @@ func (h *Handler) SendChatMessage(w http.ResponseWriter, r *http.Request) {
return
}
// Back-fill chat_message_id on attachments the sender uploaded while
// composing. New clients upload workspace-scoped unattached rows and bind
// them here; older clients may still upload against the chat_session_id.
// The query accepts both shapes, but only for this workspace, this actor,
// and rows that are not already linked to an issue/comment/message.
var boundAttachmentIDs []string
// Back-fill chat_message_id on attachments that were uploaded against
// this session while the user was composing. The query only touches rows
// where chat_session_id matches AND chat_message_id IS NULL, so it cannot
// rebind an attachment that already belongs to an earlier message.
if len(attachmentIDs) > 0 {
actorType, actorID := h.resolveActor(r, userID, workspaceID)
bound, err := h.Queries.LinkAttachmentsToChatMessage(r.Context(), db.LinkAttachmentsToChatMessageParams{
if err := h.Queries.LinkAttachmentsToChatMessage(r.Context(), db.LinkAttachmentsToChatMessageParams{
ChatMessageID: msg.ID,
ChatSessionID: session.ID,
WorkspaceID: session.WorkspaceID,
UploaderType: actorType,
UploaderID: parseUUID(actorID),
AttachmentIds: attachmentIDs,
})
if err != nil {
Column3: attachmentIDs,
}); err != nil {
// Don't fail the send — the message content is already saved and
// the attachments remain on the session (still downloadable).
slog.Warn("link chat attachments failed", "error", err, "message_id", uuidToString(msg.ID))
}
boundAttachmentIDs = make([]string, 0, len(bound))
for _, id := range bound {
boundAttachmentIDs = append(boundAttachmentIDs, uuidToString(id))
}
}
// Enqueue a chat task after the message exists. For web chat the sender is
@@ -537,10 +516,9 @@ func (h *Handler) SendChatMessage(w http.ResponseWriter, r *http.Request) {
})
writeJSON(w, http.StatusCreated, SendChatMessageResponse{
MessageID: uuidToString(msg.ID),
TaskID: uuidToString(task.ID),
CreatedAt: timestampToString(task.CreatedAt),
AttachmentIDs: boundAttachmentIDs,
MessageID: uuidToString(msg.ID),
TaskID: uuidToString(task.ID),
CreatedAt: timestampToString(task.CreatedAt),
})
}
@@ -736,11 +714,10 @@ type PendingChatTaskItem struct {
}
type CancelledChatMessageResponse struct {
ChatSessionID string `json:"chat_session_id"`
MessageID string `json:"message_id"`
Content string `json:"content"`
RestoreToInput bool `json:"restore_to_input"`
Attachments []AttachmentResponse `json:"attachments,omitempty"`
ChatSessionID string `json:"chat_session_id"`
MessageID string `json:"message_id"`
Content string `json:"content"`
RestoreToInput bool `json:"restore_to_input"`
}
type CancelTaskByUserResponse struct {
@@ -937,16 +914,11 @@ func (h *Handler) CancelTaskByUser(w http.ResponseWriter, r *http.Request) {
AgentTaskResponse: taskToResponse(cancelled.Task, workspaceID),
}
if cancelled.CancelledChatMessage != nil {
attachments := make([]AttachmentResponse, 0, len(cancelled.CancelledChatMessage.Attachments))
for _, a := range cancelled.CancelledChatMessage.Attachments {
attachments = append(attachments, h.attachmentToResponse(a))
}
resp.CancelledChatMessage = &CancelledChatMessageResponse{
ChatSessionID: cancelled.CancelledChatMessage.ChatSessionID,
MessageID: cancelled.CancelledChatMessage.MessageID,
Content: cancelled.CancelledChatMessage.Content,
RestoreToInput: cancelled.CancelledChatMessage.RestoreToInput,
Attachments: attachments,
}
}

View File

@@ -123,81 +123,6 @@ func TestSendChatMessage_LinksAttachments(t *testing.T) {
}
}
// TestSendChatMessage_LinksUnattachedAttachments verifies the new compose
// path: upload creates a workspace-scoped unattached attachment, and chat send
// binds it to both the session and the user message.
func TestSendChatMessage_LinksUnattachedAttachments(t *testing.T) {
origStorage := testHandler.Storage
testHandler.Storage = &mockStorage{}
defer func() { testHandler.Storage = origStorage }()
agentID := createHandlerTestAgent(t, "ChatSendUnattachedAttachAgent", []byte("[]"))
sessionID := createHandlerTestChatSession(t, agentID)
var body bytes.Buffer
writer := multipart.NewWriter(&body)
part, _ := writer.CreateFormFile("file", "send-unattached.png")
part.Write([]byte("\x89PNG\r\n\x1a\nbytes"))
writer.Close()
uploadReq := httptest.NewRequest("POST", "/api/upload-file", &body)
uploadReq.Header.Set("Content-Type", writer.FormDataContentType())
uploadReq.Header.Set("X-User-ID", testUserID)
uploadReq.Header.Set("X-Workspace-ID", testWorkspaceID)
uploadW := httptest.NewRecorder()
testHandler.UploadFile(uploadW, uploadReq)
if uploadW.Code != http.StatusOK {
t.Fatalf("upload precondition: %d %s", uploadW.Code, uploadW.Body.String())
}
var uploadResp AttachmentResponse
if err := json.Unmarshal(uploadW.Body.Bytes(), &uploadResp); err != nil {
t.Fatalf("decode upload: %v", err)
}
attachmentID := uploadResp.ID
t.Cleanup(func() {
testPool.Exec(context.Background(), `DELETE FROM attachment WHERE id = $1`, attachmentID)
})
if uploadResp.ChatSessionID != nil {
t.Fatalf("pre-send chat_session_id should be nil, got %v", *uploadResp.ChatSessionID)
}
if uploadResp.ChatMessageID != nil {
t.Fatalf("pre-send chat_message_id should be nil, got %v", *uploadResp.ChatMessageID)
}
sendReq := newRequest("POST", "/api/chat-sessions/"+sessionID+"/messages", map[string]any{
"content": "look at this ![](" + uploadResp.MarkdownURL + ")",
"attachment_ids": []string{attachmentID},
})
sendReq = withURLParam(sendReq, "sessionId", sessionID)
sendReq = withChatTestWorkspaceCtx(t, sendReq)
sendW := httptest.NewRecorder()
testHandler.SendChatMessage(sendW, sendReq)
if sendW.Code != http.StatusCreated {
t.Fatalf("SendChatMessage: expected 201, got %d: %s", sendW.Code, sendW.Body.String())
}
var sendResp SendChatMessageResponse
if err := json.Unmarshal(sendW.Body.Bytes(), &sendResp); err != nil {
t.Fatalf("decode send: %v", err)
}
var dbSessionID, dbMessageID *string
if err := testPool.QueryRow(
context.Background(),
`SELECT chat_session_id::text, chat_message_id::text FROM attachment WHERE id = $1`,
attachmentID,
).Scan(&dbSessionID, &dbMessageID); err != nil {
t.Fatalf("query attachment: %v", err)
}
if dbSessionID == nil || *dbSessionID != sessionID {
t.Fatalf("chat_session_id mismatch: want %s, got %v", sessionID, dbSessionID)
}
if dbMessageID == nil || *dbMessageID != sendResp.MessageID {
t.Fatalf("chat_message_id mismatch: want %s, got %v", sendResp.MessageID, dbMessageID)
}
}
// TestUpdateChatSession_RenamesTitle confirms PATCH writes the new title,
// returns the updated row, and the server-side row reflects it.
func TestUpdateChatSession_RenamesTitle(t *testing.T) {

View File

@@ -58,8 +58,8 @@ multica agent create --name <name> --runtime-id <runtime-id> \
`runAgentCreate` builds a JSON body and posts it to `/api/agents`. It only
adds a key when its flag was provided — `description`/`instructions` on a
non-empty value, the rest (`runtime-config`, `custom-args`, `model`,
`thinking-level`, `visibility`, …) on the flag being `Changed` — so omitted
flags fall through to server defaults rather than sending empty strings.
`visibility`, …) on the flag being `Changed` — so omitted flags fall through
to server defaults rather than sending empty strings.
The HTTP body (`CreateAgentRequest`) accepts: `name`, `description`,
`instructions`, `runtime_id`, `runtime_config`, `custom_env`, `custom_args`,
@@ -93,16 +93,6 @@ literal returns 400, but a value that is valid for the provider yet
unsupported for the chosen model is NOT rejected here — that gap surfaces as a
daemon-side task error at execution time.
Set it from the CLI with `--thinking-level` on `agent create` and `agent
update`, mirroring `--model`: the flag is a thin pass-through to the top-level
`thinking_level` field, and on update an empty string (`--thinking-level ""`)
clears it back to the runtime default. The CLI deliberately does not enumerate
the valid levels — they are runtime/model-specific (Claude
`low|medium|high|xhigh|max`, Codex `none|minimal|low|medium|high|xhigh`, and
others), so it forwards whatever you pass and lets the server's provider
catalog accept or reject it. A runtime whose provider has no thinking concept
rejects any non-empty value with a 400.
### model vs custom_args
`model` is a first-class persisted column the daemon reads directly.

View File

@@ -19,20 +19,19 @@ go test ./internal/service -run TestBuiltinSkillsConformToTemplate
| Contract | Line | Behavior | Safe check |
|---|---|---|---|
| Create flags: `name`, `description`, `instructions`, `runtime-id` | 159162 | Registered create flags; `name`/`runtime-id` enforced in `runAgentCreate` | `multica agent create --help` |
| `runtime-config`, `model`, `thinking-level`, `custom-args` flags | 163166 | `model` help: "Prefer this over passing --model in --custom-args"; `thinking-level` is a thin pass-through (server validates the provider enum, empty = runtime default); `custom-args` help names codex/openclaw rejecting `--model` (CLI help only, not server-enforced) | `multica agent create --help` |
| Secret-safe env input: `custom-env`, `custom-env-stdin`, `custom-env-file` | 167169 | `--custom-env` warns about shell history / `ps`; stdin and file modes keep secrets off the command line; mutually exclusive | `multica agent create --help` |
| Secret-safe MCP input: `mcp-config`, `mcp-config-stdin`, `mcp-config-file` (create) | 170172 | Same three-channel pattern as `custom-env`; `--mcp-config` warns about shell history / `ps`; value must be a JSON object or `null` | `multica agent create --help` |
| MCP flags on `agent update` | 194196 | Same three channels on update; `--mcp-config null` clears. Unlike `custom_env`, `mcp_config` IS settable via update | `multica agent update --help` |
| `thinking-level` flag on `agent update` | 184 | New reasoning/effort level; thin pass-through; `--thinking-level ""` clears to runtime default (mirrors `--model`) | `multica agent update --help` |
| `runAgentCreate` builds body + `POST /api/agents` | 419 | Only sets a body key when the flag `Changed`; posts to `/api/agents` (line 495) | read 419496 |
| Body assembly: description/instructions/runtime-config/custom-args/custom-env/mcp-config/model/thinking-level | 438488 | `resolveCustomEnv` (460) and `resolveMcpConfig` (465) gate their secret channels; `model` (470) and `thinking_level` (478) are `Changed`-gated pass-throughs; omitted flags are not sent | read 438488 |
| `runAgentUpdate` sends `thinking_level` / `mcp_config` | 508 | `thinking_level` added when `--thinking-level` is `Changed` (556); `resolveMcpConfig` adds `mcp_config` (570); `PUT /api/agents/{id}` at 584; `custom_env` is intentionally not a flag here | read 508585 |
| `parseMcpConfig` / `resolveMcpConfig` helpers | 1086, 1114 | Validator (object-or-`null`, content-free errors) + three-channel resolver, mirroring `parseCustomEnv`/`resolveCustomEnv` | read 10861170 |
| `agent skills set` = replace-all | 792 | `PUT /api/agents/{id}/skills` (810); `--skill-ids ''` clears all (798799) | `multica agent skills set --help` |
| `agent skills add` = additive | 817 | `POST /api/agents/{id}/skills/add` (838); requires ≥1 id (823828) | `multica agent skills add --help` |
| `agent skills list` | 760 | reads bindings, no side effect | `multica agent skills list --help` |
| `agent env get` | 894 | `GET /api/agents/{id}/env` | `multica agent env get --help` |
| `agent env set` | 929 | `PUT /api/agents/{id}/env` with full `custom_env` map (935, 949) | `multica agent env set --help` |
| `runtime-config`, `model`, `custom-args` flags | 163165 | `model` help: "Prefer this over passing --model in --custom-args"; `custom-args` help names codex/openclaw rejecting `--model` (CLI help only, not server-enforced) | `multica agent create --help` |
| Secret-safe env input: `custom-env`, `custom-env-stdin`, `custom-env-file` | 166168 | `--custom-env` warns about shell history / `ps`; stdin and file modes keep secrets off the command line; mutually exclusive | `multica agent create --help` |
| Secret-safe MCP input: `mcp-config`, `mcp-config-stdin`, `mcp-config-file` (create) | 169171 | Same three-channel pattern as `custom-env`; `--mcp-config` warns about shell history / `ps`; value must be a JSON object or `null` | `multica agent create --help` |
| MCP flags on `agent update` | 192194 | Same three channels on update; `--mcp-config null` clears. Unlike `custom_env`, `mcp_config` IS settable via update | `multica agent update --help` |
| `runAgentCreate` builds body + `POST /api/agents` | 414 | Only sets a body key when the flag `Changed`; posts to `/api/agents` (line 482) | read 414489 |
| Body assembly: description/instructions/runtime-config/custom-args/custom-env/mcp-config/model | 437478 | `resolveCustomEnv` (455) and `resolveMcpConfig` (460) gate their secret channels; omitted flags are not sent | read 437478 |
| `runAgentUpdate` sends `mcp_config` | 550 | `resolveMcpConfig` adds `mcp_config` to the `PUT /api/agents/{id}` body (564); `custom_env` is intentionally not a flag here | read 495565 |
| `parseMcpConfig` / `resolveMcpConfig` helpers | 1066, 1094 | Validator (object-or-`null`, content-free errors) + three-channel resolver, mirroring `parseCustomEnv`/`resolveCustomEnv` | read 10661150 |
| `agent skills set` = replace-all | 772 | `PUT /api/agents/{id}/skills` (790); `--skill-ids ''` clears all (779) | `multica agent skills set --help` |
| `agent skills add` = additive | 797 | `POST /api/agents/{id}/skills/add` (818); requires ≥1 id (804, 808) | `multica agent skills add --help` |
| `agent skills list` | 740 | reads bindings, no side effect | `multica agent skills list --help` |
| `agent env get` | 874 | `GET /api/agents/{id}/env` | `multica agent env get --help` |
| `agent env set` | 909 | `PUT /api/agents/{id}/env` with full `custom_env` map (923, 929) | `multica agent env set --help` |
Note: the CLI no longer exposes `--from-template`. The agent-template backend
still exists (registry `server/internal/agenttmpl/`, handler `agent_template.go`,

View File

@@ -825,10 +825,6 @@ type CancelledChatMessageResult struct {
MessageID string
Content string
RestoreToInput bool
// Attachments are the rows detached from the deleted user message so they
// survive the ON DELETE CASCADE and can re-bind when the restored draft is
// re-sent.
Attachments []db.Attachment
}
type CancelTaskResult struct {
@@ -888,13 +884,6 @@ func (s *TaskService) finalizeCancelledChatMessage(ctx context.Context, task db.
return fmt.Errorf("list cancelled chat task messages: %w", err)
}
if len(messages) == 0 {
// Detach attachments BEFORE deleting the user message — the
// attachment FK is ON DELETE CASCADE, so deleting first would
// destroy rows the restored draft needs to re-bind.
detached, err := qtx.DetachAttachmentsFromUserChatMessageByTask(ctx, task.ID)
if err != nil {
return fmt.Errorf("detach cancelled chat message attachments: %w", err)
}
deleted, err := qtx.DeleteUserChatMessageByTask(ctx, task.ID)
if errors.Is(err, pgx.ErrNoRows) {
return nil
@@ -907,7 +896,6 @@ func (s *TaskService) finalizeCancelledChatMessage(ctx context.Context, task db.
MessageID: util.UUIDToString(deleted.ID),
Content: deleted.Content,
RestoreToInput: true,
Attachments: detached,
}
return nil
}

View File

@@ -84,54 +84,6 @@ func (q *Queries) DeleteAttachment(ctx context.Context, arg DeleteAttachmentPara
return err
}
const detachAttachmentsFromUserChatMessageByTask = `-- name: DetachAttachmentsFromUserChatMessageByTask :many
UPDATE attachment
SET chat_message_id = NULL
WHERE chat_message_id IN (
SELECT id FROM chat_message WHERE task_id = $1 AND role = 'user'
)
RETURNING id, workspace_id, issue_id, comment_id, uploader_type, uploader_id, filename, url, content_type, size_bytes, created_at, chat_session_id, chat_message_id
`
// When an empty chat task is cancelled, its user message is deleted. The
// attachment FK is ON DELETE CASCADE, so without this the bound rows would be
// destroyed and a restored draft could never re-bind them. Detach first
// (chat_message_id -> NULL, keep chat_session_id) so the rows survive as
// workspace/session-scoped unattached attachments and re-send can re-link them.
func (q *Queries) DetachAttachmentsFromUserChatMessageByTask(ctx context.Context, taskID pgtype.UUID) ([]Attachment, error) {
rows, err := q.db.Query(ctx, detachAttachmentsFromUserChatMessageByTask, taskID)
if err != nil {
return nil, err
}
defer rows.Close()
items := []Attachment{}
for rows.Next() {
var i Attachment
if err := rows.Scan(
&i.ID,
&i.WorkspaceID,
&i.IssueID,
&i.CommentID,
&i.UploaderType,
&i.UploaderID,
&i.Filename,
&i.Url,
&i.ContentType,
&i.SizeBytes,
&i.CreatedAt,
&i.ChatSessionID,
&i.ChatMessageID,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const getAttachment = `-- name: GetAttachment :one
SELECT id, workspace_id, issue_id, comment_id, uploader_type, uploader_id, filename, url, content_type, size_bytes, created_at, chat_session_id, chat_message_id FROM attachment
WHERE id = $1 AND workspace_id = $2
@@ -195,58 +147,23 @@ func (q *Queries) GetAttachmentByIDOnly(ctx context.Context, id pgtype.UUID) (At
return i, err
}
const linkAttachmentsToChatMessage = `-- name: LinkAttachmentsToChatMessage :many
const linkAttachmentsToChatMessage = `-- name: LinkAttachmentsToChatMessage :exec
UPDATE attachment
SET chat_message_id = $1,
chat_session_id = $2
WHERE workspace_id = $3
AND issue_id IS NULL
AND comment_id IS NULL
SET chat_message_id = $1
WHERE chat_session_id = $2
AND chat_message_id IS NULL
AND (
chat_session_id IS NULL
OR chat_session_id = $2
)
AND uploader_type = $4
AND uploader_id = $5
AND id = ANY($6::uuid[])
RETURNING id
AND id = ANY($3::uuid[])
`
type LinkAttachmentsToChatMessageParams struct {
ChatMessageID pgtype.UUID `json:"chat_message_id"`
ChatSessionID pgtype.UUID `json:"chat_session_id"`
WorkspaceID pgtype.UUID `json:"workspace_id"`
UploaderType string `json:"uploader_type"`
UploaderID pgtype.UUID `json:"uploader_id"`
AttachmentIds []pgtype.UUID `json:"attachment_ids"`
Column3 []pgtype.UUID `json:"column_3"`
}
func (q *Queries) LinkAttachmentsToChatMessage(ctx context.Context, arg LinkAttachmentsToChatMessageParams) ([]pgtype.UUID, error) {
rows, err := q.db.Query(ctx, linkAttachmentsToChatMessage,
arg.ChatMessageID,
arg.ChatSessionID,
arg.WorkspaceID,
arg.UploaderType,
arg.UploaderID,
arg.AttachmentIds,
)
if err != nil {
return nil, err
}
defer rows.Close()
items := []pgtype.UUID{}
for rows.Next() {
var id pgtype.UUID
if err := rows.Scan(&id); err != nil {
return nil, err
}
items = append(items, id)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
func (q *Queries) LinkAttachmentsToChatMessage(ctx context.Context, arg LinkAttachmentsToChatMessageParams) error {
_, err := q.db.Exec(ctx, linkAttachmentsToChatMessage, arg.ChatMessageID, arg.ChatSessionID, arg.Column3)
return err
}
const linkAttachmentsToComment = `-- name: LinkAttachmentsToComment :exec

View File

@@ -66,35 +66,12 @@ WHERE issue_id = $2
OR (comment_id IS NULL AND id = ANY(sqlc.arg(attachment_ids)::uuid[]))
);
-- name: LinkAttachmentsToChatMessage :many
-- name: LinkAttachmentsToChatMessage :exec
UPDATE attachment
SET chat_message_id = sqlc.arg(chat_message_id),
chat_session_id = sqlc.arg(chat_session_id)
WHERE workspace_id = sqlc.arg(workspace_id)
AND issue_id IS NULL
AND comment_id IS NULL
SET chat_message_id = $1
WHERE chat_session_id = $2
AND chat_message_id IS NULL
AND (
chat_session_id IS NULL
OR chat_session_id = sqlc.arg(chat_session_id)
)
AND uploader_type = sqlc.arg(uploader_type)
AND uploader_id = sqlc.arg(uploader_id)
AND id = ANY(sqlc.arg(attachment_ids)::uuid[])
RETURNING id;
-- name: DetachAttachmentsFromUserChatMessageByTask :many
-- When an empty chat task is cancelled, its user message is deleted. The
-- attachment FK is ON DELETE CASCADE, so without this the bound rows would be
-- destroyed and a restored draft could never re-bind them. Detach first
-- (chat_message_id -> NULL, keep chat_session_id) so the rows survive as
-- workspace/session-scoped unattached attachments and re-send can re-link them.
UPDATE attachment
SET chat_message_id = NULL
WHERE chat_message_id IN (
SELECT id FROM chat_message WHERE task_id = $1 AND role = 'user'
)
RETURNING *;
AND id = ANY($3::uuid[]);
-- name: ListAttachmentsByChatMessage :many
SELECT * FROM attachment