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
82 changes: 82 additions & 0 deletions docs/superpowers/plans/2026-08-26-agent-title.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
# Set Agent Title Implementation Plan

> **Status: COMPLETE — TWO REVIEWS RESOLVED, QUALITY GATE GREEN**

**Goal:** Let the user assign, edit, and clear a durable title for the focused
agent from Grid, classic Dispatch, or Tiled Dispatch, then show that title as a
second pane-header row and as the preferred Dispatch index label.

**Architecture:** Reuse `SessionMeta.title`, which is already the workspace's
single durable agent-label field and is serialized to `workspace.json`. A
session-scoped command captures the Dispatch-aware command target and opens a
transient modal keyed by that session id. The modal commits through one
workspace action that trims the value, removes the field when cleared, and
caps user input before it reaches persisted state. `PaneHeader` reads the same
metadata for Grid and every Dispatch pane renderer. Dispatch rows retain their
latest-prompt fallback only when no explicit title exists.

## Constraints

- The command must target the visible/focused agent through
`commandTargetSessionId`; Grid focus and Dispatch focus must not fork.
- Terminal sessions are out of scope: the feature is agent metadata, and the
command must not appear for a plain shell pane.
- No second persistence path. `SessionMeta.title` remains the source of truth,
and existing workspace autosave/rehydration owns durability.
- Empty or whitespace-only input clears the title and restores the existing
untitled presentation.
- The title row is conditional. Untitled panes must not permanently lose feed
height to an empty header.
- Existing orchestration-provided titles remain compatible and editable.
- Add thick WHY comments at the command-target, persistence-normalization, and
Dispatch-fallback boundaries.

## Tasks

- [x] Add title normalization and an identity-stable workspace mutation for
setting/clearing one agent title.
- [x] Add transient UI-shell state, a `Set Agent Title…` session command, and an
app-root modal that edits the captured target without focus-race drift.
- [x] Render an explicit title directly below the pane's status/path/index row
in Grid, classic Dispatch, and every Tiled Dispatch lane.
- [x] Make explicit titles win over latest-prompt-derived Dispatch row text,
while preserving the prompt fallback for untitled agents.
- [x] Add focused tests for normalization/state mutation, Grid and Tiled
Dispatch target capture, modal save/clear behavior, pane-header rendering,
Dispatch precedence, and persisted-title recovery.
- [x] Run renderer tests, command/catalog checks, typecheck, and the broader
project checks justified by the final diff.
- [x] Review the final diff, update this status, open an unmerged PR, and report
verification and remaining limitations.

## Verification

- Focused unit and renderer suites pass, including command targeting in Grid,
classic Dispatch, and Tiled Dispatch; prompt save/clear; conditional header
placement; Dispatch title precedence; and persisted-title rehydration.
- Typecheck, command/keybinding governance, test contract, live-resume probe,
production build, and packaged-output verification pass.
- The complete Vitest matrix reaches 1,882 passing tests. Its only failure is
an environment-owned corpus invariant: fixture
`atp-codex-image-inside-claude-transcript` cites a transcript below
`~/.claude` that is absent on this machine. No feature test fails.

## Orchestrated review follow-up

Two independent Agent Code orchestrated reviewers inspected PR #660. Their
four validated findings are being resolved at the shared contract boundaries:

- [x] Preserve the latest explicit title when a delayed provider/reload/rewind
replacement swaps the pane to a fresh session id.
- [x] Use the same explicit-title-first resolver in Tiled Dispatch mini-chip
tooltips as the full Dispatch index.
- [x] Make modal typing and paste enforce the 120-character limit by Unicode
code point rather than native UTF-16 code-unit length.
- [x] Re-trim after truncation so a length boundary cannot manufacture durable
trailing whitespace.
- [x] Add regression coverage, rerun verification, synchronize the PR, and
confirm the current GitHub quality gate is green before merge.

