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:
@@ -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
|
||||
* @description Shared top-of-page tab bar used by the pages that merged several
|
||||
* former routes into one (Settings hub, Sessions/Events history). Rendering only -
|
||||
* the owning page keeps the active tab in its own query param.
|
||||
* the owning page keeps the active tab in its own query param. `actions` sits at
|
||||
* the bar's right edge, outside the tablist.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
import type { ComponentType } from "react";
|
||||
import type { ComponentType, ReactNode } from "react";
|
||||
|
||||
export interface PageTab {
|
||||
id: string;
|
||||
@@ -18,6 +19,8 @@ interface PageTabsProps {
|
||||
activeId: string;
|
||||
onSelect: (id: string) => void;
|
||||
ariaLabel: string;
|
||||
/** Buttons at the right edge of the bar (not tabs). */
|
||||
actions?: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -25,12 +28,14 @@ interface PageTabsProps {
|
||||
* @param props - Tabs to render, the active tab id, and the selection callback.
|
||||
* @returns The tab bar element.
|
||||
*/
|
||||
export function PageTabs({ tabs, activeId, onSelect, ariaLabel }: PageTabsProps) {
|
||||
return (
|
||||
export function PageTabs({ tabs, activeId, onSelect, ariaLabel, actions }: PageTabsProps) {
|
||||
const list = (
|
||||
<div
|
||||
role="tablist"
|
||||
aria-label={ariaLabel}
|
||||
className="flex items-center gap-1.5 overflow-x-auto no-scrollbar border-b border-border/60 pb-2"
|
||||
className={`flex items-center gap-1.5 overflow-x-auto no-scrollbar ${
|
||||
actions ? "min-w-0 flex-1" : "border-b border-border/60 pb-2"
|
||||
}`}
|
||||
>
|
||||
{tabs.map(({ id, label, Icon }) => {
|
||||
const isActive = id === activeId;
|
||||
@@ -54,4 +59,11 @@ export function PageTabs({ tabs, activeId, onSelect, ariaLabel }: PageTabsProps)
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
if (!actions) return list;
|
||||
return (
|
||||
<div className="flex items-center gap-3 border-b border-border/60 pb-2">
|
||||
{list}
|
||||
<div className="flex flex-shrink-0 items-center gap-1.5">{actions}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* @file MarkdownPreview.test.tsx
|
||||
* @description The markdown preview: GFM renders (headings, tables, task
|
||||
* lists), raw HTML stays text, links open in a new tab, frontmatter is a
|
||||
* code block; and the config FileViewer offers Raw / Preview only for a
|
||||
* markdown file.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { MarkdownPreview, isMarkdown } from "../MarkdownPreview";
|
||||
import { FileViewer } from "../config/FileViewer";
|
||||
|
||||
describe("MarkdownPreview", () => {
|
||||
it("renders GFM: headings, tables and task lists", () => {
|
||||
render(
|
||||
<MarkdownPreview
|
||||
text={"# Title\n\n| a | b |\n|---|---|\n| 1 | 2 |\n\n- [x] done\n- [ ] todo\n"}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByRole("heading", { level: 1, name: "Title" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("table")).toBeInTheDocument();
|
||||
expect(screen.getAllByRole("checkbox")).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("never runs raw HTML and opens links in a new tab", () => {
|
||||
const { container } = render(
|
||||
<MarkdownPreview
|
||||
text={'<script>alert(1)</script><img src=x onerror="alert(1)">\n\n[x](https://a.example)'}
|
||||
/>
|
||||
);
|
||||
expect(container.querySelector("script")).toBeNull();
|
||||
expect(container.querySelector("img")).toBeNull();
|
||||
const link = screen.getByRole("link", { name: "x" });
|
||||
expect(link).toHaveAttribute("target", "_blank");
|
||||
expect(link).toHaveAttribute("rel", "noreferrer noopener");
|
||||
});
|
||||
|
||||
it("shows frontmatter as a block, not as a heading", () => {
|
||||
const { container } = render(
|
||||
<MarkdownPreview text={"---\nname: demo\ndescription: d\n---\n\nBody text\n"} />
|
||||
);
|
||||
expect(container.querySelector(".md-frontmatter")).toHaveTextContent("name: demo");
|
||||
expect(screen.queryByRole("heading")).toBeNull();
|
||||
expect(screen.getByText("Body text")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("knows a markdown path", () => {
|
||||
expect(isMarkdown("/a/SKILL.md")).toBe(true);
|
||||
expect(isMarkdown("README.markdown")).toBe(true);
|
||||
expect(isMarkdown("config.toml")).toBe(false);
|
||||
expect(isMarkdown(null)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("FileViewer — markdown preview", () => {
|
||||
const data = { text: "# Hello\n", truncated: false } as never;
|
||||
|
||||
it("switches a markdown file between raw and preview", async () => {
|
||||
render(<FileViewer state={{ path: "/x/CLAUDE.md", data, error: null }} onClose={vi.fn()} />);
|
||||
expect(screen.queryByTestId("markdown-preview")).toBeNull();
|
||||
expect(screen.getByText("# Hello")).toBeInTheDocument();
|
||||
await userEvent.click(screen.getByRole("button", { name: "Preview" }));
|
||||
expect(screen.getByRole("heading", { name: "Hello" })).toBeInTheDocument();
|
||||
await userEvent.click(screen.getByRole("button", { name: "Raw" }));
|
||||
expect(screen.queryByTestId("markdown-preview")).toBeNull();
|
||||
});
|
||||
|
||||
it("offers no preview for another file type", () => {
|
||||
render(<FileViewer state={{ path: "/x/config.toml", data, error: null }} onClose={vi.fn()} />);
|
||||
expect(screen.queryByRole("button", { name: "Preview" })).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -3,15 +3,17 @@
|
||||
* @description Read-only file-content modal for the config explorer. Takes
|
||||
* already-fetched data as props (path/data/error) rather than fetching
|
||||
* itself, so it stays tool-agnostic — the orchestrator (Claude or Codex
|
||||
* config page) owns the fetch.
|
||||
* config page) owns the fetch. A markdown file gets a Raw / Preview switch
|
||||
* (`MarkdownPreview`); Raw is the default.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { FileText, X, AlertCircle } from "lucide-react";
|
||||
import { FileText, X, AlertCircle, Eye, Code2 } from "lucide-react";
|
||||
import type { CcFileResponse } from "../../lib/api";
|
||||
import { CopyButton } from "./configPrimitives";
|
||||
import { MarkdownPreview, isMarkdown } from "../MarkdownPreview";
|
||||
|
||||
export function FileViewer({
|
||||
state,
|
||||
@@ -21,6 +23,8 @@ export function FileViewer({
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation("ccConfig");
|
||||
const [preview, setPreview] = useState(false);
|
||||
const canPreview = isMarkdown(state.path) && !!state.data;
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") onClose();
|
||||
@@ -42,6 +46,17 @@ export function FileViewer({
|
||||
<span className="font-mono text-[12px] text-fg-secondary truncate flex-1">
|
||||
{state.path}
|
||||
</span>
|
||||
{canPreview && (
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={preview}
|
||||
onClick={() => setPreview((v) => !v)}
|
||||
className="inline-flex items-center gap-1 rounded-md border border-border px-2 py-0.5 text-[11px] text-fg-secondary hover:bg-surface-3 hover:text-fg-primary"
|
||||
>
|
||||
{preview ? <Code2 className="h-3 w-3" /> : <Eye className="h-3 w-3" />}
|
||||
{preview ? t("common.raw") : t("common.preview")}
|
||||
</button>
|
||||
)}
|
||||
<CopyButton value={state.path} />
|
||||
<button
|
||||
onClick={onClose}
|
||||
@@ -59,6 +74,13 @@ export function FileViewer({
|
||||
</div>
|
||||
) : !state.data ? (
|
||||
<div className="text-sm text-fg-muted">…</div>
|
||||
) : preview && canPreview ? (
|
||||
<>
|
||||
<MarkdownPreview text={state.data.text} />
|
||||
{state.data.truncated && (
|
||||
<p className="text-[11px] text-fg-muted italic">{t("common.truncated")}</p>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<pre className="text-[11px] font-mono text-fg-secondary whitespace-pre-wrap break-words">
|
||||
{state.data.text}
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { FolderOpen } from "lucide-react";
|
||||
import { ChevronDown, FolderOpen } from "lucide-react";
|
||||
import { ConfirmModal } from "../ConfirmModal";
|
||||
import { CwdAutocomplete } from "../run/RunSetup";
|
||||
import { FolderBrowseModal } from "./FolderBrowseModal";
|
||||
@@ -27,6 +27,7 @@ import { api } from "../../lib/api";
|
||||
import type { AiRouter, CwdSuggestion } from "../../lib/api";
|
||||
import type { Lane, LaneWorktree } from "../../lib/types";
|
||||
import { useAiTools } from "../../hooks/useAiTools";
|
||||
import { AiToolIcon } from "../AiToolIcon";
|
||||
|
||||
/** Polls the lane until the background `git worktree add` finishes (status
|
||||
* leaves "provisioning"), or gives up after `timeoutMs`. Returns the final
|
||||
@@ -115,7 +116,17 @@ export function AddLaneModal({
|
||||
const aiTools = useAiTools();
|
||||
const defaultTool = aiTools.defaultId ?? "claude";
|
||||
const tool = pickedTool ?? defaultTool;
|
||||
const otherTools = aiTools.tools.filter((x) => x.installed && x.enabled && x.id !== defaultTool);
|
||||
// Installed + enabled, the default first.
|
||||
const toolChoices = aiTools.tools
|
||||
.filter((x) => x.installed && x.enabled)
|
||||
.sort((a, b) => Number(b.id === defaultTool) - Number(a.id === defaultTool));
|
||||
const [toolListOpen, setToolListOpen] = useState(false);
|
||||
const pickTool = (id: string) => {
|
||||
setPickedTool(id === defaultTool ? null : id);
|
||||
// A tool with no endpoint to rewrite runs native only (see router-apply.js).
|
||||
if (!aiTools.byId(id)?.routerCapable) setRouter("native");
|
||||
setToolListOpen(false);
|
||||
};
|
||||
const noTools = aiTools.loaded && aiTools.defaultId === null;
|
||||
const toolRouterCapable =
|
||||
aiTools.byId(tool)?.routerCapable ?? (tool === "claude" || tool === "codex");
|
||||
@@ -431,38 +442,51 @@ export function AddLaneModal({
|
||||
</a>
|
||||
</p>
|
||||
) : (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="flex items-center rounded-md border border-border bg-surface-2 p-0.5">
|
||||
<Seg
|
||||
active={tool === defaultTool}
|
||||
label={t("addLaneToolDefault", { tool: aiTools.label(defaultTool) })}
|
||||
onClick={() => setPickedTool(null)}
|
||||
/>
|
||||
</div>
|
||||
{otherTools.length > 0 && (
|
||||
<select
|
||||
aria-label={t("addLaneToolOther")}
|
||||
value={tool === defaultTool ? "" : tool}
|
||||
onChange={(e) => {
|
||||
const next = e.target.value || null;
|
||||
setPickedTool(next);
|
||||
// A tool with no endpoint to rewrite runs native only
|
||||
// (see router-apply.js).
|
||||
if (next && !aiTools.byId(next)?.routerCapable) setRouter("native");
|
||||
}}
|
||||
className={`rounded-md border bg-surface-2 px-2 py-1 text-xs ${
|
||||
tool !== defaultTool
|
||||
? "border-accent text-accent"
|
||||
: "border-border text-fg-secondary"
|
||||
}`}
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
aria-expanded={toolListOpen}
|
||||
disabled={toolChoices.length < 2}
|
||||
onClick={() => setToolListOpen((o) => !o)}
|
||||
className="flex w-full items-center gap-2 rounded-md border border-border bg-surface-2 px-2.5 py-1.5 text-left text-sm text-fg-primary hover:bg-surface-3 disabled:cursor-default disabled:hover:bg-surface-2"
|
||||
>
|
||||
<AiToolIcon tool={{ id: tool, label: aiTools.label(tool) }} className="h-5 w-5" />
|
||||
<span className="min-w-0 flex-1 truncate">{aiTools.label(tool)}</span>
|
||||
{tool === defaultTool && (
|
||||
<span className="rounded-full bg-accent/15 px-2 py-0.5 text-[10px] font-medium text-accent">
|
||||
{t("addLaneToolDefaultBadge")}
|
||||
</span>
|
||||
)}
|
||||
{toolChoices.length > 1 && (
|
||||
<ChevronDown
|
||||
aria-hidden
|
||||
className={`h-4 w-4 text-fg-muted transition-transform ${toolListOpen ? "rotate-180" : ""}`}
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
{toolListOpen && (
|
||||
<div
|
||||
role="group"
|
||||
aria-label={t("addLaneToolHint")}
|
||||
className="mt-1.5 grid grid-cols-2 gap-1.5 sm:grid-cols-3"
|
||||
>
|
||||
<option value="">{t("addLaneToolOther")}</option>
|
||||
{otherTools.map((x) => (
|
||||
<option key={x.id} value={x.id}>
|
||||
{x.label}
|
||||
</option>
|
||||
{toolChoices.map((x) => (
|
||||
<button
|
||||
key={x.id}
|
||||
type="button"
|
||||
aria-pressed={x.id === tool}
|
||||
onClick={() => pickTool(x.id)}
|
||||
className={`flex items-center gap-2 rounded-md border px-2 py-1.5 text-left text-xs transition-colors ${
|
||||
x.id === tool
|
||||
? "border-accent bg-accent/10 text-accent"
|
||||
: "border-border bg-surface-2 text-fg-secondary hover:bg-surface-3 hover:text-fg-primary"
|
||||
}`}
|
||||
>
|
||||
<AiToolIcon tool={x} className="h-5 w-5" />
|
||||
<span className="min-w-0 flex-1 truncate">{x.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -5,7 +5,8 @@
|
||||
* overview — every changed file with its +/- counts and its stage/discard
|
||||
* actions; click one to open it
|
||||
* review — every file's full diff, stacked, for reading a whole change-set
|
||||
* file — one file, as a diff or as its whole content (issue #35)
|
||||
* file — one file, as a diff or as its whole content (issue #35); a
|
||||
* whole markdown file can also be read rendered (Preview)
|
||||
* history — recent commits; opening one shows that commit's diff
|
||||
* browse — quick-open by path, or the repo's tracked file tree
|
||||
*
|
||||
@@ -38,7 +39,9 @@ import {
|
||||
ChevronsDownUp,
|
||||
ChevronsUpDown,
|
||||
Check,
|
||||
Code2,
|
||||
Columns2,
|
||||
Eye,
|
||||
FileText,
|
||||
FoldVertical,
|
||||
Folder,
|
||||
@@ -53,6 +56,7 @@ import {
|
||||
import { api } from "../../lib/api";
|
||||
import { LaneDiffView, LaneFileView, type DiffLayout, type LaneDiffChange } from "./LaneDiffView";
|
||||
import { LaneConflictView } from "./LaneConflictView";
|
||||
import { MarkdownPreview, isMarkdown } from "../MarkdownPreview";
|
||||
import { LaneGitHeader, useLaneGitFacts } from "./LaneGitHeader";
|
||||
import type {
|
||||
LaneChangeFile,
|
||||
@@ -435,6 +439,9 @@ export function LaneChangesPanel({
|
||||
const [context, setContext] = useState(CONTEXT_STEPS[0]);
|
||||
const [layout, setLayout] = useState<DiffLayout>("unified");
|
||||
const [whole, setWhole] = useState(false);
|
||||
// A whole markdown file shown rendered instead of as source; per file.
|
||||
const [mdPreview, setMdPreview] = useState(false);
|
||||
useEffect(() => setMdPreview(false), [openFile]);
|
||||
const [data, setData] = useState<LaneChanges | null>(null);
|
||||
const [commits, setCommits] = useState<LaneCommit[] | null>(null);
|
||||
// A folder's children, keyed by its path ("" is the root) — fetched once,
|
||||
@@ -1429,6 +1436,18 @@ export function LaneChangesPanel({
|
||||
binary={current.binary}
|
||||
/>
|
||||
)}
|
||||
{whole && text && draft === null && isMarkdown(openFile) && (
|
||||
<button
|
||||
type="button"
|
||||
data-testid="lane-file-preview"
|
||||
aria-pressed={mdPreview}
|
||||
onClick={() => setMdPreview((v) => !v)}
|
||||
className="flex shrink-0 items-center gap-1 self-center rounded border border-border-light px-1.5 py-0.5 text-[11px] text-fg-secondary hover:bg-surface-2 hover:text-fg-primary"
|
||||
>
|
||||
{mdPreview ? <Code2 className="h-3 w-3" /> : <Eye className="h-3 w-3" />}
|
||||
{mdPreview ? t("changes.raw") : t("changes.preview")}
|
||||
</button>
|
||||
)}
|
||||
{/* Only the working file is editable: a commit is history. */}
|
||||
{/* The whole file enters the editor by a click on its text;
|
||||
only the diff, which has no whole text to click, keeps
|
||||
@@ -1442,7 +1461,8 @@ export function LaneChangesPanel({
|
||||
{t(noEditReason(text))}
|
||||
</span>
|
||||
) : draft === null && whole ? (
|
||||
text && (
|
||||
text &&
|
||||
!mdPreview && (
|
||||
<span className="shrink-0 self-center text-[10px] text-fg-muted">
|
||||
{t("changes.clickToEdit")}
|
||||
</span>
|
||||
@@ -1503,7 +1523,11 @@ export function LaneChangesPanel({
|
||||
className="min-h-0 flex-1 resize-none rounded border border-border-light bg-surface-0 p-2 font-mono text-[11px] leading-[1.45] text-fg-primary focus:border-accent focus:outline-none"
|
||||
/>
|
||||
) : whole ? (
|
||||
text ? (
|
||||
text && mdPreview ? (
|
||||
<div className="min-h-0 flex-1 overflow-auto rounded border border-border-light bg-surface-0 p-4">
|
||||
<MarkdownPreview text={text.lines.join("\n")} />
|
||||
</div>
|
||||
) : text ? (
|
||||
<LaneFileView
|
||||
path={text.path}
|
||||
lines={text.lines}
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import { render, screen, waitFor, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { AddLaneModal } from "../AddLaneModal";
|
||||
import { api } from "../../../lib/api";
|
||||
@@ -32,7 +32,7 @@ vi.mock("../../../lib/api", () => ({
|
||||
routerApply: vi.fn(),
|
||||
},
|
||||
aiRouters: { list: vi.fn() },
|
||||
aiTools: { list: vi.fn() },
|
||||
aiTools: { list: vi.fn(), iconUrl: (id: string) => `/api/ai-tools/${id}/icon` },
|
||||
run: { start: vi.fn() },
|
||||
},
|
||||
}));
|
||||
@@ -784,14 +784,20 @@ describe("AddLaneModal — tool selection", () => {
|
||||
const dirField = screen.getByLabelText("Directory");
|
||||
await focusField(user, dirField);
|
||||
await user.type(dirField, "/tmp/demo");
|
||||
const other = await screen.findByRole("combobox", { name: "Other…" });
|
||||
// Installed + enabled only, and never the default itself.
|
||||
expect(Array.from((other as HTMLSelectElement).options).map((o) => o.value)).toEqual([
|
||||
"",
|
||||
"codex",
|
||||
"gemini",
|
||||
]);
|
||||
await user.selectOptions(other, "codex");
|
||||
// One button showing the default; it opens every installed + enabled tool.
|
||||
const picker = await screen.findByRole("button", { name: /Claude Code.*Default/ });
|
||||
expect(picker).toHaveAttribute("aria-expanded", "false");
|
||||
await user.click(picker);
|
||||
const list = screen.getByRole("group", { name: /Which CLI drives this lane/ });
|
||||
expect(
|
||||
within(list)
|
||||
.getAllByRole("button")
|
||||
.map((b) => b.textContent)
|
||||
).toEqual(["Claude Code", "Codex", "Gemini"]);
|
||||
await user.click(within(list).getByRole("button", { name: "Codex" }));
|
||||
// The picked tool replaces the default on the button, and the list closes.
|
||||
expect(screen.getByRole("button", { name: "Codex", expanded: false })).toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: "Gemini" })).toBeNull();
|
||||
await user.click(screen.getByRole("button", { name: /add lane|create/i }));
|
||||
|
||||
await waitFor(() =>
|
||||
@@ -802,7 +808,8 @@ describe("AddLaneModal — tool selection", () => {
|
||||
it("offers only Native for a tool that cannot use an AI Router", async () => {
|
||||
renderModal({});
|
||||
const user = userEvent.setup();
|
||||
await user.selectOptions(await screen.findByRole("combobox", { name: "Other…" }), "gemini");
|
||||
await user.click(await screen.findByRole("button", { name: /Claude Code.*Default/ }));
|
||||
await user.click(screen.getByRole("button", { name: "Gemini" }));
|
||||
expect(screen.getByRole("radio", { name: "Native (Gemini)" })).toBeChecked();
|
||||
expect(screen.getByRole("radio", { name: "AI Router" })).toBeDisabled();
|
||||
});
|
||||
|
||||
@@ -321,6 +321,34 @@ describe("LaneChangesPanel", () => {
|
||||
expect(screen.getByText("Staged Changes")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("reads a whole markdown file rendered after Preview, and back as source after Raw", async () => {
|
||||
vi.mocked(api.lanes.gitTree).mockResolvedValue({
|
||||
available: true,
|
||||
path: "",
|
||||
entries: [{ name: "README.md", path: "README.md", dir: false, status: " M" }],
|
||||
truncated: false,
|
||||
});
|
||||
vi.mocked(api.lanes.gitFile).mockResolvedValue({
|
||||
available: true,
|
||||
path: "README.md",
|
||||
rev: "work",
|
||||
binary: false,
|
||||
truncated: false,
|
||||
lines: ["# Hello", "", "- one"],
|
||||
sha: "c".repeat(40),
|
||||
});
|
||||
const user = userEvent.setup();
|
||||
render(<LaneChangesPanel laneId={7} full />);
|
||||
const sidebar = await screen.findByTestId("lane-changes-sidebar");
|
||||
await user.click(await within(sidebar).findByText("README.md"));
|
||||
await screen.findByTestId("lane-file-view");
|
||||
await user.click(screen.getByTestId("lane-file-preview"));
|
||||
expect(screen.getByRole("heading", { name: "Hello" })).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("lane-file-view")).toBeNull();
|
||||
await user.click(screen.getByTestId("lane-file-preview"));
|
||||
expect(await screen.findByTestId("lane-file-view")).toHaveTextContent("# Hello");
|
||||
});
|
||||
|
||||
it("at full width shows the tree as a sidebar and opens a file for reading, editing on a click", async () => {
|
||||
vi.mocked(api.lanes.gitTree).mockResolvedValue({
|
||||
available: true,
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* @file AiToolsModal.tsx
|
||||
* @description The AI Tools dialog opened from the Settings tab bar's right
|
||||
* edge: a header with the title, description and a close button around
|
||||
* `AiToolsSettings`. Escape, the backdrop and the X close it; an Escape a
|
||||
* nested ConfirmModal already handled (defaultPrevented) is left to it.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Bot, X } from "lucide-react";
|
||||
import { AiToolsSettings } from "./AiToolsSettings";
|
||||
|
||||
/**
|
||||
* @param props.onClose - Called on Escape, a backdrop click or the X.
|
||||
* @returns The modal.
|
||||
*/
|
||||
export function AiToolsModal({ onClose }: { onClose: () => void }) {
|
||||
const { t } = useTranslation("settings");
|
||||
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape" && !e.defaultPrevented) onClose();
|
||||
};
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
}, [onClose]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-40 flex items-center justify-center bg-black/60 p-4"
|
||||
role="presentation"
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="ai-tools-title"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="max-h-[90vh] w-full max-w-4xl overflow-y-auto rounded-xl border border-border bg-surface-1 p-5 shadow-xl"
|
||||
>
|
||||
<div className="mb-4 flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<h2
|
||||
id="ai-tools-title"
|
||||
className="flex items-center gap-2 text-sm font-semibold text-fg-primary"
|
||||
>
|
||||
<Bot className="h-4 w-4 text-fg-muted" />
|
||||
{t("aiTools.title")}
|
||||
</h2>
|
||||
<p className="mt-1 text-xs text-fg-muted">{t("aiTools.description")}</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
aria-label={t("common:close")}
|
||||
className="rounded-md p-1 text-fg-muted hover:bg-surface-3 hover:text-fg-primary"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
<AiToolsSettings />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,44 +1,32 @@
|
||||
/**
|
||||
* @file AiToolsSettings.tsx
|
||||
* @description Settings → AI Tools: every AI coding CLI in the server's catalog
|
||||
* (GET /api/ai-tools), split into the ones found on PATH — each with
|
||||
* Enabled/Disabled, Set default and, when the catalog names one, Update with a
|
||||
* new-version badge — and the ones available to install, with Install where
|
||||
* the catalog knows how. Install/Update/Update tools confirm the exact command
|
||||
* @description The body of the AI Tools modal (Settings tab bar → AI Tools,
|
||||
* `AiToolsModal.tsx`): every AI coding CLI in the server's catalog
|
||||
* (GET /api/ai-tools). Installed ones are rows with an enable switch, a
|
||||
* Default badge or Set default, and Update whenever the catalog names an
|
||||
* updater — no version lookup, the updater itself knows whether it has work.
|
||||
* The ones available to install fold behind a disclosure, with Install where
|
||||
* the catalog knows how. Install/Update/Update all confirm the exact command
|
||||
* first, run as ONE server job at a time and show its log under the row
|
||||
* (polled from GET /api/ai-tools/job). Refresh re-reads the login shell's PATH
|
||||
* and re-detects. Icons are the server's cached favicons, a letter otherwise.
|
||||
* A disabled tool is hidden from Add lane; existing lanes keep working.
|
||||
* (polled from GET /api/ai-tools/job; a finished job folds to one line).
|
||||
* Refresh re-reads the login shell's PATH and re-detects. Icons come from
|
||||
* `AiToolIcon`. A disabled tool is hidden from Add lane; existing lanes keep
|
||||
* working.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Bot, Download, ExternalLink, RefreshCw } from "lucide-react";
|
||||
import { ChevronDown, Download, ExternalLink, RefreshCw } from "lucide-react";
|
||||
import { useAiTools } from "../../hooks/useAiTools";
|
||||
import { ConfirmModal } from "../ConfirmModal";
|
||||
import { api, type AiTool, type AiToolJob, type AiToolVersion } from "../../lib/api";
|
||||
import { AiToolIcon } from "../AiToolIcon";
|
||||
import { api, type AiTool, type AiToolJob } from "../../lib/api";
|
||||
|
||||
type Confirm = { kind: "install" | "update"; tool: AiTool } | { kind: "update-all" };
|
||||
|
||||
function Avatar({ tool }: { tool: AiTool }) {
|
||||
const [failed, setFailed] = useState(false);
|
||||
if (!failed) {
|
||||
return (
|
||||
<img
|
||||
src={api.aiTools.iconUrl(tool.id)}
|
||||
alt=""
|
||||
onError={() => setFailed(true)}
|
||||
className="h-7 w-7 shrink-0 rounded-md bg-surface-4 object-contain p-1"
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<span className="flex h-7 w-7 shrink-0 items-center justify-center rounded-md bg-surface-4 text-xs font-semibold text-fg-secondary">
|
||||
{tool.label.charAt(0).toUpperCase()}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
const GHOST_BTN =
|
||||
"inline-flex items-center gap-1.5 rounded-md border border-border px-2 py-1 text-xs text-fg-secondary hover:bg-surface-3 hover:text-fg-primary disabled:opacity-50";
|
||||
|
||||
function Homepage({ tool }: { tool: AiTool }) {
|
||||
const { t } = useTranslation("settings");
|
||||
@@ -57,12 +45,51 @@ function Homepage({ tool }: { tool: AiTool }) {
|
||||
);
|
||||
}
|
||||
|
||||
function Switch({
|
||||
tool,
|
||||
disabled,
|
||||
onToggle,
|
||||
}: {
|
||||
tool: AiTool;
|
||||
disabled: boolean;
|
||||
onToggle: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation("settings");
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={tool.enabled}
|
||||
aria-label={t("aiTools.enabledOf", { tool: tool.label })}
|
||||
title={tool.enabled ? t("aiTools.enabled") : t("aiTools.disabled")}
|
||||
disabled={disabled}
|
||||
onClick={onToggle}
|
||||
className={`relative h-5 w-9 shrink-0 rounded-full transition-colors disabled:opacity-50 ${
|
||||
tool.enabled ? "bg-accent" : "bg-surface-4"
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
aria-hidden
|
||||
className={`absolute top-0.5 h-4 w-4 rounded-full bg-white shadow transition-all ${
|
||||
tool.enabled ? "left-[18px]" : "left-0.5"
|
||||
}`}
|
||||
/>
|
||||
<span className="sr-only">{tool.enabled ? t("aiTools.enabled") : t("aiTools.disabled")}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function JobLog({ job }: { job: AiToolJob }) {
|
||||
const { t } = useTranslation("settings");
|
||||
const ref = useRef<HTMLPreElement | null>(null);
|
||||
// A finished job folds to its status line; running or failed stays open.
|
||||
const [open, setOpen] = useState(job.status !== "ok");
|
||||
useEffect(() => {
|
||||
if (job.status === "ok") setOpen(false);
|
||||
}, [job.status]);
|
||||
useEffect(() => {
|
||||
if (ref.current) ref.current.scrollTop = ref.current.scrollHeight;
|
||||
}, [job.log]);
|
||||
}, [job.log, open]);
|
||||
const state =
|
||||
job.status === "running"
|
||||
? t("aiTools.jobRunning")
|
||||
@@ -71,18 +98,29 @@ function JobLog({ job }: { job: AiToolJob }) {
|
||||
: t("aiTools.jobFailed", { code: job.exitCode ?? "?" });
|
||||
return (
|
||||
<div className="mt-2 w-full">
|
||||
<p
|
||||
className={`text-[11px] ${job.status === "failed" ? "text-status-danger" : "text-fg-muted"}`}
|
||||
>
|
||||
{state}
|
||||
</p>
|
||||
<pre
|
||||
ref={ref}
|
||||
data-testid="ai-tool-log"
|
||||
className="mt-1 max-h-48 overflow-auto whitespace-pre-wrap rounded-md border border-border bg-surface-1 p-2 font-mono text-[11px] text-fg-secondary"
|
||||
>
|
||||
{job.log}
|
||||
</pre>
|
||||
<div className="flex items-center gap-2 text-[11px]">
|
||||
<span className={job.status === "failed" ? "text-status-danger" : "text-fg-muted"}>
|
||||
{state}
|
||||
</span>
|
||||
{job.status !== "running" && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((o) => !o)}
|
||||
className="text-fg-muted underline-offset-2 hover:text-fg-primary hover:underline"
|
||||
>
|
||||
{open ? t("aiTools.hideLog") : t("aiTools.showLog")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{open && (
|
||||
<pre
|
||||
ref={ref}
|
||||
data-testid="ai-tool-log"
|
||||
className="mt-1 max-h-48 overflow-auto whitespace-pre-wrap rounded-md border border-border bg-surface-0 p-2 font-mono text-[11px] text-fg-secondary"
|
||||
>
|
||||
{job.log}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -93,31 +131,16 @@ export function AiToolsSettings() {
|
||||
const [busy, setBusy] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [job, setJob] = useState<AiToolJob | null>(null);
|
||||
const [versions, setVersions] = useState<Record<string, AiToolVersion>>({});
|
||||
const [confirm, setConfirm] = useState<Confirm | null>(null);
|
||||
const sectionRef = useRef<HTMLElement | null>(null);
|
||||
const [showAvailable, setShowAvailable] = useState(false);
|
||||
|
||||
// The Add-lane modal links here as /settings#ai-tools; the section renders
|
||||
// after the browser's own hash jump, so it has to scroll itself into view.
|
||||
useEffect(() => {
|
||||
if (window.location.hash === "#ai-tools") sectionRef.current?.scrollIntoView?.();
|
||||
}, []);
|
||||
|
||||
// Deferred like useAiTools' load: an unreachable server (or a mocked api
|
||||
// without these calls) degrades to "no job, no badges", never a throw.
|
||||
const loadVersions = () =>
|
||||
Promise.resolve()
|
||||
.then(() => api.aiTools.versions())
|
||||
.then(setVersions)
|
||||
.catch(() => undefined);
|
||||
|
||||
// A page opened mid-job picks it up; versions are cached a day server-side.
|
||||
// A modal opened mid-job picks it up. Deferred like useAiTools' load: an
|
||||
// unreachable server (or a mocked api without it) means "no job".
|
||||
useEffect(() => {
|
||||
Promise.resolve()
|
||||
.then(() => api.aiTools.job())
|
||||
.then(setJob)
|
||||
.catch(() => undefined);
|
||||
void loadVersions();
|
||||
}, []);
|
||||
|
||||
const running = job?.status === "running";
|
||||
@@ -127,10 +150,7 @@ export function AiToolsSettings() {
|
||||
try {
|
||||
const j = await api.aiTools.job();
|
||||
setJob(j);
|
||||
if (j?.status !== "running") {
|
||||
void reload();
|
||||
void loadVersions();
|
||||
}
|
||||
if (j?.status !== "running") void reload();
|
||||
} catch {
|
||||
/* next tick */
|
||||
}
|
||||
@@ -160,14 +180,10 @@ export function AiToolsSettings() {
|
||||
if (inst?.kind === "script") return t("aiTools.runScript", { url: inst.url });
|
||||
return "";
|
||||
};
|
||||
// Mirrors the server's update-all: badged npm tools, then every self updater.
|
||||
const updateAllTools = tools.filter(
|
||||
(x) =>
|
||||
x.installed &&
|
||||
(x.update?.kind === "self" || (x.update?.kind === "npm" && versions[x.id]?.updateAvailable))
|
||||
);
|
||||
const canUpdate = (tool: AiTool) =>
|
||||
!!tool.update && (tool.update.kind !== "npm" || versions[tool.id]?.npmManaged === true);
|
||||
const installed = tools.filter((x) => x.installed);
|
||||
const available = tools.filter((x) => !x.installed);
|
||||
// Mirrors the server's update-all: every installed tool with an updater.
|
||||
const updateAllTools = installed.filter((x) => x.update);
|
||||
|
||||
const startConfirmed = async () => {
|
||||
const c = confirm;
|
||||
@@ -186,8 +202,6 @@ export function AiToolsSettings() {
|
||||
|
||||
const jobBusy = running || busy !== null;
|
||||
const logFor = (id: string | null) => (job && job.tool === id ? <JobLog job={job} /> : null);
|
||||
const installed = tools.filter((x) => x.installed);
|
||||
const available = tools.filter((x) => !x.installed);
|
||||
|
||||
const confirmLines =
|
||||
confirm?.kind === "update-all"
|
||||
@@ -197,149 +211,139 @@ export function AiToolsSettings() {
|
||||
: [];
|
||||
|
||||
return (
|
||||
<section id="ai-tools" ref={sectionRef} className="scroll-mt-24">
|
||||
<h3 className="text-sm font-medium text-fg-secondary flex items-center gap-2 mb-1">
|
||||
<Bot className="w-4 h-4 text-fg-muted" />
|
||||
{t("aiTools.title")}
|
||||
</h3>
|
||||
<p className="text-xs text-fg-muted mb-4">{t("aiTools.description")}</p>
|
||||
|
||||
<div className="card p-5 space-y-5">
|
||||
<div>
|
||||
<div className="flex items-center justify-between gap-3 mb-2">
|
||||
<p className="text-xs font-medium text-fg-secondary">
|
||||
{t("aiTools.installed", { count: installed.length })}
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="space-y-5">
|
||||
<div>
|
||||
<div className="mb-2 flex items-center justify-between gap-3">
|
||||
<p className="text-xs font-medium text-fg-secondary">
|
||||
{t("aiTools.installed", { count: installed.length })}
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
{updateAllTools.length > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setConfirm({ kind: "update-all" })}
|
||||
disabled={jobBusy || updateAllTools.length === 0}
|
||||
className="inline-flex items-center gap-1.5 rounded-md border border-border px-2 py-1 text-xs text-fg-secondary hover:bg-surface-3 disabled:opacity-50"
|
||||
disabled={jobBusy}
|
||||
className={GHOST_BTN}
|
||||
>
|
||||
<Download className="w-3.5 h-3.5" />
|
||||
{t("aiTools.updateTools")}
|
||||
{t("aiTools.updateAll", { count: updateAllTools.length })}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
void run("refresh", async () => {
|
||||
await refresh();
|
||||
await loadVersions();
|
||||
})
|
||||
}
|
||||
disabled={busy !== null}
|
||||
className="inline-flex items-center gap-1.5 rounded-md border border-border px-2 py-1 text-xs text-fg-secondary hover:bg-surface-3 disabled:opacity-50"
|
||||
>
|
||||
<RefreshCw className={`w-3.5 h-3.5 ${busy === "refresh" ? "animate-spin" : ""}`} />
|
||||
{t("aiTools.refresh")}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void run("refresh", refresh)}
|
||||
disabled={busy !== null}
|
||||
className={GHOST_BTN}
|
||||
>
|
||||
<RefreshCw className={`w-3.5 h-3.5 ${busy === "refresh" ? "animate-spin" : ""}`} />
|
||||
{t("aiTools.refresh")}
|
||||
</button>
|
||||
</div>
|
||||
{error && <p className="mb-2 text-xs text-status-danger">{error}</p>}
|
||||
{logFor(null)}
|
||||
{loaded && installed.length === 0 && (
|
||||
<p className="text-xs text-fg-muted">{t("aiTools.noneInstalled")}</p>
|
||||
)}
|
||||
<ul className="divide-y divide-border rounded-md border border-border">
|
||||
{installed.map((tool) => {
|
||||
const isDefault = tool.id === defaultId;
|
||||
const v = versions[tool.id];
|
||||
return (
|
||||
<li
|
||||
key={tool.id}
|
||||
data-testid={`ai-tool-${tool.id}`}
|
||||
className={`flex flex-wrap items-center gap-3 px-3 py-2 ${tool.enabled ? "" : "opacity-60"}`}
|
||||
>
|
||||
<Avatar tool={tool} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-1.5 text-sm text-fg-primary">
|
||||
{tool.label}
|
||||
<Homepage tool={tool} />
|
||||
{v?.updateAvailable && v.latest && (
|
||||
<span className="rounded bg-accent/10 px-1.5 py-0.5 text-[10px] text-accent">
|
||||
{t("aiTools.newVersion", { version: v.latest })}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
className="truncate font-mono text-[11px] text-fg-muted"
|
||||
title={tool.path ?? ""}
|
||||
>
|
||||
{tool.command}
|
||||
{v?.installed ? ` · v${v.installed}` : ""}
|
||||
{tool.update?.kind === "npm" && v && !v.npmManaged && (
|
||||
<span className="ml-1.5 font-sans">· {t("aiTools.outsideNpm")}</span>
|
||||
)}
|
||||
</div>
|
||||
{error && <p className="mb-2 text-xs text-status-danger">{error}</p>}
|
||||
{logFor(null)}
|
||||
{loaded && installed.length === 0 && (
|
||||
<p className="text-xs text-fg-muted">{t("aiTools.noneInstalled")}</p>
|
||||
)}
|
||||
<ul className="divide-y divide-border overflow-hidden rounded-lg border border-border bg-surface-2">
|
||||
{installed.map((tool) => {
|
||||
const isDefault = tool.id === defaultId;
|
||||
return (
|
||||
<li key={tool.id} data-testid={`ai-tool-${tool.id}`} className="px-3 py-2.5">
|
||||
<div className="flex items-center gap-3">
|
||||
<div
|
||||
className={`flex min-w-0 flex-1 items-center gap-3 ${tool.enabled ? "" : "opacity-50"}`}
|
||||
>
|
||||
<AiToolIcon tool={tool} />
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-1.5 text-sm font-medium text-fg-primary">
|
||||
<span className="truncate">{tool.label}</span>
|
||||
<Homepage tool={tool} />
|
||||
{isDefault && (
|
||||
<span className="rounded-full bg-accent/15 px-2 py-0.5 text-[10px] font-medium text-accent">
|
||||
{t("aiTools.default")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
className="truncate font-mono text-[11px] text-fg-muted"
|
||||
title={tool.path ?? ""}
|
||||
>
|
||||
{tool.command}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{canUpdate(tool) && (
|
||||
<button
|
||||
type="button"
|
||||
disabled={jobBusy}
|
||||
onClick={() => setConfirm({ kind: "update", tool })}
|
||||
className="rounded-md border border-border px-2 py-1 text-xs text-fg-secondary hover:bg-surface-3 disabled:opacity-50"
|
||||
>
|
||||
{t("aiTools.update")}
|
||||
</button>
|
||||
)}
|
||||
{isDefault ? (
|
||||
<span className="text-xs text-accent">✓ {t("aiTools.default")}</span>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
disabled={!tool.enabled || busy !== null}
|
||||
onClick={() => void run(tool.id, () => update(tool.id, { default: true }))}
|
||||
className="rounded-md border border-border px-2 py-1 text-xs text-fg-secondary hover:bg-surface-3 disabled:opacity-50"
|
||||
>
|
||||
{t("aiTools.setDefault")}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={tool.enabled}
|
||||
aria-label={t("aiTools.enabledOf", { tool: tool.label })}
|
||||
{/* Fixed slots, so the buttons line up from row to row. */}
|
||||
<div className="flex min-w-20 shrink-0 justify-end">
|
||||
{tool.update && (
|
||||
<button
|
||||
type="button"
|
||||
disabled={jobBusy}
|
||||
onClick={() => setConfirm({ kind: "update", tool })}
|
||||
className={GHOST_BTN}
|
||||
>
|
||||
{t("aiTools.update")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex min-w-28 shrink-0 justify-end">
|
||||
{!isDefault && (
|
||||
<button
|
||||
type="button"
|
||||
disabled={!tool.enabled || busy !== null}
|
||||
onClick={() => void run(tool.id, () => update(tool.id, { default: true }))}
|
||||
className={GHOST_BTN}
|
||||
>
|
||||
{t("aiTools.setDefault")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<Switch
|
||||
tool={tool}
|
||||
disabled={busy !== null}
|
||||
onClick={() =>
|
||||
onToggle={() =>
|
||||
void run(tool.id, () => update(tool.id, { enabled: !tool.enabled }))
|
||||
}
|
||||
className={`w-24 rounded-md border px-2 py-1 text-xs disabled:opacity-50 ${
|
||||
tool.enabled
|
||||
? "border-status-success/40 bg-status-success/10 text-status-success"
|
||||
: "border-border bg-surface-2 text-fg-muted"
|
||||
}`}
|
||||
>
|
||||
{tool.enabled ? t("aiTools.enabled") : t("aiTools.disabled")}
|
||||
</button>
|
||||
{logFor(tool.id)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
/>
|
||||
</div>
|
||||
{logFor(tool.id)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{available.length > 0 && (
|
||||
<div>
|
||||
<p className="text-xs font-medium text-fg-secondary mb-2">
|
||||
{t("aiTools.available", { count: available.length })}
|
||||
</p>
|
||||
{available.length > 0 && (
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
aria-expanded={showAvailable}
|
||||
onClick={() => setShowAvailable((s) => !s)}
|
||||
className="mb-2 inline-flex items-center gap-1.5 text-xs font-medium text-fg-secondary hover:text-fg-primary"
|
||||
>
|
||||
<ChevronDown
|
||||
className={`w-3.5 h-3.5 transition-transform ${showAvailable ? "" : "-rotate-90"}`}
|
||||
/>
|
||||
{t("aiTools.available", { count: available.length })}
|
||||
</button>
|
||||
{showAvailable && (
|
||||
<ul className="grid grid-cols-1 gap-2 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{available.map((tool) => (
|
||||
<li
|
||||
key={tool.id}
|
||||
data-testid={`ai-tool-${tool.id}`}
|
||||
className={`flex flex-wrap items-center gap-2 rounded-md border border-border px-2.5 py-1.5 ${
|
||||
className={`flex flex-wrap items-center gap-2 rounded-lg border border-border bg-surface-2 px-2.5 py-2 ${
|
||||
job?.tool === tool.id ? "sm:col-span-2 lg:col-span-3" : ""
|
||||
}`}
|
||||
>
|
||||
<Avatar tool={tool} />
|
||||
<span className="min-w-0 flex-1 truncate text-xs text-fg-secondary">
|
||||
{tool.label}
|
||||
<span className="ml-1.5 font-mono text-[10px] text-fg-muted">
|
||||
<AiToolIcon tool={tool} />
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-xs text-fg-primary">{tool.label}</span>
|
||||
<span className="block truncate font-mono text-[10px] text-fg-muted">
|
||||
{tool.command}
|
||||
</span>
|
||||
</span>
|
||||
<Homepage tool={tool} />
|
||||
{tool.install && (
|
||||
<button
|
||||
type="button"
|
||||
@@ -350,14 +354,13 @@ export function AiToolsSettings() {
|
||||
{t("aiTools.install")}
|
||||
</button>
|
||||
)}
|
||||
<Homepage tool={tool} />
|
||||
{logFor(tool.id)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ConfirmModal
|
||||
open={confirm !== null}
|
||||
@@ -384,6 +387,6 @@ export function AiToolsSettings() {
|
||||
{confirmLines.join("\n")}
|
||||
</pre>
|
||||
</ConfirmModal>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* @file AntigravityHooksSettings.tsx
|
||||
* @description Settings → Antigravity → AgentHub → Antigravity hooks: whether
|
||||
* @description Settings → Antigravity → Antigravity hooks: whether
|
||||
* AgentHub's named hook is in `~/.gemini/config/hooks.json` (and not switched
|
||||
* off there), per event, with Install/Reinstall, Remove and Refresh. Nothing is
|
||||
* written until the user clicks; agy asks for no trust, and has no permission
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
/**
|
||||
* @file ClaudeAgentHubSettings.tsx
|
||||
* @description Settings → Claude Code → AgentHub: the dashboard's own settings
|
||||
* that belong to Claude Code — Model Pricing for Claude models (the shared
|
||||
* `PricingSettings` table, with Reset Defaults), the AgentHub hooks installed
|
||||
* into Claude Code with a Reinstall button, and the CLAUDE_HOME the server
|
||||
* reads transcripts and config from. Moved here from Settings → Dashboard
|
||||
* (#40 part B).
|
||||
* @description The dashboard's own settings that belong to Claude Code, shown
|
||||
* under Settings → Claude Code → Overview: the CLAUDE_HOME the server reads
|
||||
* transcripts and config from, the AgentHub hooks installed into Claude Code
|
||||
* with a Reinstall button, and Model Pricing for Claude models (the shared
|
||||
* `PricingSettings` table, with Reset Defaults).
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
@@ -125,7 +124,32 @@ export function ClaudeAgentHubSettings({ onHomeChanged }: { onHomeChanged?: () =
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<PricingSettings vendor="claude" allowReset />
|
||||
<HomeDirSetting
|
||||
id="claude-home"
|
||||
title={t("claudeHome.title")}
|
||||
description={t("claudeHome.description")}
|
||||
note={t("cursorPathsNote")}
|
||||
placeholder={t("claudeHome.placeholder")}
|
||||
load={() =>
|
||||
api.settings.claudeHome
|
||||
.get()
|
||||
.then((r) => ({ value: r.claude_home, envOwned: !!r.env_owned }))
|
||||
}
|
||||
save={(p) =>
|
||||
api.settings.claudeHome.set(p).then((r) => {
|
||||
void load();
|
||||
onHomeChanged?.();
|
||||
const missing = r.routers?.missing ?? [];
|
||||
return {
|
||||
value: r.claude_home,
|
||||
envOwned: !!r.env_owned,
|
||||
warning: missing.length
|
||||
? t("claudeHome.routersMissing", { lanes: missing.join(", ") })
|
||||
: null,
|
||||
};
|
||||
})
|
||||
}
|
||||
/>
|
||||
{error && (
|
||||
<div className="px-4 py-2.5 bg-status-danger/10 border border-status-danger/20 rounded-lg text-sm text-status-danger">
|
||||
{error}
|
||||
@@ -192,32 +216,7 @@ export function ClaudeAgentHubSettings({ onHomeChanged }: { onHomeChanged?: () =
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<HomeDirSetting
|
||||
id="claude-home"
|
||||
title={t("claudeHome.title")}
|
||||
description={t("claudeHome.description")}
|
||||
note={t("cursorPathsNote")}
|
||||
placeholder={t("claudeHome.placeholder")}
|
||||
load={() =>
|
||||
api.settings.claudeHome
|
||||
.get()
|
||||
.then((r) => ({ value: r.claude_home, envOwned: !!r.env_owned }))
|
||||
}
|
||||
save={(p) =>
|
||||
api.settings.claudeHome.set(p).then((r) => {
|
||||
void load();
|
||||
onHomeChanged?.();
|
||||
const missing = r.routers?.missing ?? [];
|
||||
return {
|
||||
value: r.claude_home,
|
||||
envOwned: !!r.env_owned,
|
||||
warning: missing.length
|
||||
? t("claudeHome.routersMissing", { lanes: missing.join(", ") })
|
||||
: null,
|
||||
};
|
||||
})
|
||||
}
|
||||
/>
|
||||
<PricingSettings vendor="claude" allowReset />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* @file CodexHooksSettings.tsx
|
||||
* @description Settings → Codex → AgentHub → Codex hooks: whether AgentHub's
|
||||
* @description Settings → Codex → Overview → Codex hooks: whether AgentHub's
|
||||
* hooks are in `$CODEX_HOME/hooks.json` and trusted by Codex, per event, with
|
||||
* Install/Reinstall and Remove. Nothing is written until the user clicks;
|
||||
* Codex itself asks the user to trust the hooks before it runs them, and this
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
/**
|
||||
* @file HomeDirSetting.tsx
|
||||
* @description One editable home directory (CLAUDE_HOME in Claude Code →
|
||||
* AgentHub, CODEX_HOME in Codex → AgentHub): a path field with Save, the
|
||||
* server's refusal inline, the value in effect, and a warning when the real
|
||||
* Overview, CODEX_HOME in Codex → Overview): a path field with Save, the
|
||||
* server's refusal inline, the saved value while the field differs from it,
|
||||
* and a warning when the real
|
||||
* environment sets the variable (the environment wins at the next start, so a
|
||||
* saved value would not survive a restart). The server validates and persists;
|
||||
* this component only loads, submits and reports.
|
||||
@@ -105,7 +106,8 @@ export function HomeDirSetting({ id, title, description, note, placeholder, load
|
||||
</button>
|
||||
</div>
|
||||
{error && <p className="text-xs text-status-danger">{error}</p>}
|
||||
{current && (
|
||||
{/* The saved value, only while the field says something else. */}
|
||||
{current && input !== current.value && (
|
||||
<p className="text-xs text-fg-muted">
|
||||
{t("homeDir.current")} <code className="text-fg-secondary">{current.value}</code>
|
||||
</p>
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
/**
|
||||
* @file PricingSettings.tsx
|
||||
* @description Model Pricing — the per-model rates every session's cost is
|
||||
* computed from — as shown in a tool's Settings → AgentHub sub-tab: Claude
|
||||
* computed from — as shown on a tool's Settings tab (Overview for Claude Code
|
||||
* and Codex, the Antigravity tab itself): Claude
|
||||
* Code's shows `claude-…` rows, Codex's shows OpenAI rows (`gpt-…`, `o<n>…`,
|
||||
* `codex-…`), Antigravity's Gemini rows (`gemini-…`). It is ONE table (a router model can serve either tool), so a
|
||||
* **Show all models** switch lists every row, and a row added outside the
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
/**
|
||||
* @file AiToolsSettings.test.tsx
|
||||
* @description Settings → AI Tools: installed rows carry Enabled/Disabled and
|
||||
* Set default, available rows a homepage link, Refresh re-detects, a
|
||||
* disabled tool's Settings tab disappears from the hub, and Install/Update
|
||||
* confirm the exact command, then show the polled job log.
|
||||
* @description The AI Tools modal: the Settings tab bar's button and the
|
||||
* `#ai-tools` deep link open it, Escape closes it; installed rows carry an
|
||||
* enable switch and Set default, available rows fold behind a disclosure with
|
||||
* a homepage link, Refresh re-detects, a disabled tool's Settings tab
|
||||
* disappears from the hub, every updater gets Update without a version
|
||||
* lookup, and Install/Update confirm the exact command, then show the polled
|
||||
* job log, folded once it is done.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
@@ -29,7 +32,6 @@ vi.mock("../../../lib/api", async (importOriginal) => {
|
||||
updateTool: vi.fn(),
|
||||
updateAll: vi.fn(),
|
||||
job: vi.fn(),
|
||||
versions: vi.fn(),
|
||||
iconUrl: (id: string) => `/api/ai-tools/${id}/icon`,
|
||||
},
|
||||
},
|
||||
@@ -71,7 +73,6 @@ beforeEach(() => {
|
||||
vi.mocked(api.aiTools.update).mockReset().mockResolvedValue(VIEW);
|
||||
vi.mocked(api.aiTools.refresh).mockReset().mockResolvedValue(VIEW);
|
||||
vi.mocked(api.aiTools.job).mockReset().mockResolvedValue(null);
|
||||
vi.mocked(api.aiTools.versions).mockReset().mockResolvedValue({});
|
||||
vi.mocked(api.aiTools.install).mockReset();
|
||||
vi.mocked(api.aiTools.updateTool).mockReset();
|
||||
vi.mocked(api.aiTools.updateAll).mockReset();
|
||||
@@ -93,7 +94,7 @@ describe("AiToolsSettings", () => {
|
||||
it("splits installed tools from the ones available to install", async () => {
|
||||
render(<AiToolsSettings />);
|
||||
expect(await screen.findByText("Installed (3 detected)")).toBeInTheDocument();
|
||||
expect(screen.getByText("Available to install (1)")).toBeInTheDocument();
|
||||
await userEvent.click(screen.getByRole("button", { name: "Available to install (1)" }));
|
||||
const claude = screen.getByTestId("ai-tool-claude");
|
||||
expect(within(claude).getByText(/Default/)).toBeInTheDocument();
|
||||
const aider = screen.getByTestId("ai-tool-aider");
|
||||
@@ -120,10 +121,8 @@ describe("AiToolsSettings", () => {
|
||||
const user = userEvent.setup();
|
||||
render(<AiToolsSettings />);
|
||||
await screen.findByTestId("ai-tool-claude");
|
||||
const before = vi.mocked(api.aiTools.versions).mock.calls.length;
|
||||
await user.click(screen.getByRole("button", { name: "Refresh" }));
|
||||
expect(api.aiTools.refresh).toHaveBeenCalledOnce();
|
||||
expect(vi.mocked(api.aiTools.versions).mock.calls.length).toBe(before + 1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -154,11 +153,9 @@ describe("AiToolsSettings — install and update", () => {
|
||||
};
|
||||
beforeEach(() => {
|
||||
vi.mocked(api.aiTools.list).mockResolvedValue(A2);
|
||||
vi.mocked(api.aiTools.versions).mockResolvedValue({
|
||||
codex: { npmManaged: false, installed: null, latest: null, updateAvailable: false },
|
||||
gemini: { npmManaged: true, installed: "0.60.0", latest: "0.61.0", updateAvailable: true },
|
||||
});
|
||||
});
|
||||
const openAvailable = async () =>
|
||||
userEvent.click(await screen.findByRole("button", { name: /Available to install/ }));
|
||||
|
||||
it("confirms the exact npm command, then polls and shows the log", async () => {
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true });
|
||||
@@ -169,6 +166,7 @@ describe("AiToolsSettings — install and update", () => {
|
||||
.mockResolvedValueOnce(null)
|
||||
.mockResolvedValue(job({ status: "ok", exitCode: 0, log: "added 1 package\n" }));
|
||||
render(<AiToolsSettings />);
|
||||
await user.click(await screen.findByRole("button", { name: /Available to install/ }));
|
||||
const qwen = await screen.findByTestId("ai-tool-qwen");
|
||||
await user.click(within(qwen).getByRole("button", { name: "Install" }));
|
||||
expect(screen.getByTestId("ai-tool-confirm-command")).toHaveTextContent(
|
||||
@@ -182,6 +180,8 @@ describe("AiToolsSettings — install and update", () => {
|
||||
vi.advanceTimersByTime(1100);
|
||||
});
|
||||
expect(await within(qwen).findByText("Done")).toBeInTheDocument();
|
||||
expect(within(qwen).queryByTestId("ai-tool-log")).toBeNull(); // folded once done
|
||||
await user.click(within(qwen).getByRole("button", { name: "Show log" }));
|
||||
expect(within(qwen).getByTestId("ai-tool-log")).toHaveTextContent("added 1 package");
|
||||
const calls = vi.mocked(api.aiTools.job).mock.calls.length;
|
||||
await act(async () => {
|
||||
@@ -196,6 +196,7 @@ describe("AiToolsSettings — install and update", () => {
|
||||
it("names the vendor script for a script install", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<AiToolsSettings />);
|
||||
await openAvailable();
|
||||
const cursor = await screen.findByTestId("ai-tool-cursor");
|
||||
await user.click(within(cursor).getByRole("button", { name: "Install" }));
|
||||
expect(screen.getByTestId("ai-tool-confirm-command")).toHaveTextContent(
|
||||
@@ -203,35 +204,34 @@ describe("AiToolsSettings — install and update", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("badges a newer npm version and offers no npm update outside npm", async () => {
|
||||
it("offers Update on every installed tool with an updater", async () => {
|
||||
render(<AiToolsSettings />);
|
||||
const gemini = await screen.findByTestId("ai-tool-gemini");
|
||||
expect(await within(gemini).findByText("v0.61.0 available")).toBeInTheDocument();
|
||||
expect(within(gemini).getByRole("button", { name: "Update" })).toBeInTheDocument();
|
||||
const codex = screen.getByTestId("ai-tool-codex");
|
||||
expect(within(codex).getByText(/Installed outside npm/)).toBeInTheDocument();
|
||||
expect(within(codex).queryByRole("button", { name: "Update" })).toBeNull();
|
||||
for (const id of ["claude", "codex", "gemini"]) {
|
||||
const row = await screen.findByTestId(`ai-tool-${id}`);
|
||||
expect(within(row).getByRole("button", { name: "Update" })).toBeInTheDocument();
|
||||
}
|
||||
});
|
||||
|
||||
it("Update tools lists the badged npm tools and the self updaters", async () => {
|
||||
it("Update all lists every installed tool's updater", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<AiToolsSettings />);
|
||||
await screen.findByText("v0.61.0 available");
|
||||
await user.click(screen.getByRole("button", { name: "Update tools" }));
|
||||
await user.click(await screen.findByRole("button", { name: "Update all (3)" }));
|
||||
const cmd = screen.getByTestId("ai-tool-confirm-command");
|
||||
expect(cmd).toHaveTextContent("claude update");
|
||||
expect(cmd).toHaveTextContent("npm install -g @google/gemini-cli@latest");
|
||||
expect(cmd).not.toHaveTextContent("@openai/codex");
|
||||
expect(cmd).toHaveTextContent("npm install -g @openai/codex@latest");
|
||||
});
|
||||
|
||||
it("disables Update tools when nothing has an update", async () => {
|
||||
it("hides Update all when no tool has an updater", async () => {
|
||||
vi.mocked(api.aiTools.list).mockResolvedValue(VIEW);
|
||||
render(<AiToolsSettings />);
|
||||
expect(await screen.findByRole("button", { name: "Update tools" })).toBeDisabled();
|
||||
await screen.findByTestId("ai-tool-claude");
|
||||
expect(screen.queryByRole("button", { name: /Update all/ })).toBeNull();
|
||||
});
|
||||
|
||||
it("falls back to the letter when the icon fails", async () => {
|
||||
const { container } = render(<AiToolsSettings />);
|
||||
await openAvailable();
|
||||
await screen.findByTestId("ai-tool-qwen");
|
||||
const img = container.querySelector('img[src="/api/ai-tools/qwen/icon"]')!;
|
||||
expect(img).toBeTruthy();
|
||||
@@ -256,4 +256,42 @@ describe("SettingsHub — AI tool tabs", () => {
|
||||
expect(await screen.findByRole("tab", { name: /Claude Code/ })).toBeInTheDocument();
|
||||
expect(screen.queryByRole("tab", { name: /^Codex/ })).toBeNull();
|
||||
});
|
||||
|
||||
it("opens AI Tools as a modal from the tab bar, and closes it on Escape", async () => {
|
||||
vi.doMock("../../../pages/Settings", () => ({ Settings: () => null }));
|
||||
const { SettingsHub } = await import("../../../pages/SettingsHub");
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<SettingsHub />
|
||||
</MemoryRouter>
|
||||
);
|
||||
expect(screen.queryByRole("dialog")).toBeNull();
|
||||
await userEvent.click(await screen.findByRole("button", { name: "AI Tools" }));
|
||||
const dialog = screen.getByRole("dialog", { name: "AI Tools" });
|
||||
expect(await within(dialog).findByText("Installed (3 detected)")).toBeInTheDocument();
|
||||
fireEvent.keyDown(window, { key: "Escape" });
|
||||
expect(screen.queryByRole("dialog")).toBeNull();
|
||||
});
|
||||
|
||||
it("opens the modal for the /settings#ai-tools deep link", async () => {
|
||||
vi.doMock("../../../pages/Settings", () => ({ Settings: () => null }));
|
||||
const { SettingsHub } = await import("../../../pages/SettingsHub");
|
||||
render(
|
||||
<MemoryRouter initialEntries={["/settings#ai-tools"]}>
|
||||
<SettingsHub />
|
||||
</MemoryRouter>
|
||||
);
|
||||
expect(await screen.findByRole("dialog", { name: "AI Tools" })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("AiToolIcon", () => {
|
||||
it("retries the icon when the same instance shows another tool", async () => {
|
||||
const { AiToolIcon } = await import("../../AiToolIcon");
|
||||
const { container, rerender } = render(<AiToolIcon tool={{ id: "claude", label: "Claude" }} />);
|
||||
fireEvent.error(container.querySelector("img")!);
|
||||
expect(screen.getByText("C")).toBeInTheDocument();
|
||||
rerender(<AiToolIcon tool={{ id: "codex", label: "Codex" }} />);
|
||||
expect(container.querySelector('img[src="/api/ai-tools/codex/icon"]')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import i18n from "i18next";
|
||||
@@ -43,11 +43,15 @@ describe("HomeDirSetting", () => {
|
||||
expect(button).toBeDisabled();
|
||||
expect(container.querySelector("section#x-home")).not.toBeNull();
|
||||
|
||||
// The saved value shows only while the field says something else.
|
||||
expect(screen.queryByText("/a", { selector: "code" })).toBeNull();
|
||||
await user.clear(input);
|
||||
await user.type(input, "/b");
|
||||
expect(screen.getByText("/a", { selector: "code" })).toBeInTheDocument();
|
||||
await user.click(button);
|
||||
expect(saveFn).toHaveBeenCalledWith("/b");
|
||||
expect(await screen.findByText("/b", { selector: "code" })).toBeInTheDocument();
|
||||
await waitFor(() => expect(screen.queryByText("/a", { selector: "code" })).toBeNull());
|
||||
expect(screen.getByDisplayValue("/b")).toBeInTheDocument();
|
||||
expect(screen.queryByText(/set by the environment/)).toBeNull();
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user