-
{card.repoName}
+
+ {/* Why: the project reads as an icon so its name can't crowd the
+ worktree sitting next to it; the name lives in the tooltip. */}
+
+
+
+
+
+
+
+ {card.repoName}
+
+
+ {worktreeInFooter ? {card.worktreeName} : null}
{displayTimestamp(card) > 0 ? (
-
+
{formatStartedAgo(displayTimestamp(card), now)}
) : null}
@@ -149,6 +211,7 @@ export const AgentKanbanCard = memo(
(previous, next) =>
previous.onOpenTerminal === next.onOpenTerminal &&
sameCard(previous.card, next.card) &&
+ sameRepoIcon(previous.repoIcon, next.repoIcon) &&
(displayTimestamp(previous.card) <= 0 ||
formatStartedAgo(displayTimestamp(previous.card), previous.now) ===
formatStartedAgo(displayTimestamp(next.card), next.now))
diff --git a/src/renderer/src/components/dashboard/build-dashboard-snapshot.test.ts b/src/renderer/src/components/dashboard/build-dashboard-snapshot.test.ts
index fadd385af..6da673ecb 100644
--- a/src/renderer/src/components/dashboard/build-dashboard-snapshot.test.ts
+++ b/src/renderer/src/components/dashboard/build-dashboard-snapshot.test.ts
@@ -114,6 +114,73 @@ describe('buildDashboardSnapshot', () => {
expect(card.unseen).toBe(true)
})
+ it('carries the tab conversation name and drops status-only titles', () => {
+ const named = buildDashboardSnapshot(
+ baseState({
+ agentStatusByPaneKey: { [PANE_KEY]: entry({}) },
+ tabsByWorktree: { w1: [{ ...tab(), customTitle: 'Sparse-checkout parser' }] }
+ }),
+ NOW
+ )
+ expect(named.cards[0].conversationName).toBe('Sparse-checkout parser')
+
+ // The fixture tab's title is the 'agent' placeholder — not a name.
+ const unnamed = buildDashboardSnapshot(
+ baseState({ agentStatusByPaneKey: { [PANE_KEY]: entry({}) } }),
+ NOW
+ )
+ expect(unnamed.cards[0].conversationName).toBeUndefined()
+ })
+
+ it('withholds generated titles until the setting enables them', () => {
+ const tabs = { w1: [{ ...tab(), generatedTitle: 'Fix the flaky pty test' }] }
+ const off = buildDashboardSnapshot(
+ baseState({ agentStatusByPaneKey: { [PANE_KEY]: entry({}) }, tabsByWorktree: tabs }),
+ NOW
+ )
+ expect(off.cards[0].conversationName).toBeUndefined()
+
+ const on = buildDashboardSnapshot(
+ baseState({
+ agentStatusByPaneKey: { [PANE_KEY]: entry({}) },
+ tabsByWorktree: tabs,
+ settings: { tabAutoGenerateTitle: true }
+ } as unknown as Partial),
+ NOW
+ )
+ expect(on.cards[0].conversationName).toBe('Fix the flaky pty test')
+ })
+
+ it('ships one icon per card-bearing repo, and none for repos without cards', () => {
+ const snapshot = buildDashboardSnapshot(
+ baseState({
+ repos: [
+ {
+ id: 'r1',
+ path: '/r1',
+ displayName: 'Repo One',
+ badgeColor: '#000',
+ repoIcon: { type: 'lucide', name: 'Rocket' }
+ },
+ { id: 'r2', path: '/r2', displayName: 'Repo Two', badgeColor: '#000' }
+ ],
+ worktreesByRepo: { r1: [worktree()], r2: [worktree('w2', 'wt-two')] },
+ agentStatusByPaneKey: { [PANE_KEY]: entry({}) }
+ } as unknown as Partial),
+ NOW
+ )
+ // r2 has a worktree but no agent card, so its icon never ships.
+ expect(snapshot.repoIconsByRepoId).toEqual({ r1: { type: 'lucide', name: 'Rocket' } })
+ })
+
+ it('records a null icon for a card-bearing repo that has none', () => {
+ const snapshot = buildDashboardSnapshot(
+ baseState({ agentStatusByPaneKey: { [PANE_KEY]: entry({}) } }),
+ NOW
+ )
+ expect(snapshot.repoIconsByRepoId).toEqual({ r1: null })
+ })
+
it('nulls ptyId when the layout entry points at a dead pty', () => {
const snapshot = buildDashboardSnapshot(
baseState({
diff --git a/src/renderer/src/components/dashboard/build-dashboard-snapshot.ts b/src/renderer/src/components/dashboard/build-dashboard-snapshot.ts
index 8554cf85e..d860f646f 100644
--- a/src/renderer/src/components/dashboard/build-dashboard-snapshot.ts
+++ b/src/renderer/src/components/dashboard/build-dashboard-snapshot.ts
@@ -5,7 +5,9 @@ import type {
DashboardCardDotState,
DashboardSnapshot
} from '../../../../shared/dashboard-snapshot'
+import type { RepoIcon } from '../../../../shared/repo-icon'
import { parsePaneKey } from '../../../../shared/stable-pane-id'
+import { getAgentRowConversationName } from '../../../../shared/agent-row-conversation-name'
import { migrationUnsupportedToAgentStatusEntry } from '@/lib/migration-unsupported-agent-entry'
import { applyAgentRowLineage } from './agent-row-lineage'
import { lastEnteredDoneAt } from './agent-finished-timestamp'
@@ -43,6 +45,7 @@ export type DashboardSnapshotState = Pick<
| 'ptyIdsByTabId'
| 'runtimePaneTitlesByTabId'
| 'acknowledgedAgentsByPaneKey'
+ | 'settings'
>
function bucketForState(state: DashboardAgentRow['state']): DashboardBucket {
@@ -70,6 +73,24 @@ function nonEmpty(value: string | undefined): string | undefined {
return trimmed.length > 0 ? trimmed : undefined
}
+/** Mirrors useAgentRowConversationName so the board and the sidebar label the
+ * same agent with the same name. */
+function rowConversationName(
+ row: DashboardAgentRow,
+ generatedTitlesEnabled: boolean
+): string | undefined {
+ const parentPaneKey = row.entry.orchestration?.parentPaneKey
+ // Why: a child row rendered on its parent's tab does not own that tab's name.
+ if (
+ row.lineage?.depth === 1 &&
+ parentPaneKey !== undefined &&
+ parsePaneKey(parentPaneKey)?.tabId === row.tab.id
+ ) {
+ return undefined
+ }
+ return getAgentRowConversationName(row.tab, row.agentType, generatedTitlesEnabled) ?? undefined
+}
+
/**
* Derive the serializable dashboard snapshot from the live renderer store.
* Reuses the exact per-worktree row machinery the sidebar uses
@@ -82,6 +103,8 @@ export function buildDashboardSnapshot(
now: number
): DashboardSnapshot {
const cards: DashboardCard[] = []
+ const repoIconsByRepoId: Record = {}
+ const generatedTitlesEnabled = state.settings?.tabAutoGenerateTitle === true
const activeWorktrees: {
repo: AppState['repos'][number]
worktree: AppState['worktreesByRepo'][string][number]
@@ -169,6 +192,8 @@ export function buildDashboardSnapshot(
: null
const dotState = row.state as DashboardCardDotState
const bucket = bucketForState(row.state)
+ // Only repos that actually contribute a card ship their icon.
+ repoIconsByRepoId[repo.id] = repo.repoIcon ?? null
cards.push({
paneKey: row.paneKey,
@@ -193,10 +218,11 @@ export function buildDashboardSnapshot(
unseen:
!isTitleDerived &&
(state.acknowledgedAgentsByPaneKey?.[row.paneKey] ?? 0) < row.entry.stateStartedAt,
- askSummary: bucket === 'attention' ? (row.entry.interactivePrompt ?? undefined) : undefined
+ askSummary: bucket === 'attention' ? (row.entry.interactivePrompt ?? undefined) : undefined,
+ conversationName: rowConversationName(row, generatedTitlesEnabled)
})
}
}
- return { generatedAt: now, cards }
+ return { generatedAt: now, cards, repoIconsByRepoId }
}
diff --git a/src/renderer/src/components/dashboard/useAgentBucketCounts.ts b/src/renderer/src/components/dashboard/useAgentBucketCounts.ts
index d302e7786..bab421f29 100644
--- a/src/renderer/src/components/dashboard/useAgentBucketCounts.ts
+++ b/src/renderer/src/components/dashboard/useAgentBucketCounts.ts
@@ -56,7 +56,10 @@ export function useAgentBucketCounts(): AgentBucketCounts {
ptyIdsByTabId,
runtimePaneTitlesByTabId,
// Counts do not render acknowledgement state, so avoid subscribing the sidebar to it.
- acknowledgedAgentsByPaneKey: {}
+ acknowledgedAgentsByPaneKey: {},
+ // Same: counts never render a card's conversation name, so the
+ // generated-title gate is moot and the sidebar stays off settings.
+ settings: null
},
Date.now()
)
diff --git a/src/renderer/src/components/dashboard/useDashboardPopoutBridge.test.tsx b/src/renderer/src/components/dashboard/useDashboardPopoutBridge.test.tsx
index 6b388f95f..e816231e3 100644
--- a/src/renderer/src/components/dashboard/useDashboardPopoutBridge.test.tsx
+++ b/src/renderer/src/components/dashboard/useDashboardPopoutBridge.test.tsx
@@ -56,6 +56,7 @@ function makeSnapshotWatchState(): DashboardSnapshotWatchState {
ptyIdsByTabId: {},
runtimePaneTitlesByTabId: {},
acknowledgedAgentsByPaneKey: {},
+ settings: null,
agentStatusEpoch: 0
}
}
diff --git a/src/renderer/src/components/dashboard/useDashboardPopoutBridge.ts b/src/renderer/src/components/dashboard/useDashboardPopoutBridge.ts
index 3a8e8c2c0..e1609f866 100644
--- a/src/renderer/src/components/dashboard/useDashboardPopoutBridge.ts
+++ b/src/renderer/src/components/dashboard/useDashboardPopoutBridge.ts
@@ -26,6 +26,8 @@ export function dashboardSnapshotInputsChanged(
state.ptyIdsByTabId !== previousState.ptyIdsByTabId ||
state.runtimePaneTitlesByTabId !== previousState.runtimePaneTitlesByTabId ||
state.acknowledgedAgentsByPaneKey !== previousState.acknowledgedAgentsByPaneKey ||
+ // Why: tabAutoGenerateTitle decides whether cards may show generated names.
+ state.settings !== previousState.settings ||
// Why: freshness can change a bucket without replacing any backing map.
state.agentStatusEpoch !== previousState.agentStatusEpoch
)
diff --git a/src/renderer/src/components/dashboard/useLiveDashboardSnapshot.test.ts b/src/renderer/src/components/dashboard/useLiveDashboardSnapshot.test.ts
new file mode 100644
index 000000000..9ebaa72fb
--- /dev/null
+++ b/src/renderer/src/components/dashboard/useLiveDashboardSnapshot.test.ts
@@ -0,0 +1,126 @@
+// @vitest-environment happy-dom
+import { renderHook } from '@testing-library/react'
+import { afterEach, beforeEach, describe, expect, it } from 'vitest'
+import { useAppStore } from '@/store'
+import type { AgentStatusEntry } from '../../../../shared/agent-status-types'
+import { makePaneKey } from '../../../../shared/stable-pane-id'
+import type { GlobalSettings, Repo, TerminalTab, Worktree } from '../../../../shared/types'
+import { useLiveDashboardSnapshot } from './useLiveDashboardSnapshot'
+
+const NOW = 1_000_000_000
+const TAB_ID = 'tab-1'
+const LEAF_ID = '11111111-1111-4111-8111-111111111111'
+const PANE_KEY = makePaneKey(TAB_ID, LEAF_ID)
+
+const initialAppState = useAppStore.getInitialState()
+
+beforeEach(() => {
+ useAppStore.setState(initialAppState, true)
+})
+
+afterEach(() => {
+ useAppStore.setState(initialAppState, true)
+})
+
+function repo(): Repo {
+ return {
+ id: 'repo-1',
+ path: '/repo',
+ displayName: 'Repo One',
+ badgeColor: '#000',
+ repoIcon: { type: 'lucide', name: 'Rocket' },
+ addedAt: 1
+ }
+}
+
+function worktree(): Worktree {
+ return {
+ id: 'wt-1',
+ repoId: 'repo-1',
+ path: '/repo/wt-1',
+ head: 'abc123',
+ branch: 'main',
+ isBare: false,
+ isMainWorktree: false,
+ displayName: 'wt-one',
+ comment: '',
+ linkedIssue: null,
+ linkedPR: null,
+ linkedLinearIssue: null,
+ isArchived: false,
+ isUnread: false,
+ isPinned: false,
+ sortOrder: 0,
+ lastActivityAt: NOW
+ }
+}
+
+function tab(): TerminalTab {
+ return {
+ id: TAB_ID,
+ ptyId: 'pty-1',
+ worktreeId: 'wt-1',
+ title: 'agent',
+ customTitle: null,
+ generatedTitle: 'Fix the flaky pty test',
+ color: null,
+ sortOrder: 0,
+ createdAt: NOW
+ } as TerminalTab
+}
+
+function entry(): AgentStatusEntry {
+ return {
+ paneKey: PANE_KEY,
+ state: 'working',
+ prompt: 'do the thing',
+ updatedAt: Date.now(),
+ stateStartedAt: Date.now(),
+ stateHistory: [],
+ agentType: 'claude',
+ tabId: TAB_ID,
+ worktreeId: 'wt-1'
+ }
+}
+
+function seed(settings: Partial | null): void {
+ useAppStore.setState({
+ repos: [repo()],
+ worktreesByRepo: { 'repo-1': [worktree()] },
+ tabsByWorktree: { 'wt-1': [tab()] },
+ agentStatusByPaneKey: { [PANE_KEY]: entry() },
+ terminalLayoutsByTabId: {
+ [TAB_ID]: {
+ root: { type: 'leaf', leafId: LEAF_ID },
+ activeLeafId: LEAF_ID,
+ expandedLeafId: null,
+ ptyIdsByLeafId: { [LEAF_ID]: 'pty-1' }
+ }
+ },
+ ptyIdsByTabId: { [TAB_ID]: ['pty-1'] },
+ settings: settings as GlobalSettings | null
+ })
+}
+
+// Why: the in-window drawer derives its own snapshot instead of receiving the
+// relayed one, so anything the builder reads has to be threaded in by hand —
+// a dropped slice silently blanks the field rather than failing loudly.
+describe('useLiveDashboardSnapshot', () => {
+ it('feeds the builder the settings that gate generated conversation names', () => {
+ seed({ tabAutoGenerateTitle: true })
+ const withTitles = renderHook(() => useLiveDashboardSnapshot())
+ expect(withTitles.result.current.cards[0].conversationName).toBe('Fix the flaky pty test')
+
+ seed({ tabAutoGenerateTitle: false })
+ const withoutTitles = renderHook(() => useLiveDashboardSnapshot())
+ expect(withoutTitles.result.current.cards[0].conversationName).toBeUndefined()
+ })
+
+ it('carries repo icons through to the drawer', () => {
+ seed({ tabAutoGenerateTitle: false })
+ const { result } = renderHook(() => useLiveDashboardSnapshot())
+ expect(result.current.repoIconsByRepoId).toEqual({
+ 'repo-1': { type: 'lucide', name: 'Rocket' }
+ })
+ })
+})
diff --git a/src/renderer/src/components/dashboard/useLiveDashboardSnapshot.ts b/src/renderer/src/components/dashboard/useLiveDashboardSnapshot.ts
index c6880ab0c..2df84c984 100644
--- a/src/renderer/src/components/dashboard/useLiveDashboardSnapshot.ts
+++ b/src/renderer/src/components/dashboard/useLiveDashboardSnapshot.ts
@@ -23,6 +23,8 @@ export function useLiveDashboardSnapshot(): DashboardSnapshot {
const ptyIdsByTabId = useAppStore((s) => s.ptyIdsByTabId)
const runtimePaneTitlesByTabId = useAppStore((s) => s.runtimePaneTitlesByTabId)
const acknowledgedAgentsByPaneKey = useAppStore((s) => s.acknowledgedAgentsByPaneKey)
+ // Why: gates generated tab titles in the cards' conversation names.
+ const settings = useAppStore((s) => s.settings)
// Why: freshness can flip a bucket without any backing map changing; the epoch
// ticks on the freshness boundary so the memo re-derives stale-decayed cards.
const agentStatusEpoch = useAppStore((s) => s.agentStatusEpoch)
@@ -43,7 +45,8 @@ export function useLiveDashboardSnapshot(): DashboardSnapshot {
terminalLayoutsByTabId,
ptyIdsByTabId,
runtimePaneTitlesByTabId,
- acknowledgedAgentsByPaneKey
+ acknowledgedAgentsByPaneKey,
+ settings
},
Date.now()
),
@@ -60,6 +63,7 @@ export function useLiveDashboardSnapshot(): DashboardSnapshot {
ptyIdsByTabId,
runtimePaneTitlesByTabId,
acknowledgedAgentsByPaneKey,
+ settings,
agentStatusEpoch
]
)
diff --git a/src/shared/dashboard-snapshot.ts b/src/shared/dashboard-snapshot.ts
index 0f3296c4e..1091a911b 100644
--- a/src/shared/dashboard-snapshot.ts
+++ b/src/shared/dashboard-snapshot.ts
@@ -1,4 +1,5 @@
import type { AgentType } from './agent-status-types'
+import type { RepoIcon } from './repo-icon'
/**
* Serializable contract for the pop-out agent dashboard. The main renderer owns
@@ -58,14 +59,26 @@ export type DashboardCard = {
unseen: boolean
/** Short summary of the pending question when bucket === 'attention'. */
askSummary?: string
+ /** The tab's conversation name, resolved exactly as the sidebar's agent rows
+ * resolve it. Undefined when no usable name exists (status-only titles). */
+ conversationName?: string
}
export type DashboardSnapshot = {
generatedAt: number
cards: DashboardCard[]
+ /** Icons for the repos the cards belong to. Keyed by repoId rather than
+ * carried per card: image icons are data URLs up to 400KB, and the snapshot
+ * is republished several times a second. Optional so a pop-out running
+ * pre-upgrade code still accepts the payload. */
+ repoIconsByRepoId?: Record
}
-export const EMPTY_DASHBOARD_SNAPSHOT: DashboardSnapshot = { generatedAt: 0, cards: [] }
+export const EMPTY_DASHBOARD_SNAPSHOT: DashboardSnapshot = {
+ generatedAt: 0,
+ cards: [],
+ repoIconsByRepoId: {}
+}
/** Routing payload for click-to-focus: reveal this agent's pane in the main
* window. leafId is null when the pane could not be resolved (best-effort: