Skip to content

Commit f0a334c

Browse files
authored
feat: close UI visibility gaps — toast, circuit breaker, process logs, adapter selector (#26)
* feat: close UI visibility gaps — toast system, circuit breaker, process logs, adapter selector Implement 14-step UI gap plan exposing backend capabilities to the frontend: Backend: - Circuit breaker snapshot via SlidingWindowBreaker.getSnapshot() with relative timing - Process output forwarding with secret redaction (PARTICIPANT_ONLY) - Watchdog timer events for reconnect grace period - Resume failure broadcasting to consumers - Session auto-naming from first user message (with secret redaction) - Adapter selector inbound message type Frontend: - Toast notification system (auto-dismiss, FIFO eviction, max 5) - Circuit breaker banner with live countdown (open/half_open states) - Watchdog countdown in ConnectionBanner - Process log drawer with ANSI stripping and ring buffer (200 lines) - Observer mode banner and action gating - Permission mode picker with YOLO confirmation dialog - Adapter selector dropdown in StatusBar - Model picker with abbreviated names in StatusBar - Tool result polish: line numbers, copy button, grep highlighting - Web Audio API completion sound + browser notifications - Connection health section in TaskPanel - Session state badges in Sidebar - Encryption status icon stub in TopBar Code quality (from dual review): - Fix circuit breaker casing (backend sends lowercase) - Memoize breakerEndMs to prevent re-render loops - Fix observer role check (=== "observer" not !== "participant") - Add Notification typeof guard for SSR safety - Strip ANSI from process logs before storing - Clean processLogBuffers on session close (memory leak fix) - Clean processLogs in store.removeSession() - Reset confirmingBypass dialog on session switch - Fix CopyButton setTimeout cleanup on unmount - Extract shared useDropdown hook (5 components) Tests: 419 frontend + 1543 backend passing, TypeScript clean * fix: broadcastToParticipants excludes owner/operator roles The role filter used `!== "participant"` which incorrectly skipped owner and operator roles. Changed to `=== "observer"` to only exclude read-only observers, matching the PARTICIPANT_ONLY_TYPES check pattern already used elsewhere.
1 parent 8746f5b commit f0a334c

33 files changed

+2162
-227
lines changed

docs/reviews/2026-02-16-ui-gap-analysis.md

Lines changed: 964 additions & 0 deletions
Large diffs are not rendered by default.

package-lock.json

Lines changed: 1 addition & 8 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

shared/consumer-types.ts

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -170,6 +170,19 @@ export interface ConsumerSessionState {
170170
last_duration_ms?: number;
171171
last_duration_api_ms?: number;
172172
team?: ConsumerTeamState | null;
173+
circuitBreaker?: {
174+
state: string;
175+
failureCount: number;
176+
recoveryTimeRemainingMs: number;
177+
} | null;
178+
encryption?: {
179+
isActive: boolean;
180+
isPaired: boolean;
181+
} | null;
182+
watchdog?: {
183+
gracePeriodMs: number;
184+
startedAt: number;
185+
} | null;
173186
}
174187

175188
// ── Stream Events ──────────────────────────────────────────────────────────
@@ -234,6 +247,12 @@ export type ConsumerMessage =
234247
models: InitializeModel[];
235248
account: InitializeAccount | null;
236249
skills: string[];
250+
}
251+
| { type: "resume_failed"; sessionId: string }
252+
| {
253+
type: "process_output";
254+
stream: "stdout" | "stderr";
255+
data: string;
237256
};
238257

239258
// ── Inbound Messages (consumer → bridge) ────────────────────────────────────
@@ -256,4 +275,5 @@ export type InboundMessage =
256275
| { type: "set_model"; model: string }
257276
| { type: "set_permission_mode"; mode: string }
258277
| { type: "presence_query" }
259-
| { type: "slash_command"; command: string; request_id?: string };
278+
| { type: "slash_command"; command: string; request_id?: string }
279+
| { type: "set_adapter"; adapter: string };

