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:
+4
-4
File diff suppressed because one or more lines are too long
+1
-1
@@ -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`
|
- **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
|
- **Activity Feed** — events streaming in as Claude Code uses tools
|
||||||
- **Dashboard** — stats updating in real-time
|
- **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).
|
If nothing appears after 30 seconds, see [SETUP.md](./SETUP.md#troubleshooting).
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
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
|
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,
|
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
|
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
|
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
|
tool call lands in the session's timeline, a codex lane turns running/idle as
|
||||||
|
|||||||
@@ -133,10 +133,10 @@ npm run clear-data
|
|||||||
### Data management via Settings page
|
### Data management via Settings page
|
||||||
|
|
||||||
The Settings page (`/settings`) provides a UI for the following. **Model Pricing**
|
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
|
and **Hook Configuration** are not on its Dashboard tab: they sit under the
|
||||||
Code tab's **AgentHub** sub-tab (Settings → Claude Code → AgentHub), beside
|
Claude Code tab's **Overview** (Settings → Claude Code → Overview), beside
|
||||||
CLAUDE_HOME; Codex and Antigravity have their own pricing and hooks in their
|
CLAUDE_HOME; Codex (its Overview) and Antigravity have their own pricing and
|
||||||
own tabs.
|
hooks in their own tabs.
|
||||||
|
|
||||||
- **Model Pricing** — view and edit per-model cost rates, reset to defaults, add custom models
|
- **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
|
- **Hook Configuration** — check which hooks are installed and reinstall them
|
||||||
|
|||||||
+17
-4
@@ -188,10 +188,12 @@ client/
|
|||||||
│ │ ├── RemoteSources.tsx # Remote Data Sources settings panel (SSH multi-machine collection)
|
│ │ ├── 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)
|
│ │ ├── SessionToolBadge.tsx # The CLI that produced a non-Claude session, by its AI Tools label (nothing for Claude)
|
||||||
│ │ ├── settings/ # Settings panels, among them:
|
│ │ ├── 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
|
│ │ │ ├── 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)
|
│ │ │ ├── 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
|
│ │ │ └── AntigravityHooksSettings.tsx # Antigravity → Antigravity hooks card
|
||||||
│ │ └── workflows/ # Workflows page panels: own-pipeline stats + D3 analytics (11 files)
|
│ │ └── workflows/ # Workflows page panels: own-pipeline stats + D3 analytics (11 files)
|
||||||
│ │
|
│ │
|
||||||
@@ -247,7 +249,7 @@ graph TB
|
|||||||
Detail[SessionDetail<br/>agent hierarchy + timeline]
|
Detail[SessionDetail<br/>agent hierarchy + timeline]
|
||||||
Feed[ActivityFeed<br/>streaming event log]
|
Feed[ActivityFeed<br/>streaming event log]
|
||||||
Workflows[Workflows<br/>Lane pipelines + D3 analytics]
|
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
|
App --> Layout
|
||||||
Layout --> Dashboard & Sessions & Detail & Feed & Workflows & Settings
|
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).
|
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
|
### State Strategy
|
||||||
|
|
||||||
@@ -763,6 +765,17 @@ closes. Consequences worth knowing:
|
|||||||
expanding a truncated **number** inside a line of text, but draws the same
|
expanding a truncated **number** inside a line of text, but draws the same
|
||||||
surface so the two read as one thing.
|
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
|
## Utilities
|
||||||
|
|||||||
Generated
+1484
-8
File diff suppressed because it is too large
Load Diff
+3
-1
@@ -23,7 +23,9 @@
|
|||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
"react-i18next": "^17.0.4",
|
"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": {
|
"devDependencies": {
|
||||||
"@testing-library/jest-dom": "^6.9.1",
|
"@testing-library/jest-dom": "^6.9.1",
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,10 +2,11 @@
|
|||||||
* @file PageTabs.tsx
|
* @file PageTabs.tsx
|
||||||
* @description Shared top-of-page tab bar used by the pages that merged several
|
* @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 -
|
* 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>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
*/
|
*/
|
||||||
import type { ComponentType } from "react";
|
import type { ComponentType, ReactNode } from "react";
|
||||||
|
|
||||||
export interface PageTab {
|
export interface PageTab {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -18,6 +19,8 @@ interface PageTabsProps {
|
|||||||
activeId: string;
|
activeId: string;
|
||||||
onSelect: (id: string) => void;
|
onSelect: (id: string) => void;
|
||||||
ariaLabel: string;
|
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.
|
* @param props - Tabs to render, the active tab id, and the selection callback.
|
||||||
* @returns The tab bar element.
|
* @returns The tab bar element.
|
||||||
*/
|
*/
|
||||||
export function PageTabs({ tabs, activeId, onSelect, ariaLabel }: PageTabsProps) {
|
export function PageTabs({ tabs, activeId, onSelect, ariaLabel, actions }: PageTabsProps) {
|
||||||
return (
|
const list = (
|
||||||
<div
|
<div
|
||||||
role="tablist"
|
role="tablist"
|
||||||
aria-label={ariaLabel}
|
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 }) => {
|
{tabs.map(({ id, label, Icon }) => {
|
||||||
const isActive = id === activeId;
|
const isActive = id === activeId;
|
||||||
@@ -54,4 +59,11 @@ export function PageTabs({ tabs, activeId, onSelect, ariaLabel }: PageTabsProps)
|
|||||||
})}
|
})}
|
||||||
</div>
|
</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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -3,15 +3,17 @@
|
|||||||
* @description Read-only file-content modal for the config explorer. Takes
|
* @description Read-only file-content modal for the config explorer. Takes
|
||||||
* already-fetched data as props (path/data/error) rather than fetching
|
* already-fetched data as props (path/data/error) rather than fetching
|
||||||
* itself, so it stays tool-agnostic — the orchestrator (Claude or Codex
|
* 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>
|
* @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 { 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 type { CcFileResponse } from "../../lib/api";
|
||||||
import { CopyButton } from "./configPrimitives";
|
import { CopyButton } from "./configPrimitives";
|
||||||
|
import { MarkdownPreview, isMarkdown } from "../MarkdownPreview";
|
||||||
|
|
||||||
export function FileViewer({
|
export function FileViewer({
|
||||||
state,
|
state,
|
||||||
@@ -21,6 +23,8 @@ export function FileViewer({
|
|||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation("ccConfig");
|
const { t } = useTranslation("ccConfig");
|
||||||
|
const [preview, setPreview] = useState(false);
|
||||||
|
const canPreview = isMarkdown(state.path) && !!state.data;
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const onKey = (e: KeyboardEvent) => {
|
const onKey = (e: KeyboardEvent) => {
|
||||||
if (e.key === "Escape") onClose();
|
if (e.key === "Escape") onClose();
|
||||||
@@ -42,6 +46,17 @@ export function FileViewer({
|
|||||||
<span className="font-mono text-[12px] text-fg-secondary truncate flex-1">
|
<span className="font-mono text-[12px] text-fg-secondary truncate flex-1">
|
||||||
{state.path}
|
{state.path}
|
||||||
</span>
|
</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} />
|
<CopyButton value={state.path} />
|
||||||
<button
|
<button
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
@@ -59,6 +74,13 @@ export function FileViewer({
|
|||||||
</div>
|
</div>
|
||||||
) : !state.data ? (
|
) : !state.data ? (
|
||||||
<div className="text-sm text-fg-muted">…</div>
|
<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">
|
<pre className="text-[11px] font-mono text-fg-secondary whitespace-pre-wrap break-words">
|
||||||
{state.data.text}
|
{state.data.text}
|
||||||
|
|||||||
@@ -19,7 +19,7 @@
|
|||||||
|
|
||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { FolderOpen } from "lucide-react";
|
import { ChevronDown, FolderOpen } from "lucide-react";
|
||||||
import { ConfirmModal } from "../ConfirmModal";
|
import { ConfirmModal } from "../ConfirmModal";
|
||||||
import { CwdAutocomplete } from "../run/RunSetup";
|
import { CwdAutocomplete } from "../run/RunSetup";
|
||||||
import { FolderBrowseModal } from "./FolderBrowseModal";
|
import { FolderBrowseModal } from "./FolderBrowseModal";
|
||||||
@@ -27,6 +27,7 @@ import { api } from "../../lib/api";
|
|||||||
import type { AiRouter, CwdSuggestion } from "../../lib/api";
|
import type { AiRouter, CwdSuggestion } from "../../lib/api";
|
||||||
import type { Lane, LaneWorktree } from "../../lib/types";
|
import type { Lane, LaneWorktree } from "../../lib/types";
|
||||||
import { useAiTools } from "../../hooks/useAiTools";
|
import { useAiTools } from "../../hooks/useAiTools";
|
||||||
|
import { AiToolIcon } from "../AiToolIcon";
|
||||||
|
|
||||||
/** Polls the lane until the background `git worktree add` finishes (status
|
/** Polls the lane until the background `git worktree add` finishes (status
|
||||||
* leaves "provisioning"), or gives up after `timeoutMs`. Returns the final
|
* leaves "provisioning"), or gives up after `timeoutMs`. Returns the final
|
||||||
@@ -115,7 +116,17 @@ export function AddLaneModal({
|
|||||||
const aiTools = useAiTools();
|
const aiTools = useAiTools();
|
||||||
const defaultTool = aiTools.defaultId ?? "claude";
|
const defaultTool = aiTools.defaultId ?? "claude";
|
||||||
const tool = pickedTool ?? defaultTool;
|
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 noTools = aiTools.loaded && aiTools.defaultId === null;
|
||||||
const toolRouterCapable =
|
const toolRouterCapable =
|
||||||
aiTools.byId(tool)?.routerCapable ?? (tool === "claude" || tool === "codex");
|
aiTools.byId(tool)?.routerCapable ?? (tool === "claude" || tool === "codex");
|
||||||
@@ -431,38 +442,51 @@ export function AddLaneModal({
|
|||||||
</a>
|
</a>
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex items-center gap-1.5">
|
<div>
|
||||||
<div className="flex items-center rounded-md border border-border bg-surface-2 p-0.5">
|
<button
|
||||||
<Seg
|
type="button"
|
||||||
active={tool === defaultTool}
|
aria-expanded={toolListOpen}
|
||||||
label={t("addLaneToolDefault", { tool: aiTools.label(defaultTool) })}
|
disabled={toolChoices.length < 2}
|
||||||
onClick={() => setPickedTool(null)}
|
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" : ""}`}
|
||||||
/>
|
/>
|
||||||
</div>
|
)}
|
||||||
{otherTools.length > 0 && (
|
</button>
|
||||||
<select
|
{toolListOpen && (
|
||||||
aria-label={t("addLaneToolOther")}
|
<div
|
||||||
value={tool === defaultTool ? "" : tool}
|
role="group"
|
||||||
onChange={(e) => {
|
aria-label={t("addLaneToolHint")}
|
||||||
const next = e.target.value || null;
|
className="mt-1.5 grid grid-cols-2 gap-1.5 sm:grid-cols-3"
|
||||||
setPickedTool(next);
|
>
|
||||||
// A tool with no endpoint to rewrite runs native only
|
{toolChoices.map((x) => (
|
||||||
// (see router-apply.js).
|
<button
|
||||||
if (next && !aiTools.byId(next)?.routerCapable) setRouter("native");
|
key={x.id}
|
||||||
}}
|
type="button"
|
||||||
className={`rounded-md border bg-surface-2 px-2 py-1 text-xs ${
|
aria-pressed={x.id === tool}
|
||||||
tool !== defaultTool
|
onClick={() => pickTool(x.id)}
|
||||||
? "border-accent text-accent"
|
className={`flex items-center gap-2 rounded-md border px-2 py-1.5 text-left text-xs transition-colors ${
|
||||||
: "border-border text-fg-secondary"
|
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"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<option value="">{t("addLaneToolOther")}</option>
|
<AiToolIcon tool={x} className="h-5 w-5" />
|
||||||
{otherTools.map((x) => (
|
<span className="min-w-0 flex-1 truncate">{x.label}</span>
|
||||||
<option key={x.id} value={x.id}>
|
</button>
|
||||||
{x.label}
|
|
||||||
</option>
|
|
||||||
))}
|
))}
|
||||||
</select>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -5,7 +5,8 @@
|
|||||||
* overview — every changed file with its +/- counts and its stage/discard
|
* overview — every changed file with its +/- counts and its stage/discard
|
||||||
* actions; click one to open it
|
* actions; click one to open it
|
||||||
* review — every file's full diff, stacked, for reading a whole change-set
|
* 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
|
* history — recent commits; opening one shows that commit's diff
|
||||||
* browse — quick-open by path, or the repo's tracked file tree
|
* browse — quick-open by path, or the repo's tracked file tree
|
||||||
*
|
*
|
||||||
@@ -38,7 +39,9 @@ import {
|
|||||||
ChevronsDownUp,
|
ChevronsDownUp,
|
||||||
ChevronsUpDown,
|
ChevronsUpDown,
|
||||||
Check,
|
Check,
|
||||||
|
Code2,
|
||||||
Columns2,
|
Columns2,
|
||||||
|
Eye,
|
||||||
FileText,
|
FileText,
|
||||||
FoldVertical,
|
FoldVertical,
|
||||||
Folder,
|
Folder,
|
||||||
@@ -53,6 +56,7 @@ import {
|
|||||||
import { api } from "../../lib/api";
|
import { api } from "../../lib/api";
|
||||||
import { LaneDiffView, LaneFileView, type DiffLayout, type LaneDiffChange } from "./LaneDiffView";
|
import { LaneDiffView, LaneFileView, type DiffLayout, type LaneDiffChange } from "./LaneDiffView";
|
||||||
import { LaneConflictView } from "./LaneConflictView";
|
import { LaneConflictView } from "./LaneConflictView";
|
||||||
|
import { MarkdownPreview, isMarkdown } from "../MarkdownPreview";
|
||||||
import { LaneGitHeader, useLaneGitFacts } from "./LaneGitHeader";
|
import { LaneGitHeader, useLaneGitFacts } from "./LaneGitHeader";
|
||||||
import type {
|
import type {
|
||||||
LaneChangeFile,
|
LaneChangeFile,
|
||||||
@@ -435,6 +439,9 @@ export function LaneChangesPanel({
|
|||||||
const [context, setContext] = useState(CONTEXT_STEPS[0]);
|
const [context, setContext] = useState(CONTEXT_STEPS[0]);
|
||||||
const [layout, setLayout] = useState<DiffLayout>("unified");
|
const [layout, setLayout] = useState<DiffLayout>("unified");
|
||||||
const [whole, setWhole] = useState(false);
|
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 [data, setData] = useState<LaneChanges | null>(null);
|
||||||
const [commits, setCommits] = useState<LaneCommit[] | null>(null);
|
const [commits, setCommits] = useState<LaneCommit[] | null>(null);
|
||||||
// A folder's children, keyed by its path ("" is the root) — fetched once,
|
// A folder's children, keyed by its path ("" is the root) — fetched once,
|
||||||
@@ -1429,6 +1436,18 @@ export function LaneChangesPanel({
|
|||||||
binary={current.binary}
|
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. */}
|
{/* Only the working file is editable: a commit is history. */}
|
||||||
{/* The whole file enters the editor by a click on its text;
|
{/* The whole file enters the editor by a click on its text;
|
||||||
only the diff, which has no whole text to click, keeps
|
only the diff, which has no whole text to click, keeps
|
||||||
@@ -1442,7 +1461,8 @@ export function LaneChangesPanel({
|
|||||||
{t(noEditReason(text))}
|
{t(noEditReason(text))}
|
||||||
</span>
|
</span>
|
||||||
) : draft === null && whole ? (
|
) : draft === null && whole ? (
|
||||||
text && (
|
text &&
|
||||||
|
!mdPreview && (
|
||||||
<span className="shrink-0 self-center text-[10px] text-fg-muted">
|
<span className="shrink-0 self-center text-[10px] text-fg-muted">
|
||||||
{t("changes.clickToEdit")}
|
{t("changes.clickToEdit")}
|
||||||
</span>
|
</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"
|
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 ? (
|
) : 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
|
<LaneFileView
|
||||||
path={text.path}
|
path={text.path}
|
||||||
lines={text.lines}
|
lines={text.lines}
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
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 userEvent from "@testing-library/user-event";
|
||||||
import { AddLaneModal } from "../AddLaneModal";
|
import { AddLaneModal } from "../AddLaneModal";
|
||||||
import { api } from "../../../lib/api";
|
import { api } from "../../../lib/api";
|
||||||
@@ -32,7 +32,7 @@ vi.mock("../../../lib/api", () => ({
|
|||||||
routerApply: vi.fn(),
|
routerApply: vi.fn(),
|
||||||
},
|
},
|
||||||
aiRouters: { list: 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() },
|
run: { start: vi.fn() },
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
@@ -784,14 +784,20 @@ describe("AddLaneModal — tool selection", () => {
|
|||||||
const dirField = screen.getByLabelText("Directory");
|
const dirField = screen.getByLabelText("Directory");
|
||||||
await focusField(user, dirField);
|
await focusField(user, dirField);
|
||||||
await user.type(dirField, "/tmp/demo");
|
await user.type(dirField, "/tmp/demo");
|
||||||
const other = await screen.findByRole("combobox", { name: "Other…" });
|
// One button showing the default; it opens every installed + enabled tool.
|
||||||
// Installed + enabled only, and never the default itself.
|
const picker = await screen.findByRole("button", { name: /Claude Code.*Default/ });
|
||||||
expect(Array.from((other as HTMLSelectElement).options).map((o) => o.value)).toEqual([
|
expect(picker).toHaveAttribute("aria-expanded", "false");
|
||||||
"",
|
await user.click(picker);
|
||||||
"codex",
|
const list = screen.getByRole("group", { name: /Which CLI drives this lane/ });
|
||||||
"gemini",
|
expect(
|
||||||
]);
|
within(list)
|
||||||
await user.selectOptions(other, "codex");
|
.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 user.click(screen.getByRole("button", { name: /add lane|create/i }));
|
||||||
|
|
||||||
await waitFor(() =>
|
await waitFor(() =>
|
||||||
@@ -802,7 +808,8 @@ describe("AddLaneModal — tool selection", () => {
|
|||||||
it("offers only Native for a tool that cannot use an AI Router", async () => {
|
it("offers only Native for a tool that cannot use an AI Router", async () => {
|
||||||
renderModal({});
|
renderModal({});
|
||||||
const user = userEvent.setup();
|
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: "Native (Gemini)" })).toBeChecked();
|
||||||
expect(screen.getByRole("radio", { name: "AI Router" })).toBeDisabled();
|
expect(screen.getByRole("radio", { name: "AI Router" })).toBeDisabled();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -321,6 +321,34 @@ describe("LaneChangesPanel", () => {
|
|||||||
expect(screen.getByText("Staged Changes")).toBeInTheDocument();
|
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 () => {
|
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({
|
vi.mocked(api.lanes.gitTree).mockResolvedValue({
|
||||||
available: true,
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,44 +1,32 @@
|
|||||||
/**
|
/**
|
||||||
* @file AiToolsSettings.tsx
|
* @file AiToolsSettings.tsx
|
||||||
* @description Settings → AI Tools: every AI coding CLI in the server's catalog
|
* @description The body of the AI Tools modal (Settings tab bar → AI Tools,
|
||||||
* (GET /api/ai-tools), split into the ones found on PATH — each with
|
* `AiToolsModal.tsx`): every AI coding CLI in the server's catalog
|
||||||
* Enabled/Disabled, Set default and, when the catalog names one, Update with a
|
* (GET /api/ai-tools). Installed ones are rows with an enable switch, a
|
||||||
* new-version badge — and the ones available to install, with Install where
|
* Default badge or Set default, and Update whenever the catalog names an
|
||||||
* the catalog knows how. Install/Update/Update tools confirm the exact command
|
* 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
|
* 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
|
* (polled from GET /api/ai-tools/job; a finished job folds to one line).
|
||||||
* and re-detects. Icons are the server's cached favicons, a letter otherwise.
|
* Refresh re-reads the login shell's PATH and re-detects. Icons come from
|
||||||
* A disabled tool is hidden from Add lane; existing lanes keep working.
|
* `AiToolIcon`. A disabled tool is hidden from Add lane; existing lanes keep
|
||||||
|
* working.
|
||||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
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 { useAiTools } from "../../hooks/useAiTools";
|
||||||
import { ConfirmModal } from "../ConfirmModal";
|
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" };
|
type Confirm = { kind: "install" | "update"; tool: AiTool } | { kind: "update-all" };
|
||||||
|
|
||||||
function Avatar({ tool }: { tool: AiTool }) {
|
const GHOST_BTN =
|
||||||
const [failed, setFailed] = useState(false);
|
"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";
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function Homepage({ tool }: { tool: AiTool }) {
|
function Homepage({ tool }: { tool: AiTool }) {
|
||||||
const { t } = useTranslation("settings");
|
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 }) {
|
function JobLog({ job }: { job: AiToolJob }) {
|
||||||
const { t } = useTranslation("settings");
|
const { t } = useTranslation("settings");
|
||||||
const ref = useRef<HTMLPreElement | null>(null);
|
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(() => {
|
useEffect(() => {
|
||||||
if (ref.current) ref.current.scrollTop = ref.current.scrollHeight;
|
if (ref.current) ref.current.scrollTop = ref.current.scrollHeight;
|
||||||
}, [job.log]);
|
}, [job.log, open]);
|
||||||
const state =
|
const state =
|
||||||
job.status === "running"
|
job.status === "running"
|
||||||
? t("aiTools.jobRunning")
|
? t("aiTools.jobRunning")
|
||||||
@@ -71,18 +98,29 @@ function JobLog({ job }: { job: AiToolJob }) {
|
|||||||
: t("aiTools.jobFailed", { code: job.exitCode ?? "?" });
|
: t("aiTools.jobFailed", { code: job.exitCode ?? "?" });
|
||||||
return (
|
return (
|
||||||
<div className="mt-2 w-full">
|
<div className="mt-2 w-full">
|
||||||
<p
|
<div className="flex items-center gap-2 text-[11px]">
|
||||||
className={`text-[11px] ${job.status === "failed" ? "text-status-danger" : "text-fg-muted"}`}
|
<span className={job.status === "failed" ? "text-status-danger" : "text-fg-muted"}>
|
||||||
>
|
|
||||||
{state}
|
{state}
|
||||||
</p>
|
</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
|
<pre
|
||||||
ref={ref}
|
ref={ref}
|
||||||
data-testid="ai-tool-log"
|
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"
|
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}
|
{job.log}
|
||||||
</pre>
|
</pre>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -93,31 +131,16 @@ export function AiToolsSettings() {
|
|||||||
const [busy, setBusy] = useState<string | null>(null);
|
const [busy, setBusy] = useState<string | null>(null);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [job, setJob] = useState<AiToolJob | null>(null);
|
const [job, setJob] = useState<AiToolJob | null>(null);
|
||||||
const [versions, setVersions] = useState<Record<string, AiToolVersion>>({});
|
|
||||||
const [confirm, setConfirm] = useState<Confirm | null>(null);
|
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
|
// A modal opened mid-job picks it up. Deferred like useAiTools' load: an
|
||||||
// after the browser's own hash jump, so it has to scroll itself into view.
|
// unreachable server (or a mocked api without it) means "no job".
|
||||||
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.
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
Promise.resolve()
|
Promise.resolve()
|
||||||
.then(() => api.aiTools.job())
|
.then(() => api.aiTools.job())
|
||||||
.then(setJob)
|
.then(setJob)
|
||||||
.catch(() => undefined);
|
.catch(() => undefined);
|
||||||
void loadVersions();
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const running = job?.status === "running";
|
const running = job?.status === "running";
|
||||||
@@ -127,10 +150,7 @@ export function AiToolsSettings() {
|
|||||||
try {
|
try {
|
||||||
const j = await api.aiTools.job();
|
const j = await api.aiTools.job();
|
||||||
setJob(j);
|
setJob(j);
|
||||||
if (j?.status !== "running") {
|
if (j?.status !== "running") void reload();
|
||||||
void reload();
|
|
||||||
void loadVersions();
|
|
||||||
}
|
|
||||||
} catch {
|
} catch {
|
||||||
/* next tick */
|
/* next tick */
|
||||||
}
|
}
|
||||||
@@ -160,14 +180,10 @@ export function AiToolsSettings() {
|
|||||||
if (inst?.kind === "script") return t("aiTools.runScript", { url: inst.url });
|
if (inst?.kind === "script") return t("aiTools.runScript", { url: inst.url });
|
||||||
return "";
|
return "";
|
||||||
};
|
};
|
||||||
// Mirrors the server's update-all: badged npm tools, then every self updater.
|
const installed = tools.filter((x) => x.installed);
|
||||||
const updateAllTools = tools.filter(
|
const available = tools.filter((x) => !x.installed);
|
||||||
(x) =>
|
// Mirrors the server's update-all: every installed tool with an updater.
|
||||||
x.installed &&
|
const updateAllTools = installed.filter((x) => x.update);
|
||||||
(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 startConfirmed = async () => {
|
const startConfirmed = async () => {
|
||||||
const c = confirm;
|
const c = confirm;
|
||||||
@@ -186,8 +202,6 @@ export function AiToolsSettings() {
|
|||||||
|
|
||||||
const jobBusy = running || busy !== null;
|
const jobBusy = running || busy !== null;
|
||||||
const logFor = (id: string | null) => (job && job.tool === id ? <JobLog job={job} /> : 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 =
|
const confirmLines =
|
||||||
confirm?.kind === "update-all"
|
confirm?.kind === "update-all"
|
||||||
@@ -197,39 +211,29 @@ export function AiToolsSettings() {
|
|||||||
: [];
|
: [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section id="ai-tools" ref={sectionRef} className="scroll-mt-24">
|
<div className="space-y-5">
|
||||||
<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>
|
||||||
<div className="flex items-center justify-between gap-3 mb-2">
|
<div className="mb-2 flex items-center justify-between gap-3">
|
||||||
<p className="text-xs font-medium text-fg-secondary">
|
<p className="text-xs font-medium text-fg-secondary">
|
||||||
{t("aiTools.installed", { count: installed.length })}
|
{t("aiTools.installed", { count: installed.length })}
|
||||||
</p>
|
</p>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
|
{updateAllTools.length > 0 && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setConfirm({ kind: "update-all" })}
|
onClick={() => setConfirm({ kind: "update-all" })}
|
||||||
disabled={jobBusy || updateAllTools.length === 0}
|
disabled={jobBusy}
|
||||||
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"
|
className={GHOST_BTN}
|
||||||
>
|
>
|
||||||
<Download className="w-3.5 h-3.5" />
|
<Download className="w-3.5 h-3.5" />
|
||||||
{t("aiTools.updateTools")}
|
{t("aiTools.updateAll", { count: updateAllTools.length })}
|
||||||
</button>
|
</button>
|
||||||
|
)}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() =>
|
onClick={() => void run("refresh", refresh)}
|
||||||
void run("refresh", async () => {
|
|
||||||
await refresh();
|
|
||||||
await loadVersions();
|
|
||||||
})
|
|
||||||
}
|
|
||||||
disabled={busy !== null}
|
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"
|
className={GHOST_BTN}
|
||||||
>
|
>
|
||||||
<RefreshCw className={`w-3.5 h-3.5 ${busy === "refresh" ? "animate-spin" : ""}`} />
|
<RefreshCw className={`w-3.5 h-3.5 ${busy === "refresh" ? "animate-spin" : ""}`} />
|
||||||
{t("aiTools.refresh")}
|
{t("aiTools.refresh")}
|
||||||
@@ -241,24 +245,23 @@ export function AiToolsSettings() {
|
|||||||
{loaded && installed.length === 0 && (
|
{loaded && installed.length === 0 && (
|
||||||
<p className="text-xs text-fg-muted">{t("aiTools.noneInstalled")}</p>
|
<p className="text-xs text-fg-muted">{t("aiTools.noneInstalled")}</p>
|
||||||
)}
|
)}
|
||||||
<ul className="divide-y divide-border rounded-md border border-border">
|
<ul className="divide-y divide-border overflow-hidden rounded-lg border border-border bg-surface-2">
|
||||||
{installed.map((tool) => {
|
{installed.map((tool) => {
|
||||||
const isDefault = tool.id === defaultId;
|
const isDefault = tool.id === defaultId;
|
||||||
const v = versions[tool.id];
|
|
||||||
return (
|
return (
|
||||||
<li
|
<li key={tool.id} data-testid={`ai-tool-${tool.id}`} className="px-3 py-2.5">
|
||||||
key={tool.id}
|
<div className="flex items-center gap-3">
|
||||||
data-testid={`ai-tool-${tool.id}`}
|
<div
|
||||||
className={`flex flex-wrap items-center gap-3 px-3 py-2 ${tool.enabled ? "" : "opacity-60"}`}
|
className={`flex min-w-0 flex-1 items-center gap-3 ${tool.enabled ? "" : "opacity-50"}`}
|
||||||
>
|
>
|
||||||
<Avatar tool={tool} />
|
<AiToolIcon tool={tool} />
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0">
|
||||||
<div className="flex items-center gap-1.5 text-sm text-fg-primary">
|
<div className="flex items-center gap-1.5 text-sm font-medium text-fg-primary">
|
||||||
{tool.label}
|
<span className="truncate">{tool.label}</span>
|
||||||
<Homepage tool={tool} />
|
<Homepage tool={tool} />
|
||||||
{v?.updateAvailable && v.latest && (
|
{isDefault && (
|
||||||
<span className="rounded bg-accent/10 px-1.5 py-0.5 text-[10px] text-accent">
|
<span className="rounded-full bg-accent/15 px-2 py-0.5 text-[10px] font-medium text-accent">
|
||||||
{t("aiTools.newVersion", { version: v.latest })}
|
{t("aiTools.default")}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -267,51 +270,42 @@ export function AiToolsSettings() {
|
|||||||
title={tool.path ?? ""}
|
title={tool.path ?? ""}
|
||||||
>
|
>
|
||||||
{tool.command}
|
{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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{canUpdate(tool) && (
|
</div>
|
||||||
|
{/* Fixed slots, so the buttons line up from row to row. */}
|
||||||
|
<div className="flex min-w-20 shrink-0 justify-end">
|
||||||
|
{tool.update && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
disabled={jobBusy}
|
disabled={jobBusy}
|
||||||
onClick={() => setConfirm({ kind: "update", tool })}
|
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"
|
className={GHOST_BTN}
|
||||||
>
|
>
|
||||||
{t("aiTools.update")}
|
{t("aiTools.update")}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{isDefault ? (
|
</div>
|
||||||
<span className="text-xs text-accent">✓ {t("aiTools.default")}</span>
|
<div className="flex min-w-28 shrink-0 justify-end">
|
||||||
) : (
|
{!isDefault && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
disabled={!tool.enabled || busy !== null}
|
disabled={!tool.enabled || busy !== null}
|
||||||
onClick={() => void run(tool.id, () => update(tool.id, { default: true }))}
|
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"
|
className={GHOST_BTN}
|
||||||
>
|
>
|
||||||
{t("aiTools.setDefault")}
|
{t("aiTools.setDefault")}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<button
|
</div>
|
||||||
type="button"
|
<Switch
|
||||||
role="switch"
|
tool={tool}
|
||||||
aria-checked={tool.enabled}
|
|
||||||
aria-label={t("aiTools.enabledOf", { tool: tool.label })}
|
|
||||||
disabled={busy !== null}
|
disabled={busy !== null}
|
||||||
onClick={() =>
|
onToggle={() =>
|
||||||
void run(tool.id, () => update(tool.id, { enabled: !tool.enabled }))
|
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
|
</div>
|
||||||
? "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)}
|
{logFor(tool.id)}
|
||||||
</li>
|
</li>
|
||||||
);
|
);
|
||||||
@@ -321,25 +315,35 @@ export function AiToolsSettings() {
|
|||||||
|
|
||||||
{available.length > 0 && (
|
{available.length > 0 && (
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs font-medium text-fg-secondary mb-2">
|
<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 })}
|
{t("aiTools.available", { count: available.length })}
|
||||||
</p>
|
</button>
|
||||||
|
{showAvailable && (
|
||||||
<ul className="grid grid-cols-1 gap-2 sm:grid-cols-2 lg:grid-cols-3">
|
<ul className="grid grid-cols-1 gap-2 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
{available.map((tool) => (
|
{available.map((tool) => (
|
||||||
<li
|
<li
|
||||||
key={tool.id}
|
key={tool.id}
|
||||||
data-testid={`ai-tool-${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" : ""
|
job?.tool === tool.id ? "sm:col-span-2 lg:col-span-3" : ""
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<Avatar tool={tool} />
|
<AiToolIcon tool={tool} />
|
||||||
<span className="min-w-0 flex-1 truncate text-xs text-fg-secondary">
|
<span className="min-w-0 flex-1">
|
||||||
{tool.label}
|
<span className="block truncate text-xs text-fg-primary">{tool.label}</span>
|
||||||
<span className="ml-1.5 font-mono text-[10px] text-fg-muted">
|
<span className="block truncate font-mono text-[10px] text-fg-muted">
|
||||||
{tool.command}
|
{tool.command}
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
|
<Homepage tool={tool} />
|
||||||
{tool.install && (
|
{tool.install && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -350,14 +354,13 @@ export function AiToolsSettings() {
|
|||||||
{t("aiTools.install")}
|
{t("aiTools.install")}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<Homepage tool={tool} />
|
|
||||||
{logFor(tool.id)}
|
{logFor(tool.id)}
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<ConfirmModal
|
<ConfirmModal
|
||||||
open={confirm !== null}
|
open={confirm !== null}
|
||||||
@@ -384,6 +387,6 @@ export function AiToolsSettings() {
|
|||||||
{confirmLines.join("\n")}
|
{confirmLines.join("\n")}
|
||||||
</pre>
|
</pre>
|
||||||
</ConfirmModal>
|
</ConfirmModal>
|
||||||
</section>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
/**
|
/**
|
||||||
* @file AntigravityHooksSettings.tsx
|
* @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
|
* 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
|
* 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
|
* written until the user clicks; agy asks for no trust, and has no permission
|
||||||
|
|||||||
@@ -1,11 +1,10 @@
|
|||||||
/**
|
/**
|
||||||
* @file ClaudeAgentHubSettings.tsx
|
* @file ClaudeAgentHubSettings.tsx
|
||||||
* @description Settings → Claude Code → AgentHub: the dashboard's own settings
|
* @description The dashboard's own settings that belong to Claude Code, shown
|
||||||
* that belong to Claude Code — Model Pricing for Claude models (the shared
|
* under Settings → Claude Code → Overview: the CLAUDE_HOME the server reads
|
||||||
* `PricingSettings` table, with Reset Defaults), the AgentHub hooks installed
|
* transcripts and config from, the AgentHub hooks installed into Claude Code
|
||||||
* into Claude Code with a Reinstall button, and the CLAUDE_HOME the server
|
* with a Reinstall button, and Model Pricing for Claude models (the shared
|
||||||
* reads transcripts and config from. Moved here from Settings → Dashboard
|
* `PricingSettings` table, with Reset Defaults).
|
||||||
* (#40 part B).
|
|
||||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@@ -125,7 +124,32 @@ export function ClaudeAgentHubSettings({ onHomeChanged }: { onHomeChanged?: () =
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-8">
|
<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 && (
|
{error && (
|
||||||
<div className="px-4 py-2.5 bg-status-danger/10 border border-status-danger/20 rounded-lg text-sm text-status-danger">
|
<div className="px-4 py-2.5 bg-status-danger/10 border border-status-danger/20 rounded-lg text-sm text-status-danger">
|
||||||
{error}
|
{error}
|
||||||
@@ -192,32 +216,7 @@ export function ClaudeAgentHubSettings({ onHomeChanged }: { onHomeChanged?: () =
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<HomeDirSetting
|
<PricingSettings vendor="claude" allowReset />
|
||||||
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,
|
|
||||||
};
|
|
||||||
})
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
/**
|
/**
|
||||||
* @file CodexHooksSettings.tsx
|
* @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
|
* 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;
|
* 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
|
* Codex itself asks the user to trust the hooks before it runs them, and this
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
/**
|
/**
|
||||||
* @file HomeDirSetting.tsx
|
* @file HomeDirSetting.tsx
|
||||||
* @description One editable home directory (CLAUDE_HOME in Claude Code →
|
* @description One editable home directory (CLAUDE_HOME in Claude Code →
|
||||||
* AgentHub, CODEX_HOME in Codex → AgentHub): a path field with Save, the
|
* Overview, CODEX_HOME in Codex → Overview): a path field with Save, the
|
||||||
* server's refusal inline, the value in effect, and a warning when the real
|
* 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
|
* 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;
|
* saved value would not survive a restart). The server validates and persists;
|
||||||
* this component only loads, submits and reports.
|
* this component only loads, submits and reports.
|
||||||
@@ -105,7 +106,8 @@ export function HomeDirSetting({ id, title, description, note, placeholder, load
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{error && <p className="text-xs text-status-danger">{error}</p>}
|
{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">
|
<p className="text-xs text-fg-muted">
|
||||||
{t("homeDir.current")} <code className="text-fg-secondary">{current.value}</code>
|
{t("homeDir.current")} <code className="text-fg-secondary">{current.value}</code>
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
/**
|
/**
|
||||||
* @file PricingSettings.tsx
|
* @file PricingSettings.tsx
|
||||||
* @description Model Pricing — the per-model rates every session's cost is
|
* @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>…`,
|
* 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
|
* `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
|
* **Show all models** switch lists every row, and a row added outside the
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
/**
|
/**
|
||||||
* @file AiToolsSettings.test.tsx
|
* @file AiToolsSettings.test.tsx
|
||||||
* @description Settings → AI Tools: installed rows carry Enabled/Disabled and
|
* @description The AI Tools modal: the Settings tab bar's button and the
|
||||||
* Set default, available rows a homepage link, Refresh re-detects, a
|
* `#ai-tools` deep link open it, Escape closes it; installed rows carry an
|
||||||
* disabled tool's Settings tab disappears from the hub, and Install/Update
|
* enable switch and Set default, available rows fold behind a disclosure with
|
||||||
* confirm the exact command, then show the polled job log.
|
* 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>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@@ -29,7 +32,6 @@ vi.mock("../../../lib/api", async (importOriginal) => {
|
|||||||
updateTool: vi.fn(),
|
updateTool: vi.fn(),
|
||||||
updateAll: vi.fn(),
|
updateAll: vi.fn(),
|
||||||
job: vi.fn(),
|
job: vi.fn(),
|
||||||
versions: vi.fn(),
|
|
||||||
iconUrl: (id: string) => `/api/ai-tools/${id}/icon`,
|
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.update).mockReset().mockResolvedValue(VIEW);
|
||||||
vi.mocked(api.aiTools.refresh).mockReset().mockResolvedValue(VIEW);
|
vi.mocked(api.aiTools.refresh).mockReset().mockResolvedValue(VIEW);
|
||||||
vi.mocked(api.aiTools.job).mockReset().mockResolvedValue(null);
|
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.install).mockReset();
|
||||||
vi.mocked(api.aiTools.updateTool).mockReset();
|
vi.mocked(api.aiTools.updateTool).mockReset();
|
||||||
vi.mocked(api.aiTools.updateAll).mockReset();
|
vi.mocked(api.aiTools.updateAll).mockReset();
|
||||||
@@ -93,7 +94,7 @@ describe("AiToolsSettings", () => {
|
|||||||
it("splits installed tools from the ones available to install", async () => {
|
it("splits installed tools from the ones available to install", async () => {
|
||||||
render(<AiToolsSettings />);
|
render(<AiToolsSettings />);
|
||||||
expect(await screen.findByText("Installed (3 detected)")).toBeInTheDocument();
|
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");
|
const claude = screen.getByTestId("ai-tool-claude");
|
||||||
expect(within(claude).getByText(/Default/)).toBeInTheDocument();
|
expect(within(claude).getByText(/Default/)).toBeInTheDocument();
|
||||||
const aider = screen.getByTestId("ai-tool-aider");
|
const aider = screen.getByTestId("ai-tool-aider");
|
||||||
@@ -120,10 +121,8 @@ describe("AiToolsSettings", () => {
|
|||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
render(<AiToolsSettings />);
|
render(<AiToolsSettings />);
|
||||||
await screen.findByTestId("ai-tool-claude");
|
await screen.findByTestId("ai-tool-claude");
|
||||||
const before = vi.mocked(api.aiTools.versions).mock.calls.length;
|
|
||||||
await user.click(screen.getByRole("button", { name: "Refresh" }));
|
await user.click(screen.getByRole("button", { name: "Refresh" }));
|
||||||
expect(api.aiTools.refresh).toHaveBeenCalledOnce();
|
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(() => {
|
beforeEach(() => {
|
||||||
vi.mocked(api.aiTools.list).mockResolvedValue(A2);
|
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 () => {
|
it("confirms the exact npm command, then polls and shows the log", async () => {
|
||||||
vi.useFakeTimers({ shouldAdvanceTime: true });
|
vi.useFakeTimers({ shouldAdvanceTime: true });
|
||||||
@@ -169,6 +166,7 @@ describe("AiToolsSettings — install and update", () => {
|
|||||||
.mockResolvedValueOnce(null)
|
.mockResolvedValueOnce(null)
|
||||||
.mockResolvedValue(job({ status: "ok", exitCode: 0, log: "added 1 package\n" }));
|
.mockResolvedValue(job({ status: "ok", exitCode: 0, log: "added 1 package\n" }));
|
||||||
render(<AiToolsSettings />);
|
render(<AiToolsSettings />);
|
||||||
|
await user.click(await screen.findByRole("button", { name: /Available to install/ }));
|
||||||
const qwen = await screen.findByTestId("ai-tool-qwen");
|
const qwen = await screen.findByTestId("ai-tool-qwen");
|
||||||
await user.click(within(qwen).getByRole("button", { name: "Install" }));
|
await user.click(within(qwen).getByRole("button", { name: "Install" }));
|
||||||
expect(screen.getByTestId("ai-tool-confirm-command")).toHaveTextContent(
|
expect(screen.getByTestId("ai-tool-confirm-command")).toHaveTextContent(
|
||||||
@@ -182,6 +180,8 @@ describe("AiToolsSettings — install and update", () => {
|
|||||||
vi.advanceTimersByTime(1100);
|
vi.advanceTimersByTime(1100);
|
||||||
});
|
});
|
||||||
expect(await within(qwen).findByText("Done")).toBeInTheDocument();
|
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");
|
expect(within(qwen).getByTestId("ai-tool-log")).toHaveTextContent("added 1 package");
|
||||||
const calls = vi.mocked(api.aiTools.job).mock.calls.length;
|
const calls = vi.mocked(api.aiTools.job).mock.calls.length;
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
@@ -196,6 +196,7 @@ describe("AiToolsSettings — install and update", () => {
|
|||||||
it("names the vendor script for a script install", async () => {
|
it("names the vendor script for a script install", async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
render(<AiToolsSettings />);
|
render(<AiToolsSettings />);
|
||||||
|
await openAvailable();
|
||||||
const cursor = await screen.findByTestId("ai-tool-cursor");
|
const cursor = await screen.findByTestId("ai-tool-cursor");
|
||||||
await user.click(within(cursor).getByRole("button", { name: "Install" }));
|
await user.click(within(cursor).getByRole("button", { name: "Install" }));
|
||||||
expect(screen.getByTestId("ai-tool-confirm-command")).toHaveTextContent(
|
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 />);
|
render(<AiToolsSettings />);
|
||||||
const gemini = await screen.findByTestId("ai-tool-gemini");
|
for (const id of ["claude", "codex", "gemini"]) {
|
||||||
expect(await within(gemini).findByText("v0.61.0 available")).toBeInTheDocument();
|
const row = await screen.findByTestId(`ai-tool-${id}`);
|
||||||
expect(within(gemini).getByRole("button", { name: "Update" })).toBeInTheDocument();
|
expect(within(row).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();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
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();
|
const user = userEvent.setup();
|
||||||
render(<AiToolsSettings />);
|
render(<AiToolsSettings />);
|
||||||
await screen.findByText("v0.61.0 available");
|
await user.click(await screen.findByRole("button", { name: "Update all (3)" }));
|
||||||
await user.click(screen.getByRole("button", { name: "Update tools" }));
|
|
||||||
const cmd = screen.getByTestId("ai-tool-confirm-command");
|
const cmd = screen.getByTestId("ai-tool-confirm-command");
|
||||||
expect(cmd).toHaveTextContent("claude update");
|
expect(cmd).toHaveTextContent("claude update");
|
||||||
expect(cmd).toHaveTextContent("npm install -g @google/gemini-cli@latest");
|
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);
|
vi.mocked(api.aiTools.list).mockResolvedValue(VIEW);
|
||||||
render(<AiToolsSettings />);
|
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 () => {
|
it("falls back to the letter when the icon fails", async () => {
|
||||||
const { container } = render(<AiToolsSettings />);
|
const { container } = render(<AiToolsSettings />);
|
||||||
|
await openAvailable();
|
||||||
await screen.findByTestId("ai-tool-qwen");
|
await screen.findByTestId("ai-tool-qwen");
|
||||||
const img = container.querySelector('img[src="/api/ai-tools/qwen/icon"]')!;
|
const img = container.querySelector('img[src="/api/ai-tools/qwen/icon"]')!;
|
||||||
expect(img).toBeTruthy();
|
expect(img).toBeTruthy();
|
||||||
@@ -256,4 +256,42 @@ describe("SettingsHub — AI tool tabs", () => {
|
|||||||
expect(await screen.findByRole("tab", { name: /Claude Code/ })).toBeInTheDocument();
|
expect(await screen.findByRole("tab", { name: /Claude Code/ })).toBeInTheDocument();
|
||||||
expect(screen.queryByRole("tab", { name: /^Codex/ })).toBeNull();
|
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>
|
* @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 userEvent from "@testing-library/user-event";
|
||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import i18n from "i18next";
|
import i18n from "i18next";
|
||||||
@@ -43,11 +43,15 @@ describe("HomeDirSetting", () => {
|
|||||||
expect(button).toBeDisabled();
|
expect(button).toBeDisabled();
|
||||||
expect(container.querySelector("section#x-home")).not.toBeNull();
|
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.clear(input);
|
||||||
await user.type(input, "/b");
|
await user.type(input, "/b");
|
||||||
|
expect(screen.getByText("/a", { selector: "code" })).toBeInTheDocument();
|
||||||
await user.click(button);
|
await user.click(button);
|
||||||
expect(saveFn).toHaveBeenCalledWith("/b");
|
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();
|
expect(screen.queryByText(/set by the environment/)).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -24,8 +24,7 @@
|
|||||||
"hooks": "Hooks",
|
"hooks": "Hooks",
|
||||||
"keybindings": "Keybindings",
|
"keybindings": "Keybindings",
|
||||||
"settings": "Settings",
|
"settings": "Settings",
|
||||||
"memory": "Memory",
|
"memory": "Memory"
|
||||||
"agenthub": "AgentHub"
|
|
||||||
},
|
},
|
||||||
"overview": {
|
"overview": {
|
||||||
"rootsTitle": "Roots",
|
"rootsTitle": "Roots",
|
||||||
@@ -51,7 +50,9 @@
|
|||||||
"modified": "Modified",
|
"modified": "Modified",
|
||||||
"scopeLabel": "Scope",
|
"scopeLabel": "Scope",
|
||||||
"noFrontmatter": "No frontmatter",
|
"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": {
|
"skills": {
|
||||||
"name": "Name",
|
"name": "Name",
|
||||||
|
|||||||
@@ -18,8 +18,7 @@
|
|||||||
"memory": "Memory",
|
"memory": "Memory",
|
||||||
"config": "Config",
|
"config": "Config",
|
||||||
"mcp": "MCP servers",
|
"mcp": "MCP servers",
|
||||||
"plugins": "Plugins",
|
"plugins": "Plugins"
|
||||||
"agenthub": "AgentHub"
|
|
||||||
},
|
},
|
||||||
"overview": {
|
"overview": {
|
||||||
"rootsTitle": "Roots",
|
"rootsTitle": "Roots",
|
||||||
|
|||||||
@@ -53,8 +53,7 @@
|
|||||||
"addLaneSetupTitle": "Setup",
|
"addLaneSetupTitle": "Setup",
|
||||||
"addLaneTitleLabel": "Title",
|
"addLaneTitleLabel": "Title",
|
||||||
"addLaneTitlePlaceholder": "Optional",
|
"addLaneTitlePlaceholder": "Optional",
|
||||||
"addLaneToolDefault": "Default ({{tool}})",
|
"addLaneToolDefaultBadge": "Default",
|
||||||
"addLaneToolOther": "Other…",
|
|
||||||
"addLaneNoTools": "No AI tool is installed and enabled.",
|
"addLaneNoTools": "No AI tool is installed and enabled.",
|
||||||
"addLaneNoToolsLink": "Open Settings → AI Tools",
|
"addLaneNoToolsLink": "Open Settings → AI Tools",
|
||||||
"addLaneRouterNativeTool": "Native ({{tool}})",
|
"addLaneRouterNativeTool": "Native ({{tool}})",
|
||||||
@@ -258,6 +257,8 @@
|
|||||||
"changes.save": "Save",
|
"changes.save": "Save",
|
||||||
"changes.cancelEdit": "Cancel",
|
"changes.cancelEdit": "Cancel",
|
||||||
"changes.clickToEdit": "Click the text to edit",
|
"changes.clickToEdit": "Click the text to edit",
|
||||||
|
"changes.preview": "Preview",
|
||||||
|
"changes.raw": "Raw",
|
||||||
"changes.editorLabel": "Edit {{path}}",
|
"changes.editorLabel": "Edit {{path}}",
|
||||||
"changes.prevHunk": "Previous change",
|
"changes.prevHunk": "Previous change",
|
||||||
"changes.nextHunk": "Next change",
|
"changes.nextHunk": "Next change",
|
||||||
|
|||||||
@@ -530,9 +530,7 @@
|
|||||||
"homepageOf": "{{tool}} homepage",
|
"homepageOf": "{{tool}} homepage",
|
||||||
"install": "Install",
|
"install": "Install",
|
||||||
"update": "Update",
|
"update": "Update",
|
||||||
"updateTools": "Update tools",
|
"updateAll": "Update all ({{count}})",
|
||||||
"newVersion": "v{{version}} available",
|
|
||||||
"outsideNpm": "Installed outside npm — update it the way you installed it",
|
|
||||||
"runScript": "Download {{url}} and run it with bash",
|
"runScript": "Download {{url}} and run it with bash",
|
||||||
"confirmInstall": "Install {{tool}}?",
|
"confirmInstall": "Install {{tool}}?",
|
||||||
"confirmUpdate": "Update {{tool}}?",
|
"confirmUpdate": "Update {{tool}}?",
|
||||||
@@ -542,7 +540,9 @@
|
|||||||
"cancel": "Cancel",
|
"cancel": "Cancel",
|
||||||
"jobRunning": "Running…",
|
"jobRunning": "Running…",
|
||||||
"jobOk": "Done",
|
"jobOk": "Done",
|
||||||
"jobFailed": "Failed (exit {{code}})"
|
"jobFailed": "Failed (exit {{code}})",
|
||||||
|
"showLog": "Show log",
|
||||||
|
"hideLog": "Hide log"
|
||||||
},
|
},
|
||||||
"appearance": {
|
"appearance": {
|
||||||
"title": "Appearance",
|
"title": "Appearance",
|
||||||
|
|||||||
@@ -24,8 +24,7 @@
|
|||||||
"hooks": "Hooks",
|
"hooks": "Hooks",
|
||||||
"keybindings": "Phím tắt",
|
"keybindings": "Phím tắt",
|
||||||
"settings": "Cài đặt",
|
"settings": "Cài đặt",
|
||||||
"memory": "Bộ nhớ",
|
"memory": "Bộ nhớ"
|
||||||
"agenthub": "AgentHub"
|
|
||||||
},
|
},
|
||||||
"overview": {
|
"overview": {
|
||||||
"rootsTitle": "Thư mục gốc",
|
"rootsTitle": "Thư mục gốc",
|
||||||
@@ -51,7 +50,9 @@
|
|||||||
"modified": "Đã sửa",
|
"modified": "Đã sửa",
|
||||||
"scopeLabel": "Phạm vi",
|
"scopeLabel": "Phạm vi",
|
||||||
"noFrontmatter": "Không có frontmatter",
|
"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": {
|
"skills": {
|
||||||
"name": "Tên",
|
"name": "Tên",
|
||||||
|
|||||||
@@ -18,8 +18,7 @@
|
|||||||
"memory": "Bộ nhớ",
|
"memory": "Bộ nhớ",
|
||||||
"config": "Cấu hình",
|
"config": "Cấu hình",
|
||||||
"mcp": "Máy chủ MCP",
|
"mcp": "Máy chủ MCP",
|
||||||
"plugins": "Plugin",
|
"plugins": "Plugin"
|
||||||
"agenthub": "AgentHub"
|
|
||||||
},
|
},
|
||||||
"overview": {
|
"overview": {
|
||||||
"rootsTitle": "Thư mục gốc",
|
"rootsTitle": "Thư mục gốc",
|
||||||
|
|||||||
@@ -53,8 +53,7 @@
|
|||||||
"addLaneSetupTitle": "Thiết lập",
|
"addLaneSetupTitle": "Thiết lập",
|
||||||
"addLaneTitleLabel": "Tiêu đề",
|
"addLaneTitleLabel": "Tiêu đề",
|
||||||
"addLaneTitlePlaceholder": "Không bắt buộc",
|
"addLaneTitlePlaceholder": "Không bắt buộc",
|
||||||
"addLaneToolDefault": "Mặc định ({{tool}})",
|
"addLaneToolDefaultBadge": "Mặc định",
|
||||||
"addLaneToolOther": "Khác…",
|
|
||||||
"addLaneNoTools": "Chưa có AI tool nào được cài và bật.",
|
"addLaneNoTools": "Chưa có AI tool nào được cài và bật.",
|
||||||
"addLaneNoToolsLink": "Mở Cài đặt → AI Tools",
|
"addLaneNoToolsLink": "Mở Cài đặt → AI Tools",
|
||||||
"addLaneRouterNativeTool": "Native ({{tool}})",
|
"addLaneRouterNativeTool": "Native ({{tool}})",
|
||||||
@@ -258,6 +257,8 @@
|
|||||||
"changes.save": "Lưu",
|
"changes.save": "Lưu",
|
||||||
"changes.cancelEdit": "Huỷ",
|
"changes.cancelEdit": "Huỷ",
|
||||||
"changes.clickToEdit": "Bấm vào nội dung để sửa",
|
"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.editorLabel": "Sửa {{path}}",
|
||||||
"changes.prevHunk": "Thay đổi trước",
|
"changes.prevHunk": "Thay đổi trước",
|
||||||
"changes.nextHunk": "Thay đổi sau",
|
"changes.nextHunk": "Thay đổi sau",
|
||||||
|
|||||||
@@ -530,9 +530,7 @@
|
|||||||
"homepageOf": "Trang chủ {{tool}}",
|
"homepageOf": "Trang chủ {{tool}}",
|
||||||
"install": "Cài đặt",
|
"install": "Cài đặt",
|
||||||
"update": "Cập nhật",
|
"update": "Cập nhật",
|
||||||
"updateTools": "Cập nhật tool",
|
"updateAll": "Cập nhật tất cả ({{count}})",
|
||||||
"newVersion": "Có bản v{{version}}",
|
|
||||||
"outsideNpm": "Cài ngoài npm — cập nhật theo cách bạn đã cài",
|
|
||||||
"runScript": "Tải {{url}} và chạy bằng bash",
|
"runScript": "Tải {{url}} và chạy bằng bash",
|
||||||
"confirmInstall": "Cài {{tool}}?",
|
"confirmInstall": "Cài {{tool}}?",
|
||||||
"confirmUpdate": "Cập nhật {{tool}}?",
|
"confirmUpdate": "Cập nhật {{tool}}?",
|
||||||
@@ -542,7 +540,9 @@
|
|||||||
"cancel": "Huỷ",
|
"cancel": "Huỷ",
|
||||||
"jobRunning": "Đang chạy…",
|
"jobRunning": "Đang chạy…",
|
||||||
"jobOk": "Xong",
|
"jobOk": "Xong",
|
||||||
"jobFailed": "Lỗi (exit {{code}})"
|
"jobFailed": "Lỗi (exit {{code}})",
|
||||||
|
"showLog": "Xem log",
|
||||||
|
"hideLog": "Ẩn log"
|
||||||
},
|
},
|
||||||
"appearance": {
|
"appearance": {
|
||||||
"title": "Giao diện",
|
"title": "Giao diện",
|
||||||
|
|||||||
@@ -441,3 +441,142 @@
|
|||||||
.animate-pulse-dot {
|
.animate-pulse-dot {
|
||||||
animation: pulse-dot 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
|
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%;
|
||||||
|
}
|
||||||
|
|||||||
@@ -2011,7 +2011,6 @@ export const api = {
|
|||||||
/** GET /api/ai-tools/job - the current or last job, or null. */
|
/** GET /api/ai-tools/job - the current or last job, or null. */
|
||||||
job: () => request<AiToolJob | null>("/ai-tools/job"),
|
job: () => request<AiToolJob | null>("/ai-tools/job"),
|
||||||
/** GET /api/ai-tools/versions - npm version badges, cached a day server-side. */
|
/** 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. */
|
/** The server-cached icon; an <img> cannot send the bearer header, so the token rides the query. */
|
||||||
iconUrl: (id: string) => {
|
iconUrl: (id: string) => {
|
||||||
const token = dashboardToken();
|
const token = dashboardToken();
|
||||||
@@ -3090,15 +3089,6 @@ export interface AiToolJob {
|
|||||||
log: string;
|
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 {
|
export interface AiRouter {
|
||||||
/** Identifier, derived from the label. Stable: a lane stores this. */
|
/** Identifier, derived from the label. Stable: a lane stores this. */
|
||||||
name: string;
|
name: string;
|
||||||
|
|||||||
@@ -7,8 +7,9 @@
|
|||||||
* low-risk text-file surfaces (skills, agents, commands, output styles,
|
* low-risk text-file surfaces (skills, agents, commands, output styles,
|
||||||
* CLAUDE.md memory, and per-project file-based memory files). Plugins, MCP,
|
* CLAUDE.md memory, and per-project file-based memory files). Plugins, MCP,
|
||||||
* hooks-in-settings, and settings.json files stay read-only - those have
|
* 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
|
* concurrent-write races with the live CLI. Its Overview also holds the
|
||||||
* dashboard's own Claude Code settings (pricing, hooks, CLAUDE_HOME).
|
* 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>
|
* @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 { eventBus } from "../lib/eventBus";
|
||||||
import {
|
import {
|
||||||
Boxes,
|
Boxes,
|
||||||
Gauge,
|
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
Search,
|
Search,
|
||||||
Sparkles,
|
Sparkles,
|
||||||
@@ -181,8 +181,7 @@ type TabKey =
|
|||||||
| "hooks"
|
| "hooks"
|
||||||
| "keybindings"
|
| "keybindings"
|
||||||
| "settings"
|
| "settings"
|
||||||
| "memory"
|
| "memory";
|
||||||
| "agenthub";
|
|
||||||
|
|
||||||
interface TabDef {
|
interface TabDef {
|
||||||
key: TabKey;
|
key: TabKey;
|
||||||
@@ -203,7 +202,6 @@ const TABS: TabDef[] = [
|
|||||||
{ key: "keybindings", icon: Keyboard, i18nKey: "tabs.keybindings" },
|
{ key: "keybindings", icon: Keyboard, i18nKey: "tabs.keybindings" },
|
||||||
{ key: "settings", icon: SettingsIcon, i18nKey: "tabs.settings" },
|
{ key: "settings", icon: SettingsIcon, i18nKey: "tabs.settings" },
|
||||||
{ key: "outputStyles", icon: Palette, i18nKey: "tabs.outputStyles" },
|
{ key: "outputStyles", icon: Palette, i18nKey: "tabs.outputStyles" },
|
||||||
{ key: "agenthub", icon: Gauge, i18nKey: "tabs.agenthub" },
|
|
||||||
];
|
];
|
||||||
|
|
||||||
interface PageState {
|
interface PageState {
|
||||||
@@ -243,7 +241,7 @@ const EMPTY_STATE: PageState = {
|
|||||||
export function CcConfig() {
|
export function CcConfig() {
|
||||||
const { t } = useTranslation("ccConfig");
|
const { t } = useTranslation("ccConfig");
|
||||||
const [tab, setTab] = useState<TabKey>("overview");
|
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 [agenthubKey, setAgenthubKey] = useState(0);
|
||||||
const [scope, setScope] = useState<CcScope>("all");
|
const [scope, setScope] = useState<CcScope>("all");
|
||||||
const [data, setData] = useState<PageState>(EMPTY_STATE);
|
const [data, setData] = useState<PageState>(EMPTY_STATE);
|
||||||
@@ -530,11 +528,6 @@ export function CcConfig() {
|
|||||||
|
|
||||||
<Tabs current={tab} onSelect={setTab} counts={data.overview?.counts} />
|
<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">
|
<div className="rounded-xl border border-border bg-surface-1">
|
||||||
{tab !== "overview" && (
|
{tab !== "overview" && (
|
||||||
<div className="flex items-center gap-2 border-b border-border px-4 py-2.5">
|
<div className="flex items-center gap-2 border-b border-border px-4 py-2.5">
|
||||||
@@ -596,6 +589,11 @@ export function CcConfig() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</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)} />}
|
{viewer && <FileViewer state={viewer} onClose={() => setViewer(null)} />}
|
||||||
@@ -1069,12 +1067,6 @@ function OverviewPanel({ overview }: { overview: CcOverview | null }) {
|
|||||||
{t("overview.rootsTitle")}
|
{t("overview.rootsTitle")}
|
||||||
</h2>
|
</h2>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-2">
|
<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
|
<RootRow
|
||||||
icon={FolderTree}
|
icon={FolderTree}
|
||||||
tone="emerald"
|
tone="emerald"
|
||||||
|
|||||||
@@ -8,10 +8,10 @@
|
|||||||
* CLI-owned and only offer add/remove, mirroring the Claude Code panel's
|
* CLI-owned and only offer add/remove, mirroring the Claude Code panel's
|
||||||
* split between editable artifacts and CLI-driven surfaces. Codex has no
|
* split between editable artifacts and CLI-driven surfaces. Codex has no
|
||||||
* "project-local" scope and no per-project file-based memory - its scope
|
* "project-local" scope and no per-project file-based memory - its scope
|
||||||
* union is exactly "user" | "project". Its **AgentHub** sub-tab holds the
|
* union is exactly "user" | "project". Its **Overview** also holds the
|
||||||
* dashboard's own Codex settings: Model Pricing for OpenAI models (the shared
|
* dashboard's own Codex settings: CODEX_HOME (editable, so the overview's path
|
||||||
* `PricingSettings` table), AgentHub's Codex hooks (`CodexHooksSettings`)
|
* list leaves it out), AgentHub's Codex hooks (`CodexHooksSettings`) and Model
|
||||||
* and CODEX_HOME.
|
* Pricing for OpenAI models (the shared `PricingSettings` table).
|
||||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
* @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 { eventBus } from "../lib/eventBus";
|
||||||
import {
|
import {
|
||||||
Boxes,
|
Boxes,
|
||||||
Gauge,
|
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
Search,
|
Search,
|
||||||
Sparkles,
|
Sparkles,
|
||||||
@@ -82,16 +81,7 @@ import type {
|
|||||||
CodexSkill,
|
CodexSkill,
|
||||||
} from "../lib/api";
|
} from "../lib/api";
|
||||||
|
|
||||||
type TabKey =
|
type TabKey = "overview" | "skills" | "agents" | "rules" | "memory" | "config" | "mcp" | "plugins";
|
||||||
| "overview"
|
|
||||||
| "skills"
|
|
||||||
| "agents"
|
|
||||||
| "rules"
|
|
||||||
| "memory"
|
|
||||||
| "config"
|
|
||||||
| "mcp"
|
|
||||||
| "plugins"
|
|
||||||
| "agenthub";
|
|
||||||
|
|
||||||
interface TabDef {
|
interface TabDef {
|
||||||
key: TabKey;
|
key: TabKey;
|
||||||
@@ -108,7 +98,6 @@ const TABS: TabDef[] = [
|
|||||||
{ key: "config", icon: SettingsIcon, i18nKey: "tabs.config" },
|
{ key: "config", icon: SettingsIcon, i18nKey: "tabs.config" },
|
||||||
{ key: "mcp", icon: Server, i18nKey: "tabs.mcp" },
|
{ key: "mcp", icon: Server, i18nKey: "tabs.mcp" },
|
||||||
{ key: "plugins", icon: PlugZap, i18nKey: "tabs.plugins" },
|
{ key: "plugins", icon: PlugZap, i18nKey: "tabs.plugins" },
|
||||||
{ key: "agenthub", icon: Gauge, i18nKey: "tabs.agenthub" },
|
|
||||||
];
|
];
|
||||||
|
|
||||||
function isNamedArtifact(tab: TabKey): tab is "skills" | "agents" | "rules" {
|
function isNamedArtifact(tab: TabKey): tab is "skills" | "agents" | "rules" {
|
||||||
@@ -160,7 +149,7 @@ export function CodexConfig() {
|
|||||||
const { t } = useTranslation("codexConfig");
|
const { t } = useTranslation("codexConfig");
|
||||||
const { t: tSettings } = useTranslation("settings");
|
const { t: tSettings } = useTranslation("settings");
|
||||||
const [tab, setTab] = useState<TabKey>("overview");
|
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 [agenthubKey, setAgenthubKey] = useState(0);
|
||||||
// Bumped after a CODEX_HOME save: the hooks card reads the new home's hooks.json.
|
// Bumped after a CODEX_HOME save: the hooks card reads the new home's hooks.json.
|
||||||
const [hooksKey, setHooksKey] = useState(0);
|
const [hooksKey, setHooksKey] = useState(0);
|
||||||
@@ -400,39 +389,6 @@ export function CodexConfig() {
|
|||||||
|
|
||||||
<Tabs current={tab} onSelect={setTab} counts={data.overview?.counts} />
|
<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 key={agenthubKey} className="space-y-8">
|
|
||||||
<PricingSettings vendor="openai" id="codex-pricing" />
|
|
||||||
<CodexHooksSettings key={hooksKey} />
|
|
||||||
<HomeDirSetting
|
|
||||||
id="codex-home"
|
|
||||||
title={tSettings("codexHome.title")}
|
|
||||||
description={tSettings("codexHome.description")}
|
|
||||||
note={tSettings("codexHome.restartNote")}
|
|
||||||
placeholder={tSettings("codexHome.placeholder")}
|
|
||||||
load={() =>
|
|
||||||
api.settings.codexHome
|
|
||||||
.get()
|
|
||||||
.then((r) => ({ value: r.codex_home, envOwned: r.env_owned }))
|
|
||||||
}
|
|
||||||
save={(p) =>
|
|
||||||
api.settings.codexHome.set(p).then((r) => {
|
|
||||||
void fetchAll();
|
|
||||||
setHooksKey((k) => k + 1);
|
|
||||||
return {
|
|
||||||
value: r.codex_home,
|
|
||||||
envOwned: r.env_owned,
|
|
||||||
warning: r.routers.failed.length
|
|
||||||
? tSettings("codexHome.routersFailed", { lanes: r.routers.failed.join(", ") })
|
|
||||||
: null,
|
|
||||||
};
|
|
||||||
})
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="rounded-xl border border-border bg-surface-1">
|
<div className="rounded-xl border border-border bg-surface-1">
|
||||||
{tab !== "overview" && tab !== "memory" && tab !== "config" && (
|
{tab !== "overview" && tab !== "memory" && tab !== "config" && (
|
||||||
<div className="flex items-center gap-2 border-b border-border px-4 py-2.5">
|
<div className="flex items-center gap-2 border-b border-border px-4 py-2.5">
|
||||||
@@ -494,6 +450,39 @@ export function CodexConfig() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</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">
|
||||||
|
<HomeDirSetting
|
||||||
|
id="codex-home"
|
||||||
|
title={tSettings("codexHome.title")}
|
||||||
|
description={tSettings("codexHome.description")}
|
||||||
|
note={tSettings("codexHome.restartNote")}
|
||||||
|
placeholder={tSettings("codexHome.placeholder")}
|
||||||
|
load={() =>
|
||||||
|
api.settings.codexHome
|
||||||
|
.get()
|
||||||
|
.then((r) => ({ value: r.codex_home, envOwned: r.env_owned }))
|
||||||
|
}
|
||||||
|
save={(p) =>
|
||||||
|
api.settings.codexHome.set(p).then((r) => {
|
||||||
|
void fetchAll();
|
||||||
|
setHooksKey((k) => k + 1);
|
||||||
|
return {
|
||||||
|
value: r.codex_home,
|
||||||
|
envOwned: r.env_owned,
|
||||||
|
warning: r.routers.failed.length
|
||||||
|
? tSettings("codexHome.routersFailed", { lanes: r.routers.failed.join(", ") })
|
||||||
|
: null,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<CodexHooksSettings key={hooksKey} />
|
||||||
|
<PricingSettings vendor="openai" id="codex-pricing" />
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{backupsOpen && (
|
{backupsOpen && (
|
||||||
@@ -829,7 +818,6 @@ function OverviewPanel({ overview }: { overview: CodexOverview | null }) {
|
|||||||
{t("overview.rootsTitle")}
|
{t("overview.rootsTitle")}
|
||||||
</h2>
|
</h2>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-2">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-2">
|
||||||
<RootRow icon={FolderTree} label={t("overview.codexHome")} value={roots.codexHome} />
|
|
||||||
<RootRow
|
<RootRow
|
||||||
icon={FolderTree}
|
icon={FolderTree}
|
||||||
label={t("overview.projectCodexDir")}
|
label={t("overview.projectCodexDir")}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
/**
|
/**
|
||||||
* @file Settings.tsx
|
* @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>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
*/
|
*/
|
||||||
/* =============================================================================
|
/* =============================================================================
|
||||||
@@ -60,7 +60,6 @@ import { useTranslation } from "react-i18next";
|
|||||||
import {
|
import {
|
||||||
DollarSign,
|
DollarSign,
|
||||||
Palette,
|
Palette,
|
||||||
Bot,
|
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
Database,
|
Database,
|
||||||
HardDrive,
|
HardDrive,
|
||||||
@@ -112,7 +111,6 @@ import { RemoteSources } from "../components/RemoteSources";
|
|||||||
import { Skeleton } from "../components/Skeleton";
|
import { Skeleton } from "../components/Skeleton";
|
||||||
import { AlertsNotifications } from "../components/AlertsNotifications";
|
import { AlertsNotifications } from "../components/AlertsNotifications";
|
||||||
import { AppearanceSettings } from "../components/settings/AppearanceSettings";
|
import { AppearanceSettings } from "../components/settings/AppearanceSettings";
|
||||||
import { AiToolsSettings } from "../components/settings/AiToolsSettings";
|
|
||||||
import type { WSMessage } from "../lib/types";
|
import type { WSMessage } from "../lib/types";
|
||||||
|
|
||||||
// In-page navigation for the (dense) Settings screen. Each entry maps to a
|
// In-page navigation for the (dense) Settings screen. Each entry maps to a
|
||||||
@@ -124,7 +122,6 @@ const SETTINGS_SECTIONS: {
|
|||||||
Icon: typeof DollarSign;
|
Icon: typeof DollarSign;
|
||||||
}[] = [
|
}[] = [
|
||||||
{ id: "appearance", labelKey: "appearance.title", Icon: Palette },
|
{ id: "appearance", labelKey: "appearance.title", Icon: Palette },
|
||||||
{ id: "ai-tools", labelKey: "aiTools.title", Icon: Bot },
|
|
||||||
{ id: "lanes", labelKey: "lanesSection.title", Icon: GitBranch },
|
{ id: "lanes", labelKey: "lanesSection.title", Icon: GitBranch },
|
||||||
{ id: "import", labelKey: "import.title", fallback: "Import", Icon: History },
|
{ id: "import", labelKey: "import.title", fallback: "Import", Icon: History },
|
||||||
{
|
{
|
||||||
@@ -626,8 +623,6 @@ export function Settings() {
|
|||||||
|
|
||||||
<AppearanceSettings />
|
<AppearanceSettings />
|
||||||
|
|
||||||
<AiToolsSettings />
|
|
||||||
|
|
||||||
{/* ─── LANES ─── */}
|
{/* ─── LANES ─── */}
|
||||||
<section id="lanes" className="scroll-mt-24">
|
<section id="lanes" className="scroll-mt-24">
|
||||||
<h3 className="text-sm font-medium text-fg-secondary flex items-center gap-2 mb-1">
|
<h3 className="text-sm font-medium text-fg-secondary flex items-center gap-2 mb-1">
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
/**
|
/**
|
||||||
* @file SettingsHub.tsx
|
* @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>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
*/
|
*/
|
||||||
import type { ComponentType } from "react";
|
import { useCallback, useEffect, useState, type ComponentType } from "react";
|
||||||
import { useSearchParams } from "react-router-dom";
|
import { useLocation, useNavigate, useSearchParams } from "react-router-dom";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import {
|
import {
|
||||||
|
Bot,
|
||||||
Boxes,
|
Boxes,
|
||||||
KeyRound,
|
KeyRound,
|
||||||
Orbit,
|
Orbit,
|
||||||
@@ -22,6 +23,7 @@ import { AntigravityConfig } from "./AntigravityConfig";
|
|||||||
import { AiRouterConfig } from "./AiRouterConfig";
|
import { AiRouterConfig } from "./AiRouterConfig";
|
||||||
import { GitCredentialsConfig } from "./GitCredentialsConfig";
|
import { GitCredentialsConfig } from "./GitCredentialsConfig";
|
||||||
import { useAiTools } from "../hooks/useAiTools";
|
import { useAiTools } from "../hooks/useAiTools";
|
||||||
|
import { AiToolsModal } from "../components/settings/AiToolsModal";
|
||||||
|
|
||||||
interface ToolTab {
|
interface ToolTab {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -85,6 +87,19 @@ export function SettingsHub() {
|
|||||||
(tab) => !tab.aiTool || aiTools.byId(tab.aiTool)?.enabled !== false
|
(tab) => !tab.aiTool || aiTools.byId(tab.aiTool)?.enabled !== false
|
||||||
);
|
);
|
||||||
const active = tabs.find((tab) => tab.id === requested) ?? DASHBOARD_TAB;
|
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 (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
@@ -97,9 +112,21 @@ export function SettingsHub() {
|
|||||||
Icon,
|
Icon,
|
||||||
label: t(labelKey),
|
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 />
|
<active.Panel />
|
||||||
|
{aiToolsOpen && <AiToolsModal onClose={closeAiTools} />}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -123,7 +123,7 @@ afterEach(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("CodexConfig", () => {
|
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({});
|
stubCodexApi({});
|
||||||
codexHomeMock.get.mockResolvedValue({ codex_home: "/home/.codex", env_owned: false });
|
codexHomeMock.get.mockResolvedValue({ codex_home: "/home/.codex", env_owned: false });
|
||||||
codexHomeMock.set.mockResolvedValue({
|
codexHomeMock.set.mockResolvedValue({
|
||||||
@@ -136,7 +136,8 @@ describe("CodexConfig", () => {
|
|||||||
await settle();
|
await settle();
|
||||||
const overviewCalls = codexConfigMock.overview.mock.calls.length;
|
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");
|
const input = await screen.findByDisplayValue("/home/.codex");
|
||||||
expect(await screen.findByText("Codex hooks")).toBeInTheDocument();
|
expect(await screen.findByText("Codex hooks")).toBeInTheDocument();
|
||||||
expect(screen.queryByPlaceholderText(/search/i)).not.toBeInTheDocument();
|
expect(screen.queryByPlaceholderText(/search/i)).not.toBeInTheDocument();
|
||||||
|
|||||||
@@ -58,6 +58,7 @@ vi.mock("../../lib/api", async (importOriginal) => {
|
|||||||
return {
|
return {
|
||||||
...actual,
|
...actual,
|
||||||
api: {
|
api: {
|
||||||
|
aiTools: { iconUrl: (id: string) => `/api/ai-tools/${id}/icon` },
|
||||||
lanes: {
|
lanes: {
|
||||||
list: vi.fn().mockImplementation(async () => {
|
list: vi.fn().mockImplementation(async () => {
|
||||||
recordCall("GET", "/api/lanes");
|
recordCall("GET", "/api/lanes");
|
||||||
|
|||||||
@@ -1474,35 +1474,6 @@ exports[`screen snapshots > Claude Config 1`] = `
|
|||||||
0
|
0
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</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>
|
||||||
<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"
|
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
|
<div
|
||||||
class="grid grid-cols-1 md:grid-cols-2 gap-2"
|
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
|
<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"
|
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>
|
||||||
</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>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
@@ -7025,47 +7288,6 @@ exports[`screen snapshots > Settings 1`] = `
|
|||||||
</svg>
|
</svg>
|
||||||
Appearance
|
Appearance
|
||||||
</button>
|
</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
|
<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"
|
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"
|
type="button"
|
||||||
@@ -8078,141 +8300,6 @@ exports[`screen snapshots > Settings 1`] = `
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</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
|
<section
|
||||||
class="scroll-mt-24"
|
class="scroll-mt-24"
|
||||||
id="lanes"
|
id="lanes"
|
||||||
|
|||||||
@@ -591,9 +591,10 @@ describe("screen snapshots", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Pricing, hook status and CLAUDE_HOME belong to Claude Code (#40 part B):
|
// 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", () => {
|
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(
|
const dashboard = render(
|
||||||
<MemoryRouter initialEntries={["/settings"]}>
|
<MemoryRouter initialEntries={["/settings"]}>
|
||||||
<Settings />
|
<Settings />
|
||||||
@@ -611,12 +612,13 @@ describe("Claude Code's AgentHub settings", () => {
|
|||||||
</MemoryRouter>
|
</MemoryRouter>
|
||||||
);
|
);
|
||||||
await settle();
|
await settle();
|
||||||
expect(container.querySelector("section#pricing")).toBeNull();
|
expect(screen.queryByTestId("cc-tab-agenthub")).toBeNull();
|
||||||
fireEvent.click(screen.getByTestId("cc-tab-agenthub"));
|
|
||||||
await settle();
|
|
||||||
for (const id of ["pricing", "hooks", "claude-home"]) {
|
for (const id of ["pricing", "hooks", "claude-home"]) {
|
||||||
expect(container.querySelector(`section#${id}`)).not.toBeNull();
|
expect(container.querySelector(`section#${id}`)).not.toBeNull();
|
||||||
}
|
}
|
||||||
|
fireEvent.click(screen.getByTestId("cc-tab-skills"));
|
||||||
|
await settle();
|
||||||
|
expect(container.querySelector("section#pricing")).toBeNull();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -66,7 +66,7 @@ graph TB
|
|||||||
**Database Location:**
|
**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
|
- **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
|
- **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`)
|
- **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.
|
- **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
@@ -201,7 +201,7 @@ Formatting utilities are centralized in `client/src/lib/format.ts`.
|
|||||||
`formatTime`, `formatDateTime`, and `fmtCostFull` use locale-aware `toLocale*` APIs.
|
`formatTime`, `formatDateTime`, and `fmtCostFull` use locale-aware `toLocale*` APIs.
|
||||||
Timestamp parsing normalizes timezone-less SQLite datetime strings to UTC before display formatting.
|
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
|
```mermaid
|
||||||
flowchart LR
|
flowchart LR
|
||||||
|
|||||||
+3
-3
@@ -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
|
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
|
`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
|
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
|
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
|
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
|
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`
|
disables) it lists `$CODEX_HOME/sessions/<YYYY>/<MM>/<DD>/rollout-*.jsonl`
|
||||||
(`$CODEX_HOME` defaults to `~/.codex`; every codex pane is started with the
|
(`$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
|
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
|
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
|
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`
|
`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
|
## 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:
|
A catalog file:
|
||||||
|
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ const {
|
|||||||
const { extractFirstUserText } = require("../server/lib/transcript-cache");
|
const { extractFirstUserText } = require("../server/lib/transcript-cache");
|
||||||
const CLAUDE_DIR = getClaudeHome();
|
const CLAUDE_DIR = getClaudeHome();
|
||||||
// The projects dir is read per call, not at load: CLAUDE_HOME can change while
|
// 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
|
// 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 —
|
// the event loop. A long synchronous scan of a large ~/.claude/projects tree —
|
||||||
|
|||||||
+2
-2
@@ -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/: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`. |
|
| `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/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. |
|
| `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). |
|
| `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.
|
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'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"` reaches only the codex lane at its cwd and creates a
|
||||||
`tool = 'codex'` session (see ARCHITECTURE.md, **Codex hooks**). Otherwise
|
`tool = 'codex'` session (see ARCHITECTURE.md, **Codex hooks**). Otherwise
|
||||||
`server/lib/codex-ingest.js` only mirrors its `status` — skipping a lane whose
|
`server/lib/codex-ingest.js` only mirrors its `status` — skipping a lane whose
|
||||||
|
|||||||
@@ -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();
|
await jobs.startUpdateAll();
|
||||||
const done = await settle();
|
const done = await settle();
|
||||||
assert.equal(done.status, "ok");
|
assert.equal(done.status, "ok");
|
||||||
@@ -379,5 +384,6 @@ describe("jobs", () => {
|
|||||||
spawned.map((s) => s.argv.slice(1).join(" ")),
|
spawned.map((s) => s.argv.slice(1).join(" ")),
|
||||||
["install -g npm-tool@latest", "update --yes"]
|
["install -g npm-tool@latest", "update --yes"]
|
||||||
);
|
);
|
||||||
|
assert.equal(viewed, 0);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -164,7 +164,7 @@ describe("install / update / icons", () => {
|
|||||||
const { body } = await request("GET", "/api/ai-tools");
|
const { body } = await request("GET", "/api/ai-tools");
|
||||||
const codex = body.tools.find((t) => t.id === "codex");
|
const codex = body.tools.find((t) => t.id === "codex");
|
||||||
assert.deepEqual(codex.install, { kind: "npm", package: "@openai/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");
|
assert.equal(body.catalog.source, "builtin");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,9 @@
|
|||||||
"package": "@openai/codex"
|
"package": "@openai/codex"
|
||||||
},
|
},
|
||||||
"update": {
|
"update": {
|
||||||
"kind": "npm"
|
"kind": "self",
|
||||||
|
"args": [
|
||||||
|
"update"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,5 +15,12 @@
|
|||||||
"--yolo"
|
"--yolo"
|
||||||
],
|
],
|
||||||
"homepageUrl": "https://hermes-agent.nousresearch.com/docs/",
|
"homepageUrl": "https://hermes-agent.nousresearch.com/docs/",
|
||||||
"faviconDomain": "nousresearch.com"
|
"faviconDomain": "nousresearch.com",
|
||||||
|
"update": {
|
||||||
|
"kind": "self",
|
||||||
|
"args": [
|
||||||
|
"update",
|
||||||
|
"--yes"
|
||||||
|
]
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,5 +9,12 @@
|
|||||||
"delayMs": 3000
|
"delayMs": 3000
|
||||||
},
|
},
|
||||||
"homepageUrl": "https://github.com/openclaw/openclaw",
|
"homepageUrl": "https://github.com/openclaw/openclaw",
|
||||||
"faviconDomain": "openclaw.ai"
|
"faviconDomain": "openclaw.ai",
|
||||||
|
"update": {
|
||||||
|
"kind": "self",
|
||||||
|
"args": [
|
||||||
|
"update",
|
||||||
|
"--yes"
|
||||||
|
]
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -247,18 +247,20 @@ async function start(id, action) {
|
|||||||
return view(j);
|
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() {
|
async function startUpdateAll() {
|
||||||
claim();
|
claim();
|
||||||
let steps = [];
|
let steps = [];
|
||||||
try {
|
try {
|
||||||
const v = await versions.versions();
|
|
||||||
for (const t of aiTools.list()) {
|
for (const t of aiTools.list()) {
|
||||||
if (!aiTools.detect(t.id).installed || !t.update) continue;
|
if (!aiTools.detect(t.id).installed || !t.update) continue;
|
||||||
if (t.update.kind === "self" || (t.update.kind === "npm" && v[t.id]?.updateAvailable)) {
|
if (t.update.kind === "npm" && !(await versions.npmManaged(t.id))) continue;
|
||||||
steps.push(...(await stepsFor(t.id, "update")));
|
steps.push(...(await stepsFor(t.id, "update")));
|
||||||
}
|
}
|
||||||
}
|
|
||||||
if (!steps.length) throw makeErr("ENOTHING", "no AI tool has an update to run");
|
if (!steps.length) throw makeErr("ENOTHING", "no AI tool has an update to run");
|
||||||
} finally {
|
} finally {
|
||||||
starting = false;
|
starting = false;
|
||||||
|
|||||||
Reference in New Issue
Block a user