feat(mobile): the phone's Workspace options sheet, with every desktop option (#28997)

* fix(mobile): phone server rows follow the desktop's other-client and sleeping filters

A phone created through a desktop now records whom it acted for, so the
desktop's Hide other-client workspaces filter keeps the user's own phone
workspaces (locally and on servers) and hides only other people's.
The desktop relay marks each server row its sidebar would hide, and the
rows its own browser or chat tabs keep awake; the phone applies both.

* test(e2e): dedupe sidebar row ids in the visibility spec

* fix(mobile): address review: owner naming, old-server relayed phones, scope trim

* refactor: move the sidebar's project section grouping into shared

The project sections the desktop sidebar draws under Group by Project are
built from the project/setup projection, the per-host catalog fold, the
endpoint host stamp, and the section labelling and ordering rules. Move the
pure parts of each into src/shared (no behaviour change) so the paired phone
can call the same code instead of guessing the grouping.

* fix(mobile): group by Project with the desktop sidebar's own sections

The phone grouped Project sections by repo display name, so it merged
checkouts the desktop keeps apart (several local stablyai/orca checkouts,
or a server's copy) and split what the desktop merges. The phone now reads
each host's repo.list, project.list and projectHostSetup.list (the desktop's
own and each server's through the relay), folds them with the renderer's
shared catalog fold and projection merge, and builds sections with the
desktop's shared grouping, labelling and ordering code. Keys, labels and
Recent order equal the sidebar's; a host that predates the project lists
gets the repo-derived projection, as the desktop does.

Header colour and icon come from the section's anchor repo and rows from
their own repo by id, as on the desktop. The repo filter lists every
catalog repo by id per host instead of merging same-named repos. Group-by
labels read Project and None. project.list and projectHostSetup.list are
routed for paired phones (read-only, workspace permission).

* refactor(mobile): record a phone's owner as creator and fold desktop tab activity into the host flag

A phone now records its owner as the creator (this host for a directly
paired phone, the relaying desktop for a delegated one), so the shared rule
is unchanged and onBehalfOf is gone. The relay ORs the desktop's saved
browser/chat tabs into hasHostSidebarActivity, so the phone's status dot
follows too, and local worktree.ps counts the same signals. The old-server
fallback is removed.

* test(e2e): poll the phone and the sidebar together in the visibility spec

* fix(mobile): one Project-section builder for desktop and phone, with the desktop's repo order

Review fixes. The sidebar's buildOrderedGroups now calls the shared
buildProjectSections for Group by Project (notice and pending repos are
optional inputs), so desktop and phone run one function. The phone ranks
manual header order by its folded catalog order, which is each host's
repo.list order with the desktop's own repos first, as the renderer loads
them; only the desktop's cross-host manualRepoOrder (pairing-local) is
unknown to it. The projection selector is now a memo over the shared
resolver. A transient project-list failure keeps that host's last catalog;
only forbidden or method_not_found falls back to the repo-derived
projection. Catalog rows are checked on every member grouping reads, and
the parity test answers the renderer's real catalog fetch, includes a
server's own project rows, and fixes Manual and Recent orders that differ.

* fix(mobile): use the repo-derived catalog until a host's project lists first answer

* fix(mobile): the desktop serves its sidebar's Project catalog instead of the phone fetching it

The phone fetched repo.list, project.list and projectHostSetup.list from
the desktop and every relay-ready server each minute and folded them
itself, so rows from offline or older servers fell back to repo sections
and keys moved on cold start.

The desktop renderer now publishes the inputs its own Project headers use
(state.repos in sidebar order, the projection selector's projects and
setups, projectOrderBy) on the runtime window graph it already sends to
main, only when they change. Main keeps the last one and answers it on a
new paired-desktop read, mobileRelay.projectSections. The phone reads it
with its repo metadata and groups with the shared builder; a desktop that
predates the read (forbidden or method_not_found) leaves the phone grouping
rows by repo from the desktop's repo.list, and a transient failure keeps
the last catalog. The payload carries only grouping and label fields, no
hooks, credentials or uploaded icons. The phone's own catalog fetching and
its project.list/projectHostSetup.list routes are removed.

* fix(mobile): keep uploaded repo icons on the phone, and isolate the snapshot tests

* test(e2e): the desktop window serves its sidebar's Project catalog to the phone

* chore(i18n): regenerate the runtime English catalog (drift already on main)

* fix(mobile): read the Project catalog as its own request, outside the recorded repo-metadata flow

The extra read inside the repo-metadata refresh stalled its recorded RPC
sequence. It now runs beside it with its own throttle. The repo-metadata
goldens are re-recorded for the intended state change: the name-keyed
colour, icon and id maps became desktopRepos.

* refactor(sidebar): one shared Workspace options filter and sort evaluator

The sidebar, the jump palette and the options badge each applied the
Workspace options filters with their own copy of the rules. They now
call one pure evaluator in src/shared/workspace-view, which reads each
row through a small fact reader so the phone can call it next with the
facts its host serves. The sort comparator moves there too, with each
surface supplying its own Agent activity rank.

No behaviour change: a parity test runs the old sidebar chain and the
new evaluator over every toggle combination, and the old comparator
against the new one for each sort mode.

* wip: workspace view descriptors

* refactor(sidebar): address review of the shared Workspace options evaluator

One predicate builder instead of a per-row scope argument; one
exemption-narrowing function taking the hide flag; consumers import the
shared predicates directly; Agent activity ties fall to the label in the
shared comparator. The activation filter lift reuses the shared
predicates. The parity test now covers other-client rows, live agent
attention classes and the sort-label map.

* fix(mobile): fetch the Project catalog at once when the screen moves to another host

The refresh window is now kept per client, so a host screen reused for another desktop doesn't wait out the previous host's 60s window before showing its Project headers.

* wip: desktop option items from shared choices

* refactor(sidebar): the Workspace options menu renders from one shared option list

src/shared/workspace-view/workspace-view-options.ts lists every Workspace
options control (Hosts, Projects, Group by, Sort by, Project order, Card
layout, Show properties or Card display, and each filter) with its
section, its English copy, its choices and when it shows. The desktop
menu walks that list and renders each option through a table keyed on
the option key, so a new option fails to compile until the desktop has
a control for it; the phone sheet will do the same.

The choice lists (sort modes, project order, card layout, agent activity
layout, card properties) are derived from the shared ids, and a test
checks the desktop's English copy matches the shared copy. The unused
classic branch of the card property list is removed.

No visible change.

* wip: row facts

* refactor(sidebar): address review of the shared Workspace options list

The show rules get their own tests with fixed expectations (the cases
the old filter-section test covered, plus the rest), and the menu test
checks each option's own control rather than any matching text. Card
display rows toggle one property each, with the folder-path copy in
shared. The unused Status and Tasks copy left by the removed classic
card list is deleted from the catalogs. Filter rows write through the
store directly; files are named for what they hold.

* feat(runtime): worktree.ps carries the Workspace options row facts

Each worktree.ps row now says whether it is the project's default-branch
workspace, created by an automation, created by the CLI, or on a
detached HEAD, answered by the same shared predicates the desktop
sidebar filters with. The phone has none of these facts today, so its
copies of the filters either guess (default branch) or are missing.

Additive and inert: optional fields that nothing reads yet. An older
host omits them; readers must treat absence as 'not that kind', so the
row stays visible. Server rows relayed through the desktop pass them
through unchanged.

* feat(mobile): the desktop's own phone reads and writes its other-client filter

ui.get and ui.set strip the pairing-local UI fields for every paired
client. That rule exists for clients with their own host catalog (a
desktop paired to a server, a web client). A phone has none: it shows
its desktop's view, so 'Hide other-client workspaces' must be one shared
value. For the host's own phone (clientKind mobile, no relaying parent)
ui.get now always answers the field and ui.set persists it; delegated
phones, paired desktops and web clients keep the stripping rule.

The phone's view-settings schema reads the rest of the Workspace options
values it does not read yet (the automation, CLI and detached-HEAD
filters, project order, card properties, agent activity layout), each
salvaged to absent so an older desktop keeps the phone's local value.

* feat(runtime): address review of the worktree.ps row facts

Name the fact defaultBranch, and say what absence means for it: a reader
keeps its own guess, because 'hide default branch' must not start hiding
or showing rows an older host never described. Folder workspace rows get
the facts too, so every row from a newer host carries them. The
desktopOtherClient comment now says it is a verdict, not a fact. The test
covers the rows where the host disagrees with the phone's current guess.

* wip: phone uses the shared evaluator

* feat(mobile): address review of the phone's other-client filter mirror

One isHostOwnPhone predicate decides both who shares the filter value
(ui.get/ui.set) and who gets the other-client row marks, so the two
cannot drift. The pairing-local field list notes the exception. Tests
cover a paired desktop still being stripped, and a phone write that
leaves the field out not touching the host value.

* feat(mobile): the phone filters and sorts with the desktop's shared Workspace options rules

The phone's own filter chain and comparator are replaced by the shared
evaluator, reading the facts the host stamps on each row. Phone filter
state takes the desktop's setting names and carries every filter; the
badge and Clear filters match the desktop's. The desktop's other-client
marks become facts the phone filters with the shared value.

A parity test runs the same workspaces through the desktop's reading
and the phone's (facts stamped as the host stamps them) over every
filter combination, folder projects included.

* wip: phone view state carries project order and card props

* fix(mobile): host scope in shared filters, patch-key writes, live view push

Folds the phone's host filter into the shared filters so Clear and the badge agree,
writes back only the touched filter keys, and pushes workspaceViewChanged so an open
phone list re-reads desktop Workspace options.

* fix(mobile): Clear sends only changed filters; skip a view re-read older than a local edit

An older host's strict ui.set refuses a key it lacks, so Clear names only what differs. A ui.get
that started before a local edit no longer overwrites it; the edit's own change event re-reads.
The change publisher's field list now lives with it and derives the filter fields.

* feat(mobile): the phone's Workspace options sheet, rendered from the desktop's shared option list

The Filter drawer becomes the desktop's Workspace options: hosts, projects with search, group,
sort, project order, card layout and properties, card display, and every filter. Each option
renders through a table keyed on the shared option keys, so a new desktop option fails to compile
until the phone has a control. Sort and group pickers use the desktop's words; Repo reads Project.

* fix(mobile): Workspace options sheet review fixes

Hosts read as on the desktop (none checked while all show; the last shown host is disabled).
Project order falls back to the served value until the shared one is read. Card layout and card
style changes now push to an open phone. Pick-one rows read as radio buttons. Simpler wiring:
flat card state, the preset flag rides the patch, shared choices used directly.

* fix(mobile): reset project order on host switch; simpler preset-flag handling

* fix(workspace-options): all hosts and clear reach the desktop; one filter key list drives every surface

Host visibility: the legacy host scope fallback moves to shared code; the phone reads it, and the
host resets the scope whenever a client writes the host list, so All hosts and Clear stick.
An older desktop that keeps the other-client filter to itself has its marks treated as hide.
Badge counts, is-anything-filtered, Clear and reveal derive from the shared filter keys on the
desktop sidebar and the kanban filter menu. The phone uses the desktop's group and sort values,
holds its view in one state value, and reads its UI fields from one shared list.

* feat(workspace-options): Status and PR grouping read the shared tables

The phone groups by status with the desktop's status helpers and default catalog (its own copy
had the defaults in reverse order) and by PR with a shared lane table, so its labels now read
In review / In progress as the desktop's do. The desktop's PR lane decision ends in the same
shared state-to-lane step.

* feat(mobile): phone rows follow Card layout, Show properties, Card display and agent activity

One shared helper (getWorktreeCardDetails) decides which card details show for the chosen
properties and layout; the desktop card's flags and the phone row both read it. The phone row
hides the PR badge, note/issue/Linear glyphs, host badge and agent rows the desktop turns off,
and the Full list agent layout shows every agent row.

* test(sidebar): folder rows survive restrictive filters; create grace ignores non-finite times

* fix(mobile): write the host scope with the host list, and wait for the first read before forcing the other-client filter

An older desktop narrows back to its legacy single-host scope when the phone
writes only a null host list, so the phone now also writes the scope for a null
or one-host list. The other-client fallback waits for the first ui.get so a new
desktop's marks don't hide rows before its filter value arrives. Inline the
clear-filters key list and note the PR state asymmetry.

* test(mobile): re-record view-settings goldens for the unknown other-client state

* fix(mobile): let a tall bottom drawer scroll on the page

The drag wrapper around the drawer's ScrollView had no flex limit, so it grew to
its content and the sheet clipped it instead of scrolling. The full Workspace
options sheet is the first drawer tall enough to hit this.

* fix(sidebar): reuse the palette's default host instead of reading the focus setting again

* fix(sidebar): read the host options once in Workspace options and pass them to the Hosts item

* fix: parse the ui.get reply into a named type, fix the row memo test's filters, prune the focus-read baseline

* test(mobile): give the project catalog fixture its project order

* feat(mobile): name the options chip Workspace options, as the desktop does

The chip keeps the active-filter count and reads to screen readers with the
desktop trigger's label. The pluralized filter count moves to shared code so
both surfaces word it the same way.

* fix(mobile): show the Workspace options count as a corner badge so the chip row fits

"Workspace options (2)" squeezed the Sort and Group chips to ellipses at phone
width. The count moves to a corner badge, as on the desktop trigger, and the
chip drops its icon, which the desktop trigger does not show either.

* refactor(sidebar): one switch-to-kind table, and fold the leftover helper files back

- The four kind switches come from WORKSPACE_KIND_SWITCH in workspace-kinds;
  the shared evaluator and main's phone-push check both apply it, and the filter
  type derives its switch fields from it.
- git-identity.ts folds back into git-identity-display.ts, as on main.
- SidebarFilterState moves into sidebar-filter-actions.ts; visible-worktree-kinds.ts is gone.
- buildWorkspaceRowPredicate is module-private.

* feat(mobile): Workspace options sheet in the desktop menu's shape

The sheet showed every choice of every option, so the filters sat screens down.
It now follows the desktop menu: Hosts and Projects rows with their value, Group by
as a segmented control, Sort by, Project order, Card layout and Show properties
as rows that open a page of choices, and the filters as switches. Each option's
control (an inline row, or a value plus a page) still comes from one table keyed
by the shared option list. The segmented control is the voice settings one,
extracted so both screens share it.

* fix(mobile): Workspace options review fixes

- A page whose option stops showing is forgotten, so it cannot reopen by itself.
- Only the switch takes the tap on a filter row, so the web page writes once.
- Sections split their rows in one pass; unused heading styles go.

* fix(mobile): neutral filter switches on the page

react-native-web paints an on-switch thumb teal unless given activeThumbColor,
which React Native's types omit. MobileSwitch applies the app's switch colors,
and its web sibling also sets that prop.

* refactor(mobile): one MobileSwitch file, adding the web-only thumb color on web

* fix(mobile): Show properties reads Hover under Compact, as the desktop does

The row's value now comes from getWorkspaceCardPropertiesValue in the shared
options, which the desktop trigger uses too: Hover under the compact layout,
otherwise how many properties show. Card display shows no value, as on the desktop.

* test(mobile): find the renamed Workspace options chip in the page drawer render check

* feat(mobile): drop the Sort and Group chips; the Workspace options chip covers both

The desktop has only its options button, and the sheet now holds Sort by and
Group by. The chips' pickers, their screen state and the sort label go with them.

* feat(mobile): option pages slide in from the right and keep the sheet's height

