diff --git a/src/renderer/src/assets/main.css b/src/renderer/src/assets/main.css index 0a800526dfa..f8391d83dde 100644 --- a/src/renderer/src/assets/main.css +++ b/src/renderer/src/assets/main.css @@ -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; diff --git a/src/renderer/src/components/sidebar/WorktreeCardSlot.tsx b/src/renderer/src/components/sidebar/WorktreeCardSlot.tsx index f409537f168..131a9c18d7b 100644 --- a/src/renderer/src/components/sidebar/WorktreeCardSlot.tsx +++ b/src/renderer/src/components/sidebar/WorktreeCardSlot.tsx @@ -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. */} + {signal.count > 1 ? translate('sidebar.cardSlot.needYouCount', '{{count}} need you', { count: signal.count @@ -80,6 +86,8 @@ export function WorktreeCardSlot({ case 'failed': return ( + {/* A crossed circle, not a red dot, so failed never reads like the blue unread dot. */} + ) @@ -93,7 +101,11 @@ export function WorktreeCardSlot({ case 'working': return ( - + {signal.monitoring ? ( + ) diff --git a/src/renderer/src/components/sidebar/WorktreeCardSlotAgentLines.tsx b/src/renderer/src/components/sidebar/WorktreeCardSlotAgentLines.tsx index f3f36e6b028..ac481690382 100644 --- a/src/renderer/src/components/sidebar/WorktreeCardSlotAgentLines.tsx +++ b/src/renderer/src/components/sidebar/WorktreeCardSlotAgentLines.tsx @@ -31,7 +31,7 @@ export function SlotAgentLinesHeader({ +
+
) : null} @@ -227,7 +220,7 @@ export function WorktreeCardSlotContent({ className="group/worktree-card flex min-w-0 flex-col" data-worktree-card-hover-trigger="" > -
+
{showDetailsRow ? (
{presentation.hasMetaRow ? ( diff --git a/src/renderer/src/components/sidebar/workspace-card/activity/worktree-card-idle-child-agents.tsx b/src/renderer/src/components/sidebar/workspace-card/activity/worktree-card-idle-child-agents.tsx index 3c4c67f5de9..06087b14b19 100644 --- a/src/renderer/src/components/sidebar/workspace-card/activity/worktree-card-idle-child-agents.tsx +++ b/src/renderer/src/components/sidebar/workspace-card/activity/worktree-card-idle-child-agents.tsx @@ -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', diff --git a/src/renderer/src/components/sidebar/workspace-card/surface/worktree-card-surface.tsx b/src/renderer/src/components/sidebar/workspace-card/surface/worktree-card-surface.tsx index 57afd9dba5f..60ef696ade1 100644 --- a/src/renderer/src/components/sidebar/workspace-card/surface/worktree-card-surface.tsx +++ b/src/renderer/src/components/sidebar/workspace-card/surface/worktree-card-surface.tsx @@ -60,7 +60,11 @@ export function WorktreeCardSurface({ card }: { card: WorktreeCardController }): : undefined } ) - const parentCardContent = slotLayout ? : templateContent + const parentCardContent = slotLayout ? ( + + ) : ( + templateContent + ) const { cardStyle } = geometryFor(!slotLayout && frame.statusLane) const titleOnlyCard = frame.singleLine @@ -68,19 +72,16 @@ export function WorktreeCardSurface({ card }: { card: WorktreeCardController }):
- {lineageChildren} -
- ) : lineageChildren ? ( -
diff --git a/src/renderer/src/components/sidebar/worktree-card-children-chip.tsx b/src/renderer/src/components/sidebar/worktree-card-children-chip.tsx new file mode 100644 index 00000000000..1fa689cdeb6 --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-card-children-chip.tsx @@ -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 + case 'failed': + return + case 'working': + return + case 'unread': + return + } +} + +/** "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 +}): 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 ( + + ) +} diff --git a/src/renderer/src/components/sidebar/worktree-card-slot-agent-line-row.tsx b/src/renderer/src/components/sidebar/worktree-card-slot-agent-line-row.tsx index fcf58ec10c1..c33d9c1c30b 100644 --- a/src/renderer/src/components/sidebar/worktree-card-slot-agent-line-row.tsx +++ b/src/renderer/src/components/sidebar/worktree-card-slot-agent-line-row.tsx @@ -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' diff --git a/src/renderer/src/components/sidebar/worktree-card-slot-agent-signals.ts b/src/renderer/src/components/sidebar/worktree-card-slot-agent-signals.ts index 96279285b04..5d43f70a35b 100644 --- a/src/renderer/src/components/sidebar/worktree-card-slot-agent-signals.ts +++ b/src/renderer/src/components/sidebar/worktree-card-slot-agent-signals.ts @@ -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' } }) }) diff --git a/src/renderer/src/components/sidebar/worktree-card-slot-fixture-seeds.ts b/src/renderer/src/components/sidebar/worktree-card-slot-fixture-seeds.ts new file mode 100644 index 00000000000..151c12e1f2e --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-card-slot-fixture-seeds.ts @@ -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 = {} + const agentStatusByPaneKey: Record = {} + const unreadAgentCompletionPanes: Record = {} + const acknowledged: Record = {} + const ptyIdsByTabId: Record = {} + 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 + } + })) +} diff --git a/src/renderer/src/components/sidebar/worktree-card-slot-rollup.ts b/src/renderer/src/components/sidebar/worktree-card-slot-rollup.ts index 27b36592077..ac36252a887 100644 --- a/src/renderer/src/components/sidebar/worktree-card-slot-rollup.ts +++ b/src/renderer/src/components/sidebar/worktree-card-slot-rollup.ts @@ -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)) +} diff --git a/src/renderer/src/components/sidebar/worktree-card-slot-signal.ts b/src/renderer/src/components/sidebar/worktree-card-slot-signal.ts index 8b736f55157..77e31ed154b 100644 --- a/src/renderer/src/components/sidebar/worktree-card-slot-signal.ts +++ b/src/renderer/src/components/sidebar/worktree-card-slot-signal.ts @@ -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 } diff --git a/src/renderer/src/i18n/en-runtime-required.json b/src/renderer/src/i18n/en-runtime-required.json index d2030d20df8..805610d2680 100644 --- a/src/renderer/src/i18n/en-runtime-required.json +++ b/src/renderer/src/i18n/en-runtime-required.json @@ -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}}", diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 2b60844b3de..62cfc995edb 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -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": {