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-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",
|
||||
|
||||
Generated
+979
File diff suppressed because it is too large
Load Diff
@@ -1,3 +1,5 @@
|
||||
packages:
|
||||
- "."
|
||||
allowBuilds:
|
||||
esbuild: false
|
||||
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 { 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"
|
||||
|
||||
@@ -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";
|
||||
|
||||
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 }),
|
||||
}));
|
||||
|
||||
Reference in New Issue
Block a user