mirror of
https://github.com/OpenHands/OpenHands.git
synced 2026-10-07 16:38:34 +08:00
* 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>
296 lines
10 KiB
TypeScript
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"],
|
|
]);
|
|
});
|
|
});
|