Compare commits

...

1 Commits

Author SHA1 Message Date
Naiyuan Qing
f6f90ad87f feat(chat): workspace-scoped attachment binding + fire-and-forget send
Uploads are now workspace-scoped: the chat session is created and
attachments are bound to the message at send time, so a paste/drop no
longer creates an empty session the user never sends.

- LinkAttachmentsToChatMessage returns the ids it actually bound; the
  client diffs requested-vs-bound and warns on partial bind, replacing
  an extra listChatMessagesPage fetch.
- Cancelling an empty chat task detaches attachments before deleting the
  user message (attachment FK is ON DELETE CASCADE) and returns them via
  cancelled_chat_message.attachments, so a restored draft can re-bind.
- SendChatMessageResponse.attachment_ids has no omitempty: "requested but
  bound zero" serializes [] so the client can tell it apart from an older
  server and still warn.
- Send is fire-and-forget: it no longer steals focus when the user has
  navigated to another session (guarded on the live store + new-chat agent
  id); the reply surfaces via the unread dot. commitInput gets clearEditor
  so a navigated-away commit doesn't wipe the editor now showing another
  session, while still clearing the sent draft's data.
- Draft restore is session-aware so a failed fire-and-forget send restores
  into the session it was sent from, never the one the user moved to.
- Removed the now-unreferenced migrateInputDraft store action.

Verified: core/views typecheck, chat-input (15) / store (3) / api client
(24) unit tests, go build + vet, handler SendChatMessage + CancelTaskByUser
DB tests. Full make check / E2E left to CI.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 17:20:49 +08:00
16 changed files with 780 additions and 168 deletions

View File

