diff --git a/AGENT-RUN-ACTION-REQUEST-FIX-PLAN.md b/AGENT-RUN-ACTION-REQUEST-FIX-PLAN.md new file mode 100644 index 00000000..8b112e58 --- /dev/null +++ b/AGENT-RUN-ACTION-REQUEST-FIX-PLAN.md @@ -0,0 +1,292 @@ +# Agent Run Action Request Fix Plan + +## Reader And Outcome + +This plan is for a Xero engineer fixing the owned-agent approval and tool-run regressions exposed by the latest agent run audit. + +After reading it, the engineer should be able to implement the fixes, wipe affected dev app-data state as required by project policy, and run scoped regressions that prove this failure class cannot recur. + +## Problem Statement + +The latest owned-agent run produced several user-visible failures: + +- Approval cards rendered with Approve and Reject buttons, but clicking them did not resolve the underlying action. +- Some shell command review events appeared as successful tool calls even though no command spawned. +- `command_verify` rejected a valid package-manager script named `type-check`. +- Operator/action errors were stored in state but not shown where the user was trying to act. +- Invalid tool input was rendered like an approvable operator decision even though approval could not fix it. +- The agent reasoned about missing or confusing tool availability instead of receiving clear stage/tool guidance. +- File edit mismatches were recoverable, but noisy and not clearly separated from hard failures. +- The target app update skipped at least one likely app surface, so the original “all apps” intent was not clearly completed. + +The root cause is not one isolated UI bug. The runtime, persistence, stream projection, frontend routing, command policy, and agent guidance each allow a different part of the bad experience. + +## Goals + +- Action-required events must carry durable action identity from backend persistence to frontend cards. +- Approval and rejection buttons must route to the owned-agent action APIs when the card represents an owned-agent action. +- Approving one card must not accidentally approve unrelated pending actions from the same run. +- Pending command reviews must be displayed as pending review, not succeeded command executions. +- Valid package-manager verification scripts such as `type-check` must pass the validator and policy allowlist. +- Operator/action errors must be visible near the card or composer that triggered them. +- Invalid tool input must render as a non-approvable diagnostic or failure state, not as an Approve/Reject card. +- Agent stage and tool guidance must reduce confusion about available edit/command tools and package-manager workflows. +- Scoped tests must cover the bad paths from the audited run. + +## Non-Goals + +- Do not add backwards-compatible migrations or glue for stale malformed dev state unless explicitly requested. +- Do not create temporary debug UI. +- Do not finish unrelated Tokenloom product work as part of this fix except where a fixture or regression test needs a representative monorepo scenario. +- Do not rename on-wire `workflowStructure.phases` or other legacy DTO names while fixing Stages behavior. + +## Phase 1: Preserve Durable Action Identity + +Fix owned-agent `action_required` event emission so the stream can render real, resolvable cards. + +Relevant implementation areas: + +- `client/src-tauri/src/runtime/agent_core/persistence.rs` +- `client/src-tauri/src/runtime/agent_core/tool_dispatch.rs` +- `client/src-tauri/src/commands/subscribe_runtime_stream.rs` + +Tasks: + +- In `record_command_action_required`, append the same durable `actionId` that was inserted into `agent_action_requests`. +- Include `actionType`, `title`, `detail`, and the decision shape needed by the frontend. +- For command approval boundaries, use an explicit action type such as `command_approval`. +- For safety-boundary policy denials, append the durable action id and use an explicit action type such as `safety_boundary`. +- Include enough command context for the UI to display the exact pending command without inferring from a sibling tool call. +- Stop inventing resolvable fallback ids like `owned-agent-action-{event_id}` for owned-agent action cards. +- If a historical or malformed event has no durable action id, project it as a noninteractive diagnostic with clear copy instead of rendering live Approve and Reject buttons. + +Acceptance checks: + +- A newly emitted command-review event contains the same action id as its `agent_action_requests` row. +- A newly emitted safety-boundary event contains the same action id as its `agent_action_requests` row. +- The frontend never receives a clickable owned-agent action card without a durable action id. + +## Phase 2: Make Approve And Reject Action-Scoped + +Fix the backend and frontend path so each card resolves exactly the intended action. + +Relevant implementation areas: + +- `client/src-tauri/src/db/project_store/agent_core.rs` +- `client/src-tauri/src/commands/agent_task.rs` +- `client/src-tauri/src/runtime/agent_core/facade.rs` +- `client/components/xero/agent-runtime.tsx` +- `client/components/xero/agent-runtime/use-agent-runtime-controller.ts` + +Tasks: + +- Audit `answer_pending_agent_action_requests`, which currently resolves pending action requests by run rather than by specific action id. +- Add an action-scoped answer path, or make existing owned-agent approval APIs require and honor `actionId`. +- Ensure approving one card cannot approve every pending request in the run. +- Ensure rejecting one card cannot reject unrelated pending requests. +- Update the frontend action card routing to pass `runId`, `actionId`, and `actionType` to the owned-agent action APIs. +- Keep operator-review actions separate from owned-agent actions so cards do not silently route to `resolve_operator_action`. +- Add stale-run reconciliation so a paused run with pending action requests is still resolvable even if the supervisor row is marked stale. +- If a run cannot be resumed because runtime state is truly stale, show a direct error and recovery action instead of leaving buttons inert. + +Acceptance checks: + +- Approving one of two pending action cards resolves only that card's row. +- Rejecting one of two pending action cards resolves only that card's row. +- A paused run with pending action requests can be resumed or produces a visible recovery error. +- No owned-agent action card calls the operator-review mutation path. + +## Phase 3: Surface Action Errors Where The User Acts + +The audited UI had errors in state, but the user could not see them from the card or composer. + +Relevant implementation areas: + +- `client/src/features/xero/use-xero-desktop-state/operator-auth-mutations.ts` +- `client/components/xero/agent-runtime/use-agent-runtime-controller.ts` +- `client/components/xero/agent-runtime.tsx` + +Tasks: + +- Thread `operatorActionError` and owned-agent action errors through the runtime controller. +- Render action failure feedback inline on the relevant card when possible. +- Also expose the latest action error in the composer area if the card has scrolled away. +- Clear stale action errors after a successful retry or when a different action is selected. +- Avoid generic silent failures; include the backend reason if it is safe and user-actionable. + +Acceptance checks: + +- A failed Approve click shows visible feedback without requiring logs. +- A failed Reject click shows visible feedback without requiring logs. +- Retrying after fixing the cause clears the old error. + +## Phase 4: Correct Command Review Display Semantics + +Some command-review events were marked successful even though the command never spawned. + +Relevant implementation areas: + +- `client/src-tauri/src/commands/subscribe_runtime_stream.rs` +- `client/components/xero/agent-runtime.tsx` + +Tasks: + +- Treat command tool results with `spawned=false`, `exitCode=null`, and a review/escalation outcome as pending review. +- Do not render those tool completions as successful executions. +- Collapse or visually associate the pending command tool event with its action-required card so the stream does not show both a green success and a pending approval for the same command. +- Preserve true success styling only for commands that actually spawned and exited successfully. +- Preserve true failure styling for commands that spawned and returned nonzero. + +Acceptance checks: + +- A command waiting for approval is labeled as needing review. +- A command waiting for approval has no green success affordance. +- A spawned command with exit code 0 still renders as succeeded. +- A spawned command with nonzero exit code still renders as failed. + +## Phase 5: Accept Safe `type-check` Verification Scripts + +The audited target repo used `type-check`, and direct package-manager execution passed. Xero rejected the script before it could run. + +Relevant implementation areas: + +- `client/src-tauri/src/runtime/agent_core/types.rs` +- `client/src-tauri/src/runtime/autonomous_tool_runtime/policy.rs` + +Tasks: + +- Add `type-check` to the safe package-manager verification names. +- Keep `typecheck` accepted. +- Keep `test`, `tests`, `lint`, `check`, and `build` behavior unchanged. +- Decide whether scoped variants such as `type-check:ci` are safe. If supported, implement intentionally and test them; otherwise reject them with clear copy. +- Update validator error text so it lists both `typecheck` and `type-check`. +- Keep the command verification validator and the autonomous tool policy allowlist aligned. + +Acceptance checks: + +- `pnpm --filter type-check` validates. +- `pnpm --filter run type-check` validates if the existing validator supports `run` script form. +- Equivalent npm and yarn safe script forms validate where currently supported. +- Unsafe package scripts remain rejected. + +## Phase 6: Render Invalid Tool Input As A Diagnostic + +`agent_action_tool_input_invalid` is not fixed by user approval, so it should not look like an approvable action. + +Relevant implementation areas: + +- `client/src-tauri/src/runtime/agent_core/tool_dispatch.rs` +- `client/src-tauri/src/commands/subscribe_runtime_stream.rs` +- `client/components/xero/agent-runtime.tsx` + +Tasks: + +- Classify invalid tool input separately from action-required approval boundaries. +- Render invalid tool input as a failed tool-call diagnostic with the validation message. +- If the run pauses on invalid input, explain that the next agent turn must retry with corrected input. +- Do not show Approve and Reject buttons for invalid tool input. +- Avoid storing invalid input events as pending `agent_action_requests` unless there is a real decision the operator can make. + +Acceptance checks: + +- An invalid `command_verify` input displays as a validation failure. +- The card has no Approve or Reject buttons. +- The run state and UI copy agree on whether the agent can continue. + +## Phase 7: Reduce Agent Tool Confusion + +The agent reasoned about missing or surprising tools because the stage prompt and available tools were not explained clearly enough. + +Relevant implementation areas: + +- `client/src-tauri/src/db/migrations.rs` +- Runtime prompt or stage-policy generation for Engineer mode. +- Any tests covering Stages tool allowlists. + +Tasks: + +- Update Engineer stage guidance to clearly explain available edit tools in the current runtime. +- If `patch` is intentionally unavailable, say so through tool instructions and explain the expected `edit` or `write` workflow. +- Make command stages explicit: `command_verify` is for verification commands; package-manager mutation commands need the appropriate reviewed command path. +- Add lockfile guidance: when package manifests change, the agent should use the package manager to update the lockfile through an approved command rather than hand-editing it. +- Keep user-facing terminology as "Stages" and avoid introducing "workflow phases" in UI copy. + +Acceptance checks: + +- A new run receives clear tool guidance before the first edit or command decision. +- The agent no longer needs to infer why `patch` is unavailable. +- Package manifest changes trigger a clear lockfile update path. + +## Phase 8: Add Target-Scope Completion Guardrails + +The audited run changed web, landing, and admin surfaces, but did not clearly resolve whether mobile was in scope. + +Relevant implementation areas: + +- Agent instructions/prompt planning for implementation tasks. +- E2E or integration fixture representing a multi-app workspace. + +Tasks: + +- Add a representative fixture or scripted scenario for a monorepo with shared UI, web, landing, admin, and mobile surfaces. +- Ensure the agent records which app surfaces are in scope before claiming "all apps" are complete. +- If a surface is incompatible with the chosen implementation, the agent must either implement the compatible alternative or explicitly report that it was not changed. +- Ensure final responses distinguish verified surfaces from skipped surfaces. + +Acceptance checks: + +- A multi-app request cannot be marked complete while silently skipping a likely app surface. +- Verification output names the surfaces actually tested. + +## Phase 9: Wipe Affected Dev App-Data State Before Final QA + +Project policy prohibits compatibility glue for stale state in this new app unless explicitly requested. + +Tasks: + +- After code fixes are in place, wipe the affected state under `~/Library/Application Support/dev.sn0w.xero`. +- Prefer the narrowest affected project state wipe if enough context is known. +- Use only `Support/dev.sn0w.xero` data during development. +- Do not use `.xero/` repo-local state for new project state. +- Reproduce the audited failure class from fresh dev app-data. + +Acceptance checks: + +- Fresh dev app-data produces durable action ids on new action-required events. +- No stale malformed action cards remain after reset. +- No compatibility migration was added for the malformed dev-only action events. + +## Testing Checklist + +Run scoped tests only. + +- Rust tests for action-required persistence payloads. +- Rust tests for stream projection of owned-agent action cards. +- Rust tests for action-scoped approval and rejection. +- Rust tests for `type-check` command verification and unsafe script rejection. +- Frontend tests for owned-agent Approve and Reject routing. +- Frontend tests for inline action error display. +- Frontend tests for pending command review display. +- Integration smoke test for a paused owned-agent run with a pending command approval. +- Integration smoke test for invalid tool input rendering as a diagnostic. +- Manual Tauri QA in development data if automated UI coverage cannot cover the full resume path. + +## Final Acceptance Criteria + +- The screenshot failure class no longer reproduces: Approve and Reject do something visible and correct. +- Every clickable owned-agent action card has a durable action id. +- Clicking Approve or Reject mutates exactly the intended pending action row. +- Command approvals do not render as successful command executions before spawning. +- `type-check` verification scripts are accepted by both validator and policy. +- Invalid tool input is shown as a validation failure, not an approval request. +- Action errors are visible to the user without reading logs. +- Fresh dev app-data verifies the fixed behavior. +- Scoped tests and formatting relevant to touched files pass. + +## Risks And Open Questions + +- The current run-level approval path may have been intentionally broad. If so, the product needs an explicit "approve all pending actions" affordance rather than making individual cards behave that way. +- Resuming a paused run whose supervisor state is stale may need a small reconciliation layer before action resolution. Keep this narrow and observable. +- Decide whether command approval should replay the exact pending command or only resume the model with the approval answer. The safer behavior is to bind approval to the exact stored command request. +- Decide whether `type-check:*` script names are safe enough to allow. Avoid broad wildcard script approval unless there is a clear policy reason. +- Historical malformed action events can remain noninteractive diagnostics after dev app-data reset. Do not add backwards compatibility code for stale development state unless explicitly requested. diff --git a/AGENTS.md b/AGENTS.md index 9df590bf..24ecbafd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -9,6 +9,7 @@ - Build prerequisite: `protoc` must be on PATH (the LanceDB-backed agent memory store pulls lance-* crates whose build scripts compile vendored .proto files). On macOS: `brew install protobuf`. - Run scooped tests and format instead of repo wide when working with rust to save time and storage. - Dont create branches or stash unless user asks, there may be multiple agents working at the same time and doing this will break things +- When running in development, you should only be using the data in Support/dev.sn0w.xero ## Stages vs. Workflow (terminology) diff --git a/COMPUTER-USE-SYSTEM-CONTROL-AUDIT-PLAN.md b/COMPUTER-USE-SYSTEM-CONTROL-AUDIT-PLAN.md deleted file mode 100644 index da2b0bf3..00000000 --- a/COMPUTER-USE-SYSTEM-CONTROL-AUDIT-PLAN.md +++ /dev/null @@ -1,364 +0,0 @@ -# Computer Use System Control Audit And Expansion Plan - -## Reader And Goal - -Reader: an internal engineer expanding Xero Computer Use agent control. - -Post-read action: implement the missing agent-visible tools and platform capabilities needed for the user to control their own macOS or Windows machine by prompting, with explicit local consent, visible control state, auditability, and no attempts to bypass OS security boundaries. - -## Audit Conclusion - -Computer Use already has a real agent-control foundation. It can use ordinary agent tools for project inspection, file changes, commands, browser control, diagnostics, skills, subagents, durable context, and external capabilities. It also has a native desktop-control foundation: observe the desktop, send pointer and keyboard input, launch or target apps, manage a desktop stream, and audit control actions. - -This plan is not mainly about manual WebRTC control. Manual streaming and human viewport control are one client of the desktop broker. The main target is LLM-driven Computer Use: the agent should have the right prompt-visible tools, schemas, permissions, policy gates, and platform backends to operate the user's computer as completely as the user explicitly allows. - -macOS is substantially deeper than Windows because it has Accessibility, Vision OCR, ScreenCaptureKit WebRTC streaming, and app/window automation fallbacks. Windows currently has useful pointer, keyboard, clipboard, screenshot, window, and app basics, but it lacks structured UI Automation, OCR, and native WebRTC video publishing. - -The current surface is not "absolute full system control" yet. Prompted desktop control is still bounded by: - -- OS permissions such as Screen Recording, Accessibility, Input Monitoring, Windows session access, and UAC. -- Xero policy gates that deny secret text, credential managers, payments, financial flows, identity verification, MFA/recovery flows, and system privacy/security settings. -- Repo-scoped command tools rather than a general host-administration shell. -- Agent-visible schemas that omit a few lower-level actions already implemented in the runtime. -- Tool activation and effective-policy gaps where the Computer Use agent can theoretically use a capability, but it is not present, clear, or available in the current turn. - -The right expansion path is to add an explicit owner-approved power mode, close the prompt-visible schema gaps, bring Windows to parity with macOS structured control, then add audited host-administration tools where the local user grants the privilege. - -## Scope: Agent-Driven Control - -The product goal is not only "let a remote human click the streamed desktop." It is "let the Computer Use agent complete computer tasks by choosing the best tool." - -That means the audit must cover four layers: - -- Agent policy: whether Computer Use is allowed to use a tool at all. -- Tool activation: whether the tool is included in the current turn or discoverable through tool access. -- Tool schema: whether the LLM can see every action and field it needs. -- Platform backend: whether macOS and Windows can actually execute the requested action. - -Manual WebRTC control belongs mostly to the platform backend and streaming layers. LLM-driven control also needs browser tools, file tools, command tools, process tools, system diagnostics, MCP tools, skills, subagents, project context, and future host-administration tools. - -## Current Computer Use Agent Tool Surface - -Computer Use is defined as a general-purpose runtime agent. Its base prompt says it may combine computer interaction, project inspection, file changes, commands, browser and desktop automation, diagnostics, external-capability tools, skills, subagents, and durable context when those tools are available and appropriate. - -The runtime agent gate broadly allows Computer Use to use the available autonomous tool catalog. The practical availability still depends on per-run tool policy, tool-access activation, feature rollout flags, provider schema limits, and platform support. - -### Agent Tools Already Covered - -- Repository and workspace observation: read, read-many, stat, search, find, list, tree listing, directory digest, file hash, git status, git diff, workspace index, code intelligence, and LSP. -- Repository mutation: edit, write, patch, copy, structured file transactions, JSON/TOML/YAML edit, delete, rename, mkdir, and notebook edit where available. -- Command and process control: command probe, command verify, command run, command session, and process manager. -- Browser/web control: browser observe, browser control, web fetch, and web search where enabled. -- Desktop control: macOS automation, desktop observe, desktop control, and desktop stream. -- System diagnostics: observe diagnostics and approval-gated privileged diagnostics. -- External extension surfaces: MCP list/read/get/call, dynamic MCP tools, skills, and subagents. -- Coordination and memory: todo, project context search/get/record/update/refresh, environment context, and agent coordination. -- Domain tools: emulator and Solana tools when the selected app/tool profile exposes them. - -### Agent Tool Gaps - -- There is no explicit capability matrix that says which Computer Use tools are active by default, available through tool-access request, blocked by policy, blocked by rollout, or blocked by platform. -- The current desktop-control audit covers native desktop tools more thoroughly than non-desktop agent tools. -- Repo-scoped command tools are powerful for development tasks, but they are not a complete host-administration surface. -- PowerShell appears as a tool constant/category, but the plan must verify whether it is fully agent-visible, cross-platform, policy-gated correctly, and sufficient for Windows administration. -- Browser control, desktop control, command tools, and host administration need clearer routing rules so the agent chooses the most precise safe tool instead of falling back to pixel control. -- Dynamic MCP, skills, and subagents can extend control, but there is no required "Computer Use workstation pack" that guarantees the expected macOS and Windows capabilities are installed. - -## Current Desktop Tool Surface - -### Generic Desktop Observation - -The `desktop_observe` tool is agent-visible and exposes: - -- `permissions_status` -- `display_list` -- `window_list` -- `app_list` -- `foreground_state` -- `screenshot` -- `cursor_state` -- `accessibility_snapshot` -- `ocr_snapshot` -- `element_at_point` -- `health` - -It supports display targeting, window targeting, screenshot regions, and coordinates for element lookup. - -### Generic Desktop Control - -The `desktop_control` tool is agent-visible and exposes: - -- Pointer: `mouse_move`, `mouse_click`, `mouse_double_click`, `mouse_right_click`, `mouse_drag`, `scroll` -- Keyboard and text: `key_press`, `hotkey`, `type_text`, `paste_text` -- App/window: `focus_window`, `activate_app`, `launch_app`, `quit_app` -- Structured UI: `ax_press`, `ax_set_value`, `ax_focus`, `menu_select` -- Safety/control: `cancel_current_action` - -The runtime also supports `mouse_down`, `mouse_drag_move`, and `mouse_up`, but those are not in the current agent-visible schema. They appear intended for lower-level/manual-control paths. - -### Desktop Streaming - -The `desktop_stream` tool is agent-visible and exposes: - -- `stream_capabilities` -- `stream_start` -- `stream_stop` -- `stream_status` -- `stream_set_quality` -- `stream_request_keyframe` - -The runtime and sidecar also support WebRTC offer, answer, and ICE candidate operations, but those signaling operations are not currently in the agent-visible schema. - -### macOS-Specific Automation - -The `macos_automation` tool exposes: - -- `mac_permissions` -- `mac_app_list` -- `mac_app_launch` -- `mac_app_activate` -- `mac_app_quit` -- `mac_window_list` -- `mac_window_focus` -- `mac_screenshot` - -It is macOS-only. App quit requires operator approval. On macOS, generic desktop app/window actions can fall back through this path when the sidecar does not implement those operations directly. - -## macOS Capability Matrix - -### macOS: Present - -- Permission status for Screen Recording, Accessibility, and Input Monitoring. -- Display, window, app, and foreground-state observation. -- Full-display and region screenshots. -- Cursor position. -- Accessibility snapshot and element-at-point through macOS Accessibility. -- OCR snapshot through macOS Vision. -- Mouse move, down, up, click, double-click, right-click, drag, drag-move, and scroll in the runtime/sidecar. -- Key press, hotkey, and typed text. -- Clipboard-backed paste through the sidecar. -- Accessibility actions: press, set value, focus. -- Menu path selection through Accessibility. -- App launch, activation, quit, and window focus through macOS app automation. -- Native WebRTC desktop stream using ScreenCaptureKit and VideoToolbox H.264. -- Screenshot fallback stream state. -- Controller lock, local-user takeover detection, audit log, sidecar token auth, and sidecar integrity checks. - -### macOS: Missing Or Weak - -- Agent-visible schema does not expose `mouse_down`, `mouse_up`, `mouse_drag_move`, `sourceWidth`, or `sourceHeight`. -- Agent-visible stream schema does not expose WebRTC signaling actions, even though the runtime supports them. -- Accessibility control is narrow: press, set value, focus, and menu select only. It lacks common AX actions such as select, confirm, cancel, increment/decrement, expand/collapse, scroll-to-visible, table/list row selection, checkbox/radio state changes, and robust text-field editing helpers. -- Element identity is not yet a durable targeting contract. A prompt can use snapshots, but stable element references across app refreshes need stronger IDs and re-resolution. -- Keyboard input depends on a fixed key map. International layouts, dead keys, IME text, media keys, function layers, and secure input mode need explicit handling. -- Clipboard is write/paste oriented. There is no general read/write clipboard API for text, images, files, or rich formats. -- No first-class Dock, menu bar extra, Mission Control, Spaces, notification, file dialog, open/save panel, or drag-and-drop file automation. -- No general system-administration bridge for host-level operations outside the repo sandbox. -- No privileged control for settings, network, display, audio, services, package managers, login items, or app install/uninstall beyond what can be clicked manually. -- No supported path to bypass TCC, SIP, secure input, password prompts, or other OS-protected boundaries. Those must remain user-mediated. - -## Windows Capability Matrix - -### Windows: Present - -- Display, window, app, and foreground-state observation through `xcap`. -- Full-display and region screenshots through `xcap`. -- Cursor state through the sidecar input backend. -- Mouse move, down, up, click, double-click, right-click, drag, drag-move, and scroll through Enigo. -- Key press, hotkey, and typed text through Enigo. -- Clipboard-backed paste through `arboard` plus Ctrl+V. -- Window focus and app activation through Win32 calls wrapped in PowerShell. -- App launch through PowerShell and the Windows AppsFolder shell namespace. -- App quit through `taskkill.exe`. -- Static permissions model that treats screen capture and desktop input as granted in the active user session. -- Screenshot fallback stream state. -- Controller lock and audit flow shared with macOS. - -### Windows: Missing Or Weak - -- No Windows UI Automation tree, element-at-point implementation, or structured element actions. -- No OCR snapshot implementation. -- No menu selection implementation. -- No native WebRTC desktop video publisher. The current native publisher is macOS-only; Windows falls back to screenshot-based degraded mode. -- Window management is basic: focus/activate/launch/quit only. There is no maximize, minimize, restore, move, resize, close-window message, virtual desktop, monitor move, z-order control, or owner-drawn app targeting contract. -- App launching is heuristic. It does not yet model Store apps, protocol handlers, file associations, shell verbs, admin launches, or installed-app inventory robustly. -- UAC and secure desktop are not controllable. Elevated actions require explicit user approval through the OS. -- No Registry, Services, Task Scheduler, Event Log, Windows Settings, firewall, network adapter, winget, MSI, process privilege, or local user/group management tool. -- No RDP/session switching, lock screen, credential provider, session 0, or secure-input support. -- No deep browser/Office/Explorer-specific structured automation beyond generic screen and input. - -## Cross-Platform Policy And Safety Boundaries - -Current desktop-control policy intentionally blocks: - -- Secret text typing or pasting. -- Password managers, Keychain, credentials, passkeys, and saved-password contexts. -- Purchases, checkout, payment confirmation, money transfer, and card entry. -- Banking, brokerage, tax, payroll, insurance, crypto, and wallet contexts. -- Identity verification, KYC, passport, driver license, SSN, and account ownership flows. -- MFA, TOTP, OTP, recovery codes, account recovery, password reset, and security settings. -- System privacy and security settings. - -These boundaries conflict with literal "absolute full control," but they are the right default for a shipped product. Expansion should add an explicit local owner/admin mode rather than silently weakening default Computer Use policy. - -## Expansion Plan - -### Phase 0: Agent Tool Access Manifest - -Goal: make Computer Use's LLM-accessible tool surface explicit before expanding any backend. - -Tasks: - -- Generate or maintain a Computer Use capability manifest that lists every tool, action, schema field, risk class, default availability, tool-access availability, rollout gate, platform gate, and approval requirement. -- Add tests that fail when the runtime action enum, agent-visible schema, tool catalog metadata, TypeScript DTOs, and provider schema projection drift apart. -- Verify that Computer Use can activate the expected non-desktop tool families: repository read/write, command/session/process, browser observe/control, web fetch/search, diagnostics, MCP, skill, subagent, todo, project context, environment context, code intelligence, and domain tools. -- Add a "best tool selection" guide to the Computer Use prompt or tool descriptions: prefer structured browser tools for browser tasks, command/process tools for shellable tasks, native desktop structured actions for app UI, and pixel input only when no more precise tool exists. -- Add a visible status/debug surface for developers that explains why a tool is missing in a run: policy, provider limit, rollout flag, platform unsupported, permission denied, not activated, or not installed. -- Define the required "workstation control pack" for macOS and Windows: desktop sidecar, browser control, host command/admin tools, clipboard/file-drop tools, OCR/UI tree support, and diagnostics. - -Acceptance criteria: - -- An engineer can answer "what can the Computer Use agent do on this machine right now?" from one manifest. -- A prompt-visible Computer Use tool cannot silently disappear without a failing test or visible availability reason. -- Manual WebRTC controls and LLM-driven tools are documented as separate surfaces sharing some desktop broker backends. - -### Phase 1: Make Implemented Actions Prompt-Visible - -Goal: expose the actions that already exist in the runtime where doing so improves prompted control. - -Tasks: - -- Add `mouse_down`, `mouse_up`, and `mouse_drag_move` to the `desktop_control` agent schema, tool catalog metadata, TypeScript DTOs, tests, and provider schema tests. -- Add `sourceWidth` and `sourceHeight` to the `desktop_control` agent schema so prompted actions can target screenshots or streams rendered at non-native sizes. -- Decide whether stream signaling remains internal or becomes prompt-visible. If prompt-visible, expose `stream_offer`, `stream_answer`, and `stream_ice_candidate` with strict SDP/ICE validation. -- Add focused runtime tests proving the schema and action enum stay aligned. -- Update the manual-control drag plan if the frontend still lacks the stateful gesture path. - -Acceptance criteria: - -- Every runtime desktop action intentionally available to agents is present in the tool schema. -- Hidden/internal actions are documented as internal, not accidentally omitted. -- Pointer press-hold-release can be driven by prompt when the local policy allows it. - -### Phase 2: Windows Structured UI Parity - -Goal: make Windows controllable by elements, not only pixels. - -Tasks: - -- Add a Windows UI Automation backend in the sidecar. -- Implement `accessibility_snapshot` from UIA trees with role, name, value, state, bounds, enabled/focused, and provider diagnostics. -- Implement `element_at_point` through UIA hit testing. -- Implement `ax_press`, `ax_set_value`, and `ax_focus` through UIA patterns such as Invoke, Value, Text, SelectionItem, Toggle, ExpandCollapse, and ScrollItem. -- Implement `menu_select` through UIA menu traversal where possible, with a fallback to Alt-key menu navigation. -- Add stable element handles that can be re-resolved from snapshot rows without trusting stale raw handles. -- Add Windows-specific tests behind target cfg gates and sidecar contract tests with mocked UIA payloads. - -Acceptance criteria: - -- A prompt can inspect a Windows app's UI tree, select a visible element, and invoke or edit it without coordinate clicking when UIA supports it. -- Unsupported controls return actionable diagnostics instead of falling back silently. -- The existing pointer/keyboard path remains available when UIA is blocked or absent. - -### Phase 3: Windows OCR And Native Streaming - -Goal: make Windows observation comparable to macOS for visual and remote-control tasks. - -Tasks: - -- Implement OCR with Windows.Media.Ocr where available, with a fallback strategy for unsupported Windows versions. -- Return OCR blocks with text, bounds, confidence, full text, truncation, and diagnostics matching the current sidecar contract. -- Implement native WebRTC publishing on Windows with Windows Graphics Capture plus Media Foundation or a proven H.264 encoder path. -- Preserve screenshot fallback as degraded mode, but avoid sharing a rate budget with critical input commands. -- Add GPU/driver failure diagnostics and software fallback messaging. - -Acceptance criteria: - -- Windows can return OCR snapshots from display/region captures. -- Windows can publish a native WebRTC desktop video track with cursor inclusion where supported. -- Fallback behavior is explicit and does not mask input failures. - -### Phase 4: macOS Structured Control Completion - -Goal: expand macOS Accessibility from basic actions to a practical app-control API. - -Tasks: - -- Add AX action support for select, confirm/cancel, increment/decrement, expand/collapse, scroll-to-visible, list/table row selection, checkbox/radio toggles, and text range editing. -- Add menu bar, Dock, status item, open/save panel, and file dialog helpers. -- Add element re-resolution by app, window, role, title, bounds, and ancestry path. -- Add keyboard-layout-aware key translation and a paste-first text path for long or non-US text. -- Add clipboard read/write support for text, images, files, and common rich payloads with redaction controls. - -Acceptance criteria: - -- A prompt can operate ordinary macOS forms, menus, dialogs, lists, tables, and toggles without relying only on screen coordinates. -- Text input works across keyboard layouts and long text. -- Sensitive clipboard payloads are never persisted in audit logs. - -### Phase 5: Owner-Approved Host Administration Mode - -Goal: provide as much whole-system control as possible for an explicitly consenting local owner without making default Computer Use unsafe. - -Tasks: - -- Add a "Power User" or "Owner Admin" mode in desktop-control settings. It must be local-only, time-bound, visibly active, and revocable with emergency stop. -- Add policy profiles: default safe, developer workstation, and owner admin. -- Add an audited host command tool separate from repo-scoped commands. It should require explicit mode enablement and support shell, PowerShell, AppleScript/JXA, Shortcuts, `brew`, `winget`, service management, registry edits, package install/uninstall, app launch arguments, and host file operations. -- Require per-command preview for destructive, privileged, network/security, startup-item, credential-adjacent, or privacy-sensitive operations. -- Use OS-native elevation prompts when needed. Do not attempt to bypass UAC, TCC, SIP, secure desktop, or credential prompts. -- Add rollback metadata where practical: changed files, registry keys, service state, package operations, and settings snapshots. - -Acceptance criteria: - -- A local owner can prompt Xero to perform broad workstation administration after enabling owner-admin mode. -- The user can see, stop, and audit all high-impact actions. -- Xero never claims it can bypass OS protections; it asks the user to approve OS prompts when required. - -### Phase 6: Desktop Resource Surfaces - -Goal: cover common real desktop work that is not just clicking. - -Tasks: - -- Add clipboard read/write actions for text, images, files, and common rich formats. -- Add file drag/drop synthesis for apps that expect dropped files. -- Add notification observation where platform APIs permit it. -- Add audio volume, media keys, display arrangement/readout, and window layout helpers. -- Add app inventory and launch-target discovery on both platforms. -- Add browser and terminal bridge affordances that hand off to existing browser/control/command tools when they are more precise than desktop pixels. - -Acceptance criteria: - -- Computer Use can complete routine app workflows involving files, clipboard, dialogs, notifications, and window layout. -- The agent prefers structured/native actions over coordinate input when available. - -### Phase 7: Verification Matrix - -Goal: make expanded control dependable across real machines. - -Tasks: - -- Add a capability-report fixture per platform that records sidecar capabilities, permission states, and known unavailable surfaces. -- Test macOS with Screen Recording denied/granted, Accessibility denied/granted, Input Monitoring denied/granted, multiple displays, Retina scaling, and secure input. -- Test Windows with standard user, administrator, UAC prompt, multiple DPI scales, multiple monitors, RDP, Store app, classic Win32 app, Electron app, browser, Explorer, and Office-style apps. -- Add failure-mode tests for sidecar unavailable, sidecar operation unimplemented, screenshot capture denied, UIA unavailable, OCR unavailable, stream start failure, and local-user takeover. -- Keep Cargo tests scoped and run one Cargo command at a time. - -Acceptance criteria: - -- The app can report exactly what a machine supports before the agent acts. -- Platform-specific failures produce user-actionable messages. -- High-risk actions are covered by approval, audit, and emergency-stop tests. - -## Final Target State - -The practical target is not stealthy or permissionless control. The target is consented, visible, owner-controlled workstation automation: - -- Observe the screen, UI tree, OCR text, windows, apps, cursor, permissions, and stream state. -- Choose among browser, command, file, process, diagnostics, MCP, skill, subagent, and desktop tools based on the task. -- Control pointer, keyboard, text, clipboard, menus, dialogs, windows, apps, and structured UI elements. -- Stream the desktop reliably on macOS and Windows. -- Administer the host through an explicit owner-approved tool surface. -- Preserve audit logs, leases, emergency stop, and policy profiles. -- Respect OS security prompts and refuse to bypass protected boundaries. diff --git a/README.md b/README.md index 4efd8b45..66a862b9 100644 --- a/README.md +++ b/README.md @@ -132,6 +132,9 @@ Detected/used CLIs include: If tools are missing, workbench surfaces degraded/missing-toolchain states rather than crashing. +For the current Solana workbench and autonomous tool coverage map, see +`docs/solana-workbench-tool-coverage-audit.md`. + ### 4) Server features - Postgres is provided by `server/docker-compose.yml` in local development. @@ -379,6 +382,10 @@ Use `--json` on batch commands for scriptable output, and `--app-data-dir PATH` The agent TUI parity matrix lives in `docs/agent-tui-parity.md`. Desktop-only browser viewport, emulator live panes, graphical canvas gestures, window chrome, and microphone dictation remain explicit exclusions; renderer-independent workflows are exposed through terminal-native panes or CLI commands. +Owned-agent file mutations use stale-file protection: agents must observe existing files and pass current SHA-256 guards before writing, while command mutations invalidate prior observations. See `docs/agent-stale-file-protection.md` for the mutation-path inventory and retry contract. + +Context pressure, compaction, and same-type handoff behavior are audited in `docs/context-exhaustion-compaction-handoff.md`, including the proposed continuity model-routing rollout. + Scoped verification for harness work: ```bash @@ -453,15 +460,13 @@ XERO_SKIP_DICTATION_SHIM=1 These are optional and only needed for specific runtime integrations: ```bash -# Custom web-search provider used by autonomous web tools -XERO_AUTONOMOUS_WEB_SEARCH_URL=https://... -XERO_AUTONOMOUS_WEB_SEARCH_BEARER_TOKEN=... - # Solana workbench resource overrides XERO_SOLANA_RESOURCE_ROOT=/path/to/resources XERO_SOLANA_TOOLCHAIN_ROOT=/path/to/toolchain ``` +Autonomous web search is configured in the desktop app under Settings -> Web Search. Xero stores non-secret provider settings in OS app-data and stores API keys through the same provider credential table used for LLM providers. The custom endpoint contract is `GET ?q=&limit=` with a JSON response shaped as `{ "results": [{ "title": string, "url": string, "snippet"?: string }] }`. + --- ## Persistence Model diff --git a/client/components/xero/agent-dock-sidebar.test.tsx b/client/components/xero/agent-dock-sidebar.test.tsx index d872359b..5fec2444 100644 --- a/client/components/xero/agent-dock-sidebar.test.tsx +++ b/client/components/xero/agent-dock-sidebar.test.tsx @@ -7,11 +7,16 @@ import { createXeroHighChurnStore } from '@/src/features/xero/use-xero-desktop-s import type { AgentSessionView } from '@/src/lib/xero-model' interface CapturedRuntimeProps { + active?: boolean density?: 'comfortable' | 'compact' inSidebar?: boolean sidebarSessions?: readonly AgentSessionView[] onCloseSidebar?: () => void onSelectSidebarSession?: (id: string) => void + onClearSidebarChat?: () => void + sidebarChatClearDisabled?: boolean + sidebarChatClearPending?: boolean + sidebarChatClearTitle?: string onCreateSession?: () => void isCreatingSession?: boolean agentCreateCanvasIncluded?: boolean @@ -21,11 +26,16 @@ interface CapturedRuntimeProps { vi.mock('@/components/xero/agent-runtime/live-agent-runtime', () => ({ LiveAgentRuntimeView: ({ agent, + active, density, inSidebar, sidebarSessions, onCloseSidebar, onSelectSidebarSession, + onClearSidebarChat, + sidebarChatClearDisabled, + sidebarChatClearPending, + sidebarChatClearTitle, onCreateSession, isCreatingSession, agentCreateCanvasIncluded, @@ -34,6 +44,7 @@ vi.mock('@/components/xero/agent-runtime/live-agent-runtime', () => ({ if (!agent) return null return (
+
{active ? 'true' : 'false'}
{density}
{inSidebar ? 'true' : 'false'}
{sidebarSessions?.length ?? 0}
@@ -46,6 +57,14 @@ vi.mock('@/components/xero/agent-runtime/live-agent-runtime', () => ({ + + {showIconOnlyNewSessionButton ? null : ( + {inSidebar ? 'New' : 'New Session'} + )} + ) : null} {onSpawnPane ? ( +
+ + + ) +} + +export function browserTabTranslateX( + transform: + | { x: number; y?: number; scaleX?: number; scaleY?: number } + | null + | undefined, +): string | undefined { + return transform ? CSS.Translate.toString({ ...transform, y: 0 }) : undefined +} + +interface BrowserSortableTabProps { + active: boolean + tab: BrowserTabMeta + onClose: (tabId: string) => void + onFocus: (tabId: string) => void +} + +function BrowserSortableTab({ + active, + tab, + onClose, + onFocus, +}: BrowserSortableTabProps) { + const { + attributes, + listeners, + setActivatorNodeRef, + setNodeRef, + transform, + transition, + isDragging, + } = useSortable({ id: tab.id }) + const style: CSSProperties = { + transform: browserTabTranslateX(transform), + transition: isDragging ? "none" : transition, + opacity: isDragging ? 0.82 : undefined, + willChange: transform ? "transform" : undefined, + zIndex: isDragging ? 20 : undefined, + } + const label = tab.title ?? tab.url ?? "New tab" + + return ( +
+ + +
+ ) +} + export function BrowserSidebar({ open, + dictationAdapter, + projectId = null, + fullWidth = false, + fullWidthTarget = null, + onFullWidthChange, onAddAgentContext, - onAddAgentContextLoadingChange, - projectBrowserTargets = [], + penToolDisabledReason = null, + projectBrowserTargets = EMPTY_BROWSER_LAUNCH_TARGETS, + onProjectBrowserTargetUnavailable, pendingOpenUrl = null, onPendingOpenUrlConsumed, + projectRootPath = null, + projectStartTargets = EMPTY_BROWSER_START_TARGETS, }: BrowserSidebarProps) { const [width, setWidth] = useState(viewportDefaultWidth) const [maxWidth, setMaxWidth] = useState(viewportMaxWidth) const [isResizing, setIsResizing] = useState(false) const [address, setAddress] = useState("") + const [addressSuggestionsOpen, setAddressSuggestionsOpen] = useState(false) + const [projectTargetPickerOpen, setProjectTargetPickerOpen] = useState(false) const [tabs, setTabs] = useState([]) const [activeTabId, setActiveTabId] = useState(null) const [loading, setLoading] = useState(false) const [navError, setNavError] = useState(null) const [showCookieBanner, setShowCookieBanner] = useState(false) const [toolMode, setToolMode] = useState(null) + const [toolNoteDraft, setToolNoteDraft] = useState("") + const [toolNoteActive, setToolNoteActive] = useState(false) + const [penHasDrawing, setPenHasDrawing] = useState(false) const [toolSubmitting, setToolSubmitting] = useState(false) + const [captureOverlayVisible, setCaptureOverlayVisible] = useState(false) + const [captureOverlayExiting, setCaptureOverlayExiting] = useState(false) const [toolSubmitError, setToolSubmitError] = useState(null) + const [discoveredProjectBrowserTargets, setDiscoveredProjectBrowserTargets] = + useState([]) + const [projectBrowserTargetLiveness, setProjectBrowserTargetLiveness] = useState>({}) const motionOpen = useSidebarOpenMotion(open) const [openGeometrySettled, setOpenGeometrySettled] = useState(false) - const targetWidth = motionOpen ? width : 0 - const widthMotion = useSidebarWidthMotion(targetWidth, { isResizing }) + const activeFullWidth = open && fullWidth + const renderedWidth = activeFullWidth + ? Math.max(MIN_WIDTH, Math.round(fullWidthTarget ?? viewportFullWidthTarget())) + : width + const targetWidth = motionOpen ? renderedWidth : 0 + const widthMotion = useSidebarWidthMotion(targetWidth, { + isResizing: isResizing && !activeFullWidth, + }) const { browsers: cookieBrowsers, status: importStatus, @@ -330,6 +1005,12 @@ export function BrowserSidebar({ const sidebarRef = useRef(null) const contentRef = useRef(null) const viewportRef = useRef(null) + const projectTargetPickerButtonRef = useRef(null) + const projectTargetPanelRef = useRef(null) + const toolNoteInputRef = useRef(null) + const toolNoteDraftRef = useRef("") + const toolNoteDraftBrowserEchoRef = useRef(null) + const browserToolDictationToggleRef = useRef<() => Promise>(async () => undefined) const addressFocusedRef = useRef(false) const hasWebviewRef = useRef(false) if (tabs.length > 0) { @@ -339,19 +1020,61 @@ export function BrowserSidebar({ const resizeDragRuntimeRef = useRef(null) const cookieSourcesLoadedRef = useRef(false) const openRef = useRef(open) + const projectIdRef = useRef(projectId) const activeTabIdRef = useRef(activeTabId) + const activeTabRef = useRef(null) + const browserToolTargetsRef = useRef(EMPTY_BROWSER_LAUNCH_TARGETS) + const browserToolCaptureSequenceRef = useRef(0) const toolModeRef = useRef(toolMode) const injectedToolModeRef = useRef(null) + const finishCaptureOnOverlayExitRef = useRef(false) const toolActivationRequestRef = useRef(0) + const tabOrderByProjectRef = useRef>({}) const onAddAgentContextRef = useRef(onAddAgentContext) - const onAddAgentContextLoadingChangeRef = useRef(onAddAgentContextLoadingChange) + const onProjectBrowserTargetUnavailableRef = useRef(onProjectBrowserTargetUnavailable) const consumedPendingOpenUrlIdsRef = useRef>(new Set()) + const occlusionFrameRef = useRef(null) + const lastOcclusionKeyRef = useRef("") + const projectTargetScopeKeyRef = useRef(null) openRef.current = open + projectIdRef.current = projectId activeTabIdRef.current = activeTabId toolModeRef.current = toolMode onAddAgentContextRef.current = onAddAgentContext - onAddAgentContextLoadingChangeRef.current = onAddAgentContextLoadingChange + onProjectBrowserTargetUnavailableRef.current = onProjectBrowserTargetUnavailable + + const focusBrowserToolNoteInput = useCallback(() => { + if (!isTauri()) return + void invoke("browser_eval_fire_and_forget", { + js: BROWSER_TOOL_FOCUS_COMPOSER_NOTE_SCRIPT, + }).catch(() => { + /* best-effort focus */ + }) + }, []) + + const readBrowserToolNoteDraft = useCallback(() => toolNoteDraftRef.current, []) + + const browserToolDictation = useSpeechDictation({ + adapter: dictationAdapter, + enabled: open && toolNoteActive, + scopeKey: [ + projectId ?? "global", + activeTabId ?? "none", + toolMode ?? "none", + toolNoteActive ? "active" : "inactive", + ].join(":"), + draftPrompt: toolNoteDraft, + setDraftPrompt: setToolNoteDraft, + promptInputDisabled: !open || !toolNoteActive || toolSubmitting, + promptInputRef: toolNoteInputRef, + focusPromptInput: focusBrowserToolNoteInput, + readDraftPrompt: readBrowserToolNoteDraft, + }) + + useEffect(() => { + browserToolDictationToggleRef.current = browserToolDictation.toggle + }, [browserToolDictation.toggle]) useEffect(() => { if (!open || !motionOpen) { @@ -366,6 +1089,43 @@ export function BrowserSidebar({ return () => window.clearTimeout(timeout) }, [motionOpen, open]) + const cancelBrowserOverlayOcclusionSync = useCallback(() => { + if (occlusionFrameRef.current === null) return + window.cancelAnimationFrame(occlusionFrameRef.current) + occlusionFrameRef.current = null + }, []) + + const syncBrowserOverlayOcclusions = useCallback((options?: { force?: boolean }) => { + if (occlusionFrameRef.current !== null) return + + occlusionFrameRef.current = window.requestAnimationFrame(() => { + occlusionFrameRef.current = null + + if ( + !openRef.current || + (!hasWebviewRef.current && tabsRef.current.length === 0) || + !isTauri() + ) { + return + } + + const node = viewportRef.current + if (!node) return + + const rects = collectBrowserOverlayOcclusionRects(node, RESIZE_HANDLE_INSET) + const key = occlusionRectsKey(rects) + if (!options?.force && key === lastOcclusionKeyRef.current) return + + lastOcclusionKeyRef.current = key + void invoke("browser_set_occlusion_regions", { + rects, + tabId: activeTabIdRef.current, + }).catch(() => { + /* swallow */ + }) + }) + }, []) + const resizeScheduler = useMemo( () => createBrowserResizeScheduler({ @@ -380,9 +1140,10 @@ export function BrowserSidebar({ void invoke("browser_resize", { ...rect, tabId }).catch(() => { /* swallow */ }) + syncBrowserOverlayOcclusions({ force: true }) }, }), - [], + [syncBrowserOverlayOcclusions], ) const syncBrowserViewportToWidth = useCallback( @@ -409,8 +1170,9 @@ export function BrowserSidebar({ }).catch(() => { /* swallow */ }) + syncBrowserOverlayOcclusions({ force: true }) }, - [resizeScheduler], + [resizeScheduler, syncBrowserOverlayOcclusions], ) const markBrowserViewportSyncedToWidth = useCallback( @@ -463,97 +1225,588 @@ export function BrowserSidebar({ applySidebarWidth(nextWidth) resizeScheduler.markSynced(rect) - if (payload.complete) { - runtime.finish?.() - } - }, - [applySidebarWidth, resizeScheduler], - ) + if (payload.complete) { + runtime.finish?.() + } + }, + [applySidebarWidth, resizeScheduler], + ) + + const applyNativeOcclusionWheel = useCallback((payload: BrowserOcclusionWheelPayload) => { + const deltaX = Number.isFinite(payload.deltaX) ? Number(payload.deltaX) : 0 + const deltaY = Number.isFinite(payload.deltaY) ? Number(payload.deltaY) : 0 + if (deltaX === 0 && deltaY === 0) return + + let target: HTMLElement | null = null + const x = Number.isFinite(payload.x) ? Number(payload.x) : null + const y = Number.isFinite(payload.y) ? Number(payload.y) : null + const viewport = viewportRef.current + if (viewport && x !== null && y !== null) { + const viewportRect = viewport.getBoundingClientRect() + target = findNativeWheelOverlayScrollTarget( + document.elementFromPoint(viewportRect.left + x, viewportRect.top + y), + ) + } + + const scrollTarget = target ?? projectTargetPanelRef.current + if (!scrollTarget) return + + if (deltaX !== 0) { + scrollTarget.scrollLeft += deltaX + } + if (deltaY !== 0) { + scrollTarget.scrollTop += deltaY + } + }, []) + + const applyNativeOcclusionClick = useCallback((payload: BrowserOcclusionClickPayload) => { + const x = Number.isFinite(payload.x) ? Number(payload.x) : null + const y = Number.isFinite(payload.y) ? Number(payload.y) : null + const viewport = viewportRef.current + if (!viewport || x === null || y === null) return + + const viewportRect = viewport.getBoundingClientRect() + const element = document.elementFromPoint(viewportRect.left + x, viewportRect.top + y) + const clickTarget = element?.closest( + 'button,a,input,select,textarea,[role="button"],[tabindex]:not([tabindex="-1"])', + ) + if (!clickTarget) return + + clickTarget.focus({ preventScroll: true }) + clickTarget.dispatchEvent( + new MouseEvent("click", { + bubbles: true, + button: 0, + cancelable: true, + clientX: viewportRect.left + x, + clientY: viewportRect.top + y, + }), + ) + }, []) + + const setSidebarWidthAndSync = useCallback( + (nextWidth: number, options: { syncBrowser?: boolean } = {}) => { + if (isResizingRef.current) { + applySidebarWidth(nextWidth) + } else { + widthRef.current = nextWidth + } + setWidth(nextWidth) + if (options.syncBrowser !== false) { + syncBrowserViewportToWidth(nextWidth) + } + }, + [applySidebarWidth, syncBrowserViewportToWidth], + ) + + const activeTab = useMemo( + () => tabs.find((tab) => tab.id === activeTabId && browserTabBelongsToProject(tab, projectId)) ?? null, + [tabs, activeTabId, projectId], + ) + const activeProjectTabs = useMemo( + () => tabs.filter((tab) => browserTabBelongsToProject(tab, projectId)), + [projectId, tabs], + ) + const applyLocalTabOrder = useCallback((nextTabs: BrowserTabMeta[], targetProjectId: string | null) => { + const key = browserTabOrderKey(targetProjectId) + return applyBrowserTabOrder(nextTabs, targetProjectId, tabOrderByProjectRef.current[key]) + }, []) + + const isDevTab = isDevServerUrl(activeTab?.url ?? null) + const pageLabel = activeTab?.title ?? activeTab?.url ?? null + const browserSupportedProjectStartTargets = useMemo( + () => projectStartTargets.filter((target) => target.browserSupported === true), + [projectStartTargets], + ) + const configuredProjectBrowserTargets = useMemo( + () => + projectBrowserTargets.filter((target) => + browserLaunchTargetMatchesBrowserStartTarget(target, projectStartTargets), + ), + [projectBrowserTargets, projectStartTargets], + ) + const availableProjectBrowserTargets = useMemo(() => { + const byId = new Map() + for (const target of discoveredProjectBrowserTargets) { + byId.set(target.id, target) + } + for (const target of configuredProjectBrowserTargets) { + byId.set(target.id, target) + } + const candidates = Array.from(byId.values()).sort((left, right) => right.detectedAt - left.detectedAt) + if (browserSupportedProjectStartTargets.length === 0) return candidates + + const usedTargetIds = new Set() + return browserSupportedProjectStartTargets.flatMap((startTarget) => { + const target = candidates.find( + (candidate) => + !usedTargetIds.has(candidate.id) && + browserLaunchTargetMatchesStartTarget(candidate, startTarget), + ) + if (!target) return [] + usedTargetIds.add(target.id) + return [target] + }) + }, [browserSupportedProjectStartTargets, configuredProjectBrowserTargets, discoveredProjectBrowserTargets]) + const liveProjectBrowserTargets = useMemo( + () => availableProjectBrowserTargets.filter((target) => projectBrowserTargetLiveness[target.id] === true), + [availableProjectBrowserTargets, projectBrowserTargetLiveness], + ) + const showProjectTargetPanel = + liveProjectBrowserTargets.length > 0 && (addressSuggestionsOpen || projectTargetPickerOpen) + const isCheckingProjectBrowserTargets = + availableProjectBrowserTargets.length > 0 && + availableProjectBrowserTargets.some((target) => !(target.id in projectBrowserTargetLiveness)) + activeTabRef.current = activeTab + browserToolTargetsRef.current = availableProjectBrowserTargets + const resizeLockedByPenDrawing = toolMode === "pen" || penHasDrawing + const resizeDisabled = activeFullWidth || resizeLockedByPenDrawing + const isPenToolDisabled = toolSubmitting || Boolean(penToolDisabledReason) + const penToolTooltip = penToolDisabledReason ?? "Sketch on page" + const fullWidthButtonLabel = activeFullWidth ? "Show agent panel" : "Hide agent panel" + const projectTargetScopeKey = useMemo( + () => + JSON.stringify([ + projectId ?? "", + projectRootPath ?? "", + projectStartTargets.map((target) => [ + target.name, + target.command, + target.browserSupported === true, + ]), + ]), + [projectId, projectRootPath, projectStartTargets], + ) + + useEffect(() => { + if (!penToolDisabledReason || toolMode !== "pen") return + setToolMode(null) + }, [penToolDisabledReason, toolMode]) + + useEffect(() => { + if (projectTargetScopeKeyRef.current === null) { + projectTargetScopeKeyRef.current = projectTargetScopeKey + return + } + if (projectTargetScopeKeyRef.current === projectTargetScopeKey) return + projectTargetScopeKeyRef.current = projectTargetScopeKey + setDiscoveredProjectBrowserTargets([]) + setProjectBrowserTargetLiveness({}) + setProjectTargetPickerOpen(false) + }, [projectTargetScopeKey]) + + const deactivateInjectedTool = useCallback(async () => { + if (!isTauri()) return + await invoke("browser_eval_fire_and_forget", { + js: BROWSER_TOOL_DEACTIVATE_SCRIPT, + }).catch(() => { + /* the active page may already have navigated away */ + }) + injectedToolModeRef.current = null + setPenHasDrawing(false) + }, []) + + const restoreInjectedToolCapture = useCallback(async () => { + if (!isTauri()) return + await invoke("browser_eval_fire_and_forget", { + js: BROWSER_TOOL_RESTORE_CAPTURE_SCRIPT, + }).catch(() => { + /* best-effort restore */ + }) + }, []) + + const prepareInjectedToolCapture = useCallback(async () => { + if (!isTauri()) return + await invoke("browser_eval_fire_and_forget", { + js: BROWSER_TOOL_PREPARE_CAPTURE_SCRIPT, + }) + }, []) + + const finishInjectedToolCapture = useCallback(async () => { + if (!isTauri()) return + try { + await invoke("browser_eval_fire_and_forget", { + js: BROWSER_TOOL_FINISH_CAPTURE_SCRIPT(BROWSER_CAPTURE_OVERLAY_EXIT_MS), + }) + injectedToolModeRef.current = null + setPenHasDrawing(false) + } catch { + await deactivateInjectedTool() + } + }, [deactivateInjectedTool]) + + const checkProjectBrowserTargetRunning = useCallback(async (target: BrowserLaunchTarget) => { + if (!isTauri()) return false + const running = await invoke("browser_dev_server_running", { + url: target.url, + }).catch(() => false) + return running === true + }, []) + + const markProjectBrowserTargetUnavailable = useCallback((target: BrowserLaunchTarget) => { + setProjectBrowserTargetLiveness((current) => { + if (current[target.id] === false) return current + return { ...current, [target.id]: false } + }) + onProjectBrowserTargetUnavailableRef.current?.(target.url) + }, []) + + const refreshRunningProjectBrowserTargets = useCallback(async () => { + if (!isTauri()) return + const servers = await invoke( + "browser_list_running_dev_servers", + ).catch(() => null) + if (!servers) return + + const targets = servers.flatMap((server) => { + const label = browserRunningServerDisplayLabel( + server, + projectStartTargets, + projectRootPath, + ) + if (!label) return [] + + const target = makeBrowserLaunchTarget({ + detectedAt: server.detectedAt, + label, + source: label.split(" · ", 1)[0] ?? null, + url: server.url, + }) + return target ? [target] : [] + }) + setDiscoveredProjectBrowserTargets(targets) + }, [projectRootPath, projectStartTargets]) + + const addBrowserToolContextToAgent = useCallback( + async (payload: unknown) => { + const normalized = normalizeBrowserToolContextEvent(payload) + if (!normalized) { + return + } + if (normalized.tabId && normalized.tabId !== activeTabIdRef.current) { + return + } + + const context = normalized.context + setToolSubmitError(null) + if (context.kind === "pen" && penToolDisabledReason) { + setToolSubmitError(penToolDisabledReason) + await restoreInjectedToolCapture() + return + } + const captureIndex = browserToolCaptureSequenceRef.current + 1 + browserToolCaptureSequenceRef.current = captureIndex + if (context.kind === "inspect") { + const metadata = buildBrowserToolPromptMetadataForContext({ + activeTab: activeTabRef.current, + captureIndex, + context, + targets: browserToolTargetsRef.current, + }) + try { + const add = onAddAgentContextRef.current + if (add) { + await add({ + prompt: buildBrowserToolAgentPrompt(context, { + metadata, + screenshotAttached: false, + }), + visiblePrompt: buildBrowserToolVisiblePrompt(context), + contextCard: buildBrowserToolContextCard(context), + }) + } + await deactivateInjectedTool() + setToolMode(null) + } catch (error) { + setToolSubmitError( + getToolErrorMessage(error, "Xero could not add this browser context to the agent composer."), + ) + await restoreInjectedToolCapture() + } + return + } + + setToolSubmitting(true) + try { + await prepareInjectedToolCapture() + await waitForBrowserToolPaint() + const screenshotBase64 = await invoke("browser_screenshot").catch(() => null) + const imageName = imageNameForContext(context) + let image: BrowserAgentContextRequest["image"] | undefined + if (screenshotBase64) { + try { + image = { + bytes: browserScreenshotBytesFromBase64(screenshotBase64), + mediaType: "image/png", + originalName: imageName, + } + } catch { + image = undefined + } + } + const metadata = buildBrowserToolPromptMetadataForContext({ + activeTab: activeTabRef.current, + attachmentName: image ? imageName : null, + captureIndex, + context, + targets: browserToolTargetsRef.current, + }) + const add = onAddAgentContextRef.current + if (add) { + await add({ + prompt: buildBrowserToolAgentPromptForCapture(context, Boolean(image), metadata), + visiblePrompt: buildBrowserToolVisiblePrompt(context), + contextCard: buildBrowserToolContextCard(context), + ...(image ? { image } : {}), + }) + } + finishCaptureOnOverlayExitRef.current = true + injectedToolModeRef.current = null + setPenHasDrawing(false) + setToolMode(null) + } catch (error) { + finishCaptureOnOverlayExitRef.current = false + setToolSubmitError( + getToolErrorMessage(error, "Xero could not add this browser context to the agent composer."), + ) + await restoreInjectedToolCapture() + } finally { + setToolSubmitting(false) + } + }, + [ + deactivateInjectedTool, + penToolDisabledReason, + prepareInjectedToolCapture, + restoreInjectedToolCapture, + ], + ) + + const handleBrowserToolNoteEvent = useCallback((payload: unknown) => { + const normalized = normalizeBrowserToolNoteEvent(payload) + if (!normalized) return + if (normalized.tabId && normalized.tabId !== activeTabIdRef.current) return + if (normalized.mode && normalized.mode !== toolModeRef.current) return + + toolNoteDraftBrowserEchoRef.current = normalized.note + toolNoteDraftRef.current = normalized.note + setToolNoteDraft(normalized.note) + setToolNoteActive(normalized.active) + }, []) + + const handleBrowserToolDictationToggle = useCallback( + async (payload: unknown) => { + const normalized = normalizeBrowserToolDictationToggleEvent(payload) + if (!normalized) return + if (normalized.tabId && normalized.tabId !== activeTabIdRef.current) return + if (normalized.mode && normalized.mode !== toolModeRef.current) return + + toolNoteDraftRef.current = normalized.note + setToolNoteDraft(normalized.note) + setToolNoteActive(true) + await browserToolDictationToggleRef.current() + }, + [], + ) + + useEffect(() => { + toolNoteDraftRef.current = toolNoteDraft + }, [toolNoteDraft]) + + useEffect(() => { + if (!open || toolMode === null || !activeTabId) { + toolNoteDraftBrowserEchoRef.current = null + toolNoteDraftRef.current = "" + setToolNoteActive(false) + setToolNoteDraft("") + } + }, [activeTabId, open, toolMode]) + + useEffect(() => { + if (!open || !toolNoteActive || !isTauri()) { + toolNoteDraftBrowserEchoRef.current = null + return + } + + if (toolNoteDraftBrowserEchoRef.current === toolNoteDraft) { + toolNoteDraftBrowserEchoRef.current = null + return + } + toolNoteDraftBrowserEchoRef.current = null + + void invoke("browser_eval_fire_and_forget", { + js: buildBrowserToolSetComposerNoteScript(toolNoteDraft), + }).catch(() => { + /* the active page may already have navigated away */ + }) + }, [open, toolNoteActive, toolNoteDraft]) + + useEffect(() => { + if (!open || !toolNoteActive || !isTauri()) return + + void invoke("browser_eval_fire_and_forget", { + js: buildBrowserToolDictationStateScript({ + ariaLabel: browserToolDictation.ariaLabel, + audioLevel: browserToolDictation.audioLevel, + isListening: + browserToolDictation.isListening || + browserToolDictation.phase === "requesting" || + browserToolDictation.phase === "stopping", + isToggleDisabled: browserToolDictation.isToggleDisabled, + tooltip: browserToolDictation.tooltip, + visible: browserToolDictation.isVisible, + }), + }).catch(() => { + /* the active page may already have navigated away */ + }) + }, [ + browserToolDictation.ariaLabel, + browserToolDictation.audioLevel, + browserToolDictation.isListening, + browserToolDictation.isToggleDisabled, + browserToolDictation.isVisible, + browserToolDictation.phase, + browserToolDictation.tooltip, + open, + toolNoteActive, + ]) + + useEffect(() => { + if (!toolNoteActive || !browserToolDictation.error) return + setToolSubmitError(browserToolDictation.error.message) + }, [browserToolDictation.error, toolNoteActive]) + + useEffect(() => { + if (toolSubmitting) { + setCaptureOverlayVisible(true) + setCaptureOverlayExiting(false) + return + } + + if (!captureOverlayVisible) return + + finishCaptureOnOverlayExitRef.current = + finishCaptureOnOverlayExitRef.current && isTauri() + setCaptureOverlayExiting(true) + const timeout = window.setTimeout(() => { + setCaptureOverlayVisible(false) + setCaptureOverlayExiting(false) + }, BROWSER_CAPTURE_OVERLAY_EXIT_MS) + + return () => window.clearTimeout(timeout) + }, [captureOverlayVisible, toolSubmitting]) + + useEffect(() => { + if (!captureOverlayExiting || !finishCaptureOnOverlayExitRef.current) return + finishCaptureOnOverlayExitRef.current = false + void finishInjectedToolCapture() + }, [captureOverlayExiting, finishInjectedToolCapture]) + + useEffect(() => { + if (!open || !isTauri()) return + syncBrowserOverlayOcclusions({ force: true }) + }, [captureOverlayExiting, captureOverlayVisible, open, syncBrowserOverlayOcclusions]) + + useEffect(() => { + if (!open || !isTauri()) { + setDiscoveredProjectBrowserTargets([]) + return + } + + let cancelled = false + let timeout: number | null = null + + const refreshTargets = async () => { + await refreshRunningProjectBrowserTargets() + if (cancelled) return + if (shouldRepeatProjectBrowserTargetPoll()) { + timeout = scheduleProjectBrowserTargetPoll(refreshTargets) + } + } + + void refreshTargets() + + return () => { + cancelled = true + if (timeout !== null) window.clearTimeout(timeout) + } + }, [open, refreshRunningProjectBrowserTargets]) + + useEffect(() => { + if (!open || availableProjectBrowserTargets.length === 0 || !isTauri()) { + setProjectBrowserTargetLiveness({}) + setProjectTargetPickerOpen(false) + return + } + + let cancelled = false + let timeout: number | null = null + + const checkTargets = async () => { + const snapshot = availableProjectBrowserTargets + const results = await Promise.all( + snapshot.map(async (target) => ({ + running: await checkProjectBrowserTargetRunning(target), + target, + })), + ) + if (cancelled) return - const setSidebarWidthAndSync = useCallback( - (nextWidth: number, options: { syncBrowser?: boolean } = {}) => { - if (isResizingRef.current) { - applySidebarWidth(nextWidth) - } else { - widthRef.current = nextWidth + const next: Record = {} + for (const { running, target } of results) { + next[target.id] = running + if (!running) { + onProjectBrowserTargetUnavailableRef.current?.(target.url) + } } - setWidth(nextWidth) - if (options.syncBrowser !== false) { - syncBrowserViewportToWidth(nextWidth) + setProjectBrowserTargetLiveness(next) + if (shouldRepeatProjectBrowserTargetPoll()) { + timeout = scheduleProjectBrowserTargetPoll(checkTargets) } - }, - [applySidebarWidth, syncBrowserViewportToWidth], - ) + } - const activeTab = useMemo( - () => tabs.find((tab) => tab.id === activeTabId) ?? null, - [tabs, activeTabId], - ) + void checkTargets() - const isDevTab = isDevServerUrl(activeTab?.url ?? null) - const pageLabel = activeTab?.title ?? activeTab?.url ?? null + return () => { + cancelled = true + if (timeout !== null) window.clearTimeout(timeout) + } + }, [availableProjectBrowserTargets, checkProjectBrowserTargetRunning, open]) - const deactivateInjectedTool = useCallback(async () => { - if (!isTauri()) return - await invoke("browser_eval_fire_and_forget", { - js: BROWSER_TOOL_DEACTIVATE_SCRIPT, - }).catch(() => { - /* the active page may already have navigated away */ - }) - injectedToolModeRef.current = null - }, []) + useEffect(() => { + if (liveProjectBrowserTargets.length > 0) return + setProjectTargetPickerOpen(false) + }, [liveProjectBrowserTargets.length]) - const restoreInjectedToolCapture = useCallback(async () => { - if (!isTauri()) return - await invoke("browser_eval_fire_and_forget", { - js: BROWSER_TOOL_RESTORE_CAPTURE_SCRIPT, - }).catch(() => { - /* best-effort restore */ - }) - }, []) + useEffect(() => { + if (!open) { + setProjectTargetPickerOpen(false) + setAddressSuggestionsOpen(false) + } + }, [open]) - const addBrowserToolContextToAgent = useCallback( - async (payload: BrowserToolContextEventPayload) => { - if (payload.tabId !== activeTabIdRef.current) return - if (!isBrowserToolContext(payload.context)) return + useEffect(() => { + if (!projectTargetPickerOpen) return + + const handlePointerDown = (event: PointerEvent) => { + const target = event.target + if (!(target instanceof Node)) return + if (projectTargetPickerButtonRef.current?.contains(target)) return + if (projectTargetPanelRef.current?.contains(target)) return + setProjectTargetPickerOpen(false) + } - const context = payload.context - setToolSubmitError(null) - setToolSubmitting(true) - onAddAgentContextLoadingChangeRef.current?.(true) - try { - await waitForBrowserToolPaint() - await invoke("browser_eval_fire_and_forget", { - js: BROWSER_TOOL_PREPARE_CAPTURE_SCRIPT, - }) - await waitForBrowserToolPaint() - const screenshotBase64 = await invoke("browser_screenshot") - const add = onAddAgentContextRef.current - if (add) { - await add({ - prompt: buildBrowserToolAgentPrompt(context), - visiblePrompt: buildBrowserToolVisiblePrompt(context), - image: { - bytes: browserScreenshotBytesFromBase64(screenshotBase64), - mediaType: "image/png", - originalName: imageNameForContext(context), - }, - }) - } - await deactivateInjectedTool() - setToolMode(null) - } catch (error) { - setToolSubmitError( - getToolErrorMessage(error, "Xero could not add this browser context to the agent composer."), - ) - await restoreInjectedToolCapture() - } finally { - setToolSubmitting(false) - onAddAgentContextLoadingChangeRef.current?.(false) - } - }, - [deactivateInjectedTool, restoreInjectedToolCapture], - ) + document.addEventListener("pointerdown", handlePointerDown, true) + return () => document.removeEventListener("pointerdown", handlePointerDown, true) + }, [projectTargetPickerOpen]) + + useEffect(() => { + if (!open || !isTauri()) return + if (!hasWebviewRef.current && tabsRef.current.length === 0) return + resizeScheduler.reset() + resizeScheduler.schedule({ force: true }) + syncBrowserOverlayOcclusions({ force: true }) + }, [open, resizeScheduler, showProjectTargetPanel, syncBrowserOverlayOcclusions]) // Tools are gated to dev-server tabs. Drop the active tool whenever the // tab/URL changes to one that isn't a dev server, or when the sidebar closes. @@ -561,6 +1814,7 @@ export function BrowserSidebar({ if (toolMode === null) return if (!open || !isDevTab) { setToolMode(null) + setPenHasDrawing(false) } }, [open, isDevTab, toolMode]) @@ -575,6 +1829,7 @@ export function BrowserSidebar({ } const requestId = ++toolActivationRequestRef.current + setPenHasDrawing(false) const script = buildBrowserToolActivationScript({ mode: toolMode, pageLabel, @@ -613,6 +1868,19 @@ export function BrowserSidebar({ resizeScheduler.schedule({ force: true }) }, [activeTabId, open, resizeScheduler]) + useEffect(() => { + if (!open || !isTauri()) return + if (!hasWebviewRef.current && tabsRef.current.length === 0) return + const forceSync = () => { + resizeScheduler.reset() + resizeScheduler.schedule({ force: true }) + } + + forceSync() + const timeout = window.setTimeout(forceSync, SIDEBAR_GEOMETRY_SETTLE_MS) + return () => window.clearTimeout(timeout) + }, [activeFullWidth, fullWidthTarget, open, resizeScheduler]) + useEffect(() => { if (!openGeometrySettled || !isTauri()) return if (!hasWebviewRef.current && tabsRef.current.length === 0) return @@ -625,8 +1893,9 @@ export function BrowserSidebar({ if (!open || !isTauri()) return if (!hasWebviewRef.current && tabsRef.current.length === 0) return - const node = viewportRef.current - if (!node) return + const viewportNode = viewportRef.current + const sidebarNode = sidebarRef.current + if (!viewportNode && !sidebarNode) return const ResizeObserverCtor = window.ResizeObserver if (typeof ResizeObserverCtor !== "function") { @@ -637,11 +1906,43 @@ export function BrowserSidebar({ const observer = new ResizeObserverCtor(() => { resizeScheduler.schedule() }) - observer.observe(node) + if (viewportNode) observer.observe(viewportNode) + if (sidebarNode && sidebarNode !== viewportNode) observer.observe(sidebarNode) return () => observer.disconnect() }, [activeTabId, open, resizeScheduler]) + useEffect(() => { + if (!open || !isTauri()) return + if (!hasWebviewRef.current && tabsRef.current.length === 0) return + + syncBrowserOverlayOcclusions({ force: true }) + + const schedule = () => syncBrowserOverlayOcclusions() + const observer = new MutationObserver(schedule) + if (document.body) { + observer.observe(document.body, { + attributes: true, + attributeFilter: ["aria-hidden", "class", "data-state", "hidden", "style"], + childList: true, + subtree: true, + }) + } + + window.addEventListener("resize", schedule) + window.addEventListener("scroll", schedule, true) + document.addEventListener("animationend", schedule, true) + document.addEventListener("transitionend", schedule, true) + + return () => { + observer.disconnect() + window.removeEventListener("resize", schedule) + window.removeEventListener("scroll", schedule, true) + document.removeEventListener("animationend", schedule, true) + document.removeEventListener("transitionend", schedule, true) + } + }, [activeTabId, open, syncBrowserOverlayOcclusions]) + useEffect(() => { if ( open || @@ -655,6 +1956,7 @@ export function BrowserSidebar({ void invoke("browser_hide").catch(() => { /* swallow */ }) + lastOcclusionKeyRef.current = "" }, [open, resizeScheduler]) useEffect(() => { @@ -669,7 +1971,13 @@ export function BrowserSidebar({ return () => window.removeEventListener("resize", handleResize) }, [resizeScheduler]) - useEffect(() => () => resizeScheduler.cancel(), [resizeScheduler]) + useEffect( + () => () => { + resizeScheduler.cancel() + cancelBrowserOverlayOcclusionSync() + }, + [cancelBrowserOverlayOcclusionSync, resizeScheduler], + ) // Wire backend events useEffect(() => { @@ -717,12 +2025,26 @@ export function BrowserSidebar({ } }, onTabUpdated: (payload) => { - setTabs(payload.tabs) - hasWebviewRef.current = payload.tabs.length > 0 - const active = payload.tabs.find((tab) => tab.active) + const orderedTabs = applyLocalTabOrder(payload.tabs, projectIdRef.current) + setTabs(orderedTabs) + hasWebviewRef.current = orderedTabs.some((tab) => + browserTabBelongsToProject(tab, projectIdRef.current), + ) + const active = selectActiveBrowserTab(orderedTabs, projectIdRef.current) if (active) { activeTabIdRef.current = active.id setActiveTabId(active.id) + setLoading(active.loading) + if (active.url && !addressFocusedRef.current) { + setAddress(active.url) + } + } else { + activeTabIdRef.current = null + setActiveTabId(null) + setLoading(false) + if (!addressFocusedRef.current) { + setAddress("") + } } }, }) @@ -759,6 +2081,18 @@ export function BrowserSidebar({ }), ) + trackUnlisten( + listen("browser:dev_server_unavailable", (event) => { + recordIpcPayloadSample({ + boundary: "event", + name: "browser:dev_server_unavailable", + payload: event.payload, + }) + const url = typeof event.payload?.url === "string" ? event.payload.url : null + if (url) onProjectBrowserTargetUnavailableRef.current?.(url) + }), + ) + trackUnlisten( listen("browser:resize_drag", (event) => { recordIpcPayloadSample({ boundary: "event", name: "browser:resize_drag", payload: event.payload }) @@ -767,53 +2101,117 @@ export function BrowserSidebar({ ) trackUnlisten( - listen(BROWSER_TOOL_CONTEXT_EVENT, (event) => { + listen(BROWSER_OCCLUSION_CLICK_EVENT, (event) => { + recordIpcPayloadSample({ boundary: "event", name: BROWSER_OCCLUSION_CLICK_EVENT, payload: event.payload }) + applyNativeOcclusionClick(event.payload) + }), + ) + + trackUnlisten( + listen("browser:occlusion_wheel", (event) => { + recordIpcPayloadSample({ boundary: "event", name: "browser:occlusion_wheel", payload: event.payload }) + applyNativeOcclusionWheel(event.payload) + }), + ) + + trackUnlisten( + listen(BROWSER_TOOL_CONTEXT_EVENT, (event) => { recordIpcPayloadSample({ boundary: "event", name: BROWSER_TOOL_CONTEXT_EVENT, payload: event.payload }) void addBrowserToolContextToAgent(event.payload) }), ) trackUnlisten( - listen(BROWSER_TOOL_CLOSED_EVENT, (event) => { + listen(BROWSER_TOOL_NOTE_EVENT, (event) => { + recordIpcPayloadSample({ boundary: "event", name: BROWSER_TOOL_NOTE_EVENT, payload: event.payload }) + handleBrowserToolNoteEvent(event.payload) + }), + ) + + trackUnlisten( + listen(BROWSER_TOOL_DICTATION_TOGGLE_EVENT, (event) => { + recordIpcPayloadSample({ + boundary: "event", + name: BROWSER_TOOL_DICTATION_TOGGLE_EVENT, + payload: event.payload, + }) + void handleBrowserToolDictationToggle(event.payload) + }), + ) + + trackUnlisten( + listen(BROWSER_TOOL_CLOSED_EVENT, (event) => { recordIpcPayloadSample({ boundary: "event", name: BROWSER_TOOL_CLOSED_EVENT, payload: event.payload }) - if (event.payload.tabId === activeTabIdRef.current) { + const payload = normalizeBrowserToolClosedEvent(event.payload) + if (!payload) return + if (!payload.tabId || payload.tabId === activeTabIdRef.current) { injectedToolModeRef.current = null setToolMode(null) + setToolNoteActive(false) + setPenHasDrawing(false) } }), ) + trackUnlisten( + listen(BROWSER_TOOL_STATE_EVENT, (event) => { + recordIpcPayloadSample({ boundary: "event", name: BROWSER_TOOL_STATE_EVENT, payload: event.payload }) + const payload = normalizeBrowserToolStateEvent(event.payload) + if (!payload) return + if (payload.tabId && payload.tabId !== activeTabIdRef.current) return + setPenHasDrawing(payload.mode === "pen" && payload.hasDrawing) + }), + ) + return () => { cancelled = true coalescer.dispose() unsubs.forEach((unsub) => unsub()) } - }, [addBrowserToolContextToAgent, applyNativeResizeDrag]) + }, [ + addBrowserToolContextToAgent, + applyLocalTabOrder, + applyNativeOcclusionClick, + applyNativeOcclusionWheel, + applyNativeResizeDrag, + handleBrowserToolDictationToggle, + handleBrowserToolNoteEvent, + ]) // Hydrate tabs when sidebar opens useEffect(() => { if (!open || !isTauri()) return let cancelled = false - void safeInvoke("browser_tab_list").then((list) => { + void safeInvoke("browser_tab_list", { + projectId, + }).then((list) => { if (cancelled || !list) return - setTabs(list) - hasWebviewRef.current = list.length > 0 - const active = list.find((tab) => tab.active) ?? list[0] ?? null + const orderedList = applyLocalTabOrder(list, projectId) + setTabs(orderedList) + hasWebviewRef.current = orderedList.length > 0 + const active = selectActiveBrowserTab(orderedList, projectId) if (active) { activeTabIdRef.current = active.id setActiveTabId(active.id) + setLoading(active.loading) if (active.url && !addressFocusedRef.current) setAddress(active.url) + } else { + activeTabIdRef.current = null + setActiveTabId(null) + setLoading(false) + if (!addressFocusedRef.current) setAddress("") } }) return () => { cancelled = true } - }, [open]) + }, [applyLocalTabOrder, open, projectId]) const handleResizeStart = useCallback( (event: React.PointerEvent) => { if (event.button !== 0) return event.preventDefault() + if (resizeLockedByPenDrawing) return const startX = event.clientX const startWidth = widthRef.current const ceiling = viewportMaxWidth() @@ -918,6 +2316,7 @@ export function BrowserSidebar({ [ applySidebarWidth, markBrowserViewportSyncedToWidth, + resizeLockedByPenDrawing, resizeScheduler, setSidebarWidthAndSync, syncBrowserViewportToWidth, @@ -927,6 +2326,7 @@ export function BrowserSidebar({ const handleResizeKey = useCallback((event: React.KeyboardEvent) => { if (event.key !== "ArrowLeft" && event.key !== "ArrowRight") return event.preventDefault() + if (resizeLockedByPenDrawing) return const step = event.shiftKey ? 32 : 8 const ceiling = viewportMaxWidth() const delta = event.key === "ArrowLeft" ? step : -step @@ -934,14 +2334,15 @@ export function BrowserSidebar({ setMaxWidth(ceiling) setSidebarWidthAndSync(nextWidth) resizeScheduler.schedule({ force: true }) - }, [resizeScheduler, setSidebarWidthAndSync]) + }, [resizeLockedByPenDrawing, resizeScheduler, setSidebarWidthAndSync]) const openUrl = useCallback( (target: string, options?: { tabId?: string; newTab?: boolean }) => { setNavError(null) + const navigationTarget = normalizeLoopbackBrowserUrl(target) if (!isTauri()) { - setAddress(target) + setAddress(navigationTarget) return } @@ -950,7 +2351,8 @@ export function BrowserSidebar({ const viewport = readBrowserViewportRect(node, RESIZE_HANDLE_INSET) const forceNew = options?.newTab === true const payload = { - url: target, + projectId: projectIdRef.current, + url: navigationTarget, ...viewport, tabId: forceNew ? null : options?.tabId ?? activeTabId ?? null, newTab: forceNew, @@ -964,6 +2366,7 @@ export function BrowserSidebar({ activeTabIdRef.current = meta.id setActiveTabId(meta.id) } + syncBrowserOverlayOcclusions({ force: true }) }) .catch((error: unknown) => { hasWebviewRef.current = false @@ -975,7 +2378,7 @@ export function BrowserSidebar({ setNavError(message || "Failed to open page") }) }, - [activeTabId, resizeScheduler], + [activeTabId, resizeScheduler, syncBrowserOverlayOcclusions], ) const handleSubmit = useCallback( @@ -1005,19 +2408,30 @@ export function BrowserSidebar({ const handleReload = useCallback(() => { if (!isTauri()) return + if (loading) { + setLoading(false) + void invoke("browser_stop").catch(() => { + /* swallow */ + }) + return + } void invoke("browser_reload", { tabId: activeTabId ?? null }).catch(() => { /* swallow */ }) - }, [activeTabId]) + }, [activeTabId, loading]) const handleTabFocus = useCallback( (tabId: string) => { if (!isTauri() || tabId === activeTabId) return - void invoke("browser_tab_focus", { tabId }) + void invoke("browser_tab_focus", { + projectId: projectIdRef.current, + tabId, + }) .then((meta) => { if (meta) { activeTabIdRef.current = meta.id setActiveTabId(meta.id) + setLoading(meta.loading) if (meta.url && !addressFocusedRef.current) setAddress(meta.url) } }) @@ -1031,19 +2445,25 @@ export function BrowserSidebar({ const handleTabClose = useCallback( (tabId: string) => { if (!isTauri()) return - void invoke("browser_tab_close", { tabId }) + void invoke("browser_tab_close", { + projectId: projectIdRef.current, + tabId, + }) .then((list) => { if (!list) return - setTabs(list) - if (list.length === 0) { + const orderedList = applyLocalTabOrder(list, projectIdRef.current) + setTabs(orderedList) + if (orderedList.length === 0) { activeTabIdRef.current = null setActiveTabId(null) setAddress("") + setLoading(false) hasWebviewRef.current = false } else { - const next = list.find((tab) => tab.active) ?? list[0] + const next = selectActiveBrowserTab(orderedList, projectIdRef.current) ?? orderedList[0] activeTabIdRef.current = next.id setActiveTabId(next.id) + setLoading(next.loading) if (next.url) setAddress(next.url) } }) @@ -1051,28 +2471,93 @@ export function BrowserSidebar({ /* swallow */ }) }, - [], + [applyLocalTabOrder], ) + const handleTabReorder = useCallback((activeTabId: string, overTabId: string) => { + if (!activeTabId || !overTabId || activeTabId === overTabId) return + + const projectId = projectIdRef.current + setTabs((current) => { + const next = reorderBrowserTabs(current, projectId, activeTabId, overTabId) + tabOrderByProjectRef.current[browserTabOrderKey(projectId)] = browserProjectTabIds(next, projectId) + return next + }) + + if (!isTauri()) return + void invoke("browser_tab_reorder", { + projectId, + activeTabId, + overTabId, + }) + .then((list) => { + if (list) { + setTabs(applyLocalTabOrder(list, projectId)) + } + }) + .catch(() => { + void safeInvoke("browser_tab_list", { + projectId, + }).then((list) => { + if (list) setTabs(applyLocalTabOrder(list, projectId)) + }) + }) + }, [applyLocalTabOrder]) + const handleNewTab = useCallback(() => { if (!isTauri()) return openUrl("https://www.google.com/", { newTab: true }) }, [openUrl]) const handleOpenProjectBrowserTarget = useCallback( - (target: BrowserLaunchTarget) => { + async (target: BrowserLaunchTarget) => { + setAddressSuggestionsOpen(false) + setProjectTargetPickerOpen(false) + + if (projectBrowserTargetLiveness[target.id] !== true) { + const running = await checkProjectBrowserTargetRunning(target) + if (!running) { + markProjectBrowserTargetUnavailable(target) + return + } + } + + setProjectBrowserTargetLiveness((current) => { + if (current[target.id] === true) return current + return { ...current, [target.id]: true } + }) setAddress(target.url) openUrl(target.url) }, - [openUrl], + [ + checkProjectBrowserTargetRunning, + markProjectBrowserTargetUnavailable, + openUrl, + projectBrowserTargetLiveness, + ], ) + const handleProjectTargetPanelWheel = useCallback((event: WheelEvent) => { + const panel = event.currentTarget + const deltaY = + event.deltaMode === 1 + ? event.deltaY * 16 + : event.deltaMode === 2 + ? event.deltaY * panel.clientHeight + : event.deltaY + + panel.scrollTop += deltaY + event.preventDefault() + event.stopPropagation() + }, []) + useEffect(() => { if (!open || !openGeometrySettled || !pendingOpenUrl) return if (consumedPendingOpenUrlIdsRef.current.has(pendingOpenUrl.id)) return consumedPendingOpenUrlIdsRef.current.add(pendingOpenUrl.id) - setAddress(pendingOpenUrl.url) - openUrl(pendingOpenUrl.url) + const url = normalizeLoopbackBrowserUrl(pendingOpenUrl.url) + setAddress(url) + openUrl(url) onPendingOpenUrlConsumed?.(pendingOpenUrl.id) }, [onPendingOpenUrlConsumed, open, openGeometrySettled, openUrl, pendingOpenUrl]) @@ -1082,7 +2567,7 @@ export function BrowserSidebar({ // the shared cookie store. useEffect(() => { if (!open || !isTauri()) return - if (tabs.length === 0) return + if (activeProjectTabs.length === 0) return if (cookieSourcesLoadedRef.current) return cookieSourcesLoadedRef.current = true @@ -1093,7 +2578,7 @@ export function BrowserSidebar({ if (!list.some((browser) => browser.available)) return setShowCookieBanner(true) }) - }, [open, tabs.length, refreshCookieSources]) + }, [activeProjectTabs.length, open, refreshCookieSources]) const handleImportCookies = useCallback( async (browser: DetectedBrowser) => { @@ -1110,7 +2595,7 @@ export function BrowserSidebar({ // Show the tab strip (and the + button) as soon as there's any tab — otherwise // users have no way to open a second tab because the new-tab trigger lives there. - const showTabs = tabs.length > 0 + const showTabs = activeProjectTabs.length > 0 return ( diff --git a/client/components/xero/browser-tool-injection.ts b/client/components/xero/browser-tool-injection.ts index f09f5a0b..cfd948d3 100644 --- a/client/components/xero/browser-tool-injection.ts +++ b/client/components/xero/browser-tool-injection.ts @@ -26,12 +26,33 @@ export interface BrowserToolTheme { export const BROWSER_TOOL_CONTEXT_EVENT = "browser:tool_context" export const BROWSER_TOOL_CLOSED_EVENT = "browser:tool_closed" +export const BROWSER_TOOL_STATE_EVENT = "browser:tool_state" +export const BROWSER_TOOL_NOTE_EVENT = "browser:tool_note" +export const BROWSER_TOOL_DICTATION_TOGGLE_EVENT = "browser:tool_dictation_toggle" export interface BrowserToolPageContext { url: string title: string | null } +export interface BrowserToolRectContext { + x: number + y: number + width: number + height: number +} + +export interface BrowserToolScrollContext { + x: number + y: number +} + +export interface BrowserToolViewportContext { + width: number + height: number + devicePixelRatio?: number | null +} + export interface BrowserToolElementContext { selector: string | null tagName: string @@ -40,12 +61,23 @@ export interface BrowserToolElementContext { role: string | null label: string | null text: string | null - rect: { - x: number - y: number - width: number - height: number - } + attributes?: Array<{ name: string; value: string }> + ancestors?: Array<{ + selector: string | null + tagName: string + id: string | null + role: string | null + label: string | null + }> + source?: { + framework: string | null + componentName: string | null + filePath: string | null + lineNumber: number | null + columnNumber: number | null + raw: string | null + } | null + rect: BrowserToolRectContext } export type BrowserToolContext = @@ -54,16 +86,25 @@ export type BrowserToolContext = note: string page: BrowserToolPageContext strokeCount: number - viewport: { width: number; height: number } + viewport: BrowserToolViewportContext + scroll?: BrowserToolScrollContext | null + annotationBounds?: BrowserToolRectContext | null } | { kind: "inspect" note: string page: BrowserToolPageContext element: BrowserToolElementContext - viewport: { width: number; height: number } + viewport: BrowserToolViewportContext + scroll?: BrowserToolScrollContext | null } +export interface BrowserToolPromptMetadata { + appLabel?: string | null + attachmentName?: string | null + captureIndex?: number | null +} + export interface BrowserToolContextEventPayload { tabId: string context: BrowserToolContext @@ -77,7 +118,12 @@ export interface BrowserToolClosedEventPayload { export interface BrowserAgentContextRequest { prompt: string visiblePrompt: string - image: { + contextCard?: { + kind: "element" | "sketch" + title: string + subtitle?: string + } + image?: { bytes: Uint8Array mediaType: "image/png" originalName: string @@ -167,6 +213,10 @@ const BROWSER_TOOL_RUNTIME = String.raw` ;(function () { var VERSION = 1; var ROOT_ID = "__xero-browser-tool-root"; + var PEN_DOCUMENT_LAYER_ID = "__xero-browser-pen-document-layer"; + var PEN_DOCUMENT_ROOT_ID = "__xero-browser-pen-document-root"; + var TOOL_Z_INDEX = "2147483647"; + var TOOLBAR_POSITION_KEY = "__xeroBrowserToolToolbarPosition"; var DEFAULT_THEME = ${JSON.stringify(DEFAULT_BROWSER_TOOL_THEME)}; var THEME_KEYS = Object.keys(DEFAULT_THEME); var RAINBOW_STOPS = [ @@ -179,10 +229,45 @@ const BROWSER_TOOL_RUNTIME = String.raw` ["100%", "#ff2dff"] ]; + function safeStringify(value) { + try { + if (value === undefined) return null; + return JSON.stringify(value); + } catch (_error) { + try { + return JSON.stringify(String(value)); + } catch (_inner) { + return null; + } + } + } + + function emitTauriInternalBrowserEvent(kind, payload) { + try { + var tauri = window.__TAURI_INTERNALS__; + if (!tauri || typeof tauri.invoke !== "function") return false; + var result = tauri.invoke("browser_internal_event", { + kind: String(kind || ""), + payload: safeStringify(payload || {}) + }); + if (result && typeof result.catch === "function") { + result.catch(function () {}); + } + return true; + } catch (_error) { + return false; + } + } + function bridgeEmit(kind, payload) { + if (emitTauriInternalBrowserEvent(kind, payload)) { + return; + } + try { if (window.__xeroBridge__ && typeof window.__xeroBridge__.emit === "function") { window.__xeroBridge__.emit(kind, payload || {}); + return; } } catch (_error) { // best-effort bridge @@ -199,7 +284,15 @@ const BROWSER_TOOL_RUNTIME = String.raw` function viewportContext() { return { width: Math.round(window.innerWidth || 0), - height: Math.round(window.innerHeight || 0) + height: Math.round(window.innerHeight || 0), + devicePixelRatio: round(window.devicePixelRatio || 1) + }; + } + + function pageScrollContext() { + return { + x: Math.round(window.scrollX || window.pageXOffset || 0), + y: Math.round(window.scrollY || window.pageYOffset || 0) }; } @@ -274,14 +367,233 @@ const BROWSER_TOOL_RUNTIME = String.raw` return parts.length > 0 ? parts.join(" > ") : element.tagName.toLowerCase(); } + function compactAttributeValue(value, max) { + return compactText(String(value || ""), max) || ""; + } + + function importantAttributes(element) { + if (!element || typeof element.getAttributeNames !== "function") return []; + var names = []; + try { + names = element.getAttributeNames(); + } catch (_error) { + names = []; + } + var priority = { + role: true, + "aria-label": true, + "aria-labelledby": true, + title: true, + alt: true, + name: true, + type: true, + href: true, + placeholder: true, + "data-testid": true, + "data-test": true, + "data-cy": true, + "data-component": true, + }; + var attrs = []; + names.sort(function (a, b) { + var ap = priority[a] ? 0 : 1; + var bp = priority[b] ? 0 : 1; + if (ap !== bp) return ap - bp; + return a < b ? -1 : a > b ? 1 : 0; + }); + for (var index = 0; index < names.length && attrs.length < 6; index += 1) { + var name = String(names[index] || ""); + if (!name || !priority[name]) continue; + if (/password|secret|token|key/i.test(name)) continue; + var value = element.getAttribute(name); + if (value == null) continue; + attrs.push({ name: name, value: compactAttributeValue(value, 140) }); + } + return attrs; + } + + function readNumericSourceValue(value) { + var number = Number(value); + return Number.isFinite(number) && number > 0 ? Math.round(number) : null; + } + + function parseSourceRaw(raw) { + var text = compactText(raw, 500); + if (!text) return null; + var match = text.match(/((?:[A-Za-z]:[\\/]|\/|\.{1,2}\/|[^\s:()]+\/)[^\s:()]+\.(?:tsx?|jsx?|vue|svelte|astro|html|css|scss))(?:[:(](\d+))?(?::(\d+))?/i); + if (!match) return { raw: text }; + return { + filePath: match[1] || null, + lineNumber: readNumericSourceValue(match[2]), + columnNumber: readNumericSourceValue(match[3]), + raw: text + }; + } + + function normalizeSourceHint(hint) { + if (!hint) return null; + var raw = hint.raw ? compactText(hint.raw, 500) : null; + var parsed = hint.filePath ? null : parseSourceRaw(raw || ""); + return { + framework: hint.framework || null, + componentName: hint.componentName || null, + filePath: hint.filePath || (parsed && parsed.filePath) || hint.fileName || null, + lineNumber: readNumericSourceValue(hint.lineNumber) || (parsed && parsed.lineNumber) || null, + columnNumber: readNumericSourceValue(hint.columnNumber) || (parsed && parsed.columnNumber) || null, + raw: raw || (parsed && parsed.raw) || null + }; + } + + function sourceFromAttributes(element) { + var filePath = + element.getAttribute("data-file-path") || + element.getAttribute("data-file") || + element.getAttribute("data-source-file") || + element.getAttribute("data-astro-source-file") || + null; + var raw = + element.getAttribute("data-source") || + element.getAttribute("data-src") || + element.getAttribute("data-loc") || + element.getAttribute("data-vite-dev-id") || + element.getAttribute("data-astro-source-loc") || + filePath || + null; + var componentName = + element.getAttribute("data-component") || + element.getAttribute("data-component-name") || + null; + var lineNumber = + readNumericSourceValue(element.getAttribute("data-line")) || + readNumericSourceValue(element.getAttribute("data-source-line")); + var columnNumber = + readNumericSourceValue(element.getAttribute("data-column")) || + readNumericSourceValue(element.getAttribute("data-source-column")); + if (!filePath && !raw && !componentName && !lineNumber && !columnNumber) return null; + return normalizeSourceHint({ + framework: "dom-attributes", + componentName: componentName, + filePath: filePath, + lineNumber: lineNumber, + columnNumber: columnNumber, + raw: raw + }); + } + + function componentNameForType(type) { + if (!type) return null; + if (typeof type === "string") return type; + return type.displayName || type.name || type.__name || null; + } + + function reactFiberForElement(element) { + var keys = []; + try { + keys = Object.keys(element); + } catch (_error) { + keys = []; + } + for (var index = 0; index < keys.length; index += 1) { + var key = keys[index]; + if (/^__react(?:Fiber|InternalInstance)\$/i.test(key)) { + return element[key] || null; + } + } + return null; + } + + function sourceFromReact(element) { + var fiber = reactFiberForElement(element); + var current = fiber; + var fallbackName = null; + for (var depth = 0; current && depth < 30; depth += 1) { + fallbackName = + fallbackName || + componentNameForType(current.elementType) || + componentNameForType(current.type); + var source = current._debugSource || (current._debugOwner && current._debugOwner._debugSource); + if (source) { + return normalizeSourceHint({ + framework: "react", + componentName: + fallbackName || + componentNameForType(current._debugOwner && current._debugOwner.type), + filePath: source.fileName || source.filePath || null, + lineNumber: source.lineNumber || null, + columnNumber: source.columnNumber || null, + raw: source.fileName || null + }); + } + current = current.return || null; + } + return fallbackName ? normalizeSourceHint({ framework: "react", componentName: fallbackName }) : null; + } + + function sourceFromVue(element) { + var current = element && element.__vueParentComponent; + for (var depth = 0; current && depth < 20; depth += 1) { + var type = current.type || {}; + var file = type.__file || null; + var name = type.name || type.__name || null; + if (file || name) { + return normalizeSourceHint({ + framework: "vue", + componentName: name, + filePath: file, + raw: file + }); + } + current = current.parent || null; + } + return null; + } + + function sourceHintForElement(element) { + return ( + sourceFromAttributes(element) || + sourceFromReact(element) || + sourceFromVue(element) || + null + ); + } + + function ancestorSummary(element) { + var ancestors = []; + var current = element ? element.parentElement : null; + while ( + current && + current.nodeType === 1 && + current !== document.body && + current !== document.documentElement && + ancestors.length < 3 + ) { + ancestors.push({ + selector: selectorFor(current), + tagName: current.tagName.toLowerCase(), + id: current.id ? String(current.id) : null, + role: current.getAttribute("role") || null, + label: ( + current.getAttribute("aria-label") || + current.getAttribute("title") || + current.getAttribute("alt") || + current.getAttribute("name") || + null + ) + }); + current = current.parentElement; + } + return ancestors; + } + function elementContext(element) { var rect = element.getBoundingClientRect(); var classes = []; try { - classes = Array.prototype.slice.call(element.classList || []).slice(0, 8).map(String); + classes = Array.prototype.slice.call(element.classList || []).slice(0, 4).map(String); } catch (_error) { classes = []; } + var attrs = importantAttributes(element); return { selector: selectorFor(element), tagName: element.tagName.toLowerCase(), @@ -296,6 +608,9 @@ const BROWSER_TOOL_RUNTIME = String.raw` null ), text: compactText(element.innerText || element.textContent || "", 500), + attributes: attrs, + ancestors: ancestorSummary(element), + source: sourceHintForElement(element), rect: { x: round(rect.left), y: round(rect.top), @@ -318,6 +633,19 @@ const BROWSER_TOOL_RUNTIME = String.raw` return node; } + function createMicIcon() { + var svg = createSvgNode("svg", "dictation-icon"); + svg.setAttribute("viewBox", "0 0 24 24"); + svg.setAttribute("aria-hidden", "true"); + var mic = createSvgNode("path"); + mic.setAttribute("d", "M12 2a3 3 0 0 0-3 3v6a3 3 0 0 0 6 0V5a3 3 0 0 0-3-3Z"); + var stem = createSvgNode("path"); + stem.setAttribute("d", "M19 10v1a7 7 0 0 1-14 0v-1M12 18v4M8 22h8"); + svg.appendChild(mic); + svg.appendChild(stem); + return svg; + } + function clearNode(node) { while (node.firstChild) node.removeChild(node.firstChild); } @@ -344,17 +672,24 @@ const BROWSER_TOOL_RUNTIME = String.raw` function updateRainbowGradient(stroke) { if (!stroke || !stroke.gradient || !stroke.points || stroke.points.length === 0) return; - var first = stroke.points[0]; - var last = stroke.points[stroke.points.length - 1] || first; + var points = stroke.renderedPoints || stroke.points; + var first = points[0]; + var last = points[points.length - 1] || first; var x1 = first.x; var y1 = first.y; var x2 = last.x; var y2 = last.y; if (Math.hypot(x2 - x1, y2 - y1) < 8) { - x1 = stroke.minX; - y1 = stroke.minY; - x2 = stroke.maxX; - y2 = stroke.maxY; + var bounds = stroke.renderedBounds || { + minX: stroke.minX, + minY: stroke.minY, + maxX: stroke.maxX, + maxY: stroke.maxY + }; + x1 = bounds.minX; + y1 = bounds.minY; + x2 = bounds.maxX; + y2 = bounds.maxY; } if (Math.hypot(x2 - x1, y2 - y1) < 1) { x2 = x1 + 1; @@ -387,6 +722,62 @@ const BROWSER_TOOL_RUNTIME = String.raw` host.style.setProperty("--xero-tool-selection", resolved.ring || resolved.primary); } + function isToolTopLayerOpen(element) { + if (!element) return false; + try { + if (typeof element.matches === "function" && element.matches(":popover-open")) { + return true; + } + } catch (_error) { + // Some test DOMs do not understand the :popover-open pseudo-class. + } + return element.__xeroBrowserToolTopLayerOpen === true; + } + + function applyTopLayerStyles(element) { + if (!element) return; + element.style.margin = "0"; + element.style.padding = "0"; + element.style.border = "0"; + element.style.width = "100vw"; + element.style.height = "100vh"; + element.style.maxWidth = "none"; + element.style.maxHeight = "none"; + element.style.background = "transparent"; + } + + function showInTopLayer(element, bringToFront) { + if (!element || typeof element.showPopover !== "function") return false; + element.setAttribute("popover", "manual"); + applyTopLayerStyles(element); + try { + if (bringToFront && isToolTopLayerOpen(element) && typeof element.hidePopover === "function") { + try { + element.hidePopover(); + } catch (_hideError) { + // If the browser already closed it, the next show call will repair it. + } + element.__xeroBrowserToolTopLayerOpen = false; + } + if (!isToolTopLayerOpen(element)) { + element.showPopover(); + element.__xeroBrowserToolTopLayerOpen = true; + } + return true; + } catch (_error) { + if (!isToolTopLayerOpen(element)) { + element.removeAttribute("popover"); + } + return false; + } + } + + function promoteToolLayers(state, bringToFront) { + if (!state) return; + if (state.pageRoot) showInTopLayer(state.pageRoot, bringToFront); + if (state.host) showInTopLayer(state.host, bringToFront); + } + function eventHitsChrome(event) { var path = typeof event.composedPath === "function" ? event.composedPath() : []; for (var index = 0; index < path.length; index += 1) { @@ -398,22 +789,397 @@ const BROWSER_TOOL_RUNTIME = String.raw` return false; } - function positionComposer(composer, x, y) { - var width = 320; - var height = 154; - var left = x + 12; - var top = y; - if (left + width + 8 > window.innerWidth) left = x - width - 12; - left = clamp(left, 8, Math.max(8, window.innerWidth - width - 8)); - top = clamp(top, 8, Math.max(8, window.innerHeight - height - 8)); + function rectFromEdges(left, top, width, height) { + return { + left: left, + top: top, + right: left + width, + bottom: top + height, + width: width, + height: height + }; + } + + function normalizeClientRect(rect) { + if (!rect) return null; + var left = Number(rect.left != null ? rect.left : rect.x); + var top = Number(rect.top != null ? rect.top : rect.y); + var width = Number(rect.width != null ? rect.width : (rect.right - rect.left)); + var height = Number(rect.height != null ? rect.height : (rect.bottom - rect.top)); + if (!Number.isFinite(left) || !Number.isFinite(top) || !Number.isFinite(width) || !Number.isFinite(height)) { + return null; + } + width = Math.max(1, width); + height = Math.max(1, height); + return rectFromEdges(left, top, width, height); + } + + function inflateRect(rect, padding) { + return rectFromEdges( + rect.left - padding, + rect.top - padding, + rect.width + padding * 2, + rect.height + padding * 2 + ); + } + + function overlapArea(a, b) { + var width = Math.max(0, Math.min(a.right, b.right) - Math.max(a.left, b.left)); + var height = Math.max(0, Math.min(a.bottom, b.bottom) - Math.max(a.top, b.top)); + return width * height; + } + + function clampComposerRect(left, top, width, height) { + var margin = 8; + return rectFromEdges( + clamp(left, margin, Math.max(margin, window.innerWidth - width - margin)), + clamp(top, margin, Math.max(margin, window.innerHeight - height - margin)), + width, + height + ); + } + + function bestComposerPlacement(width, height, avoidRect) { + var avoid = normalizeClientRect(avoidRect); + if (!avoid) return null; + + var gap = 20; + var centerX = avoid.left + avoid.width / 2; + var centerY = avoid.top + avoid.height / 2; + var candidates = [ + { + side: "left", + space: avoid.left, + origin: "right center", + rect: clampComposerRect(avoid.left - width - gap, centerY - height / 2, width, height) + }, + { + side: "right", + space: window.innerWidth - avoid.right, + origin: "left center", + rect: clampComposerRect(avoid.right + gap, centerY - height / 2, width, height) + }, + { + side: "above", + space: avoid.top, + origin: "center bottom", + rect: clampComposerRect(centerX - width / 2, avoid.top - height - gap, width, height) + }, + { + side: "below", + space: window.innerHeight - avoid.bottom, + origin: "center top", + rect: clampComposerRect(centerX - width / 2, avoid.bottom + gap, width, height) + } + ]; + var inflatedAvoid = inflateRect(avoid, 14); + candidates.sort(function (a, b) { + var aOverlap = overlapArea(a.rect, inflatedAvoid); + var bOverlap = overlapArea(b.rect, inflatedAvoid); + if (aOverlap !== bOverlap) return aOverlap - bOverlap; + return b.space - a.space; + }); + return candidates[0]; + } + + function positionComposer(composer, x, y, avoidRect) { + var width = composer.offsetWidth || 320; + var height = composer.offsetHeight || 154; + var placement = bestComposerPlacement(width, height, avoidRect); + var left; + var top; + if (placement) { + left = placement.rect.left; + top = placement.rect.top; + composer.style.setProperty("--xero-composer-origin", placement.origin); + } else { + left = x + 12; + top = y; + if (left + width + 8 > window.innerWidth) left = x - width - 12; + left = clamp(left, 8, Math.max(8, window.innerWidth - width - 8)); + top = clamp(top, 8, Math.max(8, window.innerHeight - height - 8)); + composer.style.setProperty("--xero-composer-origin", left > x ? "left top" : "right top"); + } composer.style.left = left + "px"; composer.style.top = top + "px"; } + function readToolbarPosition() { + try { + var stored = window[TOOLBAR_POSITION_KEY]; + if (!stored || typeof stored !== "object") return null; + var left = Number(stored.left); + var top = Number(stored.top); + if (!Number.isFinite(left) || !Number.isFinite(top)) return null; + return { left: left, top: top }; + } catch (_error) { + return null; + } + } + + function rememberToolbarPosition(position) { + try { + window[TOOLBAR_POSITION_KEY] = { + left: Number(position.left) || 0, + top: Number(position.top) || 0 + }; + } catch (_error) { + // best-effort per-page placement memory + } + } + + function toolbarSize(toolbar) { + var rect = toolbar && typeof toolbar.getBoundingClientRect === "function" + ? toolbar.getBoundingClientRect() + : null; + return { + width: Math.max(1, Number(rect && rect.width) || Number(toolbar.offsetWidth) || 360), + height: Math.max(1, Number(rect && rect.height) || Number(toolbar.offsetHeight) || 34) + }; + } + + function clampToolbarPosition(toolbar, left, top) { + var size = toolbarSize(toolbar); + var margin = 8; + return { + left: clamp(Number(left) || 0, margin, Math.max(margin, window.innerWidth - size.width - margin)), + top: clamp(Number(top) || 0, margin, Math.max(margin, window.innerHeight - size.height - margin)) + }; + } + + function applyToolbarPosition(toolbar, left, top, options) { + var position = clampToolbarPosition(toolbar, left, top); + toolbar.style.left = round(position.left) + "px"; + toolbar.style.top = round(position.top) + "px"; + toolbar.style.transform = "none"; + if (!options || options.persist !== false) { + rememberToolbarPosition(position); + } + return position; + } + + function defaultToolbarPosition(toolbar) { + var size = toolbarSize(toolbar); + return { + left: (window.innerWidth - size.width) / 2, + top: 10 + }; + } + + function syncToolbarPosition(toolbar, options) { + var stored = readToolbarPosition(); + if (stored) { + return applyToolbarPosition(toolbar, stored.left, stored.top, options); + } + + var rect = toolbar.getBoundingClientRect(); + var fallback = defaultToolbarPosition(toolbar); + return applyToolbarPosition( + toolbar, + Number(rect.left) || fallback.left, + Number(rect.top) || fallback.top, + { persist: false } + ); + } + + function setupToolbarDrag(state, toolbar, handle) { + var dragging = false; + var activePointerId = null; + var offsetX = 0; + var offsetY = 0; + + function move(event) { + if (!dragging) return; + if (activePointerId !== null && event.pointerId !== activePointerId) return; + event.preventDefault(); + event.stopPropagation(); + applyToolbarPosition(toolbar, event.clientX - offsetX, event.clientY - offsetY); + } + + function stop(event) { + if (!dragging) return; + if (activePointerId !== null && event && event.pointerId !== activePointerId) return; + dragging = false; + activePointerId = null; + toolbar.removeAttribute("data-dragging"); + window.removeEventListener("pointermove", move, true); + window.removeEventListener("pointerup", stop, true); + window.removeEventListener("pointercancel", stop, true); + if (event) { + event.preventDefault(); + event.stopPropagation(); + try { + handle.releasePointerCapture(event.pointerId); + } catch (_error) { + // ignore + } + } + } + + function nudge(event) { + var horizontal = event.key === "ArrowLeft" || event.key === "ArrowRight"; + var vertical = event.key === "ArrowUp" || event.key === "ArrowDown"; + if (!horizontal && !vertical) return; + event.preventDefault(); + event.stopPropagation(); + var step = event.shiftKey ? 32 : 8; + var rect = toolbar.getBoundingClientRect(); + var left = rect.left + (event.key === "ArrowLeft" ? -step : event.key === "ArrowRight" ? step : 0); + var top = rect.top + (event.key === "ArrowUp" ? -step : event.key === "ArrowDown" ? step : 0); + applyToolbarPosition(toolbar, left, top); + } + + handle.addEventListener("pointerdown", function (event) { + if (event.button != null && event.button !== 0) return; + event.preventDefault(); + event.stopPropagation(); + var rect = toolbar.getBoundingClientRect(); + offsetX = event.clientX - rect.left; + offsetY = event.clientY - rect.top; + dragging = true; + activePointerId = event.pointerId != null ? event.pointerId : null; + toolbar.setAttribute("data-dragging", "true"); + applyToolbarPosition(toolbar, rect.left, rect.top); + window.addEventListener("pointermove", move, true); + window.addEventListener("pointerup", stop, true); + window.addEventListener("pointercancel", stop, true); + try { + handle.setPointerCapture(event.pointerId); + } catch (_error) { + // ignore + } + }); + handle.addEventListener("click", function (event) { + event.preventDefault(); + event.stopPropagation(); + }); + handle.addEventListener("keydown", nudge); + + var resize = function () { + syncToolbarPosition(toolbar, { persist: Boolean(readToolbarPosition()) }); + }; + window.addEventListener("resize", resize); + state.cleanups.push(function () { + stop(); + window.removeEventListener("resize", resize); + }); + } + + function removeComposer(state, composer, afterRemove) { + if (!composer || composer.getAttribute("data-closing") === "true") return; + composer.setAttribute("data-closing", "true"); + composer.removeAttribute("data-open"); + var note = state && state.composerInput ? String(state.composerInput.value || "") : ""; + emitComposerNote(state, note, false); + var finished = false; + function complete() { + if (finished) return; + finished = true; + composer.removeEventListener("transitionend", complete); + if (composer.parentNode) composer.parentNode.removeChild(composer); + if (state.composer === composer) { + state.composer = null; + state.composerInput = null; + state.composerDictationButton = null; + state.composerAvoidRect = null; + } + if (typeof afterRemove === "function") afterRemove(); + } + composer.addEventListener("transitionend", complete); + var reducedMotion = false; + try { + reducedMotion = Boolean(window.matchMedia && window.matchMedia("(prefers-reduced-motion: reduce)").matches); + } catch (_error) { + reducedMotion = false; + } + window.setTimeout(complete, reducedMotion ? 0 : 290); + } + + function showCaptureLoading(state) { + if (!state) return false; + state.loadingMode = true; + if (state.root) state.root.setAttribute("data-loading", "true"); + if (state.captureLoading && state.captureLoading.parentNode) { + state.captureLoading.setAttribute("data-open", "true"); + return true; + } + var loading = createNode("div", "capture-loading"); + loading.setAttribute("aria-hidden", "true"); + state.layer.appendChild(loading); + state.captureLoading = loading; + requestAnimationFrame(function () { + loading.setAttribute("data-open", "true"); + }); + return true; + } + + function hideCaptureLoading(state) { + if (!state) return false; + state.loadingMode = false; + if (state.root) state.root.setAttribute("data-loading", "false"); + var loading = state.captureLoading; + state.captureLoading = null; + if (loading && loading.parentNode) { + loading.parentNode.removeChild(loading); + } + return true; + } + + function emitComposerNote(state, note, active) { + if (!state) return; + bridgeEmit("tool_note", { + mode: state.mode, + note: String(note || ""), + active: Boolean(active) + }); + } + + function applyComposerDictationState(state) { + if (!state || !state.composerDictationButton) return; + var control = state.composerDictationButton; + var dictation = state.dictationState || {}; + var visible = dictation.visible === true; + var listening = dictation.isListening === true; + var disabled = dictation.isToggleDisabled === true; + var ariaLabel = String(dictation.ariaLabel || (listening ? "Stop dictation" : "Start dictation")); + var tooltip = String(dictation.tooltip || ariaLabel); + var level = Number(dictation.audioLevel || 0); + + control.hidden = !visible; + control.disabled = disabled; + control.setAttribute("aria-label", ariaLabel); + control.setAttribute("aria-pressed", listening ? "true" : "false"); + control.setAttribute("title", tooltip); + control.setAttribute("data-listening", listening ? "true" : "false"); + control.style.setProperty("--xero-dictation-level", String(clamp(level, 0, 1))); + } + + function setComposerNoteValue(state, note) { + var input = state && state.composerInput; + if (!input) return false; + var next = String(note || ""); + if (input.value !== next) { + input.value = next; + try { + input.selectionStart = next.length; + input.selectionEnd = next.length; + } catch (_error) { + // ignore + } + } + try { + input.focus(); + } catch (_error) { + // ignore + } + return true; + } + function makeComposer(state, options) { if (state.composer) { state.composer.remove(); state.composer = null; + state.composerDictationButton = null; } var composer = createNode("div", "composer xero-tool-chrome"); var header = createNode("div", "composer-header"); @@ -435,11 +1201,20 @@ const BROWSER_TOOL_RUNTIME = String.raw` var footer = createNode("div", "composer-footer"); var hint = createNode("span", "composer-hint", options.footer || ""); + var actions = createNode("div", "composer-actions"); + var dictation = createNode("button", "dictation-button"); + dictation.type = "button"; + dictation.hidden = true; + dictation.setAttribute("aria-label", "Start dictation"); + dictation.setAttribute("aria-pressed", "false"); + dictation.appendChild(createMicIcon()); var send = createNode("button", "send-button", "Add"); send.type = "button"; send.setAttribute("aria-label", "Add browser context to composer"); footer.appendChild(hint); - footer.appendChild(send); + actions.appendChild(dictation); + actions.appendChild(send); + footer.appendChild(actions); composer.appendChild(header); composer.appendChild(textarea); @@ -447,17 +1222,36 @@ const BROWSER_TOOL_RUNTIME = String.raw` state.layer.appendChild(composer); state.composer = composer; state.composerInput = textarea; - positionComposer(composer, options.x, options.y); + state.composerDictationButton = dictation; + state.composerAvoidRect = options.avoidRect || null; + applyComposerDictationState(state); + positionComposer(composer, options.x, options.y, options.avoidRect || null); + emitComposerNote(state, textarea.value, true); + requestAnimationFrame(function () { + composer.setAttribute("data-open", "true"); + }); close.addEventListener("click", function () { - composer.remove(); - if (state.composer === composer) { - state.composer = null; - state.composerInput = null; - } + removeComposer(state, composer); }); send.addEventListener("click", function () { - options.onSubmit(String(textarea.value || "").trim()); + if (composer.getAttribute("data-closing") === "true") return; + var note = String(textarea.value || "").trim(); + removeComposer(state, composer, function () { + options.onSubmit(note); + }); + }); + dictation.addEventListener("click", function (event) { + event.preventDefault(); + event.stopPropagation(); + if (dictation.disabled || dictation.hidden) return; + bridgeEmit("tool_dictation_toggle", { + mode: state.mode, + note: String(textarea.value || "") + }); + }); + textarea.addEventListener("input", function () { + emitComposerNote(state, textarea.value, true); }); textarea.addEventListener("keydown", function (event) { if (event.key === "Escape") { @@ -481,14 +1275,21 @@ const BROWSER_TOOL_RUNTIME = String.raw` function makeToolbar(state, mode, pageLabel) { var toolbar = createNode("div", "toolbar xero-tool-chrome"); + var handle = createNode("button", "toolbar-handle"); var badge = createNode("span", "toolbar-badge", mode === "pen" ? "Pen mode" : "Inspect mode"); var label = createNode("span", "toolbar-label", pageLabel ? "On " + pageLabel : (mode === "pen" ? "Sketch over the page" : "Select an element")); var clear = createNode("button", "toolbar-button", "Clear"); var exit = createNode("button", "toolbar-button", "Exit"); + handle.type = "button"; + handle.setAttribute("aria-label", "Move browser tool controls"); + handle.setAttribute("title", "Move controls"); clear.type = "button"; exit.type = "button"; - clear.hidden = mode !== "pen"; clear.addEventListener("click", function () { + if (state.mode === "inspect") { + if (state.clearInspect) state.clearInspect(); + return; + } if (state.clearPen) state.clearPen(); }); exit.addEventListener("click", function () { @@ -496,6 +1297,7 @@ const BROWSER_TOOL_RUNTIME = String.raw` api.deactivate(); bridgeEmit("tool_closed", { mode: closingMode }); }); + toolbar.appendChild(handle); toolbar.appendChild(badge); toolbar.appendChild(createNode("span", "toolbar-dot", "|")); toolbar.appendChild(label); @@ -504,6 +1306,12 @@ const BROWSER_TOOL_RUNTIME = String.raw` toolbar.appendChild(exit); state.layer.appendChild(toolbar); state.toolbar = toolbar; + setupToolbarDrag(state, toolbar, handle); + requestAnimationFrame(function () { + if (state.toolbar === toolbar) { + syncToolbarPosition(toolbar, { persist: false }); + } + }); } function startCapture(state, context) { @@ -514,21 +1322,282 @@ const BROWSER_TOOL_RUNTIME = String.raw` } function setupPen(state) { - var svg = createSvgNode("svg", "pen-layer"); - var defs = createPenDefs(svg); + var overlay = createSvgNode("svg", "pen-layer"); + var existingPageLayer = document.getElementById(PEN_DOCUMENT_LAYER_ID); + if (existingPageLayer && existingPageLayer.parentNode) { + existingPageLayer.parentNode.removeChild(existingPageLayer); + } + var existingPageRoot = document.getElementById(PEN_DOCUMENT_ROOT_ID); + if (existingPageRoot && existingPageRoot.parentNode) { + existingPageRoot.parentNode.removeChild(existingPageRoot); + } + var pageRoot = createNode("div"); + pageRoot.id = PEN_DOCUMENT_ROOT_ID; + pageRoot.setAttribute("data-xero-browser-tool-document-root", "true"); + pageRoot.setAttribute("aria-hidden", "true"); + pageRoot.style.position = "fixed"; + pageRoot.style.inset = "0"; + pageRoot.style.overflow = "visible"; + pageRoot.style.pointerEvents = "none"; + pageRoot.style.background = "transparent"; + pageRoot.style.zIndex = TOOL_Z_INDEX; + var pageFrame = createNode("div"); + pageFrame.setAttribute("data-xero-browser-tool-document-frame", "true"); + pageFrame.style.position = "absolute"; + pageFrame.style.left = "0"; + pageFrame.style.top = "0"; + pageFrame.style.overflow = "visible"; + pageFrame.style.pointerEvents = "none"; + pageFrame.style.zIndex = "1"; + var pageLayer = createSvgNode("svg", "xero-pen-document-layer"); + pageLayer.id = PEN_DOCUMENT_LAYER_ID; + pageLayer.setAttribute("data-xero-browser-tool-document-layer", "true"); + pageLayer.setAttribute("aria-hidden", "true"); + pageLayer.setAttribute("preserveAspectRatio", "none"); + pageLayer.style.position = "absolute"; + pageLayer.style.left = "0"; + pageLayer.style.top = "0"; + pageLayer.style.overflow = "visible"; + pageLayer.style.pointerEvents = "none"; + pageLayer.style.zIndex = "1"; + pageLayer.style.opacity = "1"; + pageLayer.style.transformOrigin = "top left"; + pageLayer.style.transitionProperty = "opacity"; + pageLayer.style.transitionDuration = "180ms"; + pageLayer.style.transitionTimingFunction = "cubic-bezier(.2,0,0,1)"; + pageLayer.style.willChange = "transform, opacity"; + pageFrame.appendChild(pageLayer); + pageRoot.appendChild(pageFrame); + if (state.host && state.host.parentNode) { + state.host.parentNode.insertBefore(pageRoot, state.host); + } else { + (document.documentElement || document.body).appendChild(pageRoot); + } + var pageDefs = createPenDefs(pageLayer); var active = null; var rafId = 0; + var syncFrameId = 0; var strokeIndex = 0; + var visualViewport = window.visualViewport || null; + var mutationObserver = null; + var penSurface = { + kind: "document", + element: null, + restorePosition: null + }; state.strokes = []; - state.layer.appendChild(svg); - state.penLayer = svg; + state.pageRoot = pageRoot; + state.pageFrame = pageFrame; + state.pageLayer = pageLayer; + state.layer.appendChild(overlay); + state.penLayer = overlay; + overlay.setAttribute("width", "100%"); + overlay.setAttribute("height", "100%"); + overlay.setAttribute("preserveAspectRatio", "none"); + + function readViewportSize() { + return { + width: Math.max(1, Math.round(window.innerWidth || 1)), + height: Math.max(1, Math.round(window.innerHeight || 1)) + }; + } + + function readScrollPosition() { + var doc = document.documentElement || {}; + var body = document.body || {}; + return { + x: Number(window.scrollX || window.pageXOffset || doc.scrollLeft || body.scrollLeft || 0), + y: Number(window.scrollY || window.pageYOffset || doc.scrollTop || body.scrollTop || 0) + }; + } + + function readDocumentSize() { + var doc = document.documentElement || {}; + var body = document.body || {}; + var scrolling = document.scrollingElement || doc || body; + return { + width: Math.max( + 1, + Math.ceil( + scrolling.scrollWidth || + doc.scrollWidth || + body.scrollWidth || + scrolling.clientWidth || + doc.clientWidth || + body.clientWidth || + window.innerWidth || + 1 + ) + ), + height: Math.max( + 1, + Math.ceil( + scrolling.scrollHeight || + doc.scrollHeight || + body.scrollHeight || + scrolling.clientHeight || + doc.clientHeight || + body.clientHeight || + window.innerHeight || + 1 + ) + ) + }; + } + + function isDocumentScrollRoot(element) { + return ( + !element || + element === document.documentElement || + element === document.body || + element === document.scrollingElement + ); + } + + function isScrollableContainer(element) { + if (!element || element.nodeType !== 1 || isDocumentScrollRoot(element)) return false; + var style = window.getComputedStyle(element); + var overflowX = style.overflowX; + var overflowY = style.overflowY; + var scrollsX = /(auto|scroll|overlay)/.test(overflowX) && element.scrollWidth > element.clientWidth; + var scrollsY = /(auto|scroll|overlay)/.test(overflowY) && element.scrollHeight > element.clientHeight; + return scrollsX || scrollsY; + } + + function scrollContainerForElement(element) { + var current = element; + while (current && current.nodeType === 1) { + if (isScrollableContainer(current)) return current; + current = current.parentElement; + } + return null; + } + + function samePenSurface(surface, element) { + if (!surface) return false; + if (!element) return surface.kind === "document"; + return surface.kind === "element" && surface.element === element; + } + + function restorePenSurfacePosition() { + if (penSurface && typeof penSurface.restorePosition === "function") { + penSurface.restorePosition(); + } + if (penSurface) penSurface.restorePosition = null; + } + + function activatePenSurface(element) { + var nextElement = isDocumentScrollRoot(element) ? null : element; + if (samePenSurface(penSurface, nextElement)) return; + + restorePenSurfacePosition(); + + if (!nextElement) { + penSurface = { + kind: "document", + element: null, + restorePosition: null + }; + } else { + penSurface = { + kind: "element", + element: nextElement, + restorePosition: null + }; + } + + clearNode(pageLayer); + pageDefs = createPenDefs(pageLayer); + syncLayerSize(); + } + + function activatePenSurfaceForPoint(clientX, clientY) { + if (state.strokes.length > 0 || active) return; + var element = underlyingElementAt(clientX, clientY); + activatePenSurface(scrollContainerForElement(element)); + } + + function readSurfaceScrollPosition() { + if (penSurface.kind === "element" && penSurface.element) { + return { + x: Number(penSurface.element.scrollLeft || 0), + y: Number(penSurface.element.scrollTop || 0) + }; + } + return readScrollPosition(); + } + + function readSurfaceClientOrigin() { + if (penSurface.kind === "element" && penSurface.element) { + var rect = penSurface.element.getBoundingClientRect(); + return { + x: rect.left, + y: rect.top + }; + } + return { x: 0, y: 0 }; + } + + function readSurfaceSize() { + if (penSurface.kind === "element" && penSurface.element) { + return { + width: Math.max( + 1, + Math.ceil(penSurface.element.scrollWidth || penSurface.element.clientWidth || 1) + ), + height: Math.max( + 1, + Math.ceil(penSurface.element.scrollHeight || penSurface.element.clientHeight || 1) + ) + }; + } + return readDocumentSize(); + } - function resize() { - var width = Math.max(1, Math.round(window.innerWidth || 1)); - var height = Math.max(1, Math.round(window.innerHeight || 1)); - svg.setAttribute("viewBox", "0 0 " + width + " " + height); - svg.setAttribute("width", String(width)); - svg.setAttribute("height", String(height)); + function syncLayerSize() { + var size = readSurfaceSize(); + var viewport = readViewportSize(); + var scroll = readSurfaceScrollPosition(); + pageLayer.setAttribute("viewBox", "0 0 " + size.width + " " + size.height); + pageLayer.setAttribute("width", String(size.width)); + pageLayer.setAttribute("height", String(size.height)); + pageLayer.style.width = size.width + "px"; + pageLayer.style.height = size.height + "px"; + pageLayer.style.transform = "translate(" + round(-scroll.x) + "px, " + round(-scroll.y) + "px)"; + + if (penSurface.kind === "element" && penSurface.element) { + var rect = penSurface.element.getBoundingClientRect(); + pageFrame.style.left = round(rect.left) + "px"; + pageFrame.style.top = round(rect.top) + "px"; + pageFrame.style.width = Math.max(1, round(rect.width)) + "px"; + pageFrame.style.height = Math.max(1, round(rect.height)) + "px"; + pageFrame.style.overflow = "visible"; + } else { + pageFrame.style.left = "0px"; + pageFrame.style.top = "0px"; + pageFrame.style.width = viewport.width + "px"; + pageFrame.style.height = viewport.height + "px"; + pageFrame.style.overflow = "visible"; + } + } + + function syncOverlayViewport() { + var viewport = readViewportSize(); + overlay.setAttribute( + "viewBox", + "0 0 " + viewport.width + " " + viewport.height + ); + } + + function pagePoint(event) { + var scroll = readSurfaceScrollPosition(); + var origin = readSurfaceClientOrigin(); + return { + x: event.clientX - origin.x + scroll.x, + y: event.clientY - origin.y + scroll.y, + clientX: event.clientX, + clientY: event.clientY + }; } function pathData(points) { @@ -540,10 +1609,30 @@ const BROWSER_TOOL_RUNTIME = String.raw` return segments.join(" "); } + function boundsForPoints(points) { + var first = points && points[0] ? points[0] : { x: 0, y: 0 }; + var bounds = { + minX: first.x, + maxX: first.x, + minY: first.y, + maxY: first.y + }; + for (var index = 1; points && index < points.length; index += 1) { + bounds.minX = Math.min(bounds.minX, points[index].x); + bounds.maxX = Math.max(bounds.maxX, points[index].x); + bounds.minY = Math.min(bounds.minY, points[index].y); + bounds.maxY = Math.max(bounds.maxY, points[index].y); + } + return bounds; + } + function updateActivePath() { rafId = 0; if (!active || !active.path) return; + syncLayerSize(); active.path.setAttribute("d", pathData(active.points)); + active.renderedPoints = active.points; + active.renderedBounds = boundsForPoints(active.points); updateRainbowGradient(active); } @@ -552,20 +1641,33 @@ const BROWSER_TOOL_RUNTIME = String.raw` rafId = requestAnimationFrame(updateActivePath); } + function stylePenPath(path, stroke) { + path.setAttribute("fill", "none"); + path.setAttribute("stroke-width", "3"); + path.setAttribute("stroke-linecap", "round"); + path.setAttribute("stroke-linejoin", "round"); + path.style.vectorEffect = "non-scaling-stroke"; + path.style.pointerEvents = "none"; + path.style.stroke = stroke; + } + function createStroke(start) { - var gradientId = "xero-pen-rainbow-" + Date.now().toString(36) + "-" + strokeIndex; + syncLayerSize(); + var gradientId = "xero-pen-rainbow-doc-" + Date.now().toString(36) + "-" + strokeIndex; strokeIndex += 1; - var gradient = createRainbowGradient(defs, gradientId); - var path = createSvgNode("path", "pen-path active"); + var gradient = createRainbowGradient(pageDefs, gradientId); + var path = createSvgNode("path", "xero-document-pen-path active"); path.setAttribute("d", pathData([start])); - path.style.stroke = "url(#" + gradientId + ")"; - svg.appendChild(path); + stylePenPath(path, "url(#" + gradientId + ")"); + pageLayer.appendChild(path); var stroke = { points: [start], minX: start.x, maxX: start.x, minY: start.y, maxY: start.y, + renderedPoints: [start], + renderedBounds: { minX: start.x, maxX: start.x, minY: start.y, maxY: start.y }, gradient: gradient, path: path }; @@ -573,13 +1675,9 @@ const BROWSER_TOOL_RUNTIME = String.raw` return stroke; } - function point(event) { - return { x: event.clientX, y: event.clientY }; - } - function appendPoint(event, force) { if (!active) return; - var next = point(event); + var next = pagePoint(event); var last = active.points[active.points.length - 1]; if (last && Math.hypot(next.x - last.x, next.y - last.y) < (force ? 0.5 : 2)) return; active.points.push(next); @@ -590,6 +1688,109 @@ const BROWSER_TOOL_RUNTIME = String.raw` scheduleActivePathUpdate(); } + function pagePointToClient(point) { + var scroll = readSurfaceScrollPosition(); + var origin = readSurfaceClientOrigin(); + return { + x: point.x - scroll.x + origin.x, + y: point.y - scroll.y + origin.y + }; + } + + function strokeClientRect(stroke) { + var bounds = stroke.renderedBounds || boundsForPoints(stroke.points || []); + var topLeft = pagePointToClient({ x: bounds.minX, y: bounds.minY }); + var bottomRight = pagePointToClient({ x: bounds.maxX, y: bounds.maxY }); + return { + x: Math.min(topLeft.x, bottomRight.x), + y: Math.min(topLeft.y, bottomRight.y), + width: Math.max(1, Math.abs(bottomRight.x - topLeft.x)), + height: Math.max(1, Math.abs(bottomRight.y - topLeft.y)) + }; + } + + function allStrokeClientRect() { + if (!state.strokes || state.strokes.length === 0) return null; + var rect = null; + for (var index = 0; index < state.strokes.length; index += 1) { + var next = strokeClientRect(state.strokes[index]); + if (!rect) { + rect = { + x: next.x, + y: next.y, + width: next.width, + height: next.height + }; + continue; + } + var left = Math.min(rect.x, next.x); + var top = Math.min(rect.y, next.y); + var right = Math.max(rect.x + rect.width, next.x + next.width); + var bottom = Math.max(rect.y + rect.height, next.y + next.height); + rect = { + x: round(left), + y: round(top), + width: round(right - left), + height: round(bottom - top) + }; + } + return rect; + } + + function emitPenState() { + bridgeEmit("tool_state", { + mode: "pen", + strokeCount: state.strokes.length, + hasDrawing: state.strokes.length > 0 + }); + } + + function repositionComposer() { + if (!state.composer || !state.composerStroke) return; + var points = state.composerStroke.points || []; + if (points.length === 0) return; + var anchor = pagePointToClient(points[points.length - 1]); + positionComposer(state.composer, anchor.x, anchor.y, strokeClientRect(state.composerStroke)); + } + + function syncPenLayer() { + syncFrameId = 0; + promoteToolLayers(state, false); + syncLayerSize(); + syncOverlayViewport(); + repositionComposer(); + } + + function schedulePenSync() { + if (syncFrameId) return; + syncFrameId = requestAnimationFrame(syncPenLayer); + } + + function mutationBelongsToTool(target) { + return Boolean( + target && + ( + target === pageLayer || + target === pageRoot || + target === pageFrame || + target === state.host || + (pageLayer.contains && pageLayer.contains(target)) || + (pageRoot.contains && pageRoot.contains(target)) || + (state.host.contains && state.host.contains(target)) + ) + ); + } + + function pageMutationCallback(records) { + for (var index = 0; index < records.length; index += 1) { + var record = records[index]; + if (!mutationBelongsToTool(record.target)) { + schedulePenSync(); + return; + } + } + } + state.clearPen = function () { state.strokes = []; active = null; @@ -597,28 +1798,100 @@ const BROWSER_TOOL_RUNTIME = String.raw` cancelAnimationFrame(rafId); rafId = 0; } + if (syncFrameId) { + cancelAnimationFrame(syncFrameId); + syncFrameId = 0; + } + emitComposerNote(state, state.composerInput ? state.composerInput.value : "", false); state.pendingContext = null; + state.composerAnchor = null; + state.composerStroke = null; if (state.composer) state.composer.remove(); state.composer = null; state.composerInput = null; - clearNode(svg); - defs = createPenDefs(svg); + state.composerDictationButton = null; + clearNode(pageLayer); + pageDefs = createPenDefs(pageLayer); + emitPenState(); }; - svg.addEventListener("pointerdown", function (event) { + function underlyingElementAt(clientX, clientY) { + var previous = state.host.style.pointerEvents; + state.host.style.pointerEvents = "none"; + try { + return document.elementFromPoint ? document.elementFromPoint(clientX, clientY) : null; + } finally { + state.host.style.pointerEvents = previous; + } + } + + function canScrollElement(element, axis, delta) { + if (!element) return false; + if (element === document.documentElement || element === document.body || element === document.scrollingElement) { + var scrolling = document.scrollingElement || document.documentElement || document.body; + if (!scrolling) return false; + if (axis === "x") return scrolling.scrollWidth > scrolling.clientWidth; + return scrolling.scrollHeight > scrolling.clientHeight; + } + var style = window.getComputedStyle(element); + var overflow = axis === "x" ? style.overflowX : style.overflowY; + if (!/(auto|scroll|overlay)/.test(overflow)) return false; + if (axis === "x") { + if (element.scrollWidth <= element.clientWidth) return false; + if (delta < 0) return element.scrollLeft > 0; + if (delta > 0) return element.scrollLeft + element.clientWidth < element.scrollWidth; + return true; + } + if (element.scrollHeight <= element.clientHeight) return false; + if (delta < 0) return element.scrollTop > 0; + if (delta > 0) return element.scrollTop + element.clientHeight < element.scrollHeight; + return true; + } + + function scrollTargetForWheel(start, event) { + var axis = Math.abs(event.deltaX) > Math.abs(event.deltaY) ? "x" : "y"; + var delta = axis === "x" ? event.deltaX : event.deltaY; + var current = start; + while (current && current.nodeType === 1) { + if (canScrollElement(current, axis, delta)) return current; + current = current.parentElement; + } + return document.scrollingElement || document.documentElement || document.body; + } + + overlay.addEventListener("wheel", function (event) { + if (eventHitsChrome(event)) return; + var start = underlyingElementAt(event.clientX, event.clientY); + var target = scrollTargetForWheel(start, event); + if (!target) return; + event.preventDefault(); + if (target === document.documentElement || target === document.body || target === document.scrollingElement) { + window.scrollBy(event.deltaX, event.deltaY); + schedulePenSync(); + return; + } + target.scrollLeft += event.deltaX; + target.scrollTop += event.deltaY; + schedulePenSync(); + }, { passive: false }); + + overlay.addEventListener("pointerdown", function (event) { if (event.button !== 0 || eventHitsChrome(event)) return; event.preventDefault(); + promoteToolLayers(state, true); + activatePenSurfaceForPoint(event.clientX, event.clientY); + syncPenLayer(); state.captureMode = false; if (state.root) state.root.setAttribute("data-capture", "false"); - active = createStroke(point(event)); + active = createStroke(pagePoint(event)); try { - svg.setPointerCapture(event.pointerId); + overlay.setPointerCapture(event.pointerId); } catch (_error) { // ignore } }); - svg.addEventListener("pointermove", function (event) { + overlay.addEventListener("pointermove", function (event) { if (!active) return; event.preventDefault(); appendPoint(event, false); @@ -628,31 +1901,31 @@ const BROWSER_TOOL_RUNTIME = String.raw` if (!active) return; event.preventDefault(); try { - svg.releasePointerCapture(event.pointerId); + overlay.releasePointerCapture(event.pointerId); } catch (_error) { // ignore } appendPoint(event, true); var finished = active; - finished.path.setAttribute("d", pathData(finished.points)); - updateRainbowGradient(finished); - finished.path.setAttribute("class", "pen-path"); active = null; if (finished.points.length > 1) { - state.strokes.push({ - points: finished.points, - minX: finished.minX, - maxX: finished.maxX, - minY: finished.minY, - maxY: finished.maxY - }); + finished.path.setAttribute("class", "xero-document-pen-path"); + finished.path.setAttribute("d", pathData(finished.points)); + finished.renderedPoints = finished.points; + finished.renderedBounds = boundsForPoints(finished.points); + updateRainbowGradient(finished); + state.strokes.push(finished); + state.composerStroke = finished; + emitPenState(); + var composerAnchor = pagePointToClient(finished.points[finished.points.length - 1]); makeComposer(state, { title: "Sketch note", subtitle: state.strokes.length + " stroke" + (state.strokes.length === 1 ? "" : "s"), placeholder: "Tell the agent what to do with this sketch...", footer: "Drawing will be attached as an image", - x: finished.maxX, - y: finished.minY, + x: composerAnchor.x, + y: composerAnchor.y, + avoidRect: strokeClientRect(finished), onSubmit: function (note) { if (state.strokes.length === 0 && !note) return; startCapture(state, { @@ -660,7 +1933,9 @@ const BROWSER_TOOL_RUNTIME = String.raw` note: note, page: pageContext(), strokeCount: state.strokes.length, - viewport: viewportContext() + viewport: viewportContext(), + scroll: pageScrollContext(), + annotationBounds: allStrokeClientRect() }); } }); @@ -669,14 +1944,40 @@ const BROWSER_TOOL_RUNTIME = String.raw` } } - svg.addEventListener("pointerup", finish); - svg.addEventListener("pointercancel", finish); - window.addEventListener("resize", resize); + overlay.addEventListener("pointerup", finish); + overlay.addEventListener("pointercancel", finish); + window.addEventListener("resize", schedulePenSync); + window.addEventListener("scroll", schedulePenSync, true); + if (visualViewport) { + visualViewport.addEventListener("resize", schedulePenSync); + visualViewport.addEventListener("scroll", schedulePenSync); + } + if (typeof MutationObserver === "function" && document.documentElement) { + mutationObserver = new MutationObserver(pageMutationCallback); + mutationObserver.observe(document.documentElement, { + attributes: true, + attributeFilter: ["class", "style", "hidden"], + childList: true, + subtree: true + }); + } + state.syncPenLayer = syncPenLayer; state.cleanups.push(function () { - window.removeEventListener("resize", resize); + window.removeEventListener("resize", schedulePenSync); + window.removeEventListener("scroll", schedulePenSync, true); + if (visualViewport) { + visualViewport.removeEventListener("resize", schedulePenSync); + visualViewport.removeEventListener("scroll", schedulePenSync); + } + if (mutationObserver) mutationObserver.disconnect(); if (rafId) cancelAnimationFrame(rafId); + if (syncFrameId) cancelAnimationFrame(syncFrameId); + bridgeEmit("tool_state", { mode: "pen", strokeCount: 0, hasDrawing: false }); + if (pageRoot && pageRoot.parentNode) pageRoot.parentNode.removeChild(pageRoot); + restorePenSurfacePosition(); }); - resize(); + syncPenLayer(); + emitPenState(); } function setupInspect(state) { @@ -688,6 +1989,21 @@ const BROWSER_TOOL_RUNTIME = String.raw` state.hoveredElement = null; state.selectedElement = null; + state.clearInspect = function () { + state.hoveredElement = null; + state.selectedElement = null; + state.selectedContext = null; + emitComposerNote(state, state.composerInput ? state.composerInput.value : "", false); + if (state.composer && state.composer.parentNode) { + state.composer.parentNode.removeChild(state.composer); + } + state.composer = null; + state.composerInput = null; + state.composerDictationButton = null; + state.composerAvoidRect = null; + showElement(null, false); + }; + function elementAt(x, y) { var previous = state.host.style.pointerEvents; state.host.style.pointerEvents = "none"; @@ -746,16 +2062,18 @@ const BROWSER_TOOL_RUNTIME = String.raw` title: "Element note", subtitle: (context.selector || context.tagName), placeholder: "Describe what should change about this element...", - footer: "Selection and screenshot will be attached", + footer: "Element metadata will be attached", x: context.rect.x + context.rect.width, y: context.rect.y, + avoidRect: context.rect, onSubmit: function (note) { startCapture(state, { kind: "inspect", note: note, page: pageContext(), element: state.selectedContext, - viewport: viewportContext() + viewport: viewportContext(), + scroll: pageScrollContext() }); } }); @@ -766,18 +2084,30 @@ const BROWSER_TOOL_RUNTIME = String.raw` var style = createNode("style"); style.textContent = ":host{all:initial}" + - ".layer{position:fixed;inset:0;z-index:2147483647;box-sizing:border-box;color:var(--xero-tool-foreground,#fafafa);font-family:ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;letter-spacing:0}" + + ".layer{position:fixed;inset:0;z-index:" + TOOL_Z_INDEX + ";box-sizing:border-box;color:var(--xero-tool-foreground,#fafafa);font-family:ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;letter-spacing:0}" + ".layer *{box-sizing:border-box;letter-spacing:0}" + ".pen-layer{position:absolute;inset:0;z-index:1;display:block;width:100vw;height:100vh;cursor:crosshair;touch-action:none;overflow:visible}" + ".pen-path{fill:none;stroke:var(--xero-tool-pen,#f97316);stroke-width:3;stroke-linecap:round;stroke-linejoin:round;vector-effect:non-scaling-stroke;pointer-events:none}" + ".pen-path.active{stroke:var(--xero-tool-ring,#f97316)}" + - ".toolbar{position:fixed;z-index:4;top:10px;left:50%;transform:translateX(-50%);display:flex;align-items:center;gap:8px;max-width:min(760px,calc(100vw - 24px));height:34px;padding:0 12px;border:1px solid var(--xero-tool-border,#3f3f46);border-radius:999px;background:var(--xero-tool-popover,#18181b);box-shadow:0 16px 42px rgba(0,0,0,.26);font-size:12px;line-height:1;color:var(--xero-tool-muted-foreground,#a1a1aa);white-space:nowrap}" + + ".toolbar{position:fixed;z-index:4;top:10px;left:50%;transform:translateX(-50%);display:flex;align-items:center;gap:8px;max-width:min(760px,calc(100vw - 24px));height:34px;padding:0 8px 0 6px;border:1px solid var(--xero-tool-border,#3f3f46);border-radius:999px;background:var(--xero-tool-popover,#18181b);box-shadow:0 16px 42px rgba(0,0,0,.26);font-size:12px;line-height:1;color:var(--xero-tool-muted-foreground,#a1a1aa);white-space:nowrap;user-select:none}" + + ".toolbar[data-dragging='true']{cursor:grabbing}" + + ".toolbar-handle{appearance:none;display:flex;align-items:center;justify-content:center;width:22px;height:24px;flex:0 0 22px;border:0;border-radius:999px;background:transparent;color:var(--xero-tool-muted-foreground,#a1a1aa);cursor:grab;touch-action:none}" + + ".toolbar-handle::before{content:'';width:12px;height:14px;background:radial-gradient(circle,currentColor 1.15px,transparent 1.3px) 0 0/6px 6px;opacity:.75}" + + ".toolbar-handle:hover,.toolbar-handle:focus-visible{background:var(--xero-tool-secondary,#27272a);color:var(--xero-tool-secondary-foreground,#fafafa)}" + + ".toolbar-handle:active,.toolbar[data-dragging='true'] .toolbar-handle{cursor:grabbing}" + + ".toolbar-handle:focus-visible{outline:2px solid var(--xero-tool-ring,#f97316);outline-offset:1px}" + ".toolbar-badge{font-weight:700;color:var(--xero-tool-popover-foreground,#fafafa)}" + ".toolbar-label{min-width:0;overflow:hidden;text-overflow:ellipsis}" + ".toolbar-dot{color:var(--xero-tool-muted-foreground,#a1a1aa)}" + ".toolbar-button{appearance:none;border:0;border-radius:6px;background:transparent;color:var(--xero-tool-muted-foreground,#a1a1aa);height:24px;padding:0 6px;font:inherit;cursor:pointer}" + ".toolbar-button:hover{background:var(--xero-tool-secondary,#27272a);color:var(--xero-tool-secondary-foreground,#fafafa)}" + - ".composer{position:fixed;z-index:5;width:320px;overflow:hidden;border:1px solid var(--xero-tool-border,#3f3f46);border-radius:8px;background:var(--xero-tool-popover,#18181b);color:var(--xero-tool-popover-foreground,#fafafa);box-shadow:0 24px 70px rgba(0,0,0,.32),0 0 0 1px rgba(255,255,255,.03) inset}" + + ".composer{position:fixed;z-index:5;width:320px;overflow:hidden;border:1px solid var(--xero-tool-border,#3f3f46);border-radius:8px;background:var(--xero-tool-popover,#18181b);color:var(--xero-tool-popover-foreground,#fafafa);box-shadow:0 24px 70px rgba(0,0,0,.32),0 0 0 1px rgba(255,255,255,.03) inset;opacity:0;filter:blur(6px);transform:translateY(10px) scale(.96);transform-origin:var(--xero-composer-origin,top left);transition-property:opacity,transform,filter;transition-duration:240ms;transition-timing-function:cubic-bezier(.2,0,0,1);will-change:opacity,transform,filter}" + + ".composer[data-open='true']{opacity:1;filter:blur(0);transform:translateY(0) scale(1)}" + + ".composer[data-closing='true']{opacity:0;filter:blur(6px);transform:translateY(10px) scale(.96);pointer-events:none}" + + ".capture-loading{position:fixed;inset:0;z-index:6;background:rgba(0,0,0,.38);opacity:0;pointer-events:all;transition-property:opacity;transition-duration:240ms;transition-timing-function:cubic-bezier(.2,0,0,1);will-change:opacity}" + + ".capture-loading[data-open='true']{opacity:1}" + + "[data-loading='true'] .pen-layer,[data-loading='true'] .inspect-highlight{display:none!important}" + + "@media (prefers-reduced-motion:reduce){.composer,.capture-loading{filter:none;transform:none;transition-duration:0ms}.composer[data-closing='true']{filter:none;transform:none}}" + ".composer-header{display:flex;align-items:center;justify-content:space-between;gap:10px;border-bottom:1px solid var(--xero-tool-border,#3f3f46);padding:9px 10px 8px}" + ".composer-title-wrap{min-width:0}" + ".composer-title{font-size:12px;font-weight:750;color:var(--xero-tool-popover-foreground,#fafafa);line-height:1.2}" + @@ -788,12 +2118,21 @@ const BROWSER_TOOL_RUNTIME = String.raw` ".composer-input::placeholder{color:var(--xero-tool-muted-foreground,#a1a1aa)}" + ".composer-footer{display:flex;align-items:center;justify-content:space-between;gap:10px;border-top:1px solid var(--xero-tool-border,#3f3f46);background:var(--xero-tool-card,#18181b);padding:7px 8px}" + ".composer-hint{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:10px;color:var(--xero-tool-muted-foreground,#a1a1aa)}" + + ".composer-actions{display:flex;align-items:center;gap:6px;flex:0 0 auto}" + + ".dictation-button{appearance:none;position:relative;display:flex;align-items:center;justify-content:center;width:28px;height:28px;border:1px solid var(--xero-tool-border,#3f3f46);border-radius:8px;background:transparent;color:var(--xero-tool-muted-foreground,#a1a1aa);cursor:pointer}" + + ".dictation-button[hidden]{display:none!important}" + + ".dictation-button:hover{background:var(--xero-tool-secondary,#27272a);color:var(--xero-tool-secondary-foreground,#fafafa)}" + + ".dictation-button:focus-visible{outline:2px solid var(--xero-tool-ring,#f97316);outline-offset:1px}" + + ".dictation-button:disabled{cursor:not-allowed;opacity:.48}" + + ".dictation-button[data-listening='true']{border-color:var(--xero-tool-primary,#fafafa);background:var(--xero-tool-primary,#fafafa);color:var(--xero-tool-primary-foreground,#18181b);box-shadow:0 0 0 calc(2px + var(--xero-dictation-level,0) * 5px) color-mix(in srgb,var(--xero-tool-primary,#fafafa) 18%,transparent)}" + + ".dictation-icon{width:14px;height:14px;fill:none;stroke:currentColor;stroke-width:2;stroke-linecap:round;stroke-linejoin:round}" + ".send-button{appearance:none;border:1px solid var(--xero-tool-primary,#fafafa);border-radius:8px;background:var(--xero-tool-primary,#fafafa);color:var(--xero-tool-primary-foreground,#18181b);height:28px;padding:0 10px;font:700 11px/1 ui-sans-serif,system-ui;cursor:pointer}" + ".send-button:hover{filter:brightness(1.08)}" + ".inspect-highlight{position:fixed;z-index:2;display:none;border:2px solid var(--xero-tool-selection,#f97316);border-radius:6px;background:rgba(249,115,22,.08);box-shadow:0 0 0 9999px rgba(0,0,0,.08),0 0 0 1px rgba(255,255,255,.1) inset;pointer-events:none}" + ".inspect-highlight[data-selected='true']{border-color:var(--xero-tool-primary,#fafafa);background:rgba(255,255,255,.1)}" + ".inspect-label{position:absolute;left:-2px;top:-24px;max-width:360px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;border-radius:5px;background:var(--xero-tool-selection,#f97316);color:var(--xero-tool-accent-foreground,#111827);padding:4px 6px;font:700 10px/1 ui-monospace,SFMono-Regular,Menlo,monospace}" + ".inspect-highlight[data-selected='true'] .inspect-label{background:var(--xero-tool-primary,#fafafa);color:var(--xero-tool-primary-foreground,#18181b)}" + + "[data-exiting='true'] .pen-layer,[data-exiting='true'] .inspect-highlight{opacity:0;pointer-events:none}" + "[data-capture='true'] .toolbar,[data-capture='true'] .composer{display:none!important}"; shadow.appendChild(style); } @@ -801,13 +2140,17 @@ const BROWSER_TOOL_RUNTIME = String.raw` function createState(mode, pageLabel, theme) { var existing = document.getElementById(ROOT_ID); if (existing) existing.remove(); + var existingPenRoot = document.getElementById(PEN_DOCUMENT_ROOT_ID); + if (existingPenRoot) existingPenRoot.remove(); + var existingPenLayer = document.getElementById(PEN_DOCUMENT_LAYER_ID); + if (existingPenLayer) existingPenLayer.remove(); var host = document.createElement("div"); host.id = ROOT_ID; host.setAttribute("data-xero-browser-tool-host", "true"); host.style.position = "fixed"; host.style.inset = "0"; - host.style.zIndex = "2147483647"; + host.style.zIndex = TOOL_Z_INDEX; host.style.pointerEvents = "auto"; host.style.background = "transparent"; applyTheme(host, theme); @@ -815,6 +2158,7 @@ const BROWSER_TOOL_RUNTIME = String.raw` installStyles(shadow); var layer = createNode("div", "layer"); layer.setAttribute("data-capture", "false"); + layer.setAttribute("data-loading", "false"); shadow.appendChild(layer); document.documentElement.appendChild(host); @@ -827,11 +2171,16 @@ const BROWSER_TOOL_RUNTIME = String.raw` toolbar: null, composer: null, composerInput: null, + composerDictationButton: null, + composerAnchor: null, + composerStroke: null, penLayer: null, highlight: null, + dictationState: null, cleanups: [], pendingContext: null, captureMode: false, + syncPenLayer: null, clearPen: null, strokes: [], hoveredElement: null, @@ -857,25 +2206,103 @@ const BROWSER_TOOL_RUNTIME = String.raw` } else { setupPen(state); } + promoteToolLayers(state, true); return { active: true, mode: mode }; }, prepareCapture: function () { var state = api.state; - if (!state) return null; + if (!state) { + return null; + } + promoteToolLayers(state, true); + if (typeof state.syncPenLayer === "function") state.syncPenLayer(); state.captureMode = true; if (state.root) state.root.setAttribute("data-capture", "true"); return state.pendingContext || null; }, + finishCapture: function (durationMs) { + var state = api.state; + if (!state) { + return false; + } + var duration = Number(durationMs); + if (!Number.isFinite(duration) || duration < 0) duration = 0; + try { + if (window.matchMedia && window.matchMedia("(prefers-reduced-motion: reduce)").matches) { + duration = 0; + } + } catch (_error) { + // default to the requested duration + } + hideCaptureLoading(state); + state.captureMode = false; + if (state.root) { + state.root.setAttribute("data-capture", "false"); + state.root.setAttribute("data-exiting", "true"); + } + if (state.pageLayer) { + state.pageLayer.style.transitionDuration = duration + "ms"; + try { + state.pageLayer.getBoundingClientRect(); + } catch (_error) { + // continue with the fade even if layout cannot be read + } + state.pageLayer.style.opacity = "0"; + } + window.setTimeout(function () { + if (api.state === state) { + api.deactivate(); + } + }, duration); + return true; + }, restoreCapture: function () { var state = api.state; - if (!state) return false; + if (!state) { + return false; + } + hideCaptureLoading(state); state.captureMode = false; - if (state.root) state.root.setAttribute("data-capture", "false"); + if (state.root) { + state.root.setAttribute("data-capture", "false"); + state.root.setAttribute("data-exiting", "false"); + } + if (state.pageLayer) { + state.pageLayer.style.opacity = "1"; + } + return true; + }, + setComposerNote: function (note) { + return setComposerNoteValue(api.state, note); + }, + focusComposerNote: function () { + var input = api.state && api.state.composerInput; + if (!input) return false; + try { + input.focus(); + return true; + } catch (_error) { + return false; + } + }, + setDictationState: function (dictationState) { + var state = api.state; + if (!state) return false; + state.dictationState = dictationState || null; + applyComposerDictationState(state); return true; }, + showLoading: function () { + return showCaptureLoading(api.state); + }, + hideLoading: function () { + return hideCaptureLoading(api.state); + }, deactivate: function () { var state = api.state; if (state) { + hideCaptureLoading(state); + emitComposerNote(state, state.composerInput ? state.composerInput.value : "", false); for (var index = 0; index < state.cleanups.length; index += 1) { try { state.cleanups[index](); } catch (_error) { /* ignore */ } } @@ -883,6 +2310,10 @@ const BROWSER_TOOL_RUNTIME = String.raw` } else { var existing = document.getElementById(ROOT_ID); if (existing) existing.remove(); + var existingPenRoot = document.getElementById(PEN_DOCUMENT_ROOT_ID); + if (existingPenRoot) existingPenRoot.remove(); + var existingPenLayer = document.getElementById(PEN_DOCUMENT_LAYER_ID); + if (existingPenLayer) existingPenLayer.remove(); } api.state = null; return true; @@ -918,12 +2349,53 @@ if (window.__xeroBrowserTool && typeof window.__xeroBrowserTool.prepareCapture = } ` +export const BROWSER_TOOL_FINISH_CAPTURE_SCRIPT = (durationMs: number) => ` +if (window.__xeroBrowserTool && typeof window.__xeroBrowserTool.finishCapture === "function") { + window.__xeroBrowserTool.finishCapture(${JSON.stringify(durationMs)}); +} +` + +export const BROWSER_TOOL_SHOW_LOADING_SCRIPT = ` +if (window.__xeroBrowserTool && typeof window.__xeroBrowserTool.showLoading === "function") { + window.__xeroBrowserTool.showLoading(); +} +` + export const BROWSER_TOOL_RESTORE_CAPTURE_SCRIPT = ` if (window.__xeroBrowserTool && typeof window.__xeroBrowserTool.restoreCapture === "function") { window.__xeroBrowserTool.restoreCapture(); } ` +export function buildBrowserToolSetComposerNoteScript(note: string): string { + return ` +if (window.__xeroBrowserTool && typeof window.__xeroBrowserTool.setComposerNote === "function") { + window.__xeroBrowserTool.setComposerNote(${JSON.stringify(note)}); +} +` +} + +export const BROWSER_TOOL_FOCUS_COMPOSER_NOTE_SCRIPT = ` +if (window.__xeroBrowserTool && typeof window.__xeroBrowserTool.focusComposerNote === "function") { + window.__xeroBrowserTool.focusComposerNote(); +} +` + +export function buildBrowserToolDictationStateScript(state: { + ariaLabel: string + audioLevel?: number + isListening: boolean + isToggleDisabled: boolean + tooltip: string + visible: boolean +}): string { + return ` +if (window.__xeroBrowserTool && typeof window.__xeroBrowserTool.setDictationState === "function") { + window.__xeroBrowserTool.setDictationState(${JSON.stringify(state)}); +} +` +} + export function browserScreenshotBytesFromBase64(base64: string): Uint8Array { const raw = base64.includes(",") ? base64.slice(base64.lastIndexOf(",") + 1) : base64 const binary = atob(raw) @@ -940,6 +2412,71 @@ function browserToolPromptPageReference(page: BrowserToolPageContext): string { return title ? `${title} (${url})` : url } +function compactBrowserToolMetadataText(value: string | null | undefined, maxLength = 280): string | null { + const normalized = value?.replace(/\s+/g, " ").trim() + if (!normalized) return null + return normalized.length <= maxLength + ? normalized + : `${normalized.slice(0, Math.max(0, maxLength - 3)).trimEnd()}...` +} + +function browserToolPromptHeader(kind: BrowserToolContext["kind"], metadata?: BrowserToolPromptMetadata): string { + const index = metadata?.captureIndex + if (typeof index === "number" && Number.isFinite(index) && index > 0) { + return kind === "pen" + ? `Browser sketch context (capture ${Math.round(index)}):` + : `Browser element inspection context (capture ${Math.round(index)}):` + } + return kind === "pen" ? "Browser sketch context:" : "Browser element inspection context:" +} + +function formatBrowserToolViewport(viewport: BrowserToolViewportContext): string { + const parts = [`${viewport.width}x${viewport.height} CSS px`] + if (typeof viewport.devicePixelRatio === "number" && Number.isFinite(viewport.devicePixelRatio)) { + parts.push(`DPR ${viewport.devicePixelRatio}`) + } + return `Viewport: ${parts.join(", ")}` +} + +function formatBrowserToolScroll(scroll: BrowserToolScrollContext | null | undefined): string | null { + if (!scroll) return null + return `Scroll: x=${Math.round(scroll.x)} y=${Math.round(scroll.y)}` +} + +function formatBrowserToolRect(label: string, rect: BrowserToolRectContext | null | undefined): string | null { + if (!rect) return null + return `${label}: x=${Math.round(rect.x)} y=${Math.round(rect.y)} w=${Math.round(rect.width)} h=${Math.round(rect.height)} (viewport CSS px)` +} + +function formatBrowserToolAttachment(metadata: BrowserToolPromptMetadata | undefined, screenshotAttached: boolean): string | null { + if (!screenshotAttached) return null + const name = compactBrowserToolMetadataText(metadata?.attachmentName, 120) + const index = metadata?.captureIndex + const imageLabel = + typeof index === "number" && Number.isFinite(index) && index > 0 + ? `attached image ${Math.round(index)}` + : "attached image" + return name + ? `Attached image: ${imageLabel}, ${name} (paired with this capture; images are ordered by capture number).` + : `Attached image: ${imageLabel} (paired with this capture; images are ordered by capture number).` +} + +function browserToolCaptureMetadataLines( + context: BrowserToolContext, + metadata: BrowserToolPromptMetadata | undefined, + options: { screenshotAttached?: boolean } = {}, +): string[] { + const screenshotAttached = options.screenshotAttached ?? context.kind === "pen" + return [ + metadata?.appLabel ? `App: ${compactBrowserToolMetadataText(metadata.appLabel, 120)}` : null, + `Page: ${browserToolPromptPageReference(context.page)}`, + context.note ? `User note: ${compactBrowserToolMetadataText(context.note)}` : null, + formatBrowserToolAttachment(metadata, screenshotAttached), + formatBrowserToolViewport(context.viewport), + formatBrowserToolScroll(context.scroll), + ].filter((line): line is string => Boolean(line)) +} + function sanitizedBrowserToolPromptUrl(rawUrl: string): string { try { const parsed = new URL(rawUrl) @@ -955,35 +2492,139 @@ function sanitizedBrowserToolPromptUrl(rawUrl: string): string { } } -export function buildBrowserToolAgentPrompt(context: BrowserToolContext): string { - const pageLine = browserToolPromptPageReference(context.page) +function formatElementSourceHint(source: BrowserToolElementContext["source"]): string { + if (!source) return "Source: unavailable" + const location = source.filePath + ? `${source.filePath}${source.lineNumber ? `:${source.lineNumber}` : ""}${source.columnNumber ? `:${source.columnNumber}` : ""}` + : null + const details = [ + location, + source.componentName ? `component ${source.componentName}` : null, + source.framework ? `via ${source.framework}` : null, + !location && source.raw ? source.raw : null, + ].filter((value): value is string => Boolean(value)) + return details.length > 0 + ? `Source: ${details.join(" | ")}` + : "Source: unavailable" +} + +function lastBrowserToolPathSegment(path: string): string { + const parts = path.split(/[\\/]/).filter(Boolean) + return parts.at(-1) ?? path +} + +function compactBrowserToolCardText(value: string, maxLength = 54): string { + const normalized = value.replace(/\s+/g, " ").trim() + if (normalized.length <= maxLength) return normalized + return `${normalized.slice(0, Math.max(0, maxLength - 3)).trimEnd()}...` +} + +function elementContextCardSubtitle(element: BrowserToolElementContext): string { + const source = element.source ?? null + if (source?.filePath) { + const fileName = lastBrowserToolPathSegment(source.filePath) + return source.lineNumber ? `${fileName}:${source.lineNumber}` : fileName + } + if (source?.componentName) { + return source.componentName + } + if (element.label) { + return element.label + } + if (element.selector) { + return element.selector + } + return `<${element.tagName}>` +} + +export function buildBrowserToolContextCard( + context: BrowserToolContext, +): BrowserAgentContextRequest["contextCard"] | undefined { + if (context.kind === "pen") { + const strokeLabel = `${context.strokeCount} stroke${context.strokeCount === 1 ? "" : "s"}` + return { + kind: "sketch", + title: "Browser sketch context", + subtitle: compactBrowserToolCardText(`${strokeLabel} on browser screenshot`), + } + } + return { + kind: "element", + title: "Element context", + subtitle: compactBrowserToolCardText(elementContextCardSubtitle(context.element)), + } +} + +function formatElementAttributes( + attributes: BrowserToolElementContext["attributes"], +): string | null { + if (!attributes?.length) return null + return `Stable attrs: ${attributes + .slice(0, 6) + .map((attribute) => `${attribute.name}="${attribute.value}"`) + .join(", ")}` +} + +function formatElementAncestors( + ancestors: BrowserToolElementContext["ancestors"], +): string | null { + if (!ancestors?.length) return null + const chain = ancestors.slice(0, 3).map((ancestor) => { + const parts = [ + `<${ancestor.tagName}>`, + ancestor.selector, + ancestor.id ? `id ${ancestor.id}` : null, + ancestor.role ? `role ${ancestor.role}` : null, + ancestor.label ? `label "${ancestor.label}"` : null, + ].filter((value): value is string => Boolean(value)) + return parts.join(" ") + }) + return `Parent chain: ${chain.join(" > ")}` +} + +export function buildBrowserToolAgentPrompt( + context: BrowserToolContext, + options: { metadata?: BrowserToolPromptMetadata; screenshotAttached?: boolean } = {}, +): string { + const screenshotAttached = options.screenshotAttached ?? true + const captureLines = browserToolCaptureMetadataLines(context, options.metadata, { + screenshotAttached, + }) if (context.kind === "pen") { return [ - "Browser sketch context:", - `Page: ${pageLine}`, - `Drawing: ${context.strokeCount} stroke${context.strokeCount === 1 ? "" : "s"} on the attached browser screenshot.`, - ].join("\n") + browserToolPromptHeader(context.kind, options.metadata), + ...captureLines, + formatBrowserToolRect("Annotation bounds", context.annotationBounds), + screenshotAttached + ? `Drawing: ${context.strokeCount} stroke${context.strokeCount === 1 ? "" : "s"} on the attached browser screenshot.` + : `Drawing: ${context.strokeCount} stroke${context.strokeCount === 1 ? "" : "s"} captured by the browser sketch tool. No browser screenshot was attached.`, + ] + .filter((line): line is string => Boolean(line)) + .join("\n") } const element = context.element const details = [ - `Selector: ${element.selector ?? "unavailable"}`, - `Tag: <${element.tagName}>`, + formatElementSourceHint(element.source ?? null), + `Element: <${element.tagName}>`, + formatBrowserToolRect("Element bounds", element.rect), + element.selector ? `Selector: ${element.selector}` : null, element.id ? `ID: ${element.id}` : null, element.classes.length ? `Classes: ${element.classes.join(" ")}` : null, element.role ? `Role: ${element.role}` : null, element.label ? `Label: ${element.label}` : null, element.text ? `Text: ${element.text}` : null, - `Bounds: x=${element.rect.x}, y=${element.rect.y}, width=${element.rect.width}, height=${element.rect.height}`, + formatElementAttributes(element.attributes), + formatElementAncestors(element.ancestors), ].filter((line): line is string => Boolean(line)) return [ - "Browser element inspection context:", - `Page: ${pageLine}`, - "Selected element:", + browserToolPromptHeader(context.kind, options.metadata), + ...captureLines, + "Selected element (for locating code; no screenshot):", ...details.map((line) => `- ${line}`), - "The attached browser screenshot highlights this selection.", + "Use these identifiers to find the implementation before editing.", ].join("\n") } diff --git a/client/components/xero/floating-right-sidebar-frame.tsx b/client/components/xero/floating-right-sidebar-frame.tsx index 5c320bdc..248b509c 100644 --- a/client/components/xero/floating-right-sidebar-frame.tsx +++ b/client/components/xero/floating-right-sidebar-frame.tsx @@ -7,6 +7,7 @@ import { cn } from "@/lib/utils" import { FLOATING_RIGHT_SIDEBAR_TRANSITION, SIDEBAR_INSTANT_TRANSITION, + useSidebarWidthMotion, } from "@/lib/sidebar-motion" interface FloatingRightSidebarFrameProps { @@ -18,6 +19,7 @@ interface FloatingRightSidebarFrameProps { overlayClassName?: string panelClassName?: string panelStyle?: CSSProperties + isResizing?: boolean onOverlayClick?: () => void } @@ -30,12 +32,19 @@ export function FloatingRightSidebarFrame({ overlayClassName, panelClassName, panelStyle, + isResizing = false, onOverlayClick, }: FloatingRightSidebarFrameProps) { const shouldReduceMotion = useReducedMotion() const transition = shouldReduceMotion ? SIDEBAR_INSTANT_TRANSITION : FLOATING_RIGHT_SIDEBAR_TRANSITION + const numericWidth = typeof width === "number" ? width : 0 + const widthMotion = useSidebarWidthMotion(numericWidth, { + animate: typeof width === "number", + isResizing, + }) + const widthStyle = typeof width === "number" ? widthMotion.style : { width } return ( @@ -56,6 +65,7 @@ export function FloatingRightSidebarFrame({ aria-label={label} className={cn( "gpu-layer fixed inset-y-0 right-0 z-50 flex flex-col overflow-hidden border-l border-border/80 bg-sidebar shadow-2xl", + typeof width === "number" && widthMotion.islandClassName, panelClassName, )} data-slot="floating-right-sidebar-panel" @@ -63,7 +73,7 @@ export function FloatingRightSidebarFrame({ exit={{ x: "100%" }} initial={{ x: "100%" }} style={{ - width, + ...widthStyle, contain: "layout paint style", ...panelStyle, }} diff --git a/client/components/xero/floating-right-sidebar-header.tsx b/client/components/xero/floating-right-sidebar-header.tsx new file mode 100644 index 00000000..404f7618 --- /dev/null +++ b/client/components/xero/floating-right-sidebar-header.tsx @@ -0,0 +1,53 @@ +"use client" + +import type { ButtonHTMLAttributes, ReactNode } from "react" + +import { cn } from "@/lib/utils" + +interface FloatingRightSidebarHeaderProps { + title: string + actions?: ReactNode + className?: string +} + +export function FloatingRightSidebarHeader({ + title, + actions, + className, +}: FloatingRightSidebarHeaderProps) { + return ( +
+
+

+ {title} +

+
+ {actions ?
{actions}
: null} +
+ ) +} + +export function FloatingRightSidebarHeaderButton({ + className, + type = "button", + ...props +}: ButtonHTMLAttributes) { + return ( + + {currentStep.id === "welcome" ? ( + + ) : null} + {currentStep.id === "providers" ? ( + + ) : null} + {currentStep.id === "project" ? ( + void onImportProject()} + /> + ) : null} + {currentStep.id === "environment-access" ? ( + + ) : null} + {currentStep.id === "environment-access" && environmentPermissionSaveError ? ( +

+ {environmentPermissionSaveError} +

+ ) : null} + {currentStep.id === "confirm" ? ( + + ) : null} + {currentStep.id === "beta" ? : null} + + -
- {showStepSkip ? ( + {showFooter ? ( +
+
- ) : null} - -
-
- - ) : null} + +
+ +
+ + + ) : null} + + ) } diff --git a/client/components/xero/onboarding/steps/local-environment-step.tsx b/client/components/xero/onboarding/steps/local-environment-step.tsx deleted file mode 100644 index f6285e73..00000000 --- a/client/components/xero/onboarding/steps/local-environment-step.tsx +++ /dev/null @@ -1,375 +0,0 @@ -import { useCallback, useEffect, useState } from "react" -import { invoke } from "@tauri-apps/api/core" -import { AlertCircle, KeyRound, Loader2, RefreshCw, RotateCcw, Server } from "lucide-react" -import { Alert, AlertDescription } from "@/components/ui/alert" -import { Badge } from "@/components/ui/badge" -import { Button } from "@/components/ui/button" -import { Input } from "@/components/ui/input" -import { Label } from "@/components/ui/label" -import { StepHeader } from "./providers-step" - -interface LocalEnvironmentConfig { - launchMode: string | null - envFilePath: string | null - phxHost: string - port: string - databaseUrl: string - corsOrigins: string - poolSize: string - rateLimitPerMinute: string - hasSecretKeyBase: boolean -} - -const DEFAULTS: Pick< - LocalEnvironmentConfig, - "phxHost" | "port" | "databaseUrl" | "corsOrigins" | "poolSize" | "rateLimitPerMinute" -> = { - phxHost: "127.0.0.1", - port: "4000", - databaseUrl: "ecto://postgres:postgres@localhost/xero_prod", - corsOrigins: "http://localhost:3000,http://127.0.0.1:3000,tauri://localhost", - poolSize: "10", - rateLimitPerMinute: "60", -} - -interface LocalEnvironmentStepProps { - onSaved?: () => void -} - -export function LocalEnvironmentStep({ onSaved }: LocalEnvironmentStepProps) { - const [config, setConfig] = useState(null) - const [draft, setDraft] = useState(null) - const [loadError, setLoadError] = useState(null) - const [saveStatus, setSaveStatus] = useState<"idle" | "saving" | "saved" | "error">("idle") - const [saveError, setSaveError] = useState(null) - const [regenerateStatus, setRegenerateStatus] = useState<"idle" | "running" | "ok" | "error">("idle") - const [showAdvanced, setShowAdvanced] = useState(false) - - useEffect(() => { - let cancelled = false - void invoke("get_local_environment_config") - .then((value) => { - if (cancelled) return - setConfig(value) - setDraft({ - phxHost: value.phxHost, - port: value.port, - databaseUrl: value.databaseUrl, - corsOrigins: value.corsOrigins, - poolSize: value.poolSize, - rateLimitPerMinute: value.rateLimitPerMinute, - }) - }) - .catch((err: { message?: string } | string) => { - if (cancelled) return - const message = typeof err === "string" ? err : err?.message ?? "Could not load local environment." - setLoadError(message) - }) - return () => { - cancelled = true - } - }, []) - - const updateField = useCallback( - (key: K, value: string) => { - setDraft((current) => (current ? { ...current, [key]: value } : current)) - setSaveStatus("idle") - setSaveError(null) - }, - [], - ) - - const resetField = useCallback( - (key: K) => { - updateField(key, DEFAULTS[key]) - }, - [updateField], - ) - - const handleSave = useCallback(async () => { - if (!draft) return - setSaveStatus("saving") - setSaveError(null) - try { - const next = await invoke("save_local_environment_config", { - request: draft, - }) - setConfig(next) - setSaveStatus("saved") - onSaved?.() - } catch (err) { - setSaveStatus("error") - const message = typeof err === "string" ? err : (err as { message?: string })?.message ?? "Could not save local environment." - setSaveError(message) - } - }, [draft, onSaved]) - - const handleRegenerate = useCallback(async () => { - setRegenerateStatus("running") - try { - await invoke("regenerate_secret_key_base") - const next = await invoke("get_local_environment_config") - setConfig(next) - setRegenerateStatus("ok") - } catch { - setRegenerateStatus("error") - } - }, []) - - if (loadError) { - return ( -
- - - - {loadError} - -
- ) - } - - if (!config || !draft) { - return ( -
- -
- - Loading current settings… -
-
- ) - } - - return ( -
- - -
- updateField("port", v)} - onReset={() => resetField("port")} - placeholder="4000" - inputMode="numeric" - /> - - updateField("phxHost", v)} - onReset={() => resetField("phxHost")} - placeholder="127.0.0.1" - /> - -
-
- - - -
-
-

Phoenix secret key base

- {config.hasSecretKeyBase ? ( - - Generated - - ) : ( - - Missing - - )} -
-

- Used to sign cookies. Stored locally in server/.env. -

- {regenerateStatus === "error" ? ( -

Could not regenerate secret. Try again.

- ) : null} - {regenerateStatus === "ok" ? ( -

Secret regenerated. Restart `pnpm start` to pick it up.

- ) : null} -
- -
-
- - - - {showAdvanced ? ( -
- updateField("databaseUrl", v)} - onReset={() => resetField("databaseUrl")} - placeholder={DEFAULTS.databaseUrl} - monospace - /> - updateField("corsOrigins", v)} - onReset={() => resetField("corsOrigins")} - placeholder={DEFAULTS.corsOrigins} - monospace - /> -
- updateField("poolSize", v)} - onReset={() => resetField("poolSize")} - placeholder={DEFAULTS.poolSize} - inputMode="numeric" - /> - updateField("rateLimitPerMinute", v)} - onReset={() => resetField("rateLimitPerMinute")} - placeholder={DEFAULTS.rateLimitPerMinute} - inputMode="numeric" - /> -
-
- ) : null} - - {config.envFilePath ? ( -
- -
-

Config file

-

{config.envFilePath}

-
-
- ) : null} - - {saveError ? ( - - - {saveError} - - ) : null} - -
-

- Changes apply on next pnpm start. -

- -
-
-
- ) -} - -interface FieldRowProps { - label: string - hint: string - value: string - defaultValue: string - onChange: (value: string) => void - onReset: () => void - placeholder?: string - inputMode?: "text" | "numeric" - monospace?: boolean -} - -function FieldRow({ - label, - hint, - value, - defaultValue, - onChange, - onReset, - placeholder, - inputMode, - monospace, -}: FieldRowProps) { - const dirty = value !== defaultValue - return ( -
-
- - {dirty ? ( - - ) : null} -
- onChange(event.target.value)} - placeholder={placeholder} - inputMode={inputMode} - className={`h-8 text-[12px] ${monospace ? "font-mono" : ""}`} - /> -

{hint}

-
- ) -} diff --git a/client/components/xero/onboarding/steps/providers-step.tsx b/client/components/xero/onboarding/steps/providers-step.tsx index d61d96db..276a9c10 100644 --- a/client/components/xero/onboarding/steps/providers-step.tsx +++ b/client/components/xero/onboarding/steps/providers-step.tsx @@ -9,7 +9,6 @@ import type { RuntimeProviderIdDto, RuntimeSessionView, UpsertProviderCredentialRequestDto, - XaiDeviceCodeLoginDto, } from "@/src/lib/xero-model" import { ProviderCredentialsList } from "@/components/xero/provider-profiles/provider-credentials-list" @@ -33,11 +32,6 @@ interface ProvidersStepProps { providerId: RuntimeProviderIdDto originator?: string | null }) => Promise - onStartXaiDeviceCodeLogin?: (request: { providerId: "xai" }) => Promise - onPollXaiDeviceCodeLogin?: (request: { - providerId: "xai" - flowId: string - }) => Promise } export function ProvidersStep({ @@ -51,8 +45,6 @@ export function ProvidersStep({ onUpsertProviderCredential, onDeleteProviderCredential, onStartOAuthLogin, - onStartXaiDeviceCodeLogin, - onPollXaiDeviceCodeLogin, }: ProvidersStepProps) { return (
@@ -73,8 +65,6 @@ export function ProvidersStep({ onUpsertProviderCredential={onUpsertProviderCredential} onDeleteProviderCredential={onDeleteProviderCredential} onStartOAuthLogin={onStartOAuthLogin} - onStartXaiDeviceCodeLogin={onStartXaiDeviceCodeLogin} - onPollXaiDeviceCodeLogin={onPollXaiDeviceCodeLogin} />
diff --git a/client/components/xero/onboarding/types.ts b/client/components/xero/onboarding/types.ts index b6979032..b384db01 100644 --- a/client/components/xero/onboarding/types.ts +++ b/client/components/xero/onboarding/types.ts @@ -1,6 +1,5 @@ export type OnboardingStepId = | "welcome" - | "local-environment" | "providers" | "project" | "environment-access" diff --git a/client/components/xero/project-rail.test.tsx b/client/components/xero/project-rail.test.tsx index 9568d3a0..0716ab9f 100644 --- a/client/components/xero/project-rail.test.tsx +++ b/client/components/xero/project-rail.test.tsx @@ -1,4 +1,4 @@ -import { fireEvent, render, screen, within } from '@testing-library/react' +import { fireEvent, render, screen, waitFor, within } from '@testing-library/react' import { describe, expect, it, vi } from 'vitest' import { ProjectRail } from './project-rail' @@ -73,6 +73,61 @@ describe('ProjectRail', () => { expect(screen.queryByText('0%')).not.toBeInTheDocument() }) + it('shows a styled tooltip with the project name for project cards', async () => { + render( + undefined} + onRemoveProject={() => undefined} + onSelectProject={() => undefined} + pendingProjectRemovalId={null} + projectRemovalStatus="idle" + projects={projects} + />, + ) + + const projectButton = screen.getByRole('button', { name: 'Open mesh-lang (active)' }) + fireEvent.pointerEnter(projectButton) + fireEvent.pointerMove(projectButton) + + await waitFor(() => + expect(document.querySelector('[data-slot="tooltip-content"][data-side="right"]')).toHaveTextContent( + 'mesh-lang', + ), + ) + }) + + it('shows a styled tooltip for the project rail settings button', async () => { + render( + undefined} + onOpenSettings={() => undefined} + onRemoveProject={() => undefined} + onSelectProject={() => undefined} + pendingProjectRemovalId={null} + projectRemovalStatus="idle" + projects={projects} + />, + ) + + const settingsButton = screen.getByRole('button', { name: 'Settings' }) + fireEvent.pointerEnter(settingsButton) + fireEvent.pointerMove(settingsButton) + + await waitFor(() => + expect(document.querySelector('[data-slot="tooltip-content"][data-side="right"]')).toHaveTextContent( + 'Settings', + ), + ) + }) + it('keeps only compact project monograms', () => { const onImportProject = vi.fn() const { container } = render( @@ -109,6 +164,96 @@ describe('ProjectRail', () => { expect(onImportProject).not.toHaveBeenCalled() }) + it('shows the browser-capture style border animation for projects with running agents', () => { + const secondProject = { + ...projects[0], + id: 'project-2', + name: 'nova-ui', + } + const { container } = render( + undefined} + onRemoveProject={() => undefined} + onSelectProject={() => undefined} + pendingProjectRemovalId={null} + projectRemovalStatus="idle" + projects={[...projects, secondProject]} + runningProjectIds={new Set(['project-2'])} + />, + ) + + const idleProjectButton = screen.getByRole('button', { name: 'Open mesh-lang (active)' }) + const runningProjectButton = screen.getByRole('button', { name: 'Open nova-ui' }) + + expect(idleProjectButton).not.toHaveAttribute('data-agent-running') + expect(runningProjectButton).toHaveAttribute('data-agent-running', 'true') + expect( + runningProjectButton.querySelector('.xero-project-rail-activity-aura-field'), + ).not.toBeNull() + expect(container.querySelectorAll('.xero-project-rail-activity-aura-field')).toHaveLength(1) + }) + + it('shows completed unseen session counts on project cards', () => { + const secondProject = { + ...projects[0], + id: 'project-2', + name: 'nova-ui', + } + render( + undefined} + onRemoveProject={() => undefined} + onSelectProject={() => undefined} + pendingProjectRemovalId={null} + projectRemovalStatus="idle" + projects={[...projects, secondProject]} + />, + ) + + const idleProjectButton = screen.getByRole('button', { name: 'Open mesh-lang (active)' }) + const completedProjectButton = screen.getByRole('button', { name: 'Open nova-ui' }) + + expect(idleProjectButton.querySelector('.xero-project-rail-completion-count-badge')).toBeNull() + expect( + completedProjectButton.querySelector('.xero-project-rail-completion-count-badge'), + ).toHaveTextContent('2') + expect(completedProjectButton).toHaveAccessibleDescription('2 completed unseen sessions') + }) + + it('caps large completed unseen session counts in the compact badge', () => { + render( + undefined} + onRemoveProject={() => undefined} + onSelectProject={() => undefined} + pendingProjectRemovalId={null} + projectRemovalStatus="idle" + projects={projects} + />, + ) + + const projectButton = screen.getByRole('button', { name: 'Open mesh-lang (active)' }) + + expect(projectButton.querySelector('.xero-project-rail-completion-count-badge')).toHaveTextContent( + '99+', + ) + expect(projectButton).toHaveAccessibleDescription('128 completed unseen sessions') + }) + it('does not render project load errors as a destructive rail slot', () => { const { container } = render( + completedSessionCountsByProject?: ReadonlyMap errorMessage: string | null onSelectProject: (projectId: string) => void onPreloadProject?: (projectId: string) => void @@ -33,6 +36,8 @@ export function ProjectRail({ projectRemovalStatus, pendingProjectRemovalId, pendingProjectSelectionId = null, + runningProjectIds, + completedSessionCountsByProject, onSelectProject, onPreloadProject, onPreviewProject, @@ -85,21 +90,27 @@ export function ProjectRail({ onPointerLeave={onSessionsHoverLeave} >
-
    - {projects.map((project) => ( -
  • - -
  • - ))} +
      + {projects.map((project) => { + const completedSessionCount = completedSessionCountsByProject?.get(project.id) ?? 0 + + return ( +
    • + +
    • + ) + })}
    • + + + + + Settings +
) : null} @@ -152,7 +168,9 @@ export function ProjectRail({ interface ProjectRailItemProps { project: ProjectListItem + completedSessionCount: number isActive: boolean + isAgentRunning: boolean isRemovalPending: boolean isRemovalLocked: boolean onSelectProject: (projectId: string) => void @@ -163,7 +181,9 @@ interface ProjectRailItemProps { const ProjectRailItem = memo(function ProjectRailItem({ project, + completedSessionCount, isActive, + isAgentRunning, isRemovalPending, isRemovalLocked, onSelectProject, @@ -172,41 +192,79 @@ const ProjectRailItem = memo(function ProjectRailItem({ onRemoveProject, }: ProjectRailItemProps) { const [confirmOpen, setConfirmOpen] = useState(false) + const completedSessionCountDescriptionId = useId() const projectInitial = Array.from(project.name.trim())[0]?.toUpperCase() ?? '?' + const normalizedCompletedSessionCount = Number.isFinite(completedSessionCount) + ? Math.max(0, Math.floor(completedSessionCount)) + : 0 + const hasCompletedSessionCount = normalizedCompletedSessionCount > 0 + const displayedCompletedSessionCount = + normalizedCompletedSessionCount > 99 ? '99+' : String(normalizedCompletedSessionCount) + const completedSessionCountDescription = `${normalizedCompletedSessionCount} completed unseen ${ + normalizedCompletedSessionCount === 1 ? 'session' : 'sessions' + }` return ( <>
- + + + + + + {project.name} + +
({ @@ -64,27 +63,6 @@ function makeRuntimeSession(overrides: Partial = {}): Runtim } } -function makeXaiDeviceCodeLogin( - overrides: Partial = {}, -): XaiDeviceCodeLoginDto { - return { - providerId: 'xai', - flowId: 'xai-device-flow-1', - userCode: 'GROK-1234', - verificationUri: 'https://auth.x.ai/device', - verificationUriComplete: 'https://auth.x.ai/device?user_code=GROK-1234', - intervalSeconds: 5, - expiresAt: 1_779_984_000, - phase: 'awaiting_manual_input', - sessionId: null, - accountId: null, - lastErrorCode: null, - lastError: null, - updatedAt: '2026-04-15T20:00:00.000Z', - ...overrides, - } -} - function getProviderCard(label: string): HTMLElement { const card = screen .getAllByText(label) @@ -114,9 +92,10 @@ describe('ProviderCredentialsList', () => { expect(within(getProviderCard('OpenRouter')).getByRole('button', { name: /configure/i })).toBeInTheDocument() expect(within(getProviderCard('Anthropic')).getByRole('button', { name: /configure/i })).toBeInTheDocument() expect(within(getProviderCard('DeepSeek')).getByRole('button', { name: /configure/i })).toBeInTheDocument() - expect(within(getProviderCard('xAI / Grok')).getByRole('button', { name: /sign in/i })).toBeInTheDocument() - expect(within(getProviderCard('xAI / Grok')).getByRole('button', { name: /device/i })).toBeInTheDocument() - expect(within(getProviderCard('xAI / Grok')).getByRole('button', { name: /configure/i })).toBeInTheDocument() + const xaiCard = getProviderCard('xAI / Grok') + expect(within(xaiCard).getByRole('button', { name: /sign in/i })).toBeInTheDocument() + expect(within(xaiCard).queryByRole('button', { name: /device/i })).not.toBeInTheDocument() + expect(within(xaiCard).queryByRole('button', { name: /configure/i })).not.toBeInTheDocument() expect(within(getProviderCard('Cursor')).getByRole('button', { name: /configure/i })).toBeInTheDocument() expect(within(getProviderCard('Ollama')).getByRole('button', { name: /configure/i })).toBeInTheDocument() expect(within(getProviderCard('Amazon Bedrock')).getByRole('button', { name: /configure/i })).toBeInTheDocument() @@ -292,28 +271,6 @@ describe('ProviderCredentialsList', () => { await waitFor(() => expect(onStart).toHaveBeenCalledWith({ providerId: 'openai_codex' })) }) - it('starts the xAI device-code flow and shows the user code', async () => { - const onStartDevice = vi.fn(async () => makeXaiDeviceCodeLogin()) - render( - , - ) - - const card = getProviderCard('xAI / Grok') - await act(async () => { - fireEvent.click(within(card).getByRole('button', { name: /device/i })) - }) - - await waitFor(() => expect(onStartDevice).toHaveBeenCalledWith({ providerId: 'xai' })) - expect(within(card).getByText('GROK-1234')).toBeInTheDocument() - }) - it('calls deleteProviderCredential when signing out an OAuth provider', async () => { const onDelete = vi.fn(async () => makeSnapshot([])) const credentials = makeSnapshot([ diff --git a/client/components/xero/provider-profiles/provider-credentials-list.tsx b/client/components/xero/provider-profiles/provider-credentials-list.tsx index 12a2ae42..1d1a1835 100644 --- a/client/components/xero/provider-profiles/provider-credentials-list.tsx +++ b/client/components/xero/provider-profiles/provider-credentials-list.tsx @@ -4,11 +4,9 @@ import { AlertCircle, Check, ChevronDown, - ExternalLink, LoaderCircle, LogIn, LogOut, - MonitorCheck, Webhook, } from "lucide-react" import { @@ -43,7 +41,6 @@ import { type RuntimeProviderIdDto, type RuntimeSessionView, type UpsertProviderCredentialRequestDto, - type XaiDeviceCodeLoginDto, } from "@/src/lib/xero-model" import { listCloudProviderPresets } from "@/src/lib/xero-model/provider-presets" @@ -277,11 +274,6 @@ export interface ProviderCredentialsListProps { providerId: SupportedProviderId originator?: string | null }) => Promise - onStartXaiDeviceCodeLogin?: (request: { providerId: "xai" }) => Promise - onPollXaiDeviceCodeLogin?: (request: { - providerId: "xai" - flowId: string - }) => Promise } export function ProviderCredentialsList({ @@ -295,8 +287,6 @@ export function ProviderCredentialsList({ onUpsertProviderCredential, onDeleteProviderCredential, onStartOAuthLogin, - onStartXaiDeviceCodeLogin, - onPollXaiDeviceCodeLogin, }: ProviderCredentialsListProps) { const presets = useMemo(() => listCloudProviderPresets(), []) const [openProviderId, setOpenProviderId] = useState(null) @@ -304,8 +294,6 @@ export function ProviderCredentialsList({ () => ({}) as Record, ) const [authPending, setAuthPending] = useState(null) - const [deviceLogin, setDeviceLogin] = useState(null) - const [devicePollPending, setDevicePollPending] = useState(false) const [saveError, setSaveError] = useState(null) const [openAuthError, setOpenAuthError] = useState(null) @@ -317,49 +305,6 @@ export function ProviderCredentialsList({ } }, [providerCredentialsLoadStatus, onRefreshProviderCredentials]) - useEffect(() => { - if ( - !deviceLogin || - deviceLogin.providerId !== "xai" || - deviceLogin.phase !== "awaiting_manual_input" || - !onPollXaiDeviceCodeLogin - ) { - return - } - - let cancelled = false - const delayMs = Math.max(deviceLogin.intervalSeconds, 1) * 1000 - const timer = window.setTimeout(async () => { - if (cancelled) return - setDevicePollPending(true) - try { - const next = await onPollXaiDeviceCodeLogin({ - providerId: "xai", - flowId: deviceLogin.flowId, - }) - if (!cancelled) { - setDeviceLogin(next) - } - } catch (error) { - if (!cancelled) { - setOpenAuthError({ - providerId: "xai", - message: errMsg(error, "Xero could not check the xAI device-code login."), - }) - } - } finally { - if (!cancelled) { - setDevicePollPending(false) - } - } - }, delayMs) - - return () => { - cancelled = true - window.clearTimeout(timer) - } - }, [deviceLogin, onPollXaiDeviceCodeLogin]) - const updateDraft = (providerId: SupportedProviderId, patch: Partial) => { setDrafts((prev) => ({ ...prev, @@ -456,28 +401,6 @@ export function ProviderCredentialsList({ } } - const handleDeviceCodeLogin = async () => { - if (!onStartXaiDeviceCodeLogin) return - setAuthPending({ providerId: "xai" }) - setOpenAuthError(null) - try { - const login = await onStartXaiDeviceCodeLogin({ providerId: "xai" }) - setDeviceLogin(login) - setOpenProviderId("xai") - const target = login.verificationUriComplete ?? login.verificationUri - if (target) { - await openUrl(target) - } - } catch (error) { - setOpenAuthError({ - providerId: "xai", - message: errMsg(error, "Xero could not start the xAI device-code flow."), - }) - } finally { - setAuthPending(null) - } - } - const showLoadingState = providerCredentialsLoadStatus === "loading" && !providerCredentials const showLoadError = providerCredentialsLoadStatus === "error" @@ -518,9 +441,7 @@ export function ProviderCredentialsList({ : null const localOpenAuthError = openAuthError?.providerId === providerId ? openAuthError.message : null - const supportsBrowserOAuth = - preset.authMode === "oauth" || preset.browserOAuthSupported === true - const supportsDeviceCode = preset.deviceCodeSupported === true && providerId === "xai" + const supportsBrowserOAuth = preset.authMode === "oauth" const hasConfigEditor = preset.authMode !== "oauth" const isAuthenticated = credential?.kind === "oauth_session" && credential?.hasOauthAccessToken @@ -528,8 +449,20 @@ export function ProviderCredentialsList({ supportsBrowserOAuth && !!runtimeSession?.isLoginInProgress && runtimeSession.providerId === providerId - const rowDeviceLogin = providerId === "xai" ? deviceLogin : null const status = credential ? getStatus(credential) : null + const showApiKeyField = hasConfigEditor && preset.authMode === "api_key" + const showBaseUrlField = + hasConfigEditor && (preset.baseUrlMode !== "none" || preset.authMode === "local") + const showApiVersionField = hasConfigEditor && preset.apiVersionMode !== "none" + const showRegionField = hasConfigEditor && preset.regionMode === "required" + const showProjectIdField = hasConfigEditor && preset.projectIdMode === "required" + const editorFieldCount = [ + showApiKeyField, + showBaseUrlField, + showApiVersionField, + showRegionField, + showProjectIdField, + ].filter(Boolean).length return (
) ) : null} - {supportsDeviceCode && !isAuthenticated ? ( - - ) : null} {hasConfigEditor ? ( + ) : null} - ) : null} -
- {rowDeviceLogin.phase === "awaiting_manual_input" ? ( -
- - Code - - - {rowDeviceLogin.userCode} - -
- ) : null} - {rowDeviceLogin.lastError?.message ? ( -
- {rowDeviceLogin.lastError.message}
- ) : null} - - ) : null} - - {hasConfigEditor && preset.authMode === "api_key" ? ( - - - updateDraft(providerId, { apiKey: e.target.value })} - placeholder={credential?.hasApiKey ? "••••••••" : "Paste your API key"} - className="h-9" - /> - - ) : null} - - {hasConfigEditor && (preset.baseUrlMode !== "none" || preset.authMode === "local") ? ( - - - updateDraft(providerId, { baseUrl: e.target.value })} - placeholder={preset.connectionHint} - className="h-9" - /> - - ) : null} - - {hasConfigEditor && preset.apiVersionMode !== "none" ? ( - - - updateDraft(providerId, { apiVersion: e.target.value })} - className="h-9" - /> - - ) : null} - - {hasConfigEditor && preset.regionMode === "required" ? ( - - - updateDraft(providerId, { region: e.target.value })} - className="h-9" - /> - - ) : null} - - {hasConfigEditor && preset.projectIdMode === "required" ? ( - - - updateDraft(providerId, { projectId: e.target.value })} - className="h-9" - /> - - ) : null} - - {localSaveError || localSaveErrorFromAdapter ? ( - - - - {localSaveError ?? localSaveErrorFromAdapter} - - - ) : null} + - {hasConfigEditor ? ( -
- {credential ? ( - - ) : ( - - )} - -
- ) : null} + {localSaveError || localSaveErrorFromAdapter ? ( + + + + {localSaveError ?? localSaveErrorFromAdapter} + + + ) : null} + ) : null} diff --git a/client/components/xero/session-notifications-sidebar.tsx b/client/components/xero/session-notifications-sidebar.tsx new file mode 100644 index 00000000..3b7bc68e --- /dev/null +++ b/client/components/xero/session-notifications-sidebar.tsx @@ -0,0 +1,143 @@ +"use client" + +import { useMemo } from "react" +import { formatDistanceToNow } from "date-fns" +import { ArrowRight, Bell, MessageSquare, X } from "lucide-react" + +import { cn } from "@/lib/utils" +import { FloatingRightSidebarFrame } from "@/components/xero/floating-right-sidebar-frame" +import { + FloatingRightSidebarHeader, + FloatingRightSidebarHeaderButton, +} from "@/components/xero/floating-right-sidebar-header" +import type { CompletedAgentSessionNotificationView } from "@/src/features/xero/use-xero-desktop-state" + +interface SessionNotificationsSidebarProps { + open: boolean + notifications: readonly CompletedAgentSessionNotificationView[] + onClose: () => void + onOpenSession: (projectId: string, agentSessionId: string) => void +} + +interface ProjectNotificationGroup { + projectId: string + projectName: string + sessions: CompletedAgentSessionNotificationView[] +} + +export function SessionNotificationsSidebar({ + open, + notifications, + onClose, + onOpenSession, +}: SessionNotificationsSidebarProps) { + const groups = useMemo(() => groupNotificationsByProject(notifications), [notifications]) + + return ( + +
+ + + + } + /> + +
+ {groups.length > 0 ? ( +
+ {groups.map((group) => ( +
+
+

+ {group.projectName} +

+
+
    + {group.sessions.map((session) => ( +
  • + +
  • + ))} +
+
+ ))} +
+ ) : ( +
+ + + +

No unseen responses

+
+ )} +
+
+
+ ) +} + +function groupNotificationsByProject( + notifications: readonly CompletedAgentSessionNotificationView[], +): ProjectNotificationGroup[] { + const groups = new Map() + + for (const notification of notifications) { + const group = groups.get(notification.projectId) + if (group) { + group.sessions.push(notification) + continue + } + + groups.set(notification.projectId, { + projectId: notification.projectId, + projectName: notification.projectName, + sessions: [notification], + }) + } + + return Array.from(groups.values()) +} + +function formatCompletionTime(value: string): string { + const parsed = new Date(value) + if (Number.isNaN(parsed.getTime())) { + return "just now" + } + + return formatDistanceToNow(parsed, { addSuffix: true }) +} diff --git a/client/components/xero/settings-dialog.test.tsx b/client/components/xero/settings-dialog.test.tsx index ec0ef67c..1cad137d 100644 --- a/client/components/xero/settings-dialog.test.tsx +++ b/client/components/xero/settings-dialog.test.tsx @@ -23,6 +23,7 @@ import type { McpRegistryDto, XeroDoctorReportDto, AdrenalineModeSettingsDto, + AutonomousWebSearchSettingsDto, ClosedLidModeSettingsDto, DictationSettingsDto, DictationStatusDto, @@ -258,6 +259,119 @@ function makePowerAdapter( } } +function makeWebSearchSettings( + overrides: Partial = {}, +): AutonomousWebSearchSettingsDto { + return { + mode: 'auto', + activeProviderId: 'brave-main', + providerManaged: { + modeAvailable: true, + status: 'ready', + message: 'Provider-managed search is ready for the selected model.', + supportedSources: ['openai'], + }, + providerKinds: [ + { + kind: 'brave_search', + label: 'Brave Search', + requiresApiKey: true, + supportsLocale: true, + supportsFreshness: true, + supportsSafeSearch: true, + selfHosted: false, + requiresEndpoint: false, + requiresGoogleCseCx: false, + }, + { + kind: 'custom_endpoint', + label: 'Custom endpoint', + requiresApiKey: false, + supportsLocale: false, + supportsFreshness: false, + supportsSafeSearch: false, + selfHosted: true, + requiresEndpoint: true, + requiresGoogleCseCx: false, + }, + ], + providers: [ + { + profileId: 'brave-main', + kind: 'brave_search', + displayName: 'Brave fallback', + enabled: true, + endpoint: null, + baseUrl: null, + googleCseCx: null, + resultLimit: 5, + timeoutMs: 2500, + region: 'us', + language: 'en', + freshness: null, + safeSearch: true, + hasApiKey: true, + apiKeyUpdatedAt: '2026-05-30T12:00:00Z', + readiness: { + ready: true, + status: 'ready', + message: 'Provider is ready.', + }, + lastCheck: null, + createdAt: '2026-05-30T12:00:00Z', + updatedAt: '2026-05-30T12:00:00Z', + }, + ], + updatedAt: '2026-05-30T12:00:00Z', + ...overrides, + } +} + +function makeWebSearchAdapter( + settings = makeWebSearchSettings(), +): NonNullable { + return { + isDesktopRuntime: vi.fn(() => true), + autonomousWebSearchSettings: vi.fn(async () => settings), + autonomousWebSearchUpdateSettings: vi.fn(async (request) => + makeWebSearchSettings({ + ...settings, + mode: request.mode, + }), + ), + autonomousWebSearchUpsertProvider: vi.fn(async () => settings), + autonomousWebSearchDeleteProvider: vi.fn(async () => + makeWebSearchSettings({ + ...settings, + activeProviderId: null, + providers: [], + }), + ), + autonomousWebSearchSetActiveProvider: vi.fn(async (request) => + makeWebSearchSettings({ + ...settings, + activeProviderId: request.providerId, + }), + ), + autonomousWebSearchCheckProvider: vi.fn(async () => + makeWebSearchSettings({ + ...settings, + providers: settings.providers.map((provider) => ({ + ...provider, + lastCheck: { + status: 'ok', + code: 'autonomous_web_search_provider_ok', + message: 'Provider returned results.', + latencyMs: 42, + sampleResultCount: 1, + checkedAt: '2026-05-30T12:05:00Z', + }, + })), + }), + ), + } +} + function makeDoctorReport(): XeroDoctorReportDto { return createXeroDoctorReport({ reportId: 'doctor-20260426-120000', @@ -738,6 +852,16 @@ describe('SettingsDialog', () => { rootPath: '/tmp/harness-fixture', }) } + if (command === 'developer_tool_error_log_list') { + return Promise.resolve({ + databasePath: '/tmp/xero/development/tool-call-errors.sqlite', + entries: [], + projectIds: [], + totalCount: 0, + limit: 100, + offset: 0, + }) + } if (command === 'browser_control_settings') { return Promise.resolve({ preference: 'default', @@ -768,6 +892,23 @@ describe('SettingsDialog', () => { expect(screen.getByRole('button', { name: 'Power' })).toBeVisible() }) + it('opens the account section by default', async () => { + render() + + expect(await screen.findByRole('heading', { name: 'Account' })).toBeVisible() + expect(screen.getByRole('button', { name: 'Account' })).toHaveAttribute('aria-current', 'page') + expect(screen.getByRole('button', { name: 'Providers' })).not.toHaveAttribute('aria-current') + expect(screen.queryByRole('heading', { name: 'Providers' })).not.toBeInTheDocument() + }) + + it('keeps the full-screen settings dialog visible for its closed-state animation', async () => { + render() + + const dialog = await screen.findByRole('dialog') + + expect(dialog.className).toContain('data-[state=closed]:opacity-100') + }) + it('keeps settings section tab hover states immediate', async () => { render() @@ -778,6 +919,67 @@ describe('SettingsDialog', () => { } }) + it('renders web search settings and dispatches mode and provider actions', async () => { + const settings = makeWebSearchSettings() + const webSearchAdapter = makeWebSearchAdapter(settings) + + render( + , + ) + + expect(await screen.findByRole('heading', { name: 'Web Search' }, { timeout: 5000 })).toBeVisible() + expect(await screen.findByText('Brave Search')).toBeVisible() + expect(screen.getByText('Provider-managed search is ready for the selected model.')).toBeVisible() + expect(screen.getByText('Configured')).toBeVisible() + expect(screen.getByText('Available')).toBeVisible() + expect(screen.getByText('Custom endpoint')).toBeVisible() + + fireEvent.click(screen.getByRole('button', { name: 'Configure' })) + + expect(screen.queryByText('Name')).not.toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'Cancel' })).not.toBeInTheDocument() + const customEndpointInput = screen.getByPlaceholderText('https://search.example.com/api') + expect(customEndpointInput).toBeVisible() + + fireEvent.change(customEndpointInput, { + target: { value: 'https://search.example.com/api' }, + }) + fireEvent.click(screen.getByRole('button', { name: 'Save' })) + + await waitFor(() => + expect(webSearchAdapter.autonomousWebSearchUpsertProvider).toHaveBeenCalledWith({ + profileId: null, + kind: 'custom_endpoint', + displayName: 'Custom endpoint', + endpoint: 'https://search.example.com/api', + apiKey: null, + googleCseCx: null, + enabled: true, + }), + ) + + fireEvent.click(screen.getByText('Configured provider only')) + + await waitFor(() => + expect(webSearchAdapter.autonomousWebSearchUpdateSettings).toHaveBeenCalledWith({ + mode: 'configured_provider_only', + }), + ) + + fireEvent.click(screen.getByRole('button', { name: 'Test Brave Search' })) + + await waitFor(() => + expect(webSearchAdapter.autonomousWebSearchCheckProvider).toHaveBeenCalledWith({ + providerId: 'brave-main', + }), + ) + }, 15000) + it('renders development controls without storage tabs or the storage inspector', async () => { render( { { timeout: 5000 }, ) const toolbarHeading = await screen.findByRole('heading', { name: 'Toolbar platform' }) + const errorLogHeading = await screen.findByRole('heading', { name: 'Tool-call failures' }) const harnessHeading = await screen.findByRole('heading', { name: 'Tool harness' }) expect(screen.getByRole('button', { name: 'Start onboarding' })).toBeEnabled() + expect(await screen.findByText('No tool-call failures logged.')).toBeVisible() expect(await screen.findByText(/Harness fixture: Tool harness fixture/)).toBeVisible() expect( @@ -806,13 +1010,26 @@ describe('SettingsDialog', () => { toolbarHeading.compareDocumentPosition(harnessHeading) & Node.DOCUMENT_POSITION_FOLLOWING, ).toBeTruthy() + expect( + toolbarHeading.compareDocumentPosition(errorLogHeading) & + Node.DOCUMENT_POSITION_FOLLOWING, + ).toBeTruthy() + expect( + errorLogHeading.compareDocumentPosition(harnessHeading) & + Node.DOCUMENT_POSITION_FOLLOWING, + ).toBeTruthy() expect(screen.queryByRole('tab', { name: 'Storage' })).not.toBeInTheDocument() expect(screen.queryByText('Storage inspector')).not.toBeInTheDocument() expect(screen.queryByText('Local storage')).not.toBeInTheDocument() expect(screen.queryByLabelText('Reveal sensitive storage values')).not.toBeInTheDocument() expect(invokeMock).not.toHaveBeenCalledWith('developer_storage_overview') expect(invokeMock).not.toHaveBeenCalledWith('developer_storage_read_table', expect.anything()) - }) + await waitFor(() => + expect(invokeMock).toHaveBeenCalledWith('developer_tool_error_log_list', { + request: { limit: 100 }, + }), + ) + }, 15000) it('renders doctor reports from the diagnostics section and runs extended checks explicitly', async () => { const report = makeDoctorReport() diff --git a/client/components/xero/settings-dialog.tsx b/client/components/xero/settings-dialog.tsx index ec8e8dac..174e5c4f 100644 --- a/client/components/xero/settings-dialog.tsx +++ b/client/components/xero/settings-dialog.tsx @@ -13,13 +13,14 @@ import type { SkillRegistryMutationStatus, } from "@/src/features/xero/use-xero-desktop-state" import type { AgentToolingSettingsAdapter } from "@/components/xero/settings-dialog/agent-tooling-section" +import type { WebSearchSettingsAdapter } from "@/components/xero/settings-dialog/web-search-section" import type { DangerSettingsAdapter, DangerZoneProject, } from "@/components/xero/settings-dialog/account-danger-zone" import type { DictationSettingsAdapter } from "@/components/xero/settings-dialog/dictation-section" import type { DesktopControlSettingsAdapter } from "@/components/xero/settings-dialog/desktop-control-section" -import type { MemoryReviewAdapter } from "@/components/xero/settings-dialog/memory-review-section" +import type { MemoryAdapter } from "@/components/xero/settings-dialog/memory-review-section" import type { PowerSettingsAdapter } from "@/components/xero/settings-dialog/power-section" import type { ProjectStateAdapter } from "@/components/xero/settings-dialog/project-state-section" import type { SoulSettingsAdapter } from "@/components/xero/settings-dialog/soul-section" @@ -52,7 +53,6 @@ import type { UpsertSkillLocalRootRequestDto, UpsertMcpServerRequestDto, UpsertProviderCredentialRequestDto, - XaiDeviceCodeLoginDto, } from "@/src/lib/xero-model" import type { StartTargetDto, StartTargetInputDto } from "@/src/lib/xero-desktop" import type { StartTargetsModelOption } from "@/components/xero/start-targets-editor" @@ -63,7 +63,7 @@ import type { GitHubAuthStatus, GitHubSessionView, } from "@/src/lib/github-auth" -import { Activity, ArrowLeft, Bot, Brain, Cloud, Code2, Database, Globe, HardDrive, Heart, Keyboard, KeyRound, Mic, Monitor, Palette, PlaySquare, Plug, PlugZap, Power, UserRound, WandSparkles, Wrench } from "lucide-react" +import { Activity, ArrowLeft, Bot, Brain, Cloud, Code2, Database, GitBranch, Globe, HardDrive, Heart, Keyboard, KeyRound, Mic, Monitor, Palette, PlaySquare, Plug, PlugZap, Power, RadioTower, Search, Terminal, UserRound, WandSparkles, Wrench } from "lucide-react" import { BaseDialog } from "@xero/ui/components/base-dialog" import { DialogDescription, @@ -75,6 +75,7 @@ export type SettingsSection = | "account" | "cloudAccount" | "providers" + | "solanaRpc" | "diagnostics" | "soul" | "dictation" @@ -82,6 +83,7 @@ export type SettingsSection = | "skills" | "agents" | "agentTooling" + | "webSearch" | "memory" | "plugins" | "browser" @@ -89,7 +91,9 @@ export type SettingsSection = | "power" | "workspaceIndex" | "projectState" + | "sourceControl" | "projectRunner" + | "terminal" | "themes" | "shortcuts" | "development" @@ -98,12 +102,14 @@ const SETTINGS_SECTIONS: SettingsSection[] = [ "account", "cloudAccount", "providers", + "solanaRpc", "diagnostics", "soul", "dictation", "mcp", "agents", "agentTooling", + "webSearch", "memory", "skills", "plugins", @@ -112,7 +118,9 @@ const SETTINGS_SECTIONS: SettingsSection[] = [ "power", "workspaceIndex", "projectState", + "sourceControl", "projectRunner", + "terminal", "themes", "shortcuts", "development", @@ -134,6 +142,10 @@ const loadAgentToolingSection = () => import("@/components/xero/settings-dialog/agent-tooling-section").then((module) => ({ default: module.AgentToolingSection, })) +const loadWebSearchSection = () => + import("@/components/xero/settings-dialog/web-search-section").then((module) => ({ + default: module.WebSearchSection, + })) const loadBrowserSection = () => import("@/components/xero/settings-dialog/browser-section").then((module) => ({ default: module.BrowserSection, @@ -162,14 +174,18 @@ const loadMcpSection = () => import("@/components/xero/settings-dialog/mcp-section").then((module) => ({ default: module.McpSection, })) -const loadMemoryReviewSection = () => +const loadMemorySection = () => import("@/components/xero/settings-dialog/memory-review-section").then((module) => ({ - default: module.MemoryReviewSection, + default: module.MemorySection, })) const loadProvidersSection = () => import("@/components/xero/settings-dialog/providers-section").then((module) => ({ default: module.ProvidersSection, })) +const loadSolanaRpcSection = () => + import("@/components/xero/settings-dialog/solana-rpc-section").then((module) => ({ + default: module.SolanaRpcSection, + })) const loadPluginsSection = () => import("@/components/xero/settings-dialog/plugins-section").then((module) => ({ default: module.PluginsSection, @@ -198,15 +214,24 @@ const loadProjectStateSection = () => import("@/components/xero/settings-dialog/project-state-section").then((module) => ({ default: module.ProjectStateSection, })) +const loadSourceControlSection = () => + import("@/components/xero/settings-dialog/source-control-section").then((module) => ({ + default: module.SourceControlSection, + })) const loadProjectRunnerSection = () => import("@/components/xero/settings-dialog/project-runner-section").then((module) => ({ default: module.ProjectRunnerSection, })) +const loadTerminalSection = () => + import("@/components/xero/settings-dialog/terminal-section").then((module) => ({ + default: module.TerminalSection, + })) const LazyAccountSection = lazy(loadAccountSection) const LazyCloudAccountSection = lazy(loadCloudAccountSection) const LazyAgentsSection = lazy(loadAgentsSection) const LazyAgentToolingSection = lazy(loadAgentToolingSection) +const LazyWebSearchSection = lazy(loadWebSearchSection) const LazyBrowserSection = lazy(loadBrowserSection) const LazyDesktopControlSection = lazy(loadDesktopControlSection) const LazyPowerSection = lazy(loadPowerSection) @@ -214,8 +239,9 @@ const LazyDevelopmentSection = lazy(loadDevelopmentSection) const LazyDictationSection = lazy(loadDictationSection) const LazyDiagnosticsSection = lazy(loadDiagnosticsSection) const LazyMcpSection = lazy(loadMcpSection) -const LazyMemoryReviewSection = lazy(loadMemoryReviewSection) +const LazyMemorySection = lazy(loadMemorySection) const LazyProvidersSection = lazy(loadProvidersSection) +const LazySolanaRpcSection = lazy(loadSolanaRpcSection) const LazyPluginsSection = lazy(loadPluginsSection) const LazyShortcutsSection = lazy(loadShortcutsSection) const LazySkillsSection = lazy(loadSkillsSection) @@ -223,19 +249,23 @@ const LazySoulSection = lazy(loadSoulSection) const LazyThemesSection = lazy(loadThemesSection) const LazyWorkspaceIndexSection = lazy(loadWorkspaceIndexSection) const LazyProjectStateSection = lazy(loadProjectStateSection) +const LazySourceControlSection = lazy(loadSourceControlSection) const LazyProjectRunnerSection = lazy(loadProjectRunnerSection) +const LazyTerminalSection = lazy(loadTerminalSection) const SETTINGS_SECTION_LOADERS: Record Promise> = { account: loadAccountSection, cloudAccount: loadCloudAccountSection, providers: loadProvidersSection, + solanaRpc: loadSolanaRpcSection, diagnostics: loadDiagnosticsSection, soul: loadSoulSection, dictation: loadDictationSection, mcp: loadMcpSection, agents: loadAgentsSection, agentTooling: loadAgentToolingSection, - memory: loadMemoryReviewSection, + webSearch: loadWebSearchSection, + memory: loadMemorySection, skills: loadSkillsSection, plugins: loadPluginsSection, browser: loadBrowserSection, @@ -243,7 +273,9 @@ const SETTINGS_SECTION_LOADERS: Record Promise> power: loadPowerSection, workspaceIndex: loadWorkspaceIndexSection, projectState: loadProjectStateSection, + sourceControl: loadSourceControlSection, projectRunner: loadProjectRunnerSection, + terminal: loadTerminalSection, themes: loadThemesSection, shortcuts: loadShortcutsSection, development: loadDevelopmentSection, @@ -293,12 +325,14 @@ const WORKSPACE_GROUP: NavGroup = { label: "Workspace", items: [ { id: "providers", label: "Providers", icon: KeyRound }, + { id: "solanaRpc", label: "Solana RPC", icon: RadioTower }, { id: "diagnostics", label: "Diagnostics", icon: Activity }, { id: "soul", label: "Soul", icon: Heart }, { id: "dictation", label: "Dictation", icon: Mic }, { id: "mcp", label: "MCP", icon: PlugZap }, { id: "agents", label: "Agents", icon: Bot }, { id: "agentTooling", label: "Agent Tooling", icon: Wrench }, + { id: "webSearch", label: "Web Search", icon: Search }, { id: "memory", label: "Memory", icon: Brain }, { id: "skills", label: "Skills", icon: WandSparkles }, { id: "plugins", label: "Plugins", icon: Plug }, @@ -307,7 +341,9 @@ const WORKSPACE_GROUP: NavGroup = { { id: "power", label: "Power", icon: Power }, { id: "workspaceIndex", label: "Workspace Index", icon: Database }, { id: "projectState", label: "Project State", icon: HardDrive }, + { id: "sourceControl", label: "Source Control", icon: GitBranch }, { id: "projectRunner", label: "Project Runner", icon: PlaySquare }, + { id: "terminal", label: "Terminal", icon: Terminal }, ], } @@ -353,11 +389,6 @@ export interface SettingsDialogProps { providerId: RuntimeProviderIdDto originator?: string | null }) => Promise - onStartXaiDeviceCodeLogin?: (request: { providerId: "xai" }) => Promise - onPollXaiDeviceCodeLogin?: (request: { - providerId: "xai" - flowId: string - }) => Promise doctorReport?: XeroDoctorReportDto | null doctorReportStatus?: DoctorReportRunStatus doctorReportError?: OperatorActionErrorView | null @@ -372,10 +403,13 @@ export interface SettingsDialogProps { desktopControlAdapter?: DesktopControlSettingsAdapter soulAdapter?: SoulSettingsAdapter agentToolingAdapter?: AgentToolingSettingsAdapter + webSearchAdapter?: WebSearchSettingsAdapter powerAdapter?: PowerSettingsAdapter toolCallGroupingPreference?: ToolCallGroupingPreference onToolCallGroupingPreferenceChange?: (preference: ToolCallGroupingPreference) => Promise | void - memoryReviewAdapter?: MemoryReviewAdapter | null + agentRoutingAutoSwitchEnabled?: boolean + onAgentRoutingAutoSwitchChange?: (enabled: boolean) => Promise | void + memoryAdapter?: MemoryAdapter | null projectStateAdapter?: ProjectStateAdapter | null dangerAdapter?: DangerSettingsAdapter | null projects?: DangerZoneProject[] @@ -475,7 +509,7 @@ export interface SettingsDialogProps { export function SettingsDialog({ open, onOpenChange, - initialSection = "providers", + initialSection = "account", agent, providerCredentials, providerCredentialsLoadStatus, @@ -486,8 +520,6 @@ export function SettingsDialog({ onUpsertProviderCredential, onDeleteProviderCredential, onStartOAuthLogin, - onStartXaiDeviceCodeLogin, - onPollXaiDeviceCodeLogin, doctorReport = null, doctorReportStatus = "idle", doctorReportError = null, @@ -502,10 +534,13 @@ export function SettingsDialog({ desktopControlAdapter, soulAdapter, agentToolingAdapter, + webSearchAdapter, powerAdapter, toolCallGroupingPreference, onToolCallGroupingPreferenceChange, - memoryReviewAdapter = null, + agentRoutingAutoSwitchEnabled, + onAgentRoutingAutoSwitchChange, + memoryAdapter = null, projectStateAdapter = null, dangerAdapter = null, projects = [], @@ -693,12 +728,14 @@ export function SettingsDialog({ onUpsertProviderCredential={onUpsertProviderCredential} onDeleteProviderCredential={onDeleteProviderCredential} onStartOAuthLogin={onStartOAuthLogin} - onStartXaiDeviceCodeLogin={onStartXaiDeviceCodeLogin} - onPollXaiDeviceCodeLogin={onPollXaiDeviceCodeLogin} /> ) } + if (renderedSection === "solanaRpc") { + return + } + if (renderedSection === "diagnostics") { return ( ) } + if (renderedSection === "webSearch") { + return + } + if (renderedSection === "memory") { const sessionId = agent?.project.selectedAgentSessionId return ( - 0 ? sessionId : null} - adapter={memoryReviewAdapter} + adapter={memoryAdapter} /> ) } @@ -853,6 +896,10 @@ export function SettingsDialog({ ) } + if (renderedSection === "sourceControl") { + return + } + if (renderedSection === "projectRunner") { return ( + } + if (renderedSection === "themes") { return } @@ -894,13 +945,13 @@ export function SettingsDialog({ onOpenChange={onOpenChange} variant="custom" title="Settings" - contentClassName="left-0 top-0 flex h-screen w-screen max-w-none translate-x-0 translate-y-0 flex-col gap-0 overflow-hidden rounded-none border-0 p-0 shadow-none sm:max-w-none" + contentClassName="left-0 top-0 flex h-screen w-screen max-w-none translate-x-0 translate-y-0 flex-col gap-0 overflow-hidden rounded-none border-0 p-0 shadow-none data-[state=closed]:opacity-100 sm:max-w-none" showCloseButton={false} header={ <> Settings - Configure providers, skills, agent tooling, and development options. + Configure account, providers, skills, agent tooling, and development options. } diff --git a/client/components/xero/settings-dialog/agent-tooling-section.test.tsx b/client/components/xero/settings-dialog/agent-tooling-section.test.tsx index 0e88987c..0de49db1 100644 --- a/client/components/xero/settings-dialog/agent-tooling-section.test.tsx +++ b/client/components/xero/settings-dialog/agent-tooling-section.test.tsx @@ -52,8 +52,10 @@ function makeModel(modelId: string, displayName?: string): ProviderModelDto { return { modelId, displayName: displayName ?? modelId, - thinking: { supported: false, effortOptions: [] }, - } as ProviderModelDto + inputModalities: [], + inputModalitiesSource: 'test_fixture_unreported', + thinking: { supported: false, effortOptions: [], defaultEffort: null }, + } } function makeCatalog( @@ -217,6 +219,31 @@ describe('AgentToolingSection', () => { expect(adapter.agentToolingUpdateSettings).not.toHaveBeenCalled() }) + it('saves the automatic agent routing preference without touching model tooling settings', async () => { + const adapter = makeAdapter() + const onAgentRoutingAutoSwitchChange = vi.fn(async () => undefined) + + render( + , + ) + + const autoSwitch = await screen.findByRole('switch', { + name: 'Auto-switch suggested agents', + }) + expect(autoSwitch).not.toBeChecked() + + fireEvent.click(autoSwitch) + + await waitFor(() => + expect(onAgentRoutingAutoSwitchChange).toHaveBeenCalledWith(true), + ) + expect(adapter.agentToolingUpdateSettings).not.toHaveBeenCalled() + }) + it('renders saved per-model overrides and updates an override style through the adapter', async () => { const adapter = makeAdapter({ settings: makeSettings({ diff --git a/client/components/xero/settings-dialog/agent-tooling-section.tsx b/client/components/xero/settings-dialog/agent-tooling-section.tsx index b68cd10a..366c4c5f 100644 --- a/client/components/xero/settings-dialog/agent-tooling-section.tsx +++ b/client/components/xero/settings-dialog/agent-tooling-section.tsx @@ -46,6 +46,8 @@ interface AgentToolingSectionProps { adapter?: AgentToolingSettingsAdapter toolCallGroupingPreference?: ToolCallGroupingPreference onToolCallGroupingPreferenceChange?: (preference: ToolCallGroupingPreference) => Promise | void + agentRoutingAutoSwitchEnabled?: boolean + onAgentRoutingAutoSwitchChange?: (enabled: boolean) => Promise | void } interface StyleOption { @@ -96,11 +98,15 @@ export function AgentToolingSection({ adapter, toolCallGroupingPreference = "grouped", onToolCallGroupingPreferenceChange, + agentRoutingAutoSwitchEnabled = false, + onAgentRoutingAutoSwitchChange, }: AgentToolingSectionProps) { const [settings, setSettings] = useState(FALLBACK_SETTINGS) const [loadState, setLoadState] = useState("idle") const [saveState, setSaveState] = useState("idle") const [groupingSaveState, setGroupingSaveState] = useState("idle") + const [routingAutoSwitchSaveState, setRoutingAutoSwitchSaveState] = + useState("idle") const [error, setError] = useState(null) const [pendingOverrideKey, setPendingOverrideKey] = useState(null) const [credentials, setCredentials] = useState(null) @@ -168,6 +174,7 @@ export function AgentToolingSection({ const isBusy = loadState === "loading" || saveState === "saving" const isGroupingSaving = groupingSaveState === "saving" + const isRoutingAutoSwitchSaving = routingAutoSwitchSaveState === "saving" const submit = useCallback( async ( @@ -253,6 +260,23 @@ export function AgentToolingSection({ [onToolCallGroupingPreferenceChange, toolCallGroupingPreference], ) + const updateAgentRoutingAutoSwitchPreference = useCallback( + async (checked: boolean) => { + if (!onAgentRoutingAutoSwitchChange || checked === agentRoutingAutoSwitchEnabled) return + + setRoutingAutoSwitchSaveState("saving") + setError(null) + try { + await onAgentRoutingAutoSwitchChange(checked) + } catch (saveError) { + setError(getErrorMessage(saveError, "Xero could not save agent routing settings.")) + } finally { + setRoutingAutoSwitchSaveState("idle") + } + }, + [agentRoutingAutoSwitchEnabled, onAgentRoutingAutoSwitchChange], + ) + const sortedOverrides = useMemo( () => [...settings.modelOverrides].sort((left, right) => { @@ -304,12 +328,20 @@ export function AgentToolingSection({ ) : null} - {onToolCallGroupingPreferenceChange ? ( - ) : null} @@ -335,51 +367,90 @@ export function AgentToolingSection({ ) } -function ToolCallGroupingPanel({ - value, - disabled, - saving, - onChange, +function ConversationDisplayPanel({ + toolCallGroupingValue, + toolCallGroupingDisabled, + toolCallGroupingSaving, + onToolCallGroupingChange, + agentRoutingAutoSwitchEnabled, + agentRoutingAutoSwitchDisabled, + agentRoutingAutoSwitchSaving, + onAgentRoutingAutoSwitchChange, }: { - value: ToolCallGroupingPreference - disabled: boolean - saving: boolean - onChange: (checked: boolean) => void + toolCallGroupingValue: ToolCallGroupingPreference + toolCallGroupingDisabled: boolean + toolCallGroupingSaving: boolean + onToolCallGroupingChange?: (checked: boolean) => void + agentRoutingAutoSwitchEnabled: boolean + agentRoutingAutoSwitchDisabled: boolean + agentRoutingAutoSwitchSaving: boolean + onAgentRoutingAutoSwitchChange?: (checked: boolean) => void }) { - const grouped = value === "grouped" + const grouped = toolCallGroupingValue === "grouped" return (

Conversation display

- Control how tool activity appears in agent conversations. + Control how tool and routing activity appears in agent conversations.

-
-
- -

- Adjacent completed tool calls collapse into one expandable row. -

-
-
- {saving ? ( - - ) : null} - -
+
+ {onToolCallGroupingChange ? ( +
+
+ +

+ Adjacent completed tool calls collapse into one expandable row. +

+
+
+ {toolCallGroupingSaving ? ( + + ) : null} + +
+
+ ) : null} + {onAgentRoutingAutoSwitchChange ? ( +
+
+ +

+ Switch and continue automatically when the agent recommends a better specialist. +

+
+
+ {agentRoutingAutoSwitchSaving ? ( + + ) : null} + +
+
+ ) : null}
) diff --git a/client/components/xero/settings-dialog/cloud-account-section.test.tsx b/client/components/xero/settings-dialog/cloud-account-section.test.tsx new file mode 100644 index 00000000..fad27bc2 --- /dev/null +++ b/client/components/xero/settings-dialog/cloud-account-section.test.tsx @@ -0,0 +1,94 @@ +import { fireEvent, render, screen, waitFor } from "@testing-library/react" +import { beforeEach, describe, expect, it, vi } from "vitest" + +const { invokeMock, isTauriMock } = vi.hoisted(() => ({ + invokeMock: vi.fn(), + isTauriMock: vi.fn(() => true), +})) + +vi.mock("@tauri-apps/api/core", () => ({ + invoke: invokeMock, + isTauri: isTauriMock, +})) + +import { CloudAccountSection } from "./cloud-account-section" + +const linkedDevice = { + id: "device-web", + kind: "web", + name: "Xero Web", + lastSeen: "2026-05-31T12:00:00Z", + revokedAt: null, + userAgent: null, +} + +const desktopDevice = { + id: "device-desktop", + kind: "desktop", + name: "Xero Desktop", + lastSeen: "2026-05-31T12:10:00Z", + revokedAt: null, + userAgent: null, +} + +describe("CloudAccountSection", () => { + beforeEach(() => { + isTauriMock.mockReset() + isTauriMock.mockReturnValue(true) + invokeMock.mockReset() + invokeMock.mockImplementation((command: string) => { + if (command === "bridge_status") { + return Promise.resolve({ + signedIn: true, + account: { githubLogin: "sn0w" }, + devices: [desktopDevice, linkedDevice], + devicesError: null, + }) + } + if (command === "bridge_revoke_device") { + return Promise.resolve(null) + } + return Promise.resolve(null) + }) + }) + + it("requires a second click before unlinking a linked device", async () => { + render() + + expect(await screen.findByText("Xero Web")).toBeVisible() + + fireEvent.click(screen.getByRole("button", { name: "Unlink Xero Web" })) + + expect(invokeMock).not.toHaveBeenCalledWith("bridge_revoke_device", expect.anything()) + expect(screen.getByRole("button", { name: "Confirm unlink Xero Web" })).toHaveTextContent("Unlink") + + fireEvent.click(screen.getByRole("button", { name: "Confirm unlink Xero Web" })) + + await waitFor(() => + expect(invokeMock).toHaveBeenCalledWith("bridge_revoke_device", { + request: { deviceId: "device-web" }, + }), + ) + }) + + it("hides the current desktop app and only shows cloud web connections", async () => { + render() + + expect(await screen.findByText("Xero Web")).toBeVisible() + expect(screen.queryByText("Xero Desktop")).not.toBeInTheDocument() + expect(screen.queryByText("Desktop")).not.toBeInTheDocument() + }) + + it("clears the unlink confirmation when the pointer leaves the button", async () => { + render() + + expect(await screen.findByText("Xero Web")).toBeVisible() + + fireEvent.click(screen.getByRole("button", { name: "Unlink Xero Web" })) + fireEvent.pointerLeave(screen.getByRole("button", { name: "Confirm unlink Xero Web" })) + + expect(screen.getByRole("button", { name: "Unlink Xero Web" })).toBeVisible() + expect(screen.queryByRole("button", { name: "Confirm unlink Xero Web" })).not.toBeInTheDocument() + expect(invokeMock).not.toHaveBeenCalledWith("bridge_revoke_device", expect.anything()) + }) +}) diff --git a/client/components/xero/settings-dialog/cloud-account-section.tsx b/client/components/xero/settings-dialog/cloud-account-section.tsx index 21616a98..092f232f 100644 --- a/client/components/xero/settings-dialog/cloud-account-section.tsx +++ b/client/components/xero/settings-dialog/cloud-account-section.tsx @@ -34,6 +34,7 @@ export function CloudAccountSection() { const [loading, setLoading] = useState(true) const [error, setError] = useState(null) const [revoking, setRevoking] = useState(null) + const [unlinkConfirmationDeviceId, setUnlinkConfirmationDeviceId] = useState(null) const refresh = useCallback(async () => { if (!isTauri()) { @@ -46,7 +47,11 @@ export function CloudAccountSection() { const response = await invoke("bridge_status") setSignedIn(Boolean(response.signedIn)) setAccount(response.account ?? null) - setDevices((response.devices ?? []).filter((device) => !device.revokedAt)) + setDevices( + (response.devices ?? []).filter( + (device) => device.kind === "web" && !device.revokedAt, + ), + ) setError(response.devicesError?.trim() || null) } catch (caught) { setError(caught instanceof Error ? caught.message : String(caught)) @@ -61,6 +66,7 @@ export function CloudAccountSection() { const handleRevoke = async (deviceId: string) => { setRevoking(deviceId) + setUnlinkConfirmationDeviceId(null) setError(null) try { await invoke("bridge_revoke_device", { request: { deviceId } }) @@ -72,11 +78,15 @@ export function CloudAccountSection() { } } + const clearUnlinkConfirmation = useCallback(() => { + setUnlinkConfirmationDeviceId(null) + }, []) + return (
@@ -91,51 +101,88 @@ export function CloudAccountSection() {

) : devices.length === 0 ? (

- No active devices. + No active cloud-app browser connections.

) : (
    - {devices.map((device) => ( -
  • -
    - {device.kind === "desktop" ? ( - - ) : ( - - )} -
    -

    - {device.name ?? (device.kind === "desktop" ? "Desktop" : "Browser")} -

    -

    - {device.kind === "desktop" ? "Desktop" : "Browser"} - {device.lastSeen ? ` · last seen ${formatRelative(device.lastSeen)}` : ""} -

    -
    -
    - -
  • - ))} +
    + {device.kind === "desktop" ? ( + + ) : ( + + )} +
    +

    + {deviceLabel} +

    +

    + {device.kind === "desktop" ? "Desktop" : "Browser"} + {device.lastSeen ? ` · last seen ${formatRelative(device.lastSeen)}` : ""} +

    +
    +
    + + + ) + })}
)} {account?.githubLogin ? ( diff --git a/client/components/xero/settings-dialog/development-section.tsx b/client/components/xero/settings-dialog/development-section.tsx index 5f7ea946..0d7c397f 100644 --- a/client/components/xero/settings-dialog/development-section.tsx +++ b/client/components/xero/settings-dialog/development-section.tsx @@ -11,6 +11,7 @@ import type { PlatformVariant } from "@/components/xero/shell" import { detectPlatform } from "@/components/xero/shell" import { Button } from "@/components/ui/button" import { cn } from "@/lib/utils" +import { ToolErrorLog } from "./development-section/tool-error-log" import { ToolHarness } from "./development-section/tool-harness" import { SectionHeader } from "./section-header" @@ -102,6 +103,8 @@ export function DevelopmentSection({
+ +
) diff --git a/client/components/xero/settings-dialog/development-section/tool-error-log.test.tsx b/client/components/xero/settings-dialog/development-section/tool-error-log.test.tsx new file mode 100644 index 00000000..6e5eed04 --- /dev/null +++ b/client/components/xero/settings-dialog/development-section/tool-error-log.test.tsx @@ -0,0 +1,226 @@ +import { fireEvent, render, screen, waitFor } from "@testing-library/react" +import { beforeEach, describe, expect, it, vi } from "vitest" + +import { ToolErrorLog } from "./tool-error-log" + +const adapterMock = vi.hoisted(() => ({ + isDesktopRuntime: vi.fn(() => true), + listProjects: vi.fn(), + developerToolErrorLogList: vi.fn(), + developerToolErrorLogClear: vi.fn(), +})) + +vi.mock("@/src/lib/xero-desktop", () => ({ + XeroDesktopAdapter: adapterMock, +})) + +const sampleEntry = { + id: "tool-error-1", + occurredAt: "2026-06-02T14:05:00Z", + source: "tool_registry_v2_dispatch", + projectId: "project-1", + agentSessionId: "session-1", + runId: "run-1", + turnIndex: 3, + toolCallId: "call-1", + toolName: "write", + inputSha256: "a".repeat(64), + inputJson: { path: "src/main.rs", api_key: "[REDACTED]" }, + inputRedacted: true, + errorCode: "agent_tool_write_failed", + errorClass: "retryable", + errorCategory: "retryable_provider_tool_failure", + errorMessage: "The write tool failed.", + modelMessage: "Retry with different input.", + retryable: true, + dispatchJson: { groupMode: "sequential_mutating", elapsedMs: 17 }, + contextJson: { providerId: "openai_codex", modelId: "gpt-5", launchMode: "local-source" }, + messagePreview: "The write tool failed.", +} + +function project(id: string, name: string) { + return { + id, + name, + description: "", + milestone: "", + projectOrigin: "brownfield", + totalPhases: 0, + completedPhases: 0, + activePhase: 0, + branch: null, + runtime: null, + startTargets: [], + } +} + +function response(entries = [sampleEntry], projectIds = ["project-1"]) { + return { + databasePath: "/tmp/xero/development/tool-call-errors.sqlite", + entries, + projectIds, + totalCount: entries.length, + limit: 100, + offset: 0, + } +} + +beforeEach(() => { + adapterMock.isDesktopRuntime.mockReset() + adapterMock.isDesktopRuntime.mockReturnValue(true) + adapterMock.listProjects.mockReset() + adapterMock.listProjects.mockResolvedValue({ + projects: [project("project-1", "Project One")], + }) + adapterMock.developerToolErrorLogList.mockReset() + adapterMock.developerToolErrorLogClear.mockReset() + adapterMock.developerToolErrorLogList.mockResolvedValue(response()) + adapterMock.developerToolErrorLogClear.mockResolvedValue({ + databasePath: "/tmp/xero/development/tool-call-errors.sqlite", + clearedCount: 1, + }) +}) + +describe("ToolErrorLog", () => { + it("renders the loading state before the first response resolves", async () => { + adapterMock.developerToolErrorLogList.mockImplementation( + () => new Promise(() => undefined), + ) + + render() + + expect(await screen.findByText("Loading tool-call failures...")).toBeVisible() + }) + + it("renders an empty state", async () => { + adapterMock.developerToolErrorLogList.mockResolvedValue(response([])) + + render() + + expect(await screen.findByText("No tool-call failures logged.")).toBeVisible() + expect(screen.getByText("0")).toBeVisible() + }) + + it("renders populated rows and the selected details panel", async () => { + render() + + expect(await screen.findByRole("button", { name: "Inspect write failure" })).toBeVisible() + expect(screen.getByLabelText("Fuzzy search")).toBeVisible() + expect(screen.getByRole("combobox", { name: "Project" })).toHaveTextContent("All projects") + expect(screen.getByText("agent_tool_write_failed")).toBeVisible() + expect(screen.getByText("Retryable")).toBeVisible() + expect(screen.getByText("Redacted input")).toBeVisible() + expect(screen.getByText(/tool-call-errors\.sqlite/)).toBeVisible() + expect(screen.getByText(/REDACTED/)).toBeVisible() + }) + + it("does not render manual actions or removed filter controls", async () => { + render() + await screen.findByRole("button", { name: "Inspect write failure" }) + + expect(screen.queryByRole("button", { name: "Refresh" })).not.toBeInTheDocument() + expect(screen.queryByRole("button", { name: "Clear" })).not.toBeInTheDocument() + expect(screen.queryByLabelText("Tool name")).not.toBeInTheDocument() + expect(screen.queryByLabelText("Error code")).not.toBeInTheDocument() + }) + + it("sends debounced fuzzy search requests through the typed adapter", async () => { + render() + await screen.findByRole("button", { name: "Inspect write failure" }) + + fireEvent.change(screen.getByLabelText("Fuzzy search"), { + target: { value: "denied" }, + }) + + await waitFor(() => + expect(adapterMock.developerToolErrorLogList).toHaveBeenLastCalledWith({ + limit: 100, + query: "denied", + }), + ) + }) + + it("sends project dropdown requests through the typed adapter", async () => { + adapterMock.listProjects.mockResolvedValue({ + projects: [ + project("project-1", "Project One"), + project("project-2", "Project Two"), + ], + }) + adapterMock.developerToolErrorLogList.mockResolvedValue(response([sampleEntry], [])) + + render() + await screen.findByRole("button", { name: "Inspect write failure" }) + + ensurePointerCaptureApi() + fireEvent.pointerDown(screen.getByRole("combobox", { name: "Project" }), { + button: 0, + pointerId: 1, + pointerType: "mouse", + }) + fireEvent.click(await screen.findByRole("option", { name: "Project Two" })) + + await waitFor(() => + expect(adapterMock.developerToolErrorLogList).toHaveBeenLastCalledWith({ + limit: 100, + projectId: "project-2", + }), + ) + }) + + it("lists projects in the dropdown even when no failures are logged", async () => { + adapterMock.listProjects.mockResolvedValue({ + projects: [ + project("project-1", "Project One"), + project("project-2", "Project Two"), + project("project-3", "Project Three"), + ], + }) + adapterMock.developerToolErrorLogList.mockResolvedValue(response([], [])) + + render() + await screen.findByText("No tool-call failures logged.") + + ensurePointerCaptureApi() + fireEvent.pointerDown(screen.getByRole("combobox", { name: "Project" }), { + button: 0, + pointerId: 1, + pointerType: "mouse", + }) + + expect(await screen.findByRole("option", { name: "Project One" })).toBeVisible() + expect(screen.getByRole("option", { name: "Project Two" })).toBeVisible() + expect(screen.getByRole("option", { name: "Project Three" })).toBeVisible() + }) + + it("renders command errors", async () => { + adapterMock.developerToolErrorLogList.mockRejectedValue(new Error("dev log disabled")) + + render() + + expect(await screen.findByText("Tool-call failures unavailable")).toBeVisible() + expect(screen.getByText("dev log disabled")).toBeVisible() + }) + +}) + +function ensurePointerCaptureApi() { + if (!HTMLElement.prototype.hasPointerCapture) { + Object.defineProperty(HTMLElement.prototype, "hasPointerCapture", { + configurable: true, + value: () => false, + }) + } + if (!HTMLElement.prototype.setPointerCapture) { + Object.defineProperty(HTMLElement.prototype, "setPointerCapture", { + configurable: true, + value: () => undefined, + }) + } + if (!HTMLElement.prototype.releasePointerCapture) { + Object.defineProperty(HTMLElement.prototype, "releasePointerCapture", { + configurable: true, + value: () => undefined, + }) + } +} diff --git a/client/components/xero/settings-dialog/development-section/tool-error-log.tsx b/client/components/xero/settings-dialog/development-section/tool-error-log.tsx new file mode 100644 index 00000000..ede435fe --- /dev/null +++ b/client/components/xero/settings-dialog/development-section/tool-error-log.tsx @@ -0,0 +1,485 @@ +import { AlertTriangle, ChevronRight, Loader2, Search } from "lucide-react" +import { useCallback, useEffect, useMemo, useState, type ElementType } from "react" + +import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert" +import { Badge } from "@/components/ui/badge" +import { Input } from "@/components/ui/input" +import { ScrollArea } from "@/components/ui/scroll-area" +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select" +import { Separator } from "@/components/ui/separator" +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table" +import { cn } from "@/lib/utils" +import { XeroDesktopAdapter } from "@/src/lib/xero-desktop" +import type { ProjectSummaryDto } from "@/src/lib/xero-model" +import type { + DeveloperToolErrorLogEntryDto, + DeveloperToolErrorLogListRequestDto, + DeveloperToolErrorLogListResponseDto, +} from "@/src/lib/xero-model/developer-tool-error-log" + +type LoadState = "idle" | "loading" | "ready" | "error" + +interface Filters { + query: string + projectId: string +} + +interface ProjectOption { + id: string + label: string +} + +const EMPTY_FILTERS: Filters = { + query: "", + projectId: "", +} + +const ALL_PROJECTS_VALUE = "__all_projects__" + +export function ToolErrorLog() { + const [filters, setFilters] = useState(EMPTY_FILTERS) + const [response, setResponse] = useState(null) + const [state, setState] = useState("idle") + const [error, setError] = useState(null) + const [selectedId, setSelectedId] = useState(null) + const [projects, setProjects] = useState([]) + + const load = useCallback(async () => { + if (!XeroDesktopAdapter.isDesktopRuntime()) { + setResponse(null) + setState("error") + setError("Developer tool-call error logging requires the Tauri desktop runtime.") + return + } + + const list = XeroDesktopAdapter.developerToolErrorLogList + if (!list) { + setResponse(null) + setState("error") + setError("Developer tool-call error logging is unavailable in this desktop build.") + return + } + + setState("loading") + setError(null) + try { + const next = await list(requestFromFilters(filters)) + setResponse(next) + setState("ready") + setSelectedId((current) => { + if (next.entries.some((entry) => entry.id === current)) { + return current + } + return next.entries[0]?.id ?? null + }) + } catch (err) { + setResponse(null) + setState("error") + setError(errorMessage(err, "Xero could not load developer tool-call failures.")) + setSelectedId(null) + } + }, [filters]) + + useEffect(() => { + const timeout = window.setTimeout(() => { + void load() + }, 180) + return () => window.clearTimeout(timeout) + }, [load]) + + useEffect(() => { + if (!XeroDesktopAdapter.isDesktopRuntime()) { + setProjects([]) + return + } + + let cancelled = false + XeroDesktopAdapter.listProjects() + .then((next) => { + if (!cancelled) { + setProjects(next.projects) + } + }) + .catch(() => { + if (!cancelled) { + setProjects([]) + } + }) + + return () => { + cancelled = true + } + }, []) + + const entries = response?.entries ?? [] + const selected = useMemo( + () => entries.find((entry) => entry.id === selectedId) ?? entries[0] ?? null, + [entries, selectedId], + ) + const projectOptions = useMemo( + () => mergeProjectOptions(projects, response?.projectIds ?? []), + [projects, response?.projectIds], + ) + + const updateFilter = useCallback((key: keyof Filters, value: string) => { + setFilters((current) => ({ ...current, [key]: value })) + }, []) + + const isLoading = state === "loading" + + return ( +
+
+ +

+ Tool-call failures +

+ + {response ? response.totalCount : 0} + +
+ +
+ updateFilter("query", value)} + /> + updateFilter("projectId", value)} + /> +
+ + {state === "error" ? ( + + + Tool-call failures unavailable + {error} + + ) : null} + +
+ {isLoading && !response ? ( +
+ + Loading tool-call failures... +
+ ) : entries.length === 0 ? ( +
+ No tool-call failures logged. +
+ ) : ( + + + + Time + Tool + Error + Project / run + Retry + Message + + + + {entries.map((entry) => ( + setSelectedId(entry.id)} + /> + ))} + +
+ )} +
+ + {response ? ( +
+ Database + + {response.databasePath} + +
+ ) : null} + + {selected ? : null} +
+ ) +} + +function FilterInput({ + icon: Icon, + label, + value, + onChange, +}: { + icon?: ElementType + label: string + value: string + onChange: (value: string) => void +}) { + return ( +
+ {Icon ? ( + + ) : null} + onChange(event.target.value)} + /> +
+ ) +} + +function ProjectSelect({ + projects, + value, + onChange, +}: { + projects: ProjectOption[] + value: string + onChange: (value: string) => void +}) { + return ( + + ) +} + +function FailureRow({ + entry, + selected, + onSelect, +}: { + entry: DeveloperToolErrorLogEntryDto + selected: boolean + onSelect: () => void +}) { + return ( + + + {formatTimestamp(entry.occurredAt)} + + + + + +
+ + {entry.errorCode} + + {entry.errorCategory ? ( + + {entry.errorCategory} + + ) : null} +
+
+ +
+ + {entry.projectId ?? "unknown project"} + + + {entry.runId ?? "unknown run"} + +
+
+ + + {entry.retryable ? "Retryable" : "No"} + + + + {entry.messagePreview} + +
+ ) +} + +function FailureDetails({ entry }: { entry: DeveloperToolErrorLogEntryDto }) { + return ( +
+
+
+
+ + {entry.toolName} + + + {entry.errorClass} + + {entry.inputRedacted ? ( + + Redacted input + + ) : null} +
+

+ {entry.errorMessage} +

+ {entry.modelMessage ? ( +

+ {entry.modelMessage} +

+ ) : null} +
+ + {entry.toolCallId} + +
+ +
+ + + +
+
+ ) +} + +function JsonPanel({ title, value }: { title: string; value: unknown }) { + return ( +
+
{title}
+ +
+          {formatJson(value)}
+        
+
+
+ ) +} + +function requestFromFilters(filters: Filters): DeveloperToolErrorLogListRequestDto { + const request: DeveloperToolErrorLogListRequestDto = { limit: 100 } + const query = optionalText(filters.query) + const projectId = optionalText(filters.projectId) + + if (query) request.query = query + if (projectId) request.projectId = projectId + + return request +} + +function optionalText(value: string): string | undefined { + const trimmed = value.trim() + return trimmed.length > 0 ? trimmed : undefined +} + +function mergeProjectOptions( + projects: ProjectSummaryDto[], + loggedProjectIds: string[], +): ProjectOption[] { + const seen = new Set() + const options: ProjectOption[] = [] + + for (const project of projects) { + const id = project.id.trim() + if (!id || seen.has(id)) { + continue + } + + seen.add(id) + options.push({ + id, + label: project.name.trim() || id, + }) + } + + for (const projectId of loggedProjectIds) { + const id = projectId.trim() + if (!id || seen.has(id)) { + continue + } + + seen.add(id) + options.push({ id, label: id }) + } + + return options +} + +function formatTimestamp(value: string): string { + const date = new Date(value) + if (Number.isNaN(date.getTime())) return value + return date.toLocaleString(undefined, { + month: "short", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + }) +} + +function formatJson(value: unknown): string { + try { + return JSON.stringify(value, null, 2) + } catch { + return String(value) + } +} + +function errorMessage(error: unknown, fallback: string): string { + if (error instanceof Error && error.message.trim()) return error.message + if ( + error && + typeof error === "object" && + "message" in error && + typeof (error as { message?: unknown }).message === "string" + ) { + return (error as { message: string }).message + } + return fallback +} diff --git a/client/components/xero/settings-dialog/memory-review-section.test.tsx b/client/components/xero/settings-dialog/memory-review-section.test.tsx index b31f85af..b5aa26c2 100644 --- a/client/components/xero/settings-dialog/memory-review-section.test.tsx +++ b/client/components/xero/settings-dialog/memory-review-section.test.tsx @@ -2,13 +2,13 @@ import { fireEvent, render, screen, waitFor, within } from '@testing-library/rea import { describe, expect, it, vi } from 'vitest' import { - MemoryReviewSection, - type MemoryReviewAdapter, + MemorySection, + type MemoryAdapter, } from '@/components/xero/settings-dialog/memory-review-section' import type { - AgentMemoryReviewQueueItemDto, + AgentMemoryItemDto, CorrectSessionMemoryResponseDto, - GetSessionMemoryReviewQueueResponseDto, + GetSessionMemoryItemsResponseDto, SessionMemoryRecordDto, } from '@/src/lib/xero-model/session-context' @@ -17,14 +17,13 @@ const SESSION_ID = 'session-7' const CREATED_AT = '2026-05-09T18:00:00Z' function makeItem( - overrides: Partial & Pick, -): AgentMemoryReviewQueueItemDto { + overrides: Partial & Pick, +): AgentMemoryItemDto { return { memoryId: overrides.memoryId, scope: overrides.scope ?? 'session', kind: overrides.kind ?? 'project_fact', - reviewState: overrides.reviewState ?? 'candidate', - enabled: overrides.enabled ?? false, + enabled: overrides.enabled ?? true, confidence: overrides.confidence ?? 72, textPreview: overrides.textPreview ?? @@ -35,6 +34,19 @@ function makeItem( sourceItemIds: ['message-3'], diagnostic: null, }, + reinforcement: overrides.reinforcement ?? { + count: 1, + lastReinforcedAt: CREATED_AT, + sources: [ + { + observedAt: CREATED_AT, + sourceRunId: 'run-12', + sourceItemIds: ['message-3'], + }, + ], + latestSourceRunId: 'run-12', + latestSourceItemIds: ['message-3'], + }, freshness: overrides.freshness ?? { state: 'current', checkedAt: CREATED_AT, @@ -45,8 +57,8 @@ function makeItem( factKey: null, }, retrieval: overrides.retrieval ?? { - eligible: false, - reason: 'pending_or_rejected_review', + eligible: true, + reason: 'retrievable', }, redaction: overrides.redaction ?? { textPreviewRedacted: false, @@ -54,9 +66,8 @@ function makeItem( rawTextHidden: true, }, availableActions: overrides.availableActions ?? { - canApprove: true, - canReject: true, - canDisable: true, + canEnable: overrides.enabled === false, + canDisable: overrides.enabled !== false, canDelete: true, canEditByCorrection: true, }, @@ -68,20 +79,16 @@ function makeItem( const PAGE_SIZE = 10 function makeQueueResponse( - allItems: AgentMemoryReviewQueueItemDto[], + allItems: AgentMemoryItemDto[], options: { offset?: number; limit?: number } = {}, -): GetSessionMemoryReviewQueueResponseDto { +): GetSessionMemoryItemsResponseDto { const offset = options.offset ?? 0 const limit = options.limit ?? PAGE_SIZE const items = allItems.slice(offset, offset + limit) const counts = { - candidate: allItems.filter((item) => item.reviewState === 'candidate').length, - approved: allItems.filter((item) => item.reviewState === 'approved').length, - rejected: allItems.filter((item) => item.reviewState === 'rejected').length, + enabled: allItems.filter((item) => item.enabled).length, disabled: allItems.filter((item) => !item.enabled).length, - retrievableApproved: allItems.filter( - (item) => item.reviewState === 'approved' && item.retrieval.eligible, - ).length, + retrievable: allItems.filter((item) => item.enabled && item.retrieval.eligible).length, } const nextOffset = offset + items.length const hasMore = nextOffset < allItems.length @@ -95,8 +102,7 @@ function makeQueueResponse( counts, items, actions: { - approve: 'Approve memory', - reject: 'Reject memory', + enable: 'Enable memory', disable: 'Disable memory', delete: 'Delete memory', edit: 'Create a corrected memory', @@ -107,17 +113,17 @@ function makeQueueResponse( } } -function makeAdapter(initial: GetSessionMemoryReviewQueueResponseDto): { - adapter: MemoryReviewAdapter +function makeAdapter(initial: GetSessionMemoryItemsResponseDto): { + adapter: MemoryAdapter getQueue: ReturnType updateMemory: ReturnType correctMemory: ReturnType deleteMemory: ReturnType } { - const getQueue = vi.fn().mockResolvedValue(initial) - const updateMemory = vi.fn() - const correctMemory = vi.fn() - const deleteMemory = vi.fn().mockResolvedValue(undefined) + const getQueue = vi.fn().mockResolvedValue(initial) + const updateMemory = vi.fn() + const correctMemory = vi.fn() + const deleteMemory = vi.fn().mockResolvedValue(undefined) return { adapter: { getQueue, updateMemory, correctMemory, deleteMemory }, getQueue, @@ -134,7 +140,6 @@ function dummyMemoryRecord(memoryId: string): SessionMemoryRecordDto { agentSessionId: SESSION_ID, scope: 'session', kind: 'fact', - reviewState: 'approved', enabled: true, text: '', textHash: 'sha256:abc', @@ -147,25 +152,28 @@ function dummyMemoryRecord(memoryId: string): SessionMemoryRecordDto { } as unknown as SessionMemoryRecordDto } -describe('MemoryReviewSection', () => { +describe('MemorySection', () => { it('shows the project-bound empty state when no project is selected', () => { - render() + render() expect(screen.getByText('Select a project')).toBeInTheDocument() }) it('renders queue counts and items returned by the adapter', async () => { - const candidate = makeItem({ memoryId: 'mem-1' }) - const approved = makeItem({ + const disabled = makeItem({ + memoryId: 'mem-1', + enabled: false, + retrieval: { eligible: false, reason: 'disabled' }, + }) + const enabled = makeItem({ memoryId: 'mem-2', - reviewState: 'approved', enabled: true, retrieval: { eligible: true, reason: 'retrievable' }, }) - const queue = makeQueueResponse([candidate, approved]) + const queue = makeQueueResponse([disabled, enabled]) const { adapter, getQueue } = makeAdapter(queue) render( - { expect(await screen.findAllByTestId('memory-review-item')).toHaveLength(2) const counts = screen.getByTestId('memory-review-counts') - expect(within(counts).getByLabelText('Candidates: 1')).toBeVisible() - expect(within(counts).getByLabelText('Approved: 1')).toBeVisible() + expect(within(counts).getByLabelText('Enabled: 1')).toBeVisible() expect(within(counts).getByLabelText('Retrievable: 1')).toBeVisible() + expect(within(counts).getByLabelText('Disabled: 1')).toBeVisible() }) it('keeps memory details collapsed until the card is opened', async () => { const item = makeItem({ memoryId: 'mem-collapsed' }) const { adapter } = makeAdapter(makeQueueResponse([item])) - render() + render() const row = await screen.findByTestId('memory-review-item') expect(within(row).queryByTestId('memory-full-preview')).not.toBeInTheDocument() @@ -214,7 +222,7 @@ describe('MemoryReviewSection', () => { const { adapter, getQueue } = makeAdapter(firstPage) getQueue.mockResolvedValueOnce(firstPage).mockResolvedValueOnce(secondPage) - render() + render() expect(await screen.findAllByTestId('memory-review-item')).toHaveLength(PAGE_SIZE) @@ -234,15 +242,16 @@ describe('MemoryReviewSection', () => { expect(screen.getAllByText('Page 2 of 2')[0]).toBeVisible() }) - it('hides the preview and disables Approve for redacted (secret-shaped) memory', async () => { + it('hides the preview and disables Enable for redacted (secret-shaped) memory', async () => { const redacted = makeItem({ memoryId: 'mem-secret', + enabled: false, + retrieval: { eligible: false, reason: 'disabled' }, textPreview: null, redaction: { textPreviewRedacted: true, factKeyRedacted: true, rawTextHidden: true }, availableActions: { - canApprove: false, - canReject: true, - canDisable: true, + canEnable: false, + canDisable: false, canDelete: true, canEditByCorrection: true, }, @@ -251,39 +260,42 @@ describe('MemoryReviewSection', () => { const { adapter } = makeAdapter(queue) render( - , + , ) const item = await screen.findByTestId('memory-review-item') expect(within(item).queryByTestId('memory-preview')).toBeNull() expect(within(item).getByTestId('memory-redacted-notice')).toBeInTheDocument() expect(within(item).getByTestId('redaction-badge')).toBeInTheDocument() - expect(within(item).getByRole('button', { name: 'Approve memory' })).toBeDisabled() + expect(within(item).getByRole('button', { name: 'Enable memory' })).toBeDisabled() fireEvent.pointerDown(within(item).getByRole('button', { name: 'Memory actions' }), { button: 0 }) expect(await screen.findByRole('menuitem', { name: 'Edit memory' })).toBeEnabled() }) - it('approves a candidate and refetches the queue', async () => { - const candidate = makeItem({ memoryId: 'mem-1' }) - const before = makeQueueResponse([candidate]) + it('enables a disabled memory and refetches the queue', async () => { + const disabled = makeItem({ + memoryId: 'mem-1', + enabled: false, + retrieval: { eligible: false, reason: 'disabled' }, + }) + const before = makeQueueResponse([disabled]) const after = makeQueueResponse([ - { ...candidate, reviewState: 'approved', enabled: true }, + { ...disabled, enabled: true, retrieval: { eligible: true, reason: 'retrievable' } }, ]) const { adapter, getQueue, updateMemory } = makeAdapter(before) getQueue.mockResolvedValueOnce(before).mockResolvedValueOnce(after) updateMemory.mockResolvedValue(dummyMemoryRecord('mem-1')) - render() + render() const item = await screen.findByTestId('memory-review-item') - fireEvent.click(within(item).getByRole('button', { name: 'Approve memory' })) + fireEvent.click(within(item).getByRole('button', { name: 'Enable memory' })) await waitFor(() => { expect(updateMemory).toHaveBeenCalledWith({ projectId: PROJECT_ID, memoryId: 'mem-1', - reviewState: 'approved', enabled: true, }) }) @@ -292,22 +304,21 @@ describe('MemoryReviewSection', () => { }) }) - it('rejects a candidate', async () => { - const candidate = makeItem({ memoryId: 'mem-r' }) - const { adapter, updateMemory } = makeAdapter(makeQueueResponse([candidate])) - updateMemory.mockResolvedValue(dummyMemoryRecord('mem-r')) + it('disables an enabled memory', async () => { + const item = makeItem({ memoryId: 'mem-disable' }) + const { adapter, updateMemory } = makeAdapter(makeQueueResponse([item])) + updateMemory.mockResolvedValue(dummyMemoryRecord('mem-disable')) - render() + render() - const item = await screen.findByTestId('memory-review-item') - fireEvent.pointerDown(within(item).getByRole('button', { name: 'Memory actions' }), { button: 0 }) - fireEvent.click(await screen.findByRole('menuitem', { name: 'Reject memory' })) + const row = await screen.findByTestId('memory-review-item') + fireEvent.click(within(row).getByRole('button', { name: 'Disable memory' })) await waitFor(() => { expect(updateMemory).toHaveBeenCalledWith({ projectId: PROJECT_ID, - memoryId: 'mem-r', - reviewState: 'rejected', + memoryId: 'mem-disable', + enabled: false, }) }) }) @@ -316,7 +327,7 @@ describe('MemoryReviewSection', () => { const item = makeItem({ memoryId: 'mem-del' }) const { adapter, deleteMemory } = makeAdapter(makeQueueResponse([item])) - render() + render() const row = await screen.findByTestId('memory-review-item') fireEvent.pointerDown(within(row).getByRole('button', { name: 'Memory actions' }), { button: 0 }) @@ -342,7 +353,7 @@ describe('MemoryReviewSection', () => { } as unknown as CorrectSessionMemoryResponseDto correctMemory.mockResolvedValue(correctionResponse) - render() + render() const row = await screen.findByTestId('memory-review-item') fireEvent.pointerDown(within(row).getByRole('button', { name: 'Memory actions' }), { button: 0 }) @@ -366,10 +377,10 @@ describe('MemoryReviewSection', () => { const { adapter, updateMemory } = makeAdapter(makeQueueResponse([item])) updateMemory.mockRejectedValue(new Error('Network down')) - render() + render() const row = await screen.findByTestId('memory-review-item') - fireEvent.click(within(row).getByRole('button', { name: 'Approve memory' })) + fireEvent.click(within(row).getByRole('button', { name: 'Disable memory' })) expect(await screen.findByRole('alert')).toHaveTextContent('Network down') // queue items are still rendered diff --git a/client/components/xero/settings-dialog/memory-review-section.tsx b/client/components/xero/settings-dialog/memory-review-section.tsx index 55798cb2..90c7f4d5 100644 --- a/client/components/xero/settings-dialog/memory-review-section.tsx +++ b/client/components/xero/settings-dialog/memory-review-section.tsx @@ -8,6 +8,7 @@ import { Loader2, MoreHorizontal, Pencil, + Power, PowerOff, RefreshCw, ShieldAlert, @@ -35,12 +36,12 @@ import { import { Textarea } from "@/components/ui/textarea" import { cn } from "@/lib/utils" import type { - AgentMemoryReviewQueueItemDto, + AgentMemoryItemDto, CorrectSessionMemoryRequestDto, CorrectSessionMemoryResponseDto, DeleteSessionMemoryRequestDto, - GetSessionMemoryReviewQueueRequestDto, - GetSessionMemoryReviewQueueResponseDto, + GetSessionMemoryItemsRequestDto, + GetSessionMemoryItemsResponseDto, SessionMemoryRecordDto, UpdateSessionMemoryRequestDto, } from "@/src/lib/xero-model/session-context" @@ -48,29 +49,29 @@ import type { import { SectionHeader } from "./section-header" import { EmptyPanel, ErrorBanner, Pill, SubHeading, type Tone } from "./_shared" -type MemoryReviewItem = AgentMemoryReviewQueueItemDto +type MemoryItem = AgentMemoryItemDto -export interface MemoryReviewAdapter { - getQueue: (request: GetSessionMemoryReviewQueueRequestDto) => Promise +export interface MemoryAdapter { + getQueue: (request: GetSessionMemoryItemsRequestDto) => Promise updateMemory: (request: UpdateSessionMemoryRequestDto) => Promise correctMemory: (request: CorrectSessionMemoryRequestDto) => Promise deleteMemory: (request: DeleteSessionMemoryRequestDto) => Promise } -interface MemoryReviewSectionProps { +interface MemorySectionProps { projectId: string | null projectLabel: string | null agentSessionId?: string | null - adapter?: MemoryReviewAdapter | null + adapter?: MemoryAdapter | null } type LoadStatus = "idle" | "loading" | "ready" | "error" -type ActionKind = "approve" | "reject" | "disable" | "delete" | "edit" +type ActionKind = "enable" | "disable" | "delete" | "edit" interface QueueState { status: LoadStatus errorMessage: string | null - response: GetSessionMemoryReviewQueueResponseDto | null + response: GetSessionMemoryItemsResponseDto | null } const INITIAL_QUEUE_STATE: QueueState = { @@ -79,13 +80,7 @@ const INITIAL_QUEUE_STATE: QueueState = { response: null, } -const MEMORY_REVIEW_PAGE_SIZE = 10 - -const REVIEW_STATE_TONE: Record = { - candidate: "info", - approved: "good", - rejected: "bad", -} +const MEMORY_PAGE_SIZE = 10 const METRIC_TONE: Record = { good: "text-success", @@ -103,17 +98,17 @@ const METRIC_DOT: Record = { neutral: "bg-muted-foreground/50", } -export function MemoryReviewSection({ +export function MemorySection({ projectId, projectLabel, agentSessionId, adapter, -}: MemoryReviewSectionProps) { +}: MemorySectionProps) { const [queueState, setQueueState] = useState(INITIAL_QUEUE_STATE) const [pageOffset, setPageOffset] = useState(0) const [expandedIds, setExpandedIds] = useState>(() => new Set()) const [pendingAction, setPendingAction] = useState<{ memoryId: string; kind: ActionKind } | null>(null) - const [editing, setEditing] = useState(null) + const [editing, setEditing] = useState(null) const [editText, setEditText] = useState("") const [editError, setEditError] = useState(null) @@ -126,7 +121,7 @@ export function MemoryReviewSection({ projectId, agentSessionId: agentSessionId ?? null, offset: requestedOffset, - limit: MEMORY_REVIEW_PAGE_SIZE, + limit: MEMORY_PAGE_SIZE, }) setQueueState({ status: "ready", errorMessage: null, response }) if (response.offset !== requestedOffset) { @@ -136,7 +131,7 @@ export function MemoryReviewSection({ setQueueState((current) => ({ ...current, status: "error", - errorMessage: errorMessage(caught, "Xero could not load the memory review queue."), + errorMessage: errorMessage(caught, "Xero could not load memory."), })) } }, @@ -158,12 +153,12 @@ export function MemoryReviewSection({ const responseOffset = queueState.response?.offset ?? pageOffset const pageStart = totalItems === 0 ? 0 : responseOffset + 1 const pageEnd = totalItems === 0 ? 0 : responseOffset + items.length - const pageCount = Math.max(1, Math.ceil(totalItems / MEMORY_REVIEW_PAGE_SIZE)) - const pageNumber = Math.floor(responseOffset / MEMORY_REVIEW_PAGE_SIZE) + 1 + const pageCount = Math.max(1, Math.ceil(totalItems / MEMORY_PAGE_SIZE)) + const pageNumber = Math.floor(responseOffset / MEMORY_PAGE_SIZE) + 1 const pageSummary = totalItems === 0 ? "0" : `${pageStart}-${pageEnd} of ${totalItems}` const runUpdate = useCallback( - async (item: MemoryReviewItem, kind: ActionKind, payload: Omit) => { + async (item: MemoryItem, kind: ActionKind, payload: Omit) => { if (!projectId || !adapter) return setPendingAction({ memoryId: item.memoryId, kind }) try { @@ -185,29 +180,24 @@ export function MemoryReviewSection({ [adapter, loadQueue, pageOffset, projectId], ) - const handleApprove = useCallback( - (item: MemoryReviewItem) => runUpdate(item, "approve", { reviewState: "approved", enabled: true }), - [runUpdate], - ) - - const handleReject = useCallback( - (item: MemoryReviewItem) => runUpdate(item, "reject", { reviewState: "rejected" }), + const handleEnable = useCallback( + (item: MemoryItem) => runUpdate(item, "enable", { enabled: true }), [runUpdate], ) const handleDisable = useCallback( - (item: MemoryReviewItem) => runUpdate(item, "disable", { enabled: false }), + (item: MemoryItem) => runUpdate(item, "disable", { enabled: false }), [runUpdate], ) const handleDelete = useCallback( - async (item: MemoryReviewItem) => { + async (item: MemoryItem) => { if (!projectId || !adapter) return setPendingAction({ memoryId: item.memoryId, kind: "delete" }) try { await adapter.deleteMemory({ projectId, memoryId: item.memoryId }) const nextOffset = - items.length === 1 && pageOffset > 0 ? Math.max(0, pageOffset - MEMORY_REVIEW_PAGE_SIZE) : pageOffset + items.length === 1 && pageOffset > 0 ? Math.max(0, pageOffset - MEMORY_PAGE_SIZE) : pageOffset if (nextOffset !== pageOffset) { setPageOffset(nextOffset) } else { @@ -225,7 +215,7 @@ export function MemoryReviewSection({ [adapter, items.length, loadQueue, pageOffset, projectId], ) - const openEditor = useCallback((item: MemoryReviewItem) => { + const openEditor = useCallback((item: MemoryItem) => { setEditing(item) setEditText(item.textPreview ?? "") setEditError(null) @@ -261,11 +251,9 @@ export function MemoryReviewSection({ }, [adapter, closeEditor, editText, editing, loadQueue, pageOffset, projectId]) const handleAction = useCallback( - (item: MemoryReviewItem, kind: ActionKind) => { - if (kind === "approve") { - void handleApprove(item) - } else if (kind === "reject") { - void handleReject(item) + (item: MemoryItem, kind: ActionKind) => { + if (kind === "enable") { + void handleEnable(item) } else if (kind === "disable") { void handleDisable(item) } else if (kind === "delete") { @@ -274,7 +262,7 @@ export function MemoryReviewSection({ openEditor(item) } }, - [handleApprove, handleDelete, handleDisable, handleReject, openEditor], + [handleDelete, handleDisable, handleEnable, openEditor], ) const handleExpandedChange = useCallback((memoryId: string, open: boolean) => { @@ -303,7 +291,7 @@ export function MemoryReviewSection({ className="h-8 gap-1.5 text-[12.5px]" onClick={() => void loadQueue(pageOffset)} disabled={isBusy || !projectId || !adapter} - aria-label="Refresh memory review queue" + aria-label="Refresh memory" > {isBusy ? ( @@ -318,8 +306,8 @@ export function MemoryReviewSection({ return (
} @@ -334,16 +322,16 @@ export function MemoryReviewSection({ return (
} title="Memory review unavailable" - body="The desktop adapter did not provide memory review commands. Restart Xero or upgrade to enable this surface." + body="The desktop adapter did not provide memory commands. Restart Xero or upgrade to enable this surface." />
) @@ -352,11 +340,11 @@ export function MemoryReviewSection({ return (
@@ -368,7 +356,7 @@ export function MemoryReviewSection({ {queueState.status === "loading" && items.length === 0 ? (
@@ -380,13 +368,13 @@ export function MemoryReviewSection({ {queueState.status === "ready" && items.length === 0 ? ( } - title="No memory to review" - body="Memory candidates appear here when agent sessions complete, pause, fail, or hand off." + title="No memory yet" + body="Automated memories appear here when agent sessions complete, pause, fail, or hand off." /> ) : null} {items.length > 0 ? ( -
+
Memory @@ -395,7 +383,7 @@ export function MemoryReviewSection({
{items.map((item) => ( - ))}
- @@ -420,7 +408,7 @@ export function MemoryReviewSection({ onOpenChange={(open) => (open ? null : closeEditor())} variant="form" title="Correct memory" - description="Submitting a correction creates a new approved memory that cites this one. The original record stays in the audit trail." + description="Submitting a correction creates a new enabled memory that cites this one. The original record stays in the audit trail." titleClassName="text-[15px] font-semibold tracking-tight" descriptionClassName="text-[12.5px] leading-[1.55]" footer={ @@ -471,27 +459,30 @@ export function MemoryReviewSection({ ) } -interface MemoryReviewRowProps { - item: MemoryReviewItem +interface MemoryRowProps { + item: MemoryItem expanded: boolean pendingKind: ActionKind | null - onAction: (item: MemoryReviewItem, kind: ActionKind) => void + onAction: (item: MemoryItem, kind: ActionKind) => void onExpandedChange: (memoryId: string, open: boolean) => void } -const MemoryReviewRow = memo(function MemoryReviewRow({ +const MemoryRow = memo(function MemoryRow({ item, expanded, pendingKind, onAction, onExpandedChange, -}: MemoryReviewRowProps) { +}: MemoryRowProps) { const redacted = item.redaction.textPreviewRedacted const factKeyRedacted = item.redaction.factKeyRedacted const freshnessReason = freshnessExplanation(item) const ariaBusy = pendingKind !== null - const canShowPrimaryApprove = item.reviewState !== "approved" const retrievalLabel = item.retrieval.eligible ? "Eligible" : reasonLabel(item.retrieval.reason) + const status = memoryStatus(item) + const primaryAction = item.enabled ? "disable" : "enable" + const primaryActionAllowed = item.enabled ? item.availableActions.canDisable : item.availableActions.canEnable + const PrimaryIcon = item.enabled ? PowerOff : Power return ( onExpandedChange(item.memoryId, open)}> @@ -500,26 +491,27 @@ const MemoryReviewRow = memo(function MemoryReviewRow({ data-memory-id={item.memoryId} aria-busy={ariaBusy} className={cn( - "overflow-hidden rounded-lg border border-border/60 bg-card/25 shadow-xs [contain-intrinsic-size:96px] [content-visibility:auto]", - redacted && "border-warning/30 bg-warning/[0.035]", + "overflow-hidden rounded-lg border border-border/55 bg-card/35 shadow-xs [contain-intrinsic-size:112px] [content-visibility:auto]", + item.enabled && item.retrieval.eligible && "border-success/25 bg-success/[0.025]", + !item.enabled && "bg-muted/20", + redacted && "border-warning/35 bg-warning/[0.035]", )} > -
+
- ) : null} +
-
-
+
+
{!redacted ? ( -
+

Full preview

{item.textPreview}

) : null} -
+
@@ -618,17 +608,21 @@ const MemoryReviewRow = memo(function MemoryReviewRow({
{freshnessReason ? ( -

+

- {freshnessReason} + {freshnessReason}

) : null} {item.provenance.diagnostic ? ( -

- Diagnostic - {item.provenance.diagnostic.message} -

+
+

+ Diagnostic +

+
+                    {formatDiagnosticMessage(item.provenance.diagnostic.message)}
+                  
+
) : null}
@@ -643,9 +637,9 @@ function MemoryActionsMenu({ pendingKind, onAction, }: { - item: MemoryReviewItem + item: MemoryItem pendingKind: ActionKind | null - onAction: (item: MemoryReviewItem, kind: ActionKind) => void + onAction: (item: MemoryItem, kind: ActionKind) => void }) { const ariaBusy = pendingKind !== null @@ -668,27 +662,23 @@ function MemoryActionsMenu({ - onAction(item, "approve")} - > - - Approve memory - - onAction(item, "reject")} - > - - Reject memory - - onAction(item, "disable")} - > - - Disable memory - + {item.enabled ? ( + onAction(item, "disable")} + > + + Disable memory + + ) : ( + onAction(item, "enable")} + > + + Enable memory + + )} onAction(item, "edit")} @@ -710,19 +700,17 @@ function MemoryActionsMenu({ ) } -function MemoryMetricStrip({ counts }: { counts: GetSessionMemoryReviewQueueResponseDto["counts"] }) { +function MemoryMetricStrip({ counts }: { counts: GetSessionMemoryItemsResponseDto["counts"] }) { const metrics = [ - { label: "Candidates", value: counts.candidate, tone: "info" as Tone }, - { label: "Approved", value: counts.approved, tone: "good" as Tone }, - { label: "Retrievable", value: counts.retrievableApproved, tone: "good" as Tone }, + { label: "Enabled", value: counts.enabled, tone: "good" as Tone }, + { label: "Retrievable", value: counts.retrievable, tone: "good" as Tone }, { label: "Disabled", value: counts.disabled, tone: "neutral" as Tone }, - { label: "Rejected", value: counts.rejected, tone: "warn" as Tone }, ] return (
{metrics.map((metric) => { const isZero = metric.value === 0 @@ -755,7 +743,7 @@ function MemoryMetricStrip({ counts }: { counts: GetSessionMemoryReviewQueueResp ) } -function MemoryReviewPager({ +function MemoryPager({ offset, total, pageSize, @@ -824,17 +812,27 @@ function MemoryDetail({ tone?: Tone }) { return ( -
+
{label}
-
{value}
+
{value}
) } -function reasonLabel(reason: MemoryReviewItem["retrieval"]["reason"]): string { +function memoryStatus(item: MemoryItem): { label: string; tone: Tone } { + if (!item.enabled) return { label: "Disabled", tone: "neutral" } + if (item.retrieval.eligible) return { label: "Retrievable", tone: "good" } + if (["stale", "source_missing", "blocked"].includes(item.retrieval.reason)) { + return { label: reasonLabel(item.retrieval.reason), tone: "warn" } + } + if (item.retrieval.reason === "invalidated" || item.retrieval.reason === "superseded") { + return { label: reasonLabel(item.retrieval.reason), tone: "neutral" } + } + return { label: "Enabled", tone: "info" } +} + +function reasonLabel(reason: MemoryItem["retrieval"]["reason"]): string { switch (reason) { - case "pending_or_rejected_review": - return "Pending review" case "disabled": return "Disabled" case "superseded": @@ -852,7 +850,15 @@ function reasonLabel(reason: MemoryReviewItem["retrieval"]["reason"]): string { } } -function freshnessExplanation(item: MemoryReviewItem): string | null { +function formatDiagnosticMessage(message: string): string { + try { + return JSON.stringify(JSON.parse(message), null, 2) + } catch { + return message + } +} + +function freshnessExplanation(item: MemoryItem): string | null { const { freshness } = item if (freshness.staleReason) return freshness.staleReason if (freshness.state === "stale") return "Source content has changed since this memory was captured." diff --git a/client/components/xero/settings-dialog/providers-section.tsx b/client/components/xero/settings-dialog/providers-section.tsx index 356f1eb0..5e615aeb 100644 --- a/client/components/xero/settings-dialog/providers-section.tsx +++ b/client/components/xero/settings-dialog/providers-section.tsx @@ -9,7 +9,6 @@ import type { ProviderAuthSessionView, RuntimeProviderIdDto, UpsertProviderCredentialRequestDto, - XaiDeviceCodeLoginDto, } from "@/src/lib/xero-model" import { ProviderCredentialsList } from "@/components/xero/provider-profiles/provider-credentials-list" import { SectionHeader } from "./section-header" @@ -35,11 +34,6 @@ export interface ProvidersSectionProps { providerId: RuntimeProviderIdDto originator?: string | null }) => Promise - onStartXaiDeviceCodeLogin?: (request: { providerId: "xai" }) => Promise - onPollXaiDeviceCodeLogin?: (request: { - providerId: "xai" - flowId: string - }) => Promise } export function ProvidersSection({ @@ -54,8 +48,6 @@ export function ProvidersSection({ onUpsertProviderCredential, onDeleteProviderCredential, onStartOAuthLogin, - onStartXaiDeviceCodeLogin, - onPollXaiDeviceCodeLogin, }: ProvidersSectionProps) { return (
@@ -75,8 +67,6 @@ export function ProvidersSection({ onUpsertProviderCredential={onUpsertProviderCredential} onDeleteProviderCredential={onDeleteProviderCredential} onStartOAuthLogin={onStartOAuthLogin} - onStartXaiDeviceCodeLogin={onStartXaiDeviceCodeLogin} - onPollXaiDeviceCodeLogin={onPollXaiDeviceCodeLogin} />
) diff --git a/client/components/xero/settings-dialog/solana-rpc-section.test.tsx b/client/components/xero/settings-dialog/solana-rpc-section.test.tsx new file mode 100644 index 00000000..ef462732 --- /dev/null +++ b/client/components/xero/settings-dialog/solana-rpc-section.test.tsx @@ -0,0 +1,228 @@ +/** @vitest-environment jsdom */ + +import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react" +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" + +type InvokeHandler = (args: Record | undefined) => unknown + +const invokeResponses = new Map() +const invoked: Array<{ command: string; args?: Record }> = [] + +function registerInvoke(command: string, handler: InvokeHandler) { + invokeResponses.set(command, handler) +} + +vi.mock("@tauri-apps/api/core", () => ({ + isTauri: () => true, + invoke: async (command: string, args?: Record) => { + invoked.push({ command, args }) + const handler = invokeResponses.get(command) + if (!handler) return undefined + return handler(args) + }, +})) + +import { SolanaRpcSection } from "./solana-rpc-section" + +function profileResponse() { + return { + profiles: [ + { + id: "paid-devnet", + cluster: "devnet", + label: "Paid devnet", + provider: "helius", + rpcUrl: "https://rpc.example.test/?api-key=redacted", + websocketUrl: null, + secretPlacement: "query_parameter", + secretName: "api-key", + hasSecret: true, + priority: 0, + enabled: true, + allowPublicFallback: false, + rateLimit: null, + managed: false, + selected: false, + }, + { + id: "builtin-localnet", + cluster: "localnet", + label: "Local validator", + provider: "localnet", + rpcUrl: "http://127.0.0.1:8899/", + websocketUrl: "ws://127.0.0.1:8900/", + secretPlacement: "none", + secretName: null, + hasSecret: false, + priority: 0, + enabled: true, + allowPublicFallback: true, + rateLimit: null, + managed: true, + selected: true, + }, + ], + selectedProfileIds: { localnet: "builtin-localnet" }, + inventory: [], + } +} + +describe("SolanaRpcSection", () => { + beforeEach(() => { + invokeResponses.clear() + invoked.length = 0 + registerInvoke("solana_provider_profiles_list", profileResponse) + }) + + afterEach(() => { + cleanup() + }) + + it("loads redacted provider profiles in settings", async () => { + render() + + expect(await screen.findByText("Paid devnet")).toBeVisible() + expect(screen.getByText("https://rpc.example.test/?api-key=redacted")).toBeVisible() + expect(screen.getByText("Local validator")).toBeVisible() + expect(screen.queryByText("secret-token")).not.toBeInTheDocument() + }) + + it("hides edit and delete actions for built-in profiles", async () => { + render() + + expect(await screen.findByText("Local validator")).toBeVisible() + expect(screen.queryByRole("button", { name: "Edit Local validator" })).not.toBeInTheDocument() + expect(screen.queryByRole("button", { name: "Delete Local validator" })).not.toBeInTheDocument() + expect(screen.getByRole("button", { name: "Edit Paid devnet" })).toBeVisible() + expect(screen.getByRole("button", { name: "Delete Paid devnet" })).toBeVisible() + }) + + it("selects a profile from the settings tab", async () => { + let selectArgs: Record | undefined + registerInvoke("solana_provider_profile_select", (args) => { + selectArgs = args + return { + ...profileResponse(), + selectedProfileIds: { localnet: "builtin-localnet", devnet: "paid-devnet" }, + } + }) + + render() + + fireEvent.click(await screen.findByRole("button", { name: "Select Paid devnet" })) + + await waitFor(() => { + expect(selectArgs).toEqual({ + request: { cluster: "devnet", profileId: "paid-devnet" }, + }) + }) + }) + + it("saves a provider profile from the settings form", async () => { + let upsertArgs: Record | undefined + registerInvoke("solana_provider_profile_upsert", (args) => { + upsertArgs = args + return profileResponse() + }) + + render() + + await screen.findByText("Paid devnet") + fireEvent.click(screen.getByRole("button", { name: "New profile" })) + expect(await screen.findByRole("dialog")).toBeVisible() + fireEvent.change(screen.getByLabelText("Display name"), { + target: { value: "Custom localnet" }, + }) + fireEvent.change(screen.getByLabelText("RPC URL"), { + target: { value: "http://127.0.0.1:8899" }, + }) + fireEvent.click(screen.getByRole("button", { name: "Save profile" })) + + await waitFor(() => { + expect(upsertArgs).toMatchObject({ + request: { + profile: { + id: "custom-localnet", + cluster: "localnet", + label: "Custom localnet", + provider: "custom", + rpcUrl: "http://127.0.0.1:8899", + secretPlacement: "none", + allowPublicFallback: true, + enabled: true, + }, + }, + }) + }) + }) + + it("disables saving until required configuration is present", async () => { + render() + + await screen.findByText("Paid devnet") + fireEvent.click(screen.getByRole("button", { name: "New profile" })) + expect(await screen.findByRole("dialog")).toBeVisible() + expect(screen.getByRole("button", { name: "Save profile" })).toBeDisabled() + expect(screen.getByText("RPC URL is required.")).toBeVisible() + + fireEvent.change(screen.getByLabelText("RPC URL"), { + target: { value: "http://127.0.0.1:8899" }, + }) + expect(screen.getByRole("button", { name: "Save profile" })).toBeEnabled() + }) + + it("derives provider auth settings for Helius", async () => { + let upsertArgs: Record | undefined + registerInvoke("solana_provider_profile_upsert", (args) => { + upsertArgs = args + return profileResponse() + }) + + render() + + await screen.findByText("Paid devnet") + fireEvent.click(screen.getByRole("button", { name: "New profile" })) + expect(await screen.findByRole("dialog")).toBeVisible() + fireEvent.click(screen.getByLabelText("Provider")) + fireEvent.click(await screen.findByRole("option", { name: "Helius" })) + fireEvent.change(screen.getByLabelText("RPC URL"), { + target: { value: "https://devnet.helius-rpc.com" }, + }) + expect(screen.getByRole("button", { name: "Save profile" })).toBeDisabled() + expect(screen.getByText("API key is required for Helius.")).toBeVisible() + + fireEvent.change(screen.getByLabelText("API key"), { + target: { value: "secret-token" }, + }) + expect(screen.getByRole("button", { name: "Save profile" })).toBeEnabled() + fireEvent.click(screen.getByRole("button", { name: "Save profile" })) + + await waitFor(() => { + expect(upsertArgs).toMatchObject({ + request: { + profile: { + id: "helius-localnet", + label: "Helius localnet", + provider: "helius", + secretPlacement: "query_parameter", + secretName: "api-key", + apiKey: "secret-token", + }, + }, + }) + }) + }) + + it("opens custom profiles in the shared edit dialog", async () => { + render() + + fireEvent.click(await screen.findByRole("button", { name: "Edit Paid devnet" })) + + expect(await screen.findByRole("dialog")).toBeVisible() + expect(screen.getByRole("heading", { name: "Edit Solana RPC profile" })).toBeVisible() + expect(screen.getByLabelText("Display name")).toHaveValue("Paid devnet") + expect(screen.getByLabelText("RPC URL")).toHaveValue( + "https://rpc.example.test/?api-key=redacted", + ) + }) +}) diff --git a/client/components/xero/settings-dialog/solana-rpc-section.tsx b/client/components/xero/settings-dialog/solana-rpc-section.tsx new file mode 100644 index 00000000..c9568f39 --- /dev/null +++ b/client/components/xero/settings-dialog/solana-rpc-section.tsx @@ -0,0 +1,1049 @@ +"use client" + +import { invoke, isTauri } from "@tauri-apps/api/core" +import { + CircleCheckBig, + Loader2, + Pencil, + Plus, + RefreshCw, + RadioTower, + Save, + Trash2, +} from "lucide-react" +import { useCallback, useEffect, useMemo, useState, type ReactNode } from "react" + +import { BaseDialog } from "@xero/ui/components/base-dialog" + +import { Button } from "@/components/ui/button" +import { + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog" +import { Input } from "@/components/ui/input" +import { Label } from "@/components/ui/label" +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select" +import { Switch } from "@/components/ui/switch" +import { cn } from "@/lib/utils" +import type { + ClusterKind, + ProviderProfileUpsert, + ProviderProfileView, + ProviderProfilesResponse, + SecretPlacement, + SolanaProviderKind, +} from "@/src/features/solana/use-solana-workbench" +import { + EmptyPanel, + ErrorBanner, + ListContainer, + Pill, + SubHeading, + SuccessBanner, +} from "./_shared" +import { SectionHeader } from "./section-header" + +type LoadState = "idle" | "loading" | "ready" | "error" +type MutationState = "idle" | "saving" | "selecting" | "deleting" + +interface ProfileForm { + id: string + cluster: ClusterKind + label: string + provider: SolanaProviderKind + rpcUrl: string + websocketUrl: string + customSecretPlacement: SecretPlacement + apiKey: string + hasExistingSecret: boolean + allowPublicFallback: boolean + enabled: boolean +} + +const CLUSTER_OPTIONS: Array<{ value: ClusterKind; label: string }> = [ + { value: "localnet", label: "Localnet" }, + { value: "mainnet_fork", label: "Mainnet fork" }, + { value: "devnet", label: "Devnet" }, + { value: "mainnet", label: "Mainnet" }, +] + +const PROVIDER_OPTIONS: Array<{ value: SolanaProviderKind; label: string }> = [ + { value: "custom", label: "Custom" }, + { value: "helius", label: "Helius" }, + { value: "quick_node", label: "QuickNode" }, + { value: "alchemy", label: "Alchemy" }, + { value: "triton", label: "Triton" }, + { value: "chainstack", label: "Chainstack" }, + { value: "solana_public", label: "Solana public" }, + { value: "localnet", label: "Localnet" }, +] + +const SECRET_PLACEMENT_OPTIONS: Array<{ value: SecretPlacement; label: string }> = [ + { value: "none", label: "No key" }, + { value: "query_parameter", label: "Query parameter" }, + { value: "header", label: "Header" }, + { value: "embedded_url", label: "Embedded URL" }, +] + +const EMPTY_FORM: ProfileForm = { + id: "", + cluster: "localnet", + label: "", + provider: "custom", + rpcUrl: "", + websocketUrl: "", + customSecretPlacement: "none", + apiKey: "", + hasExistingSecret: false, + allowPublicFallback: true, + enabled: true, +} + +const PROVIDER_AUTH_DEFAULTS: Record< + SolanaProviderKind, + { placement: SecretPlacement; secretName: string | null; help: string } +> = { + custom: { + placement: "none", + secretName: null, + help: "Choose the authentication mode for this custom endpoint.", + }, + helius: { + placement: "query_parameter", + secretName: "api-key", + help: "Helius keys are added as an api-key query parameter.", + }, + quick_node: { + placement: "embedded_url", + secretName: null, + help: "QuickNode endpoints usually include the token in the endpoint URL.", + }, + alchemy: { + placement: "embedded_url", + secretName: null, + help: "Alchemy Solana endpoints usually include the key in the /v2 URL path.", + }, + triton: { + placement: "embedded_url", + secretName: null, + help: "Triton endpoints usually include the token in the endpoint URL.", + }, + chainstack: { + placement: "embedded_url", + secretName: null, + help: "Chainstack endpoints usually include access in the dedicated URL.", + }, + solana_public: { + placement: "none", + secretName: null, + help: "Public Solana RPC endpoints do not use an API key.", + }, + localnet: { + placement: "none", + secretName: null, + help: "Local validator endpoints do not use an API key.", + }, +} + +export function SolanaRpcSection() { + const [profilesResponse, setProfilesResponse] = + useState(null) + const [loadState, setLoadState] = useState("idle") + const [mutationState, setMutationState] = useState("idle") + const [pendingProfileId, setPendingProfileId] = useState(null) + const [error, setError] = useState(null) + const [formError, setFormError] = useState(null) + const [success, setSuccess] = useState(null) + const [form, setForm] = useState(EMPTY_FORM) + const [profileDialogOpen, setProfileDialogOpen] = useState(false) + + const profiles = profilesResponse?.profiles ?? [] + const busy = loadState === "loading" || mutationState !== "idle" + + const loadProfiles = useCallback(async () => { + if (!isTauri()) { + setProfilesResponse({ profiles: [], selectedProfileIds: {}, inventory: [] }) + setLoadState("ready") + return + } + + setLoadState("loading") + setError(null) + try { + const response = await invoke( + "solana_provider_profiles_list", + ) + setProfilesResponse(response) + setLoadState("ready") + } catch (loadError) { + setLoadState("error") + setError(getErrorMessage(loadError, "Xero could not load Solana RPC profiles.")) + } + }, []) + + useEffect(() => { + void loadProfiles() + }, [loadProfiles]) + + const groupedProfiles = useMemo(() => { + const grouped = new Map() + for (const profile of profiles) { + const list = grouped.get(profile.cluster) ?? [] + list.push(profile) + grouped.set(profile.cluster, list) + } + + return CLUSTER_OPTIONS.map((cluster) => ({ + cluster, + profiles: (grouped.get(cluster.value) ?? []).sort( + (a, b) => + Number(b.selected) - Number(a.selected) || + Number(b.enabled) - Number(a.enabled) || + a.label.localeCompare(b.label), + ), + })) + }, [profiles]) + + const selectedCount = profiles.filter((profile) => profile.selected).length + const keyedCount = profiles.filter((profile) => profile.hasSecret).length + const customCount = profiles.filter((profile) => !profile.managed).length + + const resetForm = useCallback(() => { + setForm(EMPTY_FORM) + setFormError(null) + setError(null) + }, []) + + const openNewProfile = useCallback(() => { + resetForm() + setSuccess(null) + setProfileDialogOpen(true) + }, [resetForm]) + + const editProfile = useCallback((profile: ProviderProfileView) => { + if (profile.managed) return + setForm({ + id: profile.id, + cluster: profile.cluster, + label: profile.label, + provider: profile.provider, + rpcUrl: profile.rpcUrl, + websocketUrl: profile.websocketUrl ?? "", + customSecretPlacement: profile.provider === "custom" ? profile.secretPlacement : "query_parameter", + apiKey: "", + hasExistingSecret: profile.hasSecret, + allowPublicFallback: profile.allowPublicFallback, + enabled: profile.enabled, + }) + setError(null) + setFormError(null) + setSuccess(null) + setProfileDialogOpen(true) + }, []) + + const handleProfileDialogOpenChange = useCallback( + (open: boolean) => { + if (mutationState === "saving") return + setProfileDialogOpen(open) + if (!open) { + resetForm() + } + }, + [mutationState, resetForm], + ) + + const selectProfile = useCallback( + async (profile: ProviderProfileView) => { + if (!isTauri() || profile.selected || !profile.enabled) return + setMutationState("selecting") + setPendingProfileId(profile.id) + setError(null) + setSuccess(null) + try { + const response = await invoke( + "solana_provider_profile_select", + { request: { cluster: profile.cluster, profileId: profile.id } }, + ) + setProfilesResponse(response) + setSuccess(`${profile.label} is now selected for ${profile.cluster}.`) + } catch (selectError) { + setError(getErrorMessage(selectError, "Xero could not select that Solana RPC profile.")) + } finally { + setMutationState("idle") + setPendingProfileId(null) + } + }, + [], + ) + + const deleteProfile = useCallback( + async (profile: ProviderProfileView) => { + if (!isTauri() || profile.managed) return + setMutationState("deleting") + setPendingProfileId(profile.id) + setError(null) + setSuccess(null) + try { + const response = await invoke( + "solana_provider_profile_delete", + { request: { profileId: profile.id } }, + ) + setProfilesResponse(response) + if (form.id === profile.id) { + setForm(EMPTY_FORM) + } + setSuccess(`${profile.label} was deleted.`) + } catch (deleteError) { + setError(getErrorMessage(deleteError, "Xero could not delete that Solana RPC profile.")) + } finally { + setMutationState("idle") + setPendingProfileId(null) + } + }, + [form.id], + ) + + const saveProfile = useCallback(async () => { + const validationError = profileFormValidationMessage(form) + if (validationError) { + setFormError(validationError) + setSuccess(null) + return + } + + const profile = buildProfileRequest(form) + if (!profile.ok) { + setFormError(profile.error) + setSuccess(null) + return + } + + if (!isTauri()) return + + setMutationState("saving") + setPendingProfileId(form.id.trim()) + setError(null) + setFormError(null) + setSuccess(null) + try { + const response = await invoke( + "solana_provider_profile_upsert", + { request: { profile: profile.value } }, + ) + setProfilesResponse(response) + setForm(EMPTY_FORM) + setProfileDialogOpen(false) + setSuccess(`${profile.value.label} was saved.`) + } catch (saveError) { + setFormError(getErrorMessage(saveError, "Xero could not save that Solana RPC profile.")) + } finally { + setMutationState("idle") + setPendingProfileId(null) + } + }, [form]) + + return ( +
+ void loadProfiles()} + > + + Refresh + + } + /> + + {error ? : null} + {success ? : null} + +
+
+
+ Provider profiles + +
+ +
+ + {loadState === "loading" && profiles.length === 0 ? ( +
+ + Loading Solana RPC profiles... +
+ ) : profiles.length === 0 ? ( + } + title="No RPC profiles" + body="Add a provider profile for devnet, mainnet, localnet, or a forked mainnet endpoint." + /> + ) : ( +
+ {groupedProfiles.map(({ cluster, profiles: clusterProfiles }) => + clusterProfiles.length > 0 ? ( +
+
+ + {cluster.label} + + + {clusterProfiles.filter((profile) => profile.selected).length}/ + {clusterProfiles.length} + +
+ + {clusterProfiles.map((profile) => ( + + ))} + +
+ ) : null, + )} +
+ )} +
+ + { + setForm(nextForm) + setFormError(null) + }} + onSave={saveProfile} + /> +
+ ) +} + +function ProfileSummary({ + selectedCount, + keyedCount, + customCount, +}: { + selectedCount: number + keyedCount: number + customCount: number +}) { + const parts = [ + `${selectedCount} selected`, + `${keyedCount} with keys`, + `${customCount} custom`, + ] + + return ( +

+ {parts.join(" / ")} +

+ ) +} + +function ProfileRow({ + profile, + pending, + mutating, + onEdit, + onSelect, + onDelete, +}: { + profile: ProviderProfileView + pending: boolean + mutating: MutationState + onEdit: (profile: ProviderProfileView) => void + onSelect: (profile: ProviderProfileView) => void + onDelete: (profile: ProviderProfileView) => void +}) { + const actionBusy = pending && mutating !== "idle" + + return ( +
+
+
+ + {profile.label} + + {profile.selected ? Selected : null} + {profile.hasSecret ? Key : null} + {profile.managed ? Built-in : null} + {!profile.enabled ? Disabled : null} +
+
+ {profile.rpcUrl} +
+ {profile.websocketUrl ? ( +
+ {profile.websocketUrl} +
+ ) : null} +
+ {providerLabel(profile.provider)} + + {profile.id} + + {secretPlacementLabel(profile.secretPlacement)} + {profile.allowPublicFallback ? ( + <> + + public fallback + + ) : null} +
+
+ +
+ {!profile.managed ? ( + + ) : null} + + {!profile.managed ? ( + + ) : null} +
+
+ ) +} + +function ProfileEditorDialog({ + open, + form, + error, + saving, + onOpenChange, + onChange, + onSave, +}: { + open: boolean + form: ProfileForm + error: string | null + saving: boolean + onOpenChange: (open: boolean) => void + onChange: (form: ProfileForm) => void + onSave: () => void +}) { + const setField = (key: K, value: ProfileForm[K]) => { + onChange({ ...form, [key]: value }) + } + const authPlacement = resolveSecretPlacement(form) + const apiKeyDisabled = + authPlacement === "none" || authPlacement === "embedded_url" + const rpcPlaceholder = rpcUrlPlaceholder(form.provider, form.cluster) + const websocketPlaceholder = websocketUrlPlaceholder(form.provider, form.cluster) + const validationMessage = profileFormValidationMessage(form) + const saveDisabled = saving || validationMessage !== null + const isEditing = form.id.trim().length > 0 + + const setCluster = (cluster: ClusterKind) => { + const nextLabel = shouldReplaceSuggestedLabel(form) + ? suggestedProfileLabel(form.provider, cluster) + : form.label + onChange({ ...form, cluster, label: nextLabel }) + } + + const setProvider = (provider: SolanaProviderKind) => { + const nextLabel = shouldReplaceSuggestedLabel(form) + ? suggestedProfileLabel(provider, form.cluster) + : form.label + const nextPlacement = + provider === "custom" + ? form.customSecretPlacement + : PROVIDER_AUTH_DEFAULTS[provider].placement + + onChange({ + ...form, + provider, + label: nextLabel, + customSecretPlacement: nextPlacement, + hasExistingSecret: provider === form.provider ? form.hasExistingSecret : false, + apiKey: + PROVIDER_AUTH_DEFAULTS[provider].placement === "none" || + PROVIDER_AUTH_DEFAULTS[provider].placement === "embedded_url" + ? "" + : form.apiKey, + }) + } + + return ( + + } + header={ +
+ +
+ + + + + {isEditing ? "Edit Solana RPC profile" : "Add Solana RPC profile"} + +
+ + Save one profile per provider and cluster. Leave the API key blank + when editing to keep the stored secret. + +
+
+ } + bodyClassName="relative min-h-0 overflow-y-auto px-6 pb-5 pt-3" + footerClassName="border-t border-border/60 bg-secondary/20 px-6 py-3" + footer={ +
+ + +
+ } + > +
+
+ + setField("label", event.target.value)} + placeholder={suggestedProfileLabel(form.provider, form.cluster)} + /> + + + + + + + + +
+ +
+ + setField("rpcUrl", event.target.value)} + placeholder={rpcPlaceholder} + /> + + + + setField("websocketUrl", event.target.value)} + placeholder={websocketPlaceholder} + /> + +
+ +
+ {form.provider === "custom" ? ( + + + + ) : ( +
+ +
+ {secretPlacementLabel(authPlacement)} +
+
+ )} + + setField("apiKey", event.target.value)} + placeholder={ + authPlacement === "none" + ? "Not needed" + : authPlacement === "embedded_url" + ? "Paste key in endpoint URL" + : "New key or blank to preserve" + } + /> + +
+ +
+
+ setField("enabled", checked)} + /> + setField("allowPublicFallback", checked)} + /> +
+ {validationMessage ? ( +

+ {validationMessage} +

+ ) : null} +
+
+ + {error ? ( + + ) : null} +
+ ) +} + +function FormField({ + label, + htmlFor, + children, +}: { + label: string + htmlFor: string + children: ReactNode +}) { + return ( +
+ + {children} +
+ ) +} + +function SwitchRow({ + id, + label, + checked, + onCheckedChange, +}: { + id: string + label: string + checked: boolean + onCheckedChange: (checked: boolean) => void +}) { + return ( +
+ + +
+ ) +} + +function buildProfileRequest( + form: ProfileForm, +): { ok: true; value: ProviderProfileUpsert } | { ok: false; error: string } { + const label = form.label.trim() || suggestedProfileLabel(form.provider, form.cluster) + const id = form.id.trim() || buildProfileId(form.provider, form.cluster, label) + const rpcUrl = form.rpcUrl.trim() + const websocketUrl = form.websocketUrl.trim() + const secretPlacement = resolveSecretPlacement(form) + const secretName = resolveSecretName(form) + + if (rpcUrl.length === 0) return { ok: false, error: "RPC URL is required." } + + return { + ok: true, + value: { + id, + cluster: form.cluster, + label, + provider: form.provider, + rpcUrl, + websocketUrl: websocketUrl.length > 0 ? websocketUrl : null, + secretPlacement, + secretName, + apiKey: form.apiKey.length > 0 ? form.apiKey : null, + priority: 0, + enabled: form.enabled, + allowPublicFallback: form.allowPublicFallback, + }, + } +} + +function profileFormValidationMessage(form: ProfileForm): string | null { + if (form.rpcUrl.trim().length === 0) { + return "RPC URL is required." + } + + const secretPlacement = resolveSecretPlacement(form) + if ( + (secretPlacement === "query_parameter" || secretPlacement === "header") && + !form.hasExistingSecret && + form.apiKey.trim().length === 0 + ) { + return `API key is required for ${providerLabel(form.provider)}.` + } + + return null +} + +function providerLabel(provider: SolanaProviderKind): string { + return PROVIDER_OPTIONS.find((option) => option.value === provider)?.label ?? provider +} + +function secretPlacementLabel(secretPlacement: SecretPlacement): string { + return SECRET_PLACEMENT_OPTIONS.find((option) => option.value === secretPlacement)?.label ?? secretPlacement +} + +function resolveSecretPlacement(form: ProfileForm): SecretPlacement { + if (form.provider === "custom") return form.customSecretPlacement + return PROVIDER_AUTH_DEFAULTS[form.provider].placement +} + +function resolveSecretName(form: ProfileForm): string | null { + const placement = resolveSecretPlacement(form) + if (placement === "none" || placement === "embedded_url") return null + if (form.provider === "custom") { + return placement === "header" ? "Authorization" : "api-key" + } + return PROVIDER_AUTH_DEFAULTS[form.provider].secretName +} + +function suggestedProfileLabel(provider: SolanaProviderKind, cluster: ClusterKind): string { + if (provider === "custom") return `Custom ${clusterLabel(cluster).toLowerCase()}` + if (provider === "localnet") return "Local validator" + if (provider === "solana_public") return `Solana public ${clusterLabel(cluster).toLowerCase()}` + return `${providerLabel(provider)} ${clusterLabel(cluster).toLowerCase()}` +} + +function shouldReplaceSuggestedLabel(form: ProfileForm): boolean { + const current = form.label.trim() + if (current.length === 0) return true + return PROVIDER_OPTIONS.some((provider) => + CLUSTER_OPTIONS.some( + (cluster) => current === suggestedProfileLabel(provider.value, cluster.value), + ), + ) +} + +function buildProfileId( + provider: SolanaProviderKind, + cluster: ClusterKind, + label: string, +): string { + const base = + provider === "custom" + ? label + : `${providerLabel(provider)}-${cluster}` + return slugify(base) +} + +function slugify(value: string): string { + const slug = value + .trim() + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, "") + return slug.length > 0 ? slug : "solana-rpc-profile" +} + +function clusterLabel(cluster: ClusterKind): string { + return CLUSTER_OPTIONS.find((option) => option.value === cluster)?.label ?? cluster +} + +function rpcUrlPlaceholder(provider: SolanaProviderKind, cluster: ClusterKind): string { + if (provider === "localnet" || cluster === "localnet") return "http://127.0.0.1:8899" + if (provider === "helius") { + return cluster === "devnet" + ? "https://devnet.helius-rpc.com" + : "https://mainnet.helius-rpc.com" + } + if (provider === "alchemy") { + return cluster === "devnet" + ? "https://solana-devnet.g.alchemy.com/v2/" + : "https://solana-mainnet.g.alchemy.com/v2/" + } + if (provider === "solana_public") { + return cluster === "devnet" + ? "https://api.devnet.solana.com" + : "https://api.mainnet-beta.solana.com" + } + return "https://rpc.example.com" +} + +function websocketUrlPlaceholder(provider: SolanaProviderKind, cluster: ClusterKind): string { + if (provider === "localnet" || cluster === "localnet") return "ws://127.0.0.1:8900" + if (provider === "helius") { + return cluster === "devnet" + ? "wss://devnet.helius-rpc.com" + : "wss://mainnet.helius-rpc.com" + } + return "wss://rpc.example.com" +} + +function getErrorMessage(error: unknown, fallback: string): string { + if (error && typeof error === "object" && "message" in error) { + const message = (error as { message?: unknown }).message + if (typeof message === "string" && message.length > 0) return message + } + if (typeof error === "string" && error.length > 0) return error + return fallback +} diff --git a/client/components/xero/settings-dialog/source-control-section.test.tsx b/client/components/xero/settings-dialog/source-control-section.test.tsx new file mode 100644 index 00000000..238744f7 --- /dev/null +++ b/client/components/xero/settings-dialog/source-control-section.test.tsx @@ -0,0 +1,86 @@ +import { fireEvent, render, screen, waitFor } from "@testing-library/react" +import { beforeEach, describe, expect, it } from "vitest" + +import type { StartTargetsModelOption } from "@/components/xero/start-targets-editor" +import { SOURCE_CONTROL_SETTINGS_KEY } from "@/components/xero/source-control-settings" + +import { SourceControlSection } from "./source-control-section" + +const modelOptions: StartTargetsModelOption[] = [ + { + selectionKey: "xai:grok-4.3-latest", + providerId: "xai", + providerProfileId: "xai-default", + providerLabel: "xAI / Grok", + modelId: "grok-4.3-latest", + label: "Grok 4.3", + thinkingEffortOptions: ["medium", "high"], + defaultThinkingEffort: "medium", + }, + { + selectionKey: "openai_codex:gpt-5.4", + providerId: "openai_codex", + providerProfileId: "openai_codex-default", + providerLabel: "OpenAI Codex", + modelId: "gpt-5.4", + label: "GPT-5.4", + thinkingEffortOptions: ["low", "medium", "high"], + defaultThinkingEffort: "low", + }, +] + +function ensurePointerCaptureApi() { + for (const [name, value] of [ + ["hasPointerCapture", () => false], + ["setPointerCapture", () => undefined], + ["releasePointerCapture", () => undefined], + ] as const) { + if (!(name in HTMLElement.prototype)) { + Object.defineProperty(HTMLElement.prototype, name, { + configurable: true, + value, + }) + } + } +} + +describe("SourceControlSection", () => { + beforeEach(() => { + window.localStorage.clear() + }) + + it("persists a dedicated commit message model and thinking level", async () => { + render() + + expect(screen.getByRole("heading", { name: "Source Control" })).toBeVisible() + expect(screen.getByText("Commit message model")).toBeVisible() + expect(screen.getByText(/last active chat model and thinking level/i)).toBeVisible() + + ensurePointerCaptureApi() + fireEvent.pointerDown(screen.getByRole("combobox", { name: "Commit message model" }), { + button: 0, + pointerId: 1, + pointerType: "mouse", + }) + expect(screen.getByRole("option", { name: "Default model" })).toBeVisible() + fireEvent.click(await screen.findByRole("option", { name: "GPT-5.4" })) + + const thinkingItem = screen.getByRole("menuitem", { name: /Thinking/i }) + fireEvent.keyDown(thinkingItem, { key: "ArrowRight" }) + fireEvent.click(screen.getByRole("menuitemradio", { name: "High" })) + + await waitFor(() => { + const persisted = JSON.parse( + window.localStorage.getItem(SOURCE_CONTROL_SETTINGS_KEY) ?? "null", + ) + expect(persisted).toMatchObject({ + commitMessageModelSelection: { + providerId: "openai_codex", + providerProfileId: "openai_codex-default", + modelId: "gpt-5.4", + thinkingEffort: "high", + }, + }) + }) + }) +}) diff --git a/client/components/xero/settings-dialog/source-control-section.tsx b/client/components/xero/settings-dialog/source-control-section.tsx new file mode 100644 index 00000000..605d096d --- /dev/null +++ b/client/components/xero/settings-dialog/source-control-section.tsx @@ -0,0 +1,214 @@ +"use client" + +import { useEffect, useMemo, useState } from "react" +import { + ModelThinkingSelect, + type ModelThinkingSelectGroup, + type ModelThinkingSelectOption, +} from "@xero/ui/components/model-thinking-select" + +import type { StartTargetsModelOption } from "@/components/xero/start-targets-editor" +import { + loadSourceControlSettings, + persistSourceControlSettings, + subscribeSourceControlSettings, + type SourceControlModelSelection, + type SourceControlSettings, +} from "@/components/xero/source-control-settings" +import { getProviderModelThinkingEffortLabel } from "@/src/lib/xero-model" + +import { SectionHeader } from "./section-header" + +interface SourceControlSectionProps { + modelOptions?: StartTargetsModelOption[] +} + +const DEFAULT_MODEL_VALUE = "__source_control_default_model__" + +function optionMatchesSelection( + option: StartTargetsModelOption, + selection: SourceControlModelSelection | null, +): boolean { + if (!selection?.modelId || option.modelId !== selection.modelId) return false + if (selection.providerProfileId) { + return option.providerProfileId === selection.providerProfileId + } + if (selection.providerId) { + return option.providerId === selection.providerId + } + return true +} + +function selectionFromOption( + option: StartTargetsModelOption, + thinkingEffort: SourceControlModelSelection["thinkingEffort"] = + option.defaultThinkingEffort ?? null, +): SourceControlModelSelection { + return { + providerId: option.providerId, + providerProfileId: option.providerProfileId, + modelId: option.modelId, + thinkingEffort, + } +} + +function formatStoredModelLabel(selection: SourceControlModelSelection): string { + const provider = selection.providerProfileId ?? selection.providerId + return provider ? `${provider} - ${selection.modelId}` : selection.modelId ?? "Saved model" +} + +export function SourceControlSection({ modelOptions = [] }: SourceControlSectionProps) { + const [settings, setSettings] = useState( + loadSourceControlSettings, + ) + + useEffect( + () => + subscribeSourceControlSettings((nextSettings) => { + setSettings(nextSettings) + }), + [], + ) + + const modelGroups = useMemo(() => { + const groups = new Map< + string, + { providerLabel: string; options: ModelThinkingSelectOption[] } + >() + for (const option of modelOptions) { + const existing = groups.get(option.providerLabel) + const item = { id: option.selectionKey, label: option.label } + if (existing) { + existing.options.push(item) + } else { + groups.set(option.providerLabel, { + providerLabel: option.providerLabel, + options: [item], + }) + } + } + return [ + { + id: "default", + options: [{ id: DEFAULT_MODEL_VALUE, label: "Default model" }], + }, + ...Array.from(groups.values()).map((group) => ({ + id: group.providerLabel, + label: group.providerLabel, + options: group.options, + })), + ] + }, [modelOptions]) + + const selectedModel = + modelOptions.find((option) => + optionMatchesSelection(option, settings.commitMessageModelSelection), + ) ?? null + const modelSelectValue = selectedModel?.selectionKey ?? DEFAULT_MODEL_VALUE + const thinkingOptions = useMemo( + () => + (selectedModel?.thinkingEffortOptions ?? []).map((effort) => ({ + id: effort, + label: getProviderModelThinkingEffortLabel(effort), + })), + [selectedModel], + ) + const selectedThinkingEffort = + selectedModel && + settings.commitMessageModelSelection?.thinkingEffort && + selectedModel.thinkingEffortOptions.includes( + settings.commitMessageModelSelection.thinkingEffort, + ) + ? settings.commitMessageModelSelection.thinkingEffort + : selectedModel?.defaultThinkingEffort ?? null + + const updateSettings = (patch: Partial) => { + setSettings((current) => { + const next = { ...current, ...patch } + persistSourceControlSettings(next) + return next + }) + } + + const handleModelChange = (value: string) => { + if (value === DEFAULT_MODEL_VALUE) { + updateSettings({ commitMessageModelSelection: null }) + return + } + const option = modelOptions.find((entry) => entry.selectionKey === value) + if (!option) return + const currentThinking = settings.commitMessageModelSelection?.thinkingEffort ?? null + const nextThinking = + currentThinking && option.thinkingEffortOptions.includes(currentThinking) + ? currentThinking + : option.defaultThinkingEffort ?? null + updateSettings({ commitMessageModelSelection: selectionFromOption(option, nextThinking) }) + } + + const handleThinkingChange = (value: string) => { + if (!selectedModel) return + const thinkingEffort = value as SourceControlModelSelection["thinkingEffort"] + if (!thinkingEffort || !selectedModel.thinkingEffortOptions.includes(thinkingEffort)) { + return + } + updateSettings({ + commitMessageModelSelection: selectionFromOption(selectedModel, thinkingEffort), + }) + } + + return ( +
+ + +
+
+
+
+ Commit message model + + LLM + +
+

+ Uses this model for AI-generated commit messages. Default uses the last + active chat model and thinking level. +

+ {settings.commitMessageModelSelection && !selectedModel ? ( +

+ Saved model unavailable:{" "} + {formatStoredModelLabel(settings.commitMessageModelSelection)} +

+ ) : null} +
+ + + + {modelOptions.length === 0 ? ( +

+ Configure a provider before choosing a dedicated commit-message model. +

+ ) : null} +
+
+
+ ) +} diff --git a/client/components/xero/settings-dialog/terminal-section.test.tsx b/client/components/xero/settings-dialog/terminal-section.test.tsx new file mode 100644 index 00000000..461596af --- /dev/null +++ b/client/components/xero/settings-dialog/terminal-section.test.tsx @@ -0,0 +1,97 @@ +import { fireEvent, render, screen, waitFor } from "@testing-library/react" +import { beforeEach, describe, expect, it } from "vitest" + +import type { StartTargetsModelOption } from "@/components/xero/start-targets-editor" +import { TERMINAL_SUGGESTION_SETTINGS_KEY } from "@/components/xero/terminal-suggestion-settings" + +import { TerminalSection } from "./terminal-section" + +const modelOptions: StartTargetsModelOption[] = [ + { + selectionKey: "xai:grok-4.3-latest", + providerId: "xai", + providerProfileId: "xai-default", + providerLabel: "xAI / Grok", + modelId: "grok-4.3-latest", + label: "Grok 4.3", + thinkingEffortOptions: ["medium", "high"], + defaultThinkingEffort: "medium", + }, + { + selectionKey: "openai_codex:gpt-5.4", + providerId: "openai_codex", + providerProfileId: "openai_codex-default", + providerLabel: "OpenAI Codex", + modelId: "gpt-5.4", + label: "GPT-5.4", + thinkingEffortOptions: ["low", "medium", "high"], + defaultThinkingEffort: "low", + }, +] + +function ensurePointerCaptureApi() { + for (const [name, value] of [ + ["hasPointerCapture", () => false], + ["setPointerCapture", () => undefined], + ["releasePointerCapture", () => undefined], + ] as const) { + if (!(name in HTMLElement.prototype)) { + Object.defineProperty(HTMLElement.prototype, name, { + configurable: true, + value, + }) + } + } +} + +describe("TerminalSection", () => { + beforeEach(() => { + window.localStorage.clear() + }) + + it("explains terminal suggestion modes and persists a dedicated AI model", async () => { + render() + + expect(screen.getByRole("heading", { name: "Terminal" })).toBeVisible() + expect(screen.getByText("Command suggestions")).toBeVisible() + expect(screen.getByText("Local")).toBeVisible() + expect(screen.getByText(/recent terminal commands, shell history/i)).toBeVisible() + expect(screen.getByText("AI suggestions")).toBeVisible() + expect(screen.getByText("Fallback")).toBeVisible() + expect(screen.getByText("Model")).toBeVisible() + expect(screen.getByText(/active chat model/i)).toBeVisible() + + fireEvent.click(screen.getByRole("switch", { name: "AI suggestions" })) + + ensurePointerCaptureApi() + fireEvent.pointerDown(screen.getByRole("combobox", { name: "Model" }), { + button: 0, + pointerId: 1, + pointerType: "mouse", + }) + expect(screen.getByRole("option", { name: "Default model" })).toBeVisible() + expect(screen.queryByRole("option", { name: "Provider default" })).not.toBeInTheDocument() + fireEvent.click(await screen.findByRole("option", { name: "GPT-5.4" })) + + const thinkingItem = screen.getByRole("menuitem", { name: /Thinking/i }) + fireEvent.keyDown(thinkingItem, { key: "ArrowRight" }) + fireEvent.click(screen.getByRole("menuitemradio", { name: "High" })) + + await waitFor(() => { + const persisted = JSON.parse( + window.localStorage.getItem(TERMINAL_SUGGESTION_SETTINGS_KEY) ?? "null", + ) + expect(persisted).toMatchObject({ + enabled: true, + aiEnabled: true, + modelSelection: { + providerId: "openai_codex", + providerProfileId: "openai_codex-default", + modelId: "gpt-5.4", + runtimeAgentId: null, + thinkingEffort: "high", + }, + }) + }) + }) +}) diff --git a/client/components/xero/settings-dialog/terminal-section.tsx b/client/components/xero/settings-dialog/terminal-section.tsx new file mode 100644 index 00000000..77139273 --- /dev/null +++ b/client/components/xero/settings-dialog/terminal-section.tsx @@ -0,0 +1,284 @@ +"use client" + +import { useEffect, useMemo, useState } from "react" +import { + ModelThinkingSelect, + type ModelThinkingSelectGroup, + type ModelThinkingSelectOption, +} from "@xero/ui/components/model-thinking-select" + +import { Switch } from "@/components/ui/switch" +import type { StartTargetsModelOption } from "@/components/xero/start-targets-editor" +import { + loadTerminalSuggestionSettings, + persistTerminalSuggestionSettings, + subscribeTerminalSuggestionSettings, + type TerminalSuggestionModelSelection, + type TerminalSuggestionSettings, +} from "@/components/xero/terminal-suggestion-settings" +import { cn } from "@/lib/utils" +import { getProviderModelThinkingEffortLabel } from "@/src/lib/xero-model" + +import { SectionHeader } from "./section-header" + +interface TerminalSectionProps { + modelOptions?: StartTargetsModelOption[] +} + +const DEFAULT_MODEL_VALUE = "__terminal_default_model__" + +function optionMatchesSelection( + option: StartTargetsModelOption, + selection: TerminalSuggestionModelSelection | null, +): boolean { + if (!selection?.modelId || option.modelId !== selection.modelId) return false + if (selection.providerProfileId) { + return option.providerProfileId === selection.providerProfileId + } + if (selection.providerId) { + return option.providerId === selection.providerId + } + return true +} + +function selectionFromOption( + option: StartTargetsModelOption, + thinkingEffort: TerminalSuggestionModelSelection["thinkingEffort"] = + option.defaultThinkingEffort ?? null, +): TerminalSuggestionModelSelection { + return { + providerId: option.providerId, + providerProfileId: option.providerProfileId, + modelId: option.modelId, + runtimeAgentId: null, + thinkingEffort, + } +} + +function formatStoredModelLabel(selection: TerminalSuggestionModelSelection): string { + const provider = selection.providerProfileId ?? selection.providerId + return provider ? `${provider} - ${selection.modelId}` : selection.modelId ?? "Saved model" +} + +export function TerminalSection({ modelOptions = [] }: TerminalSectionProps) { + const [settings, setSettings] = useState( + loadTerminalSuggestionSettings, + ) + + useEffect( + () => + subscribeTerminalSuggestionSettings((nextSettings) => { + setSettings(nextSettings) + }), + [], + ) + + const modelGroups = useMemo(() => { + const groups = new Map< + string, + { providerLabel: string; options: ModelThinkingSelectOption[] } + >() + for (const option of modelOptions) { + const existing = groups.get(option.providerLabel) + const item = { id: option.selectionKey, label: option.label } + if (existing) { + existing.options.push(item) + } else { + groups.set(option.providerLabel, { + providerLabel: option.providerLabel, + options: [item], + }) + } + } + return [ + { + id: "default", + options: [{ id: DEFAULT_MODEL_VALUE, label: "Default model" }], + }, + ...Array.from(groups.values()).map((group) => ({ + id: group.providerLabel, + label: group.providerLabel, + options: group.options, + })), + ] + }, [modelOptions]) + + const selectedModel = + modelOptions.find((option) => optionMatchesSelection(option, settings.modelSelection)) ?? + null + const modelSelectValue = selectedModel?.selectionKey ?? DEFAULT_MODEL_VALUE + const thinkingOptions = useMemo( + () => + (selectedModel?.thinkingEffortOptions ?? []).map((effort) => ({ + id: effort, + label: getProviderModelThinkingEffortLabel(effort), + })), + [selectedModel], + ) + const selectedThinkingEffort = + selectedModel && + settings.modelSelection?.thinkingEffort && + selectedModel.thinkingEffortOptions.includes(settings.modelSelection.thinkingEffort) + ? settings.modelSelection.thinkingEffort + : selectedModel?.defaultThinkingEffort ?? null + + const updateSettings = (patch: Partial) => { + setSettings((current) => { + const next = { ...current, ...patch } + persistTerminalSuggestionSettings(next) + return next + }) + } + + const handleModelChange = (value: string) => { + if (value === DEFAULT_MODEL_VALUE) { + updateSettings({ modelSelection: null }) + return + } + const option = modelOptions.find((entry) => entry.selectionKey === value) + if (!option) return + const currentThinking = settings.modelSelection?.thinkingEffort ?? null + const nextThinking = + currentThinking && option.thinkingEffortOptions.includes(currentThinking) + ? currentThinking + : option.defaultThinkingEffort ?? null + updateSettings({ modelSelection: selectionFromOption(option, nextThinking) }) + } + + const handleThinkingChange = (value: string) => { + if (!selectedModel) return + const thinkingEffort = value as TerminalSuggestionModelSelection["thinkingEffort"] + if (!thinkingEffort || !selectedModel.thinkingEffortOptions.includes(thinkingEffort)) { + return + } + updateSettings({ + modelSelection: selectionFromOption(selectedModel, thinkingEffort), + }) + } + + return ( +
+ + +
+ updateSettings({ enabled: checked })} + /> + updateSettings({ aiEnabled: checked })} + /> +
+
+
+ Model + + Command autocomplete + +
+

+ When AI suggestions are enabled, terminal command suggestions always + use this model instead of the active chat model. +

+ {settings.modelSelection && !selectedModel ? ( +

+ Saved model unavailable: {formatStoredModelLabel(settings.modelSelection)} +

+ ) : null} +
+ + + + {modelOptions.length === 0 ? ( +

+ Configure a provider before choosing a dedicated suggestion model. +

+ ) : null} +
+
+
+ ) +} + +interface SettingRowProps { + badge: string + checked: boolean + className?: string + description: string + disabled?: boolean + label: string + onCheckedChange: (checked: boolean) => void +} + +function SettingRow({ + badge, + checked, + className, + description, + disabled = false, + label, + onCheckedChange, +}: SettingRowProps) { + return ( + + ) +} diff --git a/client/components/xero/settings-dialog/web-search-section.tsx b/client/components/xero/settings-dialog/web-search-section.tsx new file mode 100644 index 00000000..215977d9 --- /dev/null +++ b/client/components/xero/settings-dialog/web-search-section.tsx @@ -0,0 +1,955 @@ +import { + useCallback, + useEffect, + useMemo, + useState, + type Dispatch, + type ElementType, + type FormEvent, + type ReactNode, + type SetStateAction, +} from "react" +import { + AlertTriangle, + Check, + ChevronDown, + LoaderCircle, + RefreshCw, + Search, + Trash2, +} from "lucide-react" + +import { + BraveSearchIcon, + CustomEndpointIcon, + ExaIcon, + FirecrawlIcon, + GoogleIcon, + KagiIcon, + LinkupIcon, + SearchApiIcon, + SearxngIcon, + SerpApiIcon, + TavilyIcon, + YouComIcon, +} from "@/components/xero/brand-icons" +import type { XeroDesktopAdapter } from "@/src/lib/xero-desktop" +import type { + AutonomousWebSearchModeDto, + AutonomousWebSearchProviderKindMetadataDto, + AutonomousWebSearchProviderKindDto, + AutonomousWebSearchProviderProfileDto, + AutonomousWebSearchSettingsDto, + UpsertAutonomousWebSearchProviderRequestDto, +} from "@/src/lib/xero-model" +import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert" +import { Badge } from "@/components/ui/badge" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { Label } from "@/components/ui/label" +import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group" +import { Switch } from "@/components/ui/switch" +import { cn } from "@/lib/utils" +import { SectionHeader } from "./section-header" + +export type WebSearchSettingsAdapter = Pick< + XeroDesktopAdapter, + | "isDesktopRuntime" + | "autonomousWebSearchSettings" + | "autonomousWebSearchUpdateSettings" + | "autonomousWebSearchUpsertProvider" + | "autonomousWebSearchDeleteProvider" + | "autonomousWebSearchSetActiveProvider" + | "autonomousWebSearchCheckProvider" +> + +type LoadState = "idle" | "loading" | "ready" | "error" +type SaveState = "idle" | "saving" + +const FALLBACK_SETTINGS: AutonomousWebSearchSettingsDto = { + mode: "auto", + activeProviderId: null, + providers: [], + providerKinds: [], + providerManaged: { + modeAvailable: true, + status: "depends_on_selected_model", + message: "Provider-managed search is evaluated when a run starts with the selected provider and model.", + supportedSources: [], + }, + updatedAt: null, +} + +const MODE_OPTIONS: readonly { value: AutonomousWebSearchModeDto; label: string; summary: string }[] = [ + { value: "auto", label: "Auto", summary: "Provider-managed first, configured provider second." }, + { value: "provider_managed_only", label: "Provider-managed only", summary: "Use the selected model provider." }, + { value: "configured_provider_only", label: "Configured provider only", summary: "Use the active fallback provider." }, + { value: "disabled", label: "Disabled", summary: "Disable the web_search tool." }, +] + +const PROVIDER_ICON_BY_KIND: Record = { + custom_endpoint: CustomEndpointIcon, + brave_search: BraveSearchIcon, + tavily_search: TavilyIcon, + exa_search: ExaIcon, + firecrawl_search: FirecrawlIcon, + you_search: YouComIcon, + linkup_search: LinkupIcon, + kagi_search: KagiIcon, + searxng_json: SearxngIcon, + serpapi_google: SerpApiIcon, + searchapi_google: SearchApiIcon, + google_cse: GoogleIcon, +} + +interface ProviderFormState { + profileId: string | null + kind: AutonomousWebSearchProviderKindDto + endpoint: string + apiKey: string + googleCseCx: string +} + +const EMPTY_FORM: ProviderFormState = { + profileId: null, + kind: "brave_search", + endpoint: "", + apiKey: "", + googleCseCx: "", +} + +export function WebSearchSection({ adapter }: { adapter?: WebSearchSettingsAdapter }) { + const [settings, setSettings] = useState(FALLBACK_SETTINGS) + const [loadState, setLoadState] = useState("idle") + const [saveState, setSaveState] = useState("idle") + const [pendingProviderId, setPendingProviderId] = useState(null) + const [error, setError] = useState(null) + const [form, setForm] = useState(EMPTY_FORM) + const [openProviderKey, setOpenProviderKey] = useState(null) + + const canUseAdapter = Boolean( + adapter?.isDesktopRuntime?.() && + adapter.autonomousWebSearchSettings && + adapter.autonomousWebSearchUpdateSettings && + adapter.autonomousWebSearchUpsertProvider && + adapter.autonomousWebSearchDeleteProvider && + adapter.autonomousWebSearchSetActiveProvider && + adapter.autonomousWebSearchCheckProvider, + ) + + const kindMetadata = useMemo(() => { + const map = new Map(settings.providerKinds.map((kind) => [kind.kind, kind])) + return map + }, [settings.providerKinds]) + + const availableProviderKinds = useMemo(() => { + const configuredKinds = new Set(settings.providers.map((provider) => provider.kind)) + return settings.providerKinds.filter((kind) => !configuredKinds.has(kind.kind)) + }, [settings.providerKinds, settings.providers]) + + const load = useCallback(() => { + if (!canUseAdapter || !adapter?.autonomousWebSearchSettings) { + setSettings(FALLBACK_SETTINGS) + setLoadState("ready") + return + } + setLoadState("loading") + setError(null) + adapter + .autonomousWebSearchSettings() + .then((next) => { + setSettings(next) + setLoadState("ready") + }) + .catch((loadError) => { + setSettings(FALLBACK_SETTINGS) + setError(getErrorMessage(loadError, "Xero could not load Web Search settings.")) + setLoadState("error") + }) + }, [adapter, canUseAdapter]) + + useEffect(() => { + load() + }, [load]) + + const updateMode = useCallback( + (mode: AutonomousWebSearchModeDto) => { + if (!adapter?.autonomousWebSearchUpdateSettings || mode === settings.mode) return + const previous = settings + setSettings((current) => ({ ...current, mode })) + setSaveState("saving") + setError(null) + void adapter + .autonomousWebSearchUpdateSettings({ mode }) + .then(setSettings) + .catch((saveError) => { + setSettings(previous) + setError(getErrorMessage(saveError, "Xero could not save Web Search mode.")) + }) + .finally(() => setSaveState("idle")) + }, + [adapter, settings], + ) + + const submitProvider = useCallback( + async (event: FormEvent) => { + event.preventDefault() + if (!adapter?.autonomousWebSearchUpsertProvider) return + const metadata = kindMetadata.get(form.kind) + const request: UpsertAutonomousWebSearchProviderRequestDto = { + profileId: form.profileId, + kind: form.kind, + displayName: metadata?.label || form.kind, + endpoint: form.endpoint.trim() || null, + apiKey: form.apiKey.trim() || null, + googleCseCx: form.googleCseCx.trim() || null, + enabled: true, + } + + setSaveState("saving") + setPendingProviderId(form.profileId ?? form.kind) + setError(null) + try { + const next = await adapter.autonomousWebSearchUpsertProvider(request) + setSettings(next) + setForm(EMPTY_FORM) + setOpenProviderKey(null) + } catch (saveError) { + setError(getErrorMessage(saveError, "Xero could not save the web-search provider.")) + } finally { + setSaveState("idle") + setPendingProviderId(null) + } + }, + [adapter, form, kindMetadata], + ) + + const updateProviderEnabled = useCallback( + async (provider: AutonomousWebSearchProviderProfileDto, enabled: boolean) => { + if (!adapter?.autonomousWebSearchUpsertProvider) return + setPendingProviderId(provider.profileId) + setError(null) + try { + const next = await adapter.autonomousWebSearchUpsertProvider({ + profileId: provider.profileId, + kind: provider.kind, + enabled, + }) + setSettings(next) + } catch (saveError) { + setError(getErrorMessage(saveError, "Xero could not update the web-search provider.")) + } finally { + setPendingProviderId(null) + } + }, + [adapter], + ) + + const setActive = useCallback( + async (providerId: string) => { + if (!adapter?.autonomousWebSearchSetActiveProvider) return + setPendingProviderId(providerId) + setError(null) + try { + const next = await adapter.autonomousWebSearchSetActiveProvider({ providerId }) + setSettings(next) + } catch (saveError) { + setError(getErrorMessage(saveError, "Xero could not select the active web-search provider.")) + } finally { + setPendingProviderId(null) + } + }, + [adapter], + ) + + const checkProvider = useCallback( + async (providerId: string) => { + if (!adapter?.autonomousWebSearchCheckProvider) return + setPendingProviderId(providerId) + setError(null) + try { + const next = await adapter.autonomousWebSearchCheckProvider({ providerId }) + setSettings(next) + } catch (checkError) { + setError(getErrorMessage(checkError, "Xero could not test the web-search provider.")) + } finally { + setPendingProviderId(null) + } + }, + [adapter], + ) + + const deleteProvider = useCallback( + async (providerId: string) => { + if (!adapter?.autonomousWebSearchDeleteProvider) return + setPendingProviderId(providerId) + setError(null) + try { + const next = await adapter.autonomousWebSearchDeleteProvider({ providerId }) + setSettings(next) + } catch (deleteError) { + setError(getErrorMessage(deleteError, "Xero could not delete the web-search provider.")) + } finally { + setPendingProviderId(null) + } + }, + [adapter], + ) + + const configureProviderKind = useCallback((metadata: AutonomousWebSearchProviderKindMetadataDto) => { + const key = providerKindKey(metadata.kind) + if (openProviderKey === key) { + setOpenProviderKey(null) + setForm(EMPTY_FORM) + return + } + setError(null) + setOpenProviderKey(key) + setForm({ + profileId: null, + kind: metadata.kind, + endpoint: "", + apiKey: "", + googleCseCx: "", + }) + }, [openProviderKey]) + + const editProvider = useCallback((provider: AutonomousWebSearchProviderProfileDto) => { + const key = providerProfileKey(provider.profileId) + if (openProviderKey === key) { + setOpenProviderKey(null) + setForm(EMPTY_FORM) + return + } + setError(null) + setOpenProviderKey(key) + setForm({ + profileId: provider.profileId, + kind: provider.kind, + endpoint: provider.endpoint ?? provider.baseUrl ?? "", + apiKey: "", + googleCseCx: provider.googleCseCx ?? "", + }) + }, [openProviderKey]) + + const isBusy = loadState === "loading" || saveState === "saving" || pendingProviderId !== null + + return ( +
+ + {loadState === "loading" ? ( + + ) : ( + + )} + Refresh + + } + /> + + {!canUseAdapter ? ( + + ) : ( + <> + {error ? ( + + + Web Search needs attention + {error} + + ) : null} + + + + + + + + )} +
+ ) +} + +function ModePanel({ + value, + disabled, + saving, + onChange, +}: { + value: AutonomousWebSearchModeDto + disabled: boolean + saving: boolean + onChange: (value: AutonomousWebSearchModeDto) => void +}) { + return ( +
+
+

Mode

+ {saving ? ( + + + Saving + + ) : null} +
+ onChange(next as AutonomousWebSearchModeDto)} + className="grid gap-2 sm:grid-cols-2" + > + {MODE_OPTIONS.map((option) => ( + + ))} + +
+ ) +} + +function ProviderManagedPanel({ settings }: { settings: AutonomousWebSearchSettingsDto }) { + return ( +
+
+

Provider-managed search

+

{settings.providerManaged.message}

+
+ + {formatStatus(settings.providerManaged.status)} + +
+ ) +} + +function ProviderCards({ + providers, + availableProviderKinds, + kindMetadata, + activeProviderId, + pendingProviderId, + openProviderKey, + form, + isBusy, + isSaving, + onEdit, + onConfigureKind, + onFormChange, + onSubmit, + onEnabledChange, + onSetActive, + onCheck, + onDelete, +}: { + providers: AutonomousWebSearchProviderProfileDto[] + availableProviderKinds: AutonomousWebSearchProviderKindMetadataDto[] + kindMetadata: Map + activeProviderId: string | null + pendingProviderId: string | null + openProviderKey: string | null + form: ProviderFormState + isBusy: boolean + isSaving: boolean + onEdit: (provider: AutonomousWebSearchProviderProfileDto) => void + onConfigureKind: (metadata: AutonomousWebSearchProviderKindMetadataDto) => void + onFormChange: Dispatch> + onSubmit: (event: FormEvent) => void + onEnabledChange: (provider: AutonomousWebSearchProviderProfileDto, enabled: boolean) => void + onSetActive: (providerId: string) => void + onCheck: (providerId: string) => void + onDelete: (providerId: string) => void +}) { + return ( +
+ {providers.length > 0 ? ( + + {providers.map((provider) => { + const metadata = kindMetadata.get(provider.kind) + const isOpen = openProviderKey === providerProfileKey(provider.profileId) + return ( + + ) + })} + + ) : null} + + 0 ? "Available" : "All providers"} + count={availableProviderKinds.length} + > + {availableProviderKinds.map((metadata) => ( + + ))} + +
+ ) +} + +function ConfiguredProviderCard({ + provider, + metadata, + active, + pending, + isOpen, + form, + isBusy, + isSaving, + onEdit, + onFormChange, + onSubmit, + onEnabledChange, + onSetActive, + onCheck, + onDelete, +}: { + provider: AutonomousWebSearchProviderProfileDto + metadata?: AutonomousWebSearchProviderKindMetadataDto + active: boolean + pending: boolean + isOpen: boolean + form: ProviderFormState + isBusy: boolean + isSaving: boolean + onEdit: (provider: AutonomousWebSearchProviderProfileDto) => void + onFormChange: Dispatch> + onSubmit: (event: FormEvent) => void + onEnabledChange: (provider: AutonomousWebSearchProviderProfileDto, enabled: boolean) => void + onSetActive: (providerId: string) => void + onCheck: (providerId: string) => void + onDelete: (providerId: string) => void +}) { + const Icon = PROVIDER_ICON_BY_KIND[provider.kind] + const status = configuredProviderStatus(provider, active) + const detail = providerDetail(provider, metadata) + const providerLabel = metadata?.label ?? provider.displayName + + return ( +
+
+ + +
+ {providerLabel} + + + {status.label} + {detail ? - {detail} : null} + +
+ +
+ {pending ? : null} + {!active && provider.readiness.ready ? ( + + ) : null} + + +
+
+ + {!provider.readiness.ready ? ( +
+ {provider.readiness.message} +
+ ) : null} + + {isOpen ? ( + onDelete(provider.profileId)} + > + + Remove + + } + footerMiddle={ +
+ onEnabledChange(provider, checked)} + /> + Enabled +
+ } + /> + ) : null} +
+ ) +} + +function AvailableProviderCard({ + metadata, + isOpen, + form, + pending, + isBusy, + isSaving, + onConfigure, + onFormChange, + onSubmit, +}: { + metadata: AutonomousWebSearchProviderKindMetadataDto + isOpen: boolean + form: ProviderFormState + pending: boolean + isBusy: boolean + isSaving: boolean + onConfigure: (metadata: AutonomousWebSearchProviderKindMetadataDto) => void + onFormChange: Dispatch> + onSubmit: (event: FormEvent) => void +}) { + const Icon = PROVIDER_ICON_BY_KIND[metadata.kind] + + return ( +
+
+ +
+ {metadata.label} + + {providerKindDetail(metadata)} + +
+
+ {pending ? : null} + +
+
+ + {isOpen ? ( + + ) : null} +
+ ) +} + +function ProviderEditor({ + form, + metadata, + provider, + isBusy, + isSaving, + footerLeft, + footerMiddle, + onFormChange, + onSubmit, +}: { + form: ProviderFormState + metadata?: AutonomousWebSearchProviderKindMetadataDto + provider?: AutonomousWebSearchProviderProfileDto + isBusy: boolean + isSaving: boolean + footerLeft?: ReactNode + footerMiddle?: ReactNode + onFormChange: Dispatch> + onSubmit: (event: FormEvent) => void +}) { + const shouldShowEndpoint = Boolean(metadata?.requiresEndpoint) + const shouldShowSearchEngineId = Boolean(metadata?.requiresGoogleCseCx) + const shouldShowApiKey = Boolean(metadata?.requiresApiKey || form.kind === "custom_endpoint" || form.kind === "searxng_json") + const editorFieldCount = [shouldShowEndpoint, shouldShowSearchEngineId, shouldShowApiKey].filter(Boolean).length + + return ( + +
+
1 && "sm:grid-cols-2")}> + {shouldShowEndpoint ? ( + + onFormChange((current) => ({ ...current, endpoint: event.target.value }))} + /> + + ) : null} + {shouldShowSearchEngineId ? ( + + onFormChange((current) => ({ ...current, googleCseCx: event.target.value }))} + /> + + ) : null} + {shouldShowApiKey ? ( + + onFormChange((current) => ({ ...current, apiKey: event.target.value }))} + /> + + ) : null} +
+ +
+ {footerLeft} + {footerMiddle} + +
+
+ + ) +} + +function ProviderGroup({ + title, + count, + children, +}: { + title: string + count: number + children: ReactNode +}) { + return ( +
+
+

+ {title} +

+ {count} +
+ {count > 0 ? ( +
{children}
+ ) : ( +
+ No providers available. +
+ )} +
+ ) +} + +function ProviderIcon({ icon: Icon }: { icon: ElementType }) { + return ( +
+ +
+ ) +} + +function Field({ label, children, className }: { label: string; children: ReactNode; className?: string }) { + return ( +
+ + {children} +
+ ) +} + +function UnavailableCard() { + return ( +
+ Web Search settings are available in the desktop app. +
+ ) +} + +function providerProfileKey(profileId: string): string { + return `profile:${profileId}` +} + +function providerKindKey(kind: AutonomousWebSearchProviderKindDto): string { + return `kind:${kind}` +} + +function configuredProviderStatus( + provider: AutonomousWebSearchProviderProfileDto, + active: boolean, +): { label: string; dotClassName: string } { + if (!provider.enabled) { + return { label: "Disabled", dotClassName: "bg-muted-foreground/40" } + } + if (active) { + return { label: "Active", dotClassName: "bg-success dark:bg-success" } + } + if (provider.readiness.ready) { + return { label: "Ready", dotClassName: "bg-success dark:bg-success" } + } + return { label: formatStatus(provider.readiness.status), dotClassName: "bg-warning dark:bg-warning" } +} + +function providerDetail( + provider: AutonomousWebSearchProviderProfileDto, + metadata?: AutonomousWebSearchProviderKindMetadataDto, +): string | null { + if (provider.lastCheck) { + return `last test ${provider.lastCheck.sampleResultCount} results, ${provider.lastCheck.latencyMs}ms` + } + const endpoint = provider.endpoint ?? provider.baseUrl + if (endpoint) return endpoint.replace(/^https?:\/\//, "") + return metadata?.label ?? formatStatus(provider.kind) +} + +function providerKindDetail(metadata: AutonomousWebSearchProviderKindMetadataDto): string { + const parts: string[] = [] + if (metadata.requiresApiKey) parts.push("API key") + if (metadata.requiresEndpoint) parts.push("endpoint") + if (metadata.requiresGoogleCseCx) parts.push("search engine id") + if (metadata.selfHosted) parts.push("self-hosted") + return parts.length > 0 ? parts.join(" - ") : "no key required" +} + +function formatStatus(status: string): string { + return status.split("_").join(" ") +} + +function getErrorMessage(error: unknown, fallback: string): string { + if (error && typeof error === "object" && "message" in error) { + const message = String((error as { message?: unknown }).message ?? "").trim() + if (message) return message + } + return fallback +} diff --git a/client/components/xero/shell.tsx b/client/components/xero/shell.tsx index bb3227a4..6c4ddc28 100644 --- a/client/components/xero/shell.tsx +++ b/client/components/xero/shell.tsx @@ -649,86 +649,104 @@ export function XeroShell({ ) const hasVcsLineChanges = vcsAdditions > 0 || vcsDeletions > 0 + const vcsTooltip = + vcsChangeCount > 0 + ? `${vcsChangeCount} file${vcsChangeCount === 1 ? "" : "s"} changed · +${vcsAdditions} −${vcsDeletions}` + : "Source control" const VcsBtn = ( - + + {hasVcsLineChanges ? ( + + ) : vcsChangeCount > 0 ? ( + + ) : null} + + + {vcsTooltip} + ) const WorkflowsBtn = ( - + + + + + Workflows + ) + const agentDockTooltip = agentDockDisabled ? "Already in Agent view" : "Agent" const AgentDockBtn = ( - + + + + + + + {agentDockTooltip} + ) const ComputerUseBtn = onToggleComputerUse ? ( @@ -940,6 +958,8 @@ export function XeroShell({ git={footer?.git} spend={footer?.spend} notifications={footer?.notifications} + notificationsActive={footer?.notificationsActive} + onNotificationsClick={footer?.onNotificationsClick} spendActive={footer?.spendActive} onSpendClick={footer?.onSpendClick} /> diff --git a/client/components/xero/sign-in-reminder-toast.test.tsx b/client/components/xero/sign-in-reminder-toast.test.tsx index d8c38f27..62d9b542 100644 --- a/client/components/xero/sign-in-reminder-toast.test.tsx +++ b/client/components/xero/sign-in-reminder-toast.test.tsx @@ -9,6 +9,7 @@ import { SignInReminderToast } from './sign-in-reminder-toast' interface ToastArgs { title?: string description?: string + duration?: number action?: ReactElement<{ onClick: () => void }> } @@ -72,12 +73,35 @@ describe('SignInReminderToast', () => { setAuth('idle') rerender() expect(toastMock).toHaveBeenCalledTimes(1) + expect(toastMock.mock.calls[0]?.[0]?.duration).toBe(5_000) // A subsequent re-render must not re-fire the nudge. rerender() expect(toastMock).toHaveBeenCalledTimes(1) }) + it('waits until enabled before nudging after a signed-out load', () => { + const { rerender } = render() + expect(toastMock).not.toHaveBeenCalled() + + setAuth('idle') + rerender() + expect(toastMock).not.toHaveBeenCalled() + + rerender() + expect(toastMock).toHaveBeenCalledTimes(1) + }) + + it('dismisses an open nudge when disabled', () => { + const { rerender } = render() + setAuth('idle') + rerender() + expect(toastMock).toHaveBeenCalledTimes(1) + + rerender() + expect(dismissMock).toHaveBeenCalledTimes(1) + }) + it('triggers GitHub login from the toast action', () => { const { rerender } = render() setAuth('idle') diff --git a/client/components/xero/sign-in-reminder-toast.tsx b/client/components/xero/sign-in-reminder-toast.tsx index 48c0d08b..6fa7c330 100644 --- a/client/components/xero/sign-in-reminder-toast.tsx +++ b/client/components/xero/sign-in-reminder-toast.tsx @@ -4,6 +4,12 @@ import { useEffect, useRef } from 'react' import { useGitHubAuth } from '@/src/lib/github-auth' +interface SignInReminderToastProps { + enabled?: boolean +} + +const SIGN_IN_REMINDER_TOAST_DURATION_MS = 5_000 + /** * One-time, per-launch nudge: when the desktop app opens with no GitHub * account linked, remind the user that signing in lets them drive these @@ -14,13 +20,21 @@ import { useGitHubAuth } from '@/src/lib/github-auth' * observed a load — otherwise the nudge would flash on every launch before * the real session is known. */ -export function SignInReminderToast() { +export function SignInReminderToast({ enabled = true }: SignInReminderToastProps) { const { status, session, login } = useGitHubAuth() const observedLoadRef = useRef(false) const shownRef = useRef(false) const toastRef = useRef | null>(null) useEffect(() => { + if (!enabled) { + if (status === 'loading' || status === 'authenticating') { + observedLoadRef.current = true + } + toastRef.current?.dismiss() + toastRef.current = null + return + } if (status === 'loading' || status === 'authenticating') { observedLoadRef.current = true return @@ -42,8 +56,7 @@ export function SignInReminderToast() { title: 'Sign in to continue from anywhere', description: 'Sign in with GitHub to drive your sessions from the cloud app on any device.', - // Hold until the user acts on or dismisses the nudge. - duration: Number.POSITIVE_INFINITY, + duration: SIGN_IN_REMINDER_TOAST_DURATION_MS, // Stack the action under the text (full-width description) instead of // reserving a wide right column, then tuck the button up into the // trailing whitespace of the last line. @@ -58,7 +71,7 @@ export function SignInReminderToast() { ), }) - }, [status, session, login]) + }, [enabled, status, session, login]) return null } diff --git a/client/components/xero/solana-panel-actions.test.tsx b/client/components/xero/solana-panel-actions.test.tsx new file mode 100644 index 00000000..1d014821 --- /dev/null +++ b/client/components/xero/solana-panel-actions.test.tsx @@ -0,0 +1,231 @@ +/** @vitest-environment jsdom */ + +import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react" +import { afterEach, describe, expect, it, vi } from "vitest" +import { SolanaPersonaPanel } from "./solana-persona-panel" +import { SolanaSafetyPanel } from "./solana-safety-panel" +import { SolanaScenarioPanel } from "./solana-scenario-panel" +import { SolanaTxInspector } from "./solana-tx-inspector" +import type { + ClusterKind, + FundingReceipt, + Persona, + RoleDescriptor, + ScenarioDescriptor, +} from "@/src/features/solana/use-solana-workbench" + +afterEach(() => { + cleanup() + vi.restoreAllMocks() +}) + +const cluster: ClusterKind = "localnet" + +const roles: RoleDescriptor[] = [ + { + id: "whale", + preset: { + displayLabel: "Whale", + description: "Large localnet wallet", + lamports: 1_000_000_000, + tokens: [], + nfts: [], + }, + }, +] + +const personas: Persona[] = [ + { + name: "alice", + role: "whale", + cluster, + pubkey: "Alice1111111111111111111111111111111111111", + keypairPath: "/tmp/alice.json", + createdAtMs: 1, + seed: { solLamports: 1_000_000_000, tokens: [], nfts: [] }, + }, +] + +const receipt: FundingReceipt = { + persona: "alice", + cluster, + steps: [], + succeeded: true, + startedAtMs: 1, + finishedAtMs: 2, +} + +describe("Solana panel actions", () => { + it("trims persona create arguments before invoking the workbench handler", async () => { + const onCreate = vi.fn(async () => receipt) + + render( + , + ) + + fireEvent.change(screen.getByLabelText("Persona name"), { + target: { value: " alice " }, + }) + fireEvent.change(screen.getByLabelText("Persona note"), { + target: { value: " local whale " }, + }) + fireEvent.click(screen.getByRole("button", { name: /Create \+ fund/i })) + + await waitFor(() => { + expect(onCreate).toHaveBeenCalledWith("alice", "whale", "local whale") + }) + }) + + it("dispatches the selected scenario with the active cluster and persona", async () => { + const onRunScenario = vi.fn(async () => ({ + id: "seed-liquidity", + cluster, + persona: "alice", + status: "succeeded" as const, + signatures: [], + steps: [], + fundingReceipts: [], + startedAtMs: 1, + finishedAtMs: 2, + })) + const scenarios: ScenarioDescriptor[] = [ + { + id: "seed-liquidity", + label: "Seed liquidity", + description: "Seed a local pool", + supportedClusters: [cluster], + requiredClonePrograms: [], + requiredRoles: ["whale"], + kind: "self_contained", + }, + ] + + render( + , + ) + + fireEvent.click(screen.getByRole("button", { name: /Run scenario/i })) + + await waitFor(() => { + expect(onRunScenario).toHaveBeenCalledWith({ + id: "seed-liquidity", + cluster, + persona: "alice", + params: {}, + }) + }) + }) + + it("trims simulation bytes and splits priority-fee program ids", async () => { + const onSimulate = vi.fn(async () => null) + const onEstimateFee = vi.fn(async () => ({ + samples: [], + percentiles: [], + recommendedMicroLamports: 0, + recommendedPercentile: "median" as const, + programIds: [], + source: "fixture", + })) + + render( + , + ) + + fireEvent.change(screen.getByPlaceholderText("AQABAv..."), { + target: { value: " AQ== " }, + }) + fireEvent.click(screen.getByRole("button", { name: /^Simulate$/i })) + + await waitFor(() => { + expect(onSimulate).toHaveBeenCalledWith({ + cluster, + transactionBase64: "AQ==", + skipReplaceBlockhash: false, + }) + }) + + fireEvent.click(screen.getByRole("tab", { name: /Priority fee/i })) + fireEvent.change(screen.getByPlaceholderText("JUP6Lkb..., whirLb..."), { + target: { + value: + " JUP6Lkb1111111111111111111111111111111111,\n whirLb2222222222222222222222222222222222 ", + }, + }) + fireEvent.click(screen.getByRole("button", { name: /^Estimate$/i })) + + await waitFor(() => { + expect(onEstimateFee).toHaveBeenCalledWith([ + "JUP6Lkb1111111111111111111111111111111111", + "whirLb2222222222222222222222222222222222", + ]) + }) + }) + + it("trims safety scan project roots and keeps empty severity nullable", async () => { + const onScanSecrets = vi.fn(async () => ({ + projectRoot: "/tmp/fixture", + filesScanned: 1, + filesSkipped: 0, + durationMs: 1, + findings: [], + blocksDeploy: false, + patternsApplied: 1, + })) + + render( + , + ) + + fireEvent.change(screen.getByPlaceholderText("/absolute/path/to/project"), { + target: { value: " /tmp/fixture " }, + }) + fireEvent.click(screen.getByRole("button", { name: /^Scan$/i })) + + await waitFor(() => { + expect(onScanSecrets).toHaveBeenCalledWith({ + projectRoot: "/tmp/fixture", + minSeverity: null, + }) + }) + }) +}) diff --git a/client/components/xero/solana-workbench-sidebar.test.tsx b/client/components/xero/solana-workbench-sidebar.test.tsx index ed524a5f..4840337d 100644 --- a/client/components/xero/solana-workbench-sidebar.test.tsx +++ b/client/components/xero/solana-workbench-sidebar.test.tsx @@ -247,6 +247,7 @@ function registerDefaultSolanaResponses() { registerInvoke("solana_secrets_patterns", () => []) registerInvoke("solana_cluster_drift_tracked_programs", () => []) registerInvoke("solana_doc_catalog", () => []) + registerInvoke("solana_rpc_health", () => []) registerInvoke("solana_subscribe_ready", () => undefined) } @@ -468,4 +469,14 @@ describe("SolanaWorkbenchSidebar", () => { expect(personaListCalls).toBe(1) }) + + it("keeps RPC provider configuration out of the sidebar", async () => { + render() + + fireEvent.click(await screen.findByRole("tab", { name: "RPC" })) + + expect(await screen.findByText("RPC endpoints")).toBeVisible() + expect(screen.queryByText("Provider profiles")).not.toBeInTheDocument() + expect(screen.queryByPlaceholderText("helius-devnet")).not.toBeInTheDocument() + }) }) diff --git a/client/components/xero/solana-workbench-sidebar.tsx b/client/components/xero/solana-workbench-sidebar.tsx index a358561f..e06b8ad6 100644 --- a/client/components/xero/solana-workbench-sidebar.tsx +++ b/client/components/xero/solana-workbench-sidebar.tsx @@ -1547,7 +1547,7 @@ function RpcEndpoints({ void runProbe()} - className="inline-flex h-7 items-center gap-1 rounded-md border border-border/70 bg-background/40 px-2 text-[11px] text-foreground/85 transition-colors hover:border-primary/40 hover:text-foreground disabled:opacity-50" + className="inline-flex h-7 items-center gap-1 rounded-md border border-border/70 px-2 text-[11px] text-muted-foreground hover:bg-secondary/60 hover:text-foreground disabled:opacity-60" > {probing ? "Probing" : "Probe"} diff --git a/client/components/xero/source-control-settings.ts b/client/components/xero/source-control-settings.ts new file mode 100644 index 00000000..2a04b769 --- /dev/null +++ b/client/components/xero/source-control-settings.ts @@ -0,0 +1,127 @@ +"use client" + +export const SOURCE_CONTROL_SETTINGS_KEY = "xero.sourceControl.settings.v1" +export const SOURCE_CONTROL_SETTINGS_UPDATED_EVENT = + "xero:source-control-settings-updated" + +export type SourceControlThinkingEffort = + | "none" + | "minimal" + | "low" + | "medium" + | "high" + | "x_high" + +export interface SourceControlModelSelection { + providerId?: string | null + providerProfileId?: string | null + modelId?: string | null + thinkingEffort?: SourceControlThinkingEffort | null +} + +export interface SourceControlSettings { + commitMessageModelSelection: SourceControlModelSelection | null +} + +export const DEFAULT_SOURCE_CONTROL_SETTINGS: SourceControlSettings = { + commitMessageModelSelection: null, +} + +const THINKING_EFFORT_VALUES = new Set([ + "none", + "minimal", + "low", + "medium", + "high", + "x_high", +]) + +function compactString(value: unknown): string | null { + return typeof value === "string" && value.trim().length > 0 ? value.trim() : null +} + +function normalizeThinkingEffort(value: unknown): SourceControlThinkingEffort | null { + return typeof value === "string" && + THINKING_EFFORT_VALUES.has(value as SourceControlThinkingEffort) + ? (value as SourceControlThinkingEffort) + : null +} + +export function normalizeSourceControlModelSelection( + value: unknown, +): SourceControlModelSelection | null { + if (!value || typeof value !== "object") return null + const candidate = value as Record + const modelId = compactString(candidate.modelId) + if (!modelId) return null + return { + providerId: compactString(candidate.providerId), + providerProfileId: compactString(candidate.providerProfileId), + modelId, + thinkingEffort: normalizeThinkingEffort(candidate.thinkingEffort), + } +} + +export function normalizeSourceControlSettings(value: unknown): SourceControlSettings { + if (!value || typeof value !== "object") { + return { ...DEFAULT_SOURCE_CONTROL_SETTINGS } + } + const candidate = value as Record + return { + commitMessageModelSelection: normalizeSourceControlModelSelection( + candidate.commitMessageModelSelection, + ), + } +} + +export function loadSourceControlSettings(): SourceControlSettings { + if (typeof window === "undefined") { + return { ...DEFAULT_SOURCE_CONTROL_SETTINGS } + } + try { + const stored = window.localStorage.getItem(SOURCE_CONTROL_SETTINGS_KEY) + return normalizeSourceControlSettings(JSON.parse(stored ?? "null")) + } catch { + return { ...DEFAULT_SOURCE_CONTROL_SETTINGS } + } +} + +export function persistSourceControlSettings(settings: SourceControlSettings): void { + if (typeof window === "undefined") return + const normalized = normalizeSourceControlSettings(settings) + try { + window.localStorage.setItem(SOURCE_CONTROL_SETTINGS_KEY, JSON.stringify(normalized)) + } catch { + // Best effort; the current view can still use the in-memory settings. + } + window.dispatchEvent( + new CustomEvent(SOURCE_CONTROL_SETTINGS_UPDATED_EVENT, { + detail: normalized, + }), + ) +} + +export function subscribeSourceControlSettings( + listener: (settings: SourceControlSettings) => void, +): () => void { + if (typeof window === "undefined") return () => undefined + + const handleCustomEvent = (event: Event) => { + listener( + normalizeSourceControlSettings( + (event as CustomEvent).detail, + ), + ) + } + const handleStorageEvent = (event: StorageEvent) => { + if (event.key !== SOURCE_CONTROL_SETTINGS_KEY) return + listener(loadSourceControlSettings()) + } + + window.addEventListener(SOURCE_CONTROL_SETTINGS_UPDATED_EVENT, handleCustomEvent) + window.addEventListener("storage", handleStorageEvent) + return () => { + window.removeEventListener(SOURCE_CONTROL_SETTINGS_UPDATED_EVENT, handleCustomEvent) + window.removeEventListener("storage", handleStorageEvent) + } +} diff --git a/client/components/xero/start-targets-dialog.tsx b/client/components/xero/start-targets-dialog.tsx index eb16835c..6dc8edbe 100644 --- a/client/components/xero/start-targets-dialog.tsx +++ b/client/components/xero/start-targets-dialog.tsx @@ -10,14 +10,12 @@ import { } from "@/components/ui/dialog" import { StartTargetsEditor, - type StartTargetsModelOption, type StartTargetsSuggestRequest, type SuggestedTarget, } from "@/components/xero/start-targets-editor" import type { StartTargetDto, StartTargetInputDto } from "@/src/lib/xero-desktop" export type StartTargetsDialogSuggestRequest = StartTargetsSuggestRequest -export type StartTargetsDialogModelOption = StartTargetsModelOption interface StartTargetsDialogProps { open: boolean @@ -29,7 +27,6 @@ interface StartTargetsDialogProps { onSuggest?: ( request: StartTargetsSuggestRequest, ) => Promise<{ targets: SuggestedTarget[] }> - modelOptions?: StartTargetsModelOption[] } export function StartTargetsDialog({ @@ -40,7 +37,6 @@ export function StartTargetsDialog({ onSubmit, resolveSuggestRequest, onSuggest, - modelOptions, }: StartTargetsDialogProps) { return ( onOpenChange(false)} resolveSuggestRequest={resolveSuggestRequest} onSuggest={onSuggest} - modelOptions={modelOptions} + showModelSelector={false} />
diff --git a/client/components/xero/start-targets-editor.test.tsx b/client/components/xero/start-targets-editor.test.tsx index 98c5a85d..b8e31a7a 100644 --- a/client/components/xero/start-targets-editor.test.tsx +++ b/client/components/xero/start-targets-editor.test.tsx @@ -150,7 +150,7 @@ describe('StartTargetsEditor', () => { expect(screen.getByRole('switch', { name: 'Target 2 browser supported' })).not.toBeChecked() }) - it('shows the AI model and sends the selected model/provider route', async () => { + it('shows the AI model and sends the selected model/provider route and thinking level', async () => { const onSave = vi.fn().mockResolvedValue(undefined) const onSuggest = vi.fn().mockResolvedValue({ targets: [{ name: 'web', command: 'pnpm dev' }], @@ -172,7 +172,10 @@ describe('StartTargetsEditor', () => { />, ) - expect(screen.getByText('xAI / Grok · Grok 4.3')).toBeInTheDocument() + expect(screen.getByText('AI model')).toBeInTheDocument() + expect( + screen.getByRole('combobox', { name: 'AI suggestion model' }), + ).toHaveTextContent('Grok 4.3') ensurePointerCaptureApi() fireEvent.pointerDown(screen.getByRole('combobox', { name: 'AI suggestion model' }), { @@ -181,6 +184,9 @@ describe('StartTargetsEditor', () => { pointerType: 'mouse', }) fireEvent.click(await screen.findByRole('option', { name: 'GPT-5.4' })) + const thinkingItem = screen.getByRole('menuitem', { name: /Thinking/i }) + fireEvent.keyDown(thinkingItem, { key: 'ArrowRight' }) + fireEvent.click(screen.getByRole('menuitemradio', { name: 'High' })) fireEvent.click(screen.getByRole('button', { name: /Suggest with AI/i })) @@ -190,10 +196,44 @@ describe('StartTargetsEditor', () => { providerId: 'openai_codex', providerProfileId: 'openai_codex-default', runtimeAgentId: 'ask', - thinkingEffort: 'medium', + thinkingEffort: 'high', }) }) + it('can hide the AI model selector while still using the resolved fallback request', async () => { + const onSave = vi.fn().mockResolvedValue(undefined) + const onSuggest = vi.fn().mockResolvedValue({ + targets: [{ name: 'web', command: 'pnpm dev' }], + }) + const fallbackRequest = { + modelId: 'grok-4.3-latest', + providerId: 'xai', + providerProfileId: 'xai-default', + runtimeAgentId: 'ask', + thinkingEffort: 'low', + } as const + + render( + fallbackRequest} + onSuggest={onSuggest} + modelOptions={[...modelOptions]} + showModelSelector={false} + />, + ) + + expect(screen.queryByText('AI model')).not.toBeInTheDocument() + expect( + screen.queryByRole('combobox', { name: 'AI suggestion model' }), + ).not.toBeInTheDocument() + + fireEvent.click(screen.getByRole('button', { name: /Suggest with AI/i })) + + await waitFor(() => expect(onSuggest).toHaveBeenCalledWith(fallbackRequest)) + }) + it('asks before replacing user-entered rows with AI suggestion', async () => { const onSave = vi.fn().mockResolvedValue(undefined) const onSuggest = vi.fn().mockResolvedValue({ diff --git a/client/components/xero/start-targets-editor.tsx b/client/components/xero/start-targets-editor.tsx index ad3913bb..43fcb634 100644 --- a/client/components/xero/start-targets-editor.tsx +++ b/client/components/xero/start-targets-editor.tsx @@ -3,24 +3,21 @@ import { useEffect, useMemo, useState } from "react" import { Globe2, Loader2, Plus, Save, Sparkles, Trash2 } from "lucide-react" import { BaseAlertDialog } from "@xero/ui/components/base-dialog" +import { + ModelThinkingSelect, + type ModelThinkingSelectGroup, + type ModelThinkingSelectOption, +} from "@xero/ui/components/model-thinking-select" import { Button } from "@/components/ui/button" import { Input } from "@/components/ui/input" import { Switch } from "@/components/ui/switch" -import { - Select, - SelectContent, - SelectGroup, - SelectItem, - SelectLabel, - SelectTrigger, - SelectValue, -} from "@/components/ui/select" import { cn } from "@/lib/utils" import type { StartTargetDto, StartTargetInputDto, } from "@/src/lib/xero-desktop" +import { getProviderModelThinkingEffortLabel } from "@/src/lib/xero-model" import type { RuntimeAgentIdDto } from "@/src/lib/xero-model/runtime" export interface StartTargetsSuggestRequest { @@ -72,6 +69,7 @@ interface StartTargetsEditorProps { request: StartTargetsSuggestRequest, ) => Promise<{ targets: SuggestedTarget[] }> modelOptions?: StartTargetsModelOption[] + showModelSelector?: boolean onSaved?: () => void } @@ -180,6 +178,18 @@ function resolveInitialModelSelectionKey( return findModelForRequest(options, request)?.selectionKey ?? options[0]?.selectionKey ?? null } +function resolveInitialThinkingEffort( + options: readonly StartTargetsModelOption[], + request: StartTargetsSuggestRequest | null, +): StartTargetsSuggestRequest["thinkingEffort"] { + const option = findModelForRequest(options, request) ?? options[0] ?? null + if (!option) return request?.thinkingEffort ?? null + return normalizeThinkingEffortForModel( + option, + request?.thinkingEffort ?? option.defaultThinkingEffort ?? null, + ) +} + function normalizeThinkingEffortForModel( option: StartTargetsModelOption, thinkingEffort: StartTargetsSuggestRequest["thinkingEffort"], @@ -193,6 +203,7 @@ function normalizeThinkingEffortForModel( function requestWithModelOption( request: StartTargetsSuggestRequest | null, option: StartTargetsModelOption | null, + thinkingEffort: StartTargetsSuggestRequest["thinkingEffort"], ): StartTargetsSuggestRequest | null { if (!option) return request return { @@ -202,20 +213,11 @@ function requestWithModelOption( runtimeAgentId: request?.runtimeAgentId ?? null, thinkingEffort: normalizeThinkingEffortForModel( option, - request?.thinkingEffort ?? option.defaultThinkingEffort ?? null, + thinkingEffort ?? request?.thinkingEffort ?? option.defaultThinkingEffort ?? null, ), } } -function formatRequestModelLabel( - request: StartTargetsSuggestRequest | null, -): string { - const modelId = request?.modelId.trim() ?? "" - if (!modelId) return "Provider default" - const provider = request?.providerId?.trim() || request?.providerProfileId?.trim() - return provider ? `${provider} · ${modelId}` : modelId -} - export function StartTargetsEditor({ initialTargets, saveLabel = "Save", @@ -226,6 +228,7 @@ export function StartTargetsEditor({ resolveSuggestRequest, onSuggest, modelOptions = [], + showModelSelector = true, onSaved, }: StartTargetsEditorProps) { const [rows, setRows] = useState(() => @@ -235,6 +238,9 @@ export function StartTargetsEditor({ const [selectedModelSelectionKey, setSelectedModelSelectionKey] = useState< string | null >(() => resolveInitialModelSelectionKey(modelOptions, initialSuggestRequest)) + const [selectedThinkingEffort, setSelectedThinkingEffort] = useState< + StartTargetsSuggestRequest["thinkingEffort"] + >(() => resolveInitialThinkingEffort(modelOptions, initialSuggestRequest)) const [error, setError] = useState(null) const [saveMessage, setSaveMessage] = useState(null) const [saving, setSaving] = useState(false) @@ -268,29 +274,47 @@ export function StartTargetsEditor({ const selectedModel = modelOptions.find((option) => option.selectionKey === selectedModelSelectionKey) ?? null - const currentSuggestRequest = resolveSuggestRequest?.() ?? null - const visibleModel = selectedModel ?? findModelForRequest(modelOptions, currentSuggestRequest) - const visibleModelLabel = visibleModel - ? `${visibleModel.providerLabel} · ${visibleModel.label}` - : formatRequestModelLabel(currentSuggestRequest) - const modelGroups = useMemo(() => { + const modelGroups = useMemo(() => { const groups = new Map< string, - { providerLabel: string; options: StartTargetsModelOption[] } + { providerLabel: string; options: ModelThinkingSelectOption[] } >() for (const option of modelOptions) { const existing = groups.get(option.providerLabel) + const item = { id: option.selectionKey, label: option.label } if (existing) { - existing.options.push(option) + existing.options.push(item) } else { groups.set(option.providerLabel, { providerLabel: option.providerLabel, - options: [option], + options: [item], }) } } - return Array.from(groups.values()) + return Array.from(groups.values()).map((group) => ({ + id: group.providerLabel, + label: group.providerLabel, + options: group.options, + })) }, [modelOptions]) + const thinkingOptions = useMemo( + () => + (selectedModel?.thinkingEffortOptions ?? []).map((effort) => ({ + id: effort, + label: getProviderModelThinkingEffortLabel(effort), + })), + [selectedModel], + ) + + useEffect(() => { + if (!selectedModel) { + setSelectedThinkingEffort(null) + return + } + setSelectedThinkingEffort((current) => + normalizeThinkingEffortForModel(selectedModel, current), + ) + }, [selectedModel]) const pristine = useMemo( () => rowsEqualToInitial(rows, initialTargets), [rows, initialTargets], @@ -314,7 +338,11 @@ export function StartTargetsEditor({ const handleSuggest = async () => { if (!onSuggest || !resolveSuggestRequest) return const baseRequest = resolveSuggestRequest() - const request = requestWithModelOption(baseRequest, selectedModel) + const request = requestWithModelOption( + baseRequest, + showModelSelector ? selectedModel : null, + selectedThinkingEffort, + ) if (!request) { setError("Configure a model in the Agent pane before using AI suggest.") return @@ -400,6 +428,23 @@ export function StartTargetsEditor({ setSaveMessage(null) } + const handleModelChange = (value: string) => { + const option = modelOptions.find((entry) => entry.selectionKey === value) + setSelectedModelSelectionKey(value) + if (!option) return + setSelectedThinkingEffort((current) => + normalizeThinkingEffortForModel(option, current), + ) + } + + const handleThinkingChange = (value: string) => { + if (!selectedModel) return + const nextThinkingEffort = value as StartTargetsSuggestRequest["thinkingEffort"] + setSelectedThinkingEffort( + normalizeThinkingEffortForModel(selectedModel, nextThinkingEffort), + ) + } + const saveDisabled = busy || (hideSaveOnPristine && pristine) || (!hideSaveOnPristine && false) @@ -488,43 +533,39 @@ export function StartTargetsEditor({

{saveMessage}

) : null} - {aiEnabled ? ( -
-
+ {aiEnabled && showModelSelector ? ( +
+
AI model
-
- {visibleModelLabel} -
{modelOptions.length > 0 ? ( - +
+ +
) : null}
diff --git a/client/components/xero/status-footer.test.tsx b/client/components/xero/status-footer.test.tsx index 2e2a2425..ddf2ed83 100644 --- a/client/components/xero/status-footer.test.tsx +++ b/client/components/xero/status-footer.test.tsx @@ -1,4 +1,4 @@ -import { render, screen } from '@testing-library/react' +import { fireEvent, render, screen, waitFor } from '@testing-library/react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { StatusFooter } from './status-footer' @@ -41,4 +41,50 @@ describe('StatusFooter', () => { expect(screen.queryByText('↑2 ↓0')).not.toBeInTheDocument() expect(screen.getByText('clean')).toBeVisible() }) + + it('shows a styled tooltip for the footer spend button', async () => { + vi.useRealTimers() + + render( + undefined} + />, + ) + + const spendButton = screen.getByRole('button', { + name: 'Project spend: 1.19M tokens, $1.67', + }) + fireEvent.pointerEnter(spendButton) + fireEvent.pointerMove(spendButton) + + await waitFor(() => + expect(document.querySelector('[data-slot="tooltip-content"][data-side="top"]')).toHaveTextContent( + 'View project usage breakdown', + ), + ) + }) + + it('shows a styled tooltip for the footer notifications button', async () => { + vi.useRealTimers() + + render( + undefined} + />, + ) + + const notificationsButton = screen.getByRole('button', { + name: '1 unread notifications', + }) + fireEvent.pointerEnter(notificationsButton) + fireEvent.pointerMove(notificationsButton) + + await waitFor(() => + expect(document.querySelector('[data-slot="tooltip-content"][data-side="top"]')).toHaveTextContent( + 'View unread session responses', + ), + ) + }) }) diff --git a/client/components/xero/status-footer.tsx b/client/components/xero/status-footer.tsx index ae1be8c0..af849cae 100644 --- a/client/components/xero/status-footer.tsx +++ b/client/components/xero/status-footer.tsx @@ -10,6 +10,7 @@ import { GitCommit, } from "lucide-react" import { cn } from "@/lib/utils" +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip" import { formatTokenCount, formatMicrosUsd } from "@/src/lib/xero-model/usage" export interface FooterSpendData { @@ -35,6 +36,8 @@ export interface StatusFooterProps { } | null spend?: FooterSpendData | null notifications?: number + notificationsActive?: boolean + onNotificationsClick?: () => void /** Whether the spend section is currently active (sidebar open). */ spendActive?: boolean onSpendClick?: () => void @@ -50,6 +53,8 @@ export function StatusFooter({ git = null, spend = null, notifications = 0, + notificationsActive = false, + onNotificationsClick, spendActive = false, onSpendClick, }: StatusFooterProps) { @@ -87,6 +92,9 @@ export function StatusFooter({ const spendAriaLabel = hasSpendData ? `Project spend: ${tokensLabel} tokens, ${costLabel}` : "Project spend: no usage recorded yet" + const spendTooltip = hasSpendData ? "View project usage breakdown" : "No usage recorded yet" + const notificationsTooltip = + notifications > 0 ? "View unread session responses" : "No unread session responses" return (
- + + + + + {spendTooltip} + - - - {notifications} - + + + + + {notificationsTooltip} +
) diff --git a/client/components/xero/terminal-sidebar.test.tsx b/client/components/xero/terminal-sidebar.test.tsx new file mode 100644 index 00000000..baeccb00 --- /dev/null +++ b/client/components/xero/terminal-sidebar.test.tsx @@ -0,0 +1,677 @@ +import { act, fireEvent, render, screen, waitFor } from "@testing-library/react" +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" + +const mocks = vi.hoisted(() => { + const listeners = new Map void)[]>() + return { + listeners, + terminals: [] as Array<{ + writes: string[] + options: Record + write: (data: string) => void + open: () => void + focus: () => void + dispose: () => void + loadAddon: () => void + attachCustomKeyEventHandler: (handler: (event: KeyboardEvent) => boolean) => void + customKeyHandler?: (event: KeyboardEvent) => boolean + onData: (handler: (data: string) => void) => void + dataHandler?: (data: string) => void + onResize: (handler: (size: { cols: number; rows: number }) => void) => void + onTitleChange: (handler: (title: string) => void) => void + buffer: { active: { cursorX: number; cursorY: number } } + _core: { _renderService: { dimensions: { css: { cell: { width: number; height: number } } } } } + cols: number + rows: number + }>, + adapter: { + readProjectUiState: vi.fn(), + writeProjectUiState: vi.fn(), + terminalOpen: vi.fn(), + terminalWrite: vi.fn(), + terminalResize: vi.fn(), + terminalClose: vi.fn(), + terminalReadTranscript: vi.fn(), + terminalClearTranscript: vi.fn(), + terminalSuggest: vi.fn(), + terminalRecordCommand: vi.fn(), + terminalIgnoreSuggestion: vi.fn(), + }, + } +}) + +vi.mock("@tauri-apps/api/core", () => ({ + isTauri: () => true, +})) + +vi.mock("@tauri-apps/api/event", () => ({ + listen: async ( + eventName: string, + handler: (event: { payload: unknown }) => void, + ) => { + const listeners = mocks.listeners.get(eventName) ?? [] + listeners.push(handler) + mocks.listeners.set(eventName, listeners) + return () => { + const current = mocks.listeners.get(eventName) ?? [] + mocks.listeners.set( + eventName, + current.filter((entry) => entry !== handler), + ) + } + }, +})) + +vi.mock("@xterm/xterm", () => ({ + Terminal: class MockTerminal { + writes: string[] = [] + options: Record = {} + dataHandler?: (data: string) => void + buffer = { active: { cursorX: 12, cursorY: 2 } } + _core = { + _renderService: { + dimensions: { css: { cell: { width: 9, height: 18 } } }, + }, + } + cols = 120 + rows = 32 + + constructor(options: Record) { + this.options = options + mocks.terminals.push(this) + } + + write(data: string) { + this.writes.push(data) + } + + open() {} + focus() {} + dispose() {} + loadAddon() {} + customKeyHandler?: (event: KeyboardEvent) => boolean + attachCustomKeyEventHandler(handler: (event: KeyboardEvent) => boolean) { + this.customKeyHandler = handler + } + onData(handler: (data: string) => void) { + this.dataHandler = handler + } + onResize() {} + onTitleChange() {} + }, +})) + +vi.mock("@xterm/addon-fit", () => ({ + FitAddon: class MockFitAddon { + fit() {} + }, +})) + +vi.mock("@xterm/addon-web-links", () => ({ + WebLinksAddon: class MockWebLinksAddon { + constructor() {} + }, +})) + +vi.mock("@/src/lib/xero-desktop", () => ({ + XeroDesktopAdapter: mocks.adapter, +})) + +import { TerminalSidebar, type TerminalSidebarHandle } from "./terminal-sidebar" +import { TERMINAL_SUGGESTION_SETTINGS_KEY } from "./terminal-suggestion-settings" + +function setupAdapter() { + let nextTerminal = 1 + mocks.adapter.readProjectUiState.mockImplementation(async ({ projectId }: { projectId: string }) => ({ + schema: "xero.project_ui_state.v1", + projectId, + key: "terminal.tabs.v1", + value: null, + storageScope: "os_app_data", + uiDeferred: true, + })) + mocks.adapter.writeProjectUiState.mockImplementation(async (request) => ({ + schema: "xero.project_ui_state.v1", + projectId: request.projectId, + key: request.key, + value: request.value, + storageScope: "os_app_data", + uiDeferred: true, + })) + mocks.adapter.terminalOpen.mockImplementation(async (request) => ({ + terminalId: `pty-${nextTerminal++}`, + shell: "/bin/zsh", + cwd: `/repo/${request.projectId}`, + startedAt: "2026-06-01T12:00:00Z", + })) + mocks.adapter.terminalReadTranscript.mockImplementation( + async ({ projectId, clientTerminalId }) => ({ + projectId, + clientTerminalId, + content: "", + }), + ) + mocks.adapter.terminalWrite.mockResolvedValue(undefined) + mocks.adapter.terminalResize.mockResolvedValue(undefined) + mocks.adapter.terminalClose.mockResolvedValue(undefined) + mocks.adapter.terminalClearTranscript.mockResolvedValue(undefined) + mocks.adapter.terminalSuggest.mockResolvedValue({ + requestId: 1, + candidates: [], + deterministicExhausted: true, + aiAttempted: false, + }) + mocks.adapter.terminalRecordCommand.mockResolvedValue(undefined) + mocks.adapter.terminalIgnoreSuggestion.mockResolvedValue(undefined) +} + +function emitTerminalData(terminalId: string, data: string) { + for (const listener of mocks.listeners.get("terminal:data") ?? []) { + listener({ payload: { terminalId, data } }) + } +} + +function renderWithHandle(projectId: string) { + const handleRef: { current: TerminalSidebarHandle | null } = { current: null } + const view = render( + { + handleRef.current = handle + }} + />, + ) + return { ...view, handleRef } +} + +async function spawnLabeledTab( + handleRef: { current: TerminalSidebarHandle | null }, + label: string, +): Promise { + await waitFor(() => expect(handleRef.current).not.toBeNull()) + let terminalId: string | null = null + await act(async () => { + terminalId = await handleRef.current?.spawnTabWithCommand("", { + label, + source: { kind: "xero-command", label }, + }) ?? null + }) + await screen.findByRole("button", { name: label }) + return terminalId +} + +describe("TerminalSidebar session lifetime", () => { + beforeEach(() => { + mocks.listeners.clear() + mocks.terminals.length = 0 + Object.values(mocks.adapter).forEach((mock) => mock.mockReset()) + window.localStorage.clear() + setupAdapter() + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it("starts fresh on cold mount and ignores stale app-data terminal tabs", async () => { + render() + + await waitFor(() => expect(mocks.adapter.terminalOpen).toHaveBeenCalledTimes(1)) + expect(mocks.adapter.terminalOpen).toHaveBeenCalledWith({ + projectId: "project-a", + clientTerminalId: null, + cols: 120, + rows: 32, + suppressTranscriptUntilInput: false, + }) + expect(screen.queryByRole("button", { name: "web" })).not.toBeInTheDocument() + expect(mocks.adapter.readProjectUiState).not.toHaveBeenCalled() + expect(mocks.adapter.writeProjectUiState).not.toHaveBeenCalled() + expect(mocks.adapter.terminalReadTranscript).not.toHaveBeenCalled() + expect(mocks.adapter.terminalClearTranscript).not.toHaveBeenCalled() + }) + + it("lifts xterm custom scrollbars above elevated chrome", async () => { + render() + + await waitFor(() => expect(mocks.adapter.terminalOpen).toHaveBeenCalledTimes(1)) + + const terminalViewportStyle = document.querySelector( + ".xero-terminal-viewport style", + ) + const css = terminalViewportStyle?.textContent ?? "" + + expect(css).toContain( + ".xero-terminal-viewport .xterm .xterm-scrollable-element > .scrollbar", + ) + expect(css).toContain("z-index: var(--scrollbar-z-index) !important;") + }) + + it("keeps project PTYs alive across project switches without durable writes", async () => { + const { rerender } = render() + await waitFor(() => expect(mocks.adapter.terminalOpen).toHaveBeenCalledTimes(1)) + emitTerminalData("pty-1", "project a output\r\n") + expect(mocks.terminals[0].writes.join("")).toContain("project a output") + + rerender() + + await waitFor(() => expect(mocks.adapter.terminalOpen).toHaveBeenCalledTimes(2)) + expect(mocks.adapter.terminalClose).not.toHaveBeenCalledWith("pty-1") + expect(mocks.adapter.terminalOpen).toHaveBeenLastCalledWith({ + projectId: "project-b", + clientTerminalId: null, + cols: 120, + rows: 32, + suppressTranscriptUntilInput: false, + }) + + emitTerminalData("pty-1", "still alive while hidden\r\n") + expect(mocks.terminals[0].writes.join("")).toContain("still alive while hidden") + + rerender() + + expect(await screen.findByRole("button", { name: "zsh" })).toBeVisible() + expect(mocks.adapter.terminalOpen).toHaveBeenCalledTimes(2) + expect(mocks.adapter.writeProjectUiState).not.toHaveBeenCalled() + }) + + it("replaces an unused auto-created blank tab when launching a project command", async () => { + const { handleRef } = renderWithHandle("project-a") + await waitFor(() => expect(mocks.adapter.terminalOpen).toHaveBeenCalledTimes(1)) + + await act(async () => { + await handleRef.current?.spawnTabWithCommand("pnpm dev", { + label: "landing", + browserSupported: true, + source: { + kind: "start-target", + targetId: "target-landing", + targetName: "landing", + }, + }) + }) + + expect(await screen.findByRole("button", { name: "landing" })).toBeVisible() + expect(screen.queryByRole("button", { name: "zsh" })).not.toBeInTheDocument() + expect(mocks.adapter.terminalOpen).toHaveBeenCalledTimes(1) + await waitFor(() => expect(mocks.adapter.terminalWrite).toHaveBeenCalledWith("pty-1", "pnpm dev\r")) + }) + + it("stamps detected browser launch targets with the terminal tab project", async () => { + const detected: unknown[] = [] + const handleRef: { current: TerminalSidebarHandle | null } = { current: null } + render( + detected.push(target)} + registerHandle={(handle) => { + handleRef.current = handle + }} + />, + ) + await waitFor(() => expect(handleRef.current).not.toBeNull()) + + await act(async () => { + await handleRef.current?.spawnTabWithCommand("pnpm dev", { + label: "web", + browserSupported: true, + }) + }) + + emitTerminalData("pty-1", "Local: http://localhost:4100/\r\n") + + await waitFor(() => { + expect(detected.at(-1)).toMatchObject({ + label: "web · 127.0.0.1:4100", + projectId: "project-a", + url: "http://127.0.0.1:4100/", + }) + }) + }) + + it("claims the auto-opening blank tab when a project command arrives during terminal startup", async () => { + let resolveFirstOpen: (response: { + terminalId: string + shell: string + cwd: string + startedAt: string + }) => void = () => undefined + mocks.adapter.terminalOpen.mockImplementationOnce( + async (request) => + new Promise((resolve) => { + resolveFirstOpen = resolve + }).then(() => ({ + terminalId: "pty-1", + shell: "/bin/zsh", + cwd: `/repo/${request.projectId}`, + startedAt: "2026-06-01T12:00:00Z", + })), + ) + + const { handleRef } = renderWithHandle("project-a") + await waitFor(() => expect(handleRef.current).not.toBeNull()) + await waitFor(() => expect(mocks.adapter.terminalOpen).toHaveBeenCalledTimes(1)) + + let commandPromise: Promise = Promise.resolve(null) + act(() => { + commandPromise = handleRef.current?.spawnTabWithCommand("pnpm dev", { + label: "landing", + source: { + kind: "start-target", + targetId: "target-landing", + targetName: "landing", + }, + }) ?? Promise.resolve(null) + }) + expect(mocks.adapter.terminalOpen).toHaveBeenCalledTimes(1) + + await act(async () => { + resolveFirstOpen({ + terminalId: "pty-1", + shell: "/bin/zsh", + cwd: "/repo/project-a", + startedAt: "2026-06-01T12:00:00Z", + }) + await commandPromise + }) + + expect(await screen.findByRole("button", { name: "landing" })).toBeVisible() + expect(mocks.adapter.terminalOpen).toHaveBeenCalledTimes(1) + await waitFor(() => expect(mocks.adapter.terminalWrite).toHaveBeenCalledWith("pty-1", "pnpm dev\r")) + }) + + it("opens a new command tab when the existing blank terminal has user input", async () => { + const { handleRef } = renderWithHandle("project-a") + await waitFor(() => expect(mocks.adapter.terminalOpen).toHaveBeenCalledTimes(1)) + + mocks.terminals[0].dataHandler?.("git") + await waitFor(() => expect(mocks.adapter.terminalWrite).toHaveBeenCalledWith("pty-1", "git")) + + await act(async () => { + await handleRef.current?.spawnTabWithCommand("pnpm dev", { + label: "landing", + source: { + kind: "start-target", + targetId: "target-landing", + targetName: "landing", + }, + }) + }) + + expect(await screen.findByRole("button", { name: "landing" })).toBeVisible() + expect(mocks.adapter.terminalOpen).toHaveBeenCalledTimes(2) + await waitFor(() => expect(mocks.adapter.terminalWrite).toHaveBeenCalledWith("pty-2", "pnpm dev\r")) + }) + + it("closes an explicit tab without clearing or writing durable terminal state", async () => { + render() + const closeButton = await screen.findByRole("button", { name: "Close terminal" }) + + fireEvent.click(closeButton) + + await waitFor(() => expect(mocks.adapter.terminalClose).toHaveBeenCalledWith("pty-1")) + await waitFor(() => expect(mocks.adapter.terminalOpen).toHaveBeenCalledTimes(2)) + expect(mocks.adapter.terminalOpen).toHaveBeenLastCalledWith({ + projectId: "project-a", + clientTerminalId: null, + cols: 120, + rows: 32, + suppressTranscriptUntilInput: false, + }) + expect(mocks.adapter.terminalClearTranscript).not.toHaveBeenCalled() + expect(mocks.adapter.writeProjectUiState).not.toHaveBeenCalled() + }) + + it("does not persist unsubmitted input buffers on unmount", async () => { + const { unmount } = render() + + await waitFor(() => expect(mocks.adapter.terminalOpen).toHaveBeenCalledTimes(1)) + mocks.terminals[0].dataHandler?.("clear") + unmount() + + await waitFor(() => expect(mocks.adapter.terminalClose).toHaveBeenCalledWith("pty-1")) + expect(mocks.adapter.writeProjectUiState).not.toHaveBeenCalled() + expect(mocks.adapter.terminalClearTranscript).not.toHaveBeenCalled() + }) + + it("cleans up live PTYs when the sidebar unmounts", async () => { + const { unmount } = render() + await waitFor(() => expect(mocks.adapter.terminalOpen).toHaveBeenCalledTimes(1)) + + unmount() + + await waitFor(() => expect(mocks.adapter.terminalClose).toHaveBeenCalledWith("pty-1")) + }) + + it("explains local and AI terminal suggestion modes in settings", async () => { + render() + await waitFor(() => expect(mocks.adapter.terminalOpen).toHaveBeenCalledTimes(1)) + + fireEvent.click(screen.getByRole("button", { name: "Terminal suggestion settings" })) + + expect(await screen.findByText("Inline terminal suggestions")).toBeVisible() + expect(screen.getByText("Command suggestions")).toBeVisible() + expect(screen.getByText("Local")).toBeVisible() + expect(screen.getByText(/recent terminal commands, shell history, project files, and package scripts/i)).toBeVisible() + expect(screen.getByText("AI suggestions")).toBeVisible() + expect(screen.getByText("Fallback")).toBeVisible() + expect(screen.getByText(/configured model when local sources have no useful match/i)).toBeVisible() + }) + + it("does not touch project UI state if unmounted immediately", () => { + const { unmount } = render() + + unmount() + + expect(mocks.adapter.readProjectUiState).not.toHaveBeenCalled() + expect(mocks.adapter.writeProjectUiState).not.toHaveBeenCalled() + }) + + it("remembers the active tab for each project in memory", async () => { + const { handleRef, rerender } = renderWithHandle("project-a") + await waitFor(() => expect(mocks.adapter.terminalOpen).toHaveBeenCalledTimes(1)) + await spawnLabeledTab(handleRef, "web") + await spawnLabeledTab(handleRef, "api") + + const webTab = await screen.findByRole("button", { name: "web" }) + await screen.findByRole("button", { name: "api" }) + fireEvent.click(webTab.closest("div")!) + await waitFor(() => expect(webTab.closest("div")).toHaveClass("text-foreground")) + + rerender( + { + handleRef.current = handle + }} + />, + ) + await waitFor(() => expect(mocks.adapter.terminalOpen).toHaveBeenCalledTimes(4)) + + rerender( + { + handleRef.current = handle + }} + />, + ) + + const restoredWebTab = await screen.findByRole("button", { name: "web" }) + expect(restoredWebTab).toBeVisible() + expect(restoredWebTab.closest("div")).toHaveClass("text-foreground") + expect(mocks.adapter.terminalOpen).toHaveBeenCalledTimes(4) + }) + + it("switches tabs when clicking the visual tab outside the label text", async () => { + const { handleRef } = renderWithHandle("project-a") + await waitFor(() => expect(mocks.adapter.terminalOpen).toHaveBeenCalledTimes(1)) + await spawnLabeledTab(handleRef, "web") + await spawnLabeledTab(handleRef, "api") + + const webLabelButton = await screen.findByRole("button", { name: "web" }) + const webTab = webLabelButton.closest("div") + expect(webTab).not.toBeNull() + expect(webTab).not.toHaveClass("text-foreground") + + fireEvent.click(webTab!) + + await waitFor(() => expect(webTab).toHaveClass("text-foreground")) + }) + + it("renders ghost suggestions without writing them until accepted", async () => { + mocks.adapter.terminalSuggest.mockImplementation(async (request) => ({ + requestId: request.requestId, + candidates: [ + { + replacement: " status", + display: "git status", + description: "Show working tree status", + source: "command", + confidence: 0.9, + replacementRange: { start: 3, end: 3 }, + }, + ], + deterministicExhausted: false, + aiAttempted: false, + })) + + render() + await waitFor(() => expect(mocks.adapter.terminalOpen).toHaveBeenCalledTimes(1)) + + mocks.terminals[0].dataHandler?.("git") + await new Promise((resolve) => window.setTimeout(resolve, 150)) + + expect(await screen.findByText(/status/)).toBeVisible() + expect(mocks.adapter.terminalWrite).toHaveBeenCalledWith("pty-1", "git") + expect(mocks.adapter.terminalWrite).not.toHaveBeenCalledWith("pty-1", " status") + + const inlineSuggestion = await screen.findByTestId("terminal-inline-suggestion") + expect(inlineSuggestion).toHaveStyle({ left: "120px", top: "48px" }) + expect(screen.queryByRole("option")).not.toBeInTheDocument() + + mocks.terminals[0].customKeyHandler?.(new KeyboardEvent("keydown", { key: "Tab" })) + + expect(mocks.adapter.terminalWrite).toHaveBeenCalledWith("pty-1", " status") + }) + + it("forwards common terminal text-navigation shortcuts as shell control sequences", async () => { + render() + await waitFor(() => expect(mocks.adapter.terminalOpen).toHaveBeenCalledTimes(1)) + + const handler = mocks.terminals[0].customKeyHandler + expect(handler).toBeDefined() + + expect(handler?.(new KeyboardEvent("keydown", { key: "Backspace", metaKey: true }))).toBe(false) + expect(handler?.(new KeyboardEvent("keydown", { key: "Backspace", ctrlKey: true }))).toBe(false) + expect(handler?.(new KeyboardEvent("keydown", { key: "Backspace", altKey: true }))).toBe(false) + expect(handler?.(new KeyboardEvent("keydown", { key: "Delete", metaKey: true }))).toBe(false) + expect(handler?.(new KeyboardEvent("keydown", { key: "ArrowLeft", metaKey: true }))).toBe(false) + expect(handler?.(new KeyboardEvent("keydown", { key: "ArrowRight", metaKey: true }))).toBe(false) + expect(handler?.(new KeyboardEvent("keydown", { key: "ArrowUp", metaKey: true }))).toBe(false) + expect(handler?.(new KeyboardEvent("keydown", { key: "ArrowDown", metaKey: true }))).toBe(false) + expect(handler?.(new KeyboardEvent("keydown", { key: "ArrowLeft", altKey: true }))).toBe(false) + expect(handler?.(new KeyboardEvent("keydown", { key: "ArrowRight", altKey: true }))).toBe(false) + expect(handler?.(new KeyboardEvent("keydown", { key: "Delete", ctrlKey: true }))).toBe(false) + + expect(mocks.adapter.terminalWrite).toHaveBeenCalledWith("pty-1", "\x15") + expect(mocks.adapter.terminalWrite).toHaveBeenCalledWith("pty-1", "\x17") + expect(mocks.adapter.terminalWrite).toHaveBeenCalledWith("pty-1", "\x0b") + expect(mocks.adapter.terminalWrite).toHaveBeenCalledWith("pty-1", "\x01") + expect(mocks.adapter.terminalWrite).toHaveBeenCalledWith("pty-1", "\x05") + expect(mocks.adapter.terminalWrite).toHaveBeenCalledWith("pty-1", "\x1bb") + expect(mocks.adapter.terminalWrite).toHaveBeenCalledWith("pty-1", "\x1bf") + expect(mocks.adapter.terminalWrite).toHaveBeenCalledWith("pty-1", "\x1bd") + }) + + it("uses the configured AI model when requesting terminal suggestions", async () => { + window.localStorage.setItem( + TERMINAL_SUGGESTION_SETTINGS_KEY, + JSON.stringify({ + enabled: true, + aiEnabled: true, + modelSelection: { + providerId: "openai_codex", + providerProfileId: "openai_codex-default", + modelId: "gpt-5.4", + runtimeAgentId: "ask", + thinkingEffort: "low", + }, + }), + ) + mocks.adapter.terminalSuggest.mockImplementation(async (request) => ({ + requestId: request.requestId, + candidates: [], + deterministicExhausted: true, + aiAttempted: true, + })) + + render() + await waitFor(() => expect(mocks.adapter.terminalOpen).toHaveBeenCalledTimes(1)) + + mocks.terminals[0].dataHandler?.("git") + await waitFor(() => + expect(mocks.adapter.terminalSuggest).toHaveBeenCalledWith( + expect.objectContaining({ + enableAi: true, + providerId: "openai_codex", + providerProfileId: "openai_codex-default", + modelId: "gpt-5.4", + runtimeAgentId: "ask", + thinkingEffort: "low", + }), + ), + ) + }) + + it("records submitted commands and dismisses bad suggestions through app-data", async () => { + mocks.adapter.terminalSuggest.mockImplementation(async (request) => ({ + requestId: request.requestId, + candidates: [ + { + replacement: " diff", + display: "git diff", + description: "Review changes", + source: "history", + confidence: 0.9, + replacementRange: { start: 3, end: 3 }, + }, + ], + deterministicExhausted: false, + aiAttempted: false, + })) + + render() + await waitFor(() => expect(mocks.adapter.terminalOpen).toHaveBeenCalledTimes(1)) + + mocks.terminals[0].dataHandler?.("git") + await new Promise((resolve) => window.setTimeout(resolve, 150)) + expect(await screen.findByText(/diff/)).toBeVisible() + + mocks.terminals[0].customKeyHandler?.( + new KeyboardEvent("keydown", { key: "Escape" }), + ) + await waitFor(() => + expect(mocks.adapter.terminalIgnoreSuggestion).toHaveBeenCalledWith({ + projectId: "project-a", + display: "git diff", + }), + ) + + mocks.terminals[0].dataHandler?.(" status") + mocks.terminals[0].dataHandler?.("\r") + + await waitFor(() => + expect(mocks.adapter.terminalRecordCommand).toHaveBeenCalledWith({ + projectId: "project-a", + command: "git status", + cwd: "/repo/project-a", + shell: "/bin/zsh", + }), + ) + }) +}) diff --git a/client/components/xero/terminal-sidebar.tsx b/client/components/xero/terminal-sidebar.tsx index 4016e218..a7c8ff7a 100644 --- a/client/components/xero/terminal-sidebar.tsx +++ b/client/components/xero/terminal-sidebar.tsx @@ -1,12 +1,19 @@ "use client" -import { useCallback, useEffect, useMemo, useRef, useState } from "react" +import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties } from "react" import { listen } from "@tauri-apps/api/event" import { isTauri } from "@tauri-apps/api/core" import { Terminal as XTerm, type ITheme as IXTermTheme } from "@xterm/xterm" import { FitAddon } from "@xterm/addon-fit" import { WebLinksAddon } from "@xterm/addon-web-links" -import { Plus, X } from "lucide-react" +import { Plus, Settings2, X } from "lucide-react" +import { Button } from "@/components/ui/button" +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover" +import { Switch } from "@/components/ui/switch" import { cn } from "@/lib/utils" import { useSidebarOpenMotion, useSidebarWidthMotion } from "@/lib/sidebar-motion" import { createSafeTauriUnlisten } from "@/src/lib/tauri-events" @@ -14,6 +21,7 @@ import { XeroDesktopAdapter as defaultAdapter } from "@/src/lib/xero-desktop" import type { TerminalDataEventPayload, TerminalExitEventPayload, + TerminalSuggestionCandidateDto, TerminalTitleEventPayload, } from "@/src/lib/xero-desktop" import { useTheme } from "@/src/features/theme/theme-provider" @@ -29,6 +37,20 @@ import type { ThemeDefinition, } from "@xero/ui/theme" import type { EditorTerminalTaskExit } from "./execution-view/editor-tasks" +import { + StaleTerminalSuggestionGate, + TerminalInputTracker, + acceptedSuggestionWrite, + isProbablySecretCommand, + shouldShowCandidate, + type TerminalSuggestionSnapshot, +} from "./terminal-suggestions" +import { + loadTerminalSuggestionSettings, + persistTerminalSuggestionSettings, + subscribeTerminalSuggestionSettings, + type TerminalSuggestionSettings, +} from "./terminal-suggestion-settings" import "@xterm/xterm/css/xterm.css" @@ -40,6 +62,7 @@ const TERMINAL_FONT_FAMILY = 'ui-monospace, "SF Mono", Menlo, Monaco, Consolas, "Liberation Mono", monospace' const TERMINAL_SHIFT_ENTER_SEQUENCE = "\x1b[13;2u" const MAX_TAB_LABEL_LENGTH = 48 +const TERMINAL_SUGGESTION_DEBOUNCE_MS = 110 /** * Build an xterm theme from the active Xero theme. ANSI slots draw from the @@ -96,14 +119,41 @@ export interface TerminalSidebarHandle { ) => Promise } +export type TerminalSpawnSource = + | { + kind: "start-target" + targetId?: string | null + targetName?: string | null + } + | { + kind: "editor-task" + label?: string | null + } + | { + kind: "xero-command" + label?: string | null + } + export interface TerminalSpawnOptions { label?: string browserSupported?: boolean exitWhenDone?: boolean + source?: TerminalSpawnSource onData?: (data: string) => void onExit?: (event: EditorTerminalTaskExit) => void } +interface TerminalSuggestionState { + terminalId: string + snapshot: TerminalSuggestionSnapshot + candidates: TerminalSuggestionCandidateDto[] + selectedIndex: number +} + +interface InternalTerminalSpawnOptions extends TerminalSpawnOptions { + labelLocked?: boolean +} + interface TerminalSidebarProps { open: boolean projectId: string | null @@ -117,13 +167,38 @@ interface TerminalSidebarProps { interface TerminalTab { id: string + projectId: string label: string labelLocked?: boolean browserSupported?: boolean | null + cwd: string | null + shell: string + running: boolean terminal: XTerm fit: FitAddon } +interface XTermWithCursorMetrics { + buffer?: { + active?: { + cursorX?: number + cursorY?: number + } + } + _core?: { + _renderService?: { + dimensions?: { + css?: { + cell?: { + width?: number + height?: number + } + } + } + } + } +} + function viewportDefaultWidth(): number { if (typeof window === "undefined") return 560 return Math.round(window.innerWidth * DEFAULT_RATIO) @@ -181,6 +256,31 @@ function isPlainShiftEnter(event: KeyboardEvent): boolean { ) } +function terminalShortcutWrite(event: KeyboardEvent): string | null { + if (event.type !== "keydown") return null + if (event.shiftKey) return null + + const hasWordModifier = event.altKey || event.ctrlKey + if (event.metaKey && !event.altKey && !event.ctrlKey) { + if (event.key === "ArrowLeft") return "\x01" + if (event.key === "ArrowRight") return "\x05" + if (event.key === "ArrowUp") return "\x01" + if (event.key === "ArrowDown") return "\x05" + if (event.key === "Backspace") return "\x15" + if (event.key === "Delete") return "\x0b" + return null + } + + if (hasWordModifier && !event.metaKey) { + if (event.key === "ArrowLeft") return "\x1bb" + if (event.key === "ArrowRight") return "\x1bf" + if (event.key === "Backspace") return "\x17" + if (event.key === "Delete") return "\x1bd" + } + + return null +} + function sanitizeTerminalTabLabel(label: string): string | null { const compact = label.replace(/[\u0000-\u001f\u007f]/g, " ").replace(/\s+/g, " ").trim() if (compact.length === 0) return null @@ -208,6 +308,10 @@ export function TerminalSidebar({ const [isResizing, setIsResizing] = useState(false) const [tabs, setTabs] = useState([]) const [activeTabId, setActiveTabId] = useState(null) + const [suggestionSettings, setSuggestionSettings] = useState( + loadTerminalSuggestionSettings, + ) + const [suggestionState, setSuggestionState] = useState(null) const motionOpen = useSidebarOpenMotion(open) const targetWidth = motionOpen ? width : 0 const widthMotion = useSidebarWidthMotion(targetWidth, { isResizing }) @@ -224,6 +328,8 @@ export function TerminalSidebar({ widthRef.current = width const tabsRef = useRef([]) tabsRef.current = tabs + const activeTabIdRef = useRef(activeTabId) + activeTabIdRef.current = activeTabId const openRef = useRef(open) openRef.current = open const projectIdRef = useRef(projectId) @@ -232,10 +338,39 @@ export function TerminalSidebar({ const terminalHostsRef = useRef>(new Map()) const openedTerminalIdsRef = useRef>(new Set()) const pendingWriteBuffersRef = useRef>(new Map()) + const suppressingLiveOutputIdsRef = useRef>(new Set()) const closingTerminalIdsRef = useRef>(new Set()) const taskHandlersRef = useRef>>(new Map()) const autoOpeningTerminalRef = useRef(false) + const autoOpeningTerminalPromiseRef = useRef | null>(null) const lastTabReplacementPendingRef = useRef(false) + const previousProjectIdRef = useRef(projectId) + const activeTabByProjectRef = useRef>(new Map()) + const commandTerminalIdsRef = useRef>(new Set()) + const userInputTerminalIdsRef = useRef>(new Set()) + const inputTrackersRef = useRef>(new Map()) + const suggestionGateRef = useRef(new StaleTerminalSuggestionGate()) + const suggestionDebounceRef = useRef(null) + const suggestionStateRef = useRef(null) + const suggestionSettingsRef = useRef(suggestionSettings) + + suggestionStateRef.current = suggestionState + suggestionSettingsRef.current = suggestionSettings + + useEffect(() => { + if (!suggestionSettings.enabled) { + suggestionGateRef.current.invalidate() + setSuggestionState(null) + } + }, [suggestionSettings]) + + useEffect( + () => + subscribeTerminalSuggestionSettings((settings) => { + setSuggestionSettings(settings) + }), + [], + ) const handleTerminalLink = useCallback((uri: string) => { if (isBrowserSupportedDevServerUrl(uri)) { @@ -252,6 +387,7 @@ export function TerminalSidebar({ for (const url of urls) { const target = makeBrowserLaunchTarget({ label: browserLaunchTargetLabel(url, tab.label), + projectId: tab.projectId, url, source: tab.label, }) @@ -259,9 +395,143 @@ export function TerminalSidebar({ } }, []) + const trackerForTerminal = useCallback((terminalId: string) => { + const existing = inputTrackersRef.current.get(terminalId) + if (existing) return existing + const tracker = new TerminalInputTracker() + inputTrackersRef.current.set(terminalId, tracker) + return tracker + }, []) + + const clearSuggestion = useCallback(() => { + suggestionGateRef.current.invalidate() + if (suggestionDebounceRef.current !== null) { + window.clearTimeout(suggestionDebounceRef.current) + suggestionDebounceRef.current = null + } + setSuggestionState(null) + }, []) + + const scheduleSuggestions = useCallback( + (tab: TerminalTab, snapshot: TerminalSuggestionSnapshot) => { + if (!suggestionSettingsRef.current.enabled || !defaultAdapter.terminalSuggest) { + clearSuggestion() + return + } + if (snapshot.suppressed || !tab.running) { + clearSuggestion() + return + } + if (suggestionDebounceRef.current !== null) { + window.clearTimeout(suggestionDebounceRef.current) + } + const requestId = suggestionGateRef.current.next() + suggestionDebounceRef.current = window.setTimeout(() => { + suggestionDebounceRef.current = null + const settings = suggestionSettingsRef.current + const modelSelection = settings.modelSelection + void defaultAdapter.terminalSuggest?.({ + projectId: tab.projectId, + terminalId: tab.id, + buffer: snapshot.buffer, + cursor: snapshot.cursor, + cwd: tab.cwd, + shell: tab.shell, + recentBlockContext: null, + requestId, + enableAi: settings.aiEnabled, + providerId: modelSelection?.providerId ?? null, + providerProfileId: modelSelection?.providerProfileId ?? null, + modelId: modelSelection?.modelId ?? null, + runtimeAgentId: modelSelection?.runtimeAgentId ?? null, + thinkingEffort: modelSelection?.thinkingEffort ?? null, + }).then((response) => { + if (!suggestionGateRef.current.isCurrent(response.requestId)) return + const candidates = response.candidates.filter((candidate) => + shouldShowCandidate(snapshot, candidate), + ) + setSuggestionState( + candidates.length > 0 + ? { terminalId: tab.id, snapshot, candidates, selectedIndex: 0 } + : null, + ) + }).catch(() => { + if (suggestionGateRef.current.isCurrent(requestId)) { + setSuggestionState(null) + } + }) + }, TERMINAL_SUGGESTION_DEBOUNCE_MS) + }, + [clearSuggestion], + ) + + const recordTerminalCommand = useCallback((tab: TerminalTab, command: string) => { + if (!command || isProbablySecretCommand(command)) return + void defaultAdapter.terminalRecordCommand?.({ + projectId: tab.projectId, + command, + cwd: tab.cwd, + shell: tab.shell, + }).catch(() => undefined) + }, []) + + const ignoreSuggestion = useCallback((tab: TerminalTab, candidate: TerminalSuggestionCandidateDto) => { + void defaultAdapter.terminalIgnoreSuggestion?.({ + projectId: tab.projectId, + display: candidate.display, + }).catch(() => undefined) + }, []) + + const acceptSuggestion = useCallback( + (tab: TerminalTab, candidate: TerminalSuggestionCandidateDto, mode: "full" | "word") => { + const write = acceptedSuggestionWrite(candidate, mode) + if (!write) return + const tracker = trackerForTerminal(tab.id) + const result = tracker.applyInput(write) + userInputTerminalIdsRef.current.add(tab.id) + suppressingLiveOutputIdsRef.current.delete(tab.id) + void defaultAdapter.terminalWrite?.(tab.id, write) + const snapshot = result.snapshot + setSuggestionState(null) + scheduleSuggestions(tab, snapshot) + }, + [scheduleSuggestions, trackerForTerminal], + ) + + const terminalCursorOverlayStyle = useCallback( + (tab: TerminalTab, snapshot: TerminalSuggestionSnapshot): CSSProperties => { + const terminal = tab.terminal as unknown as XTermWithCursorMetrics + const cellWidth = + terminal._core?._renderService?.dimensions?.css?.cell?.width ?? + TERMINAL_FONT_SIZE * 0.62 + const cellHeight = + terminal._core?._renderService?.dimensions?.css?.cell?.height ?? + TERMINAL_FONT_SIZE * 1.35 + const cursorX = + typeof terminal.buffer?.active?.cursorX === "number" + ? terminal.buffer.active.cursorX + : snapshot.cursor + const cursorY = + typeof terminal.buffer?.active?.cursorY === "number" + ? terminal.buffer.active.cursorY + : 0 + return { + left: 12 + cursorX * cellWidth, + top: 12 + cursorY * cellHeight, + lineHeight: `${cellHeight}px`, + } + }, + [], + ) + + const activeProjectTabs = useMemo( + () => tabs.filter((tab) => tab.projectId === projectId), + [projectId, tabs], + ) + const activeTab = useMemo( - () => tabs.find((tab) => tab.id === activeTabId) ?? null, - [tabs, activeTabId], + () => activeProjectTabs.find((tab) => tab.id === activeTabId) ?? null, + [activeProjectTabs, activeTabId], ) const updateTabLabel = useCallback((terminalId: string, label: string) => { @@ -286,9 +556,19 @@ export function TerminalSidebar({ void listen("terminal:data", (event) => { const { terminalId, data } = event.payload if (closingTerminalIdsRef.current.has(terminalId)) return + if (suppressingLiveOutputIdsRef.current.has(terminalId)) return taskHandlersRef.current.get(terminalId)?.onData?.(data) const tab = tabsRef.current.find((entry) => entry.id === terminalId) if (tab) { + const snapshot = trackerForTerminal(terminalId).observeOutput(data) + if (suggestionStateRef.current?.terminalId === terminalId && snapshot.suppressed) { + clearSuggestion() + } + if (!openedTerminalIdsRef.current.has(terminalId)) { + const buffered = pendingWriteBuffersRef.current.get(terminalId) ?? "" + pendingWriteBuffersRef.current.set(terminalId, buffered + data) + return + } detectBrowserLaunchTargets(tab, data) tab.terminal.write(data) return @@ -308,14 +588,21 @@ export function TerminalSidebar({ const { terminalId, exitCode } = event.payload if (closingTerminalIdsRef.current.has(terminalId)) { closingTerminalIdsRef.current.delete(terminalId) + suppressingLiveOutputIdsRef.current.delete(terminalId) return } + suppressingLiveOutputIdsRef.current.delete(terminalId) const tab = tabsRef.current.find((entry) => entry.id === terminalId) const code = exitCode ?? null taskHandlersRef.current.get(terminalId)?.onExit?.({ terminalId, exitCode: code }) taskHandlersRef.current.delete(terminalId) if (!tab) return tab.terminal.write(`\r\n\x1b[2m[exited${code === null ? '' : ` with code ${code}`}]\x1b[0m\r\n`) + setTabs((current) => + current.map((entry) => + entry.id === terminalId ? { ...entry, running: false } : entry, + ), + ) }).then((fn) => { const unlisten = createSafeTauriUnlisten(fn) if (cancelled) { @@ -342,7 +629,7 @@ export function TerminalSidebar({ cancelled = true unlisteners.forEach((fn) => fn()) } - }, [updateTabLabel]) + }, [clearSuggestion, trackerForTerminal, updateTabLabel]) const registerTerminalHost = useCallback((tab: TerminalTab, node: HTMLDivElement | null) => { if (!node) { @@ -353,6 +640,11 @@ export function TerminalSidebar({ if (openedTerminalIdsRef.current.has(tab.id)) return tab.terminal.open(node) openedTerminalIdsRef.current.add(tab.id) + const buffered = pendingWriteBuffersRef.current.get(tab.id) + if (buffered) { + tab.terminal.write(buffered) + pendingWriteBuffersRef.current.delete(tab.id) + } }, []) // Keep each xterm mounted once. Switching tabs only changes visibility, then @@ -407,24 +699,196 @@ export function TerminalSidebar({ } }, [activeTab]) + const writeCommandToTerminal = useCallback( + (terminalId: string, command: string | undefined, options?: TerminalSpawnOptions) => { + if (!command?.trim()) return false + commandTerminalIdsRef.current.add(terminalId) + window.setTimeout(() => { + const write = buildTerminalCommandWrite(command, options) + if (!write) return + suppressingLiveOutputIdsRef.current.delete(terminalId) + void defaultAdapter.terminalWrite?.(terminalId, write) + }, 80) + return true + }, + [], + ) + + const findReusableBlankTab = useCallback((targetProjectId: string): TerminalTab | null => { + const projectTabs = tabsRef.current.filter((tab) => tab.projectId === targetProjectId) + const activeProjectTab = + activeTabIdRef.current + ? projectTabs.find((tab) => tab.id === activeTabIdRef.current) ?? null + : null + const seen = new Set() + const candidates = [activeProjectTab, ...projectTabs.slice().reverse()] + .filter((tab): tab is TerminalTab => Boolean(tab)) + .filter((tab) => { + if (seen.has(tab.id)) return false + seen.add(tab.id) + return true + }) + + return candidates.find((tab) => + tab.running && + tab.labelLocked !== true && + !commandTerminalIdsRef.current.has(tab.id) && + !userInputTerminalIdsRef.current.has(tab.id) && + !taskHandlersRef.current.has(tab.id), + ) ?? null + }, []) + + const claimReusableBlankTabForCommand = useCallback( + ( + targetProjectId: string, + command: string | undefined, + options?: InternalTerminalSpawnOptions, + ): string | null => { + if (!command?.trim()) return null + const tab = findReusableBlankTab(targetProjectId) + if (!tab) return null + + const nextLabel = sanitizeTerminalTabLabel(options?.label ?? "") ?? tab.label + const nextLabelLocked = options?.labelLocked ?? Boolean(options?.label) + const nextBrowserSupported = options?.browserSupported ?? null + const updateTab = (entry: TerminalTab): TerminalTab => + entry.id === tab.id + ? { + ...entry, + label: nextLabel, + labelLocked: nextLabelLocked, + browserSupported: nextBrowserSupported, + } + : entry + + if (options?.onData || options?.onExit) { + taskHandlersRef.current.set(tab.id, { + onData: options.onData, + onExit: options.onExit, + }) + } + tabsRef.current = tabsRef.current.map(updateTab) + setTabs((current) => current.map(updateTab)) + activeTabIdRef.current = tab.id + activeTabByProjectRef.current.set(targetProjectId, tab.id) + setActiveTabId(tab.id) + writeCommandToTerminal(tab.id, command, options) + return tab.id + }, + [findReusableBlankTab, writeCommandToTerminal], + ) + + const disposeTerminalTab = useCallback( + ( + tab: TerminalTab, + options: { notifyTask?: boolean } = {}, + ) => { + closingTerminalIdsRef.current.add(tab.id) + terminalHostsRef.current.delete(tab.id) + openedTerminalIdsRef.current.delete(tab.id) + pendingWriteBuffersRef.current.delete(tab.id) + inputTrackersRef.current.delete(tab.id) + suppressingLiveOutputIdsRef.current.delete(tab.id) + commandTerminalIdsRef.current.delete(tab.id) + userInputTerminalIdsRef.current.delete(tab.id) + if (suggestionStateRef.current?.terminalId === tab.id) { + clearSuggestion() + } + try { tab.terminal.dispose() } catch { /* swallow */ } + if (options.notifyTask !== false) { + taskHandlersRef.current.get(tab.id)?.onExit?.({ terminalId: tab.id, exitCode: null }) + } + taskHandlersRef.current.delete(tab.id) + void defaultAdapter.terminalClose?.(tab.id).catch(() => undefined) + }, + [clearSuggestion], + ) + const spawnTab = useCallback( - async (command?: string, options?: TerminalSpawnOptions): Promise => { + async (command?: string, options?: InternalTerminalSpawnOptions): Promise => { if (!isTauri()) return null + const targetProjectId = projectIdRef.current + if (!targetProjectId) return null const cols = 120 const rows = 32 try { + if (command?.trim()) { + const claimedTabId = claimReusableBlankTabForCommand(targetProjectId, command, options) + if (claimedTabId) return claimedTabId + + const pendingBlankTab = autoOpeningTerminalPromiseRef.current + if (pendingBlankTab) { + await pendingBlankTab.catch(() => null) + const claimedPendingTabId = claimReusableBlankTabForCommand( + targetProjectId, + command, + options, + ) + if (claimedPendingTabId) return claimedPendingTabId + } + } + const response = await defaultAdapter.terminalOpen?.({ - projectId: projectIdRef.current ?? null, + projectId: targetProjectId, + clientTerminalId: null, cols, rows, + suppressTranscriptUntilInput: false, }) if (!response) return null const { terminal, fit } = createXTerm(xtermThemeRef.current, handleTerminalLink) terminal.attachCustomKeyEventHandler((event) => { + const visibleSuggestion = suggestionStateRef.current + const currentCandidate = + visibleSuggestion?.terminalId === response.terminalId + ? visibleSuggestion.candidates[visibleSuggestion.selectedIndex] + : null + if (currentCandidate) { + const currentTab = tabsRef.current.find((entry) => entry.id === response.terminalId) + const acceptsFull = + event.key === "Tab" || + (event.key === "ArrowRight" && !event.altKey && !event.metaKey && !event.shiftKey) || + (event.key.toLowerCase() === "f" && event.ctrlKey && !event.altKey && !event.metaKey) + const acceptsWord = + event.altKey && !event.ctrlKey && !event.metaKey && (event.key === "ArrowRight" || event.key.toLowerCase() === "f") + if (event.key === "Escape") { + event.preventDefault() + event.stopPropagation() + if (currentTab) ignoreSuggestion(currentTab, currentCandidate) + clearSuggestion() + return false + } + if (currentTab && (acceptsFull || acceptsWord)) { + event.preventDefault() + event.stopPropagation() + acceptSuggestion(currentTab, currentCandidate, acceptsWord ? "word" : "full") + return false + } + } + + const shortcutWrite = terminalShortcutWrite(event) + if (shortcutWrite) { + event.preventDefault() + event.stopPropagation() + suppressingLiveOutputIdsRef.current.delete(response.terminalId) + userInputTerminalIdsRef.current.add(response.terminalId) + const tracker = trackerForTerminal(response.terminalId) + const tracked = tracker.applyInput(shortcutWrite) + const currentTab = tabsRef.current.find((entry) => entry.id === response.terminalId) + clearSuggestion() + if (currentTab && tracked.kind === "edit") { + scheduleSuggestions(currentTab, tracked.snapshot) + } + void defaultAdapter.terminalWrite?.(response.terminalId, shortcutWrite) + return false + } + if (!isPlainShiftEnter(event)) return true event.preventDefault() event.stopPropagation() + suppressingLiveOutputIdsRef.current.delete(response.terminalId) + userInputTerminalIdsRef.current.add(response.terminalId) void defaultAdapter.terminalWrite?.( response.terminalId, TERMINAL_SHIFT_ENTER_SEQUENCE, @@ -432,6 +896,20 @@ export function TerminalSidebar({ return false }) terminal.onData((data) => { + suppressingLiveOutputIdsRef.current.delete(response.terminalId) + userInputTerminalIdsRef.current.add(response.terminalId) + const tracked = trackerForTerminal(response.terminalId).applyInput(data) + const currentTab = tabsRef.current.find((entry) => entry.id === response.terminalId) + if (tracked.kind === "submit") { + clearSuggestion() + if (currentTab && tracked.command) { + recordTerminalCommand(currentTab, tracked.command) + } + } else if (tracked.kind === "reset") { + clearSuggestion() + } else if (currentTab) { + scheduleSuggestions(currentTab, tracked.snapshot) + } void defaultAdapter.terminalWrite?.(response.terminalId, data) }) terminal.onResize(({ cols: c, rows: r }) => { @@ -440,20 +918,19 @@ export function TerminalSidebar({ terminal.onTitleChange((title) => { updateTabLabel(response.terminalId, title) }) - const buffered = pendingWriteBuffersRef.current.get(response.terminalId) - if (buffered) { - terminal.write(buffered) - pendingWriteBuffersRef.current.delete(response.terminalId) - } const initialLabel = sanitizeTerminalTabLabel(options?.label ?? "") ?? sanitizeTerminalTabLabel(response.shell.split(/[\\/]/).pop() ?? response.shell) ?? "terminal" const tab: TerminalTab = { id: response.terminalId, + projectId: targetProjectId, label: initialLabel, - labelLocked: !!options?.label, + labelLocked: options?.labelLocked ?? !!options?.label, browserSupported: options?.browserSupported ?? null, + cwd: response.cwd ?? null, + shell: response.shell, + running: true, terminal, fit, } @@ -463,45 +940,91 @@ export function TerminalSidebar({ onExit: options.onExit, }) } - setTabs((current) => [...current, tab]) + tabsRef.current = [...tabsRef.current, tab] + setTabs((current) => current.some((entry) => entry.id === tab.id) ? current : [...current, tab]) + activeTabIdRef.current = response.terminalId setActiveTabId(response.terminalId) - if (command && command.trim().length > 0) { - // Defer the write until the PTY has had a chance to wire up the - // shell prompt. A small delay is usually enough. - window.setTimeout(() => { - const write = buildTerminalCommandWrite(command, options) - if (!write) return - void defaultAdapter.terminalWrite?.( - response.terminalId, - write, - ) - }, 80) - } + writeCommandToTerminal(response.terminalId, command, options) return response.terminalId } catch (error) { console.error("Could not open terminal", error) return null } }, - [handleTerminalLink, updateTabLabel], + [ + acceptSuggestion, + claimReusableBlankTabForCommand, + clearSuggestion, + handleTerminalLink, + ignoreSuggestion, + recordTerminalCommand, + scheduleSuggestions, + trackerForTerminal, + updateTabLabel, + writeCommandToTerminal, + ], ) const ensureTerminalTab = useCallback(() => { if (!isTauri()) return if (autoOpeningTerminalRef.current) return autoOpeningTerminalRef.current = true - void spawnTab().finally(() => { + const openPromise = spawnTab() + autoOpeningTerminalPromiseRef.current = openPromise + void openPromise.finally(() => { autoOpeningTerminalRef.current = false + if (autoOpeningTerminalPromiseRef.current === openPromise) { + autoOpeningTerminalPromiseRef.current = null + } }) }, [spawnTab]) + useEffect(() => { + if (!projectId || !activeTabId) return + const activeTab = tabs.find((tab) => tab.id === activeTabId) + if (activeTab?.projectId === projectId) { + activeTabByProjectRef.current.set(projectId, activeTabId) + } + }, [activeTabId, projectId, tabs]) + + useEffect(() => { + const previousProjectId = previousProjectIdRef.current + if (previousProjectId && previousProjectId !== projectId) { + const previousActiveTabId = activeTabIdRef.current + if ( + previousActiveTabId && + tabsRef.current.some( + (tab) => tab.id === previousActiveTabId && tab.projectId === previousProjectId, + ) + ) { + activeTabByProjectRef.current.set(previousProjectId, previousActiveTabId) + } + clearSuggestion() + } + + previousProjectIdRef.current = projectId + + if (!projectId) { + setActiveTabId(null) + return + } + + const projectTabs = tabsRef.current.filter((tab) => tab.projectId === projectId) + const rememberedTabId = activeTabByProjectRef.current.get(projectId) ?? null + const nextActiveTabId = + projectTabs.find((tab) => tab.id === rememberedTabId)?.id ?? + projectTabs[projectTabs.length - 1]?.id ?? + null + setActiveTabId(nextActiveTabId) + }, [clearSuggestion, projectId]) + // Auto-create the first tab when the sidebar opens or recovers from an // unexpected empty state. useEffect(() => { if (!open) return - if (tabs.length > 0) return + if (activeProjectTabs.length > 0) return ensureTerminalTab() - }, [ensureTerminalTab, open, tabs.length]) + }, [activeProjectTabs.length, ensureTerminalTab, open, projectId]) useEffect(() => { if (!registerHandle) return @@ -516,21 +1039,21 @@ export function TerminalSidebar({ const snapshot = tabsRef.current const tab = snapshot.find((entry) => entry.id === id) if (!tab) return - const remaining = snapshot.filter((entry) => entry.id !== id) + const remaining = snapshot.filter( + (entry) => entry.projectId === tab.projectId && entry.id !== id, + ) const closeTab = (fallbackActiveTabId: string | null) => { - closingTerminalIdsRef.current.add(id) - terminalHostsRef.current.delete(id) - openedTerminalIdsRef.current.delete(id) - tab.terminal.dispose() - pendingWriteBuffersRef.current.delete(id) - taskHandlersRef.current.get(id)?.onExit?.({ terminalId: id, exitCode: null }) - taskHandlersRef.current.delete(id) + disposeTerminalTab(tab, { notifyTask: true }) + if (fallbackActiveTabId) { + activeTabByProjectRef.current.set(tab.projectId, fallbackActiveTabId) + } else { + activeTabByProjectRef.current.delete(tab.projectId) + } setTabs((current) => current.filter((entry) => entry.id !== id)) setActiveTabId((current) => { if (current !== id) return current return fallbackActiveTabId }) - void defaultAdapter.terminalClose?.(id).catch(() => undefined) } if (remaining.length === 0 && openRef.current && isTauri()) { @@ -550,7 +1073,7 @@ export function TerminalSidebar({ const fallbackActiveTabId = remaining.length > 0 ? remaining[remaining.length - 1].id : null closeTab(fallbackActiveTabId) }, - [spawnTab], + [disposeTerminalTab, spawnTab], ) const handleResizeStart = useCallback( @@ -603,21 +1126,36 @@ export function TerminalSidebar({ [], ) + const visibleSuggestion = + activeTab && suggestionState?.terminalId === activeTab.id + ? suggestionState + : null + const visibleCandidate = visibleSuggestion?.candidates[visibleSuggestion.selectedIndex] ?? null + const visibleSuggestionStyle = + visibleSuggestion && activeTab + ? terminalCursorOverlayStyle(activeTab, visibleSuggestion.snapshot) + : undefined + + const updateSuggestionSetting = useCallback( + (key: "enabled" | "aiEnabled", value: boolean) => { + setSuggestionSettings((current) => { + const next = { ...current, [key]: value } + persistTerminalSuggestionSettings(next) + return next + }) + }, + [], + ) + // Cleanup on unmount: dispose xterm instances + kill PTYs. useEffect(() => { return () => { const snapshot = tabsRef.current - snapshot.forEach((tab) => { - closingTerminalIdsRef.current.add(tab.id) - terminalHostsRef.current.delete(tab.id) - openedTerminalIdsRef.current.delete(tab.id) - try { tab.terminal.dispose() } catch { /* swallow */ } - taskHandlersRef.current.get(tab.id)?.onExit?.({ terminalId: tab.id, exitCode: null }) - taskHandlersRef.current.delete(tab.id) - void defaultAdapter.terminalClose?.(tab.id).catch(() => undefined) - }) + snapshot.forEach((tab) => + disposeTerminalTab(tab, { notifyTask: true }), + ) } - }, []) + }, [disposeTerminalTab]) return (