From 6097a4471bdbd10b0dd5d49ecae4b0073def0549 Mon Sep 17 00:00:00 2001 From: Kyle Seaman Date: Tue, 6 Oct 2026 22:52:49 +0000 Subject: [PATCH 1/2] feat(git): switch branches from the Git panel and the composer chip Clicking the branch name in the Git panel header, or the branch segment of the project chip under the chat box, opens a searchable picker that switches local branches, checks out remote-only branches as tracking branches, or creates a branch from the current commit. GET /api/project/git/branches lists branches; POST /api/project/git/switch is owner-gated, runs git sandboxed with hooks off, never discards work, and refuses for subfolder projects and repos declaring content filters. --- docs/feature-map/README.md | 1 + .../modules/learn-cron-dashboard.md | 2 + .../dashboard/handlers/git_branches.py | 628 ++++++++++++++++++ src/kiro_crew/dashboard/handlers/worktree.py | 9 +- src/kiro_crew/dashboard/routes/taskrunner.py | 3 + src/kiro_crew/security_posture.py | 7 + test/test_json_object_body_guard.py | 3 + test/test_project_git_branches.py | 525 +++++++++++++++ website/scripts/capture-branch-switcher.mjs | 213 ++++++ website/src/api/client/files.ts | 33 + website/src/components/BranchSwitcher.tsx | 459 +++++++++++++ website/src/components/ChatInput.tsx | 20 +- website/src/components/CopyBranchButton.tsx | 2 +- website/src/components/GitPanel.tsx | 61 +- website/src/components/GitReposPanel.tsx | 8 +- website/src/i18n/en.context.json | 6 + website/src/i18n/locales/bn.json | 30 + website/src/i18n/locales/de.json | 30 + website/src/i18n/locales/en-XA.json | 30 + website/src/i18n/locales/en.json | 30 + website/src/i18n/locales/es.json | 30 + website/src/i18n/locales/fr.json | 30 + website/src/i18n/locales/hi.json | 30 + website/src/i18n/locales/it.json | 30 + website/src/i18n/locales/ja.json | 30 + website/src/i18n/locales/ko.json | 30 + website/src/i18n/locales/pt.json | 30 + website/src/i18n/locales/ru.json | 30 + website/src/i18n/locales/zh-CN.json | 30 + website/src/pages/chat/ActivityViewer.tsx | 7 +- .../test/ApiClient.refactor.surface.test.ts | 2 + website/src/test/BranchSwitcher.test.tsx | 192 ++++++ .../src/test/ChatInput.projectBranch.test.tsx | 155 ++--- website/src/test/GitPanel.test.tsx | 51 +- 34 files changed, 2672 insertions(+), 105 deletions(-) create mode 100644 src/kiro_crew/dashboard/handlers/git_branches.py create mode 100644 test/test_project_git_branches.py create mode 100644 website/scripts/capture-branch-switcher.mjs create mode 100644 website/src/components/BranchSwitcher.tsx create mode 100644 website/src/test/BranchSwitcher.test.tsx diff --git a/docs/feature-map/README.md b/docs/feature-map/README.md index da488f0ec66..8ae59ae9dc5 100644 --- a/docs/feature-map/README.md +++ b/docs/feature-map/README.md @@ -323,6 +323,7 @@ check cannot see it and the pages-dir half can. | Subagents | Background agent runs spawned from a session. A wave that is accepted but waiting shows why (`pages/chat/subagentQueuedReason.ts` renders the gate's `reason` from `subagent_queued`: a memory floor, macOS memory pressure or a paused adaptive cap; a bare count keeps the concurrency text) | Chat activity viewer; rail badge | `pages/chat/ActivityViewer.tsx`, `pages/chat/subagentQueuedReason.ts` | `handlers/messaging.py` | `GET,POST /api/spawn`, `POST /api/spawn/stop-all`, `GET /api/spawn/{agent_id}`, `DELETE /api/spawn/{agent_id}` (dismiss, including disk-only cards), `POST /api/spawn/{agent_id}/steer` | | Dynamic Dashboard | Template-based dashboards from one shared built-in catalog, for crewmates and root sessions alike, plus questions and one-shot tool approvals. The chat side panel's Dashboard view draws a header (title, help, permission mode), the Overview / Questions / Approvals segments with their counts, and notices; its Overview is the ROOT session's own Dynamic Dashboard (`CrewDynamicDashboard` with a session target), every field folded from that session's own work ledger and crew log. A dispatched or adopted session (`created_by` or a session-tree parent) gets no Overview and opens on Questions. Questions and Approvals stay host-rendered `AttentionCard`s. No status tiles are drawn above the composer. The + menu entry, a persisted Dashboard tab and the All Dashboards menu item are offered to every reader. The Crew chat's permanent Dashboard tab is the crewmate's own page. The gallery cards the sessions that have a card to show, by the same `canCarryCard` predicate the card frame fetches on (not private, not remote, no session-tree parent), with their saved summaries and decisions first; a dispatched session keeps its card because the card route serves it a derived board, while an adopted, incognito or remote session gets none and its questions and approvals still reach the gallery's Needs you inbox. The page's description names what an entry carries, since only a root session owns the dashboard PACKAGE. Optional automatic session cards update from session events under a shared model budget; unchanged layouts accept text-data updates. Only a root session (no `created_by`, no session-tree parent) gets one, and every number on it is folded from that session's crew log by `build_crew_main`, and the card frame renders those numbers in the dashboard's language from the phrases sent beside them (`crewMainPhrases.ts`): the model designs the layout and writes only `lede`/`you`/`notes`, and a card whose model part carries a digit is refused at publish. The pipeline conductor's work board is a DERIVED card instead: the panel publish route and the member-panel read assemble it from the typed board contract and the work fold with no model call, so it needs no budget and is not gated on the automatic-cards opt-in. The dashboard MCP tools write a crewmate's page or a root session's own page, never another's. Content publication time is separate from live host status. Isolated iframes cannot answer or approve. Native controls retain exact session/request routing and never change approval mode | Chat side panel + → **Dashboard** (root sessions get the Overview); Crew chat → its permanent **Dashboard** tab; Sessions three-dot menu → **All Dashboards** (`/session-dashboards`). **Automatic session cards** starts off and is enabled from its own card in Settings > Developer (a gateway-wide server setting) | `pages/members/MembersPageSidePanel.tsx`, `pages/chat/command-center/SessionDashboardsPage.tsx`, `pages/chat/command-center/CommandCenterPanel.tsx`, `pages/members/CrewDynamicDashboard.tsx`, `pages/chat/command-center/SessionStatusFrame.tsx`, `pages/chat/command-center/crewMainPhrases.ts`, `pages/chat/command-center/AutomaticCardSetting.tsx` (mounted by `pages/settings/DeveloperPanel.tsx`), `pages/chat/SidePanel.tsx` | `crew_main_contract.py`, `dashboard/card_lifecycle.py`, `dashboard/dynamic_cards.py`, `dashboard/handlers/agent_panel.py`, `dashboard/handlers/member_dashboard.py`, `dashboard_templates/instance.py`, `pipeline_board_contract.py`, `dashboard_card_pages/pipeline_board.html`; existing artifact, session, question, approval and workflow handlers | `GET /api/chat/slots/{slot}/dashboard`, `GET /api/members/{slug}/dashboard`, `GET /api/chat/slots/{slot}/dashboard-card`, `GET /api/members/{slug}/panel`, `POST /api/agent-panel/publish`, `GET /api/chat/slots/{slot}/summary`, `GET /api/sessions/{id}/crew-log/projection/work`, `GET /api/workflows/runs`, `GET /api/ask-question/pending`, `GET /api/approvals`, `POST /api/chat/slots/{slot}/approve`, `POST /api/approvals/{id}/{action}`, `PATCH /api/config/kirocrew` | | Worktrees | Create a git worktree for a follow-up session | Chat follow-up card → new worktree | `pages/ChatPage.tsx`, `pages/useChatPageSlotSwitches.ts`, `pages/chat/page/followupCardActions.ts` | `handlers/worktree.py` | `POST /api/worktree/create` | +| Branch switcher | Switch the project's local branch, check out a remote-only branch as a tracking branch, or create a branch at the current commit, from one searchable picker; off while the chat's response runs | Chat side panel → **Git** → the branch name in the panel header; or the branch segment of the project chip under the chat box (opens upward) | `components/BranchSwitcher.tsx`, `components/GitPanel.tsx`, `components/ChatInput.tsx`, `pages/chat/ActivityViewer.tsx` | `handlers/git_branches.py` | `GET /api/project/git/branches`, `POST /api/project/git/switch` | | Crew board | One conductor's work items as a board a human can scan — items waiting on a decision lifted into a band at the top, everything still open next, finished items collapsed behind an expander; each row carries the worker's `status` and the conductor's `state` as separate chips. An orphaned item (its conductor's session gone while the item is open) offers **Stop current turn** (the delegate cancels the turn that worker is running and leaves its session open, so the label promises only what the server delivers), with one dim line stating either why it cannot run or what stopping keeps; **Take over** renders no control at all while the server reports it unavailable, which is every gateway until a server-side primitive for it exists. Polls every 10 s; no payload carries a worker session key, so actions are keyed by item and resolved server-side | `/crew-board?conductor=`, reached from a session's overflow menu (**Crew board**, shown only for a session that owns a work ledger) | `pages/CrewBoardPage.tsx`, `pages/crewBoardRows.ts`, `components/CrewBoardMenuItem.tsx` | `handlers/work_ledger_board.py` | `GET /api/crew-board`, `POST /api/crew-board/action` | ## Settings diff --git a/docs/system-specs/modules/learn-cron-dashboard.md b/docs/system-specs/modules/learn-cron-dashboard.md index 0eb110b7828..2b0da37fde1 100644 --- a/docs/system-specs/modules/learn-cron-dashboard.md +++ b/docs/system-specs/modules/learn-cron-dashboard.md @@ -3709,6 +3709,8 @@ title row or badge is clipped or painted over. **Status/System**: `/api/status`, `/api/system` (live metrics, 1s refresh, static fields cached), `/api/stream` (SSE), `/api/ws` (WebSocket — single multiplexed connection replacing SSE + polling for React SPA) **Project git**: GET `/api/project/git?path=` (checked-out branch label for the activity panel). `path` is matched against the gateway's own known project directories (every live chat slot's `project` plus the recorded recent-projects list) using pure string normalisation (`expanduser` + `normpath`), with no filesystem access on the untrusted value; an unrecognised directory is refused with 403 plus an SEL audit record, and only the matched server-held string is stat'd. Response is `{"repo": false}`, `{"repo": true, "repoRoot", "isRepoRoot", "branch"}`, or the detached form `{"repo": true, "repoRoot", "isRepoRoot", "detached": true, "head"}`, plus `path` on the envelope. `isRepoRoot` is true only when the project directory IS the work-tree root, compared on the unredacted realpaths; a project nested inside a repository rooted higher up answers false, which is what the follow-up card reads to withhold its worktree action (`POST /api/worktree/create` refuses a toplevel above the session's project unless another session is scoped to that toplevel, and the card does not offer branching a whole enclosing repository from one folder of it). No `git` subprocess is spawned; repository discovery is the dashboard's one in-process walk, `git_repo_touches.find_git_root` (shared with the Git tab's repository list below: a linked worktree's `.git` file counts, `GIT_CEILING_DIRECTORIES` stops it as it stops git -- so a project under a ceiling reads as not a repository here exactly as the status and log routes' `git rev-parse --git-dir` reports it -- and the depth is bounded by `_GIT_ROOT_WALK_LIMIT`), and the branch comes from reading `.git/HEAD` directly (a `git` invocation parses repo-local config, which can carry `[include] path = ` and read an arbitrary file, so reading HEAD ourselves removes that class), and a linked worktree's `.git` pointer file (`gitdir: `) is followed to the real HEAD. Both metadata reads go through `hooks.safe_read_prefix` (realpath canonicalisation, sensitive-target rejection, `O_NOFOLLOW`, bounded read). `branch` and `head` pass through `redact` before being returned; `path` and `repoRoot` use the same redactor with one narrow path-specific exception, applied only on Darwin, for the fixed-shape `/private/var/folders/<2>/<30>/T` temp prefix, whose slash-inclusive entropy otherwise looks like a bare credential. The split sits at the END of the OS-generated `<2>/<30>` id: only that id is withheld from the scan, while the fixed literal `/T` and the complete user-controlled suffix still pass through `redact` together — handing `/T` to the redactor is what keeps the 40-byte credential window that would start on that `/` in scope. The SEL audit keeps the real path. Every failure mode degrades to "no branch" rather than erroring, since the only consumer is a decorative label. A detached HEAD gets a fixed 7-char prefix, not git's dynamic abbreviation. GET `/api/project/git/status?path=` and GET `/api/project/git/log?path=` use the same known-project boundary, widened by `_slot_git_repo_snapshot` to the git roots each live slot's tool calls touched (see **Project git repos** below). The status route first checks `os.path.isdir` and repeats that check after any subprocess failure; a missing or non-directory project returns `200 {"repo": false, "files": []}`. It performs no in-process inspection of `.git` or anything `.git` points to. Instead it runs sandboxed Git with `LC_ALL=C` and `LANGUAGE=C`: a bounded `git rev-parse --git-dir` probe owns repository discovery. A nonzero probe whose bounded stderr contains Git's `not a git repository` verdict is confirmed absence and returns `200 {"repo": false, "files": []}`. Symlink markers, gitdir files, parent-repository discovery, ceiling and cross-filesystem rules therefore follow Git. Any other probe failure, including sandbox refusal, dubious ownership, permission failure, timeout, kill, or missing executable, returns 503 with `code: "git_status_unavailable"` unless the project directory disappeared. After a successful probe, a nonzero `git branch --show-current` (which validates corrupt symbolic HEAD while accepting detached and unborn states), a failed or empty `git rev-parse --show-toplevel`, or a nonzero `git status` is also 503. **The filter-driver refusal is 503 too, not an empty listing, and it carries its OWN code.** A repository whose own config names a `filter.*.{clean,smudge,process}` driver -- anything `git lfs install --local` touched -- is refused because `git status` re-hashes modified files through a `clean` driver on every 5 s poll, and the probe refuses under its own separate cause when it cannot prove a driver absent. The match is deliberately WIDER than execution: `smudge` fires on checkout rather than on the status re-hash, a driver that no `.gitattributes` path maps to never runs at all, and the probe-failure branch refuses without knowing a driver exists. Refusing all three is right, since none of them can be proven safe -- but the user-facing message therefore says the config DECLARES a driver and that policy refuses the check, and never claims a program ran. Spelling that refusal `200 {"repo": true, "files": []}` makes it byte-identical to a genuinely clean repository, so the panel draws its green clean badge over a working tree whose status was never read; the panel's one job is surfacing uncommitted changes, and a confident "none" is worse there than no answer at all. The code is `git_status_filter_refused`, distinct from the `git_status_unavailable` its four siblings use, because this condition is not an outage: the cause is knowable, and for the `declared` cause it is permanent for that repository with no retry clearing it while the config stands (the `unreadable` cause is the one that can clear on its own, which is why only the declared copy promises permanence). One code for both would tell an LFS user their repository is broken on every poll forever. The panel renders it as a SINGLE `ErrorNotice` whose message names the cause, rather than the two generic notices the outage path shows: both routes refuse together because the cause is repo-level, so two boxes would report one condition twice. The refusal notice is TITLED and the outage notice is deliberately untitled -- titling both put two red boxes opening on the same word beside each other while they carry opposite retry semantics, so the difference between permanent and transient was a body-read away; titled against untitled separates them at a glance, and the outage title was restating its own first clause anyway. The refusal title names the OUTCOME ("Kiro can't check this repository for changes") rather than the mechanism: "status checks are off" collided with the outage string's "couldn't read the repository status", so one panel used the word status for two conditions of different severity. A coded status refusal reaching the non-coalesced branch renders that same title and localized copy, because the backend's English sentence under a localized heading is mixed-language copy in the other twelve catalogs. The header REFRESH control is also inert while BOTH routes refuse, and stays live for an outage: the refusal copy promises that retrying will not help, a re-fetch returns the same 503 every time, and a live control over that sentence contradicts it. It is disabled rather than hidden so the header does not reflow, and the glyph itself switches to the slashed variant, because dimming plus a hover tooltip did not tell a reader the control was dead. It is keyed on both routes rather than on the coalescing predicate deliberately: the refusal is permanent only while the repository config stands, and the notice's own agent hand-off invites changing it in the same window. The status query then recovers on its own 5 s interval while the log query -- no interval, 30 s `staleTime`, and a branch-change effect that cannot fire on the FIRST successful status because it needs a previous marker -- still holds its cached refusal. Keying the inert state on the coalescing predicate would therefore disable the one control able to clear that, leaving a notice claiming history cannot be shown while the changes list renders beneath it. The panel also refetches the log automatically as soon as the status route comes back CLEAN (not merely stops refusing: a status route failing for its own reason proves nothing about the config). The 503 body carries a `cause` discriminator alongside the code -- `declared` when repo config names a driver, `unreadable` when the probe could not prove one absent -- and the panel renders one localized sentence per cause. One code, because it is ONE condition (refused by policy) with two causes; a disjunction in the copy ("declares a driver, or could not be read") made the common case, an LFS repository on every visit, read a question the guard had already answered, and a reader could only partly follow it. Naming a driver as fact in the probe-failure branch would still be wrong, which is why that branch answers its own cause rather than borrowing this one. It stays an `ErrorNotice` with the `askAgent` hand-off because the string originates in a `{error, code}` body, which is what the `errors-use-error-notice` rule scopes on, and because a repo-supplied filter driver is precisely what the agent can explain or change -- routing it around that surface would strip the one affordance that helps here. HEAD is still validated BEFORE the filter guard, which `test_status_checks_corrupt_head_before_filter_refusal` asserts by spying that the guard is never reached, and the two codes now witness the ordering as well. A successful listing is capped at 500 entries and carries `truncated: true` when entries were dropped; the panel renders that as a capped count (`500+ uncommitted`) plus a `showing first 500` note on the Changes header, because without it the cap read as the total and an undercount was presented as a count. `/api/project/git/log` shares the status route's discovery boundary through the same `_probe_git_dir` probe (`LC_ALL=C`, `LANGUAGE=C`, bounded stderr) and the same verdict predicate, `_is_not_a_repo_verdict`, which is the one documented classification contract for both routes: a nonzero probe carrying Git's `not a git repository` verdict, or a project directory that disappeared, is `200 {"repo": false, "commits": []}`; every other probe failure (sandbox refusal, spawn failure, dubious ownership, permission failure, timeout, kill, corrupt repository metadata) is 503 with `code: "git_log_unavailable"`. The log route also applies the same filter-driver refusal and answers it with 503 `code: "git_log_filter_refused"`, since an empty commit list is what a repository with no commits legitimately returns. A nonzero `git log` AFTER a successful probe deliberately stays `200 {"repo": true, "commits": []}`: an unborn repository exits 128 there, so that path is genuinely ambiguous and separating it would need a stderr match of its own. The panel renders `git_log_unavailable` as the log-side outage notice -- untitled, with the localized `log_failed` copy rather than the backend's English sentence, mirroring `git_status_unavailable` -- and suppresses the clean-repository empty state while the log route has failed, because "no commits to display" beside a notice saying history could not be read presents an outage as an empty history. The Git panel treats only `repo: false` without a simultaneous status error as a terminal no-repository state: it replaces the loading label, hides the clean badge and clean-repository empty copy, and tells the user to click the folder name below the message box. The other two `['git-status', …]` consumers now receive 503s on operational failure where they got 200s, and each was checked rather than assumed. `FileBrowserRail` renders an inline `ErrorNotice` with the agent hand-off on an error in All mode, and deliberately not in Changed mode because the Pierre tree owns the same query failure there. `PierreWorkspaceTreeImpl` treats a status error as terminal for a Changed-mode load and renders that notice with a structured report. Both of those notices RECOGNISE the refusal codes and render the panel's own refusal copy rather than their generic failed copy, because the refusal is permanent for an LFS-configured repository: generic copy there would say "failed" on every 5 s poll, forever, with no cause and no statement that retrying cannot help — the exact outage spelling this refusal code exists to end. The recognition lives once in `website/src/utils/gitStatusError.ts` (`gitErrorCode`, `isGitFilterRefusal`, `gitFilterRefusalCause`, `gitFilterRefusalCopyKey`) and all three components read it, so one condition has one wording rather than three to keep in step. Neither of those two carries the panel's TITLE, deliberately: `ErrorNotice`'s `inline` variant lays title and message out as flex siblings, so at the rail's 300-520px and the tree's width the title wraps into a stack of two-word fragments beside a narrow column of message — visible in `capture-git-filter-refusal-surfaces.mjs`'s frames, which is what settled it. The title exists in the panel to separate a refusal from a coexisting outage notice, and neither of these surfaces renders a second notice beside this one. The status outage string also has a variant used when the log route failed too, which names the half it covers ("Couldn't read the changes list."): the default wording promises a commit list that is still on screen, so beside a notice saying history cannot be shown the two boxes first contradicted each other and then, once the clause was merely dropped, read as one failure reported twice. Naming each half is what separates them. Neither of the two reads non-200 as "not a repository"; both read `repo` only from a 200 body, so a refusal reaches them as absent data rather than as a negative answer. **Project git repos**: GET `/api/project/git/repos?slot=` lists the repositories the chat side panel's Git tab shows for one session, because most sessions start in the shared workspace (not a repository) and work in repositories elsewhere. `kiro_crew.dashboard.git_repo_touches` notices them from the session's own tool calls in `chat_runner` (both `tool_call` and its `tool_call_update` refinement): an edit call's target paths (`edit_target_strings`: the params walk the edit gates run, `target_paths`, plus patch headers, with no path expansion), a shell call's `working_dir`/`cwd`, and a leading `cd `/`pushd ` or `git -C ` in its command (absolute or `~` only; a relative edit path resolves against the session's project and is dropped without one). Each candidate walks up to the nearest `.git` entry through `find_git_root`, the same walk the branch label above uses (a linked worktree's `.git` file counts; `GIT_CEILING_DIRECTORIES` stops the walk as it stops git), and the real root is kept on the slot (`_ChatSlot._git_repos`, oldest to newest, capped at `MAX_SLOT_GIT_REPOS`) unless it is the home directory, the filesystem root or a sensitive path. The walk never runs on the runner's event path: `note_tool_call` snapshots bounded raw candidate strings and a per-call `truncated` bit on the loop, retaining them only until `finish_tool_call` confirms successful execution. Refused, failed, cancelled and unfinished calls discard their candidates and loss bit; turn cleanup also clears proposal-eviction uncertainty. Executed calls queue their candidates on the slot (`_git_repo_walk_pending`, distinct, a retouched path moved to the newest end, bounded by `_MAX_PENDING_CANDIDATES_PER_SLOT` with the oldest dropped first) and returns; one walker task per slot (`_git_repo_walk_task`, held in `state._background_tasks` like every other dashboard fire-and-forget task) resolves the whole pending list in a worker thread and loops until nothing is queued, so a `stat` that hangs on a dead mount holds that task and never the turn's stream, the runner's next event is handled at once, calls land in call order (two concurrent walks could list an older touch as the newest), and a burst of calls is one thread hop. A slot closed mid-walk receives its roots on an object nothing reads; a loop shut down mid-walk cancels the task and asyncio drops the thread's late result. No broadcast follows a walk: the Git tab polls the listing (`refetchInterval` 10 s), so a root recorded after the turn's event shows on the next poll. Each listed repository is a collapsible section whose status query polls every 5 s only while it is open, and only the `MAX_POLLING_SECTIONS` (3) most recently opened sections poll at once: repositories the agent edited open by themselves, so without the cap every dirty repository would run its git subprocesses on every interval. A section the user opens becomes the newest and always polls; an open section past the cap fetched once and refreshes on window focus and on its Refresh control. The list is in memory only; the first listing after a restore folds in the roots the transcript's `file_changes` rows resolve to (`_git_repos_seeded`), so edited repositories come back while a repository the agent only ran commands in reappears on its next touch. The response is `{repos: [{path, source}], omitted: , listed: , incomplete: }`: `incomplete` is always present and reflects the sticky, slot-local `_git_repos_incomplete` flag, or a row refused for redaction in this response. Candidate-count, edit-traversal, patch-parser, command-directory, command-text/argv, refinement-merge and path-length cuts carry their loss through the call's snapshot, including an empty snapshot, and publish it only on execution. Evicting executed paths from the walker backlog also sets the flag. Pending-proposal eviction retains only one turn-local uncertainty bit, not an unbounded tombstone list: a later successful result with no retained proposal conservatively marks the list incomplete; a refusal or turn cleanup does not. Evicting the oldest terminal receipt marks nothing incomplete: the refinement a receipt waits for is read from the same wire update as its result, so it has arrived long before 64 later results are recorded, and most receipts belong to calls that name no path. Worker-thread expansion/canonicalization path bounds and the ancestor-walk depth budget return loss alongside roots, as do transcript-seed count/path bounds, and the listing folds those flags back onto the slot even when no roots survived. Home, filesystem-root, sensitive, unanchored and not-a-repository exclusions do not themselves mean truncation. The flag is not a missing-repository count and never inflates `omitted`; the Git tab renders it once per snapshot as a localized muted informational line, even on an empty list: "Some repositories the agent worked in may not be listed. Each one appears here once the agent works in it again." No path expansion or filesystem access is added to loop-side bookkeeping. `omitted` is always present and counts distinct repositories this session worked in that the list leaves out because only the `MAX_SLOT_GIT_REPOS` most recent are kept, including transcript seeding: evicted roots are remembered by path (`_ChatSlot._git_repos_evicted`, capped at `MAX_SLOT_EVICTED_GIT_REPOS`) and a root touched again leaves that list, so revisits never inflate it; older evicted roots are kept only as a count (`_git_repos_evicted_overflow`), so past that bound the number is an upper estimate (a forgotten root that returns can be counted twice). Deleted repositories filtered from the response do not count. `listed` counts the agent-touched repositories the response actually lists (the project's own included only when the agent worked in it). The Git tab's capacity notice reads its numbers from `listed` and `omitted` ("The agent worked in repositories; the most recent are listed."), so an untouched project row or a deleted repository never skews them; the notice is a localized informational line below the list, shown when the count is positive, and names no action because the server keeps nothing it could reveal. When the list is non-empty the tab opens with a one-line caption saying the rows are the repositories the agent worked in during this chat, with a second sentence appended only when the chat's own project is among them to say the project folder is listed first, so the Project tag's meaning is visible without its tooltip; the empty state says how the list fills (the agent editing files or running commands in a repository); the listing-failed notice carries a Retry that refetches beside the agent hand-off. The session's project comes first when it sits in a repository (`source: "project"`, `path` the project directory itself), then every touched root that still has a `.git`, most recent first (`source: "agent"`), each `path` accepted verbatim by the status and log routes. A row whose path the credential redactor (`_redact_project_path`) would mask is left out of `repos` and of `listed` and sets `incomplete` for that response: the masked string is not a path those routes accept, so the row could only show an error, and the unmasked path never reaches the client. The touched roots join those routes' allow-list (and only theirs: the tree, path completion and branch label keep the project-only boundary) because they are server-derived from the session's own tool calls, never from a request. An app token reads only a slot it owns on its own session and transcript (`deny_app_slot_session_access`, audited when the slot exists); any other slot is the same 404 as a missing one, and for an app token the status and log routes admit only the touched roots of the slots it owns (`app_owns_slot_session`). +**Project git branches**: GET `/api/project/git/branches?path=` and POST `/api/project/git/switch` with `{path, branch, create?, track?}` back the Git panel header's branch switcher (`website/src/components/BranchSwitcher.tsx`, read only while the picker is open). They live in `dashboard/handlers/git_branches.py` and reuse the boundary `POST /api/worktree/create` draws for the one other repository-changing route: `path` must name or sit inside a live chat slot's project, and so must the git toplevel it resolves to (`repo_root_outside_project` 403 otherwise, which also refuses a `core.worktree` pointed outside the grant), and git runs through `worktree._run_git` (strict sandbox, argv list, `core.hooksPath=`, `core.fsmonitor=false`) with `LC_ALL=C` and `LANGUAGE=C` laid over the scrubbed environment (`env_overrides`), because the failure codes below are read from git's English diagnostics. Only git's own `not a git repository` verdict on the toplevel probe is absence (404 `not_a_repository`); any other failed probe, dubious ownership included, is 503 `git_branches_unavailable`. The listing is dashboard-caller gated and returns `{repo, current, detached?, head?, local, remote, truncated?, switchBlocked?}`: local branches by `for-each-ref --sort=-committerdate`, capped at 200 per section with `truncated`, each with commit subject/author/date and upstream ahead/behind; remote-tracking branches minus `/HEAD` and minus any whose short name already has a local branch. Every string is redacted, and a name the redactor alters is returned with `switchable: false`, since echoing a redacted name back could never resolve. A repository whose config declares a checkout filter driver (`_checkout_filter`) still lists, with `switchBlocked: "filter"`, so the picker can say why its rows are inert. The switch is OWNER-gated (`require_owner_dashboard_request`, as `/api/file-write` is: it rewrites working-tree files), serialized per repository on `worktree._repo_lock`, and refuses the filter case with 409 `git_switch_filter_refused`. An existing name must be unambiguous as a ref (no leading `-`, no `@{`, no whitespace) and resolve under `refs/heads/`, then runs `git switch --no-overwrite-ignore --no-guess -- `; `create` requires the follow-up branch grammar and runs `git switch --no-overwrite-ignore --no-track -c`; `track` must resolve under `refs/remotes/` and runs `git switch --no-overwrite-ignore -c --track refs/remotes/`. git's own refusal to overwrite uncommitted or untracked changes is relied on, never bypassed, and `--no-overwrite-ignore` extends it to ignored local files the target branch tracks (git's default replaces those silently); both are reported as 409 `git_switch_dirty`; other coded failures are `git_branch_exists`, `git_branch_in_worktree`, `git_operation_in_progress`, `git_branch_not_found` and `invalid_branch`, with uncoded ones as 400 `git_switch_failed` carrying git's redacted first line as `detail`. The picker renders each code as localized copy, and after a switch invalidates the status, log, branch, project-tree and project-git (composer chip) queries. + **Project tree**: GET `/api/project/tree?path=` (workspace file listing for the Files tab's Pierre tree, and for a chat side-panel `folder` tab whose path IS the chat's project root — `FolderPanel` renders the same `PierreWorkspaceTree` there so descending expands in place instead of re-targeting the tab, and falls back to its one-level `browseFiles` listing for every other directory and whenever this endpoint does not answer; while the payload is `truncated` the tab keeps the tree but sends its search box to the recursive server search (`/api/file-search`), whose matches stand in for the tree until the query is cleared (the tree stays mounted, hidden, so the folders the user expanded are still open when it returns), because the tree's own filter can only find what the capped tree lists; the matches are the server's merged with the tree's own paths that contain the query (case-insensitive, de-duplicated, the server's first), because the server search runs under its own scan budget and can miss a file the tree is already showing; a line above them (`search_beyond_tree`) says results can include files past the 10,000-item limit -- not that it covers the whole workspace, which that budget cannot promise -- since on a whole tree the same box narrows the rows in place, and the tab's Refresh re-runs that search too, as the listing body's Refresh does, because it is the retry beside a failed search -- because a directory path chip can name any directory on the gateway while this allow-list deliberately does not). File tabs start with their optional project-tree rail hidden so the document receives the full panel width; the toolbar toggle and persisted `mc-files-rail-open` preference keep it available on demand. Same known-project allow-list, sensitive-path refusal, and SEL auditing as `/api/project/git`. Inside a git repository the listing is `git ls-files -z --cached --others --exclude-standard` with `cwd=` (tracked + untracked, `.gitignore` honored; a project dir that is a repo subdirectory lists only its own subtree); outside one -- or when that listing fails (a nonzero exit: its 8 MiB output cap, its 15 s timeout, or no sandbox backend) -- it is ONE bounded breadth-first pass (`_project_tree_walk`, `os.scandir` per folder, never following a link) that skips tool and heavy directories (`_PROJECT_TREE_SKIP_DIRS`), walks every other dot-directory (e.g. `.worktrees`), drops every entry, directory or file, that is dot-named or sits under a dot-path (judged on the read folder's real path, ancestors above the project root included) when `is_sensitive_resolved_path` fences its real path (`_project_tree_fence`), and stops reading after `_PROJECT_TREE_SCAN_LIMIT` (20 × the row cap, 200,000) directory entries, so its cost is a function of that number rather than of the size of the tree (about half a microsecond an entry). The budget is shared so one large folder cannot starve its siblings, or their subfolders, wherever it sorts, without cutting a tree that fits: each folder of a depth, and the depths below as one more claimant, is guaranteed a floor of half the even split the depth starts with, and the folders are read in order, each up to everything the claimants after it do not need for their floors -- capped at the row cap, more entries than one folder could show -- and never less than an even split of what is left. So a tree of at most about half the scan limit whose folders each hold no more than the row cap is read whole, wherever its large folder sorts. A folder holding more than its share is cut there and named in `truncatedDirectories`; the entries it did not read, subfolders included, are unknown. Telling a full folder from a cut one costs one look-ahead entry, drawn from the same budget, so no listing reads more than the limit. A folder whose row falls past the cap is never read, and a read cut by the budget is never judged hidden-only. A subfolder is queued when its parent is listed and read one level later, so each queued folder carries the `(st_dev, st_ino)` its parent's listing recorded, and is read only if it is still that folder (`_project_tree_scandir`): on POSIX the name is opened `O_DIRECTORY | O_NOFOLLOW`, the descriptor `fstat`ed and compared, and the descriptor itself listed; on Windows, which lists only by name, the name is `lstat`ed, compared and refused as a link (a name-surrogate reparse point) just before the `scandir`, leaving the window between those two calls; a reparse folder that holds its own contents, such as a cloud-files placeholder, is walked like any other folder. A folder swapped in between -- renamed away with another directory or a link put at its name -- is shown as a row, named in `unreadableDirectories` (a row that claims nothing about its contents, never `truncatedDirectories`, which every consumer renders as the item limit), and nothing behind it is listed; a root swapped for a link after the handler resolved it fails its `O_NOFOLLOW` open the same way and is named `.`. Response `{root, paths: [], directories: [], repo, truncated?, truncatedDirectories: [], hiddenOnlyDirectories: [], unreadableDirectories: [], linkedDirectories: []}`. `paths` and `directories` TOGETHER carry at most `_PROJECT_TREE_MAX_ENTRIES` (10,000) rows on both branches (`_project_tree_allot`): directory rows are spent first, shallowest first (the walk's breadth-first discovery order; the git branch orders its derived folders by depth, then segment by segment, which is the same order), because a folder row is what lets the user navigate to the rest -- but never past half the cap while there are files to show, so a tree with more folders than the cap still lists its root files (`README.md` included) -- and the files share their rows round-robin across their direct parent directories, the root first, so no one folder takes every row; half is a floor for each side, not a split, and rows the files cannot use go back to folder rows without those folders' files then sharing the file rows; a file whose folder lost its row is not listed either. Empty directories on the filesystem-walk path are rows like any other. Folder rows are capped together with files rather than kept as an uncapped skeleton, because uncapped folder rows make the walk, the per-row redaction and the serialization linear in the size of the tree, and the tree is refetched every 10 s while it is open (paused while the read is failing), so a large non-git project dir would stall the gateway. The client bounds each read: 15 s for the file search and the tree (`FILE_SEARCH_TIMEOUT_MS`), 10 s for the browse and recent-project listings (`BROWSE_FILES_TIMEOUT_MS`); a timed-out or failed read gets a cause-keyed notice, with Refresh/Retry offered only for those two causes. On the walk the file fairness is over what the walk READ: a folder it never reached contributes no file and, having a row, is named in `truncatedDirectories`. `hiddenOnlyDirectories` is walk-only: it names the directories the walk leaves childless although they are not empty on disk (no file or directory the listing keeps remains, and every entry is a `_PROJECT_TREE_SKIP_DIRS` directory or a fenced entry, so a `.docker/` holding only a fenced `config.json` is hidden-only), so the tree can tell them from genuinely empty ones (`_bg/` holding only `.kiro/` is the reported case); a directory with a listed file or a kept subfolder is never in it even when it also holds hidden entries; the root is judged by the same rule and, when its top level holds only such entries, is named as `.` (it is no directory row of its own), because the payload would otherwise be the empty-workspace shape and the dashboard would call the workspace empty — the frontend then shows a root-specific notice in the tree's place instead of the empty-workspace one (`This workspace contains only hidden items (skipped tool and cache folders, protected entries)`, `root_hidden_only` — not the per-folder row's wording, which with no folder row above it read as being inside a folder), with no action row since there is nothing to act on; and the git branch returns `[]` by construction, because a directory row exists there only as an ancestor of a listed file, so an ignored-only folder is absent rather than childless and the only childless directory that branch can produce is a truncated one. `linkedDirectories` is walk-only too: a symlink to a directory is a visible, navigable entry (`ls` shows it), so it does NOT make its folder hidden-only; the walk never follows a link (`_project_tree_is_link`: a symlink or a name-surrogate reparse point such as a Windows junction, `platform_compat.lstat_is_name_surrogate`; against link cycles) and so never reads beneath it, so the walk lists it as a directory row of its own and names it here — nothing beneath it is listed, and the frontend puts `Link to another folder: contents not listed` (`row_linked`) beneath its row instead of calling the link, or the folder holding only links, empty; links are told apart among the entries the hidden/skip filter KEPT, because what a folder shows must be predictable from the name alone — a `node_modules` or `.cache` that is a link to a directory elsewhere is filtered exactly like its real twin and counts as a hidden entry (a folder holding nothing else is hidden-only), so link-ness the user cannot see never decides visibility; a link the filter keeps is a row, and a folder holding only kept links is not hidden-only; inside a repository the list is empty by construction (git lists a symlink as a file). `unreadableDirectories` is walk-only too: a folder is a row as soon as its parent's read names it, so a kept, non-link child the process cannot read (permission denied is the usual cause) is still a row of its own, and when its own read fails -- `scandir` refusing it, its listing failing part-way, in which case what it yielded first is discarded rather than shown as if it were the folder, or the `lstat` that records its identity failing after its parent listed it (a parent readable but not searchable) -- it is named here rather than in `truncatedDirectories`, so the tree shows the folder while its parent is not childless at all; any read failure counts, not only `EACCES`, because the parent listed the entry and a row that makes no claim about its contents is the honest rendering whatever stopped the read. An unreadable folder whose row falls past the row cap is neither a row nor named here (its parent is in `truncatedDirectories` instead): this list names only folders the payload shows, so the notice below never names a folder the tree has no row for, and the same holds for `hiddenOnlyDirectories` and `linkedDirectories`. Because the value behind that entry is a failed read, it is an error and reaches the user only through `ErrorNotice`: the frontend renders one above the tree naming the folders (`Folders not readable: `, `unreadable_folders`, hand-off on since the listing holds no draft; always the alert in the danger tone — an error is never toned down to a status or a muted register, only shown or, at the user's word, not shown. Its ✕ DISMISSES it: the control is named `dismiss_unreadable` (`Dismiss: hide this notice until the unreadable folders change`), carried as its `aria-label` AND its `title`, so the promise a remembered dismissal makes is read before the click by hover as well as by a screen reader; the click leaves, where the notice stood, a one-line `role="status"` "Notice hidden — N folders still unreadable (shown again when they change) — Undo" (`unreadable_dismissed`, a plural key on the count of folders the payload still names, + `undo_dismiss_unreadable`; muted, saying what the ✕ did and did not do — hidden, not fixed, how many folders are still unreadable and that the notice returns when they change — but never which folders: a count is not a toned-down copy of the error's text) for the product's one undo window (`MOVE_UNDO_MS` on `useHeldWindow`: held while the pointer is over that spot or focus is inside it, retired for good when the notice returns on its own, never carried across a remount; the focus hold is cleared at every swap of the spot's content, because the control that was activated had focus and a removed element fires no `focusout` — left alone, the hold would pin the offer open for the life of the mount — and when that control had focus, focus is handed to its counterpart, Undo after the ✕ and the ✕ after Undo, so a keyboard dismissal lands on the way back rather than at `` with the window running) whose Undo restores the alert and CLEARS the remembered set, so a mis-click on a ✕ that sits beside "Ask the agent" at the same weight has a visible way back; the dismissal remembers the folders the notice named for that project — per project directory, in the expansion memory's own store under its own key (`treeUnreadableDismissals.ts`, one `createProjectPathMemory` instance: module map for the remount, `localStorage` mirror for the reload, the same caps) — and while every folder the payload names is remembered the notice is not rendered, so a folder that stays unreadable by design is not red alarm chrome on every visit; a folder outside the remembered set brings the alert back with the whole list, and a remembered folder that drops out of the payload is forgotten (the set is pruned to each landed payload, never to a mount that has not heard from the server yet, and in a `truncated` payload a remembered folder absent from it is kept while the cut could be what hid it -- its parent is absent too or is itself in `truncatedDirectories` -- and forgotten when its parent is listed whole), so the same folder failing anew alerts again rather than inheriting its old dismissal), plans no state row beneath such a folder (nothing beneath it is known, and a status line inside Pierre's shadow root can carry neither the hand-off nor an error), and gives the folder's own row a lock marker whose accessible label points at that notice (`Not readable — see the notice above`, `row_unreadable_marker`, Pierre's decoration `title`: the tooltip and the accessible name of the decoration span; once the notice is dismissed the label says so instead, `Not readable — notice dismissed`, `row_unreadable_marker_dismissed`, so it never sends the reader to a notice that is not there) — a signpost to where the failure is reported, not a report of it: it states no failure, carries no hand-off of its own, and is fed by the same `unreadableDirectories` list the notice renders, so the reader can tell that folder from the ones that show nothing for another reason and can never see a marker the notice does not name; the folder is never called empty, and the notice and the markers follow the payload and go when the folders read again. Its files are never listed (nothing read them); the root itself failing is recorded as `.` — the walk then yields nothing, so without it the payload would be indistinguishable from a workspace with no files in it, and the root is no directory row of its own, so it is named only here (the frontend shows, in the tree's place, an `ErrorNotice` whose lead is `No permission to read the workspace folder` (`root_unreadable` — it names its cause, so it cannot be read as the host's own `Couldn't load the file tree`, which is a failed request where this listing answered; permission is the failure a directory that passed the `isdir` check produces, and a root removed between that check and the walk shows the same notice for one refresh) and whose message is the redacted `root` path in a span of its own, with a zero-width break hint after each `/` so a narrow rail wraps it at a segment boundary rather than mid-word (a Windows-shaped root — drive letter or UNC — is shown in its forward-slash form first, `normalizeWindowsPath`, since its backslash segments would get no break point; any other path is untouched) — display only: the notice's `report` carries the sentence and the real path to the agent — followed by the hand-off and a Refresh that re-asks the listing, the same two actions as the host's failed-listing notice, instead of the empty-workspace notice; one level down the row sits under the folder it qualifies, here no folder row exists to anchor it); and the git branch returns `[]` by construction for the same reason as `hiddenOnlyDirectories`: `--others` cannot scan a directory git cannot read, so it contributes no untracked file and, with no indexed file beneath it, is absent rather than childless, while an indexed path beneath it still comes from the index (`--cached` reads no directory) and makes it an ordinary populated row. The frontend passes those directories to Pierre as explicit trailing-slash paths, keeps the workspace-level truncation notice, and appends one synthetic state row beneath every folder the payload leaves childless (`website/src/pierre/treeStateRows.ts`): `Some items not shown: limit of 10,000 items reached` for a `truncatedDirectories` entry (checked first, since a folder that lost its contents to the cap is not empty; the workspace-level notice, `workspace_truncated`, names the same `limit of 10,000 items reached`, so the two are read as one fact; neither promises that every folder is listed, because the cap counts folder rows too), `Link to another folder: contents not listed` for a `linkedDirectories` entry (checked before hidden-only: a link is never walked, so it is neither truncated nor hidden-only), `Contains only hidden items (skipped tool and cache folders, protected entries)` (`row_hidden_only`) for a `hiddenOnlyDirectories` entry (the cause, as `row_truncated` names its own — the parenthetical says what "hidden" means here, items the listing hides by nature, not items the app cut off), and `Empty folder` otherwise (an `unreadableDirectories` entry is skipped before the choice, whatever else names it, so a folder nothing read is never called empty; the order only fixes what a malformed payload shows). A truncated folder's row also carries the `some items not shown` badge (`workspace_directory_truncated`; the same "not shown" vocabulary as its state row, so "hidden" is reserved for what the listing hides), and the badge yields while the folder is expanded over its own state row, so the cut is stated once at a time (except under an `Open in folder tab` row, which does not state the cut, so the badge stays). `@pierre/trees` renders rows for paths and has no slot for a status line, so each state row is fed to the model as one synthetic child path whose basename is the localized label followed by a trailing zero-width space (U+200B, `STATE_ROW_MARKER`) — the row's identity in the plan, and only that: a real file can end in U+200B too, so the styling never keys on the path. The wrapper's `renderRowDecoration` gives a planned row (a path in the plan's set, the same set its selection and context-menu guards read) a decoration icon `STATE_ROW_ICON` — an `` with no symbol behind it — and `PIERRE_TREE_STATE_ROW_CSS` selects the row carrying that decoration (`:has()`), never the path suffix and never the label, so a real file named `x` plus U+200B, or one named "Empty folder", keeps its icon, its pointer and its open; the wrapper keeps the planned row inert (no open, no selection, no context menu) with one exception: where the host passes `onOpenFolder` (the Files rail, in the side panel and the pinned Files tab), a `truncatedDirectories` row reads `Open in folder tab` (`row_truncated_open`), carries its own decoration (`STATE_ROW_ACTION_ICON`, link-styled with the pointer) and, when selected, is deselected and hands its project-relative folder to the host, which opens that folder's tab (`FolderPanel`, the per-folder listing and name search with a growing page); that row still has no open, drag or context menu, and the folder's `some items not shown` badge stays on beside it because the row no longer names the cut, and a `/` in a translated label is rewritten to the division slash (U+2215) so a label can never mint a subfolder. While the panel's filter is active only the state rows whose FOLDER matches the query are fed to the model (`stateRowsMatchingFilter`: the folder's listed `dir/` path, lower-cased, contains the query as Pierre normalizes it — trimmed, backslashes read as slashes, lower-cased), because two things hold at once: Pierre matches every listed path, so a row fed on a match of its label alone ("hidden", "folder") would surface a status line as a search result; and Pierre force-expands every matching directory in its hide-non-matches mode, re-applying that on every store event, so a matched childless folder cannot be kept closed and, with its row withheld, would open over nothing — the down-chevron with no row beneath it that the state row exists to prevent. A fed row matches through the same folder prefix and sits under its open folder, the badge yielding to it as outside a filter; a blank query feeds the whole plan. Pierre focuses the first match, and the fed row sorts first under its folder, so when that focus lands on a state row the wrapper hands it to the folder the row qualifies (`focusPath`, which raises no store event) — a focus ring never sits on a status line. The two resets the filter brackets (filter on, filter off) carry the model's expansion in a host that does not persist it, because Pierre snapshots the expansion when a filter opens and restores it when it closes. No "loading" or "error" state row exists: whether a folder has children is decided by the payload the moment it lands and never by a fetch in flight; Changed mode plans no state rows at all, since every folder there is the parent of a changed file. `truncated` is true exactly when `truncatedDirectories` is non-empty -- files or folder rows were omitted. A truncated payload with no row at all (the walk stopped before it reached anything it lists, such as a root whose first entries are all hidden folders) shows its own `root_truncated` sentence, `Workspace too large to list: limit of 10,000 items reached`, in the empty-workspace notice's place (`workspace-tree-root-truncated`), since nothing there is known to be empty. Pierre's `flattenEmptyDirectories` folds a folder holding one listed subfolder and no file into one chain row whose path names its LAST folder, so the badge is looked up for every folder in the row's `flattenedSegments`, and yields to the state row only when the chain's last folder is the one truncated folder in it. Older clients may ignore the five additive arrays. `root`, every file path, every directory path, every truncation marker, every hidden-only marker, every linked marker, and every unreadable marker pass through the same segment-aware redaction before egress -- in the same worker thread as the listing and the JSON serialization (`_project_tree_body`), because each is linear in the size of the listing and none may run on the event loop; `root` is an absolute project path like `/api/project/git`'s `repoRoot`, so it takes the same Darwin temp-prefix exception described above, while the project-relative paths carry no OS-owned prefix and stay on the canonical redactor. **Folders past the cap**: `?dir=` lists one project-relative folder instead of the whole tree, under the same row cap, its paths still project-relative and the folder echoed as `dir`. Every check on `path` (the known-project allow-list, the sensitive-root refusal, the audit record) runs first and unchanged; `dir` must then be a plain relative path (no empty, `.` or `..` segment, no leading `/` or NUL, and on Windows no drive, `\` or `:`) whose real path is exactly its plain join under the root, so any link along it is refused, and strictly inside the root and not sensitive (`_project_tree_subdir`; `dir_refused` 403, `dir_not_found` 404, each audited as denied). The folder is then pinned: `_project_tree_pin` reaches it through `pinned_fs.open_pinned_descendant_dir` (a descriptor walk refusing a link at every component; on Windows that helper's `lstat` per prefix and its documented by-name residual) and records its `(st_dev, st_ino)`; the walk reads it only while it is still that folder (`_project_tree_walk_pinned`), and git lists it from the project root with the folder as a literal pathspec (`GIT_LITERAL_PATHSPECS`) rather than as its working directory, so an ancestor swapped for a link after the check lists nothing outside the project. On a platform that cannot pin by descriptor (Windows, where git walks into a junction) a folder listing uses the identity-checked walk instead of git, so it does not honour `.gitignore` there (the walk still skips `_PROJECT_TREE_SKIP_DIRS`); a git listing that names nothing is not shown as an empty folder row. The tree (`PierreWorkspaceTreeImpl`) asks for a folder's own listing when the user expands a folder named in `truncatedDirectories`, or when a host reveals one, under `['project-tree', projectDir, 'dir', folder]` so the host's Refresh re-reads it (not polled on their own: each whole-tree poll that lands re-reads at most three of the loaded folders that are open, in turn, since the whole listing cannot see a change inside a folder past the cap; a listing whose latest read fails is named in a notice above the tree, `folder_listing_failed`, whether or not earlier rows are still shown, with its own Retry button beside it, `folder_listing_retry`, that re-reads those folders); at most 20 folder listings, and at most 40,000 rows across them (`MAX_FOLDER_LISTING_ROWS`, the newest listing always kept), are kept per mount (a folder whose listing that bound drops while it is open is not reloaded until it is closed, so the bound never cycles), and `mergeFolderListings` (`pierre/treeFolderListings.ts`) folds them into the rendered payload, a loaded folder and every folder beneath it leaving `truncatedDirectories` unless its own listing cuts them again; a loaded folder whose listing answers 404 (deleted since) or 403 (refused, e.g. a link) is dropped and, like one the bounds drop, not reloaded until it is closed, while a notice of its own naming it, `folder_gone` for a 404 or `folder_refused` (a link or a protected folder) for a 403, with no retry since none can change it (a 404 also re-reads the whole listing once, which still named the folder), stays until the user dismisses it or a later load of that folder succeeds; a revealed folder is expanded, focused and selected. While the whole payload is truncated the Files rail (`FileBrowserRail`) sends its name filter to `/api/file-search` (`NameSearchResults`, files and folders, 50 rows) instead of the tree's local filter, under the folder tab's own note (`search_beyond_tree`; the same budgeted walk, the same caveat), its failure copy the folder tab's own (`SEARCH_FAILURE_KEYS`, `lib/searchErrorCause.ts`), its hits joined by the cut tree's own matching files through the folder tab's own `mergeTreeMatches` (`lib/treeSearchMatches.ts`), also when the search fails, the tree kept mounted behind it; a folder hit reveals that folder in the tree and a file hit opens it. **Theme / display**: GET `/api/theme/boot` (**unauthenticated** — same boundary as `/api/health`; returns `{mode, color, language, onboarded, import_onboarded, privacy_acked, crewmates_onboarded}` so the SPA can apply the workspace theme, pick its UI language, and resolve onboarding gates before the token flow completes; no secrets), GET/PUT `/api/config/theme` (read/persist workspace display prefs `{mode?, color?, language?, onboarded?, import_onboarded?, privacy_acked?, crewmates_onboarded?}`; `mode` restricted to `""`/`dark`/`light`/`system`; `language` is `""` (auto-detect from the browser) or a BCP-47 tag validated for **shape only** via `_LANGUAGE_TAG_RE`, so adding a shipped language stays a frontend-only data change; the PUT validates the whole body before writing and persists ONLY the named `dashboard.*` keys through `run_config_write(update_config_locked, ...)`, never a whole-document `save()`, and answers an unreadable `config.json` with `500 {"code": "config_unreadable"}` leaving the file untouched, a write the config guard refuses with `400 {"code": "config_write_refused"}`, and a failed write with `500 {"code": "config_write_failed"}` — see `config.md` → "`KiroCrewConfig.save() -> None`"). All four response sites are built by one helper (`handlers/core.py::_theme_payload`) so a newly-added display pref cannot be surfaced by some and omitted by others — see `config.md` → "Dashboard UI language". **Foreign-agent import**: authenticated GET `/api/onboarding/import/scan`, POST `/api/onboarding/import/apply`, PUT `/api/onboarding/import/state`; see "Foreign-Agent First-Run Import". diff --git a/src/kiro_crew/dashboard/handlers/git_branches.py b/src/kiro_crew/dashboard/handlers/git_branches.py new file mode 100644 index 00000000000..8bddb9f97db --- /dev/null +++ b/src/kiro_crew/dashboard/handlers/git_branches.py @@ -0,0 +1,628 @@ +"""Branch listing and switching for the dashboard Git panel. + +Two endpoints back the panel's branch switcher: + +* ``GET /api/project/git/branches?path=`` lists the repository's local + branches (most recently committed first) and the remote-tracking branches that + have no local counterpart, plus which one is checked out. +* ``POST /api/project/git/switch`` with ``{path, branch, create?, track?}`` + checks out an existing local branch, creates a new branch at ``HEAD`` + (``create: true``), or creates a local branch tracking a remote one + (``track: "/"``). + +Trust model +----------- +This is the same boundary ``POST /api/worktree/create`` draws for the one other +dashboard route that changes a repository, and it reuses that module's +machinery rather than restating it: + +* ``path`` must name, or sit inside, a directory an existing chat slot is scoped + to, and so must the git toplevel it resolves to (a subdirectory grant cannot + reach the parent repository). Only the server-held root is used as a path. +* git runs through :func:`kiro_crew.dashboard.handlers.worktree._run_git`: the + ``sandboxed_spawn_argv`` chokepoint in strict mode, an argv list with no shell, + resource limits, a timeout, ``core.hooksPath`` pointed at ``os.devnull`` (no + ``post-checkout`` hook runs) and ``core.fsmonitor`` off. +* A checkout runs ``filter..smudge``/``.process`` drivers, which ``-c`` + cannot disable by name, so a repository whose own config declares one is + refused before the switch (:func:`~kiro_crew.dashboard.handlers.worktree._checkout_filter`). + The listing still answers there, carrying ``switchBlocked: "filter"`` so the + panel can say why switching is off instead of failing on click. +* The switch rewrites files in the working tree, so it takes the same OWNER gate + as ``/api/file-write``; listing takes the dashboard-caller gate the worktree + route uses. +* Branch names come back through ``redact`` like every other repository string + the dashboard renders. A name the redactor alters cannot be switched to by + echoing it back, so it is marked ``switchable: false`` rather than offered as a + row that fails on click. + +``git switch`` itself refuses a switch that would overwrite uncommitted or +untracked changes, so the working tree is never discarded; that refusal is +reported as ``409 git_switch_dirty``. Non-conflicting local changes carry over +to the new branch, as they do on the command line. +""" + +from __future__ import annotations + +import asyncio +import logging +import os +import re +import subprocess + +from aiohttp import web + +from kiro_crew.dashboard.chat_handlers import deny_non_dashboard_caller +from kiro_crew.dashboard.handlers.files import _is_not_a_repo_verdict, _run_git_bounded +from kiro_crew.dashboard.handlers._shared import read_bounded_json, require_owner_dashboard_request +from kiro_crew.dashboard.handlers.worktree import ( + SandboxUnavailable, + _allowed_repo_roots, + _checkout_filter, + _git_no_repo_code, + _match_allowed_root, + _repo_lock, + _resolve_commit, + _run_git, +) +from kiro_crew.platform import redact_via_context as redact +from kiro_crew.security import is_sensitive_path +from kiro_crew.sel import sel +from kiro_crew.validation import is_valid_followup_branch + +logger = logging.getLogger(__name__) + +#: Rows per section. A repository can carry thousands of branches; the panel is +#: a picker, not an inventory, and the list is most-recent-first, so the cap +#: drops the stalest rows and the response says so with ``truncated``. +MAX_BRANCH_ROWS = 200 + +#: Longest branch name the switch route accepts. Git's own limit is the +#: filesystem's (a loose ref is a file), so this only bounds the request. +MAX_BRANCH_NAME = 200 + +#: Longest commit subject / author name a row keeps. Applied after redaction, +#: so a cut never leaves part of a secret readable. +MAX_ROW_TEXT = 300 + +#: Bytes of ``for-each-ref`` output one listing may capture. 200 rows of +#: ordinary refs fit with room to spare; a repository whose refs point at +#: commits with enormous subject lines overflows it, and the listing answers +#: 503 rather than buffering them. +MAX_LIST_OUTPUT = 2 * 1024 * 1024 + +#: git's diagnostics are matched as English text (``_SWITCH_FAILURES``, the +#: not-a-repository verdict), so every git call here runs in the C locale. +_C_LOCALE = {"LC_ALL": "C", "LANGUAGE": "C"} + +_SEP = "\x1f" +_FORMAT = _SEP.join( + ( + "%(refname)", + "%(objectname:short)", + "%(committerdate:iso-strict)", + "%(authorname)", + "%(contents:subject)", + "%(upstream:short)", + "%(upstream:track,nobracket)", + "%(HEAD)", + ) +) +# for-each-ref interpolates %xx hex escapes, so the separator is passed as one. +_FORMAT_ARG = _FORMAT.replace(_SEP, "%1f") + +_TRACK_RE = re.compile(r"(ahead|behind) (\d+)") + +_SANDBOX_REFUSAL = ( + "This host has no OS sandbox backend, so Kiro Crew will not run git for you. " + "Switch branches from a terminal." +) + + +class _Refusal(Exception): + """A request that resolves to a JSON error body instead of a repository.""" + + def __init__(self, body: dict, status: int) -> None: + super().__init__(body.get("error", "")) + self.body = body + self.status = status + + +def _error(message: str, code: str, status: int) -> web.Response: + return web.json_response({"error": message, "code": code}, status=status) + + +async def _resolve_repo_root(request: web.Request, raw: object, operation: str) -> str: + """Map the submitted project path onto a server-held git toplevel. + + Raises :class:`_Refusal` for every outcome other than a usable root. Mirrors + ``api_worktree_create``'s order: allow-list on the normalized string first, + then the filesystem checks on the server-held value, then the toplevel + re-check so resolving upward cannot leave the granted tree. + """ + caller = str(request.get("user") or "dashboard") + if not isinstance(raw, str) or not raw.strip(): + raise _Refusal({"error": "path required", "code": "path_required"}, 400) + roots = await asyncio.to_thread(_allowed_repo_roots, request.app.get("state")) + submitted = os.path.normpath(os.path.expanduser(raw.strip())) + project = _match_allowed_root(submitted, roots) + if project is None: + sel().log_api_access( + caller=caller, + operation=operation, + outcome="denied", + resources=f"path={submitted[:300]}", + error="outside slot project directories", + ) + raise _Refusal({"error": "Unknown project directory", "code": "unknown_project_dir"}, 403) + if await asyncio.to_thread(is_sensitive_path, project): + sel().log_api_access( + caller=caller, + operation=operation, + outcome="denied", + resources=f"path={project}", + error="sensitive path", + ) + raise _Refusal({"error": "Access denied", "code": "access_denied"}, 403) + if not await asyncio.to_thread(os.path.isdir, project): + raise _Refusal({"error": "Not a git repository", "code": "not_a_repository"}, 404) + try: + root = await asyncio.to_thread(_toplevel, project) + except SandboxUnavailable as exc: + logger.warning("%s: sandbox unavailable: %s", operation, exc) + raise _Refusal({"error": _SANDBOX_REFUSAL, "code": "git_sandbox_unavailable"}, 503) from exc + except (OSError, subprocess.SubprocessError) as exc: + logger.warning("%s: git toplevel probe failed: %s", operation, exc) + raise _Refusal({"error": "git is unavailable", "code": "git_unavailable"}, 503) from exc + if root is None: + raise _Refusal({"error": "Not a git repository", "code": "not_a_repository"}, 404) + if not root: + # git ran and refused for another reason (dubious ownership, a corrupt + # .git): an outage, not proof the directory holds no repository. + raise _Refusal( + { + "error": "Couldn't read the repository's branches.", + "code": "git_branches_unavailable", + }, + 503, + ) + if _match_allowed_root(root, roots) is None or await asyncio.to_thread(is_sensitive_path, root): + sel().log_api_access( + caller=caller, + operation=operation, + outcome="denied", + resources=f"root={root}", + error="git toplevel outside slot project directories", + ) + raise _Refusal( + { + "error": ( + "The repository root is outside this session's project directory, " + "so branches can't be switched from here." + ), + "code": "repo_root_outside_project", + }, + 403, + ) + return root + + +def _toplevel(project: str) -> str | None: + """Work-tree root of ``project``; None when git says it is no repository. + + Returns ``""`` for any other failed probe, which the caller reports as an + outage rather than an absence. + """ + probe = _run_git(["rev-parse", "--show-toplevel"], project, env_overrides=_C_LOCALE) + if probe.returncode != 0: + return None if _is_not_a_repo_verdict(probe.stderr or "") else "" + top = probe.stdout.strip() + return os.path.realpath(top) if top else "" + + +def _parse_track(track: str) -> tuple[int, int]: + ahead = behind = 0 + for kind, count in _TRACK_RE.findall(track): + if kind == "ahead": + ahead = int(count) + else: + behind = int(count) + return ahead, behind + + +def _list_refs(root: str, namespace: str) -> tuple[list[dict], bool] | None: + """Rows for ``refs/heads`` or ``refs/remotes``, newest first; None on failure.""" + limit = MAX_BRANCH_ROWS + 1 + rc, stdout, overflow = _run_git_bounded( + [ + "git", + *_git_no_repo_code(), + "for-each-ref", + "--sort=-committerdate", + f"--count={limit}", + f"--format={_FORMAT_ARG}", + namespace, + ], + cwd=root, + env={**os.environ, **_C_LOCALE}, + timeout=10, + cap=MAX_LIST_OUTPUT, + ) + if rc != 0 or overflow: + return None + prefix = namespace.rstrip("/") + "/" + rows: list[dict] = [] + lines = stdout.splitlines() + for line in lines: + parts = line.split(_SEP) + if len(parts) != 8: + continue + refname, sha, date, author, subject, upstream, track, head = parts + if not refname.startswith(prefix): + continue + name = refname[len(prefix) :] + # ``refs/remotes//HEAD`` is a symref naming the remote's default + # branch, not a branch of its own. + if namespace == "refs/remotes" and name.endswith("/HEAD"): + continue + row: dict = { + "name": name, + "sha": sha, + "date": date, + "author": author, + "subject": subject, + } + if namespace == "refs/heads": + row["current"] = head == "*" + if upstream: + row["upstream"] = upstream + ahead, behind = _parse_track(track) + if ahead: + row["ahead"] = ahead + if behind: + row["behind"] = behind + if "gone" in track: + row["upstreamGone"] = True + rows.append(row) + # git stops at ``limit`` lines and a skipped ``/HEAD`` symref can use + # one of them, so a capped answer may hide a row even at <= 200 rows. + truncated = len(rows) > MAX_BRANCH_ROWS or len(lines) >= limit + return rows[:MAX_BRANCH_ROWS], truncated + + +def _branches_sync(root: str) -> tuple[dict, int]: + """Blocking half of the listing route. Returns ``(json_body, http_status)``.""" + current_proc = _run_git(["branch", "--show-current"], root, env_overrides=_C_LOCALE) + if current_proc.returncode != 0: + return ( + { + "error": "Couldn't read the repository's branches.", + "code": "git_branches_unavailable", + }, + 503, + ) + current = (current_proc.stdout or "").strip() + local = _list_refs(root, "refs/heads") + remote = _list_refs(root, "refs/remotes") + if local is None or remote is None: + return ( + { + "error": "Couldn't read the repository's branches.", + "code": "git_branches_unavailable", + }, + 503, + ) + local_rows, local_truncated = local + remote_rows, remote_truncated = remote + local_names = {r["name"] for r in local_rows} + # A remote branch that already has a local branch of the same short name is + # reached through that local row; listing both offers two rows that switch + # to the same place. + remote_rows = [r for r in remote_rows if r["name"].split("/", 1)[-1] not in local_names] + body: dict = { + "repo": True, + "current": current or None, + "local": local_rows, + "remote": remote_rows, + } + if not current: + head = _run_git(["rev-parse", "--short", "HEAD"], root, env_overrides=_C_LOCALE) + if head.returncode == 0 and head.stdout.strip(): + body["detached"] = True + body["head"] = head.stdout.strip() + if local_truncated or remote_truncated: + body["truncated"] = True + if _checkout_filter(root): + body["switchBlocked"] = "filter" + return body, 200 + + +def _clip(text: str, limit: int) -> str: + return text if len(text) <= limit else text[: limit - 1] + "\u2026" + + +def _redact_rows(rows: list[dict]) -> None: + """Redact every display field, then bound it; redacting first means a cut + never leaves part of a secret readable.""" + for row in rows: + original = row["name"] + row["name"] = _clip(redact(original), MAX_BRANCH_NAME) + # A redacted or clipped name cannot be echoed back to /switch, so the + # row is shown but not offered as a target. + row["switchable"] = row["name"] == original + row["author"] = _clip(redact(row["author"]), MAX_ROW_TEXT) + row["subject"] = _clip(redact(row["subject"]), MAX_ROW_TEXT) + if "upstream" in row: + row["upstream"] = _clip(redact(row["upstream"]), MAX_BRANCH_NAME) + + +async def api_project_git_branches(request: web.Request) -> web.Response: + """GET ``/api/project/git/branches?path=``. + + Response: ``{repo, current, detached?, head?, local: [...], remote: [...], + truncated?, switchBlocked?}``. Each local row carries ``name, sha, date, + author, subject, current, switchable`` and, when it tracks one, + ``upstream, ahead?, behind?, upstreamGone?``; remote rows carry the same + commit fields with a ``/`` name. + """ + denied = deny_non_dashboard_caller(request, "project_git_branches") + if denied is not None: + return denied + try: + root = await _resolve_repo_root( + request, request.query.get("path", ""), "project_git_branches" + ) + except _Refusal as refusal: + if refusal.body.get("code") == "not_a_repository": + return web.json_response({"repo": False, "local": [], "remote": []}) + return _error(refusal.body["error"], refusal.body["code"], refusal.status) + sel().log_api_access( + caller=str(request.get("user") or "dashboard"), + operation="project_git_branches", + outcome="allowed", + resources=root, + ) + try: + body, status = await asyncio.to_thread(_branches_sync, root) + except SandboxUnavailable as exc: + logger.warning("project_git_branches: sandbox unavailable: %s", exc) + return _error(_SANDBOX_REFUSAL, "git_sandbox_unavailable", 503) + except subprocess.TimeoutExpired: + return _error("git timed out", "git_timeout", 504) + except (OSError, subprocess.SubprocessError) as exc: + logger.warning("project_git_branches failed: %s", exc) + return _error("Couldn't read the repository's branches.", "git_branches_unavailable", 503) + if status != 200: + return _error(body["error"], body["code"], status) + _redact_rows(body["local"]) + _redact_rows(body["remote"]) + if body.get("current"): + body["current"] = redact(body["current"]) + if body.get("head"): + body["head"] = redact(body["head"]) + return web.json_response(body) + + +def _is_plain_ref_name(name: str) -> bool: + """Whether ``name`` can only be read by git as a ref name, never an option or shorthand. + + Excludes a leading ``-`` (an option, and ``-`` alone is "previous branch"), + ``@{`` (reflog shorthand such as ``@{-1}``), and any whitespace or control + character. Existence and git's own ref grammar are checked separately. + """ + if not name or len(name) > MAX_BRANCH_NAME or name.startswith("-") or "@{" in name: + return False + return not any(ch.isspace() or ord(ch) < 0x20 or ord(ch) == 0x7F for ch in name) + + +# (stderr fragment, code, http status, user-facing message). Matched in order +# against git's English diagnostics; anything else falls through to the generic +# failure with git's own first line as ``detail``. +_SWITCH_FAILURES: tuple[tuple[str, str, int, str], ...] = ( + ( + "would be overwritten by checkout", + "git_switch_dirty", + 409, + "Your uncommitted changes would be overwritten by this switch. Commit or stash them first.", + ), + ( + "untracked working tree files would be overwritten", + "git_switch_dirty", + 409, + "Untracked files would be overwritten by this switch. Move or commit them first.", + ), + ( + "already used by worktree", + "git_branch_in_worktree", + 409, + "That branch is already checked out in another worktree.", + ), + ( + "is already checked out at", + "git_branch_in_worktree", + 409, + "That branch is already checked out in another worktree.", + ), + ("already exists", "git_branch_exists", 409, "A branch with that name already exists."), + ( + "you need to resolve your current index first", + "git_operation_in_progress", + 409, + "Finish or abort the merge, rebase or cherry-pick in progress first.", + ), + ( + "in the middle of", + "git_operation_in_progress", + 409, + "Finish or abort the merge, rebase or cherry-pick in progress first.", + ), + ( + "cannot switch branch while", + "git_operation_in_progress", + 409, + "Finish or abort the merge, rebase or cherry-pick in progress first.", + ), +) + + +def _first_line(text: str) -> str: + for line in (text or "").splitlines(): + line = line.strip() + if line: + return line[:300] + return "" + + +def _switch_sync(root: str, branch: str, create: bool, track: str) -> tuple[dict, int]: + """Blocking half of the switch route. Returns ``(json_body, http_status)``.""" + if _checkout_filter(root): + return ( + { + "error": ( + "This repository's Git config declares a content filter that git " + "would run on checkout, so switching is off here. Switch from a terminal." + ), + "code": "git_switch_filter_refused", + }, + 409, + ) + fmt = _run_git(["check-ref-format", f"refs/heads/{branch}"], root, env_overrides=_C_LOCALE) + if fmt.returncode != 0: + return ({"error": "Invalid branch name", "code": "invalid_branch"}, 400) + + previous_proc = _run_git(["branch", "--show-current"], root, env_overrides=_C_LOCALE) + previous = (previous_proc.stdout or "").strip() if previous_proc.returncode == 0 else "" + + if track: + if not _resolve_commit(root, f"refs/remotes/{track}"): + return ({"error": "Remote branch not found", "code": "git_branch_not_found"}, 404) + args = ["switch", "--no-overwrite-ignore", "-c", branch, "--track", f"refs/remotes/{track}"] + elif create: + if _resolve_commit(root, f"refs/heads/{branch}"): + return ( + {"error": "A branch with that name already exists.", "code": "git_branch_exists"}, + 409, + ) + args = ["switch", "--no-overwrite-ignore", "--no-track", "-c", branch] + else: + if not _resolve_commit(root, f"refs/heads/{branch}"): + return ({"error": "Branch not found", "code": "git_branch_not_found"}, 404) + if branch == previous: + return ({"ok": True, "branch": branch, "previous": previous}, 200) + # git's default lets a switch silently replace an ignored local file + # that the target branch tracks; refuse that like any other overwrite. + args = ["switch", "--no-overwrite-ignore", "--no-guess", "--", branch] + + proc = _run_git(args, root, env_overrides=_C_LOCALE) + if proc.returncode != 0: + stderr = (proc.stderr or "").lower() + for fragment, code, status, message in _SWITCH_FAILURES: + if fragment in stderr: + return ({"error": message, "code": code}, status) + return ( + { + "error": "git couldn't switch branches.", + "code": "git_switch_failed", + "detail": _first_line(proc.stderr or proc.stdout or ""), + }, + 400, + ) + return ({"ok": True, "branch": branch, "previous": previous or None}, 200) + + +async def api_project_git_switch(request: web.Request) -> web.Response: + """POST ``/api/project/git/switch`` with ``{path, branch, create?, track?}``. + + ``create: true`` makes a new branch at ``HEAD``; ``track: "/"`` + makes ``branch`` as a local branch tracking that remote branch; neither + switches to an existing local branch. Returns ``{ok, branch, previous}``. + """ + caller = str(request.get("user") or "dashboard") + owner_denied = await require_owner_dashboard_request(request, "project_git_switch") + if owner_denied is not None: + return owner_denied + body, body_err = await read_bounded_json(request) + if body_err is not None: + return body_err + assert body is not None + + branch = body.get("branch") + create = body.get("create", False) + track = body.get("track", "") + if not isinstance(branch, str) or not isinstance(create, bool) or not isinstance(track, str): + return _error("invalid input", "invalid_input", 400) + branch, track = branch.strip(), track.strip() + if create and track: + return _error("create and track are mutually exclusive", "invalid_input", 400) + # A new name must satisfy the stricter grammar the follow-up card's branch + # field uses; an existing one only has to be unambiguous as a ref name, + # since it already exists and git accepted it once. + name_ok = ( + is_valid_followup_branch(branch) and len(branch) <= MAX_BRANCH_NAME + if (create or track) + else _is_plain_ref_name(branch) + ) + if not name_ok or (track and not _is_plain_ref_name(track)): + sel().log_api_access( + caller=caller, + operation="project_git_switch", + outcome="denied", + resources=f"branch={branch[:120]}", + error="invalid branch name", + ) + return _error("Invalid branch name", "invalid_branch", 400) + + try: + root = await _resolve_repo_root(request, body.get("path"), "project_git_switch") + except _Refusal as refusal: + return _error(refusal.body["error"], refusal.body["code"], refusal.status) + + resources = f"root={root} branch={branch}" + (f" track={track}" if track else "") + try: + async with _repo_lock(root): + payload, status = await asyncio.to_thread(_switch_sync, root, branch, create, track) + except subprocess.TimeoutExpired: + sel().log_api_access( + caller=caller, + operation="project_git_switch", + outcome="error", + resources=resources, + error="git timeout", + ) + return _error("git timed out", "git_timeout", 504) + except SandboxUnavailable as exc: + logger.warning("project_git_switch: sandbox unavailable: %s", exc) + sel().log_api_access( + caller=caller, + operation="project_git_switch", + outcome="denied", + resources=resources, + error="sandbox backend unavailable", + ) + return _error(_SANDBOX_REFUSAL, "git_sandbox_unavailable", 503) + except (OSError, subprocess.SubprocessError) as exc: + logger.warning("project_git_switch failed: %s", exc) + return _error("git couldn't switch branches.", "git_switch_failed", 500) + + sel().log_api_access( + caller=caller, + operation="project_git_switch", + outcome="allowed" if status == 200 else "error", + resources=resources, + error="" if status == 200 else str(payload.get("code", "")), + ) + if status != 200: + if payload.get("detail"): + return web.json_response( + { + "error": payload["error"], + "code": payload["code"], + "detail": redact(payload["detail"]), + }, + status=status, + ) + return _error(payload["error"], payload["code"], status) + payload["branch"] = redact(payload["branch"]) + if payload.get("previous"): + payload["previous"] = redact(payload["previous"]) + return web.json_response(payload) diff --git a/src/kiro_crew/dashboard/handlers/worktree.py b/src/kiro_crew/dashboard/handlers/worktree.py index 2cbf7a53ace..7b745913073 100644 --- a/src/kiro_crew/dashboard/handlers/worktree.py +++ b/src/kiro_crew/dashboard/handlers/worktree.py @@ -70,7 +70,7 @@ import re import shutil import subprocess -from typing import Callable +from typing import Callable, Mapping from aiohttp import web @@ -198,9 +198,14 @@ def _run_git( cwd: str, *, stdout_decoder: Callable[[bytes | str | None], str] = utf8_stdout, + env_overrides: Mapping[str, str] | None = None, ) -> subprocess.CompletedProcess[str]: """Run git with an argv list (never a shell) inside ``cwd``, OS-sandboxed. + ``env_overrides`` is laid over the scrubbed environment, for a caller that + must classify git's diagnostics and so needs them in one locale + (``LC_ALL=C``). + Routed through the ``sandboxed_spawn_argv`` chokepoint, matching ``git_coord.py``'s treatment of agent-influenced git: the repository is agent-selected and the branch is LLM-authored, so this spawn takes the OS @@ -243,6 +248,8 @@ def _run_git( ) except RuntimeError as exc: # no sandbox backend and no explicit opt-in raise SandboxUnavailable(str(exc)) from exc + if env_overrides: + env.update(env_overrides) env["GIT_TERMINAL_PROMPT"] = "0" try: # Bytes mode, decoded by utf8_stdout: text mode's universal-newline diff --git a/src/kiro_crew/dashboard/routes/taskrunner.py b/src/kiro_crew/dashboard/routes/taskrunner.py index 927aeb11429..7af16978db6 100644 --- a/src/kiro_crew/dashboard/routes/taskrunner.py +++ b/src/kiro_crew/dashboard/routes/taskrunner.py @@ -12,6 +12,7 @@ from aiohttp import web from kiro_crew.dashboard import chat, handlers +from kiro_crew.dashboard.handlers import git_branches def register(app: web.Application) -> None: @@ -56,6 +57,8 @@ def register(app: web.Application) -> None: app.router.add_get("/api/project/git/status", handlers.api_project_git_status) app.router.add_get("/api/project/git/log", handlers.api_project_git_log) app.router.add_get("/api/project/git/repos", handlers.api_project_git_repos) + app.router.add_get("/api/project/git/branches", git_branches.api_project_git_branches) + app.router.add_post("/api/project/git/switch", git_branches.api_project_git_switch) app.router.add_get("/api/project/tree", handlers.api_project_tree) app.router.add_post("/api/upload", handlers.api_upload) app.router.add_post("/api/upload/file", handlers.api_upload_file) diff --git a/src/kiro_crew/security_posture.py b/src/kiro_crew/security_posture.py index e8523911901..c762f0b3f78 100644 --- a/src/kiro_crew/security_posture.py +++ b/src/kiro_crew/security_posture.py @@ -1441,6 +1441,13 @@ class PostureControl: "status and log (api_project_git_status, api_project_git_log) return are " "redacted; file paths go through redact_path_segments.", ), + ( + "Git panel branch switcher", + "dashboard/handlers/git_branches.py", + "The branch names, upstreams, commit subjects and authors the branch " + "switcher (api_project_git_branches, api_project_git_switch) returns are " + "redacted; a name the redactor alters is listed as not switchable.", + ), ( "Project file tree", "dashboard/file_api/project_tree.py", diff --git a/test/test_json_object_body_guard.py b/test/test_json_object_body_guard.py index 63c5dcd5718..1ad90b58a08 100644 --- a/test/test_json_object_body_guard.py +++ b/test/test_json_object_body_guard.py @@ -405,6 +405,9 @@ async def json(self): "handlers/workflows.py::api_workflow_run_intent": ("None", _UNBOUNDED_USER_CONTENT), "handlers/workflows.py::api_workflow_run_promote": ("", _BOUNDED_CONTROL_FIELDS), "handlers/workflows.py::api_workflow_run_rerun": ("None", _UNBOUNDED_USER_CONTENT), + # A branch switch names a project path, a branch, and an optional remote ref + # to track: control fields only, so the shared default ceiling applies. + "handlers/git_branches.py::api_project_git_switch": ("", _BOUNDED_CONTROL_FIELDS), # handlers/files.py and its file_api owners: bodies name paths and routing # fields -- the file bytes travel in api_file_write's body, which is the one # uncapped site. diff --git a/test/test_project_git_branches.py b/test/test_project_git_branches.py new file mode 100644 index 00000000000..b36e64f731d --- /dev/null +++ b/test/test_project_git_branches.py @@ -0,0 +1,525 @@ +"""Tests for the Git panel's branch switcher routes. + +``GET /api/project/git/branches`` and ``POST /api/project/git/switch`` run real +git (through the worktree module's sandboxed ``_run_git``) against a throwaway +repository, so these tests skip where the OS sandbox cannot run git, exactly as +``test_worktree_create.py`` does. +""" + +from __future__ import annotations + +import functools +import os +import shutil +import subprocess +import tempfile +from unittest.mock import MagicMock + +import pytest +from aiohttp import web +from aiohttp.test_utils import TestClient, TestServer + +from kiro_crew import sandbox as sandbox_mod +from kiro_crew.dashboard.handlers import git_branches as gb +from kiro_crew.dashboard.handlers.worktree import SandboxUnavailable, _run_git + +_GIT_ENV = { + "GIT_AUTHOR_NAME": "Test", + "GIT_AUTHOR_EMAIL": "test@example.com", + "GIT_COMMITTER_NAME": "Test", + "GIT_COMMITTER_EMAIL": "test@example.com", + "GIT_CONFIG_GLOBAL": os.devnull, + "GIT_CONFIG_SYSTEM": os.devnull, +} + +_REDACTABLE = "feat/ghp_a1B2c3D4e5F6g7H8i9J0k1L2m3N4o5P6q7R8" + + +def _git(*args: str, cwd, **env: str) -> str: + return subprocess.run( + ["git", *args], + cwd=str(cwd), + check=True, + capture_output=True, + encoding="utf-8", + env={**os.environ, **_GIT_ENV, **env}, + ).stdout.strip() + + +@functools.lru_cache(maxsize=1) +def _sandbox_exec_reason() -> str: + sandbox_mod.reset_backend() + with tempfile.TemporaryDirectory() as tmp: + try: + proc = _run_git(["--version"], tmp) + except SandboxUnavailable as exc: + return str(exc) or "sandbox unavailable" + except OSError as exc: # pragma: no cover - no git binary at all + return f"git unavailable: {exc}" + return "" if proc.returncode == 0 else (proc.stderr or "git failed").strip() + + +@pytest.fixture(scope="session", autouse=True) +def _sandbox_backend_session_boundary(): + yield + sandbox_mod.reset_backend() + + +@pytest.fixture(scope="session") +def _repo_template(tmp_path_factory): + """``main`` (current) plus ``older``, ``feature``, and three remote refs.""" + template = tmp_path_factory.mktemp("branches-seed") / "proj" + template.mkdir() + _git("init", "-q", "-b", "main", cwd=template) + _git("config", "gc.auto", "0", cwd=template) + _git("config", "maintenance.auto", "false", cwd=template) + (template / "README.md").write_text("hi\n") + _git("add", "README.md", cwd=template) + _git( + "commit", + "-q", + "-m", + "init", + cwd=template, + GIT_AUTHOR_DATE="2020-01-01T00:00:00Z", + GIT_COMMITTER_DATE="2020-01-01T00:00:00Z", + ) + _git("branch", "older", cwd=template) + _git("switch", "-q", "-c", "feature", cwd=template) + (template / "README.md").write_text("feature\n") + _git("commit", "-q", "-am", "feature work", cwd=template) + _git("switch", "-q", "main", cwd=template) + head = _git("rev-parse", "HEAD", cwd=template) + feature = _git("rev-parse", "feature", cwd=template) + # A configured remote (never contacted) so tracking can be set up against it. + _git("remote", "add", "origin", "https://example.invalid/repo.git", cwd=template) + # Remote-tracking refs without a network: one with no local twin, one whose + # short name matches a local branch, and the remote's HEAD symref. + _git("update-ref", "refs/remotes/origin/remote-only", feature, cwd=template) + _git("update-ref", "refs/remotes/origin/feature", feature, cwd=template) + _git("symbolic-ref", "refs/remotes/origin/HEAD", "refs/remotes/origin/feature", cwd=template) + _git("update-ref", f"refs/heads/{_REDACTABLE}", head, cwd=template) + return template + + +@pytest.fixture +def repo(tmp_path, request): + reason = _sandbox_exec_reason() + if reason: + pytest.skip(f"sandboxed git cannot run on this host: {reason[:120]}") + root = tmp_path / "proj" + shutil.copytree(request.getfixturevalue("_repo_template"), root) + return root + + +def _make_app(*projects, user: str = "owner") -> web.Application: + @web.middleware + async def claims(request: web.Request, handler): + request["app"] = "" + request["user"] = user + return await handler(request) + + app = web.Application(middlewares=[claims]) + state = MagicMock() + state.owner_id = "owner" + state._slots = {f"chat-{i}": MagicMock(project=str(p)) for i, p in enumerate(projects)} + app["state"] = state + app.router.add_get("/api/project/git/branches", gb.api_project_git_branches) + app.router.add_post("/api/project/git/switch", gb.api_project_git_switch) + return app + + +async def _client(*projects, user: str = "owner") -> TestClient: + client = TestClient(TestServer(_make_app(*projects, user=user))) + await client.start_server() + return client + + +async def _switch(client: TestClient, path, **body): + resp = await client.post("/api/project/git/switch", json={"path": str(path), **body}) + return resp.status, await resp.json() + + +def _current(root) -> str: + return _git("branch", "--show-current", cwd=root) + + +class TestListBranches: + @pytest.mark.asyncio + async def test_lists_local_newest_first_and_marks_current(self, repo): + client = await _client(repo) + try: + resp = await client.get("/api/project/git/branches", params={"path": str(repo)}) + body = await resp.json() + finally: + await client.close() + assert resp.status == 200 + assert body["repo"] is True + assert body["current"] == "main" + names = [r["name"] for r in body["local"]] + # `feature` carries the only newer commit, so it sorts first. + assert names[0] == "feature" + assert set(names) >= {"main", "older", "feature"} + current = [r for r in body["local"] if r["current"]] + assert [r["name"] for r in current] == ["main"] + feature = next(r for r in body["local"] if r["name"] == "feature") + assert feature["subject"] == "feature work" + assert feature["author"] == "Test" + assert feature["switchable"] is True + assert "switchBlocked" not in body + + @pytest.mark.asyncio + async def test_remote_rows_skip_head_symref_and_local_twins(self, repo): + client = await _client(repo) + try: + resp = await client.get("/api/project/git/branches", params={"path": str(repo)}) + body = await resp.json() + finally: + await client.close() + assert [r["name"] for r in body["remote"]] == ["origin/remote-only"] + + @pytest.mark.asyncio + async def test_redacted_name_is_listed_but_not_switchable(self, repo): + client = await _client(repo) + try: + resp = await client.get("/api/project/git/branches", params={"path": str(repo)}) + body = await resp.json() + finally: + await client.close() + redacted = [r for r in body["local"] if "REDACTED" in r["name"]] + assert len(redacted) == 1 + assert redacted[0]["switchable"] is False + assert _REDACTABLE not in str(body) + + @pytest.mark.asyncio + async def test_filter_driver_blocks_switching_but_still_lists(self, repo): + _git("config", "filter.lfs.smudge", "git-lfs smudge -- %f", cwd=repo) + client = await _client(repo) + try: + resp = await client.get("/api/project/git/branches", params={"path": str(repo)}) + body = await resp.json() + status, switched = await _switch(client, repo, branch="feature") + finally: + await client.close() + assert resp.status == 200 + assert body["switchBlocked"] == "filter" + assert body["local"] + assert status == 409 + assert switched["code"] == "git_switch_filter_refused" + assert _current(repo) == "main" + + @pytest.mark.asyncio + async def test_unknown_project_is_refused(self, repo, tmp_path): + client = await _client(tmp_path / "elsewhere") + try: + resp = await client.get("/api/project/git/branches", params={"path": str(repo)}) + body = await resp.json() + finally: + await client.close() + assert resp.status == 403 + assert body["code"] == "unknown_project_dir" + + @pytest.mark.asyncio + async def test_non_repository_answers_repo_false(self, repo, tmp_path): + # `repo` only applies the sandbox skip; the project below is no repository. + plain = tmp_path / "plain" + plain.mkdir() + client = await _client(plain) + try: + resp = await client.get("/api/project/git/branches", params={"path": str(plain)}) + body = await resp.json() + finally: + await client.close() + assert resp.status == 200 + assert body == {"repo": False, "local": [], "remote": []} + + @pytest.mark.asyncio + async def test_failed_probe_that_is_not_an_absence_is_an_outage(self, repo, monkeypatch): + # Dubious ownership (or a corrupt .git) is not "no repository": the + # picker must not say so under a chip that shows the repo's branch. + def dubious(args, cwd, **kwargs): + return subprocess.CompletedProcess( + args, 128, "", "fatal: detected dubious ownership in repository at '/x'\n" + ) + + monkeypatch.setattr(gb, "_run_git", dubious) + client = await _client(repo) + try: + resp = await client.get("/api/project/git/branches", params={"path": str(repo)}) + body = await resp.json() + finally: + await client.close() + assert resp.status == 503 + assert body["code"] == "git_branches_unavailable" + + @pytest.mark.asyncio + async def test_long_subject_and_author_are_bounded(self, repo): + _git("switch", "-q", "-c", "wordy", cwd=repo) + (repo / "w.txt").write_text("w\n") + _git("add", "w.txt", cwd=repo) + _git("commit", "-q", "-m", "s" * 5000, cwd=repo, GIT_AUTHOR_NAME="a" * 5000) + _git("switch", "-q", "main", cwd=repo) + client = await _client(repo) + try: + resp = await client.get("/api/project/git/branches", params={"path": str(repo)}) + body = await resp.json() + finally: + await client.close() + wordy = next(r for r in body["local"] if r["name"] == "wordy") + assert len(wordy["subject"]) == gb.MAX_ROW_TEXT + assert len(wordy["author"]) == gb.MAX_ROW_TEXT + assert wordy["subject"].endswith("\u2026") + + @pytest.mark.asyncio + async def test_listing_output_over_the_cap_is_refused_not_buffered(self, repo, monkeypatch): + monkeypatch.setattr(gb, "MAX_LIST_OUTPUT", 64) + client = await _client(repo) + try: + resp = await client.get("/api/project/git/branches", params={"path": str(repo)}) + body = await resp.json() + finally: + await client.close() + assert resp.status == 503 + assert body["code"] == "git_branches_unavailable" + + +class TestSwitch: + @pytest.mark.asyncio + async def test_switches_to_existing_local_branch(self, repo): + client = await _client(repo) + try: + status, body = await _switch(client, repo, branch="feature") + finally: + await client.close() + assert status == 200, body + assert body == {"ok": True, "branch": "feature", "previous": "main"} + assert _current(repo) == "feature" + assert (repo / "README.md").read_text() == "feature\n" + + @pytest.mark.asyncio + async def test_creates_branch_at_head_without_upstream(self, repo): + client = await _client(repo) + try: + status, body = await _switch(client, repo, branch="feat/new-thing", create=True) + finally: + await client.close() + assert status == 200, body + assert _current(repo) == "feat/new-thing" + assert _git("rev-parse", "HEAD", cwd=repo) == _git("rev-parse", "main", cwd=repo) + with pytest.raises(subprocess.CalledProcessError): + _git("rev-parse", "--abbrev-ref", "feat/new-thing@{upstream}", cwd=repo) + + @pytest.mark.asyncio + async def test_create_refuses_an_existing_name(self, repo): + client = await _client(repo) + try: + status, body = await _switch(client, repo, branch="feature", create=True) + finally: + await client.close() + assert status == 409 + assert body["code"] == "git_branch_exists" + assert _current(repo) == "main" + + @pytest.mark.asyncio + async def test_tracks_a_remote_branch(self, repo): + client = await _client(repo) + try: + status, body = await _switch( + client, repo, branch="remote-only", track="origin/remote-only" + ) + finally: + await client.close() + assert status == 200, body + assert _current(repo) == "remote-only" + assert ( + _git("rev-parse", "--abbrev-ref", "remote-only@{upstream}", cwd=repo) + == "origin/remote-only" + ) + + @pytest.mark.asyncio + async def test_missing_branch_is_404(self, repo): + client = await _client(repo) + try: + status, body = await _switch(client, repo, branch="nope") + track_status, track_body = await _switch( + client, repo, branch="nope", track="origin/nope" + ) + finally: + await client.close() + assert (status, body["code"]) == (404, "git_branch_not_found") + assert (track_status, track_body["code"]) == (404, "git_branch_not_found") + + @pytest.mark.asyncio + async def test_conflicting_local_changes_are_refused_and_kept(self, repo): + (repo / "README.md").write_text("my edit\n") + client = await _client(repo) + try: + status, body = await _switch(client, repo, branch="feature") + finally: + await client.close() + assert status == 409 + assert body["code"] == "git_switch_dirty" + assert _current(repo) == "main" + assert (repo / "README.md").read_text() == "my edit\n" + + @pytest.mark.asyncio + async def test_ignored_local_file_the_target_tracks_is_refused_and_kept(self, repo): + # `tracked-there` tracks secret.env; on main it is ignored and holds + # local content git's default switch would silently replace. + _git("switch", "-q", "-c", "tracked-there", cwd=repo) + (repo / "secret.env").write_text("branch copy\n") + _git("add", "secret.env", cwd=repo) + _git("commit", "-q", "-m", "track secret.env", cwd=repo) + _git("switch", "-q", "main", cwd=repo) + (repo / ".git" / "info" / "exclude").write_text("secret.env\n") + (repo / "secret.env").write_text("my local value\n") + client = await _client(repo) + try: + status, body = await _switch(client, repo, branch="tracked-there") + finally: + await client.close() + assert status == 409 + assert body["code"] == "git_switch_dirty" + assert _current(repo) == "main" + assert (repo / "secret.env").read_text() == "my local value\n" + + @pytest.mark.asyncio + async def test_every_git_call_runs_in_the_c_locale(self, repo, monkeypatch): + # The classifier reads English stderr; a translated user locale would + # turn a dirty refusal into a generic failure. + seen: list[dict | None] = [] + real = gb._run_git + + def recording(args, cwd, **kwargs): + seen.append(kwargs.get("env_overrides")) + return real(args, cwd, **kwargs) + + monkeypatch.setattr(gb, "_run_git", recording) + (repo / "README.md").write_text("my edit\n") + client = await _client(repo) + try: + status, body = await _switch(client, repo, branch="feature") + finally: + await client.close() + assert (status, body["code"]) == (409, "git_switch_dirty") + assert seen + assert all(env and env.get("LC_ALL") == "C" for env in seen) + + @pytest.mark.asyncio + async def test_non_conflicting_local_changes_carry_over(self, repo): + (repo / "notes.txt").write_text("scratch\n") + client = await _client(repo) + try: + status, _ = await _switch(client, repo, branch="older") + finally: + await client.close() + assert status == 200 + assert _current(repo) == "older" + assert (repo / "notes.txt").read_text() == "scratch\n" + + @pytest.mark.asyncio + async def test_switching_to_current_branch_is_a_no_op(self, repo): + client = await _client(repo) + try: + status, body = await _switch(client, repo, branch="main") + finally: + await client.close() + assert status == 200 + assert body["branch"] == "main" + + @pytest.mark.asyncio + @pytest.mark.parametrize("branch", ["-f", "-", "@{-1}", "a b", "x\ny", ""]) + async def test_option_and_shorthand_names_are_refused(self, repo, branch): + client = await _client(repo) + try: + status, body = await _switch(client, repo, branch=branch) + finally: + await client.close() + assert status == 400 + assert body["code"] == "invalid_branch" + assert _current(repo) == "main" + + @pytest.mark.asyncio + @pytest.mark.parametrize("branch", ["foo..bar", "foo.lock", "HEAD", "feat/", "a~1"]) + async def test_create_requires_a_valid_new_name(self, repo, branch): + client = await _client(repo) + try: + status, body = await _switch(client, repo, branch=branch, create=True) + finally: + await client.close() + assert status == 400 + assert body["code"] == "invalid_branch" + + @pytest.mark.asyncio + async def test_create_and_track_together_are_refused(self, repo): + client = await _client(repo) + try: + status, body = await _switch( + client, repo, branch="x", create=True, track="origin/remote-only" + ) + finally: + await client.close() + assert status == 400 + assert body["code"] == "invalid_input" + + @pytest.mark.asyncio + async def test_non_owner_is_refused_before_git_runs(self, repo, monkeypatch): + calls: list = [] + monkeypatch.setattr(gb, "_switch_sync", lambda *a: calls.append(a) or ({}, 200)) + client = await _client(repo, user="someone-else") + try: + status, body = await _switch(client, repo, branch="feature") + finally: + await client.close() + assert status in (401, 403) + assert calls == [] + assert _current(repo) == "main" + + @pytest.mark.asyncio + async def test_repo_root_above_the_granted_directory_is_refused(self, repo): + sub = repo / "sub" + sub.mkdir() + client = await _client(sub) + try: + status, body = await _switch(client, sub, branch="feature") + finally: + await client.close() + assert status == 403 + assert body["code"] == "repo_root_outside_project" + assert _current(repo) == "main" + + @pytest.mark.asyncio + async def test_work_tree_redirected_outside_the_project_is_refused(self, repo, tmp_path): + """``core.worktree`` would make the checkout write files somewhere else.""" + decoy = tmp_path / "decoy" + decoy.mkdir() + (decoy / "README.md").write_text("not yours\n") + _git("config", "core.worktree", str(decoy), cwd=repo) + client = await _client(repo) + try: + status, body = await _switch(client, repo, branch="feature") + finally: + await client.close() + assert status == 403 + assert body["code"] == "repo_root_outside_project" + assert (decoy / "README.md").read_text() == "not yours\n" + + @pytest.mark.asyncio + async def test_unknown_project_is_refused(self, repo, tmp_path): + client = await _client(tmp_path / "elsewhere") + try: + status, body = await _switch(client, repo, branch="feature") + finally: + await client.close() + assert status == 403 + assert body["code"] == "unknown_project_dir" + + +class TestPlainRefName: + @pytest.mark.parametrize("name", ["main", "feat/x", "release-1.2", "user@host/x"]) + def test_accepts_ordinary_names(self, name): + assert gb._is_plain_ref_name(name) + + @pytest.mark.parametrize("name", ["", "-x", "-", "@{-1}", "a b", "a\tb", "x" * 201]) + def test_rejects_options_shorthands_and_whitespace(self, name): + assert not gb._is_plain_ref_name(name) diff --git a/website/scripts/capture-branch-switcher.mjs b/website/scripts/capture-branch-switcher.mjs new file mode 100644 index 00000000000..84549905266 --- /dev/null +++ b/website/scripts/capture-branch-switcher.mjs @@ -0,0 +1,213 @@ +/** + * Screenshot harness for the Git panel's branch switcher. + * + * Runs the REAL built SPA (website/dist) with /api/** answered from fixtures. + * The branch fixture is stateful: a POST to /api/project/git/switch moves + * `current`, so the frames show the header and the list following a switch. + * + * Frames: + * 1-closed header shows the checked-out branch with a chevron + * 2-open picker: local (newest first, ahead pill) + remote sections + * 3-filtered typed query narrowing the list, plus the create row + * 4-switched header after switching to a local branch + * 5-dirty 409 git_switch_dirty rendered as the localized notice + * 6-blocked switchBlocked: "filter" explains why every row is inert + * 7-light-open the open picker in the light theme + * 8-chip-closed the composer's project chip with its branch segment + * 9-chip-open the same picker opened upward from the composer chip + * 10-chip-switched chip and panel header both follow a switch made from the chip + * 11-chip-light-open the composer picker in the light theme + * + * Usage: node scripts/capture-branch-switcher.mjs + */ +import { mkdirSync } from 'node:fs' +import { join } from 'node:path' +import { openTranscriptHarness } from './lib/transcript-harness.mjs' +import { json } from './lib/boot-api.mjs' + +const OUT = process.argv[2] || '../temp-screenshots/branch-switcher' +const SLOT = 'chat-branch-switcher' +const PROJECT = '/home/user/workspace/demo-service' + +mkdirSync(OUT, { recursive: true }) + +const slots = [{ + key: SLOT, + title: 'Branch switcher', + running: false, + last_message: 'Switch branches from the Changes panel.', + messages: 2, + agent: 'kirocrew', + memory_mode: 'persistent', + project: PROJECT, + modified: Math.floor(Date.now() / 1000), + source_links: [], + source_links_total: 0, +}] + +const detail = { + running: false, + has_more: false, + total: 2, + queue: [], + project: PROJECT, + messages: [ + { role: 'user', ts: Date.now() / 1000 - 60, content: 'Can I switch branches from the Changes tab?' }, + { role: 'assistant', ts: Date.now() / 1000 - 30, content: 'Yes: click the branch name in the panel header.' }, + ], +} + +const h = await openTranscriptHarness({ + slot: SLOT, + project: PROJECT, + slots, + detail, + viewport: { width: 1400, height: 820 }, + deviceScaleFactor: 1, +}) + +const ago = s => new Date(Date.now() - s * 1000).toISOString() +const row = (name, s, subject, extra = {}) => ({ + name, sha: 'a1b2c3d', date: ago(s), author: 'Gray Smith', subject, switchable: true, ...extra, +}) + +let current = 'develop' +let mode = 'ok' +const localRows = () => [ + row('develop', 3600, 'Add Namespace Governance tab to AI SDLC dashboard', { upstream: 'origin/develop', ahead: 1 }), + row('sdlc-0911', 6 * 86400, 'Add SCTE 2026 speaking materials, webinar diagrams'), + row('second-brain', 90 * 86400, 'Add sea-shell skills'), + row('feature/dev-backup-042426', 210 * 86400, 'Update docs'), + row('feature/main-backup', 400 * 86400, "Merge branch 'develop' into 'main'"), + row('main', 400 * 86400, "Merge branch 'develop' into 'main'", { upstream: 'origin/main', behind: 3 }), +].map(r => ({ ...r, current: r.name === current })) + +const dashCfg = { auto_open_git_panel: true } +await h.page.route(/\/api\/dashboard\/config$/, async route => json(route, dashCfg)) + +await h.page.route(/\/api\/project\/git/, async route => { + const req = route.request() + const path = new URL(req.url()).pathname + if (path === '/api/project/git/status') { + return json(route, { + repo: true, + repoRoot: PROJECT, + branch: current, + ahead: current === 'develop' ? 1 : undefined, + files: [ + { path: 'resources/ai-tools/kirocrew-questions-feedback.md', status: 'M', staged: false, additions: 16, deletions: 0 }, + { path: 'maps/MOC-tools.md', status: 'M', staged: false, additions: 3, deletions: 0 }, + { path: 'areas/dev-ai-platform/kiro-administration-transition-plan.html', status: '?', staged: false, additions: 128 }, + ], + }) + } + if (path === '/api/project/git/log') { + return json(route, { + repo: true, + commits: [ + { sha: '19af431', message: 'Add Namespace Governance tab to AI SDLC dashboard', author: 'Gray Smith', date: ago(3600), isHead: true }, + { sha: 'a6b0d9e', message: 'chore: ignore .kiro/settings and add program overview backup', author: 'Gray Smith', date: ago(86400), isHead: false }, + ], + }) + } + if (path === '/api/project/git/branches') { + return json(route, { + repo: true, + current, + local: localRows(), + remote: [row('origin/feature/ai-deck-v5', 30 * 86400, 'Refactor DocsWithQPhing layout')], + ...(mode === 'blocked' ? { switchBlocked: 'filter' } : {}), + }) + } + if (path === '/api/project/git/switch') { + const body = JSON.parse(req.postData() || '{}') + if (mode === 'dirty') { + return json(route, { error: 'Your uncommitted changes would be overwritten by this switch.', code: 'git_switch_dirty' }, 409) + } + const previous = current + current = body.branch + return json(route, { ok: true, branch: current, previous }) + } + return json(route, { repo: true, repoRoot: PROJECT, branch: current }) +}) + +const trigger = '[data-testid="branch-switcher-trigger"][data-variant="header"]' +const chipTrigger = '[data-testid="branch-switcher-trigger"][data-variant="chip"]' +const pop = '[data-testid="branch-switcher"]' + +async function boot(theme = 'dark') { + await h.load(theme, { selector: 'textarea', settle: 1500 }) + await h.page.waitForSelector(trigger, { timeout: 15000 }) + await h.page.waitForTimeout(400) +} + +async function shot(name) { + await h.page.waitForTimeout(300) + await h.page.screenshot({ path: join(OUT, `${name}.png`) }) + console.log('SHOT', name) +} + +async function openPicker() { + await h.page.click(trigger) + await h.page.waitForSelector(`${pop} [data-testid="branch-row-local"]`, { timeout: 15000 }) +} + +await boot() +await shot('1-closed') + +await openPicker() +await shot('2-open') + +await h.page.keyboard.type('feat') +await shot('3-filtered') +await h.page.keyboard.press('Escape') + +await openPicker() +await h.page.locator(`${pop} [data-testid="branch-row-local"]`, { hasText: 'sdlc-0911' }).dispatchEvent('mousedown') +await h.page.waitForSelector(pop, { state: 'detached', timeout: 15000 }) +await h.page.waitForSelector(`${trigger}:has-text("sdlc-0911")`, { timeout: 15000 }) +console.log('SWITCHED', await h.page.locator(trigger).textContent()) +await shot('4-switched') + +mode = 'dirty' +await openPicker() +await h.page.locator(`${pop} [data-testid="branch-row-local"]`, { hasText: 'second-brain' }).dispatchEvent('mousedown') +await h.page.waitForSelector('[data-testid="branch-switcher-error"]', { timeout: 15000 }) +console.log('DIRTY', await h.page.locator('[data-testid="branch-switcher-error"]').textContent()) +await shot('5-dirty') +await h.page.keyboard.press('Escape') + +mode = 'blocked' +await h.page.evaluate(() => {}) +await boot() +await openPicker() +await h.page.waitForSelector('[data-testid="branch-switcher-blocked"]', { timeout: 15000 }) +await shot('6-blocked') + +mode = 'ok' +await boot('light') +await openPicker() +await shot('7-light-open') +await h.page.keyboard.press('Escape') + +// The composer's project chip opens the same picker, upward. +current = 'develop' +await boot() +await h.page.waitForSelector(`${chipTrigger}:has-text("develop")`, { timeout: 15000 }) +await shot('8-chip-closed') +await h.page.click(chipTrigger) +await h.page.waitForSelector(`${pop}[data-placement="above"] [data-testid="branch-row-local"]`, { timeout: 15000 }) +await shot('9-chip-open') +await h.page.locator(`${pop} [data-testid="branch-row-local"]`, { hasText: 'second-brain' }).dispatchEvent('mousedown') +await h.page.waitForSelector(pop, { state: 'detached', timeout: 15000 }) +await h.page.waitForSelector(`${chipTrigger}:has-text("second-brain")`, { timeout: 15000 }) +await h.page.waitForSelector(`${trigger}:has-text("second-brain")`, { timeout: 15000 }) +console.log('CHIP SWITCHED', await h.page.locator(chipTrigger).textContent(), '| header:', await h.page.locator(trigger).textContent()) +await shot('10-chip-switched') + +await boot('light') +await h.page.click(chipTrigger) +await h.page.waitForSelector(`${pop} [data-testid="branch-row-local"]`, { timeout: 15000 }) +await shot('11-chip-light-open') + +await h.close() diff --git a/website/src/api/client/files.ts b/website/src/api/client/files.ts index 7edcf833422..509027288e2 100644 --- a/website/src/api/client/files.ts +++ b/website/src/api/client/files.ts @@ -77,6 +77,34 @@ export const FILE_SEARCH_TIMEOUT_MS = 15_000 * them. */ export const BROWSE_FILES_TIMEOUT_MS = 10_000 +/** One row of `GET /api/project/git/branches`. `switchable` is false when the + * name came back redacted, so echoing it to the switch route could not work. */ +export interface GitBranchRow { + name: string + sha: string + date: string + author: string + subject: string + switchable: boolean + current?: boolean + upstream?: string + ahead?: number + behind?: number + upstreamGone?: boolean +} + +export interface GitBranchList { + repo: boolean + current?: string | null + detached?: boolean + head?: string + local: GitBranchRow[] + remote: GitBranchRow[] + truncated?: boolean + /** Present when checkout is refused for this repository (a declared filter driver). */ + switchBlocked?: 'filter' +} + export function createFilesEndpoints({ post, put, del, j, jfetch: fetch, checkSessionExpired, withJournaledDeadline }: ClientTransport) { const projects = { // Follow-up card: create a sibling git worktree of `repo` on a new `branch`. @@ -127,6 +155,11 @@ export function createFilesEndpoints({ post, put, del, j, jfetch: fetch, checkSe * `listed` counts the agent rows shown and `omitted` the ones past the cap. * `incomplete` means bounded tool-path tracking may have missed repositories. */ projectGitRepos: (slot: string) => fetch('/api/project/git/repos?slot=' + encodeURIComponent(slot)).then(j) as Promise<{ repos: { path: string; source: 'project' | 'agent' }[]; omitted: number; listed: number; incomplete: boolean }>, + /** Local branches (newest commit first) plus remote branches with no local twin. See `handlers/git_branches.py`. */ + projectGitBranches: (path: string) => fetch('/api/project/git/branches?path=' + encodeURIComponent(path)).then(j) as Promise, + /** Check out `branch`; `create` makes it at HEAD, `track` makes it tracking `/`. Owner-only. */ + projectGitSwitch: (body: { path: string; branch: string; create?: boolean; track?: string }) => + post('/api/project/git/switch', body).then(j) as Promise<{ ok: true; branch: string; previous: string | null }>, /** `dir` lists one project-relative folder (one the whole listing named in * `truncatedDirectories`) instead of the whole tree; its paths stay project-relative. */ projectTree: (path: string, dir?: string) => withJournaledDeadline(FILE_SEARCH_TIMEOUT_MS, undefined, '/api/project/tree', s => diff --git a/website/src/components/BranchSwitcher.tsx b/website/src/components/BranchSwitcher.tsx new file mode 100644 index 00000000000..5a45664f5f7 --- /dev/null +++ b/website/src/components/BranchSwitcher.tsx @@ -0,0 +1,459 @@ +import { useEffect, useMemo, useRef, useState, type MouseEvent as ReactMouseEvent } from 'react' +import { createPortal } from 'react-dom' +import { useQuery, useQueryClient } from '@tanstack/react-query' +import { Check, ChevronDown, GitBranch, Globe, Loader2, Plus, Search } from 'lucide-react' +import { api } from '../api/client' +import type { GitBranchRow } from '../api/client/files' +import ErrorNotice from './ErrorNotice' +import CopyBranchButton from './CopyBranchButton' +import { useListKeyboardNav } from '../hooks/useListKeyboardNav' +import { useImeGuard } from '../hooks/useImeGuard' +import { gitErrorCode } from '../utils/gitStatusError' +import { findReport } from '../utils/errorReport' +import { errMessage } from '../utils/thunkError' +import { timeAgo } from '../utils/timeAgo' +import { i18nT } from '../i18n/t' + +/** Same grammar the switch route applies to a NEW name (`is_valid_followup_branch`), + * so the create row is offered only for a name the server will accept. */ +const NEW_BRANCH_RE = /^[A-Za-z0-9][A-Za-z0-9._-]*(?:\/[A-Za-z0-9][A-Za-z0-9._-]*)*$/ +const WINDOWS_DEVICE_STEMS = new Set([ + 'con', 'prn', 'aux', 'nul', + 'com1', 'com2', 'com3', 'com4', 'com5', 'com6', 'com7', 'com8', 'com9', + 'lpt1', 'lpt2', 'lpt3', 'lpt4', 'lpt5', 'lpt6', 'lpt7', 'lpt8', 'lpt9', +]) + +export function isValidNewBranchName(name: string): boolean { + if (!name || name.length > 200 || !NEW_BRANCH_RE.test(name)) return false + if (name.includes('..') || name === 'HEAD') return false + return name.split('/').every(part => + !!part && !part.endsWith('.') && !part.endsWith('.lock') && + !WINDOWS_DEVICE_STEMS.has(part.split('.')[0].toLowerCase())) +} + +/** Localized copy for each coded switch failure. The backend's English sentence is + * never shown under a localized surface; an uncoded failure falls back to the + * generic line with git's own first line as detail. */ +const SWITCH_ERROR_KEYS: Record = { + git_switch_dirty: 'components.branchSwitcher.error_dirty', + git_branch_in_worktree: 'components.branchSwitcher.error_in_worktree', + git_branch_exists: 'components.branchSwitcher.error_exists', + git_operation_in_progress: 'components.branchSwitcher.error_in_progress', + git_branch_not_found: 'components.branchSwitcher.error_not_found', + invalid_branch: 'components.branchSwitcher.error_invalid', + git_switch_filter_refused: 'components.branchSwitcher.switch_blocked_filter', + git_sandbox_unavailable: 'components.branchSwitcher.error_sandbox', + repo_root_outside_project: 'components.branchSwitcher.outside_project', + git_timeout: 'components.branchSwitcher.error_timeout', +} + +function errorDetail(error: unknown): string | undefined { + const body = (error as { body?: unknown } | null)?.body + if (typeof body !== 'string' || !body.trim().startsWith('{')) return undefined + try { + const parsed = JSON.parse(body) as { detail?: unknown } + return typeof parsed.detail === 'string' && parsed.detail ? parsed.detail : undefined + } catch { + return undefined + } +} + +type Choice = + | { kind: 'local'; row: GitBranchRow } + | { kind: 'remote'; row: GitBranchRow } + | { kind: 'create'; name: string } + +function choiceKey(c: Choice): string { + return c.kind === 'create' ? `create:${c.name}` : `${c.kind}:${c.row.name}` +} + +/** `/` -> ``: the local branch a tracked remote row becomes. */ +function localNameFor(remote: string): string { + const slash = remote.indexOf('/') + return slash >= 0 ? remote.slice(slash + 1) : remote +} + +interface BranchSwitcherProps { + projectDir: string + /** The branch the status route reports, shown on the trigger while the list loads. */ + branch?: string + /** `header` is the Git panel's title control and opens downward; `chip` is the + * composer shelf's branch segment, which sits at the bottom of the page and + * so opens upward. */ + variant?: 'header' | 'chip' + /** Blocks opening the picker, with the reason as the trigger's tooltip. */ + disabledReason?: string +} + +/** + * The branch control used by the Git panel header and the composer's project + * chip: shows the checked-out branch and opens a filterable picker to switch to + * a local branch, check out a remote one as a tracking branch, or create a new + * branch from HEAD by typing its name. + * + * The list is read only while the picker is open. A switch refreshes every view + * of the working tree (status, log, file tree, the composer's branch chip), + * since all of them change with the checkout. + */ +export default function BranchSwitcher({ projectDir, branch, variant = 'header', disabledReason }: BranchSwitcherProps) { + const qc = useQueryClient() + const ime = useImeGuard() + const [open, setOpen] = useState(false) + const [query, setQuery] = useState('') + const [pending, setPending] = useState(null) + const [switchError, setSwitchError] = useState(null) + const triggerRef = useRef(null) + const popRef = useRef(null) + + const { data, error: listError, isLoading } = useQuery({ + queryKey: ['git-branches', projectDir], + queryFn: () => api.projectGitBranches(projectDir), + enabled: open && !!projectDir, + staleTime: 5_000, + retry: 1, + }) + + const close = () => { + setOpen(false) + setQuery('') + setSwitchError(null) + ime.reset() + } + + // Click outside closes; deferred a tick so the opening click does not. + useEffect(() => { + if (!open) return + let detach = () => {} + const timer = setTimeout(() => { + const onDown = (e: MouseEvent) => { + const t = e.target as Node + if (popRef.current?.contains(t) || triggerRef.current?.contains(t)) return + close() + } + document.addEventListener('mousedown', onDown) + detach = () => document.removeEventListener('mousedown', onDown) + }, 0) + return () => { clearTimeout(timer); detach() } + // `close` only resets local state; re-attaching per render would churn the listener. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [open]) + + const blockedCode = data?.switchBlocked === 'filter' + ? 'git_switch_filter_refused' + : gitErrorCode(listError) === 'repo_root_outside_project' + ? 'repo_root_outside_project' + : undefined + const q = query.trim() + const ql = q.toLowerCase() + + const choices = useMemo(() => { + if (!data?.repo) return [] + const match = (r: GitBranchRow) => !ql || r.name.toLowerCase().includes(ql) + const out: Choice[] = [ + ...data.local.filter(match).map(row => ({ kind: 'local' as const, row })), + ...data.remote.filter(match).map(row => ({ kind: 'remote' as const, row })), + ] + const exists = data.local.some(r => r.name === q) + if (q && !exists && isValidNewBranchName(q)) out.push({ kind: 'create', name: q }) + return out + }, [data, q, ql]) + + const selectable = (c: Choice) => + !blockedCode && !pending && (c.kind === 'create' || (c.row.switchable && !(c.kind === 'local' && c.row.current))) + + const choose = async (c: Choice) => { + if (!selectable(c)) return + const req = c.kind === 'local' + ? { path: projectDir, branch: c.row.name } + : c.kind === 'remote' + ? { path: projectDir, branch: localNameFor(c.row.name), track: c.row.name } + : { path: projectDir, branch: c.name, create: true } + setPending(choiceKey(c)) + setSwitchError(null) + try { + await api.projectGitSwitch(req) + await Promise.all([ + qc.invalidateQueries({ queryKey: ['git-status', projectDir] }), + qc.invalidateQueries({ queryKey: ['git-log', projectDir] }), + qc.invalidateQueries({ queryKey: ['git-branches', projectDir] }), + qc.invalidateQueries({ queryKey: ['project-tree', projectDir] }), + qc.invalidateQueries({ queryKey: ['project-git'] }), + ]) + close() + } catch (err) { + setSwitchError(err) + } finally { + setPending(null) + } + } + + const nav = useListKeyboardNav({ + open, + count: choices.length, + onChoose: i => { const c = choices[i]; if (c) void choose(c) }, + onClose: close, + }) + // Start on the first row Enter can act on: the checked-out branch sorts near the + // top and is never a target, so highlighting it would make Enter a no-op. + useEffect(() => { + const first = choices.findIndex(c => c.kind === 'create' || (c.row.switchable && !(c.kind === 'local' && c.row.current))) + nav.setSelected(Math.max(0, first)) + }, [query, data]) // eslint-disable-line react-hooks/exhaustive-deps + + const label = branch ?? data?.current ?? (data?.detached && data.head + ? i18nT('components.branchSwitcher.detached', { sha: data.head }) + : '') + + const switchErrorCode = gitErrorCode(switchError) + const switchErrorKey = switchErrorCode ? SWITCH_ERROR_KEYS[switchErrorCode] : undefined + const switchErrorDetail = errorDetail(switchError) + + // A running response can disable the trigger while the picker is open; close + // it then, since a switch would change files under the turn. + useEffect(() => { + if (disabledReason && open) close() + }, [disabledReason]) // eslint-disable-line react-hooks/exhaustive-deps + + const rect = open ? triggerRef.current?.getBoundingClientRect() : undefined + // 360px, narrowed on a viewport too small to fit it beside the 8px gutters. + const width = Math.min(360, window.innerWidth - 16) + const above = variant === 'chip' + const placement = rect + ? above + ? { bottom: window.innerHeight - rect.top + 4, maxHeight: Math.max(220, Math.min(460, rect.top - 12)) } + : { top: rect.bottom + 4, maxHeight: Math.max(220, Math.min(460, window.innerHeight - rect.bottom - 12)) } + : undefined + + const renderRow = (c: Choice, i: number) => { + const active = i === nav.selected + const isPending = pending === choiceKey(c) + const enabled = selectable(c) + const key = choiceKey(c) + const common = { + id: `branch-opt-${i}`, + role: 'option' as const, + 'aria-selected': active, + 'aria-disabled': !enabled || undefined, + tabIndex: -1, + ref: (el: HTMLButtonElement | null) => { nav.itemRefs.current[i] = el }, + onMouseEnter: () => nav.setSelected(i), + onMouseDown: (e: ReactMouseEvent) => { e.preventDefault(); void choose(c) }, + className: `w-full text-left px-3 py-1.5 flex items-start gap-2 border-none transition-colors ${ + active ? 'bg-bg-hover' : 'bg-transparent'} ${enabled ? 'cursor-pointer' : 'cursor-default'}`, + } + if (c.kind === 'create') { + return ( + + {i18nT('components.branchSwitcher.create_branch', { name: c.name })} + + + ) + } + const row = c.row + const isCurrent = c.kind === 'local' && row.current + const when = row.date ? timeAgo(Date.parse(row.date) / 1000) : '' + return ( + + ) + } + + const localCount = choices.filter(c => c.kind === 'local').length + const remoteStart = localCount + const remoteCount = choices.filter(c => c.kind === 'remote').length + + return ( + <> + {variant === 'chip' ? ( + // Composer shelf segment: same muted mono look as the copy chip it + // replaces. mousedown is cancelled so a click does not pull focus out of + // the composer before the picker's own input takes it. + + ) : ( + + )} + {open && rect && placement && createPortal( +
+
+
+ + { setQuery(e.target.value); setSwitchError(null) }} + {...ime.bindComposition()} + className="w-full bg-bg-elevated border border-border rounded pl-7 pr-3 py-1.5 text-[12px] font-mono text-text placeholder:text-muted placeholder:font-sans focus:outline-hidden focus-visible:border-accent" + /> +
+
+ + {(blockedCode || switchError || listError) ? ( +
+ {/* No hand-off: the hand-off navigates away and unmounts this + picker, discarding the branch name typed in its search field + (the draft "Create branch" acts on). */} + {blockedCode ? ( + + ) : switchError ? ( + + ) : ( + + )} +
+ ) : null} + +
+ {isLoading ? ( +
+ + {i18nT('components.branchSwitcher.loading')} +
+ ) : data && !data.repo ? ( +
{i18nT('components.gitPanel.not_a_repository')}
+ ) : data && choices.length === 0 ? ( +
+ {q && !isValidNewBranchName(q) + ? i18nT('components.branchSwitcher.invalid_new_name') + : i18nT('components.branchSwitcher.no_matches')} +
+ ) : ( + <> + {localCount > 0 && ( +
+ {i18nT('components.branchSwitcher.local_branches')} +
+ )} + {choices.slice(0, localCount).map((c, i) => renderRow(c, i))} + {remoteCount > 0 && ( +
+ {i18nT('components.branchSwitcher.remote_branches')} +
+ )} + {choices.slice(remoteStart, remoteStart + remoteCount).map((c, i) => renderRow(c, remoteStart + i))} + {choices.slice(remoteStart + remoteCount).map((c, i) => renderRow(c, remoteStart + remoteCount + i))} + + )} +
+ + {(data?.current || data?.truncated) && ( +
+ {data.current && ( + + {i18nT('components.branchSwitcher.current_branch')} + + + )} + + {data.truncated && ( + + {i18nT('components.branchSwitcher.truncated', { limit: 200 })} + + )} +
+ )} +
, + document.body, + )} + + ) +} diff --git a/website/src/components/ChatInput.tsx b/website/src/components/ChatInput.tsx index 884a3ee3e9a..3d205377b41 100644 --- a/website/src/components/ChatInput.tsx +++ b/website/src/components/ChatInput.tsx @@ -5,8 +5,8 @@ import { markComposerResize } from '../utils/composerResize' import { ArrowUp, Loader2, RotateCw, Sparkles, Target, CheckCircle, Lock, FolderOpen, ClipboardList, PenLine, MoreHorizontal, Terminal, Clock } from 'lucide-react' import SketchDialog from './SketchDialog' import QuoteCard from '../pages/chat/QuoteCard' -import CopyBranchButton from './CopyBranchButton' import ProjectAwsProfileStatus from './ProjectAwsProfile' +import BranchSwitcher from './BranchSwitcher' import RejectDropdown from './RejectDropdown' import { OverlayPortal } from './overlayRoot' import { useQueryClient } from '@tanstack/react-query' @@ -1695,7 +1695,7 @@ function ChatInput({ {onProjectClick && ( /* Two sibling buttons inside one visual pill, NOT a nested button: the folder segment opens the project picker and the branch segment - copies. A + {switchable &&
{statusRow}
} + {refreshButton} {requestErrors} diff --git a/website/src/components/GitReposPanel.tsx b/website/src/components/GitReposPanel.tsx index 521441eb2b8..6722272e0c8 100644 --- a/website/src/components/GitReposPanel.tsx +++ b/website/src/components/GitReposPanel.tsx @@ -14,6 +14,8 @@ interface GitReposPanelProps { /** The chat whose repositories are listed. */ slotKey: string onFileOpen?: (path: string) => void + /** True while this chat's response runs: a checkout would change files under the turn. */ + busy?: boolean } /** How many open sections poll their status on the interval at once. */ @@ -28,7 +30,7 @@ export const MAX_POLLING_SECTIONS = 3 * tab lists them without the user picking a project folder first. The chat's * project, when it is in a repository, comes first. */ -export default function GitReposPanel({ slotKey, onFileOpen }: GitReposPanelProps) { +export default function GitReposPanel({ slotKey, onFileOpen, busy = false }: GitReposPanelProps) { const { data, error, isLoading, isFetching, refetch } = useQuery({ queryKey: ['git-repos', slotKey], queryFn: () => api.projectGitRepos(slotKey), @@ -158,7 +160,11 @@ export default function GitReposPanel({ slotKey, onFileOpen }: GitReposPanelProp projectDir={repo.path} onFileOpen={onFileOpen} onOpenChange={handleOpenChange} + busy={busy} section={{ + // Only the chat's project: the switch route accepts slot + // projects, not the repositories a tool call merely touched. + branchSwitch: repo.source === 'project', label: repo.path, badge: repo.source === 'project' ? i18nT('components.gitPanel.source_project') : undefined, badgeTitle: repo.source === 'project' ? i18nT('components.gitPanel.source_project_help') : undefined, diff --git a/website/src/i18n/en.context.json b/website/src/i18n/en.context.json index bf9ac0f1698..950c7ece5b4 100644 --- a/website/src/i18n/en.context.json +++ b/website/src/i18n/en.context.json @@ -241,6 +241,12 @@ "components.filePickerMenu.folder": "aria-label on the folder ICON in the @-mention picker row and on the staged-folder chip in the composer. A directory in the file system, not a UI grouping.", "components.filePickerMenu.folder_kind": "Row metadata in the @-mention picker, rendered as 'folder · 3d ago' where a file row shows '12 KB · 3d ago'. Lowercase noun naming the entry kind (a file-system directory).", "components.folderColorNames.red": "Display name of the red folder color, interpolated into “Set color to {{name}}” on a swatch in the folder-settings color palette. The color adjective as a noun-like label.", + "components.branchSwitcher.local_branches": "Section heading in the Git branch picker over the list of LOCAL Git branches (as opposed to remote-tracking branches). Plural adjective agreeing with 'branches'.", + "components.branchSwitcher.stop_response_to_switch": "Tooltip on the disabled branch control under the chat box while the assistant is still answering: the user must stop the answer before switching the project's Git branch. Mirrors 'Stop the current response to switch project'.", + "components.branchSwitcher.remote_branches": "Section heading in the Git branch picker over the list of REMOTE Git branches (branches on the server, e.g. origin/main). Plural adjective agreeing with 'branches'.", + "components.branchSwitcher.current_branch": "Label before the checked-out Git branch name in the branch picker footer (a click-to-copy chip follows it).", + "components.branchSwitcher.detached": "Git 'detached HEAD' state: no branch is checked out, HEAD points at commit {{sha}} directly.", + "components.branchSwitcher.truncated": "Footer note in the branch picker: only the {{limit}} most recently committed branches are listed. {{limit}} is a number.", "components.jobForm.add": "Verb on the submit button of the scheduled-job form: add this job. Shown only when the form is horizontal and owns its own submit control, and it swaps to \"Adding…\" while the request is in flight. If the target language needs an object, name the job.", "components.logEntry.ok": "Status value in a table or log line, meaning the operation succeeded. A state, not the confirmation button.", "components.markdownRenderer.click_to_switch_to_this_session": "Hover tooltip on a small inline chip inside chat prose; the chip shows an agent-session identifier and clicking it switches the dashboard to that already-open chat session (an in-app tab switch, not a page navigation). \"Session\" here is a chat/agent conversation in a developer tool, never a login session.", diff --git a/website/src/i18n/locales/bn.json b/website/src/i18n/locales/bn.json index 604d15c92ce..4c78c5d4bf5 100644 --- a/website/src/i18n/locales/bn.json +++ b/website/src/i18n/locales/bn.json @@ -9341,6 +9341,36 @@ "section_expand_hint": "পরিবর্তন ও ইতিহাস দেখো", "repos_retry": "আবার চেষ্টা করো" }, + "branchSwitcher": { + "switch_branch": "ব্রাঞ্চ বদলাও", + "switch_branch_current": "ব্রাঞ্চ বদলাও (বর্তমান: {{branch}})", + "search_label": "ব্রাঞ্চ ফিল্টার করো বা নতুন একটির নাম দাও", + "search_placeholder": "ব্রাঞ্চ বদলাও বা নতুন তৈরি করতে নাম লেখো…", + "branches": "ব্রাঞ্চ", + "local_branches": "লোকাল", + "remote_branches": "রিমোট", + "loading": "ব্রাঞ্চ লোড হচ্ছে…", + "load_failed": "ব্রাঞ্চের তালিকা লোড করা যায়নি।", + "no_matches": "কোনো মিলে যাওয়া ব্রাঞ্চ নেই", + "invalid_new_name": "বৈধ ব্রাঞ্চ নাম নয়", + "create_branch": "“{{name}}” ব্রাঞ্চ তৈরি করো", + "detached": "{{sha}}-এ ডিট্যাচড", + "current_branch": "বর্তমান ব্রাঞ্চ", + "truncated": "শুধু সাম্প্রতিকতম {{limit}}টি তালিকাভুক্ত", + "not_switchable": "এই নামে একটি সিক্রেট আছে বলে মনে হচ্ছে, তাই এটি লুকানো এবং এখান থেকে এতে স্যুইচ করা যাবে না।", + "error_generic": "ব্রাঞ্চ বদলানো যায়নি", + "error_dirty": "তোমার কমিট না করা পরিবর্তনগুলো ওভাররাইট হয়ে যাবে। আগে সেগুলো কমিট বা স্ট্যাশ করো, তারপর স্যুইচ করো।", + "error_in_worktree": "সেই ব্রাঞ্চটি ইতিমধ্যে অন্য একটি ওয়ার্কট্রিতে চেক আউট করা আছে।", + "error_exists": "এই নামের একটি ব্রাঞ্চ ইতিমধ্যে আছে।", + "error_in_progress": "একটি মার্জ, রিবেস বা চেরি-পিক চলছে। আগে সেটি শেষ বা বাতিল করো।", + "error_not_found": "সেই ব্রাঞ্চটি আর নেই।", + "error_invalid": "এটি বৈধ ব্রাঞ্চ নাম নয়।", + "error_sandbox": "স্যুইচ করা বন্ধ: এই হোস্ট স্যান্ডবক্সে Git চালাতে পারে না। টার্মিনাল থেকে স্যুইচ করো।", + "error_timeout": "ব্রাঞ্চ বদলাতে Git অনেক বেশি সময় নিয়েছে।", + "outside_project": "স্যুইচ করা বন্ধ: রিপোজিটরির রুট এই সেশনের প্রজেক্ট ফোল্ডারের বাইরে।", + "switch_blocked_filter": "স্যুইচ করা বন্ধ: এই রিপোজিটরির Git কনফিগে একটি কনটেন্ট ফিল্টার ঘোষণা করা আছে যা চেকআউটের সময় চলবে। টার্মিনাল থেকে স্যুইচ করো।", + "stop_response_to_switch": "ব্রাঞ্চ বদলাতে বর্তমান উত্তর থামাও" + }, "workspaceTree": { "status_failed": "রিপোজিটরির অবস্থা পড়া যায়নি।", "row_empty": "খালি ফোল্ডার", diff --git a/website/src/i18n/locales/de.json b/website/src/i18n/locales/de.json index 3c44ade0e18..0602241b3c8 100644 --- a/website/src/i18n/locales/de.json +++ b/website/src/i18n/locales/de.json @@ -9341,6 +9341,36 @@ "section_expand_hint": "Änderungen und Verlauf anzeigen", "repos_retry": "Erneut versuchen" }, + "branchSwitcher": { + "switch_branch": "Branch wechseln", + "switch_branch_current": "Branch wechseln (aktuell: {{branch}})", + "search_label": "Branches filtern oder einen neuen benennen", + "search_placeholder": "Branch wechseln oder Namen zum Erstellen eingeben…", + "branches": "Branches", + "local_branches": "Lokal", + "remote_branches": "Remote", + "loading": "Branches werden geladen…", + "load_failed": "Die Branch-Liste konnte nicht geladen werden.", + "no_matches": "Keine passenden Branches", + "invalid_new_name": "Kein gültiger Branch-Name", + "create_branch": "Branch „{{name}}“ erstellen", + "detached": "Losgelöst bei {{sha}}", + "current_branch": "Aktueller Branch", + "truncated": "Nur die {{limit}} neuesten werden aufgeführt", + "not_switchable": "Dieser Name scheint ein Geheimnis zu enthalten. Er wird daher verborgen, und von hier aus kann nicht zu ihm gewechselt werden.", + "error_generic": "Branch konnte nicht gewechselt werden", + "error_dirty": "Deine nicht committeten Änderungen würden überschrieben. Committe oder stashe sie und wechsle dann.", + "error_in_worktree": "Dieser Branch ist bereits in einem anderen Worktree ausgecheckt.", + "error_exists": "Ein Branch mit diesem Namen existiert bereits.", + "error_in_progress": "Ein Merge, Rebase oder Cherry-Pick läuft. Schließe ihn zuerst ab oder brich ihn ab.", + "error_not_found": "Dieser Branch existiert nicht mehr.", + "error_invalid": "Das ist kein gültiger Branch-Name.", + "error_sandbox": "Wechseln ist deaktiviert: Dieser Host kann Git nicht in einer Sandbox ausführen. Wechsle im Terminal.", + "error_timeout": "Git hat zu lange gebraucht, um den Branch zu wechseln.", + "outside_project": "Wechseln ist deaktiviert: Das Repository-Stammverzeichnis liegt außerhalb des Projektordners dieser Sitzung.", + "switch_blocked_filter": "Wechseln ist deaktiviert: Die Git-Konfiguration dieses Repositorys deklariert einen Inhaltsfilter, der beim Checkout ausgeführt würde. Wechsle im Terminal.", + "stop_response_to_switch": "Aktuelle Antwort stoppen, um den Branch zu wechseln" + }, "workspaceTree": { "status_failed": "Der Repository-Status konnte nicht gelesen werden.", "row_empty": "Leerer Ordner", diff --git a/website/src/i18n/locales/en-XA.json b/website/src/i18n/locales/en-XA.json index e8f1250cf30..3ee10afb4db 100644 --- a/website/src/i18n/locales/en-XA.json +++ b/website/src/i18n/locales/en-XA.json @@ -5407,6 +5407,36 @@ "ask_agent_changes": "[Àşķ ţĥè àğèñţ àƀøùţ ţĥè çĥàñğèş ················]", "ask_agent_history": "[Àşķ ţĥè àğèñţ àƀøùţ ţĥè ĥìşţøŕý ················]" }, + "branchSwitcher": { + "switch_branch": "[Şẁìţçĥ ƀŕàñçĥ ············]", + "switch_branch_current": "[Şẁìţçĥ ƀŕàñçĥ (çùŕŕèñţ: {{branch}}) ··················]", + "search_label": "[Ƒìĺţèŕ ƀŕàñçĥèş øŕ ñàɱè à ñèẁ øñè ·················]", + "search_placeholder": "[Şẁìţçĥ øŕ ţýþè ţø çŕèàţè à ƀŕàñçĥ… ·················]", + "branches": "[Ɓŕàñçĥèş ············]", + "local_branches": "[Ĺøçàĺ ········]", + "remote_branches": "[Ŕèɱøţè ·········]", + "loading": "[Ĺøàðìñğ ƀŕàñçĥèş… ···············]", + "load_failed": "[Çøùĺðñ'ţ ĺøàð ţĥè ƀŕàñçĥ ĺìşţ. ·····················]", + "no_matches": "[Ñø ɱàţçĥìñğ ƀŕàñçĥèş ··················]", + "invalid_new_name": "[Ñøţ à ṽàĺìð ƀŕàñçĥ ñàɱè ················]", + "create_branch": "[Çŕèàţè ƀŕàñçĥ “{{name}}” ··············]", + "detached": "[Ðèţàçĥèð àţ {{sha}} ···········]", + "current_branch": "[Çùŕŕèñţ ƀŕàñçĥ ·············]", + "truncated": "[Øñĺý ţĥè {{limit}} ɱøşţ ŕèçèñţ àŕè ĺìşţèð ················]", + "not_switchable": "[Ţĥìş ñàɱè ĺøøķş ĺìķè ìţ çøñţàìñş à şèçŕèţ, şø ìţ'ş ĥìððèñ àñð çàñ'ţ ƀè şẁìţçĥèð ţø ĥèŕè. ··························]", + "error_generic": "[Çøùĺðñ'ţ şẁìţçĥ ƀŕàñçĥèş ·················]", + "error_dirty": "[Ýøùŕ ùñçøɱɱìţţèð çĥàñğèş ẁøùĺð ƀè øṽèŕẁŕìţţèñ. Çøɱɱìţ øŕ şţàşĥ ţĥèɱ, ţĥèñ şẁìţçĥ. ························]", + "error_in_worktree": "[Ţĥàţ ƀŕàñçĥ ìş àĺŕèàðý çĥèçķèð øùţ ìñ àñøţĥèŕ ẁøŕķţŕèè. ···················]", + "error_exists": "[À ƀŕàñçĥ ẁìţĥ ţĥàţ ñàɱè àĺŕèàðý èẋìşţş. ····················]", + "error_in_progress": "[À ɱèŕğè, ŕèƀàşè øŕ çĥèŕŕý-þìçķ ìş ìñ þŕøğŕèşş. Ƒìñìşĥ øŕ àƀøŕţ ìţ ƒìŕşţ. ······················]", + "error_not_found": "[Ţĥàţ ƀŕàñçĥ ñø ĺøñğèŕ èẋìşţş. ····················]", + "error_invalid": "[Ţĥàţ ìşñ'ţ à ṽàĺìð ƀŕàñçĥ ñàɱè. ················]", + "error_sandbox": "[Şẁìţçĥìñğ ìş øƒƒ: ţĥìş ĥøşţ çàñ'ţ ŕùñ Ğìţ ìñ à şàñðƀøẋ. Şẁìţçĥ ƒŕøɱ à ţèŕɱìñàĺ. ························]", + "error_timeout": "[Ğìţ ţøøķ ţøø ĺøñğ ţø şẁìţçĥ ƀŕàñçĥèş. ···················]", + "outside_project": "[Şẁìţçĥìñğ ìş øƒƒ: ţĥè ŕèþøşìţøŕý ŕøøţ ìş øùţşìðè ţĥìş şèşşìøñ'ş þŕøĵèçţ ƒøĺðèŕ. ························]", + "switch_blocked_filter": "[Şẁìţçĥìñğ ìş øƒƒ: ţĥìş ŕèþøşìţøŕý'ş Ğìţ çøñƒìğ ðèçĺàŕèş à çøñţèñţ ƒìĺţèŕ ţĥàţ ẁøùĺð ŕùñ øñ çĥèçķøùţ. Şẁìţçĥ ƒŕøɱ à ţèŕɱìñàĺ. ·····································]", + "stop_response_to_switch": "[Şţøþ ţĥè çùŕŕèñţ ŕèşþøñşè ţø şẁìţçĥ ƀŕàñçĥ ·····················]" + }, "agentImportFlow": { "back": "[Ɓàçķ ······]", "bring_your_crew_with_you": "[Ɓŕìñğ ýøùŕ çŕèẁ ẁìţĥ ýøù. ··················]", diff --git a/website/src/i18n/locales/en.json b/website/src/i18n/locales/en.json index eb0416387c3..4a7a356a963 100644 --- a/website/src/i18n/locales/en.json +++ b/website/src/i18n/locales/en.json @@ -4132,6 +4132,36 @@ "repos_retry": "Retry", "source_project_help": "The repository this chat was started in. The others are repositories the agent worked in." }, + "branchSwitcher": { + "switch_branch": "Switch branch", + "switch_branch_current": "Switch branch (current: {{branch}})", + "search_label": "Filter branches or name a new one", + "search_placeholder": "Switch or type to create a branch…", + "branches": "Branches", + "local_branches": "Local", + "remote_branches": "Remote", + "loading": "Loading branches…", + "load_failed": "Couldn't load the branch list.", + "no_matches": "No matching branches", + "invalid_new_name": "Not a valid branch name", + "create_branch": "Create branch “{{name}}”", + "detached": "Detached at {{sha}}", + "current_branch": "Current branch", + "truncated": "Only the {{limit}} most recent are listed", + "not_switchable": "This name looks like it contains a secret, so it's hidden and can't be switched to here.", + "error_generic": "Couldn't switch branches", + "error_dirty": "Your uncommitted changes would be overwritten. Commit or stash them, then switch.", + "error_in_worktree": "That branch is already checked out in another worktree.", + "error_exists": "A branch with that name already exists.", + "error_in_progress": "A merge, rebase or cherry-pick is in progress. Finish or abort it first.", + "error_not_found": "That branch no longer exists.", + "error_invalid": "That isn't a valid branch name.", + "error_sandbox": "Switching is off: this host can't run Git in a sandbox. Switch from a terminal.", + "error_timeout": "Git took too long to switch branches.", + "outside_project": "Switching is off: the repository root is outside this session's project folder.", + "switch_blocked_filter": "Switching is off: this repository's Git config declares a content filter that would run on checkout. Switch from a terminal.", + "stop_response_to_switch": "Stop the current response to switch branch" + }, "agentImportFlow": { "back": "Back", "bring_your_crew_with_you": "Bring your crew with you.", diff --git a/website/src/i18n/locales/es.json b/website/src/i18n/locales/es.json index 881b4f40783..54eded2c6f4 100644 --- a/website/src/i18n/locales/es.json +++ b/website/src/i18n/locales/es.json @@ -9480,6 +9480,36 @@ "section_expand_hint": "Mostrar cambios e historial", "repos_retry": "Reintentar" }, + "branchSwitcher": { + "switch_branch": "Cambiar de rama", + "switch_branch_current": "Cambiar de rama (actual: {{branch}})", + "search_label": "Filtra ramas o escribe el nombre de una nueva", + "search_placeholder": "Cambia de rama o escribe para crear una…", + "branches": "Ramas", + "local_branches": "Locales", + "remote_branches": "Remotas", + "loading": "Cargando ramas…", + "load_failed": "No se pudo cargar la lista de ramas.", + "no_matches": "Ninguna rama coincide", + "invalid_new_name": "No es un nombre de rama válido", + "create_branch": "Crear la rama «{{name}}»", + "detached": "Desacoplado en {{sha}}", + "current_branch": "Rama actual", + "truncated": "Solo se muestran las {{limit}} más recientes", + "not_switchable": "Este nombre parece contener un secreto, así que está oculto y no se puede cambiar a esta rama desde aquí.", + "error_generic": "No se pudo cambiar de rama", + "error_dirty": "Tus cambios sin confirmar se sobrescribirían. Confírmalos o guárdalos con stash y luego cambia.", + "error_in_worktree": "Esa rama ya está extraída en otro worktree.", + "error_exists": "Ya existe una rama con ese nombre.", + "error_in_progress": "Hay un merge, rebase o cherry-pick en curso. Termínalo o cancélalo primero.", + "error_not_found": "Esa rama ya no existe.", + "error_invalid": "Ese no es un nombre de rama válido.", + "error_sandbox": "El cambio está desactivado: este host no puede ejecutar Git en un sandbox. Cambia desde una terminal.", + "error_timeout": "Git tardó demasiado en cambiar de rama.", + "outside_project": "El cambio está desactivado: la raíz del repositorio está fuera de la carpeta del proyecto de esta sesión.", + "switch_blocked_filter": "El cambio está desactivado: la configuración de Git de este repositorio declara un filtro de contenido que se ejecutaría al extraer. Cambia desde una terminal.", + "stop_response_to_switch": "Detén la respuesta actual para cambiar de rama" + }, "workspaceTree": { "status_failed": "No se pudo leer el estado del repositorio.", "row_empty": "Carpeta vacía", diff --git a/website/src/i18n/locales/fr.json b/website/src/i18n/locales/fr.json index bb17aa1136f..70a0d5549d9 100644 --- a/website/src/i18n/locales/fr.json +++ b/website/src/i18n/locales/fr.json @@ -9480,6 +9480,36 @@ "section_expand_hint": "Afficher les modifications et l’historique", "repos_retry": "Réessayer" }, + "branchSwitcher": { + "switch_branch": "Changer de branche", + "switch_branch_current": "Changer de branche (actuelle : {{branch}})", + "search_label": "Filtrer les branches ou nommer une nouvelle branche", + "search_placeholder": "Change de branche ou tape un nom pour en créer une…", + "branches": "Branches", + "local_branches": "Locales", + "remote_branches": "Distantes", + "loading": "Chargement des branches…", + "load_failed": "Impossible de charger la liste des branches.", + "no_matches": "Aucune branche correspondante", + "invalid_new_name": "Nom de branche non valide", + "create_branch": "Créer la branche « {{name}} »", + "detached": "Détaché sur {{sha}}", + "current_branch": "Branche actuelle", + "truncated": "Seules les {{limit}} plus récentes sont listées", + "not_switchable": "Ce nom semble contenir un secret : il est donc masqué et tu ne peux pas basculer dessus d’ici.", + "error_generic": "Impossible de changer de branche", + "error_dirty": "Tes modifications non validées seraient écrasées. Valide-les ou mets-les de côté (stash), puis change de branche.", + "error_in_worktree": "Cette branche est déjà extraite dans un autre worktree.", + "error_exists": "Une branche portant ce nom existe déjà.", + "error_in_progress": "Un merge, un rebase ou un cherry-pick est en cours. Termine-le ou annule-le d’abord.", + "error_not_found": "Cette branche n’existe plus.", + "error_invalid": "Ce n’est pas un nom de branche valide.", + "error_sandbox": "Le changement est désactivé : cet hôte ne peut pas exécuter Git dans un bac à sable. Change de branche depuis un terminal.", + "error_timeout": "Git a mis trop de temps à changer de branche.", + "outside_project": "Le changement est désactivé : la racine du dépôt est en dehors du dossier de projet de cette session.", + "switch_blocked_filter": "Le changement est désactivé : la configuration Git de ce dépôt déclare un filtre de contenu qui s’exécuterait lors de l’extraction. Change de branche depuis un terminal.", + "stop_response_to_switch": "Arrête la réponse en cours pour changer de branche" + }, "workspaceTree": { "status_failed": "Impossible de lire l’état du dépôt.", "row_empty": "Dossier vide", diff --git a/website/src/i18n/locales/hi.json b/website/src/i18n/locales/hi.json index bfc8a306c45..c5c967136dc 100644 --- a/website/src/i18n/locales/hi.json +++ b/website/src/i18n/locales/hi.json @@ -9341,6 +9341,36 @@ "section_expand_hint": "बदलाव और इतिहास देखो", "repos_retry": "फिर से कोशिश करो" }, + "branchSwitcher": { + "switch_branch": "ब्रांच बदलो", + "switch_branch_current": "ब्रांच बदलो (वर्तमान: {{branch}})", + "search_label": "ब्रांच फ़िल्टर करो या नई ब्रांच का नाम दो", + "search_placeholder": "ब्रांच बदलो या नई बनाने के लिए नाम लिखो…", + "branches": "ब्रांच", + "local_branches": "लोकल", + "remote_branches": "रिमोट", + "loading": "ब्रांच लोड हो रही हैं…", + "load_failed": "ब्रांच सूची लोड नहीं हो सकी।", + "no_matches": "कोई मेल खाती ब्रांच नहीं", + "invalid_new_name": "मान्य ब्रांच नाम नहीं है", + "create_branch": "ब्रांच “{{name}}” बनाओ", + "detached": "{{sha}} पर डिटैच्ड", + "current_branch": "वर्तमान ब्रांच", + "truncated": "केवल सबसे हाल की {{limit}} सूचीबद्ध हैं", + "not_switchable": "इस नाम में कोई सीक्रेट लगता है, इसलिए यह छिपाया गया है और यहाँ से इस पर स्विच नहीं किया जा सकता।", + "error_generic": "ब्रांच नहीं बदल सकी", + "error_dirty": "तुम्हारे बिना कमिट किए बदलाव ओवरराइट हो जाएँगे। पहले उन्हें कमिट या स्टैश करो, फिर स्विच करो।", + "error_in_worktree": "वह ब्रांच पहले से किसी दूसरे वर्कट्री में चेक आउट है।", + "error_exists": "इस नाम की ब्रांच पहले से मौजूद है।", + "error_in_progress": "कोई मर्ज, रीबेस या चेरी-पिक चल रहा है। पहले उसे पूरा या रद्द करो।", + "error_not_found": "वह ब्रांच अब मौजूद नहीं है।", + "error_invalid": "यह मान्य ब्रांच नाम नहीं है।", + "error_sandbox": "स्विच करना बंद है: यह होस्ट Git को सैंडबॉक्स में नहीं चला सकता। टर्मिनल से स्विच करो।", + "error_timeout": "Git को ब्रांच बदलने में बहुत समय लगा।", + "outside_project": "स्विच करना बंद है: रिपॉज़िटरी रूट इस सेशन के प्रोजेक्ट फ़ोल्डर के बाहर है।", + "switch_blocked_filter": "स्विच करना बंद है: इस रिपॉज़िटरी का Git कॉन्फ़िग एक कंटेंट फ़िल्टर घोषित करता है जो चेकआउट पर चलेगा। टर्मिनल से स्विच करो।", + "stop_response_to_switch": "ब्रांच बदलने के लिए मौजूदा जवाब रोको" + }, "workspaceTree": { "status_failed": "रिपॉज़िटरी की स्थिति पढ़ी नहीं जा सकी।", "row_empty": "खाली फ़ोल्डर", diff --git a/website/src/i18n/locales/it.json b/website/src/i18n/locales/it.json index 22921b40819..d3ab2ac5747 100644 --- a/website/src/i18n/locales/it.json +++ b/website/src/i18n/locales/it.json @@ -9480,6 +9480,36 @@ "section_expand_hint": "Mostra modifiche e cronologia", "repos_retry": "Riprova" }, + "branchSwitcher": { + "switch_branch": "Cambia branch", + "switch_branch_current": "Cambia branch (attuale: {{branch}})", + "search_label": "Filtra i branch o dai un nome a uno nuovo", + "search_placeholder": "Cambia branch o digita un nome per crearne uno…", + "branches": "Branch", + "local_branches": "Locali", + "remote_branches": "Remoti", + "loading": "Caricamento dei branch…", + "load_failed": "Impossibile caricare l’elenco dei branch.", + "no_matches": "Nessun branch corrispondente", + "invalid_new_name": "Nome di branch non valido", + "create_branch": "Crea il branch «{{name}}»", + "detached": "Scollegato su {{sha}}", + "current_branch": "Branch attuale", + "truncated": "Sono elencati solo i {{limit}} più recenti", + "not_switchable": "Questo nome sembra contenere un segreto, quindi è nascosto e non puoi passare a questo branch da qui.", + "error_generic": "Impossibile cambiare branch", + "error_dirty": "Le modifiche non sottoposte a commit verrebbero sovrascritte. Esegui il commit o lo stash, poi cambia branch.", + "error_in_worktree": "Quel branch è già estratto in un altro worktree.", + "error_exists": "Esiste già un branch con quel nome.", + "error_in_progress": "È in corso un merge, un rebase o un cherry-pick. Completalo o annullalo prima.", + "error_not_found": "Quel branch non esiste più.", + "error_invalid": "Non è un nome di branch valido.", + "error_sandbox": "Il cambio è disattivato: questo host non può eseguire Git in una sandbox. Cambia branch dal terminale.", + "error_timeout": "Git ha impiegato troppo tempo per cambiare branch.", + "outside_project": "Il cambio è disattivato: la radice del repository è fuori dalla cartella del progetto di questa sessione.", + "switch_blocked_filter": "Il cambio è disattivato: la configurazione Git di questo repository dichiara un filtro di contenuto che verrebbe eseguito al checkout. Cambia branch dal terminale.", + "stop_response_to_switch": "Ferma la risposta corrente per cambiare branch" + }, "workspaceTree": { "status_failed": "Impossibile leggere lo stato del repository.", "row_empty": "Cartella vuota", diff --git a/website/src/i18n/locales/ja.json b/website/src/i18n/locales/ja.json index 4ff7acfe00d..692b9528aa7 100644 --- a/website/src/i18n/locales/ja.json +++ b/website/src/i18n/locales/ja.json @@ -9202,6 +9202,36 @@ "section_expand_hint": "変更と履歴を表示", "repos_retry": "再試行" }, + "branchSwitcher": { + "switch_branch": "ブランチを切り替え", + "switch_branch_current": "ブランチを切り替え(現在: {{branch}})", + "search_label": "ブランチを絞り込むか、新しいブランチ名を入力", + "search_placeholder": "ブランチを切り替え、または名前を入力して作成…", + "branches": "ブランチ", + "local_branches": "ローカル", + "remote_branches": "リモート", + "loading": "ブランチを読み込み中…", + "load_failed": "ブランチ一覧を読み込めませんでした。", + "no_matches": "一致するブランチはありません", + "invalid_new_name": "有効なブランチ名ではありません", + "create_branch": "ブランチ「{{name}}」を作成", + "detached": "{{sha}} でデタッチ", + "current_branch": "現在のブランチ", + "truncated": "最新の {{limit}} 件のみ表示しています", + "not_switchable": "この名前にはシークレットが含まれているようなので非表示にしており、ここから切り替えることはできません。", + "error_generic": "ブランチを切り替えられませんでした", + "error_dirty": "未コミットの変更が上書きされます。先にコミットするか stash してから切り替えてください。", + "error_in_worktree": "そのブランチは別のワークツリーですでにチェックアウトされています。", + "error_exists": "同じ名前のブランチがすでに存在します。", + "error_in_progress": "マージ、リベース、またはチェリーピックが進行中です。先に完了するか中止してください。", + "error_not_found": "そのブランチはもう存在しません。", + "error_invalid": "有効なブランチ名ではありません。", + "error_sandbox": "切り替えは無効です: このホストでは Git をサンドボックスで実行できません。ターミナルから切り替えてください。", + "error_timeout": "Git のブランチ切り替えに時間がかかりすぎました。", + "outside_project": "切り替えは無効です: リポジトリのルートがこのセッションのプロジェクトフォルダーの外にあります。", + "switch_blocked_filter": "切り替えは無効です: このリポジトリの Git 設定で、チェックアウト時に実行されるコンテンツフィルターが宣言されています。ターミナルから切り替えてください。", + "stop_response_to_switch": "現在の応答を停止してブランチを切り替え" + }, "workspaceTree": { "status_failed": "リポジトリの状態を読み取れませんでした。", "row_empty": "空のフォルダー", diff --git a/website/src/i18n/locales/ko.json b/website/src/i18n/locales/ko.json index 6af8906089b..10f4df29cab 100644 --- a/website/src/i18n/locales/ko.json +++ b/website/src/i18n/locales/ko.json @@ -9202,6 +9202,36 @@ "section_expand_hint": "변경 사항 및 기록 보기", "repos_retry": "다시 시도" }, + "branchSwitcher": { + "switch_branch": "브랜치 전환", + "switch_branch_current": "브랜치 전환 (현재: {{branch}})", + "search_label": "브랜치를 필터링하거나 새 브랜치 이름 입력", + "search_placeholder": "브랜치를 전환하거나 이름을 입력해 새로 만들기…", + "branches": "브랜치", + "local_branches": "로컬", + "remote_branches": "원격", + "loading": "브랜치를 불러오는 중…", + "load_failed": "브랜치 목록을 불러오지 못했습니다.", + "no_matches": "일치하는 브랜치가 없습니다", + "invalid_new_name": "유효한 브랜치 이름이 아닙니다", + "create_branch": "‘{{name}}’ 브랜치 만들기", + "detached": "{{sha}}에서 분리됨", + "current_branch": "현재 브랜치", + "truncated": "최근 {{limit}}개만 표시됩니다", + "not_switchable": "이 이름에 비밀 정보가 포함된 것으로 보여 숨겨져 있으며, 여기서는 이 브랜치로 전환할 수 없습니다.", + "error_generic": "브랜치를 전환하지 못했습니다", + "error_dirty": "커밋하지 않은 변경 사항이 덮어쓰여집니다. 먼저 커밋하거나 stash한 다음 전환하세요.", + "error_in_worktree": "해당 브랜치는 이미 다른 워크트리에서 체크아웃되어 있습니다.", + "error_exists": "같은 이름의 브랜치가 이미 있습니다.", + "error_in_progress": "병합, 리베이스 또는 체리픽이 진행 중입니다. 먼저 완료하거나 중단하세요.", + "error_not_found": "해당 브랜치가 더 이상 없습니다.", + "error_invalid": "유효한 브랜치 이름이 아닙니다.", + "error_sandbox": "전환이 꺼져 있습니다: 이 호스트에서는 Git을 샌드박스에서 실행할 수 없습니다. 터미널에서 전환하세요.", + "error_timeout": "Git이 브랜치를 전환하는 데 너무 오래 걸렸습니다.", + "outside_project": "전환이 꺼져 있습니다: 저장소 루트가 이 세션의 프로젝트 폴더 밖에 있습니다.", + "switch_blocked_filter": "전환이 꺼져 있습니다: 이 저장소의 Git 설정에 체크아웃 시 실행될 콘텐츠 필터가 선언되어 있습니다. 터미널에서 전환하세요.", + "stop_response_to_switch": "브랜치를 전환하려면 현재 응답을 중지하세요" + }, "workspaceTree": { "status_failed": "리포지토리 상태를 읽을 수 없습니다.", "row_empty": "빈 폴더", diff --git a/website/src/i18n/locales/pt.json b/website/src/i18n/locales/pt.json index 1ae7d1a2479..28e0130d47a 100644 --- a/website/src/i18n/locales/pt.json +++ b/website/src/i18n/locales/pt.json @@ -9480,6 +9480,36 @@ "section_expand_hint": "Mostrar alterações e histórico", "repos_retry": "Tentar novamente" }, + "branchSwitcher": { + "switch_branch": "Trocar de branch", + "switch_branch_current": "Trocar de branch (atual: {{branch}})", + "search_label": "Filtre branches ou dê nome a um novo", + "search_placeholder": "Troque de branch ou digite para criar um…", + "branches": "Branches", + "local_branches": "Locais", + "remote_branches": "Remotos", + "loading": "Carregando branches…", + "load_failed": "Não foi possível carregar a lista de branches.", + "no_matches": "Nenhum branch corresponde", + "invalid_new_name": "Não é um nome de branch válido", + "create_branch": "Criar o branch “{{name}}”", + "detached": "Desanexado em {{sha}}", + "current_branch": "Branch atual", + "truncated": "Apenas os {{limit}} mais recentes são listados", + "not_switchable": "Este nome parece conter um segredo, então fica oculto e não é possível trocar para ele daqui.", + "error_generic": "Não foi possível trocar de branch", + "error_dirty": "Suas alterações não commitadas seriam sobrescritas. Faça commit ou stash delas e depois troque.", + "error_in_worktree": "Esse branch já está em checkout em outro worktree.", + "error_exists": "Já existe um branch com esse nome.", + "error_in_progress": "Há um merge, rebase ou cherry-pick em andamento. Conclua ou cancele primeiro.", + "error_not_found": "Esse branch não existe mais.", + "error_invalid": "Esse não é um nome de branch válido.", + "error_sandbox": "A troca está desativada: este host não consegue executar o Git em uma sandbox. Troque pelo terminal.", + "error_timeout": "O Git demorou demais para trocar de branch.", + "outside_project": "A troca está desativada: a raiz do repositório está fora da pasta do projeto desta sessão.", + "switch_blocked_filter": "A troca está desativada: a configuração do Git deste repositório declara um filtro de conteúdo que seria executado no checkout. Troque pelo terminal.", + "stop_response_to_switch": "Pare a resposta atual para trocar de branch" + }, "workspaceTree": { "status_failed": "Não foi possível ler o estado do repositório.", "row_empty": "Pasta vazia", diff --git a/website/src/i18n/locales/ru.json b/website/src/i18n/locales/ru.json index 0a4ae4046b3..1d043bd5239 100644 --- a/website/src/i18n/locales/ru.json +++ b/website/src/i18n/locales/ru.json @@ -9619,6 +9619,36 @@ "section_expand_hint": "Показать изменения и историю", "repos_retry": "Повторить" }, + "branchSwitcher": { + "switch_branch": "Сменить ветку", + "switch_branch_current": "Сменить ветку (текущая: {{branch}})", + "search_label": "Отфильтруй ветки или введи имя новой", + "search_placeholder": "Смени ветку или введи имя, чтобы создать…", + "branches": "Ветки", + "local_branches": "Локальные", + "remote_branches": "Удалённые", + "loading": "Загрузка веток…", + "load_failed": "Не удалось загрузить список веток.", + "no_matches": "Подходящих веток нет", + "invalid_new_name": "Недопустимое имя ветки", + "create_branch": "Создать ветку «{{name}}»", + "detached": "Отсоединено на {{sha}}", + "current_branch": "Текущая ветка", + "truncated": "Показаны только последние: {{limit}}", + "not_switchable": "Похоже, это имя содержит секрет, поэтому оно скрыто и переключиться на эту ветку отсюда нельзя.", + "error_generic": "Не удалось сменить ветку", + "error_dirty": "Твои незакоммиченные изменения будут перезаписаны. Сначала закоммить их или убери в stash, затем переключись.", + "error_in_worktree": "Эта ветка уже извлечена в другом рабочем дереве (worktree).", + "error_exists": "Ветка с таким именем уже существует.", + "error_in_progress": "Идёт слияние, перебазирование или cherry-pick. Сначала заверши или отмени его.", + "error_not_found": "Этой ветки больше нет.", + "error_invalid": "Это недопустимое имя ветки.", + "error_sandbox": "Переключение отключено: на этом хосте Git нельзя запустить в песочнице. Переключись в терминале.", + "error_timeout": "Git слишком долго переключал ветку.", + "outside_project": "Переключение отключено: корень репозитория находится вне папки проекта этого сеанса.", + "switch_blocked_filter": "Переключение отключено: конфигурация Git этого репозитория объявляет фильтр содержимого, который запустится при checkout. Переключись в терминале.", + "stop_response_to_switch": "Останови текущий ответ, чтобы сменить ветку" + }, "workspaceTree": { "status_failed": "Не удалось прочитать состояние репозитория.", "row_empty": "Пустая папка", diff --git a/website/src/i18n/locales/zh-CN.json b/website/src/i18n/locales/zh-CN.json index 4c33a3695c8..4cafd9c3a00 100644 --- a/website/src/i18n/locales/zh-CN.json +++ b/website/src/i18n/locales/zh-CN.json @@ -9202,6 +9202,36 @@ "section_expand_hint": "显示更改和历史记录", "repos_retry": "重试" }, + "branchSwitcher": { + "switch_branch": "切换分支", + "switch_branch_current": "切换分支(当前:{{branch}})", + "search_label": "筛选分支或输入新分支名", + "search_placeholder": "切换分支,或输入名称以创建分支…", + "branches": "分支", + "local_branches": "本地", + "remote_branches": "远程", + "loading": "正在加载分支…", + "load_failed": "无法加载分支列表。", + "no_matches": "没有匹配的分支", + "invalid_new_name": "不是有效的分支名", + "create_branch": "创建分支“{{name}}”", + "detached": "分离于 {{sha}}", + "current_branch": "当前分支", + "truncated": "仅列出最近的 {{limit}} 个", + "not_switchable": "此名称看起来包含密钥,因此已隐藏,无法在此切换到它。", + "error_generic": "无法切换分支", + "error_dirty": "你未提交的更改会被覆盖。请先提交或储藏(stash)这些更改,再切换。", + "error_in_worktree": "该分支已在另一个工作树中检出。", + "error_exists": "已存在同名分支。", + "error_in_progress": "有合并、变基或拣选(cherry-pick)正在进行。请先完成或中止它。", + "error_not_found": "该分支已不存在。", + "error_invalid": "这不是有效的分支名。", + "error_sandbox": "切换已关闭:此主机无法在沙盒中运行 Git。请在终端中切换。", + "error_timeout": "Git 切换分支耗时过长。", + "outside_project": "切换已关闭:仓库根目录不在此会话的项目文件夹内。", + "switch_blocked_filter": "切换已关闭:此仓库的 Git 配置声明了一个会在检出时运行的内容过滤器。请在终端中切换。", + "stop_response_to_switch": "停止当前回复以切换分支" + }, "workspaceTree": { "status_failed": "无法读取仓库状态。", "row_empty": "空文件夹", diff --git a/website/src/pages/chat/ActivityViewer.tsx b/website/src/pages/chat/ActivityViewer.tsx index 7bad4cebf9c..26616b7594b 100644 --- a/website/src/pages/chat/ActivityViewer.tsx +++ b/website/src/pages/chat/ActivityViewer.tsx @@ -18,7 +18,7 @@ import IssuePanel from '../../components/IssuePanel' import { PinnedMessagesPanel } from './PinnedMessagesPanel' import type { ChatPin } from '../../api/pins' import { useAppSelector, useAppDispatch } from '../../store' -import { markSubagentApproving, markSubagentApprovalGone, reconcileGoneSubagent, isSpawnApprovalGone, isSpawnApprovalRetired, openActivityToTab, selectSubagent, clearTerminalSubagents, sseSubagentDone } from '../../store/chatSlice' +import { markSubagentApproving, markSubagentApprovalGone, reconcileGoneSubagent, isSpawnApprovalGone, isSpawnApprovalRetired, openActivityToTab, selectSubagent, clearTerminalSubagents, sseSubagentDone, selectComposerBusy } from '../../store/chatSlice' import SegmentedControl from '../../components/SegmentedControl' import { Btn, PanelSectionHeader, ContentSkeleton } from '../../components/ui' import SideChat from './SideChat' @@ -1108,6 +1108,9 @@ export default function ActivityViewer({ subagents, toolLog, open, onToggle, slo // Why they wait, when the gateway said (memory floor, critical posture, a // paused adaptive cap); undefined keeps the concurrency text below. const queuedReason = useAppSelector(s => s.chat.subagentQueuedReason?.[slot]) + // The Git panel's branch switcher is off while this chat's composer is busy, + // the same rule the composer chip follows. + const composerBusy = useAppSelector(s => selectComposerBusy(s, slot || null)) // Render cap: bounds DOM at 60-100 agents; exceptions are always within // the cap thanks to the ordering above. const [showAllSubagents, setShowAllSubagents] = useState(false) @@ -1266,7 +1269,7 @@ export default function ActivityViewer({ subagents, toolLog, open, onToggle, slo {/* Git: every repository this chat works in, the project's first */} {effectiveTab === ('git' as string) && (
- +
)} diff --git a/website/src/test/ApiClient.refactor.surface.test.ts b/website/src/test/ApiClient.refactor.surface.test.ts index f5d7a1ed0fd..d6098d44720 100644 --- a/website/src/test/ApiClient.refactor.surface.test.ts +++ b/website/src/test/ApiClient.refactor.surface.test.ts @@ -72,6 +72,7 @@ const API_KEY_ORDER = [ 'createWorktree', 'recentProjects', 'favoriteProjects', 'addFavoriteProject', 'removeFavoriteProject', 'projectAwsProfile', 'setProjectAwsProfile', 'browseDirs', 'browseDrives', 'browseFiles', 'projectGit', 'projectGitStatus', 'projectGitLog', 'projectGitRepos', + 'projectGitBranches', 'projectGitSwitch', 'projectTree', 'workspaces', 'createWorkspace', 'updateWorkspace', 'deleteWorkspace', 'crons', 'createCron', 'deleteCron', 'batchDeleteCron', 'updateCron', 'runCron', 'cronSecretsGrant', @@ -220,6 +221,7 @@ const NO_SESSION_KEY = [ 'models', 'chatSlotSelectionCapabilities', 'effortLevels', 'slashCommands', 'chatSlotAutocompact', 'chatSlotTurnTimeout', 'chatSlotsFollowDefaultCount', 'recentProjects', 'favoriteProjects', 'browseDirs', 'browseDrives', 'browseFiles', 'projectGit', 'projectGitStatus', 'projectGitLog', 'projectGitRepos', + 'projectGitBranches', 'projectTree', 'workspaces', 'crons', 'updateCron', 'cronFolders', 'updateCronFolder', 'lessons', 'hooks', 'kiroHooks', 'webhooks', 'updateWebhookToken', 'prompts', diff --git a/website/src/test/BranchSwitcher.test.tsx b/website/src/test/BranchSwitcher.test.tsx new file mode 100644 index 00000000000..d552eff3bb6 --- /dev/null +++ b/website/src/test/BranchSwitcher.test.tsx @@ -0,0 +1,192 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { i18next, initI18n } from '../i18n/all' +import type { GitBranchList, GitBranchRow } from '../api/client/files' + +const H = vi.hoisted(() => ({ + api: { + projectGitBranches: vi.fn(), + projectGitSwitch: vi.fn(), + }, +})) + +vi.mock('../api/client', () => ({ api: H.api })) + +import BranchSwitcher, { isValidNewBranchName } from '../components/BranchSwitcher' + +const PROJECT = '/workspace/project' + +function row(name: string, extra: Partial = {}): GitBranchRow { + return { + name, + sha: 'abc1234', + date: '2026-10-01T00:00:00Z', + author: 'Ada', + subject: `work on ${name}`, + switchable: true, + ...extra, + } +} + +const LIST: GitBranchList = { + repo: true, + current: 'main', + local: [row('main', { current: true }), row('feature/login', { ahead: 2 }), row('secret', { switchable: false })], + remote: [row('origin/review-fix')], +} + +function mount() { + const qc = new QueryClient({ defaultOptions: { queries: { retry: false, retryDelay: 0 } } }) + const invalidate = vi.spyOn(qc, 'invalidateQueries') + render( + + + , + ) + return { invalidate } +} + +async function openPicker() { + fireEvent.click(screen.getByTestId('branch-switcher-trigger')) + await screen.findByText('feature/login') +} + +function apiError(code: string, extra: Record = {}) { + return Object.assign(new Error(code), { body: JSON.stringify({ error: 'x', code, ...extra }) }) +} + +beforeEach(async () => { + await initI18n() + await i18next.changeLanguage('en') + H.api.projectGitBranches.mockReset().mockResolvedValue(LIST) + H.api.projectGitSwitch.mockReset().mockResolvedValue({ ok: true, branch: 'feature/login', previous: 'main' }) +}) + +describe('BranchSwitcher', () => { + it('narrows to the viewport when 360px does not fit beside the gutters', async () => { + const original = window.innerWidth + Object.defineProperty(window, 'innerWidth', { configurable: true, value: 320 }) + try { + mount() + await openPicker() + const pop = screen.getByTestId('branch-switcher') + expect(pop.style.width).toBe('304px') + expect(Number.parseFloat(pop.style.left) + 304).toBeLessThanOrEqual(320 - 8) + } finally { + Object.defineProperty(window, 'innerWidth', { configurable: true, value: original }) + } + }) + + it('reads the branch list only once opened', async () => { + mount() + expect(screen.getByTestId('branch-switcher-trigger')).toHaveTextContent('main') + expect(H.api.projectGitBranches).not.toHaveBeenCalled() + await openPicker() + expect(H.api.projectGitBranches).toHaveBeenCalledWith(PROJECT) + expect(screen.getByText('Local')).toBeInTheDocument() + expect(screen.getByText('Remote')).toBeInTheDocument() + }) + + it('switches to a local branch and refreshes every working-tree view', async () => { + const { invalidate } = mount() + await openPicker() + fireEvent.mouseDown(screen.getByText('feature/login')) + await waitFor(() => expect(screen.queryByTestId('branch-switcher')).toBeNull()) + expect(H.api.projectGitSwitch).toHaveBeenCalledWith({ path: PROJECT, branch: 'feature/login' }) + const keys = invalidate.mock.calls.map(c => JSON.stringify((c[0] as { queryKey: unknown }).queryKey)) + for (const k of [['git-status', PROJECT], ['git-log', PROJECT], ['git-branches', PROJECT], ['project-tree', PROJECT], ['project-git']]) { + expect(keys).toContain(JSON.stringify(k)) + } + }) + + it('checks out a remote branch as a tracking branch with its short name', async () => { + mount() + await openPicker() + fireEvent.mouseDown(screen.getByText('origin/review-fix')) + await waitFor(() => expect(H.api.projectGitSwitch).toHaveBeenCalled()) + expect(H.api.projectGitSwitch).toHaveBeenCalledWith({ path: PROJECT, branch: 'review-fix', track: 'origin/review-fix' }) + }) + + it('offers to create a typed name that does not exist, and Enter picks the only match', async () => { + mount() + await openPicker() + fireEvent.change(screen.getByRole('combobox'), { target: { value: 'feat/new-thing' } }) + expect(screen.getByTestId('branch-create')).toHaveTextContent('Create branch “feat/new-thing”') + fireEvent.keyDown(screen.getByRole('combobox'), { key: 'Enter' }) + await waitFor(() => expect(H.api.projectGitSwitch).toHaveBeenCalledWith({ path: PROJECT, branch: 'feat/new-thing', create: true })) + }) + + it('does not offer creation for an invalid name', async () => { + mount() + await openPicker() + fireEvent.change(screen.getByRole('combobox'), { target: { value: 'bad..name' } }) + expect(screen.queryByTestId('branch-create')).toBeNull() + expect(screen.getByText('Not a valid branch name')).toBeInTheDocument() + }) + + it('never switches to the current branch or a redacted one', async () => { + mount() + await openPicker() + fireEvent.mouseDown(screen.getByText('secret')) + fireEvent.mouseDown(screen.getAllByText('main').find(el => el.closest('[role="option"]'))!) + expect(H.api.projectGitSwitch).not.toHaveBeenCalled() + }) + + it('shows the localized reason when uncommitted changes block the switch and stays open', async () => { + H.api.projectGitSwitch.mockRejectedValue(apiError('git_switch_dirty')) + mount() + await openPicker() + fireEvent.mouseDown(screen.getByText('feature/login')) + expect(await screen.findByTestId('branch-switcher-error')).toHaveTextContent( + 'Your uncommitted changes would be overwritten. Commit or stash them, then switch.', + ) + expect(screen.getByTestId('branch-switcher')).toBeInTheDocument() + }) + + it('falls back to git’s own line for an uncoded failure', async () => { + H.api.projectGitSwitch.mockRejectedValue(apiError('git_switch_failed', { detail: 'fatal: something odd' })) + mount() + await openPicker() + fireEvent.mouseDown(screen.getByText('feature/login')) + const notice = await screen.findByTestId('branch-switcher-error') + expect(notice).toHaveTextContent("Couldn't switch branches") + expect(notice).toHaveTextContent('fatal: something odd') + }) + + it('explains a filter-driver block and disables every row', async () => { + H.api.projectGitBranches.mockResolvedValue({ ...LIST, switchBlocked: 'filter' }) + mount() + await openPicker() + expect(screen.getByTestId('branch-switcher-blocked')).toHaveTextContent(/declares a content filter/) + fireEvent.mouseDown(screen.getByText('feature/login')) + expect(H.api.projectGitSwitch).not.toHaveBeenCalled() + }) + + it('closes on Escape', async () => { + mount() + await openPicker() + fireEvent.keyDown(screen.getByRole('combobox'), { key: 'Escape' }) + await waitFor(() => expect(screen.queryByTestId('branch-switcher')).toBeNull()) + }) +}) + +describe('isValidNewBranchName', () => { + it.each(['main', 'feat/x', 'release-1.2', 'a_b'])('accepts %s', name => { + expect(isValidNewBranchName(name)).toBe(true) + }) + it.each(['', '-x', 'a..b', 'x.lock', 'HEAD', 'feat/', 'a b', 'feat/CON', 'a~1'])('rejects %s', name => { + expect(isValidNewBranchName(name)).toBe(false) + }) +}) + +describe('BranchSwitcher keyboard start', () => { + it('highlights the first switchable row, not the checked-out branch', async () => { + mount() + await openPicker() + const selected = screen.getAllByRole('option').find(el => el.getAttribute('aria-selected') === 'true') + expect(selected).toHaveTextContent('feature/login') + fireEvent.keyDown(screen.getByRole('combobox'), { key: 'Enter' }) + await waitFor(() => expect(H.api.projectGitSwitch).toHaveBeenCalledWith({ path: PROJECT, branch: 'feature/login' })) + }) +}) diff --git a/website/src/test/ChatInput.projectBranch.test.tsx b/website/src/test/ChatInput.projectBranch.test.tsx index 7fab7d8d788..9628b4674a2 100644 --- a/website/src/test/ChatInput.projectBranch.test.tsx +++ b/website/src/test/ChatInput.projectBranch.test.tsx @@ -1,22 +1,37 @@ import React from 'react' -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { describe, it, expect, vi, beforeEach } from 'vitest' import { screen, fireEvent, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { renderWithProviders } from './helpers' import ChatInput from '../components/ChatInput' import { SlotProvider } from '../providers/SlotContext' +import { api } from '../api/client' +import type { GitBranchList } from '../api/client/files' -// Pins the project-chip branch contract: the active project button -// shows " · " so the session's git context is visible without -// opening a picker, and degrades to the folder name alone when there is no -// branch to show (not a repo, git unavailable, path gone). +// Pins the project-chip branch contract: the active project pill shows +// " · " so the session's git context is visible without opening +// a picker, degrades to the folder name alone when there is no branch to show +// (not a repo, git unavailable, path gone), and the branch segment opens the +// branch picker for the session's project folder. + +const PROJECT = '/home/u/work/KiroCrew' const defaultProps = { value: '', onChange: vi.fn(), onSend: vi.fn(), onProjectClick: vi.fn(), - project: '/home/u/work/KiroCrew', + project: PROJECT, +} + +const LIST: GitBranchList = { + repo: true, + current: 'feat/example', + local: [ + { name: 'feat/example', sha: 'abc1234', date: '2026-10-01T00:00:00Z', author: 'Ada', subject: 'wip', switchable: true, current: true }, + { name: 'main', sha: 'def5678', date: '2026-09-30T00:00:00Z', author: 'Ada', subject: 'release', switchable: true }, + ], + remote: [], } beforeEach(() => { @@ -25,23 +40,24 @@ beforeEach(() => { }) const chip = () => screen.getByRole('button', { name: /Project: |Select project/ }) -const branchBtn = () => screen.getByRole('button', { name: /Cop(y|ied) (branch name|commit) / }) +const branchBtn = () => screen.getByTestId('branch-switcher-trigger') describe('ChatInput project chip branch label', () => { it('renders the branch beside the folder name', () => { renderWithProviders() expect(chip()).toHaveTextContent('KiroCrew') expect(branchBtn()).toHaveTextContent('feat/example') + expect(branchBtn()).toHaveAccessibleName('Switch branch (current: feat/example)') expect(chip().getAttribute('title')).toContain('Branch: feat/example') - expect(chip().getAttribute('title')).toContain('/home/u/work/KiroCrew') + expect(chip().getAttribute('title')).toContain(PROJECT) }) it('shows only the folder name when no branch is known', () => { renderWithProviders() const btn = chip() expect(btn).toHaveTextContent('KiroCrew') - expect(btn.getAttribute('title')).toBe('Project: /home/u/work/KiroCrew') - expect(screen.queryByRole('button', { name: /Copy branch name/ })).not.toBeInTheDocument() + expect(btn.getAttribute('title')).toBe(`Project: ${PROJECT}`) + expect(screen.queryByTestId('branch-switcher-trigger')).not.toBeInTheDocument() // No separator glyph without a branch to separate. expect(btn.textContent).not.toContain('·') }) @@ -51,8 +67,6 @@ describe('ChatInput project chip branch label', () => { expect(branchBtn()).toHaveTextContent('a1b2c3d') expect(chip().getAttribute('title')).toContain('Detached HEAD at a1b2c3d') expect(chip().getAttribute('title')).not.toContain('Branch:') - // The copy affordance calls it a commit, not a branch. - expect(screen.getByRole('button', { name: 'Copy commit a1b2c3d' })).toBeInTheDocument() }) it('names the same project and branch while a response is running, plus when a pick lands', () => { @@ -63,10 +77,15 @@ describe('ChatInput project chip branch label', () => { expect(btn).toHaveAccessibleName(/This reply finishes in KiroCrew\./) }) - it('leaves the branch copyable while a response is running', () => { + it('blocks branch switching while a response is running', () => { + const list = vi.spyOn(api, 'projectGitBranches').mockResolvedValue(LIST) renderWithProviders() - // Switching project mid-run is unsafe; reading the branch name is not. - expect(branchBtn()).not.toBeDisabled() + // A checkout mid-turn would change files under the agent, same as a project switch. + expect(branchBtn()).toBeDisabled() + expect(branchBtn()).toHaveAccessibleName('Stop the current response to switch branch') + fireEvent.click(branchBtn()) + expect(screen.queryByTestId('branch-switcher')).not.toBeInTheDocument() + expect(list).not.toHaveBeenCalled() }) it('falls back to the full path when the project has no basename', () => { @@ -74,7 +93,7 @@ describe('ChatInput project chip branch label', () => { expect(branchBtn()).toHaveTextContent('main') }) - it('does not nest the copy button inside the picker button', () => { + it('does not nest the branch button inside the project button', () => { // A ) @@ -254,11 +500,28 @@ export default function BranchSwitcher({ projectDir, branch, variant = 'header', const row = c.row const isCurrent = c.kind === 'local' && row.current const when = row.date ? timeAgo(Date.parse(row.date) / 1000) : '' + // A remote row's click creates a local branch, which its name alone does + // not say, so the row states it on a visible line of its own. + const remoteAction = c.kind === 'remote' && row.switchable + ? i18nT('components.branchSwitcher.remote_row_action', { name: localNameFor(row.name) }) + : '' + // Under the Remote heading the `/` prefix adds nothing a reader + // needs, so the title is the branch name alone. The full name is kept when + // two remotes carry the same branch, where the prefix is what tells them + // apart. + const rowTitle = c.kind === 'remote' && !ambiguousRemoteNames.has(localNameFor(row.name)) + ? localNameFor(row.name) + : row.name return ( ) : ( )} + {/* The copy-mode reason, read as the trigger's description. */} + {switchBlocked ? triggerTitle : ''} + {/* Mounted before it has anything to say, so a landed switch is + announced politely when its text arrives. */} + {switchedText} + {showNote && anchor && createPortal( +
+ {flash?.kind === 'copy_failed' ? ( + /* No hand-off: this control sits in the composer's shelf and the + chat's Git panel, and the hand-off moves the chat to a new + session, away from a composer draft that is not sent yet (the + composer's own notices leave it off for the same reason). The + notice names the text, so it can be selected and copied by hand. */ +
+ + void copyLabel()} testId="branch-switcher-copy-retry" /> +
+ ) : flash?.kind === 'switched' ? ( + // The live region above announces it; this is the visible copy. + + ) : ( + + )} +
, + overlayRoot, + )} {open && rect && placement && createPortal(
{ setQuery(e.target.value); setSwitchError(null) }} {...ime.bindComposition()} - className="w-full bg-bg-elevated border border-border rounded pl-7 pr-3 py-1.5 text-[12px] font-mono text-text placeholder:text-muted placeholder:font-sans focus:outline-hidden focus-visible:border-accent" + className="w-full bg-bg-elevated border border-border rounded pl-7 pr-3 py-1.5 text-[12px] font-mono text-text placeholder:text-muted placeholder:font-sans focus:outline-hidden focus-visible:border-accent disabled:opacity-50 disabled:cursor-not-allowed" />
@@ -385,9 +713,13 @@ export default function BranchSwitcher({ projectDir, branch, variant = 'header', ) : ( @@ -402,7 +734,19 @@ export default function BranchSwitcher({ projectDir, branch, variant = 'header', ) : null} -
+ {/* States the guarantee the switch route gives first (git refuses any + checkout that would overwrite local work), then what a switch + does, so a row reads as safe to click. Left out beside a refusal, which + says what happened instead, and in a filter-blocked repository, + which shows its own notice. */} + {showSafety && ( +

+ {i18nT('components.branchSwitcher.safe_switch_hint')} +

+ )} + +
+
{isLoading ? (
@@ -425,8 +769,10 @@ export default function BranchSwitcher({ projectDir, branch, variant = 'header', )} {choices.slice(0, localCount).map((c, i) => renderRow(c, i))} {remoteCount > 0 && ( -
- {i18nT('components.branchSwitcher.remote_branches')} + // The count says the section has rows even when they sit + // below the list's scroll edge. +
+ {i18nT('components.branchSwitcher.remote_branches')} ({remoteCount})
)} {choices.slice(remoteStart, remoteStart + remoteCount).map((c, i) => renderRow(c, remoteStart + i))} @@ -434,16 +780,62 @@ export default function BranchSwitcher({ projectDir, branch, variant = 'header', )}
+ {moreBelow && ( + // A strip of its own below the list, not an overlay: a fade drawn + // over the last visible row made that branch look disabled. + + )} +
+ + {/* The footer copy sits inside the open picker, so its failure is + told here rather than by the trigger's transient note. No + hand-off, for the same reason as the notices above the list. */} + {footerCopyFailed && ( +
+ + { + const name = footerCopyFailed + void copyToClipboard(name).then(ok => setFooterCopyFailed(ok ? null : name)) + }} + testId="branch-switcher-footer-copy-retry" + /> +
+ )} {(data?.current || data?.truncated) && ( -
+
{data.current && ( {i18nT('components.branchSwitcher.current_branch')} - + setFooterCopyFailed(ok ? null : data.current ?? null)} + /> )} + {/* The row pills are bare arrows, so the footer says once what + they count, on a line of its own so it never squeezes the + current branch's name. Left out beside the truncation note + and when no row has a pill. */} + {showArrowLegend && !data.truncated && ( + + {i18nT('components.branchSwitcher.ahead_behind_legend')} + + )} {data.truncated && ( {i18nT('components.branchSwitcher.truncated', { limit: 200 })} @@ -452,7 +844,7 @@ export default function BranchSwitcher({ projectDir, branch, variant = 'header',
)}
, - document.body, + overlayRoot, )} ) diff --git a/website/src/components/ChatInput.tsx b/website/src/components/ChatInput.tsx index 3d205377b41..ffe39a347c7 100644 --- a/website/src/components/ChatInput.tsx +++ b/website/src/components/ChatInput.tsx @@ -6,7 +6,7 @@ import { ArrowUp, Loader2, RotateCw, Sparkles, Target, CheckCircle, Lock, Folder import SketchDialog from './SketchDialog' import QuoteCard from '../pages/chat/QuoteCard' import ProjectAwsProfileStatus from './ProjectAwsProfile' -import BranchSwitcher from './BranchSwitcher' +import BranchSwitcher from './LazyBranchSwitcher' import RejectDropdown from './RejectDropdown' import { OverlayPortal } from './overlayRoot' import { useQueryClient } from '@tanstack/react-query' @@ -1732,16 +1732,18 @@ function ChatInput({ {!shelfCompact && !!projectBranch && ( <> - {/* Opens the branch picker (switch / track / create), which keeps - click-to-copy in its footer. Disabled while a response runs, - like the folder segment: a checkout would change files under - the turn. A git ref IS code, so the trigger sets `font-mono` - itself (the pill container does not supply it). */} + {/* Opens the branch picker (switch / track / create). While a + response runs, switching is blocked like the folder segment, + since a checkout would change files under the turn, but reading + the branch name is harmless: the segment stays enabled and a + click copies the name. A git ref IS code, so the trigger sets + `font-mono` itself (the pill container does not supply it). */} )} diff --git a/website/src/components/CopyBranchButton.tsx b/website/src/components/CopyBranchButton.tsx index feaa030ade6..00474c9d422 100644 --- a/website/src/components/CopyBranchButton.tsx +++ b/website/src/components/CopyBranchButton.tsx @@ -8,7 +8,8 @@ import { copyToClipboard } from '../utils/clipboard' * (which falls back to a hidden textarea + execCommand on non-secure origins * where navigator.clipboard is absent) and briefly swaps the trailing copy glyph * for a check. If the copy genuinely fails, the failure is swallowed and the - * label stays put rather than falsely announcing success. + * label stays put rather than falsely announcing success; a caller that must + * tell the user (the branch picker footer) passes ``onCopyResult``. * * Shared by the pull-request panel header and the branch picker's footer. * Extracted to its own module so the composer does not have to import the @@ -27,10 +28,13 @@ export default function CopyBranchButton({ branch, label = 'branch name', className = '', + onCopyResult, }: { branch: string label?: string className?: string + /** Called after every copy attempt with whether it reached the clipboard. */ + onCopyResult?: (didCopy: boolean) => void }) { const [copied, setCopied] = useState(false) const resetTimer = useRef | null>(null) @@ -42,6 +46,7 @@ export default function CopyBranchButton({ ) const handleCopy = async () => { const didCopy = await copyToClipboard(branch) + onCopyResult?.(didCopy) if (!didCopy) return setCopied(true) if (resetTimer.current) clearTimeout(resetTimer.current) diff --git a/website/src/components/GitPanel.tsx b/website/src/components/GitPanel.tsx index 6f6154ccb37..e26607c2f1d 100644 --- a/website/src/components/GitPanel.tsx +++ b/website/src/components/GitPanel.tsx @@ -2,7 +2,7 @@ import { useEffect, useId, useRef, useState } from 'react' import { useQuery } from '@tanstack/react-query' import { ChevronRight, GitBranch, RefreshCw, RefreshCwOff } from 'lucide-react' import { api } from '../api/client' -import BranchSwitcher from './BranchSwitcher' +import BranchSwitcher from './LazyBranchSwitcher' import ErrorNotice from './ErrorNotice' import { Btn } from './ui' import { errMessage } from '../utils/thunkError' @@ -144,7 +144,8 @@ interface GitPanelProps { * collapsed section; the open state itself stays here, since it is partly * derived from this section's own status query. */ onOpenChange?: (projectDir: string, open: boolean) => void - /** True while this chat's response runs: a checkout would change files under the turn. */ + /** True while this chat's response runs: a checkout would change files under + * the turn, so the header branch control copies the name instead of switching. */ busy?: boolean } @@ -332,7 +333,7 @@ export default function GitPanel({ projectDir, onFileOpen, section, onOpenChange ) : ( diff --git a/website/src/components/LazyBranchSwitcher.tsx b/website/src/components/LazyBranchSwitcher.tsx new file mode 100644 index 00000000000..713a4983a4d --- /dev/null +++ b/website/src/components/LazyBranchSwitcher.tsx @@ -0,0 +1,49 @@ +import { lazy, Suspense } from 'react' +import { ChevronDown, Copy, GitBranch } from 'lucide-react' +import type { BranchSwitcherProps } from './BranchSwitcher' +import { i18nT } from '../i18n/t' + +// The picker (list, filter, keyboard model, switch flow) is ~17 KB that only +// matters once someone opens it, while both of its hosts -- the composer's +// project chip and the Git panel header -- sit in the always-loaded chat-route +// chunk. Loading it behind import() keeps that chunk inside its bundle-size +// budget (scripts/check-bundle-size.mjs). +const BranchSwitcher = lazy(() => import('./BranchSwitcher')) + +/** + * Same box as BranchSwitcher's trigger, so the chip and the header do not shift + * when the real control replaces it. Not interactive: it is on screen only for + * the moment the picker's chunk takes to arrive on first render. + */ +function TriggerPlaceholder({ branch, variant = 'header', switchBlocked = false }: Pick) { + const Icon = switchBlocked ? Copy : ChevronDown + if (variant === 'chip') { + return ( + + {branch} + + + ) + } + return ( + + + {branch || i18nT('components.gitPanel.loading')} + + + ) +} + +export default function LazyBranchSwitcher(props: BranchSwitcherProps) { + return ( + }> + + + ) +} diff --git a/website/src/i18n/en.context.json b/website/src/i18n/en.context.json index 950c7ece5b4..ccf1477d35e 100644 --- a/website/src/i18n/en.context.json +++ b/website/src/i18n/en.context.json @@ -242,11 +242,29 @@ "components.filePickerMenu.folder_kind": "Row metadata in the @-mention picker, rendered as 'folder · 3d ago' where a file row shows '12 KB · 3d ago'. Lowercase noun naming the entry kind (a file-system directory).", "components.folderColorNames.red": "Display name of the red folder color, interpolated into “Set color to {{name}}” on a swatch in the folder-settings color palette. The color adjective as a noun-like label.", "components.branchSwitcher.local_branches": "Section heading in the Git branch picker over the list of LOCAL Git branches (as opposed to remote-tracking branches). Plural adjective agreeing with 'branches'.", - "components.branchSwitcher.stop_response_to_switch": "Tooltip on the disabled branch control under the chat box while the assistant is still answering: the user must stop the answer before switching the project's Git branch. Mirrors 'Stop the current response to switch project'.", + "components.branchSwitcher.copy_branch_while_running": "Tooltip on the branch control (under the chat box, and in the Git panel header) while the assistant is still answering: clicking it copies the branch name to the clipboard, and switching the project's Git branch needs the answer stopped first. 'Copy branch name' is what a click does now, not a heading. The second sentence names the stop control by its glyph ■ (keep it) and where it sits: beside the composer's Send button (use the same word as components.chatInput.send).", + "components.branchSwitcher.copy_commit_while_running": "Same tooltip as copy_branch_while_running when no branch is checked out (detached HEAD): clicking copies the short commit id. 'Commit' is the Git noun, a recorded revision, not the verb.", + "components.branchSwitcher.safe_switch_hint": "Note at the top of the Git branch picker, above the list. First says what a switch does (the files in this project folder change to the chosen branch's version), then the guarantee: the user's uncommitted changes are kept, and a switch that would overwrite any of them changes nothing. Plain words, no Git jargon, no warning tone. Call unsaved work 'uncommitted changes' (the same term as the Changes badge) in every branch-picker string; never 'edits' or 'saved'.", + "components.branchSwitcher.error_switch_to": "Bold lead of the error shown in the branch picker when switching failed; {{branch}} is the Git branch the user tried to switch to, never translated. The reason follows on the next line.", + "components.branchSwitcher.error_session_busy": "Branch picker failure reason: the switch was NOT made because another chat session, cron turn or sub-agent working in this repository is still running. Leads with the outcome ('Not switched').", "components.branchSwitcher.remote_branches": "Section heading in the Git branch picker over the list of REMOTE Git branches (branches on the server, e.g. origin/main). Plural adjective agreeing with 'branches'.", "components.branchSwitcher.current_branch": "Label before the checked-out Git branch name in the branch picker footer (a click-to-copy chip follows it).", "components.branchSwitcher.detached": "Git 'detached HEAD' state: no branch is checked out, HEAD points at commit {{sha}} directly.", "components.branchSwitcher.truncated": "Footer note in the branch picker: only the {{limit}} most recently committed branches are listed. {{limit}} is a number.", + "components.branchSwitcher.error_dirty": "Branch picker failure reason: the switch was NOT made because this branch would overwrite some of the user's uncommitted changes. Leads with the outcome ('Not switched'), then a task-level next step: commit them, or ask the agent in this chat to commit or stash them, then switch again. The terminal commands come last in parentheses as a secondary hint (keep 'git commit' and 'git stash' verbatim). A list of the files in the way may follow. Call unsaved work 'uncommitted changes' (the same term as the Changes badge) in every branch-picker string; never 'edits' or 'saved'.", + "components.branchSwitcher.switch_blocked_filter": "Notice in the branch picker when switching is disabled for this repository: its Git settings make Git run another program whenever it switches branches (a checkout filter), which the dashboard will not do. Leads with the outcome, gives that reason in plain words with no Git jargon (avoid 'checkout' and 'filter'), then the next step: switch in a terminal.", + "components.branchSwitcher.switched_to": "Short confirmation shown next to the branch control for a few seconds after a branch switch succeeded; {{branch}} is the Git branch now checked out, never translated. Past-tense statement of what happened.", + "components.branchSwitcher.copy_failed": "Error shown next to the branch control when copying to the clipboard failed (the browser refused clipboard access). {{name}} is the Git branch name or short commit id, never translated. Quote it the way your locale quotes a name, so it does not read as a word of the sentence.", + "components.branchSwitcher.copied_while_running": "Note shown next to the branch control right after a click copied the branch name while the assistant is still answering: confirms the copy, then says how to switch branch: stop the response first with the ■ button beside Send (keep the glyph; use the same word as components.chatInput.send).", + "components.branchSwitcher.ahead_count_one": "Tooltip and screen-reader text for the '↑N' badge on a row of the branch picker: the local branch has {{count}} commits its remote branch (the one it tracks) does not have. Plural on {{count}}. Say 'remote branch' (the same term as components.branchSwitcher.ahead_behind_legend), not 'upstream'. Singular form.", + "components.branchSwitcher.ahead_count_other": "Tooltip and screen-reader text for the '↑N' badge on a row of the branch picker: the local branch has {{count}} commits its remote branch (the one it tracks) does not have. Plural on {{count}}. Say 'remote branch' (the same term as components.branchSwitcher.ahead_behind_legend), not 'upstream'. Plural form.", + "components.branchSwitcher.behind_count_one": "Same as ahead_count for the '↓N' badge: the branch's remote branch (the one it tracks) has {{count}} commits this local branch does not have. Plural on {{count}}. Say 'remote branch', not 'upstream'. Singular form.", + "components.branchSwitcher.behind_count_other": "Same as ahead_count for the '↓N' badge: the branch's remote branch (the one it tracks) has {{count}} commits this local branch does not have. Plural on {{count}}. Say 'remote branch', not 'upstream'. Plural form.", + "components.branchSwitcher.create_shortcut_mac": "Key hint on the branch picker's create row (macOS), shown only while that row is highlighted: the key that creates the branch (plain Enter does not), or a click. Keep '⌘' and the key name 'Enter' as the keyboard labels them in your locale.", + "components.branchSwitcher.create_shortcut_other": "Same key hint as create_shortcut_mac on Windows and Linux. Use the Ctrl key's label on keyboards in your locale (German: Strg).", + "components.branchSwitcher.create_branch_from": "Label of the branch picker's create row: makes a new Git branch named {{name}} (the text typed in the search box) starting from {{base}} (the branch or commit checked out now). Both are Git names, never translated. Quote {{name}} the way your locale quotes a name.", + "components.branchSwitcher.remote_row_action": "Second line of a REMOTE branch row in the branch picker. Leads with the outcome ('Switch to {{name}}'), then in parentheses that a local copy of the remote branch is made and uncommitted changes are kept. {{name}} is the Git branch name, never translated. Call unsaved work 'uncommitted changes' (the same term as the Changes badge) in every branch-picker string; never 'edits' or 'saved'.", + "components.branchSwitcher.ahead_behind_legend": "Footer legend in the branch picker explaining the '↑N' and '↓N' badges on branch rows: commits ahead of / behind the remote branch this local branch tracks (Git calls it the upstream; say 'remote branch' instead). Keep the arrows; must stay short.", "components.jobForm.add": "Verb on the submit button of the scheduled-job form: add this job. Shown only when the form is horizontal and owns its own submit control, and it swaps to \"Adding…\" while the request is in flight. If the target language needs an object, name the job.", "components.logEntry.ok": "Status value in a table or log line, meaning the operation succeeded. A state, not the confirmation button.", "components.markdownRenderer.click_to_switch_to_this_session": "Hover tooltip on a small inline chip inside chat prose; the chip shows an agent-session identifier and clicking it switches the dashboard to that already-open chat session (an in-app tab switch, not a page navigation). \"Session\" here is a chat/agent conversation in a developer tool, never a login session.", diff --git a/website/src/i18n/locales/bn.json b/website/src/i18n/locales/bn.json index 4c78c5d4bf5..2503ba52e7d 100644 --- a/website/src/i18n/locales/bn.json +++ b/website/src/i18n/locales/bn.json @@ -9345,31 +9345,54 @@ "switch_branch": "ব্রাঞ্চ বদলাও", "switch_branch_current": "ব্রাঞ্চ বদলাও (বর্তমান: {{branch}})", "search_label": "ব্রাঞ্চ ফিল্টার করো বা নতুন একটির নাম দাও", - "search_placeholder": "ব্রাঞ্চ বদলাও বা নতুন তৈরি করতে নাম লেখো…", + "search_placeholder": "ব্রাঞ্চ খোঁজো বা নতুন একটির নাম দাও…", + "safe_switch_hint": "স্যুইচ করলে এই প্রজেক্ট ফোল্ডারের ফাইলগুলো সেই ব্রাঞ্চের ভার্সনে বদলে যায়। তোমার কমিট না করা পরিবর্তনগুলো থেকে যাবে। স্যুইচ করলে যদি তার কোনোটা ওভাররাইট হতো, তাহলে কিছুই বদলাবে না।", "branches": "ব্রাঞ্চ", "local_branches": "লোকাল", "remote_branches": "রিমোট", + "remote_row_action": "{{name}}-এ স্যুইচ করো (একটি লোকাল কপি তৈরি হবে, কমিট না করা পরিবর্তনগুলো থেকে যাবে)", + "ahead_count_one": "নিজের রিমোট ব্রাঞ্চের চেয়ে {{count}}টি কমিট এগিয়ে", + "ahead_count_other": "নিজের রিমোট ব্রাঞ্চের চেয়ে {{count}}টি কমিট এগিয়ে", + "behind_count_one": "নিজের রিমোট ব্রাঞ্চের চেয়ে {{count}}টি কমিট পিছিয়ে", + "behind_count_other": "নিজের রিমোট ব্রাঞ্চের চেয়ে {{count}}টি কমিট পিছিয়ে", "loading": "ব্রাঞ্চ লোড হচ্ছে…", "load_failed": "ব্রাঞ্চের তালিকা লোড করা যায়নি।", "no_matches": "কোনো মিলে যাওয়া ব্রাঞ্চ নেই", "invalid_new_name": "বৈধ ব্রাঞ্চ নাম নয়", - "create_branch": "“{{name}}” ব্রাঞ্চ তৈরি করো", + "create_branch": "নতুন ব্রাঞ্চ “{{name}}”", + "create_branch_from": "{{base}} থেকে নতুন ব্রাঞ্চ “{{name}}”", + "create_shortcut_mac": "তৈরি করতে ⌘ Enter চাপো বা ক্লিক করো", + "create_shortcut_other": "তৈরি করতে Ctrl+Enter চাপো বা ক্লিক করো", + "ahead_behind_legend": "↑ এগিয়ে · ↓ রিমোট ব্রাঞ্চের চেয়ে পিছিয়ে", "detached": "{{sha}}-এ ডিট্যাচড", "current_branch": "বর্তমান ব্রাঞ্চ", "truncated": "শুধু সাম্প্রতিকতম {{limit}}টি তালিকাভুক্ত", "not_switchable": "এই নামে একটি সিক্রেট আছে বলে মনে হচ্ছে, তাই এটি লুকানো এবং এখান থেকে এতে স্যুইচ করা যাবে না।", - "error_generic": "ব্রাঞ্চ বদলানো যায়নি", - "error_dirty": "তোমার কমিট না করা পরিবর্তনগুলো ওভাররাইট হয়ে যাবে। আগে সেগুলো কমিট বা স্ট্যাশ করো, তারপর স্যুইচ করো।", + "error_switch_to": "{{branch}}-এ স্যুইচ করা যায়নি", + "error_create": "{{branch}} ব্রাঞ্চ তৈরি করা যায়নি", + "error_dirty": "বদলানো হয়নি: এই ব্রাঞ্চ তোমার কমিট না করা কিছু পরিবর্তন ওভাররাইট করে দিত। সেগুলো কমিট করো, অথবা এই চ্যাটে এজেন্টকে সেগুলো কমিট বা stash করতে বলো, তারপর আবার বদলাও। (টার্মিনালে: git commit বা git stash।)", "error_in_worktree": "সেই ব্রাঞ্চটি ইতিমধ্যে অন্য একটি ওয়ার্কট্রিতে চেক আউট করা আছে।", "error_exists": "এই নামের একটি ব্রাঞ্চ ইতিমধ্যে আছে।", "error_in_progress": "একটি মার্জ, রিবেস বা চেরি-পিক চলছে। আগে সেটি শেষ বা বাতিল করো।", + "error_session_busy": "স্যুইচ হয়নি: এই রিপোজিটরিতে কাজ করা আরেকটি সেশন এখনো চলছে। সেটি শেষ হওয়া পর্যন্ত অপেক্ষা করো, তারপর স্যুইচ করো।", "error_not_found": "সেই ব্রাঞ্চটি আর নেই।", "error_invalid": "এটি বৈধ ব্রাঞ্চ নাম নয়।", "error_sandbox": "স্যুইচ করা বন্ধ: এই হোস্ট স্যান্ডবক্সে Git চালাতে পারে না। টার্মিনাল থেকে স্যুইচ করো।", "error_timeout": "ব্রাঞ্চ বদলাতে Git অনেক বেশি সময় নিয়েছে।", + "switched_to": "{{branch}}-এ স্যুইচ হয়েছে", "outside_project": "স্যুইচ করা বন্ধ: রিপোজিটরির রুট এই সেশনের প্রজেক্ট ফোল্ডারের বাইরে।", - "switch_blocked_filter": "স্যুইচ করা বন্ধ: এই রিপোজিটরির Git কনফিগে একটি কনটেন্ট ফিল্টার ঘোষণা করা আছে যা চেকআউটের সময় চলবে। টার্মিনাল থেকে স্যুইচ করো।", - "stop_response_to_switch": "ব্রাঞ্চ বদলাতে বর্তমান উত্তর থামাও" + "switch_blocked_filter": "এই রিপোজিটরিতে ব্রাঞ্চ বদলানো ও তৈরি করা বন্ধ। এর Git সেটিংস প্রতিবার ব্রাঞ্চ বদলানোর সময় Git-কে আরেকটি প্রোগ্রাম চালাতে বলে, আর এই প্যানেল সেটি চালায় না। টার্মিনালে ব্রাঞ্চ বদলাও।", + "copy_branch_name": "ব্রাঞ্চের নাম {{branch}} কপি করো", + "copied_branch_name": "ব্রাঞ্চের নাম {{branch}} কপি হয়েছে", + "copy_commit": "কমিট {{sha}} কপি করো", + "copied_commit": "কমিট {{sha}} কপি হয়েছে", + "copy_failed": "“{{name}}” ক্লিপবোর্ডে কপি করা যায়নি।", + "copy_branch_while_running": "ব্রাঞ্চের নাম কপি করো। ব্রাঞ্চ বদলাতে আগে পাঠান-এর পাশের ■ দিয়ে উত্তর থামাও।", + "copy_commit_while_running": "কমিট কপি করো। ব্রাঞ্চ বদলাতে আগে পাঠান-এর পাশের ■ দিয়ে উত্তর থামাও।", + "copied_while_running": "কপি হয়েছে। ব্রাঞ্চ বদলাতে আগে পাঠান-এর পাশের ■ দিয়ে উত্তর থামাও।", + "row_switch_hint": "তোমার কমিট না করা পরিবর্তনগুলো থেকে যাবে।", + "dirty_files": "যে ফাইলগুলো বাধা দিচ্ছে: {{files}}।", + "copy_retry": "আবার চেষ্টা করো" }, "workspaceTree": { "status_failed": "রিপোজিটরির অবস্থা পড়া যায়নি।", diff --git a/website/src/i18n/locales/de.json b/website/src/i18n/locales/de.json index 0602241b3c8..fa24bcddba0 100644 --- a/website/src/i18n/locales/de.json +++ b/website/src/i18n/locales/de.json @@ -9345,31 +9345,54 @@ "switch_branch": "Branch wechseln", "switch_branch_current": "Branch wechseln (aktuell: {{branch}})", "search_label": "Branches filtern oder einen neuen benennen", - "search_placeholder": "Branch wechseln oder Namen zum Erstellen eingeben…", + "search_placeholder": "Branch suchen oder einen neuen benennen…", + "safe_switch_hint": "Ein Wechsel bringt die Dateien in diesem Projektordner auf den Stand dieses Branches. Deine nicht committeten Änderungen bleiben erhalten. Würde ein Wechsel eine davon überschreiben, ändert sich nichts.", "branches": "Branches", "local_branches": "Lokal", "remote_branches": "Remote", + "remote_row_action": "Zu {{name}} wechseln (legt eine lokale Kopie an; nicht committete Änderungen bleiben erhalten)", + "ahead_count_one": "{{count}} Commit vor seinem Remote-Branch", + "ahead_count_other": "{{count}} Commits vor seinem Remote-Branch", + "behind_count_one": "{{count}} Commit hinter seinem Remote-Branch", + "behind_count_other": "{{count}} Commits hinter seinem Remote-Branch", "loading": "Branches werden geladen…", "load_failed": "Die Branch-Liste konnte nicht geladen werden.", "no_matches": "Keine passenden Branches", "invalid_new_name": "Kein gültiger Branch-Name", - "create_branch": "Branch „{{name}}“ erstellen", + "create_branch": "Neuer Branch „{{name}}“", + "create_branch_from": "Neuer Branch „{{name}}“ von {{base}}", + "create_shortcut_mac": "⌘ Enter drücken oder klicken, um ihn zu erstellen", + "create_shortcut_other": "Strg+Enter drücken oder klicken, um ihn zu erstellen", + "ahead_behind_legend": "↑ vor · ↓ hinter dem Remote-Branch", "detached": "Losgelöst bei {{sha}}", "current_branch": "Aktueller Branch", "truncated": "Nur die {{limit}} neuesten werden aufgeführt", "not_switchable": "Dieser Name scheint ein Geheimnis zu enthalten. Er wird daher verborgen, und von hier aus kann nicht zu ihm gewechselt werden.", - "error_generic": "Branch konnte nicht gewechselt werden", - "error_dirty": "Deine nicht committeten Änderungen würden überschrieben. Committe oder stashe sie und wechsle dann.", + "error_switch_to": "Wechsel zu {{branch}} nicht möglich", + "error_create": "Branch {{branch}} konnte nicht erstellt werden", + "error_dirty": "Nicht gewechselt: Dieser Branch würde einige deiner nicht committeten Änderungen überschreiben. Committe sie oder bitte den Agenten in diesem Chat, sie zu committen oder zu stashen, und wechsle dann erneut. (Im Terminal: git commit oder git stash.)", "error_in_worktree": "Dieser Branch ist bereits in einem anderen Worktree ausgecheckt.", "error_exists": "Ein Branch mit diesem Namen existiert bereits.", "error_in_progress": "Ein Merge, Rebase oder Cherry-Pick läuft. Schließe ihn zuerst ab oder brich ihn ab.", + "error_session_busy": "Nicht gewechselt: Eine andere Sitzung, die in diesem Repository arbeitet, läuft noch. Warte, bis sie fertig ist, und wechsle dann.", "error_not_found": "Dieser Branch existiert nicht mehr.", "error_invalid": "Das ist kein gültiger Branch-Name.", "error_sandbox": "Wechseln ist deaktiviert: Dieser Host kann Git nicht in einer Sandbox ausführen. Wechsle im Terminal.", "error_timeout": "Git hat zu lange gebraucht, um den Branch zu wechseln.", + "switched_to": "Zu {{branch}} gewechselt", "outside_project": "Wechseln ist deaktiviert: Das Repository-Stammverzeichnis liegt außerhalb des Projektordners dieser Sitzung.", - "switch_blocked_filter": "Wechseln ist deaktiviert: Die Git-Konfiguration dieses Repositorys deklariert einen Inhaltsfilter, der beim Checkout ausgeführt würde. Wechsle im Terminal.", - "stop_response_to_switch": "Aktuelle Antwort stoppen, um den Branch zu wechseln" + "switch_blocked_filter": "Wechseln und Erstellen von Branches sind für dieses Repository deaktiviert. Seine Git-Einstellungen lassen Git bei jedem Branch-Wechsel ein weiteres Programm ausführen, und dieses Panel führt es nicht aus. Wechsle stattdessen im Terminal.", + "copy_branch_name": "Branch-Namen {{branch}} kopieren", + "copied_branch_name": "Branch-Name {{branch}} kopiert", + "copy_commit": "Commit {{sha}} kopieren", + "copied_commit": "Commit {{sha}} kopiert", + "copy_failed": "„{{name}}“ konnte nicht in die Zwischenablage kopiert werden.", + "copy_branch_while_running": "Branch-Namen kopieren. Zum Wechseln des Branches zuerst die Antwort mit ■ neben „Senden“ stoppen.", + "copy_commit_while_running": "Commit kopieren. Zum Wechseln des Branches zuerst die Antwort mit ■ neben „Senden“ stoppen.", + "copied_while_running": "Kopiert. Zum Wechseln des Branches zuerst die Antwort mit ■ neben „Senden“ stoppen.", + "row_switch_hint": "Deine nicht committeten Änderungen bleiben erhalten.", + "dirty_files": "Betroffene Dateien: {{files}}.", + "copy_retry": "Erneut versuchen" }, "workspaceTree": { "status_failed": "Der Repository-Status konnte nicht gelesen werden.", diff --git a/website/src/i18n/locales/en-XA.json b/website/src/i18n/locales/en-XA.json index 3ee10afb4db..2aeae681c6d 100644 --- a/website/src/i18n/locales/en-XA.json +++ b/website/src/i18n/locales/en-XA.json @@ -5411,31 +5411,54 @@ "switch_branch": "[Şẁìţçĥ ƀŕàñçĥ ············]", "switch_branch_current": "[Şẁìţçĥ ƀŕàñçĥ (çùŕŕèñţ: {{branch}}) ··················]", "search_label": "[Ƒìĺţèŕ ƀŕàñçĥèş øŕ ñàɱè à ñèẁ øñè ·················]", - "search_placeholder": "[Şẁìţçĥ øŕ ţýþè ţø çŕèàţè à ƀŕàñçĥ… ·················]", + "search_placeholder": "[Ƒìñð à ƀŕàñçĥ øŕ ñàɱè à ñèẁ øñè… ················]", + "safe_switch_hint": "[Şẁìţçĥìñğ ùþðàţèş ţĥè ƒìĺèş ìñ ţĥìş þŕøĵèçţ ƒøĺðèŕ ţø ţĥàţ ƀŕàñçĥ'ş ṽèŕşìøñ. Ýøùŕ ùñçøɱɱìţţèð çĥàñğèş àŕè ķèþţ. ̃ à şẁìţçĥ ẁøùĺð øṽèŕẁŕìţè àñý øƒ ţĥèɱ, ñøţĥìñğ çĥàñğèş. ···················································]", "branches": "[Ɓŕàñçĥèş ············]", "local_branches": "[Ĺøçàĺ ········]", "remote_branches": "[Ŕèɱøţè ·········]", + "remote_row_action": "[Şẁìţçĥ ţø {{name}} (ɱàķèş à ĺøçàĺ çøþý; ùñçøɱɱìţţèð çĥàñğèş àŕè ķèþţ) ·····················]", + "ahead_count_one": "[{{count}} çøɱɱìţ àĥèàð øƒ ìţş ŕèɱøţè ƀŕàñçĥ ·················]", + "ahead_count_other": "[{{count}} çøɱɱìţş àĥèàð øƒ ìţş ŕèɱøţè ƀŕàñçĥ ··················]", + "behind_count_one": "[{{count}} çøɱɱìţ ƀèĥìñð ìţş ŕèɱøţè ƀŕàñçĥ ················]", + "behind_count_other": "[{{count}} çøɱɱìţş ƀèĥìñð ìţş ŕèɱøţè ƀŕàñçĥ ·················]", "loading": "[Ĺøàðìñğ ƀŕàñçĥèş… ···············]", "load_failed": "[Çøùĺðñ'ţ ĺøàð ţĥè ƀŕàñçĥ ĺìşţ. ·····················]", "no_matches": "[Ñø ɱàţçĥìñğ ƀŕàñçĥèş ··················]", "invalid_new_name": "[Ñøţ à ṽàĺìð ƀŕàñçĥ ñàɱè ················]", - "create_branch": "[Çŕèàţè ƀŕàñçĥ “{{name}}” ··············]", + "create_branch": "[Ñèẁ ƀŕàñçĥ “{{name}}” ············]", + "create_branch_from": "[Ñèẁ ƀŕàñçĥ “{{name}}” ƒŕøɱ {{base}} ·················]", + "create_shortcut_mac": "[Þŕèşş ⌘ Èñţèŕ øŕ çĺìçķ ţø çŕèàţè ìţ ··················]", + "create_shortcut_other": "[Þŕèşş Çţŕĺ+Èñţèŕ øŕ çĺìçķ ţø çŕèàţè ìţ ···················]", + "ahead_behind_legend": "[↑ àĥèàð · ↓ ƀèĥìñð ìţş ŕèɱøţè ƀŕàñçĥ ··················]", "detached": "[Ðèţàçĥèð àţ {{sha}} ···········]", "current_branch": "[Çùŕŕèñţ ƀŕàñçĥ ·············]", "truncated": "[Øñĺý ţĥè {{limit}} ɱøşţ ŕèçèñţ àŕè ĺìşţèð ················]", "not_switchable": "[Ţĥìş ñàɱè ĺøøķş ĺìķè ìţ çøñţàìñş à şèçŕèţ, şø ìţ'ş ĥìððèñ àñð çàñ'ţ ƀè şẁìţçĥèð ţø ĥèŕè. ··························]", - "error_generic": "[Çøùĺðñ'ţ şẁìţçĥ ƀŕàñçĥèş ·················]", - "error_dirty": "[Ýøùŕ ùñçøɱɱìţţèð çĥàñğèş ẁøùĺð ƀè øṽèŕẁŕìţţèñ. Çøɱɱìţ øŕ şţàşĥ ţĥèɱ, ţĥèñ şẁìţçĥ. ························]", + "error_switch_to": "[Çøùĺðñ'ţ şẁìţçĥ ţø {{branch}} ·················]", + "error_create": "[Çøùĺðñ'ţ çŕèàţè ƀŕàñçĥ {{branch}} ················]", + "error_dirty": "[Ñøţ şẁìţçĥèð: ţĥìş ƀŕàñçĥ ẁøùĺð øṽèŕẁŕìţè şøɱè øƒ ýøùŕ ùñçøɱɱìţţèð çĥàñğèş. Çøɱɱìţ ţĥèɱ, øŕ àşķ ţĥè àğèñţ ìñ ţĥìş çĥàţ ţø çøɱɱìţ øŕ şţàşĥ ţĥèɱ, ţĥèñ şẁìţçĥ àğàìñ. (Ìñ à ţèŕɱìñàĺ: ğìţ çøɱɱìţ øŕ ğìţ şţàşĥ.) ·····························································]", "error_in_worktree": "[Ţĥàţ ƀŕàñçĥ ìş àĺŕèàðý çĥèçķèð øùţ ìñ àñøţĥèŕ ẁøŕķţŕèè. ···················]", "error_exists": "[À ƀŕàñçĥ ẁìţĥ ţĥàţ ñàɱè àĺŕèàðý èẋìşţş. ····················]", "error_in_progress": "[À ɱèŕğè, ŕèƀàşè øŕ çĥèŕŕý-þìçķ ìş ìñ þŕøğŕèşş. Ƒìñìşĥ øŕ àƀøŕţ ìţ ƒìŕşţ. ······················]", + "error_session_busy": "[Ñøţ şẁìţçĥèð: àñøţĥèŕ şèşşìøñ ẁøŕķìñğ ìñ ţĥìş ŕèþøşìţøŕý ìş şţìĺĺ ŕùññìñğ. Ẁàìţ ƒøŕ ìţ ţø ƒìñìşĥ, ţĥèñ şẁìţçĥ. ·································]", "error_not_found": "[Ţĥàţ ƀŕàñçĥ ñø ĺøñğèŕ èẋìşţş. ····················]", "error_invalid": "[Ţĥàţ ìşñ'ţ à ṽàĺìð ƀŕàñçĥ ñàɱè. ················]", "error_sandbox": "[Şẁìţçĥìñğ ìş øƒƒ: ţĥìş ĥøşţ çàñ'ţ ŕùñ Ğìţ ìñ à şàñðƀøẋ. Şẁìţçĥ ƒŕøɱ à ţèŕɱìñàĺ. ························]", "error_timeout": "[Ğìţ ţøøķ ţøø ĺøñğ ţø şẁìţçĥ ƀŕàñçĥèş. ···················]", + "switched_to": "[Şẁìţçĥèð ţø {{branch}} ···········]", "outside_project": "[Şẁìţçĥìñğ ìş øƒƒ: ţĥè ŕèþøşìţøŕý ŕøøţ ìş øùţşìðè ţĥìş şèşşìøñ'ş þŕøĵèçţ ƒøĺðèŕ. ························]", - "switch_blocked_filter": "[Şẁìţçĥìñğ ìş øƒƒ: ţĥìş ŕèþøşìţøŕý'ş Ğìţ çøñƒìğ ðèçĺàŕèş à çøñţèñţ ƒìĺţèŕ ţĥàţ ẁøùĺð ŕùñ øñ çĥèçķøùţ. Şẁìţçĥ ƒŕøɱ à ţèŕɱìñàĺ. ·····································]", - "stop_response_to_switch": "[Şţøþ ţĥè çùŕŕèñţ ŕèşþøñşè ţø şẁìţçĥ ƀŕàñçĥ ·····················]" + "switch_blocked_filter": "[Şẁìţçĥìñğ àñð çŕèàţìñğ ƀŕàñçĥèş àŕè øƒƒ ƒøŕ ţĥìş ŕèþøşìţøŕý. Ìţş Ğìţ şèţţìñğş àşķ Ğìţ ţø ŕùñ àñøţĥèŕ þŕøğŕàɱ ẁĥèñèṽèŕ ìţ şẁìţçĥèş ƀŕàñçĥèş, àñð ţĥìş þàñèĺ ẁøñ'ţ ŕùñ ìţ. Şẁìţçĥ ìñ à ţèŕɱìñàĺ ìñşţèàð. ···························································]", + "copy_branch_name": "[Çøþý ƀŕàñçĥ ñàɱè {{branch}} ···············]", + "copied_branch_name": "[Çøþìèð ƀŕàñçĥ ñàɱè {{branch}} ·················]", + "copy_commit": "[Çøþý çøɱɱìţ {{sha}} ···········]", + "copied_commit": "[Çøþìèð çøɱɱìţ {{sha}} ·············]", + "copy_failed": "[Çøùĺðñ'ţ çøþý “{{name}}” ţø ţĥè çĺìþƀøàŕð. ·················]", + "copy_branch_while_running": "[Çøþý ƀŕàñçĥ ñàɱè. Ţø şẁìţçĥ ƀŕàñçĥ, ƒìŕşţ şţøþ ţĥè ŕèşþøñşè ẁìţĥ ■ ñèẋţ ţø Şèñð. ························]", + "copy_commit_while_running": "[Çøþý çøɱɱìţ. Ţø şẁìţçĥ ƀŕàñçĥ, ƒìŕşţ şţøþ ţĥè ŕèşþøñşè ẁìţĥ ■ ñèẋţ ţø Şèñð. ·······················]", + "copied_while_running": "[Çøþìèð. Ţø şẁìţçĥ ƀŕàñçĥ, ƒìŕşţ şţøþ ţĥè ŕèşþøñşè ẁìţĥ ■ ñèẋţ ţø Şèñð. ·························]", + "row_switch_hint": "[Ýøùŕ ùñçøɱɱìţţèð çĥàñğèş àŕè ķèþţ. ·················]", + "dirty_files": "[Ƒìĺèş ìñ ţĥè ẁàý: {{files}}. ·················]", + "copy_retry": "[Ţŕý àğàìñ ··············]" }, "agentImportFlow": { "back": "[Ɓàçķ ······]", diff --git a/website/src/i18n/locales/en.json b/website/src/i18n/locales/en.json index 4a7a356a963..5f1924fdd0e 100644 --- a/website/src/i18n/locales/en.json +++ b/website/src/i18n/locales/en.json @@ -4136,31 +4136,54 @@ "switch_branch": "Switch branch", "switch_branch_current": "Switch branch (current: {{branch}})", "search_label": "Filter branches or name a new one", - "search_placeholder": "Switch or type to create a branch…", + "search_placeholder": "Find a branch or name a new one…", + "safe_switch_hint": "Switching updates the files in this project folder to that branch's version. Your uncommitted changes are kept. If a switch would overwrite any of them, nothing changes.", "branches": "Branches", "local_branches": "Local", "remote_branches": "Remote", + "remote_row_action": "Switch to {{name}} (makes a local copy; uncommitted changes are kept)", + "ahead_count_one": "{{count}} commit ahead of its remote branch", + "ahead_count_other": "{{count}} commits ahead of its remote branch", + "behind_count_one": "{{count}} commit behind its remote branch", + "behind_count_other": "{{count}} commits behind its remote branch", "loading": "Loading branches…", "load_failed": "Couldn't load the branch list.", "no_matches": "No matching branches", "invalid_new_name": "Not a valid branch name", - "create_branch": "Create branch “{{name}}”", + "create_branch": "New branch “{{name}}”", + "create_branch_from": "New branch “{{name}}” from {{base}}", + "create_shortcut_mac": "Press ⌘ Enter or click to create it", + "create_shortcut_other": "Press Ctrl+Enter or click to create it", + "ahead_behind_legend": "↑ ahead · ↓ behind its remote branch", "detached": "Detached at {{sha}}", "current_branch": "Current branch", "truncated": "Only the {{limit}} most recent are listed", "not_switchable": "This name looks like it contains a secret, so it's hidden and can't be switched to here.", - "error_generic": "Couldn't switch branches", - "error_dirty": "Your uncommitted changes would be overwritten. Commit or stash them, then switch.", + "error_switch_to": "Couldn't switch to {{branch}}", + "error_create": "Couldn't create branch {{branch}}", + "error_dirty": "Not switched: this branch would overwrite some of your uncommitted changes. Commit them, or ask the agent in this chat to commit or stash them, then switch again. (In a terminal: git commit or git stash.)", "error_in_worktree": "That branch is already checked out in another worktree.", "error_exists": "A branch with that name already exists.", "error_in_progress": "A merge, rebase or cherry-pick is in progress. Finish or abort it first.", + "error_session_busy": "Not switched: another session working in this repository is still running. Wait for it to finish, then switch.", "error_not_found": "That branch no longer exists.", "error_invalid": "That isn't a valid branch name.", "error_sandbox": "Switching is off: this host can't run Git in a sandbox. Switch from a terminal.", "error_timeout": "Git took too long to switch branches.", + "switched_to": "Switched to {{branch}}", "outside_project": "Switching is off: the repository root is outside this session's project folder.", - "switch_blocked_filter": "Switching is off: this repository's Git config declares a content filter that would run on checkout. Switch from a terminal.", - "stop_response_to_switch": "Stop the current response to switch branch" + "switch_blocked_filter": "Switching and creating branches are off for this repository. Its Git settings ask Git to run another program whenever it switches branches, and this panel won't run it. Switch in a terminal instead.", + "copy_branch_name": "Copy branch name {{branch}}", + "copied_branch_name": "Copied branch name {{branch}}", + "copy_commit": "Copy commit {{sha}}", + "copied_commit": "Copied commit {{sha}}", + "copy_failed": "Couldn't copy “{{name}}” to the clipboard.", + "copy_branch_while_running": "Copy branch name. To switch branch, first stop the response with ■ next to Send.", + "copy_commit_while_running": "Copy commit. To switch branch, first stop the response with ■ next to Send.", + "copied_while_running": "Copied. To switch branch, first stop the response with ■ next to Send.", + "row_switch_hint": "Your uncommitted changes are kept.", + "dirty_files": "Files in the way: {{files}}.", + "copy_retry": "Try again" }, "agentImportFlow": { "back": "Back", diff --git a/website/src/i18n/locales/es.json b/website/src/i18n/locales/es.json index 54eded2c6f4..6ac2075061f 100644 --- a/website/src/i18n/locales/es.json +++ b/website/src/i18n/locales/es.json @@ -9484,31 +9484,56 @@ "switch_branch": "Cambiar de rama", "switch_branch_current": "Cambiar de rama (actual: {{branch}})", "search_label": "Filtra ramas o escribe el nombre de una nueva", - "search_placeholder": "Cambia de rama o escribe para crear una…", + "search_placeholder": "Busca una rama o ponle nombre a una nueva…", + "safe_switch_hint": "Al cambiar de rama, los archivos de esta carpeta del proyecto pasan a la versión de esa rama. Tus cambios sin commit se conservan. Si un cambio de rama sobrescribiera alguno, no cambia nada.", "branches": "Ramas", "local_branches": "Locales", "remote_branches": "Remotas", + "remote_row_action": "Cambiar a {{name}} (crea una copia local; los cambios sin commit se conservan)", + "ahead_count_one": "{{count}} commit por delante de su rama remota", + "ahead_count_many": "{{count}} commits por delante de su rama remota", + "ahead_count_other": "{{count}} commits por delante de su rama remota", + "behind_count_one": "{{count}} commit por detrás de su rama remota", + "behind_count_many": "{{count}} commits por detrás de su rama remota", + "behind_count_other": "{{count}} commits por detrás de su rama remota", "loading": "Cargando ramas…", "load_failed": "No se pudo cargar la lista de ramas.", "no_matches": "Ninguna rama coincide", "invalid_new_name": "No es un nombre de rama válido", - "create_branch": "Crear la rama «{{name}}»", + "create_branch": "Nueva rama «{{name}}»", + "create_branch_from": "Nueva rama «{{name}}» desde {{base}}", + "create_shortcut_mac": "Pulsa ⌘ Enter o haz clic para crearla", + "create_shortcut_other": "Pulsa Ctrl+Enter o haz clic para crearla", + "ahead_behind_legend": "↑ por delante · ↓ por detrás de su rama remota", "detached": "Desacoplado en {{sha}}", "current_branch": "Rama actual", "truncated": "Solo se muestran las {{limit}} más recientes", "not_switchable": "Este nombre parece contener un secreto, así que está oculto y no se puede cambiar a esta rama desde aquí.", - "error_generic": "No se pudo cambiar de rama", - "error_dirty": "Tus cambios sin confirmar se sobrescribirían. Confírmalos o guárdalos con stash y luego cambia.", + "error_switch_to": "No se pudo cambiar a {{branch}}", + "error_create": "No se pudo crear la rama {{branch}}", + "error_dirty": "No se cambió: esta rama sobrescribiría algunos de tus cambios sin commit. Haz commit de ellos, o pide al agente en este chat que haga commit o los aparte con stash, y vuelve a cambiar. (En una terminal: git commit o git stash.)", "error_in_worktree": "Esa rama ya está extraída en otro worktree.", "error_exists": "Ya existe una rama con ese nombre.", "error_in_progress": "Hay un merge, rebase o cherry-pick en curso. Termínalo o cancélalo primero.", + "error_session_busy": "No se cambió: otra sesión que trabaja en este repositorio sigue en curso. Espera a que termine y luego cambia.", "error_not_found": "Esa rama ya no existe.", "error_invalid": "Ese no es un nombre de rama válido.", "error_sandbox": "El cambio está desactivado: este host no puede ejecutar Git en un sandbox. Cambia desde una terminal.", "error_timeout": "Git tardó demasiado en cambiar de rama.", + "switched_to": "Cambiado a {{branch}}", "outside_project": "El cambio está desactivado: la raíz del repositorio está fuera de la carpeta del proyecto de esta sesión.", - "switch_blocked_filter": "El cambio está desactivado: la configuración de Git de este repositorio declara un filtro de contenido que se ejecutaría al extraer. Cambia desde una terminal.", - "stop_response_to_switch": "Detén la respuesta actual para cambiar de rama" + "switch_blocked_filter": "Cambiar y crear ramas está desactivado en este repositorio. Su configuración de Git le pide a Git ejecutar otro programa cada vez que cambia de rama, y este panel no lo ejecuta. Cambia de rama en una terminal.", + "copy_branch_name": "Copiar el nombre de la rama {{branch}}", + "copied_branch_name": "Se copió el nombre de la rama {{branch}}", + "copy_commit": "Copiar el commit {{sha}}", + "copied_commit": "Se copió el commit {{sha}}", + "copy_failed": "No se pudo copiar «{{name}}» al portapapeles.", + "copy_branch_while_running": "Copiar el nombre de la rama. Para cambiar de rama, detén primero la respuesta con ■ junto a Enviar.", + "copy_commit_while_running": "Copiar el commit. Para cambiar de rama, detén primero la respuesta con ■ junto a Enviar.", + "copied_while_running": "Copiado. Para cambiar de rama, detén primero la respuesta con ■ junto a Enviar.", + "row_switch_hint": "Tus cambios sin commit se conservan.", + "dirty_files": "Archivos afectados: {{files}}.", + "copy_retry": "Reintentar" }, "workspaceTree": { "status_failed": "No se pudo leer el estado del repositorio.", diff --git a/website/src/i18n/locales/fr.json b/website/src/i18n/locales/fr.json index 70a0d5549d9..013e6524853 100644 --- a/website/src/i18n/locales/fr.json +++ b/website/src/i18n/locales/fr.json @@ -9484,31 +9484,56 @@ "switch_branch": "Changer de branche", "switch_branch_current": "Changer de branche (actuelle : {{branch}})", "search_label": "Filtrer les branches ou nommer une nouvelle branche", - "search_placeholder": "Change de branche ou tape un nom pour en créer une…", + "search_placeholder": "Cherche une branche ou nomme une nouvelle…", + "safe_switch_hint": "Changer de branche met les fichiers de ce dossier de projet à la version de cette branche. Tes modifications non commitées sont conservées. Si un changement de branche en écrasait une, rien ne change.", "branches": "Branches", "local_branches": "Locales", "remote_branches": "Distantes", + "remote_row_action": "Passer sur {{name}} (crée une copie locale, les modifications non commitées sont conservées)", + "ahead_count_one": "{{count}} commit d’avance sur sa branche distante", + "ahead_count_many": "{{count}} commits d’avance sur sa branche distante", + "ahead_count_other": "{{count}} commits d’avance sur sa branche distante", + "behind_count_one": "{{count}} commit de retard sur sa branche distante", + "behind_count_many": "{{count}} commits de retard sur sa branche distante", + "behind_count_other": "{{count}} commits de retard sur sa branche distante", "loading": "Chargement des branches…", "load_failed": "Impossible de charger la liste des branches.", "no_matches": "Aucune branche correspondante", "invalid_new_name": "Nom de branche non valide", - "create_branch": "Créer la branche « {{name}} »", + "create_branch": "Nouvelle branche « {{name}} »", + "create_branch_from": "Nouvelle branche « {{name}} » depuis {{base}}", + "create_shortcut_mac": "Appuie sur ⌘ Entrée ou clique pour la créer", + "create_shortcut_other": "Appuie sur Ctrl+Entrée ou clique pour la créer", + "ahead_behind_legend": "↑ en avance · ↓ en retard sur sa branche distante", "detached": "Détaché sur {{sha}}", "current_branch": "Branche actuelle", "truncated": "Seules les {{limit}} plus récentes sont listées", "not_switchable": "Ce nom semble contenir un secret : il est donc masqué et tu ne peux pas basculer dessus d’ici.", - "error_generic": "Impossible de changer de branche", - "error_dirty": "Tes modifications non validées seraient écrasées. Valide-les ou mets-les de côté (stash), puis change de branche.", + "error_switch_to": "Impossible de passer à {{branch}}", + "error_create": "Impossible de créer la branche {{branch}}", + "error_dirty": "Pas de changement : cette branche écraserait certaines de tes modifications non commitées. Commite-les, ou demande à l'agent dans ce chat de les commiter ou de les mettre de côté avec stash, puis change à nouveau de branche. (Dans un terminal : git commit ou git stash.)", "error_in_worktree": "Cette branche est déjà extraite dans un autre worktree.", "error_exists": "Une branche portant ce nom existe déjà.", "error_in_progress": "Un merge, un rebase ou un cherry-pick est en cours. Termine-le ou annule-le d’abord.", + "error_session_busy": "Branche non changée : une autre session qui travaille dans ce dépôt est toujours en cours. Attends qu’elle se termine, puis change de branche.", "error_not_found": "Cette branche n’existe plus.", "error_invalid": "Ce n’est pas un nom de branche valide.", "error_sandbox": "Le changement est désactivé : cet hôte ne peut pas exécuter Git dans un bac à sable. Change de branche depuis un terminal.", "error_timeout": "Git a mis trop de temps à changer de branche.", + "switched_to": "Passé à {{branch}}", "outside_project": "Le changement est désactivé : la racine du dépôt est en dehors du dossier de projet de cette session.", - "switch_blocked_filter": "Le changement est désactivé : la configuration Git de ce dépôt déclare un filtre de contenu qui s’exécuterait lors de l’extraction. Change de branche depuis un terminal.", - "stop_response_to_switch": "Arrête la réponse en cours pour changer de branche" + "switch_blocked_filter": "Le changement et la création de branches sont désactivés pour ce dépôt. Sa configuration Git demande à Git de lancer un autre programme à chaque changement de branche, et ce panneau ne le lance pas. Change plutôt de branche dans un terminal.", + "copy_branch_name": "Copier le nom de la branche {{branch}}", + "copied_branch_name": "Nom de la branche {{branch}} copié", + "copy_commit": "Copier le commit {{sha}}", + "copied_commit": "Commit {{sha}} copié", + "copy_failed": "Impossible de copier « {{name}} » dans le presse-papiers.", + "copy_branch_while_running": "Copier le nom de la branche. Pour changer de branche, arrête d’abord la réponse avec ■ à côté d’Envoyer.", + "copy_commit_while_running": "Copier le commit. Pour changer de branche, arrête d’abord la réponse avec ■ à côté d’Envoyer.", + "copied_while_running": "Copié. Pour changer de branche, arrête d’abord la réponse avec ■ à côté d’Envoyer.", + "row_switch_hint": "Tes modifications non commitées sont conservées.", + "dirty_files": "Fichiers concernés : {{files}}.", + "copy_retry": "Réessayer" }, "workspaceTree": { "status_failed": "Impossible de lire l’état du dépôt.", diff --git a/website/src/i18n/locales/hi.json b/website/src/i18n/locales/hi.json index c5c967136dc..2d1ecfb7f93 100644 --- a/website/src/i18n/locales/hi.json +++ b/website/src/i18n/locales/hi.json @@ -9345,31 +9345,54 @@ "switch_branch": "ब्रांच बदलो", "switch_branch_current": "ब्रांच बदलो (वर्तमान: {{branch}})", "search_label": "ब्रांच फ़िल्टर करो या नई ब्रांच का नाम दो", - "search_placeholder": "ब्रांच बदलो या नई बनाने के लिए नाम लिखो…", + "search_placeholder": "ब्रांच ढूँढो या नई का नाम दो…", + "safe_switch_hint": "स्विच करने पर इस प्रोजेक्ट फ़ोल्डर की फ़ाइलें उस ब्रांच के वर्शन में बदल जाती हैं। तुम्हारे बिना कमिट किए बदलाव बने रहेंगे। अगर स्विच करने से उनमें से कोई ओवरराइट होता, तो कुछ नहीं बदलेगा।", "branches": "ब्रांच", "local_branches": "लोकल", "remote_branches": "रिमोट", + "remote_row_action": "{{name}} पर स्विच करो (एक लोकल कॉपी बनेगी, बिना कमिट किए बदलाव बने रहेंगे)", + "ahead_count_one": "अपनी रिमोट ब्रांच से {{count}} कमिट आगे", + "ahead_count_other": "अपनी रिमोट ब्रांच से {{count}} कमिट आगे", + "behind_count_one": "अपनी रिमोट ब्रांच से {{count}} कमिट पीछे", + "behind_count_other": "अपनी रिमोट ब्रांच से {{count}} कमिट पीछे", "loading": "ब्रांच लोड हो रही हैं…", "load_failed": "ब्रांच सूची लोड नहीं हो सकी।", "no_matches": "कोई मेल खाती ब्रांच नहीं", "invalid_new_name": "मान्य ब्रांच नाम नहीं है", - "create_branch": "ब्रांच “{{name}}” बनाओ", + "create_branch": "नई ब्रांच “{{name}}”", + "create_branch_from": "{{base}} से नई ब्रांच “{{name}}”", + "create_shortcut_mac": "बनाने के लिए ⌘ Enter दबाओ या क्लिक करो", + "create_shortcut_other": "बनाने के लिए Ctrl+Enter दबाओ या क्लिक करो", + "ahead_behind_legend": "↑ आगे · ↓ रिमोट ब्रांच से पीछे", "detached": "{{sha}} पर डिटैच्ड", "current_branch": "वर्तमान ब्रांच", "truncated": "केवल सबसे हाल की {{limit}} सूचीबद्ध हैं", "not_switchable": "इस नाम में कोई सीक्रेट लगता है, इसलिए यह छिपाया गया है और यहाँ से इस पर स्विच नहीं किया जा सकता।", - "error_generic": "ब्रांच नहीं बदल सकी", - "error_dirty": "तुम्हारे बिना कमिट किए बदलाव ओवरराइट हो जाएँगे। पहले उन्हें कमिट या स्टैश करो, फिर स्विच करो।", + "error_switch_to": "{{branch}} पर स्विच नहीं हो सका", + "error_create": "ब्रांच {{branch}} नहीं बन सकी", + "error_dirty": "ब्रांच नहीं बदली: यह ब्रांच तुम्हारे बिना कमिट किए कुछ बदलावों को ओवरराइट कर देती। उन्हें कमिट करो, या इस चैट में एजेंट से उन्हें कमिट या stash करने को कहो, फिर दोबारा ब्रांच बदलो। (टर्मिनल में: git commit या git stash।)", "error_in_worktree": "वह ब्रांच पहले से किसी दूसरे वर्कट्री में चेक आउट है।", "error_exists": "इस नाम की ब्रांच पहले से मौजूद है।", "error_in_progress": "कोई मर्ज, रीबेस या चेरी-पिक चल रहा है। पहले उसे पूरा या रद्द करो।", + "error_session_busy": "स्विच नहीं हुआ: इस रिपॉज़िटरी में काम कर रहा एक और सेशन अभी चल रहा है। उसके खत्म होने तक रुको, फिर स्विच करो।", "error_not_found": "वह ब्रांच अब मौजूद नहीं है।", "error_invalid": "यह मान्य ब्रांच नाम नहीं है।", "error_sandbox": "स्विच करना बंद है: यह होस्ट Git को सैंडबॉक्स में नहीं चला सकता। टर्मिनल से स्विच करो।", "error_timeout": "Git को ब्रांच बदलने में बहुत समय लगा।", + "switched_to": "{{branch}} पर स्विच हो गया", "outside_project": "स्विच करना बंद है: रिपॉज़िटरी रूट इस सेशन के प्रोजेक्ट फ़ोल्डर के बाहर है।", - "switch_blocked_filter": "स्विच करना बंद है: इस रिपॉज़िटरी का Git कॉन्फ़िग एक कंटेंट फ़िल्टर घोषित करता है जो चेकआउट पर चलेगा। टर्मिनल से स्विच करो।", - "stop_response_to_switch": "ब्रांच बदलने के लिए मौजूदा जवाब रोको" + "switch_blocked_filter": "इस रिपॉज़िटरी के लिए ब्रांच बदलना और बनाना बंद है। इसकी Git सेटिंग हर बार ब्रांच बदलने पर Git से एक और प्रोग्राम चलवाती है, और यह पैनल उसे नहीं चलाता। टर्मिनल में ब्रांच बदलो।", + "copy_branch_name": "ब्रांच का नाम {{branch}} कॉपी करो", + "copied_branch_name": "ब्रांच का नाम {{branch}} कॉपी हो गया", + "copy_commit": "कमिट {{sha}} कॉपी करो", + "copied_commit": "कमिट {{sha}} कॉपी हो गया", + "copy_failed": "“{{name}}” को क्लिपबोर्ड पर कॉपी नहीं किया जा सका।", + "copy_branch_while_running": "ब्रांच का नाम कॉपी करो। ब्रांच बदलने के लिए पहले भेजें के पास वाले ■ से जवाब रोको।", + "copy_commit_while_running": "कमिट कॉपी करो। ब्रांच बदलने के लिए पहले भेजें के पास वाले ■ से जवाब रोको।", + "copied_while_running": "कॉपी हो गया। ब्रांच बदलने के लिए पहले भेजें के पास वाले ■ से जवाब रोको।", + "row_switch_hint": "तुम्हारे बिना कमिट किए बदलाव बने रहेंगे।", + "dirty_files": "रुकावट वाली फ़ाइलें: {{files}}।", + "copy_retry": "फिर से कोशिश करो" }, "workspaceTree": { "status_failed": "रिपॉज़िटरी की स्थिति पढ़ी नहीं जा सकी।", diff --git a/website/src/i18n/locales/it.json b/website/src/i18n/locales/it.json index d3ab2ac5747..d030fc66887 100644 --- a/website/src/i18n/locales/it.json +++ b/website/src/i18n/locales/it.json @@ -9484,31 +9484,56 @@ "switch_branch": "Cambia branch", "switch_branch_current": "Cambia branch (attuale: {{branch}})", "search_label": "Filtra i branch o dai un nome a uno nuovo", - "search_placeholder": "Cambia branch o digita un nome per crearne uno…", + "search_placeholder": "Cerca un branch o dai un nome a uno nuovo…", + "safe_switch_hint": "Cambiare branch aggiorna i file di questa cartella del progetto alla versione di quel branch. Le tue modifiche non committate restano. Se un cambio di branch ne sovrascrivesse qualcuna, non cambia nulla.", "branches": "Branch", "local_branches": "Locali", "remote_branches": "Remoti", + "remote_row_action": "Passa a {{name}} (crea una copia locale; le modifiche non committate restano)", + "ahead_count_one": "{{count}} commit avanti rispetto al suo branch remoto", + "ahead_count_many": "{{count}} commit avanti rispetto al suo branch remoto", + "ahead_count_other": "{{count}} commit avanti rispetto al suo branch remoto", + "behind_count_one": "{{count}} commit indietro rispetto al suo branch remoto", + "behind_count_many": "{{count}} commit indietro rispetto al suo branch remoto", + "behind_count_other": "{{count}} commit indietro rispetto al suo branch remoto", "loading": "Caricamento dei branch…", "load_failed": "Impossibile caricare l’elenco dei branch.", "no_matches": "Nessun branch corrispondente", "invalid_new_name": "Nome di branch non valido", - "create_branch": "Crea il branch «{{name}}»", + "create_branch": "Nuovo branch «{{name}}»", + "create_branch_from": "Nuovo branch «{{name}}» da {{base}}", + "create_shortcut_mac": "Premi ⌘ Invio o fai clic per crearlo", + "create_shortcut_other": "Premi Ctrl+Invio o fai clic per crearlo", + "ahead_behind_legend": "↑ avanti · ↓ indietro rispetto al suo branch remoto", "detached": "Scollegato su {{sha}}", "current_branch": "Branch attuale", "truncated": "Sono elencati solo i {{limit}} più recenti", "not_switchable": "Questo nome sembra contenere un segreto, quindi è nascosto e non puoi passare a questo branch da qui.", - "error_generic": "Impossibile cambiare branch", - "error_dirty": "Le modifiche non sottoposte a commit verrebbero sovrascritte. Esegui il commit o lo stash, poi cambia branch.", + "error_switch_to": "Impossibile passare a {{branch}}", + "error_create": "Impossibile creare il branch {{branch}}", + "error_dirty": "Non cambiato: questo branch sovrascriverebbe alcune delle tue modifiche non committate. Esegui il commit, oppure chiedi all'agente in questa chat di farne il commit o di metterle da parte con stash, poi cambia di nuovo. (In un terminale: git commit o git stash.)", "error_in_worktree": "Quel branch è già estratto in un altro worktree.", "error_exists": "Esiste già un branch con quel nome.", "error_in_progress": "È in corso un merge, un rebase o un cherry-pick. Completalo o annullalo prima.", + "error_session_busy": "Branch non cambiato: un’altra sessione che lavora in questo repository è ancora in esecuzione. Attendi che finisca, poi cambia branch.", "error_not_found": "Quel branch non esiste più.", "error_invalid": "Non è un nome di branch valido.", "error_sandbox": "Il cambio è disattivato: questo host non può eseguire Git in una sandbox. Cambia branch dal terminale.", "error_timeout": "Git ha impiegato troppo tempo per cambiare branch.", + "switched_to": "Passato a {{branch}}", "outside_project": "Il cambio è disattivato: la radice del repository è fuori dalla cartella del progetto di questa sessione.", - "switch_blocked_filter": "Il cambio è disattivato: la configurazione Git di questo repository dichiara un filtro di contenuto che verrebbe eseguito al checkout. Cambia branch dal terminale.", - "stop_response_to_switch": "Ferma la risposta corrente per cambiare branch" + "switch_blocked_filter": "Il cambio e la creazione di branch sono disattivati per questo repository. Le sue impostazioni Git fanno eseguire a Git un altro programma a ogni cambio di branch, e questo pannello non lo esegue. Cambia branch da un terminale.", + "copy_branch_name": "Copia il nome del branch {{branch}}", + "copied_branch_name": "Nome del branch {{branch}} copiato", + "copy_commit": "Copia il commit {{sha}}", + "copied_commit": "Commit {{sha}} copiato", + "copy_failed": "Impossibile copiare «{{name}}» negli appunti.", + "copy_branch_while_running": "Copia il nome del branch. Per cambiare branch, ferma prima la risposta con ■ accanto a Invia.", + "copy_commit_while_running": "Copia il commit. Per cambiare branch, ferma prima la risposta con ■ accanto a Invia.", + "copied_while_running": "Copiato. Per cambiare branch, ferma prima la risposta con ■ accanto a Invia.", + "row_switch_hint": "Le tue modifiche non committate restano.", + "dirty_files": "File interessati: {{files}}.", + "copy_retry": "Riprova" }, "workspaceTree": { "status_failed": "Impossibile leggere lo stato del repository.", diff --git a/website/src/i18n/locales/ja.json b/website/src/i18n/locales/ja.json index 692b9528aa7..109e127f8f4 100644 --- a/website/src/i18n/locales/ja.json +++ b/website/src/i18n/locales/ja.json @@ -9206,31 +9206,52 @@ "switch_branch": "ブランチを切り替え", "switch_branch_current": "ブランチを切り替え(現在: {{branch}})", "search_label": "ブランチを絞り込むか、新しいブランチ名を入力", - "search_placeholder": "ブランチを切り替え、または名前を入力して作成…", + "search_placeholder": "ブランチを検索、または新しい名前を入力…", + "safe_switch_hint": "切り替えると、このプロジェクトフォルダーのファイルがそのブランチの内容に更新されます。コミットしていない変更は保持されます。切り替えでそのどれかが上書きされる場合は、何も変わりません。", "branches": "ブランチ", "local_branches": "ローカル", "remote_branches": "リモート", + "remote_row_action": "{{name}} に切り替え(ローカルのコピーを作成。コミットしていない変更は保持されます)", + "ahead_count_other": "リモートブランチより {{count}} コミット進んでいます", + "behind_count_other": "リモートブランチより {{count}} コミット遅れています", "loading": "ブランチを読み込み中…", "load_failed": "ブランチ一覧を読み込めませんでした。", "no_matches": "一致するブランチはありません", "invalid_new_name": "有効なブランチ名ではありません", - "create_branch": "ブランチ「{{name}}」を作成", + "create_branch": "新しいブランチ「{{name}}」", + "create_branch_from": "{{base}} から新しいブランチ「{{name}}」", + "create_shortcut_mac": "⌘ Enter またはクリックで作成", + "create_shortcut_other": "Ctrl+Enter またはクリックで作成", + "ahead_behind_legend": "↑ 先行 · ↓ 遅れ(リモートブランチ比)", "detached": "{{sha}} でデタッチ", "current_branch": "現在のブランチ", "truncated": "最新の {{limit}} 件のみ表示しています", "not_switchable": "この名前にはシークレットが含まれているようなので非表示にしており、ここから切り替えることはできません。", - "error_generic": "ブランチを切り替えられませんでした", - "error_dirty": "未コミットの変更が上書きされます。先にコミットするか stash してから切り替えてください。", + "error_switch_to": "{{branch}} に切り替えられませんでした", + "error_create": "ブランチ {{branch}} を作成できませんでした", + "error_dirty": "切り替えていません: このブランチに切り替えると、コミットしていない変更の一部が上書きされます。変更をコミットするか、このチャットでエージェントにコミットまたは stash を頼んでから、もう一度切り替えてください。(ターミナルでは git commit または git stash)", "error_in_worktree": "そのブランチは別のワークツリーですでにチェックアウトされています。", "error_exists": "同じ名前のブランチがすでに存在します。", "error_in_progress": "マージ、リベース、またはチェリーピックが進行中です。先に完了するか中止してください。", + "error_session_busy": "切り替えていません: このリポジトリで作業中の別のセッションがまだ実行中です。完了するまで待ってから切り替えてください。", "error_not_found": "そのブランチはもう存在しません。", "error_invalid": "有効なブランチ名ではありません。", "error_sandbox": "切り替えは無効です: このホストでは Git をサンドボックスで実行できません。ターミナルから切り替えてください。", "error_timeout": "Git のブランチ切り替えに時間がかかりすぎました。", + "switched_to": "{{branch}} に切り替えました", "outside_project": "切り替えは無効です: リポジトリのルートがこのセッションのプロジェクトフォルダーの外にあります。", - "switch_blocked_filter": "切り替えは無効です: このリポジトリの Git 設定で、チェックアウト時に実行されるコンテンツフィルターが宣言されています。ターミナルから切り替えてください。", - "stop_response_to_switch": "現在の応答を停止してブランチを切り替え" + "switch_blocked_filter": "このリポジトリではブランチの切り替えと作成がオフです。Git 設定により、ブランチを切り替えるたびに Git が別のプログラムを実行しますが、このパネルはそれを実行しません。ターミナルで切り替えてください。", + "copy_branch_name": "ブランチ名 {{branch}} をコピー", + "copied_branch_name": "ブランチ名 {{branch}} をコピーしました", + "copy_commit": "コミット {{sha}} をコピー", + "copied_commit": "コミット {{sha}} をコピーしました", + "copy_failed": "「{{name}}」をクリップボードにコピーできませんでした。", + "copy_branch_while_running": "ブランチ名をコピーします。ブランチを切り替えるには、まず「送信」の横の ■ で応答を停止してください。", + "copy_commit_while_running": "コミットをコピーします。ブランチを切り替えるには、まず「送信」の横の ■ で応答を停止してください。", + "copied_while_running": "コピーしました。ブランチを切り替えるには、まず「送信」の横の ■ で応答を停止してください。", + "row_switch_hint": "コミットしていない変更は保持されます。", + "dirty_files": "対象のファイル: {{files}}。", + "copy_retry": "再試行" }, "workspaceTree": { "status_failed": "リポジトリの状態を読み取れませんでした。", diff --git a/website/src/i18n/locales/ko.json b/website/src/i18n/locales/ko.json index 10f4df29cab..6225176337e 100644 --- a/website/src/i18n/locales/ko.json +++ b/website/src/i18n/locales/ko.json @@ -9206,31 +9206,52 @@ "switch_branch": "브랜치 전환", "switch_branch_current": "브랜치 전환 (현재: {{branch}})", "search_label": "브랜치를 필터링하거나 새 브랜치 이름 입력", - "search_placeholder": "브랜치를 전환하거나 이름을 입력해 새로 만들기…", + "search_placeholder": "브랜치를 찾거나 새 이름 입력…", + "safe_switch_hint": "전환하면 이 프로젝트 폴더의 파일이 해당 브랜치의 버전으로 바뀝니다. 커밋하지 않은 변경 사항은 유지됩니다. 전환하면 그중 하나라도 덮어쓰게 되는 경우 아무것도 바뀌지 않습니다.", "branches": "브랜치", "local_branches": "로컬", "remote_branches": "원격", + "remote_row_action": "{{name}}(으)로 전환 (로컬 사본을 만들며, 커밋하지 않은 변경 사항은 유지됩니다)", + "ahead_count_other": "원격 브랜치보다 커밋 {{count}}개 앞섬", + "behind_count_other": "원격 브랜치보다 커밋 {{count}}개 뒤처짐", "loading": "브랜치를 불러오는 중…", "load_failed": "브랜치 목록을 불러오지 못했습니다.", "no_matches": "일치하는 브랜치가 없습니다", "invalid_new_name": "유효한 브랜치 이름이 아닙니다", - "create_branch": "‘{{name}}’ 브랜치 만들기", + "create_branch": "새 브랜치 ‘{{name}}’", + "create_branch_from": "{{base}}에서 새 브랜치 ‘{{name}}’", + "create_shortcut_mac": "⌘ Enter를 누르거나 클릭해 만들기", + "create_shortcut_other": "Ctrl+Enter를 누르거나 클릭해 만들기", + "ahead_behind_legend": "↑ 앞섬 · ↓ 뒤처짐 (원격 브랜치 기준)", "detached": "{{sha}}에서 분리됨", "current_branch": "현재 브랜치", "truncated": "최근 {{limit}}개만 표시됩니다", "not_switchable": "이 이름에 비밀 정보가 포함된 것으로 보여 숨겨져 있으며, 여기서는 이 브랜치로 전환할 수 없습니다.", - "error_generic": "브랜치를 전환하지 못했습니다", - "error_dirty": "커밋하지 않은 변경 사항이 덮어쓰여집니다. 먼저 커밋하거나 stash한 다음 전환하세요.", + "error_switch_to": "{{branch}}(으)로 전환하지 못했습니다", + "error_create": "브랜치 {{branch}}을(를) 만들지 못했습니다", + "error_dirty": "전환하지 않음: 이 브랜치로 전환하면 커밋하지 않은 변경 사항 일부를 덮어쓰게 됩니다. 변경 사항을 커밋하거나, 이 채팅에서 에이전트에게 커밋 또는 stash를 요청한 뒤 다시 전환하세요. (터미널에서: git commit 또는 git stash)", "error_in_worktree": "해당 브랜치는 이미 다른 워크트리에서 체크아웃되어 있습니다.", "error_exists": "같은 이름의 브랜치가 이미 있습니다.", "error_in_progress": "병합, 리베이스 또는 체리픽이 진행 중입니다. 먼저 완료하거나 중단하세요.", + "error_session_busy": "전환하지 않았습니다: 이 저장소에서 작업 중인 다른 세션이 아직 실행 중입니다. 끝날 때까지 기다린 후 전환하세요.", "error_not_found": "해당 브랜치가 더 이상 없습니다.", "error_invalid": "유효한 브랜치 이름이 아닙니다.", "error_sandbox": "전환이 꺼져 있습니다: 이 호스트에서는 Git을 샌드박스에서 실행할 수 없습니다. 터미널에서 전환하세요.", "error_timeout": "Git이 브랜치를 전환하는 데 너무 오래 걸렸습니다.", + "switched_to": "{{branch}}(으)로 전환했습니다", "outside_project": "전환이 꺼져 있습니다: 저장소 루트가 이 세션의 프로젝트 폴더 밖에 있습니다.", - "switch_blocked_filter": "전환이 꺼져 있습니다: 이 저장소의 Git 설정에 체크아웃 시 실행될 콘텐츠 필터가 선언되어 있습니다. 터미널에서 전환하세요.", - "stop_response_to_switch": "브랜치를 전환하려면 현재 응답을 중지하세요" + "switch_blocked_filter": "이 저장소에서는 브랜치 전환과 생성이 꺼져 있습니다. Git 설정이 브랜치를 전환할 때마다 Git이 다른 프로그램을 실행하게 하며, 이 패널은 그 프로그램을 실행하지 않습니다. 터미널에서 전환하세요.", + "copy_branch_name": "브랜치 이름 {{branch}} 복사", + "copied_branch_name": "브랜치 이름 {{branch}}을(를) 복사했습니다", + "copy_commit": "커밋 {{sha}} 복사", + "copied_commit": "커밋 {{sha}}을(를) 복사했습니다", + "copy_failed": "‘{{name}}’을(를) 클립보드에 복사하지 못했습니다.", + "copy_branch_while_running": "브랜치 이름을 복사합니다. 브랜치를 전환하려면 먼저 전송 옆의 ■ 버튼으로 응답을 중지하세요.", + "copy_commit_while_running": "커밋을 복사합니다. 브랜치를 전환하려면 먼저 전송 옆의 ■ 버튼으로 응답을 중지하세요.", + "copied_while_running": "복사했습니다. 브랜치를 전환하려면 먼저 전송 옆의 ■ 버튼으로 응답을 중지하세요.", + "row_switch_hint": "커밋하지 않은 변경 사항은 유지됩니다.", + "dirty_files": "해당 파일: {{files}}.", + "copy_retry": "다시 시도" }, "workspaceTree": { "status_failed": "리포지토리 상태를 읽을 수 없습니다.", diff --git a/website/src/i18n/locales/pt.json b/website/src/i18n/locales/pt.json index 28e0130d47a..be5096fd056 100644 --- a/website/src/i18n/locales/pt.json +++ b/website/src/i18n/locales/pt.json @@ -9484,31 +9484,56 @@ "switch_branch": "Trocar de branch", "switch_branch_current": "Trocar de branch (atual: {{branch}})", "search_label": "Filtre branches ou dê nome a um novo", - "search_placeholder": "Troque de branch ou digite para criar um…", + "search_placeholder": "Encontre um branch ou dê nome a um novo…", + "safe_switch_hint": "Trocar de branch atualiza os arquivos desta pasta do projeto para a versão desse branch. Suas alterações sem commit são mantidas. Se uma troca sobrescrever alguma delas, nada muda.", "branches": "Branches", "local_branches": "Locais", "remote_branches": "Remotos", + "remote_row_action": "Trocar para {{name}} (cria uma cópia local; alterações sem commit são mantidas)", + "ahead_count_one": "{{count}} commit à frente do seu branch remoto", + "ahead_count_many": "{{count}} commits à frente do seu branch remoto", + "ahead_count_other": "{{count}} commits à frente do seu branch remoto", + "behind_count_one": "{{count}} commit atrás do seu branch remoto", + "behind_count_many": "{{count}} commits atrás do seu branch remoto", + "behind_count_other": "{{count}} commits atrás do seu branch remoto", "loading": "Carregando branches…", "load_failed": "Não foi possível carregar a lista de branches.", "no_matches": "Nenhum branch corresponde", "invalid_new_name": "Não é um nome de branch válido", - "create_branch": "Criar o branch “{{name}}”", + "create_branch": "Novo branch “{{name}}”", + "create_branch_from": "Novo branch “{{name}}” a partir de {{base}}", + "create_shortcut_mac": "Pressione ⌘ Enter ou clique para criar", + "create_shortcut_other": "Pressione Ctrl+Enter ou clique para criar", + "ahead_behind_legend": "↑ à frente · ↓ atrás do branch remoto", "detached": "Desanexado em {{sha}}", "current_branch": "Branch atual", "truncated": "Apenas os {{limit}} mais recentes são listados", "not_switchable": "Este nome parece conter um segredo, então fica oculto e não é possível trocar para ele daqui.", - "error_generic": "Não foi possível trocar de branch", - "error_dirty": "Suas alterações não commitadas seriam sobrescritas. Faça commit ou stash delas e depois troque.", + "error_switch_to": "Não foi possível trocar para {{branch}}", + "error_create": "Não foi possível criar o branch {{branch}}", + "error_dirty": "Não trocado: este branch sobrescreveria algumas das suas alterações sem commit. Faça commit delas, ou peça ao agente neste chat para fazer commit ou guardá-las com stash, e depois troque de novo. (Em um terminal: git commit ou git stash.)", "error_in_worktree": "Esse branch já está em checkout em outro worktree.", "error_exists": "Já existe um branch com esse nome.", "error_in_progress": "Há um merge, rebase ou cherry-pick em andamento. Conclua ou cancele primeiro.", + "error_session_busy": "Não trocou: outra sessão que trabalha neste repositório ainda está em execução. Aguarde ela terminar e depois troque.", "error_not_found": "Esse branch não existe mais.", "error_invalid": "Esse não é um nome de branch válido.", "error_sandbox": "A troca está desativada: este host não consegue executar o Git em uma sandbox. Troque pelo terminal.", "error_timeout": "O Git demorou demais para trocar de branch.", + "switched_to": "Trocado para {{branch}}", "outside_project": "A troca está desativada: a raiz do repositório está fora da pasta do projeto desta sessão.", - "switch_blocked_filter": "A troca está desativada: a configuração do Git deste repositório declara um filtro de conteúdo que seria executado no checkout. Troque pelo terminal.", - "stop_response_to_switch": "Pare a resposta atual para trocar de branch" + "switch_blocked_filter": "Trocar e criar branches está desativado neste repositório. As configurações do Git dele pedem que o Git execute outro programa sempre que troca de branch, e este painel não o executa. Troque de branch em um terminal.", + "copy_branch_name": "Copiar o nome do branch {{branch}}", + "copied_branch_name": "Nome do branch {{branch}} copiado", + "copy_commit": "Copiar o commit {{sha}}", + "copied_commit": "Commit {{sha}} copiado", + "copy_failed": "Não foi possível copiar “{{name}}” para a área de transferência.", + "copy_branch_while_running": "Copiar o nome do branch. Para trocar de branch, pare primeiro a resposta com ■ ao lado de Enviar.", + "copy_commit_while_running": "Copiar o commit. Para trocar de branch, pare primeiro a resposta com ■ ao lado de Enviar.", + "copied_while_running": "Copiado. Para trocar de branch, pare primeiro a resposta com ■ ao lado de Enviar.", + "row_switch_hint": "Suas alterações sem commit são mantidas.", + "dirty_files": "Arquivos afetados: {{files}}.", + "copy_retry": "Tentar de novo" }, "workspaceTree": { "status_failed": "Não foi possível ler o estado do repositório.", diff --git a/website/src/i18n/locales/ru.json b/website/src/i18n/locales/ru.json index 1d043bd5239..e01830adbcc 100644 --- a/website/src/i18n/locales/ru.json +++ b/website/src/i18n/locales/ru.json @@ -9623,31 +9623,58 @@ "switch_branch": "Сменить ветку", "switch_branch_current": "Сменить ветку (текущая: {{branch}})", "search_label": "Отфильтруй ветки или введи имя новой", - "search_placeholder": "Смени ветку или введи имя, чтобы создать…", + "search_placeholder": "Найди ветку или назови новую…", + "safe_switch_hint": "Переключение обновляет файлы в этой папке проекта до версии выбранной ветки. Твои незакоммиченные изменения сохраняются. Если переключение перезаписало бы какое-то из них, ничего не меняется.", "branches": "Ветки", "local_branches": "Локальные", "remote_branches": "Удалённые", + "remote_row_action": "Переключиться на {{name}} (создаётся локальная копия, незакоммиченные изменения сохраняются)", + "ahead_count_one": "На {{count}} коммит впереди своей удалённой ветки", + "ahead_count_few": "На {{count}} коммита впереди своей удалённой ветки", + "ahead_count_many": "На {{count}} коммитов впереди своей удалённой ветки", + "ahead_count_other": "На {{count}} коммита впереди своей удалённой ветки", + "behind_count_one": "На {{count}} коммит позади своей удалённой ветки", + "behind_count_few": "На {{count}} коммита позади своей удалённой ветки", + "behind_count_many": "На {{count}} коммитов позади своей удалённой ветки", + "behind_count_other": "На {{count}} коммита позади своей удалённой ветки", "loading": "Загрузка веток…", "load_failed": "Не удалось загрузить список веток.", "no_matches": "Подходящих веток нет", "invalid_new_name": "Недопустимое имя ветки", - "create_branch": "Создать ветку «{{name}}»", + "create_branch": "Новая ветка «{{name}}»", + "create_branch_from": "Новая ветка «{{name}}» от {{base}}", + "create_shortcut_mac": "Нажми ⌘ Enter или щёлкни, чтобы создать", + "create_shortcut_other": "Нажми Ctrl+Enter или щёлкни, чтобы создать", + "ahead_behind_legend": "↑ впереди · ↓ позади удалённой ветки", "detached": "Отсоединено на {{sha}}", "current_branch": "Текущая ветка", "truncated": "Показаны только последние: {{limit}}", "not_switchable": "Похоже, это имя содержит секрет, поэтому оно скрыто и переключиться на эту ветку отсюда нельзя.", - "error_generic": "Не удалось сменить ветку", - "error_dirty": "Твои незакоммиченные изменения будут перезаписаны. Сначала закоммить их или убери в stash, затем переключись.", + "error_switch_to": "Не удалось переключиться на {{branch}}", + "error_create": "Не удалось создать ветку {{branch}}", + "error_dirty": "Не переключено: эта ветка перезаписала бы часть твоих незакоммиченных изменений. Закоммить их или попроси агента в этом чате закоммитить их или убрать в stash, затем переключись снова. (В терминале: git commit или git stash.)", "error_in_worktree": "Эта ветка уже извлечена в другом рабочем дереве (worktree).", "error_exists": "Ветка с таким именем уже существует.", "error_in_progress": "Идёт слияние, перебазирование или cherry-pick. Сначала заверши или отмени его.", + "error_session_busy": "Не переключено: другой сеанс, работающий в этом репозитории, ещё выполняется. Дождись его завершения, затем переключись.", "error_not_found": "Этой ветки больше нет.", "error_invalid": "Это недопустимое имя ветки.", "error_sandbox": "Переключение отключено: на этом хосте Git нельзя запустить в песочнице. Переключись в терминале.", "error_timeout": "Git слишком долго переключал ветку.", + "switched_to": "Переключено на {{branch}}", "outside_project": "Переключение отключено: корень репозитория находится вне папки проекта этого сеанса.", - "switch_blocked_filter": "Переключение отключено: конфигурация Git этого репозитория объявляет фильтр содержимого, который запустится при checkout. Переключись в терминале.", - "stop_response_to_switch": "Останови текущий ответ, чтобы сменить ветку" + "switch_blocked_filter": "Переключение и создание веток для этого репозитория отключены. Его настройки Git заставляют Git запускать другую программу при каждом переключении ветки, а эта панель её не запускает. Переключись в терминале.", + "copy_branch_name": "Скопировать имя ветки {{branch}}", + "copied_branch_name": "Имя ветки {{branch}} скопировано", + "copy_commit": "Скопировать коммит {{sha}}", + "copied_commit": "Коммит {{sha}} скопирован", + "copy_failed": "Не удалось скопировать «{{name}}» в буфер обмена.", + "copy_branch_while_running": "Скопировать имя ветки. Чтобы сменить ветку, сначала останови ответ кнопкой ■ рядом с «Отправить».", + "copy_commit_while_running": "Скопировать коммит. Чтобы сменить ветку, сначала останови ответ кнопкой ■ рядом с «Отправить».", + "copied_while_running": "Скопировано. Чтобы сменить ветку, сначала останови ответ кнопкой ■ рядом с «Отправить».", + "row_switch_hint": "Твои незакоммиченные изменения сохраняются.", + "dirty_files": "Мешающие файлы: {{files}}.", + "copy_retry": "Повторить" }, "workspaceTree": { "status_failed": "Не удалось прочитать состояние репозитория.", diff --git a/website/src/i18n/locales/zh-CN.json b/website/src/i18n/locales/zh-CN.json index 4cafd9c3a00..ef66aa699e3 100644 --- a/website/src/i18n/locales/zh-CN.json +++ b/website/src/i18n/locales/zh-CN.json @@ -9206,31 +9206,52 @@ "switch_branch": "切换分支", "switch_branch_current": "切换分支(当前:{{branch}})", "search_label": "筛选分支或输入新分支名", - "search_placeholder": "切换分支,或输入名称以创建分支…", + "search_placeholder": "查找分支或为新分支命名…", + "safe_switch_hint": "切换会把此项目文件夹中的文件更新为该分支的版本。你未提交的修改会保留。如果切换会覆盖其中任何一项,什么都不会改变。", "branches": "分支", "local_branches": "本地", "remote_branches": "远程", + "remote_row_action": "切换到 {{name}}(会创建本地副本,未提交的修改会保留)", + "ahead_count_other": "领先远程分支 {{count}} 个提交", + "behind_count_other": "落后远程分支 {{count}} 个提交", "loading": "正在加载分支…", "load_failed": "无法加载分支列表。", "no_matches": "没有匹配的分支", "invalid_new_name": "不是有效的分支名", - "create_branch": "创建分支“{{name}}”", + "create_branch": "新分支“{{name}}”", + "create_branch_from": "从 {{base}} 新建分支“{{name}}”", + "create_shortcut_mac": "按 ⌘ Enter 或点击即可创建", + "create_shortcut_other": "按 Ctrl+Enter 或点击即可创建", + "ahead_behind_legend": "↑ 领先 · ↓ 落后于远程分支", "detached": "分离于 {{sha}}", "current_branch": "当前分支", "truncated": "仅列出最近的 {{limit}} 个", "not_switchable": "此名称看起来包含密钥,因此已隐藏,无法在此切换到它。", - "error_generic": "无法切换分支", - "error_dirty": "你未提交的更改会被覆盖。请先提交或储藏(stash)这些更改,再切换。", + "error_switch_to": "无法切换到 {{branch}}", + "error_create": "无法创建分支 {{branch}}", + "error_dirty": "未切换:这个分支会覆盖你部分未提交的修改。请先提交它们,或在这个聊天中让智能体帮你提交或 stash,然后再切换。(在终端中:git commit 或 git stash)", "error_in_worktree": "该分支已在另一个工作树中检出。", "error_exists": "已存在同名分支。", "error_in_progress": "有合并、变基或拣选(cherry-pick)正在进行。请先完成或中止它。", + "error_session_busy": "未切换:另一个在此仓库中工作的会话仍在运行。请等它结束后再切换。", "error_not_found": "该分支已不存在。", "error_invalid": "这不是有效的分支名。", "error_sandbox": "切换已关闭:此主机无法在沙盒中运行 Git。请在终端中切换。", "error_timeout": "Git 切换分支耗时过长。", + "switched_to": "已切换到 {{branch}}", "outside_project": "切换已关闭:仓库根目录不在此会话的项目文件夹内。", - "switch_blocked_filter": "切换已关闭:此仓库的 Git 配置声明了一个会在检出时运行的内容过滤器。请在终端中切换。", - "stop_response_to_switch": "停止当前回复以切换分支" + "switch_blocked_filter": "此仓库已关闭分支的切换和创建。它的 Git 设置会让 Git 在每次切换分支时运行另一个程序,而此面板不会运行它。请在终端中切换。", + "copy_branch_name": "复制分支名称 {{branch}}", + "copied_branch_name": "已复制分支名称 {{branch}}", + "copy_commit": "复制提交 {{sha}}", + "copied_commit": "已复制提交 {{sha}}", + "copy_failed": "无法将“{{name}}”复制到剪贴板。", + "copy_branch_while_running": "复制分支名称。要切换分支,请先用“发送”旁的 ■ 停止回复。", + "copy_commit_while_running": "复制提交。要切换分支,请先用“发送”旁的 ■ 停止回复。", + "copied_while_running": "已复制。要切换分支,请先用“发送”旁的 ■ 停止回复。", + "row_switch_hint": "你未提交的修改会保留。", + "dirty_files": "受影响的文件:{{files}}。", + "copy_retry": "重试" }, "workspaceTree": { "status_failed": "无法读取仓库状态。", diff --git a/website/src/i18n/pluralKeys.json b/website/src/i18n/pluralKeys.json index 34f6d831daa..97c8d8eb8dc 100644 --- a/website/src/i18n/pluralKeys.json +++ b/website/src/i18n/pluralKeys.json @@ -289,5 +289,7 @@ "pages.settings.chatPanel.follow_default_confirm_title", "pages.settings.chatPanel.follow_default_done", "pages.settings.chatPanel.follow_default_model_chats", - "pages.settings.chatPanel.follow_default_more" + "pages.settings.chatPanel.follow_default_more", + "components.branchSwitcher.ahead_count", + "components.branchSwitcher.behind_count" ] diff --git a/website/src/test/BranchSwitcher.test.tsx b/website/src/test/BranchSwitcher.test.tsx index d552eff3bb6..49050165ab2 100644 --- a/website/src/test/BranchSwitcher.test.tsx +++ b/website/src/test/BranchSwitcher.test.tsx @@ -1,4 +1,4 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { fireEvent, render, screen, waitFor } from '@testing-library/react' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { i18next, initI18n } from '../i18n/all' @@ -13,14 +13,14 @@ const H = vi.hoisted(() => ({ vi.mock('../api/client', () => ({ api: H.api })) -import BranchSwitcher, { isValidNewBranchName } from '../components/BranchSwitcher' +import BranchSwitcher, { isValidNewBranchName, notePlacement } from '../components/BranchSwitcher' +import { LocalOverlayRoot } from '../components/overlayRoot' const PROJECT = '/workspace/project' function row(name: string, extra: Partial = {}): GitBranchRow { return { name, - sha: 'abc1234', date: '2026-10-01T00:00:00Z', author: 'Ada', subject: `work on ${name}`, @@ -36,12 +36,12 @@ const LIST: GitBranchList = { remote: [row('origin/review-fix')], } -function mount() { +function mount(props: { switchBlocked?: boolean; detached?: boolean; branch?: string } = {}) { const qc = new QueryClient({ defaultOptions: { queries: { retry: false, retryDelay: 0 } } }) const invalidate = vi.spyOn(qc, 'invalidateQueries') render( - + , ) return { invalidate } @@ -60,7 +60,7 @@ beforeEach(async () => { await initI18n() await i18next.changeLanguage('en') H.api.projectGitBranches.mockReset().mockResolvedValue(LIST) - H.api.projectGitSwitch.mockReset().mockResolvedValue({ ok: true, branch: 'feature/login', previous: 'main' }) + H.api.projectGitSwitch.mockReset().mockResolvedValue({ ok: true, branch: 'feature/login' }) }) describe('BranchSwitcher', () => { @@ -85,7 +85,42 @@ describe('BranchSwitcher', () => { await openPicker() expect(H.api.projectGitBranches).toHaveBeenCalledWith(PROJECT) expect(screen.getByText('Local')).toBeInTheDocument() - expect(screen.getByText('Remote')).toBeInTheDocument() + // The count shows the section has rows even when they sit below the scroll edge. + expect(screen.getByTestId('branch-remote-heading')).toHaveTextContent('Remote (1)') + }) + + it('says on a remote row, outcome first, that a click switches and keeps edits', async () => { + mount() + await openPicker() + const opt = screen.getByText('review-fix').closest('[role="option"]')! + // The bare `origin/` prefix is dropped: the Remote heading already says where it is from. + expect(opt.querySelector('[data-testid="branch-row-name"]')).toHaveTextContent(/^review-fix$/) + const action = opt.querySelector('[data-testid="branch-remote-action"]') + expect(action).toHaveTextContent('Switch to review-fix (makes a local copy; uncommitted changes are kept)') + expect(opt).toHaveAttribute('title', "Switch to review-fix (makes a local copy; uncommitted changes are kept)\nYour uncommitted changes are kept.") + // Local rows are a plain switch and carry no such line. + const local = screen.getByText('feature/login').closest('[role="option"]')! + expect(local.querySelector('[data-testid="branch-remote-action"]')).toBeNull() + }) + + it('gives an unswitchable remote row no action line', async () => { + H.api.projectGitBranches.mockResolvedValue({ ...LIST, remote: [row('origin/feat+1', { switchable: false })] }) + mount() + await openPicker() + const opt = screen.getByText('feat+1').closest('[role="option"]')! + expect(opt.querySelector('[data-testid="branch-remote-action"]')).toBeNull() + }) + + it('keeps the remote prefix when two remotes carry the same branch', async () => { + H.api.projectGitBranches.mockResolvedValue({ + ...LIST, + remote: [row('origin/review-fix'), row('upstream/review-fix'), row('origin/solo')], + }) + mount() + await openPicker() + const names = screen.getAllByTestId('branch-row-name').map(n => n.textContent) + expect(names).toEqual(expect.arrayContaining(['origin/review-fix', 'upstream/review-fix', 'solo'])) + expect(names).not.toContain('review-fix') }) it('switches to a local branch and refreshes every working-tree view', async () => { @@ -103,20 +138,73 @@ describe('BranchSwitcher', () => { it('checks out a remote branch as a tracking branch with its short name', async () => { mount() await openPicker() - fireEvent.mouseDown(screen.getByText('origin/review-fix')) + fireEvent.mouseDown(screen.getByText('review-fix')) await waitFor(() => expect(H.api.projectGitSwitch).toHaveBeenCalled()) expect(H.api.projectGitSwitch).toHaveBeenCalledWith({ path: PROJECT, branch: 'review-fix', track: 'origin/review-fix' }) }) - it('offers to create a typed name that does not exist, and Enter picks the only match', async () => { + it('offers to create a typed name that does not exist, but only a deliberate key creates it', async () => { mount() await openPicker() fireEvent.change(screen.getByRole('combobox'), { target: { value: 'feat/new-thing' } }) - expect(screen.getByTestId('branch-create')).toHaveTextContent('Create branch “feat/new-thing”') + // The row says what the new branch starts from: the checked-out branch. + expect(screen.getByTestId('branch-create')).toHaveTextContent('New branch “feat/new-thing” from main') + // Highlighted (it is the only row), it names the key that creates it (jsdom is not a Mac). + expect(screen.getByTestId('branch-create-shortcut')).toHaveTextContent('Press Ctrl+Enter or click to create it') + // A plain Enter (or Tab) after a search must not turn a typo into a branch. fireEvent.keyDown(screen.getByRole('combobox'), { key: 'Enter' }) + fireEvent.keyDown(screen.getByRole('combobox'), { key: 'Tab' }) + await new Promise(r => setTimeout(r, 0)) + expect(H.api.projectGitSwitch).not.toHaveBeenCalled() + fireEvent.keyDown(screen.getByRole('combobox'), { key: 'Enter', ctrlKey: true }) await waitFor(() => expect(H.api.projectGitSwitch).toHaveBeenCalledWith({ path: PROJECT, branch: 'feat/new-thing', create: true })) }) + it('creates on ⌘+Enter too', async () => { + mount() + await openPicker() + fireEvent.change(screen.getByRole('combobox'), { target: { value: 'feat/new-thing' } }) + fireEvent.keyDown(screen.getByRole('combobox'), { key: 'Enter', metaKey: true }) + await waitFor(() => expect(H.api.projectGitSwitch).toHaveBeenCalledWith({ path: PROJECT, branch: 'feat/new-thing', create: true })) + }) + + it('creates on a click of the create row', async () => { + mount() + await openPicker() + fireEvent.change(screen.getByRole('combobox'), { target: { value: 'feat/new-thing' } }) + fireEvent.mouseDown(screen.getByTestId('branch-create')) + await waitFor(() => expect(H.api.projectGitSwitch).toHaveBeenCalledWith({ path: PROJECT, branch: 'feat/new-thing', create: true })) + }) + + it('starts the highlight on a matching branch, not the create row', async () => { + mount() + await openPicker() + // "feat" matches feature/login and is also a valid new name. + fireEvent.change(screen.getByRole('combobox'), { target: { value: 'feat' } }) + expect(screen.getByTestId('branch-create')).toBeInTheDocument() + const selected = screen.getAllByRole('option').find(el => el.getAttribute('aria-selected') === 'true') + expect(selected).not.toBe(screen.getByTestId('branch-create')) + }) + + it('shows the create key hint only while the create row is highlighted', async () => { + mount() + await openPicker() + // "feat" matches feature/login, so the highlight starts there. + fireEvent.change(screen.getByRole('combobox'), { target: { value: 'feat' } }) + expect(screen.getByTestId('branch-create')).toHaveTextContent('New branch “feat” from main') + expect(screen.queryByTestId('branch-create-shortcut')).toBeNull() + fireEvent.mouseEnter(screen.getByTestId('branch-create')) + expect(screen.getByTestId('branch-create-shortcut')).toHaveTextContent('Press Ctrl+Enter or click to create it') + }) + + it('names the commit a new branch starts from when none is checked out', async () => { + H.api.projectGitBranches.mockResolvedValue({ ...LIST, current: null, detached: true, head: 'abc1234' }) + mount() + await openPicker() + fireEvent.change(screen.getByRole('combobox'), { target: { value: 'feat/new-thing' } }) + expect(screen.getByTestId('branch-create')).toHaveTextContent('New branch “feat/new-thing” from abc1234') + }) + it('does not offer creation for an invalid name', async () => { mount() await openPicker() @@ -138,10 +226,116 @@ describe('BranchSwitcher', () => { mount() await openPicker() fireEvent.mouseDown(screen.getByText('feature/login')) - expect(await screen.findByTestId('branch-switcher-error')).toHaveTextContent( - 'Your uncommitted changes would be overwritten. Commit or stash them, then switch.', - ) + const notice = await screen.findByTestId('branch-switcher-error') + // Names the branch the switch targeted, so the reader can tell it failed. + expect(notice).toHaveTextContent("Couldn't switch to feature/login") + expect(notice).toHaveTextContent("Not switched: this branch would overwrite some of your uncommitted changes. Commit them, or ask the agent in this chat to commit or stash them, then switch again. (In a terminal: git commit or git stash.)") expect(screen.getByTestId('branch-switcher')).toBeInTheDocument() + // The "never overwritten" line would contradict the refusal beside it. + expect(screen.queryByTestId('branch-switcher-safety')).toBeNull() + }) + + it('names the files that block a dirty-tree switch', async () => { + H.api.projectGitSwitch.mockRejectedValue(apiError('git_switch_dirty', { files: ['README.md', 'src/app.py'], files_more: 2 })) + mount() + await openPicker() + fireEvent.mouseDown(screen.getByText('feature/login')) + const notice = await screen.findByTestId('branch-switcher-error') + expect(notice).toHaveTextContent('then switch again. (In a terminal: git commit or git stash.) Files in the way: README.md, src/app.py +2.') + }) + + it('leaves the dirty-tree refusal unchanged when git names no files', async () => { + H.api.projectGitSwitch.mockRejectedValue(apiError('git_switch_dirty', { files: [] })) + mount() + await openPicker() + fireEvent.mouseDown(screen.getByText('feature/login')) + const notice = await screen.findByTestId('branch-switcher-error') + expect(notice).not.toHaveTextContent('Files in the way') + }) + + it('states the safety promise above the list, on the search field and on each row it applies to', async () => { + mount() + await openPicker() + const safety = screen.getByTestId('branch-switcher-safety') + expect(safety).toHaveTextContent("Switching updates the files in this project folder to that branch's version. Your uncommitted changes are kept. If a switch would overwrite any of them, nothing changes.") + // The search field owns keyboard focus while arrows move the highlight, so it carries the description. + expect(screen.getByRole('combobox')).toHaveAttribute('aria-describedby', safety.id) + const row = screen.getByText('feature/login').closest('[role="option"]')! + expect(row).toHaveAttribute('title', "feature/login\nYour uncommitted changes are kept.") + // The current branch is not a switch, so it does not repeat the promise. + const current = screen.getAllByTestId('branch-row-name').find(n => n.textContent === 'main')!.closest('[role="option"]')! + expect(current).toHaveAttribute('title', 'main') + }) + + it('brings the safety line back once the refusal is cleared by typing', async () => { + H.api.projectGitSwitch.mockRejectedValue(apiError('git_switch_dirty')) + mount() + await openPicker() + fireEvent.mouseDown(screen.getByText('feature/login')) + await screen.findByTestId('branch-switcher-error') + fireEvent.change(screen.getByRole('combobox'), { target: { value: 'f' } }) + expect(screen.getByTestId('branch-switcher-safety')).toBeInTheDocument() + }) + + it('confirms a landed switch beside the trigger and announces it politely', async () => { + vi.useFakeTimers({ shouldAdvanceTime: true }) + try { + mount() + const status = screen.getByTestId('branch-switcher-status') + expect(status).toHaveAttribute('role', 'status') + expect(status).toHaveTextContent('') + await openPicker() + fireEvent.mouseDown(screen.getByText('feature/login')) + await waitFor(() => expect(screen.queryByTestId('branch-switcher')).toBeNull()) + const note = await screen.findByTestId('branch-switcher-note') + expect(note).toHaveAttribute('data-note', 'switched') + expect(note).toHaveTextContent('Switched to feature/login') + expect(status).toHaveTextContent('Switched to feature/login') + await vi.advanceTimersByTimeAsync(4100) + expect(screen.queryByTestId('branch-switcher-note')).toBeNull() + expect(status).toHaveTextContent('') + } finally { + vi.useRealTimers() + } + }) + + it('confirms nothing when the switch is refused', async () => { + H.api.projectGitSwitch.mockRejectedValue(apiError('git_switch_dirty')) + mount() + await openPicker() + fireEvent.mouseDown(screen.getByText('feature/login')) + await screen.findByTestId('branch-switcher-error') + expect(screen.queryByTestId('branch-switcher-note')).toBeNull() + expect(screen.getByTestId('branch-switcher-status')).toHaveTextContent('') + }) + + it('says another running session blocked the switch when the server refuses it', async () => { + H.api.projectGitSwitch.mockRejectedValue(apiError('git_switch_session_busy')) + mount() + await openPicker() + fireEvent.mouseDown(screen.getByText('feature/login')) + const notice = await screen.findByTestId('branch-switcher-error') + expect(notice).toHaveTextContent("Couldn't switch to feature/login") + expect(notice).toHaveTextContent('Not switched: another session working in this repository is still running. Wait for it to finish, then switch.') + }) + + it('names the local branch a remote row would create when the switch fails', async () => { + H.api.projectGitSwitch.mockRejectedValue(apiError('git_switch_dirty')) + mount() + await openPicker() + fireEvent.mouseDown(screen.getByText('review-fix')) + expect(await screen.findByTestId('branch-switcher-error')).toHaveTextContent("Couldn't switch to review-fix") + }) + + it('names the branch a failed create was for', async () => { + H.api.projectGitSwitch.mockRejectedValue(apiError('git_branch_exists')) + mount() + await openPicker() + fireEvent.change(screen.getByRole('combobox'), { target: { value: 'feat/new-thing' } }) + fireEvent.keyDown(screen.getByRole('combobox'), { key: 'Enter', ctrlKey: true }) + const notice = await screen.findByTestId('branch-switcher-error') + expect(notice).toHaveTextContent("Couldn't create branch feat/new-thing") + expect(notice).toHaveTextContent('A branch with that name already exists.') }) it('falls back to git’s own line for an uncoded failure', async () => { @@ -150,7 +344,7 @@ describe('BranchSwitcher', () => { await openPicker() fireEvent.mouseDown(screen.getByText('feature/login')) const notice = await screen.findByTestId('branch-switcher-error') - expect(notice).toHaveTextContent("Couldn't switch branches") + expect(notice).toHaveTextContent("Couldn't switch to feature/login") expect(notice).toHaveTextContent('fatal: something odd') }) @@ -158,11 +352,138 @@ describe('BranchSwitcher', () => { H.api.projectGitBranches.mockResolvedValue({ ...LIST, switchBlocked: 'filter' }) mount() await openPicker() - expect(screen.getByTestId('branch-switcher-blocked')).toHaveTextContent(/declares a content filter/) + // Leads with the outcome and its reason, then the next step. + expect(screen.getByTestId('branch-switcher-blocked')).toHaveTextContent( + "Switching and creating branches are off for this repository. Its Git settings ask Git to run another program whenever it switches branches, and this panel won't run it. Switch in a terminal instead.") fireEvent.mouseDown(screen.getByText('feature/login')) expect(H.api.projectGitSwitch).not.toHaveBeenCalled() }) + it('offers no create row while a filter driver blocks switching', async () => { + H.api.projectGitBranches.mockResolvedValue({ ...LIST, switchBlocked: 'filter' }) + mount() + await openPicker() + fireEvent.change(screen.getByRole('combobox'), { target: { value: 'feat/new-thing' } }) + expect(screen.queryByTestId('branch-create')).toBeNull() + }) + + it('mutes every row while a filter driver blocks switching', async () => { + H.api.projectGitBranches.mockResolvedValue({ ...LIST, switchBlocked: 'filter' }) + mount() + await openPicker() + fireEvent.mouseEnter(screen.getByText('feature/login').closest('[role="option"]')!) + for (const opt of screen.getAllByRole('option')) { + expect(opt).toHaveAttribute('aria-disabled', 'true') + expect(opt).toHaveAttribute('data-muted', 'true') + expect(opt.className).toContain('opacity-50') + // No pointer or keyboard highlight on a row that cannot be chosen. + expect(opt.className).not.toContain('bg-bg-hover') + } + }) + + it('keeps rows at full strength when switching is allowed', async () => { + mount() + await openPicker() + const row = screen.getByText('feature/login').closest('[role="option"]')! + fireEvent.mouseEnter(row) + expect(row).not.toHaveAttribute('data-muted') + expect(row.className).toContain('bg-bg-hover') + }) + + it('says above the list what a switch does, then that uncommitted changes stay and are never lost', async () => { + mount() + await openPicker() + const hint = screen.getByTestId('branch-switcher-safety') + expect(hint.textContent).toMatch(/^Switching updates the files in this project folder to that branch's version\. Your uncommitted changes are kept\./) + // Sits above the list, so it is read before any row. + expect(hint.compareDocumentPosition(screen.getByRole('listbox')) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy() + }) + + it('leaves the safety line out when a filter driver blocks switching', async () => { + H.api.projectGitBranches.mockResolvedValue({ ...LIST, switchBlocked: 'filter' }) + mount() + await openPicker() + expect(screen.queryByTestId('branch-switcher-safety')).toBeNull() + }) + + it('says in words what the ahead/behind arrows count', async () => { + H.api.projectGitBranches.mockResolvedValue({ + ...LIST, + local: [row('main', { current: true }), row('feature/login', { ahead: 2, behind: 1 }), row('solo', { ahead: 1 })], + }) + mount() + await openPicker() + const [both, one] = screen.getAllByTestId('branch-ahead-behind') + expect(both).toHaveAttribute('title', '2 commits ahead of its remote branch, 1 commit behind its remote branch') + expect(one).toHaveAttribute('title', '1 commit ahead of its remote branch') + // The arrows are hidden from assistive tech; the words are read instead. + expect(both.querySelector('[aria-hidden="true"]')).toHaveTextContent('↑2 ↓1') + expect(both.querySelector('.sr-only')).toHaveTextContent('2 commits ahead of its remote branch, 1 commit behind its remote branch') + }) + + it('leaves out the Remote heading when no remote branch is listed', async () => { + H.api.projectGitBranches.mockResolvedValue({ ...LIST, remote: [] }) + mount() + await openPicker() + expect(screen.getByText('Local')).toBeInTheDocument() + expect(screen.queryByTestId('branch-remote-heading')).toBeNull() + }) + + it('says once in the footer what the arrow pills count', async () => { + mount() + await openPicker() + expect(screen.getByTestId('branch-ahead-behind-legend')).toHaveTextContent('↑ ahead · ↓ behind its remote branch') + }) + + it('leaves the arrow legend out when no row has a pill', async () => { + H.api.projectGitBranches.mockResolvedValue({ ...LIST, local: [row('main', { current: true }), row('feature/login')] }) + mount() + await openPicker() + expect(screen.queryByTestId('branch-ahead-behind-legend')).toBeNull() + }) + + it('disables the search field while a filter driver blocks switching', async () => { + H.api.projectGitBranches.mockResolvedValue({ ...LIST, switchBlocked: 'filter' }) + mount() + await openPicker() + await screen.findByTestId('branch-switcher-blocked') + expect(screen.getByRole('combobox')).toBeDisabled() + // ...and looks it: a disabled field drawn like an active one reads as broken. + expect(screen.getByRole('combobox').className).toMatch(/disabled:opacity-50/) + }) + + it('keeps the search field usable when switching is allowed', async () => { + mount() + await openPicker() + expect(screen.getByRole('combobox')).toBeEnabled() + }) + + it('fades the list edge while rows sit below it, and drops the fade at the end', async () => { + // jsdom lays nothing out, so give the list a scroll geometry by hand. + const geometry = { scrollHeight: 400, clientHeight: 200, scrollTop: 0 } + const spies = (['scrollHeight', 'clientHeight', 'scrollTop'] as const).map(prop => + vi.spyOn(HTMLElement.prototype, prop, 'get').mockImplementation(function (this: HTMLElement) { + return this.id === 'branch-switcher-list' ? geometry[prop] : 0 + })) + try { + mount() + await openPicker() + await waitFor(() => expect(screen.getByTestId('branch-switcher-more-below')).toBeInTheDocument()) + expect(screen.getByTestId('branch-switcher-more-below')).toHaveAttribute('aria-hidden', 'true') + geometry.scrollTop = 200 + fireEvent.scroll(document.getElementById('branch-switcher-list')!) + expect(screen.queryByTestId('branch-switcher-more-below')).toBeNull() + } finally { + spies.forEach(s => s.mockRestore()) + } + }) + + it('draws no fade when every row fits', async () => { + mount() + await openPicker() + expect(screen.queryByTestId('branch-switcher-more-below')).toBeNull() + }) + it('closes on Escape', async () => { mount() await openPicker() @@ -171,6 +492,174 @@ describe('BranchSwitcher', () => { }) }) +describe('BranchSwitcher while switching is blocked', () => { + let originalClipboard: PropertyDescriptor | undefined + const stubClipboard = () => { + originalClipboard = Object.getOwnPropertyDescriptor(navigator, 'clipboard') + const writeText = vi.fn().mockResolvedValue(undefined) + Object.defineProperty(navigator, 'clipboard', { value: { writeText }, configurable: true }) + return writeText + } + afterEach(() => { + if (originalClipboard) Object.defineProperty(navigator, 'clipboard', originalClipboard) + else delete (navigator as { clipboard?: unknown }).clipboard + originalClipboard = undefined + }) + + it('copies the branch name instead of opening the picker', async () => { + const writeText = stubClipboard() + mount({ switchBlocked: true }) + const trigger = screen.getByTestId('branch-switcher-trigger') + expect(trigger).toBeEnabled() + expect(trigger).toHaveAttribute('data-mode', 'copy') + expect(trigger).toHaveAccessibleName('Copy branch name main') + expect(trigger).toHaveAccessibleDescription('Copy branch name. To switch branch, first stop the response with ■ next to Send.') + // The reason is drawn visibly, not left to a delayed native tooltip. + expect(trigger).not.toHaveAttribute('title') + expect(trigger).not.toHaveAttribute('aria-haspopup') + fireEvent.click(trigger) + await waitFor(() => expect(writeText).toHaveBeenCalledWith('main')) + await waitFor(() => expect(trigger).toHaveAccessibleName('Copied branch name main')) + // Touch has no hover, so the copy itself shows how to switch. + expect(screen.getByTestId('branch-switcher-note')).toHaveTextContent('Copied. To switch branch, first stop the response with ■ next to Send.') + expect(screen.queryByTestId('branch-switcher')).toBeNull() + expect(H.api.projectGitBranches).not.toHaveBeenCalled() + }) + + it('calls a detached HEAD a commit', async () => { + const writeText = stubClipboard() + mount({ switchBlocked: true, detached: true, branch: 'a1b2c3d' }) + const trigger = screen.getByTestId('branch-switcher-trigger') + expect(trigger).toHaveAccessibleName('Copy commit a1b2c3d') + expect(trigger).toHaveAccessibleDescription('Copy commit. To switch branch, first stop the response with ■ next to Send.') + fireEvent.click(trigger) + await waitFor(() => expect(writeText).toHaveBeenCalledWith('a1b2c3d')) + }) + + it('shows the reason visibly on hover and on keyboard focus', () => { + mount({ switchBlocked: true }) + const trigger = screen.getByTestId('branch-switcher-trigger') + expect(screen.queryByTestId('branch-switcher-note')).toBeNull() + fireEvent.mouseEnter(trigger) + expect(screen.getByTestId('branch-switcher-note')).toHaveTextContent('Copy branch name. To switch branch, first stop the response with ■ next to Send.') + expect(screen.getByTestId('branch-switcher-note')).toHaveAttribute('data-note', 'busy') + fireEvent.mouseLeave(trigger) + expect(screen.queryByTestId('branch-switcher-note')).toBeNull() + fireEvent.focus(trigger) + expect(screen.getByTestId('branch-switcher-note')).toBeInTheDocument() + fireEvent.blur(trigger) + expect(screen.queryByTestId('branch-switcher-note')).toBeNull() + }) + + it('shows no busy note while switching is allowed', () => { + mount() + fireEvent.mouseEnter(screen.getByTestId('branch-switcher-trigger')) + expect(screen.queryByTestId('branch-switcher-note')).toBeNull() + }) + + it('looks busy on the trigger itself while switching is blocked', () => { + mount({ switchBlocked: true }) + const trigger = screen.getByTestId('branch-switcher-trigger') + // Visible without hovering: muted text and a copy cursor. + expect(trigger).toHaveClass('text-muted', 'cursor-copy') + expect(trigger).toHaveAttribute('data-busy', 'true') + // Static: it lasts the whole response, in peripheral vision. + expect(trigger.querySelector('.animate-spin')).toBeNull() + }) + + it('keeps the trigger at full strength while switching is allowed', () => { + mount() + const trigger = screen.getByTestId('branch-switcher-trigger') + expect(trigger).toHaveClass('text-text', 'cursor-pointer') + expect(trigger).not.toHaveClass('text-muted') + expect(trigger).not.toHaveAttribute('data-busy') + }) + + it('withholds the confirmation and shows an error notice when the copy fails', async () => { + originalClipboard = Object.getOwnPropertyDescriptor(navigator, 'clipboard') + const writeText = vi.fn().mockRejectedValue(new DOMException('denied', 'NotAllowedError')) + Object.defineProperty(navigator, 'clipboard', { value: { writeText }, configurable: true }) + const originalExecCommand = Object.getOwnPropertyDescriptor(document, 'execCommand') + const execCommand = vi.fn().mockReturnValue(false) + Object.defineProperty(document, 'execCommand', { value: execCommand, configurable: true }) + try { + mount({ switchBlocked: true }) + const trigger = screen.getByTestId('branch-switcher-trigger') + fireEvent.click(trigger) + await waitFor(() => expect(execCommand).toHaveBeenCalledWith('copy')) + expect(trigger).toHaveAccessibleName('Copy branch name main') + const notice = await screen.findByTestId('branch-switcher-copy-error') + expect(notice).toHaveAttribute('role', 'alert') + expect(notice).toHaveTextContent("Couldn't copy “main” to the clipboard.") + // No agent hand-off: it would move the chat away from the composer draft. + expect(notice.querySelector('a, button')).toBeNull() + expect(screen.getByTestId('branch-switcher-note')).toHaveAttribute('data-note', 'copy_failed') + // A next step: retrying the copy, which clears the notice once it lands. + writeText.mockResolvedValue(undefined) + fireEvent.click(screen.getByTestId('branch-switcher-copy-retry')) + await waitFor(() => expect(screen.queryByTestId('branch-switcher-copy-error')).toBeNull()) + expect(writeText).toHaveBeenLastCalledWith('main') + } finally { + if (originalExecCommand) Object.defineProperty(document, 'execCommand', originalExecCommand) + else delete (document as { execCommand?: unknown }).execCommand + } + }) + + it('clears the copy failure on its own', async () => { + originalClipboard = Object.getOwnPropertyDescriptor(navigator, 'clipboard') + Object.defineProperty(navigator, 'clipboard', { value: { writeText: vi.fn().mockRejectedValue(new Error('denied')) }, configurable: true }) + const originalExecCommand = Object.getOwnPropertyDescriptor(document, 'execCommand') + Object.defineProperty(document, 'execCommand', { value: vi.fn().mockReturnValue(false), configurable: true }) + vi.useFakeTimers({ shouldAdvanceTime: true }) + try { + mount({ switchBlocked: true }) + fireEvent.click(screen.getByTestId('branch-switcher-trigger')) + await screen.findByTestId('branch-switcher-copy-error') + await vi.advanceTimersByTimeAsync(8100) + expect(screen.queryByTestId('branch-switcher-copy-error')).toBeNull() + } finally { + vi.useRealTimers() + if (originalExecCommand) Object.defineProperty(document, 'execCommand', originalExecCommand) + else delete (document as { execCommand?: unknown }).execCommand + } + }) + + it('says inside the picker when the footer copy is refused, and clears it once a copy lands', async () => { + originalClipboard = Object.getOwnPropertyDescriptor(navigator, 'clipboard') + const writeText = vi.fn().mockRejectedValue(new DOMException('denied', 'NotAllowedError')) + Object.defineProperty(navigator, 'clipboard', { value: { writeText }, configurable: true }) + const originalExecCommand = Object.getOwnPropertyDescriptor(document, 'execCommand') + const execCommand = vi.fn().mockReturnValue(false) + Object.defineProperty(document, 'execCommand', { value: execCommand, configurable: true }) + try { + mount() + await openPicker() + const footerCopy = screen.getByRole('button', { name: /Copy branch name/ }) + fireEvent.click(footerCopy) + const notice = await screen.findByTestId('branch-switcher-footer-copy-failed') + expect(notice).toHaveAttribute('role', 'alert') + expect(notice).toHaveTextContent("Couldn't copy “main” to the clipboard.") + expect(notice.querySelector('a, button')).toBeNull() + writeText.mockResolvedValue(undefined) + fireEvent.click(screen.getByTestId('branch-switcher-footer-copy-retry')) + await waitFor(() => expect(screen.queryByTestId('branch-switcher-footer-copy-failed')).toBeNull()) + expect(writeText).toHaveBeenLastCalledWith('main') + } finally { + if (originalExecCommand) Object.defineProperty(document, 'execCommand', originalExecCommand) + else delete (document as { execCommand?: unknown }).execCommand + } + }) + + it('opens the picker again once switching is allowed', async () => { + mount() + const trigger = screen.getByTestId('branch-switcher-trigger') + expect(trigger).toHaveAttribute('data-mode', 'switch') + expect(trigger).toHaveAccessibleName('Switch branch (current: main)') + await openPicker() + expect(screen.getByTestId('branch-switcher')).toBeInTheDocument() + }) +}) + describe('isValidNewBranchName', () => { it.each(['main', 'feat/x', 'release-1.2', 'a_b'])('accepts %s', name => { expect(isValidNewBranchName(name)).toBe(true) @@ -190,3 +679,49 @@ describe('BranchSwitcher keyboard start', () => { await waitFor(() => expect(H.api.projectGitSwitch).toHaveBeenCalledWith({ path: PROJECT, branch: 'feature/login' })) }) }) + +describe('notePlacement', () => { + const chip = { top: 700, bottom: 720, left: 100, right: 180 } + beforeEach(() => { + Object.defineProperty(window, 'innerWidth', { value: 1200, configurable: true }) + Object.defineProperty(window, 'innerHeight', { value: 800, configurable: true }) + }) + + it('keeps a chip note on the chip row, clear of the composer above it', () => { + // Bottom-aligned with the chip and to its right, never above it. + expect(notePlacement('chip', chip, 280)).toEqual({ bottom: 80, left: 186 }) + }) + + it('moves a chip note to the left of the chip when the right side has no room', () => { + const right = { top: 700, bottom: 720, left: 900, right: 1000 } + expect(notePlacement('chip', right, 280)).toEqual({ bottom: 80, right: 306 }) + }) + + it('falls back above the chip only when neither side fits', () => { + Object.defineProperty(window, 'innerWidth', { value: 400, configurable: true }) + expect(notePlacement('chip', { top: 700, bottom: 720, left: 60, right: 340 }, 280)).toEqual({ bottom: 104, left: 60 }) + }) + + it('drops a header note below the trigger', () => { + expect(notePlacement('header', { top: 40, bottom: 60, left: 100, right: 180 }, 280)).toEqual({ top: 64, left: 100 }) + }) +}) + +describe('BranchSwitcher across a Crew switch', () => { + it('opens in the local overlay root and closes when the local Crew is hidden', async () => { + const qc = new QueryClient({ defaultOptions: { queries: { retry: false, retryDelay: 0 } } }) + const tree = (hidden: boolean) => ( + + + + ) + const { rerender } = render(tree(false)) + await openPicker() + const picker = screen.getByTestId('branch-switcher') + expect(picker.closest('[data-local-overlay-root]')).not.toBeNull() + rerender(tree(true)) + await waitFor(() => expect(screen.queryByTestId('branch-switcher')).toBeNull()) + }) +}) diff --git a/website/src/test/ChatInput.projectBranch.test.tsx b/website/src/test/ChatInput.projectBranch.test.tsx index 9628b4674a2..183feff7ad1 100644 --- a/website/src/test/ChatInput.projectBranch.test.tsx +++ b/website/src/test/ChatInput.projectBranch.test.tsx @@ -1,5 +1,5 @@ import React from 'react' -import { describe, it, expect, vi, beforeEach } from 'vitest' +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' import { screen, fireEvent, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { renderWithProviders } from './helpers' @@ -8,6 +8,12 @@ import { SlotProvider } from '../providers/SlotContext' import { api } from '../api/client' import type { GitBranchList } from '../api/client/files' +// The chip loads its switcher behind import(); that boundary is pinned in +// LazyBranchSwitcher.test.tsx, so here the switcher renders synchronously. +vi.mock('../components/LazyBranchSwitcher', async () => ({ + default: (await import('../components/BranchSwitcher')).default, +})) + // Pins the project-chip branch contract: the active project pill shows // " · " so the session's git context is visible without opening // a picker, degrades to the folder name alone when there is no branch to show @@ -28,8 +34,8 @@ const LIST: GitBranchList = { repo: true, current: 'feat/example', local: [ - { name: 'feat/example', sha: 'abc1234', date: '2026-10-01T00:00:00Z', author: 'Ada', subject: 'wip', switchable: true, current: true }, - { name: 'main', sha: 'def5678', date: '2026-09-30T00:00:00Z', author: 'Ada', subject: 'release', switchable: true }, + { name: 'feat/example', date: '2026-10-01T00:00:00Z', author: 'Ada', subject: 'wip', switchable: true, current: true }, + { name: 'main', date: '2026-09-30T00:00:00Z', author: 'Ada', subject: 'release', switchable: true }, ], remote: [], } @@ -39,6 +45,21 @@ beforeEach(() => { localStorage.clear() }) +let originalClipboard: PropertyDescriptor | undefined + +const stubClipboard = () => { + originalClipboard = Object.getOwnPropertyDescriptor(navigator, 'clipboard') + const writeText = vi.fn().mockResolvedValue(undefined) + Object.defineProperty(navigator, 'clipboard', { value: { writeText }, configurable: true }) + return writeText +} + +afterEach(() => { + if (originalClipboard) Object.defineProperty(navigator, 'clipboard', originalClipboard) + else delete (navigator as { clipboard?: unknown }).clipboard + originalClipboard = undefined +}) + const chip = () => screen.getByRole('button', { name: /Project: |Select project/ }) const branchBtn = () => screen.getByTestId('branch-switcher-trigger') @@ -77,17 +98,44 @@ describe('ChatInput project chip branch label', () => { expect(btn).toHaveAccessibleName(/This reply finishes in KiroCrew\./) }) - it('blocks branch switching while a response is running', () => { + it('leaves the branch copyable while a response is running', async () => { + const writeText = stubClipboard() const list = vi.spyOn(api, 'projectGitBranches').mockResolvedValue(LIST) renderWithProviders() - // A checkout mid-turn would change files under the agent, same as a project switch. - expect(branchBtn()).toBeDisabled() - expect(branchBtn()).toHaveAccessibleName('Stop the current response to switch branch') + // Switching mid-run is unsafe, since a checkout would change files under the + // agent; reading the branch name is not. The segment stays a live control + // that copies, and its tooltip says how to switch. + expect(branchBtn()).not.toBeDisabled() + expect(branchBtn()).toHaveAccessibleName('Copy branch name main') + expect(branchBtn()).toHaveAccessibleDescription('Copy branch name. To switch branch, first stop the response with ■ next to Send.') fireEvent.click(branchBtn()) + await waitFor(() => expect(writeText).toHaveBeenCalledWith('main')) + await waitFor(() => expect(branchBtn()).toHaveAccessibleName('Copied branch name main')) expect(screen.queryByTestId('branch-switcher')).not.toBeInTheDocument() expect(list).not.toHaveBeenCalled() }) + it('calls a detached HEAD a commit when copying it mid-run', () => { + renderWithProviders() + expect(screen.getByRole('button', { name: 'Copy commit a1b2c3d' })).toBeInTheDocument() + }) + + it('keeps the message input focused while copying the branch mid-run', async () => { + // A real pointer click moves focus to the pressed button before `click` + // fires. The trigger cancels that transfer on mousedown, so a user who is + // typing a steer can copy the branch and keep typing. `userEvent` replays + // the full mousedown -> focus -> mouseup -> click sequence; `fireEvent.click` + // alone would never move focus and could not fail here. + const user = userEvent.setup() + const writeText = stubClipboard() + renderWithProviders() + const input = screen.getByRole('textbox', { name: 'Message input' }) + input.focus() + await user.click(branchBtn()) + expect(input).toHaveFocus() + await waitFor(() => expect(writeText).toHaveBeenCalledWith('feat/example')) + }) + it('falls back to the full path when the project has no basename', () => { renderWithProviders() expect(branchBtn()).toHaveTextContent('main') @@ -118,7 +166,7 @@ describe('ChatInput project chip branch switcher', () => { it('switches the project folder to the chosen branch', async () => { vi.spyOn(api, 'projectGitBranches').mockResolvedValue(LIST) - const sw = vi.spyOn(api, 'projectGitSwitch').mockResolvedValue({ ok: true, branch: 'main', previous: 'feat/example' }) + const sw = vi.spyOn(api, 'projectGitSwitch').mockResolvedValue({ ok: true, branch: 'main' }) renderWithProviders() fireEvent.click(branchBtn()) fireEvent.mouseDown(await screen.findByText('main')) diff --git a/website/src/test/GitPanel.test.tsx b/website/src/test/GitPanel.test.tsx index dd75aaa595a..3184c090fa1 100644 --- a/website/src/test/GitPanel.test.tsx +++ b/website/src/test/GitPanel.test.tsx @@ -23,6 +23,10 @@ vi.mock('../api/client', () => ({ api: H.api })) import GitPanel from '../components/GitPanel' import { namedCeiling } from './namedCeiling' +// The header trigger sits behind LazyBranchSwitcher's import() boundary, which a cold, +// coverage-instrumented run can take well past the 1s default to resolve. +const LAZY_SWITCHER_TIMEOUT = { timeout: 5000 } + const PROJECT = '/workspace/project' /** One open section, as the Git tab's list renders a repository the user is @@ -237,24 +241,31 @@ describe('GitPanel repository state', () => { expect(screen.queryByText('Not a Git repository')).toBeNull() }) - it('turns the header branch switcher off while the chat response runs', async () => { + it('keeps the header branch copyable, not switchable, while the chat response runs', async () => { mount(true, { busy: true, branchSwitch: true }) - const trigger = await screen.findByTestId('branch-switcher-trigger') - expect(trigger).toBeDisabled() - expect(trigger).toHaveAttribute('title', 'Stop the current response to switch branch') + // Waits for the status query, then React.lazy -> import('./BranchSwitcher') -> the trigger. + const trigger = await screen.findByTestId('branch-switcher-trigger', {}, LAZY_SWITCHER_TIMEOUT) + expect(trigger).toBeEnabled() + expect(trigger).toHaveAttribute('data-mode', 'copy') + expect(trigger).toHaveAccessibleDescription('Copy branch name. To switch branch, first stop the response with ■ next to Send.') + expect(trigger).not.toHaveAttribute('aria-haspopup') }) it('leaves the header branch switcher on when the chat is idle', async () => { mount(true, { branchSwitch: true }) - expect(await screen.findByTestId('branch-switcher-trigger')).toBeEnabled() + // Waits for the status query, then React.lazy -> import('./BranchSwitcher') -> the trigger. + const trigger = await screen.findByTestId('branch-switcher-trigger', {}, LAZY_SWITCHER_TIMEOUT) + expect(trigger).toBeEnabled() + expect(trigger).toHaveAttribute('data-mode', 'switch') + expect(trigger).toHaveAttribute('aria-haspopup', 'listbox') }) it('keeps the switcher out of the toggle, which still covers and describes the status line', async () => { mount(false, { branchSwitch: true }) - const trigger = await screen.findByTestId('branch-switcher-trigger') + const trigger = await screen.findByTestId('branch-switcher-trigger', {}, LAZY_SWITCHER_TIMEOUT) const toggle = screen.getByTestId('git-repo-toggle') // A button cannot nest in a button: the switcher is the toggle's sibling. expect(toggle).not.toContainElement(trigger) diff --git a/website/src/test/GitReposPanel.test.tsx b/website/src/test/GitReposPanel.test.tsx index d2bb20d478e..4e91498560a 100644 --- a/website/src/test/GitReposPanel.test.tsx +++ b/website/src/test/GitReposPanel.test.tsx @@ -38,11 +38,11 @@ const REPO_STATUS_CHAIN = { timeout: REPO_STATUS_CHAIN_TIMEOUT_MS } const agentRows = (n: number) => Array.from({ length: n }, (_, i) => ({ path: `/home/me/src/repo-${i}`, source: 'agent' as const })) -function mount() { +function mount(busy?: boolean) { const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false, retryDelay: 0 } } }) const view = render( - + , ) return { ...view, queryClient } @@ -63,6 +63,22 @@ beforeEach(async () => { }) describe('GitReposPanel', () => { + it('puts the branch switcher on the project section only, copy-only while the chat runs', async () => { + H.api.projectGitRepos.mockResolvedValue({ + repos: [ + { path: PROJECT, source: 'project' }, + { path: OTHER, source: 'agent' }, + ], + }) + mount(true) + const sections = await screen.findAllByTestId('git-repo-section') + // The switch route accepts the chat's project, not a repository a tool + // call merely touched, so only the project heading offers a switch. + const trigger = await within(sections[0]).findByTestId('branch-switcher-trigger', {}, REPO_STATUS_CHAIN) + expect(trigger).toHaveAttribute('data-mode', 'copy') + await within(sections[1]).findByText('main', {}, REPO_STATUS_CHAIN) + expect(within(sections[1]).queryByTestId('branch-switcher-trigger')).toBeNull() + }) it('lists every repository by path, the project first and tagged', async () => { H.api.projectGitRepos.mockResolvedValue({ repos: [ diff --git a/website/src/test/LazyBranchSwitcher.test.tsx b/website/src/test/LazyBranchSwitcher.test.tsx new file mode 100644 index 00000000000..98f7c8e0b38 --- /dev/null +++ b/website/src/test/LazyBranchSwitcher.test.tsx @@ -0,0 +1,52 @@ +import { describe, expect, it, vi } from 'vitest' +import { render, screen } from '@testing-library/react' +import { readFileSync } from 'node:fs' +import { resolve } from 'node:path' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' + +vi.mock('../api/client', () => ({ api: { projectGitBranches: vi.fn(), projectGitSwitch: vi.fn() } })) + +import LazyBranchSwitcher from '../components/LazyBranchSwitcher' + +// The real trigger mounts only after React.lazy resolves import('./BranchSwitcher'), +// which a cold, coverage-instrumented run can take well past the 1s default. +const LAZY_SWITCHER_TIMEOUT = { timeout: 5000 } + +function mount(props: Parameters[0]) { + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }) + return render( + + + , + ) +} + +describe('LazyBranchSwitcher', () => { + it('shows the branch in a placeholder, then hands over to the real trigger', async () => { + mount({ projectDir: '/workspace/project', branch: 'develop', variant: 'chip' }) + expect(screen.getByTestId('branch-switcher-placeholder')).toHaveTextContent('develop') + // Waits for React.lazy -> import('./BranchSwitcher') -> the real trigger's first render. + const trigger = await screen.findByTestId('branch-switcher-trigger', {}, LAZY_SWITCHER_TIMEOUT) + expect(trigger).toHaveTextContent('develop') + expect(trigger).toHaveAttribute('data-variant', 'chip') + expect(screen.queryByTestId('branch-switcher-placeholder')).toBeNull() + }) + + it('passes every prop through to the switcher', async () => { + mount({ projectDir: '/workspace/project', branch: 'main', switchBlocked: true }) + // Waits for React.lazy -> import('./BranchSwitcher') -> the real trigger's first render. + const trigger = await screen.findByTestId('branch-switcher-trigger', {}, LAZY_SWITCHER_TIMEOUT) + expect(trigger).toHaveAttribute('data-variant', 'header') + expect(trigger).toHaveAttribute('data-mode', 'copy') + }) + + it('keeps the hosts behind the import() boundary', () => { + // A static import of BranchSwitcher from either host pulls the picker back + // into the always-loaded chat-route chunk and over its size budget. + for (const host of ['ChatInput.tsx', 'GitPanel.tsx']) { + const src = readFileSync(resolve(__dirname, '../components', host), 'utf8') + expect(src, host).not.toMatch(/from '\.\/BranchSwitcher'/) + expect(src, host).toMatch(/from '\.\/LazyBranchSwitcher'/) + } + }) +})