diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2b765ff1..79a586d2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -76,8 +76,8 @@ jobs: - uses: actions/checkout@v5 # Go is needed only to run the theme generator, which writes - # web/src/composables/themes.generated.ts + styles/tokens.generated.css - # (gitignored) — without them vue-tsc can't resolve ./themes.generated. + # web/src/lib/themes.generated.ts + styles/tokens.generated.css + # (gitignored) — without them tsc can't resolve the theme registry. - uses: actions/setup-go@v6 with: go-version-file: 'go.mod' @@ -92,18 +92,19 @@ jobs: - name: Generate theme assets run: go generate ./internal/theme/... - - name: Install deps - working-directory: web - run: pnpm install --frozen-lockfile + - name: Install monorepo deps + run: pnpm install --frozen-lockfile || pnpm install - - name: Type-check - working-directory: web - run: pnpm type-check + - name: Build packages + run: | + cd packages/jcode-ui-core && npx tsc -p tsconfig.build.json + cd ../jcode-ui && npx tsc -p tsconfig.build.json + npx tailwindcss -i src/styles/entry.css -o dist/styles.css --minify - - name: Lint + - name: Type-check web app working-directory: web - run: npx oxlint . + run: npx tsc --noEmit -p tsconfig.app.json - - name: Build + - name: Build web app working-directory: web run: npx vite build diff --git a/.gitignore b/.gitignore index bf3bd38b..e1b6e9c4 100644 --- a/.gitignore +++ b/.gitignore @@ -3,17 +3,16 @@ /jcode-new /weixin_poc internal/web/dist -internal/web/dist-react internal/model/registry_generated.go web/src/styles/tokens.generated.css -web/src/composables/themes.generated.ts +web/src/lib/themes.generated.ts # Node / pnpm (root monorepo + per-app workspaces) node_modules/ **/node_modules/ *.tsbuildinfo packages/*/dist/ -web-react/dist/ +web/dist/ # Lockfile — committed at repo root for the monorepo # (pnpm-lock.yaml IS committed; do NOT ignore it) diff --git a/AGENTS.md b/AGENTS.md index dddb03c4..4666bd6d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,6 +1,6 @@ # AGENTS.md — JCode Project Development Guide -Go coding agent — [Eino](https://github.com/cloudwego/eino) + BubbleTea v2 TUI + Vue 3 web UI + Tauri 2 desktop shell. +Go coding agent — [Eino](https://github.com/cloudwego/eino) + BubbleTea v2 TUI + React web UI + Tauri 2 desktop shell. - **Module:** `github.com/cnjack/jcode` | **Entry:** `cmd/jcode/` | **Config dir:** `~/.jcode/` @@ -12,7 +12,7 @@ Go coding agent — [Eino](https://github.com/cloudwego/eino) + BubbleTea v2 TUI make build # generate → build-web → go build make install # generate → build-web → go install make run # go run ./cmd/jcode/ -make lint # golangci-lint + eslint/oxlint (lint-go / lint-web) +make lint # golangci-lint + React typecheck (lint-go / lint-web) make doctor # system check make desktop-dev # Tauri desktop app in dev mode (rebuilds the Go sidecar first) make desktop-build # distributable desktop bundle (.app/.dmg/.msi) @@ -52,9 +52,8 @@ internal/ feature/ # Compile-time feature flags via build tags (e.g. desktop, jcode_headless, ble) telemetry/ # Optional Langfuse tracing tui/ # BubbleTea v2 TUI components - web/ # HTTP server (REST + WS + PTY) + embedded Vue dist -web/ # Vue 3 + Vite + TypeScript frontend source (the CURRENT product UI) -web-react/ # React 18 + Vite + RTK product app (migration in progress; parallel to web/) + 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 @@ -66,18 +65,15 @@ script/ # Build-time code generation + install.sh agent-eval/ # Agent evaluation harness + showcase generation ``` -### Frontend migration (Vue → React) — in progress +### Frontend (React) -The product UI is migrating from Vue 3 (`web/`) to React 18 (`web-react/`), -built on a new reusable component library (`packages/jcode-ui` + `jcode-ui-core`). -**During the migration both coexist:** +The product UI is React 18 (`web/`) built on `packages/jcode-ui` + `jcode-ui-core`. -- `make build-web` (default) builds the **Vue** app → `internal/web/dist/` (production). -- `make build-web-react` builds the **React** app + packages → `internal/web/dist-react/` (parallel validation). -- `make lint-react` typechecks the React app + both packages. -- The Go `embed.FS` and Tauri `frontendDist` still point at the Vue `dist/`. The switch-over happens once `web-react` reaches feature parity. +- `make build-web` builds packages + 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/`. -The component library is the migration's organizing principle — see `packages/jcode-ui/README.md` and `site/docs/chat-ui/`. It's 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 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. ### Key Design Decisions @@ -192,21 +188,19 @@ The component library is the migration's organizing principle — see `packages/ --- -## Frontend (web/) — Vue (production) +## Frontend (web/) — React (production) -> **Note:** the product UI is migrating to React (`web-react/` + `packages/jcode-ui`). The Vue app remains the production build during the migration. New reusable UI work goes in `packages/jcode-ui` (React); see the migration section above and `packages/jcode-ui/README.md`. - -- **Stack:** Vue 3 + TypeScript + Vite -- **Build:** `cd web && pnpm install && npx vite build` (or `make build-web`) -- **Output:** builds to `internal/web/dist/`, embedded in Go binary via `//go:embed` -- **Lint:** `cd web && pnpm lint` (eslint + oxlint) +- **Stack:** React 18 + TypeScript + Vite + Redux Toolkit + `jcode-ui` / `jcode-ui-core` +- **Build:** `make build-web` (packages + `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 -- **Don't confuse `web/` with `site/`:** `web/` (Vue) is the product UI embedded in the binary and reused by the desktop app; `site/` (React) is the public website + docs at www.j-code.net and is deployed separately (`cd site && pnpm build`). +- **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 -- **Icons:** use `@heroicons/vue/24/outline` exclusively. Import each icon by name from its subpath (`import { XMarkIcon } from '@heroicons/vue/24/outline'`) for per-file tree-shaking. Do **not** hand-write inline `` icons or `v-html` SVG path strings. -- **Icon sizing:** use Tailwind `w-N h-N` classes (e.g. `class="w-3.5 h-3.5"`), never a `:size` prop. -- **Colors:** every color must come from a CSS custom property defined in `src/styles/tokens.css`. Never hardcode hex/rgb/`#fff`/`white` in `.vue` or `.css`. Text on the primary/destructive fills uses `--color-on-primary` / `--color-on-destructive`; code blocks use `--code-bg` / `--code-border`; syntax highlighting uses `--hljs-*`. -- **Terminal (xterm) colors:** live in tokens (`--term-*` and the 16-color ANSI palette) and are read at runtime via `getComputedStyle` in `TerminalInstance.vue` — see `termTheme()`. Do not define terminal colors inline. -- **Adding a new color:** add the token to `tokens.css` (both `:root` light and `.dark`) first, then reference it by `var(...)`. To theme it per generated theme, edit `internal/theme/palette.go` and regenerate — never edit `tokens.generated.css` by hand. +- **Icons:** use `@heroicons/react/24/outline` exclusively. Import each icon by name. Do **not** hand-write inline `` icons. +- **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/`. diff --git a/Makefile b/Makefile index caa852d5..a9b26c3b 100644 --- a/Makefile +++ b/Makefile @@ -13,7 +13,7 @@ LDFLAGS := -s -w \ export GOFLAGS := -buildvcs=false -.PHONY: build build-binary run doctor version install clean build-web build-web-react fmt lint lint-go lint-web lint-react generate setup-hooks desktop-icons desktop-sidecar desktop-dev desktop-build desktop-react-dev desktop-react-build desktop-clean +.PHONY: build build-binary run doctor version install clean build-web fmt lint lint-go lint-web generate setup-hooks desktop-icons desktop-sidecar desktop-dev desktop-build desktop-clean build-ble fmt: @echo "Formatting Go..." @@ -26,54 +26,29 @@ lint-go: golangci-lint run lint-web: - @echo "Linting frontend (Vue)..." - cd web && (pnpm install --frozen-lockfile 2>/dev/null || pnpm install) - cd web && pnpm lint - -lint-react: @echo "Type-checking React frontend + packages..." - cd packages/jcode-ui-core && pnpm install --frozen-lockfile 2>/dev/null || pnpm install + -pnpm install --frozen-lockfile 2>/dev/null || true cd packages/jcode-ui-core && npx tsc --noEmit -p tsconfig.json cd packages/jcode-ui && npx tsc --noEmit -p tsconfig.json - cd web-react && npx tsc --noEmit -p tsconfig.app.json + cd web && npx tsc --noEmit -p tsconfig.app.json generate: @echo "Generating code..." go generate ./internal/model/... go generate ./internal/theme/... -# The frontend build. FRONTEND selects which app to build: -# web (default) — the current Vue app (production) -# web-react — the new React app (migration in progress; produces dist-react/) -# During the migration both coexist. The switch-over to React-as-default happens -# once web-react reaches feature parity and the Go embed points at dist-react. -FRONTEND ?= web - -build-web: generate - @echo "Building frontend ($(FRONTEND))..." -ifeq ($(FRONTEND),web-react) - $(MAKE) build-web-react -else - cd web && (pnpm install --frozen-lockfile 2>/dev/null || pnpm install) - cd web && npx vite build -endif - -# Build the React frontend + the two component-library packages it depends on. -# Output goes to ../internal/web/dist-react (kept separate from the Vue dist -# until the embed path is switched). The packages are workspace-linked, so this -# also builds jcode-ui-core (a dependency of jcode-ui). +# Build the React product UI (web/) + jcode-ui packages into internal/web/dist +# for Go //go:embed and the Tauri shell. # # NOTE: pnpm's ERR_PNPM_IGNORED_BUILDS (esbuild/@parcel/watcher native scripts) -# returns a non-zero exit even when deps are fully installed. We tolerate that -# exit code from the install step — the build steps below don't depend on those -# build scripts having run. -build-web-react: generate +# can return non-zero even when deps are fully installed. Tolerate install exit. +build-web: generate @echo "Building React frontend + packages..." -pnpm install --frozen-lockfile 2>/dev/null || true cd packages/jcode-ui-core && npx tsc -p tsconfig.build.json cd packages/jcode-ui && npx tsc -p tsconfig.build.json cd packages/jcode-ui && npx tailwindcss -i src/styles/entry.css -o dist/styles.css --minify - cd web-react && npx vite build + cd web && npx vite build # The main binary never links CoreBluetooth (whose eager init triggers the macOS # Bluetooth permission prompt at startup). BLE runs in a separate `jcode-ble` @@ -112,6 +87,7 @@ version: clean: rm -f $(BIN) rm -rf internal/web/dist + rm -rf packages/jcode-ui/dist packages/jcode-ui-core/dist setup-hooks: @git config core.hooksPath .githooks @@ -155,22 +131,5 @@ desktop-dev: desktop-sidecar desktop-build: desktop-sidecar cd $(DESKTOP_DIR) && (pnpm install 2>/dev/null || npm install) && pnpm tauri build -# ─── React desktop variants ──────────────────────────────────────────────── -# Same as desktop-dev/desktop-build but load the React frontend (web-react/) -# instead of the Vue app (web/). Uses tauri.react.conf.json to override the -# build block (frontendDist / beforeDevCommand / beforeBuildCommand) — every -# other Tauri setting (window, tray, sidecar, capabilities) is inherited. -# Requires `pnpm install` to have run once at the repo root (the React -# workspace lives there, not under desktop/). -desktop-react-dev: desktop-sidecar - @echo "Launching desktop (React frontend)…" - cd $(DESKTOP_DIR) && (pnpm install 2>/dev/null || npm install) && \ - pnpm tauri dev --config src-tauri/tauri.react.conf.json - -desktop-react-build: desktop-sidecar - @echo "Bundling desktop (React frontend)…" - cd $(DESKTOP_DIR) && (pnpm install 2>/dev/null || npm install) && \ - pnpm tauri build --config src-tauri/tauri.react.conf.json - desktop-clean: rm -rf $(SIDECAR_DIR) $(DESKTOP_DIR)/src-tauri/target diff --git a/desktop/src-tauri/tauri.conf.json b/desktop/src-tauri/tauri.conf.json index cb802754..a11223de 100644 --- a/desktop/src-tauri/tauri.conf.json +++ b/desktop/src-tauri/tauri.conf.json @@ -5,7 +5,7 @@ "identifier": "com.cnjack.jcode", "build": { "frontendDist": "../../internal/web/dist", - "beforeDevCommand": "pnpm --dir ../web dev", + "beforeDevCommand": "pnpm --dir ../web dev --host", "devUrl": "http://localhost:5173", "beforeBuildCommand": "make -C .. build-web" }, diff --git a/desktop/src-tauri/tauri.react.conf.json b/desktop/src-tauri/tauri.react.conf.json deleted file mode 100644 index 1d46c63b..00000000 --- a/desktop/src-tauri/tauri.react.conf.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "build": { - "frontendDist": "../../internal/web/dist-react", - "beforeDevCommand": "pnpm --dir ../web-react dev", - "devUrl": "http://localhost:5173", - "beforeBuildCommand": "make -C .. build-web-react" - } -} diff --git a/internal/theme/gen/main.go b/internal/theme/gen/main.go index debca903..964cd82a 100644 --- a/internal/theme/gen/main.go +++ b/internal/theme/gen/main.go @@ -4,9 +4,9 @@ // // Two files are generated (both build artifacts, gitignored — never edit by // hand): -// - web/src/styles/tokens.generated.css : one html[data-theme=""] block +// - web/src/styles/tokens.generated.css : one [data-theme=""] block // of CSS custom properties per theme. -// - web/src/composables/themes.generated.ts : the theme registry (id, label, +// - web/src/lib/themes.generated.ts : the theme registry (id, label, // appearance) the picker UI iterates, so the list can't drift either. package main @@ -22,7 +22,7 @@ import ( func main() { writeFile(repoPath("web", "src", "styles", "tokens.generated.css"), css()) - writeFile(repoPath("web", "src", "composables", "themes.generated.ts"), ts()) + writeFile(repoPath("web", "src", "lib", "themes.generated.ts"), ts()) fmt.Printf("theme/gen: wrote %d themes (css + ts)\n", len(theme.All())) } @@ -61,6 +61,13 @@ func ts() string { } func writeFile(path, content string) { + // Generated files are gitignored; their parent dirs may not exist on a + // clean checkout (CI). Create them so go generate never fails only because + // tokens.generated.css / themes.generated.ts were never committed. + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + fmt.Fprintf(os.Stderr, "theme/gen: mkdir %s: %v\n", filepath.Dir(path), err) + os.Exit(1) + } if err := os.WriteFile(path, []byte(content), 0o644); err != nil { fmt.Fprintf(os.Stderr, "theme/gen: write %s: %v\n", path, err) os.Exit(1) diff --git a/packages/jcode-ui-core/src/primitives/ToolCallView.tsx b/packages/jcode-ui-core/src/primitives/ToolCallView.tsx index 35789e94..da6ee7e1 100644 --- a/packages/jcode-ui-core/src/primitives/ToolCallView.tsx +++ b/packages/jcode-ui-core/src/primitives/ToolCallView.tsx @@ -1,17 +1,16 @@ /** * ToolCallView — the headless expand/collapse shell for a tool invocation. * - * Owns: expand/collapse state, status glyph dispatch, and renderer lookup via a - * ToolRendererRegistry (provided through context by the host). Does NOT own the - * per-tool rendering logic — that lives in registered renderers. The styled - * `jcode-ui` `ToolCallCard` wraps this with the jcode visual language. + * Owns: expand/collapse state, renderer lookup via ToolRendererRegistry, and + * subagent recursion. Does NOT own per-tool body chrome — the styled + * `jcode-ui` `ToolCallCard` supplies header styling + CSS for `.toolcall-body`. * - * Subagent recursion: when `tool.name === 'subagent'`, children are rendered as - * nested ToolCallView instances (capped at a max-depth). ask_user tools are - * routed to the AskUserBlock renderer. + * Subagent: only `tool.name === 'subagent'` (NOT team_spawn — that has its own + * renderer). Children recurse as nested ToolCallView instances. ask_user tools + * route to the host's renderAskUser slot. */ -import { useContext, useMemo, useState } from 'react' +import { createContext, useContext, useMemo, useState } from 'react' import type { ReactNode } from 'react' import type { ToolCall } from '../types/index.js' import type { ToolRendererRegistry, ToolRendererProps } from '../adapters/index.js' @@ -25,7 +24,6 @@ export interface ToolCallContextValue { renderAskUser?: (tool: ToolCall) => ReactNode } -import { createContext } from 'react' const ToolCallCtx = createContext(null) export function ToolCallProvider({ value, children }: { value: ToolCallContextValue; children: ReactNode }) { @@ -44,14 +42,17 @@ export interface ToolCallViewProps { maxDepth?: number /** Default expanded state. Default false (subagents default true). */ defaultExpanded?: boolean - /** Render-prop for the collapsed header. Falls back to a default row. */ + /** Render-prop for the header. Falls back to a default row. */ renderHeader?: (tool: ToolCall, expanded: boolean, toggle: () => void) => ReactNode + /** + * Optional subagent body (output/error). Styled layer supplies markdown. + * Receives only output-related fields — never args. + */ + renderSubagentOutput?: (tool: ToolCall) => ReactNode /** className passthrough. */ className?: string } -const SUBAGENT_NAMES = new Set(['subagent', 'team_spawn']) - export function ToolCallView({ tool, depth = 0, @@ -59,9 +60,11 @@ export function ToolCallView({ defaultExpanded, className, renderHeader, + renderSubagentOutput, }: ToolCallViewProps): ReactNode { const ctx = useToolCallContext() - const isSubagent = SUBAGENT_NAMES.has(tool.name) + // Only the recursive subagent tool — team_spawn has its own renderer (Vue parity). + const isSubagent = tool.name === 'subagent' const isAskUser = tool.name === 'ask_user' && (!!tool.askUserId || tool.status === 'running') const [expanded, setExpanded] = useState(defaultExpanded ?? isSubagent) @@ -72,7 +75,7 @@ export function ToolCallView({ return <>{ctx.renderAskUser(tool)} } - // Look up a renderer for the body. + // Look up a renderer for the body (not used for subagent shells — no args dump). const Renderer = ctx?.registry.get(tool.name) ?? null const header = @@ -80,23 +83,63 @@ export function ToolCallView({ ) - const body = Renderer ? : null + const body = !isSubagent && Renderer ? : null + const children = isSubagent && tool.children && tool.children.length > 0 && depth < maxDepth - ? tool.children.map((c) => ( -
- {ctx?.renderChild ? ctx.renderChild(c, depth + 1) : } -
- )) + ? tool.children.map((c) => + ctx?.renderChild ? ( +
{ctx.renderChild(c, depth + 1)}
+ ) : ( + + ), + ) : null + // Prefer displayOutput (clean) over raw output; never surface args for subagents. + const subagentText = tool.displayOutput || tool.output || '' + return ( -
+
{header} - {expanded && ( -
+ + {/* Subagent: children + output only (no args). Output rendered by styled slot. */} + {expanded && isSubagent && ( +
+ {children && children.length > 0 ? ( +
{children}
+ ) : tool.status === 'running' && !subagentText ? ( +
Starting…
+ ) : null} + {renderSubagentOutput + ? renderSubagentOutput(tool) + : subagentText + ?
{truncate(subagentText, 2000)}
+ : null} + {tool.error ?
{tool.error}
: null} +
+ )} + + {/* Regular tool: single content box under the title (top edge = divider). */} + {expanded && !isSubagent && ( +
{body} - {children}
)}
@@ -117,7 +160,12 @@ function toRendererProps(tool: ToolCall): ToolRendererProps { } } -/** Minimal default header: status glyph + title + subtitle + chevron. */ +function truncate(text: string, max: number): string { + const chars = [...text] + return chars.length > max ? chars.slice(0, max).join('') + `… (${chars.length} chars)` : text +} + +/** Minimal default header (headless fallback). */ function DefaultToolHeader({ tool, expanded, @@ -127,12 +175,23 @@ function DefaultToolHeader({ expanded: boolean onToggle: () => void }): ReactNode { - const glyph = tool.status === 'running' ? '◈' : tool.status === 'error' ? '✗' : '✓' const title = tool.displayInfo?.title ?? tool.name const subtitle = tool.displayInfo?.subtitle ?? '' return ( - - {canEdit && ( - + {message.reasoning && ( +
+ +
+ )} + + {/* Body or inline edit — flat prose, no card bg/border. */} + {editing ? ( +
+