diff --git a/.bb-env-setup.sh b/.bb-env-setup.sh index 4f9a8dfe29..3397d1d816 100755 --- a/.bb-env-setup.sh +++ b/.bb-env-setup.sh @@ -15,8 +15,30 @@ run_step() { else exit_code=$? log "Warning: ${step_name} failed (exit ${exit_code}); continuing provisioning" - return 0 + return 1 + fi +} + +# Hash of the inputs that decide what `pnpm install` would do. Stored inside +# node_modules so it travels with an installed tree (copy-on-write +# environments) and disappears with it (fresh worktrees). +INSTALL_STAMP="node_modules/.bb-env-setup-install-hash" +INSTALL_INPUTS="pnpm-lock.yaml pnpm-workspace.yaml package.json .npmrc" + +install_inputs_hash() { + if command -v sha256sum >/dev/null 2>&1; then + hash_cmd="sha256sum" + elif command -v shasum >/dev/null 2>&1; then + hash_cmd="shasum -a 256" + else + return 1 fi + for input in ${INSTALL_INPUTS}; do + if [ -f "${input}" ]; then + printf '%s\n' "${input}" + ${hash_cmd} < "${input}" + fi + done | ${hash_cmd} | cut -d ' ' -f 1 } if ! command -v pnpm >/dev/null 2>&1; then @@ -29,4 +51,16 @@ if [ ! -f package.json ]; then exit 0 fi -run_step "pnpm install" pnpm install +current_hash="$(install_inputs_hash 2>/dev/null || true)" + +if [ -n "${current_hash}" ] && [ -f "${INSTALL_STAMP}" ] && [ -d node_modules/.pnpm ]; then + if [ "$(cat "${INSTALL_STAMP}" 2>/dev/null)" = "${current_hash}" ]; then + log "Skipping pnpm install: node_modules already matches the lockfile" + exit 0 + fi +fi + +if run_step "pnpm install" pnpm install && [ -n "${current_hash}" ]; then + printf '%s\n' "${current_hash}" > "${INSTALL_STAMP}" +fi +exit 0 diff --git a/.bb/skills/verify-bb/features/projects-environments.md b/.bb/skills/verify-bb/features/projects-environments.md index bd5e9a5f1e..af30ded59a 100644 --- a/.bb/skills/verify-bb/features/projects-environments.md +++ b/.bb/skills/verify-bb/features/projects-environments.md @@ -13,30 +13,32 @@ command’s `--help` before mutation. Use fresh browser snapshots for controls. ## Source -- `apps/app/src/views/ProjectSettingsView.tsx` +- `apps/app/src/views/ProjectDetailSettingsView.tsx` - `apps/app/src/components/project/ProjectActionsMenu.tsx` - `apps/cli/src/commands/project.ts` - `apps/cli/src/commands/environment.ts` +Legacy `/projects/:projectId/settings` bookmarks redirect with history replacement to `/settings/projects/:projectId`, preserving query and hash. Check the sidebar, project actions, header, and machine checkout links against the same detail page. + ## Feature recipes -| Feature | Drive | Observable success | -| --- | --- | --- | -| Create and rename projects | Run the local-project recipe, then rename through the project actions menu and reload; compare project show/update. | Project identity remains stable while its name changes. | -| Multiple sources and default source | Add a synthetic source on a second disposable host, change its path/default flag, then remove it with project source operations. Each host permits one source and the source host is immutable. | Sources persist, the intended default is selected, and the remaining source is still usable. | -| Git remote projects | Create a local-path project, then clone a disposable remote onto a second host using project source add --clone and the UI source controls. | Clone/provisioning uses the requested remote and branch; invalid remote errors do not create a usable fake checkout. | -| Recent repository import | On a disposable host home with synthetic recent repos, run the offered import action and inspect project list. | Only discovered candidates are imported; duplicates and missing paths are handled. Do not use real recent repos as fixtures. | -| Local versus managed worktree | Create one thread with Work locally and one with a new worktree and selected base branch. | Environment path, branch, and lifecycle match the selection; edits in the managed worktree do not affect the original checkout. | -| Reuse and switch environments | Select an existing environment for another thread; use environment update for display name/merge-base changes; test path switching separately through the thread environment-directory action after reading help. | Both thread details identify the intended environment; invalid paths fail without silently changing scope. | -| Environment status and branch discovery | Compare Info panel with environment show/status/branches and project branches for the same source. | Branch, dirty state, host, and path agree; disconnected or missing workspaces show an actionable error. | -| Diff views and selected patches | Open Diff with tracked edits, additions, renames, and deletions; use environment diff/diff-files/diff-file/diff-patch. | File lists, old/new contents, line numbers, and selected patches match git diff including untracked changes as supported. | -| Commit | Prepare a fixture containing only changes intended for a commit; invoke the UI/CLI Commit action and inspect git show and the clean diff. | The action stages all workspace changes with git add -A; the resulting commit contains the fixture changes. | -| Pull requests | With a disposable authenticated remote PR, inspect environment pull-request show; exercise ready, draft, and merge only in that test repo. | Forge state agrees with UI/CLI; missing auth/checks/conflicts produce explicit failures. Never run this on a user PR for documentation. | -| Archive environment threads | Create threads in two managed worktree environments and invoke environment archive-threads for one; try a local environment separately. | Only the selected managed environment’s active threads are archived; local environments are rejected with HTTP409. | -| Project attachments and history | Upload/download a synthetic file with project attachment; compare bytes; inspect project history and workspace file/path/content commands. | Returned content and history belong to the chosen project/host; missing files report failure. | -| Execution defaults | Set project defaults for environment/provider/model/permissions, open a new root draft and override one choice before sending. | Resolved defaults populate once, explicit draft choices win, and thread details reflect the actual execution options. | -| Clone destination and folder discovery | Browse an empty test host directory and inspect suggested clone path, path existence and invalid destination feedback. | Folder and clone suggestions target the chosen host; existing paths are not overwritten by a failed clone. | -| Delete project | Delete a disposable project through its confirmation flow, then inspect projects and its threads. | Deletion scope matches the confirmation; cancel leaves all state intact. | +| Feature | Drive | Observable success | +| --------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------- | +| Create and rename projects | Run the local-project recipe, then rename through the project actions menu and reload; compare project show/update. | Project identity remains stable while its name changes. | +| Multiple sources and default source | Add a synthetic source on a second disposable host, change its path/default flag, then remove it with project source operations. Each host permits one source and the source host is immutable. | Sources persist, the intended default is selected, and the remaining source is still usable. | +| Git remote projects | Create a local-path project, then clone a disposable remote onto a second host using project source add --clone and the UI source controls. | Clone/provisioning uses the requested remote and branch; invalid remote errors do not create a usable fake checkout. | +| Recent repository import | On a disposable host home with synthetic recent repos, run the offered import action and inspect project list. | Only discovered candidates are imported; duplicates and missing paths are handled. Do not use real recent repos as fixtures. | +| Local versus managed worktree | Create one thread with Work locally and one with a new worktree and selected base branch. | Environment path, branch, and lifecycle match the selection; edits in the managed worktree do not affect the original checkout. | +| Reuse and switch environments | Select an existing environment for another thread; use environment update for display name/merge-base changes; test path switching separately through the thread environment-directory action after reading help. | Both thread details identify the intended environment; invalid paths fail without silently changing scope. | +| Environment status and branch discovery | Compare Info panel with environment show/status/branches and project branches for the same source. | Branch, dirty state, host, and path agree; disconnected or missing workspaces show an actionable error. | +| Diff views and selected patches | Open Diff with tracked edits, additions, renames, and deletions; use environment diff/diff-files/diff-file/diff-patch. | File lists, old/new contents, line numbers, and selected patches match git diff including untracked changes as supported. | +| Commit | Prepare a fixture containing only changes intended for a commit; invoke the UI/CLI Commit action and inspect git show and the clean diff. | The action stages all workspace changes with git add -A; the resulting commit contains the fixture changes. | +| Pull requests | With a disposable authenticated remote PR, inspect environment pull-request show; exercise ready, draft, and merge only in that test repo. | Forge state agrees with UI/CLI; missing auth/checks/conflicts produce explicit failures. Never run this on a user PR for documentation. | +| Archive environment threads | Create threads in two managed worktree environments and invoke environment archive-threads for one; try a local environment separately. | Only the selected managed environment’s active threads are archived; local environments are rejected with HTTP409. | +| Project attachments and history | Upload/download a synthetic file with project attachment; compare bytes; inspect project history and workspace file/path/content commands. | Returned content and history belong to the chosen project/host; missing files report failure. | +| Execution defaults | Set project defaults for environment/provider/model/permissions, open a new root draft and override one choice before sending. | Resolved defaults populate once, explicit draft choices win, and thread details reflect the actual execution options. | +| Clone destination and folder discovery | Browse an empty test host directory and inspect suggested clone path, path existence and invalid destination feedback. | Folder and clone suggestions target the chosen host; existing paths are not overwritten by a failed clone. | +| Delete project | Delete a disposable project through its confirmation flow, then inspect projects and its threads. | Deletion scope matches the confirmation; cancel leaves all state intact. | ## Evidence and cleanup @@ -49,8 +51,8 @@ recipe. External writes require a disposable test target and task authorization. ## Maintenance notes -- Create a local project, then use its project actions menu → Rename and reload; compare source project show/update. Project settings contains source controls, not Rename. Source: `apps/app/src/components/project/ProjectActionsMenu.tsx:87; apps/cli/src/commands/project.ts:530`. -- Use two disposable hosts: each project permits one source per host. Select host when adding; update path/default with source operations. Move between hosts by adding/removing sources, not by changing a source host. Source: `apps/cli/src/commands/project.ts:574; apps/app/src/views/ProjectSettingsView.tsx:173`. +- Create a local project, then use its project actions menu → Rename and reload; compare source project show/update. Project settings opens Settings → Projects → project detail, including rename, checkouts, thread defaults, project information, and deletion. Source: `apps/app/src/components/project/ProjectActionsMenu.tsx:87; apps/cli/src/commands/project.ts:530`. +- Use two disposable hosts: each project permits one source per host. Select host when adding; update path/default with source operations. Move between hosts by adding/removing sources, not by changing a source host. Source: `apps/cli/src/commands/project.ts:574; apps/app/src/views/ProjectDetailSettingsView.tsx`. - Create a project from a local path, then use project source add --clone --remote-url --target-path on a second host. project create does not accept a remote URL. Source: `apps/cli/src/commands/project.ts:487; apps/cli/src/commands/project.ts:574`. - Await provisioning with thread show; its JSON wraps thread and environment. Initial spawn may have environmentId null. Source: `apps/cli/src/commands/thread/spawn.ts; apps/cli/src/commands/environment.ts:301`. - Reuse an environment with thread spawn --environment. environment update supports display name and merge-base override only. Test path switching with the supported thread environment-directory action separately. Source: `apps/cli/src/commands/environment.ts:579; apps/cli/src/commands/thread/spawn.ts`. diff --git a/.bb/skills/verify-bb/features/settings.md b/.bb/skills/verify-bb/features/settings.md index d95ea58022..5b58d4ac59 100644 --- a/.bb/skills/verify-bb/features/settings.md +++ b/.bb/skills/verify-bb/features/settings.md @@ -34,7 +34,7 @@ command’s `--help` before mutation. Use fresh browser snapshots for controls. | File openers and local editor | Configure file/directory defaults, extension-specific openers, and local editor integration; open a fixture through each. | Chosen handler and line/path are correct; reset/default fallbacks remain usable. | | Voice configuration | Load microphones, select one, configure the available AI service, and transcribe a harmless fixture. | Choice is applied to recording/transcription; missing browser permission or service is clearly reported. | | Usage and AI services | Inspect Usage limits, settings usage, and settings ai-services; compare provider-reported windows and service selections. | Unavailable data remains unavailable rather than zero; configured services are resolved by their owning plugin. | -| Experiments | Exercise changelogPreview, editMessages, mobileApp, sidebarProgressiveDisclosure, and timelineWindowing on/off in isolated data. | Only the named feature gate changes; disabled routes/actions fail or disappear as designed; state restores. | +| Experiments | Exercise changelogPreview, mobileApp, sidebarProgressiveDisclosure, and timelineWindowing on/off in isolated data. | Only the named feature gate changes; disabled routes/actions fail or disappear as designed; state restores. | | Debug events | Toggle Show unhandled provider events and render a trusted unsupported-event fixture. | The diagnostic row visibility follows the toggle without changing persisted event data. | | Community and update surfaces | Open Community links, version/update view, changelog, and CLI skills status. | Destinations and installed/latest status are correct; viewing does not perform an update. | | Configuration reload | Change an owned test configuration value and invoke settings reload. | The running app observes supported reloadable values and reports invalid configuration without losing working state. | diff --git a/.bb/skills/verify-bb/features/timeline.md b/.bb/skills/verify-bb/features/timeline.md index 545ce8bfeb..c43385b16a 100644 --- a/.bb/skills/verify-bb/features/timeline.md +++ b/.bb/skills/verify-bb/features/timeline.md @@ -29,7 +29,7 @@ command’s `--help` before mutation. Use fresh browser snapshots for controls. | Tool work and errors | Expand command output, file changes, grouped tool calls, nested agents, and a provider error. | Arguments/results and failure details correspond to the correct call and are readable after reload. | | Images and media | Open an attachment image/lightbox, zoom or dismiss, and load a missing asset. | Correct media opens and missing content shows an error; closing restores the thread. | | Copy, selection, Add to chat | Copy a whole message and selected text, add a quote to the composer, then remove it. | Clipboard/quote content matches the selection, without hidden tool payloads or duplicate context. | -| Edit accepted message | With Edit messages enabled and a supporting provider, replace a user message and rerun. | History is rewound from the intended checkpoint; later content is not treated as unchanged. | +| Edit accepted message | With a supporting provider, replace a user message and rerun. | History is rewound from the intended checkpoint; later content is not treated as unchanged. | | Checkpoint fork and handoff | Fork at a chosen message and separately from current context; select workspace reuse/new workspace as offered. | Fork contains the correct prefix and parent relationship and executes in the chosen environment. | | Side chat | Follow plugin-side-chat for selected-message forks and Send to main. | Main history remains untouched until an explicit send-back queues content. | | File links and external links | Open an absolute file link with a line number, thread-storage attachment, and HTTP link. | Correct file/line/source opens; external/embedded browser policy is respected. | diff --git a/.github/APPROVED_CONTRIBUTORS b/.github/APPROVED_CONTRIBUTORS index f9569a4412..0d298dd35a 100644 --- a/.github/APPROVED_CONTRIBUTORS +++ b/.github/APPROVED_CONTRIBUTORS @@ -66,3 +66,5 @@ danielbachhuber aivanov93 MayankBansal12 vznh +MacHatter1 +noih diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a2dc58bf06..88abd53e85 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -138,6 +138,12 @@ jobs: pnpm-version: ${{ env.PNPM_VERSION }} cache-prefix: test-${{ matrix.shard }} + - name: Setup Bun + if: ${{ matrix.shard == 'packages' }} + uses: oven-sh/setup-bun@735343b667d3e6f658f44d0eca948eb6282f2b76 + with: + bun-version: "1.3.14" + - name: Test run: pnpm exec turbo run test ${{ matrix.filter }} --cache-dir=.turbo/cache --output-logs=new-only --concurrency=4 ${{ matrix.args }} diff --git a/.gitignore b/.gitignore index 6e11b3ad03..975012a8ba 100644 --- a/.gitignore +++ b/.gitignore @@ -91,3 +91,5 @@ provider-recordings/raw/ **/provider-corpus/** !apps/server/test/provider-corpus/** !scripts/provider-corpus/** +.builtin-server-test-*/ +.builtin-host-test-*/ diff --git a/apps/app/.ladle/config.mjs b/apps/app/.ladle/config.mjs index 38c1490835..4ec7d5759e 100644 --- a/apps/app/.ladle/config.mjs +++ b/apps/app/.ladle/config.mjs @@ -39,7 +39,11 @@ function formatNetworkUrls(serverUrl) { /** @type {import("@ladle/react").UserConfig} */ export default { - stories: ["src/**/*.stories.tsx", "../../plugins/workflows/**/*.stories.tsx"], + stories: [ + "src/**/*.stories.tsx", + "../../plugins/workflows/**/*.stories.tsx", + "../../plugins/ask-user-question/*.stories.tsx", + ], defaultStory: "", viteConfig: "./.ladle/vite.config.ts", host: "0.0.0.0", diff --git a/apps/app/.ladle/model-picker-query-provider.tsx b/apps/app/.ladle/model-picker-query-provider.tsx index 7676b2a4f7..c8edf846b5 100644 --- a/apps/app/.ladle/model-picker-query-provider.tsx +++ b/apps/app/.ladle/model-picker-query-provider.tsx @@ -17,6 +17,7 @@ import { STORY_CODEX_MODELS, STORY_CODEX_REASONING, STORY_PI_MODELS, + STORY_PI_REASONING, STORY_PROVIDER_OPTIONS, STORY_SERVICE_TIER_SUPPORT, } from "./story-fixtures"; @@ -86,29 +87,31 @@ function makeAvailableModels({ reasoningOptions, markFirstDefault = true, }: { - models: readonly ModelPickerOption[]; + models: readonly (ModelPickerOption & { + reasoningOptions?: readonly PickerOption[]; + })[]; reasoningOptions: readonly PickerOption[]; markFirstDefault?: boolean; }): AvailableModel[] { - const defaultReasoningEffort = - reasoningOptions.find((option) => option.value === "medium")?.value ?? - reasoningOptions[0]?.value ?? - "medium"; - const supportedReasoningEfforts = - makeSupportedReasoningEfforts(reasoningOptions); - - return models.map((model, index) => ({ - id: model.value, - model: model.value, - displayName: model.label, - ...(model.routeProviderId - ? { routeProviderId: model.routeProviderId } - : {}), - description: "", - supportedReasoningEfforts, - defaultReasoningEffort, - isDefault: markFirstDefault && index === 0, - })); + return models.map((model, index) => { + const modelReasoning = model.reasoningOptions ?? reasoningOptions; + const defaultReasoningEffort = + modelReasoning.find((option) => option.value === "medium")?.value ?? + modelReasoning[0]?.value ?? + "medium"; + return { + id: model.value, + model: model.value, + displayName: model.label, + ...(model.routeProviderId + ? { routeProviderId: model.routeProviderId } + : {}), + description: "", + supportedReasoningEfforts: makeSupportedReasoningEfforts(modelReasoning), + defaultReasoningEffort, + isDefault: markFirstDefault && index === 0, + }; + }); } function makeExecutionOptions( @@ -159,7 +162,7 @@ function createStoryQueryClient(): QueryClient { pi: makeExecutionOptions( makeAvailableModels({ models: STORY_PI_MODELS, - reasoningOptions: STORY_CODEX_REASONING, + reasoningOptions: STORY_PI_REASONING, }), ), }; diff --git a/apps/app/.ladle/settings-story-fixtures.tsx b/apps/app/.ladle/settings-story-fixtures.tsx index 4621364c93..46eda1df7d 100644 --- a/apps/app/.ladle/settings-story-fixtures.tsx +++ b/apps/app/.ladle/settings-story-fixtures.tsx @@ -37,9 +37,11 @@ import { HOST_IDS, HOST_NAMES, PROJECT_IDS, + PROJECT_NAMES, STORY_PROJECT_SOURCES, makeHost, makeProject, + makeThreadListEntry, makeProviderCliStatus, } from "./story-fixtures"; import codexLogoUrl from "../../../plugins/provider-codex/icons/codex.svg"; @@ -111,8 +113,32 @@ const remoteProviderStatus = { const project = makeProject({ id: PROJECT_IDS.bb, + gitRemoteUrl: "git@github.com:get-bb/bb.git", sources: [...STORY_PROJECT_SOURCES], }); +const pierreProject = makeProject({ + id: PROJECT_IDS.pierre, + name: PROJECT_NAMES.pierre, + gitRemoteUrl: "https://github.com/get-bb/pierre.git", + sources: [ + { + id: "src_pierre_remote", + projectId: PROJECT_IDS.pierre, + type: "local_path", + hostId: HOST_IDS.remote, + path: "/home/michael/pierre", + isDefault: true, + createdAt: 0, + updatedAt: 0, + }, + ], +}); +const ingestProject = makeProject({ + id: PROJECT_IDS.ingest, + name: PROJECT_NAMES.ingest, + gitRemoteUrl: null, + sources: [], +}); const personalProject = makeProject({ id: PERSONAL_PROJECT_ID, kind: "personal", @@ -122,7 +148,28 @@ const personalProject = makeProject({ const sidebarNavigation = { sections: [], - projects: [{ ...project, defaultExecutionOptions: null, threads: [] }], + projects: [ + { + ...project, + defaultExecutionOptions: null, + threads: [ + makeThreadListEntry({ id: "thr_bb_1", projectId: PROJECT_IDS.bb }), + makeThreadListEntry({ id: "thr_bb_2", projectId: PROJECT_IDS.bb }), + makeThreadListEntry({ id: "thr_bb_3", projectId: PROJECT_IDS.bb }), + ], + }, + { + ...pierreProject, + defaultExecutionOptions: null, + threads: [ + makeThreadListEntry({ + id: "thr_pierre_1", + projectId: PROJECT_IDS.pierre, + }), + ], + }, + { ...ingestProject, defaultExecutionOptions: null, threads: [] }, + ], personalProject: { ...personalProject, defaultExecutionOptions: null, diff --git a/apps/app/.ladle/story-fixtures.ts b/apps/app/.ladle/story-fixtures.ts index 15b27cfeea..903ff0e50d 100644 --- a/apps/app/.ladle/story-fixtures.ts +++ b/apps/app/.ladle/story-fixtures.ts @@ -1,3 +1,5 @@ +import { useState } from "react"; +import { reconcileReasoningLevel } from "@bb/domain"; import type { Host, ProjectSource, @@ -10,7 +12,10 @@ import type { ProviderCliKey, ProviderCliStatus, } from "@bb/host-daemon-contract"; -import type { ProjectResponse } from "@bb/server-contract"; +import type { + ProjectResponse, + SystemEnvironmentProvider, +} from "@bb/server-contract"; import { EMPTY_ORDERED_MENTION_SUGGESTIONS } from "@bb/client-core"; import { makeEnvironment as makeEnvironmentFixture, @@ -24,7 +29,7 @@ import { getProviderIconInfo } from "../src/lib/provider-icon"; import type { PickerOption } from "../src/components/pickers/OptionPicker"; import type { ModelPickerOption } from "../src/components/pickers/model-picker-option"; import type { ProjectSelectorOption } from "../src/components/pickers/ProjectSelector"; -import type { ReuseThreadOption } from "../src/components/pickers/WorktreePicker"; +import type { ReuseThreadOption } from "../src/components/pickers/ReuseEnvironmentPicker"; import type { ExecutionControlsProps } from "../src/components/promptbox/ExecutionControls"; import { INERT_TYPEAHEAD_COMMAND_CONFIG, @@ -162,51 +167,79 @@ export const STORY_CLAUDE_CODE_MORE_MODELS: readonly PickerOption[] = [ { value: "claude-haiku-4-5", label: "Claude Haiku 4.5" }, ]; -export const STORY_PI_MODELS: readonly ModelPickerOption[] = [ +export const STORY_PI_REASONING: readonly PickerOption[] = [ + { value: "none", label: "None" }, + { value: "low", label: "Low" }, + { value: "medium", label: "Medium" }, + { value: "high", label: "High" }, + { value: "xhigh", label: "Extra High" }, +]; + +const STORY_PI_OPUS_REASONING: readonly PickerOption[] = [ + ...STORY_PI_REASONING, + { value: "max", label: "Max" }, +]; + +export const STORY_PI_MODELS: readonly (ModelPickerOption & { + reasoningOptions: readonly PickerOption[]; +})[] = [ { value: "openai-codex/gpt-5.5", label: "GPT-5.5", routeProviderId: "openai-codex", + reasoningOptions: STORY_PI_REASONING, }, { value: "openai-codex/gpt-5.4", label: "GPT-5.4", routeProviderId: "openai-codex", + reasoningOptions: STORY_PI_REASONING, }, { value: "openai-codex/gpt-5.4-mini", label: "GPT-5.4 Mini", routeProviderId: "openai-codex", + reasoningOptions: STORY_PI_REASONING, }, { - value: "openai-codex/gpt-5.3-codex", + value: "openai/gpt-5.3-codex", label: "GPT-5.3 Codex", - routeProviderId: "openai-codex", + routeProviderId: "openai", + reasoningOptions: STORY_PI_REASONING, }, { value: "openai/gpt-5.3-codex-spark", label: "GPT-5.3 Codex Spark", routeProviderId: "openai", + reasoningOptions: STORY_PI_REASONING.filter( + ({ value }) => value !== "none", + ), }, { value: "openai-codex/gpt-5.3-codex-spark", label: "GPT-5.3 Codex Spark", routeProviderId: "openai-codex", + reasoningOptions: STORY_PI_REASONING, }, { value: "anthropic/claude-haiku-4-5", label: "Claude Haiku 4.5", routeProviderId: "anthropic", + reasoningOptions: STORY_PI_REASONING.filter( + ({ value }) => value !== "xhigh", + ), }, { value: "anthropic/claude-opus-4-8", label: "Claude Opus 4.8", routeProviderId: "anthropic", + reasoningOptions: STORY_PI_OPUS_REASONING, }, { value: "anthropic/claude-opus-4-7", label: "Claude Opus 4.7", routeProviderId: "anthropic", + reasoningOptions: STORY_PI_OPUS_REASONING, }, ]; @@ -215,6 +248,8 @@ export const STORY_CODEX_REASONING: readonly PickerOption[] = [ { value: "medium", label: "Medium" }, { value: "high", label: "High" }, { value: "xhigh", label: "Extra High" }, + { value: "max", label: "Max" }, + { value: "ultra", label: "Ultra" }, ]; export const STORY_CLAUDE_REASONING: readonly PickerOption[] = [ @@ -222,6 +257,7 @@ export const STORY_CLAUDE_REASONING: readonly PickerOption[] = [ { value: "medium", label: "Medium" }, { value: "high", label: "High" }, { value: "xhigh", label: "Extra High" }, + { value: "ultracode", label: "Ultracode" }, { value: "max", label: "Max" }, ]; @@ -267,6 +303,8 @@ export const STORY_WORKTREE_OPTIONS: readonly ReuseThreadOption[] = [ environmentId: "env_review_flow", branchName: "bb/review-flow-thr_4hge9xn14m", name: null, + path: null, + environmentProviderId: "git-worktree", threads: [ { id: "thr_review", title: "Review flow cleanup" }, { id: "thr_tests", title: "Backfill promptbox tests" }, @@ -276,10 +314,67 @@ export const STORY_WORKTREE_OPTIONS: readonly ReuseThreadOption[] = [ environmentId: "env_timeline", branchName: "bb/timeline-pagination-thr_qfk8ksbxkk", name: "Timeline workspace", + path: null, + environmentProviderId: "git-worktree", threads: [{ id: "thr_timeline", title: "Timeline pagination" }], }, ]; +export const STORY_ENVIRONMENT_PROVIDERS: readonly SystemEnvironmentProvider[] = + [ + { + id: "project-checkout", + displayName: "Project checkout", + icon: "Laptop", + logoUrl: null, + pluginId: "environment-project-checkout", + acceptsEmptyInputs: true, + machineAvailability: {}, + availability: null, + requires: { + projectCheckout: true, + gitCheckout: false, + gitRemote: false, + projectless: false, + }, + inputs: null, + }, + { + id: "git-worktree", + displayName: "Worktree", + icon: "GitBranch", + logoUrl: null, + pluginId: "environment-git-worktree", + acceptsEmptyInputs: true, + machineAvailability: {}, + availability: null, + requires: { + projectCheckout: true, + gitCheckout: true, + gitRemote: false, + projectless: false, + }, + inputs: null, + }, + { + id: "personal-workspace", + displayName: "Personal workspace", + icon: "Folder", + logoUrl: null, + pluginId: "environment-personal-workspace", + acceptsEmptyInputs: true, + machineAvailability: {}, + availability: null, + requires: { + projectCheckout: false, + gitCheckout: false, + gitRemote: false, + projectless: true, + }, + inputs: null, + }, + ]; + export const STORY_PROJECTS: readonly ProjectSelectorOption[] = [ { id: PROJECT_IDS.bb, name: PROJECT_NAMES.bb }, { id: PROJECT_IDS.pierre, name: PROJECT_NAMES.pierre }, @@ -319,6 +414,98 @@ export function makeExecutionControlsProps( return { ...base, ...overrides }; } +export function useInteractiveExecutionControls( + base: ExecutionControlsProps, +): ExecutionControlsProps { + const [providerId, setProviderId] = useState( + base.provider.selectedId ?? "codex", + ); + const [model, setModel] = useState(base.model.selected); + const [reasoning, setReasoning] = useState(base.reasoning.value); + const [serviceTier, setServiceTier] = useState(base.serviceTier?.value); + const catalogForProvider = (id: string, selectedModel = model) => { + if (id === base.provider.selectedId) { + return { + models: base.model.options, + moreModels: base.model.moreOptions, + reasoning: base.reasoning.options, + }; + } + return { + models: + id === "claude-code" + ? STORY_CLAUDE_CODE_MODELS + : id === "pi" + ? STORY_PI_MODELS + : STORY_CODEX_MODELS, + moreModels: id === "claude-code" ? STORY_CLAUDE_CODE_MORE_MODELS : [], + reasoning: + id === "claude-code" + ? STORY_CLAUDE_REASONING + : id === "pi" + ? (STORY_PI_MODELS.find((option) => option.value === selectedModel) + ?.reasoningOptions ?? STORY_PI_REASONING) + : STORY_CODEX_REASONING, + }; + }; + const catalog = catalogForProvider(providerId); + return { + ...base, + provider: { + ...base.provider, + selectedId: providerId, + onChange: (id) => { + const next = catalogForProvider(id, ""); + setProviderId(id); + setModel(next.models[0]?.value ?? ""); + if (next.reasoning.length > 0) { + setReasoning((current) => + reconcileReasoningLevel( + current, + next.reasoning.map((option) => option.value), + ), + ); + } + setServiceTier(undefined); + }, + }, + model: { + ...base.model, + active: { model }, + selected: model, + options: catalog.models, + moreOptions: catalog.moreModels, + onChange: (value) => { + setModel(value); + const next = catalogForProvider(providerId, value); + if (next.reasoning.length > 0) { + setReasoning((current) => + reconcileReasoningLevel( + current, + next.reasoning.map((option) => option.value), + ), + ); + } + }, + }, + reasoning: { + value: reasoning, + options: catalog.reasoning, + onChange: setReasoning, + }, + ...(base.serviceTier + ? { + serviceTier: { + ...base.serviceTier, + value: serviceTier, + onChange: setServiceTier, + supported: STORY_SERVICE_TIER_SUPPORT[providerId] ?? false, + }, + } + : {}), + }; +} + export function makeThread(overrides: Partial = {}): Thread { return makeThreadFixture({ id: "thr_demo", diff --git a/apps/app/.ladle/story-settings-chrome.tsx b/apps/app/.ladle/story-settings-chrome.tsx index cb68ea89b9..0331eaa0d3 100644 --- a/apps/app/.ladle/story-settings-chrome.tsx +++ b/apps/app/.ladle/story-settings-chrome.tsx @@ -15,11 +15,13 @@ import { PageShell } from "@/components/ui/page-shell"; import { SETTINGS_ROUTE_PATH, SETTINGS_MACHINE_ROUTE_PATH, + SETTINGS_PROJECT_ROUTE_PATH, getSettingsRoutePath, } from "@/lib/route-paths"; export type SettingsStoryRoute = | { kind: "machine"; id: string } + | { kind: "project"; id: string } | { kind: "section"; id: SettingsSectionId }; export function useSettingsStoryRoute(): SettingsStoryRoute { @@ -28,6 +30,10 @@ export function useSettingsStoryRoute(): SettingsStoryRoute { if (machineMatch?.params.hostId !== undefined) { return { kind: "machine", id: machineMatch.params.hostId }; } + const projectMatch = matchPath(SETTINGS_PROJECT_ROUTE_PATH, pathname); + if (projectMatch?.params.projectId !== undefined) { + return { kind: "project", id: projectMatch.params.projectId }; + } const section = SETTINGS_NAV_SECTIONS.find((entry) => entry.id === "general" ? pathname === SETTINGS_ROUTE_PATH @@ -47,7 +53,12 @@ export function SettingsStoryChrome({ }) { const route = useSettingsStoryRoute(); const resolvedActiveSection = - activeSection ?? (route.kind === "section" ? route.id : "machines"); + activeSection ?? + (route.kind === "section" + ? route.id + : route.kind === "project" + ? "projects" + : "machines"); return ( { render(
diff --git a/apps/app/src/App.legacy-skill-route.test.tsx b/apps/app/src/App.legacy-skill-route.test.tsx index 722507ca98..770abde919 100644 --- a/apps/app/src/App.legacy-skill-route.test.tsx +++ b/apps/app/src/App.legacy-skill-route.test.tsx @@ -1,24 +1,31 @@ // @vitest-environment jsdom -import { cleanup, render, screen } from "@testing-library/react"; -import { afterEach, describe, expect, it } from "vitest"; -import { MemoryRouter, Route, Routes, useLocation } from "react-router-dom"; -import { - ExtensionsLandingRedirect, - LegacySkillDetailRedirect, - LegacyToolsPathRedirect, -} from "./App"; -import { - LEGACY_TOOLS_AUTOMATIONS_ROUTE_PATH, - LEGACY_TOOLS_PREFIX_ROUTE_PATH, - LEGACY_TOOLS_SKILL_DETAIL_ROUTE_PATH, - LEGACY_TOOLS_SPLAT_ROUTE_PATH, - TOOLS_PLUGIN_BROWSE_ROUTE_PATH, - TOOLS_PLUGIN_DETAIL_ROUTE_PATH, - TOOLS_PLUGINS_ROUTE_PATH, - TOOLS_ROUTE_PATH, - TOOLS_SKILL_DETAIL_ROUTE_PATH, -} from "./lib/route-paths"; +import type { ReactNode } from "react"; +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { MemoryRouter, useLocation, useNavigate } from "react-router-dom"; +import { AppRoutes } from "./App"; + +vi.mock("./components/layout/AppLayout", () => ({ + AppLayout: ({ children }: { children: ReactNode }) => <>{children}, +})); +vi.mock("./views/SettingsView", () => ({ + SettingsView: () =>

Settings

, +})); +vi.mock("./views/ToolsView", () => ({ + PluginsView: ({ pluginId }: { pluginId?: string }) => ( +

Plugin detail: {pluginId}

+ ), + SkillsView: () =>

Skills

, +})); +vi.mock("./views/SplitWorkspaceRoute", () => ({ + default: () =>

App workspace

, +})); + +function HistoryBackButton() { + const navigate = useNavigate(); + return ; +} function LocationPath() { const location = useLocation(); @@ -31,137 +38,94 @@ function LocationPath() { ); } -describe("LegacySkillDetailRedirect", () => { - afterEach(cleanup); - - it("preserves old installed links while Library remains canonical", () => { - render( - - - } - /> - } - /> - - , - ); - - expect( - screen.getByText("/extensions/skills/library/skill_abc123"), - ).toBeTruthy(); - }); -}); - -describe("ExtensionsLandingRedirect", () => { - afterEach(cleanup); - - it("opens Extensions on Plugins by default", () => { - render( - - - } - /> - } /> - - , - ); - - expect(screen.getByText(TOOLS_PLUGINS_ROUTE_PATH)).toBeTruthy(); - }); -}); - -describe("LegacyToolsPathRedirect", () => { - afterEach(cleanup); - - it("forwards /tools deep links to /extensions keeping subpath, query, and hash", () => { - render( - - - } - /> - } - /> - - , - ); - - expect( - screen.getByText( - "/extensions/plugins/github?view=installed#configuration", - ), - ).toBeTruthy(); - }); - - it("forwards bare /tools into the Extensions landing redirect", () => { - render( - - - } - /> - } - /> - } /> - - , - ); - - expect(screen.getByText(TOOLS_PLUGINS_ROUTE_PATH)).toBeTruthy(); - }); - - it("loses /tools/automations to that route's own more-specific redirect", () => { - render( - - - } - /> - } - /> - - , - ); - - expect(screen.getByText(LEGACY_TOOLS_AUTOMATIONS_ROUTE_PATH)).toBeTruthy(); - }); -}); - -describe("legacy plugin browse redirect", () => { - afterEach(cleanup); +afterEach(cleanup); + +describe("legacy resource redirects", () => { + it.each(["github", "plugin with spaces"])( + "opens workspace installed detail for %s alongside Settings routes", + async (pluginId) => { + const settingsPath = `/settings/plugins/${encodeURIComponent(pluginId)}`; + render( + + + + + , + ); + expect( + await screen.findByRole("heading", { + name: `Plugin detail: ${pluginId}`, + }), + ).toBeTruthy(); + expect( + screen.getByText( + `/plugins/${encodeURIComponent(pluginId)}?view=installed&from=bookmark#details`, + ), + ).toBeTruthy(); + fireEvent.click(screen.getByRole("button", { name: "Back" })); + expect( + await screen.findByRole("heading", { name: "Settings" }), + ).toBeTruthy(); + expect(screen.getByText(settingsPath)).toBeTruthy(); + }, + ); - it("redirects the old Browse path to the canonical bare Plugins route", () => { + it.each([ + ["/settings/plugins", "/settings/plugins"], + ["/extensions?view=installed#catalog", "/plugins?view=installed#catalog"], + ["/extensions/plugins", "/plugins"], + [ + "/extensions/plugins/browse?sort=name#catalog", + "/plugins?sort=name#catalog", + ], + [ + "/extensions/plugins/browse/?sort=name#catalog", + "/plugins?sort=name#catalog", + ], + [ + "/extensions/plugins/github?view=installed#configuration", + "/plugins/github?view=installed#configuration", + ], + ["/extensions/skills", "/skills"], + [ + "/extensions/skills/library/skill_abc123?source=local#details", + "/skills/library/skill_abc123?source=local#details", + ], + [ + "/extensions/skills/installed/skill_abc123", + "/skills/library/skill_abc123", + ], + ["/extensions/skills/registry", "/skills/registry"], + [ + "/extensions/skills/registry/moss-skills%2Fmoss-notes", + "/skills/registry/moss-skills%2Fmoss-notes", + ], + ["/tools", "/plugins"], + ["/tools/plugins/browse", "/plugins"], + ["/tools/plugins/browse/?sort=name#catalog", "/plugins?sort=name#catalog"], + [ + "/tools/plugins/github?view=installed#configuration", + "/plugins/github?view=installed#configuration", + ], + [ + "/tools/skills/installed/skill_abc123?source=local#details", + "/skills/library/skill_abc123?source=local#details", + ], + ["/tools/automations", "/plugins/automations/automations"], + ])("redirects %s to %s", async (entry, expected) => { render( - - - } - /> - } /> - + + + , ); - expect(screen.getByText(TOOLS_PLUGINS_ROUTE_PATH)).toBeTruthy(); + expect(await screen.findByText(expected)).toBeTruthy(); }); }); diff --git a/apps/app/src/App.project-settings-routes.test.tsx b/apps/app/src/App.project-settings-routes.test.tsx new file mode 100644 index 0000000000..dfbaa68e86 --- /dev/null +++ b/apps/app/src/App.project-settings-routes.test.tsx @@ -0,0 +1,86 @@ +// @vitest-environment jsdom + +import type { ReactNode } from "react"; +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + MemoryRouter, + useLocation, + useNavigate, + useParams, +} from "react-router-dom"; +import { AppRoutes } from "./App"; + +vi.mock("./components/layout/AppLayout", () => ({ + AppLayout: ({ children }: { children: ReactNode }) => <>{children}, +})); +vi.mock("./views/ProjectDetailSettingsView", () => ({ + ProjectDetailSettingsView: () => { + const { projectId } = useParams(); + return

Project detail: {projectId}

; + }, +})); +vi.mock("./views/SettingsView", () => ({ + SettingsView: () =>

Settings

, +})); + +function NavigationProbe() { + const location = useLocation(); + const navigate = useNavigate(); + return ( + <> + + {location.pathname} + {location.search} + {location.hash} + + + + ); +} + +function renderRoute(path: string) { + render( + + + + , + ); +} + +afterEach(cleanup); + +describe("project settings routes", () => { + it.each([ + "proj_example", + "proj_missing", + "proj_personal", + "project with spaces", + ])("opens the existing detail for legacy project %s", async (projectId) => { + renderRoute( + `/projects/${encodeURIComponent(projectId)}/settings?from=bookmark#checkouts`, + ); + expect( + await screen.findByRole("heading", { + name: `Project detail: ${projectId}`, + }), + ).toBeTruthy(); + expect(screen.getByRole("status").textContent).toBe( + `/settings/projects/${encodeURIComponent(projectId)}?from=bookmark#checkouts`, + ); + fireEvent.click(screen.getByRole("button", { name: "Back" })); + expect( + await screen.findByRole("heading", { name: "Settings" }), + ).toBeTruthy(); + expect(screen.getByRole("status").textContent).toBe("/settings"); + }); + + it("keeps the Settings detail destination directly accessible", async () => { + renderRoute("/settings/projects/proj_example"); + expect( + await screen.findByRole("heading", { + name: "Project detail: proj_example", + }), + ).toBeTruthy(); + }); +}); diff --git a/apps/app/src/App.tsx b/apps/app/src/App.tsx index fbd5cbbaf8..d26badbd9f 100644 --- a/apps/app/src/App.tsx +++ b/apps/app/src/App.tsx @@ -1,5 +1,6 @@ import { lazy, Suspense, useEffect } from "react"; import { + matchPath, Navigate, Route, Routes, @@ -13,6 +14,7 @@ import { RouteNavigationProvider } from "./components/ui/app-route-anchor"; import { RouteNavigationIndicator } from "./components/ui/route-navigation-indicator"; import { AppNavigationUrlHost } from "./lib/url-open-routing"; import { NativeShellReporter } from "./lib/native-shell"; +import { UiPreferencesSync } from "@/lib/ui-preferences/UiPreferencesSync"; import { AppFileExternalNavigationHost } from "./components/plugin/AppFileExternalNavigationHost"; import { useAppTheme } from "./hooks/useAppTheme"; import { useFaviconColorSync } from "./lib/favicon-color-preference"; @@ -25,7 +27,6 @@ import { AUTH_CALLBACK_ROUTE_PATH, LEGACY_AUTOMATION_DETAIL_ROUTE_PATH, LEGACY_AUTOMATIONS_ROUTE_PATH, - LEGACY_SKILLS_ROUTE_PATH, LEGACY_TOOLS_AUTOMATION_BROWSE_ROUTE_PATH, LEGACY_TOOLS_AUTOMATION_DETAIL_ROUTE_PATH, LEGACY_TOOLS_AUTOMATION_EDIT_ROUTE_PATH, @@ -35,24 +36,32 @@ import { LEGACY_TOOLS_SPLAT_ROUTE_PATH, PROJECT_ARCHIVED_ROUTE_PATH, PROJECTLESS_ARCHIVED_ROUTE_PATH, - PROJECT_SETTINGS_ROUTE_PATH, + LEGACY_PROJECT_SETTINGS_ROUTE_PATH, + PLUGIN_DETAIL_ROUTE_PATH, + PLUGINS_ROUTE_PATH, + REGISTRY_SKILL_DETAIL_ROUTE_PATH, + REGISTRY_SKILLS_ROUTE_PATH, SETTINGS_PLUGIN_ROUTE_PATH, SETTINGS_PLUGINS_ROUTE_PATH, SETTINGS_MACHINE_ROUTE_PATH, + SETTINGS_PROJECT_ROUTE_PATH, SETTINGS_ROUTE_PATH, SETTINGS_SECTION_ROUTE_PATH, + SKILL_DETAIL_ROUTE_PATH, SKILLS_ROUTE_PATH, TOOLS_PLUGIN_BROWSE_ROUTE_PATH, + TOOLS_PLUGIN_DETAIL_ROUTE_PATH, TOOLS_PLUGINS_ROUTE_PATH, TOOLS_REGISTRY_SKILL_DETAIL_ROUTE_PATH, TOOLS_REGISTRY_SKILLS_ROUTE_PATH, TOOLS_ROUTE_PATH, TOOLS_SKILL_DETAIL_ROUTE_PATH, + TOOLS_SKILLS_ROUTE_PATH, getAutomationDetailRoutePath, getAutomationEditRoutePath, getAutomationsRoutePath, getSettingsRoutePath, - getSkillDetailRoutePath, + getSettingsProjectRoutePath, } from "./lib/route-paths"; import { AppCommandProvider } from "./components/commands/AppCommandProvider"; import { ProviderCliInstallLogDialogHost } from "./components/provider-cli/provider-cli-install"; @@ -63,9 +72,19 @@ const SettingsView = lazy(() => default: m.SettingsView, })), ); -const ToolsView = lazy(() => +const PluginsView = lazy(() => import("./views/ToolsView").then((m) => ({ - default: m.ToolsView, + default: m.PluginsView, + })), +); +const SkillsView = lazy(() => + import("./views/ToolsView").then((m) => ({ + default: m.SkillsView, + })), +); +const ProjectDetailSettingsView = lazy(() => + import("./views/ProjectDetailSettingsView").then((m) => ({ + default: m.ProjectDetailSettingsView, })), ); const MachineSettingsView = lazy(() => @@ -73,15 +92,27 @@ const MachineSettingsView = lazy(() => default: m.MachineSettingsView, })), ); -const ProjectSettingsView = lazy(() => - import("./views/ProjectSettingsView").then((m) => ({ - default: m.ProjectSettingsView, - })), -); const splitWorkspaceRouteModule = import("./views/SplitWorkspaceRoute"); splitWorkspaceRouteModule.catch(() => {}); const SplitWorkspaceRoute = lazy(() => splitWorkspaceRouteModule); +function LegacyProjectSettingsRedirect() { + const { projectId } = useParams<{ projectId: string }>(); + const { search, hash } = useLocation(); + return ( + + ); +} + export function LegacyAutomationDetailRedirect() { const location = useLocation(); const { projectId, automationId } = useParams<{ @@ -121,27 +152,84 @@ export function LegacyAutomationCollectionRedirect() { ); } -export function LegacySkillDetailRedirect() { - const { skillId } = useParams<{ skillId?: string }>(); +export function PluginsLandingRedirect() { + const location = useLocation(); return ( + ); +} + +function normalizeLegacyPluginSuffix(suffix: string): string { + return matchPath("/browse", suffix) !== null ? "" : suffix; +} + +export function LegacyPluginsPathRedirect() { + const location = useLocation(); + const suffix = normalizeLegacyPluginSuffix( + location.pathname.slice(TOOLS_PLUGINS_ROUTE_PATH.length), + ); + return ( + ); } -export function ExtensionsLandingRedirect() { - return ; +function normalizeLegacySkillSuffix(suffix: string): string { + if (suffix === "/installed") return "/library"; + if (suffix.startsWith("/installed/")) { + return `/library/${suffix.slice("/installed/".length)}`; + } + return suffix; +} + +export function LegacySkillsPathRedirect() { + const location = useLocation(); + const suffix = normalizeLegacySkillSuffix( + location.pathname.slice(TOOLS_SKILLS_ROUTE_PATH.length), + ); + return ( + + ); } export function LegacyToolsPathRedirect() { const location = useLocation(); const suffix = location.pathname.slice(LEGACY_TOOLS_PREFIX_ROUTE_PATH.length); + const pathname = suffix.startsWith("/plugins") + ? `${PLUGINS_ROUTE_PATH}${normalizeLegacyPluginSuffix( + suffix.slice("/plugins".length), + )}` + : suffix.startsWith("/skills") + ? `${SKILLS_ROUTE_PATH}${normalizeLegacySkillSuffix( + suffix.slice("/skills".length), + )}` + : suffix === "" || suffix === "/" + ? PLUGINS_ROUTE_PATH + : `${TOOLS_ROUTE_PATH}${suffix}`; return ( @@ -222,8 +310,12 @@ function AppRoutes() { element={} /> } + path={SETTINGS_PROJECT_ROUTE_PATH} + element={} + /> + } /> } /> + } /> } + path={TOOLS_PLUGINS_ROUTE_PATH} + element={} /> } + path={TOOLS_PLUGIN_BROWSE_ROUTE_PATH} + element={} /> } + path={TOOLS_PLUGIN_DETAIL_ROUTE_PATH} + element={} + /> + } + /> + } /> - } /> - } /> } + element={} /> } + element={} /> } + element={} /> } + path={LEGACY_TOOLS_PREFIX_ROUTE_PATH} + element={} /> } + path={LEGACY_TOOLS_SPLAT_ROUTE_PATH} + element={} /> + } /> + } /> + } /> } + path={REGISTRY_SKILL_DETAIL_ROUTE_PATH} + element={} /> + } /> + } /> (); - return ; +function PluginsRoute() { + const { pluginId } = useParams<{ pluginId?: string }>(); + return ; } export function App() { @@ -341,6 +445,7 @@ export function App() { + { }); }); +describe("app.css compact prompt controls", () => { + it("lets designated controls shrink in both compact containers", () => { + expect( + css.match( + /\[data-promptbox(?:-shell)?\] \[data-promptbox-shrinkable-control\] \{\s*flex-shrink: 1 !important;\s*\}/g, + ), + ).toHaveLength(2); + }); +}); + describe("app.css sidebar drag cursor", () => { it("scopes the grabbing cursor to the sidebar panel on fine pointers only", () => { expect(css).not.toMatch(/body\[data-sidebar-dragging="true"\]\s*\*/); diff --git a/apps/app/src/components/commands/CommandPalette.test.tsx b/apps/app/src/components/commands/CommandPalette.test.tsx index f80f514b53..45d96e6ee6 100644 --- a/apps/app/src/components/commands/CommandPalette.test.tsx +++ b/apps/app/src/components/commands/CommandPalette.test.tsx @@ -169,7 +169,11 @@ function LocationProbe() { const location = useLocation(); return ( - {JSON.stringify({ pathname: location.pathname, state: location.state })} + {JSON.stringify({ + pathname: location.pathname, + search: location.search, + state: location.state, + })} ); } @@ -259,7 +263,7 @@ describe("CommandPalette", () => { expect((searchField() as HTMLInputElement).value).toBe(">"); const titles = optionTitles(); expect(titles?.[0]).toContain("New thread"); - expect(titles).toHaveLength(17); + expect(titles).toHaveLength(19); }); it("filters as the user types and keeps the selection on a live row", async () => { @@ -299,6 +303,53 @@ describe("CommandPalette", () => { expect(selectedOption()?.textContent).toBe(titles[0]); }); + it.each(["Enter", "ArrowDown", "ArrowUp", "Home", "End"])( + "leaves %s to an active IME composition", + async (key) => { + renderPalette(); + openPalette(); + await waitFor(() => expect(searchField()).toBeTruthy()); + fireEvent.keyDown(searchField(), { key: "ArrowDown" }); + fireEvent.keyDown(searchField(), { key: "ArrowDown" }); + const activeDescendant = searchField().getAttribute( + "aria-activedescendant", + ); + + fireEvent.compositionStart(searchField()); + const composingKey = new KeyboardEvent("keydown", { + key, + isComposing: true, + bubbles: true, + cancelable: true, + }); + fireEvent(searchField(), composingKey); + + expect(composingKey.defaultPrevented).toBe(false); + + expect(screen.getByRole("combobox")).toBeTruthy(); + expect(searchField().getAttribute("aria-activedescendant")).toBe( + activeDescendant, + ); + expect(testState.calls).toEqual([]); + + fireEvent.compositionEnd(searchField()); + if (key !== "Enter") { + const navigation = new KeyboardEvent("keydown", { + key, + bubbles: true, + cancelable: true, + }); + fireEvent(searchField(), navigation); + + expect(navigation.defaultPrevented).toBe(true); + expect(searchField().getAttribute("aria-activedescendant")).not.toBe( + activeDescendant, + ); + expect(testState.calls).toEqual([]); + } + }, + ); + it("runs the highlighted command, closes, and restores focus", async () => { renderPalette(); openPalette(); @@ -315,6 +366,42 @@ describe("CommandPalette", () => { expect(document.activeElement).toBe(screen.getByTestId("origin")); }); + it("keeps composition confirmation separate from command activation", async () => { + renderPalette(); + openPalette(); + await waitFor(() => expect(searchField()).toBeTruthy()); + + fireEvent.change(searchField(), { target: { value: ">toggle panel" } }); + await waitFor(() => + expect(selectedOption()?.textContent).toContain("Toggle panel"), + ); + const input = searchField(); + fireEvent.compositionStart(input); + const confirmation = new KeyboardEvent("keydown", { + key: "Enter", + isComposing: true, + bubbles: true, + cancelable: true, + }); + fireEvent(input, confirmation); + + expect(confirmation.defaultPrevented).toBe(false); + expect(screen.queryByRole("combobox")).toBe(input); + expect(testState.calls).toEqual([]); + + fireEvent.compositionEnd(input); + const activation = new KeyboardEvent("keydown", { + key: "Enter", + bubbles: true, + cancelable: true, + }); + fireEvent(input, activation); + + expect(activation.defaultPrevented).toBe(true); + await waitFor(() => expect(testState.calls).toEqual(["panel.toggle"])); + expect(screen.queryByRole("combobox")).toBeNull(); + }); + it("runs a compact selection once after restoring focus", async () => { renderPalette(true); openPalette(); @@ -478,6 +565,31 @@ describe("CommandPalette", () => { ).toBe(""); }); + it.each([false, true])( + "opens Installed plugins in Settings (compact: %s)", + async (isCompactViewport) => { + renderPalette(isCompactViewport); + openPalette(); + await waitFor(() => expect(searchField()).toBeTruthy()); + fireEvent.change(searchField(), { + target: { value: ">installed plugins" }, + }); + await waitFor(() => + expect(selectedOption()?.textContent).toContain("Installed plugins"), + ); + fireEvent.keyDown(searchField(), { key: "Enter" }); + await waitFor(() => + expect(screen.getByTestId("location").textContent).toBe( + JSON.stringify({ + pathname: "/settings/plugins", + search: "", + state: null, + }), + ), + ); + }, + ); + it("opens a specific settings page from Cmd-K", async () => { renderPalette(); openThreadSearch(); diff --git a/apps/app/src/components/commands/CommandPalette.tsx b/apps/app/src/components/commands/CommandPalette.tsx index 6bcd03d081..13cc7d2321 100644 --- a/apps/app/src/components/commands/CommandPalette.tsx +++ b/apps/app/src/components/commands/CommandPalette.tsx @@ -173,7 +173,7 @@ export function CommandPalette({ threadId, projectId }: CommandPaletteProps) { const mode: PaletteMode = query.startsWith(">") ? "commands" : "threads"; const modeQuery = mode === "commands" ? query.slice(1) : query; - const commandActions = useMemo( + const commandActions = useMemo( () => [...actions, ...settingsActions, ...pluginPageActions], [actions, pluginPageActions, settingsActions], ); @@ -248,6 +248,7 @@ export function CommandPalette({ threadId, projectId }: CommandPaletteProps) { const handleKeyDown = useCallback( (event: ReactKeyboardEvent) => { + if (event.nativeEvent.isComposing) return; if (resultCount === 0) return; if (event.key === "ArrowDown") { event.preventDefault(); diff --git a/apps/app/src/components/dialogs/EnvironmentRenameDialog.stories.tsx b/apps/app/src/components/dialogs/EnvironmentRenameDialog.stories.tsx index 2b021239be..05f77430a6 100644 --- a/apps/app/src/components/dialogs/EnvironmentRenameDialog.stories.tsx +++ b/apps/app/src/components/dialogs/EnvironmentRenameDialog.stories.tsx @@ -32,7 +32,7 @@ export function BranchContext() { (null); return ( - + diff --git a/apps/app/src/components/dialogs/EnvironmentRenameDialog.tsx b/apps/app/src/components/dialogs/EnvironmentRenameDialog.tsx index 121169b76d..463ced69c2 100644 --- a/apps/app/src/components/dialogs/EnvironmentRenameDialog.tsx +++ b/apps/app/src/components/dialogs/EnvironmentRenameDialog.tsx @@ -5,7 +5,7 @@ const ENVIRONMENT_NAME_MAX_LENGTH = 80; const ENVIRONMENT_NAME_LENGTH_RULE = { limit: ENVIRONMENT_NAME_MAX_LENGTH, - message: `Worktree name must be ${ENVIRONMENT_NAME_MAX_LENGTH} characters or fewer.`, + message: `Environment name must be ${ENVIRONMENT_NAME_MAX_LENGTH} characters or fewer.`, }; export interface EnvironmentRenameDialogTarget { @@ -65,11 +65,11 @@ export function EnvironmentRenameDialogContent({ }: EnvironmentRenameDialogContentProps) { return ( diff --git a/apps/app/src/components/dialogs/ProjectPathDialog.test.tsx b/apps/app/src/components/dialogs/ProjectPathDialog.test.tsx index 45073ffe06..8297c7b0ea 100644 --- a/apps/app/src/components/dialogs/ProjectPathDialog.test.tsx +++ b/apps/app/src/components/dialogs/ProjectPathDialog.test.tsx @@ -117,6 +117,66 @@ describe("ProjectPathDialog machine selection", () => { ); }); + it("includes provider-made hosts in the project setup machine picker", () => { + render( + , + ); + + fireEvent.pointerDown(screen.getByRole("button", { name: "Machine" }), { + button: 0, + }); + + expect(screen.getByRole("menuitem", { name: /Kunst/u })).toBeTruthy(); + expect( + screen.getByRole("menuitem", { name: /Modal sandbox 3f9a/u }), + ).toBeTruthy(); + }); + + it("uses a provider-made host as the only project machine", () => { + const onSubmit = vi.fn(); + render( + , + ); + + fireEvent.click( + screen.getByRole("button", { name: "Choose folder on host_modal" }), + ); + fireEvent.click(screen.getByRole("button", { name: "Add project" })); + expect(onSubmit).toHaveBeenCalledWith( + { kind: "create" }, + "/home/deploy/repos/givecare", + "host_modal", + ); + }); + it("blocks submission when every listed machine is offline", () => { const onSubmit = vi.fn(); render( diff --git a/apps/app/src/components/dialogs/ProjectPathDialog.tsx b/apps/app/src/components/dialogs/ProjectPathDialog.tsx index c46740b4af..2658126dc2 100644 --- a/apps/app/src/components/dialogs/ProjectPathDialog.tsx +++ b/apps/app/src/components/dialogs/ProjectPathDialog.tsx @@ -1,3 +1,4 @@ +import { MachineStatusDot } from "@/components/machines/MachineStatusDot"; import { useEffect, useId, useState, type FormEvent } from "react"; import { DropdownMenu, @@ -25,8 +26,8 @@ import { import { Input } from "@bb/shared-ui/input"; import { cn } from "@bb/shared-ui/lib/utils"; import { RemotePathBrowser } from "@/components/dialogs/RemotePathBrowser"; -import { MachineStatusDot } from "@/components/machines/MachineStatusDot"; import { usePointerCoarse } from "@bb/shared-ui/hooks/use-pointer-coarse"; +import { selectPersistentHosts } from "@/hooks/queries/host-queries"; export type ProjectPathDialogTarget = | { @@ -157,19 +158,21 @@ export function ProjectPathDialogContent({ }: ProjectPathDialogContentProps) { const inputId = useId(); const isPointerCoarse = usePointerCoarse(); - const machineOptions = target.kind === "create" ? hosts : undefined; + const machineOptions = + target.kind === "create" ? selectPersistentHosts(hosts) : undefined; const firstConnectedHostId = machineOptions?.find( (host) => host.status === "connected", )?.id; - const initialHostId = - hostId !== null && - (machineOptions === undefined || - machineOptions.some( - (host) => host.id === hostId && host.status === "connected", - )) - ? hostId - : (firstConnectedHostId ?? hostId); - const [selectedHostId, setSelectedHostId] = useState(initialHostId); + const [selectedHostIdState, setSelectedHostId] = useState( + hostId ?? firstConnectedHostId ?? null, + ); + const selectedHostId = + machineOptions === undefined || + machineOptions.some( + (host) => host.id === selectedHostIdState && host.status === "connected", + ) + ? selectedHostIdState + : (firstConnectedHostId ?? null); const selectedHost = machineOptions?.find( (host) => host.id === selectedHostId, ); @@ -177,7 +180,8 @@ export function ProjectPathDialogContent({ const selectedHostConnected = selectedHost === undefined || selectedHost.status === "connected"; const showMachinePicker = (machineOptions?.length ?? 0) > 1; - const noMachineAvailable = showMachinePicker && selectedHostId === null; + const noMachineAvailable = + machineOptions !== undefined && selectedHostId === null; const [manualPath, setManualPath] = useState( target.kind === "update" ? target.currentPath : "", ); diff --git a/apps/app/src/components/dialogs/RemotePathBrowser.createFolder.test.tsx b/apps/app/src/components/dialogs/RemotePathBrowser.createFolder.test.tsx index 55fd6752c7..3bc232036a 100644 --- a/apps/app/src/components/dialogs/RemotePathBrowser.createFolder.test.tsx +++ b/apps/app/src/components/dialogs/RemotePathBrowser.createFolder.test.tsx @@ -248,6 +248,53 @@ describe("RemotePathBrowser new folder", () => { }); describe("RemotePathBrowser entry list", () => { + it("returns to the top when browsing into another large directory", async () => { + const names = Array.from( + { length: 4999 }, + (_, i) => `file_${String(i).padStart(5, "0")}`, + ); + const rootEntries = [...names, "next"]; + directory.mockImplementation(({ path }) => + Promise.resolve( + listing( + path === "/home/me/manyfiles/next" + ? "/home/me/manyfiles/next" + : "/home/me/manyfiles", + path === "/home/me/manyfiles/next" ? names : rootEntries, + ), + ), + ); + const { wrapper: Wrapper } = createQueryClientTestHarness(); + + const { container } = render( + + + , + ); + + await screen.findByText("file_00000"); + const list = container.querySelector("ul"); + const scrollBox = list?.parentElement; + if (!(scrollBox instanceof HTMLElement)) throw new Error("no scroll box"); + Object.defineProperty(scrollBox, "scrollTo", { + configurable: true, + value: ({ top }: ScrollToOptions) => { + scrollBox.scrollTop = top ?? scrollBox.scrollTop; + scrollBox.dispatchEvent(new Event("scroll")); + }, + }); + scrollBox.scrollTop = 4_999 * ENTRY_TEST_ROW_HEIGHT_PX; + fireEvent.scroll(scrollBox); + fireEvent.click(await screen.findByRole("button", { name: "next" })); + + expect(scrollBox.scrollTop).toBe(0); + expect(await screen.findByText("file_00000")).not.toBeNull(); + }); + it("mounts only the entries near the viewport for a huge directory", async () => { const names = Array.from( { length: 5000 }, diff --git a/apps/app/src/components/dialogs/RemotePathBrowser.tsx b/apps/app/src/components/dialogs/RemotePathBrowser.tsx index dd3766e0e9..dc08179d46 100644 --- a/apps/app/src/components/dialogs/RemotePathBrowser.tsx +++ b/apps/app/src/components/dialogs/RemotePathBrowser.tsx @@ -1,4 +1,10 @@ -import { useEffect, useRef, useState, type ReactNode } from "react"; +import { + useCallback, + useEffect, + useRef, + useState, + type ReactNode, +} from "react"; import { useVirtualizer } from "@tanstack/react-virtual"; import { useMutation, useQueryClient } from "@tanstack/react-query"; import { normalizeProjectPathInput } from "@bb/domain"; @@ -96,14 +102,34 @@ export function RemotePathBrowser({ const entries = data?.entries ?? NO_ENTRIES; const scrollRef = useRef(null); + const getScrollElement = useCallback(() => scrollRef.current, []); + const estimateEntrySize = useCallback( + () => DIRECTORY_ENTRY_ROW_HEIGHT_PX, + [], + ); + const getEntryKey = useCallback( + (index: number) => entries[index]?.path ?? index, + [entries], + ); const entryVirtualizer = useVirtualizer({ count: entries.length, - getScrollElement: () => scrollRef.current, - estimateSize: () => DIRECTORY_ENTRY_ROW_HEIGHT_PX, - getItemKey: (index) => entries[index]?.path ?? index, + getScrollElement, + estimateSize: estimateEntrySize, + getItemKey: getEntryKey, overscan: DIRECTORY_ENTRY_OVERSCAN_ROWS, }); + const cancelCreatingFolder = () => { + setIsCreatingFolder(false); + setNewFolderError(null); + }; + + const navigateTo = (path: string) => { + cancelCreatingFolder(); + entryVirtualizer.scrollToOffset(0); + setCurrentPath(path); + }; + const startCreatingFolder = () => { if ( !allowCreateFolder || @@ -120,16 +146,6 @@ export function RemotePathBrowser({ setIsCreatingFolder(true); }; - const cancelCreatingFolder = () => { - setIsCreatingFolder(false); - setNewFolderError(null); - }; - - const navigateTo = (path: string) => { - cancelCreatingFolder(); - setCurrentPath(path); - }; - const createFolder = useMutation({ mutationFn: async ({ parent, name }: { parent: string; name: string }) => { const path = joinHostPath(parent, name); diff --git a/apps/app/src/components/layout/AppLayout.plugin-panel-header.test.tsx b/apps/app/src/components/layout/AppLayout.plugin-panel-header.test.tsx index b3c03b154d..3dfb4e89ab 100644 --- a/apps/app/src/components/layout/AppLayout.plugin-panel-header.test.tsx +++ b/apps/app/src/components/layout/AppLayout.plugin-panel-header.test.tsx @@ -26,7 +26,6 @@ vi.mock("@/hooks/queries/system-queries", () => ({ data: { experiments: { changelogPreview: false, - editMessages: false, mobileApp: false, sidebarProgressiveDisclosure: false, timelineWindowing: false, diff --git a/apps/app/src/components/layout/AppLayout.root-compose-project.test.tsx b/apps/app/src/components/layout/AppLayout.root-compose-project.test.tsx index 04a958cf46..a827c03670 100644 --- a/apps/app/src/components/layout/AppLayout.root-compose-project.test.tsx +++ b/apps/app/src/components/layout/AppLayout.root-compose-project.test.tsx @@ -34,7 +34,6 @@ vi.mock("@/hooks/queries/system-queries", () => ({ data: { experiments: { changelogPreview: false, - editMessages: false, mobileApp: false, sidebarProgressiveDisclosure: false, timelineWindowing: false, diff --git a/apps/app/src/components/layout/AppLayout.test.tsx b/apps/app/src/components/layout/AppLayout.test.tsx index f8d1fb3670..4ee73f053b 100644 --- a/apps/app/src/components/layout/AppLayout.test.tsx +++ b/apps/app/src/components/layout/AppLayout.test.tsx @@ -19,8 +19,8 @@ import { AppLayout } from "./AppLayout"; const SIDEBAR_WIDTH_STORAGE_KEY = "bb.sidebar.width"; const APP_ROUTE = "/projects/proj_one/threads/thr_one?message=12#event-12"; const SETTINGS_ROUTE = "/settings/providers/codex?tab=models#preferred"; -const EXTENSIONS_ROUTE = "/extensions/plugins/ui-patterns?tab=settings#source"; -const SECONDARY_ROUTES = [SETTINGS_ROUTE, EXTENSIONS_ROUTE]; +const PLUGINS_ROUTE = "/plugins/ui-patterns?tab=settings#source"; +const SECONDARY_ROUTES = [SETTINGS_ROUTE, PLUGINS_ROUTE]; vi.mock("./AppLayoutSidebar", async () => { const { Sidebar } = await vi.importActual< @@ -44,7 +44,6 @@ vi.mock("@/hooks/queries/system-queries", () => ({ useSystemConfig: () => ({ data: { experiments: { - editMessages: false, }, generalSettings: defaultAppSettings, keybindings: [ @@ -219,13 +218,13 @@ describe("AppLayout Back to app", () => { }, ); - it("returns from Settings to Extensions before returning to the core app", () => { + it("returns from Settings to Plugins before returning to the core app", () => { renderLayout(APP_ROUTE); - fireEvent.click(screen.getByRole("link", { name: EXTENSIONS_ROUTE })); + fireEvent.click(screen.getByRole("link", { name: PLUGINS_ROUTE })); fireEvent.click(screen.getByRole("link", { name: SETTINGS_ROUTE })); fireEvent.keyDown(document, { key: "Escape" }); - expect(screen.getByTestId("location").textContent).toBe(EXTENSIONS_ROUTE); + expect(screen.getByTestId("location").textContent).toBe(PLUGINS_ROUTE); fireEvent.keyDown(document, { key: "Escape" }); expect(screen.getByTestId("location").textContent).toBe(APP_ROUTE); diff --git a/apps/app/src/components/layout/AppLayout.tools-breadcrumbs.test.ts b/apps/app/src/components/layout/AppLayout.tools-breadcrumbs.test.ts index 6d37c85fb2..f74927ef98 100644 --- a/apps/app/src/components/layout/AppLayout.tools-breadcrumbs.test.ts +++ b/apps/app/src/components/layout/AppLayout.tools-breadcrumbs.test.ts @@ -1,67 +1,38 @@ import { describe, expect, it } from "vitest"; import { resolveAutomationBreadcrumbs, - resolveToolsAreaHeaderMeta, + resolvePluginsWorkspaceHeaderMeta, + resolveSkillsWorkspaceHeaderMeta, resolveToolsBreadcrumbs, - TOOLS_NAV_ITEMS, } from "@/components/tools/tools-navigation"; describe("resolveToolsBreadcrumbs", () => { - it("uses one section identity contract for navigation and page chrome", () => { - expect( - TOOLS_NAV_ITEMS.map(({ id, label, icon, to }) => ({ - id, - label, - icon, - to, - })), - ).toEqual([ - { - id: "plugins", - label: "Plugins", - icon: "ElectricPlugs", - to: "/extensions/plugins", - }, - { id: "skills", label: "Skills", icon: "Zap", to: "/extensions/skills" }, - ]); - }); - it("includes the selected collection tab", () => { - expect(resolveToolsBreadcrumbs("/extensions/skills")).toEqual([ - { label: "Skills", to: "/extensions/skills" }, + expect(resolveToolsBreadcrumbs("/skills")).toEqual([ + { label: "Skills", to: "/skills" }, { label: "Browse" }, ]); - expect( - resolveToolsBreadcrumbs("/extensions/skills", "?view=library"), - ).toEqual([ - { label: "Skills", to: "/extensions/skills" }, + expect(resolveToolsBreadcrumbs("/skills", "?view=library")).toEqual([ + { label: "Skills", to: "/skills" }, { label: "My skills" }, ]); - expect(resolveToolsBreadcrumbs("/extensions/plugins")).toEqual([ - { label: "Plugins", to: "/extensions/plugins" }, + expect(resolveToolsBreadcrumbs("/plugins")).toEqual([ + { label: "Plugins", to: "/plugins" }, { label: "Browse" }, ]); - expect( - resolveToolsBreadcrumbs("/extensions/plugins", "?view=create"), - ).toEqual([ - { label: "Extensions", to: "/extensions/plugins" }, + expect(resolveToolsBreadcrumbs("/plugins", "?view=create")).toEqual([ + { label: "Plugins", to: "/plugins" }, { label: "Create a plugin" }, ]); - expect( - resolveToolsBreadcrumbs("/extensions/plugins", "?view=installed"), - ).toEqual([ - { label: "Plugins", to: "/extensions/plugins" }, + expect(resolveToolsBreadcrumbs("/plugins", "?view=installed")).toEqual([ + { label: "Plugins", to: "/plugins" }, { label: "Installed" }, ]); }); - it("resolves literal browse paths as Browse, not as a resource named browse", () => { - expect(resolveToolsBreadcrumbs("/extensions/plugins/browse")).toEqual([ - { label: "Plugins", to: "/extensions/plugins" }, - { label: "Browse" }, - ]); - expect(resolveToolsBreadcrumbs("/extensions/skills/registry")).toEqual([ - { label: "Skills", to: "/extensions/skills" }, + it("resolves the Skills registry path as Browse", () => { + expect(resolveToolsBreadcrumbs("/skills/registry")).toEqual([ + { label: "Skills", to: "/skills" }, { label: "Browse" }, ]); }); @@ -69,37 +40,37 @@ describe("resolveToolsBreadcrumbs", () => { it("makes every detail ancestor clickable and keeps the resource passive", () => { expect( resolveToolsBreadcrumbs( - "/extensions/skills/library/skill_abc123", + "/skills/library/skill_abc123", "", "Example Skill", ), ).toEqual([ - { label: "Skills", to: "/extensions/skills" }, - { label: "My skills", to: "/extensions/skills?view=library" }, + { label: "Skills", to: "/skills" }, + { label: "My skills", to: "/skills?view=library" }, { label: "Example Skill" }, ]); expect( resolveToolsBreadcrumbs( - "/extensions/skills/registry/vercel-labs%2Fskills%2Ffind-skills", + "/skills/registry/vercel-labs%2Fskills%2Ffind-skills", ), ).toEqual([ - { label: "Skills", to: "/extensions/skills" }, - { label: "Browse", to: "/extensions/skills/registry" }, + { label: "Skills", to: "/skills" }, + { label: "Browse", to: "/skills/registry" }, { label: "find-skills" }, ]); - expect(resolveToolsBreadcrumbs("/extensions/plugins/ui-patterns")).toEqual([ - { label: "Plugins", to: "/extensions/plugins" }, - { label: "Browse", to: "/extensions/plugins" }, + expect(resolveToolsBreadcrumbs("/plugins/ui-patterns")).toEqual([ + { label: "Plugins", to: "/plugins" }, + { label: "Browse", to: "/plugins" }, { label: "ui-patterns" }, ]); expect( resolveToolsBreadcrumbs( - "/extensions/plugins/ui-patterns", + "/plugins/ui-patterns", "?view=installed", "UI Patterns", ), ).toEqual([ - { label: "Plugins", to: "/extensions/plugins" }, + { label: "Plugins", to: "/plugins" }, { label: "Installed", to: "/settings/plugins" }, { label: "UI Patterns" }, ]); @@ -189,37 +160,38 @@ describe("resolveAutomationBreadcrumbs", () => { }); }); -describe("resolveToolsAreaHeaderMeta", () => { - it("shows the static Extensions title on tools routes", () => { +describe("resource workspace headers", () => { + it("gives Plugins ownership of only the Plugins header", () => { expect( - resolveToolsAreaHeaderMeta( - "/extensions/plugins?view=installed".split("?")[0]!, + resolvePluginsWorkspaceHeaderMeta( + "/plugins?view=installed".split("?")[0]!, ), - ).toEqual({ kind: "extensions-title", title: "Extensions" }); - expect(resolveToolsAreaHeaderMeta("/extensions/skills/registry")).toEqual({ - kind: "extensions-title", - title: "Extensions", + ).toEqual({ kind: "section-title", title: "Plugins" }); + expect(resolvePluginsWorkspaceHeaderMeta("/skills/registry")).toBeNull(); + }); + + it("gives Skills ownership of only the Skills header", () => { + expect(resolveSkillsWorkspaceHeaderMeta("/skills/registry")).toEqual({ + kind: "section-title", + title: "Skills", }); + expect(resolveSkillsWorkspaceHeaderMeta("/plugins")).toBeNull(); }); - it("shows established ancestor/current breadcrumbs during plugin creation", () => { + it("keeps plugin creation inside the Plugins header", () => { expect( - resolveToolsAreaHeaderMeta("/extensions/plugins", null, "?view=create"), + resolvePluginsWorkspaceHeaderMeta("/plugins", "?view=create"), ).toEqual({ kind: "breadcrumbs", breadcrumbs: [ - { label: "Extensions", to: "/extensions/plugins" }, + { label: "Plugins", to: "/plugins" }, { label: "Create a plugin" }, ], }); }); - it("keeps automation breadcrumbs, including the legacy /tools alias", () => { - const meta = resolveToolsAreaHeaderMeta("/plugins/automations/automations"); - expect(meta?.kind).toBe("breadcrumbs"); - }); - - it("claims nothing when the route is unrelated", () => { - expect(resolveToolsAreaHeaderMeta("/")).toBeNull(); + it("claims nothing outside either resource workspace", () => { + expect(resolvePluginsWorkspaceHeaderMeta("/")).toBeNull(); + expect(resolveSkillsWorkspaceHeaderMeta("/")).toBeNull(); }); }); diff --git a/apps/app/src/components/layout/AppLayout.tsx b/apps/app/src/components/layout/AppLayout.tsx index bb2f4a0880..14cb460916 100644 --- a/apps/app/src/components/layout/AppLayout.tsx +++ b/apps/app/src/components/layout/AppLayout.tsx @@ -28,7 +28,8 @@ import { CommandPalette } from "@/components/commands/CommandPalette"; import { NotificationCenter } from "@/components/notifications/NotificationCenter"; import { resolveAutomationBreadcrumbs, - resolveToolsAreaHeaderMeta, + resolvePluginsWorkspaceHeaderMeta, + resolveSkillsWorkspaceHeaderMeta, resolveToolsBreadcrumbs, } from "@/components/tools/tools-navigation"; import { AppBreadcrumbs } from "./AppBreadcrumbs"; @@ -81,11 +82,12 @@ import { useDesktopWindowState } from "@/hooks/useDesktopWindowState"; import { useServerDaemonLogsCommand } from "@/hooks/useServerDaemonLogsCommand"; import { getLegacyProjectComposeRoutePath, - getProjectSettingsRoutePath, + getSettingsProjectRoutePath, getRootComposeRoutePath, getThreadRoutePath, + isPluginsRoutePath, isProjectlessProjectId, - isToolsRoutePath, + isSkillsRoutePath, PLUGIN_PANEL_ROUTE_PATH, SETTINGS_ROUTE_PATH, } from "@/lib/route-paths"; @@ -287,7 +289,6 @@ function resolveRouteTitle(pathname: string): { title: string } | undefined { interface AppHeaderProps { usesProjectChromeStyle: boolean; usesDesktopChrome: boolean; - isSettingsView: boolean; projectId?: string; project?: ProjectResponse; pluginPanel?: PluginNavPanelSlot; @@ -302,7 +303,6 @@ interface AppHeaderProps { function AppHeader({ usesProjectChromeStyle, usesDesktopChrome, - isSettingsView, projectId, project, pluginPanel, @@ -343,16 +343,13 @@ function AppHeader({ !isProjectlessProjectId(projectId) ? ( <> @@ -387,14 +384,8 @@ export function AppLayout({ children }: AppLayoutProps) { restoreIOSViewportOnKeyboardDismissal, ); const location = useLocation(); - const { - projectId, - threadId, - isThreadView, - isArchivedView, - isSettingsView, - isRootView, - } = useRouteState(); + const { projectId, threadId, isThreadView, isArchivedView, isRootView } = + useRouteState(); const [resourceRouteLabel, setResourceRouteLabel] = useAtom( resourceRouteLabelAtom, ); @@ -480,10 +471,11 @@ export function AppLayout({ children }: AppLayoutProps) { const navPanelChrome = usePluginNavPanelChrome(); const isGlobalSettingsView = matchPath(`${SETTINGS_ROUTE_PATH}/*`, location.pathname) !== null; - const isGlobalToolsView = isToolsRoutePath(location.pathname); + const isPluginsWorkspace = isPluginsRoutePath(location.pathname); + const isSkillsWorkspace = isSkillsRoutePath(location.pathname); const backToAppRoutePath = isGlobalSettingsView ? appRoutePath - : isGlobalToolsView + : isPluginsWorkspace || isSkillsWorkspace ? toolsBackRoutePath : null; const pluginPanelMatch = matchPath( @@ -571,52 +563,41 @@ export function AppLayout({ children }: AppLayoutProps) { resourceRouteLabel, ); const documentTitleBreadcrumbs = toolsBreadcrumbs ?? automationBreadcrumbs; - const toolsAreaHeaderMeta = resolveToolsAreaHeaderMeta( - location.pathname, - resourceRouteLabel, - location.search, - ); + const resourceWorkspaceHeaderMeta = + resolvePluginsWorkspaceHeaderMeta(location.pathname, location.search) ?? + resolveSkillsWorkspaceHeaderMeta(location.pathname); const meta = - toolsAreaHeaderMeta?.kind === "extensions-title" - ? { title: toolsAreaHeaderMeta.title } - : toolsAreaHeaderMeta?.kind === "breadcrumbs" + resourceWorkspaceHeaderMeta?.kind === "section-title" + ? { title: resourceWorkspaceHeaderMeta.title } + : resourceWorkspaceHeaderMeta?.kind === "breadcrumbs" ? { title: "", - breadcrumbs: toolsAreaHeaderMeta.breadcrumbs, + breadcrumbs: resourceWorkspaceHeaderMeta.breadcrumbs, } - : isArchivedView && projectId - ? isProjectlessProjectId(projectId) - ? { - title: "", - breadcrumbs: [ - { label: "Threads", to: getRootComposeRoutePath() }, - ...(archivedSectionName - ? [{ label: archivedSectionName }] - : []), - { label: "Archived" }, - ], - } - : { - title: "", - breadcrumbs: [ - { - label: projectLabel ?? projectId, - to: getLegacyProjectComposeRoutePath(projectId), - }, - { label: "Archived" }, - ], - } - : isSettingsView && projectId - ? { - title: "", - breadcrumbs: [ - { - label: projectLabel ?? projectId, - to: getLegacyProjectComposeRoutePath(projectId), - }, - { label: "Settings" }, - ], - } + : automationBreadcrumbs !== null + ? { title: "", breadcrumbs: automationBreadcrumbs } + : isArchivedView && projectId + ? isProjectlessProjectId(projectId) + ? { + title: "", + breadcrumbs: [ + { label: "Threads", to: getRootComposeRoutePath() }, + ...(archivedSectionName + ? [{ label: archivedSectionName }] + : []), + { label: "Archived" }, + ], + } + : { + title: "", + breadcrumbs: [ + { + label: projectLabel ?? projectId, + to: getLegacyProjectComposeRoutePath(projectId), + }, + { label: "Archived" }, + ], + } : projectId ? { title: projectLabel ?? projectId, @@ -645,9 +626,6 @@ export function AppLayout({ children }: AppLayoutProps) { } return `${projectLabel ?? projectId} · Archived`; } - if (isSettingsView && projectId) { - return `${projectLabel ?? projectId} · Settings`; - } if (projectId) { return projectLabel ?? projectId; } @@ -759,9 +737,11 @@ export function AppLayout({ children }: AppLayoutProps) { mode={ isGlobalSettingsView ? "settings" - : isGlobalToolsView - ? "tools" - : "app" + : isPluginsWorkspace + ? "plugins" + : isSkillsWorkspace + ? "skills" + : "app" } onResizeMouseDown={handleResizeMouseDown} isResizing={isSidebarResizing} @@ -779,10 +759,7 @@ export function AppLayout({ children }: AppLayoutProps) { {showHeader ? ( { }; }); -vi.mock("@/components/tools/ToolsSidebar", async () => { +vi.mock("@/components/tools/ResourceSidebar", async () => { const { Sidebar } = await vi.importActual< typeof import("@/components/ui/sidebar") >("@/components/ui/sidebar"); return { - ToolsSidebar: ({ mobileHosted }: { mobileHosted?: boolean }) => - mobileHosted ? ( -
Tools sidebar
+ ResourceSidebar: ({ + mobileHosted, + workspace, + }: { + mobileHosted?: boolean; + workspace: "plugins" | "skills"; + }) => { + const title = + workspace === "plugins" ? "Plugins sidebar" : "Skills sidebar"; + return mobileHosted ? ( +
{title}
) : ( - Tools sidebar - ), + {title} + ); + }, }; }); @@ -120,8 +129,11 @@ function SidebarModeHarness({ - +
- ); - } +function BranchPickerSectionHeader({ label }: BranchPickerSectionHeaderProps) { return (
-
{label}
-
- {subtitle} -
-
- ); -} - -function BranchPickerUnavailableRow({ - icon, - label, - description, - title, -}: BranchPickerUnavailableRowProps) { - return ( -
- - - - {label} - - - {description} - - + {label}
); } @@ -452,15 +247,9 @@ function BranchPickerRowButton({ label, title, selected, - disabled = false, onSelect, - onPointerEnter: callerPointerEnter, - onKeyDown: callerKeyDown, }: BranchPickerRowButtonProps) { - const { hoverProps } = useMenuItemHover({ - onPointerEnter: callerPointerEnter, - onKeyDown: callerKeyDown, - }); + const { hoverProps } = useMenuItemHover(); return ( ); } @@ -1039,6 +1042,7 @@ function PluginNavSidebarItem({ } isActive={pathname === path || pathname.startsWith(`${path}/`)} @@ -1059,6 +1063,7 @@ function PluginNavSidebarItem({ interface SidebarNavRowChromeProps { rowKey: string; + loading?: boolean; title: string; icon: ReactNode; isActive: boolean; @@ -1075,6 +1080,7 @@ interface SidebarNavRowChromeProps { function SidebarNavRowChrome({ rowKey, + loading = false, title, icon, isActive, @@ -1121,7 +1127,12 @@ function SidebarNavRowChrome({
+ ); +} + +function registerWorktreeInputsControl(): void { + setPluginSlotRegistrations("environment-git-worktree", { + ...EMPTY_SLOT_REGISTRATIONS, + environmentProviderInputs: [ + { + environmentProviderId: "git-worktree", + component: WorktreeInputsControl, + }, + ], + }); +} + +function CheckoutInputsControl({ + value, + onChange, +}: PluginEnvironmentProviderInputsProps) { + useEffect(() => { + if (value === null) onChange({ status: "ready", value: {} }); + }, [onChange, value]); + return null; +} + +function registerCheckoutInputsControl(): void { + setPluginSlotRegistrations("environment-project-checkout", { + ...EMPTY_SLOT_REGISTRATIONS, + environmentProviderInputs: [ + { + environmentProviderId: "project-checkout", + component: CheckoutInputsControl, + }, + ], + }); +} + const STORED_REQUEST: NewThreadRequest = { projectId: "proj_1", providerId: "claude-code", @@ -370,12 +526,10 @@ const STORED_REQUEST: NewThreadRequest = { permissionMode: "explicit", }, environment: { - type: "host", - hostId: "host_1", - workspace: { - type: "managed-worktree", - baseBranch: { kind: "named", name: "release" }, - }, + type: "provider", + environmentProviderId: "git-worktree", + machine: { type: "existing", hostId: "host_1" }, + inputs: { branch: { kind: "named", name: "release" } }, }, input: [{ type: "text", text: "review every PR for slop", mentions: [] }], }; @@ -396,6 +550,14 @@ describe("PluginNewThreadComposer seeding", () => { mocks.sidebarNavigationSettled = true; mocks.sidebarNavigationReplayed = false; mocks.extraProjects = []; + mocks.environmentProviders = [ + CHECKOUT_PROVIDER, + MANAGED_WORKTREE_SUGAR_PROVIDER, + PERSONAL_WORKSPACE_PROVIDER, + ]; + resetPluginSlotStoreForTest(); + registerWorktreeInputsControl(); + registerCheckoutInputsControl(); window.localStorage.clear(); getPromptDraftAccessor({ kind: "new-thread" }).setDraft({ text: "", @@ -566,9 +728,9 @@ describe("PluginNewThreadComposer seeding", () => { expect(submitted[0]).toMatchObject({ projectId: PERSONAL_PROJECT_ID, environment: { - type: "host", - hostId: "host_1", - workspace: { type: "personal" }, + type: "provider", + environmentProviderId: "personal-workspace", + inputs: null, }, }); }); @@ -620,10 +782,18 @@ describe("PluginNewThreadComposer seeding", () => { await submit(); expect(submitted).toHaveLength(1); - expect(submitted[0]).toEqual(otherRecord); + expect(submitted[0]).toEqual({ + ...otherRecord, + environment: { + type: "provider", + environmentProviderId: "project-checkout", + machine: { type: "existing", hostId: "host_1" }, + inputs: { branch: { kind: "existing", name: "release" } }, + }, + }); }); - it("re-seeds the branch when the next record differs only by project", async () => { + it("re-seeds the provider inputs when the next record differs only by project", async () => { const submitted: NewThreadRequest[] = []; const onSubmit = (request: NewThreadRequest) => { submitted.push(request); @@ -633,9 +803,7 @@ describe("PluginNewThreadComposer seeding", () => { expect(latestPromptBoxProps().disabled).toBe(false); }); - await act(async () => { - latestPromptBoxProps().modeConfig.branch.onClear(); - }); + fireEvent.click(screen.getByTestId("worktree-default-branch")); const otherProjectRecord: NewThreadRequest = { ...STORED_REQUEST, @@ -653,7 +821,7 @@ describe("PluginNewThreadComposer seeding", () => { expect(submitted[0]).toEqual(otherProjectRecord); }); - it("does not resurrect the branch seed after the user leaves and returns to the environment", async () => { + it("does not resurrect the seeded inputs after the user leaves and returns to the environment", async () => { const submitted: NewThreadRequest[] = []; renderComposer( STORED_REQUEST, @@ -672,8 +840,9 @@ describe("PluginNewThreadComposer seeding", () => { ); }); await act(async () => { - latestPromptBoxProps().modeConfig.environment.onChange( - "host:host_1:worktree", + latestPromptBoxProps().modeConfig.environment.onSelectProvider( + MANAGED_WORKTREE_SUGAR_PROVIDER, + "host_1", ); }); await waitFor(() => { @@ -683,12 +852,10 @@ describe("PluginNewThreadComposer seeding", () => { expect(submitted).toHaveLength(1); expect(submitted[0].environment).toEqual({ - type: "host", - hostId: "host_1", - workspace: { - type: "managed-worktree", - baseBranch: { kind: "named", name: "main" }, - }, + type: "provider", + environmentProviderId: "git-worktree", + machine: { type: "existing", hostId: "host_1" }, + inputs: DEFAULT_BRANCH_INPUTS, }); }); @@ -720,9 +887,9 @@ describe("PluginNewThreadComposer seeding", () => { reasoningLevel: "medium", permissionMode: "auto", environment: { - type: "host", - hostId: "host_1", - workspace: { type: "unmanaged", path: null }, + type: "provider", + environmentProviderId: "project-checkout", + inputs: {}, }, }); }); @@ -771,7 +938,7 @@ describe("PluginNewThreadComposer seeding", () => { environmentName: "source", environmentBranchName: "feature/source", queuedWork: "none", - environmentWorkspaceDisplayKind: "managed-worktree", + environmentProviderId: "git-worktree", }), ]; const submitted: NewThreadRequest[] = []; @@ -841,7 +1008,7 @@ describe("PluginNewThreadComposer seeding", () => { ).toBe(true); }); - it("keeps a seeded fork's reuse selection pending until the sidebar bootstrap settles", async () => { + it("keeps a seeded fork's exact reuse selection while the sidebar bootstrap settles", async () => { mocks.sidebarNavigationSettled = false; const submitted: NewThreadRequest[] = []; const seed = { @@ -872,7 +1039,7 @@ describe("PluginNewThreadComposer seeding", () => { await waitFor(() => { expect(latestPromptBoxProps().modeConfig.environment.value).toBe( - REUSE_VALUE_WITHOUT_ENVIRONMENT, + encodeReuseValue("env-source"), ); }); expect(latestPromptBoxProps().modeConfig.worktree.options).toEqual([]); @@ -887,7 +1054,7 @@ describe("PluginNewThreadComposer seeding", () => { environmentName: "source", environmentBranchName: "feature/source", queuedWork: "none", - environmentWorkspaceDisplayKind: "managed-worktree", + environmentProviderId: "git-worktree", }), ]; rerender(element()); @@ -971,7 +1138,7 @@ describe("PluginNewThreadComposer seeding", () => { ); expect(mocks.promptBoxProps[0]?.modeConfig.environment.value).toBe( - "host:host_1:local", + "provider:personal-workspace", ); expect(mocks.promptBoxProps[0]?.value).toBe("unrelated draft"); expect(mocks.promptBoxProps[0]?.attachments.items).toHaveLength(1); @@ -1160,3 +1327,491 @@ describe("PluginNewThreadComposer seeding", () => { expect(latestPromptBoxProps().attachments.items).toHaveLength(1); }); }); + +const SANDBOX_PROVIDER: SystemEnvironmentProvider = { + id: "container", + displayName: "Docker container", + icon: "Container", + logoUrl: null, + pluginId: "docker-sandbox", + acceptsEmptyInputs: false, + machineAvailability: {}, + availability: null, + requires: { + projectCheckout: false, + gitCheckout: false, + gitRemote: false, + projectless: false, + }, + inputs: { + type: "object", + properties: { image: { type: "string" } }, + required: ["image"], + }, +}; + +const OPTIONAL_INPUTS_PROVIDER: SystemEnvironmentProvider = { + id: "optional-sandbox", + displayName: "Optional sandbox", + icon: "Container", + logoUrl: null, + pluginId: "optional-sandbox", + acceptsEmptyInputs: true, + machineAvailability: {}, + availability: null, + requires: { + projectCheckout: false, + gitCheckout: false, + gitRemote: false, + projectless: false, + }, + inputs: { + type: "object", + properties: { image: { type: "string" } }, + }, +}; + +const BRANCH_PROVIDER: SystemEnvironmentProvider = { + id: "branchy", + displayName: "New branch workspace", + icon: "GitBranch", + logoUrl: null, + pluginId: "branchy", + acceptsEmptyInputs: true, + machineAvailability: {}, + availability: null, + requires: { + projectCheckout: true, + gitCheckout: true, + gitRemote: false, + projectless: false, + }, + inputs: null, +}; + +const HOST_PROVIDER: SystemEnvironmentProvider = { + id: "hosted", + displayName: "Machine sandbox", + icon: "Server", + logoUrl: null, + pluginId: "hosted", + acceptsEmptyInputs: true, + machineAvailability: {}, + availability: null, + requires: { + projectCheckout: false, + gitCheckout: false, + gitRemote: false, + projectless: false, + }, + inputs: null, +}; + +const PROJECT_WITHOUT_CHECKOUT = { + ...PROJECT, + id: "proj_no_checkout", + name: "Project Without Checkout", + sources: [], +}; + +describe("NewThreadComposer environment providers", () => { + beforeEach(() => { + mocks.promptBoxProps.length = 0; + mocks.projectThreads = []; + mocks.sidebarNavigationSettled = true; + mocks.sidebarNavigationReplayed = false; + mocks.extraProjects = []; + mocks.environmentProviders = [CHECKOUT_PROVIDER]; + resetPluginSlotStoreForTest(); + registerCheckoutInputsControl(); + window.localStorage.clear(); + getPromptDraftAccessor({ kind: "new-thread" }).setDraft({ + text: "", + mentions: [], + attachments: [], + }); + }); + + afterEach(() => { + cleanup(); + }); + + function renderUnseeded( + onSubmit: (request: NewThreadRequest) => void, + draftKey: string, + projectId = "proj_1", + ) { + return render( + + + + , + ); + } + + it("submits the value the provider's configuration slot produced", async () => { + mocks.environmentProviders = [CHECKOUT_PROVIDER, SANDBOX_PROVIDER]; + setPluginSlotRegistrations("docker-sandbox", { + ...EMPTY_SLOT_REGISTRATIONS, + environmentProviderInputs: [ + { + environmentProviderId: "container", + component: ({ onChange }) => ( + + ), + }, + ], + }); + const submitted: NewThreadRequest[] = []; + renderUnseeded((request) => { + submitted.push(request); + }, "provider-inputs"); + + await waitFor(() => { + expect(latestPromptBoxProps().disabled).toBe(false); + }); + await act(async () => { + latestPromptBoxProps().modeConfig.environment.onSelectProvider( + SANDBOX_PROVIDER, + null, + ); + }); + await waitFor(() => { + expect(latestPromptBoxProps().modeConfig.environment.value).toBe( + "provider:container", + ); + expect(screen.getByTestId("set-provider-config")).toBeTruthy(); + }); + expect(latestPromptBoxProps().disabled).toBe(true); + fireEvent.click(screen.getByTestId("set-provider-config")); + await waitFor(() => { + expect(latestPromptBoxProps().disabled).toBe(false); + }); + await submit(); + + expect(submitted).toHaveLength(1); + expect(submitted[0].environment).toEqual({ + type: "provider", + environmentProviderId: "container", + machine: { type: "existing", hostId: "host_1" }, + inputs: { image: "img-chosen" }, + }); + }); + + it("blocks a provider that declares inputs when its plugin registered no control", async () => { + mocks.environmentProviders = [CHECKOUT_PROVIDER, SANDBOX_PROVIDER]; + const submitted: NewThreadRequest[] = []; + renderUnseeded((request) => { + submitted.push(request); + }, "provider-missing-control"); + + await waitFor(() => { + expect(latestPromptBoxProps().disabled).toBe(false); + }); + expect( + latestPromptBoxProps().modeConfig.environment.inputsControlProviderIds, + ).toEqual(new Set(["project-checkout"])); + await act(async () => { + latestPromptBoxProps().modeConfig.environment.onSelectProvider( + SANDBOX_PROVIDER, + null, + ); + }); + + await waitFor(() => { + expect(latestPromptBoxProps().disabled).toBe(true); + }); + expect(latestPromptBoxProps().disabledReason).toBe( + "Docker container needs its plugin's control", + ); + await expect(submit()).resolves.toBeUndefined(); + expect(submitted).toHaveLength(0); + }); + + it("ignores a control registered by a plugin that does not own the provider", async () => { + mocks.environmentProviders = [CHECKOUT_PROVIDER, SANDBOX_PROVIDER]; + setPluginSlotRegistrations("impostor", { + ...EMPTY_SLOT_REGISTRATIONS, + environmentProviderInputs: [ + { + environmentProviderId: "container", + component: ({ onChange }) => ( + + ), + }, + ], + }); + const submitted: NewThreadRequest[] = []; + renderUnseeded((request) => { + submitted.push(request); + }, "provider-impostor-control"); + + await waitFor(() => { + expect(latestPromptBoxProps().disabled).toBe(false); + }); + expect( + latestPromptBoxProps().modeConfig.environment.inputsControlProviderIds, + ).toEqual(new Set(["project-checkout"])); + await act(async () => { + latestPromptBoxProps().modeConfig.environment.onSelectProvider( + SANDBOX_PROVIDER, + null, + ); + }); + + await waitFor(() => { + expect(latestPromptBoxProps().disabled).toBe(true); + }); + expect(latestPromptBoxProps().disabledReason).toBe( + "Docker container needs its plugin's control", + ); + expect(screen.queryByTestId("impostor-provider-config")).toBe(null); + await expect(submit()).resolves.toBeUndefined(); + expect(submitted).toHaveLength(0); + }); + + it("submits empty inputs for a provider whose schema requires nothing and has no control", async () => { + mocks.environmentProviders = [CHECKOUT_PROVIDER, OPTIONAL_INPUTS_PROVIDER]; + const submitted: NewThreadRequest[] = []; + renderUnseeded((request) => { + submitted.push(request); + }, "provider-optional-inputs"); + + await waitFor(() => { + expect(latestPromptBoxProps().disabled).toBe(false); + }); + await act(async () => { + latestPromptBoxProps().modeConfig.environment.onSelectProvider( + OPTIONAL_INPUTS_PROVIDER, + null, + ); + }); + await waitFor(() => { + expect(latestPromptBoxProps().modeConfig.environment.value).toBe( + "provider:optional-sandbox", + ); + expect(latestPromptBoxProps().disabled).toBe(false); + }); + await submit(); + + expect(submitted).toHaveLength(1); + expect(submitted[0].environment).toEqual({ + type: "provider", + environmentProviderId: "optional-sandbox", + machine: { type: "existing", hostId: "host_1" }, + inputs: {}, + }); + }); + + it("submits a provider without interpreting deferred availability", async () => { + const setupRequiredProvider: SystemEnvironmentProvider = { + ...OPTIONAL_INPUTS_PROVIDER, + id: "modal-sandbox", + displayName: "Modal sandbox", + pluginId: "environment-modal-sandbox", + inputs: null, + availability: { + status: "setup-required", + message: "Add Modal credentials", + }, + }; + mocks.environmentProviders = [CHECKOUT_PROVIDER, setupRequiredProvider]; + const submitted: NewThreadRequest[] = []; + renderUnseeded((request) => { + submitted.push(request); + }, "provider-setup-required"); + + await act(async () => { + latestPromptBoxProps().modeConfig.environment.onSelectProvider( + setupRequiredProvider, + null, + ); + }); + await waitFor(() => { + expect(latestPromptBoxProps().modeConfig.environment.value).toBe( + "provider:modal-sandbox", + ); + }); + await submit(); + + expect(submitted).toHaveLength(1); + expect(submitted[0]?.environment).toEqual({ + type: "provider", + environmentProviderId: "modal-sandbox", + machine: { type: "existing", hostId: "host_1" }, + inputs: null, + }); + }); + + it("shows the plugin's blocked reason and prevents submit", async () => { + mocks.environmentProviders = [CHECKOUT_PROVIDER, SANDBOX_PROVIDER]; + setPluginSlotRegistrations("docker-sandbox", { + ...EMPTY_SLOT_REGISTRATIONS, + environmentProviderInputs: [ + { + environmentProviderId: "container", + component: ({ onChange }) => ( + + ), + }, + ], + }); + const submitted: NewThreadRequest[] = []; + renderUnseeded((request) => { + submitted.push(request); + }, "provider-incomplete-config"); + + await waitFor(() => { + expect(latestPromptBoxProps().disabled).toBe(false); + }); + await act(async () => { + latestPromptBoxProps().modeConfig.environment.onSelectProvider( + SANDBOX_PROVIDER, + null, + ); + }); + await waitFor(() => { + expect(screen.getByTestId("clear-provider-config")).toBeTruthy(); + }); + fireEvent.click(screen.getByTestId("clear-provider-config")); + + await waitFor(() => { + expect(latestPromptBoxProps().disabled).toBe(true); + }); + expect(latestPromptBoxProps().disabledReason).toBe( + "Choose a container image", + ); + await expect(submit()).resolves.toBeUndefined(); + expect(submitted).toHaveLength(0); + }); + + it("lets a host provider without gitCheckout use a machine that has no project checkout", async () => { + mocks.environmentProviders = [ + CHECKOUT_PROVIDER, + HOST_PROVIDER, + BRANCH_PROVIDER, + ]; + mocks.extraProjects = [PROJECT_WITHOUT_CHECKOUT]; + const submitted: NewThreadRequest[] = []; + renderUnseeded( + (request) => { + submitted.push(request); + }, + "host-provider-no-checkout", + PROJECT_WITHOUT_CHECKOUT.id, + ); + + await waitFor(() => { + expect(latestPromptBoxProps().project.value).toBe( + PROJECT_WITHOUT_CHECKOUT.id, + ); + }); + await act(async () => { + latestPromptBoxProps().modeConfig.environment.onSelectProvider( + BRANCH_PROVIDER, + "host_1", + ); + }); + await waitFor(() => { + expect(latestPromptBoxProps().modeConfig.environment.value).toBe( + "provider:branchy", + ); + }); + expect( + latestPromptBoxProps().modeConfig.environment.selectedProviderHostId, + ).toBeNull(); + + await act(async () => { + latestPromptBoxProps().modeConfig.environment.onSelectProvider( + HOST_PROVIDER, + "host_1", + ); + }); + await waitFor(() => { + expect(latestPromptBoxProps().modeConfig.environment.value).toBe( + "provider:hosted", + ); + expect( + latestPromptBoxProps().modeConfig.environment.selectedProviderHostId, + ).toBe("host_1"); + expect(latestPromptBoxProps().disabled).toBe(false); + }); + await submit(); + + expect(submitted).toHaveLength(1); + expect(submitted[0].environment).toEqual({ + type: "provider", + environmentProviderId: "hosted", + machine: { type: "existing", hostId: "host_1" }, + inputs: null, + }); + }); + + it("submits a gitCheckout provider without inputs with the row's machine and no branch picker", async () => { + mocks.environmentProviders = [CHECKOUT_PROVIDER, BRANCH_PROVIDER]; + const submitted: NewThreadRequest[] = []; + renderUnseeded((request) => { + submitted.push(request); + }, "branch-provider-row"); + + await waitFor(() => { + expect(latestPromptBoxProps().disabled).toBe(false); + }); + await act(async () => { + latestPromptBoxProps().modeConfig.environment.onSelectProvider( + BRANCH_PROVIDER, + "host_1", + ); + }); + await waitFor(() => { + expect(latestPromptBoxProps().modeConfig.environment.value).toBe( + "provider:branchy", + ); + expect( + latestPromptBoxProps().modeConfig.environment.selectedProviderHostId, + ).toBe("host_1"); + expect(latestPromptBoxProps().disabled).toBe(false); + }); + await submit(); + + expect(submitted).toHaveLength(1); + expect(submitted[0].environment).toEqual({ + type: "provider", + environmentProviderId: "branchy", + machine: { type: "existing", hostId: "host_1" }, + inputs: null, + }); + }); +}); diff --git a/apps/app/src/components/plugin/PluginPanelRightPanelHost.test.tsx b/apps/app/src/components/plugin/PluginPanelRightPanelHost.test.tsx index fe5dabe801..a9108a8f34 100644 --- a/apps/app/src/components/plugin/PluginPanelRightPanelHost.test.tsx +++ b/apps/app/src/components/plugin/PluginPanelRightPanelHost.test.tsx @@ -632,8 +632,8 @@ function PluginDetailNavigationLink() { const onRouteAnchorClick = useRouteAnchorDelegate(); return ( ); } @@ -833,7 +833,7 @@ describe("PluginPanelRightPanelHost", () => { expect(openPaneContentInSplit).toHaveBeenCalledWith( expect.objectContaining({ content: { kind: "plugin-detail", pluginId: "secrets" }, - route: "/extensions/plugins/secrets", + route: "/plugins/secrets", }), ); expect(screen.queryByTestId("marketplace-plugin-detail")).toBeNull(); diff --git a/apps/app/src/components/plugin/PluginPendingInteractionComposer.test.tsx b/apps/app/src/components/plugin/PluginPendingInteractionComposer.test.tsx index 44cf1ec9de..fd406085ef 100644 --- a/apps/app/src/components/plugin/PluginPendingInteractionComposer.test.tsx +++ b/apps/app/src/components/plugin/PluginPendingInteractionComposer.test.tsx @@ -1,6 +1,7 @@ // @vitest-environment jsdom -import { cleanup, render, screen } from "@testing-library/react"; +import { useEffect, useState } from "react"; +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { afterEach, describe, expect, it, vi } from "vitest"; import type { PluginPendingInteraction } from "@bb/domain"; @@ -56,6 +57,113 @@ afterEach(() => { }); describe("PluginPendingInteractionComposer", () => { + it("preserves drafts and pauses keyboard listeners while collapsed", () => { + const onShortcut = vi.fn(); + function QuestionRenderer() { + const [answer, setAnswer] = useState(""); + useEffect(() => { + window.addEventListener("keydown", onShortcut); + return () => window.removeEventListener("keydown", onShortcut); + }, []); + return ( + setAnswer(event.target.value)} + /> + ); + } + setPluginSlotRegistrations( + "secrets", + registrations([{ id: "secret-request", component: QuestionRenderer }]), + ); + renderComposer( + , + ); + fireEvent.change(screen.getByRole("textbox", { name: "Answer" }), { + target: { value: "Keep my draft" }, + }); + fireEvent.keyDown(window, { key: "1" }); + expect(onShortcut).toHaveBeenCalledTimes(1); + const toggle = screen.getByRole("button", { name: "Hide details" }); + toggle.focus(); + fireEvent.click(toggle); + expect(screen.queryByRole("textbox")).toBeNull(); + expect(document.activeElement).toBe( + screen.getByRole("button", { name: "Show details" }), + ); + fireEvent.keyDown(window, { key: "2" }); + expect(onShortcut).toHaveBeenCalledTimes(1); + fireEvent.click(screen.getByRole("button", { name: "Show details" })); + expect(screen.getByRole("textbox").getAttribute("value")).toBe( + "Keep my draft", + ); + fireEvent.keyDown(window, { key: "3" }); + expect(onShortcut).toHaveBeenCalledTimes(2); + fireEvent.keyDown(screen.getByRole("textbox"), { key: "Escape" }); + expect(screen.queryByRole("textbox")).toBeNull(); + expect(document.activeElement).toBe( + screen.getByRole("button", { name: "Show details" }), + ); + fireEvent.click(screen.getByRole("button", { name: "Show details" })); + expect(screen.getByRole("textbox").getAttribute("value")).toBe( + "Keep my draft", + ); + }); + + it("opens a new interaction with a fresh form after the previous one was collapsed", () => { + function Renderer() { + const [answer, setAnswer] = useState(""); + return ( + setAnswer(event.target.value)} + /> + ); + } + setPluginSlotRegistrations( + "secrets", + registrations([{ id: "secret-request", component: Renderer }]), + ); + const client = new QueryClient(); + const composer = (id: string) => ( + + + + ); + const view = render(composer(interaction.id)); + fireEvent.change(screen.getByRole("textbox"), { + target: { value: "Previous answer" }, + }); + fireEvent.click(screen.getByRole("button", { name: "Hide details" })); + view.rerender(composer("pint_new")); + expect(screen.getByRole("textbox").getAttribute("value")).toBe(""); + expect( + screen + .getByRole("button", { name: "Hide details" }) + .getAttribute("aria-expanded"), + ).toBe("true"); + }); + it("mounts only the renderer registered by the interaction's plugin", () => { function WrongRenderer() { return
wrong plugin renderer
; diff --git a/apps/app/src/components/plugin/PluginPendingInteractionComposer.tsx b/apps/app/src/components/plugin/PluginPendingInteractionComposer.tsx index 39fea4c19e..452e929aa2 100644 --- a/apps/app/src/components/plugin/PluginPendingInteractionComposer.tsx +++ b/apps/app/src/components/plugin/PluginPendingInteractionComposer.tsx @@ -1,3 +1,7 @@ +import { + PendingInteractionShell, + type PendingInteractionSourceThread, +} from "@/components/thread/pending-interactions/PendingInteractionShell"; import { useCallback, useMemo, useState } from "react"; import { Button } from "@bb/shared-ui/button"; import type { JsonValue, PendingInteraction } from "@bb/domain"; @@ -21,12 +25,14 @@ interface PluginPendingInteractionComposerProps { >; request: PluginPendingInteractionRequest; dismissal: "cancel" | "stop-turn"; + sourceThread?: PendingInteractionSourceThread; } export function PluginPendingInteractionComposer({ interaction, request, dismissal, + sourceThread, }: PluginPendingInteractionComposerProps) { const { pendingInteractions } = usePluginSlots(); const stopThread = useStopThread(); @@ -83,25 +89,62 @@ export function PluginPendingInteractionComposer({ const dismissLabel = dismissal === "cancel" ? "Cancel" : "Stop turn"; return ( -
-
-

- {request.title} -

-

- {dismissal === "cancel" ? "Requested by " : "The agent asks through "} - {request.pluginId} -

-
- {slot ? ( - + {() => ( + <> +

+ {dismissal === "cancel" + ? "Requested by " + : "The agent asks through "} + {request.pluginId} +

+ {slot ? ( + +

+ The plugin form crashed. {dismissLabel} to continue. +

+ +
+ } + > +
+ +
+ + ) : (

- The plugin form crashed. {dismissLabel} to continue. + The plugin form is unavailable. {dismissLabel} to continue.

- } - > -
- -
- - ) : ( -
-

- The plugin form is unavailable. {dismissLabel} to continue. -

- -
+ )} + )} - {error ? ( -

- {error} -

- ) : null} - + ); } diff --git a/apps/app/src/components/plugin/PluginSettings.test.tsx b/apps/app/src/components/plugin/PluginSettings.test.tsx index 3162c2f8ea..8f80866669 100644 --- a/apps/app/src/components/plugin/PluginSettings.test.tsx +++ b/apps/app/src/components/plugin/PluginSettings.test.tsx @@ -647,6 +647,118 @@ describe("PluginSettingsPage", () => { ).toBeTruthy(); }); + it("shows a skeleton while the plugin list loads, then the real settings", async () => { + let resolveList: (response: Response) => void = () => { + throw new Error("Plugin list request did not start"); + }; + vi.stubGlobal( + "fetch", + vi.fn(async (url: string) => { + if (url === "/api/v1/plugins/linear/settings") + return jsonOk(SETTINGS_VIEW); + return new Promise((resolve) => { + resolveList = resolve; + }); + }), + ); + + const { wrapper: QueryClientWrapper } = createQueryClientTestHarness(); + render( + + + + + , + ); + + const skeleton = await screen.findByTestId("plugin-settings-skeleton"); + expect(skeleton.getAttribute("role")).toBe("status"); + expect(screen.getByText("Loading plugin settings…")).toBeTruthy(); + + resolveList(jsonOk({ plugins: [installedPlugin(true)] })); + + expect(await screen.findByRole("heading", { name: "Linear" })).toBeTruthy(); + expect(screen.queryByTestId("plugin-settings-skeleton")).toBeNull(); + }); + + it("reports a failed plugin list instead of a skeleton or a missing plugin", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => { + throw new Error("offline"); + }), + ); + + const { wrapper: QueryClientWrapper } = createQueryClientTestHarness(); + render( + + + + + , + ); + + expect( + await screen.findByText("Could not load plugin settings."), + ).toBeTruthy(); + expect(screen.queryByTestId("plugin-settings-skeleton")).toBeNull(); + }); + + it("keeps loaded settings visible when a background plugin-list refresh fails", async () => { + let pluginListRequests = 0; + vi.stubGlobal( + "fetch", + vi.fn(async (url: string) => { + if (url === "/api/v1/plugins/linear/settings") { + return jsonOk(SETTINGS_VIEW); + } + pluginListRequests += 1; + if (pluginListRequests === 1) { + return jsonOk({ plugins: [installedPlugin(true)] }); + } + throw new Error("offline"); + }), + ); + + const { queryClient, wrapper: QueryClientWrapper } = + createQueryClientTestHarness(); + render( + + + + + , + ); + + expect(await screen.findByRole("heading", { name: "Linear" })).toBeTruthy(); + + await queryClient.invalidateQueries(); + + expect(screen.getByRole("heading", { name: "Linear" })).toBeTruthy(); + expect(screen.queryByText("Could not load plugin settings.")).toBeNull(); + }); + + it("keeps the not-installed message free of loading affordances", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => jsonOk({ plugins: [] })), + ); + + const { wrapper: QueryClientWrapper } = createQueryClientTestHarness(); + render( + + + + + , + ); + + expect( + await screen.findByText("This plugin is not installed."), + ).toBeTruthy(); + expect(screen.queryByTestId("plugin-settings-skeleton")).toBeNull(); + }); + it("omits Configuration for an enabled plugin with no available settings", async () => { vi.stubGlobal( "fetch", diff --git a/apps/app/src/components/plugin/PluginSettings.tsx b/apps/app/src/components/plugin/PluginSettings.tsx index 925c1d9b16..97b6719440 100644 --- a/apps/app/src/components/plugin/PluginSettings.tsx +++ b/apps/app/src/components/plugin/PluginSettings.tsx @@ -15,6 +15,7 @@ import { Textarea } from "@bb/shared-ui/textarea"; import { Link } from "react-router-dom"; import { SettingsWithControl } from "@/components/ui/settings-section.js"; import { getPluginDetailRoutePath } from "@/lib/route-paths"; +import { Skeleton } from "@bb/shared-ui/skeleton"; import { Switch } from "@bb/shared-ui/switch"; import { ResourceDetailConfigurationSection, @@ -448,16 +449,80 @@ const PLUGIN_STATUSES_WITH_SETTINGS = [ "degraded", ]; +function PluginSettingsFieldSkeleton() { + return ( +
+
+ +
+
+ +
+
+ ); +} + +function PluginSettingsPageSkeleton() { + return ( +
+ Loading plugin settings… +
+
+
+ +
+
+ +
+
+ +
+
+
+ +
+ + } + > + +
+ + +
+
+
+ } + > +
+ +
+
+
+
+
+ ); +} + export function PluginSettingsPage({ pluginId }: { pluginId: string }) { const listQuery = usePluginList({ enabled: true }); const plugin = listQuery.data?.plugins.find( (entry: PluginListItem) => entry.id === pluginId, ) ?? null; - if (listQuery.isFetching && listQuery.data === undefined) { + if (listQuery.data === undefined && !listQuery.isError) { + return ; + } + if (listQuery.data === undefined && listQuery.isError) { return (

- Loading plugin settings… + Could not load plugin settings.

); } diff --git a/apps/app/src/components/plugin/PluginSidebarFooterItems.test.tsx b/apps/app/src/components/plugin/PluginSidebarFooterItems.test.tsx index e60979abe7..4243c7426d 100644 --- a/apps/app/src/components/plugin/PluginSidebarFooterItems.test.tsx +++ b/apps/app/src/components/plugin/PluginSidebarFooterItems.test.tsx @@ -12,6 +12,7 @@ import type { ExperimentalSidebarFooterDisclosureController, } from "@get-bb/plugin-sdk"; import { MemoryRouter, useLocation } from "react-router-dom"; +import { TooltipProvider } from "@bb/shared-ui/tooltip"; import { afterEach, describe, expect, it, vi } from "vitest"; import { SidebarMenu, SidebarProvider } from "@/components/ui/sidebar.js"; import { @@ -61,10 +62,12 @@ function LocationProbe() { function renderWithProviders(ui: ReactNode) { return render( - - {ui} - - + + + {ui} + + + , ); } @@ -80,8 +83,6 @@ function FooterHarness() { @@ -271,6 +272,48 @@ describe("PluginSidebarFooterItems", () => { expect(screen.queryByRole("tooltip")).toBeNull(); }); + it("keeps the tooltip closed when the More drawer returns focus after a touch dismissal", () => { + const definition = definePluginApp((app) => { + app.experimental_sidebarFooter.register({ + kind: "disclosure", + id: "usage", + label: "Provider usage", + icon: "ChartColumn", + component: UsageDisclosure, + }); + }); + setPluginSlotRegistrations( + "usage-plugin", + collectPluginAppRegistrations(definition), + ); + + renderWithProviders(); + const trigger = screen.getByRole("button", { name: "Provider usage" }); + + fireEvent.pointerDown(trigger, { pointerType: "touch" }); + fireEvent.pointerUp(trigger, { pointerType: "touch" }); + fireEvent.click(trigger); + expect(screen.getByText("Provider usage content")).toBeDefined(); + + const dismissButton = screen.getByRole("button", { name: "Dismiss usage" }); + fireEvent.pointerDown(dismissButton, { pointerType: "touch" }); + fireEvent.pointerUp(dismissButton, { pointerType: "touch" }); + fireEvent.click(dismissButton); + expect(document.activeElement).toBe(trigger); + expect(screen.queryByRole("tooltip")).toBeNull(); + + fireEvent.pointerDown(document.body, { pointerType: "touch" }); + fireEvent.pointerUp(document.body, { pointerType: "touch" }); + fireEvent.blur(trigger); + fireEvent.pointerDown(document.body, { pointerType: "touch" }); + fireEvent.pointerUp(document.body, { pointerType: "touch" }); + trigger.focus(); + fireEvent.focus(trigger); + + expect(document.activeElement).toBe(trigger); + expect(screen.queryByRole("tooltip")).toBeNull(); + }); + it("lets the host coordinate disclosures from multiple plugins", () => { let first: ExperimentalSidebarFooterDisclosureController | null = null; let second: ExperimentalSidebarFooterDisclosureController | null = null; diff --git a/apps/app/src/components/plugin/PluginSidebarFooterItems.tsx b/apps/app/src/components/plugin/PluginSidebarFooterItems.tsx index be817c8950..e0a7b477a2 100644 --- a/apps/app/src/components/plugin/PluginSidebarFooterItems.tsx +++ b/apps/app/src/components/plugin/PluginSidebarFooterItems.tsx @@ -45,20 +45,17 @@ export function usePluginSidebarFooterDisclosure() { [sidebarFooterItems], ); const [activeKey, setActiveKey] = useState(null); - const [suppressedTooltipKey, setSuppressedTooltipKey] = useState< - string | null - >(null); + const [restoreFocusKey, setRestoreFocusKey] = useState(null); const lastProgrammaticCommand = useRef(0); const activeItem = useMemo( () => disclosures.find((item) => footerItemKey(item) === activeKey) ?? null, [activeKey, disclosures], ); - const suppressedTooltipItem = useMemo( + const restoreFocusItem = useMemo( () => - disclosures.find( - (item) => footerItemKey(item) === suppressedTooltipKey, - ) ?? null, - [disclosures, suppressedTooltipKey], + disclosures.find((item) => footerItemKey(item) === restoreFocusKey) ?? + null, + [disclosures, restoreFocusKey], ); const handleCommand = useCallback( @@ -74,7 +71,7 @@ export function usePluginSidebarFooterDisclosure() { const isClosing = (command === "close" && activeKey === itemKey) || (command === "toggle" && activeKey === itemKey); - setSuppressedTooltipKey(isClosing ? itemKey : null); + setRestoreFocusKey(isClosing ? itemKey : null); setActiveKey((current) => { if (command === "open") return itemKey; if (command === "close") return current === itemKey ? null : current; @@ -86,23 +83,17 @@ export function usePluginSidebarFooterDisclosure() { const dismiss = useCallback(() => { if (activeItem !== null) { - setSuppressedTooltipKey(footerItemKey(activeItem)); + setRestoreFocusKey(footerItemKey(activeItem)); } setActiveKey(null); }, [activeItem]); useLayoutEffect(() => { - if (suppressedTooltipItem === null || activeItem !== null) return; + if (restoreFocusItem === null || activeItem !== null) return; document - .getElementById(footerTriggerId(suppressedTooltipItem)) + .getElementById(footerTriggerId(restoreFocusItem)) ?.focus({ preventScroll: true }); - }, [activeItem, suppressedTooltipItem]); - - const clearTooltipSuppression = useCallback((itemKey: string) => { - setSuppressedTooltipKey((current) => - current === itemKey ? null : current, - ); - }, []); + }, [activeItem, restoreFocusItem]); useEffect(() => { if (activeItem === null) return; @@ -118,8 +109,6 @@ export function usePluginSidebarFooterDisclosure() { return { activeItem, activeKey: activeItem === null ? null : activeKey, - suppressedTooltipKey, - clearTooltipSuppression, dismiss, handleCommand, }; @@ -172,14 +161,10 @@ export function PluginSidebarFooterDisclosure({ export function PluginSidebarFooterItems({ activeDisclosureKey, - suppressedTooltipKey, - onTooltipSuppressionEnd, onDisclosureCommand, onNavigate, }: { activeDisclosureKey: string | null; - suppressedTooltipKey: string | null; - onTooltipSuppressionEnd: (itemKey: string) => void; onDisclosureCommand: ( itemKey: string, command: ExperimentalSidebarFooterCommandKind, @@ -196,8 +181,6 @@ export function PluginSidebarFooterItems({ key={footerItemKey(item)} item={item} isActive={footerItemKey(item) === activeDisclosureKey} - isTooltipSuppressed={footerItemKey(item) === suppressedTooltipKey} - onTooltipSuppressionEnd={onTooltipSuppressionEnd} onDisclosureCommand={onDisclosureCommand} onNavigate={onNavigate} /> @@ -209,15 +192,11 @@ export function PluginSidebarFooterItems({ function SidebarFooterItemButton({ item, isActive, - isTooltipSuppressed, - onTooltipSuppressionEnd, onDisclosureCommand, onNavigate, }: { item: PluginSidebarFooterItemSlot; isActive: boolean; - isTooltipSuppressed: boolean; - onTooltipSuppressionEnd: (itemKey: string) => void; onDisclosureCommand: ( itemKey: string, command: ExperimentalSidebarFooterCommandKind, @@ -248,7 +227,7 @@ function SidebarFooterItemButton({ aria-label={item.label} tooltip={{ children: item.label, - hidden: isTooltipSuppressed, + hidden: false, side: "top", }} className={cn( @@ -261,8 +240,6 @@ function SidebarFooterItemButton({ ? `plugin-sidebar-footer-action-${item.pluginId}-${item.id}` : `plugin-sidebar-footer-item-${item.pluginId}-${item.id}` } - onBlur={() => onTooltipSuppressionEnd(itemKey)} - onPointerLeave={() => onTooltipSuppressionEnd(itemKey)} {...(item.kind === "disclosure" ? { "aria-expanded": isActive, diff --git a/apps/app/src/components/plugin/PluginThreadChat.tsx b/apps/app/src/components/plugin/PluginThreadChat.tsx index 1bd93d6e2a..57dd3bc278 100644 --- a/apps/app/src/components/plugin/PluginThreadChat.tsx +++ b/apps/app/src/components/plugin/PluginThreadChat.tsx @@ -4,6 +4,7 @@ import type { ThreadChatMessageAction, ThreadChatProps, } from "@get-bb/plugin-sdk"; +import { PERSONAL_PROJECT_ID } from "@bb/domain"; import { formatEnvironmentDisplay, type EnvironmentDisplayHostContext, @@ -22,11 +23,15 @@ import { useThreadTimelineNavigation } from "@/components/thread/timeline/Thread import { PluginContext } from "@/components/plugin/plugin-context"; import { ThreadProviderContext } from "@/components/thread/thread-provider-context"; import { useEnvironment } from "@/hooks/queries/environment-queries"; -import { useHosts } from "@/hooks/queries/host-queries"; import { useSystemProviderInfo } from "@/hooks/queries/system-queries"; import { useThread } from "@/hooks/queries/thread-queries"; import { useHostDaemon } from "@/hooks/useHostDaemon"; -import { getEnvironmentWorkspaceSummaryDisplay } from "@/lib/environment-workspace-display"; +import { useHosts } from "@/hooks/queries/host-queries"; +import { + findEnvironmentDisplayProvider, + getEnvironmentWorkspaceSummaryDisplay, +} from "@/lib/environment-workspace-display"; +import { useSystemEnvironmentProviders } from "@/hooks/queries/environment-provider-queries"; import { formatWorkspaceCheckoutDisplay } from "@/lib/workspace-checkout-display"; import { BbHttpError } from "@/lib/sdk"; import { @@ -113,6 +118,8 @@ function PluginThreadChatBody({ ? (hostsQuery.data?.find((host) => host.id === environment.hostId)?.name ?? null) : null; + const hasMultipleMachines = (hostsQuery.data?.length ?? 0) > 1; + const { providers: environmentProviders } = useSystemEnvironmentProviders(); const timelineNavigation = useThreadTimelineNavigation(); const canUseHostFileNavigation = thread !== undefined && @@ -183,19 +190,29 @@ function PluginThreadChatBody({ locality: isLocalDaemonHost(environment.hostId) ? "local" : "remote", identity: null, }; - const display = formatEnvironmentDisplay({ environment, host }); + const providerLookup = findEnvironmentDisplayProvider( + environmentProviders, + environment.environmentProviderId, + ); + const display = formatEnvironmentDisplay({ + environment, + host, + providerLookup, + }); const summaryDisplay = getEnvironmentWorkspaceSummaryDisplay({ display, + providerLookup, environmentName: environment.name, - locality: host.locality, - hostName: environmentHostName ?? undefined, + hasMultipleMachines, + hostName: environmentHostName, + isProjectless: thread?.projectId === PERSONAL_PROJECT_ID, }); return ( ); - }, [environment, environmentHostName, isLocalDaemonHost]); + }, [ + environment, + environmentHostName, + environmentProviders, + hasMultipleMachines, + isLocalDaemonHost, + thread?.projectId, + ]); const isThreadMissing = threadQuery.error instanceof BbHttpError && diff --git a/apps/app/src/components/plugin/PluginsOverview.test.tsx b/apps/app/src/components/plugin/PluginsOverview.test.tsx index 8011c92933..0616777118 100644 --- a/apps/app/src/components/plugin/PluginsOverview.test.tsx +++ b/apps/app/src/components/plugin/PluginsOverview.test.tsx @@ -35,9 +35,7 @@ function SwitchViewButton({ view }: { view: "browse" | "installed" }) { - {onCollapse ? ( @@ -751,6 +755,38 @@ describe("FollowUpPromptBox", () => { }, ); + it.each([ + { setting: false, title: "Queue follow-up (Enter), Ctrl + Enter to steer" }, + { + setting: true, + title: "Steer current run (Enter), Ctrl + Enter to queue", + }, + ])( + "shows the platform modifier shortcut in the submit title when steer-on-Enter is $setting", + ({ setting, title }) => { + const platformMock = vi + .spyOn(navigator, "platform", "get") + .mockReturnValue("Win32"); + try { + const props = createFollowUpPromptBoxProps({ + kind: "queue", + onStop: vi.fn(), + }); + if (!props.composer) { + throw new Error("Expected follow-up composer props"); + } + props.composer.steerActiveThreadOnEnter = setting; + render(); + + expect(screen.getByText("Modifier submit").getAttribute("title")).toBe( + title, + ); + } finally { + platformMock.mockRestore(); + } + }, + ); + it("disables the permission picker while plan mode is active", () => { const props = createFollowUpPromptBoxProps({ kind: "queue", diff --git a/apps/app/src/components/promptbox/FollowUpPromptBox.tsx b/apps/app/src/components/promptbox/FollowUpPromptBox.tsx index 0022ff244c..23c3148857 100644 --- a/apps/app/src/components/promptbox/FollowUpPromptBox.tsx +++ b/apps/app/src/components/promptbox/FollowUpPromptBox.tsx @@ -20,6 +20,7 @@ import type { } from "@bb/domain"; import type { ComposerView, PluginComposerScope } from "@get-bb/plugin-sdk"; import type { ComposerTextEffectSource } from "@/lib/composer-text-effects"; +import { modifierSubmitShortcutLabel } from "./modifier-submit-shortcut"; import { isKeyboardFocusTarget } from "@/components/layout/useMobileVisualViewportHeight"; import { ComposerBannersSlot } from "@/components/plugin/PluginComposerBanners"; import { @@ -595,6 +596,8 @@ function FollowUpPromptBoxWithComposer({ ? composer.onSubmit : composer.onModifierSubmit : undefined; + const modifierSubmitHint = (action: "queue" | "steer"): string => + onModifierSubmit ? `, ${modifierSubmitShortcutLabel()} to ${action}` : ""; const executionControlsDisabled = (executionReadOnly ?? readOnly ?? false) || hasPendingInteraction; const footerStart = useMemo( @@ -739,9 +742,9 @@ function FollowUpPromptBoxWithComposer({ : canQueueFollowUp ? steerOnPrimarySubmit ? isSteeringWhenReady - ? "Steer when ready (Enter)" - : "Steer current run (Enter)" - : "Queue follow-up (Enter)" + ? `Steer when ready (Enter)${modifierSubmitHint("queue")}` + : `Steer current run (Enter)${modifierSubmitHint("queue")}` + : `Queue follow-up (Enter)${modifierSubmitHint("steer")}` : isStopping ? "Stopping run..." : isLoadingExecutionOptions diff --git a/apps/app/src/components/promptbox/NewThreadComposer.tsx b/apps/app/src/components/promptbox/NewThreadComposer.tsx index 355e81a44b..8aa9b63212 100644 --- a/apps/app/src/components/promptbox/NewThreadComposer.tsx +++ b/apps/app/src/components/promptbox/NewThreadComposer.tsx @@ -8,25 +8,33 @@ import { } from "react"; import { useNavigate } from "react-router-dom"; import { + findLocalPathProjectSourceForHost, PERSONAL_PROJECT_ID, + type EnvironmentMachineSelection, type Host, + type JsonValue, type PermissionMode, type ProjectExecutionDefaults, type ReasoningLevel, type ServiceTier, } from "@bb/domain"; -import type { NewThreadRequest } from "@get-bb/plugin-sdk"; +import type { + NewThreadRequest, + PluginEnvironmentProviderInputsChange, +} from "@get-bb/plugin-sdk"; import type { CreateExecutionInputSources, SidebarBootstrapResponse, + SystemEnvironmentProvider, SystemExecutionOptionsModelLoadError, } from "@bb/server-contract"; import type { ProjectSelectorCreateProjectConfig } from "@/components/pickers/ProjectSelector"; import { - encodeHostValue, encodeReuseValue, + encodeProviderValue, parseEnvironmentValue, } from "@/components/pickers/environment-picker-value"; +import { providerInputsControlRequired } from "@/components/pickers/environment-provider-inputs"; import { formatModelLoadErrorText } from "@/components/pickers/model-load-error-message"; import { NewThreadPromptBox, @@ -38,13 +46,19 @@ import type { PromptMentionLinkResolver } from "@/components/promptbox/editor/pr import type { PromptBoxHandle } from "@/components/promptbox/PromptBoxInternal"; import { type PluginComposerHost } from "@/components/plugin/plugin-composer-host"; import { newThreadEnvironmentArgsToSeed } from "@/components/plugin/new-thread-environment-seed"; +import { PluginSlotMount } from "@/components/plugin/PluginSlotMount"; +import { usePluginSlots } from "@/lib/plugin-slots"; import { useUploadPromptAttachment } from "@/hooks/mutations/project-mutations"; -import { selectPrimaryHost, useHosts } from "@/hooks/queries/host-queries"; +import { useSystemEnvironmentProviders } from "@/hooks/queries/environment-provider-queries"; +import { + selectPersistentHosts, + selectPrimaryHost, + useHosts, +} from "@/hooks/queries/host-queries"; import { useProjectDefaultExecutionOptions } from "@/hooks/queries/project-default-execution-options-query"; import { stripProjectThreads, useProjectPromptHistory, - useProjectSourceBranches, type SidebarProject, } from "@/hooks/queries/project-queries"; import { useSidebarNavigation } from "@/hooks/queries/sidebar-navigation-query"; @@ -74,22 +88,11 @@ import { isProjectlessProjectId, } from "@/lib/route-paths"; import { sdk } from "@/lib/sdk"; -import { - buildRootComposeBranchUiState, - type RootComposeBranchEnvironmentMode, -} from "@/views/root-compose-branch-ui"; -import { useScopedBranchSelection } from "@/views/root-compose-branch-selection"; import { buildReuseThreadOptions, - resolveProjectSourceWorktreeDisabledReason, resolveRootComposeEffectiveEnvironmentValue, - resolveRootComposeProjectRouting, - resolveRootComposeProviderRouting, } from "@/views/root-compose-environment-selection"; -import { - resolveRootComposeThreadEnvironment, - type RootComposeSelectedBranch, -} from "@/views/root-compose-thread-environment"; +import { resolveRootComposeThreadEnvironment } from "@/views/root-compose-thread-environment"; type NewThreadComposerSelectionScope = "new-thread" | "component-local"; @@ -107,7 +110,6 @@ interface NewThreadComposerLocks { project?: boolean; provider?: boolean; environment?: boolean; - branch?: boolean; } interface NewThreadComposerPromptOptions { @@ -150,7 +152,10 @@ export interface NewThreadComposerState { textEffects: NewThreadPromptBoxProps["textEffects"]; isSubmitting: boolean; seedEnvironmentSelectionValue: (value: string) => void; - setEnvironmentSelectionValue: (value: string) => void; + setEnvironmentSelectionValue: ( + value: string, + providerHostId?: string | null, + ) => void; setProviderModelReasoning: (selection: { providerId: string; model: string; @@ -184,12 +189,11 @@ type ProjectDefaultsState = | { status: "resolved"; defaults: ProjectExecutionDefaults | null }; export interface ResolveNewThreadSubmitDisabledReasonArgs { - branchMutationBlockerTitle: string | null; + environmentProviderInputsBlocker: string | null; isCopyingAttachments: boolean; isLoadingModels: boolean; isSubmitting: boolean; isUploading: boolean; - managedWorktreeUnavailableReason: string | null; modelLoadError: SystemExecutionOptionsModelLoadError | null; projectDefaultsStatus: ProjectDefaultsState["status"]; projectDefaultsUnavailable: boolean; @@ -201,12 +205,11 @@ export interface ResolveNewThreadSubmitDisabledReasonArgs { } export function resolveNewThreadSubmitDisabledReason({ - branchMutationBlockerTitle, + environmentProviderInputsBlocker, isCopyingAttachments, isLoadingModels, isSubmitting, isUploading, - managedWorktreeUnavailableReason, modelLoadError, projectDefaultsStatus, projectDefaultsUnavailable, @@ -242,11 +245,8 @@ export function resolveNewThreadSubmitDisabledReason({ }); } if (!selectedThreadModel) return "Select a model."; + if (environmentProviderInputsBlocker) return environmentProviderInputsBlocker; if (submissionEnvironmentUnavailable) return "Select an environment."; - if (managedWorktreeUnavailableReason) { - return managedWorktreeUnavailableReason; - } - if (branchMutationBlockerTitle) return branchMutationBlockerTitle; if (promptInputEmpty) return "Enter a prompt or attach a file."; return null; } @@ -338,19 +338,6 @@ export function hasPromptOptionValueChanged( return !Object.is(currentValue, nextValue); } -export function hasPromptBranchSelectionChanged( - currentBranch: RootComposeSelectedBranch | null, - nextBranch: RootComposeSelectedBranch | null, -): boolean { - if (currentBranch === null || nextBranch === null) { - return currentBranch !== nextBranch; - } - return ( - currentBranch.name !== nextBranch.name || - currentBranch.isNew !== nextBranch.isNew - ); -} - function resolvePanelThreadId( environmentId: string | null, reuseThreadOptions: ReturnType, @@ -414,31 +401,35 @@ export function NewThreadComposer({ ); const hostsQuery = useHosts(); + const availableHosts = useMemo( + () => selectPersistentHosts(hostsQuery.data), + [hostsQuery.data], + ); const systemConfigQuery = useSystemConfig(); const primaryHostId = selectPrimaryHost( - hostsQuery.data, + availableHosts, systemConfigQuery.data?.primaryHostId ?? null, )?.id ?? null; const knownHostIds = useMemo( - () => new Set((hostsQuery.data ?? []).map((host) => host.id)), - [hostsQuery.data], + () => new Set(availableHosts.map((host) => host.id)), + [availableHosts], ); const connectedHostIds = useMemo( () => new Set( - (hostsQuery.data ?? []) + availableHosts .filter((host) => host.status === "connected") .map((host) => host.id), ), - [hostsQuery.data], + [availableHosts], ); const worktreeHostNameById = useMemo(() => { - const hosts = hostsQuery.data ?? []; + const hosts = availableHosts; return hosts.length <= 1 ? null : new Map(hosts.map((host) => [host.id, host.name])); - }, [hostsQuery.data]); + }, [availableHosts]); const projectThreads = useMemo(() => { const navigation = sidebarNavigationQuery.data; if (!navigation) return undefined; @@ -453,6 +444,50 @@ export function NewThreadComposer({ [projectThreads, worktreeHostNameById], ); + const { providers: registeredEnvironmentProviders } = + useSystemEnvironmentProviders({ projectId }); + const environmentProviders = useMemo( + () => + registeredEnvironmentProviders?.filter((provider) => + isProjectless + ? !provider.requires.projectCheckout && !provider.requires.gitRemote + : !provider.requires.projectless, + ), + [isProjectless, registeredEnvironmentProviders], + ); + const projectGitRemoteUrl = currentProject?.gitRemoteUrl; + const environmentProvidersByHostId = useMemo( + () => + new Map( + availableHosts.map((host) => [ + host.id, + environmentProviders + ?.filter((provider) => { + if ( + (provider.requires.projectCheckout || + provider.requires.gitCheckout) && + findLocalPathProjectSourceForHost(projectSources, host.id) === + undefined + ) { + return false; + } + if ( + provider.requires.gitRemote && + (projectGitRemoteUrl === undefined || + projectGitRemoteUrl === null) + ) { + return false; + } + return true; + }) + .map((provider) => ({ + ...provider, + availability: provider.machineAvailability[host.id] ?? null, + })), + ]), + ), + [availableHosts, environmentProviders, projectGitRemoteUrl, projectSources], + ); const seedSignature = JSON.stringify([ resetKey ?? null, seed?.providerId ?? null, @@ -470,28 +505,102 @@ export function NewThreadComposer({ [seed?.environment], ); const [activeSeedSignature, setActiveSeedSignature] = useState(seedSignature); - const [branchSeedOverridden, setBranchSeedOverridden] = useState(false); + const [seedOverridden, setBranchSeedOverridden] = useState(false); + const [pickedProviderMachine, setPickedProviderMachine] = useState<{ + selectionValue: string; + machine: EnvironmentMachineSelection; + } | null>(null); if (activeSeedSignature !== seedSignature) { setActiveSeedSignature(seedSignature); setBranchSeedOverridden(false); + setPickedProviderMachine(null); } + const resolveProviderSelection = useCallback( + ( + effectiveValue: string, + ): { + provider: SystemEnvironmentProvider; + machine: EnvironmentMachineSelection | null; + } | null => { + const parsedValue = parseEnvironmentValue(effectiveValue); + if (parsedValue?.type !== "provider") return null; + const provider = environmentProviders?.find( + (candidate) => candidate.id === parsedValue.environmentProviderId, + ); + if (provider === undefined) return null; + const usable = (hostId: string | null): boolean => + hostId !== null && + knownHostIds.has(hostId) && + (isProjectless || + !provider.requires.projectCheckout || + findLocalPathProjectSourceForHost(projectSources, hostId) !== + undefined); + const picked = + pickedProviderMachine?.selectionValue === effectiveValue + ? pickedProviderMachine.machine + : null; + const seeded = + picked === null && + !seedOverridden && + environmentSeed !== null && + environmentSeed.selectionValue === effectiveValue + ? environmentSeed.providerMachine + : null; + const candidate = picked ?? seeded; + if (usable(candidate?.hostId ?? null)) { + return { provider, machine: candidate }; + } + return { + provider, + machine: + primaryHostId !== null && usable(primaryHostId) + ? { type: "existing", hostId: primaryHostId } + : null, + }; + }, + [ + seedOverridden, + environmentSeed, + environmentProviders, + isProjectless, + knownHostIds, + pickedProviderMachine, + primaryHostId, + projectSources, + ], + ); + const resolveProviderRouting = useCallback( - (environmentSelectionValue: string) => - resolveRootComposeProviderRouting({ + (environmentSelectionValue: string) => { + const effectiveValue = resolveRootComposeEffectiveEnvironmentValue({ environmentSelectionValue, + environmentProviders, isProjectless, knownHostIds, primaryHostId, projectSources, reuseThreadOptions, reuseThreadOptionsLoading, - }), + }); + const providerSelection = resolveProviderSelection(effectiveValue); + if (providerSelection !== null) { + return providerSelection.machine?.type !== "existing" + ? {} + : { hostId: providerSelection.machine.hostId }; + } + const parsed = parseEnvironmentValue(effectiveValue); + return parsed?.type === "reuse" && parsed.environmentId !== null + ? { environmentId: parsed.environmentId } + : {}; + }, [ + environmentProviders, isProjectless, knownHostIds, primaryHostId, projectSources, + resolveProviderSelection, reuseThreadOptions, reuseThreadOptionsLoading, ], @@ -595,23 +704,55 @@ export function NewThreadComposer({ }); const changeEnvironment = useCallback( - (value: string) => { - if (!hasPromptOptionValueChanged(environmentSelectionValue, value)) + ( + value: string, + providerTarget: string | EnvironmentMachineSelection | null = null, + ) => { + const providerMachine = + typeof providerTarget === "string" + ? { type: "existing" as const, hostId: providerTarget } + : providerTarget; + const currentProviderMachine = + pickedProviderMachine?.selectionValue === value + ? pickedProviderMachine.machine + : null; + if ( + !hasPromptOptionValueChanged(environmentSelectionValue, value) && + JSON.stringify(providerMachine) === + JSON.stringify(currentProviderMachine) + ) { return; + } snapshotDraftBeforeOptionChange(); setBranchSeedOverridden(true); + setPickedProviderMachine( + providerMachine === null + ? null + : { selectionValue: value, machine: providerMachine }, + ); setCreationEnvironmentSelectionValue(value); }, [ environmentSelectionValue, + pickedProviderMachine, setCreationEnvironmentSelectionValue, snapshotDraftBeforeOptionChange, ], ); + const handleSelectProvider = useCallback( + (provider: SystemEnvironmentProvider, hostId: string | null) => { + changeEnvironment( + encodeProviderValue(provider.id), + hostId === null ? null : { type: "existing", hostId }, + ); + }, + [changeEnvironment], + ); const effectiveEnvironmentValue = useMemo( () => resolveRootComposeEffectiveEnvironmentValue({ environmentSelectionValue, + environmentProviders, isProjectless, knownHostIds, primaryHostId, @@ -621,6 +762,7 @@ export function NewThreadComposer({ }), [ environmentSelectionValue, + environmentProviders, isProjectless, knownHostIds, primaryHostId, @@ -633,170 +775,178 @@ export function NewThreadComposer({ () => parseEnvironmentValue(effectiveEnvironmentValue), [effectiveEnvironmentValue], ); - const isHostMode = parsedEnvironment?.type === "host"; - const branchEnvironmentMode: RootComposeBranchEnvironmentMode = isProjectless - ? "other" - : isHostMode && parsedEnvironment.mode === "local" - ? "local" - : isHostMode && parsedEnvironment.mode === "worktree" - ? "worktree" - : "other"; - const { - selectedBranch: pickedBranch, - onBranchChange, - onClearBranch, - onCreateBranch, - onCreateBranchFrom, - } = useScopedBranchSelection({ - environmentValue: effectiveEnvironmentValue, - projectId, - selectionScope, - }); - const selectedBranch = - pickedBranch ?? - (!branchSeedOverridden && - environmentSeed !== null && - effectiveEnvironmentValue === environmentSeed.selectionValue - ? environmentSeed.branch - : null); - const [branchSearchQuery, setBranchSearchQuery] = useState(""); - useEffect(() => { - setBranchSearchQuery(""); - }, [effectiveEnvironmentValue, projectId]); - const branchesQuery = useProjectSourceBranches( - projectId, - isHostMode ? parsedEnvironment.hostId : null, - { - enabled: isHostMode && !isProjectless, - query: branchSearchQuery, - selectedBranch: selectedBranch?.name ?? "", - }, + const providerSelection = useMemo( + () => resolveProviderSelection(effectiveEnvironmentValue), + [effectiveEnvironmentValue, resolveProviderSelection], ); - const worktreeDisabledReason = resolveProjectSourceWorktreeDisabledReason( - branchesQuery.data, + const selectedEnvironmentProvider = providerSelection?.provider; + const providerMachine = providerSelection?.machine ?? null; + const providerHostId = + providerMachine?.type === "existing" ? providerMachine.hostId : null; + const [environmentProviderInputsOverride, setProviderInputsOverride] = + useState<{ scopeKey: string; value: JsonValue | null } | null>(null); + const [environmentProviderInputsBlocked, setProviderInputsBlocked] = + useState<{ scopeKey: string; reason: string } | null>(null); + const environmentProviderInputsScopeKey = `${projectId}\0${effectiveEnvironmentValue}\0${providerHostId ?? ""}`; + const handleProviderInputsChange = useCallback( + (next: PluginEnvironmentProviderInputsChange) => { + if (next.status === "blocked") { + setProviderInputsBlocked((current) => { + if ( + current?.scopeKey === environmentProviderInputsScopeKey && + current.reason === next.reason + ) { + return current; + } + return { + scopeKey: environmentProviderInputsScopeKey, + reason: next.reason, + }; + }); + return; + } + setProviderInputsBlocked(null); + setProviderInputsOverride((current) => { + if ( + current?.scopeKey === environmentProviderInputsScopeKey && + JSON.stringify(current.value) === JSON.stringify(next.value) + ) { + return current; + } + return { + scopeKey: environmentProviderInputsScopeKey, + value: next.value, + }; + }); + }, + [environmentProviderInputsScopeKey], ); - const worktreeUnavailable = worktreeDisabledReason !== null; - const requestsManagedWorktree = - isHostMode && parsedEnvironment.mode === "worktree"; - const managedWorktreeUnavailable = - requestsManagedWorktree && worktreeUnavailable; - useEffect(() => { + const activeProviderInputsOverride = + environmentProviderInputsOverride !== null && + environmentProviderInputsOverride.scopeKey === + environmentProviderInputsScopeKey + ? environmentProviderInputsOverride + : null; + const activeProviderInputsBlocked = + environmentProviderInputsBlocked?.scopeKey === + environmentProviderInputsScopeKey + ? environmentProviderInputsBlocked + : null; + const providerTakesInputs = + selectedEnvironmentProvider !== undefined && + selectedEnvironmentProvider.inputs !== null; + const pluginSlots = usePluginSlots(); + const environmentProviderInputsSlots = pluginSlots.environmentProviderInputs; + const inputsControlProviderIds = useMemo(() => { + const pluginIdByProviderId = new Map( + (environmentProviders ?? []).map((provider) => [ + provider.id, + provider.pluginId, + ]), + ); + return new Set( + environmentProviderInputsSlots + .filter( + (slot) => + pluginIdByProviderId.get(slot.environmentProviderId) === + slot.pluginId, + ) + .map((slot) => slot.environmentProviderId), + ); + }, [environmentProviderInputsSlots, environmentProviders]); + const environmentProviderInputsRegistration = useMemo(() => { if ( - !worktreeUnavailable || - parsedEnvironment?.type !== "host" || - parsedEnvironment.mode !== "worktree" + selectedEnvironmentProvider === undefined || + selectedEnvironmentProvider.inputs === null ) { - return; + return undefined; } - setCreationEnvironmentSelectionValue( - encodeHostValue(parsedEnvironment.hostId, "local"), + return environmentProviderInputsSlots.find( + (slot) => + slot.environmentProviderId === selectedEnvironmentProvider.id && + slot.pluginId === selectedEnvironmentProvider.pluginId, ); + }, [environmentProviderInputsSlots, selectedEnvironmentProvider]); + const controlRequiredForSelectedProvider = + selectedEnvironmentProvider !== undefined && + providerInputsControlRequired(selectedEnvironmentProvider); + const submissionProviderInputs = useMemo((): JsonValue | null => { + if (!providerTakesInputs) return null; + if (activeProviderInputsOverride !== null) { + return activeProviderInputsOverride.value; + } + const seededInputs = + !seedOverridden && + environmentSeed !== null && + effectiveEnvironmentValue === environmentSeed.selectionValue + ? environmentSeed.providerInputs + : null; + if (seededInputs !== null) return seededInputs; + return environmentProviderInputsRegistration === undefined && + !controlRequiredForSelectedProvider + ? {} + : null; }, [ - parsedEnvironment, - setCreationEnvironmentSelectionValue, - worktreeUnavailable, - ]); - const branchOptions = useMemo(() => { - const branches = branchesQuery.data?.branches ?? []; - const selectedRef = branchesQuery.data?.selectedBranch; - return selectedRef?.kind === "local" && !branches.includes(selectedRef.name) - ? [selectedRef.name, ...branches] - : branches; - }, [branchesQuery.data?.branches, branchesQuery.data?.selectedBranch]); - const remoteBranchOptions = useMemo(() => { - if (branchEnvironmentMode === "other") return []; - const branches = branchesQuery.data?.remoteBranches ?? []; - const selectedRef = branchesQuery.data?.selectedBranch; - return selectedRef?.kind === "remote" && - !branches.includes(selectedRef.name) - ? [selectedRef.name, ...branches] - : branches; - }, [ - branchEnvironmentMode, - branchesQuery.data?.remoteBranches, - branchesQuery.data?.selectedBranch, + activeProviderInputsOverride, + controlRequiredForSelectedProvider, + seedOverridden, + effectiveEnvironmentValue, + environmentSeed, + environmentProviderInputsRegistration, + providerTakesInputs, ]); - const branchSelectionSeed = - branchEnvironmentMode === "local" && - branchesQuery.data?.checkout.kind === "branch" - ? branchesQuery.data.checkout.branchName - : branchEnvironmentMode === "worktree" - ? (branchesQuery.data?.defaultWorktreeBaseBranch ?? - branchesQuery.data?.defaultBranch ?? - null) - : null; - const branchUiState = useMemo( - () => - buildRootComposeBranchUiState({ - checkout: branchesQuery.data, - isFetching: branchesQuery.isFetching, - isLoading: branchesQuery.isLoading, - mode: branchEnvironmentMode, - selectedBranch, - }), - [ - branchEnvironmentMode, - branchesQuery.data, - branchesQuery.isFetching, - branchesQuery.isLoading, - selectedBranch, - ], - ); - const handleBranchChange = useCallback( - (name: string) => { - const nextBranch = { name, isNew: false }; - if (!hasPromptBranchSelectionChanged(selectedBranch, nextBranch)) return; - snapshotDraftBeforeOptionChange(); - setBranchSeedOverridden(true); - onBranchChange(name); - }, - [onBranchChange, selectedBranch, snapshotDraftBeforeOptionChange], - ); - const handleClearBranch = useCallback(() => { - if (!hasPromptBranchSelectionChanged(selectedBranch, null)) return; - snapshotDraftBeforeOptionChange(); - setBranchSeedOverridden(true); - onClearBranch(); - }, [onClearBranch, selectedBranch, snapshotDraftBeforeOptionChange]); - const handleCreateBranch = useCallback(() => { - const name = selectedBranch?.name ?? branchSelectionSeed; - const nextBranch = name === null ? null : { name, isNew: true }; - if (!hasPromptBranchSelectionChanged(selectedBranch, nextBranch)) return; - snapshotDraftBeforeOptionChange(); - setBranchSeedOverridden(true); - onCreateBranch(name); + const environmentProviderInputsBlocker = + selectedEnvironmentProvider === undefined || !providerTakesInputs + ? null + : activeProviderInputsBlocked !== null + ? activeProviderInputsBlocked.reason + : environmentProviderInputsRegistration === undefined && + controlRequiredForSelectedProvider + ? `${selectedEnvironmentProvider.displayName} needs its plugin's control` + : submissionProviderInputs === null + ? `Configure ${selectedEnvironmentProvider.displayName}` + : null; + const environmentProviderInputsSlot = useMemo(() => { + if (environmentProviderInputsRegistration === undefined) return null; + const InputsComponent = environmentProviderInputsRegistration.component; + return ( + + + + ); }, [ - branchSelectionSeed, - onCreateBranch, - selectedBranch, - snapshotDraftBeforeOptionChange, + handleProviderInputsChange, + isProjectless, + projectId, + providerHostId, + submissionProviderInputs, + environmentProviderInputsRegistration, ]); - const handleCreateBranchFrom = useCallback( - (name: string) => { - const nextBranch = { name, isNew: true }; - if (!hasPromptBranchSelectionChanged(selectedBranch, nextBranch)) return; - snapshotDraftBeforeOptionChange(); - setBranchSeedOverridden(true); - onCreateBranchFrom(name); - }, - [onCreateBranchFrom, selectedBranch, snapshotDraftBeforeOptionChange], - ); + const selectedEnvironment = useMemo( () => resolveRootComposeThreadEnvironment({ - defaultBranch: branchesQuery.data?.defaultBranch, - defaultWorktreeBaseBranch: - branchesQuery.data?.defaultWorktreeBaseBranch, environmentValue: effectiveEnvironmentValue, projectId, - selectedBranch, + environmentProviders, + providerMachine: providerMachine, + providerInputs: submissionProviderInputs, }), [ - branchesQuery.data?.defaultBranch, - branchesQuery.data?.defaultWorktreeBaseBranch, effectiveEnvironmentValue, + environmentProviders, projectId, - selectedBranch, + submissionProviderInputs, + providerMachine, ], ); @@ -912,11 +1062,8 @@ export function NewThreadComposer({ parsedEnvironment?.type === "reuse" ? parsedEnvironment.environmentId : null; - const projectRouting = resolveRootComposeProjectRouting( - parsedEnvironment, - primaryHostId, - ); - const projectHostId = projectRouting.hostId ?? null; + const projectHostId = + reuseEnvironmentId !== null ? null : (providerHostId ?? primaryHostId); const panelThreadId = resolvePanelThreadId( reuseEnvironmentId, reuseThreadOptions, @@ -1051,17 +1198,11 @@ export function NewThreadComposer({ (selectionScope === "new-thread" ? seed?.environment : undefined) ?? null; const submitDisabledReason = resolveNewThreadSubmitDisabledReason({ - branchMutationBlockerTitle: - branchEnvironmentMode === "local" && selectedBranch !== null - ? (branchUiState.mutationBlocker?.title ?? null) - : null, + environmentProviderInputsBlocker: environmentProviderInputsBlocker, isCopyingAttachments, isLoadingModels, isSubmitting, isUploading, - managedWorktreeUnavailableReason: managedWorktreeUnavailable - ? worktreeDisabledReason - : null, modelLoadError, projectDefaultsStatus: projectDefaultsState.status, projectDefaultsUnavailable, @@ -1083,8 +1224,7 @@ export function NewThreadComposer({ projectDefaultsUnavailable || submissionEnvironment === null || !selectedProviderId || - !selectedThreadModel || - managedWorktreeUnavailable + !selectedThreadModel ) { throw new Error( blockedReason ?? @@ -1131,7 +1271,6 @@ export function NewThreadComposer({ [ clearReuseEnvironment, executionInputSources, - managedWorktreeUnavailable, onSubmit, permissionMode, projectDefaultsUnavailable, @@ -1206,13 +1345,6 @@ export function NewThreadComposer({ }, [serviceTier, setServiceTier, snapshotDraftBeforeOptionChange], ); - const refreshBranchesFromRemote = branchesQuery.refreshFromRemote; - const handleBranchOpenChange = useCallback( - (open: boolean) => { - if (open) void refreshBranchesFromRemote().catch(() => undefined); - }, - [refreshBranchesFromRemote], - ); const handleWorktreeChange = useCallback( (environmentId: string) => { changeEnvironment(encodeReuseValue(environmentId)); @@ -1282,48 +1414,17 @@ export function NewThreadComposer({ value: effectiveEnvironmentValue, onChange: changeEnvironment, sources: projectSources, - reuseDisabled: reuseThreadOptions.length === 0, - worktreeDisabledReason, disabled: locks.environment, + isLoading: environmentProviders === undefined, + providers: environmentProviders ?? [], + providersByHostId: environmentProvidersByHostId, + selectedProviderHostId: providerHostId, + inputsControlProviderIds, + onSelectProvider: handleSelectProvider, ...(!isProjectless && options.onRequestMachineSetup ? { onRequestMachineSetup: options.onRequestMachineSetup } : {}), }, - branch: { - value: - selectedBranch?.name ?? - (branchEnvironmentMode === "worktree" - ? branchUiState.currentBranch - : null), - currentBranch: branchUiState.currentBranch, - isNew: selectedBranch?.isNew ?? false, - hidden: worktreeUnavailable, - options: branchOptions, - remoteOptions: remoteBranchOptions, - loading: branchesQuery.isFetching, - placeholder: branchUiState.placeholder, - triggerLabel: branchUiState.triggerLabel, - triggerTitle: branchUiState.triggerTitle, - currentOptionLabel: - branchEnvironmentMode === "local" - ? branchUiState.currentOptionLabel - : null, - currentOptionTitle: - branchEnvironmentMode === "local" - ? (branchUiState.currentOptionLabel ?? undefined) - : undefined, - optionDisabledReason: branchUiState.mutationBlocker?.label, - optionDisabledTitle: branchUiState.mutationBlocker?.title, - createDisabledReason: branchUiState.mutationBlocker?.label, - createDisabledTitle: branchUiState.mutationBlocker?.title, - disabled: locks.branch, - onChange: handleBranchChange, - onClear: handleClearBranch, - onCreate: handleCreateBranch, - onCreateBaseChange: handleCreateBranchFrom, - onOpenChange: handleBranchOpenChange, - onSearchQueryChange: setBranchSearchQuery, - }, worktree: { options: reuseThreadOptions, value: reuseEnvironmentId, @@ -1336,6 +1437,7 @@ export function NewThreadComposer({ onChange: handlePermissionChange, supported: supportsPermissionModeSelection, }, + environmentProviderInputsSlot, banner: options.banner, header: options.header, }} @@ -1390,28 +1492,21 @@ export function NewThreadComposer({ [ activeModel, attachmentError, - branchEnvironmentMode, - branchOptions, - branchUiState, - branchesQuery.isFetching, changeEnvironment, commandSuggestions, currentDraft, defaultMentionLinkResolver, effectiveEnvironmentValue, + environmentProviders, executionOptionsRouting, handleAttachFiles, - handleBranchChange, - handleBranchOpenChange, - handleClearBranch, - handleCreateBranch, - handleCreateBranchFrom, handleEditorFocus, handleModelChange, handlePermissionChange, handleProjectChange, handleProviderChange, handleReasoningChange, + handleSelectProvider, handleServiceTierChange, handleSubmit, handleWorktreeChange, @@ -1438,10 +1533,8 @@ export function NewThreadComposer({ providerOptions, reasoningLevel, reasoningOptions, - remoteBranchOptions, reuseEnvironmentId, reuseThreadOptions, - selectedBranch, selectedModel, selectedProviderId, serviceTier, @@ -1450,9 +1543,11 @@ export function NewThreadComposer({ supportsPermissionModeSelection, supportsServiceTier, submitDisabledReason, + environmentProviderInputsSlot, + environmentProvidersByHostId, + inputsControlProviderIds, + providerHostId, textEffects, - worktreeDisabledReason, - worktreeUnavailable, serviceTierFastLabel, ], ); diff --git a/apps/app/src/components/promptbox/NewThreadEnvironmentOptions.stories.tsx b/apps/app/src/components/promptbox/NewThreadEnvironmentOptions.stories.tsx index 5fd51c94b0..bc0ada64fb 100644 --- a/apps/app/src/components/promptbox/NewThreadEnvironmentOptions.stories.tsx +++ b/apps/app/src/components/promptbox/NewThreadEnvironmentOptions.stories.tsx @@ -7,9 +7,8 @@ import { EnvironmentPickerUI, type EnvironmentPickerUIProps, } from "@/components/pickers/EnvironmentPicker"; -import { parseEnvironmentValue } from "@/components/pickers/environment-picker-value"; import { ProjectSelector } from "@/components/pickers/ProjectSelector"; -import { WorktreePicker } from "@/components/pickers/WorktreePicker"; +import { ReuseEnvironmentPicker } from "@/components/pickers/ReuseEnvironmentPicker"; import { StoryCard, StoryRow } from "../../../.ladle/story-card"; import { HOST_IDS, @@ -17,6 +16,7 @@ import { PROJECT_IDS, STORY_BRANCH_OPTIONS, STORY_PROJECTS, + STORY_ENVIRONMENT_PROVIDERS, STORY_PROJECT_SOURCES, STORY_WORKTREE_OPTIONS, } from "../../../.ladle/story-fixtures"; @@ -27,17 +27,6 @@ export default { const noop = () => {}; -function getStoryBranchMenuKind( - environmentValue: string, -): BranchPickerProps["menuKind"] { - const parsedEnvironment = parseEnvironmentValue(environmentValue); - if (parsedEnvironment?.type !== "host") { - return undefined; - } - - return parsedEnvironment.mode === "worktree" ? "base" : "checkout"; -} - interface EnvironmentOptionsStripProps { project?: { value: string | null; allowNoProject?: boolean }; projectless?: boolean; @@ -56,8 +45,9 @@ function EnvironmentOptionsStrip({ const [projectValue, setProjectValue] = useState( project?.value ?? PROJECT_IDS.bb, ); - const environmentValue = environment?.value ?? `host:${HOST_IDS.local}:local`; - const showWorktreePicker = environmentValue === "reuse"; + const environmentValue = environment?.value ?? "provider:project-checkout"; + const showReuseEnvironmentPicker = environmentValue === "reuse"; + const showBranchPicker = environmentValue === "provider:git-worktree"; return (
@@ -69,47 +59,42 @@ function EnvironmentOptionsStrip({ className="h-7 px-1.5" modal={false} /> - {projectless ? null : ( - <> - - {showWorktreePicker ? ( - - ) : ( - - )} - - )} + + {showReuseEnvironmentPicker ? ( + + ) : showBranchPicker ? ( + + ) : null}
); @@ -120,100 +105,24 @@ export function Overview() {
- - - - - - - - - - - - - - - - - - @@ -222,13 +131,15 @@ export function Overview() { hint="new worktree from named base" > @@ -254,17 +165,20 @@ export function Overview() { ); @@ -563,7 +547,7 @@ export function Overview() { diff --git a/apps/app/src/components/promptbox/NewThreadPromptBox.test.tsx b/apps/app/src/components/promptbox/NewThreadPromptBox.test.tsx index c4e11e50b0..abf39f0a82 100644 --- a/apps/app/src/components/promptbox/NewThreadPromptBox.test.tsx +++ b/apps/app/src/components/promptbox/NewThreadPromptBox.test.tsx @@ -1,103 +1,23 @@ // @vitest-environment jsdom +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { cleanup, fireEvent, render, screen } from "@testing-library/react"; -import type { Host, ProjectSource } from "@bb/domain"; +import type { Host } from "@bb/domain"; import { makeHost } from "@bb/test-helpers/domain-fixtures"; import { afterEach, describe, expect, it, vi } from "vitest"; -import { ProjectlessMachineSlot, ThreadEnvSlot } from "./NewThreadPromptBox"; +import type { SystemEnvironmentProvider } from "@bb/server-contract"; +import { EnvironmentSlot, ProjectlessMachineSlot } from "./NewThreadPromptBox"; const host = makeHost({ id: "host_test", name: "Local host", }); -const sources: readonly ProjectSource[] = [ - { - id: "src_test", - projectId: "proj_test", - type: "local_path", - hostId: host.id, - path: "/tmp/project", - isDefault: true, - createdAt: 0, - updatedAt: 0, - }, -]; - afterEach(() => { cleanup(); vi.clearAllMocks(); }); -describe("ThreadEnvSlot", () => { - it("forwards worktree disabled reasons to the environment picker", () => { - render( - , - ); - - fireEvent.pointerDown(screen.getByRole("button", { name: "Environment" }), { - button: 0, - }); - - const worktreeItem = screen.getByRole("menuitem", { - name: /New worktree/u, - }); - - expect(worktreeItem.getAttribute("aria-disabled")).toBe("true"); - }); - - it("hides the branch picker when branch controls are not applicable", () => { - render( - , - ); - - expect(screen.queryByText("Unknown checkout")).toBeNull(); - }); -}); - describe("ProjectlessMachineSlot", () => { const secondHost: Host = { ...host, @@ -105,9 +25,30 @@ describe("ProjectlessMachineSlot", () => { name: "Mac Studio", }; + const personalWorkspaceProvider: SystemEnvironmentProvider = { + id: "personal-workspace", + displayName: "Personal workspace", + icon: "Folder", + logoUrl: null, + pluginId: "environment-personal-workspace", + acceptsEmptyInputs: true, + machineAvailability: {}, + availability: null, + requires: { + projectCheckout: false, + gitCheckout: false, + gitRemote: false, + projectless: true, + }, + inputs: null, + }; + function makeEnvironment(overrides?: { - value?: string; - onChange?: (value: string) => void; + selectedProviderHostId?: string; + onSelectProvider?: ( + provider: SystemEnvironmentProvider, + hostId: string | null, + ) => void; machines?: { hosts: Host[]; localDaemonHostId: string | null; @@ -115,8 +56,8 @@ describe("ProjectlessMachineSlot", () => { } | null; }) { return { - value: overrides?.value ?? `host:${host.id}:local`, - onChange: overrides?.onChange ?? vi.fn(), + value: "provider:personal-workspace", + onChange: vi.fn(), sources: [], host, isLocal: true, @@ -128,6 +69,9 @@ describe("ProjectlessMachineSlot", () => { localDaemonHostId: host.id, primaryHostId: host.id, }, + providers: [personalWorkspaceProvider], + selectedProviderHostId: overrides?.selectedProviderHostId ?? host.id, + onSelectProvider: overrides?.onSelectProvider ?? vi.fn(), }; } @@ -157,24 +101,34 @@ describe("ProjectlessMachineSlot", () => { expect(screen.queryByRole("button", { name: "Machine" })).toBeNull(); }); - it("encodes a machine pick as that host's personal-local environment value", () => { - const onChange = vi.fn(); + it("counts provider-made machines in the projectless machine chip", () => { render( - , + , ); - fireEvent.pointerDown(screen.getByRole("button", { name: "Machine" }), { - button: 0, - }); - fireEvent.click(screen.getByRole("menuitem", { name: /Mac Studio/u })); - - expect(onChange).toHaveBeenCalledWith(`host:${secondHost.id}:local`); + expect(screen.getByRole("button", { name: "Machine" })).toBeTruthy(); }); it("names the selected machine in the chip", () => { render( , ); @@ -182,4 +136,270 @@ describe("ProjectlessMachineSlot", () => { screen.getByRole("button", { name: "Machine" }).textContent, ).toContain("Mac Studio"); }); + + it("routes a machine pick through the selected provider", () => { + const onSelectProvider = vi.fn(); + render( + , + ); + + const trigger = screen.getByRole("button", { name: "Machine" }); + expect(trigger.textContent).toContain("Mac Studio"); + fireEvent.pointerDown(trigger, { button: 0 }); + fireEvent.click(screen.getByRole("menuitem", { name: /Local host/u })); + + expect(onSelectProvider).toHaveBeenCalledWith( + personalWorkspaceProvider, + host.id, + ); + }); +}); + +describe("EnvironmentSlot", () => { + const secondHost: Host = { + ...host, + id: "host_second", + name: "Mac Studio", + }; + + const personalProvider: SystemEnvironmentProvider = { + id: "personal-workspace", + displayName: "Personal workspace", + icon: "Folder", + logoUrl: null, + pluginId: "environment-personal-workspace", + acceptsEmptyInputs: true, + machineAvailability: {}, + availability: null, + requires: { + projectCheckout: false, + gitCheckout: false, + gitRemote: false, + projectless: true, + }, + inputs: null, + }; + + const sandboxProvider: SystemEnvironmentProvider = { + id: "modal-sandbox", + displayName: "Modal sandbox", + icon: "Cloud", + logoUrl: null, + pluginId: "environment-modal-sandbox", + acceptsEmptyInputs: true, + machineAvailability: {}, + availability: null, + requires: { + projectCheckout: false, + gitCheckout: false, + gitRemote: false, + projectless: false, + }, + inputs: null, + }; + + function makeEnvironment(overrides: { + isLoading?: boolean; + value?: string; + providers?: readonly SystemEnvironmentProvider[]; + onSelectProvider?: ( + provider: SystemEnvironmentProvider, + hostId: string | null, + ) => void; + }) { + return { + value: overrides.value ?? "provider:personal-workspace", + onChange: vi.fn(), + sources: [], + host, + isLocal: true, + machines: { + hosts: [host, secondHost], + localDaemonHostId: host.id, + primaryHostId: host.id, + }, + isLoading: overrides.isLoading ?? false, + providers: overrides.providers ?? [personalProvider], + selectedProviderHostId: host.id, + onSelectProvider: overrides.onSelectProvider ?? vi.fn(), + }; + } + + function makeWorktree(value: string | null = null) { + return { + options: [ + { + environmentId: "env_personal", + branchName: null, + name: "Scratch space", + path: null, + environmentProviderId: "personal-workspace", + threads: [{ id: "thr_1", title: "Earlier personal thread" }], + }, + ], + value, + onChange: vi.fn(), + disabled: false, + }; + } + + it("shows environment loading before resolving to the machine slot", () => { + const { rerender } = render( + , + ); + expect(screen.getByRole("button", { name: "Environment" })).not.toBeNull(); + expect(screen.queryByRole("button", { name: "Machine" })).toBeNull(); + rerender( + , + ); + expect(screen.queryByRole("button", { name: "Environment" })).toBeNull(); + expect(screen.getByRole("button", { name: "Machine" })).not.toBeNull(); + }); + + it("keeps the machine slot when only one provider is available", () => { + render( + , + ); + + expect(screen.getByRole("button", { name: "Machine" })).not.toBeNull(); + expect(screen.queryByRole("button", { name: "Environment" })).toBeNull(); + }); + + it("omits project-only providers from the projectless picker", () => { + render( + , + ); + + expect(screen.getByRole("button", { name: "Machine" })).toBeTruthy(); + expect(screen.queryByRole("button", { name: "Environment" })).toBeNull(); + expect(screen.queryByText("Modal sandbox")).toBeNull(); + }); + + it("shows the reused environment instead of the machine slot when a thread reuses one", () => { + render( + + + , + ); + + expect(screen.queryByRole("button", { name: "Machine" })).toBeNull(); + const triggers = screen.getAllByRole("button", { name: "Environment" }); + expect(triggers).toHaveLength(2); + expect(triggers[0]?.textContent).toContain("Reuse"); + expect(triggers[1]?.textContent).toContain("Scratch space"); + }); + + it("keeps an open environment menu mounted while project scope replays", () => { + const projectProvider = { + ...sandboxProvider, + requires: { + ...sandboxProvider.requires, + projectless: false, + }, + }; + const environment = makeEnvironment({ + value: "reuse:env_personal", + providers: [personalProvider, projectProvider], + }); + const queryClient = new QueryClient(); + const { rerender } = render( + + + , + ); + const trigger = screen.getAllByRole("button", { name: "Environment" })[0]; + fireEvent.pointerDown(trigger!, { button: 0 }); + expect(screen.getByRole("menu")).toBeTruthy(); + + rerender( + + + , + ); + + expect(screen.getByRole("menu")).toBeTruthy(); + expect(document.querySelector('button[aria-label="Environment"]')).toBe( + trigger, + ); + }); + + it("keeps an open environment menu mounted while projectless options settle", () => { + const loadingEnvironment = makeEnvironment({ + providers: [personalProvider], + isLoading: true, + }); + const queryClient = new QueryClient(); + const { rerender } = render( + + + , + ); + const trigger = screen.getByRole("button", { name: "Environment" }); + fireEvent.pointerDown(trigger, { button: 0 }); + expect(screen.getByRole("menu")).toBeTruthy(); + + rerender( + + + , + ); + + expect(screen.getByRole("menu")).toBeTruthy(); + expect(document.querySelector('button[aria-label="Environment"]')).toBe( + trigger, + ); + fireEvent.keyDown(document, { key: "Escape" }); + expect(screen.queryByRole("button", { name: "Environment" })).toBeNull(); + expect(screen.getByRole("button", { name: "Machine" })).toBeTruthy(); + }); }); diff --git a/apps/app/src/components/promptbox/NewThreadPromptBox.tsx b/apps/app/src/components/promptbox/NewThreadPromptBox.tsx index 6f33a854f4..e479a9ad7d 100644 --- a/apps/app/src/components/promptbox/NewThreadPromptBox.tsx +++ b/apps/app/src/components/promptbox/NewThreadPromptBox.tsx @@ -10,6 +10,7 @@ import { type RefObject, } from "react"; import type { Host, ProjectSource, PromptTextMention } from "@bb/domain"; +import type { SystemEnvironmentProvider } from "@bb/server-contract"; import type { ComposerView } from "@get-bb/plugin-sdk"; import type { ComposerTextEffectSource } from "@/lib/composer-text-effects"; import { ComposerBannersSlot } from "@/components/plugin/PluginComposerBanners"; @@ -37,21 +38,13 @@ import { } from "@/components/promptbox/PromptBoxInternal"; import { usePromptVoice } from "@/components/promptbox/usePromptVoice"; import { useOptionalPaneContext } from "@/views/thread-detail/PaneContext"; -import { - BranchPicker, - type BranchPickerMenuKind, -} from "@/components/pickers/BranchPicker"; import { EnvironmentPickerUI, type EnvironmentPickerMachines, type EnvironmentPickerUIProps, } from "@/components/pickers/EnvironmentPicker"; import { MachinePickerUI } from "@/components/pickers/MachinePicker"; -import { - encodeHostValue, - type ParsedEnvironmentValue, - parseEnvironmentValue, -} from "@/components/pickers/environment-picker-value"; +import { parseEnvironmentValue } from "@/components/pickers/environment-picker-value"; import { PermissionModePicker } from "@/components/pickers/PermissionModePicker"; import { ProjectSelector, @@ -59,10 +52,14 @@ import { type ProjectSelectorOption, } from "@/components/pickers/ProjectSelector"; import { - WorktreePicker, + ReuseEnvironmentPicker, type ReuseThreadOption, -} from "@/components/pickers/WorktreePicker"; -import { selectPrimaryHost, useHosts } from "@/hooks/queries/host-queries"; +} from "@/components/pickers/ReuseEnvironmentPicker"; +import { + selectPersistentHosts, + selectPrimaryHost, + useHosts, +} from "@/hooks/queries/host-queries"; import { useSystemConfig } from "@/hooks/queries/system-queries"; import { useHostDaemon } from "@/hooks/useHostDaemon"; import { @@ -84,35 +81,13 @@ export interface NewThreadEnvironmentConfig { isLocal: EnvironmentPickerUIProps["isLocal"]; machines?: EnvironmentPickerMachines | null; onRequestMachineSetup?: (host: Host) => void; - reuseDisabled?: boolean; - worktreeDisabledReason?: string | null; disabled?: boolean; -} - -export interface NewThreadBranchConfig { - value: string | null; - currentBranch?: string | null; - isNew: boolean; - hidden?: boolean; - options: readonly string[]; - remoteOptions?: readonly string[]; - loading?: boolean; - placeholder?: string; - triggerLabel?: string; - triggerTitle?: string; - currentOptionLabel?: string | null; - currentOptionTitle?: string; - optionDisabledReason?: string | null; - optionDisabledTitle?: string; - createDisabledReason?: string | null; - createDisabledTitle?: string; - onChange: (value: string) => void; - onClear?: () => void; - onOpenChange?: (open: boolean) => void; - onSearchQueryChange?: (query: string) => void; - onCreateBaseChange?: (value: string) => void; - disabled?: boolean; - onCreate?: () => void; + isLoading?: boolean; + providers?: readonly SystemEnvironmentProvider[]; + providersByHostId?: EnvironmentPickerUIProps["providersByHostId"]; + selectedProviderHostId?: string | null; + inputsControlProviderIds?: ReadonlySet; + onSelectProvider?: EnvironmentPickerUIProps["onSelectProvider"]; } export interface NewThreadWorktreeConfig { @@ -135,9 +110,9 @@ export interface NewThreadProjectConfig { export interface NewThreadModeConfig { environment: NewThreadEnvironmentConfig; - branch: NewThreadBranchConfig; worktree: NewThreadWorktreeConfig; permission: ExecutionPermissionConfig; + environmentProviderInputsSlot?: ReactNode; banner?: ReactNode; header?: ReactNode; } @@ -170,20 +145,6 @@ interface NewThreadPromptBoxUIProps { execution: ExecutionControlsProps; } -interface GetBranchPickerMenuKindArgs { - parsedEnvironment: ParsedEnvironmentValue; -} - -function getBranchPickerMenuKind({ - parsedEnvironment, -}: GetBranchPickerMenuKindArgs): BranchPickerMenuKind | undefined { - if (parsedEnvironment?.type !== "host") { - return undefined; - } - - return parsedEnvironment.mode === "worktree" ? "base" : "checkout"; -} - function getNewThreadPromptPlaceholder(isProjectless: boolean): string { return isProjectless ? "Ask anything." @@ -408,15 +369,14 @@ const DefaultNewThreadComposer = memo(function DefaultNewThreadComposer({ className="shrink-0" /> ) : null} - {project?.value !== null ? ( - - ) : ( - - )} +
provider.requires.projectless === projectless, + ); const parsedEnvironment = useMemo( () => parseEnvironmentValue(environment.value), [environment.value], ); - const branchMenuKind = getBranchPickerMenuKind({ parsedEnvironment }); - const showBranchPicker = - parsedEnvironment?.type === "host" && branch.hidden !== true; - const showWorktreePicker = parsedEnvironment?.type === "reuse"; + const selectedProvider = + parsedEnvironment?.type === "provider" + ? providers.find( + (provider) => provider.id === parsedEnvironment.environmentProviderId, + ) + : undefined; + const showReuseEnvironmentPicker = parsedEnvironment?.type === "reuse"; + const [environmentPickerOpen, setEnvironmentPickerOpen] = useState(false); + const showEnvironmentPicker = + !projectless || + environment.isLoading || + providers.length > 1 || + showReuseEnvironmentPicker || + environmentPickerOpen; + if (!showEnvironmentPicker) { + return ; + } + return ( <> - {showBranchPicker ? ( - - ) : null} - {showWorktreePicker ? ( - ) : null} + {selectedProvider !== undefined && selectedProvider.inputs !== null + ? environmentProviderInputsSlot + : null} ); } @@ -518,27 +480,44 @@ export function ProjectlessMachineSlot({ environment, }: ProjectlessMachineSlotProps) { const machines = environment.machines ?? null; + const availableHosts = useMemo( + () => selectPersistentHosts(machines?.hosts), + [machines?.hosts], + ); const parsedEnvironment = useMemo( () => parseEnvironmentValue(environment.value), [environment.value], ); - const handleChange = environment.onChange; + const selectedProvider = + parsedEnvironment?.type === "provider" + ? environment.providers?.find( + (provider) => provider.id === parsedEnvironment.environmentProviderId, + ) + : undefined; + const handleSelectProvider = environment.onSelectProvider; const handleMachineChange = useCallback( (hostId: string) => { - handleChange(encodeHostValue(hostId, "local")); + if ( + selectedProvider !== undefined && + handleSelectProvider !== undefined + ) { + handleSelectProvider(selectedProvider, hostId); + } }, - [handleChange], + [handleSelectProvider, selectedProvider], ); - if (!machines || machines.hosts.length <= 1) { + if (!machines || availableHosts.length <= 1) { return null; } return ( ; -type NewThreadConnectedBranchConfig = Omit< - NewThreadBranchConfig, - "onCreate" -> & { - onCreate: () => void; -}; - interface NewThreadConnectedModeConfig { environment: NewThreadConnectedEnvironmentConfig; - branch: NewThreadConnectedBranchConfig; worktree: NewThreadWorktreeConfig; permission: ExecutionPermissionConfig; + environmentProviderInputsSlot?: ReactNode; banner?: ReactNode; header?: ReactNode; } @@ -583,29 +555,34 @@ export function NewThreadPromptBox({ const { data: hosts } = useHosts(); const systemConfigQuery = useSystemConfig(); const primaryHostId = systemConfigQuery.data?.primaryHostId ?? null; + const availableHosts = useMemo(() => selectPersistentHosts(hosts), [hosts]); const primaryHost = useMemo( - () => selectPrimaryHost(hosts, primaryHostId), - [hosts, primaryHostId], + () => selectPrimaryHost(availableHosts, primaryHostId), + [availableHosts, primaryHostId], ); const { isLocalDaemonHost, localDaemonHostId } = useHostDaemon(); const parsedEnvironment = parseEnvironmentValue( threadConfig.environment.value, ); + const selectedEnvironmentHostId = + parsedEnvironment?.type === "provider" + ? (threadConfig.environment.selectedProviderHostId ?? null) + : null; const selectedHost = - parsedEnvironment?.type === "host" - ? (hosts?.find((host) => host.id === parsedEnvironment.hostId) ?? + selectedEnvironmentHostId !== null + ? (availableHosts.find((host) => host.id === selectedEnvironmentHostId) ?? primaryHost) : primaryHost; const isLocalHost = selectedHost ? isLocalDaemonHost(selectedHost.id) : false; const machines = useMemo( - () => (hosts ? { hosts, localDaemonHostId, primaryHostId } : null), - [hosts, localDaemonHostId, primaryHostId], + () => + hosts + ? { hosts: availableHosts, localDaemonHostId, primaryHostId } + : null, + [availableHosts, hosts, localDaemonHostId, primaryHostId], ); - const isHostMode = parsedEnvironment?.type === "host"; - const allowCreate = isHostMode && parsedEnvironment.mode === "local"; - const uiEnvironment = useMemo( () => ({ ...threadConfig.environment, @@ -615,23 +592,15 @@ export function NewThreadPromptBox({ }), [threadConfig.environment, selectedHost, isLocalHost, machines], ); - const uiBranch = useMemo(() => { - const branch = threadConfig.branch; - return { - ...branch, - isNew: allowCreate && branch.isNew, - onCreate: allowCreate ? branch.onCreate : undefined, - }; - }, [allowCreate, threadConfig.branch]); - return ( { const onAction = vi.fn(); render( , ); @@ -82,10 +85,16 @@ describe("PromptBoxActionsMenu", () => { ); const menuItems = await screen.findAllByRole("menuitem"); expect(menuItems.map((item) => item.textContent)).toEqual([ + "Skills", "Plan", "Automation", "Plugin", ]); + expect( + menuItems.map((item) => + item.querySelector("[data-icon]")?.getAttribute("data-icon"), + ), + ).toEqual(["Zap", "ListTodo", "Repeat", "Plug02"]); fireEvent.click(screen.getByRole("menuitem", { name: "Plugin" })); diff --git a/apps/app/src/components/promptbox/PromptBoxActionsMenu.tsx b/apps/app/src/components/promptbox/PromptBoxActionsMenu.tsx index ea107bd6ab..17ef9ef70f 100644 --- a/apps/app/src/components/promptbox/PromptBoxActionsMenu.tsx +++ b/apps/app/src/components/promptbox/PromptBoxActionsMenu.tsx @@ -90,7 +90,7 @@ const PROMPT_ACTION_PRESENTATION = { }, plugin: { label: "Plugin", - icon: "ElectricPlugs", + icon: "Plug02", }, } as const satisfies Record< PromptBoxActionKind, diff --git a/apps/app/src/components/promptbox/PromptBoxInternal.test.tsx b/apps/app/src/components/promptbox/PromptBoxInternal.test.tsx index 9d6a0c5079..86432bd0ba 100644 --- a/apps/app/src/components/promptbox/PromptBoxInternal.test.tsx +++ b/apps/app/src/components/promptbox/PromptBoxInternal.test.tsx @@ -1449,6 +1449,60 @@ describe("PromptBoxInternal submit shortcuts", () => { } }); + it("routes Control+Enter to modifier submit and labels the shortcut for the platform", () => { + const platformMock = vi + .spyOn(navigator, "platform", "get") + .mockReturnValue("Linux x86_64"); + try { + const onModifierSubmit = vi.fn(); + const onSubmit = vi.fn(); + render( + , + ); + + const editor = getPromptEditorElement(); + expect(editor.getAttribute("aria-keyshortcuts")).toBe("Control+Enter"); + act(() => editor.focus()); + fireEvent.keyDown(editor, { + key: "Enter", + code: "Enter", + ctrlKey: true, + }); + + expect(onModifierSubmit).toHaveBeenCalledOnce(); + expect(onSubmit).not.toHaveBeenCalled(); + } finally { + platformMock.mockRestore(); + } + }); + + it("labels the modifier submit shortcut with Meta on Mac", () => { + const platformMock = vi + .spyOn(navigator, "platform", "get") + .mockReturnValue("MacIntel"); + try { + render( + , + ); + expect(getPromptEditorElement().getAttribute("aria-keyshortcuts")).toBe( + "Meta+Enter", + ); + } finally { + platformMock.mockRestore(); + } + }); + it("does not submit a hardware Enter that is committing IME composition", () => { const restoreMatchMedia = mockPointerCoarse(true); const restoreNavigator = mockIPadOSWebKit(); @@ -2234,6 +2288,143 @@ describe("PromptBoxInternal compact layout", () => { } }); + it("does not start voice input from the trailing click of a touch submit", () => { + const restoreMatchMedia = mockPointerCoarse(true); + try { + const start = vi.fn(); + const voice = { + state: "idle" as const, + isSupported: true, + stream: null, + start, + stop: vi.fn(), + cancel: vi.fn(), + }; + const onSubmit = vi.fn(); + const { rerender } = render( + , + ); + + const submit = screen.getByRole("button", { name: "Submit (Enter)" }); + vi.spyOn(submit, "getBoundingClientRect").mockReturnValue( + new DOMRect(0, 0, 40, 40), + ); + const touch = { + button: 0, + pointerType: "touch", + pointerId: 1, + isPrimary: true, + clientX: 20, + clientY: 20, + }; + fireEvent.pointerDown(submit, touch); + fireEvent.pointerUp(submit, touch); + expect(onSubmit).toHaveBeenCalledOnce(); + + rerender( + , + ); + + const replacement = screen.getByRole("button", { + name: "Start voice input", + }); + expect(replacement).not.toBe(submit); + + fireEvent.click(replacement, { detail: 1 }); + + expect(start).not.toHaveBeenCalled(); + expect(onSubmit).toHaveBeenCalledOnce(); + } finally { + restoreMatchMedia(); + } + }); + + it("starts voice input once for a deliberate touch tap on the voice action", () => { + const restoreMatchMedia = mockPointerCoarse(true); + try { + const start = vi.fn(); + render( + , + ); + + const voiceButton = screen.getByRole("button", { + name: "Start voice input", + }); + const touch = { + button: 0, + pointerType: "touch", + pointerId: 1, + isPrimary: true, + }; + fireEvent.pointerDown(voiceButton, touch); + fireEvent.pointerUp(voiceButton, touch); + fireEvent.click(voiceButton, { detail: 1 }); + + expect(start).toHaveBeenCalledOnce(); + } finally { + restoreMatchMedia(); + } + }); + + it("starts voice input for keyboard activation without a pointer gesture", () => { + const restoreMatchMedia = mockPointerCoarse(true); + try { + const start = vi.fn(); + render( + , + ); + + fireEvent.click( + screen.getByRole("button", { name: "Start voice input" }), + { + detail: 0, + }, + ); + + expect(start).toHaveBeenCalledOnce(); + } finally { + restoreMatchMedia(); + } + }); + it("keeps an unfocused compact submit stable on coarse pointers", () => { const restoreMatchMedia = mockPointerCoarse(true); try { @@ -2266,6 +2457,118 @@ describe("PromptBoxInternal compact layout", () => { } }); + it("sends before dismissing the keyboard when a touch synthesizes mousedown", () => { + const restoreMatchMedia = mockPointerCoarse(true); + try { + const onSubmit = vi.fn(() => { + expect(document.activeElement).toBe(getPromptEditorElement()); + }); + render( + , + ); + + const editor = getPromptEditorElement(); + act(() => editor.focus()); + const submit = screen.getByRole("button", { name: "Submit (Enter)" }); + fireEvent.pointerDown(submit, { button: 0, pointerType: "touch" }); + fireEvent.pointerUp(submit, { button: 0, pointerType: "touch" }); + + const allowsFocusChange = fireEvent.mouseDown(submit, { button: 0 }); + if (allowsFocusChange) act(() => editor.blur()); + expect(allowsFocusChange).toBe(false); + fireEvent.mouseUp(submit, { button: 0 }); + fireEvent.click(submit, { detail: 1 }); + + expect(onSubmit).toHaveBeenCalledOnce(); + expect(document.activeElement).not.toBe(editor); + } finally { + restoreMatchMedia(); + } + }); + + it("submits on touch release without waiting for or duplicating the click", () => { + const onSubmit = vi.fn(); + render( + , + ); + const editor = getPromptEditorElement(); + act(() => editor.focus()); + const submit = screen.getByRole("button", { name: "Submit (Enter)" }); + vi.spyOn(submit, "getBoundingClientRect").mockReturnValue( + new DOMRect(0, 0, 40, 40), + ); + const touch = { + button: 0, + pointerType: "touch", + pointerId: 1, + isPrimary: true, + clientX: 20, + clientY: 20, + }; + + fireEvent.pointerDown(submit, touch); + expect(onSubmit).not.toHaveBeenCalled(); + expect(document.activeElement).toBe(editor); + fireEvent.pointerUp(submit, touch); + expect(onSubmit).toHaveBeenCalledOnce(); + expect(document.activeElement).not.toBe(editor); + fireEvent.click(submit, { detail: 1 }); + expect(onSubmit).toHaveBeenCalledOnce(); + + fireEvent.click(submit, { detail: 0 }); + expect(onSubmit).toHaveBeenCalledTimes(2); + fireEvent.pointerDown(submit, touch); + fireEvent.pointerUp(submit, touch); + expect(onSubmit).toHaveBeenCalledTimes(3); + }); + + it.each(["cancel", "drag", "outside"])( + "does not send after a touch %s", + (gesture) => { + const onSubmit = vi.fn(); + render( + , + ); + const submit = screen.getByRole("button", { name: "Submit (Enter)" }); + vi.spyOn(submit, "getBoundingClientRect").mockReturnValue( + new DOMRect(0, 0, 40, 40), + ); + const touch = { + button: 0, + pointerType: "touch", + pointerId: 1, + isPrimary: true, + clientX: 38, + clientY: 20, + }; + fireEvent.pointerDown(submit, touch); + if (gesture === "cancel") fireEvent.pointerCancel(submit, touch); + if (gesture === "drag") { + fireEvent.pointerMove(submit, { ...touch, clientX: 60 }); + } + fireEvent.pointerUp(submit, { + ...touch, + clientX: gesture === "outside" ? 42 : touch.clientX, + }); + fireEvent.click(submit, { detail: 1 }); + expect(onSubmit).not.toHaveBeenCalled(); + }, + ); + it("keeps the editor focused through a pointer submit", async () => { const onSubmit = vi.fn(); render( diff --git a/apps/app/src/components/promptbox/PromptBoxInternal.tsx b/apps/app/src/components/promptbox/PromptBoxInternal.tsx index fb63604377..59d1fa19dd 100644 --- a/apps/app/src/components/promptbox/PromptBoxInternal.tsx +++ b/apps/app/src/components/promptbox/PromptBoxInternal.tsx @@ -129,6 +129,10 @@ import { import { parsePromptMentionClipboardElement } from "./mentions/prompt-mention-clipboard"; import { ComposerEditorSlot } from "./ComposerEditorSlot"; import { QueuedEditorTypeaheadLayoutContext } from "./queued-editor-typeahead-layout"; +import { + isModifierSubmitKeyEvent, + modifierSubmitShortcutAria, +} from "./modifier-submit-shortcut"; const PROMPTBOX_MIN_HEIGHT = 68; const PROMPTBOX_SELECTION_REVEAL_MARGIN = 12; @@ -231,6 +235,7 @@ interface PromptSubmitButtonProps { isCompact: boolean; onClick: (event: ReactMouseEvent) => void; onPointerDown: (event: ReactPointerEvent) => void; + onTouchSubmit: () => void; title: string; } @@ -242,8 +247,13 @@ function PromptSubmitButton({ isCompact, onClick, onPointerDown, + onTouchSubmit, title, }: PromptSubmitButtonProps) { + const touchRef = useRef<{ pointerId: number; x: number; y: number } | null>( + null, + ); + const suppressTouchClickRef = useRef(false); const button = ( - Create thread in worktree + New thread in this environment ) : null}
diff --git a/apps/app/src/components/promptbox/banner/QueuedMessagesList.stories.tsx b/apps/app/src/components/promptbox/banner/QueuedMessagesList.stories.tsx index 5228b17e8b..87f9d304a0 100644 --- a/apps/app/src/components/promptbox/banner/QueuedMessagesList.stories.tsx +++ b/apps/app/src/components/promptbox/banner/QueuedMessagesList.stories.tsx @@ -1,6 +1,11 @@ import { useCallback, useState, type ReactNode } from "react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { threadsQueryKey } from "@/hooks/queries/query-keys"; import type { ThreadQueuedMessage } from "@bb/domain"; -import { makeThreadQueuedMessage } from "@bb/test-helpers/domain-fixtures"; +import { + makeThreadListEntry, + makeThreadQueuedMessage, +} from "@bb/test-helpers/domain-fixtures"; import { applyQueuedMessageReorder, type QueuedMessageReorderRequest, @@ -806,3 +811,45 @@ export function NarrowSurface() { ); } + +export function SenderMetadata() { + const [queryClient] = useState(() => { + const client = new QueryClient(); + client.setQueryData(threadsQueryKey(), [ + makeThreadListEntry({ id: "thr_review", title: "Code review" }), + ]); + return client; + }); + const messages = [ + makeQueuedMessage({ + id: "q_user", + text: "Please review the final changes.", + }), + makeQueuedMessage({ + id: "q_agent", + text: "The review is complete. All checks passed.", + initiator: "agent", + senderThreadId: "thr_review", + }), + makeQueuedMessage({ + id: "q_system", + text: "The background task has completed.", + initiator: "system", + waitingOn: { kind: "provisioning" }, + }), + ]; + return ( + + + + + + + + + + ); +} diff --git a/apps/app/src/components/promptbox/banner/QueuedMessagesList.test.tsx b/apps/app/src/components/promptbox/banner/QueuedMessagesList.test.tsx index 464a1c9ecd..bfac9c7907 100644 --- a/apps/app/src/components/promptbox/banner/QueuedMessagesList.test.tsx +++ b/apps/app/src/components/promptbox/banner/QueuedMessagesList.test.tsx @@ -8,10 +8,16 @@ import { waitFor, } from "@testing-library/react"; import { useContext, useLayoutEffect } from "react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { threadsQueryKey } from "@/hooks/queries/query-keys"; import { afterEach, describe, expect, it, vi } from "vitest"; import type { ThreadQueuedMessage } from "@bb/domain"; -import { makeThreadQueuedMessage } from "@bb/test-helpers/domain-fixtures"; +import { + makeThreadListEntry, + makeThreadQueuedMessage, +} from "@bb/test-helpers/domain-fixtures"; import type { Active, DroppableContainer } from "@dnd-kit/core"; +import { focusWithKeyboard } from "@/test/keyboard-focus"; import { QueuedMessagesList as QueuedMessagesListComponent, clampQueuedMessageDragTransform, @@ -158,6 +164,89 @@ afterEach(() => { }); describe("QueuedMessagesList", () => { + it("labels non-user senders and refreshes their names from the thread cache", async () => { + const queryClient = new QueryClient(); + const messages = [ + makeQueuedMessage("q_user", "User follow-up"), + makeThreadQueuedMessage({ + id: "q_agent", + initiator: "agent", + senderThreadId: "thr_sender", + }), + makeThreadQueuedMessage({ + id: "q_system", + initiator: "system", + waitingOn: { kind: "provisioning" }, + }), + ]; + const { container, getByText, getByRole } = render( + + + , + ); + expect( + container.querySelector( + '[data-queued-message-id="q_user"] [data-queued-message-sender]', + ), + ).toBeNull(); + expect( + getByText("thr_sender").closest("[data-queued-message-metadata]"), + ).not.toBeNull(); + expect( + getByText("System") + .closest("[data-queued-message-metadata]") + ?.querySelector("[data-queued-message-wait]"), + ).not.toBeNull(); + expect( + getByText("thr_sender").closest(".prompt-mention-pill"), + ).not.toBeNull(); + act(() => { + queryClient.setQueryData(threadsQueryKey(), [ + makeThreadListEntry({ id: "thr_sender", title: "Code review" }), + ]); + }); + await waitFor(() => expect(getByText("Code review")).toBeTruthy()); + fireEvent.keyDown( + getByRole("button", { name: "Drag up to open the queue workspace" }), + { key: "ArrowUp" }, + ); + expect(getByText("Code review")).toBeTruthy(); + expect(getByText("System")).toBeTruthy(); + queryClient.clear(); + }); + + it.each([ + { initiator: "system" as const, senderThreadId: null, height: "104px" }, + { + initiator: "agent" as const, + senderThreadId: "thr_sender", + height: "110px", + }, + ])( + "reserves the metadata height for $initiator senders", + ({ initiator, senderThreadId, height }) => { + const { container } = renderQueuedMessages([ + makeThreadQueuedMessage({ initiator, senderThreadId }), + ]); + expect( + container.querySelector( + 'section[aria-label="Queued messages"]', + )?.style.height, + ).toBe(height); + }, + ); + it("renders as a standalone card when it is not attached to the composer", () => { const { container } = renderQueuedMessages( [makeQueuedMessage("q_one", "First queued message")], @@ -390,7 +479,7 @@ describe("QueuedMessagesList", () => { [editButton, "Edit"], [deleteButton, "Delete"], ] as const) { - fireEvent.focus(button); + focusWithKeyboard(button); expect((await findByRole("tooltip")).textContent).toBe(label); fireEvent.blur(button); await waitFor(() => { diff --git a/apps/app/src/components/promptbox/banner/QueuedMessagesList.tsx b/apps/app/src/components/promptbox/banner/QueuedMessagesList.tsx index fb5a8d1cd6..87e383c140 100644 --- a/apps/app/src/components/promptbox/banner/QueuedMessagesList.tsx +++ b/apps/app/src/components/promptbox/banner/QueuedMessagesList.tsx @@ -14,6 +14,7 @@ import { import ReactMarkdown from "react-markdown"; import type { Components } from "react-markdown"; import remarkGfm from "remark-gfm"; +import { useSenderThreadMetadataById } from "@/hooks/useSenderThreadMetadataById"; import { useSecondTick } from "@/hooks/useSecondTick"; import { usePluginDisplayName } from "@/lib/plugin-logos"; import { @@ -88,7 +89,10 @@ import { type QueuedMessageReorderRequest, } from "@/lib/queued-message-reorder"; import type { PromptMentionLinkResolver } from "@/components/promptbox/editor/prompt-mention-link"; -import { shiftMentionsToTextRange } from "@/components/thread/timeline/ConversationMessageMentions"; +import { + PromptMentionPill, + shiftMentionsToTextRange, +} from "@/components/thread/timeline/ConversationMessageMentions"; import { buildPromptMentionComponent, remarkPromptMentions, @@ -147,6 +151,7 @@ interface QueuedMessagePreviewText { } interface QueuedMessageRowProps { + senderLabel: string | null; queuedMessage: ThreadQueuedMessage; resolveMentionLink?: PromptMentionLinkResolver; index: number; @@ -171,6 +176,7 @@ const DRAWER_CHROME_HEIGHT = 1 + 32 + 12 + 2; const DRAWER_LIST_PADDING = 8; const DRAWER_ROW_HEIGHT = 33; const DRAWER_SECOND_LINE_HEIGHT = 16; +const DRAWER_SENDER_PILL_LINE_HEIGHT = 22; const WORKSPACE_MIN_HEIGHT = 240; const WORKSPACE_MAX_HEIGHT = 360; const WORKSPACE_CHROME_HEIGHT = 56; @@ -195,9 +201,13 @@ function getDrawerHeight({ (total, queuedMessage) => total + DRAWER_ROW_HEIGHT + - (queuedMessageHasWaitLine(queuedMessage) || + (queuedMessage.initiator !== "user" || + queuedMessageHasWaitLine(queuedMessage) || queuedMessage.id === processingMessageId - ? DRAWER_SECOND_LINE_HEIGHT + ? queuedMessage.initiator === "agent" && + queuedMessage.senderThreadId !== null + ? DRAWER_SENDER_PILL_LINE_HEIGHT + : DRAWER_SECOND_LINE_HEIGHT : 0), 0, ); @@ -704,7 +714,7 @@ function QueuedMessageWaitLine({ data-queued-message-wait="" data-queued-message-failed={failed ? "" : undefined} className={cn( - "mt-0.5 flex min-w-0 items-center gap-1 text-2xs", + "flex min-w-0 items-center gap-1 text-2xs", failed ? "text-destructive-text" : "text-subtle-foreground", )} > @@ -723,7 +733,7 @@ function QueuedMessageProcessingLine({ label }: { label: string }) { return (
@@ -859,13 +870,53 @@ const QueuedMessageRow = memo(function QueuedMessageRow({ ) : null}
- {isProcessing ? ( - - ) : hasWaitLine ? ( - + {senderLabel !== null || isProcessing || hasWaitLine ? ( +
+ {senderLabel === null ? null : ( + + From + {queuedMessage.initiator === "agent" && + queuedMessage.senderThreadId !== null ? ( + + + + ) : ( + {senderLabel} + )} + + )} + {senderLabel !== null && (isProcessing || hasWaitLine) ? ( + + · + + ) : null} + {isProcessing ? ( + + ) : hasWaitLine ? ( + + ) : null} +
) : null}
{isProcessing ? null : ( @@ -1154,6 +1205,7 @@ export function QueuedMessagesList({ onEdit, onDelete, }: QueuedMessagesListProps) { + const senderThreadMetadataById = useSenderThreadMetadataById(); const processingLabel = processingAction === "edit" ? "Editing…" @@ -1639,6 +1691,17 @@ export function QueuedMessagesList({ @@ -751,11 +747,11 @@ export function Overview() { diff --git a/apps/app/src/components/promptbox/banner/ThreadPromptContextBanner.tsx b/apps/app/src/components/promptbox/banner/ThreadPromptContextBanner.tsx index 9e17b60bdd..e3ae308c5a 100644 --- a/apps/app/src/components/promptbox/banner/ThreadPromptContextBanner.tsx +++ b/apps/app/src/components/promptbox/banner/ThreadPromptContextBanner.tsx @@ -104,7 +104,7 @@ export interface ThreadPromptArchivedSection { } export interface ThreadPromptEnvironmentGoneSection { - status: Extract; + status: Extract; } const THREAD_BANNER_ACTIVE_CHILD_RUNTIME_STATUSES: ReadonlySet = @@ -153,10 +153,6 @@ const ENVIRONMENT_GONE_STATUS_COPY: Record< ThreadPromptEnvironmentGoneSection["status"], { ariaLabel: string; label: string } > = { - destroying: { - ariaLabel: "This environment is being archived.", - label: "Archiving environment...", - }, destroyed: { ariaLabel: "This environment has been archived.", label: "Environment archived", diff --git a/apps/app/src/components/promptbox/modifier-submit-shortcut.test.ts b/apps/app/src/components/promptbox/modifier-submit-shortcut.test.ts new file mode 100644 index 0000000000..f22baacc1c --- /dev/null +++ b/apps/app/src/components/promptbox/modifier-submit-shortcut.test.ts @@ -0,0 +1,52 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + isModifierSubmitKeyEvent, + modifierSubmitShortcutAria, + modifierSubmitShortcutLabel, +} from "./modifier-submit-shortcut"; + +function mockPlatform(platform: string): () => void { + const originalNavigator = globalThis.navigator; + vi.stubGlobal("navigator", { ...originalNavigator, platform }); + return () => vi.unstubAllGlobals(); +} + +describe("modifier submit shortcut", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("accepts Control+Enter and Meta+Enter without other modifiers", () => { + const base = { + key: "Enter", + metaKey: false, + ctrlKey: false, + altKey: false, + shiftKey: false, + }; + expect(isModifierSubmitKeyEvent({ ...base, ctrlKey: true })).toBe(true); + expect(isModifierSubmitKeyEvent({ ...base, metaKey: true })).toBe(true); + expect(isModifierSubmitKeyEvent(base)).toBe(false); + expect( + isModifierSubmitKeyEvent({ ...base, ctrlKey: true, shiftKey: true }), + ).toBe(false); + expect( + isModifierSubmitKeyEvent({ ...base, metaKey: true, altKey: true }), + ).toBe(false); + expect(isModifierSubmitKeyEvent({ ...base, key: "a", ctrlKey: true })).toBe( + false, + ); + }); + + it("presents the shortcut as Control on Windows and Linux", () => { + mockPlatform("Linux x86_64"); + expect(modifierSubmitShortcutLabel()).toBe("Ctrl + Enter"); + expect(modifierSubmitShortcutAria()).toBe("Control+Enter"); + }); + + it("presents the shortcut as Command on Mac", () => { + mockPlatform("MacIntel"); + expect(modifierSubmitShortcutLabel()).toBe("⌘ Enter"); + expect(modifierSubmitShortcutAria()).toBe("Meta+Enter"); + }); +}); diff --git a/apps/app/src/components/promptbox/modifier-submit-shortcut.ts b/apps/app/src/components/promptbox/modifier-submit-shortcut.ts new file mode 100644 index 0000000000..fcefc0417c --- /dev/null +++ b/apps/app/src/components/promptbox/modifier-submit-shortcut.ts @@ -0,0 +1,41 @@ +import type { AppShortcut } from "@bb/domain"; +import { + formatAppShortcut, + formatAppShortcutAria, +} from "@/lib/app-keybindings"; + +const MODIFIER_SUBMIT_SHORTCUT: AppShortcut = { + key: "Enter", + mod: true, + meta: false, + control: false, + alt: false, + shift: false, +}; + +function currentPlatform(): string { + return typeof navigator === "undefined" ? "" : navigator.platform; +} + +export function isModifierSubmitKeyEvent(event: { + key: string; + metaKey: boolean; + ctrlKey: boolean; + altKey: boolean; + shiftKey: boolean; +}): boolean { + return ( + event.key === "Enter" && + (event.metaKey || event.ctrlKey) && + !event.altKey && + !event.shiftKey + ); +} + +export function modifierSubmitShortcutLabel(): string { + return formatAppShortcut(MODIFIER_SUBMIT_SHORTCUT, currentPlatform()); +} + +export function modifierSubmitShortcutAria(): string { + return formatAppShortcutAria(MODIFIER_SUBMIT_SHORTCUT, currentPlatform()); +} diff --git a/apps/app/src/components/secondary-panel/CompactSecondaryPanelShelf.test.tsx b/apps/app/src/components/secondary-panel/CompactSecondaryPanelShelf.test.tsx index c9f70d59d5..837e1d13ed 100644 --- a/apps/app/src/components/secondary-panel/CompactSecondaryPanelShelf.test.tsx +++ b/apps/app/src/components/secondary-panel/CompactSecondaryPanelShelf.test.tsx @@ -200,6 +200,47 @@ describe("CompactSecondaryPanelShelf", () => { }, ); + it("clears only its selected text on close so reopening restores swiping", () => { + const onClose = vi.fn(); + const view = (open: boolean) => ( + <> +
Outside selection
+ +
Preview selection
+
+ + ); + const selectContents = (element: Element) => { + const range = document.createRange(); + range.selectNodeContents(element); + window.getSelection()?.removeAllRanges(); + window.getSelection()?.addRange(range); + }; + const { rerender } = render(view(true)); + + selectContents(screen.getByTestId("outside-selection")); + rerender(view(false)); + expect(window.getSelection()?.toString()).toBe("Outside selection"); + + rerender(view(true)); + const shelf = screen.getByTestId("secondary-panel-shelf"); + Object.defineProperty(shelf, "clientWidth", { value: 300 }); + selectContents(screen.getByTestId("panel-body")); + rerender(view(false)); + expect(window.getSelection()?.isCollapsed).toBe(true); + + rerender(view(true)); + fireTouch(shelf, "touchstart", createTouch(60, 160)); + fireTouch(window, "touchmove", createTouch(240, 164)); + fireTouch(window, "touchend", createTouch(240, 164)); + expect(onClose).toHaveBeenCalledTimes(1); + }); + it("ignores a closing swipe from the left browser edge", () => { const { onClose } = renderShelf(true); const shelf = screen.getByTestId("secondary-panel-shelf"); diff --git a/apps/app/src/components/secondary-panel/CompactSecondaryPanelShelf.tsx b/apps/app/src/components/secondary-panel/CompactSecondaryPanelShelf.tsx index 8e6fb0cad9..bede25f1d3 100644 --- a/apps/app/src/components/secondary-panel/CompactSecondaryPanelShelf.tsx +++ b/apps/app/src/components/secondary-panel/CompactSecondaryPanelShelf.tsx @@ -116,6 +116,20 @@ export function CompactSecondaryPanelShelf({ requestClose, }); + useLayoutEffect(() => { + if (open) return; + const panel = panelRef.current; + if (panel === null) return; + const selection = panel.ownerDocument.getSelection(); + if (selection === null || selection.isCollapsed) return; + if ( + (selection.anchorNode !== null && panel.contains(selection.anchorNode)) || + (selection.focusNode !== null && panel.contains(selection.focusNode)) + ) { + selection.removeAllRanges(); + } + }, [open]); + useEffect(() => { setCompactSecondaryPanelPresentation(state); return () => setCompactSecondaryPanelPresentation("closed"); diff --git a/apps/app/src/components/secondary-panel/FilePreview.tsx b/apps/app/src/components/secondary-panel/FilePreview.tsx index 4dab48ea57..73141d2a0b 100644 --- a/apps/app/src/components/secondary-panel/FilePreview.tsx +++ b/apps/app/src/components/secondary-panel/FilePreview.tsx @@ -19,6 +19,7 @@ import { useAppCommandShortcut } from "@/components/commands/AppCommandProvider" import { AppCommandShortcutHint } from "@/components/commands/AppCommandShortcutHint"; import type { MarkdownLinkRouting } from "@/components/ui/markdown-link-routing.js"; import { MarkdownPreview } from "@/components/ui/markdown-preview.js"; +import { ImageLightbox } from "@/components/ui/image-lightbox.js"; import { Tooltip, TooltipContent, @@ -1040,12 +1041,23 @@ function CsvFilePreview({ file, onSelectionAddToChat }: CsvFilePreviewProps) { } function FilePreviewImage({ url, alt }: FilePreviewImageProps) { + const [isLightboxOpen, setIsLightboxOpen] = useState(false); + return (
- {alt} setIsLightboxOpen(true)} + > + {alt} + + setIsLightboxOpen(false)} />
); diff --git a/apps/app/src/components/secondary-panel/GitDiffCard.stories.tsx b/apps/app/src/components/secondary-panel/GitDiffCard.stories.tsx index 7e61f1b266..1becb1042c 100644 --- a/apps/app/src/components/secondary-panel/GitDiffCard.stories.tsx +++ b/apps/app/src/components/secondary-panel/GitDiffCard.stories.tsx @@ -140,7 +140,7 @@ const THREAD_ROW_TSX = `import { memo, useMemo } from "react"; import { SidebarMenuButton, SidebarMenuItem } from "@/components/ui/sidebar.js"; import { Pill } from "@bb/shared-ui/pill"; import { cn } from "@bb/shared-ui/lib/utils"; -import { getEnvironmentWorkspaceDisplayIconName } from "@/lib/environment-workspace-display"; +import { getEnvironmentDisplayIconName } from "@/lib/environment-workspace-display"; import type { ThreadListEntry } from "@bb/server-contract"; export interface ThreadRowProps { @@ -183,9 +183,10 @@ function ThreadRowComponent({ const childBusyCount = parentOptions?.childBusyCount ?? 0; const isParentBusy = isParent && (threadIsBusy || childBusyCount > 0); - const environmentIcon = getEnvironmentWorkspaceDisplayIconName( - thread.environmentWorkspaceDisplayKind, - ); + const environmentIcon = getEnvironmentDisplayIconName({ + status: "loaded", + provider: null, + }); const titleText = useMemo( () => thread.title?.trim() || thread.titleFallback || "Untitled thread", [thread.title, thread.titleFallback], diff --git a/apps/app/src/components/secondary-panel/ThreadMetadataContent.fixtures.tsx b/apps/app/src/components/secondary-panel/ThreadMetadataContent.fixtures.tsx index 688bc51b5f..01261a59f7 100644 --- a/apps/app/src/components/secondary-panel/ThreadMetadataContent.fixtures.tsx +++ b/apps/app/src/components/secondary-panel/ThreadMetadataContent.fixtures.tsx @@ -82,6 +82,7 @@ export const baseProps: ThreadMetadataContentProps = { isLoadingParentThreads: false, isParentThreadsError: false, environment: makeEnvironment(), + environmentProvisioningFailure: false, environmentDisplayHost: localEnvironmentDisplayHost, workspaceStatus: makeWorkspaceStatus(), workspaceStatusError: null, diff --git a/apps/app/src/components/secondary-panel/ThreadMetadataContent.rows.stories.tsx b/apps/app/src/components/secondary-panel/ThreadMetadataContent.rows.stories.tsx index dfcdfcec2f..a43110373c 100644 --- a/apps/app/src/components/secondary-panel/ThreadMetadataContent.rows.stories.tsx +++ b/apps/app/src/components/secondary-panel/ThreadMetadataContent.rows.stories.tsx @@ -144,10 +144,7 @@ export function Environment() { @@ -156,10 +153,7 @@ export function Environment() { @@ -170,8 +164,6 @@ export function Environment() { thread={makeThread()} environment={makeEnvironment({ status: "provisioning", - isWorktree: false, - workspaceProvisionType: "managed-worktree", })} environmentDisplayHost={localEnvironmentDisplayHost} /> @@ -207,8 +199,6 @@ export function WorkspacePath() { @@ -218,8 +208,6 @@ export function WorkspacePath() { diff --git a/apps/app/src/components/secondary-panel/ThreadMetadataContent.test.tsx b/apps/app/src/components/secondary-panel/ThreadMetadataContent.test.tsx index a07a894d75..fbc614190b 100644 --- a/apps/app/src/components/secondary-panel/ThreadMetadataContent.test.tsx +++ b/apps/app/src/components/secondary-panel/ThreadMetadataContent.test.tsx @@ -10,15 +10,100 @@ import { import { renderToStaticMarkup } from "react-dom/server"; import { MemoryRouter } from "react-router-dom"; import type { Environment, Thread } from "@bb/domain"; +import type { EnvironmentDisplayHostContext } from "@bb/core-ui"; +import type { SystemEnvironmentProvider } from "@bb/server-contract"; +import { systemEnvironmentProvidersQueryKey } from "@/hooks/queries/environment-provider-queries"; import { TooltipProvider } from "@bb/shared-ui/tooltip"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import type { ReactNode } from "react"; import { afterEach, describe, expect, it, vi } from "vitest"; +import { focusWithKeyboard } from "@/test/keyboard-focus"; import { makeEnvironment, makeThread as makeThreadFixture, } from "@bb/test-helpers/domain-fixtures"; -import { EnvironmentRow, ThreadMetadataCard } from "./ThreadMetadataContent"; +import { + EnvironmentProvisioningFailureRow, + EnvironmentRow, + GitStatusRow, + ThreadMetadataCard, +} from "./ThreadMetadataContent"; const localHost = { locality: "local", identity: null } as const; +const connectedLocalHost: EnvironmentDisplayHostContext = { + locality: "local", + identity: { name: "Michael-M4", connected: true }, +}; + +function withQueryClient( + children: ReactNode, + registeredProviders?: readonly SystemEnvironmentProvider[], +): ReactNode { + const queryClient = new QueryClient(); + if (registeredProviders !== undefined) { + queryClient.setQueryData( + systemEnvironmentProvidersQueryKey({}), + registeredProviders, + ); + } + return ( + {children} + ); +} + +const worktreeProvider: SystemEnvironmentProvider = { + id: "git-worktree", + displayName: "Worktree", + icon: "GitBranch", + logoUrl: null, + pluginId: "environment-git-worktree", + acceptsEmptyInputs: true, + machineAvailability: {}, + availability: null, + requires: { + projectCheckout: true, + gitCheckout: true, + gitRemote: false, + projectless: false, + }, + inputs: null, +}; + +const modalProvider: SystemEnvironmentProvider = { + id: "modal-sandbox", + displayName: "Modal sandbox", + icon: "Cloud", + logoUrl: null, + pluginId: "environment-modal-sandbox", + acceptsEmptyInputs: true, + machineAvailability: {}, + availability: null, + requires: { + projectCheckout: false, + gitCheckout: false, + gitRemote: true, + projectless: false, + }, + inputs: null, +}; + +const personalProvider: SystemEnvironmentProvider = { + id: "personal-workspace", + displayName: "Personal workspace", + icon: "Folder", + logoUrl: null, + pluginId: "environment-personal-workspace", + acceptsEmptyInputs: true, + machineAvailability: {}, + availability: null, + requires: { + projectCheckout: false, + gitCheckout: false, + gitRemote: false, + projectless: true, + }, + inputs: null, +}; function makeThread(overrides: Partial = {}): Thread { return makeThreadFixture({ @@ -31,17 +116,24 @@ function makeThread(overrides: Partial = {}): Thread { }); } -function renderEnvironmentRow(environment: Environment): string { +function renderEnvironmentRow( + environment: Environment, + registeredProviders?: readonly SystemEnvironmentProvider[], + environmentDisplayHost: EnvironmentDisplayHostContext = localHost, +): string { return renderToStaticMarkup( - - - - - , + withQueryClient( + + + + + , + registeredProviders, + ), ); } @@ -78,56 +170,151 @@ describe("ThreadMetadataCard", () => { }); describe("EnvironmentRow", () => { - it("shows the create-thread action for a provisioned worktree", () => { + it("shows an unregistered provider id as not installed", () => { + const markup = renderEnvironmentRow( + makeEnvironment({ environmentProviderId: "retired-cloud" }), + [], + connectedLocalHost, + ); + + expect(markup).toContain("retired-cloud (not installed)"); + }); + + it("shows the create-thread action for a ready environment", () => { expect(renderEnvironmentRow(makeEnvironment())).toContain( - 'aria-label="Create thread in worktree"', + 'aria-label="New thread in this environment"', ); }); it("explains the create-thread action in a tooltip", async () => { render( - - - - - , + withQueryClient( + + + + + , + ), ); - fireEvent.focus( + focusWithKeyboard( screen.getByRole("button", { - name: "Create thread in worktree", + name: "New thread in this environment", }), ); expect((await screen.findByRole("tooltip")).textContent).toBe( - "Create thread in worktree", + "New thread in this environment", ); }); - it("hides the create-thread action while a managed worktree is provisioning", () => { + it("hides the create-thread action while an environment is provisioning", () => { const markup = renderEnvironmentRow( makeEnvironment({ status: "provisioning", path: null, - isWorktree: false, }), ); - expect(markup).not.toContain('aria-label="Create thread in worktree"'); + expect(markup).not.toContain('aria-label="New thread in this environment"'); }); - it("hides the create-thread action before a prepared worktree has a path", () => { + it("hides the create-thread action before an environment has a path", () => { const markup = renderEnvironmentRow( makeEnvironment({ path: null, - isWorktree: false, }), ); - expect(markup).not.toContain('aria-label="Create thread in worktree"'); + expect(markup).not.toContain('aria-label="New thread in this environment"'); + }); + + it("offers the create-thread action on a project's own checkout", () => { + const markup = renderEnvironmentRow( + makeEnvironment({ environmentProviderId: null }), + ); + + expect(markup).toContain('aria-label="New thread in this environment"'); + }); + + it("shows a custom provider label with its machine", () => { + const markup = renderEnvironmentRow( + makeEnvironment({ environmentProviderId: "modal-sandbox" }), + [modalProvider], + connectedLocalHost, + ); + + expect(markup).toContain("Modal sandbox"); + expect(markup).toContain("Michael-M4"); + }); + + it("shows a personal environment with the project folder icon and machine", () => { + const markup = renderEnvironmentRow( + makeEnvironment({ + environmentProviderId: "personal-workspace", + }), + [personalProvider], + connectedLocalHost, + ); + + expect(markup).toContain(">Personal workspace<"); + expect(markup).toContain("· Michael-M4"); + expect(markup).toContain('data-icon="Folder"'); + }); + + it("shows an explicit environment name before its machine", () => { + const markup = renderEnvironmentRow( + makeEnvironment({ name: "Design system polish" }), + [worktreeProvider], + connectedLocalHost, + ); + + expect(markup).toContain("Design system polish"); + expect(markup).toContain("· Michael-M4"); + expect(markup).not.toContain("· Worktree"); + }); + + it("shows no provider id while the registered provider list is still loading", () => { + const markup = renderEnvironmentRow( + makeEnvironment({ environmentProviderId: "modal-sandbox" }), + ); + + expect(markup).not.toContain("modal-sandbox"); + }); +}); + +describe("EnvironmentProvisioningFailureRow", () => { + it("shows a short provisioning status without the failure detail", () => { + const markup = renderToStaticMarkup( + , + ); + + expect(markup).toContain("Environment"); + expect(markup).toContain("Not created"); + expect(markup).toContain("provisioning failed"); + }); +}); + +describe("GitStatusRow", () => { + it("shows no live git status for an archived attached checkout", () => { + const markup = renderToStaticMarkup( + , + ); + + expect(markup).toBe(""); }); }); diff --git a/apps/app/src/components/secondary-panel/ThreadMetadataContent.tsx b/apps/app/src/components/secondary-panel/ThreadMetadataContent.tsx index 9688f57c26..22742a10ad 100644 --- a/apps/app/src/components/secondary-panel/ThreadMetadataContent.tsx +++ b/apps/app/src/components/secondary-panel/ThreadMetadataContent.tsx @@ -1,3 +1,4 @@ +import { EnvironmentProviderIcon } from "@/components/plugin/EnvironmentProviderIcon"; import { useCallback, useEffect, @@ -25,7 +26,11 @@ import { } from "@bb/core-ui"; import { cn } from "@bb/shared-ui/lib/utils"; import { copyToClipboardWithToast } from "@/lib/clipboard"; -import { getEnvironmentWorkspaceLabelIconName } from "@/lib/environment-workspace-display"; +import { + findEnvironmentDisplayProvider, + getEnvironmentWorkspaceInfoDisplay, +} from "@/lib/environment-workspace-display"; +import { useSystemEnvironmentProviders } from "@/hooks/queries/environment-provider-queries"; import { formatWorkspaceCheckoutDisplay } from "@/lib/workspace-checkout-display"; import { Button } from "@bb/shared-ui/button"; import { @@ -40,7 +45,7 @@ import { DetailRowIconLabel, } from "@/components/ui/detail-card.js"; import { CHROME_SECTION_LABEL_CLASS } from "@bb/shared-ui/chrome-style-tokens"; -import { useCreateThreadInWorktree } from "@/hooks/useCreateThreadInWorktree"; +import { useCreateThreadInEnvironment } from "@/hooks/useCreateThreadInEnvironment"; import { Icon } from "@bb/shared-ui/icon"; import { Tooltip, TooltipContent, TooltipTrigger } from "@bb/shared-ui/tooltip"; import { @@ -232,34 +237,53 @@ export function EnvironmentRow({ environment, environmentDisplayHost, }: EnvironmentRowProps) { - const createThreadInWorktree = useCreateThreadInWorktree({ + const createThreadInEnvironment = useCreateThreadInEnvironment({ projectId: thread.projectId, environmentId: environment?.id ?? "", }); + const { providers } = useSystemEnvironmentProviders(); if (!environment) return null; + const providerLookup = findEnvironmentDisplayProvider( + providers, + environment.environmentProviderId, + ); const display = formatEnvironmentDisplay({ environment, host: environmentDisplayHost, + providerLookup, + }); + const infoDisplay = getEnvironmentWorkspaceInfoDisplay({ + display, + providerLookup, + environmentName: environment.name, + hostName: environmentDisplayHost.identity?.name ?? null, }); - const showCreateThreadButton = isProvisionedWorktreeEnvironment(environment); + const showCreateThreadButton = isReusableEnvironment(environment); return ( - Environment - + providerLookup.status === "loaded" && + providerLookup.provider !== null ? ( + + + Environment + + ) : ( + + Environment + + ) } valueClassName="min-w-0" > - - {display.compactModeLabel} + + {infoDisplay.label} - {environmentDisplayHost.identity ? ( + {infoDisplay.machineName !== null && environmentDisplayHost.identity ? ( - · {environmentDisplayHost.identity.name} + · {infoDisplay.machineName} {environmentDisplayHost.identity.connected ? "" : " (offline)"} ) : null} @@ -277,14 +301,14 @@ export function EnvironmentRow({ - Create thread in worktree + New thread in this environment ) : null} @@ -292,23 +316,37 @@ export function EnvironmentRow({ ); } -interface WorkspacePathRowProps { - environment: Environment | null; -} - -function isWorktreeEnvironment(environment: Environment): boolean { +export function EnvironmentProvisioningFailureRow({ + failed, +}: { + failed: boolean; +}) { + if (!failed) return null; return ( - environment.isWorktree || - environment.workspaceProvisionType === "managed-worktree" + + Environment + + } + valueClassName="min-w-0" + > + + Not created + + · provisioning failed + + + ); } -function isProvisionedWorktreeEnvironment(environment: Environment): boolean { - return ( - environment.status === "ready" && - environment.path !== null && - isWorktreeEnvironment(environment) - ); +interface WorkspacePathRowProps { + environment: Environment | null; +} + +function isReusableEnvironment(environment: Environment): boolean { + return environment.status === "ready" && environment.path !== null; } export function WorkspacePathRow({ environment }: WorkspacePathRowProps) { @@ -802,6 +840,7 @@ export interface ThreadMetadataContentProps { isLoadingParentThreads: boolean; isParentThreadsError: boolean; environment: Environment | null; + environmentProvisioningFailure: boolean; environmentDisplayHost: EnvironmentDisplayHostContext; workspaceStatus: WorkspaceStatus | undefined; workspaceStatusError: Error | null; @@ -829,6 +868,7 @@ export function hasAnyThreadMetadata( thread, parentThreadDisplayName, environment, + environmentProvisioningFailure, workspaceStatus, workspaceStatusError, workspaceUnavailable, @@ -838,6 +878,7 @@ export function hasAnyThreadMetadata( | "thread" | "parentThreadDisplayName" | "environment" + | "environmentProvisioningFailure" | "workspaceStatus" | "workspaceStatusError" | "workspaceUnavailable" @@ -861,6 +902,7 @@ export function hasAnyThreadMetadata( return Boolean( parentThreadId || environment || + environmentProvisioningFailure || branchName || pullRequest || showWorkspaceStatus || @@ -926,6 +968,7 @@ export function ThreadMetadataContent(props: ThreadMetadataContentProps) { isLoadingParentThreads, isParentThreadsError, environment, + environmentProvisioningFailure, environmentDisplayHost, workspaceStatus, workspaceStatusError, @@ -971,6 +1014,9 @@ export function ThreadMetadataContent(props: ThreadMetadataContentProps) { environment={environment} environmentDisplayHost={environmentDisplayHost} /> + ({ + useEnvironment: () => ({ + data: { path: "/workspace", projectId: "proj_preview" }, + }), + useEnvironmentDiffFiles: vi.fn(), + useEnvironmentFilePreview: (_environmentId: string, path: string) => + previewQuery(path), +})); + +vi.mock("@/hooks/queries/project-queries", () => ({ + useProjectFilePreview: (_projectId: string, path: string) => + previewQuery(path), +})); + +vi.mock("@/hooks/queries/thread-queries", () => ({ + useThreadHostFilePreview: ( + _threadId: string, + _environmentId: string, + path: string, + ) => previewQuery(path), + useThreadStorageFilePreview: (_threadId: string, path: string) => + previewQuery(path), +})); + +vi.mock("@/hooks/queries/host-file-preview-query", () => ({ + useHostFilePreview: (_hostId: string, path: string) => + previewQuery(path, "/api/v1/file-previews/lease_preview/readme.md"), +})); + +afterEach(cleanup); + +function imageSrc(name: string): string | null { + return screen.getByRole("img", { name }).getAttribute("src"); +} + +describe("secondary-panel Markdown image routing", () => { + it("routes workspace images when the preview caller supplies no routing", () => { + render( + , + ); + + expect(imageSrc("absolute")).toBe( + "/api/v1/threads/thr_preview/host-files/content?path=%2Fworkspace%2Fgenerated.png", + ); + expect(imageSrc("relative")).toBe( + "/api/v1/threads/thr_preview/worktree/files/docs/images/chart.png", + ); + expect(imageSrc("escape")).toBe("../../outside.png"); + }); + + it("routes project images through the selected project source", () => { + render( + , + ); + + expect(imageSrc("absolute")).toContain( + "/api/v1/projects/proj_preview/files/content?", + ); + expect(imageSrc("absolute")).toContain("path=generated.png"); + expect(imageSrc("relative")).toContain("path=docs%2Fimages%2Fchart.png"); + expect(imageSrc("absolute")).toContain("hostId=host_preview"); + expect(imageSrc("escape")).toBe("../../outside.png"); + }); + + it("routes thread host-file images through the host content endpoint", () => { + render( + , + ); + + expect(imageSrc("absolute")).toBe( + "/api/v1/threads/thr_preview/host-files/content?path=%2Fworkspace%2Fgenerated.png", + ); + expect(imageSrc("relative")).toBe( + "/api/v1/threads/thr_preview/host-files/content?path=%2Fworkspace%2Fdocs%2Fimages%2Fchart.png", + ); + expect(imageSrc("escape")).toBe("../../outside.png"); + }); + + it("confines host-scoped relative images to the preview lease root", () => { + render( + , + ); + + expect(imageSrc("absolute")).toBe("/workspace/generated.png"); + expect(imageSrc("relative")).toBe( + "/api/v1/file-previews/lease_preview/images/chart.png", + ); + expect(imageSrc("escape")).toBe("../../outside.png"); + }); + + it("routes thread-storage images when the preview caller supplies no routing", () => { + render( + , + ); + + expect(imageSrc("absolute")).toBe( + "/api/v1/threads/thr_preview/host-files/content?path=%2Fworkspace%2Fgenerated.png", + ); + expect(imageSrc("relative")).toBe( + "/api/v1/threads/thr_preview/thread-storage/files/docs/images/chart.png", + ); + expect(imageSrc("escape")).toBe("../../outside.png"); + }); +}); diff --git a/apps/app/src/components/secondary-panel/ThreadSecondaryPanelTabContent.tsx b/apps/app/src/components/secondary-panel/ThreadSecondaryPanelTabContent.tsx index 090b9c85fe..ce2174534e 100644 --- a/apps/app/src/components/secondary-panel/ThreadSecondaryPanelTabContent.tsx +++ b/apps/app/src/components/secondary-panel/ThreadSecondaryPanelTabContent.tsx @@ -1,4 +1,4 @@ -import { useEffect } from "react"; +import { useEffect, useMemo } from "react"; import type { DiffPresentation } from "@/components/code/code-rendering"; import type { WorkspaceDiffTarget } from "@bb/domain"; import type { MarkdownLinkRouting } from "@/components/ui/markdown-link-routing.js"; @@ -6,6 +6,7 @@ import { Skeleton } from "@bb/shared-ui/skeleton"; import { EmptyStatePanel } from "@bb/shared-ui/empty-state"; import { useEnvironmentDiffFiles, + useEnvironment, useEnvironmentFilePreview, } from "@/hooks/queries/environment-queries"; import { useProjectFilePreview } from "@/hooks/queries/project-queries"; @@ -15,7 +16,10 @@ import { } from "@/hooks/queries/thread-queries"; import { useHostFilePreview } from "@/hooks/queries/host-file-preview-query"; import { + buildProjectFileContentUrl, buildRawFilesystemHtmlContentUrl, + buildThreadHostFileContentUrl, + buildThreadStorageRawContentUrl, buildThreadWorktreeRawContentUrl, } from "@/lib/file-content-urls"; import type { @@ -32,6 +36,11 @@ import { SecondaryPanelFilePreview, ThreadStorageFilePreview, } from "./ThreadStorageFilePreview"; +import { + buildMarkdownFileImageRouting, + buildMarkdownLeaseImageRouting, +} from "@/components/ui/markdown-file-image-routing"; +import { getAbsoluteDirname } from "@/lib/absolute-file-path"; const GIT_DIFF_SKELETON_FILE_COUNT = 3; const PANEL_SCROLL_SLOT_CLASS = @@ -72,9 +81,12 @@ interface ProjectFilePreviewTabContentProps { environmentId: string | null; hostId: string | null; lineRange: FilePreviewLineRange | null; + markdownLinkRouting?: MarkdownLinkRouting; onSelectionAddToChat?: (text: string) => void; onOpenInEditor?: (path: string) => void; projectId: string; + rootPath?: string | null; + threadId?: string | null; } interface HostFilePreviewTabContentProps { @@ -309,6 +321,13 @@ export function WorkspaceFilePreviewTabContent({ statusLabel, threadId, }: WorkspaceFilePreviewTabContentProps) { + const environmentQuery = useEnvironment(environmentId ?? null, { + enabled: + environmentId !== null && + environmentId !== undefined && + markdownLinkRouting?.localImage === undefined, + staleTime: 5_000, + }); const { data: workspaceFilePreview, error: workspaceFilePreviewError, @@ -318,6 +337,42 @@ export function WorkspaceFilePreviewTabContent({ } = useEnvironmentFilePreview(environmentId, activePath, source, { enabled: isPanelOpen, }); + const environmentRootPath = environmentQuery.data?.path ?? null; + const environmentProjectId = environmentQuery.data?.projectId; + const resolvedMarkdownLinkRouting = useMemo(() => { + if ( + source === null || + environmentId === null || + environmentId === undefined || + (!threadId && environmentProjectId === undefined) + ) { + return markdownLinkRouting; + } + return buildMarkdownFileImageRouting({ + path: activePath, + rootPath: environmentRootPath, + threadId: threadId ?? null, + linkRouting: markdownLinkRouting, + resolveRelativeSrc: (path) => { + if (threadId && source.kind === "working-tree") { + return buildThreadWorktreeRawContentUrl(threadId, path); + } + return environmentProjectId === undefined + ? path + : buildProjectFileContentUrl(environmentProjectId, path, { + environmentId, + }); + }, + }); + }, [ + activePath, + environmentId, + environmentProjectId, + environmentRootPath, + markdownLinkRouting, + source, + threadId, + ]); return ( void refetchWorkspaceFilePreview()} @@ -349,9 +404,12 @@ export function ProjectFilePreviewTabContent({ hostId, isPanelOpen, lineRange, + markdownLinkRouting, onSelectionAddToChat, onOpenInEditor, projectId, + rootPath = null, + threadId = null, }: ProjectFilePreviewTabContentProps) { const { data: projectFilePreview, @@ -365,6 +423,30 @@ export function ProjectFilePreviewTabContent({ { environmentId, hostId }, { enabled: isPanelOpen }, ); + const resolvedMarkdownLinkRouting = useMemo(() => { + return buildMarkdownFileImageRouting({ + path: activePath, + rootPath, + threadId, + linkRouting: markdownLinkRouting, + resolveRelativeSrc: (path) => + buildProjectFileContentUrl(projectId, path, { + ...(environmentId !== null + ? { environmentId } + : hostId !== null + ? { hostId } + : {}), + }), + }); + }, [ + activePath, + environmentId, + hostId, + markdownLinkRouting, + projectId, + rootPath, + threadId, + ]); return ( void refetchProjectFilePreview()} @@ -403,6 +486,18 @@ export function HostFilePreviewTabContent({ } = useThreadHostFilePreview(threadId, environmentId, activePath, { enabled: isPanelOpen, }); + const resolvedMarkdownLinkRouting = useMemo(() => { + return buildMarkdownFileImageRouting({ + path: activePath, + rootPath: + markdownLinkRouting?.localFile?.relativeLinks?.rootPath ?? + getAbsoluteDirname({ path: activePath }), + threadId, + linkRouting: markdownLinkRouting, + resolveRelativeSrc: (_relativePath, path) => + buildThreadHostFileContentUrl(threadId, path), + }); + }, [activePath, markdownLinkRouting, threadId]); return ( void refetchHostFilePreview()} @@ -437,6 +532,13 @@ export function HostScopedFilePreviewTabContent({ isLoading, refetch, } = useHostFilePreview(hostId, activePath, { enabled: isPanelOpen }); + const markdownLinkRouting = useMemo(() => { + return buildMarkdownLeaseImageRouting({ + path: activePath, + rootPath: getAbsoluteDirname({ path: activePath }), + previewUrl: hostFilePreview?.url, + }); + }, [activePath, hostFilePreview?.url]); return ( void refetch()} statusLabel={null} @@ -473,6 +576,16 @@ export function ThreadStorageFilePreviewTabContent({ } = useThreadStorageFilePreview(threadId, activePath, { enabled: isPanelOpen, }); + const resolvedMarkdownLinkRouting = useMemo(() => { + return buildMarkdownFileImageRouting({ + path: activePath, + rootPath: null, + threadId, + linkRouting: markdownLinkRouting, + resolveRelativeSrc: (path) => + buildThreadStorageRawContentUrl(threadId, path), + }); + }, [activePath, markdownLinkRouting, threadId]); return ( void refetchThreadStorageFilePreview()} diff --git a/apps/app/src/components/secondary-panel/git-diff/DiffFilesPanel.tsx b/apps/app/src/components/secondary-panel/git-diff/DiffFilesPanel.tsx index 16f8ebc234..87be2f8cc1 100644 --- a/apps/app/src/components/secondary-panel/git-diff/DiffFilesPanel.tsx +++ b/apps/app/src/components/secondary-panel/git-diff/DiffFilesPanel.tsx @@ -61,8 +61,14 @@ export function DiffFilesPanel({ onSelectionAddToChat, }: DiffFilesPanelProps) { const scrollRef = useRef(null); - const { requestPaths, getPatchState, retry, loadPath, seedInitialPatches } = - useEnvironmentDiffPatches(environmentId, { target }); + const { + requestPaths, + getPatchState, + retry, + loadPath, + seedInitialPatches, + prunePaths, + } = useEnvironmentDiffPatches(environmentId, { target }); useEffect(() => { if (initialPatches.length > 0) { @@ -70,6 +76,10 @@ export function DiffFilesPanel({ } }, [seedInitialPatches, initialPatches, filesUpdatedAt]); + useEffect(() => { + prunePaths(files.map((file) => file.path)); + }, [files, prunePaths]); + const virtualizer = useVirtualizer({ count: files.length, getScrollElement: () => scrollRef.current, diff --git a/apps/app/src/components/secondary-panel/git-diff/useEnvironmentMergeBase.ts b/apps/app/src/components/secondary-panel/git-diff/useEnvironmentMergeBase.ts index 40a01787d6..52e8dd8077 100644 --- a/apps/app/src/components/secondary-panel/git-diff/useEnvironmentMergeBase.ts +++ b/apps/app/src/components/secondary-panel/git-diff/useEnvironmentMergeBase.ts @@ -11,7 +11,7 @@ import { parseLifecycleError, type LifecycleErrorDescription, } from "@/lib/lifecycle-errors"; -import { getMutationErrorMessage } from "@/lib/mutation-errors"; +import { showMutationErrorToast } from "@/lib/mutation-errors"; import { useUpdateEnvironment } from "../../../hooks/mutations/environment-mutations"; interface UseEnvironmentMergeBaseParams { @@ -231,13 +231,11 @@ export function useEnvironmentMergeBase({ return; } - appToast.error( - getMutationErrorMessage({ - error, - fallbackMessage: "Failed to update merge base branch", - lifecycleOperation: "update_merge_base", - }), - ); + showMutationErrorToast({ + error, + fallbackMessage: "Failed to update merge base branch", + lifecycleOperation: "update_merge_base", + }); }, }, ); diff --git a/apps/app/src/components/secondary-panel/gitDiffTabEligibility.test.ts b/apps/app/src/components/secondary-panel/gitDiffTabEligibility.test.ts index acd1e3bac6..a90778c2e8 100644 --- a/apps/app/src/components/secondary-panel/gitDiffTabEligibility.test.ts +++ b/apps/app/src/components/secondary-panel/gitDiffTabEligibility.test.ts @@ -8,7 +8,9 @@ describe("resolveGitDiffTabStatus", () => { environmentId: null, environmentIsGitRepo: undefined, environmentLoadFailed: false, + environmentOwnsPath: undefined, hasResolvedThread: false, + threadArchived: false, }), ).toBe("loading"); expect( @@ -16,7 +18,9 @@ describe("resolveGitDiffTabStatus", () => { environmentId: "env-1", environmentIsGitRepo: undefined, environmentLoadFailed: false, + environmentOwnsPath: undefined, hasResolvedThread: true, + threadArchived: false, }), ).toBe("loading"); }); @@ -27,7 +31,9 @@ describe("resolveGitDiffTabStatus", () => { environmentId: null, environmentIsGitRepo: undefined, environmentLoadFailed: false, + environmentOwnsPath: undefined, hasResolvedThread: true, + threadArchived: false, }), ).toBe("ineligible"); expect( @@ -35,7 +41,9 @@ describe("resolveGitDiffTabStatus", () => { environmentId: "env-1", environmentIsGitRepo: false, environmentLoadFailed: false, + environmentOwnsPath: true, hasResolvedThread: true, + threadArchived: false, }), ).toBe("ineligible"); }); @@ -46,8 +54,23 @@ describe("resolveGitDiffTabStatus", () => { environmentId: "env-1", environmentIsGitRepo: undefined, environmentLoadFailed: true, + environmentOwnsPath: undefined, hasResolvedThread: true, + threadArchived: false, }), ).toBe("error"); }); + + it("hides git surfaces for an archived checkout whose provider does not own its path", () => { + expect( + resolveGitDiffTabStatus({ + environmentId: "env-checkout", + environmentIsGitRepo: true, + environmentLoadFailed: false, + environmentOwnsPath: false, + hasResolvedThread: true, + threadArchived: true, + }), + ).toBe("ineligible"); + }); }); diff --git a/apps/app/src/components/secondary-panel/gitDiffTabEligibility.ts b/apps/app/src/components/secondary-panel/gitDiffTabEligibility.ts index 2abf5eb0f6..a9b5b6fde4 100644 --- a/apps/app/src/components/secondary-panel/gitDiffTabEligibility.ts +++ b/apps/app/src/components/secondary-panel/gitDiffTabEligibility.ts @@ -4,15 +4,20 @@ export function resolveGitDiffTabStatus({ environmentId, environmentIsGitRepo, environmentLoadFailed, + environmentOwnsPath, hasResolvedThread, + threadArchived, }: { environmentId: string | null; environmentIsGitRepo: boolean | undefined; environmentLoadFailed: boolean; + environmentOwnsPath: boolean | undefined; hasResolvedThread: boolean; + threadArchived: boolean; }): GitDiffTabStatus { if (!hasResolvedThread) return "loading"; if (environmentId === null) return "ineligible"; + if (threadArchived && environmentOwnsPath === false) return "ineligible"; if (environmentIsGitRepo === true) return "eligible"; if (environmentIsGitRepo === false) return "ineligible"; return environmentLoadFailed ? "error" : "loading"; diff --git a/apps/app/src/components/settings/BrowserImportDialog.tsx b/apps/app/src/components/settings/BrowserImportDialog.tsx new file mode 100644 index 0000000000..95bac41423 --- /dev/null +++ b/apps/app/src/components/settings/BrowserImportDialog.tsx @@ -0,0 +1,290 @@ +import { useEffect, useRef, useState } from "react"; +import type { BbDesktopBrowserApi } from "@bb/desktop-contract"; +import { + DESKTOP_BROWSER_IMPORT_FAILURE_COPY, + isRetryableDesktopBrowserImportReason, + type DesktopBrowserImportOutcome, + type DesktopBrowserImportSource, +} from "@bb/host-daemon-contract"; +import { Button } from "@bb/shared-ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@bb/shared-ui/dialog"; +import { cn } from "@bb/shared-ui/lib/utils"; +import { BrowserSourceIcon } from "./BrowserSourceIcon"; +import { + canCloseDialog, + failedDialogStep, + formatCookieCount, + initialDialogStep, + preferredSourceProfileDirectory, + refreshedDialogStep, + type BrowserImportDialogStep, +} from "./browser-import-wizard"; + +export interface BrowserImportDialogProps { + source: DesktopBrowserImportSource; + desktopBrowser: BbDesktopBrowserApi; + onClose: () => void; + onImported: ( + source: DesktopBrowserImportSource, + profileName: string, + outcome: DesktopBrowserImportOutcome & { ok: true }, + ) => void; +} + +const TILE_CLASS = + "flex w-full items-center gap-2.5 rounded-md border px-3 py-2 text-left text-sm transition-colors hover:bg-state-hover"; +const TILE_SELECTED_CLASS = + "border-surface-selected-border bg-surface-selected"; +const TILE_IDLE_CLASS = "border-border"; + +export function BrowserImportDialog({ + source: initialSource, + desktopBrowser, + onClose, + onImported, +}: BrowserImportDialogProps) { + const [source, setSource] = useState(initialSource); + const [step, setStep] = useState(() => + initialDialogStep(initialSource), + ); + const [sourceProfileDirectory, setSourceProfileDirectory] = useState< + string | null + >(() => preferredSourceProfileDirectory(null, initialSource)); + const mounted = useRef(true); + useEffect(() => { + mounted.current = true; + return () => { + mounted.current = false; + }; + }, []); + + const selectedProfile = source.profiles.find( + (profile) => profile.directory === sourceProfileDirectory, + ); + + const runImport = () => { + if ( + sourceProfileDirectory === null || + selectedProfile === undefined || + !desktopBrowser.importCookies + ) { + setStep({ step: "blocked", reason: "unknownSourceProfile" }); + return; + } + const profileName = selectedProfile.name; + setStep({ step: "importing" }); + desktopBrowser + .importCookies({ + sourceId: source.id, + sourceProfileDirectory, + profile: { kind: "personal" }, + }) + .then((outcome) => { + if (!mounted.current) return; + if (outcome.ok) { + onImported(source, profileName, outcome); + onClose(); + return; + } + setStep(failedDialogStep(outcome.reason)); + }) + .catch(() => { + if (mounted.current) setStep({ step: "blocked", reason: "readFailed" }); + }); + }; + + const recheck = () => { + if (!desktopBrowser.listImportSources) return; + const previous = step; + setStep({ step: "checking" }); + desktopBrowser + .listImportSources() + .then((result) => { + if (!mounted.current) return; + const refreshed = result.sources.find( + (candidate) => candidate.id === source.id, + ); + if (refreshed) { + setSource(refreshed); + setSourceProfileDirectory((current) => + preferredSourceProfileDirectory(current, refreshed), + ); + } + setStep(refreshedDialogStep(refreshed, previous)); + }) + .catch(() => { + if (mounted.current) setStep({ step: "blocked", reason: "readFailed" }); + }); + }; + + const closable = canCloseDialog(step); + const title = (text: string) => ( + + + {text} + + ); + + return ( + { + if (!open && closable) onClose(); + }} + > + { + if (!closable) event.preventDefault(); + }} + onInteractOutside={(event) => { + if (!closable) event.preventDefault(); + }} + > + {step.step === "fullDiskAccess" ? ( + <> + + {title(`Allow Full Disk Access for ${source.name}`)} + + {source.name} keeps its cookies in a protected folder. Turn on + Full Disk Access for BB in System Settings → Privacy & + Security, then come back. You can turn it off again after the + import. + + + {step.checked ? ( +

+ Full Disk Access is still off. macOS may require quitting and + reopening BB before the grant applies. +

+ ) : null} + + + {desktopBrowser.openFullDiskAccessSettings ? ( + + ) : null} + + + + ) : step.step === "checking" ? ( + + {title(`Checking ${source.name}…`)} + This only takes a moment. + + ) : step.step === "importing" ? ( + + {title(`Importing from ${source.name}…`)} + + Reading and decrypting cookies. macOS may ask for Keychain access. + + + ) : step.step === "blocked" ? ( + <> + + {title(`Can't import from ${source.name}`)} + + {DESKTOP_BROWSER_IMPORT_FAILURE_COPY[step.reason]} + + + + + {isRetryableDesktopBrowserImportReason(step.reason) ? ( + + ) : null} + + + ) : ( + <> + + {title(`Import from ${source.name}`)} + + Which profile's cookies should be copied into the BB browser? + + +
+ {source.profiles.map((profile) => { + const selected = profile.directory === sourceProfileDirectory; + return ( + + ); + })} +
+

+ {source.name} must stay closed during the import. macOS may ask + for Keychain access to its encryption key; choose Allow. +

+ + + + + + )} +
+
+ ); +} diff --git a/apps/app/src/components/settings/BrowserSettingsSection.test.tsx b/apps/app/src/components/settings/BrowserSettingsSection.test.tsx new file mode 100644 index 0000000000..798571aae6 --- /dev/null +++ b/apps/app/src/components/settings/BrowserSettingsSection.test.tsx @@ -0,0 +1,181 @@ +// @vitest-environment jsdom + +import { + cleanup, + fireEvent, + render, + screen, + waitFor, +} from "@testing-library/react"; +import type { BbDesktopBrowserApi } from "@bb/desktop-contract"; +import type { DesktopBrowserImportSource } from "@bb/host-daemon-contract"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { BrowserSettingsSectionContent } from "./BrowserSettingsSection"; +import { BROWSER_IMPORT_RECORDS_STORAGE_KEY } from "./browser-import-wizard"; + +vi.mock("@/components/ui/app-toast", () => ({ + appToast: { success: vi.fn(), error: vi.fn() }, +})); + +const PNG = "data:image/png;base64,iVBORw0KGgo="; + +const sources: DesktopBrowserImportSource[] = [ + { + id: "chrome", + name: "Google Chrome", + icon: PNG, + profiles: [ + { directory: "Default", name: "Person 1", cookieCount: 3 }, + { directory: "Profile 1", name: "Work", cookieCount: 1 }, + ], + }, + { + id: "firefox", + name: "Firefox", + profiles: [{ directory: "Profiles/p1", name: "default", cookieCount: 9 }], + }, + { id: "brave", name: "Brave", profiles: [], unavailable: "browserRunning" }, + { id: "arc", name: "Arc", profiles: [], unavailable: "notInstalled" }, + { + id: "safari", + name: "Safari", + profiles: [], + unavailable: "unsupportedPlatform", + }, +]; + +function makeDesktopBrowser( + overrides: Partial = {}, +): BbDesktopBrowserApi { + const noop = () => undefined; + return { + attach: noop, + detach: noop, + navigate: noop, + goBack: noop, + goForward: noop, + reload: noop, + stop: noop, + setBounds: noop, + setVisible: noop, + onState: () => noop, + onOpenTab: () => noop, + listImportSources: vi.fn(async () => ({ sources })), + importCookies: vi.fn(async () => ({ + ok: true as const, + imported: 2, + skipped: 1, + skippedDomains: ["accounts.example.com"], + })), + ...overrides, + }; +} + +function rowButton(id: string): HTMLButtonElement { + const button = screen + .getByTestId(`browser-import-${id}`) + .querySelector("button"); + if (!button) throw new Error(`no button in row ${id}`); + return button; +} + +afterEach(() => { + cleanup(); + window.localStorage.clear(); +}); + +describe("BrowserSettingsSectionContent", () => { + it("explains that import is desktop only outside the desktop app", () => { + render(); + expect( + screen.getByText("Only available in the BB desktop app."), + ).toBeDefined(); + }); + + it("lists installed browsers with icons and status, hiding absent ones", async () => { + render( + , + ); + await waitFor(() => + expect(screen.getByText("Google Chrome")).toBeDefined(), + ); + expect(screen.queryByText("Arc")).toBeNull(); + expect(screen.queryByText("Safari")).toBeNull(); + expect( + screen + .getByTestId("browser-import-chrome") + .querySelector("img") + ?.getAttribute("src"), + ).toBe(PNG); + expect(screen.getByText("2 profiles")).toBeDefined(); + expect(screen.getByText("4 cookies")).toBeDefined(); + expect(screen.getByText("Running · quit Brave to import")).toBeDefined(); + expect(rowButton("brave").textContent).toBe("Recheck"); + }); + + it("imports a single-profile browser directly and records it in the row", async () => { + const desktopBrowser = makeDesktopBrowser(); + render(); + await waitFor(() => expect(screen.getByText("Firefox")).toBeDefined()); + fireEvent.click(rowButton("firefox")); + await waitFor(() => + expect(screen.getByText("default · 2 cookies imported")).toBeDefined(), + ); + expect(desktopBrowser.importCookies).toHaveBeenCalledWith({ + sourceId: "firefox", + sourceProfileDirectory: "Profiles/p1", + profile: { kind: "personal" }, + }); + expect(screen.getByText("1 skipped (accounts.example.com)")).toBeDefined(); + expect(screen.getByText("imported just now")).toBeDefined(); + expect( + JSON.parse( + window.localStorage.getItem(BROWSER_IMPORT_RECORDS_STORAGE_KEY) ?? "{}", + ).firefox.imported, + ).toBe(2); + }); + + it("asks for a profile when a browser has several, then imports it", async () => { + const desktopBrowser = makeDesktopBrowser(); + render(); + await waitFor(() => + expect(screen.getByText("Google Chrome")).toBeDefined(), + ); + fireEvent.click(rowButton("chrome")); + expect(screen.getByText("Import from Google Chrome")).toBeDefined(); + fireEvent.click(screen.getByRole("radio", { name: /Work/ })); + fireEvent.click(screen.getByRole("button", { name: "Import 1 cookie" })); + await waitFor(() => + expect(screen.getByText("Work · 2 cookies imported")).toBeDefined(), + ); + expect(screen.queryByText("Import from Google Chrome")).toBeNull(); + }); + + it("rechecks a running browser from its row", async () => { + const listImportSources = vi + .fn() + .mockResolvedValueOnce({ sources }) + .mockResolvedValue({ + sources: sources.map((source) => + source.id === "brave" + ? { + ...source, + unavailable: undefined, + profiles: [ + { directory: "Default", name: "Default", cookieCount: 5 }, + ], + } + : source, + ), + }); + render( + , + ); + await waitFor(() => expect(screen.getByText("Brave")).toBeDefined()); + fireEvent.click(rowButton("brave")); + await waitFor(() => expect(rowButton("brave").textContent).toBe("Import…")); + expect(screen.getByText("5 cookies")).toBeDefined(); + }); +}); diff --git a/apps/app/src/components/settings/BrowserSettingsSection.tsx b/apps/app/src/components/settings/BrowserSettingsSection.tsx new file mode 100644 index 0000000000..ff8a7e4c98 --- /dev/null +++ b/apps/app/src/components/settings/BrowserSettingsSection.tsx @@ -0,0 +1,309 @@ +import { useCallback, useEffect, useState } from "react"; +import type { BbDesktopBrowserApi } from "@bb/desktop-contract"; +import { + DESKTOP_BROWSER_IMPORT_FAILURE_COPY, + type DesktopBrowserImportOutcome, + type DesktopBrowserImportSource, +} from "@bb/host-daemon-contract"; +import { Button } from "@bb/shared-ui/button"; +import { cn } from "@bb/shared-ui/lib/utils"; +import { appToast } from "@/components/ui/app-toast"; +import { + SettingsBadge, + SettingsRow, + SettingsRowList, + SettingsSection, +} from "@/components/ui/settings-section"; +import { getDesktopBrowserApi } from "@/lib/bb-desktop"; +import { formatRelativeTime } from "@/lib/relative-time"; +import { BrowserImportDialog } from "./BrowserImportDialog"; +import { BrowserSourceIcon } from "./BrowserSourceIcon"; +import { + BROWSER_IMPORT_RECORDS_STORAGE_KEY, + formatCookieCount, + listedSources, + needsProfileChoice, + presentSourceRow, + readBrowserImportRecords, + recordFromOutcome, + sourceAfterFailure, + type BrowserImportRecords, + type SourceRowTone, +} from "./browser-import-wizard"; + +type SourcesState = + | { status: "loading" } + | { status: "error"; message: string } + | { status: "ready"; sources: DesktopBrowserImportSource[] }; + +const DOT_TONE_CLASS: Record = { + ready: "bg-success", + attention: "bg-warning", + idle: "border border-muted-foreground", +}; + +function localStorageOrNull(): Storage | null { + try { + return typeof window === "undefined" ? null : window.localStorage; + } catch { + return null; + } +} + +interface BrowserRowProps { + source: DesktopBrowserImportSource; + record: BrowserImportRecords[DesktopBrowserImportSource["id"]]; + now: number; + pending: boolean; + onImport: () => void; + onRecheck: () => void; +} + +function BrowserRow({ + source, + record, + now, + pending, + onImport, + onRecheck, +}: BrowserRowProps) { + const presentation = presentSourceRow(source, record); + const actionable = presentation.action !== "none"; + return ( + +
+ +
+
+ + {source.name} + + {record ? ( + + imported {formatRelativeTime({ timestamp: record.at, now })} + + ) : null} +
+
+ + + {presentation.status} + + {presentation.details.map((detail) => ( + + {detail} + + ))} +
+
+
+ +
+
+
+ ); +} + +export interface BrowserSettingsSectionContentProps { + desktopBrowser: BbDesktopBrowserApi | null; +} + +export function BrowserSettingsSectionContent({ + desktopBrowser, +}: BrowserSettingsSectionContentProps) { + const supported = desktopBrowser?.listImportSources !== undefined; + const [state, setState] = useState({ status: "loading" }); + const [records, setRecords] = useState(() => + readBrowserImportRecords(localStorageOrNull()), + ); + const [dialogSource, setDialogSource] = + useState(null); + const [pendingSourceIds, setPendingSourceIds] = useState>( + () => new Set(), + ); + const anyPending = pendingSourceIds.size > 0; + + const refresh = useCallback(() => { + if (!desktopBrowser?.listImportSources) return; + setState((current) => + current.status === "ready" ? current : { status: "loading" }, + ); + desktopBrowser + .listImportSources() + .then((result) => setState({ status: "ready", sources: result.sources })) + .catch((error: unknown) => + setState({ + status: "error", + message: + error instanceof Error + ? error.message + : "Could not check installed browsers.", + }), + ); + }, [desktopBrowser]); + + useEffect(() => { + refresh(); + }, [refresh]); + + const recordImport = ( + source: DesktopBrowserImportSource, + profileName: string, + outcome: DesktopBrowserImportOutcome & { ok: true }, + ) => { + const record = recordFromOutcome(outcome, profileName, Date.now()); + setRecords((current) => { + const next: BrowserImportRecords = { ...current, [source.id]: record }; + try { + localStorageOrNull()?.setItem( + BROWSER_IMPORT_RECORDS_STORAGE_KEY, + JSON.stringify(next), + ); + } catch { + return next; + } + return next; + }); + appToast.success( + outcome.imported > 0 + ? `Imported ${formatCookieCount(outcome.imported)} from ${source.name}` + : `No cookies were imported from ${source.name}`, + ); + }; + + const importDirectly = (source: DesktopBrowserImportSource) => { + const profile = source.profiles[0]; + if (!profile || !desktopBrowser?.importCookies) { + setDialogSource(source); + return; + } + setPendingSourceIds((current) => new Set(current).add(source.id)); + desktopBrowser + .importCookies({ + sourceId: source.id, + sourceProfileDirectory: profile.directory, + profile: { kind: "personal" }, + }) + .then((outcome) => { + if (outcome.ok) { + recordImport(source, profile.name, outcome); + return; + } + const blocked = sourceAfterFailure(source, outcome.reason); + if (blocked) setDialogSource(blocked); + else + appToast.error( + `${source.name}: ${DESKTOP_BROWSER_IMPORT_FAILURE_COPY[outcome.reason]}`, + ); + }) + .catch(() => { + appToast.error(`Could not read ${source.name}'s cookies.`); + }) + .finally(() => { + setPendingSourceIds((current) => { + const next = new Set(current); + next.delete(source.id); + return next; + }); + refresh(); + }); + }; + + const listed = state.status === "ready" ? listedSources(state.sources) : []; + const now = Date.now(); + + return ( + <> + + Refresh + + ) : undefined + } + > + {!supported ? ( +

+ Only available in the BB desktop app. +

+ ) : state.status === "loading" ? ( +

Loading…

+ ) : state.status === "error" ? ( +

{state.message}

+ ) : listed.length === 0 ? ( +

+ No supported browsers were found on this machine. +

+ ) : ( + + {listed.map((source) => ( + { + if (needsProfileChoice(source)) setDialogSource(source); + else if (source.unavailable === undefined) + importDirectly(source); + else setDialogSource(source); + }} + /> + ))} + + )} +
+ {supported ? ( +

+ Also from the CLI: bb browser import-sources and{" "} + bb browser import-cookies. +

+ ) : null} + {dialogSource && desktopBrowser ? ( + { + setDialogSource(null); + refresh(); + }} + onImported={recordImport} + /> + ) : null} + + ); +} + +export function BrowserSettingsSection() { + const [desktopBrowser] = useState(getDesktopBrowserApi); + return ; +} diff --git a/apps/app/src/components/settings/BrowserSourceIcon.tsx b/apps/app/src/components/settings/BrowserSourceIcon.tsx new file mode 100644 index 0000000000..edd2e86bc9 --- /dev/null +++ b/apps/app/src/components/settings/BrowserSourceIcon.tsx @@ -0,0 +1,34 @@ +import type { DesktopBrowserImportSource } from "@bb/host-daemon-contract"; +import { Icon } from "@bb/shared-ui/icon"; +import { cn } from "@bb/shared-ui/lib/utils"; + +export function BrowserSourceIcon({ + source, + className, +}: { + source: Pick; + className?: string; +}) { + if (source.icon) { + return ( + + ); + } + return ( + + + + ); +} diff --git a/apps/app/src/components/settings/CommunitySettingsSection.tsx b/apps/app/src/components/settings/CommunitySettingsSection.tsx index c960ab0887..ee5e7e0928 100644 --- a/apps/app/src/components/settings/CommunitySettingsSection.tsx +++ b/apps/app/src/components/settings/CommunitySettingsSection.tsx @@ -1,5 +1,6 @@ import { Button } from "@bb/shared-ui/button"; import { Icon, type IconName } from "@bb/shared-ui/icon"; +import { cn } from "@bb/shared-ui/lib/utils"; import { SettingsSection, SettingsWithControl, @@ -13,6 +14,7 @@ interface CommunityLinkRowProps { description: string; href: string; icon: IconName; + iconClassName?: string; label: string; openLabel: string; } @@ -21,6 +23,7 @@ function CommunityLinkRow({ description, href, icon, + iconClassName, label, openLabel, }: CommunityLinkRowProps) { @@ -36,7 +39,7 @@ function CommunityLinkRow({ openUrlInExternalBrowser(href); }} > - + {openLabel} diff --git a/apps/app/src/components/settings/MachinesSettingsSection.test.tsx b/apps/app/src/components/settings/MachinesSettingsSection.test.tsx index 3727138ece..663469ac29 100644 --- a/apps/app/src/components/settings/MachinesSettingsSection.test.tsx +++ b/apps/app/src/components/settings/MachinesSettingsSection.test.tsx @@ -12,12 +12,13 @@ import { makeHost } from "@bb/test-helpers/domain-fixtures"; import { RETRY_ACTION_ICON } from "@bb/domain/update-state"; import { HOST_DAEMON_PROTOCOL_VERSION } from "@bb/host-daemon-contract"; import type { SystemConfigResponse } from "@bb/server-contract"; -import { MemoryRouter } from "react-router-dom"; +import { MemoryRouter, useLocation } from "react-router-dom"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { sdk } from "@/lib/sdk"; import { createQueryClientTestHarness } from "@/test/queryClientTestHarness"; import { makeSystemConfig } from "@/test/fixtures/system-config"; import { MachinesSettingsSection } from "./MachinesSettingsSection"; +import { focusWithKeyboard } from "@/test/keyboard-focus"; vi.mock("@/lib/sdk", () => ({ sdk: { @@ -99,11 +100,17 @@ function stubSidebarBootstrapFetch(): void { ); } +function LocationProbe() { + const location = useLocation(); + return
{location.pathname}
; +} + function renderSection() { const { wrapper } = createQueryClientTestHarness(); return render( + , { wrapper }, ); @@ -368,6 +375,46 @@ describe("MachinesSettingsSection", () => { ).toBe(true); }); + it("navigates to the machine detail route when the row caret is clicked", async () => { + vi.mocked(sdk.system.config).mockResolvedValue(systemConfig()); + vi.mocked(sdk.hosts.list).mockResolvedValue([primaryHost, offlineHost]); + stubSidebarBootstrapFetch(); + + renderSection(); + + const machineLink = await screen.findByRole("link", { + name: "Open dev-vm", + }); + const row = machineLink.closest("[data-machine-row]"); + const caret = row?.querySelector('[data-icon="ChevronRight"]'); + expect(caret).not.toBeNull(); + if (caret === null || caret === undefined) return; + + fireEvent.click(caret); + + await waitFor(() => { + expect(screen.getByTestId("location").textContent).toBe( + "/settings/machines/host_remote", + ); + }); + }); + + it("keeps the row menu open without navigating when its trigger is clicked", async () => { + vi.mocked(sdk.system.config).mockResolvedValue(systemConfig()); + vi.mocked(sdk.hosts.list).mockResolvedValue([primaryHost, offlineHost]); + stubSidebarBootstrapFetch(); + + renderSection(); + + await screen.findByText("dev-vm"); + await openHostMenu("dev-vm"); + + expect( + await screen.findByRole("menuitem", { name: "Rename" }), + ).toBeDefined(); + expect(screen.getByTestId("location").textContent).toBe("/"); + }); + it("renames a machine through the row menu", async () => { vi.mocked(sdk.system.config).mockResolvedValue(systemConfig()); vi.mocked(sdk.hosts.list).mockResolvedValue([primaryHost, offlineHost]); @@ -435,7 +482,7 @@ describe("MachinesSettingsSection", () => { }); expect(removeItem.getAttribute("aria-disabled")).toBe("true"); expect(removeItem.textContent).toBe("Remove machine"); - fireEvent.focus(removeItem); + focusWithKeyboard(removeItem); expect( await screen.findByRole("tooltip", { name: "bb's primary machine can't be removed.", diff --git a/apps/app/src/components/settings/MachinesSettingsSection.tsx b/apps/app/src/components/settings/MachinesSettingsSection.tsx index 1dc29b9833..1d956cbbb0 100644 --- a/apps/app/src/components/settings/MachinesSettingsSection.tsx +++ b/apps/app/src/components/settings/MachinesSettingsSection.tsx @@ -1,5 +1,5 @@ import { useMemo, useState } from "react"; -import { Link } from "react-router-dom"; +import { Link, useNavigate } from "react-router-dom"; import type { Host, PermissionMode } from "@bb/domain"; import { RETRY_ACTION_ICON } from "@bb/domain/update-state"; import type { HostPlatform } from "@bb/host-daemon-contract"; @@ -18,7 +18,10 @@ import { } from "@bb/shared-ui/dropdown-menu"; import { Icon } from "@bb/shared-ui/icon"; import { cn } from "@bb/shared-ui/lib/utils"; -import { ResourceRowDetailChevron } from "@bb/shared-ui/resource-list"; +import { + ResourceRowDetailChevron, + targetsResourceAction, +} from "@bb/shared-ui/resource-list"; import { Tooltip, TooltipContent, @@ -102,6 +105,8 @@ function MachineRow({ onRetryUpdate, retryUpdatePending, }: MachineRowProps) { + const navigate = useNavigate(); + const detailPath = getSettingsMachineRoutePath(host.id); const permission = PERMISSION_MODE_PRESENTATION[host.maxPermissionMode]; const projectLabel = `${projectCount} ${projectCount === 1 ? "project" : "projects"}`; const connectionLabel = @@ -136,10 +141,14 @@ function MachineRow({
{ + if (targetsResourceAction(event.target)) return; + navigate(detailPath); + }} > @@ -260,12 +269,14 @@ export function MachinesSettingsSection() { const now = Date.now(); const primaryHostPlatform = systemConfig.data?.primaryHostPlatform ?? null; const showMachineIdentityBadges = (hosts?.length ?? 0) > 1; + const hasMachineRows = hosts !== undefined && hosts.length > 0; return ( <>
@@ -125,7 +126,7 @@ export function MarketplacesSettingsSection() {

-
    +
      {marketplaces.map((marketplace) => (
    • ({ + sdk: { + hosts: { list: vi.fn(), pickFolder: vi.fn() }, + projects: { + create: vi.fn(), + delete: vi.fn(), + reorder: vi.fn(), + update: vi.fn(), + }, + system: { config: vi.fn() }, + }, +})); + +vi.mock("@/lib/ws", () => ({ + wsManager: { subscribe: vi.fn(), unsubscribe: vi.fn() }, +})); + +vi.mock("@/hooks/useHostDaemon", () => ({ + useHostDaemon: () => ({ + localDaemonHostId: "host_primary", + localHostId: "host_primary", + hasDaemon: true, + supportsNativeFolderPicker: false, + platform: "darwin", + isLocalDaemonHost: (hostId: string | null) => hostId === "host_primary", + }), +})); + +const NOW = Date.now(); + +function host(overrides: Partial & Pick): Host { + return makeHost({ lastSeenAt: NOW, ...overrides }); +} + +const primaryHost = host({ id: "host_primary", name: "MacBook Pro" }); +const remoteHost = host({ + id: "host_remote", + name: "dev-vm", + status: "disconnected", +}); + +interface SidebarProjectFixture { + id: string; + name: string; + gitRemoteUrl: string | null; + hostIds: string[]; + threadCount: number; +} + +function stubSidebarBootstrapFetch( + projects: SidebarProjectFixture[], + status = 200, +): void { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue( + new Response( + JSON.stringify({ + sections: [], + projects: projects.map((project) => ({ + id: project.id, + kind: "standard", + name: project.name, + gitRemoteUrl: project.gitRemoteUrl, + createdAt: NOW, + updatedAt: NOW, + sources: project.hostIds.map((hostId, index) => ({ + id: `src_${project.id}_${index}`, + projectId: project.id, + type: "local_path", + hostId, + path: `/repos/${project.name}`, + isDefault: index === 0, + createdAt: NOW, + updatedAt: NOW, + })), + defaultExecutionOptions: null, + threads: Array.from({ length: project.threadCount }, (_, i) => ({ + id: `thr_${project.id}_${i}`, + projectId: project.id, + })), + })), + personalProject: { + id: "proj_personal", + kind: "personal", + name: "Personal", + gitRemoteUrl: null, + createdAt: NOW, + updatedAt: NOW, + sources: [], + defaultExecutionOptions: null, + threads: [], + }, + }), + { status, headers: { "content-type": "application/json" } }, + ), + ), + ); +} + +const projects: SidebarProjectFixture[] = [ + { + id: "proj_bb", + name: "bb", + gitRemoteUrl: "git@github.com:get-bb/bb.git", + hostIds: ["host_primary", "host_remote"], + threadCount: 3, + }, + { + id: "proj_pierre", + name: "pierre", + gitRemoteUrl: "https://github.com/get-bb/pierre.git", + hostIds: ["host_remote"], + threadCount: 1, + }, + { + id: "proj_ingest", + name: "ingest", + gitRemoteUrl: null, + hostIds: [], + threadCount: 0, + }, +]; + +function renderSection() { + const { wrapper } = createQueryClientTestHarness(); + return render( + + + , + { wrapper }, + ); +} + +async function openProjectMenu(projectName: string): Promise { + fireEvent.pointerDown( + await screen.findByRole("button", { name: `${projectName} actions` }), + { button: 0 }, + ); +} + +beforeEach(() => { + vi.mocked(sdk.system.config).mockResolvedValue( + makeSystemConfig({ + primaryHostId: "host_primary", + primaryHostPlatform: "darwin", + }), + ); + vi.mocked(sdk.hosts.list).mockResolvedValue([primaryHost, remoteHost]); +}); + +afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); + vi.clearAllMocks(); +}); + +describe("buildProjectReorderRequest", () => { + const ids = ["a", "b", "c", "d"]; + + it("moves a project down and reports its new neighbours", () => { + expect(buildProjectReorderRequest(ids, "a", "c")).toEqual({ + order: ["b", "c", "a", "d"], + previousProjectId: "c", + nextProjectId: "d", + }); + }); + + it("moves a project to the top with no previous neighbour", () => { + expect(buildProjectReorderRequest(ids, "d", "a")).toEqual({ + order: ["d", "a", "b", "c"], + previousProjectId: null, + nextProjectId: "a", + }); + }); + + it("moves a project to the bottom with no next neighbour", () => { + expect(buildProjectReorderRequest(ids, "a", "d")).toEqual({ + order: ["b", "c", "d", "a"], + previousProjectId: "d", + nextProjectId: null, + }); + }); + + it("ignores drops on the same row or unknown ids", () => { + expect(buildProjectReorderRequest(ids, "b", "b")).toBeNull(); + expect(buildProjectReorderRequest(ids, "b", "zzz")).toBeNull(); + expect(buildProjectReorderRequest(ids, "zzz", "b")).toBeNull(); + }); +}); + +describe("formatGitRemote", () => { + it("shortens ssh and https remotes to host/path", () => { + expect(formatGitRemote("git@github.com:get-bb/bb.git")).toBe( + "github.com/get-bb/bb", + ); + expect(formatGitRemote("https://github.com/get-bb/pierre.git")).toBe( + "github.com/get-bb/pierre", + ); + }); + + it("leaves unparseable remotes alone", () => { + expect(formatGitRemote("not a url")).toBe("not a url"); + }); +}); + +describe("ProjectsSettingsSection", () => { + it("summarises each project's remote, machine coverage, and threads", async () => { + stubSidebarBootstrapFetch(projects); + + renderSection(); + + expect(await screen.findByText("bb")).toBeDefined(); + expect(screen.getByText("github.com/get-bb/bb")).toBeDefined(); + expect(screen.getByText("2 of 2 machines")).toBeDefined(); + expect(screen.getByText("3 threads")).toBeDefined(); + expect(screen.getByText("1 of 2 machines")).toBeDefined(); + expect(screen.getByText("No git remote")).toBeDefined(); + expect(screen.getByText("Not set up on any machine")).toBeDefined(); + expect(screen.getByText("needs setup")).toBeDefined(); + expect(screen.queryByText("Personal")).toBeNull(); + expect( + screen + .getByRole("link", { name: "Open bb settings" }) + .getAttribute("href"), + ).toBe("/settings/projects/proj_bb"); + }); + + it("marks a project offline when every configured machine is disconnected", async () => { + stubSidebarBootstrapFetch(projects); + + renderSection(); + + await screen.findByText("1 of 2 machines"); + const pierre = screen.getByText("pierre"); + expect(pierre.parentElement?.textContent).toContain("offline"); + const bb = screen.getByText("bb"); + expect(bb.parentElement?.textContent).not.toContain("offline"); + }); + + it("shows a drag handle per project and disables them with a single project", async () => { + stubSidebarBootstrapFetch(projects); + const { unmount } = renderSection(); + + await screen.findByText("bb"); + const handles = screen.getAllByRole("button", { name: /^Reorder / }); + expect(handles).toHaveLength(3); + expect(handles.every((handle) => !handle.hasAttribute("disabled"))).toBe( + true, + ); + unmount(); + + stubSidebarBootstrapFetch([projects[0]!]); + renderSection(); + await screen.findByText("bb"); + await waitFor(() => + expect( + ( + screen.getByRole("button", { + name: "Reorder bb", + }) as HTMLButtonElement + ).disabled, + ).toBe(true), + ); + }); + + it("renames a project through the existing dialog", async () => { + stubSidebarBootstrapFetch(projects); + vi.mocked(sdk.projects.update).mockResolvedValue({ + id: "proj_bb", + kind: "standard", + name: "bb-next", + gitRemoteUrl: null, + createdAt: NOW, + updatedAt: NOW, + sources: [], + }); + + renderSection(); + await openProjectMenu("bb"); + fireEvent.click(await screen.findByRole("menuitem", { name: "Rename" })); + + const input = await screen.findByDisplayValue("bb"); + fireEvent.change(input, { target: { value: "bb-next" } }); + fireEvent.submit(input.closest("form")!); + + await waitFor(() => + expect(sdk.projects.update).toHaveBeenCalledWith({ + projectId: "proj_bb", + name: "bb-next", + }), + ); + }); + + it("deletes a project after confirmation", async () => { + stubSidebarBootstrapFetch(projects); + vi.mocked(sdk.projects.delete).mockResolvedValue({ ok: true }); + + renderSection(); + await openProjectMenu("ingest"); + fireEvent.click( + await screen.findByRole("menuitem", { name: "Delete project" }), + ); + fireEvent.click( + await screen.findByRole("button", { name: "Remove project" }), + ); + + await waitFor(() => + expect(sdk.projects.delete).toHaveBeenCalledWith({ + projectId: "proj_ingest", + }), + ); + }); + + it("opens the add-project dialog with the paired machines listed", async () => { + stubSidebarBootstrapFetch(projects); + + renderSection(); + await screen.findByText("bb"); + fireEvent.click(screen.getByRole("button", { name: "Add a project" })); + + expect( + await screen.findByRole("heading", { name: "Add project" }), + ).toBeDefined(); + expect(screen.queryByText(/Every machine is offline/u)).toBeNull(); + }); + + it("explains an empty project list", async () => { + stubSidebarBootstrapFetch([]); + + renderSection(); + + expect(await screen.findByText(/^No projects yet/u)).toBeDefined(); + }); + + it("surfaces a failed project load instead of staying on the loader", async () => { + stubSidebarBootstrapFetch([], 500); + + renderSection(); + + expect(await screen.findByRole("alert")).toBeDefined(); + expect(screen.queryByText("Loading…")).toBeNull(); + }); +}); diff --git a/apps/app/src/components/settings/ProjectsSettingsSection.tsx b/apps/app/src/components/settings/ProjectsSettingsSection.tsx new file mode 100644 index 0000000000..e274e04b60 --- /dev/null +++ b/apps/app/src/components/settings/ProjectsSettingsSection.tsx @@ -0,0 +1,439 @@ +import { useEffect, useMemo, useState, type CSSProperties } from "react"; +import { Link } from "react-router-dom"; +import { + closestCenter, + DndContext, + KeyboardSensor, + PointerSensor, + useSensor, + useSensors, + type DragEndEvent, + type Modifier, +} from "@dnd-kit/core"; +import { + arrayMove, + SortableContext, + sortableKeyboardCoordinates, + useSortable, + verticalListSortingStrategy, +} from "@dnd-kit/sortable"; +import { CSS } from "@dnd-kit/utilities"; +import type { Host } from "@bb/domain"; +import type { ProjectWithThreadsResponse } from "@bb/server-contract"; +import { Button } from "@bb/shared-ui/button"; +import "@bb/shared-ui/icon-extended"; +import { Icon } from "@bb/shared-ui/icon"; +import { cn } from "@bb/shared-ui/lib/utils"; +import { + ResourceOverflowMenu, + ResourceRowDetailChevron, +} from "@bb/shared-ui/resource-list"; +import { ProjectPathDialog } from "@/components/dialogs/ProjectPathDialog"; +import { + ProjectDeleteDialog, + type ProjectDeleteDialogTarget, +} from "@/components/dialogs/ProjectDeleteDialog"; +import { + ProjectRenameDialog, + type ProjectRenameDialogTarget, +} from "@/components/dialogs/ProjectRenameDialog"; +import { + SettingsBadge, + SettingsRow, + SettingsRowList, + SettingsSection, +} from "@/components/ui/settings-section"; +import { + useDeleteProject, + useReorderProject, + useUpdateProject, +} from "@/hooks/mutations/project-mutations"; +import { selectPersistentHosts, useHosts } from "@/hooks/queries/host-queries"; +import { useSidebarNavigation } from "@/hooks/queries/sidebar-navigation-query"; +import { useQuickCreateProject } from "@/hooks/useQuickCreateProject"; +import { getSettingsProjectRoutePath } from "@/lib/route-paths"; + +const PROJECTS_SECTION_DESCRIPTION = + "Repositories bb can work in. Drag to change the order projects appear in the sidebar."; + +const restrictDragToVerticalAxis: Modifier = ({ transform }) => ({ + ...transform, + x: 0, +}); + +const projectDragModifiers: Modifier[] = [restrictDragToVerticalAxis]; + +export function formatGitRemote(url: string): string { + const sshMatch = /^[^@]+@([^:]+):(.+?)(?:\.git)?$/.exec(url); + if (sshMatch) return `${sshMatch[1]}/${sshMatch[2]}`; + try { + const parsed = new URL(url); + return `${parsed.host}${parsed.pathname.replace(/\.git$/, "")}`; + } catch { + return url; + } +} + +export interface ProjectReorderRequest { + order: string[]; + previousProjectId: string | null; + nextProjectId: string | null; +} + +export function buildProjectReorderRequest( + ids: readonly string[], + activeId: string, + overId: string, +): ProjectReorderRequest | null { + if (activeId === overId) return null; + const from = ids.indexOf(activeId); + const to = ids.indexOf(overId); + if (from === -1 || to === -1) return null; + const order = arrayMove([...ids], from, to); + return { + order, + previousProjectId: order[to - 1] ?? null, + nextProjectId: order[to + 1] ?? null, + }; +} + +export function pluralize(count: number, singular: string): string { + return `${count} ${count === 1 ? singular : `${singular}s`}`; +} + +interface ProjectSummary { + configuredMachineCount: number; + onlineMachineCount: number; + totalMachineCount: number; +} + +function summarizeMachines( + project: ProjectWithThreadsResponse, + hostById: ReadonlyMap, +): ProjectSummary { + let configuredMachineCount = 0; + let onlineMachineCount = 0; + for (const hostId of new Set( + project.sources.map((source) => source.hostId), + )) { + const host = hostById.get(hostId); + if (!host) continue; + configuredMachineCount += 1; + if (host.status === "connected") onlineMachineCount += 1; + } + return { + configuredMachineCount, + onlineMachineCount, + totalMachineCount: hostById.size, + }; +} + +function machineLabel(summary: ProjectSummary): string { + if (summary.configuredMachineCount === 0) return "Not set up on any machine"; + if (summary.totalMachineCount <= 1) return "1 machine"; + return `${summary.configuredMachineCount} of ${summary.totalMachineCount} machines`; +} + +interface SortableProjectRowProps { + project: ProjectWithThreadsResponse; + summary: ProjectSummary; + dragDisabled: boolean; + onRename: () => void; + onDelete: () => void; +} + +function SortableProjectRow({ + project, + summary, + dragDisabled, + onRename, + onDelete, +}: SortableProjectRowProps) { + const { + attributes, + isDragging, + listeners, + setActivatorNodeRef, + setNodeRef, + transform, + transition, + } = useSortable({ id: project.id, disabled: dragDisabled }); + const style = useMemo( + () => ({ transform: CSS.Translate.toString(transform), transition }), + [transform, transition], + ); + const detailPath = getSettingsProjectRoutePath(project.id); + const remoteLabel = + project.gitRemoteUrl === null + ? null + : formatGitRemote(project.gitRemoteUrl); + const needsSetup = summary.configuredMachineCount === 0; + const allOffline = + summary.configuredMachineCount > 0 && summary.onlineMachineCount === 0; + + return ( + + +
      + +
      +
      + + + {project.name} + + {needsSetup ? needs setup : null} + {allOffline ? offline : null} +
      +
      + {remoteLabel === null ? ( +
      No git remote
      + ) : ( +
      {remoteLabel}
      + )} +
      + + {machineLabel(summary)} + + + {pluralize(project.threads.length, "thread")} + +
      +
      +
      + +
      + + +
      +
      +
      + ); +} + +export function ProjectsSettingsSection() { + const sidebarNavigationQuery = useSidebarNavigation(); + const hostsQuery = useHosts(); + const updateProject = useUpdateProject(); + const deleteProject = useDeleteProject(); + const reorderProject = useReorderProject(); + const quickCreateProject = useQuickCreateProject(); + const [renameTarget, setRenameTarget] = + useState(null); + const [deleteTarget, setDeleteTarget] = + useState(null); + const [optimisticOrder, setOptimisticOrder] = useState(null); + + const serverProjects = sidebarNavigationQuery.data?.projects; + useEffect(() => { + setOptimisticOrder(null); + }, [serverProjects]); + const projects = useMemo(() => { + if (!serverProjects) return undefined; + if (!optimisticOrder) return serverProjects; + const byId = new Map(serverProjects.map((entry) => [entry.id, entry])); + const ordered = optimisticOrder.flatMap((id) => byId.get(id) ?? []); + const seen = new Set(optimisticOrder); + return [ + ...ordered, + ...serverProjects.filter((entry) => !seen.has(entry.id)), + ]; + }, [optimisticOrder, serverProjects]); + const projectIds = useMemo( + () => projects?.map((project) => project.id) ?? [], + [projects], + ); + const hosts = useMemo( + () => selectPersistentHosts(hostsQuery.data), + [hostsQuery.data], + ); + const hostById = useMemo( + () => new Map(hosts.map((host) => [host.id, host])), + [hosts], + ); + + const sensors = useSensors( + useSensor(PointerSensor, { activationConstraint: { distance: 4 } }), + useSensor(KeyboardSensor, { + coordinateGetter: sortableKeyboardCoordinates, + }), + ); + const dragDisabled = reorderProject.isPending || projectIds.length < 2; + + const handleDragEnd = (event: DragEndEvent): void => { + if ( + dragDisabled || + typeof event.active.id !== "string" || + typeof event.over?.id !== "string" + ) { + return; + } + const request = buildProjectReorderRequest( + projectIds, + event.active.id, + event.over.id, + ); + if (request === null) return; + setOptimisticOrder(request.order); + reorderProject.mutate( + { + id: event.active.id, + previousProjectId: request.previousProjectId, + nextProjectId: request.nextProjectId, + }, + { onError: () => setOptimisticOrder(null) }, + ); + }; + + return ( + <> + + + Add a project + + } + > + {sidebarNavigationQuery.isError ? ( +

      + Couldn't load projects. +

      + ) : projects === undefined ? ( +

      Loading…

      + ) : projects.length === 0 ? ( +

      + No projects yet. Add a project to start running threads in a + repository. +

      + ) : ( + + + + {projects.map((project) => ( + { + updateProject.reset(); + setRenameTarget({ + id: project.id, + currentName: project.name, + }); + }} + onDelete={() => { + deleteProject.reset(); + setDeleteTarget({ id: project.id, name: project.name }); + }} + /> + ))} + + + + )} +
      + + + + { + if (!open && !updateProject.isPending) setRenameTarget(null); + }} + onRename={(projectId, name) => + updateProject.mutate( + { id: projectId, name }, + { onSuccess: () => setRenameTarget(null) }, + ) + } + /> + + { + if (!open && !deleteProject.isPending) setDeleteTarget(null); + }} + onDelete={(projectId) => + deleteProject.mutate(projectId, { + onSuccess: () => setDeleteTarget(null), + }) + } + /> + + ); +} diff --git a/apps/app/src/components/settings/SettingsSidebar.test.tsx b/apps/app/src/components/settings/SettingsSidebar.test.tsx index b7376249cf..31b3b4d157 100644 --- a/apps/app/src/components/settings/SettingsSidebar.test.tsx +++ b/apps/app/src/components/settings/SettingsSidebar.test.tsx @@ -38,7 +38,7 @@ function renderSidebar(activePluginId: string | null = null) { afterEach(cleanup); describe("SettingsSidebarContent plugin navigation", () => { - it("offers installed management and configurable plugins without an extra plugin group", () => { + it("offers installed-plugin management and configurable plugin settings", () => { renderSidebar(); expect( screen diff --git a/apps/app/src/components/settings/SidebarNavigationSetting.tsx b/apps/app/src/components/settings/SidebarNavigationSetting.tsx index b5c0fd334b..f7e0d6fe22 100644 --- a/apps/app/src/components/settings/SidebarNavigationSetting.tsx +++ b/apps/app/src/components/settings/SidebarNavigationSetting.tsx @@ -21,7 +21,7 @@ import { usePluginSlots } from "@/lib/plugin-slots"; const BUILT_IN_OPTION = { key: BUILT_IN_REPLACEMENT_PROVIDER, title: "bb (built-in)", - description: "Native New thread, Search, Extensions, and plugin panels.", + description: "Native New thread, Search, Plugins, Skills, and plugin panels.", } as const; export function SidebarNavigationSetting() { diff --git a/apps/app/src/components/settings/browser-import-wizard.test.ts b/apps/app/src/components/settings/browser-import-wizard.test.ts new file mode 100644 index 0000000000..ddb8c29575 --- /dev/null +++ b/apps/app/src/components/settings/browser-import-wizard.test.ts @@ -0,0 +1,170 @@ +import { describe, expect, it } from "vitest"; +import type { DesktopBrowserImportSource } from "@bb/host-daemon-contract"; +import { + canCloseDialog, + failedDialogStep, + formatSkippedDomains, + initialDialogStep, + listedSources, + needsProfileChoice, + preferredSourceProfileDirectory, + presentSourceRow, + readBrowserImportRecords, + refreshedDialogStep, + sourceAfterFailure, +} from "./browser-import-wizard"; + +const ready: DesktopBrowserImportSource = { + id: "chrome", + name: "Google Chrome", + profiles: [ + { directory: "Default", name: "Person 1", cookieCount: 4812 }, + { directory: "Profile 1", name: "Work", cookieCount: 1 }, + ], +}; + +describe("browser import dialog steps", () => { + it("opens on the step matching the source's availability", () => { + expect(initialDialogStep(ready)).toEqual({ step: "configure" }); + expect( + initialDialogStep({ ...ready, unavailable: "browserRunning" }), + ).toEqual({ step: "blocked", reason: "browserRunning" }); + expect( + initialDialogStep({ ...ready, unavailable: "needsFullDiskAccess" }), + ).toEqual({ step: "fullDiskAccess", checked: false }); + expect(initialDialogStep({ ...ready, profiles: [] })).toEqual({ + step: "blocked", + reason: "unknownSourceProfile", + }); + }); + + it("routes failures and rechecks", () => { + expect(failedDialogStep("needsFullDiskAccess")).toEqual({ + step: "fullDiskAccess", + checked: true, + }); + expect(failedDialogStep("readFailed")).toEqual({ + step: "blocked", + reason: "readFailed", + }); + expect( + refreshedDialogStep( + { ...ready, unavailable: "needsFullDiskAccess" }, + { step: "fullDiskAccess", checked: false }, + ), + ).toEqual({ step: "fullDiskAccess", checked: true }); + expect(refreshedDialogStep(undefined, { step: "configure" })).toEqual({ + step: "blocked", + reason: "unknownSource", + }); + expect(canCloseDialog({ step: "importing" })).toBe(false); + expect(canCloseDialog({ step: "checking" })).toBe(true); + }); + + it("only opens a dialog for direct-import failures the user can fix", () => { + expect(sourceAfterFailure(ready, "browserRunning")?.unavailable).toBe( + "browserRunning", + ); + expect(sourceAfterFailure(ready, "readFailed")).toBeNull(); + }); + + it("chooses profiles and lists only installed browsers", () => { + expect(needsProfileChoice(ready)).toBe(true); + expect( + needsProfileChoice({ ...ready, profiles: ready.profiles.slice(0, 1) }), + ).toBe(false); + expect(preferredSourceProfileDirectory("gone", ready)).toBe("Default"); + expect( + listedSources([ + ready, + { ...ready, id: "arc", unavailable: "notInstalled" }, + { ...ready, id: "safari", unavailable: "unsupportedPlatform" }, + { ...ready, id: "brave", unavailable: "browserRunning" }, + ]).map((source) => source.id), + ).toEqual(["chrome", "brave"]); + }); + + it("keeps Safari importable when its cookie count is unknown", () => { + expect( + presentSourceRow( + { + id: "safari", + name: "Safari", + profiles: [{ directory: ".", name: "Safari" }], + }, + undefined, + ), + ).toMatchObject({ + status: "Ready", + action: "import", + details: ["1 profile"], + }); + }); + + it("presents rows by state and last import", () => { + expect(presentSourceRow(ready, undefined)).toEqual({ + status: "Ready", + tone: "ready", + details: ["2 profiles", "4,813 cookies"], + action: "import", + actionLabel: "Import…", + }); + expect( + presentSourceRow(ready, { + at: 1, + profileName: "Work", + imported: 10, + skipped: 2, + skippedDomains: ["a.test", "b.test"], + }).details, + ).toEqual(["Work · 10 cookies imported", "2 skipped (a.test and b.test)"]); + expect( + presentSourceRow({ ...ready, unavailable: "browserRunning" }, undefined), + ).toMatchObject({ action: "recheck", tone: "attention" }); + expect( + presentSourceRow( + { ...ready, unavailable: "needsFullDiskAccess" }, + undefined, + ), + ).toMatchObject({ action: "grant", actionLabel: "Grant access…" }); + expect( + presentSourceRow( + { ...ready, profiles: [{ directory: "d", name: "n", cookieCount: 0 }] }, + undefined, + ), + ).toMatchObject({ status: "No cookies yet", action: "none" }); + }); + + it("reads persisted import records defensively", () => { + const storage = new Map(); + const fake = { getItem: (key: string) => storage.get(key) ?? null }; + expect(readBrowserImportRecords(fake)).toEqual({}); + storage.set("bb:browser-import:records", "not json"); + expect(readBrowserImportRecords(fake)).toEqual({}); + storage.set( + "bb:browser-import:records", + JSON.stringify({ + chrome: { + at: 5, + profileName: "p", + imported: 1, + skipped: 0, + skippedDomains: ["x", 3], + }, + firefox: { at: "bad" }, + }), + ); + expect(readBrowserImportRecords(fake)).toEqual({ + chrome: { + at: 5, + profileName: "p", + imported: 1, + skipped: 0, + skippedDomains: ["x"], + }, + }); + expect(formatSkippedDomains(["a", "b", "c", "d"])).toBe( + "a, b, c and 1 more", + ); + }); +}); diff --git a/apps/app/src/components/settings/browser-import-wizard.ts b/apps/app/src/components/settings/browser-import-wizard.ts new file mode 100644 index 0000000000..712f638f50 --- /dev/null +++ b/apps/app/src/components/settings/browser-import-wizard.ts @@ -0,0 +1,276 @@ +import type { + DesktopBrowserImportFailureReason, + DesktopBrowserImportOutcome, + DesktopBrowserImportSource, +} from "@bb/host-daemon-contract"; + +export interface BrowserImportRecord { + at: number; + profileName: string; + imported: number; + skipped: number; + skippedDomains: readonly string[]; +} + +export type BrowserImportRecords = Readonly< + Partial> +>; + +export const BROWSER_IMPORT_RECORDS_STORAGE_KEY = "bb:browser-import:records"; + +export function readBrowserImportRecords( + storage: Pick | null, +): BrowserImportRecords { + try { + const raw = storage?.getItem(BROWSER_IMPORT_RECORDS_STORAGE_KEY); + if (!raw) return {}; + const parsed: unknown = JSON.parse(raw); + if (typeof parsed !== "object" || parsed === null) return {}; + const records: Partial> = {}; + for (const [id, value] of Object.entries(parsed)) { + if (typeof value !== "object" || value === null) continue; + const record = value as Partial; + if ( + typeof record.at !== "number" || + typeof record.profileName !== "string" || + typeof record.imported !== "number" || + typeof record.skipped !== "number" || + !Array.isArray(record.skippedDomains) + ) + continue; + records[id] = { + at: record.at, + profileName: record.profileName, + imported: record.imported, + skipped: record.skipped, + skippedDomains: record.skippedDomains.filter( + (domain): domain is string => typeof domain === "string", + ), + }; + } + return records as BrowserImportRecords; + } catch { + return {}; + } +} + +export type BrowserImportDialogStep = + | { step: "configure" } + | { step: "fullDiskAccess"; checked: boolean } + | { step: "checking" } + | { step: "importing" } + | { step: "blocked"; reason: DesktopBrowserImportFailureReason }; + +export function initialDialogStep( + source: DesktopBrowserImportSource, +): BrowserImportDialogStep { + if (source.unavailable === "browserRunning") + return { step: "blocked", reason: "browserRunning" }; + if (source.unavailable === "needsFullDiskAccess") + return { step: "fullDiskAccess", checked: false }; + if (source.unavailable !== undefined) + return { step: "blocked", reason: source.unavailable }; + if (source.profiles.length === 0) + return { step: "blocked", reason: "unknownSourceProfile" }; + return { step: "configure" }; +} + +export function failedDialogStep( + reason: DesktopBrowserImportFailureReason, +): BrowserImportDialogStep { + if (reason === "needsFullDiskAccess") + return { step: "fullDiskAccess", checked: true }; + return { step: "blocked", reason }; +} + +export function sourceAfterFailure( + source: DesktopBrowserImportSource, + reason: DesktopBrowserImportFailureReason, +): DesktopBrowserImportSource | null { + switch (reason) { + case "browserRunning": + case "needsFullDiskAccess": + case "needsKeychainApproval": + case "keychainItemMissing": + return { ...source, unavailable: reason }; + default: + return null; + } +} + +export function refreshedDialogStep( + source: DesktopBrowserImportSource | undefined, + previous: BrowserImportDialogStep, +): BrowserImportDialogStep { + if (source === undefined) return { step: "blocked", reason: "unknownSource" }; + const next = initialDialogStep(source); + if (next.step === "fullDiskAccess" && previous.step === "fullDiskAccess") + return { step: "fullDiskAccess", checked: true }; + return next; +} + +export function canCloseDialog(step: BrowserImportDialogStep): boolean { + return step.step !== "importing"; +} + +export function needsProfileChoice( + source: DesktopBrowserImportSource, +): boolean { + return source.unavailable === undefined && source.profiles.length > 1; +} + +export function preferredSourceProfileDirectory( + current: string | null, + source: DesktopBrowserImportSource, +): string | null { + if ( + current !== null && + source.profiles.some((profile) => profile.directory === current) + ) + return current; + return source.profiles[0]?.directory ?? null; +} + +export function recordFromOutcome( + outcome: DesktopBrowserImportOutcome & { ok: true }, + profileName: string, + at: number, +): BrowserImportRecord { + return { + at, + profileName, + imported: outcome.imported, + skipped: outcome.skipped, + skippedDomains: outcome.skippedDomains, + }; +} + +export function formatCookieCount(count: number): string { + return `${count.toLocaleString()} ${count === 1 ? "cookie" : "cookies"}`; +} + +export function formatSkippedDomains(domains: readonly string[]): string { + if (domains.length === 0) return ""; + if (domains.length === 1) return domains[0]; + if (domains.length <= 3) + return `${domains.slice(0, -1).join(", ")} and ${domains[domains.length - 1]}`; + return `${domains.slice(0, 3).join(", ")} and ${domains.length - 3} more`; +} + +export function listedSources( + sources: readonly DesktopBrowserImportSource[], +): DesktopBrowserImportSource[] { + return sources.filter( + (source) => + source.unavailable !== "unsupportedPlatform" && + source.unavailable !== "notInstalled", + ); +} + +export type SourceRowTone = "ready" | "attention" | "idle"; + +export interface SourceRowPresentation { + status: string; + tone: SourceRowTone; + details: string[]; + action: "import" | "recheck" | "grant" | "none"; + actionLabel: string; +} + +export function totalCookies( + source: DesktopBrowserImportSource, +): number | undefined { + let total = 0; + let known = false; + for (const profile of source.profiles) { + if (profile.cookieCount === undefined) return undefined; + total += profile.cookieCount; + known = true; + } + return known ? total : undefined; +} + +export function presentSourceRow( + source: DesktopBrowserImportSource, + record: BrowserImportRecord | undefined, +): SourceRowPresentation { + const profileCount = source.profiles.length; + const profileLabel = `${profileCount} ${profileCount === 1 ? "profile" : "profiles"}`; + const cookies = totalCookies(source); + switch (source.unavailable) { + case "browserRunning": + return { + status: `Running · quit ${source.name} to import`, + tone: "attention", + details: [], + action: "recheck", + actionLabel: "Recheck", + }; + case "needsFullDiskAccess": + return { + status: "Needs Full Disk Access", + tone: "attention", + details: [], + action: "grant", + actionLabel: "Grant access…", + }; + case "needsKeychainApproval": + return { + status: "Needs Keychain access", + tone: "attention", + details: [], + action: "import", + actionLabel: "Import…", + }; + case "keychainItemMissing": + return { + status: "No encryption key in Keychain", + tone: "idle", + details: [], + action: "recheck", + actionLabel: "Recheck", + }; + case undefined: + break; + default: + return { + status: "Unavailable", + tone: "idle", + details: [], + action: "none", + actionLabel: "Import…", + }; + } + if (profileCount === 0 || cookies === 0) { + return { + status: "No cookies yet", + tone: "idle", + details: profileCount === 0 ? [] : [profileLabel], + action: "none", + actionLabel: "Import…", + }; + } + const details = record + ? [ + `${record.profileName} · ${formatCookieCount(record.imported)} imported`, + ...(record.skipped > 0 + ? [ + `${record.skipped.toLocaleString()} skipped${ + record.skippedDomains.length > 0 + ? ` (${formatSkippedDomains(record.skippedDomains)})` + : "" + }`, + ] + : []), + ] + : cookies === undefined + ? [profileLabel] + : [profileLabel, formatCookieCount(cookies)]; + return { + status: "Ready", + tone: "ready", + details, + action: "import", + actionLabel: "Import…", + }; +} diff --git a/apps/app/src/components/settings/settings-nav.test.tsx b/apps/app/src/components/settings/settings-nav.test.tsx index dff115de5a..3d870ba31d 100644 --- a/apps/app/src/components/settings/settings-nav.test.tsx +++ b/apps/app/src/components/settings/settings-nav.test.tsx @@ -77,8 +77,8 @@ describe("useSettingsNavState", () => { wrapper: wrapperFor("/settings/files"), }); - expect(result.current.sections.map((section) => section.id)).toContain( - "files", + expect(result.current.sections).toContainEqual( + expect.objectContaining({ icon: "File", id: "files" }), ); }); @@ -93,12 +93,11 @@ describe("useSettingsNavState", () => { ); }); - it("resolves installed plugin management in Settings", () => { + it("recognizes installed plugins as a settings section", () => { const { result } = renderHook(() => useSettingsNavState(), { wrapper: wrapperFor("/settings/plugins"), }); - expect(result.current.activeSection).toBe("plugins"); expect(result.current.hasUnknownSection).toBe(false); expect(result.current.sections.map((section) => section.id)).toContain( "plugins", diff --git a/apps/app/src/components/settings/settings-nav.tsx b/apps/app/src/components/settings/settings-nav.tsx index 85681d467e..c2e00c4d91 100644 --- a/apps/app/src/components/settings/settings-nav.tsx +++ b/apps/app/src/components/settings/settings-nav.tsx @@ -6,6 +6,7 @@ import { usePluginList } from "@/hooks/queries/plugin-settings-queries"; import { SETTINGS_MACHINE_ROUTE_PATH, SETTINGS_PLUGIN_ROUTE_PATH, + SETTINGS_PROJECT_ROUTE_PATH, SETTINGS_SECTION_ROUTE_PATH, } from "@/lib/route-paths"; import { @@ -67,6 +68,11 @@ export function useSettingsNavState(): SettingsNavState { location.pathname, ); const activeMachineId = machineMatch?.params.hostId ?? null; + const projectMatch = matchPath( + SETTINGS_PROJECT_ROUTE_PATH, + location.pathname, + ); + const activeProjectId = projectMatch?.params.projectId ?? null; const sectionParam = sectionMatch?.params.section; const hasUnknownSection = sectionParam !== undefined && !isSettingsSectionId(sectionParam); @@ -75,11 +81,13 @@ export function useSettingsNavState(): SettingsNavState { ? "plugins" : activeMachineId !== null ? "machines" - : activePluginId !== null - ? null - : sectionParam !== undefined && isSettingsSectionId(sectionParam) - ? sectionParam - : "general"; + : activeProjectId !== null + ? "projects" + : activePluginId !== null + ? null + : sectionParam !== undefined && isSettingsSectionId(sectionParam) + ? sectionParam + : "general"; const installedPlugins = pluginListQuery.data?.plugins ?? []; const pluginEntries = buildPluginSettingsEntries({ diff --git a/apps/app/src/components/settings/settings-sections.ts b/apps/app/src/components/settings/settings-sections.ts index c1fed806f3..fa9cb2537b 100644 --- a/apps/app/src/components/settings/settings-sections.ts +++ b/apps/app/src/components/settings/settings-sections.ts @@ -6,8 +6,10 @@ export const SETTINGS_NAV_SECTIONS = [ { icon: "Bot", id: "providers", label: "Providers" }, { icon: "Palette", id: "appearance", label: "Appearance" }, { icon: "SlidersHorizontal", id: "keyboard", label: "Keyboard" }, + { icon: "Browser", id: "browser", label: "Browser" }, { icon: "ChartColumn", id: "usage", label: "Usage limits" }, - { icon: "Folder", id: "files", label: "Files" }, + { icon: "File", id: "files", label: "Files" }, + { icon: "FolderGit", id: "projects", label: "Projects" }, { icon: "Laptop", id: "machines", label: "Machines" }, { icon: "PackageReceive", id: "updates", label: "Updates" }, { icon: "ElectricPlugs", id: "plugins", label: "Installed plugins" }, diff --git a/apps/app/src/components/sidebar/AppSidebar.tsx b/apps/app/src/components/sidebar/AppSidebar.tsx index bb865c6bd2..531ce2d601 100644 --- a/apps/app/src/components/sidebar/AppSidebar.tsx +++ b/apps/app/src/components/sidebar/AppSidebar.tsx @@ -305,10 +305,6 @@ export function AppSidebar({ diff --git a/apps/app/src/components/sidebar/BuiltInSidebarNavigation.tsx b/apps/app/src/components/sidebar/BuiltInSidebarNavigation.tsx index b237d0d43c..ce19995dfb 100644 --- a/apps/app/src/components/sidebar/BuiltInSidebarNavigation.tsx +++ b/apps/app/src/components/sidebar/BuiltInSidebarNavigation.tsx @@ -2,7 +2,7 @@ import type { ComponentProps } from "react"; import { useNavigate } from "react-router-dom"; import { type BuiltInSidebarNavEntry, - ExtensionsNavSidebarItem, + ResourceNavSidebarItem, PluginNavSidebarItems, type SidebarNavActivationModifiers, } from "@/components/plugin/PluginNavSidebarItems"; @@ -14,6 +14,7 @@ import { ProjectListSearchThreadsAction, } from "./ProjectList"; import { DEFAULT_BUILT_IN_SIDEBAR_NAVIGATION_ORDER } from "@/components/plugin/pluginNavSidebarOrder"; +import { getPluginsRoutePath, getSkillsRoutePath } from "@/lib/route-paths"; export type BuiltInSidebarNavigationProps = ComponentProps< typeof ProjectListNewThreadAction @@ -36,6 +37,8 @@ export function BuiltInSidebarNavigation({ const navigate = useNavigate(); const commandRunner = useAppCommandRunner(); const pluginNavPanels = usePluginNavPanelChrome(); + const pluginsRoutePath = getPluginsRoutePath(); + const skillsRoutePath = getSkillsRoutePath(); const builtInEntries: BuiltInSidebarNavEntry[] = [ { kind: "built-in", @@ -80,17 +83,38 @@ export function BuiltInSidebarNavigation({ kind: "built-in" as const, pluginId: "__bb__" as const, id: "extensions", - title: "Extensions", - icon:
; @@ -159,7 +158,6 @@ function MachineModeProbe({ threads = [] }: { threads?: ThreadListEntry[] }) { draftThreadIds={new Set()} effectivePinnedThreadIds={new Set()} status="ready" - isReady showPinnedSection={false} pinnedSection={{ label: "Pinned", content: null }} threadsSection={{ label: "Threads" }} diff --git a/apps/app/src/components/sidebar/ProjectList.tsx b/apps/app/src/components/sidebar/ProjectList.tsx index 92f13617a6..db78a61a0c 100644 --- a/apps/app/src/components/sidebar/ProjectList.tsx +++ b/apps/app/src/components/sidebar/ProjectList.tsx @@ -4,7 +4,6 @@ import { useEffect, useMemo, useState, - type MouseEventHandler, type PointerEventHandler, type ReactNode, } from "react"; @@ -62,19 +61,14 @@ import { ConfirmDeleteDialog, ConfirmDeleteDialogContent, } from "@/components/dialogs/ConfirmDeleteDialog"; -import { CHROME_SECTION_LABEL_CLASS } from "@bb/shared-ui/chrome-style-tokens"; -import { Icon, type IconName } from "@bb/shared-ui/icon"; +import { Icon } from "@bb/shared-ui/icon"; import { LIST_HOVER_TRANSITION } from "@bb/shared-ui/motion"; import { Skeleton } from "@bb/shared-ui/skeleton"; import { SidebarGroupContent, SidebarStickyStack, } from "@/components/ui/sidebar.js"; -import { - COARSE_POINTER_ICON_SIZE_CLASS, - COARSE_POINTER_ROW_ACTION_SIZE_CLASS, - COARSE_POINTER_ROW_HEIGHT_CLASS, -} from "@bb/shared-ui/coarse-pointer-sizing"; +import { COARSE_POINTER_ROW_HEIGHT_CLASS } from "@bb/shared-ui/coarse-pointer-sizing"; import { ChronologicalSectionThreadSections, ProjectThreadTree, @@ -105,12 +99,17 @@ import { type PinnedThreadTreeProps, } from "./PinnedThreadTree"; import { useThreadTitleMentionResources } from "@/components/thread/ThreadTitleMentions"; +import { + ThreadSectionMoveProvider, + type ThreadSectionMoveDestination, +} from "@/components/thread/ThreadSectionMoveProvider"; import { collapsedEnvironmentIdsAtom, collapsedThreadIdsAtom, collapsedProjectIdsAtom, collapsedSidebarSectionIdsAtom, sidebarChronologicalSortAtom, + sidebarSortDirectionAtom, sidebarCollapsedThreadSectionsAtom, sidebarCollapsedMachinesAtom, sidebarOrganizationModeAtom, @@ -119,21 +118,16 @@ import { type SidebarOrganizationMode, type SidebarSectionId, } from "./sidebarCollapsedAtoms"; -import { - DropdownMenu, - DropdownMenuCheckboxItem, - DropdownMenuContent, - DropdownMenuGroup, - DropdownMenuLabel, - DropdownMenuSeparator, - DropdownMenuTrigger, -} from "@bb/shared-ui/dropdown-menu"; -import { Tooltip, TooltipContent, TooltipTrigger } from "@bb/shared-ui/tooltip"; +import { useUiPreferencesReady } from "@/lib/ui-preferences/UiPreferencesSync"; import { SIDEBAR_ROW_BASE_CLASS, SIDEBAR_ROW_INTERACTIVE_STATE_CLASS, SIDEBAR_STANDARD_ROW_PADDING_CLASS, } from "./sidebarRowClasses"; +import { + SidebarHeaderActionsProvider, + SidebarHeaderControls, +} from "./SidebarHeaderControls"; export { TopLevelSidebarSection } from "./TopLevelSidebarSection"; import { useAppCommandRunner, @@ -175,37 +169,14 @@ interface ProjectListSearchThreadsActionProps { } interface ProjectListActionButtonsProps - extends ProjectListNewThreadActionProps, + extends + ProjectListNewThreadActionProps, ProjectListSearchThreadsActionProps {} interface ProjectListShellProps { children: ReactNode; } -interface ProjectListSectionIconButtonProps { - ariaLabel: string; - disabled?: boolean; - icon: ReactNode; - onClick: () => void; - title: string; -} - -interface ProjectListProjectsSectionActionsProps { - isCreatingProject: boolean; - onNewProject: () => void; -} - -interface ProjectListThreadsSectionActionsProps { - isCreatingSection: boolean; - onNewSection?: () => void; - onNewThread: () => void; -} - -interface SidebarDisplayOptionsMenuProps { - open?: boolean; - onOpenChange?: (open: boolean) => void; -} - interface ProjectListNavigationLoadingRowProps { textWidthClassName: string; } @@ -224,14 +195,6 @@ export const PROJECT_LIST_ACTION_BUTTON_CLASS = cn( "min-w-0 cursor-pointer justify-start overflow-hidden font-normal ring-sidebar-ring focus-visible:ring-2 disabled:cursor-default disabled:opacity-70 max-md:pointer-coarse:[&_svg]:size-5", ); -const PROJECT_LIST_SECTION_ACTION_BUTTON_CLASS = cn( - "inline-flex items-center justify-center rounded-md text-muted-foreground outline-none ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-foreground focus-visible:ring-2 disabled:opacity-50", - LIST_HOVER_TRANSITION, - COARSE_POINTER_ROW_ACTION_SIZE_CLASS, -); - -const PROJECT_LIST_SECTION_ACTION_TOOLTIP_DELAY_MS = 350; - interface ProjectThreadListStateArgs { status: ConnectionAwareQueryStatus | undefined; threads: ThreadListEntry[] | undefined; @@ -260,10 +223,7 @@ type ToggleCollapsedId = (id: string) => void; type ToggleCollapsedSidebarSectionId = ( id: CollapsibleSidebarSectionId, ) => void; -type OpenSidebarMenu = - | "threadsDisplayOptions" - | `displayOptions:${string}` - | null; +type OpenSidebarMenu = `displayOptions:${string}` | null; function removeCollapsedIds( current: T[], @@ -450,20 +410,37 @@ function compareProjectThreadItemsByTitleAscending( export function getSidebarThreadComparator( sort: SidebarChronologicalSort, resources?: ThreadTitleMentionResources, + direction: "default" | "ascending" | "descending" = "default", ): ThreadComparator { const normalizedSort = sort === "none" ? "updated" : sort; + const multiplier = + direction === "default" || + direction === (normalizedSort === "alpha" ? "ascending" : "descending") + ? 1 + : -1; if (normalizedSort === "alpha") { const comparator: ThreadComparator = (left, right) => - compareByTitleAscending(left, right, resources); + multiplier * compareByTitleAscending(left, right, resources); comparator.compareItems = (left, right) => + multiplier * compareProjectThreadItemsByTitleAscending(left, right, resources); return comparator; } - - return normalizedSort === "created" - ? compareByCreatedAtDescending - : compareStandardThreads; + const base = + normalizedSort === "created" + ? compareByCreatedAtDescending + : compareStandardThreads; + return (left, right) => { + const comparison = base(left, right); + if ( + normalizedSort === "updated" && + (left.status === "active") !== (right.status === "active") + ) { + return comparison; + } + return multiplier * comparison; + }; } function getSectionMutationErrorMessage( @@ -476,259 +453,6 @@ function getSectionMutationErrorMessage( return getMutationErrorMessage({ error, fallbackMessage }); } -export function ProjectListSectionIconButton({ - ariaLabel, - disabled = false, - icon, - onClick, - title, -}: ProjectListSectionIconButtonProps) { - const handleClick = useCallback>( - (event) => { - event.stopPropagation(); - if (event.detail > 0) { - event.currentTarget.blur(); - } - onClick(); - }, - [onClick], - ); - - const button = ( - - ); - - return ( - - - {disabled ? {button} : button} - - {title} - - ); -} - -function ProjectListProjectsSectionActions({ - isCreatingProject, - onNewProject, -}: ProjectListProjectsSectionActionsProps) { - return ( - - } - onClick={onNewProject} - /> - ); -} - -function ProjectListThreadsSectionActions({ - isCreatingSection, - onNewSection, - onNewThread, -}: ProjectListThreadsSectionActionsProps) { - return ( - <> - {onNewSection ? ( - - } - onClick={onNewSection} - /> - ) : null} - - } - onClick={onNewThread} - /> - - ); -} - -const SIDEBAR_ORGANIZE_OPTIONS = [ - { label: "By project", mode: "project" }, - { label: "By machine", mode: "machine" }, - { label: "Manually", mode: "chronological" }, -] as const satisfies readonly { - label: string; - mode: SidebarOrganizationMode; -}[]; - -const SIDEBAR_SORT_OPTIONS = [ - { label: "Updated at", sort: "updated" }, - { label: "Created at", sort: "created" }, - { label: "Alphabetical", sort: "alpha" }, -] as const satisfies readonly { - label: string; - sort: SidebarChronologicalSort; -}[]; - -function SidebarDisplayMenuTrigger({ - ariaLabel, - iconName, - tooltip, -}: { - ariaLabel: string; - iconName: IconName; - tooltip: string; -}) { - return ( - - - - - - - - {tooltip} - - - ); -} - -export function SidebarDisplayOptionsMenu({ - open, - onOpenChange, -}: SidebarDisplayOptionsMenuProps) { - const [organizationMode, setOrganizationMode] = useAtom( - sidebarOrganizationModeAtom, - ); - const [chronologicalSort, setChronologicalSort] = useAtom( - sidebarChronologicalSortAtom, - ); - const selectedSort: SidebarChronologicalSort = - chronologicalSort === "none" ? "updated" : chronologicalSort; - - return ( - - - - - Organize - - - {SIDEBAR_ORGANIZE_OPTIONS.map((option) => ( - { - onOpenChange?.(false); - setOrganizationMode(option.mode); - }} - > - {option.label} - - ))} - - - - Sort by - - - {SIDEBAR_SORT_OPTIONS.map((option) => ( - setChronologicalSort(option.sort)} - > - {option.label} - - ))} - - - - ); -} - -interface SidebarThreadsSectionActionsProps { - displayOptionsOpen: boolean; - onDisplayOptionsOpenChange: (open: boolean) => void; - isCreatingSection: boolean; - onNewSection?: () => void; - isCreatingProject: boolean; - onNewProject?: () => void; - onNewThread: () => void; -} - -function SidebarThreadsSectionActions({ - displayOptionsOpen, - onDisplayOptionsOpenChange, - isCreatingSection, - onNewSection, - isCreatingProject, - onNewProject, - onNewThread, -}: SidebarThreadsSectionActionsProps) { - return ( - <> - - {onNewProject ? ( - - ) : null} - - - ); -} - export function ProjectListNavigationLoadingState() { return (
; effectivePinnedThreadIds: ReadonlySet; - isReady: boolean; onCreateProjectThread: (projectId: string) => void; onProjectSelect?: () => void; onToggleEnvironmentCollapsed: ToggleCollapsedId; onToggleThreadCollapsed: ToggleCollapsedId; pinnedSection: BuiltInSidebarSectionOptions; projects: readonly ProjectResponse[]; - renderSectionDisplayOptions: (sectionId: SidebarSectionId) => ReactNode; - isSectionDisplayOptionsOpen: (sectionId: SidebarSectionId) => boolean; selectedThreadId?: string; status: ConnectionAwareQueryStatus; threads: ThreadListEntry[]; @@ -934,8 +655,6 @@ function ProjectModeSections({ compareThreads, draftThreadIds, effectivePinnedThreadIds, - isReady, - isSectionDisplayOptionsOpen, onCreateProjectThread, onProjectSelect, onToggleCollapsed, @@ -943,15 +662,13 @@ function ProjectModeSections({ onToggleThreadCollapsed, pinnedSection, projects, - renderSectionDisplayOptions, selectedThreadId, showPinnedSection, status, threads, threadsSection, }: ProjectModeSectionsProps) { - const progressiveDisclosureEnabled = - useSidebarProgressiveDisclosureEnabled(); + const progressiveDisclosureEnabled = useSidebarProgressiveDisclosureEnabled(); const [collapsedProjectIdList, setCollapsedProjectIdList] = useAtom( collapsedProjectIdsAtom, ); @@ -1057,7 +774,6 @@ function ProjectModeSections({ entitySectionIds: projectSectionIds, hasThreadsSection: personalThreads.length > 0 || projectRows.length === 0, showPinnedSection, - isReady, }); const reorderDisabled = order.length < 2; const builtInSections: BuiltInSidebarSectionOptionsById = { @@ -1120,8 +836,6 @@ function ProjectModeSections({ collapsedEnvironmentIds={collapsedEnvironmentIds} compareThreads={compareThreads} isLocalPathInvalid={row.isLocalPathInvalid} - headerActions={renderSectionDisplayOptions(sectionId)} - headerActionsOpen={isSectionDisplayOptionsOpen(sectionId)} onProjectSelect={onProjectSelect} onCreateProjectThread={onCreateProjectThread} onToggleProjectCollapsed={toggleProjectCollapsed} @@ -1141,7 +855,6 @@ interface SectionModeSectionsProps extends BuiltInSectionRenderState { collapsedThreadIds: Set; compareThreads: ThreadComparator; sections: readonly SidebarSectionDefinition[]; - isReady: boolean; onCreateThreadInSection: (sectionId: string) => void; onProjectSelect?: () => void; onRemoveSection: (section: SidebarSectionDefinition) => void; @@ -1154,10 +867,6 @@ interface SectionModeSectionsProps extends BuiltInSectionRenderState { onReorderPinnedThread: NonNullable< PinnedThreadTreeProps["onReorderPinnedRoot"] >; - renderTopLevelSectionHeaderActions: (section: SidebarSectionDefinition) => { - actions: ReactNode; - actionsOpen: boolean; - }; selectedThreadId?: string; status: ConnectionAwareQueryStatus; threads: ThreadListEntry[]; @@ -1172,7 +881,6 @@ function SectionModeSections({ compareThreads, effectivePinnedThreadIds, sections, - isReady, onCreateThreadInSection, onProjectSelect, onRemoveSection, @@ -1184,7 +892,6 @@ function SectionModeSections({ pinnedReorderPending, pinnedThreads, onReorderPinnedThread, - renderTopLevelSectionHeaderActions, selectedThreadId, showPinnedSection, status, @@ -1210,36 +917,51 @@ function SectionModeSections({ mode: "chronological", entitySectionIds: threadSectionIds, showPinnedSection, - isReady, }); + const moveDestinations = useMemo(() => { + const destinationsBySidebarId = new Map( + sections.map((section) => [ + buildSidebarEntitySectionId("section", section.id), + { label: section.name, sectionId: section.id }, + ]), + ); + return order.flatMap((sectionId) => { + if (sectionId === "threads") { + return [{ label: threadsSection.label, sectionId: null }]; + } + const destination = destinationsBySidebarId.get(sectionId); + return destination ? [destination] : []; + }); + }, [order, sections, threadsSection.label]); return ( - + + + ); } @@ -1249,12 +971,14 @@ interface MachineModeSectionsProps extends BuiltInSectionRenderState { compareThreads: ThreadComparator; draftThreadIds: ReadonlySet; effectivePinnedThreadIds: ReadonlySet; - isReady: boolean; onProjectSelect?: () => void; onToggleEnvironmentCollapsed: ToggleCollapsedId; onToggleThreadCollapsed: ToggleCollapsedId; pinnedSection: BuiltInSidebarSectionOptions; - renderSectionDisplayOptions: (sectionId: SidebarSectionId) => ReactNode; + renderSectionDisplayOptions: ( + sectionId: SidebarSectionId, + label: string, + ) => ReactNode; isSectionDisplayOptionsOpen: (sectionId: SidebarSectionId) => boolean; selectedThreadId?: string; status: ConnectionAwareQueryStatus; @@ -1269,7 +993,6 @@ export function MachineModeSections({ compareThreads, draftThreadIds, effectivePinnedThreadIds, - isReady, isSectionDisplayOptionsOpen, onProjectSelect, onToggleCollapsed, @@ -1283,8 +1006,7 @@ export function MachineModeSections({ threads, threadsSection, }: MachineModeSectionsProps) { - const progressiveDisclosureEnabled = - useSidebarProgressiveDisclosureEnabled(); + const progressiveDisclosureEnabled = useSidebarProgressiveDisclosureEnabled(); const { data: hosts } = useHosts(); const [collapsedMachineKeyList, setCollapsedMachineKeyList] = useAtom( sidebarCollapsedMachinesAtom, @@ -1349,7 +1071,6 @@ export function MachineModeSections({ entitySectionIds: machineSectionIds, hasThreadsSection: machineSections.length === 0, showPinnedSection, - isReady, }); const reorderDisabled = order.length < 2; const builtInSections: BuiltInSidebarSectionOptionsById = { @@ -1400,7 +1121,7 @@ export function MachineModeSections({ id={sectionId} label={section.label} disabled={reorderDisabled} - actions={renderSectionDisplayOptions(sectionId)} + actions={renderSectionDisplayOptions(sectionId, section.label)} actionsOpen={isSectionDisplayOptionsOpen(sectionId)} actionsMobileAlways collapsedActivity={section.activity} @@ -1464,6 +1185,7 @@ function ProjectListComponent({ } return map; }, [threads]); + const uiPreferencesReady = useUiPreferencesReady(); const projectsState = useConnectionAwareQueryState({ hasResolvedData: projects !== undefined, isFetching: sidebarNavigationQuery.isFetching, @@ -1652,16 +1374,15 @@ function ProjectListComponent({ }, [], ); - const handleThreadsDisplayOptionsMenuOpenChange = useCallback( - (open: boolean) => setSidebarMenuOpen("threadsDisplayOptions", open), - [setSidebarMenuOpen], - ); - const threadsDisplayOptionsMenuOpen = - openSidebarMenu === "threadsDisplayOptions"; - const renderSectionDisplayOptions = (sectionId: SidebarSectionId) => { + const renderSectionDisplayOptions = ( + sectionId: SidebarSectionId, + label: string, + ) => { const menuId = `displayOptions:${sectionId}` as const; return ( - setSidebarMenuOpen(menuId, open)} /> @@ -1673,13 +1394,18 @@ function ProjectListComponent({ const [chronologicalSort, setChronologicalSort] = useAtom( sidebarChronologicalSortAtom, ); - const isSectionOrganizationMode = organizationMode === "chronological"; + const sortDirection = useAtomValue(sidebarSortDirectionAtom); const setCollapsedSectionList = useSetAtom( sidebarCollapsedThreadSectionsAtom, ); const sidebarThreadComparator = useMemo( - () => getSidebarThreadComparator(chronologicalSort, titleMentionResources), - [chronologicalSort, titleMentionResources], + () => + getSidebarThreadComparator( + chronologicalSort, + titleMentionResources, + sortDirection, + ), + [chronologicalSort, titleMentionResources, sortDirection], ); const collapsedThreadIds = useMemo( () => new Set(collapsedThreadIdList), @@ -1859,31 +1585,18 @@ function ProjectListComponent({ pinnedSidebarState.effectivePinnedThreadIds.has(thread.id) && isSidebarProjectThread(thread), ); - const threadsSectionActions = ( - - ); const pinnedSection: BuiltInSidebarSectionOptions = { activity: getCollapsedChildActivity(pinnedSectionThreads, draftThreadIds), collapsedThreads: pinnedSectionThreads, label: "Pinned", content: pinnedSectionContent, - actions: renderSectionDisplayOptions("pinned"), + actions: renderSectionDisplayOptions("pinned", "Pinned"), actionsOpen: isSectionDisplayOptionsOpen("pinned"), }; const threadsSection = { label: "Threads", - actions: threadsSectionActions, - actionsOpen: threadsDisplayOptionsMenuOpen, + actions: renderSectionDisplayOptions("threads", "Threads"), + actionsOpen: isSectionDisplayOptionsOpen("threads"), } satisfies Omit; const sectionCreateDialog = ( ); - if (projectsState.status === "loading") { + if (projectsState.status === "loading" || !uiPreferencesReady) { return ( @@ -1930,89 +1643,25 @@ function ProjectListComponent({ } return ( - - ( - - )} - renderChronological={() => ( - <> - { - const sectionId = buildSidebarEntitySectionId( - "section", - section.id, - ); - return { - actions: renderSectionDisplayOptions(sectionId), - actionsOpen: isSectionDisplayOptionsOpen(sectionId), - }; - }} - onToggleCollapsed={toggleSidebarSectionCollapsed} - onToggleThreadCollapsed={toggleThreadCollapsed} - onToggleEnvironmentCollapsed={toggleEnvironmentCollapsed} - /> - {sectionCreateDialog} - {sectionRenameDialogContent} - {sectionDeleteDialogContent} - - )} - renderProject={() => ( - <> - + + ( + - {sectionCreateDialog} - {sectionRenameDialogContent} - {sectionDeleteDialogContent} - - )} - /> - + )} + renderChronological={() => ( + <> + + + )} + renderProject={() => ( + <> + + + )} + /> + + {sectionCreateDialog} + {sectionRenameDialogContent} + {sectionDeleteDialogContent} + ); } diff --git a/apps/app/src/components/sidebar/ProjectListSectionHeader.test.tsx b/apps/app/src/components/sidebar/ProjectListSectionHeader.test.tsx index 8629027b23..df6584c63b 100644 --- a/apps/app/src/components/sidebar/ProjectListSectionHeader.test.tsx +++ b/apps/app/src/components/sidebar/ProjectListSectionHeader.test.tsx @@ -17,10 +17,8 @@ import { resetPluginThreadRowStatusesForTest, setPluginThreadRowStatus, } from "@/lib/plugin-thread-row-status"; -import { - ProjectListSectionIconButton, - TopLevelSidebarSection, -} from "./ProjectList"; +import { TopLevelSidebarSection } from "./TopLevelSidebarSection"; +import { SidebarControlButton } from "./SidebarRowControls"; afterEach(() => { cleanup(); @@ -30,24 +28,23 @@ afterEach(() => { window.sessionStorage.removeItem(SPLIT_LAYOUT_STORAGE_KEY); }); -describe("ProjectListSectionIconButton", () => { +describe("SidebarControlButton", () => { it("drops pointer focus before a section action opens a picker", () => { let triggerWasFocused = true; render( - +} - title="New project" + { triggerWasFocused = document.activeElement === - screen.getByRole("button", { name: "New project" }); + screen.getByRole("button", { name: "New thread" }); }} /> , ); - const trigger = screen.getByRole("button", { name: "New project" }); + const trigger = screen.getByRole("button", { name: "New thread" }); trigger.focus(); fireEvent.click(trigger, { detail: 1 }); @@ -59,15 +56,14 @@ describe("ProjectListSectionIconButton", () => { it("retains section-action focus for keyboard activation", () => { render( - +} - title="New project" + , ); - const trigger = screen.getByRole("button", { name: "New project" }); + const trigger = screen.getByRole("button", { name: "New thread" }); trigger.focus(); fireEvent.click(trigger, { detail: 0 }); diff --git a/apps/app/src/components/sidebar/ProjectRow.interactions.test.tsx b/apps/app/src/components/sidebar/ProjectRow.interactions.test.tsx index f630091f69..2a2ee4756d 100644 --- a/apps/app/src/components/sidebar/ProjectRow.interactions.test.tsx +++ b/apps/app/src/components/sidebar/ProjectRow.interactions.test.tsx @@ -26,9 +26,13 @@ const mockUpdateEnvironment = vi.hoisted(() => ({ mutate: vi.fn(), reset: vi.fn(), })); +const mockArchiveEnvironmentThreads = vi.hoisted(() => ({ + mutateAsync: vi.fn(async () => ({ ok: true, archivedThreadIds: [] })), +})); const mockDraftThreadIds = vi.hoisted(() => ({ current: new Set(), })); +const mockCreateThreadInEnvironment = vi.hoisted(() => vi.fn()); vi.mock("@/hooks/useLocalPathPicker", () => ({ usePathPickerHost: () => ({ hostId: null, hostName: null }), @@ -37,7 +41,7 @@ vi.mock("@/hooks/useLocalPathPicker", () => ({ vi.mock("@/hooks/mutations/environment-mutations", () => ({ useArchiveEnvironmentThreads: () => ({ isPending: false, - mutate: vi.fn(), + mutateAsync: mockArchiveEnvironmentThreads.mutateAsync, variables: undefined, }), useUpdateEnvironment: () => ({ @@ -49,8 +53,8 @@ vi.mock("@/hooks/mutations/environment-mutations", () => ({ }), })); -vi.mock("@/hooks/useCreateThreadInWorktree", () => ({ - useCreateThreadInWorktree: () => vi.fn(), +vi.mock("@/hooks/useCreateThreadInEnvironment", () => ({ + useCreateThreadInEnvironment: () => mockCreateThreadInEnvironment, })); vi.mock("@/hooks/usePromptDraftStorage", () => ({ @@ -97,22 +101,24 @@ function renderProjectRow( const onToggleEnvironmentCollapsed = vi.fn(); const result = render( - - 0} - progressiveDisclosureEnabled - collapsedThreadIds={new Set()} - collapsedEnvironmentIds={collapsedEnvironmentIds} - isLocalPathInvalid={false} - onToggleProjectCollapsed={onToggleProjectCollapsed} - onToggleThreadCollapsed={vi.fn()} - onToggleEnvironmentCollapsed={onToggleEnvironmentCollapsed} - /> - + + + 0} + progressiveDisclosureEnabled + collapsedThreadIds={new Set()} + collapsedEnvironmentIds={collapsedEnvironmentIds} + isLocalPathInvalid={false} + onToggleProjectCollapsed={onToggleProjectCollapsed} + onToggleThreadCollapsed={vi.fn()} + onToggleEnvironmentCollapsed={onToggleEnvironmentCollapsed} + /> + + , ); return { ...result, onToggleEnvironmentCollapsed, onToggleProjectCollapsed }; @@ -136,6 +142,34 @@ describe("ProjectRow interactions", () => { vi.clearAllMocks(); }); + it("keeps project header controls touch-accessible when their menu opens and closes", async () => { + renderProjectRow(); + const trigger = screen.getByRole("button", { + name: "Test project actions", + }); + const actions = trigger.closest(".bb-sidebar-hover-actions"); + expect(actions?.getAttribute("data-sidebar-hover-actions-mobile")).toBe( + "always", + ); + expect(actions?.getAttribute("data-sidebar-hover-actions-open")).toBeNull(); + + fireEvent.pointerDown(trigger, { button: 0 }); + const menu = await screen.findByRole("menu"); + expect(actions?.getAttribute("data-sidebar-hover-actions-mobile")).toBe( + "always", + ); + expect(actions?.getAttribute("data-sidebar-hover-actions-open")).toBe( + "true", + ); + + fireEvent.keyDown(menu, { key: "Escape" }); + await waitFor(() => expect(screen.queryByRole("menu")).toBeNull()); + expect(actions?.getAttribute("data-sidebar-hover-actions-mobile")).toBe( + "always", + ); + expect(actions?.getAttribute("data-sidebar-hover-actions-open")).toBeNull(); + }); + it("places the project disclosure after its label and keeps root threads flush", () => { const result = renderProjectRow(vi.fn(), { status: "ready", @@ -178,8 +212,9 @@ describe("ProjectRow interactions", () => { environmentId: "env_test", environmentName: "Feature workspace", environmentBranchName: "feat/menu-close", + environmentProviderId: "git-worktree", + environmentIsWorktree: true, queuedWork: "none", - environmentWorkspaceDisplayKind: "managed-worktree", activity: { activeWorkflowCount: 1, activeBackgroundAgentCount: 0, @@ -197,8 +232,9 @@ describe("ProjectRow interactions", () => { environmentId: "env_test", environmentName: "Feature workspace", environmentBranchName: "feat/menu-close", + environmentProviderId: "git-worktree", + environmentIsWorktree: true, queuedWork: "none", - environmentWorkspaceDisplayKind: "managed-worktree", }), ], }, @@ -227,7 +263,8 @@ describe("ProjectRow interactions", () => { environmentId: "env_draft", environmentName: "Draft workspace", queuedWork: "none", - environmentWorkspaceDisplayKind: "managed-worktree", + environmentProviderId: "git-worktree", + environmentIsWorktree: true, activity: { activeWorkflowCount: 0, activeBackgroundAgentCount: 0, @@ -241,7 +278,8 @@ describe("ProjectRow interactions", () => { environmentId: "env_draft", environmentName: "Draft workspace", queuedWork: "none", - environmentWorkspaceDisplayKind: "managed-worktree", + environmentProviderId: "git-worktree", + environmentIsWorktree: true, }), ], }, @@ -456,45 +494,139 @@ describe("ProjectRow interactions", () => { expect(document.querySelector('[data-icon="Edit"]')).toBeNull(); }); - it("closes the worktree actions menu after selecting rename", async () => { + it.each([false, true])( + "keeps environment actions touch-accessible when collapsed=%s", + async (isCollapsed) => { + renderProjectRow( + vi.fn(), + { + status: "ready", + threads: [ + makeThread({ + id: "thr_worktree_a", + environmentId: "env_test", + environmentName: "Feature workspace", + environmentBranchName: "feat/menu-close", + environmentProviderId: "git-worktree", + environmentIsWorktree: true, + queuedWork: "none", + }), + makeThread({ + id: "thr_worktree_b", + environmentId: "env_test", + environmentName: "Feature workspace", + environmentBranchName: "feat/menu-close", + environmentProviderId: "git-worktree", + environmentIsWorktree: true, + queuedWork: "none", + }), + ], + }, + false, + isCollapsed ? new Set(["env_test"]) : new Set(), + ); + + const createButton = screen.getByRole("button", { + name: "New thread in environment", + }); + const actions = createButton.closest(".bb-sidebar-hover-actions"); + expect(actions?.getAttribute("data-sidebar-hover-actions-mobile")).toBe( + "always", + ); + expect( + actions?.contains( + screen.getByRole("button", { name: "Environment actions" }), + ), + ).toBe(true); + fireEvent.click(createButton); + expect(mockCreateThreadInEnvironment).toHaveBeenCalledOnce(); + + fireEvent.pointerDown( + screen.getByRole("button", { name: "Environment actions" }), + { button: 0 }, + ); + const rename = await screen.findByRole("menuitem", { name: "Rename" }); + expect( + screen.getAllByRole("menuitem").map((item) => item.textContent), + ).toEqual(["Rename", "Archive"]); + fireEvent.click(rename); + + expect( + await screen.findByRole("dialog", { name: "Rename environment" }), + ).not.toBeNull(); + expect(screen.getByText("feat/menu-close")).not.toBeNull(); + await waitFor(() => { + expect(screen.queryByRole("menuitem", { name: "Rename" })).toBeNull(); + }); + }, + ); + + it("leaves threads sharing the project checkout ungrouped", () => { renderProjectRow(vi.fn(), { status: "ready", threads: [ makeThread({ - id: "thr_worktree_a", - environmentId: "env_test", - environmentName: "Feature workspace", - environmentBranchName: "feat/menu-close", + id: "thr_checkout_a", + environmentId: "env_checkout", + environmentBranchName: "main", + environmentProviderId: null, queuedWork: "none", - environmentWorkspaceDisplayKind: "managed-worktree", }), makeThread({ - id: "thr_worktree_b", - environmentId: "env_test", - environmentName: "Feature workspace", - environmentBranchName: "feat/menu-close", + id: "thr_checkout_b", + environmentId: "env_checkout", + environmentBranchName: "main", + environmentProviderId: null, + queuedWork: "none", + }), + ], + }); + + expect( + screen.queryByRole("button", { name: "Collapse main threads" }), + ).toBeNull(); + }); + + it("archives and renames an environment group from any provider", async () => { + mockArchiveEnvironmentThreads.mutateAsync.mockClear(); + renderProjectRow(vi.fn(), { + status: "ready", + threads: [ + makeThread({ + id: "thr_plain_a", + environmentId: "env_plain", + environmentBranchName: "main", + environmentProviderId: "personal-workspace", + environmentIsWorktree: true, + queuedWork: "none", + }), + makeThread({ + id: "thr_plain_b", + environmentId: "env_plain", + environmentBranchName: "main", + environmentProviderId: "personal-workspace", + environmentIsWorktree: true, queuedWork: "none", - environmentWorkspaceDisplayKind: "managed-worktree", }), ], }); fireEvent.pointerDown( - screen.getByRole("button", { name: "Worktree actions" }), + screen.getByRole("button", { name: "Environment actions" }), { button: 0 }, ); - fireEvent.click( - await screen.findByRole("menuitem", { name: "Rename worktree" }), - ); + fireEvent.click(await screen.findByRole("menuitem", { name: "Archive" })); + expect(mockArchiveEnvironmentThreads.mutateAsync).toHaveBeenCalledWith({ + id: "env_plain", + }); + fireEvent.pointerDown( + screen.getByRole("button", { name: "Environment actions" }), + { button: 0 }, + ); + fireEvent.click(await screen.findByRole("menuitem", { name: "Rename" })); expect( - await screen.findByRole("dialog", { name: "Rename worktree" }), + await screen.findByRole("dialog", { name: "Rename environment" }), ).not.toBeNull(); - expect(screen.getByText("feat/menu-close")).not.toBeNull(); - await waitFor(() => { - expect( - screen.queryByRole("menuitem", { name: "Rename worktree" }), - ).toBeNull(); - }); }); }); diff --git a/apps/app/src/components/sidebar/ProjectRow.stories.tsx b/apps/app/src/components/sidebar/ProjectRow.stories.tsx index 02b5efa1b0..deabae02b6 100644 --- a/apps/app/src/components/sidebar/ProjectRow.stories.tsx +++ b/apps/app/src/components/sidebar/ProjectRow.stories.tsx @@ -194,8 +194,8 @@ const rootThread = makeThread({ titleFallback: "Stabilize Pnpm Dev Environment", environmentHostId: HOST_IDS.local, environmentBranchName: BRANCH_NAMES.default, + environmentProviderId: "git-worktree", queuedWork: "none", - environmentWorkspaceDisplayKind: "managed-worktree", }); const sharedWorktreeThreadA = makeThread({ id: "thr_shared_wt_a", @@ -204,8 +204,8 @@ const sharedWorktreeThreadA = makeThread({ environmentId: "env_shared_worktree", environmentHostId: HOST_IDS.local, environmentBranchName: "bb/set-default-tab-for-panel-thr_vnj2qze4fg", + environmentProviderId: "git-worktree", queuedWork: "none", - environmentWorkspaceDisplayKind: "managed-worktree", }); const sharedWorktreeThreadB = makeThread({ id: "thr_shared_wt_b", @@ -214,8 +214,8 @@ const sharedWorktreeThreadB = makeThread({ environmentId: "env_shared_worktree", environmentHostId: HOST_IDS.local, environmentBranchName: "bb/set-default-tab-for-panel-thr_vnj2qze4fg", + environmentProviderId: "git-worktree", queuedWork: "none", - environmentWorkspaceDisplayKind: "managed-worktree", }); const parentThread = makeThread({ id: "thr_parent", @@ -276,8 +276,8 @@ const deepWorktreeA = makeThread({ environmentId: "env_deep_worktree", environmentHostId: HOST_IDS.local, environmentBranchName: "bb/sidebar-parent-child-nesting", + environmentProviderId: "git-worktree", queuedWork: "none", - environmentWorkspaceDisplayKind: "managed-worktree", }); const deepWorktreeB = makeThread({ id: "thr_deep_worktree_b", @@ -287,8 +287,8 @@ const deepWorktreeB = makeThread({ environmentId: "env_deep_worktree", environmentHostId: HOST_IDS.local, environmentBranchName: "bb/sidebar-parent-child-nesting", + environmentProviderId: "git-worktree", queuedWork: "none", - environmentWorkspaceDisplayKind: "managed-worktree", hasPendingInteraction: true, }); @@ -680,8 +680,8 @@ const fullProjectAThreads: ThreadListEntry[] = [ environmentId: "env_full_a_codex_train", environmentHostId: "host_local", environmentBranchName: "bb/ready-app-train-thr_s6fn8fuv9w", + environmentProviderId: "git-worktree", queuedWork: "none", - environmentWorkspaceDisplayKind: "managed-worktree", }), makeThread({ id: "thr_full_a_worktree_env_group_2", @@ -692,8 +692,8 @@ const fullProjectAThreads: ThreadListEntry[] = [ environmentId: "env_full_a_codex_train", environmentHostId: "host_local", environmentBranchName: "bb/ready-app-train-thr_s6fn8fuv9w", + environmentProviderId: "git-worktree", queuedWork: "none", - environmentWorkspaceDisplayKind: "managed-worktree", }), makeThread({ id: "thr_full_a_standalone_1", @@ -702,8 +702,8 @@ const fullProjectAThreads: ThreadListEntry[] = [ titleFallback: "Stabilize Pnpm Dev Environment", environmentHostId: "host_local", environmentBranchName: "main", + environmentProviderId: "git-worktree", queuedWork: "none", - environmentWorkspaceDisplayKind: "managed-worktree", }), makeThread({ id: "thr_full_a_standalone_2", @@ -721,8 +721,8 @@ const fullProjectAThreads: ThreadListEntry[] = [ environmentId: "env_full_a_sidebar_rail", environmentHostId: "host_local", environmentBranchName: "bb/fix-diff-panel-issues-thr_u8cnp5fnea", + environmentProviderId: "git-worktree", queuedWork: "none", - environmentWorkspaceDisplayKind: "managed-worktree", }), makeThread({ id: "thr_full_a_env_group_2", @@ -732,8 +732,8 @@ const fullProjectAThreads: ThreadListEntry[] = [ environmentId: "env_full_a_sidebar_rail", environmentHostId: "host_local", environmentBranchName: "bb/fix-diff-panel-issues-thr_u8cnp5fnea", + environmentProviderId: "git-worktree", queuedWork: "none", - environmentWorkspaceDisplayKind: "managed-worktree", }), ]; diff --git a/apps/app/src/components/sidebar/ProjectRow.tsx b/apps/app/src/components/sidebar/ProjectRow.tsx index 4c8c2aa97f..b751d35b60 100644 --- a/apps/app/src/components/sidebar/ProjectRow.tsx +++ b/apps/app/src/components/sidebar/ProjectRow.tsx @@ -1,3 +1,13 @@ +import { + SidebarHeaderControls, + SidebarSectionMenuItems, +} from "./SidebarHeaderControls"; +import { SidebarRowControls, SidebarControlButton } from "./SidebarRowControls"; +import { + SIDEBAR_CONTROL_BUTTON_CLASS, + SIDEBAR_CONTROL_PAIR_SIZE_CLASS, + SIDEBAR_GROUP_TEXT_CLASS, +} from "./sidebarRowClasses"; import { memo, useCallback, @@ -17,7 +27,14 @@ import { createPortal } from "react-dom"; import { PERSONAL_PROJECT_ID, type ThreadListEntry } from "@bb/domain"; import type { ProjectResponse } from "@bb/server-contract"; import { NavLink } from "react-router-dom"; -import { useCreateThreadInWorktree } from "@/hooks/useCreateThreadInWorktree"; +import { useCreateThreadInEnvironment } from "@/hooks/useCreateThreadInEnvironment"; +import { useSystemEnvironmentProviders } from "@/hooks/queries/environment-provider-queries"; +import { + findEnvironmentDisplayProvider, + getEnvironmentLabelIconName, + UNNAMED_ENVIRONMENT_LABEL, +} from "@/lib/environment-workspace-display"; +import { resolveEnvironmentDisplayName } from "@bb/core-ui"; import { usePromptDraftHasInput, usePromptDraftInputThreadIds, @@ -43,7 +60,7 @@ import { } from "@/components/ui/sidebar.js"; import { ProjectActionsContextMenu, - ProjectActionsMenu, + ProjectActionsMenuItems, } from "@/components/project/ProjectActionsMenu"; import { EnvironmentRenameDialog, @@ -72,7 +89,7 @@ import { } from "@bb/client-core"; import { cn } from "@bb/shared-ui/lib/utils"; import { getMutationErrorMessage } from "@/lib/mutation-errors"; -import { getProjectSettingsRoutePath } from "@/lib/route-paths"; +import { getSettingsProjectRoutePath } from "@/lib/route-paths"; import { getThreadDisplayTitle } from "@/lib/thread-title"; import { appToast } from "@/components/ui/app-toast"; import { useRouteNavigate } from "@/components/ui/app-route-anchor"; @@ -110,7 +127,6 @@ import { } from "./sidebarCollapsedAtoms"; import { SIDEBAR_PROJECT_GROUP_LINE_CLASS, - SIDEBAR_MORE_ACTION_TRIGGER_CLASS, SIDEBAR_ROW_BASE_CLASS, getSidebarThreadGroupLineLeft, getSidebarThreadRowPaddingLeft, @@ -164,8 +180,6 @@ export interface ProjectRowProps { collapsedThreadIds: Set; collapsedEnvironmentIds: Set; isLocalPathInvalid: boolean; - headerActions?: ReactNode; - headerActionsOpen?: boolean; onProjectSelect?: () => void; onCreateProjectThread?: (projectId: string) => void; onToggleProjectCollapsed: (projectId: string) => void; @@ -202,18 +216,10 @@ interface SectionThreadTreeProps { onCreateThreadInSection?: (sectionId: string) => void; onRenameSection?: (section: SidebarSectionDefinition) => void; onRemoveSection?: (section: SidebarSectionDefinition) => void; - renderTopLevelSectionHeaderActions?: ( - section: SidebarSectionDefinition, - ) => TopLevelSectionHeaderActions; onToggleThreadCollapsed: (threadId: string) => void; onToggleEnvironmentCollapsed: (environmentId: string) => void; } -interface TopLevelSectionHeaderActions { - actions: ReactNode; - actionsOpen: boolean; -} - interface ChronologicalBuiltInSidebarSections { collapsedSectionIds: ReadonlySet; onToggleCollapsed: (id: CollapsibleSidebarSectionId) => void; @@ -301,7 +307,6 @@ interface ThreadTreeItemRowProps { onCreateThreadInSection?: (sectionId: string) => void; onRenameSection?: (section: SidebarSectionDefinition) => void; onRemoveSection?: (section: SidebarSectionDefinition) => void; - renderTopLevelSectionHeaderActions?: SectionThreadTreeProps["renderTopLevelSectionHeaderActions"]; onToggleThreadCollapsed: (threadId: string) => void; onToggleEnvironmentCollapsed: (environmentId: string) => void; consumeClickSuppression?: ConsumeDragClickSuppression; @@ -323,7 +328,6 @@ interface SectionTreeItemRowProps { onCreateThreadInSection?: (sectionId: string) => void; onRenameSection?: (section: SidebarSectionDefinition) => void; onRemoveSection?: (section: SidebarSectionDefinition) => void; - renderTopLevelSectionHeaderActions?: SectionThreadTreeProps["renderTopLevelSectionHeaderActions"]; onToggleThreadCollapsed: (threadId: string) => void; onToggleEnvironmentCollapsed: (environmentId: string) => void; consumeClickSuppression?: ConsumeDragClickSuppression; @@ -388,6 +392,7 @@ interface GetThreadNodeStickyLevelArgs { interface EnvironmentThreadGroupHeaderProps { environmentId: string; + environmentProviderId: string | null; representativeThread: ThreadListEntry; rowDepth: number; stickyLevel?: number; @@ -845,19 +850,23 @@ function EnvironmentThreadGroupHeaderActions({ onOpenChange, }: EnvironmentThreadGroupHeaderActionsProps) { return ( - + + } + > - - - + { onRenameEnvironment(); }} > - + ); } function EnvironmentThreadGroupHeader({ environmentId, + environmentProviderId, representativeThread, rowDepth, stickyLevel, @@ -912,10 +918,22 @@ function EnvironmentThreadGroupHeader({ onToggleCollapsed, }: EnvironmentThreadGroupHeaderProps) { const [isActionsOpen, setIsActionsOpen] = useState(false); - const environmentName = representativeThread.environmentName; - const branchName = representativeThread.environmentBranchName; - const displayName = environmentName || branchName || "Worktree"; - const iconName: IconName = "FolderGit"; + const { providers } = useSystemEnvironmentProviders(); + const providerLookup = findEnvironmentDisplayProvider( + providers, + environmentProviderId, + ); + const displayName = + resolveEnvironmentDisplayName( + { + name: representativeThread.environmentName, + branchName: representativeThread.environmentBranchName, + path: representativeThread.environmentPath, + environmentProviderId, + }, + providerLookup, + ) ?? UNNAMED_ENVIRONMENT_LABEL; + const iconName = getEnvironmentLabelIconName(providerLookup); const showRollupGlyph = isCollapsed && (childActivity.pending || @@ -939,7 +957,8 @@ function EnvironmentThreadGroupHeader({ )} - + {displayName} @@ -962,18 +986,14 @@ function EnvironmentThreadGroupHeader({ revealOnHover /> - + {showRollupGlyph ? ( @@ -981,9 +1001,13 @@ function EnvironmentThreadGroupHeader({ ) : null}
{ onProjectSelect?.(); - createThreadInWorktree(); - }, [createThreadInWorktree, onProjectSelect]); + createThreadInEnvironment(); + }, [createThreadInEnvironment, onProjectSelect]); const { onRenameDialogOpenChange, onRenameEnvironment, @@ -1092,6 +1117,7 @@ const EnvironmentThreadGroupRow = memo(function EnvironmentThreadGroupRow({ - {externalHeaderActions?.actions} - {hasMenuActions ? ( - - - - - - {onRenameSection ? ( - onRenameSection(section)}> - - ) : null} - {onRemoveSection ? ( - onRemoveSection(section)} - > - - ) : null} - - - ) : null} - {onCreateThreadInSection ? ( - - ) : null} - - ); - const topLevelActions = hasTopLevelActions ? ( - onCreateThreadInSection(section.id) + : undefined } - className={cn( - SIDEBAR_HOVER_ACTIONS_CLASS, - "relative z-10 inline-flex shrink-0 items-center", - SIDEBAR_HOVER_ACTIONS_GAP_CLASS, - )} + onOpenChange={setIsTopLevelActionsOpen} > - {topLevelActionControls} - - ) : null; - + onRenameSection(section) : undefined + } + onRemove={ + onRemoveSection ? () => onRemoveSection(section) : undefined + } + /> + + ); return ( void; onRenameSection?: (section: SidebarSectionDefinition) => void; onRemoveSection?: (section: SidebarSectionDefinition) => void; - renderTopLevelSectionHeaderActions?: SectionThreadTreeProps["renderTopLevelSectionHeaderActions"]; } function useWindowedThreadItems({ @@ -1775,7 +1728,6 @@ function SectionThreadTreeItems({ onCreateThreadInSection, onRenameSection, onRemoveSection, - renderTopLevelSectionHeaderActions, }: SectionThreadTreeItemsProps) { const { itemKeys, estimateRows, getNavigationEntries, alwaysMountedKeys } = useWindowedThreadItems({ @@ -1812,9 +1764,6 @@ function SectionThreadTreeItems({ onCreateThreadInSection={onCreateThreadInSection} onRenameSection={onRenameSection} onRemoveSection={onRemoveSection} - renderTopLevelSectionHeaderActions={ - renderTopLevelSectionHeaderActions - } sectionDnd={sectionDnd ?? undefined} /> ); @@ -1999,7 +1948,6 @@ export const ChronologicalSectionThreadSections = memo( onCreateThreadInSection, onRenameSection, onRemoveSection, - renderTopLevelSectionHeaderActions, onToggleThreadCollapsed, onToggleEnvironmentCollapsed, builtInSections, @@ -2128,7 +2076,6 @@ export const ChronologicalSectionThreadSections = memo( onCreateThreadInSection={onCreateThreadInSection} onRenameSection={onRenameSection} onRemoveSection={onRemoveSection} - renderTopLevelSectionHeaderActions={renderTopLevelSectionHeaderActions} /> ); @@ -2290,8 +2237,6 @@ function ProjectRowComponent({ collapsedThreadIds, collapsedEnvironmentIds, isLocalPathInvalid, - headerActions, - headerActionsOpen = false, onProjectSelect, onCreateProjectThread, onToggleProjectCollapsed, @@ -2304,8 +2249,7 @@ function ProjectRowComponent({ }: ProjectRowProps) { const [isDropdownActionsOpen, setIsDropdownActionsOpen] = useState(false); const [isContextActionsOpen, setIsContextActionsOpen] = useState(false); - const isActionsOpen = - isDropdownActionsOpen || isContextActionsOpen || headerActionsOpen; + const isActionsOpen = isDropdownActionsOpen || isContextActionsOpen; const projectThreads = useMemo( () => isCollapsed && threadListState.status === "ready" @@ -2328,19 +2272,9 @@ function ProjectRowComponent({ }, [draftThreadIds, isCollapsed, projectThreads, threadListState.status]); const projectActions = ( <> - {headerActions ? ( - - {headerActions} - - ) : null} {isLocalPathInvalid ? ( { event.stopPropagation(); onProjectSelect?.(); @@ -2369,34 +2303,13 @@ function ProjectRowComponent({ SIDEBAR_HOVER_ACTIONS_GAP_CLASS, )} > - - + + @@ -2530,8 +2443,6 @@ function areProjectRowPropsEqual( prev.isCollapsed !== next.isCollapsed || prev.compareThreads !== next.compareThreads || prev.isLocalPathInvalid !== next.isLocalPathInvalid || - prev.headerActions !== next.headerActions || - prev.headerActionsOpen !== next.headerActionsOpen || prev.onProjectSelect !== next.onProjectSelect || prev.onCreateProjectThread !== next.onCreateProjectThread || prev.onToggleProjectCollapsed !== next.onToggleProjectCollapsed || diff --git a/apps/app/src/components/sidebar/SectionGrouping.stories.tsx b/apps/app/src/components/sidebar/SectionGrouping.stories.tsx index ad1ede8cb6..5b30bf5985 100644 --- a/apps/app/src/components/sidebar/SectionGrouping.stories.tsx +++ b/apps/app/src/components/sidebar/SectionGrouping.stories.tsx @@ -90,8 +90,8 @@ const sectionThreads: ThreadListEntry[] = [ environmentId: "env_story_section", environmentName: "Section build", environmentBranchName: "bb/sidebar-sections", + environmentProviderId: "git-worktree", queuedWork: "none", - environmentWorkspaceDisplayKind: "managed-worktree", latestAttentionAt: 40, createdAt: 40, }), @@ -102,8 +102,8 @@ const sectionThreads: ThreadListEntry[] = [ environmentId: "env_story_section", environmentName: "Section build", environmentBranchName: "bb/sidebar-sections", + environmentProviderId: "git-worktree", queuedWork: "none", - environmentWorkspaceDisplayKind: "managed-worktree", hasPendingInteraction: true, latestAttentionAt: 30, createdAt: 30, diff --git a/apps/app/src/components/sidebar/SectionSidebar.tsx b/apps/app/src/components/sidebar/SectionSidebar.tsx index 12543de5d9..da9459a9fe 100644 --- a/apps/app/src/components/sidebar/SectionSidebar.tsx +++ b/apps/app/src/components/sidebar/SectionSidebar.tsx @@ -36,7 +36,7 @@ export function SectionSidebarRow({ to, }: { active: boolean; - children: ReactNode; + children?: ReactNode; label: string; to: string; }) { diff --git a/apps/app/src/components/sidebar/SidebarChildToggleChevron.tsx b/apps/app/src/components/sidebar/SidebarChildToggleChevron.tsx index 029aa74659..c582c03d2a 100644 --- a/apps/app/src/components/sidebar/SidebarChildToggleChevron.tsx +++ b/apps/app/src/components/sidebar/SidebarChildToggleChevron.tsx @@ -5,6 +5,7 @@ import { SIDEBAR_HOVER_ACTIONS_MOBILE_ALWAYS_VALUE, } from "@/components/ui/sidebar-hover-actions.js"; import { cn } from "@bb/shared-ui/lib/utils"; +import { SIDEBAR_CONTROL_STATE_CLASS } from "./sidebarRowClasses"; interface SidebarChildToggleChevronProps { isCollapsed: boolean; @@ -36,7 +37,8 @@ export function SidebarChildToggleChevron({ }} className={cn( revealOnHover ? SIDEBAR_HOVER_ACTIONS_CLASS : "pointer-events-auto", - "relative z-10 inline-flex size-5 shrink-0 cursor-pointer items-center justify-center rounded-md text-subtle-foreground outline-none ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2", + "relative z-10 inline-flex size-5 shrink-0 cursor-pointer items-center justify-center rounded-md outline-none ring-sidebar-ring focus-visible:ring-2", + SIDEBAR_CONTROL_STATE_CLASS, LIST_HOVER_TRANSITION, )} > diff --git a/apps/app/src/components/sidebar/SidebarHeaderControls.test.tsx b/apps/app/src/components/sidebar/SidebarHeaderControls.test.tsx new file mode 100644 index 0000000000..26a3cbd410 --- /dev/null +++ b/apps/app/src/components/sidebar/SidebarHeaderControls.test.tsx @@ -0,0 +1,199 @@ +// @vitest-environment jsdom + +import { + cleanup, + fireEvent, + render, + screen, + waitFor, +} from "@testing-library/react"; +import { createStore, Provider } from "jotai"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { TooltipProvider } from "@bb/shared-ui/tooltip"; +import { SIDEBAR_CONTROL_STATE_CLASS } from "./sidebarRowClasses"; +import { + SidebarHeaderActionsProvider, + SidebarHeaderControls, + SidebarSectionMenuItems, +} from "./SidebarHeaderControls"; +import { + sidebarChronologicalSortAtom, + sidebarOrganizationModeAtom, + sidebarSortDirectionAtom, +} from "./sidebarCollapsedAtoms"; + +const viewport = vi.hoisted(() => ({ compact: false })); +vi.mock("@bb/shared-ui/hooks/use-compact-viewport", () => ({ + useIsCompactViewport: () => viewport.compact, +})); + +afterEach(() => { + cleanup(); + viewport.compact = false; +}); + +function setup(label = "Pinned", section = false) { + const store = createStore(); + store.set(sidebarOrganizationModeAtom, "project"); + store.set(sidebarChronologicalSortAtom, "updated"); + store.set(sidebarSortDirectionAtom, "default"); + const newThread = vi.fn(); + const newProject = vi.fn(); + const newSection = vi.fn(); + render( + + + + + {section && ( + + )} + + + + , + ); + return { store, newThread, newProject, newSection }; +} + +async function openMenu(label = "Pinned") { + fireEvent.keyDown(screen.getByRole("button", { name: `${label} actions` }), { + key: "Enter", + }); + await screen.findByRole("menuitem", { name: "New project" }); +} + +async function openSubmenu(label: string) { + fireEvent.keyDown(screen.getByRole("menuitem", { name: label }), { + key: "ArrowRight", + }); +} + +describe("sidebar header controls", () => { + it("keeps the primary before overflow and applies the shared control state", async () => { + const { newThread } = setup(); + const primary = screen.getByRole("button", { + name: "New thread in Pinned", + }); + expect(primary.nextElementSibling?.getAttribute("aria-label")).toBe( + "Pinned actions", + ); + for (const control of [primary, primary.nextElementSibling]) { + for (const token of SIDEBAR_CONTROL_STATE_CLASS.split(" ")) { + expect(control?.classList.contains(token)).toBe(true); + } + expect(control?.classList.contains("hover:bg-sidebar-accent")).toBe( + false, + ); + expect(control?.classList.contains("hover:text-foreground")).toBe(false); + } + fireEvent.click(primary); + expect(newThread).toHaveBeenCalledOnce(); + await openMenu(); + expect(primary.nextElementSibling?.getAttribute("data-state")).toBe("open"); + }); + + it("preserves creation callbacks and separates section editing/removal", async () => { + const { newSection } = setup("Review", true); + await openMenu("Review"); + expect( + screen.getAllByRole("menuitem").map((item) => item.textContent), + ).toEqual([ + "New project", + "New section", + "Organize", + "Sort by", + "Rename", + "Remove", + ]); + expect(screen.getAllByRole("separator")).toHaveLength(3); + fireEvent.click(screen.getByRole("menuitem", { name: "New section" })); + expect(newSection).toHaveBeenCalledOnce(); + await waitFor(() => + expect( + screen.queryByRole("menuitem", { name: "New project" }), + ).toBeNull(), + ); + }); + + it("exposes an exclusive Organize choice and closes after selection", async () => { + const { store } = setup(); + await openMenu(); + await openSubmenu("Organize"); + const machine = await screen.findByRole("menuitemradio", { + name: "By machine", + }); + expect( + screen + .getByRole("menuitemradio", { name: "By project" }) + .getAttribute("aria-checked"), + ).toBe("true"); + fireEvent.click(machine); + expect(store.get(sidebarOrganizationModeAtom)).toBe("machine"); + await waitFor(() => + expect( + screen.queryByRole("menuitemradio", { name: "By machine" }), + ).toBeNull(), + ); + }); + + it("toggles sort direction without closing and resets direction for a different field", async () => { + const { store } = setup(); + await openMenu(); + await openSubmenu("Sort by"); + const updated = await screen.findByRole("menuitemradio", { + name: "Updated at, descending. Sort ascending", + }); + fireEvent.click(updated); + expect(store.get(sidebarSortDirectionAtom)).toBe("ascending"); + fireEvent.click( + screen.getByRole("menuitemradio", { + name: "Updated at, ascending. Sort descending", + }), + ); + expect(store.get(sidebarSortDirectionAtom)).toBe("descending"); + fireEvent.click( + screen.getByRole("menuitemradio", { name: "Alphabetical" }), + ); + expect(store.get(sidebarChronologicalSortAtom)).toBe("alpha"); + expect(store.get(sidebarSortDirectionAtom)).toBe("ascending"); + expect( + screen + .getByRole("menuitemradio", { + name: "Alphabetical, ascending. Sort descending", + }) + .getAttribute("aria-checked"), + ).toBe("true"); + }); + + it("announces compact sort direction and resets the nested page after closing", async () => { + viewport.compact = true; + const { store } = setup(); + fireEvent.click(screen.getByRole("button", { name: "Pinned actions" })); + fireEvent.click(await screen.findByRole("menuitem", { name: "Sort by" })); + fireEvent.click( + await screen.findByRole("menuitemradio", { + name: /Updated at\s*, descending\. Sort ascending/, + }), + ); + expect(store.get(sidebarSortDirectionAtom)).toBe("ascending"); + expect( + screen + .getByRole("menuitemradio", { + name: /Updated at\s*, ascending\. Sort descending/, + }) + .getAttribute("aria-checked"), + ).toBe("true"); + fireEvent.click(screen.getByRole("menuitem", { name: "Back" })); + fireEvent.click(screen.getByRole("menuitem", { name: "Organize" })); + fireEvent.click(screen.getByRole("menuitemradio", { name: "Custom" })); + expect(store.get(sidebarOrganizationModeAtom)).toBe("chronological"); + fireEvent.click(screen.getByRole("button", { name: "Pinned actions" })); + expect( + await screen.findByRole("menuitem", { name: "New project" }), + ).toBeTruthy(); + expect(screen.queryByRole("menuitem", { name: "Back" })).toBeNull(); + }); +}); diff --git a/apps/app/src/components/sidebar/SidebarHeaderControls.tsx b/apps/app/src/components/sidebar/SidebarHeaderControls.tsx new file mode 100644 index 0000000000..3cbdc770f5 --- /dev/null +++ b/apps/app/src/components/sidebar/SidebarHeaderControls.tsx @@ -0,0 +1,281 @@ +import { createContext, useContext, useState, type ReactNode } from "react"; +import { useAtom } from "jotai"; +import { Button } from "@bb/shared-ui/button"; +import { Icon } from "@bb/shared-ui/icon"; +import { COARSE_POINTER_ICON_SIZE_CLASS } from "@bb/shared-ui/coarse-pointer-sizing"; +import { useIsCompactViewport } from "@bb/shared-ui/hooks/use-compact-viewport"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuGroup, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, + DropdownMenuSub, + DropdownMenuSubTrigger, + DropdownMenuSubContent, + DropdownMenuPortal, +} from "@bb/shared-ui/dropdown-menu"; +import { + sidebarOrganizationModeAtom, + sidebarChronologicalSortAtom, + sidebarSortDirectionAtom, +} from "./sidebarCollapsedAtoms"; +import { SidebarControlButton, SidebarRowControls } from "./SidebarRowControls"; +import { SIDEBAR_CONTROL_BUTTON_CLASS } from "./sidebarRowClasses"; + +interface HeaderCreationActions { + onNewProject?: () => void; + onNewSection?: () => void; + isCreatingProject?: boolean; + isCreatingSection?: boolean; +} + +const HeaderCreationContext = createContext({}); +export const SidebarHeaderActionsProvider = HeaderCreationContext.Provider; + +const SIDEBAR_ORGANIZE_OPTIONS = [ + { label: "By project", mode: "project" }, + { label: "By machine", mode: "machine" }, + { label: "Custom", mode: "chronological" }, +] as const; + +const SIDEBAR_SORT_OPTIONS = [ + { label: "Updated at", sort: "updated", direction: "descending" }, + { label: "Created at", sort: "created", direction: "descending" }, + { label: "Alphabetical", sort: "alpha", direction: "ascending" }, +] as const; + +function SidebarViewItems({ page }: { page: "organize" | "sort" }) { + const [organization, setOrganization] = useAtom(sidebarOrganizationModeAtom); + const [sort, setSort] = useAtom(sidebarChronologicalSortAtom); + const [savedDirection, setDirection] = useAtom(sidebarSortDirectionAtom); + const selectedSort = sort === "none" ? "updated" : sort; + return ( + + {page === "organize" + ? SIDEBAR_ORGANIZE_OPTIONS.map((option) => ( + { + setOrganization(option.mode); + }} + > + {option.label} + + {organization === option.mode && ( + + )} + + + )) + : SIDEBAR_SORT_OPTIONS.map((option) => { + const selected = selectedSort === option.sort; + const direction = + savedDirection === "default" ? option.direction : savedDirection; + const nextDirection = selected + ? direction === "ascending" + ? "descending" + : "ascending" + : option.direction; + return ( + { + event.preventDefault(); + setSort(option.sort); + setDirection(nextDirection); + }} + > + {option.label} + {selected && ( + + , {direction}. Sort {nextDirection} + + )} + + {selected && ( + + )} + + + ); + })} + + ); +} + +export function SidebarHeaderControls({ + label, + onNewThread, + children, + open, + onOpenChange, +}: { + label: string; + onNewThread?: () => void; + children?: ReactNode; + open?: boolean; + onOpenChange?: (open: boolean) => void; +}) { + const creation = useContext(HeaderCreationContext); + const compact = useIsCompactViewport(); + const [page, setPage] = useState<"organize" | "sort" | null>(null); + const changeOpen = (next: boolean) => { + if (!next) setPage(null); + onOpenChange?.(next); + }; + return ( + onNewThread?.()} + disabled={!onNewThread} + /> + } + > + + + + + + {compact && page ? ( + <> + { + event.preventDefault(); + setPage(null); + }} + > + + Back + + + + + ) : ( + <> + + + New project + + + + New section + + + {( + [ + { page: "organize", label: "Organize", icon: "Layers" }, + { page: "sort", label: "Sort by", icon: "Sort" }, + ] as const + ).map((item) => + compact ? ( + { + event.preventDefault(); + setPage(item.page); + }} + > + + {item.label} + + + ) : ( + + + + {item.label} + + + + + + + + ), + )} + {children && ( + <> + + {children} + + )} + + )} + + + + ); +} + +export function SidebarSectionMenuItems({ + onRename, + onRemove, +}: { + onRename?: () => void; + onRemove?: () => void; +}) { + return ( + <> + {onRename && ( + + + Rename + + )} + {onRemove && ( + <> + + + + Remove + + + )} + + ); +} diff --git a/apps/app/src/components/sidebar/SidebarNavigationRegion.test.tsx b/apps/app/src/components/sidebar/SidebarNavigationRegion.test.tsx index 1f4b6d1b82..7c2dc52e41 100644 --- a/apps/app/src/components/sidebar/SidebarNavigationRegion.test.tsx +++ b/apps/app/src/components/sidebar/SidebarNavigationRegion.test.tsx @@ -34,7 +34,7 @@ vi.mock("@/components/commands/AppCommandProvider", () => ({ useIsAppCommandModifierHeld: () => false, })); vi.mock("@/components/plugin/PluginNavSidebarItems", () => ({ - ExtensionsNavSidebarItem: () =>
Extensions
, + ResourceNavSidebarItem: () =>
, PluginNavSidebarItems: ({ builtInEntries = [], entries = [], @@ -69,6 +69,7 @@ vi.mock("./usePaneContentSplitDrag", () => ({ })); function Replacement({ + activeItemId, experimental_Original: Original, experimental_activate, items, @@ -83,6 +84,7 @@ function Replacement({ + + {label} + + ); +} diff --git a/apps/app/src/components/sidebar/SidebarSectionRow.test.tsx b/apps/app/src/components/sidebar/SidebarSectionRow.test.tsx index 1619ee02df..f9a7a1b32b 100644 --- a/apps/app/src/components/sidebar/SidebarSectionRow.test.tsx +++ b/apps/app/src/components/sidebar/SidebarSectionRow.test.tsx @@ -11,6 +11,10 @@ import { setPluginThreadRowStatus, } from "@/lib/plugin-thread-row-status"; import { SidebarSectionRow } from "./SidebarSectionRow"; +import { + SIDEBAR_CONTROL_STATE_CLASS, + SIDEBAR_GROUP_TEXT_CLASS, +} from "./sidebarRowClasses"; afterEach(() => { cleanup(); @@ -45,6 +49,13 @@ describe("SidebarSectionRow", () => { Node.DOCUMENT_POSITION_FOLLOWING, ).not.toBe(0); expect(row?.style.paddingLeft).toBe("32px"); + expect(row?.classList.contains(SIDEBAR_GROUP_TEXT_CLASS)).toBe(true); + for (const token of SIDEBAR_CONTROL_STATE_CLASS.split(" ")) { + expect(disclosure.classList.contains(token)).toBe(true); + } + expect(disclosure.classList.contains("hover:bg-sidebar-accent")).toBe( + false, + ); }); it("rolls hidden split threads up to the collapsed section row", () => { diff --git a/apps/app/src/components/sidebar/SidebarSectionRow.tsx b/apps/app/src/components/sidebar/SidebarSectionRow.tsx index 294ecce1f3..38a214e989 100644 --- a/apps/app/src/components/sidebar/SidebarSectionRow.tsx +++ b/apps/app/src/components/sidebar/SidebarSectionRow.tsx @@ -1,3 +1,7 @@ +import { + SidebarHeaderControls, + SidebarSectionMenuItems, +} from "./SidebarHeaderControls"; import { memo, useCallback, @@ -6,19 +10,9 @@ import { type MouseEvent, type MouseEventHandler, } from "react"; -import { Button } from "@bb/shared-ui/button"; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, -} from "@bb/shared-ui/dropdown-menu"; -import { Icon } from "@bb/shared-ui/icon"; import { SidebarStickyTier } from "@/components/ui/sidebar.js"; -import { Tooltip, TooltipContent, TooltipTrigger } from "@bb/shared-ui/tooltip"; import { COARSE_POINTER_COMPACT_ROW_HEIGHT_CLASS, - COARSE_POINTER_ICON_SIZE_CLASS, COARSE_POINTER_ROW_ACTION_SIZE_CLASS, } from "@bb/shared-ui/coarse-pointer-sizing"; import { LIST_HOVER_TRANSITION } from "@bb/shared-ui/motion"; @@ -32,9 +26,8 @@ import { import { cn } from "@bb/shared-ui/lib/utils"; import type { CollapsedChildActivity } from "@bb/client-core"; import { - SIDEBAR_MORE_ACTION_TRIGGER_CLASS, SIDEBAR_ROW_BASE_CLASS, - SIDEBAR_ROW_STATIC_STATE_CLASS, + SIDEBAR_GROUP_TEXT_CLASS, getSidebarThreadRowPaddingLeft, } from "./sidebarRowClasses"; import { SidebarChildToggleChevron } from "./SidebarChildToggleChevron"; @@ -122,7 +115,7 @@ function SidebarSectionRowComponent({ stickyLevel === undefined && "relative", SIDEBAR_ROW_BASE_CLASS, LIST_HOVER_TRANSITION, - SIDEBAR_ROW_STATIC_STATE_CLASS, + SIDEBAR_GROUP_TEXT_CLASS, COARSE_POINTER_COMPACT_ROW_HEIGHT_CLASS, dragBindings && !dragBindings.disabled && "select-none", isDropTargetActive && "bg-sidebar-accent text-sidebar-accent-foreground", @@ -197,67 +190,16 @@ function SidebarSectionRowComponent({ {renderRollupIndicator()} ) : null} - {hasMenuActions ? ( - - - - - - {onRename ? ( - - - ) : null} - {onRemove ? ( - - - ) : null} - - - ) : null} - {onCreateThread ? ( - - - - - New thread - - ) : null} + + + ) : showRollupIndicator ? ( diff --git a/apps/app/src/components/sidebar/SidebarStatusNotifications.stories.tsx b/apps/app/src/components/sidebar/SidebarStatusNotifications.stories.tsx index ade01af63a..3589a26c4e 100644 --- a/apps/app/src/components/sidebar/SidebarStatusNotifications.stories.tsx +++ b/apps/app/src/components/sidebar/SidebarStatusNotifications.stories.tsx @@ -286,8 +286,8 @@ function makeWorktreeComboThreads(combo: readonly RollupSignal[]) { environmentId, environmentHostId: HOST_IDS.local, environmentBranchName: `bb/status-${key}`, + environmentProviderId: "git-worktree", queuedWork: "none", - environmentWorkspaceDisplayKind: "managed-worktree", } satisfies Partial; return { @@ -326,8 +326,8 @@ function makeParentRollupThreads(combo: readonly RollupSignal[]) { const parent = makeThread(`thr_parent_${key}`, "Collapsed parent", { environmentHostId: HOST_IDS.local, environmentBranchName: BRANCH_NAMES.default, + environmentProviderId: "git-worktree", queuedWork: "none", - environmentWorkspaceDisplayKind: "managed-worktree", }); return { diff --git a/apps/app/src/components/sidebar/SidebarViewOptionsMenu.stories.tsx b/apps/app/src/components/sidebar/SidebarViewOptionsMenu.stories.tsx deleted file mode 100644 index 3754506bd5..0000000000 --- a/apps/app/src/components/sidebar/SidebarViewOptionsMenu.stories.tsx +++ /dev/null @@ -1,63 +0,0 @@ -import { useMemo } from "react"; -import { createStore, Provider as JotaiProvider, useAtomValue } from "jotai"; -import { StoryCard, StoryRow } from "../../../.ladle/story-card"; -import { SidebarDisplayOptionsMenu } from "./ProjectList"; -import { - sidebarChronologicalSortAtom, - sidebarOrganizationModeAtom, -} from "./sidebarCollapsedAtoms"; - -export default { - title: "sidebar/View options menu", -}; - -function StateReadout() { - const organizationMode = useAtomValue(sidebarOrganizationModeAtom); - const sort = useAtomValue(sidebarChronologicalSortAtom); - return ( -
-
organize
-
{organizationMode}
-
sort
-
{sort}
-
- ); -} - -function InteractiveMenu() { - const store = useMemo(() => { - const next = createStore(); - next.set(sidebarOrganizationModeAtom, "project"); - next.set(sidebarChronologicalSortAtom, "updated"); - return next; - }, []); - - return ( - -
-
- - Projects - -
- -
-
- -
-
- ); -} - -export function Overview() { - return ( - - - - - - ); -} diff --git a/apps/app/src/components/sidebar/ThreadRow.stories.tsx b/apps/app/src/components/sidebar/ThreadRow.stories.tsx index c3e8dcba41..afe4937e59 100644 --- a/apps/app/src/components/sidebar/ThreadRow.stories.tsx +++ b/apps/app/src/components/sidebar/ThreadRow.stories.tsx @@ -598,51 +598,6 @@ export function Overview() { /> - - - - - - - - - - - - - - - ) : ( @@ -819,25 +818,21 @@ function ThreadRowComponent({ "absolute inset-y-0 right-0 z-10 flex items-center justify-end max-md:pointer-coarse:hidden", )} > - - + + } + > + +
diff --git a/apps/app/src/components/sidebar/TopLevelSidebarSection.tsx b/apps/app/src/components/sidebar/TopLevelSidebarSection.tsx index 2bfdcae9ed..c16fea3faa 100644 --- a/apps/app/src/components/sidebar/TopLevelSidebarSection.tsx +++ b/apps/app/src/components/sidebar/TopLevelSidebarSection.tsx @@ -23,7 +23,11 @@ import { SIDEBAR_HOVER_ACTIONS_ROW_CLASS, } from "@/components/ui/sidebar-hover-actions.js"; import type { ConsumeDragClickSuppression } from "@/components/ui/use-drag-click-suppression"; -import { SIDEBAR_STANDARD_ROW_PADDING_CLASS } from "./sidebarRowClasses"; +import { + SIDEBAR_STANDARD_ROW_PADDING_CLASS, + SIDEBAR_CONTROL_STATE_CLASS, + SIDEBAR_GROUP_TEXT_CLASS, +} from "./sidebarRowClasses"; import type { SidebarSortableDragBindings } from "./sortableMotion"; import { NO_COLLAPSED_CHILD_ACTIVITY, @@ -53,6 +57,7 @@ export interface TopLevelSidebarSectionProps { label: string; children: ReactNode; sectionId?: string; + stickyHeader?: boolean; actions?: ReactNode; actionsAlwaysVisible?: boolean; actionsMobileAlways?: boolean; @@ -71,6 +76,7 @@ export function TopLevelSidebarSection({ label, children, sectionId, + stickyHeader = true, actions, actionsAlwaysVisible = false, actionsMobileAlways = false, @@ -168,8 +174,10 @@ export function TopLevelSidebarSection({ className={cn( SIDEBAR_HOVER_ACTIONS_ROW_CLASS, CHROME_SECTION_LABEL_CLASS, + SIDEBAR_GROUP_TEXT_CLASS, SIDEBAR_STANDARD_ROW_PADDING_CLASS, "rounded-md pr-0 transition-colors", + !stickyHeader && "relative top-auto", dragBindings && !dragBindings.disabled && "select-none", )} {...dragBindings?.attributes} @@ -193,7 +201,8 @@ export function TopLevelSidebarSection({ } className={cn( !collapseControl.isCollapsed && SIDEBAR_HOVER_ACTIONS_CLASS, - "relative z-20 inline-flex size-6 shrink-0 cursor-pointer items-center justify-center rounded-md text-subtle-foreground outline-none ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2", + "relative z-20 inline-flex size-6 shrink-0 cursor-pointer items-center justify-center rounded-md outline-none ring-sidebar-ring focus-visible:ring-2", + SIDEBAR_CONTROL_STATE_CLASS, LIST_HOVER_TRANSITION, )} onClick={handleCollapseControlClick} @@ -214,7 +223,7 @@ export function TopLevelSidebarSection({ {actions || collapsedActivityIndicator ? ( {collapsedActivityIndicator} diff --git a/apps/app/src/components/sidebar/sidebarCollapsedAtoms.migration.test.ts b/apps/app/src/components/sidebar/sidebarCollapsedAtoms.migration.test.ts deleted file mode 100644 index a2fa21f621..0000000000 --- a/apps/app/src/components/sidebar/sidebarCollapsedAtoms.migration.test.ts +++ /dev/null @@ -1,50 +0,0 @@ -// @vitest-environment jsdom - -import { createStore } from "jotai"; -import { afterEach, describe, expect, it, vi } from "vitest"; - -afterEach(() => { - window.localStorage.clear(); - vi.resetModules(); -}); - -describe("sidebar section preference migration", () => { - it("preserves manual order and collapsed groups from folder-era storage", async () => { - window.localStorage.setItem( - "bb.sidebar.folderSectionOrder", - JSON.stringify(["threads", "folder:release", "folders", "pinned"]), - ); - window.localStorage.setItem( - "bb.sidebar.collapsedFolders", - JSON.stringify(["project-a::fld_release"]), - ); - - const { - sidebarCollapsedThreadSectionsAtom, - sidebarManualSectionOrderAtom, - } = await import("./sidebarCollapsedAtoms"); - const store = createStore(); - - expect(store.get(sidebarManualSectionOrderAtom)).toEqual([ - "threads", - "section:release", - "sections", - "pinned", - ]); - expect(store.get(sidebarCollapsedThreadSectionsAtom)).toEqual([ - "project-a::fld_release", - ]); - expect(window.localStorage.getItem("bb.sidebar.manualSectionOrder")).toBe( - JSON.stringify(["threads", "section:release", "sections", "pinned"]), - ); - expect( - window.localStorage.getItem("bb.sidebar.collapsedThreadSections"), - ).toBe(JSON.stringify(["project-a::fld_release"])); - expect( - window.localStorage.getItem("bb.sidebar.folderSectionOrder"), - ).toBeNull(); - expect( - window.localStorage.getItem("bb.sidebar.collapsedFolders"), - ).toBeNull(); - }); -}); diff --git a/apps/app/src/components/sidebar/sidebarCollapsedAtoms.ts b/apps/app/src/components/sidebar/sidebarCollapsedAtoms.ts index 9a736e7f51..165eb0a1b1 100644 --- a/apps/app/src/components/sidebar/sidebarCollapsedAtoms.ts +++ b/apps/app/src/components/sidebar/sidebarCollapsedAtoms.ts @@ -1,182 +1,60 @@ -import { atomWithStorage } from "jotai/utils"; -import type { CollapsibleSidebarSectionId } from "@bb/client-core"; -import { - createJsonLocalStorage, - type SyncStorage, -} from "@/lib/browser-storage"; - -const COLLAPSED_PROJECTS_STORAGE_KEY = "bb.sidebar.collapsedProjects"; -const COLLAPSED_THREADS_STORAGE_KEY = "bb.sidebar.collapsedThreads"; -const COLLAPSED_ENVIRONMENTS_STORAGE_KEY = "bb.sidebar.collapsedEnvironments"; -const COLLAPSED_SIDEBAR_SECTIONS_STORAGE_KEY = "bb.sidebar.collapsedSections"; -const SIDEBAR_SECTION_ORDER_STORAGE_KEY = "bb.sidebar.sectionOrder"; -const SIDEBAR_MANUAL_SECTION_ORDER_STORAGE_KEY = - "bb.sidebar.manualSectionOrder"; -const LEGACY_SIDEBAR_FOLDER_SECTION_ORDER_STORAGE_KEY = - "bb.sidebar.folderSectionOrder"; -const SIDEBAR_MACHINE_SECTION_ORDER_STORAGE_KEY = - "bb.sidebar.machineSectionOrder"; -export const SIDEBAR_ORGANIZATION_MODE_STORAGE_KEY = - "bb.sidebar.organizationMode"; -const CHRONOLOGICAL_SORT_STORAGE_KEY = "bb.sidebar.chronologicalSort"; -const COLLAPSED_THREAD_SECTIONS_STORAGE_KEY = - "bb.sidebar.collapsedThreadSections"; -const LEGACY_COLLAPSED_FOLDERS_STORAGE_KEY = "bb.sidebar.collapsedFolders"; -const COLLAPSED_MACHINES_STORAGE_KEY = "bb.sidebar.collapsedMachines"; +import type { + SidebarChronologicalSort, + SidebarOrganizationMode, +} from "@bb/domain"; +import { createSyncedPreferenceAtom } from "@/lib/ui-preferences/synced-preference-atom"; export type { CollapsibleSidebarSectionId, SidebarSectionId, } from "@bb/client-core"; -export type SidebarOrganizationMode = "project" | "chronological" | "machine"; -export type SidebarChronologicalSort = "updated" | "created" | "alpha" | "none"; - -const DEFAULT_SIDEBAR_SECTION_ORDER: readonly string[] = [ - "pinned", - "projects", - "threads", -]; - -function createLegacyMigratingStringArrayStorage( - legacyKey: string, - migrateItem: (item: string) => string, -): SyncStorage { - const storage = createJsonLocalStorage(); - - return { - getItem(key, initialValue) { - if ( - typeof window === "undefined" || - window.localStorage.getItem(key) !== null - ) { - return storage.getItem(key, initialValue); - } +export type { SidebarChronologicalSort, SidebarOrganizationMode }; - const legacyJson = window.localStorage.getItem(legacyKey); - if (legacyJson === null) { - return initialValue; - } - let parsedLegacyValue: unknown; - try { - parsedLegacyValue = JSON.parse(legacyJson); - } catch { - storage.removeItem(legacyKey); - return initialValue; - } - if (!Array.isArray(parsedLegacyValue)) { - storage.removeItem(legacyKey); - return initialValue; - } - const migratedValue = parsedLegacyValue - .filter((item): item is string => typeof item === "string") - .map(migrateItem); - storage.setItem(key, migratedValue); - storage.removeItem(legacyKey); - return migratedValue; - }, - setItem: storage.setItem, - removeItem(key) { - storage.removeItem(key); - storage.removeItem(legacyKey); - }, - subscribe: storage.subscribe, - }; -} - -const sidebarManualSectionOrderStorage = - createLegacyMigratingStringArrayStorage( - LEGACY_SIDEBAR_FOLDER_SECTION_ORDER_STORAGE_KEY, - (item) => - item === "folders" - ? "sections" - : item.startsWith("folder:") - ? `section:${item.slice("folder:".length)}` - : item, - ); - -const collapsedThreadSectionsStorage = createLegacyMigratingStringArrayStorage( - LEGACY_COLLAPSED_FOLDERS_STORAGE_KEY, - (item) => item, +export const collapsedProjectIdsAtom = createSyncedPreferenceAtom( + "sidebar.collapsedProjects", ); -export const collapsedProjectIdsAtom = atomWithStorage( - COLLAPSED_PROJECTS_STORAGE_KEY, - [], - createJsonLocalStorage(), - { getOnInit: true }, +export const collapsedThreadIdsAtom = createSyncedPreferenceAtom( + "sidebar.collapsedThreads", ); -export const collapsedThreadIdsAtom = atomWithStorage( - COLLAPSED_THREADS_STORAGE_KEY, - [], - createJsonLocalStorage(), - { getOnInit: true }, +export const collapsedEnvironmentIdsAtom = createSyncedPreferenceAtom( + "sidebar.collapsedEnvironments", ); -export const collapsedEnvironmentIdsAtom = atomWithStorage( - COLLAPSED_ENVIRONMENTS_STORAGE_KEY, - [], - createJsonLocalStorage(), - { getOnInit: true }, +export const collapsedSidebarSectionIdsAtom = createSyncedPreferenceAtom( + "sidebar.collapsedSections", ); -export const collapsedSidebarSectionIdsAtom = atomWithStorage< - CollapsibleSidebarSectionId[] ->( - COLLAPSED_SIDEBAR_SECTIONS_STORAGE_KEY, - [], - createJsonLocalStorage(), - { getOnInit: true }, +export const sidebarSectionOrderAtom = createSyncedPreferenceAtom( + "sidebar.sectionOrder", ); -export const sidebarSectionOrderAtom = atomWithStorage( - SIDEBAR_SECTION_ORDER_STORAGE_KEY, - [...DEFAULT_SIDEBAR_SECTION_ORDER], - createJsonLocalStorage(), - { getOnInit: true }, +export const sidebarManualSectionOrderAtom = createSyncedPreferenceAtom( + "sidebar.manualSectionOrder", ); -export const sidebarManualSectionOrderAtom = atomWithStorage( - SIDEBAR_MANUAL_SECTION_ORDER_STORAGE_KEY, - ["pinned", "sections", "threads"], - sidebarManualSectionOrderStorage, - { getOnInit: true }, +export const sidebarMachineSectionOrderAtom = createSyncedPreferenceAtom( + "sidebar.machineSectionOrder", ); -export const sidebarMachineSectionOrderAtom = atomWithStorage( - SIDEBAR_MACHINE_SECTION_ORDER_STORAGE_KEY, - ["pinned", "machines", "threads"], - createJsonLocalStorage(), - { getOnInit: true }, +export const sidebarOrganizationModeAtom = createSyncedPreferenceAtom( + "sidebar.organizationMode", ); -export const sidebarOrganizationModeAtom = - atomWithStorage( - SIDEBAR_ORGANIZATION_MODE_STORAGE_KEY, - "project", - createJsonLocalStorage(), - { getOnInit: true }, - ); +export const sidebarChronologicalSortAtom = createSyncedPreferenceAtom( + "sidebar.chronologicalSort", +); -export const sidebarChronologicalSortAtom = - atomWithStorage( - CHRONOLOGICAL_SORT_STORAGE_KEY, - "updated", - createJsonLocalStorage(), - { getOnInit: true }, - ); +export const sidebarSortDirectionAtom = createSyncedPreferenceAtom( + "sidebar.sortDirection", +); -export const sidebarCollapsedThreadSectionsAtom = atomWithStorage( - COLLAPSED_THREAD_SECTIONS_STORAGE_KEY, - [], - collapsedThreadSectionsStorage, - { getOnInit: true }, +export const sidebarCollapsedThreadSectionsAtom = createSyncedPreferenceAtom( + "sidebar.collapsedThreadSections", ); -export const sidebarCollapsedMachinesAtom = atomWithStorage( - COLLAPSED_MACHINES_STORAGE_KEY, - [], - createJsonLocalStorage(), - { getOnInit: true }, +export const sidebarCollapsedMachinesAtom = createSyncedPreferenceAtom( + "sidebar.collapsedMachines", ); diff --git a/apps/app/src/components/sidebar/sidebarNavigationItems.ts b/apps/app/src/components/sidebar/sidebarNavigationItems.ts index 464d76a3b9..9af81a9eba 100644 --- a/apps/app/src/components/sidebar/sidebarNavigationItems.ts +++ b/apps/app/src/components/sidebar/sidebarNavigationItems.ts @@ -4,11 +4,25 @@ import type { ExperimentalSidebarNavigationShortcut, } from "@get-bb/plugin-sdk"; import type { PluginNavPanelSlot } from "@/lib/plugin-slots"; -import { getPluginPanelRoutePath, isToolsRoutePath } from "@/lib/route-paths"; +import { + getPluginPanelRoutePath, + getPluginsRoutePath, + getSkillsRoutePath, + isToolsRoutePath, +} from "@/lib/route-paths"; export const NEW_THREAD_NAVIGATION_ITEM_ID = "new-thread"; export const SEARCH_THREADS_NAVIGATION_ITEM_ID = "search-threads"; -export const EXTENSIONS_NAVIGATION_ITEM_ID = "extensions"; +export const PLUGINS_NAVIGATION_ITEM_ID = "extensions"; +export const SKILLS_NAVIGATION_ITEM_ID = "skills"; + +export function getResourceNavigationItemRoutePath( + itemId: string, +): string | null { + if (itemId === PLUGINS_NAVIGATION_ITEM_ID) return getPluginsRoutePath(); + if (itemId === SKILLS_NAVIGATION_ITEM_ID) return getSkillsRoutePath(); + return null; +} export function getPluginPanelNavigationItemId( panel: Pick, @@ -24,7 +38,7 @@ interface CreateSidebarNavigationItemsOptions { newThreadShortcut: ExperimentalSidebarNavigationShortcut | null; searchThreadsDisabled: boolean; searchThreadsShortcut: ExperimentalSidebarNavigationShortcut | null; - showExtensions: boolean; + showResourceWorkspaces: boolean; splitPropsFor( action: ExperimentalSidebarNavigationAction, label: string, @@ -37,12 +51,12 @@ export function createSidebarNavigationItems({ newThreadShortcut, searchThreadsDisabled, searchThreadsShortcut, - showExtensions, + showResourceWorkspaces, splitPropsFor, }: CreateSidebarNavigationItemsOptions): readonly ExperimentalSidebarNavigationItem[] { const newThreadAction = { kind: "new-thread" } as const; const searchAction = { kind: "search-threads" } as const; - const extensionsAction = { kind: "open-extensions" } as const; + const resourceWorkspaceAction = { kind: "open-extensions" } as const; return [ { id: NEW_THREAD_NAVIGATION_ITEM_ID, @@ -62,13 +76,22 @@ export function createSidebarNavigationItems({ shortcut: searchThreadsShortcut, experimental_splitProps: {}, }, - ...(showExtensions + ...(showResourceWorkspaces ? [ { - id: EXTENSIONS_NAVIGATION_ITEM_ID, - label: "Extensions", + id: PLUGINS_NAVIGATION_ITEM_ID, + label: "Plugins", + icon: { kind: "host", name: "extensions" }, + action: resourceWorkspaceAction, + isDisabled: false, + shortcut: null, + experimental_splitProps: {}, + } satisfies ExperimentalSidebarNavigationItem, + { + id: SKILLS_NAVIGATION_ITEM_ID, + label: "Skills", icon: { kind: "host", name: "extensions" }, - action: extensionsAction, + action: resourceWorkspaceAction, isDisabled: false, shortcut: null, experimental_splitProps: {}, @@ -109,9 +132,12 @@ export function resolveActiveSidebarNavigationItemId({ }): string | null { if (pathname === "/") return NEW_THREAD_NAVIGATION_ITEM_ID; if (isToolsRoutePath(pathname)) { - return items.some((item) => item.id === EXTENSIONS_NAVIGATION_ITEM_ID) - ? EXTENSIONS_NAVIGATION_ITEM_ID - : null; + const itemId = + pathname === getSkillsRoutePath() || + pathname.startsWith(`${getSkillsRoutePath()}/`) + ? SKILLS_NAVIGATION_ITEM_ID + : PLUGINS_NAVIGATION_ITEM_ID; + return items.some((item) => item.id === itemId) ? itemId : null; } for (const panel of navPanels) { const path = getPluginPanelRoutePath({ @@ -128,7 +154,7 @@ export function resolveActiveSidebarNavigationItemId({ export interface SidebarNavigationActivationHandlers { newThread(openInSplit: boolean): void; searchThreads(): void; - openExtensions(): void; + openResourceWorkspace(itemId: string): void; openPluginPanel( action: Extract< ExperimentalSidebarNavigationAction, @@ -155,7 +181,7 @@ export function activateSidebarNavigationItem( handlers.searchThreads(); return; case "open-extensions": - handlers.openExtensions(); + handlers.openResourceWorkspace(item.id); return; case "open-plugin-panel": handlers.openPluginPanel(item.action, openInSplit); diff --git a/apps/app/src/components/sidebar/sidebarNavigationProvider.ts b/apps/app/src/components/sidebar/sidebarNavigationProvider.ts index faab9ce1e9..a697e81fcc 100644 --- a/apps/app/src/components/sidebar/sidebarNavigationProvider.ts +++ b/apps/app/src/components/sidebar/sidebarNavigationProvider.ts @@ -1,18 +1,14 @@ import { useAtomValue } from "jotai"; -import { - createReplacementPreferenceAtom, - resolvePreferredReplacement, -} from "@/lib/plugin-replacement-preference"; +import { resolvePreferredReplacement } from "@/lib/plugin-replacement-preference"; +import { createSyncedPreferenceAtom } from "@/lib/ui-preferences/synced-preference-atom"; import type { ResolvedReplacement } from "@/lib/plugin-slot-resolvers"; import { usePluginSlots, type ExperimentalSidebarNavigationSlot, } from "@/lib/plugin-slots"; -const SIDEBAR_NAVIGATION_PROVIDER_STORAGE_KEY = "bb.sidebar.navigationProvider"; - -export const sidebarNavigationProviderAtom = createReplacementPreferenceAtom( - SIDEBAR_NAVIGATION_PROVIDER_STORAGE_KEY, +export const sidebarNavigationProviderAtom = createSyncedPreferenceAtom( + "sidebar.navigationProvider", ); export function useSidebarNavigationReplacement(): ResolvedReplacement { diff --git a/apps/app/src/components/sidebar/sidebarRowClasses.ts b/apps/app/src/components/sidebar/sidebarRowClasses.ts index 27174ab4de..2430ed2294 100644 --- a/apps/app/src/components/sidebar/sidebarRowClasses.ts +++ b/apps/app/src/components/sidebar/sidebarRowClasses.ts @@ -1,4 +1,7 @@ -import { COARSE_POINTER_DOT_SIZE_CLASS } from "@bb/shared-ui/coarse-pointer-sizing"; +import { + COARSE_POINTER_DOT_SIZE_CLASS, + COARSE_POINTER_ROW_ACTION_SIZE_CLASS, +} from "@bb/shared-ui/coarse-pointer-sizing"; import { CONTEXT_SELECTION_SURFACE_CLASS } from "@/components/ui/context-selection"; export const SIDEBAR_ROW_BASE_CLASS = @@ -22,6 +25,20 @@ const SIDEBAR_THREAD_ROW_GLYPH_CENTER_OFFSET_PX = 8; export const SIDEBAR_STANDARD_ROW_PADDING_CLASS = "pl-2"; +export const SIDEBAR_ROW_TEXT_CLASS = "text-sidebar-foreground"; + +export const SIDEBAR_GROUP_TEXT_CLASS = "text-muted-foreground"; + +export const SIDEBAR_CONTROL_TONE_CLASS = + "text-subtle-foreground hover:text-muted-foreground focus-visible:text-muted-foreground data-[state=open]:text-muted-foreground"; + +export const SIDEBAR_CONTROL_STATE_CLASS = `${SIDEBAR_CONTROL_TONE_CLASS} hover:bg-state-hover focus-visible:bg-state-hover active:bg-state-active data-[state=open]:bg-state-active data-[state=open]:hover:bg-state-active data-[state=open]:focus-visible:bg-state-active`; + +export const SIDEBAR_CONTROL_BUTTON_CLASS = `${COARSE_POINTER_ROW_ACTION_SIZE_CLASS} ${SIDEBAR_CONTROL_STATE_CLASS} relative m-0 shrink-0 cursor-pointer rounded-md p-0 outline-none ring-sidebar-ring focus-visible:ring-2`; + +export const SIDEBAR_CONTROL_PAIR_SIZE_CLASS = + "h-7 w-[3.625rem] max-md:pointer-coarse:h-9 max-md:pointer-coarse:w-[4.625rem]"; + export function getSidebarThreadRowPaddingLeft(depth: number): number { return ( SIDEBAR_THREAD_ROW_BASE_PADDING_PX + @@ -36,13 +53,11 @@ export function getSidebarThreadGroupLineLeft(depth: number): number { ); } -export const SIDEBAR_ROW_INTERACTIVE_STATE_CLASS = - "cursor-pointer text-sidebar-foreground/85 dark:text-sidebar-foreground hover:bg-sidebar-accent hover:text-sidebar-accent-foreground"; +export const SIDEBAR_ROW_INTERACTIVE_STATE_CLASS = `cursor-pointer ${SIDEBAR_ROW_TEXT_CLASS} hover:bg-sidebar-accent hover:text-sidebar-accent-foreground`; -export const SIDEBAR_ROW_STATIC_STATE_CLASS = - "text-sidebar-foreground/85 dark:text-sidebar-foreground"; +export const SIDEBAR_ROW_STATIC_STATE_CLASS = SIDEBAR_ROW_TEXT_CLASS; -export const SIDEBAR_ROW_SELECTED_STATE_CLASS = `${CONTEXT_SELECTION_SURFACE_CLASS} bb-sidebar-selected-row text-sidebar-foreground`; +export const SIDEBAR_ROW_SELECTED_STATE_CLASS = `${CONTEXT_SELECTION_SURFACE_CLASS} bb-sidebar-selected-row ${SIDEBAR_ROW_TEXT_CLASS}`; export const SIDEBAR_ROW_OPEN_IN_SPLIT_STATE_CLASS = "bb-sidebar-open-in-split-row"; diff --git a/apps/app/src/components/sidebar/sortComparator.test.ts b/apps/app/src/components/sidebar/sortComparator.test.ts index b94f7d7b3b..cff88c7925 100644 --- a/apps/app/src/components/sidebar/sortComparator.test.ts +++ b/apps/app/src/components/sidebar/sortComparator.test.ts @@ -52,6 +52,7 @@ function environmentItem( kind: "environment", group: { environmentId: representative.environmentId ?? "env_test", + environmentProviderId: "git-worktree", nodes: [threadNode(representative), threadNode(sibling)], stats: { childCount: 0, @@ -110,6 +111,79 @@ function order(comparator: ThreadComparator, entries: ThreadListEntry[]) { } describe("getSidebarThreadComparator", () => { + it.each(["updated", "none"] as const)( + "keeps active threads first in both directions for %s", + (sort) => { + const entries = [ + thread({ + id: "idle_new", + status: "idle", + createdAt: 30, + latestAttentionAt: 200, + }), + thread({ + id: "active_old", + status: "active", + createdAt: 10, + latestAttentionAt: 2000, + }), + thread({ + id: "idle_old", + status: "idle", + createdAt: 40, + latestAttentionAt: 100, + }), + thread({ + id: "active_new", + status: "active", + createdAt: 20, + latestAttentionAt: 1500, + }), + ]; + + expect( + order( + getSidebarThreadComparator(sort, undefined, "ascending"), + entries, + ), + ).toEqual(["active_old", "active_new", "idle_old", "idle_new"]); + for (const direction of ["default", "descending"] as const) { + expect( + order( + getSidebarThreadComparator(sort, undefined, direction), + entries, + ), + ).toEqual(["active_new", "active_old", "idle_new", "idle_old"]); + } + }, + ); + + it("reverses created dates", () => { + expect( + order(getSidebarThreadComparator("created", undefined, "ascending"), [ + cherry, + apple, + banana, + ]), + ).toEqual(["thr_a", "thr_b", "thr_c"]); + }); + + it("reverses both thread and group alphabetical comparison", () => { + const comparator = getSidebarThreadComparator( + "alpha", + undefined, + "descending", + ); + expect(order(comparator, [apple, banana, cherry])).toEqual([ + "thr_c", + "thr_b", + "thr_a", + ]); + expect( + comparator.compareItems?.(sectionItem("Apple"), sectionItem("Zebra")), + ).toBeGreaterThan(0); + }); + it("created lists newest first", () => { expect( order(getSidebarThreadComparator("created"), [apple, banana, cherry]), diff --git a/apps/app/src/components/sidebar/threadListProvider.ts b/apps/app/src/components/sidebar/threadListProvider.ts index e28393217f..50432aed2d 100644 --- a/apps/app/src/components/sidebar/threadListProvider.ts +++ b/apps/app/src/components/sidebar/threadListProvider.ts @@ -1,15 +1,11 @@ import { useAtomValue } from "jotai"; -import { - createReplacementPreferenceAtom, - resolvePreferredReplacement, -} from "@/lib/plugin-replacement-preference"; +import { resolvePreferredReplacement } from "@/lib/plugin-replacement-preference"; +import { createSyncedPreferenceAtom } from "@/lib/ui-preferences/synced-preference-atom"; import type { ResolvedReplacement } from "@/lib/plugin-slot-resolvers"; import { usePluginSlots, type PluginThreadListSlot } from "@/lib/plugin-slots"; -const THREAD_LIST_PROVIDER_STORAGE_KEY = "bb.sidebar.threadListProvider"; - -export const threadListProviderAtom = createReplacementPreferenceAtom( - THREAD_LIST_PROVIDER_STORAGE_KEY, +export const threadListProviderAtom = createSyncedPreferenceAtom( + "sidebar.threadListProvider", ); export function useThreadListReplacement(): ResolvedReplacement { diff --git a/apps/app/src/components/sidebar/usePersistedSidebarSectionOrder.ts b/apps/app/src/components/sidebar/usePersistedSidebarSectionOrder.ts index f34573b134..e532e73f2d 100644 --- a/apps/app/src/components/sidebar/usePersistedSidebarSectionOrder.ts +++ b/apps/app/src/components/sidebar/usePersistedSidebarSectionOrder.ts @@ -1,18 +1,15 @@ -import { useEffect, useMemo } from "react"; +import { useMemo } from "react"; import type { SidebarSectionId } from "./sidebarCollapsedAtoms"; import { normalizeSidebarSectionOrder, type LegacySidebarEntityAnchor, } from "@bb/client-core"; -import { haveSameOrder } from "@/lib/stored-order"; interface UsePersistedSidebarSectionOrderArgs { entitySectionIds: readonly SidebarSectionId[]; hasPinnedSection: boolean; hasThreadsSection?: boolean; - isReady: boolean; legacyEntityAnchor: LegacySidebarEntityAnchor; - setStoredOrder: (order: string[]) => void; storedOrder: readonly string[]; } @@ -20,12 +17,10 @@ export function usePersistedSidebarSectionOrder({ entitySectionIds, hasPinnedSection, hasThreadsSection, - isReady, legacyEntityAnchor, - setStoredOrder, storedOrder, }: UsePersistedSidebarSectionOrderArgs): SidebarSectionId[] { - const order = useMemo( + return useMemo( () => normalizeSidebarSectionOrder({ storedOrder, @@ -42,11 +37,4 @@ export function usePersistedSidebarSectionOrder({ storedOrder, ], ); - - useEffect(() => { - if (!isReady || haveSameOrder(storedOrder, order)) return; - setStoredOrder(order); - }, [isReady, order, setStoredOrder, storedOrder]); - - return order; } diff --git a/apps/app/src/components/sidebar/useSidebarModeSectionOrder.ts b/apps/app/src/components/sidebar/useSidebarModeSectionOrder.ts index b48689d64b..1e913a1d65 100644 --- a/apps/app/src/components/sidebar/useSidebarModeSectionOrder.ts +++ b/apps/app/src/components/sidebar/useSidebarModeSectionOrder.ts @@ -34,7 +34,6 @@ const MODE_SECTION_ORDER_CONFIG: Record< interface UseSidebarModeSectionOrderArgs { entitySectionIds: readonly SidebarSectionId[]; hasThreadsSection?: boolean; - isReady: boolean; mode: SidebarOrganizationMode; showPinnedSection: boolean; } @@ -48,7 +47,6 @@ interface UseSidebarModeSectionOrderResult { export function useSidebarModeSectionOrder({ entitySectionIds, hasThreadsSection, - isReady, mode, showPinnedSection, }: UseSidebarModeSectionOrderArgs): UseSidebarModeSectionOrderResult { @@ -56,12 +54,10 @@ export function useSidebarModeSectionOrder({ const [storedOrder, setStoredOrder] = useAtom(config.atom); const persistedOrder = usePersistedSidebarSectionOrder({ storedOrder, - setStoredOrder, entitySectionIds, legacyEntityAnchor: config.legacyEntityAnchor, hasPinnedSection: true, ...(hasThreadsSection === undefined ? {} : { hasThreadsSection }), - isReady, }); const order = useMemo( () => diff --git a/apps/app/src/components/thread/ThreadActionsMenu.test.tsx b/apps/app/src/components/thread/ThreadActionsMenu.test.tsx index 8a8d39149f..61a8902405 100644 --- a/apps/app/src/components/thread/ThreadActionsMenu.test.tsx +++ b/apps/app/src/components/thread/ThreadActionsMenu.test.tsx @@ -1,37 +1,99 @@ // @vitest-environment jsdom import { cleanup, fireEvent, render, screen } from "@testing-library/react"; -import { makeThread } from "@bb/test-helpers/domain-fixtures"; +import { CompactViewportOverrideProvider } from "@bb/shared-ui/hooks/use-compact-viewport"; +import type { ReactNode } from "react"; import { afterEach, describe, expect, it, vi } from "vitest"; -import { ThreadActionsMenu } from "./ThreadActionsMenu"; +import { makeThreadListEntry } from "../../../.ladle/story-fixtures"; +import { + ThreadActionsContextMenu, + ThreadActionsMenu, +} from "./ThreadActionsMenu"; +import { ThreadSectionMoveProvider } from "./ThreadSectionMoveProvider"; -const mocks = vi.hoisted(() => ({ - copyToClipboardWithToast: vi.fn(), +const moveThreadToSection = vi.hoisted(() => vi.fn()); +const copyToClipboardWithToast = vi.hoisted(() => vi.fn()); +const threadActions = vi.hoisted(() => ({ + archiveThreadAndChildren: vi.fn(), + requestDelete: vi.fn(), + requestRename: vi.fn(), + togglePin: vi.fn(), + toggleRead: vi.fn(), + unarchiveThread: vi.fn(), })); vi.mock("@/lib/clipboard", () => ({ - copyToClipboardWithToast: mocks.copyToClipboardWithToast, + copyToClipboardWithToast, +})); + +vi.mock("@/hooks/mutations/thread-state-mutations", () => ({ + useMoveThreadToSection: () => moveThreadToSection, })); vi.mock("./ThreadActionsProvider", () => ({ useThreadActions: () => ({ - archiveThreadAndChildren: vi.fn(), - requestRename: vi.fn(), - requestDelete: vi.fn(), - togglePin: vi.fn(), - toggleRead: vi.fn(), - unarchiveThread: vi.fn(), + ...threadActions, + renameThread: vi.fn(), }), })); +const destinations = [ + { label: "Planning", sectionId: "sec_planning" }, + { label: "Building", sectionId: "sec_building" }, + { label: "Threads", sectionId: null }, +] as const; +const thread = makeThreadListEntry({ + id: "thread-1", + pinnedAt: null, + sectionId: "sec_planning", + title: "Move me", +}); + +function renderWide(children: ReactNode, withMoveProvider = true) { + const content = withMoveProvider ? ( + + {children} + + ) : ( + children + ); + return render( + + {content} + , + ); +} + +function renderCompact(children: ReactNode) { + return render( + + + {children} + + , + ); +} + +async function openMoveSubmenu() { + const trigger = await screen.findByRole("menuitem", { + name: "Move to section", + }); + fireEvent.keyDown(trigger, { key: "ArrowRight" }); + return screen.findByRole("menuitem", { name: "Building" }); +} + afterEach(() => { cleanup(); - mocks.copyToClipboardWithToast.mockReset(); + moveThreadToSection.mockReset(); + copyToClipboardWithToast.mockReset(); + for (const action of Object.values(threadActions)) { + action.mockReset(); + } }); describe("ThreadActionsMenu", () => { it("copies the canonical thread URL from every menu instance", () => { - render(); + renderWide(); fireEvent.pointerDown( screen.getByRole("button", { name: "Thread actions" }), @@ -39,8 +101,8 @@ describe("ThreadActionsMenu", () => { ); fireEvent.click(screen.getByRole("menuitem", { name: "Copy thread link" })); - expect(mocks.copyToClipboardWithToast).toHaveBeenCalledWith( - `${window.location.origin}/projects/proj_test/threads/thr_test`, + expect(copyToClipboardWithToast).toHaveBeenCalledWith( + `${window.location.origin}/projects/${thread.projectId}/threads/${thread.id}`, { successMessage: "Thread link copied", errorMessage: "Failed to copy thread link", @@ -48,3 +110,121 @@ describe("ThreadActionsMenu", () => { ); }); }); + +describe("ThreadActionsMenu section moves", () => { + it("moves from the overflow menu and indicates the current section", async () => { + renderWide(); + + fireEvent.pointerDown( + screen.getByRole("button", { name: "Thread actions" }), + { button: 0 }, + ); + const building = await openMoveSubmenu(); + const current = screen.getByRole("menuitem", { name: "Planning" }); + expect(current.getAttribute("aria-current")).toBe("true"); + expect(current.getAttribute("aria-disabled")).toBe("true"); + + fireEvent.click(building); + expect(moveThreadToSection).toHaveBeenCalledWith({ + thread, + sectionId: "sec_building", + }); + }); + + it("offers the same destinations from the thread context menu", async () => { + renderWide( + +
Move me
+
, + ); + + fireEvent.contextMenu(screen.getByTestId("thread-row")); + const building = await openMoveSubmenu(); + fireEvent.click(building); + + expect(moveThreadToSection).toHaveBeenCalledWith({ + thread, + sectionId: "sec_building", + }); + }); + + it("does not add section controls outside Manual organization", async () => { + renderWide(, false); + + fireEvent.pointerDown( + screen.getByRole("button", { name: "Thread actions" }), + { button: 0 }, + ); + expect( + screen.queryByRole("menuitem", { name: "Move to section" }), + ).toBeNull(); + }); + + it("does not offer section moves for nested child threads", async () => { + const childThread = makeThreadListEntry({ + ...thread, + id: "thread-child", + parentThreadId: thread.id, + }); + renderWide(); + + fireEvent.pointerDown( + screen.getByRole("button", { name: "Thread actions" }), + { button: 0 }, + ); + expect( + screen.queryByRole("menuitem", { name: "Move to section" }), + ).toBeNull(); + }); + + it("supports Back and resets the compact overflow menu after a move", async () => { + renderCompact(); + + const trigger = screen.getByRole("button", { name: "Thread actions" }); + fireEvent.click(trigger); + const moveToSection = await screen.findByRole("menuitem", { + name: "Move to section", + }); + expect(moveToSection.querySelector('[data-icon="MoveTo"]')).not.toBeNull(); + fireEvent.click(moveToSection); + + expect(await screen.findByText("Move to section")).not.toBeNull(); + expect(screen.getByRole("menuitem", { name: "Building" })).not.toBeNull(); + fireEvent.click(screen.getByRole("menuitem", { name: "Back" })); + expect( + await screen.findByRole("menuitem", { name: "Rename" }), + ).not.toBeNull(); + + fireEvent.click( + await screen.findByRole("menuitem", { name: "Move to section" }), + ); + fireEvent.click(await screen.findByRole("menuitem", { name: "Building" })); + + fireEvent.click(trigger); + expect( + await screen.findByRole("menuitem", { name: "Move to section" }), + ).not.toBeNull(); + expect(screen.queryByRole("menuitem", { name: "Back" })).toBeNull(); + }); + + it("reopens the compact long-press menu at the root after moving a thread", async () => { + renderCompact( + +
Move me
+
, + ); + + const row = screen.getByTestId("thread-row"); + fireEvent.contextMenu(row); + fireEvent.click( + await screen.findByRole("menuitem", { name: "Move to section" }), + ); + fireEvent.click(await screen.findByRole("menuitem", { name: "Building" })); + + fireEvent.contextMenu(row); + expect( + await screen.findByRole("menuitem", { name: "Move to section" }), + ).not.toBeNull(); + expect(screen.queryByRole("menuitem", { name: "Back" })).toBeNull(); + }); +}); diff --git a/apps/app/src/components/thread/ThreadActionsMenu.tsx b/apps/app/src/components/thread/ThreadActionsMenu.tsx index 7cfff26edf..52162e6d1d 100644 --- a/apps/app/src/components/thread/ThreadActionsMenu.tsx +++ b/apps/app/src/components/thread/ThreadActionsMenu.tsx @@ -3,15 +3,25 @@ import { ActionMenuSeparator, } from "@/components/ui/action-menu-items"; import type { Thread } from "@bb/domain"; -import type { ReactNode } from "react"; +import { useCallback, useState, type ReactNode } from "react"; import { ContextMenu, ContextMenuContent, + ContextMenuItem, + ContextMenuSub, + ContextMenuSubContent, + ContextMenuSubTrigger, ContextMenuTrigger, } from "@bb/shared-ui/context-menu"; import { DropdownMenu, DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, DropdownMenuTrigger, } from "@bb/shared-ui/dropdown-menu"; import { Icon, type IconName } from "@bb/shared-ui/icon"; @@ -25,6 +35,7 @@ import { isThreadRead } from "@bb/client-core"; import { copyToClipboardWithToast } from "@/lib/clipboard"; import { getThreadRoutePath } from "@/lib/route-paths"; import { useThreadActions } from "./ThreadActionsProvider"; +import { useThreadSectionMove } from "./ThreadSectionMoveProvider"; interface ThreadActionsMenuBaseProps { thread: Thread; @@ -49,15 +60,122 @@ interface ThreadActionsContextMenuProps extends ThreadActionsMenuBaseProps { } type ThreadActionsMenuSurface = "context" | "dropdown"; +type ThreadActionsCompactStep = "actions" | "move"; interface ThreadActionsMenuItemsProps extends ThreadActionsMenuBaseProps { + compactStep?: ThreadActionsCompactStep; + onCompactStepChange?: (step: ThreadActionsCompactStep) => void; responsiveActions?: readonly ThreadActionsMenuResponsiveAction[]; surface: ThreadActionsMenuSurface; } +function ThreadSectionMoveMenu({ + drawerStep = false, + isDrawer, + onBack, + onOpenDrawerStep, + surface, + thread, +}: { + drawerStep?: boolean; + isDrawer: boolean; + onBack?: () => void; + onOpenDrawerStep?: () => void; + surface: ThreadActionsMenuSurface; + thread: Thread; +}) { + const sectionMove = useThreadSectionMove(); + if ( + !sectionMove || + thread.parentThreadId !== null || + thread.archivedAt !== null + ) { + return null; + } + + const hasValidDestination = sectionMove.destinations.some( + (destination) => + thread.pinnedAt !== null || thread.sectionId !== destination.sectionId, + ); + if (!hasValidDestination) return null; + + const Item = surface === "context" ? ContextMenuItem : DropdownMenuItem; + const items = sectionMove.destinations.map((destination) => { + const isCurrent = + thread.pinnedAt === null && thread.sectionId === destination.sectionId; + return ( + sectionMove.moveThread(thread, destination.sectionId)} + > + {destination.label} + {isCurrent ? ( + + ); + }); + + if (isDrawer) { + if (!drawerStep) { + return ( + { + event.preventDefault(); + onOpenDrawerStep?.(); + }} + > + + ); + } + return ( + <> + { + event.preventDefault(); + onBack?.(); + }} + > + + + Move to section + {items} + + ); + } + + const Sub = surface === "context" ? ContextMenuSub : DropdownMenuSub; + const SubTrigger = + surface === "context" ? ContextMenuSubTrigger : DropdownMenuSubTrigger; + const SubContent = + surface === "context" ? ContextMenuSubContent : DropdownMenuSubContent; + + return ( + + + + + {items} + + + ); +} + function ThreadActionsMenuItems({ thread, onOpenInSplit, + compactStep = "actions", + onCompactStepChange, responsiveActions = [], surface, }: ThreadActionsMenuItemsProps) { @@ -80,6 +198,18 @@ function ThreadActionsMenuItems({ window.location.origin, ).toString(); + if (isDrawer && compactStep === "move") { + return ( + onCompactStepChange?.("actions")} + surface={surface} + thread={thread} + /> + ); + } + return ( <> {responsiveActions.length > 0 ? ( @@ -96,9 +226,7 @@ function ThreadActionsMenuItems({ {action.label} ))} - {showSeparators ? ( - - ) : null} + {showSeparators ? : null} ) : null} {onOpenInSplit ? ( @@ -112,9 +240,7 @@ function ThreadActionsMenuItems({ > Open in split - {showSeparators ? ( - - ) : null} + {showSeparators ? : null} ) : null} {isPinned ? "Unpin" : "Pin"} + onCompactStepChange?.("move")} + surface={surface} + thread={thread} + /> void) { + const [compactStep, setCompactStep] = + useState("actions"); + const handleOpenChange = useCallback( + (open: boolean) => { + if (!open) { + setCompactStep("actions"); + } + onOpenChange?.(open); + }, + [onOpenChange], + ); + + return { compactStep, setCompactStep, handleOpenChange }; +} + export function ThreadArchiveQuickAction({ thread, className, @@ -235,8 +383,11 @@ export function ThreadActionsMenu({ onOpenChange, triggerClassName, }: ThreadActionsMenuProps) { + const { compactStep, setCompactStep, handleOpenChange } = + useThreadActionsMenuLifecycle(onOpenChange); + return ( - + + ); + const errorNode = errorMessage ? ( +
+ {errorMessage} +
+ ) : null; + const sourceThreadLink = sourceThread ? ( + + From {sourceThread.title} + + ) : null; + + return ( +
+
+ + {isExpanded ? sourceThreadLink : null} + {toggle} +
+ + + + + + {errorNode} +
+ ); +} + +function AttentionDot({ hasError }: { hasError: boolean }) { + return ( +
+ ); +} + +export function Completed() { + return ( +
+ + +
+ ); +} + +export function Grouped() { + const [closed, setClosed] = useState(false); + const rows = [ + { ...thought, id: "before", detail: "Inspect the implementation first." }, + fileReadRow({ id: "read-a", path: "src/app.ts", seq: 2 }), + { ...thought, id: "between", detail: "Check the helper before editing." }, + fileReadRow({ id: "read-b", path: "src/helper.ts", seq: 4 }), + { + ...thought, + id: "before-edit", + detail: "Keep the existing public contract.", + }, + fileChangeRow({ id: "edit", path: "src/app.ts", seq: 6 }), + ]; + return ( +
+ + + + +
+ ); +} diff --git a/apps/app/src/components/thread/timeline/useThreadTimelineController.test.tsx b/apps/app/src/components/thread/timeline/useThreadTimelineController.test.tsx index d4241aa2ba..edb57b332d 100644 --- a/apps/app/src/components/thread/timeline/useThreadTimelineController.test.tsx +++ b/apps/app/src/components/thread/timeline/useThreadTimelineController.test.tsx @@ -241,6 +241,93 @@ describe("useThreadTimelineController", () => { ]); }); + it("discards an in-flight older page when latest changes the history snapshot", async () => { + const oldRow = makeUserRow("thread-1:user-seed:1", 1); + const olderRow = makeUserRow("thread-1:user-seed:0", 0); + const boundaryRow: TimelineRow = { + id: "context-clear-10", + kind: "system", + threadId: "thread-1", + turnId: null, + sourceSeqStart: 10, + sourceSeqEnd: 10, + startedAt: 10, + createdAt: 10, + systemKind: "operation", + operationKind: "generic", + title: "Context cleared", + detail: null, + status: "completed", + completedAt: 10, + }; + let resolveOlder: (value: ThreadTimelineResponse) => void = () => {}; + vi.mocked(sdk.threads.timeline) + .mockResolvedValueOnce( + makeTimelineResponse({ + rows: [oldRow], + maxSeq: 1, + timelinePage: { + historySnapshot: "before", + hasOlderRows: true, + olderCursor: { anchorId: oldRow.id, anchorSeq: 1 }, + }, + }), + ) + .mockImplementationOnce( + () => + new Promise((resolve) => { + resolveOlder = resolve; + }), + ); + + const { queryClient, wrapper } = createQueryClientTestHarness(); + const { result } = renderHook( + () => useThreadTimelineController({ threadId: "thread-1" }), + { wrapper }, + ); + await waitFor(() => { + expect(result.current.hasOlderTimelineRows).toBe(true); + }); + + let olderRequest: Promise = Promise.resolve(); + act(() => { + olderRequest = result.current.loadOlderTimelineRows(); + }); + await waitFor(() => { + expect(sdk.threads.timeline).toHaveBeenCalledTimes(2); + }); + act(() => { + queryClient.setQueryData( + threadTimelineQueryKey("thread-1"), + makeTimelineResponse({ + timelinePage: { historySnapshot: "after" }, + maxSeq: 10, + rows: [boundaryRow], + }), + ); + }); + await waitFor(() => { + expect(result.current.timelineRows.map((row) => row.id)).toEqual([ + boundaryRow.id, + ]); + }); + + resolveOlder( + makeTimelineResponse({ + rows: [olderRow], + maxSeq: 1, + timelinePage: { kind: "older", historySnapshot: "before" }, + }), + ); + await act(async () => { + await olderRequest; + }); + + expect(result.current.timelineRows.map((row) => row.id)).toEqual([ + boundaryRow.id, + ]); + }); + it("keeps an initial timeline refetch in loading state instead of showing the previous error", async () => { const response = makeTimelineResponse(); let resolveRefetch: (value: ThreadTimelineResponse) => void = () => {}; diff --git a/apps/app/src/components/thread/timeline/useThreadTimelineController.ts b/apps/app/src/components/thread/timeline/useThreadTimelineController.ts index b76be542a3..c5b6a34483 100644 --- a/apps/app/src/components/thread/timeline/useThreadTimelineController.ts +++ b/apps/app/src/components/thread/timeline/useThreadTimelineController.ts @@ -123,7 +123,10 @@ export function useThreadTimelineController({ }); const olderRows = [...response.rows]; setLoadedTimeline((current) => { - if (current.surfaceKey !== surfaceKey) { + if ( + current.surfaceKey !== surfaceKey || + current.historySnapshot !== response.timelinePage.historySnapshot + ) { return current; } return { diff --git a/apps/app/src/components/thread/timeline/useTimelineWorkRowFullOutput.ts b/apps/app/src/components/thread/timeline/useTimelineWorkRowFullOutput.ts index 5d804e2333..74166b7a82 100644 --- a/apps/app/src/components/thread/timeline/useTimelineWorkRowFullOutput.ts +++ b/apps/app/src/components/thread/timeline/useTimelineWorkRowFullOutput.ts @@ -1,6 +1,8 @@ import { useCallback, useMemo } from "react"; import type { TimelineCommandWorkRow, + TimelineOutputPreview, + TimelineRow, TimelineToolWorkRow, } from "@bb/server-contract"; import { useThreadTimelineTurnSummaryDetails } from "@/hooks/queries/thread-queries"; @@ -12,6 +14,8 @@ export type TimelinePreviewableWorkRow = export type TimelineWorkRowFullOutputState = | "complete" | "streaming-preview" + | "limited-preview" + | "expired-preview" | "loading" | "error" | "loaded"; @@ -22,12 +26,60 @@ export interface TimelineWorkRowFullOutput { retry: () => void; } +function loadedOutputState( + outputPreview: TimelineOutputPreview | undefined, +): TimelineWorkRowFullOutputState { + if (outputPreview === undefined) { + return "loaded"; + } + switch (outputPreview.experimental_fullOutputAvailability) { + case "available": + return "error"; + case "detail-limit": + return "limited-preview"; + case "retention-expired": + return "expired-preview"; + } +} + +function findPreviewableWorkRow( + rows: readonly TimelineRow[], + predicate: (row: TimelinePreviewableWorkRow) => boolean, +): TimelinePreviewableWorkRow | null { + for (const row of rows) { + if ( + row.kind === "work" && + (row.workKind === "command" || row.workKind === "tool") && + predicate(row) + ) { + return row; + } + const children = + row.kind === "turn" + ? row.children + : row.kind === "work" && row.workKind === "delegation" + ? row.childRows + : null; + if (children !== null) { + const match = findPreviewableWorkRow(children, predicate); + if (match !== null) { + return match; + } + } + } + return null; +} + export function useTimelineWorkRowFullOutput( row: TimelinePreviewableWorkRow, ): TimelineWorkRowFullOutput { - const isPreview = row.outputPreview !== undefined; + const outputPreview = row.outputPreview; + const isPreview = outputPreview !== undefined; const shouldLoad = - isPreview && row.turnId !== null && row.status !== "pending"; + isPreview && + outputPreview.experimental_fullOutputAvailability !== "retention-expired" && + row.turnId !== null && + row.status !== "pending"; const { data, isError, refetch } = useThreadTimelineTurnSummaryDetails( { sourceSeqEnd: row.sourceSeqEnd, @@ -40,33 +92,47 @@ export function useTimelineWorkRowFullOutput( const retry = useCallback((): void => { void refetch(); }, [refetch]); - const loadedOutput = useMemo((): string | null => { + const loadedOutput = useMemo((): { + output: string; + outputPreview: TimelineOutputPreview | undefined; + } | null => { if (!shouldLoad || data === undefined) { return null; } const match = - data.rows.find((candidate) => candidate.id === row.id) ?? - data.rows.find( + findPreviewableWorkRow( + data.rows, + (candidate) => candidate.id === row.id, + ) ?? + findPreviewableWorkRow( + data.rows, (candidate) => - candidate.kind === "work" && candidate.workKind === row.workKind && candidate.callId === row.callId, ); - if ( - !match || - match.kind !== "work" || - (match.workKind !== "command" && match.workKind !== "tool") - ) { + if (match === null) { return null; } - return match.output; + return { + output: match.output, + outputPreview: match.outputPreview, + }; }, [data, row.callId, row.id, row.workKind, shouldLoad]); if (!isPreview) { return { output: row.output, state: "complete", retry }; } + if ( + outputPreview.experimental_fullOutputAvailability === "retention-expired" + ) { + return { output: row.output, state: "expired-preview", retry }; + } if (loadedOutput !== null) { - return { output: loadedOutput, state: "loaded", retry }; + return { + output: loadedOutput.output, + state: loadedOutputState(loadedOutput.outputPreview), + retry, + }; } if (!shouldLoad) { return { output: row.output, state: "streaming-preview", retry }; diff --git a/apps/app/src/components/thread/toc/ThreadTableOfContents.test.tsx b/apps/app/src/components/thread/toc/ThreadTableOfContents.test.tsx index 033fdd0efe..ef08ae0820 100644 --- a/apps/app/src/components/thread/toc/ThreadTableOfContents.test.tsx +++ b/apps/app/src/components/thread/toc/ThreadTableOfContents.test.tsx @@ -246,7 +246,6 @@ function threadListEntry( environmentHostId: "host_toc", environmentName: "ToC environment", environmentBranchName: "main", - environmentWorkspaceDisplayKind: "managed-worktree", ...thread, }); } diff --git a/apps/app/src/components/thread/user-questions/QuestionForm.test.tsx b/apps/app/src/components/thread/user-questions/QuestionForm.test.tsx new file mode 100644 index 0000000000..b4dc488a38 --- /dev/null +++ b/apps/app/src/components/thread/user-questions/QuestionForm.test.tsx @@ -0,0 +1,278 @@ +// @vitest-environment jsdom +import { + cleanup, + fireEvent, + render as renderReact, +} from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { QuestionForm } from "@bb/shared-ui/question-form"; +import type { + Question, + QuestionAnswer, +} from "@bb/shared-ui/question-form-state"; +import { ThreadQuestionFormHost } from "./ThreadQuestionFormHost"; +import { AppCommandProvider } from "@/components/commands/AppCommandProvider"; +import { defaultAppSettings } from "@bb/domain"; +type InteractionPayload = { questions: Question[] }; +type InteractionResponse = { answers: Record }; + +vi.mock("@/hooks/queries/system-queries", () => ({ + useSystemConfig: () => ({ + data: { + generalSettings: { ...defaultAppSettings }, + keybindings: [1, 2, 3].map((digit) => ({ + command: `question.select.${digit}`, + desktopOnly: false, + shortcut: { + key: String(digit), + mod: false, + meta: false, + control: false, + alt: false, + shift: false, + }, + when: { all: ["questionOpen"], none: [] }, + })), + }, + }), +})); +vi.mock("@/lib/bb-desktop", () => ({ getBbDesktopInfo: () => null })); +const pane = vi.hoisted(() => ({ isFocused: true })); +vi.mock("@/views/thread-detail/PaneContext", () => ({ + useOptionalPaneContext: () => pane, +})); + +beforeEach(() => { + pane.isFocused = true; + Object.defineProperty(window, "matchMedia", { + writable: true, + value: vi.fn((query: string) => ({ + matches: false, + media: query, + onchange: null, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + addListener: vi.fn(), + removeListener: vi.fn(), + dispatchEvent: vi.fn(), + })), + }); +}); + +afterEach(cleanup); + +const singleSelect: InteractionPayload = { + questions: [ + { + id: "q0", + prompt: "Which database should we use?", + shortLabel: "Database", + multiSelect: false, + allowFreeText: true, + options: [ + { + value: "q0o0", + label: "Postgres", + description: "Relational, needs a server.", + preview: "CREATE TABLE users (id uuid primary key);", + }, + { + value: "q0o1", + label: "SQLite", + description: "Embedded, zero setup.", + }, + ], + }, + ], +}; + +function render( + payload: InteractionPayload, + handlers: { + submit?: (value: InteractionResponse) => Promise; + cancel?: () => Promise; + } = {}, +) { + return renderReact( + + + { + void handlers.submit?.({ answers }); + }} + onCancel={() => { + void handlers.cancel?.(); + }} + /> + + , + ); +} + +function getButtonByText( + slot: ReturnType, + text: string, +): HTMLButtonElement { + const button = slot.getByText(text).closest("button"); + if (!(button instanceof HTMLButtonElement)) { + throw new Error(`${text} is not rendered inside a button`); + } + return button; +} + +describe("answering a single-select question", () => { + it("submits the selected option value", () => { + const submit = vi.fn<(value: InteractionResponse) => Promise>( + async () => undefined, + ); + const slot = render(singleSelect, { submit }); + + expect(slot.getAllByText("Which database should we use?")).toHaveLength(2); + fireEvent.click(getButtonByText(slot, "SQLite")); + fireEvent.click(getButtonByText(slot, "Submit answer")); + + expect(submit).toHaveBeenCalledTimes(1); + expect(submit.mock.calls[0]?.[0]).toEqual({ + answers: { q0: { selected: ["q0o1"] } }, + } satisfies InteractionResponse); + }); + + it("ignores answer shortcuts in an unfocused pane", () => { + pane.isFocused = false; + const slot = render(singleSelect); + fireEvent.keyDown(window, { key: "1" }); + expect(getButtonByText(slot, "Postgres").getAttribute("aria-pressed")).toBe( + "false", + ); + }); + + it("blocks submission until something is chosen", () => { + const slot = render(singleSelect); + const submitButton = getButtonByText(slot, "Submit answer"); + + expect(submitButton.disabled).toBe(true); + fireEvent.click(getButtonByText(slot, "Postgres")); + expect(submitButton.disabled).toBe(false); + }); + + it("reveals an option preview only while that option is selected", () => { + const slot = render(singleSelect); + const preview = "CREATE TABLE users (id uuid primary key);"; + + expect(slot.queryByText(preview)).toBeNull(); + fireEvent.click(getButtonByText(slot, "Postgres")); + expect(slot.getByText(preview)).toBeTruthy(); + + fireEvent.click(getButtonByText(slot, "SQLite")); + expect(slot.queryByText(preview)).toBeNull(); + }); + + it("makes 'Other' and a real option mutually exclusive", () => { + const submit = vi.fn<(value: InteractionResponse) => Promise>( + async () => undefined, + ); + const slot = render(singleSelect, { submit }); + + fireEvent.click(getButtonByText(slot, "Postgres")); + fireEvent.click(getButtonByText(slot, "Other…")); + const textarea = slot.getByLabelText("Database answer"); + fireEvent.change(textarea, { target: { value: "DuckDB" } }); + fireEvent.click(getButtonByText(slot, "Submit answer")); + + expect(submit).toHaveBeenCalledTimes(1); + expect(submit.mock.calls[0]?.[0]).toEqual({ + answers: { q0: { selected: [], freeText: "DuckDB" } }, + } satisfies InteractionResponse); + }); + + it("selects an option with its number-key shortcut", () => { + const submit = vi.fn<(value: InteractionResponse) => Promise>( + async () => undefined, + ); + const slot = render(singleSelect, { submit }); + + fireEvent.keyDown(window, { key: "2" }); + fireEvent.click(getButtonByText(slot, "Submit answer")); + + expect(submit).toHaveBeenCalledTimes(1); + expect(submit.mock.calls[0]?.[0]).toEqual({ + answers: { q0: { selected: ["q0o1"] } }, + } satisfies InteractionResponse); + }); + + it("ignores number keys typed into the free-text box", () => { + const slot = render(singleSelect); + fireEvent.click(getButtonByText(slot, "Other…")); + const textarea = slot.getByLabelText("Database answer"); + + fireEvent.keyDown(textarea, { key: "1" }); + + expect(getButtonByText(slot, "Postgres").getAttribute("aria-pressed")).toBe( + "false", + ); + }); +}); + +describe("multi-select and multi-question flows", () => { + const multi: InteractionPayload = { + questions: [ + { + id: "q0", + prompt: "Which extras?", + shortLabel: "Extras", + multiSelect: true, + allowFreeText: true, + options: [ + { value: "q0o0", label: "Metrics", description: "Prometheus." }, + { value: "q0o1", label: "Tracing", description: "OTel." }, + ], + }, + { + id: "q1", + prompt: "Which database?", + shortLabel: "Database", + multiSelect: false, + allowFreeText: true, + options: [ + { value: "q1o0", label: "Postgres", description: "Server." }, + { value: "q1o1", label: "SQLite", description: "Embedded." }, + ], + }, + ], + }; + + it("keeps several options selected and walks both questions before submitting", () => { + const submit = vi.fn<(value: InteractionResponse) => Promise>( + async () => undefined, + ); + const slot = render(multi, { submit }); + + expect(slot.getByText("1 of 2")).toBeTruthy(); + fireEvent.click(getButtonByText(slot, "Metrics")); + fireEvent.click(getButtonByText(slot, "Tracing")); + fireEvent.click(getButtonByText(slot, "Next")); + + expect(slot.getByText("2 of 2")).toBeTruthy(); + fireEvent.click(getButtonByText(slot, "Postgres")); + fireEvent.click(getButtonByText(slot, "Submit answer")); + + expect(submit).toHaveBeenCalledTimes(1); + expect(submit.mock.calls[0]?.[0]).toEqual({ + answers: { + q0: { selected: ["q0o0", "q0o1"] }, + q1: { selected: ["q1o0"] }, + }, + } satisfies InteractionResponse); + }); + + it("cancels the request instead of submitting", () => { + const cancel = vi.fn(async () => undefined); + const slot = render(multi, { cancel }); + + fireEvent.click(getButtonByText(slot, "Cancel")); + expect(cancel).toHaveBeenCalledTimes(1); + }); +}); diff --git a/apps/app/src/components/thread/user-questions/ThreadQuestionFormHost.tsx b/apps/app/src/components/thread/user-questions/ThreadQuestionFormHost.tsx new file mode 100644 index 0000000000..315e083092 --- /dev/null +++ b/apps/app/src/components/thread/user-questions/ThreadQuestionFormHost.tsx @@ -0,0 +1,56 @@ +import { isEditableKeyboardTarget } from "@/lib/app-keybindings"; +import { useCallback, useMemo, useRef, useState, type ReactNode } from "react"; +import { QUESTION_SELECT_APP_COMMAND_IDS } from "@bb/domain"; +import { QuestionFormHostProvider } from "@bb/shared-ui/question-form-host"; +import { + useAppCommandContext, + useAppCommandShortcuts, + useIndexedAppCommandHandlers, +} from "@/components/commands/AppCommandProvider"; +import { useOptionalPaneContext } from "@/views/thread-detail/PaneContext"; + +export function ThreadQuestionFormHost({ children }: { children: ReactNode }) { + const handlerRef = useRef<((index: number) => boolean) | null>(null); + const [hasHandler, setHasHandler] = useState(false); + const isFocusedPane = useOptionalPaneContext()?.isFocused ?? true; + const bindings = useAppCommandShortcuts(QUESTION_SELECT_APP_COMMAND_IDS); + const registerChoiceHandler = useCallback( + (handler: (index: number) => boolean) => { + handlerRef.current = handler; + setHasHandler(true); + return () => { + handlerRef.current = null; + setHasHandler(false); + }; + }, + [], + ); + const value = useMemo( + () => ({ + shortcuts: new Map( + QUESTION_SELECT_APP_COMMAND_IDS.flatMap((command, index) => { + const binding = bindings.get(command); + return binding ? [[String(index), binding] as const] : []; + }), + ), + registerChoiceHandler, + }), + [bindings, registerChoiceHandler], + ); + const enabled = isFocusedPane && hasHandler; + useAppCommandContext("questionOpen", enabled); + useIndexedAppCommandHandlers( + QUESTION_SELECT_APP_COMMAND_IDS, + (index, invocation) => { + if (isEditableKeyboardTarget(invocation.target)) return false; + return enabled ? (handlerRef.current?.(index) ?? false) : false; + }, + 100, + enabled, + ); + return ( + + {children} + + ); +} diff --git a/apps/app/src/components/thread/user-questions/UserQuestionInteractionContent.tsx b/apps/app/src/components/thread/user-questions/UserQuestionInteractionContent.tsx index 78710d4169..e1fca22d11 100644 --- a/apps/app/src/components/thread/user-questions/UserQuestionInteractionContent.tsx +++ b/apps/app/src/components/thread/user-questions/UserQuestionInteractionContent.tsx @@ -1,514 +1,72 @@ -import { - useLayoutEffect, - useMemo, - useRef, - useState, - type KeyboardEvent, -} from "react"; -import { QUESTION_SELECT_APP_COMMAND_IDS } from "@bb/domain"; -import type { - PendingInteractionUserQuestionOption, - PendingInteractionUserQuestionQuestion, -} from "@bb/domain"; -import { Button } from "@bb/shared-ui/button"; -import { Icon } from "@bb/shared-ui/icon"; -import { usePointerCoarse } from "@bb/shared-ui/hooks/use-pointer-coarse"; -import { TabPill } from "@/components/ui/tab-pill.js"; -import { useAutoGrow } from "@/hooks/useAutoGrow"; +import { useMemo, useRef } from "react"; +import type { PendingInteractionUserQuestionQuestion } from "@bb/domain"; +import { QuestionForm } from "@bb/shared-ui/question-form"; import { useResolveThreadPendingInteraction } from "@/hooks/mutations/thread-interaction-mutations"; import { useStopThread } from "@/hooks/mutations/thread-runtime-mutations"; import { getMutationErrorMessage } from "@/lib/mutation-errors"; -import { cn } from "@bb/shared-ui/lib/utils"; -import { - answerStateFor, - buildUserAnswerResolution, - createInitialFormState, - isQuestionAnswered, - type QuestionAnswerState, - type QuestionFormState, -} from "./user-question-form-state.js"; -import { - useAppCommandContext, - useAppCommandShortcuts, - useIndexedAppCommandHandlers, -} from "@/components/commands/AppCommandProvider"; -import type { AppShortcutPresentation } from "@/lib/app-keybindings"; -import { useOptionalPaneContext } from "@/views/thread-detail/PaneContext"; import { useStickyFooterAvailableHeight } from "./useStickyFooterAvailableHeight.js"; interface UserQuestionAnswerFormProps { - className?: string; interactionId: string; - isResolving?: boolean; + isResolving: boolean; questions: readonly PendingInteractionUserQuestionQuestion[]; - shortcutsEnabled: boolean; threadId: string; } -interface QuestionOptionRowProps { - checked: boolean; - label: string; - description?: string; - multiSelect: boolean; - onSelect: () => void; - shortcut?: AppShortcutPresentation; -} - -interface QuestionTabsProps { - currentIndex: number; - formState: QuestionFormState; - onSelect: (index: number) => void; - questions: readonly PendingInteractionUserQuestionQuestion[]; -} - -interface QuestionInputBlockProps { - disabled: boolean; - question: PendingInteractionUserQuestionQuestion; - state: QuestionAnswerState; - onToggleOption: (optionValue: string) => void; - onSelectOther: () => void; - onFreeTextChange: (value: string) => void; - onShortcutSubmit: () => void; - shortcuts: ReadonlyMap; -} - -const OTHER_OPTION_LABEL = "Other…"; -const USER_QUESTION_FREE_TEXT_MIN_HEIGHT = 84; -const USER_QUESTION_FREE_TEXT_MAX_HEIGHT = 158; - -type QuestionShortcutChoice = - | { kind: "option"; value: string } - | { kind: "other" } - | null; - -export function resolveQuestionShortcutChoice( - question: PendingInteractionUserQuestionQuestion, - index: number, -): QuestionShortcutChoice { - const options = question.options ?? []; - const option = options[index]; - if (option) return { kind: "option", value: option.value }; - if ( - index === options.length && - options.length > 0 && - question.allowFreeText - ) { - return { kind: "other" }; - } - return null; -} - -function QuestionOptionRow({ - checked, - label, - description, - multiSelect, - onSelect, - shortcut, -}: QuestionOptionRowProps) { - return ( - - ); -} - -function QuestionTabs({ - currentIndex, - formState, - onSelect, - questions, -}: QuestionTabsProps) { - return ( -
- {} -
- {questions.map((question, index) => { - const answered = isQuestionAnswered( - question, - answerStateFor(formState, question), - ); - return ( - onSelect(index)} - closeAction={null} - /> - ); - })} -
- - {currentIndex + 1} of {questions.length} - -
- ); -} - -function QuestionInputBlock({ - disabled, - question, - state, - onToggleOption, - onSelectOther, - onFreeTextChange, - onShortcutSubmit, - shortcuts, -}: QuestionInputBlockProps) { - const freeTextRef = useRef(null); - const isPointerCoarse = usePointerCoarse(); - const resizeFreeTextArea = useAutoGrow(freeTextRef, { - minHeight: USER_QUESTION_FREE_TEXT_MIN_HEIGHT, - maxHeight: USER_QUESTION_FREE_TEXT_MAX_HEIGHT, - }); - const options = question.options ?? []; - const freeTextLabel = `${question.shortLabel ?? question.prompt} answer`; - - useLayoutEffect(() => { - if (!state.otherSelected) return; - resizeFreeTextArea(); - }, [question.id, resizeFreeTextArea, state.otherSelected, state.otherText]); - - const handleFreeTextKeyDown = ( - event: KeyboardEvent, - ): void => { - if ( - event.nativeEvent.isComposing || - event.key !== "Enter" || - (!event.metaKey && !event.ctrlKey) - ) { - return; - } - event.preventDefault(); - onShortcutSubmit(); - }; - return ( -
- {question.prompt} -
- {question.prompt} -
-
- {options.map((option: PendingInteractionUserQuestionOption, index) => ( - onToggleOption(option.value)} - shortcut={shortcuts.get(String(index))} - /> - ))} - {question.allowFreeText && options.length > 0 ? ( - - ) : null} -
- {state.otherSelected ? ( -