The final GitHub quality gate passed its test contract, typechecks, core,
system, and renderer suites, coverage baseline, and distributable-output
verification. The user explicitly authorized merging after this review gate.
2 changes: 2 additions & 0 deletions src/renderer/src/app-state/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,8 @@ export type UiShellSlice = UiShellState & {
closePinAgents: () => void
openSettingsPage: () => void
closeSettingsPage: () => void
openAgentTitlePrompt: (sessionId: SessionId) => void
closeAgentTitlePrompt: () => void
openBuryPrompt: (sessionId: SessionId) => void
closeBuryPrompt: () => void
openDebugBundleNotePrompt: (payload: {
Expand Down
6 changes: 6 additions & 0 deletions src/renderer/src/app-state/uiShell/slice.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ export const createUiShellSlice: StateCreator<
reorderTabsOpen: false,
pinAgentsOpen: false,
settingsPageOpen: false,
agentTitlePromptSessionId: null,
buryPromptSessionId: null,
debugBundleNotePrompt: null,
recordingNotePrompt: null,
Expand Down Expand Up @@ -124,6 +125,11 @@ export const createUiShellSlice: StateCreator<
closeSettingsPage: () =>
set({ settingsPageOpen: false }, false, 'uiShell/closeSettingsPage'),

openAgentTitlePrompt: sessionId =>
set({ agentTitlePromptSessionId: sessionId }, false, 'uiShell/openAgentTitlePrompt'),
closeAgentTitlePrompt: () =>
set({ agentTitlePromptSessionId: null }, false, 'uiShell/closeAgentTitlePrompt'),

openBuryPrompt: sessionId =>
set({ buryPromptSessionId: sessionId }, false, 'uiShell/openBuryPrompt'),
closeBuryPrompt: () =>
Expand Down
9 changes: 9 additions & 0 deletions src/renderer/src/app-state/uiShell/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,15 @@ export type UiShellState = {
* pin session that the user later cancels with Escape. */
pinAgentsOpen: boolean
settingsPageOpen: boolean
/**
* The session captured when Set Agent Title is invoked.
*
* WHY the id is stored instead of re-reading focus at Save time: Dispatch
* focus can move while a modal is open (especially across Tiled Dispatch
* lanes). The edit must land on the agent the user invoked the command for,
* not whichever lane happens to be focused after the prompt appears.
*/
agentTitlePromptSessionId: SessionId | null
buryPromptSessionId: SessionId | null
debugBundleNotePrompt: {
bundlePath: string
Expand Down
2 changes: 2 additions & 0 deletions src/renderer/src/app/surfaces/registry.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import { AgentViewModePickerSurface } from '@renderer/features/workspace/surface
import { ColorFlagPickerSurface } from '@renderer/features/workspace/surfaces/ColorFlagPickerSurface'
import { KeyboardShortcutsSurface } from '@renderer/features/settings/surfaces/KeyboardShortcutsSurface'
import { RewindToPromptSurface } from '@renderer/features/workspace/surfaces/RewindToPromptSurface'
import { AgentTitlePromptSurface } from '@renderer/features/workspace/surfaces/AgentTitlePromptSurface'

// The surface registry (issue #494). Adding a surface = write a wrapper
// in the owning feature's surfaces/ folder + add ONE import + ONE array
Expand Down Expand Up @@ -78,6 +79,7 @@ export const modalSurfaces: SurfaceEntry[] = [
{ id: 'agent-view-mode-picker', Component: AgentViewModePickerSurface },
{ id: 'color-flag-picker', Component: ColorFlagPickerSurface },
{ id: 'rewind-to-prompt', Component: RewindToPromptSurface },
{ id: 'agent-title-prompt', Component: AgentTitlePromptSurface },
{ id: 'usage', Component: UsageModalSurface },
]

Expand Down
31 changes: 17 additions & 14 deletions src/renderer/src/features/command-palette/catalog.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,9 @@ import type { CommandDef } from '@renderer/features/command-palette/types'
// Phase 0 of the command-governance plan (docs/superpowers/plans/
// 2026-07-23-command-surface-audit.md): CHARACTERIZE THE CURRENT CATALOG.
//
// This file pinned the exact 102-id before-state, and now pins the 104-id
// This file pinned the exact 102-id before-state, and now pins the 105-id
// after-state: five durable preferences retired to Settings, seven approved
// additions (102 - 5 + 7 = 104). Keeping ONE snapshot that moved — rather
// additions (102 - 5 + 8 = 105). Keeping ONE snapshot that moved — rather
// than a "baseline" file and an "after" file — is what makes the plan's
// headline count an assertion anyone can check against running code instead of
// prose.
Expand Down Expand Up @@ -120,6 +120,8 @@ const BASELINE_COMMAND_IDS: readonly string[] = [
'toggle-rendering-debug-mode',
'toggle-html-debug-panel',
'toggle-dev-debug-panel',
// agentTitleCommands (1)
'agent.title.set',
// dispatchColorFlagCommands (1)
'dispatch.color-flag.set',
// spotlight / reader / tile-tabs (3)
Expand Down Expand Up @@ -176,22 +178,22 @@ const NAVIGATION_COMMAND_GROUP: readonly string[] = [
const ids = (): string[] => builtInCommandCatalog.map(c => c.id)

describe('built-in command catalog — baseline characterization', () => {
it('contains exactly the 104 governed commands in registration order', () => {
it('contains exactly the 105 governed commands in registration order', () => {
// Order matters: this is the palette's empty-query browse order.
expect(ids()).toEqual([...BASELINE_COMMAND_IDS])
})

it('has exactly 104 commands', () => {
it('has exactly 105 commands', () => {
// Stated separately from the order assertion because this number is the
// thing that moves, and a bare count failure is a clearer signal than a
// 99-line array diff.
//
// 102 baseline → 98 after governance (5 retirements, 1 addition) → 99 with
// `open-keyboard-shortcuts` → 102 with the three composer commands → 104
// with the two lane-removal commands. Each
// with the two lane-removal commands → 105 with Set Agent Title. Each
// step of that arithmetic was a deliberate edit to this line, which is the
// entire point of pinning it.
expect(builtInCommandCatalog).toHaveLength(104)
expect(builtInCommandCatalog).toHaveLength(105)
})

it('reports no structural defects', () => {
Expand All @@ -208,7 +210,7 @@ describe('built-in command catalog — baseline characterization', () => {
})

describe('generated per-provider split commands', () => {
// The arithmetic is 100 literal ids + 4 generated = 104. If a provider
// The arithmetic is 101 literal ids + 4 generated = 105. If a provider
// is ever added to AGENT_PROVIDER_KINDS, this invariant is what tells the
// author that the catalog count moved for a legitimate reason, and forces the
// baseline snapshot above to be updated deliberately.
Expand All @@ -222,10 +224,10 @@ describe('generated per-provider split commands', () => {
})

it('accounts for the difference between literal and total command count', () => {
// 104 total - 4 generated = 100 literal `id:` fields across the command
// 105 total - 4 generated = 101 literal `id:` fields across the command
// modules. At the original baseline this read 102 - 4 = 98; it moved down by
// the five retirements, then back up by the seven additions.
expect(builtInCommandCatalog.length - nonDefaultProviders.length * 2).toBe(100)
// the five retirements, then back up by the eight additions.
expect(builtInCommandCatalog.length - nonDefaultProviders.length * 2).toBe(101)
})

it('emits both directions for every non-default provider', () => {
Expand Down Expand Up @@ -324,7 +326,7 @@ describe('governance targets', () => {
})

it('lands on the arithmetic the plan predicted', () => {
// 102 baseline - 5 retirements + 7 additions = 104, checked against the
// 102 baseline - 5 retirements + 8 additions = 105, checked against the
// real catalog rather than trusted as prose.
//
// The subtracted term is the count of APPROVED ADDITIONS and the expected
Expand All @@ -337,9 +339,10 @@ describe('governance targets', () => {
// not be rebound because it had no command id), `open-keyboard-shortcuts`,
// and the three composer commands (`clear-composer`,
// `undo-clear-composer`, `send-composer`) and the two lane-removal
// commands (`remove-tiled-lane`, `close-agent-remove-lane`).
expect(builtInCommandCatalog.length + RETIRED_COMMAND_IDS.length - 7).toBe(102)
expect(builtInCommandCatalog).toHaveLength(104)
// commands (`remove-tiled-lane`, `close-agent-remove-lane`), and
// `agent.title.set`.
expect(builtInCommandCatalog.length + RETIRED_COMMAND_IDS.length - 8).toBe(102)
expect(builtInCommandCatalog).toHaveLength(105)
})
})

Expand Down
2 changes: 2 additions & 0 deletions src/renderer/src/features/command-palette/catalog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { promptTemplateCommands } from '@renderer/features/prompt-templates/comm
import { replyToSelectionCommands } from '@renderer/features/reply-to-selection/commands/replyToSelectionCommands'
import { agentStatusCommands } from '@renderer/features/agent-status/commands/agentStatusCommands'
import { dispatchColorFlagCommands } from '@renderer/features/workspace/commands/dispatchColorFlagCommands'
import { agentTitleCommands } from '@renderer/features/workspace/commands/agentTitleCommands'
import { remoteCommands } from '@renderer/features/remote/commands/remoteCommands'
import { usageCommands } from '@renderer/features/usage/commands/usageCommands'
import { paletteCommands } from '@renderer/features/command-palette/commands/paletteCommands'
Expand Down Expand Up @@ -64,6 +65,7 @@ export const builtInCommandCatalog: readonly CommandDef[] = Object.freeze([
// regressions to users who navigate by position.
...globalEditorCommands,
...sessionCommands,
...agentTitleCommands,
...dispatchColorFlagCommands,
...spotlightCommands,
...readerCommands,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -502,6 +502,7 @@ describe('surface ownership', () => {
'view-prompts',
'rewind-to-prompt',
'set-agent-view-mode',
'agent.title.set',
'dispatch.color-flag.set',
'save-debug-logs',
'attach-recording-note',
Expand Down
2 changes: 2 additions & 0 deletions src/renderer/src/features/command-palette/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -204,6 +204,8 @@ export type CommandContext = {
openAgentViewModePicker: (sessionId: string) => void
/** Open the Dispatch color-flag swatch picker for a session. */
openColorFlagPicker: (sessionId: string) => void
/** Open the title editor for the captured command-target agent. */
openAgentTitlePrompt: (sessionId: string) => void
openUsageModal: () => void
toggleGitBar: () => void
toggleWorktreesBar: () => void
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -263,6 +263,7 @@ function OpenCommandPalette({
const openRewindPrompt = useAppStore(state => state.openRewindPrompt)
const openAgentViewModePicker = useAppStore(state => state.openAgentViewModePicker)
const openColorFlagPicker = useAppStore(state => state.openColorFlagPicker)
const openAgentTitlePrompt = useAppStore(state => state.openAgentTitlePrompt)
const closeUsageModal = useAppStore(state => state.closeUsageModal)
const closeKeyboardShortcuts = useAppStore(state => state.closeKeyboardShortcuts)
const closeAgentActivity = useAppStore(state => state.closeAgentActivity)
Expand Down Expand Up @@ -594,6 +595,7 @@ function OpenCommandPalette({
openRewindPrompt,
openAgentViewModePicker,
openColorFlagPicker,
openAgentTitlePrompt,
closeUsageModal,
closeKeyboardShortcuts,
closeAgentActivity,
Expand Down Expand Up @@ -700,6 +702,7 @@ function OpenCommandPalette({
openBulkProviderSwitch,
openRewindPrompt,
openAgentViewModePicker,
openAgentTitlePrompt,
closeUsageModal,
closeKeyboardShortcuts,
closeAgentActivity,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import { describe, expect, it, vi } from 'vitest'

import type { CommandContext } from '@renderer/features/command-palette/types'
import { agentTitleCommands } from '@renderer/features/workspace/commands/agentTitleCommands'
import type { WorkspaceState } from '@renderer/workspace/types'

const command = agentTitleCommands[0]
if (!command) throw new Error('Set Agent Title command is missing')

function context(state: WorkspaceState) {
const openAgentTitlePrompt = vi.fn()
return {
openAgentTitlePrompt,
value: {
workspace: { state },
ui: { openAgentTitlePrompt },
flags: {},
} as unknown as CommandContext,
}
}

function baseState(): WorkspaceState {
return {
tabs: [
{ id: 'tab-a', title: 'A', root: { type: 'leaf', sessionId: 'a' }, focusedSessionId: 'a' },
{ id: 'tab-b', title: 'B', root: { type: 'leaf', sessionId: 'b' }, focusedSessionId: 'b' },
],
activeTabId: 'tab-a',
gridRelatedSelections: {},
dispatchMode: null,
sessions: {
a: { cwd: '/work/a', kind: 'claude' },
b: { cwd: '/work/b', kind: 'codex' },
},
detachedSessions: {},
buried: [],
pinnedSessionIds: [],
}
}

describe('Set Agent Title command targeting', () => {
it('captures the focused Grid agent', () => {
const harness = context(baseState())

expect(command.when?.(harness.value)).toBe(true)
command.run(harness.value)
expect(harness.openAgentTitlePrompt).toHaveBeenCalledWith('a')
})

it('captures the selected classic Dispatch agent instead of stale Grid focus', () => {
const state = baseState()
state.dispatchMode = { scope: 'global', focusedSessionId: 'b' }
const harness = context(state)

command.run(harness.value)
expect(harness.openAgentTitlePrompt).toHaveBeenCalledWith('b')
})

it('captures the focused Tiled Dispatch lane instead of stale Grid focus', () => {
const state = baseState()
state.dispatchMode = {
scope: 'global',
focusedSessionId: 'a',
tiled: {
focusedLane: 1,
lanes: [{ selectedSessionId: 'a' }, { selectedSessionId: 'b' }],
},
}
const harness = context(state)

command.run(harness.value)
expect(harness.openAgentTitlePrompt).toHaveBeenCalledWith('b')
})

it('does not advertise agent titles for a plain terminal target', () => {
const state = baseState()
state.sessions.a = { cwd: '/work/a', kind: 'terminal' }
const harness = context(state)

expect(command.when?.(harness.value)).toBe(false)
command.run(harness.value)
expect(harness.openAgentTitlePrompt).not.toHaveBeenCalled()
})
})
Loading