From 29a3ebe865cd4738999321ee756a28dce66e0c42 Mon Sep 17 00:00:00 2001 From: 2penheimer <2603237065@qq.com> Date: Tue, 22 Sep 2026 10:06:34 +0800 Subject: [PATCH 1/8] =?UTF-8?q?feat:=20=E8=90=BD=E5=9C=B0=E7=9C=8B?= =?UTF-8?q?=E6=9D=BF=EF=BC=8C=E6=8A=8A=E4=BC=9A=E8=AF=9D=E6=8C=89=20Work?= =?UTF-8?q?=20=E6=94=B6=E6=88=90=E5=8D=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 说明默认只读,点开用 Markdown 编辑器修改;Issue 和 PR 改成按行填链接,不再让人选种类。 Co-authored-by: Cursor --- AGENTS.md | 16 +- apps/web/app/(chat)/board/page.tsx | 4 + apps/web/app/chat-host.tsx | 74 +- docs/architecture.md | 59 +- packages/core/board/client.test.ts | 80 ++ packages/core/board/client.ts | 290 +++++++ packages/core/board/index.ts | 21 + packages/core/board/types.ts | 138 ++++ packages/core/index.ts | 11 + packages/core/package.json | 5 +- packages/views/board/board-grid.tsx | 110 +++ packages/views/board/group.test.ts | 38 + packages/views/board/group.ts | 63 ++ packages/views/board/inbox-actions.tsx | 83 ++ packages/views/board/index.ts | 5 + packages/views/board/info-editor.tsx | 146 ++++ packages/views/board/layout.test.ts | 57 ++ packages/views/board/layout.ts | 51 ++ packages/views/board/provider.tsx | 24 + packages/views/board/session-float.tsx | 265 ++++++ packages/views/board/session-links.tsx | 125 +++ packages/views/board/work-column.tsx | 299 +++++++ packages/views/chat/chat-page.tsx | 115 ++- packages/views/chat/session-sidebar.tsx | 254 ++++-- packages/views/index.ts | 9 + packages/views/package.json | 7 +- pnpm-lock.yaml | 296 +++++++ server/cmd/server/main.go | 9 + server/cmd/server/router.go | 36 +- server/internal/agent/coordinator.go | 12 +- server/internal/agent/coordinator_test.go | 4 +- server/internal/agent/memory/memory_test.go | 13 + server/internal/agent/memory/text.go | 2 +- server/internal/agent/memory/types.go | 3 +- server/internal/agent/memory_index.go | 10 +- server/internal/agent/memory_index_test.go | 36 + server/internal/agent/tools/memory.go | 10 + server/internal/archtest/arch_test.go | 1 + server/internal/board/board_test.go | 213 +++++ server/internal/board/service.go | 395 +++++++++ server/internal/board/store.go | 549 +++++++++++++ server/internal/board/types.go | 240 ++++++ server/internal/handler/api.go | 25 +- server/internal/handler/board.go | 668 +++++++++++++++ server/internal/handler/board_ports.go | 355 ++++++++ server/internal/handler/board_test.go | 104 +++ server/internal/handler/claude.go | 7 +- server/internal/handler/codex/api.go | 19 +- server/internal/handler/fixture_test.go | 26 + server/internal/handler/memory.go | 14 +- server/internal/pluginhost/host.go | 22 +- server/migrations/0011_board.sql | 63 ++ server/pkg/agent/context.go | 3 + server/pkg/agent/prompt.go | 3 + server/pkg/agent/types.go | 1 + server/pkg/claude/approval.go | 25 + server/pkg/db/queries/approvals.sql | 18 + server/pkg/db/queries/board.sql | 187 +++++ server/pkg/db/queries/sessions.sql | 5 + server/pkg/db/sqlite/approvals.sql.go | 102 +++ server/pkg/db/sqlite/board.sql.go | 858 ++++++++++++++++++++ server/pkg/db/sqlite/models.go | 53 ++ server/pkg/db/sqlite/sessions.sql.go | 42 + server/pkg/git/run.go | 25 + server/pkg/github/github.go | 169 ++++ server/pkg/github/github_test.go | 75 ++ 66 files changed, 6874 insertions(+), 173 deletions(-) create mode 100644 apps/web/app/(chat)/board/page.tsx create mode 100644 packages/core/board/client.test.ts create mode 100644 packages/core/board/client.ts create mode 100644 packages/core/board/index.ts create mode 100644 packages/core/board/types.ts create mode 100644 packages/views/board/board-grid.tsx create mode 100644 packages/views/board/group.test.ts create mode 100644 packages/views/board/group.ts create mode 100644 packages/views/board/inbox-actions.tsx create mode 100644 packages/views/board/index.ts create mode 100644 packages/views/board/info-editor.tsx create mode 100644 packages/views/board/layout.test.ts create mode 100644 packages/views/board/layout.ts create mode 100644 packages/views/board/provider.tsx create mode 100644 packages/views/board/session-float.tsx create mode 100644 packages/views/board/session-links.tsx create mode 100644 packages/views/board/work-column.tsx create mode 100644 server/internal/agent/memory_index_test.go create mode 100644 server/internal/board/board_test.go create mode 100644 server/internal/board/service.go create mode 100644 server/internal/board/store.go create mode 100644 server/internal/board/types.go create mode 100644 server/internal/handler/board.go create mode 100644 server/internal/handler/board_ports.go create mode 100644 server/internal/handler/board_test.go create mode 100644 server/migrations/0011_board.sql create mode 100644 server/pkg/db/queries/board.sql create mode 100644 server/pkg/db/sqlite/board.sql.go create mode 100644 server/pkg/github/github.go create mode 100644 server/pkg/github/github_test.go diff --git a/AGENTS.md b/AGENTS.md index 5b2b209..e4494ce 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -7,24 +7,26 @@ Agent Loop 已闭环:用户发文本、装上下文、调模型、产出文字 ## 目录放置规则 - 服务启动、配置读取、Router 和依赖装配放在 `server/cmd/server`。 -- 大部分 HTTP 逻辑放在 `server/internal/handler`:Session / Message / Usage / Approval 的 CRUD,SSE,Run 的 Start / Continue / Cancel / Restore,审批裁决,用户侧记忆查看/删除,以及 Git(直接调 `pkg/git`)。创建 Session 时在本包冻结 `workspace_id`。不 import `internal/agent/tools`。新对话选目录由 web 弹出系统目录选择框,不走 Agent Tool。Codex 的独立 `/codex` HTTP 放在 `server/internal/handler/codex`。Claude Code HTTP 仍在 `server/internal/handler`,直接调 `pkg/claude`。 -- Agent 运行时编排和 sqlc 持久化放在 `server/internal/agent`。 +- 大部分 HTTP 逻辑放在 `server/internal/handler`:Session / Message / Usage / Approval 的 CRUD,SSE,Run 的 Start / Continue / Cancel / Restore,审批裁决,用户侧记忆查看/删除,Git(直接调 `pkg/git`),以及看板 `/works` `/board`(调 `internal/board`)。创建 Session 时在本包冻结 `workspace_id`。不 import `internal/agent/tools`。新对话选目录由 web 弹出系统目录选择框,不走 Agent Tool。Codex 的独立 `/codex` HTTP 放在 `server/internal/handler/codex`。Claude Code HTTP 仍在 `server/internal/handler`,直接调 `pkg/claude`。 +- Agent 运行时编排和 sqlc 持久化放在 `server/internal/agent`。本回合记忆装 user + 当前 Work(`ScopeWork`);`workspace` 热层保留但不进本回合。有 placement 时 native Loop 追加 Work Packet 为独立 system。 +- 产品工作流放在 `server/internal/board`:Work / Checkout / Info / Placement / Board 聚合 / Inbox。不写记忆、不 spawn CLI、不建 worktree。不进 `pkg/agent`。 - 本机 Codex app-server 生命周期、内存排队/问票/SSE 放在 `server/internal/codex`。不新增 Codex 业务表;凡官方 API 能读到的都不入库。 - Markdown 记忆(热层目录+专题)与 context message 索引(冷层按工作区 FTS)放在 `server/internal/agent/memory`;不放 `pkg/memory`。memory 不 import 父包 `internal/agent`,不定义 Tool。 - 具体工具定义放在 `server/internal/agent/tools`。工具名、入参/出参、schema、权限和编排都在本包;Execute 若要调外部能力,只通过 `Ports` 里的接口。Runtime `New` 时由 `cmd/server` 注入 `Ports` 的具体实现,再 `Register`。每个工具只定义入参/出参结构体,执行用 `encoding/json`,schema 从类型推断。`tools` 可 import `memory`,不 import 父包 `internal/agent`。 - Agent 通用无状态逻辑放在 `server/pkg/agent`:类型、token 统计、提示词、上下文、Tool 抽象(不含具体工具定义)、Agent 配置、模型调用。六个口的信封放在 `pkg/agent/seam`。 - 插件 SDK 与 proto 放在 `server/pkg/plugin`;宿主放在 `server/internal/pluginhost`。两者都不进 `pkg/agent`,也不知道主循环内部状态机。可装载的插件放在仓根 `plugin/`(子目录名即插件名)。`plugin/example` 是作者拷贝模板:不订阅、不改正文、不换向、不登记方法。不进 `pkg/plugin`。插件共享参数用 `PluginContext`,不进模型、不复用 Hidden。 - Git CLI 操作放在 `server/pkg/git`:无状态,不写产品流程;Handler 直接调用。不进 `pkg/agent`。 +- Issue/PR 只读快照放在 `server/pkg/github`:无状态,走本机 `gh`;不关 issue、不合 PR、不落 token。不进 `pkg/agent`。 - Codex 协议与领域类型放在 `server/pkg/codex`:看板的子模块,JSONL 客户端给 `internal/codex` 调用;不查库、不 spawn CLI。不进 `pkg/agent`。 - Claude Code 对接放在 `server/pkg/claude`:无状态,不落库;会话 / 实录 / 模型 / 权限档从本机 Claude 读。Handler 直接调用。不进 `pkg/agent`,不走本地对话的工具 / 记忆 / 压缩。 - 进程内事件总线放在 `server/internal/events`。 - 数据库入口和 sqlc 生成代码放在 `server/pkg/db`。 - 数据库结构演进放在 `server/migrations`。 - 运行时产生的文件(SQLite 等)放在仓根 `data/`,不要写进 `server/`。该目录 gitignore。 -- 无头业务放在 `packages/core`(`@codedock/core`):按业务域拆(现有 `chat/`、`git/`、`codex/`、`claude/`),文件直接在域目录下,不要 `src/`。不依赖 React、Next、DOM、`process.env`。`baseUrl` / `userId` 由调用方注入。Git 用独立 `GitClient`。Codex 用独立 `CodexClient`,Claude 用独立 `ClaudeClient`,不扩 `AgentClient`。 +- 无头业务放在 `packages/core`(`@codedock/core`):按业务域拆(现有 `chat/`、`git/`、`codex/`、`claude/`、`board/`),文件直接在域目录下,不要 `src/`。不依赖 React、Next、DOM、`process.env`。`baseUrl` / `userId` 由调用方注入。Git 用独立 `GitClient`。Codex 用独立 `CodexClient`,Claude 用独立 `ClaudeClient`,看板用独立 `BoardClient`,不扩 `AgentClient`。 - 无业务 UI 放在 `packages/ui`(`@codedock/ui`):`components/`、`lib/`、`styles/`,不要 `src/`,不按业务域拆。不依赖 core,不知道 Session / Run / TimelineItem。 -- 组合层放在 `packages/views`(`@codedock/views`):按业务域拆,与 core 对齐(现有 `chat/`、`git/`、`codex/`、`claude/`)。包根 `provider.tsx` 注入 Agent client;Git 用 `views/git` 的 `GitProvider`;Codex 用 `views/codex` 的 `CodexProvider`,Claude 用 `views/claude` 的 `ClaudeProvider`,由 `ChatPage` 按模式组合,不单独做 Codex / Claude 页。不 import `next/*`;导航用回调。不要 `src/`,不预建空业务域。 -- Web 路由和平台装配放在 `apps/web`:读 `NEXT_PUBLIC_*`、创建 `AgentClient` / `GitClient` / `CodexClient` / `ClaudeClient`、包对应 Provider、`router.push`。`/git` 放在 `(chat)` 组外。Codex 走 `/` 与 `/s/c/:id`,Claude 走 `/` 与 `/s/claude/:id`。不解析 SSE。 +- 组合层放在 `packages/views`(`@codedock/views`):按业务域拆,与 core 对齐(现有 `chat/`、`git/`、`codex/`、`claude/`、`board/`)。包根 `provider.tsx` 注入 Agent client;Git 用 `views/git` 的 `GitProvider`;Codex 用 `views/codex` 的 `CodexProvider`,Claude 用 `views/claude` 的 `ClaudeProvider`,看板用 `views/board` 的 `BoardProvider`,由 `ChatPage` 组会话 / 看板两态,不单独做 Codex / Claude / 看板页。右侧窗口栏只留 Plan / 文件 / Git。不 import `next/*`;导航用回调。不要 `src/`,不预建空业务域。 +- Web 路由和平台装配放在 `apps/web`:读 `NEXT_PUBLIC_*`、创建 `AgentClient` / `GitClient` / `CodexClient` / `ClaudeClient` / `BoardClient`、包对应 Provider、`router.push`。`/git` 放在 `(chat)` 组外。Codex 走 `/` 与 `/s/c/:id`,Claude 走 `/` 与 `/s/claude/:id`,看板走 `/board`。不解析 SSE。 - 依赖方向:`apps/web` → `packages/views` → `packages/core`;`packages/views` → `packages/ui`。`ui` 不依赖 `core`。未来 CLI 只依赖 `core`。 - 不要创建 `server/pkg/ai`。大模型调用属于 `pkg/agent`。 @@ -40,9 +42,9 @@ Agent Loop 已闭环:用户发文本、装上下文、调模型、产出文字 - Agent 契约不得依赖 React、UI 包或路由框架。 - 流式事件是通知,不是真实数据源;重连时应按 `event_seq` 回放。 - 不要预先创建 Issue、Task、Review、Workspace 或其他具体业务域目录。 -- 不要把产品工作流放入 `server/pkg`。Git、Codex 与 Claude Code 是本机 CLI 域:不落库,Handler 直接调 `pkg/git` / `pkg/codex` / `pkg/claude`。 +- 不要把产品工作流放入 `server/pkg`。Git、Codex 与 Claude Code 是本机 CLI 域:不落库,Handler 直接调 `pkg/git` / `pkg/codex` / `pkg/claude`。看板工作流在 `internal/board`;`pkg/github` 只做 gh 快照。 - 前端三层不得反依赖:`core` 不依赖 React / Next / DOM / `process.env`;`ui` 不依赖 `core`;`views` 不 import `next/*`;`apps/web` 只做路由与平台装配。 -- 前端按业务域拆模块,不要 `src/`:`core` / `views` 用同名域目录(现有 `chat` / `git` / `codex` / `claude`);`ui` 只用 `components` / `lib` / `styles`。新业务再建目录,不预建空文件夹。 +- 前端按业务域拆模块,不要 `src/`:`core` / `views` 用同名域目录(现有 `chat` / `git` / `codex` / `claude` / `board`);`ui` 只用 `components` / `lib` / `styles`。新业务再建目录,不预建空文件夹。 ## 注释规则 diff --git a/apps/web/app/(chat)/board/page.tsx b/apps/web/app/(chat)/board/page.tsx new file mode 100644 index 0000000..8d29a08 --- /dev/null +++ b/apps/web/app/(chat)/board/page.tsx @@ -0,0 +1,4 @@ +// BoardPage 只占 /board 路由;看板壳由 ChatHost 组两态。 +export default function BoardPage() { + return null; +} diff --git a/apps/web/app/chat-host.tsx b/apps/web/app/chat-host.tsx index 73ad11c..ab4dce2 100644 --- a/apps/web/app/chat-host.tsx +++ b/apps/web/app/chat-host.tsx @@ -1,19 +1,25 @@ "use client"; +import { BoardClient } from "@codedock/core/board"; import { GitClient } from "@codedock/core/git"; +import { BoardProvider } from "@codedock/views/board"; import { ChatPage, type SessionEngine } from "@codedock/views/chat"; import { GitProvider } from "@codedock/views/git"; import { usePathname, useRouter } from "next/navigation"; -import { useMemo } from "react"; +import { useMemo, useRef } from "react"; -import { apiBase } from "@/lib/env"; +import { apiBase, defaultUserId } from "@/lib/env"; import { rememberSession } from "@/lib/session"; -// ChatHost 解析对话路径并把导航收成回调;views 不知道具体 URL。 +// ChatHost 解析对话 / 看板路径并把导航收成回调;views 不知道具体 URL。 export function ChatHost() { const pathname = usePathname(); const router = useRouter(); const parsed = parseChatPath(pathname); + const lastSession = useRef<{ id: string; engine: SessionEngine } | null>(null); + if (parsed.sessionId && parsed.engine) { + lastSession.current = { id: parsed.sessionId, engine: parsed.engine }; + } if (parsed.sessionId && parsed.engine === "agent") { rememberSession(parsed.sessionId); } @@ -22,33 +28,55 @@ export function ChatHost() { () => new GitClient({ baseUrl: apiBase, sessionId: gitSessionId }), [gitSessionId], ); + const board = useMemo( + () => new BoardClient({ baseUrl: apiBase, userId: defaultUserId }), + [], + ); return ( - { - const path = pathFor(id, engine); - if (pathname !== path) { - router.push(path); - } - }} - onNewConversation={() => { - if (pathname !== "/") { - router.push("/"); - } - }} - /> + + { + const path = pathFor(id, engine); + if (pathname !== path) { + router.push(path); + } + }} + onNewConversation={() => { + if (pathname !== "/") { + router.push("/"); + } + }} + onOpenBoard={() => { + if (pathname !== "/board") { + router.push("/board"); + } + }} + onLeaveBoard={() => { + const last = lastSession.current; + const path = last ? pathFor(last.id, last.engine) : "/"; + if (pathname !== path) { + router.push(path); + } + }} + /> + ); } -// parseChatPath 先认 /s/claude/:id,再认 /s/c/:id,避免吃掉 Claude 前缀。 -function parseChatPath(pathname: string): { sessionId?: string; engine?: SessionEngine } { +// parseChatPath 先认 /board,再认 /s/claude/:id 与 /s/c/:id。 +function parseChatPath(pathname: string): { sessionId?: string; engine?: SessionEngine; board?: boolean } { + if (pathname === "/board" || pathname.startsWith("/board/")) { + return { board: true }; + } const claude = pathname.match(/^\/s\/claude\/([^/]+)/); if (claude?.[1]) { return { sessionId: decodeURIComponent(claude[1]), engine: "claude" }; diff --git a/docs/architecture.md b/docs/architecture.md index 617f408..26df2cd 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -7,7 +7,7 @@ Agent Loop 已闭环:Handler 写用户消息与 Run,Worker 领取后由 Runt ## 总体架构 ```text -apps/web (路由 + NEXT_PUBLIC_* + AgentClient / GitClient / CodexClient / ClaudeClient) +apps/web (路由 + NEXT_PUBLIC_* + AgentClient / GitClient / CodexClient / ClaudeClient / BoardClient) | v packages/views (Chat 壳 / hooks,无 next/*) @@ -28,18 +28,20 @@ server/internal/handler |-- 用户记忆查看 / 删除 --> pkg/db/sqlite |-- Git HTTP --> pkg/git(本机 CLI,无产品流程) |-- Claude HTTP --> pkg/claude(本机 Claude Code,不落库) + |-- 看板 /works /board --> internal/board(Work / Checkout / Placement / Inbox) | v server/internal/agent |-- sqlc 读写 Run / Turn / 事件 / 用量 / checkpoint |-- 调用 pkg/agent 无状态方法 |-- 先持久化 AgentEvent,再发布 internal/events.Bus - |-- memory:热层目录+专题,冷层按工作区 FTS;Loop 装目录并 IndexMessage + |-- memory:热层目录+专题(含 work 范围),冷层按工作区 FTS;本回合装 user+Work |-- tools:工具定义在本包;New 注入 Ports(Execute 用的外部实现)后 Register | v server/pkg/agent server/pkg/git +server/pkg/github server/pkg/codex server/pkg/claude ``` @@ -53,9 +55,9 @@ CodeDock/ ├── apps/ │ └── web/ # Next.js 路由与平台装配;不解析 SSE ├── packages/ -│ ├── core/ # 无头业务;按业务域拆(现有 chat/ git/ codex/ claude/),不要 src/ +│ ├── core/ # 无头业务;按业务域拆(现有 chat/ git/ codex/ claude/ board/),不要 src/ │ ├── ui/ # 无业务语义;components / lib / styles,不要 src/ -│ └── views/ # 组合层;按业务域拆(现有 chat/ git/ codex/ claude/),不要 src/ +│ └── views/ # 组合层;按业务域拆(现有 chat/ git/ codex/ claude/ board/),不要 src/ ├── docs/ ├── plugin/example/ # 插件拷贝模板;不改正文、不换向 ├── plugin/redact/ # 脱敏插件;PLUGIN_DIR 指到 plugin/ @@ -63,11 +65,12 @@ CodeDock/ ├── server/ │ ├── cmd/server/ # 服务启动、配置、Router 和依赖装配 │ ├── internal/ -│ │ ├── handler/ # 大部分 HTTP:CRUD、SSE、Start / Continue / Cancel、记忆查看/删除、Git、Claude Code +│ │ ├── handler/ # 大部分 HTTP:CRUD、SSE、Start / Continue / Cancel、记忆查看/删除、Git、看板、Claude Code │ │ │ └── codex/ # 独立 /codex HTTP 薄桥接 │ │ ├── agent/ # 运行时编排 + sqlc 持久化 -│ │ │ ├── memory/ # 热层目录+专题,冷层工作区 FTS 索引 +│ │ │ ├── memory/ # 热层目录+专题(user / workspace / work),冷层工作区 FTS 索引 │ │ │ └── tools/ # 具体工具定义:ping、memory_*、编码八工具、plan_*、explore +│ │ ├── board/ # Work / Checkout / Info / Placement / Inbox / Packet;不写记忆、不建 worktree │ │ ├── codex/ # 本机 app-server 生命周期与内存排队/问票/SSE │ │ ├── events/ # 进程内事件总线 │ │ ├── pluginhost/ # go-plugin 宿主:拉进程、Dispatch、Host 白名单 @@ -80,6 +83,7 @@ CodeDock/ │ │ │ └── seam/ # Envelope / Dispatcher / 六个口的类型常量 │ │ ├── plugin/ # 插件 SDK 与 proto;作者只 import 这个包 │ │ ├── git/ # 无状态 Git CLI 操作,供 Handler 直接调用 +│ │ ├── github/ # 无状态 gh CLI:issue/pr view 快照;不落 token、不合 PR │ │ ├── claude/ # 无状态 Claude Code 对接;从本机 Claude 读,不落库 │ │ ├── codex/ # 看板的 Codex 子模块:协议客户端与领域类型 │ │ └── db/ # Client 与 sqlc 生成代码 @@ -105,7 +109,9 @@ internal/handler -> pkg/db/sqlite.Queries -> pkg/agent # 映射响应、token 统计、Profile 装配 -> pkg/git # 本机 Git CLI 操作 + -> pkg/github # 本机 gh Issue/PR 快照 -> pkg/claude # 本机 Claude Code 对接 + -> internal/board # Work / 看板聚合 / Inbox 转交 -> internal/agent # Worker 领取后的 Loop -> internal/agent/memory # 用户侧记忆响应类型 @@ -118,12 +124,18 @@ internal/codex 启动本机 `codex app-server --stdio` 排队、草稿、问票、SSE 只放内存 +internal/board + -> pkg/db/sqlite.Queries + -> pkg/agent # 审批 kind 文本 + 不写记忆、不 spawn CLI、不建 worktree + internal/agent -> pkg/db/sqlite.Queries -> pkg/agent -> internal/events -> internal/agent/memory -> internal/agent/tools + -> internal/board # native 读 Placement 与 Packet internal/agent/memory -> pkg/db/sqlite.Queries @@ -162,6 +174,10 @@ pkg/git 不依赖 handler、internal、sqlc 无状态,只 exec 本机 git;不写产品流程 +pkg/github + 不依赖 handler、internal、sqlc + 无状态,只 exec 本机 gh issue/pr view;不落 token + pkg/claude 不依赖 handler、internal、sqlc 无状态,从本机 Claude 读会话 / 实录 / 配置,不落库;不写产品流程 @@ -174,7 +190,7 @@ pkg/codex packages/core 不依赖 React、Next、DOM、process.env、AI SDK - 按业务域拆目录(chat / git / codex / claude),不要 src/ + 按业务域拆目录(chat / git / codex / claude / board),不要 src/ 文件直接落在 packages/core// baseUrl / userId 由调用方注入 @@ -187,13 +203,13 @@ packages/views -> packages/core -> packages/ui 不 import next/* - 按业务域拆目录,与 core 对齐(现有 chat / git / codex / claude) - AgentProvider 在包根注入 client + userId;CodexProvider / ClaudeProvider 注入各自 Client - ChatPage 在新建会话时选择 Local / Codex / Claude;导航用回调 + 按业务域拆目录,与 core 对齐(现有 chat / git / codex / claude / board) + AgentProvider 在包根注入 client + userId;CodexProvider / ClaudeProvider / BoardProvider 注入各自 Client + ChatPage 组会话 / 看板两态;导航用回调 apps/web -> packages/views - -> packages/core # 创建 AgentClient / GitClient / CodexClient / ClaudeClient + -> packages/core # 创建 AgentClient / GitClient / CodexClient / ClaudeClient / BoardClient -> packages/ui # 引入 tokens.css 不直接解析 SSE 或 event type ``` @@ -205,7 +221,8 @@ apps/web 承担大部分接口逻辑: - Session / Message / Usage / Approval 的增删改查。创建 Session 时在本包冻结 `workspace_id`(工作目录):用户指定的路径必须是已存在目录,否则 400;未指定(空或 `default`)则 `GIT_REPO`,再否则 cwd。不 import `internal/agent/tools`。新对话选目录由 web 弹出系统目录选择框。`sessions.summary` 在首次用户消息写入,列表与详情返回 -- 用户侧 TextMemory 的查看与删除(不提供写入,不暴露 message 索引;List 用 user_id / workspace_id,Get/Delete 用 name 默认目录) +- 用户侧 TextMemory 的查看与删除(不提供写入,不暴露 message 索引;List 用 user_id / workspace_id / work_id,Get/Delete 用 name 默认目录) +- 看板 HTTP(`/works`、`/board`、`/placements`、`/inbox/decision`、`/session-links`):调 `internal/board`。Inbox 按引擎转给已有 `/approvals`、`/claude/...`、`/codex/...` 裁决,不把三引擎问票收成同一种结构。Issue/PR 快照走 `pkg/github` - SSE:先按 `afterSeq` / `Last-Event-ID` 回放已落库事件,再 `SubscribeAll` 并按 Session 过滤;客户端断开不取消 Run - 事件 JSON 回放:`GET /sessions/{id}/event-log`,供前端一次 hydrate,不替代 SSE 直播 - Run 的 Start / Continue / Retry / Cancel / Restore 和审批裁决直接在 Handler 中处理,需要执行时再交给 Worker。`POST /runs/{id}/restore` 按快照粒度还原工作区。验证熔断单带 `kind=verify`(旧 `evaluate` 单仍可回放),人可 `accept` / `retry` / `abort` @@ -231,13 +248,13 @@ Handler 直接依赖 `*sqlite.Queries`,不经过 Store 接口。Git 带 `sessi ### `internal/agent/memory` -热层是每个 scope 一篇目录(`kind=index`,`name` 固定 `index`)加多篇专题(`kind=topic`)。scope 只有 `user` / `workspace`。冷层是同一 `workspace_id` 下的 context message FTS。与 Loop 独立: +热层是每个 scope 一篇目录(`kind=index`,`name` 固定 `index`)加多篇专题(`kind=topic`)。scope 是 `user` / `workspace` / `work`(`ScopeWork`)。冷层是同一 `workspace_id` 下的 context message FTS。与 Loop 独立: - 类型、`ByteLen`、`IndexOverBudget`、`ClipIndex`(200 行 / 25KB) - Agent 侧 TextMemory 的 Get / Upsert / Delete / List - `SearchMessages` 只查不写;`IndexMessage` 由 Loop 写 message 时调用 -不 import 父包;不解析 Markdown;不负责 Prompt / Context Packet / 对话压缩;不定义 Tool。不放在 `pkg`。不新建 Workspace 业务包,只持有 `workspace_id` 字段。Loop 在新 Session / 对话压缩后装冻结目录(独立 system 消息,不拼进静态 prompt,不入库)。超限目录立刻写入,由 Runtime 后台 `pkg.CompactIndex` 改短盖写,不自动建专题,不改当前 Session 冻结前缀。 +不 import 父包;不解析 Markdown;不负责 Prompt / Context Packet / 对话压缩;不定义 Tool。不放在 `pkg`。不新建 Workspace 业务包,只持有 `workspace_id` 字段。Loop 在新 Session / 对话压缩后装 **user + 当前 Work** 冻结目录(有 placement 时);`workspace` 热层保留但不进本回合。Packet 是独立 system,不入库、不抄进记忆。超限目录立刻写入,由 Runtime 后台 `pkg.CompactIndex` 改短盖写,不自动建专题,不改当前 Session 冻结前缀。未归组会话只能写 user 记忆。 ### `internal/agent/tools` @@ -259,10 +276,18 @@ Handler 直接依赖 `*sqlite.Queries`,不经过 Store 接口。Git 带 `sessi 详见 [plugin.md](plugin.md)。 +### `internal/board` + +产品工作流:Work / Checkout / Info / Placement / Board 聚合 / Inbox 编排 / Packet。一张卡可挂多目录、多路会话。旧会话不自动建卡;可先聊再补挂。问答会话不能事后补绑目录。删卡只断开归属,不删会话、不删磁盘。看板只聚合摘要,不加载对话正文。目录 Git 状态现问 `pkg/git`。不写记忆正文、不 spawn CLI、不建 worktree。 + ### `pkg/git` 无状态 Git CLI:`Open` / `Status`(`SiteState` 整局)/ Diff / 图 / 暂存提交 / reset / revert / 推拉 / remote / 分支 / worktree / `stash create` 副本 / 冲突读写。不进 `pkg/agent`,不写 HTTP 或产品流程。Workspace / Branch / Undo / 说明 / Agent 快照的产品组合在 Handler。 +### `pkg/github` + +无状态本机 `gh` CLI:`issue view` / `pr view` JSON 快照。不关 issue、不合 PR、不落 token。不进 `pkg/agent`,不写产品流程。会话头上的 Issue/PR 由 Handler 写入 `session_issues` / `session_pulls`。 + ### `pkg/claude` 无状态 Claude Code 对接:引擎探测、会话、配置、斜杠命令、附件、回合、实录、审批,以及对本机 Claude 说话。会话 / 实录 / 模型 / 权限档从本机 Claude 读,不落库。不进 `pkg/agent`,不走本地对话的工具 / 记忆 / 压缩。Handler 直接调用。 @@ -306,7 +331,7 @@ Handler 直接依赖 `*sqlite.Queries`,不经过 Store 接口。Git 带 `sessi ### `packages/core` -跨端无头业务,无 UI。按业务域拆目录,文件直接放在 `packages/core//`,不要 `src/`。现有 `chat/`:Session / Message / Run / 审批的 HTTP、SSE、Timeline reducer。Git 前端在 `git/`(`GitClient`,不扩 `AgentClient`)。Codex 前端在 `codex/`(`CodexClient`,不扩 `AgentClient`)。Claude 前端在 `claude/`(`ClaudeClient`,不扩 `AgentClient`)。`baseUrl` / `userId` 由调用方注入。不依赖 React。thinking 用 Run 状态(`queued` / `loading_context` / `running_llm` / `verifying`),不是模型 reasoning token。验证另有独立时间线卡片;跳过验证不画卡片。 +跨端无头业务,无 UI。按业务域拆目录,文件直接放在 `packages/core//`,不要 `src/`。现有 `chat/`:Session / Message / Run / 审批的 HTTP、SSE、Timeline reducer。Git 前端在 `git/`(`GitClient`,不扩 `AgentClient`)。Codex 前端在 `codex/`(`CodexClient`,不扩 `AgentClient`)。Claude 前端在 `claude/`(`ClaudeClient`,不扩 `AgentClient`)。看板前端在 `board/`(`BoardClient`,不扩 `AgentClient`)。`baseUrl` / `userId` 由调用方注入。不依赖 React。thinking 用 Run 状态(`queued` / `loading_context` / `running_llm` / `verifying`),不是模型 reasoning token。验证另有独立时间线卡片;跳过验证不画卡片。 ### `packages/ui` @@ -320,11 +345,11 @@ Handler 直接依赖 `*sqlite.Queries`,不经过 Store 接口。Git 带 `sessi ### `packages/views` -组合 core + ui。按业务域拆,与 core 对齐,不要 `src/`。现有 `chat/`:`ChatPage` 三栏(会话列表、对话、右侧窗口栏,左右栏可收起),瀑布、审批、prompt;新建会话可选 Local / Codex / Claude。对话里的 Plan 收成条目,点开后在右侧窗口显示。每次 Run 改过的文件集中在一个框里(时间线与文件窗口共用),点选后右侧看正文。右侧窗口栏可新建 / 关闭 Plan、文件、Git,后续窗口种类往这里加。包根 `provider.tsx` 注入 `AgentClient` + `userId`。`ChatPage` 接 `sessionId` 与 `onOpenSession`。Git 在 `git/`:`GitProvider` 只注入 `GitClient`,不进 `AgentContext`;由 `ChatPage` 右侧窗口组合,不单独做业务页。Codex 在 `codex/`:`CodexProvider` 只注入 `CodexClient`。Claude 在 `claude/`:`ClaudeProvider` 只注入 `ClaudeClient`。二者都由 `ChatPage` 组合。不 import `next/*`。新业务新建目录,不预建 Issue / Task / Review / Workspace。 +组合 core + ui。按业务域拆,与 core 对齐,不要 `src/`。现有 `chat/`:`ChatPage` 两态。会话模式是三栏(左侧按 Work 分组、中间对话、右侧 Plan / 文件 / Git)。看板模式藏左栏与中间对话,中间是横向 Work 列(列高滚动、每行最多 3 列、底部分页),完整对话用可拖悬浮窗,右侧窗口栏不加 `chat` 种类。新建会话可选 Local / Codex / Claude,可先聊再补挂。包根 `provider.tsx` 注入 `AgentClient` + `userId`。`ChatPage` 接 `sessionId` / `boardMode` 与导航回调。Git 在 `git/`:`GitProvider` 只注入 `GitClient`。Codex / Claude 同上。看板在 `board/`:`BoardProvider` 只注入 `BoardClient`。不 import `next/*`。新业务新建目录,不预建 Issue / Task / Review / Workspace。 ### `apps/web` -路由、`NEXT_PUBLIC_API_BASE` / `NEXT_PUBLIC_USER_ID`、创建 `AgentClient` / `GitClient` / `CodexClient` / `ClaudeClient`、包 `AgentProvider` / `GitProvider` / `CodexProvider` / `ClaudeProvider`、`router.push`。本机 Web 直连 `:8080`(仅回环 Origin 的 CORS)。对话页装配 `GitClient` 给右侧 Git 窗口。Codex 走对话页的 `/` 与 `/s/c/:id`,Claude 走 `/` 与 `/s/claude/:id`。 +路由、`NEXT_PUBLIC_API_BASE` / `NEXT_PUBLIC_USER_ID`、创建 `AgentClient` / `GitClient` / `CodexClient` / `ClaudeClient` / `BoardClient`、包 `AgentProvider` / `GitProvider` / `CodexProvider` / `ClaudeProvider` / `BoardProvider`、`router.push`。本机 Web 直连 `:8080`(仅回环 Origin 的 CORS)。对话页装配 `GitClient` 给右侧 Git 窗口。Codex 走对话页的 `/` 与 `/s/c/:id`,Claude 走 `/` 与 `/s/claude/:id`,看板走 `/board`,`/s/...` 仍是会话模式。 ## 组装关系 diff --git a/packages/core/board/client.test.ts b/packages/core/board/client.test.ts new file mode 100644 index 0000000..1d22c62 --- /dev/null +++ b/packages/core/board/client.test.ts @@ -0,0 +1,80 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import { BoardClient, BoardClientError } from "./client.ts"; + +test("BoardClient hits /works /board and inbox routes", async () => { + const calls: { url: string; method: string; body?: unknown }[] = []; + const client = new BoardClient({ + baseUrl: "http://api.test/", + userId: "u1", + tenantId: "t1", + fetch: async (input, init) => { + const url = String(input); + const method = init?.method ?? "GET"; + const raw = typeof init?.body === "string" ? init.body : undefined; + calls.push({ url, method, body: raw ? JSON.parse(raw) : undefined }); + if (url.includes("/board?") && method === "GET") { + return json({ board: { cards: [{ work: { id: "w1", title: "修登录" }, sessions: [] }], ungrouped: [] } }); + } + if (url.endsWith("/works") && method === "POST") { + return json({ work: { id: "w1", title: "修登录", user_id: "u1", tenant_id: "t1" } }); + } + if (url.includes("/works/w1/sessions")) { + return json({ + placement: { engine: "native", session_id: "s1", work_id: "w1", checkout: "" }, + session_id: "s1", + engine: "agent", + }); + } + if (url.includes("/works/w1/inbox")) { + return json({ items: [{ engine: "agent", session_id: "s1", ticket_id: "a1", summary: "tool" }] }); + } + if (url.includes("/inbox/decision")) { + return json({ ok: true }); + } + if (url.includes("/placements?")) { + return json({ placements: [{ engine: "native", session_id: "s1", work_id: "w1", checkout: "" }] }); + } + return json({ ok: true }); + }, + }); + + const work = await client.createWork("修登录"); + assert.equal(work.id, "w1"); + const board = await client.getBoard(); + assert.equal(board.cards[0]?.work.title, "修登录"); + const started = await client.startSession("w1", { engine: "agent", kind: "talk" }); + assert.equal(started.session_id, "s1"); + const items = await client.listInbox("w1"); + assert.equal(items[0]?.ticket_id, "a1"); + await client.decideInbox({ engine: "agent", ticket_id: "a1", status: "approved", actor_id: "u1" }); + const places = await client.listPlacements(); + assert.equal(places[0]?.engine, "agent"); + + assert.equal(calls[0]?.url, "http://api.test/works"); + assert.match(String(calls[1]?.url), /user_id=u1/); + assert.equal(calls.some((item) => item.url.endsWith("/inbox/decision")), true); + const saved = await client.replaceLinks("agent", "s1", ["https://github.com/acme/app/pull/8"]); + assert.equal(saved.pulls.length, 0); + assert.equal(calls.at(-1)?.method, "PUT"); + assert.deepEqual(calls.at(-1)?.body, { links: ["https://github.com/acme/app/pull/8"] }); + + await assert.rejects( + () => + new BoardClient({ + baseUrl: "http://api.test", + userId: "u1", + fetch: async () => json({ error: "no" }, 400), + }).createWork("x"), + (err: unknown) => err instanceof BoardClientError && err.status === 400, + ); +}); + +// json 造一条假响应。 +function json(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { "content-type": "application/json" }, + }); +} diff --git a/packages/core/board/client.ts b/packages/core/board/client.ts new file mode 100644 index 0000000..33ed54f --- /dev/null +++ b/packages/core/board/client.ts @@ -0,0 +1,290 @@ +import type { + BoardEngine, + BoardView, + Card, + Checkout, + CheckoutKind, + InboxDecideRequest, + InboxItem, + IssueSnap, + Packet, + Placement, + PullSnap, + PutLinkRequest, + SessionLinks, + StartWorkSessionRequest, + Work, + WorkInfo, +} from "./types.ts"; + +export class BoardClientError extends Error { + readonly status: number; + + // 记录看板 HTTP 失败状态。 + constructor(status: number, message: string) { + super(message); + this.name = "BoardClientError"; + this.status = status; + } +} + +export type BoardClientOptions = { + baseUrl: string; + userId: string; + tenantId?: string; + fetch?: typeof fetch; +}; + +export class BoardClient { + readonly baseUrl: string; + readonly userId: string; + readonly tenantId: string; + private readonly fetchImpl: typeof fetch; + + // 组装看板 HTTP 客户端;tenant 空则 default。 + constructor(options: BoardClientOptions) { + this.baseUrl = options.baseUrl.replace(/\/$/, ""); + this.userId = options.userId; + this.tenantId = options.tenantId?.trim() || "default"; + this.fetchImpl = options.fetch ?? fetch.bind(globalThis); + } + + // listWorks 列出当前用户的卡。 + async listWorks(): Promise { + const body = (await this.request(`/works${this.userQuery()}`)) as { works?: Work[] }; + return body.works ?? []; + } + + // createWork 建一张卡。 + async createWork(title: string): Promise { + const body = (await this.request("/works", { + method: "POST", + json: { user_id: this.userId, tenant_id: this.tenantId, title }, + })) as { work: Work }; + return body.work; + } + + // updateWork 改卡片标题。 + async updateWork(workId: string, title: string): Promise { + const body = (await this.request(`/works/${workId}`, { + method: "PATCH", + json: { title }, + })) as { work: Work }; + return body.work; + } + + // deleteWork 删卡并断开归属。 + async deleteWork(workId: string): Promise { + await this.request(`/works/${workId}`, { method: "DELETE" }); + } + + // attachCheckout 把已存在目录挂到卡上。 + async attachCheckout(workId: string, path: string, kind: CheckoutKind = "folder"): Promise { + const body = (await this.request(`/works/${workId}/checkouts`, { + method: "POST", + json: { path, kind }, + })) as { checkout: Checkout }; + return body.checkout; + } + + // listCheckouts 列出卡上目录。 + async listCheckouts(workId: string): Promise { + const body = (await this.request(`/works/${workId}/checkouts`)) as { checkouts?: Checkout[] }; + return body.checkouts ?? []; + } + + // detachCheckout 卸下目录。 + async detachCheckout(workId: string, path: string): Promise { + await this.request(`/works/${workId}/checkouts`, { + method: "DELETE", + json: { path }, + }); + } + + // getInfo 读 Work 级或目录级说明。 + async getInfo(workId: string, checkout = ""): Promise { + const query = new URLSearchParams(); + if (checkout) { + query.set("checkout", checkout); + } + const suffix = query.toString() ? `?${query}` : ""; + const body = (await this.request(`/works/${workId}/info${suffix}`)) as { info: WorkInfo }; + return body.info; + } + + // putInfo 覆盖写入说明。 + async putInfo(workId: string, bodyText: string, checkout = ""): Promise { + const body = (await this.request(`/works/${workId}/info`, { + method: "PUT", + json: { checkout, body: bodyText }, + })) as { info: WorkInfo }; + return body.info; + } + + // startSession 从一张卡开问答或目录会话。 + async startSession( + workId: string, + req: StartWorkSessionRequest, + ): Promise<{ placement: Placement; session_id: string; engine: BoardEngine }> { + return (await this.request(`/works/${workId}/sessions`, { + method: "POST", + json: { + engine: req.engine, + kind: req.kind, + checkout: req.checkout ?? "", + user_id: this.userId, + tenant_id: this.tenantId, + agent_id: req.agent_id, + }, + })) as { placement: Placement; session_id: string; engine: BoardEngine }; + } + + // attachPlacement 把未归组会话补挂到卡上。 + async attachPlacement(workId: string, engine: BoardEngine, sessionId: string): Promise { + const body = (await this.request(`/works/${workId}/placements`, { + method: "POST", + json: { engine, session_id: sessionId }, + })) as { placement: Placement }; + return publicPlacement(body.placement); + } + + // listPlacements 列出用户所有归属。 + async listPlacements(): Promise { + const body = (await this.request(`/placements${this.userQuery()}`)) as { placements?: Placement[] }; + return (body.placements ?? []).map(publicPlacement); + } + + // getPlacement 读一路会话的归属。 + async getPlacement(engine: BoardEngine, sessionId: string): Promise { + const body = (await this.request(`/placements/${engine}/${sessionId}`)) as { placement: Placement }; + return publicPlacement(body.placement); + } + + // deletePlacement 断开归属。 + async deletePlacement(engine: BoardEngine, sessionId: string): Promise { + await this.request(`/placements/${engine}/${sessionId}`, { method: "DELETE" }); + } + + // getBoard 聚合横向看板。 + async getBoard(): Promise { + const body = (await this.request(`/board${this.userQuery()}`)) as { board?: BoardView }; + return { + cards: body.board?.cards ?? [], + ungrouped: body.board?.ungrouped ?? [], + }; + } + + // getCard 聚合一张卡。 + async getCard(workId: string): Promise { + const body = (await this.request(`/board/cards/${workId}`)) as { card: Card }; + return body.card; + } + + // listInbox 扫该卡下三引擎待批。 + async listInbox(workId: string): Promise { + const body = (await this.request(`/works/${workId}/inbox`)) as { items?: InboxItem[] }; + return body.items ?? []; + } + + // decideInbox 按引擎把裁决转给已有审批。 + async decideInbox(req: InboxDecideRequest): Promise { + await this.request("/inbox/decision", { + method: "POST", + json: req, + }); + } + + // getPacket 读开回合用的只读 Packet。 + async getPacket(engine: BoardEngine, sessionId: string): Promise { + const body = (await this.request(`/placements/${engine}/${sessionId}/packet`)) as { packet: Packet }; + return body.packet; + } + + // getLinks 读会话头上的 Issue/PR。 + async getLinks(engine: BoardEngine, sessionId: string): Promise { + const body = (await this.request(`/session-links/${engine}/${sessionId}`)) as { links?: SessionLinks }; + return { issue: body.links?.issue ?? null, pulls: body.links?.pulls ?? [] }; + } + + // replaceLinks 用一批链接收掉会话上的 Issue/PR,种类由链接路径决定。 + async replaceLinks(engine: BoardEngine, sessionId: string, links: string[]): Promise { + const body = (await this.request(`/session-links/${engine}/${sessionId}`, { + method: "PUT", + json: { links }, + })) as { links?: SessionLinks }; + return { issue: body.links?.issue ?? null, pulls: body.links?.pulls ?? [] }; + } + + // putIssue 用 gh 拉 Issue 快照并挂到会话头。 + async putIssue(engine: BoardEngine, sessionId: string, req: PutLinkRequest): Promise { + const body = (await this.request(`/session-links/${engine}/${sessionId}/issue`, { + method: "PUT", + json: req, + })) as { issue: IssueSnap }; + return body.issue; + } + + // deleteIssue 去掉会话上的 Issue。 + async deleteIssue(engine: BoardEngine, sessionId: string): Promise { + await this.request(`/session-links/${engine}/${sessionId}/issue`, { method: "DELETE" }); + } + + // putPull 用 gh 拉 PR 快照并挂到会话头。 + async putPull(engine: BoardEngine, sessionId: string, req: PutLinkRequest): Promise { + const body = (await this.request(`/session-links/${engine}/${sessionId}/pulls`, { + method: "POST", + json: req, + })) as { pull: PullSnap }; + return body.pull; + } + + // deletePull 去掉会话上的一条 PR。 + async deletePull(engine: BoardEngine, sessionId: string, number: number, repo = ""): Promise { + const query = repo ? `?repo=${encodeURIComponent(repo)}` : ""; + await this.request(`/session-links/${engine}/${sessionId}/pulls/${number}${query}`, { method: "DELETE" }); + } + + // userQuery 带上当前用户,给列表接口用。 + private userQuery(): string { + const search = new URLSearchParams({ user_id: this.userId, tenant_id: this.tenantId }); + return `?${search.toString()}`; + } + + // request 发 JSON 请求,失败时抛 BoardClientError。 + private async request(path: string, init: RequestInit & { json?: unknown } = {}): Promise { + const headers = new Headers(init.headers); + if (init.json !== undefined) { + headers.set("Content-Type", "application/json"); + } + const res = await this.fetchImpl(`${this.baseUrl}${path}`, { + ...init, + headers, + body: init.json !== undefined ? JSON.stringify(init.json) : init.body, + }); + const text = await res.text(); + let parsed: unknown = undefined; + if (text) { + try { + parsed = JSON.parse(text); + } catch { + parsed = { error: text }; + } + } + if (!res.ok) { + const message = + parsed && typeof parsed === "object" && "error" in parsed + ? String((parsed as { error: unknown }).error) + : res.statusText; + throw new BoardClientError(res.status, message); + } + return parsed; + } +} + +// publicPlacement 把库内 native 收成前端 agent。 +function publicPlacement(place: Placement): Placement { + return { + ...place, + engine: place.engine === "native" ? "agent" : place.engine, + }; +} diff --git a/packages/core/board/index.ts b/packages/core/board/index.ts new file mode 100644 index 0000000..7259f8a --- /dev/null +++ b/packages/core/board/index.ts @@ -0,0 +1,21 @@ +export { BoardClient, BoardClientError, type BoardClientOptions } from "./client.ts"; +export type { + BoardEngine, + BoardView, + Card, + Checkout, + CheckoutKind, + DirView, + InboxDecideRequest, + InboxItem, + IssueSnap, + Packet, + Placement, + PullSnap, + PutLinkRequest, + SessionLinks, + SessionView, + StartWorkSessionRequest, + Work, + WorkInfo, +} from "./types.ts"; diff --git a/packages/core/board/types.ts b/packages/core/board/types.ts new file mode 100644 index 0000000..fdf74ea --- /dev/null +++ b/packages/core/board/types.ts @@ -0,0 +1,138 @@ +export type BoardEngine = "agent" | "claude" | "codex"; + +export type CheckoutKind = "folder" | "primary" | "worktree"; + +export type Work = { + id: string; + tenant_id: string; + user_id: string; + title: string; + created_at: string; + updated_at: string; +}; + +export type WorkInfo = { + work_id: string; + checkout: string; + body: string; + updated_at: string; +}; + +export type Checkout = { + work_id: string; + path: string; + kind: CheckoutKind; +}; + +export type Placement = { + engine: BoardEngine | "native"; + session_id: string; + work_id: string; + checkout: string; +}; + +export type IssueSnap = { + engine: string; + session_id: string; + repo: string; + number: number; + title: string; + body: string; + url: string; + state: string; + updated_at: string; +}; + +export type PullSnap = { + engine: string; + session_id: string; + repo: string; + number: number; + title: string; + body: string; + url: string; + state: string; + updated_at: string; +}; + +export type SessionLinks = { + issue?: IssueSnap | null; + pulls: PullSnap[]; +}; + +export type SessionView = { + engine: BoardEngine; + session_id: string; + summary: string; + checkout: string; + running: boolean; + pending: number; + updated_at: string; +}; + +export type DirView = { + path: string; + kind: string; + info: WorkInfo; + branch: string; + dirty: boolean; + shared_writers: string[]; +}; + +export type Card = { + work: Work; + info: WorkInfo; + dirs: DirView[]; + sessions: SessionView[]; + running: number; + pending: number; +}; + +export type BoardView = { + cards: Card[]; + ungrouped: SessionView[]; +}; + +export type InboxItem = { + engine: BoardEngine; + session_id: string; + ticket_id: string; + summary: string; + payload: unknown; +}; + +export type Packet = { + work_info: WorkInfo; + dir_info?: WorkInfo | null; + issue?: IssueSnap | null; + pulls: PullSnap[]; + text: string; +}; + +export type StartWorkSessionRequest = { + engine: BoardEngine; + kind: "talk" | "dir"; + checkout?: string; + agent_id?: string; +}; + +export type InboxDecideRequest = { + engine: BoardEngine; + ticket_id: string; + decisions?: { tool_call_id: string; status: string; reason?: string }[]; + status?: string; + scope?: string; + actor_id?: string; + reason?: string; + override?: string; + approved?: boolean; + choice?: string; + values?: string[]; + answers?: Record; +}; + +export type PutLinkRequest = { + repo?: string; + number?: number; + ref?: string; +}; diff --git a/packages/core/index.ts b/packages/core/index.ts index ce981fb..979d8d3 100644 --- a/packages/core/index.ts +++ b/packages/core/index.ts @@ -36,6 +36,17 @@ export { export { GitClient, GitClientError, type GitClientOptions } from "./git/index.ts"; export { CodexClient, CodexClientError, type CodexClientOptions } from "./codex/index.ts"; export { ClaudeClient, ClaudeClientError, type ClaudeClientOptions } from "./claude/index.ts"; +export { BoardClient, BoardClientError, type BoardClientOptions } from "./board/index.ts"; +export type { + BoardEngine, + BoardView, + Card, + InboxItem, + Placement, + SessionLinks, + SessionView, + Work, +} from "./board/index.ts"; export type { ApprovalAsk, AskAnswer, diff --git a/packages/core/package.json b/packages/core/package.json index 6e6f931..7ed925b 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -8,10 +8,11 @@ "./chat": "./chat/index.ts", "./git": "./git/index.ts", "./codex": "./codex/index.ts", - "./claude": "./claude/index.ts" + "./claude": "./claude/index.ts", + "./board": "./board/index.ts" }, "scripts": { - "test": "node --test --experimental-strip-types chat/reducer.test.ts chat/queue.test.ts chat/sse.test.ts chat/plan.test.ts chat/files.test.ts chat/wait-run.test.ts git/client.test.ts codex/client.test.ts codex/reducer.test.ts claude/client.test.ts arch.test.ts" + "test": "node --test --experimental-strip-types chat/reducer.test.ts chat/queue.test.ts chat/sse.test.ts chat/plan.test.ts chat/files.test.ts chat/wait-run.test.ts git/client.test.ts codex/client.test.ts codex/reducer.test.ts claude/client.test.ts board/client.test.ts arch.test.ts" }, "devDependencies": { "@types/node": "^20", diff --git a/packages/views/board/board-grid.tsx b/packages/views/board/board-grid.tsx new file mode 100644 index 0000000..341aa16 --- /dev/null +++ b/packages/views/board/board-grid.tsx @@ -0,0 +1,110 @@ +"use client"; + +import type { BoardView } from "@codedock/core/board"; +import { Button } from "@codedock/ui"; +import { useEffect, useRef, useState } from "react"; + +import type { SessionEngine } from "../chat/chat-page.tsx"; +import { boardColumns, columnsPerRow, pageColumns, pageCount } from "./layout.ts"; +import { useBoard } from "./provider.tsx"; +import { WorkColumn } from "./work-column.tsx"; + +// BoardGrid 横向 Work 列:列高滚动、每行限列、底部分页。 +export function BoardGrid({ + onOpenSession, +}: { + onOpenSession: (id: string, engine: SessionEngine) => void; +}) { + const { client } = useBoard(); + const [view, setView] = useState({ cards: [], ungrouped: [] }); + const [width, setWidth] = useState(720); + const [page, setPage] = useState(0); + const [error, setError] = useState(null); + const [title, setTitle] = useState(""); + const frameRef = useRef(null); + + // reload 只拉摘要,不加载对话正文。 + const reload = async () => { + const next = await client.getBoard(); + setView(next); + }; + + useEffect(() => { + void reload().catch((err: unknown) => { + setError(err instanceof Error ? err.message : "无法加载看板"); + }); + }, [client]); + + useEffect(() => { + const node = frameRef.current; + if (!node || typeof ResizeObserver === "undefined") { + return; + } + const frame = new ResizeObserver((entries) => { + const next = entries[0]?.contentRect.width ?? 0; + if (next > 0) { + setWidth(next); + } + }); + frame.observe(node); + setWidth(node.clientWidth || 720); + return () => frame.disconnect(); + }, []); + + const perPage = columnsPerRow(width); + const columns = boardColumns(view); + const pages = pageCount(columns.length, perPage); + const current = Math.min(page, pages - 1); + const visible = pageColumns(columns, current, perPage); + + return ( +
+
+ setTitle(event.target.value)} + placeholder="标题" + className="h-7 min-w-0 flex-1 rounded border border-border bg-background px-2 text-sm" + /> + +
+ {error ?

{error}

: null} +
+ {visible.map((column) => ( + + ))} +
+
+ + + {current + 1} / {pages} + + +
+
+ ); +} diff --git a/packages/views/board/group.test.ts b/packages/views/board/group.test.ts new file mode 100644 index 0000000..9b30cad --- /dev/null +++ b/packages/views/board/group.test.ts @@ -0,0 +1,38 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import { groupSessionsByWork, sessionKey } from "./group.ts"; + +test("groupSessionsByWork groups by work then time, leftover goes ungrouped", () => { + const groups = groupSessionsByWork( + [ + { id: "s-old", engine: "agent", updated_at: "2026-01-01T00:00:00Z", summary: "旧" }, + { id: "s-new", engine: "agent", updated_at: "2026-02-01T00:00:00Z", summary: "新" }, + { id: "s-free", engine: "claude", updated_at: "2026-03-01T00:00:00Z", summary: "闲" }, + ], + [ + { engine: "agent", session_id: "s-old", work_id: "w1", checkout: "" }, + { engine: "native", session_id: "s-new", work_id: "w1", checkout: "/repo" }, + ], + [ + { + id: "w1", + tenant_id: "t", + user_id: "u", + title: "修登录", + created_at: "2026-01-01T00:00:00Z", + updated_at: "2026-02-01T00:00:00Z", + }, + ], + ); + assert.equal(groups.length, 2); + assert.equal(groups[0]?.id, "w1"); + assert.deepEqual( + groups[0]?.sessions.map((item) => item.id), + ["s-new", "s-old"], + ); + assert.equal(groups[1]?.id, null); + assert.equal(groups[1]?.title, "未分组"); + assert.equal(groups[1]?.sessions[0]?.id, "s-free"); + assert.equal(sessionKey("native", "s-new"), "agent:s-new"); +}); diff --git a/packages/views/board/group.ts b/packages/views/board/group.ts new file mode 100644 index 0000000..ef00c7f --- /dev/null +++ b/packages/views/board/group.ts @@ -0,0 +1,63 @@ +import type { Placement, Work } from "@codedock/core/board"; + +export type GroupedSession = { + id: string; + engine?: string; + updated_at: string; + summary?: string; +}; + +export type WorkGroup = { + id: string | null; + title: string; + updatedAt: string; + sessions: T[]; +}; + +// sessionKey 用引擎+会话拼侧栏/看板主键。 +export function sessionKey(engine: string | undefined, sessionId: string): string { + return `${engine === "native" ? "agent" : engine ?? "agent"}:${sessionId}`; +} + +// groupSessionsByWork 先按 Work 再按时间聚会话;未挂卡的进未归组。 +export function groupSessionsByWork( + sessions: T[], + placements: Placement[], + works: Work[], +): WorkGroup[] { + const bySession = new Map(); + for (const place of placements) { + bySession.set(sessionKey(place.engine, place.session_id), place.work_id); + } + const buckets = new Map(); + for (const work of works) { + buckets.set(work.id, []); + } + buckets.set(null, []); + for (const session of sessions) { + const workId = bySession.get(sessionKey(session.engine, session.id)) ?? null; + const list = buckets.get(workId) ?? buckets.get(null); + if (!list) { + continue; + } + if (!buckets.has(workId)) { + buckets.set(null, list); + } + list.push(session); + } + const ranked = [...works].sort((left, right) => (left.updated_at < right.updated_at ? 1 : -1)); + const groups: WorkGroup[] = ranked.map((work) => ({ + id: work.id, + title: work.title || "未命名", + updatedAt: work.updated_at, + sessions: sortSessions(buckets.get(work.id) ?? []), + })); + const ungrouped = sortSessions(buckets.get(null) ?? []); + groups.push({ id: null, title: "未分组", updatedAt: ungrouped[0]?.updated_at ?? "", sessions: ungrouped }); + return groups; +} + +// sortSessions 组内按更新时间倒序。 +function sortSessions(sessions: T[]): T[] { + return sessions.slice().sort((left, right) => (left.updated_at < right.updated_at ? 1 : -1)); +} diff --git a/packages/views/board/inbox-actions.tsx b/packages/views/board/inbox-actions.tsx new file mode 100644 index 0000000..bafd203 --- /dev/null +++ b/packages/views/board/inbox-actions.tsx @@ -0,0 +1,83 @@ +"use client"; + +import type { BoardEngine, InboxItem } from "@codedock/core/board"; +import { Button } from "@codedock/ui"; +import { useState } from "react"; + +import { useAgent } from "../provider.tsx"; +import { useBoard } from "./provider.tsx"; + +// InboxActions 在列内直接批一条问票,不把三引擎收成同一种结构。 +export function InboxActions({ + items, + onDone, +}: { + items: InboxItem[]; + onDone?: () => Promise | void; +}) { + const { client } = useBoard(); + const { userId } = useAgent(); + const [busy, setBusy] = useState(null); + const [error, setError] = useState(null); + if (items.length === 0) { + return null; + } + return ( +
+ {items.map((item) => ( +
+

{item.summary || item.ticket_id}

+
+ + +
+
+ ))} + {error ?

{error}

: null} +
+ ); + + // decide 按引擎把原问票转给已有裁决入口。 + async function decide(item: InboxItem, approved: boolean) { + setBusy(item.ticket_id); + setError(null); + try { + const engine = (item.engine === "native" ? "agent" : item.engine) as BoardEngine; + if (engine === "agent") { + await client.decideInbox({ + engine, + ticket_id: item.ticket_id, + status: approved ? "approved" : "denied", + scope: "once", + actor_id: userId, + }); + } else { + await client.decideInbox({ + engine, + ticket_id: item.ticket_id, + approved, + scope: "once", + }); + } + await onDone?.(); + } catch (err) { + setError(err instanceof Error ? err.message : "审批失败"); + } finally { + setBusy(null); + } + } +} diff --git a/packages/views/board/index.ts b/packages/views/board/index.ts new file mode 100644 index 0000000..b8c6fa4 --- /dev/null +++ b/packages/views/board/index.ts @@ -0,0 +1,5 @@ +export { BoardGrid } from "./board-grid.tsx"; +export { BoardProvider, useBoard } from "./provider.tsx"; +export { groupSessionsByWork, sessionKey, type WorkGroup } from "./group.ts"; +export { boardColumns, columnsPerRow, pageColumns, pageCount } from "./layout.ts"; +export { SessionFloat, type FloatSession } from "./session-float.tsx"; diff --git a/packages/views/board/info-editor.tsx b/packages/views/board/info-editor.tsx new file mode 100644 index 0000000..b3d5db8 --- /dev/null +++ b/packages/views/board/info-editor.tsx @@ -0,0 +1,146 @@ +"use client"; + +import { Button } from "@codedock/ui"; +import { useEffect, useRef, useState, type ComponentType } from "react"; +import { createPortal } from "react-dom"; + +type MarkdownEditor = ComponentType<{ + value?: string; + height?: number | string; + preview?: "live" | "edit" | "preview"; + visibleDragbar?: boolean; + autoFocus?: boolean; + commands?: unknown[]; + extraCommands?: unknown[]; + onChange?: (value?: string) => void; +}>; + +// InfoEditor 弹出 @uiw/react-md-editor,用来改这张卡的说明。 +export function InfoEditor({ + open, + value, + onClose, + onSave, +}: { + open: boolean; + value: string; + onClose: () => void; + onSave: (next: string) => Promise; +}) { + const [draft, setDraft] = useState(value); + const valueRef = useRef(value); + valueRef.current = value; + const [Editor, setEditor] = useState(null); + const [commands, setCommands] = useState<{ main: unknown[]; extra: unknown[] } | null>(null); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + + useEffect(() => { + if (!open) { + setBusy(false); + return; + } + setDraft(valueRef.current); + setError(null); + let cancelled = false; + void Promise.all([import("@uiw/react-md-editor"), import("@uiw/react-md-editor/commands-cn")]).then( + ([editorMod, cn]) => { + if (cancelled) { + return; + } + setEditor(() => editorMod.default); + setCommands({ main: cn.getCommands(), extra: cn.getExtraCommands() }); + }, + (err: unknown) => { + if (!cancelled) { + setError(err instanceof Error ? err.message : "编辑器加载失败"); + } + }, + ); + return () => { + cancelled = true; + }; + }, [open]); + + useEffect(() => { + if (!open) { + return; + } + const onKey = (event: KeyboardEvent) => { + if (event.key === "Escape" && !busy) { + onClose(); + } + }; + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, [busy, onClose, open]); + + if (!open || typeof document === "undefined") { + return null; + } + + // save 写回说明并关掉编辑框。 + const save = async () => { + setBusy(true); + setError(null); + try { + await onSave(draft); + } catch (err) { + setError(err instanceof Error ? err.message : "保存失败"); + } finally { + setBusy(false); + } + }; + + return createPortal( +
{ + if (!busy) { + onClose(); + } + }} + > +
event.stopPropagation()} + > +
+

