Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 38 additions & 10 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,9 +54,9 @@ internal/
tui/ # BubbleTea v2 TUI components
web/ # HTTP server (REST + WS + PTY) + embedded React dist
web/ # React 18 + Vite + RTK product UI (embedded in the binary / Tauri)
packages/ # pnpm workspace: the reusable jcode-ui component library
jcode-ui/ # published styled React chat components (→ npm: jcode-ui)
jcode-ui-core/ # framework-agnostic core: types, ChatRuntime, headless primitives
packages/ # publish source for the jcode-ui npm packages (still in root pnpm workspace)
jcode-ui/ # styled React chat components → npm: jcode-ui
jcode-ui-core/ # types, ChatRuntime, headless primitives → npm: jcode-ui-core
site/ # React + Vite marketing/docs site → www.j-code.net (docs markdown in site/docs/)
desktop/ # Tauri 2 desktop shell; the Go binary runs as a sidecar
extension/ # jcode Browser Bridge Chrome extension (MV3) for the browser-use extension backend
Expand All @@ -67,13 +67,39 @@ agent-eval/ # Agent evaluation harness + showcase generation

### Frontend (React)

The product UI is React 18 (`web/`) built on `packages/jcode-ui` + `jcode-ui-core`.
The product UI is React 18 (`web/`) and **consumes `jcode-ui` / `jcode-ui-core` from the npm registry** (not `file:` / `workspace:*` links). `packages/` is the source tree used to publish those packages.

- `make build-web` builds packages + the React app → `internal/web/dist/` (production embed).
- `make build-web` typechecks/builds package `dist/` then builds the React app → `internal/web/dist/` (production embed).
- `make lint-web` typechecks the React app + both packages.
- Go `//go:embed dist/*` and Tauri `frontendDist` both point at `internal/web/dist/`.
- Consumers (`web/`, `site/`, `examples/*`) declare registry versions, e.g. `"jcode-ui": "^0.1.1"`, `"jcode-ui-core": "^0.1.0"`.
- Inside `packages/jcode-ui`, depend on core via a version range (`"jcode-ui-core": "^0.1.0"`) — **never** `file:../jcode-ui-core` (that broke `jcode-ui@0.1.0` on npm; use `0.1.1+`).

