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
577 changes: 577 additions & 0 deletions docs/superpowers/plans/2026-07-26-session-header-color-flag.md

Large diffs are not rendered by default.

12 changes: 11 additions & 1 deletion src/remote-client/src/ui/SessionView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -296,8 +296,18 @@ export function SessionView({
phone's Back/conn nav — on desktop this IS the pane's top bar.
statusMode off (a multi-pane-grid glance affordance, meaningless on
a single phone screen) and related-agent chips empty (the v1 wire
emits no sub-agent data). */}
emits no sub-agent data).

sessionId feeds the header's color-flag chunk. On the phone that
always resolves to "no flag": the store is the frozen
DEFAULT_SETTINGS snapshot from src/stubs/appStateHooks, whose
dispatchColorFlags is `{}`, and the v1 wire carries no settings.
It is still passed honestly rather than stubbed out, so that if the
phone ever syncs real settings the flag appears with no edit here —
and so a reader does not have to wonder which sessionId the header
would have used. */}
<PaneHeader
sessionId={sessionId}
paneLabel={provider}
projectDir={cwd}
statusMode={false}
Expand Down
19 changes: 16 additions & 3 deletions src/renderer/src/app-state/settings/dispatchColorFlags.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,22 @@
// Dispatch color flags — the fixed swatch palette + coercion.
//
// A "color flag" is a per-agent marker the user sets from the "Set color flag"
// command to paint a thick strip on the right edge of that agent's Dispatch
// row, so a flagged agent is instantly spottable when scanning many running
// workflows. See docs/plans_and_ideas/2026-07-23-dispatch-color-flags.md.
// command, so a flagged agent is instantly spottable when scanning many running
// workflows. It renders in two places: a thick strip on the right edge of that
// agent's Dispatch row, and a chunk of the right end of its pane's session
// header in the grid. See docs/plans_and_ideas/2026-07-23-dispatch-color-flags.md
// for the original feature and
// docs/superpowers/plans/2026-07-26-session-header-color-flag.md for the header
// surface. `useColorFlag` is the shared reader both surfaces go through.
//
// DO NOT RENAME the `Settings.dispatchColorFlags` key to drop the now-misleading
// "dispatch" prefix. The key name is itself persisted — it is what the flags are
// stored under in the zustand-persisted settings blob — so renaming it silently
// orphans every flag a user has already set unless it comes with a
// PERSIST_VERSION bump and a migration. The prefix is historical (the feature
// shipped in Dispatch first) and is not a claim about where the flag renders;
// the same applies to the `dispatch.color-flag.set` command id, which keys
// `Settings.commandVisibilityOverrides`.
//
// WHY a fixed palette rather than a free RGB/hex picker: the whole point is
// fast visual triage of a list, and a small set of high-contrast, distinct
Expand Down
23 changes: 23 additions & 0 deletions src/renderer/src/app-state/settings/useColorFlag.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { useAppStore } from '@renderer/app-state/hooks'
import { colorFlagById, type ColorFlag } from '@renderer/app-state/settings/dispatchColorFlags'
import type { SessionId } from '@renderer/workspace/types'

/**
* Resolve a session's color flag, or `undefined` when it has none.
*
* WHY this is a hook and not each surface selecting inline: color flags now
* have more than one reader (the Dispatch trailing column and the pane header
* chunk), and the thing that must NEVER fork between them is the state
* question — which settings key is read, and how an unknown/stale persisted id
* degrades. Geometry deliberately stays with each surface, because a 10px
* alignment column and a 25% header chunk are different visual contracts that
* should be free to diverge.
*
* Returning the resolved `ColorFlag` rather than the raw id means every caller
* gets the same "unknown id → no flag" degradation for free: a palette entry
* deleted in a future version stops rendering everywhere at once instead of
* throwing at one call site and rendering `undefined` at another.
*/
export function useColorFlag(sessionId: SessionId): ColorFlag | undefined {
return useAppStore(state => colorFlagById(state.settings.dispatchColorFlags[sessionId]))
}
Original file line number Diff line number Diff line change
@@ -1,27 +1,46 @@
import type { CommandDef } from '@renderer/features/command-palette/types'
import { commandTargetSessionId } from '@renderer/workspace/hook/selectors/commandTargetSessionId'

