diff --git a/.agents/checks/design-system-tokens.md b/.agents/checks/design-system-tokens.md new file mode 100644 index 000000000..c891e9e6b --- /dev/null +++ b/.agents/checks/design-system-tokens.md @@ -0,0 +1,72 @@ +--- +name: design-system-tokens +description: Ensure UI color changes use the Berd shadcn-first token contract instead of raw palette values or broad custom tokens. +severity-default: medium +tools: [Grep, Read] +--- + +Review changed frontend styling for design-system token drift. + +Use `docs/color-token-mapping.md` as the source of truth for how colors should +be chosen. The short version: + +- Shared UI should use shadcn semantic tokens first: `background`, + `foreground`, `card`, `popover`, `muted`, `accent`, `primary`, + `secondary`, `destructive`, `border`, `input`, and `ring`. +- Sidebar rows and text should use the shadcn sidebar state tokens: + `sidebar-foreground`, `sidebar-accent`, `sidebar-border`, and + `sidebar-ring`. Floating chrome shells (nav panes, right rail, top bar) + paint `card-glass`; the bare `sidebar` shell token is retired. +- Berd-specific tokens are allowed only for product-specific surfaces that do + not map cleanly to shadcn, such as `canvas-*`, `surface-composer`, + `surface-editor-panel`, `message-user-bg`, `chip-*-bg`, `chip-*-fg`, + `success`, `warning`, `info`, and clock/status/chart tokens. + +## What to flag + +- **Raw Tailwind palette utilities** like `text-gray-*`, `bg-zinc-*`, + `border-neutral-*`, `ring-blue-*`, `fill-slate-*`, or `stroke-stone-*`. + Suggest the closest semantic token instead, such as `text-muted-foreground`, + `bg-accent`, `border-border`, `border-input`, or `ring-ring`. +- **Deleted broad Berd token families** such as `background-default`, + `background-hover`, `text-default`, `text-muted`, `border-default`, + `border-focus`, `surface-card`, `surface-overlay`, `surface-chrome`, + `sidebar-nav-bg-hover`, or `sidebar-nav-fg` — and the retired shell tokens + `bg-sidebar`/`--sidebar`, `sidebar-navigation-panel-bg`, + `canvas-project-tint`, and `surface-agent-profile-bg`. Suggest the mapping + in `docs/color-token-mapping.md`. +- **New broad custom color tokens** that duplicate shadcn concepts. For + example, do not introduce a new token that means "normal page background", + "hover gray", "secondary text", "card surface", "popover surface", + "default border", "input border", or "focus ring"; use shadcn tokens. +- **Berd extension tokens without a narrow product job.** If a new token is + added, it should name a real Berd-specific surface or identity role, and the + PR should update both `docs/color-token-mapping.md` and + `scripts/design-system-tokens.mjs`. +- **Component-level one-off color decisions** where the same role already + exists in a shared UI primitive. Prefer adjusting the shared primitive or + design token over scattering local styling. + +## What not to flag + +- Approved shadcn token utilities such as `bg-background`, `text-foreground`, + `bg-card`, `bg-popover`, `bg-muted`, `bg-accent`, `bg-primary`, + `bg-secondary`, `bg-destructive`, `border-border`, `border-input`, or + `ring-ring`. +- Approved sidebar state utilities such as `text-sidebar-foreground`, + `hover:bg-sidebar-accent`, `border-sidebar-border`, and + `ring-sidebar-ring`. The pane shell itself is `bg-card-glass`, not + `bg-sidebar`. +- Approved narrow Berd extension utilities documented in + `docs/color-token-mapping.md`, such as `bg-canvas-base`, + `bg-surface-composer`, `bg-message-user-bg`, chip tokens, status tokens, and + clock/chart tokens. +- Opacity modifiers on semantic tokens when they preserve the role, such as + `border-border/70`, `bg-destructive/10`, or `text-muted-foreground/70`. + +## Review posture + +Only leave comments that are actionable. Name the specific semantic token or +shared primitive the author should use. If the automated +`design-system:tokens` check already catches the issue, mention that command as +the quick local verification path rather than restating the entire token system. diff --git a/.agents/skills/assistive-ux/SKILL.md b/.agents/skills/assistive-ux/SKILL.md new file mode 100644 index 000000000..1d23da0d2 --- /dev/null +++ b/.agents/skills/assistive-ux/SKILL.md @@ -0,0 +1,76 @@ +--- +name: assistive-ux +description: Use when adding, reviewing, designing, testing, or managing Assistive UX moments in Berd, including discover, suggest, and autoApply guidance, adaptive settings, behavior signals, retirement rules, or changes under src/shared/assistive-ux. +--- + +# Assistive UX + +Use this skill for Berd product intelligence moments that teach, +suggest, or adapt settings based on user behavior. + +Before changing code, read `docs/assistive-ux.md`. Treat that doc as the source +of product intent and architecture. + +## Core Rule + +Keep assistive state separate from user settings: + +```text +User settings answer: what should the product do? +Assistive UX answers: what has the app shown, observed, accepted, retired, or applied? +``` + +Do not store guidance lifecycle state inside a feature setting. Do not use +assistive state as the source of truth for product behavior. + +## Types + +- `discover`: teaches that a control, setting, or capability exists. It should + not change settings. +- `suggest`: notices a repeated behavior and asks before changing a setting. +- `autoApply`: changes a reversible local setting only after strong evidence, + then explains the change and offers undo. + +Use `autoApply` conservatively. Never auto-apply changes involving secrets, +credentials, permissions, destructive actions, provider setup, billing-like +behavior, or hard-to-reverse choices. + +## Implementation Workflow + +1. Define or update the rule in `src/shared/assistive-ux/registry.ts`. +2. Keep lifecycle reads and writes in `src/shared/assistive-ux/runtime.ts` and + `src/shared/assistive-ux/state.ts`. +3. Render the moment from the relevant feature surface. +4. Retire the moment when the user accepts it, dismisses it, sees it enough + times, or manually changes the related setting. +5. If the moment changes a setting, call the setting's public setter and provide + an undo path when the change is automatic. +6. Keep stored signals coarse. Do not store chat text, file paths, provider + secrets, credentials, or message contents. + +## UX Checklist + +Before shipping, confirm: + +- The moment appears close to the behavior it helps with. +- The copy explains the next action without selling the feature. +- The primary workflow still has visual priority. +- The moment has a clear retirement path. +- Repeated exposure is capped. +- Manual setting changes stop or reset related guidance. +- The behavior works in light and dark themes. + +## Testing + +Add focused tests for: + +- fresh eligibility +- shown-count updates +- max-show expiration +- acceptance or dismissal retirement +- related setting-change retirement +- invalid localStorage fallback +- any feature surface that renders or applies the moment + +For broad behavior, add Playwright coverage only when the flow benefits from +real app navigation or persistence across reloads. diff --git a/.agents/skills/berdctl-new-command/SKILL.md b/.agents/skills/berdctl-new-command/SKILL.md new file mode 100644 index 000000000..355a3a850 --- /dev/null +++ b/.agents/skills/berdctl-new-command/SKILL.md @@ -0,0 +1,47 @@ +--- +name: berdctl-new-command +description: Use when adding, extending, modifying, or removing berdctl commands, verbs, nouns, schemas, help text, or any of the berdctl surface. +--- + +# berdctl command changes + +One command is one renderer module. Descriptors are the source for zod +validation, TS input types, CLI help, and generated contract JSON. Background: +`docs/berdctl-architecture.md`. + +## Rules + +- Verbs must be UI-visible. Prefer reversible mutations, but one-way visible + product actions like creating a session or sending a prompt are allowed. + Delete, bulk, silent, invisible, or broadly destructive work requires + auth/confirmation design review. +- Put bounds in zod; clap mirrors them. +- Keep descriptors import-pure. Import stores, Tauri APIs, navigation, + providers, and caches only inside `execute`/`precheck`. +- Do not hand-edit generated contract JSON. +- Keep the broker command-agnostic; normal command changes do not touch + `src-tauri/plugins/berdctl/`. +- Breaking wire reshapes bump both discovery.rs constants and the contract + mirror. New commands and optional fields are not reshapes. + +## Workflow + +- Add commands with `just new-command `. +- Implement `src/features/berdctl/commands/impl/.ts` with: + `.strict()` schema, `.describe()` on every field, `summary`, `description`, + `helpFooter`, safety metadata, optional `precheck`, and `execute`. +- Update inventories only when needed: `registry.ts` group `cli.about`, + top-level help pins in `tree.rs`, and `distro/skills/berdctl/SKILL.md` for + overview changes. +- Run `pnpm generate:berdctl-contract`, + `pnpm vitest run src/features/berdctl`, and `cargo test -p berdctl` from + `src-tauri/`. +- Before review, confirm the verb is UI-visible and either reversible or a + direct visible product action, help is complete, error messages name the + fixing command, artifacts are regenerated, and tests cover the behavior. + +Keep CLI shapes expressible by the generated field walker. If a requested +operation needs an explicit null or another shape that generic flags cannot +represent safely, model it as a separate command action instead of adding +command-specific Rust mapping. Example: use `session move --project-id ` +for moving into a project and `session clear-project` for moving out. diff --git a/.agents/skills/code-review/SKILL.md b/.agents/skills/code-review/SKILL.md new file mode 100644 index 000000000..29bdf2f35 --- /dev/null +++ b/.agents/skills/code-review/SKILL.md @@ -0,0 +1,313 @@ +--- +name: code-review +description: >- + Senior engineer code review focused on catching issues before they become PR + comments. Reviews only changed lines, categorizes issues by priority, and fixes + them one by one. Use when the user says "code review", "review my code", + "review this branch", or wants pre-PR feedback. +--- + +# Pre-PR Code Review + +You are a senior engineer conducting a thorough code review. Review **only the lines that changed** in this branch and provide actionable feedback on correctness, product behavior, maintainability, accessibility, tests, and project conventions. Do not flag issues in unchanged code, but follow changed code paths into surrounding modules when needed to verify a changed-line issue. + +## Determine Files To Review + +**Before starting the review**, identify which files to review by checking: + +1. **Resolve the review base branch**: + - Prefer the branch's upstream merge base when one exists + - Otherwise, discover the default branch with `git remote show origin` + - Fall back to `origin/main` only if the repo does not expose a default branch + +2. **Run git commands** to check both: + - Committed changes: `git diff --name-only ...HEAD` + - Unstaged/staged changes: `git status --short` + +3. **Ask the user which set to review** if both exist: + - If there are both committed changes and unstaged/staged changes, ask: "I see both committed changes and unstaged/staged changes. Which would you like me to review?" + - **Option A**: Committed changes in this branch (compare against the review base) + - **Option B**: Current unstaged/staged changes + - **Option C**: Both + +4. **Proceed automatically** if only one set exists: + - If only committed changes exist, review those + - If only unstaged/staged changes exist, review those + - If neither exists, tell the user there are no changes to review + +5. **Get the file list** based on the user's choice: + - For committed changes: use `git diff --name-only ...HEAD` + - For unstaged/staged: use `git diff --name-only` and `git diff --cached --name-only` + - Filter to only include files that still exist, unless deleted files are relevant to the review + +**Only proceed with the review once you have the specific list of files to review.** + +## Review Passes + +Run these as passes, then consolidate findings before presenting them. A finding should appear once, even if multiple sections support it. + +- Use the baseline safety pass for correctness, regressions, data flow, async state, accessibility, i18n completeness, CI failures, and obvious cleanup. +- Use the maintainability pass for decomposition, layering, hooks vs helpers, type hygiene, duplication, naming, module boundaries, and refactor structure. +- Prefer the repo's existing architecture, naming, test patterns, and design system over generic advice. +- Do not duplicate the same underlying concern across passes. Report each issue once under the clearest framing. + +### Baseline Safety Pass + +#### Correctness And Product Behavior +- Does the change preserve the intended user flow? +- Are edge cases, empty states, error states, and loading states handled? +- Could a user see stale, misleading, or contradictory state after a failed action? +- Are optimistic updates paired with rollback or confirmation when needed? +- Are defaults, saved preferences, cached values, and fallbacks validated before reuse? +- Do parent-child state changes clear or recompute dependent values so stale state does not linger? + +#### React And Frontend Best Practices +- Are functional components and hooks used consistently where the project uses React? +- Are hooks called at the top level and in a stable order? +- Is state scoped to the smallest reasonable owner? +- Are list keys stable and unique? +- Are props and component contracts clear? +- Are expensive computations memoized only when needed? +- Are race conditions avoided in effects, async handlers, and animations? + +#### Type Safety +- Is `const` used by default, with `let` only when reassignment is needed? +- Are types explicit enough to protect the behavior without adding noise? +- Are `any`, unchecked type assertions, and non-null assertions avoided? +- Are nullable values handled with guards or optional chaining? +- Are repeated or verbose inline object shapes extracted into named types when that improves readability? +- Are shared types placed where the repo expects shared contracts to live? + +#### Design System And Styling +- Are design system components, tokens, and utilities used instead of custom styling? +- Are raw HTML controls avoided when the repo has shared primitives for the same job? +- Are theme tokens used for colors that must work across themes? +- For design-system changes, check the local guidance in `DESIGN.md`, `docs/color-token-mapping.md`, `src/shared/ui/AGENTS.md`, and `src/features/design-system/AGENTS.md` before judging the implementation. +- Check every changed visual surface in both light and dark mode. Missing dark-mode support is a review issue, not visual polish. +- Flag raw light/dark colors in component code, such as `text-black`, `bg-white`, `border-gray-*`, hex colors, or inline color styles, unless there is an approved design-system exception. +- Do not accept component-local `dark:` class patches for new color behavior when a semantic token can own the theme switch. New reusable color behavior must use semantic tokens with both `:root` and `.dark` values. +- When a PR adds a new token, require the token to have the right theme mapping, Tailwind bridge if needed, design-system manifest/docs coverage, and state coverage for default, hover, active/open, disabled, and focus-visible states. +- If a component creates a new visual pattern with repeated light/dark behavior, flag it as a design-system issue unless it is implemented as a shared variant or tokenized component pattern. +- Treat missing dark-mode support, raw light/dark colors, and new tokens without dark-mode mapping as [Must Fix] design-system findings unless the PR includes an explicit approved exception. +- New token names must describe product meaning, anatomy, property, and state, not the literal color or implementation. Use names shaped like `----` or `-----`, such as `--app-top-bar-control-fg-disabled`; reject names like `--black-icon`, `--gray-hover`, `--light-button`, or broad aliases that duplicate shadcn tokens. +- Use shadcn token names first for shared anatomy (`background`, `foreground`, `card`, `popover`, `muted`, `accent`, `primary`, `destructive`, `border`, `input`, `ring`). Berd extension tokens are allowed only for narrow product-specific surfaces or identities that do not map honestly to shadcn. +- Shared component APIs must carry reusable visual behavior. Add or extend a `variant` when the component needs a reusable visual treatment, intent, or product-surface role. Add or extend `size` when only spatial scale changes. Add a named prop when the component owns a semantic behavior or state, such as `loading`, `selected`, `invalid`, `open`, `feedbackState`, or `leftIcon`. +- Do not add boolean props that only toggle arbitrary class bundles. If a prop would mean "make it black", "add the special hover", or "use this one-off layout", require a semantic variant, size, token, or small composed wrapper instead. +- `className` in feature code may handle local layout and positioning. It must not be the primary home for repeated color, typography, radius, shadow, icon sizing, hover, active, selected, disabled, or focus behavior. Flag repeated `className` styling as a design-system issue. +- If a PR adds or changes a shared component variant, prop, token, or state, require the design-system explorer/manifest/token docs to stay in sync and expect `pnpm design-system:generate`, `pnpm design-system:tokens`, `pnpm design-system:manifest-check`, and relevant checks/tests to pass. +- Are utility classes static and compatible with the project's build tooling? +- Does the layout work across the breakpoints this feature supports? +- Are visual changes consistent with the existing product surface? + +#### Accessibility And Internationalization +- Are interactive controls keyboard-accessible and semantically correct? +- Do icon-only or color-only affordances have text alternatives? +- Are focus, selected, expanded, and disabled states exposed when relevant? +- Are user-facing strings routed through the project's localization system when one exists? +- Are translation keys stable and updated across required locales? +- Are user-facing errors understandable and routed through shared notification/error primitives? + +#### Async State, Data Flow, And Boundaries +- Is there a clear source of truth for data that crosses component, feature, storage, or service boundaries? +- Does UI state update at the right time relative to persisted or service-confirmed state? +- Are service/API/client calls kept in the repo's expected layer instead of embedded in render-heavy components? +- Do best-effort lookups fail softly when the primary user flow can continue? +- Are request, response, and persistence shapes kept minimal and consumed on both sides of the boundary? + +#### Goose Backend Ownership +- Treat checked-in patches against the Goose backend, patch registrations in `goose-backend.lock.json`, and scripts that apply or maintain local Goose patches as **P0 [Must Fix]** findings. +- Do not accept a local patch as an interim or expedient implementation. If Berd needs different Goose backend behavior, require the change to be contributed to the Goose backend/upstream repository, then update Berd's pinned backend commit after that change lands. +- Keep Berd-side changes limited to genuine client integration around backend capabilities. Flag backend behavior copied, overridden, or reimplemented in Berd when the durable ownership belongs in Goose. +- When reporting the finding, explain the build and maintenance risk: local patches can drift from the pinned backend, fail during backend upgrades or packaging, and create a second unsupported implementation path. + +#### Code Cleanliness +- Are there leftover `console.log` statements, commented-out code, unused imports, dead exports, or unrelated files? +- Are names clear and domain-specific? +- Are magic numbers or hard-coded policies named or explained? +- Are comments reserved for non-obvious decisions rather than restating the code? +- Are unrelated changes separated from the branch's main purpose? + +#### Test Coverage And Integrity +Review both sides: whether changed behavior needs protection, and whether changed tests still protect the intended behavior. + +**Coverage gaps** +- For each new behavior, bug fix, state transition, or boundary change, identify the regression that a test should catch. +- Check whether that regression is covered by an existing or changed test. For bug fixes, expect a test that fails without the fix when practical. +- Require the lowest reliable test level: unit tests for pure logic, component/integration tests for user interactions and state, and E2E tests only for critical flows that cross boundaries or depend on the real app environment. +- Do not require tests for trivial copy or visual-only changes unless behavior or accessibility changes. +- Treat missing coverage as **[Must Fix]** when an automatable regression could affect persisted data, async success/failure, recovery, destructive actions, shared behavior, or a reproduced bug. Otherwise use **[Your Call]** and explain the residual risk. + +**Changed-test integrity** +- When tests change or are deleted, compare the old and new assertions and verify that an intentional product-contract change justifies the update. +- Flag deleted or weakened assertions, broader mocks, skipped tests, added retries/timeouts, or snapshots/existence checks that replace meaningful behavior checks. +- Ask: would the revised test fail if the regression returned? A passing suite is not enough if the test was changed merely to accept the implementation. +- Accept removed tests only when the protected behavior was intentionally removed or equivalent coverage exists elsewhere. + +Always state whether coverage is sufficient, identify any uncovered regression risk, and say whether changed tests preserve or intentionally redefine the behavioral contract. + +### Maintainability Pass + +Use this focused pass when the user asks about cleanup, maintainability, decomposition, layering, type hygiene, duplication, dead code, readability, or extensibility. + +Keep the focus on behavior-preserving improvement. Favor the repo's existing architecture and patterns over broad refactor advice. + +- Review changed code for refactor quality, not just correctness. +- Review the final shape of the changed code, not whether it is better than what came before. +- Judge changes by whether they leave the code easier to maintain and extend in future work. +- Ask for approval before making code changes unless the user explicitly asks for fixes. + +#### Smell Checklist +Before finalizing the review, explicitly ask: + +- Is any view, module, or class still doing too many jobs? +- Is pure derivation logic trapped in a UI or orchestration layer? +- Is repeated async workflow ready for a focused helper or hook? +- Are helpers duplicated or living in the wrong layer? +- Are large inline shapes making the code hard to scan? +- Did logic move without moving or adding the right tests? +- Did the refactor preserve feature wiring while improving structure? + +#### Size And Decomposition +- Treat these as smell thresholds, not hard limits: + - components around 200 lines + - functions around 40 lines + - files around 300 lines + - JSX nesting around 4 levels +- Treat many unrelated state variables, handlers, and effects in one place as a smell even when line count is acceptable. +- Treat a file that owns multiple unrelated responsibilities across loading, derivation, mutation, and rendering as a smell unless there is a strong project reason. +- Split by responsibility, not by arbitrary line count. +- If a component or module does more than its name claims, rename it or split it. +- When substantial pure logic appears in UI code, prefer extracting it into pure helpers with direct tests. +- When substantial effectful workflow logic appears in UI code, prefer extracting it into a focused hook or orchestration helper consistent with the repo. + +#### Layer Discipline +- Keep rendering, state orchestration, transport, persistence, and pure domain logic in the layers where the project already expects them. +- Do not introduce a new architectural layer for a small local problem. +- Keep pure helpers free of framework, DOM, storage, network, or process side effects. +- Keep transport/client modules free of UI imports and presentation policy. +- Do not move local state into global state unless multiple consumers genuinely need it. +- If logic lives in the wrong layer after the PR, report that as an issue even if the PR reduced the amount of misplaced logic. + +#### Module Encapsulation +- Export the minimum surface a module needs to share. +- Keep helpers, constants, and intermediate transforms private unless another module genuinely needs them. +- Treat removing stale exports as a quality improvement. +- If a helper is used in only one module, default to keeping it local. +- If similar helpers appear across modules, consider extracting them when the shared shape is stable. + +#### Duplication And Abstractions +- Extract shared behavior once duplication is clear and the shared abstraction is stable. +- Two call sites can be enough when the shared shape is obvious and both call sites become simpler. +- Prefer hooks or stateful helpers for shared React state/effect orchestration. +- Prefer pure helpers for React-independent transforms, normalization, formatting, or parsing. +- Do not use a hook as the default extraction target for oversized components. +- Do not hide simple code behind an abstraction that makes the behavior harder to see. + +## Review And Fix Process + +### Step 0: Run Quality Checks + +Before reading code, establish a baseline with the project's non-mutating checks. + +1. Inspect project docs and scripts for the expected check commands. Useful places include `README`, `AGENTS.md`, `package.json`, `justfile`, `Makefile`, CI config, and language-specific config files. +2. Prefer check-only commands so the baseline does not mutate the working tree. +3. If a standard check command formats or rewrites files, do not run it as the baseline unless the user explicitly agrees or the tree is clean and you can clearly separate formatting output from authored changes. +4. Run targeted tests for the changed area when the project makes them easy to identify. + +Report the results as pass/fail. Any quality-check failure that blocks merge should appear at the top of the findings list as a high-priority issue. + +### Step 1: Conduct Review + +For each file in the review list: + +1. Run the relevant `git diff` command for that file to get the exact changed lines. +2. Review only changed lines against the review passes, following changed code paths into surrounding modules when needed to verify an issue. +3. For stateful UI or async flow changes, trace the user action through local state, persistence, service calls, success handling, and failure handling. +4. For refactors, run the maintainability pass before finalizing findings. +5. Note the file path and line numbers from the diff output for each issue found. + +### Step 2: Categorize Issues + +Assign each issue a priority level: + +- **P0**: Breaks functionality, build/type errors, security issues, or merge-blocking quality-check failures +- **P1-P2**: Performance problems, accessibility issues, code quality risks, unnecessary complexity, poor practices, design system violations +- **P3**: Style inconsistencies, minor improvements, missing type safety, animation issues, theme token usage +- **P4**: Cleanup, console logs, unused imports, dead code, unnecessary comments, unrelated changes + +If many high-severity issues exist in a file, assess whether a focused refactor would be simpler than individual fixes. + +### Step 3: Present Findings + +After reviewing all files, provide: + +- **Summary**: total files reviewed and an overall quality rating from 1-5 stars +- **Issues**: a single numbered list ordered by priority, P0 first and P4 last + +Each issue must follow this format: + +```text +1. Short Issue Title (P0) [Must Fix] + - Description of the issue and why it matters + - User effect if this ships + - Recommended fix + +2. Short Issue Title (P3) [Your Call] + - Description of the issue and why it may or may not need addressing + - User effect if this ships + - Recommended fix if the user chooses to act on it +``` + +Write the user-effect bullet in product language: describe what the user would experience, misunderstand, lose, or be blocked from doing if the issue reached production. + +Use a short, descriptive title (3-6 words max) so issues can be referenced by number. + +### Step 3b: Self-Check + +Before presenting findings to the user, silently review the issue list: + +1. For each issue, ask whether it is genuinely a problem or could be intentional/acceptable. +2. For each remaining issue, ask whether the recommended fix actually improves the code or is only a preference. +3. For async state or data-flow issues, ask whether state can truly disagree after a failure, fallback, or delayed update. +4. For refactor issues, ask whether a confirmed final-shape smell survives in decomposition, layering, effects, helpers, type shapes, duplication, tests, or feature wiring. + +After these passes, tag each surviving issue as one of: + +- **[Must Fix]**: clear violation, likely to get flagged in PR review +- **[Your Call]**: valid concern that may be intentional or a reasonable tradeoff + +Only present issues that survived these passes. + +Merge duplicate concerns before presenting findings. If there are no issues, say that clearly and mention any remaining test gaps or residual risk. + +### Step 4: Fix Issues + +**Before fixing**, ask: "Would you like me to fix these issues in order, or do you have questions about any of them first? I will fix each issue one by one and ask for approval before moving to the next one." + +**When approved**, work through issues one at a time in numbered order, P0 through P4. After each fix: + +1. Explain what changed and why. +2. Ask: "Does that look good? Ready to move on to issue [N]?" +3. Wait for confirmation before proceeding to the next issue. + +When adding documentation comments: + +- Only add comments for non-obvious things: magic numbers, complex logic, design decisions, or workarounds. +- If you call out something as confusing or hard-coded in your review and suggest documentation, it is acceptable to add a comment when approved. +- Do not add comments that simply restate what the code does. + +Cleanup tasks like removing comments should be done last, because earlier fixes might introduce new comments that also need cleanup. + +### Step 5: Ready To Ship + +Once all approved issues are fixed, display: + +--- + +**Code review complete. All approved issues have been addressed.** + +Your code is ready to commit and push. Run the repo's configured gates before opening or updating the PR. + +Next steps: generate a PR summary that explains the intent of this change, what files were modified and why, and how to verify the changes work. + +--- diff --git a/.agents/skills/create-pr/SKILL.md b/.agents/skills/create-pr/SKILL.md new file mode 100644 index 000000000..023bd6441 --- /dev/null +++ b/.agents/skills/create-pr/SKILL.md @@ -0,0 +1,174 @@ +--- +name: create-pr +description: >- + Create a GitHub PR from the current branch: handle uncommitted changes, generate + a summary, submit via gh CLI, then watch the PR to a ready state — fixing failing + checks and addressing review comments. Use when the user says "create PR", "open PR", + "submit PR", "push PR", or wants to create a pull request. +--- + +# Create PR + +Create a GitHub PR from the current branch: handle uncommitted changes, generate a summary, submit, then watch the PR until it is in a ready state. + +## Step 1: Resolve Base Branch + +Before doing anything else, identify the PR base branch. Prefer the branch's upstream base or the repository's default branch from `git remote show origin`. Fall back to `origin/main` only if the repo does not expose a default branch. + +Remind the user to rebase onto the base branch if they have not already. Ask if they would like to proceed or rebase first. + +## Step 2: Check for Uncommitted Changes + +Run `git status` to check for staged, unstaged, or untracked changes. + +- If there are uncommitted changes, show the user what's outstanding and ask if they'd like to commit them before creating the PR. +- If the user says yes, stage the relevant files, draft a concise commit message based on the changes, and commit. +- If there are no uncommitted changes, move on. + +## Step 3: Gather Branch Context + +Run these commands in parallel to understand the branch: + +1. `git log ..HEAD --oneline` to see all commits on this branch. +2. `git diff ..HEAD --stat` to get the list of changed files. +3. `git diff ..HEAD` to understand what changed in each file. +4. `git rev-parse --abbrev-ref HEAD` to get the current branch name. +5. `git status` to check if the branch has been pushed to remote. + +## Step 4: Generate PR Title and Summary + +**Title:** Generate a concise PR title (under 72 characters) that captures the intent of the change. Use conventional style: lowercase, imperative mood (e.g., "prevent chat list from reordering when renaming sessions"). + +**Body:** Generate a PR summary with these sections: + +### Section 1: Overview + +Start with metadata tags, then a Problem/Solution block: + +- `**Category:**` — one of: `new-feature`, `improvement`, `fix`, `infrastructure` +- `**User Impact:**` — one sentence describing what changed from the user's perspective. Write this as a standalone sentence a non-technical stakeholder would understand (e.g., "Users can now create and schedule repeatable tasks directly from the desktop app."). This line is used for project changelogs. +- `**Problem:**` — describe the user-facing confusion, mismatch, or friction this PR addresses. +- `**Solution:**` — explain how the change resolves that UX problem and, if applicable, why the approach was chosen. + +Keep Problem + Solution to 2-4 sentences total. Prioritize intent and expected user experience, but include brief high-level implementation rationale when it explains reliability, maintainability, or code quality. + +### Section 2: Changes + +Wrap this section in a collapsible `
` block with the summary "File changes". + +Inside, list every changed file. For each file, use the filename as a bold header, then underneath write one or two sentences about what was changed and why. Focus on intent, not implementation details. + +Format: +``` +
+File changes + +**path/to/file.ts** +What changed and why. + +**path/to/other.rs** +What changed and why. + +
+``` + +## Step 5: Resolve And Link A GitHub Issue + +Keep issue tracking lightweight and automatic. Complete the issue decision before pushing or creating the PR; a user request to create a PR is not permission to skip issue resolution. + +Before creating the PR: + +1. Look for an explicit GitHub issue reference in the current conversation, branch name, commit messages, PR title, or PR body. +2. If no issue is explicit, search the repository's open GitHub issues using the branch name, commit subjects, changed-file intent, and PR title. +3. If one issue clearly matches the same user need or implementation intent, use it. If a few issues could match, pause and ask the user which one to link. If none match, continue without creating an issue unless the user asks for one. + +When an issue is resolved, include `Closes #` in the PR body so GitHub links the work and closes the issue when the PR merges. Use `Refs #` instead when the PR contributes to the issue without completing it. Verify the rendered PR shows the intended issue relationship. + +## Step 6: Push and Create PR + +1. Push the branch to remote if it hasn't been pushed yet: `git push -u origin HEAD` +2. Create the PR using `gh pr create` with the generated title and body. Use a HEREDOC for the body to preserve formatting. +3. Output the PR URL as a clickable hyperlink so the user can open it directly. + +## Step 7: Watch the PR to a Ready State + +Once the PR is created, do not stop. Own the loop from "PR is open" to "PR is in a ready state": watch GitHub, fix failing checks, and address review comments until checks pass and no actionable feedback remains. This step ends at a ready state — it does not merge the PR. + +Keep track of the latest head SHA. Any new push changes the CI/review baseline and restarts this loop. + +### Poll feedback first, then CI + +Use a non-blocking polling loop until both review feedback and CI have settled. **Never use `gh pr checks --watch` or any other command that blocks until checks finish** because review feedback commonly arrives while CI is still running. + +Each polling cycle must run in this order: + +1. Fetch all new top-level comments, review summaries, inline comments, and unresolved review threads. +2. If actionable feedback exists, evaluate and address it immediately. Do not wait for pending checks; any fix will restart CI anyway. +3. Only when no actionable feedback remains, fetch the current check states and handle failures or pending checks. +4. If checks are still pending and no feedback needs action, sleep for a fixed interval, then begin a new cycle from step 1. + +Use whatever non-blocking commands are available, such as: + +- `gh pr view` for PR state, reviews, comments, and mergeability. +- Repeated `gh pr checks` without `--watch` for current check status. +- `gh run list`, `gh run view`, and `gh run rerun` for workflow failures and reruns. +- GitHub API/GraphQL to inspect review threads and unresolved conversations. + +After every code push, comment reply, or thread resolution, restart from the latest head SHA and begin with a fresh feedback sweep. + +### Handle failing or stuck checks + +When a check fails or gets stuck, decide whether it looks flaky/infrastructure-related or caused by the PR. + +**If it looks flaky or infrastructure-related:** + +1. Try to rerun the failed job/check first. +2. If rerun is not available because of permissions or tooling limits, push an empty commit as a last-resort CI kick: confirm the working tree has no unrelated changes, then `git commit --allow-empty -m "chore: rerun CI"` and push. +3. After any rerun or empty commit, restart the loop from the latest head SHA. + +**If it looks like a real failure:** + +1. Read the failing logs enough to understand the cause. +2. Reproduce locally when practical. +3. Fix the root cause, not just the symptom. +4. Run the relevant local tests, linters, type checks, or targeted commands. +5. Commit only the intended changes and push, then restart the loop from the new head SHA. + +Prefer rerunning before pushing an empty commit. Prefer fixing code before repeatedly rerunning a failure that has evidence of being real. + +### Handle GitHub comments + +Read all PR comments, review summaries, inline review comments, and unresolved review threads from both people and bots. + +Evaluate every comment from two perspectives: + +- Senior software engineer: correctness, maintainability, test coverage, reliability, security, architecture, readability, and long-term cost. +- Product designer: user behavior, UX clarity, accessibility, visual/system consistency, edge cases, and whether the implementation matches product intent. + +For each comment, choose the path that best matches the intent of the PR: + +1. **Apply the recommended fix** — the suggestion is right for the PR, so make it as described. +2. **Apply a better or different fix** — the comment points at a real issue, but a larger, more holistic, or a simpler fix more closely matches the PR's intent. Prefer the systemic fix over a band-aid, and use the design system and its tokens for UI work. +3. **Decline the fix** — the suggestion is not valid for the intention of the PR (incorrect, harmful, out of scope, or would work against the PR's goal). + +After deciding: + +- If you take path 1 or 2: make the code changes, commit, and push. Then **always comment back** explaining what changed, and **resolve the thread** when the comment is a resolvable review thread. +- If you take path 3: **always comment back** with a concise, respectful rationale for why you did not make the change, but **do not resolve** the thread — leave it open so the user can see that something was not resolved and decide for themselves. + +Only review threads can be resolved. Top-level PR comments and review summaries are not resolvable threads, so reply to them when they call for it but do not try to resolve them. + +After any code change, comment reply, or thread resolution, check whether new feedback arrived and restart this loop if needed. + +### Done + +The workflow is complete when all of the following are true on the latest head commit: + +- Checks are passing. +- No actionable feedback remains. Every comment has either been addressed (fixed, committed, replied to, and its review thread resolved) or intentionally declined (replied to with a rationale and left open on purpose). Declined-but-open threads and non-resolvable top-level comments do not block the ready state — do not keep polling for them or try to resolve them. + +When that state is reached, tell the user the PR is in a ready state, and include the PR URL, what CI/review issues you handled, and any commits you pushed. Stop watching unless the user asks you to keep going. + +## Tone + +Write from the perspective of a product designer explaining their thinking to engineers. Be clear and concise — just enough to establish intent. They can read the code; your job is to guide their understanding of the "why." diff --git a/.agents/skills/experimental-features/SKILL.md b/.agents/skills/experimental-features/SKILL.md new file mode 100644 index 000000000..6b1be9fed --- /dev/null +++ b/.agents/skills/experimental-features/SKILL.md @@ -0,0 +1,139 @@ +--- +name: experimental-features +description: Use when adding, reviewing, configuring, graduating, or removing Berd experiments. +--- + +# Experimental Features + +Use experiments for opt-in, user-local in-progress Berd UI or workflow behavior. +Do not use experiments for secrets, credentials, backend authority, packaged +policy, or app state that should survive graduation as a normal preference. + +## When To Use Experiments + +- An individual user opts into unstable UI or workflow behavior. +- Stable behavior can remain the default path. +- Config is small, non-sensitive, typed, and user-editable. +- The feature can be graduated or removed later. + +## When To Use `distro.json` + +Use `distro.json` for packaged build policy and startup defaults, especially +when the Tauri shell or sidecar needs bundled resources/config. + +Good distro fits include `providerAllowlist`, `kgoose`, +`featureToggles.costTracking`, bundled `config.yaml`, `bin/`, `skills/`, and +`agents/`. + +Do not use `distro.json` for normal app state, user preferences, dynamic runtime +switches, ACP-backed data, or per-user experiments. + +## Registry Shape + +Add experiments only in +`src/features/experiments/experimentDefinitions.ts`. + +Each definition needs: + +- `id`: stable kebab-case string +- `titleKey` and `descriptionKey`: settings i18n keys +- `config`: optional typed controls + +Experiments without a manual per-experiment override follow the global +`autoEnable` preference. That preference defaults on in dev builds and off in +production builds. Users can force an experiment on/off or reset it back to auto +from settings. + +Config entries under an experiment are settings for that experiment, not nested +experiments or independent feature flags. Keep them stored with the parent +experiment and gate their runtime effect on the parent experiment being enabled. + +Supported config controls: + +- `boolean`: switch with a boolean default +- `select`: fixed string options with a default +- `number`: default plus optional min/max/step +- `text`: default plus optional placeholder; never for secrets + +Use `getExperiment(id)` or `useExperiment(id)` for callers. When an experiment is +disabled, keep config stored but gate behavior as disabled. + +## Storage Contract + +Experiment preferences live in `localStorage` under +`goose:experimental-features`: + +```json +{ + "version": 2, + "autoEnable": false, + "experiments": { + "experiment-id": { + "enabled": false, + "config": {} + } + } +} +``` + +- Treat `version` as real schema state. On newer stored versions, abort writes + instead of overwriting; on older versions, migrate explicitly or discard. +- Store `autoEnable` as the global default provider. Store `enabled` only for + explicit per-experiment overrides; clearing `enabled` returns that experiment + to auto behavior and must preserve `config`. +- Keep `config` under the parent experiment. Do not migrate config keys into + separate experiment ids or apply auto-enable behavior to individual settings. +- Preserve unknown experiment ids when writing so branch switches do not erase + local choices. +- Write only the touched experiment/key and re-read latest storage immediately + before saving to reduce cross-window clobbering. +- Setters return `boolean`; callers must surface failed writes to users. +- Use `useSyncExternalStore` for React subscriptions. Memoize only the current + raw storage value per registry/id; do not retain historical snapshot keys. + +## Config UX + +- Boolean controls use switches. +- Select controls use fixed options. +- Number controls keep a string draft while editing, commit on blur, treat empty + input as no write, commit on Enter, and clamp to min/max on commit. +- Text controls are never for secrets. +- Config controls may stay editable in storage while disabled, but UI should make + disabled/inert behavior clear when the experiment is off. + +## Tauri Guardrails + +Do not add Rust commands, capabilities, or permissions unless the experiment +needs backend authority. If backend access is required, add the smallest typed +command possible, validate all IPC input, return `Result`, and use async for +heavy work so the UI does not freeze. + +When adding commands, update capabilities with least privilege. If backend state +is needed, use Tauri managed state deliberately and protect shared mutable state +correctly. + +## Testing + +Cover: + +- dev default-on and production default-off behavior +- global auto-enable overrides and per-experiment explicit override precedence +- resetting an explicit override back to auto while preserving config +- enabled and disabled behavior for any gated caller +- invalid localStorage fallback +- unsupported storage version fallback or migration +- typed config validation +- number-control draft and clamp behavior +- same-window preference updates +- cross-window storage events +- read and write storage failures +- preserving unknown experiment ids when writing +- injected test registry UI behavior without shipping fake experiments + +Run focused Vitest tests and `just check` for frontend changes. + +## Graduation Cleanup + +When graduating or removing an experiment, remove the registry entry, i18n keys, +settings UI tests, storage assumptions, and all gated code paths. Keep migrations +small and explicit if the final feature needs a real user preference. diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 000000000..4381b1cf6 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,15 @@ +*.ts text diff +*.tsx text diff +src/shared/assets/avatars/webm/**/*.webm filter=lfs diff=lfs merge=lfs -text +src/shared/assets/avatars/hevc/**/*.mov filter=lfs diff=lfs merge=lfs -text +src/shared/assets/avatars/hevc/**/*.mp4 filter=lfs diff=lfs merge=lfs -text + +# tauri-build embeds permission TOML prose into gen/schemas/*.json; keep the +# sources LF on Windows checkouts so regeneration is byte-identical across +# platforms instead of flip-flopping \r\n into the committed JSON. +src-tauri/plugins/*/permissions/**/*.toml text eol=lf +src-tauri/gen/schemas/*.json text eol=lf +src-tauri/tauri.windows.conf.json text eol=lf +# sqlx migration checksums cover exact bytes; checkout conversion would make an +# already-applied migration appear modified and abort application startup. +src-tauri/migrations/*.sql text eol=lf diff --git a/.github/ISSUE_TEMPLATE/bug-report.md b/.github/ISSUE_TEMPLATE/bug-report.md index 25f0bc5ed..3d97a3ac6 100644 --- a/.github/ISSUE_TEMPLATE/bug-report.md +++ b/.github/ISSUE_TEMPLATE/bug-report.md @@ -1,31 +1,24 @@ --- -name: 🐛 Bug Report -about: Thank you for taking the time, please report a reproducible bug -title: "[Bug] " +name: Bug report +about: Report a reproducible bug in Berd labels: bug -assignees: add codeowner's @name here - --- **Describe the bug** -*A clear and concise description of what the bug is.* +A clear and concise description of what the bug is. -**To Reproduce:** -*Steps to reproduce the behavior:* +**Steps to reproduce** 1. Go to '...' -2. Click on '....' -3. Scroll down to '....' -4. See error - -**Expected behavior:** -*A clear and concise description of what you expected to happen.* +2. Click on '...' +3. See error -**Supporting Material** -*If applicable, add screenshots, output log and/or other documentation to help explain your problem.* +**Expected behavior** +What did you expect to happen? -**Environment (please complete the following information):** - - OS: [ex: iOS] - - Version +**Version and platform** +Find your version in Settings. Write "unknown" if you cannot determine it. +- Berd version: +- OS: -**Additional context** -Add any other context that you feel is relevant about the problem here. +**Logs / additional context** +Paste any relevant logs, error messages, or screenshots here. diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml index 0ba9db253..0086358db 100644 --- a/.github/ISSUE_TEMPLATE/config.yml +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -1,4 +1 @@ -contact_links: - - name: ❓ Questions and Help 🤔 - url: https://discord.gg/block-opensource (/add your discord channel if applicable) - about: This issue tracker is not for support questions. Please refer to the community for more help. +blank_issues_enabled: true diff --git a/.github/ISSUE_TEMPLATE/feature-request.md b/.github/ISSUE_TEMPLATE/feature-request.md new file mode 100644 index 000000000..837bc2f05 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature-request.md @@ -0,0 +1,21 @@ +--- +name: Feature request +about: Propose a new feature or improvement +labels: enhancement +--- + +**Motivation** +What problem does this solve? Who runs into it and when? + +**Proposed solution** +Describe what you'd like to see. + +**Alternatives considered** +What other approaches did you consider? + +**Additional context** +Anything else that helps — links, screenshots, prior art. + +--- + +Before opening: please [search open issues and PRs](https://github.com/squareup/berd/issues?q=is%3Aopen) for duplicates — link the closest one, or say "none found". diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 000000000..238878c74 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,8 @@ +## Summary + + +### Related issue + + +### Testing + diff --git a/.github/workflows/bump-sq-kgoose-formula.yml b/.github/workflows/bump-sq-kgoose-formula.yml new file mode 100644 index 000000000..da2050b35 --- /dev/null +++ b/.github/workflows/bump-sq-kgoose-formula.yml @@ -0,0 +1,129 @@ +# When you push a bb-cli release tag to this repo, tell squareup/homebrew-formulas +# to bump the version of sq-kgoose.rb. +name: Bump sq-kgoose Formula + +on: + push: + tags: + - 'v[0-9]*.[0-9]*.[0-9]*' + - '[0-9]*.[0-9]*.[0-9]*' + workflow_dispatch: + inputs: + tag: + description: Git tag + required: true + +concurrency: + group: bump-sq-kgoose-formula-${{ github.event.inputs.tag || github.ref_name }} + cancel-in-progress: false + +permissions: + contents: read + +jobs: + bump-formula: + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + with: + ref: ${{ github.event.inputs.tag || github.ref }} + fetch-depth: 0 + + - name: Prepare formula bump + id: vars + env: + INPUT_TAG: ${{ github.event.inputs.tag }} + REF_NAME: ${{ github.ref_name }} + EVENT_NAME: ${{ github.event_name }} + run: | + set -euo pipefail + + tag="${INPUT_TAG:-$REF_NAME}" + if [[ ! "$tag" =~ ^v?[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "formula bump tags must be semver like v0.1.0 or 0.1.0; got: $tag" >&2 + exit 1 + fi + + manifest_version="$(sed -n 's/^version = "\([^"]*\)"/\1/p' bb-cli/Cargo.toml | head -n1)" + if [[ -z "$manifest_version" ]]; then + echo "failed to read version from bb-cli/Cargo.toml" >&2 + exit 1 + fi + + should_dispatch=true + if [[ "$EVENT_NAME" != "workflow_dispatch" ]]; then + tag_ref="refs/tags/$tag" + previous_tag="$( + git for-each-ref refs/tags \ + --sort=-creatordate \ + --format='%(refname)' \ + | grep -E '^refs/tags/v?[0-9]+\.[0-9]+\.[0-9]+$' \ + | grep -v -Fx "$tag_ref" \ + | while read -r candidate; do + if git merge-base --is-ancestor "$candidate" HEAD; then + printf '%s\n' "${candidate#refs/tags/}" + break + fi + done + )" + + if [[ -n "$previous_tag" ]]; then + previous_manifest_version="" + if previous_manifest="$(git show "${previous_tag}:bb-cli/Cargo.toml" 2>/dev/null)"; then + previous_manifest_version="$(printf '%s\n' "$previous_manifest" \ + | sed -n 's/^version = "\([^"]*\)"/\1/p' \ + | head -n1)" + fi + + if [[ -z "$previous_manifest_version" ]]; then + echo "Previous tag $previous_tag does not include bb-cli/Cargo.toml; dispatching formula bump" + elif [[ "$manifest_version" == "$previous_manifest_version" ]]; then + should_dispatch=false + echo "bb-cli version is still $manifest_version from previous tag $previous_tag; skipping formula bump" + fi + else + echo "No previous semver tag found; dispatching formula bump" + fi + fi + + echo "formula=sq-kgoose" >> "$GITHUB_OUTPUT" + echo "formula_version=$manifest_version" >> "$GITHUB_OUTPUT" + echo "should_dispatch=$should_dispatch" >> "$GITHUB_OUTPUT" + echo "tag=$tag" >> "$GITHUB_OUTPUT" + + - name: Check Homebrew bump secrets + if: steps.vars.outputs.should_dispatch == 'true' + env: + HOMEBREW_FORMULAS_APP_ID: ${{ secrets.HOMEBREW_FORMULAS_APP_ID }} + HOMEBREW_FORMULAS_PRIVATE_KEY: ${{ secrets.HOMEBREW_FORMULAS_PRIVATE_KEY }} + run: | + set -euo pipefail + + if [[ -z "$HOMEBREW_FORMULAS_APP_ID" ]]; then + echo "HOMEBREW_FORMULAS_APP_ID is not configured for this repository" >&2 + exit 1 + fi + + if [[ -z "$HOMEBREW_FORMULAS_PRIVATE_KEY" ]]; then + echo "HOMEBREW_FORMULAS_PRIVATE_KEY is not configured for this repository" >&2 + exit 1 + fi + + - name: Generate token + if: steps.vars.outputs.should_dispatch == 'true' + uses: actions/create-github-app-token@a8d616148505b5069dccd32f177bb87d7f39123b # v2.1.1 + id: generate_token + with: + app-id: ${{ secrets.HOMEBREW_FORMULAS_APP_ID }} + private-key: ${{ secrets.HOMEBREW_FORMULAS_PRIVATE_KEY }} + repositories: homebrew-formulas + + - name: Dispatch formula bump + if: steps.vars.outputs.should_dispatch == 'true' + uses: peter-evans/repository-dispatch@ff45666b9427631e3450c54a1bcbee4d9ff4d7c0 + with: + token: ${{ steps.generate_token.outputs.token }} + repository: squareup/homebrew-formulas + event-type: bump_formula + client-payload: '{"repo":"${{ github.repository }}", "formula":"${{ steps.vars.outputs.formula }}", "version":"${{ steps.vars.outputs.formula_version }}", "tag":"${{ steps.vars.outputs.tag }}"}' diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 000000000..b95ff48e5 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,207 @@ +name: CI + +on: + pull_request: + push: + branches: + - main + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: ci-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +env: + CI: "true" + FORCE_COLOR: "1" + +jobs: + frontend: + name: Frontend checks and unit tests + runs-on: macos-latest + timeout-minutes: 30 + steps: + - name: Check out repository + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + with: + persist-credentials: false + + - name: Activate Hermit + uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1 + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Build vendored SDK + run: pnpm --filter @aaif/goose-sdk build + + - name: Run frontend checks + run: just check + + - name: Run unit tests + run: just test + + - name: Run release script tests + run: just release-scripts-test + + - name: Upload test results + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: frontend-test-results + path: | + coverage/ + test-results/ + if-no-files-found: ignore + retention-days: 7 + + rust: + name: Tauri Rust checks (${{ matrix.platform }}) + strategy: + fail-fast: false + matrix: + include: + - runner: macos-latest + platform: macOS + - runner: ubuntu-latest + platform: Linux + runs-on: ${{ matrix.runner }} + timeout-minutes: 45 + env: + BERD_TAURI_CARGO_TARGET_DIR: ${{ github.workspace }}/src-tauri/target + steps: + - name: Check out repository + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + with: + persist-credentials: false + + - name: Activate Hermit + uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1 + + - name: Restore Rust cache + uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 + with: + workspaces: src-tauri -> target + + - name: Install Linux Tauri dependencies + if: runner.os == 'Linux' + run: | + sudo apt-get update + sudo apt-get install -y --no-install-recommends \ + pkg-config \ + libwebkit2gtk-4.1-dev \ + libgtk-3-dev \ + libayatana-appindicator3-dev \ + librsvg2-dev + + - name: Check Rust formatting + run: just tauri-fmt-check + + - name: Check Tauri and Rust crates + run: just tauri-check + + - name: Run Rust tests + run: just tauri-test + + - name: Run clippy + run: just clippy + + rust-windows: + name: Tauri Rust checks (Windows) + runs-on: windows-latest + timeout-minutes: 60 + env: + BERD_TAURI_CARGO_TARGET_DIR: ${{ github.workspace }}/src-tauri/target + CARGO_PROFILE_DEV_DEBUG: "0" + CARGO_PROFILE_TEST_DEBUG: "0" + JUST_UNSTABLE: "1" + steps: + - name: Check out repository + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + with: + persist-credentials: false + + - name: Install just + uses: taiki-e/install-action@7f4eb899022d8fe70b20c4f3de697aa85c309026 # v2.85.11 + with: + tool: just@1.40.0 + + - name: Restore Rust cache + uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 + with: + workspaces: src-tauri -> target + + - name: Run Windows-native Rust tests + shell: powershell + run: just ci-windows + + transcript-virtualization: + name: Transcript virtualization + needs: frontend + runs-on: macos-latest + timeout-minutes: 30 + steps: + - name: Check out repository + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + with: + persist-credentials: false + + - name: Activate Hermit + uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1 + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Build vendored SDK + run: pnpm --filter @aaif/goose-sdk build + + - name: Install Playwright browser + run: pnpm exec playwright install chromium + + - name: Run transcript virtualization tests + run: pnpm test:transcript-virtualization:ci + + - name: Upload Playwright results + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: transcript-virtualization-results + path: | + tests/transcript-virtualization/transcript-playwright-report/ + test-results/ + if-no-files-found: ignore + retention-days: 7 + + build: + name: Frontend build smoke + needs: frontend + runs-on: macos-latest + timeout-minutes: 30 + steps: + - name: Check out repository + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + with: + persist-credentials: false + + - name: Activate Hermit + uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1 + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Build vendored SDK + run: pnpm --filter @aaif/goose-sdk build + + - name: Build frontend + run: just build + + - name: Upload frontend build + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: frontend-build + path: dist/ + if-no-files-found: error + retention-days: 7 diff --git a/.github/workflows/public-npm-install.yml b/.github/workflows/public-npm-install.yml new file mode 100644 index 000000000..f98f94cb5 --- /dev/null +++ b/.github/workflows/public-npm-install.yml @@ -0,0 +1,37 @@ +name: Public npm install + +on: + pull_request: + workflow_dispatch: + +permissions: + contents: read + +jobs: + install: + name: Clean-room frozen install + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - name: Check out repository + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + with: + persist-credentials: false + + - name: Install pinned pnpm + run: | + set -euo pipefail + corepack enable + corepack prepare pnpm@10.33.0 --activate + pnpm --version | grep -Fx '10.33.0' + + - name: Verify public lockfile and install from a fresh store + env: + PNPM_STORE_DIR: ${{ runner.temp }}/pnpm-store + run: | + set -euo pipefail + if grep -Fq 'block-artifacts.com' pnpm-lock.yaml; then + echo 'pnpm lockfile must not reference Block Artifactory' >&2 + exit 1 + fi + pnpm install --frozen-lockfile --store-dir "$PNPM_STORE_DIR" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 000000000..ca92de823 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,571 @@ +name: Berd Release + +on: + push: + tags: + - 'v[0-9]*.[0-9]*.[0-9]*' + workflow_dispatch: + inputs: + tag: + description: Existing immutable v tag to recover; select this same tag as the run ref + required: true + type: string + +permissions: + contents: read + +# Serialize the entire staging lifecycle for a tag. Recovery may delete an +# incomplete platform payload before rebuilding it, so concurrent runs for the +# same immutable tag would otherwise race against each other's uploads. +concurrency: + group: berd-release-${{ github.ref }} + cancel-in-progress: false + +env: + RELEASE_CHANNEL_CONFIG: scripts/release/release-channel.json + +jobs: + setup: + name: Verify immutable release source + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: write + outputs: + repository: ${{ steps.channel.outputs.repository }} + rolling_tag: ${{ steps.channel.outputs.rolling_tag }} + macos_platform: ${{ steps.channel.outputs.macos_platform }} + windows_platform: ${{ steps.channel.outputs.windows_platform }} + linux_platform: ${{ steps.channel.outputs.linux_platform }} + tag: ${{ steps.release.outputs.tag }} + version: ${{ steps.release.outputs.version }} + source_sha: ${{ steps.source.outputs.source_sha }} + staged_macos_assets_ready: ${{ steps.assets.outputs.macos_ready }} + staged_windows_assets_ready: ${{ steps.assets.outputs.windows_ready }} + staged_linux_assets_ready: ${{ steps.assets.outputs.linux_ready }} + steps: + - name: Check out requested immutable ref + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + with: + ref: ${{ github.ref }} + fetch-depth: 0 + persist-credentials: false + + - name: Validate requested tag + id: release + env: + EVENT_NAME: ${{ github.event_name }} + PUSH_TAG: ${{ github.ref_name }} + DISPATCH_TAG: ${{ inputs.tag }} + run: | + set -euo pipefail + if [[ "$EVENT_NAME" == "push" ]]; then + TAG="$PUSH_TAG" + else + TAG="$DISPATCH_TAG" + fi + # shellcheck source=scripts/release/lib.sh + source scripts/release/lib.sh + validate_release_tag "$TAG" || { + echo "::error::Expected an existing v tag without build metadata; got '$TAG'" + exit 1 + } + if [[ "$EVENT_NAME" == "workflow_dispatch" && "${GITHUB_REF:-}" != "refs/tags/$TAG" ]]; then + echo "::error::Recovery dispatch must run from refs/tags/$TAG; got '${GITHUB_REF:-}'" + exit 1 + fi + echo "tag=$TAG" >> "$GITHUB_OUTPUT" + echo "version=${TAG#v}" >> "$GITHUB_OUTPUT" + + - name: Verify tag-bound source on origin + id: source + env: + TAG: ${{ steps.release.outputs.tag }} + run: | + set -euo pipefail + scripts/release/github/verify-release-ref.sh "$TAG" + echo "source_sha=$(git rev-parse 'HEAD^{commit}')" >> "$GITHUB_OUTPUT" + + - name: Load release channel boundary + id: channel + env: + ACTUAL_REPOSITORY: ${{ github.repository }} + run: | + set -euo pipefail + # shellcheck source=scripts/release/lib.sh + source scripts/release/lib.sh + load_release_channel "$RELEASE_CHANNEL_CONFIG" + REPOSITORY="$RELEASE_REPOSITORY" + ROLLING_TAG="$RELEASE_ROLLING_TAG" + [[ "${#RELEASE_PLATFORMS[@]}" -eq 3 && "${RELEASE_PLATFORMS[0]}" == "darwin-aarch64" && "${RELEASE_PLATFORMS[1]}" == "windows-x86_64" && "${RELEASE_PLATFORMS[2]}" == "linux-x86_64" ]] || { + echo "::error::Release platforms must be darwin-aarch64, windows-x86_64, and linux-x86_64" + exit 1 + } + [[ "$ACTUAL_REPOSITORY" == "$REPOSITORY" ]] || { + echo "::error::Workflow repository $ACTUAL_REPOSITORY does not match configured release repository $REPOSITORY" + exit 1 + } + echo "repository=$REPOSITORY" >> "$GITHUB_OUTPUT" + echo "rolling_tag=$ROLLING_TAG" >> "$GITHUB_OUTPUT" + echo "macos_platform=${RELEASE_PLATFORMS[0]}" >> "$GITHUB_OUTPUT" + echo "windows_platform=${RELEASE_PLATFORMS[1]}" >> "$GITHUB_OUTPUT" + echo "linux_platform=${RELEASE_PLATFORMS[2]}" >> "$GITHUB_OUTPUT" + + - name: Activate Hermit + uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1 + + - name: Ensure immutable versioned release + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + REPOSITORY: ${{ steps.channel.outputs.repository }} + SOURCE_SHA: ${{ steps.source.outputs.source_sha }} + TAG: ${{ steps.release.outputs.tag }} + VERSION: ${{ steps.release.outputs.version }} + run: just release-ensure-versioned "$REPOSITORY" "$TAG" "$VERSION" "$SOURCE_SHA" + + - name: Reconcile staged platform payloads + id: assets + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + REPOSITORY: ${{ steps.channel.outputs.repository }} + TAG: ${{ steps.release.outputs.tag }} + VERSION: ${{ steps.release.outputs.version }} + run: just release-reconcile-assets "$REPOSITORY" "$TAG" "$VERSION" "$GITHUB_OUTPUT" + + stage-macos: + name: Build, sign, and stage macOS arm64 + needs: setup + if: needs.setup.outputs.staged_macos_assets_ready != 'true' + runs-on: macos-latest + timeout-minutes: 120 + permissions: + contents: write + id-token: write + attestations: write + env: + VERSION: ${{ needs.setup.outputs.version }} + TAG: ${{ needs.setup.outputs.tag }} + SOURCE_SHA: ${{ needs.setup.outputs.source_sha }} + REPOSITORY: ${{ needs.setup.outputs.repository }} + PLATFORM: ${{ needs.setup.outputs.macos_platform }} + BERD_RELEASE_CHANNEL: public + BERD_UPDATER_ENDPOINT: https://github.com/${{ needs.setup.outputs.repository }}/releases/download/${{ needs.setup.outputs.rolling_tag }}/latest.json + BERD_UPDATER_PUBLIC_KEY: ${{ vars.BERD_UPDATER_PUBLIC_KEY }} + steps: + - name: Check out verified source + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + with: + ref: ${{ needs.setup.outputs.source_sha }} + fetch-depth: 0 + persist-credentials: false + + - name: Verify tag-bound source again + run: scripts/release/github/verify-release-ref.sh "$TAG" + + - name: Activate Hermit + uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1 + + - name: Require updater public key + run: | + : "${BERD_UPDATER_PUBLIC_KEY:?BERD_UPDATER_PUBLIC_KEY repository variable is required}" + + - name: Install locked signer tooling + run: pnpm install --frozen-lockfile + + - name: Build existing macOS arm64 product unsigned + env: + BUILD_KIND: official + run: | + set -euo pipefail + scripts/release/build-macos.sh + scripts/package-macos-dmg.sh release/macos/Berd.app release/macos/Berd.dmg + + - name: Sign, notarize, and staple the app through OIDC + id: codesign + uses: block/apple-codesign-action@679535d1ab7c5a7c18e6f9afcba3464512cc3dde # v1.1.0 + with: + osx-codesign-role: ${{ secrets.OSX_CODESIGN_ROLE }} + codesign-s3-bucket: ${{ secrets.CODESIGN_S3_BUCKET }} + unsigned-artifact-path: release/macos/Berd.dmg + entitlements-plist-path: release/macos/entitlements.plist + artifact-name: berd-${{ needs.setup.outputs.source_sha }}-${{ github.run_id }}-arm64 + + - name: Package and reverify signed release assets + env: + SIGNED_APP_ZIP: ${{ steps.codesign.outputs.signed-artifact-path }} + SIGNED_DMG: ${{ steps.codesign.outputs.signed-dmg-path }} + TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} + TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} + run: | + set -euo pipefail + : "${SIGNED_APP_ZIP:?apple-codesign-action returned no signed app zip}" + : "${SIGNED_DMG:?apple-codesign-action returned no signed DMG}" + scripts/release/github/prepare-release-assets.sh \ + "$SIGNED_APP_ZIP" "$SIGNED_DMG" "$VERSION" "$PLATFORM" \ + "$RUNNER_TEMP/release-assets" "$GITHUB_ENV" + + asset_dir="$RUNNER_TEMP/release-assets" + app_zip_name="Berd_${VERSION}_${PLATFORM}.app.zip" + dmg_name="Berd_${VERSION}_${PLATFORM}.dmg" + ARCHIVE_NAME="$(source scripts/release/lib.sh; release_archive_name "$VERSION" "$PLATFORM")" + just release-write-provenance "$SOURCE_SHA" "$VERSION" "$PLATFORM" "$asset_dir" \ + "$ARCHIVE_NAME" "$ARCHIVE_NAME.sig" "$ARCHIVE_NAME.sha256" \ + "$app_zip_name" "$dmg_name" + + - name: Attest staged macOS payload + uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 # v4.2.2 + with: + subject-path: ${{ runner.temp }}/release-assets/Berd_${{ env.VERSION }}_${{ env.PLATFORM }}.provenance.json + + - name: Upload immutable versioned assets + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -euo pipefail + ARCHIVE_NAME="$(source scripts/release/lib.sh; release_archive_name "$VERSION" "$PLATFORM")" + scripts/release/github/upload-immutable-assets.sh "$REPOSITORY" "$TAG" \ + "$asset_dir/$app_zip_name" \ + "$asset_dir/$dmg_name" \ + "$asset_dir/$ARCHIVE_NAME" \ + "$asset_dir/$ARCHIVE_NAME.sig" \ + "$asset_dir/$ARCHIVE_NAME.sha256" \ + "$asset_dir/$(source scripts/release/lib.sh; release_provenance_name "$VERSION" "$PLATFORM")" + + - name: Record staged release + run: | + ARCHIVE_NAME="$(source scripts/release/lib.sh; release_archive_name "$VERSION" "$PLATFORM")" + DIGEST=$(awk 'NR == 1 {print $1}' "$asset_dir/$ARCHIVE_NAME.sha256") + cat >> "$GITHUB_STEP_SUMMARY" <> "$GITHUB_ENV" + echo "installer_name=$INSTALLER_NAME" >> "$GITHUB_ENV" + ARCHIVE_NAME="$(source scripts/release/lib.sh; release_archive_name "$VERSION" "$PLATFORM")" + echo "archive_name=$ARCHIVE_NAME" >> "$GITHUB_ENV" + just release-write-provenance "$SOURCE_SHA" "$VERSION" "$PLATFORM" \ + "$RUNNER_TEMP/release-assets" "$INSTALLER_NAME" \ + "$ARCHIVE_NAME" "$ARCHIVE_NAME.sig" "$ARCHIVE_NAME.sha256" + + - name: Attest staged Windows payload + uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 # v4.2.2 + with: + subject-path: ${{ runner.temp }}/release-assets/Berd_${{ env.VERSION }}_${{ env.PLATFORM }}.provenance.json + + - name: Upload immutable versioned assets + shell: bash + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -euo pipefail + ARCHIVE_NAME="$(source scripts/release/lib.sh; release_archive_name "$VERSION" "$PLATFORM")" + INSTALLER_NAME="$(source scripts/release/lib.sh; release_installer_name "$VERSION" "$PLATFORM")" + PROVENANCE_NAME="$(source scripts/release/lib.sh; release_provenance_name "$VERSION" "$PLATFORM")" + scripts/release/github/upload-immutable-assets.sh "$REPOSITORY" "$TAG" \ + "$asset_dir/$INSTALLER_NAME" "$asset_dir/$ARCHIVE_NAME" \ + "$asset_dir/$ARCHIVE_NAME.sig" "$asset_dir/$ARCHIVE_NAME.sha256" \ + "$asset_dir/$PROVENANCE_NAME" + + + stage-linux: + name: Build and stage Linux x86_64 + needs: setup + if: needs.setup.outputs.staged_linux_assets_ready != 'true' + runs-on: ubuntu-latest + timeout-minutes: 120 + permissions: + contents: write + id-token: write + attestations: write + env: + VERSION: ${{ needs.setup.outputs.version }} + TAG: ${{ needs.setup.outputs.tag }} + SOURCE_SHA: ${{ needs.setup.outputs.source_sha }} + REPOSITORY: ${{ needs.setup.outputs.repository }} + PLATFORM: ${{ needs.setup.outputs.linux_platform }} + BERD_RELEASE_CHANNEL: public + BERD_UPDATER_ENDPOINT: https://github.com/${{ needs.setup.outputs.repository }}/releases/download/${{ needs.setup.outputs.rolling_tag }}/latest.json + BERD_UPDATER_PUBLIC_KEY: ${{ vars.BERD_UPDATER_PUBLIC_KEY }} + BERD_TAURI_CARGO_TARGET_DIR: ${{ github.workspace }}/src-tauri/target + steps: + - name: Check out verified source + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + with: + ref: ${{ needs.setup.outputs.source_sha }} + fetch-depth: 0 + persist-credentials: false + + - name: Verify tag-bound source again + run: scripts/release/github/verify-release-ref.sh "$TAG" + + - name: Activate Hermit + uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1 + + - name: Install Linux Tauri packaging dependencies + run: | + sudo apt-get update + sudo apt-get install -y --no-install-recommends \ + pkg-config \ + libwebkit2gtk-4.1-dev \ + libgtk-3-dev \ + libayatana-appindicator3-dev \ + librsvg2-dev \ + rpm + + - name: Require updater public key + run: | + : "${BERD_UPDATER_PUBLIC_KEY:?BERD_UPDATER_PUBLIC_KEY repository variable is required}" + + - name: Set up Linux release dependencies and locked signer tooling + run: | + pnpm install --frozen-lockfile + GOOSE_BUILD_PROFILE=release just setup + + - name: Build Linux packages + run: | + set -euo pipefail + pnpm tauri:release:config + tmp=$(mktemp) + jq --arg version "$VERSION" \ + '.version = $version | del(.bundle.createUpdaterArtifacts)' \ + src-tauri/tauri.release.conf.json > "$tmp" + mv "$tmp" src-tauri/tauri.release.conf.json + GOOSE_BUILD_PROFILE=release scripts/prepare-goose-sidecar.sh + CARGO_TARGET_DIR="$BERD_TAURI_CARGO_TARGET_DIR" scripts/prepare-berdctl-sidecar.sh x86_64-unknown-linux-gnu + scripts/prepare-catch-sidecar.sh x86_64-unknown-linux-gnu + BERD_APP_VERSION="$VERSION" \ + VITE_APP_VERSION="$VERSION" \ + VITE_ENVIRONMENT=production \ + VITE_UPDATER_ENABLED=true \ + pnpm tauri build --bundles appimage,deb,rpm --features berdctl \ + --config src-tauri/tauri.release.conf.json + + - name: Package and sign Linux updater archive + env: + TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} + TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} + run: | + set -euo pipefail + bundle_dir="$BERD_TAURI_CARGO_TARGET_DIR/release/bundle" + built_appimage=$(find "$bundle_dir/appimage" -maxdepth 1 -type f -name '*.AppImage' -print -quit) + built_deb=$(find "$bundle_dir/deb" -maxdepth 1 -type f -name '*.deb' -print -quit) + built_rpm=$(find "$bundle_dir/rpm" -maxdepth 1 -type f -name '*.rpm' -print -quit) + : "${built_appimage:?Linux build produced no AppImage}" + : "${built_deb:?Linux build produced no deb}" + : "${built_rpm:?Linux build produced no rpm}" + asset_dir="$RUNNER_TEMP/release-assets" + mkdir -p "$asset_dir" + appimage_name="Berd_${VERSION}_${PLATFORM}.AppImage" + deb_name="Berd_${VERSION}_${PLATFORM}.deb" + rpm_name="Berd_${VERSION}_${PLATFORM}.rpm" + cp "$built_deb" "$asset_dir/$deb_name" + cp "$built_rpm" "$asset_dir/$rpm_name" + scripts/release/package-signed-updater-linux.sh \ + --appimage "$built_appimage" \ + --version "$VERSION" \ + --output-dir "$asset_dir" + archive_name="$(source scripts/release/lib.sh; release_archive_name "$VERSION" "$PLATFORM")" + just release-write-provenance "$SOURCE_SHA" "$VERSION" "$PLATFORM" "$asset_dir" \ + "$appimage_name" "$deb_name" "$rpm_name" \ + "$archive_name" "$archive_name.sig" "$archive_name.sha256" + echo "asset_dir=$asset_dir" >> "$GITHUB_ENV" + + - name: Attest staged Linux payload + uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 # v4.2.2 + with: + subject-path: ${{ runner.temp }}/release-assets/Berd_${{ env.VERSION }}_${{ env.PLATFORM }}.provenance.json + + - name: Upload immutable versioned assets + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -euo pipefail + archive_name="$(source scripts/release/lib.sh; release_archive_name "$VERSION" "$PLATFORM")" + provenance_name="$(source scripts/release/lib.sh; release_provenance_name "$VERSION" "$PLATFORM")" + scripts/release/github/upload-immutable-assets.sh "$REPOSITORY" "$TAG" \ + "$asset_dir/Berd_${VERSION}_${PLATFORM}.AppImage" \ + "$asset_dir/Berd_${VERSION}_${PLATFORM}.deb" \ + "$asset_dir/Berd_${VERSION}_${PLATFORM}.rpm" \ + "$asset_dir/$archive_name" "$asset_dir/$archive_name.sig" \ + "$asset_dir/$archive_name.sha256" "$asset_dir/$provenance_name" + + promote: + name: Approve and promote updater feed + needs: [setup, stage-macos, stage-windows, stage-linux] + if: always() && needs.setup.result == 'success' && (needs.stage-macos.result == 'success' || needs.stage-macos.result == 'skipped') && (needs.stage-windows.result == 'success' || needs.stage-windows.result == 'skipped') && (needs.stage-linux.result == 'success' || needs.stage-linux.result == 'skipped') + runs-on: macos-latest + timeout-minutes: 30 + environment: release + concurrency: + group: berd-release + cancel-in-progress: false + permissions: + contents: write + deployments: read + actions: read + attestations: read + env: + TAG: ${{ needs.setup.outputs.tag }} + SOURCE_SHA: ${{ needs.setup.outputs.source_sha }} + BERD_RELEASE_CHANNEL_ID: "main" + BERD_STORE_CONTRACT_VERSION: "1" + BERD_WRITES_DATA_EPOCH: "1" + BERD_MIN_READABLE_DATA_EPOCH: "1" + BERD_MAX_READABLE_DATA_EPOCH: "1" + BERD_UPDATER_PUBLIC_KEY: ${{ vars.BERD_UPDATER_PUBLIC_KEY }} + steps: + - name: Check out verified source + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + with: + ref: ${{ needs.setup.outputs.source_sha }} + fetch-depth: 0 + persist-credentials: false + + - name: Verify tag-bound source again + run: scripts/release/github/verify-release-ref.sh "$TAG" + + - name: Activate Hermit + uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1 + + - name: Preflight environment approval record access + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + REPOSITORY: ${{ github.repository }} + RUN_ID: ${{ github.run_id }} + run: | + set -euo pipefail + APPROVER=$(gh api "repos/$REPOSITORY/actions/runs/$RUN_ID/approvals" \ + --jq '[.[] | select(.state == "approved") | .user.login] | unique | join(", ")') + [[ -n "$APPROVER" ]] || { + echo "::error::GitHub returned no approved reviewer for this environment-gated run" + exit 1 + } + echo "environment_approver=$APPROVER" >> "$GITHUB_ENV" + + - name: Verify staged immutable assets for all platforms + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + REPOSITORY: ${{ needs.setup.outputs.repository }} + VERSION: ${{ needs.setup.outputs.version }} + EXPECTED_TAG: ${{ needs.setup.outputs.tag }} + EXPECTED_SOURCE_SHA: ${{ needs.setup.outputs.source_sha }} + run: | + set -euo pipefail + for PLATFORM in darwin-aarch64 windows-x86_64 linux-x86_64; do + export PLATFORM + scripts/release/github/verify-versioned-release.sh "$EXPECTED_TAG" "$EXPECTED_SOURCE_SHA" + done + + - name: Install locked signer tooling + run: pnpm install --frozen-lockfile + + - name: Download, validate, and promote staged updater + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} + TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} + run: | + set -euo pipefail + scripts/release/github/promote-updater.sh "$TAG" "$SOURCE_SHA" "$RUNNER_TEMP/promotion-summary.md" + cat "$RUNNER_TEMP/promotion-summary.md" >> "$GITHUB_STEP_SUMMARY" + + - name: Record workflow and environment approval + env: + REPOSITORY: ${{ github.repository }} + RUN_ID: ${{ github.run_id }} + run: | + cat >> "$GITHUB_STEP_SUMMARY" <> $env:GITHUB_ENV + + - name: Cache managed Goose Cargo target on Windows + if: runner.os == 'Windows' + uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3 + with: + path: ${{ env.GOOSE_DEV_CARGO_TARGET_DIR }} + key: goose-cargo-${{ runner.os }}-rust-1.94.1-${{ hashFiles('goose-backend.lock.json') }} + + - name: Activate Hermit on macOS + if: runner.os == 'macOS' + uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1 + + - name: Build unsigned macOS DMG + if: runner.os == 'macOS' + env: + VERSION: 0.0.0-unsigned + BUILD_KIND: official + BERD_RELEASE_CHANNEL: disabled + run: | + set -euo pipefail + scripts/release/build-macos.sh + scripts/package-macos-dmg.sh release/macos/Berd.app release/macos/Berd-unsigned.dmg + + - name: Validate unsigned Windows packaging scripts + if: runner.os == 'Windows' + shell: pwsh + run: scripts/windows/Test-UnsignedWindowsPackaging.ps1 + + - name: Build unsigned Windows installer + if: runner.os == 'Windows' + shell: pwsh + env: + BERD_RELEASE_CHANNEL: disabled + run: | + $ErrorActionPreference = 'Stop' + just bundle-windows nsis + & "$env:GITHUB_WORKSPACE/scripts/windows/Collect-UnsignedWindowsInstaller.ps1" + + - name: Upload unsigned package + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: berd-unsigned-${{ runner.os }} + path: | + release/macos/Berd-unsigned.dmg + release/windows/Berd-unsigned-setup.exe + if-no-files-found: error + retention-days: 7 diff --git a/.gitignore b/.gitignore new file mode 100644 index 000000000..e2614571d --- /dev/null +++ b/.gitignore @@ -0,0 +1,69 @@ +# Dependencies +node_modules/ +.pnpm-store/ + +# Build output +dist/ +.generated/ + +# Rust/Tauri build artifacts +**/target/ + +# Environment files +.env +.env.* +!.env.example + +# Editor / IDE +.idea/ +.vscode/ +*.swp +*.swo +*~ +.*.sw? + +# OS artifacts +.DS_Store +Thumbs.db + +# Scratch / working files +.scratch/ +.docker-cache/ +.docker-home/ +avatar-manifest.json +project-artifacts-manifest.json +src/features/projects/artifact/assets/images/*.webp +src/features/projects/artifact/assets/hdri/studio_soft.exr +resources/bb +# Legacy staged ACP bundle dir; bridges now install at runtime from the npm +# registry onto the managed Node runtime. Kept ignored so stale staged trees +# in older checkouts stay hidden. +resources/acp/ + +# Playwright artifacts +playwright-report/ +transcript-playwright-report/ +test-results/ + +# App E2E screenshots +tests/app-e2e/screenshots/ + +# Testing coverage +coverage/ + +# Logs +*.log + +# Hermit (toolchain manager cache) +.hermit/ + +# Claude +.claude + +# Tauri staged external binaries (generated by scripts/prepare-goose-sidecar.sh, +# scripts/prepare-berdctl-sidecar.sh, and scripts/prepare-bb-cli-resource.sh) +src-tauri/binaries/ + +# Generated release config and catalog (created by scripts/release/build-tauri-release-config.mjs) +src-tauri/tauri.release.conf.json +src-tauri/resources/release-channels.json diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 000000000..f71830f35 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,130 @@ +# AGENTS.md + +Guidelines for agents working on Berd. + +Berd is a standalone Tauri 2 + React 19 desktop. ACP is the main interface +we use for the actual agent loop - creating and running sessions, finding available +models, and setting configuration. When available, we work over ACP methods, but the +UI can handle operations that are not yet in ACP or are client specific. + +## Layout + +- `src/` — React UI/features/shared code +- `src-tauri/` — Tauri shell that starts or resolves `goose serve` +- `sdk/` — vendored `@aaif/goose-sdk` package and generated ACP types +- `distro/` — bundled app defaults and packaged distribution assets +- `goose-backend.lock.json` — pinned upstream Goose backend used by dev and bundles +- `scripts/ensure-local-goose.sh` — managed local Goose checkout for dev +- `scripts/prepare-goose-sidecar.sh` — stages the pinned or explicit Goose binary for Tauri bundling +- `scripts/update-goose-backend-lock.sh` — resolves and records a new Goose backend pin +- `src/features/berdctl/` — berdctl command registry +- `src-tauri/plugins/berdctl/` — berdctl broker +- `src-tauri/crates/berdctl/` — bundled berdctl CLI +- `distro/skills/berd-help/references/berdctl.md` — berdctl guidance agents + read from the bundled `berd-help` skill + +## Architectural laws + +`LAWS/` defines required product and user experience behavior. Before planning, +implementing, or reviewing behavior changes, read `LAWS/README.md` and every +law file relevant to the affected behavior. Laws take correctness precedence +over the current code and tests; when they disagree, change the implementation +and tests or explicitly propose a product-approved law change. + +## Startup assets + +Startup artifact media is resolved by the Tauri backend and returned as local +cache paths. Renderer code should use `getArtifacts()` or +`selectProjectPreviewArtifacts()` from `src/shared/api/artifacts.ts`, then pass +paths through `convertFileSrc(..., "asset")` before rendering media. Do not +vendor startup media, fetch catalogs in the renderer, or construct CDN +URLs in UI code. + +## Experimental features + +Experiments are opt-in, user-local switches for in-progress UI or workflow +behavior. Use `.agents/skills/experimental-features/SKILL.md` before adding, +reviewing, graduating, or removing an experiment. + +Do not create one-off localStorage keys, distro flags, Tauri commands, or +capabilities for per-user experiments unless the skill says the use case +requires it. + +## Common commands + +- `just setup` — install pnpm deps, build SDK, build managed local Goose +- `just dev` — run the Tauri app in dev mode +- `just fmt` — format frontend and Tauri/Rust files +- `just fmt-check` — check frontend and Tauri/Rust formatting +- `just lint` — Biome lint checks +- `just typecheck` — TypeScript type checks +- `just check` — frontend formatting/lint/i18n/type checks +- `just test` — Vitest suite +- `just tauri-check` — Rust check with external sidecars disabled +- `just clippy` — Rust clippy with warnings denied +- `just ci` — local validation gate: frontend checks, Tauri/Rust checks, clippy, tests, build +- `just bundle` — stage the pinned Goose backend and run `pnpm tauri build` + +## When to validate + +- Frontend changes: `just check` +- Vitest-covered behavior: `just test` +- `src-tauri/`, Tauri config, sidecars, or Rust: `just tauri-check` +- berdctl commands: `pnpm generate:berdctl-contract`, `pnpm vitest run + src/features/berdctl`, and `cargo test -p berdctl` (from `src-tauri/`) +- Broad/release/packaging changes: `just ci` + +## berdctl + +berdctl lets agents control the app: CLI → broker → renderer registry. +Design and reasoning: `docs/berdctl-architecture.md`. To add or change a +command, use `.agents/skills/berdctl-new-command/SKILL.md` +(`just new-command `). + +Invariants (1, 3, 4 are gated by test failures; 2, 5, 6 are review rules — +the doc has the whys and the enforcement map): + +1. No command-specific knowledge below the renderer registry — the broker + stays transport-only (single reviewed exception: the create-cap's + `action == "create"` peek). +2. Single dispatch point in the renderer. +3. Bounds live in zod; clap only mirrors them. +4. Help is hand-authored in the command module (summary, description, + helpFooter, `.describe()` per field); `cargo test -p berdctl` fails on + empty/TODO prose. +5. UI-visible verbs only; prefer reversible mutations, but one-way visible + product actions like creating a session or sending a prompt are allowed. + Delete, bulk, silent, or invisible work reopens the auth decision as a + design review, not a PR. +6. Reviewers identify breaking wire reshapes and bump `protocolVersion` in + both discovery.rs copies and the contract.ts mirror; tests pin only that + the constants are equal. + +The CLI is built from the contract at startup: command modules (zod schemas ++ help prose) → `pnpm generate:berdctl-contract` → `api-surface.json` (the +client-neutral wire surface, with JSON Schema per action) + +`cli-surface.json` (the CLI projection) → embedded by the berdctl crate, +whose `tree.rs` builds the clap tree at runtime (`validate.rs` gates +consistency via the crate's tests). Never hand-edit the contract JSONs. + +## Sidecar rule + +Release builds should use the Goose backend pinned in `goose-backend.lock.json`: + +```bash +just setup +just bundle +``` + +The Tauri config uses `"externalBin": ["binaries/goosed"]`; the staging script +copies to `src-tauri/binaries/goosed-$(rustc -vV | sed -n 's|host: ||p')`, which +is the filename Tauri expects. Use `GOOSE_BIN=/path/to/goose` only as an explicit +local override. + +## Conventions + +- Use `@/` imports for frontend code. +- Use `cn()` from `@/shared/lib/cn` for Tailwind class merging. +- All ` + + + + + ), +})); + +vi.mock("@/shared/api/acp", () => ({ + acpCreateSession: (...args: unknown[]) => mockAcpCreateSession(...args), + acpListSessionsPage: (...args: unknown[]) => mockAcpListSessionsPage(...args), + acpLoadSession: (...args: unknown[]) => mockAcpLoadSession(...args), + discoverAcpProviders: vi.fn().mockResolvedValue([]), +})); + +vi.mock("@/shared/api/acpApi", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + archiveSession: (...args: unknown[]) => mockAcpArchiveSession(...args), + }; +}); + +vi.mock("@/features/chat/lib/sessionActivation", async (importOriginal) => { + const actual = + await importOriginal< + typeof import("@/features/chat/lib/sessionActivation") + >(); + return { + ...actual, + loadSessionMessages: (...args: unknown[]) => + mockLoadSessionMessages(...args), + loadSessionMessagesAndPrepare: (...args: unknown[]) => + mockLoadSessionMessages(...args), + }; +}); + +vi.mock("@/shared/api/agents", () => ({ + createPersonaSource: (...args: unknown[]) => mockCreatePersonaSource(...args), + listPersonaSources: (...args: unknown[]) => mockListPersonaSources(...args), + readAgentSourceFile: (...args: unknown[]) => mockReadAgentSourceFile(...args), + deletePersonaSource: (...args: unknown[]) => mockDeletePersonaSource(...args), + promotePersonaSource: vi.fn().mockResolvedValue(null), +})); + +vi.mock("@/shared/api/pathResolver", () => ({ + resolvePath: async ({ parts }: { parts: string[] }) => ({ + path: parts.join("/") || "/tmp", + }), + checkDirectoriesExist: (...args: unknown[]) => + mockCheckDirectoriesExist(...args), +})); + +vi.mock("@/features/updates/ui/UpdateButton", () => ({ + UpdateButton: () => null, +})); + +vi.mock("@/features/updates/ui/ChannelSwitchDialog", () => ({ + ChannelSwitchDialog: () => null, +})); + +vi.mock("@/features/updates/ui/BetaBadge", () => ({ + BetaBadge: () => null, +})); + +vi.mock("@/features/providers/hooks/useAgentProviderStatus", () => ({ + useAgentProviderStatus: () => ({ + readyAgentIds: new Set(["goose"]), + agentReadiness: new Map([["goose", "ready"]]), + agentChecks: new Map(), + loading: false, + refresh: vi.fn().mockResolvedValue(undefined), + }), +})); + +vi.mock("./ui/AppShellContent", () => ({ + AppShellContent: (({ + targetLocation, + onNavigateAutomations, + onAutomationBuilderLeaveActionChange, + onCreatePersona, + onArchiveChat, + }) => { + const activeView = targetLocation.view; + const activeAutomationsRoute = + targetLocation.view === "automations" + ? targetLocation.route + : { surface: "overview" }; + + return ( +
+
{activeView}
+ + + {activeView === "automations" && + activeAutomationsRoute.surface === "builder" ? ( + + ) : null} + {activeView === "agents" ? ( + + ) : null} +
+ ); + }) satisfies typeof AppShellContentType, +})); + +function makeSession(overrides: Partial = {}): ChatSession { + const now = "2026-06-09T00:00:00.000Z"; + return { + id: "session-1", + title: "Calling chat", + executionTarget: { harnessId: "goose" }, + workingDir: "/tmp/session-1", + createdAt: now, + updatedAt: now, + messageCount: 1, + ...overrides, + }; +} + +function setReadyRuntimeConfig(config: RuntimeConfig = DEFAULT_RUNTIME_CONFIG) { + useRuntimeConfigStore.setState({ + loaded: true, + result: { + status: "ready", + source: "fakeEndpoint", + config, + }, + config, + }); +} + +/** Starts a controller command inside act() so prompt-open state updates flush. */ +function startCommand( + start: () => Promise, +): Promise { + let outcome!: Promise; + act(() => { + outcome = start(); + }); + return outcome; +} + +async function runCommand( + start: () => Promise, +): Promise { + let outcome!: CommandOutcome; + await act(async () => { + outcome = await start(); + }); + return outcome; +} + +describe("AppShell berdctl integration", () => { + beforeEach(() => { + vi.stubEnv("VITE_AUTOMATIONS", "1"); + window.history.replaceState(null, "", "/"); + window.localStorage.clear(); + useShortcutsDialogStore.setState({ open: false }); + mockAcpCreateSession.mockReset(); + mockAcpCreateSession.mockResolvedValue({ sessionId: "created-session" }); + mockAcpListSessionsPage.mockReset(); + mockAcpListSessionsPage.mockImplementation(async () => ({ + sessions: useChatSessionStore.getState().sessions.map((session) => { + const selection = gooseServeSelectionFromExecutionTarget( + session.executionTarget, + ); + return { + sessionId: session.id, + title: session.title, + updatedAt: session.updatedAt, + createdAt: session.createdAt, + lastMessageAt: session.lastMessageAt ?? null, + archivedAt: session.archivedAt ?? null, + userSetName: session.userSetName ?? false, + messageCount: session.messageCount, + subtitle: session.subtitle ?? null, + workingDir: session.workingDir ?? null, + projectId: session.projectId ?? null, + providerId: selection.providerId ?? null, + modelId: selection.modelId ?? null, + personaId: session.personaId ?? null, + }; + }), + nextCursor: null, + })); + mockAcpLoadSession.mockReset(); + mockAcpLoadSession.mockResolvedValue(undefined); + mockAcpArchiveSession.mockReset(); + mockAcpArchiveSession.mockResolvedValue(undefined); + mockCheckDirectoriesExist.mockReset(); + mockCheckDirectoriesExist.mockResolvedValue([]); + mockCreatePersonaSource.mockReset(); + mockCreatePersonaSource.mockResolvedValue({ + type: "agent", + path: "/Users/test/.agents/agents/untitled-agent-created-session.md", + name: "Untitled agent created-sess", + description: "Draft", + content: "Draft in progress.", + global: true, + writable: true, + properties: { draft: true, builderSessionId: "created-session" }, + }); + mockListPersonaSources.mockReset(); + mockListPersonaSources.mockResolvedValue([]); + mockReadAgentSourceFile.mockReset(); + mockReadAgentSourceFile.mockRejectedValue(new Error("not found")); + mockDeletePersonaSource.mockReset(); + mockDeletePersonaSource.mockResolvedValue(undefined); + mockLoadSessionMessages.mockReset(); + mockLoadSessionMessages.mockResolvedValue(true); + mockToastError.mockReset(); + useChatStore.setState({ + messagesBySession: {}, + sessionStateById: {}, + draftsBySession: {}, + queuedMessageBySession: {}, + scrollTargetMessageBySession: {}, + activeSessionId: null, + isConnected: true, + }); + useChatSessionStore.setState({ + sessions: [], + activeSessionId: null, + isLoading: false, + hasHydratedSessions: false, + isRightRailOpen: false, + activeWorkspaceBySession: {}, + archiveMutationBySessionId: {}, + }); + useAgentStore.setState({ + selectedProvider: "goose", + }); + useDefaultProviderReadinessStore.setState({ + readiness: { status: "ready", providerId: "goose" }, + }); + useProjectStore.setState({ + projects: [], + loading: false, + activeProjectId: null, + }); + setReadyRuntimeConfig({ + ...DEFAULT_RUNTIME_CONFIG, + kgoose: { baseUrl: "https://kgoose.example.test" }, + }); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + }); + + it("registers the controller after mount and clears it on unmount", () => { + const { unmount } = render(); + + expect(() => getAppNavigationController()).not.toThrow(); + + unmount(); + + expect(() => getAppNavigationController()).toThrow( + "AppNavigationController not registered", + ); + }); + + it("openSession activates both stores and switches to chat view", async () => { + useChatSessionStore.setState({ sessions: [makeSession()] }); + render(); + + const outcome = await runCommand(() => + getAppNavigationController().openSession("session-1"), + ); + + expect(outcome).toEqual({ ok: true }); + await waitFor(() => { + expect(screen.getByTestId("active-view")).toHaveTextContent("chat"); + }); + expect(useChatSessionStore.getState().activeSessionId).toBe("session-1"); + expect(useChatStore.getState().activeSessionId).toBe("session-1"); + expect(mockLoadSessionMessages).toHaveBeenCalledWith("session-1"); + }); + + it("openSession returns to chat when the session is active but another view is showing", async () => { + const user = userEvent.setup(); + useChatSessionStore.setState({ sessions: [makeSession()] }); + render(); + + await runCommand(() => + getAppNavigationController().openSession("session-1"), + ); + await waitFor(() => { + expect(screen.getByTestId("active-view")).toHaveTextContent("chat"); + }); + + // Settings keeps the session active while hiding the chat surface. + await user.click(screen.getByRole("button", { name: "Sidebar settings" })); + await waitFor(() => { + expect(screen.getByTestId("active-view")).toHaveTextContent("settings"); + }); + expect(useChatSessionStore.getState().activeSessionId).toBe("session-1"); + + const outcome = await runCommand(() => + getAppNavigationController().openSession("session-1"), + ); + + expect(outcome).toEqual({ ok: true }); + await waitFor(() => { + expect(screen.getByTestId("active-view")).toHaveTextContent("chat"); + }); + expect(useChatSessionStore.getState().activeSessionId).toBe("session-1"); + }); + + it("openSession of an unknown session resolves session_not_found", async () => { + render(); + + const outcome = await runCommand(() => + getAppNavigationController().openSession("missing-session"), + ); + + expect(outcome).toEqual({ ok: false, reason: "session_not_found" }); + expect(screen.getByTestId("active-view")).toHaveTextContent("home"); + expect(useChatSessionStore.getState().activeSessionId).toBeNull(); + }); + + it("getAppContext reflects the real view, session, and project after openSession", async () => { + useChatSessionStore.setState({ + sessions: [makeSession({ projectId: "project-1" })], + }); + render(); + + expect(getAppNavigationController().getAppContext()).toEqual({ + view: "home", + activeSessionId: null, + activeProjectId: null, + }); + + await runCommand(() => + getAppNavigationController().openSession("session-1"), + ); + await waitFor(() => { + expect(screen.getByTestId("active-view")).toHaveTextContent("chat"); + }); + + expect(getAppNavigationController().getAppContext()).toEqual({ + view: "chat", + activeSessionId: "session-1", + activeProjectId: "project-1", + }); + }); + + it("archiveSession ignores session pagination failures", async () => { + mockAcpListSessionsPage.mockRejectedValue(new Error("list failed")); + useChatSessionStore.setState({ sessions: [makeSession()] }); + render(); + + const outcome = await runCommand(() => + getAppNavigationController().archiveSession("session-1", "reject"), + ); + + expect(outcome).toEqual({ ok: true }); + expect(mockAcpArchiveSession).toHaveBeenCalledWith("session-1"); + expect(mockAcpListSessionsPage).not.toHaveBeenCalled(); + }); + + it("archiveSession reports backend failure and keeps the session unarchived", async () => { + mockAcpArchiveSession.mockRejectedValue(new Error("backend down")); + useChatSessionStore.setState({ sessions: [makeSession()] }); + render(); + + const outcome = await runCommand(() => + getAppNavigationController().archiveSession("session-1", "reject"), + ); + + expect(outcome).toEqual({ ok: false, reason: "backend_archive_failed" }); + expect( + useChatSessionStore.getState().getSession("session-1")?.archivedAt, + ).toBeUndefined(); + }); + + it("archiveSession of the active session waits for backend success before local UI cleanup", async () => { + useChatSessionStore.setState({ sessions: [makeSession()] }); + render(); + + await runCommand(() => + getAppNavigationController().openSession("session-1"), + ); + await waitFor(() => { + expect(screen.getByTestId("active-view")).toHaveTextContent("chat"); + }); + + mockAcpArchiveSession.mockRejectedValue(new Error("backend down")); + const outcome = await runCommand(() => + getAppNavigationController().archiveSession("session-1", "reject"), + ); + + expect(outcome).toEqual({ ok: false, reason: "backend_archive_failed" }); + expect(useChatSessionStore.getState().activeSessionId).toBe("session-1"); + expect(screen.getByTestId("active-view")).toHaveTextContent("chat"); + expect( + useChatSessionStore.getState().getSession("session-1")?.archivedAt, + ).toBeUndefined(); + }); + + it("UI archive waits for backend success before cleaning up local state and reports failure", async () => { + const user = userEvent.setup(); + let rejectArchive!: (error: Error) => void; + mockAcpArchiveSession.mockReturnValue( + new Promise((_resolve, reject) => { + rejectArchive = reject; + }), + ); + useChatSessionStore.setState({ sessions: [makeSession()] }); + render(); + + await runCommand(() => + getAppNavigationController().openSession("session-1"), + ); + await waitFor(() => { + expect(screen.getByTestId("active-view")).toHaveTextContent("chat"); + }); + + await user.click(screen.getByRole("button", { name: "Archive session" })); + + expect( + useChatSessionStore.getState().getSession("session-1")?.archivedAt, + ).toEqual(expect.any(String)); + expect(useChatSessionStore.getState().activeSessionId).toBe("session-1"); + expect(screen.getByTestId("active-view")).toHaveTextContent("chat"); + + await act(async () => { + rejectArchive(new Error("backend down")); + }); + + await waitFor(() => { + expect( + useChatSessionStore.getState().getSession("session-1")?.archivedAt, + ).toBeUndefined(); + }); + expect(useChatSessionStore.getState().activeSessionId).toBe("session-1"); + expect(screen.getByTestId("active-view")).toHaveTextContent("chat"); + expect(mockToastError).toHaveBeenCalledWith("backend down"); + }); + + it("keeps a newly selected session active when archival finishes", async () => { + let resolveArchive!: () => void; + mockAcpArchiveSession.mockReturnValue( + new Promise((resolve) => { + resolveArchive = resolve; + }), + ); + useChatSessionStore.setState({ + sessions: [makeSession(), makeSession({ id: "session-2" })], + }); + render(); + + await runCommand(() => + getAppNavigationController().openSession("session-1"), + ); + const outcome = startCommand(() => + getAppNavigationController().archiveSession("session-1", "reject"), + ); + await waitFor(() => { + expect(mockAcpArchiveSession).toHaveBeenCalledWith("session-1"); + }); + + await act(async () => { + useChatSessionStore.getState().setActiveSession("session-2"); + useChatStore.getState().setActiveSession("session-2"); + resolveArchive(); + await outcome; + }); + + await expect(outcome).resolves.toEqual({ ok: true }); + expect(mockAcpListSessionsPage).not.toHaveBeenCalled(); + expect(useChatSessionStore.getState().activeSessionId).toBe("session-2"); + expect(useChatStore.getState().activeSessionId).toBe("session-2"); + expect(screen.getByTestId("active-view")).toHaveTextContent("chat"); + }); + + it("archiveSession of an unknown session resolves session_not_found", async () => { + render(); + + const outcome = await runCommand(() => + getAppNavigationController().archiveSession("missing", "reject"), + ); + + expect(outcome).toEqual({ ok: false, reason: "session_not_found" }); + expect(mockAcpArchiveSession).not.toHaveBeenCalled(); + }); + + it("archiveSession navigates home when the session was active", async () => { + useChatSessionStore.setState({ sessions: [makeSession()] }); + render(); + + await runCommand(() => + getAppNavigationController().openSession("session-1"), + ); + await waitFor(() => { + expect(screen.getByTestId("active-view")).toHaveTextContent("chat"); + }); + + const outcome = await runCommand(() => + getAppNavigationController().archiveSession("session-1", "reject"), + ); + + expect(outcome).toEqual({ ok: true }); + expect(mockAcpArchiveSession).toHaveBeenCalledWith("session-1"); + expect( + useChatSessionStore.getState().getSession("session-1")?.archivedAt, + ).toEqual(expect.any(String)); + expect(useChatSessionStore.getState().activeSessionId).toBeNull(); + await waitFor(() => { + expect(screen.getByTestId("active-view")).toHaveTextContent("home"); + }); + }); + + it("openSession resolves blocked_unsaved_changes when the automation guard is cancelled", async () => { + const user = userEvent.setup(); + useChatSessionStore.setState({ sessions: [makeSession()] }); + render(); + + await user.click( + screen.getByRole("button", { name: "Sidebar automations" }), + ); + await user.click( + screen.getByRole("button", { name: "Open automation builder" }), + ); + await user.click( + screen.getByRole("button", { name: "Mark automation edits unsaved" }), + ); + + const outcome = startCommand(() => + getAppNavigationController().openSession("session-1"), + ); + + expect( + await screen.findByText("Unsaved automation changes"), + ).toBeInTheDocument(); + + await user.click(screen.getByRole("button", { name: "Keep editing" })); + + await expect(outcome).resolves.toEqual({ + ok: false, + reason: "blocked_unsaved_changes", + }); + expect(screen.getByTestId("active-view")).toHaveTextContent("automations"); + }); + + it("settles a superseded pending guard entry as cancelled when a second guarded navigation arrives", async () => { + const user = userEvent.setup(); + useChatSessionStore.setState({ + sessions: [ + makeSession(), + makeSession({ id: "session-2", workingDir: "/tmp/session-2" }), + ], + }); + render(); + + await user.click( + screen.getByRole("button", { name: "Sidebar automations" }), + ); + await user.click( + screen.getByRole("button", { name: "Open automation builder" }), + ); + await user.click( + screen.getByRole("button", { name: "Mark automation edits unsaved" }), + ); + + const first = startCommand(() => + getAppNavigationController().openSession("session-1"), + ); + expect( + await screen.findByText("Unsaved automation changes"), + ).toBeInTheDocument(); + + // A second guarded navigation supersedes the first pending entry; the + // first command must settle (cancelled) instead of dying by timeout. + const second = startCommand(() => + getAppNavigationController().openSession("session-2"), + ); + + await expect(first).resolves.toEqual({ + ok: false, + reason: "blocked_unsaved_changes", + }); + + // The second entry is still live and resolves through the prompt. + await user.click(screen.getByRole("button", { name: "Keep editing" })); + await expect(second).resolves.toEqual({ + ok: false, + reason: "blocked_unsaved_changes", + }); + expect(screen.getByTestId("active-view")).toHaveTextContent("automations"); + }); + + it("openSession resolves blocked_unsaved_changes when the agent draft guard is cancelled", async () => { + const user = userEvent.setup(); + useChatSessionStore.setState({ sessions: [makeSession()] }); + render(); + + await user.click(screen.getByRole("button", { name: "Sidebar agents" })); + await user.click(screen.getByRole("button", { name: "Create agent" })); + await waitFor(() => { + expect(screen.getByTestId("active-view")).toHaveTextContent("chat"); + }); + + useChatStore.getState().setDraft("created-session", "make me a reviewer"); + + const outcome = startCommand(() => + getAppNavigationController().openSession("session-1"), + ); + + expect( + await screen.findByText("Save this agent draft?"), + ).toBeInTheDocument(); + + await user.click(screen.getByRole("button", { name: "Keep editing" })); + + await expect(outcome).resolves.toEqual({ + ok: false, + reason: "blocked_unsaved_changes", + }); + expect(screen.getByTestId("active-view")).toHaveTextContent("chat"); + }); + + it("settles a superseded agent draft guard entry as cancelled when a second guarded navigation arrives", async () => { + const user = userEvent.setup(); + useChatSessionStore.setState({ + sessions: [ + makeSession(), + makeSession({ id: "session-2", workingDir: "/tmp/session-2" }), + ], + }); + render(); + + await user.click(screen.getByRole("button", { name: "Sidebar agents" })); + await user.click(screen.getByRole("button", { name: "Create agent" })); + await waitFor(() => { + expect(screen.getByTestId("active-view")).toHaveTextContent("chat"); + }); + + useChatStore.getState().setDraft("created-session", "make me a reviewer"); + + const first = startCommand(() => + getAppNavigationController().openSession("session-1"), + ); + expect( + await screen.findByText("Save this agent draft?"), + ).toBeInTheDocument(); + + const second = startCommand(() => + getAppNavigationController().openSession("session-2"), + ); + + await expect(first).resolves.toEqual({ + ok: false, + reason: "blocked_unsaved_changes", + }); + + await user.click(screen.getByRole("button", { name: "Keep editing" })); + await expect(second).resolves.toEqual({ + ok: false, + reason: "blocked_unsaved_changes", + }); + expect(screen.getByTestId("active-view")).toHaveTextContent("chat"); + }); +}); diff --git a/src/app/AppShell.navigation.test.tsx b/src/app/AppShell.navigation.test.tsx new file mode 100644 index 000000000..1ddd50226 --- /dev/null +++ b/src/app/AppShell.navigation.test.tsx @@ -0,0 +1,5441 @@ +import { getModelSelectionIntent } from "@/features/chat/model-selection/modelSelectionIntent"; +import { beginModelSelectionIntent } from "@/features/chat/model-selection/modelSelectionIntent"; +import { + act, + cleanup, + fireEvent, + render, + screen, + waitFor, +} from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import type { ReactNode } from "react"; +import { i18n } from "@/shared/i18n"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { useAgentStore } from "@/features/agents/stores/agentStore"; +import { getAppNavigationController } from "@/features/berdctl/navigation"; +import { resetAgentBuilderSourceLifecycleForTests } from "@/features/agents/lib/agentBuilderSourceLifecycle"; +import { useChatStore } from "@/features/chat/stores/chatStore"; +import { useChatSessionStore } from "@/features/chat/stores/chatSessionStore"; +import { useSessionWindowStore } from "@/features/chat/stores/sessionWindowStore"; +import type { ChatSession } from "@/features/chat/stores/chatSessionStore"; +import type { Message } from "@/shared/types/messages"; +import type { GitState } from "@/shared/types/git"; +import { setMultiWorkspaceEnabled } from "@/features/workspaces/multiWorkspacePreference"; +import { OPEN_SETTINGS_EVENT } from "@/features/settings/lib/settingsEvents"; +import { SHORTCUT_PREFERENCES_STORAGE_KEY } from "@/features/shortcuts/lib/shortcutRegistry"; +import { useShortcutsDialogStore } from "@/features/shortcuts/stores/shortcutsDialogStore"; +import { useProjectStore } from "@/features/projects/stores/projectStore"; +import { + resetHomeWidgetStoreForTests, + useHomeWidgetStore, +} from "@/features/home/stores/homeWidgetStore"; +import { useStarterTasks } from "@/features/home/onboarding/StarterTasksContext"; +import { + hasStarterWidgetPickerRequest, + resetStarterWidgetPickerRequestForTests, +} from "@/features/home/onboarding/starterWidgetTask"; +import type { ProjectInfo } from "@/features/projects/api/projects"; +import { BUILDERBOT_SURFACE_EXPERIMENT_ID } from "@/features/experiments/experimentDefinitions"; +import { + EXPERIMENT_PREFERENCES_STORAGE_KEY, + EXPERIMENT_PREFERENCES_STORAGE_VERSION, +} from "@/features/experiments/experimentPreferences"; +import { ThemeProvider } from "@/shared/theme/ThemeProvider"; +import { useDefaultProviderReadinessStore } from "@/features/providers/stores/defaultProviderReadinessStore"; +import { useProviderModelCacheStore } from "@/features/providers/stores/providerModelCacheStore"; +import { useProviderCatalogStore } from "@/features/providers/stores/providerCatalogStore"; +import { useRuntimeConfigStore } from "@/shared/runtime-config/runtimeConfigStore"; +import { gooseServeSelectionFromExecutionTarget } from "@/features/chat/lib/gooseServeExecutionTarget"; + +import { + DEFAULT_RUNTIME_CONFIG, + type RuntimeConfig, +} from "@/shared/runtime-config/schema"; +import { + AppShell, + shouldStopVoiceConversationOnExperimentChange, + shouldStopVoiceConversationOnSessionChange, +} from "./AppShell"; +import type { NavigationPanesViewProps } from "@/app/views/NavigationPanesView"; +import type { AppShellContent as AppShellContentType } from "./ui/AppShellContent"; + +const mockAcpCreateSession = vi.hoisted(() => vi.fn()); +const mockAcpPrepareSession = vi.hoisted(() => vi.fn()); +const mockAcpSetSessionConfigOption = vi.hoisted(() => vi.fn()); +const mockAcpListSessionsPage = vi.hoisted(() => vi.fn()); +const mockBuildFeatures = vi.hoisted(() => ({ byoKeyProviders: false })); +const mockAcpArchiveSession = vi.hoisted(() => vi.fn()); +const mockAcpGetSessionInfo = vi.hoisted(() => vi.fn()); +const mockAcpLoadSession = vi.hoisted(() => vi.fn()); +const mockListExtensions = vi.hoisted(() => vi.fn()); +const mockCheckDirectoriesExist = vi.hoisted(() => vi.fn()); +const mockPathExists = vi.hoisted(() => vi.fn()); +const mockCheckAllProviderStatus = vi.hoisted(() => vi.fn()); +const mockRepairManagedGooseModelSelection = vi.hoisted(() => vi.fn()); +const gitMocks = vi.hoisted(() => ({ + countBranchCommitsNotInBase: vi.fn(), + hasIgnoredFiles: vi.fn(), + createBranch: vi.fn(), + createWorktree: vi.fn(), + deleteBranch: vi.fn(), + getGitState: vi.fn(), + removeWorktree: vi.fn(), +})); +const mockIsExternalAgentReady = vi.hoisted(() => vi.fn()); +const mockAgentStatus = vi.hoisted(() => ({ + readyAgentIds: new Set(["goose"]), +})); +const mockCreatePersonaSource = vi.hoisted(() => vi.fn()); +const mockListPersonaSources = vi.hoisted(() => vi.fn()); +const mockReadAgentSourceFile = vi.hoisted(() => vi.fn()); +const mockDeletePersonaSource = vi.hoisted(() => vi.fn()); +const mockListPersonas = vi.hoisted(() => vi.fn()); +const mockRepairBundledAgent = vi.hoisted(() => vi.fn()); +const mockAutomationBuilderSave = vi.hoisted(() => vi.fn()); +const mockToastError = vi.hoisted(() => vi.fn()); +const mockListenSessionDeepLinkErrors = vi.hoisted(() => vi.fn()); +const mockAfterNextPaint = vi.hoisted(() => ({ + callbacks: [] as Array<{ callback: () => void; cancelled: boolean }>, +})); +const mockSessionWindowSupport = vi.hoisted(() => ({ supported: false })); +const mockFocusSessionWindow = vi.hoisted(() => vi.fn()); + +function deferred() { + let resolve!: (value: T) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((promiseResolve, promiseReject) => { + resolve = promiseResolve; + reject = promiseReject; + }); + return { promise, reject, resolve }; +} + +function rect(left = 0, top = 0, width = 100, height = 100): DOMRect { + return { + x: left, + y: top, + left, + top, + right: left + width, + bottom: top + height, + width, + height, + toJSON: () => ({}), + } as DOMRect; +} + +function mockVisibleRegionRects() { + vi.spyOn(HTMLElement.prototype, "getBoundingClientRect").mockReturnValue( + rect(), + ); +} + +function flushAfterNextPaintCallbacks() { + const entries = mockAfterNextPaint.callbacks.splice(0); + for (const entry of entries) { + if (!entry.cancelled) { + entry.callback(); + } + } +} + +function appShellWithTheme(children?: ReactNode) { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + return ( + + + {children} + + + ); +} + +function renderAppShell(children?: ReactNode) { + return render(appShellWithTheme(children)); +} + +function managedWorktreeGitState( + branch: string, + worktreePath = `/repo-worktrees/${branch}`, +): GitState { + return { + isGitRepo: true, + currentBranch: branch, + dirtyFileCount: 0, + incomingCommitCount: 0, + worktrees: [ + { path: "/repo", branch: "main", isMain: true }, + { path: worktreePath, branch, isMain: false }, + ], + isWorktree: true, + mainWorktreePath: "/repo", + localBranches: ["main", branch], + }; +} + +function makeManagedWorktreeSession( + branch: string, + worktreePath = `/repo-worktrees/${branch}`, +): ChatSession { + return { + id: "session-1", + title: branch, + executionTarget: { harnessId: "goose" }, + workingDir: worktreePath, + workspaceAttachments: [ + { + id: `path:${worktreePath}`, + path: worktreePath, + kind: "git-linked-worktree", + source: "created", + branch, + repositoryPath: "/repo", + worktreePath, + usedByAgent: true, + lifecycle: { + owner: "goose", + cleanup: "worktree", + branch, + baseBranch: "main", + repositoryPath: "/repo", + worktreePath, + createdBranch: true, + }, + }, + ], + createdAt: "2026-07-10T00:00:00.000Z", + updatedAt: "2026-07-10T00:00:00.000Z", + messageCount: 1, + }; +} + +async function openCenteredComposerFromChat() { + const user = userEvent.setup(); + await user.click(screen.getByRole("button", { name: "Sidebar new chat" })); + await waitFor(() => { + expect(screen.getByTestId("active-view")).toHaveTextContent("chat"); + }); + await user.keyboard("{Meta>}n{/Meta}"); + const textbox = await screen.findByPlaceholderText("Start a conversation"); + await waitFor(() => { + expect(textbox.closest("[data-placement]")).toHaveAttribute( + "data-placement", + "centered", + ); + }); + return { textbox, user }; +} + +async function waitForCreatedAgentBuilderTarget() { + await waitFor(() => { + expect(useChatSessionStore.getState().getActiveSession()).toMatchObject({ + id: "created-session", + intent: "build-agent", + targetAgentPath: + "/Users/test/.agents/agents/untitled-agent-created-session.md", + targetAgentDraftState: null, + }); + }); +} + +function setReadyRuntimeConfig(config: RuntimeConfig = DEFAULT_RUNTIME_CONFIG) { + useRuntimeConfigStore.setState({ + loaded: true, + result: { + status: "ready", + source: "fakeEndpoint", + config, + }, + config, + }); +} + +function requireByoDefaultProviderSetup() { + mockBuildFeatures.byoKeyProviders = true; + useDefaultProviderReadinessStore.setState({ + readiness: { status: "needs_setup", reason: "missing_defaults" }, + }); +} + +function selectCodexProvider() { + useAgentStore.setState({ + providers: [ + { id: "goose", label: "Goose" }, + { id: "codex-acp", label: "Codex" }, + ], + selectedProvider: "codex-acp", + }); +} + +function setResolvingPersona( + model?: string, + provider = "databricks_v2", + modelProviderId?: string, +) { + useAgentStore.setState({ + selectedProvider: "goose", + providers: [ + { id: "goose", label: "Goose" }, + { id: "databricks_v2", label: "Databricks AI Gateway" }, + ], + personas: [ + { + id: "persona-resolves", + displayName: "Reviewer", + systemPrompt: "Review code.", + provider, + ...(modelProviderId ? { modelProviderId } : {}), + ...(model ? { model } : {}), + isBuiltin: false, + writable: true, + }, + ], + }); +} + +function seedProviderModels( + providerId: string, + models: Array<{ id: string; name: string; recommended?: boolean }>, +) { + useProviderModelCacheStore.getState().seedRuntimeModels( + new Map([ + [ + providerId, + models.map((model) => ({ + ...model, + displayName: model.name, + providerId, + })), + ], + ]), + ); +} + +vi.mock("@/shared/profile/buildProfile", () => ({ + getBuildFeatureState: () => ({ + authGate: false, + agentTools: true, + automations: true, + builderbot: true, + telemetry: true, + voiceDictation: true, + managedConnections: true, + securityMl: true, + updater: true, + ...mockBuildFeatures, + }), +})); + +const mockGetPlatform = vi.hoisted(() => vi.fn(() => "mac")); +vi.mock("@/shared/lib/platform", () => ({ + getPlatform: mockGetPlatform, +})); + +const mockDesignSystemExplorerEnabled = vi.hoisted(() => vi.fn(() => false)); +vi.mock("@/features/design-system/lib/designSystemEnabled", () => ({ + isDesignSystemExplorerEnabled: mockDesignSystemExplorerEnabled, +})); + +vi.mock("./hooks/useAppStartup", () => ({ + useAppStartup: () => ({ ready: true }), +})); + +vi.mock("@/features/migration/hooks/useMigrationGate", () => ({ + useMigrationGate: () => ({ status: "ready", retry: vi.fn() }), +})); + +vi.mock("@/features/migration/hooks/useDefaultModelGate", () => ({ + useDefaultModelGate: () => ({ status: "ok", retry: vi.fn() }), +})); + +vi.mock("@/app/views/NavigationPanesView", () => ({ + NavigationPanesView: ({ + collapsed, + onNavigate, + onNewChat, + onNewChatInProject, + onSettingsClick, + onSettingsSectionChange, + width, + }: NavigationPanesViewProps) => ( + + ), +})); + +vi.mock("@/features/chat/hooks/useSessionWindowSupport", () => ({ + useSessionWindowSupport: () => mockSessionWindowSupport, +})); + +vi.mock("@/features/chat/lib/sessionWindowCommands", () => ({ + focusSessionWindow: (...args: unknown[]) => mockFocusSessionWindow(...args), + releaseSession: vi.fn(), +})); + +vi.mock("@/features/extensions/api/extensions", () => ({ + listExtensions: (...args: unknown[]) => mockListExtensions(...args), +})); + +vi.mock("@/features/providers/api/credentials", () => ({ + checkAllProviderStatus: (...args: unknown[]) => + mockCheckAllProviderStatus(...args), +})); + +vi.mock("@/features/chat/lib/externalAgentReadiness", () => ({ + isExternalAgentReady: (...args: unknown[]) => + mockIsExternalAgentReady(...args), +})); + +vi.mock("@/features/providers/lib/managedModelSelectionRepair", () => ({ + repairManagedGooseModelSelection: (...args: unknown[]) => + mockRepairManagedGooseModelSelection(...args), +})); + +vi.mock("@/shared/api/acp", () => ({ + acpCreateSession: (...args: unknown[]) => mockAcpCreateSession(...args), + acpPrepareSession: (...args: unknown[]) => mockAcpPrepareSession(...args), + acpSetSessionConfigOption: (...args: unknown[]) => + mockAcpSetSessionConfigOption(...args), + acpGetSessionInfo: (...args: unknown[]) => mockAcpGetSessionInfo(...args), + acpListSessionsPage: (...args: unknown[]) => mockAcpListSessionsPage(...args), + acpLoadSession: (...args: unknown[]) => mockAcpLoadSession(...args), + discoverAcpProviders: vi.fn().mockResolvedValue([]), +})); + +vi.mock("@/shared/api/acpApi", () => ({ + DEFAULT_PROVIDER: { id: "goose", label: "Goose (Default)" }, + archiveSession: (...args: unknown[]) => mockAcpArchiveSession(...args), + renameSession: vi.fn().mockResolvedValue(undefined), + unarchiveSession: vi.fn().mockResolvedValue(undefined), + updateSessionProject: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock("@/shared/api/git", () => ({ + countBranchCommitsNotInBase: (...args: unknown[]) => + gitMocks.countBranchCommitsNotInBase(...args), + hasIgnoredFiles: (...args: unknown[]) => gitMocks.hasIgnoredFiles(...args), + createBranch: (...args: unknown[]) => gitMocks.createBranch(...args), + createWorktree: (...args: unknown[]) => gitMocks.createWorktree(...args), + deleteBranch: (...args: unknown[]) => gitMocks.deleteBranch(...args), + getGitState: (...args: unknown[]) => gitMocks.getGitState(...args), + removeWorktree: (...args: unknown[]) => gitMocks.removeWorktree(...args), +})); +vi.mock("sonner", () => ({ + toast: { + error: (...args: unknown[]) => mockToastError(...args), + info: vi.fn(), + }, +})); + +vi.mock("./lib/sessionDeepLinkErrors", () => ({ + listenSessionDeepLinkErrors: (...args: unknown[]) => + mockListenSessionDeepLinkErrors(...args), +})); + +vi.mock("@/shared/api/agents", () => ({ + createPersonaSource: (...args: unknown[]) => mockCreatePersonaSource(...args), + listPersonaSources: (...args: unknown[]) => mockListPersonaSources(...args), + listPersonas: (...args: unknown[]) => mockListPersonas(...args), + repairBundledAgent: (...args: unknown[]) => mockRepairBundledAgent(...args), + readAgentSourceFile: (...args: unknown[]) => mockReadAgentSourceFile(...args), + deletePersonaSource: (...args: unknown[]) => mockDeletePersonaSource(...args), + promotePersonaSource: vi.fn().mockResolvedValue(null), +})); + +vi.mock("@/shared/api/pathResolver", () => ({ + resolvePath: async ({ parts }: { parts: string[] }) => ({ + path: parts.join("/") || "/tmp", + }), + checkDirectoriesExist: (...args: unknown[]) => + mockCheckDirectoriesExist(...args), +})); + +vi.mock("@/features/chat/hooks/useMentionHandlers", () => ({ + useMentionHandlers: () => ({ + mentionOpen: false, + atMentionCategory: "agents", + mentionSelectedIndex: 0, + filteredPersonas: [], + filteredSkills: [], + filteredFiles: [], + fileMentionsLoading: false, + fileMentionsError: null, + detectMention: vi.fn(), + closeMention: vi.fn(), + navigateMention: vi.fn(), + setAtMentionCategory: vi.fn(), + handleMentionCategoryKey: vi.fn(), + confirmMention: vi.fn(), + handleMentionConfirm: vi.fn(), + }), +})); + +vi.mock("@/shared/api/system", () => ({ + getHomeDir: vi.fn().mockResolvedValue("/Users/test"), + pathExists: (...args: unknown[]) => mockPathExists(...args), +})); + +vi.mock("@/features/updates/ui/UpdateButton", () => ({ + UpdateButton: () => null, +})); + +vi.mock("@/features/updates/ui/ChannelSwitchDialog", () => ({ + ChannelSwitchDialog: () => null, +})); + +vi.mock("@/features/updates/ui/BetaBadge", () => ({ + BetaBadge: () => null, +})); + +vi.mock("@/features/providers/hooks/useAgentProviderStatus", () => ({ + useAgentProviderStatus: () => ({ + readyAgentIds: mockAgentStatus.readyAgentIds, + agentReadiness: new Map( + [...mockAgentStatus.readyAgentIds].map((providerId) => [ + providerId, + "ready" as const, + ]), + ), + agentChecks: new Map(), + loading: false, + refresh: vi.fn().mockResolvedValue(undefined), + }), +})); + +vi.mock("./lib/scheduleAfterNextPaint", () => ({ + scheduleAfterNextPaint: (callback: () => void) => { + const entry = { callback, cancelled: false }; + mockAfterNextPaint.callbacks.push(entry); + return () => { + entry.cancelled = true; + }; + }, +})); + +vi.mock("./ui/AppShellContent", () => ({ + AppShellContent: (({ + targetLocation, + renderedLocation, + isPreparingContent, + renderedSession, + onCloseDesignSystem, + onNavigateSkills, + onNavigateAgents, + onNavigateAutomations, + onNavigateBuilderbot, + onSkillsBreadcrumbLabelChange, + onAgentsBreadcrumbLabelChange, + onAutomationsBreadcrumbLabelChange, + onBuilderbotBreadcrumbLabelChange, + onAutomationBuilderLeaveActionChange, + onCreatePersona, + onExitSearch, + onArchiveChat, + onOpenAgent, + onTagHomeComposerAgent, + onTagHomeComposerProject, + onTagHomeComposerSkill, + onSelectSession, + onStartProjectChat, + onStartChatWithPrompt, + }) => { + const starterTasks = useStarterTasks(); + const activeView = targetLocation.view; + const activeSettingsSection = + targetLocation.view === "settings" + ? targetLocation.settingsSection + : "general"; + const activeSkillsSkillId = + targetLocation.view === "skills" ? targetLocation.skillId : null; + const activeAgentsPersonaId = + targetLocation.view === "agents" ? targetLocation.personaId : null; + const activeAutomationsRoute = + targetLocation.view === "automations" + ? targetLocation.route + : { surface: "overview" }; + const activeBuilderbotRoute = + targetLocation.view === "builderbot" + ? targetLocation.route + : { surface: "overview" }; + + return ( +
+
{activeView}
+
{renderedLocation.view}
+
{String(isPreparingContent)}
+
+ {renderedSession?.id ?? "none"} +
+
{activeSettingsSection}
+
{activeSkillsSkillId ?? "list"}
+
{activeAgentsPersonaId ?? "list"}
+
+ {JSON.stringify(activeAutomationsRoute)} +
+
+ {JSON.stringify(activeBuilderbotRoute)} +
+ + + + + + + + + + {activeView === "automations" && + activeAutomationsRoute.surface === "builder" ? ( + + ) : null} + + + + + + + + + + + {activeView === "agents" ? ( + + ) : null} + {activeView === "search" ? ( + + ) : null} + +
+ ); + }) satisfies typeof AppShellContentType, +})); + +function enableBuilderbotExperiment() { + window.localStorage.setItem( + EXPERIMENT_PREFERENCES_STORAGE_KEY, + JSON.stringify({ + version: EXPERIMENT_PREFERENCES_STORAGE_VERSION, + experiments: { + [BUILDERBOT_SURFACE_EXPERIMENT_ID]: { enabled: true }, + }, + }), + ); +} + +describe("AppShell global navigation", () => { + it("does no Voice native cleanup on startup-off and cleans up an on-to-off transition", () => { + expect( + shouldStopVoiceConversationOnExperimentChange({ + wasEnabled: false, + isEnabled: false, + }), + ).toBe(false); + expect( + shouldStopVoiceConversationOnExperimentChange({ + wasEnabled: true, + isEnabled: false, + }), + ).toBe(true); + expect( + shouldStopVoiceConversationOnExperimentChange({ + wasEnabled: true, + isEnabled: true, + }), + ).toBe(false); + }); + + it("stops voice only when navigation leaves its bound chat", () => { + const base = { + previousSessionId: "session-1", + boundSessionId: "session-1", + lifecycle: "running", + }; + + expect( + shouldStopVoiceConversationOnSessionChange({ + ...base, + nextSessionId: "session-2", + }), + ).toBe(true); + expect( + shouldStopVoiceConversationOnSessionChange({ + ...base, + nextSessionId: null, + }), + ).toBe(true); + expect( + shouldStopVoiceConversationOnSessionChange({ + ...base, + nextSessionId: "session-1", + }), + ).toBe(false); + expect( + shouldStopVoiceConversationOnSessionChange({ + ...base, + nextSessionId: "session-2", + boundSessionId: "session-elsewhere", + }), + ).toBe(false); + expect( + shouldStopVoiceConversationOnSessionChange({ + ...base, + nextSessionId: "session-2", + lifecycle: "stopped", + }), + ).toBe(false); + }); + afterEach(cleanup); + + beforeEach(() => { + resetHomeWidgetStoreForTests(); + resetStarterWidgetPickerRequestForTests(); + mockRepairManagedGooseModelSelection.mockReset(); + mockRepairManagedGooseModelSelection.mockImplementation( + async (selection: unknown) => selection, + ); + window.history.replaceState(null, "", "/"); + window.localStorage.clear(); + mockBuildFeatures.byoKeyProviders = false; + mockGetPlatform.mockReturnValue("mac"); + mockDesignSystemExplorerEnabled.mockReturnValue(false); + mockAfterNextPaint.callbacks = []; + resetAgentBuilderSourceLifecycleForTests(); + useShortcutsDialogStore.setState({ open: false }); + document.documentElement.removeAttribute("data-global-composer-visible"); + mockSessionWindowSupport.supported = false; + mockFocusSessionWindow.mockReset(); + useSessionWindowStore.getState().setSnapshot([]); + mockListExtensions.mockReset(); + mockListExtensions.mockResolvedValue([]); + mockAcpCreateSession.mockReset(); + mockAcpCreateSession.mockResolvedValue({ sessionId: "created-session" }); + mockAcpPrepareSession.mockReset(); + mockAcpPrepareSession.mockResolvedValue({}); + mockAcpSetSessionConfigOption.mockReset(); + mockAcpSetSessionConfigOption.mockResolvedValue({}); + mockAcpListSessionsPage.mockReset(); + mockAcpListSessionsPage.mockImplementation(async () => ({ + sessions: useChatSessionStore.getState().sessions.map((session) => { + const selection = gooseServeSelectionFromExecutionTarget( + session.executionTarget, + ); + return { + sessionId: session.id, + title: session.title, + updatedAt: session.updatedAt, + createdAt: session.createdAt, + lastMessageAt: session.lastMessageAt ?? null, + archivedAt: session.archivedAt ?? null, + userSetName: session.userSetName ?? false, + messageCount: session.messageCount, + subtitle: session.subtitle ?? null, + workingDir: session.workingDir ?? null, + projectId: session.projectId ?? null, + providerId: selection.providerId ?? null, + modelId: selection.modelId ?? null, + personaId: session.personaId ?? null, + }; + }), + nextCursor: null, + })); + mockAcpArchiveSession.mockReset(); + mockAcpArchiveSession.mockResolvedValue(undefined); + mockAcpGetSessionInfo.mockReset(); + mockAcpGetSessionInfo.mockResolvedValue(null); + mockAcpLoadSession.mockReset(); + mockAcpLoadSession.mockResolvedValue(undefined); + mockToastError.mockReset(); + mockListenSessionDeepLinkErrors.mockReset(); + mockListenSessionDeepLinkErrors.mockResolvedValue(vi.fn()); + gitMocks.getGitState.mockReset(); + gitMocks.getGitState.mockResolvedValue({ + isGitRepo: true, + currentBranch: "main", + dirtyFileCount: 0, + incomingCommitCount: 0, + worktrees: [{ path: "/repo", branch: "main", isMain: true }], + isWorktree: false, + mainWorktreePath: "/repo", + localBranches: ["main"], + }); + gitMocks.createBranch.mockReset(); + gitMocks.createBranch.mockResolvedValue(undefined); + gitMocks.createWorktree.mockReset(); + gitMocks.createWorktree.mockResolvedValue({ + path: "/repo-worktrees/chat-123", + branch: "chat-123", + }); + gitMocks.countBranchCommitsNotInBase.mockReset(); + gitMocks.countBranchCommitsNotInBase.mockResolvedValue(0); + gitMocks.hasIgnoredFiles.mockReset(); + gitMocks.hasIgnoredFiles.mockResolvedValue(false); + gitMocks.deleteBranch.mockReset(); + gitMocks.deleteBranch.mockResolvedValue(undefined); + gitMocks.removeWorktree.mockReset(); + gitMocks.removeWorktree.mockResolvedValue(undefined); + mockPathExists.mockReset(); + mockPathExists.mockResolvedValue(false); + mockCheckDirectoriesExist.mockReset(); + mockCheckDirectoriesExist.mockResolvedValue([]); + mockCheckAllProviderStatus.mockReset(); + mockCheckAllProviderStatus.mockResolvedValue([]); + mockIsExternalAgentReady.mockReset(); + mockIsExternalAgentReady.mockResolvedValue(false); + mockAgentStatus.readyAgentIds = new Set(["goose"]); + mockCreatePersonaSource.mockReset(); + mockCreatePersonaSource.mockResolvedValue({ + type: "agent", + path: "/Users/test/.agents/agents/untitled-agent-created-session.md", + name: "Untitled agent created-sess", + description: "Draft", + content: "Draft in progress.", + global: true, + writable: true, + properties: { draft: true, builderSessionId: "created-session" }, + }); + mockListPersonaSources.mockReset(); + mockListPersonaSources.mockResolvedValue([]); + mockListPersonas.mockReset(); + mockListPersonas.mockResolvedValue([]); + mockRepairBundledAgent.mockReset(); + mockRepairBundledAgent.mockResolvedValue(undefined); + mockReadAgentSourceFile.mockReset(); + mockReadAgentSourceFile.mockRejectedValue(new Error("not found")); + mockDeletePersonaSource.mockReset(); + mockDeletePersonaSource.mockResolvedValue(undefined); + mockAutomationBuilderSave.mockReset(); + useChatStore.setState({ + messagesBySession: {}, + sessionStateById: {}, + draftsBySession: {}, + nonEmptyDraftSessionIds: new Set(), + skillDraftsBySession: {}, + draftAttachmentsBySession: {}, + queuedMessageBySession: {}, + scrollTargetMessageBySession: {}, + activeSessionId: null, + isConnected: true, + }); + useChatSessionStore.setState({ + sessions: [], + activeSessionId: null, + isLoading: false, + hasHydratedSessions: false, + isRightRailOpen: false, + activeWorkspaceBySession: {}, + archiveMutationBySessionId: {}, + }); + useAgentStore.setState({ + selectedProvider: "goose", + }); + useProjectStore.setState({ + projects: [], + loading: false, + activeProjectId: null, + }); + useDefaultProviderReadinessStore.setState({ + readiness: { status: "ready", providerId: "goose" }, + }); + useProviderModelCacheStore.setState({ + providers: new Map(), + refreshingProviderIds: new Set(), + runtimeManagedProviderIds: new Set(), + }); + useProviderCatalogStore.getState().reset(); + setReadyRuntimeConfig(); + }); + + it("starts a full blank chat from the sidebar new chat action", async () => { + const user = userEvent.setup(); + const { container } = renderAppShell(); + + await user.click(screen.getByRole("button", { name: "Sidebar new chat" })); + + await waitFor(() => { + expect(screen.getByTestId("active-view")).toHaveTextContent("chat"); + }); + expect(mockAcpCreateSession).toHaveBeenCalledWith( + "goose", + "~/goose artifacts", + { + deferProviderSetup: false, + modelId: undefined, + projectId: undefined, + }, + ); + expect( + (container.firstElementChild as HTMLElement).style.getPropertyValue( + "--project-tint", + ), + ).toBe("transparent"); + }); + + it("repairs an obsolete managed model before creating Home", async () => { + setReadyRuntimeConfig({ + schemaVersion: 1, + goose: { + defaultModelProviderId: "databricks_v2", + defaultModelId: "goose-gpt-5-5", + modelProviders: [ + { + id: "databricks_v2", + displayName: "Databricks", + models: [ + { id: "goose-gpt-5-5", name: "GPT-5.5" }, + { id: "legacy-v1-model", name: "Legacy" }, + ], + }, + ], + }, + }); + useDefaultProviderReadinessStore.setState({ + readiness: { + status: "ready", + providerId: "databricks_v2", + modelId: "legacy-v1-model", + }, + }); + mockRepairManagedGooseModelSelection.mockResolvedValue({ + providerId: "databricks_v2", + modelId: "goose-gpt-5-5", + }); + useChatSessionStore.setState({ hasHydratedSessions: true }); + + renderAppShell(); + + await waitFor(() => { + expect(mockAcpCreateSession).toHaveBeenCalledWith( + "databricks_v2", + "~/goose artifacts", + expect.objectContaining({ modelId: "goose-gpt-5-5" }), + ); + }); + expect(mockAcpCreateSession).not.toHaveBeenCalledWith( + "databricks_v2", + "~/goose artifacts", + expect.objectContaining({ modelId: "legacy-v1-model" }), + ); + expect( + useChatSessionStore.getState().getSession("created-session"), + ).toMatchObject({ + executionTarget: { + harnessId: "goose", + modelProviderId: "databricks_v2", + modelId: "goose-gpt-5-5", + }, + }); + }); + + it("keeps a newer Home picker choice while managed repair is pending", async () => { + const repair = deferred<{ + providerId: string; + modelId: string; + }>(); + setReadyRuntimeConfig({ + schemaVersion: 1, + goose: { + defaultModelProviderId: "databricks_v2", + defaultModelId: "goose-gpt-5-5", + modelProviders: [ + { + id: "databricks_v2", + displayName: "Databricks", + models: [ + { id: "goose-gpt-5-5", name: "GPT-5.5" }, + { id: "goose-gpt-5-6", name: "GPT-5.6" }, + { id: "legacy-v1-model", name: "Legacy" }, + ], + }, + ], + }, + }); + seedProviderModels("databricks_v2", [ + { id: "goose-gpt-5-5", name: "GPT-5.5" }, + { id: "goose-gpt-5-6", name: "GPT-5.6" }, + ]); + useDefaultProviderReadinessStore.setState({ + readiness: { + status: "ready", + providerId: "databricks_v2", + modelId: "legacy-v1-model", + }, + }); + mockRepairManagedGooseModelSelection + .mockReturnValueOnce(repair.promise) + .mockImplementation( + async (selection: { providerId?: string; modelId?: string }) => ({ + providerId: selection.providerId ?? "databricks_v2", + modelId: selection.modelId ?? "goose-gpt-5-5", + }), + ); + useChatSessionStore.setState({ hasHydratedSessions: true }); + const user = userEvent.setup(); + + renderAppShell(); + + await user.click(screen.getByPlaceholderText("Start a conversation")); + await user.click( + screen.getByRole("button", { name: /choose agent and model/i }), + ); + await user.click(screen.getByRole("button", { name: "GPT-5.6" })); + + await act(async () => { + repair.resolve({ + providerId: "databricks_v2", + modelId: "goose-gpt-5-5", + }); + await repair.promise; + }); + + await waitFor(() => { + expect(mockAcpCreateSession).toHaveBeenCalledWith( + "databricks_v2", + "~/goose artifacts", + expect.objectContaining({ modelId: "goose-gpt-5-6" }), + ); + }); + expect( + useChatSessionStore.getState().getSession("created-session"), + ).toMatchObject({ + executionTarget: { + harnessId: "goose", + modelProviderId: "databricks_v2", + modelId: "goose-gpt-5-6", + }, + }); + }); + + it("keeps a fresh-start picker selection when Home appears with its default model", async () => { + const homeCreation = deferred<{ + sessionId: string; + configOptionsSnapshot: { + model: { modelId: string; modelName: string }; + }; + }>(); + seedProviderModels("databricks_v2", [ + { id: "goose-gpt-5-5", name: "GPT-5.5", recommended: true }, + { id: "goose-gpt-5-6", name: "GPT-5.6", recommended: true }, + ]); + useDefaultProviderReadinessStore.setState({ + readiness: { + status: "ready", + providerId: "databricks_v2", + modelId: "goose-gpt-5-5", + }, + }); + useChatSessionStore.setState({ hasHydratedSessions: true }); + mockAcpCreateSession.mockReturnValueOnce(homeCreation.promise); + const user = userEvent.setup(); + + renderAppShell(); + + await waitFor(() => { + expect(mockAcpCreateSession).toHaveBeenCalledWith( + "databricks_v2", + "~/goose artifacts", + expect.objectContaining({ modelId: "goose-gpt-5-5" }), + ); + }); + expect(useChatSessionStore.getState().sessions).toHaveLength(0); + + await user.click(screen.getByPlaceholderText("Start a conversation")); + await user.click( + screen.getByRole("button", { name: /choose agent and model/i }), + ); + await user.click(screen.getByRole("button", { name: "GPT-5.6" })); + expect( + screen.getByRole("button", { name: /choose agent and model/i }), + ).toHaveTextContent("GPT-5.6"); + + await act(async () => { + homeCreation.resolve({ + sessionId: "home-session", + configOptionsSnapshot: { + model: { modelId: "goose-gpt-5-5", modelName: "GPT-5.5" }, + }, + }); + await homeCreation.promise; + }); + + await waitFor(() => { + expect( + useChatSessionStore.getState().getSession("home-session"), + ).toMatchObject({ + executionTarget: { + harnessId: "goose", + modelProviderId: "databricks_v2", + modelId: "goose-gpt-5-6", + modelName: "GPT-5.6", + }, + }); + }); + expect( + screen.getByRole("button", { name: /choose agent and model/i }), + ).toHaveTextContent("GPT-5.6"); + }); + + it("keeps an external agent as the Home model harness", async () => { + selectCodexProvider(); + mockAgentStatus.readyAgentIds = new Set(["codex-acp"]); + seedProviderModels("codex-acp", [ + { id: "gpt-5.5", name: "GPT-5.5", recommended: true }, + ]); + useChatSessionStore.setState({ hasHydratedSessions: true }); + const user = userEvent.setup(); + renderAppShell(); + + await waitFor(() => { + expect( + useChatSessionStore.getState().getSession("created-session"), + ).toMatchObject({ executionTarget: { harnessId: "codex-acp" } }); + }); + await user.click(screen.getByPlaceholderText("Start a conversation")); + await user.click( + screen.getByRole("button", { name: /choose agent and model/i }), + ); + await user.click(screen.getByRole("button", { name: "GPT-5.5" })); + + await waitFor(() => { + expect( + useChatSessionStore.getState().getSession("created-session"), + ).toMatchObject({ + executionTarget: { + harnessId: "codex-acp", + modelProviderId: "codex-acp", + modelId: "gpt-5.5", + }, + }); + }); + }); + + it("does not reseed an explicitly unresolved Home session", async () => { + window.localStorage.setItem("goose:home-session-id", "home-unresolved"); + useDefaultProviderReadinessStore.setState({ + readiness: { + status: "ready", + providerId: "databricks_v2", + modelId: "goose-gpt-5-5", + }, + }); + useChatSessionStore.setState({ + sessions: [ + { + id: "home-unresolved", + title: "Home", + executionTargetSource: "ui", + workingDir: "~/goose artifacts", + createdAt: "2026-08-06T00:00:00.000Z", + updatedAt: "2026-08-06T00:00:00.000Z", + messageCount: 0, + }, + ], + hasHydratedSessions: true, + }); + + renderAppShell(); + await screen.findByPlaceholderText("Start a conversation"); + + expect(mockAcpPrepareSession).not.toHaveBeenCalled(); + expect(mockAcpCreateSession).not.toHaveBeenCalled(); + const unresolved = useChatSessionStore + .getState() + .getSession("home-unresolved"); + expect(unresolved?.executionTarget).toBeUndefined(); + expect(unresolved?.executionTargetSource).toBe("ui"); + }); + + it("preserves a UI-owned provider-only Home target", async () => { + window.localStorage.setItem("goose:home-session-id", "home-provider"); + useDefaultProviderReadinessStore.setState({ + readiness: { + status: "ready", + providerId: "databricks_v2", + modelId: "goose-gpt-5-5", + }, + }); + mockCheckAllProviderStatus.mockResolvedValue([ + { providerId: "openai", isConfigured: true }, + ]); + useChatSessionStore.setState({ + sessions: [ + { + id: "home-provider", + title: "Home", + executionTarget: { + harnessId: "goose", + modelProviderId: "openai", + }, + executionTargetSource: "ui", + workingDir: "~/goose artifacts", + createdAt: "2026-08-06T00:00:00.000Z", + updatedAt: "2026-08-06T00:00:00.000Z", + messageCount: 0, + }, + ], + hasHydratedSessions: true, + }); + + renderAppShell(); + + await waitFor(() => { + expect(mockAcpPrepareSession).toHaveBeenCalledWith( + "home-provider", + "openai", + "~/goose artifacts", + expect.any(Object), + ); + }); + expect( + useChatSessionStore.getState().getSession("home-provider"), + ).toMatchObject({ + executionTarget: { + harnessId: "goose", + modelProviderId: "openai", + }, + executionTargetSource: "ui", + }); + }); + + it("does not create a chat when BYO default provider setup is required", async () => { + requireByoDefaultProviderSetup(); + const user = userEvent.setup(); + renderAppShell(); + + await user.click(screen.getByRole("button", { name: "Sidebar new chat" })); + + await waitFor(() => { + expect(screen.getByTestId("active-view")).toHaveTextContent("settings"); + }); + expect(screen.getByTestId("settings-section")).toHaveTextContent( + "providers", + ); + expect(mockAcpCreateSession).not.toHaveBeenCalled(); + }); + + it("allows chat creation when BYO default provider is ready", async () => { + mockBuildFeatures.byoKeyProviders = true; + useDefaultProviderReadinessStore.setState({ + readiness: { status: "ready", providerId: "openai", modelId: "gpt-4o" }, + }); + mockCheckAllProviderStatus.mockResolvedValue([ + { providerId: "openai", isConfigured: true }, + ]); + const user = userEvent.setup(); + renderAppShell(); + + await user.click(screen.getByRole("button", { name: "Sidebar new chat" })); + + await waitFor(() => { + expect(screen.getByTestId("active-view")).toHaveTextContent("chat"); + }); + expect(mockAcpCreateSession).toHaveBeenCalled(); + }); + + it("allows a configured concrete provider when the BYO default is missing", async () => { + requireByoDefaultProviderSetup(); + setResolvingPersona(); + mockCheckAllProviderStatus.mockResolvedValue([ + { providerId: "databricks_v2", isConfigured: true }, + ]); + const user = userEvent.setup(); + renderAppShell(); + + await user.click( + screen.getByRole("button", { name: "Start chat with resolving agent" }), + ); + + await waitFor(() => { + expect(screen.getByTestId("active-view")).toHaveTextContent("chat"); + }); + expect(mockAcpCreateSession).toHaveBeenCalledWith( + "databricks_v2", + "~/goose artifacts", + { + deferProviderSetup: false, + modelId: undefined, + projectId: undefined, + }, + ); + }); + + it("uses a configured explicit Goose model provider when Goose defaults need setup", async () => { + requireByoDefaultProviderSetup(); + setResolvingPersona("goose-model", "goose", "databricks_v2"); + mockCheckAllProviderStatus.mockResolvedValue([ + { providerId: "databricks_v2", isConfigured: true }, + ]); + const user = userEvent.setup(); + renderAppShell(); + + await user.click( + screen.getByRole("button", { name: "Start chat with resolving agent" }), + ); + + await waitFor(() => { + expect(screen.getByTestId("active-view")).toHaveTextContent("chat"); + }); + expect(mockAcpCreateSession).toHaveBeenCalledWith( + "databricks_v2", + "~/goose artifacts", + { + deferProviderSetup: false, + modelId: "goose-model", + projectId: undefined, + }, + ); + }); + + it("blocks an unconfigured explicit Goose model provider when the Goose default is ready", async () => { + mockBuildFeatures.byoKeyProviders = true; + useDefaultProviderReadinessStore.setState({ + readiness: { + status: "ready", + providerId: "openai", + modelId: "gpt-4o", + }, + }); + setResolvingPersona("goose-model", "goose", "databricks_v2"); + mockCheckAllProviderStatus.mockResolvedValue([ + { providerId: "openai", isConfigured: true }, + { providerId: "databricks_v2", isConfigured: false }, + ]); + const user = userEvent.setup(); + renderAppShell(); + + await user.click( + screen.getByRole("button", { name: "Start chat with resolving agent" }), + ); + + await waitFor(() => { + expect(screen.getByTestId("active-view")).toHaveTextContent("settings"); + }); + expect(screen.getByTestId("settings-section")).toHaveTextContent( + "providers", + ); + expect(mockAcpCreateSession).not.toHaveBeenCalled(); + }); + + it("starts general chats with the resolved provider when a stored agent is unavailable", async () => { + useAgentStore.setState({ + providers: [ + { id: "goose", label: "Goose" }, + { id: "codex-acp", label: "Codex" }, + ], + selectedProvider: "codex-acp", + }); + const user = userEvent.setup(); + renderAppShell(); + + await user.click(screen.getByRole("button", { name: "Sidebar new chat" })); + + await waitFor(() => { + expect(screen.getByTestId("active-view")).toHaveTextContent("chat"); + }); + expect(mockAcpCreateSession).toHaveBeenCalledWith( + "goose", + "~/goose artifacts", + { + deferProviderSetup: false, + modelId: undefined, + projectId: undefined, + }, + ); + }); + + it("allows a ready external ACP agent when the BYO default is missing", async () => { + requireByoDefaultProviderSetup(); + selectCodexProvider(); + mockIsExternalAgentReady.mockResolvedValue(true); + mockAgentStatus.readyAgentIds = new Set(["goose", "codex-acp"]); + const user = userEvent.setup(); + renderAppShell(); + + await user.click(screen.getByRole("button", { name: "Sidebar new chat" })); + + await waitFor(() => { + expect(screen.getByTestId("active-view")).toHaveTextContent("chat"); + }); + expect(mockAcpCreateSession).toHaveBeenCalledWith( + "codex-acp", + "~/goose artifacts", + { + deferProviderSetup: false, + modelId: undefined, + projectId: undefined, + }, + ); + }); + + it("materializes an external ACP default model when promoting a draft", async () => { + selectCodexProvider(); + mockAgentStatus.readyAgentIds = new Set(["codex-acp"]); + mockAcpCreateSession.mockResolvedValueOnce({ + sessionId: "created-session", + configOptionsSnapshot: { + model: { modelId: "gpt-5.5", modelName: "GPT-5.5" }, + reasoningEffort: null, + }, + }); + const user = userEvent.setup(); + renderAppShell(); + + await user.click(screen.getByRole("button", { name: "Sidebar new chat" })); + + await waitFor(() => { + expect( + useChatSessionStore.getState().getSession("created-session"), + ).toMatchObject({ + executionTarget: { + harnessId: "codex-acp", + modelProviderId: "codex-acp", + modelId: "gpt-5.5", + modelName: "GPT-5.5", + }, + }); + }); + }); + + it("preserves the stored model for a ready external ACP agent", async () => { + requireByoDefaultProviderSetup(); + selectCodexProvider(); + mockAgentStatus.readyAgentIds = new Set(["codex-acp"]); + window.localStorage.setItem( + "goose:preferredModelsByAgent", + JSON.stringify({ + "codex-acp": { + modelId: "gpt-5.5", + modelName: "GPT-5.5", + providerId: "codex-acp", + }, + }), + ); + const user = userEvent.setup(); + renderAppShell(); + + await user.click(screen.getByRole("button", { name: "Sidebar new chat" })); + + await waitFor(() => { + expect(mockAcpCreateSession).toHaveBeenCalledWith( + "codex-acp", + "~/goose artifacts", + { + deferProviderSetup: false, + modelId: "gpt-5.5", + projectId: undefined, + }, + ); + }); + }); + + it("routes an auth-failed external ACP agent to Providers settings", async () => { + requireByoDefaultProviderSetup(); + selectCodexProvider(); + mockIsExternalAgentReady.mockResolvedValue(false); + mockAgentStatus.readyAgentIds = new Set(["goose"]); + const user = userEvent.setup(); + renderAppShell(); + + await user.click(screen.getByRole("button", { name: "Sidebar new chat" })); + + await waitFor(() => { + expect(screen.getByTestId("active-view")).toHaveTextContent("settings"); + }); + expect(screen.getByTestId("settings-section")).toHaveTextContent( + "providers", + ); + expect(mockAcpCreateSession).not.toHaveBeenCalled(); + }); + + it("starts general chats with goose when the stored provider is unknown", async () => { + useAgentStore.setState({ + providers: [{ id: "goose", label: "Goose" }], + selectedProvider: "ghost-provider", + }); + const user = userEvent.setup(); + renderAppShell(); + + await user.click(screen.getByRole("button", { name: "Sidebar new chat" })); + + await waitFor(() => { + expect(screen.getByTestId("active-view")).toHaveTextContent("chat"); + }); + expect(mockAcpCreateSession).toHaveBeenCalledWith( + "goose", + "~/goose artifacts", + { + deferProviderSetup: false, + modelId: undefined, + projectId: undefined, + }, + ); + }); + + it("opens pane jump mode and focuses app regions by badge key", () => { + vi.spyOn(HTMLElement.prototype, "getBoundingClientRect").mockImplementation( + function (this: HTMLElement) { + if (this.tagName === "HEADER") { + return rect(0, 0, 1000, 48); + } + if (this.tagName === "MAIN") { + return rect(260, 48, 740, 652); + } + if (this.querySelector('nav[aria-label="mock sidebar"]')) { + return rect(0, 48, 260, 652); + } + return rect(760, 580, 220, 100); + }, + ); + renderAppShell(); + + fireEvent.keyDown(window, { key: ";", ctrlKey: true }); + expect(screen.getByTestId("pane-jump-overlay")).toBeInTheDocument(); + expect(screen.getByText("s")).toBeInTheDocument(); + expect(screen.getByText("sidebar")).toBeInTheDocument(); + + fireEvent.keyDown(window, { key: "s" }); + expect( + screen.getByRole("button", { name: "Sidebar new chat" }), + ).toHaveFocus(); + + fireEvent.keyDown(window, { key: ";", ctrlKey: true }); + fireEvent.keyDown(window, { key: "l" }); + expect(screen.getByPlaceholderText("Start a conversation")).toHaveFocus(); + expect(screen.queryByTestId("pane-jump-overlay")).not.toBeInTheDocument(); + }); + + it("starts pane jump mode from the main composer", async () => { + mockVisibleRegionRects(); + renderAppShell(); + + await act(async () => { + screen.getByPlaceholderText("Start a conversation").focus(); + }); + fireEvent.keyDown(screen.getByPlaceholderText("Start a conversation"), { + key: ";", + ctrlKey: true, + }); + + expect(screen.getByTestId("pane-jump-overlay")).toBeInTheDocument(); + }); + + it("starts a full blank chat from the saved artifact location", async () => { + window.localStorage.setItem( + "goose:artifact-root-path", + "/Users/test/goose artifacts test", + ); + const user = userEvent.setup(); + + renderAppShell(); + + await user.click(screen.getByRole("button", { name: "Sidebar new chat" })); + + await waitFor(() => { + expect(screen.getByTestId("active-view")).toHaveTextContent("chat"); + }); + expect(mockAcpCreateSession).toHaveBeenCalledWith( + "goose", + "/Users/test/goose artifacts test", + { + deferProviderSetup: false, + modelId: undefined, + projectId: undefined, + }, + ); + expect( + useChatSessionStore + .getState() + .getSession(useChatSessionStore.getState().activeSessionId ?? ""), + ).toMatchObject({ + workingDir: "/Users/test/goose artifacts test", + }); + }); + + it("opens an existing session with a missing saved cwd using the artifact fallback warning", async () => { + const user = userEvent.setup(); + const session: ChatSession = { + id: "missing-session", + title: "Missing cwd chat", + executionTarget: { harnessId: "goose" }, + workingDir: "/missing/session", + createdAt: "2026-06-09T00:00:00.000Z", + updatedAt: "2026-06-09T00:00:00.000Z", + messageCount: 1, + }; + useChatSessionStore.setState({ + sessions: [session], + activeSessionId: null, + }); + mockCheckDirectoriesExist.mockResolvedValue(["/missing/session"]); + + renderAppShell(); + + await user.click( + screen.getByRole("button", { name: "Open missing session" }), + ); + + await waitFor(() => { + expect(screen.getByTestId("active-view")).toHaveTextContent("chat"); + expect(mockAcpLoadSession).toHaveBeenCalledWith( + "missing-session", + "~/goose artifacts", + ); + }); + + const messages = + useChatStore.getState().messagesBySession["missing-session"] ?? []; + expect(messages).toHaveLength(1); + expect(messages[0]?.content[0]).toMatchObject({ + type: "systemNotification", + notificationType: "warning", + action: { type: "openContextPanel" }, + }); + }); + + it("shows a toast when a session deep link cannot open its target", async () => { + let handler: + | ((payload: { sessionId: string; message: string }) => void) + | undefined; + const unlisten = vi.fn(); + mockListenSessionDeepLinkErrors.mockImplementation( + (nextHandler: typeof handler) => { + handler = nextHandler; + return Promise.resolve(unlisten); + }, + ); + + renderAppShell(); + + await waitFor(() => { + expect(handler).toBeDefined(); + }); + + act(() => { + handler?.({ + sessionId: "missing-session", + message: 'No session "missing-session".', + }); + }); + + expect(mockToastError).toHaveBeenCalledWith( + 'No session "missing-session".', + ); + }); + + it("cleans up the session deep link error listener on unmount", async () => { + const unlisten = vi.fn(); + mockListenSessionDeepLinkErrors.mockResolvedValue(unlisten); + + const { unmount } = renderAppShell(); + + await waitFor(() => { + expect(mockListenSessionDeepLinkErrors).toHaveBeenCalled(); + }); + await act(async () => {}); + + unmount(); + + expect(unlisten).toHaveBeenCalledTimes(1); + }); + + it("cleans up the session deep link error listener when setup finishes after unmount", async () => { + const listenDeferred = deferred<() => void>(); + const unlisten = vi.fn(); + mockListenSessionDeepLinkErrors.mockReturnValue(listenDeferred.promise); + + const { unmount } = renderAppShell(); + + await waitFor(() => { + expect(mockListenSessionDeepLinkErrors).toHaveBeenCalled(); + }); + + unmount(); + + await act(async () => { + listenDeferred.resolve(unlisten); + await listenDeferred.promise; + }); + + expect(unlisten).toHaveBeenCalledTimes(1); + }); + + it("renders the target chat immediately without app-level staging", async () => { + const user = userEvent.setup(); + const session: ChatSession = { + id: "session-1", + title: "Active chat", + executionTarget: { harnessId: "goose" }, + workingDir: "~/goose artifacts", + createdAt: "2026-06-09T00:00:00.000Z", + updatedAt: "2026-06-09T00:00:00.000Z", + messageCount: 1, + }; + useChatSessionStore.setState({ + sessions: [session], + activeSessionId: null, + }); + + renderAppShell(); + + await user.click(screen.getByRole("button", { name: "Open session 1" })); + + expect(screen.getByTestId("active-view")).toHaveTextContent("chat"); + expect(screen.getByTestId("preparing-content")).toHaveTextContent("false"); + expect(screen.getByTestId("rendered-view")).toHaveTextContent("chat"); + expect(screen.getByTestId("rendered-session-id")).toHaveTextContent( + "session-1", + ); + + await act(async () => { + flushAfterNextPaintCallbacks(); + }); + + expect(screen.getByTestId("preparing-content")).toHaveTextContent("false"); + expect(screen.getByTestId("rendered-view")).toHaveTextContent("chat"); + expect(screen.getByTestId("rendered-session-id")).toHaveTextContent( + "session-1", + ); + }); + + it("renders session-to-session chat changes immediately", async () => { + const user = userEvent.setup(); + const sessionBase = { + executionTarget: { harnessId: "goose" }, + workingDir: "~/goose artifacts", + createdAt: "2026-06-09T00:00:00.000Z", + updatedAt: "2026-06-09T00:00:00.000Z", + messageCount: 1, + } satisfies Partial; + useChatSessionStore.setState({ + sessions: [ + { ...sessionBase, id: "session-1", title: "First chat" }, + { ...sessionBase, id: "session-2", title: "Second chat" }, + ] as ChatSession[], + activeSessionId: "session-1", + }); + + renderAppShell(); + + await user.click(screen.getByRole("button", { name: "Open session 2" })); + + expect(screen.getByTestId("active-view")).toHaveTextContent("chat"); + expect(screen.getByTestId("preparing-content")).toHaveTextContent("false"); + expect(screen.getByTestId("rendered-view")).toHaveTextContent("chat"); + expect(screen.getByTestId("rendered-session-id")).toHaveTextContent( + "session-2", + ); + }); + + it("keeps archive UI active until the backend succeeds and rolls back archivedAt on failure", async () => { + const user = userEvent.setup(); + const archive = deferred(); + mockAcpArchiveSession.mockReturnValueOnce(archive.promise); + const session: ChatSession = { + id: "session-1", + title: "Active chat", + executionTarget: { harnessId: "goose" }, + workingDir: "~/goose artifacts", + createdAt: "2026-06-09T00:00:00.000Z", + updatedAt: "2026-06-09T00:00:00.000Z", + messageCount: 1, + }; + const message: Message = { + id: "message-1", + role: "user", + created: Date.now(), + content: [{ type: "text", text: "hello" }], + }; + useChatSessionStore.setState({ + sessions: [session], + activeSessionId: null, + }); + useChatStore.setState({ + messagesBySession: { "session-1": [message] }, + }); + + renderAppShell(); + + await user.click(screen.getByRole("button", { name: "Open session 1" })); + await waitFor(() => { + expect(screen.getByTestId("active-view")).toHaveTextContent("chat"); + }); + + await user.click(screen.getByRole("button", { name: "Archive session 1" })); + + await waitFor(() => { + expect(mockAcpArchiveSession).toHaveBeenCalledWith("session-1"); + }); + expect(screen.getByTestId("active-view")).toHaveTextContent("chat"); + expect(useChatSessionStore.getState().activeSessionId).toBe("session-1"); + expect( + useChatSessionStore.getState().getSession("session-1")?.archivedAt, + ).toEqual(expect.any(String)); + expect(useChatStore.getState().messagesBySession["session-1"]).toEqual([ + message, + ]); + + act(() => { + archive.reject(new Error("backend down")); + }); + + await waitFor(() => { + expect( + useChatSessionStore.getState().getSession("session-1")?.archivedAt, + ).toBeUndefined(); + }); + expect(screen.getByTestId("active-view")).toHaveTextContent("chat"); + expect(useChatSessionStore.getState().activeSessionId).toBe("session-1"); + expect(useChatStore.getState().messagesBySession["session-1"]).toEqual([ + message, + ]); + expect(mockToastError).toHaveBeenCalledWith("backend down"); + }); + + it("removes a pinned chat from Home only after archive succeeds", async () => { + const user = userEvent.setup(); + const archive = deferred(); + mockAcpArchiveSession.mockReturnValueOnce(archive.promise); + useChatSessionStore.setState({ + sessions: [ + { + id: "session-1", + title: "Pinned chat", + executionTarget: { harnessId: "goose" }, + workingDir: "~/goose artifacts", + createdAt: "2026-06-09T00:00:00.000Z", + updatedAt: "2026-06-09T00:00:00.000Z", + messageCount: 1, + }, + ], + }); + useHomeWidgetStore.setState({ + loadStatus: "ready", + itemRevision: 1, + instances: [ + { + id: "chat-pin-1", + type: "chatPin", + x: 0, + y: 0, + z: 1, + state: { sessionId: "session-1" }, + }, + ], + }); + + renderAppShell(); + await user.click(screen.getByRole("button", { name: "Archive session 1" })); + expect(useHomeWidgetStore.getState().instances).toHaveLength(1); + + await act(async () => archive.resolve(undefined)); + await waitFor(() => { + expect(useHomeWidgetStore.getState().instances).toHaveLength(0); + }); + }); + + it("archives chats without managed Git resources when session pagination fails", async () => { + const user = userEvent.setup(); + mockAcpListSessionsPage.mockRejectedValue(new Error("list failed")); + useChatSessionStore.setState({ + sessions: [ + { + id: "session-1", + title: "Plain chat", + executionTarget: { harnessId: "goose" }, + workingDir: "/tmp/plain-chat", + createdAt: "2026-07-10T00:00:00.000Z", + updatedAt: "2026-07-10T00:00:00.000Z", + messageCount: 1, + }, + ], + }); + renderAppShell(); + + await user.click(screen.getByRole("button", { name: "Open session 1" })); + await user.click(screen.getByRole("button", { name: "Archive session 1" })); + + await waitFor(() => { + expect(mockAcpArchiveSession).toHaveBeenCalledWith("session-1"); + }); + expect(mockAcpListSessionsPage).not.toHaveBeenCalled(); + expect(mockToastError).not.toHaveBeenCalled(); + }); + + it("rejects noninteractive archive before local-file loss", async () => { + const worktreePath = "/repo-worktrees/cli-reject"; + mockPathExists.mockResolvedValue(true); + gitMocks.hasIgnoredFiles.mockResolvedValue(true); + gitMocks.getGitState.mockResolvedValue({ + isGitRepo: true, + currentBranch: "cli-reject", + dirtyFileCount: 0, + incomingCommitCount: 0, + worktrees: [ + { path: "/repo", branch: "main", isMain: true }, + { path: worktreePath, branch: "cli-reject", isMain: false }, + ], + isWorktree: true, + mainWorktreePath: "/repo", + localBranches: ["main", "cli-reject"], + }); + useChatSessionStore.setState({ + sessions: [ + { + id: "session-1", + title: "CLI reject", + executionTarget: { harnessId: "goose" }, + workingDir: worktreePath, + workspaceAttachments: [ + { + id: `path:${worktreePath}`, + path: worktreePath, + kind: "git-linked-worktree", + source: "created", + branch: "cli-reject", + repositoryPath: "/repo", + worktreePath, + usedByAgent: true, + lifecycle: { + owner: "goose", + cleanup: "worktree", + branch: "cli-reject", + baseBranch: "main", + repositoryPath: "/repo", + worktreePath, + createdBranch: true, + }, + }, + ], + createdAt: "2026-07-10T00:00:00.000Z", + updatedAt: "2026-07-10T00:00:00.000Z", + messageCount: 1, + }, + ], + }); + renderAppShell(); + + const outcome = await getAppNavigationController().archiveSession( + "session-1", + "reject", + ); + + expect(outcome).toEqual({ + ok: false, + reason: "cleanup_requires_discard", + }); + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); + expect(mockAcpArchiveSession).not.toHaveBeenCalled(); + expect(gitMocks.removeWorktree).not.toHaveBeenCalled(); + }); + + it("noninteractive discard archives and cleans without a dialog", async () => { + const worktreePath = "/repo-worktrees/cli-discard"; + mockPathExists.mockResolvedValue(true); + gitMocks.hasIgnoredFiles.mockResolvedValue(true); + gitMocks.getGitState.mockResolvedValue({ + isGitRepo: true, + currentBranch: "cli-discard", + dirtyFileCount: 0, + incomingCommitCount: 0, + worktrees: [ + { path: "/repo", branch: "main", isMain: true }, + { path: worktreePath, branch: "cli-discard", isMain: false }, + ], + isWorktree: true, + mainWorktreePath: "/repo", + localBranches: ["main", "cli-discard"], + }); + useChatSessionStore.setState({ + sessions: [ + { + id: "session-1", + title: "CLI discard", + executionTarget: { harnessId: "goose" }, + workingDir: worktreePath, + workspaceAttachments: [ + { + id: `path:${worktreePath}`, + path: worktreePath, + kind: "git-linked-worktree", + source: "created", + branch: "cli-discard", + repositoryPath: "/repo", + worktreePath, + usedByAgent: true, + lifecycle: { + owner: "goose", + cleanup: "worktree", + branch: "cli-discard", + baseBranch: "main", + repositoryPath: "/repo", + worktreePath, + createdBranch: true, + }, + }, + ], + createdAt: "2026-07-10T00:00:00.000Z", + updatedAt: "2026-07-10T00:00:00.000Z", + messageCount: 1, + }, + ], + }); + renderAppShell(); + + let outcome: unknown; + await act(async () => { + outcome = await getAppNavigationController().archiveSession( + "session-1", + "discard", + ); + }); + + expect(outcome).toEqual({ ok: true }); + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); + expect(mockAcpArchiveSession).toHaveBeenCalledWith("session-1"); + expect(gitMocks.removeWorktree).toHaveBeenCalledWith( + "/repo", + worktreePath, + true, + ); + }); + + it("blocks destructive Git cleanup and chat archival until confirmed", async () => { + const user = userEvent.setup(); + const worktreePath = "/repo-worktrees/dirty-chat"; + mockPathExists.mockResolvedValue(true); + gitMocks.getGitState.mockResolvedValue({ + isGitRepo: true, + currentBranch: "dirty-chat", + dirtyFileCount: 2, + incomingCommitCount: 0, + worktrees: [ + { path: "/repo", branch: "main", isMain: true }, + { path: worktreePath, branch: "dirty-chat", isMain: false }, + ], + isWorktree: true, + mainWorktreePath: "/repo", + localBranches: ["main", "dirty-chat"], + }); + const session: ChatSession = { + id: "session-1", + title: "Dirty chat", + executionTarget: { harnessId: "goose" }, + workingDir: worktreePath, + workspaceAttachments: [ + { + id: `path:${worktreePath}`, + path: worktreePath, + kind: "git-linked-worktree", + source: "created", + branch: "dirty-chat", + repositoryPath: "/repo", + worktreePath, + usedByAgent: true, + lifecycle: { + owner: "goose", + cleanup: "worktree", + branch: "dirty-chat", + baseBranch: "main", + repositoryPath: "/repo", + worktreePath, + createdBranch: true, + }, + }, + ], + createdAt: "2026-07-10T00:00:00.000Z", + updatedAt: "2026-07-10T00:00:00.000Z", + messageCount: 1, + }; + useChatSessionStore.setState({ sessions: [session] }); + renderAppShell(); + + await user.click(screen.getByRole("button", { name: "Open session 1" })); + await user.click(screen.getByRole("button", { name: "Archive session 1" })); + + expect( + await screen.findByRole("dialog", { + name: "Archive chat and remove its worktrees?", + }), + ).toBeInTheDocument(); + expect( + screen.getByText(/discard local files and changes/i), + ).toBeInTheDocument(); + expect(gitMocks.removeWorktree).not.toHaveBeenCalled(); + expect(mockAcpArchiveSession).not.toHaveBeenCalled(); + + await user.click(screen.getByRole("button", { name: "Cancel" })); + + await waitFor(() => { + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); + }); + expect(gitMocks.removeWorktree).not.toHaveBeenCalled(); + expect(mockAcpArchiveSession).not.toHaveBeenCalled(); + expect(useChatSessionStore.getState().activeSessionId).toBe("session-1"); + expect( + useChatSessionStore.getState().getSession("session-1")?.archivedAt, + ).toBeUndefined(); + + await user.click(screen.getByRole("button", { name: "Archive session 1" })); + await user.click( + await screen.findByRole("button", { name: "Archive and remove" }), + ); + + await waitFor(() => { + expect(mockAcpArchiveSession).toHaveBeenCalledWith("session-1"); + }); + expect(gitMocks.removeWorktree).toHaveBeenCalledWith( + "/repo", + worktreePath, + true, + ); + expect(gitMocks.deleteBranch).toHaveBeenCalledWith( + "/repo", + "dirty-chat", + true, + "main", + ); + expect(mockAcpArchiveSession.mock.invocationCallOrder[0]).toBeLessThan( + gitMocks.removeWorktree.mock.invocationCallOrder[0] ?? Infinity, + ); + expect(screen.getByTestId("active-view")).toHaveTextContent("home"); + }); + + it("blocks archive with pre-archive copy when Git inspection fails", async () => { + const user = userEvent.setup(); + const worktreePath = "/repo-worktrees/inspect-fails"; + mockPathExists.mockResolvedValue(true); + mockAcpListSessionsPage.mockRejectedValue(new Error("list failed")); + useChatSessionStore.setState({ + sessions: [ + { + id: "session-1", + title: "Inspect fails", + executionTarget: { harnessId: "goose" }, + workingDir: worktreePath, + workspaceAttachments: [ + { + id: `path:${worktreePath}`, + path: worktreePath, + kind: "git-linked-worktree", + source: "created", + branch: "inspect-fails", + repositoryPath: "/repo", + worktreePath, + usedByAgent: true, + lifecycle: { + owner: "goose", + cleanup: "worktree", + branch: "inspect-fails", + baseBranch: "main", + repositoryPath: "/repo", + worktreePath, + createdBranch: true, + }, + }, + ], + createdAt: "2026-07-10T00:00:00.000Z", + updatedAt: "2026-07-10T00:00:00.000Z", + messageCount: 1, + }, + ], + }); + renderAppShell(); + + await user.click(screen.getByRole("button", { name: "Open session 1" })); + await user.click(screen.getByRole("button", { name: "Archive session 1" })); + + await waitFor(() => { + expect(mockToastError).toHaveBeenCalledWith( + "Couldn't inspect the worktrees or branches. The chat wasn't archived.", + { description: "list failed" }, + ); + }); + expect(mockAcpArchiveSession).not.toHaveBeenCalled(); + }); + + it("prompts before removing a worktree with only ignored files", async () => { + const user = userEvent.setup(); + const worktreePath = "/repo-worktrees/ignored-files"; + mockPathExists.mockResolvedValue(true); + gitMocks.hasIgnoredFiles.mockResolvedValue(true); + gitMocks.getGitState.mockResolvedValue({ + isGitRepo: true, + currentBranch: "ignored-files", + dirtyFileCount: 0, + incomingCommitCount: 0, + worktrees: [ + { path: "/repo", branch: "main", isMain: true }, + { path: worktreePath, branch: "ignored-files", isMain: false }, + ], + isWorktree: true, + mainWorktreePath: "/repo", + localBranches: ["main", "ignored-files"], + }); + useChatSessionStore.setState({ + sessions: [ + { + id: "session-1", + title: "Ignored files", + executionTarget: { harnessId: "goose" }, + workingDir: worktreePath, + workspaceAttachments: [ + { + id: `path:${worktreePath}`, + path: worktreePath, + kind: "git-linked-worktree", + source: "created", + branch: "ignored-files", + repositoryPath: "/repo", + worktreePath, + usedByAgent: true, + lifecycle: { + owner: "goose", + cleanup: "worktree", + branch: "ignored-files", + baseBranch: "main", + repositoryPath: "/repo", + worktreePath, + createdBranch: true, + }, + }, + ], + createdAt: "2026-07-10T00:00:00.000Z", + updatedAt: "2026-07-10T00:00:00.000Z", + messageCount: 1, + }, + ], + }); + renderAppShell(); + + await user.click(screen.getByRole("button", { name: "Open session 1" })); + await user.click(screen.getByRole("button", { name: "Archive session 1" })); + + expect( + await screen.findByRole("dialog", { + name: "Archive chat and remove its worktrees?", + }), + ).toBeInTheDocument(); + expect(mockAcpArchiveSession).not.toHaveBeenCalled(); + expect(gitMocks.removeWorktree).not.toHaveBeenCalled(); + }); + + it("reports cleanup failure as an archived chat with incomplete cleanup", async () => { + const user = userEvent.setup(); + const worktreePath = "/repo-worktrees/cleanup-fails"; + mockPathExists.mockResolvedValue(true); + gitMocks.getGitState.mockResolvedValue( + managedWorktreeGitState("cleanup-fails", worktreePath), + ); + gitMocks.removeWorktree.mockRejectedValue(new Error("cleanup failed")); + const session = makeManagedWorktreeSession("cleanup-fails", worktreePath); + useChatSessionStore.setState({ sessions: [session] }); + renderAppShell(); + + await user.click(screen.getByRole("button", { name: "Open session 1" })); + let outcome: unknown; + await act(async () => { + outcome = await getAppNavigationController().archiveSession( + "session-1", + "confirm", + ); + }); + + expect(outcome).toEqual({ + ok: true, + cleanupIncomplete: "workspace_cleanup_failed", + }); + expect(gitMocks.removeWorktree).toHaveBeenCalled(); + expect(mockToastError).toHaveBeenCalledWith("cleanup failed"); + expect(mockAcpArchiveSession).toHaveBeenCalledWith("session-1"); + expect(useChatSessionStore.getState().activeSessionId).toBeNull(); + expect( + useChatSessionStore.getState().getSession("session-1")?.archivedAt, + ).toEqual(expect.any(String)); + expect(screen.getByTestId("active-view")).toHaveTextContent("home"); + }); + + it("reports noninteractive cleanup as incomplete if the session starts running after archival", async () => { + let resolveArchive!: () => void; + mockAcpArchiveSession.mockReturnValue( + new Promise((resolve) => { + resolveArchive = resolve; + }), + ); + mockPathExists.mockResolvedValue(true); + gitMocks.getGitState.mockResolvedValue( + managedWorktreeGitState("runs-after-archive"), + ); + useChatSessionStore.setState({ + sessions: [makeManagedWorktreeSession("runs-after-archive")], + }); + renderAppShell(); + + let outcome!: Promise; + act(() => { + outcome = getAppNavigationController().archiveSession( + "session-1", + "reject", + ); + }); + await waitFor(() => { + expect(mockAcpArchiveSession).toHaveBeenCalledWith("session-1"); + }); + + await act(async () => { + useChatStore.getState().setChatState("session-1", "thinking"); + resolveArchive(); + await outcome; + }); + + await expect(outcome).resolves.toEqual({ + ok: true, + cleanupIncomplete: "target_session_running", + }); + expect(gitMocks.removeWorktree).not.toHaveBeenCalled(); + }); + + it("does not start noninteractive archival inside the deadline margin", async () => { + useChatSessionStore.setState({ + sessions: [makeManagedWorktreeSession("near-deadline")], + }); + renderAppShell(); + + const outcome = await getAppNavigationController().archiveSession( + "session-1", + "reject", + Date.now() + 2_999, + ); + + expect(outcome).toEqual({ ok: false, reason: "timed_out" }); + expect(mockAcpArchiveSession).not.toHaveBeenCalled(); + expect(gitMocks.removeWorktree).not.toHaveBeenCalled(); + }); + + it("rechecks running state before noninteractive archival", async () => { + const inspection = deferred(); + mockPathExists.mockResolvedValue(true); + gitMocks.getGitState.mockReturnValue(inspection.promise); + useChatSessionStore.setState({ + sessions: [makeManagedWorktreeSession("starts-running")], + }); + renderAppShell(); + + const outcome = getAppNavigationController().archiveSession( + "session-1", + "reject", + ); + await waitFor(() => { + expect(gitMocks.getGitState).toHaveBeenCalled(); + }); + + act(() => { + useChatStore.getState().setChatState("session-1", "thinking"); + inspection.resolve(managedWorktreeGitState("starts-running")); + }); + + await expect(outcome).resolves.toEqual({ + ok: false, + reason: "target_session_running", + }); + expect(mockAcpArchiveSession).not.toHaveBeenCalled(); + expect(gitMocks.removeWorktree).not.toHaveBeenCalled(); + }); + + it("archives the active session with Cmd+E", async () => { + const user = userEvent.setup(); + const session: ChatSession = { + id: "session-1", + title: "Active chat", + executionTarget: { harnessId: "goose" }, + workingDir: "~/goose artifacts", + createdAt: "2026-06-09T00:00:00.000Z", + updatedAt: "2026-06-09T00:00:00.000Z", + messageCount: 1, + }; + useChatSessionStore.setState({ + sessions: [session], + activeSessionId: null, + }); + + renderAppShell(); + + await user.click(screen.getByRole("button", { name: "Open session 1" })); + await waitFor(() => { + expect(screen.getByTestId("active-view")).toHaveTextContent("chat"); + }); + + fireEvent.keyDown(window, { key: "e", metaKey: true }); + + await waitFor(() => { + expect(screen.getByTestId("active-view")).toHaveTextContent("home"); + }); + expect(mockAcpArchiveSession).toHaveBeenCalledWith("session-1"); + expect(useChatSessionStore.getState().activeSessionId).toBeNull(); + expect( + useChatSessionStore.getState().getSession("session-1")?.archivedAt, + ).toEqual(expect.any(String)); + }); + + it("archives the active session with Cmd+E while the chat composer is focused", async () => { + const user = userEvent.setup(); + const session: ChatSession = { + id: "session-1", + title: "Active chat", + executionTarget: { harnessId: "goose" }, + workingDir: "~/goose artifacts", + createdAt: "2026-06-09T00:00:00.000Z", + updatedAt: "2026-06-09T00:00:00.000Z", + messageCount: 1, + }; + useChatSessionStore.setState({ + sessions: [session], + activeSessionId: null, + }); + + renderAppShell(); + + await user.click(screen.getByRole("button", { name: "Open session 1" })); + await waitFor(() => { + expect(screen.getByTestId("active-view")).toHaveTextContent("chat"); + }); + + // The real composer textarea (ChatInput) carries data-chat-composer. + const composer = document.createElement("textarea"); + composer.setAttribute("data-chat-composer", ""); + document.body.appendChild(composer); + + composer.focus(); + fireEvent.keyDown(composer, { key: "e", metaKey: true }); + + await waitFor(() => { + expect(screen.getByTestId("active-view")).toHaveTextContent("home"); + }); + expect(mockAcpArchiveSession).toHaveBeenCalledWith("session-1"); + expect( + useChatSessionStore.getState().getSession("session-1")?.archivedAt, + ).toEqual(expect.any(String)); + }); + + it("does not archive with Cmd+E from editable fields outside the composer", async () => { + const user = userEvent.setup(); + const session: ChatSession = { + id: "session-1", + title: "Active chat", + executionTarget: { harnessId: "goose" }, + workingDir: "~/goose artifacts", + createdAt: "2026-06-09T00:00:00.000Z", + updatedAt: "2026-06-09T00:00:00.000Z", + messageCount: 1, + }; + useChatSessionStore.setState({ + sessions: [session], + activeSessionId: null, + }); + + renderAppShell(); + + await user.click(screen.getByRole("button", { name: "Open session 1" })); + await waitFor(() => { + expect(screen.getByTestId("active-view")).toHaveTextContent("chat"); + }); + + const renameInput = document.createElement("input"); + renameInput.type = "text"; + document.body.appendChild(renameInput); + + renameInput.focus(); + fireEvent.keyDown(renameInput, { key: "e", metaKey: true }); + + expect(screen.getByTestId("active-view")).toHaveTextContent("chat"); + expect(mockAcpArchiveSession).not.toHaveBeenCalled(); + expect( + useChatSessionStore.getState().getSession("session-1")?.archivedAt, + ).toBeUndefined(); + }); + + it("does not archive from Ctrl+E inside the terminal on non-mac platforms", async () => { + mockGetPlatform.mockReturnValue("windows"); + const user = userEvent.setup(); + const session: ChatSession = { + id: "session-1", + title: "Active chat", + executionTarget: { harnessId: "goose" }, + workingDir: "~/goose artifacts", + createdAt: "2026-06-09T00:00:00.000Z", + updatedAt: "2026-06-09T00:00:00.000Z", + messageCount: 1, + }; + useChatSessionStore.setState({ + sessions: [session], + activeSessionId: null, + }); + + renderAppShell(); + + await user.click(screen.getByRole("button", { name: "Open session 1" })); + await waitFor(() => { + expect(screen.getByTestId("active-view")).toHaveTextContent("chat"); + }); + + const terminal = document.createElement("div"); + terminal.className = "xterm"; + const terminalInput = document.createElement("textarea"); + terminal.appendChild(terminalInput); + document.body.appendChild(terminal); + + terminalInput.focus(); + fireEvent.keyDown(terminalInput, { key: "e", ctrlKey: true }); + + expect(screen.getByTestId("active-view")).toHaveTextContent("chat"); + expect(mockAcpArchiveSession).not.toHaveBeenCalled(); + expect(useChatSessionStore.getState().activeSessionId).toBe("session-1"); + expect( + useChatSessionStore.getState().getSession("session-1")?.archivedAt, + ).toBeUndefined(); + }); + + it("reserves toast space only while the global composer is visible", async () => { + const user = userEvent.setup(); + renderAppShell(); + + await waitFor(() => { + expect(document.documentElement).toHaveAttribute( + "data-global-composer-visible", + "true", + ); + }); + + await user.click(screen.getByRole("button", { name: "Sidebar new chat" })); + + await waitFor(() => { + expect(screen.getByTestId("active-view")).toHaveTextContent("chat"); + }); + expect(document.documentElement).not.toHaveAttribute( + "data-global-composer-visible", + ); + }); + + it("keeps the current view and focuses a centered global composer with Cmd+N from chat", async () => { + const user = userEvent.setup(); + renderAppShell(); + + await user.click(screen.getByRole("button", { name: "Sidebar new chat" })); + await waitFor(() => { + expect(screen.getByTestId("active-view")).toHaveTextContent("chat"); + }); + await waitFor(() => { + expect(mockAcpCreateSession).toHaveBeenCalledTimes(1); + }); + mockAcpCreateSession.mockClear(); + + await user.keyboard("{Meta>}n{/Meta}"); + + await act(async () => { + flushAfterNextPaintCallbacks(); + }); + expect(screen.getByTestId("active-view")).toHaveTextContent("chat"); + const textbox = await screen.findByPlaceholderText("Start a conversation"); + await waitFor(() => { + expect(textbox).toHaveFocus(); + }); + expect(textbox.closest("[data-placement]")).toHaveAttribute( + "data-placement", + "centered", + ); + expect(mockAcpCreateSession).not.toHaveBeenCalled(); + }); + + it("dismisses the centered global composer from the backdrop and global Escape", async () => { + for (const dismiss of ["backdrop", "escape"] as const) { + const { container, unmount } = renderAppShell(); + + await openCenteredComposerFromChat(); + if (dismiss === "backdrop") { + const shim = container.querySelector(".global-composer-shim"); + expect(shim).not.toBeNull(); + fireEvent.click(shim as Element); + } else { + fireEvent.keyDown(window, { key: "Escape" }); + } + + await waitFor(() => { + expect( + screen.queryByPlaceholderText("Start a conversation"), + ).not.toBeInTheDocument(); + }); + unmount(); + } + }); + + it("lets nested centered-composer pickers consume Escape before the composer dismisses", async () => { + renderAppShell(); + + const { textbox, user } = await openCenteredComposerFromChat(); + await user.click( + screen.getByRole("button", { name: /choose agent and model/i }), + ); + await screen.findByText("Agent"); + + await user.keyboard("{Escape}"); + + await waitFor(() => { + expect(screen.queryByText("Agent")).not.toBeInTheDocument(); + }); + expect(textbox).toBeInTheDocument(); + expect(textbox.closest("[data-placement]")).toHaveAttribute( + "data-placement", + "centered", + ); + + fireEvent.keyDown(window, { key: "Escape" }); + await waitFor(() => { + expect( + screen.queryByPlaceholderText("Start a conversation"), + ).not.toBeInTheDocument(); + }); + }); + + it("preserves the suggested agent tag when starting chat from the global composer", async () => { + renderAppShell(); + + fireEvent.click(screen.getByRole("button", { name: "Open agent detail" })); + await act(async () => { + flushAfterNextPaintCallbacks(); + }); + expect(screen.getByTestId("agent-route")).toHaveTextContent("persona-1"); + + const textbox = screen.getByPlaceholderText("Start a conversation"); + fireEvent.change(textbox, { + target: { value: "ask the tagged agent" }, + }); + fireEvent.keyDown(textbox, { key: "Enter" }); + + await waitFor(() => { + expect(useChatStore.getState().queuedMessageBySession).toMatchObject({ + "created-session": [ + { + payload: { + text: "ask the tagged agent", + persona: { kind: "persona", id: "persona-1" }, + }, + }, + ], + }); + expect( + useChatSessionStore.getState().getSession("created-session"), + ).toMatchObject({ + personaId: "persona-1", + }); + }); + }); + + it.each([ + { + label: "explicit no persona", + selectPersona: false, + expectedPersonaId: null, + }, + { + label: "a captured persona", + selectPersona: true, + expectedPersonaId: "persona-1", + }, + ])("preserves $label through global admission, Home handoff, and release", async ({ + selectPersona, + expectedPersonaId, + }) => { + renderAppShell(); + + if (selectPersona) { + fireEvent.click( + screen.getByRole("button", { name: "Open agent detail" }), + ); + await act(async () => { + flushAfterNextPaintCallbacks(); + }); + } + + const textbox = screen.getByPlaceholderText("Start a conversation"); + fireEvent.change(textbox, { target: { value: "preserve my intent" } }); + fireEvent.keyDown(textbox, { key: "Enter" }); + + await waitFor(() => { + expect( + useChatStore.getState().queuedMessageBySession["created-session"]?.[0] + ?.payload.persona, + ).toEqual( + expectedPersonaId === null + ? { kind: "none" } + : { kind: "persona", id: expectedPersonaId }, + ); + }); + + const chat = useChatStore.getState(); + const record = chat.queuedMessageBySession["created-session"]?.[0]; + expect(record?.kind).toBe("transport-ready"); + expect(record?.recordId).toEqual(expect.any(String)); + + act(() => { + useChatSessionStore.getState().patchSession("created-session", { + personaId: "later-session-persona", + }); + expect( + chat.deferTransportReadyMessage( + "created-session", + record?.recordId ?? "missing", + { type: "compaction", status: "pending" }, + ), + ).toBe(true); + expect( + useChatStore + .getState() + .releaseDeferredMessage( + "created-session", + record?.recordId ?? "missing", + ), + ).toBe(true); + }); + + expect( + useChatStore.getState().queuedMessageBySession["created-session"]?.[0], + ).toMatchObject({ + kind: "transport-ready", + releasedFromDeferred: true, + payload: { + text: "preserve my intent", + persona: + expectedPersonaId === null + ? { kind: "none" } + : { kind: "persona", id: expectedPersonaId }, + }, + }); + }); + + it("starts Berdy help prompts with the bundled Berdy persona", async () => { + const personaId = "/Users/test/.agents/agents/berdy.md"; + useAgentStore.setState({ + personas: [ + { + id: personaId, + displayName: "Berdy", + avatar: "app-avatar:gloopies-14", + systemPrompt: "Help people use Berd.", + isBuiltin: false, + writable: true, + sourceProperties: { metadata: { berdBundled: true } }, + }, + ], + }); + renderAppShell(); + + fireEvent.click( + screen.getByRole("button", { name: "Ask Berdy from Home" }), + ); + + await waitFor(() => { + expect(useChatStore.getState().queuedMessageBySession).toMatchObject({ + "created-session": [ + { + payload: { + text: "How do projects work?", + persona: { kind: "persona", id: personaId }, + showInComposer: false, + }, + }, + ], + }); + expect( + useChatSessionStore.getState().getSession("created-session"), + ).toMatchObject({ personaId }); + }); + }); + + it("restores a missing bundled Berdy agent before starting a chat", async () => { + const personaId = "/Users/test/.agents/agents/berdy.md"; + mockListPersonas.mockResolvedValue([ + { + id: personaId, + displayName: "Berdy", + avatar: "app-avatar:gloopies-14", + systemPrompt: "Help people use Berd.", + isBuiltin: false, + writable: false, + sourceProperties: { metadata: { berdBundled: true } }, + }, + ]); + useAgentStore.setState({ personas: [], personasLoading: false }); + renderAppShell(); + + fireEvent.click( + screen.getByRole("button", { name: "Ask Berdy from Home" }), + ); + + await waitFor(() => { + expect(mockRepairBundledAgent).toHaveBeenCalledWith("berdy.md"); + expect( + useChatSessionStore.getState().getSession("created-session"), + ).toMatchObject({ personaId }); + }); + expect(mockRepairBundledAgent.mock.invocationCallOrder[0]).toBeLessThan( + mockListPersonas.mock.invocationCallOrder[0], + ); + expect(mockListPersonas.mock.invocationCallOrder[0]).toBeLessThan( + mockAcpCreateSession.mock.invocationCallOrder[0], + ); + expect(mockToastError).not.toHaveBeenCalledWith( + "Berdy couldn't start a chat. Try again.", + ); + }); + + it("refreshes personas when repair reports an error after changing disk", async () => { + const personaId = "/Users/test/.agents/agents/berdy2.md"; + mockRepairBundledAgent.mockRejectedValue(new Error("marker write failed")); + mockListPersonas.mockResolvedValue([ + { + id: personaId, + displayName: "Berdy", + avatar: "app-avatar:gloopies-14", + systemPrompt: "Help people use Berd.", + isBuiltin: false, + writable: false, + sourceProperties: { metadata: { berdBundled: true } }, + }, + ]); + useAgentStore.setState({ personas: [], personasLoading: false }); + renderAppShell(); + + fireEvent.click( + screen.getByRole("button", { name: "Ask Berdy from Home" }), + ); + + await waitFor(() => { + expect(mockListPersonas).toHaveBeenCalled(); + expect( + useChatSessionStore.getState().getSession("created-session"), + ).toMatchObject({ personaId }); + }); + }); + + it.each([ + { + multiWorkspaceEnabled: true, + expectedAttachments: [ + { path: "/repo/builderbot", source: "inferred" }, + { path: "/repo/bbsubscriber", source: "inferred" }, + ], + }, + { + multiWorkspaceEnabled: false, + expectedAttachments: [{ path: "/repo/builderbot", source: "inferred" }], + }, + ])("gates as-is project workspace attachments for centered composer sends (multi=$multiWorkspaceEnabled)", async ({ + multiWorkspaceEnabled, + expectedAttachments, + }) => { + setMultiWorkspaceEnabled(multiWorkspaceEnabled); + const createSession = deferred<{ sessionId: string }>(); + mockAcpCreateSession.mockReturnValueOnce(createSession.promise); + const project: ProjectInfo = { + id: "project-1", + path: "/tmp/project-startup.md", + name: "Project Startup", + description: "", + prompt: "", + icon: "tabler:folder-code", + color: "olive", + projectWorkspaces: [ + { + id: "path:/repo/builderbot", + path: "/repo/builderbot", + kind: "subdirectory", + source: "selected", + branch: "main", + repositoryPath: "/repo", + worktreePath: "/repo", + usedByAgent: false, + startupMode: "none", + }, + { + id: "path:/repo/bbsubscriber", + path: "/repo/bbsubscriber", + kind: "subdirectory", + source: "selected", + branch: "main", + repositoryPath: "/repo", + worktreePath: "/repo", + usedByAgent: false, + startupMode: "none", + }, + ], + workingDirs: ["/repo/builderbot", "/repo/bbsubscriber"], + useWorktrees: false, + order: 0, + archivedAt: null, + artifact: null, + }; + useProjectStore.setState({ projects: [project] }); + const user = userEvent.setup(); + renderAppShell(); + + await user.click(screen.getByRole("button", { name: "Sidebar new chat" })); + await waitFor(() => { + expect(screen.getByTestId("active-view")).toHaveTextContent("chat"); + }); + mockAcpCreateSession.mockClear(); + await user.keyboard("{Meta>}n{/Meta}"); + const textbox = await screen.findByPlaceholderText("Start a conversation"); + await waitFor(() => { + expect(textbox.closest("[data-placement]")).toHaveAttribute( + "data-placement", + "centered", + ); + }); + + await user.click(screen.getByRole("button", { name: /select project/i })); + await user.click( + screen.getByRole("menuitem", { name: /Project Startup/i }), + ); + expect(await screen.findByText("Project Startup")).toBeInTheDocument(); + await user.type(textbox, "send with all folders"); + await user.keyboard("{Enter}"); + + await waitFor(() => { + expect(mockAcpCreateSession).toHaveBeenCalledWith( + "goose", + "/repo/builderbot", + { + deferProviderSetup: false, + modelId: undefined, + projectId: "project-1", + }, + ); + }); + expect(gitMocks.getGitState).not.toHaveBeenCalled(); + expect(gitMocks.createWorktree).not.toHaveBeenCalled(); + expect(gitMocks.createBranch).not.toHaveBeenCalled(); + + const draftSessionId = useChatSessionStore.getState().sessions[0]?.id; + expect(draftSessionId).toEqual(expect.any(String)); + expect( + useChatSessionStore + .getState() + .sessions[0]?.workspaceAttachments?.map((attachment) => ({ + path: attachment.path, + source: attachment.source, + })), + ).toEqual(expectedAttachments); + expect(useChatStore.getState().queuedMessageBySession).toMatchObject({ + [draftSessionId as string]: [ + { payload: { text: "send with all folders" } }, + ], + }); + + createSession.resolve({ sessionId: "created-session" }); + await waitFor(() => { + expect(useChatStore.getState().queuedMessageBySession).toMatchObject({ + "created-session": [{ payload: { text: "send with all folders" } }], + }); + }); + }); + + it("skips the centered composer handoff delay for reduced-motion users", async () => { + const originalMatchMedia = window.matchMedia; + window.matchMedia = vi.fn().mockImplementation((query: string) => ({ + matches: query === "(prefers-reduced-motion: reduce)", + media: query, + onchange: null, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + addListener: vi.fn(), + removeListener: vi.fn(), + dispatchEvent: vi.fn(), + })); + + try { + const session: ChatSession = { + id: "session-1", + title: "Active chat", + executionTarget: { harnessId: "goose" }, + workingDir: "~/goose artifacts", + createdAt: "2026-06-09T00:00:00.000Z", + updatedAt: "2026-06-09T00:00:00.000Z", + messageCount: 1, + }; + useChatSessionStore.setState({ + sessions: [session], + activeSessionId: null, + }); + renderAppShell(); + + fireEvent.click(screen.getByRole("button", { name: "Open session 1" })); + fireEvent.keyDown(window, { key: "n", metaKey: true }); + const textbox = screen.getByPlaceholderText("Start a conversation"); + fireEvent.change(textbox, { + target: { value: "send without animation" }, + }); + fireEvent.keyDown(textbox, { key: "Enter" }); + await waitFor(() => { + expect(useChatSessionStore.getState().activeSessionId).not.toBe( + "session-1", + ); + }); + + expect(screen.getByTestId("active-view")).toHaveTextContent("chat"); + expect(useChatSessionStore.getState().activeSessionId).not.toBe( + "session-1", + ); + expect( + screen.queryByPlaceholderText("Start a conversation"), + ).not.toBeInTheDocument(); + } finally { + window.matchMedia = originalMatchMedia; + } + }); + + it("keeps centered composer send activation after navigation resets the visual handoff", async () => { + vi.useFakeTimers(); + try { + const session: ChatSession = { + id: "session-1", + title: "Active chat", + executionTarget: { harnessId: "goose" }, + workingDir: "~/goose artifacts", + createdAt: "2026-06-09T00:00:00.000Z", + updatedAt: "2026-06-09T00:00:00.000Z", + messageCount: 1, + }; + useChatSessionStore.setState({ + sessions: [session], + activeSessionId: null, + }); + renderAppShell(); + + fireEvent.click(screen.getByRole("button", { name: "Open session 1" })); + fireEvent.keyDown(window, { key: "n", metaKey: true }); + const textbox = screen.getByPlaceholderText("Start a conversation"); + fireEvent.change(textbox, { + target: { value: "send then navigate quickly" }, + }); + fireEvent.keyDown(textbox, { key: "Enter" }); + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); + + const [draftSessionId] = Object.keys( + useChatStore.getState().queuedMessageBySession, + ); + expect(draftSessionId).toEqual(expect.any(String)); + + fireEvent.click(screen.getByRole("button", { name: "Sidebar skills" })); + expect(screen.getByTestId("active-view")).toHaveTextContent("skills"); + + act(() => { + vi.advanceTimersByTime(220); + }); + + expect(screen.getByTestId("active-view")).toHaveTextContent("chat"); + expect(useChatSessionStore.getState().activeSessionId).not.toBe( + "session-1", + ); + } finally { + vi.useRealTimers(); + } + }); + + it("activates a centered composer handoff while reasoning configuration is pending", async () => { + vi.useFakeTimers(); + const configUpdate = deferred>(); + mockAcpSetSessionConfigOption.mockReturnValue(configUpdate.promise); + window.localStorage.setItem("goose:home-session-id", "home-session"); + + try { + const homeSession: ChatSession = { + id: "home-session", + title: "Home", + executionTarget: { harnessId: "goose" }, + workingDir: "~/goose artifacts", + reasoningEffort: { + configId: "thinking_effort", + currentValue: "high", + options: [ + { id: "low", name: "low" }, + { id: "high", name: "high" }, + ], + }, + createdAt: "2026-06-09T00:00:00.000Z", + updatedAt: "2026-06-09T00:00:00.000Z", + messageCount: 0, + }; + const activeSession: ChatSession = { + ...homeSession, + id: "session-1", + title: "Active chat", + reasoningEffort: undefined, + messageCount: 1, + }; + const reusableDraft: ChatSession = { + ...homeSession, + id: "reusable-draft", + title: "New Chat", + }; + useChatSessionStore.setState({ + sessions: [homeSession, activeSession, reusableDraft], + activeSessionId: null, + }); + useChatStore.setState((state) => ({ + draftsBySession: { + ...state.draftsBySession, + "reusable-draft": "preserve this draft", + }, + })); + renderAppShell(); + + fireEvent.click(screen.getByRole("button", { name: "Open session 1" })); + fireEvent.keyDown(window, { key: "n", metaKey: true }); + const textbox = screen.getByPlaceholderText("Start a conversation"); + fireEvent.change(textbox, { target: { value: "Think before sending" } }); + fireEvent.keyDown(textbox, { key: "Enter" }); + + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); + expect(mockAcpSetSessionConfigOption).toHaveBeenCalled(); + + await act(async () => { + vi.advanceTimersByTime(220); + }); + + expect(screen.getByTestId("active-view")).toHaveTextContent("chat"); + expect(useChatSessionStore.getState().activeSessionId).not.toBe( + "session-1", + ); + } finally { + await act(async () => { + configUpdate.resolve({}); + }); + vi.useRealTimers(); + } + }); + + it("does not queue Cmd+N focus when the global composer remains hidden", async () => { + const user = userEvent.setup(); + const { rerender } = renderAppShell(
Custom shell content
); + + await user.click(screen.getByRole("button", { name: "Sidebar new chat" })); + await waitFor(() => { + expect(mockAcpCreateSession).toHaveBeenCalledTimes(1); + }); + expect( + screen.queryByPlaceholderText("Start a conversation"), + ).not.toBeInTheDocument(); + + await user.keyboard("{Meta>}n{/Meta}"); + + expect(useChatSessionStore.getState().activeSessionId).not.toBeNull(); + rerender(appShellWithTheme()); + await act(async () => { + flushAfterNextPaintCallbacks(); + }); + + expect( + screen.queryByPlaceholderText("Start a conversation"), + ).not.toBeInTheDocument(); + }); + + it("opens a blank chat before ACP session creation finishes", async () => { + const pendingSession = deferred<{ sessionId: string }>(); + mockAcpCreateSession.mockReturnValueOnce(pendingSession.promise); + const user = userEvent.setup(); + renderAppShell(); + + await user.click(screen.getByRole("button", { name: "Sidebar new chat" })); + + await waitFor(() => { + expect(screen.getByTestId("active-view")).toHaveTextContent("chat"); + }); + await waitFor(() => { + expect(mockAcpCreateSession).toHaveBeenCalled(); + }); + + const draftSessionId = useChatSessionStore.getState().activeSessionId; + expect(draftSessionId).toEqual(expect.any(String)); + expect(draftSessionId).not.toBe("created-session"); + expect( + useChatSessionStore.getState().getSession(draftSessionId ?? ""), + ).toMatchObject({ + creationState: "pending", + workingDir: "~/goose artifacts", + }); + const draftWorkingDir = useChatSessionStore + .getState() + .getSession(draftSessionId ?? "")?.workingDir; + + act(() => { + pendingSession.resolve({ sessionId: "created-session" }); + }); + + await waitFor(() => { + expect(useChatSessionStore.getState().activeSessionId).toBe( + "created-session", + ); + }); + expect( + useChatSessionStore.getState().getSession("created-session"), + ).toMatchObject({ + creationState: undefined, + workingDir: draftWorkingDir, + }); + }); + + it("applies the latest pending draft selection before promotion", async () => { + const pendingSession = deferred<{ sessionId: string }>(); + const pendingPrepare = deferred>(); + mockAcpCreateSession.mockReturnValueOnce(pendingSession.promise); + mockAcpPrepareSession.mockReturnValueOnce(pendingPrepare.promise); + const user = userEvent.setup(); + renderAppShell(); + + await user.click(screen.getByRole("button", { name: "Sidebar new chat" })); + await waitFor(() => expect(mockAcpCreateSession).toHaveBeenCalled()); + const draftSessionId = useChatSessionStore.getState().activeSessionId ?? ""; + + act(() => { + const target = { + harnessId: "codex-acp", + modelProviderId: "codex-acp", + modelId: "gpt-5.4-mini", + modelName: "GPT-5.4 mini", + } as const; + beginModelSelectionIntent(draftSessionId, { + requestId: "pending-model", + target, + preferenceAgentId: "codex-acp", + }); + pendingSession.resolve({ sessionId: "created-session" }); + }); + + await waitFor(() => { + expect(mockAcpPrepareSession).toHaveBeenCalledWith( + "created-session", + "codex-acp", + "~/goose artifacts", + expect.objectContaining({ modelId: "gpt-5.4-mini" }), + ); + }); + act(() => pendingPrepare.resolve({})); + + await waitFor(() => { + expect( + useChatSessionStore.getState().getSession("created-session"), + ).toMatchObject({ + executionTarget: { + harnessId: "codex-acp", + modelProviderId: "codex-acp", + modelId: "gpt-5.4-mini", + modelName: "GPT-5.4 mini", + }, + }); + }); + expect( + JSON.parse(localStorage.getItem("goose:preferredModelsByAgent") ?? "{}"), + ).toMatchObject({ + "codex-acp": { + modelId: "gpt-5.4-mini", + modelName: "GPT-5.4 mini", + providerId: "codex-acp", + }, + }); + expect(getModelSelectionIntent("created-session")).toBeUndefined(); + }); + + it("adopts a repaired pending draft selection before promotion", async () => { + const pendingSession = deferred<{ sessionId: string }>(); + mockAcpCreateSession.mockReturnValueOnce(pendingSession.promise); + mockRepairManagedGooseModelSelection.mockImplementation( + async (selection: { providerId?: string; modelId?: string }) => + selection.modelId === "legacy-v1-model" + ? { providerId: "databricks_v2", modelId: "goose-gpt-5-5" } + : selection, + ); + const user = userEvent.setup(); + renderAppShell(); + + await user.click(screen.getByRole("button", { name: "Sidebar new chat" })); + await waitFor(() => expect(mockAcpCreateSession).toHaveBeenCalled()); + const draftSessionId = useChatSessionStore.getState().activeSessionId ?? ""; + + act(() => { + useChatSessionStore + .getState() + .replaceSessionExecutionTarget(draftSessionId, { + harnessId: "goose", + modelProviderId: "databricks_v2", + modelId: "legacy-v1-model", + modelName: "Legacy", + }); + pendingSession.resolve({ sessionId: "created-session" }); + }); + + await waitFor(() => { + expect(mockAcpPrepareSession).toHaveBeenCalledWith( + "created-session", + "databricks_v2", + "~/goose artifacts", + expect.objectContaining({ modelId: "goose-gpt-5-5" }), + ); + }); + await waitFor(() => { + expect( + useChatSessionStore.getState().getSession("created-session"), + ).toMatchObject({ + executionTarget: { + harnessId: "goose", + modelProviderId: "databricks_v2", + modelId: "goose-gpt-5-5", + modelName: "goose-gpt-5-5", + }, + }); + }); + }); + + it("does not restore a draft target after the UI explicitly clears it", async () => { + const pendingSession = deferred<{ sessionId: string }>(); + mockAcpCreateSession.mockReturnValueOnce(pendingSession.promise); + const user = userEvent.setup(); + renderAppShell(); + + await user.click(screen.getByRole("button", { name: "Sidebar new chat" })); + await waitFor(() => expect(mockAcpCreateSession).toHaveBeenCalled()); + const draftSessionId = useChatSessionStore.getState().activeSessionId ?? ""; + + act(() => { + useChatSessionStore + .getState() + .replaceSessionExecutionTarget(draftSessionId, undefined); + pendingSession.resolve({ sessionId: "created-session" }); + }); + + await waitFor(() => { + expect(mockAcpArchiveSession).toHaveBeenCalledWith("created-session"); + expect( + useChatSessionStore.getState().getSession(draftSessionId), + ).toMatchObject({ + creationState: "failed", + executionTarget: undefined, + executionTargetSource: "ui", + }); + }); + expect( + useChatSessionStore.getState().getSession("created-session"), + ).toBeUndefined(); + }); + + it("archives the backend session when post-creation reconciliation fails", async () => { + const pendingSession = deferred<{ sessionId: string }>(); + mockAcpCreateSession.mockReturnValueOnce(pendingSession.promise); + mockAcpPrepareSession.mockRejectedValueOnce(new Error("switch failed")); + const user = userEvent.setup(); + renderAppShell(); + + await user.click(screen.getByRole("button", { name: "Sidebar new chat" })); + await waitFor(() => expect(mockAcpCreateSession).toHaveBeenCalled()); + const draftSessionId = useChatSessionStore.getState().activeSessionId ?? ""; + act(() => { + useChatSessionStore + .getState() + .replaceSessionExecutionTarget(draftSessionId, { + harnessId: "codex-acp", + modelProviderId: "codex-acp", + modelId: "gpt-5.4-mini", + modelName: "GPT-5.4 mini", + }); + pendingSession.resolve({ sessionId: "created-session" }); + }); + + await waitFor(() => { + expect(mockAcpArchiveSession).toHaveBeenCalledWith("created-session"); + expect( + useChatSessionStore.getState().getSession(draftSessionId), + ).toMatchObject({ creationState: "failed" }); + }); + }); + + it("reuses the active blank chat when the sidebar new chat action is repeated", async () => { + const pendingSession = deferred<{ sessionId: string }>(); + mockAcpCreateSession.mockReturnValueOnce(pendingSession.promise); + const user = userEvent.setup(); + renderAppShell(); + + await user.click(screen.getByRole("button", { name: "Sidebar new chat" })); + + await waitFor(() => { + expect(mockAcpCreateSession).toHaveBeenCalledTimes(1); + }); + const draftSessionId = useChatSessionStore.getState().activeSessionId; + + await user.click(screen.getByRole("button", { name: "Sidebar new chat" })); + + expect(useChatSessionStore.getState().activeSessionId).toBe(draftSessionId); + expect(useChatSessionStore.getState().sessions).toHaveLength(1); + expect(mockAcpCreateSession).toHaveBeenCalledTimes(1); + + await act(async () => { + pendingSession.resolve({ sessionId: "created-session" }); + }); + }); + + it("shows ACP error data when draft session creation fails", async () => { + const error = new Error("Internal error") as Error & { data: string }; + error.name = "RequestError"; + error.data = "Failed to create session: provider config is missing"; + mockAcpCreateSession.mockRejectedValueOnce(error); + const user = userEvent.setup(); + renderAppShell(); + + await user.click(screen.getByRole("button", { name: "Sidebar new chat" })); + + await waitFor(() => { + const draftSessionId = useChatSessionStore.getState().activeSessionId; + expect( + useChatSessionStore.getState().getSession(draftSessionId ?? ""), + ).toMatchObject({ + creationState: "failed", + creationError: "Failed to create session: provider config is missing", + }); + }); + + const draftSessionId = useChatSessionStore.getState().activeSessionId ?? ""; + const messages = useChatStore.getState().messagesBySession[draftSessionId]; + expect(messages).toHaveLength(1); + expect(messages[0]).toMatchObject({ + role: "system", + content: [ + { + type: "systemNotification", + notificationType: "error", + text: "Failed to create session: provider config is missing", + }, + ], + }); + }); + + it("goes back and forward through Skills detail subroutes", async () => { + const user = userEvent.setup(); + renderAppShell(); + + await user.click(screen.getByRole("button", { name: "Sidebar skills" })); + await user.click(screen.getByRole("button", { name: "Open skill detail" })); + + expect(screen.getByTestId("active-view")).toHaveTextContent("skills"); + expect(screen.getByTestId("skill-route")).toHaveTextContent("skill-1"); + + await user.click(screen.getByRole("button", { name: "Back" })); + + await waitFor(() => { + expect(screen.getByTestId("skill-route")).toHaveTextContent("list"); + }); + + await user.click(screen.getByRole("button", { name: "Forward" })); + + await waitFor(() => { + expect(screen.getByTestId("skill-route")).toHaveTextContent("skill-1"); + }); + }); + + it("goes back and forward with the navigation history shortcuts", async () => { + const user = userEvent.setup(); + renderAppShell(); + + await user.click(screen.getByRole("button", { name: "Sidebar skills" })); + await user.click(screen.getByRole("button", { name: "Open skill detail" })); + + expect(screen.getByTestId("skill-route")).toHaveTextContent("skill-1"); + + fireEvent.keyDown(window, { key: "[", metaKey: true }); + await waitFor(() => { + expect(screen.getByTestId("skill-route")).toHaveTextContent("list"); + }); + + fireEvent.keyDown(window, { key: "]", metaKey: true }); + await waitFor(() => { + expect(screen.getByTestId("skill-route")).toHaveTextContent("skill-1"); + }); + }); + + it("uses Alt+Left and Alt+Right for navigation history on Windows", async () => { + mockGetPlatform.mockReturnValue("windows"); + const user = userEvent.setup(); + renderAppShell(); + + await user.click(screen.getByRole("button", { name: "Sidebar skills" })); + await user.click(screen.getByRole("button", { name: "Open skill detail" })); + + fireEvent.keyDown(window, { key: "ArrowLeft", altKey: true }); + await waitFor(() => { + expect(screen.getByTestId("skill-route")).toHaveTextContent("list"); + }); + + fireEvent.keyDown(window, { key: "ArrowRight", altKey: true }); + await waitFor(() => { + expect(screen.getByTestId("skill-route")).toHaveTextContent("skill-1"); + }); + }); + + it("does not navigate history while an embedded terminal has focus", async () => { + mockGetPlatform.mockReturnValue("windows"); + const user = userEvent.setup(); + renderAppShell(); + + await user.click(screen.getByRole("button", { name: "Sidebar skills" })); + await user.click(screen.getByRole("button", { name: "Open skill detail" })); + + const terminal = document.createElement("div"); + terminal.className = "xterm"; + document.body.append(terminal); + try { + fireEvent.keyDown(terminal, { key: "ArrowLeft", altKey: true }); + expect(screen.getByTestId("skill-route")).toHaveTextContent("skill-1"); + } finally { + terminal.remove(); + } + }); + + it("allows Cmd+[ to navigate history while an embedded terminal has focus", async () => { + const user = userEvent.setup(); + renderAppShell(); + + await user.click(screen.getByRole("button", { name: "Sidebar skills" })); + await user.click(screen.getByRole("button", { name: "Open skill detail" })); + + const terminal = document.createElement("div"); + terminal.className = "xterm"; + document.body.append(terminal); + try { + fireEvent.keyDown(terminal, { key: "[", metaKey: true }); + await waitFor(() => { + expect(screen.getByTestId("skill-route")).toHaveTextContent("list"); + }); + } finally { + terminal.remove(); + } + }); + + it("goes back and forward through Automations tabs", async () => { + const user = userEvent.setup(); + renderAppShell(); + + await user.click( + screen.getByRole("button", { name: "Sidebar automations" }), + ); + await user.click( + screen.getByRole("button", { name: "Open automation history" }), + ); + + expect(screen.getByTestId("automation-route")).toHaveTextContent( + '"surface":"history"', + ); + + await user.click(screen.getByRole("button", { name: "Back" })); + + await waitFor(() => { + expect(screen.getByTestId("automation-route")).toHaveTextContent( + '"surface":"overview"', + ); + }); + + await user.click(screen.getByRole("button", { name: "Forward" })); + + await waitFor(() => { + expect(screen.getByTestId("automation-route")).toHaveTextContent( + '"surface":"history"', + ); + }); + }); + + it("goes back and forward through Builderbot detail subroutes", async () => { + enableBuilderbotExperiment(); + const user = userEvent.setup(); + renderAppShell(); + + await user.click( + screen.getByRole("button", { name: "Sidebar builderbot" }), + ); + await user.click( + screen.getByRole("button", { name: "Open builderbot task" }), + ); + + expect(screen.getByTestId("active-view")).toHaveTextContent("builderbot"); + expect(screen.getByTestId("builderbot-route")).toHaveTextContent( + '"surface":"task"', + ); + expect(screen.getByTestId("builderbot-route")).toHaveTextContent( + '"taskKey":"TASK-1"', + ); + + await user.click(screen.getByRole("button", { name: "Back" })); + + await waitFor(() => { + expect(screen.getByTestId("builderbot-route")).toHaveTextContent( + '"surface":"overview"', + ); + }); + + await user.click(screen.getByRole("button", { name: "Forward" })); + + await waitFor(() => { + expect(screen.getByTestId("builderbot-route")).toHaveTextContent( + '"surface":"task"', + ); + }); + }); + + it("goes back and forward through Agents detail subroutes", async () => { + const user = userEvent.setup(); + renderAppShell(); + + await user.click(screen.getByRole("button", { name: "Sidebar agents" })); + await user.click(screen.getByRole("button", { name: "Open agent detail" })); + + expect(screen.getByTestId("active-view")).toHaveTextContent("agents"); + expect(screen.getByTestId("agent-route")).toHaveTextContent("persona-1"); + + await user.click(screen.getByRole("button", { name: "Back" })); + + await waitFor(() => { + expect(screen.getByTestId("agent-route")).toHaveTextContent("list"); + }); + + await user.click(screen.getByRole("button", { name: "Forward" })); + + await waitFor(() => { + expect(screen.getByTestId("agent-route")).toHaveTextContent("persona-1"); + }); + }); + + it("starts a new agent builder session without prompting against itself", async () => { + const user = userEvent.setup(); + renderAppShell(); + + await user.click(screen.getByRole("button", { name: "Sidebar agents" })); + await user.click(screen.getByRole("button", { name: "Create agent" })); + + await waitFor(() => { + expect(screen.getByTestId("active-view")).toHaveTextContent("chat"); + }); + expect( + screen.queryByText("Save this agent draft?"), + ).not.toBeInTheDocument(); + await waitForCreatedAgentBuilderTarget(); + }); + + it("shows the new agent builder before the draft target is ready", async () => { + const user = userEvent.setup(); + const draft = deferred<{ + type: "agent"; + path: string; + name: string; + description: string; + content: string; + global: boolean; + writable: boolean; + properties: { draft: boolean; builderSessionId: string }; + }>(); + mockCreatePersonaSource.mockImplementation(() => draft.promise); + renderAppShell(); + + await user.click(screen.getByRole("button", { name: "Sidebar agents" })); + await user.click(screen.getByRole("button", { name: "Create agent" })); + + await waitFor(() => { + expect(mockAcpCreateSession).toHaveBeenCalled(); + }); + await waitFor(() => { + expect(screen.getByTestId("active-view")).toHaveTextContent("chat"); + }); + await waitFor(() => { + expect(useChatSessionStore.getState().activeSessionId).toBe( + "created-session", + ); + }); + expect(useChatSessionStore.getState().getActiveSession()).toMatchObject({ + id: "created-session", + intent: "build-agent", + targetAgentPath: null, + targetAgentSlug: null, + targetAgentDraftState: "preparing", + }); + + draft.resolve({ + type: "agent", + path: "/Users/test/.agents/agents/untitled-agent-created-session.md", + name: "Untitled agent created-sess", + description: "Draft", + content: "Draft in progress.", + global: true, + writable: true, + properties: { draft: true, builderSessionId: "created-session" }, + }); + + await waitFor(() => { + expect(screen.getByTestId("active-view")).toHaveTextContent("chat"); + }); + await waitForCreatedAgentBuilderTarget(); + }); + + it("prompts when navigating away from a dirty new agent draft", async () => { + const user = userEvent.setup(); + renderAppShell(); + + await user.click(screen.getByRole("button", { name: "Sidebar agents" })); + await user.click(screen.getByRole("button", { name: "Create agent" })); + await waitFor(() => { + expect(screen.getByTestId("active-view")).toHaveTextContent("chat"); + }); + await waitForCreatedAgentBuilderTarget(); + + const dirtyDraft = { + type: "agent", + path: "/Users/test/.agents/agents/untitled-agent-created-session.md", + name: "Reviewer", + description: "Draft", + content: "Review code carefully.", + global: true, + writable: true, + properties: { draft: true, builderSessionId: "created-session" }, + }; + mockListPersonaSources.mockResolvedValue([dirtyDraft]); + mockReadAgentSourceFile.mockResolvedValue(dirtyDraft); + + await user.click(screen.getByRole("button", { name: "Sidebar agents" })); + + await waitFor(() => { + expect(screen.getByText("Save this agent draft?")).toBeInTheDocument(); + }); + expect(screen.getByTestId("active-view")).toHaveTextContent("chat"); + }); + + it("does not prompt when navigating away from an untouched new agent draft", async () => { + const user = userEvent.setup(); + renderAppShell(); + + await user.click(screen.getByRole("button", { name: "Sidebar agents" })); + await user.click(screen.getByRole("button", { name: "Create agent" })); + await waitFor(() => { + expect(screen.getByTestId("active-view")).toHaveTextContent("chat"); + }); + + await user.click(screen.getByRole("button", { name: "Sidebar skills" })); + + await waitFor(() => { + expect(screen.getByTestId("active-view")).toHaveTextContent("skills"); + }); + expect( + screen.queryByText("Save this agent draft?"), + ).not.toBeInTheDocument(); + }); + + it("returns to agent builder mode after going back then forward", async () => { + const user = userEvent.setup(); + renderAppShell(); + + await user.click(screen.getByRole("button", { name: "Sidebar agents" })); + await user.click(screen.getByRole("button", { name: "Create agent" })); + await waitFor(() => { + expect(screen.getByTestId("active-view")).toHaveTextContent("chat"); + }); + await waitFor(() => { + expect(useChatSessionStore.getState().getActiveSession()).toMatchObject({ + id: "created-session", + intent: "build-agent", + }); + }); + + await user.click(screen.getByRole("button", { name: "Back" })); + await waitFor(() => { + expect(screen.getByTestId("active-view")).toHaveTextContent("agents"); + }); + + await user.click(screen.getByRole("button", { name: "Forward" })); + await waitFor(() => { + expect(screen.getByTestId("active-view")).toHaveTextContent("chat"); + }); + await waitFor(() => { + expect(useChatSessionStore.getState().getActiveSession()).toMatchObject({ + id: "created-session", + intent: "build-agent", + targetAgentPath: + "/Users/test/.agents/agents/untitled-agent-created-session.md", + }); + }); + }); + + it("prompts when navigating away after typing in the agent builder chat", async () => { + const user = userEvent.setup(); + renderAppShell(); + + await user.click(screen.getByRole("button", { name: "Sidebar agents" })); + await user.click(screen.getByRole("button", { name: "Create agent" })); + await waitFor(() => { + expect(screen.getByTestId("active-view")).toHaveTextContent("chat"); + }); + await waitForCreatedAgentBuilderTarget(); + + useChatStore.getState().setDraft("created-session", "make me a reviewer"); + + await user.click(screen.getByRole("button", { name: "Sidebar skills" })); + + await waitFor(() => { + expect(screen.getByText("Save this agent draft?")).toBeInTheDocument(); + }); + expect(screen.getByTestId("active-view")).toHaveTextContent("chat"); + }); + + it("returns from provider setup settings to the dirty agent draft without prompting", async () => { + const user = userEvent.setup(); + renderAppShell(); + + await user.click(screen.getByRole("button", { name: "Sidebar agents" })); + await user.click(screen.getByRole("button", { name: "Create agent" })); + await waitFor(() => { + expect(screen.getByTestId("active-view")).toHaveTextContent("chat"); + }); + await waitForCreatedAgentBuilderTarget(); + + const dirtyDraft = { + type: "agent", + path: "/Users/test/.agents/agents/untitled-agent-created-session.md", + name: "Reviewer", + description: "Draft", + content: "Review code carefully.", + global: true, + writable: true, + properties: { draft: true, builderSessionId: "created-session" }, + }; + mockListPersonaSources.mockResolvedValue([dirtyDraft]); + mockReadAgentSourceFile.mockResolvedValue(dirtyDraft); + + act(() => { + window.dispatchEvent( + new CustomEvent(OPEN_SETTINGS_EVENT, { + detail: { + section: "providers", + returnTarget: { + type: "agent-builder-provider-setup", + sessionId: "created-session", + providerId: "claude-acp", + }, + }, + }), + ); + }); + + await waitFor(() => { + expect(screen.getByTestId("active-view")).toHaveTextContent("settings"); + }); + + await user.click(screen.getByRole("button", { name: "Back" })); + + await waitFor(() => { + expect(screen.getByTestId("active-view")).toHaveTextContent("chat"); + }); + expect( + screen.queryByText("Save this agent draft?"), + ).not.toBeInTheDocument(); + }); + + it("discarding a dirty agent draft continues the pending navigation", async () => { + const user = userEvent.setup(); + renderAppShell(); + + await user.click(screen.getByRole("button", { name: "Sidebar agents" })); + await user.click(screen.getByRole("button", { name: "Create agent" })); + await waitFor(() => { + expect(screen.getByTestId("active-view")).toHaveTextContent("chat"); + }); + await waitForCreatedAgentBuilderTarget(); + + const dirtyDraft = { + type: "agent", + path: "/Users/test/.agents/agents/untitled-agent-created-session.md", + name: "Reviewer", + description: "Draft", + content: "Review code carefully.", + global: true, + writable: true, + properties: { draft: true, builderSessionId: "created-session" }, + }; + mockListPersonaSources.mockResolvedValue([dirtyDraft]); + mockReadAgentSourceFile.mockResolvedValue(dirtyDraft); + + await user.click(screen.getByRole("button", { name: "Sidebar skills" })); + await user.click(await screen.findByRole("button", { name: "Discard" })); + + await waitFor(() => { + expect(screen.getByTestId("active-view")).toHaveTextContent("skills"); + }); + expect(mockDeletePersonaSource).toHaveBeenCalledWith(dirtyDraft.path); + }); + + it("keeping a dirty agent draft continues the pending navigation without deleting it", async () => { + const user = userEvent.setup(); + renderAppShell(); + + await user.click(screen.getByRole("button", { name: "Sidebar agents" })); + await user.click(screen.getByRole("button", { name: "Create agent" })); + await waitFor(() => { + expect(screen.getByTestId("active-view")).toHaveTextContent("chat"); + }); + await waitForCreatedAgentBuilderTarget(); + + const dirtyDraft = { + type: "agent", + path: "/Users/test/.agents/agents/untitled-agent-created-session.md", + name: "Reviewer", + description: "Draft", + content: "Review code carefully.", + global: true, + writable: true, + properties: { draft: true, builderSessionId: "created-session" }, + }; + mockListPersonaSources.mockResolvedValue([dirtyDraft]); + mockReadAgentSourceFile.mockResolvedValue(dirtyDraft); + mockDeletePersonaSource.mockClear(); + + await user.click(screen.getByRole("button", { name: "Sidebar skills" })); + await user.click(await screen.findByRole("button", { name: "Save draft" })); + + await waitFor(() => { + expect(screen.getByTestId("active-view")).toHaveTextContent("skills"); + }); + expect(mockDeletePersonaSource).not.toHaveBeenCalled(); + }); + + it("keeps a saved agent draft visible in recent chats", async () => { + const user = userEvent.setup(); + renderAppShell(); + + await user.click(screen.getByRole("button", { name: "Sidebar agents" })); + await user.click(screen.getByRole("button", { name: "Create agent" })); + await waitFor(() => { + expect(screen.getByTestId("active-view")).toHaveTextContent("chat"); + }); + await waitForCreatedAgentBuilderTarget(); + + const sessionBeforeSave = useChatSessionStore + .getState() + .getSession("created-session"); + expect(sessionBeforeSave).toMatchObject({ + messageCount: 0, + intent: "build-agent", + }); + + const dirtyDraft = { + type: "agent", + path: "/Users/test/.agents/agents/untitled-agent-created-session.md", + name: "Reviewer", + description: "Draft", + content: "Review code carefully.", + global: true, + writable: true, + properties: { draft: true, builderSessionId: "created-session" }, + }; + mockListPersonaSources.mockResolvedValue([dirtyDraft]); + mockReadAgentSourceFile.mockResolvedValue(dirtyDraft); + + await user.click(screen.getByRole("button", { name: "Sidebar skills" })); + await user.click(await screen.findByRole("button", { name: "Save draft" })); + + await waitFor(() => { + expect(screen.getByTestId("active-view")).toHaveTextContent("skills"); + }); + + const savedSession = useChatSessionStore + .getState() + .getSession("created-session"); + expect(savedSession).toMatchObject({ + messageCount: 0, + intent: "build-agent", + targetAgentPath: dirtyDraft.path, + targetAgentDraftSaved: true, + }); + expect(savedSession?.updatedAt).toEqual(expect.any(String)); + }); + + it("opens Agent Builder without also opening Context", async () => { + const user = userEvent.setup(); + useChatSessionStore.setState({ + sessions: [ + { + id: "session-1", + title: "New agent", + executionTarget: { harnessId: "goose" }, + workingDir: "~/goose artifacts", + createdAt: "2026-06-09T00:00:00.000Z", + updatedAt: "2026-06-09T00:00:00.000Z", + messageCount: 0, + intent: "build-agent", + targetAgentPath: "/Users/test/.agents/agents/draft-session.md", + targetAgentSlug: "draft-session", + targetAgentDraftState: null, + targetAgentDraftSaved: true, + }, + ], + activeSessionId: null, + isRightRailOpen: false, + }); + renderAppShell(); + + await user.click(screen.getByRole("button", { name: "Open session 1" })); + + await waitFor(() => { + expect(screen.getByTestId("active-view")).toHaveTextContent("chat"); + }); + expect(useChatSessionStore.getState().getActiveSession()).toMatchObject({ + id: "session-1", + intent: "build-agent", + }); + expect(useChatSessionStore.getState().isRightRailOpen).toBe(false); + }); + + it("guards the add-widget starter task against unsaved automation changes", async () => { + const user = userEvent.setup(); + renderAppShell(); + + await user.click( + screen.getByRole("button", { name: "Sidebar automations" }), + ); + await user.click( + screen.getByRole("button", { name: "Open automation builder" }), + ); + await user.click( + screen.getByRole("button", { name: "Mark automation edits unsaved" }), + ); + await user.click( + screen.getByRole("button", { name: "Select add widget starter task" }), + ); + + expect( + await screen.findByText("Unsaved automation changes"), + ).toBeInTheDocument(); + expect(screen.getByTestId("active-view")).toHaveTextContent("automations"); + expect(hasStarterWidgetPickerRequest()).toBe(false); + + await user.click(screen.getByRole("button", { name: "Keep editing" })); + expect(screen.getByTestId("active-view")).toHaveTextContent("automations"); + expect(hasStarterWidgetPickerRequest()).toBe(false); + }); + + it("prompts before leaving unsaved automation builder changes", async () => { + const user = userEvent.setup(); + renderAppShell(); + + await user.click( + screen.getByRole("button", { name: "Sidebar automations" }), + ); + await user.click( + screen.getByRole("button", { name: "Open automation builder" }), + ); + await user.click( + screen.getByRole("button", { name: "Mark automation edits unsaved" }), + ); + + await user.click(screen.getByRole("button", { name: "Sidebar skills" })); + + expect( + await screen.findByText("Unsaved automation changes"), + ).toBeInTheDocument(); + expect(screen.getByTestId("active-view")).toHaveTextContent("automations"); + + await user.click(screen.getByRole("button", { name: "Keep editing" })); + + expect( + screen.queryByText("Unsaved automation changes"), + ).not.toBeInTheDocument(); + expect(screen.getByTestId("active-view")).toHaveTextContent("automations"); + }); + + it("discarding unsaved automation builder changes continues navigation", async () => { + const user = userEvent.setup(); + renderAppShell(); + + await user.click( + screen.getByRole("button", { name: "Sidebar automations" }), + ); + await user.click( + screen.getByRole("button", { name: "Open automation builder" }), + ); + await user.click( + screen.getByRole("button", { name: "Mark automation edits unsaved" }), + ); + + await user.click(screen.getByRole("button", { name: "Sidebar skills" })); + await user.click(await screen.findByRole("button", { name: "Discard" })); + + await waitFor(() => { + expect(screen.getByTestId("active-view")).toHaveTextContent("skills"); + }); + }); + + it("saving unsaved automation builder changes continues navigation", async () => { + const user = userEvent.setup(); + renderAppShell(); + + await user.click( + screen.getByRole("button", { name: "Sidebar automations" }), + ); + await user.click( + screen.getByRole("button", { name: "Open automation builder" }), + ); + await user.click( + screen.getByRole("button", { name: "Mark automation edits unsaved" }), + ); + + await user.click(screen.getByRole("button", { name: "Sidebar skills" })); + await user.click( + await screen.findByRole("button", { name: "Save changes" }), + ); + + expect(mockAutomationBuilderSave).toHaveBeenCalledTimes(1); + await waitFor(() => { + expect(screen.getByTestId("active-view")).toHaveTextContent("skills"); + }); + }); + + it("opens search over unsaved automation builder changes without navigating", async () => { + const user = userEvent.setup(); + renderAppShell(); + + await user.click( + screen.getByRole("button", { name: "Sidebar automations" }), + ); + await user.click( + screen.getByRole("button", { name: "Open automation builder" }), + ); + await user.click( + screen.getByRole("button", { name: "Mark automation edits unsaved" }), + ); + + await user.keyboard("{Meta>}k{/Meta}"); + + expect( + await screen.findByRole("textbox", { name: "Universal search" }), + ).toBeInTheDocument(); + expect(screen.queryByText("Unsaved automation changes")).toBeNull(); + expect(screen.getByTestId("active-view")).toHaveTextContent("automations"); + }); + + it("guards settings results selected over unsaved automation changes", async () => { + const user = userEvent.setup(); + renderAppShell(); + + await user.click( + screen.getByRole("button", { name: "Sidebar automations" }), + ); + await user.click( + screen.getByRole("button", { name: "Open automation builder" }), + ); + await user.click( + screen.getByRole("button", { name: "Mark automation edits unsaved" }), + ); + + await user.keyboard("{Meta>}k{/Meta}"); + const search = await screen.findByRole("textbox", { + name: "Universal search", + }); + await user.type(search, "animated avatars"); + await user.click( + await screen.findByRole("button", { + name: "Open Animated avatars settings", + }), + ); + + expect( + await screen.findByText("Unsaved automation changes"), + ).toBeInTheDocument(); + expect(screen.getByTestId("active-view")).toHaveTextContent("automations"); + + await user.click(screen.getByRole("button", { name: "Keep editing" })); + + expect(screen.getByTestId("active-view")).toHaveTextContent("automations"); + expect( + screen.getByRole("textbox", { name: "Universal search" }), + ).toBeInTheDocument(); + }); + + it("keeps search open when guarded agent navigation is cancelled", async () => { + const user = userEvent.setup(); + useAgentStore.setState({ + personas: [ + { + id: "agent-reviewer", + displayName: "Reviewer", + systemPrompt: "Review code changes", + isBuiltin: true, + writable: false, + }, + ], + }); + renderAppShell(); + + await user.click( + screen.getByRole("button", { name: "Sidebar automations" }), + ); + await user.click( + screen.getByRole("button", { name: "Open automation builder" }), + ); + await user.click( + screen.getByRole("button", { name: "Mark automation edits unsaved" }), + ); + + await user.keyboard("{Meta>}k{/Meta}"); + const search = await screen.findByRole("textbox", { + name: "Universal search", + }); + await user.type(search, "reviewer"); + await user.click( + await screen.findByRole("button", { name: "Start chat with Reviewer" }), + ); + + expect( + await screen.findByText("Unsaved automation changes"), + ).toBeInTheDocument(); + await user.click(screen.getByRole("button", { name: "Keep editing" })); + + expect(search).toBeInTheDocument(); + expect(search).toHaveValue("reviewer"); + expect(screen.getByTestId("active-view")).toHaveTextContent("automations"); + }); + + it("prompts before opening the centered composer from unsaved automation builder changes", async () => { + const user = userEvent.setup(); + renderAppShell(); + + await user.click( + screen.getByRole("button", { name: "Sidebar automations" }), + ); + await user.click( + screen.getByRole("button", { name: "Open automation builder" }), + ); + await user.click( + screen.getByRole("button", { name: "Mark automation edits unsaved" }), + ); + + await user.keyboard("{Meta>}n{/Meta}"); + + expect( + await screen.findByText("Unsaved automation changes"), + ).toBeInTheDocument(); + expect(screen.getByTestId("active-view")).toHaveTextContent("automations"); + + await user.click(screen.getByRole("button", { name: "Discard" })); + + await waitFor(() => { + expect(screen.getByTestId("active-view")).toHaveTextContent("home"); + }); + const textbox = await screen.findByPlaceholderText("Start a conversation"); + await waitFor(() => { + expect(textbox).toHaveFocus(); + }); + expect(textbox.closest("[data-placement]")).toHaveAttribute( + "data-placement", + "centered", + ); + }); + + it("resets a centered composer when entering a route that hides it", async () => { + const user = userEvent.setup(); + renderAppShell(); + + await user.click( + screen.getByRole("button", { name: "Sidebar automations" }), + ); + await user.keyboard("{Meta>}n{/Meta}"); + + const centeredTextbox = await screen.findByPlaceholderText( + "Start a conversation", + ); + expect(centeredTextbox.closest("[data-placement]")).toHaveAttribute( + "data-placement", + "centered", + ); + + await user.click( + screen.getByRole("button", { name: "Open automation builder" }), + ); + expect( + screen.queryByPlaceholderText("Start a conversation"), + ).not.toBeInTheDocument(); + + await user.click( + screen.getByRole("button", { name: "Open automation history" }), + ); + await act(async () => { + flushAfterNextPaintCallbacks(); + }); + + const dockedTextbox = await screen.findByPlaceholderText( + "Start a conversation", + ); + expect(dockedTextbox.closest("[data-placement]")).toHaveAttribute( + "data-placement", + "docked", + ); + }); + + it("keeps Settings section navigation in the global stack", async () => { + const user = userEvent.setup(); + renderAppShell(); + + await user.click(screen.getByRole("button", { name: "Sidebar settings" })); + await user.click(screen.getByRole("button", { name: "Sidebar providers" })); + + expect(screen.getByTestId("active-view")).toHaveTextContent("settings"); + expect(screen.getByTestId("settings-section")).toHaveTextContent( + "providers", + ); + + await user.click(screen.getByRole("button", { name: "Back" })); + + await waitFor(() => { + expect(screen.getByTestId("settings-section")).toHaveTextContent( + "appearance", + ); + }); + }); + + it("redirects a legacy deep-linked Doctor settings section to System", async () => { + // rev 4: Doctor is a dialog opened from a row inside System, not a + // settings section -- `?section=doctor` is a legacy URL now, resolved + // to System (where that row lives) via LEGACY_SECTION_REDIRECTS at + // initial parse time. That resolution only affects which section + // renders, not the URL string itself (nothing rewrites the URL unless a + // capability-gating effect fires, which System has no reason to), so + // the address bar keeps showing the legacy `?section=doctor` param. + window.history.replaceState(null, "", "/settings?section=doctor"); + + renderAppShell(); + + await waitFor(() => { + expect(screen.getByTestId("active-view")).toHaveTextContent("settings"); + expect(screen.getByTestId("settings-section")).toHaveTextContent( + "system", + ); + }); + expect(window.location.pathname).toBe("/settings"); + }); + + it("closes the design system takeover back to the previous view", async () => { + const user = userEvent.setup(); + mockDesignSystemExplorerEnabled.mockReturnValue(true); + renderAppShell(); + + await user.click(screen.getByRole("button", { name: "Sidebar agents" })); + expect(screen.getByTestId("active-view")).toHaveTextContent("agents"); + + await user.click( + screen.getByRole("button", { name: "Sidebar design system" }), + ); + expect(screen.getByTestId("active-view")).toHaveTextContent( + "design-system", + ); + expect(window.location.pathname).toBe("/design-system"); + + await user.click( + screen.getByRole("button", { name: "Close design system" }), + ); + await waitFor(() => { + expect(screen.getByTestId("active-view")).toHaveTextContent("agents"); + }); + expect(window.location.pathname).not.toBe("/design-system"); + }); + + it("closes the design system takeover back to settings with its section URL", async () => { + const user = userEvent.setup(); + mockDesignSystemExplorerEnabled.mockReturnValue(true); + renderAppShell(); + + await user.click(screen.getByRole("button", { name: "Sidebar settings" })); + expect(screen.getByTestId("active-view")).toHaveTextContent("settings"); + await user.click(screen.getByRole("button", { name: "Sidebar providers" })); + expect(screen.getByTestId("settings-section")).toHaveTextContent( + "providers", + ); + + await user.click( + screen.getByRole("button", { name: "Sidebar design system" }), + ); + expect(screen.getByTestId("active-view")).toHaveTextContent( + "design-system", + ); + + await user.click( + screen.getByRole("button", { name: "Close design system" }), + ); + await waitFor(() => { + expect(screen.getByTestId("active-view")).toHaveTextContent("settings"); + }); + expect(window.location.pathname).toBe("/settings"); + expect(new URLSearchParams(window.location.search).get("section")).toBe( + "providers", + ); + }); + + it("focuses a detached chat selected from search", async () => { + mockSessionWindowSupport.supported = true; + useSessionWindowStore + .getState() + .setSnapshot([ + { sessionId: "session-1", windowLabel: "session-session-1" }, + ]); + useChatSessionStore.setState({ + sessions: [ + { + id: "session-1", + title: "Detached planning", + createdAt: "2026-07-28T11:00:00.000Z", + updatedAt: "2026-07-28T12:00:00.000Z", + messageCount: 2, + }, + ], + }); + const user = userEvent.setup(); + renderAppShell(); + await act(async () => { + useSessionWindowStore + .getState() + .setSnapshot([ + { sessionId: "session-1", windowLabel: "session-session-1" }, + ]); + }); + + await user.click(screen.getByRole("button", { name: "Search" })); + const search = screen.getByRole("textbox", { name: "Universal search" }); + await user.type(search, "detached planning"); + await user.click( + await screen.findByRole("button", { + name: "Open chat Detached planning", + }), + ); + + await waitFor(() => { + expect(mockFocusSessionWindow).toHaveBeenCalledWith("session-1"); + }); + expect(screen.getByTestId("active-view")).toHaveTextContent("home"); + expect(useChatSessionStore.getState().activeSessionId).toBeNull(); + }); + + it("opens extension search results in Settings Connections", async () => { + mockListExtensions.mockResolvedValue([ + { + config_key: "glean-stdio", + type: "stdio", + name: "Glean", + description: "Search internal documents", + cmd: "glean", + args: [], + enabled: true, + }, + ]); + const user = userEvent.setup(); + renderAppShell(); + + await user.click(screen.getByRole("button", { name: "Search" })); + const search = screen.getByRole("textbox", { name: "Universal search" }); + await user.type(search, "glean"); + await user.click( + await screen.findByRole("button", { name: "Open extension Glean" }), + ); + + await waitFor(() => { + expect(screen.getByTestId("active-view")).toHaveTextContent("settings"); + }); + expect(screen.getByTestId("settings-section")).toHaveTextContent( + "connections", + ); + expect(window.location.pathname).toBe("/settings"); + expect(new URLSearchParams(window.location.search).get("section")).toBe( + "connections", + ); + }); + + it("opens search from the top bar and returns to the previous view", async () => { + const user = userEvent.setup(); + renderAppShell(); + + await user.click(screen.getByRole("button", { name: "Sidebar agents" })); + expect(screen.getByTestId("active-view")).toHaveTextContent("agents"); + + await user.click(screen.getByRole("button", { name: "Search" })); + expect( + screen.getByRole("textbox", { name: "Universal search" }), + ).toBeInTheDocument(); + expect(screen.getByTestId("active-view")).toHaveTextContent("agents"); + + await user.keyboard("{Escape}"); + await waitFor(() => { + expect( + screen.queryByRole("textbox", { name: "Universal search" }), + ).not.toBeInTheDocument(); + }); + expect(screen.getByTestId("active-view")).toHaveTextContent("agents"); + }); + + it("clears a focused search query before Escape closes the dialog", async () => { + const user = userEvent.setup(); + renderAppShell(); + + await user.click(screen.getByRole("button", { name: "Search" })); + const search = screen.getByRole("textbox", { name: "Universal search" }); + await user.type(search, "reviewer"); + await user.keyboard("{Escape}"); + + expect(search).toHaveValue(""); + expect(search).toBeInTheDocument(); + }); + + it("closes search with Escape when focus is on the dialog", async () => { + const user = userEvent.setup(); + renderAppShell(); + + await user.click(screen.getByRole("button", { name: "Search" })); + const dialog = await screen.findByRole("dialog"); + fireEvent.focus(dialog); + fireEvent.keyDown(dialog, { key: "Escape" }); + + await waitFor(() => { + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); + }); + }); + + it("localizes the Skills title in the top bar", async () => { + await i18n.changeLanguage("es"); + const user = userEvent.setup(); + renderAppShell(); + + await user.click(screen.getByRole("button", { name: "Sidebar skills" })); + + expect(screen.getByText("Habilidades")).toBeInTheDocument(); + }); + + it("shows the Agents and Skills titles but keeps detail breadcrumbs out of the top bar", async () => { + const user = userEvent.setup(); + renderAppShell(); + + await user.click(screen.getByRole("button", { name: "Sidebar agents" })); + expect(screen.getByTestId("active-view")).toHaveTextContent("agents"); + expect(screen.getByText("Agents")).toBeInTheDocument(); + + await user.click(screen.getByRole("button", { name: "Sidebar skills" })); + expect(screen.getByText("Skills")).toBeInTheDocument(); + await user.click(screen.getByRole("button", { name: "Open skill detail" })); + expect(screen.getByText("Skills")).toBeInTheDocument(); + expect(screen.queryByRole("link", { name: "Skills" })).toBeNull(); + expect(screen.queryByRole("link", { name: "Code Review" })).toBeNull(); + + await user.click( + screen.getByRole("button", { name: "Sidebar automations" }), + ); + await user.click( + screen.getByRole("button", { name: "Open automation history" }), + ); + expect(screen.queryByRole("link", { name: "Automations" })).toBeNull(); + expect(screen.queryByRole("link", { name: "History" })).toBeNull(); + + enableBuilderbotExperiment(); + await user.click( + screen.getByRole("button", { name: "Sidebar builderbot" }), + ); + await user.click( + screen.getByRole("button", { name: "Open builderbot task" }), + ); + expect(screen.queryByRole("link", { name: "Builderbot" })).toBeNull(); + expect(screen.queryByRole("link", { name: "TASK-1" })).toBeNull(); + + await user.click( + screen.getByRole("button", { name: "Open builderbot automation" }), + ); + expect(screen.queryByRole("link", { name: "Daily docs" })).toBeNull(); + }); + + it("shows only the session title for a project chat", async () => { + const user = userEvent.setup(); + const { container } = renderAppShell(); + + await user.click(screen.getByRole("button", { name: "Sidebar new chat" })); + await waitFor(() => { + expect(screen.getByTestId("active-view")).toHaveTextContent("chat"); + }); + + const project: ProjectInfo = { + id: "proj-1", + path: "/tmp/sample-project", + name: "Sample Project", + description: "", + prompt: "", + icon: "folder", + color: "blue", + projectWorkspaces: [], + workingDirs: [], + useWorktrees: false, + order: 0, + archivedAt: null, + }; + const now = new Date().toISOString(); + const session: ChatSession = { + id: "created-session", + title: "MCPs vs Extensions", + projectId: "proj-1", + createdAt: now, + updatedAt: now, + messageCount: 0, + }; + + act(() => { + useProjectStore.setState({ projects: [project] }); + useChatSessionStore.setState({ + sessions: [session], + activeSessionId: "created-session", + hasHydratedSessions: true, + }); + }); + + await waitFor(() => { + expect(screen.getByText("MCPs vs Extensions")).toBeInTheDocument(); + }); + expect(screen.queryByText("Sample Project")).toBeNull(); + expect(screen.queryByText("Chat")).toBeNull(); + expect( + (container.firstElementChild as HTMLElement).style.getPropertyValue( + "--project-tint", + ), + ).toBe("var(--color-pill-blue)"); + }); + + it("repairs an explicit persona model before creating its session", async () => { + setResolvingPersona("legacy-v1-model"); + useAgentStore.setState({ + selectedProvider: "codex-acp", + providers: [ + { id: "goose", label: "Goose" }, + { id: "codex-acp", label: "Codex" }, + { id: "databricks_v2", label: "Databricks AI Gateway" }, + ], + }); + mockCheckAllProviderStatus.mockResolvedValue([ + { providerId: "databricks_v2", isConfigured: true }, + ]); + mockRepairManagedGooseModelSelection.mockImplementation( + async (selection: { providerId?: string; modelId?: string }) => + selection.modelId === "legacy-v1-model" + ? { providerId: "databricks_v2", modelId: "goose-gpt-5-5" } + : selection, + ); + const user = userEvent.setup(); + renderAppShell(); + + await user.click( + screen.getByRole("button", { name: "Start chat with resolving agent" }), + ); + + await waitFor(() => { + expect(mockAcpCreateSession).toHaveBeenCalledWith( + "databricks_v2", + "~/goose artifacts", + expect.objectContaining({ modelId: "goose-gpt-5-5" }), + ); + }); + expect(mockAcpCreateSession).not.toHaveBeenCalledWith( + "databricks_v2", + "~/goose artifacts", + expect.objectContaining({ modelId: "legacy-v1-model" }), + ); + expect( + useChatSessionStore.getState().getSession("created-session"), + ).toMatchObject({ + executionTarget: { + harnessId: "goose", + modelProviderId: "databricks_v2", + modelId: "goose-gpt-5-5", + }, + }); + }); + + it("forwards a persona's provider and model when the provider resolves", async () => { + setResolvingPersona("goose-model"); + useAgentStore.setState({ + selectedProvider: "codex-acp", + providers: [ + { id: "goose", label: "Goose" }, + { id: "codex-acp", label: "Codex" }, + { id: "databricks_v2", label: "Databricks AI Gateway" }, + ], + }); + mockCheckAllProviderStatus.mockResolvedValue([ + { providerId: "databricks_v2", isConfigured: true }, + ]); + const user = userEvent.setup(); + renderAppShell(); + + await user.click( + screen.getByRole("button", { name: "Start chat with resolving agent" }), + ); + + await waitFor(() => { + expect(mockAcpCreateSession).toHaveBeenCalledWith( + "databricks_v2", + "~/goose artifacts", + { + deferProviderSetup: false, + modelId: "goose-model", + projectId: undefined, + }, + ); + }); + await waitFor(() => { + expect( + useChatSessionStore.getState().getSession("created-session"), + ).toMatchObject({ + executionTarget: { + harnessId: "goose", + modelProviderId: "databricks_v2", + modelId: "goose-model", + }, + }); + }); + }); + + it("qualifies a Goose persona model from provider inventory", async () => { + setResolvingPersona("custom-model", "goose"); + seedProviderModels("databricks_v2", [ + { id: "custom-model", name: "Custom model" }, + ]); + mockCheckAllProviderStatus.mockResolvedValue([ + { providerId: "databricks_v2", isConfigured: true }, + ]); + const user = userEvent.setup(); + renderAppShell(); + + await user.click( + screen.getByRole("button", { name: "Start chat with resolving agent" }), + ); + + await waitFor(() => { + expect(mockAcpCreateSession).toHaveBeenCalledWith( + "databricks_v2", + "~/goose artifacts", + expect.objectContaining({ modelId: "custom-model" }), + ); + }); + await waitFor(() => { + expect( + useChatSessionStore.getState().getSession("created-session"), + ).toMatchObject({ + executionTarget: { + harnessId: "goose", + modelProviderId: "databricks_v2", + modelId: "custom-model", + }, + }); + }); + }); + + it("uses the normal new-chat target when a persona has no plausible target", async () => { + useDefaultProviderReadinessStore.setState({ + readiness: { + status: "ready", + providerId: "databricks_v2", + modelId: "goose-default", + }, + }); + useAgentStore.setState({ + selectedProvider: "goose", + providers: [{ id: "goose", label: "Goose" }], + personas: [ + { + id: "persona-unresolved", + displayName: "Reviewer", + systemPrompt: "Review code.", + provider: "totally-unknown-provider", + model: "unresolved-model", + isBuiltin: false, + writable: true, + }, + ], + }); + const user = userEvent.setup(); + renderAppShell(); + + await user.click( + screen.getByRole("button", { name: "Start chat with unresolved agent" }), + ); + + await waitFor(() => { + expect(mockAcpCreateSession).toHaveBeenCalledWith( + "databricks_v2", + "~/goose artifacts", + expect.objectContaining({ modelId: "goose-default" }), + ); + }); + expect( + useChatSessionStore.getState().getSession("created-session"), + ).toMatchObject({ personaId: "persona-unresolved" }); + }); + + it("tags a Home agent starter in the composer instead of opening a blank chat", async () => { + setResolvingPersona("goose-model"); + const user = userEvent.setup(); + renderAppShell(); + + await user.click( + screen.getByRole("button", { name: "Tag home composer agent" }), + ); + + await waitFor(() => { + expect(screen.getByTestId("active-view")).toHaveTextContent("home"); + expect(screen.getByText("Reviewer")).toBeInTheDocument(); + }); + expect(mockAcpCreateSession).not.toHaveBeenCalled(); + }); + + it("tags a Home skill starter in the composer instead of opening a blank chat", async () => { + const user = userEvent.setup(); + renderAppShell(); + + await user.click( + screen.getByRole("button", { name: "Tag home composer skill" }), + ); + + await waitFor(() => { + expect(screen.getByTestId("active-view")).toHaveTextContent("home"); + expect(screen.getByText("code-review")).toBeInTheDocument(); + }); + expect(mockAcpCreateSession).not.toHaveBeenCalled(); + }); + + it("tags a Home project starter in the composer instead of opening a blank chat", async () => { + useProjectStore.setState({ + projects: [ + { + id: "project-1", + path: "/tmp/project.yaml", + name: "Project One", + description: "", + prompt: "", + icon: "", + color: "", + workingDirs: ["/workspace/project"], + projectWorkspaces: [], + useWorktrees: false, + order: 0, + archivedAt: null, + }, + ], + loading: false, + activeProjectId: null, + }); + const user = userEvent.setup(); + renderAppShell(); + + await user.click( + screen.getByRole("button", { name: "Tag home composer project" }), + ); + + await waitFor(() => { + expect(screen.getByTestId("active-view")).toHaveTextContent("home"); + expect(screen.getByText("Project One")).toBeInTheDocument(); + }); + expect(mockAcpCreateSession).not.toHaveBeenCalled(); + }); + + it("expands the Home composer into a full chat with the current draft context", async () => { + setResolvingPersona("goose-model"); + mockCheckAllProviderStatus.mockResolvedValue([ + { providerId: "databricks_v2", isConfigured: true }, + ]); + useProjectStore.setState({ + projects: [ + { + id: "project-1", + path: "/tmp/project.yaml", + name: "Project One", + description: "", + prompt: "", + icon: "", + color: "", + workingDirs: ["/workspace/project"], + projectWorkspaces: [], + useWorktrees: false, + order: 0, + archivedAt: null, + }, + ], + loading: false, + activeProjectId: null, + }); + const user = userEvent.setup(); + renderAppShell(); + + await user.click( + screen.getByRole("button", { name: "Tag home composer agent" }), + ); + await user.click( + screen.getByRole("button", { name: "Tag home composer project" }), + ); + await user.click( + screen.getByRole("button", { name: "Tag home composer skill" }), + ); + + const textbox = screen.getByPlaceholderText("Start a conversation"); + await user.type(textbox, "expand this"); + await user.click( + screen.getByRole("button", { name: "Expand to full chat" }), + ); + + await waitFor(() => { + expect(screen.getByTestId("active-view")).toHaveTextContent("chat"); + expect(useChatSessionStore.getState().getActiveSession()).toMatchObject({ + id: "created-session", + projectId: "project-1", + personaId: "persona-resolves", + executionTarget: { + harnessId: "goose", + modelProviderId: "databricks_v2", + modelId: "goose-model", + modelName: "goose-model", + }, + }); + expect(useChatStore.getState().draftsBySession).toMatchObject({ + "created-session": "expand this", + }); + expect(useChatStore.getState().skillDraftsBySession).toMatchObject({ + "created-session": [ + expect.objectContaining({ + id: "global:/Users/test/.agents/skills/code-review/SKILL.md", + name: "code-review", + }), + ], + }); + }); + }); + + it("applies later Home starters after consuming the previous starter request", async () => { + setResolvingPersona("goose-model"); + useProjectStore.setState({ + projects: [ + { + id: "project-1", + path: "/tmp/project.yaml", + name: "Project One", + description: "", + prompt: "", + icon: "", + color: "", + workingDirs: ["/workspace/project"], + projectWorkspaces: [], + useWorktrees: false, + order: 0, + archivedAt: null, + }, + ], + loading: false, + activeProjectId: null, + }); + const user = userEvent.setup(); + renderAppShell(); + + await user.click( + screen.getByRole("button", { name: "Tag home composer agent" }), + ); + await waitFor(() => { + expect(screen.getByText("Reviewer")).toBeInTheDocument(); + }); + + await user.click( + screen.getByRole("button", { name: "Tag home composer project" }), + ); + + await waitFor(() => { + expect(screen.getByText("Project One")).toBeInTheDocument(); + }); + expect(screen.getByTestId("active-view")).toHaveTextContent("home"); + expect(mockAcpCreateSession).not.toHaveBeenCalled(); + }); + + it("opens search with Cmd+K", async () => { + const user = userEvent.setup(); + renderAppShell(); + + await user.keyboard("{Meta>}k{/Meta}"); + + await waitFor(() => { + expect( + screen.getByRole("textbox", { name: "Universal search" }), + ).toBeInTheDocument(); + }); + expect(screen.getByTestId("active-view")).toHaveTextContent("home"); + }); + + it("opens search with Ctrl+K off macOS", async () => { + mockGetPlatform.mockReturnValue("windows"); + const user = userEvent.setup(); + renderAppShell(); + + await user.keyboard("{Control>}k{/Control}"); + + await waitFor(() => { + expect( + screen.getByRole("textbox", { name: "Universal search" }), + ).toBeInTheDocument(); + }); + expect(screen.getByTestId("active-view")).toHaveTextContent("home"); + }); + + it("toggles the dev design system inspector with Cmd+Shift+D", async () => { + mockDesignSystemExplorerEnabled.mockReturnValue(true); + renderAppShell(); + + expect( + screen.queryByRole("button", { name: "Inspect (⌘I)" }), + ).not.toBeInTheDocument(); + + fireEvent.keyDown(window, { + key: "d", + metaKey: true, + shiftKey: true, + }); + + expect( + screen.getByRole("button", { name: "Inspect (⌘I)" }), + ).toBeInTheDocument(); + + fireEvent.keyDown(window, { + key: "d", + metaKey: true, + shiftKey: true, + }); + + await waitFor(() => { + expect( + screen.queryByRole("button", { name: "Inspect (⌘I)" }), + ).not.toBeInTheDocument(); + }); + }); + + it("toggles design system inspect mode with Cmd+I", async () => { + mockDesignSystemExplorerEnabled.mockReturnValue(true); + renderAppShell(); + + expect( + screen.queryByRole("button", { name: "Inspect (⌘I)" }), + ).not.toBeInTheDocument(); + + fireEvent.keyDown(window, { + key: "i", + metaKey: true, + }); + + await waitFor(() => { + expect( + screen.getByRole("button", { name: "Inspecting (⌘I)" }), + ).toHaveAttribute("aria-pressed", "true"); + }); + + fireEvent.keyDown(window, { + key: "i", + metaKey: true, + }); + + await waitFor(() => { + expect( + screen.getByRole("button", { name: "Inspect (⌘I)" }), + ).toHaveAttribute("aria-pressed", "false"); + }); + }); + + it("does not toggle the design system inspector outside dev explorer mode", () => { + renderAppShell(); + + fireEvent.keyDown(window, { + key: "d", + metaKey: true, + }); + + expect( + screen.queryByRole("button", { name: "Inspect (⌘I)" }), + ).not.toBeInTheDocument(); + + fireEvent.keyDown(window, { + key: "i", + metaKey: true, + }); + + expect( + screen.queryByRole("button", { name: "Inspect (⌘I)" }), + ).not.toBeInTheDocument(); + }); + + it("toggles the keyboard shortcuts reference with Cmd+/", async () => { + renderAppShell(); + + fireEvent.keyDown(window, { key: "/", code: "Slash", metaKey: true }); + await waitFor(() => { + expect(useShortcutsDialogStore.getState().open).toBe(true); + }); + + fireEvent.keyDown(window, { key: "/", code: "Slash", metaKey: true }); + await waitFor(() => { + expect(useShortcutsDialogStore.getState().open).toBe(false); + }); + }); + + it("opens the shortcuts reference with Ctrl+/ off macOS", async () => { + mockGetPlatform.mockReturnValue("windows"); + renderAppShell(); + + fireEvent.keyDown(window, { key: "/", code: "Slash", ctrlKey: true }); + + await waitFor(() => { + expect(useShortcutsDialogStore.getState().open).toBe(true); + }); + }); + + it("ignores Cmd on a non-Slash physical key that types '/'", async () => { + renderAppShell(); + + // QWERTZ layouts type "/" from Shift+7; the shortcut must not fire. + fireEvent.keyDown(window, { key: "/", code: "Digit7", metaKey: true }); + + expect(useShortcutsDialogStore.getState().open).toBe(false); + }); + + it("opens search with an overridden combo instead of the default", async () => { + window.localStorage.setItem( + SHORTCUT_PREFERENCES_STORAGE_KEY, + JSON.stringify({ + version: 1, + overrides: { "navigation.search": "meta+shift+x" }, + }), + ); + const user = userEvent.setup(); + renderAppShell(); + + await user.keyboard("{Meta>}k{/Meta}"); + expect(screen.getByTestId("active-view")).toHaveTextContent("home"); + expect( + screen.queryByRole("textbox", { name: "Universal search" }), + ).not.toBeInTheDocument(); + + await user.keyboard("{Meta>}{Shift>}x{/Shift}{/Meta}"); + await waitFor(() => { + expect( + screen.getByRole("textbox", { name: "Universal search" }), + ).toBeInTheDocument(); + }); + expect(screen.getByTestId("active-view")).toHaveTextContent("home"); + }); + + it("toggles the shortcuts reference with an overridden combo, including while it is open", async () => { + window.localStorage.setItem( + SHORTCUT_PREFERENCES_STORAGE_KEY, + JSON.stringify({ + version: 1, + overrides: { "help.shortcuts": "meta+shift+h" }, + }), + ); + renderAppShell(); + + // The default no longer fires once overridden. + fireEvent.keyDown(window, { key: "/", code: "Slash", metaKey: true }); + expect(useShortcutsDialogStore.getState().open).toBe(false); + + fireEvent.keyDown(window, { key: "h", metaKey: true, shiftKey: true }); + await waitFor(() => { + expect(useShortcutsDialogStore.getState().open).toBe(true); + }); + // The dialog is a keyboard-owning layer; the toggle must still close it. + await screen.findByRole("dialog"); + + fireEvent.keyDown(window, { key: "h", metaKey: true, shiftKey: true }); + await waitFor(() => { + expect(useShortcutsDialogStore.getState().open).toBe(false); + }); + }); + + it("does not run global shortcuts while a keyboard-owning layer is open", async () => { + renderAppShell(); + + fireEvent.keyDown(window, { key: "/", code: "Slash", metaKey: true }); + await screen.findByRole("dialog"); + + fireEvent.keyDown(window, { key: "k", metaKey: true }); + expect(screen.getByTestId("active-view")).toHaveTextContent("home"); + expect( + screen.queryByRole("textbox", { name: "Universal search" }), + ).not.toBeInTheDocument(); + }); + + it("opens the session quick switcher with Cmd+P, honoring an override over the default", async () => { + renderAppShell(); + + // The default combo opens the switcher. + fireEvent.keyDown(window, { key: "p", metaKey: true }); + const input = await screen.findByPlaceholderText("Jump to session..."); + + fireEvent.keyDown(input, { key: "Escape" }); + await waitFor(() => { + expect( + screen.queryByPlaceholderText("Jump to session..."), + ).not.toBeInTheDocument(); + }); + + // Once overridden, the default stops firing and the override opens it. + window.localStorage.setItem( + SHORTCUT_PREFERENCES_STORAGE_KEY, + JSON.stringify({ + version: 1, + overrides: { "session.quickSwitch": "meta+shift+p" }, + }), + ); + fireEvent.keyDown(window, { key: "p", metaKey: true }); + expect( + screen.queryByPlaceholderText("Jump to session..."), + ).not.toBeInTheDocument(); + + fireEvent.keyDown(window, { key: "p", metaKey: true, shiftKey: true }); + expect( + await screen.findByPlaceholderText("Jump to session..."), + ).toBeInTheDocument(); + }); + + it("cycles sessions with Ctrl+Tab and Ctrl+Shift+Tab", async () => { + const user = userEvent.setup(); + const sessionBase = { + executionTarget: { harnessId: "goose" }, + workingDir: "~/goose artifacts", + createdAt: "2026-06-09T00:00:00.000Z", + messageCount: 1, + } satisfies Partial; + useChatSessionStore.setState({ + sessions: [ + { + ...sessionBase, + id: "session-1", + title: "Newest chat", + updatedAt: "2026-06-09T12:00:00.000Z", + }, + { + ...sessionBase, + id: "session-2", + title: "Older chat", + updatedAt: "2026-06-09T10:00:00.000Z", + }, + ] as ChatSession[], + activeSessionId: null, + }); + + renderAppShell(); + + // From home, Ctrl+Tab enters the list at the most recent session. + fireEvent.keyDown(window, { key: "Tab", ctrlKey: true }); + await waitFor(() => { + expect(screen.getByTestId("rendered-session-id")).toHaveTextContent( + "session-1", + ); + }); + + // Forward wraps through the older session. + fireEvent.keyDown(window, { key: "Tab", ctrlKey: true }); + await waitFor(() => { + expect(screen.getByTestId("rendered-session-id")).toHaveTextContent( + "session-2", + ); + }); + + // Backward returns to the newer one. + fireEvent.keyDown(window, { key: "Tab", ctrlKey: true, shiftKey: true }); + await waitFor(() => { + expect(screen.getByTestId("rendered-session-id")).toHaveTextContent( + "session-1", + ); + }); + + // Plain Tab (no ctrl) never cycles. + await user.keyboard("{Tab}"); + expect(screen.getByTestId("rendered-session-id")).toHaveTextContent( + "session-1", + ); + }); +}); diff --git a/src/app/AppShell.startupDiagnostics.test.tsx b/src/app/AppShell.startupDiagnostics.test.tsx new file mode 100644 index 000000000..1fcc7e611 --- /dev/null +++ b/src/app/AppShell.startupDiagnostics.test.tsx @@ -0,0 +1,215 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { useAgentStore } from "@/features/agents/stores/agentStore"; +import { useChatStore } from "@/features/chat/stores/chatStore"; +import { useChatSessionStore } from "@/features/chat/stores/chatSessionStore"; +import { useProjectStore } from "@/features/projects/stores/projectStore"; +import { AppShell } from "./AppShell"; + +const mocks = vi.hoisted(() => ({ + startupRetry: vi.fn(), + defaultModelRepair: vi.fn(), + startupState: { + ready: true, + error: null as unknown, + }, + migrationState: { + status: "ready", + error: null as Error | null, + }, +})); + +vi.mock("@tauri-apps/api/path", () => ({ + appLogDir: vi.fn().mockResolvedValue("/Users/test/Library/Logs/goose"), +})); + +vi.mock("./hooks/useAppStartup", () => ({ + useAppStartup: () => ({ + ready: mocks.startupState.ready, + error: mocks.startupState.error, + retry: mocks.startupRetry, + }), +})); + +vi.mock("@/features/agents/hooks/useAgentBuilderCoordinator", () => ({ + useAgentBuilderCoordinator: () => ({ + closeAgentBuilderSession: vi.fn(), + navigateAgentBuilderAgents: vi.fn(), + navigateAgentBuilderChat: vi.fn(), + }), +})); + +vi.mock("@/features/migration/hooks/useMigrationGate", () => ({ + useMigrationGate: () => ({ + status: mocks.migrationState.status, + error: mocks.migrationState.error ?? undefined, + retry: vi.fn(), + }), +})); + +vi.mock("@/features/migration/hooks/useDefaultModelGate", () => ({ + useDefaultModelGate: (...args: unknown[]) => + mocks.defaultModelRepair(...args), +})); + +vi.mock("@/features/projects/api/projects", () => ({ + archiveProject: vi.fn().mockResolvedValue(undefined), + createProject: vi.fn(), + deleteProject: vi.fn(), + listProjects: vi.fn().mockResolvedValue([]), + reorderProjects: vi.fn().mockResolvedValue(undefined), + updateProject: vi.fn(), +})); + +vi.mock("@/features/projects/artifact/prefetchProjectArtifactRenderer", () => ({ + prefetchProjectArtifactRenderer: vi.fn().mockResolvedValue({}), +})); + +vi.mock("@/features/updates/ui/UpdateButton", () => ({ + UpdateButton: () => null, +})); + +vi.mock("@/features/updates/ui/ChannelSwitchDialog", () => ({ + ChannelSwitchDialog: () => null, +})); + +vi.mock("@/features/updates/ui/BetaBadge", () => ({ + BetaBadge: () => null, +})); + +vi.mock("@/shared/ui/GlobalComposerPill", () => ({ + GlobalComposerPill: () => null, +})); + +vi.mock("@/features/providers/hooks/useAgentProviderStatus", () => ({ + useAgentProviderStatus: () => ({ + readyAgentIds: new Set(["goose"]), + agentReadiness: new Map([["goose", "ready"]]), + agentChecks: new Map(), + loading: false, + refresh: vi.fn().mockResolvedValue(undefined), + }), +})); + +vi.mock("./ui/AppShellContent", () => ({ + AppShellContent: () =>
, +})); + +function renderAppShell() { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, + }); + return render( + + + , + ); +} + +describe("AppShell startup diagnostics", () => { + beforeEach(() => { + vi.clearAllMocks(); + window.history.replaceState(null, "", "/"); + window.localStorage.clear(); + mocks.startupState.ready = true; + mocks.startupState.error = null; + mocks.migrationState.status = "ready"; + mocks.migrationState.error = null; + useChatStore.setState({ + messagesBySession: {}, + sessionStateById: {}, + draftsBySession: {}, + queuedMessageBySession: {}, + scrollTargetMessageBySession: {}, + activeSessionId: null, + isConnected: true, + }); + useChatSessionStore.setState({ + sessions: [], + activeSessionId: null, + isLoading: false, + hasHydratedSessions: false, + isRightRailOpen: false, + activeWorkspaceBySession: {}, + }); + useAgentStore.setState({ + selectedProvider: "goose", + }); + useProjectStore.setState({ + projects: [], + loading: false, + hasFetchedProjects: true, + activeProjectId: null, + fetchProjects: vi.fn().mockResolvedValue(undefined), + }); + }); + + it("renders app content even when migration setup fails", () => { + mocks.migrationState.status = "error"; + mocks.migrationState.error = new Error("default save failed"); + + renderAppShell(); + + expect(screen.getByTestId("app-shell-content")).toBeInTheDocument(); + expect(mocks.defaultModelRepair).toHaveBeenCalledWith(true); + expect( + screen.queryByRole("heading", { name: "Berd couldn't start" }), + ).not.toBeInTheDocument(); + }); + + it("shows the Berd loader while app startup is loading", () => { + mocks.startupState.ready = false; + + const { container } = renderAppShell(); + + expect( + screen.getByRole("status", { name: "Starting Berd" }), + ).toBeInTheDocument(); + expect( + container.querySelector('[data-slot="berd-loader"]'), + ).toBeInTheDocument(); + expect(container.querySelector("img")).toBeNull(); + expect(container.querySelector("video")).toBeNull(); + expect(screen.queryByTestId("app-shell-content")).not.toBeInTheDocument(); + }); + + it("shows diagnostics only for app startup errors", async () => { + const user = userEvent.setup(); + mocks.startupState.error = new Error( + "Failed to spawn goose serve (binary: goosed): denied", + ); + + renderAppShell(); + + expect( + screen.getByRole("heading", { name: "Berd couldn't start" }), + ).toBeInTheDocument(); + expect(screen.queryByTestId("app-shell-content")).not.toBeInTheDocument(); + + await user.click(screen.getByRole("button", { name: "Retry" })); + + expect(mocks.startupRetry).toHaveBeenCalledTimes(1); + }); + + it("shows a blocking configuration unavailable startup error", () => { + mocks.startupState.error = Object.assign( + new Error( + "Runtime config unavailable: missing from fakeEndpoint: No fake response", + ), + { name: "RuntimeConfigUnavailableError" }, + ); + + render(); + + expect( + screen.getByRole("heading", { name: "Configuration unavailable" }), + ).toBeInTheDocument(); + expect(screen.queryByTestId("app-shell-content")).not.toBeInTheDocument(); + }); +}); diff --git a/src/app/AppShell.tsx b/src/app/AppShell.tsx new file mode 100644 index 000000000..ef28b6dc4 --- /dev/null +++ b/src/app/AppShell.tsx @@ -0,0 +1,5223 @@ +import { + useCallback, + useEffect, + useLayoutEffect, + useMemo, + useRef, + useState, +} from "react"; +import { useTranslation } from "react-i18next"; +import { toast } from "sonner"; +import { FeedbackDialog } from "@/features/feedback/FeedbackDialog"; +import { useFeedbackDialogStore } from "@/features/feedback/feedbackDialogStore"; +import { KeyboardShortcutsDialog } from "@/features/shortcuts/ui/KeyboardShortcutsDialog"; +import { eventMatchesShortcutCommand } from "@/features/shortcuts/lib/shortcutRegistry"; +import { useShortcutsDialogStore } from "@/features/shortcuts/stores/shortcutsDialogStore"; +import { prefetchProjectArtifactRenderer } from "@/features/projects/artifact/prefetchProjectArtifactRenderer"; +import { getPlatform, type Platform } from "@/shared/lib/platform"; +import { archiveProject } from "@/features/projects/api/projects"; +import type { ProjectInfo } from "@/features/projects/api/projects"; +import { + DEFAULT_SETTINGS_SECTION, + resolveEnabledSettingsSection, + resolveSettingsSection, + SETTINGS_SECTIONS, + type SectionId, +} from "@/features/settings/ui/settingsSections"; +import { + OPEN_SETTINGS_EVENT, + type AgentBuilderProviderSetupReturnTarget, + type OpenSettingsEventDetail, +} from "@/features/settings/lib/settingsEvents"; +import type { ExtensionEntry } from "@/features/extensions/types"; +import { acceptFirstSend } from "@/features/chat/lib/firstWorkspaceSend"; +import { + admitSystemInheritedQueuedMessage, + personaIntentFromComposer, +} from "@/features/chat/lib/admittedSend"; +import { planProjectChatWorkspacesAsIs } from "@/features/projects/lib/projectChatWorkspaces"; +import { ProjectWorkspaceStartupNameDialog } from "@/features/projects/ui/ProjectWorkspaceStartupNameDialog"; +import { useWorkspaceRepository } from "@/features/workspaces/workspaceRepository"; +import type { TopBarChromeInsets } from "./ui/TopBar"; +import { useChatStore } from "@/features/chat/stores/chatStore"; +import { useActiveProjectTint } from "@/features/chat/hooks/useActiveProjectTint"; +import { useWorkspaceNameRequestQueue } from "@/features/chat/hooks/useWorkspaceNameRequestQueue"; +import { + cleanupSessionWorkspaces, + countSessionWorkspaceCleanupResources, + hasSessionWorkspaceCleanupTargets, + inspectSessionWorkspaceCleanup, + type InspectedSessionWorkspaceCleanupPlan, + loadAllSessionsForWorkspaceCleanup, + planSessionWorkspaceCleanup, + SessionWorkspaceCleanupInterruptedError, + type SessionWorkspaceCleanupInterruptionReason, + wouldSessionWorkspaceCleanupDiscardFiles, +} from "@/features/chat/lib/sessionWorkspaceCleanup"; +import { getCachedHomeDir, getHomeDir } from "@/shared/api/system"; +import { isSessionRunning } from "@/features/chat/lib/sessionActivity"; +import { + isAgentBuilderVisible, + isContextPanelVisible, +} from "@/features/chat/lib/chatCapabilityVisibility"; +import { SessionWorkspaceCleanupDialog } from "@/features/chat/ui/SessionWorkspaceCleanupDialog"; +import { + type ChatSession, + type ChatSessionReasoningEffortConfig, + getVisibleSessions, + SessionNotFoundError, + useChatSessionStore, +} from "@/features/chat/stores/chatSessionStore"; +import { selectLocalMessageCountsBySession } from "@/features/chat/stores/chatSelectors"; +import { resolveSessionCycleTarget } from "@/features/sessions/lib/sessionCycle"; +import { useSessionWindowStore } from "@/features/chat/stores/sessionWindowStore"; +import { + selectActiveSessionId, + selectHasHydratedSessions, + selectSessions, + selectSessionsLoading, +} from "@/features/chat/stores/chatSessionSelectors"; +import { useAgentStore } from "@/features/agents/stores/agentStore"; +import { useProviderSelection } from "@/features/agents/hooks/useProviderSelection"; +import { personaExecutionTarget } from "@/features/agents/lib/personaExecutionTarget"; +import { useProjectStore } from "@/features/projects/stores/projectStore"; +import { selectProjects } from "@/features/projects/stores/projectSelectors"; +import { findExistingDraft } from "@/features/chat/lib/newChat"; +import { DEFAULT_CHAT_TITLE } from "@/features/chat/lib/sessionTitle"; +import { useAppStartup } from "./hooks/useAppStartup"; +import { useCompletionNotifications } from "@/shared/hooks/useCompletionNotifications"; +import { useHomeSessionStateSync } from "./hooks/useHomeSessionStateSync"; +import { useHomeWidgetStore } from "@/features/home/stores/homeWidgetStore"; +import { useProjectDialog } from "./hooks/useProjectDialog"; +import { useResizableSidebar } from "./hooks/useResizableSidebar"; +import { + areAppNavigationLocationsEqual, + getAppNavigationLocation, +} from "./lib/appNavigationLocation"; +import { useStagedAppContentLocation } from "./lib/useStagedAppContentLocation"; +import { loadStoredHomeSessionId } from "./lib/homeSessionStorage"; +import { resolveSupportedSessionModelPreference } from "@/features/providers/lib/resolveSessionModelPreference"; +import { listenSessionDeepLinkErrors } from "./lib/sessionDeepLinkErrors"; +import { + clearSettingsSectionUrl, + getInitialSettingsSection, + setDesignSystemUrl, + setSettingsSectionUrl, +} from "./lib/settingsSectionUrl"; +import { useAgentBuilderCoordinator } from "@/features/agents/hooks/useAgentBuilderCoordinator"; +import { + type ArchiveCleanupPolicy, + MUTATION_DEADLINE_MARGIN_MS, + useRegisterAppNavigationController, +} from "@/features/berdctl/navigation"; +import { AgentBuilderLeaveDraftDialog } from "@/features/agents/ui/AgentBuilderLeaveDraftDialog"; +import { AutomationBuilderLeaveDialog } from "@/features/automations/ui/AutomationBuilderLeaveDialog"; +import type { AutomationBuilderLeaveAction } from "@/features/automations/ui/AutomationBuilderView"; +import { AppShellLayout } from "./ui/AppShellLayout"; +import type { AuthStatus } from "@/features/auth/api/auth"; +import { AppShellContent } from "./ui/AppShellContent"; +import { + replaceSessionTargetAfterDispatch, + transferSessionTargetOwnership, + transitionSessionTarget, +} from "@/features/chat/lib/sessionTargetCoordinator"; +import { + beginModelSelectionIntent, + getModelSelectionIntent, + clearCurrentModelSelectionIntent, + createModelSelectionRequestId, + isCurrentModelSelectionIntent, + showModelSwitchErrorToast, +} from "@/features/chat/model-selection/modelSelectionIntent"; +import { setStoredModelPreference } from "@/features/chat/lib/modelPreferences"; +import { archiveSession as archiveSessionApi } from "@/shared/api/acpApi"; +import { + moveSessionToProject, + updateSessionTitle, +} from "@/features/chat/stores/chatSessionOperations"; +import { + activateSession as activateChatSession, + hasConversationMessages, + loadSessionMessagesAndPrepare, +} from "@/features/chat/lib/sessionActivation"; +import { + focusSessionWindow, + releaseSession, +} from "@/features/chat/lib/sessionWindowCommands"; +import { sendSessionWindowSearchTarget } from "@/features/chat/lib/sessionWindowSearchEvents"; +import { useSessionHandoffSource } from "@/features/chat/hooks/useSessionHandoffSource"; +import { useSessionWindowSupport } from "@/features/chat/hooks/useSessionWindowSupport"; +import { useSessionWindowTracking } from "@/features/chat/hooks/useSessionWindowTracking"; +import { resolveSessionCwd } from "@/features/projects/lib/sessionCwdSelection"; +import { perfLog } from "@/shared/lib/perfLog"; +import { cn } from "@/shared/lib/cn"; +import { isEditableTarget } from "@/shared/keyboard/isEditableTarget"; +import { + getChatSessionIdsWithTerminals, + setTerminalRenderingSuspended, +} from "@/features/terminal/lib/terminalSessionManager"; +import type { AgentSetupTroubleshootingRequest } from "@/features/providers/lib/agentSetupTroubleshooting"; +import type { SkillInfo } from "@/features/skills/api/skills"; +import { toChatSkillDraft } from "@/features/skills/lib/skillChatPrompt"; +import { useMigrationGate } from "@/features/migration/hooks/useMigrationGate"; +import { useNewSessionTarget } from "@/features/providers/hooks/useNewSessionTarget"; +import { useAgentProviderStatus } from "@/features/providers/hooks/useAgentProviderStatus"; +import { useDefaultProviderReadinessStore } from "@/features/providers/stores/defaultProviderReadinessStore"; +import { + getProviderCatalog, + resolveAgentProviderCatalogIdStrict, +} from "@/features/providers/providerCatalog"; +import { useProviderModelCacheStore } from "@/features/providers/stores/providerModelCacheStore"; +import { getBuildFeatureState } from "@/shared/profile/buildProfile"; +import { gooseServeSelectionFromExecutionTarget } from "@/features/chat/lib/gooseServeExecutionTarget"; +import { + isModelExecutionTarget, + materializeSessionExecutionModel, + normalizeSessionExecutionTarget, + sameSessionExecutionTarget, + type SessionExecutionTarget, +} from "@/features/chat/lib/sessionExecutionTarget"; +import { useDefaultModelGate } from "@/features/migration/hooks/useDefaultModelGate"; +import { findBerdyPersonaId } from "@/features/onboarding/berdyAgent"; +import { StartupDiagnosticView } from "./ui/StartupDiagnosticView"; +import { buildStartupDiagnosticIssue } from "./lib/startupDiagnostics"; +import { usePersistedState } from "@/shared/hooks/usePersistedState"; +import { + FocusRegionProvider, + hasOpenKeyboardOwningLayer, +} from "./focus/FocusRegionProvider"; +import { SessionQuickSwitcher } from "@/features/sessions/ui/SessionQuickSwitcher"; +import { SearchView } from "@/features/search/ui/SearchView"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogTitle, +} from "@/shared/ui/dialog"; +import { useForkSession } from "@/features/sessions/hooks/useForkSession"; +import { + GlobalComposerPill, + type GlobalComposerExpandPayload, + type GlobalComposerHandoffRect, + type GlobalComposerStarterRequest, + type GlobalComposeOptions, +} from "@/shared/ui/GlobalComposerPill"; +import { acpCreateSession, acpSetSessionConfigOption } from "@/shared/api/acp"; +import { formatAcpErrorMessage } from "@/shared/api/acpErrors"; +import { findMissingProjectDirs } from "@/features/projects/lib/missingProjectDirs"; +import { + createSystemNotificationMessage, + isSystemNotification, +} from "@/shared/types/messages"; +import { isDesignSystemExplorerEnabled } from "@/features/design-system/lib/designSystemEnabled"; +import { FIRST_RUN_ONBOARDING_EXPERIMENT_ID } from "@/features/experiments/experimentDefinitions"; +import { useExperiment } from "@/features/experiments/experimentPreferences"; +import { OnboardingFlow } from "@/features/onboarding/ui/OnboardingFlow"; +import { useOnboardingState } from "@/features/onboarding/model"; +import { useVoiceConversationStore } from "@/features/voice-conversation/stores/voiceConversationStore"; +import { usePocketVoiceSetup } from "@/features/voice-conversation/hooks/usePocketVoiceSetup"; +import { PocketVoiceSetupDialog } from "@/features/voice-conversation/ui/PocketVoiceSetupDialog"; +import { + cancelPendingVoiceStart, + continuePendingVoiceStart, + deferPendingVoiceStart, + type DeferredPendingVoiceStart, +} from "@/features/voice-conversation/lib/pendingVoiceStart"; +import { useProfileCapabilities } from "@/shared/profile/capabilities"; +import { getOptimisticArtifactCwd } from "@/shared/artifacts/sessionArtifactLocation"; +import { + DEFAULT_DESIGN_SYSTEM_SECTION, + DESIGN_SYSTEM_SECTIONS, + type DesignSystemSection, +} from "@/features/design-system/ui/designSystemSections"; +import type { + AppNavigationLocation, + AppNavigationUpdateOptions, + AppView, + AutomationNavigationRoute, + BuilderbotNavigationRoute, +} from "./types/appNavigation"; +import type { TopBarBreadcrumb } from "./ui/TopBar"; +import { STARTUP_LOADING_MIN_DISPLAY_MS } from "./lib/startupLoading"; +import { StartupLoadingView } from "./ui/StartupLoadingView"; +import { deriveStarterTaskCompletion } from "@/features/home/onboarding/starterTaskCompletion"; +import { + omittedStarterTasksAfterFirstRun, + type StarterTaskCompletionState, + type StarterTaskId, +} from "@/features/home/onboarding/starterTasks"; +import { StarterTaskList } from "@/features/home/onboarding/StarterTaskList"; +import { + clearStarterTaskProgress, + EMPTY_STARTER_TASK_COMPLETION, + loadStarterTaskProgress, + saveStarterTaskProgress, + STARTER_TASK_PROGRESS_STORAGE_KEY, +} from "@/features/home/onboarding/starterTaskProgress"; +import { StarterTasksProvider } from "@/features/home/onboarding/StarterTasksContext"; +import { + requestStarterWidgetPicker, + STARTER_WIDGET_ADDED_EVENT, +} from "@/features/home/onboarding/starterWidgetTask"; +import { STARTER_TASKS_EXPERIMENT_ID } from "@/features/experiments/experimentDefinitions"; +import { + recordAssistiveMomentRetired, + recordAssistiveMomentShown, + shouldShowAssistiveMoment, +} from "@/shared/assistive-ux/runtime"; +export type { AppView } from "./types/appNavigation"; + +type AppNavigationHistory = { + entries: AppNavigationLocation[]; + index: number; + isApplying: boolean; +}; + +type ResolvedSessionModelPreference = Awaited< + ReturnType +>; +type MaybePromise = T | Promise; +type DraftSessionCreationReady = { + backendSessionId: string; + configOptionsSnapshot: Awaited< + ReturnType + >["configOptionsSnapshot"]; +}; +type ProjectChatDraftOptions = { + executionTarget?: SessionExecutionTarget; + reuseExistingDraft?: boolean; + reasoningEffort?: GlobalComposeOptions["reasoningEffort"]; +}; + +function executionTargetFromModelPreference( + harnessId: string, + preference: ResolvedSessionModelPreference, +): SessionExecutionTarget { + const canApplyModel = + !preference.modelId || + harnessId !== "goose" || + (preference.providerId !== "goose" && + !resolveAgentProviderCatalogIdStrict(preference.providerId)); + return normalizeSessionExecutionTarget({ + harnessId, + modelProviderId: + canApplyModel && + (preference.modelId || preference.providerId !== harnessId) + ? preference.providerId + : undefined, + modelId: canApplyModel ? preference.modelId : undefined, + modelName: canApplyModel ? preference.modelName : undefined, + }); +} + +interface PendingSessionWorkspaceCleanupConfirmation { + worktreeCount: number; + branchCount: number; + resolve: (confirmed: boolean) => void; +} + +const APP_NAVIGATION_HISTORY_LIMIT = 50; +const PINNED_CHAT_HYDRATION_CONCURRENCY = 5; +const DESIGN_SYSTEM_INSPECTOR_VISIBLE_STORAGE_KEY = + "goose:design-system-inspector-visible:v2"; +const REDUCED_MOTION_QUERY = "(prefers-reduced-motion: reduce)"; +const GLOBAL_COMPOSER_HANDOFF_MS = 620; +const GLOBAL_COMPOSER_ROUTE_SWAP_DELAY_MS = 220; + +function getSessionArchiveInterruptionReason( + sessionId: string, + cleanupPolicy: ArchiveCleanupPolicy, + deadlineMs?: number, +): SessionWorkspaceCleanupInterruptionReason | null { + if ( + deadlineMs != null && + Date.now() >= deadlineMs - MUTATION_DEADLINE_MARGIN_MS + ) { + return "timed_out"; + } + if (cleanupPolicy === "confirm") { + return null; + } + if (useSessionWindowStore.getState().isOpenInWindow(sessionId)) { + return "target_session_running"; + } + const runtime = useChatStore.getState().getSessionRuntime(sessionId); + return isSessionRunning(runtime.chatState) || runtime.isRunCancellationPending + ? "target_session_running" + : null; +} + +type GlobalComposerPlacement = "docked" | "centered" | "handoff"; + +export function shouldStopVoiceConversationOnSessionChange({ + previousSessionId, + nextSessionId, + boundSessionId, + lifecycle, +}: { + previousSessionId: string | null; + nextSessionId: string | null; + boundSessionId: string | null; + lifecycle: string; +}): boolean { + return ( + previousSessionId !== null && + previousSessionId !== nextSessionId && + boundSessionId === previousSessionId && + lifecycle !== "stopped" && + lifecycle !== "unavailable" + ); +} +const current = (id: string, label: string): TopBarBreadcrumb => ({ + id, + label, +}); + +const parent = ( + id: string, + label: string, + onClick: () => void, +): TopBarBreadcrumb => ({ id, label, onClick }); + +function validateBooleanPreference(value: unknown, defaults: boolean) { + return typeof value === "boolean" ? value : defaults; +} + +function isArchiveShortcutBlockedTarget(target: EventTarget | null) { + if (!(target instanceof Element)) { + return false; + } + // The chat composer opts back in (data-chat-composer): it holds focus for + // most of a session's life, so treating it like other editable fields would + // make the archive shortcut effectively dead in chat view. + if (target.closest("[data-chat-composer]")) { + return false; + } + if (isEditableTarget(target)) { + return true; + } + return Boolean(target.closest(".xterm")); +} + +function isTerminalOwnedHistoryShortcut(event: KeyboardEvent) { + if (!(event.target instanceof Element) || !event.target.closest(".xterm")) { + return false; + } + + return ( + event.altKey && + !event.ctrlKey && + !event.metaKey && + !event.shiftKey && + (event.key === "ArrowLeft" || event.key === "ArrowRight") + ); +} + +function getInitialAppView(initialSettingsSection: SectionId | null): AppView { + if (initialSettingsSection) return "settings"; + if ( + isDesignSystemExplorerEnabled() && + window.location.pathname === "/design-system" + ) { + return "design-system"; + } + return "home"; +} + +function getOptimisticSessionCwd(project?: ProjectInfo | null): string { + const projectWorkingDir = (project?.workingDirs ?? []) + .map((directory) => directory.trim()) + .find((directory) => directory.length > 0); + return projectWorkingDir ?? getOptimisticArtifactCwd(); +} + +function resolveLiveSessionId(sessionId: string): string | null { + const session = useChatSessionStore + .getState() + .sessions.find( + (candidate) => + candidate.id === sessionId || candidate.clientSessionId === sessionId, + ); + return session && !session.archivedAt ? session.id : null; +} + +function readSessionReasoningEffort( + sessionId: string, +): ChatSessionReasoningEffortConfig | undefined { + return useChatSessionStore.getState().getSession(sessionId)?.reasoningEffort; +} + +function patchSessionReasoningEffort( + sessionId: string, + reasoningEffort: ChatSessionReasoningEffortConfig, +) { + useChatSessionStore.getState().patchSession(sessionId, { reasoningEffort }); +} + +async function applyReasoningEffortToSession( + sessionId: string, + reasoningEffort: NonNullable, + options: { + currentReasoningEffort?: ChatSessionReasoningEffortConfig; + patchSessionId?: string; + } = {}, +) { + const currentReasoningEffort = + options.currentReasoningEffort ?? readSessionReasoningEffort(sessionId); + if (!currentReasoningEffort) { + return; + } + + const patchSessionId = options.patchSessionId ?? sessionId; + const targetAtRequest = + useChatSessionStore.getState().getSession(patchSessionId) + ?.executionTarget ?? + useChatSessionStore.getState().getSession(sessionId)?.executionTarget; + const optimisticReasoningEffort = + currentReasoningEffort.configId === reasoningEffort.configId + ? { + ...currentReasoningEffort, + currentValue: reasoningEffort.value, + } + : currentReasoningEffort; + patchSessionReasoningEffort(patchSessionId, optimisticReasoningEffort); + const requestIsCurrent = () => { + const liveSession = useChatSessionStore + .getState() + .getSession(patchSessionId); + return ( + sameSessionExecutionTarget( + liveSession?.executionTarget, + targetAtRequest, + ) && + liveSession?.reasoningEffort?.configId === + optimisticReasoningEffort.configId && + liveSession.reasoningEffort.currentValue === + optimisticReasoningEffort.currentValue + ); + }; + const { providerId, modelId } = + gooseServeSelectionFromExecutionTarget(targetAtRequest); + + try { + const configOptionsSnapshot = await acpSetSessionConfigOption( + sessionId, + reasoningEffort.configId, + reasoningEffort.value, + { providerId, modelId, reasoningEffortValue: reasoningEffort.value }, + ); + if (configOptionsSnapshot.reasoningEffort && requestIsCurrent()) { + patchSessionReasoningEffort( + patchSessionId, + configOptionsSnapshot.reasoningEffort, + ); + } + } catch (error) { + if (requestIsCurrent()) { + patchSessionReasoningEffort(patchSessionId, currentReasoningEffort); + } + throw error; + } +} + +function applyReasoningEffortAfterDraftCreation( + draftSessionId: string, + reasoningEffort: GlobalComposeOptions["reasoningEffort"] | undefined, +): ((result: DraftSessionCreationReady) => Promise) | undefined { + if (!reasoningEffort) { + return undefined; + } + + return async ({ backendSessionId, configOptionsSnapshot }) => { + if (!configOptionsSnapshot?.reasoningEffort) { + return; + } + + try { + await applyReasoningEffortToSession(backendSessionId, reasoningEffort, { + currentReasoningEffort: configOptionsSnapshot.reasoningEffort, + patchSessionId: draftSessionId, + }); + } catch (error) { + console.error( + "Failed to apply reasoning effort during draft session creation:", + error, + ); + } + }; +} + +function prefersReducedMotion(): boolean { + return window.matchMedia?.(REDUCED_MOTION_QUERY).matches ?? false; +} + +function logProjectChatStartError(message: string, error: unknown): void { + console.error(message, error); + toast.error(formatAcpErrorMessage(error, "Couldn't start chat. Try again.")); +} + +function useWindowFullscreenState() { + const [isWindowFullscreen, setIsWindowFullscreen] = useState(false); + + useEffect(() => { + if (!window.__TAURI_INTERNALS__) { + return; + } + + let didCancel = false; + let unlisten: (() => void) | undefined; + + async function setupFullscreenState() { + const { getCurrentWindow } = await import("@tauri-apps/api/window"); + const appWindow = getCurrentWindow(); + + async function syncFullscreenState() { + const nextIsFullscreen = await appWindow.isFullscreen(); + if (!didCancel) { + setIsWindowFullscreen(nextIsFullscreen); + } + } + + await syncFullscreenState(); + unlisten = await appWindow.onResized(() => { + void syncFullscreenState(); + }); + + if (didCancel) { + unlisten(); + } + } + + void setupFullscreenState().catch(() => undefined); + + return () => { + didCancel = true; + unlisten?.(); + }; + }, []); + + return isWindowFullscreen; +} + +function getTopBarChromeInsets( + platform: Platform, + isWindowFullscreen: boolean, +): TopBarChromeInsets { + if (platform === "mac" && !isWindowFullscreen) { + return { leading: "trafficLights" }; + } + + return { leading: "compact" }; +} + +export function shouldStopVoiceConversationOnExperimentChange({ + wasEnabled, + isEnabled, +}: { + wasEnabled: boolean; + isEnabled: boolean; +}): boolean { + return wasEnabled && !isEnabled; +} + +export function AppShell({ + authStatus, + children, + onLoggedOut, +}: { + authStatus?: AuthStatus; + children?: React.ReactNode; + onLoggedOut?: (status: AuthStatus) => void; +}) { + const { t } = useTranslation([ + "chat", + "common", + "agents", + "settings", + "search", + "home", + "sidebar", + ]); + const { + expandSidebar, + handleCornerResizeDoubleClick, + handleCornerResizeStart, + handleHeightResizeDoubleClick, + handleHeightResizeStart, + handleResizeDoubleClick, + handleResizeStart, + isCollapsed: sidebarCollapsed, + isResizing, + resizeHandleHeight, + resizeHandleWidth, + sidebarOuterHeight, + sidebarPanelOuterWidth, + sidebarWidth, + toggleCollapse: toggleSidebar, + } = useResizableSidebar(); + const isWindowFullscreen = useWindowFullscreenState(); + const platform = getPlatform(); + const topBarChromeInsets = getTopBarChromeInsets( + platform, + isWindowFullscreen, + ); + const initialSettingsSection = getInitialSettingsSection(); + const [activeSettingsSection, setActiveSettingsSection] = useState( + initialSettingsSection ?? DEFAULT_SETTINGS_SECTION, + ); + const [quickSwitcherOpen, setQuickSwitcherOpen] = useState(false); + const [searchDialogOpen, setSearchDialogOpen] = useState(false); + const [searchEscapeRequest, setSearchEscapeRequest] = useState(0); + const [ + pendingWorkspaceCleanupConfirmation, + setPendingWorkspaceCleanupConfirmation, + ] = useState(null); + const pendingWorkspaceCleanupConfirmationRef = + useRef(null); + const sessionArchiveQueueRef = useRef>(Promise.resolve()); + const [activeDesignSystemSection, setActiveDesignSystemSection] = + useState(DEFAULT_DESIGN_SYSTEM_SECTION); + const [designSystemInspectorVisible, setDesignSystemInspectorVisible] = + usePersistedState( + DESIGN_SYSTEM_INSPECTOR_VISIBLE_STORAGE_KEY, + false, + validateBooleanPreference, + ); + const [ + designSystemInspectorModeToggleRequest, + setDesignSystemInspectorModeToggleRequest, + ] = useState(0); + const initialActiveView = getInitialAppView(initialSettingsSection); + const [activeView, setActiveView] = useState(initialActiveView); + const capabilities = useProfileCapabilities(); + const isAutomationsFeatureEnabled = capabilities.automations; + const isBuilderbotSurfaceEnabled = capabilities.builderbot; + const isFeedbackEnabled = capabilities.feedback; + const sessionWindowSupport = useSessionWindowSupport(); + const isMultiWindowEnabled = sessionWindowSupport.supported; + const stopVoiceConversation = useVoiceConversationStore( + (state) => state.stop, + ); + const requestVoiceConversationStart = useVoiceConversationStore( + (state) => state.requestStart, + ); + const globalPocketVoiceSetup = usePocketVoiceSetup( + capabilities.voiceConversation, + ); + const [globalPocketVoiceSetupOpen, setGlobalPocketVoiceSetupOpen] = + useState(false); + const pendingGlobalVoiceStartRef = + useRef | null>(null); + const voiceConversationWasEnabledRef = useRef(capabilities.voiceConversation); + useEffect(() => { + const wasEnabled = voiceConversationWasEnabledRef.current; + voiceConversationWasEnabledRef.current = capabilities.voiceConversation; + if ( + !shouldStopVoiceConversationOnExperimentChange({ + wasEnabled, + isEnabled: capabilities.voiceConversation, + }) + ) { + return; + } + // The native process survives renderer reloads and may be owned by another + // window, so an explicit on-to-off transition must clean up active use. + // Mounting with the experiment already off performs no Voice native work. + cancelPendingVoiceStart(pendingGlobalVoiceStartRef); + setGlobalPocketVoiceSetupOpen(false); + void stopVoiceConversation().catch(() => undefined); + }, [capabilities.voiceConversation, stopVoiceConversation]); + const sessions = useChatSessionStore(selectSessions); + const activeSessionId = useChatSessionStore(selectActiveSessionId); + const messagesBySession = useChatStore((state) => state.messagesBySession); + const personas = useAgentStore((state) => state.personas); + const personasLoading = useAgentStore((state) => state.personasLoading); + const previousActiveSessionIdRef = useRef(activeSessionId); + useEffect(() => { + const previousSessionId = previousActiveSessionIdRef.current; + previousActiveSessionIdRef.current = activeSessionId; + const voice = useVoiceConversationStore.getState(); + if ( + previousSessionId !== null && + previousSessionId !== activeSessionId && + voice.requestedStartSessionId === previousSessionId + ) { + voice.clearRequestedStart(previousSessionId); + } + if ( + !shouldStopVoiceConversationOnSessionChange({ + previousSessionId, + nextSessionId: activeSessionId, + boundSessionId: voice.status.sessionId, + lifecycle: voice.status.lifecycle, + }) + ) { + return; + } + void stopVoiceConversation().catch(() => undefined); + }, [activeSessionId, stopVoiceConversation]); + const sidebarIsResizing = isResizing; + const sidebarDockedPanelOuterWidth = sidebarPanelOuterWidth; + const sidebarDockedOuterWidth = sidebarCollapsed ? 0 : sidebarPanelOuterWidth; + const [skillsSkillId, setSkillsSkillId] = useState(null); + const [agentsPersonaId, setAgentsPersonaId] = useState(null); + const [globalComposerFocusRequest, setGlobalComposerFocusRequest] = + useState(0); + const onboardingExperiment = useExperiment( + FIRST_RUN_ONBOARDING_EXPERIMENT_ID, + ); + const onboardingState = useOnboardingState(); + const omittedStarterTaskIds = useMemo>( + () => + omittedStarterTasksAfterFirstRun({ + onboardingCompleted: onboardingState.lifecycle === "completed", + providerHandled: onboardingState.completedHarnessSetupIds.length > 0, + }), + [ + onboardingState.completedHarnessSetupIds.length, + onboardingState.lifecycle, + ], + ); + const starterTasksExperimentEnabled = + useExperiment(STARTER_TASKS_EXPERIMENT_ID)?.enabled === true; + const [starterTasksEligible, setStarterTasksEligible] = useState(() => + shouldShowAssistiveMoment("home.starterTasks"), + ); + const starterTasksVisible = + starterTasksExperimentEnabled && starterTasksEligible; + const [starterTasksDocked, setStarterTasksDocked] = useState(false); + const [starterProjectId, setStarterProjectId] = useState(null); + const starterTasksLeftHomeRef = useRef(false); + const initialStarterTaskProgressRef = useRef(loadStarterTaskProgress()); + const [starterTaskOverrides, setStarterTaskOverrides] = + useState( + initialStarterTaskProgressRef.current.completion, + ); + const [starterTasksAwaitingCompletion, setStarterTasksAwaitingCompletion] = + useState>( + initialStarterTaskProgressRef.current.awaiting, + ); + + useEffect(() => { + if (starterTasksVisible) { + recordAssistiveMomentShown("home.starterTasks"); + } + }, [starterTasksVisible]); + + useEffect(() => { + const reset = () => { + clearStarterTaskProgress(); + setStarterTaskOverrides({ ...EMPTY_STARTER_TASK_COMPLETION }); + setStarterTasksAwaitingCompletion(new Set()); + setStarterTasksDocked(false); + setStarterProjectId(null); + setStarterTasksEligible(true); + }; + const synchronize = (event: StorageEvent) => { + if ( + event.key !== STARTER_TASK_PROGRESS_STORAGE_KEY && + event.key !== null + ) { + return; + } + const progress = loadStarterTaskProgress(); + setStarterTaskOverrides(progress.completion); + setStarterTasksAwaitingCompletion(progress.awaiting); + }; + window.addEventListener("starter-tasks-reset", reset); + window.addEventListener("starter-tasks-state-reset", reset); + window.addEventListener("storage", synchronize); + return () => { + window.removeEventListener("starter-tasks-reset", reset); + window.removeEventListener("starter-tasks-state-reset", reset); + window.removeEventListener("storage", synchronize); + }; + }, []); + const [globalComposerPlacement, setGlobalComposerPlacement] = + useState("docked"); + const [globalComposerStarterRequest, setGlobalComposerStarterRequest] = + useState(null); + const globalComposerStarterRequestIdRef = useRef(0); + const [chatComposerHandoffRequest, setChatComposerHandoffRequest] = + useState(0); + const [chatComposerHandoffSessionId, setChatComposerHandoffSessionId] = + useState(null); + const [globalComposerHandoffSourceRect, setGlobalComposerHandoffSourceRect] = + useState(null); + const [globalComposerHandoffTargetRect, setGlobalComposerHandoffTargetRect] = + useState(null); + const globalComposerHandoffTimeoutRef = useRef(null); + const globalComposerRouteSwapTimeoutRef = useRef(null); + const [automationsRoute, setAutomationsRoute] = + useState({ surface: "overview" }); + const [builderbotRoute, setBuilderbotRoute] = + useState({ surface: "overview" }); + const [skillsBreadcrumbLabel, setSkillsBreadcrumbLabel] = useState< + string | null + >(null); + const [agentsBreadcrumbLabel, setAgentsBreadcrumbLabel] = useState< + string | null + >(null); + const [automationsBreadcrumbLabel, setAutomationsBreadcrumbLabel] = useState< + string | null + >(null); + const [builderbotBreadcrumbLabel, setBuilderbotBreadcrumbLabel] = useState< + string | null + >(null); + const [ + agentBuilderSettingsReturnTarget, + setAgentBuilderSettingsReturnTarget, + ] = useState(null); + const [homeSessionId, setHomeSessionId] = useState(() => + loadStoredHomeSessionId(), + ); + const [globalComposerExecutionTarget, setGlobalComposerExecutionTarget] = + useState(undefined); + const globalComposerExecutionTargetRef = useRef( + globalComposerExecutionTarget, + ); + globalComposerExecutionTargetRef.current = globalComposerExecutionTarget; + const replaceNextNavigationEntryRef = useRef(false); + const navigationHistoryRef = useRef({ + entries: [ + getAppNavigationLocation( + initialActiveView, + null, + initialSettingsSection ?? DEFAULT_SETTINGS_SECTION, + null, + null, + { surface: "overview" }, + { surface: "overview" }, + DEFAULT_DESIGN_SYSTEM_SECTION, + ), + ], + index: 0, + isApplying: false, + }); + const [navigationAvailability, setNavigationAvailability] = useState({ + canGoBack: false, + canGoForward: false, + }); + const closeAgentBuilderSessionRef = useRef< + (sessionId: string) => void | Promise + >(() => {}); + const navigateAgentBuilderChatRef = useRef< + (sessionId: string) => void | Promise + >(() => {}); + const automationBuilderLeaveActionRef = + useRef(null); + const pendingAutomationNavigationRef = useRef<{ + next: () => void; + onCancel?: () => void; + } | null>(null); + const [ + automationBuilderHasUnsavedChanges, + setAutomationBuilderHasUnsavedChanges, + ] = useState(false); + const [automationLeavePromptOpen, setAutomationLeavePromptOpen] = + useState(false); + const [automationLeaveSaving, setAutomationLeaveSaving] = useState(false); + const { + workspaceNameRequest: pendingWorkspaceName, + enqueueWorkspaceNameRequest, + cancelWorkspaceNameRequest, + submitWorkspaceNameRequest, + } = useWorkspaceNameRequestQueue(); + const workspaceRepository = useWorkspaceRepository(); + + const homeSessionMessages = useChatStore((s) => + homeSessionId ? s.messagesBySession[homeSessionId] : undefined, + ); + const setChatActiveSession = useChatStore((s) => s.setActiveSession); + const setChatActiveSessionViewing = useChatStore( + (s) => s.setActiveSessionViewing, + ); + const promoteChatSessionId = useChatStore((s) => s.promoteSessionId); + const cleanupChatSession = useChatStore((s) => s.cleanupSession); + const isRightRailOpen = useChatSessionStore((s) => s.isRightRailOpen); + const activeProjectTint = useActiveProjectTint(); + const hasHydratedSessions = useChatSessionStore(selectHasHydratedSessions); + const sessionsLoading = useChatSessionStore(selectSessionsLoading); + const activeSessionWindowLabel = useSessionWindowStore((s) => + isMultiWindowEnabled && activeSessionId + ? s.openSessions[activeSessionId] + : undefined, + ); + const activeSessionInHandoff = useSessionWindowStore((s) => + isMultiWindowEnabled && activeSessionId + ? s.isInHandoff(activeSessionId) + : false, + ); + const createSession = useChatSessionStore((s) => s.createSession); + const createDraftSession = useChatSessionStore((s) => s.createDraftSession); + const promoteDraftSession = useChatSessionStore((s) => s.promoteDraftSession); + const markSessionCreationFailed = useChatSessionStore( + (s) => s.markSessionCreationFailed, + ); + const resetSessionCreation = useChatSessionStore( + (s) => s.resetSessionCreation, + ); + const patchSession = useChatSessionStore((s) => s.patchSession); + const setActiveSession = useChatSessionStore((s) => s.setActiveSession); + const handleNavigateToSession = useCallback( + (sessionId: string) => { + setActiveSession(sessionId); + setChatActiveSession(sessionId); + setActiveView("chat"); + useChatStore.getState().markSessionRead(sessionId); + }, + [setActiveSession, setChatActiveSession], + ); + + useCompletionNotifications(handleNavigateToSession); + + useEffect(() => { + let didCancel = false; + let unlisten: (() => void) | null = null; + + listenSessionDeepLinkErrors(({ message }) => { + toast.error(message); + }) + .then((cleanup) => { + if (didCancel) { + cleanup(); + } else { + unlisten = cleanup; + } + }) + .catch((error) => { + console.error("Failed to listen for session deep link errors:", error); + }); + + return () => { + didCancel = true; + unlisten?.(); + }; + }, []); + const setRightRailOpen = useChatSessionStore((s) => s.setRightRailOpen); + const { selectedProvider } = useProviderSelection(); + const ensureNewSessionTarget = useNewSessionTarget(); + const resolveSessionCreationTarget = useCallback( + async ( + options: Pick, + ): Promise => { + const requestedTarget = options.executionTarget; + const requestedSelection = requestedTarget + ? gooseServeSelectionFromExecutionTarget(requestedTarget) + : undefined; + const resolution = await ensureNewSessionTarget( + requestedTarget + ? { + providerId: + requestedSelection?.providerId ?? requestedTarget.harnessId, + modelId: requestedSelection?.modelId, + } + : {}, + ); + if (resolution.status !== "ready") { + return undefined; + } + if (requestedTarget) { + return executionTargetFromModelPreference( + requestedTarget.harnessId, + resolution, + ); + } + + const modelPreference = await resolveSupportedSessionModelPreference( + resolution.providerId, + resolution.modelId, + ); + return executionTargetFromModelPreference( + resolution.providerId, + modelPreference, + ); + }, + [ensureNewSessionTarget], + ); + const { readyAgentIds } = useAgentProviderStatus(); + const defaultProviderReadinessStatus = useDefaultProviderReadinessStore( + (state) => state.readiness?.status, + ); + const providerSetupRequiredForHome = + getBuildFeatureState().byoKeyProviders && + defaultProviderReadinessStatus === "needs_setup" && + ![...readyAgentIds].some((providerId) => providerId !== "goose"); + const selectedProviderRef = useRef(selectedProvider); + selectedProviderRef.current = selectedProvider; + const projects = useProjectStore(selectProjects); + const hasFetchedProjects = useProjectStore( + (state) => state.hasFetchedProjects, + ); + const fetchProjects = useProjectStore((s) => s.fetchProjects); + const reorderProjects = useProjectStore((s) => s.reorderProjects); + const retryFailedSessionsForProjectRef = useRef< + (project: ProjectInfo) => void + >(() => {}); + const refreshProjectsAfterDialogSave = useCallback( + (savedProject: ProjectInfo) => { + useProjectStore + .getState() + .replaceProjectsFromBackend( + useProjectStore + .getState() + .projects.some((project) => project.id === savedProject.id) + ? useProjectStore + .getState() + .projects.map((project) => + project.id === savedProject.id ? savedProject : project, + ) + : [...useProjectStore.getState().projects, savedProject], + ); + retryFailedSessionsForProjectRef.current(savedProject); + }, + [], + ); + + const { + closeCreateProjectDialog, + createProjectInitialWorkingDir, + createProjectOpen, + editingProject, + handleProjectCreated, + openCreateProjectDialog, + openEditProjectDialog, + } = useProjectDialog({ + onProjectSaved: refreshProjectsAfterDialogSave, + }); + const startup = useAppStartup(); + const [startupLoadingMinElapsed, setStartupLoadingMinElapsed] = useState( + () => startup.ready, + ); + + useEffect(() => { + const timeoutId = window.setTimeout( + () => setStartupLoadingMinElapsed(true), + STARTUP_LOADING_MIN_DISPLAY_MS, + ); + + return () => window.clearTimeout(timeoutId); + }, []); + const clearGlobalComposerHandoffTimer = useCallback(() => { + if (globalComposerHandoffTimeoutRef.current !== null) { + window.clearTimeout(globalComposerHandoffTimeoutRef.current); + globalComposerHandoffTimeoutRef.current = null; + } + }, []); + + const clearGlobalComposerRouteSwapTimer = useCallback(() => { + if (globalComposerRouteSwapTimeoutRef.current !== null) { + window.clearTimeout(globalComposerRouteSwapTimeoutRef.current); + globalComposerRouteSwapTimeoutRef.current = null; + } + }, []); + + useEffect(() => { + return () => { + clearGlobalComposerHandoffTimer(); + clearGlobalComposerRouteSwapTimer(); + }; + }, [clearGlobalComposerHandoffTimer, clearGlobalComposerRouteSwapTimer]); + + const resetGlobalComposerTransition = useCallback(() => { + clearGlobalComposerHandoffTimer(); + setGlobalComposerPlacement("docked"); + setChatComposerHandoffSessionId(null); + setGlobalComposerHandoffSourceRect(null); + setGlobalComposerHandoffTargetRect(null); + }, [clearGlobalComposerHandoffTimer]); + + const finishGlobalComposerHandoff = useCallback(() => { + resetGlobalComposerTransition(); + }, [resetGlobalComposerTransition]); + + useEffect(() => { + if (globalComposerPlacement !== "handoff") { + return; + } + + if (globalComposerHandoffTimeoutRef.current !== null) { + window.clearTimeout(globalComposerHandoffTimeoutRef.current); + globalComposerHandoffTimeoutRef.current = null; + } + + if (prefersReducedMotion()) { + finishGlobalComposerHandoff(); + return; + } + + const hasMeasuredHandoff = + globalComposerHandoffSourceRect && globalComposerHandoffTargetRect; + globalComposerHandoffTimeoutRef.current = window.setTimeout( + finishGlobalComposerHandoff, + hasMeasuredHandoff + ? GLOBAL_COMPOSER_HANDOFF_MS + : GLOBAL_COMPOSER_HANDOFF_MS + 500, + ); + + return () => { + if (globalComposerHandoffTimeoutRef.current !== null) { + window.clearTimeout(globalComposerHandoffTimeoutRef.current); + globalComposerHandoffTimeoutRef.current = null; + } + }; + }, [ + finishGlobalComposerHandoff, + globalComposerHandoffSourceRect, + globalComposerHandoffTargetRect, + globalComposerPlacement, + ]); + const startupReady = startup.ready && !startup.error; + const migrationGate = useMigrationGate(startupReady); + const migrationSettled = + migrationGate.status === "ready" || migrationGate.status === "error"; + useDefaultModelGate(migrationSettled); + useSessionWindowTracking({ enabled: isMultiWindowEnabled }); + useSessionHandoffSource({ enabled: isMultiWindowEnabled }); + const lastNonSecondaryViewRef = useRef("home"); + const designSystemReturnViewRef = useRef("home"); + const homeSessionRequestRef = useRef | null>( + null, + ); + const hydratingPinnedSessionIdsRef = useRef>(new Set()); + + const hydratePinnedChatSessions = useCallback( + async (sessionIds: string[]) => { + const uniqueSessionIds = [...new Set(sessionIds)].filter(Boolean); + const sessionStore = useChatSessionStore.getState(); + const sessionsToLoad: string[] = []; + + for (const sessionId of uniqueSessionIds) { + if (hydratingPinnedSessionIdsRef.current.has(sessionId)) { + continue; + } + + const session = sessionStore.getSession(sessionId); + if (session?.creationState) { + continue; + } + + const hasMessages = hasConversationMessages( + useChatStore.getState().messagesBySession[sessionId], + ); + if (hasMessages) { + continue; + } + + sessionsToLoad.push(sessionId); + } + + if (sessionsToLoad.length === 0) { + return; + } + + const pendingSessionIds: string[] = []; + for (const sessionId of sessionsToLoad) { + useChatSessionStore + .getState() + .ensurePinnedSessionPlaceholder(sessionId); + hydratingPinnedSessionIdsRef.current.add(sessionId); + pendingSessionIds.push(sessionId); + } + + let nextIndex = 0; + + async function worker(): Promise { + while (nextIndex < pendingSessionIds.length) { + const sessionId = pendingSessionIds[nextIndex]; + nextIndex += 1; + const ok = await loadSessionMessagesAndPrepare(sessionId); + if (!ok) { + useChatSessionStore.getState().patchSession(sessionId, { + pinnedLoadState: "failed", + }); + } + hydratingPinnedSessionIdsRef.current.delete(sessionId); + } + } + + await Promise.all( + Array.from( + { + length: Math.min( + PINNED_CHAT_HYDRATION_CONCURRENCY, + pendingSessionIds.length, + ), + }, + () => worker(), + ), + ); + }, + [], + ); + + useEffect(() => { + fetchProjects(); + }, [fetchProjects]); + + useEffect(() => { + void prefetchProjectArtifactRenderer(); + }, []); + + useEffect(() => { + if ( + !activeSessionId || + !activeSessionWindowLabel || + activeSessionInHandoff + ) { + return; + } + + clearSettingsSectionUrl(); + setActiveView("home"); + setActiveSession(null); + }, [ + activeSessionId, + activeSessionInHandoff, + activeSessionWindowLabel, + setActiveSession, + ]); + + useEffect(() => { + const isViewingChat = activeView === "chat" && Boolean(activeSessionId); + setChatActiveSessionViewing(isViewingChat); + + if (isViewingChat && activeSessionId) { + useChatStore.getState().markSessionRead(activeSessionId); + } + }, [activeSessionId, activeView, setChatActiveSessionViewing]); + + useEffect(() => { + if (activeView !== "settings" && activeView !== "design-system") { + lastNonSecondaryViewRef.current = activeView; + } + }, [activeView]); + + useEffect(() => { + if (activeView === "home") { + return; + } + void prefetchProjectArtifactRenderer(); + }, [activeView]); + + useEffect(() => { + if (activeView === "builderbot" && !isBuilderbotSurfaceEnabled) { + setActiveView("home"); + } + if (activeView === "automations" && !isAutomationsFeatureEnabled) { + setActiveView("home"); + } + }, [activeView, isAutomationsFeatureEnabled, isBuilderbotSurfaceEnabled]); + + useEffect(() => { + const enabledSection = resolveEnabledSettingsSection( + activeSettingsSection, + capabilities, + ); + if (enabledSection === activeSettingsSection) { + return; + } + setActiveSettingsSection(enabledSection); + if (activeView === "settings") { + setSettingsSectionUrl(enabledSection); + } + }, [activeSettingsSection, activeView, capabilities]); + + useEffect(() => { + if (activeView !== "settings") { + setAgentBuilderSettingsReturnTarget(null); + } + }, [activeView]); + + const activeSession = activeSessionId + ? sessions.find((session) => session.id === activeSessionId) + : undefined; + const homeSession = homeSessionId + ? sessions.find((session) => session.id === homeSessionId) + : undefined; + const hasHomeSession = homeSession != null; + const currentGlobalComposerExecutionTarget = + globalComposerExecutionTarget === undefined + ? homeSession + ? (homeSession.executionTarget ?? null) + : undefined + : globalComposerExecutionTarget; + const targetLocation = useMemo( + () => + getAppNavigationLocation( + activeView, + activeSessionId, + activeSettingsSection, + skillsSkillId, + agentsPersonaId, + automationsRoute, + builderbotRoute, + activeDesignSystemSection, + ), + [ + activeDesignSystemSection, + activeSessionId, + activeSettingsSection, + activeView, + agentsPersonaId, + automationsRoute, + builderbotRoute, + skillsSkillId, + ], + ); + const { renderedLocation, isPreparingContent } = + useStagedAppContentLocation(targetLocation); + const renderedSession = + renderedLocation.view === "chat" && renderedLocation.sessionId + ? sessions.find((session) => session.id === renderedLocation.sessionId) + : undefined; + const isContextVisible = isContextPanelVisible( + activeSession, + isRightRailOpen, + ); + const rightRailLabel = isContextVisible + ? t("rightRail.close") + : t("rightRail.open"); + + useEffect(() => { + perfLog( + `[perf:nav] target selected location=${JSON.stringify(targetLocation)}`, + ); + }, [targetLocation]); + + useLayoutEffect(() => { + setTerminalRenderingSuspended(isPreparingContent); + return () => { + setTerminalRenderingSuspended(false); + }; + }, [isPreparingContent]); + + const updateNavigationAvailability = useCallback(() => { + const history = navigationHistoryRef.current; + const nextAvailability = { + canGoBack: history.index > 0, + canGoForward: history.index < history.entries.length - 1, + }; + + setNavigationAvailability((current) => + current.canGoBack === nextAvailability.canGoBack && + current.canGoForward === nextAvailability.canGoForward + ? current + : nextAvailability, + ); + }, []); + + const replaceNavigationSessionId = useCallback( + (fromSessionId: string, toSessionId: string) => { + const history = navigationHistoryRef.current; + history.entries = history.entries.map((entry) => + entry.view === "chat" && entry.sessionId === fromSessionId + ? { ...entry, sessionId: toSessionId } + : entry, + ); + updateNavigationAvailability(); + }, + [updateNavigationAvailability], + ); + + useEffect(() => { + const history = navigationHistoryRef.current; + const location = targetLocation; + const currentLocation = history.entries[history.index]; + + if (history.isApplying) { + history.isApplying = false; + if (!areAppNavigationLocationsEqual(currentLocation, location)) { + history.entries[history.index] = location; + } + updateNavigationAvailability(); + return; + } + + if (replaceNextNavigationEntryRef.current) { + replaceNextNavigationEntryRef.current = false; + history.entries[history.index] = location; + updateNavigationAvailability(); + return; + } + + if (areAppNavigationLocationsEqual(currentLocation, location)) { + updateNavigationAvailability(); + return; + } + + let nextEntries = history.entries.slice(0, history.index + 1); + nextEntries.push(location); + if (nextEntries.length > APP_NAVIGATION_HISTORY_LIMIT) { + nextEntries = nextEntries.slice( + nextEntries.length - APP_NAVIGATION_HISTORY_LIMIT, + ); + } + + history.entries = nextEntries; + history.index = nextEntries.length - 1; + updateNavigationAvailability(); + }, [targetLocation, updateNavigationAvailability]); + + useHomeSessionStateSync({ + homeSessionId, + homeSession, + homeSessionMessages, + hasHydratedSessions, + isLoading: sessionsLoading, + setHomeSessionId, + }); + + const ensureHomeSession = useCallback(async () => { + if (!hasHydratedSessions || sessionsLoading) { + return undefined; + } + + if (homeSessionRequestRef.current) { + return homeSessionRequestRef.current; + } + + const request = (async () => { + const currentProvider = () => selectedProviderRef.current ?? "goose"; + + if ( + homeSession && + !homeSession.archivedAt && + homeSession.messageCount === 0 + ) { + const project = homeSession.projectId + ? (projects.find( + (candidate) => candidate.id === homeSession.projectId, + ) ?? null) + : null; + const workingDir = await resolveSessionCwd(project); + const readLiveHomeSession = () => + useChatSessionStore.getState().getSession(homeSession.id) ?? + homeSession; + const liveHomeSession = readLiveHomeSession(); + const bootstrapTarget = liveHomeSession.executionTarget; + const uiOwnsBootstrapTarget = + liveHomeSession.executionTargetSource === "ui"; + if (uiOwnsBootstrapTarget && !bootstrapTarget) { + return liveHomeSession; + } + // UI ownership preserves provider-only targets and explicit clears as + // well as full model selections. ACP model snapshots are also stable + // bootstrap targets; neither path may be re-seeded from preferences. + if ( + bootstrapTarget && + (uiOwnsBootstrapTarget || isModelExecutionTarget(bootstrapTarget)) + ) { + const bootstrapSelection = + gooseServeSelectionFromExecutionTarget(bootstrapTarget); + const target = await ensureNewSessionTarget( + { + providerId: + bootstrapSelection.providerId ?? bootstrapTarget.harnessId, + modelId: bootstrapSelection.modelId, + }, + { onUnavailable: "silent" }, + ); + if (target.status !== "ready") return liveHomeSession; + if ( + !sameSessionExecutionTarget( + readLiveHomeSession().executionTarget, + bootstrapTarget, + ) + ) { + return readLiveHomeSession(); + } + const validatedBootstrapTarget = executionTargetFromModelPreference( + bootstrapTarget.harnessId, + target, + ); + const result = await transitionSessionTarget({ + sessionId: homeSession.id, + target: validatedBootstrapTarget, + workingDir, + requireReasoningEffort: !liveHomeSession.reasoningEffort, + }); + if (!result.applied) { + return liveHomeSession; + } + if ( + !sameSessionExecutionTarget( + readLiveHomeSession().executionTarget, + bootstrapTarget, + ) + ) { + return readLiveHomeSession(); + } + return readLiveHomeSession(); + } + + const harnessAtStart = currentProvider(); + const sessionModelPreference = + await resolveSupportedSessionModelPreference(harnessAtStart); + const resolvedHarnessId = currentProvider(); + const targetToApply = + resolvedHarnessId === harnessAtStart + ? executionTargetFromModelPreference( + resolvedHarnessId, + sessionModelPreference, + ) + : normalizeSessionExecutionTarget({ + harnessId: resolvedHarnessId, + }); + if ( + sameSessionExecutionTarget( + liveHomeSession.executionTarget, + targetToApply, + ) && + liveHomeSession.workingDir === workingDir && + !targetToApply.modelProviderId + ) { + return liveHomeSession; + } + const targetSelection = + gooseServeSelectionFromExecutionTarget(targetToApply); + const target = await ensureNewSessionTarget( + { + providerId: targetSelection.providerId ?? targetToApply.harnessId, + modelId: targetSelection.modelId, + }, + { onUnavailable: "silent" }, + ); + if (target.status !== "ready") return liveHomeSession; + if ( + !sameSessionExecutionTarget( + readLiveHomeSession().executionTarget, + bootstrapTarget, + ) + ) { + return readLiveHomeSession(); + } + const validatedTargetToApply = executionTargetFromModelPreference( + targetToApply.harnessId, + target, + ); + const requestId = createModelSelectionRequestId(); + beginModelSelectionIntent(homeSession.id, { + requestId, + target: validatedTargetToApply, + previousTarget: bootstrapTarget, + }); + + try { + const result = await transitionSessionTarget({ + sessionId: homeSession.id, + target: validatedTargetToApply, + workingDir, + requireReasoningEffort: !liveHomeSession.reasoningEffort, + requestId, + }); + const intentStillMatches = clearCurrentModelSelectionIntent( + homeSession.id, + requestId, + ); + if (!result.applied || !intentStillMatches) { + return readLiveHomeSession(); + } + return readLiveHomeSession(); + } catch (error) { + if (clearCurrentModelSelectionIntent(homeSession.id, requestId)) { + replaceSessionTargetAfterDispatch(homeSession.id, bootstrapTarget); + } + throw error; + } + } + + const composerTargetAtStart = globalComposerExecutionTargetRef.current; + const workingDir = await resolveSessionCwd(null); + const harnessId = currentProvider(); + const sessionModelPreference = + await resolveSupportedSessionModelPreference(harnessId); + const executionTarget = executionTargetFromModelPreference( + harnessId, + sessionModelPreference, + ); + const executionSelection = + gooseServeSelectionFromExecutionTarget(executionTarget); + const target = await ensureNewSessionTarget( + { + providerId: + executionSelection.providerId ?? executionTarget.harnessId, + modelId: executionSelection.modelId, + }, + { onUnavailable: "silent" }, + ); + if (target.status !== "ready") return null; + const finalComposerTarget = globalComposerExecutionTargetRef.current; + const resolvedExecutionTarget = + finalComposerTarget !== composerTargetAtStart + ? (finalComposerTarget ?? + normalizeSessionExecutionTarget({ harnessId: currentProvider() })) + : executionTargetFromModelPreference( + executionTarget.harnessId, + target, + ); + const session = await createSession({ + title: DEFAULT_CHAT_TITLE, + executionTarget: resolvedExecutionTarget, + workingDir, + }); + setHomeSessionId(session.id); + return session; + })(); + + homeSessionRequestRef.current = request; + try { + return await request; + } finally { + if (homeSessionRequestRef.current === request) { + homeSessionRequestRef.current = null; + } + } + }, [ + createSession, + hasHydratedSessions, + homeSession, + projects, + sessionsLoading, + ensureNewSessionTarget, + ]); + + useEffect(() => { + if ( + activeView !== "home" || + !migrationSettled || + providerSetupRequiredForHome + ) { + return; + } + void ensureHomeSession().catch((error) => { + console.error("Failed to ensure Home session:", error); + }); + }, [ + activeView, + ensureHomeSession, + migrationSettled, + providerSetupRequiredForHome, + ]); + + const startDraftSessionCreation = useCallback( + ({ + session, + sessionExecutionTarget, + workingDir, + projectId, + onReady, + onCreationFailed, + }: { + session: ChatSession; + sessionExecutionTarget: SessionExecutionTarget; + workingDir: MaybePromise; + projectId?: string; + onReady?: (result: DraftSessionCreationReady) => Promise | void; + onCreationFailed?: (error: unknown) => Promise | void; + }) => { + let hasHandledCreationFailure = false; + let createdBackendSessionId: string | null = null; + const resolveDraftTarget = ( + draft: ChatSession | undefined, + fallback: SessionExecutionTarget, + ): SessionExecutionTarget => { + if (draft?.executionTarget) return draft.executionTarget; + if (draft?.executionTargetSource === "ui") { + throw new Error( + "Select a model before creating this unresolved session.", + ); + } + return fallback; + }; + const handleCreationFailure = async ( + error: unknown, + ): Promise => { + if (!onCreationFailed || hasHandledCreationFailure) { + return null; + } + hasHandledCreationFailure = true; + try { + await onCreationFailed(error); + return null; + } catch (cleanupError) { + console.error( + "Failed to clean up project workspace startup after session creation failure:", + cleanupError, + ); + return cleanupError; + } + }; + const appendCleanupFailure = ( + message: string, + cleanupError: unknown | null, + ): string => { + if (!cleanupError) { + return message; + } + const cleanupMessage = + cleanupError instanceof Error + ? cleanupError.message + : String(cleanupError); + return `${message} Workspace cleanup also failed: ${cleanupMessage}`; + }; + void Promise.resolve(workingDir) + .then(async (resolvedWorkingDir) => { + const liveDraft = useChatSessionStore + .getState() + .getSession(session.id); + const requestedTarget = resolveDraftTarget( + liveDraft, + sessionExecutionTarget, + ); + const creationSelection = + gooseServeSelectionFromExecutionTarget(requestedTarget); + return acpCreateSession( + creationSelection.providerId ?? requestedTarget.harnessId, + resolvedWorkingDir, + { + projectId, + modelId: requestedTarget.modelId, + // The draft is already interactive. Construct its provider now so + // a selection made while creation is in flight can be applied to + // the backend session as soon as it exists. + deferProviderSetup: false, + }, + ).then(({ sessionId, configOptionsSnapshot }) => { + createdBackendSessionId = sessionId; + return { + sessionId, + configOptionsSnapshot, + sessionExecutionTarget: requestedTarget, + workingDir: resolvedWorkingDir, + }; + }); + }) + .then( + async ({ + sessionId, + configOptionsSnapshot, + sessionExecutionTarget, + workingDir, + }) => { + const sessionStore = useChatSessionStore.getState(); + const latestSession = sessionStore.getSession(session.id); + if (!latestSession || latestSession.archivedAt) { + await handleCreationFailure( + new Error( + "Draft session disappeared before session creation completed.", + ), + ); + return; + } + let appliedTarget = sessionExecutionTarget; + let resolvedConfigOptionsSnapshot = configOptionsSnapshot; + const reconcileLatestDraftSelection = async () => { + while (true) { + const liveDraft = useChatSessionStore + .getState() + .getSession(session.id); + const latestTarget = resolveDraftTarget( + liveDraft, + appliedTarget, + ); + if (sameSessionExecutionTarget(latestTarget, appliedTarget)) { + return latestTarget; + } + const result = await transitionSessionTarget({ + sessionId, + target: latestTarget, + workingDir, + }); + if (!result.applied) { + throw new Error( + "Draft session selection was superseded during creation.", + ); + } + const effectiveTarget = result.resolvedTarget ?? latestTarget; + resolvedConfigOptionsSnapshot = + result.configOptionsSnapshot ?? resolvedConfigOptionsSnapshot; + if ( + result.resolvedTarget && + !sameSessionExecutionTarget( + result.resolvedTarget, + latestTarget, + ) + ) { + const liveDraftAfterRepair = useChatSessionStore + .getState() + .getSession(session.id); + if ( + sameSessionExecutionTarget( + liveDraftAfterRepair?.executionTarget, + latestTarget, + ) + ) { + replaceSessionTargetAfterDispatch( + session.id, + result.resolvedTarget, + ); + } + } + appliedTarget = effectiveTarget; + } + }; + + await reconcileLatestDraftSelection(); + if (onReady) { + await onReady({ + backendSessionId: sessionId, + configOptionsSnapshot: resolvedConfigOptionsSnapshot, + }); + const pendingReasoningEffort = useChatSessionStore + .getState() + .getSession(session.id)?.reasoningEffort; + if (pendingReasoningEffort) { + resolvedConfigOptionsSnapshot = { + ...resolvedConfigOptionsSnapshot, + reasoningEffort: pendingReasoningEffort, + }; + } + } + const latestTarget = await reconcileLatestDraftSelection(); + const promotedTarget = + !latestTarget.modelId && resolvedConfigOptionsSnapshot?.model + ? (materializeSessionExecutionModel( + latestTarget, + resolvedConfigOptionsSnapshot.model, + ) ?? latestTarget) + : latestTarget; + + const sessionStoreAfterReady = useChatSessionStore.getState(); + const latestSessionAfterReady = sessionStoreAfterReady.getSession( + session.id, + ); + if ( + !latestSessionAfterReady || + latestSessionAfterReady.archivedAt + ) { + await handleCreationFailure( + new Error( + "Draft session disappeared before session creation completed.", + ), + ); + return; + } + const latestSessionPatch = { + intent: latestSessionAfterReady.intent, + agentBuilderOpen: latestSessionAfterReady.agentBuilderOpen, + agentBuilderContextState: + latestSessionAfterReady.agentBuilderContextState, + targetAgentPath: latestSessionAfterReady.targetAgentPath, + targetAgentSlug: latestSessionAfterReady.targetAgentSlug, + targetAgentDraftState: + latestSessionAfterReady.targetAgentDraftState, + targetAgentDraftSaved: + latestSessionAfterReady.targetAgentDraftSaved, + updatedAt: latestSessionAfterReady.updatedAt, + }; + const shouldRemainActive = + sessionStoreAfterReady.activeSessionId === session.id; + const pendingSelectionIntent = getModelSelectionIntent(session.id); + if ( + pendingSelectionIntent && + isModelExecutionTarget(pendingSelectionIntent.target) && + pendingSelectionIntent.preferenceAgentId + ) { + setStoredModelPreference( + pendingSelectionIntent.preferenceAgentId, + { + modelId: pendingSelectionIntent.target.modelId, + modelName: pendingSelectionIntent.target.modelName, + providerId: pendingSelectionIntent.target.modelProviderId, + }, + ); + clearCurrentModelSelectionIntent( + session.id, + pendingSelectionIntent.requestId, + ); + } + promoteChatSessionId(session.id, sessionId); + transferSessionTargetOwnership(session.id, sessionId); + promoteDraftSession(session.id, sessionId, { + executionTarget: promotedTarget, + workingDir, + ...latestSessionPatch, + ...(resolvedConfigOptionsSnapshot?.reasoningEffort + ? { + reasoningEffort: + resolvedConfigOptionsSnapshot.reasoningEffort, + } + : {}), + }); + useHomeWidgetStore + .getState() + .replaceChatPinSessionId(session.id, sessionId); + replaceNavigationSessionId(session.id, sessionId); + if (shouldRemainActive) { + setActiveSession(sessionId); + setChatActiveSession(sessionId); + } + }, + ) + .catch(async (error) => { + const chatStore = useChatStore.getState(); + if (createdBackendSessionId) { + try { + await archiveSessionApi(createdBackendSessionId); + } catch (archiveError) { + console.error( + "Failed to archive backend session after draft startup failed:", + archiveError, + ); + } + } + const cleanupError = await handleCreationFailure(error); + + // Before falling back to the opaque backend error, check whether the + // failure is actually a missing project folder. We confirm against + // the real filesystem rather than string-matching the error text, so + // unrelated failures keep their generic message. + const project = projectId + ? useProjectStore + .getState() + .projects.find((candidate) => candidate.id === projectId) + : undefined; + if (project) { + try { + const missing = await findMissingProjectDirs(project); + if (missing.length > 0) { + const message = t( + missing.length === 1 + ? "toolbar.sessionMissingProjectDir" + : "toolbar.sessionMissingProjectDirs", + { paths: missing.join(", ") }, + ); + const messageWithCleanupStatus = appendCleanupFailure( + message, + cleanupError, + ); + markSessionCreationFailed(session.id, messageWithCleanupStatus); + chatStore.addMessage( + session.id, + createSystemNotificationMessage( + messageWithCleanupStatus, + "error", + { + type: "editProject", + projectId: project.id, + }, + ), + ); + chatStore.setError(session.id, messageWithCleanupStatus); + return; + } + } catch (checkError) { + console.error( + "Failed to check project directories after session creation failure:", + checkError, + ); + } + } + + const message = formatAcpErrorMessage( + error, + "Failed to create session.", + ); + const messageWithCleanupStatus = appendCleanupFailure( + message, + cleanupError, + ); + markSessionCreationFailed(session.id, messageWithCleanupStatus); + chatStore.addMessage( + session.id, + createSystemNotificationMessage(messageWithCleanupStatus, "error"), + ); + chatStore.setError(session.id, messageWithCleanupStatus); + }); + }, + [ + t, + markSessionCreationFailed, + promoteChatSessionId, + promoteDraftSession, + replaceNavigationSessionId, + setActiveSession, + setChatActiveSession, + ], + ); + + // When a project is edited and saved, any of its sessions that previously + // failed to create because their working folder was missing can be retried: + // the draft id is still valid, and editing the project may have fixed the + // path. We re-resolve the working dir from the *updated* project (the folder + // is what changed), clear the stale error notification + runtime error, and + // hand the draft back to startDraftSessionCreation. If the edit didn't + // actually fix the folders, we skip the retry so the existing error stands. + const retryFailedSessionsForProject = useCallback( + (savedProject: ProjectInfo) => { + void (async () => { + // Reload projects so the rest of the UI reflects the saved edit. + await fetchProjects(); + + // Prefer the freshest copy from the store; fall back to the saved arg. + const updatedProject = + useProjectStore + .getState() + .projects.find((candidate) => candidate.id === savedProject.id) ?? + savedProject; + + const sessionStore = useChatSessionStore.getState(); + const failedSessions = sessionStore.sessions.filter( + (candidate) => + candidate.creationState === "failed" && + candidate.projectId === updatedProject.id && + !candidate.archivedAt, + ); + if (failedSessions.length === 0) { + return; + } + + // Only retry if the edit actually fixed the missing folders; otherwise + // the same error would immediately reappear. + try { + const missing = await findMissingProjectDirs(updatedProject); + if (missing.length > 0) { + return; + } + } catch (error) { + console.error( + "Failed to re-check project directories before retrying session creation:", + error, + ); + return; + } + + const chatStore = useChatStore.getState(); + for (const session of failedSessions) { + const sessionExecutionTarget = session.executionTarget; + if (!sessionExecutionTarget) { + continue; + } + // Drop the stale missing-folder error notification so the retry + // doesn't stack a duplicate, then clear the runtime + creation error. + const messages = chatStore.messagesBySession[session.id] ?? []; + for (const message of messages) { + const isMissingFolderNotice = message.content.some( + (content) => + isSystemNotification(content) && + content.action?.type === "editProject" && + content.action.projectId === updatedProject.id, + ); + if (isMissingFolderNotice) { + chatStore.removeMessage(session.id, message.id); + } + } + chatStore.setError(session.id, null); + resetSessionCreation(session.id); + + startDraftSessionCreation({ + session, + sessionExecutionTarget, + workingDir: resolveSessionCwd(updatedProject), + projectId: updatedProject.id, + }); + } + })(); + }, + [fetchProjects, resetSessionCreation, startDraftSessionCreation], + ); + retryFailedSessionsForProjectRef.current = retryFailedSessionsForProject; + + const createNewTab = useCallback( + async ( + title = DEFAULT_CHAT_TITLE, + project?: ProjectInfo, + options: { + activate?: boolean; + reuseExistingDraft?: boolean; + executionTarget?: SessionExecutionTarget; + reasoningEffort?: GlobalComposeOptions["reasoningEffort"]; + } = {}, + ) => { + const shouldActivate = options.activate !== false; + const tStart = performance.now(); + perfLog( + `[perf:newtab] createNewTab start (project=${project?.id ?? "none"})`, + ); + const sessionExecutionTarget = + await resolveSessionCreationTarget(options); + if (!sessionExecutionTarget) return undefined; + const sessionState = useChatSessionStore.getState(); + const chatState = useChatStore.getState(); + // New chats always start at the project default folder; worktree + // selections in other chats are per-chat state and do not carry over. + const existingDraft = findExistingDraft({ + sessions: sessionState.sessions, + activeSessionId: sessionState.activeSessionId, + draftsBySession: chatState.draftsBySession, + messagesBySession: chatState.messagesBySession, + sessionIdsWithTerminals: getChatSessionIdsWithTerminals(), + request: { + title, + projectId: project?.id, + executionTarget: sessionExecutionTarget, + reasoningEffortValue: options.reasoningEffort?.value, + }, + allowDraftReuse: options.reuseExistingDraft !== false, + }); + + if ( + existingDraft && + (chatState.queuedMessageBySession[existingDraft.id]?.length ?? 0) === 0 + ) { + if (shouldActivate) { + clearSettingsSectionUrl(); + setActiveSession(existingDraft.id); + setActiveView("chat"); + setChatActiveSession(existingDraft.id); + } + perfLog( + `[perf:newtab] ${existingDraft.id.slice(0, 8)} reused draft in ${(performance.now() - tStart).toFixed(1)}ms`, + ); + return existingDraft; + } + + if (!shouldActivate) { + const workingDir = await resolveSessionCwd(project); + const session = await createSession({ + title, + projectId: project?.id, + executionTarget: sessionExecutionTarget, + workingDir, + }); + perfLog( + `[perf:newtab] ${session.id.slice(0, 8)} created session in ${(performance.now() - tStart).toFixed(1)}ms`, + ); + return session; + } + + const optimisticWorkingDir = getOptimisticSessionCwd(project); + const session = createDraftSession({ + title, + projectId: project?.id, + executionTarget: sessionExecutionTarget, + workingDir: optimisticWorkingDir, + }); + clearSettingsSectionUrl(); + setActiveSession(session.id); + setActiveView("chat"); + setChatActiveSession(session.id); + perfLog( + `[perf:newtab] ${session.id.slice(0, 8)} created draft in ${(performance.now() - tStart).toFixed(1)}ms`, + ); + startDraftSessionCreation({ + session, + sessionExecutionTarget, + workingDir: resolveSessionCwd(project), + projectId: project?.id, + onReady: applyReasoningEffortAfterDraftCreation( + session.id, + options.reasoningEffort, + ), + }); + return session; + }, + [ + createSession, + createDraftSession, + resolveSessionCreationTarget, + setActiveSession, + setChatActiveSession, + startDraftSessionCreation, + ], + ); + + const agentBuilder = useAgentBuilderCoordinator({ + startupReady: startup.ready, + createNewTab: async (title, options) => { + const session = await createNewTab(title, undefined, options); + if (!session) { + throw new Error(t("settings:providers.setupRequired.toast")); + } + return session; + }, + closeSession: (sessionId) => closeAgentBuilderSessionRef.current(sessionId), + navigateChat: (sessionId) => navigateAgentBuilderChatRef.current(sessionId), + }); + + const handleAutomationBuilderLeaveActionChange = useCallback( + (action: AutomationBuilderLeaveAction | null) => { + automationBuilderLeaveActionRef.current = action; + setAutomationBuilderHasUnsavedChanges(Boolean(action?.hasUnsavedChanges)); + }, + [], + ); + + const guardAutomationBuilderNavigation = useCallback( + (next: () => void, onCancel?: () => void) => { + const action = automationBuilderLeaveActionRef.current; + if ( + activeView === "automations" && + automationsRoute.surface === "builder" && + automationBuilderHasUnsavedChanges && + action?.hasUnsavedChanges + ) { + // A newer guarded navigation supersedes any pending one; settle the + // old entry as cancelled so its caller is not left waiting forever. + pendingAutomationNavigationRef.current?.onCancel?.(); + pendingAutomationNavigationRef.current = { next, onCancel }; + setAutomationLeavePromptOpen(true); + return; + } + + next(); + }, + [activeView, automationBuilderHasUnsavedChanges, automationsRoute.surface], + ); + + const guardAppNavigation = useCallback( + (next: () => void, onCancel?: () => void) => { + agentBuilder.guardNavigation(() => { + guardAutomationBuilderNavigation(next, onCancel); + }, onCancel); + }, + [agentBuilder.guardNavigation, guardAutomationBuilderNavigation], + ); + + const continuePendingAutomationNavigation = useCallback(() => { + const pending = pendingAutomationNavigationRef.current; + pendingAutomationNavigationRef.current = null; + pending?.next(); + }, []); + + const cancelAutomationLeave = useCallback(() => { + const pending = pendingAutomationNavigationRef.current; + pendingAutomationNavigationRef.current = null; + setAutomationLeavePromptOpen(false); + pending?.onCancel?.(); + }, []); + + const discardAutomationLeave = useCallback(() => { + automationBuilderLeaveActionRef.current?.discard(); + automationBuilderLeaveActionRef.current = null; + setAutomationBuilderHasUnsavedChanges(false); + setAutomationLeavePromptOpen(false); + continuePendingAutomationNavigation(); + }, [continuePendingAutomationNavigation]); + + const saveAutomationLeave = useCallback(async () => { + const action = automationBuilderLeaveActionRef.current; + if (!action) { + discardAutomationLeave(); + return; + } + + setAutomationLeaveSaving(true); + try { + const saved = await action.save(); + if (saved === false) { + return; + } + automationBuilderLeaveActionRef.current = null; + setAutomationBuilderHasUnsavedChanges(false); + setAutomationLeavePromptOpen(false); + continuePendingAutomationNavigation(); + } finally { + setAutomationLeaveSaving(false); + } + }, [continuePendingAutomationNavigation, discardAutomationLeave]); + + const createNewProjectDraft = useCallback( + async ( + title = DEFAULT_CHAT_TITLE, + project: ProjectInfo, + options: ProjectChatDraftOptions = {}, + ) => { + perfLog( + `[perf:newtab] createNewProjectDraft start (project=${project.id})`, + ); + const sessionExecutionTarget = + await resolveSessionCreationTarget(options); + if (!sessionExecutionTarget) return undefined; + const sessionState = useChatSessionStore.getState(); + const chatState = useChatStore.getState(); + const needsStartup = + workspaceRepository.mode === "multi" && + project.projectWorkspaces.some( + (workspace) => workspace.startupMode !== "none", + ); + const existingDraft = findExistingDraft({ + sessions: sessionState.sessions, + activeSessionId: sessionState.activeSessionId, + draftsBySession: chatState.draftsBySession, + messagesBySession: chatState.messagesBySession, + sessionIdsWithTerminals: getChatSessionIdsWithTerminals(), + request: { + title, + projectId: project.id, + executionTarget: sessionExecutionTarget, + reasoningEffortValue: options.reasoningEffort?.value, + }, + allowDraftReuse: options.reuseExistingDraft !== false && !needsStartup, + }); + if ( + existingDraft && + (chatState.queuedMessageBySession[existingDraft.id]?.length ?? 0) === 0 + ) { + clearSettingsSectionUrl(); + setActiveSession(existingDraft.id); + setActiveView("chat"); + setChatActiveSession(existingDraft.id); + return existingDraft; + } + const asIs = + workspaceRepository.mode === "multi" + ? planProjectChatWorkspacesAsIs(project) + : null; + const session = createDraftSession({ + title, + projectId: project.id, + executionTarget: sessionExecutionTarget, + workingDir: getOptimisticSessionCwd(project), + workspaceAttachments: needsStartup + ? asIs?.workspaceAttachments.filter( + (_, index) => + project.projectWorkspaces[index]?.startupMode === "none", + ) + : asIs?.workspaceAttachments, + }); + clearSettingsSectionUrl(); + setActiveSession(session.id); + setActiveView("chat"); + setChatActiveSession(session.id); + startDraftSessionCreation({ + session, + sessionExecutionTarget, + workingDir: resolveSessionCwd(project), + projectId: project.id, + onReady: applyReasoningEffortAfterDraftCreation( + session.id, + options.reasoningEffort, + ), + }); + return session; + }, + [ + createDraftSession, + resolveSessionCreationTarget, + setActiveSession, + setChatActiveSession, + startDraftSessionCreation, + workspaceRepository.mode, + ], + ); + + const createBackgroundDraftChat = useCallback( + async ( + title = DEFAULT_CHAT_TITLE, + project?: ProjectInfo, + options: { + executionTarget?: SessionExecutionTarget; + reasoningEffort?: GlobalComposeOptions["reasoningEffort"]; + } = {}, + ) => { + const tStart = performance.now(); + perfLog( + `[perf:newtab] createBackgroundDraftChat start (project=${project?.id ?? "none"})`, + ); + const sessionExecutionTarget = + await resolveSessionCreationTarget(options); + if (!sessionExecutionTarget) return undefined; + const sessionState = useChatSessionStore.getState(); + const chatState = useChatStore.getState(); + const existingDraft = findExistingDraft({ + sessions: sessionState.sessions, + activeSessionId: sessionState.activeSessionId, + draftsBySession: chatState.draftsBySession, + messagesBySession: chatState.messagesBySession, + sessionIdsWithTerminals: getChatSessionIdsWithTerminals(), + request: { + title, + projectId: project?.id, + executionTarget: sessionExecutionTarget, + reasoningEffortValue: options.reasoningEffort?.value, + }, + }); + + if ( + existingDraft && + (chatState.queuedMessageBySession[existingDraft.id]?.length ?? 0) === 0 + ) { + perfLog( + `[perf:newtab] ${existingDraft.id.slice(0, 8)} reused background draft in ${(performance.now() - tStart).toFixed(1)}ms`, + ); + return existingDraft; + } + + const optimisticWorkingDir = getOptimisticSessionCwd(project); + const session = createDraftSession({ + title, + projectId: project?.id, + executionTarget: sessionExecutionTarget, + workingDir: optimisticWorkingDir, + }); + perfLog( + `[perf:newtab] ${session.id.slice(0, 8)} created background draft in ${(performance.now() - tStart).toFixed(1)}ms`, + ); + startDraftSessionCreation({ + session, + sessionExecutionTarget, + workingDir: resolveSessionCwd(project), + projectId: project?.id, + onReady: applyReasoningEffortAfterDraftCreation( + session.id, + options.reasoningEffort, + ), + }); + return session; + }, + [ + createDraftSession, + resolveSessionCreationTarget, + startDraftSessionCreation, + ], + ); + + const activateDeferredChatSession = useCallback( + (sessionId: string) => { + const liveSessionId = resolveLiveSessionId(sessionId); + if (!liveSessionId) { + return; + } + clearSettingsSectionUrl(); + setChatComposerHandoffSessionId(liveSessionId); + setActiveSession(liveSessionId); + setActiveView("chat"); + setChatActiveSession(liveSessionId); + }, + [setActiveSession, setChatActiveSession], + ); + + const closeWorkspaceName = cancelWorkspaceNameRequest; + const submitWorkspaceName = submitWorkspaceNameRequest; + + const handleStartChatFromProject = useCallback( + (project: ProjectInfo) => { + guardAppNavigation(() => { + void createNewProjectDraft(DEFAULT_CHAT_TITLE, project).catch( + (error) => { + logProjectChatStartError( + "Failed to start chat from project:", + error, + ); + }, + ); + }); + }, + [createNewProjectDraft, guardAppNavigation], + ); + + const handleStartProjectChat = useCallback( + (projectId: string) => { + const project = projects.find((candidate) => candidate.id === projectId); + if (project) { + guardAppNavigation(() => { + void createNewProjectDraft(DEFAULT_CHAT_TITLE, project).catch( + (error) => { + logProjectChatStartError("Failed to start project chat:", error); + }, + ); + }); + } + }, + [createNewProjectDraft, projects, guardAppNavigation], + ); + + const handleStartChatWithSkill = useCallback( + ( + skill: SkillInfo, + projectId?: string | null, + onNavigationAccepted?: () => void, + ) => { + guardAppNavigation(() => { + onNavigationAccepted?.(); + const project = projectId + ? projects.find((candidate) => candidate.id === projectId) + : undefined; + const createChat = project + ? createNewProjectDraft(DEFAULT_CHAT_TITLE, project) + : createNewTab(DEFAULT_CHAT_TITLE); + + void createChat + .then((session) => { + if (session) { + useChatStore + .getState() + .setSkillDrafts(session.id, [toChatSkillDraft(skill)]); + } + }) + .catch((error) => { + logProjectChatStartError("Failed to start chat with skill:", error); + }); + }); + }, + [createNewProjectDraft, createNewTab, projects, guardAppNavigation], + ); + + const primeGlobalComposerFromHomeStarter = useCallback( + (request: Omit) => { + guardAppNavigation(() => { + clearGlobalComposerHandoffTimer(); + setChatComposerHandoffSessionId(null); + setGlobalComposerHandoffSourceRect(null); + setGlobalComposerHandoffTargetRect(null); + setGlobalComposerPlacement("docked"); + globalComposerStarterRequestIdRef.current += 1; + setGlobalComposerStarterRequest({ + ...request, + id: globalComposerStarterRequestIdRef.current, + }); + setGlobalComposerFocusRequest((focusRequest) => focusRequest + 1); + }); + }, + [clearGlobalComposerHandoffTimer, guardAppNavigation], + ); + + const handleTagHomeComposerSkill = useCallback( + (skill: SkillInfo) => { + primeGlobalComposerFromHomeStarter({ + skill: toChatSkillDraft(skill), + }); + }, + [primeGlobalComposerFromHomeStarter], + ); + + const handleTagHomeComposerAgent = useCallback( + (agentId: string) => { + primeGlobalComposerFromHomeStarter({ + personaId: agentId, + }); + }, + [primeGlobalComposerFromHomeStarter], + ); + + const handleTagHomeComposerProject = useCallback( + (projectId: string) => { + primeGlobalComposerFromHomeStarter({ + projectId, + }); + }, + [primeGlobalComposerFromHomeStarter], + ); + + const handleGlobalComposerStarterRequestConsumed = useCallback( + (requestId: number) => { + setGlobalComposerStarterRequest((current) => + current?.id === requestId ? null : current, + ); + }, + [], + ); + + const handleStartChatWithAgent = useCallback( + (agentId: string, onNavigationAccepted?: () => void) => { + guardAppNavigation(() => { + onNavigationAccepted?.(); + if (activeView === "agents" && agentsPersonaId === agentId) { + setGlobalComposerFocusRequest((request) => request + 1); + return; + } + + const agentState = useAgentStore.getState(); + const persona = agentState.personas.find( + (candidate) => candidate.id === agentId, + ); + const cachedModels = [ + ...useProviderModelCacheStore.getState().providers, + ].flatMap(([providerId, entry]) => + entry.models.map((model) => ({ + ...model, + providerId: model.providerId ?? providerId, + })), + ); + const executionTarget = personaExecutionTarget(persona, { + providers: agentState.providers, + models: cachedModels, + catalogEntries: getProviderCatalog(), + }); + + void createNewTab(DEFAULT_CHAT_TITLE, undefined, { + executionTarget, + }) + .then((session) => { + if (!session) return; + patchSession(session.id, { personaId: agentId }); + }) + .catch((error) => { + console.error("Failed to start chat with agent:", error); + }); + }); + }, + [ + activeView, + agentsPersonaId, + createNewTab, + patchSession, + guardAppNavigation, + ], + ); + + const handleGlobalComposerReasoningEffortChange = useCallback( + (value: string) => { + if (!homeSessionId || !homeSession?.reasoningEffort) { + return; + } + const current = homeSession.reasoningEffort; + if (current.currentValue === value) { + return; + } + + patchSession(homeSessionId, { + reasoningEffort: { + ...current, + currentValue: value, + }, + }); + + const targetAtRequest = homeSession.executionTarget; + const { providerId, modelId } = + gooseServeSelectionFromExecutionTarget(targetAtRequest); + void acpSetSessionConfigOption(homeSessionId, current.configId, value, { + providerId, + modelId, + reasoningEffortValue: value, + }).catch((error) => { + const liveSession = useChatSessionStore + .getState() + .getSession(homeSessionId); + if ( + !sameSessionExecutionTarget( + liveSession?.executionTarget, + targetAtRequest, + ) || + liveSession?.reasoningEffort?.currentValue !== value + ) { + return; + } + console.error("Failed to set Home reasoning effort:", error); + patchSession(homeSessionId, { + reasoningEffort: current, + }); + }); + }, + [ + homeSession?.executionTarget, + homeSession?.reasoningEffort, + homeSessionId, + patchSession, + ], + ); + + const syncGlobalComposerExecutionTargetToHome = useCallback( + (sessionId: string, requestedTarget: SessionExecutionTarget) => { + const sessionStore = useChatSessionStore.getState(); + const liveHomeSession = sessionStore.getSession(sessionId); + if (!liveHomeSession) { + return undefined; + } + const project = liveHomeSession.projectId + ? (useProjectStore + .getState() + .projects.find( + (candidate) => candidate.id === liveHomeSession.projectId, + ) ?? null) + : null; + + const requestId = createModelSelectionRequestId(); + const target = normalizeSessionExecutionTarget(requestedTarget); + beginModelSelectionIntent(sessionId, { + requestId, + target, + previousTarget: liveHomeSession.executionTarget, + }); + + void (async () => { + try { + const workingDir = await resolveSessionCwd( + project, + liveHomeSession.workingDir, + ); + if (!isCurrentModelSelectionIntent(sessionId, requestId)) { + return; + } + const result = await transitionSessionTarget({ + sessionId, + target, + workingDir, + requireReasoningEffort: true, + requestId, + }); + const intentStillMatches = clearCurrentModelSelectionIntent( + sessionId, + requestId, + ); + if (!result.applied || !intentStillMatches) { + return; + } + + if (!sameSessionExecutionTarget(result.target, target)) { + setGlobalComposerExecutionTarget(result.target); + } + } catch (error) { + if (clearCurrentModelSelectionIntent(sessionId, requestId)) { + const previousTarget = liveHomeSession.executionTarget; + replaceSessionTargetAfterDispatch(sessionId, previousTarget); + setGlobalComposerExecutionTarget(previousTarget ?? null); + showModelSwitchErrorToast({ + modelName: target.modelName ?? target.modelId ?? target.harnessId, + fallbackModelName: + liveHomeSession.executionTarget?.modelName ?? + liveHomeSession.executionTarget?.modelId ?? + null, + }); + } + console.error( + "Failed to apply the selected Home execution target:", + error, + ); + } + })(); + + return () => { + clearCurrentModelSelectionIntent(sessionId, requestId); + }; + }, + [], + ); + + useEffect(() => { + if (!globalComposerExecutionTarget || !homeSessionId || !hasHomeSession) { + return; + } + + return syncGlobalComposerExecutionTargetToHome( + homeSessionId, + globalComposerExecutionTarget, + ); + }, [ + globalComposerExecutionTarget, + hasHomeSession, + homeSessionId, + syncGlobalComposerExecutionTargetToHome, + ]); + + const handleGlobalComposerExecutionTargetChange = useCallback( + (target: SessionExecutionTarget | null) => { + globalComposerExecutionTargetRef.current = target; + setGlobalComposerExecutionTarget(target); + if (!target && homeSessionId) { + clearCurrentModelSelectionIntent(homeSessionId); + } + }, + [homeSessionId], + ); + + const handleGlobalCompose = useCallback( + ( + text: string, + options?: GlobalComposeOptions, + internalOptions?: { + showQueuedHandoff?: boolean; + onSettled?: (didStart: boolean) => void; + }, + ) => { + const project = options?.projectId + ? projects.find((candidate) => candidate.id === options.projectId) + : undefined; + const requiresProjectWorkspaceDraftPlan = + workspaceRepository.mode === "multi" && + Boolean(project?.projectWorkspaces.length); + const shouldRunComposerHandoff = + globalComposerPlacement === "centered" && + !requiresProjectWorkspaceDraftPlan; + if (shouldRunComposerHandoff) { + clearGlobalComposerHandoffTimer(); + setGlobalComposerPlacement("handoff"); + setChatComposerHandoffRequest((request) => request + 1); + setChatComposerHandoffSessionId(null); + setGlobalComposerHandoffTargetRect(null); + } else if ( + globalComposerPlacement === "centered" && + requiresProjectWorkspaceDraftPlan + ) { + resetGlobalComposerTransition(); + } + + const chatOptions = { + executionTarget: options?.executionTarget, + reasoningEffort: options?.reasoningEffort, + }; + const acceptGlobalFirstSend = async (session: ChatSession) => { + const sessionId = resolveLiveSessionId(session.id) ?? session.id; + + if (options?.personaId !== undefined) { + patchSession(sessionId, { + personaId: options.personaId ?? undefined, + }); + } + if (options?.reasoningEffort) { + try { + await applyReasoningEffortToSession( + sessionId, + options.reasoningEffort, + ); + } catch (error) { + console.error( + "Failed to apply reasoning effort from global composer:", + error, + ); + } + } + acceptFirstSend( + sessionId, + { + text, + ...(internalOptions?.showQueuedHandoff === false + ? { showInComposer: false } + : {}), + persona: personaIntentFromComposer(options?.personaId), + attachments: options?.attachments, + ...(options?.sendOptions + ? { sendOptions: options.sendOptions } + : {}), + }, + { queueReady: true, onNeedsName: enqueueWorkspaceNameRequest }, + ); + }; + + const startChat = async () => { + const createChat = project + ? createNewProjectDraft(DEFAULT_CHAT_TITLE, project, chatOptions) + : createNewTab(DEFAULT_CHAT_TITLE, undefined, chatOptions); + + try { + const session = await createChat; + if (!session) { + resetGlobalComposerTransition(); + internalOptions?.onSettled?.(false); + return; + } + await acceptGlobalFirstSend(session); + internalOptions?.onSettled?.(true); + } catch (error) { + logProjectChatStartError( + "Failed to start chat from global composer:", + error, + ); + resetGlobalComposerTransition(); + internalOptions?.onSettled?.(false); + } + }; + + const startBackgroundChat = async () => { + try { + const session = await createBackgroundDraftChat( + DEFAULT_CHAT_TITLE, + project, + chatOptions, + ); + if (!session) { + resetGlobalComposerTransition(); + internalOptions?.onSettled?.(false); + return; + } + setChatComposerHandoffSessionId(session.id); + const firstSendPromise = acceptGlobalFirstSend(session); + clearGlobalComposerRouteSwapTimer(); + if (prefersReducedMotion()) { + activateDeferredChatSession(session.id); + resetGlobalComposerTransition(); + } else { + globalComposerRouteSwapTimeoutRef.current = window.setTimeout( + () => { + globalComposerRouteSwapTimeoutRef.current = null; + activateDeferredChatSession(session.id); + }, + GLOBAL_COMPOSER_ROUTE_SWAP_DELAY_MS, + ); + } + await firstSendPromise; + internalOptions?.onSettled?.(true); + } catch (error) { + logProjectChatStartError( + "Failed to start chat from global composer:", + error, + ); + resetGlobalComposerTransition(); + internalOptions?.onSettled?.(false); + } + }; + + if (shouldRunComposerHandoff) { + guardAppNavigation(startBackgroundChat, () => { + resetGlobalComposerTransition(); + internalOptions?.onSettled?.(false); + }); + return; + } + + guardAppNavigation(startChat, () => internalOptions?.onSettled?.(false)); + }, + [ + activateDeferredChatSession, + createBackgroundDraftChat, + createNewProjectDraft, + createNewTab, + clearGlobalComposerHandoffTimer, + clearGlobalComposerRouteSwapTimer, + globalComposerPlacement, + patchSession, + projects, + guardAppNavigation, + resetGlobalComposerTransition, + workspaceRepository, + enqueueWorkspaceNameRequest, + ], + ); + + const handleStartChatWithBerdy = useCallback( + async (text: string): Promise => { + const store = useAgentStore.getState(); + let personaId = findBerdyPersonaId(store.personas); + + if (!personaId) { + const { listPersonas, repairBundledAgent } = await import( + "@/shared/api/agents" + ); + try { + await repairBundledAgent("berdy.md"); + } catch (error) { + console.error("Failed to restore the bundled Berdy agent:", error); + } + + try { + const personas = await listPersonas(); + personaId = findBerdyPersonaId(personas); + const repairedBerdy = personaId + ? personas.find((persona) => persona.id === personaId) + : undefined; + if (repairedBerdy) { + useAgentStore.setState((current) => ({ + personas: [ + ...current.personas.filter( + (persona) => persona.id !== repairedBerdy.id, + ), + repairedBerdy, + ], + })); + } + } catch (error) { + console.error( + "Failed to refresh personas after Berdy repair:", + error, + ); + } + } + + if (!personaId) { + toast.error(t("home:onboarding.callout.agentUnavailable")); + return false; + } + + return new Promise((resolve) => { + handleGlobalCompose( + text, + { personaId }, + { showQueuedHandoff: false, onSettled: resolve }, + ); + }); + }, + [handleGlobalCompose, t], + ); + + const handleGlobalComposerExpand = useCallback( + (payload: GlobalComposerExpandPayload): Promise => { + const options = payload.options; + const project = options?.projectId + ? projects.find((candidate) => candidate.id === options.projectId) + : undefined; + const chatOptions = { + executionTarget: options?.executionTarget, + reasoningEffort: options?.reasoningEffort, + }; + + const shouldDismissCenteredComposer = + globalComposerPlacement === "centered"; + + const openExpandedDraft = async () => { + const session = project + ? await createNewProjectDraft( + DEFAULT_CHAT_TITLE, + project, + chatOptions, + ) + : await createNewTab(DEFAULT_CHAT_TITLE, undefined, chatOptions); + if (!session) { + return false; + } + const sessionId = resolveLiveSessionId(session.id) ?? session.id; + + if (options?.personaId !== undefined) { + patchSession(sessionId, { + personaId: options.personaId ?? undefined, + }); + } + + if (options?.reasoningEffort) { + try { + await applyReasoningEffortToSession( + sessionId, + options.reasoningEffort, + ); + } catch (error) { + console.error( + "Failed to apply reasoning effort from expanded global composer:", + error, + ); + } + } + + const chatState = useChatStore.getState(); + chatState.setDraft(sessionId, payload.text); + chatState.setSkillDrafts(sessionId, payload.selectedSkills); + chatState.setDraftAttachments(sessionId, options?.attachments ?? []); + + if (shouldDismissCenteredComposer) { + resetGlobalComposerTransition(); + } + return true; + }; + + return new Promise((resolve) => { + guardAppNavigation( + () => { + void openExpandedDraft() + .then((expanded) => { + resolve(expanded); + }) + .catch((error) => { + console.error("Failed to expand global composer:", error); + resolve(false); + }); + }, + () => { + resolve(false); + }, + ); + }); + }, + [ + createNewProjectDraft, + createNewTab, + guardAppNavigation, + globalComposerPlacement, + patchSession, + projects, + resetGlobalComposerTransition, + ], + ); + + const handleGlobalVoiceConversationStart = useCallback( + ( + payload: GlobalComposerExpandPayload, + setupComplete = false, + ): Promise => { + if (!capabilities.voiceConversation) return Promise.resolve(false); + if (!setupComplete && globalPocketVoiceSetup.status?.installed !== true) { + const pending = deferPendingVoiceStart( + pendingGlobalVoiceStartRef, + payload, + ); + setGlobalPocketVoiceSetupOpen(true); + return pending; + } + + const options = payload.options; + const project = options?.projectId + ? projects.find((candidate) => candidate.id === options.projectId) + : undefined; + const chatOptions = { + activate: false, + reuseExistingDraft: false, + executionTarget: options?.executionTarget, + reasoningEffort: options?.reasoningEffort, + }; + + const createAndStart = async () => { + const voice = useVoiceConversationStore.getState(); + if ( + voice.status.lifecycle === "starting" || + voice.status.lifecycle === "running" || + voice.status.lifecycle === "stopping" + ) { + await stopVoiceConversation(); + } + const session = await createNewTab( + DEFAULT_CHAT_TITLE, + project, + chatOptions, + ); + if (!session) { + toast.error(t("chat:globalPill.voiceConversationStartFailed")); + return false; + } + + const sessionId = resolveLiveSessionId(session.id) ?? session.id; + if (options?.personaId !== undefined) { + patchSession(sessionId, { + personaId: options.personaId ?? undefined, + }); + } + if (options?.reasoningEffort) { + try { + await applyReasoningEffortToSession( + sessionId, + options.reasoningEffort, + ); + } catch (error) { + console.error( + "Failed to apply reasoning effort for voice conversation:", + error, + ); + } + } + + const chatState = useChatStore.getState(); + chatState.setDraft(sessionId, payload.text); + chatState.setSkillDrafts(sessionId, payload.selectedSkills); + chatState.setDraftAttachments(sessionId, options?.attachments ?? []); + handleNavigateToSession(sessionId); + requestVoiceConversationStart(sessionId); + resetGlobalComposerTransition(); + return true; + }; + + return new Promise((resolve) => { + guardAppNavigation( + () => { + void createAndStart() + .then(resolve) + .catch((error) => { + console.error( + "Failed to create chat for voice conversation:", + error, + ); + toast.error(t("chat:globalPill.voiceConversationStartFailed")); + resolve(false); + }); + }, + () => resolve(false), + ); + }); + }, + [ + capabilities.voiceConversation, + createNewTab, + globalPocketVoiceSetup.status?.installed, + guardAppNavigation, + handleNavigateToSession, + patchSession, + projects, + requestVoiceConversationStart, + resetGlobalComposerTransition, + stopVoiceConversation, + t, + ], + ); + const handleGlobalPocketVoiceSetupOpenChange = useCallback( + (open: boolean) => { + if (!open) { + cancelPendingVoiceStart(pendingGlobalVoiceStartRef); + } + setGlobalPocketVoiceSetupOpen(open); + }, + [], + ); + const handleGlobalPocketVoiceUseSelected = useCallback(() => { + setGlobalPocketVoiceSetupOpen(false); + void continuePendingVoiceStart(pendingGlobalVoiceStartRef, (payload) => + handleGlobalVoiceConversationStart(payload, true), + ); + }, [handleGlobalVoiceConversationStart]); + + const handleStartProviderTroubleshootingChat = useCallback( + (request: AgentSetupTroubleshootingRequest) => { + guardAppNavigation(() => { + void createNewTab(request.title, undefined, { + executionTarget: { harnessId: "goose" }, + }) + .then((session) => { + if (!session) return; + useChatStore.getState().enqueueTransportReadyMessage( + session.id, + admitSystemInheritedQueuedMessage({ + text: request.prompt, + }), + ); + }) + .catch((error) => { + console.error( + "Failed to start provider troubleshooting chat:", + error, + ); + }); + }); + }, + [guardAppNavigation, createNewTab], + ); + + const handleNewChatInProject = useCallback( + ( + projectId: string, + options: { + reuseExistingDraft?: boolean; + } = {}, + ) => { + const project = projects.find((p) => p.id === projectId); + if (!project) { + return Promise.resolve(undefined); + } + + return new Promise((resolve) => { + guardAppNavigation( + () => { + const draftOptions = + options.reuseExistingDraft === undefined + ? {} + : { reuseExistingDraft: options.reuseExistingDraft }; + void createNewProjectDraft( + DEFAULT_CHAT_TITLE, + project, + draftOptions, + ) + .then(resolve) + .catch((error) => { + logProjectChatStartError( + "Failed to start project chat:", + error, + ); + resolve(undefined); + }); + }, + () => { + resolve(undefined); + }, + ); + }); + }, + [createNewProjectDraft, projects, guardAppNavigation], + ); + const handleArchiveProject = useCallback( + async (projectId: string) => { + try { + await archiveProject(projectId); + fetchProjects(); + } catch { + // best-effort + } + }, + [fetchProjects], + ); + + const clearActiveSession = useCallback( + (sessionId: string) => { + cleanupChatSession(sessionId); + setActiveSession(null); + clearSettingsSectionUrl(); + if (activeView === "chat") { + setActiveView("home"); + } + }, + [activeView, cleanupChatSession, setActiveSession], + ); + + const returnToAgentBuilderSettingsTarget = useCallback(() => { + const target = agentBuilderSettingsReturnTarget; + if (!target) { + return false; + } + + const session = useChatSessionStore.getState().getSession(target.sessionId); + setAgentBuilderSettingsReturnTarget(null); + if (!session || session.archivedAt) { + return false; + } + + clearSettingsSectionUrl(); + setActiveSession(target.sessionId); + setActiveView("chat"); + setChatActiveSession(target.sessionId); + useChatStore.getState().markSessionRead(target.sessionId); + void loadSessionMessagesAndPrepare(target.sessionId); + return true; + }, [ + agentBuilderSettingsReturnTarget, + setActiveSession, + setChatActiveSession, + ]); + + const openSettings = useCallback( + (section: SectionId = DEFAULT_SETTINGS_SECTION) => { + const enabledSection = resolveEnabledSettingsSection( + section, + capabilities, + ); + if (activeView !== "settings" && activeView !== "design-system") { + lastNonSecondaryViewRef.current = activeView; + } + setActiveSettingsSection(enabledSection); + setSettingsSectionUrl(enabledSection); + setActiveView("settings"); + if (sidebarCollapsed) { + void expandSidebar(); + } + }, + [activeView, capabilities, expandSidebar, sidebarCollapsed], + ); + + const leaveSecondarySurface = useCallback(() => { + if (returnToAgentBuilderSettingsTarget()) { + return; + } + clearSettingsSectionUrl(); + setActiveView(lastNonSecondaryViewRef.current); + }, [returnToAgentBuilderSettingsTarget]); + + const selectSettingsSection = useCallback( + (section: SectionId) => { + const enabledSection = resolveEnabledSettingsSection( + section, + capabilities, + ); + setActiveSettingsSection(enabledSection); + setSettingsSectionUrl(enabledSection); + }, + [capabilities], + ); + + const openDesignSystem = useCallback(() => { + if (!isDesignSystemExplorerEnabled()) return; + if (activeView !== "design-system") { + designSystemReturnViewRef.current = activeView; + } + setDesignSystemUrl(); + setActiveView("design-system"); + }, [activeView]); + + const closeDesignSystem = useCallback(() => { + const returnView = designSystemReturnViewRef.current; + if (returnView === "settings") { + setSettingsSectionUrl(activeSettingsSection); + } else { + clearSettingsSectionUrl(); + } + setActiveView(returnView); + }, [activeSettingsSection]); + + const selectDesignSystemSection = useCallback( + (section: DesignSystemSection) => { + setActiveDesignSystemSection(section); + }, + [], + ); + + useEffect(() => { + const handleOpenSettingsEvent = (event: Event) => { + const detail = (event as CustomEvent).detail; + const section = detail?.section; + setAgentBuilderSettingsReturnTarget( + detail?.returnTarget?.type === "agent-builder-provider-setup" + ? detail.returnTarget + : null, + ); + openSettings(resolveSettingsSection(section ?? null)); + }; + + window.addEventListener( + OPEN_SETTINGS_EVENT, + handleOpenSettingsEvent as EventListener, + ); + return () => { + window.removeEventListener( + OPEN_SETTINGS_EVENT, + handleOpenSettingsEvent as EventListener, + ); + }; + }, [openSettings]); + + const settleWorkspaceCleanupConfirmation = useCallback( + (confirmed: boolean) => { + const pending = pendingWorkspaceCleanupConfirmationRef.current; + if (!pending) return; + pendingWorkspaceCleanupConfirmationRef.current = null; + setPendingWorkspaceCleanupConfirmation(null); + pending.resolve(confirmed); + }, + [], + ); + + useEffect( + () => () => { + const pending = pendingWorkspaceCleanupConfirmationRef.current; + pendingWorkspaceCleanupConfirmationRef.current = null; + pending?.resolve(false); + }, + [], + ); + + const confirmGitCleanup = useCallback( + (plans: InspectedSessionWorkspaceCleanupPlan[]): Promise => { + const { worktreeCount, branchCount } = + countSessionWorkspaceCleanupResources(plans); + return new Promise((resolve) => { + const pending: PendingSessionWorkspaceCleanupConfirmation = { + worktreeCount, + branchCount, + resolve, + }; + pendingWorkspaceCleanupConfirmationRef.current = pending; + setPendingWorkspaceCleanupConfirmation(pending); + }); + }, + [], + ); + + const archiveChat = useCallback( + async ( + sessionId: string, + cleanupPolicy: ArchiveCleanupPolicy, + deadlineMs?: number, + ) => { + let releaseArchiveQueue!: () => void; + const previousArchive = sessionArchiveQueueRef.current; + sessionArchiveQueueRef.current = new Promise((resolve) => { + releaseArchiveQueue = resolve; + }); + await previousArchive; + + try { + const sessionStore = useChatSessionStore.getState(); + const session = sessionStore.getSession(sessionId); + if (!session) { + return { ok: false as const, reason: "session_not_found" as const }; + } + + let plans: InspectedSessionWorkspaceCleanupPlan[] = []; + if (hasSessionWorkspaceCleanupTargets(session)) { + try { + const allSessions = await loadAllSessionsForWorkspaceCleanup(); + // Resolve the home dir so the used-elsewhere check can match a + // `~`-spelled attachment in another chat against an absolute + // cleanup target; on failure fall back to the cached value (raw + // comparison, as before). + const homeDir = await getHomeDir().catch(() => getCachedHomeDir()); + plans = await inspectSessionWorkspaceCleanup( + planSessionWorkspaceCleanup( + session, + [...allSessions, ...sessionStore.sessions], + homeDir, + ), + ); + } catch (error) { + console.error("Failed to inspect session Git resources:", error); + if (cleanupPolicy === "confirm") { + toast.error(t("chat:notifications.gitInspectionError"), { + description: formatAcpErrorMessage(error), + }); + } + return { + ok: false as const, + reason: "git_inspection_failed" as const, + }; + } + } + + const wouldDiscardFiles = plans.some( + wouldSessionWorkspaceCleanupDiscardFiles, + ); + if (wouldDiscardFiles) { + if (cleanupPolicy === "reject") { + return { + ok: false as const, + reason: "cleanup_requires_discard" as const, + }; + } + if ( + cleanupPolicy === "confirm" && + !(await confirmGitCleanup(plans)) + ) { + return { + ok: false as const, + reason: "blocked_unsaved_changes" as const, + }; + } + } + + const preArchiveInterruption = getSessionArchiveInterruptionReason( + sessionId, + cleanupPolicy, + deadlineMs, + ); + if (preArchiveInterruption) { + return { ok: false as const, reason: preArchiveInterruption }; + } + + try { + await useChatSessionStore.getState().archiveSession(sessionId); + const homeWidgetState = useHomeWidgetStore.getState(); + const pinnedWidget = homeWidgetState.instances.find( + (instance) => + instance.type === "chatPin" && + instance.state?.sessionId === sessionId, + ); + if (pinnedWidget) { + homeWidgetState.removeWidget(pinnedWidget.id); + } + } catch (error) { + if (cleanupPolicy === "confirm") { + toast.error( + formatAcpErrorMessage( + error, + t("chat:notifications.archiveError"), + ), + ); + } + return { + ok: false as const, + reason: + error instanceof SessionNotFoundError + ? ("session_not_found" as const) + : ("backend_archive_failed" as const), + }; + } + + let cleanupFailureReason: + | "target_session_running" + | "workspace_cleanup_failed" + | "timed_out" + | null = null; + try { + await cleanupSessionWorkspaces(plans, { + getInterruptionReason: () => + getSessionArchiveInterruptionReason( + sessionId, + cleanupPolicy, + deadlineMs, + ), + }); + } catch (error) { + cleanupFailureReason = + error instanceof SessionWorkspaceCleanupInterruptedError + ? error.reason + : "workspace_cleanup_failed"; + console.error( + "Failed to clean up archived session Git resources:", + error, + ); + if (cleanupPolicy === "confirm") { + toast.error( + formatAcpErrorMessage( + error, + t("chat:notifications.gitCleanupError"), + ), + ); + } + } + + const wasActiveSession = + useChatSessionStore.getState().activeSessionId === sessionId; + cleanupChatSession(sessionId); + if (useSessionWindowStore.getState().isOpenInWindow(sessionId)) { + releaseSession(sessionId).catch((error: unknown) => + console.error("Failed to release session window:", error), + ); + } + if (wasActiveSession) { + setActiveSession(null); + setActiveView("home"); + } + + return cleanupFailureReason + ? { ok: true as const, cleanupIncomplete: cleanupFailureReason } + : { ok: true as const }; + } finally { + releaseArchiveQueue(); + } + }, + [cleanupChatSession, confirmGitCleanup, setActiveSession, t], + ); + + const handleArchiveChat = useCallback( + (sessionId: string) => archiveChat(sessionId, "confirm"), + [archiveChat], + ); + closeAgentBuilderSessionRef.current = async (sessionId) => { + await handleArchiveChat(sessionId); + }; + + const handleEditProject = useCallback( + (projectId: string) => { + const project = projects.find((p) => p.id === projectId); + if (project) { + openEditProjectDialog(project); + } + }, + [openEditProjectDialog, projects], + ); + + const handleMoveToProject = useCallback( + (sessionId: string, projectId: string | null) => { + const session = useChatSessionStore.getState().getSession(sessionId); + if (!session) { + return; + } + + // Ignore drops that would not change the chat's group (e.g. dropping a + // chat back onto a sibling in the same list) so we never fire a no-op + // move that looks like a failed drag. + if ((session.projectId ?? null) === projectId) { + return; + } + + void moveSessionToProject(sessionId, projectId).catch((error) => { + console.error("Failed to move session to project:", error); + toast.error( + formatAcpErrorMessage(error, t("chat:notifications.moveError")), + ); + }); + }, + [t], + ); + + const handleRenameChat = useCallback( + (sessionId: string, nextTitle: string) => { + void updateSessionTitle(sessionId, nextTitle).catch((error) => { + console.error("Failed to rename session:", error); + toast.error( + formatAcpErrorMessage(error, t("notifications.renameError")), + ); + }); + }, + [t], + ); + + const handleMarkChatRead = useCallback((sessionId: string) => { + useChatStore.getState().markSessionRead(sessionId); + }, []); + + const handleMarkChatUnread = useCallback((sessionId: string) => { + useChatStore.getState().markSessionUnread(sessionId); + }, []); + + const activateHomeSession = useCallback( + (sessionId: string) => { + guardAppNavigation(() => { + if (homeSessionId === sessionId) { + setHomeSessionId(null); + } + setActiveSession(sessionId); + clearSettingsSectionUrl(); + setActiveView("chat"); + setChatActiveSession(sessionId); + useChatStore.getState().markSessionRead(sessionId); + }); + }, + [homeSessionId, guardAppNavigation, setActiveSession, setChatActiveSession], + ); + + const selectSessionDirect = useCallback((id: string) => { + activateChatSession(id); + clearSettingsSectionUrl(); + setActiveView("chat"); + void loadSessionMessagesAndPrepare(id); + }, []); + navigateAgentBuilderChatRef.current = selectSessionDirect; + + const handleSelectSession = useCallback( + (id: string) => { + if ( + isMultiWindowEnabled && + useSessionWindowStore.getState().isOpenInWindow(id) + ) { + void focusSessionWindow(id); + return; + } + if ( + activeView === "chat" && + id === useChatSessionStore.getState().activeSessionId + ) { + return; + } + guardAppNavigation(() => { + selectSessionDirect(id); + }); + }, + [activeView, guardAppNavigation, isMultiWindowEnabled, selectSessionDirect], + ); + + const handleSelectSearchResult = useCallback( + (sessionId: string, messageId?: string, query?: string) => { + guardAppNavigation(() => { + setSearchDialogOpen(false); + if (messageId) { + useChatStore + .getState() + .setScrollTargetMessage(sessionId, messageId, query); + } + const sessionWindowStore = useSessionWindowStore.getState(); + if ( + isMultiWindowEnabled && + sessionWindowStore.isOpenInWindow(sessionId) + ) { + const windowLabel = sessionWindowStore.getWindowLabel(sessionId); + if (messageId && windowLabel) { + void sendSessionWindowSearchTarget(windowLabel, { + sessionId, + messageId, + query, + }).then(() => focusSessionWindow(sessionId)); + } else { + void focusSessionWindow(sessionId); + } + return; + } + selectSessionDirect(sessionId); + }); + }, + [guardAppNavigation, isMultiWindowEnabled, selectSessionDirect], + ); + + const handleForkChat = useForkSession({ onForked: handleSelectSession }); + + const handleOpenSettingsFromSearch = useCallback( + (section: SectionId) => { + guardAppNavigation(() => { + setSearchDialogOpen(false); + openSettings(section); + }); + }, + [guardAppNavigation, openSettings], + ); + + const handleOpenExtensionFromSearch = useCallback( + (_entry: ExtensionEntry) => { + handleOpenSettingsFromSearch("connections"); + }, + [handleOpenSettingsFromSearch], + ); + + const handleOpenAutomationFromSearch = useCallback( + (automationId: string, onNavigationAccepted?: () => void) => { + if (!isAutomationsFeatureEnabled) { + return; + } + guardAppNavigation(() => { + onNavigationAccepted?.(); + replaceNextNavigationEntryRef.current = false; + setAutomationsRoute({ + surface: "detail", + automationId, + tab: "details", + selectedRunKey: null, + }); + setActiveSession(null); + clearSettingsSectionUrl(); + setActiveView("automations"); + }); + }, + [guardAppNavigation, isAutomationsFeatureEnabled, setActiveSession], + ); + + const handleNavigate = useCallback( + (view: AppView) => { + guardAppNavigation(() => { + resetGlobalComposerTransition(); + if (view === "automations" && !isAutomationsFeatureEnabled) { + setActiveView("home"); + return; + } + if (view === "builderbot" && !isBuilderbotSurfaceEnabled) { + setActiveView("home"); + return; + } + if (view === "settings") { + openSettings(); + return; + } + if (view === "design-system") { + openDesignSystem(); + return; + } + if (view !== "chat" && view !== "search") { + setActiveSession(null); + } + if (view === "skills") { + setSkillsSkillId(null); + } + if (view === "agents") { + setAgentsPersonaId(null); + } + if (view === "automations") { + setAutomationsRoute({ surface: "overview" }); + } + if (view === "builderbot") { + setBuilderbotRoute({ surface: "overview" }); + } + clearSettingsSectionUrl(); + setActiveView(view); + }); + }, + [ + openDesignSystem, + openSettings, + guardAppNavigation, + resetGlobalComposerTransition, + setActiveSession, + isAutomationsFeatureEnabled, + isBuilderbotSurfaceEnabled, + ], + ); + + const handleOpenProject = useCallback( + (projectId: string) => { + useProjectStore.getState().setActiveProject(projectId); + handleNavigate("projects"); + }, + [handleNavigate], + ); + + useRegisterAppNavigationController({ + guardAppNavigation, + selectSessionDirect, + archiveChat, + getActiveSessionId: () => useChatSessionStore.getState().activeSessionId, + hasSession: (sessionId) => + Boolean(useChatSessionStore.getState().getSession(sessionId)), + isSessionOpenInWindow: (sessionId) => + useSessionWindowStore.getState().isOpenInWindow(sessionId), + focusSessionWindow, + getAppContext: () => { + const sessionStore = useChatSessionStore.getState(); + const activeSession = sessionStore.activeSessionId + ? sessionStore.getSession(sessionStore.activeSessionId) + : undefined; + return { + view: activeView, + activeSessionId: sessionStore.activeSessionId, + activeProjectId: activeSession?.projectId ?? null, + }; + }, + activeView, + isMultiWindowEnabled, + }); + + const navigateSkills = useCallback( + (skillId: string | null, options?: AppNavigationUpdateOptions) => { + guardAppNavigation(() => { + replaceNextNavigationEntryRef.current = Boolean(options?.replace); + setSkillsSkillId(skillId); + setActiveSession(null); + clearSettingsSectionUrl(); + setActiveView("skills"); + }); + }, + [guardAppNavigation, setActiveSession], + ); + + const navigateAgentsDirect = useCallback( + (personaId: string | null, options?: AppNavigationUpdateOptions) => { + replaceNextNavigationEntryRef.current = Boolean(options?.replace); + setAgentsPersonaId(personaId); + setActiveSession(null); + clearSettingsSectionUrl(); + setActiveView("agents"); + }, + [setActiveSession], + ); + const navigateAgents = useCallback( + (personaId: string | null, options?: AppNavigationUpdateOptions) => { + guardAppNavigation(() => { + navigateAgentsDirect(personaId, options); + }); + }, + [guardAppNavigation, navigateAgentsDirect], + ); + + const navigateAutomations = useCallback( + ( + route: AutomationNavigationRoute, + options?: AppNavigationUpdateOptions, + ) => { + if (!isAutomationsFeatureEnabled) { + return; + } + guardAppNavigation(() => { + replaceNextNavigationEntryRef.current = Boolean(options?.replace); + setAutomationsRoute(route); + setActiveSession(null); + clearSettingsSectionUrl(); + setActiveView("automations"); + }); + }, + [guardAppNavigation, isAutomationsFeatureEnabled, setActiveSession], + ); + + const navigateBuilderbot = useCallback( + ( + route: BuilderbotNavigationRoute, + options?: AppNavigationUpdateOptions, + ) => { + if (!isBuilderbotSurfaceEnabled) { + return; + } + guardAppNavigation(() => { + replaceNextNavigationEntryRef.current = Boolean(options?.replace); + setBuilderbotRoute(route); + setActiveSession(null); + clearSettingsSectionUrl(); + setActiveView("builderbot"); + }); + }, + [guardAppNavigation, isBuilderbotSurfaceEnabled, setActiveSession], + ); + + const applyNavigationLocation = useCallback( + (location: AppNavigationLocation) => { + navigationHistoryRef.current.isApplying = true; + + if (location.view === "settings") { + setActiveSettingsSection(location.settingsSection); + setSettingsSectionUrl(location.settingsSection); + setActiveView("settings"); + if (sidebarCollapsed) { + void expandSidebar(); + } + return; + } + + if ( + location.view === "design-system" && + isDesignSystemExplorerEnabled() + ) { + setActiveDesignSystemSection(location.designSystemSection); + setDesignSystemUrl(); + setActiveView("design-system"); + if (sidebarCollapsed) { + void expandSidebar(); + } + return; + } + + clearSettingsSectionUrl(); + + if (location.view === "skills") { + setActiveSession(null); + setSkillsSkillId(location.skillId); + setActiveView("skills"); + return; + } + + if (location.view === "agents") { + setActiveSession(null); + setAgentsPersonaId(location.personaId); + setActiveView("agents"); + return; + } + + if (location.view === "automations") { + if (!isAutomationsFeatureEnabled) { + setActiveSession(null); + setActiveView("home"); + return; + } + setActiveSession(null); + setAutomationsRoute(location.route); + setActiveView("automations"); + return; + } + + if (location.view === "builderbot") { + if (!isBuilderbotSurfaceEnabled) { + setActiveSession(null); + setActiveView("home"); + return; + } + setActiveSession(null); + setBuilderbotRoute(location.route); + setActiveView("builderbot"); + return; + } + + if (location.view === "search") { + setActiveView("search"); + return; + } + + if (location.view === "chat" && location.sessionId) { + const session = useChatSessionStore + .getState() + .getSession(location.sessionId); + + if (session && !session.archivedAt) { + setActiveSession(location.sessionId); + setActiveView("chat"); + setChatActiveSession(location.sessionId); + useChatStore.getState().markSessionRead(location.sessionId); + void loadSessionMessagesAndPrepare(location.sessionId); + return; + } + } + + setActiveSession(null); + setActiveView(location.view === "chat" ? "home" : location.view); + }, + [ + expandSidebar, + isAutomationsFeatureEnabled, + isBuilderbotSurfaceEnabled, + setActiveSession, + setChatActiveSession, + sidebarCollapsed, + ], + ); + + const goBack = useCallback(() => { + if (activeView === "settings" && agentBuilderSettingsReturnTarget) { + const history = navigationHistoryRef.current; + const previousLocation = + history.index > 0 ? history.entries[history.index - 1] : null; + if ( + previousLocation?.view === "chat" && + previousLocation.sessionId === + agentBuilderSettingsReturnTarget.sessionId + ) { + history.index -= 1; + if (returnToAgentBuilderSettingsTarget()) { + updateNavigationAvailability(); + return; + } + history.index += 1; + } + } + + guardAppNavigation(() => { + const history = navigationHistoryRef.current; + if (history.index <= 0) { + return; + } + + history.index -= 1; + applyNavigationLocation(history.entries[history.index]); + updateNavigationAvailability(); + }); + }, [ + activeView, + agentBuilderSettingsReturnTarget, + applyNavigationLocation, + guardAppNavigation, + returnToAgentBuilderSettingsTarget, + updateNavigationAvailability, + ]); + + const goForward = useCallback(() => { + guardAppNavigation(() => { + const history = navigationHistoryRef.current; + if (history.index >= history.entries.length - 1) { + return; + } + + history.index += 1; + applyNavigationLocation(history.entries[history.index]); + updateNavigationAvailability(); + }); + }, [ + applyNavigationLocation, + guardAppNavigation, + updateNavigationAvailability, + ]); + + const handleExitSearch = useCallback(() => { + const history = navigationHistoryRef.current; + if (history.index > 0) { + goBack(); + return; + } + + guardAppNavigation(() => { + clearSettingsSectionUrl(); + setActiveSession(null); + setActiveView("home"); + }); + }, [goBack, guardAppNavigation, setActiveSession]); + + const toggleRightRail = useCallback(() => { + if (!activeSessionId) { + return; + } + + const nextOpen = !isContextVisible; + if (nextOpen && isAgentBuilderVisible(activeSession)) { + useChatSessionStore.getState().patchSession(activeSessionId, { + agentBuilderContextState: "userOpened", + }); + } + setRightRailOpen(nextOpen); + }, [activeSession, activeSessionId, isContextVisible, setRightRailOpen]); + + const feedbackOpen = useFeedbackDialogStore((state) => state.open); + const feedbackDraft = useFeedbackDialogStore((state) => state.draft); + const openFeedbackDialog = useFeedbackDialogStore( + (state) => state.openDialog, + ); + const setFeedbackOpen = useFeedbackDialogStore((state) => state.setOpen); + const shortcutsOpen = useShortcutsDialogStore((state) => state.open); + const setShortcutsOpen = useShortcutsDialogStore((state) => state.setOpen); + const handleFeedbackClick = useCallback(() => { + if (!isFeedbackEnabled) { + return; + } + openFeedbackDialog(); + }, [isFeedbackEnabled, openFeedbackDialog]); + + useEffect(() => { + if (!isFeedbackEnabled) { + setFeedbackOpen(false); + } + }, [isFeedbackEnabled, setFeedbackOpen]); + + const startupIssue = useMemo( + () => + startup.error + ? buildStartupDiagnosticIssue(startup.error, startup.probe) + : null, + [startup.error, startup.probe], + ); + const forceStartupLoading = + import.meta.env.DEV && + new URLSearchParams(window.location.search).has("startupLoading"); + const isGlobalComposerHandoff = globalComposerPlacement === "handoff"; + const isGlobalComposerRouteDisallowed = + targetLocation.view === "automations" && + targetLocation.route.surface === "builder"; + const canShowGlobalComposer = + startup.ready && + !forceStartupLoading && + !startupIssue && + children == null && + (!isPreparingContent || globalComposerPlacement === "handoff") && + !isGlobalComposerRouteDisallowed; + const canUseGlobalComposerShortcut = + startup.ready && !forceStartupLoading && !startupIssue && children == null; + const showGlobalComposer = + canShowGlobalComposer && + (globalComposerPlacement !== "docked" || renderedLocation.view !== "chat"); + const showGlobalComposerShim = + canShowGlobalComposer && globalComposerPlacement !== "docked"; + + useEffect(() => { + if ( + globalComposerPlacement === "docked" || + !isGlobalComposerRouteDisallowed + ) { + return; + } + + resetGlobalComposerTransition(); + }, [ + globalComposerPlacement, + isGlobalComposerRouteDisallowed, + resetGlobalComposerTransition, + ]); + + const handleGlobalComposerHandoffStart = useCallback( + (rect: GlobalComposerHandoffRect) => { + setGlobalComposerHandoffSourceRect(rect); + setGlobalComposerHandoffTargetRect(null); + }, + [], + ); + const handleChatComposerHandoffTarget = useCallback( + (rect: GlobalComposerHandoffRect) => { + setGlobalComposerHandoffTargetRect((current) => current ?? rect); + }, + [], + ); + const dismissCenteredGlobalComposer = useCallback(() => { + if (globalComposerPlacement === "centered") { + resetGlobalComposerTransition(); + } + }, [globalComposerPlacement, resetGlobalComposerTransition]); + + const topBarBreadcrumbs = useMemo(() => { + switch (activeView) { + case "chat": { + if (!activeSession?.title) { + return [current("root", "Home")]; + } + const chatProject = activeSession.projectId + ? (projects.find((p) => p.id === activeSession.projectId) ?? null) + : null; + // "Chat" and the project segment are intentionally non-clickable for now: + // neither destination exists yet (no chats-list view, no per-project surface). + // Swap `current` → `parent` with a real onClick when those routes land. + return chatProject + ? [ + current("chat", "Chat"), + current("chat-project", chatProject.name), + current("chat-session", activeSession.title), + ] + : [ + current("chat", "Chat"), + current("chat-session", activeSession.title), + ]; + } + case "skills": + return skillsSkillId && skillsBreadcrumbLabel + ? [ + parent("skills", t("sidebar:navigation.skills"), () => + handleNavigate("skills"), + ), + current("skill-detail", skillsBreadcrumbLabel), + ] + : [current("skills", t("sidebar:navigation.skills"))]; + case "agents": + return agentsPersonaId && agentsBreadcrumbLabel + ? [ + parent("agents", "Agents", () => handleNavigate("agents")), + current("agent-detail", agentsBreadcrumbLabel), + ] + : [current("agents", "Agents")]; + case "automations": + return automationsBreadcrumbLabel + ? [ + parent("automations", "Automations", () => + handleNavigate("automations"), + ), + current("automation-detail", automationsBreadcrumbLabel), + ] + : [current("automations", "Automations")]; + case "builderbot": + if (!builderbotBreadcrumbLabel) { + return [current("builderbot", "Builderbot")]; + } + if (builderbotRoute.surface === "task") { + return [ + parent("builderbot", "Builderbot", () => + navigateBuilderbot({ surface: "overview" }), + ), + parent("builderbot-tasks", "Tasks", () => + navigateBuilderbot({ surface: "overview", tab: "tasks" }), + ), + current("builderbot-detail", builderbotBreadcrumbLabel), + ]; + } + if (builderbotRoute.surface === "automation") { + return [ + parent("builderbot", "Builderbot", () => + navigateBuilderbot({ surface: "overview" }), + ), + parent("builderbot-automations", "Automations", () => + navigateBuilderbot({ surface: "overview", tab: "automations" }), + ), + current("builderbot-detail", builderbotBreadcrumbLabel), + ]; + } + return [current("builderbot", "Builderbot")]; + case "design-system": { + const designSystemSectionLabel = DESIGN_SYSTEM_SECTIONS.find( + (section) => section.id === activeDesignSystemSection, + )?.label; + const showDesignSystemSection = + activeDesignSystemSection !== DEFAULT_DESIGN_SYSTEM_SECTION && + Boolean(designSystemSectionLabel); + + return showDesignSystemSection && designSystemSectionLabel + ? [ + parent("design-system", "Design System", () => { + setActiveDesignSystemSection(DEFAULT_DESIGN_SYSTEM_SECTION); + openDesignSystem(); + }), + current("design-system-section", designSystemSectionLabel), + ] + : [current("design-system", "Design System")]; + } + case "settings": { + const settingsSection = SETTINGS_SECTIONS.find( + (section) => section.id === activeSettingsSection, + ); + const showSettingsSection = + activeSettingsSection !== DEFAULT_SETTINGS_SECTION && + Boolean(settingsSection); + + if (!showSettingsSection || !settingsSection) { + return [current("settings", "Settings")]; + } + + // rev 4: Doctor no longer needs a "back to parent section" + // breadcrumb segment here -- it moved from a hidden sub-page (routed + // through activeSettingsSection) to a dialog opened directly from a + // row inside System, so it never becomes the active settings + // section in the first place. + return [ + parent("settings", "Settings", () => + openSettings(DEFAULT_SETTINGS_SECTION), + ), + current( + "settings-section", + t(`settings:${settingsSection.labelKey}`), + ), + ]; + } + case "projects": + return [current("projects", "Projects")]; + case "search": + return [current("search", "Search")]; + case "session-history": + return [current("session-history", "Session History")]; + case "home": + return [current("root", "Home")]; + } + }, [ + activeDesignSystemSection, + activeSession?.projectId, + activeSession?.title, + activeSettingsSection, + activeView, + agentsBreadcrumbLabel, + agentsPersonaId, + automationsBreadcrumbLabel, + builderbotBreadcrumbLabel, + builderbotRoute.surface, + handleNavigate, + navigateBuilderbot, + openDesignSystem, + openSettings, + projects, + skillsBreadcrumbLabel, + skillsSkillId, + t, + ]); + + useEffect(() => { + const handler = (e: KeyboardEvent) => { + if (e.isComposing || e.repeat) { + return; + } + if (eventMatchesShortcutCommand(e, "view.toggleDesignSystemInspector")) { + e.preventDefault(); + setDesignSystemInspectorModeToggleRequest(0); + setDesignSystemInspectorVisible((visible) => !visible); + return; + } + if ( + eventMatchesShortcutCommand(e, "view.toggleDesignSystemInspectorMode") + ) { + e.preventDefault(); + setDesignSystemInspectorVisible(true); + setDesignSystemInspectorModeToggleRequest((request) => request + 1); + return; + } + // Toggles the keyboard shortcuts reference. Handled before the layer + // guard so it can close its own (modal) dialog. + if (eventMatchesShortcutCommand(e, "help.shortcuts")) { + e.preventDefault(); + useShortcutsDialogStore.getState().toggle(); + return; + } + // Any mounted modal/popper owns the keyboard (matching the transcript + // search and pane-jump guards). + if (hasOpenKeyboardOwningLayer()) { + return; + } + // Dismiss the centered global composer on Escape from anywhere once + // nested menus/popovers have had the chance to handle Escape first. + if ( + e.key === "Escape" && + !e.defaultPrevented && + globalComposerPlacement === "centered" + ) { + e.preventDefault(); + resetGlobalComposerTransition(); + return; + } + // Navigation history (defaults mod+[ / mod+]) + if ( + eventMatchesShortcutCommand(e, "navigation.back") && + !e.defaultPrevented && + !isTerminalOwnedHistoryShortcut(e) + ) { + e.preventDefault(); + goBack(); + return; + } + if ( + eventMatchesShortcutCommand(e, "navigation.forward") && + !e.defaultPrevented && + !isTerminalOwnedHistoryShortcut(e) + ) { + e.preventDefault(); + goForward(); + return; + } + // Settings (default mod+,) + if (eventMatchesShortcutCommand(e, "navigation.openSettings")) { + e.preventDefault(); + if (activeView === "settings") { + leaveSecondarySurface(); + return; + } + handleNavigate("settings"); + return; + } + // Sidebar toggle (default mod+b) + if (eventMatchesShortcutCommand(e, "view.toggleSidebar")) { + e.preventDefault(); + toggleSidebar(); + return; + } + // Global search dialog (default mod+k). + if (eventMatchesShortcutCommand(e, "navigation.search")) { + e.preventDefault(); + setSearchDialogOpen(true); + return; + } + // Session quick switcher (default mod+p) + if (eventMatchesShortcutCommand(e, "session.quickSwitch")) { + e.preventDefault(); + setQuickSwitcherOpen((open) => !open); + return; + } + // Cycle sessions by recency (defaults ctrl+tab / ctrl+shift+tab) + const cycleDirection = eventMatchesShortcutCommand(e, "session.next") + ? 1 + : eventMatchesShortcutCommand(e, "session.previous") + ? -1 + : null; + if (cycleDirection !== null) { + e.preventDefault(); + const { sessions, activeSessionId } = useChatSessionStore.getState(); + const sessionWindowStore = useSessionWindowStore.getState(); + const candidates = getVisibleSessions( + sessions, + selectLocalMessageCountsBySession(useChatStore.getState()), + ).filter( + (session) => + !session.archivedAt && + // Sessions open in other windows aren't part of this window's + // cycle order. + !( + isMultiWindowEnabled && + sessionWindowStore.isOpenInWindow(session.id) + ), + ); + const currentSessionId = + activeView === "chat" && activeSessionId + ? resolveLiveSessionId(activeSessionId) + : null; + const targetId = resolveSessionCycleTarget( + candidates, + currentSessionId, + cycleDirection, + ); + if (targetId) { + handleSelectSession(targetId); + } + return; + } + // Archive the current chat/session (default mod+e) + if (eventMatchesShortcutCommand(e, "chat.archiveSession")) { + if (e.defaultPrevented || isArchiveShortcutBlockedTarget(e.target)) { + return; + } + const { activeSessionId } = useChatSessionStore.getState(); + const sessionId = + activeView === "chat" && activeSessionId + ? resolveLiveSessionId(activeSessionId) + : null; + if (!sessionId) { + return; + } + e.preventDefault(); + void handleArchiveChat(sessionId); + return; + } + // Returns to home instead of closing the window (default mod+w) + if (eventMatchesShortcutCommand(e, "navigation.closeSession")) { + e.preventDefault(); + const { activeSessionId } = useChatSessionStore.getState(); + if (activeSessionId) { + clearActiveSession(activeSessionId); + } else if (activeView === "design-system") { + closeDesignSystem(); + } else if (activeView === "settings") { + clearSettingsSectionUrl(); + setActiveView("home"); + } + return; + } + // Open the floating new conversation composer (default mod+n). + if (eventMatchesShortcutCommand(e, "navigation.newConversation")) { + e.preventDefault(); + if (!canUseGlobalComposerShortcut) { + return; + } + guardAppNavigation(() => { + clearGlobalComposerHandoffTimer(); + setChatComposerHandoffSessionId(null); + setGlobalComposerHandoffSourceRect(null); + setGlobalComposerHandoffTargetRect(null); + if (!canShowGlobalComposer) { + setActiveSession(null); + clearSettingsSectionUrl(); + setActiveView("home"); + } + setGlobalComposerPlacement("centered"); + setGlobalComposerFocusRequest((request) => request + 1); + }); + } + }; + window.addEventListener("keydown", handler); + return () => window.removeEventListener("keydown", handler); + }, [ + activeView, + canShowGlobalComposer, + canUseGlobalComposerShortcut, + clearActiveSession, + clearGlobalComposerHandoffTimer, + closeDesignSystem, + globalComposerPlacement, + goBack, + goForward, + guardAppNavigation, + handleArchiveChat, + handleNavigate, + handleSelectSession, + isMultiWindowEnabled, + leaveSecondarySurface, + resetGlobalComposerTransition, + setDesignSystemInspectorVisible, + setActiveSession, + toggleSidebar, + ]); + + useEffect(() => { + if (showGlobalComposer) { + document.documentElement.setAttribute( + "data-global-composer-visible", + "true", + ); + } else { + document.documentElement.removeAttribute("data-global-composer-visible"); + } + + return () => { + document.documentElement.removeAttribute("data-global-composer-visible"); + }; + }, [showGlobalComposer]); + + const derivedStarterTaskCompletion = useMemo( + () => + deriveStarterTaskCompletion({ + providerReady: defaultProviderReadinessStatus === "ready", + sessionsHydrated: hasHydratedSessions, + sessions, + messagesBySession, + projectsFetched: hasFetchedProjects, + projects, + personasLoaded: !personasLoading, + personas, + }), + [ + defaultProviderReadinessStatus, + hasHydratedSessions, + sessions, + messagesBySession, + hasFetchedProjects, + projects, + personasLoading, + personas, + ], + ); + useEffect(() => { + const completedTaskIds = [...starterTasksAwaitingCompletion].filter( + (taskId) => derivedStarterTaskCompletion[taskId], + ); + if (completedTaskIds.length === 0) return; + + setStarterTasksAwaitingCompletion((awaiting) => { + const next = new Set(awaiting); + for (const taskId of completedTaskIds) next.delete(taskId); + return next; + }); + setStarterTaskOverrides((overrides) => { + const next = { ...overrides }; + for (const taskId of completedTaskIds) next[taskId] = true; + return next; + }); + }, [derivedStarterTaskCompletion, starterTasksAwaitingCompletion]); + + const starterTaskCompletion = starterTaskOverrides; + + useEffect(() => { + if (omittedStarterTaskIds.size === 0) return; + setStarterTasksAwaitingCompletion((awaiting) => { + const next = new Set(awaiting); + for (const taskId of omittedStarterTaskIds) next.delete(taskId); + return next.size === awaiting.size ? awaiting : next; + }); + }, [omittedStarterTaskIds]); + + useEffect(() => { + saveStarterTaskProgress({ + completion: starterTaskOverrides, + awaiting: starterTasksAwaitingCompletion, + }); + }, [starterTaskOverrides, starterTasksAwaitingCompletion]); + + useEffect(() => { + if (!starterTasksDocked) return; + if (renderedLocation.view !== "home") { + starterTasksLeftHomeRef.current = true; + return; + } + if (starterTasksLeftHomeRef.current) { + starterTasksLeftHomeRef.current = false; + setStarterTasksDocked(false); + } + }, [renderedLocation.view, starterTasksDocked]); + + useEffect(() => { + const handleStarterWidgetAdded = () => { + setStarterTasksAwaitingCompletion((awaiting) => { + if (!awaiting.has("add-widget")) return awaiting; + const next = new Set(awaiting); + next.delete("add-widget"); + return next; + }); + setStarterTaskOverrides((overrides) => ({ + ...overrides, + "add-widget": true, + })); + }; + window.addEventListener( + STARTER_WIDGET_ADDED_EVENT, + handleStarterWidgetAdded, + ); + return () => + window.removeEventListener( + STARTER_WIDGET_ADDED_EVENT, + handleStarterWidgetAdded, + ); + }, []); + + const handleStarterTaskToggle = (taskId: StarterTaskId) => { + setStarterTasksAwaitingCompletion((awaiting) => { + if (!awaiting.has(taskId)) return awaiting; + const next = new Set(awaiting); + next.delete(taskId); + return next; + }); + setStarterTaskOverrides((overrides) => ({ + ...overrides, + [taskId]: !overrides[taskId], + })); + }; + + const handleStarterProjectCreated = (projectId: string) => { + setStarterProjectId(projectId); + setStarterTasksAwaitingCompletion((awaiting) => { + const next = new Set(awaiting); + next.delete("create-project"); + return next; + }); + setStarterTaskOverrides((overrides) => ({ + ...overrides, + "create-project": true, + })); + const homeWidgetState = useHomeWidgetStore.getState(); + const starterProject = homeWidgetState.instances.find( + (instance) => + instance.type === "onboardingProjectArtifact" || + instance.state?.onboardingStarterProject === true, + ); + if (starterProject) { + homeWidgetState.updateWidgetState(starterProject.id, { + ...starterProject.state, + projectId, + }); + } + }; + + const handleStarterTaskSelect = (taskId: StarterTaskId) => { + starterTasksLeftHomeRef.current = renderedLocation.view !== "home"; + if (derivedStarterTaskCompletion[taskId]) { + setStarterTaskOverrides((overrides) => ({ + ...overrides, + [taskId]: true, + })); + } else { + setStarterTasksAwaitingCompletion((awaiting) => + new Set(awaiting).add(taskId), + ); + } + setStarterTasksDocked(true); + switch (taskId) { + case "connect-provider": + openSettings("providers"); + break; + case "start-chat": + void createNewTab(); + break; + case "create-project": + openCreateProjectDialog({ onCreated: handleStarterProjectCreated }); + break; + case "build-agent": + agentBuilder.create(); + break; + case "add-widget": + guardAppNavigation(() => { + // This task stays on Home, so keep the checklist on the canvas instead + // of docking it as an overlay with a redundant back arrow. + setStarterTasksDocked(false); + setActiveSession(null); + clearSettingsSectionUrl(); + setActiveView("home"); + window.setTimeout(requestStarterWidgetPicker, 0); + }); + break; + } + }; + + const handleStarterTasksBackHome = () => { + setStarterTasksDocked(false); + handleNavigate("home"); + }; + + const dismissStarterTasks = () => { + recordAssistiveMomentRetired("home.starterTasks", "dismissed"); + setStarterTasksEligible(false); + }; + + if (forceStartupLoading || !startup.ready || !startupLoadingMinElapsed) { + return ; + } + + if (startupIssue) { + return ( + + ); + } + + const shouldShowOnboarding = + onboardingExperiment?.enabled === true && + onboardingState.lifecycle !== "completed"; + if (shouldShowOnboarding) { + return ; + } + + return ( + + handleNavigate("home"), + onGoBack: goBack, + onGoForward: goForward, + showRightRailToggle: + activeView === "chat" && Boolean(activeSessionId), + chromeInsets: topBarChromeInsets, + rightRailOpen: isContextVisible, + rightRailLabel, + onToggleRightRail: toggleRightRail, + onFeedbackClick: isFeedbackEnabled ? handleFeedbackClick : undefined, + onSearchClick: () => setSearchDialogOpen(true), + }} + navigationPanes={{ + collapsed: false, + width: sidebarWidth, + isResizing: sidebarIsResizing, + onSettingsClick: () => handleNavigate("settings"), + onSettingsBack: leaveSecondarySurface, + onSettingsSectionChange: selectSettingsSection, + onNavigate: handleNavigate, + onNewChatInProject: handleNewChatInProject, + onNewChat: () => { + guardAppNavigation(() => { + void createNewTab(DEFAULT_CHAT_TITLE).catch((error) => { + console.error("Failed to start new chat:", error); + }); + }); + }, + onCreateProject: () => openCreateProjectDialog(), + onEditProject: handleEditProject, + onOpenProject: handleOpenProject, + onArchiveProject: handleArchiveProject, + onArchiveChat: handleArchiveChat, + onRenameChat: handleRenameChat, + onForkChat: handleForkChat, + onMarkChatRead: handleMarkChatRead, + onMarkChatUnread: handleMarkChatUnread, + onMoveToProject: handleMoveToProject, + onReorderProject: reorderProjects, + onSelectSession: handleSelectSession, + activeView, + activeSettingsSection, + activeSessionId, + projects, + className: "h-full rounded-md", + }} + sidebarCollapsed={sidebarCollapsed} + sidebarContentAnchor="right" + sidebarOuterWidth={sidebarDockedOuterWidth} + sidebarPanelOuterWidth={sidebarDockedPanelOuterWidth} + isResizing={sidebarIsResizing} + resizeHandleHeight={resizeHandleHeight} + resizeHandleWidth={resizeHandleWidth} + sidebarOuterHeight={sidebarOuterHeight} + onResizeStart={handleResizeStart} + onResizeDoubleClick={handleResizeDoubleClick} + onHeightResizeStart={handleHeightResizeStart} + onHeightResizeDoubleClick={handleHeightResizeDoubleClick} + onCornerResizeStart={handleCornerResizeStart} + onCornerResizeDoubleClick={handleCornerResizeDoubleClick} + contentUnderSidebar={activeView === "home"} + contentUnderTopBar={activeView === "home"} + projectTint={activeView === "chat" ? activeProjectTint : null} + designSystemInspectorModeToggleRequest={ + designSystemInspectorModeToggleRequest + } + onOpenDesignSystemExplorer={() => handleNavigate("design-system")} + showDesignSystemInspector={designSystemInspectorVisible} + contentTakeover={activeView === "design-system"} + createProjectDialog={{ + isOpen: createProjectOpen, + onClose: () => { + closeCreateProjectDialog(); + setStarterTasksAwaitingCompletion((awaiting) => { + if (!awaiting.has("create-project")) return awaiting; + const next = new Set(awaiting); + next.delete("create-project"); + return next; + }); + if (starterTasksDocked && renderedLocation.view === "home") { + setStarterTasksDocked(false); + } + }, + onCreated: handleProjectCreated, + initialWorkingDir: createProjectInitialWorkingDir, + editingProject: editingProject ?? undefined, + }} + > + {children ?? ( + + { + if (starterTasksVisible) { + setStarterTasksAwaitingCompletion((awaiting) => + new Set(awaiting).add("create-project"), + ); + openCreateProjectDialog({ + onCreated: handleStarterProjectCreated, + }); + return; + } + openCreateProjectDialog(); + }} + onOpenProjectSettings={handleEditProject} + onActivateHomeSession={activateHomeSession} + onRenameChat={handleRenameChat} + onForkChat={handleForkChat} + onSelectSession={handleSelectSession} + onSelectSearchResult={handleSelectSearchResult} + onStartChatFromProjectId={handleStartProjectChat} + onStartChatFromProject={handleStartChatFromProject} + onStartProjectChat={handleStartProjectChat} + onStartChatWithSkill={handleStartChatWithSkill} + onStartChatWithPrompt={handleStartChatWithBerdy} + onExitSearch={handleExitSearch} + onOpenExtension={handleOpenExtensionFromSearch} + onOpenAgent={handleStartChatWithAgent} + onOpenAutomation={handleOpenAutomationFromSearch} + onOpenSkill={handleStartChatWithSkill} + onTagHomeComposerAgent={handleTagHomeComposerAgent} + onTagHomeComposerProject={handleTagHomeComposerProject} + onTagHomeComposerSkill={handleTagHomeComposerSkill} + onHydratePinnedChatSessions={hydratePinnedChatSessions} + onLoggedOut={onLoggedOut} + onStartProviderTroubleshootingChat={ + handleStartProviderTroubleshootingChat + } + onReturnToAgentDraft={ + agentBuilderSettingsReturnTarget + ? returnToAgentBuilderSettingsTarget + : undefined + } + onOpenProvidersSettings={() => openSettings("providers")} + /> + {starterTasksVisible && starterTasksDocked ? ( + + t("home:onboarding.starterTasks.openTask", { label }), + completedTask: (label) => + t("home:onboarding.starterTasks.completedTask", { label }), + checkTask: (label) => + t("home:onboarding.starterTasks.checkTask", { label }), + uncheckTask: (label) => + t("home:onboarding.starterTasks.uncheckTask", { label }), + }} + onTaskSelect={handleStarterTaskSelect} + onTaskToggle={handleStarterTaskToggle} + onBackHome={handleStarterTasksBackHome} + onDismiss={dismissStarterTasks} + /> + ) : null} + {showGlobalComposerShim ? ( +