feat(agent-settings): tab the Agents settings into per-backend panels (#2632)

* feat(agent-settings): tab the Agents settings into per-backend panels

Convert the Agents settings tab from stacked vertical sections into a
sub-tab strip (OpenCode / Claude / Codex / Quick Chat) built on the
existing setting-tabs primitive. The global default-backend picker and
MCP servers panel stay above the strip. Within each backend panel the
default-model picker now sits above the model enable list, followed by
the backend's binary/auth config. Quick Chat keeps its existing model
curation. Placement-only revamp; no default-model resolution, seeding,
or persistence changes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017Swdw8vKE3zczYJFzAboWG

* fix(agent-settings): anchor tab strip on switch; add Quick Chat icon + default-model picker

Switching sub-tabs no longer jumps the settings scroll: the tab strip is
pinned to its pre-switch viewport position when panel heights differ. The
Quick Chat tab gains a chat-bubble icon (was iconless) and a Default model
picker mirroring the per-agent panels, writing the shared defaultModelKey.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017Swdw8vKE3zczYJFzAboWG

* chore(scripts): reload deploy-target vault by cwd; timestamp-only build label

The Obsidian CLI resolves its target vault from the working directory, so the
reload step now runs from $VAULT_PATH (was hitting the repo's own vault). The
manifest label is tagged with the build timestamp only, not the branch name.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017Swdw8vKE3zczYJFzAboWG

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Logan Yang 2026-06-22 20:31:04 -07:00 committed by GitHub
parent 769b8724bf
commit f4da806b1c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 255 additions and 29 deletions

View file

@ -95,28 +95,45 @@ for f in main.js styles.css; do
fi
done
# Write a branch- and timestamp-tagged manifest.json (real file, not a symlink)
# so Obsidian's Community plugins list visibly reflects which worktree/branch
# is loaded and when this build was deployed.
# Write a timestamp-tagged manifest.json (real file, not a symlink). The plugin
# NAME (shown in Obsidian's Community plugins list / sidebar) carries the build
# timestamp ONLY — never the branch/worktree name, which is not a reliable signal
# of what code is actually loaded. The DESCRIPTION still carries `branch: <name>`
# because the `npm run test:vault` preflight in TESTING_GUIDE.md greps it
# (`/branch: ([^ |]+)/`) to catch deploying from the wrong worktree when several
# worktrees share one vault.
BRANCH="$(git -C "$WORKTREE_ROOT" rev-parse --abbrev-ref HEAD 2>/dev/null || echo unknown)"
BUILD_TS="$(date +%Y%m%d-%H%M%S)"
echo "==> Writing branch-tagged manifest.json (branch: $BRANCH, build: $BUILD_TS)"
echo "==> Writing timestamp-tagged manifest.json (name build: $BUILD_TS, branch: $BRANCH)"
rm -f "$PLUGIN_DIR/manifest.json"
SRC="$WORKTREE_ROOT/manifest.json" DEST="$PLUGIN_DIR/manifest.json" BRANCH="$BRANCH" BUILD_TS="$BUILD_TS" node -e '
const fs = require("fs");
const m = JSON.parse(fs.readFileSync(process.env.SRC, "utf8"));
m.name = m.name + " [" + process.env.BRANCH + " @ " + process.env.BUILD_TS + "]";
m.name = m.name + " [" + process.env.BUILD_TS + "]";
m.description = "[branch: " + process.env.BRANCH + " | build: " + process.env.BUILD_TS + "] " + m.description;
fs.writeFileSync(process.env.DEST, JSON.stringify(m, null, 2) + "\n");
'
echo "==> Reloading plugin in Obsidian"
# Reload by toggling disable -> enable, NOT `plugin:reload`. On this setup
# `plugin:reload` returns success but does NOT re-run the plugin's onload, so the
# freshly deployed main.js never executes. A disable+enable cycle re-runs onload.
#
# CRITICAL: the Obsidian CLI picks its TARGET VAULT from the current working
# directory (it resolves the vault enclosing $PWD; `vault=` does NOT override
# this). So we MUST run the CLI from inside the target vault's directory, or the
# reload silently hits whatever vault the caller's cwd sits in (e.g. the
# repo/worktree vault) instead of the deploy target. Hence the `cd "$VAULT_PATH"`.
echo "==> Reloading plugin in Obsidian (vault dir: $VAULT_PATH)"
if [[ ! -x "$OBSIDIAN_BIN" ]]; then
echo "warning: Obsidian CLI not found at $OBSIDIAN_BIN; skipping reload." >&2
else
if ! "$OBSIDIAN_BIN" plugin:enable id="$PLUGIN_ID" >/dev/null 2>&1 \
|| ! "$OBSIDIAN_BIN" plugin:reload id="$PLUGIN_ID" >/dev/null 2>&1; then
echo "warning: Obsidian doesn't appear to be running. Start it and the symlinked plugin will load on next open." >&2
( cd "$VAULT_PATH" && "$OBSIDIAN_BIN" plugin:disable id="$PLUGIN_ID" >/dev/null 2>&1 ) || true
if ( cd "$VAULT_PATH" && "$OBSIDIAN_BIN" plugin:enable id="$PLUGIN_ID" >/dev/null 2>&1 ); then
echo " reloaded (onload re-ran). Note: the sidebar manifest label only"
echo " refreshes on a full Obsidian restart; use a dev-console marker to"
echo " confirm the loaded build, not the label."
else
echo "warning: could not reload via the CLI. Is Obsidian running with this vault open? The plugin will load on next open." >&2
fi
fi

View file

@ -0,0 +1,115 @@
import { fireEvent, render, screen, within } from "@testing-library/react";
import React from "react";
import { AgentSettings } from "./AgentSettings";
jest.mock("@/logger", () => ({ logInfo: jest.fn(), logWarn: jest.fn(), logError: jest.fn() }));
jest.mock("obsidian", () => ({ Platform: { isMobile: false } }));
jest.mock("@/settings/model", () => ({
// eslint-disable-next-line @eslint-react/hooks-extra/no-unnecessary-use-prefix -- mocks the real hook; name must match the export
useSettingsValue: () => ({ agentMode: { activeBackend: "opencode", backends: {} } }),
setSettings: jest.fn(),
updateSetting: jest.fn(),
}));
// Mock the chat-backend options hook to avoid pulling the heavy @/modelManagement
// dependency chain (ByokPanel -> ConfirmModal extends Modal) into the test.
jest.mock("@/hooks/useChatBackendModelOptions", () => ({
// eslint-disable-next-line @eslint-react/hooks-extra/no-unnecessary-use-prefix -- mocks the real hook; name must match the export
useChatBackendModelOptions: () => ({ options: [], resolveSelectionId: () => undefined }),
}));
const Icon: React.FC<{ className?: string }> = () => <svg data-testid="icon" />;
function makeDescriptor(id: string, displayName: string) {
return {
id,
displayName,
Icon,
getInstallState: () => ({ kind: "ready" as const }),
getResolvedBinaryPath: () => null,
openInstallUI: jest.fn(),
SettingsPanel: () => <div data-testid={`panel-${id}`}>settings panel</div>,
};
}
const DESCRIPTORS = [
makeDescriptor("opencode", "OpenCode"),
makeDescriptor("claude", "Claude"),
makeDescriptor("codex", "Codex"),
];
jest.mock("@/agentMode", () => ({
listBackendDescriptors: () => DESCRIPTORS,
InstallBadge: () => <span data-testid="install-badge" />,
McpServersPanel: () => <div data-testid="mcp-panel">mcp</div>,
AgentDefaultModelSetting: ({ descriptor }: { descriptor: { id: string } }) => (
<div data-testid={`default-model-${descriptor.id}`}>default model</div>
),
}));
jest.mock("@/contexts/PluginContext", () => ({
// eslint-disable-next-line @eslint-react/hooks-extra/no-unnecessary-use-prefix -- mocks the real `usePlugin` hook; the name must match the export
usePlugin: () => ({
app: {},
agentSessionManager: {
getCachedBackendState: () => ({ model: "x" }),
preloadModels: jest.fn().mockResolvedValue(undefined),
},
}),
}));
jest.mock("./ChatModelEnableList", () => ({
ChatModelEnableList: () => <div data-testid="chat-model-list">chat models</div>,
}));
jest.mock("./ConfiguredModelEnableList", () => ({
ConfiguredModelEnableList: ({ descriptor }: { descriptor: { id: string } }) => (
<div data-testid={`model-list-${descriptor.id}`}>model list</div>
),
}));
describe("AgentSettings", () => {
it("renders the four sub-tabs in order: OpenCode, Claude, Codex, Quick Chat", () => {
render(<AgentSettings />);
const tabs = screen.getAllByRole("tab").map((t) => t.textContent);
expect(tabs).toEqual(["OpenCode", "Claude", "Codex", "Quick Chat"]);
});
it("keeps global items (Default backend + MCP) outside the tab strip", () => {
render(<AgentSettings />);
const tablist = screen.getByRole("tablist");
expect(within(tablist).queryByText("Default backend")).toBeNull();
expect(within(tablist).queryByTestId("mcp-panel")).toBeNull();
expect(screen.getByText("Default backend")).not.toBeNull();
expect(screen.getByTestId("mcp-panel")).not.toBeNull();
});
it("shows the first backend's content by default and the default-model picker above the model list", () => {
render(<AgentSettings />);
const picker = screen.getByTestId("default-model-opencode");
const list = screen.getByTestId("model-list-opencode");
expect(picker).not.toBeNull();
expect(list).not.toBeNull();
// DOCUMENT_POSITION_FOLLOWING means `list` comes after `picker` in the DOM.
expect(picker.compareDocumentPosition(list) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
// Other backends' panels are not mounted while their tab is unselected.
expect(screen.queryByTestId("default-model-codex")).toBeNull();
});
it("switches to the selected backend's content", () => {
render(<AgentSettings />);
expect(screen.queryByTestId("model-list-codex")).toBeNull();
fireEvent.click(screen.getByRole("tab", { name: "Codex" }));
expect(screen.getByTestId("model-list-codex")).not.toBeNull();
expect(screen.queryByTestId("model-list-opencode")).toBeNull();
});
it("shows the Quick Chat model list on the Quick Chat tab", () => {
render(<AgentSettings />);
expect(screen.queryByTestId("chat-model-list")).toBeNull();
fireEvent.click(screen.getByText("Quick Chat"));
expect(screen.getByTestId("chat-model-list")).not.toBeNull();
});
});

View file

@ -8,30 +8,71 @@ import {
} from "@/agentMode";
import { Button } from "@/components/ui/button";
import { SettingItem } from "@/components/ui/setting-item";
import { TabContent, TabItem, type TabItem as TabItemType } from "@/components/ui/setting-tabs";
import { TruncatedText } from "@/components/TruncatedText";
import { usePlugin } from "@/contexts/PluginContext";
import { useChatBackendModelOptions } from "@/hooks/useChatBackendModelOptions";
import { logError } from "@/logger";
import { setSettings, useSettingsValue } from "@/settings/model";
import { setSettings, updateSetting, useSettingsValue } from "@/settings/model";
import { formatBinaryPathForDisplay } from "@/utils/binaryPath";
import { MessageCircle } from "lucide-react";
import { Platform } from "obsidian";
import React from "react";
import { ChatModelEnableList } from "./ChatModelEnableList";
import { ConfiguredModelEnableList } from "./ConfiguredModelEnableList";
/**
* Explicit ordering for backend sections. Keeps Opencode Claude Codex
* Explicit ordering for backend sub-tabs. Keeps Opencode Claude Codex
* regardless of what `listBackendDescriptors()` returns.
*/
const BACKEND_ORDER: BackendId[] = ["opencode", "claude", "codex"];
/** Synthetic sub-tab id for the (non-backend) Quick Chat model curation. */
const QUICK_CHAT_TAB_ID = "quickchat";
/** Nearest scrollable ancestor, used to keep the tab strip anchored on switch. */
function getScrollableParent(el: HTMLElement): HTMLElement | null {
let node: HTMLElement | null = el.parentElement;
while (node) {
const overflowY = getComputedStyle(node).overflowY;
if (overflowY === "auto" || overflowY === "scroll") return node;
node = node.parentElement;
}
return null;
}
/**
* Top-level "Agents" settings tab. Owns the master agent-mode toggle, the
* default backend picker, the MCP server panel, and one per-backend section
* (binary path + model curation).
* Top-level "Agents" settings tab. Owns the global default-backend picker and
* the MCP server panel, then a sub-tab strip with one panel per backend plus a
* Quick Chat panel. Each backend panel curates that backend's default model,
* enabled models, and binary/auth config.
*/
export const AgentSettings: React.FC = () => {
const settings = useSettingsValue();
const plugin = usePlugin();
const [selectedTab, setSelectedTab] = React.useState<string>(BACKEND_ORDER[0]);
const tabStripRef = React.useRef<HTMLDivElement>(null);
const pendingAnchorTop = React.useRef<number | null>(null);
// Panels vary widely in height (opencode's model list is long, Quick Chat is
// short), so switching to a shorter one clamps the settings scroll and jumps
// the view. Pin the tab strip to its pre-switch viewport position so only the
// content below it changes.
React.useLayoutEffect(() => {
const strip = tabStripRef.current;
if (!strip || pendingAnchorTop.current === null) return;
const scroller = getScrollableParent(strip);
if (scroller) {
const delta = strip.getBoundingClientRect().top - pendingAnchorTop.current;
if (delta !== 0) scroller.scrollTop += delta;
}
pendingAnchorTop.current = null;
}, [selectedTab]);
const handleSelectTab = React.useCallback((id: string) => {
pendingAnchorTop.current = tabStripRef.current?.getBoundingClientRect().top ?? null;
setSelectedTab(id);
}, []);
if (Platform.isMobile) {
return (
@ -49,6 +90,15 @@ export const AgentSettings: React.FC = () => {
allDescriptors.find((d) => d.id === id)
).filter((d): d is BackendDescriptor => d !== undefined);
const tabs: TabItemType[] = [
...orderedDescriptors.map((d) => ({
id: d.id,
icon: <d.Icon className="tw-size-4" />,
label: d.displayName,
})),
{ id: QUICK_CHAT_TAB_ID, icon: <MessageCircle className="tw-size-4" />, label: "Quick Chat" },
];
return (
<section>
<div className="tw-mb-3 tw-text-xl tw-font-bold">Agents (alpha)</div>
@ -66,43 +116,87 @@ export const AgentSettings: React.FC = () => {
<McpServersPanel />
{orderedDescriptors.map((descriptor) => (
<BackendSection key={descriptor.id} descriptor={descriptor} plugin={plugin} />
))}
<div className="tw-flex tw-flex-col">
<div ref={tabStripRef} className="tw-flex tw-flex-wrap tw-gap-1" role="tablist">
{tabs.map((tab, index) => (
<TabItem
key={tab.id}
tab={tab}
isSelected={selectedTab === tab.id}
onClick={() => handleSelectTab(tab.id)}
isFirst={index === 0}
isLast={index === tabs.length - 1}
/>
))}
</div>
<QuickChatSection />
{orderedDescriptors.map((descriptor) => (
<TabContent
key={descriptor.id}
id={descriptor.id}
isSelected={selectedTab === descriptor.id}
>
<BackendPanel descriptor={descriptor} plugin={plugin} />
</TabContent>
))}
<TabContent id={QUICK_CHAT_TAB_ID} isSelected={selectedTab === QUICK_CHAT_TAB_ID}>
<QuickChatPanel />
</TabContent>
</div>
</div>
</section>
);
};
/**
* Quick Chat curation card: which models appear in the (non-agent) chat model
* Quick Chat curation panel: which models appear in the (non-agent) chat model
* picker. Lives under Agents per the model-management design (chat is a
* first-class curation backend alongside the agents). Models come from the
* BYOK / Plus registries chat doesn't own providers.
*/
const QuickChatSection: React.FC = () => {
const QuickChatPanel: React.FC = () => {
const settings = useSettingsValue();
const { options: chatModelOptions, resolveSelectionId } = useChatBackendModelOptions();
const resolvedDefaultModelId = resolveSelectionId(settings.defaultModelKey);
const hasDefault = resolvedDefaultModelId !== undefined;
return (
<div className="tw-space-y-3 tw-rounded-md tw-border tw-border-solid tw-border-border tw-p-3">
<div className="tw-space-y-3">
<div className="tw-flex tw-min-w-0 tw-flex-col">
<span className="tw-text-base tw-font-semibold">Quick Chat models</span>
<span className="tw-text-xs tw-text-muted">
Models shown in the chat model picker. Add providers on the Models (BYOK) tab.
</span>
</div>
<SettingItem
type="select"
title="Default model"
description="The model new chats start with. Pick from your enabled Quick Chat models."
value={resolvedDefaultModelId ?? "Select Model"}
onChange={(value) => {
if (value === "Select Model") return;
updateSetting("defaultModelKey", value);
}}
options={
hasDefault
? chatModelOptions
: [{ label: "Select Model", value: "Select Model" }, ...chatModelOptions]
}
placeholder="Model"
/>
<ChatModelEnableList />
</div>
);
};
/**
* One per-backend block: heading, binary install panel, and the model enable
* list. If the backend is installed but no catalog is cached yet, it kicks a
* probe so discovery enrolls the reported models, which then populate the list
* (the list reads the model-management registry, not the probe state).
* One per-backend panel: install header, then (when ready) the default-model
* picker above the model enable list, then the binary/auth config. If the
* backend is installed but no catalog is cached yet, it kicks a probe so
* discovery enrolls the reported models, which then populate the list (the
* list reads the model-management registry, not the probe state).
*/
const BackendSection: React.FC<{
const BackendPanel: React.FC<{
descriptor: BackendDescriptor;
plugin: ReturnType<typeof usePlugin>;
}> = ({ descriptor, plugin }) => {
@ -127,7 +221,7 @@ const BackendSection: React.FC<{
const Icon = descriptor.Icon;
return (
<div className="tw-space-y-3 tw-rounded-md tw-border tw-border-solid tw-border-border tw-p-3">
<div className="tw-space-y-3">
<div className="tw-flex tw-items-center tw-justify-between tw-gap-2">
<div className="tw-flex tw-min-w-0 tw-items-center tw-gap-2">
<Icon className="tw-size-4 tw-shrink-0" />
@ -153,12 +247,12 @@ const BackendSection: React.FC<{
</Button>
</div>
{installState.kind === "ready" && <ConfiguredModelEnableList descriptor={descriptor} />}
{installState.kind === "ready" && manager && (
<AgentDefaultModelSetting descriptor={descriptor} manager={manager} />
)}
{installState.kind === "ready" && <ConfiguredModelEnableList descriptor={descriptor} />}
{Panel && <Panel plugin={plugin} app={plugin.app} />}
</div>
);