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[]