See `packages/jcode-ui/README.md` and `site/docs/chat-ui/`. Published to npm as `jcode-ui` (styled) + `jcode-ui-core` (headless). The runtime abstraction (`ChatRuntime` + `createExternalStoreRuntime`) is the seam that lets the components render from any Redux-shaped store.
See `packages/jcode-ui/README.md` and `site/docs/chat-ui/`. Published: [jcode-ui](https://www.npmjs.com/package/jcode-ui) (styled) + [jcode-ui-core](https://www.npmjs.com/package/jcode-ui-core) (headless). The runtime abstraction (`ChatRuntime` + `createExternalStoreRuntime`) is the seam that lets the components render from any Redux-shaped store.

#### Publishing jcode-ui to npm

Order matters: **publish `jcode-ui-core` first, then `jcode-ui`**.

```bash
# 1) bump versions in packages/*/package.json as needed
# 2) build
cd packages/jcode-ui-core && pnpm build
cd ../jcode-ui && pnpm build

# 3) publish (2FA/OTP may be required)
cd packages/jcode-ui-core && npm publish --access public --otp=XXXXXX
cd ../jcode-ui && npm publish --access public --otp=XXXXXX
```

After a successful publish, bump consumer deps (`web/`, `site/`, `examples/*`) to the new range and run `pnpm install` (and `cd site && pnpm install` for the site workspace). Also refresh `minimumReleaseAgeExclude` entries in every `pnpm-workspace.yaml` (root, `site/`, `examples/*`) so the newly published versions are not blocked by release-age checks. Local edits under `packages/` do **not** reach `web`/`site` until a new version is published and the dependency range is updated.

Checklist before publish:

1. `jcode-ui` → `jcode-ui-core` is a registry range (`^x.y.z`), not `file:`
2. Both packages have fresh `dist/` (`pnpm build`)
3. Smoke: `npm install jcode-ui@<ver>` in a temp dir imports both packages and pulls core transitively
4. After publish: update `minimumReleaseAgeExclude` in all `pnpm-workspace.yaml` files to the new `jcode-ui` / `jcode-ui-core` versions (drop stale entries)

### Key Design Decisions

Expand Down Expand Up @@ -176,6 +202,7 @@ See `packages/jcode-ui/README.md` and `site/docs/chat-ui/`. Published to npm as
- **Don't store mutable state in tool closures.** Use `*Env` or pass state explicitly. Tools may be re-created across mode transitions (normal ↔ plan).
- **Don't skip `env.ResolvePath()`.** Raw path concatenation can escape the working directory without warning.
- **Don't import `internal/tui` from non-TUI packages.** The handler interface is the decoupling boundary.
- **Don't depend on `jcode-ui` / `jcode-ui-core` via `file:` or `workspace:*`.** Consumers and `packages/jcode-ui`→core must use registry version ranges so publish and local installs match.

---

Expand All @@ -190,11 +217,12 @@ See `packages/jcode-ui/README.md` and `site/docs/chat-ui/`. Published to npm as

## Frontend (web/) — React (production)

- **Stack:** React 18 + TypeScript + Vite + Redux Toolkit + `jcode-ui` / `jcode-ui-core`
- **Build:** `make build-web` (packages + `cd web && npx vite build`)
- **Stack:** React 18 + TypeScript + Vite + Redux Toolkit + npm `jcode-ui` / `jcode-ui-core`
- **Build:** `make build-web` (package typecheck/build + `cd web && npx vite build`)
- **Output:** builds to `internal/web/dist/`, embedded in the Go binary via `//go:embed`
- **Lint:** `make lint-web` (tsc for web + packages)
- Changes to the frontend require rebuilding via `make build-web` for the Go binary to pick them up
- Changes to the product UI (`web/`) require rebuilding via `make build-web` for the Go binary to pick them up
- Changes to the chat library (`packages/jcode-ui*`) require **npm publish + consumer version bump + `pnpm install`** before `web`/`site` see them (registry deps, not workspace links)
- **Don't confuse `web/` with `site/`:** `web/` is the product UI embedded in the binary and reused by the desktop app; `site/` is the public website + docs at www.j-code.net and is deployed separately (`cd site && pnpm build`).

### Icons & Styling
Expand All @@ -203,4 +231,4 @@ See `packages/jcode-ui/README.md` and `site/docs/chat-ui/`. Published to npm as
- **Icon sizing:** use Tailwind `h-N w-N` classes (e.g. `className="h-3.5 w-3.5"`).
- **Colors:** every color must come from a CSS custom property (jcode-ui tokens / `tokens.generated.css`). Never hardcode hex/rgb/`#fff`/`white` in components.
- **Themes:** edit `internal/theme/palette.go` and run `make generate` — never edit `tokens.generated.css` or `themes.generated.ts` by hand.
- **Reusable chat UI:** prefer components from `packages/jcode-ui` over one-off markup in `web/`.
- **Reusable chat UI:** import from the `jcode-ui` package (registry); implement/fix library code under `packages/jcode-ui` and publish.
4 changes: 2 additions & 2 deletions examples/jcode-ui-minimal/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,8 @@
"preview": "vite preview"
},
"dependencies": {
"jcode-ui": "file:../../packages/jcode-ui",
"jcode-ui-core": "file:../../packages/jcode-ui-core",
"jcode-ui": "^0.1.1",
"jcode-ui-core": "^0.1.0",
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
Expand Down
3 changes: 3 additions & 0 deletions examples/jcode-ui-minimal/pnpm-workspace.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -3,5 +3,8 @@ packages:
- '.'
allowBuilds:
esbuild: true
minimumReleaseAgeExclude:
- jcode-ui-core@0.1.0
- jcode-ui@0.1.1
onlyBuiltDependencies:
- esbuild
4 changes: 2 additions & 2 deletions examples/jcode-ui-zustand/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,8 @@
"preview": "vite preview"
},
"dependencies": {
"jcode-ui": "file:../../packages/jcode-ui",
"jcode-ui-core": "file:../../packages/jcode-ui-core",
"jcode-ui": "^0.1.1",
"jcode-ui-core": "^0.1.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"zustand": "^5.0.3"
Expand Down
3 changes: 3 additions & 0 deletions examples/jcode-ui-zustand/pnpm-workspace.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -3,5 +3,8 @@ packages:
- '.'
allowBuilds:
esbuild: true
minimumReleaseAgeExclude:
- jcode-ui-core@0.1.0
- jcode-ui@0.1.1
onlyBuiltDependencies:
- esbuild
2 changes: 1 addition & 1 deletion internal-doc/dynamic-workflow-design.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@
| **进度** | stdout 行 | `/workflows` 面板(仿 team panel) | WS `flow_progress` → WorkflowsView 树 | ACP session/update 文本 |

**接线点(verbatim 已勘):**
- Web 事件:`internal/handler/web.go` 加 `OnFlowProgress(data)` → `h.emit("flow_progress", WebFlowProgressData{...})`;`internal/web/engine.go:startPump` 自动 `WSBroker.Broadcast(WSEvent{Type,TaskID,Data})`;前端 `web/src/composables/ws.ts` handlerMap 加 `flow_progress`。
- Web 事件:`internal/handler/web.go` 加 `OnFlowProgress(data)` → `h.emit("flow_progress", WebFlowProgressData{...})`;`internal/web/engine.go:startPump` 自动 `WSBroker.Broadcast(WSEvent{Type,TaskID,Data})`;前端 `web/src/lib/ws.ts` handlerMap 加 `flow_progress`。
- Web API:`internal/web/server.go` 加 `GET/POST /api/workflows`、`GET /api/workflows/runs`、`POST /api/workflows/{name}/run`(仿 automations)。Vue:`stores/workflow.ts` + `components/WorkflowsView.vue` + App.vue `activeView` 加 `'workflows'` + Sidebar 入口。
- TUI:`internal/tui/messages.go` 加 `FlowProgressMsg`;`internal/tui/update.go` 加 `case FlowProgressMsg`(仿 `SubagentProgressMsg`);`internal/command/interactive.go` 加 `s.flowProgress(...)` → `s.p.Send(...)`;斜杠复用 `skillSlashCommands` 模式,新增 flow slash 源。
- 命令:`internal/command/workflow.go` 新建 `NewWorkflowCmd()`(`Use:"workflow" Aliases:["workflows","flow"]`),`cmd/jcode/main.go:47` 注册。
Expand Down
189 changes: 189 additions & 0 deletions internal/web/approval.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,189 @@
package web

import (
"encoding/json"
"io"
"net/http"

"github.com/cnjack/jcode/internal/handler"
"github.com/cnjack/jcode/internal/mode"
"github.com/cnjack/jcode/internal/tools"
)

func (s *Server) handleGetTodos(w http.ResponseWriter, r *http.Request) {
eng := s.activeEngine()
if eng == nil || eng.todoStore == nil {
writeJSON(w, http.StatusOK, []any{})
return
}
writeJSON(w, http.StatusOK, eng.todoStore.Items())
}

// handleGetGoal returns the current session goal (or null when none is set).
func (s *Server) handleGetGoal(w http.ResponseWriter, _ *http.Request) {
eng := s.activeEngine()
if eng == nil || eng.env == nil || eng.env.GoalStore == nil {
writeJSON(w, http.StatusOK, nil)
return
}
writeJSON(w, http.StatusOK, eng.env.GoalStore.Get())
}

// handleSetGoal sets (or replaces) the session goal. Unless start=false, it also
// kicks off an agent run so work begins immediately.
func (s *Server) handleSetGoal(w http.ResponseWriter, r *http.Request) {
eng := s.activeEngine()
if eng == nil || eng.env == nil || eng.env.GoalStore == nil {
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "goals not available"})
return
}
var req struct {
Objective string `json:"objective"`
Start *bool `json:"start,omitempty"` // default true
}
if err := json.NewDecoder(io.LimitReader(r.Body, 1<<20)).Decode(&req); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request body"})
return
}
objective, err := tools.ValidateGoalObjective(req.Objective)
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return
}
g := eng.env.GoalStore.Set(objective)

