mirror of
https://github.com/stablyai/orca.git
synced 2026-11-13 09:27:29 +00:00
Show child workspace status counts in sidebar cards
Distinguish monitoring agents and refine lineage alignment so workspace activity is easier to scan.
This commit is contained in:
@@ -1724,15 +1724,25 @@ html.native-shell .app-layout {
|
||||
--border: color-mix(in oklab, var(--worktree-sidebar-foreground) 5%, var(--worktree-sidebar));
|
||||
}
|
||||
|
||||
/* Experimental 2.0 lineage guide: painted, so it adds no width; a 5px tick reaches each child's title. */
|
||||
/* Experimental 2.0 lineage guide, painted in the children's original 14px offset; a tick reaches each child. */
|
||||
.worktree-card-slot-lineage {
|
||||
--worktree-card-slot-guide: color-mix(
|
||||
in srgb,
|
||||
var(--worktree-sidebar-foreground) 16%,
|
||||
transparent
|
||||
);
|
||||
background: linear-gradient(var(--worktree-card-slot-guide), var(--worktree-card-slot-guide))
|
||||
no-repeat 0 0 / 1px 100%;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.worktree-card-slot-lineage::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: -9px;
|
||||
top: 0;
|
||||
bottom: 8px;
|
||||
width: 1px;
|
||||
background: var(--worktree-card-slot-guide);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.worktree-card-slot-lineage > * {
|
||||
@@ -1742,9 +1752,9 @@ html.native-shell .app-layout {
|
||||
.worktree-card-slot-lineage > *::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: -3px;
|
||||
left: -9px;
|
||||
top: 15px;
|
||||
width: 5px;
|
||||
width: 12px;
|
||||
height: 1px;
|
||||
background: var(--worktree-card-slot-guide);
|
||||
pointer-events: none;
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import React from 'react'
|
||||
import { Activity, CircleX } from 'lucide-react'
|
||||
import { AgentQuestionIcon } from '@/components/AgentQuestionIcon'
|
||||
import { AgentWorkingSpinner } from '@/components/AgentWorkingSpinner'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { formatShortTimeAgo } from '@/lib/short-time-ago'
|
||||
@@ -20,7 +22,9 @@ function slotLabel(signal: SlotSignal): string | undefined {
|
||||
? translate('sidebar.cardSlot.markedUnread', 'Marked unread by you')
|
||||
: translate('sidebar.cardSlot.unread', 'Unread result')
|
||||
case 'working':
|
||||
return translate('sidebar.cardSlot.working', 'Working')
|
||||
return signal.monitoring
|
||||
? translate('sidebar.cardSlot.monitoring', 'Monitoring')
|
||||
: translate('sidebar.cardSlot.working', 'Working')
|
||||
case 'offline':
|
||||
return translate('sidebar.cardSlot.offlineLabel', 'Host offline')
|
||||
case 'time':
|
||||
@@ -70,6 +74,8 @@ export function WorktreeCardSlot({
|
||||
'h-4 rounded-[5px] bg-agent-question/15 px-1.5 font-semibold text-agent-question-text'
|
||||
)}
|
||||
>
|
||||
{/* Same question glyph tabs, the dashboard and kanban use for an agent waiting on you. */}
|
||||
<AgentQuestionIcon className="size-2.5 shrink-0" />
|
||||
{signal.count > 1
|
||||
? translate('sidebar.cardSlot.needYouCount', '{{count}} need you', {
|
||||
count: signal.count
|
||||
@@ -80,6 +86,8 @@ export function WorktreeCardSlot({
|
||||
case 'failed':
|
||||
return (
|
||||
<span {...slotProps} className={cn(base, 'font-semibold text-destructive')}>
|
||||
{/* A crossed circle, not a red dot, so failed never reads like the blue unread dot. */}
|
||||
<CircleX className="size-2.5 shrink-0" aria-hidden="true" />
|
||||
{translate('sidebar.cardSlot.failed', 'Failed')}
|
||||
</span>
|
||||
)
|
||||
@@ -93,7 +101,11 @@ export function WorktreeCardSlot({
|
||||
case 'working':
|
||||
return (
|
||||
<span {...slotProps} className={cn(base, 'text-muted-foreground')}>
|
||||
<AgentWorkingSpinner className="size-[9px] border-[1.5px]" />
|
||||
{signal.monitoring ? (
|
||||
<Activity className="size-2.5 shrink-0 text-agent-working" aria-hidden="true" />
|
||||
) : (
|
||||
<AgentWorkingSpinner className="size-[9px] border-[1.5px]" />
|
||||
)}
|
||||
{formatSlotTime(signal.at, now)}
|
||||
</span>
|
||||
)
|
||||
|
||||
@@ -31,7 +31,7 @@ export function SlotAgentLinesHeader({
|
||||
<button
|
||||
type="button"
|
||||
draggable={false}
|
||||
className="worktree-agent-row-hover mt-[3px] flex h-[19px] w-full min-w-0 items-center gap-1.5 rounded-sm pr-2 pl-1 text-left text-2xs leading-none font-medium text-muted-foreground outline-none focus-visible:ring-1 focus-visible:ring-worktree-sidebar-ring"
|
||||
className="worktree-agent-row-hover mt-[3px] flex h-[19px] w-full min-w-0 items-center gap-1.5 rounded-sm pr-1.5 pl-1 text-left text-2xs leading-none font-medium text-muted-foreground outline-none focus-visible:ring-1 focus-visible:ring-worktree-sidebar-ring"
|
||||
aria-expanded={true}
|
||||
aria-label={translate(
|
||||
'auto.components.sidebar.worktree.card.compact.agents.0c1debfe84',
|
||||
@@ -172,7 +172,7 @@ export function WorktreeCardSlotAgentLines({
|
||||
}
|
||||
return (
|
||||
<div
|
||||
className="mt-px flex flex-col gap-px"
|
||||
className="mt-px flex flex-col gap-px pl-2.5"
|
||||
role={hasLineage ? 'tree' : 'group'}
|
||||
aria-label={translate('auto.components.sidebar.WorktreeCardAgents.1b0a156717', 'Agents')}
|
||||
onDoubleClick={stopPropagation}
|
||||
|
||||
@@ -1,126 +1,21 @@
|
||||
import React from 'react'
|
||||
import { defineFixture } from '../../component-fixtures/component-fixture'
|
||||
import { useAppStore } from '@/store'
|
||||
import type { AgentStatusEntry } from '../../../../shared/agent-status/contracts/agent-status-types'
|
||||
import type { TerminalTab } from '../../../../shared/workspace-layout/terminal-tab-types'
|
||||
import type { Worktree } from '../../../../shared/worktree/types'
|
||||
import { folderWorkspaceToWorktree } from '../../../../shared/folder-workspaces/folder-workspace-worktree'
|
||||
import { worktreeCardStyleSettingsUpdate } from '../../../../shared/worktree/card-style'
|
||||
import WorktreeCard from './workspace-card/surface/WorktreeCard'
|
||||
import * as agentExpansion from './workspace-card/activity/worktree-card-agents-expansion-state'
|
||||
import * as rows from './worktree-list/rows/indentation'
|
||||
import {
|
||||
MINUTE,
|
||||
makeWorktree,
|
||||
seedWorkspaces,
|
||||
type WorkspaceSeed
|
||||
} from './worktree-card-slot-fixture-seeds'
|
||||
|
||||
const MINUTE = 60_000
|
||||
const SIDEBAR_WIDTH = 320
|
||||
// Grouped-by-project geometry from getWorktreeCardContentIndent: one tree step plus the project inset.
|
||||
const TOP_LEVEL_CONTENT_INDENT = 20
|
||||
|
||||
type AgentSeed = {
|
||||
agentType: string
|
||||
state: AgentStatusEntry['state']
|
||||
minutesAgo: number
|
||||
toolName?: string
|
||||
toolInput?: string
|
||||
message?: string
|
||||
failed?: boolean
|
||||
unread?: boolean
|
||||
prompt?: string
|
||||
}
|
||||
|
||||
type WorkspaceSeed = {
|
||||
id: string
|
||||
name: string
|
||||
agents: AgentSeed[]
|
||||
manualUnread?: boolean
|
||||
lastActivityMinutesAgo?: number
|
||||
}
|
||||
|
||||
function makeWorktree(seed: WorkspaceSeed, now: number): Worktree {
|
||||
return {
|
||||
id: seed.id,
|
||||
repoId: 'repo-orca',
|
||||
path: `/repo/orca/${seed.name}`,
|
||||
displayName: seed.name,
|
||||
branch: `jinjing/${seed.name}`,
|
||||
head: 'abc123',
|
||||
isBare: false,
|
||||
isMainWorktree: false,
|
||||
comment: '',
|
||||
linkedIssue: null,
|
||||
linkedPR: null,
|
||||
linkedLinearIssue: null,
|
||||
isArchived: false,
|
||||
isUnread: seed.manualUnread === true,
|
||||
isPinned: false,
|
||||
sortOrder: 0,
|
||||
lastActivityAt: now - (seed.lastActivityMinutesAgo ?? 30) * MINUTE
|
||||
}
|
||||
}
|
||||
|
||||
function seedWorkspaces(seeds: readonly WorkspaceSeed[], now: number): void {
|
||||
agentExpansion.clearWorktreeAgentExpansionStateForTests()
|
||||
const tabsByWorktree: Record<string, TerminalTab[]> = {}
|
||||
const agentStatusByPaneKey: Record<string, AgentStatusEntry> = {}
|
||||
const unreadAgentCompletionPanes: Record<string, 'agent-completion'> = {}
|
||||
const acknowledged: Record<string, number> = {}
|
||||
const ptyIdsByTabId: Record<string, string[]> = {}
|
||||
for (const seed of seeds) {
|
||||
tabsByWorktree[seed.id] = seed.agents.map((agent, index) => {
|
||||
const tabId = `${seed.id}-tab-${index}`
|
||||
const paneKey = `${tabId}:00000000-0000-4000-8000-00000000000${index}`
|
||||
agentStatusByPaneKey[paneKey] = {
|
||||
state: agent.state,
|
||||
prompt: agent.prompt ?? '',
|
||||
updatedAt: now,
|
||||
stateStartedAt: now - agent.minutesAgo * MINUTE,
|
||||
agentType: agent.agentType,
|
||||
paneKey,
|
||||
tabId,
|
||||
worktreeId: seed.id,
|
||||
stateHistory: [],
|
||||
...(agent.toolName ? { toolName: agent.toolName, toolInput: agent.toolInput } : {}),
|
||||
...(agent.message ? { lastAssistantMessage: agent.message } : {}),
|
||||
...(agent.failed
|
||||
? { mainAgent: { state: 'done', outcome: 'failure', stateStartedAt: now } }
|
||||
: {})
|
||||
}
|
||||
ptyIdsByTabId[tabId] = [`pty-${tabId}`]
|
||||
if (agent.unread) {
|
||||
unreadAgentCompletionPanes[paneKey] = 'agent-completion'
|
||||
} else {
|
||||
acknowledged[paneKey] = now - agent.minutesAgo * MINUTE
|
||||
}
|
||||
return {
|
||||
id: tabId,
|
||||
ptyId: `pty-${tabId}`,
|
||||
worktreeId: seed.id,
|
||||
title: agent.agentType,
|
||||
customTitle: null,
|
||||
color: null,
|
||||
sortOrder: index,
|
||||
createdAt: now - 60 * MINUTE
|
||||
}
|
||||
})
|
||||
}
|
||||
useAppStore.setState((state) => ({
|
||||
settings: state.settings
|
||||
? {
|
||||
...state.settings,
|
||||
...worktreeCardStyleSettingsUpdate('experimental-2')
|
||||
}
|
||||
: state.settings,
|
||||
worktreeCardProperties: ['status', 'inline-agents'],
|
||||
tabsByWorktree: { ...state.tabsByWorktree, ...tabsByWorktree },
|
||||
ptyIdsByTabId: { ...state.ptyIdsByTabId, ...ptyIdsByTabId },
|
||||
agentStatusByPaneKey: { ...state.agentStatusByPaneKey, ...agentStatusByPaneKey },
|
||||
acknowledgedAgentsByPaneKey: { ...state.acknowledgedAgentsByPaneKey, ...acknowledged },
|
||||
agentStatusEpoch: state.agentStatusEpoch + 1,
|
||||
unreadAgentCompletionPanes: {
|
||||
...state.unreadAgentCompletionPanes,
|
||||
...unreadAgentCompletionPanes
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
const FLAT: WorkspaceSeed[] = [
|
||||
{
|
||||
id: 'wt-needs',
|
||||
@@ -334,7 +229,11 @@ function Card({
|
||||
lineageChildCount={childCount}
|
||||
lineageCollapsed={collapsed}
|
||||
lineageDescendantIds={descendantIds}
|
||||
lineageChildWorktreeIds={descendantIds}
|
||||
lineageChildren={children}
|
||||
lineageChildrenStyle={rows.getLineageChildrenInlineStyle(
|
||||
rows.LINEAGE_CHILDREN_INLINE_OFFSET
|
||||
)}
|
||||
onLineageToggle={childCount > 0 ? () => {} : undefined}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import React, { useCallback, useMemo } from 'react'
|
||||
import { ChevronDown, ChevronRight } from 'lucide-react'
|
||||
import { useShallow } from 'zustand/react/shallow'
|
||||
|
||||
import type { DashboardAgentRow } from '@/components/dashboard/useDashboardData'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { useNow } from '@/hooks/use-now'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { canConnectSshStatus, isConnectingSshStatus } from '@/ssh/ssh-connection-recoverability'
|
||||
@@ -31,6 +29,7 @@ import {
|
||||
WorktreeCardRenameFailedButton
|
||||
} from './worktree-card-title-actions'
|
||||
import { getWorktreeCardHost, WorktreeCardHostName } from './worktree-card-host-name'
|
||||
import { WorktreeCardChildrenChip } from './worktree-card-children-chip'
|
||||
import { WorktreeCardSlot } from './WorktreeCardSlot'
|
||||
import { SlotAgentLinesHeader, WorktreeCardSlotAgentLines } from './WorktreeCardSlotAgentLines'
|
||||
import { shouldBeginWorktreeRename } from './workspace-card/controller/worktree-card-model'
|
||||
@@ -107,6 +106,7 @@ export function WorktreeCardSlotContent({
|
||||
showLineageChildChip,
|
||||
lineageCollapsed,
|
||||
lineageDescendantIds,
|
||||
lineageChildWorktreeIds,
|
||||
onLineageToggle,
|
||||
lineageChildAriaLabel,
|
||||
childWorkspaceShortLabel,
|
||||
@@ -177,9 +177,11 @@ export function WorktreeCardSlotContent({
|
||||
{foldsAgents ? (
|
||||
<>
|
||||
{agentsExpanded ? (
|
||||
<SlotAgentLinesHeader label={agentCountLabel} onCollapse={toggleAgents} />
|
||||
<div className="pl-2.5">
|
||||
<SlotAgentLinesHeader label={agentCountLabel} onCollapse={toggleAgents} />
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-[3px] min-w-0 pr-2 pl-1">
|
||||
<div className="mt-[3px] min-w-0 pr-1.5 pl-3.5">
|
||||
<CompactAgentSummaryButton
|
||||
agents={lines.map((line) => line.agent)}
|
||||
subjectLabel={agentCountLabel}
|
||||
@@ -194,23 +196,14 @@ export function WorktreeCardSlotContent({
|
||||
agentLines
|
||||
)}
|
||||
{showLineageChildChip ? (
|
||||
<div className="mt-[3px] flex min-w-0 pl-1">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="chip"
|
||||
className="max-w-40 text-muted-foreground"
|
||||
aria-label={lineageChildAriaLabel}
|
||||
aria-expanded={!lineageCollapsed}
|
||||
onClick={onLineageToggle}
|
||||
>
|
||||
{lineageCollapsed ? (
|
||||
<ChevronRight className="size-2.5" />
|
||||
) : (
|
||||
<ChevronDown className="size-2.5" />
|
||||
)}
|
||||
<span className="truncate">{childWorkspaceShortLabel}</span>
|
||||
</Button>
|
||||
<div className="mt-[3px] flex min-w-0 pr-1.5 pl-3.5">
|
||||
<WorktreeCardChildrenChip
|
||||
childWorktreeIds={lineageChildWorktreeIds}
|
||||
collapsed={lineageCollapsed}
|
||||
label={childWorkspaceShortLabel}
|
||||
ariaLabel={lineageChildAriaLabel}
|
||||
onToggle={onLineageToggle}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
@@ -227,7 +220,7 @@ export function WorktreeCardSlotContent({
|
||||
className="group/worktree-card flex min-w-0 flex-col"
|
||||
data-worktree-card-hover-trigger=""
|
||||
>
|
||||
<div className="flex h-5 min-w-0 items-center gap-1.5 pr-2">
|
||||
<div className="flex h-5 min-w-0 items-center gap-1.5 pr-1.5">
|
||||
<WorktreeTitleInlineRename
|
||||
displayName={visibleCardTitle}
|
||||
disabled={isDeleting || affiliateListMode}
|
||||
@@ -259,7 +252,7 @@ export function WorktreeCardSlotContent({
|
||||
</div>
|
||||
{showDetailsRow ? (
|
||||
<div
|
||||
className="mt-1 mb-1 flex h-4 min-w-0 items-center gap-1.5 pr-2 pl-1"
|
||||
className="mt-1 mb-1 flex h-4 min-w-0 items-center gap-1.5 pr-1.5 pl-3.5"
|
||||
data-worktree-card-details-row=""
|
||||
>
|
||||
{presentation.hasMetaRow ? (
|
||||
|
||||
+1
-1
@@ -108,7 +108,7 @@ export function IdleChildAgentGroupRow({
|
||||
className={cn(
|
||||
'worktree-agent-row-hover',
|
||||
layout === 'slot'
|
||||
? 'flex h-[19px] w-full min-w-0 items-center gap-1.5 rounded-sm pr-2 pl-1 text-left text-2xs leading-none'
|
||||
? 'flex h-[19px] w-full min-w-0 items-center gap-1.5 rounded-sm pr-1.5 pl-1 text-left text-2xs leading-none'
|
||||
: 'flex h-6 min-w-0 items-center gap-1 rounded-sm text-left text-[11px] leading-none',
|
||||
layout === 'full' && '-ml-1 w-[calc(100%+0.25rem)] pl-1 pr-2',
|
||||
layout === 'compact' && 'w-full px-1',
|
||||
|
||||
+17
-19
@@ -60,7 +60,11 @@ export function WorktreeCardSurface({ card }: { card: WorktreeCardController }):
|
||||
: undefined
|
||||
}
|
||||
)
|
||||
const parentCardContent = slotLayout ? <WorktreeCardSlotContent card={card} presentation={buildWorktreeCardPresentation(card)} /> : templateContent
|
||||
const parentCardContent = slotLayout ? (
|
||||
<WorktreeCardSlotContent card={card} presentation={buildWorktreeCardPresentation(card)} />
|
||||
) : (
|
||||
templateContent
|
||||
)
|
||||
const { cardStyle } = geometryFor(!slotLayout && frame.statusLane)
|
||||
const titleOnlyCard = frame.singleLine
|
||||
|
||||
@@ -68,19 +72,16 @@ export function WorktreeCardSurface({ card }: { card: WorktreeCardController }):
|
||||
<div
|
||||
className={cn(
|
||||
'relative flex cursor-pointer flex-col transition-[background-color,border-color,opacity,box-shadow] duration-200 outline-none select-none',
|
||||
// Why: 2.0 rows own their right inset and the surface has no border, so nested slots share one right edge.
|
||||
slotLayout ? 'py-1.25' : ['pr-1.5', titleOnlyCard ? 'py-2' : 'pt-1.25 pb-1.5'],
|
||||
'pr-1.5',
|
||||
titleOnlyCard ? 'py-2' : 'pt-1.25 pb-1.5',
|
||||
flushSurface ? 'ml-1 w-[calc(100%-0.25rem)]' : 'ml-1',
|
||||
'overflow-hidden rounded-lg',
|
||||
// Navigation updates the live attribute before React; it must own the complete active style.
|
||||
isLineageDropTarget
|
||||
? [
|
||||
'bg-worktree-sidebar-accent text-worktree-sidebar-accent-foreground ring-1 ring-inset ring-worktree-sidebar-ring/60',
|
||||
!slotLayout && 'border border-worktree-sidebar-foreground/40'
|
||||
]
|
||||
? 'border border-worktree-sidebar-foreground/40 bg-worktree-sidebar-accent text-worktree-sidebar-accent-foreground ring-1 ring-inset ring-worktree-sidebar-ring/60'
|
||||
: isActiveSurface
|
||||
? !slotLayout && 'border border-transparent'
|
||||
: [!slotLayout && 'border border-transparent', 'worktree-sidebar-card-hover'],
|
||||
? 'border border-transparent'
|
||||
: 'border border-transparent worktree-sidebar-card-hover',
|
||||
isActiveSurface && isMultiSelected && 'ring-1 ring-worktree-sidebar-ring/35',
|
||||
revealHighlight && [
|
||||
'scroll-to-current-workspace-reveal-highlight',
|
||||
@@ -127,17 +128,14 @@ export function WorktreeCardSurface({ card }: { card: WorktreeCardController }):
|
||||
parentCardContent
|
||||
)}
|
||||
|
||||
{lineageChildren && slotLayout ? (
|
||||
// Why: 5px guide + 3px + the child's 4px margin and 2px inset puts child names 14px in.
|
||||
{lineageChildren ? (
|
||||
<div
|
||||
className="worktree-card-slot-lineage mt-1 ml-[5px] space-y-0.5 pl-[3px]"
|
||||
data-worktree-lineage-children=""
|
||||
>
|
||||
{lineageChildren}
|
||||
</div>
|
||||
) : lineageChildren ? (
|
||||
<div
|
||||
className={cn('space-y-1', newCardStyle ? 'mt-1.5' : 'mt-3')}
|
||||
className={cn(
|
||||
'space-y-1',
|
||||
newCardStyle ? 'mt-1.5' : 'mt-3',
|
||||
// 2.0 draws a guide in the original offset; it's painted, so the layout is unchanged.
|
||||
slotLayout && 'worktree-card-slot-lineage'
|
||||
)}
|
||||
data-worktree-lineage-children=""
|
||||
style={lineageChildrenStyle}
|
||||
>
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import React from 'react'
|
||||
import { ChevronDown, CircleX } from 'lucide-react'
|
||||
import { AgentQuestionIcon } from '@/components/AgentQuestionIcon'
|
||||
import { AgentWorkingSpinner } from '@/components/AgentWorkingSpinner'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useLineageChildCounts, type LineageChildCounts } from './worktree-card-slot-rollup'
|
||||
|
||||
const EMPTY_CHILD_IDS: readonly string[] = []
|
||||
|
||||
// Most urgent first, matching the slot's priority.
|
||||
const ORDER: readonly (keyof LineageChildCounts)[] = ['needs', 'failed', 'unread', 'working']
|
||||
|
||||
function countLabel(kind: keyof LineageChildCounts, count: number): string {
|
||||
switch (kind) {
|
||||
case 'needs':
|
||||
return translate('sidebar.cardSlot.childNeeds', '{{count}} need you', {
|
||||
count,
|
||||
defaultValue_one: '{{count}} needs you'
|
||||
})
|
||||
case 'failed':
|
||||
return translate('sidebar.cardSlot.childFailed', '{{count}} failed', { count })
|
||||
case 'unread':
|
||||
return translate('sidebar.cardSlot.childUnread', '{{count}} unread', { count })
|
||||
case 'working':
|
||||
return translate('sidebar.cardSlot.childWorking', '{{count}} working', { count })
|
||||
}
|
||||
}
|
||||
|
||||
// The slot's glyphs, so a count reads the same as the row it stands for.
|
||||
function CountGlyph({ kind }: { kind: keyof LineageChildCounts }): React.JSX.Element {
|
||||
switch (kind) {
|
||||
case 'needs':
|
||||
return <AgentQuestionIcon className="size-2.5 shrink-0" />
|
||||
case 'failed':
|
||||
return <CircleX className="size-2.5 shrink-0 text-destructive" aria-hidden />
|
||||
case 'working':
|
||||
return <AgentWorkingSpinner className="size-2 border-[1.5px]" />
|
||||
case 'unread':
|
||||
return <span className="size-1.5 shrink-0 rounded-full bg-unread" />
|
||||
}
|
||||
}
|
||||
|
||||
/** "6 children", then only the children that need attention, so a quiet subtree stays short. */
|
||||
export function WorktreeCardChildrenChip({
|
||||
childWorktreeIds = EMPTY_CHILD_IDS,
|
||||
collapsed,
|
||||
label,
|
||||
ariaLabel,
|
||||
onToggle
|
||||
}: {
|
||||
childWorktreeIds?: readonly string[]
|
||||
collapsed: boolean
|
||||
label: string
|
||||
ariaLabel: string
|
||||
onToggle?: React.MouseEventHandler<HTMLButtonElement>
|
||||
}): React.JSX.Element {
|
||||
const counts = useLineageChildCounts(childWorktreeIds)
|
||||
const shown = ORDER.filter((kind) => counts[kind] > 0)
|
||||
const description = shown.map((kind) => countLabel(kind, counts[kind])).join(', ')
|
||||
return (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="chip"
|
||||
className="max-w-full min-w-0 gap-1 text-muted-foreground"
|
||||
aria-label={description ? `${ariaLabel}. ${description}` : ariaLabel}
|
||||
aria-expanded={!collapsed}
|
||||
title={description || undefined}
|
||||
onClick={onToggle}
|
||||
>
|
||||
<ChevronDown
|
||||
className={cn('size-2.5 shrink-0 transition-transform', collapsed && '-rotate-90')}
|
||||
aria-hidden
|
||||
/>
|
||||
<span className="truncate">{label}</span>
|
||||
{shown.length > 0 ? (
|
||||
<span
|
||||
className="inline-flex shrink-0 items-center gap-1 border-l border-border pl-1"
|
||||
aria-hidden
|
||||
>
|
||||
{shown.map((kind) => (
|
||||
<span key={kind} className="inline-flex items-center gap-0.5 tabular-nums">
|
||||
<CountGlyph kind={kind} />
|
||||
{counts[kind]}
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
) : null}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
@@ -188,7 +188,7 @@ export const SlotAgentLineRow = React.memo(function SlotAgentLineRow({
|
||||
aria-expanded={hasChildren ? childrenExpanded : undefined}
|
||||
aria-current={view?.current ? 'true' : undefined}
|
||||
className={cn(
|
||||
'group/slot-line worktree-agent-row-hover flex h-[19px] w-full min-w-0 cursor-pointer items-center gap-1.5 rounded-sm pr-2 pl-1 text-left text-2xs leading-none outline-none focus-visible:ring-1 focus-visible:ring-worktree-sidebar-ring',
|
||||
'group/slot-line worktree-agent-row-hover flex h-[19px] w-full min-w-0 cursor-pointer items-center gap-1.5 rounded-sm pr-1.5 pl-1 text-left text-2xs leading-none outline-none focus-visible:ring-1 focus-visible:ring-worktree-sidebar-ring',
|
||||
highlighted && 'bg-worktree-sidebar-accent',
|
||||
sendTarget?.status === 'sending' && 'cursor-progress opacity-75',
|
||||
sendTarget?.status === 'disabled' && 'cursor-default opacity-60'
|
||||
|
||||
@@ -51,7 +51,7 @@ export function buildSlotAgentSignals(
|
||||
lineByPaneKey.set(agent.paneKey, {
|
||||
agent,
|
||||
dotState,
|
||||
signal: { kind, at: slotLineAt(agent, kind) }
|
||||
signal: { kind, at: slotLineAt(agent, kind), monitoring: dotState === 'monitoring' }
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
import { useAppStore } from '@/store'
|
||||
import type { AgentStatusEntry } from '../../../../shared/agent-status/contracts/agent-status-types'
|
||||
import type { TerminalTab } from '../../../../shared/workspace-layout/terminal-tab-types'
|
||||
import type { Worktree } from '../../../../shared/worktree/types'
|
||||
import { worktreeCardStyleSettingsUpdate } from '../../../../shared/worktree/card-style'
|
||||
import * as agentExpansion from './workspace-card/activity/worktree-card-agents-expansion-state'
|
||||
|
||||
/** Store seeding for the 2.0 card fixtures: workspaces, their agents, and which results are read. */
|
||||
export const MINUTE = 60_000
|
||||
|
||||
type AgentSeed = {
|
||||
agentType: string
|
||||
state: AgentStatusEntry['state']
|
||||
minutesAgo: number
|
||||
toolName?: string
|
||||
toolInput?: string
|
||||
message?: string
|
||||
failed?: boolean
|
||||
unread?: boolean
|
||||
prompt?: string
|
||||
}
|
||||
|
||||
export type WorkspaceSeed = {
|
||||
id: string
|
||||
name: string
|
||||
agents: AgentSeed[]
|
||||
manualUnread?: boolean
|
||||
lastActivityMinutesAgo?: number
|
||||
}
|
||||
|
||||
export function makeWorktree(seed: WorkspaceSeed, now: number): Worktree {
|
||||
return {
|
||||
id: seed.id,
|
||||
repoId: 'repo-orca',
|
||||
path: `/repo/orca/${seed.name}`,
|
||||
displayName: seed.name,
|
||||
branch: `jinjing/${seed.name}`,
|
||||
head: 'abc123',
|
||||
isBare: false,
|
||||
isMainWorktree: false,
|
||||
comment: '',
|
||||
linkedIssue: null,
|
||||
linkedPR: null,
|
||||
linkedLinearIssue: null,
|
||||
isArchived: false,
|
||||
isUnread: seed.manualUnread === true,
|
||||
isPinned: false,
|
||||
sortOrder: 0,
|
||||
lastActivityAt: now - (seed.lastActivityMinutesAgo ?? 30) * MINUTE
|
||||
}
|
||||
}
|
||||
|
||||
export function seedWorkspaces(seeds: readonly WorkspaceSeed[], now: number): void {
|
||||
agentExpansion.clearWorktreeAgentExpansionStateForTests()
|
||||
const tabsByWorktree: Record<string, TerminalTab[]> = {}
|
||||
const agentStatusByPaneKey: Record<string, AgentStatusEntry> = {}
|
||||
const unreadAgentCompletionPanes: Record<string, 'agent-completion'> = {}
|
||||
const acknowledged: Record<string, number> = {}
|
||||
const ptyIdsByTabId: Record<string, string[]> = {}
|
||||
for (const seed of seeds) {
|
||||
tabsByWorktree[seed.id] = seed.agents.map((agent, index) => {
|
||||
const tabId = `${seed.id}-tab-${index}`
|
||||
const paneKey = `${tabId}:00000000-0000-4000-8000-00000000000${index}`
|
||||
agentStatusByPaneKey[paneKey] = {
|
||||
state: agent.state,
|
||||
prompt: agent.prompt ?? '',
|
||||
updatedAt: now,
|
||||
stateStartedAt: now - agent.minutesAgo * MINUTE,
|
||||
agentType: agent.agentType,
|
||||
paneKey,
|
||||
tabId,
|
||||
worktreeId: seed.id,
|
||||
stateHistory: [],
|
||||
...(agent.toolName ? { toolName: agent.toolName, toolInput: agent.toolInput } : {}),
|
||||
...(agent.message ? { lastAssistantMessage: agent.message } : {}),
|
||||
...(agent.failed
|
||||
? { mainAgent: { state: 'done', outcome: 'failure', stateStartedAt: now } }
|
||||
: {})
|
||||
}
|
||||
ptyIdsByTabId[tabId] = [`pty-${tabId}`]
|
||||
if (agent.unread) {
|
||||
unreadAgentCompletionPanes[paneKey] = 'agent-completion'
|
||||
} else {
|
||||
acknowledged[paneKey] = now - agent.minutesAgo * MINUTE
|
||||
}
|
||||
return {
|
||||
id: tabId,
|
||||
ptyId: `pty-${tabId}`,
|
||||
worktreeId: seed.id,
|
||||
title: agent.agentType,
|
||||
customTitle: null,
|
||||
color: null,
|
||||
sortOrder: index,
|
||||
createdAt: now - 60 * MINUTE
|
||||
}
|
||||
})
|
||||
}
|
||||
useAppStore.setState((state) => ({
|
||||
settings: state.settings
|
||||
? {
|
||||
...state.settings,
|
||||
...worktreeCardStyleSettingsUpdate('experimental-2')
|
||||
}
|
||||
: state.settings,
|
||||
worktreeCardProperties: ['status', 'inline-agents'],
|
||||
tabsByWorktree: { ...state.tabsByWorktree, ...tabsByWorktree },
|
||||
ptyIdsByTabId: { ...state.ptyIdsByTabId, ...ptyIdsByTabId },
|
||||
agentStatusByPaneKey: { ...state.agentStatusByPaneKey, ...agentStatusByPaneKey },
|
||||
acknowledgedAgentsByPaneKey: { ...state.acknowledgedAgentsByPaneKey, ...acknowledged },
|
||||
agentStatusEpoch: state.agentStatusEpoch + 1,
|
||||
unreadAgentCompletionPanes: {
|
||||
...state.unreadAgentCompletionPanes,
|
||||
...unreadAgentCompletionPanes
|
||||
}
|
||||
}))
|
||||
}
|
||||
@@ -37,13 +37,15 @@ export function selectCollapsedLineageSlot(
|
||||
if (!isExplicitAgentStatusFresh(entry, now, AGENT_STATUS_STALE_AFTER_MS)) {
|
||||
continue
|
||||
}
|
||||
const dotState = agentRowDisplayDotState({ state: entry.state, entry })
|
||||
lines.push({
|
||||
kind: classifySlotLine({
|
||||
dotState: agentRowDisplayDotState({ state: entry.state, entry }),
|
||||
dotState,
|
||||
needsAttention: false,
|
||||
unreadCompletion: isAgentSubjectUnread(turnRecords, entry.paneKey)
|
||||
}),
|
||||
at: entry.stateStartedAt > 0 ? entry.stateStartedAt : null
|
||||
at: entry.stateStartedAt > 0 ? entry.stateStartedAt : null,
|
||||
monitoring: dotState === 'monitoring'
|
||||
})
|
||||
}
|
||||
for (const retained of selectRetainedAgentEntriesForWorktree(state, worktreeId)) {
|
||||
@@ -107,3 +109,29 @@ export function useLineageLatestAt(descendantIds: readonly string[]): number | n
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
export type LineageChildCounts = { needs: number; failed: number; unread: number; working: number }
|
||||
|
||||
/** Direct children counted once each by their most urgent exception; quiet children aren't counted. */
|
||||
export function selectLineageChildCounts(
|
||||
state: AppState,
|
||||
childIds: readonly string[]
|
||||
): LineageChildCounts {
|
||||
const counts: LineageChildCounts = { needs: 0, failed: 0, unread: 0, working: 0 }
|
||||
for (const childId of childIds) {
|
||||
const signal = selectCollapsedLineageSlot(state, [childId])
|
||||
if (signal && signal.kind in counts) {
|
||||
counts[signal.kind as keyof LineageChildCounts] += 1
|
||||
}
|
||||
}
|
||||
return counts
|
||||
}
|
||||
|
||||
export function useLineageChildCounts(childIds: readonly string[]): LineageChildCounts {
|
||||
const childKey = childIds.join('\n')
|
||||
const selector = useCallback(
|
||||
(state: AppState) => selectLineageChildCounts(state, childKey ? childKey.split('\n') : []),
|
||||
[childKey]
|
||||
)
|
||||
return useAppStore(useShallow(selector))
|
||||
}
|
||||
|
||||
@@ -5,14 +5,15 @@ import { lastEnteredDoneAt } from '@/components/dashboard/agent-finished-timesta
|
||||
/** What one agent line means for the Experimental 2.0 card, in slot priority order. */
|
||||
export type SlotLineKind = 'needs' | 'failed' | 'unread' | 'working' | 'seen' | 'idle'
|
||||
|
||||
export type SlotLineSignal = { kind: SlotLineKind; at: number | null }
|
||||
/** `monitoring`: working, but watching rather than taking turns; drawn with its own glyph. */
|
||||
export type SlotLineSignal = { kind: SlotLineKind; at: number | null; monitoring?: boolean }
|
||||
|
||||
/** One right-hand slot: the first exception that applies, otherwise a time, or quiet. */
|
||||
export type SlotSignal =
|
||||
| { kind: 'needs'; count: number }
|
||||
| { kind: 'failed' }
|
||||
| { kind: 'unread'; at: number | null; manual: boolean }
|
||||
| { kind: 'working'; at: number | null }
|
||||
| { kind: 'working'; at: number | null; monitoring?: boolean }
|
||||
| { kind: 'offline' }
|
||||
| { kind: 'time'; at: number | null }
|
||||
| { kind: 'quiet' }
|
||||
@@ -93,7 +94,11 @@ export function slotExceptionFor(lines: readonly SlotLineSignal[]): SlotSignal |
|
||||
}
|
||||
const working = lines.filter((line) => line.kind === 'working')
|
||||
if (working.length > 0) {
|
||||
return { kind: 'working', at: latest(working) }
|
||||
return {
|
||||
kind: 'working',
|
||||
at: latest(working),
|
||||
monitoring: working.every((line) => line.monitoring === true)
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
+4
@@ -2531,6 +2531,10 @@
|
||||
"working_other": "{{count}} working"
|
||||
}
|
||||
},
|
||||
"cardSlot": {
|
||||
"childNeeds_one": "{{count}} needs you",
|
||||
"childNeeds_other": "{{count}} need you"
|
||||
},
|
||||
"createProject": {
|
||||
"errors": {
|
||||
"createDirectoryFailed": "Failed to create directory: {{detail}}",
|
||||
|
||||
@@ -259,6 +259,7 @@
|
||||
"markedUnread": "Marked unread by you",
|
||||
"unread": "Unread result",
|
||||
"working": "Working",
|
||||
"monitoring": "Monitoring",
|
||||
"offlineLabel": "Host offline",
|
||||
"offline": "offline",
|
||||
"needYouCount": "{{count}} need you",
|
||||
@@ -268,7 +269,12 @@
|
||||
"detailsRow": "Details row",
|
||||
"hostRemoved": "host removed",
|
||||
"disconnected": "disconnected",
|
||||
"orcaServer": "Orca server"
|
||||
"orcaServer": "Orca server",
|
||||
"childNeeds_one": "{{count}} needs you",
|
||||
"childNeeds_other": "{{count}} need you",
|
||||
"childFailed": "{{count}} failed",
|
||||
"childUnread": "{{count}} unread",
|
||||
"childWorking": "{{count}} working"
|
||||
}
|
||||
},
|
||||
"app": {
|
||||
|
||||
Reference in New Issue
Block a user