src/adapters/sdk-url/sdk-url-launcher.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -510,4 +510,13 @@ export class SdkUrlLauncher extends ProcessSupervisor<LauncherEventMap> {
510510
this.persistState();
511511
}
512512
}
513+
514+
/** Set the display name for a session. */
515+
setSessionName(sessionId: string, name: string): void {
516+
const info = this.sessions.get(sessionId);
517+
if (info) {
518+
info.name = name;
519+
this.persistState();
520+
}
521+
}
513522
}

src/adapters/sliding-window-breaker.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,4 +109,19 @@ export class SlidingWindowBreaker implements CircuitBreaker {
109109
const cutoff = Date.now() - this.windowMs;
110110
return this.failureTimestamps.filter((t) => t > cutoff).length;
111111
}
112+
113+
/**
114+
* Get a snapshot of the breaker state for consumer reporting.
115+
* Uses relative recoveryTimeRemainingMs to avoid leaking server clock.
116+
*/
117+
getSnapshot(): { state: string; failureCount: number; recoveryTimeRemainingMs: number } {
118+
const state = this.getState();
119+
const failureCount = this.getFailureCount();
120+
let recoveryTimeRemainingMs = 0;
121+
if (state === "open" && this.lastFailureTime > 0) {
122+
const elapsed = Date.now() - this.lastFailureTime;
123+
recoveryTimeRemainingMs = Math.max(0, this.recoveryTimeMs - elapsed);
124+
}
125+
return { state, failureCount, recoveryTimeRemainingMs };
126+
}
112127
}

src/core/process-supervisor.ts

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,22 @@ import type { Logger } from "../interfaces/logger.js";
55
import type { ProcessHandle, ProcessManager } from "../interfaces/process-manager.js";
66
import { TypedEventEmitter } from "./typed-emitter.js";
77

8+
/** Circuit breaker snapshot included in process:exited when breaker is not CLOSED. */
9+
export interface BreakerSnapshot {
10+
state: string;
11+
failureCount: number;
12+
recoveryTimeRemainingMs: number;
13+
}
14+
815
/** Minimum event map that any ProcessSupervisor must support. */
916
export interface SupervisorEventMap {
1017
"process:spawned": { sessionId: string; pid: number };
11-
"process:exited": { sessionId: string; exitCode: number | null; uptimeMs: number };
18+
"process:exited": {
19+
sessionId: string;
20+
exitCode: number | null;
21+
uptimeMs: number;
22+
circuitBreaker?: BreakerSnapshot;
23+
};
1224
"process:stdout": { sessionId: string; data: string };
1325
"process:stderr": { sessionId: string; data: string };
1426
error: { source: string; error: Error; sessionId?: string };
@@ -248,7 +260,14 @@ export abstract class ProcessSupervisor<
248260
this.processes.delete(sessionId);
249261
this.onProcessExited(sessionId, exitCode, uptimeMs);
250262

251-
this.emit("process:exited", { sessionId, exitCode, uptimeMs });
263+
// Include circuit breaker snapshot when breaker is not CLOSED
264+
const breakerState = this.restartCircuitBreaker.getState();
265+
const circuitBreaker =
266+
breakerState !== "closed" && this.restartCircuitBreaker instanceof SlidingWindowBreaker
267+
? this.restartCircuitBreaker.getSnapshot()
268+
: undefined;
269+
270+
this.emit("process:exited", { sessionId, exitCode, uptimeMs, circuitBreaker });
252271
});
253272
}
254273
}

src/core/session-bridge.ts

Lines changed: 46 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,7 @@ const PARTICIPANT_ONLY_TYPES = new Set([
120120
"set_model",
121121
"set_permission_mode",
122122
"slash_command",
123+
"set_adapter",
123124
]);
124125

125126
// ─── SessionBridge ───────────────────────────────────────────────────────────
@@ -1351,6 +1352,50 @@ export class SessionBridge extends TypedEventEmitter<BridgeEventMap> {
13511352
});
13521353
}
13531354

