Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
6ed4e27
feat(chat): merge tools menu into plus button with file upload footer
FraterCCCLXIII May 20, 2026
1d2f75e
feat(chat): support clipboard image paste on home and conversation in…
FraterCCCLXIII May 20, 2026
90883c3
feat(chat): per-pasted-image upload-as-file control on thumbnails
FraterCCCLXIII May 20, 2026
c716bc9
fix(chat): polish pasted-image upload toggle styling and tooltips
FraterCCCLXIII May 20, 2026
e8f8459
fix(chat): add bottom padding below attachment thumbnails row
FraterCCCLXIII May 20, 2026
ab0e44c
fix(chat): use surface grey for upload-as-file toggle and nudge position
FraterCCCLXIII May 20, 2026
9a44308
fix(home): defer attachment sends until cloud start task is ready
FraterCCCLXIII May 20, 2026
7ba6a26
Revert "fix(home): defer attachment sends until cloud start task is r…
FraterCCCLXIII May 20, 2026
47713d5
Reapply "fix(home): defer attachment sends until cloud start task is …
FraterCCCLXIII May 20, 2026
b6154d0
fix(chat): upload attachments into the conversation workspace
FraterCCCLXIII May 20, 2026
d0818d8
fix(cloud): route home attachments through the runtime sandbox
FraterCCCLXIII May 20, 2026
b362ac1
fix(chat): send home attachments in a single first message
FraterCCCLXIII May 21, 2026
14020bd
fix(cloud): show home submit as first message during task provisioning
FraterCCCLXIII May 21, 2026
e1feab9
fix(chat): suppress spurious older-messages errors on new conversations
FraterCCCLXIII May 21, 2026
cd6a809
fix(chat): hide history skeleton when pending message is visible
FraterCCCLXIII May 21, 2026
51d2c5e
fix(cloud): stop empty-state suggestions flashing after home submit
FraterCCCLXIII May 21, 2026
2e93dcd
fix(chat): show upload-as-file toggle on all attached images
FraterCCCLXIII May 21, 2026
ff21cdc
fix(chat): use file-plus icon for upload-as-file toggle
FraterCCCLXIII May 21, 2026
dc7bdd0
refactor: resolve merge conflicts
hieptl May 22, 2026
b136bc2
refactor: resolve merge conflicts
hieptl May 24, 2026
b1a3749
refactor: remove unrelated files
hieptl May 24, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
102 changes: 102 additions & 0 deletions __tests__/api/conversation-file-upload.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { RemoteWorkspace } from "@openhands/typescript-client/workspace/remote-workspace";
import {
__resetActiveStoreForTests,
setActiveSelection,
setRegisteredBackends,
} from "#/api/backend-registry/active-store";
import type { Backend } from "#/api/backend-registry/types";
import { uploadFilesToConversation } from "#/api/conversation-file-upload.api";

const fileUploadMock = vi.fn();

vi.mock("@openhands/typescript-client/workspace/remote-workspace", () => ({
RemoteWorkspace: vi.fn(function RemoteWorkspaceMock() {
return { fileUpload: fileUploadMock };
}),
}));

const batchGetCloudConversations = vi.fn();

vi.mock("#/api/cloud/conversation-service.api", () => ({
batchGetCloudConversations: (...args: unknown[]) =>
batchGetCloudConversations(...args),
}));

const cloudBackend: Backend = {
id: "cloud-1",
name: "Cloud",
host: "https://app.all-hands.dev",
apiKey: "cloud-token",
kind: "cloud",
};

function makeFile(name: string) {
return new File(["content"], name, { type: "text/plain" });
}

