Files
OpenHands/__tests__/stores/optimistic-user-message-store.test.ts
e49f6d721e feat(chat): attachment UX, upload-as-file, and home/cloud submit fixes (#712)
* feat(chat): merge tools menu into plus button with file upload footer

Combine the chat tools dropdown with the + control and add an
Add Files and Images action with a paperclip icon at the bottom.

Co-authored-by: Cursor <cursoragent@cursor.com>

* feat(chat): support clipboard image paste on home and conversation inputs

Read pasted screenshots from clipboard items, wire the home launcher
through the shared attachment upload flow, and send first messages with
attachments after creating a conversation.

Co-authored-by: Cursor <cursoragent@cursor.com>

* feat(chat): per-pasted-image upload-as-file control on thumbnails

Replace the global checkbox with a circular upload button on clipboard
pasted images, track paste source in the store, and fix overlay stacking.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(chat): polish pasted-image upload toggle styling and tooltips

Match the remove button size and corner inset, show a checkmark when
active with hover feedback, and swap the tooltip to "Do not upload as file".

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(chat): add bottom padding below attachment thumbnails row

Match the chat input container's top inset so pasted images and files
are spaced evenly above and below the thumbnail strip.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(chat): use surface grey for upload-as-file toggle and nudge position

Replace invalid primary tokens with oh-surface/oh-muted styling for both
states and raise the button slightly from the thumbnail corner.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(home): defer attachment sends until cloud start task is ready

Cloud conversation creation can return a provisional `task-{uuid}` URL
while the sandbox provisions. Sending messages or uploading files against
that id caused 422 UUID parsing errors on the home `/conversations` input.

- Queue attachments in memory keyed by start-task id when provisioning
- Flush uploads and the user message once `useTaskPolling` sees READY
- Always include typed text in the start request, even with attachments
- Add store and flush helper tests

Paste/drag on the home input were already wired; this fixes starting a
conversation with attachments (or text + attachments) on cloud backends.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Revert "fix(home): defer attachment sends until cloud start task is ready"

This reverts commit 9a443088ca5b14613c72b60459e26f1d8fb0f545.

* Reapply "fix(home): defer attachment sends until cloud start task is ready"

This reverts commit 7ba6a267cedaa67dbf08948ddcd00b9c5d6e269c.

* fix(chat): upload attachments into the conversation workspace

File uploads targeted read-only /workspace in Docker dev stacks and
cloud task flush used raw i18next before app init. Resolve the
conversation working_dir for upload paths and use the initialized
openhands i18n instance when flushing deferred attachments.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(cloud): route home attachments through the runtime sandbox

Cloud file uploads and the first message were hitting the bundled
local agent-server and 404ing. Defer home attachments until the start
task is ready, upload via the provisioned runtime URL, and send events
through the cloud proxy.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(chat): send home attachments in a single first message

Skip initial_message when starting with attachments and avoid enqueueing
optimistic duplicates after the attachment send already persisted the message.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(cloud): show home submit as first message during task provisioning

Enqueue optimistic pending messages on cloud start-task routes so the chat
shows the user's message while the sandbox provisions, hide empty-state
suggestions during provisioning, and reassign pending bubbles to the real
conversation id when the task is ready.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(chat): suppress spurious older-messages errors on new conversations

Skip auto-pagination when the initial history page is complete, on cloud
start-task routes, or while provisioning, and stop surfacing an error
banner when older events cannot be anchored.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(chat): hide history skeleton when pending message is visible

Treat optimistic home-submit bubbles as loaded content so the feed
skeleton does not flash over the user's first message on new conversations.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(cloud): stop empty-state suggestions flashing after home submit

Keep pending bubbles linked across task-to-conversation redirect, reassign
them before paint on the real route, and tighten suggestion gating so the
Let's Start Building overlay does not flash during provisioning.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(chat): show upload-as-file toggle on all attached images

Mark every attached image for the per-image upload control, not just
clipboard pastes, so file picker and drag-and-drop previews match pasted
screenshot behavior.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(chat): use file-plus icon for upload-as-file toggle

Replace the generic upload glyph with Lucide FilePlus so the per-image
control reads more clearly as "add this image as a workspace file."

Co-authored-by: Cursor <cursoragent@cursor.com>

* refactor: remove unrelated files

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: hieptl <hieptl.developer@gmail.com>
2026-05-24 17:13:10 +07:00

296 lines
10 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
PENDING_MESSAGE_TIMEOUT_MS,
useOptimisticUserMessageStore,
} from "#/stores/optimistic-user-message-store";
const CONVO = "conv-a";
describe("optimistic-user-message-store", () => {
beforeEach(() => {
vi.useFakeTimers();
useOptimisticUserMessageStore.setState({ pendingMessages: [] });
});
afterEach(() => {
vi.useRealTimers();
});
it("enqueues new messages with status 'sending' and tags them with conversationId", () => {
const store = useOptimisticUserMessageStore.getState();
const id = store.enqueuePendingMessage({
conversationId: CONVO,
text: "hello",
});
const pending = useOptimisticUserMessageStore.getState().pendingMessages;
expect(pending).toHaveLength(1);
expect(pending[0].id).toBe(id);
expect(pending[0].conversationId).toBe(CONVO);
expect(pending[0].text).toBe("hello");
expect(pending[0].status).toBe("sending");
expect(pending[0].imageUrls).toEqual([]);
expect(pending[0].fileUrls).toEqual([]);
expect(typeof pending[0].timestamp).toBe("string");
});
it("preserves FIFO order across multiple enqueues", () => {
const store = useOptimisticUserMessageStore.getState();
store.enqueuePendingMessage({ conversationId: CONVO, text: "first" });
store.enqueuePendingMessage({ conversationId: CONVO, text: "second" });
store.enqueuePendingMessage({ conversationId: CONVO, text: "third" });
const pending = useOptimisticUserMessageStore.getState().pendingMessages;
expect(pending.map((m) => m.text)).toEqual(["first", "second", "third"]);
});
it("marks a pending message as 'error' with details", () => {
const store = useOptimisticUserMessageStore.getState();
const id = store.enqueuePendingMessage({
conversationId: CONVO,
text: "broken",
});
store.markPendingMessageError(id, "boom");
const [entry] = useOptimisticUserMessageStore.getState().pendingMessages;
expect(entry.status).toBe("error");
expect(entry.errorMessage).toBe("boom");
});
it("flips an errored message back to 'sending' on retry", () => {
const store = useOptimisticUserMessageStore.getState();
const id = store.enqueuePendingMessage({
conversationId: CONVO,
text: "broken",
});
store.markPendingMessageError(id, "boom");
store.markPendingMessageSending(id);
const [entry] = useOptimisticUserMessageStore.getState().pendingMessages;
expect(entry.status).toBe("sending");
expect(entry.errorMessage).toBeUndefined();
});
it("enqueue stores `content` separately from `text` and defaults it to `text`", () => {
const store = useOptimisticUserMessageStore.getState();
const idA = store.enqueuePendingMessage({
conversationId: CONVO,
text: "hello",
});
const idB = store.enqueuePendingMessage({
conversationId: CONVO,
text: "hello",
content: "hello\n\nFiles: foo.txt",
});
const pending = useOptimisticUserMessageStore.getState().pendingMessages;
const a = pending.find((m) => m.id === idA)!;
const b = pending.find((m) => m.id === idB)!;
expect(a.content).toBe("hello");
expect(b.text).toBe("hello");
expect(b.content).toBe("hello\n\nFiles: foo.txt");
});
it("consumeMatchingPendingMessage prefers an exact content match (out-of-order echo)", () => {
const store = useOptimisticUserMessageStore.getState();
const firstId = store.enqueuePendingMessage({
conversationId: CONVO,
text: "first",
});
const secondId = store.enqueuePendingMessage({
conversationId: CONVO,
text: "second",
});
// Echo for "second" arrives before "first" — must pop "second", not the
// oldest entry. This is the case the previous FIFO-only implementation
// got wrong.
const consumed = store.consumeMatchingPendingMessage(CONVO, "second");
expect(consumed?.id).toBe(secondId);
const remaining =
useOptimisticUserMessageStore.getState().pendingMessages;
expect(remaining).toHaveLength(1);
expect(remaining[0].id).toBe(firstId);
});
it("consumeMatchingPendingMessage falls back to oldest sending entry when no exact match exists", () => {
const store = useOptimisticUserMessageStore.getState();
const firstId = store.enqueuePendingMessage({
conversationId: CONVO,
text: "hello",
});
store.enqueuePendingMessage({ conversationId: CONVO, text: "world" });
// Server munged the echo (e.g., trimmed whitespace). FIFO fallback keeps
// the bubble from getting stuck.
const consumed = store.consumeMatchingPendingMessage(
CONVO,
"something else",
);
expect(consumed?.id).toBe(firstId);
expect(
useOptimisticUserMessageStore.getState().pendingMessages,
).toHaveLength(1);
});
it("consumeMatchingPendingMessage skips entries already in 'error' state", () => {
const store = useOptimisticUserMessageStore.getState();
const firstId = store.enqueuePendingMessage({
conversationId: CONVO,
text: "first",
});
const secondId = store.enqueuePendingMessage({
conversationId: CONVO,
text: "second",
});
store.markPendingMessageError(firstId, "boom");
const consumed = store.consumeMatchingPendingMessage(CONVO, "second");
expect(consumed?.id).toBe(secondId);
const remaining =
useOptimisticUserMessageStore.getState().pendingMessages;
expect(remaining).toHaveLength(1);
expect(remaining[0].id).toBe(firstId);
expect(remaining[0].status).toBe("error");
});
it("consumeMatchingPendingMessage is a no-op when nothing is sending", () => {
const store = useOptimisticUserMessageStore.getState();
const id = store.enqueuePendingMessage({
conversationId: CONVO,
text: "broken",
});
store.markPendingMessageError(id, "boom");
const consumed = store.consumeMatchingPendingMessage(CONVO, "broken");
expect(consumed).toBeNull();
expect(
useOptimisticUserMessageStore.getState().pendingMessages,
).toHaveLength(1);
});
it("consumeMatchingPendingMessage only consumes entries for the given conversation", () => {
const store = useOptimisticUserMessageStore.getState();
const aId = store.enqueuePendingMessage({
conversationId: "conv-a",
text: "shared",
});
const bId = store.enqueuePendingMessage({
conversationId: "conv-b",
text: "shared",
});
// A cross-conversation ack for conv-b — even with identical content,
// must not pop conv-a's pending entry.
const consumed = store.consumeMatchingPendingMessage("conv-b", "shared");
expect(consumed?.id).toBe(bId);
const remaining =
useOptimisticUserMessageStore.getState().pendingMessages;
expect(remaining).toHaveLength(1);
expect(remaining[0].id).toBe(aId);
});
it("enqueuePendingMessage flips the entry to 'error' after the watchdog timeout", () => {
const store = useOptimisticUserMessageStore.getState();
const id = store.enqueuePendingMessage({
conversationId: CONVO,
text: "stuck",
});
// Still sending right after enqueue.
expect(
useOptimisticUserMessageStore.getState().pendingMessages[0].status,
).toBe("sending");
// Fire the watchdog.
vi.advanceTimersByTime(PENDING_MESSAGE_TIMEOUT_MS);
const [entry] = useOptimisticUserMessageStore.getState().pendingMessages;
expect(entry.id).toBe(id);
expect(entry.status).toBe("error");
expect(entry.errorMessage).toBe("Send timed out");
});
it("watchdog timeout does nothing if the echo already consumed the message", () => {
const store = useOptimisticUserMessageStore.getState();
store.enqueuePendingMessage({ conversationId: CONVO, text: "fast" });
store.consumeMatchingPendingMessage(CONVO, "fast");
vi.advanceTimersByTime(PENDING_MESSAGE_TIMEOUT_MS);
expect(
useOptimisticUserMessageStore.getState().pendingMessages,
).toHaveLength(0);
});
it("watchdog timeout does nothing if the message already failed via send error", () => {
const store = useOptimisticUserMessageStore.getState();
const id = store.enqueuePendingMessage({
conversationId: CONVO,
text: "explicit-error",
});
store.markPendingMessageError(id, "boom");
vi.advanceTimersByTime(PENDING_MESSAGE_TIMEOUT_MS);
const [entry] = useOptimisticUserMessageStore.getState().pendingMessages;
// Should keep the original error message, not get overwritten to "Send timed out".
expect(entry.errorMessage).toBe("boom");
});
it("removePendingMessage drops a specific entry by id", () => {
const store = useOptimisticUserMessageStore.getState();
const firstId = store.enqueuePendingMessage({
conversationId: CONVO,
text: "first",
});
store.enqueuePendingMessage({ conversationId: CONVO, text: "second" });
store.removePendingMessage(firstId);
const remaining =
useOptimisticUserMessageStore.getState().pendingMessages;
expect(remaining.map((m) => m.text)).toEqual(["second"]);
});
it("clearPendingMessages wipes the queue", () => {
const store = useOptimisticUserMessageStore.getState();
store.enqueuePendingMessage({ conversationId: CONVO, text: "first" });
store.enqueuePendingMessage({ conversationId: CONVO, text: "second" });
store.clearPendingMessages();
expect(
useOptimisticUserMessageStore.getState().pendingMessages,
).toHaveLength(0);
});
it("reassignPendingMessages moves entries from a task id to the real conversation id", () => {
const store = useOptimisticUserMessageStore.getState();
store.enqueuePendingMessage({
conversationId: "task-abc",
text: "hello",
});
store.enqueuePendingMessage({
conversationId: "other-convo",
text: "untouched",
});
store.reassignPendingMessages("task-abc", "real-convo");
const pending = useOptimisticUserMessageStore.getState().pendingMessages;
expect(pending.map((m) => [m.conversationId, m.text])).toEqual([
["real-convo", "hello"],
["other-convo", "untouched"],
]);
});
});