feat(web): add ACP chat panel with tabs (Terminal | Agent)
- web: add react-markdown + remark-gfm + react-syntax-highlighter - web: new lib/api/acp.ts (useAcpStatus/History/Prompt/Cancel) - web: new components/acp/ (AcpPanel + MessageBubble + MarkdownView + ThinkingIndicator). Markdown rendering with code highlighting via Prism oneDark. Auto-scroll to bottom when near it; error toasts fade after 5s. - web: WorkspaceShell now renders a tabbed bottom panel (Terminal | Agent) when the bottom panel is visible. Tab state in workspace-ui-store. - web: StatusBar shows agent status (ready / initializing / error) when the ACP session is initialized. - web: pnpm-workspace.yaml: add packages ['.'] so pnpm install works in this workspace. Conversation: 019f3562-9382-7e00-ad92-3162341e9274
This commit is contained in:
@@ -0,0 +1,220 @@
|
||||
import { Ban, Send } from "lucide-react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { MessageBubble } from "./MessageBubble";
|
||||
import { ThinkingIndicator } from "./ThinkingIndicator";
|
||||
|
||||
import {
|
||||
useAcpCancel,
|
||||
useAcpHistory,
|
||||
useAcpPrompt,
|
||||
useAcpStatus,
|
||||
} from "@/lib/api/acp";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface AcpPanelProps {
|
||||
workspaceId: string | null;
|
||||
}
|
||||
|
||||
interface LocalError {
|
||||
id: number;
|
||||
message: string;
|
||||
}
|
||||
|
||||
function StatusPill({ status }: { status: ReturnType<typeof useAcpStatus> }) {
|
||||
let dotClass = "bg-muted-foreground";
|
||||
let label = "not started";
|
||||
|
||||
if (status.isPending) {
|
||||
dotClass = "bg-muted-foreground animate-pulse";
|
||||
label = "initializing…";
|
||||
} else if (status.isError) {
|
||||
dotClass = "bg-destructive";
|
||||
label = "error";
|
||||
} else if (status.data) {
|
||||
if (status.data.error) {
|
||||
dotClass = "bg-destructive";
|
||||
label = `error: ${status.data.error}`;
|
||||
} else if (status.data.ready) {
|
||||
dotClass = "bg-green-500";
|
||||
label = "ready";
|
||||
} else if (status.data.running) {
|
||||
dotClass = "bg-muted-foreground animate-pulse";
|
||||
label = "initializing…";
|
||||
} else {
|
||||
dotClass = "bg-muted-foreground";
|
||||
label = "not started";
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className={cn("h-2 w-2 shrink-0 rounded-full", dotClass)} />
|
||||
<span
|
||||
className={cn(
|
||||
"max-w-40 truncate",
|
||||
status.isError || status.data?.error
|
||||
? "text-destructive"
|
||||
: "text-muted-foreground",
|
||||
)}
|
||||
title={label}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function AcpPanel({ workspaceId }: AcpPanelProps) {
|
||||
const acpStatus = useAcpStatus(workspaceId);
|
||||
const acpHistory = useAcpHistory(workspaceId);
|
||||
const prompt = useAcpPrompt();
|
||||
const cancel = useAcpCancel();
|
||||
|
||||
const [input, setInput] = useState("");
|
||||
const [localErrors, setLocalErrors] = useState<LocalError[]>([]);
|
||||
const scrollRootRef = useRef<HTMLDivElement | null>(null);
|
||||
const sentinelRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
const messages = useMemo(
|
||||
() => acpHistory.data?.messages ?? [],
|
||||
[acpHistory.data],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (prompt.error) {
|
||||
const id = Date.now();
|
||||
setLocalErrors((prev) => [
|
||||
...prev,
|
||||
{ id, message: prompt.error?.message ?? "Failed to send prompt" },
|
||||
]);
|
||||
const timeout = window.setTimeout(() => {
|
||||
setLocalErrors((prev) => prev.filter((e) => e.id !== id));
|
||||
}, 5000);
|
||||
return () => window.clearTimeout(timeout);
|
||||
}
|
||||
}, [prompt.error]);
|
||||
|
||||
useEffect(() => {
|
||||
const root = scrollRootRef.current;
|
||||
const sentinel = sentinelRef.current;
|
||||
if (!root || !sentinel) return;
|
||||
|
||||
const viewport = root.querySelector(
|
||||
"[data-radix-scroll-area-viewport]",
|
||||
) as HTMLDivElement | null;
|
||||
if (!viewport) return;
|
||||
|
||||
const distanceFromBottom =
|
||||
viewport.scrollHeight - viewport.scrollTop - viewport.clientHeight;
|
||||
if (distanceFromBottom <= 80) {
|
||||
sentinel.scrollIntoView({ behavior: "smooth", block: "end" });
|
||||
}
|
||||
}, [messages, prompt.isPending]);
|
||||
|
||||
const handleSend = () => {
|
||||
const content = input.trim();
|
||||
if (!content || !workspaceId || prompt.isPending) return;
|
||||
prompt.mutate({ workspaceId, content });
|
||||
setInput("");
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSend();
|
||||
}
|
||||
};
|
||||
|
||||
const isReady = acpStatus.data?.ready === true;
|
||||
const sessionId = acpStatus.data?.sessionId;
|
||||
const trimmedSession =
|
||||
sessionId && sessionId.length > 12
|
||||
? `${sessionId.slice(0, 12)}…`
|
||||
: sessionId;
|
||||
|
||||
if (!workspaceId) {
|
||||
return (
|
||||
<section className="flex h-full w-full items-center justify-center bg-background text-sm text-muted-foreground">
|
||||
Select a workspace to use the agent.
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="flex h-full w-full flex-col overflow-hidden bg-background">
|
||||
<header className="flex h-8 shrink-0 items-center justify-between border-b border-border bg-sidebar px-3 text-xs">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium text-foreground">Agent</span>
|
||||
{sessionId && (
|
||||
<span
|
||||
className="font-mono text-[10px] text-muted-foreground"
|
||||
title={sessionId}
|
||||
>
|
||||
sess:{trimmedSession}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<StatusPill status={acpStatus} />
|
||||
</header>
|
||||
|
||||
<ScrollArea ref={scrollRootRef} className="flex-1">
|
||||
<div className="flex flex-col gap-3 p-3">
|
||||
{messages.length === 0 && isReady && (
|
||||
<div className="flex flex-1 items-center justify-center py-8 text-sm text-muted-foreground">
|
||||
Send a message to start the conversation.
|
||||
</div>
|
||||
)}
|
||||
{messages.map((message, index) => (
|
||||
<MessageBubble key={index} message={message} />
|
||||
))}
|
||||
{prompt.isPending && <ThinkingIndicator />}
|
||||
{localErrors.map((err) => (
|
||||
<div
|
||||
key={err.id}
|
||||
className="text-center text-xs text-destructive"
|
||||
>
|
||||
{err.message}
|
||||
</div>
|
||||
))}
|
||||
<div ref={sentinelRef} />
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
<div className="flex h-12 shrink-0 items-center gap-2 border-t border-border bg-background px-3">
|
||||
<textarea
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Ask the agent…"
|
||||
rows={1}
|
||||
maxLength={4000}
|
||||
disabled={!workspaceId}
|
||||
className="max-h-32 min-h-8 flex-1 resize-none rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
|
||||
/>
|
||||
{prompt.isPending ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => workspaceId && cancel.mutate(workspaceId)}
|
||||
disabled={cancel.isPending}
|
||||
>
|
||||
<Ban className="size-4" aria-hidden="true" />
|
||||
<span className="sr-only">Cancel</span>
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleSend}
|
||||
disabled={!input.trim() || prompt.isPending}
|
||||
>
|
||||
<Send className="size-4" aria-hidden="true" />
|
||||
<span className="sr-only">Send</span>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import type { ComponentPropsWithoutRef, CSSProperties } from "react";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
|
||||
import { oneDark } from "react-syntax-highlighter/dist/esm/styles/prism";
|
||||
import remarkGfm from "remark-gfm";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface CodeProps extends ComponentPropsWithoutRef<"code"> {
|
||||
inline?: boolean;
|
||||
node?: unknown;
|
||||
}
|
||||
|
||||
function Code({ inline, className, children, node: _node }: CodeProps) {
|
||||
void _node;
|
||||
const match = /language-(\w+)/.exec(className || "");
|
||||
if (!inline && match) {
|
||||
return (
|
||||
<SyntaxHighlighter
|
||||
// react-syntax-highlighter types the style union too broadly;
|
||||
// oneDark is the object-of-CSSProperties shape the component expects.
|
||||
style={oneDark as { [key: string]: CSSProperties }}
|
||||
language={match[1]}
|
||||
PreTag="div"
|
||||
className={cn("rounded-md text-xs", className)}
|
||||
>
|
||||
{String(children).replace(/\n$/, "")}
|
||||
</SyntaxHighlighter>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<code
|
||||
className={cn(
|
||||
"rounded bg-muted px-1 py-0.5 font-mono text-xs",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</code>
|
||||
);
|
||||
}
|
||||
|
||||
interface MarkdownViewProps {
|
||||
text: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function MarkdownView({ text, className }: MarkdownViewProps) {
|
||||
return (
|
||||
<div className={cn("text-sm", className)}>
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
components={{
|
||||
code: Code,
|
||||
p: ({ children }) => <p className="mb-2 last:mb-0">{children}</p>,
|
||||
ul: ({ children }) => (
|
||||
<ul className="mb-2 list-inside list-disc last:mb-0">{children}</ul>
|
||||
),
|
||||
ol: ({ children }) => (
|
||||
<ol className="mb-2 list-inside list-decimal last:mb-0">
|
||||
{children}
|
||||
</ol>
|
||||
),
|
||||
li: ({ children }) => <li className="mb-0.5">{children}</li>,
|
||||
h1: ({ children }) => (
|
||||
<h1 className="mb-2 text-base font-semibold">{children}</h1>
|
||||
),
|
||||
h2: ({ children }) => (
|
||||
<h2 className="mb-2 text-sm font-semibold">{children}</h2>
|
||||
),
|
||||
h3: ({ children }) => (
|
||||
<h3 className="mb-1 text-sm font-semibold">{children}</h3>
|
||||
),
|
||||
pre: ({ children }) => (
|
||||
<pre className="mb-2 overflow-x-auto rounded-md last:mb-0">
|
||||
{children}
|
||||
</pre>
|
||||
),
|
||||
blockquote: ({ children }) => (
|
||||
<blockquote className="border-l-2 border-border pl-3 italic text-muted-foreground">
|
||||
{children}
|
||||
</blockquote>
|
||||
),
|
||||
a: ({ href, children }) => (
|
||||
<a
|
||||
href={href}
|
||||
className="text-primary underline"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
table: ({ children }) => (
|
||||
<table className="mb-2 w-full border-collapse text-xs last:mb-0">
|
||||
{children}
|
||||
</table>
|
||||
),
|
||||
thead: ({ children }) => <thead className="bg-muted">{children}</thead>,
|
||||
th: ({ children }) => (
|
||||
<th className="border border-border px-2 py-1 text-left font-semibold">
|
||||
{children}
|
||||
</th>
|
||||
),
|
||||
td: ({ children }) => (
|
||||
<td className="border border-border px-2 py-1">{children}</td>
|
||||
),
|
||||
}}
|
||||
>
|
||||
{text}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { MarkdownView } from "./MarkdownView";
|
||||
|
||||
import type { AcpMessage } from "@/lib/api/acp";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface MessageBubbleProps {
|
||||
message: AcpMessage;
|
||||
}
|
||||
|
||||
export function MessageBubble({ message }: MessageBubbleProps) {
|
||||
const isUser = message.role === "user";
|
||||
const time = new Date(message.time).toLocaleTimeString([], {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex w-full",
|
||||
isUser ? "justify-end" : "justify-start",
|
||||
)}
|
||||
>
|
||||
<div className="flex max-w-prose flex-col gap-0.5">
|
||||
<div
|
||||
className={cn(
|
||||
"rounded-lg px-3 py-2",
|
||||
isUser
|
||||
? "bg-accent text-accent-foreground"
|
||||
: "bg-muted text-foreground",
|
||||
)}
|
||||
>
|
||||
{isUser ? (
|
||||
<p className="whitespace-pre-wrap text-sm">{message.text}</p>
|
||||
) : (
|
||||
<MarkdownView text={message.text} />
|
||||
)}
|
||||
</div>
|
||||
<span className="px-1 text-[10px] text-muted-foreground">{time}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
export function ThinkingIndicator() {
|
||||
return (
|
||||
<div className="flex w-full justify-start">
|
||||
<div className="flex max-w-prose items-center gap-1.5 rounded-lg bg-muted px-3 py-2 text-xs text-muted-foreground">
|
||||
<span>thinking</span>
|
||||
<span className="flex gap-0.5">
|
||||
<span className="size-1 animate-bounce rounded-full bg-muted-foreground [animation-delay:-0.3s]" />
|
||||
<span className="size-1 animate-bounce rounded-full bg-muted-foreground [animation-delay:-0.15s]" />
|
||||
<span className="size-1 animate-bounce rounded-full bg-muted-foreground" />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,10 +1,11 @@
|
||||
import { useMemo } from "react";
|
||||
import { Panel, PanelGroup, PanelResizeHandle } from "react-resizable-panels";
|
||||
import { PanelLeftClose, PanelLeftOpen, Play, RotateCw, Square, Terminal } from "lucide-react";
|
||||
import { Bot, PanelLeftClose, PanelLeftOpen, Play, RotateCw, Square, Terminal } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { AcpPanel } from "@/components/acp/AcpPanel";
|
||||
import { EditorPanel } from "@/components/editor/EditorPanel";
|
||||
import { TerminalPanel } from "@/components/terminal/TerminalPanel";
|
||||
import { FileExplorerPanel } from "@/components/workspace/FileExplorerPanel";
|
||||
@@ -22,6 +23,51 @@ interface WorkspaceShellProps {
|
||||
workspaceId: string | null;
|
||||
}
|
||||
|
||||
function BottomTabs({ workspaceId }: { workspaceId: string }) {
|
||||
const bottomTab = useWorkspaceUiStore((s) => s.bottomTab);
|
||||
const setBottomTab = useWorkspaceUiStore((s) => s.setBottomTab);
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col overflow-hidden bg-background">
|
||||
<div className="flex h-7 shrink-0 items-center gap-1 border-b border-border bg-sidebar px-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setBottomTab("terminal")}
|
||||
className={cn(
|
||||
"flex h-full items-center gap-1.5 px-2 text-xs transition-colors hover:text-foreground",
|
||||
bottomTab === "terminal"
|
||||
? "text-foreground"
|
||||
: "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
<Terminal className="size-3" aria-hidden="true" />
|
||||
Terminal
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setBottomTab("agent")}
|
||||
className={cn(
|
||||
"flex h-full items-center gap-1.5 px-2 text-xs transition-colors hover:text-foreground",
|
||||
bottomTab === "agent"
|
||||
? "text-foreground"
|
||||
: "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
<Bot className="size-3" aria-hidden="true" />
|
||||
Agent
|
||||
</button>
|
||||
</div>
|
||||
<div className="min-h-0 flex-1">
|
||||
{bottomTab === "terminal" ? (
|
||||
<TerminalPanel workspaceId={workspaceId} />
|
||||
) : (
|
||||
<AcpPanel workspaceId={workspaceId} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function WorkspaceShell({ workspaceId }: WorkspaceShellProps) {
|
||||
const activeFilePath = useWorkspaceUiStore((s) => s.activeFilePath);
|
||||
const sidebarCollapsed = useWorkspaceUiStore((s) => s.sidebarCollapsed);
|
||||
@@ -131,12 +177,11 @@ export function WorkspaceShell({ workspaceId }: WorkspaceShellProps) {
|
||||
<EditorPanel workspaceId={workspaceId} path={activeFilePath || null} />
|
||||
</Panel>
|
||||
|
||||
{/* Terminal panel */}
|
||||
{terminalVisible && (
|
||||
<>
|
||||
<PanelResizeHandle className="h-1 bg-border transition-colors hover:bg-accent" />
|
||||
<Panel defaultSize={30} minSize={10} maxSize={70}>
|
||||
<TerminalPanel workspaceId={workspaceId} />
|
||||
<BottomTabs workspaceId={workspaceId} />
|
||||
</Panel>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { Terminal, FileCode2 } from "lucide-react";
|
||||
import { Bot, FileCode2, Terminal } from "lucide-react";
|
||||
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useWorkspaceUiStore } from "@/lib/store/workspace-ui-store";
|
||||
import { useAcpStatus } from "@/lib/api/acp";
|
||||
import { useProcessStatus } from "@/lib/api/process";
|
||||
|
||||
interface StatusBarProps {
|
||||
@@ -15,6 +16,17 @@ export function StatusBar({ activeFilePath, workspaceId }: StatusBarProps) {
|
||||
const toggleTerminal = useWorkspaceUiStore((s) => s.toggleTerminal);
|
||||
|
||||
const status = useProcessStatus(workspaceId);
|
||||
const acpStatus = useAcpStatus(workspaceId);
|
||||
const acpData = acpStatus.data;
|
||||
const showAgentPill =
|
||||
workspaceId &&
|
||||
(acpData?.running || acpData?.ready || Boolean(acpData?.error));
|
||||
|
||||
const agentLabel = acpData?.error
|
||||
? `agent: ${acpData.error.slice(0, 40)}${acpData.error.length > 40 ? "…" : ""}`
|
||||
: acpData?.ready
|
||||
? "agent: ready"
|
||||
: "agent: initializing…";
|
||||
|
||||
return (
|
||||
<footer className="flex h-6 shrink-0 items-center border-t border-border bg-sidebar px-3 text-[11px] text-muted-foreground">
|
||||
@@ -55,6 +67,23 @@ export function StatusBar({ activeFilePath, workspaceId }: StatusBarProps) {
|
||||
</>
|
||||
)}
|
||||
<span className="font-medium">codespace</span>
|
||||
{showAgentPill && (
|
||||
<>
|
||||
<Separator orientation="vertical" className="mx-2 h-3" />
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Bot className="size-3" aria-hidden="true" />
|
||||
<span
|
||||
className={cn(
|
||||
"max-w-48 truncate",
|
||||
acpData?.error ? "text-destructive" : "text-muted-foreground",
|
||||
)}
|
||||
title={agentLabel}
|
||||
>
|
||||
{agentLabel}
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<div className="ml-auto flex items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
|
||||
Reference in New Issue
Block a user