if req.Start == nil || *req.Start {
// Start working immediately when idle; if busy, the continuation guard
// will pick the goal up after the current run finishes. Targets the active
// task.
if eng.running.CompareAndSwap(false, true) {
s.submitMessage(eng, tools.GoalKickoffPrompt(objective), eng.curMode(), "", "", nil)
}
}
writeJSON(w, http.StatusOK, g)
}

// handleClearGoal removes the session goal.
func (s *Server) handleClearGoal(w http.ResponseWriter, _ *http.Request) {
if eng := s.activeEngine(); eng != nil && eng.env != nil && eng.env.GoalStore != nil {
eng.env.GoalStore.Clear()
}
writeJSON(w, http.StatusOK, map[string]string{"status": "cleared"})
}

func (s *Server) handleApproval(w http.ResponseWriter, r *http.Request) {
var req struct {
ID string `json:"id"`
TaskID string `json:"task_id"`
Approved bool `json:"approved"`
ApproveAll bool `json:"approve_all"`
}
if err := json.NewDecoder(io.LimitReader(r.Body, 1<<16)).Decode(&req); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request"})
return
}
// Route the resolve to the requesting task's handler. resolveEngine maps an
// empty task_id to the active task (legacy clients) but a NON-empty unknown id
// to nil — so a stray id can't resolve against the active task's handler-local
// approval ids.
reng := s.resolveEngine(req.TaskID)
if reng == nil || reng.handler == nil {
writeJSON(w, http.StatusNotFound, map[string]string{"error": "no such task"})
return
}
if err := reng.handler.ResolveApproval(req.ID, req.Approved, req.ApproveAll); err != nil {
writeJSON(w, http.StatusNotFound, map[string]string{"error": err.Error()})
return
}
// "Allow all" promotes that task to auto-approve (the runner flips its
// ApprovalState on resolve). Mirror it onto that task's mode + selector.
s.syncModeAfterApproval(reng, req.Approved, req.ApproveAll)
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
}

