diff --git a/README.md b/README.md index 45b035f..d2558ff 100644 --- a/README.md +++ b/README.md @@ -58,7 +58,16 @@ ClawGo 更关注真正的运行时能力: - `system_prompt_file -> AGENT.md` - WebUI 可编辑、热更新、查看运行态 -### 5. 适合真正长期运行 +### 5. Spec Coding(规范驱动开发) + +- 明确需要编码且属于非 trivial 的任务可走 `spec.md -> tasks.md -> checklist.md` +- 小修小补、轻微代码调整、单点改动默认不启用这套流程 +- `spec.md` 负责范围、决策、权衡 +- `tasks.md` 负责任务拆解和进度更新 +- `checklist.md` 负责最终完整性核查 +- 这三份文档是活文档,允许在开发过程中持续修订 + +### 6. 适合真正长期运行 - 本地优先 - Go 原生 runtime @@ -152,6 +161,7 @@ user -> main -> worker -> main -> user - 🌐 本地主控 + 远端 node 分支的分布式执行 - 🔍 需要强观测、强审计、强恢复的 Agent 系统 - 🏭 想把 prompt、agent、工具权限、运行策略工程化管理的团队 +- 📝 想把编码过程变成可追踪的 spec-driven delivery 流程 ## 配置结构 diff --git a/checklist.md b/checklist.md new file mode 100644 index 0000000..fe8ae45 --- /dev/null +++ b/checklist.md @@ -0,0 +1,14 @@ +# Verification Checklist (checklist.md) + +- [x] Built-in `spec-coding` skill added +- [x] Scaffold script added for `spec.md`, `tasks.md`, and `checklist.md` +- [x] Shared markdown templates added under the skill +- [x] Workspace policy updated to prefer spec-driven coding for non-trivial implementation work +- [x] Agent context loads active project spec docs when present +- [x] Missing project spec docs auto-initialize for coding tasks only +- [x] `tasks.md` auto-tracks current coding turn progress +- [x] Completed items can be reopened automatically on later debugging / retry work +- [x] Context loading has tests for presence and truncation +- [x] README documents the workflow +- [x] Go tests pass for affected packages +- [x] No known missing follow-up inside current scope diff --git a/docs/node-p2p-e2e.md b/docs/node-p2p-e2e.md deleted file mode 100644 index b59408a..0000000 --- a/docs/node-p2p-e2e.md +++ /dev/null @@ -1,287 +0,0 @@ -# Node P2P E2E - -这份文档用于验证 `gateway.nodes.p2p` 的两条真实数据面: - -- `websocket_tunnel` -- `webrtc` - -目标不是单元测试,而是两台公网机器上的真实联通性验证。 - -## 验证目标 - -验证通过需要同时满足: - -1. 两台远端 node 都能成功注册到同一个 gateway -2. `websocket_tunnel` 模式下,远端 node 任务可成功完成 -3. `webrtc` 模式下,远端 node 任务可成功完成 -4. `webrtc` 模式下,`/webui/api/nodes` 的 `p2p.active_sessions` 大于 `0` -5. `Dashboard` / `Subagents` 能看到 node P2P 会话状态和最近调度路径 - -## 前置条件 - -- 一台 gateway 机器 -- 两台远端 node 机器 -- 三台机器都能运行 `clawgo` -- 远端机器有 `python3` -- gateway 机器对外开放 WebUI / node registry 端口 - -推荐: - -- 先验证 `websocket_tunnel` -- 再切到 `webrtc` -- `webrtc` 至少配置一个可用的 `stun_servers` - -## 测试思路 - -为了排除 HTTP relay 误判,建议让目标 node 的 `endpoint` 故意写成只对目标 node 本机有效的地址,例如: - -```text -http://127.0.0.1: -``` - -这样如果任务仍能完成,就说明请求不是靠 gateway 直接 HTTP relay 打过去的,而是走了 node P2P 通道。 - -## 建议配置 - -### 1. websocket_tunnel - -```json -{ - "gateway": { - "host": "0.0.0.0", - "port": 18790, - "token": "YOUR_GATEWAY_TOKEN", - "nodes": { - "p2p": { - "enabled": true, - "transport": "websocket_tunnel", - "stun_servers": [], - "ice_servers": [] - } - } - } -} -``` - -### 2. webrtc - -```json -{ - "gateway": { - "host": "0.0.0.0", - "port": 18790, - "token": "YOUR_GATEWAY_TOKEN", - "nodes": { - "p2p": { - "enabled": true, - "transport": "webrtc", - "stun_servers": ["stun:stun.l.google.com:19302"], - "ice_servers": [] - } - } - } -} -``` - -## 最小 node endpoint - -在每台远端 node 上启动一个最小 HTTP 服务,用于返回固定结果: - -```python -#!/usr/bin/env python3 -import json -import os -import socket -from http.server import BaseHTTPRequestHandler, HTTPServer - -PORT = int(os.environ.get("PORT", "19081")) -LABEL = os.environ.get("NODE_LABEL", socket.gethostname()) - -class H(BaseHTTPRequestHandler): - def log_message(self, fmt, *args): - pass - - def do_POST(self): - length = int(self.headers.get("Content-Length", "0") or 0) - raw = self.rfile.read(length) if length else b"{}" - try: - req = json.loads(raw.decode("utf-8") or "{}") - except Exception: - req = {} - action = req.get("action") or self.path.strip("/") - payload = { - "handler": LABEL, - "hostname": socket.gethostname(), - "path": self.path, - "echo": req, - } - if action == "agent_task": - payload["result"] = f"agent_task from {LABEL}" - else: - payload["result"] = f"{action} from {LABEL}" - body = json.dumps({ - "ok": True, - "code": "ok", - "node": LABEL, - "action": action, - "payload": payload, - }).encode("utf-8") - self.send_response(200) - self.send_header("Content-Type", "application/json") - self.send_header("Content-Length", str(len(body))) - self.end_headers() - self.wfile.write(body) - -HTTPServer(("0.0.0.0", PORT), H).serve_forever() -``` - -## 注册远端 node - -在每台 node 上执行: - -```bash -clawgo node register \ - --gateway http://:18790 \ - --token YOUR_GATEWAY_TOKEN \ - --id \ - --name \ - --endpoint http://127.0.0.1: \ - --actions run,agent_task \ - --models gpt-4o-mini \ - --capabilities run,invoke,model \ - --watch \ - --heartbeat-sec 10 -``` - -验证注册成功: - -```bash -curl -s -H 'Authorization: Bearer YOUR_GATEWAY_TOKEN' \ - http://:18790/webui/api/nodes -``` - -预期: - -- 远端 node 出现在 `nodes` -- `online = true` -- 主拓扑中出现 `node..main` - -## 建议的任务验证方式 - -不要通过普通聊天 prompt 让模型“自己决定是否调用 nodes 工具”作为主判据。 -更稳定的方式是直接调用 subagent runtime,把任务派给远端 node branch: - -```bash -curl -s \ - -H 'Authorization: Bearer YOUR_GATEWAY_TOKEN' \ - -H 'Content-Type: application/json' \ - http://:18790/webui/api/subagents_runtime \ - -d '{ - "action": "dispatch_and_wait", - "agent_id": "node..main", - "task": "Return exactly the string NODE_P2P_OK", - "wait_timeout_sec": 30 - }' -``` - -预期: - -- `ok = true` -- `result.reply.status = completed` -- `result.reply.result` 含远端 endpoint 返回内容 - -## websocket_tunnel 判定 - -在 `websocket_tunnel` 模式下,上面的任务应能成功完成。 - -如果目标 node 的 `endpoint` 配成了 `127.0.0.1:`,且任务仍成功,则说明: - -- 不是 gateway 直接 HTTP relay 到远端公网地址 -- 实际请求已经通过 node websocket 隧道送达目标 node - -## webrtc 判定 - -切到 `webrtc` 配置后,重复同样的 `dispatch_and_wait`。 - -随后查看: - -```bash -curl -s -H 'Authorization: Bearer YOUR_GATEWAY_TOKEN' \ - http://:18790/webui/api/nodes -``` - -预期 `p2p` 段包含: - -- `transport = "webrtc"` -- `active_sessions > 0` -- `nodes[].status = "open"` -- `nodes[].last_ready_at` 非空 - -这表示 WebRTC DataChannel 已经真正建立,而不只是 signaling 被触发。 - -## WebUI 判定 - -验证页面: - -- `Dashboard` - - 能看到 Node P2P 会话明细 - - 能看到最近节点调度记录,包括 `used_transport` 和 `fallback_from` -- `Subagents` - - 远端 node branch 的卡片/tooltip 能显示: - - P2P transport - - session status - - retry count - - last ready - - last error - -## 常见问题 - -### 1. gateway 端口上已经有旧实例 - -现象: - -- 新配置明明改了,但 `/webui/api/version` 或 `/webui/api/nodes` 仍表现出旧行为 - -处理: - -- 先确认端口上实际监听的是哪一个 `clawgo` 进程 -- 再启动测试实例 - -### 2. chat 路由干扰 node 工具验证 - -现象: - -- 普通聊天请求被 router 或 skill 行为分流 -- 没有真正命中 `nodes` 数据面 - -处理: - -- 直接用 `/webui/api/subagents_runtime` 的 `dispatch_and_wait` -- 让任务明确走 `node..main` - -### 3. webrtc 一直停在 connecting - -优先检查: - -- `stun_servers` 是否可达 -- 两端机器是否允许 UDP 出站 -- 是否需要 `turn:` / `turns:` 服务器 - -### 4. 任务成功但 UI 没显示会话 - -优先检查: - -- 是否真的运行在 `webrtc` 配置下 -- `/webui/api/nodes` 返回的 `p2p` 是否含 `active_sessions` -- 前端是否已经更新到包含 node P2P runtime 展示的版本 - -## 回归建议 - -每次改动以下模块后,至少回归一次本流程: - -- `pkg/nodes/webrtc.go` -- `pkg/nodes/transport.go` -- `pkg/agent/loop.go` -- `pkg/api/server.go` -- `cmd/clawgo/cmd_node.go` -- `cmd/clawgo/cmd_gateway.go` diff --git a/pkg/agent/context.go b/pkg/agent/context.go index 6e5dce2..d309950 100644 --- a/pkg/agent/context.go +++ b/pkg/agent/context.go @@ -15,6 +15,7 @@ import ( type ContextBuilder struct { workspace string + cwd string skillsLoader *skills.SkillsLoader memory *MemoryStore toolsSummary func() []string // Function to get tool summaries dynamically @@ -37,6 +38,7 @@ func NewContextBuilder(workspace string, toolsSummaryFunc func() []string) *Cont return &ContextBuilder{ workspace: workspace, + cwd: wd, skillsLoader: skills.NewSkillsLoader(workspace, globalSkillsDir, builtinSkillsDir), memory: NewMemoryStore(workspace), toolsSummary: toolsSummaryFunc, @@ -67,8 +69,12 @@ Your workspace is at: %s - Daily Notes: %s/memory/YYYY-MM-DD.md - Skills: %s/skills/{skill-name}/SKILL.md +## Spec-Driven Coding +- Active project spec docs (when present): %s/{spec.md,tasks.md,checklist.md} +- Keep spec.md as project scope / decisions, tasks.md as execution plan, checklist.md as final verification gate + %s`, - now, runtime, workspacePath, workspacePath, workspacePath, workspacePath, toolsSection) + now, runtime, workspacePath, workspacePath, workspacePath, workspacePath, cb.projectRootPath(), toolsSection) } func (cb *ContextBuilder) buildToolsSection() string { @@ -127,6 +133,18 @@ func (cb *ContextBuilder) BuildSystemPromptWithMemoryNamespace(memoryNamespace s return strings.Join(parts, "\n\n---\n\n") } +func (cb *ContextBuilder) projectRootPath() string { + root := strings.TrimSpace(cb.cwd) + if root == "" { + root, _ = os.Getwd() + } + root, _ = filepath.Abs(root) + if root == "" { + root = "." + } + return root +} + func (cb *ContextBuilder) LoadBootstrapFiles() string { bootstrapFiles := []string{ "AGENTS.md", @@ -155,6 +173,77 @@ func (cb *ContextBuilder) LoadBootstrapFiles() string { return result } +func (cb *ContextBuilder) LoadProjectPlanningFiles() string { + root := cb.projectRootPath() + if root == "" { + return "" + } + files := []struct { + name string + description string + maxChars int + }{ + {name: "spec.md", description: "Project scope and decisions", maxChars: 4000}, + {name: "tasks.md", description: "Execution plan and progress", maxChars: 5000}, + {name: "checklist.md", description: "Verification checklist", maxChars: 3000}, + } + var parts []string + for _, file := range files { + fullPath := filepath.Join(root, file.name) + data, err := os.ReadFile(fullPath) + if err != nil { + continue + } + text := strings.TrimSpace(string(data)) + if text == "" { + continue + } + if file.maxChars > 0 && len(text) > file.maxChars { + text = strings.TrimSpace(text[:file.maxChars]) + "\n\n[TRUNCATED]" + } + parts = append(parts, fmt.Sprintf("## %s\n\nPath: %s\nPurpose: %s\n\n%s", file.name, fullPath, file.description, text)) + } + return strings.Join(parts, "\n\n") +} + +func (cb *ContextBuilder) shouldUseSpecCoding(currentMessage string) bool { + text := strings.ToLower(strings.TrimSpace(currentMessage)) + if text == "" { + return false + } + if containsAnyKeyword(text, + "spec coding", "spec-driven", "spec驱动", "规范驱动", "用 spec", "spec.md", "tasks.md", "checklist.md", + ) { + return true + } + if !containsAnyKeyword(text, + "写代码", "改代码", "编码", "实现", "开发", "修复", "重构", "补测试", "加测试", "测试", + "implement", "implementation", "code", "coding", "fix", "refactor", "rewrite", "add test", "update test", + ) { + return false + } + if containsAnyKeyword(text, + "小改", "小修", "微调", "轻微", "小幅", "顺手改", "顺便改", "一行", "两行", "单文件", "简单修复", "简单改一下", + "tiny", "small tweak", "minor", "small fix", "quick fix", "one-line", "one line", "two-line", "single-file", "single file", + ) { + return false + } + return containsAnyKeyword(text, + "多文件", "跨模块", "模块", "架构", "设计", "完整", "系统性", "成套", "专项", "一轮", "整体", "项目", "范围", "方案", "联调", + "feature", "workflow", "module", "architecture", "design", "project", "scope", "end-to-end", "full", "multi-file", "cross-cutting", + "debug", "排查", "回归", "返工", "问题定位", + ) +} + +func containsAnyKeyword(text string, keywords ...string) bool { + for _, keyword := range keywords { + if strings.Contains(text, strings.ToLower(strings.TrimSpace(keyword))) { + return true + } + } + return false +} + func (cb *ContextBuilder) shouldLoadBootstrap() bool { identityPath := filepath.Join(cb.workspace, "IDENTITY.md") userPath := filepath.Join(cb.workspace, "USER.md") @@ -187,6 +276,11 @@ func (cb *ContextBuilder) BuildMessagesWithMemoryNamespace(history []providers.M if responseLanguage != "" { systemPrompt += fmt.Sprintf("\n\n## Response Language\nReply in %s unless user explicitly asks to switch language. Keep code identifiers and CLI commands unchanged.", responseLanguage) } + if cb.shouldUseSpecCoding(currentMessage) { + if projectPlanning := cb.LoadProjectPlanningFiles(); projectPlanning != "" { + systemPrompt += "\n\n## Active Project Planning\n\n" + projectPlanning + } + } // Log system prompt summary for debugging (debug mode only) logger.DebugCF("agent", logger.C0143, diff --git a/pkg/agent/context_spec_test.go b/pkg/agent/context_spec_test.go new file mode 100644 index 0000000..ba70899 --- /dev/null +++ b/pkg/agent/context_spec_test.go @@ -0,0 +1,91 @@ +package agent + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestLoadProjectPlanningFilesIncludesSpecDocs(t *testing.T) { + root := t.TempDir() + for name, body := range map[string]string{ + "spec.md": "# spec\nscope", + "tasks.md": "# tasks\n- [ ] one", + "checklist.md": "# checklist\n- [ ] verify", + } { + if err := os.WriteFile(filepath.Join(root, name), []byte(body), 0644); err != nil { + t.Fatalf("write %s: %v", name, err) + } + } + + cb := &ContextBuilder{workspace: t.TempDir(), cwd: root} + got := cb.LoadProjectPlanningFiles() + if !strings.Contains(got, "spec.md") || !strings.Contains(got, "tasks.md") || !strings.Contains(got, "checklist.md") { + t.Fatalf("expected project planning files in output, got:\n%s", got) + } +} + +func TestLoadProjectPlanningFilesTruncatesLargeDocs(t *testing.T) { + root := t.TempDir() + large := strings.Repeat("x", 4500) + if err := os.WriteFile(filepath.Join(root, "spec.md"), []byte(large), 0644); err != nil { + t.Fatalf("write spec.md: %v", err) + } + + cb := &ContextBuilder{workspace: t.TempDir(), cwd: root} + got := cb.LoadProjectPlanningFiles() + if !strings.Contains(got, "[TRUNCATED]") { + t.Fatalf("expected truncation marker, got:\n%s", got) + } +} + +func TestBuildMessagesOnlyLoadsProjectPlanningForCodingTasks(t *testing.T) { + root := t.TempDir() + if err := os.WriteFile(filepath.Join(root, "spec.md"), []byte("# spec\ncoding plan"), 0644); err != nil { + t.Fatalf("write spec.md: %v", err) + } + cb := NewContextBuilder(t.TempDir(), nil) + cb.cwd = root + + coding := cb.BuildMessagesWithMemoryNamespace(nil, "", "请实现一个登录功能", nil, "cli", "direct", "", "main") + if len(coding) == 0 || strings.Contains(coding[0].Content, "Active Project Planning") { + t.Fatalf("did not expect small coding task to include project planning by default, got:\n%s", coding[0].Content) + } + + heavyCoding := cb.BuildMessagesWithMemoryNamespace(nil, "", "请实现一个完整登录注册模块,涉及多文件改动并补测试", nil, "cli", "direct", "", "main") + if len(heavyCoding) == 0 || !strings.Contains(heavyCoding[0].Content, "Active Project Planning") { + t.Fatalf("expected substantial coding task to include project planning, got:\n%s", heavyCoding[0].Content) + } + + explicitSpec := cb.BuildMessagesWithMemoryNamespace(nil, "", "这个改动不大,但请用 spec coding 流程来做", nil, "cli", "direct", "", "main") + if len(explicitSpec) == 0 || !strings.Contains(explicitSpec[0].Content, "Active Project Planning") { + t.Fatalf("expected explicit spec request to include project planning, got:\n%s", explicitSpec[0].Content) + } + + nonCoding := cb.BuildMessagesWithMemoryNamespace(nil, "", "帮我总结一下今天的工作", nil, "cli", "direct", "", "main") + if len(nonCoding) == 0 { + t.Fatalf("expected system message") + } + if strings.Contains(nonCoding[0].Content, "Active Project Planning") { + t.Fatalf("did not expect non-coding task to include project planning, got:\n%s", nonCoding[0].Content) + } +} + +func TestShouldUseSpecCodingRequiresExplicitAndNonTrivialCodingIntent(t *testing.T) { + cb := &ContextBuilder{} + cases := []struct { + message string + want bool + }{ + {message: "请实现一个完整支付模块,涉及多文件改动并补测试", want: true}, + {message: "修一下这个小 bug,顺手改一行就行", want: false}, + {message: "帮我总结这个接口问题", want: false}, + {message: "这个改动不大,但请用 spec coding 流程来做", want: true}, + } + for _, tc := range cases { + if got := cb.shouldUseSpecCoding(tc.message); got != tc.want { + t.Fatalf("shouldUseSpecCoding(%q) = %v, want %v", tc.message, got, tc.want) + } + } +} diff --git a/pkg/agent/loop.go b/pkg/agent/loop.go index 3cec7e2..1d63eb8 100644 --- a/pkg/agent/loop.go +++ b/pkg/agent/loop.go @@ -1011,10 +1011,57 @@ func (al *AgentLoop) processMessage(ctx context.Context, msg bus.InboundMessage) if msg.Channel == "system" { return al.processSystemMessage(ctx, msg) } + specTaskRef := specCodingTaskRef{} + if err := al.maybeEnsureSpecCodingDocs(msg.Content); err != nil { + logger.WarnCF("agent", logger.C0172, map[string]interface{}{ + "session_key": msg.SessionKey, + "error": err.Error(), + }) + } + if taskRef, err := al.maybeStartSpecCodingTask(msg.Content); err != nil { + logger.WarnCF("agent", logger.C0172, map[string]interface{}{ + "session_key": msg.SessionKey, + "error": err.Error(), + }) + } else { + specTaskRef = normalizeSpecCodingTaskRef(taskRef) + } if configAction, handled, configErr := al.maybeHandleSubagentConfigIntent(ctx, msg); handled { + if configErr != nil && specTaskRef.Summary != "" { + if err := al.maybeReopenSpecCodingTask(specTaskRef, msg.Content, configErr.Error()); err != nil { + logger.WarnCF("agent", logger.C0172, map[string]interface{}{ + "session_key": msg.SessionKey, + "error": err.Error(), + }) + } + } + if configErr == nil && specTaskRef.Summary != "" { + if err := al.maybeCompleteSpecCodingTask(specTaskRef, configAction); err != nil { + logger.WarnCF("agent", logger.C0172, map[string]interface{}{ + "session_key": msg.SessionKey, + "error": err.Error(), + }) + } + } return configAction, configErr } if routed, ok, routeErr := al.maybeAutoRoute(ctx, msg); ok { + if routeErr != nil && specTaskRef.Summary != "" { + if err := al.maybeReopenSpecCodingTask(specTaskRef, msg.Content, routeErr.Error()); err != nil { + logger.WarnCF("agent", logger.C0172, map[string]interface{}{ + "session_key": msg.SessionKey, + "error": err.Error(), + }) + } + } + if routeErr == nil && specTaskRef.Summary != "" { + if err := al.maybeCompleteSpecCodingTask(specTaskRef, routed); err != nil { + logger.WarnCF("agent", logger.C0172, map[string]interface{}{ + "session_key": msg.SessionKey, + "error": err.Error(), + }) + } + } return routed, routeErr } @@ -1129,6 +1176,14 @@ func (al *AgentLoop) processMessage(ctx context.Context, msg bus.InboundMessage) "iteration": iteration, "error": err.Error(), }) + if specTaskRef.Summary != "" { + if rerr := al.maybeReopenSpecCodingTask(specTaskRef, msg.Content, err.Error()); rerr != nil { + logger.WarnCF("agent", logger.C0172, map[string]interface{}{ + "session_key": msg.SessionKey, + "error": rerr.Error(), + }) + } + } return "", fmt.Errorf("LLM call failed: %w", err) } @@ -1252,6 +1307,14 @@ func (al *AgentLoop) processMessage(ctx context.Context, msg bus.InboundMessage) "user_length": len(userContent), }) + if specTaskRef.Summary != "" { + if err := al.maybeCompleteSpecCodingTask(specTaskRef, userContent); err != nil { + logger.WarnCF("agent", logger.C0172, map[string]interface{}{ + "session_key": msg.SessionKey, + "error": err.Error(), + }) + } + } al.appendDailySummaryLog(msg, userContent) return userContent, nil } diff --git a/pkg/agent/spec_coding.go b/pkg/agent/spec_coding.go new file mode 100644 index 0000000..b2eca7d --- /dev/null +++ b/pkg/agent/spec_coding.go @@ -0,0 +1,540 @@ +package agent + +import ( + "crypto/sha1" + "encoding/hex" + "fmt" + "os" + "path/filepath" + "regexp" + "strings" + "time" +) + +var specCodingDocNames = []string{"spec.md", "tasks.md", "checklist.md"} +var reWhitespace = regexp.MustCompile(`\s+`) +var reSpecTaskMeta = regexp.MustCompile(`\s*`) +var reChecklistItem = regexp.MustCompile(`(?m)^- \[( |x)\] (.+)$`) + +type specCodingTaskRef struct { + ID string + Summary string +} + +func (al *AgentLoop) maybeEnsureSpecCodingDocs(currentMessage string) error { + if al == nil || al.contextBuilder == nil { + return nil + } + if !al.contextBuilder.shouldUseSpecCoding(currentMessage) { + return nil + } + projectRoot := al.contextBuilder.projectRootPath() + if strings.TrimSpace(projectRoot) == "" { + return nil + } + _, err := ensureSpecCodingDocs(al.workspace, projectRoot) + return err +} + +func (al *AgentLoop) maybeStartSpecCodingTask(currentMessage string) (specCodingTaskRef, error) { + if al == nil || al.contextBuilder == nil || !al.contextBuilder.shouldUseSpecCoding(currentMessage) { + return specCodingTaskRef{}, nil + } + projectRoot := al.contextBuilder.projectRootPath() + if strings.TrimSpace(projectRoot) == "" { + return specCodingTaskRef{}, nil + } + return upsertSpecCodingTask(projectRoot, currentMessage) +} + +func (al *AgentLoop) maybeCompleteSpecCodingTask(taskRef specCodingTaskRef, response string) error { + if al == nil || al.contextBuilder == nil { + return nil + } + taskRef = normalizeSpecCodingTaskRef(taskRef) + if taskRef.Summary == "" { + return nil + } + projectRoot := al.contextBuilder.projectRootPath() + if strings.TrimSpace(projectRoot) == "" { + return nil + } + if err := completeSpecCodingTask(projectRoot, taskRef, response); err != nil { + return err + } + return updateSpecCodingChecklist(projectRoot, taskRef, response) +} + +func (al *AgentLoop) maybeReopenSpecCodingTask(taskRef specCodingTaskRef, currentMessage, reason string) error { + if al == nil || al.contextBuilder == nil { + return nil + } + taskRef = normalizeSpecCodingTaskRef(taskRef) + if taskRef.Summary == "" { + return nil + } + if strings.TrimSpace(reason) == "" && !shouldReopenSpecCodingTask(currentMessage) { + return nil + } + projectRoot := al.contextBuilder.projectRootPath() + if strings.TrimSpace(projectRoot) == "" { + return nil + } + note := strings.TrimSpace(reason) + if note == "" { + note = strings.TrimSpace(currentMessage) + } + if err := reopenSpecCodingTask(projectRoot, taskRef, note); err != nil { + return err + } + return resetSpecCodingChecklist(projectRoot, taskRef, note) +} + +func ensureSpecCodingDocs(workspace, projectRoot string) ([]string, error) { + workspace = strings.TrimSpace(workspace) + projectRoot = strings.TrimSpace(projectRoot) + if workspace == "" || projectRoot == "" { + return nil, nil + } + projectRoot = filepath.Clean(projectRoot) + if err := os.MkdirAll(projectRoot, 0755); err != nil { + return nil, err + } + + templatesDir := filepath.Join(workspace, "skills", "spec-coding", "templates") + created := make([]string, 0, len(specCodingDocNames)) + for _, name := range specCodingDocNames { + targetPath := filepath.Join(projectRoot, name) + if _, err := os.Stat(targetPath); err == nil { + continue + } else if !os.IsNotExist(err) { + return created, err + } + templatePath := filepath.Join(templatesDir, name) + data, err := os.ReadFile(templatePath) + if err != nil { + return created, fmt.Errorf("read spec-coding template %s failed: %w", templatePath, err) + } + if err := os.WriteFile(targetPath, data, 0644); err != nil { + return created, err + } + created = append(created, targetPath) + } + return created, nil +} + +func upsertSpecCodingTask(projectRoot, currentMessage string) (specCodingTaskRef, error) { + projectRoot = strings.TrimSpace(projectRoot) + taskSummary := summarizeSpecCodingTask(currentMessage) + if projectRoot == "" || taskSummary == "" { + return specCodingTaskRef{}, nil + } + taskID := stableSpecCodingTaskID(taskSummary) + taskRef := specCodingTaskRef{ID: taskID, Summary: taskSummary} + tasksPath := filepath.Join(projectRoot, "tasks.md") + data, err := os.ReadFile(tasksPath) + if err != nil { + return specCodingTaskRef{}, err + } + text := string(data) + if line, done, ok := findSpecCodingTaskLine(text, taskRef); ok { + if done { + if shouldReopenSpecCodingTask(currentMessage) { + if err := reopenSpecCodingTask(projectRoot, taskRef, currentMessage); err != nil { + return specCodingTaskRef{}, err + } + } + } + if strings.Contains(line, "", state, taskRef.Summary, taskRef.ID) +} + +func specTaskRefFromLine(line string) specCodingTaskRef { + line = strings.TrimSpace(line) + line = strings.TrimPrefix(line, "- [ ] ") + line = strings.TrimPrefix(line, "- [x] ") + ref := specCodingTaskRef{} + if matches := reSpecTaskMeta.FindStringSubmatch(line); len(matches) == 2 { + ref.ID = strings.ToLower(strings.TrimSpace(matches[1])) + line = strings.TrimSpace(reSpecTaskMeta.ReplaceAllString(line, "")) + } + ref.Summary = summarizeSpecCodingTask(line) + return normalizeSpecCodingTaskRef(ref) +} + +func findSpecCodingTaskLine(tasksText string, taskRef specCodingTaskRef) (string, bool, bool) { + taskRef = normalizeSpecCodingTaskRef(taskRef) + if taskRef.Summary == "" { + return "", false, false + } + for _, raw := range strings.Split(tasksText, "\n") { + line := strings.TrimSpace(raw) + if !strings.HasPrefix(line, "- [ ] ") && !strings.HasPrefix(line, "- [x] ") { + continue + } + ref := specTaskRefFromLine(line) + if ref.Summary == "" { + continue + } + if ref.ID != "" && ref.ID == taskRef.ID { + return line, strings.HasPrefix(line, "- [x] "), true + } + if ref.Summary == taskRef.Summary { + return line, strings.HasPrefix(line, "- [x] "), true + } + } + return "", false, false +} + +func scoreSpecCodingTaskMatch(taskSummary, currentMessage string) int { + taskSummary = summarizeSpecCodingTask(taskSummary) + currentMessage = summarizeSpecCodingTask(currentMessage) + if taskSummary == "" || currentMessage == "" { + return 0 + } + if strings.Contains(currentMessage, taskSummary) || strings.Contains(taskSummary, currentMessage) { + return len(taskSummary) + } + shorter := taskSummary + longer := currentMessage + if len([]rune(shorter)) > len([]rune(longer)) { + shorter, longer = longer, shorter + } + runes := []rune(shorter) + best := 0 + for i := 0; i < len(runes); i++ { + for j := i + 2; j <= len(runes); j++ { + frag := string(runes[i:j]) + if strings.TrimSpace(frag) == "" { + continue + } + if strings.Contains(longer, frag) && len([]rune(frag)) > best { + best = len([]rune(frag)) + } + } + } + return best +} diff --git a/pkg/agent/spec_coding_test.go b/pkg/agent/spec_coding_test.go new file mode 100644 index 0000000..04ca12c --- /dev/null +++ b/pkg/agent/spec_coding_test.go @@ -0,0 +1,233 @@ +package agent + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestEnsureSpecCodingDocsCreatesMissingFilesInProjectRoot(t *testing.T) { + workspace := t.TempDir() + projectRoot := t.TempDir() + templatesDir := filepath.Join(workspace, "skills", "spec-coding", "templates") + if err := os.MkdirAll(templatesDir, 0755); err != nil { + t.Fatalf("mkdir templates: %v", err) + } + for name, body := range map[string]string{ + "spec.md": "# spec template", + "tasks.md": "# tasks template", + "checklist.md": "# checklist template", + } { + if err := os.WriteFile(filepath.Join(templatesDir, name), []byte(body), 0644); err != nil { + t.Fatalf("write template %s: %v", name, err) + } + } + + created, err := ensureSpecCodingDocs(workspace, projectRoot) + if err != nil { + t.Fatalf("ensureSpecCodingDocs failed: %v", err) + } + if len(created) != 3 { + t.Fatalf("expected 3 created files, got %d: %#v", len(created), created) + } + for _, name := range []string{"spec.md", "tasks.md", "checklist.md"} { + if _, err := os.Stat(filepath.Join(projectRoot, name)); err != nil { + t.Fatalf("expected %s to be created: %v", name, err) + } + } +} + +func TestEnsureSpecCodingDocsDoesNotOverwriteExistingFiles(t *testing.T) { + workspace := t.TempDir() + projectRoot := t.TempDir() + templatesDir := filepath.Join(workspace, "skills", "spec-coding", "templates") + if err := os.MkdirAll(templatesDir, 0755); err != nil { + t.Fatalf("mkdir templates: %v", err) + } + for _, name := range []string{"spec.md", "tasks.md", "checklist.md"} { + if err := os.WriteFile(filepath.Join(templatesDir, name), []byte("template"), 0644); err != nil { + t.Fatalf("write template %s: %v", name, err) + } + } + existingPath := filepath.Join(projectRoot, "spec.md") + if err := os.WriteFile(existingPath, []byte("custom spec"), 0644); err != nil { + t.Fatalf("write existing spec: %v", err) + } + + created, err := ensureSpecCodingDocs(workspace, projectRoot) + if err != nil { + t.Fatalf("ensureSpecCodingDocs failed: %v", err) + } + if len(created) != 2 { + t.Fatalf("expected 2 created files, got %d: %#v", len(created), created) + } + data, err := os.ReadFile(existingPath) + if err != nil { + t.Fatalf("read existing spec: %v", err) + } + if string(data) != "custom spec" { + t.Fatalf("expected existing spec to be preserved, got %q", string(data)) + } +} + +func TestSpecCodingTaskProgressUpdatesTasksFile(t *testing.T) { + projectRoot := t.TempDir() + tasksPath := filepath.Join(projectRoot, "tasks.md") + initial := "# Task Breakdown (tasks.md)\n\n## Workstreams\n\n### 1.\n- [ ] base\n\n## Progress Notes\n" + if err := os.WriteFile(tasksPath, []byte(initial), 0644); err != nil { + t.Fatalf("write tasks.md: %v", err) + } + + taskSummary, err := upsertSpecCodingTask(projectRoot, "请实现登录功能并补测试") + if err != nil { + t.Fatalf("upsertSpecCodingTask failed: %v", err) + } + if taskSummary.Summary == "" || taskSummary.ID == "" { + t.Fatalf("expected task summary") + } + if err := completeSpecCodingTask(projectRoot, taskSummary, "登录功能已完成,测试已补齐"); err != nil { + t.Fatalf("completeSpecCodingTask failed: %v", err) + } + + data, err := os.ReadFile(tasksPath) + if err != nil { + t.Fatalf("read tasks.md: %v", err) + } + text := string(data) + if !strings.Contains(text, renderSpecCodingTaskLine(true, taskSummary)) { + t.Fatalf("expected task to be checked, got:\n%s", text) + } + if !strings.Contains(text, "登录功能已完成") { + t.Fatalf("expected progress note summary, got:\n%s", text) + } +} + +func TestSpecCodingTaskReopensCompletedTaskWhenIssueReturns(t *testing.T) { + projectRoot := t.TempDir() + tasksPath := filepath.Join(projectRoot, "tasks.md") + taskSummary := "请实现登录功能并补测试" + initial := "# Task Breakdown (tasks.md)\n\n## Current Coding Tasks\n- [x] " + taskSummary + "\n\n## Progress Notes\n" + if err := os.WriteFile(tasksPath, []byte(initial), 0644); err != nil { + t.Fatalf("write tasks.md: %v", err) + } + + got, err := upsertSpecCodingTask(projectRoot, "登录功能还有问题,继续排查并修复") + if err != nil { + t.Fatalf("upsertSpecCodingTask failed: %v", err) + } + if got.Summary != taskSummary { + t.Fatalf("expected reopened task summary %q, got %q", taskSummary, got.Summary) + } + + data, err := os.ReadFile(tasksPath) + if err != nil { + t.Fatalf("read tasks.md: %v", err) + } + text := string(data) + if !strings.Contains(text, renderSpecCodingTaskLine(false, got)) { + t.Fatalf("expected task to be reopened, got:\n%s", text) + } + if !strings.Contains(text, "reopened "+taskSummary) { + t.Fatalf("expected reopened progress note, got:\n%s", text) + } +} + +func TestSpecCodingTaskUsesStableIDAcrossCompleteAndReopen(t *testing.T) { + projectRoot := t.TempDir() + tasksPath := filepath.Join(projectRoot, "tasks.md") + initial := "# Task Breakdown (tasks.md)\n\n## Current Coding Tasks\n\n## Progress Notes\n" + if err := os.WriteFile(tasksPath, []byte(initial), 0644); err != nil { + t.Fatalf("write tasks.md: %v", err) + } + + taskRef, err := upsertSpecCodingTask(projectRoot, "实现支付回调验签并补充回归测试") + if err != nil { + t.Fatalf("upsertSpecCodingTask failed: %v", err) + } + if taskRef.ID == "" || taskRef.Summary == "" { + t.Fatalf("expected stable task ref, got %#v", taskRef) + } + if err := completeSpecCodingTask(projectRoot, taskRef, "支付回调验签已完成,测试已补充"); err != nil { + t.Fatalf("completeSpecCodingTask failed: %v", err) + } + + reopened, err := upsertSpecCodingTask(projectRoot, "支付回调验签回归失败,继续排查修复") + if err != nil { + t.Fatalf("upsertSpecCodingTask reopen failed: %v", err) + } + if reopened.ID != taskRef.ID { + t.Fatalf("expected reopened task to keep id %q, got %q", taskRef.ID, reopened.ID) + } + + data, err := os.ReadFile(tasksPath) + if err != nil { + t.Fatalf("read tasks.md: %v", err) + } + text := string(data) + if strings.Count(text, "") != 1 { + t.Fatalf("expected exactly one task line for stable id %q, got:\n%s", taskRef.ID, text) + } + if !strings.Contains(text, renderSpecCodingTaskLine(false, reopened)) { + t.Fatalf("expected reopened task line with same id, got:\n%s", text) + } +} + +func TestSpecCodingChecklistUpdatesOnTaskCompletion(t *testing.T) { + projectRoot := t.TempDir() + checklistPath := filepath.Join(projectRoot, "checklist.md") + initial := "# Verification Checklist (checklist.md)\n\n- [ ] Scope implemented\n- [ ] Edge cases reviewed\n- [ ] Tests added or updated where needed\n- [ ] Validation run\n- [ ] Docs / prompts / config updated if required\n- [ ] No known missing follow-up inside current scope\n" + if err := os.WriteFile(checklistPath, []byte(initial), 0644); err != nil { + t.Fatalf("write checklist.md: %v", err) + } + + taskRef := specCodingTaskRef{Summary: "实现支付回调验签并补充测试"} + if err := updateSpecCodingChecklist(projectRoot, taskRef, "已完成实现,go test 验证通过,并更新文档说明"); err != nil { + t.Fatalf("updateSpecCodingChecklist failed: %v", err) + } + + data, err := os.ReadFile(checklistPath) + if err != nil { + t.Fatalf("read checklist.md: %v", err) + } + text := string(data) + for _, needle := range []string{ + "- [x] Scope implemented", + "- [x] Tests added or updated where needed", + "- [x] Validation run", + "- [x] Docs / prompts / config updated if required", + "- [x] No known missing follow-up inside current scope", + "## Verification Notes", + "verified 实现支付回调验签并补充测试", + } { + if !strings.Contains(text, needle) { + t.Fatalf("expected checklist to contain %q, got:\n%s", needle, text) + } + } +} + +func TestSpecCodingChecklistResetsWhenTaskReopens(t *testing.T) { + projectRoot := t.TempDir() + checklistPath := filepath.Join(projectRoot, "checklist.md") + initial := "# Verification Checklist (checklist.md)\n\n- [x] Scope implemented\n- [x] Edge cases reviewed\n- [x] Tests added or updated where needed\n- [x] Validation run\n- [x] Docs / prompts / config updated if required\n- [x] No known missing follow-up inside current scope\n" + if err := os.WriteFile(checklistPath, []byte(initial), 0644); err != nil { + t.Fatalf("write checklist.md: %v", err) + } + + taskRef := specCodingTaskRef{Summary: "实现支付回调验签并补充测试"} + if err := resetSpecCodingChecklist(projectRoot, taskRef, "回归失败,继续排查"); err != nil { + t.Fatalf("resetSpecCodingChecklist failed: %v", err) + } + + data, err := os.ReadFile(checklistPath) + if err != nil { + t.Fatalf("read checklist.md: %v", err) + } + text := string(data) + if strings.Contains(text, "- [x] ") { + t.Fatalf("expected all checklist items to reopen, got:\n%s", text) + } + if !strings.Contains(text, "reopened 实现支付回调验签并补充测试") { + t.Fatalf("expected reopen note, got:\n%s", text) + } +} diff --git a/pkg/api/server.go b/pkg/api/server.go index 3fa9bef..211142f 100644 --- a/pkg/api/server.go +++ b/pkg/api/server.go @@ -1226,8 +1226,36 @@ func (s *Server) webUINodesPayload(ctx context.Context) map[string]interface{} { if s.mgr != nil { list = s.mgr.List() } + localRegistry := s.fetchRegistryItems(ctx) + localAgents := make([]nodes.AgentInfo, 0, len(localRegistry)) + for _, item := range localRegistry { + agentID := strings.TrimSpace(stringFromMap(item, "agent_id")) + if agentID == "" { + continue + } + localAgents = append(localAgents, nodes.AgentInfo{ + ID: agentID, + DisplayName: strings.TrimSpace(stringFromMap(item, "display_name")), + Role: strings.TrimSpace(stringFromMap(item, "role")), + Type: strings.TrimSpace(stringFromMap(item, "type")), + Transport: fallbackString(strings.TrimSpace(stringFromMap(item, "transport")), "local"), + }) + } host, _ := os.Hostname() - local := nodes.NodeInfo{ID: "local", Name: "local", Endpoint: "gateway", Version: gatewayBuildVersion(), LastSeenAt: time.Now(), Online: true} + local := nodes.NodeInfo{ + ID: "local", + Name: "local", + Endpoint: "gateway", + Version: gatewayBuildVersion(), + OS: runtime.GOOS, + Arch: runtime.GOARCH, + LastSeenAt: time.Now(), + Online: true, + Capabilities: nodes.Capabilities{Run: true, Invoke: true, Model: true, Camera: true, Screen: true, Location: true, Canvas: true}, + Actions: []string{"run", "agent_task", "camera_snap", "camera_clip", "screen_snapshot", "screen_record", "location_get", "canvas_snapshot", "canvas_action"}, + Models: []string{"local-sim"}, + Agents: localAgents, + } if strings.TrimSpace(host) != "" { local.Name = host } diff --git a/pkg/api/server_test.go b/pkg/api/server_test.go index 6bbfa62..9f68a31 100644 --- a/pkg/api/server_test.go +++ b/pkg/api/server_test.go @@ -486,6 +486,59 @@ func TestHandleWebUINodesIncludesP2PSummary(t *testing.T) { } } +func TestHandleWebUINodesEnrichesLocalNodeMetadata(t *testing.T) { + t.Parallel() + + srv := NewServer("127.0.0.1", 0, "", nodes.NewManager()) + srv.SetSubagentHandler(func(ctx context.Context, action string, args map[string]interface{}) (interface{}, error) { + if action != "registry" { + return map[string]interface{}{"items": []map[string]interface{}{}}, nil + } + return map[string]interface{}{ + "items": []map[string]interface{}{ + { + "agent_id": "coder", + "display_name": "Code Agent", + "role": "coding", + "type": "worker", + "transport": "local", + }, + }, + }, nil + }) + + req := httptest.NewRequest(http.MethodGet, "/webui/api/nodes", nil) + rec := httptest.NewRecorder() + srv.handleWebUINodes(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", rec.Code) + } + + var body map[string]interface{} + if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { + t.Fatalf("decode body: %v", err) + } + items, _ := body["nodes"].([]interface{}) + if len(items) == 0 { + t.Fatalf("expected local node in payload") + } + local, _ := items[0].(map[string]interface{}) + if strings.TrimSpace(fmt.Sprint(local["id"])) != "local" { + t.Fatalf("expected first node to be local, got %+v", local) + } + if strings.TrimSpace(fmt.Sprint(local["os"])) == "" || strings.TrimSpace(fmt.Sprint(local["arch"])) == "" { + t.Fatalf("expected local os/arch, got %+v", local) + } + actions, _ := local["actions"].([]interface{}) + if len(actions) == 0 { + t.Fatalf("expected local actions, got %+v", local) + } + agents, _ := local["agents"].([]interface{}) + if len(agents) != 1 { + t.Fatalf("expected local agents from registry, got %+v", local) + } +} + func TestHandleWebUINodeDispatchReplay(t *testing.T) { t.Parallel() diff --git a/spec.md b/spec.md new file mode 100644 index 0000000..3faf913 --- /dev/null +++ b/spec.md @@ -0,0 +1,36 @@ +# Project Scope (spec.md) + +## Overview +- What is being built: spec-driven coding support for ClawGo. +- Why this change is needed: coding tasks need a durable project scope, task plan, and verification gate instead of ad hoc reasoning scattered across chat turns. +- Desired outcome: ClawGo should guide non-trivial coding work through `spec.md`, `tasks.md`, and `checklist.md`, expose that workflow in workspace policy, make active project docs visible to the agent during execution, and keep `tasks.md` progress moving automatically during coding turns. + Completed tasks should also be reopenable when later debugging or regression work shows the task is not actually done. + +## In Scope +- Add a built-in `spec-coding` skill. +- Provide a scaffold script for `spec.md`, `tasks.md`, and `checklist.md`. +- Update workspace agent policy to prefer this workflow for non-trivial coding work. +- Load current-project spec docs into agent context when present. +- Document the workflow in the repo README. + +## Out of Scope +- A dedicated planner UI for spec documents. +- Automatic task execution directly from `tasks.md`. +- Complex state synchronization beyond markdown documents. + +## Decisions +- Use plain markdown files named exactly `spec.md`, `tasks.md`, and `checklist.md`. +- Default location is the current project root, not the long-term workspace directory. +- Expose the workflow primarily through workspace policy + built-in skill, not a new core planner service. +- Load project docs into the system prompt with truncation to keep context bounded. +- Only activate this workflow automatically for coding-oriented tasks, not for general chat or non-coding work. +- Treat `workspace/skills/spec-coding/` as template storage only; project-specific docs must live in the target coding project. + +## Tradeoffs +- Loading project docs into prompt improves continuity but increases token usage when the files exist. +- Root-level markdown files are simple and transparent, but they do add repo-visible artifacts. +- A skill + prompt workflow is faster to adopt than introducing a new runtime subsystem, but it depends on agent discipline. + +## Risks / Open Questions +- `tasks.md` can grow large; prompt truncation mitigates this but may eventually need smarter summarization. +- Some users may prefer a nested path such as `.clawgo/specs/`; current default stays simple unless explicitly requested. diff --git a/tasks.md b/tasks.md new file mode 100644 index 0000000..ffd6977 --- /dev/null +++ b/tasks.md @@ -0,0 +1,32 @@ +# Task Breakdown (tasks.md) + +## Workstreams + +### 1. Workspace workflow +- [x] Define how spec-driven coding should work in ClawGo. +- [x] Update workspace policy to require `spec.md`, `tasks.md`, and `checklist.md` for non-trivial coding tasks. +- [x] Add a built-in `spec-coding` skill. + +### 2. Scaffolding +- [x] Add a script that initializes the three markdown documents in the current project root. +- [x] Keep scaffolding idempotent so existing docs are preserved. +- [x] Reuse shared template files so shell and runtime initialization stay consistent. + +### 3. Agent context +- [x] Extend the context builder to surface current-project `spec.md`, `tasks.md`, and `checklist.md` when present. +- [x] Add truncation to keep prompt growth bounded. +- [x] Add tests for loading and truncation behavior. +- [x] Restrict project planning docs to coding-oriented tasks only. +- [x] Auto-initialize spec docs in the coding target project root when a coding task begins and files are missing. +- [x] Auto-register the current coding request into `tasks.md`. +- [x] Auto-mark the request complete and append a progress note when the turn succeeds. +- [x] Reopen previously completed tasks when later repair/regression/debug work indicates they are not actually done. + +### 4. Documentation +- [x] Document the workflow in `README.md`. +- [x] Apply the workflow to the current ClawGo enhancement itself by creating the three docs in the repo root. +- [x] Clarify that skill files are templates and real project docs live in the coding target project. + +## Progress Notes +- The implementation stays lightweight on purpose: markdown files + built-in skill + policy + context loading. +- No new planner service or database state was introduced in this iteration. diff --git a/webui/src/components/NavItem.tsx b/webui/src/components/NavItem.tsx index 1944ce7..8a12657 100644 --- a/webui/src/components/NavItem.tsx +++ b/webui/src/components/NavItem.tsx @@ -1,6 +1,5 @@ import React from 'react'; import { NavLink } from 'react-router-dom'; -import { Check } from 'lucide-react'; interface NavItemProps { icon: React.ReactNode; @@ -23,11 +22,6 @@ const NavItem: React.FC = ({ icon, label, to, collapsed = false }) <> {icon} {!collapsed && {label}} - {!collapsed && isActive && ( - - - - )} )} diff --git a/webui/src/components/Sidebar.tsx b/webui/src/components/Sidebar.tsx index 8eb2596..0271e35 100644 --- a/webui/src/components/Sidebar.tsx +++ b/webui/src/components/Sidebar.tsx @@ -58,11 +58,11 @@ const Sidebar: React.FC = () => { return (