The main sheet stays laid out under an open page, so the drawer keeps its height
going in and out. The page slides in over 200 ms and back out on back (the row,
or Android's back key), instantly under reduced motion. The reduced-motion hook
moves to platform/ now that two screens use it.

* fix(mobile): option pages fill the sheet and open at its top

- The page layer grows to the pager's height, so no part of the main sheet shows below a short page.
- The drawer takes a scrollResetKey; the sheet passes the open page, so a page opened from a row low on a scrolled sheet starts at the top.
- The main sheet's height is measured under a page too, and the pager state lives in the sheet so it resets when the drawer closes.
- A misplaced doc comment goes back to its function.

* fix(mobile): phone rows show the Jira, automation and CLI glyphs; the options pager forgets a gone page in an effect

* fix(mobile): Jira glyph sits before Linear as on the desktop; Jira reader tests beside it

* fix(mobile): the phone leaves Ports out of Show properties and Card display

* refactor(mobile): clearer names and tests for the phone's hidden card properties

* refactor(mobile): one view state and one settings read for the phone's Workspace options; tighter card glyph wiring

- The phone's Card layout and card style now live in its view state; one parallel settings.get
  reads them with pinned placement beside ui.get.
- The Card layout preset flag is a write option, no longer a view field or a change-event field.
- getWorktreeCardDetails reads the raw desktop settings and resolves the layout once; the desktop
  card computes it once in its foundation.
- The row computes its glyph presence in one place; Ports is hidden through one set.
- Drops the vanished-page effect, the native-only a11y props and the web-only switch branch.

* fix(mobile): the list settings read applies each half on its own, with tests; pinned placement changes announce themselves

* fix(mobile): Card layout writes the setting before its preset; Card display names folder paths when the desktop has folder workspaces
This commit is contained in:
Jinwoo Hong
2026-10-15 19:27:51 -07:00
committed by GitHub
parent 38ac75b9c1
commit 7538fa9466
106 changed files with 5432 additions and 1406 deletions
+1 -4
View File
@@ -355,10 +355,7 @@ const ADVICE =
"Route by the resource's owner. Only a creation flow with no source row may use defaultCreationHost."
export function isScannedPath(rel) {
return (
/\.(ts|tsx)$/.test(rel) &&
!/\.(test|spec)(?:-fixture)?\.tsx?$/.test(rel)
)
return /\.(ts|tsx)$/.test(rel) && !/\.(test|spec)(?:-fixture)?\.tsx?$/.test(rel)
}
// One read and one discovery per root per run; `--prune` then `main` reuse them.
@@ -187,9 +187,9 @@ describeDrawer('the bottom drawer on the page', () => {
() => document.documentElement.dataset.orcaWebEntry === 'mounted',
{ timeout: 30_000, polling: 250 }
)
// The filter sheet, not the row's action sheet: both are the same MountedBottomDrawer, and
// The Workspace options sheet, not the row's action sheet: both are the same MountedBottomDrawer, and
// this one opens from the header, which needs nothing of the list's own layout.
const chip = await page.waitForFunction(centreOf, 'Filter', {
const chip = await page.waitForFunction(centreOf, 'Workspace options', {
timeout: 30_000,
polling: 250
})
@@ -313,7 +313,7 @@ describeDrawer('the device Back key reaching a sheet on the page', () => {
// leaves today's pop as the fallback.
expect(await page.evaluate(notifyNames)).not.toContain(backNames.claim)
const chip = await page.waitForFunction(centreOf, 'Filter', {
const chip = await page.waitForFunction(centreOf, 'Workspace options', {
timeout: 30_000,
polling: 250
})
+1 -1
View File
@@ -10,7 +10,7 @@ import {
type NotificationOnboardingChoice
} from '../src/onboarding/MobileOnboardingPage'
import { parseMobileOnboardingSteps } from '../src/onboarding/mobile-onboarding-plan'
import { useReducedMotionEnabled } from '../src/onboarding/use-reduced-motion'
import { useReducedMotionEnabled } from '../src/platform/use-reduced-motion'
import { mobileOnboardingStyles as styles } from '../src/onboarding/mobile-onboarding-styles'
import {
saveDefaultSessionView,
@@ -4,28 +4,90 @@
"family": "host.view-settings",
"namedDeltas": [],
"values": {
"3337ab8c1ffc": {
"name": "ui.set#1",
"ordinal": 6,
"json": "{\"id\":\"frame-2\",\"deviceToken\":\"recording-device\",\"method\":\"ui.set\",\"params\":{\"sortBy\":\"name\"}}"
"1c2564f1daca": {
"name": "settings.get#1",
"ordinal": 4,
"json": "{\"id\":\"frame-2\",\"deviceToken\":\"recording-device\",\"method\":\"settings.get\"}"
},
"582477c8e0af": {
"name": "ui.get#1",
"424e4d52d6fa": {
"view": {
"agentActivityDisplayMode": "compact",
"collapsedGroups": [],
"compactWorktreeCards": true,
"filters": {
"alwaysShowDefaultBranchWorkspace": true,
"filterRepoIds": [],
"hideAutomationGeneratedWorkspaces": false,
"hideCliCreatedWorkspaces": false,
"hideDefaultBranchWorkspace": false,
"hideDetachedHeadWorkspaces": false,
"hideSleepingWorkspaces": true,
"hideWorkspacesFromOtherDevices": false,
"visibleHostIds": {
"$rpc": "null"
}
},
"groupBy": "repo",
"newCardStyle": false,
"projectOrderBy": {
"$rpc": "null"
},
"sharesOtherClientFilter": false,
"sortBy": "name",
"workspaceStatuses": [
{
"color": "neutral",
"icon": "circle",
"id": "todo",
"label": "Todo"
},
{
"color": "conductor-progress",
"icon": "conductor-progress",
"id": "in-progress",
"label": "In progress"
},
{
"color": "conductor-review",
"icon": "conductor-review",
"id": "in-review",
"label": "In review"
},
{
"color": "conductor-done",
"icon": "conductor-done",
"id": "completed",
"label": "Done"
}
],
"worktreeCardProperties": [
"status",
"unread",
"issue",
"linear-issue",
"jira-issue",
"pr",
"automation",
"cli",
"comment",
"ports",
"inline-agents",
"host"
]
}
},
"51d4cb56be85": {
"name": "settings.get#1",
"ordinal": 2,
"json": "{\"id\":\"frame-1\",\"deviceToken\":\"recording-device\",\"method\":\"ui.get\"}"
},
"5b5eda740798": {
"name": "ui.set#1",
"ordinal": 5,
"args": [
{
"name": "method",
"value": "ui.set"
"value": "settings.get"
},
{
"name": "params",
"value": {
"sortBy": "name"
"$rpc": "absent"
}
},
{
@@ -36,16 +98,8 @@
}
],
"settlement": {
"status": "fulfilled",
"startedAt": 0,
"settledAt": 0,
"value": {
"id": "frame-2",
"ok": true,
"result": {
"ok": true
}
}
"status": "pending",
"startedAt": 0
}
},
"651e8014c9ff": {
@@ -86,9 +140,18 @@
}
}
},
"7bbe6d34ee5c": {
"view": {
"6bab186637b0": {
"name": "showPinnedInGroups",
"ordinal": 5,
"value": false
},
"8369a6f35fda": {
"name": "view",
"ordinal": 6,
"value": {
"agentActivityDisplayMode": "compact",
"collapsedGroups": [],
"compactWorktreeCards": true,
"filters": {
"alwaysShowDefaultBranchWorkspace": true,
"filterRepoIds": [],
@@ -103,6 +166,10 @@
}
},
"groupBy": "repo",
"newCardStyle": false,
"projectOrderBy": {
"$rpc": "null"
},
"sharesOtherClientFilter": false,
"sortBy": "name",
"workspaceStatuses": [
@@ -130,6 +197,20 @@
"id": "completed",
"label": "Done"
}
],
"worktreeCardProperties": [
"status",
"unread",
"issue",
"linear-issue",
"jira-issue",
"pr",
"automation",
"cli",
"comment",
"ports",
"inline-agents",
"host"
]
}
},
@@ -163,11 +244,13 @@
"startedAt": 0
}
},
"c81e3e604f1c": {
"c49dcad120d2": {
"name": "view",
"ordinal": 4,
"ordinal": 7,
"value": {
"agentActivityDisplayMode": "compact",
"collapsedGroups": [],
"compactWorktreeCards": true,
"filters": {
"alwaysShowDefaultBranchWorkspace": true,
"filterRepoIds": [],
@@ -182,6 +265,10 @@
}
},
"groupBy": "repo",
"newCardStyle": false,
"projectOrderBy": {
"$rpc": "null"
},
"sharesOtherClientFilter": false,
"sortBy": "name",
"workspaceStatuses": [
@@ -209,12 +296,33 @@
"id": "completed",
"label": "Done"
}
],
"worktreeCardProperties": [
"status",
"unread",
"issue",
"linear-issue",
"jira-issue",
"pr",
"automation",
"cli",
"comment",
"ports",
"inline-agents",
"host"
]
}
},
"c886cd24aa84": {
"cd9c5de9f2aa": {
"name": "ui.set#1",
"ordinal": 9,
"json": "{\"id\":\"frame-3\",\"deviceToken\":\"recording-device\",\"method\":\"ui.set\",\"params\":{\"sortBy\":\"name\"}}"
},
"dc007face350": {
"view": {
"agentActivityDisplayMode": "compact",
"collapsedGroups": [],
"compactWorktreeCards": false,
"filters": {
"alwaysShowDefaultBranchWorkspace": true,
"filterRepoIds": [],
@@ -229,6 +337,10 @@
}
},
"groupBy": "repo",
"newCardStyle": false,
"projectOrderBy": {
"$rpc": "null"
},
"sharesOtherClientFilter": {
"$rpc": "null"
},
@@ -258,56 +370,57 @@
"id": "completed",
"label": "Done"
}
],
"worktreeCardProperties": [
"status",
"unread",
"issue",
"linear-issue",
"jira-issue",
"pr",
"automation",
"cli",
"comment",
"ports",
"inline-agents",
"host"
]
}
},
"e51c608f6ed4": {
"name": "view",
"ordinal": 3,
"value": {
"collapsedGroups": [],
"filters": {
"alwaysShowDefaultBranchWorkspace": true,
"filterRepoIds": [],
"hideAutomationGeneratedWorkspaces": false,
"hideCliCreatedWorkspaces": false,
"hideDefaultBranchWorkspace": false,
"hideDetachedHeadWorkspaces": false,
"hideSleepingWorkspaces": true,
"hideWorkspacesFromOtherDevices": false,
"visibleHostIds": {
"$rpc": "null"
"e55533c6e358": {
"name": "settings.get#1",
"ordinal": 2,
"args": [
{
"name": "method",
"value": "settings.get"
},
{
"name": "params",
"value": {
"$rpc": "absent"
}
},
"groupBy": "repo",
"sharesOtherClientFilter": false,
"sortBy": "name",
"workspaceStatuses": [
{
"color": "neutral",
"icon": "circle",
"id": "todo",
"label": "Todo"
},
{
"color": "conductor-progress",
"icon": "conductor-progress",
"id": "in-progress",
"label": "In progress"
},
{
"color": "conductor-review",
"icon": "conductor-review",
"id": "in-review",
"label": "In review"
},
{
"color": "conductor-done",
"icon": "conductor-done",
"id": "completed",
"label": "Done"
{
"name": "options",
"value": {
"$rpc": "absent"
}
]
}
],
"settlement": {
"status": "fulfilled",
"startedAt": 0,
"settledAt": 0,
"value": {
"id": "frame-2",
"ok": true,
"result": {
"settings": {
"compactWorktreeCards": true
}
}
}
}
},
"eb79a9b3682a": {
@@ -317,6 +430,45 @@
"value": {
"$rpc": "undefined"
}
},
"ec336f23ef91": {
"name": "ui.get#1",
"ordinal": 3,
"json": "{\"id\":\"frame-1\",\"deviceToken\":\"recording-device\",\"method\":\"ui.get\"}"
},
"f56221cb03b8": {
"name": "ui.set#1",
"ordinal": 8,
"args": [
{
"name": "method",
"value": "ui.set"
},
{
"name": "params",
"value": {
"sortBy": "name"
}
},
{
"name": "options",
"value": {
"$rpc": "absent"
}
}
],
"settlement": {
"status": "fulfilled",
"startedAt": 0,
"settledAt": 0,
"value": {
"id": "frame-3",
"ok": true,
"result": {
"ok": true
}
}
}
}
},
"recording": {
@@ -325,28 +477,28 @@
{
"id": "ui-pending",
"observation": {
"sender": ["985caf5772e9"],
"payloads": ["582477c8e0af"],
"sender": ["985caf5772e9", "51d4cb56be85"],
"payloads": ["ec336f23ef91", "1c2564f1daca"],
"settlements": {
"mount": "eb79a9b3682a",
"sync": "9270aeb7d9c6"
},
"state": "c886cd24aa84",
"state": "dc007face350",
"effects": []
}
},
{
"id": "settled",
"observation": {
"sender": ["651e8014c9ff", "5b5eda740798"],
"payloads": ["582477c8e0af", "3337ab8c1ffc"],
"sender": ["651e8014c9ff", "e55533c6e358", "f56221cb03b8"],
"payloads": ["ec336f23ef91", "1c2564f1daca", "cd9c5de9f2aa"],
"settlements": {
"mount": "eb79a9b3682a",
"sync": "eb79a9b3682a",
"sort": "eb79a9b3682a"
},
"state": "7bbe6d34ee5c",
"effects": ["e51c608f6ed4", "c81e3e604f1c"]
"state": "424e4d52d6fa",
"effects": ["6bab186637b0", "8369a6f35fda", "c49dcad120d2"]
}
}
]
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -4,83 +4,9 @@
"family": "host.view-settings",
"namedDeltas": [],
"values": {
"0dcbfe528b58": {
"1071e4898c81": {
"name": "ui.set#1",
"ordinal": 5,
"args": [
{
"name": "method",
"value": "ui.set"
},
{
"name": "params",
"value": {
"sortBy": "name"
}
},
{
"name": "options",
"value": {
"$rpc": "absent"
}
}
],
"settlement": {
"status": "rejected",
"startedAt": 0,
"settledAt": 0,
"error": {
"category": "Error",
"message": "transport failure",
"isRpcDeliveryUnknown": true
}
}
},
"3337ab8c1ffc": {
"name": "ui.set#1",
"ordinal": 6,
"json": "{\"id\":\"frame-2\",\"deviceToken\":\"recording-device\",\"method\":\"ui.set\",\"params\":{\"sortBy\":\"name\"}}"
},
"506504c485ac": {
"name": "ui.set#1",
"ordinal": 5,
"args": [
{
"name": "method",
"value": "ui.set"
},
{
"name": "params",
"value": {
"sortBy": "name"
}
},
{
"name": "options",
"value": {
"$rpc": "absent"
}
}
],
"settlement": {
"status": "rejected",
"startedAt": 0,
"settledAt": 0,
"error": {
"category": "Error",
"message": "",
"isRpcDeliveryUnknown": true
}
}
},
"582477c8e0af": {
"name": "ui.get#1",
"ordinal": 2,
"json": "{\"id\":\"frame-1\",\"deviceToken\":\"recording-device\",\"method\":\"ui.get\"}"
},
"5b5eda740798": {
"name": "ui.set#1",
"ordinal": 5,
"ordinal": 8,
"args": [
{
"name": "method",
@@ -104,46 +30,145 @@
"startedAt": 0,
"settledAt": 0,
"value": {
"id": "frame-2",
"ok": true,
"result": {
"ok": true
}
"error": {
"code": "refused",
"message": "outer refused"
},
"id": "frame-3",
"ok": false
}
}
},
"63cc37872320": {
"name": "ui.set#1",
"ordinal": 5,
"args": [
{
"name": "method",
"value": "ui.set"
},
{
"name": "params",
"value": {
"sortBy": "name"
}
},
{
"name": "options",
"value": {
"$rpc": "absent"
}
}
],
"settlement": {
"status": "fulfilled",
"startedAt": 0,
"settledAt": 0,
"value": {
"id": "frame-2",
"ok": true,
"result": {
"1c2564f1daca": {
"name": "settings.get#1",
"ordinal": 4,
"json": "{\"id\":\"frame-2\",\"deviceToken\":\"recording-device\",\"method\":\"settings.get\"}"
},
"424e4d52d6fa": {
"view": {
"agentActivityDisplayMode": "compact",
"collapsedGroups": [],
"compactWorktreeCards": true,
"filters": {
"alwaysShowDefaultBranchWorkspace": true,
"filterRepoIds": [],
"hideAutomationGeneratedWorkspaces": false,
"hideCliCreatedWorkspaces": false,
"hideDefaultBranchWorkspace": false,
"hideDetachedHeadWorkspaces": false,
"hideSleepingWorkspaces": true,
"hideWorkspacesFromOtherDevices": false,
"visibleHostIds": {
"$rpc": "null"
}
},
"groupBy": "repo",
"newCardStyle": false,
"projectOrderBy": {
"$rpc": "null"
},
"sharesOtherClientFilter": false,
"sortBy": "name",
"workspaceStatuses": [
{
"color": "neutral",
"icon": "circle",
"id": "todo",
"label": "Todo"
},
{
"color": "conductor-progress",
"icon": "conductor-progress",
"id": "in-progress",
"label": "In progress"
},
{
"color": "conductor-review",
"icon": "conductor-review",
"id": "in-review",
"label": "In review"
},
{
"color": "conductor-done",
"icon": "conductor-done",
"id": "completed",
"label": "Done"
}
],
"worktreeCardProperties": [
"status",
"unread",
"issue",
"linear-issue",
"jira-issue",
"pr",
"automation",
"cli",
"comment",
"ports",
"inline-agents",
"host"
]
}
},
"4cc5a473bafa": {
"name": "ui.set#1",
"ordinal": 8,
"args": [
{
"name": "method",
"value": "ui.set"
},
{
"name": "params",
"value": {
"sortBy": "name"
}
},
{
"name": "options",
"value": {
"$rpc": "absent"
}
}
],
"settlement": {
"status": "fulfilled",
"startedAt": 0,
"settledAt": 0,
"value": {
"id": "frame-3",
"ok": true,
"result": {
"error": "refused"
}
}
}
},
"51d4cb56be85": {
"name": "settings.get#1",
"ordinal": 2,
"args": [
{
"name": "method",
"value": "settings.get"
},
{
"name": "params",
"value": {
"$rpc": "absent"
}
},
{
"name": "options",
"value": {
"$rpc": "absent"
}
}
],
"settlement": {
"status": "pending",
"startedAt": 0
}
},
"651e8014c9ff": {
@@ -184,9 +209,14 @@
}
}
},
"674196929e83": {
"name": "ui.set#1",
"6bab186637b0": {
"name": "showPinnedInGroups",
"ordinal": 5,
"value": false
},
"793af4fc3c95": {
"name": "ui.set#1",
"ordinal": 8,
"args": [
{
"name": "method",
@@ -211,17 +241,21 @@
"settledAt": 0,
"value": {
"error": {
"code": "refused",
"message": ""
"code": "method_not_found",
"message": "Unknown method"
},
"id": "frame-2",
"id": "frame-3",
"ok": false
}
}
},
"7bbe6d34ee5c": {
"view": {
"8369a6f35fda": {
"name": "view",
"ordinal": 6,
"value": {
"agentActivityDisplayMode": "compact",
"collapsedGroups": [],
"compactWorktreeCards": true,
"filters": {
"alwaysShowDefaultBranchWorkspace": true,
"filterRepoIds": [],
@@ -236,6 +270,10 @@
}
},
"groupBy": "repo",
"newCardStyle": false,
"projectOrderBy": {
"$rpc": "null"
},
"sharesOtherClientFilter": false,
"sortBy": "name",
"workspaceStatuses": [
@@ -263,12 +301,26 @@
"id": "completed",
"label": "Done"
}
],
"worktreeCardProperties": [
"status",
"unread",
"issue",
"linear-issue",
"jira-issue",
"pr",
"automation",
"cli",
"comment",
"ports",
"inline-agents",
"host"
]
}
},
"83f9f945965e": {
"8ab052f2be74": {
"name": "ui.set#1",
"ordinal": 5,
"ordinal": 8,
"args": [
{
"name": "method",
@@ -292,7 +344,7 @@
"startedAt": 0,
"settledAt": 0,
"value": {
"id": "frame-2",
"id": "frame-3",
"ok": true,
"result": {
"error": "inner refused",
@@ -331,9 +383,9 @@
"startedAt": 0
}
},
"98e0a2b6883b": {
"b2b58c2d731f": {
"name": "ui.set#1",
"ordinal": 5,
"ordinal": 8,
"args": [
{
"name": "method",
@@ -353,21 +405,19 @@
}
],
"settlement": {
"status": "fulfilled",
"status": "rejected",
"startedAt": 0,
"settledAt": 0,
"value": {
"id": "frame-2",
"ok": true,
"result": {
"error": "refused"
}
"error": {
"category": "Error",
"message": "transport failure",
"isRpcDeliveryUnknown": true
}
}
},
"c2801ac49075": {
"b5cabaf13de8": {
"name": "ui.set#1",
"ordinal": 5,
"ordinal": 8,
"args": [
{
"name": "method",
@@ -392,19 +442,58 @@
"settledAt": 0,
"value": {
"error": {
"code": "method_not_found",
"message": "Unknown method"
"code": "refused",
"message": ""
},
"id": "frame-2",
"id": "frame-3",
"ok": false
}
}
},
"c81e3e604f1c": {
"c0ca5ec08d54": {
"name": "ui.set#1",
"ordinal": 8,
"args": [
{
"name": "method",
"value": "ui.set"
},
{
"name": "params",
"value": {
"sortBy": "name"
}
},
{
"name": "options",
"value": {
"$rpc": "absent"
}
}
],
"settlement": {
"status": "fulfilled",
"startedAt": 0,
"settledAt": 0,
"value": {
"id": "frame-3",
"ok": true,
"result": {
"error": {
"message": "inner refused"
},
"ok": false
}
}
}
},
"c49dcad120d2": {
"name": "view",
"ordinal": 4,
"ordinal": 7,
"value": {
"agentActivityDisplayMode": "compact",
"collapsedGroups": [],
"compactWorktreeCards": true,
"filters": {
"alwaysShowDefaultBranchWorkspace": true,
"filterRepoIds": [],
@@ -419,6 +508,10 @@
}
},
"groupBy": "repo",
"newCardStyle": false,
"projectOrderBy": {
"$rpc": "null"
},
"sharesOtherClientFilter": false,
"sortBy": "name",
"workspaceStatuses": [
@@ -446,12 +539,65 @@
"id": "completed",
"label": "Done"
}
],
"worktreeCardProperties": [
"status",
"unread",
"issue",
"linear-issue",
"jira-issue",
"pr",
"automation",
"cli",
"comment",
"ports",
"inline-agents",
"host"
]
}
},
"c886cd24aa84": {
"c54bbdf6b784": {
"name": "ui.set#1",
"ordinal": 8,
"args": [
{
"name": "method",
"value": "ui.set"
},
{
"name": "params",
"value": {
"sortBy": "name"
}
},
{
"name": "options",
"value": {
"$rpc": "absent"
}
}
],
"settlement": {
"status": "rejected",
"startedAt": 0,
"settledAt": 0,
"error": {
"category": "Error",
"message": "",
"isRpcDeliveryUnknown": true
}
}
},
"cd9c5de9f2aa": {
"name": "ui.set#1",
"ordinal": 9,
"json": "{\"id\":\"frame-3\",\"deviceToken\":\"recording-device\",\"method\":\"ui.set\",\"params\":{\"sortBy\":\"name\"}}"
},
"dc007face350": {
"view": {
"agentActivityDisplayMode": "compact",
"collapsedGroups": [],
"compactWorktreeCards": false,
"filters": {
"alwaysShowDefaultBranchWorkspace": true,
"filterRepoIds": [],
@@ -466,6 +612,10 @@
}
},
"groupBy": "repo",
"newCardStyle": false,
"projectOrderBy": {
"$rpc": "null"
},
"sharesOtherClientFilter": {
"$rpc": "null"
},
@@ -495,12 +645,26 @@
"id": "completed",
"label": "Done"
}
],
"worktreeCardProperties": [
"status",
"unread",
"issue",
"linear-issue",
"jira-issue",
"pr",
"automation",
"cli",
"comment",
"ports",
"inline-agents",
"host"
]
}
},
"e46f4b760153": {
"e524e828664d": {
"name": "ui.set#1",
"ordinal": 5,
"ordinal": 8,
"args": [
{
"name": "method",
@@ -524,80 +688,23 @@
"startedAt": 0,
"settledAt": 0,
"value": {
"id": "frame-2",
"id": "frame-3",
"ok": true
}
}
},
"e51c608f6ed4": {
"name": "view",
"ordinal": 3,
"value": {
"collapsedGroups": [],
"filters": {
"alwaysShowDefaultBranchWorkspace": true,
"filterRepoIds": [],
"hideAutomationGeneratedWorkspaces": false,
"hideCliCreatedWorkspaces": false,
"hideDefaultBranchWorkspace": false,
"hideDetachedHeadWorkspaces": false,
"hideSleepingWorkspaces": true,
"hideWorkspacesFromOtherDevices": false,
"visibleHostIds": {
"$rpc": "null"
}
},
"groupBy": "repo",
"sharesOtherClientFilter": false,
"sortBy": "name",
"workspaceStatuses": [
{
"color": "neutral",
"icon": "circle",
"id": "todo",
"label": "Todo"
},
{
"color": "conductor-progress",
"icon": "conductor-progress",
"id": "in-progress",
"label": "In progress"
},
{
"color": "conductor-review",
"icon": "conductor-review",
"id": "in-review",
"label": "In review"
},
{
"color": "conductor-done",
"icon": "conductor-done",
"id": "completed",
"label": "Done"
}
]
}
},
"eb79a9b3682a": {
"status": "fulfilled",
"startedAt": 0,
"settledAt": 0,
"value": {
"$rpc": "undefined"
}
},
"f5f6301d0717": {
"name": "ui.set#1",
"ordinal": 5,
"e55533c6e358": {
"name": "settings.get#1",
"ordinal": 2,
"args": [
{
"name": "method",
"value": "ui.set"
"value": "settings.get"
},
{
"name": "params",
"value": {
"sortBy": "name"
"$rpc": "absent"
}
},
{
@@ -615,17 +722,29 @@
"id": "frame-2",
"ok": true,
"result": {
"error": {
"message": "inner refused"
},
"ok": false
"settings": {
"compactWorktreeCards": true
}
}
}
}
},
"f626c3f35574": {
"eb79a9b3682a": {
"status": "fulfilled",
"startedAt": 0,
"settledAt": 0,
"value": {
"$rpc": "undefined"
}
},
"ec336f23ef91": {
"name": "ui.get#1",
"ordinal": 3,
"json": "{\"id\":\"frame-1\",\"deviceToken\":\"recording-device\",\"method\":\"ui.get\"}"
},
"eebc1c0c9b3f": {
"name": "ui.set#1",
"ordinal": 5,
"ordinal": 8,
"args": [
{
"name": "method",
@@ -649,12 +768,45 @@
"startedAt": 0,
"settledAt": 0,
"value": {
"error": {
"code": "refused",
"message": "outer refused"
},
"id": "frame-2",
"ok": false
"id": "frame-3",
"ok": true,
"result": {
"$rpc": "null"
}
}
}
},
"f56221cb03b8": {
"name": "ui.set#1",
"ordinal": 8,
"args": [
{
"name": "method",
"value": "ui.set"
},
{
"name": "params",
"value": {
"sortBy": "name"
}
},
{
"name": "options",
"value": {
"$rpc": "absent"
}
}
],
"settlement": {
"status": "fulfilled",
"startedAt": 0,
"settledAt": 0,
"value": {
"id": "frame-3",
"ok": true,
"result": {
"ok": true
}
}
}
}
@@ -665,168 +817,168 @@
{
"id": "host-view-settings-sync.prelude:ui-pending",
"observation": {
"sender": ["985caf5772e9"],
"payloads": ["582477c8e0af"],
"sender": ["985caf5772e9", "51d4cb56be85"],
"payloads": ["ec336f23ef91", "1c2564f1daca"],
"settlements": {
"mount": "eb79a9b3682a",
"sync": "9270aeb7d9c6"
},
"state": "c886cd24aa84",
"state": "dc007face350",
"effects": []
}
},
{
"id": "host-view-settings-sync.normal:settled",
"observation": {
"sender": ["651e8014c9ff", "5b5eda740798"],
"payloads": ["582477c8e0af", "3337ab8c1ffc"],
"sender": ["651e8014c9ff", "e55533c6e358", "f56221cb03b8"],
"payloads": ["ec336f23ef91", "1c2564f1daca", "cd9c5de9f2aa"],
"settlements": {
"mount": "eb79a9b3682a",
"sync": "eb79a9b3682a",
"sort": "eb79a9b3682a"
},
"state": "7bbe6d34ee5c",
"effects": ["e51c608f6ed4", "c81e3e604f1c"]
"state": "424e4d52d6fa",
"effects": ["6bab186637b0", "8369a6f35fda", "c49dcad120d2"]
}
},
{
"id": "host-view-settings-sync.result-absent:settled",
"observation": {
"sender": ["651e8014c9ff", "e46f4b760153"],
"payloads": ["582477c8e0af", "3337ab8c1ffc"],
"sender": ["651e8014c9ff", "e55533c6e358", "e524e828664d"],
"payloads": ["ec336f23ef91", "1c2564f1daca", "cd9c5de9f2aa"],
"settlements": {
"mount": "eb79a9b3682a",
"sync": "eb79a9b3682a",
"sort": "eb79a9b3682a"
},
"state": "7bbe6d34ee5c",
"effects": ["e51c608f6ed4", "c81e3e604f1c"]
"state": "424e4d52d6fa",
"effects": ["6bab186637b0", "8369a6f35fda", "c49dcad120d2"]
}
},
{
"id": "host-view-settings-sync.result-null:settled",
"observation": {
"sender": ["651e8014c9ff", "63cc37872320"],
"payloads": ["582477c8e0af", "3337ab8c1ffc"],
"sender": ["651e8014c9ff", "e55533c6e358", "eebc1c0c9b3f"],
"payloads": ["ec336f23ef91", "1c2564f1daca", "cd9c5de9f2aa"],
"settlements": {
"mount": "eb79a9b3682a",
"sync": "eb79a9b3682a",
"sort": "eb79a9b3682a"
},
"state": "7bbe6d34ee5c",
"effects": ["e51c608f6ed4", "c81e3e604f1c"]
"state": "424e4d52d6fa",
"effects": ["6bab186637b0", "8369a6f35fda", "c49dcad120d2"]
}
},
{
"id": "host-view-settings-sync.inner-ok-missing:settled",
"observation": {
"sender": ["651e8014c9ff", "98e0a2b6883b"],
"payloads": ["582477c8e0af", "3337ab8c1ffc"],
"sender": ["651e8014c9ff", "e55533c6e358", "4cc5a473bafa"],
"payloads": ["ec336f23ef91", "1c2564f1daca", "cd9c5de9f2aa"],
"settlements": {
"mount": "eb79a9b3682a",
"sync": "eb79a9b3682a",
"sort": "eb79a9b3682a"
},
"state": "7bbe6d34ee5c",
"effects": ["e51c608f6ed4", "c81e3e604f1c"]
"state": "424e4d52d6fa",
"effects": ["6bab186637b0", "8369a6f35fda", "c49dcad120d2"]
}
},
{
"id": "host-view-settings-sync.inner-false-string-error:settled",
"observation": {
"sender": ["651e8014c9ff", "83f9f945965e"],
"payloads": ["582477c8e0af", "3337ab8c1ffc"],
"sender": ["651e8014c9ff", "e55533c6e358", "8ab052f2be74"],
"payloads": ["ec336f23ef91", "1c2564f1daca", "cd9c5de9f2aa"],
"settlements": {
"mount": "eb79a9b3682a",
"sync": "eb79a9b3682a",
"sort": "eb79a9b3682a"
},
"state": "7bbe6d34ee5c",
"effects": ["e51c608f6ed4", "c81e3e604f1c"]
"state": "424e4d52d6fa",
"effects": ["6bab186637b0", "8369a6f35fda", "c49dcad120d2"]
}
},
{
"id": "host-view-settings-sync.inner-false-object-error:settled",
"observation": {
"sender": ["651e8014c9ff", "f5f6301d0717"],
"payloads": ["582477c8e0af", "3337ab8c1ffc"],
"sender": ["651e8014c9ff", "e55533c6e358", "c0ca5ec08d54"],
"payloads": ["ec336f23ef91", "1c2564f1daca", "cd9c5de9f2aa"],
"settlements": {
"mount": "eb79a9b3682a",
"sync": "eb79a9b3682a",
"sort": "eb79a9b3682a"
},
"state": "7bbe6d34ee5c",
"effects": ["e51c608f6ed4", "c81e3e604f1c"]
"state": "424e4d52d6fa",
"effects": ["6bab186637b0", "8369a6f35fda", "c49dcad120d2"]
}
},
{
"id": "host-view-settings-sync.outer-refused:settled",
"observation": {
"sender": ["651e8014c9ff", "f626c3f35574"],
"payloads": ["582477c8e0af", "3337ab8c1ffc"],
"sender": ["651e8014c9ff", "e55533c6e358", "1071e4898c81"],
"payloads": ["ec336f23ef91", "1c2564f1daca", "cd9c5de9f2aa"],
"settlements": {
"mount": "eb79a9b3682a",
"sync": "eb79a9b3682a",
"sort": "eb79a9b3682a"
},
"state": "7bbe6d34ee5c",
"effects": ["e51c608f6ed4", "c81e3e604f1c"]
"state": "424e4d52d6fa",
"effects": ["6bab186637b0", "8369a6f35fda", "c49dcad120d2"]
}
},
{
"id": "host-view-settings-sync.outer-refused-no-message:settled",
"observation": {
"sender": ["651e8014c9ff", "674196929e83"],
"payloads": ["582477c8e0af", "3337ab8c1ffc"],
"sender": ["651e8014c9ff", "e55533c6e358", "b5cabaf13de8"],
"payloads": ["ec336f23ef91", "1c2564f1daca", "cd9c5de9f2aa"],
"settlements": {
"mount": "eb79a9b3682a",
"sync": "eb79a9b3682a",
"sort": "eb79a9b3682a"
},
"state": "7bbe6d34ee5c",
"effects": ["e51c608f6ed4", "c81e3e604f1c"]
"state": "424e4d52d6fa",
"effects": ["6bab186637b0", "8369a6f35fda", "c49dcad120d2"]
}
},
{
"id": "host-view-settings-sync.method-not-found:settled",
"observation": {
"sender": ["651e8014c9ff", "c2801ac49075"],
"payloads": ["582477c8e0af", "3337ab8c1ffc"],
"sender": ["651e8014c9ff", "e55533c6e358", "793af4fc3c95"],
"payloads": ["ec336f23ef91", "1c2564f1daca", "cd9c5de9f2aa"],
"settlements": {
"mount": "eb79a9b3682a",
"sync": "eb79a9b3682a",
"sort": "eb79a9b3682a"
},
"state": "7bbe6d34ee5c",
"effects": ["e51c608f6ed4", "c81e3e604f1c"]
"state": "424e4d52d6fa",
"effects": ["6bab186637b0", "8369a6f35fda", "c49dcad120d2"]
}
},
{
"id": "host-view-settings-sync.transport-rejection:settled",
"observation": {
"sender": ["651e8014c9ff", "0dcbfe528b58"],
"payloads": ["582477c8e0af", "3337ab8c1ffc"],
"sender": ["651e8014c9ff", "e55533c6e358", "b2b58c2d731f"],
"payloads": ["ec336f23ef91", "1c2564f1daca", "cd9c5de9f2aa"],
"settlements": {
"mount": "eb79a9b3682a",
"sync": "eb79a9b3682a",
"sort": "eb79a9b3682a"
},
"state": "7bbe6d34ee5c",
"effects": ["e51c608f6ed4", "c81e3e604f1c"]
"state": "424e4d52d6fa",
"effects": ["6bab186637b0", "8369a6f35fda", "c49dcad120d2"]
}
},
{
"id": "host-view-settings-sync.transport-rejection-no-message:settled",
"observation": {
"sender": ["651e8014c9ff", "506504c485ac"],
"payloads": ["582477c8e0af", "3337ab8c1ffc"],
"sender": ["651e8014c9ff", "e55533c6e358", "c54bbdf6b784"],
"payloads": ["ec336f23ef91", "1c2564f1daca", "cd9c5de9f2aa"],
"settlements": {
"mount": "eb79a9b3682a",
"sync": "eb79a9b3682a",
"sort": "eb79a9b3682a"
},
"state": "7bbe6d34ee5c",
"effects": ["e51c608f6ed4", "c81e3e604f1c"]
"state": "424e4d52d6fa",
"effects": ["6bab186637b0", "8369a6f35fda", "c49dcad120d2"]
}
}
]
@@ -7962,6 +7962,20 @@
}
}
},
{
"complete": "settings.get#1",
"params": {
"$undefined": true
},
"reply": {
"ok": true,
"result": {
"settings": {
"compactWorktreeCards": true
}
}
}
},
{
"action": "sort",
"id": "sort"
+1
View File
@@ -16,6 +16,7 @@ type Props = {
// letting it take touches, draw a second backdrop, or keyboard-lift.
interactive?: boolean
zIndex?: number
scrollResetKey?: string | null
}
const SHOWN = 'shown'
@@ -0,0 +1,69 @@
import { Pressable, StyleSheet, Text, View, type StyleProp, type ViewStyle } from 'react-native'
import { colors, radii, spacing, typography } from '../theme/mobile-theme'
export type MobileSegment<Value extends string> = { value: Value; label: string }
type Props<Value extends string> = {
segments: readonly MobileSegment<Value>[]
/** A value outside the segments selects none, e.g. one a newer host sent. */
selected: string | undefined
onSelect: (value: Value) => void
/** Prefixes each segment's testID with this, then the segment's value. */
testIDPrefix?: string
/** Segments share the full width instead of sizing to their labels. */
fill?: boolean
/** The track; its default color suits a panel-colored row. */
style?: StyleProp<ViewStyle>
}
/** A pick-one pill control; each segment reads as a radio button. */
export function MobileSegmentedControl<Value extends string>(props: Props<Value>) {
return (
<View style={[styles.segmented, props.style]} accessibilityRole="radiogroup">
{props.segments.map((segment) => {
const active = segment.value === props.selected
return (
<Pressable
key={segment.value}
accessibilityRole="radio"
aria-checked={active}
accessibilityLabel={segment.label}
testID={props.testIDPrefix ? `${props.testIDPrefix}${segment.value}` : undefined}
onPress={() => props.onSelect(segment.value)}
style={[
styles.segment,
props.fill && styles.segmentFill,
active && styles.segmentActive
]}
>
<Text
style={[styles.segmentText, active && styles.segmentTextActive]}
numberOfLines={1}
>
{segment.label}
</Text>
</Pressable>
)
})}
</View>
)
}
const styles = StyleSheet.create({
segmented: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: colors.bgBase,
borderRadius: radii.button,
padding: 2
},
segment: {
paddingHorizontal: spacing.md,
paddingVertical: 6,
borderRadius: radii.button - 1
},
segmentFill: { flex: 1, alignItems: 'center', paddingHorizontal: spacing.xs },
segmentActive: { backgroundColor: colors.bgRaised },
segmentText: { fontSize: typography.metaSize, color: colors.textSecondary, fontWeight: '600' },
segmentTextActive: { color: colors.textPrimary }
})
+26
View File
@@ -0,0 +1,26 @@
import { Switch } from 'react-native'
import { colors } from '../theme/mobile-theme'
export type MobileSwitchProps = {
value: boolean
onValueChange: (value: boolean) => void
accessibilityLabel: string
disabled?: boolean
testID?: string
}
// react-native-web paints an "on" thumb with its own teal unless given activeThumbColor, a prop
// React Native's types do not declare.
const ON_THUMB = { activeThumbColor: colors.textPrimary }
/** The app's switch: neutral track and thumb, no platform accent. */
export function MobileSwitch(props: MobileSwitchProps) {
return (
<Switch
{...props}
{...ON_THUMB}
trackColor={{ false: colors.bgRaised, true: colors.textSecondary }}
thumbColor={colors.textPrimary}
/>
)
}
@@ -70,6 +70,21 @@ describe('WorktreeAgentList', () => {
expect(renderer!.root.findAllByType('WorktreeAgentRow')).toHaveLength(3)
})
it("shows every agent row with no summary under the desktop's Full list layout", async () => {
await act(async () => {
renderer = create(
createElement(WorktreeAgentList, {
agents: [agent('agent-1'), agent('agent-2'), agent('agent-3')],
now: 2_000,
unvisited: false,
fullList: true
})
)
})
expect(renderer!.root.findAllByType('Pressable')).toHaveLength(0)
expect(renderer!.root.findAllByType('WorktreeAgentRow')).toHaveLength(3)
})
it('keeps a single agent visible without a redundant disclosure control', async () => {
await act(async () => {
renderer = create(
+4 -2
View File
@@ -9,19 +9,21 @@ type Props = {
agents: RuntimeWorktreeAgentRow[]
now: number
unvisited: boolean
/** The desktop's "Full list" agent activity layout: every row, no summary. */
fullList?: boolean
}
// Inline agent list for one worktree row: flattens the spawn lineage and renders
// a depth-indented WorktreeAgentRow per agent, mirroring the desktop sidebar's
// WorktreeCardAgents.
export function WorktreeAgentList({ agents, now, unvisited }: Props) {
export function WorktreeAgentList({ agents, now, unvisited, fullList = false }: Props) {
const nodes = useMemo(() => flattenAgentRowLineage(agents), [agents])
const summaryAgents = useMemo(() => {
const lineage = buildAgentRowLineageTree(agents)
return lineage.childrenByParentPaneKey.size > 0 ? lineage.rootRows : agents
}, [agents])
const [expanded, setExpanded] = useState(false)
const usesSummary = summaryAgents.length > 1
const usesSummary = !fullList && summaryAgents.length > 1
return (
<View style={styles.list}>
+93 -4
View File
@@ -13,10 +13,16 @@ import type { RuntimeWorktreeAgentRow } from '../../../src/shared/runtime-types'
import { WorktreeAgentRow } from './WorktreeAgentRow'
import type { Worktree } from '../worktree/workspace-list-types'
import { WorktreeListRow } from './WorktreeListRow'
import {
DEFAULT_WORKTREE_CARD_PROPERTIES,
getWorktreeCardDetails,
type WorktreeCardDetails
} from '../../../src/shared/worktree/card-properties'
const { agentSpinnerRender, agentStateDotRender } = vi.hoisted(() => ({
const { agentSpinnerRender, agentStateDotRender, metaGlyphsRender } = vi.hoisted(() => ({
agentSpinnerRender: vi.fn(),
agentStateDotRender: vi.fn()
agentStateDotRender: vi.fn(),
metaGlyphsRender: vi.fn()
}))
vi.mock('react-native', () => ({
@@ -54,7 +60,10 @@ vi.mock('./MobileRepoIcon', () => ({ MobileRepoIcon: () => null }))
vi.mock('./WorktreeAgentList', () => ({ WorktreeAgentList: () => null }))
vi.mock('./WorktreeMetaGlyphs', () => ({
prStateColor: () => '#000000',
WorktreeMetaGlyphs: () => null
WorktreeMetaGlyphs: (props: unknown) => {
metaGlyphsRender(props)
return null
}
}))
type TestItem = Worktree & { status: NonNullable<Worktree['status']> }
@@ -62,7 +71,20 @@ type TestItem = Worktree & { status: NonNullable<Worktree['status']> }
const stableRepoIcon = { type: 'emoji', emoji: 'o' } as const
let updateSibling: Dispatch<SetStateAction<number>> = () => undefined
function ListRowHarness({ item, now }: { item: TestItem; now: number }) {
const ALL_DETAILS = getWorktreeCardDetails(DEFAULT_WORKTREE_CARD_PROPERTIES, {
compactWorktreeCards: false,
newCardStyle: false
})
function ListRowHarness({
item,
now,
cardDetails = ALL_DETAILS
}: {
item: TestItem
now: number
cardDetails?: WorktreeCardDetails
}) {
const [sibling, setSibling] = useState(0)
updateSibling = setSibling
const onPress = useCallback(() => undefined, [])
@@ -82,6 +104,8 @@ function ListRowHarness({ item, now }: { item: TestItem; now: number }) {
repoColor: '#000000',
repoIcon: stableRepoIcon,
hideRepo: false,
cardDetails,
agentActivityDisplayMode: 'compact',
status: item.status,
onPress,
onLongPress,
@@ -132,6 +156,7 @@ describe('memoized worktree rows', () => {
beforeEach(() => {
agentSpinnerRender.mockClear()
agentStateDotRender.mockClear()
metaGlyphsRender.mockClear()
})
afterEach(() => {
@@ -148,6 +173,8 @@ describe('memoized worktree rows', () => {
now: 2_000,
repoColor: '#000000',
hideRepo,
cardDetails: ALL_DETAILS,
agentActivityDisplayMode: 'compact',
status: 'active',
onPress: () => undefined
})
@@ -202,6 +229,9 @@ describe('memoized worktree rows', () => {
now: overrides.now ?? 2_000,
repoColor: '#000000',
repoIcon: { type: 'emoji', emoji: 'o' },
// A fresh but equal details object each render, as a parent re-render makes.
cardDetails: { ...ALL_DETAILS },
agentActivityDisplayMode: 'compact',
status: item.status,
onPress: overrides.onPress ?? onPress
})
@@ -345,4 +375,63 @@ describe('memoized worktree rows', () => {
expect(textNodes()).not.toContain('Local Mac')
expect(renderer!.root.findAllByType('Monitor' as never)).toHaveLength(0)
})
it("leaves out the PR, host and agents the desktop's card properties turn off", async () => {
const item: TestItem = {
...baseItem,
hostId: 'ssh:ssh-1',
hostContextLabel: 'openclaw',
linkedPR: { number: 7, state: 'open' }
}
const shown = () => ({
pr: renderer!.root.findAll((node) => String(node.type) === 'GitPullRequest').length,
host: renderer!.root.findAll((node) => String(node.type) === 'Server').length
})
await act(async () => {
renderer = create(createElement(ListRowHarness, { item, now: 2_000 }))
})
expect(shown()).toEqual({ pr: 1, host: 1 })
await act(async () =>
renderer!.update(
createElement(ListRowHarness, {
item,
now: 2_000,
cardDetails: getWorktreeCardDetails(['status', 'unread'], {
compactWorktreeCards: false,
newCardStyle: false
})
})
)
)
expect(shown()).toEqual({ pr: 0, host: 0 })
})
it('passes the Jira, automation and CLI facts the card properties show', async () => {
const item: TestItem = {
...baseItem,
automationCreated: true,
cliCreated: true,
linkedItems: [{ provider: 'jira', type: 'issue', number: 12, jiraIdentifier: 'ORCA-12' }]
}
const lastPresence = () => metaGlyphsRender.mock.lastCall?.[0].presence
await act(async () => {
renderer = create(createElement(ListRowHarness, { item, now: 2_000 }))
})
expect(lastPresence()).toMatchObject({ jira: true, automation: true, cli: true })
await act(async () =>
renderer!.update(
createElement(ListRowHarness, {
item,
now: 2_000,
cardDetails: getWorktreeCardDetails(['status', 'unread'], {
compactWorktreeCards: false,
newCardStyle: false
})
})
)
)
expect(lastPresence()).toMatchObject({ jira: false, automation: false, cli: false })
})
})
+18 -11
View File
@@ -5,7 +5,10 @@ import type { MobileRenderableRepoIcon } from '../host-screen/host-screen-reply-
import { triggerMediumImpact } from '../platform/haptics'
import { colors, radii, spacing, typography } from '../theme/mobile-theme'
import type { Worktree } from '../worktree/workspace-list-types'
import type { WorktreeCardDetails } from '../../../src/shared/worktree/card-properties'
import type { AgentActivityDisplayMode } from '../../../src/shared/ui-chrome-types'
import { areStructurallyEqual } from '../worktree/structural-equality'
import { getWorktreeMetaGlyphPresence } from '../worktree/worktree-meta-glyph-presence'
import { AgentSpinner } from './AgentSpinner'
import { HostKindIcon } from './HostKindIcon'
import { MobileRepoIcon } from './MobileRepoIcon'
@@ -29,6 +32,9 @@ type Props = {
// When the list is already grouped under this repo's section header, the row
// omits its own repo icon+name to avoid the redundant "📁 orca" on every row.
hideRepo?: boolean
/** What the desktop's shared Card layout and properties show. */
cardDetails: WorktreeCardDetails
agentActivityDisplayMode: AgentActivityDisplayMode
status: WorktreeRollupStatus
onPress: (item: Worktree) => void
onLongPress?: (item: Worktree) => void
@@ -42,6 +48,8 @@ function WorktreeListRowComponent({
repoColor,
repoIcon,
hideRepo = false,
cardDetails,
agentActivityDisplayMode,
status,
onPress,
onLongPress,
@@ -98,7 +106,7 @@ function WorktreeListRowComponent({
>
{item.displayName || item.repo}
</Text>
{item.linkedPR && (
{cardDetails.pr && item.linkedPR && (
<View style={styles.prBadge}>
<GitPullRequest size={10} color={prStateColor(item.linkedPR.state)} />
<Text style={[styles.prNumber, { color: prStateColor(item.linkedPR.state) }]}>
@@ -111,13 +119,7 @@ function WorktreeListRowComponent({
<Text style={styles.folderBadgeText}>Folder</Text>
</View>
)}
<WorktreeMetaGlyphs
comment={item.comment}
linkedLinearIssue={item.linkedLinearIssue}
linkedGitLabMR={item.linkedGitLabMR}
linkedIssue={item.linkedIssue}
linkedGitLabIssue={item.linkedGitLabIssue}
/>
<WorktreeMetaGlyphs presence={getWorktreeMetaGlyphPresence(item, cardDetails)} />
</View>
<View style={styles.worktreeMetaRow}>
{lineageDepth > 0 && (
@@ -126,7 +128,7 @@ function WorktreeListRowComponent({
<Text style={styles.childBadgeText}>Child</Text>
</View>
)}
{item.hostContextLabel ? (
{cardDetails.host && item.hostContextLabel ? (
<View
style={[styles.childBadge, styles.hostBadge]}
accessibilityLabel={[item.hostContextLabel, item.hostContextHealthLabel]
@@ -162,8 +164,13 @@ function WorktreeListRowComponent({
</View>
{/* Only agents get a secondary activity line, matching desktop. A plain
terminal's shell-output tail is intentionally not surfaced here. */}
{item.agents && item.agents.length > 0 ? (
<WorktreeAgentList agents={item.agents} now={now} unvisited={item.unread} />
{cardDetails.inlineAgents && item.agents && item.agents.length > 0 ? (
<WorktreeAgentList
agents={item.agents}
now={now}
unvisited={item.unread}
fullList={agentActivityDisplayMode === 'full'}
/>
) : null}
{lineageChildCount > 0 && onToggleLineage ? (
<Pressable
@@ -0,0 +1,64 @@
import { createElement } from 'react'
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
import { afterEach, describe, expect, it, vi } from 'vitest'
vi.mock('react-native', () => ({
StyleSheet: { create: <T,>(styles: T) => styles },
Text: 'Text',
View: 'View'
}))
vi.mock('lucide-react-native', () => ({
CalendarClock: 'CalendarClock',
CircleDot: 'CircleDot',
GitMerge: 'GitMerge',
SquareTerminal: 'SquareTerminal',
StickyNote: 'StickyNote'
}))
vi.mock('./TaskProviderLogo', () => ({ TaskProviderLogo: 'TaskProviderLogo' }))
import { WorktreeMetaGlyphs } from './WorktreeMetaGlyphs'
let renderer: ReactTestRenderer | null = null
afterEach(() => {
act(() => renderer?.unmount())
renderer = null
})
const NONE = {
notes: false,
automation: false,
cli: false,
issue: false,
jira: false,
linear: false,
gitLabMR: false
}
function glyphs(presence: Partial<typeof NONE>): string[] {
act(() => {
renderer = create(createElement(WorktreeMetaGlyphs, { presence: { ...NONE, ...presence } }))
})
return renderer!.root
.findAll((node) => typeof node.type === 'string' && String(node.type) !== 'View')
.map((node) =>
String(node.type) === 'TaskProviderLogo'
? `logo:${String(node.props.provider)}`
: String(node.type)
)
}
describe('WorktreeMetaGlyphs', () => {
it('draws the glyphs in the desktop order', () => {
expect(
glyphs({ notes: true, automation: true, cli: true, issue: true, jira: true, linear: true })
).toEqual(['StickyNote', 'CalendarClock', 'SquareTerminal', 'CircleDot', 'logo:jira', 'Text'])
})
it('draws nothing when the row has none of them', () => {
act(() => {
renderer = create(createElement(WorktreeMetaGlyphs, { presence: NONE }))
})
expect(renderer!.toJSON()).toBeNull()
})
})
+14 -28
View File
@@ -1,6 +1,8 @@
import { CircleDot, GitMerge, StickyNote } from 'lucide-react-native'
import { CalendarClock, CircleDot, GitMerge, SquareTerminal, StickyNote } from 'lucide-react-native'
import { StyleSheet, Text, View } from 'react-native'
import { colors } from '../theme/mobile-theme'
import { TaskProviderLogo } from './TaskProviderLogo'
import type { WorktreeMetaGlyphPresence } from '../worktree/worktree-meta-glyph-presence'
import { prStateToken } from './pr-state-token'
import { statusColor } from './pr-sidebar/pr-sidebar-status-color'
@@ -11,37 +13,21 @@ export function prStateColor(state: string): string {
return statusColor(prStateToken(state))
}
type Props = {
comment?: string | null
linkedLinearIssue?: string | null
linkedGitLabMR?: number | null
linkedIssue?: number | null
linkedGitLabIssue?: number | null
}
// Presence glyphs for linked notes / Linear / GitLab MR / issue, matching the
// desktop WorktreeCardMetaBadges row. Mobile shows presence only; the full
// detail (title/state/labels) is a follow-up detail sheet.
export function WorktreeMetaGlyphs({
comment,
linkedLinearIssue,
linkedGitLabMR,
linkedIssue,
linkedGitLabIssue
}: Props) {
const hasNotes = (comment ?? '').trim().length > 0
const hasLinear = Boolean(linkedLinearIssue)
const hasGitLabMR = linkedGitLabMR != null
const hasIssue = linkedIssue != null || linkedGitLabIssue != null
if (!hasNotes && !hasLinear && !hasGitLabMR && !hasIssue) {
// Presence glyphs in the desktop WorktreeCardMetaBadges order. Mobile shows presence only; the
// full detail (title/state/labels) is a follow-up detail sheet.
export function WorktreeMetaGlyphs({ presence }: { presence: WorktreeMetaGlyphPresence }) {
if (!Object.values(presence).some(Boolean)) {
return null
}
return (
<View style={styles.metaGlyphs}>
{hasNotes && <StickyNote size={11} color={colors.textMuted} />}
{hasIssue && <CircleDot size={11} color={colors.textMuted} />}
{hasLinear && <Text style={styles.linearGlyph}>L</Text>}
{hasGitLabMR && <GitMerge size={11} color={colors.textMuted} />}
{presence.notes && <StickyNote size={11} color={colors.textMuted} />}
{presence.automation && <CalendarClock size={11} color={colors.textMuted} />}
{presence.cli && <SquareTerminal size={11} color={colors.textMuted} />}
{presence.issue && <CircleDot size={11} color={colors.textMuted} />}
{presence.jira && <TaskProviderLogo provider="jira" size={10} color={colors.textMuted} />}
{presence.linear && <Text style={styles.linearGlyph}>L</Text>}
{presence.gitLabMR && <GitMerge size={11} color={colors.textMuted} />}
</View>
)
}
@@ -63,6 +63,11 @@ export const bottomDrawerStyles = StyleSheet.create({
staticContentFill: {
flex: 1
},
// Lets the drag wrapper give up height to the sheet's max, so its ScrollView scrolls.
scrollFrame: {
flexShrink: 1,
minHeight: 0
},
bottomExtension: {
position: 'absolute',
bottom: -500,
@@ -47,6 +47,8 @@ export type MountedBottomDrawerProps = {
// preserved) but must not take touches, stack backdrops, or keyboard-lift.
interactive?: boolean
zIndex?: number
/** When this changes to a non-null value the content scrolls back to its top, e.g. a new page. */
scrollResetKey?: string | null
}
export function MountedBottomDrawer({
@@ -58,12 +60,20 @@ export function MountedBottomDrawer({
contentScrollable = true,
fillAvailable = false,
interactive = true,
zIndex = 1000
zIndex = 1000,
scrollResetKey = null
}: MountedBottomDrawerProps) {
const translateY = useSharedValue(0)
const progress = useSharedValue(0)
const keyboardOffset = useSharedValue(0)
const scrollOffsetY = useSharedValue(0)
const scrollRef = useRef<ScrollView>(null)
useEffect(() => {
if (scrollResetKey !== null) {
scrollRef.current?.scrollTo({ y: 0, animated: false })
scrollOffsetY.value = 0
}
}, [scrollOffsetY, scrollResetKey])
const contentDragStartY = useSharedValue(0)
const contentDragCanDismiss = useSharedValue(false)
// Why: fill mode needs the keyboard inset in React layout (not only the
@@ -336,9 +346,10 @@ export function MountedBottomDrawer({
<>
{handle}
<GestureDetector gesture={contentPanGesture}>
<Animated.View collapsable={false}>
<Animated.View collapsable={false} style={styles.scrollFrame}>
<GestureDetector gesture={scrollGesture}>
<Animated.ScrollView
ref={scrollRef}
bounces={false}
keyboardShouldPersistTaps="handled"
onScroll={scrollHandler}
@@ -355,6 +366,7 @@ export function MountedBottomDrawer({
<>
{handle}
<ScrollView
ref={scrollRef}
bounces={false}
keyboardShouldPersistTaps="handled"
showsVerticalScrollIndicator={false}
@@ -0,0 +1,238 @@
import { createElement } from 'react'
import { act, create } from 'react-test-renderer'
import { describe, expect, it, vi } from 'vitest'
// The desktop's server-list feed loads react-native; its timer never runs without a subscriber.
vi.mock('react-native', () => ({}))
vi.mock('expo-router', () => ({ useFocusEffect: () => {} }))
vi.mock('../cache/worktree-cache', () => ({
getCachedWorktrees: () => null,
setCachedWorktrees: () => {}
}))
vi.mock('../storage/preferences', () => ({
loadPinnedIds: async () => new Set(),
savePinnedIds: async () => {}
}))
vi.mock('../transport/host-store', () => ({
loadHosts: async () => [],
updateLastConnected: async () => {}
}))
vi.mock('../worktree/host-worktree-refresh', () => ({ startHostWorktreeRefresh: () => () => {} }))
vi.mock('../transport/use-worktree-resync', () => ({
useWorktreeResync: () => ({ refreshing: false, onRefresh: async () => {} })
}))
import type { RpcClient } from '../transport/rpc-client'
import { FakeSession } from '../transport/mobile-endpoint-supervisor-test-fakes'
import type { RpcResponse } from '../transport/types'
import { useHostScreenIdentity } from './use-host-screen-identity'
import { useHostViewSettings } from './use-host-view-settings'
import { INITIAL_MOBILE_VIEW, type MobileViewState } from '../worktree/workspace-view-settings'
function reply(result: unknown): RpcResponse {
return { id: 'reply', ok: true, result, _meta: { runtimeId: 'runtime' } }
}
const noop = () => {}
type ClientRef = { current: RpcClient | null }
function screenState(clientRef: ClientRef, applied: boolean[]) {
const viewRef = { current: INITIAL_MOBILE_VIEW }
return {
clientRef,
viewRef,
repoMetadataFetchedAtRef: { current: 0 },
setView: (view: MobileViewState) => {
viewRef.current = view
},
setShowPinnedInGroups: (show: boolean) => applied.push(show),
...Object.fromEntries(
[
'setCatalogError',
'setDesktopRepos',
'setError',
'setHostLabelById',
'setHostHealthById',
'setHostName',
'setHostPlatform',
'setHostStoredDescriptor',
'setLastKnownWorktrees',
'setPinnedIds',
'setRepoHostIdByRepoId',
'setServedProjectSections',
'setWorktrees',
'setWorktreesLoaded'
].map((setter) => [setter, noop])
)
}
}
type Actions = ReturnType<typeof useHostViewSettings>
// One view sync: ui.get and settings.get go out together; `failing` rejects one of them.
async function syncWith(
settings: unknown,
options: {
failing?: 'ui.get' | 'settings.get'
onSent?: (clientRef: ClientRef, actions: Actions) => void
} = {}
): Promise<{ applied: boolean[]; view: MobileViewState; methods: string[] }> {
const client = new FakeSession('connected')
const clientRef: ClientRef = { current: client }
let actions: Actions | null = null
client.sendRequest.mockImplementation(async (method: string) => {
if (method === options.failing) {
throw new Error('transport closed')
}
if (method === 'ui.set') {
return reply(null)
}
options.onSent?.(clientRef, actions!)
return method === 'settings.get' ? reply({ settings }) : reply({ ui: { groupBy: 'none' } })
})
const applied: boolean[] = []
const state = screenState(clientRef, applied)
function Probe(): null {
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the view sync reads only the refs and setters screenState provides.
const hostState = state as unknown as Parameters<typeof useHostViewSettings>[0]['state']
actions = useHostViewSettings({ client, connState: 'connected', state: hostState })
return null
}
await act(async () => {
create(createElement(Probe))
})
await act(async () => {
await actions!.syncViewSettingsFromDesktop()
})
return {
applied,
view: state.viewRef.current,
methods: client.sendRequest.mock.calls
.map(([method]) => String(method))
.filter((method) => method !== 'ui.set')
.sort()
}
}
const ALL_ON = {
showPinnedWorktreesInGroups: true,
compactWorktreeCards: true,
experimentalNewWorktreeCardStyle: true
}
describe('the host list mirrors the desktop list settings', () => {
it('reads them with one settings.get beside ui.get, absent reading as off', async () => {
const on = await syncWith(ALL_ON)
expect(on.methods).toEqual(['settings.get', 'ui.get'])
expect(on.applied).toEqual([true])
expect(on.view).toMatchObject({
groupBy: 'none',
compactWorktreeCards: true,
newCardStyle: true
})
const off = await syncWith({})
expect(off.applied).toEqual([false])
expect(off.view).toMatchObject({ compactWorktreeCards: false, newCardStyle: false })
})
it('drops a reply that lands after the screen moved to another client', async () => {
const moveOn = (clientRef: ClientRef) => {
clientRef.current = new FakeSession('connected')
}
const moved = await syncWith(ALL_ON, { onSent: moveOn })
expect(moved.applied).toEqual([])
expect(moved.view).toBe(INITIAL_MOBILE_VIEW)
})
it('applies each read on its own when the other fails', async () => {
const noUi = await syncWith(ALL_ON, { failing: 'ui.get' })
expect(noUi.applied).toEqual([true])
expect(noUi.view).toMatchObject({ groupBy: 'repo', compactWorktreeCards: true })
const noSettings = await syncWith(ALL_ON, { failing: 'settings.get' })
expect(noSettings.applied).toEqual([])
expect(noSettings.view).toMatchObject({ groupBy: 'none', compactWorktreeCards: false })
})
it('keeps a local edit made during the read, while pinned placement still applies', async () => {
const edited = await syncWith(ALL_ON, {
onSent: (_clientRef, actions) => actions.handleSortChange('name')
})
expect(edited.applied).toEqual([true])
expect(edited.view).toMatchObject({
sortBy: 'name',
groupBy: 'repo',
compactWorktreeCards: false,
newCardStyle: false
})
})
it('resets to off when the screen is reused for another host', async () => {
const applied: boolean[] = []
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the identity hook reads only the refs and setters screenState provides.
const identityState = screenState({ current: null }, applied) as unknown as Parameters<
typeof useHostScreenIdentity
>[0]['state']
function Probe({ hostId }: { hostId: string }): null {
useHostScreenIdentity({ client: null, hostId, state: identityState })
return null
}
const renderer = create(createElement(Probe, { hostId: 'host-1' }))
await act(async () => {})
applied.length = 0
await act(async () => {
renderer.update(createElement(Probe, { hostId: 'host-2' }))
})
expect(applied).toEqual([false])
})
})
describe('Card layout from the phone', () => {
async function chooseCompact(settingsReply: RpcResponse) {
const client = new FakeSession('connected')
const clientRef: ClientRef = { current: client }
client.sendRequest.mockImplementation(async (method: string) =>
method === 'settings.update' ? settingsReply : reply(null)
)
const state = screenState(clientRef, [])
let actions: Actions | null = null
function Probe(): null {
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the hook reads only the refs and setters screenState provides.
const hostState = state as unknown as Parameters<typeof useHostViewSettings>[0]['state']
actions = useHostViewSettings({ client, connState: 'connected', state: hostState })
return null
}
await act(async () => {
create(createElement(Probe))
})
await act(async () => {
actions!.setCardLayout('compact')
})
return {
view: state.viewRef.current,
calls: client.sendRequest.mock.calls.map(([method, params]) => ({ method, params }))
}
}
it('writes the setting first, then the matching preset once the host accepts it', async () => {
const { view, calls } = await chooseCompact(reply({ settings: {} }))
expect(calls.map(({ method }) => method)).toEqual(['settings.update', 'ui.set'])
expect(calls[1]?.params).toEqual({
worktreeCardProperties: ['status'],
_worktreeCardModeDefaulted: true
})
expect(view).toMatchObject({ compactWorktreeCards: true, worktreeCardProperties: ['status'] })
})
it('leaves the preset and the phone alone when the host refuses the setting', async () => {
const refused: RpcResponse = {
id: 'reply',
ok: false,
error: { code: 'runtime_error', message: 'refused' },
_meta: { runtimeId: 'runtime' }
}
const { view, calls } = await chooseCompact(refused)
expect(calls.map(({ method }) => method)).toEqual(['settings.update'])
expect(view).toBe(INITIAL_MOBILE_VIEW)
})
})
@@ -49,7 +49,7 @@ function controllerWith(forceReconnectHost: ForceReconnect) {
pairingRejected: false,
relayHostReachability: 'connecting'
},
settings: { activeFilterCount: 0, selectedSortLabel: 'Recent' },
settings: { activeFilterCount: 0 },
state: { hostName: 'Desk', view: { groupBy: 'none' }, showSearch: false }
}
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the header reads only these members on a narrow, non-embedded layout; the rest of the controller is unreachable from it.
+13 -67
View File
@@ -1,14 +1,15 @@
import { Pressable, Text, View } from 'react-native'
import { workspaceGroupChipLabel } from '../worktree/workspace-list-picker-options'
import {
workspaceOptionsAccessibilityLabel,
WORKSPACE_OPTIONS_TITLE
} from '../worktree/workspace-options-copy'
import { WorkspaceOptionsCountBadge } from './workspace-options-count-badge'
import {
ChevronLeft,
Filter,
Layers,
List,
PanelLeftClose,
Plus,
Search,
SlidersHorizontal,
SquareTerminal,
UserCircle,
X
@@ -36,7 +37,6 @@ export function HostScreenHeader({ controller }: { controller: HostScreenControl
onHideSidebar,
reconnectAttempts,
relayRecovery,
settings,
state
} = controller
@@ -145,14 +145,10 @@ export function HostScreenHeader({ controller }: { controller: HostScreenControl
styles.embeddedFilterChip,
controller.activeFilterCount > 0 && styles.filterChipActive
]}
onPress={() => state.setShowFilterModal(true)}
onPress={() => state.setShowWorkspaceOptions(true)}
accessibilityRole="button"
accessibilityLabel={`Filter workspaces${controller.activeFilterCount > 0 ? `, ${controller.activeFilterCount} active` : ''}`}
accessibilityLabel={workspaceOptionsAccessibilityLabel(controller.activeFilterCount)}
>
<Filter
size={12}
color={controller.activeFilterCount > 0 ? colors.textPrimary : colors.textSecondary}
/>
<Text
style={[
styles.filterChipText,
@@ -160,32 +156,9 @@ export function HostScreenHeader({ controller }: { controller: HostScreenControl
]}
numberOfLines={1}
>
Filter{controller.activeFilterCount > 0 ? ` ${controller.activeFilterCount}` : ''}
</Text>
</Pressable>
<Pressable
style={[styles.modeButton, styles.embeddedModeButton]}
onPress={() => state.setShowSortPicker(true)}
accessibilityRole="button"
accessibilityLabel={`Sort by ${settings.selectedSortLabel}`}
>
<SlidersHorizontal size={14} color={colors.textSecondary} />
<Text style={styles.sortLabel} numberOfLines={1}>
{settings.selectedSortLabel}
</Text>
</Pressable>
<Pressable
style={[styles.modeButton, styles.embeddedModeButton]}
onPress={() => state.setShowGroupPicker(true)}
accessibilityRole="button"
accessibilityLabel="Group workspaces"
>
<Layers size={14} color={colors.textSecondary} />
<Text style={styles.sortLabel} numberOfLines={1}>
{workspaceGroupChipLabel(state.view.groupBy)}
{WORKSPACE_OPTIONS_TITLE}
</Text>
<WorkspaceOptionsCountBadge count={controller.activeFilterCount} />
</Pressable>
</View>
@@ -276,46 +249,19 @@ export function HostScreenHeader({ controller }: { controller: HostScreenControl
<View style={styles.toolbar}>
<Pressable
style={[styles.filterChip, controller.activeFilterCount > 0 && styles.filterChipActive]}
onPress={() => state.setShowFilterModal(true)}
onPress={() => state.setShowWorkspaceOptions(true)}
accessibilityRole="button"
accessibilityLabel={`Filter workspaces${controller.activeFilterCount > 0 ? `, ${controller.activeFilterCount} active` : ''}`}
accessibilityLabel={workspaceOptionsAccessibilityLabel(controller.activeFilterCount)}
>
<Filter
size={12}
color={controller.activeFilterCount > 0 ? colors.textPrimary : colors.textSecondary}
/>
<Text
style={[
styles.filterChipText,
controller.activeFilterCount > 0 && styles.filterChipTextActive
]}
>
Filter{controller.activeFilterCount > 0 ? ` (${controller.activeFilterCount})` : ''}
</Text>
</Pressable>
<Pressable
style={styles.modeButton}
onPress={() => state.setShowSortPicker(true)}
accessibilityRole="button"
accessibilityLabel={`Sort by ${settings.selectedSortLabel}`}
>
<SlidersHorizontal size={14} color={colors.textSecondary} />
<Text style={styles.sortLabel} numberOfLines={1}>
{settings.selectedSortLabel}
</Text>
</Pressable>
<Pressable
style={styles.modeButton}
onPress={() => state.setShowGroupPicker(true)}
accessibilityRole="button"
accessibilityLabel="Group workspaces"
>
<Layers size={14} color={colors.textSecondary} />
<Text style={styles.sortLabel} numberOfLines={1}>
{workspaceGroupChipLabel(state.view.groupBy)}
{WORKSPACE_OPTIONS_TITLE}
</Text>
<WorkspaceOptionsCountBadge count={controller.activeFilterCount} />
</Pressable>
<View style={styles.toolbarSpacer} />
@@ -14,8 +14,9 @@ import {
//
// A skip's reader runs only on a reply the policy already admitted, so an unreadable one throws
// rather than skipping. Both readers that project a list are inside the metadata refresh's own
// try/catch, which already treats a failed refresh as "retry on the next one"; the four writes read
// no reply body at all. host-screen-reply-schema.ts says which members each screen actually reads.
// try/catch, which already treats a failed refresh as "retry on the next one"; the view-settings
// sync's two reads sit inside its own catch the same way. The five writes read no reply body.
// host-screen-reply-schema.ts says which members each screen actually reads.
export const hostRepoCatalogRead = bindDeferredRpcOperation(
defineRpcOperation({
@@ -75,6 +76,17 @@ export const hostViewSettingsWrite = bindDeferredRpcOperation(
})
)
/** Card layout's settings half. Best-effort like the ui.set it pairs with. */
export const hostCardLayoutWrite = bindDeferredRpcOperation(
defineRpcOperation({
name: 'settings.update-card-layout-or-skip',
method: 'settings.update',
acceptance: 'success-result-or-skip',
barrier: 'after-caller-barrier',
read: rpcResultVariant('card-layout-written', hostScreenUnreadReplySchema)
})
)
/**
* The host list's three row mutations.
*
@@ -12,7 +12,7 @@ vi.mock('react-native', () => ({
View: 'View'
}))
vi.mock('expo-secure-store', () => ({ WHEN_UNLOCKED_THIS_DEVICE_ONLY: 'when-unlocked' }))
vi.mock('lucide-react-native', () => ({ Check: 'Check', Moon: 'Moon' }))
vi.mock('lucide-react-native', () => ({ Moon: 'Moon' }))
vi.mock('../agent-history/worktree-navigation-actions', () => ({
buildWorktreeNavigationActions: () => []
}))
@@ -22,7 +22,7 @@ vi.mock('../components/ConfirmModal', () => ({ ConfirmModal: 'ConfirmModal' }))
vi.mock('../components/NewWorktreeModalController', () => ({
NewWorktreeModalController: 'NewWorktreeModalController'
}))
vi.mock('../components/PickerModal', () => ({ PickerModal: 'PickerModal' }))
vi.mock('./workspace-options-sheet', () => ({ WorkspaceOptionsSheet: 'WorkspaceOptionsSheet' }))
import { MOBILE_DESKTOP_RELAY_RUNTIME_CAPABILITY } from '../../../src/shared/mobile-desktop-relay-contract'
import { FakeSession } from '../transport/mobile-endpoint-supervisor-test-fakes'
@@ -47,13 +47,12 @@ async function actionLabels(row: Partial<Worktree>): Promise<string[]> {
existingWorktreePaths: [],
hostCapabilities: [MOBILE_DESKTOP_RELAY_RUNTIME_CAPABILITY],
hostId: 'host-1',
sectionsResult: { uniqueRepos: [] },
settings: { activeFilterCount: 0 },
settings: {},
showNewWorktree: false,
state: {
actionTarget: { worktreeId: 'wt', repo: 'orca', branch: 'main', ...row },
confirmDelete: null,
view: { ...INITIAL_MOBILE_VIEW },
view: INITIAL_MOBILE_VIEW,
hostLabelById: new Map(),
pinnedIds: new Set()
}
+3 -100
View File
@@ -1,6 +1,6 @@
import { openExternalLink } from '../platform/external-link'
import { Pressable, Text, View } from 'react-native'
import { Check, Moon } from 'lucide-react-native'
import { Moon } from 'lucide-react-native'
import { buildWorktreeNavigationActions } from '../agent-history/worktree-navigation-actions'
import { workspaceRouteHref } from '../navigation/workspace-execution-host'
import { useWorkspaceServer } from '../transport/workspace-server'
@@ -8,16 +8,10 @@ import { ActionSheetContent } from '../components/ActionSheetModal'
import { BottomDrawer } from '../components/BottomDrawer'
import { ConfirmModal } from '../components/ConfirmModal'
import { NewWorktreeModalController } from '../components/NewWorktreeModalController'
import { PickerModal } from '../components/PickerModal'
import { colors } from '../theme/mobile-theme'
import { hostNewWorktreeSessionRoute } from '../host-route-action-state'
import {
WORKSPACE_GROUP_OPTIONS as GROUP_OPTIONS,
WORKSPACE_SORT_OPTIONS as SORT_OPTIONS,
WORKSPACE_VIEW_SHARED_NOTE
} from '../worktree/workspace-list-picker-options'
import { isWorktreePinned } from '../worktree/workspace-list-sections'
import { hostScreenStyles as styles } from './host-screen-styles'
import { WorkspaceOptionsSheet } from './workspace-options-sheet'
import type { HostScreenController } from './use-host-screen-controller'
export function HostScreenOverlays({ controller }: { controller: HostScreenController }) {
@@ -29,7 +23,6 @@ export function HostScreenOverlays({ controller }: { controller: HostScreenContr
existingWorktreePaths,
hostCapabilities,
hostId,
settings,
showNewWorktree,
state
} = controller
@@ -44,97 +37,7 @@ export function HostScreenOverlays({ controller }: { controller: HostScreenContr
return (
<>
<PickerModal
visible={state.showSortPicker}
title="Sort By"
subtitle={WORKSPACE_VIEW_SHARED_NOTE}
options={SORT_OPTIONS}
selected={state.view.sortBy}
onSelect={settings.handleSortChange}
onClose={() => state.setShowSortPicker(false)}
/>
<PickerModal
visible={state.showGroupPicker}
title="Group By"
subtitle={WORKSPACE_VIEW_SHARED_NOTE}
options={GROUP_OPTIONS}
selected={state.view.groupBy}
onSelect={settings.handleGroupChange}
onClose={() => state.setShowGroupPicker(false)}
/>
<BottomDrawer visible={state.showFilterModal} onClose={() => state.setShowFilterModal(false)}>
<View style={styles.filterModalHeader}>
<View style={styles.filterModalHeading}>
<Text style={styles.filterModalTitle}>Filter</Text>
<Text style={styles.filterModalSubtitle}>{WORKSPACE_VIEW_SHARED_NOTE}</Text>
</View>
{controller.activeFilterCount > 0 && (
<Pressable onPress={settings.clearFilters}>
<Text style={styles.clearFiltersText}>Clear filters</Text>
</Pressable>
)}
</View>
<Text style={styles.filterSectionLabel}>Workspaces</Text>
<View style={styles.filterGroup}>
<Pressable
style={styles.filterRow}
onPress={() =>
settings.setFilter(
'hideSleepingWorkspaces',
!state.view.filters.hideSleepingWorkspaces
)
}
>
<Text style={styles.filterRowText}>Hide sleeping</Text>
{state.view.filters.hideSleepingWorkspaces && (
<Check size={14} color={colors.textPrimary} />
)}
</Pressable>
<View style={styles.filterSeparator} />
<Pressable
style={styles.filterRow}
onPress={() =>
settings.setFilter(
'hideDefaultBranchWorkspace',
!state.view.filters.hideDefaultBranchWorkspace
)
}
>
<Text style={styles.filterRowText}>Hide default branch</Text>
{state.view.filters.hideDefaultBranchWorkspace && (
<Check size={14} color={colors.textPrimary} />
)}
</Pressable>
</View>
{controller.sectionsResult.uniqueRepos.length > 1 && (
<>
<Text style={styles.filterSectionLabel}>Repositories</Text>
<View style={styles.filterGroup}>
{controller.sectionsResult.uniqueRepos.map((repo, i) => (
<View key={repo.key}>
{i > 0 && <View style={styles.filterSeparator} />}
<Pressable
style={styles.filterRow}
onPress={() => settings.toggleRepoFilter(repo.id)}
>
<View style={[styles.filterRepoDot, { backgroundColor: repo.color }]} />
<Text style={styles.filterRowText} numberOfLines={1}>
{repo.name}
</Text>
{state.view.filters.filterRepoIds.includes(repo.id) && (
<Check size={14} color={colors.textPrimary} />
)}
</Pressable>
</View>
))}
</View>
</>
)}
</BottomDrawer>
<WorkspaceOptionsSheet controller={controller} />
{/* Worktree long-press action sheet (inline confirm to avoid double-Modal lag) */}
<BottomDrawer
@@ -87,15 +87,7 @@ export const hostScreenPrimaryStyles = StyleSheet.create({
gap: spacing.sm
},
embeddedFilterChip: {
flex: 1,
minWidth: 0,
height: 30,
justifyContent: 'center',
paddingHorizontal: spacing.xs,
paddingVertical: 0
},
embeddedModeButton: {
flex: 1,
flexShrink: 1,
minWidth: 0,
height: 30,
justifyContent: 'center',
@@ -123,21 +115,6 @@ export const hostScreenPrimaryStyles = StyleSheet.create({
filterChipTextActive: {
color: colors.textPrimary
},
modeButton: {
flexDirection: 'row',
alignItems: 'center',
flexShrink: 1,
minWidth: 0,
gap: 4,
paddingHorizontal: spacing.sm,
paddingVertical: spacing.xs
},
sortLabel: {
flexShrink: 1,
minWidth: 0,
fontSize: 12,
color: colors.textSecondary
},
toolbarSpacer: {
flex: 1
},
@@ -203,13 +203,13 @@ export const hostViewSettingsSchema = z
.transform((reply) => reply.ui)
/**
* The four host-list writes whose reply body no call site reads.
* The host-list writes whose reply body no call site reads.
*
* The `ui.set` patch, the pin write, the row delete and the activate ping are all decided by the
* acceptance verdict alone — the pin write never interprets its reply at all, and the delete reads
* `accepted` and nothing else.
* The `ui.set` patch, the card layout `settings.update`, the pin write, the row delete and the
* activate ping are all decided by the acceptance verdict alone — the pin write never interprets its
* reply at all, and the delete reads `accepted` and nothing else.
*
* `worktree.activate` is the one of the four whose payload a *second* consumer looks at, and it is
* `worktree.activate` is the one whose payload a *second* consumer looks at, and it is
* deliberately left opaque: headlessActivationNeedsHostRenderer is a total guard over `unknown`
* (worktree-activation-result.ts:1), and the session route's second report site
* (use-mobile-session-startup.ts:170) reports from inside a fire-and-forget `void (async …)()` with
@@ -96,6 +96,50 @@ export const hostScreenSecondaryStyles = StyleSheet.create({
backgroundColor: colors.borderSubtle,
marginHorizontal: spacing.md
},
optionRowLabel: {
flex: 1
},
optionRowDetail: {
fontSize: 11,
color: colors.textMuted,
marginTop: 2
},
optionRowIndented: {
paddingLeft: spacing.md + 2 + spacing.lg
},
optionRowDisabled: {
opacity: 0.5
},
optionSearch: {
padding: spacing.sm
},
optionRowValue: {
flexShrink: 1,
maxWidth: '45%',
fontSize: 13,
color: colors.textMuted
},
// On the drawer's base color, so the track takes the panel color the option cards use.
optionSegmented: {
backgroundColor: colors.bgPanel,
marginBottom: spacing.md
},
optionBack: {
flexDirection: 'row',
alignItems: 'center',
gap: 2,
alignSelf: 'flex-start',
paddingVertical: spacing.xs,
marginBottom: spacing.xs
},
optionBackText: {
fontSize: 14,
color: colors.textSecondary
},
optionPageTitle: {
paddingHorizontal: spacing.xs,
marginBottom: spacing.md
},
filterRepoDot: {
width: 8,
height: 8,
@@ -1,140 +0,0 @@
import { createElement } from 'react'
import { act, create } from 'react-test-renderer'
import { describe, expect, it, vi } from 'vitest'
// The desktop's server-list feed loads react-native; its timer never runs without a subscriber.
vi.mock('react-native', () => ({}))
vi.mock('expo-router', () => ({ useFocusEffect: () => {} }))
vi.mock('../cache/worktree-cache', () => ({
getCachedWorktrees: () => null,
setCachedWorktrees: () => {}
}))
vi.mock('../storage/preferences', () => ({
loadPinnedIds: async () => new Set(),
savePinnedIds: async () => {}
}))
vi.mock('../transport/host-store', () => ({
loadHosts: async () => [],
updateLastConnected: async () => {}
}))
vi.mock('../worktree/host-worktree-refresh', () => ({ startHostWorktreeRefresh: () => () => {} }))
vi.mock('../transport/use-worktree-resync', () => ({
useWorktreeResync: () => ({ refreshing: false, onRefresh: async () => {} })
}))
import type { RpcClient } from '../transport/rpc-client'
import { FakeSession } from '../transport/mobile-endpoint-supervisor-test-fakes'
import type { RpcResponse } from '../transport/types'
import { useHostScreenIdentity } from './use-host-screen-identity'
import { useHostWorktreeCatalog } from './use-host-worktree-catalog'
function settingsReply(settings: unknown): RpcResponse {
return { id: 'reply', ok: true, result: { settings }, _meta: { runtimeId: 'runtime' } }
}
const noop = () => {}
type ClientRef = { current: RpcClient | null }
function screenState(clientRef: ClientRef, applied: boolean[]) {
return {
clientRef,
fetchWorktreesInFlightRef: { current: false },
newWorktreeModalVisibleRef: { current: false },
repoMetadataFetchedAtRef: { current: 0 },
worktreeCatalogRef: { current: null },
viewRef: { current: null },
setShowPinnedInGroups: (show: boolean) => applied.push(show),
...Object.fromEntries(
[
'setActionError',
'setCatalogError',
'setDesktopRepos',
'setError',
'setHostLabelById',
'setHostHealthById',
'setHostName',
'setHostPlatform',
'setHostStoredDescriptor',
'setLastKnownWorktrees',
'setOptimisticActiveWorktreeIdentity',
'setPinnedIds',
'setView',
'setRepoHostIdByRepoId',
'setServedProjectSections',
'setSleptIds',
'setWorktrees',
'setWorktreesLoaded'
].map((setter) => [setter, noop])
)
}
}
// Mounting the embedded catalog runs its refresh, which issues the placement read.
async function refreshWith(
reply: Promise<RpcResponse>,
onSent: (clientRef: ClientRef) => void = noop
): Promise<boolean[]> {
const client = new FakeSession('connected')
const clientRef: ClientRef = { current: client }
client.sendRequest.mockImplementation(() => {
onSent(clientRef)
return reply
})
const applied: boolean[] = []
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the mount-time refresh reads clientRef and setShowPinnedInGroups; the other members are inert stubs.
const args = {
client,
connState: 'connected',
embedded: true,
fetchRepoMetadata: async () => {},
hostId: 'host-1',
state: screenState(clientRef, applied),
syncViewSettingsFromDesktop: async () => {}
} as unknown as Parameters<typeof useHostWorktreeCatalog>[0]
function Probe(): null {
useHostWorktreeCatalog(args)
return null
}
await act(async () => {
create(createElement(Probe))
await reply.catch(noop)
})
expect(client.sendRequest.mock.calls.map(([method]) => method)).toEqual(['settings.get'])
return applied
}
describe('the host list mirrors the desktop pinned-placement setting', () => {
it('applies the setting the host reports on refresh, absent reading as off', async () => {
const on = settingsReply({ showPinnedWorktreesInGroups: true })
expect(await refreshWith(Promise.resolve(on))).toEqual([true])
expect(await refreshWith(Promise.resolve(settingsReply({})))).toEqual([false])
})
it('drops a reply that lands after the screen moved to another client', async () => {
const on = settingsReply({ showPinnedWorktreesInGroups: true })
const moveOn = (clientRef: ClientRef) => {
clientRef.current = new FakeSession('connected')
}
expect(await refreshWith(Promise.resolve(on), moveOn)).toEqual([])
})
it('resets to off when the screen is reused for another host', async () => {
const applied: boolean[] = []
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the identity hook reads only the refs and setters screenState provides.
const identityState = screenState({ current: null }, applied) as unknown as Parameters<
typeof useHostScreenIdentity
>[0]['state']
function Probe({ hostId }: { hostId: string }): null {
useHostScreenIdentity({ client: null, hostId, state: identityState })
return null
}
const renderer = create(createElement(Probe, { hostId: 'host-1' }))
await act(async () => {})
applied.length = 0
await act(async () => {
renderer.update(createElement(Probe, { hostId: 'host-2' }))
})
expect(applied).toEqual([false])
})
})
@@ -31,6 +31,7 @@ vi.mock('../worktree/host-workspace-list-states', () => ({
import { colors } from '../theme/mobile-theme'
import { HostWorkspaceList } from './host-workspace-list'
import { INITIAL_MOBILE_VIEW } from '../worktree/workspace-view-settings'
/**
* The banner the list paints for an action that did not happen, rendered rather than described.
@@ -68,7 +69,7 @@ function listWith(fields: { actionError: string; setActionError: (value: string)
setActionError: fields.setActionError,
catalogError: null,
collapsedGroups: new Set(),
groupMode: 'none',
view: { ...INITIAL_MOBILE_VIEW, groupBy: 'none' },
search: '',
showSearch: false,
worktreesLoaded: true
@@ -33,11 +33,11 @@ vi.mock('../platform/haptics', () => ({ triggerMediumImpact: vi.fn() }))
vi.mock('../components/AgentSpinner', () => ({ AgentSpinner: () => null }))
vi.mock('../components/MobileRepoIcon', () => ({ MobileRepoIcon: () => null }))
vi.mock('../components/WorktreeAgentList', () => ({ WorktreeAgentList: () => null }))
// The row always renders the glyphs, so each call is one row render, named by its comment.
// The row always renders the glyphs, so each call is one row render.
vi.mock('../components/WorktreeMetaGlyphs', () => ({
prStateColor: () => '#000000',
WorktreeMetaGlyphs: ({ comment }: { comment?: string }) => {
rowRenders(comment)
WorktreeMetaGlyphs: () => {
rowRenders()
return null
}
}))
@@ -48,6 +48,7 @@ vi.mock('../components/NewWorkspaceFab', () => ({ FAB_SIZE: 56 }))
import { buildSections } from '../worktree/workspace-list-sections'
import { NO_WORKSPACE_ROW_FILTERS } from '../../../src/shared/workspace-view/workspace-row-filters'
import { HostWorkspaceList } from './host-workspace-list'
import { INITIAL_MOBILE_VIEW } from '../worktree/workspace-view-settings'
function worktree(id: string, overrides: Partial<Worktree> = {}): Worktree {
return {
@@ -62,7 +63,6 @@ function worktree(id: string, overrides: Partial<Worktree> = {}): Worktree {
unread: false,
isPinned: false,
linkedPR: null,
comment: id,
status: 'active',
...overrides
}
@@ -92,7 +92,7 @@ function listProps(worktrees: Worktree[]) {
state: {
actionError: '',
setActionTarget: noop,
view: { groupBy: 'none' },
view: { ...INITIAL_MOBILE_VIEW, groupBy: 'none' },
search: '',
showSearch: false,
worktreesLoaded: true
@@ -114,7 +114,7 @@ describe('host list rows across polls', () => {
const changed = [rows[0]!, { ...rows[1]!, branch: 'feature/b-renamed' }, rows[2]!]
act(() => tree!.update(createElement(HostWorkspaceList, listProps(changed))))
expect(rowRenders.mock.calls.map(([comment]) => comment)).toEqual(['b'])
expect(rowRenders).toHaveBeenCalledTimes(1)
act(() => tree!.unmount())
})
})
@@ -13,6 +13,7 @@ import { getWorktreeRowIdentity } from '../worktree/worktree-host-row-identity'
import { HostWorkspaceListStates } from '../worktree/host-workspace-list-states'
import { getWorktreeStatus } from '../worktree/workspace-list-sections'
import { repoColor } from '../worktree/repo-color'
import { getWorktreeCardDetails } from '../../../src/shared/worktree/card-properties'
import { hostScreenStyles as styles } from './host-screen-styles'
import { buildRepoLookById } from './host-repo-looks'
import type { HostScreenController } from './use-host-screen-controller'
@@ -44,6 +45,11 @@ export function HostWorkspaceList({ controller }: { controller: HostScreenContro
} = controller
const { rawSections, sections, repoById } = sectionsResult
const repoLookById = useMemo(() => buildRepoLookById(repoById), [repoById])
const { worktreeCardProperties, compactWorktreeCards, newCardStyle } = state.view
const cardDetails = useMemo(
() => getWorktreeCardDetails(worktreeCardProperties, { compactWorktreeCards, newCardStyle }),
[worktreeCardProperties, compactWorktreeCards, newCardStyle]
)
return (
<>
@@ -196,6 +202,8 @@ export function HostWorkspaceList({ controller }: { controller: HostScreenContro
repoColor={repoLookById.get(item.repoId)?.color ?? repoColor(item.repo)}
repoIcon={repoLookById.get(item.repoId)?.icon ?? null}
hideRepo={state.view.groupBy === 'repo' && !item.showProjectName}
cardDetails={cardDetails}
agentActivityDisplayMode={state.view.agentActivityDisplayMode}
onPress={actions.openWorktreeSession}
onLongPress={
item.workspaceKind === 'folder-workspace' ? undefined : state.setActionTarget
@@ -0,0 +1,27 @@
import { describe, expect, it } from 'vitest'
import {
WORKSPACE_CARD_DISPLAY_CHOICES,
WORKSPACE_CARD_PROPERTY_CHOICES
} from '../../../src/shared/workspace-view/workspace-view-options'
import type { WorktreeCardProperty } from '../../../src/shared/ui-chrome-types'
import {
phoneCardPropertyChoices,
withoutPhoneHiddenProperties
} from './phone-card-property-choices'
const ids = (choices: readonly { id: string }[]) => choices.map((choice) => choice.id)
describe('the card properties the phone offers', () => {
it('hides Ports and keeps Orca CLI', () => {
expect(ids(phoneCardPropertyChoices(WORKSPACE_CARD_PROPERTY_CHOICES))).not.toContain('ports')
const display = ids(phoneCardPropertyChoices(WORKSPACE_CARD_DISPLAY_CHOICES))
expect(display).not.toContain('ports')
expect(display).toContain('cli')
})
it('drops Ports from what it counts without touching the shared value', () => {
const shared: readonly WorktreeCardProperty[] = Object.freeze(['issue', 'ports', 'cli'])
expect(withoutPhoneHiddenProperties(shared)).toEqual(['issue', 'cli'])
expect(shared).toEqual(['issue', 'ports', 'cli'])
})
})
@@ -0,0 +1,19 @@
import type { WorkspaceViewChoice } from '../../../src/shared/workspace-view/workspace-view-options'
import type { WorktreeCardProperty } from '../../../src/shared/ui-chrome-types'
// Product call: the phone has no port scan, and ports are not worth one; the desktop keeps them.
const PHONE_HIDDEN_CARD_PROPERTIES: ReadonlySet<WorktreeCardProperty> = new Set(['ports'])
/** The shared choices the phone offers, in the shared order. */
export function phoneCardPropertyChoices<Choice extends WorkspaceViewChoice<WorktreeCardProperty>>(
choices: readonly Choice[]
): Choice[] {
return choices.filter((choice) => !PHONE_HIDDEN_CARD_PROPERTIES.has(choice.id))
}
/** The shown properties the phone counts, so its count matches the rows it lists. */
export function withoutPhoneHiddenProperties(
properties: readonly WorktreeCardProperty[]
): WorktreeCardProperty[] {
return properties.filter((property) => !PHONE_HIDDEN_CARD_PROPERTIES.has(property))
}
@@ -68,7 +68,7 @@ export function useHostScreenController({
const now = useNow(30_000)
const { hostCapabilities, floatingWorkspaceEnabled } = useHostProtocolGates()
const state = useHostScreenState(hostId, action)
const settings = useHostViewSettings({ client, connState, hostId, state })
const settings = useHostViewSettings({ client, connState, state })
const hostDisplay = useHostDisplay(
hostId && state.hostName
? { id: hostId, name: state.hostName, ...state.hostStoredDescriptor }
@@ -121,9 +121,15 @@ export function useHostScreenController({
buildProjectSectionCatalog(
state.servedProjectSections,
state.desktopRepos,
state.view.filters.visibleHostIds
state.view.filters.visibleHostIds,
state.view.projectOrderBy
),
[state.servedProjectSections, state.desktopRepos, state.view.filters.visibleHostIds]
[
state.servedProjectSections,
state.desktopRepos,
state.view.filters.visibleHostIds,
state.view.projectOrderBy
]
)
const collapsedGroups = useMemo(
() => new Set(state.view.collapsedGroups),
@@ -162,6 +168,8 @@ export function useHostScreenController({
activeWorktreeScroll,
catalog,
collapsedGroups,
// Resolved: the desktop's value once read; the served catalog's (or the default) before.
projectOrderBy: projectCatalog.projectOrderBy,
client,
connState,
contentMaxWidth,
@@ -70,9 +70,7 @@ export function useHostScreenState(hostId: string | undefined, action: string |
const [serverWorkspaces, setServerWorkspaces] = useState<ServerWorkspaces>(NO_SERVER_WORKSPACES)
// Why its own banner state: a tap on a server that needs an update says so until dismissed.
const [serverNotice, setServerNotice] = useState<string | null>(null)
const [showSortPicker, setShowSortPicker] = useState(false)
const [showGroupPicker, setShowGroupPicker] = useState(false)
const [showFilterModal, setShowFilterModal] = useState(false)
const [showWorkspaceOptions, setShowWorkspaceOptions] = useState(false)
const [actionTarget, setActionTarget] = useState<Worktree | null>(null)
const [confirmDelete, setConfirmDelete] = useState<Worktree | null>(null)
const [confirmRemoveHost, setConfirmRemoveHost] = useState(false)
@@ -134,22 +132,18 @@ export function useHostScreenState(hostId: string | undefined, action: string |
setSearch,
setServerNotice,
setServerWorkspaces,
setShowFilterModal,
setShowWorkspaceOptions,
setShowPinnedInGroups,
setShowGroupPicker,
setShowSearch,
setShowSortPicker,
setSleptIds,
setWorktrees,
setWorktreesLoaded,
showFilterModal,
showWorkspaceOptions,
showPinnedInGroups,
showGroupPicker,
showSearch,
setView,
view,
viewRef,
showSortPicker,
sleptIds,
worktreeCatalogRef,
worktrees,
@@ -2,7 +2,6 @@ import { useCallback, useRef } from 'react'
import type { RpcClient } from '../transport/rpc-client'
import type { ConnectionState } from '../transport/types'
import { getMobileWorkspaceLineageGroupKey } from '../worktree/mobile-workspace-lineage'
import { WORKSPACE_SORT_OPTIONS as SORT_OPTIONS } from '../worktree/workspace-list-picker-options'
import {
applyDesktopViewSettings,
buildWorkspaceViewSettingsUpdate,
@@ -15,18 +14,28 @@ import {
changedWorkspaceRowFilters,
NO_WORKSPACE_ROW_FILTERS
} from '../../../src/shared/workspace-view/workspace-row-filters'
import { hostViewSettingsRead, hostViewSettingsWrite } from './host-screen-operations'
import {
hostCardLayoutWrite,
hostViewSettingsRead,
hostViewSettingsWrite
} from './host-screen-operations'
import { hostListSettingsRead } from '../transport/settings-read-operations'
import type {
AgentActivityDisplayMode,
ProjectOrderBy,
WorktreeCardProperty
} from '../../../src/shared/ui-chrome-types'
import { getWorktreeCardModeUpdates } from '../../../src/shared/worktree/card-properties'
import type { HostScreenState } from './use-host-screen-state'
import type { FilterState } from '../worktree/workspace-list-types'
export function useHostViewSettings(args: {
client: RpcClient | null
connState: ConnectionState
hostId: string | undefined
state: HostScreenState
}) {
const { client, connState, hostId, state } = args
const { clientRef, setView, viewRef } = state
const { client, connState, state } = args
const { clientRef, setShowPinnedInGroups, setView, viewRef } = state
const localEditsRef = useRef(0)
@@ -38,7 +47,7 @@ export function useHostViewSettings(args: {
// Apply the change locally, then patch the desktop's shared store (ui.set) so both apps stay in sync.
const persistViewSettings = useCallback(
(patch: MobileViewPatch) => {
(patch: MobileViewPatch, options: { cardLayoutPreset?: boolean } = {}) => {
localEditsRef.current += 1
const previous = viewRef.current
const next: MobileViewState = {
@@ -53,7 +62,7 @@ export function useHostViewSettings(args: {
// Send only the touched fields: the host merges partial updates, so a stale
// mirror can no longer revert sibling settings another client just changed
// (STA-5781; supersedes the #8873 whole-payload special case).
const payload: WorkspaceViewSettings = buildWorkspaceViewSettingsUpdate(patch, next)
const payload: WorkspaceViewSettings = buildWorkspaceViewSettingsUpdate(patch, next, options)
if (Object.keys(payload).length === 0) {
return
}
@@ -70,32 +79,43 @@ export function useHostViewSettings(args: {
return
}
const requestClient = client
const requestHostId = hostId
const editsAtRequest = localEditsRef.current
try {
const reply = await hostViewSettingsRead.request(requestClient)
// Each half applies on its own, so one failing read does not hold back the other.
const [uiReply, settingsReply] = await Promise.all([
hostViewSettingsRead.request(requestClient).catch(() => null),
hostListSettingsRead.request(requestClient).catch(() => null)
])
// Why the client, not the host id: a new host always arrives with a new client.
if (clientRef.current !== requestClient) {
return
}
const hostSettings = settingsReply
? hostListSettingsRead.interpret(settingsReply)
: { accepted: false as const }
if (hostSettings.accepted) {
setShowPinnedInGroups(hostSettings.value.showPinnedWorktreesInGroups)
}
// Why skip after a local edit: this reply may predate it; the edit's own change event re-reads.
if (
clientRef.current !== requestClient ||
hostId !== requestHostId ||
localEditsRef.current !== editsAtRequest
) {
return
}
const settings = hostViewSettingsRead.interpret(reply)
if (!settings.accepted) {
if (localEditsRef.current !== editsAtRequest) {
return
}
const uiRead = uiReply ? hostViewSettingsRead.interpret(uiReply) : null
const read = uiRead?.accepted ? uiRead.value : undefined
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Preserve the established response shape at this boundary.
const ui = settings.value as WorkspaceViewSettings | undefined
if (!ui) {
return
const ui = read as WorkspaceViewSettings | undefined
let next = ui ? applyDesktopViewSettings(viewRef.current, ui) : viewRef.current
if (hostSettings.accepted) {
const { compactWorktreeCards, newCardStyle } = hostSettings.value
next = { ...next, compactWorktreeCards, newCardStyle }
}
if (next !== viewRef.current) {
applyViewState(next)
}
applyViewState(applyDesktopViewSettings(viewRef.current, ui))
} catch {
// Transient transport failure; retry on the next focus/connect.
}
}, [client, connState, hostId, applyViewState])
}, [client, connState, applyViewState])
const handleSortChange = useCallback(
(value: MobileViewState['sortBy']) => {
@@ -130,9 +150,6 @@ export function useHostViewSettings(args: {
})
}, [persistViewSettings])
const selectedSortLabel =
SORT_OPTIONS.find((option) => option.value === state.view.sortBy)?.label ?? 'Recent'
const handleGroupChange = useCallback(
(value: MobileViewState['groupBy']) => {
persistViewSettings({ groupBy: value })
@@ -140,6 +157,57 @@ export function useHostViewSettings(args: {
[persistViewSettings]
)
const setProjectOrderBy = useCallback(
(value: ProjectOrderBy) => persistViewSettings({ projectOrderBy: value }),
[persistViewSettings]
)
const setAgentActivityDisplayMode = useCallback(
(value: AgentActivityDisplayMode) => persistViewSettings({ agentActivityDisplayMode: value }),
[persistViewSettings]
)
const toggleCardProperty = useCallback(
(property: WorktreeCardProperty) => {
const current = viewRef.current.worktreeCardProperties
persistViewSettings({
worktreeCardProperties: current.includes(property)
? current.filter((candidate) => candidate !== property)
: [...current, property]
})
},
[persistViewSettings]
)
// Why both halves: a layout is a preset, so the desktop also resets the shown properties.
// Why the setting first: a host that refuses it must not be left with the other layout's preset.
const setCardLayout = useCallback(
(layout: 'detailed' | 'compact') => {
if (!client) {
return
}
const updates = getWorktreeCardModeUpdates(layout === 'compact' ? 'Compact' : 'Default')
void (async () => {
try {
const reply = await hostCardLayoutWrite.request(client, updates.settings)
if (!hostCardLayoutWrite.interpret(reply).accepted || clientRef.current !== client) {
return
}
} catch {
return
}
persistViewSettings(
{
worktreeCardProperties: updates.ui.worktreeCardProperties,
compactWorktreeCards: updates.settings.compactWorktreeCards
},
{ cardLayoutPreset: true }
)
})()
},
[client, persistViewSettings]
)
const toggleCollapsed = useCallback(
(key: string) => {
const next = new Set(viewRef.current.collapsedGroups)
@@ -157,9 +225,12 @@ export function useHostViewSettings(args: {
return {
clearFilters,
setAgentActivityDisplayMode,
setCardLayout,
setProjectOrderBy,
toggleCardProperty,
handleGroupChange,
handleSortChange,
selectedSortLabel,
syncViewSettingsFromDesktop,
toggleCollapsed,
setFilter,
@@ -4,7 +4,6 @@ import { setCachedWorktrees } from '../cache/worktree-cache'
import type { RpcClient } from '../transport/rpc-client'
import type { ConnectionState } from '../transport/types'
import { RpcIncompatibleReplyError } from '../transport/rpc-incompatible-reply-error'
import { showPinnedWorktreesInGroupsRead } from '../transport/settings-read-operations'
import { useWorktreeResync } from '../transport/use-worktree-resync'
import { startHostWorktreeRefresh } from '../worktree/host-worktree-refresh'
import { areWorktreeListsEqual } from '../worktree/worktree-list-snapshot'
@@ -52,7 +51,6 @@ export function useHostWorktreeCatalog(args: {
setOptimisticActiveWorktreeIdentity,
setPinnedIds,
setServerWorkspaces,
setShowPinnedInGroups,
setSleptIds,
setWorktrees,
setWorktreesLoaded,
@@ -187,41 +185,18 @@ export function useHostWorktreeCatalog(args: {
}, [])
)
const syncShowPinnedInGroups = useCallback(async (requestClient: RpcClient) => {
try {
const reply = await showPinnedWorktreesInGroupsRead.request(requestClient)
if (clientRef.current !== requestClient) {
return
}
const read = showPinnedWorktreesInGroupsRead.interpret(reply)
if (read.accepted) {
setShowPinnedInGroups(read.value)
}
} catch {
// Best-effort: keep the current placement until the next focus/connect.
}
}, [])
const startWorktreeRefresh = useCallback(() => {
if (!client || connState !== 'connected') {
return
}
void syncViewSettingsFromDesktop()
void syncShowPinnedInGroups(client)
return startHostWorktreeRefresh({
client,
fetchWorktrees,
fetchRepoMetadata,
syncViewSettings: syncViewSettingsFromDesktop
})
}, [
client,
connState,
fetchWorktrees,
fetchRepoMetadata,
syncViewSettingsFromDesktop,
syncShowPinnedInGroups
])
}, [client, connState, fetchWorktrees, fetchRepoMetadata, syncViewSettingsFromDesktop])
useFocusEffect(
useCallback(() => {
@@ -0,0 +1,325 @@
import { useState, type ComponentType } from 'react'
import { Text, View } from 'react-native'
import { MobileSearchField } from '../components/MobileSearchField'
import { MobileSegmentedControl } from '../components/MobileSegmentedControl'
import {
WORKSPACE_AGENT_ACTIVITY_LAYOUT_CHOICES,
WORKSPACE_CARD_DISPLAY_CHOICES,
WORKSPACE_CARD_LAYOUT_CHOICES,
WORKSPACE_CARD_PROPERTY_CHOICES,
WORKSPACE_GROUP_BY_CHOICES,
WORKSPACE_PROJECT_ORDER_CHOICES,
WORKSPACE_SORT_BY_CHOICES,
getWorkspaceCardDisplayChoiceLabel,
getWorkspaceCardPropertiesValue,
type WorkspaceViewChoice,
type WorkspaceViewOptionKey
} from '../../../src/shared/workspace-view/workspace-view-options'
import {
getCheckedHostRows,
toggleAllVisibleHosts,
toggleVisibleHostRow
} from '../../../src/shared/workspace-view/workspace-host-visibility'
import type { ExecutionHostId } from '../../../src/shared/execution-host'
import type { WorktreeCardProperty } from '../../../src/shared/ui-chrome-types'
import { hostScreenStyles as styles } from './host-screen-styles'
import {
phoneCardPropertyChoices,
withoutPhoneHiddenProperties
} from './phone-card-property-choices'
import type { HostScreenController } from './use-host-screen-controller'
import { CheckRow, ChoiceRows, OptionRowList, SwitchRow } from './workspace-option-rows'
import { choiceLabel, summarizeChecklist } from './workspace-option-values'
export type WorkspaceOptionHost = { id: ExecutionHostId; label: string }
export type WorkspaceOptionProps = {
controller: HostScreenController
hosts: readonly WorkspaceOptionHost[]
/** The shared option's label. */
label: string
}
/**
* How the phone draws one shared option. Inline options draw their own main-sheet row (`framed`
* ones inside the section's card); the rest show their value on the main sheet and open a page.
*/
export type WorkspaceOptionControl =
| { kind: 'inline'; framed: boolean; Row: ComponentType<WorkspaceOptionProps> }
| {
kind: 'page'
value: (props: WorkspaceOptionProps) => string
Page: ComponentType<WorkspaceOptionProps>
}
const CARD_PROPERTY_CHOICES = phoneCardPropertyChoices(WORKSPACE_CARD_PROPERTY_CHOICES)
// Why folder rows: the desktop asks whether it has project groups, which the phone does not read;
// the host lists folder workspaces only under a group, so their rows are that fact as it shows.
function cardDisplayChoices(
controller: HostScreenController
): WorkspaceViewChoice<WorktreeCardProperty>[] {
const hasProjectGroups = controller.state.worktrees.some(
(row) => row.workspaceKind === 'folder-workspace'
)
return phoneCardPropertyChoices(WORKSPACE_CARD_DISPLAY_CHOICES).map((choice) => ({
id: choice.id,
label: getWorkspaceCardDisplayChoiceLabel(choice, hasProjectGroups)
}))
}
function hostsValue({ controller, hosts }: WorkspaceOptionProps): string {
const visible = controller.state.view.filters.visibleHostIds
const checked = visible ? getCheckedHostRows(hosts, visible) : hosts
return summarizeChecklist(
checked.map((host) => host.label),
hosts.length,
'All hosts'
)
}
// As the desktop: while every host shows, no single host is checked, and tapping one shows only it.
function HostsPage({ controller, hosts }: WorkspaceOptionProps) {
const { settings, state } = controller
const visible = state.view.filters.visibleHostIds
const checked = new Set(visible ? getCheckedHostRows(hosts, visible).map((host) => host.id) : [])
const write = (next: ExecutionHostId[] | null | undefined) => {
if (next !== undefined) {
settings.setFilter('visibleHostIds', next)
}
}
return (
<View style={styles.filterGroup}>
<OptionRowList>
{[
<CheckRow
key="all"
label="All hosts"
checked={!visible}
onPress={() => write(toggleAllVisibleHosts(hosts, visible))}
/>,
...hosts.map((host) => {
const next = toggleVisibleHostRow(hosts, visible, host)
return (
<CheckRow
key={host.id}
label={host.label}
checked={checked.has(host.id)}
// The last shown host cannot be turned off.
disabled={next === undefined}
onPress={() => write(next)}
/>
)
})
]}
</OptionRowList>
</View>
)
}
function projectsValue({ controller }: WorkspaceOptionProps): string {
const repos = controller.sectionsResult.uniqueRepos
const selected = controller.state.view.filters.filterRepoIds
// As the desktop: no project picked shows every project.
const checked = selected.length === 0 ? repos : repos.filter((repo) => selected.includes(repo.id))
return summarizeChecklist(
checked.map((repo) => repo.name),
repos.length,
'All projects'
)
}
function ProjectsPage({ controller }: WorkspaceOptionProps) {
const { settings, state } = controller
const [query, setQuery] = useState('')
const needle = query.trim().toLowerCase()
const selected = state.view.filters.filterRepoIds
const repos = controller.sectionsResult.uniqueRepos.filter(
(repo) => !needle || repo.name.toLowerCase().includes(needle)
)
return (
<View style={styles.filterGroup}>
<View style={styles.optionSearch}>
<MobileSearchField
value={query}
onChangeText={setQuery}
placeholder="Search projects…"
accessibilityLabel="Search projects"
/>
</View>
<OptionRowList>
{[
<CheckRow
key="all"
label="All projects"
checked={selected.length === 0}
onPress={() => settings.setFilter('filterRepoIds', [])}
/>,
...repos.map((repo) => (
<CheckRow
key={repo.key}
label={repo.name}
checked={selected.includes(repo.id)}
onPress={() => settings.toggleRepoFilter(repo.id)}
leading={<View style={[styles.filterRepoDot, { backgroundColor: repo.color }]} />}
/>
))
]}
</OptionRowList>
</View>
)
}
function choicePage<Id extends string>(
choices: readonly WorkspaceViewChoice<Id>[],
selected: (controller: HostScreenController) => Id,
onSelect: (controller: HostScreenController) => (id: Id) => void
): Extract<WorkspaceOptionControl, { kind: 'page' }> {
return {
kind: 'page',
value: ({ controller }) => choiceLabel(choices, selected(controller)),
// Picking keeps the page open, as the desktop keeps its submenu open.
Page: ({ controller }) => (
<View style={styles.filterGroup}>
<ChoiceRows
choices={choices}
selected={selected(controller)}
onSelect={onSelect(controller)}
/>
</View>
)
}
}
function CardPropertyRows(props: {
controller: HostScreenController
choices: readonly WorkspaceViewChoice<WorktreeCardProperty>[]
}) {
const { settings, state } = props.controller
return (
<View style={styles.filterGroup}>
<OptionRowList>
{props.choices.map((choice) => (
<CheckRow
key={choice.id}
label={choice.label}
checked={state.view.worktreeCardProperties.includes(choice.id)}
onPress={() => settings.toggleCardProperty(choice.id)}
/>
))}
</OptionRowList>
</View>
)
}
// As the desktop: "Hover" under Compact, otherwise how many of the phone's properties show.
function showPropertiesValue({ controller }: WorkspaceOptionProps): string {
const value = getWorkspaceCardPropertiesValue(
withoutPhoneHiddenProperties(controller.state.view.worktreeCardProperties),
controller.state.view.compactWorktreeCards
)
return value === 'hover' ? 'Hover' : value === null ? '' : String(value)
}
function propertiesPage(
choices: (
controller: HostScreenController
) => readonly WorkspaceViewChoice<WorktreeCardProperty>[],
options: { value: (props: WorkspaceOptionProps) => string; withAgentLayout: boolean }
): Extract<WorkspaceOptionControl, { kind: 'page' }> {
const { withAgentLayout } = options
return {
kind: 'page',
value: options.value,
Page: ({ controller }) => (
<>
<CardPropertyRows controller={controller} choices={choices(controller)} />
{withAgentLayout && (
<>
<Text style={styles.filterSectionLabel}>Agent activity layout</Text>
<MobileSegmentedControl
segments={WORKSPACE_AGENT_ACTIVITY_LAYOUT_CHOICES.map(({ id, label }) => ({
value: id,
label
}))}
selected={controller.state.view.agentActivityDisplayMode}
onSelect={controller.settings.setAgentActivityDisplayMode}
fill
style={styles.optionSegmented}
/>
</>
)}
</>
)
}
}
function filterSwitch(
key: Extract<WorkspaceViewOptionKey, `hide${string}` | `always${string}`>
): WorkspaceOptionControl {
const isExemption = key === 'alwaysShowDefaultBranchWorkspace'
return {
kind: 'inline',
framed: true,
Row: function FilterSwitch({ controller, label }: WorkspaceOptionProps) {
const value = controller.state.view.filters[key]
// The exemption's absence means on (#8873), so only an explicit false reads as off.
const on = isExemption ? value !== false : value === true
return (
<SwitchRow
label={label}
value={on}
indent={isExemption}
onChange={(next) => controller.settings.setFilter(key, next)}
/>
)
}
}
}
/** One phone control per shared option; a new option fails to compile until it has one. */
export const WORKSPACE_OPTION_CONTROLS: Record<WorkspaceViewOptionKey, WorkspaceOptionControl> = {
hosts: { kind: 'page', value: hostsValue, Page: HostsPage },
projects: { kind: 'page', value: projectsValue, Page: ProjectsPage },
groupBy: {
kind: 'inline',
framed: false,
Row: ({ controller }) => (
<MobileSegmentedControl
segments={WORKSPACE_GROUP_BY_CHOICES.map(({ id, label }) => ({ value: id, label }))}
selected={controller.state.view.groupBy}
onSelect={controller.settings.handleGroupChange}
fill
style={styles.optionSegmented}
/>
)
},
sortBy: choicePage(
WORKSPACE_SORT_BY_CHOICES,
(controller) => controller.state.view.sortBy,
(controller) => controller.settings.handleSortChange
),
projectOrderBy: choicePage(
WORKSPACE_PROJECT_ORDER_CHOICES,
(controller) => controller.projectOrderBy,
(controller) => controller.settings.setProjectOrderBy
),
cardLayout: choicePage(
WORKSPACE_CARD_LAYOUT_CHOICES,
(controller) => (controller.state.view.compactWorktreeCards ? 'compact' : 'detailed'),
(controller) => controller.settings.setCardLayout
),
cardProperties: propertiesPage(() => CARD_PROPERTY_CHOICES, {
value: showPropertiesValue,
withAgentLayout: true
}),
// As the desktop, Card display shows no value.
cardDisplay: propertiesPage(cardDisplayChoices, { value: () => '', withAgentLayout: false }),
hideSleepingWorkspaces: filterSwitch('hideSleepingWorkspaces'),
alwaysShowDefaultBranchWorkspace: filterSwitch('alwaysShowDefaultBranchWorkspace'),
hideDefaultBranchWorkspace: filterSwitch('hideDefaultBranchWorkspace'),
hideAutomationGeneratedWorkspaces: filterSwitch('hideAutomationGeneratedWorkspaces'),
hideCliCreatedWorkspaces: filterSwitch('hideCliCreatedWorkspaces'),
hideWorkspacesFromOtherDevices: filterSwitch('hideWorkspacesFromOtherDevices'),
hideDetachedHeadWorkspaces: filterSwitch('hideDetachedHeadWorkspaces')
}
@@ -0,0 +1,123 @@
import { Children, type ReactNode } from 'react'
import { Pressable, Text, View } from 'react-native'
import { Check, ChevronRight } from 'lucide-react-native'
import { colors } from '../theme/mobile-theme'
import { MobileSwitch } from '../components/MobileSwitch'
import type { WorkspaceViewChoice } from '../../../src/shared/workspace-view/workspace-view-options'
import { hostScreenStyles as styles } from './host-screen-styles'
export function CheckRow(props: {
label: string
checked: boolean
onPress: () => void
detail?: string
indent?: boolean
leading?: ReactNode
/** Pick-one rows read as radio buttons. */
radio?: boolean
disabled?: boolean
}) {
return (
<Pressable
style={[
styles.filterRow,
props.indent && styles.optionRowIndented,
props.disabled && styles.optionRowDisabled
]}
onPress={props.onPress}
disabled={props.disabled}
accessibilityRole={props.radio ? 'radio' : 'checkbox'}
accessibilityState={{ checked: props.checked, disabled: props.disabled }}
accessibilityLabel={props.label}
>
{props.leading}
<View style={styles.optionRowLabel}>
<Text style={styles.filterRowText} numberOfLines={1}>
{props.label}
</Text>
{props.detail ? (
<Text style={styles.optionRowDetail} numberOfLines={2}>
{props.detail}
</Text>
) : null}
</View>
{props.checked && <Check size={14} color={colors.textPrimary} />}
</Pressable>
)
}
/** Rows inside one option's card, a hairline between each. */
export function OptionRowList({ children }: { children: ReactNode }) {
// Children.map keys each result by its child's own key.
return (
<>
{Children.map(children, (child, i) => (
<>
{i > 0 && <View style={styles.filterSeparator} />}
{child}
</>
))}
</>
)
}
export function ChoiceRows<Id extends string>(props: {
choices: readonly WorkspaceViewChoice<Id>[]
selected: Id
onSelect: (id: Id) => void
}) {
return (
<OptionRowList>
{props.choices.map((choice) => (
<CheckRow
key={choice.id}
label={choice.label}
detail={choice.description}
radio
checked={choice.id === props.selected}
onPress={() => props.onSelect(choice.id)}
/>
))}
</OptionRowList>
)
}
/** A filter switch, as the desktop's switch rows; only the switch takes the tap (one write on web). */
export function SwitchRow(props: {
label: string
value: boolean
onChange: (value: boolean) => void
indent?: boolean
}) {
return (
<View style={[styles.filterRow, props.indent && styles.optionRowIndented]}>
<Text style={styles.filterRowText} numberOfLines={1}>
{props.label}
</Text>
<MobileSwitch
value={props.value}
onValueChange={props.onChange}
accessibilityLabel={props.label}
/>
</View>
)
}
/** An option with its current value; tapping it opens the option's own page. */
export function DrillRow(props: { label: string; value: string; onPress: () => void }) {
return (
<Pressable
style={styles.filterRow}
onPress={props.onPress}
accessibilityRole="button"
accessibilityLabel={props.value ? `${props.label}, ${props.value}` : props.label}
>
<Text style={styles.filterRowText} numberOfLines={1}>
{props.label}
</Text>
<Text style={styles.optionRowValue} numberOfLines={1}>
{props.value}
</Text>
<ChevronRight size={16} color={colors.textMuted} />
</Pressable>
)
}
@@ -0,0 +1,22 @@
import { describe, expect, it } from 'vitest'
import { choiceLabel, summarizeChecklist } from './workspace-option-values'
describe('a checklist option value on the main sheet', () => {
it('reads all, none, the single checked item, or a count', () => {
expect(summarizeChecklist(['a', 'b', 'c'], 3, 'All hosts')).toBe('All hosts')
expect(summarizeChecklist([], 3, 'All')).toBe('None')
expect(summarizeChecklist(['m4 air'], 3, 'All hosts')).toBe('m4 air')
expect(summarizeChecklist(['a', 'b'], 3, 'All')).toBe('2 of 3')
})
})
describe('a pick-one option value', () => {
it("reads the chosen choice's label, and nothing for an unknown id", () => {
const choices = [
{ id: 'name', label: 'Name' },
{ id: 'recent', label: 'Recent' }
]
expect(choiceLabel(choices, 'recent')).toBe('Recent')
expect(choiceLabel(choices, 'newer-host-value')).toBe('')
})
})
@@ -0,0 +1,22 @@
/** A checklist option's value on the main sheet: all, none, the one checked item, or "N of M". */
export function summarizeChecklist(
checkedLabels: readonly string[],
total: number,
allLabel: string
): string {
if (checkedLabels.length >= total) {
return allLabel
}
if (checkedLabels.length === 0) {
return 'None'
}
return checkedLabels.length === 1 ? checkedLabels[0] : `${checkedLabels.length} of ${total}`
}
/** A pick-one option's value: the chosen choice's label. */
export function choiceLabel<Id extends string>(
choices: readonly { id: Id; label: string }[],
selected: string
): string {
return choices.find((choice) => choice.id === selected)?.label ?? ''
}
@@ -0,0 +1,36 @@
import { StyleSheet, Text, View } from 'react-native'
import { colors } from '../theme/mobile-theme'
import { workspaceOptionsBadgeText } from '../worktree/workspace-options-copy'
/** The active-filter count in the chip's corner, as on the desktop trigger (the chip must not clip). */
export function WorkspaceOptionsCountBadge({ count }: { count: number }) {
const text = workspaceOptionsBadgeText(count)
if (text === null) {
return null
}
return (
<View style={styles.badge} pointerEvents="none" importantForAccessibility="no-hide-descendants">
<Text style={styles.badgeText}>{text}</Text>
</View>
)
}
const styles = StyleSheet.create({
badge: {
position: 'absolute',
top: -5,
right: -5,
minWidth: 14,
height: 14,
paddingHorizontal: 3,
borderRadius: 7,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: colors.textPrimary
},
badgeText: {
fontSize: 9,
fontWeight: '600',
color: colors.bgBase
}
})
@@ -0,0 +1,93 @@
import { useCallback, useState, type ReactNode } from 'react'
import { StyleSheet, View } from 'react-native'
import Animated, {
runOnJS,
useAnimatedStyle,
useSharedValue,
withTiming,
type SharedValue
} from 'react-native-reanimated'
import { useReducedMotionEnabled } from '../platform/use-reduced-motion'
import { colors } from '../theme/mobile-theme'
const PAGE_SLIDE_MS = 200
/** Which page is open, and the slide that brings it in from the right and takes it back out. */
export function useOptionPager<Key extends string>() {
const reduceMotion = useReducedMotionEnabled()
const duration = reduceMotion ? 0 : PAGE_SLIDE_MS
const [page, setPage] = useState<Key | null>(null)
// 0 shows the page; 1 parks it one width to the right.
const slide = useSharedValue(1)
const open = useCallback(
(key: Key) => {
slide.value = 1
setPage(key)
slide.value = withTiming(0, { duration })
},
[duration, slide]
)
const close = useCallback(() => {
if (duration === 0) {
setPage(null)
return
}
slide.value = withTiming(1, { duration }, (finished) => {
if (finished) {
runOnJS(setPage)(null)
}
})
}, [duration, slide])
const forget = useCallback(() => setPage(null), [])
return { page, open, close, forget, slide }
}
export type OptionPagerControls<Key extends string> = ReturnType<typeof useOptionPager<Key>>
/**
* The main sheet with a page over it. The main sheet stays laid out underneath, so the drawer
* keeps its height while a page is open and does not jump on the way in or out.
*/
export function OptionPager(props: {
pager: { slide: SharedValue<number> }
main: ReactNode
/** The open page; null shows the main sheet. */
page: ReactNode | null
}) {
const [width, setWidth] = useState(0)
const [mainHeight, setMainHeight] = useState<number | null>(null)
const { slide } = props.pager
// Why the dependency list: the page bundle has no worklet plugin, so web mappers read only it.
const pageStyle = useAnimatedStyle(
() => ({ transform: [{ translateX: slide.value * width }] }),
[slide, width]
)
const pageOpen = props.page !== null
return (
<View
style={[styles.pager, pageOpen && mainHeight !== null && { minHeight: mainHeight }]}
onLayout={(event) => setWidth(event.nativeEvent.layout.width)}
>
<View
style={pageOpen && styles.underlay}
// Measured while under a page too, so a change behind the page does not jump on the way out.
onLayout={(event) => setMainHeight(event.nativeEvent.layout.height)}
pointerEvents={pageOpen ? 'none' : 'auto'}
aria-hidden={pageOpen}
>
{props.main}
</View>
{pageOpen && <Animated.View style={[styles.page, pageStyle]}>{props.page}</Animated.View>}
</View>
)
}
const styles = StyleSheet.create({
pager: { overflow: 'hidden' },
underlay: { position: 'absolute', top: 0, left: 0, right: 0 },
// Opaque and as tall as the pager, so no part of the main sheet shows around the page.
page: { flexGrow: 1, backgroundColor: colors.bgBase }
})
@@ -0,0 +1,229 @@
import { createElement } from 'react'
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
import { afterEach, describe, expect, it, vi } from 'vitest'
vi.mock('react-native', () => ({
Platform: { OS: 'android' },
Pressable: 'Pressable',
StyleSheet: { create: <T,>(styles: T) => styles, hairlineWidth: 1 },
Switch: 'Switch',
Text: 'Text',
View: 'View'
}))
vi.mock('lucide-react-native', () => ({
Check: 'Check',
ChevronLeft: 'ChevronLeft',
ChevronRight: 'ChevronRight'
}))
vi.mock('../navigation/use-back-claim', () => ({ useBackClaim: () => {} }))
vi.mock('../platform/use-reduced-motion', () => ({ useReducedMotionEnabled: () => false }))
// The slide settles at once, so a back press unmounts the page in the same act.
vi.mock('react-native-reanimated', () => ({
default: { View: 'AnimatedView' },
runOnJS:
<Args extends unknown[]>(fn: (...args: Args) => void) =>
(...args: Args) =>
fn(...args),
useAnimatedStyle: () => ({}),
useSharedValue: (value: number) => ({ value }),
withTiming: (to: number, _config: unknown, done?: (finished: boolean) => void) => {
done?.(true)
return to
}
}))
vi.mock('../components/BottomDrawer', () => ({ BottomDrawer: 'BottomDrawer' }))
vi.mock('../components/MobileSearchField', () => ({ MobileSearchField: 'MobileSearchField' }))
import { NO_WORKSPACE_ROW_FILTERS } from '../../../src/shared/workspace-view/workspace-row-filters'
import { INITIAL_MOBILE_VIEW } from '../worktree/workspace-view-settings'
import { WorkspaceOptionsSheet } from './workspace-options-sheet'
let renderer: ReactTestRenderer | null = null
afterEach(() => {
act(() => renderer?.unmount())
renderer = null
})
type SheetOptions = { newCardStyle: boolean; hideSleeping: boolean; hasFolderRow?: boolean }
function buildController(options: SheetOptions) {
const settings = {
clearFilters: vi.fn(),
handleGroupChange: vi.fn(),
handleSortChange: vi.fn(),
setAgentActivityDisplayMode: vi.fn(),
setCardLayout: vi.fn(),
setFilter: vi.fn(),
setProjectOrderBy: vi.fn(),
toggleCardProperty: vi.fn(),
toggleRepoFilter: vi.fn()
}
const controller = {
activeFilterCount: 0,
projectOrderBy: 'manual',
displayWorktrees: [],
sectionsResult: {
uniqueRepos: [
{ key: 'r1', id: 'r1', name: 'orca', color: '#fff' },
{ key: 'r2', id: 'r2', name: 'docs', color: '#000' }
]
},
settings,
state: {
view: {
...INITIAL_MOBILE_VIEW,
filters: { ...NO_WORKSPACE_ROW_FILTERS, hideSleepingWorkspaces: options.hideSleeping },
groupBy: 'repo',
sortBy: 'smart',
sharesOtherClientFilter: true,
newCardStyle: options.newCardStyle
},
hostLabelById: new Map(),
hostPlatform: null,
repoHostIdByRepoId: new Map([['r2', 'ssh:box']]),
serverWorkspaces: { hosts: [{ hostId: 'runtime:vm', label: 'VM' }], worktrees: [] },
setShowWorkspaceOptions: vi.fn(),
showWorkspaceOptions: true,
worktrees: options.hasFolderRow ? [{ workspaceKind: 'folder-workspace' }] : []
}
}
return { controller, settings }
}
function sheet(options: SheetOptions, settings?: ReturnType<typeof buildController>['settings']) {
const built = buildController(options)
const controller = settings ? { ...built.controller, settings } : built.controller
return createElement(WorkspaceOptionsSheet, {
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the sheet reads only these members.
controller: controller as unknown as Parameters<typeof WorkspaceOptionsSheet>[0]['controller']
})
}
function render(options: SheetOptions) {
const { settings } = buildController(options)
act(() => {
renderer = create(sheet(options, settings))
})
return settings
}
const texts = () =>
renderer!.root
.findAll((node) => String(node.type) === 'Text')
.map((node) => node.props.children)
.filter((child): child is string => typeof child === 'string')
/** Taps the control whose accessibility label is `label`. */
const press = (label: string) => {
act(() => {
renderer!.root
.find((node) => String(node.type) === 'Pressable' && node.props.accessibilityLabel === label)
.props.onPress()
})
}
const back = () => press('Back to Workspace options')
/** Flips the filter switch whose accessibility label is `label`. */
const flip = (label: string, value: boolean) => {
act(() => {
renderer!.root
.find((node) => String(node.type) === 'Switch' && node.props.accessibilityLabel === label)
.props.onValueChange(value)
})
}
describe('the phone Workspace options sheet', () => {
it('shows the classic card options as rows with their values, and filters as switches', () => {
render({ newCardStyle: false, hideSleeping: true })
const shown = texts()
for (const label of [
'Show',
'Hosts',
'All hosts',
'Projects',
'All projects',
'Group by',
'Project',
'Sort by',
'Agent Activity',
'Project order',
'Card layout',
'Detailed',
'Show properties',
'Filters',
'Hide sleeping',
'Except default branch',
'Hide other-client workspaces',
'Hide detached HEAD'
]) {
expect(shown).toContain(label)
}
expect(shown).not.toContain('Card display')
// The choices themselves live on each option's page.
expect(shown).not.toContain('Agent activity layout')
})
it('swaps in Card display for the new card style and drops the exemption when nothing sleeps', () => {
render({ newCardStyle: true, hideSleeping: false })
const shown = texts()
expect(shown).toContain('Card display')
expect(shown).not.toContain('Card layout')
expect(shown).not.toContain('Show properties')
expect(shown).not.toContain('Except default branch')
press('Card display')
expect(texts()).toContain('Orca CLI')
expect(texts()).toContain('Branch name')
expect(texts()).not.toContain('Ports')
})
it('names the branch choice for folder paths too once the desktop has folder workspaces', () => {
render({ newCardStyle: true, hideSleeping: false, hasFolderRow: true })
press('Card display')
expect(texts()).toContain('Branch / folder path')
})
it('opens an option page from its row, stays there after a pick, and goes back', () => {
const settings = render({ newCardStyle: false, hideSleeping: false })
press('Sort by, Agent Activity')
expect(texts()).toContain('Agents that need attention, then most recent activity.')
press('Recent')
expect(settings.handleSortChange).toHaveBeenCalledWith('recent')
expect(texts()).toContain('Name')
back()
expect(texts()).toContain('Filters')
})
it('writes each control to the shared value it names', () => {
const settings = render({ newCardStyle: false, hideSleeping: false })
flip('Hide CLI-created', true)
expect(settings.setFilter).toHaveBeenCalledWith('hideCliCreatedWorkspaces', true)
press('PR')
expect(settings.handleGroupChange).toHaveBeenCalledWith('pr-status')
press('Card layout, Detailed')
press('Compact')
expect(settings.setCardLayout).toHaveBeenCalledWith('compact')
back()
press('Project order, Manual')
press('Recent')
expect(settings.setProjectOrderBy).toHaveBeenCalledWith('recent')
back()
press('Hosts, All hosts')
press('VM')
expect(settings.setFilter).toHaveBeenCalledWith('visibleHostIds', ['runtime:vm'])
back()
press('Projects, All projects')
press('docs')
expect(settings.toggleRepoFilter).toHaveBeenCalledWith('r2')
back()
press('Show properties, 8')
// Ports is the desktop's own; the phone leaves it out.
expect(texts()).not.toContain('Ports')
press('Automation')
expect(settings.toggleCardProperty).toHaveBeenCalledWith('automation')
expect(settings.toggleCardProperty).not.toHaveBeenCalledWith('ports')
press('Full list')
expect(settings.setAgentActivityDisplayMode).toHaveBeenCalledWith('full')
})
})
@@ -0,0 +1,183 @@
import { useMemo, type ReactNode } from 'react'
import { Pressable, Text, View } from 'react-native'
import { ChevronLeft } from 'lucide-react-native'
import { colors } from '../theme/mobile-theme'
import { useBackClaim } from '../navigation/use-back-claim'
import { BottomDrawer } from '../components/BottomDrawer'
import {
LOCAL_EXECUTION_HOST_ID,
parseExecutionHostId,
type ExecutionHostId
} from '../../../src/shared/execution-host'
import {
WORKSPACE_VIEW_SECTION_LABELS,
getShownWorkspaceViewSections,
type WorkspaceViewOptionKey
} from '../../../src/shared/workspace-view/workspace-view-options'
import { getHostContextLabel } from '../../../src/shared/worktree/host-context-labels'
import {
WORKSPACE_OPTIONS_TITLE,
WORKSPACE_VIEW_SHARED_NOTE
} from '../worktree/workspace-options-copy'
import { resolveWorktreeHostId } from '../worktree/worktree-host-context-labels'
import { withServerHosts } from '../worktree/server-workspaces'
import { hostScreenStyles as styles } from './host-screen-styles'
import type { HostScreenController } from './use-host-screen-controller'
import { DrillRow, OptionRowList } from './workspace-option-rows'
import { OptionPager, useOptionPager, type OptionPagerControls } from './workspace-options-pager'
import {
WORKSPACE_OPTION_CONTROLS,
type WorkspaceOptionHost,
type WorkspaceOptionProps
} from './workspace-option-controls'
/** Every host the list can show, this computer first; labelled as the rows label them. */
function useWorkspaceOptionHosts(controller: HostScreenController): WorkspaceOptionHost[] {
const { displayWorktrees, state } = controller
return useMemo(() => {
const ids = new Set<ExecutionHostId>([LOCAL_EXECUTION_HOST_ID])
state.repoHostIdByRepoId.forEach((id) => ids.add(id))
state.serverWorkspaces.hosts.forEach((host) => ids.add(host.hostId))
displayWorktrees.forEach((row) => ids.add(resolveWorktreeHostId(row, state.repoHostIdByRepoId)))
const hostLabelById = withServerHosts(
state.hostLabelById,
state.serverWorkspaces.hosts,
(host) => host.label
)
return [...ids].map((id) => ({
id,
label: getHostContextLabel(id, { hostLabelById, hostPlatform: state.hostPlatform })
}))
}, [
displayWorktrees,
state.repoHostIdByRepoId,
state.serverWorkspaces.hosts,
state.hostLabelById,
state.hostPlatform
])
}
/** The desktop sidebar's Workspace options, rendered from the shared option list. */
export function WorkspaceOptionsSheet({ controller }: { controller: HostScreenController }) {
const { state } = controller
const pager = useOptionPager<WorkspaceViewOptionKey>()
return (
<BottomDrawer
visible={state.showWorkspaceOptions}
onClose={() => state.setShowWorkspaceOptions(false)}
// The sheet opens on its main page next time.
onAfterClose={pager.forget}
// A page opens at the top even when the main sheet was scrolled down to its row.
scrollResetKey={pager.page}
>
<WorkspaceOptionsContent controller={controller} pager={pager} />
</BottomDrawer>
)
}
// Its own component so the host list is built only while the sheet is open.
function WorkspaceOptionsContent({
controller,
pager
}: {
controller: HostScreenController
pager: OptionPagerControls<WorkspaceViewOptionKey>
}) {
const { settings, state } = controller
const hosts = useWorkspaceOptionHosts(controller)
const sections = getShownWorkspaceViewSections({
groupBy: state.view.groupBy,
hideSleepingWorkspaces: state.view.filters.hideSleepingWorkspaces,
hideWorkspacesFromOtherDevices: state.view.filters.hideWorkspacesFromOtherDevices,
newCardStyle: state.view.newCardStyle,
hasRemoteHosts: hosts.some((host) => parseExecutionHostId(host.id)?.kind !== 'local'),
projectCount: controller.sectionsResult.uniqueRepos.length,
// Why the host's say-so: a host that keeps the filter to itself cannot apply the phone's value.
mayHaveServerHosts:
state.view.sharesOtherClientFilter === true && state.serverWorkspaces.hosts.length > 0
})
// The open option; it falls back to the main sheet once the option stops showing.
const pageOption = sections
.flatMap(({ options }) => options)
.find((option) => option.key === pager.page)
const pageControl = pageOption ? WORKSPACE_OPTION_CONTROLS[pageOption.key] : null
// Back from a page returns to the main sheet; the drawer's own claim closes it from there.
useBackClaim(
pageOption
? () => {
pager.close()
return true
}
: null
)
const page =
pageOption && pageControl?.kind === 'page' ? (
<>
<Pressable
style={styles.optionBack}
onPress={pager.close}
accessibilityRole="button"
accessibilityLabel={`Back to ${WORKSPACE_OPTIONS_TITLE}`}
>
<ChevronLeft size={16} color={colors.textSecondary} />
<Text style={styles.optionBackText}>{WORKSPACE_OPTIONS_TITLE}</Text>
</Pressable>
<Text style={[styles.filterModalTitle, styles.optionPageTitle]}>{pageOption.label}</Text>
<pageControl.Page controller={controller} hosts={hosts} label={pageOption.label} />
</>
) : null
const main = (
<>
<View style={styles.filterModalHeader}>
<View style={styles.filterModalHeading}>
<Text style={styles.filterModalTitle}>{WORKSPACE_OPTIONS_TITLE}</Text>
<Text style={styles.filterModalSubtitle}>{WORKSPACE_VIEW_SHARED_NOTE}</Text>
</View>
{controller.activeFilterCount > 0 && (
<Pressable onPress={settings.clearFilters}>
<Text style={styles.clearFiltersText}>Clear filters</Text>
</Pressable>
)}
</View>
{sections.map(({ section, options }) => {
const sectionLabel = WORKSPACE_VIEW_SECTION_LABELS[section]
// Inline unframed rows sit above the section's card; every other row goes in the card.
const bareRows: ReactNode[] = []
const framedRows: ReactNode[] = []
for (const option of options) {
const control = WORKSPACE_OPTION_CONTROLS[option.key]
const props: WorkspaceOptionProps = { controller, hosts, label: option.label }
if (control.kind === 'page') {
framedRows.push(
<DrillRow
key={option.key}
label={option.label}
value={control.value(props)}
onPress={() => pager.open(option.key)}
/>
)
} else {
const target = control.framed ? framedRows : bareRows
target.push(<control.Row key={option.key} {...props} />)
}
}
return (
<View key={section} testID={`workspace-option-section-${section}`}>
{sectionLabel && <Text style={styles.filterSectionLabel}>{sectionLabel}</Text>}
{bareRows}
{framedRows.length > 0 && (
<View style={styles.filterGroup}>
<OptionRowList>{framedRows}</OptionRowList>
</View>
)}
</View>
)
})}
</>
)
return <OptionPager pager={pager} main={main} page={page} />
}
@@ -2,7 +2,7 @@ import { useEffect, useRef } from 'react'
import { Animated, Easing, StyleSheet, Text, View } from 'react-native'
import { OrcaLogo } from '../components/OrcaLogo'
import { colors, radii, spacing, typography } from '../theme/mobile-theme'
import { useReducedMotionEnabled } from './use-reduced-motion'
import { useReducedMotionEnabled } from '../platform/use-reduced-motion'
const SAMPLE_NOTIFICATIONS = [
{ title: 'Codex finished', body: 'Tests are passing.' },
+7 -19
View File
@@ -6,6 +6,7 @@ import { voiceSettingsStyles as styles } from './voice-settings-styles'
import { ChevronLeft, ChevronRight } from 'lucide-react-native'
import { colors, spacing } from '../theme/mobile-theme'
import { BottomDrawer } from '../components/BottomDrawer'
import { MobileSegmentedControl } from '../components/MobileSegmentedControl'
import { VoiceModelList } from '../components/VoiceModelList'
import { useDictationSetupPoller } from '../dictation/use-dictation-setup-poller'
import {
@@ -229,25 +230,12 @@ export default function VoiceSettingsScreen({
Toggle: press once to start, again to stop. Hold: dictate while held.
</Text>
</View>
<View style={styles.segmented}>
{DICTATION_MODES.map((mode) => {
const active = setup.dictationMode === mode.value
return (
<Pressable
key={mode.value}
accessibilityRole="radio"
aria-checked={active}
testID={`voice-mode-${mode.value}`}
onPress={() => void configure({ dictationMode: mode.value })}
style={[styles.segment, active && styles.segmentActive]}
>
<Text style={[styles.segmentText, active && styles.segmentTextActive]}>
{mode.label}
</Text>
</Pressable>
)
})}
</View>
<MobileSegmentedControl
segments={DICTATION_MODES}
selected={setup.dictationMode}
onSelect={(dictationMode) => void configure({ dictationMode })}
testIDPrefix="voice-mode-"
/>
</View>
</View>
@@ -88,20 +88,5 @@ export const voiceSettingsStyles = StyleSheet.create({
backgroundColor: colors.borderSubtle,
marginHorizontal: spacing.md
},
segmented: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: colors.bgBase,
borderRadius: radii.button,
padding: 2
},
segment: {
paddingHorizontal: spacing.md,
paddingVertical: 6,
borderRadius: radii.button - 1
},
segmentActive: { backgroundColor: colors.bgRaised },
segmentText: { fontSize: typography.metaSize, color: colors.textSecondary, fontWeight: '600' },
segmentTextActive: { color: colors.textPrimary },
error: { color: colors.statusRed, fontSize: typography.metaSize, marginTop: spacing.md }
})
@@ -24,11 +24,11 @@ function asPinned(): Map<string, PageClosureObservation> {
}
describe('the C1 page closure', () => {
it('is the census the design named: 20 families, 95 goldens', () => {
it('is the census the design named: 20 families, 96 goldens', () => {
const goldens = Object.values(C1_PAGE_CLOSURE).flatMap((family) => Object.keys(family))
expect({ families: Object.keys(C1_PAGE_CLOSURE).length, goldens: goldens.length }).toEqual({
families: 20,
goldens: 95
goldens: 96
})
expect(new Set(goldens).size).toBe(goldens.length)
})
@@ -131,6 +131,7 @@ export const C1_PAGE_CLOSURE: PageClosurePins = {
},
'host.view-settings': {
'host-view-settings-sync': 'identical',
'matrix-host.view-settings-settings.get-1': 'result-absent-settlement',
'matrix-host.view-settings-ui.get-1': 'result-absent-settlement',
'matrix-host.view-settings-ui.set-1': 'result-absent-settlement'
},
@@ -26,11 +26,11 @@ function asPinned(): Map<string, PageClosureObservation> {
}
describe('the C5 page closure', () => {
it('is the census the design named: 25 families, 126 goldens', () => {
it('is the census the design named: 25 families, 127 goldens', () => {
const goldens = Object.values(C5_PAGE_CLOSURE).flatMap((family) => Object.keys(family))
expect({ families: Object.keys(C5_PAGE_CLOSURE).length, goldens: goldens.length }).toEqual({
families: 25,
goldens: 126
goldens: 127
})
expect(new Set(goldens).size).toBe(goldens.length)
})
@@ -47,7 +47,7 @@ describe('the C5 page closure', () => {
it('pins how many goldens land in each class, which a per-id walk cannot see move', () => {
expect(pageClosureTotals(C5_PAGE_CLOSURE)).toEqual({
identical: 66,
'result-absent-settlement': 48,
'result-absent-settlement': 49,
'params-undefined': 7,
'result-absent-stream-release': 3,
'write-ordinal': 2
@@ -119,7 +119,7 @@ describe('the C5 page closure', () => {
it('excludes a closure golden only into a class that has a reason', () => {
const exclusions = pageClosureExclusions(C5_PAGE_CLOSURE)
expect(exclusions.length).toBe(60)
expect(exclusions.length).toBe(61)
expect(exclusions.filter(([, name]) => BRIDGED_PARITY_EXCLUSIONS[name] === undefined)).toEqual(
[]
)
@@ -102,6 +102,7 @@ export const C5_PAGE_CLOSURE: PageClosurePins = {
},
'host.view-settings': {
'host-view-settings-sync': 'identical',
'matrix-host.view-settings-settings.get-1': 'result-absent-settlement',
'matrix-host.view-settings-ui.get-1': 'result-absent-settlement',
'matrix-host.view-settings-ui.set-1': 'result-absent-settlement'
},
@@ -244,7 +244,7 @@ describe('what the pin still admits', () => {
expect(drift.join('\n')).toContain(
`identical: pinned ${tally.identical}, ran ${moved.identical}`
)
expect(drift.join('\n')).toContain('result-absent-settlement: pinned 344, ran 343')
expect(drift.join('\n')).toContain('result-absent-settlement: pinned 345, ran 344')
})
it('goes red on a class that grew and on the corpus losing a golden', () => {
@@ -185,7 +185,7 @@ export const BRIDGED_PARITY_BASELINE: Readonly<Record<BridgedParityClass | 'iden
// identically, and the corpus is a fixed size, so a shuffle between two excluded classes cannot
// hide one. Two more arrived with Jira: the hydration and workspace-context groups each ask
// `jira.status`, so each family gained one absent-result partition.
'result-absent-settlement': 344,
'result-absent-settlement': 345,
// Four left here and two left `write-ordinal` for the class below, which is the `cancel` a
// refused stream frame now posts: the run stops at a renamed occurrence before it reaches the
// checkpoint or the ordinal that used to be what differed first.
@@ -28,7 +28,6 @@ export function hostScreenMountAdapters(
actions = useViewSettings({
client: context.client,
connState: 'connected',
hostId: HOST,
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the recorder supplies only the members the hook reads.
state: state as unknown as Parameters<typeof useViewSettings>[0]['state']
})
@@ -10,7 +10,7 @@ import {
botOverridesRead,
newTabSettingsRead,
terminalCopyTrimsGutterRead,
showPinnedWorktreesInGroupsRead
hostListSettingsRead
} from './settings-read-operations'
import type { RpcResponse } from './types'
@@ -111,28 +111,36 @@ describe('settings historical acceptance', () => {
expect(terminalCopyTrimsGutterRead.interpret(refused)).toEqual({ accepted: false })
})
it('reads pinned placement, treating an absent or malformed key as the desktop default', async () => {
const showFor = async (settings: unknown) =>
showPinnedWorktreesInGroupsRead.interpret(
await showPinnedWorktreesInGroupsRead.request(replyWith(success(settings)))
it('reads the list settings, treating an absent or malformed key as the desktop default (off)', async () => {
const readFor = async (settings: unknown) =>
hostListSettingsRead.interpret(
await hostListSettingsRead.request(replyWith(success(settings)))
)
expect(await showFor({ settings: { showPinnedWorktreesInGroups: true } })).toEqual({
const off = {
showPinnedWorktreesInGroups: false,
compactWorktreeCards: false,
newCardStyle: false
}
expect(
await readFor({
settings: {
showPinnedWorktreesInGroups: true,
compactWorktreeCards: true,
experimentalNewWorktreeCardStyle: true
}
})
).toEqual({
accepted: true,
value: true
value: { showPinnedWorktreesInGroups: true, compactWorktreeCards: true, newCardStyle: true }
})
expect(await showFor({ settings: { showPinnedWorktreesInGroups: false } })).toEqual({
accepted: true,
value: false
})
// A host predating the setting sends no key; the desktop default is off.
expect(await showFor({ settings: {} })).toEqual({ accepted: true, value: false })
expect(await showFor({ settings: { showPinnedWorktreesInGroups: 'true' } })).toEqual({
accepted: true,
value: false
})
expect(await showFor(null)).toEqual({ accepted: true, value: false })
const refused = await showPinnedWorktreesInGroupsRead.request(replyWith(refusal()))
expect(showPinnedWorktreesInGroupsRead.interpret(refused)).toEqual({ accepted: false })
// A host predating the settings sends no keys.
expect(await readFor({ settings: {} })).toEqual({ accepted: true, value: off })
expect(
await readFor({ settings: { showPinnedWorktreesInGroups: 'true', compactWorktreeCards: 1 } })
).toEqual({ accepted: true, value: off })
expect(await readFor(null)).toEqual({ accepted: true, value: off })
const refused = await hostListSettingsRead.request(replyWith(refusal()))
expect(hostListSettingsRead.interpret(refused)).toEqual({ accepted: false })
})
it('does not read a stale payload until its caller permits interpretation', async () => {
@@ -112,28 +112,40 @@ export const terminalCopyTrimsGutterRead = bindDeferredRpcOperation(
})
)
const showPinnedInGroupsReader: RpcCompatibleReader<unknown, 'show-pinned-in-groups', boolean> = (
raw
) => {
type HostListSettings = {
showPinnedWorktreesInGroups: boolean
compactWorktreeCards: boolean
newCardStyle: boolean
}
// Why `=== true` throughout: a host predating a setting sends no key, and each desktop default is off.
const hostListSettingsReader: RpcCompatibleReader<
unknown,
'host-list-settings',
HostListSettings
> = (raw) => {
const settings = raw == null ? undefined : settingsMember(raw)
const show: unknown =
settings == null ? undefined : settingsField(settings, 'showPinnedWorktreesInGroups')
const isOn = (key: string) => settings != null && settingsField(settings, key) === true
return {
compatible: true,
variant: 'show-pinned-in-groups',
// Why `=== true`: a host predating the setting sends no key; desktop's default is off.
value: show === true,
variant: 'host-list-settings',
value: {
showPinnedWorktreesInGroups: isOn('showPinnedWorktreesInGroups'),
compactWorktreeCards: isOn('compactWorktreeCards'),
newCardStyle: isOn('experimentalNewWorktreeCardStyle')
},
salvage: { droppedPaths: [], droppedCount: 0 }
}
}
export const showPinnedWorktreesInGroupsRead = bindDeferredRpcOperation(
/** The desktop settings the workspace list mirrors: pinned placement, Card layout and card style. */
export const hostListSettingsRead = bindDeferredRpcOperation(
defineRpcOperation({
name: 'settings.show-pinned-worktrees-in-groups-or-skip',
name: 'settings.host-list-settings-or-skip',
method: 'settings.get',
acceptance: 'success-result-or-skip',
barrier: 'after-caller-barrier',
read: showPinnedInGroupsReader
read: hostListSettingsReader
})
)
@@ -31,7 +31,12 @@ describe('buildProjectSectionCatalog', () => {
setups: [],
projectOrderBy: 'manual'
}
const catalog = buildProjectSectionCatalog(served, [repo('r1', undefined, uploaded)], null)
const catalog = buildProjectSectionCatalog(
served,
[repo('r1', undefined, uploaded)],
null,
'manual'
)
expect(catalog.repos.map((entry) => entry.repoIcon)).toEqual([uploaded, undefined])
})
})
+10 -3
View File
@@ -1,5 +1,6 @@
import { getRepoExecutionHostId, type ExecutionHostId } from '../../../src/shared/execution-host'
import { getLogicalRepoOrderRankById } from '../../../src/shared/project-section-order'
import type { ProjectOrderBy } from '../../../src/shared/ui-chrome-types'
import type { ServedProjectSections } from './project-sections-operations'
import { NO_PROJECT_SECTION_CATALOG, type ProjectSectionCatalog } from './workspace-list-sections'
import type { SectionRepo } from './workspace-list-types'
@@ -12,14 +13,20 @@ import type { SectionRepo } from './workspace-list-types'
export function buildProjectSectionCatalog(
served: ServedProjectSections | null,
desktopRepos: readonly SectionRepo[],
visibleHostIds: readonly ExecutionHostId[] | null
visibleHostIds: readonly ExecutionHostId[] | null,
/** The shared UI value; null until read, when the served one stands in. */
projectOrderBy: ProjectOrderBy | null
): ProjectSectionCatalog {
const shown = (repos: readonly SectionRepo[]) =>
visibleHostIds
? repos.filter((repo) => visibleHostIds.includes(getRepoExecutionHostId(repo)))
: repos
if (!served) {
return { ...NO_PROJECT_SECTION_CATALOG, repos: shown(desktopRepos) }
return {
...NO_PROJECT_SECTION_CATALOG,
repos: shown(desktopRepos),
projectOrderBy: projectOrderBy ?? NO_PROJECT_SECTION_CATALOG.projectOrderBy
}
}
// Why: the desktop withholds uploaded image icons from the snapshot; its repo.list still carries them.
const iconKey = (repo: SectionRepo) => `${getRepoExecutionHostId(repo)}|${repo.id}`
@@ -33,6 +40,6 @@ export function buildProjectSectionCatalog(
grouping: { projects: served.projects, projectHostSetups: served.setups },
// Why: served repos come in the sidebar's header order, cross-host manual order included.
repoOrder: getLogicalRepoOrderRankById(served.repos.map((repo) => repo.id)),
projectOrderBy: served.projectOrderBy
projectOrderBy: projectOrderBy ?? served.projectOrderBy
}
}
@@ -60,6 +60,7 @@ const catalogSchema = z.object({
repos: salvagingArray(repoSchema),
projects: salvagingArray(z.object({ id: z.string(), displayName: z.string() })),
setups: salvagingArray(setupSchema),
// Until the phone has read the shared UI value (projectOrderBy, ui.get), it orders by this.
projectOrderBy: z.enum(PROJECT_ORDER_BY_ARMS).catch('manual')
})
@@ -1,34 +0,0 @@
import type { PickerOption } from '../components/PickerModal'
import {
WORKSPACE_GROUP_BY_CHOICES,
WORKSPACE_SORT_BY_CHOICES,
type WorkspaceGroupBy,
type WorkspaceSortBy,
type WorkspaceViewChoice
} from '../../../src/shared/workspace-view/workspace-view-options'
// Why: the host may be headless, so the note can't promise a desktop sidebar.
export const WORKSPACE_VIEW_SHARED_NOTE = 'Synced across your devices'
function toPickerOption<Id extends string>(choice: WorkspaceViewChoice<Id>): PickerOption<Id> {
return {
value: choice.id,
label: choice.label,
...(choice.description ? { subtitle: choice.description } : {})
}
}
/** The desktop's Sort by choices, in its order and words. */
export const WORKSPACE_SORT_OPTIONS: PickerOption<WorkspaceSortBy>[] =
WORKSPACE_SORT_BY_CHOICES.map(toPickerOption)
/** The desktop's Group by choices, in its order and words. */
export const WORKSPACE_GROUP_OPTIONS: PickerOption<WorkspaceGroupBy>[] =
WORKSPACE_GROUP_BY_CHOICES.map(toPickerOption)
/** The Group chip's label: the chosen grouping, or "Group" when there is none. */
export function workspaceGroupChipLabel(groupBy: WorkspaceGroupBy): string {
return groupBy === 'none'
? 'Group'
: (WORKSPACE_GROUP_OPTIONS.find((option) => option.value === groupBy)?.label ?? 'Group')
}
@@ -18,11 +18,11 @@ import { buildSections, type Worktree } from './workspace-list-sections'
// worktree-list-groups-mobile-parity.test.ts: buildRows renders these headers, and main serves
// PROJECT_SECTION_PARITY_SERVED for the same host answers.
function served(projectOrderBy: 'manual' | 'recent'): ServedProjectSections | null {
function served(): ServedProjectSections | null {
const verdict = projectSectionsRead.interpret({
id: 'sections',
ok: true,
result: { catalog: { ...PROJECT_SECTION_PARITY_SERVED, projectOrderBy } }
result: { catalog: PROJECT_SECTION_PARITY_SERVED }
})
return verdict.accepted ? verdict.value : null
}
@@ -70,7 +70,7 @@ describe('Project grouping on the phone', () => {
"renders the desktop sidebar's %s headers from the catalog the desktop serves",
(projectOrderBy) => {
expect(rows()).toHaveLength(PROJECT_SECTION_PARITY_ROWS.length)
expect(headers(buildProjectSectionCatalog(served(projectOrderBy), [], null))).toEqual(
expect(headers(buildProjectSectionCatalog(served(), [], null, projectOrderBy))).toEqual(
PROJECT_SECTION_PARITY_HEADERS[projectOrderBy]
)
}
@@ -81,7 +81,7 @@ describe('Project grouping on the phone', () => {
(repo) => repo.executionHostId === 'local'
)
expect(headers(buildProjectSectionCatalog(null, desktopRepos, null))).toEqual([
expect(headers(buildProjectSectionCatalog(null, desktopRepos, null, 'manual'))).toEqual([
{ key: 'repo:m4-orca-two', label: 'orca' },
{ key: 'repo:win-orca', label: 'orca' },
{ key: 'repo:local-workspace', label: 'Users/jinwoo/orca/workspaces/orca/orca' },
+6 -1
View File
@@ -1,6 +1,9 @@
import type { ExecutionHostId } from '../../../src/shared/execution-host'
import type { AgentWorkingMode } from '../../../src/shared/agent-status-types'
import type { RuntimeWorktreeAgentRow } from '../../../src/shared/runtime-types'
import type {
RuntimeWorktreeAgentRow,
RuntimeWorktreePsSummary
} from '../../../src/shared/runtime-types'
import type { ProjectSectionRepo } from '../../../src/shared/project-section-grouping'
import type { WorkspaceRowFilters } from '../../../src/shared/workspace-view/workspace-row-filters'
@@ -60,6 +63,8 @@ export type Worktree = {
linkedLinearIssue?: string | null
linkedGitLabMR?: number | null
linkedGitLabIssue?: number | null
/** Every linked issue and review; hosts before the attachment list omit it. Unchecked wire data. */
linkedItems?: RuntimeWorktreePsSummary['linkedItems']
comment?: string
status?: 'working' | 'active' | 'permission' | 'done' | 'inactive'
workingMode?: AgentWorkingMode
@@ -0,0 +1,19 @@
import { describe, expect, it } from 'vitest'
import {
workspaceOptionsAccessibilityLabel,
workspaceOptionsBadgeText
} from './workspace-options-copy'
describe('the Workspace options chip', () => {
it('badges the active-filter count as the desktop does, capped at 9+', () => {
expect(workspaceOptionsBadgeText(0)).toBeNull()
expect(workspaceOptionsBadgeText(2)).toBe('2')
expect(workspaceOptionsBadgeText(12)).toBe('9+')
})
it("reads to screen readers as the desktop's trigger does", () => {
expect(workspaceOptionsAccessibilityLabel(0)).toBe('Workspace options')
expect(workspaceOptionsAccessibilityLabel(1)).toBe('Workspace options (1 filter active)')
expect(workspaceOptionsAccessibilityLabel(3)).toBe('Workspace options (3 filters active)')
})
})
@@ -0,0 +1,22 @@
import {
formatActiveFilterBadge,
formatActiveFilterLabel
} from '../../../src/shared/workspace-view/workspace-row-filters'
/** The desktop's name for the control that opens these options, and for the options themselves. */
export const WORKSPACE_OPTIONS_TITLE = 'Workspace options'
/** The chip's corner badge, capped as on the desktop; null with no active filter. */
export function workspaceOptionsBadgeText(activeFilterCount: number): string | null {
return activeFilterCount > 0 ? formatActiveFilterBadge(activeFilterCount) : null
}
/** The desktop trigger's accessibility label, word for word. */
export function workspaceOptionsAccessibilityLabel(activeFilterCount: number): string {
return activeFilterCount > 0
? `${WORKSPACE_OPTIONS_TITLE} (${formatActiveFilterLabel(activeFilterCount)} active)`
: WORKSPACE_OPTIONS_TITLE
}
// Why: the host may be headless, so the note can't promise a desktop sidebar.
export const WORKSPACE_VIEW_SHARED_NOTE = 'Synced across your devices'
@@ -170,3 +170,50 @@ describe('buildWorkspaceViewSettingsUpdate', () => {
expect(buildWorkspaceViewSettingsUpdate({}, base)).toEqual({})
})
})
describe('card view settings', () => {
it('reads the shared card values and writes a hand-picked property set as no longer a preset', () => {
const next = applyDesktopViewSettings(base, {
projectOrderBy: 'recent',
worktreeCardProperties: ['pr'],
agentActivityDisplayMode: 'full'
})
expect([
next.projectOrderBy,
next.worktreeCardProperties,
next.agentActivityDisplayMode
]).toEqual(['recent', ['pr'], 'full'])
expect(
buildWorkspaceViewSettingsUpdate(
{ worktreeCardProperties: ['pr', 'host'] },
{
...next,
worktreeCardProperties: ['pr', 'host']
}
)
).toEqual({ worktreeCardProperties: ['pr', 'host'], _worktreeCardModeDefaulted: false })
})
it('writes a Card layout preset as one, and leaves the layout setting out of ui.set', () => {
const next = {
...base,
worktreeCardProperties: ['status'] as const,
compactWorktreeCards: true
}
expect(
buildWorkspaceViewSettingsUpdate(
{ worktreeCardProperties: next.worktreeCardProperties, compactWorktreeCards: true },
next,
{ cardLayoutPreset: true }
)
).toEqual({ worktreeCardProperties: ['status'], _worktreeCardModeDefaulted: true })
})
it('keeps the card settings a ui.get does not carry', () => {
const current = { ...base, compactWorktreeCards: true, newCardStyle: true }
expect(applyDesktopViewSettings(current, { groupBy: 'none' })).toMatchObject({
compactWorktreeCards: true,
newCardStyle: true
})
})
})
+52 -6
View File
@@ -17,6 +17,12 @@ import { resolveVisibleWorkspaceHostIds } from '../../../src/shared/workspace-vi
import type { PersistedUIState } from '../../../src/shared/persisted-ui-state-types'
import type { WORKSPACE_VIEW_UI_FIELDS } from '../../../src/shared/workspace-view/workspace-view-ui-fields'
import type { FilterState } from './workspace-list-types'
import type {
AgentActivityDisplayMode,
ProjectOrderBy,
WorktreeCardProperty
} from '../../../src/shared/ui-chrome-types'
import { DEFAULT_WORKTREE_CARD_PROPERTIES } from '../../../src/shared/worktree/card-properties'
import {
WORKSPACE_GROUP_BY_CHOICES,
WORKSPACE_SORT_BY_CHOICES
@@ -31,7 +37,14 @@ export type WorkspaceViewSettings = {
: Key extends 'visibleHostIds'
? string[] | null
: FilterState[Key]
} & Partial<Pick<PersistedUIState, (typeof WORKSPACE_VIEW_UI_FIELDS)[number]>>
} & Partial<
Pick<
PersistedUIState,
| (typeof WORKSPACE_VIEW_UI_FIELDS)[number]
// Written with the card properties only: whether they are a Card layout preset.
| '_worktreeCardModeDefaulted'
>
>
export type MobileViewState = {
groupBy: PersistedUIState['groupBy']
@@ -39,12 +52,20 @@ export type MobileViewState = {
filters: FilterState
collapsedGroups: readonly string[]
workspaceStatuses: readonly WorkspaceStatusDefinition[]
/** Null until the desktop's value is read; the served project list's order stands in. */
projectOrderBy: ProjectOrderBy | null
/** May hold a property a newer desktop added; it is carried back unchanged. */
worktreeCardProperties: readonly WorktreeCardProperty[]
agentActivityDisplayMode: AgentActivityDisplayMode
/**
* Whether the desktop shares "Hide other-client workspaces" with this phone. One that does not
* (before #28986) marks a row other-client only while its own filter hides it. Null until the
* first ui.get, so a new desktop's marks don't hide rows before its filter value arrives.
*/
sharesOtherClientFilter: boolean | null
/** Desktop settings, not UI state: read with settings.get; Card layout writes the first. */
compactWorktreeCards: boolean
newCardStyle: boolean
}
/** What the screen shows until the desktop's own values arrive. */
@@ -54,12 +75,20 @@ export const INITIAL_MOBILE_VIEW: MobileViewState = {
filters: NO_WORKSPACE_ROW_FILTERS,
collapsedGroups: [],
workspaceStatuses: DEFAULT_WORKSPACE_STATUSES,
sharesOtherClientFilter: null
projectOrderBy: null,
worktreeCardProperties: DEFAULT_WORKTREE_CARD_PROPERTIES,
agentActivityDisplayMode: 'compact',
sharesOtherClientFilter: null,
compactWorktreeCards: false,
newCardStyle: false
}
/** An edit: the fields it sets, and for filters only the filters it touches. */
/**
* An edit: the fields it sets, and for filters only the filters it touches. compactWorktreeCards
* is a desktop setting, not ui.set state: only setCardLayout patches it, once the host accepted the setting.
*/
export type MobileViewPatch = Partial<
Omit<MobileViewState, 'filters' | 'sharesOtherClientFilter'>
Omit<MobileViewState, 'filters' | 'sharesOtherClientFilter' | 'newCardStyle'>
> & {
filters?: Partial<FilterState>
}
@@ -91,7 +120,8 @@ export function rowFiltersForView(view: MobileViewState): FilterState {
*/
export function buildWorkspaceViewSettingsUpdate(
patch: MobileViewPatch,
next: MobileViewState
next: MobileViewState,
options: { cardLayoutPreset?: boolean } = {}
): WorkspaceViewSettings {
const update: WorkspaceViewSettings = {}
if ('groupBy' in patch) {
@@ -113,6 +143,17 @@ export function buildWorkspaceViewSettingsUpdate(
if (patch.filters && 'visibleHostIds' in patch.filters && (hostIds?.length ?? 1) === 1) {
update.workspaceHostScope = hostIds?.[0] ?? 'all'
}
if ('projectOrderBy' in patch && next.projectOrderBy) {
update.projectOrderBy = next.projectOrderBy
}
if ('worktreeCardProperties' in patch) {
update.worktreeCardProperties = [...next.worktreeCardProperties]
// As the desktop's own edit: a hand-picked set is no longer a Card layout preset.
update._worktreeCardModeDefaulted = options.cardLayoutPreset === true
}
if ('agentActivityDisplayMode' in patch) {
update.agentActivityDisplayMode = next.agentActivityDisplayMode
}
if ('collapsedGroups' in patch) {
update.collapsedGroups = [...next.collapsedGroups]
}
@@ -153,7 +194,12 @@ export function applyDesktopViewSettings(
filters,
collapsedGroups: settings.collapsedGroups ?? current.collapsedGroups,
workspaceStatuses,
projectOrderBy: settings.projectOrderBy ?? current.projectOrderBy,
worktreeCardProperties: settings.worktreeCardProperties ?? current.worktreeCardProperties,
agentActivityDisplayMode: settings.agentActivityDisplayMode ?? current.agentActivityDisplayMode,
// Each ui.get is the desktop's whole state, so the field's absence means it keeps the filter.
sharesOtherClientFilter: settings.hideWorkspacesFromOtherDevices !== undefined
sharesOtherClientFilter: settings.hideWorkspacesFromOtherDevices !== undefined,
compactWorktreeCards: current.compactWorktreeCards,
newCardStyle: current.newCardStyle
}
}
@@ -0,0 +1,77 @@
import { describe, expect, it } from 'vitest'
import {
DEFAULT_WORKTREE_CARD_PROPERTIES,
getWorktreeCardDetails
} from '../../../src/shared/worktree/card-properties'
import type { Worktree } from './workspace-list-types'
import { getWorktreeMetaGlyphPresence } from './worktree-meta-glyph-presence'
const ALL = getWorktreeCardDetails(DEFAULT_WORKTREE_CARD_PROPERTIES, {
compactWorktreeCards: false,
newCardStyle: false
})
const row: Worktree = {
worktreeId: 'w',
repoId: 'r',
repo: 'orca',
path: '/w',
branch: 'b',
displayName: 'w',
liveTerminalCount: 0,
hasAttachedPty: false,
unread: false,
isPinned: false,
linkedPR: null
}
describe('getWorktreeMetaGlyphPresence', () => {
it('shows each fact the row carries and its property turns on', () => {
expect(
getWorktreeMetaGlyphPresence(
{
...row,
comment: ' note ',
automationCreated: true,
cliCreated: true,
linkedIssue: 3,
linkedLinearIssue: 'ENG-1',
linkedGitLabMR: 4,
linkedItems: [{ provider: 'jira', type: 'issue', number: 1 }]
},
ALL
)
).toEqual({
notes: true,
automation: true,
cli: true,
issue: true,
jira: true,
linear: true,
gitLabMR: true
})
})
it('shows none for a bare row, or when the properties are off', () => {
const none = getWorktreeCardDetails([], { compactWorktreeCards: false, newCardStyle: false })
expect(Object.values(getWorktreeMetaGlyphPresence(row, ALL)).some(Boolean)).toBe(false)
expect(
Object.values(
getWorktreeMetaGlyphPresence({ ...row, automationCreated: true, cliCreated: true }, none)
).some(Boolean)
).toBe(false)
})
it('finds only a linked Jira issue, and reads an unchecked list without throwing', () => {
const jiraFor = (linkedItems: unknown) =>
getWorktreeMetaGlyphPresence(
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: rows reach the phone unchecked; this feeds the malformed shapes a host could send.
{ ...row, linkedItems } as Worktree,
ALL
).jira
expect(jiraFor([{ provider: 'linear', type: 'issue', number: 1 }])).toBe(false)
expect(jiraFor([{ provider: 'jira', type: 'pr', number: 1 }])).toBe(false)
expect(jiraFor('jira')).toBe(false)
expect(jiraFor([null, 3])).toBe(false)
})
})
@@ -0,0 +1,44 @@
import type { WorktreeCardDetails } from '../../../src/shared/worktree/card-properties'
import type { Worktree } from './workspace-list-types'
/** Which presence glyphs a row draws, as the desktop's card badges gate them. */
export type WorktreeMetaGlyphPresence = {
notes: boolean
automation: boolean
cli: boolean
issue: boolean
jira: boolean
linear: boolean
gitLabMR: boolean
}
// Why unknown: rows reach the phone unchecked, and hosts before the attachment list omit it.
function hasLinkedJiraIssue(linkedItems: unknown): boolean {
return (
Array.isArray(linkedItems) &&
linkedItems.some(
(item: unknown) =>
typeof item === 'object' &&
item !== null &&
'provider' in item &&
item.provider === 'jira' &&
'type' in item &&
item.type === 'issue'
)
)
}
export function getWorktreeMetaGlyphPresence(
item: Worktree,
details: WorktreeCardDetails
): WorktreeMetaGlyphPresence {
return {
notes: details.comment && (item.comment ?? '').trim().length > 0,
automation: details.automation && item.automationCreated === true,
cli: details.cli && item.cliCreated === true,
issue: details.issue && (item.linkedIssue != null || item.linkedGitLabIssue != null),
jira: details.jiraIssue && hasLinkedJiraIssue(item.linkedItems),
linear: details.linearIssue && Boolean(item.linkedLinearIssue),
gitLabMR: details.pr && item.linkedGitLabMR != null
}
}
@@ -150,7 +150,9 @@ export function startParityLanes(
// A folder's contained repo is saved too, so the host can borrow its runtime if it would.
const repo =
scopeRepo ??
(args.workspace.repoPath ? launchScope({ kind: 'repo', path: args.workspace.repoPath }).repo : null)
(args.workspace.repoPath
? launchScope({ kind: 'repo', path: args.workspace.repoPath }).repo
: null)
const store = {
getSettings: () => settings,
getProjects: () => [
@@ -147,71 +147,84 @@ describe.each([
})
})
it.each(['git', 'folder'] as const)('projects exact known SSH %s endpoints without building foreign repos', async (kind) => {
const { repo, store } = fixture({ connectionId: 'host-a' })
const parentInstance = '11111111-2222-3333-4444-555555555555'
const parentPath = `/remote/parent::workspace:${parentInstance}`
const parentRepo = hydrateRepo(
{ ...repo, id: 'parent-repo', kind, path: kind === 'folder' ? '/remote/parent' : parentPath, displayName: 'parent' },
new Map()
)
const childId = 'repo::/remote/repository'
const parentId = `parent-repo::${parentPath}`
const metaById: Record<string, WorktreeMeta> = {
[childId]: mergeWorktreeMetaForWrite(undefined, {
hostId: 'ssh:host-a',
instanceId: 'child-instance',
projectId: 'child-project'
}),
[parentId]: mergeWorktreeMetaForWrite(undefined, {
hostId: 'ssh:host-a',
instanceId: parentInstance,
projectId: 'parent-project'
it.each(['git', 'folder'] as const)(
'projects exact known SSH %s endpoints without building foreign repos',
async (kind) => {
const { repo, store } = fixture({ connectionId: 'host-a' })
const parentInstance = '11111111-2222-3333-4444-555555555555'
const parentPath = `/remote/parent::workspace:${parentInstance}`
const parentRepo = hydrateRepo(
{
...repo,
id: 'parent-repo',
kind,
path: kind === 'folder' ? '/remote/parent' : parentPath,
displayName: 'parent'
},
new Map()
)
const childId = 'repo::/remote/repository'
const parentId = `parent-repo::${parentPath}`
const metaById: Record<string, WorktreeMeta> = {
[childId]: mergeWorktreeMetaForWrite(undefined, {
hostId: 'ssh:host-a',
instanceId: 'child-instance',
projectId: 'child-project'
}),
[parentId]: mergeWorktreeMetaForWrite(undefined, {
hostId: 'ssh:host-a',
instanceId: parentInstance,
projectId: 'parent-project'
})
}
if (kind === 'folder') {
metaById[parentId].instanceId = undefined
}
store.setWorktreeMeta.mockImplementation((id: string, updates: Partial<WorktreeMeta>) => {
const meta = mergeWorktreeMetaForWrite(metaById[id], updates)
metaById[id] = meta
return meta
})
}
if (kind === 'folder') {
metaById[parentId].instanceId = undefined
}
store.setWorktreeMeta.mockImplementation((id: string, updates: Partial<WorktreeMeta>) => {
const meta = mergeWorktreeMetaForWrite(metaById[id], updates)
metaById[id] = meta
return meta
})
Object.assign(store, {
getRepos: () => [repo, parentRepo],
getAllWorktreeMeta: () => metaById,
getWorktreeMeta: (id: string) => metaById[id],
getAllWorktreeLineage: () => ({
[childId]: {
worktreeId: childId,
worktreeInstanceId: 'child-instance',
parentWorktreeId: parentId,
parentWorktreeInstanceId: parentInstance,
origin: 'cli',
capture: { source: 'explicit-cli-flag', confidence: 'explicit' },
createdAt: 1
Object.assign(store, {
getRepos: () => [repo, parentRepo],
getAllWorktreeMeta: () => metaById,
getWorktreeMeta: (id: string) => metaById[id],
getAllWorktreeLineage: () => ({
[childId]: {
worktreeId: childId,
worktreeInstanceId: 'child-instance',
parentWorktreeId: parentId,
parentWorktreeInstanceId: parentInstance,
origin: 'cli',
capture: { source: 'explicit-cli-flag', confidence: 'explicit' },
createdAt: 1
}
})
})
const child = await invoke('known')
expect(child).toMatchObject({
status: 'complete',
result: { worktrees: [expect.objectContaining({ id: childId, parentWorktreeId: parentId })] }
})
expect(store.setWorktreeMeta).not.toHaveBeenCalledWith(parentId, expect.anything())
const parent = mocks.handlers.get('worktrees:listKnownForExecutionHost')!(null, {
repoId: parentRepo.id,
executionHostId: 'ssh:host-a'
})
expect(parent).toMatchObject({
status: 'complete',
result: {
worktrees: [
...(kind === 'folder'
? [expect.objectContaining({ id: 'parent-repo::/remote/parent' })]
: []),
expect.objectContaining({ id: parentId, childWorktreeIds: [childId] })
]
}
})
})
const child = await invoke('known')
expect(child).toMatchObject({
status: 'complete',
result: { worktrees: [expect.objectContaining({ id: childId, parentWorktreeId: parentId })] }
})
expect(store.setWorktreeMeta).not.toHaveBeenCalledWith(parentId, expect.anything())
const parent = mocks.handlers.get('worktrees:listKnownForExecutionHost')!(null, {
repoId: parentRepo.id,
executionHostId: 'ssh:host-a'
})
expect(parent).toMatchObject({
status: 'complete',
result: { worktrees: [
...(kind === 'folder' ? [expect.objectContaining({ id: 'parent-repo::/remote/parent' })] : []),
expect.objectContaining({ id: parentId, childWorktreeIds: [childId] })
] }
})
})
}
)
it.each([undefined, 'nested'])(
'keeps runtime rows out of local cache and root registration: %s',
@@ -1,17 +1,33 @@
import { describe, expect, it, vi } from 'vitest'
import { getDefaultUIState } from '../../shared/constants'
import { getDefaultSettings, getDefaultUIState } from '../../shared/constants'
import type { GlobalSettings } from '../../shared/global-settings-types'
import type { PersistedUIState } from '../../shared/persisted-ui-state-types'
import { publishWorkspaceViewChanges } from './runtime-workspace-view-change-events'
function fakeStore() {
let ui: PersistedUIState = getDefaultUIState()
const listeners: ((ui: PersistedUIState) => void)[] = []
const settingsListeners: ((
updates: Partial<GlobalSettings>,
settings: GlobalSettings
) => void)[] = []
return {
getUI: () => ui,
onUIChanged: (listener: (ui: PersistedUIState) => void) => {
listeners.push(listener)
return () => {}
},
onSettingsChanged: (
listener: (updates: Partial<GlobalSettings>, settings: GlobalSettings) => void
) => {
settingsListeners.push(listener)
return () => {}
},
updateSettings(updates: Partial<GlobalSettings>) {
for (const listener of settingsListeners) {
listener(updates, { ...getDefaultSettings('/home/test'), ...updates })
}
},
update(updates: Partial<PersistedUIState>) {
ui = { ...ui, ...updates }
for (const listener of listeners) {
@@ -38,4 +54,17 @@ describe('publishWorkspaceViewChanges', () => {
[{ type: 'workspaceViewChanged' }]
])
})
it('announces a change to the card layout, card style or pinned placement, and nothing else', () => {
const store = fakeStore()
const emit = vi.fn()
publishWorkspaceViewChanges(store, emit)
store.updateSettings({ terminalCopyTrimsGutter: false })
expect(emit).not.toHaveBeenCalled()
store.updateSettings({ compactWorktreeCards: true })
store.updateSettings({ experimentalNewWorktreeCardStyle: true })
store.updateSettings({ showPinnedWorktreesInGroups: true })
expect(emit).toHaveBeenCalledTimes(3)
})
})
@@ -1,9 +1,17 @@
import { isDeepStrictEqual } from 'node:util'
import type { RuntimeClientEvent } from '../../shared/runtime-client-events'
import type { GlobalSettings } from '../../shared/global-settings-types'
import type { PersistedUIState } from '../../shared/persisted-ui-state-types'
import { ALL_WORKSPACE_VIEW_UI_FIELDS } from '../../shared/workspace-view/workspace-view-ui-fields'
import type { RuntimeStore } from './runtime-store-contract'
/** The settings a phone's workspace list mirrors: Card layout, card style and pinned placement. */
const WORKSPACE_VIEW_SETTINGS: readonly (keyof GlobalSettings)[] = [
'compactWorktreeCards',
'experimentalNewWorktreeCardStyle',
'showPinnedWorktreesInGroups'
]
function workspaceViewUiValues(ui: PersistedUIState): unknown[] {
return ALL_WORKSPACE_VIEW_UI_FIELDS.map((key) => ui[key])
}
@@ -14,7 +22,7 @@ function workspaceViewUiValues(ui: PersistedUIState): unknown[] {
* would otherwise trigger a re-read on every frame.
*/
export function publishWorkspaceViewChanges(
store: Pick<RuntimeStore, 'getUI' | 'onUIChanged'> | null,
store: Pick<RuntimeStore, 'getUI' | 'onUIChanged' | 'onSettingsChanged'> | null,
emit: (event: RuntimeClientEvent) => void
): void {
if (!store?.getUI || !store.onUIChanged) {
@@ -28,4 +36,10 @@ export function publishWorkspaceViewChanges(
emit({ type: 'workspaceViewChanged' })
}
})
// Card layout and the card style are settings, not UI state.
store.onSettingsChanged?.((updates) => {
if (WORKSPACE_VIEW_SETTINGS.some((key) => key in updates)) {
emit({ type: 'workspaceViewChanged' })
}
})
}
+2 -1
View File
@@ -66,7 +66,8 @@ export function projectWorktreeLineageWithHostPeers<T extends Worktree>(
if (
evidence.some((owner) => owner.status !== 'owned') ||
hostOwners.length !== 1 ||
!meta || (!meta.instanceId && !isFolderRepo(hostOwners[0])) ||
!meta ||
(!meta.instanceId && !isFolderRepo(hostOwners[0])) ||
(!meta.hostId && owners.length > 1)
) {
continue
@@ -158,9 +158,9 @@ function ActivityThreadHoverCardContent({
linkedBitbucketPR: review.linkedBitbucketPR,
linkedAzureDevOpsPR: review.linkedAzureDevOpsPR,
linkedGiteaPR: review.linkedGiteaPR,
cardProps: foundation.cardProps,
newCardStyle: foundation.newCardStyle,
compactCards: foundation.compactCards,
cardDetails: foundation.cardDetails,
agentActivityDisplayMode: foundation.agentActivityDisplayMode,
workspacePorts: foundation.workspacePorts,
openTaskPage: foundation.openTaskPage,
@@ -17,10 +17,8 @@ import {
} from '@/components/ui/dropdown-menu'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { translate } from '@/i18n/i18n'
import {
formatActiveFilterLabel,
OptionsFilterCountBadge
} from '@/components/sidebar/options-filter-count-badge'
import { OptionsFilterCountBadge } from '@/components/sidebar/options-filter-count-badge'
import { formatActiveFilterLabel } from '../../../../shared/workspace-view/workspace-row-filters'
import {
ActivityScopeFilterMenuItems,
useActivityScopeFilterCount
@@ -392,7 +392,9 @@ function ParentWorktreeCandidateList({
{[
repoMap.get(candidate.repoId)?.displayName,
branchDisplayName(candidate.branch)
].filter(Boolean).join(' · ')}
]
.filter(Boolean)
.join(' · ')}
</span>
</div>
</div>
@@ -323,11 +323,20 @@ describe('DeleteWorktreeDialog lineage copy', () => {
const parent = { ...makeWorktree('parent', '/parent'), repoId: repo.id }
const child = { ...makeWorktree('child', '/child'), repoId: checkout.id }
const { DeleteWorktreeLineageNotice } = await import('./DeleteWorktreeLineageNotice')
const markup = renderToStaticMarkup(<DeleteWorktreeLineageNotice
parent={parent} descendants={[child]}
repoMap={new Map([[repo.id, repo], [checkout.id, checkout]])}
dirtyChangeCountsByWorktreeId={new Map()} dirtyChangePreviewsByWorktreeId={new Map()}
/>)
const markup = renderToStaticMarkup(
<DeleteWorktreeLineageNotice
parent={parent}
descendants={[child]}
repoMap={
new Map([
[repo.id, repo],
[checkout.id, checkout]
])
}
dirtyChangeCountsByWorktreeId={new Map()}
dirtyChangePreviewsByWorktreeId={new Map()}
/>
)
expect(visibleMarkupText(markup)).toContain('(Second checkout)')
expect(visibleMarkupText(markup)).toContain('child · Second checkout')
})
@@ -10,16 +10,13 @@ import type {
VisibleWorkspaceHostIds,
WorkspaceHostScope
} from '../../../../shared/ui-chrome-types'
import {
expandEquivalentExecutionHostIds,
pickerExecutionHosts
} from '../../../../shared/managed-orcad-execution-host'
import { pickerExecutionHosts } from '../../../../shared/managed-orcad-execution-host'
import {
getCheckedHostRows,
getSidebarHostHealthLabel,
toggleVisibleHostRow,
type SidebarHostOption
} from './sidebar-host-options'
toggleAllVisibleHosts,
toggleVisibleHostRow
} from '../../../../shared/workspace-view/workspace-host-visibility'
import { getSidebarHostHealthLabel, type SidebarHostOption } from './sidebar-host-options'
import { translate } from '@/i18n/i18n'
type SidebarHostScopeMenuSectionProps = {
@@ -80,13 +77,11 @@ export function SidebarHostScopeMenuSection({
)
const toggleAllHosts = (): void => {
if (!allVisible) {
const next = toggleAllVisibleHosts(hostOptions, visibleWorkspaceHostIds)
if (next === null) {
setWorkspaceHostScope(ALL_EXECUTION_HOSTS_SCOPE)
return
}
const firstHost = hostRows[0]
if (firstHost) {
setVisibleWorkspaceHostIds(expandEquivalentExecutionHostIds(hostOptions, [firstHost.id]))
} else if (next) {
setVisibleWorkspaceHostIds(next)
}
}
@@ -182,7 +182,10 @@ it.each([true, false])('reveals the target with pinned ancestors visible=%s', (v
capture: { source: 'orchestration-context', confidence: 'explicit' },
createdAt: 1
})
const worktreeLineageById = { [parent.id]: lineage(parent, root), [child.id]: lineage(child, parent) }
const worktreeLineageById = {
[parent.id]: lineage(parent, root),
[child.id]: lineage(child, parent)
}
const collapsedGroups = new Set([
'host:ssh:builder',
'lineage:ssh:builder|remote',
@@ -1,9 +1,5 @@
import React from 'react'
/** Pluralized "N filter(s)" used in the options triggers' tooltip and aria label. */
export function formatActiveFilterLabel(count: number): string {
return `${count} ${count === 1 ? 'filter' : 'filters'}`
}
import { formatActiveFilterBadge } from '../../../../shared/workspace-view/workspace-row-filters'
/** Active-filter count on a sidebar options trigger (the trigger must be `relative`). Shared so
* the workspace and activity headers signal filtering identically. */
@@ -17,7 +13,7 @@ export function OptionsFilterCountBadge({ count }: { count: number }): React.JSX
data-options-filter-count=""
className="absolute -top-0.5 -right-0.5 flex h-3 min-w-3 items-center justify-center rounded-full bg-primary px-0.5 text-[9px] font-medium leading-none text-primary-foreground"
>
{count > 9 ? '9+' : count}
{formatActiveFilterBadge(count)}
</span>
)
}
@@ -110,17 +110,41 @@ describe('getPinnedSectionWorktrees', () => {
const visible = { ...grandchild, repoId: 'visible-project' }
const map = new Map([pinnedParent, hidden, visible].map((row) => [row.id, row]))
const allowed = [pinnedParent.repoId, visible.repoId]
expect(getPinnedSectionWorktrees([pinnedParent, visible], lineageById, map, allowed))
.toEqual([pinnedParent])
expect(getPinnedSectionWorktrees([pinnedParent, visible], lineageById, map))
.toEqual([pinnedParent, visible])
expect(getPinnedSectionWorktrees([pinnedParent, visible], lineageById, map, allowed)).toEqual([
pinnedParent
])
expect(getPinnedSectionWorktrees([pinnedParent, visible], lineageById, map)).toEqual([
pinnedParent,
visible
])
const rows = buildRows(
'repo', [pinnedParent, visible], repoMap, null, new Set(), undefined, undefined,
undefined, lineageById, map, true, undefined, [], new Set(), new Map(), new Map(),
[], undefined, [], undefined, 'local', 'single-location', allowed
'repo',
[pinnedParent, visible],
repoMap,
null,
new Set(),
undefined,
undefined,
undefined,
lineageById,
map,
true,
undefined,
[],
new Set(),
new Map(),
new Map(),
[],
undefined,
[],
undefined,
'local',
'single-location',
allowed
)
expect(rows.find((row) => row.type === 'item' && row.worktree.id === visible.id)).toMatchObject(
{ sectionKey: 'repo:visible-project', depth: 0 }
)
expect(rows.find((row) => row.type === 'item' && row.worktree.id === visible.id))
.toMatchObject({ sectionKey: 'repo:visible-project', depth: 0 })
})
it('does not collect descendants of a pinned parent that is not itself visible', () => {
@@ -57,7 +57,10 @@ export function getPinnedSectionWorktrees(
rows: worktrees,
isPinned: (worktree) => worktree.isPinned === true,
childrenByParentIdentity: getSidebarLineageChildren(
worktrees, lineageById, worktreeMap, filterRepoIds
worktrees,
lineageById,
worktreeMap,
filterRepoIds
),
identityOf: getWorktreeHostIdentity,
duplicatePinned: false
@@ -6,9 +6,9 @@ import {
getSidebarHostVisibilityLabel,
getSidebarHostHealthLabel,
shouldShowHostScopeControls,
toggleVisibleHostRow,
type SidebarHostOption
} from './sidebar-host-options'
import { toggleVisibleHostRow } from '../../../../shared/workspace-view/workspace-host-visibility'
import { filterFolderWorkspacesForVisibleHosts } from './worktree-list/listing/host-filtering'
import type { FolderWorkspace } from '../../../../shared/folder-workspace-types'
import type { ProjectGroup } from '../../../../shared/project-group-types'
@@ -12,10 +12,8 @@ import {
type ExecutionHostHealth
} from '../../../../shared/execution-host-registry'
import { getExecutionHostHealthLabel } from '../../../../shared/execution-host-health'
import {
expandEquivalentExecutionHostIds,
pickerExecutionHosts
} from '../../../../shared/managed-orcad-execution-host'
import { pickerExecutionHosts } from '../../../../shared/managed-orcad-execution-host'
import { getCheckedHostRows } from '../../../../shared/workspace-view/workspace-host-visibility'
import type { RuntimeCompatVerdict } from '../../../../shared/protocol-compat'
import type { SshConnectionState, SshConnectionStatus } from '../../../../shared/ssh-types'
import {
@@ -202,44 +200,6 @@ export function getSidebarHostVisibilityLabel(
)
}
/** Picker rows a saved visibility list selects; a row counts when any id it stands for is saved. */
export function getCheckedHostRows<T extends SidebarHostOption>(
rows: readonly T[],
visibleHostIds: readonly ExecutionHostId[]
): T[] {
const visible = new Set(visibleHostIds)
return rows.filter(
(row) => visible.has(row.id) || (row.aliasHostIds ?? []).some((id) => visible.has(id))
)
}
export function getSidebarHostHealthLabel(health: SidebarHostScopeOption['health']): string {
return health === 'mixed' ? 'Mixed' : getExecutionHostHealthLabel(health)
}
/**
* The visibility list after toggling one picker row, or undefined when the toggle is refused (the
* last checked row). A row writes every id it stands for, so exact-id filters match both owners.
*/
export function toggleVisibleHostRow(
hosts: readonly SidebarHostOption[],
visibleHostIds: readonly ExecutionHostId[] | null,
row: SidebarHostOption
): ExecutionHostId[] | null | undefined {
const rowIds = expandEquivalentExecutionHostIds(hosts, [row.id])
if (!visibleHostIds) {
return rowIds
}
const rows = pickerExecutionHosts(hosts)
const checkedRows = getCheckedHostRows(rows, visibleHostIds)
const next = new Set(visibleHostIds)
if (checkedRows.some((checked) => checked.id === row.id)) {
if (checkedRows.length <= 1) {
return undefined
}
rowIds.forEach((id) => next.delete(id))
} else {
rowIds.forEach((id) => next.add(id))
}
return getCheckedHostRows(rows, [...next]).length === rows.length ? null : [...next]
}
@@ -35,14 +35,16 @@ export function useWorktreeCardController(props: ResolvedWorktreeCardProps) {
})
const showStatus = foundation.cardProps.includes('status')
const showIssue = foundation.cardProps.includes('issue')
const showLinearIssue = foundation.cardProps.includes('linear-issue')
const showJiraIssue = foundation.cardProps.includes('jira-issue')
const showPR = foundation.cardProps.includes('pr')
const showAutomation = foundation.cardProps.includes('automation')
const showCli = foundation.cardProps.includes('cli')
const showComment = foundation.cardProps.includes('comment')
const showPorts = foundation.cardProps.includes('ports')
const {
issue: showIssue,
linearIssue: showLinearIssue,
jiraIssue: showJiraIssue,
pr: showPR,
automation: showAutomation,
cli: showCli,
comment: showComment,
ports: showPorts
} = foundation.cardDetails
const shouldRefreshHostedReview = foundation.newCardStyle ? showStatus : showPR
const detailsHoverControl = useWorktreeCardDetailsHoverControl()
const hoverDetailsOpen = detailsHoverControl.hoverOpen
@@ -146,9 +148,9 @@ export function useWorktreeCardController(props: ResolvedWorktreeCardProps) {
linkedBitbucketPR: review.linkedBitbucketPR,
linkedAzureDevOpsPR: review.linkedAzureDevOpsPR,
linkedGiteaPR: review.linkedGiteaPR,
cardProps: foundation.cardProps,
newCardStyle: foundation.newCardStyle,
compactCards: foundation.compactCards,
cardDetails: foundation.cardDetails,
agentActivityDisplayMode: foundation.agentActivityDisplayMode,
workspacePorts: foundation.workspacePorts,
openTaskPage: foundation.openTaskPage,
@@ -1,6 +1,7 @@
import React, { useCallback, useEffect, useState } from 'react'
import React, { useCallback, useEffect, useMemo, useState } from 'react'
import { DEFAULT_AGENT_ACTIVITY_DISPLAY_MODE } from '../../../../shared/constants'
import { getWorktreeCardDetails } from '../../../../shared/worktree/card-properties'
import {
isRuntimeOwnedSshTargetId,
parseExecutionHostId,
@@ -46,7 +47,12 @@ export function useWorktreeCardFoundation({
useAppStore((s) => s.agentActivityDisplayMode) ?? DEFAULT_AGENT_ACTIVITY_DISPLAY_MODE
const projectGroups = useAppStore((s) => s.projectGroups)
const newCardStyle = settings?.experimentalNewWorktreeCardStyle === true
const compactCards = !newCardStyle && settings?.compactWorktreeCards === true
const compactWorktreeCards = settings?.compactWorktreeCards === true
const cardDetails = useMemo(
() => getWorktreeCardDetails(cardProps, { compactWorktreeCards, newCardStyle }),
[cardProps, compactWorktreeCards, newCardStyle]
)
const { compactCards } = cardDetails
const handleEditIssue = useCallback(
(e: React.MouseEvent) => {
e.stopPropagation()
@@ -236,6 +242,7 @@ export function useWorktreeCardFoundation({
projectGroups,
newCardStyle,
compactCards,
cardDetails,
handleEditIssue,
handleManageLinks,
handleEditComment,
@@ -3,6 +3,7 @@
import { act, type ReactNode } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { getWorktreeCardDetails } from '../../../../shared/worktree/card-properties'
import { useAppStore } from '@/store'
import { readStoreListenerCount } from '@/store/store-listener-census'
import type { GlobalSettings } from '../../../../shared/global-settings-types'
@@ -93,9 +94,9 @@ function secondaryDetailsArgs(settings: GlobalSettings) {
linkedBitbucketPR: null,
linkedAzureDevOpsPR: null,
linkedGiteaPR: null,
cardProps: [] as never,
newCardStyle: false,
compactCards: false,
cardDetails: getWorktreeCardDetails([], { compactWorktreeCards: false, newCardStyle: false }),
agentActivityDisplayMode: 'compact' as const,
workspacePorts: [],
openTaskPage: (() => {}) as never,
@@ -39,9 +39,9 @@ export function useWorktreeCardSecondaryDetails({
linkedBitbucketPR,
linkedAzureDevOpsPR,
linkedGiteaPR,
cardProps,
newCardStyle,
compactCards,
cardDetails,
agentActivityDisplayMode,
workspacePorts,
openTaskPage,
@@ -50,9 +50,9 @@ export function useWorktreeCardSecondaryDetails({
}: Pick<WorktreeCardProps, 'worktree' | 'repo' | 'statusPrDisplay'> &
Pick<
Foundation,
| 'cardProps'
| 'newCardStyle'
| 'compactCards'
| 'cardDetails'
| 'agentActivityDisplayMode'
| 'workspacePorts'
| 'openTaskPage'
@@ -89,7 +89,7 @@ export function useWorktreeCardSecondaryDetails({
const metaAutomationProvenance = showAutomation ? worktree.automationProvenance : null
const metaCliProvenance = showCli ? worktree.cliProvenance : null
const metaComment = showComment ? hoverComment : null
const showInlineAgentList = cardProps.includes('inline-agents') && (newCardStyle || !compactCards)
const showInlineAgentList = cardDetails.inlineAgents
const compactInlineAgentRows = useWorktreeAgentRows(
worktree.id,
showInlineAgentList && agentActivityDisplayMode === 'compact'
@@ -10,7 +10,10 @@ import {
DropdownMenuSubTrigger
} from '@/components/ui/dropdown-menu'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { countActiveWorkspaceFilters } from '../../../../shared/workspace-view/workspace-row-filters'
import {
countActiveWorkspaceFilters,
formatActiveFilterLabel
} from '../../../../shared/workspace-view/workspace-row-filters'
import { useShallow } from 'zustand/react/shallow'
import { selectSidebarFilterState, sidebarWorkspaceRowFilters } from './sidebar-filter-actions'
import SidebarRepositoryFilterSection from './SidebarRepositoryFilterSection'
@@ -35,7 +38,6 @@ import {
} from '../../../../shared/workspace-view/workspace-view-options'
import { translate } from '@/i18n/i18n'
import { SidebarGroupByToggle } from './SidebarGroupByToggle'
import { formatActiveFilterLabel } from './options-filter-count-badge'
export function useWorkspaceOptionsFilterBadge(): {
hasAnyFilter: boolean
@@ -11,6 +11,7 @@ import {
DropdownMenuSubTrigger
} from '@/components/ui/dropdown-menu'
import type { WorktreeCardProperty } from '../../../../shared/ui-chrome-types'
import { getWorkspaceCardPropertiesValue } from '../../../../shared/workspace-view/workspace-view-options'
import {
AGENT_ACTIVITY_DISPLAY_OPTIONS,
CARD_LAYOUT_OPTIONS,
@@ -131,9 +132,7 @@ export function WorktreeCardPropertiesMenuItem({
const agentActivityDisplayMode = useAppStore((s) => s.agentActivityDisplayMode)
const setAgentActivityDisplayMode = useAppStore((s) => s.setAgentActivityDisplayMode)
const toggleProperty = useToggleCardProperty()
const visiblePropertyCount = PROPERTY_OPTIONS.filter((opt) =>
worktreeCardProperties.includes(opt.id)
).length
const value = getWorkspaceCardPropertiesValue(worktreeCardProperties, compact)
return (
<DropdownMenuSub>
<DropdownMenuSubTrigger>
@@ -144,15 +143,16 @@ export function WorktreeCardPropertiesMenuItem({
'Show properties'
)}
</span>
{compact ? (
{value === null ? null : (
<span className="text-[11px] font-medium text-muted-foreground">
{translate('auto.components.sidebar.SidebarWorkspaceOptionsMenu.3d4b9c4997', 'Hover')}
{value === 'hover'
? translate(
'auto.components.sidebar.SidebarWorkspaceOptionsMenu.3d4b9c4997',
'Hover'
)
: value}
</span>
) : visiblePropertyCount > 0 ? (
<span className="text-[11px] font-medium text-muted-foreground">
{visiblePropertyCount}
</span>
) : null}
)}
</span>
</DropdownMenuSubTrigger>
<DropdownMenuSubContent
@@ -28,7 +28,10 @@ import {
import { getWorktreeHostIdentity } from '../../../../../../shared/worktree/host-qualified-identity'
import type { OrderedGroupEntry, WorktreeGroupEntry } from './worktree-group-entry'
import type { ProjectGroupingModel } from '../../../../../../shared/project-section-grouping'
import { appendProjectGroupSections, getProjectGroupOccurrencePaths } from './project-group-sections'
import {
appendProjectGroupSections,
getProjectGroupOccurrencePaths
} from './project-group-sections'
import { getSidebarLineageChildren } from '../../pinned-section-worktrees'
import { emitPinnedGroup } from './pinned-group-rows'
import {
@@ -103,29 +106,35 @@ export function buildSidebarRowPlan(
: new Map<string, Worktree[]>(),
isPinned: (worktree) => worktree.isPinned === true,
duplicatePinned: pinnedDisplayPolicy === 'duplicate-in-groups',
owningSectionOf: (worktree) => getProjectGroupingForRepo(worktree.repoId, repoMap, projectIndex),
owningSectionOf: (worktree) =>
getProjectGroupingForRepo(worktree.repoId, repoMap, projectIndex),
projectIdOf: (worktree) => worktree.repoId,
sectionsOf: ({ pinned, natural, displayRootByIdentity }) => {
const groups: OrderedGroupEntry[] =
groupBy === 'none'
? [[ALL_GROUP_KEY, { label: ALL_GROUP_META.label, items: [...natural], repoIds: new Set() }]]
? [
[
ALL_GROUP_KEY,
{ label: ALL_GROUP_META.label, items: [...natural], repoIds: new Set() }
]
]
: buildOrderedGroups({
groupBy,
naturalWorktrees: natural,
repoMap,
prCache,
settings,
workspaceStatuses,
projectIndex,
displayRootByIdentity,
placeholderRepoIds,
importedWorktreesByRepo,
newExternalWorktreesInboxByRepo,
pendingByRepo,
repoOrder,
projectOrderBy,
folderWorkspaces: renderableFolderWorkspaces
})
groupBy,
naturalWorktrees: natural,
repoMap,
prCache,
settings,
workspaceStatuses,
projectIndex,
displayRootByIdentity,
placeholderRepoIds,
importedWorktreesByRepo,
newExternalWorktreesInboxByRepo,
pendingByRepo,
repoOrder,
projectOrderBy,
folderWorkspaces: renderableFolderWorkspaces
})
if (pinned.length > 0) {
groups.unshift([
PINNED_GROUP_KEY,
@@ -2,11 +2,20 @@ import { describe, expect, it } from 'vitest'
import type { WorktreeLineage } from '../../../../../../shared/worktree/lineage-types'
import { getWorktreeHostIdentity } from '../../../../../../shared/worktree/host-qualified-identity'
import type { Worktree } from '../../../../../../shared/worktree/types'
import { repo, worktree, project, projectHostSetups } from '../../worktree-list-groups-test-fixtures'
import {
repo,
worktree,
project,
projectHostSetups
} from '../../worktree-list-groups-test-fixtures'
import { makeProjectGroup } from '../../folder-workspace-composer-submit-fixtures'
import { getWorktreeRevealCollapsedGroupKeys } from '../navigation/worktree-reveal-group-keys'
import { buildRows, buildSidebarRowPlan } from './build-rows'
import { getWorktreeLineageGroupKey, getProjectGroupHeaderKey, PINNED_GROUP_KEY } from './group-keys'
import {
getWorktreeLineageGroupKey,
getProjectGroupHeaderKey,
PINNED_GROUP_KEY
} from './group-keys'
const childRepo = { ...repo, id: 'child-repo', displayName: 'Child project', path: '/tmp/child' }
const repos = new Map([
@@ -63,18 +72,40 @@ describe('cross-project sidebar lineage', () => {
{ ...worktree, id: 'gamma', repoId: gammaRepo.id, lastActivityAt: 50 }
],
new Map([...repos, [gammaRepo.id, gammaRepo]]),
null, new Set(), undefined, undefined, 'recent', lineageById, worktreeMap, true,
undefined, [], new Set(),
new Map([[childRepo.id, { repo: childRepo, hiddenWorktrees: [] }]]), new Map(),
null,
new Set(),
undefined,
undefined,
'recent',
lineageById,
worktreeMap,
true,
undefined,
[],
new Set(),
new Map([[childRepo.id, { repo: childRepo, hiddenWorktrees: [] }]]),
new Map(),
[{ creationId: 'creating-child', repoId: childRepo.id }]
)
expect(rows.filter((row) => row.type === 'header').map((row) => [row.repo?.id, row.count]))
.toEqual([[childRepo.id, 0], [gammaRepo.id, 1], [repo.id, 2]])
expect(rows.find((row) => row.type === 'item' && row.worktree.id === child.id))
.toMatchObject({ repo: childRepo, sectionKey: `repo:${repo.id}`, depth: 1 })
const nextSection = rows.findIndex((row) => row.type === 'header' && row.repo?.id === gammaRepo.id)
expect(rows.slice(1, nextSection).map((row) => row.type))
.toEqual(['imported-worktrees-card', 'pending-creation'])
expect(
rows.filter((row) => row.type === 'header').map((row) => [row.repo?.id, row.count])
).toEqual([
[childRepo.id, 0],
[gammaRepo.id, 1],
[repo.id, 2]
])
expect(rows.find((row) => row.type === 'item' && row.worktree.id === child.id)).toMatchObject({
repo: childRepo,
sectionKey: `repo:${repo.id}`,
depth: 1
})
const nextSection = rows.findIndex(
(row) => row.type === 'header' && row.repo?.id === gammaRepo.id
)
expect(rows.slice(1, nextSection).map((row) => row.type)).toEqual([
'imported-worktrees-card',
'pending-creation'
])
expect(rows.find((row) => row.type === 'pending-creation')).toMatchObject({ repo: childRepo })
})
@@ -89,34 +120,71 @@ describe('cross-project sidebar lineage', () => {
const graph = {
...lineageById,
[grandchild.id]: {
...lineage, worktreeId: grandchild.id, worktreeInstanceId: grandchild.instanceId,
parentWorktreeId: child.id, parentWorktreeInstanceId: 'child-instance'
...lineage,
worktreeId: grandchild.id,
worktreeInstanceId: grandchild.instanceId,
parentWorktreeId: child.id,
parentWorktreeInstanceId: 'child-instance'
}
}
const worktrees = [grandchild, child, pinnedParent]
const parentSection = `project:${project.id}::setup:${repo.id}`
const plan = buildSidebarRowPlan(
'repo', worktrees, repoMap, null, new Set(), undefined, undefined, undefined,
graph, new Map(worktrees.map((row) => [row.id, row])), true,
undefined, [group], undefined, undefined, undefined, undefined, grouping,
undefined, undefined, 'local', policy
'repo',
worktrees,
repoMap,
null,
new Set(),
undefined,
undefined,
undefined,
graph,
new Map(worktrees.map((row) => [row.id, row])),
true,
undefined,
[group],
undefined,
undefined,
undefined,
undefined,
grouping,
undefined,
undefined,
'local',
policy
)
const occurrence = plan.occurrencePlan.preferredByIdentity.get(
getWorktreeHostIdentity(grandchild)
)
const occurrence = plan.occurrencePlan.preferredByIdentity.get(getWorktreeHostIdentity(grandchild))
expect(occurrence).toMatchObject({
row: { repoId: childRepo.id }, depth: 2, showProjectName: false,
row: { repoId: childRepo.id },
depth: 2,
showProjectName: false,
sectionKey: policy === 'single-location' ? PINNED_GROUP_KEY : parentSection
})
expect(occurrence?.parent?.row).toBe(child)
const collapsedGroups = new Set([
PINNED_GROUP_KEY, parentSection, getProjectGroupHeaderKey(group.id),
getWorktreeLineageGroupKey(parent), getWorktreeLineageGroupKey(child)
PINNED_GROUP_KEY,
parentSection,
getProjectGroupHeaderKey(group.id),
getWorktreeLineageGroupKey(parent),
getWorktreeLineageGroupKey(child)
])
expect(plan.render(collapsedGroups).some((row) => row.type === 'item')).toBe(false)
const keys = getWorktreeRevealCollapsedGroupKeys({
occurrencePlan: plan.occurrencePlan,
worktree: grandchild, worktrees, worktreeLineageById: graph, repoMap,
defaultHostId: 'local', hostScopedGroups: false, collapsedGroups, groupBy: 'repo',
pinnedDisplayPolicy: policy, prCache: null, workspaceStatuses: [], settings: null,
worktree: grandchild,
worktrees,
worktreeLineageById: graph,
repoMap,
defaultHostId: 'local',
hostScopedGroups: false,
collapsedGroups,
groupBy: 'repo',
pinnedDisplayPolicy: policy,
prCache: null,
workspaceStatuses: [],
settings: null,
projectGroups: [group]
})
for (const key of keys) {
@@ -131,8 +199,17 @@ describe('cross-project sidebar lineage', () => {
it('keeps a hidden parent child in its own section without redundant context', () => {
const rows = buildRows(
'repo', [child], repos, null, new Set(), undefined, undefined, undefined,
lineageById, worktreeMap, true
'repo',
[child],
repos,
null,
new Set(),
undefined,
undefined,
undefined,
lineageById,
worktreeMap,
true
)
expect(rows.filter((row) => row.type === 'item')).toMatchObject([
{ worktree: child, sectionKey: `repo:${childRepo.id}`, depth: 0, showProjectName: false }
@@ -39,31 +39,33 @@ export function getWorktreeRevealCollapsedGroupKeys(args: {
args.repoMap.get(args.worktree.repoId),
args.defaultHostId
)
const plan = args.occurrencePlan ?? buildSidebarRowPlan(
args.groupBy,
[...(args.visibleWorktrees ?? args.worktrees)],
args.repoMap,
args.prCache,
new Set(),
undefined,
args.workspaceStatuses,
undefined,
{ ...args.worktreeLineageById },
new Map(args.worktrees.map((row) => [row.id, row])),
true,
args.settings,
args.projectGroups,
undefined,
undefined,
undefined,
undefined,
args.projectGrouping,
undefined,
undefined,
args.defaultHostId,
args.pinnedDisplayPolicy,
args.filterRepoIds
).occurrencePlan
const plan =
args.occurrencePlan ??
buildSidebarRowPlan(
args.groupBy,
[...(args.visibleWorktrees ?? args.worktrees)],
args.repoMap,
args.prCache,
new Set(),
undefined,
args.workspaceStatuses,
undefined,
{ ...args.worktreeLineageById },
new Map(args.worktrees.map((row) => [row.id, row])),
true,
args.settings,
args.projectGroups,
undefined,
undefined,
undefined,
undefined,
args.projectGrouping,
undefined,
undefined,
args.defaultHostId,
args.pinnedDisplayPolicy,
args.filterRepoIds
).occurrencePlan
const occurrence = plan.preferredByIdentity.get(getWorktreeHostIdentity(args.worktree))
if (!occurrence) {
return []
+5 -1
View File
@@ -286,7 +286,11 @@ export function buildProjectSections<
for (const item of args.items) {
const repoId = args.repoIdOf(item)
addItemToProjectSection(
sections, item, args.displayRepoIdOf?.(item) ?? repoId, repoMap, projectIndex
sections,
item,
args.displayRepoIdOf?.(item) ?? repoId,
repoMap,
projectIndex
)
}
// Why: repos can arrive before worktree scans, but stale IDs passed by
+9 -2
View File
@@ -74,9 +74,16 @@ describe('projectResolvedWorktreeLineage', () => {
const child = worktree('child', 'child-instance')
it('projects exact instance-aware metadata across projects on one host', () => {
const foreignParent = { ...parent, repoId: 'other', projectId: 'parent', hostId: 'local' as const }
const foreignParent = {
...parent,
repoId: 'other',
projectId: 'parent',
hostId: 'local' as const
}
const ownedChild = { ...child, projectId: 'child', hostId: 'local' as const }
const projected = projectResolvedWorktreeLineage([ownedChild, foreignParent], { child: lineage() })
const projected = projectResolvedWorktreeLineage([ownedChild, foreignParent], {
child: lineage()
})
expect(projected).toMatchObject([
{ id: 'child', parentWorktreeId: 'parent', childWorktreeIds: [], lineage: lineage() },
@@ -0,0 +1,17 @@
import { describe, expect, it } from 'vitest'
import { toggleAllVisibleHosts, toggleVisibleHostRow } from './workspace-host-visibility'
const hosts = [{ id: 'local' as const }, { id: 'ssh:box' as const }]
describe('the Hosts checklist', () => {
it('"All hosts" shows every host, or narrows to the first when already showing all', () => {
expect(toggleAllVisibleHosts(hosts, ['ssh:box'])).toBeNull()
expect(toggleAllVisibleHosts(hosts, null)).toEqual(['local'])
expect(toggleAllVisibleHosts([], null)).toBeUndefined()
})
it('refuses to hide the last shown host and collapses a full list back to every host', () => {
expect(toggleVisibleHostRow(hosts, ['local'], hosts[0])).toBeUndefined()
expect(toggleVisibleHostRow(hosts, ['local'], hosts[1])).toBeNull()
})
})
@@ -4,6 +4,11 @@ import {
type ExecutionHostId,
type ExecutionHostScope
} from '../execution-host'
import {
expandEquivalentExecutionHostIds,
pickerExecutionHosts,
type MergedExecutionHost
} from '../managed-orcad-execution-host'
// The Workspace options "Hosts" value, shared so every surface reads and writes it the same way.
@@ -34,3 +39,53 @@ export function workspaceHostScopeForVisibleHosts(
}
return visibleHostIds.length === 1 ? visibleHostIds[0] : current
}
/** Picker rows a saved visibility list selects; a row counts when any id it stands for is saved. */
export function getCheckedHostRows<T extends MergedExecutionHost>(
rows: readonly T[],
visibleHostIds: readonly ExecutionHostId[]
): T[] {
const visible = new Set(visibleHostIds)
return rows.filter(
(row) => visible.has(row.id) || (row.aliasHostIds ?? []).some((id) => visible.has(id))
)
}
/**
* The visibility list after toggling one picker row, or undefined when the toggle is refused (the
* last checked row). A row writes every id it stands for, so exact-id filters match both owners.
*/
export function toggleVisibleHostRow(
hosts: readonly MergedExecutionHost[],
visibleHostIds: readonly ExecutionHostId[] | null,
row: MergedExecutionHost
): ExecutionHostId[] | null | undefined {
const rowIds = expandEquivalentExecutionHostIds(hosts, [row.id])
if (!visibleHostIds) {
return rowIds
}
const rows = pickerExecutionHosts(hosts)
const checkedRows = getCheckedHostRows(rows, visibleHostIds)
const next = new Set(visibleHostIds)
if (checkedRows.some((checked) => checked.id === row.id)) {
if (checkedRows.length <= 1) {
return undefined
}
rowIds.forEach((id) => next.delete(id))
} else {
rowIds.forEach((id) => next.add(id))
}
return getCheckedHostRows(rows, [...next]).length === rows.length ? null : [...next]
}
/** "All hosts": shows every host, or when it already does, narrows to the first row. */
export function toggleAllVisibleHosts(
hosts: readonly MergedExecutionHost[],
visibleHostIds: readonly ExecutionHostId[] | null
): ExecutionHostId[] | null | undefined {
if (visibleHostIds) {
return null
}
const firstHost = pickerExecutionHosts(hosts)[0]
return firstHost ? expandEquivalentExecutionHostIds(hosts, [firstHost.id]) : undefined
}

Some files were not shown because too many files have changed in this diff Show More