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
@@ -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();
});
});