mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-21 16:27:18 +02:00
fix(ui): retain first-chat uploads through task creation
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
2e5bf12411
commit
9d972c8b1e
3 files changed
+62
-10
No files matched your search
@@ -39,6 +39,17 @@ Preconditions: follow the [baseline](./README.md#before-driving-a-journey). Enab
|
||||
|
||||
Automated: [chat landing](../../ui/src/pages/AgentChats.test.tsx) checks recent-chat routing; [conversation service](../../server/src/__tests__/agent-conversations.test.ts) covers persisted conversation behavior.
|
||||
|
||||
Automated: [thread and composer](../../ui/src/components/TaskChatThread.test.tsx)
|
||||
keeps an in-flight attachment through first-chat creation on desktop and mobile,
|
||||
while initial history for an existing conversation stays deferred. The
|
||||
[first-upload browser journey](../../tests/e2e/agent-chat-projects.spec.ts)
|
||||
checks the visible file chip, server attachment, no premature agent run, and
|
||||
saved reference after send and reload. That journey uses a simulated agent.
|
||||
|
||||
Manual: In a conversation with no messages, attach a file before the first
|
||||
send. Confirm the same file stays in the composer after the chat is created.
|
||||
Send it once, reload, and confirm the file and message remain.
|
||||
|
||||
Manual: Start a conversation, send a recognizable request, and inspect its reply.
|
||||
Navigate away and reopen the same history. Use `/new` for a fresh session within
|
||||
the same standing conversation. Test an unavailable historical agent and an
|
||||
|
||||
@@ -343,6 +343,46 @@ it.each([true, false])("defers hidden content until history is ready and keeps i
|
||||
expect(container.querySelector('.task-chat-history-pending')).toBeNull();
|
||||
});
|
||||
|
||||
it("defers initial history for a saved conversation with its own draft key", () => {
|
||||
render(<TaskChatThread comments={[]} onAdd={async () => {}} issueId="saved-chat"
|
||||
conversationMode draftKey="agent-chat:company:saved-agent" initialHistoryPending />);
|
||||
expect(container.querySelector('[data-testid="mock-editor"]')).toBeNull();
|
||||
expect(container.querySelector('[data-testid="task-chat-composer-dock"]')?.hasAttribute("inert")).toBe(true);
|
||||
});
|
||||
|
||||
it.each([true, false])("keeps an upload attached when a conversation draft becomes a saved task (mobile=%s)", async (mobile) => {
|
||||
sidebarState.isMobile = mobile;
|
||||
const onAdd = vi.fn().mockResolvedValue(undefined);
|
||||
let resolveUpload!: (receipt: { id: string; contentPath: string; originalFilename: string }) => void;
|
||||
const onAttachImage = vi.fn().mockReturnValue(new Promise(resolve => { resolveUpload = resolve; }));
|
||||
const props = { comments: [], onAdd, onAttachImage, conversationMode: true, draftKey: "agent-chat:company:agent" };
|
||||
render(<TaskChatThread {...props} issueId="" />);
|
||||
const composer = container.querySelector('[data-testid="mock-editor"]');
|
||||
const input = container.querySelector<HTMLInputElement>('input[type="file"]')!;
|
||||
const file = new File(["notes"], "chat-notes.txt", { type: "text/plain" });
|
||||
Object.defineProperty(input, "files", { configurable: true, value: [file] });
|
||||
await act(async () => { input.dispatchEvent(new Event("change", { bubbles: true })); });
|
||||
expect(onAttachImage).toHaveBeenCalledExactlyOnceWith(file);
|
||||
expect(container.querySelector('[data-testid="task-chat-composer-attachments"]')?.textContent).toContain("chat-notes.txt");
|
||||
|
||||
render(<TaskChatThread {...props} issueId="persisted-chat" initialHistoryPending />);
|
||||
expect(container.querySelector('[data-testid="mock-editor"]')).toBe(composer);
|
||||
expect(container.querySelector('[data-testid="task-chat-composer-attachments"]')?.textContent).toContain("chat-notes.txt");
|
||||
expect(onAdd).not.toHaveBeenCalled();
|
||||
const id = "9af8228f-0be7-45ae-a104-6fbe0af6f1d3";
|
||||
await act(async () => { resolveUpload({ id, contentPath: `/api/attachments/${id}/content`, originalFilename: file.name }); });
|
||||
render(<TaskChatThread {...props} issueId="persisted-chat" initialHistoryPending={false} />);
|
||||
await act(async () => { await new Promise(resolve => requestAnimationFrame(resolve)); });
|
||||
const chip = container.querySelector('[data-testid="task-chat-composer-attachments"] [data-slot="attachment"]');
|
||||
expect(chip?.getAttribute("data-state")).toBe("done");
|
||||
const send = container.querySelector<HTMLButtonElement>('[data-testid="task-chat-composer-send"]')!;
|
||||
expect(send.disabled).toBe(false);
|
||||
await act(async () => { send.click(); });
|
||||
expect(onAdd).toHaveBeenCalledTimes(1);
|
||||
expect(onAdd.mock.calls[0]?.[0]).toBe(`[chat-notes.txt](/api/attachments/${id}/content)`);
|
||||
expect(onAdd.mock.calls[0]?.[3]).toEqual([id]);
|
||||
});
|
||||
|
||||
it("prepares comment-link history and its composer before reveal", async () => {
|
||||
const props = {
|
||||
issueId: "linked-issue",
|
||||
|
||||
@@ -2924,12 +2924,13 @@ export function TaskChatThread(props: TaskChatThreadProps) {
|
||||
(logTranscriptByRun.get(run.id)?.length ?? 0) === 0
|
||||
: Boolean(logErrorsByRun?.has(run.id)),
|
||||
);
|
||||
const [revealedIssue, setRevealedIssue] = useState<string | null | undefined>(
|
||||
() => (historyPending ? undefined : issueId),
|
||||
const historyKey = conversationMode && draftKey ? draftKey : issueId;
|
||||
const [revealedHistoryKey, setRevealedHistoryKey] = useState<string | null | undefined>(
|
||||
() => (historyPending ? undefined : historyKey),
|
||||
);
|
||||
const historyRevealed = revealedIssue === issueId;
|
||||
const [expiredHistoryWait, setExpiredHistoryWait] = useState<{ issueId: typeof issueId } | null>(null);
|
||||
const historyWaitExpired = expiredHistoryWait !== null && expiredHistoryWait.issueId === issueId;
|
||||
const historyRevealed = revealedHistoryKey === historyKey;
|
||||
const [expiredHistoryWait, setExpiredHistoryWait] = useState<{ historyKey: typeof historyKey } | null>(null);
|
||||
const historyWaitExpired = expiredHistoryWait !== null && expiredHistoryWait.historyKey === historyKey;
|
||||
const prepareInitialContent = Boolean(scrollNavigation.hash) || !historyPending || historyRevealed || historyWaitExpired;
|
||||
// Supporting requests can stall without rejecting. Bound the first reveal
|
||||
// independently of their pending states so saved conversation and the
|
||||
@@ -2937,19 +2938,19 @@ export function TaskChatThread(props: TaskChatThreadProps) {
|
||||
useEffect(() => {
|
||||
if (historyRevealed) return;
|
||||
const timer = window.setTimeout(() => {
|
||||
setExpiredHistoryWait({ issueId });
|
||||
setRevealedIssue(issueId);
|
||||
setExpiredHistoryWait({ historyKey });
|
||||
setRevealedHistoryKey(historyKey);
|
||||
}, INITIAL_HISTORY_REVEAL_TIMEOUT_MS);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [historyRevealed, issueId]);
|
||||
}, [historyRevealed, historyKey]);
|
||||
// Mount and measure the real thread while concealed, then reveal in one
|
||||
// commit. A frame also lets ancestor navigation scroll restoration finish.
|
||||
// Readiness is latched per issue: refetches never hide existing conversation.
|
||||
useEffect(() => {
|
||||
if (historyRevealed || historyPending) return;
|
||||
const frame = requestAnimationFrame(() => setRevealedIssue(issueId));
|
||||
const frame = requestAnimationFrame(() => setRevealedHistoryKey(historyKey));
|
||||
return () => cancelAnimationFrame(frame);
|
||||
}, [historyPending, historyRevealed, issueId]);
|
||||
}, [historyPending, historyRevealed, historyKey]);
|
||||
useEffect(() => {
|
||||
if (!historyRevealed || !issueId) return;
|
||||
scheduleIssueDetailPaintMeasure(ISSUE_DETAIL_CONTENT_PAINT_MARK, ISSUE_DETAIL_CONTENT_MEASURE);
|
||||
|
||||
Reference in new issue
Block a user