+ 编辑说明 +

+
+
+ {Editor && commands ? ( + setDraft(next ?? "")} + /> + ) : ( +

{error ?? "正在打开编辑器"}

+ )} +
+ {error && Editor ?

{error}

: null} +
+ + +
+
+
, + document.body, + ); +} diff --git a/packages/views/board/layout.test.ts b/packages/views/board/layout.test.ts new file mode 100644 index 0000000..478ef9e --- /dev/null +++ b/packages/views/board/layout.test.ts @@ -0,0 +1,57 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import { boardColumns, columnsPerRow, pageColumns, pageCount } from "./layout.ts"; + +test("columnsPerRow caps at three and never drops below one", () => { + assert.equal(columnsPerRow(0), 1); + assert.equal(columnsPerRow(239), 1); + assert.equal(columnsPerRow(480), 2); + assert.equal(columnsPerRow(800), 3); + assert.equal(columnsPerRow(1600), 3); +}); + +test("boardColumns appends the ungrouped lane and paginates by row", () => { + const cols = boardColumns({ + cards: [ + { + work: { + id: "w1", + tenant_id: "t", + user_id: "u", + title: "A", + created_at: "", + updated_at: "", + }, + info: { work_id: "w1", checkout: "", body: "", updated_at: "" }, + dirs: [], + sessions: [], + running: 0, + pending: 0, + }, + { + work: { + id: "w2", + tenant_id: "t", + user_id: "u", + title: "B", + created_at: "", + updated_at: "", + }, + info: { work_id: "w2", checkout: "", body: "", updated_at: "" }, + dirs: [], + sessions: [], + running: 0, + pending: 0, + }, + ], + ungrouped: [{ engine: "agent", session_id: "s1", summary: "闲", checkout: "", running: false, pending: 0, updated_at: "" }], + }); + assert.equal(cols.length, 3); + assert.equal(cols[2]?.ungrouped, true); + assert.equal(pageCount(cols.length, 2), 2); + assert.deepEqual( + pageColumns(cols, 1, 2).map((col) => col.id), + ["ungrouped"], + ); +}); diff --git a/packages/views/board/layout.ts b/packages/views/board/layout.ts new file mode 100644 index 0000000..ec06718 --- /dev/null +++ b/packages/views/board/layout.ts @@ -0,0 +1,51 @@ +import type { BoardView, Card, SessionView } from "@codedock/core/board"; + +export const BOARD_COL_MIN = 240; +export const BOARD_COL_MAX = 3; + +export type BoardColumn = { + id: string; + title: string; + card?: Card; + sessions: SessionView[]; + ungrouped: boolean; +}; + +// columnsPerRow 按容器宽度限制一行列数,约 240px 一列,最多 3。 +export function columnsPerRow(width: number): number { + if (width <= 0) { + return 1; + } + return Math.min(BOARD_COL_MAX, Math.max(1, Math.floor(width / BOARD_COL_MIN))); +} + +// boardColumns 把已归组卡和未归组列收成横向列。 +export function boardColumns(view: BoardView): BoardColumn[] { + const cols: BoardColumn[] = (view.cards ?? []).map((card) => ({ + id: card.work.id, + title: card.work.title || "未命名", + card, + sessions: card.sessions ?? [], + ungrouped: false, + })); + cols.push({ + id: "ungrouped", + title: "未分组", + sessions: view.ungrouped ?? [], + ungrouped: true, + }); + return cols; +} + +// pageColumns 按当前页切出一排列。 +export function pageColumns(columns: BoardColumn[], page: number, perPage: number): BoardColumn[] { + const size = Math.max(1, perPage); + const start = Math.max(0, page) * size; + return columns.slice(start, start + size); +} + +// pageCount 看板底部分页页数。 +export function pageCount(total: number, perPage: number): number { + const size = Math.max(1, perPage); + return Math.max(1, Math.ceil(Math.max(0, total) / size)); +} diff --git a/packages/views/board/provider.tsx b/packages/views/board/provider.tsx new file mode 100644 index 0000000..f635c0e --- /dev/null +++ b/packages/views/board/provider.tsx @@ -0,0 +1,24 @@ +"use client"; + +import type { BoardClient } from "@codedock/core/board"; +import { createContext, useContext, type ReactNode } from "react"; + +type BoardContextValue = { + client: BoardClient; +}; + +const BoardContext = createContext(null); + +// BoardProvider 只注入 BoardClient,不扩 AgentContext。 +export function BoardProvider({ client, children }: { client: BoardClient; children: ReactNode }) { + return {children}; +} + +// useBoard 读取看板客户端。 +export function useBoard(): BoardContextValue { + const ctx = useContext(BoardContext); + if (!ctx) { + throw new Error("useBoard must be used within BoardProvider"); + } + return ctx; +} diff --git a/packages/views/board/session-float.tsx b/packages/views/board/session-float.tsx new file mode 100644 index 0000000..791dea7 --- /dev/null +++ b/packages/views/board/session-float.tsx @@ -0,0 +1,265 @@ +"use client"; + +import type { BoardEngine } from "@codedock/core/board"; +import type { TimelineItem } from "@codedock/core/chat"; +import { Button } from "@codedock/ui"; +import { Settings, X } from "lucide-react"; +import { useEffect, useMemo, useRef, useState, type PointerEvent as ReactPointerEvent } from "react"; + +import { ApprovalDock } from "../chat/approval-dock.tsx"; +import type { SessionEngine } from "../chat/chat-page.tsx"; +import { ConversationTimeline } from "../chat/conversation-timeline.tsx"; +import { useSessionTimeline } from "../chat/hooks/use-session-timeline.ts"; +import { PendingDock } from "../chat/pending-dock.tsx"; +import { PromptBar } from "../chat/prompt-bar.tsx"; +import { ClaudePane } from "../claude/claude-pane.tsx"; +import { CodexPane } from "../codex/codex-pane.tsx"; +import { useAgent } from "../provider.tsx"; +import { SessionLinkEditor } from "./session-links.tsx"; + +export type FloatSession = { + id: string; + engine: SessionEngine; +}; + +const MIN_W = 320; +const MIN_H = 240; + +type FloatBox = { x: number; y: number; w: number; h: number }; + +type ResizeEdge = { n?: boolean; s?: boolean; e?: boolean; w?: boolean }; + +type Gesture = { + kind: "move" | "resize"; + edge?: ResizeEdge; + x: number; + y: number; + box: FloatBox; +}; + +// SessionFloat 看板不退时用可拖悬浮窗看完整对话;无遮罩,关掉前始终浮在壳上。 +export function SessionFloat({ + session, + onClose, + onOpenPlan, + onOpenFile, + onOpenGit, +}: { + session: FloatSession; + onClose: () => void; + onOpenPlan: Parameters[0]["onOpenPlan"]; + onOpenFile: Parameters[0]["onOpenFile"]; + onOpenGit: () => void; +}) { + const { pickFiles } = useAgent(); + const timeline = useSessionTimeline(session.engine === "agent" ? session.id : undefined); + const pendingApprovals = useMemo( + () => + timeline.state.items.filter( + (item): item is Extract => + item.kind === "approval" && item.status === "pending", + ), + [timeline.state.items], + ); + const [box, setBox] = useState({ x: 72, y: 72, w: 560, h: 640 }); + const [linksOpen, setLinksOpen] = useState(false); + const gesture = useRef(null); + + useEffect(() => { + setLinksOpen(false); + }, [session.id, session.engine]); + + useEffect(() => { + const onMove = (event: PointerEvent) => { + const current = gesture.current; + if (!current) { + return; + } + const dx = event.clientX - current.x; + const dy = event.clientY - current.y; + if (current.kind === "move") { + setBox(clampBox({ ...current.box, x: current.box.x + dx, y: current.box.y + dy })); + return; + } + setBox(resizeBox(current.box, current.edge ?? {}, dx, dy)); + }; + const onUp = () => { + gesture.current = null; + }; + window.addEventListener("pointermove", onMove); + window.addEventListener("pointerup", onUp); + return () => { + window.removeEventListener("pointermove", onMove); + window.removeEventListener("pointerup", onUp); + }; + }, []); + + // beginMove 从标题栏拖动整窗,点到按钮和输入时不拖。 + const beginMove = (event: ReactPointerEvent) => { + if ((event.target as HTMLElement).closest("button, input, a, textarea, select")) { + return; + } + gesture.current = { kind: "move", x: event.clientX, y: event.clientY, box }; + }; + + // beginResize 从边或角改宽高,西/北侧同时挪位置。 + const beginResize = (edge: ResizeEdge) => (event: ReactPointerEvent) => { + event.preventDefault(); + event.stopPropagation(); + gesture.current = { kind: "resize", edge, x: event.clientX, y: event.clientY, box }; + }; + + return ( +
+ + + + + + + + +
+

