Files
AgentHub/client/src/components/settings/AgentInstructionsSettings.tsx
T
nntrivi2001 3a8be55b55 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.
2026-10-12 13:06:04 +07:00

172 lines
6.4 KiB
TypeScript

/**
* @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>
);
}