// syncModeAfterApproval reflects an approve-all promotion onto the server's
// user-facing mode state and notifies connected clients. A plain single approve
// (or a deny) leaves the mode untouched. The runner's ApprovalState is the
// source of truth for the approval axis; this only projects it onto the unified
// selector the frontend renders.
func (s *Server) syncModeAfterApproval(eng *Engine, approved, approveAll bool) {
if !approved || !approveAll || eng == nil {
return
}
sm := mode.FullAccess
eng.applyModeSwitch(sm.String(), nil)
s.wsBroker.Broadcast(WSEvent{Type: "mode_changed", TaskID: eng.taskID, Data: map[string]string{
"mode": sm.String(),
}})
}

// handlePendingApproval returns approval requests still awaiting a decision.
// The frontend pulls this after rebuilding the timeline (page reload / session
// resume / WS reconnect) so an in-flight approval is re-attached as a card
// instead of leaving the agent blocked forever.
func (s *Server) handlePendingApproval(w http.ResponseWriter, r *http.Request) {
// Empty task_id → active task; non-empty unknown → empty (don't leak another
// task's pending requests under a stray id).
eng := s.resolveEngine(r.URL.Query().Get("task_id"))
if eng == nil || eng.handler == nil {
writeJSON(w, http.StatusOK, []handler.WebApprovalRequestData{})
return
}
writeJSON(w, http.StatusOK, eng.handler.PendingApprovalRequests())
}

// handleAskUser resolves a pending ask_user request with the user's answers,
// routed back to the blocked tool via WebHandler.ResolveAskUser. The "answers"
// array is parallel to the questions the frontend received in ask_user_request:
// each carries the question header plus either a free-text answer or selected
// option labels.
func (s *Server) handleAskUser(w http.ResponseWriter, r *http.Request) {
var req struct {
ID string `json:"id"`
TaskID string `json:"task_id"`
Answers []struct {
QuestionHeader string `json:"question_header"`
Answer string `json:"answer"`
Selected []string `json:"selected"`
} `json:"answers"`
}
if err := json.NewDecoder(io.LimitReader(r.Body, 1<<16)).Decode(&req); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request"})
return
}

resp := tools.AskUserBatchResponse{}
for _, a := range req.Answers {
resp.Answers = append(resp.Answers, tools.AskUserAnswer{
QuestionHeader: a.QuestionHeader,
Answer: a.Answer,
Selected: a.Selected,
})
}

// Route the answer to the requesting task's handler. Empty task_id → active;
// non-empty unknown → reject (ids are handler-local).
eng := s.resolveEngine(req.TaskID)
if eng == nil || eng.handler == nil {
writeJSON(w, http.StatusNotFound, map[string]string{"error": "no such task"})
return
}
if err := eng.handler.ResolveAskUser(req.ID, resp); err != nil {
writeJSON(w, http.StatusNotFound, map[string]string{"error": err.Error()})
return
}
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
}

// handlePendingAskUser returns ask_user questions still awaiting an answer.
// The frontend pulls this after rebuilding the timeline (page reload / session
// resume) so an in-flight question is re-attached to its tool card instead of
// leaving the agent blocked forever.
func (s *Server) handlePendingAskUser(w http.ResponseWriter, r *http.Request) {
eng := s.resolveEngine(r.URL.Query().Get("task_id"))
if eng == nil || eng.handler == nil {
writeJSON(w, http.StatusOK, []handler.WebAskUserRequestData{})
return
}
writeJSON(w, http.StatusOK, eng.handler.PendingAskUserRequests())
}
Loading
Loading