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:
15
.changeset/cli-agent-terminal-ui.md
Normal file
15
.changeset/cli-agent-terminal-ui.md
Normal 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.
|
||||
@@ -8394,10 +8394,36 @@ export function reorderTodoItems(listId: string, itemIds: string[], projectId?:
|
||||
|
||||
// ── 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 {
|
||||
id: string;
|
||||
type: "planning" | "subtask" | "mission_interview" | "milestone_interview" | "slice_interview";
|
||||
status: "draft" | "generating" | "awaiting_input" | "complete" | "error";
|
||||
type:
|
||||
| "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;
|
||||
/** Server-derived preview of the in-progress initialPlan; only set for draft planning sessions. */
|
||||
preview?: string;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import "./BackgroundTasksIndicator.css";
|
||||
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 type { AiSessionSummary } from "../api";
|
||||
import { useAiSessionSync } from "../hooks/useAiSessionSync";
|
||||
@@ -21,6 +21,7 @@ const TYPE_ICONS = {
|
||||
mission_interview: Target,
|
||||
milestone_interview: Target,
|
||||
slice_interview: Target,
|
||||
"cli-agent": Terminal,
|
||||
} as const;
|
||||
|
||||
export function BackgroundTasksIndicator({
|
||||
@@ -49,6 +50,7 @@ export function BackgroundTasksIndicator({
|
||||
mission_interview: t("backgroundTasks.typeLabel.missionInterview", "Mission Interview"),
|
||||
milestone_interview: t("backgroundTasks.typeLabel.milestoneInterview", "Milestone Interview"),
|
||||
slice_interview: t("backgroundTasks.typeLabel.sliceInterview", "Slice Interview"),
|
||||
"cli-agent": t("backgroundTasks.typeLabel.cliAgent", "CLI Agent"),
|
||||
}),
|
||||
[t],
|
||||
);
|
||||
|
||||
@@ -1,22 +1,35 @@
|
||||
import "./SessionNotificationBanner.css";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { AlertCircle, Lightbulb, Layers, Target, X } from "lucide-react";
|
||||
import type { AiSessionSummary } from "../api";
|
||||
import { AlertCircle, Lightbulb, Layers, Target, Terminal, X } from "lucide-react";
|
||||
import type { AiSessionSummary, CliNeedsAttentionVariant } from "../api";
|
||||
|
||||
type CliActionId = "advance" | "retry" | "cancel" | "reauthenticate" | "relaunch";
|
||||
|
||||
interface SessionNotificationBannerProps {
|
||||
sessions: AiSessionSummary[];
|
||||
onResumeSession: (session: AiSessionSummary) => void;
|
||||
onDismissSession: (id: string) => 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 = {
|
||||
planning: Lightbulb,
|
||||
subtask: Layers,
|
||||
mission_interview: Target,
|
||||
milestone_interview: Target,
|
||||
slice_interview: Target,
|
||||
"cli-agent": Terminal,
|
||||
} as const;
|
||||
|
||||
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" },
|
||||
milestone_interview: { key: "sessionBanner.typeLabel.milestoneInterview", defaultVal: "Milestone 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";
|
||||
@@ -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
|
||||
// banner for that session. The banner re-shows the session only when the
|
||||
// session's `updatedAt` advances strictly past the recorded dismissal time
|
||||
@@ -78,6 +138,7 @@ export function SessionNotificationBanner({
|
||||
onResumeSession,
|
||||
onDismissSession,
|
||||
onDismissAll,
|
||||
onCliAction,
|
||||
}: SessionNotificationBannerProps) {
|
||||
const { t } = useTranslation("app");
|
||||
const [dismissRevision, setDismissRevision] = useState(0);
|
||||
@@ -98,7 +159,7 @@ export function SessionNotificationBanner({
|
||||
for (const [id, dismissedAtMs] of dismissedIds) {
|
||||
const session = sessionById.get(id);
|
||||
if (!session) continue;
|
||||
const stillNotifying = session.status === "awaiting_input" || session.status === "error";
|
||||
const stillNotifying = isNotifyingStatus(session.status);
|
||||
if (!stillNotifying) {
|
||||
dismissedIds.delete(id);
|
||||
pruned = true;
|
||||
@@ -117,7 +178,7 @@ export function SessionNotificationBanner({
|
||||
const sessionsNeedingInput = useMemo(
|
||||
() =>
|
||||
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);
|
||||
if (dismissedAtMs === undefined) return true;
|
||||
return parseUpdatedAtMs(session.updatedAt) > dismissedAtMs;
|
||||
@@ -129,8 +190,14 @@ export function SessionNotificationBanner({
|
||||
return null;
|
||||
}
|
||||
|
||||
const awaitingInputCount = sessionsNeedingInput.filter((s) => s.status === "awaiting_input").length;
|
||||
const errorCount = sessionsNeedingInput.filter((s) => s.status === "error").length;
|
||||
// CLI `waiting_on_input` rolls into the "needs input" count; `needs_attention`
|
||||
// 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 = "";
|
||||
if (awaitingInputCount > 0 && errorCount > 0) {
|
||||
@@ -207,6 +274,64 @@ export function SessionNotificationBanner({
|
||||
{sessionsNeedingInput.map((session) => {
|
||||
const Icon = TYPE_ICONS[session.type];
|
||||
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 (
|
||||
<article
|
||||
|
||||
165
packages/dashboard/app/components/SessionTerminal.css
Normal file
165
packages/dashboard/app/components/SessionTerminal.css
Normal 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);
|
||||
}
|
||||
429
packages/dashboard/app/components/SessionTerminal.tsx
Normal file
429
packages/dashboard/app/components/SessionTerminal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -407,6 +407,25 @@ interface TaskCardProps {
|
||||
/** Card-placed custom field definitions for this task's workflow (U13/KTD-14).
|
||||
* Empty/undefined → no field badges render (card byte-identical to today). */
|
||||
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 {
|
||||
@@ -540,6 +559,7 @@ function areTaskCardPropsEqual(previous: TaskCardProps, next: TaskCardProps): bo
|
||||
previous.taskStuckTimeoutMs === next.taskStuckTimeoutMs &&
|
||||
previous.prAuthAvailable === next.prAuthAvailable &&
|
||||
previous.autoMergeEnabled === next.autoMergeEnabled &&
|
||||
previous.cliSessionState?.agentState === next.cliSessionState?.agentState &&
|
||||
previous.cardFieldDefs === next.cardFieldDefs &&
|
||||
(previous.cardFieldDefs == null && next.cardFieldDefs == null
|
||||
? true
|
||||
@@ -658,6 +678,7 @@ function TaskCardComponent({
|
||||
prAuthAvailable,
|
||||
autoMergeEnabled = false,
|
||||
cardFieldDefs,
|
||||
cliSessionState,
|
||||
}: TaskCardProps) {
|
||||
const { t } = useTranslation("app");
|
||||
const columnLabel = useColumnLabel();
|
||||
@@ -924,6 +945,9 @@ function TaskCardComponent({
|
||||
const stalledReview = getStalledReviewSignal(task);
|
||||
const showStalledReview = Boolean(stalledReview && task.column === "in-review" && !isPaused);
|
||||
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
|
||||
? getInReviewStallCopy(task.inReviewStall, {
|
||||
mergeRetries: task.mergeRetries,
|
||||
@@ -1811,6 +1835,24 @@ function TaskCardComponent({
|
||||
{stallCopy.badgeLabel}{stallCopy.counter ? ` ${stallCopy.counter}` : ""}
|
||||
</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 && (
|
||||
<span
|
||||
className={`card-status-badge card-status-badge--in-review stale-paused-review stale-paused-review--${stalePausedReviewCopy.code}`}
|
||||
|
||||
@@ -21,7 +21,7 @@ import {
|
||||
resolveTaskPlanningModel,
|
||||
resolveTaskValidatorModel,
|
||||
} 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 { ApiRequestError } from "../api";
|
||||
import { TaskFieldsSection } from "./TaskFieldsSection";
|
||||
@@ -46,6 +46,7 @@ import { BranchGroupCard } from "./BranchGroupCard";
|
||||
import { PluginSlot } from "./PluginSlot";
|
||||
import { ProviderIcon } from "./ProviderIcon";
|
||||
import { subscribeSse } from "../sse-bus";
|
||||
import type { SessionTerminalMode, SessionTerminalPosture } from "./SessionTerminal";
|
||||
import { usePluginUiSlots } from "../hooks/usePluginUiSlots";
|
||||
import { appendTokenQuery } from "../auth";
|
||||
import { extractDependencyDeleteConflict, extractLineageDeleteConflict } from "../utils/taskDelete";
|
||||
@@ -281,7 +282,69 @@ function formatDurationCompact(ageMs: number): string {
|
||||
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 {
|
||||
task: Task | TaskDetail;
|
||||
@@ -494,6 +557,9 @@ export function TaskDetailContent({
|
||||
const columnLabel = useColumnLabel();
|
||||
const [activeTab, setActiveTab] = useState<TabId>(initialTab === "retries" ? "definition" : initialTab);
|
||||
|
||||
// ── CLI agent session (U11) ────────────────────────────────────────────────
|
||||
const [cliSession, setCliSession] = useState<CliSessionSummaryRecord | null>(null);
|
||||
|
||||
// ── Async detail loading ──────────────────────────────────────────────────
|
||||
// When opened optimistically with a Task (no prompt), fetch the full
|
||||
// TaskDetail in the background. The modal renders immediately with the
|
||||
@@ -757,6 +823,56 @@ export function TaskDetailContent({
|
||||
? pluginTabs.find((tab) => tab.tabId === activeTab) ?? 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
|
||||
useEffect(() => {
|
||||
mountedRef.current = true;
|
||||
@@ -886,6 +1002,76 @@ export function TaskDetailContent({
|
||||
});
|
||||
}, [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
|
||||
useEffect(() => {
|
||||
if (!showDepDropdown) {
|
||||
@@ -2827,6 +3013,14 @@ export function TaskDetailContent({
|
||||
>
|
||||
{t("taskDetail.tabs.routing", "Routing")}
|
||||
</button>
|
||||
{showCliTab && (
|
||||
<button
|
||||
className={`detail-tab${activeTab === "terminal" ? " detail-tab-active" : ""}`}
|
||||
onClick={() => setActiveTab("terminal")}
|
||||
>
|
||||
{t("taskDetail.tabs.terminal", "Terminal")}
|
||||
</button>
|
||||
)}
|
||||
{/* Plugin tabs */}
|
||||
{pluginTabs.map(({ entry, tabId }) => {
|
||||
return (
|
||||
@@ -3118,6 +3312,27 @@ export function TaskDetailContent({
|
||||
onTaskUpdated={onTaskUpdated}
|
||||
/>
|
||||
</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 */}
|
||||
|
||||
@@ -315,3 +315,108 @@ describe("SessionNotificationBanner", () => {
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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());
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -112,6 +112,7 @@
|
||||
"@types/multer": "^2.1.0",
|
||||
"@xterm/addon-fit": "^0.10.0",
|
||||
"@xterm/addon-search": "^0.15.0",
|
||||
"@xterm/addon-unicode11": "^0.8.0",
|
||||
"@xterm/addon-web-links": "^0.11.0",
|
||||
"@xterm/addon-webgl": "^0.18.0",
|
||||
"@xterm/xterm": "^5.5.0",
|
||||
|
||||
@@ -1135,7 +1135,8 @@
|
||||
"missionInterview": "Mission Interview",
|
||||
"planning": "Planning",
|
||||
"sliceInterview": "Slice Interview",
|
||||
"subtask": "Subtask Breakdown"
|
||||
"subtask": "Subtask Breakdown",
|
||||
"cliAgent": "CLI Agent"
|
||||
}
|
||||
},
|
||||
"board": {
|
||||
@@ -4890,7 +4891,20 @@
|
||||
"headerErrorSingular_other": "",
|
||||
"regionLabel": "AI sessions needing input or failed",
|
||||
"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": {
|
||||
"actions": {
|
||||
@@ -6074,7 +6088,8 @@
|
||||
"review": "Review",
|
||||
"routing": "Routing",
|
||||
"stats": "Stats",
|
||||
"workflow": "Workflow"
|
||||
"workflow": "Workflow",
|
||||
"terminal": "Terminal"
|
||||
},
|
||||
"timedDuration": "Timed duration",
|
||||
"timestamps": {
|
||||
@@ -6097,7 +6112,10 @@
|
||||
},
|
||||
"workflowRuntime": "Workflow runtime",
|
||||
"workflowTimedSteps": "Workflow timed steps",
|
||||
"yes": "Yes"
|
||||
"yes": "Yes",
|
||||
"terminal": {
|
||||
"loading": "Loading terminal…"
|
||||
}
|
||||
},
|
||||
"taskDocuments": {
|
||||
"cancel": "Cancel",
|
||||
@@ -6471,7 +6489,11 @@
|
||||
"usingDefault": "Using default",
|
||||
"viewDependency": "Click to view {{depId}}",
|
||||
"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": {
|
||||
"clear": "Clear",
|
||||
@@ -6882,5 +6904,16 @@
|
||||
},
|
||||
"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
12
pnpm-lock.yaml
generated
@@ -269,6 +269,9 @@ importers:
|
||||
'@xterm/addon-search':
|
||||
specifier: ^0.15.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':
|
||||
specifier: ^0.11.0
|
||||
version: 0.11.0(@xterm/xterm@5.5.0)
|
||||
@@ -3189,6 +3192,11 @@ packages:
|
||||
peerDependencies:
|
||||
'@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':
|
||||
resolution: {integrity: sha512-nIHQ38pQI+a5kXnRaTgwqSHnX7KE6+4SVoceompgHL26unAxdfP6IPqUTSYPQgSwM56hsElfoNrrW5V7BUED/Q==}
|
||||
peerDependencies:
|
||||
@@ -9811,6 +9819,10 @@ snapshots:
|
||||
dependencies:
|
||||
'@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)':
|
||||
dependencies:
|
||||
'@xterm/xterm': 5.5.0
|
||||
|
||||
Reference in New Issue
Block a user