// "Set color flag" — mark the currently-commanded agent with a colored strip on
// the right edge of its Dispatch row, so it's easy to spot while scanning many
// running workflows. See docs/plans_and_ideas/2026-07-23-dispatch-color-flags.md.
// "Set Color Flag…" — mark the currently-commanded agent with a color, so it's
// easy to spot while scanning many running workflows. It renders as a strip on
// the right edge of the agent's Dispatch row and as a chunk of its pane's
// session header. See docs/plans_and_ideas/2026-07-23-dispatch-color-flags.md
// and docs/superpowers/plans/2026-07-26-session-header-color-flag.md.
//
// `surface: 'session'` (not 'dispatch') so a flag can be set from any mode; the
// strip is a Dispatch-list affordance, but the act of flagging an agent isn't
// mode-specific and shouldn't disappear the moment you leave Dispatch. Targets
// `surface: 'session'` (not 'dispatch') so a flag can be set from any mode.
// That choice predates the header surface and was already right for a different
// reason — flagging an agent isn't mode-specific and shouldn't disappear the
// moment you leave Dispatch — but the header makes it load-bearing: the flag is
// now visible in the grid, so a grid-only user must be able to set one. Targets
// `commandTargetSessionId` (Dispatch-aware focus), so it needs no separate row
// selection. No keybind by design — a low-frequency, list-management action.
//
// No getState chip: the current flag is already visible on the row itself, and
// the command context does not carry the settings store, so surfacing it here
// would mean threading settings into every command's context for one label.
// The command id and the settings key both still say "dispatch" even though
// the flag now renders in the pane header too. Neither is a description — both
// are persisted identifiers: the id keys `Settings.commandVisibilityOverrides`,
// so renaming it silently drops a user's saved show/hide choices, and the key
// holds the flags themselves, so renaming it costs a PERSIST_VERSION migration.
// The name is historical — the feature was born in Dispatch. Treat "dispatch
// color flag" as the concept's proper noun, not as a claim about where it
// renders. (The exported symbol below shares the prefix for consistency only;
// it is not persisted and can be renamed freely.)
//
// No getState chip: the current flag is already visible on the row and in the
// pane header, and the command context does not carry the settings store, so
// surfacing it here would mean threading settings into every command's context
// for one label.
export const dispatchColorFlagCommands: CommandDef[] = [
{
id: 'dispatch.color-flag.set',
surface: 'session',
title: 'Set color flag',
// Title case + ellipsis per docs/command-style.md rules 8 and 9: `run`
// always opens the swatch picker, so the command needs more input after
// invocation and must advertise that.
title: 'Set Color Flag…',
description:
'**What it does:** Marks the focused agent with a colored strip on the ' +
'right edge of its **Dispatch row**, so you can spot it in a long list.\n\n' +
'**What it does:** Marks the focused agent with a color — a chunk of its ' +
'**session header** in the grid, and a strip on the right edge of its ' +
'**Dispatch row**.\n\n' +
'**Use when:** You are juggling several agents and want to flag one ' +
'(e.g. red) to find it fast.',
keywords: ['color', 'colour', 'flag', 'highlight', 'mark', 'tag', 'dispatch', 'spot'],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,11 @@ import {
} from '@renderer/app-state/settings/dispatchColorFlags'
import type { SessionId } from '@renderer/workspace/types'

// The swatch picker for Dispatch color flags. Picking a swatch sets the flag and
// closes; "Clear flag" removes it. The strip itself renders on the Dispatch row
// (see DispatchAgentList). Kept intentionally tiny — this is a one-tap triage
// affordance, not a color editor.
// The swatch picker for color flags. Picking a swatch sets the flag and closes;
// "Clear flag" removes it. The flag itself renders in two places: the Dispatch
// row's trailing strip (see DispatchColorFlagStrip) and the pane's session
// header (see PaneHeaderColorFlag). Kept intentionally tiny — this is a one-tap
// triage affordance, not a color editor.
export function ColorFlagPickerModal({
open,
sessionId,
Expand Down
22 changes: 17 additions & 5 deletions src/renderer/src/workspace/dispatch/DispatchColorFlagStrip.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import { memo } from 'react'

import { useAppStore } from '@renderer/app-state/hooks'
import { colorFlagById } from '@renderer/app-state/settings/dispatchColorFlags'
import { useColorFlag } from '@renderer/app-state/settings/useColorFlag'
import type { SessionId } from '@renderer/workspace/types'

// WHY this is a real, always-mounted flex child rather than conditional
Expand All @@ -13,14 +12,27 @@ import type { SessionId } from '@renderer/workspace/types'
// which made the rich list look correct in isolation but gave Tiled Dispatch no
// reusable width contract at all. Keeping the settings lookup and the geometry
// in one component makes every Dispatch selector opt into the same invariant.
//
// The settings lookup itself now lives in `useColorFlag` because the pane
// header reads the same state with different geometry; only the geometry below
// is Dispatch-specific.
//
// WHY this one keeps `pointer-events-none` and gets no `title`, while its pane
// header counterpart takes the opposite choice: this strip sits INSIDE the
// Dispatch row's <button>, which already carries a richer tooltip (the agent
// label, title, and detached state — DispatchAgentList.tsx / DispatchMiniList.tsx).
// A `title` here would not add information, it would REPLACE that tooltip for
// the 10px the strip covers, so hovering the right edge of a row would tell you
// less than hovering anywhere else in it. Swallowing pointer events keeps the
// row's own hover and tooltip intact across its full width. The pane header has
// no such competing tooltip on its right edge, which is why the chunk there can
// afford one.
export const DispatchColorFlagStrip = memo(function DispatchColorFlagStrip({
sessionId,
}: {
sessionId: SessionId
}) {
const colorFlag = useAppStore(state =>
colorFlagById(state.settings.dispatchColorFlags[sessionId]),
)
const colorFlag = useColorFlag(sessionId)

return (
<span
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import type {
DispatchAgentRow,
DispatchTabGroup,
} from '@renderer/workspace/dispatch/dispatchSelectors'
import { PaneHeader } from '@renderer/workspace/tile-tree/TileLeaf/PaneHeader'

const appState = vi.hoisted(() => ({
settings: {
Expand Down Expand Up @@ -141,3 +142,113 @@ describe('Dispatch color-flag layout', () => {
expect(screen.getByRole('button', { name: 'Purple' })).toHaveAttribute('aria-pressed', 'false')
})
})

// WHY the pane-header cases live in this file rather than a new one: this file
// is the color-flag feature's coverage, not the Dispatch list's. The flag is
// one piece of state with several renderers, and keeping every renderer's
// contract in one place is what caught the tiled-lane gap that #605 had to fix.
describe('Session header color flag', () => {
function renderHeader(sessionId: string, statusMode: boolean) {
return render(
<PaneHeader
sessionId={sessionId}
paneLabel="A2"
projectDir="/Users/dev/agent-code"
statusMode={statusMode}
isSessionLive
/>,
)
}

it('paints a trailing chunk of the flag color over the status strip', () => {
setColorFlags({ [FLAGGED_SESSION_ID]: 'red' })
const { container } = renderHeader(FLAGGED_SESSION_ID, true)

const chunk = container.querySelector<HTMLElement>('[data-pane-color-flag]')
expect(chunk).toHaveAttribute('data-pane-color-flag', 'red')
expect(chunk).toHaveClass('w-1/4', 'flex-none', 'self-stretch')
expect(chunk).not.toHaveClass('absolute')
expect(chunk).toHaveStyle({ backgroundColor: '#ef4444' })
// The seam that keeps the chunk legible when the user's accent shares a hue
// with their flag color — without it a live pane can swallow the flag whole.
expect(chunk).toHaveClass('border-l', 'border-canvas')
// Hoverable on purpose: the tooltip is the only way to tell red from green
// apart for a colorblind user, and `pointer-events-none` would kill it.
expect(chunk).toHaveAttribute('title', 'Red flag')
expect(chunk).not.toHaveClass('pointer-events-none')
})

it('puts the chunk at the far right of the header, not beside the label', () => {
setColorFlags({ [FLAGGED_SESSION_ID]: 'red' })
const { container } = renderHeader(FLAGGED_SESSION_ID, true)

// WHY assert `justify-between` explicitly: it is the ONLY thing placing the
// chunk at the right edge. Every other assertion in this block passes with
// the chunk sitting in the middle of the header, immediately after the
// label group — which is the single most likely way to break this feature
// while keeping it looking implemented.
const row = container.querySelector<HTMLElement>('[data-pane-header-row="true"]')
expect(row).toHaveClass('justify-between')
expect(row?.lastElementChild).toHaveAttribute('data-pane-color-flag', 'red')
})

it('renders no chunk at all for an unflagged session', () => {
setColorFlags({ [FLAGGED_SESSION_ID]: 'red' })
const { container } = renderHeader(UNFLAGGED_SESSION_ID, true)

// WHY absence rather than a transparent placeholder — the deliberate
// difference from the Dispatch column. Nothing sits to the right of this
// chunk, so there is no cross-row alignment to preserve, and reserving a
// quarter of every header would permanently squeeze the project dir for a
// signal that is switched off.
//
// Note this renders the UNFLAGGED session while a different session is
// flagged, so it also proves the chunk is keyed by session id rather than
// rendering for whatever flag happens to exist in settings.
expect(container.querySelector('[data-pane-color-flag]')).toBeNull()
})

it('drops a stale persisted flag id instead of rendering a broken chunk', () => {
// The headline justification for routing both surfaces through
// `useColorFlag`: a palette entry removed in a future version leaves this
// id behind in persisted settings, and every reader must degrade to "no
// flag" rather than throwing or painting `undefined`.
setColorFlags({ [FLAGGED_SESSION_ID]: 'chartreuse' })
const { container } = renderHeader(FLAGGED_SESSION_ID, true)

expect(container.querySelector('[data-pane-color-flag]')).toBeNull()
})

it('carries all header padding on the label group so the chunk can bleed to every edge', () => {
setColorFlags({ [FLAGGED_SESSION_ID]: 'purple' })
const { container } = renderHeader(FLAGGED_SESSION_ID, false)

// The row must stay bare: `self-stretch` fills the row's CONTENT box, so
// any padding here becomes a gap the chunk cannot cross. `pl-3` in
// particular also shrinks the chunk, since `w-1/4` resolves against the
// content box.
const row = container.querySelector<HTMLElement>('[data-pane-header-row="true"]')
expect(row).not.toHaveClass('px-3')
expect(row).not.toHaveClass('pl-3')
expect(row).not.toHaveClass('py-1')
// ...and the padding must still exist one level down, so the header's
// rendered height and the label's insets are unchanged from before the
// chunk existed. `px-3` is what keeps the project dir off the pane edge on
// UNFLAGGED panes, where no chunk mounts to stand in for it.
expect(row?.firstElementChild).toHaveClass('px-3', 'py-1')
})

it('keeps Status Mode compact by dropping the label group to zero vertical padding', () => {
// Status Mode's whole point is a ~5px glance strip. An unconditional `py-1`
// on the group silently grows it to a 24px bar, which no other assertion in
// this block would catch — the padding case above runs with statusMode off,
// where `py-1` is correct.
setColorFlags({ [FLAGGED_SESSION_ID]: 'purple' })
const { container } = renderHeader(FLAGGED_SESSION_ID, true)

const row = container.querySelector<HTMLElement>('[data-pane-header-row="true"]')
expect(row).toHaveClass('min-h-[5px]')
expect(row?.firstElementChild).toHaveClass('py-0')
expect(row?.firstElementChild).not.toHaveClass('py-1')
})
})
1 change: 1 addition & 0 deletions src/renderer/src/workspace/tile-tree/TileLeaf.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -618,6 +618,7 @@ export function TileLeaf({
onMouseDown={onFocusRequest}
>
<PaneHeader
sessionId={sessionId}
paneLabel={paneLabel}
projectDir={runtime.projectDir}
statusMode={showStatusMode}
Expand Down
Loading