Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3daf7ed4cb | |||
| f9869ada6d | |||
| 0fc436dc0c | |||
| cd25791160 | |||
| 2a83551eee | |||
| d0889d5250 | |||
| 6958a7b4f6 | |||
| aa7f1b1c60 | |||
| 70d7cf30c2 | |||
| 5912e9e613 | |||
| 497faae3bf | |||
| e6ce8009b1 | |||
| 705353b0da |
@@ -1,595 +0,0 @@
|
|||||||
/**
|
|
||||||
* @file PetChat.tsx
|
|
||||||
* @description The pet's chat window — its whole UI: clicking the pet opens
|
|
||||||
* it. Above the conversation sits what the pet knows (`info`: live / waiting /
|
|
||||||
* errored, the sessions that need you, Mute, Clear alerts — `SagiInfo.tsx`).
|
|
||||||
* Before a chat runs, a question the pet can answer itself (`onLocal`: status,
|
|
||||||
* who are you…) is answered right here; anything else — and every message once
|
|
||||||
* a chat runs — goes to the AI: the first message starts a
|
|
||||||
* session of the user's DEFAULT AI tool (`agenthub.defaultTool`, Claude or
|
|
||||||
* Codex — the two with a readable transcript) in the "Sagi" workspace, in its
|
|
||||||
* first free tab (`startInFreeTabDetailed`), and the conversation is shown as
|
|
||||||
* chat bubbles read from the session's transcript. Follow-ups are typed into
|
|
||||||
* that session's pane through a terminal attached OFF-SCREEN (`TerminalView`
|
|
||||||
* in a fixed box left of the viewport — `submitToRun` needs a mounted,
|
|
||||||
* attached pane, exactly like the workspace's own Chat view), so nothing new
|
|
||||||
* reaches the server. A menu the CLI asks with (a permission or trust prompt)
|
|
||||||
* is read off that pane and answered with `PaneMenuCard`. The chat is kept in
|
|
||||||
* `localStorage` (`agenthub.pet.chat`) while its pane lives, so closing and
|
|
||||||
* reopening the window continues it; **New chat** STOPS that session (kills
|
|
||||||
* its pane and archives it, asking first only mid-reply), **Open in workspace**
|
|
||||||
* opens its pane there. Opening never resumes an ended session: once the pane
|
|
||||||
* is gone, the next message starts a new one.
|
|
||||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { useCallback, useEffect, useRef, useState, type FormEvent, type ReactNode } from "react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { ArrowUp, ExternalLink, Loader2, SquarePen, Wrench, X } from "lucide-react";
|
|
||||||
import { api } from "../../lib/api";
|
|
||||||
import type { TranscriptMessage } from "../../lib/types";
|
|
||||||
import { readDefaultMode, readDefaultTool } from "../../lib/sessionDefaults";
|
|
||||||
import {
|
|
||||||
answerRunMenu,
|
|
||||||
chooseRunOption,
|
|
||||||
interruptRun,
|
|
||||||
readRunMenu,
|
|
||||||
stepRunMenu,
|
|
||||||
submitToRun,
|
|
||||||
type PaneMenu,
|
|
||||||
} from "../../lib/panePaste";
|
|
||||||
import { isSubmitEnter } from "../../lib/keys";
|
|
||||||
import { confirmDialog } from "../../lib/confirm";
|
|
||||||
import { toast } from "../../lib/toast";
|
|
||||||
import { startInFreeTabDetailed } from "../lanes/startSession";
|
|
||||||
import { TerminalView } from "../run/TerminalView";
|
|
||||||
import { PaneMenuCard } from "../run/PaneChat";
|
|
||||||
import { MarkdownContent } from "../conversation/MarkdownContent";
|
|
||||||
|
|
||||||
/** Tools whose session the chat can read back (a transcript AgentHub parses). */
|
|
||||||
export const PET_CHAT_TOOLS = new Set(["claude", "codex"]);
|
|
||||||
const CHAT_KEY = "agenthub.pet.chat";
|
|
||||||
const POLL_MS = 1500;
|
|
||||||
const MENU_POLL_MS = 700;
|
|
||||||
// A follow-up waits this long for the off-screen pane to attach.
|
|
||||||
const SEND_TRIES = 20;
|
|
||||||
const SEND_RETRY_MS = 250;
|
|
||||||
|
|
||||||
/** The session the chat is talking to. */
|
|
||||||
export interface PetChatRef {
|
|
||||||
laneId: number;
|
|
||||||
runId: string;
|
|
||||||
tool: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function readPetChat(): PetChatRef | null {
|
|
||||||
try {
|
|
||||||
const v = JSON.parse(localStorage.getItem(CHAT_KEY) ?? "null") as Partial<PetChatRef> | null;
|
|
||||||
if (
|
|
||||||
v &&
|
|
||||||
typeof v.laneId === "number" &&
|
|
||||||
typeof v.runId === "string" &&
|
|
||||||
typeof v.tool === "string"
|
|
||||||
)
|
|
||||||
return { laneId: v.laneId, runId: v.runId, tool: v.tool };
|
|
||||||
} catch {
|
|
||||||
/* unreadable: no chat */
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function writePetChat(ref: PetChatRef | null) {
|
|
||||||
try {
|
|
||||||
if (ref) localStorage.setItem(CHAT_KEY, JSON.stringify(ref));
|
|
||||||
else localStorage.removeItem(CHAT_KEY);
|
|
||||||
} catch {
|
|
||||||
/* best-effort */
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The tool a new chat starts: the user's default, when the chat can show it. */
|
|
||||||
export function petChatTool(): string | null {
|
|
||||||
const tool = readDefaultTool() ?? "claude";
|
|
||||||
return PET_CHAT_TOOLS.has(tool) ? tool : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
type Line =
|
|
||||||
| { key: string; role: "user" | "assistant"; text: string }
|
|
||||||
| { key: string; role: "tools"; names: string[] };
|
|
||||||
|
|
||||||
/** A human line's text: Claude Code's own injected lines (`<command-…>`,
|
|
||||||
* `<system-reminder>`…) are not something the user said. */
|
|
||||||
const humanText = (m: TranscriptMessage) =>
|
|
||||||
m.content
|
|
||||||
.filter((c) => c.type === "text" && c.text)
|
|
||||||
.map((c) => c.text!.trim())
|
|
||||||
.filter((t) => t && !t.startsWith("<"))
|
|
||||||
.join("\n\n");
|
|
||||||
|
|
||||||
/** The transcript as chat lines: the user's words, the assistant's prose, and
|
|
||||||
* each run of tool calls between them folded into one chip. */
|
|
||||||
export function chatLines(messages: TranscriptMessage[]): Line[] {
|
|
||||||
const out: Line[] = [];
|
|
||||||
messages.forEach((m, i) => {
|
|
||||||
const sender = m.sender ?? m.type;
|
|
||||||
if (sender === "user") {
|
|
||||||
const text = humanText(m);
|
|
||||||
if (text) out.push({ key: `u${i}`, role: "user", text });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (m.type !== "assistant") return;
|
|
||||||
m.content.forEach((c, j) => {
|
|
||||||
if (c.type === "text" && c.text?.trim()) {
|
|
||||||
out.push({ key: `a${i}-${j}`, role: "assistant", text: c.text.trim() });
|
|
||||||
} else if (c.type === "tool_use" && c.name) {
|
|
||||||
const last = out[out.length - 1];
|
|
||||||
if (last?.role === "tools") last.names.push(c.name);
|
|
||||||
else out.push({ key: `t${i}-${j}`, role: "tools", names: [c.name] });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Still working: the last line is the user's, or an assistant turn not ended. */
|
|
||||||
function isBusy(messages: TranscriptMessage[]): boolean {
|
|
||||||
for (let i = messages.length - 1; i >= 0; i--) {
|
|
||||||
const m = messages[i]!;
|
|
||||||
const sender = m.sender ?? m.type;
|
|
||||||
if (m.type === "assistant") return m.stop_reason !== undefined && m.stop_reason !== "end_turn";
|
|
||||||
if (sender === "user" && humanText(m)) return true;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** "Read ×2 · Bash" */
|
|
||||||
function toolSummary(names: string[]): string {
|
|
||||||
const counts = new Map<string, number>();
|
|
||||||
names.forEach((n) => counts.set(n, (counts.get(n) ?? 0) + 1));
|
|
||||||
return [...counts].map(([n, c]) => (c > 1 ? `${n} ×${c}` : n)).join(" · ");
|
|
||||||
}
|
|
||||||
|
|
||||||
/** What the container made of a message: an answer the pet gives itself, or
|
|
||||||
* `handled` (it took the message elsewhere); null sends it to the AI. */
|
|
||||||
export type PetLocalReply = { answer: string } | { handled: true } | null;
|
|
||||||
|
|
||||||
interface PetChatProps {
|
|
||||||
/** The pet's shown name, for the header and greeting. */
|
|
||||||
name: string;
|
|
||||||
/** Shown in place of the plain name (the rename button). */
|
|
||||||
title?: ReactNode;
|
|
||||||
/** Extra header buttons, before Open in workspace (the pat heart). */
|
|
||||||
headerActions?: ReactNode;
|
|
||||||
/** Under the header: what the pet knows (status, sessions that need you). */
|
|
||||||
info?: ReactNode;
|
|
||||||
/** Asked first while no chat runs. */
|
|
||||||
onLocal?: (text: string) => PetLocalReply;
|
|
||||||
onClose: () => void;
|
|
||||||
/** Opens the chat's pane in its workspace. */
|
|
||||||
onOpenWorkspace: (laneId: number, runId: string) => void;
|
|
||||||
/** The thinking mood while a reply is being written. */
|
|
||||||
onBusy?: (busy: boolean) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function PetChat({
|
|
||||||
name,
|
|
||||||
title,
|
|
||||||
headerActions,
|
|
||||||
info,
|
|
||||||
onLocal,
|
|
||||||
onClose,
|
|
||||||
onOpenWorkspace,
|
|
||||||
onBusy,
|
|
||||||
}: PetChatProps) {
|
|
||||||
const { t } = useTranslation("settings");
|
|
||||||
const [chat, setChat] = useState<PetChatRef | null>(readPetChat);
|
|
||||||
const [sessionId, setSessionId] = useState<string | null>(null);
|
|
||||||
const [messages, setMessages] = useState<TranscriptMessage[]>([]);
|
|
||||||
// Sent but not in the transcript yet.
|
|
||||||
const [pending, setPending] = useState<string | null>(null);
|
|
||||||
const [starting, setStarting] = useState(false);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
const [draft, setDraft] = useState("");
|
|
||||||
const [menu, setMenu] = useState<PaneMenu | null>(null);
|
|
||||||
// The pet's own answers, given before any chat ran.
|
|
||||||
const [local, setLocal] = useState<Line[]>([]);
|
|
||||||
const listRef = useRef<HTMLDivElement>(null);
|
|
||||||
|
|
||||||
const remember = (ref: PetChatRef | null) => {
|
|
||||||
writePetChat(ref);
|
|
||||||
setChat(ref);
|
|
||||||
setSessionId(null);
|
|
||||||
setMessages([]);
|
|
||||||
setMenu(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
// A remembered chat whose pane is gone is over: forget it, the next message starts anew.
|
|
||||||
useEffect(() => {
|
|
||||||
if (!chat) return;
|
|
||||||
let live = true;
|
|
||||||
const check = () =>
|
|
||||||
api.run
|
|
||||||
.get(chat.runId)
|
|
||||||
.then((h) => {
|
|
||||||
if (!live) return;
|
|
||||||
if (h.status !== "running") remember(null);
|
|
||||||
else if (h.sessionId) setSessionId(h.sessionId);
|
|
||||||
})
|
|
||||||
.catch(() => live && remember(null));
|
|
||||||
void check();
|
|
||||||
// Codex learns its session only after a while; Claude's is known at start.
|
|
||||||
const timer = window.setInterval(() => {
|
|
||||||
if (!sessionId) void check();
|
|
||||||
}, POLL_MS);
|
|
||||||
return () => {
|
|
||||||
live = false;
|
|
||||||
window.clearInterval(timer);
|
|
||||||
};
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [chat?.runId, sessionId]);
|
|
||||||
|
|
||||||
// The conversation, polled while the window is open.
|
|
||||||
useEffect(() => {
|
|
||||||
if (!sessionId) return;
|
|
||||||
let live = true;
|
|
||||||
const load = () =>
|
|
||||||
api.sessions
|
|
||||||
.transcript(sessionId, { limit: 60 })
|
|
||||||
.then((r) => {
|
|
||||||
if (!live) return;
|
|
||||||
setMessages((prev) =>
|
|
||||||
prev.length === r.messages.length &&
|
|
||||||
JSON.stringify(prev[prev.length - 1]) ===
|
|
||||||
JSON.stringify(r.messages[r.messages.length - 1])
|
|
||||||
? prev
|
|
||||||
: r.messages
|
|
||||||
);
|
|
||||||
})
|
|
||||||
.catch(() => undefined); // not started yet: the next poll asks again
|
|
||||||
void load();
|
|
||||||
const timer = window.setInterval(load, POLL_MS);
|
|
||||||
return () => {
|
|
||||||
live = false;
|
|
||||||
window.clearInterval(timer);
|
|
||||||
};
|
|
||||||
}, [sessionId]);
|
|
||||||
|
|
||||||
// A menu the CLI shows in the pane (permission, trust, a question).
|
|
||||||
useEffect(() => {
|
|
||||||
if (!chat) return;
|
|
||||||
const read = () =>
|
|
||||||
setMenu((prev) => {
|
|
||||||
const next = readRunMenu(chat.runId);
|
|
||||||
return JSON.stringify(next) === JSON.stringify(prev) ? prev : next;
|
|
||||||
});
|
|
||||||
const timer = window.setInterval(read, MENU_POLL_MS);
|
|
||||||
return () => window.clearInterval(timer);
|
|
||||||
}, [chat]);
|
|
||||||
|
|
||||||
const lines = [...local, ...chatLines(messages)];
|
|
||||||
// The pending line shows until the transcript has it.
|
|
||||||
useEffect(() => {
|
|
||||||
if (pending && lines.some((l) => l.role === "user" && l.text === pending.trim()))
|
|
||||||
setPending(null);
|
|
||||||
}, [lines, pending]);
|
|
||||||
const busy = starting || !!pending || isBusy(messages);
|
|
||||||
useEffect(() => onBusy?.(busy), [busy, onBusy]);
|
|
||||||
useEffect(() => () => onBusy?.(false), [onBusy]);
|
|
||||||
|
|
||||||
// Keep the newest in sight.
|
|
||||||
useEffect(() => {
|
|
||||||
const el = listRef.current;
|
|
||||||
if (el) el.scrollTop = el.scrollHeight;
|
|
||||||
}, [lines.length, pending, busy, menu]);
|
|
||||||
|
|
||||||
const send = useCallback(
|
|
||||||
async (text: string) => {
|
|
||||||
const prompt = text.trim();
|
|
||||||
if (!prompt || starting) return;
|
|
||||||
setError(null);
|
|
||||||
if (!chat && onLocal) {
|
|
||||||
const reply = onLocal(prompt);
|
|
||||||
if (reply && "handled" in reply) return;
|
|
||||||
if (reply) {
|
|
||||||
const n = Date.now();
|
|
||||||
setLocal((prev) => [
|
|
||||||
...prev,
|
|
||||||
{ key: `lu${n}`, role: "user", text: prompt },
|
|
||||||
{ key: `la${n}`, role: "assistant", text: reply.answer },
|
|
||||||
]);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
setPending(prompt);
|
|
||||||
if (chat) {
|
|
||||||
// Typed into the attached pane; it may still be attaching.
|
|
||||||
for (let i = 0; i < SEND_TRIES; i++) {
|
|
||||||
if (submitToRun(chat.runId, prompt)) return;
|
|
||||||
await new Promise((r) => setTimeout(r, SEND_RETRY_MS));
|
|
||||||
}
|
|
||||||
setPending(null);
|
|
||||||
setDraft(prompt);
|
|
||||||
setError(t("sagi.chat.notSent"));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const tool = petChatTool() ?? "claude";
|
|
||||||
setStarting(true);
|
|
||||||
try {
|
|
||||||
const { lane } = await api.lanes.ensure({ title: "Sagi" });
|
|
||||||
const r = await startInFreeTabDetailed(lane.id, {
|
|
||||||
prompt,
|
|
||||||
...(tool !== lane.tool ? { tool } : {}),
|
|
||||||
...(tool === "claude" ? { permissionMode: readDefaultMode({ safe: true }) } : {}),
|
|
||||||
});
|
|
||||||
remember({ laneId: r.laneId, runId: r.runId, tool });
|
|
||||||
} catch (err) {
|
|
||||||
setPending(null);
|
|
||||||
setDraft(prompt);
|
|
||||||
setError(
|
|
||||||
t("sagi.startFailed", { error: err instanceof Error ? err.message : String(err) })
|
|
||||||
);
|
|
||||||
} finally {
|
|
||||||
setStarting(false);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
[chat, starting, t, onLocal]
|
|
||||||
);
|
|
||||||
|
|
||||||
// New chat: the old one's session is stopped, not left running unseen —
|
|
||||||
// asking first only while it is still writing a reply.
|
|
||||||
const newChat = async () => {
|
|
||||||
if (chat) {
|
|
||||||
if (
|
|
||||||
busy &&
|
|
||||||
!(await confirmDialog({
|
|
||||||
title: t("sagi.chat.endTitle"),
|
|
||||||
message: t("sagi.chat.endBody"),
|
|
||||||
confirmLabel: t("sagi.chat.endConfirm"),
|
|
||||||
destructive: true,
|
|
||||||
}))
|
|
||||||
)
|
|
||||||
return;
|
|
||||||
try {
|
|
||||||
await api.run.kill(chat.runId, { archive: true });
|
|
||||||
} catch (err) {
|
|
||||||
toast.error(err);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
remember(null);
|
|
||||||
setLocal([]);
|
|
||||||
setPending(null);
|
|
||||||
setError(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
const submit = (e?: FormEvent) => {
|
|
||||||
e?.preventDefault();
|
|
||||||
if (!draft.trim()) return;
|
|
||||||
const text = draft;
|
|
||||||
setDraft("");
|
|
||||||
void send(text);
|
|
||||||
};
|
|
||||||
|
|
||||||
const toolLabel = chat?.tool ?? petChatTool() ?? "claude";
|
|
||||||
const empty = lines.length === 0 && !pending;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className="pet-chat flex h-[30rem] max-h-[calc(100vh-6rem)] w-80 flex-col overflow-hidden rounded-3xl border border-border-light shadow-2xl animate-slide-up"
|
|
||||||
role="dialog"
|
|
||||||
aria-label={t("sagi.chat.label", { name })}
|
|
||||||
>
|
|
||||||
{/* header */}
|
|
||||||
<div className="pet-chat-head flex items-center gap-1 px-2 py-2">
|
|
||||||
<span className="pl-1.5 text-base leading-none" aria-hidden>
|
|
||||||
🐾
|
|
||||||
</span>
|
|
||||||
<div className="min-w-0 flex-1 px-1">
|
|
||||||
{title ?? <div className="truncate text-sm font-semibold text-fg-primary">{name}</div>}
|
|
||||||
<div className="flex items-center gap-1 text-[10px] uppercase tracking-wider text-fg-muted">
|
|
||||||
<span
|
|
||||||
className={`inline-block h-1.5 w-1.5 rounded-full ${
|
|
||||||
busy
|
|
||||||
? "animate-pulse bg-status-warning"
|
|
||||||
: chat
|
|
||||||
? "bg-status-success"
|
|
||||||
: "bg-fg-muted"
|
|
||||||
}`}
|
|
||||||
aria-hidden
|
|
||||||
/>
|
|
||||||
{busy ? t("sagi.chat.typing") : toolLabel}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{headerActions}
|
|
||||||
{chat && (
|
|
||||||
<IconButton
|
|
||||||
label={t("sagi.chat.openWorkspace")}
|
|
||||||
onClick={() => onOpenWorkspace(chat.laneId, chat.runId)}
|
|
||||||
>
|
|
||||||
<ExternalLink size={15} />
|
|
||||||
</IconButton>
|
|
||||||
)}
|
|
||||||
<IconButton
|
|
||||||
label={t("sagi.chat.new")}
|
|
||||||
onClick={() => void newChat()}
|
|
||||||
disabled={(!chat && !local.length) || starting}
|
|
||||||
>
|
|
||||||
<SquarePen size={15} />
|
|
||||||
</IconButton>
|
|
||||||
<IconButton label={t("sagi.chat.close", { name })} onClick={onClose}>
|
|
||||||
<X size={16} />
|
|
||||||
</IconButton>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{info}
|
|
||||||
|
|
||||||
{/* conversation */}
|
|
||||||
<div ref={listRef} className="flex-1 space-y-2 overflow-y-auto px-3 py-3">
|
|
||||||
{empty && (
|
|
||||||
<div className="flex h-full flex-col items-center justify-center gap-2 px-4 text-center">
|
|
||||||
<span className="text-3xl" aria-hidden>
|
|
||||||
🐾
|
|
||||||
</span>
|
|
||||||
<p className="text-sm font-medium text-fg-primary">
|
|
||||||
{t("sagi.chat.emptyTitle", { name })}
|
|
||||||
</p>
|
|
||||||
<p className="text-xs text-fg-muted">{t("sagi.chat.emptyHint", { tool: toolLabel })}</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{lines.map((l) =>
|
|
||||||
l.role === "tools" ? (
|
|
||||||
<div key={l.key} className="flex justify-start">
|
|
||||||
<span className="pet-chat-tools inline-flex max-w-[85%] items-center gap-1 truncate rounded-full px-2 py-0.5 text-[10px] text-fg-muted">
|
|
||||||
<Wrench size={10} className="shrink-0" aria-hidden />
|
|
||||||
<span className="truncate">{toolSummary(l.names)}</span>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
) : l.role === "user" ? (
|
|
||||||
<div key={l.key} className="flex justify-end">
|
|
||||||
<div className="max-w-[85%] whitespace-pre-wrap break-words rounded-2xl rounded-br-md bg-accent-solid px-3 py-2 text-sm text-white shadow-sm">
|
|
||||||
{l.text}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div key={l.key} className="flex justify-start">
|
|
||||||
<div className="pet-chat-reply max-w-[90%] break-words rounded-2xl rounded-bl-md px-3 py-2 text-sm text-fg-primary shadow-sm">
|
|
||||||
<MarkdownContent text={l.text} dense prose />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
)}
|
|
||||||
{pending && (
|
|
||||||
<div className="flex justify-end">
|
|
||||||
<div className="max-w-[85%] whitespace-pre-wrap break-words rounded-2xl rounded-br-md bg-accent-solid px-3 py-2 text-sm text-white opacity-70 shadow-sm">
|
|
||||||
{pending}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{busy && !menu && (
|
|
||||||
<div className="flex justify-start" aria-label={t("sagi.chat.typing")}>
|
|
||||||
<div className="pet-chat-reply flex gap-1 rounded-2xl rounded-bl-md px-3 py-3 shadow-sm">
|
|
||||||
<span className="pet-chat-dot" />
|
|
||||||
<span className="pet-chat-dot" />
|
|
||||||
<span className="pet-chat-dot" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{starting && (
|
|
||||||
<p className="flex items-center justify-center gap-1.5 text-[11px] text-fg-muted">
|
|
||||||
<Loader2 size={12} className="animate-spin" aria-hidden />
|
|
||||||
{t("sagi.chat.starting", { tool: toolLabel })}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
{chat && !sessionId && !starting && (
|
|
||||||
<p className="text-center text-[11px] text-fg-muted">{t("sagi.chat.notStarted")}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{error && (
|
|
||||||
<p className="mx-3 mb-1 rounded-lg bg-status-danger/10 px-2 py-1 text-[11px] text-status-danger">
|
|
||||||
{error}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* prompt, or the question the CLI asks */}
|
|
||||||
<div className="pet-chat-foot px-2.5 py-2.5">
|
|
||||||
{menu && chat ? (
|
|
||||||
<div className="max-h-56 overflow-y-auto">
|
|
||||||
<PaneMenuCard
|
|
||||||
menu={menu}
|
|
||||||
onChoose={(i) => {
|
|
||||||
if (chooseRunOption(chat.runId, menu, i)) setMenu(null);
|
|
||||||
else setError(t("sagi.chat.notSent"));
|
|
||||||
}}
|
|
||||||
onAnswer={(text) => {
|
|
||||||
if (answerRunMenu(chat.runId, menu, text)) setMenu(null);
|
|
||||||
else setError(t("sagi.chat.notSent"));
|
|
||||||
}}
|
|
||||||
onStep={(dir) => {
|
|
||||||
if (!stepRunMenu(chat.runId, dir)) setError(t("sagi.chat.notSent"));
|
|
||||||
}}
|
|
||||||
onCancel={() => {
|
|
||||||
if (interruptRun(chat.runId)) setMenu(null);
|
|
||||||
else setError(t("sagi.chat.notSent"));
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<form onSubmit={submit} className="flex items-end gap-1.5">
|
|
||||||
<textarea
|
|
||||||
rows={1}
|
|
||||||
autoFocus
|
|
||||||
className="max-h-28 min-h-[2.25rem] flex-1 resize-none rounded-2xl border border-border bg-surface-1 px-3.5 py-2 text-sm text-fg-primary placeholder-fg-muted focus:border-accent focus:outline-none focus:ring-1 focus:ring-accent/30"
|
|
||||||
placeholder={t("sagi.chat.placeholder", { name })}
|
|
||||||
aria-label={t("sagi.chat.placeholder", { name })}
|
|
||||||
value={draft}
|
|
||||||
onChange={(e) => setDraft(e.target.value)}
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (isSubmitEnter(e) && !e.shiftKey) {
|
|
||||||
e.preventDefault();
|
|
||||||
submit();
|
|
||||||
} else if (e.key === "Escape") {
|
|
||||||
e.stopPropagation();
|
|
||||||
e.nativeEvent.stopImmediatePropagation();
|
|
||||||
onClose();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
disabled={!draft.trim() || starting}
|
|
||||||
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-accent-solid text-white shadow-sm transition-opacity hover:opacity-90 disabled:opacity-40"
|
|
||||||
aria-label={t("sagi.panel.send")}
|
|
||||||
>
|
|
||||||
<ArrowUp size={16} />
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* The chat's pane, attached off-screen: the only way a follow-up or a
|
|
||||||
menu answer reaches it. A real size, so tmux does not shrink the pane. */}
|
|
||||||
{chat && (
|
|
||||||
<div className="pet-chat-term" aria-hidden>
|
|
||||||
<TerminalView
|
|
||||||
runId={chat.runId}
|
|
||||||
wsBaseUrl={window.location.origin.replace(/^http/, "ws")}
|
|
||||||
autoFocus={false}
|
|
||||||
// The pane ended: the conversation stays on screen, the next
|
|
||||||
// message starts a new session.
|
|
||||||
onExit={() => {
|
|
||||||
writePetChat(null);
|
|
||||||
setChat(null);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function IconButton({
|
|
||||||
label,
|
|
||||||
onClick,
|
|
||||||
disabled,
|
|
||||||
children,
|
|
||||||
}: {
|
|
||||||
label: string;
|
|
||||||
onClick: () => void;
|
|
||||||
disabled?: boolean;
|
|
||||||
children: React.ReactNode;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="rounded-full p-1.5 text-fg-muted transition-colors hover:bg-surface-4 hover:text-fg-primary disabled:cursor-not-allowed disabled:opacity-40"
|
|
||||||
onClick={onClick}
|
|
||||||
disabled={disabled}
|
|
||||||
aria-label={label}
|
|
||||||
title={label}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
/**
|
||||||
|
* @file PetPanel.tsx
|
||||||
|
* @description The pet's window — clicking the pet opens it: its name (which
|
||||||
|
* the user can rename), a pat button, and what the pet knows (`info`: live /
|
||||||
|
* waiting / errored, the sessions that need you, Mute, Clear alerts —
|
||||||
|
* `SagiInfo.tsx`). The pet no longer chats: it started a full AI session per
|
||||||
|
* question, a heavy process on a shared server, so the chat was removed
|
||||||
|
* (v2.8.68); this window is where the pet's own care comes next.
|
||||||
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { X } from "lucide-react";
|
||||||
|
|
||||||
|
export function PetPanel({
|
||||||
|
name,
|
||||||
|
title,
|
||||||
|
headerActions,
|
||||||
|
info,
|
||||||
|
onClose,
|
||||||
|
}: {
|
||||||
|
name: string;
|
||||||
|
/** The name line (a rename button); plain text without it. */
|
||||||
|
title?: ReactNode;
|
||||||
|
/** Buttons before Close (the pat). */
|
||||||
|
headerActions?: ReactNode;
|
||||||
|
/** What the pet knows (`SagiInfo`). */
|
||||||
|
info: ReactNode;
|
||||||
|
onClose: () => void;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation("settings");
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="pet-chat flex max-h-[calc(100vh-6rem)] w-[22rem] max-w-[calc(100vw-2rem)] flex-col overflow-hidden rounded-3xl border border-border-light shadow-2xl animate-slide-up"
|
||||||
|
role="dialog"
|
||||||
|
aria-label={t("sagi.panel.label", { name })}
|
||||||
|
>
|
||||||
|
<div className="pet-chat-head flex items-center gap-1 px-2 py-2">
|
||||||
|
<span className="pl-1.5 text-base leading-none" aria-hidden>
|
||||||
|
🐾
|
||||||
|
</span>
|
||||||
|
<div className="min-w-0 flex-1 px-1">
|
||||||
|
{title ?? <div className="truncate text-sm font-semibold text-fg-primary">{name}</div>}
|
||||||
|
</div>
|
||||||
|
{headerActions}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="rounded-full p-1.5 text-fg-muted transition-colors hover:bg-surface-4 hover:text-fg-primary"
|
||||||
|
onClick={onClose}
|
||||||
|
aria-label={t("sagi.panel.close", { name })}
|
||||||
|
title={t("sagi.panel.close", { name })}
|
||||||
|
>
|
||||||
|
<X size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/* Vertical only: an auto overflow-y makes x scrollable too, and the
|
||||||
|
status chips' row then showed a horizontal scrollbar. */}
|
||||||
|
<div className="min-w-0 overflow-y-auto overflow-x-hidden">{info}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -5,9 +5,8 @@
|
|||||||
* enabled; `Sagi` gates on the same preference itself, and the body (brain,
|
* enabled; `Sagi` gates on the same preference itself, and the body (brain,
|
||||||
* tick, WS reducer) runs only while it is on. Persists across routes and
|
* tick, WS reducer) runs only while it is on. Persists across routes and
|
||||||
* shares the single WebSocket.
|
* shares the single WebSocket.
|
||||||
* Clicking the pet opens its chat window (`PetChat`), which is its whole UI:
|
* Clicking the pet opens its window (`PetPanel`): its name, a pat, and the
|
||||||
* the status strip (`SagiInfo`) sits on top, a question the pet can answer
|
* status strip (`SagiInfo`). The pet no longer chats (v2.8.68).
|
||||||
* itself is answered there, anything else goes to the AI.
|
|
||||||
* Owns the open/closed chat state, the ⌘B / Esc shortcuts, reduced-motion
|
* Owns the open/closed chat state, the ⌘B / Esc shortcuts, reduced-motion
|
||||||
* detection, and route navigation. Reactive personality + status/Ask come
|
* detection, and route navigation. Reactive personality + status/Ask come
|
||||||
* from useSagiBrain; the avatar is draggable (AssistiveTouch-style) via
|
* from useSagiBrain; the avatar is draggable (AssistiveTouch-style) via
|
||||||
@@ -15,10 +14,7 @@
|
|||||||
* bubble/panel render in a self-clamping flyout so
|
* bubble/panel render in a self-clamping flyout so
|
||||||
* they never spill off any screen edge regardless of where the mascot is docked.
|
* they never spill off any screen edge regardless of where the mascot is docked.
|
||||||
*
|
*
|
||||||
* The "do the job" path: an unmatched question is the chat's — it starts a
|
* ⌘B/Ctrl+B is taken only while the mascot is shown and focus is not
|
||||||
* session of the default AI tool in the "Sagi" workspace; a default tool the
|
|
||||||
* chat cannot read back starts there and its session view opens
|
|
||||||
* (`/workspace?lane=<id>&run=<runId>`) — no new LLM backend. ⌘B/Ctrl+B is taken only while the mascot is shown and focus is not
|
|
||||||
* in an editable field (a terminal's Ctrl+B is tmux's prefix).
|
* in an editable field (a terminal's Ctrl+B is tmux's prefix).
|
||||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
*/
|
*/
|
||||||
@@ -91,15 +87,12 @@ import { PetNameButton, SagiInfo } from "./SagiInfo";
|
|||||||
import { useSagiBrain } from "./useSagiBrain";
|
import { useSagiBrain } from "./useSagiBrain";
|
||||||
import { useSagiPosition } from "./useSagiPosition";
|
import { useSagiPosition } from "./useSagiPosition";
|
||||||
import { useSagiEnabled } from "./SagiMount";
|
import { useSagiEnabled } from "./SagiMount";
|
||||||
import { matchIntent } from "./intents";
|
|
||||||
import { petName, sagiPrefs } from "./prefs";
|
import { petName, sagiPrefs } from "./prefs";
|
||||||
import type { SagiSubject } from "./brain";
|
import type { SagiSubject } from "./brain";
|
||||||
import { api } from "../../lib/api";
|
import { api } from "../../lib/api";
|
||||||
import { readDefaultTool } from "../../lib/sessionDefaults";
|
|
||||||
import { startInFreeTabDetailed } from "../lanes/startSession";
|
|
||||||
import { workspaceHref } from "../SessionRowMenu";
|
import { workspaceHref } from "../SessionRowMenu";
|
||||||
import { Heart } from "lucide-react";
|
import { Heart } from "lucide-react";
|
||||||
import { PetChat, petChatTool, type PetLocalReply } from "./PetChat";
|
import { PetPanel } from "./PetPanel";
|
||||||
|
|
||||||
// Stroking: this many left/right turns of the pointer over the pet, within
|
// Stroking: this many left/right turns of the pointer over the pet, within
|
||||||
// STROKE_MS and with no button held, is a pat; then a pause before the next.
|
// STROKE_MS and with no button held, is a pat; then a pause before the next.
|
||||||
@@ -229,7 +222,7 @@ function SagiBody() {
|
|||||||
// The name it goes by: the user's nickname, the pet's own, else Sagi.
|
// The name it goes by: the user's nickname, the pet's own, else Sagi.
|
||||||
const [name, setName] = useState(() => petName(sagiPrefs.getPet()));
|
const [name, setName] = useState(() => petName(sagiPrefs.getPet()));
|
||||||
const [size, setSize] = useState(() => sagiPrefs.getSize());
|
const [size, setSize] = useState(() => sagiPrefs.getSize());
|
||||||
// The chat window (PetChat.tsx): the pet's whole UI.
|
// The pet's window (PetPanel.tsx).
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
// The right-click pet menu (`PetMenu.tsx`).
|
// The right-click pet menu (`PetMenu.tsx`).
|
||||||
const [menu, setMenu] = useState(false);
|
const [menu, setMenu] = useState(false);
|
||||||
@@ -340,43 +333,6 @@ function SagiBody() {
|
|||||||
[brain, navigate]
|
[brain, navigate]
|
||||||
);
|
);
|
||||||
|
|
||||||
// Asked by the chat before any session runs.
|
|
||||||
const onLocal = useCallback(
|
|
||||||
(query: string): PetLocalReply => {
|
|
||||||
const result = matchIntent(query, brain.status, { name, attention: brain.attention });
|
|
||||||
if (result.kind === "answer") {
|
|
||||||
if (result.moment) brain.playMoment(result.moment);
|
|
||||||
return { answer: result.text };
|
|
||||||
}
|
|
||||||
// A question the pet cannot answer: the chat starts a session of the
|
|
||||||
// default AI tool and shows it as chat.
|
|
||||||
if (petChatTool()) return null;
|
|
||||||
// A default tool the chat cannot read back (not Claude/Codex): start it
|
|
||||||
// in the "Sagi" workspace and open its pane there, as before.
|
|
||||||
const prompt = result.prompt;
|
|
||||||
brain.setThinking(true);
|
|
||||||
(async () => {
|
|
||||||
try {
|
|
||||||
const { lane } = await api.lanes.ensure({ title: "Sagi" });
|
|
||||||
const tool = readDefaultTool();
|
|
||||||
const r = await startInFreeTabDetailed(lane.id, {
|
|
||||||
prompt,
|
|
||||||
...(tool && tool !== lane.tool ? { tool } : {}),
|
|
||||||
});
|
|
||||||
navigate(`/workspace?lane=${r.laneId}&run=${encodeURIComponent(r.runId)}`);
|
|
||||||
setOpen(false);
|
|
||||||
} catch (err) {
|
|
||||||
const msg = err instanceof Error ? err.message : String(err);
|
|
||||||
brain.setBubble(t("sagi.startFailed", { error: msg }));
|
|
||||||
} finally {
|
|
||||||
brain.setThinking(false);
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
return { handled: true };
|
|
||||||
},
|
|
||||||
[brain, navigate, t, name]
|
|
||||||
);
|
|
||||||
|
|
||||||
// Stroke the pet (move the pointer back and forth over it) to pat it.
|
// Stroke the pet (move the pointer back and forth over it) to pat it.
|
||||||
const stroke = useRef({ dir: 0, x: 0, turns: [] as number[], until: 0 });
|
const stroke = useRef({ dir: 0, x: 0, turns: [] as number[], until: 0 });
|
||||||
const onStroke = (e: React.PointerEvent) => {
|
const onStroke = (e: React.PointerEvent) => {
|
||||||
@@ -398,8 +354,6 @@ function SagiBody() {
|
|||||||
s.dir = dir;
|
s.dir = dir;
|
||||||
};
|
};
|
||||||
|
|
||||||
const onBusy = useCallback((b: boolean) => brain.setThinking(b), [brain.setThinking]);
|
|
||||||
|
|
||||||
const anchor: Anchor = {
|
const anchor: Anchor = {
|
||||||
left: place.left,
|
left: place.left,
|
||||||
top: place.top,
|
top: place.top,
|
||||||
@@ -412,11 +366,10 @@ function SagiBody() {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* Beside the pet, riding along with it mid-drag too (SagiFlyout follows
|
{/* Beside the pet, riding along with it mid-drag too (SagiFlyout follows
|
||||||
it 1:1). Closing unmounts it; its chat is remembered, so reopening
|
it 1:1). */}
|
||||||
re-attaches the same session. */}
|
|
||||||
{open && (
|
{open && (
|
||||||
<SagiFlyout anchor={anchor} beside>
|
<SagiFlyout anchor={anchor} beside>
|
||||||
<PetChat
|
<PetPanel
|
||||||
name={name}
|
name={name}
|
||||||
title={
|
title={
|
||||||
<PetNameButton
|
<PetNameButton
|
||||||
@@ -449,13 +402,7 @@ function SagiBody() {
|
|||||||
defaultOpen={brain.attention.length > 0}
|
defaultOpen={brain.attention.length > 0}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
onLocal={onLocal}
|
|
||||||
onClose={() => setOpen(false)}
|
onClose={() => setOpen(false)}
|
||||||
onOpenWorkspace={(laneId, runId) => {
|
|
||||||
navigate(`/workspace?lane=${laneId}&run=${encodeURIComponent(runId)}`);
|
|
||||||
setOpen(false);
|
|
||||||
}}
|
|
||||||
onBusy={onBusy}
|
|
||||||
/>
|
/>
|
||||||
</SagiFlyout>
|
</SagiFlyout>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
/**
|
/**
|
||||||
* @file SagiInfo.tsx
|
* @file SagiInfo.tsx
|
||||||
* @description What the pet knows, shown inside its chat window (`PetChat`):
|
* @description What the pet knows, shown inside its window (`PetPanel`):
|
||||||
* the name you rename by clicking it (`PetNameButton`), and a status strip
|
* the name you rename by clicking it (`PetNameButton`), and a status strip
|
||||||
* (`SagiInfo`) — live / waiting / errored counts and the connection state
|
* (`SagiInfo`) — live / waiting / errored counts and the connection state
|
||||||
* always in sight, and on expanding, the sessions that need the user, by
|
* always in sight, and on expanding, the sessions that need the user, by
|
||||||
@@ -118,8 +118,8 @@ export function SagiInfo({
|
|||||||
className="flex w-full items-center gap-1.5 rounded-lg text-left"
|
className="flex w-full items-center gap-1.5 rounded-lg text-left"
|
||||||
onClick={() => setOpen((v) => !v)}
|
onClick={() => setOpen((v) => !v)}
|
||||||
aria-expanded={open}
|
aria-expanded={open}
|
||||||
aria-label={t("sagi.chat.status")}
|
aria-label={t("sagi.panel.status")}
|
||||||
title={t("sagi.chat.status")}
|
title={t("sagi.panel.status")}
|
||||||
>
|
>
|
||||||
<StatChip
|
<StatChip
|
||||||
icon={Radio}
|
icon={Radio}
|
||||||
|
|||||||
@@ -1,46 +0,0 @@
|
|||||||
/**
|
|
||||||
* @file PetChat.test.ts
|
|
||||||
* @description Unit tests for the pet chat's transcript-to-bubbles folding (`chatLines`).
|
|
||||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
|
||||||
*/
|
|
||||||
import { describe, it, expect } from "vitest";
|
|
||||||
import { chatLines } from "../PetChat";
|
|
||||||
import type { TranscriptMessage } from "../../../lib/types";
|
|
||||||
|
|
||||||
const msg = (m: Partial<TranscriptMessage>): TranscriptMessage =>
|
|
||||||
({ type: "user", timestamp: null, content: [], ...m }) as TranscriptMessage;
|
|
||||||
|
|
||||||
describe("chatLines", () => {
|
|
||||||
it("keeps the user's words and the assistant's prose, folding tool calls into one chip", () => {
|
|
||||||
const lines = chatLines([
|
|
||||||
msg({ type: "user", sender: "user", content: [{ type: "text", text: "hi" }] }),
|
|
||||||
msg({
|
|
||||||
type: "assistant",
|
|
||||||
content: [
|
|
||||||
{ type: "thinking", text: "hmm" },
|
|
||||||
{ type: "tool_use", name: "Read" },
|
|
||||||
],
|
|
||||||
}),
|
|
||||||
msg({ type: "user", sender: "tool", content: [{ type: "tool_result", output: "x" }] }),
|
|
||||||
msg({
|
|
||||||
type: "assistant",
|
|
||||||
content: [
|
|
||||||
{ type: "tool_use", name: "Read" },
|
|
||||||
{ type: "text", text: "hello!" },
|
|
||||||
],
|
|
||||||
}),
|
|
||||||
]);
|
|
||||||
expect(lines.map((l) => (l.role === "tools" ? l.names : l.text))).toEqual([
|
|
||||||
"hi",
|
|
||||||
["Read", "Read"],
|
|
||||||
"hello!",
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("drops Claude Code's own injected lines", () => {
|
|
||||||
const lines = chatLines([
|
|
||||||
msg({ sender: "user", content: [{ type: "text", text: "<command-name>/clear</command-name>" }] }),
|
|
||||||
]);
|
|
||||||
expect(lines).toEqual([]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
/**
|
/**
|
||||||
* @file Sagi.test.tsx
|
* @file Sagi.test.tsx
|
||||||
* @description Render tests for the Sagi companion component — mounting, mood rendering, the ⌘B chat toggle (the chat window is the pet's whole UI: status strip, local answers, New chat stopping the old session), and accessibility attributes.
|
* @description Render tests for the Sagi companion component — mounting, mood rendering, the ⌘B window toggle (the pet's window: name, pat, status strip — the pet no longer chats), and accessibility attributes.
|
||||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@@ -18,7 +18,6 @@ import { useSagiPosition } from "../useSagiPosition";
|
|||||||
import { MemoryRouter } from "react-router-dom";
|
import { MemoryRouter } from "react-router-dom";
|
||||||
import { Sagi } from "../Sagi";
|
import { Sagi } from "../Sagi";
|
||||||
import { eventBus } from "../../../lib/eventBus";
|
import { eventBus } from "../../../lib/eventBus";
|
||||||
import { api } from "../../../lib/api";
|
|
||||||
import type { WSMessage, Session } from "../../../lib/types";
|
import type { WSMessage, Session } from "../../../lib/types";
|
||||||
|
|
||||||
function renderSagi() {
|
function renderSagi() {
|
||||||
@@ -55,16 +54,15 @@ describe("Sagi widget", () => {
|
|||||||
expect(screen.getByRole("img", { name: /sagi/i })).toBeInTheDocument();
|
expect(screen.getByRole("img", { name: /sagi/i })).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("opens the chat on click and answers a local status question there", () => {
|
it("opens its window on click — status, no chat box", () => {
|
||||||
renderSagi();
|
renderSagi();
|
||||||
fireEvent.click(screen.getByRole("button", { name: /open sagi companion/i }));
|
fireEvent.click(screen.getByRole("button", { name: /open sagi companion/i }));
|
||||||
const panel = screen.getByRole("dialog", { name: /chat with sagi/i });
|
const panel = screen.getByRole("dialog", { name: /pet sagi/i });
|
||||||
expect(panel).toBeInTheDocument();
|
expect(within(panel).getByText("live")).toBeInTheDocument();
|
||||||
|
expect(within(panel).queryByRole("textbox")).toBeNull();
|
||||||
const input = within(panel).getByLabelText(/ask sagi anything/i);
|
expect(within(panel).queryByRole("button", { name: /new chat/i })).toBeNull();
|
||||||
fireEvent.change(input, { target: { value: "status" } });
|
fireEvent.click(within(panel).getByRole("button", { name: /close sagi/i }));
|
||||||
fireEvent.submit(input.closest("form")!);
|
expect(screen.queryByRole("dialog")).toBeNull();
|
||||||
expect(within(panel).getByText(/live ·/i)).toBeInTheDocument();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("toggles open/closed with Cmd/Ctrl+B and closes with Esc", () => {
|
it("toggles open/closed with Cmd/Ctrl+B and closes with Esc", () => {
|
||||||
@@ -89,7 +87,7 @@ describe("Sagi widget", () => {
|
|||||||
expect(within(btn).getByText("1")).toBeInTheDocument();
|
expect(within(btn).getByText("1")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("names the errored session in its bubble and in the chat", () => {
|
it("names the errored session in its bubble and in its window", () => {
|
||||||
renderSagi();
|
renderSagi();
|
||||||
act(() => {
|
act(() => {
|
||||||
eventBus.publish({
|
eventBus.publish({
|
||||||
@@ -102,7 +100,7 @@ describe("Sagi widget", () => {
|
|||||||
expect(within(bubble).getByText("Fix login")).toBeInTheDocument();
|
expect(within(bubble).getByText("Fix login")).toBeInTheDocument();
|
||||||
expect(within(bubble).getByRole("button", { name: "Open" })).toBeInTheDocument();
|
expect(within(bubble).getByRole("button", { name: "Open" })).toBeInTheDocument();
|
||||||
fireEvent.click(screen.getByRole("button", { name: /open sagi companion/i }));
|
fireEvent.click(screen.getByRole("button", { name: /open sagi companion/i }));
|
||||||
const panel = screen.getByRole("dialog", { name: /chat with sagi/i });
|
const panel = screen.getByRole("dialog", { name: /pet sagi/i });
|
||||||
expect(within(panel).getByText("Needs you")).toBeInTheDocument();
|
expect(within(panel).getByText("Needs you")).toBeInTheDocument();
|
||||||
expect(within(panel).getByRole("button", { name: /Fix login/ })).toBeInTheDocument();
|
expect(within(panel).getByRole("button", { name: /Fix login/ })).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
@@ -122,54 +120,17 @@ describe("Sagi widget", () => {
|
|||||||
expect(JSON.parse(localStorage.getItem("agenthub.pet.dismissedErrors")!)).toEqual(["a"]);
|
expect(JSON.parse(localStorage.getItem("agenthub.pet.dismissedErrors")!)).toEqual(["a"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("renames the pet from the chat header", () => {
|
it("renames the pet from its window header", () => {
|
||||||
renderSagi();
|
renderSagi();
|
||||||
fireEvent.click(screen.getByRole("button", { name: /open sagi companion/i }));
|
fireEvent.click(screen.getByRole("button", { name: /open sagi companion/i }));
|
||||||
fireEvent.click(screen.getByRole("button", { name: /rename sagi/i }));
|
fireEvent.click(screen.getByRole("button", { name: /rename sagi/i }));
|
||||||
const input = screen.getByLabelText("Pet name");
|
const input = screen.getByLabelText("Pet name");
|
||||||
fireEvent.change(input, { target: { value: "Mochi" } });
|
fireEvent.change(input, { target: { value: "Mochi" } });
|
||||||
fireEvent.keyDown(input, { key: "Enter" });
|
fireEvent.keyDown(input, { key: "Enter" });
|
||||||
expect(screen.getByRole("dialog", { name: /chat with mochi/i })).toBeInTheDocument();
|
expect(screen.getByRole("dialog", { name: /pet mochi/i })).toBeInTheDocument();
|
||||||
expect(screen.getByLabelText(/ask mochi anything/i)).toBeInTheDocument();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("hands a question it cannot answer to the AI", () => {
|
it("a pat shows its reaction on the pet even while its window is open", () => {
|
||||||
localStorage.setItem("agenthub.defaultTool", "claude");
|
|
||||||
const ensure = vi.spyOn(api.lanes, "ensure").mockReturnValue(new Promise(() => {}));
|
|
||||||
renderSagi();
|
|
||||||
fireEvent.click(screen.getByRole("button", { name: /open sagi companion/i }));
|
|
||||||
const chat = screen.getByRole("dialog", { name: /chat with sagi/i });
|
|
||||||
const input = within(chat).getByLabelText(/ask sagi anything/i);
|
|
||||||
fireEvent.change(input, { target: { value: "write me a haiku" } });
|
|
||||||
fireEvent.submit(input.closest("form")!);
|
|
||||||
expect(within(chat).getByText("write me a haiku")).toBeInTheDocument();
|
|
||||||
expect(ensure).toHaveBeenCalledWith({ title: "Sagi" });
|
|
||||||
ensure.mockRestore();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("reopens the remembered chat, and New chat stops its session", async () => {
|
|
||||||
localStorage.setItem(
|
|
||||||
"agenthub.pet.chat",
|
|
||||||
JSON.stringify({ laneId: 7, runId: "agenthub-lane-7-1", tool: "claude" })
|
|
||||||
);
|
|
||||||
const get = vi
|
|
||||||
.spyOn(api.run, "get")
|
|
||||||
.mockReturnValue(new Promise(() => {}) as ReturnType<typeof api.run.get>);
|
|
||||||
const kill = vi.spyOn(api.run, "kill").mockResolvedValue({ ok: true });
|
|
||||||
renderSagi();
|
|
||||||
fireEvent.click(screen.getByRole("button", { name: /open sagi companion/i }));
|
|
||||||
expect(screen.getByRole("button", { name: /open in workspace/i })).toBeInTheDocument();
|
|
||||||
await act(async () => {
|
|
||||||
fireEvent.click(screen.getByRole("button", { name: /new chat/i }));
|
|
||||||
});
|
|
||||||
expect(kill).toHaveBeenCalledWith("agenthub-lane-7-1", { archive: true });
|
|
||||||
expect(localStorage.getItem("agenthub.pet.chat")).toBeNull();
|
|
||||||
expect(screen.queryByRole("button", { name: /open in workspace/i })).not.toBeInTheDocument();
|
|
||||||
get.mockRestore();
|
|
||||||
kill.mockRestore();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("a pat shows its reaction on the pet even while the chat is open", () => {
|
|
||||||
renderSagi();
|
renderSagi();
|
||||||
fireEvent.click(screen.getByRole("button", { name: /open sagi companion/i }));
|
fireEvent.click(screen.getByRole("button", { name: /open sagi companion/i }));
|
||||||
fireEvent.click(screen.getByRole("button", { name: /pat sagi/i }));
|
fireEvent.click(screen.getByRole("button", { name: /pat sagi/i }));
|
||||||
@@ -187,14 +148,14 @@ describe("Sagi widget", () => {
|
|||||||
expect(pet.querySelector(".sagi-reaction-text")).not.toBeNull();
|
expect(pet.querySelector(".sagi-reaction-text")).not.toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("reflects the live count in the chat status", () => {
|
it("reflects the live count in its status", () => {
|
||||||
renderSagi();
|
renderSagi();
|
||||||
act(() => {
|
act(() => {
|
||||||
eventBus.publish(sessionMsg("a", "active"));
|
eventBus.publish(sessionMsg("a", "active"));
|
||||||
eventBus.publish(sessionMsg("b", "active"));
|
eventBus.publish(sessionMsg("b", "active"));
|
||||||
});
|
});
|
||||||
fireEvent.click(screen.getByRole("button", { name: /open sagi companion/i }));
|
fireEvent.click(screen.getByRole("button", { name: /open sagi companion/i }));
|
||||||
const panel = screen.getByRole("dialog", { name: /chat with sagi/i });
|
const panel = screen.getByRole("dialog", { name: /pet sagi/i });
|
||||||
// The "live" stat chip shows value 2 next to its label.
|
// The "live" stat chip shows value 2 next to its label.
|
||||||
const liveChip = within(panel).getByText("live").closest("span.rounded-full")! as HTMLElement;
|
const liveChip = within(panel).getByText("live").closest("span.rounded-full")! as HTMLElement;
|
||||||
expect(within(liveChip).getByText("2")).toBeInTheDocument();
|
expect(within(liveChip).getByText("2")).toBeInTheDocument();
|
||||||
@@ -208,7 +169,7 @@ describe("Sagi widget", () => {
|
|||||||
eventBus.publish({ ...sessionMsg("theirs-err", "error"), mine: false });
|
eventBus.publish({ ...sessionMsg("theirs-err", "error"), mine: false });
|
||||||
});
|
});
|
||||||
fireEvent.click(screen.getByRole("button", { name: /open sagi companion/i }));
|
fireEvent.click(screen.getByRole("button", { name: /open sagi companion/i }));
|
||||||
const panel = screen.getByRole("dialog", { name: /chat with sagi/i });
|
const panel = screen.getByRole("dialog", { name: /pet sagi/i });
|
||||||
const liveChip = within(panel).getByText("live").closest("span.rounded-full")! as HTMLElement;
|
const liveChip = within(panel).getByText("live").closest("span.rounded-full")! as HTMLElement;
|
||||||
expect(within(liveChip).getByText("1")).toBeInTheDocument();
|
expect(within(liveChip).getByText("1")).toBeInTheDocument();
|
||||||
expect(screen.queryByText(/theirs/)).toBeNull();
|
expect(screen.queryByText(/theirs/)).toBeNull();
|
||||||
@@ -220,7 +181,7 @@ describe("Sagi widget", () => {
|
|||||||
expect(screen.queryByRole("button", { name: /open sagi companion/i })).not.toBeInTheDocument();
|
expect(screen.queryByRole("button", { name: /open sagi companion/i })).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("a tap (no movement) still opens the chat", () => {
|
it("a tap (no movement) still opens its window", () => {
|
||||||
renderSagi();
|
renderSagi();
|
||||||
const btn = screen.getByRole("button", { name: /open sagi companion/i });
|
const btn = screen.getByRole("button", { name: /open sagi companion/i });
|
||||||
act(() => {
|
act(() => {
|
||||||
@@ -228,7 +189,7 @@ describe("Sagi widget", () => {
|
|||||||
fireEvent.pointerUp(btn, { clientX: 990, clientY: 700 });
|
fireEvent.pointerUp(btn, { clientX: 990, clientY: 700 });
|
||||||
});
|
});
|
||||||
fireEvent.click(btn);
|
fireEvent.click(btn);
|
||||||
expect(screen.getByRole("dialog", { name: /chat with sagi/i })).toBeInTheDocument();
|
expect(screen.getByRole("dialog", { name: /pet sagi/i })).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("dragging snaps to an edge, persists position, and does not open the panel", () => {
|
it("dragging snaps to an edge, persists position, and does not open the panel", () => {
|
||||||
@@ -257,7 +218,7 @@ describe("Sagi widget", () => {
|
|||||||
fireEvent.pointerUp(btn, { clientX: 992, clientY: 701 });
|
fireEvent.pointerUp(btn, { clientX: 992, clientY: 701 });
|
||||||
});
|
});
|
||||||
fireEvent.click(btn);
|
fireEvent.click(btn);
|
||||||
expect(screen.getByRole("dialog", { name: /chat with sagi/i })).toBeInTheDocument();
|
expect(screen.getByRole("dialog", { name: /pet sagi/i })).toBeInTheDocument();
|
||||||
// No position was persisted because no real drag happened.
|
// No position was persisted because no real drag happened.
|
||||||
expect(localStorage.getItem("agent-dashboard-sagi-pos")).toBeNull();
|
expect(localStorage.getItem("agent-dashboard-sagi-pos")).toBeNull();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -529,13 +529,10 @@
|
|||||||
z-index: 42;
|
z-index: 42;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ════════ the pet's chat window (PetChat.tsx) ════════ */
|
/* ════════ the pet's window (PetPanel.tsx) ════════ */
|
||||||
|
|
||||||
/* The reference's soft gradient, in the theme's own accent rather than a fixed
|
/* A tint of the theme's accent at the top fading into the surface, so it
|
||||||
colour: a tint of the accent at the top fading into the surface, so it
|
reads in every light and dark theme. */
|
||||||
reads in every light and dark theme. Replies sit on the plain surface like
|
|
||||||
the reference's white bubbles; the user's own are accent-solid (white text
|
|
||||||
is only ever on accent-solid). */
|
|
||||||
.pet-chat {
|
.pet-chat {
|
||||||
background: linear-gradient(
|
background: linear-gradient(
|
||||||
180deg,
|
180deg,
|
||||||
@@ -547,61 +544,6 @@
|
|||||||
.pet-chat-head {
|
.pet-chat-head {
|
||||||
border-bottom: 1px solid color-mix(in srgb, rgb(var(--accent)) 18%, transparent);
|
border-bottom: 1px solid color-mix(in srgb, rgb(var(--accent)) 18%, transparent);
|
||||||
}
|
}
|
||||||
.pet-chat-reply {
|
|
||||||
background: rgb(var(--surface-0));
|
|
||||||
border: 1px solid color-mix(in srgb, rgb(var(--accent)) 12%, transparent);
|
|
||||||
}
|
|
||||||
.pet-chat-tools {
|
|
||||||
background: color-mix(in srgb, rgb(var(--surface-0)) 70%, transparent);
|
|
||||||
border: 1px dashed color-mix(in srgb, rgb(var(--accent)) 25%, transparent);
|
|
||||||
}
|
|
||||||
.pet-chat-foot {
|
|
||||||
background: rgb(var(--surface-0));
|
|
||||||
border-top: 1px solid rgb(var(--border) / 0.6);
|
|
||||||
}
|
|
||||||
/* Three bouncing dots while a reply is written. */
|
|
||||||
.pet-chat-dot {
|
|
||||||
width: 6px;
|
|
||||||
height: 6px;
|
|
||||||
border-radius: 9999px;
|
|
||||||
background: rgb(var(--accent));
|
|
||||||
opacity: 0.5;
|
|
||||||
animation: pet-chat-dot 1.2s ease-in-out infinite;
|
|
||||||
}
|
|
||||||
.pet-chat-dot:nth-child(2) {
|
|
||||||
animation-delay: 0.15s;
|
|
||||||
}
|
|
||||||
.pet-chat-dot:nth-child(3) {
|
|
||||||
animation-delay: 0.3s;
|
|
||||||
}
|
|
||||||
@keyframes pet-chat-dot {
|
|
||||||
0%,
|
|
||||||
60%,
|
|
||||||
100% {
|
|
||||||
transform: translateY(0);
|
|
||||||
opacity: 0.35;
|
|
||||||
}
|
|
||||||
30% {
|
|
||||||
transform: translateY(-4px);
|
|
||||||
opacity: 0.9;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
/* The chat's terminal, attached but off-screen. It keeps a real size (so tmux
|
|
||||||
does not shrink the pane to fit it) and a non-zero box (so the paste
|
|
||||||
registry counts it as on screen). */
|
|
||||||
.pet-chat-term {
|
|
||||||
position: fixed;
|
|
||||||
left: -10000px;
|
|
||||||
top: 0;
|
|
||||||
width: 760px;
|
|
||||||
height: 460px;
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
|
||||||
.pet-chat-dot {
|
|
||||||
animation: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ════════ a pat's reaction, drawn on the pet itself ════════ */
|
/* ════════ a pat's reaction, drawn on the pet itself ════════ */
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,14 @@
|
|||||||
* the next option whose label starts with it. Focus returns to the trigger on
|
* the next option whose label starts with it. Focus returns to the trigger on
|
||||||
* every close except Tab.
|
* every close except Tab.
|
||||||
*
|
*
|
||||||
|
* ## Search — a combobox once the list is long
|
||||||
|
* With more than `SEARCH_FROM` options (or `searchable`), the popup opens with
|
||||||
|
* a search box on top, focused: typing narrows the list to options whose
|
||||||
|
* label or value contains the text (case-insensitive), ↑/↓ move, Enter picks,
|
||||||
|
* Escape closes. The box is `role="combobox"` controlling the listbox through
|
||||||
|
* `aria-activedescendant`. Options may carry a `group`: a heading is drawn
|
||||||
|
* where the group changes.
|
||||||
|
*
|
||||||
* ## Consumers
|
* ## Consumers
|
||||||
* Used on the Run Claude page and webhook settings form wherever a compact
|
* Used on the Run Claude page and webhook settings form wherever a compact
|
||||||
* enum picker is needed.
|
* enum picker is needed.
|
||||||
@@ -73,7 +81,11 @@
|
|||||||
* ----------------------------------------------------------------------------- */
|
* ----------------------------------------------------------------------------- */
|
||||||
|
|
||||||
import { useEffect, useId, useRef, useState, type ReactNode } from "react";
|
import { useEffect, useId, useRef, useState, type ReactNode } from "react";
|
||||||
import { ChevronDown, Check } from "lucide-react";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { ChevronDown, Check, Search } from "lucide-react";
|
||||||
|
|
||||||
|
/** A list longer than this gets a search box (unless `searchable` says otherwise). */
|
||||||
|
export const SEARCH_FROM = 8;
|
||||||
|
|
||||||
/** Room above and below `r` (the trigger's rect) for a menu it opens: inside
|
/** Room above and below `r` (the trigger's rect) for a menu it opens: inside
|
||||||
* the nearest `data-menu-bounds` box — a split pane, whose neighbour paints
|
* the nearest `data-menu-bounds` box — a split pane, whose neighbour paints
|
||||||
@@ -95,6 +107,8 @@ export interface SelectOption<T extends string> {
|
|||||||
icon?: ReactNode;
|
icon?: ReactNode;
|
||||||
/** Draw a rule above this option: it starts a group of its own. */
|
/** Draw a rule above this option: it starts a group of its own. */
|
||||||
divider?: boolean;
|
divider?: boolean;
|
||||||
|
/** The group this option belongs to: a heading is drawn where it changes. */
|
||||||
|
group?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Props for {@link Select}. */
|
/** Props for {@link Select}. */
|
||||||
@@ -111,6 +125,10 @@ export interface SelectProps<T extends string> {
|
|||||||
variant?: "field" | "ghost";
|
variant?: "field" | "ghost";
|
||||||
/** Accessible name and tooltip of the trigger (a ghost one has no label). */
|
/** Accessible name and tooltip of the trigger (a ghost one has no label). */
|
||||||
label?: string;
|
label?: string;
|
||||||
|
/** Show the search box: default only for lists longer than `SEARCH_FROM`. */
|
||||||
|
searchable?: boolean;
|
||||||
|
/** The search box's name and placeholder (default: "Search…"). */
|
||||||
|
searchLabel?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -124,15 +142,26 @@ export function Select<T extends string>({
|
|||||||
disabled,
|
disabled,
|
||||||
variant = "field",
|
variant = "field",
|
||||||
label,
|
label,
|
||||||
|
searchable,
|
||||||
|
searchLabel,
|
||||||
}: SelectProps<T>) {
|
}: SelectProps<T>) {
|
||||||
|
const { t } = useTranslation("common");
|
||||||
const ghost = variant === "ghost";
|
const ghost = variant === "ghost";
|
||||||
|
const canSearch = searchable ?? options.length > SEARCH_FROM;
|
||||||
|
const [query, setQuery] = useState("");
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [active, setActive] = useState(0);
|
const [active, setActive] = useState(0);
|
||||||
const [openUp, setOpenUp] = useState(false);
|
const [openUp, setOpenUp] = useState(false);
|
||||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||||
const buttonRef = useRef<HTMLButtonElement | null>(null);
|
const buttonRef = useRef<HTMLButtonElement | null>(null);
|
||||||
const listRef = useRef<HTMLDivElement | null>(null);
|
const listRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const searchRef = useRef<HTMLInputElement | null>(null);
|
||||||
const typed = useRef({ text: "", at: 0 });
|
const typed = useRef({ text: "", at: 0 });
|
||||||
|
// The options shown: all of them, or those the search text matches.
|
||||||
|
const q = canSearch ? query.trim().toLowerCase() : "";
|
||||||
|
const shown = q
|
||||||
|
? options.filter((o) => `${o.label} ${o.value}`.toLowerCase().includes(q))
|
||||||
|
: options;
|
||||||
const id = useId();
|
const id = useId();
|
||||||
const optionId = (idx: number) => `${id}-opt-${idx}`;
|
const optionId = (idx: number) => `${id}-opt-${idx}`;
|
||||||
|
|
||||||
@@ -150,6 +179,7 @@ export function Select<T extends string>({
|
|||||||
// above the trigger (288 px = `max-h-72`), and move focus into the list.
|
// above the trigger (288 px = `max-h-72`), and move focus into the list.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) return;
|
if (!open) return;
|
||||||
|
setQuery("");
|
||||||
const idx = options.findIndex((o) => o.value === value);
|
const idx = options.findIndex((o) => o.value === value);
|
||||||
setActive(idx >= 0 ? idx : 0);
|
setActive(idx >= 0 ? idx : 0);
|
||||||
if (buttonRef.current) {
|
if (buttonRef.current) {
|
||||||
@@ -159,14 +189,14 @@ export function Select<T extends string>({
|
|||||||
);
|
);
|
||||||
setOpenUp(below < 288 && above > below);
|
setOpenUp(below < 288 && above > below);
|
||||||
}
|
}
|
||||||
listRef.current?.focus({ preventScroll: true });
|
(canSearch ? searchRef.current : listRef.current)?.focus({ preventScroll: true });
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- only on open
|
// eslint-disable-next-line react-hooks/exhaustive-deps -- only on open
|
||||||
}, [open]);
|
}, [open]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (open) document.getElementById(optionId(active))?.scrollIntoView?.({ block: "nearest" });
|
if (open) document.getElementById(optionId(active))?.scrollIntoView?.({ block: "nearest" });
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- id is stable
|
// eslint-disable-next-line react-hooks/exhaustive-deps -- id is stable
|
||||||
}, [open, active]);
|
}, [open, active, q]);
|
||||||
|
|
||||||
const close = (refocus = true) => {
|
const close = (refocus = true) => {
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
@@ -185,8 +215,12 @@ export function Select<T extends string>({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const onListKey = (e: React.KeyboardEvent<HTMLDivElement>) => {
|
const onListKey = (e: React.KeyboardEvent<HTMLElement>) => {
|
||||||
const last = options.length - 1;
|
const last = shown.length - 1;
|
||||||
|
// In the search box, keys that edit text stay the box's own.
|
||||||
|
const inSearch = e.currentTarget === searchRef.current;
|
||||||
|
if (inSearch && (e.key === " " || e.key === "Home" || e.key === "End")) return;
|
||||||
|
if (inSearch && e.key === "Enter" && e.nativeEvent.isComposing) return;
|
||||||
const move: Record<string, (a: number) => number> = {
|
const move: Record<string, (a: number) => number> = {
|
||||||
ArrowDown: (a) => Math.min(last, a + 1),
|
ArrowDown: (a) => Math.min(last, a + 1),
|
||||||
ArrowUp: (a) => Math.max(0, a - 1),
|
ArrowUp: (a) => Math.max(0, a - 1),
|
||||||
@@ -200,7 +234,7 @@ export function Select<T extends string>({
|
|||||||
setActive(move[e.key]!);
|
setActive(move[e.key]!);
|
||||||
} else if (e.key === "Enter" || e.key === " ") {
|
} else if (e.key === "Enter" || e.key === " ") {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const opt = options[active];
|
const opt = shown[active];
|
||||||
if (opt) choose(opt);
|
if (opt) choose(opt);
|
||||||
} else if (e.key === "Escape") {
|
} else if (e.key === "Escape") {
|
||||||
// Stop here: a Select inside a dialog must not close the dialog too.
|
// Stop here: a Select inside a dialog must not close the dialog too.
|
||||||
@@ -209,7 +243,7 @@ export function Select<T extends string>({
|
|||||||
close();
|
close();
|
||||||
} else if (e.key === "Tab") {
|
} else if (e.key === "Tab") {
|
||||||
close(false);
|
close(false);
|
||||||
} else if (e.key.length === 1 && !e.ctrlKey && !e.metaKey && !e.altKey) {
|
} else if (!inSearch && e.key.length === 1 && !e.ctrlKey && !e.metaKey && !e.altKey) {
|
||||||
// Type-ahead: keys typed within 500 ms build one prefix; a repeated single
|
// Type-ahead: keys typed within 500 ms build one prefix; a repeated single
|
||||||
// character cycles through the options that start with it.
|
// character cycles through the options that start with it.
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
@@ -258,57 +292,100 @@ export function Select<T extends string>({
|
|||||||
</button>
|
</button>
|
||||||
{open && (
|
{open && (
|
||||||
<div
|
<div
|
||||||
ref={listRef}
|
className={`absolute z-30 left-0 ${ghost ? "w-max min-w-[12rem] max-w-[20rem]" : "right-0"} flex max-h-72 flex-col overflow-hidden rounded-md border border-border bg-surface-1 shadow-lg shadow-black/40 ${
|
||||||
id={`${id}-list`}
|
|
||||||
role="listbox"
|
|
||||||
tabIndex={-1}
|
|
||||||
aria-label={label}
|
|
||||||
aria-activedescendant={options[active] ? optionId(active) : undefined}
|
|
||||||
onKeyDown={onListKey}
|
|
||||||
onBlur={(e) => {
|
|
||||||
if (!containerRef.current?.contains(e.relatedTarget as Node | null)) setOpen(false);
|
|
||||||
}}
|
|
||||||
className={`absolute z-30 left-0 ${ghost ? "w-max min-w-[12rem] max-w-[20rem]" : "right-0"} rounded-md border border-border bg-surface-1 shadow-lg shadow-black/40 max-h-72 overflow-auto py-1 outline-none ${
|
|
||||||
openUp ? "bottom-full mb-1" : "top-full mt-1"
|
openUp ? "bottom-full mb-1" : "top-full mt-1"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{options.map((opt, idx) => {
|
{canSearch && (
|
||||||
const isSelected = opt.value === value;
|
<div className="flex shrink-0 items-center gap-1.5 border-b border-border px-2.5 py-1.5">
|
||||||
const isActive = idx === active;
|
<Search className="h-3 w-3 shrink-0 text-fg-muted" aria-hidden />
|
||||||
return (
|
<input
|
||||||
<div
|
ref={searchRef}
|
||||||
key={opt.value || "__default__"}
|
role="combobox"
|
||||||
id={optionId(idx)}
|
aria-label={searchLabel ?? t("select.search")}
|
||||||
role="option"
|
aria-expanded
|
||||||
aria-selected={isSelected}
|
aria-controls={`${id}-list`}
|
||||||
onMouseDown={(e) => e.preventDefault()}
|
aria-autocomplete="list"
|
||||||
onClick={() => choose(opt)}
|
aria-activedescendant={shown[active] ? optionId(active) : undefined}
|
||||||
onMouseEnter={() => setActive(idx)}
|
placeholder={searchLabel ?? t("select.search")}
|
||||||
className={`w-full cursor-pointer text-left px-3 py-1.5 transition-colors ${
|
value={query}
|
||||||
opt.divider ? "mt-1 border-t border-border pt-2" : ""
|
onChange={(e) => {
|
||||||
} ${
|
setQuery(e.target.value);
|
||||||
isActive ? "bg-accent/15" : isSelected ? "bg-surface-3" : "hover:bg-surface-3"
|
setActive(0);
|
||||||
}`}
|
}}
|
||||||
>
|
onKeyDown={onListKey}
|
||||||
<div className="flex items-center gap-2">
|
onBlur={(e) => {
|
||||||
{opt.icon}
|
if (!containerRef.current?.contains(e.relatedTarget as Node | null))
|
||||||
<span
|
setOpen(false);
|
||||||
className={`text-[11px] flex-1 truncate ${
|
}}
|
||||||
isSelected ? "text-accent font-medium" : "text-fg-secondary"
|
className="min-w-0 flex-1 bg-transparent text-[11px] text-fg-primary placeholder:text-fg-muted focus:outline-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div
|
||||||
|
ref={listRef}
|
||||||
|
id={`${id}-list`}
|
||||||
|
role="listbox"
|
||||||
|
tabIndex={-1}
|
||||||
|
aria-label={label}
|
||||||
|
aria-activedescendant={!canSearch && shown[active] ? optionId(active) : undefined}
|
||||||
|
onKeyDown={onListKey}
|
||||||
|
onBlur={(e) => {
|
||||||
|
if (!containerRef.current?.contains(e.relatedTarget as Node | null)) setOpen(false);
|
||||||
|
}}
|
||||||
|
className="min-h-0 flex-1 overflow-auto py-1 outline-none"
|
||||||
|
>
|
||||||
|
{canSearch && shown.length === 0 && (
|
||||||
|
<div className="px-3 py-1.5 text-[11px] text-fg-muted">{t("select.noMatch")}</div>
|
||||||
|
)}
|
||||||
|
{shown.map((opt, idx) => {
|
||||||
|
const isSelected = opt.value === value;
|
||||||
|
const isActive = idx === active;
|
||||||
|
const heading = opt.group && opt.group !== shown[idx - 1]?.group ? opt.group : null;
|
||||||
|
return (
|
||||||
|
<div key={opt.value || "__default__"} role="none">
|
||||||
|
{heading && (
|
||||||
|
<div
|
||||||
|
role="presentation"
|
||||||
|
className="mt-1 border-t border-border px-3 pb-0.5 pt-1.5 text-[10px] font-medium text-fg-muted"
|
||||||
|
>
|
||||||
|
{heading}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div
|
||||||
|
id={optionId(idx)}
|
||||||
|
role="option"
|
||||||
|
aria-selected={isSelected}
|
||||||
|
onMouseDown={(e) => e.preventDefault()}
|
||||||
|
onClick={() => choose(opt)}
|
||||||
|
onMouseEnter={() => setActive(idx)}
|
||||||
|
className={`w-full cursor-pointer text-left px-3 py-1.5 transition-colors ${
|
||||||
|
opt.divider ? "mt-1 border-t border-border pt-2" : ""
|
||||||
|
} ${
|
||||||
|
isActive ? "bg-accent/15" : isSelected ? "bg-surface-3" : "hover:bg-surface-3"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{opt.label}
|
<div className="flex items-center gap-2">
|
||||||
</span>
|
{opt.icon}
|
||||||
{isSelected && (
|
<span
|
||||||
<Check className="w-3 h-3 text-accent flex-shrink-0" aria-hidden />
|
className={`text-[11px] flex-1 truncate ${
|
||||||
)}
|
isSelected ? "text-accent font-medium" : "text-fg-secondary"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{opt.label}
|
||||||
|
</span>
|
||||||
|
{isSelected && (
|
||||||
|
<Check className="w-3 h-3 text-accent flex-shrink-0" aria-hidden />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{opt.hint && (
|
||||||
|
<div className="text-[10px] text-fg-muted truncate mt-0.5">{opt.hint}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{opt.hint && (
|
);
|
||||||
<div className="text-[10px] text-fg-muted truncate mt-0.5">{opt.hint}</div>
|
})}
|
||||||
)}
|
</div>
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -559,6 +559,8 @@ export function WebhookSettings() {
|
|||||||
headerRows: [],
|
headerRows: [],
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
// A short, fixed list of providers: no search box.
|
||||||
|
searchable={false}
|
||||||
options={providers.map((p) => ({ value: p.type, label: p.label }))}
|
options={providers.map((p) => ({ value: p.type, label: p.label }))}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -105,7 +105,61 @@ describe("Select (listbox button)", () => {
|
|||||||
vi.spyOn(button, "getBoundingClientRect").mockReturnValue(rect(360, 380));
|
vi.spyOn(button, "getBoundingClientRect").mockReturnValue(rect(360, 380));
|
||||||
vi.stubGlobal("innerHeight", 2000);
|
vi.stubGlobal("innerHeight", 2000);
|
||||||
fireEvent.click(button);
|
fireEvent.click(button);
|
||||||
expect(screen.getByRole("listbox").className).toContain("bottom-full");
|
expect(screen.getByRole("listbox").parentElement!.className).toContain("bottom-full");
|
||||||
vi.unstubAllGlobals();
|
vi.unstubAllGlobals();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("turns a long list into a combobox: type to narrow, Enter picks, groups keep headings", async () => {
|
||||||
|
const onChange = vi.fn();
|
||||||
|
const options = [
|
||||||
|
{ value: "", label: "Switch branch…" },
|
||||||
|
...["develop", "main", "feat/campaign-pause", "fix/campaign-step", "chore/a", "chore/b"].map(
|
||||||
|
(v) => ({ value: v, label: v })
|
||||||
|
),
|
||||||
|
...["origin/feat/campaign-statistic", "origin/x", "origin/y"].map((v) => ({
|
||||||
|
value: v,
|
||||||
|
label: v,
|
||||||
|
group: "On the remote",
|
||||||
|
})),
|
||||||
|
];
|
||||||
|
render(<Select value="" onChange={onChange} options={options} label="Switch branch…" />);
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Switch branch…" }));
|
||||||
|
const search = screen.getByRole("combobox", { name: "Search…" });
|
||||||
|
expect(search).toHaveFocus();
|
||||||
|
expect(screen.getByText("On the remote")).toBeInTheDocument();
|
||||||
|
|
||||||
|
fireEvent.change(search, { target: { value: "CAMPAIGN" } });
|
||||||
|
expect(screen.getAllByRole("option").map((o) => o.textContent)).toEqual([
|
||||||
|
"feat/campaign-pause",
|
||||||
|
"fix/campaign-step",
|
||||||
|
"origin/feat/campaign-statistic",
|
||||||
|
]);
|
||||||
|
fireEvent.keyDown(search, { key: "ArrowDown" });
|
||||||
|
fireEvent.keyDown(search, { key: "Enter" });
|
||||||
|
expect(onChange).toHaveBeenCalledWith("fix/campaign-step");
|
||||||
|
expect(screen.queryByRole("listbox")).toBeNull();
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Switch branch…" }));
|
||||||
|
fireEvent.change(screen.getByRole("combobox"), { target: { value: "zzz" } });
|
||||||
|
expect(screen.getByText("No match")).toBeInTheDocument();
|
||||||
|
fireEvent.keyDown(screen.getByRole("combobox"), { key: "Escape" });
|
||||||
|
expect(screen.queryByRole("listbox")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps a short list a plain listbox (no search box)", () => {
|
||||||
|
render(
|
||||||
|
<Select
|
||||||
|
value="a"
|
||||||
|
onChange={vi.fn()}
|
||||||
|
options={[
|
||||||
|
{ value: "a", label: "A" },
|
||||||
|
{ value: "b", label: "B" },
|
||||||
|
]}
|
||||||
|
label="Pick"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Pick" }));
|
||||||
|
expect(screen.queryByRole("combobox")).toBeNull();
|
||||||
|
expect(screen.getByRole("listbox")).toHaveFocus();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ import { isSubmitEnter } from "../../lib/keys";
|
|||||||
import type { LaneGitFacts, LaneWorktree } from "../../lib/types";
|
import type { LaneGitFacts, LaneWorktree } from "../../lib/types";
|
||||||
import { restoreShelf } from "./LaneShelves";
|
import { restoreShelf } from "./LaneShelves";
|
||||||
import { BranchesButton } from "./git/BranchesButton";
|
import { BranchesButton } from "./git/BranchesButton";
|
||||||
|
import { Select } from "../Select";
|
||||||
|
|
||||||
type Action =
|
type Action =
|
||||||
"fetch" | "pull" | "push" | "switch" | "branch-create" | "branch-delete" | "merge" | "publish";
|
"fetch" | "pull" | "push" | "switch" | "branch-create" | "branch-delete" | "merge" | "publish";
|
||||||
@@ -526,33 +527,32 @@ export function LaneGitBar({
|
|||||||
|
|
||||||
{switching && (
|
{switching && (
|
||||||
<div className="flex flex-wrap items-center gap-1.5">
|
<div className="flex flex-wrap items-center gap-1.5">
|
||||||
<select
|
{/* A combobox once the list is long (Select's search box, #88):
|
||||||
aria-label={t("gitBar.switchLabel")}
|
type to narrow the local and remote branches. */}
|
||||||
value={picked}
|
<div className="min-w-[8rem] flex-1 font-mono">
|
||||||
disabled={!!busy}
|
<Select
|
||||||
onChange={(e) => setPicked(e.target.value)}
|
label={t("gitBar.switchLabel")}
|
||||||
className="select min-w-[8rem] flex-1 !rounded !py-0.5 !pl-2 !pr-6 font-mono !text-[11px]"
|
searchLabel={t("gitBar.branchSearch")}
|
||||||
>
|
searchable
|
||||||
<option value="">{t("gitBar.switchLabel")}</option>
|
value={picked}
|
||||||
{(branches || []).map((b) => {
|
disabled={!!busy}
|
||||||
const held = heldBy.get(b);
|
onChange={setPicked}
|
||||||
return (
|
options={[
|
||||||
<option key={b} value={b}>
|
{ value: "", label: t("gitBar.switchLabel") },
|
||||||
{b}
|
...(branches || []).map((b) => {
|
||||||
{b === git.branch ? ` — ${t("gitBar.switchCurrent")}` : held ? ` — ${held}` : ""}
|
const held = heldBy.get(b);
|
||||||
</option>
|
const note =
|
||||||
);
|
b === git.branch ? ` — ${t("gitBar.switchCurrent")}` : held ? ` — ${held}` : "";
|
||||||
})}
|
return { value: b, label: `${b}${note}` };
|
||||||
{remoteBranches.length > 0 && (
|
}),
|
||||||
<optgroup label={t("gitBar.remoteBranches")}>
|
...remoteBranches.map((b) => ({
|
||||||
{remoteBranches.map((b) => (
|
value: b,
|
||||||
<option key={`remote:${b}`} value={b}>
|
label: b,
|
||||||
{b}
|
group: t("gitBar.remoteBranches"),
|
||||||
</option>
|
})),
|
||||||
))}
|
]}
|
||||||
</optgroup>
|
/>
|
||||||
)}
|
</div>
|
||||||
</select>
|
|
||||||
<Btn
|
<Btn
|
||||||
label={t("gitBar.switchApply")}
|
label={t("gitBar.switchApply")}
|
||||||
busy={busy === "switch"}
|
busy={busy === "switch"}
|
||||||
|
|||||||
@@ -171,21 +171,23 @@ beforeEach(() => {
|
|||||||
vi.mocked(h.commit).mockReset().mockResolvedValue({ available: false });
|
vi.mocked(h.commit).mockReset().mockResolvedValue({ available: false });
|
||||||
vi.mocked(h.sequence).mockReset().mockResolvedValue({ available: true, active: false });
|
vi.mocked(h.sequence).mockReset().mockResolvedValue({ available: true, active: false });
|
||||||
vi.mocked(h.pick).mockReset();
|
vi.mocked(h.pick).mockReset();
|
||||||
vi.mocked(h.pickPlan).mockReset().mockImplementation(async (_t, op, shas) => ({
|
vi.mocked(h.pickPlan)
|
||||||
available: true,
|
.mockReset()
|
||||||
op,
|
.mockImplementation(async (_t, op, shas) => ({
|
||||||
commits: shas.map((sha) => ({
|
available: true,
|
||||||
sha,
|
op,
|
||||||
short: sha.slice(0, 7),
|
commits: shas.map((sha) => ({
|
||||||
subject: "feat: the commit",
|
sha,
|
||||||
author: "t",
|
short: sha.slice(0, 7),
|
||||||
date: "2026-09-22T01:00:00Z",
|
subject: "feat: the commit",
|
||||||
})),
|
author: "t",
|
||||||
branch: "feat/thing",
|
date: "2026-09-22T01:00:00Z",
|
||||||
clean: true,
|
})),
|
||||||
repoRoot: true,
|
branch: "feat/thing",
|
||||||
busy: false,
|
clean: true,
|
||||||
}));
|
repoRoot: true,
|
||||||
|
busy: false,
|
||||||
|
}));
|
||||||
vi.mocked(h.fileHistory)
|
vi.mocked(h.fileHistory)
|
||||||
.mockReset()
|
.mockReset()
|
||||||
.mockResolvedValue({
|
.mockResolvedValue({
|
||||||
@@ -538,7 +540,8 @@ describe("LaneChangesPanel", () => {
|
|||||||
await screen.findByTitle("only-on-thing.ts");
|
await screen.findByTitle("only-on-thing.ts");
|
||||||
|
|
||||||
await user.click(screen.getByRole("button", { name: /^Branch$/ }));
|
await user.click(screen.getByRole("button", { name: /^Branch$/ }));
|
||||||
await user.selectOptions(await screen.findByLabelText("Switch branch…"), "other");
|
await user.click(await screen.findByRole("button", { name: "Switch branch…" }));
|
||||||
|
await user.click(screen.getByRole("option", { name: "other" }));
|
||||||
await user.click(screen.getByRole("button", { name: "Switch branch" }));
|
await user.click(screen.getByRole("button", { name: "Switch branch" }));
|
||||||
|
|
||||||
await waitFor(() => expect(screen.getByTestId("lane-git")).toHaveTextContent("other"));
|
await waitFor(() => expect(screen.getByTestId("lane-git")).toHaveTextContent("other"));
|
||||||
@@ -1344,9 +1347,7 @@ describe("LaneChangesPanel", () => {
|
|||||||
await user.click(within(await commitMenu(user)).getByText("Cherry-pick onto feat/thing"));
|
await user.click(within(await commitMenu(user)).getByText("Cherry-pick onto feat/thing"));
|
||||||
await user.click(await screen.findByTestId("pick-confirm"));
|
await user.click(await screen.findByTestId("pick-confirm"));
|
||||||
await waitFor(() =>
|
await waitFor(() =>
|
||||||
expect(api.lanes.gitHistory.pick).toHaveBeenCalledWith(7, "cherry-pick", [
|
expect(api.lanes.gitHistory.pick).toHaveBeenCalledWith(7, "cherry-pick", ["a".repeat(40)])
|
||||||
"a".repeat(40),
|
|
||||||
])
|
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -13,13 +13,22 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
import { render, screen, waitFor } from "@testing-library/react";
|
import { render, screen, waitFor, within } from "@testing-library/react";
|
||||||
import userEvent from "@testing-library/user-event";
|
import userEvent from "@testing-library/user-event";
|
||||||
import { LaneGitBar } from "../LaneGitBar";
|
import { LaneGitBar } from "../LaneGitBar";
|
||||||
import { api } from "../../../lib/api";
|
import { api } from "../../../lib/api";
|
||||||
import { confirmDialog } from "../../../lib/confirm";
|
import { confirmDialog } from "../../../lib/confirm";
|
||||||
import type { LaneGitFacts } from "../../../lib/types";
|
import type { LaneGitFacts } from "../../../lib/types";
|
||||||
|
|
||||||
|
/** Picks `name` in the branch switcher — a Select combobox (#88). */
|
||||||
|
async function pickBranch(user: ReturnType<typeof userEvent.setup>, name: string) {
|
||||||
|
await user.click(await screen.findByRole("button", { name: "Switch branch…" }));
|
||||||
|
const esc = name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||||
|
await user.click(
|
||||||
|
within(screen.getByRole("listbox")).getByRole("option", { name: new RegExp(`^${esc}( —|$)`) })
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// A merge asks through the themed confirm (its host lives in App): yes.
|
// A merge asks through the themed confirm (its host lives in App): yes.
|
||||||
vi.mock("../../../lib/confirm", () => ({ confirmDialog: vi.fn(async () => true) }));
|
vi.mock("../../../lib/confirm", () => ({ confirmDialog: vi.fn(async () => true) }));
|
||||||
vi.mock("../../../lib/api", () => ({
|
vi.mock("../../../lib/api", () => ({
|
||||||
@@ -210,7 +219,7 @@ describe("LaneGitBar", () => {
|
|||||||
|
|
||||||
await user.click(screen.getByRole("button", { name: /Branch/ }));
|
await user.click(screen.getByRole("button", { name: /Branch/ }));
|
||||||
await waitFor(() => expect(api.lanes.gitBranches).toHaveBeenCalledWith(3));
|
await waitFor(() => expect(api.lanes.gitBranches).toHaveBeenCalledWith(3));
|
||||||
await user.selectOptions(await screen.findByLabelText("Switch branch…"), "development");
|
await pickBranch(user, "development");
|
||||||
await user.click(screen.getByRole("button", { name: "Switch branch" }));
|
await user.click(screen.getByRole("button", { name: "Switch branch" }));
|
||||||
// Nothing is sent before the user picks what happens to the changes.
|
// Nothing is sent before the user picks what happens to the changes.
|
||||||
expect(api.lanes.gitAction).not.toHaveBeenCalled();
|
expect(api.lanes.gitAction).not.toHaveBeenCalled();
|
||||||
@@ -235,7 +244,7 @@ describe("LaneGitBar", () => {
|
|||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
render(<LaneGitBar laneId={3} git={facts()} onChanged={vi.fn()} />);
|
render(<LaneGitBar laneId={3} git={facts()} onChanged={vi.fn()} />);
|
||||||
await user.click(screen.getByRole("button", { name: /Branch/ }));
|
await user.click(screen.getByRole("button", { name: /Branch/ }));
|
||||||
await user.selectOptions(await screen.findByLabelText("Switch branch…"), "development");
|
await pickBranch(user, "development");
|
||||||
await user.click(screen.getByRole("button", { name: "Switch branch" }));
|
await user.click(screen.getByRole("button", { name: "Switch branch" }));
|
||||||
await user.click(screen.getByRole("button", { name: "Bring my changes" }));
|
await user.click(screen.getByRole("button", { name: "Bring my changes" }));
|
||||||
expect(await screen.findByTestId("lane-git-carry-conflicts")).toHaveTextContent(
|
expect(await screen.findByTestId("lane-git-carry-conflicts")).toHaveTextContent(
|
||||||
@@ -258,7 +267,7 @@ describe("LaneGitBar", () => {
|
|||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
render(<LaneGitBar laneId={3} git={facts()} onChanged={vi.fn()} />);
|
render(<LaneGitBar laneId={3} git={facts()} onChanged={vi.fn()} />);
|
||||||
await user.click(screen.getByRole("button", { name: /Branch/ }));
|
await user.click(screen.getByRole("button", { name: /Branch/ }));
|
||||||
await user.selectOptions(await screen.findByLabelText("Switch branch…"), "development");
|
await pickBranch(user, "development");
|
||||||
await user.click(screen.getByRole("button", { name: "Switch branch" }));
|
await user.click(screen.getByRole("button", { name: "Switch branch" }));
|
||||||
await user.click(screen.getByRole("button", { name: "Shelve and switch" }));
|
await user.click(screen.getByRole("button", { name: "Shelve and switch" }));
|
||||||
await waitFor(() =>
|
await waitFor(() =>
|
||||||
@@ -291,7 +300,7 @@ describe("LaneGitBar", () => {
|
|||||||
const onChanged = vi.fn();
|
const onChanged = vi.fn();
|
||||||
render(<LaneGitBar laneId={3} git={facts({ dirty: 0, untracked: 0 })} onChanged={onChanged} />);
|
render(<LaneGitBar laneId={3} git={facts({ dirty: 0, untracked: 0 })} onChanged={onChanged} />);
|
||||||
await user.click(screen.getByRole("button", { name: /Branch/ }));
|
await user.click(screen.getByRole("button", { name: /Branch/ }));
|
||||||
await user.selectOptions(await screen.findByLabelText("Switch branch…"), "development");
|
await pickBranch(user, "development");
|
||||||
await user.click(screen.getByRole("button", { name: "Switch branch" }));
|
await user.click(screen.getByRole("button", { name: "Switch branch" }));
|
||||||
await waitFor(() =>
|
await waitFor(() =>
|
||||||
expect(api.lanes.gitAction).toHaveBeenLastCalledWith(3, "unshelve", { sha: "abc1234def" })
|
expect(api.lanes.gitAction).toHaveBeenLastCalledWith(3, "unshelve", { sha: "abc1234def" })
|
||||||
@@ -319,8 +328,8 @@ describe("LaneGitBar", () => {
|
|||||||
render(<LaneGitBar laneId={3} git={facts({ dirty: 0, untracked: 0 })} onChanged={vi.fn()} />);
|
render(<LaneGitBar laneId={3} git={facts({ dirty: 0, untracked: 0 })} onChanged={vi.fn()} />);
|
||||||
|
|
||||||
await user.click(screen.getByRole("button", { name: /Branch/ }));
|
await user.click(screen.getByRole("button", { name: /Branch/ }));
|
||||||
const select = await screen.findByLabelText("Switch branch…");
|
await screen.findByRole("button", { name: "Switch branch…" });
|
||||||
await user.selectOptions(select, "development");
|
await pickBranch(user, "development");
|
||||||
await user.click(screen.getByRole("button", { name: "Switch branch" }));
|
await user.click(screen.getByRole("button", { name: "Switch branch" }));
|
||||||
|
|
||||||
await waitFor(() =>
|
await waitFor(() =>
|
||||||
@@ -332,11 +341,42 @@ describe("LaneGitBar", () => {
|
|||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
render(<LaneGitBar laneId={3} git={facts({ dirty: 0, untracked: 0 })} onChanged={vi.fn()} />);
|
render(<LaneGitBar laneId={3} git={facts({ dirty: 0, untracked: 0 })} onChanged={vi.fn()} />);
|
||||||
await user.click(screen.getByRole("button", { name: /Branch/ }));
|
await user.click(screen.getByRole("button", { name: /Branch/ }));
|
||||||
await user.selectOptions(await screen.findByLabelText("Switch branch…"), "development");
|
await pickBranch(user, "development");
|
||||||
await user.click(screen.getByRole("button", { name: "Switch branch" }));
|
await user.click(screen.getByRole("button", { name: "Switch branch" }));
|
||||||
await waitFor(() => expect(api.lanes.gitBranches).toHaveBeenCalledTimes(2));
|
await waitFor(() => expect(api.lanes.gitBranches).toHaveBeenCalledTimes(2));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("opens the branch switcher as a combobox: type to narrow, Enter picks (#88)", async () => {
|
||||||
|
vi.mocked(api.lanes.gitBranches).mockResolvedValue({
|
||||||
|
available: true,
|
||||||
|
branches: ["development", "feat/campaign-pause", "fix/campaign-step", "main"],
|
||||||
|
remotes: ["origin/feat/campaign-statistic", "origin/feature/thienpt/unit_test"],
|
||||||
|
current: "development",
|
||||||
|
worktrees: [],
|
||||||
|
});
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(<LaneGitBar laneId={3} git={facts({ dirty: 0, untracked: 0 })} onChanged={vi.fn()} />);
|
||||||
|
await user.click(screen.getByRole("button", { name: /Branch/ }));
|
||||||
|
const trigger = await screen.findByRole("button", { name: "Switch branch…" });
|
||||||
|
await user.click(trigger);
|
||||||
|
const search = screen.getByRole("combobox", { name: "Search branches…" });
|
||||||
|
expect(search).toHaveFocus();
|
||||||
|
await user.keyboard("CAMPAIGN");
|
||||||
|
expect(screen.getAllByRole("option").map((o) => o.textContent)).toEqual([
|
||||||
|
"feat/campaign-pause",
|
||||||
|
"fix/campaign-step",
|
||||||
|
"origin/feat/campaign-statistic",
|
||||||
|
]);
|
||||||
|
await user.keyboard("{Enter}");
|
||||||
|
expect(trigger).toHaveTextContent("feat/campaign-pause");
|
||||||
|
await user.click(screen.getByRole("button", { name: "Switch branch" }));
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(api.lanes.gitAction).toHaveBeenCalledWith(3, "switch", {
|
||||||
|
branch: "feat/campaign-pause",
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it("lists branches only the remote has, switches to one, and re-reads the list after a fetch", async () => {
|
it("lists branches only the remote has, switches to one, and re-reads the list after a fetch", async () => {
|
||||||
vi.mocked(api.lanes.gitBranches).mockResolvedValue({
|
vi.mocked(api.lanes.gitBranches).mockResolvedValue({
|
||||||
available: true,
|
available: true,
|
||||||
@@ -355,11 +395,12 @@ describe("LaneGitBar", () => {
|
|||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
await user.click(screen.getByRole("button", { name: /Branch/ }));
|
await user.click(screen.getByRole("button", { name: /Branch/ }));
|
||||||
const select = await screen.findByLabelText("Switch branch…");
|
await user.click(await screen.findByRole("button", { name: "Switch branch…" }));
|
||||||
expect(
|
expect(
|
||||||
screen.getByRole("group", { name: "On the remote — switching creates the local branch" })
|
screen.getByText("On the remote — switching creates the local branch")
|
||||||
).toBeInTheDocument();
|
).toBeInTheDocument();
|
||||||
await user.selectOptions(select, "origin/feat/new");
|
await user.keyboard("{Escape}");
|
||||||
|
await pickBranch(user, "origin/feat/new");
|
||||||
// Not a local branch yet: nothing to delete, no worktree to start on it.
|
// Not a local branch yet: nothing to delete, no worktree to start on it.
|
||||||
expect(screen.getByRole("button", { name: "Delete" })).toBeDisabled();
|
expect(screen.getByRole("button", { name: "Delete" })).toBeDisabled();
|
||||||
expect(screen.getByRole("button", { name: /New workspace here/ })).toBeDisabled();
|
expect(screen.getByRole("button", { name: /New workspace here/ })).toBeDisabled();
|
||||||
@@ -395,7 +436,7 @@ describe("LaneGitBar", () => {
|
|||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
await user.selectOptions(screen.getByLabelText("Switch branch…"), "development");
|
await pickBranch(user, "development");
|
||||||
await user.click(screen.getByRole("button", { name: "New workspace here" }));
|
await user.click(screen.getByRole("button", { name: "New workspace here" }));
|
||||||
expect(onNewLane).toHaveBeenCalledWith("development");
|
expect(onNewLane).toHaveBeenCalledWith("development");
|
||||||
});
|
});
|
||||||
@@ -413,18 +454,18 @@ describe("LaneGitBar", () => {
|
|||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
render(<LaneGitBar laneId={3} git={facts()} onChanged={vi.fn()} onNewLane={vi.fn()} />);
|
render(<LaneGitBar laneId={3} git={facts()} onChanged={vi.fn()} onNewLane={vi.fn()} />);
|
||||||
await user.click(screen.getByRole("button", { name: /Branch/ }));
|
await user.click(screen.getByRole("button", { name: /Branch/ }));
|
||||||
const select = await screen.findByLabelText("Switch branch…");
|
await screen.findByRole("button", { name: "Switch branch…" });
|
||||||
const newLane = () => screen.getByRole("button", { name: "New workspace here" });
|
const newLane = () => screen.getByRole("button", { name: "New workspace here" });
|
||||||
|
|
||||||
// This lane's own branch: its worktree is this lane.
|
// This lane's own branch: its worktree is this lane.
|
||||||
await user.selectOptions(select, "feat/thing");
|
await pickBranch(user, "feat/thing");
|
||||||
expect(newLane()).toBeDisabled();
|
expect(newLane()).toBeDisabled();
|
||||||
// Held by another worktree: git allows one worktree per branch (EBRANCHBUSY).
|
// Held by another worktree: git allows one worktree per branch (EBRANCHBUSY).
|
||||||
await user.selectOptions(select, "development");
|
await pickBranch(user, "development");
|
||||||
expect(newLane()).toBeDisabled();
|
expect(newLane()).toBeDisabled();
|
||||||
expect(newLane()).toHaveAttribute("title", expect.stringContaining("/repo/dev"));
|
expect(newLane()).toHaveAttribute("title", expect.stringContaining("/repo/dev"));
|
||||||
|
|
||||||
await user.selectOptions(select, "feat/free");
|
await pickBranch(user, "feat/free");
|
||||||
expect(newLane()).toBeEnabled();
|
expect(newLane()).toBeEnabled();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -443,13 +484,15 @@ describe("LaneGitBar", () => {
|
|||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
const { rerender } = render(<LaneGitBar laneId={3} git={facts()} onChanged={vi.fn()} />);
|
const { rerender } = render(<LaneGitBar laneId={3} git={facts()} onChanged={vi.fn()} />);
|
||||||
await user.click(screen.getByRole("button", { name: /Branch/ }));
|
await user.click(screen.getByRole("button", { name: /Branch/ }));
|
||||||
await user.selectOptions(await screen.findByLabelText("Switch branch…"), "development");
|
await pickBranch(user, "development");
|
||||||
|
|
||||||
rerender(<LaneGitBar laneId={4} git={facts()} onChanged={vi.fn()} />);
|
rerender(<LaneGitBar laneId={4} git={facts()} onChanged={vi.fn()} />);
|
||||||
expect(screen.queryByLabelText("Switch branch…")).toBeNull();
|
expect(screen.queryByLabelText("Switch branch…")).toBeNull();
|
||||||
await user.click(screen.getByRole("button", { name: /Branch/ }));
|
await user.click(screen.getByRole("button", { name: /Branch/ }));
|
||||||
await waitFor(() => expect(api.lanes.gitBranches).toHaveBeenCalledWith(4));
|
await waitFor(() => expect(api.lanes.gitBranches).toHaveBeenCalledWith(4));
|
||||||
expect(await screen.findByLabelText("Switch branch…")).toHaveValue("");
|
expect(await screen.findByRole("button", { name: "Switch branch…" })).toHaveTextContent(
|
||||||
|
"Switch branch…"
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("sends one branch-create for a double Enter", async () => {
|
it("sends one branch-create for a double Enter", async () => {
|
||||||
@@ -476,7 +519,7 @@ describe("LaneGitBar", () => {
|
|||||||
|
|
||||||
rerender(<LaneGitBar laneId={4} git={facts()} onChanged={vi.fn()} onNewLane={vi.fn()} />);
|
rerender(<LaneGitBar laneId={4} git={facts()} onChanged={vi.fn()} onNewLane={vi.fn()} />);
|
||||||
await user.click(screen.getByRole("button", { name: /Branch/ }));
|
await user.click(screen.getByRole("button", { name: /Branch/ }));
|
||||||
const select = await screen.findByLabelText("Switch branch…");
|
await screen.findByRole("button", { name: "Switch branch…" });
|
||||||
await waitFor(() => expect(api.lanes.gitBranches).toHaveBeenCalledWith(4));
|
await waitFor(() => expect(api.lanes.gitBranches).toHaveBeenCalledWith(4));
|
||||||
// Lane 3's answer lands late: its worktree held `development`.
|
// Lane 3's answer lands late: its worktree held `development`.
|
||||||
resolveOld({
|
resolveOld({
|
||||||
@@ -485,7 +528,7 @@ describe("LaneGitBar", () => {
|
|||||||
current: "feat/thing",
|
current: "feat/thing",
|
||||||
worktrees: [{ path: "/repo/dev", branch: "development" }],
|
worktrees: [{ path: "/repo/dev", branch: "development" }],
|
||||||
});
|
});
|
||||||
await user.selectOptions(select, "development");
|
await pickBranch(user, "development");
|
||||||
const newLane = screen.getByRole("button", { name: "New workspace here" });
|
const newLane = screen.getByRole("button", { name: "New workspace here" });
|
||||||
expect(newLane).toBeEnabled();
|
expect(newLane).toBeEnabled();
|
||||||
expect(newLane).not.toHaveAttribute("title", expect.stringContaining("/repo/dev"));
|
expect(newLane).not.toHaveAttribute("title", expect.stringContaining("/repo/dev"));
|
||||||
@@ -572,7 +615,7 @@ describe("merge (issue #45)", () => {
|
|||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
render(<LaneGitBar laneId={3} git={facts({ dirty: 0, untracked: 0 })} onChanged={vi.fn()} />);
|
render(<LaneGitBar laneId={3} git={facts({ dirty: 0, untracked: 0 })} onChanged={vi.fn()} />);
|
||||||
await user.click(screen.getByRole("button", { name: /Branch/ }));
|
await user.click(screen.getByRole("button", { name: /Branch/ }));
|
||||||
await user.selectOptions(await screen.findByLabelText("Switch branch…"), "development");
|
await pickBranch(user, "development");
|
||||||
await user.click(screen.getByRole("button", { name: "Merge into current" }));
|
await user.click(screen.getByRole("button", { name: "Merge into current" }));
|
||||||
await waitFor(() =>
|
await waitFor(() =>
|
||||||
expect(api.lanes.gitAction).toHaveBeenCalledWith(3, "merge", { branch: "development" })
|
expect(api.lanes.gitAction).toHaveBeenCalledWith(3, "merge", { branch: "development" })
|
||||||
|
|||||||
@@ -124,6 +124,11 @@ const isSettable = (m: unknown): m is PermissionMode => MODES.some(([v]) => v ==
|
|||||||
const OUTPUT_FETCH_MS = 1000;
|
const OUTPUT_FETCH_MS = 1000;
|
||||||
/** A tool with no known busy line counts as at work this long after its pane last drew. */
|
/** A tool with no known busy line counts as at work this long after its pane last drew. */
|
||||||
const OUTPUT_BUSY_MS = 2000;
|
const OUTPUT_BUSY_MS = 2000;
|
||||||
|
/** How long Stop stays up after every busy signal dropped: the screen's busy
|
||||||
|
* line comes and goes while the CLI redraws, and without a hold the button
|
||||||
|
* flickered between Stop and Send mid-turn. An explicit end (Stop pressed)
|
||||||
|
* is not held. */
|
||||||
|
const BUSY_HOLD_MS = 2500;
|
||||||
const SIDE_KEY = "agenthub.chat.panelWidth";
|
const SIDE_KEY = "agenthub.chat.panelWidth";
|
||||||
const clampSide = (f: number) => Math.round(Math.min(0.7, Math.max(0.2, f)) * 100) / 100;
|
const clampSide = (f: number) => Math.round(Math.min(0.7, Math.max(0.2, f)) * 100) / 100;
|
||||||
const outlineKey = (sessionId: string) => `agenthub.chat.panel.${sessionId}`;
|
const outlineKey = (sessionId: string) => `agenthub.chat.panel.${sessionId}`;
|
||||||
@@ -140,6 +145,23 @@ const storedSide = () => {
|
|||||||
* older; every Claude model since (Sonnet/Opus 4.6+, the 5 family) has 1M
|
* older; every Claude model since (Sonnet/Opus 4.6+, the 5 family) has 1M
|
||||||
* (LiteLLM's list, 2026-10). Null for another vendor's model. */
|
* (LiteLLM's list, 2026-10). Null for another vendor's model. */
|
||||||
// ponytail: a fixed rule, not a table; read LiteLLM's max_input_tokens if a new 200K model ships.
|
// ponytail: a fixed rule, not a table; read LiteLLM's max_input_tokens if a new 200K model ships.
|
||||||
|
/** `value`, except that a drop to false shows only once it has lasted
|
||||||
|
* `holdMs`; a change of `release` (Stop pressed) lets it drop at once. */
|
||||||
|
export function useHeldTrue(value: boolean, holdMs: number, release: unknown): boolean {
|
||||||
|
const [held, setHeld] = useState(value);
|
||||||
|
const released = useRef(release);
|
||||||
|
useEffect(() => {
|
||||||
|
if (value) return setHeld(true);
|
||||||
|
if (released.current !== release) {
|
||||||
|
released.current = release;
|
||||||
|
return setHeld(false);
|
||||||
|
}
|
||||||
|
const timer = window.setTimeout(() => setHeld(false), holdMs);
|
||||||
|
return () => window.clearTimeout(timer);
|
||||||
|
}, [value, holdMs, release]);
|
||||||
|
return value || held;
|
||||||
|
}
|
||||||
|
|
||||||
export function contextWindow(model: string | null): number | null {
|
export function contextWindow(model: string | null): number | null {
|
||||||
// A Codex/omp chat reports its own window (ContextMeter's `window`).
|
// A Codex/omp chat reports its own window (ContextMeter's `window`).
|
||||||
if (!model?.startsWith("claude-")) return null;
|
if (!model?.startsWith("claude-")) return null;
|
||||||
@@ -622,13 +644,18 @@ export const PaneChat = memo(function PaneChat({
|
|||||||
const [waiting, setWaiting] = useState(false);
|
const [waiting, setWaiting] = useState(false);
|
||||||
// Stop presses, for the conversation's live line (issue #74).
|
// Stop presses, for the conversation's live line (issue #74).
|
||||||
const [stops, setStops] = useState(0);
|
const [stops, setStops] = useState(0);
|
||||||
const readWorking = useCallback(
|
// Reads overlap (every throttled transcript change starts one); only the
|
||||||
() =>
|
// newest may land, or a slow older answer put back a state already left.
|
||||||
|
const readSeq = useRef(0);
|
||||||
|
const readWorking = useCallback(() => {
|
||||||
|
const seq = ++readSeq.current;
|
||||||
|
return (
|
||||||
api.agents
|
api.agents
|
||||||
.list({ session_id: sessionId, status: "working" })
|
.list({ session_id: sessionId, status: "working" })
|
||||||
// With session_id the server answers every agent of the session, its
|
// With session_id the server answers every agent of the session, its
|
||||||
// status filter ignored: check the status here.
|
// status filter ignored: check the status here.
|
||||||
.then((r) => {
|
.then((r) => {
|
||||||
|
if (seq !== readSeq.current) return;
|
||||||
const main = r.agents.filter((a) => a.type === "main");
|
const main = r.agents.filter((a) => a.type === "main");
|
||||||
// A fresh start, resume or `/clear` leaves main "working" at an
|
// A fresh start, resume or `/clear` leaves main "working" at an
|
||||||
// empty prompt (SessionStart's flag) until the first prompt.
|
// empty prompt (SessionStart's flag) until the first prompt.
|
||||||
@@ -637,9 +664,9 @@ export const PaneChat = memo(function PaneChat({
|
|||||||
);
|
);
|
||||||
setWaiting(main.some((a) => agentAwaitingReason(a) === "notification"));
|
setWaiting(main.some((a) => agentAwaitingReason(a) === "notification"));
|
||||||
})
|
})
|
||||||
.catch(() => {}),
|
.catch(() => {})
|
||||||
[sessionId]
|
);
|
||||||
);
|
}, [sessionId]);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void readWorking();
|
void readWorking();
|
||||||
}, [readWorking]);
|
}, [readWorking]);
|
||||||
@@ -714,7 +741,7 @@ export const PaneChat = memo(function PaneChat({
|
|||||||
}, 700);
|
}, 700);
|
||||||
return () => window.clearInterval(timer);
|
return () => window.clearInterval(timer);
|
||||||
}, [runId, onScreen]);
|
}, [runId, onScreen]);
|
||||||
const busy = working || screenBusy;
|
const busy = useHeldTrue(working || screenBusy, BUSY_HOLD_MS, stops);
|
||||||
const asking = waiting || menu !== null;
|
const asking = waiting || menu !== null;
|
||||||
// Gone until the pane redraws: no second press on a stale menu.
|
// Gone until the pane redraws: no second press on a stale menu.
|
||||||
const choose = (index: number) => {
|
const choose = (index: number) => {
|
||||||
|
|||||||
@@ -215,6 +215,10 @@ describe("PaneChat — Stop", () => {
|
|||||||
await act(async () => {
|
await act(async () => {
|
||||||
await vi.advanceTimersByTimeAsync(5000);
|
await vi.advanceTimersByTimeAsync(5000);
|
||||||
});
|
});
|
||||||
|
// Send comes back once nothing has said busy for BUSY_HOLD_MS.
|
||||||
|
await act(async () => {
|
||||||
|
await vi.advanceTimersByTimeAsync(2600);
|
||||||
|
});
|
||||||
await waitFor(() => expect(screen.queryByTestId("pane-chat-stop")).toBeNull());
|
await waitFor(() => expect(screen.queryByTestId("pane-chat-stop")).toBeNull());
|
||||||
} finally {
|
} finally {
|
||||||
vi.useRealTimers();
|
vi.useRealTimers();
|
||||||
@@ -233,6 +237,10 @@ describe("PaneChat — Stop", () => {
|
|||||||
await act(async () => {
|
await act(async () => {
|
||||||
await vi.advanceTimersByTimeAsync(800);
|
await vi.advanceTimersByTimeAsync(800);
|
||||||
});
|
});
|
||||||
|
// Send comes back once nothing has said busy for BUSY_HOLD_MS.
|
||||||
|
await act(async () => {
|
||||||
|
await vi.advanceTimersByTimeAsync(2600);
|
||||||
|
});
|
||||||
await waitFor(() => expect(screen.queryByTestId("pane-chat-stop")).toBeNull());
|
await waitFor(() => expect(screen.queryByTestId("pane-chat-stop")).toBeNull());
|
||||||
} finally {
|
} finally {
|
||||||
vi.useRealTimers();
|
vi.useRealTimers();
|
||||||
@@ -240,6 +248,32 @@ describe("PaneChat — Stop", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("does not flicker to Send when the busy line drops out for a moment", async () => {
|
||||||
|
vi.useFakeTimers({ shouldAdvanceTime: true });
|
||||||
|
vi.mocked(readRunBusy).mockReturnValue(true);
|
||||||
|
try {
|
||||||
|
renderChat();
|
||||||
|
expect(await screen.findByTestId("pane-chat-stop")).toBeInTheDocument();
|
||||||
|
// A redraw: the spinner line is gone for a poll or two.
|
||||||
|
vi.mocked(readRunBusy).mockReturnValue(false);
|
||||||
|
await act(async () => {
|
||||||
|
await vi.advanceTimersByTimeAsync(1500);
|
||||||
|
});
|
||||||
|
await act(async () => {
|
||||||
|
await vi.advanceTimersByTimeAsync(50);
|
||||||
|
});
|
||||||
|
expect(screen.getByTestId("pane-chat-stop")).toBeInTheDocument();
|
||||||
|
vi.mocked(readRunBusy).mockReturnValue(true);
|
||||||
|
await act(async () => {
|
||||||
|
await vi.advanceTimersByTimeAsync(800);
|
||||||
|
});
|
||||||
|
expect(screen.getByTestId("pane-chat-stop")).toBeInTheDocument();
|
||||||
|
} finally {
|
||||||
|
vi.useRealTimers();
|
||||||
|
vi.mocked(readRunBusy).mockReturnValue(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it("counts a tool with no known busy line as working while its pane draws", async () => {
|
it("counts a tool with no known busy line as working while its pane draws", async () => {
|
||||||
vi.useFakeTimers({ shouldAdvanceTime: true });
|
vi.useFakeTimers({ shouldAdvanceTime: true });
|
||||||
vi.mocked(readRunBusy).mockReturnValue(null);
|
vi.mocked(readRunBusy).mockReturnValue(null);
|
||||||
@@ -261,6 +295,10 @@ describe("PaneChat — Stop", () => {
|
|||||||
await act(async () => {
|
await act(async () => {
|
||||||
await vi.advanceTimersByTimeAsync(2800);
|
await vi.advanceTimersByTimeAsync(2800);
|
||||||
});
|
});
|
||||||
|
// Send comes back once nothing has said busy for BUSY_HOLD_MS.
|
||||||
|
await act(async () => {
|
||||||
|
await vi.advanceTimersByTimeAsync(2600);
|
||||||
|
});
|
||||||
await waitFor(() => expect(screen.queryByTestId("pane-chat-stop")).toBeNull());
|
await waitFor(() => expect(screen.queryByTestId("pane-chat-stop")).toBeNull());
|
||||||
} finally {
|
} finally {
|
||||||
vi.useRealTimers();
|
vi.useRealTimers();
|
||||||
|
|||||||
@@ -131,6 +131,8 @@ export function BackupSettings() {
|
|||||||
onChange={(v) => setHour(Number(v))}
|
onChange={(v) => setHour(Number(v))}
|
||||||
label={t("backup.hour")}
|
label={t("backup.hour")}
|
||||||
disabled={!state || owned?.hour}
|
disabled={!state || owned?.hour}
|
||||||
|
// 24 fixed hours: type-ahead finds one; a search box would only add noise.
|
||||||
|
searchable={false}
|
||||||
options={HOURS.map((h) => ({ value: String(h), label: `${pad(h)}:00` }))}
|
options={HOURS.map((h) => ({ value: String(h), label: `${pad(h)}:00` }))}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
/**
|
||||||
|
* @file MemberPolicySettings — Settings → AI tools → Tests on this server:
|
||||||
|
* the admin's server-wide switch that allows or blocks members' Claude from
|
||||||
|
* running test runners and container commands on this box
|
||||||
|
* (`GET/PUT /api/ai-tools/member-policy`, `server/lib/member-policy.js`,
|
||||||
|
* enforced by `scripts/member-guard.js`). Renders nothing outside server mode
|
||||||
|
* with per-user accounts (S3), or for anyone but an admin.
|
||||||
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { api, type MemberPolicyState } from "../../lib/api";
|
||||||
|
import { toast } from "../../lib/toast";
|
||||||
|
import { usePermissions } from "../AuthGate";
|
||||||
|
import { SettingsSection, SettingsSwitchRow } from "./SettingsRow";
|
||||||
|
|
||||||
|
export function MemberPolicySettings() {
|
||||||
|
const { t } = useTranslation("settings");
|
||||||
|
const { isAdmin, local } = usePermissions();
|
||||||
|
const [state, setState] = useState<MemberPolicyState | null>(null);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (local || !isAdmin) return;
|
||||||
|
api.aiTools
|
||||||
|
.memberPolicy()
|
||||||
|
.then(setState)
|
||||||
|
.catch(() => setState(null));
|
||||||
|
}, [local, isAdmin]);
|
||||||
|
|
||||||
|
if (local || !isAdmin || !state?.available) return null;
|
||||||
|
return (
|
||||||
|
<SettingsSection id="member-policy" title={t("memberPolicy.title")}>
|
||||||
|
<SettingsSwitchRow
|
||||||
|
label={t("memberPolicy.switch")}
|
||||||
|
description={t("memberPolicy.hint")}
|
||||||
|
checked={state.allowTests}
|
||||||
|
disabled={busy}
|
||||||
|
onChange={async (on) => {
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
setState(await api.aiTools.setMemberPolicy(on));
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err);
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</SettingsSection>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -222,6 +222,8 @@ export function ReportSettings() {
|
|||||||
value={String(form.value.hour)}
|
value={String(form.value.hour)}
|
||||||
onChange={(v) => set({ hour: Number(v) })}
|
onChange={(v) => set({ hour: Number(v) })}
|
||||||
label={t("reports.hour")}
|
label={t("reports.hour")}
|
||||||
|
// 24 fixed hours: type-ahead finds one; a search box would only add noise.
|
||||||
|
searchable={false}
|
||||||
options={HOURS.map((h) => ({ value: String(h), label: `${pad(h)}:00` }))}
|
options={HOURS.map((h) => ({ value: String(h), label: `${pad(h)}:00` }))}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
/**
|
||||||
|
* @file Tests for MemberPolicySettings — the admin's "allow members to run
|
||||||
|
* tests" switch in Settings → AI tools: hidden in local mode, for a non-admin
|
||||||
|
* and outside S3, and a toggle saves through PUT /api/ai-tools/member-policy.
|
||||||
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||||
|
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||||
|
import "../../../i18n";
|
||||||
|
import { MemberPolicySettings } from "../MemberPolicySettings";
|
||||||
|
import { api } from "../../../lib/api";
|
||||||
|
|
||||||
|
const perms = vi.hoisted(() => ({ isAdmin: true, local: false }));
|
||||||
|
vi.mock("../../AuthGate", () => ({ usePermissions: () => perms }));
|
||||||
|
vi.mock("../../../lib/api", () => ({
|
||||||
|
api: { aiTools: { memberPolicy: vi.fn(), setMemberPolicy: vi.fn() } },
|
||||||
|
}));
|
||||||
|
|
||||||
|
const get = vi.mocked(api.aiTools.memberPolicy);
|
||||||
|
const set = vi.mocked(api.aiTools.setMemberPolicy);
|
||||||
|
|
||||||
|
describe("MemberPolicySettings", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
perms.isAdmin = true;
|
||||||
|
perms.local = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lets an admin allow tests, off by default", async () => {
|
||||||
|
get.mockResolvedValue({ available: true, allowTests: false });
|
||||||
|
set.mockResolvedValue({ available: true, allowTests: true });
|
||||||
|
render(<MemberPolicySettings />);
|
||||||
|
const sw = await screen.findByRole("switch", { name: "Allow members to run tests" });
|
||||||
|
expect(sw).toHaveAttribute("aria-checked", "false");
|
||||||
|
fireEvent.click(sw);
|
||||||
|
await waitFor(() => expect(set).toHaveBeenCalledWith(true));
|
||||||
|
await waitFor(() => expect(sw).toHaveAttribute("aria-checked", "true"));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("hides outside S3", async () => {
|
||||||
|
get.mockResolvedValue({ available: false, allowTests: false });
|
||||||
|
const { container } = render(<MemberPolicySettings />);
|
||||||
|
await waitFor(() => expect(get).toHaveBeenCalled());
|
||||||
|
expect(container).toBeEmptyDOMElement();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("asks nothing for a non-admin or in local mode", () => {
|
||||||
|
perms.isAdmin = false;
|
||||||
|
const { container } = render(<MemberPolicySettings />);
|
||||||
|
expect(container).toBeEmptyDOMElement();
|
||||||
|
perms.isAdmin = true;
|
||||||
|
perms.local = true;
|
||||||
|
render(<MemberPolicySettings />);
|
||||||
|
expect(get).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -223,5 +223,9 @@
|
|||||||
"errors": "Steps that failed. A few are normal; many may mean the AI got stuck.",
|
"errors": "Steps that failed. A few are normal; many may mean the AI got stuck.",
|
||||||
"tokenFlow": "Input = sent to the AI, Output = written by the AI, Cache = reused from memory (cheaper)."
|
"tokenFlow": "Input = sent to the AI, Output = written by the AI, Cache = reused from memory (cheaper)."
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"select": {
|
||||||
|
"search": "Search…",
|
||||||
|
"noMatch": "No match"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -195,6 +195,7 @@
|
|||||||
"gitBar.pushHint": "git push -u origin HEAD",
|
"gitBar.pushHint": "git push -u origin HEAD",
|
||||||
"gitBar.switch": "Branch",
|
"gitBar.switch": "Branch",
|
||||||
"gitBar.switchApply": "Switch branch",
|
"gitBar.switchApply": "Switch branch",
|
||||||
|
"gitBar.branchSearch": "Search branches…",
|
||||||
"gitBar.switchCurrent": "current",
|
"gitBar.switchCurrent": "current",
|
||||||
"gitBar.remoteBranches": "On the remote — switching creates the local branch",
|
"gitBar.remoteBranches": "On the remote — switching creates the local branch",
|
||||||
"gitBar.switchDirty": "Commit, discard or shelve the uncommitted changes first",
|
"gitBar.switchDirty": "Commit, discard or shelve the uncommitted changes first",
|
||||||
|
|||||||
@@ -45,7 +45,6 @@
|
|||||||
"mute": "Mute",
|
"mute": "Mute",
|
||||||
"unmute": "Unmute",
|
"unmute": "Unmute",
|
||||||
"clearAlerts": "Clear alerts",
|
"clearAlerts": "Clear alerts",
|
||||||
"send": "Send",
|
|
||||||
"rename": "Click to rename {{name}}",
|
"rename": "Click to rename {{name}}",
|
||||||
"renameLabel": "Pet name",
|
"renameLabel": "Pet name",
|
||||||
"renameHint": "Enter to save · empty = default name",
|
"renameHint": "Enter to save · empty = default name",
|
||||||
@@ -53,13 +52,15 @@
|
|||||||
"attention": "Needs you",
|
"attention": "Needs you",
|
||||||
"attentionError": "errored",
|
"attentionError": "errored",
|
||||||
"attentionWaiting": "waiting on you",
|
"attentionWaiting": "waiting on you",
|
||||||
"attentionMore": "+{{count}} more in Sessions"
|
"attentionMore": "+{{count}} more in Sessions",
|
||||||
|
"label": "Pet {{name}}",
|
||||||
|
"close": "Close {{name}}",
|
||||||
|
"status": "Status — click to show the sessions that need you"
|
||||||
},
|
},
|
||||||
"bubbleDismiss": "Dismiss",
|
"bubbleDismiss": "Dismiss",
|
||||||
"open": "Open {{name}} companion",
|
"open": "Open {{name}} companion",
|
||||||
"closeAvatar": "Close {{name}}",
|
"closeAvatar": "Close {{name}}",
|
||||||
"avatarTitle": "{{name}} — Ctrl/⌘+B · drag or arrow keys to move · stroke to pat · right-click for pets",
|
"avatarTitle": "{{name}} — Ctrl/⌘+B · drag or arrow keys to move · stroke to pat · right-click for pets",
|
||||||
"startFailed": "Couldn't start that session: {{error}}",
|
|
||||||
"bubbleOpen": "Open",
|
"bubbleOpen": "Open",
|
||||||
"bubbleMore": "{{list}} +{{count}} more",
|
"bubbleMore": "{{list}} +{{count}} more",
|
||||||
"notify": {
|
"notify": {
|
||||||
@@ -174,23 +175,6 @@
|
|||||||
"watching": ["on the prowl 👀", "watching your sessions 😼", "eyes peeled 🐾"],
|
"watching": ["on the prowl 👀", "watching your sessions 😼", "eyes peeled 🐾"],
|
||||||
"sleeping": ["zzz… 💤", "wake me if something happens 😴", "curled up, all calm 🐈"],
|
"sleeping": ["zzz… 💤", "wake me if something happens 😴", "curled up, all calm 🐈"],
|
||||||
"idle": ["all quiet 😺", "ready when you are 🐾", "just vibing ✨"]
|
"idle": ["all quiet 😺", "ready when you are 🐾", "just vibing ✨"]
|
||||||
},
|
|
||||||
"chat": {
|
|
||||||
"label": "Chat with {{name}}",
|
|
||||||
"close": "Close {{name}}",
|
|
||||||
"new": "New chat",
|
|
||||||
"status": "Status — click to show the sessions that need you",
|
|
||||||
"endTitle": "End this chat?",
|
|
||||||
"endBody": "It is still writing a reply. A new chat stops this session.",
|
|
||||||
"endConfirm": "Stop and start new",
|
|
||||||
"openWorkspace": "Open in workspace",
|
|
||||||
"typing": "typing…",
|
|
||||||
"placeholder": "Ask {{name}} anything…",
|
|
||||||
"emptyTitle": "Hi! I'm {{name}} 🐾",
|
|
||||||
"emptyHint": "Ask me anything — I'll get {{tool}} to answer right here.",
|
|
||||||
"starting": "Starting {{tool}}…",
|
|
||||||
"notStarted": "Waiting for the session to start — if it asks something, open it in the workspace.",
|
|
||||||
"notSent": "Couldn't reach the session's terminal — try again, or open it in the workspace."
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"pricing": {
|
"pricing": {
|
||||||
@@ -1169,6 +1153,11 @@
|
|||||||
"vi": "Tiếng Việt"
|
"vi": "Tiếng Việt"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"memberPolicy": {
|
||||||
|
"title": "Tests on this server",
|
||||||
|
"switch": "Allow members to run tests",
|
||||||
|
"hint": "Off: members' Claude may not run test runners or container commands here (vitest, jest, phpunit, pytest, npm test, docker, …) — a few at once freeze this shared server, so they test on their own machine. On: they may. Commands typed by hand in a terminal and Codex are not checked either way."
|
||||||
|
},
|
||||||
"ompSharing": {
|
"ompSharing": {
|
||||||
"title": "Oh My Pi login sharing",
|
"title": "Oh My Pi login sharing",
|
||||||
"switch": "Share this server's Oh My Pi login",
|
"switch": "Share this server's Oh My Pi login",
|
||||||
|
|||||||
@@ -223,5 +223,9 @@
|
|||||||
"errors": "Các bước bị lỗi. Vài lỗi là bình thường; nhiều lỗi có thể nghĩa là AI bị kẹt.",
|
"errors": "Các bước bị lỗi. Vài lỗi là bình thường; nhiều lỗi có thể nghĩa là AI bị kẹt.",
|
||||||
"tokenFlow": "Input = gửi cho AI, Output = AI viết ra, Cache = dùng lại từ bộ nhớ đệm (rẻ hơn)."
|
"tokenFlow": "Input = gửi cho AI, Output = AI viết ra, Cache = dùng lại từ bộ nhớ đệm (rẻ hơn)."
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"select": {
|
||||||
|
"search": "Tìm…",
|
||||||
|
"noMatch": "Không có kết quả"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -195,6 +195,7 @@
|
|||||||
"gitBar.pushHint": "git push -u origin HEAD",
|
"gitBar.pushHint": "git push -u origin HEAD",
|
||||||
"gitBar.switch": "Nhánh",
|
"gitBar.switch": "Nhánh",
|
||||||
"gitBar.switchApply": "Chuyển nhánh",
|
"gitBar.switchApply": "Chuyển nhánh",
|
||||||
|
"gitBar.branchSearch": "Tìm nhánh…",
|
||||||
"gitBar.switchCurrent": "hiện tại",
|
"gitBar.switchCurrent": "hiện tại",
|
||||||
"gitBar.remoteBranches": "Trên remote — switch sẽ tạo nhánh local",
|
"gitBar.remoteBranches": "Trên remote — switch sẽ tạo nhánh local",
|
||||||
"gitBar.switchDirty": "Hãy commit, bỏ hoặc cất (shelve) các thay đổi chưa commit trước",
|
"gitBar.switchDirty": "Hãy commit, bỏ hoặc cất (shelve) các thay đổi chưa commit trước",
|
||||||
|
|||||||
@@ -45,7 +45,6 @@
|
|||||||
"mute": "Tắt tiếng",
|
"mute": "Tắt tiếng",
|
||||||
"unmute": "Bật tiếng",
|
"unmute": "Bật tiếng",
|
||||||
"clearAlerts": "Xoá cảnh báo",
|
"clearAlerts": "Xoá cảnh báo",
|
||||||
"send": "Gửi",
|
|
||||||
"rename": "Bấm để đổi tên {{name}}",
|
"rename": "Bấm để đổi tên {{name}}",
|
||||||
"renameLabel": "Tên pet",
|
"renameLabel": "Tên pet",
|
||||||
"renameHint": "Enter để lưu · để trống = tên mặc định",
|
"renameHint": "Enter để lưu · để trống = tên mặc định",
|
||||||
@@ -53,13 +52,15 @@
|
|||||||
"attention": "Cần bạn xem",
|
"attention": "Cần bạn xem",
|
||||||
"attentionError": "bị lỗi",
|
"attentionError": "bị lỗi",
|
||||||
"attentionWaiting": "đang chờ bạn",
|
"attentionWaiting": "đang chờ bạn",
|
||||||
"attentionMore": "+{{count}} phiên khác trong Phiên"
|
"attentionMore": "+{{count}} phiên khác trong Phiên",
|
||||||
|
"label": "Pet {{name}}",
|
||||||
|
"close": "Đóng {{name}}",
|
||||||
|
"status": "Trạng thái — bấm để xem các phiên cần bạn"
|
||||||
},
|
},
|
||||||
"bubbleDismiss": "Đóng",
|
"bubbleDismiss": "Đóng",
|
||||||
"open": "Mở trợ lý {{name}}",
|
"open": "Mở trợ lý {{name}}",
|
||||||
"closeAvatar": "Đóng {{name}}",
|
"closeAvatar": "Đóng {{name}}",
|
||||||
"avatarTitle": "{{name}} — Ctrl/⌘+B · kéo hoặc dùng phím mũi tên để di chuyển · vuốt qua lại để xoa đầu · chuột phải để chọn pet",
|
"avatarTitle": "{{name}} — Ctrl/⌘+B · kéo hoặc dùng phím mũi tên để di chuyển · vuốt qua lại để xoa đầu · chuột phải để chọn pet",
|
||||||
"startFailed": "Không khởi động được phiên: {{error}}",
|
|
||||||
"bubbleOpen": "Mở",
|
"bubbleOpen": "Mở",
|
||||||
"bubbleMore": "{{list}} và {{count}} phiên khác",
|
"bubbleMore": "{{list}} và {{count}} phiên khác",
|
||||||
"notify": {
|
"notify": {
|
||||||
@@ -162,23 +163,6 @@
|
|||||||
"watching": ["đang tuần tra 👀", "đang canh các phiên của bạn 😼", "mắt mở to 🐾"],
|
"watching": ["đang tuần tra 👀", "đang canh các phiên của bạn 😼", "mắt mở to 🐾"],
|
||||||
"sleeping": ["zzz… 💤", "có gì thì gọi mình dậy nhé 😴", "cuộn tròn, mọi thứ yên ổn 🐈"],
|
"sleeping": ["zzz… 💤", "có gì thì gọi mình dậy nhé 😴", "cuộn tròn, mọi thứ yên ổn 🐈"],
|
||||||
"idle": ["yên ắng quá 😺", "sẵn sàng khi bạn cần 🐾", "chill thôi ✨"]
|
"idle": ["yên ắng quá 😺", "sẵn sàng khi bạn cần 🐾", "chill thôi ✨"]
|
||||||
},
|
|
||||||
"chat": {
|
|
||||||
"label": "Trò chuyện với {{name}}",
|
|
||||||
"close": "Đóng {{name}}",
|
|
||||||
"new": "Cuộc trò chuyện mới",
|
|
||||||
"status": "Trạng thái — bấm để xem các phiên cần bạn",
|
|
||||||
"endTitle": "Kết thúc chat này?",
|
|
||||||
"endBody": "Nó vẫn đang trả lời. Chat mới sẽ dừng phiên này.",
|
|
||||||
"endConfirm": "Dừng và bắt đầu mới",
|
|
||||||
"openWorkspace": "Mở trong workspace",
|
|
||||||
"typing": "đang trả lời…",
|
|
||||||
"placeholder": "Hỏi {{name}} bất cứ điều gì…",
|
|
||||||
"emptyTitle": "Chào bạn! Mình là {{name}} 🐾",
|
|
||||||
"emptyHint": "Hỏi gì cũng được — mình sẽ nhờ {{tool}} trả lời ngay tại đây.",
|
|
||||||
"starting": "Đang khởi động {{tool}}…",
|
|
||||||
"notStarted": "Đang chờ phiên khởi động — nếu nó hỏi gì, hãy mở trong workspace.",
|
|
||||||
"notSent": "Không gửi được tới terminal của phiên — thử lại, hoặc mở trong workspace."
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"pricing": {
|
"pricing": {
|
||||||
@@ -1157,6 +1141,11 @@
|
|||||||
"vi": "Tiếng Việt"
|
"vi": "Tiếng Việt"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"memberPolicy": {
|
||||||
|
"title": "Chạy test trên server này",
|
||||||
|
"switch": "Cho phép thành viên chạy test",
|
||||||
|
"hint": "Tắt: Claude của thành viên không được chạy test hay lệnh container ở đây (vitest, jest, phpunit, pytest, npm test, docker, …) — vài lượt chạy cùng lúc là server dùng chung bị đứng, nên họ test trên máy của mình. Bật: được phép chạy. Lệnh tự gõ trong terminal và Codex thì không bị kiểm tra trong cả hai trường hợp."
|
||||||
|
},
|
||||||
"ompSharing": {
|
"ompSharing": {
|
||||||
"title": "Dùng chung đăng nhập Oh My Pi",
|
"title": "Dùng chung đăng nhập Oh My Pi",
|
||||||
"switch": "Dùng chung đăng nhập Oh My Pi của máy chủ",
|
"switch": "Dùng chung đăng nhập Oh My Pi của máy chủ",
|
||||||
|
|||||||
@@ -3782,6 +3782,14 @@ export const api = {
|
|||||||
method: "PUT",
|
method: "PUT",
|
||||||
body: JSON.stringify({ enabled }),
|
body: JSON.stringify({ enabled }),
|
||||||
}),
|
}),
|
||||||
|
/** GET /api/ai-tools/member-policy - what members' Claude may run (server mode, S3). */
|
||||||
|
memberPolicy: () => request<MemberPolicyState>("/ai-tools/member-policy"),
|
||||||
|
/** PUT /api/ai-tools/member-policy - an admin's switch. */
|
||||||
|
setMemberPolicy: (allowTests: boolean) =>
|
||||||
|
request<MemberPolicyState>("/ai-tools/member-policy", {
|
||||||
|
method: "PUT",
|
||||||
|
body: JSON.stringify({ allowTests }),
|
||||||
|
}),
|
||||||
/** POST /api/ai-tools/refresh - re-read the login shell's PATH and re-detect. */
|
/** POST /api/ai-tools/refresh - re-read the login shell's PATH and re-detect. */
|
||||||
refresh: () => request<AiToolsView>("/ai-tools/refresh", { method: "POST" }),
|
refresh: () => request<AiToolsView>("/ai-tools/refresh", { method: "POST" }),
|
||||||
/** PUT /api/ai-tools/:id - enable/disable a tool, make it the default, or set its default model ("" clears). */
|
/** PUT /api/ai-tools/:id - enable/disable a tool, make it the default, or set its default model ("" clears). */
|
||||||
@@ -5274,6 +5282,13 @@ export interface ArtifactCandidate {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** `GET /api/ai-tools/omp-sharing` — never a token. */
|
/** `GET /api/ai-tools/omp-sharing` — never a token. */
|
||||||
|
export interface MemberPolicyState {
|
||||||
|
/** Server mode with per-user Linux accounts (S3): the only place it applies. */
|
||||||
|
available: boolean;
|
||||||
|
/** May members' Claude run test runners and container commands? Off by default. */
|
||||||
|
allowTests: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export interface OmpSharingState {
|
export interface OmpSharingState {
|
||||||
/** Server mode with per-user Linux accounts (S3): the only place it applies. */
|
/** Server mode with per-user Linux accounts (S3): the only place it applies. */
|
||||||
available: boolean;
|
available: boolean;
|
||||||
|
|||||||
@@ -72,6 +72,7 @@ import { AiToolsSettings } from "../components/settings/AiToolsSettings";
|
|||||||
import { AgentInstructionsSettings } from "../components/settings/AgentInstructionsSettings";
|
import { AgentInstructionsSettings } from "../components/settings/AgentInstructionsSettings";
|
||||||
import { SessionDefaultsSettings } from "../components/settings/SessionDefaultsSettings";
|
import { SessionDefaultsSettings } from "../components/settings/SessionDefaultsSettings";
|
||||||
import { ArtifactsHubSettings } from "../components/settings/ArtifactsHubSettings";
|
import { ArtifactsHubSettings } from "../components/settings/ArtifactsHubSettings";
|
||||||
|
import { MemberPolicySettings } from "../components/settings/MemberPolicySettings";
|
||||||
import { ToolProfilesSettings } from "../components/settings/ToolProfilesSettings";
|
import { ToolProfilesSettings } from "../components/settings/ToolProfilesSettings";
|
||||||
import { PoolAccountSettings } from "../components/settings/PoolAccountSettings";
|
import { PoolAccountSettings } from "../components/settings/PoolAccountSettings";
|
||||||
import { OmpSharingSettings } from "../components/settings/OmpSharingSettings";
|
import { OmpSharingSettings } from "../components/settings/OmpSharingSettings";
|
||||||
@@ -317,6 +318,8 @@ export function SettingsHub({ section }: { section?: string } = {}) {
|
|||||||
<ArtifactsHubSettings />
|
<ArtifactsHubSettings />
|
||||||
{/* The host's omp login for members: server mode + S3, an admin's. */}
|
{/* The host's omp login for members: server mode + S3, an admin's. */}
|
||||||
<OmpSharingSettings />
|
<OmpSharingSettings />
|
||||||
|
{/* Members' test runs on this box: server mode + S3, an admin's. */}
|
||||||
|
<MemberPolicySettings />
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -7316,7 +7316,7 @@ exports[`screen snapshots > Settings — about 1`] = `
|
|||||||
<span
|
<span
|
||||||
class="text-status-warning/90"
|
class="text-status-warning/90"
|
||||||
>
|
>
|
||||||
UI build v2.8.67 (rebuild client to match)
|
UI build v2.8.70 (rebuild client to match)
|
||||||
</span>
|
</span>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+7
-5
@@ -89,7 +89,7 @@ A dashboard shared by several people runs in **server mode** ([SERVER-MODE.md](S
|
|||||||
| `POST /api/auth/login` `{username, password}` | `{user}` + the cookie; `401 EBADLOGIN`, `429 ETOOMANY`, `404 ENOTSERVER` in local mode |
|
| `POST /api/auth/login` `{username, password}` | `{user}` + the cookie; `401 EBADLOGIN`, `429 ETOOMANY`, `404 ENOTSERVER` in local mode |
|
||||||
| `POST /api/auth/logout` | `{ok: true}`, cookie cleared |
|
| `POST /api/auth/logout` | `{ok: true}`, cookie cleared |
|
||||||
|
|
||||||
In server mode a **developer** sees only rows of the workspaces they own (`lanes.owner_id`): every list, count, chart and cost answer is limited to them, and a request naming another user's session, agent, workspace or run (in the path, query or body) answers `404 ENOTFOUND`. Admin and analyst see everything; `GET /api/users` lists every account (`displayName`, `email`, `createdAt`, `logins`, `lastLoginAt` — the last login ever, kept after it ends —, `mustChangePassword` included) with its workspaces, sessions and `today` (since the viewer's local midnight — `?tz_offset=` is the browser's `getTimezoneOffset()`, UTC without it) / 7 / 30-day `{cost, tokens}`, `GET /api/users/storage[?refresh=1]` each user's home size (`{storage: {<id>: {bytes, files, partial, measuredAt} | null}, measuring}` — measured in the background, see [SERVER-MODE.md](SERVER-MODE.md)), `GET /api/users/tmux` each member's own tmux server under one Linux user per account (`{available, installed, matched, users: [{id, username, running, stuck?, version, pid, startedAt, sessions, busy, outdated}]}`; an admin's `POST /api/users/:id/tmux-restart` and `POST /api/users/tmux-restart-idle` restart them with `currentPassword`, audited, `409 ETMUXSTUCK` for a server that will not stop), `GET /api/host` the machine and each user's live share of it (`{at, hostname, os, uptime, cpu: {model, cores, load}, memory: {total, available, used, swapTotal, swapUsed}, disks: [{device, mount, type, kind: "HDD"|"SSD"|null, total, used, free}], homesMount, gpus: [{name, utilization, memoryUsed, memoryTotal}], usage: {cpu, sampleMs, groups: [{key, kind: "user"|"host"|"system", userId?, username?, displayName?, avatarAt?, cpu, memory, memoryPercent, processes, top: [{pid, name, cpu, memory}]}]} | null, bootTime, network: [{name, rx, tx}], diskIo: [{name, read, write}], temperatures: [{chip, label, celsius}], pressure: {cpu, memory, io: {some10, some60, full10} | null} | null (Linux PSI, `/proc/pressure/*`: % of the last 10 / 60 s tasks stalled on it), topProcesses: [{pid, name, cpu, memory, group}], process: {pid, node, uptime, rss, heapUsed}}`, `cpu` also carrying `physicalCores` and `perCore` (busy % per thread), rates in bytes/s over the sample — read-only, cached 3 s, `usage` null and the lists empty off Linux), `GET /api/host/history?range=1h|6h|24h` the CPU/memory history every server on Linux keeps in memory since it started (both read by the Dashboard's System Health tab and open to every signed-in user — `/host` is not staff-only) (`{range, intervalMs, running, startedAt, points: [{at, cpu, memory, memoryUsed, swapUsed, load1, users: {<id>: {cpu, memory}}}]}`, one point per 30 s, averaged to ≤ 360), `GET /api/users/:id/details[?tz_offset=]` one account in full for the Administration page's user detail (`{user (with logins, lastLoginAt), sessions: {total, active, firstAt, lastAt}, usage: {today, last7d, last30d, daily: [{date, cost, tokens}] (30 local days, oldest first), byTool, byModel: [{key, cost, tokens, sessions}]}, workspaces: [{id, title, cwd, tool, kind, branch, status, createdAt, sessions, lastActiveAt, livePanes}], panes: [{id, laneId, laneTitle, laneCwd, tool, model, account, proxy, sessionId, startedAt, status}], recentSessions (15, with cost/tokens), pool: {claude|codex|cursor: {pin, group, accounts: [{name, kind, email, expired, allowed, pinned, quota, quotaPercent, full, livePanes, spend5h, spend7d, userSpend5h, userSpend7d, userTokens5h, userTokens7d, userRuns7d, userLivePanes, userLastAt}]}}, audit}` — `user*` fields are only the runs on that user's workspaces; never a token; `404 ENOUSER`), and `GET /api/users/audit[?limit=]` the staff reads of developers' sessions and admins' account changes (kept `AGENTHUB_AUDIT_DAYS` days, default 180, `0` = forever) (`reader`/`owner` are usernames, `reader_display`/`owner_display` their display names or null) (all `403 EADMIN` for a developer). `GET /api/users` also carries each account's [spend quota](SERVER-MODE.md#spend-quotas) — `quotaSetting` (`{mode: "default"|"unlimited"|"custom", limit_usd?, period?, tz_offset?}`) and `quota` (the one binding them now with `spend`, `pct`, `exceeded`, `resetsAt`, or null) — and the top-level `quotaDefault`; an admin sets the default with `PUT /api/users/quota-default` `{quota: {limit_usd, period, tz_offset} | null, currentPassword}` and a user's own with `PATCH /api/users/:id` `{quota}` (`400 EBADQUOTA`). A workspace owner over their quota gets `429 EQUOTA` from `POST /api/run` and `POST /api/lanes/:id/start`. See [SERVER-MODE.md](SERVER-MODE.md#what-each-user-sees).
|
In server mode a **developer** sees only rows of the workspaces they own (`lanes.owner_id`): every list, count, chart and cost answer is limited to them, and a request naming another user's session, agent, workspace or run (in the path, query or body) answers `404 ENOTFOUND`. Admin and analyst see everything; `GET /api/users` lists every account (`displayName`, `email`, `createdAt`, `logins`, `lastLoginAt` — the last login ever, kept after it ends —, `mustChangePassword` included) with its workspaces, sessions and `today` (since the viewer's local midnight — `?tz_offset=` is the browser's `getTimezoneOffset()`, UTC without it) / 7 / 30-day `{cost, tokens}`, `GET /api/users/storage[?refresh=1]` each user's home size (`{storage: {<id>: {bytes, files, partial, measuredAt} | null}, measuring}` — measured in the background, see [SERVER-MODE.md](SERVER-MODE.md)), `GET /api/users/tmux` each member's own tmux server under one Linux user per account (`{available, installed, matched, users: [{id, username, running, stuck?, version, pid, startedAt, sessions, busy, outdated}]}`; an admin's `POST /api/users/:id/tmux-restart` and `POST /api/users/tmux-restart-idle` restart them with `currentPassword`, audited, `409 ETMUXSTUCK` for a server that will not stop), `GET /api/host` the machine and each user's live share of it (`{at, hostname, os, uptime, cpu: {model, cores, load}, memory: {total, available, used, swapTotal, swapUsed}, disks: [{device, mount, type, kind: "HDD"|"SSD"|null, total, used, free}], homesMount, gpus: [{name, utilization, memoryUsed, memoryTotal}], usage: {cpu, sampleMs, groups: [{key, kind: "user"|"host"|"system", userId?, username?, displayName?, avatarAt?, cpu, memory, memoryPercent, processes, top: [{pid, name, cpu, memory}]}]} | null, bootTime, network: [{name, rx, tx}], diskIo: [{name, read, write}], temperatures: [{chip, label, celsius}], pressure: {cpu, memory, io: {some10, some60, full10} | null} | null (Linux PSI, `/proc/pressure/*`: % of the last 10 / 60 s tasks stalled on it), topProcesses: [{pid, name, cpu, memory, group}], process: {pid, node, uptime, rss, heapUsed}}`, `cpu` also carrying `physicalCores` and `perCore` (busy % per thread), rates in bytes/s over the sample — read-only, cached 3 s, `usage` null and the lists empty off Linux), `GET /api/host/history?range=1h|6h|24h` the CPU/memory history every server on Linux keeps in memory since it started (both read by the Dashboard's System Health tab and open to every signed-in user — `/host` is not staff-only) (`{range, intervalMs, running, startedAt, points: [{at, cpu, memory, memoryUsed, swapUsed, load1, users: {<id>: {cpu, memory}}}]}`, one point per 30 s, averaged to ≤ 360), `GET /api/users/:id/details[?tz_offset=]` one account in full for the Administration page's user detail (`{user (with logins, lastLoginAt), sessions: {total, active, firstAt, lastAt}, usage: {today, last7d, last30d, daily: [{date, cost, tokens}] (30 local days, oldest first), byTool, byModel: [{key, cost, tokens, sessions}]}, workspaces: [{id, title, cwd, tool, kind, branch, status, createdAt, sessions, lastActiveAt, livePanes}], panes: [{id, laneId, laneTitle, laneCwd, tool, model, account, proxy, sessionId, startedAt, status}], recentSessions (15, with cost/tokens), pool: {claude|codex|cursor: {pin, group, accounts: [{name, kind, email, expired, allowed, pinned, quota, quotaPercent, full, livePanes, spend5h, spend7d, userSpend5h, userSpend7d, userTokens5h, userTokens7d, userRuns7d, userLivePanes, userLastAt}]}}, audit}` — `user*` fields are only the runs on that user's workspaces; never a token; `404 ENOUSER`), and `GET /api/users/audit[?limit=]` the staff reads of developers' sessions and admins' account changes (kept `AGENTHUB_AUDIT_DAYS` days, default 180, `0` = forever) (`reader`/`owner` are usernames, `reader_display`/`owner_display` their display names or null) (all `403 EADMIN` for a developer). `GET /api/users` also carries each account's [spend quota](SERVER-MODE.md#spend-quotas) — `quotaSetting` (`{mode: "default"|"unlimited"|"custom", limit_usd?, period?, tz_offset?}`) and `quota` (the one binding them now with `spend`, `pct`, `exceeded`, `resetsAt`, or null) — and the top-level `quotaDefault`; an admin sets the default with `PUT /api/users/quota-default` `{quota: {limit_usd, period, tz_offset} | null, currentPassword}` and a user's own with `PATCH /api/users/:id` `{quota}` (`400 EBADQUOTA`). A workspace owner over their quota gets `429 EQUOTA` from `POST /api/run` and `POST /api/lanes/:id/start`. Under one Linux user per account, a member already running `MAX_AI_SESSIONS` AI sessions (default 4) gets `429 ESESSIONLIMIT` from the same two routes. An admin allows or blocks members' test runs with `GET`/`PUT /api/ai-tools/member-policy` (`{available, allowTests}`, `409 ENOS3` outside it). See [SERVER-MODE.md](SERVER-MODE.md#what-each-user-sees).
|
||||||
|
|
||||||
An admin changes accounts with `POST /api/users` (`{username, password, role}` → `201 {user}`; the password may be any non-empty starting one and the account gets `mustChangePassword: true`), `GET /api/users/:id` (`{user, workspaces: [{id, title, cwd}]}`, the account with `logins` and `lastLoginAt`, for the Edit form; admin or analyst), `GET /api/users/:id/avatar` (that account's picture for the sidebar's owner groups and System Health — the one `/api/users` read EVERY signed-in account may make, a developer included, `nosniff` + `sandbox` CSP, `404 ENOAVATAR`; a workspace in `GET /api/lanes` carries `owner_avatar_at` to version its URL), `PATCH /api/users/:id` (any of `{displayName?, email?, role?, disabled?, password?, addWorkspaces?: [laneId], removeWorkspaces?: [laneId], signOut?: true}` — every field validated before any is written; `removeWorkspaces` leaves the workspace with no owner and answers `409 ENOTOWNED` for one the user does not own; an admin's own `password`/`signOut` keeps the login it came from; a password for SOMEONE ELSE may be anything non-empty and sets their `mustChangePassword`, the admin's OWN must pass every rule below, `400 EWEAKPASSWORD` with `rules`), `POST /api/users/:id/password` (`{password}`; that user's logins end; the same two bars) `POST /api/users/:id/workspaces` (`{laneId}`; the workspace's owner becomes that user) and `DELETE /api/users/:id` (delete for good: the user's logins, panes, workspaces' records, prefs and — under S3 — their Linux account and whole home, removed FIRST so a refusal (`502 EPROVISION`) leaves every row; `409 ESELF` for yourself, `409 ELASTADMIN` for the last admin; session history and the audit log stay). No path — this API or the `agenthub users` CLI — may demote, disable or delete the last enabled admin (`409 ELASTADMIN`). Every one carries `currentPassword`, the admin's own password: `403 EREAUTH` when it is wrong or missing, `429 ETOOMANY` after 10 wrong tries in 15 minutes. `409 ESELF` for an admin's own role or disable, `409 EEXISTS` for a taken name, `409 ENAMETAKEN` for a `displayName` that reads as another account's (the same rule as `PATCH /api/me/account`), `400 EBADUSER`/`EBADPASSWORD`/`EBADROLE`/`EBADEMAIL`/`EBADFIELD`, `404 ENOUSER`/`ENOLANE`; a developer or analyst gets `403 EADMIN`, and local mode `403 ELOCALMODE`. No answer carries a password hash; each change is written to the audit log.
|
An admin changes accounts with `POST /api/users` (`{username, password, role}` → `201 {user}`; the password may be any non-empty starting one and the account gets `mustChangePassword: true`), `GET /api/users/:id` (`{user, workspaces: [{id, title, cwd}]}`, the account with `logins` and `lastLoginAt`, for the Edit form; admin or analyst), `GET /api/users/:id/avatar` (that account's picture for the sidebar's owner groups and System Health — the one `/api/users` read EVERY signed-in account may make, a developer included, `nosniff` + `sandbox` CSP, `404 ENOAVATAR`; a workspace in `GET /api/lanes` carries `owner_avatar_at` to version its URL), `PATCH /api/users/:id` (any of `{displayName?, email?, role?, disabled?, password?, addWorkspaces?: [laneId], removeWorkspaces?: [laneId], signOut?: true}` — every field validated before any is written; `removeWorkspaces` leaves the workspace with no owner and answers `409 ENOTOWNED` for one the user does not own; an admin's own `password`/`signOut` keeps the login it came from; a password for SOMEONE ELSE may be anything non-empty and sets their `mustChangePassword`, the admin's OWN must pass every rule below, `400 EWEAKPASSWORD` with `rules`), `POST /api/users/:id/password` (`{password}`; that user's logins end; the same two bars) `POST /api/users/:id/workspaces` (`{laneId}`; the workspace's owner becomes that user) and `DELETE /api/users/:id` (delete for good: the user's logins, panes, workspaces' records, prefs and — under S3 — their Linux account and whole home, removed FIRST so a refusal (`502 EPROVISION`) leaves every row; `409 ESELF` for yourself, `409 ELASTADMIN` for the last admin; session history and the audit log stay). No path — this API or the `agenthub users` CLI — may demote, disable or delete the last enabled admin (`409 ELASTADMIN`). Every one carries `currentPassword`, the admin's own password: `403 EREAUTH` when it is wrong or missing, `429 ETOOMANY` after 10 wrong tries in 15 minutes. `409 ESELF` for an admin's own role or disable, `409 EEXISTS` for a taken name, `409 ENAMETAKEN` for a `displayName` that reads as another account's (the same rule as `PATCH /api/me/account`), `400 EBADUSER`/`EBADPASSWORD`/`EBADROLE`/`EBADEMAIL`/`EBADFIELD`, `404 ENOUSER`/`ENOLANE`; a developer or analyst gets `403 EADMIN`, and local mode `403 ELOCALMODE`. No answer carries a password hash; each change is written to the audit log.
|
||||||
|
|
||||||
@@ -197,9 +197,11 @@ POST /api/lanes/ensure
|
|||||||
```
|
```
|
||||||
|
|
||||||
`cwd` is optional — when missing or empty the server falls back to
|
`cwd` is optional — when missing or empty the server falls back to
|
||||||
`os.homedir()` (the user's home directory). This is how the Sagi mascot
|
`os.homedir()` (the user's home directory) — or, for a server-mode member
|
||||||
adopts its dedicated workspace without the client needing to know an absolute
|
with an own folder, `<their folder>/sagi`, made as them on first use (the
|
||||||
path. Existing cwd values still pass through unchanged. `pipeline` is
|
host's home lies outside their folder and would be `403 EFOLDER`). This is
|
||||||
|
how the Sagi mascot adopts its dedicated workspace without the client
|
||||||
|
needing to know an absolute path. Existing cwd values still pass through unchanged. `pipeline` is
|
||||||
optional and, like `title`, applies **only when a workspace is actually created**
|
optional and, like `title`, applies **only when a workspace is actually created**
|
||||||
— an existing workspace is returned untouched. Omitted, the workspace gets `default`.
|
— an existing workspace is returned untouched. Omitted, the workspace gets `default`.
|
||||||
`POST /api/lanes/worktree` and `POST /api/lanes` take the same optional
|
`POST /api/lanes/worktree` and `POST /api/lanes` take the same optional
|
||||||
@@ -3491,7 +3493,7 @@ POST /api/pets/featured/:id Download a FEATURED pet from codex-pets.ne
|
|||||||
DELETE /api/pets/:id Delete a pet (its folder under the data dir)
|
DELETE /api/pets/:id Delete a pet (its folder under the data dir)
|
||||||
```
|
```
|
||||||
|
|
||||||
The mascot (Sagi) can wear a pet: right-click it for **Hide pet**, **Size** (48–250 px), **Choose pet** (Sagi, the featured pets, your own — deletable), **Upload your own…**, **Import .codex-pet bundle…**, **Find more on codex-pets.net** and **Pet settings…**, which opens the pet block of **Settings → Appearance** (`/settings/appearance#pet`, `client/src/components/settings/PetSettings.tsx`; the old `/settings/pet` redirects there): show/hide, speech bubbles, size with a live preview, and the same choose / download / upload / import / delete as a grid. The menu and the page share `components/Sagi/usePets.ts`. Which pet is worn (with its display name, which the mascot goes by) and its size are viewer preferences in `localStorage` (`agenthub.pet`, `agenthub.pet.size`); a name the user types by clicking it in the mascot's chat window header is `agenthub.pet.names` (`{ [petId]: name }`, empty = the pet's own); the pets live on the server, one folder each under `<data dir>/pets/<id>/` with a `meta.json` AgentHub writes.
|
The mascot (Sagi) no longer chats (v2.8.68): a click opens its window — its name, a pat and the status of your sessions. It can wear a pet: right-click it for **Hide pet**, **Size** (48–250 px), **Choose pet** (Sagi, the featured pets, your own — deletable), **Upload your own…**, **Import .codex-pet bundle…**, **Find more on codex-pets.net** and **Pet settings…**, which opens the pet block of **Settings → Appearance** (`/settings/appearance#pet`, `client/src/components/settings/PetSettings.tsx`; the old `/settings/pet` redirects there): show/hide, speech bubbles, size with a live preview, and the same choose / download / upload / import / delete as a grid. The menu and the page share `components/Sagi/usePets.ts`. Which pet is worn (with its display name, which the mascot goes by) and its size are viewer preferences in `localStorage` (`agenthub.pet`, `agenthub.pet.size`); a name the user types by clicking it in the mascot's window header is `agenthub.pet.names` (`{ [petId]: name }`, empty = the pet's own); the pets live on the server, one folder each under `<data dir>/pets/<id>/` with a `meta.json` AgentHub writes.
|
||||||
|
|
||||||
A **Codex pet** is the `.codex-pet.zip` format Codex Desktop uses (the bundles shared on codex-pets.net and in `mileson/codex-pets`): `pet.json` (`id`, `displayName`, `description`, `spritesheetPath`) and `spritesheet.webp`, a 1536×1872 atlas of 8 columns × 9 rows of 192×208 frames — idle, run right, run left, waving, jumping, failed, waiting, running, review, with 6/8/8/4/5/8/6/6/6 frames — or a v2 atlas (`spriteVersionNumber: 2`, 1536×2288, 11 rows) whose rows 9–10 add 16 gaze poses, 22.5° apart clockwise from straight up. The mascot plays the row of its mood (an agent working → running, waiting for you → waiting, an error → failed, a finished turn → jumping, a live session at rest → review, idle/asleep → idle), and on an event it plays a moment over the mood (a new session → waving, a finish or a pat → jumping, a new subagent → review, a session waiting on you → waiting, an error → failed); dragged it runs left or right with the drag (and along the glide to its edge), hovered it waves, and a v2 pet at idle turns toward the pointer (back to idle 2 s after the pointer rests). Frames play at Codex's own timings (`codex-rs/tui/src/pets/model.rs`: e.g. running 120 ms a frame, its last 220 ms); idle plays at the drawn pace (280/110/110/140/140/320 ms), a sleeping pet at Codex's 6× slower one. Waving, jumping, failed and review are moments — they play three times and settle into idle; running, waiting and the run rows loop. Only the two files are read, at the archive root or one folder deep; an entry's size is checked from the zip's directory before it is inflated; the sheet must be a WebP of exactly one of those sizes (`400 EBADPET` otherwise). An uploaded image is shown still and bobs; SVG is refused because an SVG served from the dashboard's origin runs script.
|
A **Codex pet** is the `.codex-pet.zip` format Codex Desktop uses (the bundles shared on codex-pets.net and in `mileson/codex-pets`): `pet.json` (`id`, `displayName`, `description`, `spritesheetPath`) and `spritesheet.webp`, a 1536×1872 atlas of 8 columns × 9 rows of 192×208 frames — idle, run right, run left, waving, jumping, failed, waiting, running, review, with 6/8/8/4/5/8/6/6/6 frames — or a v2 atlas (`spriteVersionNumber: 2`, 1536×2288, 11 rows) whose rows 9–10 add 16 gaze poses, 22.5° apart clockwise from straight up. The mascot plays the row of its mood (an agent working → running, waiting for you → waiting, an error → failed, a finished turn → jumping, a live session at rest → review, idle/asleep → idle), and on an event it plays a moment over the mood (a new session → waving, a finish or a pat → jumping, a new subagent → review, a session waiting on you → waiting, an error → failed); dragged it runs left or right with the drag (and along the glide to its edge), hovered it waves, and a v2 pet at idle turns toward the pointer (back to idle 2 s after the pointer rests). Frames play at Codex's own timings (`codex-rs/tui/src/pets/model.rs`: e.g. running 120 ms a frame, its last 220 ms); idle plays at the drawn pace (280/110/110/140/140/320 ms), a sleeping pet at Codex's 6× slower one. Waving, jumping, failed and review are moments — they play three times and settle into idle; running, waiting and the run rows loop. Only the two files are read, at the archive root or one folder deep; an entry's size is checked from the zip's directory before it is inflated; the sheet must be a WebP of exactly one of those sizes (`400 EBADPET` otherwise). An uploaded image is shown still and bobs; SVG is refused because an SVG served from the dashboard's origin runs script.
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -531,7 +531,7 @@ the Changes panel carries the per-repo git **writes** the dashboard offers (`POS
|
|||||||
| **Pull** | `git pull --ff-only` | the branch has diverged (it reports, never merges) |
|
| **Pull** | `git pull --ff-only` | the branch has diverged (it reports, never merges) |
|
||||||
| **Push** (labelled **Publish branch** while the branch has no upstream) | `git push -u origin HEAD` | the remote rejects it |
|
| **Push** (labelled **Publish branch** while the branch has no upstream) | `git push -u origin HEAD` | the remote rejects it |
|
||||||
| **Publish…** (only when the repo has NO remote) | `git remote add origin -- <url>`, then `git push -u origin HEAD`; `origin` is removed again if the push fails | the repo has no commit yet, already has a remote, or the URL is not `https://` (no `user:token@`), `ssh://` or `user@host:path` |
|
| **Publish…** (only when the repo has NO remote) | `git remote add origin -- <url>`, then `git push -u origin HEAD`; `origin` is removed again if the push fails | the repo has no commit yet, already has a remote, or the URL is not `https://` (no `user:token@`), `ssh://` or `user@host:path` |
|
||||||
| **Branch** → **Switch branch** | `git switch …`; with uncommitted changes it first asks: **Bring my changes** (`changes: "carry"`, a plain `git switch`) or **Shelve and switch** (`changes: "shelve"`) | another worktree holds that branch; **Bring my changes** when git would overwrite a carried file (the files are listed and **Shelve and switch** offered instead) |
|
| **Branch** → **Switch branch** | the list is a combobox: it opens with a **Search branches…** box focused, typing narrows the local and remote branches to names containing the text, ↑/↓ move, Enter picks, Esc closes (#88 — every `Select` longer than 8 options gets the same search box); `git switch …`; with uncommitted changes it first asks: **Bring my changes** (`changes: "carry"`, a plain `git switch`) or **Shelve and switch** (`changes: "shelve"`) | another worktree holds that branch; **Bring my changes** when git would overwrite a carried file (the files are listed and **Shelve and switch** offered instead) |
|
||||||
| **Create & switch** | `git switch -c <name>` — uncommitted changes come along (the default on Enter while the tree is dirty) | the branch already exists |
|
| **Create & switch** | `git switch -c <name>` — uncommitted changes come along (the default on Enter while the tree is dirty) | the branch already exists |
|
||||||
| **Create** | `git branch <name>` — never switches | the branch already exists |
|
| **Create** | `git branch <name>` — never switches | the branch already exists |
|
||||||
| **Delete** | `git branch -d <name>` | it is protected (`main`, `master`, `development`, `develop`), current, held by another worktree, or has unmerged commits |
|
| **Delete** | `git branch -d <name>` | it is protected (`main`, `master`, `development`, `develop`), current, held by another worktree, or has unmerged commits |
|
||||||
|
|||||||
@@ -1156,6 +1156,52 @@ The same re-run installs `agenthub-provision delete`, behind the Users page's
|
|||||||
**Delete user**: until then a delete answers `502 EPROVISION` ("unknown
|
**Delete user**: until then a delete answers `502 EPROVISION` ("unknown
|
||||||
action: delete") and changes nothing.
|
action: delete") and changes nothing.
|
||||||
|
|
||||||
|
The same re-run (v2.8.68) keeps members from freezing the box — on 11-02
|
||||||
|
some 36 open sessions plus test runs filled RAM and the 4 GB swap on a slow
|
||||||
|
disk, and only a hard reset brought it back. The box is for writing code;
|
||||||
|
tests run on each member's own machine:
|
||||||
|
|
||||||
|
- **Memory.** `agenthub.service` gets `MemoryMax=85%`, `MemorySwapMax=1G`,
|
||||||
|
`OOMPolicy=continue` and `OOMScoreAdjust=-100`. The cgroup holds the server
|
||||||
|
AND every member pane, so memory runs out inside the unit first and the
|
||||||
|
kernel kills one process there by `oom_score_adj`: every command a member's
|
||||||
|
Claude runs is 1000 (`CLAUDE_CODE_SHELL_PREFIX=/opt/agenthub/libexec/tool-run`,
|
||||||
|
which also tells Claude why its command died), a Terminal tab 800, an AI
|
||||||
|
session 300, the dashboard and tmux -100. A test run dies; the session
|
||||||
|
holding the conversation does not. Codex runs its commands without that
|
||||||
|
wrapper, so its commands sit at 300 beside it.
|
||||||
|
- **CPU and disk.** `pane-launch` starts every member pane at `nice 10` and
|
||||||
|
best-effort IO priority 7 (the nice cannot be taken back without root), and
|
||||||
|
defaults `VITEST_MAX_THREADS` / `VITEST_MAX_FORKS` to 2.
|
||||||
|
- **No tests or containers from Claude.** A synchronous `PreToolUse` hook
|
||||||
|
(`/opt/agenthub/app/scripts/member-guard.js`, Bash only, `ah-*` accounts
|
||||||
|
only) denies `vitest`, `jest`, `phpunit`, `pest`, `pytest`, `npm test`,
|
||||||
|
`npm run test:*`, `php artisan test`, `go test`, `cargo test`, `docker`,
|
||||||
|
`podman`, … and tells Claude to leave testing to the member's machine.
|
||||||
|
A person typing a command in a Terminal tab, and Codex, are not covered.
|
||||||
|
An admin can allow them again with **Settings → AI tools → Tests on this
|
||||||
|
server** (`PUT /api/ai-tools/member-policy` `{allowTests}`, kept in
|
||||||
|
`/opt/agenthub/app/member-policy.json`, no file = blocked); it takes effect
|
||||||
|
at members' next Bash call, no restart.
|
||||||
|
- **At most 4 AI sessions per member at once** (`MAX_AI_SESSIONS=<n>` in
|
||||||
|
`/etc/agenthub/server.conf`, `0` = no limit). Counted from the account's
|
||||||
|
running processes (`server/lib/ai-procs.js`: `claude`, `codex`, `agy`,
|
||||||
|
`cursor-agent`, `omp`, a nested one counted once), so a `claude` typed into
|
||||||
|
a Terminal tab counts and the tab itself does not. A new pane past the
|
||||||
|
limit answers `429 ESESSIONLIMIT`; a session started by hand past it has
|
||||||
|
every prompt refused by the guard's `UserPromptSubmit` until another one
|
||||||
|
ends. The host account (admins) is not limited.
|
||||||
|
|
||||||
|
The memory caps apply to the running unit at once (the re-run does
|
||||||
|
`daemon-reload`); `OOMScoreAdjust`, the session limit on new panes and the
|
||||||
|
guard script in `/opt/agenthub/app` arrive with the next restart
|
||||||
|
(`sudo -n /usr/bin/systemctl restart --no-block agenthub.service` — panes
|
||||||
|
survive, `KillMode=process`). Panes started before the re-run keep their old
|
||||||
|
priority. Check with
|
||||||
|
`systemctl show agenthub.service -p MemoryMax,MemorySwapMax,OOMPolicy`
|
||||||
|
and, in a new member pane, `ps -o ni= -p $$; cat /proc/self/oom_score_adj;
|
||||||
|
echo $CLAUDE_CODE_SHELL_PREFIX`.
|
||||||
|
|
||||||
### Deleting a user
|
### Deleting a user
|
||||||
|
|
||||||
Users page → Edit → **Delete user** (the admin's own password, then a red
|
Users page → Edit → **Delete user** (the admin's own password, then a red
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "agenthub",
|
"name": "agenthub",
|
||||||
"version": "2.8.67",
|
"version": "2.8.70",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "agenthub",
|
"name": "agenthub",
|
||||||
"version": "2.8.67",
|
"version": "2.8.70",
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"license": "UNLICENSED",
|
"license": "UNLICENSED",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "agenthub",
|
"name": "agenthub",
|
||||||
"version": "2.8.67",
|
"version": "2.8.70",
|
||||||
"bin": {
|
"bin": {
|
||||||
"agenthub": "bin/agenthub.js"
|
"agenthub": "bin/agenthub.js"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,194 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* @file member-guard.js
|
||||||
|
* @description Server mode, one Linux user per account (S3): the members'
|
||||||
|
* SYNCHRONOUS Claude Code hook, from /etc/claude-code/managed-settings.json
|
||||||
|
* (install-system.sh), run AS the member from /opt/agenthub/app. The box is
|
||||||
|
* shared and small, so it is for writing code only:
|
||||||
|
*
|
||||||
|
* - `PreToolUse` (matcher Bash): denies a test run or a container command
|
||||||
|
* (`isHeavy`) — the member commits, pulls the branch on their own machine
|
||||||
|
* and runs it there. Claude gets the reason and stops trying. An admin
|
||||||
|
* can allow them again (Settings → AI tools, server/lib/member-policy.js).
|
||||||
|
* - `UserPromptSubmit`: a member runs at most `MAX_AI_SESSIONS` AI sessions
|
||||||
|
* at once (server/lib/ai-procs.js). The dashboard refuses a pane past the
|
||||||
|
* limit itself (ESESSIONLIMIT); this catches a `claude` typed into a
|
||||||
|
* Terminal tab, by refusing every prompt of a session that is not among
|
||||||
|
* the account's oldest `limit` ones.
|
||||||
|
*
|
||||||
|
* Only `ah-*` accounts are guarded (the host is an admin's). Fail-open like
|
||||||
|
* every AgentHub hook: any error lets the call through. A person's own
|
||||||
|
* terminal commands and Codex (whose hooks are opt-in) are not covered.
|
||||||
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
|
*/
|
||||||
|
|
||||||
|
const os = require("node:os");
|
||||||
|
const path = require("node:path");
|
||||||
|
|
||||||
|
const procs = require(path.join(__dirname, "..", "server", "lib", "ai-procs.js"));
|
||||||
|
const policy = require(path.join(__dirname, "..", "server", "lib", "member-policy.js"));
|
||||||
|
|
||||||
|
/** Test runners and container tools, by the program a command segment runs. */
|
||||||
|
const HEAVY_BINS = new Set([
|
||||||
|
"vitest",
|
||||||
|
"jest",
|
||||||
|
"mocha",
|
||||||
|
"ava",
|
||||||
|
"karma",
|
||||||
|
"playwright",
|
||||||
|
"cypress",
|
||||||
|
"phpunit",
|
||||||
|
"pest",
|
||||||
|
"paratest",
|
||||||
|
"pytest",
|
||||||
|
"py.test",
|
||||||
|
"tox",
|
||||||
|
"nox",
|
||||||
|
"rspec",
|
||||||
|
"ctest",
|
||||||
|
"nextest",
|
||||||
|
"docker",
|
||||||
|
"docker-compose",
|
||||||
|
"podman",
|
||||||
|
"podman-compose",
|
||||||
|
"nerdctl",
|
||||||
|
"buildah",
|
||||||
|
"kind",
|
||||||
|
"minikube",
|
||||||
|
"k3d",
|
||||||
|
]);
|
||||||
|
/** Wrappers that run the command after them. */
|
||||||
|
const PASS_THROUGH = new Set([
|
||||||
|
"sudo",
|
||||||
|
"env",
|
||||||
|
"time",
|
||||||
|
"nice",
|
||||||
|
"ionice",
|
||||||
|
"timeout",
|
||||||
|
"npx",
|
||||||
|
"bunx",
|
||||||
|
"pnpx",
|
||||||
|
"exec",
|
||||||
|
"command",
|
||||||
|
"xvfb-run",
|
||||||
|
"stdbuf",
|
||||||
|
]);
|
||||||
|
const TEST_SCRIPT = /^(test|tests|e2e|spec|unit|integration)([:._-].*)?$/;
|
||||||
|
|
||||||
|
/** The words of one command segment, wrappers and env assignments dropped. */
|
||||||
|
function words(segment) {
|
||||||
|
const w = segment
|
||||||
|
.trim()
|
||||||
|
.split(/\s+/)
|
||||||
|
.map((x) => x.replace(/^['"]|['"]$/g, ""))
|
||||||
|
.filter(Boolean);
|
||||||
|
for (;;) {
|
||||||
|
while (w.length && /^[A-Za-z_][A-Za-z0-9_]*=/.test(w[0])) w.shift();
|
||||||
|
if (!w.length || !PASS_THROUGH.has(path.basename(w[0]))) break;
|
||||||
|
w.shift();
|
||||||
|
// a wrapper's own options and arguments (`timeout 60`, `nice -n 5`, `npx -y`)
|
||||||
|
while (w.length && (w[0].startsWith("-") || /^\d+[smhd]?$/.test(w[0]))) w.shift();
|
||||||
|
}
|
||||||
|
return w;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Does this one segment run tests or containers? */
|
||||||
|
function segmentIsHeavy(segment) {
|
||||||
|
const w = words(segment);
|
||||||
|
if (!w.length) return false;
|
||||||
|
const bin = path.basename(w[0]);
|
||||||
|
const [a, b] = [w[1] || "", w[2] || ""];
|
||||||
|
if (HEAVY_BINS.has(bin)) return true;
|
||||||
|
if (["npm", "pnpm", "yarn", "bun", "composer"].includes(bin)) {
|
||||||
|
if (a === "t" || TEST_SCRIPT.test(a)) return true; // npm test, yarn test:unit
|
||||||
|
if (["run", "run-script", "exec", "x", "dlx"].includes(a)) {
|
||||||
|
return TEST_SCRIPT.test(b) || HEAVY_BINS.has(path.basename(b));
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (["go", "deno", "dotnet", "mvn", "mvnw", "gradle", "gradlew"].includes(bin)) {
|
||||||
|
return ["test", "verify", "integrationTest"].includes(a);
|
||||||
|
}
|
||||||
|
if (bin === "cargo") return a === "test" || a === "nextest";
|
||||||
|
if (bin === "make") return w.slice(1).some((x) => TEST_SCRIPT.test(x) || x === "check");
|
||||||
|
if (bin === "php" || bin === "artisan") {
|
||||||
|
const rest = bin === "php" ? w.slice(1) : w;
|
||||||
|
const i = rest.findIndex((x) => /(^|\/)artisan$/.test(x));
|
||||||
|
if (i !== -1 && TEST_SCRIPT.test(rest[i + 1] || "")) return true;
|
||||||
|
return rest.some((x) => HEAVY_BINS.has(path.basename(x)));
|
||||||
|
}
|
||||||
|
if (/^python[0-9.]*$/.test(bin)) {
|
||||||
|
const i = w.indexOf("-m");
|
||||||
|
return i !== -1 && ["pytest", "unittest", "tox", "nox"].includes(w[i + 1]);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Does a Bash command run tests or containers anywhere in it? */
|
||||||
|
function isHeavy(command) {
|
||||||
|
if (typeof command !== "string") return false;
|
||||||
|
// Inner `bash -c "..."` / `sh -c '...'` bodies are checked too.
|
||||||
|
const inner = [...command.matchAll(/\b(?:ba|z|da)?sh\s+-l?c\s+(["'])([\s\S]*?)\1/g)].map(
|
||||||
|
(m) => m[2]
|
||||||
|
);
|
||||||
|
return [command, ...inner].some((c) => c.split(/&&|\|\||[;|&\n()]|\$\(|`/).some(segmentIsHeavy));
|
||||||
|
}
|
||||||
|
|
||||||
|
const DENY_REASON =
|
||||||
|
"AgentHub: this shared server is for writing code only — tests and containers " +
|
||||||
|
"(vitest, jest, phpunit, pytest, npm test, docker, …) are not run here, because " +
|
||||||
|
"a few of them at once freeze it for the whole team. Do not try another way to " +
|
||||||
|
"run them. Finish the change, tell the user which tests to run, and let them " +
|
||||||
|
"commit, pull the branch on their own machine and test there.";
|
||||||
|
|
||||||
|
/** The admin's switch (Settings → AI tools), beside this script in the app root. */
|
||||||
|
const testsAllowed = () =>
|
||||||
|
policy.read(path.join(__dirname, "..", policy.FILE)).allowTests === true;
|
||||||
|
|
||||||
|
function guardTool(input, allowed = testsAllowed()) {
|
||||||
|
if (allowed || input.tool_name !== "Bash" || !isHeavy(input.tool_input?.command)) return;
|
||||||
|
process.stdout.write(
|
||||||
|
JSON.stringify({
|
||||||
|
hookSpecificOutput: {
|
||||||
|
hookEventName: "PreToolUse",
|
||||||
|
permissionDecision: "deny",
|
||||||
|
permissionDecisionReason: DENY_REASON,
|
||||||
|
},
|
||||||
|
}) + "\n"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function guardPrompt() {
|
||||||
|
const limit = procs.sessionLimit();
|
||||||
|
if (!limit) return;
|
||||||
|
const mine = procs.sessionOf(process.pid);
|
||||||
|
if (mine == null) return;
|
||||||
|
const all = procs.sessionsOf(process.getuid());
|
||||||
|
const at = all.findIndex((s) => s.pid === mine);
|
||||||
|
if (at < limit) return; // among the oldest `limit` (or not found: let it be)
|
||||||
|
process.stderr.write(
|
||||||
|
`AgentHub: you are running ${all.length} AI sessions — at most ${limit} at once on this ` +
|
||||||
|
"server, and this one started past the limit. Close another session (or this one) first.\n"
|
||||||
|
);
|
||||||
|
process.exit(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
function main() {
|
||||||
|
const event = process.argv[2];
|
||||||
|
let raw = "";
|
||||||
|
process.stdin.setEncoding("utf8");
|
||||||
|
process.stdin.on("data", (c) => (raw += c));
|
||||||
|
process.stdin.on("end", () => {
|
||||||
|
try {
|
||||||
|
if (!os.userInfo().username.startsWith("ah-")) return;
|
||||||
|
if (event === "PreToolUse") guardTool(JSON.parse(raw || "{}"));
|
||||||
|
else if (event === "UserPromptSubmit") guardPrompt();
|
||||||
|
} catch {
|
||||||
|
/* fail-open */
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (require.main === module) main();
|
||||||
|
|
||||||
|
module.exports = { isHeavy, segmentIsHeavy, words, guardTool };
|
||||||
@@ -16,11 +16,13 @@
|
|||||||
# /opt/agenthub/libexec/member-hook Claude hook forwarder for members
|
# /opt/agenthub/libexec/member-hook Claude hook forwarder for members
|
||||||
# /opt/agenthub/libexec/write-as writes in a member's home AS the member (sudo -u)
|
# /opt/agenthub/libexec/write-as writes in a member's home AS the member (sudo -u)
|
||||||
# /opt/agenthub/libexec/pane-launch starts a member's pane from its launch file
|
# /opt/agenthub/libexec/pane-launch starts a member's pane from its launch file
|
||||||
|
# /opt/agenthub/libexec/tool-run wraps each command a member's Claude runs (OOM: it dies first)
|
||||||
# /opt/agenthub/libexec/tmux-member a member's tmux, with their running server's own binary
|
# /opt/agenthub/libexec/tmux-member a member's tmux, with their running server's own binary
|
||||||
# /etc/sudoers.d/agenthub checked with visudo before it is installed
|
# /etc/sudoers.d/agenthub checked with visudo before it is installed
|
||||||
# /etc/claude-code/managed-settings.json the member hooks (never over a foreign file)
|
# /etc/claude-code/managed-settings.json the member hooks (never over a foreign file)
|
||||||
# /proc hidepid=invisible,gid=agenthub-proc (now and in /etc/fstab)
|
# /proc hidepid=invisible,gid=agenthub-proc (now and in /etc/fstab)
|
||||||
# /etc/systemd/system/agenthub.service reads the host-owned env file
|
# /etc/systemd/system/agenthub.service reads the host-owned env file; caps
|
||||||
|
# the cgroup (server + every pane) at 85% of RAM, 1G swap
|
||||||
# @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
# @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
@@ -106,6 +108,7 @@ chmod 0644 /etc/agenthub/server.conf
|
|||||||
install -m 0755 -o root -g root "$ROOT/scripts/server/agenthub-provision.sh" /opt/agenthub/sbin/agenthub-provision
|
install -m 0755 -o root -g root "$ROOT/scripts/server/agenthub-provision.sh" /opt/agenthub/sbin/agenthub-provision
|
||||||
install -m 0755 -o root -g root "$ROOT/scripts/server/write-as.sh" /opt/agenthub/libexec/write-as
|
install -m 0755 -o root -g root "$ROOT/scripts/server/write-as.sh" /opt/agenthub/libexec/write-as
|
||||||
install -m 0755 -o root -g root "$ROOT/scripts/server/pane-launch.sh" /opt/agenthub/libexec/pane-launch
|
install -m 0755 -o root -g root "$ROOT/scripts/server/pane-launch.sh" /opt/agenthub/libexec/pane-launch
|
||||||
|
install -m 0755 -o root -g root "$ROOT/scripts/server/tool-run.sh" /opt/agenthub/libexec/tool-run
|
||||||
install -m 0755 -o root -g root "$ROOT/scripts/server/tmux-member.sh" /opt/agenthub/libexec/tmux-member
|
install -m 0755 -o root -g root "$ROOT/scripts/server/tmux-member.sh" /opt/agenthub/libexec/tmux-member
|
||||||
|
|
||||||
# ── node + the hook handler, for members (host-owned: the dashboard refreshes them) ──
|
# ── node + the hook handler, for members (host-owned: the dashboard refreshes them) ──
|
||||||
@@ -163,7 +166,14 @@ else
|
|||||||
const hook = (e) => [{ ...(["PreToolUse","PostToolUse"].includes(e) ? { matcher: "*" } : {}),
|
const hook = (e) => [{ ...(["PreToolUse","PostToolUse"].includes(e) ? { matcher: "*" } : {}),
|
||||||
hooks: [{ type: "command", command: `/opt/agenthub/libexec/member-hook ${e}`, timeout: 5,
|
hooks: [{ type: "command", command: `/opt/agenthub/libexec/member-hook ${e}`, timeout: 5,
|
||||||
...(["PreToolUse","PostToolUse"].includes(e) ? { async: true } : {}) }] }];
|
...(["PreToolUse","PostToolUse"].includes(e) ? { async: true } : {}) }] }];
|
||||||
process.stdout.write(JSON.stringify({ hooks: Object.fromEntries(ev.map((e) => [e, hook(e)])) }, null, 2) + "\n");
|
const hooks = Object.fromEntries(ev.map((e) => [e, hook(e)]));
|
||||||
|
// The guard is synchronous (it decides): no test runs or containers from
|
||||||
|
// Claude, and at most MAX_AI_SESSIONS AI sessions per member (member-guard.js).
|
||||||
|
const guard = (e) => ({ type: "command", timeout: 5,
|
||||||
|
command: `/opt/agenthub/tools/bin/node /opt/agenthub/app/scripts/member-guard.js ${e}` });
|
||||||
|
hooks.PreToolUse.push({ matcher: "Bash", hooks: [guard("PreToolUse")] });
|
||||||
|
hooks.UserPromptSubmit.push({ hooks: [guard("UserPromptSubmit")] });
|
||||||
|
process.stdout.write(JSON.stringify({ hooks }, null, 2) + "\n");
|
||||||
' >"$MANAGED"
|
' >"$MANAGED"
|
||||||
chmod 0644 "$MANAGED"
|
chmod 0644 "$MANAGED"
|
||||||
sha256sum "$MANAGED" >"$MANAGED_MARK"
|
sha256sum "$MANAGED" >"$MANAGED_MARK"
|
||||||
@@ -203,6 +213,14 @@ ExecStart=/usr/bin/env \${AGENTHUB_NODE} \${AGENTHUB_ROOT}/server/index.js
|
|||||||
Restart=always
|
Restart=always
|
||||||
RestartSec=3
|
RestartSec=3
|
||||||
KillMode=process
|
KillMode=process
|
||||||
|
# Every member pane lives in this cgroup. Capped below RAM with little swap
|
||||||
|
# (swap on a slow disk froze the whole box on 11-02), a runaway test run is
|
||||||
|
# OOM-killed inside the unit — pane-launch makes panes the first pick — and
|
||||||
|
# "continue" keeps the dashboard up when that happens.
|
||||||
|
MemoryMax=85%
|
||||||
|
MemorySwapMax=1G
|
||||||
|
OOMPolicy=continue
|
||||||
|
OOMScoreAdjust=-100
|
||||||
|
|
||||||
[Install]
|
[Install]
|
||||||
WantedBy=multi-user.target
|
WantedBy=multi-user.target
|
||||||
|
|||||||
@@ -10,7 +10,8 @@
|
|||||||
# the environment and execs the command; no shell ever parses a value.
|
# the environment and execs the command; no shell ever parses a value.
|
||||||
#
|
#
|
||||||
# The file is NUL-separated: the number of environment entries, the
|
# The file is NUL-separated: the number of environment entries, the
|
||||||
# NAME=value entries, then the argv (none = a login shell).
|
# NAME=value entries, then the argv (none = a login shell). Every pane starts
|
||||||
|
# at a lower CPU/IO priority, Claude's commands as the kernel's first OOM pick.
|
||||||
# @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
# @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
set -eu
|
set -eu
|
||||||
f=${1:?usage: pane-launch <launch file>}
|
f=${1:?usage: pane-launch <launch file>}
|
||||||
@@ -22,6 +23,27 @@ for kv in "${parts[@]:1:n}"; do
|
|||||||
[[ "$kv" =~ ^[A-Za-z_][A-Za-z0-9_]*= ]] || { echo "pane-launch: bad variable" >&2; exit 2; }
|
[[ "$kv" =~ ^[A-Za-z_][A-Za-z0-9_]*= ]] || { echo "pane-launch: bad variable" >&2; exit 2; }
|
||||||
export "$kv"
|
export "$kv"
|
||||||
done
|
done
|
||||||
|
# A member's pane shares the 4-core box with the dashboard and every other
|
||||||
|
# member, so it runs at a lower CPU/IO priority (a member cannot take the
|
||||||
|
# nice back: that needs root). Memory (11-02: 36 sessions plus test runs
|
||||||
|
# filled RAM and swap, and the box froze until a hard reset): the unit caps
|
||||||
|
# its cgroup below RAM (install-system.sh), and inside it the kernel kills
|
||||||
|
# by oom_score_adj — the dashboard and tmux at the unit's -100, an AI session
|
||||||
|
# at 300, a Terminal tab at 800, and every command Claude runs at 1000
|
||||||
|
# through CLAUDE_CODE_SHELL_PREFIX=tool-run. So a runaway command dies and
|
||||||
|
# the session that holds the conversation is told why; a member who lowers
|
||||||
|
# their own value can go no lower than -100.
|
||||||
|
renice -n 10 -p $$ >/dev/null 2>&1 || true
|
||||||
|
ionice -c 2 -n 7 -p $$ >/dev/null 2>&1 || true
|
||||||
|
TOOL_RUN=/opt/agenthub/libexec/tool-run
|
||||||
|
[ -x "$TOOL_RUN" ] && export CLAUDE_CODE_SHELL_PREFIX="${CLAUDE_CODE_SHELL_PREFIX:-$TOOL_RUN}"
|
||||||
|
# Vitest ≤ 3 starts one worker per core by default and these override the
|
||||||
|
# config; a member may set their own in the pane.
|
||||||
|
export VITEST_MAX_THREADS="${VITEST_MAX_THREADS:-2}" VITEST_MAX_FORKS="${VITEST_MAX_FORKS:-2}"
|
||||||
argv=("${parts[@]:n+1}")
|
argv=("${parts[@]:n+1}")
|
||||||
if [ ${#argv[@]} -eq 0 ]; then exec -l "${SHELL:-/bin/bash}"; fi
|
if [ ${#argv[@]} -eq 0 ]; then
|
||||||
|
echo 800 >/proc/self/oom_score_adj 2>/dev/null || true
|
||||||
|
exec -l "${SHELL:-/bin/bash}"
|
||||||
|
fi
|
||||||
|
echo 300 >/proc/self/oom_score_adj 2>/dev/null || true
|
||||||
exec "${argv[@]}"
|
exec "${argv[@]}"
|
||||||
|
|||||||
Executable
+23
@@ -0,0 +1,23 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# @file tool-run.sh
|
||||||
|
# @description Server mode, one Linux user per account (S3): installed as
|
||||||
|
# /opt/agenthub/libexec/tool-run and named by CLAUDE_CODE_SHELL_PREFIX in
|
||||||
|
# every member pane (pane-launch). Claude Code runs each Bash command (and
|
||||||
|
# hook) as `tool-run '<command string>'`. The command runs at the kernel's
|
||||||
|
# highest OOM preference, so when memory runs out it is the command — a test
|
||||||
|
# run, a build — that is killed, never the Claude session above it that holds
|
||||||
|
# the conversation (pane-launch puts that one at 300). When the cgroup's OOM
|
||||||
|
# count moved while the command ran, Claude is told why it died.
|
||||||
|
# @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
|
[ $# -gt 0 ] || exec /bin/bash
|
||||||
|
echo 1000 >/proc/self/oom_score_adj 2>/dev/null
|
||||||
|
cg=$(sed -n 's/^0:://p' /proc/self/cgroup 2>/dev/null)
|
||||||
|
events=/sys/fs/cgroup${cg}/memory.events
|
||||||
|
kills() { sed -n 's/^oom_kill //p' "$events" 2>/dev/null; }
|
||||||
|
before=$(kills)
|
||||||
|
/bin/bash -c "$1"
|
||||||
|
rc=$?
|
||||||
|
if [ "$rc" -ne 0 ] && [ -n "$before" ] && [ "$(kills)" != "$before" ]; then
|
||||||
|
printf '\n%s\n' "AgentHub: the server ran out of memory, so a process of this command was stopped (exit $rc). This shared server is for writing code only: do not run tests, containers or other heavy jobs here — tell the user to run them on their own machine." >&2
|
||||||
|
fi
|
||||||
|
exit "$rc"
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
/**
|
||||||
|
* @file Tests for the S3 members' synchronous hook (scripts/member-guard.js)
|
||||||
|
* and the AI-session counter it shares with spawnRun (server/lib/ai-procs.js):
|
||||||
|
* which Bash commands count as a test run or a container command, and which
|
||||||
|
* processes count as one top-level AI session, read from a fake /proc.
|
||||||
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
|
*/
|
||||||
|
|
||||||
|
const { describe, it } = require("node:test");
|
||||||
|
const assert = require("node:assert/strict");
|
||||||
|
const fs = require("node:fs");
|
||||||
|
const os = require("node:os");
|
||||||
|
const path = require("node:path");
|
||||||
|
|
||||||
|
const { isHeavy, guardTool } = require("../../scripts/member-guard.js");
|
||||||
|
const procs = require("../lib/ai-procs.js");
|
||||||
|
|
||||||
|
describe("member-guard: a test run or a container command", () => {
|
||||||
|
const heavy = [
|
||||||
|
"npx vitest run",
|
||||||
|
"vitest",
|
||||||
|
"npm test",
|
||||||
|
"npm t",
|
||||||
|
"npm run test:unit",
|
||||||
|
"pnpm test",
|
||||||
|
"yarn test --watch=false",
|
||||||
|
"bun test",
|
||||||
|
"cd client && NODE_ENV=test npx vitest related src/a.ts --run",
|
||||||
|
"timeout 300 npx jest",
|
||||||
|
"./vendor/bin/phpunit --filter Foo",
|
||||||
|
"php artisan test",
|
||||||
|
"vendor/bin/pest",
|
||||||
|
"python -m pytest -q",
|
||||||
|
"pytest tests/",
|
||||||
|
"go test ./...",
|
||||||
|
"cargo test",
|
||||||
|
"docker compose up -d",
|
||||||
|
"docker-compose up",
|
||||||
|
"podman run alpine",
|
||||||
|
"node_modules/.bin/playwright test",
|
||||||
|
'bash -c "npm run e2e"',
|
||||||
|
"make test",
|
||||||
|
"npm run build && npm test 2>&1 | tail -20",
|
||||||
|
];
|
||||||
|
const fine = [
|
||||||
|
"npm run build",
|
||||||
|
"npm install -D vitest",
|
||||||
|
"git commit -m 'add vitest tests'",
|
||||||
|
"rg -n docker src/",
|
||||||
|
"cat tests/a.test.js",
|
||||||
|
"ls node_modules/.bin/vitest",
|
||||||
|
"echo run npm test later",
|
||||||
|
"npx tsc -b",
|
||||||
|
"npm run lint",
|
||||||
|
"php artisan migrate:status",
|
||||||
|
"go build ./...",
|
||||||
|
"git log --grep test",
|
||||||
|
"composer install",
|
||||||
|
];
|
||||||
|
for (const c of heavy) it(`refuses: ${c}`, () => assert.equal(isHeavy(c), true));
|
||||||
|
for (const c of fine) it(`allows: ${c}`, () => assert.equal(isHeavy(c), false));
|
||||||
|
|
||||||
|
it("answers Claude Code with a PreToolUse deny and its reason, and nothing for the rest", () => {
|
||||||
|
const out = [];
|
||||||
|
const write = process.stdout.write;
|
||||||
|
process.stdout.write = (s) => out.push(String(s));
|
||||||
|
try {
|
||||||
|
guardTool({ tool_name: "Bash", tool_input: { command: "npm test" } });
|
||||||
|
guardTool({ tool_name: "Bash", tool_input: { command: "npm run build" } });
|
||||||
|
guardTool({ tool_name: "Read", tool_input: { file_path: "/x" } });
|
||||||
|
} finally {
|
||||||
|
process.stdout.write = write;
|
||||||
|
}
|
||||||
|
assert.equal(out.length, 1);
|
||||||
|
const o = JSON.parse(out[0]).hookSpecificOutput;
|
||||||
|
assert.equal(o.hookEventName, "PreToolUse");
|
||||||
|
assert.equal(o.permissionDecision, "deny");
|
||||||
|
assert.match(o.permissionDecisionReason, /own machine/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lets everything through once an admin allows tests", () => {
|
||||||
|
const out = [];
|
||||||
|
const write = process.stdout.write;
|
||||||
|
process.stdout.write = (s) => out.push(String(s));
|
||||||
|
try {
|
||||||
|
guardTool({ tool_name: "Bash", tool_input: { command: "npx vitest run" } }, true);
|
||||||
|
} finally {
|
||||||
|
process.stdout.write = write;
|
||||||
|
}
|
||||||
|
assert.equal(out.length, 0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/** A fake /proc: one dir per pid with stat, cmdline; owner is this process (uid). */
|
||||||
|
function fakeProc(list) {
|
||||||
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "ah-proc-"));
|
||||||
|
for (const p of list) {
|
||||||
|
const d = path.join(root, String(p.pid));
|
||||||
|
fs.mkdirSync(d);
|
||||||
|
// pid (comm) state ppid ... field 22 = starttime (index 19 after the ')')
|
||||||
|
const fields = ["S", String(p.ppid), ...Array(17).fill("0"), String(p.start)];
|
||||||
|
fs.writeFileSync(path.join(d, "stat"), `${p.pid} (x y) ${fields.join(" ")}`);
|
||||||
|
fs.writeFileSync(path.join(d, "cmdline"), p.argv.join("\0") + "\0");
|
||||||
|
}
|
||||||
|
return root;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("member-policy: the admin's switch, read by the guard", () => {
|
||||||
|
it("blocks tests without a file, and writes a world-readable one", () => {
|
||||||
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "ah-app-"));
|
||||||
|
const prev = process.env.AGENTHUB_APP_ROOT;
|
||||||
|
process.env.AGENTHUB_APP_ROOT = dir;
|
||||||
|
try {
|
||||||
|
const policy = require("../lib/member-policy.js");
|
||||||
|
assert.equal(policy.read().allowTests, false);
|
||||||
|
fs.writeFileSync(path.join(dir, policy.FILE), "not json");
|
||||||
|
assert.equal(policy.read().allowTests, false);
|
||||||
|
assert.equal(policy.setAllowTests(true).allowTests, true);
|
||||||
|
const f = path.join(dir, policy.FILE);
|
||||||
|
assert.equal(fs.statSync(f).mode & 0o777, 0o644);
|
||||||
|
assert.equal(policy.read(f).allowTests, true);
|
||||||
|
assert.equal(policy.setAllowTests(false).allowTests, false);
|
||||||
|
} finally {
|
||||||
|
if (prev === undefined) delete process.env.AGENTHUB_APP_ROOT;
|
||||||
|
else process.env.AGENTHUB_APP_ROOT = prev;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("ai-procs: a member's top-level AI sessions", () => {
|
||||||
|
const uid = process.getuid();
|
||||||
|
const root = fakeProc([
|
||||||
|
{ pid: 10, ppid: 1, start: 1, argv: ["tmux"] },
|
||||||
|
{ pid: 11, ppid: 10, start: 5, argv: ["/opt/agenthub/tools/bin/claude", "--resume", "x"] },
|
||||||
|
{ pid: 12, ppid: 11, start: 6, argv: ["/bin/bash", "-c", "claude -p hi"] },
|
||||||
|
{ pid: 13, ppid: 12, start: 7, argv: ["claude", "-p", "hi"] }, // nested: not counted
|
||||||
|
{ pid: 20, ppid: 10, start: 3, argv: ["bash"] }, // a Terminal tab
|
||||||
|
{ pid: 21, ppid: 20, start: 4, argv: ["node", "/x/@openai/codex/bin/codex.js"] },
|
||||||
|
{ pid: 22, ppid: 21, start: 4, argv: ["/x/codex", "--yolo"] }, // Codex's native child
|
||||||
|
{ pid: 30, ppid: 10, start: 9, argv: ["omp", "auth-gateway", "serve"] }, // a server
|
||||||
|
{ pid: 31, ppid: 10, start: 8, argv: ["vitest"] },
|
||||||
|
]);
|
||||||
|
|
||||||
|
it("counts each session once, oldest first, and no tool server", () => {
|
||||||
|
assert.deepEqual(
|
||||||
|
procs.sessionsOf(uid, root).map((s) => s.pid),
|
||||||
|
[21, 11]
|
||||||
|
);
|
||||||
|
});
|
||||||
|
it("finds the session a hook process runs under", () => {
|
||||||
|
assert.equal(procs.sessionOf(13, root), 11);
|
||||||
|
assert.equal(procs.sessionOf(31, root), null);
|
||||||
|
});
|
||||||
|
it("reads the limit from server.conf, 4 without it, 0 = off", () => {
|
||||||
|
const conf = path.join(root, "server.conf");
|
||||||
|
assert.equal(procs.sessionLimit(conf), 4);
|
||||||
|
fs.writeFileSync(conf, "HOST_USER=x\nMAX_AI_SESSIONS=0\n");
|
||||||
|
assert.equal(procs.sessionLimit(conf), 0);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -192,3 +192,31 @@ describe("a member's new project folder", () => {
|
|||||||
assert.equal(fs.existsSync(path.join(TMP, "host-new")), false);
|
assert.equal(fs.existsSync(path.join(TMP, "host-new")), false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("the pet chat's workspace for a member", () => {
|
||||||
|
it("is <their folder>/sagi, made as them — never the host's home (EFOLDER)", async () => {
|
||||||
|
const { fakeSudo } = require("./fixtures/fake-sudo");
|
||||||
|
const sudo = fakeSudo();
|
||||||
|
runAs.__setExecImpl(sudo.exec);
|
||||||
|
const login = await fetch(`${BASE}/api/auth/login`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ username: "mia", password: "member-pass-1" }),
|
||||||
|
});
|
||||||
|
const cm = login.headers.get("set-cookie").split(";")[0];
|
||||||
|
const ensure = () =>
|
||||||
|
fetch(`${BASE}/api/lanes/ensure`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json", cookie: cm },
|
||||||
|
body: JSON.stringify({ title: "Sagi" }),
|
||||||
|
}).then(async (r) => ({ status: r.status, body: await r.json() }));
|
||||||
|
const first = await ensure();
|
||||||
|
assert.equal(first.status, 201, JSON.stringify(first.body));
|
||||||
|
const want = fs.realpathSync(path.join(USERS, "mia", "sagi"));
|
||||||
|
assert.equal(first.body.lane.cwd, want);
|
||||||
|
const mk = sudo.calls.find((c) => c.includes("/opt/agenthub/libexec/write-as"));
|
||||||
|
assert.ok(mk && mk.includes("ah-mia") && mk.includes("mkdir"));
|
||||||
|
const again = await ensure();
|
||||||
|
assert.equal(again.body.lane.id, first.body.lane.id, "the same workspace next time");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -0,0 +1,106 @@
|
|||||||
|
/**
|
||||||
|
* @file Regression test: a turn the transcript says has ended lands the main
|
||||||
|
* agent in Waiting even when Claude Code never sends the Stop hook (it skips
|
||||||
|
* it in some turns — only the prompt-suggestion helper's SubagentStop
|
||||||
|
* arrives). Before, the agent stayed `working` until the idle Notification a
|
||||||
|
* minute later and the chat showed Stop on a finished answer. A turn end OLDER
|
||||||
|
* than the last prompt, or one read on new work (UserPromptSubmit), must not.
|
||||||
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
|
*/
|
||||||
|
|
||||||
|
const { describe, it, before, after } = require("node:test");
|
||||||
|
const assert = require("node:assert/strict");
|
||||||
|
const path = require("path");
|
||||||
|
const fs = require("fs");
|
||||||
|
const os = require("os");
|
||||||
|
const http = require("http");
|
||||||
|
|
||||||
|
const STAMP = `turn-end-${Date.now()}-${process.pid}`;
|
||||||
|
const TMP = path.join(os.tmpdir(), STAMP);
|
||||||
|
process.env.DASHBOARD_DB_PATH = path.join(TMP, "dashboard.db");
|
||||||
|
process.env.CLAUDE_HOME = path.join(TMP, "home");
|
||||||
|
process.env.DASHBOARD_DATA_DIR = path.join(TMP, "data");
|
||||||
|
|
||||||
|
const { createApp, startServer } = require("../index");
|
||||||
|
const { stmts } = require("../db");
|
||||||
|
|
||||||
|
let server;
|
||||||
|
let BASE;
|
||||||
|
|
||||||
|
function post(body) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const url = new URL("/api/hooks/event", BASE);
|
||||||
|
const payload = JSON.stringify(body);
|
||||||
|
const r = http.request(
|
||||||
|
{
|
||||||
|
hostname: url.hostname,
|
||||||
|
port: url.port,
|
||||||
|
path: url.pathname,
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json", "Content-Length": Buffer.byteLength(payload) },
|
||||||
|
},
|
||||||
|
(res) => {
|
||||||
|
res.resume();
|
||||||
|
res.on("end", () => resolve(res.statusCode));
|
||||||
|
}
|
||||||
|
);
|
||||||
|
r.on("error", reject);
|
||||||
|
r.write(payload);
|
||||||
|
r.end();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const line = (o) => JSON.stringify(o) + "\n";
|
||||||
|
const user = (text) => ({ type: "user", message: { role: "user", content: text } });
|
||||||
|
const reply = (id) => ({
|
||||||
|
type: "assistant",
|
||||||
|
message: { id, model: "claude-sonnet-5", content: [{ type: "text", text: "ok" }], usage: { input_tokens: 1, output_tokens: 1 } },
|
||||||
|
});
|
||||||
|
const turnEnd = (ts) => ({ type: "system", subtype: "turn_duration", durationMs: 900, timestamp: ts });
|
||||||
|
|
||||||
|
before(async () => {
|
||||||
|
fs.mkdirSync(TMP, { recursive: true });
|
||||||
|
server = await startServer(createApp(), 0);
|
||||||
|
BASE = `http://127.0.0.1:${server.address().port}`;
|
||||||
|
});
|
||||||
|
|
||||||
|
after(() => {
|
||||||
|
server?.close();
|
||||||
|
fs.rmSync(TMP, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("a finished turn without a Stop hook", () => {
|
||||||
|
it("lands in Waiting on the next hook that reads the turn's end", async () => {
|
||||||
|
const cwd = path.join(TMP, "work");
|
||||||
|
fs.mkdirSync(cwd, { recursive: true });
|
||||||
|
const sid = "12121212-3434-5656-7878-909090909090";
|
||||||
|
const tpath = path.join(TMP, `${sid}.jsonl`);
|
||||||
|
const event = (extra = {}) => ({ session_id: sid, cwd, transcript_path: tpath, ...extra });
|
||||||
|
const main = () => stmts.getAgent.get(`${sid}-main`);
|
||||||
|
|
||||||
|
fs.writeFileSync(tpath, line(user("hello")));
|
||||||
|
assert.equal(await post({ hook_type: "UserPromptSubmit", data: event({ prompt: "hello" }) }), 200);
|
||||||
|
assert.equal(main().status, "working");
|
||||||
|
|
||||||
|
// The turn ends; Claude Code writes turn_duration but sends no Stop —
|
||||||
|
// only a helper agent's SubagentStop arrives.
|
||||||
|
fs.appendFileSync(tpath, line(reply("m1")) + line(turnEnd(new Date().toISOString())));
|
||||||
|
assert.equal(
|
||||||
|
await post({ hook_type: "SubagentStop", data: event({ agent_id: "a1", agent_type: "" }) }),
|
||||||
|
200
|
||||||
|
);
|
||||||
|
assert.equal(main().status, "waiting");
|
||||||
|
assert.equal(stmts.getSession.get(sid).awaiting_reason, "stop");
|
||||||
|
|
||||||
|
// A new prompt: that turn's old end must not end the new one.
|
||||||
|
await new Promise((r) => setTimeout(r, 5));
|
||||||
|
fs.appendFileSync(tpath, line(user("again")));
|
||||||
|
assert.equal(await post({ hook_type: "UserPromptSubmit", data: event({ prompt: "again" }) }), 200);
|
||||||
|
assert.equal(main().status, "working");
|
||||||
|
assert.equal(
|
||||||
|
await post({ hook_type: "SubagentStop", data: event({ agent_id: "a2", agent_type: "" }) }),
|
||||||
|
200
|
||||||
|
);
|
||||||
|
assert.equal(main().status, "working", "no turn end newer than the prompt yet");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
/**
|
||||||
|
* @file ai-procs.js
|
||||||
|
* @description Server mode, one Linux user per account (S3): counts the AI
|
||||||
|
* Tools sessions a Linux account is running RIGHT NOW, from /proc — whoever
|
||||||
|
* started them (a dashboard pane, or `claude` typed into a terminal tab).
|
||||||
|
* A session is a process whose program is one of the catalog tools' binaries
|
||||||
|
* (`claude`, `codex`, `agy`, `cursor-agent`, `omp`) with no such process
|
||||||
|
* among its ancestors, so a nested `claude -p`, Codex's native child behind
|
||||||
|
* its npm launcher, or a subagent never counts twice. Used by `spawnRun`
|
||||||
|
* (ESESSIONLIMIT before a pane starts) and by `scripts/member-guard.js`
|
||||||
|
* (refuses the prompt of a session over the limit started by hand), which
|
||||||
|
* runs AS the member and sees only that member's processes (hidepid) — that
|
||||||
|
* is all it needs. Pure Node built-ins: the guard runs from /opt/agenthub/app.
|
||||||
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
|
*/
|
||||||
|
|
||||||
|
const fs = require("node:fs");
|
||||||
|
const path = require("node:path");
|
||||||
|
|
||||||
|
/** The catalog tools' binary names (server/data/ai-tools/*.json `binary`). */
|
||||||
|
const AI_BINS = new Set(["claude", "codex", "agy", "cursor-agent", "omp"]);
|
||||||
|
/** Interpreters whose first argument names the tool (an npm launcher). */
|
||||||
|
const INTERPRETERS = new Set(["node", "nodejs", "bun", "deno"]);
|
||||||
|
/** A tool's server, not a session (the host's omp share). */
|
||||||
|
const NOT_A_SESSION = new Set(["auth-gateway", "auth-broker", "mcp", "daemon"]);
|
||||||
|
|
||||||
|
const DEFAULT_LIMIT = 4;
|
||||||
|
const CONF = "/etc/agenthub/server.conf";
|
||||||
|
|
||||||
|
const base = (p) => path.basename(String(p || "")).replace(/\.(c|m)?js$/, "");
|
||||||
|
|
||||||
|
/** `{pid, ppid, uid, start, ai}` for one /proc entry, or null once it is gone. */
|
||||||
|
function readProc(pid, procRoot = "/proc") {
|
||||||
|
try {
|
||||||
|
const dir = path.join(procRoot, String(pid));
|
||||||
|
const stat = fs.readFileSync(path.join(dir, "stat"), "utf8");
|
||||||
|
// comm may hold spaces and parentheses: the fields start after the LAST ')'.
|
||||||
|
const f = stat.slice(stat.lastIndexOf(")") + 2).split(" ");
|
||||||
|
const argv = fs.readFileSync(path.join(dir, "cmdline"), "utf8").split("\0").filter(Boolean);
|
||||||
|
const uid = fs.statSync(dir).uid;
|
||||||
|
let name = base(argv[0]);
|
||||||
|
let rest = argv.slice(1);
|
||||||
|
if (INTERPRETERS.has(name)) {
|
||||||
|
const i = rest.findIndex((a) => !a.startsWith("-"));
|
||||||
|
name = i === -1 ? "" : base(rest[i]);
|
||||||
|
rest = i === -1 ? [] : rest.slice(i + 1);
|
||||||
|
}
|
||||||
|
const ai = AI_BINS.has(name) && !rest.some((a) => NOT_A_SESSION.has(a));
|
||||||
|
return { pid: Number(pid), ppid: Number(f[1]), start: Number(f[19]), uid, ai };
|
||||||
|
} catch {
|
||||||
|
return null; // exited while we looked, or not ours to read
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The account's top-level AI sessions, oldest first.
|
||||||
|
* @param {number} uid
|
||||||
|
* @returns {{pid:number, start:number}[]}
|
||||||
|
*/
|
||||||
|
function sessionsOf(uid, procRoot = "/proc") {
|
||||||
|
const procs = new Map();
|
||||||
|
let names = [];
|
||||||
|
try {
|
||||||
|
names = fs.readdirSync(procRoot);
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
for (const n of names) {
|
||||||
|
if (!/^\d+$/.test(n)) continue;
|
||||||
|
const p = readProc(n, procRoot);
|
||||||
|
if (p) procs.set(p.pid, p);
|
||||||
|
}
|
||||||
|
const out = [];
|
||||||
|
for (const p of procs.values()) {
|
||||||
|
if (!p.ai || p.uid !== uid) continue;
|
||||||
|
let nested = false;
|
||||||
|
const seen = new Set();
|
||||||
|
for (let a = procs.get(p.ppid); a && !seen.has(a.pid); a = procs.get(a.ppid)) {
|
||||||
|
seen.add(a.pid);
|
||||||
|
if (a.ai) {
|
||||||
|
nested = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!nested) out.push({ pid: p.pid, start: p.start });
|
||||||
|
}
|
||||||
|
return out.sort((a, b) => a.start - b.start || a.pid - b.pid);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The pid of the top-level AI session `pid` runs under (itself included), or null. */
|
||||||
|
function sessionOf(pid, procRoot = "/proc") {
|
||||||
|
let top = null;
|
||||||
|
const seen = new Set();
|
||||||
|
for (let p = readProc(pid, procRoot); p && !seen.has(p.pid); p = readProc(p.ppid, procRoot)) {
|
||||||
|
seen.add(p.pid);
|
||||||
|
if (p.ai) top = p.pid;
|
||||||
|
if (p.ppid <= 1) break;
|
||||||
|
}
|
||||||
|
return top;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The most AI sessions one member may run at once: `MAX_AI_SESSIONS` in
|
||||||
|
* server.conf (0 = no limit), else 4.
|
||||||
|
*/
|
||||||
|
function sessionLimit(confFile = CONF) {
|
||||||
|
try {
|
||||||
|
const m = fs.readFileSync(confFile, "utf8").match(/^MAX_AI_SESSIONS=(\d+)\s*$/m);
|
||||||
|
if (m) return Number(m[1]);
|
||||||
|
} catch {
|
||||||
|
/* no server.conf: the default */
|
||||||
|
}
|
||||||
|
return DEFAULT_LIMIT;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { AI_BINS, DEFAULT_LIMIT, readProc, sessionsOf, sessionOf, sessionLimit };
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
/**
|
||||||
|
* @file member-policy.js
|
||||||
|
* @description Server mode, one Linux user per account (S3): the admin's
|
||||||
|
* switch for what members' Claude may run on this box — today whether test
|
||||||
|
* runners and container commands are allowed (`allowTests`, off by default).
|
||||||
|
* Kept in `<app root>/member-policy.json` (/opt/agenthub/app, host-owned,
|
||||||
|
* world-readable), because `scripts/member-guard.js` runs AS the member and
|
||||||
|
* reads it from there; a missing or unreadable file means the default, so
|
||||||
|
* tests stay blocked unless an admin turned them on.
|
||||||
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
|
*/
|
||||||
|
|
||||||
|
const fs = require("node:fs");
|
||||||
|
const path = require("node:path");
|
||||||
|
|
||||||
|
const FILE = "member-policy.json";
|
||||||
|
const DEFAULTS = Object.freeze({ allowTests: false });
|
||||||
|
|
||||||
|
const appRoot = () => process.env.AGENTHUB_APP_ROOT || "/opt/agenthub/app";
|
||||||
|
const file = () => path.join(appRoot(), FILE);
|
||||||
|
|
||||||
|
/** The policy in force (defaults for anything missing or unreadable). */
|
||||||
|
function read(p = file()) {
|
||||||
|
try {
|
||||||
|
const j = JSON.parse(fs.readFileSync(p, "utf8"));
|
||||||
|
return { allowTests: j?.allowTests === true };
|
||||||
|
} catch {
|
||||||
|
return { ...DEFAULTS };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Does it apply here? Only S3 runs members' panes as their own accounts. */
|
||||||
|
function available() {
|
||||||
|
try {
|
||||||
|
return require("./linux-accounts").s3Enabled();
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function state() {
|
||||||
|
return { available: available(), ...read() };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Writes the switch (atomically, 0644: members' hooks read it). */
|
||||||
|
function setAllowTests(on) {
|
||||||
|
const next = { ...read(), allowTests: on === true };
|
||||||
|
fs.mkdirSync(appRoot(), { recursive: true });
|
||||||
|
const tmp = `${file()}.${process.pid}.tmp`;
|
||||||
|
fs.writeFileSync(tmp, JSON.stringify(next, null, 2) + "\n", { mode: 0o644 });
|
||||||
|
fs.chmodSync(tmp, 0o644);
|
||||||
|
fs.renameSync(tmp, file());
|
||||||
|
return state();
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { FILE, DEFAULTS, read, available, state, setAllowTests };
|
||||||
@@ -126,6 +126,30 @@ function laneIdFromRunId(id) {
|
|||||||
return m ? Number(m[1]) : null;
|
return m ? Number(m[1]) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* S3: a member runs at most `MAX_AI_SESSIONS` AI sessions at once (default 4,
|
||||||
|
* server.conf), counted from the processes their account runs right now —
|
||||||
|
* a `claude` typed into a Terminal tab counts, the tab itself does not
|
||||||
|
* (ai-procs.js; scripts/member-guard.js refuses the prompts of one started
|
||||||
|
* past the limit by hand). The host account is an admin's and not limited.
|
||||||
|
* @throws ESESSIONLIMIT
|
||||||
|
*/
|
||||||
|
function assertSessionRoom(laneId) {
|
||||||
|
const account = require("./run-as").accountForLane(laneId);
|
||||||
|
if (!account) return;
|
||||||
|
const procs = require("./ai-procs");
|
||||||
|
const limit = procs.sessionLimit();
|
||||||
|
if (!limit) return;
|
||||||
|
const running = procs.sessionsOf(account.uid).length;
|
||||||
|
if (running >= limit) {
|
||||||
|
throw makeErr(
|
||||||
|
"ESESSIONLIMIT",
|
||||||
|
`AgentHub: you are running ${running} AI sessions — at most ${limit} at once on this server. ` +
|
||||||
|
"Close one (end its pane) and start again."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function makeErr(code, message) {
|
function makeErr(code, message) {
|
||||||
const err = new Error(message);
|
const err = new Error(message);
|
||||||
err.code = code;
|
err.code = code;
|
||||||
@@ -475,6 +499,7 @@ function spawnRun(args) {
|
|||||||
assertCwdAccount(laneId, cwd); // ECWDACCOUNT
|
assertCwdAccount(laneId, cwd); // ECWDACCOUNT
|
||||||
// Server mode: an owner over their spend quota starts nothing new (user-quota.js).
|
// Server mode: an owner over their spend quota starts nothing new (user-quota.js).
|
||||||
require("./user-quota").assertSpawnAllowed(laneOwner); // EQUOTA
|
require("./user-quota").assertSpawnAllowed(laneOwner); // EQUOTA
|
||||||
|
assertSessionRoom(laneId); // ESESSIONLIMIT
|
||||||
const proxyProfile = resolveProxy(tool, proxy, laneOwner); // EBADPROXY, EUNSUPPORTED
|
const proxyProfile = resolveProxy(tool, proxy, laneOwner); // EBADPROXY, EUNSUPPORTED
|
||||||
// A Claude session on the vendor's own API runs on the least-loaded pool
|
// A Claude session on the vendor's own API runs on the least-loaded pool
|
||||||
// account, if the admin added any (claude-pool.js) — or the one the
|
// account, if the admin added any (claude-pool.js) — or the one the
|
||||||
|
|||||||
@@ -173,6 +173,43 @@ const paths = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
"/api/ai-tools/member-policy": {
|
||||||
|
get: {
|
||||||
|
tags: ["AI Tools"],
|
||||||
|
summary: "What members' Claude may run on this server",
|
||||||
|
description:
|
||||||
|
"Server mode with per-user accounts (S3): whether members' Claude may run test runners and container commands (`allowTests`, off by default). Kept in `/opt/agenthub/app/member-policy.json` and enforced by the members' synchronous PreToolUse hook (scripts/member-guard.js).",
|
||||||
|
operationId: "aiToolsMemberPolicy",
|
||||||
|
responses: {
|
||||||
|
200: { description: "`{available, allowTests}`." },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
put: {
|
||||||
|
tags: ["AI Tools"],
|
||||||
|
summary: "Allow or block members' test runs",
|
||||||
|
description:
|
||||||
|
"An admin's switch. Takes effect at the next Bash call of every member's Claude, no restart. Same-origin only; audited.",
|
||||||
|
operationId: "aiToolsSetMemberPolicy",
|
||||||
|
requestBody: {
|
||||||
|
required: true,
|
||||||
|
content: {
|
||||||
|
"application/json": {
|
||||||
|
schema: {
|
||||||
|
type: "object",
|
||||||
|
required: ["allowTests"],
|
||||||
|
properties: { allowTests: { type: "boolean" } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
responses: {
|
||||||
|
200: { description: "The new state, as GET." },
|
||||||
|
400: { description: "EBADFIELD" },
|
||||||
|
403: { description: "Not an admin" },
|
||||||
|
409: { description: "ENOS3 — not server mode with per-user accounts" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
"/api/ai-tools/{id}": {
|
"/api/ai-tools/{id}": {
|
||||||
put: {
|
put: {
|
||||||
tags: ["AI Tools"],
|
tags: ["AI Tools"],
|
||||||
|
|||||||
@@ -66,6 +66,35 @@ router.put("/omp-sharing", sameOriginGuard, async (req, res) => {
|
|||||||
res.json(state);
|
res.json(state);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// What members' Claude may run on this box (server/lib/member-policy.js,
|
||||||
|
// read by scripts/member-guard.js): S3 only, an admin's switch.
|
||||||
|
router.get("/member-policy", (_req, res) => {
|
||||||
|
res.json(require("../lib/member-policy").state());
|
||||||
|
});
|
||||||
|
|
||||||
|
router.put("/member-policy", sameOriginGuard, (req, res) => {
|
||||||
|
const on = req.body?.allowTests;
|
||||||
|
if (typeof on !== "boolean") {
|
||||||
|
return res.status(400).json({ error: { code: "EBADFIELD", message: "allowTests: boolean" } });
|
||||||
|
}
|
||||||
|
const policy = require("../lib/member-policy");
|
||||||
|
if (!policy.available()) {
|
||||||
|
return res.status(409).json({
|
||||||
|
error: { code: "ENOS3", message: "this needs server mode with per-user accounts" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const state = policy.setAllowTests(on);
|
||||||
|
if (req.user) {
|
||||||
|
require("../lib/audit").recordAccountChange(
|
||||||
|
req.user.id,
|
||||||
|
null,
|
||||||
|
"PUT",
|
||||||
|
`members' test runs ${on ? "allowed" : "blocked"}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
res.json(state);
|
||||||
|
});
|
||||||
|
|
||||||
router.put("/:id", sameOriginGuard, (req, res) => {
|
router.put("/:id", sameOriginGuard, (req, res) => {
|
||||||
const { enabled, default: makeDefault, defaultModel } = req.body || {};
|
const { enabled, default: makeDefault, defaultModel } = req.body || {};
|
||||||
// Every field checked before anything is written: a bad one saves nothing.
|
// Every field checked before anything is written: a bad one saves nothing.
|
||||||
|
|||||||
@@ -294,6 +294,10 @@ const apiErrorSummariesStmt = db.prepare(
|
|||||||
const turnDurationStampsStmt = db.prepare(
|
const turnDurationStampsStmt = db.prepare(
|
||||||
"SELECT created_at FROM events WHERE session_id = ? AND event_type = 'TurnDuration'"
|
"SELECT created_at FROM events WHERE session_id = ? AND event_type = 'TurnDuration'"
|
||||||
);
|
);
|
||||||
|
// When the session's current turn began: the newest UserPromptSubmit.
|
||||||
|
const lastPromptAtStmt = db.prepare(
|
||||||
|
"SELECT MAX(created_at) AS at FROM events WHERE session_id = ? AND event_type = 'UserPromptSubmit'"
|
||||||
|
);
|
||||||
|
|
||||||
// What this process already handled of each session's transcript parse.
|
// What this process already handled of each session's transcript parse.
|
||||||
// `transcriptCache.extract` returns the SAME result object while the file is
|
// `transcriptCache.extract` returns the SAME result object while the file is
|
||||||
@@ -1202,6 +1206,7 @@ const processEventTx = db.transaction((hookType, data, tool = "claude", peer = n
|
|||||||
? null
|
? null
|
||||||
: tailAfter(result.turnDurations, mark?.lastTurn, (a, b) => a === b);
|
: tailAfter(result.turnDurations, mark?.lastTurn, (a, b) => a === b);
|
||||||
if (freshTurns && freshTurns.length) {
|
if (freshTurns && freshTurns.length) {
|
||||||
|
endTurnFromTranscript(sessionId, mainAgentId, hookType, freshTurns);
|
||||||
// `turnDurations` is every turn in the transcript (up to 1000), on
|
// `turnDurations` is every turn in the transcript (up to 1000), on
|
||||||
// every hook: dedupe against ONE read of the recorded timestamps (a
|
// every hook: dedupe against ONE read of the recorded timestamps (a
|
||||||
// covering-index scan) instead of a query per turn — 23 ms per hook on
|
// covering-index scan) instead of a query per turn — 23 ms per hook on
|
||||||
@@ -1327,6 +1332,33 @@ const processEventTx = db.transaction((hookType, data, tool = "claude", peer = n
|
|||||||
* that transaction COMMITTED — in order, in the same tick. A frame sent from
|
* that transaction COMMITTED — in order, in the same tick. A frame sent from
|
||||||
* inside it described a row a later throw could still roll back.
|
* inside it described a row a later throw could still roll back.
|
||||||
*/
|
*/
|
||||||
|
/**
|
||||||
|
* A turn the transcript says has ended is over, Stop hook or not. Claude Code
|
||||||
|
* writes a `turn_duration` entry when a turn finishes, but in roughly one turn
|
||||||
|
* in fifteen it never sends the Stop hook (seen on 2.1.31x: only the
|
||||||
|
* prompt-suggestion helper's SubagentStop arrives), and the main agent then
|
||||||
|
* stayed `working` until the idle Notification a minute later — the chat
|
||||||
|
* showed Stop on a finished answer. So a turn end NEWER than the session's
|
||||||
|
* last prompt, read while the main agent is still working with no tool open,
|
||||||
|
* lands it in Waiting exactly like Stop does. A UserPromptSubmit/PreToolUse is
|
||||||
|
* new work and never ends a turn: its own read may still carry the previous
|
||||||
|
* turn's entry.
|
||||||
|
*/
|
||||||
|
function endTurnFromTranscript(sessionId, mainAgentId, hookType, turns) {
|
||||||
|
if (!mainAgentId || hookType === "UserPromptSubmit" || hookType === "PreToolUse") return;
|
||||||
|
const main = stmts.getAgent.get(mainAgentId);
|
||||||
|
if (!main || main.status !== "working" || main.current_tool) return;
|
||||||
|
const prompt = lastPromptAtStmt.get(sessionId)?.at;
|
||||||
|
const end = turns.reduce((m, td) => (td.timestamp && td.timestamp > m ? td.timestamp : m), "");
|
||||||
|
if (!end || (prompt && end <= prompt)) return;
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
stmts.updateAgent.run(null, "waiting", null, null, null, null, mainAgentId);
|
||||||
|
stmts.setSessionAwaitingInput.run(now, "stop", sessionId);
|
||||||
|
stmts.setAgentAwaitingInput.run(now, "stop", mainAgentId);
|
||||||
|
broadcast("session_updated", stmts.getSession.get(sessionId));
|
||||||
|
broadcast("agent_updated", stmts.getAgent.get(mainAgentId));
|
||||||
|
}
|
||||||
|
|
||||||
function processEvent(hookType, data, tool, peer = null) {
|
function processEvent(hookType, data, tool, peer = null) {
|
||||||
const outer = pendingMarks;
|
const outer = pendingMarks;
|
||||||
pendingMarks = [];
|
pendingMarks = [];
|
||||||
|
|||||||
+36
-4
@@ -436,6 +436,23 @@ function refuseFolder(res) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A member's folder for a workspace no caller picked a directory for (the
|
||||||
|
* pet chat): `<own folder>/sagi`, made AS the member under S3 (`mkdirAs`,
|
||||||
|
* never a host write in their home). The caller still runs it through
|
||||||
|
* `folderAllowed`, so a link planted there cannot lead outside.
|
||||||
|
*/
|
||||||
|
function ownDefaultFolder(own) {
|
||||||
|
const target = path.join(own, "sagi");
|
||||||
|
if (!fs.existsSync(target)) {
|
||||||
|
const runAs = require("../lib/run-as");
|
||||||
|
const acct = runAs.accountForPath(target);
|
||||||
|
if (acct) runAs.mkdirAs(acct, target);
|
||||||
|
else fs.mkdirSync(target, { recursive: true });
|
||||||
|
}
|
||||||
|
return target;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Idempotent "which lane owns this directory?" — the Workspace page opens on a
|
* Idempotent "which lane owns this directory?" — the Workspace page opens on a
|
||||||
* cwd, not on a lane id, so it needs one lane to exist for that cwd without
|
* cwd, not on a lane id, so it needs one lane to exist for that cwd without
|
||||||
@@ -447,10 +464,24 @@ router.post("/ensure", sameOriginGuard, (req, res) => {
|
|||||||
// specific directory (e.g. Sagi's mascot companion) get a real, existing
|
// specific directory (e.g. Sagi's mascot companion) get a real, existing
|
||||||
// path and lane uniqueness stays meaningful. Callers that do care pass one
|
// path and lane uniqueness stays meaningful. Callers that do care pass one
|
||||||
// through unchanged.
|
// through unchanged.
|
||||||
|
// A member's default is NOT the host's home (outside their folder: EFOLDER,
|
||||||
|
// the pet chat could never start) nor their own home (no workspace under
|
||||||
|
// S3), but `<their folder>/sagi`, made as them on first use.
|
||||||
// Canonical before the lookup, so the lookup and createLane's insert agree.
|
// Canonical before the lookup, so the lookup and createLane's insert agree.
|
||||||
const cwd = lanesLib.canonicalCwd(
|
let cwd;
|
||||||
typeof body.cwd === "string" && body.cwd.trim() ? body.cwd.trim() : os.homedir()
|
if (typeof body.cwd === "string" && body.cwd.trim()) {
|
||||||
);
|
cwd = lanesLib.canonicalCwd(body.cwd.trim());
|
||||||
|
} else if (scope.ownFolder(req)) {
|
||||||
|
try {
|
||||||
|
cwd = lanesLib.canonicalCwd(ownDefaultFolder(scope.ownFolder(req)));
|
||||||
|
} catch (err) {
|
||||||
|
return res.status(err.code === "EMEMBERGONE" ? 409 : 500).json({
|
||||||
|
error: { code: err.code || "EMKDIR", message: err.message },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
cwd = lanesLib.canonicalCwd(os.homedir());
|
||||||
|
}
|
||||||
|
|
||||||
if (body.tool !== undefined && !aiTools.has(body.tool)) {
|
if (body.tool !== undefined && !aiTools.has(body.tool)) {
|
||||||
return res.status(400).json({ error: { code: "EBADTOOL", message: "unknown tool" } });
|
return res.status(400).json({ error: { code: "EBADTOOL", message: "unknown tool" } });
|
||||||
@@ -3533,11 +3564,12 @@ router.post("/:id/:action", sameOriginGuard, async (req, res) => {
|
|||||||
// cannot do (EUNSUPPORTED: a fork on a non-Claude lane) is the request's fault.
|
// cannot do (EUNSUPPORTED: a fork on a non-Claude lane) is the request's fault.
|
||||||
// ENOTSHARED (S3): the tool is not under /opt/agenthub/tools yet — a setup state.
|
// ENOTSHARED (S3): the tool is not under /opt/agenthub/tools yet — a setup state.
|
||||||
// EQUOTA (server mode): the workspace owner is over their spend quota.
|
// EQUOTA (server mode): the workspace owner is over their spend quota.
|
||||||
|
// ESESSIONLIMIT (S3): the member already runs the most AI sessions allowed.
|
||||||
const status = /^(EBAD|EUNSUPPORTED$)/.test(err.code || "")
|
const status = /^(EBAD|EUNSUPPORTED$)/.test(err.code || "")
|
||||||
? 400
|
? 400
|
||||||
: ["ENOTSHARED", "EMEMBERGONE", "EOWNERFOLDER", "ECWDACCOUNT"].includes(err.code)
|
: ["ENOTSHARED", "EMEMBERGONE", "EOWNERFOLDER", "ECWDACCOUNT"].includes(err.code)
|
||||||
? 409
|
? 409
|
||||||
: err.code === "EQUOTA"
|
: err.code === "EQUOTA" || err.code === "ESESSIONLIMIT"
|
||||||
? 429
|
? 429
|
||||||
: 500;
|
: 500;
|
||||||
return res.status(status).json({ error: { code: err.code, message: err.message } });
|
return res.status(status).json({ error: { code: err.code, message: err.message } });
|
||||||
|
|||||||
@@ -673,7 +673,7 @@ router.post("/", (req, res) => {
|
|||||||
return res.status(409).json({ error: { code: err.code, message: err.message } });
|
return res.status(409).json({ error: { code: err.code, message: err.message } });
|
||||||
}
|
}
|
||||||
// Server mode: the workspace owner is over their spend quota (user-quota.js).
|
// Server mode: the workspace owner is over their spend quota (user-quota.js).
|
||||||
if (err.code === "EQUOTA") {
|
if (err.code === "EQUOTA" || err.code === "ESESSIONLIMIT") {
|
||||||
return res.status(429).json({ error: { code: err.code, message: err.message } });
|
return res.status(429).json({ error: { code: err.code, message: err.message } });
|
||||||
}
|
}
|
||||||
// Nor is a host missing tmux: the request was fine, the dependency is not
|
// Nor is a host missing tmux: the request was fine, the dependency is not
|
||||||
|
|||||||
Reference in New Issue
Block a user