1355+
/** Broadcast resume_failed to all consumers for a session. */
1356+
broadcastResumeFailedToConsumers(sessionId: string): void {
1357+
const session = this.sessions.get(sessionId);
1358+
if (!session) return;
1359+
this.broadcastToConsumers(session, { type: "resume_failed", sessionId });
1360+
}
1361+
1362+
/** Broadcast process output to participants only (observers must not see process logs). */
1363+
broadcastProcessOutput(sessionId: string, stream: "stdout" | "stderr", data: string): void {
1364+
const session = this.sessions.get(sessionId);
1365+
if (!session) return;
1366+
this.broadcastToParticipants(session, {
1367+
type: "process_output",
1368+
stream,
1369+
data,
1370+
});
1371+
}
1372+
1373+
/** Broadcast watchdog state update via session_update. */
1374+
broadcastWatchdogState(
1375+
sessionId: string,
1376+
watchdog: { gracePeriodMs: number; startedAt: number } | null,
1377+
): void {
1378+
const session = this.sessions.get(sessionId);
1379+
if (!session) return;
1380+
this.broadcastToConsumers(session, {
1381+
type: "session_update",
1382+
session: { watchdog } as Partial<SessionState>,
1383+
});
1384+
}
1385+
1386+
/** Broadcast circuit breaker state update via session_update. */
1387+
broadcastCircuitBreakerState(
1388+
sessionId: string,
1389+
circuitBreaker: { state: string; failureCount: number; recoveryTimeRemainingMs: number },
1390+
): void {
1391+
const session = this.sessions.get(sessionId);
1392+
if (!session) return;
1393+
this.broadcastToConsumers(session, {
1394+
type: "session_update",
1395+
session: { circuitBreaker } as Partial<SessionState>,
1396+
});
1397+
}
1398+
13541399
private static readonly MAX_CONSUMER_MESSAGE_SIZE = 262_144; // 256 KB
13551400
private static readonly BACKPRESSURE_THRESHOLD = 1_048_576; // 1 MB
13561401

