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

- AI Tools moves from a Settings section into a modal behind a button at the
  Settings tab bar's right edge (AiToolsModal, `actions` on PageTabs,
  /settings#ai-tools); rows get an enable switch, Default badge, and an
  Update button for every tool whose catalog names an updater (no version
  lookup on open); not-installed tools fold behind a disclosure, a finished
  job's log folds to one line; shared AiToolIcon
- codex updates through its own `codex update`, openclaw/hermes gain
  `update --yes`; update-all runs every installed updater and skips an npm
  one only when npm does not manage the binary
- Add-lane tool picker is one button showing the chosen tool that opens the
  enabled tools as buttons
- Claude Code / Codex AgentHub sub-tab folded into Overview (home editor,
  hooks, pricing); the path list drops CLAUDE_HOME/CODEX_HOME;
  HomeDirSetting shows the saved value only while the field differs
- Preview/Raw toggle for markdown files in the config FileViewer and the
  Changes panel's whole-file view (MarkdownPreview: react-markdown +
  remark-gfm, raw HTML never run, frontmatter as a code block)
- docs and the CLAUDE.md marker updated
This commit is contained in:
2026-10-01 10:11:43 +07:00
parent 058c574138
commit c5aba094bd
55 changed files with 2985 additions and 853 deletions
+56 -32
View File
@@ -19,7 +19,7 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { FolderOpen } from "lucide-react";
import { ChevronDown, FolderOpen } from "lucide-react";
import { ConfirmModal } from "../ConfirmModal";
import { CwdAutocomplete } from "../run/RunSetup";
import { FolderBrowseModal } from "./FolderBrowseModal";
@@ -27,6 +27,7 @@ import { api } from "../../lib/api";
import type { AiRouter, CwdSuggestion } from "../../lib/api";
import type { Lane, LaneWorktree } from "../../lib/types";
import { useAiTools } from "../../hooks/useAiTools";
import { AiToolIcon } from "../AiToolIcon";
/** Polls the lane until the background `git worktree add` finishes (status
* leaves "provisioning"), or gives up after `timeoutMs`. Returns the final
@@ -115,7 +116,17 @@ export function AddLaneModal({
const aiTools = useAiTools();
const defaultTool = aiTools.defaultId ?? "claude";
const tool = pickedTool ?? defaultTool;
const otherTools = aiTools.tools.filter((x) => x.installed && x.enabled && x.id !== defaultTool);
// Installed + enabled, the default first.
const toolChoices = aiTools.tools
.filter((x) => x.installed && x.enabled)
.sort((a, b) => Number(b.id === defaultTool) - Number(a.id === defaultTool));
const [toolListOpen, setToolListOpen] = useState(false);
const pickTool = (id: string) => {
setPickedTool(id === defaultTool ? null : id);
// A tool with no endpoint to rewrite runs native only (see router-apply.js).
if (!aiTools.byId(id)?.routerCapable) setRouter("native");
setToolListOpen(false);
};
const noTools = aiTools.loaded && aiTools.defaultId === null;
const toolRouterCapable =
aiTools.byId(tool)?.routerCapable ?? (tool === "claude" || tool === "codex");
@@ -431,38 +442,51 @@ export function AddLaneModal({
</a>
</p>
) : (
<div className="flex items-center gap-1.5">
<div className="flex items-center rounded-md border border-border bg-surface-2 p-0.5">
<Seg
active={tool === defaultTool}
label={t("addLaneToolDefault", { tool: aiTools.label(defaultTool) })}
onClick={() => setPickedTool(null)}
/>
</div>
{otherTools.length > 0 && (
<select
aria-label={t("addLaneToolOther")}
value={tool === defaultTool ? "" : tool}
onChange={(e) => {
const next = e.target.value || null;
setPickedTool(next);
// A tool with no endpoint to rewrite runs native only
// (see router-apply.js).
if (next && !aiTools.byId(next)?.routerCapable) setRouter("native");
}}
className={`rounded-md border bg-surface-2 px-2 py-1 text-xs ${
tool !== defaultTool
? "border-accent text-accent"
: "border-border text-fg-secondary"
}`}
<div>
<button
type="button"
aria-expanded={toolListOpen}
disabled={toolChoices.length < 2}
onClick={() => setToolListOpen((o) => !o)}
className="flex w-full items-center gap-2 rounded-md border border-border bg-surface-2 px-2.5 py-1.5 text-left text-sm text-fg-primary hover:bg-surface-3 disabled:cursor-default disabled:hover:bg-surface-2"
>
<AiToolIcon tool={{ id: tool, label: aiTools.label(tool) }} className="h-5 w-5" />
<span className="min-w-0 flex-1 truncate">{aiTools.label(tool)}</span>
{tool === defaultTool && (
<span className="rounded-full bg-accent/15 px-2 py-0.5 text-[10px] font-medium text-accent">
{t("addLaneToolDefaultBadge")}
</span>
)}
{toolChoices.length > 1 && (
<ChevronDown
aria-hidden
className={`h-4 w-4 text-fg-muted transition-transform ${toolListOpen ? "rotate-180" : ""}`}
/>
)}
</button>
{toolListOpen && (
<div
role="group"
aria-label={t("addLaneToolHint")}
className="mt-1.5 grid grid-cols-2 gap-1.5 sm:grid-cols-3"
>
<option value="">{t("addLaneToolOther")}</option>
{otherTools.map((x) => (
<option key={x.id} value={x.id}>
{x.label}
</option>
{toolChoices.map((x) => (
<button
key={x.id}
type="button"
aria-pressed={x.id === tool}
onClick={() => pickTool(x.id)}
className={`flex items-center gap-2 rounded-md border px-2 py-1.5 text-left text-xs transition-colors ${
x.id === tool
? "border-accent bg-accent/10 text-accent"
: "border-border bg-surface-2 text-fg-secondary hover:bg-surface-3 hover:text-fg-primary"
}`}
>
<AiToolIcon tool={x} className="h-5 w-5" />
<span className="min-w-0 flex-1 truncate">{x.label}</span>
</button>
))}
</select>
</div>
)}
</div>
)}
@@ -5,7 +5,8 @@
* overview — every changed file with its +/- counts and its stage/discard
* actions; click one to open it
* review — every file's full diff, stacked, for reading a whole change-set
* file — one file, as a diff or as its whole content (issue #35)
* file — one file, as a diff or as its whole content (issue #35); a
* whole markdown file can also be read rendered (Preview)
* history — recent commits; opening one shows that commit's diff
* browse — quick-open by path, or the repo's tracked file tree
*
@@ -38,7 +39,9 @@ import {
ChevronsDownUp,
ChevronsUpDown,
Check,
Code2,
Columns2,
Eye,
FileText,
FoldVertical,
Folder,
@@ -53,6 +56,7 @@ import {
import { api } from "../../lib/api";
import { LaneDiffView, LaneFileView, type DiffLayout, type LaneDiffChange } from "./LaneDiffView";
import { LaneConflictView } from "./LaneConflictView";
import { MarkdownPreview, isMarkdown } from "../MarkdownPreview";
import { LaneGitHeader, useLaneGitFacts } from "./LaneGitHeader";
import type {
LaneChangeFile,
@@ -435,6 +439,9 @@ export function LaneChangesPanel({
const [context, setContext] = useState(CONTEXT_STEPS[0]);
const [layout, setLayout] = useState<DiffLayout>("unified");
const [whole, setWhole] = useState(false);
// A whole markdown file shown rendered instead of as source; per file.
const [mdPreview, setMdPreview] = useState(false);
useEffect(() => setMdPreview(false), [openFile]);
const [data, setData] = useState<LaneChanges | null>(null);
const [commits, setCommits] = useState<LaneCommit[] | null>(null);
// A folder's children, keyed by its path ("" is the root) — fetched once,
@@ -1429,6 +1436,18 @@ export function LaneChangesPanel({
binary={current.binary}
/>
)}
{whole && text && draft === null && isMarkdown(openFile) && (
<button
type="button"
data-testid="lane-file-preview"
aria-pressed={mdPreview}
onClick={() => setMdPreview((v) => !v)}
className="flex shrink-0 items-center gap-1 self-center rounded border border-border-light px-1.5 py-0.5 text-[11px] text-fg-secondary hover:bg-surface-2 hover:text-fg-primary"
>
{mdPreview ? <Code2 className="h-3 w-3" /> : <Eye className="h-3 w-3" />}
{mdPreview ? t("changes.raw") : t("changes.preview")}
</button>
)}
{/* Only the working file is editable: a commit is history. */}
{/* The whole file enters the editor by a click on its text;
only the diff, which has no whole text to click, keeps
@@ -1442,7 +1461,8 @@ export function LaneChangesPanel({
{t(noEditReason(text))}
</span>
) : draft === null && whole ? (
text && (
text &&
!mdPreview && (
<span className="shrink-0 self-center text-[10px] text-fg-muted">
{t("changes.clickToEdit")}
</span>
@@ -1503,7 +1523,11 @@ export function LaneChangesPanel({
className="min-h-0 flex-1 resize-none rounded border border-border-light bg-surface-0 p-2 font-mono text-[11px] leading-[1.45] text-fg-primary focus:border-accent focus:outline-none"
/>
) : whole ? (
text ? (
text && mdPreview ? (
<div className="min-h-0 flex-1 overflow-auto rounded border border-border-light bg-surface-0 p-4">
<MarkdownPreview text={text.lines.join("\n")} />
</div>
) : text ? (
<LaneFileView
path={text.path}
lines={text.lines}
@@ -10,7 +10,7 @@
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import { render, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { AddLaneModal } from "../AddLaneModal";
import { api } from "../../../lib/api";
@@ -32,7 +32,7 @@ vi.mock("../../../lib/api", () => ({
routerApply: vi.fn(),
},
aiRouters: { list: vi.fn() },
aiTools: { list: vi.fn() },
aiTools: { list: vi.fn(), iconUrl: (id: string) => `/api/ai-tools/${id}/icon` },
run: { start: vi.fn() },
},
}));
@@ -784,14 +784,20 @@ describe("AddLaneModal — tool selection", () => {
const dirField = screen.getByLabelText("Directory");
await focusField(user, dirField);
await user.type(dirField, "/tmp/demo");
const other = await screen.findByRole("combobox", { name: "Other…" });
// Installed + enabled only, and never the default itself.
expect(Array.from((other as HTMLSelectElement).options).map((o) => o.value)).toEqual([
"",
"codex",
"gemini",
]);
await user.selectOptions(other, "codex");
// One button showing the default; it opens every installed + enabled tool.
const picker = await screen.findByRole("button", { name: /Claude Code.*Default/ });
expect(picker).toHaveAttribute("aria-expanded", "false");
await user.click(picker);
const list = screen.getByRole("group", { name: /Which CLI drives this lane/ });
expect(
within(list)
.getAllByRole("button")
.map((b) => b.textContent)
).toEqual(["Claude Code", "Codex", "Gemini"]);
await user.click(within(list).getByRole("button", { name: "Codex" }));
// The picked tool replaces the default on the button, and the list closes.
expect(screen.getByRole("button", { name: "Codex", expanded: false })).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Gemini" })).toBeNull();
await user.click(screen.getByRole("button", { name: /add lane|create/i }));
await waitFor(() =>
@@ -802,7 +808,8 @@ describe("AddLaneModal — tool selection", () => {
it("offers only Native for a tool that cannot use an AI Router", async () => {
renderModal({});
const user = userEvent.setup();
await user.selectOptions(await screen.findByRole("combobox", { name: "Other…" }), "gemini");
await user.click(await screen.findByRole("button", { name: /Claude Code.*Default/ }));
await user.click(screen.getByRole("button", { name: "Gemini" }));
expect(screen.getByRole("radio", { name: "Native (Gemini)" })).toBeChecked();
expect(screen.getByRole("radio", { name: "AI Router" })).toBeDisabled();
});
@@ -321,6 +321,34 @@ describe("LaneChangesPanel", () => {
expect(screen.getByText("Staged Changes")).toBeInTheDocument();
});
it("reads a whole markdown file rendered after Preview, and back as source after Raw", async () => {
vi.mocked(api.lanes.gitTree).mockResolvedValue({
available: true,
path: "",
entries: [{ name: "README.md", path: "README.md", dir: false, status: " M" }],
truncated: false,
});
vi.mocked(api.lanes.gitFile).mockResolvedValue({
available: true,
path: "README.md",
rev: "work",
binary: false,
truncated: false,
lines: ["# Hello", "", "- one"],
sha: "c".repeat(40),
});
const user = userEvent.setup();
render(<LaneChangesPanel laneId={7} full />);
const sidebar = await screen.findByTestId("lane-changes-sidebar");
await user.click(await within(sidebar).findByText("README.md"));
await screen.findByTestId("lane-file-view");
await user.click(screen.getByTestId("lane-file-preview"));
expect(screen.getByRole("heading", { name: "Hello" })).toBeInTheDocument();
expect(screen.queryByTestId("lane-file-view")).toBeNull();
await user.click(screen.getByTestId("lane-file-preview"));
expect(await screen.findByTestId("lane-file-view")).toHaveTextContent("# Hello");
});
it("at full width shows the tree as a sidebar and opens a file for reading, editing on a click", async () => {
vi.mocked(api.lanes.gitTree).mockResolvedValue({
available: true,