diff --git a/web/frontend/src/components/agent/cockpit/cockpit-page.tsx b/web/frontend/src/components/agent/cockpit/cockpit-page.tsx index 7e49c911f..28cff97dc 100644 --- a/web/frontend/src/components/agent/cockpit/cockpit-page.tsx +++ b/web/frontend/src/components/agent/cockpit/cockpit-page.tsx @@ -1,84 +1,15 @@ -import { - IconArrowRight, - IconBrain, - IconMicrophone, - IconMicrophoneOff, - IconPhoto, - IconSearch, - IconSettings, - IconUpload, -} from "@tabler/icons-react" -import { Link } from "@tanstack/react-router" import dayjs from "dayjs" -import { type ChangeEvent, useEffect, useMemo, useRef, useState } from "react" -import { toast } from "sonner" +import { useMemo } from "react" +import { IconArrowRight } from "@tabler/icons-react" -import type { ChatAttachment } from "@/store/chat" import { usePicoChat } from "@/hooks/use-pico-chat" -import { PageHeader } from "@/components/page-header" import { Badge } from "@/components/ui/badge" -import { Button } from "@/components/ui/button" -import { - Card, - CardContent, - CardDescription, - CardHeader, - CardTitle, -} from "@/components/ui/card" -import { Input } from "@/components/ui/input" -import { Skeleton } from "@/components/ui/skeleton" import { Switch } from "@/components/ui/switch" import { cn } from "@/lib/utils" import { MemoryGraph } from "./memory-graph" import { useAgentCockpit } from "./use-agent-cockpit" -const MAX_IMAGE_SIZE_BYTES = 7 * 1024 * 1024 -const ALLOWED_IMAGE_TYPES = new Set([ - "image/jpeg", - "image/png", - "image/gif", - "image/webp", - "image/bmp", -]) - -declare global { - interface Window { - SpeechRecognition?: new () => SpeechRecognitionLike - webkitSpeechRecognition?: new () => SpeechRecognitionLike - } -} - -interface SpeechRecognitionLike { - continuous: boolean - interimResults: boolean - lang: string - onresult: ((event: SpeechRecognitionEventLike) => void) | null - onend: (() => void) | null - onerror: ((event: { error: string }) => void) | null - start(): void - stop(): void -} - -interface SpeechRecognitionEventLike { - results: ArrayLike> -} - -function statusBadgeVariant(status: string) { - switch (status) { - case "enabled": - case "completed": - return "default" as const - case "blocked": - case "failed": - return "destructive" as const - case "running": - return "secondary" as const - default: - return "outline" as const - } -} - function reasonLabel(reasonCode?: string) { switch (reasonCode) { case "requires_subagent": @@ -96,556 +27,182 @@ function reasonLabel(reasonCode?: string) { } } -function readFileAsDataUrl(file: File): Promise { - return new Promise((resolve, reject) => { - const reader = new FileReader() - reader.onload = () => { - if (typeof reader.result === "string") { - resolve(reader.result) - return - } - reject(new Error("Failed to read file")) - } - reader.onerror = () => - reject(reader.error || new Error("Failed to read file")) - reader.readAsDataURL(file) - }) -} - export function CockpitPage() { - const { activeSessionId, connectionState, sendMessage } = usePicoChat() + const { activeSessionId } = usePicoChat() const { - categoryCounts, groupedTools, pendingToolName, - searchQuery, sessionSubagents, sessionMemoryGraph, - statusCounts, - statusFilter, - webSearchConfig, - hasMemoryGraphError, - hasSubagentsError, - hasToolsError, - isMemoryGraphLoading, - isSubagentsLoading, - isToolsLoading, - isWebSearchLoading, - setSearchQuery, - setStatusFilter, toggleTool, } = useAgentCockpit(activeSessionId) - const [prompt, setPrompt] = useState("") - const [attachments, setAttachments] = useState([]) - const [isListening, setIsListening] = useState(false) - const fileInputRef = useRef(null) - const recognitionRef = useRef(null) - - useEffect(() => { - const Recognition = - window.SpeechRecognition ?? window.webkitSpeechRecognition - if (!Recognition) { - return - } - const recognition = new Recognition() - recognition.continuous = false - recognition.interimResults = false - recognition.lang = "en-US" - recognition.onresult = (event) => { - const transcript = event.results[0]?.[0]?.transcript?.trim() ?? "" - setPrompt(transcript) - if (!transcript) { - return - } - const sent = sendMessage({ content: transcript }) - if (!sent) { - toast.error("Voice capture worked, but chat is not ready to send.") - } - } - recognition.onend = () => setIsListening(false) - recognition.onerror = (event) => { - setIsListening(false) - toast.error(`Voice capture error: ${event.error}`) - } - recognitionRef.current = recognition - }, [sendMessage]) - const filteredToolCount = useMemo( () => groupedTools.reduce((total, [, items]) => total + items.length, 0), [groupedTools], ) - const currentProviderLabel = useMemo(() => { - const current = webSearchConfig?.providers.find((provider) => provider.current) - return current?.label ?? webSearchConfig?.provider ?? "Auto" - }, [webSearchConfig]) - - const handleImageSelection = async (event: ChangeEvent) => { - const files = Array.from(event.target.files ?? []) - event.target.value = "" - if (files.length === 0) { - return - } - - const nextAttachments: ChatAttachment[] = [] - for (const file of files) { - if (!ALLOWED_IMAGE_TYPES.has(file.type)) { - toast.error(`Unsupported image type: ${file.name}`) - continue - } - if (file.size > MAX_IMAGE_SIZE_BYTES) { - toast.error(`${file.name} exceeds 7 MB.`) - continue - } - try { - const url = await readFileAsDataUrl(file) - nextAttachments.push({ - type: "image", - url, - filename: file.name, - contentType: file.type, - }) - } catch (error) { - toast.error( - error instanceof Error ? error.message : `Failed to read ${file.name}`, - ) - } - } - - setAttachments((current) => [...current, ...nextAttachments]) - } - - const handleSendBridgeMessage = () => { - const sent = sendMessage({ content: prompt, attachments }) - if (!sent) { - toast.error("Chat connection is not ready yet.") - return - } - setPrompt("") - setAttachments([]) - } - - const toggleVoice = () => { - if (!recognitionRef.current) { - toast.error("Voice capture is not supported in this browser.") - return - } - if (isListening) { - recognitionRef.current.stop() - setIsListening(false) - return - } - try { - recognitionRef.current.start() - setIsListening(true) - } catch (error) { - toast.error( - error instanceof Error ? error.message : "Unable to start voice capture.", - ) - setIsListening(false) - } - } - return ( -
- +
+ {/* Ghost Background Typography */} +
+ COCKPIT + SYSTEM +
-
-
- - -
- - -
-
- - Tool Grid - - - Real launcher tools, live from PicoClaw. - -
-
- - {filteredToolCount} visible - - + {/* Memory Network */} +
+
+
+ Relational Map +

Memory Network

- - - {hasToolsError ? ( -
- Failed to load tools. -
- ) : isToolsLoading ? ( -
- {Array.from({ length: 6 }).map((_, index) => ( - - ))} -
- ) : ( -
- {groupedTools.map(([category, items]) => ( -
-
-

- {category} -

- - {items.length} - -
-
- {items.map((tool) => ( - - -
-
- - {tool.name} - - - {tool.status} - -
- - toggleTool(tool.name, checked) - } - /> -
- - {tool.description} - -
- -
- {tool.category} - {tool.config_key} -
- {tool.reason_code ? ( -
- {reasonLabel(tool.reason_code)} -
- ) : null} -
-
- ))} -
-
- ))} -
- )} -
- - - - - - Memory Network - - - Obsidian-style graph built from PicoClaw workspace memory and the active session trail. - - - - {hasMemoryGraphError ? ( -
- Failed to load memory graph. -
- ) : isMemoryGraphLoading ? ( - - ) : sessionMemoryGraph && sessionMemoryGraph.nodes.length > 0 ? ( +
- ) : ( -
- Memory graph will appear when the session and workspace memory have visible context. -
- )} - - -
- -
+ +
- setPrompt(event.target.value)} - placeholder="Send a message to the active agent session" - className="border-[#1f4f31] bg-[#050d08] text-[#d7f9df] placeholder:text-[#5c8168]" - /> - - {attachments.length > 0 ? ( -
- {attachments.map((attachment, index) => ( -
- {attachment.filename ?? "Image"} - -
- ))} -
- ) : null} - -
- - -
- - - -
- - -
- - - - - - - - - Main Agent Subagents - - - Live status for the current Pico session only. - - - - {hasSubagentsError ? ( -
- Failed to load subagent status. -
- ) : isSubagentsLoading ? ( - Array.from({ length: 3 }).map((_, index) => ( - - )) - ) : sessionSubagents.length === 0 ? ( -
+ {/* Right Sidebar */} +
+ + {/* Footer */} +
) } diff --git a/web/frontend/src/components/chat/chat-composer.tsx b/web/frontend/src/components/chat/chat-composer.tsx index b3354cc33..f722e51f2 100644 --- a/web/frontend/src/components/chat/chat-composer.tsx +++ b/web/frontend/src/components/chat/chat-composer.tsx @@ -1,5 +1,6 @@ -import { IconArrowUp, IconPhotoPlus, IconX } from "@tabler/icons-react" -import type { KeyboardEvent } from "react" +import { IconArrowUp, IconMicrophone, IconMicrophoneOff, IconPhotoPlus, IconX } from "@tabler/icons-react" +import { type KeyboardEvent } from "react" +import { useEffect, useRef, useState } from "react" import { useTranslation } from "react-i18next" import TextareaAutosize from "react-textarea-autosize" @@ -13,6 +14,28 @@ import { import { cn } from "@/lib/utils" import type { ChatAttachment, ContextUsage } from "@/store/chat" +declare global { + interface Window { + SpeechRecognition?: new () => SpeechRecognitionLike + webkitSpeechRecognition?: new () => SpeechRecognitionLike + } +} + +interface SpeechRecognitionLike { + continuous: boolean + interimResults: boolean + lang: string + onresult: ((event: SpeechRecognitionEventLike) => void) | null + onend: (() => void) | null + onerror: ((event: { error: string }) => void) | null + start(): void + stop(): void +} + +interface SpeechRecognitionEventLike { + results: ArrayLike> +} + export type ChatInputDisabledReason = | "gatewayUnknown" | "gatewayStarting" @@ -51,6 +74,41 @@ export function ChatComposer({ contextUsage, }: ChatComposerProps) { const { t } = useTranslation() + const [isListening, setIsListening] = useState(false) + const recognitionRef = useRef(null) + + useEffect(() => { + const Recognition = + window.SpeechRecognition ?? window.webkitSpeechRecognition + if (!Recognition) return + const recognition = new Recognition() + recognition.continuous = false + recognition.interimResults = false + recognition.lang = "en-US" + recognition.onresult = (event) => { + const transcript = event.results[0]?.[0]?.transcript?.trim() ?? "" + if (transcript) onInputChange(transcript) + } + recognition.onend = () => setIsListening(false) + recognition.onerror = () => setIsListening(false) + recognitionRef.current = recognition + }, [onInputChange]) + + const toggleVoice = () => { + if (!recognitionRef.current) return + if (isListening) { + recognitionRef.current.stop() + setIsListening(false) + } else { + try { + recognitionRef.current.start() + setIsListening(true) + } catch { + setIsListening(false) + } + } + } + const canInput = inputDisabledReason === null const disabledMessage = inputDisabledReason === null @@ -110,21 +168,37 @@ export function ChatComposer({ maxRows={8} /> -
-
- -
+
+
+ + +
{contextUsage && (