Build automation and development improvements

- Add build-without-make.bat script for Windows builds without make
- Update .gitignore to exclude .gocache and dist directories
- Fix scripts/build-without-make.bat to properly handle repository root resolution
- Add CALL directive for pnpm commands in batch scripts
- Agent system improvements and health server enhancements
- Add agent cockpit UI components and memory graph visualization
- API improvements for PicoClaw web backend
- Update frontend routing and app sidebar
This commit is contained in:
anthrodjear 2026-05-06 16:20:27 +03:00
parent 03dae1301d
commit b8b231964c
17 changed files with 1928 additions and 5 deletions

2
.gitignore vendored
View file

@ -11,6 +11,8 @@ build/
/picoclaw /picoclaw
/picoclaw-test /picoclaw-test
cmd/**/workspace cmd/**/workspace
.gocache/
dist/
# Picoclaw specific # Picoclaw specific

View file

@ -28,6 +28,7 @@ import (
"github.com/sipeed/picoclaw/pkg/routing" "github.com/sipeed/picoclaw/pkg/routing"
"github.com/sipeed/picoclaw/pkg/session" "github.com/sipeed/picoclaw/pkg/session"
"github.com/sipeed/picoclaw/pkg/state" "github.com/sipeed/picoclaw/pkg/state"
"github.com/sipeed/picoclaw/pkg/tools"
"github.com/sipeed/picoclaw/pkg/utils" "github.com/sipeed/picoclaw/pkg/utils"
) )
@ -57,6 +58,7 @@ type AgentLoop struct {
mcp mcpRuntime mcp mcpRuntime
hookRuntime hookRuntime hookRuntime hookRuntime
steering *steeringQueue steering *steeringQueue
subagents *tools.SubagentManager
pendingSkills sync.Map pendingSkills sync.Map
mu sync.RWMutex mu sync.RWMutex

View file

@ -242,6 +242,9 @@ func registerSharedTools(
if (spawnEnabled || spawnStatusEnabled) && cfg.Tools.IsToolEnabled("subagent") { if (spawnEnabled || spawnStatusEnabled) && cfg.Tools.IsToolEnabled("subagent") {
subagentManager := tools.NewSubagentManager(provider, agent.Model, agent.Workspace) subagentManager := tools.NewSubagentManager(provider, agent.Model, agent.Workspace)
subagentManager.SetLLMOptions(agent.MaxTokens, agent.Temperature) subagentManager.SetLLMOptions(agent.MaxTokens, agent.Temperature)
if agentID == "main" {
al.subagents = subagentManager
}
// Inject a media resolver so the legacy RunToolLoop fallback path can // Inject a media resolver so the legacy RunToolLoop fallback path can
// resolve media:// refs in the same way the main AgentLoop does. // resolve media:// refs in the same way the main AgentLoop does.

View file

@ -119,3 +119,29 @@ func (al *AgentLoop) GetStartupInfo() map[string]any {
return info return info
} }
func (al *AgentLoop) GetMainSubagentTasks(channel, chatID string) []tools.SubagentTask {
al.mu.RLock()
manager := al.subagents
al.mu.RUnlock()
if manager == nil {
return nil
}
all := manager.ListTaskCopies()
if channel == "" && chatID == "" {
return all
}
filtered := make([]tools.SubagentTask, 0, len(all))
for _, task := range all {
if channel != "" && task.OriginChannel != "" && task.OriginChannel != channel {
continue
}
if chatID != "" && task.OriginChatID != "" && task.OriginChatID != chatID {
continue
}
filtered = append(filtered, task)
}
return filtered
}

View file

@ -246,6 +246,13 @@ func Run(debug bool, homePath, configPath string, allowEmptyStartup bool) (runEr
runningServices.HealthServer.SetPermissionGrantFunc(func(agentID, path, duration string) error { runningServices.HealthServer.SetPermissionGrantFunc(func(agentID, path, duration string) error {
return agentLoop.GrantPermission(agentID, path, duration) return agentLoop.GrantPermission(agentID, path, duration)
}) })
runningServices.HealthServer.SetSubagentStatusFunc(func(channel, chatID string) (any, error) {
return map[string]any{
"channel": channel,
"chat_id": chatID,
"tasks": agentLoop.GetMainSubagentTasks(channel, chatID),
}, nil
})
for _, bindHost := range listenResult.BindHosts { for _, bindHost := range listenResult.BindHosts {
fmt.Printf("✓ Gateway started on %s\n", net.JoinHostPort(bindHost, strconv.Itoa(cfg.Gateway.Port))) fmt.Printf("✓ Gateway started on %s\n", net.JoinHostPort(bindHost, strconv.Itoa(cfg.Gateway.Port)))

View file

@ -19,8 +19,9 @@ type Server struct {
ready bool ready bool
checks map[string]Check checks map[string]Check
startTime time.Time startTime time.Time
reloadFunc func() error reloadFunc func() error
permissionGrantFunc func(agentID, path, duration string) error permissionGrantFunc func(agentID, path, duration string) error
subagentStatusFunc func(channel, chatID string) (any, error)
authToken string // optional bearer token for protected endpoints authToken string // optional bearer token for protected endpoints
} }
@ -51,6 +52,7 @@ func NewServer(host string, port int, token string) *Server {
mux.HandleFunc("/ready", s.readyHandler) mux.HandleFunc("/ready", s.readyHandler)
mux.HandleFunc("/reload", s.reloadHandler) mux.HandleFunc("/reload", s.reloadHandler)
mux.HandleFunc("/internal/permission/grant", s.permissionGrantHandler) mux.HandleFunc("/internal/permission/grant", s.permissionGrantHandler)
mux.HandleFunc("/internal/subagents/status", s.subagentStatusHandler)
addr := net.JoinHostPort(host, strconv.Itoa(port)) addr := net.JoinHostPort(host, strconv.Itoa(port))
s.server = &http.Server{ s.server = &http.Server{
@ -128,6 +130,12 @@ func (s *Server) SetPermissionGrantFunc(fn func(agentID, path, duration string)
s.permissionGrantFunc = fn s.permissionGrantFunc = fn
} }
func (s *Server) SetSubagentStatusFunc(fn func(channel, chatID string) (any, error)) {
s.mu.Lock()
defer s.mu.Unlock()
s.subagentStatusFunc = fn
}
// permissionGrantHandler handles POST /internal/permission/grant requests. // permissionGrantHandler handles POST /internal/permission/grant requests.
func (s *Server) permissionGrantHandler(w http.ResponseWriter, r *http.Request) { func (s *Server) permissionGrantHandler(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost { if r.Method != http.MethodPost {
@ -241,6 +249,49 @@ func (s *Server) reloadHandler(w http.ResponseWriter, r *http.Request) {
json.NewEncoder(w).Encode(map[string]string{"status": "reload triggered"}) json.NewEncoder(w).Encode(map[string]string{"status": "reload triggered"})
} }
func (s *Server) subagentStatusHandler(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusMethodNotAllowed)
json.NewEncoder(w).Encode(map[string]string{"error": "method not allowed, use GET"})
return
}
s.mu.RLock()
requiredToken := s.authToken
statusFunc := s.subagentStatusFunc
s.mu.RUnlock()
if requiredToken != "" {
given := extractBearerToken(r.Header.Get("Authorization"))
if given == "" || subtle.ConstantTimeCompare([]byte(given), []byte(requiredToken)) != 1 {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusUnauthorized)
json.NewEncoder(w).Encode(map[string]string{"error": "unauthorized"})
return
}
}
if statusFunc == nil {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusServiceUnavailable)
json.NewEncoder(w).Encode(map[string]string{"error": "subagent status not configured"})
return
}
payload, err := statusFunc(r.URL.Query().Get("channel"), r.URL.Query().Get("chat_id"))
if err != nil {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusInternalServerError)
json.NewEncoder(w).Encode(map[string]string{"error": err.Error()})
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(payload)
}
func (s *Server) healthHandler(w http.ResponseWriter, r *http.Request) { func (s *Server) healthHandler(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
@ -307,6 +358,8 @@ func (s *Server) RegisterOnMux(mux HandlerMux) {
mux.HandleFunc("/health", s.healthHandler) mux.HandleFunc("/health", s.healthHandler)
mux.HandleFunc("/ready", s.readyHandler) mux.HandleFunc("/ready", s.readyHandler)
mux.HandleFunc("/reload", s.reloadHandler) mux.HandleFunc("/reload", s.reloadHandler)
mux.HandleFunc("/internal/permission/grant", s.permissionGrantHandler)
mux.HandleFunc("/internal/subagents/status", s.subagentStatusHandler)
} }
func statusString(ok bool) string { func statusString(ok bool) string {

View file

@ -215,6 +215,60 @@ func TestReloadHandler_Error(t *testing.T) {
} }
} }
func TestSubagentStatusHandler_Success(t *testing.T) {
s := newTestServer()
s.SetSubagentStatusFunc(func(channel, chatID string) (any, error) {
return map[string]any{
"channel": channel,
"chat_id": chatID,
"tasks": []map[string]any{
{"id": "subagent-1", "status": "running"},
},
}, nil
})
req := httptest.NewRequest(http.MethodGet, "/internal/subagents/status?channel=pico&chat_id=session-1", nil)
req.Header.Set("Authorization", "Bearer test")
w := httptest.NewRecorder()
s.subagentStatusHandler(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status = %d, want %d, body=%s", w.Code, http.StatusOK, w.Body.String())
}
var resp struct {
Channel string `json:"channel"`
ChatID string `json:"chat_id"`
Tasks []map[string]any `json:"tasks"`
}
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
t.Fatalf("decode response: %v", err)
}
if resp.Channel != "pico" || resp.ChatID != "session-1" {
t.Fatalf("response = %#v, want channel/chat_id preserved", resp)
}
if len(resp.Tasks) != 1 {
t.Fatalf("tasks len = %d, want 1", len(resp.Tasks))
}
}
func TestSubagentStatusHandler_RequiresAuth(t *testing.T) {
s := newTestServer()
s.SetSubagentStatusFunc(func(channel, chatID string) (any, error) {
return map[string]any{}, nil
})
req := httptest.NewRequest(http.MethodGet, "/internal/subagents/status", nil)
w := httptest.NewRecorder()
s.subagentStatusHandler(w, req)
if w.Code != http.StatusUnauthorized {
t.Fatalf("status = %d, want %d", w.Code, http.StatusUnauthorized)
}
}
func TestSetReady_Toggle(t *testing.T) { func TestSetReady_Toggle(t *testing.T) {
s := newTestServer() s := newTestServer()

View file

@ -4,10 +4,17 @@ REM Usage: scripts\build-without-make.bat
SETLOCAL ENABLEEXTENSIONS SETLOCAL ENABLEEXTENSIONS
SET "REPO_ROOT=%~dp0.."
SET "REPO_ROOT=%REPO_ROOT:~0,-1%"
SET "GO_TAGS=goolm,stdjson" SET "GO_TAGS=goolm,stdjson"
REM Resolve the repository root directory from the script location.
PUSHD "%~dp0.."
IF ERRORLEVEL 1 (
echo ERROR: Failed to resolve repository root from "%~dp0..".
EXIT /B 1
)
SET "REPO_ROOT=%CD%"
POPD
REM Ensure Go is available REM Ensure Go is available
where go >nul 2>&1 where go >nul 2>&1
IF ERRORLEVEL 1 ( IF ERRORLEVEL 1 (
@ -57,14 +64,14 @@ IF ERRORLEVEL 1 (
POPD POPD
EXIT /B 1 EXIT /B 1
) )
pnpm install --frozen-lockfile CALL pnpm install --frozen-lockfile
IF ERRORLEVEL 1 ( IF ERRORLEVEL 1 (
echo ERROR: pnpm install failed. echo ERROR: pnpm install failed.
POPD POPD
POPD POPD
EXIT /B 1 EXIT /B 1
) )
pnpm build:backend CALL pnpm build:backend
IF ERRORLEVEL 1 ( IF ERRORLEVEL 1 (
echo ERROR: pnpm build:backend failed. echo ERROR: pnpm build:backend failed.
POPD POPD

View file

@ -5,8 +5,14 @@ import (
"encoding/hex" "encoding/hex"
"encoding/json" "encoding/json"
"fmt" "fmt"
"io"
"net/http" "net/http"
"net/http/httputil" "net/http/httputil"
"os"
"path/filepath"
"sort"
"strconv"
"strings"
"time" "time"
"github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/config"
@ -17,6 +23,8 @@ import (
// registerPicoRoutes binds Pico Channel management endpoints to the ServeMux. // registerPicoRoutes binds Pico Channel management endpoints to the ServeMux.
func (h *Handler) registerPicoRoutes(mux *http.ServeMux) { func (h *Handler) registerPicoRoutes(mux *http.ServeMux) {
mux.HandleFunc("GET /api/pico/info", h.handleGetPicoInfo) mux.HandleFunc("GET /api/pico/info", h.handleGetPicoInfo)
mux.HandleFunc("GET /api/pico/memory-graph", h.handleGetPicoMemoryGraph)
mux.HandleFunc("GET /api/pico/subagents", h.handleGetPicoSubagents)
mux.HandleFunc("POST /api/pico/token", h.handleRegenPicoToken) mux.HandleFunc("POST /api/pico/token", h.handleRegenPicoToken)
mux.HandleFunc("POST /api/pico/setup", h.handlePicoSetup) mux.HandleFunc("POST /api/pico/setup", h.handlePicoSetup)
@ -28,6 +36,43 @@ func (h *Handler) registerPicoRoutes(mux *http.ServeMux) {
mux.HandleFunc("HEAD /pico/media/{id}", h.handlePicoMediaProxy()) mux.HandleFunc("HEAD /pico/media/{id}", h.handlePicoMediaProxy())
} }
type picoSubagentStatusItem struct {
ID string `json:"id"`
Label string `json:"label,omitempty"`
Status string `json:"status"`
Created int64 `json:"created"`
Result string `json:"result,omitempty"`
}
type picoSubagentStatusResponse struct {
SessionID string `json:"session_id"`
Channel string `json:"channel"`
ChatID string `json:"chat_id"`
Tasks []picoSubagentStatusItem `json:"tasks"`
}
type picoMemoryGraphNode struct {
ID string `json:"id"`
Label string `json:"label"`
Kind string `json:"kind"`
Group string `json:"group"`
Preview string `json:"preview,omitempty"`
Weight int `json:"weight,omitempty"`
}
type picoMemoryGraphEdge struct {
Source string `json:"source"`
Target string `json:"target"`
Kind string `json:"kind"`
}
type picoMemoryGraphResponse struct {
SessionID string `json:"session_id"`
GeneratedAt string `json:"generated_at"`
Nodes []picoMemoryGraphNode `json:"nodes"`
Edges []picoMemoryGraphEdge `json:"edges"`
}
// createWsProxy creates a reverse proxy to the current gateway WebSocket endpoint. // createWsProxy creates a reverse proxy to the current gateway WebSocket endpoint.
// The gateway bind host and port are resolved from the latest configuration. // The gateway bind host and port are resolved from the latest configuration.
func (h *Handler) createWsProxy(origProtocol string, upstreamProtocol string) *httputil.ReverseProxy { func (h *Handler) createWsProxy(origProtocol string, upstreamProtocol string) *httputil.ReverseProxy {
@ -207,6 +252,433 @@ func (h *Handler) handleGetPicoInfo(w http.ResponseWriter, r *http.Request) {
h.writePicoInfoResponse(w, r, cfg, nil) h.writePicoInfoResponse(w, r, cfg, nil)
} }
func (h *Handler) handleGetPicoMemoryGraph(w http.ResponseWriter, r *http.Request) {
sessionID := strings.TrimSpace(r.URL.Query().Get("session_id"))
if sessionID == "" {
http.Error(w, "session_id is required", http.StatusBadRequest)
return
}
cfg, err := config.LoadConfig(h.configPath)
if err != nil {
http.Error(w, "failed to load config", http.StatusInternalServerError)
return
}
sessionDir := resolveSessionsDir(cfg.Agents.Defaults.Workspace)
toolFeedbackMaxArgsLength := cfg.Agents.Defaults.GetToolFeedbackMaxArgsLength()
workspaceDir := resolveWorkspaceDir(cfg.Agents.Defaults.Workspace)
ref, refErr := h.findPicoJSONLSession(sessionDir, sessionID)
var sess sessionFile
err = refErr
if refErr == nil {
sess, err = h.readJSONLSession(sessionDir, ref.Key)
}
if err != nil {
http.Error(w, "session not found", http.StatusNotFound)
return
}
nodes, edges := buildPicoMemoryGraph(
sessionID,
detailSessionMessages(sess.Messages, toolFeedbackMaxArgsLength),
workspaceDir,
)
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(picoMemoryGraphResponse{
SessionID: sessionID,
GeneratedAt: time.Now().UTC().Format(time.RFC3339),
Nodes: nodes,
Edges: edges,
})
}
func (h *Handler) handleGetPicoSubagents(w http.ResponseWriter, r *http.Request) {
sessionID := strings.TrimSpace(r.URL.Query().Get("session_id"))
if sessionID == "" {
http.Error(w, "session_id is required", http.StatusBadRequest)
return
}
if !h.gatewayAvailableForProxy() {
logger.Warnf("Gateway not available for Pico subagent status proxy")
http.Error(w, "Gateway not available", http.StatusServiceUnavailable)
return
}
gateway.mu.Lock()
pidData := gateway.pidData
gateway.mu.Unlock()
if pidData == nil || pidData.Token == "" {
logger.Warnf("Gateway auth token not available for Pico subagent status proxy")
http.Error(w, "Gateway auth token not available", http.StatusServiceUnavailable)
return
}
target := h.gatewayProxyURL()
target.Path = "/internal/subagents/status"
query := target.Query()
query.Set("channel", "pico")
query.Set("chat_id", sessionID)
target.RawQuery = query.Encode()
req, err := http.NewRequestWithContext(r.Context(), http.MethodGet, target.String(), nil)
if err != nil {
http.Error(w, "Failed to create subagent status request", http.StatusInternalServerError)
return
}
req.Header.Set("Authorization", "Bearer "+pidData.Token)
resp, err := (&http.Client{Timeout: 5 * time.Second}).Do(req)
if err != nil {
logger.Errorf("Failed to fetch Pico subagent status: %v", err)
http.Error(w, "Gateway unavailable: "+err.Error(), http.StatusBadGateway)
return
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
w.WriteHeader(resp.StatusCode)
_, _ = io.Copy(w, resp.Body)
return
}
var upstream struct {
Channel string `json:"channel"`
ChatID string `json:"chat_id"`
Tasks []struct {
ID string `json:"id"`
Label string `json:"label"`
Status string `json:"status"`
Created int64 `json:"created"`
Result string `json:"result"`
} `json:"tasks"`
}
if err := json.NewDecoder(resp.Body).Decode(&upstream); err != nil {
http.Error(w, "Failed to decode subagent status response", http.StatusBadGateway)
return
}
tasks := make([]picoSubagentStatusItem, 0, len(upstream.Tasks))
for _, task := range upstream.Tasks {
tasks = append(tasks, picoSubagentStatusItem{
ID: task.ID,
Label: task.Label,
Status: task.Status,
Created: task.Created,
Result: summarizeSubagentResult(task.Result),
})
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(picoSubagentStatusResponse{
SessionID: sessionID,
Channel: upstream.Channel,
ChatID: upstream.ChatID,
Tasks: tasks,
})
}
func summarizeSubagentResult(result string) string {
result = strings.TrimSpace(result)
if result == "" {
return ""
}
const maxRunes = 180
runes := []rune(result)
if len(runes) <= maxRunes {
return result
}
return string(runes[:maxRunes]) + "..."
}
func resolveWorkspaceDir(workspace string) string {
if workspace == "" {
home, _ := os.UserHomeDir()
workspace = filepath.Join(home, ".picoclaw", "workspace")
}
if len(workspace) > 0 && workspace[0] == '~' {
home, _ := os.UserHomeDir()
if len(workspace) > 1 && workspace[1] == '/' {
workspace = home + workspace[1:]
} else {
workspace = home
}
}
return workspace
}
func buildPicoMemoryGraph(sessionID string, messages []sessionChatMessage, workspaceDir string) ([]picoMemoryGraphNode, []picoMemoryGraphEdge) {
nodes := make([]picoMemoryGraphNode, 0, 32)
edges := make([]picoMemoryGraphEdge, 0, 48)
seenNodes := make(map[string]struct{})
seenEdges := make(map[string]struct{})
addNode := func(node picoMemoryGraphNode) {
if _, exists := seenNodes[node.ID]; exists {
return
}
seenNodes[node.ID] = struct{}{}
nodes = append(nodes, node)
}
addEdge := func(edge picoMemoryGraphEdge) {
key := edge.Source + "\x00" + edge.Target + "\x00" + edge.Kind
if _, exists := seenEdges[key]; exists {
return
}
seenEdges[key] = struct{}{}
edges = append(edges, edge)
}
const (
memoryRootID = "memory-root"
sessionRootID = "session-root"
)
addNode(picoMemoryGraphNode{
ID: memoryRootID,
Label: "Workspace Memory",
Kind: "root",
Group: "memory",
Preview: "Long-term memory and recent notes",
Weight: 5,
})
addNode(picoMemoryGraphNode{
ID: sessionRootID,
Label: "Active Session",
Kind: "root",
Group: "session",
Preview: sessionID,
Weight: 5,
})
addEdge(picoMemoryGraphEdge{Source: memoryRootID, Target: sessionRootID, Kind: "context"})
appendMemoryDocumentGraph(memoryRootID, filepath.Join(workspaceDir, "memory", "MEMORY.md"), "memory", "Long-term Memory", 8, addNode, addEdge)
appendRecentDailyNoteGraph(memoryRootID, filepath.Join(workspaceDir, "memory"), addNode, addEdge)
appendSessionGraph(sessionRootID, messages, addNode, addEdge)
sort.Slice(nodes, func(i, j int) bool {
return nodes[i].ID < nodes[j].ID
})
sort.Slice(edges, func(i, j int) bool {
if edges[i].Source == edges[j].Source {
if edges[i].Target == edges[j].Target {
return edges[i].Kind < edges[j].Kind
}
return edges[i].Target < edges[j].Target
}
return edges[i].Source < edges[j].Source
})
return nodes, edges
}
func appendMemoryDocumentGraph(
rootID string,
path string,
group string,
title string,
maxItems int,
addNode func(picoMemoryGraphNode),
addEdge func(picoMemoryGraphEdge),
) {
data, err := os.ReadFile(path)
if err != nil || len(data) == 0 {
return
}
docID := group + ":document"
addNode(picoMemoryGraphNode{
ID: docID,
Label: title,
Kind: "document",
Group: group,
Preview: filepath.Base(path),
Weight: 4,
})
addEdge(picoMemoryGraphEdge{Source: rootID, Target: docID, Kind: "contains"})
lines := strings.Split(string(data), "\n")
currentParent := docID
items := 0
headingIndex := 0
noteIndex := 0
for _, rawLine := range lines {
line := strings.TrimSpace(rawLine)
if line == "" {
continue
}
if strings.HasPrefix(line, "#") {
headingText := summarizeGraphText(strings.TrimSpace(strings.TrimLeft(line, "#")), 48)
if headingText == "" {
continue
}
headingIndex++
headingID := group + ":heading:" + strconv.Itoa(headingIndex)
addNode(picoMemoryGraphNode{
ID: headingID,
Label: headingText,
Kind: "heading",
Group: group,
Preview: headingText,
Weight: 3,
})
addEdge(picoMemoryGraphEdge{Source: docID, Target: headingID, Kind: "section"})
currentParent = headingID
continue
}
trimmed := strings.TrimSpace(strings.TrimLeft(line, "-*0123456789. "))
if trimmed == "" {
continue
}
noteIndex++
noteID := group + ":note:" + strconv.Itoa(noteIndex)
addNode(picoMemoryGraphNode{
ID: noteID,
Label: summarizeGraphText(trimmed, 38),
Kind: "note",
Group: group,
Preview: summarizeGraphText(trimmed, 140),
Weight: 2,
})
addEdge(picoMemoryGraphEdge{Source: currentParent, Target: noteID, Kind: "note"})
items++
if items >= maxItems {
break
}
}
}
func appendRecentDailyNoteGraph(
rootID string,
memoryDir string,
addNode func(picoMemoryGraphNode),
addEdge func(picoMemoryGraphEdge),
) {
matches, err := filepath.Glob(filepath.Join(memoryDir, "*", "*.md"))
if err != nil || len(matches) == 0 {
return
}
sort.Slice(matches, func(i, j int) bool {
return matches[i] > matches[j]
})
for idx, match := range matches {
if idx >= 3 {
break
}
appendMemoryDocumentGraph(
rootID,
match,
"daily-"+strconv.Itoa(idx+1),
"Daily Note "+filepath.Base(match),
4,
addNode,
addEdge,
)
}
}
func appendSessionGraph(
rootID string,
messages []sessionChatMessage,
addNode func(picoMemoryGraphNode),
addEdge func(picoMemoryGraphEdge),
) {
if len(messages) == 0 {
return
}
start := 0
if len(messages) > 8 {
start = len(messages) - 8
}
recent := messages[start:]
previousID := rootID
for index, message := range recent {
messageID := "session:message:" + strconv.Itoa(index)
label := strings.ToUpper(message.Role)
if preview := summarizeGraphText(message.Content, 34); preview != "" {
label += ": " + preview
}
if label == strings.ToUpper(message.Role) && len(message.Attachments) > 0 {
label += ": attachment"
}
addNode(picoMemoryGraphNode{
ID: messageID,
Label: label,
Kind: "message",
Group: "session",
Preview: summarizeGraphText(message.Content, 160),
Weight: 3,
})
addEdge(picoMemoryGraphEdge{Source: previousID, Target: messageID, Kind: "flow"})
previousID = messageID
for toolIndex, toolCall := range message.ToolCalls {
if toolCall.Function == nil {
continue
}
name := strings.TrimSpace(toolCall.Function.Name)
if name == "" {
continue
}
toolID := messageID + ":tool:" + strconv.Itoa(toolIndex)
addNode(picoMemoryGraphNode{
ID: toolID,
Label: name,
Kind: "tool",
Group: "tool",
Preview: summarizeGraphText(toolCall.Function.Arguments, 120),
Weight: 2,
})
addEdge(picoMemoryGraphEdge{Source: messageID, Target: toolID, Kind: "tool"})
}
for attachmentIndex, attachment := range message.Attachments {
name := strings.TrimSpace(attachment.Filename)
if name == "" {
name = attachment.Type
}
if name == "" {
name = "attachment"
}
attachmentID := messageID + ":attachment:" + strconv.Itoa(attachmentIndex)
addNode(picoMemoryGraphNode{
ID: attachmentID,
Label: summarizeGraphText(name, 34),
Kind: "attachment",
Group: "media",
Preview: summarizeGraphText(attachment.URL, 120),
Weight: 1,
})
addEdge(picoMemoryGraphEdge{Source: messageID, Target: attachmentID, Kind: "attachment"})
}
}
}
func summarizeGraphText(text string, maxRunes int) string {
trimmed := strings.Join(strings.Fields(strings.TrimSpace(text)), " ")
if trimmed == "" {
return ""
}
runes := []rune(trimmed)
if len(runes) <= maxRunes {
return trimmed
}
return string(runes[:maxRunes-1]) + "…"
}
// handleRegenPicoToken rotates the raw Pico WebSocket token and returns // handleRegenPicoToken rotates the raw Pico WebSocket token and returns
// non-secret connection info for the launcher UI. // non-secret connection info for the launcher UI.
// //

View file

@ -11,9 +11,12 @@ import (
"strconv" "strconv"
"strings" "strings"
"testing" "testing"
"time"
"github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/config"
"github.com/sipeed/picoclaw/pkg/memory"
ppid "github.com/sipeed/picoclaw/pkg/pid" ppid "github.com/sipeed/picoclaw/pkg/pid"
"github.com/sipeed/picoclaw/pkg/providers"
) )
func newPicoProxyRequest(method, path string) *http.Request { func newPicoProxyRequest(method, path string) *http.Request {
@ -821,6 +824,188 @@ func TestHandlePicoMediaProxyUsesRawBearerToken(t *testing.T) {
} }
} }
func TestHandleGetPicoMemoryGraph_BuildsWorkspaceAndSessionGraph(t *testing.T) {
configPath := filepath.Join(t.TempDir(), "config.json")
workspaceDir := filepath.Join(t.TempDir(), "workspace")
sessionsDir := filepath.Join(workspaceDir, "sessions")
memoryDir := filepath.Join(workspaceDir, "memory")
if err := os.MkdirAll(memoryDir, 0o755); err != nil {
t.Fatalf("MkdirAll(memoryDir) error = %v", err)
}
if err := os.MkdirAll(filepath.Join(memoryDir, time.Now().Format("200601")), 0o755); err != nil {
t.Fatalf("MkdirAll(dailyNoteDir) error = %v", err)
}
cfg := config.DefaultConfig()
cfg.Agents.Defaults.Workspace = workspaceDir
if err := config.SaveConfig(configPath, cfg); err != nil {
t.Fatalf("SaveConfig() error = %v", err)
}
store, err := memory.NewJSONLStore(sessionsDir)
if err != nil {
t.Fatalf("NewJSONLStore() error = %v", err)
}
sessionKey := legacyPicoSessionPrefix + "graph-session"
for _, msg := range []providers.Message{
{Role: "user", Content: "Remember the Nairobi deployment notes."},
{Role: "assistant", Content: "Saved the deployment note and linked it to workspace memory."},
} {
if err := store.AddFullMessage(nil, sessionKey, msg); err != nil {
t.Fatalf("AddFullMessage() error = %v", err)
}
}
if err := store.SetSummary(nil, sessionKey, "Graph session"); err != nil {
t.Fatalf("SetSummary() error = %v", err)
}
if err := os.WriteFile(
filepath.Join(memoryDir, "MEMORY.md"),
[]byte("# Preferences\n- User prefers network graph views\n# Projects\n- PicoClaw cockpit integration"),
0o644,
); err != nil {
t.Fatalf("WriteFile(MEMORY.md) error = %v", err)
}
todayPath := filepath.Join(memoryDir, time.Now().Format("200601"), time.Now().Format("20060102")+".md")
if err := os.WriteFile(
todayPath,
[]byte("# Daily\n- Reviewed active session memory graph"),
0o644,
); err != nil {
t.Fatalf("WriteFile(todayPath) error = %v", err)
}
h := NewHandler(configPath)
mux := http.NewServeMux()
h.RegisterRoutes(mux)
req := httptest.NewRequest(http.MethodGet, "/api/pico/memory-graph?session_id=graph-session", nil)
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String())
}
var resp picoMemoryGraphResponse
if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil {
t.Fatalf("Decode() error = %v", err)
}
if resp.SessionID != "graph-session" {
t.Fatalf("resp.SessionID = %q, want %q", resp.SessionID, "graph-session")
}
if len(resp.Nodes) < 5 {
t.Fatalf("len(resp.Nodes) = %d, want at least 5", len(resp.Nodes))
}
foundMemoryRoot := false
foundSessionMessage := false
for _, node := range resp.Nodes {
if node.ID == "memory-root" {
foundMemoryRoot = true
}
if strings.Contains(node.Label, "NAIROBI") || strings.Contains(node.Preview, "Nairobi") {
foundSessionMessage = true
}
}
if !foundMemoryRoot {
t.Fatal("expected memory-root node in graph")
}
if !foundSessionMessage {
t.Fatal("expected session content to appear in graph nodes")
}
}
func TestHandleGetPicoSubagents_UsesScopedGatewayStatus(t *testing.T) {
home := t.TempDir()
t.Setenv("PICOCLAW_HOME", home)
configPath := filepath.Join(t.TempDir(), "config.json")
h := NewHandler(configPath)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/internal/subagents/status" {
t.Fatalf("path = %q, want %q", r.URL.Path, "/internal/subagents/status")
}
if got := r.Header.Get("Authorization"); got != "Bearer gateway-auth-token" {
t.Fatalf("Authorization = %q, want %q", got, "Bearer gateway-auth-token")
}
if got := r.URL.Query().Get("channel"); got != "pico" {
t.Fatalf("channel query = %q, want %q", got, "pico")
}
if got := r.URL.Query().Get("chat_id"); got != "session-42" {
t.Fatalf("chat_id query = %q, want %q", got, "session-42")
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"channel": "pico",
"chat_id": "session-42",
"tasks": []map[string]any{
{
"id": "subagent-1",
"label": "Research",
"status": "running",
"created": int64(1710000000000),
"result": "This is a very long status summary that should still round-trip through the launcher API cleanly.",
},
},
})
}))
defer server.Close()
cfg := config.DefaultConfig()
cfg.Gateway.Host = "127.0.0.1"
cfg.Gateway.Port = mustGatewayTestPort(t, server.URL)
if err := config.SaveConfig(configPath, cfg); err != nil {
t.Fatalf("SaveConfig() error = %v", err)
}
cmd := startGatewayLikeProcess(t)
t.Cleanup(func() {
if cmd.Process != nil {
_ = cmd.Process.Kill()
}
_ = cmd.Wait()
})
origPidData := gateway.pidData
origCmd := gateway.cmd
t.Cleanup(func() {
gateway.mu.Lock()
gateway.pidData = origPidData
gateway.cmd = origCmd
gateway.mu.Unlock()
})
gateway.mu.Lock()
gateway.pidData = &ppid.PidFileData{PID: cmd.Process.Pid, Token: "gateway-auth-token"}
gateway.cmd = cmd
gateway.mu.Unlock()
req := httptest.NewRequest(http.MethodGet, "http://launcher.local/api/pico/subagents?session_id=session-42", nil)
rec := httptest.NewRecorder()
h.handleGetPicoSubagents(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
}
var resp picoSubagentStatusResponse
if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil {
t.Fatalf("decode response: %v", err)
}
if resp.SessionID != "session-42" || resp.ChatID != "session-42" || resp.Channel != "pico" {
t.Fatalf("response = %#v, want scoped session metadata", resp)
}
if len(resp.Tasks) != 1 || resp.Tasks[0].ID != "subagent-1" {
t.Fatalf("tasks = %#v, want one proxied task", resp.Tasks)
}
}
func TestHandleWebSocketProxyRejectsStalePidDataAfterProcessExit(t *testing.T) { func TestHandleWebSocketProxyRejectsStalePidDataAfterProcessExit(t *testing.T) {
tmpDir := t.TempDir() tmpDir := t.TempDir()
t.Setenv("HOME", tmpDir) t.Setenv("HOME", tmpDir)

View file

@ -15,6 +15,43 @@ interface PicoSetupResponse {
changed: boolean changed: boolean
} }
export interface PicoSubagentStatusItem {
id: string
label?: string
status: "running" | "completed" | "failed" | "canceled" | string
created: number
result?: string
}
export interface PicoSubagentStatusResponse {
session_id: string
channel: string
chat_id: string
tasks: PicoSubagentStatusItem[]
}
export interface PicoMemoryGraphNode {
id: string
label: string
kind: string
group: string
preview?: string
weight?: number
}
export interface PicoMemoryGraphEdge {
source: string
target: string
kind: string
}
export interface PicoMemoryGraphResponse {
session_id: string
generated_at: string
nodes: PicoMemoryGraphNode[]
edges: PicoMemoryGraphEdge[]
}
const BASE_URL = "" const BASE_URL = ""
async function request<T>(path: string, options?: RequestInit): Promise<T> { async function request<T>(path: string, options?: RequestInit): Promise<T> {
@ -37,4 +74,22 @@ export async function setupPico(): Promise<PicoSetupResponse> {
return request<PicoSetupResponse>("/api/pico/setup", { method: "POST" }) return request<PicoSetupResponse>("/api/pico/setup", { method: "POST" })
} }
export async function getPicoSubagents(
sessionId: string,
): Promise<PicoSubagentStatusResponse> {
const params = new URLSearchParams({ session_id: sessionId })
return request<PicoSubagentStatusResponse>(
`/api/pico/subagents?${params.toString()}`,
)
}
export async function getPicoMemoryGraph(
sessionId: string,
): Promise<PicoMemoryGraphResponse> {
const params = new URLSearchParams({ session_id: sessionId })
return request<PicoMemoryGraphResponse>(
`/api/pico/memory-graph?${params.toString()}`,
)
}
export type { PicoInfoResponse, PicoSetupResponse } export type { PicoInfoResponse, PicoSetupResponse }

View file

@ -0,0 +1,651 @@
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 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<ArrayLike<{ transcript: string }>>
}
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":
return "Requires subagent runtime"
case "requires_skills":
return "Requires skills support"
case "requires_mcp_discovery":
return "Requires MCP discovery"
case "requires_linux":
return "Linux only"
case "requires_serial_platform":
return "Unsupported serial platform"
default:
return reasonCode ?? ""
}
}
function readFileAsDataUrl(file: File): Promise<string> {
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 {
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<ChatAttachment[]>([])
const [isListening, setIsListening] = useState(false)
const fileInputRef = useRef<HTMLInputElement | null>(null)
const recognitionRef = useRef<SpeechRecognitionLike | null>(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<HTMLInputElement>) => {
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 (
<div className="flex h-full flex-col overflow-hidden bg-[#07110a] text-[#d7f9df]">
<PageHeader title="Agent Cockpit" />
<div className="flex-1 overflow-auto px-4 py-4 sm:px-6 sm:py-6">
<div className="mx-auto grid w-full max-w-[1500px] gap-4 xl:grid-cols-[260px_minmax(0,1fr)_360px]">
<aside className="space-y-4">
<Card className="border-[#1f4f31] bg-[#09150c] text-[#d7f9df] shadow-none">
<CardHeader>
<CardTitle className="font-mono text-sm uppercase tracking-[0.22em]">
Systems
</CardTitle>
<CardDescription className="text-[#8bb39a]">
Filter the active tool surface.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="relative">
<IconSearch className="absolute top-1/2 left-3 size-4 -translate-y-1/2 text-[#6aa37b]" />
<Input
value={searchQuery}
onChange={(event) => setSearchQuery(event.target.value)}
placeholder="Search tools"
className="border-[#1f4f31] bg-[#050d08] pl-9 text-[#d7f9df] placeholder:text-[#5c8168]"
/>
</div>
<div className="space-y-2">
{(["all", "enabled", "disabled", "blocked"] as const).map(
(status) => (
<button
key={status}
type="button"
onClick={() => setStatusFilter(status)}
className={cn(
"flex w-full items-center justify-between rounded-lg border px-3 py-2 text-left text-sm transition-colors",
statusFilter === status
? "border-[#4fc267] bg-[#10351b] text-[#effff3]"
: "border-[#173621] bg-[#08100b] text-[#8bb39a] hover:border-[#2c6a3e] hover:text-[#d7f9df]",
)}
>
<span className="capitalize">{status}</span>
<span className="font-mono text-xs">
{statusCounts[status]}
</span>
</button>
),
)}
</div>
<div className="space-y-2 border-t border-[#173621] pt-4">
<p className="font-mono text-[11px] uppercase tracking-[0.22em] text-[#7ca88a]">
Categories
</p>
{categoryCounts.map(([category, count]) => (
<div
key={category}
className="flex items-center justify-between text-sm text-[#9cc8a8]"
>
<span className="capitalize">{category}</span>
<span className="font-mono text-xs">{count}</span>
</div>
))}
</div>
</CardContent>
</Card>
</aside>
<section className="space-y-4">
<Card className="border-[#215d36] bg-[linear-gradient(180deg,#0a150c_0%,#0a1c10_100%)] text-[#effff3] shadow-none">
<CardHeader>
<div className="flex items-center justify-between gap-4">
<div>
<CardTitle className="font-mono text-sm uppercase tracking-[0.24em]">
Tool Grid
</CardTitle>
<CardDescription className="text-[#98c7a5]">
Real launcher tools, live from PicoClaw.
</CardDescription>
</div>
<div className="flex items-center gap-2">
<Badge variant="outline" className="border-[#2a6b3d] text-[#9fd8ae]">
{filteredToolCount} visible
</Badge>
<Button asChild variant="outline" className="border-[#2a6b3d] bg-transparent text-[#c8f5d2] hover:bg-[#12301a]">
<Link to="/agent/tools">
<IconSettings className="size-4" />
Full Tools
</Link>
</Button>
</div>
</div>
</CardHeader>
<CardContent>
{hasToolsError ? (
<div className="rounded-lg border border-red-500/30 bg-red-500/10 px-4 py-3 text-sm text-red-200">
Failed to load tools.
</div>
) : isToolsLoading ? (
<div className="grid gap-3 md:grid-cols-2">
{Array.from({ length: 6 }).map((_, index) => (
<Skeleton key={index} className="h-36 rounded-xl bg-[#112117]" />
))}
</div>
) : (
<div className="space-y-5">
{groupedTools.map(([category, items]) => (
<div key={category} className="space-y-3">
<div className="flex items-center justify-between border-b border-[#173621] pb-2">
<h2 className="font-mono text-xs uppercase tracking-[0.24em] text-[#8ec49c]">
{category}
</h2>
<span className="font-mono text-xs text-[#5f8f6f]">
{items.length}
</span>
</div>
<div className="grid gap-3 md:grid-cols-2">
{items.map((tool) => (
<Card
key={tool.name}
size="sm"
className="border-[#173621] bg-[#08100b] text-[#d7f9df] shadow-none"
>
<CardHeader className="gap-2">
<div className="flex items-start justify-between gap-3">
<div className="space-y-2">
<CardTitle className="font-mono text-sm">
{tool.name}
</CardTitle>
<Badge
variant={statusBadgeVariant(tool.status)}
className="capitalize"
>
{tool.status}
</Badge>
</div>
<Switch
checked={tool.status !== "disabled"}
disabled={pendingToolName === tool.name}
onCheckedChange={(checked) =>
toggleTool(tool.name, checked)
}
/>
</div>
<CardDescription className="text-[#8fb59b]">
{tool.description}
</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
<div className="flex items-center justify-between text-xs text-[#739981]">
<span className="capitalize">{tool.category}</span>
<span className="font-mono">{tool.config_key}</span>
</div>
{tool.reason_code ? (
<div className="rounded-lg border border-amber-400/20 bg-amber-400/10 px-3 py-2 text-xs text-amber-100">
{reasonLabel(tool.reason_code)}
</div>
) : null}
</CardContent>
</Card>
))}
</div>
</div>
))}
</div>
)}
</CardContent>
</Card>
<Card className="border-[#215d36] bg-[linear-gradient(180deg,#09130b_0%,#071109_100%)] text-[#effff3] shadow-none">
<CardHeader>
<CardTitle className="font-mono text-sm uppercase tracking-[0.24em]">
Memory Network
</CardTitle>
<CardDescription className="text-[#98c7a5]">
Obsidian-style graph built from PicoClaw workspace memory and the active session trail.
</CardDescription>
</CardHeader>
<CardContent>
{hasMemoryGraphError ? (
<div className="rounded-lg border border-red-500/30 bg-red-500/10 px-4 py-3 text-sm text-red-200">
Failed to load memory graph.
</div>
) : isMemoryGraphLoading ? (
<Skeleton className="h-[420px] rounded-xl bg-[#112117]" />
) : sessionMemoryGraph && sessionMemoryGraph.nodes.length > 0 ? (
<MemoryGraph
nodes={sessionMemoryGraph.nodes}
edges={sessionMemoryGraph.edges}
/>
) : (
<div className="rounded-lg border border-[#173621] bg-[#050d08] px-4 py-3 text-sm text-[#8bb39a]">
Memory graph will appear when the session and workspace memory have visible context.
</div>
)}
</CardContent>
</Card>
</section>
<aside className="space-y-4">
<Card className="border-[#1f4f31] bg-[#09150c] text-[#d7f9df] shadow-none">
<CardHeader>
<CardTitle className="font-mono text-sm uppercase tracking-[0.22em]">
Session Bridge
</CardTitle>
<CardDescription className="text-[#8bb39a]">
Voice and images route into the active Pico session.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="rounded-lg border border-[#173621] bg-[#050d08] px-3 py-2 text-xs text-[#9fc6aa]">
<div className="flex items-center justify-between gap-3">
<span className="font-mono uppercase tracking-[0.18em]">
Session
</span>
<Badge variant="outline" className="border-[#2a6b3d] text-[#9fd8ae]">
{connectionState}
</Badge>
</div>
<p className="mt-2 break-all font-mono text-[11px] text-[#d7f9df]">
{activeSessionId}
</p>
</div>
<Input
value={prompt}
onChange={(event) => 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 ? (
<div className="space-y-2">
{attachments.map((attachment, index) => (
<div
key={`${attachment.filename ?? "attachment"}-${index}`}
className="flex items-center justify-between rounded-lg border border-[#173621] bg-[#050d08] px-3 py-2 text-xs text-[#bfe7c8]"
>
<span className="truncate">{attachment.filename ?? "Image"}</span>
<button
type="button"
className="text-[#7ca88a] hover:text-[#effff3]"
onClick={() =>
setAttachments((current) =>
current.filter((_, itemIndex) => itemIndex !== index),
)
}
>
Remove
</button>
</div>
))}
</div>
) : null}
<div className="grid grid-cols-2 gap-2">
<Button
type="button"
variant="outline"
className="border-[#2a6b3d] bg-transparent text-[#d7f9df] hover:bg-[#12301a]"
onClick={() => fileInputRef.current?.click()}
>
<IconPhoto className="size-4" />
Add Image
</Button>
<Button
type="button"
variant={isListening ? "destructive" : "outline"}
className={cn(
"border-[#2a6b3d] text-[#d7f9df] hover:bg-[#12301a]",
isListening && "border-red-400/40 bg-red-500/10 text-red-100",
)}
onClick={toggleVoice}
>
{isListening ? (
<IconMicrophoneOff className="size-4" />
) : (
<IconMicrophone className="size-4" />
)}
Voice
</Button>
</div>
<Button
type="button"
className="w-full bg-[#4fc267] text-[#08120b] hover:bg-[#77e58e]"
onClick={handleSendBridgeMessage}
>
<IconUpload className="size-4" />
Send To Active Session
</Button>
<div className="grid grid-cols-2 gap-2">
<Button asChild variant="outline" className="border-[#2a6b3d] bg-transparent text-[#d7f9df] hover:bg-[#12301a]">
<Link to="/">
Open Chat
<IconArrowRight className="size-4" />
</Link>
</Button>
<Button asChild variant="outline" className="border-[#2a6b3d] bg-transparent text-[#d7f9df] hover:bg-[#12301a]">
<Link to="/agent/hub">
Skills Hub
<IconArrowRight className="size-4" />
</Link>
</Button>
</div>
<input
ref={fileInputRef}
type="file"
accept="image/jpeg,image/png,image/gif,image/webp,image/bmp"
className="hidden"
multiple
onChange={handleImageSelection}
/>
</CardContent>
</Card>
<Card className="border-[#1f4f31] bg-[#09150c] text-[#d7f9df] shadow-none">
<CardHeader>
<CardTitle className="font-mono text-sm uppercase tracking-[0.22em]">
Main Agent Subagents
</CardTitle>
<CardDescription className="text-[#8bb39a]">
Live status for the current Pico session only.
</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
{hasSubagentsError ? (
<div className="rounded-lg border border-red-500/30 bg-red-500/10 px-3 py-2 text-sm text-red-200">
Failed to load subagent status.
</div>
) : isSubagentsLoading ? (
Array.from({ length: 3 }).map((_, index) => (
<Skeleton key={index} className="h-20 rounded-xl bg-[#112117]" />
))
) : sessionSubagents.length === 0 ? (
<div className="rounded-lg border border-[#173621] bg-[#050d08] px-3 py-3 text-sm text-[#8bb39a]">
No subagents have been created in this session yet.
</div>
) : (
sessionSubagents.map((task) => (
<div
key={task.id}
className="rounded-xl border border-[#173621] bg-[#050d08] px-3 py-3"
>
<div className="flex items-center justify-between gap-3">
<div>
<p className="font-mono text-sm text-[#effff3]">
{task.label || task.id}
</p>
<p className="text-[11px] text-[#7ca88a]">
{dayjs(task.created).format("MMM D, HH:mm")}
</p>
</div>
<Badge variant={statusBadgeVariant(task.status)} className="capitalize">
{task.status}
</Badge>
</div>
{task.result ? (
<p className="mt-3 text-xs leading-relaxed text-[#9dc2a7]">
{task.result}
</p>
) : null}
</div>
))
)}
</CardContent>
</Card>
<Card className="border-[#1f4f31] bg-[#09150c] text-[#d7f9df] shadow-none">
<CardHeader>
<CardTitle className="font-mono text-sm uppercase tracking-[0.22em]">
Tool Runtime
</CardTitle>
<CardDescription className="text-[#8bb39a]">
Quick view of the current search/tool setup.
</CardDescription>
</CardHeader>
<CardContent className="space-y-3 text-sm text-[#a8cfb3]">
<div className="flex items-center justify-between rounded-lg border border-[#173621] bg-[#050d08] px-3 py-3">
<span className="flex items-center gap-2">
<IconBrain className="size-4 text-[#71d888]" />
Web search provider
</span>
<span className="font-mono text-xs uppercase text-[#effff3]">
{isWebSearchLoading ? "Loading" : currentProviderLabel}
</span>
</div>
<p className="text-xs text-[#7ca88a]">
The cockpit reuses PicoClaws existing Pico session, tool
toggles, and media pipeline instead of the source apps
Firebase and Gemini-specific wiring.
</p>
</CardContent>
</Card>
</aside>
</div>
</div>
</div>
)
}

View file

@ -0,0 +1,231 @@
import { useMemo, useState } from "react"
import type {
PicoMemoryGraphEdge,
PicoMemoryGraphNode,
} from "@/api/pico"
import { Badge } from "@/components/ui/badge"
import { cn } from "@/lib/utils"
const VIEWBOX_WIDTH = 1000
const VIEWBOX_HEIGHT = 560
type PositionedNode = PicoMemoryGraphNode & { x: number; y: number }
interface MemoryGraphProps {
nodes: PicoMemoryGraphNode[]
edges: PicoMemoryGraphEdge[]
}
const GROUP_COLUMNS: Record<string, number> = {
memory: 170,
"daily-1": 295,
"daily-2": 295,
"daily-3": 295,
session: 700,
tool: 840,
media: 920,
}
function groupColor(group: string) {
if (group === "memory") return "#72f0a0"
if (group.startsWith("daily-")) return "#4dd0e1"
if (group === "tool") return "#f8d66d"
if (group === "media") return "#f395d6"
return "#90e89f"
}
function computeLayout(nodes: PicoMemoryGraphNode[]): PositionedNode[] {
const groups = new Map<string, PicoMemoryGraphNode[]>()
for (const node of nodes) {
const items = groups.get(node.group) ?? []
items.push(node)
groups.set(node.group, items)
}
const layout: PositionedNode[] = []
for (const [group, items] of groups.entries()) {
const x = GROUP_COLUMNS[group] ?? 500
const count = items.length
const gap = VIEWBOX_HEIGHT / (count + 1)
items.forEach((node, index) => {
const offset =
group === "session" && node.kind === "root"
? 0
: Math.sin((index + 1) * 1.7) * 12
layout.push({
...node,
x,
y: Math.max(52, Math.min(VIEWBOX_HEIGHT - 52, gap * (index + 1) + offset)),
})
})
}
return layout.sort((left, right) => left.x - right.x || left.y - right.y)
}
export function MemoryGraph({ nodes, edges }: MemoryGraphProps) {
const [selectedId, setSelectedId] = useState<string | null>(nodes[0]?.id ?? null)
const positionedNodes = useMemo(() => computeLayout(nodes), [nodes])
const nodeMap = useMemo(
() => new Map(positionedNodes.map((node) => [node.id, node])),
[positionedNodes],
)
const selectedNode =
positionedNodes.find((node) => node.id === selectedId) ?? positionedNodes[0]
return (
<div className="grid gap-4 xl:grid-cols-[minmax(0,1fr)_280px]">
<div className="overflow-hidden rounded-xl border border-[#173621] bg-[#041008]">
<svg
viewBox={`0 0 ${VIEWBOX_WIDTH} ${VIEWBOX_HEIGHT}`}
className="h-[420px] w-full"
role="img"
aria-label="PicoClaw memory graph"
>
<defs>
<linearGradient id="memoryGrid" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stopColor="#0c2014" />
<stop offset="100%" stopColor="#050d08" />
</linearGradient>
</defs>
<rect width={VIEWBOX_WIDTH} height={VIEWBOX_HEIGHT} fill="url(#memoryGrid)" />
{Array.from({ length: 10 }).map((_, index) => (
<line
key={`v-${index}`}
x1={(VIEWBOX_WIDTH / 10) * index}
y1="0"
x2={(VIEWBOX_WIDTH / 10) * index}
y2={VIEWBOX_HEIGHT}
stroke="#0d2817"
strokeWidth="1"
/>
))}
{Array.from({ length: 8 }).map((_, index) => (
<line
key={`h-${index}`}
x1="0"
y1={(VIEWBOX_HEIGHT / 8) * index}
x2={VIEWBOX_WIDTH}
y2={(VIEWBOX_HEIGHT / 8) * index}
stroke="#0d2817"
strokeWidth="1"
/>
))}
{edges.map((edge) => {
const source = nodeMap.get(edge.source)
const target = nodeMap.get(edge.target)
if (!source || !target) {
return null
}
const active =
selectedId != null &&
(selectedId === edge.source || selectedId === edge.target)
return (
<line
key={`${edge.source}-${edge.target}-${edge.kind}`}
x1={source.x}
y1={source.y}
x2={target.x}
y2={target.y}
stroke={active ? "#9dfdbb" : "#1f5c34"}
strokeOpacity={active ? 0.9 : 0.45}
strokeWidth={active ? 2.5 : 1.4}
/>
)
})}
{positionedNodes.map((node) => {
const active = node.id === selectedNode?.id
const color = groupColor(node.group)
const radius =
node.kind === "root" ? 24 : node.kind === "document" ? 18 : 14
return (
<g
key={node.id}
onMouseEnter={() => setSelectedId(node.id)}
onClick={() => setSelectedId(node.id)}
className="cursor-pointer"
>
<circle
cx={node.x}
cy={node.y}
r={radius + 10}
fill={active ? `${color}22` : "transparent"}
/>
<circle
cx={node.x}
cy={node.y}
r={radius}
fill="#07110a"
stroke={color}
strokeWidth={active ? 3 : 2}
/>
<text
x={node.x}
y={node.y + 4}
textAnchor="middle"
fill={color}
fontSize={node.kind === "root" ? "12" : "10"}
fontFamily="ui-monospace, SFMono-Regular, Menlo, monospace"
>
{node.kind === "root" ? node.label : node.label.slice(0, 18)}
</text>
</g>
)
})}
</svg>
</div>
<div className="space-y-3 rounded-xl border border-[#173621] bg-[#050d08] p-4">
{selectedNode ? (
<>
<div className="flex items-start justify-between gap-3">
<div>
<p className="font-mono text-sm uppercase tracking-[0.22em] text-[#95d7a5]">
Node Focus
</p>
<h3 className="mt-2 text-sm font-semibold text-[#effff3]">
{selectedNode.label}
</h3>
</div>
<Badge
variant="outline"
className={cn(
"border-[#2a6b3d] text-[#9fd8ae]",
selectedNode.kind === "tool" && "border-[#8c7931] text-[#f8d66d]",
)}
>
{selectedNode.kind}
</Badge>
</div>
<p className="text-sm leading-relaxed text-[#9cc8a8]">
{selectedNode.preview || "No extra preview for this node yet."}
</p>
<div className="grid gap-2 text-xs text-[#74a282]">
<div className="flex items-center justify-between rounded-lg border border-[#13301d] px-3 py-2">
<span>Cluster</span>
<span className="font-mono uppercase text-[#d7f9df]">
{selectedNode.group}
</span>
</div>
<div className="flex items-center justify-between rounded-lg border border-[#13301d] px-3 py-2">
<span>Weight</span>
<span className="font-mono text-[#d7f9df]">
{selectedNode.weight ?? 1}
</span>
</div>
</div>
</>
) : (
<p className="text-sm text-[#8bb39a]">No graph data available.</p>
)}
</div>
</div>
)
}

View file

@ -0,0 +1,136 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
import { useDeferredValue, useMemo, useState } from "react"
import { useTranslation } from "react-i18next"
import { toast } from "sonner"
import { getPicoMemoryGraph, getPicoSubagents } from "@/api/pico"
import { getTools, getWebSearchConfig, setToolEnabled } from "@/api/tools"
import { showSaveSuccessOrRestartToast } from "@/lib/restart-required"
import { refreshGatewayState } from "@/store/gateway"
type ToolStatusFilter = "all" | "enabled" | "disabled" | "blocked"
export function useAgentCockpit(sessionId: string) {
const { t } = useTranslation()
const queryClient = useQueryClient()
const [searchQuery, setSearchQuery] = useState("")
const [statusFilter, setStatusFilter] = useState<ToolStatusFilter>("all")
const deferredSearchQuery = useDeferredValue(searchQuery)
const toolsQuery = useQuery({
queryKey: ["tools"],
queryFn: getTools,
})
const webSearchQuery = useQuery({
queryKey: ["tools", "web-search-config"],
queryFn: getWebSearchConfig,
})
const subagentsQuery = useQuery({
queryKey: ["pico", "subagents", sessionId],
queryFn: () => getPicoSubagents(sessionId),
enabled: Boolean(sessionId),
refetchInterval: 3000,
})
const memoryGraphQuery = useQuery({
queryKey: ["pico", "memory-graph", sessionId],
queryFn: () => getPicoMemoryGraph(sessionId),
enabled: Boolean(sessionId),
refetchInterval: 10000,
})
const toggleToolMutation = useMutation({
mutationFn: async ({ name, enabled }: { name: string; enabled: boolean }) =>
setToolEnabled(name, enabled),
onSuccess: async (_, variables) => {
const gateway = await refreshGatewayState({ force: true })
showSaveSuccessOrRestartToast(
t,
variables.enabled
? t("pages.agent.tools.enable_success", "Tool enabled successfully")
: t(
"pages.agent.tools.disable_success",
"Tool disabled successfully",
),
"Agent Cockpit",
gateway?.restartRequired === true,
)
void queryClient.invalidateQueries({ queryKey: ["tools"] })
},
onError: (error) => {
toast.error(
error instanceof Error
? error.message
: t("pages.agent.tools.toggle_error", "Failed to toggle tool"),
)
},
})
const tools = toolsQuery.data?.tools ?? []
const normalizedSearchQuery = deferredSearchQuery.trim().toLowerCase()
const groupedTools = useMemo(() => {
const groups = new Map<string, typeof tools>()
for (const tool of tools) {
if (statusFilter !== "all" && tool.status !== statusFilter) {
continue
}
if (normalizedSearchQuery) {
const haystack = `${tool.name} ${tool.description}`.toLowerCase()
if (!haystack.includes(normalizedSearchQuery)) {
continue
}
}
const items = groups.get(tool.category) ?? []
items.push(tool)
groups.set(tool.category, items)
}
return Array.from(groups.entries())
}, [normalizedSearchQuery, statusFilter, tools])
const categoryCounts = useMemo(() => {
const counts = new Map<string, number>()
for (const tool of tools) {
counts.set(tool.category, (counts.get(tool.category) ?? 0) + 1)
}
return Array.from(counts.entries())
}, [tools])
const statusCounts = useMemo(() => {
return {
all: tools.length,
enabled: tools.filter((tool) => tool.status === "enabled").length,
disabled: tools.filter((tool) => tool.status === "disabled").length,
blocked: tools.filter((tool) => tool.status === "blocked").length,
}
}, [tools])
return {
categoryCounts,
groupedTools,
pendingToolName: toggleToolMutation.isPending
? (toggleToolMutation.variables?.name ?? null)
: null,
searchQuery,
sessionMemoryGraph: memoryGraphQuery.data ?? null,
sessionSubagents: subagentsQuery.data?.tasks ?? [],
statusCounts,
statusFilter,
tools,
hasMemoryGraphError: memoryGraphQuery.error != null,
webSearchConfig: webSearchQuery.data ?? null,
hasSubagentsError: subagentsQuery.error != null,
hasToolsError: toolsQuery.error != null,
isMemoryGraphLoading: memoryGraphQuery.isLoading,
isSubagentsLoading: subagentsQuery.isLoading,
isToolsLoading: toolsQuery.isLoading,
isWebSearchLoading: webSearchQuery.isLoading,
setSearchQuery,
setStatusFilter,
toggleTool: (name: string, enabled: boolean) =>
toggleToolMutation.mutate({ name, enabled }),
}
}

View file

@ -3,6 +3,7 @@ import {
IconAtom, IconAtom,
IconChevronsDown, IconChevronsDown,
IconChevronsUp, IconChevronsUp,
IconCpu,
IconKey, IconKey,
IconListDetails, IconListDetails,
IconMessageCircle, IconMessageCircle,
@ -132,6 +133,12 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
{ {
...baseNavGroups[2], ...baseNavGroups[2],
items: [ items: [
{
title: "Agent Cockpit",
url: "/agent/cockpit",
icon: IconCpu,
translateTitle: false,
},
{ {
title: "navigation.hub", title: "navigation.hub",
url: "/agent/hub", url: "/agent/hub",

View file

@ -23,6 +23,7 @@ import { Route as ChannelsNameRouteImport } from './routes/channels/$name'
import { Route as AgentToolsRouteImport } from './routes/agent/tools' import { Route as AgentToolsRouteImport } from './routes/agent/tools'
import { Route as AgentSkillsRouteImport } from './routes/agent/skills' import { Route as AgentSkillsRouteImport } from './routes/agent/skills'
import { Route as AgentHubRouteImport } from './routes/agent/hub' import { Route as AgentHubRouteImport } from './routes/agent/hub'
import { Route as AgentCockpitRouteImport } from './routes/agent/cockpit'
const ModelsRoute = ModelsRouteImport.update({ const ModelsRoute = ModelsRouteImport.update({
id: '/models', id: '/models',
@ -94,6 +95,11 @@ const AgentHubRoute = AgentHubRouteImport.update({
path: '/hub', path: '/hub',
getParentRoute: () => AgentRoute, getParentRoute: () => AgentRoute,
} as any) } as any)
const AgentCockpitRoute = AgentCockpitRouteImport.update({
id: '/cockpit',
path: '/cockpit',
getParentRoute: () => AgentRoute,
} as any)
export interface FileRoutesByFullPath { export interface FileRoutesByFullPath {
'/': typeof IndexRoute '/': typeof IndexRoute
@ -105,6 +111,7 @@ export interface FileRoutesByFullPath {
'/launcher-setup': typeof LauncherSetupRoute '/launcher-setup': typeof LauncherSetupRoute
'/logs': typeof LogsRoute '/logs': typeof LogsRoute
'/models': typeof ModelsRoute '/models': typeof ModelsRoute
'/agent/cockpit': typeof AgentCockpitRoute
'/agent/hub': typeof AgentHubRoute '/agent/hub': typeof AgentHubRoute
'/agent/skills': typeof AgentSkillsRoute '/agent/skills': typeof AgentSkillsRoute
'/agent/tools': typeof AgentToolsRoute '/agent/tools': typeof AgentToolsRoute
@ -121,6 +128,7 @@ export interface FileRoutesByTo {
'/launcher-setup': typeof LauncherSetupRoute '/launcher-setup': typeof LauncherSetupRoute
'/logs': typeof LogsRoute '/logs': typeof LogsRoute
'/models': typeof ModelsRoute '/models': typeof ModelsRoute
'/agent/cockpit': typeof AgentCockpitRoute
'/agent/hub': typeof AgentHubRoute '/agent/hub': typeof AgentHubRoute
'/agent/skills': typeof AgentSkillsRoute '/agent/skills': typeof AgentSkillsRoute
'/agent/tools': typeof AgentToolsRoute '/agent/tools': typeof AgentToolsRoute
@ -138,6 +146,7 @@ export interface FileRoutesById {
'/launcher-setup': typeof LauncherSetupRoute '/launcher-setup': typeof LauncherSetupRoute
'/logs': typeof LogsRoute '/logs': typeof LogsRoute
'/models': typeof ModelsRoute '/models': typeof ModelsRoute
'/agent/cockpit': typeof AgentCockpitRoute
'/agent/hub': typeof AgentHubRoute '/agent/hub': typeof AgentHubRoute
'/agent/skills': typeof AgentSkillsRoute '/agent/skills': typeof AgentSkillsRoute
'/agent/tools': typeof AgentToolsRoute '/agent/tools': typeof AgentToolsRoute
@ -156,6 +165,7 @@ export interface FileRouteTypes {
| '/launcher-setup' | '/launcher-setup'
| '/logs' | '/logs'
| '/models' | '/models'
| '/agent/cockpit'
| '/agent/hub' | '/agent/hub'
| '/agent/skills' | '/agent/skills'
| '/agent/tools' | '/agent/tools'
@ -172,6 +182,7 @@ export interface FileRouteTypes {
| '/launcher-setup' | '/launcher-setup'
| '/logs' | '/logs'
| '/models' | '/models'
| '/agent/cockpit'
| '/agent/hub' | '/agent/hub'
| '/agent/skills' | '/agent/skills'
| '/agent/tools' | '/agent/tools'
@ -188,6 +199,7 @@ export interface FileRouteTypes {
| '/launcher-setup' | '/launcher-setup'
| '/logs' | '/logs'
| '/models' | '/models'
| '/agent/cockpit'
| '/agent/hub' | '/agent/hub'
| '/agent/skills' | '/agent/skills'
| '/agent/tools' | '/agent/tools'
@ -307,6 +319,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AgentHubRouteImport preLoaderRoute: typeof AgentHubRouteImport
parentRoute: typeof AgentRoute parentRoute: typeof AgentRoute
} }
'/agent/cockpit': {
id: '/agent/cockpit'
path: '/cockpit'
fullPath: '/agent/cockpit'
preLoaderRoute: typeof AgentCockpitRouteImport
parentRoute: typeof AgentRoute
}
} }
} }
@ -323,12 +342,14 @@ const ChannelsRouteRouteWithChildren = ChannelsRouteRoute._addFileChildren(
) )
interface AgentRouteChildren { interface AgentRouteChildren {
AgentCockpitRoute: typeof AgentCockpitRoute
AgentHubRoute: typeof AgentHubRoute AgentHubRoute: typeof AgentHubRoute
AgentSkillsRoute: typeof AgentSkillsRoute AgentSkillsRoute: typeof AgentSkillsRoute
AgentToolsRoute: typeof AgentToolsRoute AgentToolsRoute: typeof AgentToolsRoute
} }
const AgentRouteChildren: AgentRouteChildren = { const AgentRouteChildren: AgentRouteChildren = {
AgentCockpitRoute: AgentCockpitRoute,
AgentHubRoute: AgentHubRoute, AgentHubRoute: AgentHubRoute,
AgentSkillsRoute: AgentSkillsRoute, AgentSkillsRoute: AgentSkillsRoute,
AgentToolsRoute: AgentToolsRoute, AgentToolsRoute: AgentToolsRoute,

View file

@ -0,0 +1,11 @@
import { createFileRoute } from "@tanstack/react-router"
import { CockpitPage } from "@/components/agent/cockpit/cockpit-page"
export const Route = createFileRoute("/agent/cockpit")({
component: AgentCockpitRoute,
})
function AgentCockpitRoute() {
return <CockpitPage />
}