singularity-forge/web/components/sf/chat-mode.tsx
Mikael Hugo 2d34d3a386 fix(web): resolve ESLint regressions from eslint-config-next upgrade
- Escape unescaped entities (react/no-unescaped-entities) in step-remote,
  step-welcome, projects-view, settings-panels
- Add targeted eslint-disable-next-line for react-hooks/set-state-in-effect
  on established async-fetch and prop-sync patterns in useEffect bodies:
  chat-mode, file-content-viewer, files-view, step-dev-root, projects-view,
  settings-panels, update-banner, visualizer-view, carousel, use-mobile
- Add targeted eslint-disable-next-line for react-hooks/purity on Date.now()
  display timestamps in streaming chat messages (chat-mode)
- Remove now-unused eslint-disable directives (projects-view, settings-panels)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-10 12:18:58 +02:00

2884 lines
81 KiB
TypeScript

"use client";
import {
BarChart3,
BookOpen,
Check,
ChevronDown,
ChevronRight,
Compass,
Eye,
EyeOff,
FileEdit,
FileOutput,
FilePlus,
Globe,
History,
Inbox,
LayoutGrid,
ListOrdered,
Loader2,
type LucideIcon,
MessageCircle,
MessagesSquare,
Milestone,
MoreHorizontal,
Pause,
PenLine,
Play,
SendHorizonal,
Settings,
SkipForward,
SlidersHorizontal,
Square,
Stethoscope,
Terminal,
Trash2,
Undo2,
X,
Zap,
} from "lucide-react";
import Image from "next/image";
import {
type ClipboardEvent,
type DragEvent,
type KeyboardEvent,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { Input } from "@/components/ui/input";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import {
generateImageId,
MAX_PENDING_IMAGES,
type PendingImage,
processImageFile,
} from "@/lib/image-utils";
import type { ChatMessage, TuiPrompt } from "@/lib/pty-chat-parser";
import {
type ActiveToolExecution,
buildPromptCommand,
type CompletedToolExecution,
type PendingUiRequest,
useSFWorkspaceActions,
useSFWorkspaceState,
} from "@/lib/sf-workspace-store";
import { useTerminalFontSize } from "@/lib/use-terminal-font-size";
import { cn } from "@/lib/utils";
import { deriveWorkflowAction } from "@/lib/workflow-actions";
/* ─── ActionPanel types ─── */
// ActionPanelConfig removed — all commands now route through the main bridge.
/* ─── SF Action Definitions ─── */
/**
* Defines every /sf subcommand available in the chat input bar.
* Top 3 are shown as standalone buttons; the rest live in the overflow menu.
* All commands dispatch through the main bridge session.
*/
interface SFActionDef {
label: string;
command: string;
icon: LucideIcon;
description: string;
category:
| "workflow"
| "visibility"
| "correction"
| "knowledge"
| "config"
| "maintenance";
/** When true, this command is disabled while auto-mode is active (injects competing LLM prompt) */
disabledDuringAuto?: boolean;
}
const SF_ACTIONS: SFActionDef[] = [
// ── Top 3 (standalone buttons) ──
{
label: "Discuss",
command: "/discuss",
icon: MessageCircle,
description: "Start guided milestone/slice discussion",
category: "workflow",
disabledDuringAuto: true,
},
{
label: "Next",
command: "/next",
icon: Play,
description: "Execute next task, then pause",
category: "workflow",
},
{
label: "Autonomous",
command: "/autonomous",
icon: Zap,
description: "Run all queued product units continuously",
category: "workflow",
},
// ── Overflow: Workflow ──
{
label: "Stop",
command: "/stop",
icon: Square,
description: "Stop autonomous mode gracefully",
category: "workflow",
},
{
label: "Pause",
command: "/pause",
icon: Pause,
description: "Pause autonomous mode (preserves state)",
category: "workflow",
},
// ── Overflow: Visibility ──
{
label: "Status",
command: "/status",
icon: BarChart3,
description: "Show progress dashboard",
category: "visibility",
},
{
label: "Visualize",
command: "/visualize",
icon: LayoutGrid,
description: "Interactive TUI (progress, deps, metrics, timeline)",
category: "visibility",
},
{
label: "Queue",
command: "/queue",
icon: ListOrdered,
description: "Show queued/dispatched units and execution order",
category: "visibility",
},
{
label: "History",
command: "/history",
icon: History,
description: "View execution history with cost/phase/model details",
category: "visibility",
},
// ── Overflow: Course correction ──
{
label: "Steer",
command: "/steer",
icon: Compass,
description: "Apply user override to active work",
category: "correction",
},
{
label: "Capture",
command: "/capture",
icon: PenLine,
description: "Quick-capture a thought to CAPTURES.md",
category: "correction",
},
{
label: "Triage",
command: "/triage",
icon: Inbox,
description: "Classify and route pending captures",
category: "correction",
disabledDuringAuto: true,
},
{
label: "Skip",
command: "/skip",
icon: SkipForward,
description: "Prevent a unit from auto-mode dispatch",
category: "correction",
},
{
label: "Undo",
command: "/undo",
icon: Undo2,
description: "Revert last completed unit",
category: "correction",
},
// ── Overflow: Knowledge ──
{
label: "Knowledge",
command: "/knowledge",
icon: BookOpen,
description: "Add rule, pattern, or lesson to KNOWLEDGE.md",
category: "knowledge",
},
// ── Overflow: Configuration ──
{
label: "Mode",
command: "/mode",
icon: SlidersHorizontal,
description: "Set workflow mode (solo/team)",
category: "config",
},
{
label: "Prefs",
command: "/prefs",
icon: Settings,
description: "Manage preferences (global/project)",
category: "config",
},
// ── Overflow: Maintenance ──
{
label: "Doctor",
command: "/doctor",
icon: Stethoscope,
description: "Diagnose and repair .sf/ state",
category: "maintenance",
},
{
label: "Export",
command: "/export",
icon: FileOutput,
description: "Export milestone/slice results (JSON or Markdown)",
category: "maintenance",
},
{
label: "Cleanup",
command: "/cleanup",
icon: Trash2,
description: "Remove merged branches or snapshots",
category: "maintenance",
},
{
label: "Remote",
command: "/remote",
icon: Globe,
description: "Control remote auto-mode (Slack/Discord)",
category: "maintenance",
},
];
/** Top 3 shown as standalone buttons next to chat input */
const TOP_ACTIONS = SF_ACTIONS.slice(0, 3);
/** Remaining actions in the overflow menu */
const OVERFLOW_ACTIONS = SF_ACTIONS.slice(3);
const CATEGORY_LABELS: Record<SFActionDef["category"], string> = {
workflow: "Workflow",
visibility: "Visibility",
correction: "Course Correction",
knowledge: "Knowledge",
config: "Configuration",
maintenance: "Maintenance",
};
function groupByCategory(actions: SFActionDef[]): Array<{
category: SFActionDef["category"];
label: string;
items: SFActionDef[];
}> {
const seen = new Map<SFActionDef["category"], SFActionDef[]>();
for (const a of actions) {
let group = seen.get(a.category);
if (!group) {
group = [];
seen.set(a.category, group);
}
group.push(a);
}
return Array.from(seen.entries()).map(([cat, items]) => ({
category: cat,
label: CATEGORY_LABELS[cat],
items,
}));
}
/**
* ChatMode — main view for the Chat tab.
*
* All /sf commands dispatch through the main bridge session.
* Commands that inject competing LLM prompts (discuss, triage)
* are disabled while auto-mode is active.
*
* Observability:
* - This component mounts only when activeView === "chat" (no hidden pre-init).
* - sessionStorage key "sf-active-view:<cwd>" equals "chat" when this view is active.
* - Header toolbar: data-testid="chat-mode-action-bar" confirms toolbar rendered.
* - Primary button: data-testid="chat-primary-action" reflects current workflowAction label.
* - Secondary buttons: data-testid="chat-secondary-action-{command}".
*/
export function ChatMode({ className }: { className?: string }) {
const state = useSFWorkspaceState();
const { sendCommand } = useSFWorkspaceActions();
const bridge = state.boot?.bridge ?? null;
const handleAction = useCallback(
(command: string) => {
void sendCommand(buildPromptCommand(command, bridge));
},
[sendCommand, bridge],
);
return (
<div
className={cn(
"flex h-full flex-col overflow-hidden bg-background",
className,
)}
>
{/* ── Header bar ── */}
<ChatModeHeader
onPrimaryAction={handleAction}
onSecondaryAction={handleAction}
/>
{/* ── Main chat pane ── */}
<ChatPane
sessionId="sf-main"
command="sf"
className="flex-1"
onOpenAction={(action) => handleAction(action.command)}
/>
</div>
);
}
/* ─── Header ─── */
interface ChatModeHeaderProps {
onPrimaryAction: (command: string) => void;
onSecondaryAction: (command: string) => void;
}
/**
* ChatModeHeader — action toolbar for Chat Mode.
*
* Single-row layout matching the Power User Mode header: title + badge left-aligned,
* workflow action buttons immediately to the right (no second row).
*
* Observability:
* - data-testid="chat-mode-action-bar" on the workflow button row
* - data-testid="chat-primary-action" on the primary button
* - data-testid="chat-secondary-action-{command}" on each secondary button
*/
function ChatModeHeader({
onPrimaryAction,
onSecondaryAction,
}: ChatModeHeaderProps) {
const state = useSFWorkspaceState();
const boot = state.boot;
const workspace = boot?.workspace ?? null;
const auto = boot?.auto ?? null;
const workflowAction = deriveWorkflowAction({
phase: workspace?.active.phase ?? "pre-planning",
autoActive: auto?.active ?? false,
autoPaused: auto?.paused ?? false,
onboardingLocked: boot?.onboarding.locked ?? false,
commandInFlight: state.commandInFlight,
bootStatus: state.bootStatus,
hasMilestones: (workspace?.milestones.length ?? 0) > 0,
projectDetectionKind: boot?.projectDetection?.kind ?? null,
});
const handlePrimary = () => {
if (!workflowAction.primary) return;
onPrimaryAction(workflowAction.primary.command);
};
// Derive a short SF state badge label
const stateBadge = (() => {
if (state.bootStatus !== "ready") return state.bootStatus;
const phase = workspace?.active.phase;
if (!phase) return "idle";
if (auto?.active && !auto?.paused) return "auto";
if (auto?.paused) return "paused";
return phase;
})();
return (
<div className="flex items-center justify-between border-b border-border bg-card px-4 py-2">
{/* Left: title + state badge */}
<div className="flex items-center gap-2">
<MessagesSquare className="h-4 w-4 text-muted-foreground" />
<span className="font-medium">Chat Mode</span>
<span className="rounded-full border border-border bg-muted px-2 py-0.5 text-[10px] font-medium text-muted-foreground uppercase tracking-wide">
{stateBadge}
</span>
</div>
{/* Right: workflow action buttons */}
<div
className="flex items-center gap-2"
data-testid="chat-mode-action-bar"
>
{workflowAction.primary && (
<button
type="button"
data-testid="chat-primary-action"
onClick={handlePrimary}
disabled={workflowAction.disabled}
className={cn(
"inline-flex items-center gap-1.5 rounded-md px-3 py-1 text-xs font-medium transition-colors",
workflowAction.primary.variant === "destructive"
? "bg-destructive text-destructive-foreground hover:bg-destructive/90"
: "bg-primary text-primary-foreground hover:bg-primary/90",
workflowAction.disabled && "cursor-not-allowed opacity-50",
)}
title={workflowAction.disabledReason}
>
{state.commandInFlight ? (
<Loader2 className="h-3 w-3 animate-spin" />
) : workflowAction.isNewMilestone ? (
<Milestone className="h-3 w-3" />
) : (
<Play className="h-3 w-3" />
)}
{workflowAction.primary.label}
</button>
)}
{workflowAction.secondaries.map((action) => (
<button
type="button"
key={action.command}
data-testid={`chat-secondary-action-${action.command}`}
onClick={() => onSecondaryAction(action.command)}
disabled={workflowAction.disabled}
className={cn(
"inline-flex items-center gap-1 rounded-md border border-border bg-background px-2 py-1 text-xs font-medium transition-colors hover:bg-accent",
workflowAction.disabled && "cursor-not-allowed opacity-50",
)}
title={workflowAction.disabledReason}
>
{action.label}
</button>
))}
{state.commandInFlight && (
<span className="flex items-center gap-1 text-xs text-muted-foreground">
<Loader2 className="h-3 w-3 animate-spin" />
</span>
)}
</div>
</div>
);
}
type ShikiHighlighter = {
codeToTokens: (
code: string,
options: { lang: string; theme: string },
) => {
tokens: Array<
Array<{
color?: string;
content: string;
fontStyle?: number;
offset?: number;
}>
>;
bg?: string;
fg?: string;
};
};
let chatHighlighterPromise: Promise<ShikiHighlighter> | null = null;
function getChatHighlighter(): Promise<ShikiHighlighter> {
if (!chatHighlighterPromise) {
chatHighlighterPromise = import("shiki")
.then((mod) =>
mod.createHighlighter({
themes: ["github-dark-default", "github-light-default"],
langs: [
"typescript",
"tsx",
"javascript",
"jsx",
"json",
"jsonc",
"markdown",
"mdx",
"css",
"scss",
"less",
"html",
"xml",
"yaml",
"toml",
"bash",
"python",
"ruby",
"rust",
"go",
"java",
"kotlin",
"swift",
"c",
"cpp",
"csharp",
"php",
"sql",
"graphql",
"dockerfile",
"makefile",
"lua",
"diff",
"ini",
"dotenv",
],
}) as Promise<ShikiHighlighter>,
)
.catch((err) => {
chatHighlighterPromise = null;
throw err;
}) as Promise<ShikiHighlighter>;
}
return chatHighlighterPromise!;
}
function HighlightedCode({
code,
highlighter,
lang,
theme,
className,
}: {
code: string;
highlighter: ShikiHighlighter;
lang: string;
theme: string;
className?: string;
}) {
const highlighted = highlighter.codeToTokens(code, { lang, theme });
return (
<pre
className={className}
style={{
backgroundColor: highlighted.bg,
color: highlighted.fg,
}}
>
<code>
{highlighted.tokens.map((line, lineNumber) => (
<span
key={`line-${lineNumber}-${line.map((token) => token.content).join("")}`}
>
{line.map((token, tokenIndex) => (
<span
key={`${token.content}-${token.offset ?? tokenIndex}`}
style={{
color: token.color,
fontStyle:
token.fontStyle === 1 || token.fontStyle === 3
? "italic"
: undefined,
fontWeight:
token.fontStyle === 2 || token.fontStyle === 3
? "bold"
: undefined,
}}
>
{token.content}
</span>
))}
{lineNumber < highlighted.tokens.length - 1 ? "\n" : null}
</span>
))}
</code>
</pre>
);
}
/* ─── Markdown renderer for assistant bubbles ─── */
/**
* Renders markdown content using react-markdown + remark-gfm + shiki code blocks.
* Dynamic imports keep the main bundle lean.
* Falls back to plain text if modules fail to load.
*
* Observability:
* - console.debug("[ChatBubble] markdown modules loaded") fires once on first render
*/
function MarkdownContent({ content }: { content: string }) {
const [rendered, setRendered] = useState<React.ReactNode | null>(null);
const [ready, setReady] = useState(false);
const isDark = useIsDark();
useEffect(() => {
let cancelled = false;
Promise.all([
import("react-markdown"),
import("remark-gfm"),
getChatHighlighter(),
])
.then(([ReactMarkdownMod, remarkGfmMod, highlighter]) => {
if (cancelled) return;
console.debug("[ChatBubble] markdown modules loaded");
const ReactMarkdown = ReactMarkdownMod.default;
const remarkGfm = remarkGfmMod.default;
const shikiTheme = isDark
? "github-dark-default"
: "github-light-default";
const buildComponents = (h: typeof highlighter) => ({
code({
className,
children,
...props
}: React.HTMLAttributes<HTMLElement> & {
children?: React.ReactNode;
}) {
const match = /language-(\w+)/.exec(className || "");
const codeStr = String(children).replace(/\n$/, "");
if (match) {
try {
return (
<HighlightedCode
code={codeStr}
highlighter={h}
lang={match[1]}
theme={shikiTheme}
className="chat-code-block my-3 rounded-xl overflow-x-auto text-sm shadow-sm border border-border/50"
/>
);
} catch {
/* unsupported language — fall through */
}
}
const isInline = !className && !String(children).includes("\n");
if (isInline) {
return (
<code
className="rounded-md bg-muted px-1.5 py-0.5 text-[0.85em] font-mono text-foreground"
{...props}
>
{children}
</code>
);
}
return (
<pre
className={cn(
"my-3 overflow-x-auto rounded-xl p-4 text-sm border border-border/50",
isDark ? "bg-[#0d1117]" : "bg-[#f6f8fa]",
)}
>
<code className="font-mono">{children}</code>
</pre>
);
},
pre({ children }: { children?: React.ReactNode }) {
return <>{children}</>;
},
table({ children }: { children?: React.ReactNode }) {
return (
<div className="my-4 overflow-x-auto rounded-lg border border-border">
<table className="min-w-full border-collapse text-sm">
{children}
</table>
</div>
);
},
th({ children }: { children?: React.ReactNode }) {
return (
<th className="border-b border-border bg-muted/50 px-3 py-2 text-left text-xs font-semibold text-muted-foreground uppercase tracking-wide">
{children}
</th>
);
},
td({ children }: { children?: React.ReactNode }) {
return (
<td className="border-b border-border/50 px-3 py-2 text-sm last:border-0">
{children}
</td>
);
},
a({ href, children }: { href?: string; children?: React.ReactNode }) {
return (
<a
href={href}
className="text-info underline underline-offset-2 hover:text-info transition-colors"
target="_blank"
rel="noopener noreferrer"
>
{children}
</a>
);
},
h1({ children }: { children?: React.ReactNode }) {
return (
<h1 className="mt-4 mb-2 text-base font-semibold text-foreground first:mt-0">
{children}
</h1>
);
},
h2({ children }: { children?: React.ReactNode }) {
return (
<h2 className="mt-3 mb-1.5 text-sm font-semibold text-foreground first:mt-0">
{children}
</h2>
);
},
h3({ children }: { children?: React.ReactNode }) {
return (
<h3 className="mt-2 mb-1 text-sm font-medium text-foreground first:mt-0">
{children}
</h3>
);
},
ul({ children }: { children?: React.ReactNode }) {
return (
<ul className="my-2 ml-4 list-disc space-y-0.5 text-sm [&>li]:text-foreground">
{children}
</ul>
);
},
ol({ children }: { children?: React.ReactNode }) {
return (
<ol className="my-2 ml-4 list-decimal space-y-0.5 text-sm [&>li]:text-foreground">
{children}
</ol>
);
},
blockquote({ children }: { children?: React.ReactNode }) {
return (
<blockquote className="my-3 border-l-2 border-primary/40 pl-3 text-sm text-muted-foreground italic">
{children}
</blockquote>
);
},
hr() {
return <hr className="my-4 border-border/50" />;
},
p({ children }: { children?: React.ReactNode }) {
return (
<p className="mb-2 text-sm leading-relaxed last:mb-0 text-foreground">
{children}
</p>
);
},
img({ alt, src }: { alt?: string; src?: string }) {
return (
<span className="my-2 block rounded-lg border border-border bg-muted/50 px-3 py-2 text-xs text-muted-foreground italic">
🖼 {alt || src || "image"}
</span>
);
},
});
setRendered(
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={
buildComponents(
highlighter,
) as import("react-markdown").Components
}
>
{content}
</ReactMarkdown>,
);
setReady(true);
})
.catch(() => {
if (!cancelled) setReady(true);
});
return () => {
cancelled = true;
};
}, [content, isDark]); // re-render when content changes (streaming) or theme toggles
if (!ready) {
// Plain text fallback while modules load
return (
<span className="whitespace-pre-wrap text-sm leading-relaxed text-foreground">
{content}
</span>
);
}
if (!rendered) {
return (
<span className="whitespace-pre-wrap text-sm leading-relaxed text-foreground">
{content}
</span>
);
}
return <div className="chat-markdown min-w-0">{rendered}</div>;
}
/* ─── TuiSelectPrompt ─── */
/**
* Renders a SF arrow-key select prompt as a native clickable list.
*
* Clicking an option calculates the arrow-key delta from the current
* PTY-tracked selection, sends that many \x1b[A/\x1b[B + \r to the PTY,
* and transitions to a static post-submission state.
*
* Observability:
* - Logs "[TuiSelectPrompt] mounted kind=select label=%s" on mount
* - Logs "[TuiSelectPrompt] submit delta=%d keystrokes=%j" on submit
* - data-testid="tui-select-prompt" on container
* - data-testid="tui-select-option-{i}" on each option button
* - data-testid="tui-prompt-submitted" on post-submission element
*/
function TuiSelectPrompt({
prompt,
onSubmit,
}: {
prompt: TuiPrompt;
onSubmit: (data: string) => void;
}) {
const [localIndex, setLocalIndex] = useState(prompt.selectedIndex ?? 0);
const [submitted, setSubmitted] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
console.log("[TuiSelectPrompt] mounted kind=select label=%s", prompt.label);
// Auto-focus the container so keyboard events are captured immediately
containerRef.current?.focus();
}, [prompt.label]);
const submitIndex = useCallback(
(clickedIndex: number) => {
const delta = clickedIndex - localIndex;
let keystrokes = "";
if (delta > 0) {
keystrokes = "\x1b[B".repeat(delta);
} else if (delta < 0) {
keystrokes = "\x1b[A".repeat(Math.abs(delta));
}
keystrokes += "\r";
console.log(
"[TuiSelectPrompt] submit delta=%d keystrokes=%j",
delta,
keystrokes,
);
setLocalIndex(clickedIndex);
setSubmitted(true);
onSubmit(keystrokes);
},
[localIndex, onSubmit],
);
const handleKeyDown = useCallback(
(e: KeyboardEvent<HTMLDivElement>) => {
if (submitted) return;
if (e.key === "ArrowUp") {
e.preventDefault();
setLocalIndex((i) => Math.max(0, i - 1));
} else if (e.key === "ArrowDown") {
e.preventDefault();
setLocalIndex((i) => Math.min(prompt.options.length - 1, i + 1));
} else if (e.key === "Enter") {
e.preventDefault();
submitIndex(localIndex);
}
},
[submitted, localIndex, prompt.options.length, submitIndex],
);
if (submitted) {
const selectedLabel = prompt.options[localIndex] ?? "";
return (
<div
data-testid="tui-prompt-submitted"
className="mt-2 flex items-center gap-1.5 rounded-lg bg-primary/10 px-3 py-2 text-sm text-primary"
>
<Check className="h-3.5 w-3.5 flex-shrink-0" />
<span className="font-medium">{selectedLabel}</span>
</div>
);
}
return (
<div
ref={containerRef}
data-testid="tui-select-prompt"
tabIndex={0}
onKeyDown={handleKeyDown}
className="mt-2 rounded-xl border border-border bg-background p-1.5 shadow-sm outline-none focus-visible:ring-1 focus-visible:ring-border"
aria-label={`Select: ${prompt.label}`}
role="listbox"
aria-activedescendant={`tui-select-option-${localIndex}`}
>
{prompt.label && (
<p className="mb-1.5 px-2 text-[11px] font-medium text-muted-foreground uppercase tracking-wide">
{prompt.label}
</p>
)}
{prompt.options.map((option, i) => {
const isSelected = i === localIndex;
const description = prompt.descriptions?.[i];
return (
<button
type="button"
key={i}
id={`tui-select-option-${i}`}
data-testid={`tui-select-option-${i}`}
role="option"
aria-selected={isSelected}
onClick={() => submitIndex(i)}
className={cn(
"flex w-full items-start gap-2 rounded-lg px-3 py-1.5 text-left text-sm transition-colors",
isSelected
? "bg-primary/15 text-primary font-medium"
: "text-foreground hover:bg-muted",
)}
>
<span className="mt-0.5 flex h-4 w-4 flex-shrink-0 items-center justify-center">
{isSelected ? (
<Check className="h-3 w-3 text-primary" />
) : (
<span className="h-1.5 w-1.5 rounded-full bg-muted-foreground/30" />
)}
</span>
<span className="min-w-0">
<span className="block">{option}</span>
{description && (
<span className="mt-0.5 block text-xs font-normal text-muted-foreground">
{description}
</span>
)}
</span>
</button>
);
})}
</div>
);
}
/* ─── TuiTextPrompt ─── */
/**
* Renders a SF text prompt as a native labeled input field.
*
* Submitting sends the typed value + "\r" to the PTY (carriage return = Enter).
* After submission shows a static "✓ Submitted" confirmation (value not echoed).
*
* Observability:
* - Logs "[TuiTextPrompt] mounted kind=text label=%s" on mount
* - Logs "[TuiTextPrompt] submitted label=%s" on submit
* - data-testid="tui-text-prompt" on container
* - data-testid="tui-prompt-submitted" on post-submission element
*/
function TuiTextPrompt({
prompt,
onSubmit,
}: {
prompt: TuiPrompt;
onSubmit: (data: string) => void;
}) {
const [value, setValue] = useState("");
const [submitted, setSubmitted] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
console.log("[TuiTextPrompt] mounted kind=text label=%s", prompt.label);
inputRef.current?.focus();
}, [prompt.label]);
const handleSubmit = useCallback(() => {
if (submitted) return;
console.log("[TuiTextPrompt] submitted label=%s", prompt.label);
setSubmitted(true);
onSubmit(value + "\r");
}, [submitted, value, prompt.label, onSubmit]);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Enter") {
e.preventDefault();
handleSubmit();
}
},
[handleSubmit],
);
if (submitted) {
return (
<div
data-testid="tui-prompt-submitted"
className="mt-2 flex items-center gap-1.5 rounded-lg bg-primary/10 px-3 py-2 text-sm text-primary"
>
<Check className="h-3.5 w-3.5 flex-shrink-0" />
<span className="font-medium"> Submitted</span>
</div>
);
}
return (
<div
data-testid="tui-text-prompt"
className="mt-2 rounded-xl border border-border bg-background p-3 shadow-sm"
>
{prompt.label && (
<p className="mb-2 text-[11px] font-medium text-muted-foreground uppercase tracking-wide">
{prompt.label}
</p>
)}
<div className="flex gap-2">
<Input
ref={inputRef}
value={value}
onChange={(e) => setValue(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Type your answer…"
className="flex-1 h-8 text-sm"
aria-label={prompt.label || "Text input"}
/>
<button
type="button"
onClick={handleSubmit}
disabled={!value.trim()}
className={cn(
"flex h-8 items-center justify-center rounded-lg px-3 text-xs font-medium transition-all",
value.trim()
? "bg-primary text-primary-foreground hover:bg-primary/90 active:scale-95 shadow-sm"
: "bg-muted text-muted-foreground cursor-not-allowed",
)}
>
Submit
</button>
</div>
</div>
);
}
/* ─── TuiPasswordPrompt ─── */
/**
* Renders a SF password/API-key prompt as a native masked input field.
*
* Submitting sends the typed value + "\r" to the PTY.
* The entered value is NEVER shown in the DOM, logs, or post-submission text.
* After submission shows "{label} — entered ✓" with no value echo.
*
* Observability:
* - Logs "[TuiPasswordPrompt] mounted kind=password label=%s" on mount
* - Logs "[TuiPasswordPrompt] submitted label=%s" on submit (value not logged)
* - data-testid="tui-password-prompt" on container
* - data-testid="tui-prompt-submitted" on post-submission element
*/
function TuiPasswordPrompt({
prompt,
onSubmit,
}: {
prompt: TuiPrompt;
onSubmit: (data: string) => void;
}) {
const [value, setValue] = useState("");
const [submitted, setSubmitted] = useState(false);
const [showPassword, setShowPassword] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
console.log(
"[TuiPasswordPrompt] mounted kind=password label=%s",
prompt.label,
);
inputRef.current?.focus();
}, [prompt.label]);
const handleSubmit = useCallback(() => {
if (submitted) return;
// Value intentionally not logged — redaction constraint
console.log("[TuiPasswordPrompt] submitted label=%s", prompt.label);
setSubmitted(true);
onSubmit(value + "\r");
}, [submitted, value, prompt.label, onSubmit]);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Enter") {
e.preventDefault();
handleSubmit();
}
},
[handleSubmit],
);
if (submitted) {
const displayLabel = prompt.label || "Value";
return (
<div
data-testid="tui-prompt-submitted"
className="mt-2 flex items-center gap-1.5 rounded-lg bg-primary/10 px-3 py-2 text-sm text-primary"
>
<Check className="h-3.5 w-3.5 flex-shrink-0" />
<span className="font-medium">{displayLabel} entered </span>
</div>
);
}
return (
<div
data-testid="tui-password-prompt"
className="mt-2 rounded-xl border border-border bg-background p-3 shadow-sm"
>
{prompt.label && (
<p className="mb-2 text-[11px] font-medium text-muted-foreground uppercase tracking-wide">
{prompt.label}
</p>
)}
<div className="flex gap-2">
<div className="relative flex-1">
<Input
ref={inputRef}
type={showPassword ? "text" : "password"}
value={value}
onChange={(e) => setValue(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Enter value…"
className="h-8 pr-9 text-sm"
aria-label={prompt.label || "Password input"}
autoComplete="off"
/>
<button
type="button"
onClick={() => setShowPassword((s) => !s)}
tabIndex={-1}
aria-label={showPassword ? "Hide input" : "Show input"}
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-muted-foreground transition-colors"
>
{showPassword ? (
<EyeOff className="h-3.5 w-3.5" />
) : (
<Eye className="h-3.5 w-3.5" />
)}
</button>
</div>
<button
type="button"
onClick={handleSubmit}
disabled={!value}
className={cn(
"flex h-8 items-center justify-center rounded-lg px-3 text-xs font-medium transition-all",
value
? "bg-primary text-primary-foreground hover:bg-primary/90 active:scale-95 shadow-sm"
: "bg-muted text-muted-foreground cursor-not-allowed",
)}
>
Submit
</button>
</div>
<p className="mt-1.5 text-[10px] text-muted-foreground">
Value is transmitted securely and not stored in chat history.
</p>
</div>
);
}
/* ─── StreamingCursor ─── */
function StreamingCursor() {
return (
<span
aria-hidden="true"
className="ml-0.5 inline-block h-3.5 w-0.5 translate-y-0.5 rounded-full bg-current opacity-70"
style={{ animation: "chat-cursor 1s ease-in-out infinite" }}
/>
);
}
function createLocalMessageId(): string {
if (
typeof crypto !== "undefined" &&
typeof crypto.randomUUID === "function"
) {
return crypto.randomUUID();
}
return `msg-${Date.now()}-${Math.random().toString(16).slice(2)}`;
}
/* ─── Theme detection hook ─── */
function useIsDark(): boolean {
const [isDark, setIsDark] = useState(
() =>
typeof document !== "undefined" &&
document.documentElement.classList.contains("dark"),
);
useEffect(() => {
if (typeof document === "undefined") return;
const el = document.documentElement;
const observer = new MutationObserver(() => {
setIsDark(el.classList.contains("dark"));
});
observer.observe(el, { attributes: true, attributeFilter: ["class"] });
return () => observer.disconnect();
}, []);
return isDark;
}
/* ─── PlatformLogoIcon ─── */
/**
* Renders the platform logo icon, dynamically switching between
* light and dark variants based on the current theme.
*/
function PlatformLogoIcon({ className }: { className?: string }) {
const isDark = useIsDark();
return (
<Image
src={isDark ? "/logo-icon-white.svg" : "/logo-icon-black.svg"}
alt=""
width={24}
height={32}
unoptimized
className={cn("h-4 w-auto", className)}
/>
);
}
/* ─── InlineThinking ─── */
/**
* Thinking indicator rendered inline inside an assistant bubble.
* Shows a collapsible preview of the LLM's reasoning with a visible,
* well-styled block that shows more context lines.
*/
function InlineThinking({
content,
isStreaming,
}: {
content: string;
isStreaming: boolean;
}) {
const [expanded, setExpanded] = useState(false);
const scrollRef = useRef<HTMLDivElement>(null);
const lines = content.split("\n").filter((l) => l.trim());
const previewLines = lines.slice(-5);
const hasMore = lines.length > 5;
// Auto-scroll the expanded view to the bottom when streaming
useEffect(() => {
if (expanded && isStreaming && scrollRef.current) {
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
}
}, [expanded, isStreaming]);
return (
<div className="mb-3">
<button
type="button"
onClick={() => setExpanded((e) => !e)}
className={cn(
"group w-full rounded-xl border px-3.5 py-2.5 text-left transition-all",
"border-border/50 bg-muted/50 hover:bg-muted/50",
)}
>
{/* Header row */}
<div className="flex items-center gap-2">
{isStreaming ? (
<span className="relative flex h-2 w-2 flex-shrink-0">
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-muted-foreground/30" />
<span className="relative inline-flex h-2 w-2 rounded-full bg-muted-foreground/50" />
</span>
) : (
<span className="flex h-4 w-4 flex-shrink-0 items-center justify-center rounded bg-muted-foreground/10">
<span className="text-[9px] text-muted-foreground">💭</span>
</span>
)}
<span className="text-[11px] font-medium uppercase tracking-wider text-muted-foreground">
{isStreaming ? "Thinking…" : "Thought process"}
</span>
{hasMore && !expanded && (
<span className="ml-1 rounded-full bg-muted px-1.5 py-0.5 text-[10px] font-medium text-muted-foreground">
{lines.length} lines
</span>
)}
<span className="ml-auto flex-shrink-0">
{expanded ? (
<ChevronDown className="h-3 w-3 text-muted-foreground transition-transform" />
) : (
<ChevronRight className="h-3 w-3 text-muted-foreground transition-transform group-hover:text-muted-foreground" />
)}
</span>
</div>
{/* Collapsed preview — show 5 lines */}
{!expanded && (
<div className="mt-2 space-y-0.5 border-l-2 border-muted-foreground/10 pl-3">
{previewLines.map((line, i) => (
<p
key={i}
className="text-[12px] leading-relaxed text-muted-foreground line-clamp-1"
>
{line}
</p>
))}
{isStreaming && <StreamingCursor />}
</div>
)}
{/* Expanded view — scrollable with more space */}
{expanded && (
<div
ref={scrollRef}
className="mt-2 max-h-[400px] overflow-y-auto overscroll-contain rounded-lg border border-border/50 bg-background/50 p-3 text-[12px] leading-[1.7] text-muted-foreground whitespace-pre-wrap scrollbar-thin scrollbar-thumb-border scrollbar-track-transparent"
>
{content}
{isStreaming && <StreamingCursor />}
</div>
)}
</button>
</div>
);
}
/* ─── ChatBubble ─── */
/**
* Renders a single ChatMessage as a styled bubble.
*
* - assistant: left-aligned bubble with full markdown rendering + syntax-highlighted code blocks
* - user: right-aligned outgoing bubble with plain text
* - system: small centered muted line (no bubble chrome)
* - incomplete messages show an animated streaming cursor
* - when message.prompt.kind === 'select', TuiSelectPrompt renders below content
*/
function ChatBubble({
message,
onSubmitPrompt,
isThinking,
}: {
message: ChatMessage;
onSubmitPrompt?: (data: string) => void;
isThinking?: boolean;
}) {
if (message.role === "system") {
return (
<div className="flex items-center justify-center py-1">
<span className="text-[11px] text-muted-foreground italic px-3">
{message.content}
</span>
</div>
);
}
if (message.role === "user") {
return (
<div className="flex justify-end">
<div className="max-w-[72%] rounded-2xl rounded-br-md bg-primary px-4 py-2.5 text-sm text-primary-foreground shadow-sm">
{message.images && message.images.length > 0 && (
<div className="flex gap-1.5 mb-2 flex-wrap">
{message.images.map((img, idx) => (
<Image
key={idx}
src={`data:${img.mimeType};base64,${img.data}`}
alt={`Attached image ${idx + 1}`}
width={32}
height={32}
unoptimized
className="h-8 w-8 rounded object-cover border border-primary-foreground/20"
/>
))}
</div>
)}
<span className="whitespace-pre-wrap leading-relaxed">
{message.content}
</span>
{!message.complete && <StreamingCursor />}
</div>
</div>
);
}
// assistant
const hasSelectPrompt =
message.prompt?.kind === "select" &&
!message.complete &&
onSubmitPrompt != null;
const hasTextPrompt =
message.prompt?.kind === "text" &&
!message.complete &&
onSubmitPrompt != null;
const hasPasswordPrompt =
message.prompt?.kind === "password" &&
!message.complete &&
onSubmitPrompt != null;
const hasAnyPrompt = hasSelectPrompt || hasTextPrompt || hasPasswordPrompt;
return (
<div className="flex justify-start gap-3">
<div className="mt-1 flex-shrink-0 flex h-7 w-7 items-center justify-center rounded-full bg-card border border-border">
<PlatformLogoIcon className="h-3.5 w-auto" />
</div>
<div className="max-w-[82%] min-w-0 rounded-2xl rounded-tl-md border border-border bg-card px-4 py-3 shadow-sm">
{/* Minimal waiting indicator — shown when streaming starts but no content yet */}
{isThinking && !message.content && (
<div className="flex items-center gap-2 py-1">
<span className="relative flex h-2 w-2">
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-muted-foreground/30" />
<span className="relative inline-flex h-2 w-2 rounded-full bg-muted-foreground/50" />
</span>
<span className="text-[10px] font-medium text-muted-foreground uppercase tracking-wider">
Thinking
</span>
</div>
)}
{message.content && <MarkdownContent content={message.content} />}
{!message.complete && !hasAnyPrompt && <StreamingCursor />}
{hasSelectPrompt && (
<TuiSelectPrompt
prompt={message.prompt!}
onSubmit={onSubmitPrompt!}
/>
)}
{hasTextPrompt && (
<TuiTextPrompt prompt={message.prompt!} onSubmit={onSubmitPrompt!} />
)}
{hasPasswordPrompt && (
<TuiPasswordPrompt
prompt={message.prompt!}
onSubmit={onSubmitPrompt!}
/>
)}
</div>
</div>
);
}
/* ─── ChatInputBar ─── */
/**
* Text input bar at the bottom of ChatPane.
*
* - Enter: send input + "\r" and clear
* - Shift+Enter: insert newline (multiline)
* - Disabled when disconnected; shows "Disconnected" badge
* - Send button visible when input has content and connected
* - Top 3 action buttons (Discuss, Next, Auto) shown standalone
* - Overflow menu (⋯) contains all remaining /sf subcommands grouped by category
* - Every action has a tooltip with description on hover
*/
function ChatInputBar({
onSendInput,
connected,
onOpenAction,
}: {
onSendInput: (data: string, images?: PendingImage[]) => void;
connected: boolean;
onOpenAction?: (action: SFActionDef) => void;
}) {
const autoActive = useSFWorkspaceState().boot?.auto?.active ?? false;
const [value, setValue] = useState("");
const [overflowOpen, setOverflowOpen] = useState(false);
const [pendingImages, setPendingImages] = useState<PendingImage[]>([]);
const [isDragging, setIsDragging] = useState(false);
const [imageNotice, setImageNotice] = useState<string | null>(null);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const dragCounterRef = useRef(0);
// Cleanup blob URLs on unmount
useEffect(() => {
return () => {
pendingImages.forEach((img) => URL.revokeObjectURL(img.previewUrl));
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [pendingImages.forEach]);
const addImages = useCallback(
async (files: File[]) => {
setImageNotice(null);
const imageFiles = files.filter((f) => f.type.startsWith("image/"));
if (imageFiles.length === 0) return;
setPendingImages((prev) => {
const remaining = MAX_PENDING_IMAGES - prev.length;
if (remaining <= 0) {
setImageNotice(`Maximum ${MAX_PENDING_IMAGES} images per message`);
return prev;
}
return prev; // return current, processing happens below
});
// Process files outside setState to handle async
const currentCount = pendingImages.length;
const toProcess = imageFiles.slice(0, MAX_PENDING_IMAGES - currentCount);
if (toProcess.length < imageFiles.length) {
setImageNotice(`Maximum ${MAX_PENDING_IMAGES} images per message`);
}
const newImages: PendingImage[] = [];
for (const file of toProcess) {
try {
const result = await processImageFile(file);
const previewUrl = URL.createObjectURL(file);
newImages.push({
id: generateImageId(),
data: result.data,
mimeType: result.mimeType,
previewUrl,
});
} catch (err) {
console.warn(
"[chat-input] image processing failed:",
err instanceof Error ? err.message : err,
);
setImageNotice(
err instanceof Error ? err.message : "Failed to process image",
);
}
}
if (newImages.length > 0) {
setPendingImages((prev) => {
const combined = [...prev, ...newImages];
if (combined.length > MAX_PENDING_IMAGES) {
// Revoke excess
combined
.slice(MAX_PENDING_IMAGES)
.forEach((img) => URL.revokeObjectURL(img.previewUrl));
setImageNotice(`Maximum ${MAX_PENDING_IMAGES} images per message`);
return combined.slice(0, MAX_PENDING_IMAGES);
}
return combined;
});
}
},
[pendingImages.length],
);
const removeImage = useCallback((id: string) => {
setPendingImages((prev) => {
const removed = prev.find((img) => img.id === id);
if (removed) URL.revokeObjectURL(removed.previewUrl);
return prev.filter((img) => img.id !== id);
});
setImageNotice(null);
}, []);
const handleDrop = useCallback(
(e: DragEvent<HTMLDivElement>) => {
e.preventDefault();
e.stopPropagation();
setIsDragging(false);
dragCounterRef.current = 0;
const files = Array.from(e.dataTransfer.files);
void addImages(files);
},
[addImages],
);
const handleDragOver = useCallback((e: DragEvent<HTMLDivElement>) => {
e.preventDefault();
e.stopPropagation();
}, []);
const handleDragEnter = useCallback((e: DragEvent<HTMLDivElement>) => {
e.preventDefault();
e.stopPropagation();
dragCounterRef.current += 1;
setIsDragging(true);
}, []);
const handleDragLeave = useCallback((e: DragEvent<HTMLDivElement>) => {
e.preventDefault();
e.stopPropagation();
dragCounterRef.current -= 1;
if (dragCounterRef.current <= 0) {
dragCounterRef.current = 0;
setIsDragging(false);
}
}, []);
const handlePaste = useCallback(
(e: ClipboardEvent<HTMLTextAreaElement>) => {
const items = e.clipboardData?.files;
if (items && items.length > 0) {
const imageFiles = Array.from(items).filter((f) =>
f.type.startsWith("image/"),
);
if (imageFiles.length > 0) {
e.preventDefault();
void addImages(imageFiles);
}
// If no image files in clipboard, let normal text paste proceed (no-regression)
}
},
[addImages],
);
const handleSend = useCallback(() => {
const trimmed = value.trim();
if (!trimmed && pendingImages.length === 0) return;
if (!connected) return;
onSendInput(
value + "\r",
pendingImages.length > 0 ? pendingImages : undefined,
);
setValue("");
// Don't revoke URLs here — they'll be used in the chat bubble for the sent message
setPendingImages([]);
setImageNotice(null);
if (textareaRef.current) {
textareaRef.current.style.height = "auto";
}
}, [value, connected, onSendInput, pendingImages]);
const handleKeyDown = useCallback(
(e: KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleSend();
}
},
[handleSend],
);
const handleInput = useCallback(
(e: React.ChangeEvent<HTMLTextAreaElement>) => {
setValue(e.target.value);
const el = e.target;
el.style.height = "auto";
el.style.height = `${Math.min(el.scrollHeight, 160)}px`;
},
[],
);
const hasContent = value.trim().length > 0 || pendingImages.length > 0;
const overflowGroups = useMemo(() => groupByCategory(OVERFLOW_ACTIONS), []);
return (
<div className="flex-shrink-0 border-t border-border bg-card px-4 py-3 backdrop-blur-sm">
<div
className="flex items-end gap-2"
onDrop={handleDrop}
onDragOver={handleDragOver}
onDragEnter={handleDragEnter}
onDragLeave={handleDragLeave}
>
{/* Input + send button */}
<div
className={cn(
"flex flex-1 flex-col rounded-xl border bg-background transition-colors",
connected
? "border-border focus-within:ring-1 focus-within:ring-border/30"
: "border-border/50 opacity-80",
isDragging &&
connected &&
"border-primary/60 ring-2 ring-primary/20 bg-primary/5",
)}
>
{/* Thumbnail preview row */}
{pendingImages.length > 0 && (
<div className="flex items-center gap-2 px-3 pt-2.5 pb-1 flex-wrap">
{pendingImages.map((img) => (
<div key={img.id} className="relative group flex-shrink-0">
<Image
src={img.previewUrl}
alt="Pending image"
width={48}
height={48}
unoptimized
className="h-12 w-12 rounded-lg object-cover border border-border/50"
/>
<button
type="button"
onClick={() => removeImage(img.id)}
aria-label="Remove image"
className="absolute -top-1.5 -right-1.5 flex h-4 w-4 items-center justify-center rounded-full bg-destructive text-destructive-foreground text-[10px] opacity-0 group-hover:opacity-100 transition-opacity shadow-sm"
>
<X className="h-2.5 w-2.5" />
</button>
</div>
))}
{imageNotice && (
<span className="text-[10px] text-muted-foreground italic">
{imageNotice}
</span>
)}
</div>
)}
<div className="flex items-end gap-2">
<textarea
ref={textareaRef}
value={value}
onChange={handleInput}
onKeyDown={handleKeyDown}
onPaste={handlePaste}
disabled={!connected}
rows={1}
aria-label="Send message"
placeholder={connected ? "Message…" : "Connecting…"}
className="min-h-[40px] flex-1 resize-none bg-transparent px-3 py-2.5 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none disabled:cursor-not-allowed disabled:text-muted-foreground"
style={{ height: "40px", maxHeight: "160px", overflowY: "auto" }}
/>
<div className="flex flex-shrink-0 items-end pb-1.5 pr-1.5 gap-1">
{!connected && (
<span className="px-2 py-1 text-[10px] font-medium text-muted-foreground uppercase tracking-wide">
Disconnected
</span>
)}
<button
type="button"
onClick={handleSend}
disabled={!connected || !hasContent}
aria-label="Send"
className={cn(
"flex h-7 w-7 items-center justify-center rounded-lg transition-all",
hasContent && connected
? "bg-primary text-primary-foreground shadow-sm hover:bg-primary/90 active:scale-95"
: "bg-muted text-muted-foreground cursor-not-allowed",
)}
>
<SendHorizonal className="h-3.5 w-3.5" />
</button>
</div>
</div>
</div>
{/* ── Top 3 action buttons with tooltips ── */}
{onOpenAction && (
<TooltipProvider delayDuration={300}>
{TOP_ACTIONS.map((action) => {
const Icon = action.icon;
const isDisabled = action.disabledDuringAuto && autoActive;
return (
<Tooltip key={action.command}>
<TooltipTrigger asChild>
<button
type="button"
onClick={() => onOpenAction(action)}
disabled={isDisabled}
aria-label={action.description}
className={cn(
"flex flex-shrink-0 items-center justify-center gap-1.5 rounded-xl border border-border bg-background px-3 py-2.5 text-xs font-medium text-foreground transition-colors hover:bg-accent",
isDisabled && "cursor-not-allowed opacity-40",
)}
>
<Icon className="h-3.5 w-3.5 text-muted-foreground" />
{action.label}
</button>
</TooltipTrigger>
<TooltipContent side="top" sideOffset={6}>
<p className="font-medium">{action.label}</p>
<p className="text-[10px] opacity-80">
{isDisabled
? "Disabled while auto-mode is running"
: action.description}
</p>
</TooltipContent>
</Tooltip>
);
})}
{/* ── Overflow menu ── */}
<Popover open={overflowOpen} onOpenChange={setOverflowOpen}>
<Tooltip>
<TooltipTrigger asChild>
<PopoverTrigger asChild>
<button
type="button"
aria-label="More SF commands"
className={cn(
"flex flex-shrink-0 items-center justify-center rounded-xl border border-border bg-background p-2.5 text-foreground transition-colors hover:bg-accent",
overflowOpen && "bg-accent",
)}
>
<MoreHorizontal className="h-4 w-4 text-muted-foreground" />
</button>
</PopoverTrigger>
</TooltipTrigger>
{!overflowOpen && (
<TooltipContent side="top" sideOffset={6}>
More commands
</TooltipContent>
)}
</Tooltip>
<PopoverContent
side="top"
align="end"
sideOffset={8}
className="w-64 max-h-[420px] overflow-y-auto rounded-xl border border-border bg-popover p-2 shadow-lg"
>
{overflowGroups.map((group, gi) => (
<div key={group.category}>
{gi > 0 && (
<div className="my-1.5 border-t border-border/50" />
)}
<p className="px-2 py-1 text-[10px] font-semibold text-muted-foreground uppercase tracking-wider">
{group.label}
</p>
{group.items.map((action) => {
const Icon = action.icon;
const isDisabled =
action.disabledDuringAuto && autoActive;
return (
<Tooltip key={action.command}>
<TooltipTrigger asChild>
<button
type="button"
onClick={() => {
if (isDisabled) return;
setOverflowOpen(false);
onOpenAction(action);
}}
disabled={isDisabled}
className={cn(
"flex w-full items-center gap-2.5 rounded-lg px-2 py-1.5 text-left text-sm text-foreground transition-colors hover:bg-accent",
isDisabled && "cursor-not-allowed opacity-40",
)}
>
<Icon className="h-3.5 w-3.5 flex-shrink-0 text-muted-foreground" />
<span className="flex-1 truncate">
{action.label}
</span>
</button>
</TooltipTrigger>
<TooltipContent side="left" sideOffset={8}>
<p className="font-medium">{action.label}</p>
<p className="text-[10px] opacity-80">
{isDisabled
? "Disabled while auto-mode is running"
: action.description}
</p>
</TooltipContent>
</Tooltip>
);
})}
</div>
))}
</PopoverContent>
</Popover>
</TooltipProvider>
)}
</div>
</div>
);
}
/* ─── Placeholder state ─── */
function PlaceholderState({
connected,
runningLabel,
notice,
primaryAction,
onPrimaryAction,
}: {
connected: boolean;
runningLabel?: string;
notice?: string | null;
primaryAction?: { label: string; icon: LucideIcon } | null;
onPrimaryAction?: () => void;
}) {
const showSpinner = connected && Boolean(runningLabel);
return (
<div className="flex flex-1 flex-col items-center justify-center text-center py-16">
<div className="flex h-12 w-12 items-center justify-center rounded-full border border-border bg-card">
{showSpinner ? (
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
) : (
<MessagesSquare className="h-6 w-6 text-muted-foreground" />
)}
</div>
<div className="mt-3 space-y-1">
<p className="text-sm font-medium text-foreground">Chat Mode</p>
{showSpinner ? (
<p className="max-w-xs text-xs text-muted-foreground">
Running {runningLabel}
</p>
) : notice ? (
<p className="max-w-xs text-xs text-muted-foreground">{notice}</p>
) : !connected ? (
<p className="max-w-xs text-xs text-muted-foreground">
Connecting to SF session
</p>
) : primaryAction && onPrimaryAction ? (
<div className="mt-4">
<button
type="button"
onClick={onPrimaryAction}
className="inline-flex items-center gap-2 rounded-xl border border-border bg-background px-5 py-2.5 text-sm font-medium text-foreground transition-colors hover:bg-accent active:scale-[0.98]"
>
<primaryAction.icon className="h-4 w-4 text-muted-foreground" />
{primaryAction.label}
</button>
</div>
) : (
<p className="max-w-xs text-xs text-muted-foreground">
Connected waiting for SF output
</p>
)}
</div>
</div>
);
}
/* ─── InlineUiRequest ─── */
/**
* Renders a bridge-level PendingUiRequest inline in the chat message flow.
* Supports select (single + multi), confirm, input, and editor requests.
* After submission, transitions to a static confirmation state.
*
* The FocusedPanel (Sheet overlay in app-shell) is the fallback surface for
* these same requests in non-chat views. Whichever the user interacts with
* first resolves the request — the store deduplicates.
*/
function InlineUiRequest({ request }: { request: PendingUiRequest }) {
const { respondToUiRequest, dismissUiRequest } = useSFWorkspaceActions();
const isSubmitting =
useSFWorkspaceState().commandInFlight === "extension_ui_response";
const handleSubmit = useCallback(
(value: Record<string, unknown>) => {
void respondToUiRequest(request.id, value);
},
[respondToUiRequest, request.id],
);
const handleDismiss = useCallback(() => {
void dismissUiRequest(request.id);
}, [dismissUiRequest, request.id]);
return (
<div
className="flex justify-start gap-3"
data-testid="inline-ui-request"
data-request-id={request.id}
>
<div className="mt-1 flex-shrink-0 flex h-7 w-7 items-center justify-center rounded-full bg-card border border-border">
<PlatformLogoIcon className="h-3.5 w-auto" />
</div>
<div className="max-w-[82%] min-w-0 rounded-2xl rounded-tl-md border border-border bg-card px-4 py-3 shadow-sm">
{request.title && (
<p className="mb-2.5 text-sm font-medium text-foreground">
{request.title}
</p>
)}
{request.method === "select" && (
<InlineSelect
request={request}
onSubmit={handleSubmit}
disabled={isSubmitting}
/>
)}
{request.method === "confirm" && (
<InlineConfirm
request={request}
onSubmit={handleSubmit}
onDismiss={handleDismiss}
disabled={isSubmitting}
/>
)}
{request.method === "input" && (
<InlineInput
request={request}
onSubmit={handleSubmit}
disabled={isSubmitting}
/>
)}
{request.method === "editor" && (
<InlineEditor
request={request}
onSubmit={handleSubmit}
disabled={isSubmitting}
/>
)}
</div>
</div>
);
}
function InlineSelect({
request,
onSubmit,
disabled,
}: {
request: Extract<PendingUiRequest, { method: "select" }>;
onSubmit: (value: Record<string, unknown>) => void;
disabled: boolean;
}) {
const isMulti = Boolean(request.allowMultiple);
const [singleValue, setSingleValue] = useState("");
const [multiValues, setMultiValues] = useState<Set<string>>(new Set());
const [submitted, setSubmitted] = useState(false);
const handleSubmit = useCallback(() => {
setSubmitted(true);
onSubmit({ value: isMulti ? Array.from(multiValues) : singleValue });
}, [isMulti, singleValue, multiValues, onSubmit]);
if (submitted) {
return (
<div className="flex items-center gap-1.5 rounded-lg bg-primary/10 px-3 py-2 text-sm text-primary">
<Check className="h-3.5 w-3.5 flex-shrink-0" />
<span className="font-medium">
{isMulti ? `${multiValues.size} selected` : singleValue}
</span>
</div>
);
}
const canSubmit = isMulti ? multiValues.size > 0 : singleValue !== "";
return (
<div className="space-y-1.5">
{request.options.map((option, i) => {
if (isMulti) {
const checked = multiValues.has(option);
return (
<button
type="button"
key={i}
onClick={() => {
const next = new Set(multiValues);
if (checked) next.delete(option);
else next.add(option);
setMultiValues(next);
}}
disabled={disabled}
className={cn(
"flex w-full items-center gap-2.5 rounded-lg px-3 py-2 text-left text-sm transition-colors",
checked
? "bg-primary/15 text-primary font-medium"
: "text-foreground hover:bg-muted",
)}
>
<span className="flex h-4 w-4 flex-shrink-0 items-center justify-center rounded border border-border">
{checked && <Check className="h-2.5 w-2.5 text-primary" />}
</span>
<span>{option}</span>
</button>
);
}
const selected = singleValue === option;
return (
<button
type="button"
key={i}
onClick={() => setSingleValue(option)}
disabled={disabled}
className={cn(
"flex w-full items-center gap-2.5 rounded-lg px-3 py-2 text-left text-sm transition-colors",
selected
? "bg-primary/15 text-primary font-medium"
: "text-foreground hover:bg-muted",
)}
>
<span className="flex h-4 w-4 flex-shrink-0 items-center justify-center">
{selected ? (
<Check className="h-3 w-3 text-primary" />
) : (
<span className="h-1.5 w-1.5 rounded-full bg-muted-foreground/30" />
)}
</span>
<span>{option}</span>
</button>
);
})}
<button
type="button"
onClick={handleSubmit}
disabled={disabled || !canSubmit}
className={cn(
"mt-2 flex w-full items-center justify-center rounded-lg px-3 py-2 text-xs font-medium transition-all",
canSubmit && !disabled
? "bg-primary text-primary-foreground hover:bg-primary/90 active:scale-[0.98] shadow-sm"
: "bg-muted text-muted-foreground cursor-not-allowed",
)}
>
{isMulti ? `Submit (${multiValues.size})` : "Submit"}
</button>
</div>
);
}
function InlineConfirm({
request,
onSubmit,
onDismiss,
disabled,
}: {
request: Extract<PendingUiRequest, { method: "confirm" }>;
onSubmit: (value: Record<string, unknown>) => void;
onDismiss: () => void;
disabled: boolean;
}) {
const [resolved, setResolved] = useState<boolean | null>(null);
if (resolved !== null) {
return (
<div className="flex items-center gap-1.5 rounded-lg bg-primary/10 px-3 py-2 text-sm text-primary">
<Check className="h-3.5 w-3.5 flex-shrink-0" />
<span className="font-medium">
{resolved ? "Confirmed" : "Cancelled"}
</span>
</div>
);
}
return (
<div className="space-y-2.5">
<p className="text-sm text-foreground leading-relaxed">
{request.message}
</p>
<div className="flex gap-2">
<button
type="button"
onClick={() => {
setResolved(true);
onSubmit({ value: true });
}}
disabled={disabled}
className="flex-1 rounded-lg bg-primary px-3 py-2 text-xs font-medium text-primary-foreground hover:bg-primary/90 active:scale-[0.98] shadow-sm transition-all"
>
Confirm
</button>
<button
type="button"
onClick={() => {
setResolved(false);
onDismiss();
}}
disabled={disabled}
className="flex-1 rounded-lg border border-border bg-background px-3 py-2 text-xs font-medium text-foreground hover:bg-accent transition-colors"
>
Cancel
</button>
</div>
</div>
);
}
function InlineInput({
request,
onSubmit,
disabled,
}: {
request: Extract<PendingUiRequest, { method: "input" }>;
onSubmit: (value: Record<string, unknown>) => void;
disabled: boolean;
}) {
const [value, setValue] = useState("");
const [submitted, setSubmitted] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
inputRef.current?.focus();
}, []);
if (submitted) {
return (
<div className="flex items-center gap-1.5 rounded-lg bg-primary/10 px-3 py-2 text-sm text-primary">
<Check className="h-3.5 w-3.5 flex-shrink-0" />
<span className="font-medium">Submitted</span>
</div>
);
}
const handleSubmit = () => {
if (!value.trim() || disabled) return;
setSubmitted(true);
onSubmit({ value });
};
return (
<div className="flex gap-2">
<Input
ref={inputRef}
value={value}
onChange={(e) => setValue(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
handleSubmit();
}
}}
placeholder={request.placeholder || "Type your answer…"}
disabled={disabled}
className="flex-1 h-8 text-sm"
/>
<button
type="button"
onClick={handleSubmit}
disabled={disabled || !value.trim()}
className={cn(
"flex h-8 items-center justify-center rounded-lg px-3 text-xs font-medium transition-all",
value.trim() && !disabled
? "bg-primary text-primary-foreground hover:bg-primary/90 active:scale-95 shadow-sm"
: "bg-muted text-muted-foreground cursor-not-allowed",
)}
>
Submit
</button>
</div>
);
}
function InlineEditor({
request,
onSubmit,
disabled,
}: {
request: Extract<PendingUiRequest, { method: "editor" }>;
onSubmit: (value: Record<string, unknown>) => void;
disabled: boolean;
}) {
const [value, setValue] = useState(request.prefill || "");
const [submitted, setSubmitted] = useState(false);
if (submitted) {
return (
<div className="flex items-center gap-1.5 rounded-lg bg-primary/10 px-3 py-2 text-sm text-primary">
<Check className="h-3.5 w-3.5 flex-shrink-0" />
<span className="font-medium">Submitted</span>
</div>
);
}
return (
<div className="space-y-2">
<textarea
value={value}
onChange={(e) => setValue(e.target.value)}
disabled={disabled}
className="w-full min-h-[120px] rounded-lg border border-border bg-background px-3 py-2 text-sm font-mono focus:outline-none focus:ring-1 focus:ring-border/30 resize-y"
/>
<button
type="button"
onClick={() => {
setSubmitted(true);
onSubmit({ value });
}}
disabled={disabled}
className="flex w-full items-center justify-center rounded-lg bg-primary px-3 py-2 text-xs font-medium text-primary-foreground hover:bg-primary/90 active:scale-[0.98] shadow-sm transition-all"
>
Submit
</button>
</div>
);
}
/* ─── Chat Pane ─── */
interface ChatPaneProps {
sessionId?: string;
command?: string;
commandArgs?: string[];
className?: string;
initialCommand?: string;
onCompletionSignal?: () => void;
onOpenAction?: (action: SFActionDef) => void;
activityLabel?: string;
suppressTerminalChrome?: boolean;
suppressInitialEcho?: boolean;
}
/* ─── ToolExecutionBlock ─── */
/**
* Renders a completed tool execution as a collapsible block.
* Edit tool shows a syntax-highlighted unified diff.
* Write tool shows the file path and a preview.
* Bash tool shows the command and output.
* Other tools show a compact summary.
*/
function ToolExecutionBlock({ tool }: { tool: CompletedToolExecution }) {
const [expanded, setExpanded] = useState(false);
const autoExpandedRef = useRef(false);
const normalizedToolName =
typeof tool.name === "string" ? tool.name.toLowerCase() : "";
const path =
typeof tool.args?.path === "string"
? tool.args.path
: typeof tool.args?.file_path === "string"
? tool.args.file_path
: null;
const shortPath = path
? path.startsWith(process.env.HOME ?? "/Users")
? "~" + path.slice((process.env.HOME ?? "").length)
: path
: null;
const isError = tool.result?.isError ?? false;
const diff = tool.result?.details?.diff as string | undefined;
// Choose icon and label
const icon =
normalizedToolName === "edit" ? (
<FileEdit className="h-3.5 w-3.5" />
) : normalizedToolName === "write" ? (
<FilePlus className="h-3.5 w-3.5" />
) : (
<Terminal className="h-3.5 w-3.5" />
);
const label =
normalizedToolName === "edit"
? "Edit"
: normalizedToolName === "write"
? "Write"
: normalizedToolName === "bash"
? "$"
: tool.name;
// For bash, show the command
const bashCommand =
normalizedToolName === "bash" && typeof tool.args?.command === "string"
? tool.args.command
: null;
// Result text (for bash output, read result, etc.)
const resultText =
tool.result?.content
?.filter((c) => c.type === "text" && c.text)
.map((c) => c.text)
.join("\n") ?? "";
useEffect(() => {
if (autoExpandedRef.current) return;
const hasVisibleResult = Boolean(diff || resultText.trim() || isError);
if (!hasVisibleResult) return;
autoExpandedRef.current = true;
// eslint-disable-next-line react-hooks/set-state-in-effect -- ref-guarded, runs at most once
setExpanded(true);
}, [diff, resultText, isError]);
return (
<div className="flex justify-start gap-3">
<div className="w-7 flex-shrink-0" />
<div className="max-w-[82%] min-w-0 w-full">
<button
type="button"
onClick={() => setExpanded((e) => !e)}
className={cn(
"w-full rounded-lg border px-3 py-2 text-left text-xs transition-colors",
isError
? "border-destructive/30 bg-destructive/5 hover:bg-destructive/10"
: "border-border/50 bg-muted/50 hover:bg-muted/50",
)}
>
{/* Header */}
<div className="flex items-center gap-2">
<span
className={cn(
"flex-shrink-0",
isError ? "text-destructive" : "text-muted-foreground",
)}
>
{icon}
</span>
<span
className={cn(
"font-mono font-medium",
isError ? "text-destructive" : "text-muted-foreground",
)}
>
{label}
</span>
{shortPath && (
<span className="truncate font-mono text-info/80">
{shortPath}
</span>
)}
{bashCommand && !shortPath && (
<span className="truncate font-mono text-muted-foreground">
{bashCommand.length > 60
? bashCommand.slice(0, 60) + "…"
: bashCommand}
</span>
)}
<span className="ml-auto flex-shrink-0 text-muted-foreground">
{expanded ? (
<ChevronDown className="h-3 w-3" />
) : (
<ChevronRight className="h-3 w-3" />
)}
</span>
</div>
{/* Expanded content */}
{expanded && diff && (
<div className="mt-2 overflow-x-auto rounded-md border border-border/50 bg-background p-2 font-mono text-[11px] leading-relaxed">
{diff.split("\n").map((line, i) => {
const isAdd = line.startsWith("+");
const isRemove = line.startsWith("-");
const isContext = line.startsWith(" ");
return (
<div
key={i}
className={cn(
"whitespace-pre",
isAdd && "bg-success/10 text-success",
isRemove && "bg-destructive/10 text-destructive",
isContext && "text-muted-foreground",
!isAdd &&
!isRemove &&
!isContext &&
"text-muted-foreground",
)}
>
{line}
</div>
);
})}
</div>
)}
{/* Expanded: bash output or other result */}
{expanded && !diff && resultText && (
<div className="mt-2 max-h-[200px] overflow-y-auto rounded-md border border-border/50 bg-background p-2 font-mono text-[11px] leading-relaxed text-muted-foreground whitespace-pre-wrap">
{resultText.length > 2000
? resultText.slice(0, 2000) + "\n…"
: resultText}
</div>
)}
{/* Error message */}
{expanded && isError && resultText && (
<div className="mt-2 rounded-md border border-destructive/20 bg-destructive/5 p-2 text-[11px] text-destructive whitespace-pre-wrap">
{resultText}
</div>
)}
</button>
</div>
</div>
);
}
/**
* ChatPane — bridge event-driven chat rendering.
*
* Consumes structured agent events from the workspace store:
* - streamingAssistantText: live text deltas from the LLM
* - streamingThinkingText: live thinking/reasoning deltas
* - liveTranscript: completed text blocks from previous turns
* - activeToolExecution: currently running tool call
*
* User messages are tracked locally and sent via submitInput().
* No terminal buffer parsing — all data comes from the bridge event stream.
*
* Observability:
* - data-testid="chat-pane-store-driven" on the root element
* - ChatInputBar shows "Disconnected" badge when bridge is not connected
*/
export function ChatPane({ className, onOpenAction }: ChatPaneProps) {
const state = useSFWorkspaceState();
const { submitInput, sendCommand, pushChatUserMessage } =
useSFWorkspaceActions();
const [terminalFontSize] = useTerminalFontSize();
const connected = state.connectionState === "connected";
const isStreaming = state.boot?.bridge.sessionState?.isStreaming ?? false;
const bridge = state.boot?.bridge ?? null;
// ── Derive smart CTA for the placeholder state ──
const workflowAction = deriveWorkflowAction({
phase: state.boot?.workspace?.active.phase ?? "pre-planning",
autoActive: state.boot?.auto?.active ?? false,
autoPaused: state.boot?.auto?.paused ?? false,
onboardingLocked: state.boot?.onboarding.locked ?? false,
commandInFlight: state.commandInFlight,
bootStatus: state.bootStatus,
hasMilestones: (state.boot?.workspace?.milestones.length ?? 0) > 0,
projectDetectionKind: state.boot?.projectDetection?.kind ?? null,
});
const placeholderCTA = useMemo((): {
label: string;
icon: LucideIcon;
} | null => {
if (!workflowAction.primary || workflowAction.disabled) return null;
const phase = state.boot?.workspace?.active.phase ?? "pre-planning";
const autoActive = state.boot?.auto?.active ?? false;
const autoPaused = state.boot?.auto?.paused ?? false;
if (autoActive && !autoPaused) {
return { label: "Stop Autonomous", icon: Square };
}
if (autoPaused) {
return { label: "Resume Autonomous", icon: Play };
}
if (phase === "complete") {
return { label: "New Milestone", icon: Milestone };
}
if (phase === "planning") {
return { label: "Plan", icon: Play };
}
if (phase === "executing" || phase === "summarizing") {
return { label: "Start Autonomous", icon: Zap };
}
if (phase === "pre-planning") {
return { label: "Initialize Project", icon: Play };
}
return { label: "Continue", icon: Play };
}, [
workflowAction,
state.boot?.workspace?.active.phase,
state.boot?.auto?.active,
state.boot?.auto?.paused,
]);
const handlePlaceholderCTA = useCallback(() => {
if (!workflowAction.primary) return;
void sendCommand(
buildPromptCommand(workflowAction.primary.command, bridge),
);
}, [workflowAction, sendCommand, bridge]);
/** Send user text — adds a user bubble and dispatches via the store */
const handleUserInput = useCallback(
(data: string, images?: PendingImage[]) => {
const text = data.replace(/\r$/, "").trim();
if (!text && (!images || images.length === 0)) return;
const userMsg: ChatMessage = {
id: createLocalMessageId(),
role: "user",
content: text,
complete: true,
timestamp: Date.now(),
images: images?.map((i) => ({ data: i.data, mimeType: i.mimeType })),
};
pushChatUserMessage(userMsg);
void submitInput(text, images);
},
[submitInput, pushChatUserMessage],
);
// Build unified timeline from store state.
// Uses the segment-ordered data to render thinking/text/tool blocks
// in their actual chronological order within each turn.
type TimelineItem =
| { kind: "thinking"; content: string; id: string }
| { kind: "message"; message: ChatMessage }
| { kind: "tool"; tool: CompletedToolExecution }
| { kind: "active-tool"; tool: ActiveToolExecution }
| { kind: "streaming-thinking"; content: string }
| { kind: "streaming-message"; content: string; isThinking: boolean }
| { kind: "ui-request"; request: PendingUiRequest };
const timeline = useMemo((): TimelineItem[] => {
const items: TimelineItem[] = [];
const transcriptBlocks = state.liveTranscript;
const segmentBlocks = state.completedTurnSegments;
const userMsgs = state.chatUserMessages;
// Interleave: user messages alternate with assistant turns.
// For completed turns, render from segments to preserve chronological order.
for (
let i = 0;
i < Math.max(userMsgs.length, transcriptBlocks.length);
i++
) {
if (i < userMsgs.length) {
items.push({ kind: "message", message: userMsgs[i] });
}
if (i < segmentBlocks.length && segmentBlocks[i].length > 0) {
// Render each segment in order
for (const seg of segmentBlocks[i]) {
if (seg.kind === "thinking") {
items.push({
kind: "thinking",
content: seg.content,
id: `turn-${i}-thinking-${items.length}`,
});
} else if (seg.kind === "text") {
items.push({
kind: "message",
message: {
id: `turn-${i}-text-${items.length}`,
role: "assistant",
content: seg.content,
complete: true,
timestamp: i + 1,
},
});
} else if (seg.kind === "tool") {
items.push({ kind: "tool", tool: seg.tool });
}
}
} else if (i < transcriptBlocks.length && transcriptBlocks[i].trim()) {
// Fallback: no segments stored yet (shouldn't happen for new turns, but safe)
items.push({
kind: "message",
message: {
id: `transcript-${i}`,
role: "assistant",
content: transcriptBlocks[i],
complete: true,
timestamp: i + 1,
},
});
}
}
// Current turn: render finalized segments, then any in-flight content
for (const seg of state.currentTurnSegments) {
if (seg.kind === "thinking") {
items.push({
kind: "thinking",
content: seg.content,
id: `current-thinking-${items.length}`,
});
} else if (seg.kind === "text") {
items.push({
kind: "message",
message: {
id: `current-text-${items.length}`,
role: "assistant",
content: seg.content,
complete: true,
// eslint-disable-next-line react-hooks/purity -- display timestamp for in-progress message
timestamp: Date.now(),
},
});
} else if (seg.kind === "tool") {
items.push({ kind: "tool", tool: seg.tool });
}
}
// Active tool execution indicator
if (state.activeToolExecution) {
items.push({ kind: "active-tool", tool: state.activeToolExecution });
}
// Currently streaming thinking (live, not yet finalized into a segment)
if (state.streamingThinkingText.length > 0) {
items.push({
kind: "streaming-thinking",
content: state.streamingThinkingText,
});
}
// Currently streaming text (live)
if (state.streamingAssistantText.length > 0) {
items.push({
kind: "streaming-message",
content: state.streamingAssistantText,
isThinking: false,
});
}
// If only thinking is happening (no text yet, no tool), show a minimal indicator
if (
state.streamingThinkingText.length === 0 &&
state.streamingAssistantText.length === 0 &&
!state.activeToolExecution &&
isStreaming &&
state.currentTurnSegments.length === 0
) {
// Pure waiting state — streaming started but nothing produced yet
items.push({ kind: "streaming-message", content: "", isThinking: true });
}
// Pending UI requests — at the end
for (const req of state.pendingUiRequests) {
items.push({ kind: "ui-request", request: req });
}
return items;
}, [
state.liveTranscript,
state.completedTurnSegments,
state.currentTurnSegments,
state.streamingAssistantText,
state.streamingThinkingText,
state.activeToolExecution,
state.pendingUiRequests,
state.chatUserMessages,
isStreaming,
]);
// Prompt submit handler for TUI prompts (select/text/password)
const handlePromptSubmit = useCallback(
(data: string) => {
void submitInput(data.replace(/\r$/, ""));
},
[submitInput],
);
const showPlaceholder = timeline.length === 0 && !isStreaming;
// Show an "awaiting input" indicator when the session is idle (connected,
// not streaming, has timeline content) so the UI does not appear stuck (#2707).
const showAwaitingInput =
connected &&
!isStreaming &&
timeline.length > 0 &&
!state.activeToolExecution &&
state.pendingUiRequests.length === 0;
// Auto-scroll ref
const scrollRef = useRef<HTMLDivElement>(null);
const isNearBottomRef = useRef(true);
const handleScroll = useCallback(() => {
const el = scrollRef.current;
if (!el) return;
isNearBottomRef.current =
el.scrollHeight - el.scrollTop - el.clientHeight < 100;
}, []);
useEffect(() => {
const el = scrollRef.current;
if (!el) return;
if (isNearBottomRef.current) {
el.scrollTop = el.scrollHeight;
}
}, []);
return (
<div
data-testid="chat-pane-store-driven"
className={cn("flex flex-col overflow-hidden", className)}
>
<div className="flex flex-1 flex-col overflow-hidden">
{showPlaceholder ? (
<PlaceholderState
connected={connected}
runningLabel={isStreaming ? "responding" : undefined}
primaryAction={placeholderCTA}
onPrimaryAction={handlePlaceholderCTA}
/>
) : (
<div
ref={scrollRef}
onScroll={handleScroll}
className="flex-1 overflow-y-auto px-4 py-4 space-y-4"
style={
terminalFontSize !== 13
? { fontSize: `${terminalFontSize}px` }
: undefined
}
>
{timeline.map((item, _idx) => {
switch (item.kind) {
case "message":
return (
<ChatBubble
key={item.message.id}
message={item.message}
onSubmitPrompt={handlePromptSubmit}
/>
);
case "thinking":
return (
<div key={item.id} className="flex justify-start gap-3">
<div className="w-7 flex-shrink-0" />
<div className="max-w-[82%] min-w-0">
<InlineThinking
content={item.content}
isStreaming={false}
/>
</div>
</div>
);
case "streaming-thinking":
return (
<div
key="streaming-thinking"
className="flex justify-start gap-3"
>
<div className="w-7 flex-shrink-0" />
<div className="max-w-[82%] min-w-0">
<InlineThinking
content={item.content}
isStreaming={true}
/>
</div>
</div>
);
case "streaming-message":
return (
<ChatBubble
key="streaming-message"
message={{
id: "streaming-current",
role: "assistant",
content: item.content,
complete: false,
// eslint-disable-next-line react-hooks/purity -- display timestamp for streaming message
timestamp: Date.now(),
}}
isThinking={item.isThinking}
/>
);
case "tool":
return (
<ToolExecutionBlock key={item.tool.id} tool={item.tool} />
);
case "active-tool":
return (
<div
key={`active-${item.tool.id}`}
className="flex justify-start gap-3"
>
<div className="w-7 flex-shrink-0" />
<div className="max-w-[82%] min-w-0">
<div className="flex items-center gap-2 rounded-lg border border-border/50 bg-muted/50 px-3.5 py-2">
<Loader2 className="h-3 w-3 animate-spin text-muted-foreground" />
<span className="font-mono text-xs text-muted-foreground">
{item.tool.name}
</span>
{Boolean(item.tool.args?.path) && (
<span className="font-mono text-xs text-info/80 truncate">
{String(item.tool.args?.path)}
</span>
)}
</div>
</div>
</div>
);
case "ui-request":
return (
<InlineUiRequest
key={item.request.id}
request={item.request}
/>
);
}
})}
{showAwaitingInput && (
<div className="flex items-center gap-2 px-1 py-1 text-xs text-muted-foreground animate-in fade-in duration-500">
<span className="inline-block h-2 w-2 rounded-full bg-emerald-500/70 animate-pulse" />
Ready for your input
</div>
)}
<div className="h-2" />
</div>
)}
</div>
<ChatInputBar
onSendInput={handleUserInput}
connected={connected}
onOpenAction={onOpenAction}
/>
</div>
);
}