diff --git a/AGENTS.md b/AGENTS.md index ac602940ec..36f1316bef 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -281,7 +281,7 @@ stderr note). Provisioning, rotation, and revocation live in **Rename scope contract:** Claude, Codex, and OpenCode coding-agent sessions (terminal CLI panes and fresh* panes) have ONE durable saved name per session, shared by the pane header, the tab (when it takes its name from the session), the sidebar/history rows, the terminal presentation, and every API projection; renaming from any surface (UI inline editors and menus, the session/terminal/pane/tab routes, CLI verbs, MCP actions) writes that same record everywhere, automation defaults to a non-permanent automatic suggestion, and only Rename is exposed (no generate/reset/custom-label control). Everything else keeps the legacy four-scope model (layout-local pane/tab labels, terminal-scoped titles, provider-specific session overrides). The unified contract, native sync status semantics (finite unsynced recovery; no native human-intent provenance; serial generation queuing), and the migration recovery procedure (immutable `name-migration-v1` evidence + a deliberate Rename; never reactivate aliases) live in [docs/development/rename-scope-contract.md](docs/development/rename-scope-contract.md). -**Agent Status Indicators:** Blue/busy status is derived from provider activity slices through `resolvePaneActivity`; the unified needs-attention signal (green highlight + one bell) flows through `recordTurnComplete`/`recordTerminalIdle` and `useTurnCompletionNotifications`. The signal is server-authoritative everywhere and outcome-uniform: any turn end the user did not witness — finished, errored, max-turns, crashed (sidecar death with a turn in flight), mid-turn stream exception, wedged/stuck (codex deadman), or waiting on an approval/question — produces the identical attention treatment (tab highlight, sidebar row highlight, pane-header mark, and a single bell), with no distinction by outcome; only a USER-initiated interrupt is silent. Terminal CLI panes keep today's behavior unchanged (a per-event source discriminator partitions the hook — `recordTerminalIdle` stamps `source:'terminal'`, `recordTurnComplete` stamps `source:'freshAgent'`): terminals mark tab+pane attention on the truly-idle `terminal.idle` edge, suppress only the sound when watched, and coalesce a batch into one chime, while fresh-agent panes (freshclaude/kilroy/freshcodex/freshopencode) ring a discrete `freshAgent.turn.complete` edge emitted for every turn end except a user-initiated interrupt — freshclaude/kilroy for ANY SDK `result` subtype unless the sidecar's interrupt gate holds a pending accepted user-interrupt mark (armed only while a result is pending, set before the interrupt RPC, disarmed on RPC failure, consumed by the interrupted turn's own result — ordering-scoped, deliberately no reset-at-send), plus the consumeStream-finally mint when a pending turn ends with no result frame (stream exception or natural EOF) and a Rust-synthesized edge on an unrequested sidecar death mid-turn (requested teardown and idle deaths stay silent); freshcodex for `completed`/`failed`/absent statuses and non-user `interrupted` (the interrupt lane arms a per-session marker before the RPC and disarms it on RPC failure; the marker is consumed by the matching `interrupted` completion and cleared at the next `turn/started` — the opencode discipline, where `turn_aborted` clears at every turn dispatch — so only user-caused interrupts are silent); freshopencode for every settle except `turn_aborted`. Every fresh-agent edge carries a finite numeric strictly-monotonic per-session `at` (the client silently drops a malformed `at`) and folds in via `applyFreshAgentCompletion` under the `at`-monotonic dedupe regime (wall-clock `at`, no per-session counter, so a resumed durable session can't swallow completions across a server restart — a real restart also clears the per-terminal baselines). Attention flags are no longer persisted across reloads: a reload witnesses nothing (idle/busy state rehydrates; no highlight or bell ever replays a pre-load turn end). A watched fresh-agent ending (window focused + tab active) sets a tab-strip-only watched mark (`watchedCompletionByTab` — no sidebar row, no pane-header mark, no sound) that clears when the user navigates away and back. The fresh-agent bell rings audibly once per event through a serialized unbounded ring queue (a fresh chime per ring; the next ring starts when the previous chime ends or the built-in fallback tone completes; with sound disabled the highlights still appear, and an unplayable chime file substitutes a quiet built-in tone). The waiting-for-approval edge is ALSO server-authoritative: freshclaude/kilroy emit a discrete `freshAgent.turn.waiting` edge on the 0→≥1 pending permission/question transition, and codex's approval/question controls emit the same edge on their own 0→≥1 pending transition (Claude/kilroy and codex both raise approvals/questions); the client folds it in via `applyFreshAgentWaiting` under a distinct `${provider}:${sessionId}#waiting` dedupe namespace so it can never poison (or be poisoned by) the turn-complete bucket. The fragile client-side busy→idle derivation AND the client-side waiting-edge hook (`useAgentSessionTurnCompletion`) were both removed — all attention edges are server-emitted. freshcodex additionally self-heals a crashed/disconnected codex sidecar by consuming the runtime `onExit` hook in `subscribe()`, broadcasting the terminal `exited` status to clear BLUE while the exit watcher's crash arm rings the same unified edge when a turn was in flight (an idle crash stays silent). freshcodex also runs a wedged-sidecar deadman: after a bounded quiet window (default 10 min, env `FRESHELL_FRESHCODEX_QUIET_WINDOW_MS`) with a turn in flight and no sidecar events, the server stops asserting busy, marks the pane `stuck`, and fires the same unified edge; the client shows an amber "Agent appears stuck" card (`role="alert"`) with "Restart sidecar" (kill + resume re-mint) and "Start new conversation" actions as pane-level inline content. `freshopencode` still runs on a shared long-lived `opencode serve` sidecar and uses server-pushed `session.idle`/`session.status` events to drive busy. A freshAgent.send arriving while a native /compact drive is in flight — or while older queued sends are still pending — is queued server-side (FIFO, immediate send.accepted) and auto-driven when the compact settles; kill drops the queue, and the drain re-validates a captured observed fence through the lane op guard (stale pairs refuse typed, request-correlated), releasing the guard at the prompt's dispatch boundary. The runtime self-heals a shared-daemon death (the 2026-09-20 incident class): daemon loss fans a typed `freshAgent.error{code:"OPENCODE_DAEMON_LOST"}` edge out to exactly one frame per materialized session (the client's generic `sessionError` banner + busy-clear; the typed edge itself carries no chime — a daemon death mid-turn is a crashed turn end and rings the unified edge through the turn's own non-clean settle), the manager respawns the daemon on a backoff ladder (fresh-incident reset, crash-loop escalation), every successful cold start drives a level-triggered revival pass that restarts dead session bridges and pushes `freshAgent.session.snapshot{status:"idle"}` (the client's transcript-refetch trigger) while respecting the ownership coordinator (retired, transitioned, or terminal-owned sessions are never revived), and a generation-fenced `freshAgent.attach` is itself a recovery verb that respawns the daemon before re-bridging. Client-side, a snapshot GET that still answers the typed 409 `RESTORE_UNAVAILABLE` for the pane's own stale fresh-agent claim (e.g. the pane loaded while the server was restarting) does not dead-end on the dismiss-only banner: the pane drives the documented recovery ONCE per pane identity — it refreshes the observed owner fence from the refusal's own `ownerGeneration` (preserving the record's epoch), sends one generation-fenced `freshAgent.attach`, and refetches through the reveal path when reveal-dirty (so the "Refreshing conversation" overlay can clear) or via `manual` otherwise; a suppressed attach restores the once-guard, and repeated 409s fall through to the honest error banner — terminal-owned refusals stay out of this path (their recovery door is the session-directory handoff). Gemini and Kimi terminal modes are status-in... [truncated] Separately, the sidebar shows cross-device remote status rings around a session row's icon: a green ring means the session is open on another device, a blue ring means it is busy on another device (blue wins over green), and rings are suppressed entirely when the session is open on this device (derived from `tabs.sync` registry snapshots — producing clients stamp pane payloads with `sessionKeys`/`busySessionKeys`, consumers re-query remote snapshots on a 30s interval, and the server partitions same-device records into `sameDeviceOpen`, which never produces rings). +**Agent Status Indicators:** Blue/busy status is derived from provider activity slices through `resolvePaneActivity`; the unified needs-attention signal (green highlight + one bell) flows through `recordTurnComplete`/`recordTerminalIdle` and `useTurnCompletionNotifications`. The signal is server-authoritative everywhere and outcome-uniform: any turn end the user did not witness — finished, errored, max-turns, crashed (sidecar death with a turn in flight), mid-turn stream exception, wedged/stuck (codex deadman), or waiting on an approval/question — produces the identical attention treatment (tab highlight, sidebar row highlight, pane-header mark, and a single bell), with no distinction by outcome; only a USER-initiated interrupt is silent. Terminal CLI panes keep today's behavior unchanged (a per-event source discriminator partitions the hook — `recordTerminalIdle` stamps `source:'terminal'`, `recordTurnComplete` stamps `source:'freshAgent'`): terminals mark tab+pane attention on the truly-idle `terminal.idle` edge, suppress only the sound when watched, and coalesce a batch into one chime, while fresh-agent panes (freshclaude/kilroy/freshcodex/freshopencode) ring a discrete `freshAgent.turn.complete` edge emitted for every turn end except a user-initiated interrupt — freshclaude/kilroy for ANY SDK `result` subtype unless the sidecar's interrupt gate holds a pending accepted user-interrupt mark (armed only while a result is pending, set before the interrupt RPC, disarmed on RPC failure, consumed by the interrupted turn's own result — ordering-scoped, deliberately no reset-at-send), plus the consumeStream-finally mint when a pending turn ends with no result frame (stream exception or natural EOF) and a Rust-synthesized edge on an unrequested sidecar death mid-turn (requested teardown and idle deaths stay silent); freshcodex for `completed`/`failed`/absent statuses and non-user `interrupted` (the interrupt lane arms a per-session marker before the RPC and disarms it on RPC failure; the marker is consumed by the matching `interrupted` completion and cleared at the next `turn/started` — the opencode discipline, where `turn_aborted` clears at every turn dispatch — so only user-caused interrupts are silent); freshopencode for every settle except `turn_aborted`. Every fresh-agent edge carries a finite numeric strictly-monotonic per-session `at` (the client silently drops a malformed `at`) and folds in via `applyFreshAgentCompletion` under the `at`-monotonic dedupe regime (wall-clock `at`, no per-session counter, so a resumed durable session can't swallow completions across a server restart — a real restart also clears the per-terminal baselines). Attention flags are no longer persisted across reloads: a reload witnesses nothing (idle/busy state rehydrates; no highlight or bell ever replays a pre-load turn end). A watched fresh-agent ending (window focused + tab active) sets a tab-strip-only watched mark (`watchedCompletionByTab` — no sidebar row, no pane-header mark, no sound) that clears when the user navigates away and back. The fresh-agent bell rings audibly once per event through a serialized unbounded ring queue (a fresh chime per ring; the next ring starts when the previous chime ends or is paused, or the built-in fallback tone completes; an unplayable or aborted chime file substitutes a quiet built-in tone; a 5 s per-ring watchdog (`RING_WATCHDOG_MS` in `src/hooks/useNotificationSound.ts`, logged as the `notification_ring_watchdog_fired` warn event) releases a ring whose completion signal never arrives, so one dead ring cannot silence later bells; with sound disabled the highlights still appear). The waiting-for-approval edge is ALSO server-authoritative: freshclaude/kilroy emit a discrete `freshAgent.turn.waiting` edge on the 0→≥1 pending permission/question transition, and codex's approval/question controls emit the same edge on their own 0→≥1 pending transition (Claude/kilroy and codex both raise approvals/questions); the client folds it in via `applyFreshAgentWaiting` under a distinct `${provider}:${sessionId}#waiting` dedupe namespace so it can never poison (or be poisoned by) the turn-complete bucket. The fragile client-side busy→idle derivation AND the client-side waiting-edge hook (`useAgentSessionTurnCompletion`) were both removed — all attention edges are server-emitted. freshcodex additionally self-heals a crashed/disconnected codex sidecar by consuming the runtime `onExit` hook in `subscribe()`, broadcasting the terminal `exited` status to clear BLUE while the exit watcher's crash arm rings the same unified edge when a turn was in flight (an idle crash stays silent). freshcodex also runs a wedged-sidecar deadman: after a bounded quiet window (default 10 min, env `FRESHELL_FRESHCODEX_QUIET_WINDOW_MS`) with a turn in flight and no sidecar events, the server stops asserting busy, marks the pane `stuck`, and fires the same unified edge; the client shows an amber "Agent appears stuck" card (`role="alert"`) with "Restart sidecar" (kill + resume re-mint) and "Start new conversation" actions as pane-level inline content. `freshopencode` still runs on a shared long-lived `opencode serve` sidecar and uses server-pushed `session.idle`/`session.status` events to drive busy. A freshAgent.send arriving while a native /compact drive is in flight — or while older queued sends are still pending — is queued server-side (FIFO, immediate send.accepted) and auto-driven when the compact settles; kill drops the queue, and the drain re-validates a captured observed fence through the lane op guard (stale pairs refuse typed, request-correlated), releasing the guard at the prompt's dispatch boundary. The runtime self-heals a shared-daemon death (the 2026-09-20 incident class): daemon loss fans a typed `freshAgent.error{code:"OPENCODE_DAEMON_LOST"}` edge out to exactly one frame per materialized session (the client's generic `sessionError` banner + busy-clear; the typed edge itself carries no chime — a daemon death mid-turn is a crashed turn end and rings the unified edge through the turn's own non-clean settle), the manager respawns the daemon on a backoff ladder (fresh-incident reset, crash-loop escalation), every successful cold start drives a level-triggered revival pass that restarts dead session bridges and pushes `freshAgent.session.snapshot{status:"idle"}` (the client's transcript-refetch trigger) while respecting the ownership coordinator (retired, transitioned, or terminal-owned sessions are never revived), and a generation-fenced `freshAgent.attach` is itself a recovery verb that respawns the daemon before re-bridging. Client-side, a snapshot GET that still answers the typed 409 `RESTORE_UNAVAILABLE` for the pane's own stale fresh-agent claim (e.g. the pane loaded while the server was restarting) does not dead-end on the dismiss-only banner: the pane drives the documented recovery ONCE per pane identity — it refreshes the observed owner fence from the refusal's own `ownerGeneration` (preserving the record's epoch), sends one generation-fenced `freshAgent.attach`, and refetches through the reveal path when reveal-dirty (so the "Refreshing conversation" overlay can clear) or via `manual` otherwise; a suppressed attach restores the once-guard, and repeated 409s fall through to the honest error banner — terminal-owned refusals stay out of this path (their recovery door is the session-directory handoff). Gemini and Kimi terminal modes are status-in... [truncated] Separately, the sidebar shows cross-device remote status rings around a session row's icon: a green ring means the session is open on another device, a blue ring means it is busy on another device (blue wins over green), and rings are suppressed entirely when the session is open on this device (derived from `tabs.sync` registry snapshots — producing clients stamp pane payloads with `sessionKeys`/`busySessionKeys`, consumers re-query remote snapshots on a 30s interval, and the server partitions same-device records into `sameDeviceOpen`, which never produces rings). **Fresh-Agent Orchestration:** The Rust REST agent API (`/api/tabs`, `/api/panes/:id/split`, `/api/panes/:id/send-keys`, `/api/panes/:id/capture`, `/api/panes/:id/wait-for`) and the standalone Node MCP client accept `agent`/`model`/`effort` parameters where the Rust contract supports them. The Rust orchestration layer dispatches to the registered fresh-agent runtimes. On MCP `new-tab`, resume sugar (`resume`/`resumeSessionId`) is honored for `agent: "opencode"`; terminal-mode resume uses an explicit provider-matched `sessionRef` (raw Codex resume IDs are rejected because they are not sufficient restore identity). Unsupported legacy actions return a deterministic unavailable result instead of contacting a removed backend route. diff --git a/crates/freshell-server/src/settings_store.rs b/crates/freshell-server/src/settings_store.rs index 86aab3dfd6..3cbe1e99e0 100644 --- a/crates/freshell-server/src/settings_store.rs +++ b/crates/freshell-server/src/settings_store.rs @@ -2677,6 +2677,59 @@ mod tests { std::fs::remove_dir_all(&dir).ok(); } + /// G11: the effort levels saved with a fresh-pane default model travel + /// with it through PATCH, GET, config.json and restart, and the client's + /// `null` clear sentinel removes them (the `freshAgent.providers` subtree + /// is opaque to the server and deep-merged). + #[tokio::test] + async fn fresh_agent_provider_model_effort_levels_round_trip_and_clear() { + let dir = std::env::temp_dir().join(format!("frs-settings-{}", uuid_like())); + std::fs::create_dir_all(dir.join(".freshell")).unwrap(); + let store = store_at(&dir); + let levels = json!({ "modelId": "claude-haiku-x", "levels": [] }); + + store + .patch(&json!({ "freshAgent": { "providers": { "freshclaude": { + "modelSelection": { "kind": "exact", "modelId": "claude-haiku-x" }, + "modelEffortLevels": levels, + } } } })) + .await + .unwrap(); + let got = store.get().await; + assert_eq!( + got.fresh_agent.providers["freshclaude"]["modelEffortLevels"], + levels + ); + + // An unrelated provider patch keeps the levels. + store + .patch( + &json!({ "freshAgent": { "providers": { "freshclaude": { "effort": "low" } } } }), + ) + .await + .unwrap(); + let restarted = store_at(&dir); + let after = restarted.get().await; + assert_eq!( + after.fresh_agent.providers["freshclaude"]["modelEffortLevels"], + levels + ); + assert_eq!(after.fresh_agent.providers["freshclaude"]["effort"], "low"); + + // The clear sentinel removes them. + let cleared = restarted + .patch(&json!({ "freshAgent": { "providers": { "freshclaude": { "modelEffortLevels": null } } } })) + .await + .unwrap(); + assert!(cleared.fresh_agent.providers["freshclaude"]["modelEffortLevels"].is_null()); + assert_eq!( + cleared.fresh_agent.providers["freshclaude"]["modelSelection"]["modelId"], + "claude-haiku-x" + ); + + std::fs::remove_dir_all(&dir).ok(); + } + fn valid5() -> Vec { ["claude", "codex", "gemini", "kimi", "opencode"] .iter() diff --git a/docs/index.html b/docs/index.html index 0df5db8808..8e509e1966 100644 --- a/docs/index.html +++ b/docs/index.html @@ -87,6 +87,10 @@ /* On mobile (<768px): tab bar is replaced by MobileTabStrip (centered tab name with chevrons + tab switcher grid overlay). At the right edge, the chevron becomes a + action on the last tab to create a new tab. Touch targets are 44px min. + Needs-attention marks match the desktop tabs (and the "Tab attention style" + setting): the active title takes the green pill, a green dot plus count reads + "N other tabs need attention", the chevron toward a marked tab gets a dot, and + switcher cards show a "Needs attention" badge; tapping a card dismisses it. Sidebar opens/closes via swipe gesture and uses full-width drawer mode. Pull-to-refresh is disabled, safe-area insets are respected, and fullscreen/landscape mode minimizes chrome for terminal-focused use. */ diff --git a/shared/settings.ts b/shared/settings.ts index 981b6abd75..a02e4da65e 100644 --- a/shared/settings.ts +++ b/shared/settings.ts @@ -141,11 +141,23 @@ export type CodingCliSettings = { mcpServer: boolean } +/** + * The effort levels a fresh-pane default model offers, captured when the + * model was picked (catalog-only claude models are absent from the static + * model table). Paired with the model id so a later model change can never + * pick up the wrong levels; an empty list means the model declares none. + */ +export type FreshAgentModelEffortLevels = { + modelId: string + levels: string[] +} + export type FreshAgentProviderDefaults = { modelSelection?: FreshAgentModelSelection defaultPermissionMode?: string effort?: FreshAgentEffort style?: FreshAgentStyle + modelEffortLevels?: FreshAgentModelEffortLevels } export type ServerSettings = { @@ -746,6 +758,13 @@ function createCodingCliProviderConfigPatchSchema() { .strict() } +const FreshAgentModelEffortLevelsSchema = z + .object({ + modelId: z.string().min(1), + levels: z.array(z.string()), + }) + .strict() + function createFreshAgentProviderDefaultsSchema() { return z .object({ @@ -753,6 +772,7 @@ function createFreshAgentProviderDefaultsSchema() { defaultPermissionMode: z.string().optional(), effort: FreshAgentModelCapabilitiesOpaqueStringSchema.optional(), style: FreshAgentStyleSchema.optional(), + modelEffortLevels: FreshAgentModelEffortLevelsSchema.optional(), }) .strict() } @@ -764,6 +784,7 @@ function createFreshAgentProviderDefaultsPatchSchema() { defaultPermissionMode: z.string().optional(), effort: z.union([FreshAgentModelCapabilitiesOpaqueStringSchema, z.literal('')]).nullable().optional(), style: FreshAgentStyleSchema.optional(), + modelEffortLevels: FreshAgentModelEffortLevelsSchema.nullable().optional(), }) .strict() } @@ -1030,6 +1051,9 @@ function sanitizeFreshAgentServerSettingsPatchInput( if (hasOwn(normalizedProviderPatchInput, 'style')) { normalizedProviderPatch.style = parsed.data.style } + if (hasOwn(normalizedProviderPatchInput, 'modelEffortLevels')) { + normalizedProviderPatch.modelEffortLevels = parsed.data.modelEffortLevels ?? undefined + } providers[providerName] = normalizedProviderPatch } } @@ -1268,7 +1292,7 @@ function normalizeLegacyFreshAgentProviderDefaultsInput( const normalized = pickOwnKeysPreservingUndefined( providerPatch, - ['modelSelection', 'defaultPermissionMode', 'effort', 'style'], + ['modelSelection', 'defaultPermissionMode', 'effort', 'style', 'modelEffortLevels'], ) if ( diff --git a/src/components/MobileTabStrip.tsx b/src/components/MobileTabStrip.tsx index 53a1168b75..d380044975 100644 --- a/src/components/MobileTabStrip.tsx +++ b/src/components/MobileTabStrip.tsx @@ -1,5 +1,10 @@ +import { useId } from 'react' import { ChevronLeft, ChevronRight, PanelLeft, Plus } from 'lucide-react' import { useAppDispatch, useAppSelector } from '@/store/hooks' +import { selectAttentionTabIds, selectTabAttentionStyle } from '@/store/selectors/tabAttentionSelectors' +import { otherTabsNeedAttentionLabel, tabAttentionClassName } from '@/lib/tab-attention' +import { cn } from '@/lib/utils' +import { TabAttentionIndicator } from '@/components/TabAttentionIndicator' import { addTab, switchToNextTab, switchToPrevTab } from '@/store/tabsSlice' import { getTabDisplayTitle } from '@/lib/tab-title' import { getBusyPaneIdsForTab } from '@/lib/pane-activity' @@ -35,10 +40,25 @@ export function MobileTabStrip({ onOpenSwitcher, sidebarCollapsed, onToggleSideb (s) => s.paneRuntimeActivity?.byPaneId ?? EMPTY_PANE_RUNTIME_ACTIVITY_BY_ID ) const extensions = useAppSelector((s) => s.extensions?.entries) + const attentionTabIds = useAppSelector(selectAttentionTabIds) + const tabAttentionStyle = useAppSelector(selectTabAttentionStyle) + const descriptionIdBase = useId() const activeIndex = tabs.findIndex((t) => t.id === activeTabId) const activeTab = activeIndex >= 0 ? tabs[activeIndex] : null + // Needs-attention marks (same style-gated union as the desktop tabs): the + // active title, a count of the OTHER marked tabs, and a dot on the chevron + // pointing toward marked tabs. + const activeNeedsAttention = Boolean(activeTab && attentionTabIds.includes(activeTab.id)) + const otherAttentionCount = attentionTabIds.length - (activeNeedsAttention ? 1 : 0) + const attentionIndexes = attentionTabIds.map((id) => tabs.findIndex((t) => t.id === id)) + const earlierNeedsAttention = activeIndex >= 0 && attentionIndexes.some((index) => index >= 0 && index < activeIndex) + const laterNeedsAttention = activeIndex >= 0 && attentionIndexes.some((index) => index > activeIndex) + const activeDescriptionId = `${descriptionIdBase}-active-attention` + const prevDescriptionId = `${descriptionIdBase}-prev-attention` + const nextDescriptionId = `${descriptionIdBase}-next-attention` + const displayTitle = activeTab ? getTabDisplayTitle(activeTab, paneLayouts[activeTab.id], paneTitles?.[activeTab.id], extensions) : '' @@ -69,6 +89,9 @@ export function MobileTabStrip({ onOpenSwitcher, sidebarCollapsed, onToggleSideb return (
+ {activeNeedsAttention && This tab needs attention} + {earlierNeedsAttention && An earlier tab needs attention} + {laterNeedsAttention && A later tab needs attention} {sidebarCollapsed && onToggleSidebar && ( + +
) diff --git a/src/components/TabAttentionIndicator.tsx b/src/components/TabAttentionIndicator.tsx new file mode 100644 index 0000000000..e5d8691bd9 --- /dev/null +++ b/src/components/TabAttentionIndicator.tsx @@ -0,0 +1,59 @@ +import { cn } from '@/lib/utils' +import { tabAttentionClassName } from '@/lib/tab-attention' +import type { TabAttentionStyle } from '@/store/types' + +interface TabAttentionIndicatorProps { + /** How many tabs carry the mark; the status is empty at 0. Omit for a bare mark. */ + count?: number + /** What the mark means, e.g. "2 other tabs need attention". */ + label: string + /** `dot`: a decorative mark inside a control whose own name/title carries + * the meaning (aria-hidden). `count` (default): a polite live status. */ + variant?: 'count' | 'dot' + /** The user's tabAttentionStyle (named apart from React's `style` prop). */ + attentionStyle?: TabAttentionStyle + className?: string +} + +/** + * The shared needs-attention mark for tab surfaces (mobile tab strip, its + * chevrons). Visual tokens come from `tabAttentionClassName`, so it follows + * the user's tabAttentionStyle like the desktop tabs. + */ +export function TabAttentionIndicator({ + count, + label, + variant = 'count', + attentionStyle = 'highlight', + className, +}: TabAttentionIndicatorProps) { + const dotClassName = cn( + 'inline-block h-2 w-2 shrink-0 rounded-full', + tabAttentionClassName({ needsAttention: true, style: attentionStyle, surface: 'indicator' }), + ) + + if (variant === 'dot') { + return