Files
orca/src/shared/workspace-statuses.ts
T
e49cab0a92 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <edisonhuang1122@gmail.com>

* fix(workspace-board): deliver a host's workspaceViewChanged so its columns refresh

Co-authored-by: Kooooojun <edisonhuang1122@gmail.com>

* 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 <edisonhuang1122@gmail.com>

* 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 <edisonhuang1122@gmail.com>

* 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 <edisonhuang1122@gmail.com>

* 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 <edisonhuang1122@gmail.com>

---------

Co-authored-by: Kooooojun <edisonhuang1122@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 03:02:14 -07:00

311 lines
9.8 KiB
TypeScript

import type { WorkspaceStatus, WorkspaceStatusDefinition, Worktree } from './worktree/types'
import { DEFAULT_STATUS_VISUALS, DEFAULT_WORKSPACE_STATUSES } from './workspace-status-defaults'
import {
isKnownBadPRReorderedDefaultStatusPayload,
isLegacyDefaultWorkflowStatusPayload
} from './workspace-status-default-migration'
import { sliceAtCodeUnitLimit } from './surrogate-safe-text-slice'
export { DEFAULT_WORKSPACE_STATUSES } from './workspace-status-defaults'
const WORKSPACE_STATUS_GROUP_PREFIX = 'workspace-status:'
const MAX_STATUS_LABEL_LENGTH = 32
type WorkspaceStatusNormalizationOptions = {
migrateDefaultWorkflowStatuses?: boolean
migrateLegacyDefaultStatusVisuals?: boolean
}
export const DEFAULT_WORKSPACE_STATUS_ID: WorkspaceStatus = 'in-progress'
export const DEFAULT_WORKSPACE_STATUS_COLOR_ID = 'neutral'
export const DEFAULT_WORKSPACE_STATUS_ICON_ID = 'circle-dot'
export const WORKSPACE_BOARD_COLUMN_WIDTH_DEFAULT = 308
export const WORKSPACE_BOARD_COLUMN_WIDTH_MIN = 220
export const WORKSPACE_BOARD_COLUMN_WIDTH_MAX = 520
export const WORKSPACE_BOARD_COLUMN_WIDTH_STEP = 20
export const WORKSPACE_STATUS_COLOR_IDS = [
'neutral',
'blue',
'sky',
'violet',
'amber',
'emerald',
'rose',
'zinc',
'conductor-done',
'conductor-review',
'conductor-progress'
] as const
export const WORKSPACE_STATUS_ICON_IDS = [
'circle',
'circle-dot',
'circle-progress',
'circle-dashed',
'circle-ellipsis',
'git-pull-request',
'timer',
'flag',
'circle-alert',
'circle-pause',
'circle-play',
'circle-check',
'ban',
'conductor-done',
'conductor-review',
'conductor-progress'
] as const
export function cloneDefaultWorkspaceStatuses(): WorkspaceStatusDefinition[] {
return DEFAULT_WORKSPACE_STATUSES.map((status) => ({ ...status }))
}
function sanitizeWorkspaceStatusLabel(value: unknown, fallback: string): string {
if (typeof value !== 'string') {
return fallback
}
const trimmed = value.trim().replace(/\s+/g, ' ')
return trimmed ? sliceAtCodeUnitLimit(trimmed, MAX_STATUS_LABEL_LENGTH) : fallback
}
function slugWorkspaceStatusLabel(label: string): string {
const slug = label
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
return slug || 'status'
}
function sanitizeWorkspaceStatusId(value: unknown, fallbackLabel: string): WorkspaceStatus {
if (typeof value !== 'string') {
return slugWorkspaceStatusLabel(fallbackLabel)
}
const trimmed = value.trim().toLowerCase()
if (!trimmed) {
return slugWorkspaceStatusLabel(fallbackLabel)
}
return trimmed.replace(/[^a-z0-9_-]+/g, '-').replace(/^-+|-+$/g, '') || 'status'
}
function sanitizeWorkspaceStatusColor(
value: unknown,
statusId: string,
label: string,
index: number,
options: WorkspaceStatusNormalizationOptions
): string {
if (
options.migrateLegacyDefaultStatusVisuals === true &&
((statusId === 'in-progress' && label === 'In progress' && value === 'blue') ||
(statusId === 'in-review' && label === 'In review' && value === 'violet') ||
(statusId === 'completed' &&
(label === 'Completed' || label === 'Done') &&
value === 'emerald')) &&
DEFAULT_STATUS_VISUALS[statusId]
) {
return DEFAULT_STATUS_VISUALS[statusId]?.color ?? DEFAULT_WORKSPACE_STATUS_COLOR_ID
}
if (typeof value === 'string' && WORKSPACE_STATUS_COLOR_IDS.some((id) => id === value)) {
return value
}
const defaultVisual = DEFAULT_STATUS_VISUALS[statusId]
if (defaultVisual) {
return defaultVisual.color
}
return WORKSPACE_STATUS_COLOR_IDS[index % WORKSPACE_STATUS_COLOR_IDS.length]
}
function sanitizeWorkspaceStatusIcon(
value: unknown,
statusId: string,
label: string,
options: WorkspaceStatusNormalizationOptions
): string {
if (
options.migrateLegacyDefaultStatusVisuals === true &&
((statusId === 'in-progress' &&
label === 'In progress' &&
(value === 'circle-dot' || value === 'circle-progress')) ||
(statusId === 'in-review' && label === 'In review' && value === 'git-pull-request') ||
(statusId === 'completed' &&
(label === 'Completed' || label === 'Done') &&
value === 'circle-check')) &&
DEFAULT_STATUS_VISUALS[statusId]
) {
return DEFAULT_STATUS_VISUALS[statusId]?.icon ?? DEFAULT_WORKSPACE_STATUS_ICON_ID
}
if (typeof value === 'string' && WORKSPACE_STATUS_ICON_IDS.some((id) => id === value)) {
return value
}
return DEFAULT_STATUS_VISUALS[statusId]?.icon ?? DEFAULT_WORKSPACE_STATUS_ICON_ID
}
export function makeWorkspaceStatusId(
label: string,
existingStatuses: readonly WorkspaceStatusDefinition[]
): WorkspaceStatus {
const base = slugWorkspaceStatusLabel(label)
const existingIds = new Set(existingStatuses.map((status) => status.id))
if (!existingIds.has(base)) {
return base
}
for (let index = 2; index < 100; index += 1) {
const candidate = `${base}-${index}`
if (!existingIds.has(candidate)) {
return candidate
}
}
return `status-${Date.now().toString(36)}`
}
function normalizeWorkspaceStatusesInternal(
value: unknown,
options: WorkspaceStatusNormalizationOptions
): WorkspaceStatusDefinition[] {
if (!Array.isArray(value)) {
return cloneDefaultWorkspaceStatuses()
}
const statuses: WorkspaceStatusDefinition[] = []
const usedIds = new Set<string>()
for (const rawStatus of value) {
if (!rawStatus || typeof rawStatus !== 'object' || Array.isArray(rawStatus)) {
continue
}
const raw = rawStatus as Record<string, unknown>
const fallbackLabel = `Status ${statuses.length + 1}`
const label = sanitizeWorkspaceStatusLabel(raw.label, fallbackLabel)
let id = sanitizeWorkspaceStatusId(raw.id, label)
if (usedIds.has(id)) {
id = makeWorkspaceStatusId(label, statuses)
}
usedIds.add(id)
statuses.push({
id,
label,
color: sanitizeWorkspaceStatusColor(raw.color, id, label, statuses.length, options),
icon: sanitizeWorkspaceStatusIcon(raw.icon, id, label, options)
})
}
if (statuses.length === 0) {
return cloneDefaultWorkspaceStatuses()
}
return statuses
}
export function normalizeWorkspaceStatuses(value: unknown): WorkspaceStatusDefinition[] {
return normalizeWorkspaceStatusesInternal(value, {})
}
export function normalizePersistedWorkspaceStatuses(
value: unknown,
options: {
migrateDefaultWorkflowStatuses?: boolean
repairReorderedDefaultStatuses?: boolean
migrateLegacyDefaultStatusVisuals?: boolean
} = {}
): WorkspaceStatusDefinition[] {
if (
options.migrateDefaultWorkflowStatuses === true &&
isLegacyDefaultWorkflowStatusPayload(value)
) {
return cloneDefaultWorkspaceStatuses()
}
// Repair only the raw known reversed default payload once; normalized values cannot distinguish it from a user reorder.
if (
options.repairReorderedDefaultStatuses === true &&
isKnownBadPRReorderedDefaultStatusPayload(value)
) {
return cloneDefaultWorkspaceStatuses()
}
return normalizeWorkspaceStatusesInternal(value, {
migrateLegacyDefaultStatusVisuals: options.migrateLegacyDefaultStatusVisuals
})
}
export function clampWorkspaceBoardOpacity(value: unknown): number {
if (typeof value !== 'number' || !Number.isFinite(value)) {
return 1
}
return Math.min(1, Math.max(0.2, Math.round(value * 100) / 100))
}
export function clampWorkspaceBoardColumnWidth(value: unknown): number {
if (typeof value !== 'number' || !Number.isFinite(value)) {
return WORKSPACE_BOARD_COLUMN_WIDTH_DEFAULT
}
return Math.min(
WORKSPACE_BOARD_COLUMN_WIDTH_MAX,
Math.max(WORKSPACE_BOARD_COLUMN_WIDTH_MIN, Math.round(value))
)
}
export function isWorkspaceStatusId(
value: string,
statuses: readonly WorkspaceStatusDefinition[]
): value is WorkspaceStatus {
return statuses.some((status) => status.id === value)
}
export function getDefaultWorkspaceStatusId(
statuses: readonly WorkspaceStatusDefinition[]
): WorkspaceStatus {
return statuses.some((status) => status.id === DEFAULT_WORKSPACE_STATUS_ID)
? DEFAULT_WORKSPACE_STATUS_ID
: (statuses[0]?.id ?? DEFAULT_WORKSPACE_STATUS_ID)
}
/** The statuses to group by: a payload with none (a partially hydrated store) keeps the defaults. */
export function withDefaultWorkspaceStatuses(
statuses: readonly WorkspaceStatusDefinition[]
): readonly WorkspaceStatusDefinition[] {
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<Worktree, 'workspaceStatus'>,
statuses: readonly WorkspaceStatusDefinition[]
): WorkspaceStatus {
return worktree.workspaceStatus && isWorkspaceStatusId(worktree.workspaceStatus, statuses)
? worktree.workspaceStatus
: getDefaultWorkspaceStatusId(statuses)
}
export function getWorkspaceStatusGroupKey(status: WorkspaceStatus): string {
return `${WORKSPACE_STATUS_GROUP_PREFIX}${encodeURIComponent(status)}`
}
export function getWorkspaceStatusFromGroupKey(
groupKey: string,
statuses: readonly WorkspaceStatusDefinition[]
): WorkspaceStatus | null {
if (!groupKey.startsWith(WORKSPACE_STATUS_GROUP_PREFIX)) {
return null
}
try {
const status = decodeURIComponent(groupKey.slice(WORKSPACE_STATUS_GROUP_PREFIX.length))
return isWorkspaceStatusId(status, statuses) ? status : null
} catch {
return null
}
}