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:
tao.chen
2026-07-06 11:30:43 +08:00
parent 2597d0d60c
commit a633af272b
11 changed files with 1610 additions and 4 deletions
+4
View File
@@ -29,8 +29,11 @@
"react": "^19.1.0",
"react-dom": "^19.1.0",
"react-hook-form": "^7.60.0",
"react-markdown": "^10.1.0",
"react-resizable-panels": "^3.0.2",
"react-router": "^7.7.0",
"react-syntax-highlighter": "^16.1.1",
"remark-gfm": "^4.0.1",
"tailwind-merge": "^3.3.0",
"tw-animate-css": "^1.3.4",
"zod": "^4.0.0",
@@ -42,6 +45,7 @@
"@types/node": "^24.0.10",
"@types/react": "^19.1.8",
"@types/react-dom": "^19.1.6",
"@types/react-syntax-highlighter": "^15.5.13",
"@vitejs/plugin-react": "^4.6.2",
"eslint": "^9.30.1",
"eslint-plugin-react-hooks": "^5.2.0",
+979
View File
File diff suppressed because it is too large Load Diff
+2
View File
@@ -1,3 +1,5 @@
packages:
- "."
allowBuilds:
esbuild: false
onlyBuiltDependencies:
+220
View File
@@ -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>
);
}
+115
View File
@@ -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>
);
}
+43
View File
@@ -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>
);
}
+48 -3
View File
@@ -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>
</>
)}
+30 -1
View File
@@ -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"
+149
View File
@@ -0,0 +1,149 @@
import {
useMutation,
useQuery,
useQueryClient,
type UseMutationResult,
type UseQueryResult,
} from "@tanstack/react-query";
import { apiClient } from "@/lib/api/client";
export interface AcpStatus {
workspaceId: string;
ready: boolean;
sessionId?: string;
running: boolean;
pid?: number;
error?: string;
}
export interface AcpMessage {
role: "user" | "agent";
text: string;
time: string;
}
export interface AcpHistoryResponse {
sessionId: string;
messages: AcpMessage[];
}
export interface AcpPromptResponse {
sessionId: string;
stopReason: string;
text: string;
}
function acpStatusQueryKey(workspaceId: string) {
return ["workspaces", workspaceId, "acp", "status"] as const;
}
function acpHistoryQueryKey(workspaceId: string) {
return ["workspaces", workspaceId, "acp", "history"] as const;
}
async function fetchAcpStatus(workspaceId: string): Promise<AcpStatus> {
const res = await apiClient(
`/api/workspaces/${encodeURIComponent(workspaceId)}/acp/status`,
);
if (!res.ok) {
throw new Error(`Failed to fetch ACP status: ${res.status}`);
}
return (await res.json()) as AcpStatus;
}
async function fetchAcpHistory(
workspaceId: string,
): Promise<AcpHistoryResponse> {
const res = await apiClient(
`/api/workspaces/${encodeURIComponent(workspaceId)}/acp/history`,
);
if (!res.ok) {
throw new Error(`Failed to fetch ACP history: ${res.status}`);
}
return (await res.json()) as AcpHistoryResponse;
}
export function useAcpStatus(
workspaceId: string | null,
): UseQueryResult<AcpStatus, Error> {
return useQuery({
queryKey: acpStatusQueryKey(workspaceId ?? ""),
queryFn: () => fetchAcpStatus(workspaceId!),
enabled: !!workspaceId,
refetchInterval: 3000,
});
}
export function useAcpHistory(
workspaceId: string | null,
): UseQueryResult<AcpHistoryResponse, Error> {
return useQuery({
queryKey: acpHistoryQueryKey(workspaceId ?? ""),
queryFn: () => fetchAcpHistory(workspaceId!),
enabled: !!workspaceId,
refetchInterval: 5000,
});
}
async function postAcpPrompt({
workspaceId,
content,
}: {
workspaceId: string;
content: string;
}): Promise<AcpPromptResponse> {
const res = await apiClient(
`/api/workspaces/${encodeURIComponent(workspaceId)}/acp/prompt`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ content }),
},
);
if (!res.ok) {
throw new Error(`Failed to send prompt: ${res.status}`);
}
return (await res.json()) as AcpPromptResponse;
}
export function useAcpPrompt(): UseMutationResult<
AcpPromptResponse,
Error,
{ workspaceId: string; content: string }
> {
const queryClient = useQueryClient();
return useMutation({
mutationFn: postAcpPrompt,
onSuccess: (_, { workspaceId }) => {
queryClient.invalidateQueries({
queryKey: acpHistoryQueryKey(workspaceId),
});
queryClient.invalidateQueries({
queryKey: acpStatusQueryKey(workspaceId),
});
},
});
}
async function postAcpCancel(workspaceId: string): Promise<void> {
const res = await apiClient(
`/api/workspaces/${encodeURIComponent(workspaceId)}/acp/cancel`,
{ method: "POST" },
);
if (!res.ok) {
throw new Error(`Failed to cancel prompt: ${res.status}`);
}
}
export function useAcpCancel(): UseMutationResult<void, Error, string> {
const queryClient = useQueryClient();
return useMutation({
mutationFn: postAcpCancel,
onSuccess: (_, workspaceId) => {
queryClient.invalidateQueries({
queryKey: acpStatusQueryKey(workspaceId),
});
},
});
}
+6
View File
@@ -1,14 +1,18 @@
import { create } from "zustand";
type BottomTab = "terminal" | "agent";
interface WorkspaceUiState {
activeFilePath: string;
terminalVisible: boolean;
sidebarCollapsed: boolean;
currentWorkspaceId: string | null;
bottomTab: BottomTab;
setActiveFilePath: (path: string) => void;
toggleTerminal: () => void;
toggleSidebar: () => void;
setCurrentWorkspaceId: (id: string | null) => void;
setBottomTab: (tab: BottomTab) => void;
}
export const useWorkspaceUiStore = create<WorkspaceUiState>((set) => ({
@@ -16,8 +20,10 @@ export const useWorkspaceUiStore = create<WorkspaceUiState>((set) => ({
terminalVisible: true,
sidebarCollapsed: false,
currentWorkspaceId: null,
bottomTab: "terminal",
setActiveFilePath: (path) => set({ activeFilePath: path }),
toggleTerminal: () => set((s) => ({ terminalVisible: !s.terminalVisible })),
toggleSidebar: () => set((s) => ({ sidebarCollapsed: !s.sidebarCollapsed })),
setCurrentWorkspaceId: (id) => set({ currentWorkspaceId: id }),
setBottomTab: (tab) => set({ bottomTab: tab }),
}));