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:
@@ -0,0 +1,171 @@
|
||||
/**
|
||||
* @file AgentInstructionsSettings — Settings → AI tools → My agent
|
||||
* instructions (issue #67): the account's own rules in markdown, put before
|
||||
* every workspace's own instructions in each new Claude or Codex session of a
|
||||
* workspace it owns (`GET/PUT /api/me/instructions`). Saved per account. A
|
||||
* failed load says so with a Retry instead of leaving a dead, empty editor —
|
||||
* the editor stays read-only until the saved text is known, so a save can
|
||||
* never overwrite instructions it did not load. **Choose file** reads a local
|
||||
* Markdown/text file in the browser into the draft (nothing is uploaded until
|
||||
* Save, and Discard brings the saved text back). "Applies to" lists only the
|
||||
* installed, enabled tools whose panes the server says receive them.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useEffect, useRef, useState, type ChangeEvent } from "react";
|
||||
import { FileUp } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { api, type AgentInstructionsView } from "../../lib/api";
|
||||
import { useAiTools } from "../../hooks/useAiTools";
|
||||
import { AiToolIcon } from "../AiToolIcon";
|
||||
import { toast } from "../../lib/toast";
|
||||
import { SettingsSection } from "./SettingsRow";
|
||||
|
||||
export function AgentInstructionsSettings() {
|
||||
const { t } = useTranslation("settings");
|
||||
const [saved, setSaved] = useState<string | null>(null);
|
||||
const [draft, setDraft] = useState("");
|
||||
const [max, setMax] = useState<number | undefined>();
|
||||
const [toolIds, setToolIds] = useState<string[]>([]);
|
||||
const { tools } = useAiTools();
|
||||
const [loadError, setLoadError] = useState<string | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const fileInput = useRef<HTMLInputElement | null>(null);
|
||||
|
||||
const apply = (r: AgentInstructionsView) => {
|
||||
setSaved(r.instructions);
|
||||
setDraft(r.instructions);
|
||||
setMax(r.max);
|
||||
setToolIds(r.tools ?? []);
|
||||
};
|
||||
|
||||
const load = () => {
|
||||
setLoadError(null);
|
||||
api.me
|
||||
.instructions()
|
||||
.then(apply)
|
||||
.catch((err) => setLoadError(err instanceof Error ? err.message : String(err)));
|
||||
};
|
||||
|
||||
useEffect(load, []);
|
||||
|
||||
async function save() {
|
||||
setSaving(true);
|
||||
try {
|
||||
apply(await api.me.saveInstructions(draft));
|
||||
toast.success(t("agentInstructions.saved"));
|
||||
} catch (err) {
|
||||
toast.error(
|
||||
t("agentInstructions.saveFailed", { message: err instanceof Error ? err.message : "" })
|
||||
);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function pickFile(e: ChangeEvent<HTMLInputElement>) {
|
||||
const file = e.target.files?.[0];
|
||||
e.target.value = ""; // the same file can be picked again after a Discard
|
||||
if (!file) return;
|
||||
const text = await file.text();
|
||||
if (max && text.length > max) {
|
||||
toast.error(
|
||||
t("agentInstructions.fileTooBig", {
|
||||
name: file.name,
|
||||
size: text.length.toLocaleString(),
|
||||
max: max.toLocaleString(),
|
||||
})
|
||||
);
|
||||
return;
|
||||
}
|
||||
setDraft(text);
|
||||
}
|
||||
|
||||
const appliesTo = tools.filter((x) => x.installed && x.enabled && toolIds.includes(x.id));
|
||||
const ready = saved !== null;
|
||||
const dirty = ready && draft !== saved;
|
||||
|
||||
return (
|
||||
<SettingsSection
|
||||
id="agent-instructions"
|
||||
title={t("agentInstructions.title")}
|
||||
description={t("agentInstructions.description")}
|
||||
>
|
||||
<div className="pt-1">
|
||||
{appliesTo.length > 0 && (
|
||||
<div className="mb-3 flex flex-wrap items-center gap-1.5 text-xs text-fg-muted">
|
||||
<span>{t("agentInstructions.appliesTo")}</span>
|
||||
{appliesTo.map((x) => (
|
||||
<span
|
||||
key={x.id}
|
||||
className="inline-flex items-center gap-1.5 rounded-full border border-border bg-surface-2 py-0.5 pl-1 pr-2.5 text-fg-secondary"
|
||||
>
|
||||
<AiToolIcon tool={x} className="h-4 w-4" />
|
||||
{x.label}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<textarea
|
||||
aria-label={t("agentInstructions.title")}
|
||||
className="block w-full min-h-[180px] resize-y rounded-lg border border-border bg-surface-1 px-3 py-2.5 font-mono text-xs leading-relaxed text-fg-primary placeholder:text-fg-muted/70 focus:border-accent focus:outline-none disabled:opacity-60"
|
||||
rows={8}
|
||||
spellCheck={false}
|
||||
placeholder={t("agentInstructions.placeholder")}
|
||||
value={draft}
|
||||
maxLength={max}
|
||||
disabled={!ready}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
/>
|
||||
<div className="mt-3 flex items-center justify-between gap-3">
|
||||
{loadError ? (
|
||||
<p className="flex min-w-0 items-center gap-2 text-xs text-status-danger">
|
||||
<span className="truncate">
|
||||
{t("agentInstructions.loadFailed", { message: loadError })}
|
||||
</span>
|
||||
<button type="button" className="btn-ghost shrink-0 text-xs" onClick={load}>
|
||||
{t("common:retry")}
|
||||
</button>
|
||||
</p>
|
||||
) : (
|
||||
<span className="text-[11px] tabular-nums text-fg-muted">
|
||||
{ready && max ? `${draft.length.toLocaleString()} / ${max.toLocaleString()}` : ""}
|
||||
</span>
|
||||
)}
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<input
|
||||
ref={fileInput}
|
||||
type="file"
|
||||
accept=".md,.markdown,.txt,text/markdown,text/plain"
|
||||
className="hidden"
|
||||
data-testid="agent-instructions-file"
|
||||
onChange={(e) => void pickFile(e)}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-ghost inline-flex items-center gap-1.5 text-xs disabled:opacity-50"
|
||||
disabled={!ready || saving}
|
||||
onClick={() => fileInput.current?.click()}
|
||||
>
|
||||
<FileUp className="h-3.5 w-3.5" />
|
||||
{t("agentInstructions.chooseFile")}
|
||||
</button>
|
||||
{dirty && !saving && (
|
||||
<button type="button" className="btn-ghost text-xs" onClick={() => setDraft(saved)}>
|
||||
{t("agentInstructions.discard")}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="btn-primary text-xs disabled:cursor-not-allowed disabled:opacity-50"
|
||||
disabled={saving || !dirty}
|
||||
onClick={() => void save()}
|
||||
>
|
||||
{saving ? t("homeDir.saving") : t("homeDir.save")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
@@ -20,6 +20,7 @@ import { Checkbox } from "../Checkbox";
|
||||
import { useEffect, useState, useCallback, useRef, Fragment } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Link } from "react-router-dom";
|
||||
import { useSettingsHref } from "../../lib/settingsLink";
|
||||
import {
|
||||
ArrowRight,
|
||||
DollarSign,
|
||||
@@ -208,6 +209,7 @@ export type PricingVendor = "claude" | "openai" | "google";
|
||||
*/
|
||||
export function PricingLink({ vendor }: { vendor: PricingVendor }) {
|
||||
const { t } = useTranslation("settings");
|
||||
const href = useSettingsHref();
|
||||
return (
|
||||
<div className="card flex items-center justify-between gap-4 p-5">
|
||||
<div className="min-w-0">
|
||||
@@ -217,7 +219,7 @@ export function PricingLink({ vendor }: { vendor: PricingVendor }) {
|
||||
</h3>
|
||||
<p className="mt-0.5 text-xs text-fg-muted">{t("pricingLink.description")}</p>
|
||||
</div>
|
||||
<Link to={`/settings/pricing#pricing-${vendor}`} className="btn-ghost shrink-0 text-xs">
|
||||
<Link to={href(`pricing#pricing-${vendor}`)} className="btn-ghost shrink-0 text-xs">
|
||||
{t("pricingLink.open")}
|
||||
<ArrowRight className="h-3.5 w-3.5" />
|
||||
</Link>
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
/**
|
||||
* @file AgentInstructionsSettings.test.tsx
|
||||
* @description My agent instructions: a failed load shows the error with a
|
||||
* Retry (the editor stays locked, so a save cannot overwrite unloaded text)
|
||||
* and Retry unlocks it; Choose file puts a local file's text into the draft
|
||||
* without saving, and refuses one longer than the limit; "Applies to" names
|
||||
* only the installed, enabled tools the server says receive them.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { AgentInstructionsSettings } from "../AgentInstructionsSettings";
|
||||
import { api } from "../../../lib/api";
|
||||
import { toast } from "../../../lib/toast";
|
||||
|
||||
vi.mock("../../../lib/api", async (importOriginal) => {
|
||||
const actual = await importOriginal<Record<string, unknown>>();
|
||||
return {
|
||||
...actual,
|
||||
api: { me: { instructions: vi.fn(), saveInstructions: vi.fn() } },
|
||||
};
|
||||
});
|
||||
const aiTools = vi.hoisted(() => ({
|
||||
tools: [] as { id: string; label: string; installed: boolean; enabled: boolean }[],
|
||||
}));
|
||||
vi.mock("../../../hooks/useAiTools", () => ({ useAiTools: () => aiTools }));
|
||||
vi.mock("../../../lib/toast", () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
|
||||
|
||||
const instructions = vi.mocked(api.me.instructions);
|
||||
const saveInstructions = vi.mocked(api.me.saveInstructions);
|
||||
const editor = () => screen.getByRole("textbox") as HTMLTextAreaElement;
|
||||
// jsdom's File has no text(); every browser AgentHub supports does.
|
||||
const pick = (text: string, name = "rules.md") => {
|
||||
const file = Object.assign(new File([text], name, { type: "text/markdown" }), {
|
||||
text: () => Promise.resolve(text),
|
||||
});
|
||||
fireEvent.change(screen.getByTestId("agent-instructions-file"), { target: { files: [file] } });
|
||||
};
|
||||
|
||||
describe("AgentInstructionsSettings", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
aiTools.tools = [];
|
||||
});
|
||||
|
||||
it("names only the installed, enabled tools that receive the instructions", async () => {
|
||||
aiTools.tools = [
|
||||
{ id: "claude", label: "Claude Code", installed: true, enabled: true },
|
||||
{ id: "codex", label: "Codex", installed: true, enabled: false },
|
||||
{ id: "cursor", label: "Cursor CLI", installed: true, enabled: true },
|
||||
];
|
||||
instructions.mockResolvedValue({ instructions: "", max: 100, tools: ["claude", "codex"] });
|
||||
render(<AgentInstructionsSettings />);
|
||||
expect(await screen.findByText("Claude Code")).toBeInTheDocument();
|
||||
expect(screen.getByText("Applies to")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Codex")).toBeNull();
|
||||
expect(screen.queryByText("Cursor CLI")).toBeNull();
|
||||
});
|
||||
|
||||
it("shows a failed load with Retry, and Retry unlocks the editor", async () => {
|
||||
instructions.mockRejectedValueOnce(new Error("HTTP 404"));
|
||||
render(<AgentInstructionsSettings />);
|
||||
expect(await screen.findByText(/HTTP 404/)).toBeInTheDocument();
|
||||
expect(editor()).toBeDisabled();
|
||||
|
||||
instructions.mockResolvedValueOnce({ instructions: "# saved", max: 100, tools: [] });
|
||||
fireEvent.click(screen.getByRole("button", { name: /retry/i }));
|
||||
await waitFor(() => expect(editor()).not.toBeDisabled());
|
||||
expect(editor().value).toBe("# saved");
|
||||
});
|
||||
|
||||
it("Choose file fills the draft without saving; Save sends it", async () => {
|
||||
instructions.mockResolvedValue({ instructions: "", max: 100, tools: [] });
|
||||
saveInstructions.mockResolvedValue({ instructions: "# from file", max: 100, tools: [] });
|
||||
render(<AgentInstructionsSettings />);
|
||||
await waitFor(() => expect(editor()).not.toBeDisabled());
|
||||
|
||||
pick("# from file");
|
||||
await waitFor(() => expect(editor().value).toBe("# from file"));
|
||||
expect(saveInstructions).not.toHaveBeenCalled();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /^save$/i }));
|
||||
await waitFor(() => expect(saveInstructions).toHaveBeenCalledWith("# from file"));
|
||||
});
|
||||
|
||||
it("refuses a file longer than the limit and keeps the draft", async () => {
|
||||
instructions.mockResolvedValue({ instructions: "keep", max: 5, tools: [] });
|
||||
render(<AgentInstructionsSettings />);
|
||||
await waitFor(() => expect(editor()).not.toBeDisabled());
|
||||
|
||||
pick("way too long");
|
||||
await waitFor(() => expect(toast.error).toHaveBeenCalled());
|
||||
expect(editor().value).toBe("keep");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user