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:
@@ -77,6 +77,7 @@ import {
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useSettingsHref } from "../../lib/settingsLink";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { SagiAvatar } from "./SagiAvatar";
|
||||
import { PetSprite } from "./PetSprite";
|
||||
@@ -215,6 +216,7 @@ function SagiBody() {
|
||||
const [hover, setHover] = useState(false);
|
||||
const reducedMotion = usePrefersReducedMotion();
|
||||
const navigate = useNavigate();
|
||||
const href = useSettingsHref();
|
||||
const brain = useSagiBrain();
|
||||
const place = useSagiPosition(size);
|
||||
|
||||
@@ -348,7 +350,7 @@ function SagiBody() {
|
||||
onClose={() => setMenu(false)}
|
||||
onSettings={() => {
|
||||
setMenu(false);
|
||||
navigate("/settings/appearance#pet");
|
||||
navigate(href("appearance#pet"));
|
||||
}}
|
||||
/>
|
||||
</SagiFlyout>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* @file Sidebar.tsx
|
||||
* @description The app's left sidebar: the brand, the navigation links and the workspace list, with Settings and the collapse/expand button pinned to its foot. It also owns the connection details modal (opened from the status bar), which carries the version and the update check — the sidebar itself has no footer, language or theme controls (those live in Settings → General / Appearance). The collapsed state is kept in localStorage; with none stored it starts collapsed below Tailwind's `lg`. Below `md` the Layout turns it into an off-canvas drawer (`drawer` prop): a modal dialog with the shared `useDialog` focus contract.
|
||||
* @description The app's left sidebar: the brand, the navigation links and the workspace list, with Settings (on a workspace, that workspace's Settings tab — `lib/settingsLink.ts`) and the collapse/expand button pinned to its foot. It also owns the connection details modal (opened from the status bar), which carries the version and the update check — the sidebar itself has no footer, language or theme controls (those live in Settings → General / Appearance). The collapsed state is kept in localStorage; with none stored it starts collapsed below Tailwind's `lg`. Below `md` the Layout turns it into an off-canvas drawer (`drawer` prop): a modal dialog with the shared `useDialog` focus contract.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
/* =============================================================================
|
||||
@@ -49,7 +49,8 @@
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { NavLink } from "react-router-dom";
|
||||
import { NavLink, useLocation } from "react-router-dom";
|
||||
import { settingsHref } from "../lib/settingsLink";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useDialog } from "../hooks/useDialog";
|
||||
import {
|
||||
@@ -186,6 +187,12 @@ export function Sidebar({
|
||||
}: SidebarProps) {
|
||||
const { t } = useTranslation();
|
||||
const auth = useAuth();
|
||||
// On a workspace Settings opens as its tab (lib/settingsLink.ts), so the
|
||||
// link's own match (the path) cannot tell when it is the one on screen.
|
||||
const location = useLocation();
|
||||
const settingsOn =
|
||||
location.pathname.startsWith("/settings") ||
|
||||
new URLSearchParams(location.search).has("settings");
|
||||
// Checking for (and applying) an update acts on the host: an admin's.
|
||||
const canUpdate = usePermissions().isAdmin;
|
||||
const asideRef = useRef<HTMLElement>(null);
|
||||
@@ -533,9 +540,9 @@ export function Sidebar({
|
||||
}`}
|
||||
>
|
||||
<NavLink
|
||||
to="/settings"
|
||||
to={settingsHref("", location)}
|
||||
title={collapsed ? t("nav:settings") : undefined}
|
||||
className={(p) => `${navClass(p)} ${collapsed ? "" : "flex-1"}`}
|
||||
className={() => `${navClass({ isActive: settingsOn })} ${collapsed ? "" : "flex-1"}`}
|
||||
>
|
||||
<Settings className="w-4 h-4 flex-shrink-0" />
|
||||
{!collapsed && <span>{t("nav:settings")}</span>}
|
||||
|
||||
@@ -6,7 +6,10 @@
|
||||
* horizontal rules, simple tables, inline code, bold, italic, strikethrough, links,
|
||||
* and auto-linked URLs. An absolute file path (bare or in inline code) opens
|
||||
* through `OpenFileContext` when the page provides one; `linkify` does the
|
||||
* same links for plain text.
|
||||
* same links for plain text. To make a reply easy to scan, bold is coloured
|
||||
* (accent), standalone numbers (87%, 3.5s, v2.7.1, dates) are tinted and
|
||||
* tabular, and a flag label ("Lưu ý:", "Warning:", "Lỗi:") takes the colour
|
||||
* of how serious it is.
|
||||
*
|
||||
* Output is built as a React element tree (no dangerouslySetInnerHTML) so user content
|
||||
* is escaped by React. Code blocks delegate to <CodeBlock /> for syntax highlighting and
|
||||
@@ -256,6 +259,22 @@ const URL_RE = /^https?:\/\/[^\s<>()]+[^\s<>().,!?;:'"]/;
|
||||
const PATH_RE = /^(?:\/[^\s/<>()'"`,;:]*[^\s/<>()'"`,;:.!?]){2,}/;
|
||||
// A link starts a word: "and/or" or "a/b/c" stays text.
|
||||
const atWordStart = (text: string, i: number) => i === 0 || /[\s([{"'`]/.test(text[i - 1]!);
|
||||
// A number worth spotting: 42, 1,234.5, 87%, $1.20, 3.5s, 12MB, v2.7.1, 2026-10-12, 12:30,
|
||||
// 2026-10-12T08:00:00Z. It must stand alone, so "abc123", "file.2", "#67" and a sha like
|
||||
// "2fd0c903" stay text.
|
||||
const NUM_RE =
|
||||
/^(?:[-+−]?[$€£¥]?|v)\d+(?:[.,:/-]\d+)*(?:T\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?(?:Z|[+-]\d{2}:?\d{2})?)?(?:%|×|x|k|K|M|B|KB|MB|GB|TB|ms|s|m|h|d|px|đ)?(?![\p{L}\p{N}_])/u;
|
||||
const numStart = (text: string, i: number) =>
|
||||
i === 0 || !/[\p{L}\p{N}_.\/\\#@-]/u.test(text[i - 1]!);
|
||||
// A label that flags what follows — "Lưu ý:", "Warning:" — coloured by how serious it is.
|
||||
const LABEL_RE =
|
||||
/^(Error|Lỗi|Caution|Danger|Breaking|Warning|Cảnh báo|Important|Quan trọng|Chú ý|Note|Lưu ý|Tip|Mẹo):/i;
|
||||
const LABEL_CLASS = (word: string) =>
|
||||
/^(error|lỗi|caution|danger|breaking)$/i.test(word)
|
||||
? "text-status-danger"
|
||||
: /^(warning|cảnh báo|important|quan trọng|chú ý)$/i.test(word)
|
||||
? "text-status-warning"
|
||||
: "text-accent";
|
||||
|
||||
function UrlLink({ url }: { url: string }) {
|
||||
return (
|
||||
@@ -354,7 +373,7 @@ function renderInline(text: string, baseKey = ""): React.ReactNode[] {
|
||||
const boldM = rest.match(/^(\*\*|__)(.+?)\1/);
|
||||
if (boldM) {
|
||||
push(
|
||||
<strong className="font-semibold text-fg-primary">
|
||||
<strong className="font-bold text-accent">
|
||||
{renderInline(boldM[2]!, `${baseKey}-b${n}`)}
|
||||
</strong>
|
||||
);
|
||||
@@ -419,6 +438,24 @@ function renderInline(text: string, baseKey = ""): React.ReactNode[] {
|
||||
continue;
|
||||
}
|
||||
|
||||
const labelM = atWordStart(text, i) ? LABEL_RE.exec(rest) : null;
|
||||
if (labelM) {
|
||||
push(<span className={`font-bold ${LABEL_CLASS(labelM[1]!)}`}>{labelM[0]}</span>);
|
||||
i += labelM[0].length;
|
||||
continue;
|
||||
}
|
||||
|
||||
const numM = numStart(text, i) ? NUM_RE.exec(rest) : null;
|
||||
if (numM) {
|
||||
push(
|
||||
<span className="font-semibold tabular-nums text-teal-700 dark:text-teal-300">
|
||||
{numM[0]}
|
||||
</span>
|
||||
);
|
||||
i += numM[0].length;
|
||||
continue;
|
||||
}
|
||||
|
||||
buf += text[i]!;
|
||||
i++;
|
||||
}
|
||||
|
||||
@@ -9,7 +9,8 @@
|
||||
* image instead (`GET /api/run/drop/:name`), and Claude Code's own
|
||||
* `[Image: source: <drop>]` line for it joins that turn rather than showing as
|
||||
* a System row; URLs and absolute paths in a turn are links (a path opens
|
||||
* through `onOpenFile`). Each assistant turn that changed
|
||||
* through `onOpenFile`), and its `/command` and `@path` tokens chips coloured
|
||||
* like the prompt box's (`lib/promptTokens.ts`). Each assistant turn that changed
|
||||
* files ends with Claude Desktop's "Edited N files" card: every file with the
|
||||
* lines added and removed (the tool results' `diff`); a click opens the file
|
||||
* when the caller can (`onOpenFile`), and a row (or the card's header, for
|
||||
@@ -66,7 +67,7 @@
|
||||
*
|
||||
* ----------------------------------------------------------------------------- */
|
||||
|
||||
import { memo, useEffect, useState, useMemo } from "react";
|
||||
import { Fragment, memo, useEffect, useState, useMemo } from "react";
|
||||
import { ImageLightbox } from "../ImageLightbox";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import i18n from "../../i18n";
|
||||
@@ -130,6 +131,7 @@ const SENDER_STYLES: Record<
|
||||
import { ToolCallBlock, buildSummary, renderInput, renderResult } from "./ToolCallBlock";
|
||||
import { MarkdownContent, OpenFileContext, linkify } from "./MarkdownContent";
|
||||
import { dragAttachments, projectAttachment } from "../../lib/promptAttach";
|
||||
import { TOKEN_TONE, promptTokens as splitPrompt } from "../../lib/promptTokens";
|
||||
import { fmt, formatModelName, formatMs, parseDate } from "../../lib/format";
|
||||
import { parseTuiSegments, stripAnsi, hasTuiTags, type TuiSegment } from "./tuiSegments";
|
||||
|
||||
@@ -205,12 +207,32 @@ function SessionEventRow({ title, timestamp }: { title?: string; timestamp: stri
|
||||
);
|
||||
}
|
||||
|
||||
/** Compact pill for /command invocations parsed out of TUI markup. */
|
||||
/** Typed text with its `/command` and `@path` tokens as chips, the rest linkified. */
|
||||
function typedText(text: string): React.ReactNode[] {
|
||||
return splitPrompt(text).map((tok, i) =>
|
||||
tok.kind === "text" ? (
|
||||
<Fragment key={i}>{linkify(tok.text)}</Fragment>
|
||||
) : (
|
||||
<span
|
||||
key={i}
|
||||
className={`rounded-md px-1.5 py-0.5 font-mono text-[0.92em] font-medium ${TOKEN_TONE[tok.kind]}`}
|
||||
>
|
||||
{tok.text}
|
||||
</span>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/** Compact pill for /command invocations parsed out of TUI markup: the
|
||||
* command in the prompt box's colour, its arguments after it. */
|
||||
function CommandPill({ display }: { display: string }) {
|
||||
const [name, ...args] = display.split(" ");
|
||||
return (
|
||||
<div className="inline-flex items-center gap-2 text-sm text-status-success font-mono bg-status-success/10 border border-status-success/20 rounded-md px-3 py-1.5 max-w-full">
|
||||
<span className="text-status-success/70">›</span>
|
||||
<span className="break-all">{display}</span>
|
||||
<div className="inline-flex max-w-full items-baseline gap-2 rounded-lg border border-accent/25 bg-accent/10 px-2.5 py-1 font-mono text-sm">
|
||||
<span className="font-semibold text-accent">{name}</span>
|
||||
{args.length > 0 && (
|
||||
<span className="break-all text-fg-secondary">{typedText(args.join(" "))}</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -792,7 +814,7 @@ const UserBubble = memo(function UserBubble({ msg }: { msg: TranscriptMessage })
|
||||
const plain = !hasTuiTags(text) && !isSkillContent(text) && !isTaskNotification(text);
|
||||
return plain ? (
|
||||
<p key={b} className="whitespace-pre-wrap break-words leading-relaxed">
|
||||
{linkify(stripAnsi(text).trim())}
|
||||
{typedText(stripAnsi(text).trim())}
|
||||
</p>
|
||||
) : (
|
||||
renderTextBlock(text, b)
|
||||
|
||||
@@ -66,6 +66,43 @@ describe("<MarkdownContent />", () => {
|
||||
expect(screen.getByText("just a normal sentence.")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("highlights standalone numbers, never ones inside a word, sha or issue ref", () => {
|
||||
const { container } = render(
|
||||
<MarkdownContent
|
||||
text={"Took 3.5s, 87% done, $1.20, v2.7.1 on 2026-10-12 — abc123 file.2 #67 2fd0c903 x9"}
|
||||
/>
|
||||
);
|
||||
const nums = [...container.querySelectorAll("span.tabular-nums")].map((n) => n.textContent);
|
||||
expect(nums).toEqual(["3.5s", "87%", "$1.20", "v2.7.1", "2026-10-12"]);
|
||||
});
|
||||
|
||||
it("highlights an ISO date-time as one number", () => {
|
||||
const { container } = render(
|
||||
<MarkdownContent
|
||||
text={"At 2026-10-12T08:00, 2026-10-12T08:00:00Z and 2026-10-12T08:00:00.123+07:00."}
|
||||
/>
|
||||
);
|
||||
const nums = [...container.querySelectorAll("span.tabular-nums")].map((n) => n.textContent);
|
||||
expect(nums).toEqual([
|
||||
"2026-10-12T08:00",
|
||||
"2026-10-12T08:00:00Z",
|
||||
"2026-10-12T08:00:00.123+07:00",
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not highlight numbers inside inline code", () => {
|
||||
const { container } = render(<MarkdownContent text={"Run `sleep 5` now"} />);
|
||||
expect(container.querySelector("span.tabular-nums")).toBeNull();
|
||||
});
|
||||
|
||||
it("colours a flag label by how serious it is", () => {
|
||||
render(<MarkdownContent text={"Lưu ý: a. Warning: b. Lỗi: c. Note that x"} />);
|
||||
expect(screen.getByText("Lưu ý:").className).toContain("text-accent");
|
||||
expect(screen.getByText("Warning:").className).toContain("text-status-warning");
|
||||
expect(screen.getByText("Lỗi:").className).toContain("text-status-danger");
|
||||
expect(screen.queryByText("Note")).toBeNull();
|
||||
});
|
||||
|
||||
it("handles empty input safely", () => {
|
||||
const { container } = render(<MarkdownContent text="" />);
|
||||
// Wrapper exists but no block elements
|
||||
|
||||
@@ -4,11 +4,12 @@
|
||||
* sender — the human as a bubble, the assistant's prose bare (Claude Desktop's
|
||||
* layout), Main agent / System under their own label — instead of treating
|
||||
* every `type:"user"` line as the user (reported transcript mis-attribution) —
|
||||
* and folds a run of tool calls into one line.
|
||||
* and folds a run of tool calls into one line; a typed `/command` or `@path`
|
||||
* shows as a chip.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { fireEvent, render, screen, within } from "@testing-library/react";
|
||||
import { MessageList, workLabel, workingOn } from "../MessageList";
|
||||
import type { TranscriptMessage } from "../../../lib/types";
|
||||
|
||||
@@ -22,6 +23,21 @@ function msg(partial: Partial<TranscriptMessage>): TranscriptMessage {
|
||||
}
|
||||
|
||||
describe("MessageList — sender attribution", () => {
|
||||
it("shows a typed /command and @path as chips in the user's bubble", () => {
|
||||
render(
|
||||
<MessageList
|
||||
messages={[
|
||||
msg({ sender: "user", content: [{ type: "text", text: "/review @src/a.ts now" }] }),
|
||||
]}
|
||||
loading={false}
|
||||
/>
|
||||
);
|
||||
const bubble = screen.getByTestId("chat-user");
|
||||
expect(bubble).toHaveTextContent("/review @src/a.ts now");
|
||||
expect(within(bubble).getByText("/review").className).toContain("text-accent");
|
||||
expect(within(bubble).getByText("@src/a.ts").className).toContain("text-violet");
|
||||
});
|
||||
|
||||
it("labels each row by its sender, not blanket 'User'", () => {
|
||||
const messages: TranscriptMessage[] = [
|
||||
msg({ sender: "user", content: [{ type: "text", text: "spin up a team" }] }),
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
* commit (Annotate); a click hands its sha to `onBlameClick`. `navRef` gets
|
||||
* a function that moves the cursor to the next / previous changed block.
|
||||
* Given `onAddToChat`, a selection in the editable file gets Cursor's Add to
|
||||
* Chat chip at the end of its first line (inline, so it covers no code),
|
||||
* Chat chip floating above its last line, at the column where it ends,
|
||||
* which hands the panel the selection's 1-based lines.
|
||||
* Lazy-loaded by LaneChangesPanel, which keys it by file and shape:
|
||||
* CodeMirror stays out of the main bundle, and one mount is one view.
|
||||
@@ -50,6 +50,8 @@ import {
|
||||
highlightActiveLineGutter,
|
||||
keymap,
|
||||
lineNumbers,
|
||||
showTooltip,
|
||||
type Tooltip,
|
||||
type DecorationSet,
|
||||
type ViewUpdate,
|
||||
} from "@codemirror/view";
|
||||
@@ -284,41 +286,34 @@ function blockBars(orig: Text, build: (b: EditorBlock) => HTMLElement) {
|
||||
});
|
||||
}
|
||||
|
||||
class ChatChip extends WidgetType {
|
||||
constructor(
|
||||
readonly start: number,
|
||||
readonly end: number,
|
||||
readonly build: (start: number, end: number) => HTMLElement
|
||||
) {
|
||||
super();
|
||||
}
|
||||
eq(o: ChatChip) {
|
||||
return this.start === o.start && this.end === o.end;
|
||||
}
|
||||
toDOM() {
|
||||
return this.build(this.start, this.end);
|
||||
}
|
||||
}
|
||||
|
||||
/** Add to Chat inline at the end of a non-empty selection's first line —
|
||||
* the empty space right of the code, so it covers no line. `build` makes its
|
||||
* DOM from the selection's first and last 1-based line. A selection ending
|
||||
* at a line's start (a whole-line drag) does not take that line. */
|
||||
/** Add to Chat floating just above the selection's last line, at the column
|
||||
* where the selection ends — so a long line never pushes it out of view.
|
||||
* `build` makes its DOM from the selection's first and last 1-based line. A
|
||||
* selection ending at a line's start (a whole-line drag) does not take that
|
||||
* line, and the chip then sits at the start of the last line it does take. */
|
||||
function chatChip(build: (start: number, end: number) => HTMLElement) {
|
||||
return StateField.define<DecorationSet>({
|
||||
create: () => Decoration.none,
|
||||
update(deco, tr) {
|
||||
if (!tr.docChanged && !tr.selection) return deco;
|
||||
return StateField.define<Tooltip | null>({
|
||||
create: () => null,
|
||||
update(tip, tr) {
|
||||
if (!tr.docChanged && !tr.selection) return tip;
|
||||
const { from, to, empty } = tr.state.selection.main;
|
||||
if (empty) return Decoration.none;
|
||||
if (empty) return null;
|
||||
const doc = tr.state.doc;
|
||||
const first = doc.lineAt(from);
|
||||
const last = doc.lineAt(to);
|
||||
const end = to === last.from && last.number > first.number ? last.number - 1 : last.number;
|
||||
const widget = new ChatChip(first.number, end, build);
|
||||
return Decoration.set(Decoration.widget({ widget, side: 1 }).range(first.to));
|
||||
const wholeLine = to === last.from && last.number > first.number;
|
||||
const end = wholeLine ? last.number - 1 : last.number;
|
||||
return {
|
||||
pos: wholeLine ? doc.line(end).from : to,
|
||||
above: true,
|
||||
create: () => {
|
||||
const dom = build(first.number, end);
|
||||
dom.classList.add("cm-chat-chip");
|
||||
return { dom };
|
||||
},
|
||||
};
|
||||
},
|
||||
provide: (f) => EditorView.decorations.from(f),
|
||||
provide: (f) => showTooltip.from(f),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -355,9 +350,19 @@ const theme = EditorView.theme({
|
||||
".cm-activeLine": { backgroundColor: "rgb(var(--fg-primary) / 0.04)" },
|
||||
".cm-activeLineGutter": { backgroundColor: "rgb(var(--fg-primary) / 0.08)" },
|
||||
".cm-cursor": { borderLeftColor: "rgb(var(--fg-primary))" },
|
||||
"&.cm-focused .cm-selectionBackground, .cm-selectionBackground, .cm-content ::selection": {
|
||||
backgroundColor: "rgb(var(--accent) / 0.3)",
|
||||
// Doubled classes: drawSelection's base theme scopes its light/dark colours
|
||||
// as `.ͼ1.ͼ2.cm-focused > .cm-scroller > .cm-selectionLayer …`, which a
|
||||
// plain `.cm-selectionBackground` never outranks.
|
||||
"&.cm-focused.cm-focused > .cm-scroller > .cm-selectionLayer .cm-selectionBackground, .cm-selectionLayer .cm-selectionBackground.cm-selectionBackground, .cm-content ::selection":
|
||||
{
|
||||
background: "rgb(var(--accent) / 0.3)",
|
||||
},
|
||||
// Doubled class: outranks the base theme's light/dark-scoped tooltip frame.
|
||||
".cm-tooltip.cm-chat-chip.cm-chat-chip": {
|
||||
border: "1px solid rgb(var(--accent) / 0.6)",
|
||||
backgroundColor: "rgb(var(--surface-1))",
|
||||
},
|
||||
".cm-tooltip.cm-chat-chip.cm-chat-chip:hover": { backgroundColor: "rgb(var(--surface-2))" },
|
||||
[`${M} .cm-changedLine`]: { backgroundColor: GREEN },
|
||||
[`${M} .cm-changedLine.cm-ah-mod`]: { backgroundColor: BLUE },
|
||||
[`${M} .cm-changedText`]: { background: "none" },
|
||||
@@ -544,7 +549,7 @@ export default function DiffEditor({
|
||||
b.dataset.testid = "lane-editor-add-to-chat";
|
||||
b.textContent = cb.current.t("changes.addToChat");
|
||||
b.className =
|
||||
"ml-3 rounded border border-accent/60 bg-surface-1 px-1.5 font-sans text-[10px] leading-[14px] text-accent align-middle hover:bg-surface-2";
|
||||
"rounded border border-accent/60 bg-surface-1 px-1.5 font-sans text-[10px] leading-[14px] text-accent hover:bg-surface-2";
|
||||
// Keep the editor's focus (and so its selection) through the click.
|
||||
b.onmousedown = (e) => e.preventDefault();
|
||||
b.onclick = () => cb.current.onAddToChat?.(start, end);
|
||||
|
||||
@@ -313,10 +313,10 @@ describe("DiffEditor", () => {
|
||||
expect(b).not.toBeNull();
|
||||
return b!;
|
||||
});
|
||||
// Inline at the end of the first selected line, never a tooltip over the
|
||||
// line above it.
|
||||
expect(chip.closest(".cm-tooltip")).toBeNull();
|
||||
expect(chip.closest(".cm-line")?.textContent).toMatch(/^b/);
|
||||
// A tooltip above the last selected line, never inline at a line's end
|
||||
// (a long line pushed it out of view).
|
||||
expect(chip.classList.contains("cm-tooltip")).toBe(true);
|
||||
expect(chip.closest(".cm-line")).toBeNull();
|
||||
fireEvent.click(chip);
|
||||
expect(onAddToChat).toHaveBeenCalledWith(2, 3);
|
||||
view.dispatch({ selection: { anchor: 6 } });
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
import { useEffect, useState, type ComponentType, type DragEvent, type ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Link } from "react-router-dom";
|
||||
import { useSettingsHref } from "../../lib/settingsLink";
|
||||
import {
|
||||
BookOpen,
|
||||
Check,
|
||||
@@ -209,6 +210,7 @@ export function ChatOutline({
|
||||
onOpenList: (list: OutlineList) => void;
|
||||
}) {
|
||||
const { t } = useTranslation("run");
|
||||
const href = useSettingsHref();
|
||||
const used = o.uploads.length + o.files.length + o.memory.length + o.skills.length + o.mcp.length;
|
||||
const close = (
|
||||
<button
|
||||
@@ -329,7 +331,7 @@ export function ChatOutline({
|
||||
icon={Plug}
|
||||
label={t("chat.outline.connectors")}
|
||||
names={o.mcp}
|
||||
to="/settings/agents/claude-code?tab=mcp"
|
||||
to={href("agents/claude-code?tab=mcp")}
|
||||
/>
|
||||
)}
|
||||
</Section>
|
||||
|
||||
@@ -28,7 +28,9 @@
|
||||
* one, up to four panes). A right-click on a tab
|
||||
* opens the same actions as a menu (`ContextMenu`). With tabs from every
|
||||
* workspace on, `tabs` spans every workspace (each run's `slot` a `paneKey`)
|
||||
* and `workspaceOf` names each tab's workspace before its own name.
|
||||
* and `workspaceOf` names each tab's workspace before its own name. Settings
|
||||
* opened on a workspace is one more tab at the bar's end (`settings`, kept
|
||||
* until its X), shown instead of the panes while it is the active one.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
@@ -43,8 +45,9 @@ import {
|
||||
} from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { House, Plus, Settings2, SquareTerminal, X } from "lucide-react";
|
||||
import { House, Plus, Settings, Settings2, SquareTerminal, X } from "lucide-react";
|
||||
import { api } from "../../lib/api";
|
||||
import { useSettingsHref } from "../../lib/settingsLink";
|
||||
import type { RunHandle } from "../../lib/api";
|
||||
import { isSessionAwaitingInput, type Lane, type Session } from "../../lib/types";
|
||||
import { toast } from "../../lib/toast";
|
||||
@@ -152,6 +155,9 @@ interface Tab {
|
||||
|
||||
const URGENCY: RunState[] = ["awaiting", "error", "running"];
|
||||
|
||||
/** The Settings tab's key among the tabs' (a group's key is its slots). */
|
||||
const SETTINGS_KEY = "settings";
|
||||
|
||||
export function ConsoleTabs({
|
||||
lane,
|
||||
tabs: runs,
|
||||
@@ -164,6 +170,7 @@ export function ConsoleTabs({
|
||||
sessions,
|
||||
onRenamed,
|
||||
workspaceOf,
|
||||
settings,
|
||||
}: {
|
||||
lane: Lane;
|
||||
/** The workspace's live runs, in slot order. */
|
||||
@@ -187,9 +194,12 @@ export function ConsoleTabs({
|
||||
onHome?: () => void;
|
||||
/** Right end of the bar (the single pane's split menus). */
|
||||
actions?: ReactNode;
|
||||
/** The Settings tab, while it is open: on screen (`active`), shown, closed. */
|
||||
settings?: { active: boolean; onOpen: () => void; onClose: () => void } | null;
|
||||
}) {
|
||||
const { t } = useTranslation("lanes");
|
||||
const navigate = useNavigate();
|
||||
const settingsHref = useSettingsHref();
|
||||
const aiTools = useAiTools();
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
// The tab being renamed (its run id) and the draft name.
|
||||
@@ -397,8 +407,9 @@ export function ConsoleTabs({
|
||||
};
|
||||
|
||||
// APG tabs: one tab stop; arrows, Home and End move between the tabs.
|
||||
const keys = [...tabs.map((x) => x.key), ...(settings ? [SETTINGS_KEY] : [])];
|
||||
const onTabKey = (e: ReactKeyboardEvent, i: number) => {
|
||||
const last = tabs.length - 1;
|
||||
const last = keys.length - 1;
|
||||
const to =
|
||||
e.key === "ArrowRight"
|
||||
? i === last
|
||||
@@ -413,11 +424,14 @@ export function ConsoleTabs({
|
||||
: e.key === "End"
|
||||
? last
|
||||
: null;
|
||||
if (to === null || !tabs[to]) return;
|
||||
const key = to === null ? undefined : keys[to];
|
||||
if (key === undefined) return;
|
||||
e.preventDefault();
|
||||
tabRefs.current.get(tabs[to].key)?.focus();
|
||||
tabRefs.current.get(key)?.focus();
|
||||
};
|
||||
const tabStop = tabs.find((x) => x.index === groups.active)?.key ?? tabs[0]?.key;
|
||||
const tabStop = settings?.active
|
||||
? SETTINGS_KEY
|
||||
: (tabs.find((x) => x.index === groups.active)?.key ?? keys[0]);
|
||||
|
||||
// A tab-menu item: close the menu, then act on the tab it was opened on.
|
||||
const pickTab = (fn: (tab: Tab) => unknown) => () => {
|
||||
@@ -456,7 +470,7 @@ export function ConsoleTabs({
|
||||
className="flex min-w-0 items-stretch overflow-x-auto no-scrollbar"
|
||||
>
|
||||
{tabs.map((tab, i) => {
|
||||
const active = tab.index === groups.active;
|
||||
const active = !settings?.active && tab.index === groups.active;
|
||||
const single = tab.runs.length === 1 ? tab.focus : null;
|
||||
const state = tabState(tab);
|
||||
if (single && renaming === single.id) {
|
||||
@@ -569,6 +583,48 @@ export function ConsoleTabs({
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{settings && (
|
||||
<div
|
||||
className={`group relative flex shrink-0 items-center rounded-t-md ${
|
||||
settings.active
|
||||
? "bg-surface-0 text-fg-primary"
|
||||
: "text-fg-muted hover:bg-surface-4 hover:text-fg-secondary"
|
||||
}`}
|
||||
>
|
||||
{settings.active && (
|
||||
<span className="absolute inset-x-2 -bottom-px h-0.5 rounded-full bg-accent" />
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={settings.active}
|
||||
tabIndex={tabStop === SETTINGS_KEY ? 0 : -1}
|
||||
ref={(el) => {
|
||||
if (el) tabRefs.current.set(SETTINGS_KEY, el);
|
||||
else tabRefs.current.delete(SETTINGS_KEY);
|
||||
}}
|
||||
onKeyDown={(e) => onTabKey(e, keys.length - 1)}
|
||||
data-testid="console-tab-settings"
|
||||
onClick={settings.onOpen}
|
||||
className="flex h-full items-center gap-2 pl-3 pr-1.5 text-sm"
|
||||
>
|
||||
<Settings className="h-4 w-4 shrink-0" />
|
||||
{t("tabs.settings")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="console-tab-settings-close"
|
||||
onClick={settings.onClose}
|
||||
aria-label={t("tabs.closeSettings")}
|
||||
title={t("tabs.closeSettings")}
|
||||
className={`mr-2 shrink-0 rounded p-0.5 hover:bg-surface-4 hover:text-fg-primary ${
|
||||
settings.active ? "" : "opacity-0 group-hover:opacity-100 focus:opacity-100"
|
||||
}`}
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div ref={menuRef} className="relative flex items-center px-1.5">
|
||||
<button
|
||||
@@ -618,7 +674,10 @@ export function ConsoleTabs({
|
||||
type="button"
|
||||
role="menuitem"
|
||||
className={`${item} !text-fg-muted`}
|
||||
onClick={() => navigate("/settings/agents")}
|
||||
onClick={() => {
|
||||
setMenuOpen(false);
|
||||
navigate(settingsHref("agents"));
|
||||
}}
|
||||
>
|
||||
<Settings2 className="h-4 w-4" />
|
||||
{t("tabs.agentSettings")}
|
||||
|
||||
@@ -28,8 +28,9 @@
|
||||
* `SkillListModal`). Anything there drags into the prompt box. A file in a turn's
|
||||
* "Edited N files" card opens in the Workspace's Changes panel
|
||||
* (`openInChanges`) when it lies in the pane's folder. The unsent prompt and
|
||||
* its attachments are kept per session (`lib/chatDraft.ts`), so switching to
|
||||
* the terminal and back, or a reload, leaves them as typed. A send scrolls the
|
||||
* its attachments are kept per session (`lib/chatDraft.ts`), so a reload
|
||||
* leaves them as typed; switching to the terminal pastes them into its input
|
||||
* box and back brings that box's text here (`ChatToggle`). A send scrolls the
|
||||
* conversation to its end and keeps it there (`follow`), so the message and
|
||||
* the step running after it come into sight on their own. While the session's
|
||||
* main agent is working (a prompt submitted, no Stop yet) an empty box shows
|
||||
|
||||
@@ -58,6 +58,9 @@ import {
|
||||
import { AiToolIcon } from "../AiToolIcon";
|
||||
import { RunDot, type RunState } from "./ConsoleTabs";
|
||||
import { setSessionChat, useSessionChat } from "../../lib/chatUi";
|
||||
import { readChatDraft, writeChatDraft } from "../../lib/chatDraft";
|
||||
import { pasteToRun, takeRunInput } from "../../lib/panePaste";
|
||||
import { promptWithImages } from "./RunSetup";
|
||||
|
||||
export const PANE_DRAG_TYPE = "application/x-agenthub-pane";
|
||||
/** The slot being dragged: `dataTransfer` data cannot be read during dragover. */
|
||||
@@ -195,7 +198,9 @@ const ITEM =
|
||||
"flex w-full items-center gap-2.5 rounded-md px-2.5 py-1.5 text-left text-sm text-fg-secondary hover:bg-surface-4 hover:text-fg-primary";
|
||||
|
||||
/** Chat UI ⇄ terminal for a Claude/Codex pane — this session's own view
|
||||
* (`lib/chatUi.ts`); a session with no id yet has no chat to show. */
|
||||
* (`lib/chatUi.ts`); a session with no id yet has no chat to show. The
|
||||
* unsent prompt goes along: the terminal's input box into the chat's draft,
|
||||
* the draft (its files as paths) back into the input box, never sent. */
|
||||
function ChatToggle({ run }: { run?: RunHandle }) {
|
||||
const { t } = useTranslation("lanes");
|
||||
const chat = useSessionChat(run?.sessionId);
|
||||
@@ -209,7 +214,22 @@ function ChatToggle({ run }: { run?: RunHandle }) {
|
||||
aria-pressed={chat}
|
||||
title={label}
|
||||
aria-label={label}
|
||||
onClick={() => setSessionChat(sessionId, !chat)}
|
||||
onClick={() => {
|
||||
const draft = readChatDraft(sessionId);
|
||||
if (chat) {
|
||||
const text = promptWithImages(draft.prompt, draft.images);
|
||||
if (text && pasteToRun(run.id, text)) writeChatDraft(sessionId, "", []);
|
||||
} else {
|
||||
const text = takeRunInput(run.id);
|
||||
if (text)
|
||||
writeChatDraft(
|
||||
sessionId,
|
||||
[draft.prompt, text].filter(Boolean).join("\n"),
|
||||
draft.images
|
||||
);
|
||||
}
|
||||
setSessionChat(sessionId, !chat);
|
||||
}}
|
||||
className="flex h-6 w-6 items-center justify-center rounded text-fg-muted hover:bg-surface-4 hover:text-fg-primary"
|
||||
>
|
||||
{chat ? (
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -562,6 +562,24 @@ export function TerminalView({ runId, wsBaseUrl, onExit, autoFocus = true }: Ter
|
||||
() => !!containerRef.current?.offsetWidth && wsRef.current?.readyState === WebSocket.OPEN,
|
||||
(data) => {
|
||||
if (wsRef.current?.readyState === WebSocket.OPEN) wsRef.current.send(data);
|
||||
},
|
||||
// The rows on screen, dim cells (a TUI's placeholder) left out.
|
||||
() => {
|
||||
const term = termRef.current;
|
||||
if (!term) return null;
|
||||
const buf = term.buffer.active;
|
||||
const rows: string[] = [];
|
||||
for (let y = 0; y < term.rows; y++) {
|
||||
const line = buf.getLine(buf.baseY + y);
|
||||
let row = "";
|
||||
for (let x = 0; line && x < line.length; x++) {
|
||||
const cell = line.getCell(x);
|
||||
// Width 0: the right half of a wide character.
|
||||
if (cell && cell.getWidth() && !cell.isDim()) row += cell.getChars() || " ";
|
||||
}
|
||||
rows.push(row);
|
||||
}
|
||||
return { rows, cols: term.cols };
|
||||
}
|
||||
),
|
||||
[runId]
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
* open, `/` only where slash commands apply), Shift/Alt+Enter newlines, a
|
||||
* pasted image shown as a thumbnail and sent as its saved path (and
|
||||
* remembered as a recent upload), a Files panel row dropped as an `@path`
|
||||
* chip, a composer
|
||||
* chip, `/command` and `@path` coloured by a mirror over the text, a composer
|
||||
* (`bare`) box growing with its text up to a cap, and `ModelPicker` — a tool's own
|
||||
* list from the server, a fixed list (an AI Proxy's models) when given one,
|
||||
* and Custom…, which is a sentinel, never a model id — and `ModelMenu`, the
|
||||
@@ -16,7 +16,7 @@
|
||||
*/
|
||||
|
||||
import { useState } from "react";
|
||||
import { createEvent, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { createEvent, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import i18n from "i18next";
|
||||
import {
|
||||
@@ -117,6 +117,31 @@ function renderEditor(value: string, slash = true) {
|
||||
const box = () => screen.getByPlaceholderText("Ask anything…");
|
||||
|
||||
describe("PromptEditor — / and @ suggestions", () => {
|
||||
it("colours /command and @path tokens through a mirror, only when there are any", () => {
|
||||
const { unmount } = render(
|
||||
<PromptEditor
|
||||
value={"/review @src/a.ts please"}
|
||||
placeholder="Ask anything…"
|
||||
slash
|
||||
fileCwd="/Users/tester"
|
||||
images={[]}
|
||||
onImagesChange={() => {}}
|
||||
onChange={() => {}}
|
||||
onSubmit={() => {}}
|
||||
/>
|
||||
);
|
||||
const mirror = screen.getByTestId("prompt-mirror");
|
||||
expect(mirror).toHaveAttribute("aria-hidden");
|
||||
expect(within(mirror).getByText("/review").className).toContain("text-accent");
|
||||
expect(within(mirror).getByText("@src/a.ts").className).toContain("text-violet");
|
||||
expect(box().className).toContain("text-transparent");
|
||||
unmount();
|
||||
|
||||
renderEditor("plain words");
|
||||
expect(screen.queryByTestId("prompt-mirror")).toBeNull();
|
||||
expect(box().className).toContain("text-fg-primary");
|
||||
});
|
||||
|
||||
it("lists the folder's commands, skills and the CLI built-ins on /, and Enter picks one", async () => {
|
||||
const spies = renderEditor("");
|
||||
fireEvent.change(box(), { target: { value: "/rev" } });
|
||||
|
||||
@@ -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