+ {session.engine === "codex" ? "Codex" : session.engine === "claude" ? "Claude" : "Local"} · {session.id} +

+ + +
+ {linksOpen ? : null} +
+ {session.engine === "codex" ? ( + undefined} + onNewConversation={() => undefined} + onListChange={async () => undefined} + /> + ) : session.engine === "claude" ? ( + undefined} + onNewConversation={() => undefined} + onListChange={async () => undefined} + /> + ) : ( + <> + {timeline.error ? ( +
+ {timeline.error} +
+ ) : null} + +
+ + + +
+ + )} +
+
+ ); +} + +// ResizeHandle 是悬浮窗边上的拖拽热区。 +function ResizeHandle({ + label, + className, + onPointerDown, +}: { + label: string; + className: string; + onPointerDown: (event: ReactPointerEvent) => void; +}) { + return ( +
+ ); +} + +// resizeBox 按拖动方向改宽高;西、北侧同时移动原点。 +function resizeBox(start: FloatBox, edge: ResizeEdge, dx: number, dy: number): FloatBox { + let { x, y, w, h } = start; + if (edge.e) { + w = start.w + dx; + } + if (edge.s) { + h = start.h + dy; + } + if (edge.w) { + w = start.w - dx; + x = start.x + dx; + } + if (edge.n) { + h = start.h - dy; + y = start.y + dy; + } + if (w < MIN_W) { + if (edge.w) { + x -= MIN_W - w; + } + w = MIN_W; + } + if (h < MIN_H) { + if (edge.n) { + y -= MIN_H - h; + } + h = MIN_H; + } + return clampBox({ x, y, w, h }); +} + +// clampBox 把窗限制在视口内,并守住最小宽高。 +function clampBox(box: FloatBox): FloatBox { + const maxW = Math.max(MIN_W, window.innerWidth - 16); + const maxH = Math.max(MIN_H, window.innerHeight - 16); + const w = Math.min(Math.max(MIN_W, box.w), maxW); + const h = Math.min(Math.max(MIN_H, box.h), maxH); + const x = Math.min(Math.max(8, box.x), Math.max(8, window.innerWidth - w - 8)); + const y = Math.min(Math.max(8, box.y), Math.max(8, window.innerHeight - h - 8)); + return { x, y, w, h }; +} diff --git a/packages/views/board/session-links.tsx b/packages/views/board/session-links.tsx new file mode 100644 index 0000000..ce46a1d --- /dev/null +++ b/packages/views/board/session-links.tsx @@ -0,0 +1,125 @@ +"use client"; + +import type { BoardEngine, SessionLinks } from "@codedock/core/board"; +import { Button } from "@codedock/ui"; +import { X } from "lucide-react"; +import { useEffect, useState } from "react"; + +import { useBoard } from "./provider.tsx"; + +// SessionLinkEditor 按行编辑会话上的 GitHub 链接,不让用户选择 Issue 还是 PR。 +export function SessionLinkEditor({ + engine, + sessionId, +}: { + engine: BoardEngine; + sessionId: string; +}) { + const { client } = useBoard(); + const [rows, setRows] = useState([""]); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + + useEffect(() => { + let cancelled = false; + void client + .getLinks(engine, sessionId) + .then((links) => { + if (!cancelled) { + setRows(rowsFromLinks(links)); + } + }) + .catch((err: unknown) => { + if (!cancelled) { + setError(err instanceof Error ? err.message : "无法读取链接"); + } + }); + return () => { + cancelled = true; + }; + }, [client, engine, sessionId]); + + // save 把非空行整批换上去。 + const save = async () => { + setBusy(true); + setError(null); + try { + const saved = await client.replaceLinks( + engine, + sessionId, + rows.map((row) => row.trim()).filter(Boolean), + ); + setRows(rowsFromLinks(saved)); + } catch (err) { + setError(err instanceof Error ? err.message : "保存失败"); + } finally { + setBusy(false); + } + }; + + return ( +
+
+ {rows.map((row, index) => ( +
+ { + const next = rows.slice(); + next[index] = event.target.value; + setRows(next); + }} + /> + +
+ ))} +
+
+ + +
+ {error ?

{error}

: null} +
+ ); +} + +// rowsFromLinks 把已挂快照收成一行一个链接;没有时留一条空行。 +function rowsFromLinks(links: SessionLinks): string[] { + const rows: string[] = []; + if (links.issue) { + rows.push(linkText(links.issue.url, links.issue.repo, links.issue.number, "issues")); + } + for (const pull of links.pulls) { + rows.push(linkText(pull.url, pull.repo, pull.number, "pull")); + } + return rows.length > 0 ? rows : [""]; +} + +// linkText 优先用快照里的地址,没有就按仓库和编号拼一条 GitHub 链接。 +function linkText(url: string, repo: string, number: number, path: "issues" | "pull"): string { + if (url) { + return url; + } + if (repo && number > 0) { + return `https://github.com/${repo}/${path}/${number}`; + } + return ""; +} diff --git a/packages/views/board/work-column.tsx b/packages/views/board/work-column.tsx new file mode 100644 index 0000000..e2c7fa7 --- /dev/null +++ b/packages/views/board/work-column.tsx @@ -0,0 +1,299 @@ +"use client"; + +import type { BoardEngine, InboxItem } from "@codedock/core/board"; +import { Button, MessageResponse } from "@codedock/ui"; +import { useEffect, useState } from "react"; + +import type { SessionEngine } from "../chat/chat-page.tsx"; +import { relativeTime, shortWorkspace } from "../chat/lib/format.ts"; +import { useAgent } from "../provider.tsx"; +import { InboxActions } from "./inbox-actions.tsx"; +import { InfoEditor } from "./info-editor.tsx"; +import type { BoardColumn } from "./layout.ts"; +import { useBoard } from "./provider.tsx"; + +// WorkColumn 一列上头是卡信息,下面挂会话并可直接批。 +export function WorkColumn({ + column, + onOpenSession, + onRefresh, +}: { + column: BoardColumn; + onOpenSession: (id: string, engine: SessionEngine) => void; + onRefresh: () => Promise | void; +}) { + const { client } = useBoard(); + const { pickDirectory } = useAgent(); + const [title, setTitle] = useState(column.title); + const [info, setInfo] = useState(column.card?.info.body ?? ""); + const [editingInfo, setEditingInfo] = useState(false); + const [engine, setEngine] = useState("agent"); + const [inbox, setInbox] = useState([]); + const [error, setError] = useState(null); + const workId = column.card?.work.id; + + useEffect(() => { + setTitle(column.title); + setInfo(column.card?.info.body ?? ""); + }, [column.card?.info.body, column.title]); + + useEffect(() => { + if (!workId) { + setInbox([]); + return; + } + void client.listInbox(workId).then(setInbox).catch(() => setInbox([])); + }, [client, workId, column.card?.pending, column.sessions]); + + // saveTitle 改列头标题。 + const saveTitle = async () => { + if (!workId || title.trim() === "" || title === column.title) { + return; + } + await client.updateWork(workId, title.trim()); + await onRefresh(); + }; + + // saveInfo 从编辑框覆盖写入这张卡的说明。 + const saveInfo = async (next: string) => { + if (!workId) { + return; + } + if (next !== (column.card?.info.body ?? "")) { + await client.putInfo(workId, next); + setInfo(next); + } + setEditingInfo(false); + void onRefresh(); + }; + + // startTalk 在这张卡上开问答会话并打开悬浮窗。 + const startTalk = async () => { + if (!workId) { + return; + } + setError(null); + try { + const started = await client.startSession(workId, { engine, kind: "talk" }); + await onRefresh(); + onOpenSession(started.session_id, started.engine); + } catch (err) { + setError(err instanceof Error ? err.message : "无法开会话"); + } + }; + + // startInDir 在已挂目录上开新会话。 + const startInDir = async (path: string) => { + if (!workId) { + return; + } + setError(null); + try { + const started = await client.startSession(workId, { engine, kind: "dir", checkout: path }); + await onRefresh(); + onOpenSession(started.session_id, started.engine); + } catch (err) { + setError(err instanceof Error ? err.message : "无法开目录会话"); + } + }; + + // attachDir 弹出系统目录选择框后挂到卡上。 + const attachDir = async () => { + if (!workId || !pickDirectory) { + return; + } + const path = await pickDirectory(); + if (!path) { + return; + } + await client.attachCheckout(workId, path); + await onRefresh(); + }; + + return ( +
+
+ {column.ungrouped ? ( +

未分组

+ ) : ( + setTitle(event.target.value)} + onBlur={() => void saveTitle()} + className="w-full bg-transparent text-sm font-semibold outline-none" + /> + )} + {column.card ? ( + <> +
+
+ {info.trim() ? ( + + {info} + + ) : ( +

还没有说明

+ )} +
+ +
+ setEditingInfo(false)} onSave={saveInfo} /> +
+ {(column.card.dirs ?? []).map((dir) => ( +
+ + +
+ ))} +
+
+ {column.sessions.length} 会话 + 进行中 {column.card.running} + 待审批 {column.card.pending} +
+
+ + + + +
+ + ) : ( +

可以先聊天,再放到某个分组。

+ )} + {error ?

{error}

: null} +
+
    + {column.sessions.length === 0 ? ( +
  • 还没有会话
  • + ) : ( + column.sessions.map((session) => { + const sessionInbox = inbox.filter((item) => item.session_id === session.session_id); + return ( +
  • + +

    + {engineLabel(session.engine)} + {session.running ? " · 进行中" : ""} + {session.pending ? ` · 待审批 ${session.pending}` : ""} + {session.updated_at ? ` · ${relativeTime(session.updated_at)}` : ""} +

    + { + if (workId) { + setInbox(await client.listInbox(workId)); + } + await onRefresh(); + }} + /> + {column.ungrouped ? ( + + ) : null} +
  • + ); + }) + )} +
