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') + } +}