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) ─────────────────────────────────────────
|
// ── 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;
|
||||||
|
|||||||
@@ -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],
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
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).
|
/** 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}`}
|
||||||
|
|||||||
@@ -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 */}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -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",
|
"@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",
|
||||||
|
|||||||
@@ -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
12
pnpm-lock.yaml
generated
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user