Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 68 additions & 0 deletions src/renderer/src/workspace/dispatch/DispatchLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,14 @@ const DispatchAgentListRow = memo(function DispatchAgentListRow({
workContext: current?.workContext,
workActivity: current?.workActivity,
entries: current?.entries,
unreadSince: current?.unreadSince,
unreadKind: current?.unreadKind,
pendingApproval: current?.pendingApproval,
pendingTrustDialog: current?.pendingTrustDialog,
pendingResumePrompt: current?.pendingResumePrompt,
pendingPermissionPrompt: current?.pendingPermissionPrompt,
pendingCompaction: current?.pendingCompaction,
processError: current?.processError,
}
}))
const onSelect = useCallback(() => {
Expand All @@ -218,6 +226,12 @@ const DispatchAgentListRow = memo(function DispatchAgentListRow({
const title = runtime.entries
? cachedLatestPromptTitle(runtime.entries, row.kind) ?? row.title
: row.title
const attentionLabel = dispatchAttentionLabel(runtime)
const unreadKind = attentionLabel
? 'attention'
: runtime.unreadKind === 'attention'
? 'output'
: runtime.unreadKind

return (
<button
Expand All @@ -241,6 +255,9 @@ const DispatchAgentListRow = memo(function DispatchAgentListRow({
<WorktreeBadge context={runtime?.workContext} activity={runtime?.workActivity} />
)}
<AgentTypeBadge kind={row.kind} />
{unreadKind && (
<DispatchUnreadBadge kind={unreadKind} label={attentionLabel} />
)}
</div>
<div className="mt-1 pl-7 text-[10px] text-muted truncate">
{subtitle}
Expand All @@ -265,6 +282,7 @@ function dispatchSubtitle(runtime: {
sessionStatus?: string
streamPhase?: string
exited?: number | null
unreadSince?: number | null
}): string {
if (runtime.sessionStatus === undefined) return 'starting'
if (runtime.streamPhase && runtime.streamPhase !== 'idle') return runtime.streamPhase
Expand All @@ -273,6 +291,56 @@ function dispatchSubtitle(runtime: {
return 'idle'
}

function dispatchAttentionLabel(runtime: {
pendingApproval?: unknown
pendingTrustDialog?: unknown
pendingResumePrompt?: unknown
pendingPermissionPrompt?: unknown
pendingCompaction?: { phase?: string } | null
processError?: string | null
}): string | null {
if (runtime.pendingPermissionPrompt) return 'ACTION'
if (runtime.pendingApproval) return 'ACTION'
if (runtime.pendingTrustDialog) return 'TRUST'
if (runtime.pendingResumePrompt) return 'RESUME'
if (runtime.pendingCompaction?.phase === 'error') return 'ERROR'
if (runtime.processError) return 'ERROR'
return null
}

function DispatchUnreadBadge({
kind,
label,
}: {
kind: 'output' | 'attention'
label: string | null
}) {
if (kind === 'attention') {
return (
<span
className="
flex-shrink-0 rounded-sm border border-amber-300/70 bg-amber-400/20
px-1.5 py-[1px] text-[9px] font-semibold leading-none text-amber-100
shadow-[0_0_12px_rgba(251,191,36,0.22)]
"
>
{label ?? 'ACTION'}
</span>
)
}
return (
<span
className="
flex-shrink-0 rounded-sm border border-accent/70 bg-accent/20
px-1.5 py-[1px] text-[9px] font-semibold leading-none text-accent
shadow-[0_0_12px_rgba(56,189,248,0.18)]
"
>
NEW
</span>
)
}

function dispatchActivity(runtime: {
sessionStatus?: string
streamPhase?: string
Expand Down
20 changes: 18 additions & 2 deletions src/renderer/src/workspace/hook/actions/pane.ts
Original file line number Diff line number Diff line change
Expand Up @@ -690,13 +690,21 @@ export function usePaneActions(
t.id === prev.activeTabId ? { ...t, focusedSessionId: sessionId } : t,
),
}))
setRuntimes(prev => {
const current = prev[sessionId]
if (!current || (current.unreadSince === null && current.unreadKind === null)) return prev
return {
...prev,
[sessionId]: { ...current, unreadSince: null, unreadKind: null },
}
})
setSpotlight(prev => (
prev && prev.tabId === refs.stateRef.current.activeTabId
? { ...prev, focusedSessionId: sessionId }
: prev
))
},
[refs.stateRef, setSpotlight, setState],
[refs.stateRef, setRuntimes, setSpotlight, setState],
)

const focusSessionInTab = useCallback(
Expand All @@ -708,6 +716,14 @@ export function usePaneActions(
t.id === tabId ? { ...t, focusedSessionId: sessionId } : t,
),
}))
setRuntimes(prev => {
const current = prev[sessionId]
if (!current || (current.unreadSince === null && current.unreadKind === null)) return prev
return {
...prev,
[sessionId]: { ...current, unreadSince: null, unreadKind: null },
}
})
setSpotlight(prev => (
prev && prev.tabId === tabId
? { ...prev, focusedSessionId: sessionId }
Expand All @@ -719,7 +735,7 @@ export function usePaneActions(
: prev
))
},
[setSpotlight, setState, setTileTabs],
[setRuntimes, setSpotlight, setState, setTileTabs],
)

const navigate = useCallback(
Expand Down
107 changes: 90 additions & 17 deletions src/renderer/src/workspace/hook/ipc/useIpcSubscriptions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -243,6 +243,33 @@ export function useIpcSubscriptions(
})
}

const isFocusedSession = (sessionId: SessionId): boolean => {
const snap = refs.stateRef.current
const activeTab = snap.tabs.find(tab => tab.id === snap.activeTabId)
return activeTab?.focusedSessionId === sessionId
}

const withUnread = (
sessionId: SessionId,
runtime: SessionRuntime,
kind: 'output' | 'attention',
): SessionRuntime => {
if (isFocusedSession(sessionId)) return runtime
// Attention outranks ordinary output: once a permission/trust
// prompt appears, the list should keep showing ACTION until
// the user opens that agent or the prompt resolves. A later
// transcript append must not downgrade the marker to NEW.
const unreadKind =
runtime.unreadKind === 'attention' || kind === 'attention'
? 'attention'
: 'output'
return {
...runtime,
unreadSince: runtime.unreadSince ?? Date.now(),
unreadKind,
}
}

const offStarted = window.api.onSessionStarted(({ sessionId, projectDir }) => {
updateRuntime(sessionId, {
projectDir,
Expand Down Expand Up @@ -392,10 +419,14 @@ export function useIpcSubscriptions(
// writer.
pendingApproval: nextApproval,
}
const approvalOpened = current.pendingApproval === null && nextApproval !== null
const nextBodyWithUnread = approvalOpened
? withUnread(sessionId, nextBody, 'attention')
: nextBody
const nextCurrent = chromeTickOnly
? nextBody
? nextBodyWithUnread
: appendFeedDebugLog(
nextBody,
nextBodyWithUnread,
{
layer: 'STATE',
kind: 'screen_update',
Expand Down Expand Up @@ -756,8 +787,16 @@ export function useIpcSubscriptions(

const offTrustDialog = window.api.onSessionTrustDialog(
({ sessionId, visible, workspace }) => {
updateRuntime(sessionId, {
pendingTrustDialog: visible ? { workspace } : null,
setRuntimes(prev => {
const current = prev[sessionId] ?? emptyRuntime()
const next = {
...current,
pendingTrustDialog: visible ? { workspace } : null,
}
return {
...prev,
[sessionId]: visible ? withUnread(sessionId, next, 'attention') : next,
}
})
},
)
Expand All @@ -770,10 +809,18 @@ export function useIpcSubscriptions(
options,
selectedIndex,
}) => {
updateRuntime(sessionId, {
pendingResumePrompt: visible
? { sessionAgeText, tokenCountText, options, selectedIndex }
: null,
setRuntimes(prev => {
const current = prev[sessionId] ?? emptyRuntime()
const next = {
...current,
pendingResumePrompt: visible
? { sessionAgeText, tokenCountText, options, selectedIndex }
: null,
}
return {
...prev,
[sessionId]: visible ? withUnread(sessionId, next, 'attention') : next,
}
})
})

Expand All @@ -786,10 +833,18 @@ export function useIpcSubscriptions(
options,
selectedIndex,
}) => {
updateRuntime(sessionId, {
pendingPermissionPrompt: visible
? { title, toolName, command, options, selectedIndex }
: null,
setRuntimes(prev => {
const current = prev[sessionId] ?? emptyRuntime()
const next = {
...current,
pendingPermissionPrompt: visible
? { title, toolName, command, options, selectedIndex }
: null,
}
return {
...prev,
[sessionId]: visible ? withUnread(sessionId, next, 'attention') : next,
}
})
})

Expand All @@ -800,10 +855,20 @@ export function useIpcSubscriptions(
statusText,
errorText,
}) => {
updateRuntime(sessionId, {
pendingCompaction: visible && phase
? { phase, statusText, errorText }
: null,
setRuntimes(prev => {
const current = prev[sessionId] ?? emptyRuntime()
const next = {
...current,
pendingCompaction: visible && phase
? { phase, statusText, errorText }
: null,
}
return {
...prev,
[sessionId]: visible && phase === 'error'
? withUnread(sessionId, next, 'attention')
: next,
}
})
})

Expand Down Expand Up @@ -895,6 +960,7 @@ export function useIpcSubscriptions(
let awaitingAssistant = current.awaitingAssistant
let workActivity = current.workActivity
let workContext = current.workContext
let approvalOpened = false
const cachedWorktrees = sessionCwd ? worktreeCache.get(sessionCwd) : undefined
const hasWorktreeCache = !!cachedWorktrees && cachedWorktrees.refreshedAt > 0
const worktrees = cachedWorktrees?.worktrees ?? []
Expand Down Expand Up @@ -948,6 +1014,7 @@ export function useIpcSubscriptions(
// Codex exec_approval_request opens an approval pane;
// exec_command_end with the same call_id closes it.
if (raw.type === 'event_msg' && payload?.type === 'exec_approval_request') {
approvalOpened = pendingApproval === null
pendingApproval = {
callId: typeof payload.call_id === 'string' ? payload.call_id : null,
command: Array.isArray(payload.command)
Expand Down Expand Up @@ -1174,7 +1241,8 @@ export function useIpcSubscriptions(
return prev
}

const nextRuntime = withDerivedSessionStatus(
const hasLiveOutput = appended.length > 0 && current.transcriptStatus === 'ready'
const nextRuntimeBase = withDerivedSessionStatus(
appendFeedDebugLog(
{
...current,
Expand Down Expand Up @@ -1220,6 +1288,11 @@ export function useIpcSubscriptions(
},
),
)
const nextRuntime = approvalOpened
? withUnread(sessionId, nextRuntimeBase, 'attention')
: hasLiveOutput
? withUnread(sessionId, nextRuntimeBase, 'output')
: nextRuntimeBase
closeSpan({
sessionId,
burstSize: entries.length,
Expand Down
31 changes: 20 additions & 11 deletions src/renderer/src/workspace/tile-tree/TileLeaf/SessionBadges.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,19 +13,28 @@ export function WorktreeBadge({
context: AgentWorkContext | null | undefined
activity: WorktreeActivityState | null | undefined
}) {
if (!context?.worktreePath) return null
const label = context.branch ?? shortPath(context.worktreePath)
// The badge is a "where is this agent working now?" signal, so
// prefer the latest active worktree over the longer-lived primary
// score winner. The primary context can lag badly in sessions that
// start on main and later move to a feature worktree: main has more
// cumulative events, while activity.active already reflects the
// most recent command cwd.
const displayContext = activity?.active?.worktreePath
? activity.active
: context
if (!displayContext?.worktreePath) return null
const label = displayContext.branch ?? shortPath(displayContext.worktreePath)
if (!label) return null
const color = worktreeBadgeColor(context)
const color = worktreeBadgeColor(displayContext)
const title = [
'Primary worktree',
context.branch ? `Branch: ${context.branch}` : null,
`Worktree: ${context.worktreePath}`,
`Source: ${context.source}`,
`Confidence: ${context.confidence}`,
activity?.active?.worktreePath &&
activity.active.worktreePath !== context.worktreePath
? `Active now: ${activity.active.branch ?? shortPath(activity.active.worktreePath)} (${activity.active.worktreePath})`
activity?.active?.worktreePath ? 'Active worktree' : 'Primary worktree',
displayContext.branch ? `Branch: ${displayContext.branch}` : null,
`Worktree: ${displayContext.worktreePath}`,
`Source: ${displayContext.source}`,
`Confidence: ${displayContext.confidence}`,
context?.worktreePath &&
context.worktreePath !== displayContext.worktreePath
? `Primary: ${context.branch ?? shortPath(context.worktreePath)} (${context.worktreePath})`
: null,
activity
? `Touched: ${Object.values(activity.touched).length}`
Expand Down
13 changes: 13 additions & 0 deletions src/renderer/src/workspace/workspaceState.ts
Original file line number Diff line number Diff line change
Expand Up @@ -261,6 +261,17 @@ export type SessionRuntime = {
draftInput: string
draftImages: ClaudeDraftImage[]
activityStatus: string | null
/** Unread marker for list surfaces such as Dispatch Mode.
*
* WHY this lives on the runtime instead of being derived from
* entry counts: entries arrive from several sources (semantic
* ghosts, JSONL replay, optimistic rows), and replaying history
* would make count-based badges lie. The UI only needs a durable
* "something happened while you were elsewhere" bit. Focus
* actions clear it; IPC writers set it when hidden sessions
* receive user-visible output or action-required prompts. */
unreadSince: number | null
unreadKind: 'output' | 'attention' | null
paneToast: string | null
pendingApproval: {
callId: string | null
Expand Down Expand Up @@ -427,6 +438,8 @@ export function emptyRuntime(): SessionRuntime {
draftInput: '',
draftImages: [],
activityStatus: null,
unreadSince: null,
unreadKind: null,
paneToast: null,
pendingApproval: null,
pendingTrustDialog: null,
Expand Down