describe("uploadFilesToConversation", () => {
beforeEach(() => {
vi.clearAllMocks();
window.localStorage.clear();
__resetActiveStoreForTests();
fileUploadMock.mockResolvedValue(undefined);
batchGetCloudConversations.mockReset();
});

it("uploads local conversations through the bundled agent-server host", async () => {
setRegisteredBackends([
{
id: "local-1",
name: "Local",
host: "http://127.0.0.1:18000",
apiKey: "local-key",
kind: "local",
},
]);
setActiveSelection({ backendId: "local-1" });

const result = await uploadFilesToConversation("conv-1", [
makeFile("a.txt"),
]);

expect(fileUploadMock).toHaveBeenCalledWith(
expect.objectContaining({ name: "a.txt" }),
"/workspace/project/a.txt",
);
expect(result.uploaded_files).toEqual(["a.txt"]);
expect(batchGetCloudConversations).not.toHaveBeenCalled();
});

it("uploads cloud conversations against the provisioned runtime URL", async () => {
setRegisteredBackends([cloudBackend]);
setActiveSelection({ backendId: cloudBackend.id });
batchGetCloudConversations.mockResolvedValue([
{
id: "1717df59-63ee-43bf-b32a-83428d3efdc8",
conversation_url:
"http://runtime.example.dev/api/conversations/1717df59-63ee-43bf-b32a-83428d3efdc8",
session_api_key: "runtime-session-key",
workspace: { working_dir: "/workspace/project" },
},
]);

const conversationId = "1717df59-63ee-43bf-b32a-83428d3efdc8";
const result = await uploadFilesToConversation(conversationId, [
makeFile("notes.md"),
]);

expect(batchGetCloudConversations).toHaveBeenCalledWith([conversationId]);
expect(RemoteWorkspace).toHaveBeenCalledWith(
expect.objectContaining({
host: "http://runtime.example.dev",
apiKey: "runtime-session-key",
}),
);
expect(fileUploadMock).toHaveBeenCalledWith(
expect.objectContaining({ name: "notes.md" }),
"/workspace/project/notes.md",
);
expect(result.uploaded_files).toEqual(["notes.md"]);
});
});
21 changes: 18 additions & 3 deletions __tests__/api/conversation-service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,11 +38,11 @@ describe("ConversationService", () => {
);
expect(fileUploadMock).toHaveBeenCalledWith(
expect.objectContaining({ name: "a.txt" }),
"/workspace/a.txt",
"/workspace/project/a.txt",
);
expect(fileUploadMock).toHaveBeenCalledWith(
expect.objectContaining({ name: "b.txt" }),
"/workspace/b.txt",
"/workspace/project/b.txt",
);
expect(result).toEqual({
uploaded_files: ["a.txt", "b.txt"],
Expand All @@ -59,14 +59,29 @@ describe("ConversationService", () => {

expect(fileUploadMock).toHaveBeenCalledWith(
expect.objectContaining({ name: "../../evil.txt" }),
"/workspace/evil.txt",
"/workspace/project/evil.txt",
);
expect(result).toEqual({
uploaded_files: ["evil.txt"],
skipped_files: [],
});
});

it("uploads into the active conversation workspace when set", async () => {
ConversationService.setCurrentConversation({
id: "conv-1",
workspace: { working_dir: "/workspace/project/my-app" },
} as never);
fileUploadMock.mockResolvedValue(undefined);

await ConversationService.uploadFiles("conv-1", [makeFile("doc.txt")]);

expect(fileUploadMock).toHaveBeenCalledWith(
expect.objectContaining({ name: "doc.txt" }),
"/workspace/project/my-app/doc.txt",
);
});

it("uses the current conversation session key and reports per-file failures", async () => {
ConversationService.setCurrentConversation({
id: "conv-1",
Expand Down
53 changes: 53 additions & 0 deletions __tests__/api/workspace-upload-path.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { describe, expect, it, vi } from "vitest";
import {
buildWorkspaceUploadPath,
getSafeUploadFileName,
resolveConversationUploadWorkingDir,
toAbsoluteWorkspacePath,
} from "#/api/workspace-upload-path";

vi.mock("#/api/conversation-service/agent-server-conversation-service.api", () => ({
default: {
resolveConversationWorkingDir: vi.fn(
async (id: string) => `/workspace/project/${id.replace(/-/g, "")}`,
),
},
}));

describe("workspace-upload-path", () => {
it("normalizes relative working dirs to absolute paths", () => {
expect(toAbsoluteWorkspacePath("workspace/project")).toBe(
"/workspace/project",
);
expect(buildWorkspaceUploadPath("a.txt", "workspace/project")).toBe(
"/workspace/project/a.txt",
);
});

it("strips path segments from file names", () => {
expect(getSafeUploadFileName("../../evil.txt")).toBe("evil.txt");
expect(buildWorkspaceUploadPath("../../evil.txt", "/workspace/project")).toBe(
"/workspace/project/evil.txt",
);
});

it("prefers the active conversation workspace when ids match", async () => {
const dir = await resolveConversationUploadWorkingDir("conv-uuid", {
id: "conv-uuid",
workspace: { working_dir: "/workspace/project/custom" },
} as never);

expect(dir).toBe("/workspace/project/custom");
});

it("resolves per-conversation dirs for UUID ids", async () => {
const dir = await resolveConversationUploadWorkingDir(
"550e8400-e29b-41d4-a716-446655440000",
null,
);

expect(dir).toBe(
"/workspace/project/550e8400e29b41d4a716446655440000",
);
});
});
55 changes: 41 additions & 14 deletions __tests__/components/chat/chat-add-file-button.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,28 +4,58 @@ import { describe, expect, it, vi } from "vitest";
import { ChatAddFileButton } from "#/components/features/chat/chat-add-file-button";
import { I18nKey } from "#/i18n/declaration";

vi.mock("#/hooks/query/use-active-conversation", () => ({
useActiveConversation: () => ({ data: undefined }),
}));

vi.mock("#/hooks/use-conversation-id", () => ({
useOptionalConversationId: () => ({ conversationId: undefined }),
}));

vi.mock("#/hooks/use-conversation-name-context-menu", () => ({
useConversationNameContextMenu: () => ({
handleShowAgentTools: vi.fn(),
handleShowSkills: vi.fn(),
handleShowHooks: vi.fn(),
systemModalVisible: false,
setSystemModalVisible: vi.fn(),
skillsModalVisible: false,
setSkillsModalVisible: vi.fn(),
hooksModalVisible: false,
setHooksModalVisible: vi.fn(),
systemMessage: null,
shouldShowAgentTools: true,
shouldShowHooks: false,
}),
}));

vi.mock("#/hooks/use-user-providers", () => ({
useUserProviders: () => ({ providers: [] }),
}));

describe("ChatAddFileButton", () => {
it("uses the translated aria-label key instead of a hardcoded string", () => {
it("uses the translated aria-label for the plus menu trigger", () => {
render(<ChatAddFileButton handleFileIconClick={vi.fn()} />);

const button = screen.getByTestId("paperclip-icon");
expect(button).toHaveAttribute("aria-label", I18nKey.CHAT_INTERFACE$ADD_FILE);
expect(button).not.toHaveAttribute("aria-label", "Add file");
const button = screen.getByTestId("chat-plus-button");
expect(button).toHaveAttribute("aria-label", I18nKey.CHAT_INTERFACE$PLUS_MENU);
expect(button).toHaveAttribute("aria-haspopup", "menu");
});

it("invokes handleFileIconClick when clicked", async () => {
it("opens the tools menu and invokes handleFileIconClick from the footer item", async () => {
const user = userEvent.setup();
const handleFileIconClick = vi.fn();

render(<ChatAddFileButton handleFileIconClick={handleFileIconClick} />);

await user.click(
screen.getByLabelText(I18nKey.CHAT_INTERFACE$ADD_FILE),
);
await user.click(screen.getByTestId("chat-plus-button"));
expect(screen.getByTestId("tools-context-menu")).toBeInTheDocument();

await user.click(screen.getByTestId("add-files-and-images-button"));
expect(handleFileIconClick).toHaveBeenCalledTimes(1);
});

it("disables the button and suppresses clicks when disabled", async () => {
it("does not open the menu or invoke the file handler when disabled", async () => {
const user = userEvent.setup();
const handleFileIconClick = vi.fn();

Expand All @@ -36,14 +66,11 @@ describe("ChatAddFileButton", () => {
/>,
);

const button = screen.getByTestId("paperclip-icon");
const button = screen.getByTestId("chat-plus-button");
expect(button).toBeDisabled();
expect(button).toHaveAttribute(
"aria-label",
I18nKey.CHAT_INTERFACE$ADD_FILE,
);

await user.click(button);
expect(screen.queryByTestId("tools-context-menu")).not.toBeInTheDocument();
expect(handleFileIconClick).not.toHaveBeenCalled();
});
});
Loading
Loading