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:
2026-10-12 13:06:04 +07:00
parent 2fd0c903a2
commit 3a8be55b55
52 changed files with 1559 additions and 203 deletions
+46 -23
View File
@@ -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 ${