From 7538fa94667ec64683e9fbe2ecbeeb13de90c346 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Thu, 15 Oct 2026 19:27:51 -0700 Subject: [PATCH] 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 --- config/scripts/check-focus-read-ratchet.mjs | 5 +- .../mobile-web-app-drawer-render.test.mjs | 6 +- mobile/app/mobile-onboarding.tsx | 2 +- .../goldens/host-view-settings-sync.json | 310 +++- ...rix-host.view-settings-settings.get-1.json | 1344 +++++++++++++++++ .../matrix-host.view-settings-ui.get-1.json | 841 ++++++++--- .../matrix-host.view-settings-ui.set-1.json | 674 +++++---- mobile/rpc-foundation/pilot-scenarios.json | 14 + mobile/src/components/BottomDrawer.tsx | 1 + .../src/components/MobileSegmentedControl.tsx | 69 + mobile/src/components/MobileSwitch.tsx | 26 + .../src/components/WorktreeAgentList.test.tsx | 15 + mobile/src/components/WorktreeAgentList.tsx | 6 +- mobile/src/components/WorktreeListRow.test.ts | 97 +- mobile/src/components/WorktreeListRow.tsx | 29 +- .../components/WorktreeMetaGlyphs.test.tsx | 64 + mobile/src/components/WorktreeMetaGlyphs.tsx | 42 +- mobile/src/components/bottom-drawer-styles.ts | 5 + .../src/components/mounted-bottom-drawer.tsx | 16 +- .../host-list-settings-sync.test.ts | 238 +++ .../host-screen-header-reconnect.test.tsx | 2 +- mobile/src/host-screen/host-screen-header.tsx | 80 +- .../src/host-screen/host-screen-operations.ts | 16 +- .../host-screen/host-screen-overlays.test.tsx | 9 +- .../src/host-screen/host-screen-overlays.tsx | 103 +- .../host-screen/host-screen-primary-styles.ts | 25 +- .../host-screen/host-screen-reply-schema.ts | 10 +- .../host-screen-secondary-styles.ts | 44 + .../host-show-pinned-in-groups.test.ts | 140 -- .../host-workspace-list-action-error.test.tsx | 3 +- .../host-workspace-list-row-memo.test.tsx | 12 +- .../src/host-screen/host-workspace-list.tsx | 8 + .../phone-card-property-choices.test.ts | 27 + .../phone-card-property-choices.ts | 19 + .../host-screen/use-host-screen-controller.ts | 14 +- .../src/host-screen/use-host-screen-state.ts | 12 +- .../src/host-screen/use-host-view-settings.ts | 125 +- .../host-screen/use-host-worktree-catalog.ts | 27 +- .../host-screen/workspace-option-controls.tsx | 325 ++++ .../src/host-screen/workspace-option-rows.tsx | 123 ++ .../workspace-option-values.test.ts | 22 + .../host-screen/workspace-option-values.ts | 22 + .../workspace-options-count-badge.tsx | 36 + .../host-screen/workspace-options-pager.tsx | 93 ++ .../workspace-options-sheet.test.tsx | 229 +++ .../host-screen/workspace-options-sheet.tsx | 183 +++ .../NotificationOnboardingPreview.tsx | 2 +- .../use-reduced-motion.ts | 0 mobile/src/settings/voice-settings-screen.tsx | 26 +- mobile/src/settings/voice-settings-styles.ts | 15 - .../bridged-parity/c1-page-closure.test.ts | 4 +- .../bridged-parity/c1-page-closure.ts | 1 + .../bridged-parity/c5-page-closure.test.ts | 8 +- .../bridged-parity/c5-page-closure.ts | 1 + .../bridged-parity/divergence-classes.test.ts | 2 +- .../bridged-parity/divergence-classes.ts | 2 +- .../adapters/host-screen-mount-adapters.ts | 1 - .../settings-read-operations.test.ts | 48 +- .../src/transport/settings-read-operations.ts | 34 +- .../worktree/project-section-catalog.test.ts | 7 +- .../src/worktree/project-section-catalog.ts | 13 +- .../worktree/project-sections-operations.ts | 1 + .../worktree/workspace-list-picker-options.ts | 34 - ...space-list-sections-desktop-parity.test.ts | 8 +- mobile/src/worktree/workspace-list-types.ts | 7 +- .../worktree/workspace-options-copy.test.ts | 19 + mobile/src/worktree/workspace-options-copy.ts | 22 + .../worktree/workspace-view-settings.test.ts | 47 + .../src/worktree/workspace-view-settings.ts | 58 +- .../worktree-meta-glyph-presence.test.ts | 77 + .../worktree/worktree-meta-glyph-presence.ts | 44 + .../ipc/pty-launch-parity.test-fixture.ts | 4 +- .../worktrees-canonical-ssh-listing.test.ts | 137 +- ...ntime-workspace-view-change-events.test.ts | 31 +- .../runtime-workspace-view-change-events.ts | 16 +- src/main/worktree-lineage-host-peers.ts | 3 +- .../activity/activity-thread-hover-card.tsx | 2 +- .../activity/activity-thread-options-menu.tsx | 6 +- .../ComposerParentWorktreePicker.tsx | 4 +- .../sidebar/DeleteWorktreeDialog.test.tsx | 19 +- .../sidebar/SidebarHostScopeMenuSection.tsx | 23 +- .../agent-send-picker-collapse.test.tsx | 5 +- .../sidebar/options-filter-count-badge.tsx | 8 +- .../sidebar/pinned-section-worktrees.test.ts | 42 +- .../sidebar/pinned-section-worktrees.ts | 5 +- .../sidebar/sidebar-host-options.test.ts | 2 +- .../sidebar/sidebar-host-options.ts | 44 +- .../sidebar/use-worktree-card-controller.ts | 20 +- .../sidebar/use-worktree-card-foundation.ts | 11 +- ...ndary-details.store-subscriptions.test.tsx | 3 +- .../use-worktree-card-secondary-details.ts | 6 +- .../sidebar/workspace-options-menu-items.tsx | 6 +- .../sidebar/worktree-card-option-items.tsx | 20 +- .../worktree-list/grouping/build-rows.ts | 47 +- .../grouping/cross-project-lineage.test.ts | 131 +- .../navigation/worktree-reveal-group-keys.ts | 52 +- src/shared/project-section-grouping.ts | 6 +- src/shared/resolved-worktree-lineage.test.ts | 11 +- .../workspace-host-visibility.test.ts | 17 + .../workspace-host-visibility.ts | 55 + .../workspace-view/workspace-row-filters.ts | 10 + .../workspace-view-options.test.ts | 10 + .../workspace-view/workspace-view-options.ts | 17 + .../workspace-view-ui-fields.ts | 3 + src/shared/worktree/card-properties.test.ts | 28 + src/shared/worktree/card-properties.ts | 40 + 106 files changed, 5432 insertions(+), 1406 deletions(-) create mode 100644 mobile/rpc-foundation/goldens/matrix-host.view-settings-settings.get-1.json create mode 100644 mobile/src/components/MobileSegmentedControl.tsx create mode 100644 mobile/src/components/MobileSwitch.tsx create mode 100644 mobile/src/components/WorktreeMetaGlyphs.test.tsx create mode 100644 mobile/src/host-screen/host-list-settings-sync.test.ts delete mode 100644 mobile/src/host-screen/host-show-pinned-in-groups.test.ts create mode 100644 mobile/src/host-screen/phone-card-property-choices.test.ts create mode 100644 mobile/src/host-screen/phone-card-property-choices.ts create mode 100644 mobile/src/host-screen/workspace-option-controls.tsx create mode 100644 mobile/src/host-screen/workspace-option-rows.tsx create mode 100644 mobile/src/host-screen/workspace-option-values.test.ts create mode 100644 mobile/src/host-screen/workspace-option-values.ts create mode 100644 mobile/src/host-screen/workspace-options-count-badge.tsx create mode 100644 mobile/src/host-screen/workspace-options-pager.tsx create mode 100644 mobile/src/host-screen/workspace-options-sheet.test.tsx create mode 100644 mobile/src/host-screen/workspace-options-sheet.tsx rename mobile/src/{onboarding => platform}/use-reduced-motion.ts (100%) delete mode 100644 mobile/src/worktree/workspace-list-picker-options.ts create mode 100644 mobile/src/worktree/workspace-options-copy.test.ts create mode 100644 mobile/src/worktree/workspace-options-copy.ts create mode 100644 mobile/src/worktree/worktree-meta-glyph-presence.test.ts create mode 100644 mobile/src/worktree/worktree-meta-glyph-presence.ts create mode 100644 src/shared/workspace-view/workspace-host-visibility.test.ts diff --git a/config/scripts/check-focus-read-ratchet.mjs b/config/scripts/check-focus-read-ratchet.mjs index 3af9936ffb5..6ba68dc4127 100644 --- a/config/scripts/check-focus-read-ratchet.mjs +++ b/config/scripts/check-focus-read-ratchet.mjs @@ -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. diff --git a/config/scripts/mobile-web-app-drawer-render.test.mjs b/config/scripts/mobile-web-app-drawer-render.test.mjs index 22d3b14a1d5..b664338158a 100644 --- a/config/scripts/mobile-web-app-drawer-render.test.mjs +++ b/config/scripts/mobile-web-app-drawer-render.test.mjs @@ -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 }) diff --git a/mobile/app/mobile-onboarding.tsx b/mobile/app/mobile-onboarding.tsx index 3139de1d6ab..4ef9d4adf79 100644 --- a/mobile/app/mobile-onboarding.tsx +++ b/mobile/app/mobile-onboarding.tsx @@ -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, diff --git a/mobile/rpc-foundation/goldens/host-view-settings-sync.json b/mobile/rpc-foundation/goldens/host-view-settings-sync.json index 632c2b2db26..a898a5bae94 100644 --- a/mobile/rpc-foundation/goldens/host-view-settings-sync.json +++ b/mobile/rpc-foundation/goldens/host-view-settings-sync.json @@ -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"] } } ] diff --git a/mobile/rpc-foundation/goldens/matrix-host.view-settings-settings.get-1.json b/mobile/rpc-foundation/goldens/matrix-host.view-settings-settings.get-1.json new file mode 100644 index 00000000000..15ad750d79d --- /dev/null +++ b/mobile/rpc-foundation/goldens/matrix-host.view-settings-settings.get-1.json @@ -0,0 +1,1344 @@ +{ + "goldenFormatVersion": 6, + "operation": "host.view-settings", + "family": "host.view-settings", + "namedDeltas": [], + "values": { + "0ba58e9fadf7": { + "name": "settings.get#1", + "ordinal": 2, + "args": [ + { + "name": "method", + "value": "settings.get" + }, + { + "name": "params", + "value": { + "$rpc": "absent" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "error": { + "code": "method_not_found", + "message": "Unknown method" + }, + "id": "frame-2", + "ok": false + } + } + }, + "0ca529648be4": { + "name": "settings.get#1", + "ordinal": 2, + "args": [ + { + "name": "method", + "value": "settings.get" + }, + { + "name": "params", + "value": { + "$rpc": "absent" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "rejected", + "startedAt": 0, + "settledAt": 0, + "error": { + "category": "Error", + "message": "", + "isRpcDeliveryUnknown": true + } + } + }, + "1c2564f1daca": { + "name": "settings.get#1", + "ordinal": 4, + "json": "{\"id\":\"frame-2\",\"deviceToken\":\"recording-device\",\"method\":\"settings.get\"}" + }, + "1c5359ea3f7d": { + "name": "settings.get#1", + "ordinal": 2, + "args": [ + { + "name": "method", + "value": "settings.get" + }, + { + "name": "params", + "value": { + "$rpc": "absent" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-2", + "ok": true, + "result": { + "error": { + "message": "inner refused" + }, + "ok": false + } + } + } + }, + "205efdb45ef8": { + "name": "view", + "ordinal": 6, + "value": { + "agentActivityDisplayMode": "compact", + "collapsedGroups": [], + "compactWorktreeCards": false, + "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" + ] + } + }, + "2a983adf6648": { + "view": { + "agentActivityDisplayMode": "compact", + "collapsedGroups": [], + "compactWorktreeCards": false, + "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" + ] + } + }, + "34f3a5b8bd78": { + "name": "settings.get#1", + "ordinal": 2, + "args": [ + { + "name": "method", + "value": "settings.get" + }, + { + "name": "params", + "value": { + "$rpc": "absent" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "error": { + "code": "refused", + "message": "" + }, + "id": "frame-2", + "ok": false + } + } + }, + "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" + ] + } + }, + "510b3345c0c6": { + "name": "view", + "ordinal": 7, + "value": { + "agentActivityDisplayMode": "compact", + "collapsedGroups": [], + "compactWorktreeCards": false, + "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, + "args": [ + { + "name": "method", + "value": "settings.get" + }, + { + "name": "params", + "value": { + "$rpc": "absent" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "pending", + "startedAt": 0 + } + }, + "566d8202b582": { + "name": "settings.get#1", + "ordinal": 2, + "args": [ + { + "name": "method", + "value": "settings.get" + }, + { + "name": "params", + "value": { + "$rpc": "absent" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "rejected", + "startedAt": 0, + "settledAt": 0, + "error": { + "category": "Error", + "message": "transport failure", + "isRpcDeliveryUnknown": true + } + } + }, + "651e8014c9ff": { + "name": "ui.get#1", + "ordinal": 1, + "args": [ + { + "name": "method", + "value": "ui.get" + }, + { + "name": "params", + "value": { + "$rpc": "absent" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-1", + "ok": true, + "result": { + "ui": { + "groupBy": "repo", + "hideSleepingWorkspaces": true, + "sortBy": "name" + } + } + } + } + }, + "69518e60477b": { + "name": "settings.get#1", + "ordinal": 2, + "args": [ + { + "name": "method", + "value": "settings.get" + }, + { + "name": "params", + "value": { + "$rpc": "absent" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-2", + "ok": true, + "result": { + "$rpc": "null" + } + } + } + }, + "6bab186637b0": { + "name": "showPinnedInGroups", + "ordinal": 5, + "value": false + }, + "8369a6f35fda": { + "name": "view", + "ordinal": 6, + "value": { + "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" + ] + } + }, + "86798e4821d1": { + "name": "settings.get#1", + "ordinal": 2, + "args": [ + { + "name": "method", + "value": "settings.get" + }, + { + "name": "params", + "value": { + "$rpc": "absent" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-2", + "ok": true, + "result": { + "error": "inner refused", + "ok": false + } + } + } + }, + "89c617ff2299": { + "name": "ui.set#1", + "ordinal": 7, + "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 + } + } + } + }, + "9270aeb7d9c6": { + "status": "pending", + "startedAt": 0 + }, + "985caf5772e9": { + "name": "ui.get#1", + "ordinal": 1, + "args": [ + { + "name": "method", + "value": "ui.get" + }, + { + "name": "params", + "value": { + "$rpc": "absent" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "pending", + "startedAt": 0 + } + }, + "98bd3a5fda2b": { + "name": "settings.get#1", + "ordinal": 2, + "args": [ + { + "name": "method", + "value": "settings.get" + }, + { + "name": "params", + "value": { + "$rpc": "absent" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-2", + "ok": true + } + } + }, + "aa1089f2edc0": { + "name": "ui.set#1", + "ordinal": 8, + "json": "{\"id\":\"frame-3\",\"deviceToken\":\"recording-device\",\"method\":\"ui.set\",\"params\":{\"sortBy\":\"name\"}}" + }, + "c49dcad120d2": { + "name": "view", + "ordinal": 7, + "value": { + "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" + ] + } + }, + "cd9c5de9f2aa": { + "name": "ui.set#1", + "ordinal": 9, + "json": "{\"id\":\"frame-3\",\"deviceToken\":\"recording-device\",\"method\":\"ui.set\",\"params\":{\"sortBy\":\"name\"}}" + }, + "ceb5fe72c101": { + "name": "settings.get#1", + "ordinal": 2, + "args": [ + { + "name": "method", + "value": "settings.get" + }, + { + "name": "params", + "value": { + "$rpc": "absent" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "error": { + "code": "refused", + "message": "outer refused" + }, + "id": "frame-2", + "ok": false + } + } + }, + "dc007face350": { + "view": { + "agentActivityDisplayMode": "compact", + "collapsedGroups": [], + "compactWorktreeCards": false, + "filters": { + "alwaysShowDefaultBranchWorkspace": true, + "filterRepoIds": [], + "hideAutomationGeneratedWorkspaces": false, + "hideCliCreatedWorkspaces": false, + "hideDefaultBranchWorkspace": false, + "hideDetachedHeadWorkspaces": false, + "hideSleepingWorkspaces": false, + "hideWorkspacesFromOtherDevices": false, + "visibleHostIds": { + "$rpc": "null" + } + }, + "groupBy": "repo", + "newCardStyle": false, + "projectOrderBy": { + "$rpc": "null" + }, + "sharesOtherClientFilter": { + "$rpc": "null" + }, + "sortBy": "recent", + "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" + ] + } + }, + "dc0e0f9e2e34": { + "name": "settings.get#1", + "ordinal": 2, + "args": [ + { + "name": "method", + "value": "settings.get" + }, + { + "name": "params", + "value": { + "$rpc": "absent" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "rejected", + "startedAt": 0, + "settledAt": 0, + "error": { + "category": "Error", + "message": "Connection closed", + "isRpcDeliveryUnknown": true + } + } + }, + "e55533c6e358": { + "name": "settings.get#1", + "ordinal": 2, + "args": [ + { + "name": "method", + "value": "settings.get" + }, + { + "name": "params", + "value": { + "$rpc": "absent" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-2", + "ok": true, + "result": { + "settings": { + "compactWorktreeCards": true + } + } + } + } + }, + "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\"}" + }, + "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 + } + } + } + }, + "f8b3ebb97001": { + "name": "view", + "ordinal": 5, + "value": { + "agentActivityDisplayMode": "compact", + "collapsedGroups": [], + "compactWorktreeCards": false, + "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" + ] + } + }, + "fb9f08890f70": { + "name": "settings.get#1", + "ordinal": 2, + "args": [ + { + "name": "method", + "value": "settings.get" + }, + { + "name": "params", + "value": { + "$rpc": "absent" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-2", + "ok": true, + "result": { + "error": "refused" + } + } + } + } + }, + "recording": { + "scenario": "matrix-host.view-settings-settings.get-1", + "checkpoints": [ + { + "id": "host-view-settings-sync.prelude:ui-pending", + "observation": { + "sender": ["985caf5772e9", "51d4cb56be85"], + "payloads": ["ec336f23ef91", "1c2564f1daca"], + "settlements": { + "mount": "eb79a9b3682a", + "sync": "9270aeb7d9c6" + }, + "state": "dc007face350", + "effects": [] + } + }, + { + "id": "host-view-settings-sync.prelude:cleanup", + "observation": { + "sender": ["651e8014c9ff", "dc0e0f9e2e34"], + "payloads": ["ec336f23ef91", "1c2564f1daca"], + "settlements": { + "mount": "eb79a9b3682a", + "sync": "eb79a9b3682a" + }, + "state": "dc007face350", + "effects": ["f8b3ebb97001"] + } + }, + { + "id": "host-view-settings-sync.normal:settled", + "observation": { + "sender": ["651e8014c9ff", "e55533c6e358", "f56221cb03b8"], + "payloads": ["ec336f23ef91", "1c2564f1daca", "cd9c5de9f2aa"], + "settlements": { + "mount": "eb79a9b3682a", + "sync": "eb79a9b3682a", + "sort": "eb79a9b3682a" + }, + "state": "424e4d52d6fa", + "effects": ["6bab186637b0", "8369a6f35fda", "c49dcad120d2"] + } + }, + { + "id": "host-view-settings-sync.result-absent:settled", + "observation": { + "sender": ["651e8014c9ff", "98bd3a5fda2b", "f56221cb03b8"], + "payloads": ["ec336f23ef91", "1c2564f1daca", "cd9c5de9f2aa"], + "settlements": { + "mount": "eb79a9b3682a", + "sync": "eb79a9b3682a", + "sort": "eb79a9b3682a" + }, + "state": "2a983adf6648", + "effects": ["6bab186637b0", "205efdb45ef8", "510b3345c0c6"] + } + }, + { + "id": "host-view-settings-sync.result-null:settled", + "observation": { + "sender": ["651e8014c9ff", "69518e60477b", "f56221cb03b8"], + "payloads": ["ec336f23ef91", "1c2564f1daca", "cd9c5de9f2aa"], + "settlements": { + "mount": "eb79a9b3682a", + "sync": "eb79a9b3682a", + "sort": "eb79a9b3682a" + }, + "state": "2a983adf6648", + "effects": ["6bab186637b0", "205efdb45ef8", "510b3345c0c6"] + } + }, + { + "id": "host-view-settings-sync.inner-ok-missing:settled", + "observation": { + "sender": ["651e8014c9ff", "fb9f08890f70", "f56221cb03b8"], + "payloads": ["ec336f23ef91", "1c2564f1daca", "cd9c5de9f2aa"], + "settlements": { + "mount": "eb79a9b3682a", + "sync": "eb79a9b3682a", + "sort": "eb79a9b3682a" + }, + "state": "2a983adf6648", + "effects": ["6bab186637b0", "205efdb45ef8", "510b3345c0c6"] + } + }, + { + "id": "host-view-settings-sync.inner-false-string-error:settled", + "observation": { + "sender": ["651e8014c9ff", "86798e4821d1", "f56221cb03b8"], + "payloads": ["ec336f23ef91", "1c2564f1daca", "cd9c5de9f2aa"], + "settlements": { + "mount": "eb79a9b3682a", + "sync": "eb79a9b3682a", + "sort": "eb79a9b3682a" + }, + "state": "2a983adf6648", + "effects": ["6bab186637b0", "205efdb45ef8", "510b3345c0c6"] + } + }, + { + "id": "host-view-settings-sync.inner-false-object-error:settled", + "observation": { + "sender": ["651e8014c9ff", "1c5359ea3f7d", "f56221cb03b8"], + "payloads": ["ec336f23ef91", "1c2564f1daca", "cd9c5de9f2aa"], + "settlements": { + "mount": "eb79a9b3682a", + "sync": "eb79a9b3682a", + "sort": "eb79a9b3682a" + }, + "state": "2a983adf6648", + "effects": ["6bab186637b0", "205efdb45ef8", "510b3345c0c6"] + } + }, + { + "id": "host-view-settings-sync.outer-refused:settled", + "observation": { + "sender": ["651e8014c9ff", "ceb5fe72c101", "89c617ff2299"], + "payloads": ["ec336f23ef91", "1c2564f1daca", "aa1089f2edc0"], + "settlements": { + "mount": "eb79a9b3682a", + "sync": "eb79a9b3682a", + "sort": "eb79a9b3682a" + }, + "state": "2a983adf6648", + "effects": ["f8b3ebb97001", "205efdb45ef8"] + } + }, + { + "id": "host-view-settings-sync.outer-refused-no-message:settled", + "observation": { + "sender": ["651e8014c9ff", "34f3a5b8bd78", "89c617ff2299"], + "payloads": ["ec336f23ef91", "1c2564f1daca", "aa1089f2edc0"], + "settlements": { + "mount": "eb79a9b3682a", + "sync": "eb79a9b3682a", + "sort": "eb79a9b3682a" + }, + "state": "2a983adf6648", + "effects": ["f8b3ebb97001", "205efdb45ef8"] + } + }, + { + "id": "host-view-settings-sync.method-not-found:settled", + "observation": { + "sender": ["651e8014c9ff", "0ba58e9fadf7", "89c617ff2299"], + "payloads": ["ec336f23ef91", "1c2564f1daca", "aa1089f2edc0"], + "settlements": { + "mount": "eb79a9b3682a", + "sync": "eb79a9b3682a", + "sort": "eb79a9b3682a" + }, + "state": "2a983adf6648", + "effects": ["f8b3ebb97001", "205efdb45ef8"] + } + }, + { + "id": "host-view-settings-sync.transport-rejection:settled", + "observation": { + "sender": ["651e8014c9ff", "566d8202b582", "89c617ff2299"], + "payloads": ["ec336f23ef91", "1c2564f1daca", "aa1089f2edc0"], + "settlements": { + "mount": "eb79a9b3682a", + "sync": "eb79a9b3682a", + "sort": "eb79a9b3682a" + }, + "state": "2a983adf6648", + "effects": ["f8b3ebb97001", "205efdb45ef8"] + } + }, + { + "id": "host-view-settings-sync.transport-rejection-no-message:settled", + "observation": { + "sender": ["651e8014c9ff", "0ca529648be4", "89c617ff2299"], + "payloads": ["ec336f23ef91", "1c2564f1daca", "aa1089f2edc0"], + "settlements": { + "mount": "eb79a9b3682a", + "sync": "eb79a9b3682a", + "sort": "eb79a9b3682a" + }, + "state": "2a983adf6648", + "effects": ["f8b3ebb97001", "205efdb45ef8"] + } + } + ] + } +} diff --git a/mobile/rpc-foundation/goldens/matrix-host.view-settings-ui.get-1.json b/mobile/rpc-foundation/goldens/matrix-host.view-settings-ui.get-1.json index c85e510a748..df0a06709b9 100644 --- a/mobile/rpc-foundation/goldens/matrix-host.view-settings-ui.get-1.json +++ b/mobile/rpc-foundation/goldens/matrix-host.view-settings-ui.get-1.json @@ -4,89 +4,6 @@ "family": "host.view-settings", "namedDeltas": [], "values": { - "04ce05cbae1a": { - "name": "ui.set#1", - "ordinal": 4, - "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": { - "ok": true - } - } - } - }, - "1601446c4409": { - "view": { - "collapsedGroups": [], - "filters": { - "alwaysShowDefaultBranchWorkspace": true, - "filterRepoIds": [], - "hideAutomationGeneratedWorkspaces": false, - "hideCliCreatedWorkspaces": false, - "hideDefaultBranchWorkspace": false, - "hideDetachedHeadWorkspaces": false, - "hideSleepingWorkspaces": false, - "hideWorkspacesFromOtherDevices": false, - "visibleHostIds": { - "$rpc": "null" - } - }, - "groupBy": "repo", - "sharesOtherClientFilter": { - "$rpc": "null" - }, - "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" - } - ] - } - }, "19da51d77d45": { "name": "ui.get#1", "ordinal": 1, @@ -119,6 +36,82 @@ } } }, + "1c2564f1daca": { + "name": "settings.get#1", + "ordinal": 4, + "json": "{\"id\":\"frame-2\",\"deviceToken\":\"recording-device\",\"method\":\"settings.get\"}" + }, + "238b10afd803": { + "name": "view", + "ordinal": 6, + "value": { + "agentActivityDisplayMode": "compact", + "collapsedGroups": [], + "compactWorktreeCards": false, + "filters": { + "alwaysShowDefaultBranchWorkspace": true, + "filterRepoIds": [], + "hideAutomationGeneratedWorkspaces": false, + "hideCliCreatedWorkspaces": false, + "hideDefaultBranchWorkspace": false, + "hideDetachedHeadWorkspaces": false, + "hideSleepingWorkspaces": false, + "hideWorkspacesFromOtherDevices": false, + "visibleHostIds": { + "$rpc": "null" + } + }, + "groupBy": "repo", + "newCardStyle": false, + "projectOrderBy": { + "$rpc": "null" + }, + "sharesOtherClientFilter": { + "$rpc": "null" + }, + "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" + ] + } + }, "2f74fcfacfbd": { "name": "ui.get#1", "ordinal": 1, @@ -153,11 +146,6 @@ } } }, - "3337ab8c1ffc": { - "name": "ui.set#1", - "ordinal": 6, - "json": "{\"id\":\"frame-2\",\"deviceToken\":\"recording-device\",\"method\":\"ui.set\",\"params\":{\"sortBy\":\"name\"}}" - }, "3937ae2aa8ac": { "name": "ui.get#1", "ordinal": 1, @@ -193,6 +181,73 @@ } } }, + "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" + ] + } + }, "443c5731ec35": { "name": "ui.get#1", "ordinal": 1, @@ -228,23 +283,18 @@ } } }, - "582477c8e0af": { - "name": "ui.get#1", + "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" } }, { @@ -255,16 +305,8 @@ } ], "settlement": { - "status": "fulfilled", - "startedAt": 0, - "settledAt": 0, - "value": { - "id": "frame-2", - "ok": true, - "result": { - "ok": true - } - } + "status": "pending", + "startedAt": 0 } }, "651e8014c9ff": { @@ -340,6 +382,11 @@ } } }, + "6bab186637b0": { + "name": "showPinnedInGroups", + "ordinal": 5, + "value": false + }, "754a5e5d6868": { "name": "ui.get#1", "ordinal": 1, @@ -374,9 +421,13 @@ } } }, - "7bbe6d34ee5c": { - "view": { + "8369a6f35fda": { + "name": "view", + "ordinal": 6, + "value": { + "agentActivityDisplayMode": "compact", "collapsedGroups": [], + "compactWorktreeCards": true, "filters": { "alwaysShowDefaultBranchWorkspace": true, "filterRepoIds": [], @@ -391,6 +442,10 @@ } }, "groupBy": "repo", + "newCardStyle": false, + "projectOrderBy": { + "$rpc": "null" + }, "sharesOtherClientFilter": false, "sortBy": "name", "workspaceStatuses": [ @@ -418,14 +473,62 @@ "id": "completed", "label": "Done" } + ], + "worktreeCardProperties": [ + "status", + "unread", + "issue", + "linear-issue", + "jira-issue", + "pr", + "automation", + "cli", + "comment", + "ports", + "inline-agents", + "host" ] } }, - "85b53cd1c708": { - "name": "view", - "ordinal": 3, - "value": { + "89c617ff2299": { + "name": "ui.set#1", + "ordinal": 7, + "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 + } + } + } + }, + "918022e415be": { + "view": { + "agentActivityDisplayMode": "compact", "collapsedGroups": [], + "compactWorktreeCards": true, "filters": { "alwaysShowDefaultBranchWorkspace": true, "filterRepoIds": [], @@ -440,6 +543,10 @@ } }, "groupBy": "repo", + "newCardStyle": false, + "projectOrderBy": { + "$rpc": "null" + }, "sharesOtherClientFilter": { "$rpc": "null" }, @@ -469,6 +576,20 @@ "id": "completed", "label": "Done" } + ], + "worktreeCardProperties": [ + "status", + "unread", + "issue", + "linear-issue", + "jira-issue", + "pr", + "automation", + "cli", + "comment", + "ports", + "inline-agents", + "host" ] } }, @@ -502,6 +623,82 @@ "startedAt": 0 } }, + "a9b819fe5a27": { + "name": "view", + "ordinal": 7, + "value": { + "agentActivityDisplayMode": "compact", + "collapsedGroups": [], + "compactWorktreeCards": true, + "filters": { + "alwaysShowDefaultBranchWorkspace": true, + "filterRepoIds": [], + "hideAutomationGeneratedWorkspaces": false, + "hideCliCreatedWorkspaces": false, + "hideDefaultBranchWorkspace": false, + "hideDetachedHeadWorkspaces": false, + "hideSleepingWorkspaces": false, + "hideWorkspacesFromOtherDevices": false, + "visibleHostIds": { + "$rpc": "null" + } + }, + "groupBy": "repo", + "newCardStyle": false, + "projectOrderBy": { + "$rpc": "null" + }, + "sharesOtherClientFilter": { + "$rpc": "null" + }, + "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" + ] + } + }, + "aa1089f2edc0": { + "name": "ui.set#1", + "ordinal": 8, + "json": "{\"id\":\"frame-3\",\"deviceToken\":\"recording-device\",\"method\":\"ui.set\",\"params\":{\"sortBy\":\"name\"}}" + }, "c40ad123c151": { "name": "ui.get#1", "ordinal": 1, @@ -537,11 +734,13 @@ } } }, - "c81e3e604f1c": { + "c49dcad120d2": { "name": "view", - "ordinal": 4, + "ordinal": 7, "value": { + "agentActivityDisplayMode": "compact", "collapsedGroups": [], + "compactWorktreeCards": true, "filters": { "alwaysShowDefaultBranchWorkspace": true, "filterRepoIds": [], @@ -556,6 +755,10 @@ } }, "groupBy": "repo", + "newCardStyle": false, + "projectOrderBy": { + "$rpc": "null" + }, "sharesOtherClientFilter": false, "sortBy": "name", "workspaceStatuses": [ @@ -583,57 +786,27 @@ "id": "completed", "label": "Done" } + ], + "worktreeCardProperties": [ + "status", + "unread", + "issue", + "linear-issue", + "jira-issue", + "pr", + "automation", + "cli", + "comment", + "ports", + "inline-agents", + "host" ] } }, - "c886cd24aa84": { - "view": { - "collapsedGroups": [], - "filters": { - "alwaysShowDefaultBranchWorkspace": true, - "filterRepoIds": [], - "hideAutomationGeneratedWorkspaces": false, - "hideCliCreatedWorkspaces": false, - "hideDefaultBranchWorkspace": false, - "hideDetachedHeadWorkspaces": false, - "hideSleepingWorkspaces": false, - "hideWorkspacesFromOtherDevices": false, - "visibleHostIds": { - "$rpc": "null" - } - }, - "groupBy": "repo", - "sharesOtherClientFilter": { - "$rpc": "null" - }, - "sortBy": "recent", - "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" - } - ] - } + "cd9c5de9f2aa": { + "name": "ui.set#1", + "ordinal": 9, + "json": "{\"id\":\"frame-3\",\"deviceToken\":\"recording-device\",\"method\":\"ui.set\",\"params\":{\"sortBy\":\"name\"}}" }, "d5d6bbbd1680": { "name": "ui.get#1", @@ -666,11 +839,11 @@ } } }, - "e51c608f6ed4": { - "name": "view", - "ordinal": 3, - "value": { + "dc007face350": { + "view": { + "agentActivityDisplayMode": "compact", "collapsedGroups": [], + "compactWorktreeCards": false, "filters": { "alwaysShowDefaultBranchWorkspace": true, "filterRepoIds": [], @@ -678,14 +851,125 @@ "hideCliCreatedWorkspaces": false, "hideDefaultBranchWorkspace": false, "hideDetachedHeadWorkspaces": false, - "hideSleepingWorkspaces": true, + "hideSleepingWorkspaces": false, "hideWorkspacesFromOtherDevices": false, "visibleHostIds": { "$rpc": "null" } }, "groupBy": "repo", - "sharesOtherClientFilter": false, + "newCardStyle": false, + "projectOrderBy": { + "$rpc": "null" + }, + "sharesOtherClientFilter": { + "$rpc": "null" + }, + "sortBy": "recent", + "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" + ] + } + }, + "e55533c6e358": { + "name": "settings.get#1", + "ordinal": 2, + "args": [ + { + "name": "method", + "value": "settings.get" + }, + { + "name": "params", + "value": { + "$rpc": "absent" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-2", + "ok": true, + "result": { + "settings": { + "compactWorktreeCards": true + } + } + } + } + }, + "e96acdd915c3": { + "view": { + "agentActivityDisplayMode": "compact", + "collapsedGroups": [], + "compactWorktreeCards": false, + "filters": { + "alwaysShowDefaultBranchWorkspace": true, + "filterRepoIds": [], + "hideAutomationGeneratedWorkspaces": false, + "hideCliCreatedWorkspaces": false, + "hideDefaultBranchWorkspace": false, + "hideDetachedHeadWorkspaces": false, + "hideSleepingWorkspaces": false, + "hideWorkspacesFromOtherDevices": false, + "visibleHostIds": { + "$rpc": "null" + } + }, + "groupBy": "repo", + "newCardStyle": false, + "projectOrderBy": { + "$rpc": "null" + }, + "sharesOtherClientFilter": { + "$rpc": "null" + }, "sortBy": "name", "workspaceStatuses": [ { @@ -712,6 +996,20 @@ "id": "completed", "label": "Done" } + ], + "worktreeCardProperties": [ + "status", + "unread", + "issue", + "linear-issue", + "jira-issue", + "pr", + "automation", + "cli", + "comment", + "ports", + "inline-agents", + "host" ] } }, @@ -723,6 +1021,82 @@ "$rpc": "undefined" } }, + "ec336f23ef91": { + "name": "ui.get#1", + "ordinal": 3, + "json": "{\"id\":\"frame-1\",\"deviceToken\":\"recording-device\",\"method\":\"ui.get\"}" + }, + "ec949dc49cb9": { + "name": "view", + "ordinal": 6, + "value": { + "agentActivityDisplayMode": "compact", + "collapsedGroups": [], + "compactWorktreeCards": true, + "filters": { + "alwaysShowDefaultBranchWorkspace": true, + "filterRepoIds": [], + "hideAutomationGeneratedWorkspaces": false, + "hideCliCreatedWorkspaces": false, + "hideDefaultBranchWorkspace": false, + "hideDetachedHeadWorkspaces": false, + "hideSleepingWorkspaces": false, + "hideWorkspacesFromOtherDevices": false, + "visibleHostIds": { + "$rpc": "null" + } + }, + "groupBy": "repo", + "newCardStyle": false, + "projectOrderBy": { + "$rpc": "null" + }, + "sharesOtherClientFilter": { + "$rpc": "null" + }, + "sortBy": "recent", + "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" + ] + } + }, "efd2522b079c": { "name": "ui.get#1", "ordinal": 1, @@ -792,10 +1166,39 @@ } } }, - "f3cde9fe56ca": { + "f56221cb03b8": { "name": "ui.set#1", - "ordinal": 5, - "json": "{\"id\":\"frame-2\",\"deviceToken\":\"recording-device\",\"method\":\"ui.set\",\"params\":{\"sortBy\":\"name\"}}" + "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": { @@ -804,168 +1207,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": ["d5d6bbbd1680", "04ce05cbae1a"], - "payloads": ["582477c8e0af", "f3cde9fe56ca"], + "sender": ["d5d6bbbd1680", "e55533c6e358", "89c617ff2299"], + "payloads": ["ec336f23ef91", "1c2564f1daca", "aa1089f2edc0"], "settlements": { "mount": "eb79a9b3682a", "sync": "eb79a9b3682a", "sort": "eb79a9b3682a" }, - "state": "1601446c4409", - "effects": ["85b53cd1c708"] + "state": "e96acdd915c3", + "effects": ["6bab186637b0", "238b10afd803"] } }, { "id": "host-view-settings-sync.result-null:settled", "observation": { - "sender": ["2f74fcfacfbd", "04ce05cbae1a"], - "payloads": ["582477c8e0af", "f3cde9fe56ca"], + "sender": ["2f74fcfacfbd", "e55533c6e358", "89c617ff2299"], + "payloads": ["ec336f23ef91", "1c2564f1daca", "aa1089f2edc0"], "settlements": { "mount": "eb79a9b3682a", "sync": "eb79a9b3682a", "sort": "eb79a9b3682a" }, - "state": "1601446c4409", - "effects": ["85b53cd1c708"] + "state": "e96acdd915c3", + "effects": ["6bab186637b0", "238b10afd803"] } }, { "id": "host-view-settings-sync.inner-ok-missing:settled", "observation": { - "sender": ["754a5e5d6868", "04ce05cbae1a"], - "payloads": ["582477c8e0af", "f3cde9fe56ca"], + "sender": ["754a5e5d6868", "e55533c6e358", "89c617ff2299"], + "payloads": ["ec336f23ef91", "1c2564f1daca", "aa1089f2edc0"], "settlements": { "mount": "eb79a9b3682a", "sync": "eb79a9b3682a", "sort": "eb79a9b3682a" }, - "state": "1601446c4409", - "effects": ["85b53cd1c708"] + "state": "e96acdd915c3", + "effects": ["6bab186637b0", "238b10afd803"] } }, { "id": "host-view-settings-sync.inner-false-string-error:settled", "observation": { - "sender": ["c40ad123c151", "04ce05cbae1a"], - "payloads": ["582477c8e0af", "f3cde9fe56ca"], + "sender": ["c40ad123c151", "e55533c6e358", "89c617ff2299"], + "payloads": ["ec336f23ef91", "1c2564f1daca", "aa1089f2edc0"], "settlements": { "mount": "eb79a9b3682a", "sync": "eb79a9b3682a", "sort": "eb79a9b3682a" }, - "state": "1601446c4409", - "effects": ["85b53cd1c708"] + "state": "e96acdd915c3", + "effects": ["6bab186637b0", "238b10afd803"] } }, { "id": "host-view-settings-sync.inner-false-object-error:settled", "observation": { - "sender": ["efd2522b079c", "04ce05cbae1a"], - "payloads": ["582477c8e0af", "f3cde9fe56ca"], + "sender": ["efd2522b079c", "e55533c6e358", "89c617ff2299"], + "payloads": ["ec336f23ef91", "1c2564f1daca", "aa1089f2edc0"], "settlements": { "mount": "eb79a9b3682a", "sync": "eb79a9b3682a", "sort": "eb79a9b3682a" }, - "state": "1601446c4409", - "effects": ["85b53cd1c708"] + "state": "e96acdd915c3", + "effects": ["6bab186637b0", "238b10afd803"] } }, { "id": "host-view-settings-sync.outer-refused:settled", "observation": { - "sender": ["6811622f22fd", "04ce05cbae1a"], - "payloads": ["582477c8e0af", "f3cde9fe56ca"], + "sender": ["6811622f22fd", "e55533c6e358", "f56221cb03b8"], + "payloads": ["ec336f23ef91", "1c2564f1daca", "cd9c5de9f2aa"], "settlements": { "mount": "eb79a9b3682a", "sync": "eb79a9b3682a", "sort": "eb79a9b3682a" }, - "state": "1601446c4409", - "effects": ["85b53cd1c708"] + "state": "918022e415be", + "effects": ["6bab186637b0", "ec949dc49cb9", "a9b819fe5a27"] } }, { "id": "host-view-settings-sync.outer-refused-no-message:settled", "observation": { - "sender": ["3937ae2aa8ac", "04ce05cbae1a"], - "payloads": ["582477c8e0af", "f3cde9fe56ca"], + "sender": ["3937ae2aa8ac", "e55533c6e358", "f56221cb03b8"], + "payloads": ["ec336f23ef91", "1c2564f1daca", "cd9c5de9f2aa"], "settlements": { "mount": "eb79a9b3682a", "sync": "eb79a9b3682a", "sort": "eb79a9b3682a" }, - "state": "1601446c4409", - "effects": ["85b53cd1c708"] + "state": "918022e415be", + "effects": ["6bab186637b0", "ec949dc49cb9", "a9b819fe5a27"] } }, { "id": "host-view-settings-sync.method-not-found:settled", "observation": { - "sender": ["443c5731ec35", "04ce05cbae1a"], - "payloads": ["582477c8e0af", "f3cde9fe56ca"], + "sender": ["443c5731ec35", "e55533c6e358", "f56221cb03b8"], + "payloads": ["ec336f23ef91", "1c2564f1daca", "cd9c5de9f2aa"], "settlements": { "mount": "eb79a9b3682a", "sync": "eb79a9b3682a", "sort": "eb79a9b3682a" }, - "state": "1601446c4409", - "effects": ["85b53cd1c708"] + "state": "918022e415be", + "effects": ["6bab186637b0", "ec949dc49cb9", "a9b819fe5a27"] } }, { "id": "host-view-settings-sync.transport-rejection:settled", "observation": { - "sender": ["19da51d77d45", "04ce05cbae1a"], - "payloads": ["582477c8e0af", "f3cde9fe56ca"], + "sender": ["19da51d77d45", "e55533c6e358", "f56221cb03b8"], + "payloads": ["ec336f23ef91", "1c2564f1daca", "cd9c5de9f2aa"], "settlements": { "mount": "eb79a9b3682a", "sync": "eb79a9b3682a", "sort": "eb79a9b3682a" }, - "state": "1601446c4409", - "effects": ["85b53cd1c708"] + "state": "918022e415be", + "effects": ["6bab186637b0", "ec949dc49cb9", "a9b819fe5a27"] } }, { "id": "host-view-settings-sync.transport-rejection-no-message:settled", "observation": { - "sender": ["f2c38899636a", "04ce05cbae1a"], - "payloads": ["582477c8e0af", "f3cde9fe56ca"], + "sender": ["f2c38899636a", "e55533c6e358", "f56221cb03b8"], + "payloads": ["ec336f23ef91", "1c2564f1daca", "cd9c5de9f2aa"], "settlements": { "mount": "eb79a9b3682a", "sync": "eb79a9b3682a", "sort": "eb79a9b3682a" }, - "state": "1601446c4409", - "effects": ["85b53cd1c708"] + "state": "918022e415be", + "effects": ["6bab186637b0", "ec949dc49cb9", "a9b819fe5a27"] } } ] diff --git a/mobile/rpc-foundation/goldens/matrix-host.view-settings-ui.set-1.json b/mobile/rpc-foundation/goldens/matrix-host.view-settings-ui.set-1.json index 420293dfbf2..7b9bc3b6a1c 100644 --- a/mobile/rpc-foundation/goldens/matrix-host.view-settings-ui.set-1.json +++ b/mobile/rpc-foundation/goldens/matrix-host.view-settings-ui.set-1.json @@ -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"] } } ] diff --git a/mobile/rpc-foundation/pilot-scenarios.json b/mobile/rpc-foundation/pilot-scenarios.json index 535b5b5119d..b57c77771b7 100644 --- a/mobile/rpc-foundation/pilot-scenarios.json +++ b/mobile/rpc-foundation/pilot-scenarios.json @@ -7962,6 +7962,20 @@ } } }, + { + "complete": "settings.get#1", + "params": { + "$undefined": true + }, + "reply": { + "ok": true, + "result": { + "settings": { + "compactWorktreeCards": true + } + } + } + }, { "action": "sort", "id": "sort" diff --git a/mobile/src/components/BottomDrawer.tsx b/mobile/src/components/BottomDrawer.tsx index 4b381511d76..257f59308bb 100644 --- a/mobile/src/components/BottomDrawer.tsx +++ b/mobile/src/components/BottomDrawer.tsx @@ -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' diff --git a/mobile/src/components/MobileSegmentedControl.tsx b/mobile/src/components/MobileSegmentedControl.tsx new file mode 100644 index 00000000000..6637a270563 --- /dev/null +++ b/mobile/src/components/MobileSegmentedControl.tsx @@ -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: Value; label: string } + +type Props = { + segments: readonly MobileSegment[] + /** 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 +} + +/** A pick-one pill control; each segment reads as a radio button. */ +export function MobileSegmentedControl(props: Props) { + return ( + + {props.segments.map((segment) => { + const active = segment.value === props.selected + return ( + props.onSelect(segment.value)} + style={[ + styles.segment, + props.fill && styles.segmentFill, + active && styles.segmentActive + ]} + > + + {segment.label} + + + ) + })} + + ) +} + +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 } +}) diff --git a/mobile/src/components/MobileSwitch.tsx b/mobile/src/components/MobileSwitch.tsx new file mode 100644 index 00000000000..4e9e1f412f2 --- /dev/null +++ b/mobile/src/components/MobileSwitch.tsx @@ -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 ( + + ) +} diff --git a/mobile/src/components/WorktreeAgentList.test.tsx b/mobile/src/components/WorktreeAgentList.test.tsx index aef5161cb4c..6ddd0275654 100644 --- a/mobile/src/components/WorktreeAgentList.test.tsx +++ b/mobile/src/components/WorktreeAgentList.test.tsx @@ -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( diff --git a/mobile/src/components/WorktreeAgentList.tsx b/mobile/src/components/WorktreeAgentList.tsx index 3abe97e00e1..563138092e4 100644 --- a/mobile/src/components/WorktreeAgentList.tsx +++ b/mobile/src/components/WorktreeAgentList.tsx @@ -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 ( diff --git a/mobile/src/components/WorktreeListRow.test.ts b/mobile/src/components/WorktreeListRow.test.ts index 7ff15b6e4d9..947b0877caf 100644 --- a/mobile/src/components/WorktreeListRow.test.ts +++ b/mobile/src/components/WorktreeListRow.test.ts @@ -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 } @@ -62,7 +71,20 @@ type TestItem = Worktree & { status: NonNullable } const stableRepoIcon = { type: 'emoji', emoji: 'o' } as const let updateSibling: Dispatch> = () => 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 }) + }) }) diff --git a/mobile/src/components/WorktreeListRow.tsx b/mobile/src/components/WorktreeListRow.tsx index 3a29aae9392..c7025062d46 100644 --- a/mobile/src/components/WorktreeListRow.tsx +++ b/mobile/src/components/WorktreeListRow.tsx @@ -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} - {item.linkedPR && ( + {cardDetails.pr && item.linkedPR && ( @@ -111,13 +119,7 @@ function WorktreeListRowComponent({ Folder )} - + {lineageDepth > 0 && ( @@ -126,7 +128,7 @@ function WorktreeListRowComponent({ Child )} - {item.hostContextLabel ? ( + {cardDetails.host && item.hostContextLabel ? ( {/* 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 ? ( - + {cardDetails.inlineAgents && item.agents && item.agents.length > 0 ? ( + ) : null} {lineageChildCount > 0 && onToggleLineage ? ( ({ + StyleSheet: { create: (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): 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() + }) +}) diff --git a/mobile/src/components/WorktreeMetaGlyphs.tsx b/mobile/src/components/WorktreeMetaGlyphs.tsx index 860d840a33a..e5df5307dde 100644 --- a/mobile/src/components/WorktreeMetaGlyphs.tsx +++ b/mobile/src/components/WorktreeMetaGlyphs.tsx @@ -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 ( - {hasNotes && } - {hasIssue && } - {hasLinear && L} - {hasGitLabMR && } + {presence.notes && } + {presence.automation && } + {presence.cli && } + {presence.issue && } + {presence.jira && } + {presence.linear && L} + {presence.gitLabMR && } ) } diff --git a/mobile/src/components/bottom-drawer-styles.ts b/mobile/src/components/bottom-drawer-styles.ts index ce74ac5a50e..9820e662260 100644 --- a/mobile/src/components/bottom-drawer-styles.ts +++ b/mobile/src/components/bottom-drawer-styles.ts @@ -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, diff --git a/mobile/src/components/mounted-bottom-drawer.tsx b/mobile/src/components/mounted-bottom-drawer.tsx index 96fb131fe4d..bac83538a59 100644 --- a/mobile/src/components/mounted-bottom-drawer.tsx +++ b/mobile/src/components/mounted-bottom-drawer.tsx @@ -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(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} - + {handle} ({})) +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 + +// 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[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[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) + }) +}) diff --git a/mobile/src/host-screen/host-screen-header-reconnect.test.tsx b/mobile/src/host-screen/host-screen-header-reconnect.test.tsx index 7605ac3272f..076b43077c4 100644 --- a/mobile/src/host-screen/host-screen-header-reconnect.test.tsx +++ b/mobile/src/host-screen/host-screen-header-reconnect.test.tsx @@ -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. diff --git a/mobile/src/host-screen/host-screen-header.tsx b/mobile/src/host-screen/host-screen-header.tsx index 1067a2de0b1..322f1b2c825 100644 --- a/mobile/src/host-screen/host-screen-header.tsx +++ b/mobile/src/host-screen/host-screen-header.tsx @@ -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)} > - 0 ? colors.textPrimary : colors.textSecondary} - /> - Filter{controller.activeFilterCount > 0 ? ` ${controller.activeFilterCount}` : ''} - - - - state.setShowSortPicker(true)} - accessibilityRole="button" - accessibilityLabel={`Sort by ${settings.selectedSortLabel}`} - > - - - {settings.selectedSortLabel} - - - - state.setShowGroupPicker(true)} - accessibilityRole="button" - accessibilityLabel="Group workspaces" - > - - - {workspaceGroupChipLabel(state.view.groupBy)} + {WORKSPACE_OPTIONS_TITLE} + @@ -276,46 +249,19 @@ export function HostScreenHeader({ controller }: { controller: HostScreenControl 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)} > - 0 ? colors.textPrimary : colors.textSecondary} - /> 0 && styles.filterChipTextActive ]} > - Filter{controller.activeFilterCount > 0 ? ` (${controller.activeFilterCount})` : ''} - - - - state.setShowSortPicker(true)} - accessibilityRole="button" - accessibilityLabel={`Sort by ${settings.selectedSortLabel}`} - > - - - {settings.selectedSortLabel} - - - - state.setShowGroupPicker(true)} - accessibilityRole="button" - accessibilityLabel="Group workspaces" - > - - - {workspaceGroupChipLabel(state.view.groupBy)} + {WORKSPACE_OPTIONS_TITLE} + diff --git a/mobile/src/host-screen/host-screen-operations.ts b/mobile/src/host-screen/host-screen-operations.ts index 7a66e623405..d4402fb4731 100644 --- a/mobile/src/host-screen/host-screen-operations.ts +++ b/mobile/src/host-screen/host-screen-operations.ts @@ -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. * diff --git a/mobile/src/host-screen/host-screen-overlays.test.tsx b/mobile/src/host-screen/host-screen-overlays.test.tsx index 7d7e4dd138b..ac134a56a53 100644 --- a/mobile/src/host-screen/host-screen-overlays.test.tsx +++ b/mobile/src/host-screen/host-screen-overlays.test.tsx @@ -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): Promise { 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() } diff --git a/mobile/src/host-screen/host-screen-overlays.tsx b/mobile/src/host-screen/host-screen-overlays.tsx index 6ee6f10ca1a..ea7e66e40ce 100644 --- a/mobile/src/host-screen/host-screen-overlays.tsx +++ b/mobile/src/host-screen/host-screen-overlays.tsx @@ -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 ( <> - state.setShowSortPicker(false)} - /> - - state.setShowGroupPicker(false)} - /> - - state.setShowFilterModal(false)}> - - - Filter - {WORKSPACE_VIEW_SHARED_NOTE} - - {controller.activeFilterCount > 0 && ( - - Clear filters - - )} - - - Workspaces - - - settings.setFilter( - 'hideSleepingWorkspaces', - !state.view.filters.hideSleepingWorkspaces - ) - } - > - Hide sleeping - {state.view.filters.hideSleepingWorkspaces && ( - - )} - - - - settings.setFilter( - 'hideDefaultBranchWorkspace', - !state.view.filters.hideDefaultBranchWorkspace - ) - } - > - Hide default branch - {state.view.filters.hideDefaultBranchWorkspace && ( - - )} - - - - {controller.sectionsResult.uniqueRepos.length > 1 && ( - <> - Repositories - - {controller.sectionsResult.uniqueRepos.map((repo, i) => ( - - {i > 0 && } - settings.toggleRepoFilter(repo.id)} - > - - - {repo.name} - - {state.view.filters.filterRepoIds.includes(repo.id) && ( - - )} - - - ))} - - - )} - + {/* Worktree long-press action sheet (inline confirm to avoid double-Modal lag) */} 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 diff --git a/mobile/src/host-screen/host-screen-secondary-styles.ts b/mobile/src/host-screen/host-screen-secondary-styles.ts index 7f6c4f3f65c..57a5a17de7d 100644 --- a/mobile/src/host-screen/host-screen-secondary-styles.ts +++ b/mobile/src/host-screen/host-screen-secondary-styles.ts @@ -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, diff --git a/mobile/src/host-screen/host-show-pinned-in-groups.test.ts b/mobile/src/host-screen/host-show-pinned-in-groups.test.ts deleted file mode 100644 index 4016bd7c36b..00000000000 --- a/mobile/src/host-screen/host-show-pinned-in-groups.test.ts +++ /dev/null @@ -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, - onSent: (clientRef: ClientRef) => void = noop -): Promise { - 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[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]) - }) -}) diff --git a/mobile/src/host-screen/host-workspace-list-action-error.test.tsx b/mobile/src/host-screen/host-workspace-list-action-error.test.tsx index 90b5677f374..c9b55835453 100644 --- a/mobile/src/host-screen/host-workspace-list-action-error.test.tsx +++ b/mobile/src/host-screen/host-workspace-list-action-error.test.tsx @@ -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 diff --git a/mobile/src/host-screen/host-workspace-list-row-memo.test.tsx b/mobile/src/host-screen/host-workspace-list-row-memo.test.tsx index cf72b9b2dea..a21eb00478f 100644 --- a/mobile/src/host-screen/host-workspace-list-row-memo.test.tsx +++ b/mobile/src/host-screen/host-workspace-list-row-memo.test.tsx @@ -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 { return { @@ -62,7 +63,6 @@ function worktree(id: string, overrides: Partial = {}): 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()) }) }) diff --git a/mobile/src/host-screen/host-workspace-list.tsx b/mobile/src/host-screen/host-workspace-list.tsx index ec1c38dc6b0..99e7a9c373e 100644 --- a/mobile/src/host-screen/host-workspace-list.tsx +++ b/mobile/src/host-screen/host-workspace-list.tsx @@ -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 diff --git a/mobile/src/host-screen/phone-card-property-choices.test.ts b/mobile/src/host-screen/phone-card-property-choices.test.ts new file mode 100644 index 00000000000..7eb042c3823 --- /dev/null +++ b/mobile/src/host-screen/phone-card-property-choices.test.ts @@ -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']) + }) +}) diff --git a/mobile/src/host-screen/phone-card-property-choices.ts b/mobile/src/host-screen/phone-card-property-choices.ts new file mode 100644 index 00000000000..07df726054f --- /dev/null +++ b/mobile/src/host-screen/phone-card-property-choices.ts @@ -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 = new Set(['ports']) + +/** The shared choices the phone offers, in the shared order. */ +export function phoneCardPropertyChoices>( + 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)) +} diff --git a/mobile/src/host-screen/use-host-screen-controller.ts b/mobile/src/host-screen/use-host-screen-controller.ts index 21e75ce4502..d2661f09668 100644 --- a/mobile/src/host-screen/use-host-screen-controller.ts +++ b/mobile/src/host-screen/use-host-screen-controller.ts @@ -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, diff --git a/mobile/src/host-screen/use-host-screen-state.ts b/mobile/src/host-screen/use-host-screen-state.ts index 0c024528ee5..1c28e87184c 100644 --- a/mobile/src/host-screen/use-host-screen-state.ts +++ b/mobile/src/host-screen/use-host-screen-state.ts @@ -70,9 +70,7 @@ export function useHostScreenState(hostId: string | undefined, action: string | const [serverWorkspaces, setServerWorkspaces] = useState(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(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(null) const [confirmDelete, setConfirmDelete] = useState(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, diff --git a/mobile/src/host-screen/use-host-view-settings.ts b/mobile/src/host-screen/use-host-view-settings.ts index aac87e7b86e..215058803a1 100644 --- a/mobile/src/host-screen/use-host-view-settings.ts +++ b/mobile/src/host-screen/use-host-view-settings.ts @@ -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, diff --git a/mobile/src/host-screen/use-host-worktree-catalog.ts b/mobile/src/host-screen/use-host-worktree-catalog.ts index c322c6d820c..6ad0fcec835 100644 --- a/mobile/src/host-screen/use-host-worktree-catalog.ts +++ b/mobile/src/host-screen/use-host-worktree-catalog.ts @@ -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(() => { diff --git a/mobile/src/host-screen/workspace-option-controls.tsx b/mobile/src/host-screen/workspace-option-controls.tsx new file mode 100644 index 00000000000..99ad65b18dd --- /dev/null +++ b/mobile/src/host-screen/workspace-option-controls.tsx @@ -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 } + | { + kind: 'page' + value: (props: WorkspaceOptionProps) => string + Page: ComponentType + } + +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[] { + 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 ( + + + {[ + write(toggleAllVisibleHosts(hosts, visible))} + />, + ...hosts.map((host) => { + const next = toggleVisibleHostRow(hosts, visible, host) + return ( + write(next)} + /> + ) + }) + ]} + + + ) +} + +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 ( + + + + + + {[ + settings.setFilter('filterRepoIds', [])} + />, + ...repos.map((repo) => ( + settings.toggleRepoFilter(repo.id)} + leading={} + /> + )) + ]} + + + ) +} + +function choicePage( + choices: readonly WorkspaceViewChoice[], + selected: (controller: HostScreenController) => Id, + onSelect: (controller: HostScreenController) => (id: Id) => void +): Extract { + return { + kind: 'page', + value: ({ controller }) => choiceLabel(choices, selected(controller)), + // Picking keeps the page open, as the desktop keeps its submenu open. + Page: ({ controller }) => ( + + + + ) + } +} + +function CardPropertyRows(props: { + controller: HostScreenController + choices: readonly WorkspaceViewChoice[] +}) { + const { settings, state } = props.controller + return ( + + + {props.choices.map((choice) => ( + settings.toggleCardProperty(choice.id)} + /> + ))} + + + ) +} + +// 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[], + options: { value: (props: WorkspaceOptionProps) => string; withAgentLayout: boolean } +): Extract { + const { withAgentLayout } = options + return { + kind: 'page', + value: options.value, + Page: ({ controller }) => ( + <> + + {withAgentLayout && ( + <> + Agent activity layout + ({ + value: id, + label + }))} + selected={controller.state.view.agentActivityDisplayMode} + onSelect={controller.settings.setAgentActivityDisplayMode} + fill + style={styles.optionSegmented} + /> + + )} + + ) + } +} + +function filterSwitch( + key: Extract +): 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 ( + 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 = { + hosts: { kind: 'page', value: hostsValue, Page: HostsPage }, + projects: { kind: 'page', value: projectsValue, Page: ProjectsPage }, + groupBy: { + kind: 'inline', + framed: false, + Row: ({ controller }) => ( + ({ 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') +} diff --git a/mobile/src/host-screen/workspace-option-rows.tsx b/mobile/src/host-screen/workspace-option-rows.tsx new file mode 100644 index 00000000000..1685b2bf38e --- /dev/null +++ b/mobile/src/host-screen/workspace-option-rows.tsx @@ -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 ( + + {props.leading} + + + {props.label} + + {props.detail ? ( + + {props.detail} + + ) : null} + + {props.checked && } + + ) +} + +/** 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 && } + {child} + + ))} + + ) +} + +export function ChoiceRows(props: { + choices: readonly WorkspaceViewChoice[] + selected: Id + onSelect: (id: Id) => void +}) { + return ( + + {props.choices.map((choice) => ( + props.onSelect(choice.id)} + /> + ))} + + ) +} + +/** 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 ( + + + {props.label} + + + + ) +} + +/** 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 ( + + + {props.label} + + + {props.value} + + + + ) +} diff --git a/mobile/src/host-screen/workspace-option-values.test.ts b/mobile/src/host-screen/workspace-option-values.test.ts new file mode 100644 index 00000000000..659b83ce62a --- /dev/null +++ b/mobile/src/host-screen/workspace-option-values.test.ts @@ -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('') + }) +}) diff --git a/mobile/src/host-screen/workspace-option-values.ts b/mobile/src/host-screen/workspace-option-values.ts new file mode 100644 index 00000000000..a82b639a164 --- /dev/null +++ b/mobile/src/host-screen/workspace-option-values.ts @@ -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( + choices: readonly { id: Id; label: string }[], + selected: string +): string { + return choices.find((choice) => choice.id === selected)?.label ?? '' +} diff --git a/mobile/src/host-screen/workspace-options-count-badge.tsx b/mobile/src/host-screen/workspace-options-count-badge.tsx new file mode 100644 index 00000000000..e1943abd9d1 --- /dev/null +++ b/mobile/src/host-screen/workspace-options-count-badge.tsx @@ -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 ( + + {text} + + ) +} + +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 + } +}) diff --git a/mobile/src/host-screen/workspace-options-pager.tsx b/mobile/src/host-screen/workspace-options-pager.tsx new file mode 100644 index 00000000000..bf7e11b2c2b --- /dev/null +++ b/mobile/src/host-screen/workspace-options-pager.tsx @@ -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() { + const reduceMotion = useReducedMotionEnabled() + const duration = reduceMotion ? 0 : PAGE_SLIDE_MS + const [page, setPage] = useState(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 = ReturnType> + +/** + * 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 } + main: ReactNode + /** The open page; null shows the main sheet. */ + page: ReactNode | null +}) { + const [width, setWidth] = useState(0) + const [mainHeight, setMainHeight] = useState(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 ( + setWidth(event.nativeEvent.layout.width)} + > + setMainHeight(event.nativeEvent.layout.height)} + pointerEvents={pageOpen ? 'none' : 'auto'} + aria-hidden={pageOpen} + > + {props.main} + + {pageOpen && {props.page}} + + ) +} + +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 } +}) diff --git a/mobile/src/host-screen/workspace-options-sheet.test.tsx b/mobile/src/host-screen/workspace-options-sheet.test.tsx new file mode 100644 index 00000000000..b7e928e184e --- /dev/null +++ b/mobile/src/host-screen/workspace-options-sheet.test.tsx @@ -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: (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: + (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['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[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') + }) +}) diff --git a/mobile/src/host-screen/workspace-options-sheet.tsx b/mobile/src/host-screen/workspace-options-sheet.tsx new file mode 100644 index 00000000000..5a66efff80e --- /dev/null +++ b/mobile/src/host-screen/workspace-options-sheet.tsx @@ -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([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() + return ( + 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} + > + + + ) +} + +// Its own component so the host list is built only while the sheet is open. +function WorkspaceOptionsContent({ + controller, + pager +}: { + controller: HostScreenController + pager: OptionPagerControls +}) { + 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' ? ( + <> + + + {WORKSPACE_OPTIONS_TITLE} + + {pageOption.label} + + + ) : null + + const main = ( + <> + + + {WORKSPACE_OPTIONS_TITLE} + {WORKSPACE_VIEW_SHARED_NOTE} + + {controller.activeFilterCount > 0 && ( + + Clear filters + + )} + + + {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( + pager.open(option.key)} + /> + ) + } else { + const target = control.framed ? framedRows : bareRows + target.push() + } + } + return ( + + {sectionLabel && {sectionLabel}} + {bareRows} + {framedRows.length > 0 && ( + + {framedRows} + + )} + + ) + })} + + ) + + return +} diff --git a/mobile/src/onboarding/NotificationOnboardingPreview.tsx b/mobile/src/onboarding/NotificationOnboardingPreview.tsx index 2c81d8d1262..8992869ce59 100644 --- a/mobile/src/onboarding/NotificationOnboardingPreview.tsx +++ b/mobile/src/onboarding/NotificationOnboardingPreview.tsx @@ -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.' }, diff --git a/mobile/src/onboarding/use-reduced-motion.ts b/mobile/src/platform/use-reduced-motion.ts similarity index 100% rename from mobile/src/onboarding/use-reduced-motion.ts rename to mobile/src/platform/use-reduced-motion.ts diff --git a/mobile/src/settings/voice-settings-screen.tsx b/mobile/src/settings/voice-settings-screen.tsx index 1e2c33c7a4c..70306a12a91 100644 --- a/mobile/src/settings/voice-settings-screen.tsx +++ b/mobile/src/settings/voice-settings-screen.tsx @@ -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. - - {DICTATION_MODES.map((mode) => { - const active = setup.dictationMode === mode.value - return ( - void configure({ dictationMode: mode.value })} - style={[styles.segment, active && styles.segmentActive]} - > - - {mode.label} - - - ) - })} - + void configure({ dictationMode })} + testIDPrefix="voice-mode-" + /> diff --git a/mobile/src/settings/voice-settings-styles.ts b/mobile/src/settings/voice-settings-styles.ts index 19f3a8f06e8..232c6205337 100644 --- a/mobile/src/settings/voice-settings-styles.ts +++ b/mobile/src/settings/voice-settings-styles.ts @@ -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 } }) diff --git a/mobile/src/test-support/bridged-parity/c1-page-closure.test.ts b/mobile/src/test-support/bridged-parity/c1-page-closure.test.ts index 10b7aa159b9..731132df3f7 100644 --- a/mobile/src/test-support/bridged-parity/c1-page-closure.test.ts +++ b/mobile/src/test-support/bridged-parity/c1-page-closure.test.ts @@ -24,11 +24,11 @@ function asPinned(): Map { } 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) }) diff --git a/mobile/src/test-support/bridged-parity/c1-page-closure.ts b/mobile/src/test-support/bridged-parity/c1-page-closure.ts index 1d9b4dd7418..e04cbd63031 100644 --- a/mobile/src/test-support/bridged-parity/c1-page-closure.ts +++ b/mobile/src/test-support/bridged-parity/c1-page-closure.ts @@ -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' }, diff --git a/mobile/src/test-support/bridged-parity/c5-page-closure.test.ts b/mobile/src/test-support/bridged-parity/c5-page-closure.test.ts index 55020479531..4b257fae7ec 100644 --- a/mobile/src/test-support/bridged-parity/c5-page-closure.test.ts +++ b/mobile/src/test-support/bridged-parity/c5-page-closure.test.ts @@ -26,11 +26,11 @@ function asPinned(): Map { } 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( [] ) diff --git a/mobile/src/test-support/bridged-parity/c5-page-closure.ts b/mobile/src/test-support/bridged-parity/c5-page-closure.ts index a64378c157f..9ea3d188666 100644 --- a/mobile/src/test-support/bridged-parity/c5-page-closure.ts +++ b/mobile/src/test-support/bridged-parity/c5-page-closure.ts @@ -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' }, diff --git a/mobile/src/test-support/bridged-parity/divergence-classes.test.ts b/mobile/src/test-support/bridged-parity/divergence-classes.test.ts index 63b2e791bba..eebdcdec6eb 100644 --- a/mobile/src/test-support/bridged-parity/divergence-classes.test.ts +++ b/mobile/src/test-support/bridged-parity/divergence-classes.test.ts @@ -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', () => { diff --git a/mobile/src/test-support/bridged-parity/divergence-classes.ts b/mobile/src/test-support/bridged-parity/divergence-classes.ts index 6c116877258..3acb82c2d66 100644 --- a/mobile/src/test-support/bridged-parity/divergence-classes.ts +++ b/mobile/src/test-support/bridged-parity/divergence-classes.ts @@ -185,7 +185,7 @@ export const BRIDGED_PARITY_BASELINE: Readonly[0]['state'] }) diff --git a/mobile/src/transport/settings-read-operations.test.ts b/mobile/src/transport/settings-read-operations.test.ts index de288de3b6c..5420c519c2c 100644 --- a/mobile/src/transport/settings-read-operations.test.ts +++ b/mobile/src/transport/settings-read-operations.test.ts @@ -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 () => { diff --git a/mobile/src/transport/settings-read-operations.ts b/mobile/src/transport/settings-read-operations.ts index a2dc885795b..61588e6ed3c 100644 --- a/mobile/src/transport/settings-read-operations.ts +++ b/mobile/src/transport/settings-read-operations.ts @@ -112,28 +112,40 @@ export const terminalCopyTrimsGutterRead = bindDeferredRpcOperation( }) ) -const showPinnedInGroupsReader: RpcCompatibleReader = ( - 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 }) ) diff --git a/mobile/src/worktree/project-section-catalog.test.ts b/mobile/src/worktree/project-section-catalog.test.ts index b08fd27c50e..b252a77f6d1 100644 --- a/mobile/src/worktree/project-section-catalog.test.ts +++ b/mobile/src/worktree/project-section-catalog.test.ts @@ -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]) }) }) diff --git a/mobile/src/worktree/project-section-catalog.ts b/mobile/src/worktree/project-section-catalog.ts index f9c180dbcf3..4e89b4d7d73 100644 --- a/mobile/src/worktree/project-section-catalog.ts +++ b/mobile/src/worktree/project-section-catalog.ts @@ -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 } } diff --git a/mobile/src/worktree/project-sections-operations.ts b/mobile/src/worktree/project-sections-operations.ts index 21bbf9270a3..cf54067a665 100644 --- a/mobile/src/worktree/project-sections-operations.ts +++ b/mobile/src/worktree/project-sections-operations.ts @@ -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') }) diff --git a/mobile/src/worktree/workspace-list-picker-options.ts b/mobile/src/worktree/workspace-list-picker-options.ts deleted file mode 100644 index b93a03c3a23..00000000000 --- a/mobile/src/worktree/workspace-list-picker-options.ts +++ /dev/null @@ -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(choice: WorkspaceViewChoice): PickerOption { - 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[] = - WORKSPACE_SORT_BY_CHOICES.map(toPickerOption) - -/** The desktop's Group by choices, in its order and words. */ -export const WORKSPACE_GROUP_OPTIONS: PickerOption[] = - 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') -} diff --git a/mobile/src/worktree/workspace-list-sections-desktop-parity.test.ts b/mobile/src/worktree/workspace-list-sections-desktop-parity.test.ts index 8e508478dfd..e42e79da9ce 100644 --- a/mobile/src/worktree/workspace-list-sections-desktop-parity.test.ts +++ b/mobile/src/worktree/workspace-list-sections-desktop-parity.test.ts @@ -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' }, diff --git a/mobile/src/worktree/workspace-list-types.ts b/mobile/src/worktree/workspace-list-types.ts index 42a869a8a62..e090af72e70 100644 --- a/mobile/src/worktree/workspace-list-types.ts +++ b/mobile/src/worktree/workspace-list-types.ts @@ -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 diff --git a/mobile/src/worktree/workspace-options-copy.test.ts b/mobile/src/worktree/workspace-options-copy.test.ts new file mode 100644 index 00000000000..2b43c38810f --- /dev/null +++ b/mobile/src/worktree/workspace-options-copy.test.ts @@ -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)') + }) +}) diff --git a/mobile/src/worktree/workspace-options-copy.ts b/mobile/src/worktree/workspace-options-copy.ts new file mode 100644 index 00000000000..7c9f3dbdfe8 --- /dev/null +++ b/mobile/src/worktree/workspace-options-copy.ts @@ -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' diff --git a/mobile/src/worktree/workspace-view-settings.test.ts b/mobile/src/worktree/workspace-view-settings.test.ts index ea60567f0a1..93b086142d9 100644 --- a/mobile/src/worktree/workspace-view-settings.test.ts +++ b/mobile/src/worktree/workspace-view-settings.test.ts @@ -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 + }) + }) +}) diff --git a/mobile/src/worktree/workspace-view-settings.ts b/mobile/src/worktree/workspace-view-settings.ts index 3e4bf1c1ed1..baace1906ba 100644 --- a/mobile/src/worktree/workspace-view-settings.ts +++ b/mobile/src/worktree/workspace-view-settings.ts @@ -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> +} & 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 + Omit > & { filters?: Partial } @@ -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 } } diff --git a/mobile/src/worktree/worktree-meta-glyph-presence.test.ts b/mobile/src/worktree/worktree-meta-glyph-presence.test.ts new file mode 100644 index 00000000000..54a89328d6e --- /dev/null +++ b/mobile/src/worktree/worktree-meta-glyph-presence.test.ts @@ -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) + }) +}) diff --git a/mobile/src/worktree/worktree-meta-glyph-presence.ts b/mobile/src/worktree/worktree-meta-glyph-presence.ts new file mode 100644 index 00000000000..dede29f37db --- /dev/null +++ b/mobile/src/worktree/worktree-meta-glyph-presence.ts @@ -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 + } +} diff --git a/src/main/ipc/pty-launch-parity.test-fixture.ts b/src/main/ipc/pty-launch-parity.test-fixture.ts index bce4dcc2ebc..bfea858cebc 100644 --- a/src/main/ipc/pty-launch-parity.test-fixture.ts +++ b/src/main/ipc/pty-launch-parity.test-fixture.ts @@ -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: () => [ diff --git a/src/main/ipc/worktrees-canonical-ssh-listing.test.ts b/src/main/ipc/worktrees-canonical-ssh-listing.test.ts index c80ee295c3d..7f174639a1a 100644 --- a/src/main/ipc/worktrees-canonical-ssh-listing.test.ts +++ b/src/main/ipc/worktrees-canonical-ssh-listing.test.ts @@ -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 = { - [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 = { + [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) => { + 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) => { - 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', diff --git a/src/main/runtime/runtime-workspace-view-change-events.test.ts b/src/main/runtime/runtime-workspace-view-change-events.test.ts index e88480d0e2e..ad03fff7335 100644 --- a/src/main/runtime/runtime-workspace-view-change-events.test.ts +++ b/src/main/runtime/runtime-workspace-view-change-events.test.ts @@ -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, + settings: GlobalSettings + ) => void)[] = [] return { getUI: () => ui, onUIChanged: (listener: (ui: PersistedUIState) => void) => { listeners.push(listener) return () => {} }, + onSettingsChanged: ( + listener: (updates: Partial, settings: GlobalSettings) => void + ) => { + settingsListeners.push(listener) + return () => {} + }, + updateSettings(updates: Partial) { + for (const listener of settingsListeners) { + listener(updates, { ...getDefaultSettings('/home/test'), ...updates }) + } + }, update(updates: Partial) { 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) + }) }) diff --git a/src/main/runtime/runtime-workspace-view-change-events.ts b/src/main/runtime/runtime-workspace-view-change-events.ts index 46b686a78c8..2fba21feffa 100644 --- a/src/main/runtime/runtime-workspace-view-change-events.ts +++ b/src/main/runtime/runtime-workspace-view-change-events.ts @@ -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 | null, + store: Pick | 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' }) + } + }) } diff --git a/src/main/worktree-lineage-host-peers.ts b/src/main/worktree-lineage-host-peers.ts index 22658c5a34c..b001b9d23d8 100644 --- a/src/main/worktree-lineage-host-peers.ts +++ b/src/main/worktree-lineage-host-peers.ts @@ -66,7 +66,8 @@ export function projectWorktreeLineageWithHostPeers( 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 diff --git a/src/renderer/src/components/activity/activity-thread-hover-card.tsx b/src/renderer/src/components/activity/activity-thread-hover-card.tsx index 584143e9155..bd016ef9de8 100644 --- a/src/renderer/src/components/activity/activity-thread-hover-card.tsx +++ b/src/renderer/src/components/activity/activity-thread-hover-card.tsx @@ -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, diff --git a/src/renderer/src/components/activity/activity-thread-options-menu.tsx b/src/renderer/src/components/activity/activity-thread-options-menu.tsx index 6e9cd0ea7c3..0ee6dfa5689 100644 --- a/src/renderer/src/components/activity/activity-thread-options-menu.tsx +++ b/src/renderer/src/components/activity/activity-thread-options-menu.tsx @@ -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 diff --git a/src/renderer/src/components/new-workspace/ComposerParentWorktreePicker.tsx b/src/renderer/src/components/new-workspace/ComposerParentWorktreePicker.tsx index 907a18eb048..74c6216ba61 100644 --- a/src/renderer/src/components/new-workspace/ComposerParentWorktreePicker.tsx +++ b/src/renderer/src/components/new-workspace/ComposerParentWorktreePicker.tsx @@ -392,7 +392,9 @@ function ParentWorktreeCandidateList({ {[ repoMap.get(candidate.repoId)?.displayName, branchDisplayName(candidate.branch) - ].filter(Boolean).join(' · ')} + ] + .filter(Boolean) + .join(' · ')} diff --git a/src/renderer/src/components/sidebar/DeleteWorktreeDialog.test.tsx b/src/renderer/src/components/sidebar/DeleteWorktreeDialog.test.tsx index 179c6b288cd..3c295bc27db 100644 --- a/src/renderer/src/components/sidebar/DeleteWorktreeDialog.test.tsx +++ b/src/renderer/src/components/sidebar/DeleteWorktreeDialog.test.tsx @@ -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() + const markup = renderToStaticMarkup( + + ) expect(visibleMarkupText(markup)).toContain('(Second checkout)') expect(visibleMarkupText(markup)).toContain('child · Second checkout') }) diff --git a/src/renderer/src/components/sidebar/SidebarHostScopeMenuSection.tsx b/src/renderer/src/components/sidebar/SidebarHostScopeMenuSection.tsx index f6f166d18dd..37710e590a3 100644 --- a/src/renderer/src/components/sidebar/SidebarHostScopeMenuSection.tsx +++ b/src/renderer/src/components/sidebar/SidebarHostScopeMenuSection.tsx @@ -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) } } diff --git a/src/renderer/src/components/sidebar/agent-send-picker-collapse.test.tsx b/src/renderer/src/components/sidebar/agent-send-picker-collapse.test.tsx index 53e244789a3..f703300dbc8 100644 --- a/src/renderer/src/components/sidebar/agent-send-picker-collapse.test.tsx +++ b/src/renderer/src/components/sidebar/agent-send-picker-collapse.test.tsx @@ -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', diff --git a/src/renderer/src/components/sidebar/options-filter-count-badge.tsx b/src/renderer/src/components/sidebar/options-filter-count-badge.tsx index ad835d5698b..4c2879729ba 100644 --- a/src/renderer/src/components/sidebar/options-filter-count-badge.tsx +++ b/src/renderer/src/components/sidebar/options-filter-count-badge.tsx @@ -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)} ) } diff --git a/src/renderer/src/components/sidebar/pinned-section-worktrees.test.ts b/src/renderer/src/components/sidebar/pinned-section-worktrees.test.ts index 97eef4f075b..a0bbc0a4f25 100644 --- a/src/renderer/src/components/sidebar/pinned-section-worktrees.test.ts +++ b/src/renderer/src/components/sidebar/pinned-section-worktrees.test.ts @@ -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', () => { diff --git a/src/renderer/src/components/sidebar/pinned-section-worktrees.ts b/src/renderer/src/components/sidebar/pinned-section-worktrees.ts index 86135c9db48..963ef49b760 100644 --- a/src/renderer/src/components/sidebar/pinned-section-worktrees.ts +++ b/src/renderer/src/components/sidebar/pinned-section-worktrees.ts @@ -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 diff --git a/src/renderer/src/components/sidebar/sidebar-host-options.test.ts b/src/renderer/src/components/sidebar/sidebar-host-options.test.ts index ab8b8452c39..e8da971e0fc 100644 --- a/src/renderer/src/components/sidebar/sidebar-host-options.test.ts +++ b/src/renderer/src/components/sidebar/sidebar-host-options.test.ts @@ -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' diff --git a/src/renderer/src/components/sidebar/sidebar-host-options.ts b/src/renderer/src/components/sidebar/sidebar-host-options.ts index d73a79e54a8..60910d0e7c9 100644 --- a/src/renderer/src/components/sidebar/sidebar-host-options.ts +++ b/src/renderer/src/components/sidebar/sidebar-host-options.ts @@ -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( - 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] -} diff --git a/src/renderer/src/components/sidebar/use-worktree-card-controller.ts b/src/renderer/src/components/sidebar/use-worktree-card-controller.ts index febe23350f8..b52c168fa0d 100644 --- a/src/renderer/src/components/sidebar/use-worktree-card-controller.ts +++ b/src/renderer/src/components/sidebar/use-worktree-card-controller.ts @@ -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, diff --git a/src/renderer/src/components/sidebar/use-worktree-card-foundation.ts b/src/renderer/src/components/sidebar/use-worktree-card-foundation.ts index 3598be2ed92..7fee0173f2e 100644 --- a/src/renderer/src/components/sidebar/use-worktree-card-foundation.ts +++ b/src/renderer/src/components/sidebar/use-worktree-card-foundation.ts @@ -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, diff --git a/src/renderer/src/components/sidebar/use-worktree-card-secondary-details.store-subscriptions.test.tsx b/src/renderer/src/components/sidebar/use-worktree-card-secondary-details.store-subscriptions.test.tsx index 7bb7bc05e0e..915a90d42f5 100644 --- a/src/renderer/src/components/sidebar/use-worktree-card-secondary-details.store-subscriptions.test.tsx +++ b/src/renderer/src/components/sidebar/use-worktree-card-secondary-details.store-subscriptions.test.tsx @@ -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, diff --git a/src/renderer/src/components/sidebar/use-worktree-card-secondary-details.ts b/src/renderer/src/components/sidebar/use-worktree-card-secondary-details.ts index 41aaa49451b..a213efc8a01 100644 --- a/src/renderer/src/components/sidebar/use-worktree-card-secondary-details.ts +++ b/src/renderer/src/components/sidebar/use-worktree-card-secondary-details.ts @@ -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 & 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' diff --git a/src/renderer/src/components/sidebar/workspace-options-menu-items.tsx b/src/renderer/src/components/sidebar/workspace-options-menu-items.tsx index b223faf320b..6cd29a97dcb 100644 --- a/src/renderer/src/components/sidebar/workspace-options-menu-items.tsx +++ b/src/renderer/src/components/sidebar/workspace-options-menu-items.tsx @@ -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 diff --git a/src/renderer/src/components/sidebar/worktree-card-option-items.tsx b/src/renderer/src/components/sidebar/worktree-card-option-items.tsx index 2455d3a9a05..3fea7952441 100644 --- a/src/renderer/src/components/sidebar/worktree-card-option-items.tsx +++ b/src/renderer/src/components/sidebar/worktree-card-option-items.tsx @@ -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 ( @@ -144,15 +143,16 @@ export function WorktreeCardPropertiesMenuItem({ 'Show properties' )} - {compact ? ( + {value === null ? null : ( - {translate('auto.components.sidebar.SidebarWorkspaceOptionsMenu.3d4b9c4997', 'Hover')} + {value === 'hover' + ? translate( + 'auto.components.sidebar.SidebarWorkspaceOptionsMenu.3d4b9c4997', + 'Hover' + ) + : value} - ) : visiblePropertyCount > 0 ? ( - - {visiblePropertyCount} - - ) : null} + )} (), 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, diff --git a/src/renderer/src/components/sidebar/worktree-list/grouping/cross-project-lineage.test.ts b/src/renderer/src/components/sidebar/worktree-list/grouping/cross-project-lineage.test.ts index f74a0ddca30..05fdb59129b 100644 --- a/src/renderer/src/components/sidebar/worktree-list/grouping/cross-project-lineage.test.ts +++ b/src/renderer/src/components/sidebar/worktree-list/grouping/cross-project-lineage.test.ts @@ -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 } diff --git a/src/renderer/src/components/sidebar/worktree-list/navigation/worktree-reveal-group-keys.ts b/src/renderer/src/components/sidebar/worktree-list/navigation/worktree-reveal-group-keys.ts index f9a9ce3da5e..ffa61c81930 100644 --- a/src/renderer/src/components/sidebar/worktree-list/navigation/worktree-reveal-group-keys.ts +++ b/src/renderer/src/components/sidebar/worktree-list/navigation/worktree-reveal-group-keys.ts @@ -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 [] diff --git a/src/shared/project-section-grouping.ts b/src/shared/project-section-grouping.ts index 6c833c586b3..29baa3a9f8a 100644 --- a/src/shared/project-section-grouping.ts +++ b/src/shared/project-section-grouping.ts @@ -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 diff --git a/src/shared/resolved-worktree-lineage.test.ts b/src/shared/resolved-worktree-lineage.test.ts index a40cc0856c8..19d3561ad01 100644 --- a/src/shared/resolved-worktree-lineage.test.ts +++ b/src/shared/resolved-worktree-lineage.test.ts @@ -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() }, diff --git a/src/shared/workspace-view/workspace-host-visibility.test.ts b/src/shared/workspace-view/workspace-host-visibility.test.ts new file mode 100644 index 00000000000..4c73da06536 --- /dev/null +++ b/src/shared/workspace-view/workspace-host-visibility.test.ts @@ -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() + }) +}) diff --git a/src/shared/workspace-view/workspace-host-visibility.ts b/src/shared/workspace-view/workspace-host-visibility.ts index 5dd04e62372..acb5c9fa87c 100644 --- a/src/shared/workspace-view/workspace-host-visibility.ts +++ b/src/shared/workspace-view/workspace-host-visibility.ts @@ -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( + 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 +} diff --git a/src/shared/workspace-view/workspace-row-filters.ts b/src/shared/workspace-view/workspace-row-filters.ts index 034d777b019..713455dfe6d 100644 --- a/src/shared/workspace-view/workspace-row-filters.ts +++ b/src/shared/workspace-view/workspace-row-filters.ts @@ -184,6 +184,16 @@ export function activeWorkspaceRowFilterKeys( } /** The badge count: each active filter once, projects one per selected known project. */ +/** Pluralized "N filter(s)" for an options trigger's tooltip and accessibility label. */ +export function formatActiveFilterLabel(count: number): string { + return `${count} ${count === 1 ? 'filter' : 'filters'}` +} + +/** The active-filter count as an options trigger's corner badge shows it. */ +export function formatActiveFilterBadge(count: number): string { + return count > 9 ? '9+' : String(count) +} + export function countActiveWorkspaceFilters( filters: WorkspaceRowFilters, knownRepoIds: readonly string[] diff --git a/src/shared/workspace-view/workspace-view-options.test.ts b/src/shared/workspace-view/workspace-view-options.test.ts index 25d335c0f87..bddd0901b8a 100644 --- a/src/shared/workspace-view/workspace-view-options.test.ts +++ b/src/shared/workspace-view/workspace-view-options.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest' import { + getWorkspaceCardPropertiesValue, getShownWorkspaceViewSections, type WorkspaceViewOptionContext, type WorkspaceViewOptionKey @@ -69,3 +70,12 @@ describe('which Workspace options show', () => { expect(keys).not.toContain('cardProperties') }) }) + +describe('the Show properties value', () => { + it('reads Hover under the compact layout, else how many properties show, else nothing', () => { + expect(getWorkspaceCardPropertiesValue(['pr', 'host'], true)).toBe('hover') + expect(getWorkspaceCardPropertiesValue(['pr', 'host'], false)).toBe(2) + // Ids outside the Show properties list (Card display only) are not counted. + expect(getWorkspaceCardPropertiesValue(['cli', 'branch'], false)).toBeNull() + }) +}) diff --git a/src/shared/workspace-view/workspace-view-options.ts b/src/shared/workspace-view/workspace-view-options.ts index 95386701f31..a81e73b90d3 100644 --- a/src/shared/workspace-view/workspace-view-options.ts +++ b/src/shared/workspace-view/workspace-view-options.ts @@ -84,6 +84,23 @@ export const WORKSPACE_CARD_DISPLAY_CHOICES = [ { id: 'branch', label: 'Branch name' } ] as const satisfies readonly WorkspaceViewChoice[] +/** + * The Show properties row's value: "hover" under the compact layout (properties show on hover), + * otherwise how many properties show; null when none do. + */ +export function getWorkspaceCardPropertiesValue( + shownProperties: readonly string[], + compactWorktreeCards: boolean +): 'hover' | number | null { + if (compactWorktreeCards) { + return 'hover' + } + const count = WORKSPACE_CARD_PROPERTY_CHOICES.filter((choice) => + shownProperties.includes(choice.id) + ).length + return count > 0 ? count : null +} + /** Project groups can hold folder workspaces, whose identity is a path, not a branch. */ export function getWorkspaceCardDisplayChoiceLabel( choice: (typeof WORKSPACE_CARD_DISPLAY_CHOICES)[number], diff --git a/src/shared/workspace-view/workspace-view-ui-fields.ts b/src/shared/workspace-view/workspace-view-ui-fields.ts index b543616825c..c313f286cfe 100644 --- a/src/shared/workspace-view/workspace-view-ui-fields.ts +++ b/src/shared/workspace-view/workspace-view-ui-fields.ts @@ -5,6 +5,9 @@ import { WORKSPACE_ROW_FILTER_UI_FIELDS } from './workspace-row-filters' export const WORKSPACE_VIEW_UI_FIELDS = [ 'groupBy', 'sortBy', + 'projectOrderBy', + 'worktreeCardProperties', + 'agentActivityDisplayMode', 'collapsedGroups', 'workspaceStatuses', // The legacy host scope: read when no host list is saved, written with a null or one-host list. diff --git a/src/shared/worktree/card-properties.test.ts b/src/shared/worktree/card-properties.test.ts index a4ebac83c31..b40347f499c 100644 --- a/src/shared/worktree/card-properties.test.ts +++ b/src/shared/worktree/card-properties.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest' import { + getWorktreeCardDetails, getWorktreeCardModeProperties, getWorktreeCardModeUpdates, isDefaultedCompactWorktreeCardProperties, @@ -32,3 +33,30 @@ describe('worktree card properties', () => { expect(isDefaultedCompactWorktreeCardProperties(['status', 'automation', 'pr'])).toBe(false) }) }) + +describe('getWorktreeCardDetails', () => { + it('shows each chosen property, and compact cards leave out the host and agent rows', () => { + const chosen = ['pr', 'host', 'inline-agents'] as const + expect( + getWorktreeCardDetails(chosen, { compactWorktreeCards: false, newCardStyle: false }) + ).toMatchObject({ + compactCards: false, + pr: true, + host: true, + inlineAgents: true, + comment: false + }) + expect( + getWorktreeCardDetails(chosen, { compactWorktreeCards: true, newCardStyle: false }) + ).toMatchObject({ compactCards: true, pr: true, host: false, inlineAgents: false }) + }) + + it('lets the new card style override the Compact layout', () => { + expect( + getWorktreeCardDetails(['host', 'inline-agents'], { + compactWorktreeCards: true, + newCardStyle: true + }) + ).toMatchObject({ compactCards: false, host: true, inlineAgents: true }) + }) +}) diff --git a/src/shared/worktree/card-properties.ts b/src/shared/worktree/card-properties.ts index 16dc2b14a12..8f91fab8260 100644 --- a/src/shared/worktree/card-properties.ts +++ b/src/shared/worktree/card-properties.ts @@ -118,3 +118,43 @@ function matchesWorktreeCardProperties( } return expected.every((property, index) => properties[index] === property) } + +/** What the shared Card layout and properties show on a workspace card; each surface draws its own. */ +export type WorktreeCardDetails = { + /** The Compact layout as it applies: the new card style overrides it. */ + compactCards: boolean + issue: boolean + linearIssue: boolean + jiraIssue: boolean + pr: boolean + automation: boolean + cli: boolean + comment: boolean + ports: boolean + /** The host badge; compact cards leave it out. */ + host: boolean + /** The agent rows under a card; compact cards leave them out. */ + inlineAgents: boolean +} + +/** Reads the desktop's raw settings, so no caller resolves the layout on its own. */ +export function getWorktreeCardDetails( + cardProps: readonly WorktreeCardProperty[], + settings: { compactWorktreeCards: boolean; newCardStyle: boolean } +): WorktreeCardDetails { + const compactCards = !settings.newCardStyle && settings.compactWorktreeCards + const shows = (property: WorktreeCardProperty) => cardProps.includes(property) + return { + compactCards, + issue: shows('issue'), + linearIssue: shows('linear-issue'), + jiraIssue: shows('jira-issue'), + pr: shows('pr'), + automation: shows('automation'), + cli: shows('cli'), + comment: shows('comment'), + ports: shows('ports'), + host: !compactCards && shows('host'), + inlineAgents: !compactCards && shows('inline-agents') + } +}