+
+ ); +} + +// AttachMenu 把未归组会话补挂到一张已有卡。 +function AttachMenu({ + sessionId, + engine, + onRefresh, +}: { + sessionId: string; + engine: SessionEngine; + onRefresh: () => Promise | void; +}) { + const { client } = useBoard(); + const [works, setWorks] = useState<{ id: string; title: string }[]>([]); + useEffect(() => { + void client.listWorks().then((items) => setWorks(items.map((item) => ({ id: item.id, title: item.title })))); + }, [client]); + if (works.length === 0) { + return null; + } + return ( + + ); +} + +// engineLabel 列内用短名区分引擎。 +function engineLabel(engine: string): string { + if (engine === "codex") { + return "Codex"; + } + if (engine === "claude") { + return "Claude"; + } + return "Local"; +} diff --git a/packages/views/chat/chat-page.tsx b/packages/views/chat/chat-page.tsx index 4ed4642..d735f4c 100644 --- a/packages/views/chat/chat-page.tsx +++ b/packages/views/chat/chat-page.tsx @@ -1,5 +1,6 @@ "use client"; +import type { Placement, Work } from "@codedock/core/board"; import type { ApprovalMode, Session, TimelineItem, WorkMode } from "@codedock/core/chat"; import type { ClaudeSession } from "@codedock/core/claude"; import type { Session as CodexSession } from "@codedock/core/codex"; @@ -7,6 +8,10 @@ import { Button } from "@codedock/ui"; import { PanelLeft, PanelLeftClose, PanelRight, PanelRightClose, PlusIcon } from "lucide-react"; import { useEffect, useMemo, useState, type ReactNode } from "react"; +import { BoardGrid } from "../board/board-grid.tsx"; +import { groupSessionsByWork } from "../board/group.ts"; +import { useBoard } from "../board/provider.tsx"; +import { SessionFloat, type FloatSession } from "../board/session-float.tsx"; import { ClaudePane } from "../claude/claude-pane.tsx"; import { useClaudeSessionList } from "../claude/hooks/use-session-list.ts"; import { useClaude } from "../claude/provider.tsx"; @@ -39,26 +44,33 @@ export type SessionEngine = "agent" | "codex" | "claude"; export type ChatPageProps = { sessionId?: string; engine?: SessionEngine; + boardMode?: boolean; onOpenSession: (id: string, engine?: SessionEngine) => void; onNewConversation: () => void; + onOpenBoard?: () => void; + onLeaveBoard?: () => void; brandSrc?: string; codexIconSrc?: string; claudeIconSrc?: string; headerActions?: ReactNode; }; -// ChatPage 组合会话列表、对话和右侧多窗口栏。 +// ChatPage 组两态:会话三栏,看板藏左与中、右侧仍是 Plan / 文件 / Git。 export function ChatPage({ sessionId, engine, + boardMode = false, onOpenSession, onNewConversation, + onOpenBoard, + onLeaveBoard, brandSrc, codexIconSrc, claudeIconSrc, headerActions, }: ChatPageProps) { const { client, pickDirectory, pickFiles } = useAgent(); + const { client: boardClient } = useBoard(); const { client: codexClient } = useCodex(); const { client: claudeClient } = useClaude(); const list = useSessionList(); @@ -78,6 +90,9 @@ export function ChatPage({ const [pickingWorkspace, setPickingWorkspace] = useState(false); const workbench = useWorkbench(); const columns = useColumnLayout(); + const [works, setWorks] = useState([]); + const [placements, setPlacements] = useState([]); + const [float, setFloat] = useState(null); useEffect(() => { workbench.reset(); @@ -92,6 +107,18 @@ export function ChatPage({ () => mergeSessions(list.sessions, codexList.sessions, claudeList.sessions), [claudeList.sessions, codexList.sessions, list.sessions], ); + useEffect(() => { + void Promise.all([boardClient.listWorks(), boardClient.listPlacements()]) + .then(([nextWorks, nextPlaces]) => { + setWorks(nextWorks); + setPlacements(nextPlaces); + }) + .catch(() => undefined); + }, [boardClient, sessions]); + const groups = useMemo( + () => groupSessionsByWork(sessions, placements, works), + [placements, sessions, works], + ); const currentKey = sessionId ? `${activeEngine}:${sessionId}` : undefined; const current = sessions.find((session) => `${session.engine}:${session.id}` === currentKey); @@ -226,6 +253,60 @@ export function ChatPage({ workbench.createKind(kind, seed); }; + const rightDock = columns.rightOpen ? ( + <> + columns.moveRight(-delta, persist)} + onCollapse={() => columns.setRightOpen(false)} + /> + + + ) : null; + + if (boardMode) { + return ( +
+
+
+ +
+ {headerActions} + + {columns.rightOpen ? : } + +
+
+ setFloat({ id, engine: nextEngine })} /> +
+ {rightDock} + {float ? ( + setFloat(null)} + onOpenPlan={openPlan} + onOpenFile={openFile} + onOpenGit={openGit} + /> + ) : null} +
+ ); + } + return (
{columns.leftOpen ? ( @@ -233,6 +314,8 @@ export function ChatPage({ ({ id: work.id, title: work.title }))} currentId={currentKey} busy={list.busy || codexList.busy || claudeList.busy} error={ @@ -245,6 +328,16 @@ export function ChatPage({ hasMore={codexList.hasMore} onLoadMore={codexList.hasMore ? () => void codexList.loadMore() : undefined} onCreate={onNewConversation} + onOpenBoard={onOpenBoard} + onAttach={async (session, workId) => { + await boardClient.attachPlacement(workId, session.engine ?? "agent", session.id); + const [nextWorks, nextPlaces] = await Promise.all([ + boardClient.listWorks(), + boardClient.listPlacements(), + ]); + setWorks(nextWorks); + setPlacements(nextPlaces); + }} onSelect={(id, nextEngine) => onOpenSession(id, nextEngine ?? "agent")} onRecover={async (runId) => { await timeline.recover(runId); @@ -451,25 +544,7 @@ export function ChatPage({ )} - {columns.rightOpen ? ( - <> - columns.moveRight(-delta, persist)} - onCollapse={() => columns.setRightOpen(false)} - /> - - - ) : null} + {rightDock}
); } diff --git a/packages/views/chat/session-sidebar.tsx b/packages/views/chat/session-sidebar.tsx index cba483c..ca590d1 100644 --- a/packages/views/chat/session-sidebar.tsx +++ b/packages/views/chat/session-sidebar.tsx @@ -2,16 +2,19 @@ import type { Session } from "@codedock/core/chat"; import { Button, cn } from "@codedock/ui"; -import { Archive, PlusIcon } from "lucide-react"; +import { Archive, LayoutGrid, PlusIcon } from "lucide-react"; +import type { WorkGroup } from "../board/group.ts"; import type { SessionEngine } from "./chat-page.tsx"; import { relativeTime, sessionTitle, sessionTitleParts, shortId } from "./lib/format.ts"; export type SidebarSession = Session & { engine?: SessionEngine }; -// SessionSidebar 混排 Local / Codex / Claude,引擎只靠图标区分。 +// SessionSidebar 按 Work 分组、组内按时间;未挂卡的进未归组。 export function SessionSidebar({ sessions, + groups, + works = [], currentId, busy, error, @@ -21,6 +24,8 @@ export function SessionSidebar({ onSelect, onRecover, onArchive, + onOpenBoard, + onAttach, canRecoverCurrent = false, brandSrc, codexIconSrc, @@ -28,6 +33,8 @@ export function SessionSidebar({ width, }: { sessions: SidebarSession[]; + groups?: WorkGroup[]; + works?: { id: string; title: string }[]; currentId?: string; busy: boolean; error: string | null; @@ -37,12 +44,15 @@ export function SessionSidebar({ onSelect: (id: string, engine?: SessionEngine) => void; onRecover?: (runId: string) => Promise; onArchive?: (session: SidebarSession) => Promise; + onOpenBoard?: () => void; + onAttach?: (session: SidebarSession, workId: string) => Promise; canRecoverCurrent?: boolean; brandSrc?: string; codexIconSrc?: string; claudeIconSrc?: string; width?: number; }) { + const rendered = groups ?? [{ id: null, title: "未分组", updatedAt: "", sessions }]; return (
- +
+ {onOpenBoard ? ( + + ) : null} + +
{error ?

{error}

: null}