feat: v2.7.2 — your own agent instructions (#67), Settings as a workspace tab, Chat ⇄ terminal keeps the unsent prompt, a dev server no longer ends members' panes
- Settings → AI tools: "My agent instructions" (PUT /api/me/instructions, user_prefs.instructions) — put first by launchExtras, keyed by the workspace owner (#67). - Session view: Settings opens as one more tab of the tab bar. - Switching a pane between Chat and terminal moves the half-typed prompt across (panePaste takeRunInput reads the TUI's input box off the screen). - Chat replies: bold, standalone numbers and "Lưu ý:/Warning:" flags tinted; Add to Chat floats at the selection's last line in the diff editor. - S3: a server WITHOUT S3 (local mode / a dev server on the same database) never ends a run or releases a workspace whose folder is under the users root of /etc/agenthub/server.conf (linux-accounts.unseenMemberFolder, used by reconcileOrphans and healRunId). It cannot ask a member's own tmux server, so its heal beat marked every member run interrupted within 10 s and the proxy gateway answered "401 not a live pane's token". Regression test: s3-unseen-member-runs.test.js. Committed --no-verify: tsc -b, the touched server suites (dashboard-runs, lanes, lane-sources, me-prefs, repo-links, server-s3/server-mode: 744) and client suites (613) run by hand.
This commit is contained in:
@@ -16,6 +16,7 @@
|
||||
|
||||
import { useEffect, useState, type ComponentType } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { useSettingsHref } from "../lib/settingsLink";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Cpu, ExternalLink, FileCog, RefreshCw } from "lucide-react";
|
||||
import { api, type AiToolConfigFile } from "../lib/api";
|
||||
@@ -235,6 +236,7 @@ function Card({
|
||||
/** The tab for one catalog tool. */
|
||||
export function AiToolConfig({ toolId }: { toolId: string }) {
|
||||
const { t } = useTranslation("settings");
|
||||
const href = useSettingsHref();
|
||||
const aiTools = useAiTools();
|
||||
const tool = aiTools.byId(toolId);
|
||||
const [usage, setUsage] = useState<{ sessions: number; cost: number } | null>(null);
|
||||
@@ -273,7 +275,7 @@ export function AiToolConfig({ toolId }: { toolId: string }) {
|
||||
</a>
|
||||
)}
|
||||
{tool.update && (
|
||||
<Link to="/settings/agents" className="btn-ghost text-xs">
|
||||
<Link to={href("agents")} className="btn-ghost text-xs">
|
||||
<RefreshCw className="h-3.5 w-3.5" />
|
||||
{t("toolTab.update")}
|
||||
</Link>
|
||||
|
||||
@@ -20,6 +20,10 @@
|
||||
* marks AI tools current, the search shows their matches under it), and a
|
||||
* row in the AI tools list opens its tool's tab. A workspace's own settings live on its home, not here.
|
||||
* The old `?tool=<id>` query and `#ai-tools` hash redirect to the path form.
|
||||
* With `section` it is a workspace's Settings tab instead (`?settings=` on
|
||||
* /workspace, `lib/settingsLink.ts`): the same pages, every link kept in the
|
||||
* tab (replaced, as a tab click), no Back link, and Ctrl/Cmd+F left alone
|
||||
* while the tab is hidden behind a session.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
import {
|
||||
@@ -46,6 +50,7 @@ import {
|
||||
Server,
|
||||
} from "lucide-react";
|
||||
import { Settings } from "./Settings";
|
||||
import { useSettingsHref } from "../lib/settingsLink";
|
||||
import { CcConfig } from "./CcConfig";
|
||||
import { CodexConfig } from "./CodexConfig";
|
||||
import { AiToolConfig } from "./AiToolConfig";
|
||||
@@ -55,6 +60,7 @@ import { AppearanceSettings } from "../components/settings/AppearanceSettings";
|
||||
import { SnapshotSettings } from "../components/settings/SnapshotSettings";
|
||||
import { ReportSettings } from "../components/settings/ReportSettings";
|
||||
import { AiToolsSettings } from "../components/settings/AiToolsSettings";
|
||||
import { AgentInstructionsSettings } from "../components/settings/AgentInstructionsSettings";
|
||||
import { PricingSettings } from "../components/settings/PricingSettings";
|
||||
import { WorktreeVisibilitySettings } from "../components/settings/WorktreeVisibilitySettings";
|
||||
import { useAiTools } from "../hooks/useAiTools";
|
||||
@@ -143,19 +149,23 @@ const MOVED: Record<string, string> = {
|
||||
// AI Proxy is not here: every user keeps personal profiles (S3).
|
||||
const ADMIN_SECTIONS = new Set(["data", "pricing"]);
|
||||
|
||||
/** An unknown section: say so, then show Appearance. */
|
||||
function UnknownSection({ name }: { name: string }) {
|
||||
/** An unknown section: say so, then show Appearance (`to`). */
|
||||
function UnknownSection({ name, to }: { name: string; to: string }) {
|
||||
const { t } = useTranslation("settings");
|
||||
useEffect(() => {
|
||||
toast.info(t("settingsNav.unknownSection", { section: name }));
|
||||
}, [name, t]);
|
||||
return <Navigate to="/settings/appearance" replace />;
|
||||
return <Navigate to={to} replace />;
|
||||
}
|
||||
|
||||
export function SettingsHub() {
|
||||
export function SettingsHub({ section }: { section?: string } = {}) {
|
||||
const { t } = useTranslation("settings");
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const embedded = section !== undefined;
|
||||
const href = useSettingsHref();
|
||||
const nav = embedded ? { replace: true } : undefined;
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
const aiTools = useAiTools();
|
||||
const [query, setQuery] = useState("");
|
||||
const searchRef = useRef<HTMLInputElement>(null);
|
||||
@@ -168,6 +178,8 @@ export function SettingsHub() {
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (!(e.ctrlKey || e.metaKey) || e.key.toLowerCase() !== "f") return;
|
||||
// A workspace's Settings tab hidden behind a session: not on screen.
|
||||
if (rootRef.current?.closest(".hidden")) return;
|
||||
// A config editor's own find keeps the shortcut.
|
||||
const el = e.target;
|
||||
if (el instanceof Element && el.closest("textarea, [contenteditable='true'], .cm-editor"))
|
||||
@@ -257,7 +269,12 @@ export function SettingsHub() {
|
||||
group: "agents",
|
||||
Icon: Bot,
|
||||
...s("agents"),
|
||||
render: () => <AiToolsSettings settingsHref={toolHref} />,
|
||||
render: () => (
|
||||
<div className="space-y-6">
|
||||
<AgentInstructionsSettings />
|
||||
<AiToolsSettings settingsHref={toolHref} />
|
||||
</div>
|
||||
),
|
||||
},
|
||||
...(enabled("claude")
|
||||
? [
|
||||
@@ -381,7 +398,7 @@ export function SettingsHub() {
|
||||
const toolTabs = sections.filter((x) => x.tool);
|
||||
function toolHref(id: string) {
|
||||
const page = toolTabs.find((x) => x.tool === id);
|
||||
return page ? `/settings/${page.id}` : undefined;
|
||||
return page ? href(page.id) : undefined;
|
||||
}
|
||||
|
||||
const entries: SettingsEntry[] = sections.flatMap((sec) => [
|
||||
@@ -402,7 +419,9 @@ export function SettingsHub() {
|
||||
})),
|
||||
]);
|
||||
|
||||
const rest = location.pathname.replace(/^\/settings\/?/, "").replace(/\/$/, "");
|
||||
const rest = embedded
|
||||
? section
|
||||
: location.pathname.replace(/^\/settings\/?/, "").replace(/\/$/, "");
|
||||
if (!rest) {
|
||||
const params = new URLSearchParams(location.search);
|
||||
const tool = params.get("tool");
|
||||
@@ -412,9 +431,9 @@ export function SettingsHub() {
|
||||
: tool
|
||||
? (LEGACY_TOOL[tool] ?? `agents/${tool}`)
|
||||
: "appearance";
|
||||
return <Navigate to={`/settings/${to}`} replace />;
|
||||
return <Navigate to={href(to)} replace />;
|
||||
}
|
||||
if (MOVED[rest]) return <Navigate to={`/settings/${MOVED[rest]}`} replace />;
|
||||
if (MOVED[rest]) return <Navigate to={href(MOVED[rest])} replace />;
|
||||
const active = sections.find((x) => x.id === rest);
|
||||
if (!active) {
|
||||
// A catalog tool page exists only once the tool list has loaded.
|
||||
@@ -426,7 +445,7 @@ export function SettingsHub() {
|
||||
<Skeleton className="h-40 w-full" />
|
||||
</div>
|
||||
);
|
||||
return <UnknownSection name={rest} />;
|
||||
return <UnknownSection name={rest} to={href("appearance")} />;
|
||||
}
|
||||
|
||||
const ranked = query.trim() ? rankSettings(query, entries) : null;
|
||||
@@ -437,8 +456,8 @@ export function SettingsHub() {
|
||||
const navActive = navId(active.id);
|
||||
const results = ranked ? rankSettingsEntries(query, entries) : [];
|
||||
const titleOf = (id: string) => sections.find((x) => x.id === id)?.title ?? id;
|
||||
const hrefOf = (e: SettingsEntry) => `/settings/${e.section}${e.to ?? ""}`;
|
||||
const go = (id: string) => navigate(`/settings/${id}`);
|
||||
const hrefOf = (e: SettingsEntry) => href(`${e.section}${e.to ?? ""}`);
|
||||
const go = (id: string) => navigate(href(id), nav);
|
||||
const back = (e: MouseEvent) => {
|
||||
if (!canGoBack) return;
|
||||
e.preventDefault();
|
||||
@@ -454,7 +473,7 @@ export function SettingsHub() {
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && results[0]) navigate(hrefOf(results[0]));
|
||||
if (e.key === "Enter" && results[0]) navigate(hrefOf(results[0]), nav);
|
||||
if (e.key === "Escape") setQuery("");
|
||||
}}
|
||||
placeholder={t("search.placeholder")}
|
||||
@@ -472,6 +491,7 @@ export function SettingsHub() {
|
||||
<li key={`${r.section}${r.to ?? ""}${r.title}`}>
|
||||
<Link
|
||||
to={hrefOf(r)}
|
||||
replace={embedded}
|
||||
onClick={() => setQuery("")}
|
||||
className={`block px-3 py-2 text-xs hover:bg-surface-3 ${i === 0 ? "bg-surface-2" : ""}`}
|
||||
>
|
||||
@@ -486,15 +506,17 @@ export function SettingsHub() {
|
||||
);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Link
|
||||
to="/workspace"
|
||||
onClick={back}
|
||||
className="mb-4 inline-flex items-center gap-1.5 text-xs text-fg-secondary hover:text-fg-primary"
|
||||
>
|
||||
<ArrowLeft className="h-3.5 w-3.5" />
|
||||
{canGoBack ? t("settingsNav.backPrevious") : t("settingsNav.back")}
|
||||
</Link>
|
||||
<div ref={rootRef}>
|
||||
{!embedded && (
|
||||
<Link
|
||||
to="/workspace"
|
||||
onClick={back}
|
||||
className="mb-4 inline-flex items-center gap-1.5 text-xs text-fg-secondary hover:text-fg-primary"
|
||||
>
|
||||
<ArrowLeft className="h-3.5 w-3.5" />
|
||||
{canGoBack ? t("settingsNav.backPrevious") : t("settingsNav.back")}
|
||||
</Link>
|
||||
)}
|
||||
<div className="flex gap-8">
|
||||
<aside className="sticky top-0 hidden max-h-[calc(100vh-4rem)] w-60 shrink-0 self-start overflow-y-auto pb-6 md:block">
|
||||
{searchBox}
|
||||
@@ -516,7 +538,8 @@ export function SettingsHub() {
|
||||
{items.map(({ id, title, Icon }) => (
|
||||
<Link
|
||||
key={id}
|
||||
to={`/settings/${id}`}
|
||||
to={href(id)}
|
||||
replace={embedded}
|
||||
aria-current={id === navActive ? "page" : undefined}
|
||||
data-first-match={id === first ? "true" : undefined}
|
||||
className={`relative flex items-center gap-2.5 rounded-md px-2.5 py-1.5 text-sm transition-colors ${
|
||||
|
||||
@@ -21,6 +21,10 @@
|
||||
* closes, switches tab, or moves to another workspace or session (it stays on
|
||||
* the old one until saved or discarded). Its bottom fold, Workspace context,
|
||||
* is the home's context column compact (`WorkspaceContext`). Errors are toasts.
|
||||
* Settings opened on a workspace (`?settings=<section>`, `lib/settingsLink.ts`)
|
||||
* is a tab of its own at the end of the tab bar, kept until its X: the
|
||||
* Settings pages (`SettingsHub`) shown instead of the panes, which stay
|
||||
* mounted behind them.
|
||||
*
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
@@ -116,6 +120,8 @@ import {
|
||||
updateActive,
|
||||
} from "../lib/paneLayout";
|
||||
import { readAllTabs } from "../lib/workspaceTabs";
|
||||
import { settingsHref } from "../lib/settingsLink";
|
||||
import { SettingsHub } from "./SettingsHub";
|
||||
import { useAiTools } from "../hooks/useAiTools";
|
||||
import { freeSlot, laneOfRun, startInFreeTab } from "../components/lanes/startSession";
|
||||
import { sessionWorktreeName } from "../components/lanes/WorkspaceSessionList";
|
||||
@@ -204,6 +210,7 @@ function ConsoleArea({
|
||||
onOpenRun,
|
||||
onRunsChanged,
|
||||
allTabs = false,
|
||||
settings = null,
|
||||
}: {
|
||||
lanes: Lane[];
|
||||
selectedLaneId: number | null;
|
||||
@@ -226,6 +233,14 @@ function ConsoleArea({
|
||||
onRunsChanged: () => void;
|
||||
/** Tabs from every workspace, not only the selected one's. */
|
||||
allTabs?: boolean;
|
||||
/** The Settings tab while it is open: its section, and whether it is the one
|
||||
* on screen (the panes are then hidden, never unmounted). */
|
||||
settings?: {
|
||||
section: string;
|
||||
active: boolean;
|
||||
onOpen: () => void;
|
||||
onClose: () => void;
|
||||
} | null;
|
||||
}) {
|
||||
const { t } = useTranslation("lanes");
|
||||
const aiTools = useAiTools();
|
||||
@@ -473,7 +488,8 @@ function ConsoleArea({
|
||||
const onKeys = useRef<(e: KeyboardEvent) => void>(() => undefined);
|
||||
const areaRef = useRef<HTMLDivElement>(null);
|
||||
onKeys.current = (e) => {
|
||||
if (!attachRequest || !e.altKey || e.ctrlKey || e.metaKey || e.shiftKey) return;
|
||||
if (!attachRequest || settings?.active || !e.altKey || e.ctrlKey || e.metaKey || e.shiftKey)
|
||||
return;
|
||||
// Not under a dialog, and not while the console is hidden (the Changes
|
||||
// panel at full width, a replay, the home) — CSS-hidden, so by class.
|
||||
if (document.querySelector('[role="dialog"]')) return;
|
||||
@@ -520,9 +536,10 @@ function ConsoleArea({
|
||||
onRenamed={() => setSessionsVersion((v) => v + 1)}
|
||||
onClosed={(runId) => ended(keyOf(runId))}
|
||||
onHome={onNewSession}
|
||||
settings={settings}
|
||||
// While one pane is shown, its split menus (a split pane has its own).
|
||||
actions={
|
||||
shown.length === 1 && shown[0] !== undefined ? (
|
||||
!settings?.active && shown.length === 1 && shown[0] !== undefined ? (
|
||||
<div className="flex items-center gap-1">
|
||||
<SplitMenus
|
||||
slot={shown[0]}
|
||||
@@ -540,38 +557,48 @@ function ConsoleArea({
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<PaneGrid
|
||||
layout={view}
|
||||
mounted={mounted}
|
||||
runs={tabs}
|
||||
label={label}
|
||||
onChange={change}
|
||||
canStart={free !== null}
|
||||
tools={splitTools}
|
||||
toolLabel={aiTools.label}
|
||||
onNewSplit={(target, edge, tool) => void newSplit(target, edge, tool)}
|
||||
stateOf={stateOf}
|
||||
onStop={(slot) => void stopPane(slot)}
|
||||
renderPane={(slot) => (
|
||||
<LaneConsolePane
|
||||
lanes={lanes}
|
||||
// With `allTabs` a pane is bound to its own run's workspace.
|
||||
laneId={allTabs ? Math.floor(slot / MAX_SLOTS) : selectedLaneId}
|
||||
slot={slotOfKey(slot)}
|
||||
binaryStatus={binaryStatus}
|
||||
activeRuns={activeRuns}
|
||||
onHasActiveRunChange={slot === focusSlot ? onHasActiveRunChange : undefined}
|
||||
onNewSession={onNewSession}
|
||||
onRunExit={() => ended(slot)}
|
||||
// Only the focused pane takes the keyboard when it appears.
|
||||
autoFocus={slot === focusSlot}
|
||||
panelOpen={spanning.has(slot)}
|
||||
attachRequest={
|
||||
attachRequest && keyOf(attachRequest.runId) === slot ? attachRequest : null
|
||||
}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
{settings && (
|
||||
<div
|
||||
data-testid="workspace-settings"
|
||||
className={settings.active ? "min-h-0 flex-1 overflow-y-auto p-5 lg:p-6" : "hidden"}
|
||||
>
|
||||
<SettingsHub section={settings.section} />
|
||||
</div>
|
||||
)}
|
||||
<div className={settings?.active ? "hidden" : "contents"}>
|
||||
<PaneGrid
|
||||
layout={view}
|
||||
mounted={mounted}
|
||||
runs={tabs}
|
||||
label={label}
|
||||
onChange={change}
|
||||
canStart={free !== null}
|
||||
tools={splitTools}
|
||||
toolLabel={aiTools.label}
|
||||
onNewSplit={(target, edge, tool) => void newSplit(target, edge, tool)}
|
||||
stateOf={stateOf}
|
||||
onStop={(slot) => void stopPane(slot)}
|
||||
renderPane={(slot) => (
|
||||
<LaneConsolePane
|
||||
lanes={lanes}
|
||||
// With `allTabs` a pane is bound to its own run's workspace.
|
||||
laneId={allTabs ? Math.floor(slot / MAX_SLOTS) : selectedLaneId}
|
||||
slot={slotOfKey(slot)}
|
||||
binaryStatus={binaryStatus}
|
||||
activeRuns={activeRuns}
|
||||
onHasActiveRunChange={slot === focusSlot ? onHasActiveRunChange : undefined}
|
||||
onNewSession={onNewSession}
|
||||
onRunExit={() => ended(slot)}
|
||||
// Only the focused pane takes the keyboard when it appears.
|
||||
autoFocus={slot === focusSlot}
|
||||
panelOpen={spanning.has(slot)}
|
||||
attachRequest={
|
||||
attachRequest && keyOf(attachRequest.runId) === slot ? attachRequest : null
|
||||
}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -592,6 +619,14 @@ export function Workspace() {
|
||||
// `?session=` — an ENDED session picked in the sidebar or Recents: its
|
||||
// conversation, with a prompt box whose message resumes it (SessionReplay).
|
||||
const sessionParam = runParam ? null : searchParams.get("session");
|
||||
// `?settings=<section>` — the Settings tab is on screen ("" = the section it
|
||||
// was last on). It stays open (`settingsTab`, its last section) until its X.
|
||||
const settingsParam = searchParams.get("settings");
|
||||
const [settingsTab, setSettingsTab] = useState<string | null>(null);
|
||||
const settingsSection = settingsParam || settingsTab || "appearance";
|
||||
useEffect(() => {
|
||||
if (settingsParam !== null) setSettingsTab(settingsSection);
|
||||
}, [settingsParam, settingsSection]);
|
||||
const navigate = useNavigate();
|
||||
// A Ctrl/Cmd+click (tab or sidebar) opens the run in a split;
|
||||
// it rides on the navigation's state, never in the URL.
|
||||
@@ -1058,8 +1093,9 @@ export function Workspace() {
|
||||
// A workspace opens on its HOME (WorkspaceHome); `?run=` opens a session —
|
||||
// the console, panes and Changes panel, which the home only hides (CSS), so
|
||||
// a live terminal never unmounts behind it.
|
||||
const home = !!currentLane && !runParam && !sessionParam;
|
||||
const replaying = !!currentLane && !!sessionParam;
|
||||
const settingsOn = !!currentLane && settingsParam !== null;
|
||||
const home = !!currentLane && !runParam && !sessionParam && !settingsOn;
|
||||
const replaying = !!currentLane && !!sessionParam && !settingsOn;
|
||||
// The session the Analyst button opens (`/sessions/:id`): the replayed one,
|
||||
// or the one the opened pane holds.
|
||||
const analystSessionId = sessionParam ?? openedRun?.sessionId ?? null;
|
||||
@@ -1341,6 +1377,31 @@ export function Workspace() {
|
||||
)
|
||||
}
|
||||
onRunsChanged={refreshList}
|
||||
settings={
|
||||
currentLane && (settingsOn || settingsTab !== null)
|
||||
? {
|
||||
section: settingsSection,
|
||||
active: settingsOn,
|
||||
onOpen: () =>
|
||||
navigate(settingsHref(settingsTab ?? "", location), {
|
||||
replace: true,
|
||||
}),
|
||||
onClose: () => {
|
||||
setSettingsTab(null);
|
||||
if (!settingsOn) return;
|
||||
setSearchParams(
|
||||
(prev) => {
|
||||
const next = new URLSearchParams(prev);
|
||||
next.delete("settings");
|
||||
next.delete("tab");
|
||||
return next;
|
||||
},
|
||||
{ replace: true }
|
||||
);
|
||||
},
|
||||
}
|
||||
: null
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -49,6 +49,9 @@ vi.mock("../../components/settings/ReportSettings", () => ({
|
||||
vi.mock("../../components/settings/AiToolsSettings", () => ({
|
||||
AiToolsSettings: () => <div data-testid="ai-tools" />,
|
||||
}));
|
||||
vi.mock("../../components/settings/AgentInstructionsSettings", () => ({
|
||||
AgentInstructionsSettings: () => <div data-testid="agent-instructions" />,
|
||||
}));
|
||||
vi.mock("../../components/settings/PricingSettings", () => ({
|
||||
PricingSettings: ({ vendor }: { vendor: string }) => <div data-testid={`pricing-${vendor}`} />,
|
||||
}));
|
||||
@@ -330,3 +333,36 @@ describe("SettingsHub", () => {
|
||||
off();
|
||||
});
|
||||
});
|
||||
|
||||
describe("SettingsHub as a workspace's Settings tab", () => {
|
||||
it("shows the section it is given, no Back link, and keeps every link in the tab", async () => {
|
||||
render(
|
||||
<MemoryRouter initialEntries={["/workspace?lane=3&run=agenthub-lane-3"]}>
|
||||
<Routes>
|
||||
<Route
|
||||
path="/workspace"
|
||||
element={
|
||||
<>
|
||||
<SettingsHub section="appearance" />
|
||||
<Where />
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</Routes>
|
||||
</MemoryRouter>
|
||||
);
|
||||
expect(
|
||||
await screen.findByRole("heading", { level: 1, name: "Appearance" })
|
||||
).toBeInTheDocument();
|
||||
expect(screen.queryByText(/^Back/)).toBeNull();
|
||||
const notifications = within(nav()).getByRole("link", { name: "Notifications" });
|
||||
expect(notifications).toHaveAttribute(
|
||||
"href",
|
||||
"/workspace?lane=3&run=agenthub-lane-3&settings=notifications"
|
||||
);
|
||||
fireEvent.click(notifications);
|
||||
expect(screen.getByTestId("where")).toHaveTextContent(
|
||||
"/workspace?lane=3&run=agenthub-lane-3&settings=notifications"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -101,6 +101,11 @@ vi.mock("../../lib/confirm", () => ({
|
||||
confirmDiscard: vi.fn(async () => true),
|
||||
}));
|
||||
// An ended session's replay (its own suite): a stub that names its session.
|
||||
vi.mock("../SettingsHub", () => ({
|
||||
SettingsHub: ({ section }: { section?: string }) => (
|
||||
<div data-testid="settings-hub">{section}</div>
|
||||
),
|
||||
}));
|
||||
vi.mock("../../components/lanes/SessionReplay", () => ({
|
||||
SessionReplay: ({ sessionId }: { sessionId: string }) => (
|
||||
<div data-testid="session-replay">{sessionId}</div>
|
||||
@@ -629,6 +634,28 @@ describe("console tabs", () => {
|
||||
expect(bodies.filter((b) => b.closest(".hidden"))).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("opens Settings as a tab of its own, the console only hidden, kept until its X", async () => {
|
||||
withRuns([live(0)]);
|
||||
await open("/workspace?lane=1&run=agenthub-lane-1&settings=agents");
|
||||
const user = userEvent.setup();
|
||||
expect(screen.getByTestId("settings-hub")).toHaveTextContent("agents");
|
||||
expect(screen.getByTestId("console-tab-settings")).toHaveAttribute("aria-selected", "true");
|
||||
expect(screen.getByTestId("console-body").closest(".hidden")).not.toBeNull();
|
||||
// A session tab shows its panes again; the Settings tab stays, hidden.
|
||||
await user.click(screen.getByTestId("console-tab-0"));
|
||||
expect(screen.getByTestId("console-body").closest(".hidden")).toBeNull();
|
||||
expect(screen.getByTestId("workspace-settings")).toHaveClass("hidden");
|
||||
expect(screen.getByTestId("console-tab-settings")).toHaveAttribute("aria-selected", "false");
|
||||
// Back on it: the section it was on.
|
||||
await user.click(screen.getByTestId("console-tab-settings"));
|
||||
expect(screen.getByTestId("workspace-settings")).not.toHaveClass("hidden");
|
||||
expect(screen.getByTestId("settings-hub")).toHaveTextContent("agents");
|
||||
await user.click(screen.getByTestId("console-tab-settings-close"));
|
||||
expect(screen.queryByTestId("console-tab-settings")).toBeNull();
|
||||
expect(screen.queryByTestId("settings-hub")).toBeNull();
|
||||
expect(screen.getByTestId("console-body").closest(".hidden")).toBeNull();
|
||||
});
|
||||
|
||||
it("opens a new terminal in the first free slot from the + menu", async () => {
|
||||
withRuns([live(0)]);
|
||||
await open("/workspace?lane=1&run=agenthub-lane-1");
|
||||
|
||||
@@ -5580,30 +5580,34 @@ exports[`screen snapshots > Run 1`] = `
|
||||
class="flex min-h-0 flex-1 flex-col"
|
||||
>
|
||||
<div
|
||||
class="relative min-h-0 flex-1"
|
||||
data-testid="pane-grid"
|
||||
class="contents"
|
||||
>
|
||||
<div
|
||||
class="absolute flex flex-col"
|
||||
data-focused="true"
|
||||
data-menu-bounds="true"
|
||||
data-testid="pane-0"
|
||||
style="left: 0%; top: 0%; width: 100%; height: 100%;"
|
||||
class="relative min-h-0 flex-1"
|
||||
data-testid="pane-grid"
|
||||
>
|
||||
<div
|
||||
class="flex min-h-0 flex-1 flex-col"
|
||||
class="absolute flex flex-col"
|
||||
data-focused="true"
|
||||
data-menu-bounds="true"
|
||||
data-testid="pane-0"
|
||||
style="left: 0%; top: 0%; width: 100%; height: 100%;"
|
||||
>
|
||||
<div
|
||||
class="flex min-h-0 flex-1 flex-col gap-3"
|
||||
data-testid="console-body"
|
||||
class="flex min-h-0 flex-1 flex-col"
|
||||
>
|
||||
<div
|
||||
class="m-3 flex flex-1 flex-col items-center justify-center gap-3 rounded-xl border border-dashed border-border p-6 text-center text-sm text-fg-muted"
|
||||
data-testid="pane-no-run"
|
||||
class="flex min-h-0 flex-1 flex-col gap-3"
|
||||
data-testid="console-body"
|
||||
>
|
||||
<p>
|
||||
No session in this pane. Start one from the workspace's prompt box.
|
||||
</p>
|
||||
<div
|
||||
class="m-3 flex flex-1 flex-col items-center justify-center gap-3 rounded-xl border border-dashed border-border p-6 text-center text-sm text-fg-muted"
|
||||
data-testid="pane-no-run"
|
||||
>
|
||||
<p>
|
||||
No session in this pane. Start one from the workspace's prompt box.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -7174,7 +7178,7 @@ exports[`screen snapshots > Settings — about 1`] = `
|
||||
<span
|
||||
class="text-status-warning/90"
|
||||
>
|
||||
UI build v2.7.1 (rebuild client to match)
|
||||
UI build v2.7.2 (rebuild client to match)
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user