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:
@@ -29,8 +29,11 @@
|
|||||||
"react": "^19.1.0",
|
"react": "^19.1.0",
|
||||||
"react-dom": "^19.1.0",
|
"react-dom": "^19.1.0",
|
||||||
"react-hook-form": "^7.60.0",
|
"react-hook-form": "^7.60.0",
|
||||||
|
"react-markdown": "^10.1.0",
|
||||||
"react-resizable-panels": "^3.0.2",
|
"react-resizable-panels": "^3.0.2",
|
||||||
"react-router": "^7.7.0",
|
"react-router": "^7.7.0",
|
||||||
|
"react-syntax-highlighter": "^16.1.1",
|
||||||
|
"remark-gfm": "^4.0.1",
|
||||||
"tailwind-merge": "^3.3.0",
|
"tailwind-merge": "^3.3.0",
|
||||||
"tw-animate-css": "^1.3.4",
|
"tw-animate-css": "^1.3.4",
|
||||||
"zod": "^4.0.0",
|
"zod": "^4.0.0",
|
||||||
@@ -42,6 +45,7 @@
|
|||||||
"@types/node": "^24.0.10",
|
"@types/node": "^24.0.10",
|
||||||
"@types/react": "^19.1.8",
|
"@types/react": "^19.1.8",
|
||||||
"@types/react-dom": "^19.1.6",
|
"@types/react-dom": "^19.1.6",
|
||||||
|
"@types/react-syntax-highlighter": "^15.5.13",
|
||||||
"@vitejs/plugin-react": "^4.6.2",
|
"@vitejs/plugin-react": "^4.6.2",
|
||||||
"eslint": "^9.30.1",
|
"eslint": "^9.30.1",
|
||||||
"eslint-plugin-react-hooks": "^5.2.0",
|
"eslint-plugin-react-hooks": "^5.2.0",
|
||||||
|
|||||||
Generated
+979
File diff suppressed because it is too large
Load Diff
@@ -1,3 +1,5 @@
|
|||||||
|
packages:
|
||||||
|
- "."
|
||||||
allowBuilds:
|
allowBuilds:
|
||||||
esbuild: false
|
esbuild: false
|
||||||
onlyBuiltDependencies:
|
onlyBuiltDependencies:
|
||||||
|
|||||||
@@ -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 { useMemo } from "react";
|
||||||
import { Panel, PanelGroup, PanelResizeHandle } from "react-resizable-panels";
|
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 { Button } from "@/components/ui/button";
|
||||||
import { Separator } from "@/components/ui/separator";
|
import { Separator } from "@/components/ui/separator";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
import { AcpPanel } from "@/components/acp/AcpPanel";
|
||||||
import { EditorPanel } from "@/components/editor/EditorPanel";
|
import { EditorPanel } from "@/components/editor/EditorPanel";
|
||||||
import { TerminalPanel } from "@/components/terminal/TerminalPanel";
|
import { TerminalPanel } from "@/components/terminal/TerminalPanel";
|
||||||
import { FileExplorerPanel } from "@/components/workspace/FileExplorerPanel";
|
import { FileExplorerPanel } from "@/components/workspace/FileExplorerPanel";
|
||||||
@@ -22,6 +23,51 @@ interface WorkspaceShellProps {
|
|||||||
workspaceId: string | null;
|
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) {
|
export function WorkspaceShell({ workspaceId }: WorkspaceShellProps) {
|
||||||
const activeFilePath = useWorkspaceUiStore((s) => s.activeFilePath);
|
const activeFilePath = useWorkspaceUiStore((s) => s.activeFilePath);
|
||||||
const sidebarCollapsed = useWorkspaceUiStore((s) => s.sidebarCollapsed);
|
const sidebarCollapsed = useWorkspaceUiStore((s) => s.sidebarCollapsed);
|
||||||
@@ -131,12 +177,11 @@ export function WorkspaceShell({ workspaceId }: WorkspaceShellProps) {
|
|||||||
<EditorPanel workspaceId={workspaceId} path={activeFilePath || null} />
|
<EditorPanel workspaceId={workspaceId} path={activeFilePath || null} />
|
||||||
</Panel>
|
</Panel>
|
||||||
|
|
||||||
{/* Terminal panel */}
|
|
||||||
{terminalVisible && (
|
{terminalVisible && (
|
||||||
<>
|
<>
|
||||||
<PanelResizeHandle className="h-1 bg-border transition-colors hover:bg-accent" />
|
<PanelResizeHandle className="h-1 bg-border transition-colors hover:bg-accent" />
|
||||||
<Panel defaultSize={30} minSize={10} maxSize={70}>
|
<Panel defaultSize={30} minSize={10} maxSize={70}>
|
||||||
<TerminalPanel workspaceId={workspaceId} />
|
<BottomTabs workspaceId={workspaceId} />
|
||||||
</Panel>
|
</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 { Separator } from "@/components/ui/separator";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { useWorkspaceUiStore } from "@/lib/store/workspace-ui-store";
|
import { useWorkspaceUiStore } from "@/lib/store/workspace-ui-store";
|
||||||
|
import { useAcpStatus } from "@/lib/api/acp";
|
||||||
import { useProcessStatus } from "@/lib/api/process";
|
import { useProcessStatus } from "@/lib/api/process";
|
||||||
|
|
||||||
interface StatusBarProps {
|
interface StatusBarProps {
|
||||||
@@ -15,6 +16,17 @@ export function StatusBar({ activeFilePath, workspaceId }: StatusBarProps) {
|
|||||||
const toggleTerminal = useWorkspaceUiStore((s) => s.toggleTerminal);
|
const toggleTerminal = useWorkspaceUiStore((s) => s.toggleTerminal);
|
||||||
|
|
||||||
const status = useProcessStatus(workspaceId);
|
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 (
|
return (
|
||||||
<footer className="flex h-6 shrink-0 items-center border-t border-border bg-sidebar px-3 text-[11px] text-muted-foreground">
|
<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>
|
<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">
|
<div className="ml-auto flex items-center gap-1">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -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),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,14 +1,18 @@
|
|||||||
import { create } from "zustand";
|
import { create } from "zustand";
|
||||||
|
|
||||||
|
type BottomTab = "terminal" | "agent";
|
||||||
|
|
||||||
interface WorkspaceUiState {
|
interface WorkspaceUiState {
|
||||||
activeFilePath: string;
|
activeFilePath: string;
|
||||||
terminalVisible: boolean;
|
terminalVisible: boolean;
|
||||||
sidebarCollapsed: boolean;
|
sidebarCollapsed: boolean;
|
||||||
currentWorkspaceId: string | null;
|
currentWorkspaceId: string | null;
|
||||||
|
bottomTab: BottomTab;
|
||||||
setActiveFilePath: (path: string) => void;
|
setActiveFilePath: (path: string) => void;
|
||||||
toggleTerminal: () => void;
|
toggleTerminal: () => void;
|
||||||
toggleSidebar: () => void;
|
toggleSidebar: () => void;
|
||||||
setCurrentWorkspaceId: (id: string | null) => void;
|
setCurrentWorkspaceId: (id: string | null) => void;
|
||||||
|
setBottomTab: (tab: BottomTab) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useWorkspaceUiStore = create<WorkspaceUiState>((set) => ({
|
export const useWorkspaceUiStore = create<WorkspaceUiState>((set) => ({
|
||||||
@@ -16,8 +20,10 @@ export const useWorkspaceUiStore = create<WorkspaceUiState>((set) => ({
|
|||||||
terminalVisible: true,
|
terminalVisible: true,
|
||||||
sidebarCollapsed: false,
|
sidebarCollapsed: false,
|
||||||
currentWorkspaceId: null,
|
currentWorkspaceId: null,
|
||||||
|
bottomTab: "terminal",
|
||||||
setActiveFilePath: (path) => set({ activeFilePath: path }),
|
setActiveFilePath: (path) => set({ activeFilePath: path }),
|
||||||
toggleTerminal: () => set((s) => ({ terminalVisible: !s.terminalVisible })),
|
toggleTerminal: () => set((s) => ({ terminalVisible: !s.terminalVisible })),
|
||||||
toggleSidebar: () => set((s) => ({ sidebarCollapsed: !s.sidebarCollapsed })),
|
toggleSidebar: () => set((s) => ({ sidebarCollapsed: !s.sidebarCollapsed })),
|
||||||
setCurrentWorkspaceId: (id) => set({ currentWorkspaceId: id }),
|
setCurrentWorkspaceId: (id) => set({ currentWorkspaceId: id }),
|
||||||
|
setBottomTab: (tab) => set({ bottomTab: tab }),
|
||||||
}));
|
}));
|
||||||
|
|||||||
Reference in New Issue
Block a user