chore: v2.8.12 — clone queue and progress, session defaults, chat agents, drops as @ mentions
Whole tree from parallel sessions, plus the version bump: - Clone: a queue with live progress (Add workspace and extra folders), clones run under nice/ionice so the machine stays responsive. - Settings → AI tools → Session defaults: a new session's default permission mode / bypass per account. - Chat lists the background subagents that have not reported back yet. - A file pasted or dropped into a claude pane goes in as an @ mention. - Changes panel: say why amend/undo is refused on a pushed commit. - openapi.yaml regenerated (Artifacts, Host, resumeById).
This commit is contained in:
@@ -165,12 +165,15 @@ export function MenuItem({
|
||||
danger,
|
||||
disabled,
|
||||
testId,
|
||||
title,
|
||||
children,
|
||||
}: {
|
||||
onSelect: () => void;
|
||||
danger?: boolean;
|
||||
disabled?: boolean;
|
||||
testId?: string;
|
||||
/** Hover text — e.g. why a disabled item cannot run. */
|
||||
title?: string;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
@@ -179,6 +182,7 @@ export function MenuItem({
|
||||
role="menuitem"
|
||||
data-testid={testId}
|
||||
disabled={disabled}
|
||||
title={title}
|
||||
className={`${MENU_ITEM}${danger ? " !text-status-danger" : ""}`}
|
||||
onClick={onSelect}
|
||||
>
|
||||
|
||||
@@ -318,7 +318,7 @@ export function PetChat({
|
||||
const r = await startInFreeTabDetailed(lane.id, {
|
||||
prompt,
|
||||
...(tool !== lane.tool ? { tool } : {}),
|
||||
...(tool === "claude" ? { permissionMode: readDefaultMode() } : {}),
|
||||
...(tool === "claude" ? { permissionMode: readDefaultMode({ safe: true }) } : {}),
|
||||
});
|
||||
remember({ laneId: r.laneId, runId: r.runId, tool });
|
||||
} catch (err) {
|
||||
|
||||
@@ -17,7 +17,9 @@
|
||||
* A caller that knows the session has ended passes `ended`: nothing more is
|
||||
* being written, so the catch-up poll drops from 3 s to 60 s and the
|
||||
* sub-agent list stops being rescanned — a `new_event` frame, a reconnect or
|
||||
* the view being shown again still fetch the tail at once.
|
||||
* the view being shown again still fetch the tail at once. The main
|
||||
* transcript hands MessageList its `sessionId`, so the list can read its
|
||||
* background agents' own transcripts for their progress.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
/* =============================================================================
|
||||
@@ -622,6 +624,9 @@ function ConversationViewPane({
|
||||
waiting={waiting}
|
||||
onOpenTerminal={onOpenTerminal}
|
||||
stopped={stopped}
|
||||
sessionId={
|
||||
selectedTranscript && selectedTranscript !== "main" ? undefined : sessionId
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -26,7 +26,14 @@
|
||||
* terminal (`waiting`) — and, like the terminal's status line, how long the
|
||||
* turn has run and the output tokens it has made; a call running long shows
|
||||
* its own time, and a line silent for minutes turns amber. A turn that ended
|
||||
* closes with how long it took and its output.
|
||||
* closes with how long it took and its output. A live chat also lists the
|
||||
* subagents it launched in the background that have not reported back yet
|
||||
* (an `Agent` call whose result is "Async agent launched…" and no
|
||||
* `<task-notification>` naming its call since), like the terminal's footer:
|
||||
* their turn returns at once, so without the list a turn that ended reads
|
||||
* "Done" while they still work (issue #81) — it then reads "Replied in …".
|
||||
* Given the `sessionId`, each line also shows what that agent is doing now
|
||||
* and how large its context is, read off the tail of its own transcript.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
/* =============================================================================
|
||||
@@ -101,6 +108,7 @@ import {
|
||||
CornerDownRight,
|
||||
} from "lucide-react";
|
||||
import type { TranscriptMessage, TranscriptContent, TranscriptSender } from "../../lib/types";
|
||||
import { api } from "../../lib/api";
|
||||
|
||||
/** Per-sender visual treatment for a transcript row. A JSONL `type:"user"` line
|
||||
* is not always the human (tool results, harness task-notifications, the
|
||||
@@ -163,6 +171,9 @@ interface MessageListProps {
|
||||
/** Bumped by the chat's Stop: the turn on screen is over, even when the
|
||||
* transcript never says so. */
|
||||
stopped?: number;
|
||||
/** The session whose MAIN transcript this is: its background agents'
|
||||
* own transcripts are read for their progress. */
|
||||
sessionId?: string;
|
||||
}
|
||||
|
||||
/** Build a map from tool_use id → tool_result for matching */
|
||||
@@ -641,6 +652,7 @@ function MessageListView({
|
||||
waiting = false,
|
||||
onOpenTerminal,
|
||||
stopped = 0,
|
||||
sessionId,
|
||||
}: MessageListProps) {
|
||||
const { t } = useTranslation("sessions");
|
||||
const toolResultMap = useMemo(() => buildToolResultMap(messages), [messages]);
|
||||
@@ -659,6 +671,10 @@ function MessageListView({
|
||||
);
|
||||
const stats = useMemo(() => (pending ? turnStats(messages) : null), [pending, messages]);
|
||||
const items = useMemo(() => chatItems(messages, keys), [messages, keys]);
|
||||
const agents = useMemo(
|
||||
() => (live ? backgroundAgents(messages, toolResultMap) : []),
|
||||
[live, messages, toolResultMap]
|
||||
);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
@@ -715,7 +731,14 @@ function MessageListView({
|
||||
/>
|
||||
);
|
||||
case "done":
|
||||
return <TurnDone key={item.key} ms={item.ms} output={item.output} />;
|
||||
return (
|
||||
<TurnDone
|
||||
key={item.key}
|
||||
ms={item.ms}
|
||||
output={item.output}
|
||||
agents={item === items[items.length - 1] ? agents.length : 0}
|
||||
/>
|
||||
);
|
||||
}
|
||||
})}
|
||||
{pending && (
|
||||
@@ -727,6 +750,7 @@ function MessageListView({
|
||||
onOpenTerminal={onOpenTerminal}
|
||||
/>
|
||||
)}
|
||||
{agents.length > 0 && <BackgroundAgents agents={agents} sessionId={sessionId} />}
|
||||
</div>
|
||||
</OpenFileContext.Provider>
|
||||
);
|
||||
@@ -783,6 +807,48 @@ export function workingOn(
|
||||
return alive ? { blocks, since: null, last } : null;
|
||||
}
|
||||
|
||||
// A launch older than this with no notification is from a run that ended
|
||||
// without one (the CLI quit, a resume of an old session): not running now.
|
||||
const AGENT_STALE_MS = 6 * 60 * 60_000;
|
||||
|
||||
const NOTIFIED_CALL = /<tool-use-id>([^<]+)<\/tool-use-id>/g;
|
||||
// The launch result names the agent's own transcript (`agent-<id>.jsonl`).
|
||||
const LAUNCHED_ID = /agentId:\s*([\w-]+)/;
|
||||
|
||||
type BackgroundAgent = { block: TranscriptContent; since: string | null; agentId: string | null };
|
||||
|
||||
/** The subagents launched in the background that have not reported back: an
|
||||
* Agent call whose result says it was launched async, with no
|
||||
* `<task-notification>` naming that call since (any status — completed,
|
||||
* failed, killed — means it stopped). `since` is its launch time. */
|
||||
export function backgroundAgents(
|
||||
messages: TranscriptMessage[],
|
||||
results: Map<string, TranscriptContent>,
|
||||
now = Date.now()
|
||||
): BackgroundAgent[] {
|
||||
const running = new Map<string, BackgroundAgent>();
|
||||
for (const msg of messages) {
|
||||
if (msg.type === "assistant") {
|
||||
for (const b of msg.content) {
|
||||
if (b.type !== "tool_use" || !b.id || (b.name !== "Agent" && b.name !== "Task")) continue;
|
||||
// The output is the result content as JSON when it is a block list.
|
||||
const output = results.get(b.id)?.output;
|
||||
if (!output?.includes("Async agent launched")) continue;
|
||||
const at = msg.timestamp ?? null;
|
||||
if (at && now - parseDate(at).getTime() > AGENT_STALE_MS) continue;
|
||||
running.set(b.id, { block: b, since: at, agentId: output.match(LAUNCHED_ID)?.[1] ?? null });
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (!running.size) continue;
|
||||
for (const c of msg.content) {
|
||||
if (c.type !== "text" || !c.text || !isTaskNotification(c.text)) continue;
|
||||
for (const m of c.text.matchAll(NOTIFIED_CALL)) running.delete(m[1]!.trim());
|
||||
}
|
||||
}
|
||||
return [...running.values()];
|
||||
}
|
||||
|
||||
/** Output tokens counted once per API call: Claude Code writes one line per
|
||||
* content block, each repeating its call's usage — consecutive lines with
|
||||
* the same prompt size are one call. */
|
||||
@@ -857,6 +923,23 @@ function useCountUp(value: number): number {
|
||||
return shown;
|
||||
}
|
||||
|
||||
/** The time now, ticking every second while `ref`'s box is on screen: a
|
||||
* hidden tab or a hidden pane (another workspace, kept mounted) shows no
|
||||
* clock, so the re-render is skipped until it is shown again. */
|
||||
function useClock(ref: React.RefObject<HTMLElement | null>): number {
|
||||
const [now, setNow] = useState(() => Date.now());
|
||||
useEffect(() => {
|
||||
const timer = window.setInterval(() => {
|
||||
const el = ref.current;
|
||||
if (document.visibilityState !== "visible" || (el && el.getClientRects().length === 0))
|
||||
return;
|
||||
setNow(Date.now());
|
||||
}, 1000);
|
||||
return () => window.clearInterval(timer);
|
||||
}, [ref]);
|
||||
return now;
|
||||
}
|
||||
|
||||
/** A call's one-line title: its description (Claude Code's Bash
|
||||
* `description`), else the tool and its target. */
|
||||
function stepTitle(block: TranscriptContent): string {
|
||||
@@ -886,20 +969,9 @@ function RunningStep({
|
||||
onOpenTerminal?: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation("sessions");
|
||||
const [now, setNow] = useState(() => Date.now());
|
||||
const [showAll, setShowAll] = useState(false);
|
||||
const boxRef = useRef<HTMLDivElement>(null);
|
||||
useEffect(() => {
|
||||
// A hidden tab or a hidden pane (another workspace, kept mounted) shows
|
||||
// no clock: skip the re-render until it is on screen again.
|
||||
const timer = window.setInterval(() => {
|
||||
const el = boxRef.current;
|
||||
if (document.visibilityState !== "visible" || (el && el.getClientRects().length === 0))
|
||||
return;
|
||||
setNow(Date.now());
|
||||
}, 1000);
|
||||
return () => window.clearInterval(timer);
|
||||
}, []);
|
||||
const now = useClock(boxRef);
|
||||
const counted = useCountUp(tokens);
|
||||
const age = (at: string | null) => (at ? now - parseDate(at).getTime() : 0);
|
||||
const first = blocks[0];
|
||||
@@ -976,7 +1048,159 @@ function RunningStep({
|
||||
);
|
||||
}
|
||||
|
||||
/** Under a turn that ended: how long it took and the output it made. */
|
||||
// How often a running agent's transcript tail is re-read, and how much of it
|
||||
// is kept: enough for its newest step and its newest usage.
|
||||
const AGENT_TAIL_MS = 4000;
|
||||
const AGENT_TAIL_KEEP = 40;
|
||||
|
||||
/** The newest lines of each agent's own transcript (by its `agentId`),
|
||||
* re-read while `ref`'s box is on screen — only the lines after those
|
||||
* already read (`after`). A failed read leaves the line without detail. */
|
||||
function useAgentTails(
|
||||
sessionId: string | undefined,
|
||||
ids: string[],
|
||||
ref: React.RefObject<HTMLElement | null>
|
||||
): Map<string, TranscriptMessage[]> {
|
||||
const [tails, setTails] = useState(() => new Map<string, TranscriptMessage[]>());
|
||||
const lastRef = useRef(new Map<string, number>());
|
||||
const key = ids.join(",");
|
||||
useEffect(() => {
|
||||
if (!sessionId || !key) return;
|
||||
let cancelled = false;
|
||||
let busy = false;
|
||||
const read = async () => {
|
||||
const el = ref.current;
|
||||
if (
|
||||
busy ||
|
||||
document.visibilityState !== "visible" ||
|
||||
(el && el.getClientRects().length === 0)
|
||||
)
|
||||
return;
|
||||
busy = true;
|
||||
await Promise.all(
|
||||
key.split(",").map(async (id) => {
|
||||
const last = lastRef.current.get(id);
|
||||
try {
|
||||
const r = await api.sessions.transcript(sessionId, {
|
||||
agent_id: id,
|
||||
...(last ? { after: last } : { limit: 20 }),
|
||||
});
|
||||
if (cancelled || !r.messages.length) return;
|
||||
lastRef.current.set(id, r.last_line);
|
||||
setTails((prev) => {
|
||||
const next = new Map(prev);
|
||||
next.set(id, [...(prev.get(id) ?? []), ...r.messages].slice(-AGENT_TAIL_KEEP));
|
||||
return next;
|
||||
});
|
||||
} catch {
|
||||
// Non-fatal: the line keeps its description and time.
|
||||
}
|
||||
})
|
||||
);
|
||||
busy = false;
|
||||
};
|
||||
void read();
|
||||
const timer = window.setInterval(() => void read(), AGENT_TAIL_MS);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
window.clearInterval(timer);
|
||||
};
|
||||
}, [sessionId, key, ref]);
|
||||
return tails;
|
||||
}
|
||||
|
||||
/** What an agent's transcript tail says it is doing: the call it waits on
|
||||
* (or none while it thinks), and its newest prompt size — its context. */
|
||||
export function agentProgress(
|
||||
messages: TranscriptMessage[],
|
||||
now = Date.now()
|
||||
): { step: string | null; context: number } {
|
||||
let context = 0;
|
||||
for (let i = messages.length - 1; i >= 0 && !context; i--) {
|
||||
const u = messages[i]!.usage;
|
||||
if (messages[i]!.type === "assistant" && u) context = promptTokens(u);
|
||||
}
|
||||
const first = workingOn(messages, buildToolResultMap(messages), now)?.blocks[0];
|
||||
return { step: first ? stepTitle(first) : null, context };
|
||||
}
|
||||
|
||||
/** The subagents still working in the background, one line each like the
|
||||
* terminal's footer: its type, its task and how long it has run — with its
|
||||
* step now and its context once its own transcript was read. */
|
||||
function BackgroundAgents({
|
||||
agents,
|
||||
sessionId,
|
||||
}: {
|
||||
agents: BackgroundAgent[];
|
||||
sessionId?: string;
|
||||
}) {
|
||||
const { t } = useTranslation("sessions");
|
||||
const boxRef = useRef<HTMLDivElement>(null);
|
||||
const now = useClock(boxRef);
|
||||
const ids = useMemo(() => agents.flatMap((a) => (a.agentId ? [a.agentId] : [])), [agents]);
|
||||
const tails = useAgentTails(sessionId, ids, boxRef);
|
||||
return (
|
||||
<div
|
||||
ref={boxRef}
|
||||
data-testid="chat-background-agents"
|
||||
className="overflow-hidden rounded-xl border border-border text-sm"
|
||||
>
|
||||
<div className="flex items-center gap-2 border-b border-border bg-surface-1 px-3 py-2 text-fg-primary">
|
||||
<Bot className="h-3.5 w-3.5 text-fg-muted" />
|
||||
<span className="flex-1">{t("conversation.agentsRunning", { count: agents.length })}</span>
|
||||
</div>
|
||||
<ul className="divide-y divide-border">
|
||||
{agents.map(({ block, since, agentId }, i) => {
|
||||
const input = (block.input ?? {}) as Record<string, unknown>;
|
||||
const type = typeof input.subagent_type === "string" ? input.subagent_type : "Agent";
|
||||
const desc = typeof input.description === "string" ? input.description : "";
|
||||
const tail = agentId ? tails.get(agentId) : undefined;
|
||||
const { step, context } = tail ? agentProgress(tail, now) : { step: null, context: 0 };
|
||||
return (
|
||||
<li
|
||||
key={block.id ?? i}
|
||||
data-testid="chat-background-agent"
|
||||
className="px-3 py-2 text-fg-secondary"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Loader2 className="h-3.5 w-3.5 flex-shrink-0 animate-spin text-accent" />
|
||||
<span className="flex-shrink-0 rounded bg-surface-3/60 px-1.5 py-0.5 font-mono text-xs text-fg-muted">
|
||||
{type}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 truncate" title={desc}>
|
||||
{desc}
|
||||
</span>
|
||||
{[
|
||||
since && formatMs(now - parseDate(since).getTime()),
|
||||
context > 0 && `↓ ${t("conversation.agentContext", { tokens: fmt(context) })}`,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.map((v, j) => (
|
||||
<span key={j} className="flex-shrink-0 font-mono text-xs text-fg-muted">
|
||||
{v}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
{tail && (
|
||||
<div
|
||||
data-testid="chat-background-agent-step"
|
||||
className="ml-[1.375rem] mt-0.5 flex items-center gap-1 text-xs text-fg-muted"
|
||||
>
|
||||
<CornerDownRight className="h-3 w-3 flex-shrink-0" />
|
||||
<span className="min-w-0 truncate">{step ?? t("conversation.working")}</span>
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Under a turn that ended: how long it took and the output it made — or,
|
||||
* while subagents it launched still work (`agents`), that only the reply is
|
||||
* done. */
|
||||
/** An option's label carries the model's " (Recommended)" marker into the
|
||||
* answer the CLI records; the user's pick reads without it (every picked
|
||||
* label of a multi-select answer too). */
|
||||
@@ -1028,14 +1252,21 @@ function AskCard({
|
||||
);
|
||||
}
|
||||
|
||||
function TurnDone({ ms, output }: { ms: number; output: number }) {
|
||||
function TurnDone({ ms, output, agents = 0 }: { ms: number; output: number; agents?: number }) {
|
||||
const { t } = useTranslation("sessions");
|
||||
return (
|
||||
<div data-testid="chat-turn-done" className="flex items-center gap-1.5 text-xs text-fg-muted">
|
||||
<Check className="h-3 w-3 flex-shrink-0" />
|
||||
{agents > 0 ? (
|
||||
<Loader2 className="h-3 w-3 flex-shrink-0 animate-spin text-accent" />
|
||||
) : (
|
||||
<Check className="h-3 w-3 flex-shrink-0" />
|
||||
)}
|
||||
{[
|
||||
t("conversation.turnDone", { time: formatMs(ms) }),
|
||||
t(agents > 0 ? "conversation.turnReplied" : "conversation.turnDone", {
|
||||
time: formatMs(ms),
|
||||
}),
|
||||
output > 0 && t("conversation.turnTokens", { tokens: fmt(output) }),
|
||||
agents > 0 && t("conversation.agentsStill", { count: agents }),
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" · ")}
|
||||
@@ -1045,8 +1276,8 @@ function TurnDone({ ms, output }: { ms: number; output: number }) {
|
||||
|
||||
const meta = "text-[10px] font-mono text-fg-muted";
|
||||
|
||||
// What the prompt box sends for an attached file: its saved path under the
|
||||
// drop dir (`<data dir>/drops/<16 hex>[-name].ext`, server/routes/run.js; the
|
||||
// What the prompt box sends for an attached file: its saved path (an `@`
|
||||
// mention for claude) under the drop dir (`<data dir>/drops/<16 hex>[-name].ext`, server/routes/run.js; the
|
||||
// older `<tmp>/agenthub-drops-<uid>/` still in past transcripts).
|
||||
const DROP_PATH =
|
||||
/(?<=^|\s)(\S*[\\/](?:agenthub-drops--?\d+|drops)[\\/]([0-9a-f]{16}(?:-[A-Za-z0-9._-]+)?\.[A-Za-z0-9]+))(?=\s|$)/gi;
|
||||
@@ -1066,7 +1297,7 @@ export function splitDropImages(text: string): {
|
||||
const files: string[] = [];
|
||||
const rest = text.replace(DROP_PATH, (_all, full: string, name: string) => {
|
||||
if (IMAGE_EXT.test(name)) images.push(name);
|
||||
else files.push(full);
|
||||
else files.push(full.replace(/^@/, "")); // a claude prompt's `@` mention
|
||||
return "";
|
||||
});
|
||||
return {
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
/**
|
||||
* @file MessageList.agents.test.tsx
|
||||
* @description Verifies a live chat's background-agents card reads each
|
||||
* running agent's own transcript (by the agentId its launch result names)
|
||||
* and shows the step it is on and its context size (issue #81).
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { MessageList, agentProgress } from "../MessageList";
|
||||
import { api } from "../../../lib/api";
|
||||
import type { TranscriptMessage } from "../../../lib/types";
|
||||
|
||||
vi.mock("../../../lib/api", async (importOriginal) => {
|
||||
const actual = await importOriginal<Record<string, unknown>>();
|
||||
return { ...actual, api: { sessions: { transcript: vi.fn() } } };
|
||||
});
|
||||
|
||||
const at = new Date().toISOString();
|
||||
const m = (p: Partial<TranscriptMessage>): TranscriptMessage =>
|
||||
({ type: "user", timestamp: at, content: [], ...p }) as TranscriptMessage;
|
||||
|
||||
// The subagent's own transcript: its task, then a search still running.
|
||||
const tail: TranscriptMessage[] = [
|
||||
m({ sender: "orchestrator", content: [{ type: "text", text: "Find copyId" }] }),
|
||||
m({
|
||||
type: "assistant",
|
||||
sender: "assistant",
|
||||
stop_reason: "tool_use",
|
||||
usage: { input_tokens: 300, output_tokens: 20, cache_read_input_tokens: 54_000 },
|
||||
content: [
|
||||
{
|
||||
type: "tool_use",
|
||||
id: "g1",
|
||||
name: "Grep",
|
||||
input: { pattern: "copyId", description: "Searching for copyId" },
|
||||
},
|
||||
],
|
||||
}),
|
||||
];
|
||||
|
||||
const main: TranscriptMessage[] = [
|
||||
m({ sender: "user", content: [{ type: "text", text: "go" }] }),
|
||||
m({
|
||||
type: "assistant",
|
||||
sender: "assistant",
|
||||
stop_reason: "tool_use",
|
||||
content: [
|
||||
{
|
||||
type: "tool_use",
|
||||
id: "t1",
|
||||
name: "Agent",
|
||||
input: { description: "Checking copyId coverage", subagent_type: "Explore" },
|
||||
},
|
||||
],
|
||||
}),
|
||||
m({
|
||||
content: [
|
||||
{
|
||||
type: "tool_result",
|
||||
id: "t1",
|
||||
output: JSON.stringify([
|
||||
{ type: "text", text: "Async agent launched successfully.\nagentId: a4118367b503adecc" },
|
||||
]),
|
||||
},
|
||||
],
|
||||
}),
|
||||
m({
|
||||
type: "assistant",
|
||||
sender: "assistant",
|
||||
stop_reason: "end_turn",
|
||||
content: [{ type: "text", text: "waiting" }],
|
||||
}),
|
||||
];
|
||||
|
||||
describe("background agents' progress", () => {
|
||||
const rects = Element.prototype.getClientRects;
|
||||
beforeEach(() => {
|
||||
// jsdom lays nothing out: make the card count as on screen.
|
||||
Element.prototype.getClientRects = () => [{}] as unknown as DOMRectList;
|
||||
vi.mocked(api.sessions.transcript).mockResolvedValue({
|
||||
messages: tail,
|
||||
total: 2,
|
||||
has_more: false,
|
||||
first_line: 1,
|
||||
last_line: 2,
|
||||
});
|
||||
});
|
||||
afterEach(() => {
|
||||
Element.prototype.getClientRects = rects;
|
||||
vi.mocked(api.sessions.transcript).mockReset();
|
||||
});
|
||||
|
||||
it("reads the step an agent is on and its context", () => {
|
||||
expect(agentProgress(tail)).toEqual({ step: "Searching for copyId", context: 54_300 });
|
||||
expect(agentProgress(tail.slice(0, 1))).toEqual({ step: null, context: 0 });
|
||||
});
|
||||
|
||||
it("shows them on the agent's line, from its own transcript", async () => {
|
||||
render(<MessageList live loading={false} messages={main} sessionId="s1" />);
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId("chat-background-agent-step")).toHaveTextContent(
|
||||
"Searching for copyId"
|
||||
)
|
||||
);
|
||||
expect(api.sessions.transcript).toHaveBeenCalledWith("s1", {
|
||||
agent_id: "a4118367b503adecc",
|
||||
limit: 20,
|
||||
});
|
||||
expect(screen.getByTestId("chat-background-agent")).toHaveTextContent("↓ 54.3K tokens");
|
||||
});
|
||||
|
||||
it("reads nothing without the session", () => {
|
||||
render(<MessageList live loading={false} messages={main} />);
|
||||
expect(screen.getByTestId("chat-background-agent")).toBeInTheDocument();
|
||||
expect(api.sessions.transcript).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -5,12 +5,13 @@
|
||||
* layout), Main agent / System under their own label — instead of treating
|
||||
* every `type:"user"` line as the user (reported transcript mis-attribution) —
|
||||
* and folds a run of tool calls into one line; a typed `/command` or `@path`
|
||||
* shows as a chip.
|
||||
* shows as a chip. A live chat lists the background subagents still running
|
||||
* and does not call their turn done (issue #81).
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { fireEvent, render, screen, within } from "@testing-library/react";
|
||||
import { MessageList, turnStats, workLabel, workingOn } from "../MessageList";
|
||||
import { MessageList, backgroundAgents, turnStats, workLabel, workingOn } from "../MessageList";
|
||||
import type { TranscriptMessage } from "../../../lib/types";
|
||||
|
||||
function msg(partial: Partial<TranscriptMessage>): TranscriptMessage {
|
||||
@@ -343,4 +344,77 @@ describe("MessageList — sender attribution", () => {
|
||||
// Silent for 15 minutes with nothing running: the turn died.
|
||||
expect(workingOn([prompt], new Map(), now + 20 * 60_000)).toBeNull();
|
||||
});
|
||||
|
||||
describe("background subagents", () => {
|
||||
const at = new Date().toISOString();
|
||||
const launch = msg({
|
||||
type: "assistant",
|
||||
sender: "assistant",
|
||||
timestamp: at,
|
||||
stop_reason: "tool_use",
|
||||
content: [
|
||||
{
|
||||
type: "tool_use",
|
||||
id: "t1",
|
||||
name: "Agent",
|
||||
input: { description: "Checking copyId coverage", subagent_type: "Explore" },
|
||||
},
|
||||
],
|
||||
});
|
||||
const launched = msg({
|
||||
timestamp: at,
|
||||
content: [
|
||||
{
|
||||
type: "tool_result",
|
||||
id: "t1",
|
||||
output: JSON.stringify([{ type: "text", text: "Async agent launched successfully." }]),
|
||||
},
|
||||
],
|
||||
});
|
||||
const reply = msg({
|
||||
type: "assistant",
|
||||
sender: "assistant",
|
||||
timestamp: at,
|
||||
stop_reason: "end_turn",
|
||||
content: [{ type: "text", text: "waiting for the agent" }],
|
||||
});
|
||||
const notified = msg({
|
||||
sender: "system",
|
||||
timestamp: at,
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: "<task-notification>\n<task-id>a1</task-id>\n<tool-use-id>t1</tool-use-id>\n<status>completed</status>\n</task-notification>",
|
||||
},
|
||||
],
|
||||
});
|
||||
const base = [msg({ sender: "user", timestamp: at }), launch, launched, reply];
|
||||
|
||||
it("lists an agent until its notification names the call", () => {
|
||||
const results = (m: TranscriptMessage[]) =>
|
||||
new Map(
|
||||
m.flatMap((x) => x.content.filter((c) => c.type === "tool_result").map((c) => [c.id!, c]))
|
||||
);
|
||||
expect(backgroundAgents(base, results(base)).map((a) => a.block.id)).toEqual(["t1"]);
|
||||
const all = [...base, notified];
|
||||
expect(backgroundAgents(all, results(all))).toEqual([]);
|
||||
});
|
||||
|
||||
it("does not call the turn done while the agent works", () => {
|
||||
const { rerender } = render(<MessageList live loading={false} messages={base} />);
|
||||
expect(screen.getByTestId("chat-background-agent")).toHaveTextContent(
|
||||
"ExploreChecking copyId coverage"
|
||||
);
|
||||
expect(screen.getByTestId("chat-turn-done")).toHaveTextContent("Replied in");
|
||||
expect(screen.getByTestId("chat-turn-done")).toHaveTextContent("1 agent still running");
|
||||
rerender(<MessageList live loading={false} messages={[...base, notified]} />);
|
||||
expect(screen.queryByTestId("chat-background-agents")).toBeNull();
|
||||
expect(screen.getByTestId("chat-turn-done")).toHaveTextContent("Done in");
|
||||
});
|
||||
|
||||
it("shows nothing once the session is no longer live", () => {
|
||||
render(<MessageList loading={false} messages={base} />);
|
||||
expect(screen.queryByTestId("chat-background-agents")).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -9,7 +9,8 @@
|
||||
* manually-typed branch name; "Clone" runs `POST /api/lanes/clone` (an
|
||||
* https/ssh URL cloned into a folder of the user's, with their OWN SSH key or
|
||||
* HTTPS login — a 409 `ECREDENTIALS` opens a login row, `ESSHAUTH` points to
|
||||
* Settings → Git). There is no AI tool picker: the lane is created with the
|
||||
* Settings → Git; **Partial clone** makes it blobless, and `CloneProgress`
|
||||
* shows its queue place, then git's progress). There is no AI tool picker: the lane is created with the
|
||||
* default AI tool (Settings → AI Tools), and each new session picks its own
|
||||
* AI on the workspace home. Either
|
||||
* mode's path field can be filled by typing, by the CwdAutocomplete
|
||||
@@ -30,6 +31,7 @@ import { useDialog } from "../../hooks/useDialog";
|
||||
import { CwdAutocomplete } from "../run/RunSetup";
|
||||
import { PathPickButton } from "../PathPickButton";
|
||||
import { api } from "../../lib/api";
|
||||
import { CloneProgress, newCloneToken } from "./CloneProgress";
|
||||
import type { CwdSuggestion } from "../../lib/api";
|
||||
import type { Lane, LaneWorktree } from "../../lib/types";
|
||||
import { useAiTools } from "../../hooks/useAiTools";
|
||||
@@ -116,6 +118,9 @@ export function AddLaneModal({
|
||||
// server asked for one — a login for the URL's https host.
|
||||
const [cloneUrl, setCloneUrl] = useState("");
|
||||
const [cloneName, setCloneName] = useState("");
|
||||
const [clonePartial, setClonePartial] = useState(false);
|
||||
/** The running clone's progress token (CloneProgress polls it). */
|
||||
const [cloneToken, setCloneToken] = useState<string | null>(null);
|
||||
const [login, setLogin] = useState<{
|
||||
host: string;
|
||||
username: string;
|
||||
@@ -159,6 +164,7 @@ export function AddLaneModal({
|
||||
setMode("repo");
|
||||
setCloneUrl("");
|
||||
setCloneName("");
|
||||
setClonePartial(false);
|
||||
setLogin(null);
|
||||
setSshRefused(false);
|
||||
setSourceRepo("");
|
||||
@@ -236,13 +242,17 @@ export function AddLaneModal({
|
||||
const url = cloneUrl.trim();
|
||||
if (!url) return;
|
||||
setSshRefused(false);
|
||||
const progressToken = newCloneToken();
|
||||
setCloneToken(progressToken);
|
||||
try {
|
||||
const result = await api.lanes.clone({
|
||||
progressToken,
|
||||
url,
|
||||
parent: repo,
|
||||
name: cloneName.trim() || undefined,
|
||||
title: name || undefined,
|
||||
tool,
|
||||
...(clonePartial ? { partial: true } : {}),
|
||||
...(login?.username.trim() && login.token
|
||||
? {
|
||||
credentials: {
|
||||
@@ -274,6 +284,8 @@ export function AddLaneModal({
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
setBusy(false);
|
||||
} finally {
|
||||
setCloneToken(null);
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -547,6 +559,23 @@ export function AddLaneModal({
|
||||
spellCheck={false}
|
||||
className="w-full rounded-md border border-border-light bg-surface-0 px-3 py-1.5 font-mono text-xs text-fg-primary placeholder:text-fg-muted focus:border-accent focus:outline-none"
|
||||
/>
|
||||
<label
|
||||
className="mt-2 flex items-center gap-1.5 text-[11px] text-fg-secondary"
|
||||
title={t("clone.partialHint")}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
data-testid="add-lane-clone-partial"
|
||||
checked={clonePartial}
|
||||
onChange={(e) => setClonePartial(e.target.checked)}
|
||||
/>
|
||||
{t("clone.partial")}
|
||||
</label>
|
||||
{cloneToken && (
|
||||
<div className="mt-2">
|
||||
<CloneProgress token={cloneToken} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
/**
|
||||
* @file CloneProgress — the live line under a running clone: its place in the
|
||||
* server's clone queue while it waits (clones run a few at a time machine-wide,
|
||||
* `server/lib/clone-queue.js`), then git's own phase, percent, size and speed
|
||||
* with a bar. It polls `GET /api/lanes/clone/progress/:token` every second for
|
||||
* the token the clone's POST carried (`newCloneToken`); a 404 — the POST not
|
||||
* yet arrived, or an older server — just shows nothing yet.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { api, type CloneProgressView } from "../../lib/api";
|
||||
|
||||
const POLL_MS = 1000;
|
||||
|
||||
/** A fresh, unguessable progress token (works on plain http, unlike randomUUID). */
|
||||
export function newCloneToken(): string {
|
||||
const bytes = new Uint8Array(16);
|
||||
crypto.getRandomValues(bytes);
|
||||
return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
|
||||
}
|
||||
|
||||
export function CloneProgress({ token }: { token: string }) {
|
||||
const { t } = useTranslation("lanes");
|
||||
const [view, setView] = useState<CloneProgressView | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
setView(null);
|
||||
// Never throws: progress is a courtesy, the clone's own answer decides.
|
||||
const poll = () =>
|
||||
Promise.resolve()
|
||||
.then(() => api.lanes.cloneProgress(token))
|
||||
.then((v) => {
|
||||
if (alive) setView(v);
|
||||
})
|
||||
.catch(() => {});
|
||||
void poll();
|
||||
const timer = setInterval(() => void poll(), POLL_MS);
|
||||
return () => {
|
||||
alive = false;
|
||||
clearInterval(timer);
|
||||
};
|
||||
}, [token]);
|
||||
|
||||
if (!view || view.state === "done" || view.state === "failed") return null;
|
||||
|
||||
if (view.state === "queued") {
|
||||
return (
|
||||
<p data-testid="clone-progress" className="text-[11px] text-status-warning">
|
||||
{t("clone.queued", {
|
||||
position: view.position ?? 1,
|
||||
waiting: view.waiting,
|
||||
running: view.running,
|
||||
limit: view.limit,
|
||||
})}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
const phase = view.phase
|
||||
? view.phase === "other"
|
||||
? view.label || ""
|
||||
: t(`clone.phase.${view.phase}`)
|
||||
: t("clone.connecting");
|
||||
const percent = view.percent ?? 0;
|
||||
return (
|
||||
<div data-testid="clone-progress" className="space-y-1">
|
||||
<p className="flex justify-between gap-2 text-[11px] text-fg-secondary">
|
||||
<span className="truncate">
|
||||
{phase}
|
||||
{view.phase ? ` ${percent}%` : ""}
|
||||
</span>
|
||||
{view.detail && (
|
||||
<span className="shrink-0 font-mono text-[10px] text-fg-muted">{view.detail}</span>
|
||||
)}
|
||||
</p>
|
||||
{view.phase && (
|
||||
<div
|
||||
className="h-1 overflow-hidden rounded bg-surface-2"
|
||||
role="progressbar"
|
||||
aria-valuenow={percent}
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={100}
|
||||
aria-label={phase}
|
||||
>
|
||||
<div className="h-full bg-accent-solid transition-all" style={{ width: `${percent}%` }} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -10,7 +10,8 @@
|
||||
* sent to Settings → Git first — and an HTTPS host with no saved login offers
|
||||
* the login row up front (optional: a public repo needs none). The identity is
|
||||
* read again when the window regains focus, so a key made in another tab
|
||||
* counts at once.
|
||||
* counts at once. The running clone shows its queue place, then git's
|
||||
* progress (`CloneProgress`); **Partial clone** sends `partial: true`.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
@@ -18,6 +19,7 @@ import { useCallback, useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Check, Loader2, X } from "lucide-react";
|
||||
import { api, type GitIdentity, type LaneSourcesView } from "../../lib/api";
|
||||
import { CloneProgress, newCloneToken } from "./CloneProgress";
|
||||
|
||||
type Row = {
|
||||
url: string;
|
||||
@@ -25,6 +27,8 @@ type Row = {
|
||||
note?: string;
|
||||
/** The note points at Settings → Git (no key, or a key the host refused). */
|
||||
ssh?: boolean;
|
||||
/** While busy: the progress token CloneProgress polls. */
|
||||
token?: string;
|
||||
};
|
||||
type Login = { host: string; username: string; token: string; remember: boolean };
|
||||
|
||||
@@ -71,6 +75,7 @@ export function CloneSourcesForm({
|
||||
const [rows, setRows] = useState<Row[]>([]);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [login, setLogin] = useState<Login | null>(null);
|
||||
const [partial, setPartial] = useState(false);
|
||||
const [identity, setIdentity] = useState<GitIdentity | null>(null);
|
||||
|
||||
// An unknown identity (an older server, a failed read) checks nothing: the
|
||||
@@ -116,10 +121,13 @@ export function CloneSourcesForm({
|
||||
for (const row of next) {
|
||||
if (row.state === "failed") continue;
|
||||
row.state = "busy";
|
||||
row.token = newCloneToken();
|
||||
show();
|
||||
try {
|
||||
const view = await api.lanes.cloneFolder(laneId, {
|
||||
url: row.url,
|
||||
progressToken: row.token,
|
||||
...(partial ? { partial: true } : {}),
|
||||
...(login?.username.trim() && login.token
|
||||
? {
|
||||
credentials: {
|
||||
@@ -151,6 +159,7 @@ export function CloneSourcesForm({
|
||||
row.note = e.message || String(err);
|
||||
}
|
||||
}
|
||||
row.token = undefined;
|
||||
show();
|
||||
}
|
||||
if (asked) setLogin(asked);
|
||||
@@ -191,6 +200,18 @@ export function CloneSourcesForm({
|
||||
className="w-full rounded border border-border bg-surface-1 px-2 py-1 font-mono text-[11px] text-fg-primary"
|
||||
/>
|
||||
<p className="text-[11px] text-fg-muted">{t("home.cloneHint", { path: parent })}</p>
|
||||
<label
|
||||
className="flex items-center gap-1.5 text-[11px] text-fg-secondary"
|
||||
title={t("clone.partialHint")}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
data-testid="workspace-clone-partial"
|
||||
checked={partial}
|
||||
onChange={(e) => setPartial(e.target.checked)}
|
||||
/>
|
||||
{t("clone.partial")}
|
||||
</label>
|
||||
{sshHosts.length > 0 && (
|
||||
<p
|
||||
role="alert"
|
||||
@@ -280,6 +301,11 @@ export function CloneSourcesForm({
|
||||
{r.url}
|
||||
</span>
|
||||
</span>
|
||||
{r.state === "busy" && r.token && (
|
||||
<div className="ml-[18px] mt-0.5">
|
||||
<CloneProgress token={r.token} />
|
||||
</div>
|
||||
)}
|
||||
{r.note && (
|
||||
<span className="ml-[18px] block text-status-danger">
|
||||
{r.note}
|
||||
|
||||
@@ -1728,7 +1728,12 @@ export function LaneChangesPanel({
|
||||
{commits?.[0]?.sha === commitCtx.menu.item.sha && (
|
||||
<MenuItem
|
||||
danger
|
||||
disabled={busy || opOpen || commitCtx.menu.item.parents.length !== 1}
|
||||
// Pushed: the server refuses it (EPUSHED), so say why here
|
||||
// instead of failing after the confirm.
|
||||
disabled={
|
||||
busy || opOpen || headPushed || commitCtx.menu.item.parents.length !== 1
|
||||
}
|
||||
title={headPushed ? t("changes.undoCommitPushed") : undefined}
|
||||
testId="lane-changes-menu-undo-commit"
|
||||
onSelect={pick(
|
||||
commitCtx.close,
|
||||
|
||||
@@ -94,7 +94,7 @@ export function SessionReplay({
|
||||
const tool = session?.tool || "claude";
|
||||
const resumable = byId(tool)?.resumeById ?? RESUMABLE.has(tool);
|
||||
const livePane = liveRunFor(lane, liveRuns, sessionId);
|
||||
const text = promptWithImages(prompt, images);
|
||||
const text = promptWithImages(prompt, images, tool === "claude");
|
||||
|
||||
const send = async () => {
|
||||
if (!text || busy || uploading || livePane || !resumable) return;
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
* shell tab at once, like the tab bar's New terminal) and one Model chip (`ModelMenu`: the tool's own list or the
|
||||
* proxy's models, plus Effort); under it the quieter choices — AI Proxy
|
||||
* (Native or a profile), Permission (Claude's mode, Codex's sandbox/approval,
|
||||
* a catalog tool's bypass) and Local / New worktree — the workspace's Recents — the sessions it
|
||||
* a catalog tool's bypass — a YOLO chip for Codex) and Local / New worktree — the workspace's Recents — the sessions it
|
||||
* owns and has not archived (`?lane=&archived=0`, with each one's state dot,
|
||||
* **Show more** past the first page, and the sidebar row's Rename/Archive in
|
||||
* its right-click menu), then the archived ones folded with Unarchive; a live session opens its pane,
|
||||
@@ -20,7 +20,6 @@
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { Checkbox } from "../Checkbox";
|
||||
import { isSubmitEnter } from "../../lib/keys";
|
||||
import { confirmDialog } from "../../lib/confirm";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
@@ -34,6 +33,7 @@ import {
|
||||
MoreHorizontal,
|
||||
ArrowUp,
|
||||
SquareTerminal,
|
||||
Zap,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
api,
|
||||
@@ -76,7 +76,7 @@ export {
|
||||
} from "./startSession";
|
||||
import { startInFreeTab } from "./startSession";
|
||||
import { WorkspaceContext } from "./WorkspaceContext";
|
||||
import { readDefaultMode } from "../../lib/sessionDefaults";
|
||||
import { readCodexDefaults, readDefaultMode } from "../../lib/sessionDefaults";
|
||||
|
||||
/** Recents' page size; Show more adds another. */
|
||||
const RECENT_PAGE = 30;
|
||||
@@ -165,12 +165,14 @@ function HomeBody({
|
||||
// Claude and Codex always take a model and an effort; a catalog tool a
|
||||
// model only when its file names the flag, an effort never.
|
||||
const hasModel = claude || codex || !!entry?.modelFlag;
|
||||
// The account's own default (first-run setup) — never bypass.
|
||||
// The account's own defaults (first-run setup, Settings → AI tools →
|
||||
// Session defaults); a pick here is for this session only.
|
||||
const [mode, setMode] = useState<PermissionMode>(readDefaultMode);
|
||||
const [sandbox, setSandbox] = useState<CodexSandbox>("workspace-write");
|
||||
const [approval, setApproval] = useState<CodexApproval>("on-request");
|
||||
// Per session, never remembered.
|
||||
const [sandbox, setSandbox] = useState<CodexSandbox>(() => readCodexDefaults().sandbox);
|
||||
const [approval, setApproval] = useState<CodexApproval>(() => readCodexDefaults().approval);
|
||||
// Off for every tool but Codex, which starts from the account's default.
|
||||
const [bypass, setBypass] = useState(false);
|
||||
useEffect(() => setBypass(codex && readCodexDefaults().bypass), [codex]);
|
||||
const canBypass = !claude && !!entry?.bypass;
|
||||
const [effort, setEffort] = useState<EffortLevel>("");
|
||||
const hasEffort = claude || codex;
|
||||
@@ -254,7 +256,7 @@ function HomeBody({
|
||||
}
|
||||
};
|
||||
|
||||
const text = promptWithImages(prompt, images);
|
||||
const text = promptWithImages(prompt, images, claude);
|
||||
const start = async () => {
|
||||
// Enter reaches here past the disabled button: the same guards again.
|
||||
// A ref, not only the state: two Enters in one tick both read `starting` false.
|
||||
@@ -607,15 +609,25 @@ function HomeBody({
|
||||
</div>
|
||||
)}
|
||||
{canBypass && (
|
||||
<span className="px-2">
|
||||
<Checkbox
|
||||
tone="danger"
|
||||
checked={bypass}
|
||||
onChange={setBypass}
|
||||
label={t("run:fields.bypass")}
|
||||
labelClassName="text-xs text-status-danger"
|
||||
// A chip like the selects beside it: quiet when off, red when on.
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={bypass}
|
||||
data-testid="workspace-home-bypass"
|
||||
title={t(codex ? "run:fields.yoloHint" : "run:fields.bypass")}
|
||||
onClick={() => setBypass(!bypass)}
|
||||
className={`flex items-center gap-1.5 rounded-md px-2 py-1 text-xs transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-status-danger/60 ${
|
||||
bypass
|
||||
? "bg-status-danger/10 font-medium text-status-danger ring-1 ring-inset ring-status-danger/30 hover:bg-status-danger/20"
|
||||
: "text-fg-secondary hover:bg-surface-3 hover:text-fg-primary"
|
||||
}`}
|
||||
>
|
||||
<Zap
|
||||
className={`h-3.5 w-3.5 ${bypass ? "fill-current" : "text-fg-muted"}`}
|
||||
aria-hidden
|
||||
/>
|
||||
</span>
|
||||
{t(codex ? "run:fields.yolo" : "run:fields.bypassShort")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{danger && (
|
||||
|
||||
@@ -786,9 +786,25 @@ describe("AddLaneModal — Clone", () => {
|
||||
)
|
||||
);
|
||||
expect(vi.mocked(api.lanes.clone).mock.calls[0]?.[0].credentials).toBeUndefined();
|
||||
expect(vi.mocked(api.lanes.clone).mock.calls[0]?.[0].partial).toBeUndefined();
|
||||
await waitFor(() => expect(onAdded).toHaveBeenCalled());
|
||||
});
|
||||
|
||||
it("sends partial only when the box is ticked", async () => {
|
||||
vi.mocked(api.lanes.clone).mockResolvedValue({
|
||||
lane: laneFixture({ id: 7 }),
|
||||
created: true,
|
||||
path: "/home/an/code/big",
|
||||
});
|
||||
const user = userEvent.setup();
|
||||
await fillClone(user, "https://git.example.com/o/big.git");
|
||||
await user.click(screen.getByTestId("add-lane-clone-partial"));
|
||||
await user.click(screen.getByRole("button", { name: "Add workspace" }));
|
||||
await waitFor(() =>
|
||||
expect(api.lanes.clone).toHaveBeenCalledWith(expect.objectContaining({ partial: true }))
|
||||
);
|
||||
});
|
||||
|
||||
it("asks for a login on ECREDENTIALS and sends it on the retry", async () => {
|
||||
vi.mocked(api.lanes.clone)
|
||||
.mockRejectedValueOnce(
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
/**
|
||||
* @file Tests for CloneProgress: a queued clone shows its place in the
|
||||
* server's clone queue, a running one git's phase, percent and speed with a
|
||||
* bar, and nothing shows before the server knows the token. Also that
|
||||
* `newCloneToken` makes a token the server accepts.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import "../../../i18n";
|
||||
|
||||
vi.mock("../../../lib/api", () => ({
|
||||
api: { lanes: { cloneProgress: vi.fn() } },
|
||||
}));
|
||||
|
||||
import { api, type CloneProgressView } from "../../../lib/api";
|
||||
import { CloneProgress, newCloneToken } from "../CloneProgress";
|
||||
|
||||
const base: CloneProgressView = {
|
||||
state: "running",
|
||||
position: null,
|
||||
running: 2,
|
||||
waiting: 1,
|
||||
limit: 2,
|
||||
phase: null,
|
||||
label: null,
|
||||
percent: null,
|
||||
detail: null,
|
||||
};
|
||||
|
||||
describe("CloneProgress", () => {
|
||||
// Braces: a function returned from beforeEach is run as its teardown.
|
||||
beforeEach(() => {
|
||||
vi.mocked(api.lanes.cloneProgress).mockReset();
|
||||
});
|
||||
|
||||
it("shows the clone's place in the queue while it waits", async () => {
|
||||
vi.mocked(api.lanes.cloneProgress).mockResolvedValue({
|
||||
...base,
|
||||
state: "queued",
|
||||
position: 1,
|
||||
});
|
||||
render(<CloneProgress token="t" />);
|
||||
expect(await screen.findByTestId("clone-progress")).toHaveTextContent(
|
||||
"Waiting in the clone queue — #1 of 1 (2/2 clones running)"
|
||||
);
|
||||
});
|
||||
|
||||
it("shows git's phase, percent and speed with a bar while it runs", async () => {
|
||||
vi.mocked(api.lanes.cloneProgress).mockResolvedValue({
|
||||
...base,
|
||||
phase: "receiving",
|
||||
label: "Receiving objects",
|
||||
percent: 45,
|
||||
detail: "450/1000, 120.50 MiB | 5.20 MiB/s",
|
||||
});
|
||||
render(<CloneProgress token="t" />);
|
||||
const line = await screen.findByTestId("clone-progress");
|
||||
expect(line).toHaveTextContent("Downloading 45%");
|
||||
expect(line).toHaveTextContent("120.50 MiB | 5.20 MiB/s");
|
||||
expect(screen.getByRole("progressbar")).toHaveAttribute("aria-valuenow", "45");
|
||||
});
|
||||
|
||||
it("shows nothing while the server does not know the token yet", async () => {
|
||||
vi.mocked(api.lanes.cloneProgress).mockImplementation(() =>
|
||||
Promise.reject(Object.assign(new Error("no such clone"), { code: "ENOTFOUND" }))
|
||||
);
|
||||
render(<CloneProgress token="t" />);
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
expect(screen.queryByTestId("clone-progress")).toBeNull();
|
||||
});
|
||||
|
||||
it("makes tokens the server accepts", () => {
|
||||
const a = newCloneToken();
|
||||
expect(a).toMatch(/^[A-Za-z0-9_-]{16,64}$/);
|
||||
expect(newCloneToken()).not.toBe(a);
|
||||
});
|
||||
});
|
||||
@@ -3,7 +3,8 @@
|
||||
* an extra folder, a host that wants a login opens the login row, and Retry
|
||||
* re-runs only the failed URLs with that login. An SSH link while the
|
||||
* account has no key is never sent — the user is pointed at Settings → Git —
|
||||
* and an HTTPS host with no saved login is flagged before Clone.
|
||||
* and an HTTPS host with no saved login is flagged before Clone. The
|
||||
* partial-clone box adds `partial: true` to every clone.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
@@ -59,12 +60,29 @@ describe("CloneSourcesForm", () => {
|
||||
|
||||
await waitFor(() => expect(onAdded).toHaveBeenCalledTimes(2));
|
||||
expect(api.lanes.cloneFolder).toHaveBeenLastCalledWith(3, {
|
||||
progressToken: expect.any(String),
|
||||
url: "https://private.test/b.git",
|
||||
credentials: { username: "dev", password: "tok", remember: true },
|
||||
});
|
||||
expect(screen.getByTestId("workspace-clone-input")).toHaveValue("");
|
||||
});
|
||||
|
||||
it("asks for partial clones when the box is ticked", async () => {
|
||||
const user = userEvent.setup();
|
||||
vi.mocked(api.lanes.cloneFolder).mockResolvedValue(view);
|
||||
render(<CloneSourcesForm laneId={3} parent="/w" onAdded={vi.fn()} />);
|
||||
await user.type(screen.getByTestId("workspace-clone-input"), "https://h/big.git");
|
||||
await user.click(screen.getByTestId("workspace-clone-partial"));
|
||||
await user.click(screen.getByTestId("workspace-clone-run"));
|
||||
await waitFor(() =>
|
||||
expect(api.lanes.cloneFolder).toHaveBeenCalledWith(3, {
|
||||
progressToken: expect.any(String),
|
||||
url: "https://h/big.git",
|
||||
partial: true,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("tells an SSH link from an HTTPS one", () => {
|
||||
expect(cloneTarget("git@github.com:o/r.git")).toEqual({ kind: "ssh", host: "github.com" });
|
||||
expect(cloneTarget("ssh://git@h.test:2222/o/r")).toEqual({ kind: "ssh", host: "h.test" });
|
||||
@@ -92,7 +110,10 @@ describe("CloneSourcesForm", () => {
|
||||
|
||||
await user.click(screen.getByTestId("workspace-clone-run"));
|
||||
await waitFor(() => expect(api.lanes.cloneFolder).toHaveBeenCalledTimes(1));
|
||||
expect(api.lanes.cloneFolder).toHaveBeenCalledWith(3, { url: "https://h.test/a.git" });
|
||||
expect(api.lanes.cloneFolder).toHaveBeenCalledWith(3, {
|
||||
url: "https://h.test/a.git",
|
||||
progressToken: expect.any(String),
|
||||
});
|
||||
expect(await screen.findByText(/has no SSH key yet/)).toBeInTheDocument();
|
||||
expect(screen.getByTestId("workspace-clone-input")).toHaveValue("git@github.com:o/r.git");
|
||||
});
|
||||
|
||||
@@ -1062,6 +1062,20 @@ describe("LaneChangesPanel", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("does not offer Undo for a top commit its upstream already holds", async () => {
|
||||
withParent();
|
||||
vi.mocked(api.lanes.git).mockResolvedValue(
|
||||
facts({ hasRemote: true, upstream: "origin/feat/thing", ahead: 0, behind: 0 })
|
||||
);
|
||||
const user = userEvent.setup();
|
||||
render(<LaneChangesPanel laneId={7} />);
|
||||
await screen.findByTestId("lane-changes-overview");
|
||||
await user.click(screen.getByRole("button", { name: "History" }));
|
||||
const undo = within(await commitMenu(user)).getByTestId("lane-changes-menu-undo-commit");
|
||||
await waitFor(() => expect(undo).toBeDisabled());
|
||||
expect(undo).toHaveAttribute("title", expect.stringMatching(/already on the remote/));
|
||||
});
|
||||
|
||||
it("cherry-picks only from another branch's history", async () => {
|
||||
withParent();
|
||||
vi.mocked(api.lanes.git).mockResolvedValue(facts());
|
||||
|
||||
@@ -245,7 +245,9 @@ describe("WorkspaceHome", () => {
|
||||
// As the Workspace page: `&run=` replaces the home with the session view.
|
||||
function HomeUntilRun() {
|
||||
const [params] = useSearchParams();
|
||||
return params.get("run") ? null : <WorkspaceHome lane={lane()} onMenuAction={() => undefined} />;
|
||||
return params.get("run") ? null : (
|
||||
<WorkspaceHome lane={lane()} onMenuAction={() => undefined} />
|
||||
);
|
||||
}
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
|
||||
@@ -29,9 +29,9 @@ import { PetSettings } from "../settings/PetSettings";
|
||||
import { Swatch, THEME_LABELS } from "../settings/AppearanceSettings";
|
||||
import { markOnboarded, useOnboarding } from "../../lib/userPrefs";
|
||||
import {
|
||||
DEFAULT_MODES,
|
||||
readDefaultMode,
|
||||
readDefaultTool,
|
||||
SAFE_MODES,
|
||||
writeDefaultMode,
|
||||
writeDefaultTool,
|
||||
type DefaultMode,
|
||||
@@ -310,7 +310,7 @@ function AgentStep() {
|
||||
writeDefaultMode(m);
|
||||
setMode(m);
|
||||
}}
|
||||
options={DEFAULT_MODES.map((m) => ({ value: m, label: t(`agent.modes.${m}`) }))}
|
||||
options={SAFE_MODES.map((m) => ({ value: m, label: t(`agent.modes.${m}`) }))}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-fg-muted">{t("agent.permissionHint")}</p>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* @file Onboarding.test.tsx
|
||||
* @description The first-run setup (docs/plans/onboarding): the account's
|
||||
* preferences are loaded into localStorage on sign-in and every later change
|
||||
* is sent back; the starting choices never hold bypass; the wizard shows only
|
||||
* is sent back; the wizard never offers bypass (a Settings default may hold it); the wizard shows only
|
||||
* for an account that is new to it, writes each pick like Settings does and
|
||||
* ends in the tour; the tour follows the real UI step by step.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
@@ -55,7 +55,7 @@ vi.mock("../../settings/PetSettings", () => ({
|
||||
}));
|
||||
|
||||
import { __resetUserPrefs, hydrate, useOnboarding } from "../../../lib/userPrefs";
|
||||
import { readDefaultMode, writeDefaultMode } from "../../../lib/sessionDefaults";
|
||||
import { readDefaultMode, SAFE_MODES, writeDefaultMode } from "../../../lib/sessionDefaults";
|
||||
import { OnboardingWizard } from "../OnboardingWizard";
|
||||
import { OnboardingTour, startTour } from "../OnboardingTour";
|
||||
import { __resetAiTools } from "../../../hooks/useAiTools";
|
||||
@@ -98,10 +98,12 @@ describe("account preferences", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("never keeps bypass as the starting permission mode", () => {
|
||||
it("keeps a bypass default from Settings, but the pet chat's safe read and the wizard never offer it", () => {
|
||||
localStorage.setItem("agenthub.permissionMode", "bypassPermissions");
|
||||
expect(readDefaultMode()).toBe("auto");
|
||||
writeDefaultMode("bypassPermissions" as never);
|
||||
expect(readDefaultMode()).toBe("bypassPermissions");
|
||||
expect(readDefaultMode({ safe: true })).toBe("auto");
|
||||
expect(SAFE_MODES).not.toContain("bypassPermissions");
|
||||
writeDefaultMode("nonsense" as never);
|
||||
expect(localStorage.getItem("agenthub.permissionMode")).toBe("bypassPermissions");
|
||||
writeDefaultMode("plan");
|
||||
expect(readDefaultMode()).toBe("plan");
|
||||
|
||||
@@ -218,6 +218,7 @@ export function LaneConsolePane({
|
||||
wsBaseUrl={window.location.origin.replace(/^http/, "ws")}
|
||||
autoFocus={autoFocus && !(chatable && chat)}
|
||||
covered={!!(chatable && chat)}
|
||||
tool={handle.tool}
|
||||
// The pane died (double Ctrl+C out of `claude`, `exit`, a crash) —
|
||||
// the tab bar moves on (another tab, or the home's prompt box)
|
||||
// rather than leaving a dead terminal the user has to click out of.
|
||||
|
||||
@@ -476,7 +476,10 @@ export const PaneChat = memo(function PaneChat({
|
||||
|
||||
// Stable, so typing in the prompt box does not re-render the conversation
|
||||
// (ConversationView is memoized).
|
||||
const openTerminal = useCallback(() => showTerminal(runId, sessionId), [runId, sessionId]);
|
||||
const openTerminal = useCallback(
|
||||
() => showTerminal(runId, sessionId, run.tool),
|
||||
[runId, sessionId, run.tool]
|
||||
);
|
||||
const openFile = useCallback(
|
||||
(named: string) => {
|
||||
// A relative path in a reply (`server/lib/x.js`) is relative to this pane's folder.
|
||||
@@ -514,7 +517,7 @@ export const PaneChat = memo(function PaneChat({
|
||||
|
||||
const send = async () => {
|
||||
if (!text || uploading) return;
|
||||
const out = promptForPane(prompt, images);
|
||||
const out = promptForPane(prompt, images, claude);
|
||||
if (!(await command(out.text, out.files))) return;
|
||||
setFollow((n) => n + 1);
|
||||
setPrompt("");
|
||||
|
||||
@@ -244,9 +244,9 @@ const ITEM =
|
||||
* the draft (its files as paths) back into the input box, never sent. */
|
||||
/** A session's chat switched to its terminal: the unsent prompt goes into
|
||||
* the CLI's input box, without Enter. */
|
||||
export function showTerminal(runId: string, sessionId: string) {
|
||||
export function showTerminal(runId: string, sessionId: string, tool?: string) {
|
||||
const draft = readChatDraft(sessionId);
|
||||
const { text, files } = promptForPane(draft.prompt, draft.images);
|
||||
const { text, files } = promptForPane(draft.prompt, draft.images, tool === "claude");
|
||||
if ((text || files.length) && pasteToRun(runId, text, files)) writeChatDraft(sessionId, "", []);
|
||||
setSessionChat(sessionId, false);
|
||||
}
|
||||
@@ -265,7 +265,7 @@ function ChatToggle({ run }: { run?: RunHandle }) {
|
||||
title={label}
|
||||
aria-label={label}
|
||||
onClick={() => {
|
||||
if (chat) return showTerminal(run.id, sessionId);
|
||||
if (chat) return showTerminal(run.id, sessionId, run.tool);
|
||||
const draft = readChatDraft(sessionId);
|
||||
const text = takeRunInput(run.id);
|
||||
if (text)
|
||||
|
||||
@@ -67,6 +67,7 @@ import { TOKEN_TONE, promptTokens } from "../../lib/promptTokens";
|
||||
import {
|
||||
ATTACH_MIME,
|
||||
addAttachments,
|
||||
claudeMention,
|
||||
droppedAttachments,
|
||||
droppedSkill,
|
||||
droppedText,
|
||||
@@ -203,21 +204,29 @@ interface PromptEditorProps {
|
||||
export type PromptImage = { path: string; url?: string; name: string };
|
||||
|
||||
/** The prompt as sent: the text, then each file's saved path — which is how
|
||||
* the CLI is handed an image or a file. */
|
||||
export function promptWithImages(text: string, images: PromptImage[]): string {
|
||||
return [text.trim(), ...images.map((i) => i.path)].filter(Boolean).join(" ");
|
||||
* the CLI is handed an image or a file; for `claude`, as `@` mentions. */
|
||||
export function promptWithImages(text: string, images: PromptImage[], claude = false): string {
|
||||
return [text.trim(), ...images.map((i) => (claude ? claudeMention(i.path) : i.path))]
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
}
|
||||
|
||||
const IMAGE_PATH = /^[^@].*\.(png|jpe?g|gif|webp)$/i;
|
||||
|
||||
/** The prompt as pasted into a live pane: the text with every non-image file
|
||||
* in it (as `promptWithImages`), and the images' paths apart — each goes in
|
||||
* a paste of its own, the one shape `claude` turns into `[Image #n]` on every
|
||||
* platform (a Windows path inside a paste of text stayed a plain path). */
|
||||
/** The prompt as pasted into a live pane. `claude`: one paste, every file an
|
||||
* `@` mention, and a trailing space so a mention at the end does not leave
|
||||
* the `@` file suggester open to swallow the Enter after it. Any other tool:
|
||||
* the text with every non-image file in it, and the images' paths apart,
|
||||
* each in a paste of its own (how `codex` attaches a pasted image path). */
|
||||
export function promptForPane(
|
||||
text: string,
|
||||
images: PromptImage[]
|
||||
images: PromptImage[],
|
||||
claude = false
|
||||
): { text: string; files: string[] } {
|
||||
if (claude) {
|
||||
const all = promptWithImages(text, images, true);
|
||||
return { text: images.length ? `${all} ` : all, files: [] };
|
||||
}
|
||||
const isImage = (i: PromptImage) => IMAGE_PATH.test(i.path);
|
||||
return {
|
||||
text: promptWithImages(text, images.filter((i) => !isImage(i))),
|
||||
|
||||
@@ -16,8 +16,8 @@
|
||||
* (`lib/terminalTheme.ts`), recoloured live without re-attaching.
|
||||
* Files (images or any other) dropped or pasted on it — Ctrl+V included — are saved on the server
|
||||
* (`POST /api/run/drop-file`) and their PATHS pasted into the pane — what a
|
||||
* local terminal does on a drop, and how `claude` attaches an image or reads
|
||||
* a file. A text drag (a Files/Changes row, a skill) is pasted as its text. The pane also registers with
|
||||
* local terminal does on a drop — as `@` mentions in a `claude` pane, which
|
||||
* reads them (an image as an image) when the prompt is sent. A text drag (a Files/Changes row, a skill) is pasted as its text. The pane also registers with
|
||||
* `lib/panePaste.ts`, so a diff-line comment from the Changes panel can be
|
||||
* pasted into whichever of the workspace's panes was focused last.
|
||||
* Keyboard: the terminal keeps Tab and Shift+Tab (claude cycles its permission
|
||||
@@ -63,6 +63,7 @@ import { terminalThemeFor } from "../../lib/terminalTheme";
|
||||
import { api } from "../../lib/api";
|
||||
import { writeClipboard } from "../../lib/clipboard";
|
||||
import { MENU_CURSOR_ROW, notePaneFocus, registerPane } from "../../lib/panePaste";
|
||||
import { claudeMention } from "../../lib/promptAttach";
|
||||
import { attachImeInput } from "../../lib/imeInput";
|
||||
import { TERMINAL_FONTS, readTerminalFont } from "../../lib/terminalFont";
|
||||
import { FONT_SIZE_EVENT, readTerminalFontSize } from "../../lib/fontSize";
|
||||
@@ -160,6 +161,9 @@ interface TerminalViewProps {
|
||||
* so it is moved out of the viewport with a transform, which keeps its
|
||||
* layout size (no PTY resize; the chat still reads and answers its menus). */
|
||||
covered?: boolean;
|
||||
/** The pane's AI tool: a `claude` pane is handed a pasted or dropped file
|
||||
* as an `@` mention (`claudeMention`), any other one its plain path. */
|
||||
tool?: string;
|
||||
}
|
||||
|
||||
/** Memoized: the Workspace re-renders its pane tree on every lane_update, and
|
||||
@@ -170,6 +174,7 @@ export const TerminalView = memo(function TerminalView({
|
||||
onExit,
|
||||
autoFocus = true,
|
||||
covered = false,
|
||||
tool,
|
||||
}: TerminalViewProps) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
// Read through a ref: an inline `onExit` from the parent changes identity on
|
||||
@@ -261,7 +266,8 @@ export const TerminalView = memo(function TerminalView({
|
||||
const saved = await Promise.all(files.map((f) => api.run.dropFile(f, runId)));
|
||||
// paste(), not a raw send: it honours bracketed-paste mode, so claude
|
||||
// sees pasted paths rather than typed keystrokes.
|
||||
termRef.current?.paste(saved.map((r) => r.path).join(" ") + " ");
|
||||
const asText = tool === "claude" ? claudeMention : (p: string) => p;
|
||||
termRef.current?.paste(saved.map((r) => asText(r.path)).join(" ") + " ");
|
||||
termRef.current?.focus();
|
||||
setDrop(null);
|
||||
} catch {
|
||||
|
||||
@@ -430,7 +430,7 @@ describe("PaneChat — the unsent prompt", () => {
|
||||
expect(screen.getByPlaceholderText("Write a message…")).toHaveValue("half typed");
|
||||
expect(screen.getByText("a.ts")).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByTestId("pane-chat-send"));
|
||||
expect(submitToRun).toHaveBeenCalledWith("agenthub-lane-1", "half typed @src/a.ts", []);
|
||||
expect(submitToRun).toHaveBeenCalledWith("agenthub-lane-1", "half typed @src/a.ts ", []);
|
||||
await waitFor(() => expect(localStorage.getItem("agenthub.chat.draft.s1")).toBeNull());
|
||||
});
|
||||
|
||||
@@ -462,7 +462,9 @@ describe("PaneChat — the unsent prompt", () => {
|
||||
fireEvent.click(screen.getByTestId("pane-chat-send"));
|
||||
expect(submitToRun).toHaveBeenCalledWith(
|
||||
"agenthub-lane-1",
|
||||
'read @"/home/u/My Docs/spec.md" @/home/u/b.ts',
|
||||
// A trailing space: an @ mention at the end would leave claude's file
|
||||
// suggester open, and it takes the Enter.
|
||||
'read @"/home/u/My Docs/spec.md" @/home/u/b.ts ',
|
||||
[]
|
||||
);
|
||||
});
|
||||
|
||||
@@ -606,4 +606,26 @@ describe("promptForPane — images apart, every other file in the text", () => {
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("hands claude every file as an @ mention in one paste, with a trailing space", () => {
|
||||
// A bare pasted path stayed plain text on Windows, and a path in the
|
||||
// command-line prompt is never an image; claude reads an @ mention at submit.
|
||||
const images: PromptImage[] = [
|
||||
{ path: "C:\\Users\\u\\.claude\\agenthub\\drops\\f326fc666d3bdd92-image.png", name: "image.png" },
|
||||
{ path: "/data/drops/0123456789abcdef-log.txt", name: "log.txt" },
|
||||
{ path: '@"/home/u/My Docs/a.PNG"', name: "a.PNG" },
|
||||
{ path: "C:\\Users\\Jo Do\\drops\\0123456789abcdef.png", name: "b.png" },
|
||||
];
|
||||
expect(promptForPane(" look ", images, true)).toEqual({
|
||||
text:
|
||||
'look @C:/Users/u/.claude/agenthub/drops/f326fc666d3bdd92-image.png ' +
|
||||
'@/data/drops/0123456789abcdef-log.txt @"/home/u/My Docs/a.PNG" ' +
|
||||
'@"C:/Users/Jo Do/drops/0123456789abcdef.png" ',
|
||||
files: [],
|
||||
});
|
||||
expect(promptForPane("just text", [], true)).toEqual({ text: "just text", files: [] });
|
||||
expect(promptWithImages("see", [images[1]!], true)).toBe(
|
||||
"see @/data/drops/0123456789abcdef-log.txt"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
/**
|
||||
* @file SessionDefaultsSettings — Settings → AI tools → Session defaults: the
|
||||
* account's own starting choices for a new Claude Code session (permission
|
||||
* mode, Bypass included) and a new Codex session (sandbox, approval, bypass),
|
||||
* which the workspace home's prompt box starts from (`lib/sessionDefaults.ts`,
|
||||
* synced per account). Turning bypass on as a default asks first; a pick in
|
||||
* the prompt box still changes only that one session.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ShieldAlert } from "lucide-react";
|
||||
import type { CodexApproval, CodexSandbox } from "../../lib/api";
|
||||
import { useAiTools } from "../../hooks/useAiTools";
|
||||
import { confirmDialog } from "../../lib/confirm";
|
||||
import {
|
||||
CODEX_APPROVALS,
|
||||
CODEX_SANDBOXES,
|
||||
DEFAULT_MODES,
|
||||
readCodexDefaults,
|
||||
readDefaultMode,
|
||||
writeCodexDefaults,
|
||||
writeDefaultMode,
|
||||
type DefaultMode,
|
||||
} from "../../lib/sessionDefaults";
|
||||
import { SettingsRow, SettingsSection, SettingsSegmented, SettingsSwitchRow } from "./SettingsRow";
|
||||
|
||||
const MODE_KEYS: Record<DefaultMode, string> = {
|
||||
auto: "Auto",
|
||||
acceptEdits: "AcceptEdits",
|
||||
default: "Default",
|
||||
plan: "Plan",
|
||||
bypassPermissions: "Bypass",
|
||||
};
|
||||
const SANDBOX_KEYS: Record<CodexSandbox, string> = {
|
||||
"read-only": "sandboxReadOnly",
|
||||
"workspace-write": "sandboxWorkspaceWrite",
|
||||
"danger-full-access": "sandboxFullAccess",
|
||||
};
|
||||
const APPROVAL_KEYS: Record<CodexApproval, string> = {
|
||||
"on-request": "approvalOnRequest",
|
||||
never: "approvalNever",
|
||||
};
|
||||
|
||||
function Danger({ children }: { children: string }) {
|
||||
return (
|
||||
<p className="flex items-start gap-2 py-2 text-xs text-status-danger">
|
||||
<ShieldAlert className="mt-0.5 h-3.5 w-3.5 shrink-0" />
|
||||
{children}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
export function SessionDefaultsSettings() {
|
||||
const { t } = useTranslation("settings");
|
||||
const aiTools = useAiTools();
|
||||
const shown = (id: string) => aiTools.tools.some((x) => x.id === id && x.enabled);
|
||||
const [mode, setMode] = useState<DefaultMode>(() => readDefaultMode() as DefaultMode);
|
||||
const [codex, setCodex] = useState(readCodexDefaults);
|
||||
|
||||
const askBypass = (tool: string) =>
|
||||
confirmDialog({
|
||||
title:
|
||||
tool === "Codex"
|
||||
? t("sessionDefaults.codexConfirmTitle")
|
||||
: t("sessionDefaults.confirmTitle", { tool }),
|
||||
message: t("sessionDefaults.confirmBody", { tool }),
|
||||
confirmLabel: t("sessionDefaults.confirmLabel"),
|
||||
});
|
||||
|
||||
const pickMode = async (m: DefaultMode) => {
|
||||
if (m === "bypassPermissions" && !(await askBypass("Claude Code"))) return;
|
||||
writeDefaultMode(m);
|
||||
setMode(m);
|
||||
};
|
||||
const patchCodex = (patch: Partial<typeof codex>) => {
|
||||
writeCodexDefaults(patch);
|
||||
setCodex((c) => ({ ...c, ...patch }));
|
||||
};
|
||||
const pickCodexBypass = async (on: boolean) => {
|
||||
if (on && !(await askBypass("Codex"))) return;
|
||||
patchCodex({ bypass: on });
|
||||
};
|
||||
|
||||
if (!shown("claude") && !shown("codex")) return null;
|
||||
return (
|
||||
<SettingsSection
|
||||
id="session-defaults"
|
||||
title={t("sessionDefaults.title")}
|
||||
description={t("sessionDefaults.description")}
|
||||
>
|
||||
{shown("claude") && (
|
||||
<>
|
||||
<SettingsRow
|
||||
label={t("sessionDefaults.claudeMode")}
|
||||
description={t("sessionDefaults.claudeModeDesc")}
|
||||
control={
|
||||
<SettingsSegmented
|
||||
ariaLabel={t("sessionDefaults.claudeMode")}
|
||||
value={mode}
|
||||
onChange={(m) => void pickMode(m)}
|
||||
options={DEFAULT_MODES.map((m) => ({
|
||||
value: m,
|
||||
label: t(`lanes:home.permission${MODE_KEYS[m]}`),
|
||||
}))}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
{mode === "bypassPermissions" && <Danger>{t("run:hint.permissionWarning")}</Danger>}
|
||||
</>
|
||||
)}
|
||||
{shown("codex") && (
|
||||
<>
|
||||
<SettingsRow
|
||||
label={t("sessionDefaults.codexSandbox")}
|
||||
control={
|
||||
<SettingsSegmented
|
||||
ariaLabel={t("sessionDefaults.codexSandbox")}
|
||||
value={codex.sandbox}
|
||||
onChange={(sandbox) => patchCodex({ sandbox })}
|
||||
options={CODEX_SANDBOXES.map((v) => ({
|
||||
value: v,
|
||||
label: t(`run:fields.${SANDBOX_KEYS[v]}`),
|
||||
}))}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<SettingsRow
|
||||
label={t("sessionDefaults.codexApproval")}
|
||||
control={
|
||||
<SettingsSegmented
|
||||
ariaLabel={t("sessionDefaults.codexApproval")}
|
||||
value={codex.approval}
|
||||
onChange={(approval) => patchCodex({ approval })}
|
||||
options={CODEX_APPROVALS.map((v) => ({
|
||||
value: v,
|
||||
label: t(`run:fields.${APPROVAL_KEYS[v]}`),
|
||||
}))}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<SettingsSwitchRow
|
||||
label={t("sessionDefaults.codexBypass")}
|
||||
description={t("sessionDefaults.codexBypassDesc")}
|
||||
checked={codex.bypass}
|
||||
onChange={(on) => void pickCodexBypass(on)}
|
||||
/>
|
||||
{codex.bypass && <Danger>{t("run:hint.bypassWarning", { tool: "Codex" })}</Danger>}
|
||||
</>
|
||||
)}
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* @file SessionDefaultsSettings.test.tsx
|
||||
* @description Session defaults: Bypass becomes Claude's default permission
|
||||
* mode or Codex's default bypass only after the confirm, a cancelled confirm
|
||||
* keeps the old default, the plain modes save at once, and the pet chat's
|
||||
* safe read never returns bypass.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { SessionDefaultsSettings } from "../SessionDefaultsSettings";
|
||||
import { confirmDialog } from "../../../lib/confirm";
|
||||
import { readCodexDefaults, readDefaultMode } from "../../../lib/sessionDefaults";
|
||||
|
||||
const aiTools = vi.hoisted(() => ({
|
||||
tools: [
|
||||
{ id: "claude", label: "Claude Code", installed: true, enabled: true },
|
||||
{ id: "codex", label: "Codex", installed: true, enabled: true },
|
||||
],
|
||||
}));
|
||||
vi.mock("../../../hooks/useAiTools", () => ({ useAiTools: () => aiTools }));
|
||||
vi.mock("../../../lib/confirm", () => ({ confirmDialog: vi.fn() }));
|
||||
const confirm = vi.mocked(confirmDialog);
|
||||
|
||||
const radio = (name: RegExp) => screen.getAllByRole("radio", { name })[0]!;
|
||||
|
||||
describe("SessionDefaultsSettings", () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
confirm.mockReset();
|
||||
});
|
||||
|
||||
it("saves a plain mode without asking", () => {
|
||||
render(<SessionDefaultsSettings />);
|
||||
fireEvent.click(radio(/plan/i));
|
||||
expect(confirm).not.toHaveBeenCalled();
|
||||
expect(readDefaultMode()).toBe("plan");
|
||||
});
|
||||
|
||||
it("makes Bypass Claude's default only once confirmed", async () => {
|
||||
confirm.mockResolvedValueOnce(false);
|
||||
render(<SessionDefaultsSettings />);
|
||||
fireEvent.click(radio(/bypass/i));
|
||||
await waitFor(() => expect(confirm).toHaveBeenCalledTimes(1));
|
||||
expect(readDefaultMode()).toBe("auto");
|
||||
|
||||
confirm.mockResolvedValueOnce(true);
|
||||
fireEvent.click(radio(/bypass/i));
|
||||
await waitFor(() => expect(readDefaultMode()).toBe("bypassPermissions"));
|
||||
expect(readDefaultMode({ safe: true })).toBe("auto");
|
||||
});
|
||||
|
||||
it("turns Codex's default bypass on only once confirmed, off without asking", async () => {
|
||||
confirm.mockResolvedValueOnce(true);
|
||||
render(<SessionDefaultsSettings />);
|
||||
const sw = screen.getByRole("switch");
|
||||
fireEvent.click(sw);
|
||||
await waitFor(() => expect(readCodexDefaults().bypass).toBe(true));
|
||||
fireEvent.click(screen.getByRole("switch"));
|
||||
await waitFor(() => expect(readCodexDefaults().bypass).toBe(false));
|
||||
expect(confirm).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("shows nothing when neither Claude nor Codex is enabled", () => {
|
||||
const saved = aiTools.tools;
|
||||
aiTools.tools = [];
|
||||
const { container } = render(<SessionDefaultsSettings />);
|
||||
expect(container.firstChild).toBeNull();
|
||||
aiTools.tools = saved;
|
||||
});
|
||||
});
|
||||
@@ -220,6 +220,14 @@
|
||||
"clone.remember": "Remember this login for my account",
|
||||
"clone.sshRefused": "{{host}} refused the SSH login. Add your public key to that host, or make one in Settings → Git.",
|
||||
"clone.sshSettings": "Open Settings → Git",
|
||||
"clone.partial": "Partial clone (faster for big repos)",
|
||||
"clone.partialHint": "Downloads every commit but a file's contents only when it is checked out (--filter=blob:none). Checking out an old revision later fetches from the remote.",
|
||||
"clone.queued": "Waiting in the clone queue — #{{position}} of {{waiting}} ({{running}}/{{limit}} clones running)",
|
||||
"clone.connecting": "Connecting…",
|
||||
"clone.phase.remote": "Server preparing",
|
||||
"clone.phase.receiving": "Downloading",
|
||||
"clone.phase.resolving": "Unpacking",
|
||||
"clone.phase.checkout": "Writing files",
|
||||
"clone.sshNoKey": "Not cloned: your account has no SSH key yet. Make one in Settings → Git, add its public key to {{host}}, then retry.",
|
||||
"moreActions": "More actions",
|
||||
"pipelinePicker.label": "Pipeline template",
|
||||
@@ -299,6 +307,7 @@
|
||||
"changes.undoCommitTitle": "Undo the last commit?",
|
||||
"changes.undoCommitBody": "{{short}} \"{{subject}}\" is removed from the branch. Its changes stay staged, so you can edit them and commit again.",
|
||||
"changes.undoCommitConfirm": "Undo commit",
|
||||
"changes.undoCommitPushed": "The last commit is already on the remote branch (as of the last fetch); undoing it would rewrite shared history. Revert it instead — or Fetch first if the remote no longer has it.",
|
||||
"changes.revertConfirm": "Add a commit on {{branch}} that undoes {{short}} \"{{subject}}\"? Files in this workspace change now.",
|
||||
"changes.cherryPickConfirm": "Apply {{short}} \"{{subject}}\" from {{from}} onto {{branch}} as a new commit? Files in this workspace change now.",
|
||||
"changes.amendTitle": "Amend the last commit?",
|
||||
|
||||
@@ -50,6 +50,9 @@
|
||||
"permissionAuto": "auto (recommended - asks only for risky actions)",
|
||||
"permissionBypass": "bypassPermissions (DANGER - allows everything without asking)",
|
||||
"bypass": "Bypass permissions for this run",
|
||||
"yolo": "YOLO",
|
||||
"yoloHint": "YOLO mode for this session: no sandbox, no approvals (--dangerously-bypass-approvals-and-sandbox)",
|
||||
"bypassShort": "Bypass",
|
||||
"permissionDefault": "default (interactive prompts - sessions may hang headlessly)",
|
||||
"permissionMode": "Permission mode",
|
||||
"permissionPlan": "plan (read-only planning)",
|
||||
|
||||
@@ -210,7 +210,13 @@
|
||||
"moreSteps_other": "+{{count}} more",
|
||||
"quiet": "no new activity for {{time}}",
|
||||
"askDeclined": "Declined — no answer given",
|
||||
"turnDone": "Done in {{time}}"
|
||||
"turnDone": "Done in {{time}}",
|
||||
"turnReplied": "Replied in {{time}}",
|
||||
"agentsStill_one": "{{count}} agent still running",
|
||||
"agentsStill_other": "{{count}} agents still running",
|
||||
"agentsRunning_one": "{{count}} agent running in the background",
|
||||
"agentsRunning_other": "{{count}} agents running in the background",
|
||||
"agentContext": "{{tokens}} tokens"
|
||||
},
|
||||
"overview": {
|
||||
"agent": "Agent",
|
||||
|
||||
@@ -1307,6 +1307,20 @@
|
||||
"labelDesc": "A Claude or Codex session first shows its conversation like Claude Desktop, with a message box, instead of its terminal. Each session then keeps its own view: the chat/terminal button beside a pane's split buttons switches it. Permission prompts and menus still need the terminal.",
|
||||
"saveFailed": "Could not save: {{message}}"
|
||||
},
|
||||
"sessionDefaults": {
|
||||
"title": "Session defaults",
|
||||
"description": "What a new session starts with in a workspace's prompt box. Saved for your account only; a pick in the prompt box changes only that one session.",
|
||||
"claudeMode": "Claude Code permission mode",
|
||||
"claudeModeDesc": "Bypass permissions lets every new Claude session run any tool without asking.",
|
||||
"codexSandbox": "Codex sandbox",
|
||||
"codexApproval": "Codex approval",
|
||||
"codexBypass": "Codex: YOLO mode",
|
||||
"codexBypassDesc": "Every new Codex session starts in YOLO mode — `--dangerously-bypass-approvals-and-sandbox`: no sandbox, no approvals.",
|
||||
"confirmTitle": "Make bypass the default for {{tool}}?",
|
||||
"confirmBody": "Every new {{tool}} session you start will run commands and edit files without asking, until you change this back. Use it only with folders and prompts you trust.",
|
||||
"confirmLabel": "Make it the default",
|
||||
"codexConfirmTitle": "Make YOLO the default for Codex?"
|
||||
},
|
||||
"agentInstructions": {
|
||||
"title": "My agent instructions",
|
||||
"description": "Your own rules in Markdown — coding style, language, workflow. Every new session in a workspace you own starts with them, before that workspace's own instructions. Saved for your account only; sessions already running keep what they started with.",
|
||||
|
||||
@@ -220,6 +220,14 @@
|
||||
"clone.remember": "Lưu đăng nhập này cho tài khoản của tôi",
|
||||
"clone.sshRefused": "{{host}} từ chối đăng nhập SSH. Hãy thêm public key của bạn vào host đó, hoặc tạo key trong Cài đặt → Git.",
|
||||
"clone.sshSettings": "Mở Cài đặt → Git",
|
||||
"clone.partial": "Clone một phần (nhanh hơn với repo lớn)",
|
||||
"clone.partialHint": "Tải toàn bộ commit nhưng chỉ tải nội dung file khi cần checkout (--filter=blob:none). Checkout một phiên bản cũ sau này sẽ tải thêm từ remote.",
|
||||
"clone.queued": "Đang chờ trong hàng clone — thứ {{position}}/{{waiting}} ({{running}}/{{limit}} clone đang chạy)",
|
||||
"clone.connecting": "Đang kết nối…",
|
||||
"clone.phase.remote": "Máy chủ đang chuẩn bị",
|
||||
"clone.phase.receiving": "Đang tải về",
|
||||
"clone.phase.resolving": "Đang giải nén",
|
||||
"clone.phase.checkout": "Đang ghi file",
|
||||
"clone.sshNoKey": "Chưa clone: tài khoản của bạn chưa có SSH key. Hãy tạo key trong Cài đặt → Git, thêm public key vào {{host}}, rồi thử lại.",
|
||||
"moreActions": "Thêm hành động",
|
||||
"pipelinePicker.label": "Mẫu pipeline",
|
||||
@@ -299,6 +307,7 @@
|
||||
"changes.undoCommitTitle": "Hoàn tác commit cuối?",
|
||||
"changes.undoCommitBody": "{{short}} \"{{subject}}\" sẽ bị gỡ khỏi nhánh. Thay đổi của nó vẫn được giữ ở trạng thái staged để sửa và commit lại.",
|
||||
"changes.undoCommitConfirm": "Hoàn tác commit",
|
||||
"changes.undoCommitPushed": "Commit cuối đã có trên nhánh remote (theo lần fetch gần nhất); hoàn tác sẽ viết lại lịch sử đã chia sẻ. Hãy Revert — hoặc Fetch trước nếu remote không còn commit này.",
|
||||
"changes.revertConfirm": "Thêm một commit trên {{branch}} để đảo ngược {{short}} \"{{subject}}\"? File trong workspace sẽ thay đổi ngay.",
|
||||
"changes.cherryPickConfirm": "Áp {{short}} \"{{subject}}\" từ {{from}} vào {{branch}} thành một commit mới? File trong workspace sẽ thay đổi ngay.",
|
||||
"changes.amendTitle": "Amend commit cuối?",
|
||||
|
||||
@@ -50,6 +50,9 @@
|
||||
"permissionAuto": "auto (khuyên dùng - chỉ hỏi khi hành động rủi ro)",
|
||||
"permissionBypass": "bypassPermissions (NGUY HIỂM - cho phép tất cả mà không hỏi)",
|
||||
"bypass": "Bỏ qua xin quyền cho lần chạy này",
|
||||
"yolo": "YOLO",
|
||||
"yoloHint": "Chế độ YOLO cho phiên này: không sandbox, không hỏi phê duyệt (--dangerously-bypass-approvals-and-sandbox)",
|
||||
"bypassShort": "Bỏ qua quyền",
|
||||
"permissionDefault": "default (prompt tương tác - headless sẽ treo)",
|
||||
"permissionMode": "Chế độ quyền",
|
||||
"permissionPlan": "plan (chỉ đọc, lập kế hoạch)",
|
||||
|
||||
@@ -210,7 +210,13 @@
|
||||
"moreSteps_other": "+{{count}} việc nữa",
|
||||
"quiet": "không có hoạt động mới trong {{time}}",
|
||||
"askDeclined": "Đã từ chối — không trả lời",
|
||||
"turnDone": "Xong trong {{time}}"
|
||||
"turnDone": "Xong trong {{time}}",
|
||||
"turnReplied": "Đã trả lời trong {{time}}",
|
||||
"agentsStill_one": "còn {{count}} agent đang chạy",
|
||||
"agentsStill_other": "còn {{count}} agent đang chạy",
|
||||
"agentsRunning_one": "{{count}} agent đang chạy nền",
|
||||
"agentsRunning_other": "{{count}} agent đang chạy nền",
|
||||
"agentContext": "{{tokens}} token"
|
||||
},
|
||||
"overview": {
|
||||
"agent": "Agent",
|
||||
|
||||
@@ -1307,6 +1307,20 @@
|
||||
"labelDesc": "Session Claude hoặc Codex sẽ mở hội thoại như Claude Desktop, kèm ô nhập tin, thay cho terminal. Sau đó mỗi session giữ dạng xem riêng: nút chat/terminal cạnh nút split của pane để chuyển. Hỏi quyền và menu vẫn cần terminal.",
|
||||
"saveFailed": "Không lưu được: {{message}}"
|
||||
},
|
||||
"sessionDefaults": {
|
||||
"title": "Mặc định cho phiên mới",
|
||||
"description": "Phiên mới bắt đầu với các lựa chọn này trong ô prompt của workspace. Chỉ lưu cho tài khoản của bạn; chọn khác trong ô prompt chỉ đổi cho phiên đó.",
|
||||
"claudeMode": "Chế độ quyền của Claude Code",
|
||||
"claudeModeDesc": "Bỏ qua quyền cho mọi phiên Claude mới chạy mọi tool mà không hỏi.",
|
||||
"codexSandbox": "Sandbox của Codex",
|
||||
"codexApproval": "Phê duyệt của Codex",
|
||||
"codexBypass": "Codex: chế độ YOLO",
|
||||
"codexBypassDesc": "Mọi phiên Codex mới chạy ở chế độ YOLO — `--dangerously-bypass-approvals-and-sandbox`: không sandbox, không hỏi phê duyệt.",
|
||||
"confirmTitle": "Đặt bỏ qua quyền làm mặc định cho {{tool}}?",
|
||||
"confirmBody": "Mọi phiên {{tool}} mới bạn mở sẽ chạy lệnh và sửa file mà không hỏi, cho tới khi bạn đổi lại. Chỉ dùng với thư mục và prompt bạn tin tưởng.",
|
||||
"confirmLabel": "Đặt làm mặc định",
|
||||
"codexConfirmTitle": "Đặt YOLO làm mặc định cho Codex?"
|
||||
},
|
||||
"agentInstructions": {
|
||||
"title": "Hướng dẫn agent của tôi",
|
||||
"description": "Quy tắc riêng của bạn bằng Markdown — style code, ngôn ngữ, quy trình. Mọi session mới trong workspace của bạn đều nhận chúng, trước hướng dẫn riêng của workspace. Chỉ lưu cho tài khoản của bạn; session đang chạy giữ nội dung lúc khởi động.",
|
||||
|
||||
@@ -3576,11 +3576,22 @@ export const api = {
|
||||
title?: string;
|
||||
tool?: LaneTool;
|
||||
credentials?: { username: string; password: string; remember?: boolean };
|
||||
/** Blobless clone (`--filter=blob:none`): file contents fetched on checkout. */
|
||||
partial?: boolean;
|
||||
/** This clone's name for {@link lanes.cloneProgress}. */
|
||||
progressToken?: string;
|
||||
}) =>
|
||||
request<{ lane: Lane; created: boolean; path: string }>("/lanes/clone", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
/**
|
||||
* GET /api/lanes/clone/progress/:token — a clone's place in the
|
||||
* machine-wide queue, then git's own progress; 404 until the POST
|
||||
* carrying `progressToken` arrives, and a minute after it ends.
|
||||
*/
|
||||
cloneProgress: (token: string) =>
|
||||
request<CloneProgressView>(`/lanes/clone/progress/${encodeURIComponent(token)}`),
|
||||
/**
|
||||
* GET /api/lanes/pipelines — every pipeline template the server can render
|
||||
* a lane against, built-in plus any `DASHBOARD_PIPELINES_DIR` override.
|
||||
@@ -3977,6 +3988,9 @@ export const api = {
|
||||
url: string;
|
||||
name?: string;
|
||||
credentials?: { username: string; password: string; remember?: boolean };
|
||||
/** Blobless clone, like {@link lanes.clone}'s `partial`. */
|
||||
partial?: boolean;
|
||||
progressToken?: string;
|
||||
}
|
||||
) =>
|
||||
request<LaneSourcesView & { path: string }>(`/lanes/${id}/sources/clone`, {
|
||||
@@ -5290,6 +5304,23 @@ export interface LaneSource {
|
||||
}
|
||||
|
||||
/** Response shape of GET /api/lanes/:id/sources (and of every sources write). */
|
||||
/** `GET /api/lanes/clone/progress/:token` — see server/lib/clone-queue.js. */
|
||||
export interface CloneProgressView {
|
||||
state: "queued" | "running" | "done" | "failed";
|
||||
/** 1-based place among the waiting clones while `queued`. */
|
||||
position: number | null;
|
||||
/** Clones running / waiting machine-wide, and how many may run at once. */
|
||||
running: number;
|
||||
waiting: number;
|
||||
limit: number;
|
||||
phase: "remote" | "receiving" | "resolving" | "checkout" | "other" | null;
|
||||
/** git's own words for the phase (shown for `other`). */
|
||||
label: string | null;
|
||||
percent: number | null;
|
||||
/** e.g. "450/1000, 120.50 MiB | 5.20 MiB/s". */
|
||||
detail: string | null;
|
||||
}
|
||||
|
||||
export interface LaneSourcesView {
|
||||
instructions: string;
|
||||
/** The workspace's tool can take folders and instructions (claude, codex). */
|
||||
|
||||
@@ -14,9 +14,10 @@
|
||||
* terminal moves the unsent prompt across (`takeRunInput` reads the TUI's
|
||||
* input box off the screen and empties it; the chat's draft is pasted back
|
||||
* without Enter), so the half-typed text is never left behind on the other side.
|
||||
* Attached images' paths go after the text, each in a paste of its own: `claude`
|
||||
* turns a paste that is only an image's path into `[Image #n]`, but left a
|
||||
* Windows path (`C:\…\drops\…png`) inside a paste of text as a plain path.
|
||||
* Attached images' paths go after the text, each in a paste of its own — how
|
||||
* `codex` attaches one; a `claude` pane gets every file as an `@` mention in
|
||||
* the text instead (`promptForPane`), since a pasted path stayed a plain
|
||||
* `C:\…\drops\…png` there on Windows.
|
||||
* The Chat view also answers a menu the CLI asks with (`parsePaneMenu` reads
|
||||
* it off the screen; `chooseRunOption`/`answerRunMenu` press its digit, the
|
||||
* arrows and Enter, or type into its "Type something" row).
|
||||
|
||||
@@ -4,8 +4,9 @@
|
||||
* file or folder dragged from the Files panel (`ATTACH_MIME`, read back by
|
||||
* `PromptEditor`'s drop) or from a Chat pane's activity panel
|
||||
* (`dragAttachments`, `dragSkill`: a skill becomes the prompt's `/command`) and the files uploaded through a prompt box in the
|
||||
* last day (`recentUploads`, kept in this browser). An attachment is a path
|
||||
* sent with the prompt, so nothing here touches the server.
|
||||
* last day (`recentUploads`, kept in this browser), and `claudeMention`, the
|
||||
* `@` form a file is handed to `claude` in. An attachment is a path sent with
|
||||
* the prompt, so nothing here touches the server.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
@@ -101,6 +102,19 @@ export function droppedText(
|
||||
}
|
||||
}
|
||||
|
||||
/** An attached file as Claude Code's `@` mention, which `claude` reads at
|
||||
* submit — an image as an image — whether it came on the command line or in
|
||||
* a paste. A bare pasted PATH becomes `[Image #n]` only when the TUI catches
|
||||
* the paste on its own and finishes before Enter: on Windows (psmux) it
|
||||
* stayed a plain `C:\…\drops\…png` in the prompt, and on the command line
|
||||
* a path is never an image at all. A Windows path's backslashes become `/`
|
||||
* (Windows takes either); a path with a space is quoted. */
|
||||
export function claudeMention(path: string): string {
|
||||
if (path.startsWith("@")) return path;
|
||||
const p = /^[A-Za-z]:\\/.test(path) ? path.replace(/\\/g, "/") : path;
|
||||
return /\s/.test(p) ? `@"${p}"` : `@${p}`;
|
||||
}
|
||||
|
||||
export const SKILL_MIME = "application/x-agenthub-skill";
|
||||
// A skill's name as Claude Code types it: `name` or `plugin:name`.
|
||||
const SKILL_RE = /^[\w.-]+(?::[\w.-]+)?$/;
|
||||
|
||||
@@ -1,22 +1,37 @@
|
||||
/**
|
||||
* @file sessionDefaults.ts
|
||||
* @description The account's own starting choices for new work, picked in the
|
||||
* first-run setup (docs/plans/onboarding) and synced per account like every
|
||||
* other preference (`lib/userPrefs.ts`): the AI a new workspace starts on, and
|
||||
* the permission mode a new Claude session starts in. Bypass is NEVER one of
|
||||
* them — it stays the per-session switch (CLAUDE.md), so `bypassPermissions`
|
||||
* is refused on read and on write.
|
||||
* first-run setup (docs/plans/onboarding) or Settings → AI tools → Session
|
||||
* defaults, and synced per account like every other preference
|
||||
* (`lib/userPrefs.ts`): the AI a new workspace starts on, the permission mode
|
||||
* a new Claude session starts in, and a new Codex session's sandbox, approval
|
||||
* and bypass. Bypass (Claude's `bypassPermissions`, Codex's bypass switch) may
|
||||
* be a default only because the user turned it on in Settings behind a
|
||||
* confirm — the first-run setup never offers it, and the pet chat never
|
||||
* starts in it (`readDefaultMode({ safe: true })`).
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import type { PermissionMode } from "./api";
|
||||
import type { CodexApproval, CodexSandbox, PermissionMode } from "./api";
|
||||
|
||||
const TOOL_KEY = "agenthub.defaultTool";
|
||||
const MODE_KEY = "agenthub.permissionMode";
|
||||
const CODEX_SANDBOX_KEY = "agenthub.codex.sandbox";
|
||||
const CODEX_APPROVAL_KEY = "agenthub.codex.approval";
|
||||
const CODEX_BYPASS_KEY = "agenthub.codex.bypass";
|
||||
|
||||
/** The permission modes a default may be. */
|
||||
export const DEFAULT_MODES = ["auto", "acceptEdits", "default", "plan"] as const;
|
||||
export const DEFAULT_MODES = ["auto", "acceptEdits", "default", "plan", "bypassPermissions"] as const;
|
||||
export type DefaultMode = (typeof DEFAULT_MODES)[number];
|
||||
/** The ones the first-run setup offers: never bypass. */
|
||||
export const SAFE_MODES = DEFAULT_MODES.filter((m) => m !== "bypassPermissions");
|
||||
|
||||
export const CODEX_SANDBOXES: readonly CodexSandbox[] = [
|
||||
"read-only",
|
||||
"workspace-write",
|
||||
"danger-full-access",
|
||||
];
|
||||
export const CODEX_APPROVALS: readonly CodexApproval[] = ["on-request", "never"];
|
||||
|
||||
function read(key: string): string | null {
|
||||
try {
|
||||
@@ -37,11 +52,36 @@ function write(key: string, value: string): void {
|
||||
export const readDefaultTool = (): string | null => read(TOOL_KEY);
|
||||
export const writeDefaultTool = (id: string): void => write(TOOL_KEY, id);
|
||||
|
||||
/** The permission mode a new Claude session starts in ("auto" when none was picked). */
|
||||
export function readDefaultMode(): PermissionMode {
|
||||
/** The permission mode a new Claude session starts in ("auto" when none was
|
||||
* picked); `safe` turns a bypass default into "auto". */
|
||||
export function readDefaultMode({ safe = false }: { safe?: boolean } = {}): PermissionMode {
|
||||
const v = read(MODE_KEY);
|
||||
if (safe && v === "bypassPermissions") return "auto";
|
||||
return (DEFAULT_MODES as readonly string[]).includes(v ?? "") ? (v as DefaultMode) : "auto";
|
||||
}
|
||||
export function writeDefaultMode(mode: DefaultMode): void {
|
||||
if ((DEFAULT_MODES as readonly string[]).includes(mode)) write(MODE_KEY, mode);
|
||||
}
|
||||
|
||||
export interface CodexDefaults {
|
||||
sandbox: CodexSandbox;
|
||||
approval: CodexApproval;
|
||||
bypass: boolean;
|
||||
}
|
||||
|
||||
/** A new Codex session's sandbox, approval and bypass (Codex's own defaults when none was picked). */
|
||||
export function readCodexDefaults(): CodexDefaults {
|
||||
const sandbox = read(CODEX_SANDBOX_KEY) as CodexSandbox | null;
|
||||
const approval = read(CODEX_APPROVAL_KEY) as CodexApproval | null;
|
||||
return {
|
||||
sandbox: sandbox && CODEX_SANDBOXES.includes(sandbox) ? sandbox : "workspace-write",
|
||||
approval: approval && CODEX_APPROVALS.includes(approval) ? approval : "on-request",
|
||||
bypass: read(CODEX_BYPASS_KEY) === "true",
|
||||
};
|
||||
}
|
||||
export function writeCodexDefaults(patch: Partial<CodexDefaults>): void {
|
||||
if (patch.sandbox && CODEX_SANDBOXES.includes(patch.sandbox)) write(CODEX_SANDBOX_KEY, patch.sandbox);
|
||||
if (patch.approval && CODEX_APPROVALS.includes(patch.approval))
|
||||
write(CODEX_APPROVAL_KEY, patch.approval);
|
||||
if (patch.bypass !== undefined) write(CODEX_BYPASS_KEY, String(patch.bypass === true));
|
||||
}
|
||||
|
||||
@@ -38,6 +38,9 @@ export const PREF_KEYS = [
|
||||
"agent-monitor-notifications",
|
||||
"agenthub.defaultTool",
|
||||
"agenthub.permissionMode",
|
||||
"agenthub.codex.sandbox",
|
||||
"agenthub.codex.approval",
|
||||
"agenthub.codex.bypass",
|
||||
"agenthub.tour",
|
||||
"agenthub.workspace.allTabs",
|
||||
] as const;
|
||||
|
||||
@@ -67,6 +67,7 @@ import { SnapshotSettings } from "../components/settings/SnapshotSettings";
|
||||
import { ReportSettings } from "../components/settings/ReportSettings";
|
||||
import { AiToolsSettings } from "../components/settings/AiToolsSettings";
|
||||
import { AgentInstructionsSettings } from "../components/settings/AgentInstructionsSettings";
|
||||
import { SessionDefaultsSettings } from "../components/settings/SessionDefaultsSettings";
|
||||
import { ArtifactsHubSettings } from "../components/settings/ArtifactsHubSettings";
|
||||
import { PricingSettings } from "../components/settings/PricingSettings";
|
||||
import { WorktreeVisibilitySettings } from "../components/settings/WorktreeVisibilitySettings";
|
||||
@@ -292,6 +293,7 @@ export function SettingsHub({ section }: { section?: string } = {}) {
|
||||
render: () => (
|
||||
<div className="space-y-6">
|
||||
<AgentInstructionsSettings />
|
||||
<SessionDefaultsSettings />
|
||||
<AiToolsSettings settingsHref={toolHref} />
|
||||
{/* The Hub's install switch: server mode, an admin's (it hides itself). */}
|
||||
<ArtifactsHubSettings />
|
||||
|
||||
@@ -7239,7 +7239,7 @@ exports[`screen snapshots > Settings — about 1`] = `
|
||||
<span
|
||||
class="text-status-warning/90"
|
||||
>
|
||||
UI build v2.8.11 (rebuild client to match)
|
||||
UI build v2.8.12 (rebuild client to match)
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user