From e49cab0a9263a593f10596a35ade9bb96b5a9022 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Tue, 27 Oct 2026 03:02:14 -0700 Subject: [PATCH] fix(workspace-board): paired hosts keep their own custom status columns (#34362) * fix(workspace-board): paired hosts keep their own custom status columns A desktop client grouped a paired host's workspaces with its local column list, so cards in host-only columns (e.g. status-6) fell back to the default column. Fetch each host's workspaceStatuses via the existing ui.get RPC and merge them after the local columns for grouping, without persisting them. Fixes #34227 Co-Authored-By: Claude Opus 5.5 * fix(workspace-board): keep paired host columns safe from local column edits - Removing a local column resolves cards against the merged board list, so a host card in a host-only column is never rewritten. - New column ids are unique against the merged list, not just local ones. - The context menu status submenu and the closed-sidebar order use the merged columns too; the shared-status helper moves to the menu policy module. - A host that sends no columns adds none instead of the defaults; identical replies keep the stored identity and out-of-order replies are dropped. - The merged list is named boardStatuses and tolerates stores without the host fields, so the earlier WorktreeList fixture edits are reverted. Co-Authored-By: Claude Opus 5.5 * fix(workspace-board): keep host cards whose host still defines a removed id Removing a local column whose id a paired host also defines rewrote that host's cards to the local fallback. Skip runtime-host cards when their own host still has the column. Co-Authored-By: Claude Opus 5.5 * fix(workspace-board): keep host column ids in the composer and drop a retired host's columns Creating from a host-only lane now keeps that column's status, a host that is removed or re-paired to another machine no longer leaves its columns behind, and the new tests use checked fixtures instead of casts. Co-authored-by: Kooooojun * fix(workspace-board): deliver a host's workspaceViewChanged so its columns refresh Co-authored-by: Kooooojun * fix(workspace-board): show and offer a paired host's columns only for that host's cards The board adds a host's column only when one of that host's own cards uses it, never the host's copy of the default column, so local cards keep falling back to the local list. The context menu offers host columns only when every card is on that host, the composer applies a lane's host column only when creating on that host, and status writes drop another host's column id. Co-authored-by: Kooooojun * fix(workspace-board): read cards through the record selectors and skip foreign-lane drops Board lanes and the context menu now leave out a host's copy of the default column, so a host card's menu matches its lane. Lanes skip the card scan when no host adds a column. A drop into another paired host's lane is ignored instead of re-ranking the lane. Co-authored-by: Kooooojun * fix(workspace-board): sync tasks against every offered column and test host-lane drops Task-status sync now finds a paired host's column that no card uses yet, as the card's menu offers it. A write left empty after dropping another host's column is skipped. New tests cover board and sidebar drops into a host-only lane. Co-authored-by: Kooooojun * fix(workspace-board): resolve host columns by runtime owner and compare mixed selections per card A paired host's SSH workspace, which the host's runtime owns, now gets that host's columns like its other cards. A mixed selection marks and moves each card against its own host's columns. A status write for a card the store does not know yet passes through unchanged. Co-authored-by: Kooooojun --------- Co-authored-by: Kooooojun Co-authored-by: Claude Opus 5.5 --- ...rkspaceKanbanDrawer.host-statuses.test.tsx | 232 ++++++++++++++++++ .../sidebar/WorkspaceKanbanDrawer.tsx | 21 +- ...WorktreeContextMenu.host-statuses.test.tsx | 143 +++++++++++ .../sidebar/WorktreeContextMenu.test.ts | 23 +- .../src/components/sidebar/WorktreeList.tsx | 9 +- .../rendered-sidebar-worktree-order.test.ts | 20 ++ .../rendered-sidebar-worktree-order.ts | 3 +- .../sidebar/use-workspace-board-statuses.ts | 82 +++++++ .../use-workspace-board-task-status-sync.ts | 8 +- .../use-workspace-kanban-board-projection.ts | 4 +- .../use-workspace-kanban-status-actions.ts | 20 +- .../use-workspace-kanban-worktree-actions.ts | 21 +- .../use-worktree-context-menu-commands.ts | 4 +- .../use-worktree-context-menu-model.tsx | 24 +- ...workspace-board-host-column-drops.test.tsx | 86 +++++++ .../sidebar/worktree-context-menu-policy.ts | 17 +- .../drag/use-status-mutations.ts | 35 ++- .../composer-host-workspace-status.test.tsx | 33 +++ .../composer-state/composer-target-store.ts | 4 +- .../quick-creation-execution.ts | 16 +- .../composer-state/target-store-model.ts | 2 +- .../hooks/ipc-events-harness-store-state.ts | 1 + .../ipc-events/runtime-client-ipc-bridge.ts | 6 +- ...eIpcEvents-cli-worktree-activation.test.ts | 1 + ...seIpcEvents-repos-changed-catalogs.test.ts | 3 + .../src/runtime/runtime-client-events.test.ts | 12 + .../src/runtime/runtime-client-events.ts | 3 +- .../host-workspace-status-columns.test.ts | 101 ++++++++ .../store/host-workspace-status-columns.ts | 181 ++++++++++++++ .../src/store/slices/runtime-status-types.ts | 1 + .../src/store/slices/runtime-status.ts | 1 + .../slices/ui-host-workspace-statuses.test.ts | 127 ++++++++++ .../ui/ui-slice-contract-preferences.ts | 4 + .../ui/ui-slice-host-workspace-statuses.ts | 78 ++++++ .../slices/ui/ui-slice-preference-actions.ts | 2 + .../worktrees-metadata-persistence.test.ts | 60 +++++ src/renderer/src/store/slices/worktrees.ts | 8 +- .../metadata/foreign-host-status-guard.ts | 65 +++++ src/shared/workspace-statuses.ts | 16 ++ 39 files changed, 1424 insertions(+), 53 deletions(-) create mode 100644 src/renderer/src/components/sidebar/WorkspaceKanbanDrawer.host-statuses.test.tsx create mode 100644 src/renderer/src/components/sidebar/WorktreeContextMenu.host-statuses.test.tsx create mode 100644 src/renderer/src/components/sidebar/use-workspace-board-statuses.ts create mode 100644 src/renderer/src/components/sidebar/workspace-board-host-column-drops.test.tsx create mode 100644 src/renderer/src/hooks/composer-state/composer-host-workspace-status.test.tsx create mode 100644 src/renderer/src/store/host-workspace-status-columns.test.ts create mode 100644 src/renderer/src/store/host-workspace-status-columns.ts create mode 100644 src/renderer/src/store/slices/ui-host-workspace-statuses.test.ts create mode 100644 src/renderer/src/store/slices/ui/ui-slice-host-workspace-statuses.ts create mode 100644 src/renderer/src/store/slices/worktrees/metadata/foreign-host-status-guard.ts diff --git a/src/renderer/src/components/sidebar/WorkspaceKanbanDrawer.host-statuses.test.tsx b/src/renderer/src/components/sidebar/WorkspaceKanbanDrawer.host-statuses.test.tsx new file mode 100644 index 00000000000..7583d6eb378 --- /dev/null +++ b/src/renderer/src/components/sidebar/WorkspaceKanbanDrawer.host-statuses.test.tsx @@ -0,0 +1,232 @@ +// @vitest-environment happy-dom + +import React, { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { useAppStore } from '@/store' +import type { AppState } from '@/store/types' +import type { Repo } from '../../../../shared/repo-types' +import type { WorkspaceStatusDefinition, Worktree } from '../../../../shared/worktree/types' +import { toRuntimeExecutionHostId } from '../../../../shared/execution-host' +import { getWorktreeHostIdentity } from '../../../../shared/worktree/host-qualified-identity' +import WorkspaceKanbanDrawer from './WorkspaceKanbanDrawer' +import type { WorkspaceKanbanLaneView } from './workspace-kanban-search' +import { makeWorktree } from '@/store/slices/worktrees-slice-test-fixtures' + +type GridCapture = { + statuses: readonly WorkspaceStatusDefinition[] + laneViews: ReadonlyMap +} + +type HeaderCapture = { + onAddStatus: () => void + onRemoveStatus: (statusId: string) => void +} + +const { gridState, headerState, syncState } = vi.hoisted(() => { + const grid: { current: GridCapture | null } = { current: null } + const header: { current: HeaderCapture | null } = { current: null } + const sync: { statuses: readonly WorkspaceStatusDefinition[] } = { statuses: [] } + return { gridState: grid, headerState: header, syncState: sync } +}) + +globalThis.IS_REACT_ACT_ENVIRONMENT = true + +vi.mock('sonner', () => ({ toast: { error: vi.fn(), warning: vi.fn() } })) +vi.mock('@/components/ui/sheet', () => ({ + Sheet: ({ children }: { children: React.ReactNode }) =>
{children}
, + SheetContent: ({ children }: { children: React.ReactNode }) =>
{children}
+})) +vi.mock('./WorkspaceKanbanDrawerHeader', () => ({ + default: (props: HeaderCapture) => { + headerState.current = props + return
+ } +})) +vi.mock('./WorkspaceKanbanLaneGrid', () => ({ + default: (props: GridCapture) => { + gridState.current = props + return
+ } +})) +vi.mock('./WorkspaceKanbanAreaSelectionOverlay', () => ({ + default: React.forwardRef((_, ref) =>
) +})) +vi.mock('./WorkspaceKanbanPinDropTarget', () => ({ default: () =>
})) +vi.mock('./use-visible-workspace-kanban-worktree-ids', () => ({ + useVisibleWorkspaceKanbanWorktreeIds: ({ allWorktrees }: { allWorktrees: readonly Worktree[] }) => + new Set(allWorktrees.map(getWorktreeHostIdentity)) +})) +vi.mock('./use-workspace-kanban-create-worktree', () => ({ + useWorkspaceKanbanCreateWorktree: () => ({ createWorktreeForStatus: vi.fn() }) +})) +vi.mock('./use-workspace-kanban-shift-wheel-scroll', () => ({ + useWorkspaceKanbanShiftWheelScroll: vi.fn() +})) +vi.mock('./use-workspace-kanban-outside-dismiss', () => ({ + isWorkspaceBoardKeepOpenTarget: () => false, + useWorkspaceKanbanOutsideDismiss: vi.fn() +})) +vi.mock('@/components/contextual-tours/use-contextual-tour', () => ({ + useContextualTour: vi.fn() +})) +vi.mock('./use-workspace-status-drop', () => ({ useWorkspaceStatusDocumentDrop: vi.fn() })) +vi.mock('./use-workspace-board-task-status-sync', () => ({ + useWorkspaceBoardTaskStatusSync: (args: { + workspaceStatuses: readonly WorkspaceStatusDefinition[] + }) => { + syncState.statuses = args.workspaceStatuses + return vi.fn() + } +})) + +const hostCard = makeWorktree({ + id: 'repo-r::/today', + repoId: 'repo-r', + hostId: toRuntimeExecutionHostId('env-1'), + displayName: 'Today', + path: '/today', + branch: 'feature/today', + sortOrder: 1, + manualOrder: 1, + lastActivityAt: 1, + workspaceStatus: 'status-6' +}) + +const localCard = makeWorktree({ + ...hostCard, + id: 'repo-l::/local', + repoId: 'repo-l', + hostId: undefined, + path: '/local', + workspaceStatus: 'in-progress' +}) + +function makeRepo(id: string): Repo { + return { id, path: `/${id}`, displayName: id, badgeColor: '#999999', addedAt: 1 } +} + +let container: HTMLDivElement +let root: Root +let updateWorktreeMeta: ReturnType> +let setWorkspaceStatuses: ReturnType void>> + +beforeEach(() => { + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + gridState.current = null + headerState.current = null + updateWorktreeMeta = vi.fn(() => Promise.resolve({ ok: true })) + setWorkspaceStatuses = vi.fn<(statuses: WorkspaceStatusDefinition[]) => void>() + useAppStore.setState({ + repos: [makeRepo('repo-r'), makeRepo('repo-l')], + worktreesByRepo: { 'repo-r': [hostCard], 'repo-l': [localCard] }, + updateWorktreeMeta, + setWorkspaceStatuses, + workspaceStatuses: [ + { id: 'in-progress', label: 'In progress' }, + { id: 'todo', label: 'Todo' } + ], + hostWorkspaceStatuses: { + 'env-1': [ + { id: 'status-6', label: '🔥 今天收尾' }, + { id: 'todo', label: 'Host todo' }, + { id: 'status-7', label: 'No card here' } + ] + }, + removedRuntimeEnvironmentIds: new Set(), + sortBy: 'manual', + recordFeatureInteraction: vi.fn() + }) +}) + +afterEach(() => { + act(() => root.unmount()) + container.remove() +}) + +function renderDrawer(): void { + act(() => { + root.render( + + ) + }) +} + +function laneIds(status: string): string[] { + return (gridState.current?.laneViews.get(status)?.items ?? []).map((item) => item.id) +} + +describe('WorkspaceKanbanDrawer paired host columns', () => { + it("puts a paired host's card in the host's own column, not the default one", () => { + renderDrawer() + + expect(gridState.current?.statuses).toEqual([ + { id: 'in-progress', label: 'In progress' }, + { id: 'todo', label: 'Todo' }, + { id: 'status-6', label: '🔥 今天收尾' } + ]) + expect(laneIds('status-6')).toEqual([hostCard.id]) + expect(laneIds('in-progress')).toEqual([localCard.id]) + }) + + it('never rewrites a host card in a host-only column when the local default column is removed', () => { + renderDrawer() + + act(() => headerState.current?.onRemoveStatus('in-progress')) + + expect(setWorkspaceStatuses).toHaveBeenCalledWith([{ id: 'todo', label: 'Todo' }]) + expect(updateWorktreeMeta.mock.calls.map(([id]) => id)).toEqual([localCard.id]) + }) + + it('never rewrites a host card whose own host still defines the removed column id', () => { + const hostTodoCard = { ...hostCard, id: 'repo-r::/repo-r/todo', workspaceStatus: 'todo' } + useAppStore.setState({ + worktreesByRepo: { + 'repo-r': [hostTodoCard], + 'repo-l': [{ ...localCard, workspaceStatus: 'todo' }] + } + }) + renderDrawer() + + act(() => headerState.current?.onRemoveStatus('todo')) + + expect(updateWorktreeMeta.mock.calls.map(([id]) => id)).toEqual([localCard.id]) + }) + + it("gives a new local column an id no paired host's column already uses", () => { + useAppStore.setState({ + workspaceStatuses: [{ id: 'in-progress', label: 'In progress' }], + hostWorkspaceStatuses: { 'env-1': [{ id: 'status-2', label: 'Host lane' }] } + }) + renderDrawer() + + act(() => headerState.current?.onAddStatus()) + + const added = setWorkspaceStatuses.mock.calls[0]?.[0] ?? [] + expect(added.map((status) => status.id)).toEqual(['in-progress', expect.any(String)]) + expect(added[1]?.id).not.toBe('status-2') + }) + + it("lets task sync find a host column no card uses yet, as the card's menu offers it", () => { + renderDrawer() + + expect(syncState.statuses.map((status) => status.id)).toContain('status-7') + expect(gridState.current?.statuses.map((status) => status.id)).not.toContain('status-7') + }) + + it('drops a removed host’s columns', () => { + useAppStore.setState({ removedRuntimeEnvironmentIds: new Set(['env-1']) }) + renderDrawer() + + expect(gridState.current?.statuses.map((status) => status.id)).toEqual(['in-progress', 'todo']) + }) +}) diff --git a/src/renderer/src/components/sidebar/WorkspaceKanbanDrawer.tsx b/src/renderer/src/components/sidebar/WorkspaceKanbanDrawer.tsx index 0375253c984..87227e764e8 100644 --- a/src/renderer/src/components/sidebar/WorkspaceKanbanDrawer.tsx +++ b/src/renderer/src/components/sidebar/WorkspaceKanbanDrawer.tsx @@ -22,6 +22,10 @@ import { useWorkspaceKanbanRenderLifecycle } from './use-workspace-kanban-render import { useWorkspaceKanbanDrawerLingering } from './use-workspace-kanban-drawer-lingering' import { buildWorktreeManualOrderCatalog } from './worktree-manual-order-catalog' import { selectFolderWorkspaces } from '@/store/workspace-record-selectors' +import { + useAllOfferedWorkspaceStatuses, + useWorkspaceBoardStatuses +} from './use-workspace-board-statuses' type WorkspaceKanbanDrawerProps = { leftSidebarStyle?: React.CSSProperties @@ -60,7 +64,9 @@ function WorkspaceKanbanDrawerContent({ const activeWorkspaceExecutionHostId = useAppStore((s) => s.activeWorkspaceExecutionHostId) const updateWorktreeMeta = useAppStore((s) => s.updateWorktreeMeta) const updateWorktreesMeta = useAppStore((s) => s.updateWorktreesMeta) - const workspaceStatuses = useAppStore((s) => s.workspaceStatuses) + const localWorkspaceStatuses = useAppStore((s) => s.workspaceStatuses) + const boardStatuses = useWorkspaceBoardStatuses() + const offeredStatuses = useAllOfferedWorkspaceStatuses() const setWorkspaceStatuses = useAppStore((s) => s.setWorkspaceStatuses) const syncTaskStatusFromWorkspaceBoard = useAppStore((s) => s.syncTaskStatusFromWorkspaceBoard) const setSyncTaskStatusFromWorkspaceBoard = useAppStore( @@ -96,7 +102,7 @@ function WorkspaceKanbanDrawerContent({ open, repoMap, sortBy, - workspaceStatuses + workspaceStatuses: boardStatuses }) const { selectedWorktreeIds, @@ -120,7 +126,7 @@ function WorkspaceKanbanDrawerContent({ const maybeSyncWorkspaceBoardTaskStatuses = useWorkspaceBoardTaskStatusSync({ enabled: syncTaskStatusFromWorkspaceBoard, worktreesById: worktreeById, - workspaceStatuses + workspaceStatuses: offeredStatuses }) const { dropPointerDraggedWorktreesInStatus, @@ -138,7 +144,7 @@ function WorkspaceKanbanDrawerContent({ sortBy, updateWorktreeMeta, updateWorktreesMeta, - workspaceStatuses, + workspaceStatuses: boardStatuses, worktreeById, manualOrderCatalog, worktreesByStatus @@ -202,7 +208,8 @@ function WorkspaceKanbanDrawerContent({ handleRemoveStatus } = useWorkspaceKanbanStatusActions({ allWorktrees, - workspaceStatuses, + workspaceStatuses: localWorkspaceStatuses, + boardStatuses, setWorkspaceStatuses, updateWorktreeMeta }) @@ -256,7 +263,7 @@ function WorkspaceKanbanDrawerContent({ totalCount: boardWorktrees.length, onQueryChange: setQuery, onClearQuery: clearQuery, - workspaceStatuses, + workspaceStatuses: localWorkspaceStatuses, syncTaskStatusFromWorkspaceBoard, onSyncTaskStatusFromWorkspaceBoardChange: setSyncTaskStatusFromWorkspaceBoard, onRenameStatus: handleRenameStatus, @@ -270,7 +277,7 @@ function WorkspaceKanbanDrawerContent({ }} laneGridProps={{ laneScrollerRef, - statuses: workspaceStatuses, + statuses: boardStatuses, laneViews, laneFullWorktreeIds, hasQuery, diff --git a/src/renderer/src/components/sidebar/WorktreeContextMenu.host-statuses.test.tsx b/src/renderer/src/components/sidebar/WorktreeContextMenu.host-statuses.test.tsx new file mode 100644 index 00000000000..f6c927e0a06 --- /dev/null +++ b/src/renderer/src/components/sidebar/WorktreeContextMenu.host-statuses.test.tsx @@ -0,0 +1,143 @@ +/** + * @vitest-environment happy-dom + */ +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { WorkspaceStatusDefinition, Worktree } from '../../../../shared/worktree/types' +import { TooltipProvider } from '@/components/ui/tooltip' +import WorktreeContextMenu from './WorktreeContextMenu' +import { makeWorktree } from '@/store/slices/worktrees-slice-test-fixtures' +import { toRuntimeExecutionHostId } from '../../../../shared/execution-host' + +globalThis.IS_REACT_ACT_ENVIRONMENT = true + +const state = { + unifiedTabsByWorktree: {}, + updateWorktreeMeta: vi.fn(), + setWorktreesPinnedAndReveal: vi.fn(), + workspaceStatuses: [ + { id: 'in-progress', label: 'In progress' }, + { id: 'todo', label: 'Todo' } + ], + hostWorkspaceStatuses: { 'env-1': [{ id: 'status-6', label: '🔥 今天收尾' }] }, + removedRuntimeEnvironmentIds: new Set(), + openModal: vi.fn(), + projectGroups: [], + createProjectGroup: vi.fn(), + moveProjectToGroup: vi.fn(), + deleteStateByWorktreeId: {}, + worktreeLineageById: {}, + workspaceLineageByChildKey: {}, + updateWorktreeLineage: vi.fn(), + tabsByWorktree: {}, + ptyIdsByTabId: {}, + browserTabsByWorktree: {}, + keybindings: {}, + settings: { activeRuntimeEnvironmentId: null, openInApplications: [] }, + openSettingsPage: vi.fn(), + openSettingsTarget: vi.fn() +} + +vi.mock('@/store', () => ({ + useAppStore: Object.assign((selector: (s: typeof state) => unknown) => selector(state), { + getState: () => state + }) +})) + +vi.mock('@/store/selectors', () => ({ + useAllWorktrees: () => [], + useRepoById: (repoId?: string) => + repoId ? { id: repoId, name: repoId, displayName: repoId, projectGroupId: null } : undefined, + useRepoMap: () => new Map(), + useWorktreeMap: () => new Map() +})) + +vi.mock('@/i18n/i18n', () => ({ + translate: (_key: string, fallback: string) => fallback, + i18n: { language: 'en', on: () => {}, off: () => {} } +})) + +vi.mock('./ProjectGroupNameDialog', () => ({ ProjectGroupNameDialog: () => null })) +vi.mock('./WorktreeParentPickerPopover', () => ({ WorktreeParentPickerPopover: () => null })) + +type StatusMenuCapture = { + contextWorkspaceStatus: string + workspaceStatuses: readonly WorkspaceStatusDefinition[] +} +const statusMenu = vi.hoisted(() => { + const capture: { current: StatusMenuCapture | null } = { current: null } + return capture +}) +vi.mock('./WorktreeStatusMenuItems', () => ({ + WorktreeStatusMenuItems: (props: StatusMenuCapture) => { + statusMenu.current = props + return null + } +})) +vi.mock('./WorktreeOpenInMenu', () => ({ WorktreeOpenInSubMenu: () => null })) + +const mounted: { container: HTMLDivElement; root: Root }[] = [] + +afterEach(() => { + for (const { root, container } of mounted) { + act(() => root.unmount()) + container.remove() + } + mounted.length = 0 + statusMenu.current = null +}) + +function openMenu(worktree: Worktree): void { + const container = document.createElement('div') + document.body.appendChild(container) + const root = createRoot(container) + mounted.push({ container, root }) + act(() => { + root.render( + + +
Card
+
+
+ ) + }) + const scope = container.querySelector('[data-worktree-context-menu-scope]') + act(() => { + scope?.dispatchEvent( + new MouseEvent('contextmenu', { bubbles: true, cancelable: true, clientX: 10, clientY: 10 }) + ) + }) +} + +describe('WorktreeContextMenu Move to Status for a paired host card', () => { + it("offers the host's own column and marks it current instead of the default", () => { + openMenu( + makeWorktree({ + id: 'repo::wt-1', + repoId: 'repo', + hostId: toRuntimeExecutionHostId('env-1'), + displayName: 'Today', + branch: 'refs/heads/today', + path: '/today', + workspaceStatus: 'status-6' + }) + ) + + expect(statusMenu.current?.workspaceStatuses.map((status) => status.id)).toEqual([ + 'in-progress', + 'todo', + 'status-6' + ]) + expect(statusMenu.current?.contextWorkspaceStatus).toBe('status-6') + }) + + it("offers a local card only the local columns, not a paired host's", () => { + openMenu(makeWorktree({ id: 'repo::wt-2', repoId: 'repo', path: '/local' })) + + expect(statusMenu.current?.workspaceStatuses.map((status) => status.id)).toEqual([ + 'in-progress', + 'todo' + ]) + }) +}) diff --git a/src/renderer/src/components/sidebar/WorktreeContextMenu.test.ts b/src/renderer/src/components/sidebar/WorktreeContextMenu.test.ts index 4cddc64e6e4..a97486ccb3d 100644 --- a/src/renderer/src/components/sidebar/WorktreeContextMenu.test.ts +++ b/src/renderer/src/components/sidebar/WorktreeContextMenu.test.ts @@ -10,6 +10,7 @@ import { getWorktreeParentPickerLabel, hasWorktreeParentLink, isWorktreeParentPickerDisabled, + getSharedWorkspaceStatus, planWorkspaceStatusAssignment, selectMenuScopedMap, shouldRevealWorktreeDeveloperMenu @@ -271,7 +272,7 @@ describe('planWorkspaceStatusAssignment (context-menu "Move to Status" routing)' planWorkspaceStatusAssignment( [wt('a', 'todo'), wt('b', 'in-review')], 'in-review', - statuses, + () => statuses, true ) ).toEqual({ kind: 'board-sync', worktreeIds: ['a', 'b'] }) @@ -282,18 +283,34 @@ describe('planWorkspaceStatusAssignment (context-menu "Move to Status" routing)' planWorkspaceStatusAssignment( [wt('a', 'todo'), wt('b', 'in-review')], 'in-review', - statuses, + () => statuses, false ) ).toEqual({ kind: 'local-only', localWriteIds: ['a'] }) }) + it("compares each card against its own host's columns in a mixed selection", () => { + const hostColumns: WorkspaceStatusDefinition[] = [ + ...statuses, + { id: 'status-6', label: 'Today' } + ] + const local = wt('a', 'todo') + const hosted = { ...wt('b', 'status-6'), hostId: 'runtime:env-1' as const } + const statusesFor = (item: Worktree) => (item.hostId ? hostColumns : statuses) + + expect(getSharedWorkspaceStatus([local, hosted], statusesFor)).toBe('') + expect(planWorkspaceStatusAssignment([local, hosted], 'todo', statusesFor, false)).toEqual({ + kind: 'local-only', + localWriteIds: ['b'] + }) + }) + it('writes nothing on the local-only path when every worktree already has the target status', () => { expect( planWorkspaceStatusAssignment( [wt('a', 'in-review'), wt('b', 'in-review')], 'in-review', - statuses, + () => statuses, false ) ).toEqual({ kind: 'local-only', localWriteIds: [] }) diff --git a/src/renderer/src/components/sidebar/WorktreeList.tsx b/src/renderer/src/components/sidebar/WorktreeList.tsx index 7cc5567b70a..2b084b88ede 100644 --- a/src/renderer/src/components/sidebar/WorktreeList.tsx +++ b/src/renderer/src/components/sidebar/WorktreeList.tsx @@ -35,6 +35,7 @@ import { ORCA_SIDEBAR_INPUTS_IS_MAIN } from '../../../../shared/sidebar-inputs-s import { useWorktreeStatusMutations } from './worktree-list/drag/use-status-mutations' import { shouldFiltersHideAllRows } from './sidebar-empty-state-gate' import { buildWorktreeManualOrderCatalog } from './worktree-manual-order-catalog' +import { useWorkspaceBoardStatuses } from './use-workspace-board-statuses' import { selectDetectedWorktreesByRepo, selectFolderWorkspaces @@ -80,7 +81,7 @@ const WorktreeList = React.memo(function WorktreeList({ [activeWorkspaceKey, activeWorktreeId] ) const groupBy = useAppStore((s) => s.groupBy) - const workspaceStatuses = useAppStore((s) => s.workspaceStatuses) + const boardStatuses = useWorkspaceBoardStatuses() const sortBy = useAppStore((s) => s.sortBy) const projectOrderBy = useAppStore((s) => s.projectOrderBy) const openModal = useAppStore((s) => s.openModal) @@ -126,7 +127,7 @@ const WorktreeList = React.memo(function WorktreeList({ projectOrderBy, pinnedDisplayPolicy, defaultHostId, - workspaceStatuses, + workspaceStatuses: boardStatuses, settings, collapsedGroups, repos, @@ -152,7 +153,7 @@ const WorktreeList = React.memo(function WorktreeList({ }) const statusMutations = useWorktreeStatusMutations({ manualOrderCatalog, - workspaceStatuses, + workspaceStatuses: boardStatuses, sortBy }) const projectGroupDialogs = useProjectGroupDialogs({ repos, repoMap, projectGroups }) @@ -310,7 +311,7 @@ const WorktreeList = React.memo(function WorktreeList({ onHostDragActiveChange={rowModel.setHostDragActive} prCache={prCache} hostedReviewCache={hostedReviewCache} - workspaceStatuses={workspaceStatuses} + workspaceStatuses={boardStatuses} projectGrouping={projectGrouping} projectGroups={projectGroups} visibleProjectGroups={visibleScope.visibleProjectGroupsForRows} diff --git a/src/renderer/src/components/sidebar/rendered-sidebar-worktree-order.test.ts b/src/renderer/src/components/sidebar/rendered-sidebar-worktree-order.test.ts index 9a4d971bc7d..fa667e15f7b 100644 --- a/src/renderer/src/components/sidebar/rendered-sidebar-worktree-order.test.ts +++ b/src/renderer/src/components/sidebar/rendered-sidebar-worktree-order.test.ts @@ -248,6 +248,26 @@ describe('closed-sidebar Cmd+1-9 ordering (#9497)', () => { ]) }) + it("orders a card in a paired host's own column by that column, not the default", () => { + const today = makeWorktree('wt-today', { + hostId: 'runtime:env-1', + workspaceStatus: 'status-6', + sortOrder: 0 + }) + const todo = makeWorktree('wt-todo', { workspaceStatus: 'todo', sortOrder: 1 }) + const doing = makeWorktree('wt-doing', { workspaceStatus: 'in-progress', sortOrder: 2 }) + seedStore([today, todo, doing], { + groupBy: 'workspace-status', + workspaceStatuses: [ + { id: 'in-progress', label: 'In progress' }, + { id: 'todo', label: 'Todo' } + ], + hostWorkspaceStatuses: { 'env-1': [{ id: 'status-6', label: '🔥 今天收尾' }] } + }) + + expect(getVisibleWorktreeIds()).toEqual(['wt-doing', 'wt-todo', 'wt-today']) + }) + it('keeps the sort layer intact for smart and comparator sort modes', () => { const main = makeMainWorktree('wt-main') const older = makeWorktree('wt-older', { displayName: 'b-older', lastActivityAt: 1 }) diff --git a/src/renderer/src/components/sidebar/rendered-sidebar-worktree-order.ts b/src/renderer/src/components/sidebar/rendered-sidebar-worktree-order.ts index 361c0b456f8..e98d7c3579c 100644 --- a/src/renderer/src/components/sidebar/rendered-sidebar-worktree-order.ts +++ b/src/renderer/src/components/sidebar/rendered-sidebar-worktree-order.ts @@ -31,6 +31,7 @@ import { getVisibleSidebarHostIdSet } from '../../../../shared/sidebar-list/host-filtering' import { selectFolderWorkspaces } from '@/store/workspace-record-selectors' +import { selectWorkspaceBoardStatuses } from '@/store/host-workspace-status-columns' const EMPTY_REPO_ID_SET: ReadonlySet = Object.freeze(new Set()) const EMPTY_IMPORTED_BY_REPO: ReadonlyMap = new Map() @@ -73,7 +74,7 @@ export function computeRenderedSidebarRows( repoMap, prGroupOf: createSidebarPRGroupReader(repoMap, prCache), repoOrder: getLogicalRepoOrderRankById(state.repos.map((repo) => repo.id)), - workspaceStatuses: state.workspaceStatuses, + workspaceStatuses: selectWorkspaceBoardStatuses(state), projectOrderBy: state.projectOrderBy, lineageById: state.worktreeLineageById, worktreeMap: getWorktreeMapFromState(state), diff --git a/src/renderer/src/components/sidebar/use-workspace-board-statuses.ts b/src/renderer/src/components/sidebar/use-workspace-board-statuses.ts new file mode 100644 index 00000000000..8a41509648a --- /dev/null +++ b/src/renderer/src/components/sidebar/use-workspace-board-statuses.ts @@ -0,0 +1,82 @@ +import { useCallback, useMemo } from 'react' +import { useAppStore } from '@/store' +import { + selectAllOfferedWorkspaceStatuses, + selectWorkspaceBoardStatuses, + selectWorkspaceStatusesForHost, + type StatusColumnOwner +} from '@/store/host-workspace-status-columns' +import { selectFolderWorkspaces, selectWorktreesByRepo } from '@/store/workspace-record-selectors' +import type { WorkspaceStatusDefinition, Worktree } from '../../../../shared/worktree/types' + +/** The columns cards group by: local ones plus the paired hosts' columns their own cards use. Edits use the local list. */ +export function useWorkspaceBoardStatuses(): readonly WorkspaceStatusDefinition[] { + const workspaceStatuses = useAppStore((s) => s.workspaceStatuses) + const hostWorkspaceStatuses = useAppStore((s) => s.hostWorkspaceStatuses) + const removedRuntimeEnvironmentIds = useAppStore((s) => s.removedRuntimeEnvironmentIds) + const worktreesByRepo = useAppStore(selectWorktreesByRepo) + const folderWorkspaces = useAppStore(selectFolderWorkspaces) + return useMemo( + () => + selectWorkspaceBoardStatuses({ + workspaceStatuses, + hostWorkspaceStatuses, + removedRuntimeEnvironmentIds, + worktreesByRepo, + folderWorkspaces + }), + [ + workspaceStatuses, + hostWorkspaceStatuses, + removedRuntimeEnvironmentIds, + worktreesByRepo, + folderWorkspaces + ] + ) +} + +/** + * A context menu's columns: a paired host's own only when every card is on that host. Each card + * still resolves against its own host's columns, so a mixed selection compares like with like. + */ +export function useContextWorkspaceStatuses(worktrees: readonly Worktree[]): { + workspaceStatuses: readonly WorkspaceStatusDefinition[] + statusesForWorktree: (worktree: Worktree) => readonly WorkspaceStatusDefinition[] +} { + const workspaceStatuses = useAppStore((s) => s.workspaceStatuses) + const hostWorkspaceStatuses = useAppStore((s) => s.hostWorkspaceStatuses) + const removedRuntimeEnvironmentIds = useAppStore((s) => s.removedRuntimeEnvironmentIds) + const owners = new Set( + worktrees.map((item) => item.runtimeOwnerEnvironmentId ?? item.hostId ?? 'local') + ) + const sharedOwner = owners.size === 1 ? (worktrees[0] ?? null) : null + const statusesForWorktree = useCallback( + (owner: StatusColumnOwner | null) => + selectWorkspaceStatusesForHost( + { workspaceStatuses, hostWorkspaceStatuses, removedRuntimeEnvironmentIds }, + owner + ), + [workspaceStatuses, hostWorkspaceStatuses, removedRuntimeEnvironmentIds] + ) + const offered = useMemo( + () => statusesForWorktree(sharedOwner), + [statusesForWorktree, sharedOwner] + ) + return { workspaceStatuses: offered, statusesForWorktree } +} + +/** Every column a menu can offer, so task sync finds a host column no card uses yet. */ +export function useAllOfferedWorkspaceStatuses(): readonly WorkspaceStatusDefinition[] { + const workspaceStatuses = useAppStore((s) => s.workspaceStatuses) + const hostWorkspaceStatuses = useAppStore((s) => s.hostWorkspaceStatuses) + const removedRuntimeEnvironmentIds = useAppStore((s) => s.removedRuntimeEnvironmentIds) + return useMemo( + () => + selectAllOfferedWorkspaceStatuses({ + workspaceStatuses, + hostWorkspaceStatuses, + removedRuntimeEnvironmentIds + }), + [workspaceStatuses, hostWorkspaceStatuses, removedRuntimeEnvironmentIds] + ) +} diff --git a/src/renderer/src/components/sidebar/use-workspace-board-task-status-sync.ts b/src/renderer/src/components/sidebar/use-workspace-board-task-status-sync.ts index 215735db777..2ecd1767ba0 100644 --- a/src/renderer/src/components/sidebar/use-workspace-board-task-status-sync.ts +++ b/src/renderer/src/components/sidebar/use-workspace-board-task-status-sync.ts @@ -6,7 +6,11 @@ import { warnAmbiguousOwnerOnce } from '@/lib/ambiguous-owner-warning' import type { WorktreeOperationRouteState } from '@/lib/worktree-operation-route' import type { RuntimeLinearSettings } from '@/runtime/runtime-linear-client' import { translate } from '@/i18n/i18n' -import type { WorkspaceStatus, Worktree } from '../../../../shared/worktree/types' +import type { + WorkspaceStatus, + WorkspaceStatusDefinition, + Worktree +} from '../../../../shared/worktree/types' import { getWorkspaceBoardTaskStatusSyncRequest, syncWorkspaceBoardTaskStatuses, @@ -134,7 +138,7 @@ export function linearSettingsForBoardWorktree( export function useWorkspaceBoardTaskStatusSync(args: { enabled: boolean worktreesById: ReadonlyMap - workspaceStatuses: ReturnType['workspaceStatuses'] + workspaceStatuses: readonly WorkspaceStatusDefinition[] }): (worktreeIds: readonly string[], status: WorkspaceStatus) => void { return useCallback( (worktreeIds, status) => { diff --git a/src/renderer/src/components/sidebar/use-workspace-kanban-board-projection.ts b/src/renderer/src/components/sidebar/use-workspace-kanban-board-projection.ts index 74d3a9ec47a..77c8db1a769 100644 --- a/src/renderer/src/components/sidebar/use-workspace-kanban-board-projection.ts +++ b/src/renderer/src/components/sidebar/use-workspace-kanban-board-projection.ts @@ -10,7 +10,7 @@ import { composeWorktreeHostIdentity, getWorktreeHostIdentity } from '../../../../shared/worktree/host-qualified-identity' -import type { Worktree } from '../../../../shared/worktree/types' +import type { WorkspaceStatusDefinition, Worktree } from '../../../../shared/worktree/types' import type { ExecutionHostId } from '../../../../shared/execution-host' import type { WorktreeDragGroup } from './worktree-manual-order' import type { useRepoMap } from '@/store/selectors' @@ -22,7 +22,7 @@ export function useWorkspaceKanbanBoardProjection(args: { open: boolean repoMap: ReturnType sortBy: ReturnType['sortBy'] - workspaceStatuses: ReturnType['workspaceStatuses'] + workspaceStatuses: readonly WorkspaceStatusDefinition[] }) { const visibleWorktreeIds = useVisibleWorkspaceKanbanWorktreeIds({ allWorktrees: args.allWorktrees, diff --git a/src/renderer/src/components/sidebar/use-workspace-kanban-status-actions.ts b/src/renderer/src/components/sidebar/use-workspace-kanban-status-actions.ts index 5e5f9729ddc..7dbb2301868 100644 --- a/src/renderer/src/components/sidebar/use-workspace-kanban-status-actions.ts +++ b/src/renderer/src/components/sidebar/use-workspace-kanban-status-actions.ts @@ -2,13 +2,19 @@ import { useCallback } from 'react' import { useAppStore } from '@/store' import { getWorkspaceStatus } from './workspace-status' import { makeWorkspaceStatusId } from '../../../../shared/workspace-statuses' -import type { Worktree } from '../../../../shared/worktree/types' +import { + ownerHostDefinesStatus, + selectAllKnownWorkspaceStatuses +} from '@/store/host-workspace-status-columns' +import type { WorkspaceStatusDefinition, Worktree } from '../../../../shared/worktree/types' type WorkspaceStatuses = ReturnType['workspaceStatuses'] export function useWorkspaceKanbanStatusActions(args: { allWorktrees: readonly Worktree[] workspaceStatuses: WorkspaceStatuses + /** Local plus paired hosts' columns: cards resolve and new ids stay unique against these. */ + boardStatuses: readonly WorkspaceStatusDefinition[] setWorkspaceStatuses: (statuses: WorkspaceStatuses) => void updateWorktreeMeta: ReturnType['updateWorktreeMeta'] }) { @@ -71,7 +77,10 @@ export function useWorkspaceKanbanStatusActions(args: { const label = `Status ${args.workspaceStatuses.length + 1}` args.setWorkspaceStatuses([ ...args.workspaceStatuses, - { id: makeWorkspaceStatusId(label, args.workspaceStatuses), label } + { + id: makeWorkspaceStatusId(label, selectAllKnownWorkspaceStatuses(useAppStore.getState())), + label + } ]) recordInteraction() }, [args]) @@ -88,8 +97,13 @@ export function useWorkspaceKanbanStatusActions(args: { const fallbackStatus = next[Math.min(index, next.length - 1)]?.id ?? next[0]!.id args.setWorkspaceStatuses(next) recordInteraction() + const state = useAppStore.getState() for (const worktree of args.allWorktrees) { - if (getWorkspaceStatus(worktree, args.workspaceStatuses) === statusId) { + // A host that still defines this id owns the card's column; rewriting it would change host data. + if (ownerHostDefinesStatus(state, worktree, statusId)) { + continue + } + if (getWorkspaceStatus(worktree, args.boardStatuses) === statusId) { void args.updateWorktreeMeta( worktree.id, { workspaceStatus: fallbackStatus }, diff --git a/src/renderer/src/components/sidebar/use-workspace-kanban-worktree-actions.ts b/src/renderer/src/components/sidebar/use-workspace-kanban-worktree-actions.ts index b0efb012b8d..0230f4afe5d 100644 --- a/src/renderer/src/components/sidebar/use-workspace-kanban-worktree-actions.ts +++ b/src/renderer/src/components/sidebar/use-workspace-kanban-worktree-actions.ts @@ -9,9 +9,14 @@ import { type WorktreeDragGroup } from './worktree-manual-order' import type { WorktreeMeta } from '../../../../shared/worktree/meta-types' -import type { WorkspaceStatus, Worktree } from '../../../../shared/worktree/types' +import type { + WorkspaceStatus, + WorkspaceStatusDefinition, + Worktree +} from '../../../../shared/worktree/types' import type { WorktreeMetaBatchUpdate } from '../../store/slices/worktree-helpers' import type { WorktreeManualOrderCatalog } from './worktree-manual-order-catalog' +import { isWorkspaceStatusAllowedOnHost } from '@/store/host-workspace-status-columns' type LaneView = { items: readonly Worktree[] } @@ -23,7 +28,7 @@ export function useWorkspaceKanbanWorktreeActions(args: { sortBy: ReturnType['sortBy'] updateWorktreeMeta: ReturnType['updateWorktreeMeta'] updateWorktreesMeta: ReturnType['updateWorktreesMeta'] - workspaceStatuses: ReturnType['workspaceStatuses'] + workspaceStatuses: readonly WorkspaceStatusDefinition[] worktreeById: ReadonlyMap manualOrderCatalog: WorktreeManualOrderCatalog worktreesByStatus: ReadonlyMap @@ -90,12 +95,22 @@ export function useWorkspaceKanbanWorktreeActions(args: { [args] ) const dropWorktreesInStatus = useCallback( - (drop: { + (requestedDrop: { worktreeIds: readonly string[] status: WorkspaceStatus dropIndex: number writeManualOrder?: boolean }) => { + // Cards that can't take this status (another paired host's column) aren't re-ranked either. + const state = useAppStore.getState() + const worktreeIds = requestedDrop.worktreeIds.filter((worktreeId) => { + const current = args.worktreeById.get(worktreeId) + return current && isWorkspaceStatusAllowedOnHost(state, current, requestedDrop.status) + }) + if (worktreeIds.length === 0) { + return + } + const drop = { ...requestedDrop, worktreeIds } const updates: WorktreeMetaBatchUpdate[] = [] const writeManualOrder = drop.writeManualOrder ?? shouldWriteDropManualOrder(drop.worktreeIds, drop.status) diff --git a/src/renderer/src/components/sidebar/use-worktree-context-menu-commands.ts b/src/renderer/src/components/sidebar/use-worktree-context-menu-commands.ts index 6fb719ba0eb..20b3d14c087 100644 --- a/src/renderer/src/components/sidebar/use-worktree-context-menu-commands.ts +++ b/src/renderer/src/components/sidebar/use-worktree-context-menu-commands.ts @@ -41,7 +41,7 @@ export function useWorktreeContextMenuCommands(args: { updateWorktreeMeta: ReturnType['updateWorktreeMeta'] validParentWorktreeId: string | null worktree: Worktree - workspaceStatuses: readonly WorkspaceStatusDefinition[] + statusesForWorktree: (worktree: Worktree) => readonly WorkspaceStatusDefinition[] }) { const handleCopyPath = useCallback(() => { window.api.ui.writeClipboardText(args.worktree.path) @@ -105,7 +105,7 @@ export function useWorktreeContextMenuCommands(args: { const plan = planWorkspaceStatusAssignment( args.activeContextWorktrees, status, - args.workspaceStatuses, + args.statusesForWorktree, Boolean(args.onAssignWorkspaceStatus) ) if (plan.kind === 'board-sync') { diff --git a/src/renderer/src/components/sidebar/use-worktree-context-menu-model.tsx b/src/renderer/src/components/sidebar/use-worktree-context-menu-model.tsx index 6b511a975fc..15be27bfee8 100644 --- a/src/renderer/src/components/sidebar/use-worktree-context-menu-model.tsx +++ b/src/renderer/src/components/sidebar/use-worktree-context-menu-model.tsx @@ -6,7 +6,7 @@ import { getCyclicProjectedWorktreeLineageIds, getLineageRenderInfo } from '../../../../shared/sidebar-list/worktree-lineage-projection' -import { getWorkspaceStatus } from './workspace-status' +import { useContextWorkspaceStatuses } from './use-workspace-board-statuses' import { getEligibleWorktreeParents } from './worktree-parent-candidates' import { hasSleepableWorkspaceActivity, @@ -25,6 +25,7 @@ import { EMPTY_UNIFIED_TABS_BY_WORKTREE, EMPTY_WORKSPACE_LINEAGE_BY_CHILD_KEY, EMPTY_WORKTREE_LINEAGE_BY_ID, + getSharedWorkspaceStatus, hasWorktreeParentLink, isContextWorktreeDeletable, selectMenuScopedMap, @@ -61,7 +62,6 @@ export function useWorktreeContextMenuModel({ const updateWorktreeMeta = useAppStore((s) => s.updateWorktreeMeta) const setWorkspaceUnreadByUser = useAppStore((s) => s.setWorkspaceUnreadByUser) const setWorktreesPinnedAndReveal = useAppStore((s) => s.setWorktreesPinnedAndReveal) - const workspaceStatuses = useAppStore((s) => s.workspaceStatuses) const openModal = useAppStore((s) => s.openModal) const projectGroups = useAppStore((s) => s.projectGroups) const createProjectGroup = useAppStore((s) => s.createProjectGroup) @@ -131,6 +131,8 @@ export function useWorktreeContextMenuModel({ const scopeRef = useRef(null) const contextMenuOpenedAtRef = useRef(null) const activeContextWorktrees = menuOpen ? contextWorktrees : effectiveSelectedWorktrees + const { workspaceStatuses, statusesForWorktree } = + useContextWorkspaceStatuses(activeContextWorktrees) const isMultiContext = activeContextWorktrees.length > 1 const workspaceScope = parseWorkspaceKey(worktree.id) const folderWorkspaceId = @@ -180,16 +182,10 @@ export function useWorktreeContextMenuModel({ (item) => getDeleteStateForWorktreeHost(item, deleteStateByWorktreeId)?.isDeleting ) const contextDeletePending = isMultiContext ? deletingContext : deletingSubtree - const contextWorkspaceStatus = useMemo(() => { - const [first, ...rest] = activeContextWorktrees - if (!first) { - return '' - } - const status = getWorkspaceStatus(first, workspaceStatuses) - return rest.every((item) => getWorkspaceStatus(item, workspaceStatuses) === status) - ? status - : '' - }, [activeContextWorktrees, workspaceStatuses]) + const contextWorkspaceStatus = useMemo( + () => getSharedWorkspaceStatus(activeContextWorktrees, statusesForWorktree), + [activeContextWorktrees, statusesForWorktree] + ) const batchDeleteWorktrees = useMemo( () => activeContextWorktrees.filter((item) => { @@ -319,10 +315,10 @@ export function useWorktreeContextMenuModel({ setWorktreesPinnedAndReveal, sleepableWorktrees, subtreeSleepableWorktrees, + statusesForWorktree, updateWorktreeMeta, validParentWorktreeId, - worktree, - workspaceStatuses + worktree }) const { handleOpenParentPicker, handleParentPickerOpenChange, openPendingParentPicker } = useWorktreeParentPickerTransition({ diff --git a/src/renderer/src/components/sidebar/workspace-board-host-column-drops.test.tsx b/src/renderer/src/components/sidebar/workspace-board-host-column-drops.test.tsx new file mode 100644 index 00000000000..d2c46bc4e10 --- /dev/null +++ b/src/renderer/src/components/sidebar/workspace-board-host-column-drops.test.tsx @@ -0,0 +1,86 @@ +// @vitest-environment happy-dom + +import { cleanup, renderHook } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { useAppStore } from '@/store' +import type { AppState } from '@/store/types' +import { makeWorktree } from '@/store/slices/worktrees-slice-test-fixtures' +import { toRuntimeExecutionHostId } from '../../../../shared/execution-host' +import { useWorkspaceKanbanWorktreeActions } from './use-workspace-kanban-worktree-actions' +import { useWorktreeStatusMutations } from './worktree-list/drag/use-status-mutations' + +const local = makeWorktree({ id: 'repo::/local', repoId: 'repo', path: '/local' }) +const hosted = makeWorktree({ + id: 'repo::/hosted', + repoId: 'repo', + path: '/hosted', + hostId: toRuntimeExecutionHostId('env-1') +}) +const catalog = { orderedIds: [local.id, hosted.id], rankByWorktreeId: new Map() } + +let updateWorktreesMeta: ReturnType> + +beforeEach(() => { + updateWorktreesMeta = vi.fn(() => Promise.resolve()) + useAppStore.setState({ + worktreesByRepo: { repo: [local, hosted] }, + folderWorkspaces: [], + workspaceStatuses: [{ id: 'in-progress', label: 'In progress' }], + hostWorkspaceStatuses: { 'env-1': [{ id: 'status-6', label: 'Today' }] }, + removedRuntimeEnvironmentIds: new Set(), + updateWorktreesMeta, + recordFeatureInteraction: vi.fn() + }) +}) + +afterEach(cleanup) + +describe("drops into a paired host's own column", () => { + it('board: moves only the host card and leaves the local card unranked', () => { + const { result } = renderHook(() => + useWorkspaceKanbanWorktreeActions({ + boardDragGroups: [], + laneFullWorktreeIds: new Map(), + laneViews: new Map(), + maybeSyncTaskStatuses: vi.fn(), + sortBy: 'manual', + updateWorktreeMeta: vi.fn(), + updateWorktreesMeta, + workspaceStatuses: [{ id: 'in-progress', label: 'In progress' }], + worktreeById: new Map([ + [local.id, local], + [hosted.id, hosted] + ]), + manualOrderCatalog: catalog, + worktreesByStatus: new Map() + }) + ) + + result.current.dropWorktreesAtEndOfStatus([local.id], 'status-6') + expect(updateWorktreesMeta).not.toHaveBeenCalled() + + result.current.dropWorktreesAtEndOfStatus([local.id, hosted.id], 'status-6') + expect(updateWorktreesMeta.mock.calls[0]?.[0].map((entry) => entry.worktreeId)).toEqual([ + hosted.id + ]) + }) + + it('sidebar: ignores a local card dropped into the host-only group', () => { + const { result } = renderHook(() => + useWorktreeStatusMutations({ + manualOrderCatalog: catalog, + workspaceStatuses: [{ id: 'in-progress', label: 'In progress' }], + sortBy: 'manual' + }) + ) + + result.current.moveWorktreesToStatusAtIndex({ + worktreeIds: [local.id], + status: 'status-6', + dropIndex: 0, + groups: [] + }) + + expect(updateWorktreesMeta).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/components/sidebar/worktree-context-menu-policy.ts b/src/renderer/src/components/sidebar/worktree-context-menu-policy.ts index bc5f364eb7b..1e017ecc1b9 100644 --- a/src/renderer/src/components/sidebar/worktree-context-menu-policy.ts +++ b/src/renderer/src/components/sidebar/worktree-context-menu-policy.ts @@ -214,6 +214,19 @@ export function preserveDeleteSiblingPosition(scope: HTMLElement | null): () => } } +/** The status every context worktree shares, or '' when they differ or there are none. */ +export function getSharedWorkspaceStatus( + worktrees: readonly Worktree[], + statusesFor: (worktree: Worktree) => readonly WorkspaceStatusDefinition[] +): WorkspaceStatus { + const [first, ...rest] = worktrees + if (!first) { + return '' + } + const status = getWorkspaceStatus(first, statusesFor(first)) + return rest.every((item) => getWorkspaceStatus(item, statusesFor(item)) === status) ? status : '' +} + export type WorkspaceStatusAssignmentPlan = | { readonly kind: 'board-sync'; readonly worktreeIds: readonly string[] } | { readonly kind: 'local-only'; readonly localWriteIds: readonly string[] } @@ -222,14 +235,14 @@ export type WorkspaceStatusAssignmentPlan = export function planWorkspaceStatusAssignment( worktrees: readonly Worktree[], status: WorkspaceStatus, - workspaceStatuses: readonly WorkspaceStatusDefinition[], + statusesFor: (worktree: Worktree) => readonly WorkspaceStatusDefinition[], boardSyncEnabled: boolean ): WorkspaceStatusAssignmentPlan { if (boardSyncEnabled) { return { kind: 'board-sync', worktreeIds: worktrees.map((item) => item.id) } } const localWriteIds = worktrees - .filter((item) => getWorkspaceStatus(item, workspaceStatuses) !== status) + .filter((item) => getWorkspaceStatus(item, statusesFor(item)) !== status) .map((item) => item.id) return { kind: 'local-only', localWriteIds } } diff --git a/src/renderer/src/components/sidebar/worktree-list/drag/use-status-mutations.ts b/src/renderer/src/components/sidebar/worktree-list/drag/use-status-mutations.ts index fc2787d73bb..78b13cdbddc 100644 --- a/src/renderer/src/components/sidebar/worktree-list/drag/use-status-mutations.ts +++ b/src/renderer/src/components/sidebar/worktree-list/drag/use-status-mutations.ts @@ -13,6 +13,7 @@ import { import { buildWorkspaceKanbanSidebarDropUpdates } from '../../workspace-kanban-sidebar-drop' import type { SortBy } from '../../../../../../shared/sidebar-list/smart-sort' import { switchSortToManualAfterDrop } from '../../manual-sort-switch-toast' +import { isWorkspaceStatusAllowedOnHost } from '@/store/host-workspace-status-columns' import type { WorktreeReorderArgs, WorktreeStatusDropAtIndexArgs } from './drop-commit-context' import type { WorktreeManualOrderCatalog } from '../../worktree-manual-order-catalog' import { @@ -21,6 +22,18 @@ import { type DraggedWorktreeCopies } from './dragged-worktree-copies' +/** Drops cards that can't take this status (another paired host's column), so they aren't re-ranked either. */ +function idsAcceptingStatus( + rowsFor: ReturnType, + worktreeIds: readonly string[], + status: WorkspaceStatus +): readonly string[] { + const state = useAppStore.getState() + return worktreeIds.filter((worktreeId) => + rowsFor.status(worktreeId).some((row) => isWorkspaceStatusAllowedOnHost(state, row, status)) + ) +} + // Route every status/rank write through dropWriteRows because IDs repeat across hosts. export function useWorktreeStatusMutations(args: { manualOrderCatalog: WorktreeManualOrderCatalog @@ -68,7 +81,17 @@ export function useWorktreeStatusMutations(args: { ) const moveWorktreesToStatusAtIndex = useCallback( - (dropArgs: WorktreeStatusDropAtIndexArgs) => { + (requestedDrop: WorktreeStatusDropAtIndexArgs) => { + const rowsFor = dropWriteRows(useAppStore.getState(), requestedDrop.draggedCopies) + const worktreeIds = idsAcceptingStatus( + rowsFor, + requestedDrop.worktreeIds, + requestedDrop.status + ) + if (worktreeIds.length === 0) { + return + } + const dropArgs = { ...requestedDrop, worktreeIds } const order = buildManualOrderUpdatesForGroupDrop({ groups: dropArgs.groups, targetGroupKey: getWorkspaceStatusGroupKey(dropArgs.status), @@ -80,7 +103,7 @@ export function useWorktreeStatusMutations(args: { }) const dragged = new Set(dropArgs.worktreeIds) const updates = collectDropWrites({ - rowsFor: dropWriteRows(useAppStore.getState(), dropArgs.draggedCopies), + rowsFor, statusIds: dropArgs.worktreeIds, status: dropArgs.status, workspaceStatuses, @@ -157,9 +180,15 @@ export function useWorktreeStatusMutations(args: { const dropWorktreesOnWorkspaceBoard = useCallback( (dropArgs: WorktreeStatusDropAtIndexArgs) => { + const rowsFor = dropWriteRows(useAppStore.getState(), dropArgs.draggedCopies) + const worktreeIds = idsAcceptingStatus(rowsFor, dropArgs.worktreeIds, dropArgs.status) + if (worktreeIds.length === 0) { + return + } const result = buildWorkspaceKanbanSidebarDropUpdates({ ...dropArgs, - rowsFor: dropWriteRows(useAppStore.getState(), dropArgs.draggedCopies), + worktreeIds, + rowsFor, workspaceStatuses, sortBy, now: Date.now(), diff --git a/src/renderer/src/hooks/composer-state/composer-host-workspace-status.test.tsx b/src/renderer/src/hooks/composer-state/composer-host-workspace-status.test.tsx new file mode 100644 index 00000000000..8606cb12277 --- /dev/null +++ b/src/renderer/src/hooks/composer-state/composer-host-workspace-status.test.tsx @@ -0,0 +1,33 @@ +// @vitest-environment happy-dom + +import { cleanup, renderHook } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { useAppStore } from '@/store' +import * as decisions from './composer-decisions' +import { useComposerTargetStore } from './composer-target-store' +import { useComposerInitialTargetState } from './initial-target-state' + +beforeEach(() => { + useAppStore.setState({ + workspaceStatuses: [{ id: 'in-progress', label: 'In progress' }], + hostWorkspaceStatuses: { 'env-1': [{ id: 'status-6', label: 'Today' }] }, + removedRuntimeEnvironmentIds: new Set() + }) +}) + +afterEach(cleanup) + +describe('composer initial workspace status', () => { + it("keeps a paired host's own column from that host's board lane", () => { + const { result } = renderHook(() => + useComposerInitialTargetState( + useComposerTargetStore( + { persistDraft: false, initialWorkspaceStatus: 'status-6' }, + decisions + ) + ) + ) + + expect(result.current.resolvedInitialWorkspaceStatus).toBe('status-6') + }) +}) diff --git a/src/renderer/src/hooks/composer-state/composer-target-store.ts b/src/renderer/src/hooks/composer-state/composer-target-store.ts index 60c28e2d6c5..8782bf86697 100644 --- a/src/renderer/src/hooks/composer-state/composer-target-store.ts +++ b/src/renderer/src/hooks/composer-state/composer-target-store.ts @@ -19,6 +19,7 @@ import type { LinkedWorkItemSummary } from '@/lib/new-workspace' import type { ComposerDecisions } from './composer-decisions' import type { WorkspaceCreationTargetHint } from '@/lib/project-host-workspace-target' import { selectWorktreesByRepo } from '@/store/workspace-record-selectors' +import { selectAllKnownWorkspaceStatuses } from '@/store/host-workspace-status-columns' export type ComposerStateInput = { initialRepoId?: string @@ -113,7 +114,8 @@ export function useComposerTargetStore(options: ComposerStateInput, decisions: C const sparsePresetsByRepo = useAppStore((s) => s.sparsePresetsByRepo) - const workspaceStatuses = useAppStore((s) => s.workspaceStatuses) + // Every paired host's columns too, so a host-only lane's + keeps its status; submit scopes it to the target. + const workspaceStatuses = useAppStore(useShallow(selectAllKnownWorkspaceStatuses)) const sshConnectionStates = useAppStore((s) => s.sshConnectionStates) diff --git a/src/renderer/src/hooks/composer-state/quick-creation-execution.ts b/src/renderer/src/hooks/composer-state/quick-creation-execution.ts index b66ddfe53ed..b20456cdf8e 100644 --- a/src/renderer/src/hooks/composer-state/quick-creation-execution.ts +++ b/src/renderer/src/hooks/composer-state/quick-creation-execution.ts @@ -36,6 +36,7 @@ import { useCallback } from 'react' import type { TuiAgent } from '../../../../shared/tui-agent' import type { WorktreeCreationRequest } from '@/lib/pending-worktree-creation' import { useAppStore } from '@/store' +import { isWorkspaceStatusAllowedOnHost } from '@/store/host-workspace-status-columns' import { settleComposerSubmit } from '@/lib/composer-submit-cancellation' import { runBackgroundWorktreeCreation } from '@/lib/worktree-creation-flow' import type { ComposerCreateHandoff } from '@/lib/composer-create-handoff' @@ -224,7 +225,20 @@ export function useQuickCreationExecution(input: QuickCreationExecutionInput) { branchNameOverride: effectiveBranchNameOverride, branchIntent, parentWorktreeId, - workspaceStatus: resolvedInitialWorkspaceStatus, + // A lane's host-only column applies only when creating on that host. + workspaceStatus: + resolvedInitialWorkspaceStatus && + isWorkspaceStatusAllowedOnHost( + useAppStore.getState(), + { + hostId: workspaceRunContext?.hostId ?? selectedRepoExecutionHostId, + runtimeOwnerEnvironmentId: + selectedRepoTarget?.kind === 'environment' ? selectedRepoTarget.environmentId : null + }, + resolvedInitialWorkspaceStatus + ) + ? resolvedInitialWorkspaceStatus + : undefined, linkedGitLabMR, linkedGitLabIssue, includeGitLabLinks: smartGitHubResolution.kind === 'none', diff --git a/src/renderer/src/hooks/composer-state/target-store-model.ts b/src/renderer/src/hooks/composer-state/target-store-model.ts index 17f96ab69e6..14506eac73b 100644 --- a/src/renderer/src/hooks/composer-state/target-store-model.ts +++ b/src/renderer/src/hooks/composer-state/target-store-model.ts @@ -64,7 +64,7 @@ export type ComposerTargetStoreModel = { newWorkspaceDraft: UISlice['newWorkspaceDraft'] worktreesByRepo: Record sparsePresetsByRepo: Record - workspaceStatuses: WorkspaceStatusDefinition[] + workspaceStatuses: readonly WorkspaceStatusDefinition[] sshConnectionStates: Map sshTargetLabels: Map sshConnectedGeneration: number diff --git a/src/renderer/src/hooks/ipc-events-harness-store-state.ts b/src/renderer/src/hooks/ipc-events-harness-store-state.ts index 41caa04709f..f5e6b05562a 100644 --- a/src/renderer/src/hooks/ipc-events-harness-store-state.ts +++ b/src/renderer/src/hooks/ipc-events-harness-store-state.ts @@ -38,6 +38,7 @@ export function createHarnessStoreState( setUpdateStatus: vi.fn(), fetchRepos: vi.fn(), fetchWorktrees: vi.fn(), + fetchHostWorkspaceStatuses: vi.fn(), closeModal: vi.fn(), openModal: vi.fn(), setActiveRepo: vi.fn(), diff --git a/src/renderer/src/hooks/ipc-events/runtime-client-ipc-bridge.ts b/src/renderer/src/hooks/ipc-events/runtime-client-ipc-bridge.ts index 1029612464e..1369694c4b5 100644 --- a/src/renderer/src/hooks/ipc-events/runtime-client-ipc-bridge.ts +++ b/src/renderer/src/hooks/ipc-events/runtime-client-ipc-bridge.ts @@ -64,6 +64,8 @@ export function registerRuntimeClientIpcBridge( refresh: async (environmentId) => { // Why: project events can reveal target CRUD, but known target states already arrive by push. void refreshRuntimeEnvironmentSshTargetMetadata(environmentId).catch(() => {}) + // Why: hosts without workspaceViewChanged still refresh their columns here. + void useAppStore.getState().fetchHostWorkspaceStatuses(environmentId) const repos = await useAppStore.getState().fetchRuntimeEnvironmentRepos(environmentId) // Group/folder edits share reposChanged; refresh groups before folders to resolve their owners. const runtimeOwner = { runtimeEnvironmentId: environmentId } @@ -110,8 +112,9 @@ export function registerRuntimeClientIpcBridge( applyNativeChatLaunchDraftResolved(useAppStore.getState(), event) return } - // A paired desktop keeps its own Workspace options; only the host's phone mirrors them. + // A paired desktop keeps its own Workspace options; it reads only the host's columns, for the host's cards. if (event.type === 'workspaceViewChanged') { + void useAppStore.getState().fetchHostWorkspaceStatuses(environmentId) return } if (event.type === 'reposChanged') { @@ -222,6 +225,7 @@ export function registerRuntimeClientIpcBridge( const sshGeneration = getEnvironmentSshStateGeneration(environmentId) const runtimeGeneration = getRuntimeEnvironmentConnectionGeneration(environmentId) const runtimeRevision = getRuntimeEnvironmentRevision(environmentId) + void useAppStore.getState().fetchHostWorkspaceStatuses(environmentId) const subscription = subscribeRuntimeClientEvents( environmentId, (event) => { diff --git a/src/renderer/src/hooks/useIpcEvents-cli-worktree-activation.test.ts b/src/renderer/src/hooks/useIpcEvents-cli-worktree-activation.test.ts index 7e2f4434858..3b5abc37f68 100644 --- a/src/renderer/src/hooks/useIpcEvents-cli-worktree-activation.test.ts +++ b/src/renderer/src/hooks/useIpcEvents-cli-worktree-activation.test.ts @@ -297,6 +297,7 @@ describe('useIpcEvents CLI-created worktree activation', () => { fetchRepos: vi.fn(), fetchRuntimeEnvironmentRepos: vi.fn(), fetchProjectGroups: vi.fn(), + fetchHostWorkspaceStatuses: vi.fn(), fetchWorktrees, fetchWorktreeLineage, repos: [{ id: 'repo-1' }], diff --git a/src/renderer/src/hooks/useIpcEvents-repos-changed-catalogs.test.ts b/src/renderer/src/hooks/useIpcEvents-repos-changed-catalogs.test.ts index b9639670a1e..ab38b219268 100644 --- a/src/renderer/src/hooks/useIpcEvents-repos-changed-catalogs.test.ts +++ b/src/renderer/src/hooks/useIpcEvents-repos-changed-catalogs.test.ts @@ -35,6 +35,7 @@ describe('runtime host catalog refresh on reposChanged', () => { fetchRepos: vi.fn(() => Promise.resolve()), fetchRuntimeEnvironmentRepos, fetchProjectGroups, + fetchHostWorkspaceStatuses: vi.fn(), fetchFolderWorkspaces, fetchWorktrees: vi.fn(() => Promise.resolve()), fetchWorktreeLineage: vi.fn(() => Promise.resolve()) @@ -128,6 +129,7 @@ describe('runtime host catalog refresh on reposChanged', () => { fetchRepos: vi.fn(() => Promise.resolve()), fetchRuntimeEnvironmentRepos, fetchProjectGroups, + fetchHostWorkspaceStatuses: vi.fn(), fetchFolderWorkspaces, fetchWorktrees, fetchWorktreeLineage @@ -193,6 +195,7 @@ describe('repo catalog refresh on repos:changed', () => { remountTerminalTabForRecovery, fetchRepos, fetchProjectGroups, + fetchHostWorkspaceStatuses: vi.fn(), fetchFolderWorkspaces } diff --git a/src/renderer/src/runtime/runtime-client-events.test.ts b/src/renderer/src/runtime/runtime-client-events.test.ts index f95b8c1dda0..11d05256351 100644 --- a/src/renderer/src/runtime/runtime-client-events.test.ts +++ b/src/renderer/src/runtime/runtime-client-events.test.ts @@ -95,6 +95,18 @@ describe('subscribeRuntimeClientEvents', () => { expect(onError).not.toHaveBeenCalled() }) + it("forwards a host's workspaceViewChanged so its board columns refresh", async () => { + const { subscribe, respond } = recordRuntimeSubscription() + const onEvent = vi.fn() + stubRuntimeEnvironmentBridge({ subscribe }) + + await subscribeRuntimeClientEvents('env-1', onEvent, vi.fn()) + + respond({ ok: true, result: { type: 'workspaceViewChanged' } }) + + expect(onEvent).toHaveBeenCalledWith({ type: 'workspaceViewChanged' }) + }) + it('signals a replay-tagged response so event-derived state can resync after a reconnect', async () => { const { subscribe, respond } = recordRuntimeSubscription() const onEvent = vi.fn() diff --git a/src/renderer/src/runtime/runtime-client-events.ts b/src/renderer/src/runtime/runtime-client-events.ts index 66486c8e436..631a5283e82 100644 --- a/src/renderer/src/runtime/runtime-client-events.ts +++ b/src/renderer/src/runtime/runtime-client-events.ts @@ -91,6 +91,7 @@ function isRuntimeClientEvent( message.type === 'browserProfilesChanged' || message.type === 'linearLinkedIssueUpdated' || message.type === 'activateWorktree' || - message.type === 'worktreeTerminalSleepState' + message.type === 'worktreeTerminalSleepState' || + message.type === 'workspaceViewChanged' ) } diff --git a/src/renderer/src/store/host-workspace-status-columns.test.ts b/src/renderer/src/store/host-workspace-status-columns.test.ts new file mode 100644 index 00000000000..677262b9f81 --- /dev/null +++ b/src/renderer/src/store/host-workspace-status-columns.test.ts @@ -0,0 +1,101 @@ +import { describe, expect, it } from 'vitest' +import { getWorkspaceStatus } from '../../../shared/workspace-statuses' +import { makeWorktree } from './slices/worktrees-slice-test-fixtures' +import { + isWorkspaceStatusAllowedOnHost, + selectWorkspaceBoardStatuses, + selectWorkspaceStatusesForHost +} from './host-workspace-status-columns' + +const local = [{ id: 'todo', label: 'Todo' }] +const hostWorkspaceStatuses = { + 'env-1': [ + { id: 'in-progress', label: 'Host in progress' }, + { id: 'status-6', label: 'Today' }, + { id: 'status-7', label: 'Unused' } + ] +} + +function card(id: string, workspaceStatus: string, hostId?: 'runtime:env-1') { + return makeWorktree({ id, repoId: 'repo', hostId, workspaceStatus }) +} + +describe('selectWorkspaceBoardStatuses', () => { + it("adds a host's column only when that host's own card sits in it", () => { + const lanes = selectWorkspaceBoardStatuses({ + workspaceStatuses: local, + hostWorkspaceStatuses, + worktreesByRepo: { + repo: [card('host', 'status-6', 'runtime:env-1'), card('local', 'status-7')] + }, + folderWorkspaces: [] + }) + + expect(lanes.map((status) => status.id)).toEqual(['todo', 'status-6']) + }) + + it("adds a host's column for its SSH workspace, which the host's runtime owns", () => { + const sshCard = makeWorktree({ + id: 'ssh', + repoId: 'repo', + hostId: 'ssh:builder', + runtimeOwnerEnvironmentId: 'env-1', + workspaceStatus: 'status-6' + }) + const lanes = selectWorkspaceBoardStatuses({ + workspaceStatuses: local, + hostWorkspaceStatuses, + worktreesByRepo: { repo: [sshCard] }, + folderWorkspaces: [] + }) + + expect(lanes.map((status) => status.id)).toEqual(['todo', 'status-6']) + }) + + it("keeps a local card's fallback on the local list when a host card is in the host's default column", () => { + const unassigned = makeWorktree({ id: 'local', repoId: 'repo' }) + const lanes = selectWorkspaceBoardStatuses({ + workspaceStatuses: local, + hostWorkspaceStatuses, + worktreesByRepo: { repo: [card('host', 'in-progress', 'runtime:env-1'), unassigned] }, + folderWorkspaces: [] + }) + + expect(getWorkspaceStatus(unassigned, lanes)).toBe('todo') + }) +}) + +describe('isWorkspaceStatusAllowedOnHost', () => { + const state = { workspaceStatuses: local, hostWorkspaceStatuses } + + it("lets a card take local columns and its own host's columns only", () => { + expect(isWorkspaceStatusAllowedOnHost(state, { hostId: 'runtime:env-1' }, 'status-6')).toBe( + true + ) + expect(isWorkspaceStatusAllowedOnHost(state, { hostId: 'local' }, 'status-6')).toBe(false) + expect(isWorkspaceStatusAllowedOnHost(state, { hostId: 'runtime:env-2' }, 'status-6')).toBe( + false + ) + expect(isWorkspaceStatusAllowedOnHost(state, { hostId: 'local' }, 'todo')).toBe(true) + expect(isWorkspaceStatusAllowedOnHost(state, { hostId: 'local' }, 'legacy-id')).toBe(true) + // A paired host's SSH workspace keeps its owning runtime's columns. + expect( + isWorkspaceStatusAllowedOnHost( + state, + { hostId: 'ssh:builder', runtimeOwnerEnvironmentId: 'env-1' }, + 'status-6' + ) + ).toBe(true) + }) +}) + +describe('selectWorkspaceStatusesForHost', () => { + it("offers a host card its host's columns but not the host's copy of the default, matching the board", () => { + const state = { workspaceStatuses: local, hostWorkspaceStatuses } + + expect( + selectWorkspaceStatusesForHost(state, { hostId: 'runtime:env-1' }).map((status) => status.id) + ).toEqual(['todo', 'status-6', 'status-7']) + expect(selectWorkspaceStatusesForHost(state, { hostId: 'local' })).toBe(local) + }) +}) diff --git a/src/renderer/src/store/host-workspace-status-columns.ts b/src/renderer/src/store/host-workspace-status-columns.ts new file mode 100644 index 00000000000..ba5cbe8264f --- /dev/null +++ b/src/renderer/src/store/host-workspace-status-columns.ts @@ -0,0 +1,181 @@ +import type { AppState } from './types' +import { + DEFAULT_WORKSPACE_STATUS_ID, + mergeHostWorkspaceStatuses +} from '../../../shared/workspace-statuses' +import { parseExecutionHostId } from '../../../shared/execution-host' +import type { WorkspaceStatus, WorkspaceStatusDefinition } from '../../../shared/worktree/types' +import { getIndexedAllWorktrees } from './worktree-repo-index' +import { selectFolderWorkspaces, selectWorktreesByRepo } from './workspace-record-selectors' + +export type HostStatusColumnInputs = Pick & + Partial> + +type BoardCardInputs = HostStatusColumnInputs & + Pick + +/** A card or creation target: its execution host, plus the paired runtime that owns it when that host is SSH. */ +export type StatusColumnOwner = { + hostId?: string | null + runtimeOwnerEnvironmentId?: string | null +} + +function ownerEnvironmentId(owner: StatusColumnOwner | null | undefined): string | null { + const explicit = owner?.runtimeOwnerEnvironmentId?.trim() + if (explicit) { + return explicit + } + const host = parseExecutionHostId(owner?.hostId) + return host?.kind === 'runtime' ? host.environmentId : null +} + +function liveHostStatuses( + state: HostStatusColumnInputs, + owner: StatusColumnOwner | null | undefined +): readonly WorkspaceStatusDefinition[] | undefined { + const environmentId = ownerEnvironmentId(owner) + if (!environmentId || state.removedRuntimeEnvironmentIds?.has(environmentId)) { + return undefined + } + return state.hostWorkspaceStatuses?.[environmentId] +} + +/** + * A host's columns the local list lacks, minus its copy of the default id: local cards fall back + * to that id, so a host's copy must not move them. + */ +function hostOnlyColumns( + state: HostStatusColumnInputs, + hostStatuses: readonly WorkspaceStatusDefinition[] +): readonly WorkspaceStatusDefinition[] { + return hostStatuses.filter( + (status) => + status.id !== DEFAULT_WORKSPACE_STATUS_ID && + !state.workspaceStatuses.some((local) => local.id === status.id) + ) +} + +/** The columns offered for a card on this host: the local ones plus, for a paired runtime's card, its own. */ +export function selectWorkspaceStatusesForHost( + state: HostStatusColumnInputs, + owner: StatusColumnOwner | null | undefined +): readonly WorkspaceStatusDefinition[] { + const hostStatuses = liveHostStatuses(state, owner) + const extra = hostStatuses ? hostOnlyColumns(state, hostStatuses) : [] + return extra.length > 0 ? [...state.workspaceStatuses, ...extra] : state.workspaceStatuses +} + +/** Every column a card can be offered: local ones plus each paired host's own, as menus offer them. */ +export function selectAllOfferedWorkspaceStatuses( + state: HostStatusColumnInputs +): readonly WorkspaceStatusDefinition[] { + const environmentIds = Object.keys(state.hostWorkspaceStatuses ?? {}).sort() + return mergeHostWorkspaceStatuses( + state.workspaceStatuses, + environmentIds.map((environmentId) => { + const hostStatuses = liveHostStatuses(state, { runtimeOwnerEnvironmentId: environmentId }) + return hostStatuses ? hostOnlyColumns(state, hostStatuses) : [] + }) + ) +} + +/** Every column id in use locally or on any paired host, so a new local column gets a fresh id. */ +export function selectAllKnownWorkspaceStatuses( + state: HostStatusColumnInputs +): readonly WorkspaceStatusDefinition[] { + return mergeHostWorkspaceStatuses( + state.workspaceStatuses, + Object.values(state.hostWorkspaceStatuses ?? {}) + ) +} + +/** The board's lanes: local columns, then the paired hosts' columns their own cards sit in. */ +export function selectWorkspaceBoardStatuses( + state: BoardCardInputs +): readonly WorkspaceStatusDefinition[] { + const local = state.workspaceStatuses + const candidates = new Map() + for (const environmentId of Object.keys(state.hostWorkspaceStatuses ?? {}).sort()) { + const hostStatuses = liveHostStatuses(state, { runtimeOwnerEnvironmentId: environmentId }) + const extra = hostStatuses ? hostOnlyColumns(state, hostStatuses) : [] + if (extra.length > 0) { + candidates.set(environmentId, extra) + } + } + // Why: most pairings share the local columns, so skip the card scan. + if (candidates.size === 0) { + return local + } + const usedByEnvironment = new Map>() + const noteCard = (owner: StatusColumnOwner, status: WorkspaceStatus | undefined) => { + const environmentId = ownerEnvironmentId(owner) + if (status && environmentId && candidates.has(environmentId)) { + const used = usedByEnvironment.get(environmentId) ?? new Set() + usedByEnvironment.set(environmentId, used.add(status)) + } + } + for (const worktree of getIndexedAllWorktrees(selectWorktreesByRepo(state))) { + noteCard(worktree, worktree.workspaceStatus) + } + for (const folder of selectFolderWorkspaces(state)) { + noteCard({ hostId: folder.executionHostId }, folder.workspaceStatus) + } + const extra: WorkspaceStatusDefinition[] = [] + const seen = new Set() + // Sorted above so lanes keep their order whichever host replied first. + for (const [environmentId, columns] of candidates) { + const used = usedByEnvironment.get(environmentId) + for (const status of columns) { + if (used?.has(status.id) && !seen.has(status.id)) { + seen.add(status.id) + extra.push(status) + } + } + } + return extra.length > 0 ? [...local, ...extra] : local +} + +/** False for another paired host's column: a card may only take its own host's columns or local ones. */ +export function isWorkspaceStatusAllowedOnHost( + state: HostStatusColumnInputs, + owner: StatusColumnOwner | null | undefined, + status: WorkspaceStatus +): boolean { + const pairedColumns = Object.values(state.hostWorkspaceStatuses ?? {}) + if ( + pairedColumns.length === 0 || + state.workspaceStatuses.some((column) => column.id === status) + ) { + return true + } + if (liveHostStatuses(state, owner)?.some((column) => column.id === status)) { + return true + } + // Why: ids no paired host defines keep their old behavior. + return !pairedColumns.some((columns) => columns.some((column) => column.id === status)) +} + +/** Drops a status write that would put a card in another paired host's column; other fields still apply. */ +export function withoutForeignHostStatus( + state: HostStatusColumnInputs, + owner: StatusColumnOwner | null | undefined, + updates: T +): Omit & Pick, 'workspaceStatus'> { + if ( + !updates.workspaceStatus || + isWorkspaceStatusAllowedOnHost(state, owner, updates.workspaceStatus) + ) { + return updates + } + const { workspaceStatus: _dropped, ...rest } = updates + return rest +} + +/** Whether the card's own paired runtime defines this column id. */ +export function ownerHostDefinesStatus( + state: HostStatusColumnInputs, + owner: StatusColumnOwner | null | undefined, + status: WorkspaceStatus +): boolean { + return liveHostStatuses(state, owner)?.some((column) => column.id === status) ?? false +} diff --git a/src/renderer/src/store/slices/runtime-status-types.ts b/src/renderer/src/store/slices/runtime-status-types.ts index 6bfb268b15b..f1bc867fda5 100644 --- a/src/renderer/src/store/slices/runtime-status-types.ts +++ b/src/renderer/src/store/slices/runtime-status-types.ts @@ -43,6 +43,7 @@ export type RuntimeStatusStoreState = RuntimeStatusSlice & | 'clearRuntimeDetectedAgents' | 'markEnvironmentSshStateStale' | 'purgeStaleRuntimeHostState' + | 'clearHostWorkspaceStatuses' | 'pairedTerminalTabCloseIntentsByEnvironment' | 'recordPairedTerminalTabCloseIntent' | 'clearPairedTerminalTabCloseIntents' diff --git a/src/renderer/src/store/slices/runtime-status.ts b/src/renderer/src/store/slices/runtime-status.ts index 5e54bdb9727..185579c79ec 100644 --- a/src/renderer/src/store/slices/runtime-status.ts +++ b/src/renderer/src/store/slices/runtime-status.ts @@ -151,6 +151,7 @@ export const createRuntimeStatusSlice: ( if (retiredEnvironmentIds.length > 0) { evictInstalledAgentSkillDiscoveryForRuntimeEnvironments(retiredEnvironmentIds) get().purgeStaleRuntimeHostState?.(retiredEnvironmentIds) + get().clearHostWorkspaceStatuses?.(retiredEnvironmentIds) retiredEnvironmentIds.forEach(dismissRuntimeDisconnectedToast) } reconcileWebSessionCloseReplaysWithCatalog(pairedTerminalTabCloseIntentStoreOf(get), { diff --git a/src/renderer/src/store/slices/ui-host-workspace-statuses.test.ts b/src/renderer/src/store/slices/ui-host-workspace-statuses.test.ts new file mode 100644 index 00000000000..90c838f54cc --- /dev/null +++ b/src/renderer/src/store/slices/ui-host-workspace-statuses.test.ts @@ -0,0 +1,127 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { useAppStore } from '@/store' +import { createUIStore } from './ui-slice-test-harness' +import type * as RuntimeRpcClient from '../../runtime/runtime-rpc-client' + +type RuntimeRpcClientModule = typeof RuntimeRpcClient + +const { callRuntimeRpc } = vi.hoisted(() => ({ callRuntimeRpc: vi.fn() })) +vi.mock('../../runtime/runtime-rpc-client', async (importOriginal) => ({ + ...(await importOriginal()), + callRuntimeRpc +})) + +const hostColumns = [ + { id: 'status-8', label: '🏠 主控台' }, + { id: 'status-6', label: '🔥 今天收尾' }, + { id: 'in-progress', label: 'In progress' } +] + +describe('fetchHostWorkspaceStatuses', () => { + beforeEach(() => { + callRuntimeRpc.mockReset() + }) + + it("stores the host's columns from ui.get without touching the local list", async () => { + callRuntimeRpc.mockResolvedValue({ ui: { workspaceStatuses: hostColumns } }) + const store = createUIStore() + const local = store.getState().workspaceStatuses + + await store.getState().fetchHostWorkspaceStatuses('env-1') + + expect(callRuntimeRpc).toHaveBeenCalledWith( + { kind: 'environment', environmentId: 'env-1' }, + 'ui.get', + undefined, + expect.anything() + ) + expect(store.getState().hostWorkspaceStatuses['env-1']?.map((status) => status.id)).toEqual([ + 'status-8', + 'status-6', + 'in-progress' + ]) + expect(store.getState().workspaceStatuses).toBe(local) + }) + + it('keeps the last columns when the host is unreachable', async () => { + const store = createUIStore() + callRuntimeRpc.mockResolvedValueOnce({ ui: { workspaceStatuses: hostColumns } }) + await store.getState().fetchHostWorkspaceStatuses('env-1') + callRuntimeRpc.mockRejectedValueOnce(new Error('offline')) + vi.spyOn(console, 'warn').mockImplementation(() => {}) + + await store.getState().fetchHostWorkspaceStatuses('env-1') + + expect(store.getState().hostWorkspaceStatuses['env-1']).toHaveLength(3) + }) + + it('stores no columns, not the defaults, when the host sends none', async () => { + callRuntimeRpc.mockResolvedValue({ ui: {} }) + const store = createUIStore() + + await store.getState().fetchHostWorkspaceStatuses('env-1') + + expect(store.getState().hostWorkspaceStatuses['env-1'] ?? []).toEqual([]) + }) + + it('keeps the stored list identity when the host resends the same columns', async () => { + callRuntimeRpc.mockResolvedValue({ ui: { workspaceStatuses: hostColumns } }) + const store = createUIStore() + await store.getState().fetchHostWorkspaceStatuses('env-1') + const first = store.getState().hostWorkspaceStatuses + + await store.getState().fetchHostWorkspaceStatuses('env-1') + + expect(store.getState().hostWorkspaceStatuses).toBe(first) + }) + + it('drops a reply that arrives after a newer one', async () => { + let answerOld: (value: unknown) => void = () => {} + callRuntimeRpc + .mockImplementationOnce(() => new Promise((resolve) => (answerOld = resolve))) + .mockResolvedValueOnce({ ui: { workspaceStatuses: [{ id: 'status-9', label: 'New' }] } }) + const store = createUIStore() + + const older = store.getState().fetchHostWorkspaceStatuses('env-1') + await store.getState().fetchHostWorkspaceStatuses('env-1') + answerOld({ ui: { workspaceStatuses: hostColumns } }) + await older + + expect(store.getState().hostWorkspaceStatuses['env-1']?.map((status) => status.id)).toEqual([ + 'status-9' + ]) + }) +}) + +describe('host workspace statuses after the host is retired', () => { + function pairedEnvironment(pairingRevision: number) { + return { + id: 'env-1', + name: 'env-1', + createdAt: 1, + updatedAt: pairingRevision, + pairingRevision, + lastUsedAt: null, + runtimeId: 'env-1', + preferredEndpointId: 'endpoint', + endpoints: [ + { id: 'endpoint', kind: 'websocket' as const, label: 'env-1', endpoint: 'ws://host' } + ] + } + } + + it("drops the old peer's columns, and its late reply, when the id is re-paired to another machine", async () => { + let answerOld: (value: unknown) => void = () => {} + callRuntimeRpc.mockReset() + callRuntimeRpc.mockImplementationOnce(() => new Promise((resolve) => (answerOld = resolve))) + useAppStore.getState().setRuntimeEnvironments([pairedEnvironment(1)]) + useAppStore.setState({ hostWorkspaceStatuses: { 'env-1': hostColumns } }) + const inFlight = useAppStore.getState().fetchHostWorkspaceStatuses('env-1') + + useAppStore.getState().setRuntimeEnvironments([pairedEnvironment(2)]) + answerOld({ ui: { workspaceStatuses: hostColumns } }) + await inFlight + + expect(useAppStore.getState().hostWorkspaceStatuses['env-1']).toBeUndefined() + }) +}) diff --git a/src/renderer/src/store/slices/ui/ui-slice-contract-preferences.ts b/src/renderer/src/store/slices/ui/ui-slice-contract-preferences.ts index a847228fa02..9520aab8b44 100644 --- a/src/renderer/src/store/slices/ui/ui-slice-contract-preferences.ts +++ b/src/renderer/src/store/slices/ui/ui-slice-contract-preferences.ts @@ -98,6 +98,10 @@ export type UISlicePreferences = { setAgentActivityDisplayMode: (mode: AgentActivityDisplayMode) => void workspaceStatuses: WorkspaceStatusDefinition[] setWorkspaceStatuses: (statuses: WorkspaceStatusDefinition[]) => void + /** Each paired runtime host's own columns, keyed by environment id; never persisted locally. */ + hostWorkspaceStatuses: Readonly> + fetchHostWorkspaceStatuses: (environmentId: string) => Promise + clearHostWorkspaceStatuses: (environmentIds: readonly string[]) => void workspaceBoardOpacity: number setWorkspaceBoardOpacity: (opacity: number) => void workspaceBoardColumnWidth: number diff --git a/src/renderer/src/store/slices/ui/ui-slice-host-workspace-statuses.ts b/src/renderer/src/store/slices/ui/ui-slice-host-workspace-statuses.ts new file mode 100644 index 00000000000..2bade6c0061 --- /dev/null +++ b/src/renderer/src/store/slices/ui/ui-slice-host-workspace-statuses.ts @@ -0,0 +1,78 @@ +import type { UISlice, UISliceGet, UISliceSet } from './ui-slice-contract' +import { normalizeWorkspaceStatuses } from '../../../../../shared/workspace-statuses' +import { callRuntimeRpc } from '../../../runtime/runtime-rpc-client' +import type { WorkspaceStatusDefinition } from '../../../../../shared/worktree/types' + +function sameWorkspaceStatuses( + a: readonly WorkspaceStatusDefinition[] | undefined, + b: readonly WorkspaceStatusDefinition[] +): boolean { + return ( + a?.length === b.length && + a.every( + (status, index) => + status.id === b[index]?.id && + status.label === b[index]?.label && + status.color === b[index]?.color && + status.icon === b[index]?.icon + ) + ) +} + +/** Each paired runtime host's own board columns, read from the host and never persisted locally. */ +export function createHostWorkspaceStatusActions( + set: UISliceSet, + get: UISliceGet +): Pick< + UISlice, + 'hostWorkspaceStatuses' | 'fetchHostWorkspaceStatuses' | 'clearHostWorkspaceStatuses' +> { + const hostStatusRequests = new Map() + return { + hostWorkspaceStatuses: {}, + fetchHostWorkspaceStatuses: async (environmentId) => { + const request = (hostStatusRequests.get(environmentId) ?? 0) + 1 + hostStatusRequests.set(environmentId, request) + try { + const { ui } = await callRuntimeRpc<{ ui: { workspaceStatuses?: unknown } }>( + { kind: 'environment', environmentId }, + 'ui.get', + undefined, + { timeoutMs: 15_000, reuseRecentCompatibilityFailure: true } + ) + // A reply older than the latest request may predate a column edit on the host. + if (hostStatusRequests.get(environmentId) !== request) { + return + } + // Why no defaults: a host that sends no columns adds none to this board. + const statuses = Array.isArray(ui.workspaceStatuses) + ? normalizeWorkspaceStatuses(ui.workspaceStatuses) + : [] + if (sameWorkspaceStatuses(get().hostWorkspaceStatuses[environmentId], statuses)) { + return + } + set((s) => ({ + hostWorkspaceStatuses: { ...s.hostWorkspaceStatuses, [environmentId]: statuses } + })) + } catch (err) { + // An unreachable host keeps its last columns. + console.warn(`Skipped workspace statuses for runtime environment ${environmentId}:`, err) + } + }, + clearHostWorkspaceStatuses: (environmentIds) => { + // Why bump: a retired peer's in-flight reply must not restore its columns. + for (const id of environmentIds) { + hostStatusRequests.set(id, (hostStatusRequests.get(id) ?? 0) + 1) + } + const current = get().hostWorkspaceStatuses + if (!environmentIds.some((id) => id in current)) { + return + } + const next = { ...current } + for (const id of environmentIds) { + delete next[id] + } + set({ hostWorkspaceStatuses: next }) + } + } +} diff --git a/src/renderer/src/store/slices/ui/ui-slice-preference-actions.ts b/src/renderer/src/store/slices/ui/ui-slice-preference-actions.ts index f24b3ba4782..4bed57a7acb 100644 --- a/src/renderer/src/store/slices/ui/ui-slice-preference-actions.ts +++ b/src/renderer/src/store/slices/ui/ui-slice-preference-actions.ts @@ -34,6 +34,7 @@ import { normalizeWorkspaceStatuses, WORKSPACE_BOARD_COLUMN_WIDTH_DEFAULT } from '../../../../../shared/workspace-statuses' +import { createHostWorkspaceStatusActions } from './ui-slice-host-workspace-statuses' /** Builds preference defaults and setters for the UI slice, leaving durable writes to the persistence layer. */ export function createUiPreferenceActions(set: UISliceSet, get: UISliceGet): Partial { @@ -207,6 +208,7 @@ export function createUiPreferenceActions(set: UISliceSet, get: UISliceGet): Par window.api.ui.set({ workspaceStatuses: normalized }).catch(console.error) set({ workspaceStatuses: normalized }) }, + ...createHostWorkspaceStatusActions(set, get), workspaceBoardOpacity: 1, setWorkspaceBoardOpacity: (opacity) => { diff --git a/src/renderer/src/store/slices/worktrees-metadata-persistence.test.ts b/src/renderer/src/store/slices/worktrees-metadata-persistence.test.ts index 3f6d3db1776..d18b4cb64dc 100644 --- a/src/renderer/src/store/slices/worktrees-metadata-persistence.test.ts +++ b/src/renderer/src/store/slices/worktrees-metadata-persistence.test.ts @@ -406,6 +406,66 @@ describe('worktree remote runtime mutations', () => { expect(mockApi.worktrees.updateMeta).toHaveBeenCalledTimes(2) }) + it("keeps a local card out of a paired host's own column but still applies its rank", async () => { + const store = createTestStore() + const local = makeWorktree({ id: 'repo1::/path/wt1', repoId: 'repo1', path: '/path/wt1' }) + store.setState({ + worktreesByRepo: { repo1: [local] }, + workspaceStatuses: [{ id: 'in-progress', label: 'In progress' }], + hostWorkspaceStatuses: { 'env-1': [{ id: 'status-6', label: 'Today' }] } + }) + + await store + .getState() + .updateWorktreesMeta([ + { worktreeId: local.id, updates: { workspaceStatus: 'status-6', manualOrder: 5 } } + ]) + await store.getState().updateWorktreeMeta(local.id, { workspaceStatus: 'status-6' }) + + expect(store.getState().worktreesByRepo.repo1[0]).toMatchObject({ manualOrder: 5 }) + expect(store.getState().worktreesByRepo.repo1[0]?.workspaceStatus).toBeUndefined() + expect(mockApi.worktrees.updateMeta).toHaveBeenCalledWith( + expect.objectContaining({ updates: { manualOrder: 5 } }) + ) + }) + + it("still moves a paired host's card into that host's own column", async () => { + const store = createTestStore() + const hosted = makeWorktree({ + id: 'repo1::/path/wt1', + repoId: 'repo1', + path: '/path/wt1', + hostId: 'runtime:env-1' + }) + runtimeEnvironmentCall.mockResolvedValue({ + id: 'rpc-set-status', + ok: true, + result: { worktree: { ...hosted, workspaceStatus: 'status-6' } }, + _meta: { runtimeId: 'runtime-remote' } + }) + store.setState({ + worktreesByRepo: { repo1: [hosted] }, + workspaceStatuses: [{ id: 'in-progress', label: 'In progress' }], + hostWorkspaceStatuses: { 'env-1': [{ id: 'status-6', label: 'Today' }] } + }) + + await store.getState().updateWorktreesMeta([ + { + worktreeId: hosted.id, + updates: { workspaceStatus: 'status-6' }, + executionHostId: 'runtime:env-1' + } + ]) + + expect(store.getState().worktreesByRepo.repo1[0]?.workspaceStatus).toBe('status-6') + expect(runtimeEnvironmentCall).toHaveBeenCalledWith( + expect.objectContaining({ + selector: 'env-1', + params: expect.objectContaining({ workspaceStatus: 'status-6' }) + }) + ) + }) + it('persists a same-id manual rank to every owning host', async () => { const store = createTestStore() const worktreeId = 'repo1::/same/path' diff --git a/src/renderer/src/store/slices/worktrees.ts b/src/renderer/src/store/slices/worktrees.ts index 423a1466a09..cfb4f4e750f 100644 --- a/src/renderer/src/store/slices/worktrees.ts +++ b/src/renderer/src/store/slices/worktrees.ts @@ -34,6 +34,10 @@ import { createForceDeletePreservedBranch } from './worktrees/teardown/force-del import { createUpdateWorktreeMeta } from './worktrees/metadata/update-worktree-meta' import { createEnsureHostedReviewPushTarget } from './worktrees/metadata/hosted-review-push-target-ensure' import { createUpdateWorktreesMeta } from './worktrees/metadata/update-worktrees-meta' +import { + guardUpdateWorktreeMetaHostStatus, + guardUpdateWorktreesMetaHostStatus +} from './worktrees/metadata/foreign-host-status-guard' import { createSetWorktreesPinnedAndReveal } from './worktrees/session/worktree-pin-reveal' import { createBumpWorktreeActivity } from './worktrees/session/worktree-activity-bump' import { @@ -90,9 +94,9 @@ export const createWorktreeSlice: StateCreator markWorktreesQueuedForDeletion: createMarkWorktreesQueuedForDeletion(set, get), forceDeletePreservedBranch: createForceDeletePreservedBranch(set, get), clearWorktreeDeleteState: createClearWorktreeDeleteState(set, get), - updateWorktreeMeta: createUpdateWorktreeMeta(set, get), + updateWorktreeMeta: guardUpdateWorktreeMetaHostStatus(createUpdateWorktreeMeta(set, get), get), ensureHostedReviewPushTarget: createEnsureHostedReviewPushTarget(set, get), - updateWorktreesMeta: createUpdateWorktreesMeta(set, get), + updateWorktreesMeta: guardUpdateWorktreesMetaHostStatus(createUpdateWorktreesMeta(set, get), get), setWorktreesPinnedAndReveal: createSetWorktreesPinnedAndReveal(set, get), observeTerminalGitHubPullRequestLink: createObserveTerminalGitHubPullRequestLink(get), bumpWorktreeActivity: createBumpWorktreeActivity(set, get), diff --git a/src/renderer/src/store/slices/worktrees/metadata/foreign-host-status-guard.ts b/src/renderer/src/store/slices/worktrees/metadata/foreign-host-status-guard.ts new file mode 100644 index 00000000000..9b0090811a7 --- /dev/null +++ b/src/renderer/src/store/slices/worktrees/metadata/foreign-host-status-guard.ts @@ -0,0 +1,65 @@ +import type { WorktreeSlice } from '../../worktree-helpers' +import type { WorktreeSliceGet } from '../listing/worktree-slice-types' +import { findKnownWorktreeById } from '../listing/detected-worktree-meta' +import { + withoutForeignHostStatus, + type StatusColumnOwner +} from '../../../host-workspace-status-columns' +import type { ExecutionHostId } from '../../../../../../shared/execution-host' + +/** The card's host and runtime owner; null when neither the caller nor the store knows its host. */ +function statusOwner( + state: ReturnType, + worktreeId: string, + executionHostId: ExecutionHostId | undefined +): StatusColumnOwner | null { + const card = findKnownWorktreeById(state, worktreeId, executionHostId) + if (!card && !executionHostId) { + return null + } + // A known card without a host id is local. + return { + hostId: executionHostId ?? card?.hostId ?? 'local', + runtimeOwnerEnvironmentId: card?.runtimeOwnerEnvironmentId + } +} + +/** Every status write path keeps a card out of another paired host's columns. */ +export function guardUpdateWorktreeMetaHostStatus( + update: WorktreeSlice['updateWorktreeMeta'], + get: WorktreeSliceGet +): WorktreeSlice['updateWorktreeMeta'] { + return (worktreeId, updates, options) => { + const owner = statusOwner(get(), worktreeId, options?.executionHostId) + if (!owner) { + return update(worktreeId, updates, options) + } + const allowed = withoutForeignHostStatus(get(), owner, updates) + // Why: a write that was only the dropped status has nothing left to persist. + if (allowed !== updates && Object.keys(allowed).length === 0) { + return Promise.resolve({ ok: true as const }) + } + return update(worktreeId, allowed, options) + } +} + +export function guardUpdateWorktreesMetaHostStatus( + update: WorktreeSlice['updateWorktreesMeta'], + get: WorktreeSliceGet +): WorktreeSlice['updateWorktreesMeta'] { + return (entries) => { + const state = get() + return update( + entries.flatMap((entry) => { + const owner = statusOwner(state, entry.worktreeId, entry.executionHostId) + if (!owner) { + return [entry] + } + const allowed = withoutForeignHostStatus(state, owner, entry.updates) + return allowed !== entry.updates && Object.keys(allowed).length === 0 + ? [] + : [{ ...entry, updates: allowed }] + }) + ) + } +} diff --git a/src/shared/workspace-statuses.ts b/src/shared/workspace-statuses.ts index 31d4df4cffe..72372bbc2a2 100644 --- a/src/shared/workspace-statuses.ts +++ b/src/shared/workspace-statuses.ts @@ -265,6 +265,22 @@ export function withDefaultWorkspaceStatuses( return statuses.length > 0 ? statuses : DEFAULT_WORKSPACE_STATUSES } +/** Local columns first, then paired hosts' columns the local list lacks; an id clash keeps the local column. */ +export function mergeHostWorkspaceStatuses( + local: readonly WorkspaceStatusDefinition[], + hosts: readonly (readonly WorkspaceStatusDefinition[])[] +): readonly WorkspaceStatusDefinition[] { + const seen = new Set(local.map((status) => status.id)) + const extra: WorkspaceStatusDefinition[] = [] + for (const status of hosts.flat()) { + if (!seen.has(status.id)) { + seen.add(status.id) + extra.push(status) + } + } + return extra.length > 0 ? [...local, ...extra] : local +} + export function getWorkspaceStatus( worktree: Pick, statuses: readonly WorkspaceStatusDefinition[]