From bfa15f2b7b35a38a3f1392b97b7184aaa6549a06 Mon Sep 17 00:00:00 2001 From: Jacob Magar Date: Mon, 25 May 2026 01:09:11 -0400 Subject: [PATCH 01/11] feat(chat): group codex model picker by base + effort segments Add `model-grouping` helper that splits codex model ids like `gpt-5.5/medium` or `GPT-5.5 (medium)` into `(base, effort)` tuples. When every option in the list parses cleanly, the picker collapses 20 rows into 5 base rows with inline low/medium/high/xhigh toggle pills; otherwise it falls back to the existing flat listbox so claude/gemini/cline pickers keep their current shape. Extract `nextNavIndex` + `useListKeyboard` to dedup the agent/model picker keyboard navigation. The pure helper is unit-tested; the hook supplies auto-reset-on-shrink so a provider switch that shrinks the option list doesn't leave activeIndex pointing past the end. Refs lab-de6yc.4 --- .../components/chat/chat-input.tsx | 191 +++++++++++------- .../lib/chat/model-grouping.test.ts | 62 ++++++ apps/gateway-admin/lib/chat/model-grouping.ts | 49 +++++ .../lib/chat/use-list-keyboard.test.ts | 30 +++ .../lib/chat/use-list-keyboard.ts | 54 +++++ 5 files changed, 316 insertions(+), 70 deletions(-) create mode 100644 apps/gateway-admin/lib/chat/model-grouping.test.ts create mode 100644 apps/gateway-admin/lib/chat/model-grouping.ts create mode 100644 apps/gateway-admin/lib/chat/use-list-keyboard.test.ts create mode 100644 apps/gateway-admin/lib/chat/use-list-keyboard.ts diff --git a/apps/gateway-admin/components/chat/chat-input.tsx b/apps/gateway-admin/components/chat/chat-input.tsx index 35e312e78..ea2062121 100644 --- a/apps/gateway-admin/components/chat/chat-input.tsx +++ b/apps/gateway-admin/components/chat/chat-input.tsx @@ -12,6 +12,7 @@ import { DropdownMenuTrigger, } from '@/components/ui/dropdown-menu' import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip' +import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group' import type { ACPAgent, ACPModelOption } from './types' import { createLocalAttachmentDraft, @@ -19,6 +20,8 @@ import { revokeLocalAttachmentPreview, validateLocalFiles, } from '@/lib/chat/local-attachments' +import { groupModels } from '@/lib/chat/model-grouping' +import { nextNavIndex, useListKeyboard } from '@/lib/chat/use-list-keyboard' import type { AttachmentRef, LocalAttachmentDraft, PromptAttachmentRef } from '@/lib/fs/types' import { isInlineImageMime, previewWorkspaceFile } from '@/lib/fs/client' import { WorkspacePicker } from './workspace-picker' @@ -89,10 +92,15 @@ export function ChatInput({ const modelPickerRef = React.useRef(null) const modelTriggerRef = React.useRef(null) const modelOptionRefs = React.useRef>([]) - const [activeAgentIndex, setActiveAgentIndex] = React.useState(0) - const [activeModelIndex, setActiveModelIndex] = React.useState(0) + const agentNav = useListKeyboard({ count: agents.length }) + const modelNav = useListKeyboard({ count: modelOptions.length }) + const activeAgentIndex = agentNav.activeIndex + const setActiveAgentIndex = agentNav.setActiveIndex + const activeModelIndex = modelNav.activeIndex + const setActiveModelIndex = modelNav.setActiveIndex const pickerId = React.useId() const modelPickerId = React.useId() + const groupedModels = React.useMemo(() => groupModels(modelOptions), [modelOptions]) const value = draftText ?? uncontrolledValue const setValue = React.useCallback( @@ -300,26 +308,17 @@ export function ChatInput({ if (agents.length === 0) return - const moveTo = (nextIndex: number) => { - setActiveAgentIndex(nextIndex) - optionRefs.current[nextIndex]?.focus() + if ((event.key === 'Enter' || event.key === ' ') && agents[activeAgentIndex]) { + event.preventDefault() + selectAgent(agents[activeAgentIndex].id) + return } - if (event.key === 'ArrowDown') { - event.preventDefault() - moveTo((activeAgentIndex + 1) % agents.length) - } else if (event.key === 'ArrowUp') { - event.preventDefault() - moveTo((activeAgentIndex - 1 + agents.length) % agents.length) - } else if (event.key === 'Home') { + const next = nextNavIndex(activeAgentIndex, event.key, agents.length) + if (next !== null) { event.preventDefault() - moveTo(0) - } else if (event.key === 'End') { - event.preventDefault() - moveTo(agents.length - 1) - } else if ((event.key === 'Enter' || event.key === ' ') && agents[activeAgentIndex]) { - event.preventDefault() - selectAgent(agents[activeAgentIndex].id) + setActiveAgentIndex(next) + optionRefs.current[next]?.focus() } } @@ -363,26 +362,21 @@ export function ChatInput({ if (modelOptions.length === 0) return - const moveTo = (nextIndex: number) => { - setActiveModelIndex(nextIndex) - modelOptionRefs.current[nextIndex]?.focus() - } + // In grouped mode each row owns a ToggleGroup with its own keyboard nav — + // bow out so we don't double-handle arrow keys. + if (groupedModels.kind === 'grouped') return - if (event.key === 'ArrowDown') { - event.preventDefault() - moveTo((activeModelIndex + 1) % modelOptions.length) - } else if (event.key === 'ArrowUp') { - event.preventDefault() - moveTo((activeModelIndex - 1 + modelOptions.length) % modelOptions.length) - } else if (event.key === 'Home') { - event.preventDefault() - moveTo(0) - } else if (event.key === 'End') { - event.preventDefault() - moveTo(modelOptions.length - 1) - } else if ((event.key === 'Enter' || event.key === ' ') && modelOptions[activeModelIndex]) { + if ((event.key === 'Enter' || event.key === ' ') && modelOptions[activeModelIndex]) { event.preventDefault() selectModel(modelOptions[activeModelIndex].id) + return + } + + const next = nextNavIndex(activeModelIndex, event.key, modelOptions.length) + if (next !== null) { + event.preventDefault() + setActiveModelIndex(next) + modelOptionRefs.current[next]?.focus() } } @@ -516,9 +510,13 @@ export function ChatInput({ {modelPickerOpen && (
- {modelOptions.map((model, index) => { - const optionButton = ( - - ) - if (!model.description) return optionButton - return ( - - {optionButton} - - {model.description} - - - ) - })} + {groupedModels.kind === 'flat' ? ( + modelOptions.map((model, index) => { + const optionButton = ( + + ) + if (!model.description) return optionButton + return ( + + {optionButton} + + {model.description} + + + ) + }) + ) : ( + groupedModels.groups.map((group) => { + const selectedVariant = group.variants.find( + (v) => v.option.id === selectedModel?.id, + ) + return ( +
+ + {group.base} + + { + if (!effort) return + const variant = group.variants.find((v) => v.effort === effort) + if (variant) selectModel(variant.option.id) + }} + className="h-7 shrink-0" + > + {group.variants.map((variant) => { + const item = ( + + {variant.effort} + + ) + if (!variant.option.description) return item + return ( + + {item} + + {variant.option.description} + + + ) + })} + +
+ ) + }) + )}
)} diff --git a/apps/gateway-admin/lib/chat/model-grouping.test.ts b/apps/gateway-admin/lib/chat/model-grouping.test.ts new file mode 100644 index 000000000..d5f577a8e --- /dev/null +++ b/apps/gateway-admin/lib/chat/model-grouping.test.ts @@ -0,0 +1,62 @@ +import test from 'node:test' +import assert from 'node:assert/strict' + +import { groupModels, parseModelId } from './model-grouping' +import type { ACPModelOption } from '@/components/chat/types' + +function option(id: string, name?: string): ACPModelOption { + return { id, name: name ?? id } +} + +test('parseModelId handles slash separator', () => { + assert.deepEqual(parseModelId('gpt-5.5/medium'), { base: 'gpt-5.5', effort: 'medium' }) +}) + +test('parseModelId handles paren separator after normalization', () => { + assert.deepEqual(parseModelId('GPT-5.5 (medium)'), { base: 'GPT-5.5', effort: 'medium' }) +}) + +test('parseModelId returns null for non-effort suffixes', () => { + assert.equal(parseModelId('gpt-5.4-mini'), null) + assert.equal(parseModelId('gpt-5.3-codex-spark'), null) + assert.equal(parseModelId('Default (recommended)'), null) + assert.equal(parseModelId('Auto (Gemini 3)'), null) +}) + +test('parseModelId rejects names containing slash that are not effort suffixes', () => { + assert.equal(parseModelId('OpenCode Zen/Big Pickle'), null) +}) + +test('groupModels returns grouped result for codex-style list', () => { + const opts = [ + option('gpt-5.5/low', 'GPT-5.5 (low)'), + option('gpt-5.5/medium', 'GPT-5.5 (medium)'), + option('gpt-5.5/high', 'GPT-5.5 (high)'), + option('gpt-5.5/xhigh', 'GPT-5.5 (xhigh)'), + option('gpt-5.4/low', 'GPT-5.4 (low)'), + ] + const result = groupModels(opts) + assert.equal(result.kind, 'grouped') + if (result.kind !== 'grouped') return + assert.equal(result.groups.length, 2) + assert.equal(result.groups[0].base, 'gpt-5.5') + assert.equal(result.groups[0].variants.length, 4) + assert.deepEqual( + result.groups[0].variants.map((v) => v.effort), + ['low', 'medium', 'high', 'xhigh'], + ) +}) + +test('groupModels returns flat for the claude default option', () => { + assert.equal(groupModels([option('default', 'Default (recommended)')]).kind, 'flat') +}) + +test('groupModels returns flat if any option fails to parse', () => { + const opts = [option('gpt-5.5/medium', 'GPT-5.5 (medium)'), option('gpt-5.4-mini', 'GPT-5.4-Mini')] + assert.equal(groupModels(opts).kind, 'flat') +}) + +test('groupModels returns flat for empty and single-option lists', () => { + assert.equal(groupModels([]).kind, 'flat') + assert.equal(groupModels([option('x/medium', 'X')]).kind, 'flat') +}) diff --git a/apps/gateway-admin/lib/chat/model-grouping.ts b/apps/gateway-admin/lib/chat/model-grouping.ts new file mode 100644 index 000000000..da7a6df9a --- /dev/null +++ b/apps/gateway-admin/lib/chat/model-grouping.ts @@ -0,0 +1,49 @@ +import type { ACPModelOption } from '@/components/chat/types' + +export type Effort = 'low' | 'medium' | 'high' | 'xhigh' + +const EFFORT_ORDER: readonly Effort[] = ['low', 'medium', 'high', 'xhigh'] + +export function parseModelId(id: string): { base: string; effort: Effort } | null { + const normalized = id.replace(/\s*\(([^)]+)\)\s*$/, ' $1') + const match = /^(.+?)[\s/]\s*(low|medium|high|xhigh)$/i.exec(normalized) + if (!match) return null + const base = match[1].trim() + const effort = match[2].toLowerCase() as Effort + if (base.includes('/')) return null + return { base, effort } +} + +export interface GroupedOption { + base: string + variants: Array<{ effort: Effort; option: ACPModelOption }> +} + +export type GroupingResult = + | { kind: 'flat'; options: ACPModelOption[] } + | { kind: 'grouped'; groups: GroupedOption[] } + +export function groupModels(options: ACPModelOption[]): GroupingResult { + if (options.length <= 1) return { kind: 'flat', options } + + const parsed = options.map((option) => ({ option, parsed: parseModelId(option.id) })) + if (parsed.some((p) => p.parsed === null)) { + return { kind: 'flat', options } + } + + const groupMap = new Map() + for (const { option, parsed: p } of parsed) { + if (!p) continue + const existing = groupMap.get(p.base) ?? { base: p.base, variants: [] } + existing.variants.push({ effort: p.effort, option }) + groupMap.set(p.base, existing) + } + + for (const group of groupMap.values()) { + group.variants.sort( + (a, b) => EFFORT_ORDER.indexOf(a.effort) - EFFORT_ORDER.indexOf(b.effort), + ) + } + + return { kind: 'grouped', groups: Array.from(groupMap.values()) } +} diff --git a/apps/gateway-admin/lib/chat/use-list-keyboard.test.ts b/apps/gateway-admin/lib/chat/use-list-keyboard.test.ts new file mode 100644 index 000000000..174926816 --- /dev/null +++ b/apps/gateway-admin/lib/chat/use-list-keyboard.test.ts @@ -0,0 +1,30 @@ +import test from 'node:test' +import assert from 'node:assert/strict' + +import { nextNavIndex } from './use-list-keyboard' + +test('ArrowDown wraps at end', () => { + assert.equal(nextNavIndex(0, 'ArrowDown', 3), 1) + assert.equal(nextNavIndex(2, 'ArrowDown', 3), 0) +}) + +test('ArrowUp wraps at start', () => { + assert.equal(nextNavIndex(0, 'ArrowUp', 3), 2) + assert.equal(nextNavIndex(1, 'ArrowUp', 3), 0) +}) + +test('Home and End jump to ends', () => { + assert.equal(nextNavIndex(2, 'Home', 5), 0) + assert.equal(nextNavIndex(0, 'End', 5), 4) +}) + +test('returns null for empty list', () => { + assert.equal(nextNavIndex(0, 'ArrowDown', 0), null) + assert.equal(nextNavIndex(0, 'Home', 0), null) +}) + +test('returns null for unrelated keys', () => { + assert.equal(nextNavIndex(0, 'a', 5), null) + assert.equal(nextNavIndex(0, 'Enter', 5), null) + assert.equal(nextNavIndex(0, 'Tab', 5), null) +}) diff --git a/apps/gateway-admin/lib/chat/use-list-keyboard.ts b/apps/gateway-admin/lib/chat/use-list-keyboard.ts new file mode 100644 index 000000000..3d81911ac --- /dev/null +++ b/apps/gateway-admin/lib/chat/use-list-keyboard.ts @@ -0,0 +1,54 @@ +import * as React from 'react' + +const NAV_KEYS = new Set(['ArrowDown', 'ArrowUp', 'Home', 'End']) + +/** + * Pure navigation reducer for keyboard list pickers. Returns the next index + * for a given key press, or null if the key is not a navigation key. Wraps + * at both ends. Returns null when count is 0. + * + * Callers wire this up alongside DOM focus management (e.g., focusing the + * underlying option button) — keeping that side-effect outside the helper + * keeps the helper deterministic and unit-testable. + */ +export function nextNavIndex(current: number, key: string, count: number): number | null { + if (count <= 0 || !NAV_KEYS.has(key)) return null + switch (key) { + case 'ArrowDown': + return (current + 1) % count + case 'ArrowUp': + return (current - 1 + count) % count + case 'Home': + return 0 + case 'End': + return count - 1 + default: + return null + } +} + +export interface ListKeyboardHandle { + activeIndex: number + setActiveIndex: React.Dispatch> +} + +/** + * Shared state shape for keyboard-navigable lists. Tracks an active index and + * resets to 0 when `count` shrinks past the current index (e.g., the list + * collapses on a provider switch). Pair with `nextNavIndex` to react to keys. + */ +export function useListKeyboard({ + count, + initialIndex = 0, +}: { + count: number + initialIndex?: number +}): ListKeyboardHandle { + const [activeIndex, setActiveIndex] = React.useState(initialIndex) + + React.useEffect(() => { + if (count > 0 && activeIndex >= count) setActiveIndex(0) + }, [count, activeIndex]) + + return { activeIndex, setActiveIndex } +} From d550a5b132802f929fed7eadee7b3cce99abb3b9 Mon Sep 17 00:00:00 2001 From: Jacob Magar Date: Mon, 25 May 2026 01:22:58 -0400 Subject: [PATCH 02/11] feat(acp): add session.bulk_close dispatch action with typed selector MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a destructive `acp.session.bulk_close` action that closes every session the caller owns matching a typed `BulkCloseSelector { states?, max_age_days?, max_count? }`. Returns the canonical batch-result envelope `{ closed: string[], failed: [{ id, kind, message }] }` documented in docs/dev/ERRORS.md. Sessions owned by other principals are silently omitted to preserve the not_found masking pattern of close_session. Implementation notes: - `BulkCloseSelector::validate_non_empty` blocks the delete-all shortcut. - `max_count` (default 500) caps the fan-out — selector matches above the cap return invalid_param without touching state. - Per-session closes run concurrently bounded by a 5-permit semaphore. - Dispatcher arm explicitly calls `require_confirm`, with a regression test that proves the gate fires even if a surface bypasses destructive elicitation. - New `ToolError::user_message()` accessor exposes the inner message text so `BulkCloseFailure.message` doesn't double-encode the JSON envelope. Tests: empty-selector rejection, missing-confirm rejection, and cross-principal isolation against fake sessions. Refs lab-de6yc.1 --- crates/lab/src/acp/registry.rs | 139 ++++++++++++++++++++++++ crates/lab/src/dispatch/acp/catalog.rs | 23 +++- crates/lab/src/dispatch/acp/dispatch.rs | 96 +++++++++++++++- crates/lab/src/dispatch/acp/params.rs | 35 ++++++ crates/lab/src/dispatch/error.rs | 17 +++ docs/dev/ERRORS.md | 20 ++++ 6 files changed, 328 insertions(+), 2 deletions(-) diff --git a/crates/lab/src/acp/registry.rs b/crates/lab/src/acp/registry.rs index d98c33723..0d0e58b36 100644 --- a/crates/lab/src/acp/registry.rs +++ b/crates/lab/src/acp/registry.rs @@ -23,6 +23,7 @@ use lab_apis::acp::types::{ AcpEvent, AcpModelOption, AcpProviderHealth, AcpSessionState, AcpSessionSummary, }; +use crate::dispatch::acp::params::BulkCloseSelector; use crate::dispatch::acp::persistence::SqliteAcpPersistence; use crate::dispatch::error::ToolError; @@ -127,6 +128,21 @@ impl Session { // Registry // --------------------------------------------------------------------------- +/// Partial-success envelope for `bulk_close_sessions`. +#[derive(Debug, Clone, serde::Serialize)] +pub struct BulkCloseResult { + pub closed: Vec, + pub failed: Vec, +} + +/// Per-session failure inside a `BulkCloseResult.failed[]` array. +#[derive(Debug, Clone, serde::Serialize)] +pub struct BulkCloseFailure { + pub id: String, + pub kind: String, + pub message: String, +} + #[derive(Clone)] pub struct AcpSessionRegistry { sessions: Arc>>>, @@ -987,6 +1003,108 @@ impl AcpSessionRegistry { Ok(()) } + /// Close every session the caller owns that matches the typed selector. + /// Returns a per-id partial-success envelope. Sessions belonging to other + /// principals are silently omitted (matches the not_found masking pattern + /// of `close_session`). + pub async fn bulk_close_sessions( + &self, + selector: BulkCloseSelector, + principal: &str, + ) -> Result { + if principal.trim().is_empty() { + return Err(ToolError::Sdk { + sdk_kind: "auth_failed".to_string(), + message: "authenticated principal required for bulk_close".to_string(), + }); + } + let now = jiff::Timestamp::now(); + let max_age_secs: Option = selector.max_age_days.map(|d| i64::from(d) * 86_400); + + let candidates: Vec = { + let sessions = self.sessions.read().await; + let mut ids = Vec::new(); + for session in sessions.values() { + if session.principal != principal { + continue; + } + let summary = session.summary.read().await; + if !selector.states.is_empty() && !selector.states.contains(&summary.state) { + continue; + } + if let Some(window) = max_age_secs { + let updated_secs = summary + .updated_at + .parse::() + .ok() + .map(|ts| now.as_second() - ts.as_second()) + .unwrap_or(0); + if updated_secs < window { + continue; + } + } + ids.push(session.id.clone()); + } + ids + }; + + if (candidates.len() as u32) > selector.max_count { + return Err(ToolError::InvalidParam { + message: format!( + "selector matches {} sessions; max_count is {}", + candidates.len(), + selector.max_count + ), + param: "selector".to_string(), + }); + } + + let semaphore = Arc::new(tokio::sync::Semaphore::new(5)); + let mut handles = Vec::with_capacity(candidates.len()); + for id in candidates { + let sem = semaphore.clone(); + let registry = self.clone(); + let principal_owned = principal.to_string(); + handles.push(tokio::spawn(async move { + let _permit = sem.acquire().await.expect("bulk_close semaphore closed"); + let outcome = registry.close_session(&id, &principal_owned).await; + (id, outcome) + })); + } + + let mut closed = Vec::new(); + let mut failed = Vec::new(); + for handle in handles { + if let Ok((id, outcome)) = handle.await { + match outcome { + Ok(()) => closed.push(id), + Err(error) => { + // Silently skip sessions reaped or otherwise inaccessible between + // the snapshot and close attempt — preserves not_found masking. + if error.kind() == "not_found" { + continue; + } + failed.push(BulkCloseFailure { + id, + kind: error.kind().to_string(), + message: error.user_message().to_string(), + }); + } + } + } + } + + tracing::info!( + surface = "acp", service = "registry", action = "session.bulk_close", + principal = %principal, + closed_count = closed.len(), + failed_count = failed.len(), + "ACP bulk_close completed", + ); + + Ok(BulkCloseResult { closed, failed }) + } + /// Gracefully terminate all sessions. Sets shutting_down flag, cancels every /// runtime, waits ≤10 s, then force-clears the map. #[allow(dead_code)] @@ -1639,6 +1757,27 @@ impl AcpSessionRegistry { summary } + /// Force the cached summary state of a session for tests. Both the + /// fast-path state lock and the summary lock are updated so any consumer + /// that reads either sees the new value. + #[cfg(test)] + pub async fn force_summary_state_for_tests( + &self, + session_id: &str, + state: AcpSessionState, + ) { + if let Ok(session) = self.get_session_arc(session_id).await { + *session.state.write().await = state.clone(); + session.summary.write().await.state = state; + } + } + + /// Check whether a session id is still registered (for assertions). + #[cfg(test)] + pub async fn session_exists_for_tests(&self, session_id: &str) -> bool { + self.sessions.read().await.contains_key(session_id) + } + /// Inject a pre-built session whose runtime command queue is already full. #[cfg(test)] pub async fn inject_saturated_fake_session( diff --git a/crates/lab/src/dispatch/acp/catalog.rs b/crates/lab/src/dispatch/acp/catalog.rs index a43f66f18..e3a6c2f3f 100644 --- a/crates/lab/src/dispatch/acp/catalog.rs +++ b/crates/lab/src/dispatch/acp/catalog.rs @@ -1,7 +1,7 @@ //! Action catalog for the `acp` (Agent Client Protocol) service. //! //! Single authoritative source for MCP, CLI, and API adapters. -//! `session.cancel` and `session.close` are marked destructive. +//! `session.cancel`, `session.close`, and `session.bulk_close` are marked destructive. use lab_apis::core::action::{ActionSpec, ParamSpec}; @@ -260,6 +260,27 @@ pub const ACTIONS: &[ActionSpec] = &[ }, ], }, + ActionSpec { + name: "session.bulk_close", + description: "Bulk close sessions matching a typed selector. Self-service only — \ + only the caller's own sessions are touched. [destructive]", + destructive: true, + returns: r#"{ "closed": string[], "failed": [{ "id": string, "kind": string, "message": string }] }"#, + params: &[ + ParamSpec { + name: "selector", + ty: "object", + required: true, + description: "BulkCloseSelector { states?: AcpSessionState[], max_age_days?: number, max_count?: number (default 500) }", + }, + ParamSpec { + name: "principal", + ty: "string", + required: true, + description: "Caller principal; only the caller's sessions are touched", + }, + ], + }, ActionSpec { name: "session.events", description: "Get stored events for a session. ProviderInfo events of type \ diff --git a/crates/lab/src/dispatch/acp/dispatch.rs b/crates/lab/src/dispatch/acp/dispatch.rs index cedc73907..929373b41 100644 --- a/crates/lab/src/dispatch/acp/dispatch.rs +++ b/crates/lab/src/dispatch/acp/dispatch.rs @@ -15,7 +15,8 @@ use super::catalog::ACTIONS; use super::client::require_registry; use super::page_context::{PageContextInput, build_prompt_with_context}; use super::params::{ - LocalPromptAttachment, opt_str, opt_u64, require_str, validate_local_attachments, + BulkCloseSelector, LocalPromptAttachment, opt_str, opt_u64, require_str, + validate_local_attachments, }; /// SSE ticket lifetime in seconds. @@ -342,6 +343,27 @@ pub async fn dispatch_with_registry( to_json(json!({ "ok": true, "session_id": session_id })) } + "session.bulk_close" => { + require_confirm(¶ms, "session.bulk_close")?; + let selector_value = params + .get("selector") + .cloned() + .ok_or_else(|| ToolError::MissingParam { + message: "selector is required".to_string(), + param: "selector".to_string(), + })?; + let selector: BulkCloseSelector = serde_json::from_value(selector_value).map_err( + |error| ToolError::InvalidParam { + message: format!("invalid selector: {error}"), + param: "selector".to_string(), + }, + )?; + selector.validate_non_empty()?; + let principal = require_str(¶ms, "principal")?; + let result = registry.bulk_close_sessions(selector, principal).await?; + to_json(json!(result)) + } + "session.subscribe_ticket" => { let session_id = require_str(¶ms, "session_id")?; let principal = opt_str(¶ms, "principal").unwrap_or(""); @@ -470,6 +492,7 @@ mod tests { use std::time::Duration; use crate::acp::registry::AcpSessionRegistry; + use lab_apis::acp::types::AcpSessionState; use serde_json::json; #[tokio::test] @@ -592,4 +615,75 @@ mod tests { assert_eq!(session_id, "sess-456"); assert_eq!(principal, ""); } + + #[tokio::test] + async fn session_bulk_close_rejects_empty_selector() { + let registry = AcpSessionRegistry::new_for_tests(Duration::from_millis(100)); + let err = dispatch_with_registry( + ®istry, + "session.bulk_close", + json!({ + "selector": {}, + "principal": "alice", + "confirm": true, + }), + ) + .await + .expect_err("empty selector must be rejected"); + assert_eq!(err.kind(), "invalid_param"); + } + + #[tokio::test] + async fn session_bulk_close_requires_confirm() { + // Gate-drift guard: the dispatcher arm itself must enforce require_confirm + // so a surface bypassing destructive-elicitation cannot fall through. + let registry = AcpSessionRegistry::new_for_tests(Duration::from_millis(100)); + let err = dispatch_with_registry( + ®istry, + "session.bulk_close", + json!({ + "selector": { "states": ["failed"], "max_count": 100 }, + "principal": "alice", + // INTENTIONALLY no "confirm": true + }), + ) + .await + .expect_err("missing confirm must be rejected"); + assert_eq!(err.kind(), "confirmation_required"); + } + + #[tokio::test] + async fn session_bulk_close_only_touches_caller_principal() { + let registry = AcpSessionRegistry::new_for_tests(Duration::from_millis(100)); + registry.inject_fake_session("sess-alice", "alice").await; + registry.inject_fake_session("sess-bob", "bob").await; + // Flip both into Failed so a Failed-only selector would match either. + registry + .force_summary_state_for_tests("sess-alice", AcpSessionState::Failed) + .await; + registry + .force_summary_state_for_tests("sess-bob", AcpSessionState::Failed) + .await; + + let value = dispatch_with_registry( + ®istry, + "session.bulk_close", + json!({ + "selector": { "states": ["failed"], "max_count": 100 }, + "principal": "alice", + "confirm": true, + }), + ) + .await + .expect("bulk_close should succeed"); + + let closed = value["closed"].as_array().expect("closed array"); + assert_eq!(closed.len(), 1, "should close exactly one session"); + assert_eq!(closed[0], "sess-alice"); + // Bob's session must still be registered. + assert!( + registry.session_exists_for_tests("sess-bob").await, + "other-principal session must remain untouched", + ); + } } diff --git a/crates/lab/src/dispatch/acp/params.rs b/crates/lab/src/dispatch/acp/params.rs index 8801fb438..2d659c050 100644 --- a/crates/lab/src/dispatch/acp/params.rs +++ b/crates/lab/src/dispatch/acp/params.rs @@ -6,6 +6,7 @@ //! ACP's contract. These wrappers preserve the empty-as-missing behavior on //! top of the shared helpers. +use lab_apis::acp::types::AcpSessionState; use serde::{Deserialize, Serialize}; use serde_json::Value; @@ -17,6 +18,40 @@ use base64::engine::general_purpose::STANDARD as B64; pub const MAX_LOCAL_ATTACHMENTS: usize = 5; pub const MAX_LOCAL_ATTACHMENT_BYTES: u64 = 48 * 1024; +/// Hard cap on the number of sessions a single `session.bulk_close` call can match. +/// Protects against accidental delete-all and bounds the concurrent-close fan-out. +pub const DEFAULT_BULK_CLOSE_MAX_COUNT: u32 = 500; + +/// Typed selector for `session.bulk_close`. At least one of `states` or +/// `max_age_days` must be set — `validate_non_empty` enforces it so an +/// empty selector cannot be used as a delete-all shortcut. +#[derive(Debug, Clone, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct BulkCloseSelector { + #[serde(default)] + pub states: Vec, + #[serde(default)] + pub max_age_days: Option, + #[serde(default = "default_bulk_close_max_count")] + pub max_count: u32, +} + +fn default_bulk_close_max_count() -> u32 { + DEFAULT_BULK_CLOSE_MAX_COUNT +} + +impl BulkCloseSelector { + pub fn validate_non_empty(&self) -> Result<(), ToolError> { + if self.states.is_empty() && self.max_age_days.is_none() { + return Err(ToolError::InvalidParam { + message: "selector must specify at least one of: states, max_age_days".to_string(), + param: "selector".to_string(), + }); + } + Ok(()) + } +} + #[derive(Debug, Clone, Deserialize, Serialize)] #[serde(rename_all = "camelCase", tag = "contentKind")] pub enum LocalAttachmentContent { diff --git a/crates/lab/src/dispatch/error.rs b/crates/lab/src/dispatch/error.rs index 9b35b37a8..d251cc7ef 100644 --- a/crates/lab/src/dispatch/error.rs +++ b/crates/lab/src/dispatch/error.rs @@ -158,6 +158,23 @@ impl ToolError { } } + /// Human-readable message text. `Display` on `ToolError` emits the full + /// JSON envelope; this returns only the message field so callers building + /// nested error payloads (e.g. `BulkCloseFailure`) can avoid double-encoding. + #[must_use] + pub fn user_message(&self) -> &str { + match self { + Self::UnknownAction { message, .. } + | Self::MissingParam { message, .. } + | Self::InvalidParam { message, .. } + | Self::UnknownInstance { message, .. } + | Self::AmbiguousTool { message, .. } + | Self::ConfirmationRequired { message } + | Self::Conflict { message, .. } + | Self::Sdk { message, .. } => message.as_str(), + } + } + /// Whether this error represents an internal/fatal failure (ERROR level) /// vs a caller/user error (WARN level). /// diff --git a/docs/dev/ERRORS.md b/docs/dev/ERRORS.md index a3e9d9cee..7a6ac69a5 100644 --- a/docs/dev/ERRORS.md +++ b/docs/dev/ERRORS.md @@ -472,6 +472,26 @@ At minimum, verify: 3. HTTP emits the expected structured JSON error with the matching semantic kind 4. messages do not leak secrets +## Batch-result envelope + +Actions that operate on multiple items in one call (e.g. `acp.session.bulk_close`) return a partial-success envelope with two arrays. Inner `failed[]` items reuse the same `{ kind, message }` shape as top-level `ToolError::Sdk` so per-item taxonomy stays consistent with the rest of the system. + +```json +{ + "closed": ["session-uuid-1", "session-uuid-2"], + "failed": [ + { "id": "session-uuid-3", "kind": "internal_error", "message": "..." } + ] +} +``` + +Rules: + +- `closed[]` contains the ids that completed the action. +- `failed[]` contains ids that the action attempted but errored on; per-item `kind` must be one of the canonical kinds listed above. +- Items the caller is not authorized to act on are silently omitted from BOTH arrays (preserves the `not_found` masking pattern — do not leak existence by reporting forbidden items). +- Authorization or validation errors that prevent the action from running at all return a top-level `ToolError` (not a 200 with empty arrays). + ## Related Docs - [CONVENTIONS.md](../CONVENTIONS.md) From 74e4c345414614cc61a7aded7adc0fcf47444229 Mon Sep 17 00:00:00 2001 From: Jacob Magar Date: Mon, 25 May 2026 01:34:46 -0400 Subject: [PATCH 03/11] feat(chat): hide inactive sessions by default + bulk cleanup trigger MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sessions in state failed, closed, or cancelled are filtered out of the sidebar by default. The provider keeps the unfiltered list in `runs` and exposes a derived `visibleRuns` with a `hiddenRunCount` and an `includeHiddenRuns` toggle through the actions context. When the hidden count is non-zero, the sidebar shows a "Show N inactive" toggle plus a "Clean up" button. Cleanup dispatches `session.bulk_close` through the existing destructive-action gate — the API auto-injects both the principal from the auth context and the `confirm: true` flag, so the client only sends the selector body. Tests cover the pure filter helper. The dispatch action itself ships with backend tests for selector validation, the confirm gate, and per-principal isolation (preceding commit). Refs lab-de6yc.1 --- .../components/chat/chat-shell.tsx | 30 ++++++++-- .../components/chat/session-sidebar.tsx | 59 +++++++++++++++++++ .../components/floating-chat-shell.tsx | 29 +++++++-- .../lib/chat/chat-session-provider.tsx | 47 ++++++++++++++- .../lib/chat/session-filters.test.ts | 54 +++++++++++++++++ .../gateway-admin/lib/chat/session-filters.ts | 15 +++++ 6 files changed, 224 insertions(+), 10 deletions(-) create mode 100644 apps/gateway-admin/lib/chat/session-filters.test.ts create mode 100644 apps/gateway-admin/lib/chat/session-filters.ts diff --git a/apps/gateway-admin/components/chat/chat-shell.tsx b/apps/gateway-admin/components/chat/chat-shell.tsx index 8418816eb..b9a698ff0 100644 --- a/apps/gateway-admin/components/chat/chat-shell.tsx +++ b/apps/gateway-admin/components/chat/chat-shell.tsx @@ -48,9 +48,27 @@ export function ChatShell() { const [maxTokens, setMaxTokens] = React.useState(8192) const [draftText, setDraftText] = React.useState('') const [attachmentsResetToken, setAttachmentsResetToken] = React.useState(0) - const { runs, selectedRun, selectedRunId, providerHealth, selectedAgent, selectedModel, agents, projects } = - useChatSessionData() - const { selectRun, createSession, sendPrompt, selectAgent, selectModel } = useChatSessionActions() + const { + visibleRuns, + hiddenRunCount, + includeHiddenRuns, + selectedRun, + selectedRunId, + providerHealth, + selectedAgent, + selectedModel, + agents, + projects, + } = useChatSessionData() + const { + selectRun, + createSession, + sendPrompt, + selectAgent, + selectModel, + setIncludeHiddenRuns, + bulkCloseHiddenSessions, + } = useChatSessionActions() const { messages } = useChatSessionStream() const { connectionState } = useChatSessionConnection() const providerReady = Boolean(providerHealth?.ready) @@ -228,11 +246,15 @@ export function ChatShell() { void createRun()} + hiddenRunCount={hiddenRunCount} + includeHiddenRuns={includeHiddenRuns} + onToggleIncludeHidden={() => setIncludeHiddenRuns((v) => !v)} + onBulkCloseHidden={bulkCloseHiddenSessions} /> diff --git a/apps/gateway-admin/components/chat/session-sidebar.tsx b/apps/gateway-admin/components/chat/session-sidebar.tsx index 1910843ff..65ab808ba 100644 --- a/apps/gateway-admin/components/chat/session-sidebar.tsx +++ b/apps/gateway-admin/components/chat/session-sidebar.tsx @@ -11,6 +11,7 @@ import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/component import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip' import { ScrollArea } from '@/components/ui/scroll-area' import { formatTimeAgo } from './mock-data' +import { ConfirmDialog, type ConfirmState } from '@/components/marketplace/confirm-dialog' import type { ACPProject, ACPRun, ACPRunStatus } from './types' interface SessionSidebarProps { @@ -21,6 +22,14 @@ interface SessionSidebarProps { selectedProjectId: string | null onSelectRun: (runId: string, projectId: string) => void onNewRun: (projectId: string) => void + /** Number of failed/closed/cancelled runs hidden from `runs`. */ + hiddenRunCount?: number + /** Whether the toggle currently includes hidden runs in `runs`. */ + includeHiddenRuns?: boolean + /** Called when the user clicks the show/hide toggle chip. */ + onToggleIncludeHidden?: () => void + /** Called when the user confirms the bulk cleanup. Should resolve to closed/failed counts. */ + onBulkCloseHidden?: () => Promise<{ closedCount: number; failedCount: number }> } function RunIcon({ status, agentId }: { status: ACPRunStatus; agentId: string }) { @@ -188,10 +197,29 @@ export function SessionSidebar({ selectedProjectId, onSelectRun, onNewRun, + hiddenRunCount = 0, + includeHiddenRuns = false, + onToggleIncludeHidden, + onBulkCloseHidden, }: SessionSidebarProps) { const activeProjectId = selectedProjectId const [search, setSearch] = React.useState('') const deferredSearch = React.useDeferredValue(search) + const [confirm, setConfirm] = React.useState(null) + + const handleCleanup = React.useCallback(() => { + if (!onBulkCloseHidden || hiddenRunCount === 0) return + setConfirm({ + title: `Delete ${hiddenRunCount} session${hiddenRunCount === 1 ? '' : 's'}?`, + description: + 'Sessions in state failed, closed, or cancelled will be permanently removed. This action cannot be undone.', + confirmLabel: `Delete ${hiddenRunCount}`, + destructive: true, + onConfirm: async () => { + await onBulkCloseHidden() + }, + }) + }, [hiddenRunCount, onBulkCloseHidden]) const visibleProjects = React.useMemo(() => { const normalizedSearch = deferredSearch.trim().toLowerCase() @@ -241,6 +269,30 @@ export function SessionSidebar({ + {/* Hidden-session controls */} + {hiddenRunCount > 0 && ( +
+ + {onBulkCloseHidden && ( + + )} +
+ )} + {/* Project list */}
@@ -257,6 +309,13 @@ export function SessionSidebar({ ))}
+ + { + if (!open) setConfirm(null) + }} + /> ) } diff --git a/apps/gateway-admin/components/floating-chat-shell.tsx b/apps/gateway-admin/components/floating-chat-shell.tsx index b6031a6b1..d50fc3e4a 100644 --- a/apps/gateway-admin/components/floating-chat-shell.tsx +++ b/apps/gateway-admin/components/floating-chat-shell.tsx @@ -44,9 +44,26 @@ export function FloatingChatShell({ config, }: FloatingChatShellProps) { // ---- Context consumers ---- - const { runs, selectedRun, selectedRunId, providerHealth, selectedAgent, agents, projects, pageContext } = - useChatSessionData() - const { createSession, selectRun, sendPrompt: sendPromptAction, selectAgent } = useChatSessionActions() + const { + visibleRuns, + hiddenRunCount, + includeHiddenRuns, + selectedRun, + selectedRunId, + providerHealth, + selectedAgent, + agents, + projects, + pageContext, + } = useChatSessionData() + const { + createSession, + selectRun, + sendPrompt: sendPromptAction, + selectAgent, + setIncludeHiddenRuns, + bulkCloseHiddenSessions, + } = useChatSessionActions() const { connectionState } = useChatSessionConnection() const { messages } = useChatSessionStream() @@ -188,11 +205,15 @@ export function FloatingChatShell({ selectRun(runId)} onNewRun={() => void createRun()} + hiddenRunCount={hiddenRunCount} + includeHiddenRuns={includeHiddenRuns} + onToggleIncludeHidden={() => setIncludeHiddenRuns((v) => !v)} + onBulkCloseHidden={bulkCloseHiddenSessions} /> )} diff --git a/apps/gateway-admin/lib/chat/chat-session-provider.tsx b/apps/gateway-admin/lib/chat/chat-session-provider.tsx index 7c0bfcb45..d7f6efb8e 100644 --- a/apps/gateway-admin/lib/chat/chat-session-provider.tsx +++ b/apps/gateway-admin/lib/chat/chat-session-provider.tsx @@ -44,6 +44,7 @@ import { errorMessageFromPayload, sameProviderList, } from './acp-normalizers' +import { filterVisibleRuns } from './session-filters' import type { ACPAgent, ACPRun, ACPProject, ACPMessage, ACPModelOption } from '@/components/chat/types' import type { BridgeSessionSummary, ProviderHealth, BridgeEvent } from '@/lib/acp/types' import type { SessionEventConnectionState } from './use-session-events' @@ -75,6 +76,9 @@ export type PageContext = { export type ChatSessionDataContextValue = { runs: ACPRun[] + visibleRuns: ACPRun[] + hiddenRunCount: number + includeHiddenRuns: boolean selectedRunId: string | null selectedRun: ACPRun | null providerHealth: ProviderHealth | null @@ -100,6 +104,8 @@ export type ChatSessionActionsContextValue = { selectAgent: (providerId: string) => void selectModel: (providerId: string, modelId: string) => void setPageContext: (ctx: PageContext) => void + setIncludeHiddenRuns: (include: boolean | ((prev: boolean) => boolean)) => void + bulkCloseHiddenSessions: () => Promise<{ closedCount: number; failedCount: number }> } export type ChatSessionConnectionContextValue = { @@ -188,6 +194,7 @@ export function ChatSessionProvider({ const [selectedModelByProvider, setSelectedModelByProvider] = React.useState>({}) const [optimisticMessages, setOptimisticMessages] = React.useState([]) const [pageContext, setPageContext] = React.useState(null) + const [includeHiddenRuns, setIncludeHiddenRuns] = React.useState(false) // SSE state const [events, setEvents] = React.useState([]) @@ -645,9 +652,43 @@ export function ChatSessionProvider({ // ---- Context values ---- + const visibleRuns = React.useMemo( + () => filterVisibleRuns(runs, { includeHidden: includeHiddenRuns }), + [runs, includeHiddenRuns], + ) + const hiddenRunCount = runs.length - visibleRuns.length + + const bulkCloseHiddenSessions = React.useCallback(async () => { + // The API auto-injects `confirm: true` for destructive actions and + // `principal` from the auth context, so the client only sends the selector. + const response = await fetchAcp('', { + method: 'POST', + body: JSON.stringify({ + action: 'session.bulk_close', + params: { + selector: { states: ['failed', 'closed', 'cancelled'], max_count: 500 }, + }, + }), + }) + if (!response.ok) { + const errorPayload = await readJsonSafe(response) + throw new Error(errorMessageFromPayload(errorPayload, 'Failed to clean up sessions')) + } + const payload = (await readJsonSafe(response)) as + | { closed?: string[]; failed?: Array<{ id: string }> } + | null + const closedCount = payload?.closed?.length ?? 0 + const failedCount = payload?.failed?.length ?? 0 + await refreshSessions() + return { closedCount, failedCount } + }, [fetchAcp, refreshSessions]) + const dataValue = React.useMemo( () => ({ runs, + visibleRuns, + hiddenRunCount, + includeHiddenRuns, selectedRunId, selectedRun, providerHealth, @@ -660,7 +701,7 @@ export function ChatSessionProvider({ sessionsLoaded, pageContext, }), - [runs, selectedRunId, selectedRun, providerHealth, providers, selectedProviderId, selectedAgent, selectedModel, agents, projects, sessionsLoaded, pageContext], + [runs, visibleRuns, hiddenRunCount, includeHiddenRuns, selectedRunId, selectedRun, providerHealth, providers, selectedProviderId, selectedAgent, selectedModel, agents, projects, sessionsLoaded, pageContext], ) const actionsValue = React.useMemo( @@ -673,8 +714,10 @@ export function ChatSessionProvider({ selectAgent, selectModel, setPageContext: setPageContextStable, + setIncludeHiddenRuns, + bulkCloseHiddenSessions, }), - [createSession, selectRun, sendPrompt, refreshSessions, refreshProvider, selectAgent, selectModel, setPageContextStable], + [createSession, selectRun, sendPrompt, refreshSessions, refreshProvider, selectAgent, selectModel, setPageContextStable, bulkCloseHiddenSessions], ) const connectionValue = React.useMemo( diff --git a/apps/gateway-admin/lib/chat/session-filters.test.ts b/apps/gateway-admin/lib/chat/session-filters.test.ts new file mode 100644 index 000000000..c9dfdb915 --- /dev/null +++ b/apps/gateway-admin/lib/chat/session-filters.test.ts @@ -0,0 +1,54 @@ +import test from 'node:test' +import assert from 'node:assert/strict' + +import { filterVisibleRuns, isHiddenState } from './session-filters' +import type { ACPRun } from '@/components/chat/types' + +function run(id: string, status: ACPRun['status']): ACPRun { + return { + id, + projectId: 'p', + agentId: 'a', + provider: 'codex-acp', + title: id, + createdAt: new Date(), + updatedAt: new Date(), + status, + providerSessionId: 'psid', + cwd: '.', + } +} + +test('isHiddenState matches failed, closed, cancelled', () => { + assert.equal(isHiddenState('failed'), true) + assert.equal(isHiddenState('closed'), true) + assert.equal(isHiddenState('cancelled'), true) + assert.equal(isHiddenState('idle'), false) + assert.equal(isHiddenState('completed'), false) + assert.equal(isHiddenState(undefined), false) +}) + +test('filterVisibleRuns hides failed/closed/cancelled by default', () => { + const visible = filterVisibleRuns( + [ + run('a', 'idle'), + run('b', 'failed'), + run('c', 'closed'), + run('d', 'completed'), + run('e', 'cancelled'), + ], + { includeHidden: false }, + ) + assert.deepEqual( + visible.map((r) => r.id), + ['a', 'd'], + ) +}) + +test('filterVisibleRuns passes everything when includeHidden=true', () => { + const visible = filterVisibleRuns( + [run('a', 'idle'), run('b', 'failed')], + { includeHidden: true }, + ) + assert.equal(visible.length, 2) +}) diff --git a/apps/gateway-admin/lib/chat/session-filters.ts b/apps/gateway-admin/lib/chat/session-filters.ts new file mode 100644 index 000000000..4c9f1c93f --- /dev/null +++ b/apps/gateway-admin/lib/chat/session-filters.ts @@ -0,0 +1,15 @@ +import type { ACPRun, ACPRunStatus } from '@/components/chat/types' + +const HIDDEN_STATES: ReadonlySet = new Set(['failed', 'closed', 'cancelled']) + +export function isHiddenState(status: ACPRunStatus | undefined): boolean { + return status !== undefined && HIDDEN_STATES.has(status) +} + +export function filterVisibleRuns( + runs: ACPRun[], + options: { includeHidden: boolean }, +): ACPRun[] { + if (options.includeHidden) return runs + return runs.filter((run) => !isHiddenState(run.status)) +} From 1bece8db89f5f952cf6eac7c73e9283b91c0ef5f Mon Sep 17 00:00:00 2001 From: Jacob Magar Date: Mon, 25 May 2026 01:37:24 -0400 Subject: [PATCH 04/11] fix(chat): align bulk-cleanup with lab-de6yc.1 locked decisions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Hidden states are [failed, closed] only — cancelled stays visible per bead. - Default cleanup selector adds max_age_days: 7 (don't sweep recent failures). - Confirm button uses the bead-canonical 'Delete N Sessions' copy. - Toggle chip uses 'closed/failed' instead of generic 'inactive'. Refs lab-de6yc.1 --- apps/gateway-admin/components/chat/session-sidebar.tsx | 8 ++++---- apps/gateway-admin/lib/chat/chat-session-provider.tsx | 7 ++++++- apps/gateway-admin/lib/chat/session-filters.test.ts | 8 ++++---- apps/gateway-admin/lib/chat/session-filters.ts | 2 +- 4 files changed, 15 insertions(+), 10 deletions(-) diff --git a/apps/gateway-admin/components/chat/session-sidebar.tsx b/apps/gateway-admin/components/chat/session-sidebar.tsx index 65ab808ba..9fc3cfd70 100644 --- a/apps/gateway-admin/components/chat/session-sidebar.tsx +++ b/apps/gateway-admin/components/chat/session-sidebar.tsx @@ -212,8 +212,8 @@ export function SessionSidebar({ setConfirm({ title: `Delete ${hiddenRunCount} session${hiddenRunCount === 1 ? '' : 's'}?`, description: - 'Sessions in state failed, closed, or cancelled will be permanently removed. This action cannot be undone.', - confirmLabel: `Delete ${hiddenRunCount}`, + 'Sessions in state failed or closed, last active more than 7 days ago, will be permanently removed. This action cannot be undone.', + confirmLabel: `Delete ${hiddenRunCount} Session${hiddenRunCount === 1 ? '' : 's'}`, destructive: true, onConfirm: async () => { await onBulkCloseHidden() @@ -278,8 +278,8 @@ export function SessionSidebar({ className="hover:text-aurora-text-primary transition-colors" > {includeHiddenRuns - ? `Hide ${hiddenRunCount} inactive` - : `Show ${hiddenRunCount} inactive`} + ? `Hide ${hiddenRunCount} closed/failed` + : `Show ${hiddenRunCount} closed/failed`} {onBulkCloseHidden && (