- Rename native Rust crates: gsd-engine → forge-engine, gsd-ast → forge-ast, gsd-grep → forge-grep - Update all crate dependencies (Cargo.toml, .rs source) and N-API artifacts - Mass rename log prefix [gsd] → [forge] across 81 files (scripts, src/, extensions, tests) - Rename log prefix "gsd-db:" → "forge-db:" in template literals - Update nix flake: add sf-run-native devShell with Rust toolchain for native addon builds - Update CI workflow artifact names (build-native.yml) - Verify only packages/native/* touched (no upstream pi-* packages renamed) Rationale: Complete gsd-2 → singularity-forge rebrand (2026-04-15). Native addon is sf-run-specific; all gsd-prefixed logging and crate names must align with new identity. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2346 lines
87 KiB
TypeScript
2346 lines
87 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/sf-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 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
|
|
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
|
|
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>
|
|
)
|
|
}
|