456 lines
19 KiB
TypeScript
456 lines
19 KiB
TypeScript
|
|
import { FormEvent, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||
|
|
import { api, type AgentDetail, type AgentRow, type TeamEvent, type TranscriptItem } from "./api";
|
||
|
|
import { SandButton, SandIcon, SandIconButton } from "./grok/sand-kit-primitives";
|
||
|
|
import { RUNTIME_THEME_CLASS } from "./grok/runtime-theme-token-installer";
|
||
|
|
|
||
|
|
const PROMPT_ATTACH_CLASS = "sand-prompt-attach sand-2lah0s sand-i07v4r sand-uo9n5k sand-1vhj7fz sand-4b2ntj sand-1dsx48b sand-1hc1fzr sand-1lfpgzf sand-1ypdohk";
|
||
|
|
const PROMPT_SEND_CLASS = "sand-prompt-send sand-2lah0s sand-mak4db sand-1tc92z3 sand-1hc1fzr sand-1p5hr7d sand-1lfpgzf sand-1ypdohk";
|
||
|
|
const COMPUTER_HEADER_CLASS = "sand-chat-header__computer";
|
||
|
|
const DETAILS_ID = "sand-conversation-details";
|
||
|
|
|
||
|
|
type Activity = { id: string; text: string };
|
||
|
|
|
||
|
|
export function App() {
|
||
|
|
const [agents, setAgents] = useState<AgentRow[]>([]);
|
||
|
|
const [activeId, setActiveId] = useState(localStorage.getItem("grokboy.agent") || "");
|
||
|
|
const [detail, setDetail] = useState<AgentDetail | null>(null);
|
||
|
|
const [transcript, setTranscript] = useState<TranscriptItem[]>([]);
|
||
|
|
const [activity, setActivity] = useState<Activity[]>([]);
|
||
|
|
const [question, setQuestion] = useState<{ prompt: string; options: string[] } | null>(null);
|
||
|
|
const [status, setStatus] = useState("Ready");
|
||
|
|
const [draft, setDraft] = useState("");
|
||
|
|
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||
|
|
const [computerOpen, setComputerOpen] = useState(false);
|
||
|
|
const [computerUrl, setComputerUrl] = useState("");
|
||
|
|
const [computerStatus, setComputerStatus] = useState("");
|
||
|
|
const [typing, setTyping] = useState(false);
|
||
|
|
const [showCreate, setShowCreate] = useState(false);
|
||
|
|
const [newName, setNewName] = useState("");
|
||
|
|
const [query, setQuery] = useState("");
|
||
|
|
const [searchOpen, setSearchOpen] = useState(false);
|
||
|
|
const searchRef = useRef<HTMLInputElement>(null);
|
||
|
|
const scroller = useRef<HTMLDivElement>(null);
|
||
|
|
const activeIdRef = useRef(activeId);
|
||
|
|
activeIdRef.current = activeId;
|
||
|
|
|
||
|
|
const active = useMemo(
|
||
|
|
() => agents.find((a) => a.id === activeId || a.name === activeId) || null,
|
||
|
|
[agents, activeId]
|
||
|
|
);
|
||
|
|
|
||
|
|
const visibleAgents = useMemo(() => {
|
||
|
|
const needle = query.trim().toLowerCase();
|
||
|
|
if (!needle) return agents;
|
||
|
|
return agents.filter(
|
||
|
|
(agent) =>
|
||
|
|
agent.name.toLowerCase().includes(needle) ||
|
||
|
|
(agent.preview || "").toLowerCase().includes(needle)
|
||
|
|
);
|
||
|
|
}, [agents, query]);
|
||
|
|
|
||
|
|
const loadAgents = useCallback(async () => {
|
||
|
|
const data = await api.agents();
|
||
|
|
setAgents(data.agents || []);
|
||
|
|
return data.agents || [];
|
||
|
|
}, []);
|
||
|
|
|
||
|
|
const openAgent = useCallback(async (id: string) => {
|
||
|
|
setActiveId(id);
|
||
|
|
localStorage.setItem("grokboy.agent", id);
|
||
|
|
setSidebarOpen(false);
|
||
|
|
setTyping(false);
|
||
|
|
setStatus("Loading");
|
||
|
|
try {
|
||
|
|
const data = await api.agent(id);
|
||
|
|
setDetail(data);
|
||
|
|
setTranscript(data.transcript || []);
|
||
|
|
setQuestion(null);
|
||
|
|
setActivity([]);
|
||
|
|
setStatus(data.running ? "Working" : "Ready");
|
||
|
|
} catch (err) {
|
||
|
|
setDetail(null);
|
||
|
|
setTranscript([]);
|
||
|
|
setStatus(err instanceof Error ? err.message : String(err));
|
||
|
|
}
|
||
|
|
}, []);
|
||
|
|
|
||
|
|
useEffect(() => {
|
||
|
|
loadAgents()
|
||
|
|
.then((list) => {
|
||
|
|
const remembered = localStorage.getItem("grokboy.agent");
|
||
|
|
const pick = list.find((a) => a.id === remembered || a.name === remembered) || list[0];
|
||
|
|
if (pick) return openAgent(pick.id);
|
||
|
|
})
|
||
|
|
.catch((err) => setStatus(String(err.message)));
|
||
|
|
}, [loadAgents, openAgent]);
|
||
|
|
|
||
|
|
useEffect(() => {
|
||
|
|
if (!activeId) return;
|
||
|
|
const sourceAgent = activeId;
|
||
|
|
const source = new EventSource(`/api/agents/${encodeURIComponent(sourceAgent)}/events`);
|
||
|
|
source.onmessage = (ev) => {
|
||
|
|
if (activeIdRef.current !== sourceAgent) return;
|
||
|
|
let payload: { events?: TeamEvent[] };
|
||
|
|
try {
|
||
|
|
payload = JSON.parse(ev.data);
|
||
|
|
} catch {
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
for (const event of payload.events || []) handleEvent(event);
|
||
|
|
};
|
||
|
|
const poll = window.setInterval(() => {
|
||
|
|
loadAgents().catch(() => undefined);
|
||
|
|
}, 4000);
|
||
|
|
return () => {
|
||
|
|
source.close();
|
||
|
|
window.clearInterval(poll);
|
||
|
|
};
|
||
|
|
}, [activeId, loadAgents]);
|
||
|
|
|
||
|
|
useEffect(() => {
|
||
|
|
scroller.current?.scrollTo(0, scroller.current.scrollHeight);
|
||
|
|
}, [transcript, activity, question, typing]);
|
||
|
|
|
||
|
|
function handleEvent(event: TeamEvent) {
|
||
|
|
const p = event.payload || {};
|
||
|
|
switch (event.kind) {
|
||
|
|
case "reply":
|
||
|
|
if (typeof p.message === "string" && p.message.trim()) {
|
||
|
|
setTranscript((cur) => [...cur, { role: "assistant", content: p.message as string }]);
|
||
|
|
}
|
||
|
|
setTyping(false);
|
||
|
|
setStatus(typeof p.verdict === "string" ? String(p.verdict) : "Ready");
|
||
|
|
setQuestion(null);
|
||
|
|
void loadAgents();
|
||
|
|
break;
|
||
|
|
case "runtime": {
|
||
|
|
const type = String(p.type || "");
|
||
|
|
if (type === "message" && typeof p.content === "string") {
|
||
|
|
const content = p.content;
|
||
|
|
setTranscript((cur) => [...cur, { role: "assistant", content }]);
|
||
|
|
} else if (type === "tool_started") {
|
||
|
|
setTyping(true);
|
||
|
|
pushActivity(`Started ${p.name}`);
|
||
|
|
} else if (type === "tool_finished") {
|
||
|
|
pushActivity(`${p.success ? "Done" : "Failed"} ${p.name}`);
|
||
|
|
} else if (type === "progress" || type === "status") {
|
||
|
|
pushActivity(String(p.message || type));
|
||
|
|
} else if (type === "question") {
|
||
|
|
const q = (p.question || {}) as { question?: string; reason?: string; options?: unknown[] };
|
||
|
|
setTyping(false);
|
||
|
|
setQuestion({
|
||
|
|
prompt: q.question || q.reason || "Needs a reply",
|
||
|
|
options: (q.options || [])
|
||
|
|
.map((o) =>
|
||
|
|
typeof o === "string"
|
||
|
|
? o
|
||
|
|
: String((o as { label?: string; value?: string }).label || (o as { value?: string }).value || "")
|
||
|
|
)
|
||
|
|
.filter(Boolean),
|
||
|
|
});
|
||
|
|
} else if (type === "waiting") {
|
||
|
|
setStatus(String(p.stage || "Waiting"));
|
||
|
|
}
|
||
|
|
break;
|
||
|
|
}
|
||
|
|
case "error":
|
||
|
|
setTyping(false);
|
||
|
|
pushActivity(String(p.message || JSON.stringify(p)));
|
||
|
|
setStatus("Failed");
|
||
|
|
break;
|
||
|
|
default:
|
||
|
|
break;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
function pushActivity(text: string) {
|
||
|
|
setActivity((cur) => [...cur.slice(-7), { id: `${Date.now()}-${text.slice(0, 16)}`, text }]);
|
||
|
|
}
|
||
|
|
|
||
|
|
async function onNewChat() {
|
||
|
|
setShowCreate(true);
|
||
|
|
}
|
||
|
|
|
||
|
|
function onOpenSearch() {
|
||
|
|
setSearchOpen(true);
|
||
|
|
queueMicrotask(() => searchRef.current?.focus());
|
||
|
|
}
|
||
|
|
|
||
|
|
async function onCreate(ev?: FormEvent) {
|
||
|
|
ev?.preventDefault();
|
||
|
|
const name = newName.trim();
|
||
|
|
if (!name) return;
|
||
|
|
try {
|
||
|
|
const created = await api.createAgent(name);
|
||
|
|
setNewName("");
|
||
|
|
setShowCreate(false);
|
||
|
|
await loadAgents();
|
||
|
|
await openAgent(created.id || created.name);
|
||
|
|
} catch (err) {
|
||
|
|
setStatus(err instanceof Error ? err.message : String(err));
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
async function onSend(text?: string, ev?: FormEvent) {
|
||
|
|
ev?.preventDefault();
|
||
|
|
const value = (text ?? draft).trim();
|
||
|
|
if (!value) return;
|
||
|
|
let id = activeId;
|
||
|
|
if (!id) {
|
||
|
|
const created = await api.createAgent(`agent_${Date.now().toString(36)}`);
|
||
|
|
id = created.id;
|
||
|
|
await loadAgents();
|
||
|
|
setActiveId(id);
|
||
|
|
localStorage.setItem("grokboy.agent", id);
|
||
|
|
}
|
||
|
|
setDraft("");
|
||
|
|
setTranscript((cur) => [...cur, { role: "user", content: value }]);
|
||
|
|
setQuestion(null);
|
||
|
|
setTyping(true);
|
||
|
|
setStatus("Working");
|
||
|
|
try {
|
||
|
|
await api.send(id, value);
|
||
|
|
} catch (err) {
|
||
|
|
setTyping(false);
|
||
|
|
setStatus(err instanceof Error ? err.message : String(err));
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
async function onStop() {
|
||
|
|
const id = activeId;
|
||
|
|
if (!id) return;
|
||
|
|
try {
|
||
|
|
await api.stop(id);
|
||
|
|
setTyping(false);
|
||
|
|
setStatus("Stopped");
|
||
|
|
setDetail((current) => (current ? { ...current, running: false } : current));
|
||
|
|
setAgents((current) => current.map((agent) => (agent.id === id ? { ...agent, running: false } : agent)));
|
||
|
|
} catch (err) {
|
||
|
|
setStatus(err instanceof Error ? err.message : String(err));
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
async function onComputer() {
|
||
|
|
if (computerOpen) {
|
||
|
|
setComputerOpen(false);
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
setComputerOpen(true);
|
||
|
|
setComputerStatus("Starting Grok Bot's Computer…");
|
||
|
|
try {
|
||
|
|
const data = await api.computer();
|
||
|
|
setComputerUrl(data.viewer_url);
|
||
|
|
setComputerStatus(data.ready ? "Grok Bot's Computer" : data.error || "Starting desktop…");
|
||
|
|
} catch (err) {
|
||
|
|
setComputerStatus(err instanceof Error ? err.message : String(err));
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
const hasPayload = draft.trim().length > 0;
|
||
|
|
const working = Boolean(typing || detail?.running || active?.running || status === "Working");
|
||
|
|
|
||
|
|
return (
|
||
|
|
<div className="sand-shell" data-runtime="browser" data-theme={RUNTIME_THEME_CLASS.dark}>
|
||
|
|
<aside aria-label="Agents" className={`sand-agents-sidebar${sidebarOpen ? " is-open" : ""}`}>
|
||
|
|
<header className="sand-agents-sidebar__header">
|
||
|
|
<div className="sand-agents-sidebar__new-actions">
|
||
|
|
<SandIconButton aria-label="New" className="sand-agents-sidebar__new" icon="plus" label="New" onClick={() => void onNewChat()} size="sm" title="New chat" />
|
||
|
|
</div>
|
||
|
|
</header>
|
||
|
|
<SandButton aria-label="Search" className="sand-agents-sidebar__search" onClick={onOpenSearch} size="md" variant="secondary">
|
||
|
|
<SandIcon name="search" size="md" />
|
||
|
|
Search
|
||
|
|
</SandButton>
|
||
|
|
{searchOpen ? (
|
||
|
|
<input
|
||
|
|
ref={searchRef}
|
||
|
|
className="sand-agents-search-field"
|
||
|
|
placeholder="Search agents"
|
||
|
|
value={query}
|
||
|
|
onChange={(e) => setQuery(e.target.value)}
|
||
|
|
onBlur={() => {
|
||
|
|
if (!query.trim()) setSearchOpen(false);
|
||
|
|
}}
|
||
|
|
onKeyDown={(e) => {
|
||
|
|
if (e.key === "Escape") {
|
||
|
|
setQuery("");
|
||
|
|
setSearchOpen(false);
|
||
|
|
}
|
||
|
|
}}
|
||
|
|
/>
|
||
|
|
) : null}
|
||
|
|
{showCreate ? (
|
||
|
|
<form className="sand-agents-create" onSubmit={(e) => void onCreate(e)}>
|
||
|
|
<input autoFocus value={newName} placeholder="Agent name" onChange={(e) => setNewName(e.target.value)} />
|
||
|
|
</form>
|
||
|
|
) : null}
|
||
|
|
<nav aria-label="Agent list" className="sand-agents-list" role="region" tabIndex={0}>
|
||
|
|
<div className="sand-agents-section">
|
||
|
|
<button type="button" className="sand-agents-section__header" aria-expanded="true">
|
||
|
|
<span>Agents</span>
|
||
|
|
<span aria-hidden="true" data-icon-name="chevron-right" data-size="sm" />
|
||
|
|
</button>
|
||
|
|
<div className="sand-agents-section__rows sand-agents-list__rows">
|
||
|
|
{visibleAgents.length === 0 ? <span className="sand-agents-section__empty">{agents.length === 0 ? "No agents yet" : "No matching agents"}</span> : null}
|
||
|
|
{visibleAgents.map((agent) => (
|
||
|
|
<button
|
||
|
|
key={agent.id}
|
||
|
|
type="button"
|
||
|
|
aria-label={agent.name}
|
||
|
|
aria-current={agent.id === active?.id ? "page" : undefined}
|
||
|
|
className="sand-agent-item"
|
||
|
|
data-active={agent.id === active?.id || undefined}
|
||
|
|
onClick={() => void openAgent(agent.id)}
|
||
|
|
>
|
||
|
|
<span className="sand-agent-item__avatar">{agent.name.slice(0, 1).toUpperCase()}</span>
|
||
|
|
<span className="sand-agent-item__body">
|
||
|
|
<strong className="sand-agent-item__name">{agent.name}</strong>
|
||
|
|
<small className="sand-agent-item__preview">{agent.running ? "Working" : agent.preview || "New chat"}</small>
|
||
|
|
</span>
|
||
|
|
<span className="sand-agent-item__trailing">
|
||
|
|
<span className={`sand-kit-status-dot ${agent.running ? "sand-1rm5x0x" : ""}`} data-status={agent.running ? "working" : "idle"} />
|
||
|
|
</span>
|
||
|
|
</button>
|
||
|
|
))}
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</nav>
|
||
|
|
<div className="sand-agents-sidebar__footer">
|
||
|
|
<div className="sand-agents-sidebar__account">
|
||
|
|
<button type="button" aria-label="GrokBoy">
|
||
|
|
<span>G</span>
|
||
|
|
<span>
|
||
|
|
<strong>GrokBoy</strong>
|
||
|
|
<small>local</small>
|
||
|
|
</span>
|
||
|
|
</button>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</aside>
|
||
|
|
|
||
|
|
<main className="sand-chat-stage">
|
||
|
|
<div aria-labelledby="sand-conversation-heading" className="sand-chat-header" role="group">
|
||
|
|
<button className="sand-chat-header__menu" type="button" aria-label="Agents" onClick={() => setSidebarOpen(true)}>☰</button>
|
||
|
|
<div className="sand-chat-header__identity">
|
||
|
|
<span className="sand-chat-header__avatar">{(active?.name || "G").slice(0, 1).toUpperCase()}</span>
|
||
|
|
<span id="sand-conversation-heading">{active?.name || "Select an agent"}</span>
|
||
|
|
{detail?.running || status === "Working" ? <small>Working</small> : null}
|
||
|
|
</div>
|
||
|
|
<div className="sand-chat-header__controls">
|
||
|
|
{working ? (
|
||
|
|
<SandIconButton
|
||
|
|
aria-label="Stop"
|
||
|
|
className="sand-chat-header__stop"
|
||
|
|
icon="stop"
|
||
|
|
label="Stop"
|
||
|
|
onClick={() => void onStop()}
|
||
|
|
size="md"
|
||
|
|
title="Stop"
|
||
|
|
/>
|
||
|
|
) : null}
|
||
|
|
<SandIconButton
|
||
|
|
aria-controls={DETAILS_ID}
|
||
|
|
aria-expanded={computerOpen}
|
||
|
|
className={`${computerOpen ? "sand-1qfxjfa sand-11n3mlv sand-18ti0zn sand-hn7xur " : ""}${COMPUTER_HEADER_CLASS}`}
|
||
|
|
data-computer-active={computerOpen || undefined}
|
||
|
|
icon="computer"
|
||
|
|
label={computerOpen ? "Grok Bot's Computer, in use" : "Grok Bot's Computer"}
|
||
|
|
onClick={() => void onComputer()}
|
||
|
|
size="md"
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div className="sand-virtual-transcript" ref={scroller}>
|
||
|
|
{transcript.map((item, i) => (
|
||
|
|
<div className={`sand-transcript-row${item.role === "user" ? " sand-transcript-row--user" : ""}`} key={`${item.role}-${i}`}>
|
||
|
|
<div className={`sand-message${item.role === "user" ? "" : " sand-1g0q52m"}`}>{item.content}</div>
|
||
|
|
</div>
|
||
|
|
))}
|
||
|
|
{activity.map((item) => (
|
||
|
|
<div className="sand-activity" key={item.id}>{item.text}</div>
|
||
|
|
))}
|
||
|
|
{typing ? <div className="sand-typing-indicator" aria-hidden="true"><span /><span /><span /></div> : null}
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{question ? (
|
||
|
|
<div className="sand-question">
|
||
|
|
<p>{question.prompt}</p>
|
||
|
|
<div className="sand-question__options">
|
||
|
|
{question.options.map((option) => (
|
||
|
|
<button key={option} type="button" onClick={() => void onSend(option)}>{option}</button>
|
||
|
|
))}
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
) : null}
|
||
|
|
|
||
|
|
<div className="sand-chat-input-dock">
|
||
|
|
<form className="sand-prompt-form" onSubmit={(e) => void onSend(undefined, e)}>
|
||
|
|
<div className="sand-prompt-shell" data-expanded={hasPayload || undefined}>
|
||
|
|
<textarea
|
||
|
|
className="sand-prompt-field"
|
||
|
|
rows={1}
|
||
|
|
value={draft}
|
||
|
|
placeholder="Ask anything"
|
||
|
|
onChange={(e) => setDraft(e.target.value)}
|
||
|
|
onKeyDown={(e) => {
|
||
|
|
if (e.key === "Enter" && !e.shiftKey && window.matchMedia("(min-width: 861px)").matches) {
|
||
|
|
e.preventDefault();
|
||
|
|
if (!working) void onSend();
|
||
|
|
}
|
||
|
|
}}
|
||
|
|
/>
|
||
|
|
<div className="sand-prompt-actions-row">
|
||
|
|
<SandIconButton aria-label="Attach file" className={PROMPT_ATTACH_CLASS} disabled icon="plus" shape="circle" size="lg" type="button" variant="default" />
|
||
|
|
<span className="sand-prompt-actions-trailing sand-prompt-cta-cluster sand-78zum5 sand-6s0dn4 sand-2lah0s">
|
||
|
|
{working ? (
|
||
|
|
<button aria-label="Stop" className={PROMPT_SEND_CLASS} type="button" onClick={() => void onStop()}>
|
||
|
|
<span className="sand-1n2onr6 sand-1kky2od sand-lup9mm">
|
||
|
|
<SandIcon name="stop" size="sm" variant="filled" />
|
||
|
|
</span>
|
||
|
|
</button>
|
||
|
|
) : hasPayload ? (
|
||
|
|
<button aria-label="Send message" className={PROMPT_SEND_CLASS} disabled={!hasPayload} type="submit">
|
||
|
|
<span className="sand-1n2onr6 sand-1kky2od sand-lup9mm">
|
||
|
|
<SandIcon name="arrow-up" size="sm" variant="filled" />
|
||
|
|
</span>
|
||
|
|
</button>
|
||
|
|
) : (
|
||
|
|
<SandIconButton aria-label="Start voice input" className="sand-prompt-mic sand-2lah0s sand-jbqb8w sand-uo9n5k sand-19aaqeu sand-1dsx48b sand-1hc1fzr sand-1lfpgzf sand-19991ni sand-13dflua sand-12w9bfk sand-b51amx" disabled icon="mic" shape="circle" size="lg" type="button" variant="default" />
|
||
|
|
)}
|
||
|
|
</span>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</form>
|
||
|
|
</div>
|
||
|
|
</main>
|
||
|
|
|
||
|
|
<aside
|
||
|
|
aria-hidden={!computerOpen || undefined}
|
||
|
|
aria-label="Conversation details"
|
||
|
|
className={`sand-info-pane sand--default-marker sand-1n2onr6 sand-1k3v4rp sand-1ms6mhf sand-5yr21d sand-2lwn1j sand-b3r6kr sand-lvsv26 sand-qwupev sand-9kvfbb${computerOpen ? " sand-9c3od3 sand-1uxagwj" : " sand-nalus7"}`}
|
||
|
|
data-open={computerOpen || undefined}
|
||
|
|
hidden={!computerOpen}
|
||
|
|
id={DETAILS_ID}
|
||
|
|
>
|
||
|
|
<div className="sand-info-pane__inner sand-78zum5 sand-dt5ytf sand-9c3od3 sand-1uxagwj sand-5yr21d sand-2lwn1j sand-1ua6jya sand-qwldcu sand-9kvfbb sand-1hc1fzr">
|
||
|
|
<header className="sand-info-pane__top sand-1n2onr6 sand-78zum5 sand-6s0dn4 sand-1qughib sand-167g77z sand-1c4vz4f sand-2lah0s sand-dl72j9 sand-lvsv26 sand-xlogw sand-14kp3v7 sand-exx8yu sand-j9b1aj sand-18d9i69 sand-f18ygs">
|
||
|
|
<span aria-hidden="true" />
|
||
|
|
<SandIconButton aria-label="Close details" icon="close" onClick={() => setComputerOpen(false)} size="md" variant="ghost" />
|
||
|
|
</header>
|
||
|
|
<div className="sand-info-pane__vnc">
|
||
|
|
{computerUrl ? <iframe title="Grok Bot's Computer" src={computerUrl} allow="clipboard-read; clipboard-write" /> : null}
|
||
|
|
<p className="sand-info-pane__status">{computerStatus}</p>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</aside>
|
||
|
|
|
||
|
|
<nav className="sand-tabbar" aria-label="Main">
|
||
|
|
<button type="button" aria-current={!computerOpen} onClick={() => { setComputerOpen(false); setSidebarOpen(true); }}>Chat</button>
|
||
|
|
<button type="button" aria-current={computerOpen} onClick={() => void onComputer()}>Computer</button>
|
||
|
|
</nav>
|
||
|
|
<div className="sand-backdrop" hidden={!sidebarOpen} onClick={() => setSidebarOpen(false)} />
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
}
|