@@ -1386,7 +1431,7 @@ export class SessionBridge extends TypedEventEmitter<BridgeEventMap> {
13861431
const json = JSON.stringify(msg);
13871432
const failed: WebSocketLike[] = [];
13881433
for (const [ws, identity] of session.consumerSockets.entries()) {
1389-
if (identity.role !== "participant") continue;
1434+
if (identity.role === "observer") continue;
13901435
try {
13911436
ws.send(json);
13921437
} catch (err) {

src/core/session-manager.ts

Lines changed: 68 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import type {
1616
import type { ProviderConfig, ResolvedConfig } from "../types/config.js";
1717
import { resolveConfig } from "../types/config.js";
1818
import type { SessionManagerEventMap } from "../types/events.js";
19+
import { redactSecrets } from "../utils/redact-secrets.js";
1920
import { SessionBridge } from "./session-bridge.js";
2021
import { TypedEventEmitter } from "./typed-emitter.js";
2122

@@ -162,6 +163,10 @@ export class SessionManager extends TypedEventEmitter<SessionManagerEventMap> {
162163
this.started = false;
163164
}
164165

166+
/** Ring buffer for process output per session. */
167+
private processLogBuffers = new Map<string, string[]>();
168+
private static readonly MAX_LOG_LINES = 500;
169+
165170
private wireEvents(): void {
166171
// When the backend reports its session_id, store it for --resume on relaunch
167172
this.bridge.on("backend:session_id", ({ sessionId, backendSessionId }) => {
@@ -173,6 +178,59 @@ export class SessionManager extends TypedEventEmitter<SessionManagerEventMap> {
173178
this.launcher.markConnected(sessionId);
174179
});
175180

181+
// ── Resume failure → broadcast to consumers (Step 3) ──
182+
this.launcher.on("process:resume_failed", ({ sessionId }) => {
183+
this.bridge.broadcastResumeFailedToConsumers(sessionId);
184+
});
185+
186+
// ── Process output forwarding with redaction (Step 11) ──
187+
for (const stream of ["process:stdout", "process:stderr"] as const) {
188+
this.launcher.on(stream, ({ sessionId, data }) => {
189+
const redacted = redactSecrets(data);
190+
// Ring buffer
191+
const buffer = this.processLogBuffers.get(sessionId) ?? [];
192+
const lines = redacted.split("\n").filter((l) => l.trim());
193+
buffer.push(...lines);
194+
if (buffer.length > SessionManager.MAX_LOG_LINES) {
195+
buffer.splice(0, buffer.length - SessionManager.MAX_LOG_LINES);
196+
}
197+
this.processLogBuffers.set(sessionId, buffer);
198+
// Forward to consumers (participant-only is enforced in bridge)
199+
this.bridge.broadcastProcessOutput(
200+
sessionId,
201+
stream === "process:stdout" ? "stdout" : "stderr",
202+
redacted,
203+
);
204+
});
205+
}
206+
207+
// ── Circuit breaker state from process:exited (Step 9) ──
208+
this.launcher.on("process:exited", ({ sessionId, circuitBreaker }) => {
209+
if (circuitBreaker) {
210+
this.bridge.broadcastCircuitBreakerState(sessionId, circuitBreaker);
211+
}
212+
});
213+
214+
// ── Session auto-naming on first turn (Step 4) ──
215+
this.bridge.on("session:first_turn_completed", ({ sessionId, firstUserMessage }) => {
216+
const session = this.launcher.getSession(sessionId);
217+
if (session?.name) return; // Already named
218+
219+
// Derive name: first line, truncated, redacted
220+
let name = firstUserMessage.split("\n")[0].trim();
221+
name = redactSecrets(name);
222+
if (name.length > 50) name = `${name.slice(0, 47)}...`;
223+
if (!name) return;
224+
225+
this.bridge.broadcastNameUpdate(sessionId, name);
226+
this.launcher.setSessionName(sessionId, name);
227+
});
228+
229+
// Clean up process log buffer when a session is closed
230+
this.bridge.on("session:closed", ({ sessionId }) => {
231+
this.processLogBuffers.delete(sessionId);
232+
});
233+
176234
// Auto-relaunch when a consumer connects but backend is dead (with dedup — A5)
177235
this.bridge.on("backend:relaunch_needed", async ({ sessionId }) => {
178236
if (this.relaunchingSet.has(sessionId)) return;
@@ -290,19 +348,27 @@ export class SessionManager extends TypedEventEmitter<SessionManagerEventMap> {
290348
const starting = this.launcher.getStartingSessions();
291349
if (starting.length === 0) return;
292350

351+
const gracePeriodMs = this.config.reconnectGracePeriodMs;
293352
this.logger.info(
294-
`Waiting ${this.config.reconnectGracePeriodMs / 1000}s for ${starting.length} CLI process(es) to reconnect...`,
353+
`Waiting ${gracePeriodMs / 1000}s for ${starting.length} CLI process(es) to reconnect...`,
295354
);
296355

356+
// Broadcast watchdog:active to all sessions
357+
for (const info of starting) {
358+
this.bridge.broadcastWatchdogState(info.sessionId, { gracePeriodMs, startedAt: Date.now() });
359+
}
360+
297361
this.reconnectTimer = setTimeout(async () => {
298362
this.reconnectTimer = null;
299363
const stale = this.launcher.getStartingSessions();
300364
for (const info of stale) {
365+
// Clear watchdog state
366+
this.bridge.broadcastWatchdogState(info.sessionId, null);
301367
if (info.archived) continue;
302368
this.logger.info(`CLI for session ${info.sessionId} did not reconnect, relaunching...`);
303369
await this.launcher.relaunch(info.sessionId);
304370
}
305-
}, this.config.reconnectGracePeriodMs);
371+
}, gracePeriodMs);
306372
}
307373

308374
/** Periodically reap idle sessions (no CLI or consumer connections). */

src/types/auth.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,11 @@ export type {
77

88
import type { ConsumerIdentity } from "../interfaces/auth.js";
99

10+
/**
11+
* Create an anonymous identity with participant role.
12+
* Note: Observer mode is inert without a configured Authenticator —
13+
* anonymous identities always receive "participant" role.
14+
*/
1015
export function createAnonymousIdentity(index: number): ConsumerIdentity {
1116
return { userId: `anonymous-${index}`, displayName: `User ${index}`, role: "participant" };
1217
}

src/types/consumer-messages.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -180,4 +180,10 @@ export type ConsumerMessage =
180180
models: InitializeModel[];
181181
account: InitializeAccount | null;
182182
skills: string[];
183+
}
184+
| { type: "resume_failed"; sessionId: string }
185+
| {
186+
type: "process_output";
187+
stream: "stdout" | "stderr";
188+
data: string;
183189
};

0 commit comments

Comments
 (0)