@@ -467,6 +467,9 @@ 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,6 +1,7 @@
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>();
@@ -15,6 +16,26 @@ 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");
@@ -22,38 +43,31 @@ describe("newSessionDraftKey", () => {
});
});
describe("chat store — migrateInputDraft", () => {
describe("chat store — draft attachments", () => {
let store: ReturnType<typeof createChatStore>;
beforeEach(() => {
store = createChatStore({ storage: memStorage() });
});
it("moves a draft to the new key and clears the source", () => {
const from = newSessionDraftKey("agent-1");
store.getState().setInputDraft(from, "!file[x.pdf]()");
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",
});
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);
expect(store.getState().inputDraftAttachments["draft-1"]).toHaveLength(1);
expect(store.getState().inputDraftAttachments["draft-1"]?.[0]?.filename).toBe("updated.png");
});
it("is a no-op when the source draft is absent", () => {
store.getState().setInputDraft("session-1", "keep me");
it("clearInputDraft clears both text and attachment records", () => {
store.getState().setInputDraft("draft-1", "hello");
store.getState().addInputDraftAttachment("draft-1", makeAttachment("att-1"));
store.getState().migrateInputDraft(newSessionDraftKey("agent-1"), "session-1");
store.getState().clearInputDraft("draft-1");
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");
expect(store.getState().inputDrafts["draft-1"]).toBeUndefined();
expect(store.getState().inputDraftAttachments["draft-1"]).toBeUndefined();
});
});

View File

@@ -1,5 +1,6 @@
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";
@@ -9,6 +10,8 @@ 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__";
@@ -57,6 +60,49 @@ 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;
@@ -83,6 +129,8 @@ 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;
@@ -93,15 +141,9 @@ 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;
@@ -130,6 +172,7 @@ 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",
@@ -165,30 +208,40 @@ 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 current = get().inputDrafts;
if (!(sessionId in current)) {
const currentDrafts = get().inputDrafts;
const currentAttachments = get().inputDraftAttachments;
if (!(sessionId in currentDrafts) && !(sessionId in currentAttachments)) {
logger.debug("clearInputDraft skipped (no draft)", { sessionId });
return;
}
logger.info("clearInputDraft", { sessionId });
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 });
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 });
},
setChatSize: (w, h) => {
logger.debug("setChatSize", { w, h });
@@ -213,17 +266,20 @@ 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

@@ -79,6 +79,13 @@ 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 {
@@ -86,6 +93,11 @@ 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,6 +39,9 @@ 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 }) => {
@@ -69,9 +72,12 @@ vi.mock("../../editor", () => ({
useImperativeHandle(ref, () => ({
getMarkdown: () => valueRef.current,
clearContent: () => {
editorState.cleared += 1;
valueRef.current = "";
},
blur: () => {},
blur: () => {
editorState.blurred += 1;
},
focus: () => {},
uploadFile: async (file: File) => {
uploadingRef.current += 1;
@@ -116,7 +122,10 @@ 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 {
@@ -133,21 +142,49 @@ 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>> = {}) {
@@ -223,6 +260,10 @@ 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 () => {
@@ -379,12 +420,12 @@ describe("ChatInput async send", () => {
);
});
it("keeps the draft while send is pending and clears after acceptance", async () => {
it("keeps the draft while send is pending until the owner commits the handoff", async () => {
let resolveSend: (accepted: boolean) => void;
const sendPromise = new Promise<boolean>((res) => {
resolveSend = res;
});
const onSend = vi.fn(() => sendPromise);
const onSend = vi.fn<ChatInputOnSend>(() => sendPromise);
renderInput({ onSend });
fireEvent.change(screen.getByTestId("editor"), { target: { value: "slow network" } });
@@ -398,18 +439,29 @@ describe("ChatInput async send", () => {
fireEvent.click(sendButton!);
expect(onSend).toHaveBeenCalledWith("slow network", undefined);
expect(onSend).toHaveBeenCalledWith(
"slow network",
undefined,
expect.any(Function),
[],
);
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;
});
await waitFor(() => {
expect(useChatStore.getState().clearInputDraft).toHaveBeenCalledWith("__draft_new__:agent-1");
});
expect(useChatStore.getState().clearInputDraft).toHaveBeenCalledTimes(2);
});
it("keeps the draft when send is rejected by the owner", async () => {
@@ -430,7 +482,148 @@ describe("ChatInput async send", () => {
await Promise.resolve();
});
expect(onSend).toHaveBeenCalledWith("retry me", undefined);
expect(onSend).toHaveBeenCalledWith("retry me", undefined, expect.any(Function), []);
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,13 +16,50 @@ 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[]) => void | boolean | Promise<void | boolean>;
restoreDraftRequest?: { id: string; content: string } | null;
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;
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
@@ -88,7 +125,12 @@ 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);
@@ -97,6 +139,7 @@ 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
@@ -122,7 +165,20 @@ export function ChatInput({
const uploadMapRef = useRef<Map<string, string>>(new Map());
useEffect(() => {
if (!restoreDraftRequest) return;
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 (inputDraft.trim()) {
logger.info("input.restore skipped: draft already has content", {
draftKey,
@@ -132,6 +188,7 @@ export function ChatInput({
return;
}
setInputDraft(draftKey, restoreDraftRequest.content);
setInputDraftAttachments(draftKey, restoreDraftRequest.attachments ?? []);
setIsEmpty(!restoreDraftRequest.content.trim());
setEditorRestore({
id: restoreDraftRequest.id,
@@ -139,7 +196,14 @@ export function ChatInput({
draftKey,
});
onRestoreDraftConsumed?.();
}, [draftKey, inputDraft, onRestoreDraftConsumed, restoreDraftRequest, setInputDraft]);
}, [
draftKey,
inputDraft,
onRestoreDraftConsumed,
restoreDraftRequest,
setInputDraft,
setInputDraftAttachments,
]);
const handleUpload = useCallback(
async (file: File): Promise<UploadResult | null> => {
@@ -150,13 +214,14 @@ 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));
}
},
[onUploadFile],
[addInputDraftAttachment, draftKey, onUploadFile],
);
// Drop zone wraps the rounded card so a drop anywhere on the input
@@ -195,38 +260,69 @@ 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: activeIds.length,
attachmentCount: uniqueActiveIds.length,
});
setIsSubmitting(true);
let accepted: void | boolean;
try {
accepted = await onSend(content, activeIds.length > 0 ? activeIds : undefined);
accepted = await onSend(
content,
uniqueActiveIds.length > 0 ? uniqueActiveIds : undefined,
commitInput,
draftAttachments.filter((attachment) => uniqueActiveIds.includes(attachment.id)),
);
} catch (err) {
logger.warn("input.send failed", err);
setIsSubmitting(false);
if (!committed) setIsSubmitting(false);
return;
}
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);
if (accepted === false) {
if (!committed) setIsSubmitting(false);
return;
}
if (!committed) commitInput();
};
const placeholder = noAgent
@@ -275,9 +371,18 @@ 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}
@@ -307,6 +412,7 @@ 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,39 +44,20 @@ import {
useMarkChatSessionRead,
useUpdateChatSession,
} from "@multica/core/chat/mutations";
import { useChatStore, newSessionDraftKey } from "@multica/core/chat";
import { useChatStore } 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, ChatMessage, ChatMessagesPage, ChatPendingTask, ChatSession, PendingChatTasksResponse } from "@multica/core/types";
import type { Agent, Attachment, 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,
@@ -186,7 +167,6 @@ 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));
@@ -230,6 +210,8 @@ export function ChatWindow() {
const [restoreDraftRequest, setRestoreDraftRequest] = useState<{
id: string;
content: string;
attachments?: Attachment[];
sessionId?: string;
} | null>(null);
const handleRestoreDraftConsumed = useCallback(() => {
setRestoreDraftRequest(null);
@@ -371,40 +353,14 @@ export function ChatWindow() {
const handleUploadFile = useCallback(
async (file: 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 });
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);
},
[
activeSessionId,
ensureSession,
migrateInputDraft,
selectedAgentId,
uploadWithToast,
qc,
setActiveSession,
],
[activeAgent, uploadWithToast],
);
const cancelChatTask = useCallback(
@@ -429,6 +385,8 @@ export function ChatWindow() {
setRestoreDraftRequest({
id: restored.message_id,
content: restored.content,
attachments: restored.attachments,
sessionId: restored.chat_session_id,
});
}
}
@@ -455,7 +413,12 @@ export function ChatWindow() {
);
const handleSend = useCallback(
async (content: string, attachmentIds?: string[]): Promise<boolean> => {
async (
content: string,
attachmentIds?: string[],
commitInput?: (options?: { extraDraftKeys?: string[]; clearEditor?: boolean }) => void,
draftAttachments: Attachment[] = [],
): Promise<boolean> => {
if (!activeAgent) {
apiLogger.warn("sendChatMessage skipped: no active agent");
return false;
@@ -499,6 +462,7 @@ 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
@@ -521,9 +485,22 @@ export function ChatWindow() {
status: "queued",
created_at: sentAt,
});
// Cache primed → safe to publish the new active session. Idempotent
// when the session was already active (existing-conversation send).
setActiveSession(sessionId);
// 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 });
apiLogger.debug("sendChatMessage.optimistic", { sessionId, optimisticId: optimistic.id });
let result;
@@ -534,6 +511,15 @@ 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;
}
@@ -559,12 +545,29 @@ 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

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

View File

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

View File

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

View File

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

View File

@@ -386,6 +386,16 @@ 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
@@ -449,20 +459,31 @@ func (h *Handler) SendChatMessage(w http.ResponseWriter, r *http.Request) {
return
}
// 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.
// 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
if len(attachmentIDs) > 0 {
if err := h.Queries.LinkAttachmentsToChatMessage(r.Context(), db.LinkAttachmentsToChatMessageParams{
actorType, actorID := h.resolveActor(r, userID, workspaceID)
bound, err := h.Queries.LinkAttachmentsToChatMessage(r.Context(), db.LinkAttachmentsToChatMessageParams{
ChatMessageID: msg.ID,
ChatSessionID: session.ID,
Column3: attachmentIDs,
}); err != nil {
WorkspaceID: session.WorkspaceID,
UploaderType: actorType,
UploaderID: parseUUID(actorID),
AttachmentIds: attachmentIDs,
})
if 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
@@ -516,9 +537,10 @@ 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),
MessageID: uuidToString(msg.ID),
TaskID: uuidToString(task.ID),
CreatedAt: timestampToString(task.CreatedAt),
AttachmentIDs: boundAttachmentIDs,
})
}
@@ -714,10 +736,11 @@ 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"`
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"`
}
type CancelTaskByUserResponse struct {
@@ -914,11 +937,16 @@ 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,6 +123,81 @@ 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

@@ -825,6 +825,10 @@ 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 {
@@ -884,6 +888,13 @@ 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
@@ -896,6 +907,7 @@ 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,6 +84,54 @@ 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
@@ -147,23 +195,58 @@ func (q *Queries) GetAttachmentByIDOnly(ctx context.Context, id pgtype.UUID) (At
return i, err
}
const linkAttachmentsToChatMessage = `-- name: LinkAttachmentsToChatMessage :exec
const linkAttachmentsToChatMessage = `-- name: LinkAttachmentsToChatMessage :many
UPDATE attachment
SET chat_message_id = $1
WHERE chat_session_id = $2
SET chat_message_id = $1,
chat_session_id = $2
WHERE workspace_id = $3
AND issue_id IS NULL
AND comment_id IS NULL
AND chat_message_id IS NULL
AND id = ANY($3::uuid[])
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
`
type LinkAttachmentsToChatMessageParams struct {
ChatMessageID pgtype.UUID `json:"chat_message_id"`
ChatSessionID pgtype.UUID `json:"chat_session_id"`
Column3 []pgtype.UUID `json:"column_3"`
WorkspaceID pgtype.UUID `json:"workspace_id"`
UploaderType string `json:"uploader_type"`
UploaderID pgtype.UUID `json:"uploader_id"`
AttachmentIds []pgtype.UUID `json:"attachment_ids"`
}
func (q *Queries) LinkAttachmentsToChatMessage(ctx context.Context, arg LinkAttachmentsToChatMessageParams) error {
_, err := q.db.Exec(ctx, linkAttachmentsToChatMessage, arg.ChatMessageID, arg.ChatSessionID, arg.Column3)
return err
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
}
const linkAttachmentsToComment = `-- name: LinkAttachmentsToComment :exec

View File

@@ -66,12 +66,35 @@ WHERE issue_id = $2
OR (comment_id IS NULL AND id = ANY(sqlc.arg(attachment_ids)::uuid[]))
);
-- name: LinkAttachmentsToChatMessage :exec
-- name: LinkAttachmentsToChatMessage :many
UPDATE attachment
SET chat_message_id = $1
WHERE chat_session_id = $2
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
AND chat_message_id IS NULL
AND id = ANY($3::uuid[]);
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 *;
-- name: ListAttachmentsByChatMessage :many
SELECT * FROM attachment