mirror of
https://github.com/multica-ai/multica.git
synced 2026-06-17 11:48:42 +02:00
Compare commits
1 Commits
main
...
fix/chat-i
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f6f90ad87f |
@@ -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({
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 ";
|
||||
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 ",
|
||||
["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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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)}`}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -11,7 +11,8 @@
|
||||
"placeholder_default": "メッセージを入力…",
|
||||
"send_tooltip": "送信",
|
||||
"stop_tooltip": "停止",
|
||||
"send_failed_toast": "メッセージを送信できませんでした"
|
||||
"send_failed_toast": "メッセージを送信できませんでした",
|
||||
"attachment_bind_failed_toast": "メッセージは送信されましたが、ファイルを添付できませんでした。しばらくしてからもう一度お試しください。"
|
||||
},
|
||||
"message_list": {
|
||||
"show_details": "詳細を表示",
|
||||
|
||||
@@ -12,7 +12,8 @@
|
||||
"placeholder_default": "메시지 입력…",
|
||||
"send_tooltip": "보내기",
|
||||
"stop_tooltip": "중지",
|
||||
"send_failed_toast": "메시지를 보내지 못했습니다"
|
||||
"send_failed_toast": "메시지를 보내지 못했습니다",
|
||||
"attachment_bind_failed_toast": "메시지는 보냈지만 파일이 첨부되지 않았습니다. 잠시 후 다시 시도해 주세요."
|
||||
},
|
||||
"message_list": {
|
||||
"show_details": "세부 정보 보기",
|
||||
|
||||
@@ -11,7 +11,8 @@
|
||||
"placeholder_default": "输入消息…",
|
||||
"send_tooltip": "发送",
|
||||
"stop_tooltip": "停止",
|
||||
"send_failed_toast": "发送消息失败"
|
||||
"send_failed_toast": "发送消息失败",
|
||||
"attachment_bind_failed_toast": "消息已发送,但文件未能附加。请稍后重试。"
|
||||
},
|
||||
"message_list": {
|
||||
"show_details": "查看详情",
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 ",
|
||||
"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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user