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:
@@ -12,6 +12,8 @@
|
||||
* remembered for the + menu's Recent uploads), and a panel row (Files,
|
||||
* Changes, a context file or folder) dropped on it (`lib/promptAttach.ts`)
|
||||
* as an `@path` chip — a `bare` one takes a drop anywhere on its box;
|
||||
* its `/command` and `@path` tokens are coloured by a mirror laid over
|
||||
* the textarea (`lib/promptTokens.ts`);
|
||||
* a borderless (`bare`) one grows with its text up to 40vh;
|
||||
* - `CwdAutocomplete` — the folder field the Add-workspace modal uses;
|
||||
* - `ModelPicker` — a tool's own model list (`GET /api/ai-tools/:id/models`)
|
||||
@@ -61,6 +63,7 @@ import { Select, menuRoom } from "../Select";
|
||||
import { ContextMenu, MenuItem, menuPoint, type MenuPoint } from "../ContextMenu";
|
||||
import { isSubmitEnter } from "../../lib/keys";
|
||||
import { ImageLightbox } from "../ImageLightbox";
|
||||
import { TOKEN_TONE, promptTokens } from "../../lib/promptTokens";
|
||||
import {
|
||||
ATTACH_MIME,
|
||||
addAttachments,
|
||||
@@ -224,6 +227,15 @@ export function PromptEditor({
|
||||
}: PromptEditorProps) {
|
||||
const { t } = useTranslation("run");
|
||||
const taRef = useRef<HTMLTextAreaElement | null>(null);
|
||||
// With a `/command` or `@path` in it, the textarea's own text turns
|
||||
// transparent and a mirror over it — same box, font and wrapping, scrolled
|
||||
// with it — draws the text with those tokens coloured.
|
||||
const mirrorRef = useRef<HTMLDivElement | null>(null);
|
||||
const tokens = useMemo(() => promptTokens(value), [value]);
|
||||
const marked = tokens.some((tok) => tok.kind !== "text");
|
||||
const box = bare
|
||||
? "block max-h-[40vh] w-full overflow-y-auto px-1 py-1 text-sm"
|
||||
: "w-full rounded-md border px-3 py-2 text-[11px] font-mono";
|
||||
// A composer grows with its text: reset to its rows, then take what the
|
||||
// text needs; `max-h-[40vh]` caps it and it scrolls from there.
|
||||
useLayoutEffect(() => {
|
||||
@@ -232,6 +244,10 @@ export function PromptEditor({
|
||||
ta.style.height = "auto";
|
||||
ta.style.height = `${ta.scrollHeight}px`;
|
||||
}, [bare, value]);
|
||||
// A mirror that just appeared starts at the top, whatever the textarea shows.
|
||||
useLayoutEffect(() => {
|
||||
if (mirrorRef.current && taRef.current) mirrorRef.current.scrollTop = taRef.current.scrollTop;
|
||||
}, [marked]);
|
||||
const [trigger, setTrigger] = useState<Trigger | null>(null);
|
||||
const [active, setActive] = useState(0);
|
||||
const [files, setFiles] = useState<string[]>([]);
|
||||
@@ -545,39 +561,64 @@ export function PromptEditor({
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<textarea
|
||||
ref={taRef}
|
||||
value={value}
|
||||
onChange={(e) => {
|
||||
onChange(e.target.value);
|
||||
retrack(e.target);
|
||||
setActive(0);
|
||||
}}
|
||||
onSelect={(e) => retrack(e.currentTarget)}
|
||||
onBlur={() => setTrigger(null)}
|
||||
onKeyDown={handleKeyDown}
|
||||
onPaste={(e) => {
|
||||
if (attachImages(e.clipboardData?.files)) e.preventDefault();
|
||||
else {
|
||||
// A very long paste goes in as a text file, like Claude Desktop:
|
||||
// the prompt then carries its path, never the text (#68: a CLI's
|
||||
// argument and a tmux message both have a size limit).
|
||||
const text = e.clipboardData?.getData("text/plain") ?? "";
|
||||
if (text.length > PASTE_AS_FILE_CHARS) {
|
||||
e.preventDefault();
|
||||
attachImages([new File([text], "pasted-text.txt", { type: "text/plain" })]);
|
||||
<div className="relative">
|
||||
<textarea
|
||||
ref={taRef}
|
||||
onScroll={(e) => {
|
||||
if (mirrorRef.current) mirrorRef.current.scrollTop = e.currentTarget.scrollTop;
|
||||
}}
|
||||
value={value}
|
||||
onChange={(e) => {
|
||||
onChange(e.target.value);
|
||||
retrack(e.target);
|
||||
setActive(0);
|
||||
}}
|
||||
onSelect={(e) => retrack(e.currentTarget)}
|
||||
onBlur={() => setTrigger(null)}
|
||||
onKeyDown={handleKeyDown}
|
||||
onPaste={(e) => {
|
||||
if (attachImages(e.clipboardData?.files)) e.preventDefault();
|
||||
else {
|
||||
// A very long paste goes in as a text file, like Claude Desktop:
|
||||
// the prompt then carries its path, never the text (#68: a CLI's
|
||||
// argument and a tmux message both have a size limit).
|
||||
const text = e.clipboardData?.getData("text/plain") ?? "";
|
||||
if (text.length > PASTE_AS_FILE_CHARS) {
|
||||
e.preventDefault();
|
||||
attachImages([new File([text], "pasted-text.txt", { type: "text/plain" })]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}}
|
||||
placeholder={placeholder}
|
||||
rows={rows}
|
||||
spellCheck={false}
|
||||
className={
|
||||
bare
|
||||
? "block max-h-[40vh] w-full resize-none overflow-y-auto bg-transparent px-1 py-1 text-sm text-fg-primary placeholder:text-fg-muted focus:outline-none"
|
||||
: "w-full bg-surface-2 border border-border rounded-md px-3 py-2 text-[11px] font-mono text-fg-primary placeholder:text-fg-muted focus:outline-none focus:border-accent/50 resize-none"
|
||||
}
|
||||
/>
|
||||
}}
|
||||
placeholder={placeholder}
|
||||
rows={rows}
|
||||
spellCheck={false}
|
||||
className={`${box} resize-none placeholder:text-fg-muted focus:outline-none ${
|
||||
marked ? "text-transparent caret-fg-primary" : "text-fg-primary"
|
||||
} ${bare ? "bg-transparent" : "bg-surface-2 border-border focus:border-accent/50"}`}
|
||||
/>
|
||||
{marked && (
|
||||
<div
|
||||
ref={mirrorRef}
|
||||
aria-hidden
|
||||
data-testid="prompt-mirror"
|
||||
// Its own scrollbar keeps the width the textarea's takes, but is
|
||||
// invisible, so the real one shows through.
|
||||
className={`${box} pointer-events-none absolute inset-0 overflow-y-auto whitespace-pre-wrap break-words border-transparent text-fg-primary [scrollbar-color:transparent_transparent]`}
|
||||
>
|
||||
{tokens.map((tok, i) =>
|
||||
tok.kind === "text" ? (
|
||||
tok.text
|
||||
) : (
|
||||
<span key={i} className={`rounded-sm ${TOKEN_TONE[tok.kind]}`}>
|
||||
{tok.text}
|
||||
</span>
|
||||
)
|
||||
)}
|
||||
{/* A trailing newline is a line in a textarea, not in a div. */}
|
||||
{"\u200b"}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{upload && (
|
||||
<p
|
||||
role={upload === "failed" ? "alert" : "status"}
|
||||
|
||||
Reference in New Issue
Block a user