feat(ui): AI Tools modal, one-button lane tool picker, AgentHub settings in Overview, markdown preview

- AI Tools moves from a Settings section into a modal behind a button at the
  Settings tab bar's right edge (AiToolsModal, `actions` on PageTabs,
  /settings#ai-tools); rows get an enable switch, Default badge, and an
  Update button for every tool whose catalog names an updater (no version
  lookup on open); not-installed tools fold behind a disclosure, a finished
  job's log folds to one line; shared AiToolIcon
- codex updates through its own `codex update`, openclaw/hermes gain
  `update --yes`; update-all runs every installed updater and skips an npm
  one only when npm does not manage the binary
- Add-lane tool picker is one button showing the chosen tool that opens the
  enabled tools as buttons
- Claude Code / Codex AgentHub sub-tab folded into Overview (home editor,
  hooks, pricing); the path list drops CLAUDE_HOME/CODEX_HOME;
  HomeDirSetting shows the saved value only while the field differs
- Preview/Raw toggle for markdown files in the config FileViewer and the
  Changes panel's whole-file view (MarkdownPreview: react-markdown +
  remark-gfm, raw HTML never run, frontmatter as a code block)
- docs and the CLAUDE.md marker updated
This commit is contained in:
2026-10-01 10:11:43 +07:00
parent 058c574138
commit c5aba094bd
55 changed files with 2985 additions and 853 deletions
+4 -4
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -214,7 +214,7 @@ After starting a Claude Code session, you should see:
- **Agent Board** — a `Main Agent` card in the `Waiting` column until you type your first message; it flips to `Working` on `UserPromptSubmit` / `PreToolUse` and back to `Waiting` after each `Stop`
- **Activity Feed** — events streaming in as Claude Code uses tools
- **Dashboard** — stats updating in real-time
- **Settings page** — data export and cleanup tools on the Dashboard tab; Claude Code's model pricing rules, hook configuration status and CLAUDE_HOME on **Settings → Claude Code → AgentHub**
- **Settings page** — data export and cleanup tools on the Dashboard tab; Claude Code's model pricing rules, hook configuration status and CLAUDE_HOME on **Settings → Claude Code → Overview**
If nothing appears after 30 seconds, see [SETUP.md](./SETUP.md#troubleshooting).
+1 -1
View File
@@ -225,7 +225,7 @@ Codex lane's run panel offers Codex's own sandbox, approval and reasoning-effort
choices, and resumes with `codex resume --last`. Codex sessions and token usage
are read from Codex's own rollout journals: every Codex session (a lane's or
not) appears in Sessions with a **Codex** badge and is priced like any other,
with OpenAI rates filled in automatically (Settings → Codex → AgentHub). For a
with OpenAI rates filled in automatically (Settings → Codex → Overview). For a
live feed, install **Codex hooks** from the same tab: the next time Codex starts
it asks you to trust them (**Trust all and continue**), and from then on each
tool call lands in the session's timeline, a codex lane turns running/idle as
+4 -4
View File
@@ -133,10 +133,10 @@ npm run clear-data
### Data management via Settings page
The Settings page (`/settings`) provides a UI for the following. **Model Pricing**
and **Hook Configuration** are not on its Dashboard tab: they live in the Claude
Code tab's **AgentHub** sub-tab (Settings → Claude Code → AgentHub), beside
CLAUDE_HOME; Codex and Antigravity have their own pricing and hooks in their
own tabs.
and **Hook Configuration** are not on its Dashboard tab: they sit under the
Claude Code tab's **Overview** (Settings → Claude Code → Overview), beside
CLAUDE_HOME; Codex (its Overview) and Antigravity have their own pricing and
hooks in their own tabs.
- **Model Pricing** — view and edit per-model cost rates, reset to defaults, add custom models
- **Hook Configuration** — check which hooks are installed and reinstall them
+17 -4
View File
@@ -188,10 +188,12 @@ client/
│ │ ├── RemoteSources.tsx # Remote Data Sources settings panel (SSH multi-machine collection)
│ │ ├── SessionToolBadge.tsx # The CLI that produced a non-Claude session, by its AI Tools label (nothing for Claude)
│ │ ├── settings/ # Settings panels, among them:
│ │ │ ├── AiToolsModal.tsx # AI Tools dialog, opened from the Settings tab bar's right edge
│ │ │ ├── AiToolsSettings.tsx # Its body: installed/available CLIs, enable/default, install/update
│ │ │ ├── PricingSettings.tsx # Model Pricing table, `vendor` filter + Show all models
│ │ │ ├── ClaudeAgentHubSettings.tsx # Claude Code → AgentHub: pricing, hook status, CLAUDE_HOME
│ │ │ ├── ClaudeAgentHubSettings.tsx # Claude Code → Overview: CLAUDE_HOME, hook status, pricing
│ │ │ ├── HomeDirSetting.tsx # CLAUDE_HOME / CODEX_HOME editor (env-owned warning)
│ │ │ ├── CodexHooksSettings.tsx # Codex → AgentHub → Codex hooks card
│ │ │ ├── CodexHooksSettings.tsx # Codex → Overview → Codex hooks card
│ │ │ └── AntigravityHooksSettings.tsx # Antigravity → Antigravity hooks card
│ │ └── workflows/ # Workflows page panels: own-pipeline stats + D3 analytics (11 files)
│ │
@@ -247,7 +249,7 @@ graph TB
Detail[SessionDetail<br/>agent hierarchy + timeline]
Feed[ActivityFeed<br/>streaming event log]
Workflows[Workflows<br/>Lane pipelines + D3 analytics]
Settings[SettingsHub<br/>Dashboard tab: appearance + notifications + data<br/>Claude Code / Codex → AgentHub, Antigravity:<br/>each tool's pricing + hooks]
Settings[SettingsHub<br/>Dashboard tab: appearance + notifications + data<br/>Claude Code / Codex → Overview, Antigravity:<br/>each tool's pricing + hooks]
App --> Layout
Layout --> Dashboard & Sessions & Detail & Feed & Workflows & Settings
@@ -293,7 +295,7 @@ sequenceDiagram
The client uses **local component state** and **React hooks** for state management. No global state library (Redux, Zustand) is used to keep the architecture simple. The one small exception is the **data-scope store** (`lib/dataScope.ts`): a lightweight app-wide store holding the current set of data sources (`local` plus any configured [Remote Data Sources](../server/README.md#remote-data-sources)). Pages read it and append `?sources=` to their API requests, so a single selector narrows the whole app to one or more machines' data. Remote sources are managed from the Settings page via the `RemoteSources` component (`components/RemoteSources.tsx`), which drives the `/api/remote-sources` CRUD/test/sync endpoints and reflects live `remote_source.status` WebSocket updates. When a sync finishes, stats pages refetch via `lib/remoteDataEvents.ts` (`remote_data.updated`, `remote_source.status` with `ok`, or remote `import.progress` complete).
**Cursor sessions (informational):** Settings surfaces a subtle note on the CLAUDE_HOME (Settings → Claude Code → AgentHub), Import History, and Remote Data Sources panels — **Cursor** agent sessions count too because Cursor stores transcripts under the same `~/.claude` paths as Claude Code locally (and on synced remotes).
**Cursor sessions (informational):** Settings surfaces a subtle note on the CLAUDE_HOME (Settings → Claude Code → Overview), Import History, and Remote Data Sources panels — **Cursor** agent sessions count too because Cursor stores transcripts under the same `~/.claude` paths as Claude Code locally (and on synced remotes).
### State Strategy
@@ -763,6 +765,17 @@ closes. Consequences worth knowing:
expanding a truncated **number** inside a line of text, but draws the same
surface so the two read as one thing.
#### MarkdownPreview (`components/MarkdownPreview.tsx`)
A `.md`/`.markdown`/`.mdx` file (`isMarkdown`) rendered for reading with
`react-markdown` + `remark-gfm` (tables, task lists, strikethrough). It sits
behind a **Preview** / **Raw** toggle in two places: the config explorer's
`FileViewer` and the Changes panel's whole-file view (`lane-file-preview`,
reset to Raw on every file switch; the editor still opens from Raw). Raw HTML
in the file is shown as text, never run (no `rehype-raw`), links open in a new
tab, and a leading YAML frontmatter block is shown as a code block. Styles are
the `.md-preview` rules in `index.css`, built from theme tokens.
---
## Utilities
+1484 -8
View File
File diff suppressed because it is too large Load Diff
+3 -1
View File
@@ -23,7 +23,9 @@
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-i18next": "^17.0.4",
"react-router-dom": "^6.28.2"
"react-markdown": "^10.1.0",
"react-router-dom": "^6.28.2",
"remark-gfm": "^4.0.1"
},
"devDependencies": {
"@testing-library/jest-dom": "^6.9.1",
+42
View File
@@ -0,0 +1,42 @@
/**
* @file AiToolIcon.tsx
* @description An AI tool's icon: the server's cached favicon
* (GET /api/ai-tools/:id/icon), or the label's first letter when there is
* none. Shared by the AI Tools modal and the Add-lane tool picker.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useState } from "react";
import { api, type AiTool } from "../lib/api";
/**
* @param props.tool - The catalog entry.
* @param props.className - Size classes (default `h-8 w-8`).
*/
export function AiToolIcon({
tool,
className = "h-8 w-8",
}: {
tool: Pick<AiTool, "id" | "label">;
className?: string;
}) {
// Keyed by id: one instance showing another tool must retry its icon.
const [failedId, setFailedId] = useState<string | null>(null);
if (failedId !== tool.id) {
return (
<img
src={api.aiTools.iconUrl(tool.id)}
alt=""
onError={() => setFailedId(tool.id)}
className={`${className} shrink-0 rounded-md bg-surface-4 object-contain p-1`}
/>
);
}
return (
<span
className={`${className} flex shrink-0 items-center justify-center rounded-md bg-surface-4 text-xs font-semibold text-fg-secondary`}
>
{tool.label.charAt(0).toUpperCase()}
</span>
);
}
+44
View File
@@ -0,0 +1,44 @@
/**
* @file MarkdownPreview.tsx
* @description A markdown file rendered for reading (GitHub-flavoured:
* tables, task lists, strikethrough, autolinks), shared by the config
* explorer's file viewer and the lane Changes panel. Raw HTML in the file is
* shown as text, never run (react-markdown without rehype-raw), and links
* open in a new tab. A leading YAML frontmatter block (skills, agents) is
* shown as a code block instead of being misread as a heading. `isMarkdown`
* says which paths get the preview toggle.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
/** Whether `path` names a markdown file. */
export function isMarkdown(path: string | null | undefined): boolean {
return !!path && /\.(md|markdown|mdx)$/i.test(path);
}
/** Split a leading `---` frontmatter block off the body. */
function splitFrontmatter(text: string): { front: string | null; body: string } {
const m = /^---\r?\n([\s\S]*?)\r?\n---[ \t]*(?:\r?\n|$)/.exec(text);
return m ? { front: m[1]!, body: text.slice(m[0].length) } : { front: null, body: text };
}
export function MarkdownPreview({ text, className = "" }: { text: string; className?: string }) {
const { front, body } = splitFrontmatter(text);
return (
<div data-testid="markdown-preview" className={`md-preview ${className}`}>
{front !== null && <pre className="md-frontmatter">{front}</pre>}
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={{
a: ({ node: _node, ...props }) => (
<a {...props} target="_blank" rel="noreferrer noopener" />
),
}}
>
{body}
</ReactMarkdown>
</div>
);
}
+17 -5
View File
@@ -2,10 +2,11 @@
* @file PageTabs.tsx
* @description Shared top-of-page tab bar used by the pages that merged several
* former routes into one (Settings hub, Sessions/Events history). Rendering only -
* the owning page keeps the active tab in its own query param.
* the owning page keeps the active tab in its own query param. `actions` sits at
* the bar's right edge, outside the tablist.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import type { ComponentType } from "react";
import type { ComponentType, ReactNode } from "react";
export interface PageTab {
id: string;
@@ -18,6 +19,8 @@ interface PageTabsProps {
activeId: string;
onSelect: (id: string) => void;
ariaLabel: string;
/** Buttons at the right edge of the bar (not tabs). */
actions?: ReactNode;
}
/**
@@ -25,12 +28,14 @@ interface PageTabsProps {
* @param props - Tabs to render, the active tab id, and the selection callback.
* @returns The tab bar element.
*/
export function PageTabs({ tabs, activeId, onSelect, ariaLabel }: PageTabsProps) {
return (
export function PageTabs({ tabs, activeId, onSelect, ariaLabel, actions }: PageTabsProps) {
const list = (
<div
role="tablist"
aria-label={ariaLabel}
className="flex items-center gap-1.5 overflow-x-auto no-scrollbar border-b border-border/60 pb-2"
className={`flex items-center gap-1.5 overflow-x-auto no-scrollbar ${
actions ? "min-w-0 flex-1" : "border-b border-border/60 pb-2"
}`}
>
{tabs.map(({ id, label, Icon }) => {
const isActive = id === activeId;
@@ -54,4 +59,11 @@ export function PageTabs({ tabs, activeId, onSelect, ariaLabel }: PageTabsProps)
})}
</div>
);
if (!actions) return list;
return (
<div className="flex items-center gap-3 border-b border-border/60 pb-2">
{list}
<div className="flex flex-shrink-0 items-center gap-1.5">{actions}</div>
</div>
);
}
@@ -0,0 +1,75 @@
/**
* @file MarkdownPreview.test.tsx
* @description The markdown preview: GFM renders (headings, tables, task
* lists), raw HTML stays text, links open in a new tab, frontmatter is a
* code block; and the config FileViewer offers Raw / Preview only for a
* markdown file.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import { MarkdownPreview, isMarkdown } from "../MarkdownPreview";
import { FileViewer } from "../config/FileViewer";
describe("MarkdownPreview", () => {
it("renders GFM: headings, tables and task lists", () => {
render(
<MarkdownPreview
text={"# Title\n\n| a | b |\n|---|---|\n| 1 | 2 |\n\n- [x] done\n- [ ] todo\n"}
/>
);
expect(screen.getByRole("heading", { level: 1, name: "Title" })).toBeInTheDocument();
expect(screen.getByRole("table")).toBeInTheDocument();
expect(screen.getAllByRole("checkbox")).toHaveLength(2);
});
it("never runs raw HTML and opens links in a new tab", () => {
const { container } = render(
<MarkdownPreview
text={'<script>alert(1)</script><img src=x onerror="alert(1)">\n\n[x](https://a.example)'}
/>
);
expect(container.querySelector("script")).toBeNull();
expect(container.querySelector("img")).toBeNull();
const link = screen.getByRole("link", { name: "x" });
expect(link).toHaveAttribute("target", "_blank");
expect(link).toHaveAttribute("rel", "noreferrer noopener");
});
it("shows frontmatter as a block, not as a heading", () => {
const { container } = render(
<MarkdownPreview text={"---\nname: demo\ndescription: d\n---\n\nBody text\n"} />
);
expect(container.querySelector(".md-frontmatter")).toHaveTextContent("name: demo");
expect(screen.queryByRole("heading")).toBeNull();
expect(screen.getByText("Body text")).toBeInTheDocument();
});
it("knows a markdown path", () => {
expect(isMarkdown("/a/SKILL.md")).toBe(true);
expect(isMarkdown("README.markdown")).toBe(true);
expect(isMarkdown("config.toml")).toBe(false);
expect(isMarkdown(null)).toBe(false);
});
});
describe("FileViewer — markdown preview", () => {
const data = { text: "# Hello\n", truncated: false } as never;
it("switches a markdown file between raw and preview", async () => {
render(<FileViewer state={{ path: "/x/CLAUDE.md", data, error: null }} onClose={vi.fn()} />);
expect(screen.queryByTestId("markdown-preview")).toBeNull();
expect(screen.getByText("# Hello")).toBeInTheDocument();
await userEvent.click(screen.getByRole("button", { name: "Preview" }));
expect(screen.getByRole("heading", { name: "Hello" })).toBeInTheDocument();
await userEvent.click(screen.getByRole("button", { name: "Raw" }));
expect(screen.queryByTestId("markdown-preview")).toBeNull();
});
it("offers no preview for another file type", () => {
render(<FileViewer state={{ path: "/x/config.toml", data, error: null }} onClose={vi.fn()} />);
expect(screen.queryByRole("button", { name: "Preview" })).toBeNull();
});
});
+25 -3
View File
@@ -3,15 +3,17 @@
* @description Read-only file-content modal for the config explorer. Takes
* already-fetched data as props (path/data/error) rather than fetching
* itself, so it stays tool-agnostic — the orchestrator (Claude or Codex
* config page) owns the fetch.
* config page) owns the fetch. A markdown file gets a Raw / Preview switch
* (`MarkdownPreview`); Raw is the default.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useEffect } from "react";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { FileText, X, AlertCircle } from "lucide-react";
import { FileText, X, AlertCircle, Eye, Code2 } from "lucide-react";
import type { CcFileResponse } from "../../lib/api";
import { CopyButton } from "./configPrimitives";
import { MarkdownPreview, isMarkdown } from "../MarkdownPreview";
export function FileViewer({
state,
@@ -21,6 +23,8 @@ export function FileViewer({
onClose: () => void;
}) {
const { t } = useTranslation("ccConfig");
const [preview, setPreview] = useState(false);
const canPreview = isMarkdown(state.path) && !!state.data;
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
@@ -42,6 +46,17 @@ export function FileViewer({
<span className="font-mono text-[12px] text-fg-secondary truncate flex-1">
{state.path}
</span>
{canPreview && (
<button
type="button"
aria-pressed={preview}
onClick={() => setPreview((v) => !v)}
className="inline-flex items-center gap-1 rounded-md border border-border px-2 py-0.5 text-[11px] text-fg-secondary hover:bg-surface-3 hover:text-fg-primary"
>
{preview ? <Code2 className="h-3 w-3" /> : <Eye className="h-3 w-3" />}
{preview ? t("common.raw") : t("common.preview")}
</button>
)}
<CopyButton value={state.path} />
<button
onClick={onClose}
@@ -59,6 +74,13 @@ export function FileViewer({
</div>
) : !state.data ? (
<div className="text-sm text-fg-muted">…</div>
) : preview && canPreview ? (
<>
<MarkdownPreview text={state.data.text} />
{state.data.truncated && (
<p className="text-[11px] text-fg-muted italic">{t("common.truncated")}</p>
)}
</>
) : (
<pre className="text-[11px] font-mono text-fg-secondary whitespace-pre-wrap break-words">
{state.data.text}
+56 -32
View File
@@ -19,7 +19,7 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { FolderOpen } from "lucide-react";
import { ChevronDown, FolderOpen } from "lucide-react";
import { ConfirmModal } from "../ConfirmModal";
import { CwdAutocomplete } from "../run/RunSetup";
import { FolderBrowseModal } from "./FolderBrowseModal";
@@ -27,6 +27,7 @@ import { api } from "../../lib/api";
import type { AiRouter, CwdSuggestion } from "../../lib/api";
import type { Lane, LaneWorktree } from "../../lib/types";
import { useAiTools } from "../../hooks/useAiTools";
import { AiToolIcon } from "../AiToolIcon";
/** Polls the lane until the background `git worktree add` finishes (status
* leaves "provisioning"), or gives up after `timeoutMs`. Returns the final
@@ -115,7 +116,17 @@ export function AddLaneModal({
const aiTools = useAiTools();
const defaultTool = aiTools.defaultId ?? "claude";
const tool = pickedTool ?? defaultTool;
const otherTools = aiTools.tools.filter((x) => x.installed && x.enabled && x.id !== defaultTool);
// Installed + enabled, the default first.
const toolChoices = aiTools.tools
.filter((x) => x.installed && x.enabled)
.sort((a, b) => Number(b.id === defaultTool) - Number(a.id === defaultTool));
const [toolListOpen, setToolListOpen] = useState(false);
const pickTool = (id: string) => {
setPickedTool(id === defaultTool ? null : id);
// A tool with no endpoint to rewrite runs native only (see router-apply.js).
if (!aiTools.byId(id)?.routerCapable) setRouter("native");
setToolListOpen(false);
};
const noTools = aiTools.loaded && aiTools.defaultId === null;
const toolRouterCapable =
aiTools.byId(tool)?.routerCapable ?? (tool === "claude" || tool === "codex");
@@ -431,38 +442,51 @@ export function AddLaneModal({
</a>
</p>
) : (
<div className="flex items-center gap-1.5">
<div className="flex items-center rounded-md border border-border bg-surface-2 p-0.5">
<Seg
active={tool === defaultTool}
label={t("addLaneToolDefault", { tool: aiTools.label(defaultTool) })}
onClick={() => setPickedTool(null)}
/>
</div>
{otherTools.length > 0 && (
<select
aria-label={t("addLaneToolOther")}
value={tool === defaultTool ? "" : tool}
onChange={(e) => {
const next = e.target.value || null;
setPickedTool(next);
// A tool with no endpoint to rewrite runs native only
// (see router-apply.js).
if (next && !aiTools.byId(next)?.routerCapable) setRouter("native");
}}
className={`rounded-md border bg-surface-2 px-2 py-1 text-xs ${
tool !== defaultTool
? "border-accent text-accent"
: "border-border text-fg-secondary"
}`}
<div>
<button
type="button"
aria-expanded={toolListOpen}
disabled={toolChoices.length < 2}
onClick={() => setToolListOpen((o) => !o)}
className="flex w-full items-center gap-2 rounded-md border border-border bg-surface-2 px-2.5 py-1.5 text-left text-sm text-fg-primary hover:bg-surface-3 disabled:cursor-default disabled:hover:bg-surface-2"
>
<AiToolIcon tool={{ id: tool, label: aiTools.label(tool) }} className="h-5 w-5" />
<span className="min-w-0 flex-1 truncate">{aiTools.label(tool)}</span>
{tool === defaultTool && (
<span className="rounded-full bg-accent/15 px-2 py-0.5 text-[10px] font-medium text-accent">
{t("addLaneToolDefaultBadge")}
</span>
)}
{toolChoices.length > 1 && (
<ChevronDown
aria-hidden
className={`h-4 w-4 text-fg-muted transition-transform ${toolListOpen ? "rotate-180" : ""}`}
/>
)}
</button>
{toolListOpen && (
<div
role="group"
aria-label={t("addLaneToolHint")}
className="mt-1.5 grid grid-cols-2 gap-1.5 sm:grid-cols-3"
>
<option value="">{t("addLaneToolOther")}</option>
{otherTools.map((x) => (
<option key={x.id} value={x.id}>
{x.label}
</option>
{toolChoices.map((x) => (
<button
key={x.id}
type="button"
aria-pressed={x.id === tool}
onClick={() => pickTool(x.id)}
className={`flex items-center gap-2 rounded-md border px-2 py-1.5 text-left text-xs transition-colors ${
x.id === tool
? "border-accent bg-accent/10 text-accent"
: "border-border bg-surface-2 text-fg-secondary hover:bg-surface-3 hover:text-fg-primary"
}`}
>
<AiToolIcon tool={x} className="h-5 w-5" />
<span className="min-w-0 flex-1 truncate">{x.label}</span>
</button>
))}
</select>
</div>
)}
</div>
)}
@@ -5,7 +5,8 @@
* overview — every changed file with its +/- counts and its stage/discard
* actions; click one to open it
* review — every file's full diff, stacked, for reading a whole change-set
* file — one file, as a diff or as its whole content (issue #35)
* file — one file, as a diff or as its whole content (issue #35); a
* whole markdown file can also be read rendered (Preview)
* history — recent commits; opening one shows that commit's diff
* browse — quick-open by path, or the repo's tracked file tree
*
@@ -38,7 +39,9 @@ import {
ChevronsDownUp,
ChevronsUpDown,
Check,
Code2,
Columns2,
Eye,
FileText,
FoldVertical,
Folder,
@@ -53,6 +56,7 @@ import {
import { api } from "../../lib/api";
import { LaneDiffView, LaneFileView, type DiffLayout, type LaneDiffChange } from "./LaneDiffView";
import { LaneConflictView } from "./LaneConflictView";
import { MarkdownPreview, isMarkdown } from "../MarkdownPreview";
import { LaneGitHeader, useLaneGitFacts } from "./LaneGitHeader";
import type {
LaneChangeFile,
@@ -435,6 +439,9 @@ export function LaneChangesPanel({
const [context, setContext] = useState(CONTEXT_STEPS[0]);
const [layout, setLayout] = useState<DiffLayout>("unified");
const [whole, setWhole] = useState(false);
// A whole markdown file shown rendered instead of as source; per file.
const [mdPreview, setMdPreview] = useState(false);
useEffect(() => setMdPreview(false), [openFile]);
const [data, setData] = useState<LaneChanges | null>(null);
const [commits, setCommits] = useState<LaneCommit[] | null>(null);
// A folder's children, keyed by its path ("" is the root) — fetched once,
@@ -1429,6 +1436,18 @@ export function LaneChangesPanel({
binary={current.binary}
/>
)}
{whole && text && draft === null && isMarkdown(openFile) && (
<button
type="button"
data-testid="lane-file-preview"
aria-pressed={mdPreview}
onClick={() => setMdPreview((v) => !v)}
className="flex shrink-0 items-center gap-1 self-center rounded border border-border-light px-1.5 py-0.5 text-[11px] text-fg-secondary hover:bg-surface-2 hover:text-fg-primary"
>
{mdPreview ? <Code2 className="h-3 w-3" /> : <Eye className="h-3 w-3" />}
{mdPreview ? t("changes.raw") : t("changes.preview")}
</button>
)}
{/* Only the working file is editable: a commit is history. */}
{/* The whole file enters the editor by a click on its text;
only the diff, which has no whole text to click, keeps
@@ -1442,7 +1461,8 @@ export function LaneChangesPanel({
{t(noEditReason(text))}
</span>
) : draft === null && whole ? (
text && (
text &&
!mdPreview && (
<span className="shrink-0 self-center text-[10px] text-fg-muted">
{t("changes.clickToEdit")}
</span>
@@ -1503,7 +1523,11 @@ export function LaneChangesPanel({
className="min-h-0 flex-1 resize-none rounded border border-border-light bg-surface-0 p-2 font-mono text-[11px] leading-[1.45] text-fg-primary focus:border-accent focus:outline-none"
/>
) : whole ? (
text ? (
text && mdPreview ? (
<div className="min-h-0 flex-1 overflow-auto rounded border border-border-light bg-surface-0 p-4">
<MarkdownPreview text={text.lines.join("\n")} />
</div>
) : text ? (
<LaneFileView
path={text.path}
lines={text.lines}
@@ -10,7 +10,7 @@
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import { render, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { AddLaneModal } from "../AddLaneModal";
import { api } from "../../../lib/api";
@@ -32,7 +32,7 @@ vi.mock("../../../lib/api", () => ({
routerApply: vi.fn(),
},
aiRouters: { list: vi.fn() },
aiTools: { list: vi.fn() },
aiTools: { list: vi.fn(), iconUrl: (id: string) => `/api/ai-tools/${id}/icon` },
run: { start: vi.fn() },
},
}));
@@ -784,14 +784,20 @@ describe("AddLaneModal — tool selection", () => {
const dirField = screen.getByLabelText("Directory");
await focusField(user, dirField);
await user.type(dirField, "/tmp/demo");
const other = await screen.findByRole("combobox", { name: "Other…" });
// Installed + enabled only, and never the default itself.
expect(Array.from((other as HTMLSelectElement).options).map((o) => o.value)).toEqual([
"",
"codex",
"gemini",
]);
await user.selectOptions(other, "codex");
// One button showing the default; it opens every installed + enabled tool.
const picker = await screen.findByRole("button", { name: /Claude Code.*Default/ });
expect(picker).toHaveAttribute("aria-expanded", "false");
await user.click(picker);
const list = screen.getByRole("group", { name: /Which CLI drives this lane/ });
expect(
within(list)
.getAllByRole("button")
.map((b) => b.textContent)
).toEqual(["Claude Code", "Codex", "Gemini"]);
await user.click(within(list).getByRole("button", { name: "Codex" }));
// The picked tool replaces the default on the button, and the list closes.
expect(screen.getByRole("button", { name: "Codex", expanded: false })).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Gemini" })).toBeNull();
await user.click(screen.getByRole("button", { name: /add lane|create/i }));
await waitFor(() =>
@@ -802,7 +808,8 @@ describe("AddLaneModal — tool selection", () => {
it("offers only Native for a tool that cannot use an AI Router", async () => {
renderModal({});
const user = userEvent.setup();
await user.selectOptions(await screen.findByRole("combobox", { name: "Other…" }), "gemini");
await user.click(await screen.findByRole("button", { name: /Claude Code.*Default/ }));
await user.click(screen.getByRole("button", { name: "Gemini" }));
expect(screen.getByRole("radio", { name: "Native (Gemini)" })).toBeChecked();
expect(screen.getByRole("radio", { name: "AI Router" })).toBeDisabled();
});
@@ -321,6 +321,34 @@ describe("LaneChangesPanel", () => {
expect(screen.getByText("Staged Changes")).toBeInTheDocument();
});
it("reads a whole markdown file rendered after Preview, and back as source after Raw", async () => {
vi.mocked(api.lanes.gitTree).mockResolvedValue({
available: true,
path: "",
entries: [{ name: "README.md", path: "README.md", dir: false, status: " M" }],
truncated: false,
});
vi.mocked(api.lanes.gitFile).mockResolvedValue({
available: true,
path: "README.md",
rev: "work",
binary: false,
truncated: false,
lines: ["# Hello", "", "- one"],
sha: "c".repeat(40),
});
const user = userEvent.setup();
render(<LaneChangesPanel laneId={7} full />);
const sidebar = await screen.findByTestId("lane-changes-sidebar");
await user.click(await within(sidebar).findByText("README.md"));
await screen.findByTestId("lane-file-view");
await user.click(screen.getByTestId("lane-file-preview"));
expect(screen.getByRole("heading", { name: "Hello" })).toBeInTheDocument();
expect(screen.queryByTestId("lane-file-view")).toBeNull();
await user.click(screen.getByTestId("lane-file-preview"));
expect(await screen.findByTestId("lane-file-view")).toHaveTextContent("# Hello");
});
it("at full width shows the tree as a sidebar and opens a file for reading, editing on a click", async () => {
vi.mocked(api.lanes.gitTree).mockResolvedValue({
available: true,
@@ -0,0 +1,67 @@
/**
* @file AiToolsModal.tsx
* @description The AI Tools dialog opened from the Settings tab bar's right
* edge: a header with the title, description and a close button around
* `AiToolsSettings`. Escape, the backdrop and the X close it; an Escape a
* nested ConfirmModal already handled (defaultPrevented) is left to it.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useEffect } from "react";
import { useTranslation } from "react-i18next";
import { Bot, X } from "lucide-react";
import { AiToolsSettings } from "./AiToolsSettings";
/**
* @param props.onClose - Called on Escape, a backdrop click or the X.
* @returns The modal.
*/
export function AiToolsModal({ onClose }: { onClose: () => void }) {
const { t } = useTranslation("settings");
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape" && !e.defaultPrevented) onClose();
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [onClose]);
return (
<div
className="fixed inset-0 z-40 flex items-center justify-center bg-black/60 p-4"
role="presentation"
onClick={onClose}
>
<div
role="dialog"
aria-modal="true"
aria-labelledby="ai-tools-title"
onClick={(e) => e.stopPropagation()}
className="max-h-[90vh] w-full max-w-4xl overflow-y-auto rounded-xl border border-border bg-surface-1 p-5 shadow-xl"
>
<div className="mb-4 flex items-start justify-between gap-3">
<div>
<h2
id="ai-tools-title"
className="flex items-center gap-2 text-sm font-semibold text-fg-primary"
>
<Bot className="h-4 w-4 text-fg-muted" />
{t("aiTools.title")}
</h2>
<p className="mt-1 text-xs text-fg-muted">{t("aiTools.description")}</p>
</div>
<button
type="button"
onClick={onClose}
aria-label={t("common:close")}
className="rounded-md p-1 text-fg-muted hover:bg-surface-3 hover:text-fg-primary"
>
<X className="h-4 w-4" />
</button>
</div>
<AiToolsSettings />
</div>
</div>
);
}
+200 -197
View File
@@ -1,44 +1,32 @@
/**
* @file AiToolsSettings.tsx
* @description Settings → AI Tools: every AI coding CLI in the server's catalog
* (GET /api/ai-tools), split into the ones found on PATH — each with
* Enabled/Disabled, Set default and, when the catalog names one, Update with a
* new-version badge — and the ones available to install, with Install where
* the catalog knows how. Install/Update/Update tools confirm the exact command
* @description The body of the AI Tools modal (Settings tab bar → AI Tools,
* `AiToolsModal.tsx`): every AI coding CLI in the server's catalog
* (GET /api/ai-tools). Installed ones are rows with an enable switch, a
* Default badge or Set default, and Update whenever the catalog names an
* updater — no version lookup, the updater itself knows whether it has work.
* The ones available to install fold behind a disclosure, with Install where
* the catalog knows how. Install/Update/Update all confirm the exact command
* first, run as ONE server job at a time and show its log under the row
* (polled from GET /api/ai-tools/job). Refresh re-reads the login shell's PATH
* and re-detects. Icons are the server's cached favicons, a letter otherwise.
* A disabled tool is hidden from Add lane; existing lanes keep working.
* (polled from GET /api/ai-tools/job; a finished job folds to one line).
* Refresh re-reads the login shell's PATH and re-detects. Icons come from
* `AiToolIcon`. A disabled tool is hidden from Add lane; existing lanes keep
* working.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { Bot, Download, ExternalLink, RefreshCw } from "lucide-react";
import { ChevronDown, Download, ExternalLink, RefreshCw } from "lucide-react";
import { useAiTools } from "../../hooks/useAiTools";
import { ConfirmModal } from "../ConfirmModal";
import { api, type AiTool, type AiToolJob, type AiToolVersion } from "../../lib/api";
import { AiToolIcon } from "../AiToolIcon";
import { api, type AiTool, type AiToolJob } from "../../lib/api";
type Confirm = { kind: "install" | "update"; tool: AiTool } | { kind: "update-all" };
function Avatar({ tool }: { tool: AiTool }) {
const [failed, setFailed] = useState(false);
if (!failed) {
return (
<img
src={api.aiTools.iconUrl(tool.id)}
alt=""
onError={() => setFailed(true)}
className="h-7 w-7 shrink-0 rounded-md bg-surface-4 object-contain p-1"
/>
);
}
return (
<span className="flex h-7 w-7 shrink-0 items-center justify-center rounded-md bg-surface-4 text-xs font-semibold text-fg-secondary">
{tool.label.charAt(0).toUpperCase()}
</span>
);
}
const GHOST_BTN =
"inline-flex items-center gap-1.5 rounded-md border border-border px-2 py-1 text-xs text-fg-secondary hover:bg-surface-3 hover:text-fg-primary disabled:opacity-50";
function Homepage({ tool }: { tool: AiTool }) {
const { t } = useTranslation("settings");
@@ -57,12 +45,51 @@ function Homepage({ tool }: { tool: AiTool }) {
);
}
function Switch({
tool,
disabled,
onToggle,
}: {
tool: AiTool;
disabled: boolean;
onToggle: () => void;
}) {
const { t } = useTranslation("settings");
return (
<button
type="button"
role="switch"
aria-checked={tool.enabled}
aria-label={t("aiTools.enabledOf", { tool: tool.label })}
title={tool.enabled ? t("aiTools.enabled") : t("aiTools.disabled")}
disabled={disabled}
onClick={onToggle}
className={`relative h-5 w-9 shrink-0 rounded-full transition-colors disabled:opacity-50 ${
tool.enabled ? "bg-accent" : "bg-surface-4"
}`}
>
<span
aria-hidden
className={`absolute top-0.5 h-4 w-4 rounded-full bg-white shadow transition-all ${
tool.enabled ? "left-[18px]" : "left-0.5"
}`}
/>
<span className="sr-only">{tool.enabled ? t("aiTools.enabled") : t("aiTools.disabled")}</span>
</button>
);
}
function JobLog({ job }: { job: AiToolJob }) {
const { t } = useTranslation("settings");
const ref = useRef<HTMLPreElement | null>(null);
// A finished job folds to its status line; running or failed stays open.
const [open, setOpen] = useState(job.status !== "ok");
useEffect(() => {
if (job.status === "ok") setOpen(false);
}, [job.status]);
useEffect(() => {
if (ref.current) ref.current.scrollTop = ref.current.scrollHeight;
}, [job.log]);
}, [job.log, open]);
const state =
job.status === "running"
? t("aiTools.jobRunning")
@@ -71,18 +98,29 @@ function JobLog({ job }: { job: AiToolJob }) {
: t("aiTools.jobFailed", { code: job.exitCode ?? "?" });
return (
<div className="mt-2 w-full">
<p
className={`text-[11px] ${job.status === "failed" ? "text-status-danger" : "text-fg-muted"}`}
>
{state}
</p>
<pre
ref={ref}
data-testid="ai-tool-log"
className="mt-1 max-h-48 overflow-auto whitespace-pre-wrap rounded-md border border-border bg-surface-1 p-2 font-mono text-[11px] text-fg-secondary"
>
{job.log}
</pre>
<div className="flex items-center gap-2 text-[11px]">
<span className={job.status === "failed" ? "text-status-danger" : "text-fg-muted"}>
{state}
</span>
{job.status !== "running" && (
<button
type="button"
onClick={() => setOpen((o) => !o)}
className="text-fg-muted underline-offset-2 hover:text-fg-primary hover:underline"
>
{open ? t("aiTools.hideLog") : t("aiTools.showLog")}
</button>
)}
</div>
{open && (
<pre
ref={ref}
data-testid="ai-tool-log"
className="mt-1 max-h-48 overflow-auto whitespace-pre-wrap rounded-md border border-border bg-surface-0 p-2 font-mono text-[11px] text-fg-secondary"
>
{job.log}
</pre>
)}
</div>
);
}
@@ -93,31 +131,16 @@ export function AiToolsSettings() {
const [busy, setBusy] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [job, setJob] = useState<AiToolJob | null>(null);
const [versions, setVersions] = useState<Record<string, AiToolVersion>>({});
const [confirm, setConfirm] = useState<Confirm | null>(null);
const sectionRef = useRef<HTMLElement | null>(null);
const [showAvailable, setShowAvailable] = useState(false);
// The Add-lane modal links here as /settings#ai-tools; the section renders
// after the browser's own hash jump, so it has to scroll itself into view.
useEffect(() => {
if (window.location.hash === "#ai-tools") sectionRef.current?.scrollIntoView?.();
}, []);
// Deferred like useAiTools' load: an unreachable server (or a mocked api
// without these calls) degrades to "no job, no badges", never a throw.
const loadVersions = () =>
Promise.resolve()
.then(() => api.aiTools.versions())
.then(setVersions)
.catch(() => undefined);
// A page opened mid-job picks it up; versions are cached a day server-side.
// A modal opened mid-job picks it up. Deferred like useAiTools' load: an
// unreachable server (or a mocked api without it) means "no job".
useEffect(() => {
Promise.resolve()
.then(() => api.aiTools.job())
.then(setJob)
.catch(() => undefined);
void loadVersions();
}, []);
const running = job?.status === "running";
@@ -127,10 +150,7 @@ export function AiToolsSettings() {
try {
const j = await api.aiTools.job();
setJob(j);
if (j?.status !== "running") {
void reload();
void loadVersions();
}
if (j?.status !== "running") void reload();
} catch {
/* next tick */
}
@@ -160,14 +180,10 @@ export function AiToolsSettings() {
if (inst?.kind === "script") return t("aiTools.runScript", { url: inst.url });
return "";
};
// Mirrors the server's update-all: badged npm tools, then every self updater.
const updateAllTools = tools.filter(
(x) =>
x.installed &&
(x.update?.kind === "self" || (x.update?.kind === "npm" && versions[x.id]?.updateAvailable))
);
const canUpdate = (tool: AiTool) =>
!!tool.update && (tool.update.kind !== "npm" || versions[tool.id]?.npmManaged === true);
const installed = tools.filter((x) => x.installed);
const available = tools.filter((x) => !x.installed);
// Mirrors the server's update-all: every installed tool with an updater.
const updateAllTools = installed.filter((x) => x.update);
const startConfirmed = async () => {
const c = confirm;
@@ -186,8 +202,6 @@ export function AiToolsSettings() {
const jobBusy = running || busy !== null;
const logFor = (id: string | null) => (job && job.tool === id ? <JobLog job={job} /> : null);
const installed = tools.filter((x) => x.installed);
const available = tools.filter((x) => !x.installed);
const confirmLines =
confirm?.kind === "update-all"
@@ -197,149 +211,139 @@ export function AiToolsSettings() {
: [];
return (
<section id="ai-tools" ref={sectionRef} className="scroll-mt-24">
<h3 className="text-sm font-medium text-fg-secondary flex items-center gap-2 mb-1">
<Bot className="w-4 h-4 text-fg-muted" />
{t("aiTools.title")}
</h3>
<p className="text-xs text-fg-muted mb-4">{t("aiTools.description")}</p>
<div className="card p-5 space-y-5">
<div>
<div className="flex items-center justify-between gap-3 mb-2">
<p className="text-xs font-medium text-fg-secondary">
{t("aiTools.installed", { count: installed.length })}
</p>
<div className="flex items-center gap-2">
<div className="space-y-5">
<div>
<div className="mb-2 flex items-center justify-between gap-3">
<p className="text-xs font-medium text-fg-secondary">
{t("aiTools.installed", { count: installed.length })}
</p>
<div className="flex items-center gap-2">
{updateAllTools.length > 0 && (
<button
type="button"
onClick={() => setConfirm({ kind: "update-all" })}
disabled={jobBusy || updateAllTools.length === 0}
className="inline-flex items-center gap-1.5 rounded-md border border-border px-2 py-1 text-xs text-fg-secondary hover:bg-surface-3 disabled:opacity-50"
disabled={jobBusy}
className={GHOST_BTN}
>
<Download className="w-3.5 h-3.5" />
{t("aiTools.updateTools")}
{t("aiTools.updateAll", { count: updateAllTools.length })}
</button>
<button
type="button"
onClick={() =>
void run("refresh", async () => {
await refresh();
await loadVersions();
})
}
disabled={busy !== null}
className="inline-flex items-center gap-1.5 rounded-md border border-border px-2 py-1 text-xs text-fg-secondary hover:bg-surface-3 disabled:opacity-50"
>
<RefreshCw className={`w-3.5 h-3.5 ${busy === "refresh" ? "animate-spin" : ""}`} />
{t("aiTools.refresh")}
</button>
</div>
)}
<button
type="button"
onClick={() => void run("refresh", refresh)}
disabled={busy !== null}
className={GHOST_BTN}
>
<RefreshCw className={`w-3.5 h-3.5 ${busy === "refresh" ? "animate-spin" : ""}`} />
{t("aiTools.refresh")}
</button>
</div>
{error && <p className="mb-2 text-xs text-status-danger">{error}</p>}
{logFor(null)}
{loaded && installed.length === 0 && (
<p className="text-xs text-fg-muted">{t("aiTools.noneInstalled")}</p>
)}
<ul className="divide-y divide-border rounded-md border border-border">
{installed.map((tool) => {
const isDefault = tool.id === defaultId;
const v = versions[tool.id];
return (
<li
key={tool.id}
data-testid={`ai-tool-${tool.id}`}
className={`flex flex-wrap items-center gap-3 px-3 py-2 ${tool.enabled ? "" : "opacity-60"}`}
>
<Avatar tool={tool} />
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5 text-sm text-fg-primary">
{tool.label}
<Homepage tool={tool} />
{v?.updateAvailable && v.latest && (
<span className="rounded bg-accent/10 px-1.5 py-0.5 text-[10px] text-accent">
{t("aiTools.newVersion", { version: v.latest })}
</span>
)}
</div>
<div
className="truncate font-mono text-[11px] text-fg-muted"
title={tool.path ?? ""}
>
{tool.command}
{v?.installed ? ` · v${v.installed}` : ""}
{tool.update?.kind === "npm" && v && !v.npmManaged && (
<span className="ml-1.5 font-sans">· {t("aiTools.outsideNpm")}</span>
)}
</div>
{error && <p className="mb-2 text-xs text-status-danger">{error}</p>}
{logFor(null)}
{loaded && installed.length === 0 && (
<p className="text-xs text-fg-muted">{t("aiTools.noneInstalled")}</p>
)}
<ul className="divide-y divide-border overflow-hidden rounded-lg border border-border bg-surface-2">
{installed.map((tool) => {
const isDefault = tool.id === defaultId;
return (
<li key={tool.id} data-testid={`ai-tool-${tool.id}`} className="px-3 py-2.5">
<div className="flex items-center gap-3">
<div
className={`flex min-w-0 flex-1 items-center gap-3 ${tool.enabled ? "" : "opacity-50"}`}
>
<AiToolIcon tool={tool} />
<div className="min-w-0">
<div className="flex items-center gap-1.5 text-sm font-medium text-fg-primary">
<span className="truncate">{tool.label}</span>
<Homepage tool={tool} />
{isDefault && (
<span className="rounded-full bg-accent/15 px-2 py-0.5 text-[10px] font-medium text-accent">
{t("aiTools.default")}
</span>
)}
</div>
<div
className="truncate font-mono text-[11px] text-fg-muted"
title={tool.path ?? ""}
>
{tool.command}
</div>
</div>
</div>
{canUpdate(tool) && (
<button
type="button"
disabled={jobBusy}
onClick={() => setConfirm({ kind: "update", tool })}
className="rounded-md border border-border px-2 py-1 text-xs text-fg-secondary hover:bg-surface-3 disabled:opacity-50"
>
{t("aiTools.update")}
</button>
)}
{isDefault ? (
<span className="text-xs text-accent">✓ {t("aiTools.default")}</span>
) : (
<button
type="button"
disabled={!tool.enabled || busy !== null}
onClick={() => void run(tool.id, () => update(tool.id, { default: true }))}
className="rounded-md border border-border px-2 py-1 text-xs text-fg-secondary hover:bg-surface-3 disabled:opacity-50"
>
{t("aiTools.setDefault")}
</button>
)}
<button
type="button"
role="switch"
aria-checked={tool.enabled}
aria-label={t("aiTools.enabledOf", { tool: tool.label })}
{/* Fixed slots, so the buttons line up from row to row. */}
<div className="flex min-w-20 shrink-0 justify-end">
{tool.update && (
<button
type="button"
disabled={jobBusy}
onClick={() => setConfirm({ kind: "update", tool })}
className={GHOST_BTN}
>
{t("aiTools.update")}
</button>
)}
</div>
<div className="flex min-w-28 shrink-0 justify-end">
{!isDefault && (
<button
type="button"
disabled={!tool.enabled || busy !== null}
onClick={() => void run(tool.id, () => update(tool.id, { default: true }))}
className={GHOST_BTN}
>
{t("aiTools.setDefault")}
</button>
)}
</div>
<Switch
tool={tool}
disabled={busy !== null}
onClick={() =>
onToggle={() =>
void run(tool.id, () => update(tool.id, { enabled: !tool.enabled }))
}
className={`w-24 rounded-md border px-2 py-1 text-xs disabled:opacity-50 ${
tool.enabled
? "border-status-success/40 bg-status-success/10 text-status-success"
: "border-border bg-surface-2 text-fg-muted"
}`}
>
{tool.enabled ? t("aiTools.enabled") : t("aiTools.disabled")}
</button>
{logFor(tool.id)}
</li>
);
})}
</ul>
</div>
/>
</div>
{logFor(tool.id)}
</li>
);
})}
</ul>
</div>
{available.length > 0 && (
<div>
<p className="text-xs font-medium text-fg-secondary mb-2">
{t("aiTools.available", { count: available.length })}
</p>
{available.length > 0 && (
<div>
<button
type="button"
aria-expanded={showAvailable}
onClick={() => setShowAvailable((s) => !s)}
className="mb-2 inline-flex items-center gap-1.5 text-xs font-medium text-fg-secondary hover:text-fg-primary"
>
<ChevronDown
className={`w-3.5 h-3.5 transition-transform ${showAvailable ? "" : "-rotate-90"}`}
/>
{t("aiTools.available", { count: available.length })}
</button>
{showAvailable && (
<ul className="grid grid-cols-1 gap-2 sm:grid-cols-2 lg:grid-cols-3">
{available.map((tool) => (
<li
key={tool.id}
data-testid={`ai-tool-${tool.id}`}
className={`flex flex-wrap items-center gap-2 rounded-md border border-border px-2.5 py-1.5 ${
className={`flex flex-wrap items-center gap-2 rounded-lg border border-border bg-surface-2 px-2.5 py-2 ${
job?.tool === tool.id ? "sm:col-span-2 lg:col-span-3" : ""
}`}
>
<Avatar tool={tool} />
<span className="min-w-0 flex-1 truncate text-xs text-fg-secondary">
{tool.label}
<span className="ml-1.5 font-mono text-[10px] text-fg-muted">
<AiToolIcon tool={tool} />
<span className="min-w-0 flex-1">
<span className="block truncate text-xs text-fg-primary">{tool.label}</span>
<span className="block truncate font-mono text-[10px] text-fg-muted">
{tool.command}
</span>
</span>
<Homepage tool={tool} />
{tool.install && (
<button
type="button"
@@ -350,14 +354,13 @@ export function AiToolsSettings() {
{t("aiTools.install")}
</button>
)}
<Homepage tool={tool} />
{logFor(tool.id)}
</li>
))}
</ul>
</div>
)}
</div>
)}
</div>
)}
<ConfirmModal
open={confirm !== null}
@@ -384,6 +387,6 @@ export function AiToolsSettings() {
{confirmLines.join("\n")}
</pre>
</ConfirmModal>
</section>
</div>
);
}
@@ -1,6 +1,6 @@
/**
* @file AntigravityHooksSettings.tsx
* @description Settings → Antigravity → AgentHub → Antigravity hooks: whether
* @description Settings → Antigravity → Antigravity hooks: whether
* AgentHub's named hook is in `~/.gemini/config/hooks.json` (and not switched
* off there), per event, with Install/Reinstall, Remove and Refresh. Nothing is
* written until the user clicks; agy asks for no trust, and has no permission
@@ -1,11 +1,10 @@
/**
* @file ClaudeAgentHubSettings.tsx
* @description Settings → Claude Code → AgentHub: the dashboard's own settings
* that belong to Claude Code — Model Pricing for Claude models (the shared
* `PricingSettings` table, with Reset Defaults), the AgentHub hooks installed
* into Claude Code with a Reinstall button, and the CLAUDE_HOME the server
* reads transcripts and config from. Moved here from Settings → Dashboard
* (#40 part B).
* @description The dashboard's own settings that belong to Claude Code, shown
* under Settings → Claude Code → Overview: the CLAUDE_HOME the server reads
* transcripts and config from, the AgentHub hooks installed into Claude Code
* with a Reinstall button, and Model Pricing for Claude models (the shared
* `PricingSettings` table, with Reset Defaults).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -125,7 +124,32 @@ export function ClaudeAgentHubSettings({ onHomeChanged }: { onHomeChanged?: () =
return (
<div className="space-y-8">
<PricingSettings vendor="claude" allowReset />
<HomeDirSetting
id="claude-home"
title={t("claudeHome.title")}
description={t("claudeHome.description")}
note={t("cursorPathsNote")}
placeholder={t("claudeHome.placeholder")}
load={() =>
api.settings.claudeHome
.get()
.then((r) => ({ value: r.claude_home, envOwned: !!r.env_owned }))
}
save={(p) =>
api.settings.claudeHome.set(p).then((r) => {
void load();
onHomeChanged?.();
const missing = r.routers?.missing ?? [];
return {
value: r.claude_home,
envOwned: !!r.env_owned,
warning: missing.length
? t("claudeHome.routersMissing", { lanes: missing.join(", ") })
: null,
};
})
}
/>
{error && (
<div className="px-4 py-2.5 bg-status-danger/10 border border-status-danger/20 rounded-lg text-sm text-status-danger">
{error}
@@ -192,32 +216,7 @@ export function ClaudeAgentHubSettings({ onHomeChanged }: { onHomeChanged?: () =
</div>
</section>
<HomeDirSetting
id="claude-home"
title={t("claudeHome.title")}
description={t("claudeHome.description")}
note={t("cursorPathsNote")}
placeholder={t("claudeHome.placeholder")}
load={() =>
api.settings.claudeHome
.get()
.then((r) => ({ value: r.claude_home, envOwned: !!r.env_owned }))
}
save={(p) =>
api.settings.claudeHome.set(p).then((r) => {
void load();
onHomeChanged?.();
const missing = r.routers?.missing ?? [];
return {
value: r.claude_home,
envOwned: !!r.env_owned,
warning: missing.length
? t("claudeHome.routersMissing", { lanes: missing.join(", ") })
: null,
};
})
}
/>
<PricingSettings vendor="claude" allowReset />
</div>
);
}
@@ -1,6 +1,6 @@
/**
* @file CodexHooksSettings.tsx
* @description Settings → Codex → AgentHub → Codex hooks: whether AgentHub's
* @description Settings → Codex → Overview → Codex hooks: whether AgentHub's
* hooks are in `$CODEX_HOME/hooks.json` and trusted by Codex, per event, with
* Install/Reinstall and Remove. Nothing is written until the user clicks;
* Codex itself asks the user to trust the hooks before it runs them, and this
@@ -1,8 +1,9 @@
/**
* @file HomeDirSetting.tsx
* @description One editable home directory (CLAUDE_HOME in Claude Code →
* AgentHub, CODEX_HOME in Codex → AgentHub): a path field with Save, the
* server's refusal inline, the value in effect, and a warning when the real
* Overview, CODEX_HOME in Codex → Overview): a path field with Save, the
* server's refusal inline, the saved value while the field differs from it,
* and a warning when the real
* environment sets the variable (the environment wins at the next start, so a
* saved value would not survive a restart). The server validates and persists;
* this component only loads, submits and reports.
@@ -105,7 +106,8 @@ export function HomeDirSetting({ id, title, description, note, placeholder, load
</button>
</div>
{error && <p className="text-xs text-status-danger">{error}</p>}
{current && (
{/* The saved value, only while the field says something else. */}
{current && input !== current.value && (
<p className="text-xs text-fg-muted">
{t("homeDir.current")} <code className="text-fg-secondary">{current.value}</code>
</p>
@@ -1,7 +1,8 @@
/**
* @file PricingSettings.tsx
* @description Model Pricing — the per-model rates every session's cost is
* computed from — as shown in a tool's Settings → AgentHub sub-tab: Claude
* computed from — as shown on a tool's Settings tab (Overview for Claude Code
* and Codex, the Antigravity tab itself): Claude
* Code's shows `claude-…` rows, Codex's shows OpenAI rows (`gpt-…`, `o<n>…`,
* `codex-…`), Antigravity's Gemini rows (`gemini-…`). It is ONE table (a router model can serve either tool), so a
* **Show all models** switch lists every row, and a row added outside the
@@ -1,9 +1,12 @@
/**
* @file AiToolsSettings.test.tsx
* @description Settings → AI Tools: installed rows carry Enabled/Disabled and
* Set default, available rows a homepage link, Refresh re-detects, a
* disabled tool's Settings tab disappears from the hub, and Install/Update
* confirm the exact command, then show the polled job log.
* @description The AI Tools modal: the Settings tab bar's button and the
* `#ai-tools` deep link open it, Escape closes it; installed rows carry an
* enable switch and Set default, available rows fold behind a disclosure with
* a homepage link, Refresh re-detects, a disabled tool's Settings tab
* disappears from the hub, every updater gets Update without a version
* lookup, and Install/Update confirm the exact command, then show the polled
* job log, folded once it is done.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -29,7 +32,6 @@ vi.mock("../../../lib/api", async (importOriginal) => {
updateTool: vi.fn(),
updateAll: vi.fn(),
job: vi.fn(),
versions: vi.fn(),
iconUrl: (id: string) => `/api/ai-tools/${id}/icon`,
},
},
@@ -71,7 +73,6 @@ beforeEach(() => {
vi.mocked(api.aiTools.update).mockReset().mockResolvedValue(VIEW);
vi.mocked(api.aiTools.refresh).mockReset().mockResolvedValue(VIEW);
vi.mocked(api.aiTools.job).mockReset().mockResolvedValue(null);
vi.mocked(api.aiTools.versions).mockReset().mockResolvedValue({});
vi.mocked(api.aiTools.install).mockReset();
vi.mocked(api.aiTools.updateTool).mockReset();
vi.mocked(api.aiTools.updateAll).mockReset();
@@ -93,7 +94,7 @@ describe("AiToolsSettings", () => {
it("splits installed tools from the ones available to install", async () => {
render(<AiToolsSettings />);
expect(await screen.findByText("Installed (3 detected)")).toBeInTheDocument();
expect(screen.getByText("Available to install (1)")).toBeInTheDocument();
await userEvent.click(screen.getByRole("button", { name: "Available to install (1)" }));
const claude = screen.getByTestId("ai-tool-claude");
expect(within(claude).getByText(/Default/)).toBeInTheDocument();
const aider = screen.getByTestId("ai-tool-aider");
@@ -120,10 +121,8 @@ describe("AiToolsSettings", () => {
const user = userEvent.setup();
render(<AiToolsSettings />);
await screen.findByTestId("ai-tool-claude");
const before = vi.mocked(api.aiTools.versions).mock.calls.length;
await user.click(screen.getByRole("button", { name: "Refresh" }));
expect(api.aiTools.refresh).toHaveBeenCalledOnce();
expect(vi.mocked(api.aiTools.versions).mock.calls.length).toBe(before + 1);
});
});
@@ -154,11 +153,9 @@ describe("AiToolsSettings — install and update", () => {
};
beforeEach(() => {
vi.mocked(api.aiTools.list).mockResolvedValue(A2);
vi.mocked(api.aiTools.versions).mockResolvedValue({
codex: { npmManaged: false, installed: null, latest: null, updateAvailable: false },
gemini: { npmManaged: true, installed: "0.60.0", latest: "0.61.0", updateAvailable: true },
});
});
const openAvailable = async () =>
userEvent.click(await screen.findByRole("button", { name: /Available to install/ }));
it("confirms the exact npm command, then polls and shows the log", async () => {
vi.useFakeTimers({ shouldAdvanceTime: true });
@@ -169,6 +166,7 @@ describe("AiToolsSettings — install and update", () => {
.mockResolvedValueOnce(null)
.mockResolvedValue(job({ status: "ok", exitCode: 0, log: "added 1 package\n" }));
render(<AiToolsSettings />);
await user.click(await screen.findByRole("button", { name: /Available to install/ }));
const qwen = await screen.findByTestId("ai-tool-qwen");
await user.click(within(qwen).getByRole("button", { name: "Install" }));
expect(screen.getByTestId("ai-tool-confirm-command")).toHaveTextContent(
@@ -182,6 +180,8 @@ describe("AiToolsSettings — install and update", () => {
vi.advanceTimersByTime(1100);
});
expect(await within(qwen).findByText("Done")).toBeInTheDocument();
expect(within(qwen).queryByTestId("ai-tool-log")).toBeNull(); // folded once done
await user.click(within(qwen).getByRole("button", { name: "Show log" }));
expect(within(qwen).getByTestId("ai-tool-log")).toHaveTextContent("added 1 package");
const calls = vi.mocked(api.aiTools.job).mock.calls.length;
await act(async () => {
@@ -196,6 +196,7 @@ describe("AiToolsSettings — install and update", () => {
it("names the vendor script for a script install", async () => {
const user = userEvent.setup();
render(<AiToolsSettings />);
await openAvailable();
const cursor = await screen.findByTestId("ai-tool-cursor");
await user.click(within(cursor).getByRole("button", { name: "Install" }));
expect(screen.getByTestId("ai-tool-confirm-command")).toHaveTextContent(
@@ -203,35 +204,34 @@ describe("AiToolsSettings — install and update", () => {
);
});
it("badges a newer npm version and offers no npm update outside npm", async () => {
it("offers Update on every installed tool with an updater", async () => {
render(<AiToolsSettings />);
const gemini = await screen.findByTestId("ai-tool-gemini");
expect(await within(gemini).findByText("v0.61.0 available")).toBeInTheDocument();
expect(within(gemini).getByRole("button", { name: "Update" })).toBeInTheDocument();
const codex = screen.getByTestId("ai-tool-codex");
expect(within(codex).getByText(/Installed outside npm/)).toBeInTheDocument();
expect(within(codex).queryByRole("button", { name: "Update" })).toBeNull();
for (const id of ["claude", "codex", "gemini"]) {
const row = await screen.findByTestId(`ai-tool-${id}`);
expect(within(row).getByRole("button", { name: "Update" })).toBeInTheDocument();
}
});
it("Update tools lists the badged npm tools and the self updaters", async () => {
it("Update all lists every installed tool's updater", async () => {
const user = userEvent.setup();
render(<AiToolsSettings />);
await screen.findByText("v0.61.0 available");
await user.click(screen.getByRole("button", { name: "Update tools" }));
await user.click(await screen.findByRole("button", { name: "Update all (3)" }));
const cmd = screen.getByTestId("ai-tool-confirm-command");
expect(cmd).toHaveTextContent("claude update");
expect(cmd).toHaveTextContent("npm install -g @google/gemini-cli@latest");
expect(cmd).not.toHaveTextContent("@openai/codex");
expect(cmd).toHaveTextContent("npm install -g @openai/codex@latest");
});
it("disables Update tools when nothing has an update", async () => {
it("hides Update all when no tool has an updater", async () => {
vi.mocked(api.aiTools.list).mockResolvedValue(VIEW);
render(<AiToolsSettings />);
expect(await screen.findByRole("button", { name: "Update tools" })).toBeDisabled();
await screen.findByTestId("ai-tool-claude");
expect(screen.queryByRole("button", { name: /Update all/ })).toBeNull();
});
it("falls back to the letter when the icon fails", async () => {
const { container } = render(<AiToolsSettings />);
await openAvailable();
await screen.findByTestId("ai-tool-qwen");
const img = container.querySelector('img[src="/api/ai-tools/qwen/icon"]')!;
expect(img).toBeTruthy();
@@ -256,4 +256,42 @@ describe("SettingsHub — AI tool tabs", () => {
expect(await screen.findByRole("tab", { name: /Claude Code/ })).toBeInTheDocument();
expect(screen.queryByRole("tab", { name: /^Codex/ })).toBeNull();
});
it("opens AI Tools as a modal from the tab bar, and closes it on Escape", async () => {
vi.doMock("../../../pages/Settings", () => ({ Settings: () => null }));
const { SettingsHub } = await import("../../../pages/SettingsHub");
render(
<MemoryRouter>
<SettingsHub />
</MemoryRouter>
);
expect(screen.queryByRole("dialog")).toBeNull();
await userEvent.click(await screen.findByRole("button", { name: "AI Tools" }));
const dialog = screen.getByRole("dialog", { name: "AI Tools" });
expect(await within(dialog).findByText("Installed (3 detected)")).toBeInTheDocument();
fireEvent.keyDown(window, { key: "Escape" });
expect(screen.queryByRole("dialog")).toBeNull();
});
it("opens the modal for the /settings#ai-tools deep link", async () => {
vi.doMock("../../../pages/Settings", () => ({ Settings: () => null }));
const { SettingsHub } = await import("../../../pages/SettingsHub");
render(
<MemoryRouter initialEntries={["/settings#ai-tools"]}>
<SettingsHub />
</MemoryRouter>
);
expect(await screen.findByRole("dialog", { name: "AI Tools" })).toBeInTheDocument();
});
});
describe("AiToolIcon", () => {
it("retries the icon when the same instance shows another tool", async () => {
const { AiToolIcon } = await import("../../AiToolIcon");
const { container, rerender } = render(<AiToolIcon tool={{ id: "claude", label: "Claude" }} />);
fireEvent.error(container.querySelector("img")!);
expect(screen.getByText("C")).toBeInTheDocument();
rerender(<AiToolIcon tool={{ id: "codex", label: "Codex" }} />);
expect(container.querySelector('img[src="/api/ai-tools/codex/icon"]')).toBeTruthy();
});
});
@@ -7,7 +7,7 @@
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { render, screen } from "@testing-library/react";
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it, vi } from "vitest";
import i18n from "i18next";
@@ -43,11 +43,15 @@ describe("HomeDirSetting", () => {
expect(button).toBeDisabled();
expect(container.querySelector("section#x-home")).not.toBeNull();
// The saved value shows only while the field says something else.
expect(screen.queryByText("/a", { selector: "code" })).toBeNull();
await user.clear(input);
await user.type(input, "/b");
expect(screen.getByText("/a", { selector: "code" })).toBeInTheDocument();
await user.click(button);
expect(saveFn).toHaveBeenCalledWith("/b");
expect(await screen.findByText("/b", { selector: "code" })).toBeInTheDocument();
await waitFor(() => expect(screen.queryByText("/a", { selector: "code" })).toBeNull());
expect(screen.getByDisplayValue("/b")).toBeInTheDocument();
expect(screen.queryByText(/set by the environment/)).toBeNull();
});
+4 -3
View File
@@ -24,8 +24,7 @@
"hooks": "Hooks",
"keybindings": "Keybindings",
"settings": "Settings",
"memory": "Memory",
"agenthub": "AgentHub"
"memory": "Memory"
},
"overview": {
"rootsTitle": "Roots",
@@ -51,7 +50,9 @@
"modified": "Modified",
"scopeLabel": "Scope",
"noFrontmatter": "No frontmatter",
"redactedNotice": "Values matching /token|secret|password|key|auth/i are redacted server-side."
"redactedNotice": "Values matching /token|secret|password|key|auth/i are redacted server-side.",
"preview": "Preview",
"raw": "Raw"
},
"skills": {
"name": "Name",
+1 -2
View File
@@ -18,8 +18,7 @@
"memory": "Memory",
"config": "Config",
"mcp": "MCP servers",
"plugins": "Plugins",
"agenthub": "AgentHub"
"plugins": "Plugins"
},
"overview": {
"rootsTitle": "Roots",
+3 -2
View File
@@ -53,8 +53,7 @@
"addLaneSetupTitle": "Setup",
"addLaneTitleLabel": "Title",
"addLaneTitlePlaceholder": "Optional",
"addLaneToolDefault": "Default ({{tool}})",
"addLaneToolOther": "Other…",
"addLaneToolDefaultBadge": "Default",
"addLaneNoTools": "No AI tool is installed and enabled.",
"addLaneNoToolsLink": "Open Settings → AI Tools",
"addLaneRouterNativeTool": "Native ({{tool}})",
@@ -258,6 +257,8 @@
"changes.save": "Save",
"changes.cancelEdit": "Cancel",
"changes.clickToEdit": "Click the text to edit",
"changes.preview": "Preview",
"changes.raw": "Raw",
"changes.editorLabel": "Edit {{path}}",
"changes.prevHunk": "Previous change",
"changes.nextHunk": "Next change",
+4 -4
View File
@@ -530,9 +530,7 @@
"homepageOf": "{{tool}} homepage",
"install": "Install",
"update": "Update",
"updateTools": "Update tools",
"newVersion": "v{{version}} available",
"outsideNpm": "Installed outside npm — update it the way you installed it",
"updateAll": "Update all ({{count}})",
"runScript": "Download {{url}} and run it with bash",
"confirmInstall": "Install {{tool}}?",
"confirmUpdate": "Update {{tool}}?",
@@ -542,7 +540,9 @@
"cancel": "Cancel",
"jobRunning": "Running…",
"jobOk": "Done",
"jobFailed": "Failed (exit {{code}})"
"jobFailed": "Failed (exit {{code}})",
"showLog": "Show log",
"hideLog": "Hide log"
},
"appearance": {
"title": "Appearance",
+4 -3
View File
@@ -24,8 +24,7 @@
"hooks": "Hooks",
"keybindings": "Phím tắt",
"settings": "Cài đặt",
"memory": "Bộ nhớ",
"agenthub": "AgentHub"
"memory": "Bộ nhớ"
},
"overview": {
"rootsTitle": "Thư mục gốc",
@@ -51,7 +50,9 @@
"modified": "Đã sửa",
"scopeLabel": "Phạm vi",
"noFrontmatter": "Không có frontmatter",
"redactedNotice": "Các giá trị khớp /token|secret|password|key|auth/i đã được ẩn ở phía máy chủ."
"redactedNotice": "Các giá trị khớp /token|secret|password|key|auth/i đã được ẩn ở phía máy chủ.",
"preview": "Xem trước",
"raw": "Mã nguồn"
},
"skills": {
"name": "Tên",
+1 -2
View File
@@ -18,8 +18,7 @@
"memory": "Bộ nhớ",
"config": "Cấu hình",
"mcp": "Máy chủ MCP",
"plugins": "Plugin",
"agenthub": "AgentHub"
"plugins": "Plugin"
},
"overview": {
"rootsTitle": "Thư mục gốc",
+3 -2
View File
@@ -53,8 +53,7 @@
"addLaneSetupTitle": "Thiết lập",
"addLaneTitleLabel": "Tiêu đề",
"addLaneTitlePlaceholder": "Không bắt buộc",
"addLaneToolDefault": "Mặc định ({{tool}})",
"addLaneToolOther": "Khác…",
"addLaneToolDefaultBadge": "Mặc định",
"addLaneNoTools": "Chưa có AI tool nào được cài và bật.",
"addLaneNoToolsLink": "Mở Cài đặt → AI Tools",
"addLaneRouterNativeTool": "Native ({{tool}})",
@@ -258,6 +257,8 @@
"changes.save": "Lưu",
"changes.cancelEdit": "Huỷ",
"changes.clickToEdit": "Bấm vào nội dung để sửa",
"changes.preview": "Xem trước",
"changes.raw": "Mã nguồn",
"changes.editorLabel": "Sửa {{path}}",
"changes.prevHunk": "Thay đổi trước",
"changes.nextHunk": "Thay đổi sau",
+4 -4
View File
@@ -530,9 +530,7 @@
"homepageOf": "Trang chủ {{tool}}",
"install": "Cài đặt",
"update": "Cập nhật",
"updateTools": "Cập nhật tool",
"newVersion": "Có bản v{{version}}",
"outsideNpm": "Cài ngoài npm — cập nhật theo cách bạn đã cài",
"updateAll": "Cập nhật tất cả ({{count}})",
"runScript": "Tải {{url}} và chạy bằng bash",
"confirmInstall": "Cài {{tool}}?",
"confirmUpdate": "Cập nhật {{tool}}?",
@@ -542,7 +540,9 @@
"cancel": "Huỷ",
"jobRunning": "Đang chạy…",
"jobOk": "Xong",
"jobFailed": "Lỗi (exit {{code}})"
"jobFailed": "Lỗi (exit {{code}})",
"showLog": "Xem log",
"hideLog": "Ẩn log"
},
"appearance": {
"title": "Giao diện",
+139
View File
@@ -441,3 +441,142 @@
.animate-pulse-dot {
animation: pulse-dot 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
}
/* Rendered markdown (MarkdownPreview.tsx): theme tokens only, so every theme
reads it; no typography plugin. */
.md-preview {
color: rgb(var(--fg-secondary));
font-size: 13px;
line-height: 1.6;
overflow-wrap: anywhere;
}
.md-preview > :first-child {
margin-top: 0;
}
.md-preview h1,
.md-preview h2,
.md-preview h3,
.md-preview h4,
.md-preview h5,
.md-preview h6 {
color: rgb(var(--fg-primary));
font-weight: 600;
line-height: 1.3;
margin: 1.4em 0 0.6em;
}
.md-preview h1 {
font-size: 1.6em;
padding-bottom: 0.3em;
border-bottom: 1px solid rgb(var(--border));
}
.md-preview h2 {
font-size: 1.3em;
padding-bottom: 0.25em;
border-bottom: 1px solid rgb(var(--border));
}
.md-preview h3 {
font-size: 1.12em;
}
.md-preview h4,
.md-preview h5,
.md-preview h6 {
font-size: 1em;
}
.md-preview p,
.md-preview ul,
.md-preview ol,
.md-preview blockquote,
.md-preview pre,
.md-preview table {
margin: 0 0 0.9em;
}
.md-preview ul {
list-style: disc;
padding-left: 1.5em;
}
.md-preview ol {
list-style: decimal;
padding-left: 1.5em;
}
.md-preview li + li {
margin-top: 0.2em;
}
.md-preview li > ul,
.md-preview li > ol {
margin: 0.2em 0 0;
}
.md-preview li.task-list-item {
list-style: none;
margin-left: -1.3em;
}
.md-preview li.task-list-item input {
margin-right: 0.4em;
vertical-align: middle;
}
.md-preview a {
color: rgb(var(--accent));
text-decoration: underline;
text-underline-offset: 2px;
}
.md-preview strong {
color: rgb(var(--fg-primary));
font-weight: 600;
}
.md-preview code {
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 0.88em;
padding: 0.1em 0.35em;
border-radius: 4px;
background: rgb(var(--surface-3));
color: rgb(var(--fg-primary));
}
.md-preview pre,
.md-preview .md-frontmatter {
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 12px;
line-height: 1.5;
padding: 0.75em 1em;
border-radius: 6px;
border: 1px solid rgb(var(--border));
background: rgb(var(--surface-0));
overflow-x: auto;
white-space: pre;
}
.md-preview .md-frontmatter {
margin: 0 0 1em;
color: rgb(var(--fg-muted));
}
.md-preview pre code {
padding: 0;
background: none;
font-size: inherit;
}
.md-preview blockquote {
padding: 0 1em;
border-left: 3px solid rgb(var(--border));
color: rgb(var(--fg-muted));
}
.md-preview hr {
margin: 1.5em 0;
border: 0;
border-top: 1px solid rgb(var(--border));
}
.md-preview table {
display: block;
max-width: 100%;
overflow-x: auto;
border-collapse: collapse;
}
.md-preview th,
.md-preview td {
padding: 0.35em 0.75em;
border: 1px solid rgb(var(--border));
}
.md-preview th {
color: rgb(var(--fg-primary));
font-weight: 600;
background: rgb(var(--surface-2));
}
.md-preview img {
max-width: 100%;
}
-10
View File
@@ -2011,7 +2011,6 @@ export const api = {
/** GET /api/ai-tools/job - the current or last job, or null. */
job: () => request<AiToolJob | null>("/ai-tools/job"),
/** GET /api/ai-tools/versions - npm version badges, cached a day server-side. */
versions: () => request<Record<string, AiToolVersion>>("/ai-tools/versions"),
/** The server-cached icon; an <img> cannot send the bearer header, so the token rides the query. */
iconUrl: (id: string) => {
const token = dashboardToken();
@@ -3090,15 +3089,6 @@ export interface AiToolJob {
log: string;
}
/** One row of GET /api/ai-tools/versions. */
export interface AiToolVersion {
/** Its binary lives under npm's global prefix, so an npm update replaces it. */
npmManaged: boolean;
installed: string | null;
latest: string | null;
updateAvailable: boolean;
}
export interface AiRouter {
/** Identifier, derived from the label. Stable: a lane stores this. */
name: string;
+67 -75
View File
@@ -7,8 +7,9 @@
* low-risk text-file surfaces (skills, agents, commands, output styles,
* CLAUDE.md memory, and per-project file-based memory files). Plugins, MCP,
* hooks-in-settings, and settings.json files stay read-only - those have
* concurrent-write races with the live CLI. Its AgentHub sub-tab holds the
* dashboard's own Claude Code settings (pricing, hooks, CLAUDE_HOME).
* concurrent-write races with the live CLI. Its Overview also holds the
* dashboard's own Claude Code settings (CLAUDE_HOME — editable, so the path
* list leaves it out — hooks, pricing: `ClaudeAgentHubSettings`).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
/* =============================================================================
@@ -59,7 +60,6 @@ import { useTranslation } from "react-i18next";
import { eventBus } from "../lib/eventBus";
import {
Boxes,
Gauge,
RefreshCw,
Search,
Sparkles,
@@ -181,8 +181,7 @@ type TabKey =
| "hooks"
| "keybindings"
| "settings"
| "memory"
| "agenthub";
| "memory";
interface TabDef {
key: TabKey;
@@ -203,7 +202,6 @@ const TABS: TabDef[] = [
{ key: "keybindings", icon: Keyboard, i18nKey: "tabs.keybindings" },
{ key: "settings", icon: SettingsIcon, i18nKey: "tabs.settings" },
{ key: "outputStyles", icon: Palette, i18nKey: "tabs.outputStyles" },
{ key: "agenthub", icon: Gauge, i18nKey: "tabs.agenthub" },
];
interface PageState {
@@ -243,7 +241,7 @@ const EMPTY_STATE: PageState = {
export function CcConfig() {
const { t } = useTranslation("ccConfig");
const [tab, setTab] = useState<TabKey>("overview");
// Bumped by Refresh: the AgentHub sub-tab loads on mount, so a new key reloads it.
// Bumped by Refresh: the AgentHub cards load on mount, so a new key reloads them.
const [agenthubKey, setAgenthubKey] = useState(0);
const [scope, setScope] = useState<CcScope>("all");
const [data, setData] = useState<PageState>(EMPTY_STATE);
@@ -530,72 +528,72 @@ export function CcConfig() {
<Tabs current={tab} onSelect={setTab} counts={data.overview?.counts} />
{/* AgentHub's own Claude Code settings are cards of their own, not a
list over this tab's search box and scope. */}
{tab === "agenthub" ? (
<ClaudeAgentHubSettings key={agenthubKey} onHomeChanged={() => void fetchAll()} />
) : (
<div className="rounded-xl border border-border bg-surface-1">
{tab !== "overview" && (
<div className="flex items-center gap-2 border-b border-border px-4 py-2.5">
<Search className="w-4 h-4 text-fg-muted" />
<input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder={t("common.search")}
className="h-7 bg-transparent text-sm text-fg-primary placeholder:text-fg-muted focus:outline-none flex-1"
/>
{search && (
<button
type="button"
onClick={() => setSearch("")}
title={t("common.clearSearch")}
aria-label={t("common.clearSearch")}
className="h-7 w-7 flex-shrink-0 inline-flex items-center justify-center rounded-md text-fg-muted hover:text-fg-secondary hover:bg-surface-3 focus:outline-none focus:ring-1 focus:ring-accent/40"
>
<X className="w-3.5 h-3.5" />
</button>
)}
{isMutable(tab) && tab !== "memory" && (
<button
onClick={() => openCreate(tabToArtifactType(tab))}
className="h-7 text-[11px] font-medium px-2.5 rounded-md border border-accent/30 bg-accent/10 hover:bg-accent/20 text-accent inline-flex items-center gap-1.5"
>
<Plus className="w-3 h-3" />
{t("edit.newButton")}
</button>
)}
{/* The memory tab's own "new" is a different thing: the two
<div className="rounded-xl border border-border bg-surface-1">
{tab !== "overview" && (
<div className="flex items-center gap-2 border-b border-border px-4 py-2.5">
<Search className="w-4 h-4 text-fg-muted" />
<input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder={t("common.search")}
className="h-7 bg-transparent text-sm text-fg-primary placeholder:text-fg-muted focus:outline-none flex-1"
/>
{search && (
<button
type="button"
onClick={() => setSearch("")}
title={t("common.clearSearch")}
aria-label={t("common.clearSearch")}
className="h-7 w-7 flex-shrink-0 inline-flex items-center justify-center rounded-md text-fg-muted hover:text-fg-secondary hover:bg-surface-3 focus:outline-none focus:ring-1 focus:ring-accent/40"
>
<X className="w-3.5 h-3.5" />
</button>
)}
{isMutable(tab) && tab !== "memory" && (
<button
onClick={() => openCreate(tabToArtifactType(tab))}
className="h-7 text-[11px] font-medium px-2.5 rounded-md border border-accent/30 bg-accent/10 hover:bg-accent/20 text-accent inline-flex items-center gap-1.5"
>
<Plus className="w-3 h-3" />
{t("edit.newButton")}
</button>
)}
{/* The memory tab's own "new" is a different thing: the two
CLAUDE.md cards below are this machine's user + current-project
files, while this creates one in ANOTHER project's directory. */}
{tab === "memory" && (
<button
onClick={() => setProjectMemoryOpen(true)}
className="h-7 text-[11px] font-medium px-2.5 rounded-md border border-accent/30 bg-accent/10 hover:bg-accent/20 text-accent inline-flex items-center gap-1.5 whitespace-nowrap"
>
<Plus className="w-3 h-3" />
{t("projectMemory.newButton")}
</button>
)}
</div>
)}
<div className="p-4">
<TabPanel
tab={tab}
data={data}
search={search}
onOpenFile={openViewer}
onEdit={openEdit}
onDelete={openDelete}
onCreateMemory={(s) => openCreate("memory", s)}
onEditAuto={openEditAuto}
onDeleteAuto={openDeleteAuto}
onCreateAuto={openCreateAuto}
onChanged={fetchAll}
onToast={setToast}
/>
{tab === "memory" && (
<button
onClick={() => setProjectMemoryOpen(true)}
className="h-7 text-[11px] font-medium px-2.5 rounded-md border border-accent/30 bg-accent/10 hover:bg-accent/20 text-accent inline-flex items-center gap-1.5 whitespace-nowrap"
>
<Plus className="w-3 h-3" />
{t("projectMemory.newButton")}
</button>
)}
</div>
)}
<div className="p-4">
<TabPanel
tab={tab}
data={data}
search={search}
onOpenFile={openViewer}
onEdit={openEdit}
onDelete={openDelete}
onCreateMemory={(s) => openCreate("memory", s)}
onEditAuto={openEditAuto}
onDeleteAuto={openDeleteAuto}
onCreateAuto={openCreateAuto}
onChanged={fetchAll}
onToast={setToast}
/>
</div>
</div>
{/* AgentHub's own Claude Code settings follow the overview, outside the
list's search box and scope. */}
{tab === "overview" && (
<ClaudeAgentHubSettings key={agenthubKey} onHomeChanged={() => void fetchAll()} />
)}
{viewer && <FileViewer state={viewer} onClose={() => setViewer(null)} />}
@@ -1069,12 +1067,6 @@ function OverviewPanel({ overview }: { overview: CcOverview | null }) {
{t("overview.rootsTitle")}
</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-2">
<RootRow
icon={FolderTree}
tone="sky"
label={t("overview.claudeHome")}
value={roots.claudeHome}
/>
<RootRow
icon={FolderTree}
tone="emerald"
+73 -85
View File
@@ -8,10 +8,10 @@
* CLI-owned and only offer add/remove, mirroring the Claude Code panel's
* split between editable artifacts and CLI-driven surfaces. Codex has no
* "project-local" scope and no per-project file-based memory - its scope
* union is exactly "user" | "project". Its **AgentHub** sub-tab holds the
* dashboard's own Codex settings: Model Pricing for OpenAI models (the shared
* `PricingSettings` table), AgentHub's Codex hooks (`CodexHooksSettings`)
* and CODEX_HOME.
* union is exactly "user" | "project". Its **Overview** also holds the
* dashboard's own Codex settings: CODEX_HOME (editable, so the overview's path
* list leaves it out), AgentHub's Codex hooks (`CodexHooksSettings`) and Model
* Pricing for OpenAI models (the shared `PricingSettings` table).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -20,7 +20,6 @@ import { useTranslation } from "react-i18next";
import { eventBus } from "../lib/eventBus";
import {
Boxes,
Gauge,
RefreshCw,
Search,
Sparkles,
@@ -82,16 +81,7 @@ import type {
CodexSkill,
} from "../lib/api";
type TabKey =
| "overview"
| "skills"
| "agents"
| "rules"
| "memory"
| "config"
| "mcp"
| "plugins"
| "agenthub";
type TabKey = "overview" | "skills" | "agents" | "rules" | "memory" | "config" | "mcp" | "plugins";
interface TabDef {
key: TabKey;
@@ -108,7 +98,6 @@ const TABS: TabDef[] = [
{ key: "config", icon: SettingsIcon, i18nKey: "tabs.config" },
{ key: "mcp", icon: Server, i18nKey: "tabs.mcp" },
{ key: "plugins", icon: PlugZap, i18nKey: "tabs.plugins" },
{ key: "agenthub", icon: Gauge, i18nKey: "tabs.agenthub" },
];
function isNamedArtifact(tab: TabKey): tab is "skills" | "agents" | "rules" {
@@ -160,7 +149,7 @@ export function CodexConfig() {
const { t } = useTranslation("codexConfig");
const { t: tSettings } = useTranslation("settings");
const [tab, setTab] = useState<TabKey>("overview");
// Bumped by Refresh: the AgentHub sub-tab loads on mount, so a new key reloads it.
// Bumped by Refresh: the AgentHub cards load on mount, so a new key reloads them.
const [agenthubKey, setAgenthubKey] = useState(0);
// Bumped after a CODEX_HOME save: the hooks card reads the new home's hooks.json.
const [hooksKey, setHooksKey] = useState(0);
@@ -400,12 +389,72 @@ export function CodexConfig() {
<Tabs current={tab} onSelect={setTab} counts={data.overview?.counts} />
{/* AgentHub's own Codex settings are cards of their own, not a list. A
new CODEX_HOME re-reads every list, since they all live under it. */}
{tab === "agenthub" ? (
<div className="rounded-xl border border-border bg-surface-1">
{tab !== "overview" && tab !== "memory" && tab !== "config" && (
<div className="flex items-center gap-2 border-b border-border px-4 py-2.5">
<Search className="w-4 h-4 text-fg-muted" />
<input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder={t("common.search")}
className="h-7 bg-transparent text-sm text-fg-primary placeholder:text-fg-muted focus:outline-none flex-1"
/>
{search && (
<button
type="button"
onClick={() => setSearch("")}
title={t("common.clearSearch")}
aria-label={t("common.clearSearch")}
className="h-7 w-7 flex-shrink-0 inline-flex items-center justify-center rounded-md text-fg-muted hover:text-fg-secondary hover:bg-surface-3 focus:outline-none focus:ring-1 focus:ring-accent/40"
>
<X className="w-3.5 h-3.5" />
</button>
)}
{isNamedArtifact(tab) && (
<button
onClick={() => openCreate(tab)}
className="h-7 text-[11px] font-medium px-2.5 rounded-md border border-accent/30 bg-accent/10 hover:bg-accent/20 text-accent inline-flex items-center gap-1.5"
>
<Plus className="w-3 h-3" />
{t("list.newButton")}
</button>
)}
</div>
)}
{/* The memory tab hides the search bar, but still needs its own
"new": a CLAUDE.md-style AGENTS.md in ANOTHER project's dir, not
the user/project files listed below. */}
{tab === "memory" && (
<div className="flex items-center justify-end border-b border-border px-4 py-2.5">
<button
onClick={() => setProjectMemoryOpen(true)}
className="h-7 text-[11px] font-medium px-2.5 rounded-md border border-accent/30 bg-accent/10 hover:bg-accent/20 text-accent inline-flex items-center gap-1.5"
>
<Plus className="w-3 h-3" />
{t("projectMemory.newButton")}
</button>
</div>
)}
<div className="p-4">
<TabPanel
tab={tab}
data={data}
search={search}
cliError={cliError}
onOpenFile={openViewer}
onEdit={openEdit}
onDelete={openDelete}
onCreateScopeFile={openCreate}
onChanged={fetchAll}
onToast={setToast}
/>
</div>
</div>
{/* AgentHub's own Codex settings follow the overview. A new CODEX_HOME
re-reads every list, since they all live under it. */}
{tab === "overview" && (
<div key={agenthubKey} className="space-y-8">
<PricingSettings vendor="openai" id="codex-pricing" />
<CodexHooksSettings key={hooksKey} />
<HomeDirSetting
id="codex-home"
title={tSettings("codexHome.title")}
@@ -431,68 +480,8 @@ export function CodexConfig() {
})
}
/>
</div>
) : (
<div className="rounded-xl border border-border bg-surface-1">
{tab !== "overview" && tab !== "memory" && tab !== "config" && (
<div className="flex items-center gap-2 border-b border-border px-4 py-2.5">
<Search className="w-4 h-4 text-fg-muted" />
<input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder={t("common.search")}
className="h-7 bg-transparent text-sm text-fg-primary placeholder:text-fg-muted focus:outline-none flex-1"
/>
{search && (
<button
type="button"
onClick={() => setSearch("")}
title={t("common.clearSearch")}
aria-label={t("common.clearSearch")}
className="h-7 w-7 flex-shrink-0 inline-flex items-center justify-center rounded-md text-fg-muted hover:text-fg-secondary hover:bg-surface-3 focus:outline-none focus:ring-1 focus:ring-accent/40"
>
<X className="w-3.5 h-3.5" />
</button>
)}
{isNamedArtifact(tab) && (
<button
onClick={() => openCreate(tab)}
className="h-7 text-[11px] font-medium px-2.5 rounded-md border border-accent/30 bg-accent/10 hover:bg-accent/20 text-accent inline-flex items-center gap-1.5"
>
<Plus className="w-3 h-3" />
{t("list.newButton")}
</button>
)}
</div>
)}
{/* The memory tab hides the search bar, but still needs its own
"new": a CLAUDE.md-style AGENTS.md in ANOTHER project's dir, not
the user/project files listed below. */}
{tab === "memory" && (
<div className="flex items-center justify-end border-b border-border px-4 py-2.5">
<button
onClick={() => setProjectMemoryOpen(true)}
className="h-7 text-[11px] font-medium px-2.5 rounded-md border border-accent/30 bg-accent/10 hover:bg-accent/20 text-accent inline-flex items-center gap-1.5"
>
<Plus className="w-3 h-3" />
{t("projectMemory.newButton")}
</button>
</div>
)}
<div className="p-4">
<TabPanel
tab={tab}
data={data}
search={search}
cliError={cliError}
onOpenFile={openViewer}
onEdit={openEdit}
onDelete={openDelete}
onCreateScopeFile={openCreate}
onChanged={fetchAll}
onToast={setToast}
/>
</div>
<CodexHooksSettings key={hooksKey} />
<PricingSettings vendor="openai" id="codex-pricing" />
</div>
)}
@@ -829,7 +818,6 @@ function OverviewPanel({ overview }: { overview: CodexOverview | null }) {
{t("overview.rootsTitle")}
</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-2">
<RootRow icon={FolderTree} label={t("overview.codexHome")} value={roots.codexHome} />
<RootRow
icon={FolderTree}
label={t("overview.projectCodexDir")}
+1 -6
View File
@@ -1,6 +1,6 @@
/**
* @file Settings.tsx
* @description Settings → Dashboard: the install-wide settings — total cost, appearance, AI Tools, lanes housekeeping, import, remote sources, Sagi, notifications, alerts, data management and system information. Per-tool settings live in that tool's own tab (Claude Code's pricing, hooks and CLAUDE_HOME: `components/settings/ClaudeAgentHubSettings.tsx`).
* @description Settings → Dashboard: the install-wide settings — total cost, appearance, lanes housekeeping, import, remote sources, Sagi, notifications, alerts, data management and system information. Per-tool settings live in that tool's own tab (Claude Code's pricing, hooks and CLAUDE_HOME: `components/settings/ClaudeAgentHubSettings.tsx`). AI Tools is a modal opened from the tab bar (`SettingsHub.tsx`).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
/* =============================================================================
@@ -60,7 +60,6 @@ import { useTranslation } from "react-i18next";
import {
DollarSign,
Palette,
Bot,
RefreshCw,
Database,
HardDrive,
@@ -112,7 +111,6 @@ import { RemoteSources } from "../components/RemoteSources";
import { Skeleton } from "../components/Skeleton";
import { AlertsNotifications } from "../components/AlertsNotifications";
import { AppearanceSettings } from "../components/settings/AppearanceSettings";
import { AiToolsSettings } from "../components/settings/AiToolsSettings";
import type { WSMessage } from "../lib/types";
// In-page navigation for the (dense) Settings screen. Each entry maps to a
@@ -124,7 +122,6 @@ const SETTINGS_SECTIONS: {
Icon: typeof DollarSign;
}[] = [
{ id: "appearance", labelKey: "appearance.title", Icon: Palette },
{ id: "ai-tools", labelKey: "aiTools.title", Icon: Bot },
{ id: "lanes", labelKey: "lanesSection.title", Icon: GitBranch },
{ id: "import", labelKey: "import.title", fallback: "Import", Icon: History },
{
@@ -626,8 +623,6 @@ export function Settings() {
<AppearanceSettings />
<AiToolsSettings />
{/* ─── LANES ─── */}
<section id="lanes" className="scroll-mt-24">
<h3 className="text-sm font-medium text-fg-secondary flex items-center gap-2 mb-1">
+30 -3
View File
@@ -1,12 +1,13 @@
/**
* @file SettingsHub.tsx
* @description Tabbed shell for /settings that hosts the dashboard's own settings plus one configuration panel per AI tool (Claude Code, Codex and Antigravity) plus the AI Router profiles they can be pointed at and the git logins dashboard fetch/pull/push use, with the active tab kept in the `?tool=` query param. A tool's tab is hidden while that tool is disabled in Settings → AI Tools.
* @description Tabbed shell for /settings that hosts the dashboard's own settings plus one configuration panel per AI tool (Claude Code, Codex and Antigravity) plus the AI Router profiles they can be pointed at and the git logins dashboard fetch/pull/push use, with the active tab kept in the `?tool=` query param. The **AI Tools** button at the bar's right edge opens `AiToolsModal` (also opened by `/settings#ai-tools`, the Add-lane modal's link); a tool's tab is hidden while that tool is disabled there.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import type { ComponentType } from "react";
import { useSearchParams } from "react-router-dom";
import { useCallback, useEffect, useState, type ComponentType } from "react";
import { useLocation, useNavigate, useSearchParams } from "react-router-dom";
import { useTranslation } from "react-i18next";
import {
Bot,
Boxes,
KeyRound,
Orbit,
@@ -22,6 +23,7 @@ import { AntigravityConfig } from "./AntigravityConfig";
import { AiRouterConfig } from "./AiRouterConfig";
import { GitCredentialsConfig } from "./GitCredentialsConfig";
import { useAiTools } from "../hooks/useAiTools";
import { AiToolsModal } from "../components/settings/AiToolsModal";
interface ToolTab {
id: string;
@@ -85,6 +87,19 @@ export function SettingsHub() {
(tab) => !tab.aiTool || aiTools.byId(tab.aiTool)?.enabled !== false
);
const active = tabs.find((tab) => tab.id === requested) ?? DASHBOARD_TAB;
const location = useLocation();
const navigate = useNavigate();
const [aiToolsOpen, setAiToolsOpen] = useState(false);
useEffect(() => {
if (location.hash === "#ai-tools") setAiToolsOpen(true);
}, [location.hash]);
const closeAiTools = useCallback(() => {
setAiToolsOpen(false);
// Drop the deep link, or a reload would open the modal again.
if (location.hash) navigate({ search: location.search }, { replace: true });
}, [location.hash, location.search, navigate]);
return (
<div className="space-y-6">
@@ -97,9 +112,21 @@ export function SettingsHub() {
Icon,
label: t(labelKey),
}))}
actions={
<button
type="button"
onClick={() => setAiToolsOpen(true)}
aria-haspopup="dialog"
className="inline-flex items-center gap-1.5 whitespace-nowrap rounded-lg border border-border px-3 py-1.5 text-xs text-fg-secondary transition-colors hover:bg-surface-3 hover:text-fg-primary"
>
<Bot className="h-3.5 w-3.5" />
{t("aiTools.title")}
</button>
}
/>
<active.Panel />
{aiToolsOpen && <AiToolsModal onClose={closeAiTools} />}
</div>
);
}
@@ -123,7 +123,7 @@ afterEach(() => {
});
describe("CodexConfig", () => {
it("edits CODEX_HOME on the AgentHub sub-tab and reports lanes whose router layer failed", async () => {
it("edits CODEX_HOME on the Overview and reports lanes whose router layer failed", async () => {
stubCodexApi({});
codexHomeMock.get.mockResolvedValue({ codex_home: "/home/.codex", env_owned: false });
codexHomeMock.set.mockResolvedValue({
@@ -136,7 +136,8 @@ describe("CodexConfig", () => {
await settle();
const overviewCalls = codexConfigMock.overview.mock.calls.length;
await userEvent.click(screen.getByRole("tab", { name: /agenthub/i }));
// No AgentHub sub-tab: its cards sit under the Overview, the page's first tab.
expect(screen.queryByRole("tab", { name: /agenthub/i })).toBeNull();
const input = await screen.findByDisplayValue("/home/.codex");
expect(await screen.findByText("Codex hooks")).toBeInTheDocument();
expect(screen.queryByPlaceholderText(/search/i)).not.toBeInTheDocument();
@@ -58,6 +58,7 @@ vi.mock("../../lib/api", async (importOriginal) => {
return {
...actual,
api: {
aiTools: { iconUrl: (id: string) => `/api/ai-tools/${id}/icon` },
lanes: {
list: vi.fn().mockImplementation(async () => {
recordCall("GET", "/api/lanes");
@@ -1474,35 +1474,6 @@ exports[`screen snapshots > Claude Config 1`] = `
0
</span>
</button>
<button
aria-selected="false"
class="flex items-center gap-2 rounded-lg px-3 py-1.5 text-xs font-medium transition-colors flex-shrink-0 whitespace-nowrap text-fg-secondary hover:text-fg-primary hover:bg-surface-3 border border-transparent"
data-testid="cc-tab-agenthub"
role="tab"
>
<svg
class="lucide lucide-gauge w-3.5 h-3.5"
fill="none"
height="24"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="m12 14 4-4"
/>
<path
d="M3.34 19a10 10 0 1 1 17.32 0"
/>
</svg>
<span>
AgentHub
</span>
</button>
</div>
<div
class="pointer-events-none absolute right-0 top-0 bottom-0 w-12 rounded-r-xl bg-gradient-to-l from-surface-1 to-transparent transition-opacity z-10 opacity-0"
@@ -1526,86 +1497,6 @@ exports[`screen snapshots > Claude Config 1`] = `
<div
class="grid grid-cols-1 md:grid-cols-2 gap-2"
>
<div
class="relative flex items-center gap-2.5 rounded-lg border border-border bg-surface-2 px-3 py-2 min-w-0 overflow-hidden"
>
<div
aria-hidden="true"
class="absolute left-0 top-0 bottom-0 w-1 bg-sky-500/40"
/>
<span
class="w-7 h-7 rounded-md bg-sky-500/10 flex items-center justify-center flex-shrink-0 ml-1.5"
>
<svg
class="lucide lucide-folder-tree w-3.5 h-3.5 text-sky-700 dark:text-sky-300"
fill="none"
height="24"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M20 10a1 1 0 0 0 1-1V6a1 1 0 0 0-1-1h-2.5a1 1 0 0 1-.8-.4l-.9-1.2A1 1 0 0 0 15 3h-2a1 1 0 0 0-1 1v5a1 1 0 0 0 1 1Z"
/>
<path
d="M20 21a1 1 0 0 0 1-1v-3a1 1 0 0 0-1-1h-2.9a1 1 0 0 1-.88-.55l-.42-.85a1 1 0 0 0-.92-.6H13a1 1 0 0 0-1 1v5a1 1 0 0 0 1 1Z"
/>
<path
d="M3 5a2 2 0 0 0 2 2h3"
/>
<path
d="M3 3v13a2 2 0 0 0 2 2h3"
/>
</svg>
</span>
<div
class="min-w-0 flex-1"
>
<div
class="text-[10px] font-semibold uppercase tracking-wider text-fg-secondary"
>
Claude home
</div>
<div
class="font-mono text-[11px] text-fg-secondary truncate"
>
/home/test/.claude
</div>
</div>
<button
class="text-[10px] font-medium px-1.5 py-1 rounded border border-border bg-surface-1 hover:bg-surface-3 text-fg-secondary hover:text-fg-primary inline-flex items-center gap-1 flex-shrink-0"
title="Copy path"
>
<svg
class="lucide lucide-copy w-3 h-3"
fill="none"
height="24"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<rect
height="14"
rx="2"
ry="2"
width="14"
x="8"
y="8"
/>
<path
d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"
/>
</svg>
</button>
</div>
<div
class="relative flex items-center gap-2.5 rounded-lg border border-border bg-surface-2 px-3 py-2 min-w-0 overflow-hidden"
>
@@ -2602,6 +2493,378 @@ exports[`screen snapshots > Claude Config 1`] = `
</div>
</div>
</div>
<div
class="space-y-8"
>
<section
class="scroll-mt-24"
id="claude-home"
>
<h3
class="text-sm font-medium text-fg-secondary flex items-center gap-2 mb-1"
>
<svg
class="lucide lucide-folder-open w-4 h-4 text-fg-muted"
fill="none"
height="24"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="m6 14 1.5-2.9A2 2 0 0 1 9.24 10H20a2 2 0 0 1 1.94 2.5l-1.54 6a2 2 0 0 1-1.95 1.5H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h3.9a2 2 0 0 1 1.69.9l.81 1.2a2 2 0 0 0 1.67.9H18a2 2 0 0 1 2 2v2"
/>
</svg>
CLAUDE_HOME
</h3>
<p
class="text-xs text-fg-muted mb-1"
>
Root directory for Claude Code session data. Changing this updates the scan path for transcripts and imports immediately.
</p>
<p
class="text-[11px] text-fg-muted italic mb-4 leading-snug"
>
Informational: Cursor sessions count here too — Cursor happens to use the same ~/.claude paths as Claude Code (locally and on synced remotes).
</p>
<div
class="card p-5 space-y-4 "
>
<div
class="flex items-center gap-3"
>
<input
aria-label="CLAUDE_HOME"
class="flex-1 bg-surface-4 border border-surface-3 rounded-lg px-3 py-2 text-sm text-fg-secondary font-mono focus:outline-none focus:border-violet-500/50"
placeholder="/path/to/claude-home"
type="text"
value="/home/test/.claude"
/>
<button
class="btn-primary px-4 py-2 text-sm disabled:opacity-50 disabled:cursor-not-allowed"
disabled=""
>
Save
</button>
</div>
</div>
</section>
<section
class="scroll-mt-24"
id="hooks"
>
<h3
class="text-sm font-medium text-fg-secondary flex items-center gap-2 mb-1"
>
<svg
class="lucide lucide-plug w-4 h-4 text-fg-muted"
fill="none"
height="24"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M12 22v-5"
/>
<path
d="M9 8V2"
/>
<path
d="M15 8V2"
/>
<path
d="M18 8v5a4 4 0 0 1-4 4h-4a4 4 0 0 1-4-4V8Z"
/>
</svg>
Hook Configuration
</h3>
<p
class="text-xs text-fg-muted mb-4"
>
Claude Code hooks forward events to the dashboard in real time.
</p>
<div
class="card p-5 space-y-4"
>
<div
class="flex items-center justify-between flex-wrap gap-3"
>
<div
class="flex items-center gap-3"
>
<span
class="inline-flex items-center gap-1.5 text-xs font-medium text-status-success bg-status-success/10 border border-status-success/20 px-2.5 py-1 rounded-full"
>
<svg
class="lucide lucide-circle-check-big w-3.5 h-3.5"
fill="none"
height="24"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M21.801 10A10 10 0 1 1 17 3.335"
/>
<path
d="m9 11 3 3L22 4"
/>
</svg>
All hooks installed
</span>
</div>
<button
class="btn-ghost text-xs disabled:opacity-50"
>
<svg
class="lucide lucide-rotate-ccw w-3.5 h-3.5"
fill="none"
height="24"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"
/>
<path
d="M3 3v5h5"
/>
</svg>
Reinstall Hooks
</button>
</div>
<div
class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-5 gap-2"
/>
<p
class="text-[11px] text-fg-muted font-mono truncate"
>
/tmp/settings.json
</p>
</div>
</section>
<section
class="scroll-mt-24"
id="pricing"
>
<div
class="flex flex-wrap items-center justify-between gap-3 mb-4"
>
<div>
<h3
class="text-sm font-medium text-fg-secondary flex items-center gap-2"
>
<svg
class="lucide lucide-dollar-sign w-4 h-4 text-fg-muted"
fill="none"
height="24"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<line
x1="12"
x2="12"
y1="2"
y2="22"
/>
<path
d="M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6"
/>
</svg>
Model Pricing
<button
aria-label="How model pricing works"
class="inline-flex items-center justify-center rounded-full p-0.5 text-fg-muted hover:text-fg-secondary focus:outline-none focus:ring-1 focus:ring-accent/40"
type="button"
>
<svg
class="lucide lucide-info w-3.5 h-3.5"
fill="none"
height="24"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<circle
cx="12"
cy="12"
r="10"
/>
<path
d="M12 16v-4"
/>
<path
d="M12 8h.01"
/>
</svg>
</button>
</h3>
<p
class="text-xs text-fg-muted mt-0.5"
>
Rates per million tokens (USD). Use % as wildcard in patterns. Some models have a time-limited introductory rate (shown in violet); usage on or before its cutoff date is billed at that rate, and usage after it at the standard rate.
</p>
</div>
<div
class="flex items-center gap-2"
>
<button
class="text-xs px-3 py-1.5 rounded-md transition-colors disabled:opacity-50 inline-flex items-center gap-1.5 text-fg-secondary hover:text-fg-primary hover:bg-surface-4"
>
<svg
class="lucide lucide-rotate-ccw w-3 h-3"
fill="none"
height="24"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"
/>
<path
d="M3 3v5h5"
/>
</svg>
Reset Defaults
</button>
<button
class="btn-primary text-xs disabled:opacity-50"
>
<svg
class="lucide lucide-plus w-3.5 h-3.5"
fill="none"
height="24"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M5 12h14"
/>
<path
d="M12 5v14"
/>
</svg>
Add Model
</button>
</div>
</div>
<div
class="card overflow-x-auto mt-4"
>
<table
class="w-full min-w-[1000px]"
>
<thead>
<tr
class="border-b border-border text-left"
>
<th
class="px-4 py-3 text-[11px] font-semibold text-fg-muted uppercase tracking-wider"
>
Pattern
</th>
<th
class="px-4 py-3 text-[11px] font-semibold text-fg-muted uppercase tracking-wider"
>
Model
</th>
<th
class="px-4 py-3 text-[11px] font-semibold text-fg-muted uppercase tracking-wider text-right"
>
Input
</th>
<th
class="px-4 py-3 text-[11px] font-semibold text-fg-muted uppercase tracking-wider text-right"
>
Output
</th>
<th
class="px-4 py-3 text-[11px] font-semibold text-fg-muted uppercase tracking-wider text-right"
>
Cache Read
</th>
<th
class="px-4 py-3 text-[11px] font-semibold text-fg-muted uppercase tracking-wider text-right"
>
Cache Write (5m)
</th>
<th
class="px-4 py-3 text-[11px] font-semibold text-fg-muted uppercase tracking-wider text-right"
>
Cache Write (1h)
</th>
<th
class="px-4 py-3 text-[11px] font-semibold text-fg-muted uppercase tracking-wider text-right"
>
Fast Input
</th>
<th
class="px-4 py-3 text-[11px] font-semibold text-fg-muted uppercase tracking-wider text-right"
>
Fast Output
</th>
<th
class="w-24 px-4 py-3 text-[11px] font-semibold text-fg-muted uppercase tracking-wider"
>
Actions
</th>
</tr>
</thead>
<tbody
class="divide-y divide-border"
>
<tr>
<td
class="px-4 py-6 text-center text-xs text-fg-muted"
colspan="10"
>
No Claude model prices. Reset Defaults restores them.
</td>
</tr>
</tbody>
</table>
</div>
</section>
</div>
</div>
</div>
`;
@@ -7025,47 +7288,6 @@ exports[`screen snapshots > Settings 1`] = `
</svg>
Appearance
</button>
<button
class="inline-flex items-center gap-1.5 text-xs whitespace-nowrap px-2.5 py-1.5 rounded-lg border transition-colors flex-shrink-0 border-border text-fg-secondary hover:text-fg-primary hover:bg-surface-3"
type="button"
>
<svg
class="lucide lucide-bot w-3.5 h-3.5"
fill="none"
height="24"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M12 8V4H8"
/>
<rect
height="12"
rx="2"
width="16"
x="4"
y="8"
/>
<path
d="M2 14h2"
/>
<path
d="M20 14h2"
/>
<path
d="M15 13v2"
/>
<path
d="M9 13v2"
/>
</svg>
AI Tools
</button>
<button
class="inline-flex items-center gap-1.5 text-xs whitespace-nowrap px-2.5 py-1.5 rounded-lg border transition-colors flex-shrink-0 border-border text-fg-secondary hover:text-fg-primary hover:bg-surface-3"
type="button"
@@ -8078,141 +8300,6 @@ exports[`screen snapshots > Settings 1`] = `
</div>
</div>
</section>
<section
class="scroll-mt-24"
id="ai-tools"
>
<h3
class="text-sm font-medium text-fg-secondary flex items-center gap-2 mb-1"
>
<svg
class="lucide lucide-bot w-4 h-4 text-fg-muted"
fill="none"
height="24"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M12 8V4H8"
/>
<rect
height="12"
rx="2"
width="16"
x="4"
y="8"
/>
<path
d="M2 14h2"
/>
<path
d="M20 14h2"
/>
<path
d="M15 13v2"
/>
<path
d="M9 13v2"
/>
</svg>
AI Tools
</h3>
<p
class="text-xs text-fg-muted mb-4"
>
AI coding CLIs AgentHub can run in a lane. Detected on this machine's PATH; a disabled tool is hidden from Add lane.
</p>
<div
class="card p-5 space-y-5"
>
<div>
<div
class="flex items-center justify-between gap-3 mb-2"
>
<p
class="text-xs font-medium text-fg-secondary"
>
Installed (0 detected)
</p>
<div
class="flex items-center gap-2"
>
<button
class="inline-flex items-center gap-1.5 rounded-md border border-border px-2 py-1 text-xs text-fg-secondary hover:bg-surface-3 disabled:opacity-50"
disabled=""
type="button"
>
<svg
class="lucide lucide-download w-3.5 h-3.5"
fill="none"
height="24"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"
/>
<polyline
points="7 10 12 15 17 10"
/>
<line
x1="12"
x2="12"
y1="15"
y2="3"
/>
</svg>
Update tools
</button>
<button
class="inline-flex items-center gap-1.5 rounded-md border border-border px-2 py-1 text-xs text-fg-secondary hover:bg-surface-3 disabled:opacity-50"
type="button"
>
<svg
class="lucide lucide-refresh-cw w-3.5 h-3.5"
fill="none"
height="24"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8"
/>
<path
d="M21 3v5h-5"
/>
<path
d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16"
/>
<path
d="M8 16H3v5"
/>
</svg>
Refresh
</button>
</div>
</div>
<ul
class="divide-y divide-border rounded-md border border-border"
/>
</div>
</div>
</section>
<section
class="scroll-mt-24"
id="lanes"
@@ -591,9 +591,10 @@ describe("screen snapshots", () => {
});
// Pricing, hook status and CLAUDE_HOME belong to Claude Code (#40 part B):
// they live behind the Claude Code tab's AgentHub sub-tab, not on Dashboard.
// they sit under the Claude Code tab's Overview, not on Dashboard or on the
// other sub-tabs.
describe("Claude Code's AgentHub settings", () => {
it("shows pricing, hooks and CLAUDE_HOME on the AgentHub sub-tab only", async () => {
it("shows pricing, hooks and CLAUDE_HOME on the Overview only", async () => {
const dashboard = render(
<MemoryRouter initialEntries={["/settings"]}>
<Settings />
@@ -611,12 +612,13 @@ describe("Claude Code's AgentHub settings", () => {
</MemoryRouter>
);
await settle();
expect(container.querySelector("section#pricing")).toBeNull();
fireEvent.click(screen.getByTestId("cc-tab-agenthub"));
await settle();
expect(screen.queryByTestId("cc-tab-agenthub")).toBeNull();
for (const id of ["pricing", "hooks", "claude-home"]) {
expect(container.querySelector(`section#${id}`)).not.toBeNull();
}
fireEvent.click(screen.getByTestId("cc-tab-skills"));
await settle();
expect(container.querySelector("section#pricing")).toBeNull();
});
});
+1 -1
View File
@@ -66,7 +66,7 @@ graph TB
**Database Location:**
- **Canonical (default):** `~/.claude/agenthub/dashboard.db` — shared by `npm start`, `npm run dev`, and the desktop app when it uses the same data dir
- **Override:** set `DASHBOARD_DATA_DIR` (directory) or `DASHBOARD_DB_PATH` (file path) for tests or custom deployments
- **Pinned on a CLAUDE_HOME change:** the data dir defaults to `<CLAUDE_HOME>/agenthub`, so saving a new CLAUDE_HOME from Settings → Claude Code → AgentHub first writes the data dir in use as `DASHBOARD_DATA_DIR` to AgentHub's settings file (`~/.config/agenthub/settings.env`, or `$XDG_CONFIG_HOME/agenthub/settings.env`, or `AGENTHUB_SETTINGS_FILE`). The next start keeps the same database instead of opening an empty one under the new home. An explicit `DASHBOARD_DATA_DIR` is left alone
- **Pinned on a CLAUDE_HOME change:** the data dir defaults to `<CLAUDE_HOME>/agenthub`, so saving a new CLAUDE_HOME from Settings → Claude Code → Overview first writes the data dir in use as `DASHBOARD_DATA_DIR` to AgentHub's settings file (`~/.config/agenthub/settings.env`, or `$XDG_CONFIG_HOME/agenthub/settings.env`, or `AGENTHUB_SETTINGS_FILE`). The next start keeps the same database instead of opening an empty one under the new home. An explicit `DASHBOARD_DATA_DIR` is left alone
- **Legacy:** repo-local `./data/dashboard.db` is migrated into the canonical location on first launch (see `server/db.js`)
- **Pre-rebrand:** an install that predates AgentHub keeps its data in `~/.claude/agent-dashboard/`. Server startup renames that directory to `~/.claude/agenthub/` once, before anything opens the database (`migrateLegacyDataDir()` in `server/lib/claude-home.js`). It is a same-filesystem rename, so it is instant even at multiple gigabytes and never leaves two copies. If the rename fails, or `DASHBOARD_DATA_DIR` is set, nothing moves and the old directory keeps being used.
+1 -1
View File
@@ -201,7 +201,7 @@ Formatting utilities are centralized in `client/src/lib/format.ts`.
`formatTime`, `formatDateTime`, and `fmtCostFull` use locale-aware `toLocale*` APIs.
Timestamp parsing normalizes timezone-less SQLite datetime strings to UTC before display formatting.
`formatModelName` converts raw model identifiers (e.g. `claude-opus-4-7-20260101`, `claude-opus-4-7[1m]`) into human-friendly display names (e.g. "Claude Opus 4.7", "Claude Opus 4.7 (1M)"). This is locale-independent (brand names are proper nouns) and is applied across all UI surfaces except the Model Pricing table (Settings → Claude Code → AgentHub, Settings → Codex → AgentHub and Settings → Antigravity), which shows raw patterns for pricing rule configuration.
`formatModelName` converts raw model identifiers (e.g. `claude-opus-4-7-20260101`, `claude-opus-4-7[1m]`) into human-friendly display names (e.g. "Claude Opus 4.7", "Claude Opus 4.7 (1M)"). This is locale-independent (brand names are proper nouns) and is applied across all UI surfaces except the Model Pricing table (Settings → Claude Code → Overview, Settings → Codex → Overview and Settings → Antigravity), which shows raw patterns for pricing rule configuration.
```mermaid
flowchart LR
+3 -3
View File
@@ -1113,7 +1113,7 @@ on every hook, live — so it only ever sees `claude` lanes.
touches a `codex` lane sharing the same directory, nor a Codex hook the
`claude` one — and runs detection for `claude` lanes only. Codex lanes carry no
pipeline, so nothing detects or advances their stage. Codex's own hooks are
opt-in (Settings → Codex → AgentHub → **Codex hooks**; Codex then asks you to
opt-in (Settings → Codex → Overview → **Codex hooks**; Codex then asks you to
trust them): with them a codex lane gets `running`/`idle` as each turn starts
and ends, and "Codex needs your permission: …" as its `needs_action` while
Codex waits on an approval. A sub-agent thread's tool calls are folded onto
@@ -1128,7 +1128,7 @@ start and end): every `DASHBOARD_CODEX_POLL_MS` (default 15000; `0`
disables) it lists `$CODEX_HOME/sessions/<YYYY>/<MM>/<DD>/rollout-*.jsonl`
(`$CODEX_HOME` defaults to `~/.codex`; every codex pane is started with the
dashboard's own `CODEX_HOME` through tmux `-e`, so the pane writes where this
reads; it is editable in Settings → Codex → AgentHub, and a save re-writes each
reads; it is editable in Settings → Codex → Overview, and a save re-writes each
codex lane's AI Router layer into the new home — a pane already running keeps
the old one until it restarts), reads each thread's `cwd` from the
`session_meta` on the file's first line, and calls the owning lane `running`
@@ -1703,7 +1703,7 @@ agenthub stage <stage> [--evidence "..."] # Report the current stage
## Lanes on any AI tool
A lane can run any AI coding CLI in the AI Tools catalog (`server/data/ai-tools/*.json`, plus any `*.json` in `DASHBOARD_AI_TOOLS_DIR`, which replaces a built-in file with the same `id` but may not set `launcher`). Settings → Dashboard → **AI Tools** lists what is installed (found on PATH; **Refresh** re-reads the login shell's PATH first) with **Enabled/Disabled** and **Set default**, and what is available to install with a homepage link. The Add-lane modal offers **Default (<tool>)** and **Other…**; `agenthub lanes add --tool <id>` does the same from a terminal.
A lane can run any AI coding CLI in the AI Tools catalog (`server/data/ai-tools/*.json`, plus any `*.json` in `DASHBOARD_AI_TOOLS_DIR`, which replaces a built-in file with the same `id` but may not set `launcher`). The **AI Tools** button at the right of the Settings tab bar opens a dialog (also `/settings#ai-tools`) that lists what is installed (found on PATH; **Refresh** re-reads the login shell's PATH first) with an enable switch, **Set default** and **Update** (for every tool whose catalog file names an updater — no version lookup, the updater itself reports "up to date"), and, folded behind a disclosure, what is available to install with a homepage link. The Add-lane modal shows the chosen tool as one button (the default until changed); clicking it lists every installed, enabled tool as buttons to pick from; `agenthub lanes add --tool <id>` does the same from a terminal.
A catalog file:
+1 -1
View File
@@ -32,7 +32,7 @@ const {
const { extractFirstUserText } = require("../server/lib/transcript-cache");
const CLAUDE_DIR = getClaudeHome();
// The projects dir is read per call, not at load: CLAUDE_HOME can change while
// the dashboard runs (Settings → Claude Code → AgentHub).
// the dashboard runs (Settings → Claude Code → Overview).
// Max session files a directory sweep scans synchronously before it yields to
// the event loop. A long synchronous scan of a large ~/.claude/projects tree —
+2 -2
View File
@@ -542,7 +542,7 @@ Request body shape:
| `POST` | `/api/ai-tools/:id/update` | Same-origin guarded: start the update job; npm only for an npm-managed tool (`server/lib/ai-tool-versions.js`), else `409 ENOTNPM`. `400 ENOUPDATE`, `409 ENOTINSTALLED`/`EBUSY`. |
| `POST` | `/api/ai-tools/update-all` | Same-origin guarded: one job over every npm-managed tool with a newer version plus every self updater. `400 ENOTHING`. |
| `GET` | `/api/ai-tools/job` | The current or last job (in memory), or `null`. |
| `GET` | `/api/ai-tools/versions` | npm version badges, cached 24 h. |
| `GET` | `/api/ai-tools/versions` | npm-managed check and newest npm version per npm-installed tool, cached 24 h. The AI Tools modal no longer calls it: Update shows for every updater. |
| `GET` | `/api/ai-tools/:id/icon` | The server-cached favicon (`server/lib/ai-tool-icons.js`), raster only; `204` when there is none (a normal state — no console error per letter avatar), `404` for an unknown id. |
| `POST` | `/api/lanes/:id/git/:action` | Same-origin guarded git write, serialized under the lane lock: `fetch`, `pull` (always `--ff-only`), `push` (`-u origin HEAD`), `stage`/`unstage`/`discard` (per file; a path must be one git reports changed, and `discard` needs `confirm:true`), `stage-hunk`/`discard-hunk` (one hunk by its `@@` line, re-read fresh — `409 EHUNKGONE` when it moved; `discard-hunk` needs `confirm:true`), `commit` (`add -A` + `-m`, or `only:"staged"`), `switch` (refuses a dirty tree), `branch-create` (never switches) `branch-delete` (`-d`, refuses protected/current/busy branches) and `publish` (`{url}`: `remote add origin` + `push -u origin HEAD`, only when the repo has no remote; URL allowlisted to https-without-userinfo/ssh/scp-like; remote rolled back on a failed push). `merge` (clean tree, repo-root lane, a ref that exists; conflicts come back as `conflicts[]`), `resolve` (`side: ours|theirs|done`, conflicted paths only, `done` refuses leftover markers), `merge-commit`, `merge-abort` (`confirm:true`); `EMERGING` blocks pull/switch/merge/hunk actions mid-merge. No rebase/reset/stash by design. A remote wanting a login is `409 EGITAUTH` with `auth: {protocol, host, username, rejected}`; resend with `credentials: {protocol, host, username, password, remember}` (a repo remote's host only, else `400 EBADHOST`; saved only after git succeeds and `remember` is set). |
@@ -595,7 +595,7 @@ renders a node as `done` (see `docs/LANES.md#stage-detection`).
A `codex` lane carries no pipeline, so nothing detects or advances its stage.
Codex's hooks are opt-in (`server/lib/codex-hooks.js`, installed from Settings →
Codex → AgentHub and trusted by the user inside Codex); a hook posted with
Codex → Overview and trusted by the user inside Codex); a hook posted with
`tool: "codex"` reaches only the codex lane at its cwd and creates a
`tool = 'codex'` session (see ARCHITECTURE.md, **Codex hooks**). Otherwise
`server/lib/codex-ingest.js` only mirrors its `status` — skipping a lane whose
+7 -1
View File
@@ -371,7 +371,12 @@ describe("jobs", () => {
}
});
it("update-all runs the badged npm tools and every self updater", async () => {
it("update-all runs every npm-managed tool and every self updater, asking npm no version", async () => {
let viewed = 0;
versions.__setExec(async (_f, args) => {
if (args[0] === "view") viewed++;
return { stdout: args[0] === "prefix" ? `${prefix}\n` : "1.0.0\n" };
});
await jobs.startUpdateAll();
const done = await settle();
assert.equal(done.status, "ok");
@@ -379,5 +384,6 @@ describe("jobs", () => {
spawned.map((s) => s.argv.slice(1).join(" ")),
["install -g npm-tool@latest", "update --yes"]
);
assert.equal(viewed, 0);
});
});
+1 -1
View File
@@ -164,7 +164,7 @@ describe("install / update / icons", () => {
const { body } = await request("GET", "/api/ai-tools");
const codex = body.tools.find((t) => t.id === "codex");
assert.deepEqual(codex.install, { kind: "npm", package: "@openai/codex" });
assert.deepEqual(codex.update, { kind: "npm" });
assert.deepEqual(codex.update, { kind: "self", args: ["update"] }); // standalone or npm
assert.equal(body.catalog.source, "builtin");
});
+4 -1
View File
@@ -14,6 +14,9 @@
"package": "@openai/codex"
},
"update": {
"kind": "npm"
"kind": "self",
"args": [
"update"
]
}
}
+8 -1
View File
@@ -15,5 +15,12 @@
"--yolo"
],
"homepageUrl": "https://hermes-agent.nousresearch.com/docs/",
"faviconDomain": "nousresearch.com"
"faviconDomain": "nousresearch.com",
"update": {
"kind": "self",
"args": [
"update",
"--yes"
]
}
}
+8 -1
View File
@@ -9,5 +9,12 @@
"delayMs": 3000
},
"homepageUrl": "https://github.com/openclaw/openclaw",
"faviconDomain": "openclaw.ai"
"faviconDomain": "openclaw.ai",
"update": {
"kind": "self",
"args": [
"update",
"--yes"
]
}
}
+7 -5
View File
@@ -247,17 +247,19 @@ async function start(id, action) {
return view(j);
}
/** One job that updates every npm-managed tool with a newer version and every self-updater. */
/**
* One job that runs every installed tool's updater — its own, or npm's when
* npm manages it (one installed another way is skipped). No version lookup:
* each updater knows whether it has anything to do.
*/
async function startUpdateAll() {
claim();
let steps = [];
try {
const v = await versions.versions();
for (const t of aiTools.list()) {
if (!aiTools.detect(t.id).installed || !t.update) continue;
if (t.update.kind === "self" || (t.update.kind === "npm" && v[t.id]?.updateAvailable)) {
steps.push(...(await stepsFor(t.id, "update")));
}
if (t.update.kind === "npm" && !(await versions.npmManaged(t.id))) continue;
steps.push(...(await stepsFor(t.id, "update")));
}
if (!steps.length) throw makeErr("ENOTHING", "no AI tool has an update to run");
} finally {