mirror of
https://github.com/stablyai/orca.git
synced 2026-11-13 17:37:40 +00:00
* 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>
311 lines
9.8 KiB
TypeScript
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
|
|
}
|
|
}
|