mirror of
https://github.com/OpenHands/OpenHands.git
synced 2026-10-07 16:38:34 +08:00
* fix(conversation): cap chat column width at 800px Replace responsive max-w-4xl / max-w-6xl with max-w-[800px] so the middle column stays narrower on large viewports. Co-authored-by: Cursor <cursoragent@cursor.com> * feat(chat): Connect Repo CTA and hide empty branch pill - Use COMMON$CONNECT_REPO with FolderOpen when no repo/workspace is linked - Show branch control only when selectedBranch is set (drop No Branch) - Cap chat interface wrapper at max-w-[800px] without right-panel width coupling Co-authored-by: Cursor <cursoragent@cursor.com> * fix(chat): refine input controls and local auth fallback Improve chat input pills and model dropdown interactions while ensuring local agent-server auth uses the configured session key for default-local and cloud-proxy calls to avoid stale-key 401s. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(chat): align attachment and placeholder control styling Move the file-attach trigger into the chat action controls so it sits before Tools, and restyle it as a grey plus button with a circular hover state to match adjacent controls. Also align the chat input placeholder color with the same neutral control tone for visual consistency. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(chat): simplify agent status labels and tone Shorten English agent-status messages for the chat pill and align the status text color with the other grey controls for a more consistent compact UI. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(chat): align model popover settings row styling Add an LLM Settings action to the model popover and normalize its layout, spacing, and divider treatment to match existing dropdown menu patterns while keeping left-aligned positioning. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(chat): restyle status controls and move send action Make the agent-status control transparent by default with gray-to-white icon hover behavior, and move the submit button to the bottom-right controls area beside agent status. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(chat): tighten spacing above git control bar Reduce the top margin before the git control bar so it better matches the bottom spacing around the chat action controls. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(chat): gate submit button on input content Keep the send button inactive until the input has non-whitespace text, and align the revised button sizing/positioning with the bottom action row layout. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(chat): streamline overlays and remove legacy event rails Unify chat control styling and overlay behavior so status/typing/scroll controls float above the thread without adding layout bars, and remove left-rail/checkmark affordances from grouped and generic event cards for a cleaner stream. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(chat): tighten status indicator spacing Reduce status indicator pill padding and icon size, and add right text padding to balance the compact layout in the chat control overlay. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(chat): prioritize centered scroll control over loader Keep the scroll-to-bottom control centered and visible whenever the user is away from the bottom, and use solid base/hover fills so it matches the updated chat surface styling. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(chat): soften conversation event header styling Use the lighter gray chat tone for conversation event header labels/icons and switch those labels to normal weight so grouped event rows match the updated control styling. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(chat): refine markdown spacing and divider styling Tighten markdown vertical rhythm in chat content, add a shared grey horizontal-rule renderer, and tune heading hierarchy to medium/compact styles for clearer structure without heavy emphasis. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(chat): tighten vertical spacing in action event rows Reduce stacked margins and paddings across grouped action rows, generic event cards, and collapsible thinking blocks so adjacent conversation entries read as a denser, more consistent stream. Co-authored-by: Cursor <cursoragent@cursor.com> * docs(design): add app gray palette reference artifacts Capture the current gray color usage in dedicated SVG references, including both a curated palette and a strict exhaustive inventory for design and UI consistency work. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(chat): align compact input overflow menus with menu conventions Keep add-file pinned inline, collapse controls only when width truly runs out, and switch overflow entries to standard context-menu row/submenu patterns while preserving the send button layout at tight widths. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(conversation-panel): use list filter icon for older filters Swap the older-conversations summary toggle icon to ListFilter so it matches the intended sidebar filter affordance. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(chat): complete local workspace launch flow in git controls Switch the local git control CTA from repository connection to workspace launching, including an above-button workspace menu and automatic add-workspace modal when none exist. This also captures the pending chat action/menu styling and test updates in the current working tree. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(chat): relocate desktop vertical padding to input controls Remove desktop top/bottom padding from the main chat panel and apply equivalent bottom spacing to the chat control area so the open repo/workspace controls and input footer keep consistent breathing room. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(conversation): remove bottom margin from chat pane header Drop the chat header bottom margin so the conversation title row sits flush with the content below. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(chat): refresh git control bar immediately after Connect Repo The "Connect Repo" empty-state in the chat input footer kept rendering even after the Open Repository modal had successfully launched a clone and the agent had reported the repository as ready. The bar would only heal after a hard refresh (or never, on cloud backends). Three independent bugs were stacking: 1. Optimistic update was writing to the wrong React Query cache key. `useUpdateConversationRepository.onMutate` called `setQueryData` with `["user", "conversation", id]` (3 elements), but `useUserConversation` reads from `["user", "conversation", id, backendId, orgId]` (5 elements). `setQueryData` requires an *exact* key match, so the update landed on an orphan cache entry that no observer ever read. Switched to `setQueriesData`/`getQueriesData` with the 3-element prefix so the optimistic write actually reaches the active query (Tanstack v5 prefix-matches `setQueriesData` filters). Also normalized `branch`/`gitProvider` to `null` to match the shape produced by the server-side refetch and prevent identity-flicker between the two updates. 2. Cloud `batchGetCloudConversations` / `searchCloudConversations` ignored the local repo selection entirely. For local backends `toAppConversation` overlays `selected_repository`/`selected_branch`/ `git_provider` from `localStorage`, but the cloud path returned the raw SaaS payload — and the SaaS often returns `null` for those fields until its own background hydration finishes. So every refetch (mutation invalidation, 30s poll, panel mount) overwrote the optimistic value with `null` and the bar snapped back to "Connect Repo". Added `overlayStoredRepoSelection` which fills only the `null` slots from local storage; populated server values still win, so we don't shadow real backend changes. 3. `updateConversationRepository` overwrote the entire metadata blob. `setStoredConversationMetadata` is replace-not-merge, so calling it with just `{selected_repository, selected_branch, git_provider}` silently dropped `selected_workspace` (the local-folder attach marker used by the Files tab to default to diff view, see the "Files tab diff-view default logic" note in `AGENTS.md`). Now reads the existing entry first and spreads it under the new repo fields. Defense-in-depth changes: - `useLocalGitInfo` now stays enabled until the conversation reports a *complete* repo tuple (`selected_repository` + `git_provider` + `selected_branch`), not just `selected_repository`. This lets the bar recover from partial-metadata cases (e.g. cloud hydration populates only the repo name first, or the user clones into a subdirectory of `working_dir`). The probe also gained a nested `find . -mindepth 2 -maxdepth 4 -name .git` fallback so a clone into `<workingDir>/<repo>/` is still detected after the direct `git remote get-url origin` in `<workingDir>` returns "no such remote 'origin'" (the agent-server pre-initialises every workspace as a worktree, so the parent directory always has a `.git` folder with no remote). - `useUpdateConversationRepository.onSettled` invalidates `["local-git-info", conversationId]` so the bar re-probes immediately after a connect rather than waiting on the next 10s refetch tick. - `git-control-bar.tsx`'s `hasRepository` predicate now keys off the *resolved* `selectedRepository` + `gitProvider` (which include the local-git probe's findings), not just the conversation field. This lets pull/push/PR buttons light up for local-workspace conversations whose repo metadata was inferred from `git remote`, matching what the repo + branch chips already showed. Verification I traced the failure mode by hitting the live agent-server directly: $ curl -s -X POST .../api/bash/execute_bash_command \\ -H "X-Session-API-Key: \$KEY" \\ -d '{"command":"git remote get-url origin", "cwd":"<workingDir>"}' git remote: error: No such remote 'origin' git rev-parse HEAD: ambiguous argument 'HEAD': unknown revision confirming the worktree-without-remote shape that broke the direct probe and forced the nested-find fallback. Tests __tests__/hooks/mutation/use-update-conversation-repository.test.tsx - optimistically updates the cached conversation under the prefix-extended key used by useUserConversation - rolls back the prefix-keyed cache entry when the mutation rejects __tests__/api/cloud-conversation-service.test.ts (new) - overlays locally-stored repo selection onto batchGetCloudConversations results when the server returns nulls - prefers the cloud server values over locally-stored selections when present - leaves null entries untouched when the cloud server returns null for a missing conversation - returns an empty array without calling the proxy when no ids are provided - overlays repo selection on each item returned from searchCloudConversations Wider sweep: npx vitest run __tests__/hooks/mutation \\ __tests__/api/cloud-conversation-service.test.ts \\ __tests__/api/conversation-metadata-store.test.ts \\ __tests__/api/agent-server-adapter.test.ts \\ __tests__/components/features/chat -> 22 files, 152 tests passed. User-visible behavior after this change: 1. Clicking Launch in the Connect Repo modal flips the bar to repo + branch chips immediately (optimistic update now reaches the active query). 2. The bar stays flipped through the next refetch on cloud backends (overlay keeps the local selection visible until the SaaS catches up). 3. Bar picks up nested clones within ~1s on local backends (local-git-info invalidation forces a re-probe instead of waiting on the 10s poll), and the nested-find fallback handles 'clone into <workingDir>/<repo>/' flows. 4. Pull/push/PR buttons now light up for local-workspace conversations whose remote was inferred from git remote. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(conversation): make right-panel drawer state session-only The right-side drawer's open/closed state (`isRightPanelShown` / `hasRightPanelToggled`) was persisted in localStorage, which made the panel feel sticky in a way users didn't expect — it would still be open after reloads or revisits even though they wanted a clean, focused chat view. Move drawer state fully into the in-memory Zustand store so it: - always starts closed on app load (or on opening a conversation after a restart), - survives in-app navigation because Zustand stays alive across React Router transitions, - only persists tab selection (`selectedTab`), which is the part users do want to come back to. The legacy `rightPanelShown` field is silently stripped from older persisted blobs by `sanitizeStoredState`, so old localStorage data doesn't churn or leak into the new schema. Co-authored-by: Cursor <cursoragent@cursor.com> * refactor(ui): standardize three-dots ellipsis trigger across the app Different surfaces had drifted to slightly different "more options" buttons: - conversation header used a 24x24 icon with a hardcoded fill color, - conversation cards in the side panel used a separate square `ellipsis.svg` glyph, - the conversation tab bar used a 20x20 icon with bespoke colors, - LLM profile rows wrapped the icon in a bordered button with yet another color. Promote `EllipsisButton` to be the canonical trigger and route every inline variant through it so size (w-4 h-4 / 16x16), color (`text-[#9299AA]`), and hover treatment (`hover:text-white hover:bg-white/10`) stay consistent everywhere. Layout-only overrides (e.g. translate, opacity-when-paused) flow through `className`, and a `testId` escape hatch keeps the existing `profile-menu-trigger` selector working. The chat-input overflow button intentionally keeps its pill-shaped custom variant; a doc comment on `EllipsisButton` calls that out so future contributors don't replace it. Co-authored-by: Cursor <cursoragent@cursor.com> * fix: lint * fix: failing tests * refactor: package-lock.json * refactor: package-lock.json * refactor: remove artifacts * refactor: update the code based on feedback --------- Co-authored-by: Cursor <cursoragent@cursor.com> Co-authored-by: hieptl <hieptl.developer@gmail.com>
250 lines
7.5 KiB
TypeScript
250 lines
7.5 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
import {
|
|
__resetActiveStoreForTests,
|
|
setActiveSelection,
|
|
setRegisteredBackends,
|
|
} from "#/api/backend-registry/active-store";
|
|
import type { Backend } from "#/api/backend-registry/types";
|
|
import { setStoredConversationMetadata } from "#/api/conversation-metadata-store";
|
|
import {
|
|
batchGetCloudConversations,
|
|
searchCloudConversations,
|
|
} from "#/api/cloud/conversation-service.api";
|
|
|
|
const { mockCallCloudProxy } = vi.hoisted(() => ({
|
|
mockCallCloudProxy: vi.fn(),
|
|
}));
|
|
|
|
vi.mock("#/api/cloud/proxy", () => ({
|
|
callCloudProxy: (...args: unknown[]) => mockCallCloudProxy(...args),
|
|
}));
|
|
|
|
const cloudBackend: Backend = {
|
|
id: "cloud-1",
|
|
kind: "cloud",
|
|
host: "https://app.all-hands.dev",
|
|
apiKey: "secret",
|
|
name: "Cloud",
|
|
};
|
|
|
|
describe("cloud conversation-service overlay", () => {
|
|
beforeEach(() => {
|
|
window.localStorage.clear();
|
|
__resetActiveStoreForTests();
|
|
setRegisteredBackends([cloudBackend]);
|
|
setActiveSelection({ backendId: cloudBackend.id, orgId: null });
|
|
mockCallCloudProxy.mockReset();
|
|
});
|
|
|
|
afterEach(() => {
|
|
window.localStorage.clear();
|
|
__resetActiveStoreForTests();
|
|
mockCallCloudProxy.mockReset();
|
|
});
|
|
|
|
it("overlays locally-stored repo selection onto batchGetCloudConversations results when the server returns nulls", async () => {
|
|
setStoredConversationMetadata("conv-1", {
|
|
selected_repository: "octocat/hello-world",
|
|
selected_branch: "main",
|
|
git_provider: "github",
|
|
});
|
|
|
|
mockCallCloudProxy.mockResolvedValueOnce([
|
|
{
|
|
id: "conv-1",
|
|
title: "Hello",
|
|
selected_repository: null,
|
|
selected_branch: null,
|
|
git_provider: null,
|
|
},
|
|
]);
|
|
|
|
const [conversation] = await batchGetCloudConversations(["conv-1"]);
|
|
|
|
expect(conversation).not.toBeNull();
|
|
expect(conversation?.selected_repository).toBe("octocat/hello-world");
|
|
expect(conversation?.selected_branch).toBe("main");
|
|
expect(conversation?.git_provider).toBe("github");
|
|
});
|
|
|
|
it("prefers the cloud server values over locally-stored selections when present", async () => {
|
|
setStoredConversationMetadata("conv-1", {
|
|
selected_repository: "stale/local",
|
|
selected_branch: "stale-branch",
|
|
git_provider: "gitlab",
|
|
});
|
|
|
|
mockCallCloudProxy.mockResolvedValueOnce([
|
|
{
|
|
id: "conv-1",
|
|
title: "Hello",
|
|
selected_repository: "octocat/hello-world",
|
|
selected_branch: "main",
|
|
git_provider: "github",
|
|
},
|
|
]);
|
|
|
|
const [conversation] = await batchGetCloudConversations(["conv-1"]);
|
|
|
|
expect(conversation?.selected_repository).toBe("octocat/hello-world");
|
|
expect(conversation?.selected_branch).toBe("main");
|
|
expect(conversation?.git_provider).toBe("github");
|
|
});
|
|
|
|
it("leaves null entries untouched when the cloud server returns null for a missing conversation", async () => {
|
|
mockCallCloudProxy.mockResolvedValueOnce([null]);
|
|
|
|
const result = await batchGetCloudConversations(["missing"]);
|
|
|
|
expect(result).toEqual([null]);
|
|
});
|
|
|
|
it("returns an empty array without calling the proxy when no ids are provided", async () => {
|
|
const result = await batchGetCloudConversations([]);
|
|
|
|
expect(result).toEqual([]);
|
|
expect(mockCallCloudProxy).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("only fills server-side nulls — server-populated fields are preserved", async () => {
|
|
setStoredConversationMetadata("conv-1", {
|
|
selected_repository: "local/repo",
|
|
selected_branch: "local-branch",
|
|
git_provider: "gitlab",
|
|
});
|
|
|
|
mockCallCloudProxy.mockResolvedValueOnce([
|
|
{
|
|
id: "conv-1",
|
|
title: "Hello",
|
|
selected_repository: "server/repo",
|
|
selected_branch: null,
|
|
git_provider: null,
|
|
},
|
|
]);
|
|
|
|
const [conversation] = await batchGetCloudConversations(["conv-1"]);
|
|
|
|
// Server-populated field wins…
|
|
expect(conversation?.selected_repository).toBe("server/repo");
|
|
// …but the two nulls are filled in from local storage.
|
|
expect(conversation?.selected_branch).toBe("local-branch");
|
|
expect(conversation?.git_provider).toBe("gitlab");
|
|
});
|
|
|
|
it("leaves server-side nulls null when local storage only has a partial selection", async () => {
|
|
setStoredConversationMetadata("conv-1", {
|
|
selected_repository: "local/repo",
|
|
selected_branch: null,
|
|
git_provider: null,
|
|
});
|
|
|
|
mockCallCloudProxy.mockResolvedValueOnce([
|
|
{
|
|
id: "conv-1",
|
|
title: "Hello",
|
|
selected_repository: null,
|
|
selected_branch: null,
|
|
git_provider: null,
|
|
},
|
|
]);
|
|
|
|
const [conversation] = await batchGetCloudConversations(["conv-1"]);
|
|
|
|
expect(conversation?.selected_repository).toBe("local/repo");
|
|
expect(conversation?.selected_branch).toBeNull();
|
|
expect(conversation?.git_provider).toBeNull();
|
|
});
|
|
|
|
it("applies the overlay independently per conversation when batching multiple ids", async () => {
|
|
setStoredConversationMetadata("conv-1", {
|
|
selected_repository: "octocat/hello-world",
|
|
selected_branch: "main",
|
|
git_provider: "github",
|
|
});
|
|
setStoredConversationMetadata("conv-2", {
|
|
selected_repository: "octocat/other",
|
|
selected_branch: "dev",
|
|
git_provider: "github",
|
|
});
|
|
|
|
mockCallCloudProxy.mockResolvedValueOnce([
|
|
{
|
|
id: "conv-1",
|
|
title: "First",
|
|
selected_repository: null,
|
|
selected_branch: null,
|
|
git_provider: null,
|
|
},
|
|
{
|
|
id: "conv-2",
|
|
title: "Second",
|
|
// Server returns the repo already — overlay should NOT override.
|
|
selected_repository: "server/two",
|
|
selected_branch: null,
|
|
git_provider: null,
|
|
},
|
|
{
|
|
id: "conv-3",
|
|
title: "Third (no local data)",
|
|
selected_repository: null,
|
|
selected_branch: null,
|
|
git_provider: null,
|
|
},
|
|
]);
|
|
|
|
const conversations = await batchGetCloudConversations([
|
|
"conv-1",
|
|
"conv-2",
|
|
"conv-3",
|
|
]);
|
|
|
|
expect(conversations[0]?.selected_repository).toBe("octocat/hello-world");
|
|
expect(conversations[0]?.selected_branch).toBe("main");
|
|
expect(conversations[0]?.git_provider).toBe("github");
|
|
|
|
expect(conversations[1]?.selected_repository).toBe("server/two");
|
|
expect(conversations[1]?.selected_branch).toBe("dev");
|
|
expect(conversations[1]?.git_provider).toBe("github");
|
|
|
|
expect(conversations[2]?.selected_repository).toBeNull();
|
|
expect(conversations[2]?.selected_branch).toBeNull();
|
|
expect(conversations[2]?.git_provider).toBeNull();
|
|
});
|
|
|
|
it("overlays repo selection on each item returned from searchCloudConversations", async () => {
|
|
setStoredConversationMetadata("conv-1", {
|
|
selected_repository: "octocat/hello-world",
|
|
selected_branch: "main",
|
|
git_provider: "github",
|
|
});
|
|
|
|
mockCallCloudProxy.mockResolvedValueOnce({
|
|
items: [
|
|
{
|
|
id: "conv-1",
|
|
title: "Hello",
|
|
selected_repository: null,
|
|
selected_branch: null,
|
|
git_provider: null,
|
|
},
|
|
{
|
|
id: "conv-2",
|
|
title: "Other",
|
|
selected_repository: null,
|
|
selected_branch: null,
|
|
git_provider: null,
|
|
},
|
|
],
|
|
next_page_id: null,
|
|
});
|
|
|
|
const page = await searchCloudConversations(20);
|
|
|
|
expect(page.items[0].selected_repository).toBe("octocat/hello-world");
|
|
expect(page.items[0].selected_branch).toBe("main");
|
|
expect(page.items[0].git_provider).toBe("github");
|
|
expect(page.items[1].selected_repository).toBeNull();
|
|
});
|
|
});
|