feat(dashboard): cli-agent terminal UI, task-card states, and notification banner support (U11)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-06-05 01:16:13 -07:00
parent 7815055388
commit e10db81393
15 changed files with 1528 additions and 16 deletions

View File

@@ -0,0 +1,15 @@
---
"@runfusion/fusion": minor
---
CLI agent terminal UI (U11): a shared `SessionTerminal` component (lazy-loaded
xterm + fit/webgl/unicode11) that attaches to the U10 cli-sessions WebSocket
with ACK flow control, a posture chip (baseline vs elevated), a read-only
badge, session-idle/ended replay states, and a generic-tier confirm-advance
strip. Adds a `terminal` tab to the task detail view driven by the lifecycle
visibility matrix (live / read-only live / replay-idle / replay-ended / hidden)
with live `cli:session:state` SSE merging, waiting-on-input and needs-attention
task-card badges (distinct from staleness/stall badges), and extends
`SessionNotificationBanner` with a `cli-agent` session type plus the pinned
needs-attention variants (userExited / authFailed / resume-exhausted) and their
actions. All new strings flow through the i18n catalogs.

View File

@@ -8394,10 +8394,36 @@ export function reorderTodoItems(listId: string, itemIds: string[], projectId?:
// ── AI Sessions (Background Tasks) ───────────────────────────────────────── // ── AI Sessions (Background Tasks) ─────────────────────────────────────────
/**
* Needs-attention variants for a CLI agent session (CLI Agent Executor, U11).
* Each carries pinned banner copy + action verbs:
* - userExited → Advance / Retry / Cancel task
* - authFailed → Re-authenticate / Retry
* - resume-exhausted → Relaunch fresh / Cancel task
*/
export type CliNeedsAttentionVariant = "userExited" | "authFailed" | "resume-exhausted";
export interface AiSessionSummary { export interface AiSessionSummary {
id: string; id: string;
type: "planning" | "subtask" | "mission_interview" | "milestone_interview" | "slice_interview"; type:
status: "draft" | "generating" | "awaiting_input" | "complete" | "error"; | "planning"
| "subtask"
| "mission_interview"
| "milestone_interview"
| "slice_interview"
| "cli-agent";
status:
| "draft"
| "generating"
| "awaiting_input"
| "complete"
| "error"
| "waiting_on_input"
| "needs_attention";
/** For cli-agent sessions: which needs-attention variant (drives pinned copy/actions). */
cliVariant?: CliNeedsAttentionVariant;
/** Underlying CLI session id, for action wiring (confirm-advance / re-auth / etc.). */
cliSessionId?: string;
title: string; title: string;
/** Server-derived preview of the in-progress initialPlan; only set for draft planning sessions. */ /** Server-derived preview of the in-progress initialPlan; only set for draft planning sessions. */
preview?: string; preview?: string;

View File

@@ -1,6 +1,6 @@
import "./BackgroundTasksIndicator.css"; import "./BackgroundTasksIndicator.css";
import { useState, useRef, useEffect, useMemo } from "react"; import { useState, useRef, useEffect, useMemo } from "react";
import { Lightbulb, Layers, Target, Loader2, HelpCircle, X, Lock, AlertCircle } from "lucide-react"; import { Lightbulb, Layers, Target, Terminal, Loader2, HelpCircle, X, Lock, AlertCircle } from "lucide-react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import type { AiSessionSummary } from "../api"; import type { AiSessionSummary } from "../api";
import { useAiSessionSync } from "../hooks/useAiSessionSync"; import { useAiSessionSync } from "../hooks/useAiSessionSync";
@@ -21,6 +21,7 @@ const TYPE_ICONS = {
mission_interview: Target, mission_interview: Target,
milestone_interview: Target, milestone_interview: Target,
slice_interview: Target, slice_interview: Target,
"cli-agent": Terminal,
} as const; } as const;
export function BackgroundTasksIndicator({ export function BackgroundTasksIndicator({
@@ -49,6 +50,7 @@ export function BackgroundTasksIndicator({
mission_interview: t("backgroundTasks.typeLabel.missionInterview", "Mission Interview"), mission_interview: t("backgroundTasks.typeLabel.missionInterview", "Mission Interview"),
milestone_interview: t("backgroundTasks.typeLabel.milestoneInterview", "Milestone Interview"), milestone_interview: t("backgroundTasks.typeLabel.milestoneInterview", "Milestone Interview"),
slice_interview: t("backgroundTasks.typeLabel.sliceInterview", "Slice Interview"), slice_interview: t("backgroundTasks.typeLabel.sliceInterview", "Slice Interview"),
"cli-agent": t("backgroundTasks.typeLabel.cliAgent", "CLI Agent"),
}), }),
[t], [t],
); );

View File

@@ -1,22 +1,35 @@
import "./SessionNotificationBanner.css"; import "./SessionNotificationBanner.css";
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { AlertCircle, Lightbulb, Layers, Target, X } from "lucide-react"; import { AlertCircle, Lightbulb, Layers, Target, Terminal, X } from "lucide-react";
import type { AiSessionSummary } from "../api"; import type { AiSessionSummary, CliNeedsAttentionVariant } from "../api";
type CliActionId = "advance" | "retry" | "cancel" | "reauthenticate" | "relaunch";
interface SessionNotificationBannerProps { interface SessionNotificationBannerProps {
sessions: AiSessionSummary[]; sessions: AiSessionSummary[];
onResumeSession: (session: AiSessionSummary) => void; onResumeSession: (session: AiSessionSummary) => void;
onDismissSession: (id: string) => void; onDismissSession: (id: string) => void;
onDismissAll: () => void; onDismissAll: () => void;
/**
* CLI agent needs-attention / confirm-advance actions (CLI Agent Executor,
* U11). `advance` wires the userExited "Advance" verb + generic-tier
* confirm-advance; the others map to existing endpoints where present, else
* are no-op callbacks marked TODO-wire by the caller.
*/
onCliAction?: (session: AiSessionSummary, action: CliActionId) => void;
} }
// `cli-agent` extends the previously-closed union: a SINGLE Terminal icon for
// all adapters (reusing the banner without this entry crashes on the unknown
// type — the union-regression the U11 tests guard).
const TYPE_ICONS = { const TYPE_ICONS = {
planning: Lightbulb, planning: Lightbulb,
subtask: Layers, subtask: Layers,
mission_interview: Target, mission_interview: Target,
milestone_interview: Target, milestone_interview: Target,
slice_interview: Target, slice_interview: Target,
"cli-agent": Terminal,
} as const; } as const;
const TYPE_LABEL_KEYS: Record<keyof typeof TYPE_ICONS, { key: string; defaultVal: string }> = { const TYPE_LABEL_KEYS: Record<keyof typeof TYPE_ICONS, { key: string; defaultVal: string }> = {
@@ -25,6 +38,38 @@ const TYPE_LABEL_KEYS: Record<keyof typeof TYPE_ICONS, { key: string; defaultVal
mission_interview: { key: "sessionBanner.typeLabel.missionInterview", defaultVal: "Mission Interview" }, mission_interview: { key: "sessionBanner.typeLabel.missionInterview", defaultVal: "Mission Interview" },
milestone_interview: { key: "sessionBanner.typeLabel.milestoneInterview", defaultVal: "Milestone Interview" }, milestone_interview: { key: "sessionBanner.typeLabel.milestoneInterview", defaultVal: "Milestone Interview" },
slice_interview: { key: "sessionBanner.typeLabel.sliceInterview", defaultVal: "Slice Interview" }, slice_interview: { key: "sessionBanner.typeLabel.sliceInterview", defaultVal: "Slice Interview" },
"cli-agent": { key: "sessionBanner.typeLabel.cliAgent", defaultVal: "CLI Agent" },
};
/** Action verb defaults (i18n) for each pinned needs-attention variant. */
const CLI_ACTION_LABELS: Record<CliActionId, { key: string; defaultVal: string }> = {
advance: { key: "sessionBanner.cli.advance", defaultVal: "Advance" },
retry: { key: "sessionBanner.cli.retry", defaultVal: "Retry" },
cancel: { key: "sessionBanner.cli.cancelTask", defaultVal: "Cancel task" },
reauthenticate: { key: "sessionBanner.cli.reauthenticate", defaultVal: "Re-authenticate" },
relaunch: { key: "sessionBanner.cli.relaunch", defaultVal: "Relaunch fresh" },
};
/** Pinned copy + ordered actions per needs-attention variant (U11). */
const CLI_VARIANT_SPEC: Record<
CliNeedsAttentionVariant,
{ messageKey: string; messageDefault: string; actions: CliActionId[] }
> = {
userExited: {
messageKey: "sessionBanner.cli.userExited",
messageDefault: "Agent exited before completing",
actions: ["advance", "retry", "cancel"],
},
authFailed: {
messageKey: "sessionBanner.cli.authFailed",
messageDefault: "CLI authentication failed",
actions: ["reauthenticate", "retry"],
},
"resume-exhausted": {
messageKey: "sessionBanner.cli.resumeExhausted",
messageDefault: "Couldn't resume the session",
actions: ["relaunch", "cancel"],
},
}; };
const STORAGE_KEY = "fusion:session-banner-dismissed"; const STORAGE_KEY = "fusion:session-banner-dismissed";
@@ -66,6 +111,21 @@ function persistDismissed(map: Map<string, number>): void {
} }
} }
/**
* Statuses that warrant a banner entry. Extended for CLI agent sessions:
* `waiting_on_input` (F2) and `needs_attention` (pinned variants) join the
* existing `awaiting_input` / `error`. A CLI session returning to `busy`
* (no longer in this set) clears the banner entry — covering F2.
*/
function isNotifyingStatus(status: AiSessionSummary["status"]): boolean {
return (
status === "awaiting_input" ||
status === "error" ||
status === "waiting_on_input" ||
status === "needs_attention"
);
}
// Map of sessionId → epoch-ms timestamp at which the user dismissed the // Map of sessionId → epoch-ms timestamp at which the user dismissed the
// banner for that session. The banner re-shows the session only when the // banner for that session. The banner re-shows the session only when the
// session's `updatedAt` advances strictly past the recorded dismissal time // session's `updatedAt` advances strictly past the recorded dismissal time
@@ -78,6 +138,7 @@ export function SessionNotificationBanner({
onResumeSession, onResumeSession,
onDismissSession, onDismissSession,
onDismissAll, onDismissAll,
onCliAction,
}: SessionNotificationBannerProps) { }: SessionNotificationBannerProps) {
const { t } = useTranslation("app"); const { t } = useTranslation("app");
const [dismissRevision, setDismissRevision] = useState(0); const [dismissRevision, setDismissRevision] = useState(0);
@@ -98,7 +159,7 @@ export function SessionNotificationBanner({
for (const [id, dismissedAtMs] of dismissedIds) { for (const [id, dismissedAtMs] of dismissedIds) {
const session = sessionById.get(id); const session = sessionById.get(id);
if (!session) continue; if (!session) continue;
const stillNotifying = session.status === "awaiting_input" || session.status === "error"; const stillNotifying = isNotifyingStatus(session.status);
if (!stillNotifying) { if (!stillNotifying) {
dismissedIds.delete(id); dismissedIds.delete(id);
pruned = true; pruned = true;
@@ -117,7 +178,7 @@ export function SessionNotificationBanner({
const sessionsNeedingInput = useMemo( const sessionsNeedingInput = useMemo(
() => () =>
sessions.filter((session) => { sessions.filter((session) => {
if (session.status !== "awaiting_input" && session.status !== "error") return false; if (!isNotifyingStatus(session.status)) return false;
const dismissedAtMs = dismissedIds.get(session.id); const dismissedAtMs = dismissedIds.get(session.id);
if (dismissedAtMs === undefined) return true; if (dismissedAtMs === undefined) return true;
return parseUpdatedAtMs(session.updatedAt) > dismissedAtMs; return parseUpdatedAtMs(session.updatedAt) > dismissedAtMs;
@@ -129,8 +190,14 @@ export function SessionNotificationBanner({
return null; return null;
} }
const awaitingInputCount = sessionsNeedingInput.filter((s) => s.status === "awaiting_input").length; // CLI `waiting_on_input` rolls into the "needs input" count; `needs_attention`
const errorCount = sessionsNeedingInput.filter((s) => s.status === "error").length; // rolls into the "failed" count for the summary header.
const awaitingInputCount = sessionsNeedingInput.filter(
(s) => s.status === "awaiting_input" || s.status === "waiting_on_input",
).length;
const errorCount = sessionsNeedingInput.filter(
(s) => s.status === "error" || s.status === "needs_attention",
).length;
let headerText = ""; let headerText = "";
if (awaitingInputCount > 0 && errorCount > 0) { if (awaitingInputCount > 0 && errorCount > 0) {
@@ -207,6 +274,64 @@ export function SessionNotificationBanner({
{sessionsNeedingInput.map((session) => { {sessionsNeedingInput.map((session) => {
const Icon = TYPE_ICONS[session.type]; const Icon = TYPE_ICONS[session.type];
const isError = session.status === "error"; const isError = session.status === "error";
const variantSpec =
session.type === "cli-agent" && session.cliVariant
? CLI_VARIANT_SPEC[session.cliVariant]
: null;
// Pinned needs-attention variant: per-variant copy + ordered actions.
if (variantSpec) {
return (
<article
className="session-notification-banner__item session-notification-banner__item--cli session-notification-banner__item--error"
key={session.id}
data-session-type={session.type}
data-session-status={session.status}
data-cli-variant={session.cliVariant}
>
<div className="session-notification-banner__item-main">
<Icon size={16} className="session-notification-banner__type-icon" aria-hidden="true" />
<div className="session-notification-banner__text">
<p className="session-notification-banner__title" title={session.title}>{session.title}</p>
<p className="session-notification-banner__meta">
{t(variantSpec.messageKey, variantSpec.messageDefault)}
</p>
</div>
</div>
<div className="session-notification-banner__actions">
{variantSpec.actions.map((action) => (
<button
key={action}
className="session-notification-banner__resume"
data-cli-action={action}
onClick={() => {
// "advance" wires confirm-advance; other verbs hit
// existing endpoints or remain TODO-wire no-ops upstream.
onCliAction?.(session, action);
if (action === "cancel" || action === "advance") {
dismissLocally(session);
onDismissSession(session.id);
}
}}
>
{t(CLI_ACTION_LABELS[action].key, CLI_ACTION_LABELS[action].defaultVal)}
</button>
))}
<button
className="session-notification-banner__dismiss"
onClick={() => {
dismissLocally(session);
onDismissSession(session.id);
}}
aria-label={t("sessionBanner.dismissItem", "Dismiss {{title}}", { title: session.title })}
>
<X size={14} aria-hidden="true" />
</button>
</div>
</article>
);
}
return ( return (
<article <article

View File

@@ -0,0 +1,165 @@
/* SessionTerminal (CLI Agent Executor, U11) — canonical tokens only. */
.cli-session-terminal {
display: flex;
flex-direction: column;
min-height: 0;
height: 100%;
background: var(--terminal-bg, var(--bg));
border: 1px solid var(--border);
border-radius: var(--radius-md);
overflow: hidden;
}
.cli-session-terminal__header {
display: flex;
align-items: center;
gap: var(--space-sm);
padding: var(--space-xs) var(--space-sm);
background: var(--surface);
border-bottom: 1px solid var(--border);
flex-wrap: wrap;
}
.cli-session-terminal__posture-wrap {
position: relative;
}
.cli-posture-chip {
display: inline-flex;
align-items: center;
gap: var(--space-xs);
padding: 2px var(--space-sm);
font-size: 0.75rem;
line-height: 1.4;
color: var(--text);
background: var(--card);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
cursor: pointer;
transition: background var(--transition-fast, 0.12s) ease;
}
.cli-posture-chip:hover {
background: var(--card-hover);
}
.cli-posture-chip--elevated {
color: var(--warning, var(--color-warning));
border-color: var(--warning, var(--color-warning));
}
.cli-posture-chip__mode {
color: var(--text-muted);
}
.cli-posture-chip__flag {
font-weight: 600;
}
.cli-posture-tooltip {
position: absolute;
top: calc(100% + var(--space-xs));
left: 0;
z-index: 10;
min-width: 220px;
padding: var(--space-sm);
background: var(--card);
border: 1px solid var(--border);
border-radius: var(--radius-md);
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.25);
}
.cli-posture-tooltip__title {
margin: 0 0 var(--space-xs);
font-size: 0.75rem;
font-weight: 600;
color: var(--text);
}
.cli-posture-tooltip__list {
margin: 0;
padding-left: var(--space-md);
font-size: 0.75rem;
color: var(--text-muted);
}
.cli-posture-tooltip__settings {
display: inline-flex;
align-items: center;
gap: var(--space-xs);
margin-top: var(--space-sm);
padding: 2px var(--space-sm);
font-size: 0.75rem;
color: var(--accent, var(--color-primary));
background: transparent;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
cursor: pointer;
}
.cli-session-terminal__readonly-badge,
.cli-session-terminal__replay-badge {
display: inline-flex;
align-items: center;
gap: var(--space-xs);
padding: 2px var(--space-sm);
font-size: 0.7rem;
color: var(--text-muted);
background: var(--card);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
}
.cli-session-terminal__replay-badge[data-replay-mode="ended"] {
color: var(--text-muted);
}
.cli-session-terminal__viewport {
flex: 1 1 auto;
min-height: 0;
padding: var(--space-xs);
background: var(--terminal-bg, var(--bg));
}
.cli-session-terminal__advance-strip {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-sm);
padding: var(--space-sm);
background: var(--surface);
border-top: 1px solid var(--border);
flex-wrap: wrap;
}
.cli-session-terminal__advance-copy {
font-size: 0.8125rem;
color: var(--text);
}
.cli-session-terminal__advance-actions {
display: flex;
gap: var(--space-xs);
}
.cli-session-terminal__advance-btn {
padding: 4px var(--space-md);
font-size: 0.8125rem;
color: var(--button-primary-text, var(--accent-text));
background: var(--button-primary-bg, var(--accent));
border: 1px solid transparent;
border-radius: var(--radius-sm);
cursor: pointer;
}
.cli-session-terminal__advance-btn:disabled {
opacity: 0.6;
cursor: default;
}
.cli-session-terminal__advance-btn--secondary {
color: var(--text);
background: var(--card);
border-color: var(--border);
}

View File

@@ -0,0 +1,429 @@
import "./SessionTerminal.css";
import "@xterm/xterm/css/xterm.css";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { Terminal as TerminalIcon, ShieldAlert, Settings, Eye } from "lucide-react";
import type { Terminal as XTerm, ITerminalAddon } from "@xterm/xterm";
import { appendTokenQuery } from "../auth";
import { api } from "../api";
/**
* SessionTerminal (CLI Agent Executor, U11) — shared xterm terminal for a CLI
* agent session. Lazy-loads xterm + fit/webgl/unicode11 addons (kept out of the
* main bundle), bridges to the U10 WebSocket attach channel with ACK flow
* control, and renders the posture chip / read-only badge / confirm-advance
* strip / replay states described in the U11 visibility matrix.
*
* The WS bridge:
* 1. POST /api/cli-sessions/:id/attach-ticket → { ticket }
* 2. open WS /api/cli-sessions/ws?sessionId=&ticket= (fn_token carried on URL)
* 3. base64 scrollback/data → term.write; term.onData → input frames
* 4. fit + debounced ResizeObserver → resize frames
* 5. ACK {type:"ack",bytes} via term.write callbacks (~32KB cadence)
*/
/** ACK cadence — ACK roughly every 32KB of consumed output. */
const ACK_THRESHOLD_BYTES = 32 * 1024;
const RESIZE_DEBOUNCE_MS = 100;
/** The posture surfaced on the session record (denormalized at launch, U15). */
export interface SessionTerminalPosture {
/** Adapter display name (single Terminal icon for all adapters). */
adapterName: string;
/** Resolved autonomy mode label (e.g. "auto-approve", "default"). */
mode?: string;
/**
* Whether the resolved argv+env elevates above the adapter baseline. When
* true the chip renders in warning color with a shield naming the flag.
*/
elevated?: boolean;
/** The elevated flag(s), named on the chip / tooltip when elevated. */
elevatedFlags?: string[];
/** Resolved posture lines shown in the click tooltip. */
resolved?: string[];
}
/** Replay/live mode for the terminal viewport. */
export type SessionTerminalMode = "live" | "idle" | "ended";
export interface SessionTerminalProps {
sessionId: string;
/** When true, term.onData is dropped (one-shot / replay sessions). */
readOnly?: boolean;
posture?: SessionTerminalPosture;
/** Drives the replay header: live | "session idle" | "session ended". */
mode?: SessionTerminalMode;
projectId?: string;
/** Generic-tier idle confirm-advance strip — POST confirm-advance on Advance. */
onConfirmAdvance?: (decision: "advance" | "not-yet") => void | Promise<void>;
/** Whether the confirm-advance strip is offered (generic-tier idle). */
showConfirmAdvance?: boolean;
/** Settings deep link for the posture chip tooltip. */
onOpenAdapterSettings?: () => void;
}
interface AttachTicketResponse {
ticket: string;
expiresAt: string;
readOnly: boolean;
}
/** Build the WS URL for the cli-sessions attach channel (mirrors useTerminal). */
function buildCliWsUrl(sessionId: string, ticket: string): string {
if (typeof window === "undefined") return "";
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
const base =
`${protocol}//${window.location.host}/api/cli-sessions/ws` +
`?sessionId=${encodeURIComponent(sessionId)}&ticket=${encodeURIComponent(ticket)}`;
return appendTokenQuery(base);
}
function decodeBase64ToString(b64: string): string {
if (typeof window !== "undefined" && typeof window.atob === "function") {
// atob → binary string → UTF-8 decode.
const binary = window.atob(b64);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
return new TextDecoder("utf-8").decode(bytes);
}
return Buffer.from(b64, "base64").toString("utf8");
}
export function SessionTerminal({
sessionId,
readOnly = false,
posture,
mode = "live",
projectId,
onConfirmAdvance,
showConfirmAdvance = false,
onOpenAdapterSettings,
}: SessionTerminalProps) {
const { t } = useTranslation("app");
const containerRef = useRef<HTMLDivElement | null>(null);
const xtermRef = useRef<XTerm | null>(null);
const fitAddonRef = useRef<ITerminalAddon | null>(null);
const wsRef = useRef<WebSocket | null>(null);
const [postureTooltipOpen, setPostureTooltipOpen] = useState(false);
const [advanceDismissed, setAdvanceDismissed] = useState(false);
const [advancePending, setAdvancePending] = useState(false);
// Re-arm the strip whenever a fresh idle window is offered.
useEffect(() => {
if (showConfirmAdvance) setAdvanceDismissed(false);
}, [showConfirmAdvance, sessionId]);
// ── xterm lifecycle + WS bridge ──────────────────────────────────────────
useEffect(() => {
if (!sessionId || typeof window === "undefined") return;
let disposed = false;
let resizeObserver: ResizeObserver | null = null;
let resizeTimer: ReturnType<typeof setTimeout> | null = null;
let unackedBytes = 0;
const sendResize = (cols: number, rows: number) => {
const ws = wsRef.current;
if (ws?.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: "resize", cols, rows }));
}
};
const ackBytes = (n: number) => {
unackedBytes += n;
if (unackedBytes < ACK_THRESHOLD_BYTES) return;
const ws = wsRef.current;
if (ws?.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: "ack", bytes: unackedBytes }));
}
unackedBytes = 0;
};
const init = async () => {
// 1. Mint a single-use attach ticket via the app API helper.
let ticketRes: AttachTicketResponse;
try {
ticketRes = await api<AttachTicketResponse>(
`/cli-sessions/${encodeURIComponent(sessionId)}/attach-ticket`,
{ method: "POST", body: JSON.stringify(projectId ? { projectId } : {}) },
);
} catch {
return; // surfaced via the "disconnected" state header below
}
if (disposed) return;
// 2. Lazy-load xterm + addons (out of the main bundle).
const [{ Terminal }, { FitAddon }, { Unicode11Addon }] = await Promise.all([
import("@xterm/xterm"),
import("@xterm/addon-fit"),
import("@xterm/addon-unicode11"),
]);
if (disposed || !containerRef.current) return;
const term = new Terminal({
convertEol: false,
cursorBlink: !readOnly && mode === "live",
disableStdin: readOnly,
scrollback: 10000,
// Defensive: do NOT register an OSC 52 (clipboard-write) handler. The
// server-side neutralizer (U10) strips it; we add no client handling.
fontFamily:
'ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace',
fontSize: 13,
});
const fitAddon = new FitAddon();
term.loadAddon(fitAddon);
const unicode11 = new Unicode11Addon();
term.loadAddon(unicode11);
term.unicode.activeVersion = "11";
term.open(containerRef.current);
xtermRef.current = term;
fitAddonRef.current = fitAddon as unknown as ITerminalAddon;
// WebGL renderer with context-loss fallback to the DOM renderer.
try {
const { WebglAddon } = await import("@xterm/addon-webgl");
if (!disposed) {
const webgl = new WebglAddon();
webgl.onContextLoss(() => {
try {
webgl.dispose();
} catch {
/* fall back to DOM renderer */
}
});
term.loadAddon(webgl);
}
} catch {
/* WebGL unavailable — DOM renderer is the default fallback */
}
try {
(fitAddon as unknown as { fit: () => void }).fit();
} catch {
/* container not measurable yet */
}
// term.onData → input frames (skip entirely when read-only).
if (!readOnly) {
term.onData((data: string) => {
const ws = wsRef.current;
if (ws?.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: "input", data }));
}
});
}
// Debounced ResizeObserver → resize frames.
resizeObserver = new ResizeObserver(() => {
if (resizeTimer) clearTimeout(resizeTimer);
resizeTimer = setTimeout(() => {
try {
(fitAddon as unknown as { fit: () => void }).fit();
sendResize(term.cols, term.rows);
} catch {
/* ignore transient measure failures */
}
}, RESIZE_DEBOUNCE_MS);
});
resizeObserver.observe(containerRef.current);
// 3. Open the WS attach channel.
const ws = new WebSocket(buildCliWsUrl(sessionId, ticketRes.ticket));
wsRef.current = ws;
ws.onopen = () => {
sendResize(term.cols, term.rows);
};
ws.onmessage = (event) => {
let msg: { type?: string; data?: string };
try {
msg = JSON.parse(typeof event.data === "string" ? event.data : "");
} catch {
return;
}
switch (msg.type) {
case "scrollback":
case "data": {
if (typeof msg.data !== "string") return;
const text = decodeBase64ToString(msg.data);
const byteLen = text.length;
// ACK once xterm has flushed the chunk to the screen.
term.write(text, () => ackBytes(byteLen));
break;
}
// state / error / exit frames are advisory; the SSE channel and the
// mode prop drive header copy. We intentionally do not mutate the
// viewport on them.
default:
break;
}
};
};
void init();
return () => {
disposed = true;
if (resizeTimer) clearTimeout(resizeTimer);
if (resizeObserver) resizeObserver.disconnect();
const ws = wsRef.current;
if (ws) {
ws.onopen = null;
ws.onmessage = null;
ws.onclose = null;
ws.onerror = null;
try {
ws.close();
} catch {
/* already closing */
}
wsRef.current = null;
}
const term = xtermRef.current;
if (term) {
try {
term.dispose();
} catch {
/* ignore */
}
xtermRef.current = null;
}
fitAddonRef.current = null;
};
}, [sessionId, readOnly, mode, projectId]);
const replayLabel = useMemo(() => {
if (mode === "idle") return t("cliTerminal.replayIdle", "Session idle");
if (mode === "ended") return t("cliTerminal.replayEnded", "Session ended");
return null;
}, [mode, t]);
const handleAdvance = useCallback(async () => {
if (!onConfirmAdvance) return;
setAdvancePending(true);
try {
await onConfirmAdvance("advance");
setAdvanceDismissed(true);
} finally {
setAdvancePending(false);
}
}, [onConfirmAdvance]);
const handleNotYet = useCallback(async () => {
if (onConfirmAdvance) await onConfirmAdvance("not-yet");
// "Not yet" stays in execute and re-arms the idle timer (server-side); the
// strip hides until the next idle window re-offers it.
setAdvanceDismissed(true);
}, [onConfirmAdvance]);
const elevated = Boolean(posture?.elevated);
const flagSummary = posture?.elevatedFlags?.join(", ");
return (
<div className="cli-session-terminal" data-mode={mode} data-read-only={readOnly}>
<header className="cli-session-terminal__header">
{posture && (
<div className="cli-session-terminal__posture-wrap">
<button
type="button"
className={`cli-posture-chip${elevated ? " cli-posture-chip--elevated" : ""}`}
data-elevated={elevated}
aria-expanded={postureTooltipOpen}
onClick={() => setPostureTooltipOpen((v) => !v)}
>
{elevated ? (
<ShieldAlert size={13} aria-hidden="true" />
) : (
<TerminalIcon size={13} aria-hidden="true" />
)}
<span className="cli-posture-chip__name">{posture.adapterName}</span>
{posture.mode && (
<span className="cli-posture-chip__mode">{posture.mode}</span>
)}
{elevated && flagSummary && (
<span className="cli-posture-chip__flag">{flagSummary}</span>
)}
</button>
{postureTooltipOpen && (
<div className="cli-posture-tooltip" role="tooltip">
<p className="cli-posture-tooltip__title">
{t("cliTerminal.postureResolved", "Resolved posture")}
</p>
<ul className="cli-posture-tooltip__list">
{(posture.resolved ?? []).map((line, i) => (
<li key={i}>{line}</li>
))}
{(posture.resolved ?? []).length === 0 && (
<li>{posture.mode ?? t("cliTerminal.postureBaseline", "Baseline")}</li>
)}
</ul>
{onOpenAdapterSettings && (
<button
type="button"
className="cli-posture-tooltip__settings"
onClick={() => {
setPostureTooltipOpen(false);
onOpenAdapterSettings();
}}
>
<Settings size={12} aria-hidden="true" />
{t("cliTerminal.adapterSettings", "Adapter settings")}
</button>
)}
</div>
)}
</div>
)}
{readOnly && (
<span className="cli-session-terminal__readonly-badge">
<Eye size={12} aria-hidden="true" />
{t("cliTerminal.readOnly", "Read-only")}
</span>
)}
{replayLabel && (
<span className="cli-session-terminal__replay-badge" data-replay-mode={mode}>
{replayLabel}
</span>
)}
</header>
<div
className="cli-session-terminal__viewport"
ref={containerRef}
data-testid="cli-terminal-viewport"
/>
{showConfirmAdvance && !advanceDismissed && (
<div className="cli-session-terminal__advance-strip" role="region">
<span className="cli-session-terminal__advance-copy">
{t(
"cliTerminal.advancePrompt",
"This session looks idle — advance to review?",
)}
</span>
<div className="cli-session-terminal__advance-actions">
<button
type="button"
className="cli-session-terminal__advance-btn"
disabled={advancePending}
onClick={handleAdvance}
>
{t("cliTerminal.advance", "Advance")}
</button>
<button
type="button"
className="cli-session-terminal__advance-btn cli-session-terminal__advance-btn--secondary"
disabled={advancePending}
onClick={handleNotYet}
>
{t("cliTerminal.notYet", "Not yet")}
</button>
</div>
</div>
)}
</div>
);
}

View File

@@ -407,6 +407,25 @@ interface TaskCardProps {
/** Card-placed custom field definitions for this task's workflow (U13/KTD-14). /** Card-placed custom field definitions for this task's workflow (U13/KTD-14).
* Empty/undefined → no field badges render (card byte-identical to today). */ * Empty/undefined → no field badges render (card byte-identical to today). */
cardFieldDefs?: WorkflowFieldDefinition[]; cardFieldDefs?: WorkflowFieldDefinition[];
/**
* CLI agent session state for this task's session (CLI Agent Executor, U11).
* Drives the waiting-on-input / needs-attention card badges, which are
* DISTINCT from staleness/stall badges (which U8 suppresses in these states).
* Undefined when the task has no CLI session → no badge (card unchanged).
*/
cliSessionState?: CliCardState;
}
/** Minimal CLI session shape the card needs for its badges (U11). */
export interface CliCardState {
agentState:
| "starting"
| "ready"
| "busy"
| "waitingOnInput"
| "done"
| "dead"
| "needsAttention";
} }
function getTaskPrimaryPrInfo(task: Pick<Task, "prInfo" | "prInfos">): PrInfo | undefined { function getTaskPrimaryPrInfo(task: Pick<Task, "prInfo" | "prInfos">): PrInfo | undefined {
@@ -540,6 +559,7 @@ function areTaskCardPropsEqual(previous: TaskCardProps, next: TaskCardProps): bo
previous.taskStuckTimeoutMs === next.taskStuckTimeoutMs && previous.taskStuckTimeoutMs === next.taskStuckTimeoutMs &&
previous.prAuthAvailable === next.prAuthAvailable && previous.prAuthAvailable === next.prAuthAvailable &&
previous.autoMergeEnabled === next.autoMergeEnabled && previous.autoMergeEnabled === next.autoMergeEnabled &&
previous.cliSessionState?.agentState === next.cliSessionState?.agentState &&
previous.cardFieldDefs === next.cardFieldDefs && previous.cardFieldDefs === next.cardFieldDefs &&
(previous.cardFieldDefs == null && next.cardFieldDefs == null (previous.cardFieldDefs == null && next.cardFieldDefs == null
? true ? true
@@ -658,6 +678,7 @@ function TaskCardComponent({
prAuthAvailable, prAuthAvailable,
autoMergeEnabled = false, autoMergeEnabled = false,
cardFieldDefs, cardFieldDefs,
cliSessionState,
}: TaskCardProps) { }: TaskCardProps) {
const { t } = useTranslation("app"); const { t } = useTranslation("app");
const columnLabel = useColumnLabel(); const columnLabel = useColumnLabel();
@@ -924,6 +945,9 @@ function TaskCardComponent({
const stalledReview = getStalledReviewSignal(task); const stalledReview = getStalledReviewSignal(task);
const showStalledReview = Boolean(stalledReview && task.column === "in-review" && !isPaused); const showStalledReview = Boolean(stalledReview && task.column === "in-review" && !isPaused);
const hasInReviewStall = shouldShowInReviewStallBadge(task); const hasInReviewStall = shouldShowInReviewStallBadge(task);
// CLI agent session badges (U11) — distinct from staleness/stall badges.
const cliWaitingOnInput = cliSessionState?.agentState === "waitingOnInput";
const cliNeedsAttention = cliSessionState?.agentState === "needsAttention";
const stallCopy = task.inReviewStall const stallCopy = task.inReviewStall
? getInReviewStallCopy(task.inReviewStall, { ? getInReviewStallCopy(task.inReviewStall, {
mergeRetries: task.mergeRetries, mergeRetries: task.mergeRetries,
@@ -1811,6 +1835,24 @@ function TaskCardComponent({
{stallCopy.badgeLabel}{stallCopy.counter ? ` ${stallCopy.counter}` : ""} {stallCopy.badgeLabel}{stallCopy.counter ? ` ${stallCopy.counter}` : ""}
</span> </span>
)} )}
{cliWaitingOnInput && (
<span
className="card-status-badge card-status-badge--cli-waiting"
data-cli-state="waitingOnInput"
title={t("tasks.cliWaitingOnInputTitle", "The CLI agent is waiting for your input")}
>
{t("tasks.cliWaitingOnInput", "Waiting on input")}
</span>
)}
{cliNeedsAttention && (
<span
className="card-status-badge card-status-badge--cli-attention failed"
data-cli-state="needsAttention"
title={t("tasks.cliNeedsAttentionTitle", "The CLI agent needs your attention")}
>
{t("tasks.cliNeedsAttention", "Needs attention")}
</span>
)}
{hasStalePausedReview && stalePausedReviewCopy && ( {hasStalePausedReview && stalePausedReviewCopy && (
<span <span
className={`card-status-badge card-status-badge--in-review stale-paused-review stale-paused-review--${stalePausedReviewCopy.code}`} className={`card-status-badge card-status-badge--in-review stale-paused-review stale-paused-review--${stalePausedReviewCopy.code}`}

View File

@@ -21,7 +21,7 @@ import {
resolveTaskPlanningModel, resolveTaskPlanningModel,
resolveTaskValidatorModel, resolveTaskValidatorModel,
} from "@fusion/core"; } from "@fusion/core";
import { uploadAttachment, deleteAttachment, updateTask, pauseTask, unpauseTask, fetchTaskDetail, fetchSettings, fetchGlobalSettings, requestSpecRevision, rebuildTaskSpec, approvePlan, rejectPlan, refineTask, fetchWorkflowResults, assignTask, fetchAgents, fetchAgent, recoverBranchBinding, refreshPrStatus, fetchBoardWorkflows, updateTaskCustomFields } from "../api"; import { uploadAttachment, deleteAttachment, updateTask, pauseTask, unpauseTask, fetchTaskDetail, fetchSettings, fetchGlobalSettings, requestSpecRevision, rebuildTaskSpec, approvePlan, rejectPlan, refineTask, fetchWorkflowResults, assignTask, fetchAgents, fetchAgent, recoverBranchBinding, refreshPrStatus, fetchBoardWorkflows, updateTaskCustomFields, api } from "../api";
import type { RecoverBranchBindingOutcome, WorkflowFieldDefinition, CustomFieldRejection } from "../api"; import type { RecoverBranchBindingOutcome, WorkflowFieldDefinition, CustomFieldRejection } from "../api";
import { ApiRequestError } from "../api"; import { ApiRequestError } from "../api";
import { TaskFieldsSection } from "./TaskFieldsSection"; import { TaskFieldsSection } from "./TaskFieldsSection";
@@ -46,6 +46,7 @@ import { BranchGroupCard } from "./BranchGroupCard";
import { PluginSlot } from "./PluginSlot"; import { PluginSlot } from "./PluginSlot";
import { ProviderIcon } from "./ProviderIcon"; import { ProviderIcon } from "./ProviderIcon";
import { subscribeSse } from "../sse-bus"; import { subscribeSse } from "../sse-bus";
import type { SessionTerminalMode, SessionTerminalPosture } from "./SessionTerminal";
import { usePluginUiSlots } from "../hooks/usePluginUiSlots"; import { usePluginUiSlots } from "../hooks/usePluginUiSlots";
import { appendTokenQuery } from "../auth"; import { appendTokenQuery } from "../auth";
import { extractDependencyDeleteConflict, extractLineageDeleteConflict } from "../utils/taskDelete"; import { extractDependencyDeleteConflict, extractLineageDeleteConflict } from "../utils/taskDelete";
@@ -281,7 +282,69 @@ function formatDurationCompact(ageMs: number): string {
return `${minutes}m`; return `${minutes}m`;
} }
type TabId = "definition" | "logs" | "changes" | "review" | "pr" | "comments" | "model" | "workflow" | "documents" | "stats" | "routing" | "retries" | `plugin-${string}`; type TabId = "definition" | "logs" | "changes" | "review" | "pr" | "comments" | "model" | "workflow" | "documents" | "stats" | "routing" | "retries" | "terminal" | `plugin-${string}`;
// Lazy-load the terminal so xterm + addons stay out of the main bundle (U11).
const LazySessionTerminal = lazy(() =>
import("./SessionTerminal").then((m) => ({ default: m.SessionTerminal })),
);
/** CLI session record fields the terminal tab needs (mirrors @fusion/core CliSession). */
export interface CliSessionSummaryRecord {
id: string;
taskId: string | null;
projectId: string;
adapterId: string;
agentState:
| "starting"
| "ready"
| "busy"
| "waitingOnInput"
| "done"
| "dead"
| "needsAttention";
terminationReason: string | null;
autonomyPosture?: Record<string, unknown> | null;
}
type CliTabVisibility =
| { kind: "hidden" }
| { kind: "live"; readOnly: boolean; mode: SessionTerminalMode; showConfirmAdvance: boolean }
| { kind: "replay"; mode: SessionTerminalMode };
/**
* Tab visibility matrix (U11):
* - starting/ready/busy/waitingOnInput → live terminal
* - one-shot (planning/validator) live → read-only live + badge
* - done (resumable) → replay "session idle"
* - dead/needsAttention (PTY reaped) → replay "session ended"
* - no recorded session → hidden
*/
export function deriveCliTabVisibility(
session: CliSessionSummaryRecord | null,
opts: { oneShot?: boolean; genericIdle?: boolean } = {},
): CliTabVisibility {
if (!session) return { kind: "hidden" };
const live =
session.agentState === "starting" ||
session.agentState === "ready" ||
session.agentState === "busy" ||
session.agentState === "waitingOnInput";
if (live) {
return {
kind: "live",
readOnly: Boolean(opts.oneShot),
mode: "live",
showConfirmAdvance: Boolean(opts.genericIdle),
};
}
if (session.agentState === "done") {
// execute-done but resumable → scrollback replay with a "session idle" header.
return { kind: "replay", mode: "idle" };
}
// dead / needsAttention → PTY reaped → "session ended".
return { kind: "replay", mode: "ended" };
}
export interface TaskDetailModalProps { export interface TaskDetailModalProps {
task: Task | TaskDetail; task: Task | TaskDetail;
@@ -494,6 +557,9 @@ export function TaskDetailContent({
const columnLabel = useColumnLabel(); const columnLabel = useColumnLabel();
const [activeTab, setActiveTab] = useState<TabId>(initialTab === "retries" ? "definition" : initialTab); const [activeTab, setActiveTab] = useState<TabId>(initialTab === "retries" ? "definition" : initialTab);
// ── CLI agent session (U11) ────────────────────────────────────────────────
const [cliSession, setCliSession] = useState<CliSessionSummaryRecord | null>(null);
// ── Async detail loading ────────────────────────────────────────────────── // ── Async detail loading ──────────────────────────────────────────────────
// When opened optimistically with a Task (no prompt), fetch the full // When opened optimistically with a Task (no prompt), fetch the full
// TaskDetail in the background. The modal renders immediately with the // TaskDetail in the background. The modal renders immediately with the
@@ -757,6 +823,56 @@ export function TaskDetailContent({
? pluginTabs.find((tab) => tab.tabId === activeTab) ?? null ? pluginTabs.find((tab) => tab.tabId === activeTab) ?? null
: null; : null;
// ── CLI terminal tab visibility + posture (U11) ────────────────────────────
const cliOneShot =
cliSession?.adapterId != null &&
(cliSession?.autonomyPosture?.purpose === "planning" ||
cliSession?.autonomyPosture?.purpose === "validator" ||
cliSession?.autonomyPosture?.readOnly === true);
const cliGenericIdle = cliSession?.autonomyPosture?.genericIdle === true;
const cliTabVisibility = useMemo(
() =>
deriveCliTabVisibility(cliSession, {
oneShot: cliOneShot,
genericIdle: cliGenericIdle,
}),
[cliSession, cliOneShot, cliGenericIdle],
);
const showCliTab = cliTabVisibility.kind !== "hidden";
const cliPosture: SessionTerminalPosture | undefined = useMemo(() => {
if (!cliSession) return undefined;
const p = cliSession.autonomyPosture ?? {};
const flags = Array.isArray(p.elevatedFlags) ? (p.elevatedFlags as string[]) : undefined;
return {
adapterName: (p.adapterName as string) ?? cliSession.adapterId,
mode: (p.mode as string) ?? (p.autoApprove ? "auto-approve" : undefined),
elevated: p.elevated === true,
elevatedFlags: flags,
resolved: Array.isArray(p.resolved) ? (p.resolved as string[]) : undefined,
};
}, [cliSession]);
// Confirm-advance handler — POST /api/cli-sessions/:id/confirm-advance.
const handleConfirmAdvance = useCallback(
async (decision: "advance" | "not-yet") => {
if (!cliSession) return;
try {
await api(`/cli-sessions/${encodeURIComponent(cliSession.id)}/confirm-advance`, {
method: "POST",
body: JSON.stringify({ decision, ...(projectId ? { projectId } : {}) }),
});
} catch {
/* surfaced via the strip's disabled state reset */
}
},
[cliSession, projectId],
);
// If the terminal tab is active but the session disappears, fall back.
useEffect(() => {
if (activeTab === "terminal" && !showCliTab) setActiveTab("definition");
}, [activeTab, showCliTab]);
// Track mount state to avoid setting state on unmounted component // Track mount state to avoid setting state on unmounted component
useEffect(() => { useEffect(() => {
mountedRef.current = true; mountedRef.current = true;
@@ -886,6 +1002,76 @@ export function TaskDetailContent({
}); });
}, [activeTab, task.id, projectId]); }, [activeTab, task.id, projectId]);
// Load the CLI agent session for this task (drives the terminal tab + matrix).
useEffect(() => {
let cancelled = false;
const search = new URLSearchParams({ taskId: task.id });
if (projectId) search.set("projectId", projectId);
void api<{ sessions: CliSessionSummaryRecord[] }>(`/cli-sessions?${search.toString()}`)
.then((res) => {
if (cancelled) return;
// Most-recent session for the task (the list is store-ordered).
const sessions = res.sessions ?? [];
setCliSession(sessions.length > 0 ? sessions[sessions.length - 1] : null);
})
.catch(() => {
if (!cancelled) setCliSession(null);
});
return () => {
cancelled = true;
};
}, [task.id, projectId]);
// Live CLI session state via SSE — MERGE payload fields onto the record
// (never wholesale-replace: the list fetch carries enriched fields the SSE
// payload omits, e.g. adapterId / autonomyPosture).
useEffect(() => {
const query = projectId ? `?projectId=${encodeURIComponent(projectId)}` : "";
const handleCliState = (e: MessageEvent) => {
try {
const payload = JSON.parse(e.data) as {
sessionId: string;
taskId: string | null;
state: string;
terminationReason?: string | null;
};
if (payload.taskId !== task.id) return;
setCliSession((prev) => {
if (!prev || prev.id !== payload.sessionId) {
// Unknown/new session for this task — keep the enriched record from
// the list fetch as the source of truth; ignore until it loads.
if (!prev) return prev;
}
// The machine "idle"/"resuming" states map onto persisted enums; the
// card/tab only need the persisted set, so coerce here.
const next = { ...prev } as CliSessionSummaryRecord;
if (
payload.state === "starting" ||
payload.state === "ready" ||
payload.state === "busy" ||
payload.state === "waitingOnInput" ||
payload.state === "done" ||
payload.state === "dead" ||
payload.state === "needsAttention"
) {
next.agentState = payload.state;
} else if (payload.state === "idle" || payload.state === "resuming") {
next.agentState = "busy";
}
if (payload.terminationReason !== undefined) {
next.terminationReason = payload.terminationReason ?? null;
}
return next;
});
} catch {
/* skip malformed events */
}
};
return subscribeSse(`/api/events${query}`, {
events: { "cli:session:state": handleCliState },
});
}, [task.id, projectId]);
// Reset dependency search when dropdown closes // Reset dependency search when dropdown closes
useEffect(() => { useEffect(() => {
if (!showDepDropdown) { if (!showDepDropdown) {
@@ -2827,6 +3013,14 @@ export function TaskDetailContent({
> >
{t("taskDetail.tabs.routing", "Routing")} {t("taskDetail.tabs.routing", "Routing")}
</button> </button>
{showCliTab && (
<button
className={`detail-tab${activeTab === "terminal" ? " detail-tab-active" : ""}`}
onClick={() => setActiveTab("terminal")}
>
{t("taskDetail.tabs.terminal", "Terminal")}
</button>
)}
{/* Plugin tabs */} {/* Plugin tabs */}
{pluginTabs.map(({ entry, tabId }) => { {pluginTabs.map(({ entry, tabId }) => {
return ( return (
@@ -3118,6 +3312,27 @@ export function TaskDetailContent({
onTaskUpdated={onTaskUpdated} onTaskUpdated={onTaskUpdated}
/> />
</div> </div>
) : activeTab === "terminal" ? (
<div className="detail-section detail-section--terminal">
{cliSession && cliTabVisibility.kind !== "hidden" ? (
<Suspense fallback={<div className="detail-loading">{t("taskDetail.terminal.loading", "Loading terminal…")}</div>}>
<LazySessionTerminal
sessionId={cliSession.id}
projectId={projectId}
posture={cliPosture}
readOnly={
cliTabVisibility.kind === "replay" ||
(cliTabVisibility.kind === "live" && cliTabVisibility.readOnly)
}
mode={cliTabVisibility.mode}
showConfirmAdvance={
cliTabVisibility.kind === "live" && cliTabVisibility.showConfirmAdvance
}
onConfirmAdvance={handleConfirmAdvance}
/>
</Suspense>
) : null}
</div>
) : ( ) : (
<> <>
{/* Summary section - only for done tasks with summary */} {/* Summary section - only for done tasks with summary */}

View File

@@ -315,3 +315,108 @@ describe("SessionNotificationBanner", () => {
expect(screen.queryByText("Error Session")).not.toBeInTheDocument(); expect(screen.queryByText("Error Session")).not.toBeInTheDocument();
}); });
}); });
// ── CLI agent extensions (CLI Agent Executor, U11) ──────────────────────────
function buildCliSession(overrides: Partial<AiSessionSummary>): AiSessionSummary {
return {
id: overrides.id ?? "cli-1",
type: "cli-agent",
status: overrides.status ?? "waiting_on_input",
title: overrides.title ?? "Implement FN-1",
projectId: overrides.projectId ?? "proj-1",
lockedByTab: null,
updatedAt: overrides.updatedAt ?? new Date().toISOString(),
cliVariant: overrides.cliVariant,
cliSessionId: overrides.cliSessionId ?? "cli-1",
};
}
describe("SessionNotificationBanner — cli-agent (U11)", () => {
beforeEach(() => dismissedIds.clear());
it("renders the cli-agent type without crashing (union regression)", () => {
expect(() =>
render(
<SessionNotificationBanner
sessions={[buildCliSession({ status: "waiting_on_input" })]}
onResumeSession={vi.fn()}
onDismissSession={vi.fn()}
onDismissAll={vi.fn()}
/>,
),
).not.toThrow();
expect(screen.getByText("Implement FN-1")).toBeInTheDocument();
});
it("waiting_on_input surfaces a banner entry; busy clears it (F2)", () => {
const { rerender, container } = render(
<SessionNotificationBanner
sessions={[buildCliSession({ status: "waiting_on_input" })]}
onResumeSession={vi.fn()}
onDismissSession={vi.fn()}
onDismissAll={vi.fn()}
/>,
);
expect(container.querySelector(".session-notification-banner")).toBeTruthy();
rerender(
<SessionNotificationBanner
sessions={[buildCliSession({ status: "generating" as AiSessionSummary["status"] })]}
onResumeSession={vi.fn()}
onDismissSession={vi.fn()}
onDismissAll={vi.fn()}
/>,
);
expect(container.querySelector(".session-notification-banner")).toBeFalsy();
});
it("userExited needs-attention renders pinned copy + Advance/Retry/Cancel task", () => {
const onCliAction = vi.fn();
render(
<SessionNotificationBanner
sessions={[buildCliSession({ status: "needs_attention", cliVariant: "userExited" })]}
onResumeSession={vi.fn()}
onDismissSession={vi.fn()}
onDismissAll={vi.fn()}
onCliAction={onCliAction}
/>,
);
expect(screen.getByText("Agent exited before completing")).toBeInTheDocument();
// All three pinned actions render before any action removes the item.
expect(screen.getByText("Advance")).toBeInTheDocument();
expect(screen.getByText("Retry")).toBeInTheDocument();
expect(screen.getByText("Cancel task")).toBeInTheDocument();
fireEvent.click(screen.getByText("Advance"));
expect(onCliAction).toHaveBeenCalledWith(expect.objectContaining({ id: "cli-1" }), "advance");
});
it("authFailed renders Re-authenticate / Retry", () => {
render(
<SessionNotificationBanner
sessions={[buildCliSession({ status: "needs_attention", cliVariant: "authFailed" })]}
onResumeSession={vi.fn()}
onDismissSession={vi.fn()}
onDismissAll={vi.fn()}
onCliAction={vi.fn()}
/>,
);
expect(screen.getByText("CLI authentication failed")).toBeInTheDocument();
expect(screen.getByText("Re-authenticate")).toBeInTheDocument();
expect(screen.getByText("Retry")).toBeInTheDocument();
});
it("resume-exhausted renders Relaunch fresh / Cancel task", () => {
render(
<SessionNotificationBanner
sessions={[buildCliSession({ status: "needs_attention", cliVariant: "resume-exhausted" })]}
onResumeSession={vi.fn()}
onDismissSession={vi.fn()}
onDismissAll={vi.fn()}
onCliAction={vi.fn()}
/>,
);
expect(screen.getByText("Couldn't resume the session")).toBeInTheDocument();
expect(screen.getByText("Relaunch fresh")).toBeInTheDocument();
expect(screen.getByText("Cancel task")).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,173 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
// ── Mock xterm + addon dynamic imports (jsdom has no canvas/WebGL) ──────────
const mockTerm = {
loadAddon: vi.fn(),
open: vi.fn(),
onData: vi.fn(),
write: vi.fn((_data: string, cb?: () => void) => cb?.()),
dispose: vi.fn(),
unicode: { activeVersion: "6" },
cols: 80,
rows: 24,
};
vi.mock("@xterm/xterm", () => ({ Terminal: vi.fn(() => mockTerm) }));
vi.mock("@xterm/addon-fit", () => ({ FitAddon: vi.fn(() => ({ fit: vi.fn() })) }));
vi.mock("@xterm/addon-unicode11", () => ({ Unicode11Addon: vi.fn(() => ({})) }));
vi.mock("@xterm/addon-webgl", () => ({
WebglAddon: vi.fn(() => ({ onContextLoss: vi.fn(), dispose: vi.fn() })),
}));
const apiMock = vi.fn();
vi.mock("../../api", () => ({ api: (...args: unknown[]) => apiMock(...args) }));
vi.mock("../../auth", () => ({ appendTokenQuery: (u: string) => u }));
// ── Minimal WebSocket stub ──────────────────────────────────────────────────
class FakeWS {
static instances: FakeWS[] = [];
static OPEN = 1;
readyState = 1;
onopen: (() => void) | null = null;
onmessage: ((e: { data: string }) => void) | null = null;
onclose: (() => void) | null = null;
onerror: (() => void) | null = null;
sent: string[] = [];
constructor(public url: string) {
FakeWS.instances.push(this);
}
send(d: string) {
this.sent.push(d);
}
close() {
this.readyState = 3;
}
}
(globalThis as unknown as { WebSocket: typeof FakeWS }).WebSocket = FakeWS;
(globalThis as unknown as { ResizeObserver: unknown }).ResizeObserver = class {
observe() {}
disconnect() {}
};
import { SessionTerminal } from "../SessionTerminal";
beforeEach(() => {
FakeWS.instances = [];
mockTerm.onData.mockReset();
mockTerm.write.mockClear();
apiMock.mockReset();
apiMock.mockResolvedValue({ ticket: "tkt-1", expiresAt: "", readOnly: false });
});
afterEach(() => {
vi.clearAllMocks();
});
describe("SessionTerminal", () => {
it("mints an attach ticket and opens the WS attach channel", async () => {
render(<SessionTerminal sessionId="s1" />);
await waitFor(() =>
expect(apiMock).toHaveBeenCalledWith(
"/cli-sessions/s1/attach-ticket",
expect.objectContaining({ method: "POST" }),
),
);
await waitFor(() => expect(FakeWS.instances.length).toBe(1));
expect(FakeWS.instances[0].url).toContain("sessionId=s1");
expect(FakeWS.instances[0].url).toContain("ticket=tkt-1");
});
it("decodes base64 scrollback/data into term.write and ACKs", async () => {
render(<SessionTerminal sessionId="s1" />);
await waitFor(() => expect(FakeWS.instances.length).toBe(1));
const ws = FakeWS.instances[0];
const b64 = Buffer.from("hello", "utf8").toString("base64");
ws.onmessage?.({ data: JSON.stringify({ type: "scrollback", data: b64 }) });
await waitFor(() => expect(mockTerm.write).toHaveBeenCalledWith("hello", expect.any(Function)));
});
it("read-only: never registers term.onData (input suppressed)", async () => {
render(<SessionTerminal sessionId="s1" readOnly />);
await waitFor(() => expect(FakeWS.instances.length).toBe(1));
expect(mockTerm.onData).not.toHaveBeenCalled();
});
it("renders the Read-only badge when readOnly", async () => {
render(<SessionTerminal sessionId="s1" readOnly />);
expect(await screen.findByText("Read-only")).toBeTruthy();
});
it("renders the session-ended replay state", () => {
render(<SessionTerminal sessionId="s1" mode="ended" />);
expect(screen.getByText("Session ended")).toBeTruthy();
});
it("renders the session-idle replay state", () => {
render(<SessionTerminal sessionId="s1" mode="idle" />);
expect(screen.getByText("Session idle")).toBeTruthy();
});
it("posture chip: baseline shows adapter name without elevated styling", () => {
render(
<SessionTerminal
sessionId="s1"
posture={{ adapterName: "Claude Code", mode: "default", elevated: false }}
/>,
);
const chip = screen.getByRole("button", { name: /Claude Code/ });
expect(chip.getAttribute("data-elevated")).toBe("false");
expect(chip.className).not.toContain("cli-posture-chip--elevated");
});
it("posture chip: elevated shows warning styling, the flag, and a tooltip", () => {
render(
<SessionTerminal
sessionId="s1"
posture={{
adapterName: "Codex",
elevated: true,
elevatedFlags: ["--dangerously-skip-permissions"],
resolved: ["autonomy: full-auto"],
}}
/>,
);
const chip = screen.getByRole("button", { name: /Codex/ });
expect(chip.getAttribute("data-elevated")).toBe("true");
expect(chip.className).toContain("cli-posture-chip--elevated");
expect(screen.getByText("--dangerously-skip-permissions")).toBeTruthy();
fireEvent.click(chip);
expect(screen.getByRole("tooltip")).toBeTruthy();
expect(screen.getByText("autonomy: full-auto")).toBeTruthy();
});
it("confirm-advance strip: Advance posts advance and hides the strip", async () => {
const onConfirmAdvance = vi.fn().mockResolvedValue(undefined);
render(
<SessionTerminal
sessionId="s1"
mode="live"
showConfirmAdvance
onConfirmAdvance={onConfirmAdvance}
/>,
);
const advance = screen.getByText("Advance");
fireEvent.click(advance);
await waitFor(() => expect(onConfirmAdvance).toHaveBeenCalledWith("advance"));
await waitFor(() => expect(screen.queryByText("Advance")).toBeNull());
});
it("confirm-advance strip: Not yet re-arms (calls callback, hides strip)", async () => {
const onConfirmAdvance = vi.fn().mockResolvedValue(undefined);
render(
<SessionTerminal
sessionId="s1"
mode="live"
showConfirmAdvance
onConfirmAdvance={onConfirmAdvance}
/>,
);
fireEvent.click(screen.getByText("Not yet"));
await waitFor(() => expect(onConfirmAdvance).toHaveBeenCalledWith("not-yet"));
await waitFor(() => expect(screen.queryByText("Not yet")).toBeNull());
});
});

View File

@@ -0,0 +1,103 @@
import React from "react";
import { afterEach, describe, it, expect, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import { TaskCard, type CliCardState } from "../TaskCard";
import type { Task } from "@fusion/core";
vi.mock("lucide-react", () => {
const Stub = () => null;
return new Proxy({}, { get: () => Stub });
});
vi.mock("../ProviderIcon", () => ({
ProviderIcon: ({ provider }: { provider: string }) => <span data-testid={`provider-icon-${provider}`} />,
}));
vi.mock("../../hooks/useTaskDiffStats", () => ({
useTaskDiffStats: () => ({ stats: null, loading: false }),
}));
const badgeUpdatesMock = new Map<string, unknown>();
vi.mock("../../hooks/useBadgeWebSocket", () => ({
useBadgeWebSocket: () => ({
badgeUpdates: badgeUpdatesMock,
isConnected: true,
subscribeToBadge: vi.fn(),
unsubscribeFromBadge: vi.fn(),
}),
}));
vi.mock("../../hooks/useBatchBadgeFetch", () => ({
getFreshBatchData: vi.fn(() => null),
}));
vi.mock("../../api", () => ({
fetchTaskDetail: vi.fn(),
uploadAttachment: vi.fn(),
fetchMission: vi.fn(),
fetchAgent: vi.fn(),
fetchAgents: vi.fn(),
}));
vi.mock("../../hooks/useConfirm", () => ({
useConfirm: () => ({ confirm: vi.fn(), confirmWithChoice: vi.fn() }),
}));
function makeTask(overrides: Partial<Task> = {}): Task {
return {
id: "FN-001",
title: "Test task",
column: "in-progress",
status: undefined as never,
steps: [],
dependencies: [],
description: "",
...overrides,
} as Task;
}
const noop = () => {};
function renderCard(cliSessionState?: CliCardState) {
return render(
<TaskCard
task={makeTask()}
onOpenDetail={noop}
addToast={noop}
cliSessionState={cliSessionState}
/>,
);
}
afterEach(() => {
badgeUpdatesMock.clear();
vi.clearAllMocks();
});
describe("TaskCard CLI agent state badges (U11)", () => {
it("renders the waiting-on-input badge when the session is waitingOnInput", () => {
renderCard({ agentState: "waitingOnInput" });
const badge = screen.getByText("Waiting on input");
expect(badge).toBeTruthy();
expect(badge.getAttribute("data-cli-state")).toBe("waitingOnInput");
});
it("renders the needs-attention badge when the session needsAttention", () => {
renderCard({ agentState: "needsAttention" });
const badge = screen.getByText("Needs attention");
expect(badge).toBeTruthy();
expect(badge.getAttribute("data-cli-state")).toBe("needsAttention");
});
it("busy clears both CLI badges (F2 — answering re-arms to busy)", () => {
renderCard({ agentState: "busy" });
expect(screen.queryByText("Waiting on input")).toBeNull();
expect(screen.queryByText("Needs attention")).toBeNull();
});
it("no cli session → no CLI badges (card unchanged)", () => {
renderCard(undefined);
expect(screen.queryByText("Waiting on input")).toBeNull();
expect(screen.queryByText("Needs attention")).toBeNull();
});
});

View File

@@ -0,0 +1,66 @@
import { describe, it, expect } from "vitest";
import {
deriveCliTabVisibility,
type CliSessionSummaryRecord,
} from "../TaskDetailModal";
function session(
agentState: CliSessionSummaryRecord["agentState"],
): CliSessionSummaryRecord {
return {
id: "cli-1",
taskId: "FN-1",
projectId: "p1",
adapterId: "claude-local",
agentState,
terminationReason: null,
autonomyPosture: null,
};
}
describe("TaskDetailModal terminal-tab visibility matrix (U11)", () => {
it("no recorded session → tab hidden", () => {
expect(deriveCliTabVisibility(null)).toEqual({ kind: "hidden" });
});
it("starting / busy / waitingOnInput → live terminal", () => {
for (const s of ["starting", "ready", "busy", "waitingOnInput"] as const) {
const v = deriveCliTabVisibility(session(s));
expect(v.kind).toBe("live");
if (v.kind === "live") {
expect(v.mode).toBe("live");
expect(v.readOnly).toBe(false);
}
}
});
it("one-shot (planning/validator) live → read-only live terminal", () => {
const v = deriveCliTabVisibility(session("busy"), { oneShot: true });
expect(v.kind).toBe("live");
if (v.kind === "live") expect(v.readOnly).toBe(true);
});
it("generic-tier idle → confirm-advance strip offered on the live terminal", () => {
const v = deriveCliTabVisibility(session("busy"), { genericIdle: true });
expect(v.kind).toBe("live");
if (v.kind === "live") expect(v.showConfirmAdvance).toBe(true);
});
it("execute-done resumable → replay 'session idle'", () => {
expect(deriveCliTabVisibility(session("done"))).toEqual({
kind: "replay",
mode: "idle",
});
});
it("reaped (dead / needsAttention) → replay 'session ended'", () => {
expect(deriveCliTabVisibility(session("dead"))).toEqual({
kind: "replay",
mode: "ended",
});
expect(deriveCliTabVisibility(session("needsAttention"))).toEqual({
kind: "replay",
mode: "ended",
});
});
});

View File

@@ -112,6 +112,7 @@
"@types/multer": "^2.1.0", "@types/multer": "^2.1.0",
"@xterm/addon-fit": "^0.10.0", "@xterm/addon-fit": "^0.10.0",
"@xterm/addon-search": "^0.15.0", "@xterm/addon-search": "^0.15.0",
"@xterm/addon-unicode11": "^0.8.0",
"@xterm/addon-web-links": "^0.11.0", "@xterm/addon-web-links": "^0.11.0",
"@xterm/addon-webgl": "^0.18.0", "@xterm/addon-webgl": "^0.18.0",
"@xterm/xterm": "^5.5.0", "@xterm/xterm": "^5.5.0",

View File

@@ -1135,7 +1135,8 @@
"missionInterview": "Mission Interview", "missionInterview": "Mission Interview",
"planning": "Planning", "planning": "Planning",
"sliceInterview": "Slice Interview", "sliceInterview": "Slice Interview",
"subtask": "Subtask Breakdown" "subtask": "Subtask Breakdown",
"cliAgent": "CLI Agent"
} }
}, },
"board": { "board": {
@@ -4890,7 +4891,20 @@
"headerErrorSingular_other": "", "headerErrorSingular_other": "",
"regionLabel": "AI sessions needing input or failed", "regionLabel": "AI sessions needing input or failed",
"resume": "Resume", "resume": "Resume",
"retry": "Retry" "retry": "Retry",
"typeLabel": {
"cliAgent": "CLI Agent"
},
"cli": {
"advance": "Advance",
"retry": "Retry",
"cancelTask": "Cancel task",
"reauthenticate": "Re-authenticate",
"relaunch": "Relaunch fresh",
"userExited": "Agent exited before completing",
"authFailed": "CLI authentication failed",
"resumeExhausted": "Couldn't resume the session"
}
}, },
"settings": { "settings": {
"actions": { "actions": {
@@ -6074,7 +6088,8 @@
"review": "Review", "review": "Review",
"routing": "Routing", "routing": "Routing",
"stats": "Stats", "stats": "Stats",
"workflow": "Workflow" "workflow": "Workflow",
"terminal": "Terminal"
}, },
"timedDuration": "Timed duration", "timedDuration": "Timed duration",
"timestamps": { "timestamps": {
@@ -6097,7 +6112,10 @@
}, },
"workflowRuntime": "Workflow runtime", "workflowRuntime": "Workflow runtime",
"workflowTimedSteps": "Workflow timed steps", "workflowTimedSteps": "Workflow timed steps",
"yes": "Yes" "yes": "Yes",
"terminal": {
"loading": "Loading terminal…"
}
}, },
"taskDocuments": { "taskDocuments": {
"cancel": "Cancel", "cancel": "Cancel",
@@ -6471,7 +6489,11 @@
"usingDefault": "Using default", "usingDefault": "Using default",
"viewDependency": "Click to view {{depId}}", "viewDependency": "Click to view {{depId}}",
"workflow": "workflow", "workflow": "workflow",
"workflowCheck": "Workflow check" "workflowCheck": "Workflow check",
"cliWaitingOnInput": "Waiting on input",
"cliWaitingOnInputTitle": "The CLI agent is waiting for your input",
"cliNeedsAttention": "Needs attention",
"cliNeedsAttentionTitle": "The CLI agent needs your attention"
}, },
"terminal": { "terminal": {
"clear": "Clear", "clear": "Clear",
@@ -6882,5 +6904,16 @@
}, },
"notifyNote": "How you are alerted when the agent pauses waiting for input on this step." "notifyNote": "How you are alerted when the agent pauses waiting for input on this step."
} }
},
"cliTerminal": {
"replayIdle": "Session idle",
"replayEnded": "Session ended",
"readOnly": "Read-only",
"postureResolved": "Resolved posture",
"postureBaseline": "Baseline",
"adapterSettings": "Adapter settings",
"advancePrompt": "This session looks idle — advance to review?",
"advance": "Advance",
"notYet": "Not yet"
} }
} }

12
pnpm-lock.yaml generated
View File

@@ -269,6 +269,9 @@ importers:
'@xterm/addon-search': '@xterm/addon-search':
specifier: ^0.15.0 specifier: ^0.15.0
version: 0.15.0(@xterm/xterm@5.5.0) version: 0.15.0(@xterm/xterm@5.5.0)
'@xterm/addon-unicode11':
specifier: ^0.8.0
version: 0.8.0(@xterm/xterm@5.5.0)
'@xterm/addon-web-links': '@xterm/addon-web-links':
specifier: ^0.11.0 specifier: ^0.11.0
version: 0.11.0(@xterm/xterm@5.5.0) version: 0.11.0(@xterm/xterm@5.5.0)
@@ -3189,6 +3192,11 @@ packages:
peerDependencies: peerDependencies:
'@xterm/xterm': ^5.0.0 '@xterm/xterm': ^5.0.0
'@xterm/addon-unicode11@0.8.0':
resolution: {integrity: sha512-LxinXu8SC4OmVa6FhgwsVCBZbr8WoSGzBl2+vqe8WcQ6hb1r6Gj9P99qTNdPiFPh4Ceiu2pC8xukZ6+2nnh49Q==}
peerDependencies:
'@xterm/xterm': ^5.0.0
'@xterm/addon-web-links@0.11.0': '@xterm/addon-web-links@0.11.0':
resolution: {integrity: sha512-nIHQ38pQI+a5kXnRaTgwqSHnX7KE6+4SVoceompgHL26unAxdfP6IPqUTSYPQgSwM56hsElfoNrrW5V7BUED/Q==} resolution: {integrity: sha512-nIHQ38pQI+a5kXnRaTgwqSHnX7KE6+4SVoceompgHL26unAxdfP6IPqUTSYPQgSwM56hsElfoNrrW5V7BUED/Q==}
peerDependencies: peerDependencies:
@@ -9811,6 +9819,10 @@ snapshots:
dependencies: dependencies:
'@xterm/xterm': 5.5.0 '@xterm/xterm': 5.5.0
'@xterm/addon-unicode11@0.8.0(@xterm/xterm@5.5.0)':
dependencies:
'@xterm/xterm': 5.5.0
'@xterm/addon-web-links@0.11.0(@xterm/xterm@5.5.0)': '@xterm/addon-web-links@0.11.0(@xterm/xterm@5.5.0)':
dependencies: dependencies:
'@xterm/xterm': 5.5.0 '@xterm/xterm': 5.5.0