Three root causes addressed: 1. PtyChatParser: user input echoed after a bare prompt line (e.g. "❯ \n" followed by "hello\n") was misclassified as assistant content. Added _awaitingInput flag that flips true on prompt boundary and classifies the next content line as role=user. 2. Chat mode "looks stuck": when the session is idle (connected, not streaming, has timeline content), no visual cue indicated GSD was waiting for input. Added a "Ready for your input" indicator with a pulsing dot. 3. Transcript overflow misalignment: chatUserMessages was not trimmed when liveTranscript/completedTurnSegments overflowed MAX_TRANSCRIPT_BLOCKS, causing index-based interleaving to pair user messages with wrong assistant responses. Also exposed isAwaitingInput() on PtyChatParser so chat UIs can query whether the session is waiting for user input, and widened the > and $ prompt marker regexes to match bare prompts after trimEnd strips trailing whitespace. Closes #2707 Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2336 lines
86 KiB
TypeScript
2336 lines
86 KiB
TypeScript
"use client"
|
|
|
|
import Image from "next/image"
|
|
import { useEffect, useRef, useCallback, useState, useMemo, KeyboardEvent, DragEvent, ClipboardEvent } from "react"
|
|
import { MessagesSquare, SendHorizonal, Check, Eye, EyeOff, Play, Loader2, Milestone, X, MessageCircle, FileEdit, FilePlus, Terminal, ChevronDown, ChevronRight, MoreHorizontal, Zap, Square, Pause, BarChart3, LayoutGrid, ListOrdered, History, Compass, PenLine, Inbox, SkipForward, Undo2, BookOpen, Settings, SlidersHorizontal, Stethoscope, FileOutput, Trash2, Globe, type LucideIcon } from "lucide-react"
|
|
import { cn } from "@/lib/utils"
|
|
import { Input } from "@/components/ui/input"
|
|
import { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider } from "@/components/ui/tooltip"
|
|
import { Popover, PopoverTrigger, PopoverContent } from "@/components/ui/popover"
|
|
import { ChatMessage, TuiPrompt } from "@/lib/pty-chat-parser"
|
|
import { PendingImage, processImageFile, generateImageId, MAX_PENDING_IMAGES } from "@/lib/image-utils"
|
|
import {
|
|
useGSDWorkspaceState,
|
|
useGSDWorkspaceActions,
|
|
buildPromptCommand,
|
|
type CompletedToolExecution,
|
|
type ActiveToolExecution,
|
|
type PendingUiRequest,
|
|
type TurnSegment,
|
|
} from "@/lib/gsd-workspace-store"
|
|
import { deriveWorkflowAction } from "@/lib/workflow-actions"
|
|
import { useTerminalFontSize } from "@/lib/use-terminal-font-size"
|
|
|
|
/* ─── ActionPanel types ─── */
|
|
|
|
// ActionPanelConfig removed — all commands now route through the main bridge.
|
|
|
|
/* ─── GSD Action Definitions ─── */
|
|
|
|
/**
|
|
* Defines every /gsd 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 GSDActionDef {
|
|
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 GSD_ACTIONS: GSDActionDef[] = [
|
|
// ── Top 3 (standalone buttons) ──
|
|
{ label: "Discuss", command: "/gsd discuss", icon: MessageCircle, description: "Start guided milestone/slice discussion", category: "workflow", disabledDuringAuto: true },
|
|
{ label: "Next", command: "/gsd next", icon: Play, description: "Execute next task, then pause", category: "workflow" },
|
|
{ label: "Auto", command: "/gsd auto", icon: Zap, description: "Run all queued units continuously", category: "workflow" },
|
|
// ── Overflow: Workflow ──
|
|
{ label: "Stop", command: "/gsd stop", icon: Square, description: "Stop auto-mode gracefully", category: "workflow" },
|
|
{ label: "Pause", command: "/gsd pause", icon: Pause, description: "Pause auto-mode (preserves state)", category: "workflow" },
|
|
// ── Overflow: Visibility ──
|
|
{ label: "Status", command: "/gsd status", icon: BarChart3, description: "Show progress dashboard", category: "visibility" },
|
|
{ label: "Visualize", command: "/gsd visualize", icon: LayoutGrid, description: "Interactive TUI (progress, deps, metrics, timeline)", category: "visibility" },
|
|
{ label: "Queue", command: "/gsd queue", icon: ListOrdered, description: "Show queued/dispatched units and execution order", category: "visibility" },
|
|
{ label: "History", command: "/gsd history", icon: History, description: "View execution history with cost/phase/model details", category: "visibility" },
|
|
// ── Overflow: Course correction ──
|
|
{ label: "Steer", command: "/gsd steer", icon: Compass, description: "Apply user override to active work", category: "correction" },
|
|
{ label: "Capture", command: "/gsd capture", icon: PenLine, description: "Quick-capture a thought to CAPTURES.md", category: "correction" },
|
|
{ label: "Triage", command: "/gsd triage", icon: Inbox, description: "Classify and route pending captures", category: "correction", disabledDuringAuto: true },
|
|
{ label: "Skip", command: "/gsd skip", icon: SkipForward, description: "Prevent a unit from auto-mode dispatch", category: "correction" },
|
|
{ label: "Undo", command: "/gsd undo", icon: Undo2, description: "Revert last completed unit", category: "correction" },
|
|
// ── Overflow: Knowledge ──
|
|
{ label: "Knowledge", command: "/gsd knowledge", icon: BookOpen, description: "Add rule, pattern, or lesson to KNOWLEDGE.md", category: "knowledge" },
|
|
// ── Overflow: Configuration ──
|
|
{ label: "Mode", command: "/gsd mode", icon: SlidersHorizontal, description: "Set workflow mode (solo/team)", category: "config" },
|
|
{ label: "Prefs", command: "/gsd prefs", icon: Settings, description: "Manage preferences (global/project)", category: "config" },
|
|
// ── Overflow: Maintenance ──
|
|
{ label: "Doctor", command: "/gsd doctor", icon: Stethoscope, description: "Diagnose and repair .gsd/ state", category: "maintenance" },
|
|
{ label: "Export", command: "/gsd export", icon: FileOutput, description: "Export milestone/slice results (JSON or Markdown)", category: "maintenance" },
|
|
{ label: "Cleanup", command: "/gsd cleanup", icon: Trash2, description: "Remove merged branches or snapshots", category: "maintenance" },
|
|
{ label: "Remote", command: "/gsd 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 = GSD_ACTIONS.slice(0, 3)
|
|
/** Remaining actions in the overflow menu */
|
|
const OVERFLOW_ACTIONS = GSD_ACTIONS.slice(3)
|
|
|
|
const CATEGORY_LABELS: Record<GSDActionDef["category"], string> = {
|
|
workflow: "Workflow",
|
|
visibility: "Visibility",
|
|
correction: "Course Correction",
|
|
knowledge: "Knowledge",
|
|
config: "Configuration",
|
|
maintenance: "Maintenance",
|
|
}
|
|
|
|
function groupByCategory(actions: GSDActionDef[]): Array<{ category: GSDActionDef["category"]; label: string; items: GSDActionDef[] }> {
|
|
const seen = new Map<GSDActionDef["category"], GSDActionDef[]>()
|
|
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 /gsd 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 "gsd-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 = useGSDWorkspaceState()
|
|
const { sendCommand } = useGSDWorkspaceActions()
|
|
|
|
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="gsd-main"
|
|
command="gsd"
|
|
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 = useGSDWorkspaceState()
|
|
|
|
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 GSD 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
|
|
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
|
|
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 = {
|
|
codeToHtml: (code: string, options: { lang: string; theme: string }) => 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",
|
|
],
|
|
}),
|
|
)
|
|
.catch((err) => {
|
|
chatHighlighterPromise = null
|
|
throw err
|
|
})
|
|
}
|
|
return chatHighlighterPromise
|
|
}
|
|
|
|
/* ─── 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 {
|
|
const highlighted = h.codeToHtml(codeStr, {
|
|
lang: match[1],
|
|
theme: shikiTheme,
|
|
})
|
|
return (
|
|
<div
|
|
className="chat-code-block my-3 rounded-xl overflow-x-auto text-sm shadow-sm border border-border/50"
|
|
dangerouslySetInnerHTML={{ __html: highlighted }}
|
|
/>
|
|
)
|
|
} 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 GSD 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
|
|
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 GSD 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
|
|
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 GSD 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
|
|
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, content])
|
|
|
|
return (
|
|
<div className="mb-3">
|
|
<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>
|
|
)
|
|
}
|
|
|
|
/* ─── ChatMessageList ─── */
|
|
|
|
/**
|
|
* Renders ChatMessage[] as a scrollable list of ChatBubble components.
|
|
*
|
|
* Scroll behavior:
|
|
* - Auto-scrolls to bottom on new messages ONLY when the user is within 100px of bottom
|
|
* - If the user has scrolled up to read history, auto-scroll is suppressed
|
|
*/
|
|
function ChatMessageList({
|
|
messages,
|
|
onSubmitPrompt,
|
|
fontSize,
|
|
}: {
|
|
messages: ChatMessage[]
|
|
onSubmitPrompt: (data: string) => void
|
|
fontSize?: number
|
|
}) {
|
|
const scrollRef = useRef<HTMLDivElement>(null)
|
|
const isNearBottomRef = useRef(true)
|
|
const prevMessageCountRef = useRef(messages.length)
|
|
|
|
const handleScroll = useCallback(() => {
|
|
const el = scrollRef.current
|
|
if (!el) return
|
|
const distanceFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight
|
|
isNearBottomRef.current = distanceFromBottom < 100
|
|
}, [])
|
|
|
|
// Scroll to bottom on new messages (if user is near bottom)
|
|
useEffect(() => {
|
|
const el = scrollRef.current
|
|
if (!el) return
|
|
|
|
const isNewMessage = messages.length !== prevMessageCountRef.current
|
|
prevMessageCountRef.current = messages.length
|
|
|
|
if (isNearBottomRef.current) {
|
|
el.scrollTop = el.scrollHeight
|
|
}
|
|
|
|
// If a new message arrives while scrolled up, still update the count but don't scroll
|
|
void isNewMessage
|
|
}, [messages])
|
|
|
|
return (
|
|
<div
|
|
ref={scrollRef}
|
|
onScroll={handleScroll}
|
|
className="flex-1 overflow-y-auto px-4 py-4 space-y-4"
|
|
style={fontSize ? { fontSize: `${fontSize}px` } : undefined}
|
|
>
|
|
{messages.map((msg) => (
|
|
<ChatBubble key={msg.id} message={msg} onSubmitPrompt={onSubmitPrompt} />
|
|
))}
|
|
{/* Bottom spacer for scroll anchor */}
|
|
<div className="h-2" />
|
|
</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 /gsd 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: GSDActionDef) => void
|
|
}) {
|
|
const autoActive = useGSDWorkspaceState().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
|
|
}, [])
|
|
|
|
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
|
|
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
|
|
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
|
|
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
|
|
aria-label="More GSD 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
|
|
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 GSD session…
|
|
</p>
|
|
) : primaryAction && onPrimaryAction ? (
|
|
<div className="mt-4">
|
|
<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 GSD 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 } = useGSDWorkspaceActions()
|
|
const isSubmitting = useGSDWorkspaceState().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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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"
|
|
autoFocus
|
|
/>
|
|
<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: GSDActionDef) => 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 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 = tool.name === "edit" ? <FileEdit className="h-3.5 w-3.5" />
|
|
: tool.name === "write" ? <FilePlus className="h-3.5 w-3.5" />
|
|
: <Terminal className="h-3.5 w-3.5" />
|
|
|
|
const label = tool.name === "edit" ? "Edit"
|
|
: tool.name === "write" ? "Write"
|
|
: tool.name === "bash" ? "$"
|
|
: tool.name
|
|
|
|
// For bash, show the command
|
|
const bashCommand = tool.name === "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") ?? ""
|
|
|
|
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
|
|
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 = useGSDWorkspaceState()
|
|
const { submitInput, sendCommand, pushChatUserMessage } = useGSDWorkspaceActions()
|
|
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 Auto", icon: Square }
|
|
}
|
|
if (autoPaused) {
|
|
return { label: "Resume Auto", 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 Auto", 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,
|
|
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
|
|
}
|
|
}, [timeline])
|
|
|
|
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,
|
|
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>
|
|
)
|
|
}
|