дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-08 19:02:17 +02:00
fix(agents): flatten the conversation and tidy command approvals (#2083)
Flatten the agent conversation into a dense tool-window layout and show command approvals as an inline strip with the request's own command (shell wrapper unwrapped), standard buttons and no horizontal overflow.
Цей коміт міститься в:
1 джерело
2bf412ff0d
коміт
b746a0a3a9
16 files changed
+790
-241
No files matched your search
@@ -1756,12 +1756,7 @@ impl Task {
|
||||
fn ask_permission(self: &Arc<Self>, params: &Value) -> Value {
|
||||
let id = self.next_permission.fetch_add(1, Ordering::Relaxed);
|
||||
let (questions, by_index) = asked_questions(params);
|
||||
let title = params
|
||||
.pointer("/toolCall/title")
|
||||
.and_then(Value::as_str)
|
||||
.filter(|t| !t.is_empty())
|
||||
.unwrap_or("The agent asks for permission")
|
||||
.to_owned();
|
||||
let title = permission_title(params);
|
||||
// An agent that answers by index titles its request "Ask user N question(s)"; its
|
||||
// first question says what it wants, as the Claude Code adapter titles its own.
|
||||
let title =
|
||||
@@ -1935,6 +1930,26 @@ fn clean(mut prompt: AgentPrompt) -> Option<AgentPrompt> {
|
||||
(!prompt.text.is_empty() || !prompt.images.is_empty() || !prompt.files.is_empty()).then_some(prompt)
|
||||
}
|
||||
|
||||
fn permission_title(params: &Value) -> String {
|
||||
let call = ¶ms["toolCall"];
|
||||
let title = call.get("title").and_then(Value::as_str).filter(|t| !t.is_empty());
|
||||
if call.get("kind").and_then(Value::as_str) == Some("execute") {
|
||||
if let Some(command) = call.pointer("/rawInput/command").and_then(Value::as_str).filter(|c| !c.is_empty()) {
|
||||
// Network approvals describe the host rather than approving command execution.
|
||||
if !title.is_some_and(|t| t.starts_with("Allow network access to ")) {
|
||||
return format!("Run {command}");
|
||||
}
|
||||
}
|
||||
if title.is_none()
|
||||
&& let Some(content) = call.get("content").and_then(Value::as_array)
|
||||
&& let Some(text) = content.iter().find_map(|c| c.pointer("/content/text").and_then(Value::as_str))
|
||||
{
|
||||
return format!("Run {text}");
|
||||
}
|
||||
}
|
||||
title.unwrap_or("The agent asks for permission").to_owned()
|
||||
}
|
||||
|
||||
/// The questions a permission request asks, and whether they answer by question index.
|
||||
/// The Claude Code adapter carries them as `_meta.questions`, answered as the option id;
|
||||
/// Qwen Code (ACP) as `_meta.qwenQuestions`, answered with `answers` keyed by index beside
|
||||
|
||||
@@ -822,7 +822,7 @@ impl CodexShim {
|
||||
match method {
|
||||
"item/commandExecution/requestApproval" => {
|
||||
let choices = translate::decisions(params, true);
|
||||
let title = translate::command_approval_title(params, item.as_ref(), cwd);
|
||||
let title = translate::command_approval_title(params, item.as_ref());
|
||||
let call = tool_call_of(
|
||||
&item_id,
|
||||
&title,
|
||||
|
||||
@@ -247,13 +247,39 @@ fn approvals_offer_what_codex_offers_and_always_a_decline() {
|
||||
assert_eq!(
|
||||
command_approval_title(
|
||||
&json!({"command": "/bin/zsh -lc 'ls'", "commandActions": [{"type": "unknown", "command": "touch b"}]}),
|
||||
None,
|
||||
None
|
||||
),
|
||||
"Run touch b"
|
||||
"Run ls"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn approval_title_shows_the_script_inside_a_shell_wrapper() {
|
||||
let title = |command: &str| command_approval_title(&json!({ "command": command }), None);
|
||||
assert_eq!(
|
||||
title(r#"/bin/zsh -lc "python3 -c 'import pathlib\np = pathlib.Path(\"a.txt\")'""#),
|
||||
"Run python3 -c 'import pathlib\\np = pathlib.Path(\"a.txt\")'"
|
||||
);
|
||||
assert_eq!(title(r#"bash -lc 'echo '\''hi'\'''"#), "Run echo 'hi'");
|
||||
assert_eq!(title("/bin/zsh -lc ls"), "Run /bin/zsh -lc ls");
|
||||
assert_eq!(title("/bin/zsh -lc 'ls' && rm x"), "Run /bin/zsh -lc 'ls' && rm x");
|
||||
assert_eq!(title("python3 -c 'print(1)'"), "Run python3 -c 'print(1)'");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn approval_command_uses_its_request_and_matching_item_only() {
|
||||
let command = "python3 -c 'import pathlib\np = pathlib.Path(\"note.txt\")\np.write_text(\"hi\\n\")'";
|
||||
let pending_read = json!({"id": "read", "type": "commandExecution", "command": "ls",
|
||||
"commandActions": [{"type": "listFiles", "path": "."}]});
|
||||
let request = json!({"itemId": "write", "command": command});
|
||||
assert_eq!(command_approval_title(&request, Some(&pending_read)), format!("Run {command}"));
|
||||
let item = json!({"id": "write", "command": command});
|
||||
let request = json!({"itemId": "write"});
|
||||
assert_eq!(command_approval_title(&request, Some(&item)), format!("Run {command}"));
|
||||
assert_eq!(command_approval_title(&request, Some(&pending_read)), "Run a command");
|
||||
assert_eq!(command_approval_title(&request, None), "Run a command");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn history_replays_turns() {
|
||||
let thread = json!({"turns": [{"id": "t", "status": "completed", "items": [
|
||||
|
||||
@@ -733,22 +733,62 @@ pub(super) fn answer_of(choices: &[Choice], outcome: &Value, decline: Value) ->
|
||||
}
|
||||
|
||||
/// The ACP title of a command approval.
|
||||
pub(super) fn command_approval_title(params: &Value, item: Option<&Value>, cwd: Option<&str>) -> String {
|
||||
pub(super) fn command_approval_title(params: &Value, item: Option<&Value>) -> String {
|
||||
if let Some(host) = params.pointer("/networkApprovalContext/host").and_then(Value::as_str) {
|
||||
return format!("Allow network access to {host}");
|
||||
}
|
||||
let from_item = item.and_then(|i| tool_call(i, false, cwd, None)).and_then(|c| str_of(&c, "title"));
|
||||
let title = from_item.or_else(|| {
|
||||
let synthetic = json!({ "id": "", "type": "commandExecution", "command": params.get("command"),
|
||||
"commandActions": params.get("commandActions").cloned().unwrap_or(json!([])) });
|
||||
tool_call(&synthetic, false, cwd, None).and_then(|c| str_of(&c, "title"))
|
||||
});
|
||||
match title.filter(|t| !t.is_empty()) {
|
||||
Some(t) => format!("Run {t}"),
|
||||
// Approval payloads can describe a different command from the item's earlier summary.
|
||||
let item = item.filter(|i| i.get("id") == params.get("itemId"));
|
||||
let command = params
|
||||
.get("command")
|
||||
.and_then(Value::as_str)
|
||||
.filter(|c| !c.is_empty())
|
||||
.or_else(|| item.and_then(|i| i.get("command")).and_then(Value::as_str).filter(|c| !c.is_empty()));
|
||||
match command {
|
||||
Some(command) => format!("Run {}", shell_script(command).unwrap_or_else(|| command.to_owned())),
|
||||
None => "Run a command".to_owned(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The script of a `<shell> -c <quoted script>` command line, when the whole line is exactly that.
|
||||
fn shell_script(command: &str) -> Option<String> {
|
||||
let (shell, rest) = command.split_once(' ')?;
|
||||
if !matches!(shell.rsplit('/').next(), Some("sh" | "bash" | "zsh")) {
|
||||
return None;
|
||||
}
|
||||
let quoted = rest.strip_prefix("-lc ").or_else(|| rest.strip_prefix("-c "))?;
|
||||
let mut out = String::new();
|
||||
let mut chars = quoted.chars();
|
||||
// Adjacent quoted words, e.g. 'a'\''b' or "a \"b\"".
|
||||
while let Some(c) = chars.next() {
|
||||
match c {
|
||||
'\'' => loop {
|
||||
match chars.next()? {
|
||||
'\'' => break,
|
||||
c => out.push(c),
|
||||
}
|
||||
},
|
||||
'"' => loop {
|
||||
match chars.next()? {
|
||||
'"' => break,
|
||||
'\\' => match chars.next()? {
|
||||
c @ ('"' | '\\' | '$' | '`') => out.push(c),
|
||||
'\n' => {}
|
||||
c => {
|
||||
out.push('\\');
|
||||
out.push(c);
|
||||
}
|
||||
},
|
||||
c => out.push(c),
|
||||
}
|
||||
},
|
||||
'\\' => out.push(chars.next()?),
|
||||
_ => return None,
|
||||
}
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
|
||||
/// The ACP title of a file-change approval, from the item it is about.
|
||||
pub(super) fn file_approval_title(item: Option<&Value>, cwd: Option<&str>) -> String {
|
||||
let changes = item.and_then(|i| i.get("changes")).and_then(Value::as_array).map(Vec::as_slice).unwrap_or_default();
|
||||
|
||||
@@ -3,6 +3,20 @@
|
||||
use super::*;
|
||||
use tokio::io::duplex;
|
||||
|
||||
#[test]
|
||||
fn permission_uses_its_own_command_payload() {
|
||||
let command = "python3 -c 'import pathlib\npathlib.Path(\"a.txt\").touch()'";
|
||||
let mut request = json!({"toolCall": {"toolCallId": "write", "kind": "execute",
|
||||
"title": "Run List .", "rawInput": {"command": command}}});
|
||||
assert_eq!(permission_title(&request), format!("Run {command}"));
|
||||
request["toolCall"]["title"] = json!("Allow network access to example.com");
|
||||
assert_eq!(permission_title(&request), "Allow network access to example.com");
|
||||
request["toolCall"].as_object_mut().unwrap().remove("title");
|
||||
request["toolCall"].as_object_mut().unwrap().remove("rawInput");
|
||||
request["toolCall"]["content"] = json!([{ "type": "content", "content": {"type": "text", "text": command}}]);
|
||||
assert_eq!(permission_title(&request), format!("Run {command}"));
|
||||
}
|
||||
|
||||
/// Says "Hi", edits a file, asks to run a command and reports what it was allowed.
|
||||
#[derive(Default)]
|
||||
struct FakeAgent {
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import type { AgentEntry, AgentTask } from "../src/bindings";
|
||||
|
||||
const task: AgentTask = {
|
||||
id: "approval",
|
||||
title: "Command approval",
|
||||
status: "needsInput",
|
||||
isolation: "local",
|
||||
provider: "codex",
|
||||
model: null,
|
||||
cwd: "/dev/acme-api",
|
||||
branch: null,
|
||||
base: null,
|
||||
baseCommit: null,
|
||||
error: null,
|
||||
createdMs: 0,
|
||||
updatedMs: 0,
|
||||
config: [],
|
||||
commands: [],
|
||||
usage: null,
|
||||
queue: [],
|
||||
};
|
||||
const command = `python3 -c 'import pathlib\np = pathlib.Path("note.txt")\np.write_text("${"long content ".repeat(80)}")'`;
|
||||
const entries: AgentEntry[] = [
|
||||
{ type: "user", text: "Write a note with Python.", attachments: [], steered: false },
|
||||
{ type: "agent", text: "I will request approval before writing the note." },
|
||||
{
|
||||
type: "tool",
|
||||
id: "read",
|
||||
title: "List .",
|
||||
kind: "search",
|
||||
status: "running",
|
||||
locations: [],
|
||||
output: null,
|
||||
diffs: [],
|
||||
},
|
||||
{
|
||||
type: "permission",
|
||||
id: 1,
|
||||
title: `Run ${command}`,
|
||||
options: [
|
||||
{ id: "once", kind: "allow_once", name: "Allow once" },
|
||||
{ id: "always", kind: "allow_always", name: `Always allow \`${command}\`` },
|
||||
{ id: "decline", kind: "reject_once", name: "Decline" },
|
||||
{ id: "cancel", kind: "reject_once", name: "Decline and stop" },
|
||||
],
|
||||
chosen: null,
|
||||
},
|
||||
];
|
||||
|
||||
for (const theme of ["dark", "light"] as const) {
|
||||
for (const width of [760, 1400]) {
|
||||
test(`approval stays inside a flat transcript at ${width}px in ${theme}`, async ({ page }) => {
|
||||
await page.setViewportSize({ width, height: 900 });
|
||||
await page.goto("/");
|
||||
await page.getByRole("tree", { name: "Project" }).waitFor();
|
||||
await page.evaluate(
|
||||
async ({ task, entries, theme }) => {
|
||||
const path = "/src/features/agents/store.ts";
|
||||
const store = await import(path);
|
||||
const themePath = "/src/workbench/theme.ts";
|
||||
(await import(themePath)).previewTheme(theme);
|
||||
store.useAgents.setState({
|
||||
loaded: true,
|
||||
tasks: new Map([[task.id, task]]),
|
||||
entries: new Map([[task.id, entries]]),
|
||||
});
|
||||
store.openTaskInEditorLocation(task.id);
|
||||
},
|
||||
{ task, entries, theme },
|
||||
);
|
||||
const card = page.getByRole("region", { name: "codex wants to run a command" });
|
||||
await expect(card).toHaveCount(1);
|
||||
await expect(card.locator("pre")).toHaveText(command);
|
||||
await expect(card.getByRole("button")).toHaveText(["Allow Once", "Always Allow", "Decline", "Decline and Stop"]);
|
||||
const geometry = await card.evaluate((el) => {
|
||||
const transcript = el.parentElement!;
|
||||
const prompt = transcript.querySelector('[class*="_user_"]')!;
|
||||
const reply = transcript.querySelector('[class*="_agentCard_"]')!;
|
||||
const rect = el.getBoundingClientRect();
|
||||
const bounds = transcript.getBoundingClientRect();
|
||||
return {
|
||||
scroll: transcript.scrollWidth,
|
||||
client: transcript.clientWidth,
|
||||
contained: rect.left >= bounds.left && rect.right <= bounds.right,
|
||||
promptRadius: parseFloat(getComputedStyle(prompt).borderRadius),
|
||||
replyBackground: getComputedStyle(reply).backgroundColor,
|
||||
replyBorder: getComputedStyle(reply).borderTopWidth,
|
||||
leftAligned: Math.abs(prompt.getBoundingClientRect().left - reply.getBoundingClientRect().left) < 1,
|
||||
};
|
||||
});
|
||||
expect(geometry.scroll).toBe(geometry.client);
|
||||
expect(geometry.contained).toBe(true);
|
||||
expect(geometry.promptRadius).toBeLessThanOrEqual(4);
|
||||
expect(geometry.replyBackground).toBe("rgba(0, 0, 0, 0)");
|
||||
expect(geometry.replyBorder).toBe("0px");
|
||||
expect(geometry.leftAligned).toBe(true);
|
||||
await card.locator("pre").focus();
|
||||
for (const label of ["Allow Once", "Always Allow", "Decline", "Decline and Stop"]) {
|
||||
await page.keyboard.press("Tab");
|
||||
await expect(card.getByRole("button", { name: label, exact: true })).toBeFocused();
|
||||
}
|
||||
await page.evaluate(async () => {
|
||||
const path = "/src/platform/ipc.ts";
|
||||
const { commands } = await import(path);
|
||||
commands.agentRespond = async (task: string, permission: number, option: string) => {
|
||||
(window as unknown as { approvalAnswer: unknown }).approvalAnswer = { task, permission, option };
|
||||
return { status: "ok", data: null };
|
||||
};
|
||||
});
|
||||
await card.getByRole("button", { name: "Allow Once", exact: true }).focus();
|
||||
await page.keyboard.press("Enter");
|
||||
await expect
|
||||
.poll(() => page.evaluate(() => (window as unknown as { approvalAnswer: unknown }).approvalAnswer))
|
||||
.toEqual({
|
||||
task: task.id,
|
||||
permission: 1,
|
||||
option: "once",
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -165,7 +165,7 @@ test("copies reply Markdown with the keyboard and keeps code copying separate",
|
||||
const copy = page.getByRole("button", { name: "Copy Markdown", exact: true });
|
||||
const actions = copy.locator("..");
|
||||
await page.mouse.move(0, 0);
|
||||
await expect(actions).toHaveCSS("opacity", "1");
|
||||
await expect(actions).toHaveCSS("opacity", "0");
|
||||
await copy.focus();
|
||||
await expect(actions).toHaveCSS("opacity", "1");
|
||||
await page.keyboard.press("Enter");
|
||||
@@ -175,21 +175,21 @@ test("copies reply Markdown with the keyboard and keeps code copying separate",
|
||||
expect(await page.evaluate(() => navigator.clipboard.readText())).toBe("const a = 1;");
|
||||
});
|
||||
|
||||
test("keeps message actions visible without shifting content and quotes into the draft", async ({ page }) => {
|
||||
test("shows message actions on hover without shifting content and quotes into the draft", async ({ page }) => {
|
||||
const reply = await openReply(page, REPLY);
|
||||
const copy = page.getByRole("button", { name: "Copy Markdown", exact: true });
|
||||
const actions = copy.locator("..");
|
||||
await page.mouse.move(0, 0);
|
||||
await expect(actions).toHaveCSS("opacity", "1");
|
||||
await expect(actions).toHaveCSS("opacity", "0");
|
||||
const before = await reply.boundingBox();
|
||||
await reply.hover();
|
||||
await expect(actions).toHaveCSS("opacity", "1");
|
||||
expect(await reply.boundingBox()).toEqual(before);
|
||||
await expect(actions.getByRole("button")).toHaveCount(4);
|
||||
await page.mouse.move(0, 0);
|
||||
await expect(actions).toHaveCSS("opacity", "1");
|
||||
await expect(actions).toHaveCSS("opacity", "0");
|
||||
const userCopy = page.getByRole("button", { name: "Copy", exact: true });
|
||||
await expect(userCopy.locator("..")).toHaveCSS("opacity", "1");
|
||||
await expect(userCopy.locator("..")).toHaveCSS("opacity", "0");
|
||||
await page.getByText("Show Markdown", { exact: true }).hover();
|
||||
await expect(userCopy.locator("..")).toHaveCSS("opacity", "1");
|
||||
await reply.hover();
|
||||
@@ -258,7 +258,8 @@ test("keeps chat history readable and selections free of timestamps and status l
|
||||
});
|
||||
expect(geometry.reply).toBeGreaterThan(0);
|
||||
expect(geometry.reply).toBeLessThan(geometry.root);
|
||||
expect(geometry.bubbleLeft).toBeGreaterThan(geometry.replyRight);
|
||||
expect(geometry.bubbleLeft).toBe(geometry.replyLeft);
|
||||
expect(geometry.bubbleRight).toBe(geometry.replyRight);
|
||||
expect(Math.abs(geometry.columnCenter - geometry.composerCenter)).toBeLessThan(1);
|
||||
await stream.getByText("hi", { exact: true }).hover();
|
||||
await expect(stream.locator("[class*='_messageTime_']").first()).toBeVisible();
|
||||
@@ -294,7 +295,7 @@ test("keeps chat history readable and selections free of timestamps and status l
|
||||
}));
|
||||
expect(narrow.width).toBeGreaterThan(0);
|
||||
expect(narrow.content).toBeLessThanOrEqual(narrow.width);
|
||||
expect(narrow.reply).toBeLessThanOrEqual((narrow.width - narrow.padding) * 0.8);
|
||||
expect(narrow.reply).toBe(narrow.width - narrow.padding);
|
||||
});
|
||||
|
||||
test("keeps hover actions reachable when the pointer leaves the message body", async ({ page, context }) => {
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import clsx from "clsx";
|
||||
import { useEffect, useState, type ReactNode } from "react";
|
||||
import type { AgentTask } from "@/bindings";
|
||||
import { IconButton } from "@/ui";
|
||||
import { Markdown } from "./Markdown";
|
||||
@@ -22,7 +21,7 @@ export function AgentReply({
|
||||
reply?: ReplyTurn;
|
||||
turnCount?: number;
|
||||
onDiagramDrawn?: () => void;
|
||||
/** False when the caller shows `ReplyActions` in the message's header row. */
|
||||
/** False when the caller supplies the message footer. */
|
||||
actions?: boolean;
|
||||
}) {
|
||||
return (
|
||||
@@ -37,7 +36,15 @@ export function AgentReply({
|
||||
);
|
||||
}
|
||||
|
||||
/** Copy, quote, regenerate and branch for a reply; kept in its header row when it has one. */
|
||||
export function MessageActions({ children, copied = false }: { children: ReactNode; copied?: boolean }) {
|
||||
return (
|
||||
<span className={styles.messageActions} data-copied={copied || undefined}>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/** Shared action row for a reply in either the conversation or a subagent preview. */
|
||||
export function ReplyActions({
|
||||
text,
|
||||
task,
|
||||
@@ -57,11 +64,11 @@ export function ReplyActions({
|
||||
return () => clearTimeout(timer);
|
||||
}, [copied]);
|
||||
return (
|
||||
<span className={clsx(styles.messageActions, styles.replyActions)} data-copied={copied === text || undefined}>
|
||||
<MessageActions copied={copied === text}>
|
||||
<IconButton
|
||||
icon={copied === text ? "check" : "copy"}
|
||||
title={copied === text ? "Copied" : "Copy Markdown"}
|
||||
size="normal"
|
||||
size="small"
|
||||
action={copyReplyAction.id}
|
||||
actionArg={{ text, onCopied: () => setCopied(text) } satisfies CopyReply}
|
||||
/>
|
||||
@@ -69,7 +76,7 @@ export function ReplyActions({
|
||||
<IconButton
|
||||
icon="quote"
|
||||
title="Quote Reply"
|
||||
size="normal"
|
||||
size="small"
|
||||
action="agents.quoteReply"
|
||||
actionArg={{ task: task.id, text }}
|
||||
/>
|
||||
@@ -78,7 +85,7 @@ export function ReplyActions({
|
||||
<IconButton
|
||||
icon="refresh"
|
||||
title="Regenerate Response"
|
||||
size="normal"
|
||||
size="small"
|
||||
action="agents.regenerateReply"
|
||||
actionArg={{ task: task.id, turn: reply.turn }}
|
||||
/>
|
||||
@@ -87,11 +94,11 @@ export function ReplyActions({
|
||||
<IconButton
|
||||
icon="branch"
|
||||
title="Branch Conversation from Here"
|
||||
size="normal"
|
||||
size="small"
|
||||
action="agents.forkReply"
|
||||
actionArg={{ task: task.id, turn: reply.turn }}
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
</MessageActions>
|
||||
);
|
||||
}
|
||||
@@ -1,11 +1,13 @@
|
||||
.session {
|
||||
--session-content-width: calc(var(--agent-conversation-width) - var(--space-4) * 6);
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
background: var(--bg-editor);
|
||||
background: var(--bg-app);
|
||||
color: var(--fg);
|
||||
font-size: var(--fs-ui);
|
||||
}
|
||||
|
||||
.conversation {
|
||||
@@ -82,62 +84,9 @@
|
||||
color: var(--fg-subtle);
|
||||
}
|
||||
|
||||
/* Pending permission: an editor notification banner. A long question (AskUserQuestion) wraps,
|
||||
scrolling past a third of the height; the answers follow it, wrapping too. */
|
||||
.banner {
|
||||
flex: none;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-start;
|
||||
gap: 4px 8px;
|
||||
min-height: 36px;
|
||||
padding: 9px 12px;
|
||||
background: var(--bg-feedback-warning);
|
||||
border-bottom: 1px solid var(--border-feedback-warning);
|
||||
color: var(--fg-strong);
|
||||
}
|
||||
.banner > svg {
|
||||
flex: none;
|
||||
margin-top: 1px;
|
||||
}
|
||||
.bannerText {
|
||||
flex: 1 1 240px;
|
||||
min-width: 0;
|
||||
max-height: 33vh;
|
||||
overflow-y: auto;
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.bannerSpacer {
|
||||
flex: 1;
|
||||
}
|
||||
.bannerActions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px 12px;
|
||||
margin-left: auto;
|
||||
}
|
||||
.bannerLink {
|
||||
flex: none;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: none;
|
||||
color: var(--link);
|
||||
font: inherit;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
}
|
||||
.bannerLink:hover:not(:disabled) {
|
||||
color: var(--link-hover);
|
||||
text-decoration: underline;
|
||||
}
|
||||
.bannerLink:disabled {
|
||||
color: var(--fg-subtle);
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
/* The conversation with its find bar. */
|
||||
.streamArea {
|
||||
min-width: 0;
|
||||
position: relative;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
@@ -148,23 +97,26 @@
|
||||
.stream {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
min-width: 0;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
overflow-anchor: none;
|
||||
padding: calc(var(--space-3) * 2) var(--space-4);
|
||||
padding: var(--space-3);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
/* Actual row heights keep WebKit's scrollbar and cross-message selections anchored. */
|
||||
.stream > * {
|
||||
flex: none;
|
||||
width: 100%;
|
||||
max-width: var(--agent-conversation-width);
|
||||
margin-inline: auto;
|
||||
min-width: 0;
|
||||
width: min(100%, var(--session-content-width));
|
||||
max-width: 100%;
|
||||
margin-inline: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
/* Messages keep room around them; the folded reasoning/tool rows sit close together. */
|
||||
/* Messages and folded process rows share the tool-window gutter. */
|
||||
.stream > * + * {
|
||||
margin-top: calc(var(--space-3) * 2);
|
||||
margin-top: var(--space-3);
|
||||
}
|
||||
.stream > * + .process {
|
||||
margin-top: var(--space-2);
|
||||
@@ -172,6 +124,9 @@
|
||||
.stream > .process + * {
|
||||
margin-top: var(--space-2);
|
||||
}
|
||||
.stream > * + .agentMessage[data-continuation] {
|
||||
margin-top: var(--space-2);
|
||||
}
|
||||
.stream > .process + .process {
|
||||
margin-top: 2px;
|
||||
}
|
||||
@@ -208,14 +163,14 @@
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
/* The author line, with the reply actions on its right; its height is always reserved. */
|
||||
/* Keep the author attached to the message. */
|
||||
.messageHead {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
gap: 8px;
|
||||
min-height: 24px;
|
||||
margin-bottom: 3px;
|
||||
gap: var(--space-2);
|
||||
min-height: var(--h-control-small);
|
||||
margin-bottom: calc(var(--space-2) / 2);
|
||||
}
|
||||
.messageTime {
|
||||
-webkit-user-select: none;
|
||||
@@ -227,39 +182,25 @@
|
||||
.messageHead > .role {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
/* Chat layout: the user's messages sit on the right, the agent's on the left. */
|
||||
/* User prompts stay in the same column as replies and tool calls. */
|
||||
.userMessage {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
}
|
||||
.userMessage > .messageHead {
|
||||
align-self: stretch;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
.userMessage > .user {
|
||||
width: fit-content;
|
||||
max-width: min(80%, 720px);
|
||||
}
|
||||
.userMessage > .messageFooter {
|
||||
align-self: flex-end;
|
||||
align-items: stretch;
|
||||
}
|
||||
/* The actions and time below a message, in flow so they never cover the next entry. */
|
||||
.messageFooter {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-height: 24px;
|
||||
min-height: var(--h-control-small);
|
||||
max-width: 100%;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
/* The footer's icons stay visible: the time follows them, at the message's side. */
|
||||
.messageFooter .messageActions {
|
||||
margin-left: 0;
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
.messageFooter > .messageTime {
|
||||
order: -1;
|
||||
}
|
||||
/* Who wrote the message, above its card. */
|
||||
/* Who wrote the message. */
|
||||
.role {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -273,26 +214,20 @@
|
||||
.agentMessage {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
align-items: stretch;
|
||||
}
|
||||
.agentMessage > .messageHead,
|
||||
.agentCard {
|
||||
max-width: min(80%, 760px);
|
||||
}
|
||||
.agentCard {
|
||||
box-sizing: border-box;
|
||||
width: fit-content;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
padding: var(--space-2) var(--space-3);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-popup);
|
||||
background: var(--bg-elev);
|
||||
}
|
||||
.messageActions {
|
||||
display: inline-flex;
|
||||
gap: 4px;
|
||||
margin-left: 8px;
|
||||
max-width: 100%;
|
||||
flex-wrap: wrap;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
-webkit-user-select: none;
|
||||
@@ -324,9 +259,6 @@
|
||||
-webkit-user-select: none;
|
||||
user-select: none;
|
||||
}
|
||||
.replyActions {
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
@media (hover: none) {
|
||||
.messageActions {
|
||||
@@ -369,10 +301,10 @@
|
||||
.user {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
padding: var(--space-2) var(--space-3);
|
||||
border: 1px solid color-mix(in srgb, var(--accent) 32%, var(--border));
|
||||
border-radius: var(--radius-popup);
|
||||
background: color-mix(in srgb, var(--accent) 12%, var(--bg-app));
|
||||
padding: var(--space-2);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-small);
|
||||
background: var(--bg-elev);
|
||||
color: var(--fg-strong);
|
||||
line-height: 1.5;
|
||||
white-space: pre-wrap;
|
||||
@@ -407,7 +339,7 @@
|
||||
}
|
||||
|
||||
.markdown {
|
||||
line-height: 1.55;
|
||||
line-height: 1.5;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.markdown :global(p) {
|
||||
@@ -582,7 +514,8 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
width: fit-content;
|
||||
min-width: 0;
|
||||
height: var(--h-row);
|
||||
color: var(--fg-muted);
|
||||
font-size: var(--fs-small);
|
||||
-webkit-user-select: none;
|
||||
@@ -614,24 +547,34 @@
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
padding: 2px 8px;
|
||||
margin-left: -8px;
|
||||
box-sizing: border-box;
|
||||
min-height: var(--h-row);
|
||||
padding: 0 calc(var(--space-2) / 2);
|
||||
border-radius: var(--radius-row);
|
||||
color: var(--fg-secondary);
|
||||
width: fit-content;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
}
|
||||
.toolDetails > summary.tool:hover {
|
||||
background: var(--bg-hover);
|
||||
color: var(--fg);
|
||||
}
|
||||
.tool > svg {
|
||||
flex: none;
|
||||
}
|
||||
.toolTitle {
|
||||
font-family: var(--font-mono);
|
||||
min-width: 0;
|
||||
font-size: var(--fs-small);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.toolChevron {
|
||||
flex: none;
|
||||
}
|
||||
.toolDetails[open] > summary .toolChevron {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
.toolDiff {
|
||||
font: inherit;
|
||||
font-size: var(--fs-small);
|
||||
@@ -658,10 +601,11 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
width: fit-content;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
padding: 2px 8px;
|
||||
margin-left: -8px;
|
||||
box-sizing: border-box;
|
||||
min-height: var(--h-row);
|
||||
padding: 0 calc(var(--space-2) / 2);
|
||||
border-radius: var(--radius-row);
|
||||
color: var(--fg-muted);
|
||||
font-size: var(--fs-small);
|
||||
@@ -680,6 +624,7 @@
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
.processRun:not([open]) > .processBody {
|
||||
min-width: 0;
|
||||
display: none;
|
||||
}
|
||||
.processBody {
|
||||
@@ -701,6 +646,7 @@
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--fs-small);
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
color: var(--fg-secondary);
|
||||
}
|
||||
|
||||
@@ -726,56 +672,99 @@
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
gap: var(--space-2);
|
||||
min-width: 0;
|
||||
padding: var(--space-2) var(--space-3);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-popup);
|
||||
background: var(--bg-elev);
|
||||
padding: var(--space-2);
|
||||
border: 1px solid var(--border-feedback-warning);
|
||||
border-radius: var(--radius-small);
|
||||
background: var(--bg-feedback-warning);
|
||||
color: var(--fg-secondary);
|
||||
}
|
||||
/* Waiting for an answer: the card takes the warning colour of the banner above. */
|
||||
.permissionAsking {
|
||||
border-color: var(--border-feedback-warning);
|
||||
background: var(--bg-feedback-warning);
|
||||
.permissionLine > svg {
|
||||
flex: none;
|
||||
margin-top: calc(var(--space-2) / 4);
|
||||
}
|
||||
.permissionDone {
|
||||
opacity: 0.85;
|
||||
.permissionAsking > svg {
|
||||
color: var(--warning);
|
||||
}
|
||||
/* Title, state and answers on one line when they fit; a long title takes its own line and
|
||||
the state and answers go below it, in line with it. */
|
||||
.permissionBody {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-start;
|
||||
gap: 2px 6px;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
/* Up to three lines; the full text is its tooltip. */
|
||||
.permissionTitle {
|
||||
display: -webkit-box;
|
||||
flex: 0 1 auto;
|
||||
color: var(--fg-strong);
|
||||
font-weight: 600;
|
||||
font-weight: 400;
|
||||
}
|
||||
.permissionCommand {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 3;
|
||||
max-width: 100%;
|
||||
max-height: calc(var(--lh-code) * 6);
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: var(--space-2);
|
||||
overflow: auto;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-small);
|
||||
background: var(--bg-editor);
|
||||
color: var(--fg);
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--fs-code);
|
||||
line-height: var(--lh-code);
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
tab-size: 2;
|
||||
}
|
||||
.permissionCommand:focus-visible,
|
||||
.permissionDone:focus-visible {
|
||||
outline: 2px solid var(--focus-ring);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
.permissionDetail {
|
||||
max-height: 33vh;
|
||||
overflow-y: auto;
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.permissionLine > svg,
|
||||
.permissionBody > .muted {
|
||||
flex: none;
|
||||
}
|
||||
.permissionAnswers {
|
||||
flex: 1 1 160px;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 2px 12px;
|
||||
margin-left: 6px;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
.permissionOption {
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
padding-inline: var(--space-3);
|
||||
}
|
||||
.permissionOptionLabel {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.permissionPrefix {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--fs-small);
|
||||
}
|
||||
.permissionDone {
|
||||
padding: calc(var(--space-2) / 2) 0;
|
||||
border: 0;
|
||||
border-radius: var(--radius-small);
|
||||
background: none;
|
||||
color: var(--fg-muted);
|
||||
font-size: var(--fs-small);
|
||||
}
|
||||
.permissionRecord {
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.error {
|
||||
@@ -803,11 +792,11 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
align-self: center;
|
||||
align-self: flex-start;
|
||||
width: 100%;
|
||||
max-width: calc(var(--agent-conversation-width) + var(--space-4) * 2);
|
||||
max-width: calc(var(--session-content-width) + var(--space-3) * 2);
|
||||
box-sizing: border-box;
|
||||
padding: var(--space-2) var(--space-4) var(--space-4);
|
||||
padding: var(--space-2) var(--space-3) var(--space-4);
|
||||
}
|
||||
|
||||
.background {
|
||||
@@ -1249,7 +1238,7 @@
|
||||
overflow-y: auto;
|
||||
/* The composer's width, so the card sits exactly over the box it belongs to. */
|
||||
width: 100%;
|
||||
max-width: var(--agent-conversation-width);
|
||||
max-width: var(--session-content-width);
|
||||
margin: 0 auto 8px;
|
||||
padding: 12px;
|
||||
border: 1px solid var(--border-strong);
|
||||
|
||||
@@ -67,7 +67,14 @@ import {
|
||||
import { ConfigPickers } from "./ConfigPickers";
|
||||
import { findRanges, formatDuration, formatSentAt } from "./conversation";
|
||||
import { ImagePreview, imageSrc } from "./ImagePreview";
|
||||
import { AgentReply, ReplyActions } from "./AgentReply";
|
||||
import { AgentReply, MessageActions, ReplyActions } from "./AgentReply";
|
||||
import {
|
||||
commandPrefix,
|
||||
permissionDetails,
|
||||
permissionOption,
|
||||
permissionResult,
|
||||
type PermissionEntry,
|
||||
} from "./permission";
|
||||
import { Markdown } from "./Markdown";
|
||||
import { useAgentErrorText } from "./loginHint";
|
||||
import {
|
||||
@@ -182,7 +189,6 @@ function TaskSession({ id, docked }: { id: string; docked: boolean }) {
|
||||
docked={docked}
|
||||
/>
|
||||
{view && <SubagentBar task={task} path={path} cards={cards} onNavigate={setPath} />}
|
||||
<PermissionBanner task={task} entries={entries ?? null} />
|
||||
<Stream
|
||||
key={view ?? ""}
|
||||
task={task}
|
||||
@@ -597,6 +603,18 @@ function Stream({ task, entries, subagent, onOpenSubagent, finding, findFocus, o
|
||||
const times = useMemo(() => (entries && !subagent ? replyTimes(entries) : null), [entries, subagent]);
|
||||
const busy = subagent ? subagentWorking(subagent.status) : isBusy(task.status);
|
||||
const lastIndex = entries ? entries.length - 1 : -1;
|
||||
const authorHeaders = useMemo(() => {
|
||||
const headers = new Set<number>();
|
||||
let first = true;
|
||||
entries?.slice(start).forEach((entry, i) => {
|
||||
if (entry.type === "user") first = true;
|
||||
if (entry.type === "agent" && first) {
|
||||
headers.add(start + i);
|
||||
first = false;
|
||||
}
|
||||
});
|
||||
return headers;
|
||||
}, [entries, start]);
|
||||
const renderRow = useCallback(
|
||||
(row: EntryRow) => (
|
||||
<Entry
|
||||
@@ -608,11 +626,12 @@ function Stream({ task, entries, subagent, onOpenSubagent, finding, findFocus, o
|
||||
reply={replies?.get(row.index)}
|
||||
time={times?.get(row.index) ?? null}
|
||||
working={row.index === lastIndex && busy}
|
||||
showAuthor={authorHeaders.has(row.index)}
|
||||
onOpenSubagent={onOpenSubagent}
|
||||
onDiagramDrawn={onDiagramDrawn}
|
||||
/>
|
||||
),
|
||||
[task, turns, replies, times, lastIndex, busy, onOpenSubagent, onDiagramDrawn],
|
||||
[task, turns, replies, times, lastIndex, busy, authorHeaders, onOpenSubagent, onDiagramDrawn],
|
||||
);
|
||||
// Only the tail is built while following the end; `start` moves earlier as batches load.
|
||||
const rows = useMemo(() => (entries ? conversationRows(entries, start) : []), [entries, start]);
|
||||
@@ -945,6 +964,7 @@ const Entry = memo(function Entry({
|
||||
reply,
|
||||
time,
|
||||
working,
|
||||
showAuthor,
|
||||
onOpenSubagent,
|
||||
onDiagramDrawn,
|
||||
}: {
|
||||
@@ -957,6 +977,7 @@ const Entry = memo(function Entry({
|
||||
/** When the turn this reply answered was asked and finished. */
|
||||
time?: ReplyTimes | null;
|
||||
working: boolean;
|
||||
showAuthor: boolean;
|
||||
onOpenSubagent: (id: string) => void;
|
||||
onDiagramDrawn: () => void;
|
||||
}) {
|
||||
@@ -965,13 +986,15 @@ const Entry = memo(function Entry({
|
||||
return <UserMessage task={task} entry={entry} turn={turn} turnCount={turnCount} />;
|
||||
case "agent":
|
||||
return (
|
||||
<div className={styles.agentMessage}>
|
||||
<div className={styles.messageHead}>
|
||||
<div className={styles.role}>
|
||||
<Icon name={providerIcon(task.provider)} size={13} />
|
||||
<span>{providerName(task.provider)}</span>
|
||||
<div className={styles.agentMessage} data-continuation={!showAuthor || undefined}>
|
||||
{showAuthor && (
|
||||
<div className={styles.messageHead}>
|
||||
<div className={styles.role}>
|
||||
<Icon name={providerIcon(task.provider)} size={13} />
|
||||
<span>{providerName(task.provider)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className={styles.agentCard}>
|
||||
<AgentReply
|
||||
text={entry.text}
|
||||
@@ -1128,11 +1151,14 @@ function UserMessage({
|
||||
)}
|
||||
</div>
|
||||
<div className={styles.messageFooter}>
|
||||
<span className={styles.messageActions}>
|
||||
<IconButton icon="copy" title="Copy" onClick={() => void copyText(entry.text)} />
|
||||
{inPlace && entry.text && <IconButton icon="edit" title="Edit" onClick={() => setEditing(true)} />}
|
||||
<MessageActions>
|
||||
<IconButton size="small" icon="copy" title="Copy" onClick={() => void copyText(entry.text)} />
|
||||
{inPlace && entry.text && (
|
||||
<IconButton size="small" icon="edit" title="Edit" onClick={() => setEditing(true)} />
|
||||
)}
|
||||
{inPlace && (
|
||||
<IconButton
|
||||
size="small"
|
||||
icon="diff-revert"
|
||||
title="Retract Message"
|
||||
action="agents.retractMessage"
|
||||
@@ -1141,6 +1167,7 @@ function UserMessage({
|
||||
)}
|
||||
{!archived && entry.text && entry.attachments.length === 0 && (
|
||||
<IconButton
|
||||
size="small"
|
||||
icon="refresh"
|
||||
title={inPlace ? "Run Again From Here" : "Send Again"}
|
||||
onClick={() =>
|
||||
@@ -1152,12 +1179,13 @@ function UserMessage({
|
||||
)}
|
||||
{canFork && (
|
||||
<IconButton
|
||||
size="small"
|
||||
icon="branch"
|
||||
title="Copy to a New Task From Here"
|
||||
onClick={() => void forkTask(task.id, turn + 1).catch(failed("Couldn't copy the task"))}
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
</MessageActions>
|
||||
<MessageTime sentMs={entry.sentMs ?? null} doneMs={entry.doneMs ?? null} />
|
||||
</div>
|
||||
</div>
|
||||
@@ -1386,7 +1414,9 @@ const ToolEntry = memo(function ToolEntry({
|
||||
const head = (
|
||||
<>
|
||||
<Icon name={TOOL_ICONS[entry.kind] ?? "run"} size={14} />
|
||||
<span className={styles.toolTitle}>{shortTitle(task, entry.title || entry.kind)}</span>
|
||||
<span className={styles.toolTitle} title={entry.title || entry.kind}>
|
||||
{shortTitle(task, entry.title || entry.kind)}
|
||||
</span>
|
||||
{entry.diffs.map((d) => (
|
||||
<button
|
||||
key={d.path}
|
||||
@@ -1414,7 +1444,10 @@ const ToolEntry = memo(function ToolEntry({
|
||||
return (
|
||||
<div className={styles.process}>
|
||||
<details className={styles.toolDetails} open={liveTool(entry) || undefined}>
|
||||
<summary className={styles.tool}>{head}</summary>
|
||||
<summary className={styles.tool}>
|
||||
<Icon name="chevron-right" size={12} className={styles.toolChevron} />
|
||||
{head}
|
||||
</summary>
|
||||
<pre className={styles.output}>{entry.output}</pre>
|
||||
</details>
|
||||
</div>
|
||||
@@ -1546,8 +1579,6 @@ function WorkingSubagents({ entries, onOpen }: { entries: AgentEntry[] | null; o
|
||||
/** Agent-written titles with the task's own folder dropped from paths. */
|
||||
const shortTitle = (task: AgentTask, title: string) => title.replaceAll(`${task.cwd}/`, "");
|
||||
|
||||
type PermissionEntry = Extract<AgentEntry, { type: "permission" }>;
|
||||
|
||||
/**
|
||||
* The answers to an open permission request. A request without options (some agents send
|
||||
* none) can still be cancelled, so the agent doesn't wait for an answer it can't get.
|
||||
@@ -1562,56 +1593,109 @@ function PermissionAnswers({
|
||||
className?: string;
|
||||
}) {
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const pending = useRef(false);
|
||||
const { command } = permissionDetails(entry);
|
||||
const answer = (option: string | null) => {
|
||||
if (pending.current) return;
|
||||
pending.current = true;
|
||||
setBusy(true);
|
||||
void respond(task.id, entry.id, option).finally(() => setBusy(false));
|
||||
setError(null);
|
||||
void respond(task.id, entry.id, option)
|
||||
.catch((e: unknown) => setError(String(e)))
|
||||
.finally(() => {
|
||||
pending.current = false;
|
||||
setBusy(false);
|
||||
});
|
||||
};
|
||||
return (
|
||||
<span className={className}>
|
||||
{entry.options.length ? (
|
||||
entry.options.map((o) => (
|
||||
<button key={o.id} type="button" className={styles.bannerLink} disabled={busy} onClick={() => answer(o.id)}>
|
||||
{o.name}
|
||||
</button>
|
||||
))
|
||||
entry.options.map((o) => {
|
||||
const { label, variant } = permissionOption(o, !!command);
|
||||
return (
|
||||
<Button
|
||||
key={o.id}
|
||||
variant={variant}
|
||||
className={styles.permissionOption}
|
||||
size="small"
|
||||
aria-label={label}
|
||||
title={o.name}
|
||||
disabled={busy}
|
||||
onClick={() => answer(o.id)}
|
||||
>
|
||||
<span className={styles.permissionOptionLabel}>{label}</span>
|
||||
</Button>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<IconButton icon="close" title="Cancel" disabled={busy} onClick={() => answer(null)} />
|
||||
<Button variant="ghost" disabled={busy} onClick={() => answer(null)}>
|
||||
Cancel
|
||||
</Button>
|
||||
)}
|
||||
{error && (
|
||||
<span className={styles.error} role="alert">
|
||||
{error}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/** A permission request in the conversation; while open, answered right there as in the banner. */
|
||||
/** A single inline decision, reduced to a compact record after it is answered. */
|
||||
function PermissionLine({ task, entry }: { task: AgentTask; entry: PermissionEntry }) {
|
||||
const tx = useTx();
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const hadFocus = useRef(false);
|
||||
const { title, detail, command } = permissionDetails(entry, providerName(task.provider));
|
||||
const result = permissionResult(entry);
|
||||
useLayoutEffect(() => {
|
||||
if (entry.chosen && hadFocus.current && document.activeElement === document.body)
|
||||
ref.current?.focus({ preventScroll: true });
|
||||
}, [entry.chosen]);
|
||||
return (
|
||||
<div className={clsx(styles.permissionLine, entry.chosen ? styles.permissionDone : styles.permissionAsking)}>
|
||||
<Icon name={!entry.chosen ? "warning" : entry.chosen === "Cancelled" ? "minus" : "check"} size={13} />
|
||||
<div
|
||||
ref={ref}
|
||||
role="region"
|
||||
aria-label={title}
|
||||
tabIndex={entry.chosen ? -1 : undefined}
|
||||
onFocusCapture={() => {
|
||||
hadFocus.current = true;
|
||||
}}
|
||||
onBlurCapture={(e) => {
|
||||
if (e.relatedTarget && !e.currentTarget.contains(e.relatedTarget as Node)) hadFocus.current = false;
|
||||
}}
|
||||
className={clsx(styles.permissionLine, entry.chosen ? styles.permissionDone : styles.permissionAsking)}
|
||||
>
|
||||
<Icon name={!entry.chosen ? "warning" : result.allowed ? "check" : "minus"} size={14} />
|
||||
<div className={styles.permissionBody}>
|
||||
<span className={styles.permissionTitle} title={shortTitle(task, entry.title)}>
|
||||
{shortTitle(task, entry.title)}
|
||||
</span>
|
||||
<span className={styles.muted}>{entry.chosen ?? <T>Waiting for your answer</T>}</span>
|
||||
{!entry.chosen && !entry.questions?.length && (
|
||||
<PermissionAnswers task={task} entry={entry} className={styles.permissionAnswers} />
|
||||
{entry.chosen ? (
|
||||
<span className={styles.permissionRecord} title={detail}>
|
||||
{result.label}:{" "}
|
||||
<span className={command ? styles.permissionPrefix : undefined}>
|
||||
{command ? commandPrefix(command) : shortTitle(task, detail)}
|
||||
</span>
|
||||
</span>
|
||||
) : (
|
||||
<>
|
||||
<span className={styles.permissionTitle}>{title}</span>
|
||||
{command ? (
|
||||
<pre className={styles.permissionCommand} tabIndex={0} aria-label={tx("Command")}>
|
||||
<code>{command}</code>
|
||||
</pre>
|
||||
) : (
|
||||
<span className={styles.permissionDetail}>{shortTitle(task, detail)}</span>
|
||||
)}
|
||||
{!entry.questions?.length && (
|
||||
<PermissionAnswers task={task} entry={entry} className={styles.permissionAnswers} />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** The open permission request, as an editor notification banner above the conversation. */
|
||||
function PermissionBanner({ task, entries }: { task: AgentTask; entries: AgentEntry[] | null }) {
|
||||
const entry = entries?.find((e): e is PermissionEntry => e.type === "permission" && !e.chosen);
|
||||
if (!entry || entry.questions?.length) return null;
|
||||
return (
|
||||
<div className={styles.banner} role="alert" aria-label={entry.title}>
|
||||
<Icon name="warning" size={16} />
|
||||
<span className={styles.bannerText}>{shortTitle(task, entry.title)}</span>
|
||||
<PermissionAnswers task={task} entry={entry} className={styles.bannerActions} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface QuestionAnswer {
|
||||
question: AgentQuestion;
|
||||
picked: string[];
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
commandPrefix,
|
||||
permissionDetails,
|
||||
permissionOption,
|
||||
permissionResult,
|
||||
type PermissionEntry,
|
||||
} from "./permission";
|
||||
|
||||
const entry: PermissionEntry = {
|
||||
type: "permission",
|
||||
id: 1,
|
||||
title: 'Run python3 -c \'from pathlib import Path\np = Path("note.txt")\np.write_text("one\\ntwo")\'',
|
||||
options: [],
|
||||
chosen: null,
|
||||
};
|
||||
|
||||
it("separates the command from the heading without interpreting shell or Python escapes", () => {
|
||||
const { title, command } = permissionDetails(entry);
|
||||
expect(title).toBe("Codex wants to run a command");
|
||||
expect(command).toContain('Path("note.txt")\np.write_text("one\\ntwo")');
|
||||
expect(commandPrefix(command!)).toBe("python3 …");
|
||||
});
|
||||
|
||||
it.each(["Codex", "Claude Code", "Gemini"])("names the requesting provider %s", (provider) => {
|
||||
expect(permissionDetails(entry, provider).title).toBe(`${provider} wants to run a command`);
|
||||
});
|
||||
|
||||
it("keeps a subagent command in the code area without its session label", () => {
|
||||
expect(permissionDetails({ ...entry, title: `Explore: ${entry.title}` }).command).toBe(
|
||||
permissionDetails(entry).command,
|
||||
);
|
||||
});
|
||||
|
||||
describe("permission choices", () => {
|
||||
it.each([
|
||||
["allow_once", "yes", "Allow Once", "primary"],
|
||||
["allow_always", "policy", "Always Allow", "secondary"],
|
||||
["reject_once", "no", "Decline", "secondary"],
|
||||
["reject_always", "never", "Always Decline", "ghost"],
|
||||
["reject_once", "cancel", "Decline and Stop", "ghost"],
|
||||
])("presents %s without changing its id", (kind, id, label, variant) => {
|
||||
const option = { kind, id, name: "Always Allow `python3 -c a very long script`" };
|
||||
expect(permissionOption(option, true)).toMatchObject({ label, variant });
|
||||
expect(option.id).toBe(id);
|
||||
});
|
||||
|
||||
it("preserves the narrower session scope and custom question answers", () => {
|
||||
expect(permissionOption({ id: "session", kind: "allow_always", name: "Allow for This Session" }, true).label).toBe(
|
||||
"Allow for This Session",
|
||||
);
|
||||
expect(permissionOption({ id: "ship", kind: "allow_once", name: "Ship it" }, false).label).toBe("Ship it");
|
||||
});
|
||||
|
||||
it.each([
|
||||
["allow_once", "Allowed once", true],
|
||||
["allow_always", "Always allowed", true],
|
||||
["reject_once", "Declined", false],
|
||||
["reject_always", "Always declined", false],
|
||||
])("records %s compactly", (kind, label, allowed) => {
|
||||
const name = "Option with a long command";
|
||||
expect(permissionResult({ ...entry, chosen: name, options: [{ id: "opaque", name, kind }] })).toEqual({
|
||||
label,
|
||||
allowed,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
import type { AgentEntry, AgentPermissionOption } from "@/bindings";
|
||||
import { tx } from "@/platform/i18n";
|
||||
|
||||
export type PermissionEntry = Extract<AgentEntry, { type: "permission" }>;
|
||||
|
||||
export function permissionDetails(entry: PermissionEntry, provider = "Codex") {
|
||||
const command = entry.title.match(/^Run ([\s\S]+)/)?.[1] ?? entry.title.match(/^[^\n]+?: Run ([\s\S]+)/)?.[1];
|
||||
return {
|
||||
command,
|
||||
title: command ? tx`${provider} wants to run a command` : tx("Allow tool call?"),
|
||||
detail: command ?? entry.title,
|
||||
};
|
||||
}
|
||||
|
||||
/** Keep the permission scope explicit; the option id is sent back unchanged. */
|
||||
export function permissionOption(option: AgentPermissionOption, command: boolean) {
|
||||
const stop = option.id === "cancel" || /decline and stop/i.test(option.name);
|
||||
let label = option.name;
|
||||
if (stop) label = tx("Decline and Stop");
|
||||
else if (option.kind === "allow_always") {
|
||||
label = /for (this |the )?session/i.test(option.name) ? tx("Allow for This Session") : tx("Always Allow");
|
||||
} else if (option.kind === "reject_always") label = tx("Always Decline");
|
||||
else if (command || /^(allow( once)?|accept|decline|reject)$/i.test(option.name)) {
|
||||
if (option.kind === "allow_once") label = tx("Allow Once");
|
||||
else if (option.kind === "reject_once") label = tx("Decline");
|
||||
}
|
||||
return {
|
||||
label,
|
||||
variant:
|
||||
option.kind === "allow_once"
|
||||
? ("primary" as const)
|
||||
: stop || option.kind === "reject_always"
|
||||
? ("ghost" as const)
|
||||
: ("secondary" as const),
|
||||
};
|
||||
}
|
||||
|
||||
export function permissionResult(entry: PermissionEntry) {
|
||||
const option = entry.options.find((o) => o.name === entry.chosen);
|
||||
if (entry.chosen === "Cancelled") return { label: tx("Cancelled"), allowed: false };
|
||||
const allowed = option?.kind.startsWith("allow") ?? false;
|
||||
const labels: Record<string, string> = {
|
||||
allow_once: tx("Allowed once"),
|
||||
allow_always: /for (this |the )?session/i.test(option?.name ?? "")
|
||||
? tx("Allowed for this session")
|
||||
: tx("Always allowed"),
|
||||
reject_once: tx("Declined"),
|
||||
reject_always: tx("Always declined"),
|
||||
};
|
||||
return { label: option ? (labels[option.kind] ?? entry.chosen) : entry.chosen, allowed };
|
||||
}
|
||||
|
||||
export function commandPrefix(command: string) {
|
||||
return `${command.trim().split(/\s/)[0] ?? ""} …`;
|
||||
}
|
||||
@@ -121,6 +121,40 @@ beforeEach(() => {
|
||||
});
|
||||
|
||||
describe("conversation rows", () => {
|
||||
it("groups agent authors by turn and uses matching icon-only message action rows", async () => {
|
||||
const user = (text: string): AgentEntry => ({
|
||||
type: "user",
|
||||
text,
|
||||
attachments: [],
|
||||
steered: false,
|
||||
sentMs: null,
|
||||
doneMs: null,
|
||||
});
|
||||
backend.entries.set("A", [
|
||||
user("First question"),
|
||||
{ type: "agent", text: "First reply" },
|
||||
tool("read"),
|
||||
{ type: "agent", text: "More detail" },
|
||||
user("Next question"),
|
||||
{ type: "agent", text: "Next reply" },
|
||||
]);
|
||||
useAgents.setState({ entries: new Map(backend.entries) });
|
||||
render(<SessionEditor input={input("A")} />);
|
||||
await screen.findByText("More detail");
|
||||
expect(stream().querySelectorAll(`.${styles.agentMessage} .${styles.role}`)).toHaveLength(2);
|
||||
const quote = within(stream()).getAllByRole("button", { name: "Quote Reply" });
|
||||
const copy = within(stream()).getAllByRole("button", { name: "Copy Markdown" });
|
||||
expect(quote).toHaveLength(3);
|
||||
for (const button of [...quote, ...copy]) {
|
||||
expect(button.textContent).toBe("");
|
||||
expect(button.querySelector("svg path")).not.toBeNull();
|
||||
expect(button.parentElement).toHaveClass(styles.messageActions!);
|
||||
}
|
||||
expect(stream()).not.toHaveTextContent("66");
|
||||
expect(quote[0]?.className).toBe(copy[0]?.className);
|
||||
expect(within(stream()).getAllByRole("button", { name: "Copy" })[0]?.className).toBe(copy[0]?.className);
|
||||
});
|
||||
|
||||
it("builds only the requested tail, with absolute indexes", () => {
|
||||
const entries = Array.from({ length: 45 }, (_, i) => ({ type: "agent", text: `reply ${i}` }) as AgentEntry);
|
||||
const rows = conversationRows(entries, 40);
|
||||
@@ -269,7 +303,7 @@ describe("tool calls", () => {
|
||||
});
|
||||
|
||||
describe("a pending question", () => {
|
||||
it("shows the whole question in the banner, with every answer, and sends the chosen one", async () => {
|
||||
it("shows the whole question once inline, with every answer, and sends the chosen one", async () => {
|
||||
const question =
|
||||
'Scenario 4 ("follows the TR") still leaves the "About your request" section empty after two rewrites.\nHow should we continue?';
|
||||
const entries: AgentEntry[] = [
|
||||
@@ -289,9 +323,10 @@ describe("a pending question", () => {
|
||||
backend.answers = [];
|
||||
useAgents.setState({ entries: new Map(backend.entries) });
|
||||
render(<SessionEditor input={input("A")} />);
|
||||
const banner = await screen.findByRole("alert", { name: question });
|
||||
expect(banner.querySelector(`.${styles.bannerText}`)?.textContent).toBe(question);
|
||||
const answers = within(banner.querySelector<HTMLElement>(`.${styles.bannerActions}`)!).getAllByRole("button");
|
||||
const card = await screen.findByRole("region", { name: "Allow tool call?" });
|
||||
expect(within(card).getByText(question.replaceAll("\n", " "))).toBeInTheDocument();
|
||||
expect(screen.getAllByText(question.replaceAll("\n", " "))).toHaveLength(1);
|
||||
const answers = within(card).getAllByRole("button");
|
||||
expect(answers.map((b) => b.textContent)).toEqual([
|
||||
"Ship it as is and record it (Recommended)",
|
||||
"Detect the request in code",
|
||||
@@ -434,14 +469,76 @@ describe("a pending AskUserQuestion", () => {
|
||||
});
|
||||
|
||||
describe("a pending permission request in the conversation", () => {
|
||||
const command = 'python3 -c \'from pathlib import Path\np = Path("note.txt")\np.write_text("one\\ntwo")\'';
|
||||
const options = [
|
||||
{ id: "opaque-once", name: "Allow Once", kind: "allow_once" },
|
||||
{ id: "opaque-policy", name: `Always Allow \`${command}\``, kind: "allow_always" },
|
||||
{ id: "opaque-no", name: "Decline", kind: "reject_once" },
|
||||
{ id: "opaque-stop", name: "Decline and Stop", kind: "reject_always" },
|
||||
];
|
||||
|
||||
it("shows the requested command while another read tool is pending, with one card and short buttons", async () => {
|
||||
backend.entries.set("A", [
|
||||
{
|
||||
type: "tool",
|
||||
id: "pending-read",
|
||||
title: "List .",
|
||||
kind: "search",
|
||||
status: "running",
|
||||
output: null,
|
||||
diffs: [],
|
||||
locations: [],
|
||||
},
|
||||
{ type: "permission", id: 12, title: `Run ${command}`, options, chosen: null },
|
||||
]);
|
||||
useAgents.setState({ entries: new Map(backend.entries) });
|
||||
render(<SessionEditor input={input("A")} />);
|
||||
const card = await screen.findByRole("region", { name: "codex wants to run a command" });
|
||||
expect(screen.getAllByRole("region", { name: "codex wants to run a command" })).toHaveLength(1);
|
||||
expect(screen.queryByRole("alert")).toBeNull();
|
||||
expect(stream()).toContainElement(card);
|
||||
expect(card.querySelector("pre")?.textContent).toBe(command);
|
||||
const buttons = within(card).getAllByRole("button");
|
||||
expect(buttons.map((b) => b.getAttribute("aria-label"))).toEqual([
|
||||
"Allow Once",
|
||||
"Always Allow",
|
||||
"Decline",
|
||||
"Decline and Stop",
|
||||
]);
|
||||
expect(buttons.every((b) => b.tabIndex === 0)).toBe(true);
|
||||
expect(buttons[1]?.textContent).toBe("Always Allow");
|
||||
expect(buttons[1]?.title).toBe(options[1]?.name);
|
||||
});
|
||||
|
||||
it.each(options)("sends the original $kind option id, then collapses the resolved card", async (option) => {
|
||||
const pending: AgentEntry = { type: "permission", id: 12, title: `Run ${command}`, options, chosen: null };
|
||||
backend.answers = [];
|
||||
backend.entries.set("A", [pending]);
|
||||
useAgents.setState({ entries: new Map(backend.entries) });
|
||||
render(<SessionEditor input={input("A")} />);
|
||||
const card = await screen.findByRole("region", { name: "codex wants to run a command" });
|
||||
const button = within(card).getAllByRole("button")[options.indexOf(option)]!;
|
||||
button.focus();
|
||||
fireEvent.click(button);
|
||||
await waitFor(() => expect(backend.answers).toEqual([["A", 12, option.id]]));
|
||||
act(() =>
|
||||
useAgents.setState({ entries: new Map(backend.entries).set("A", [{ ...pending, chosen: option.name }]) }),
|
||||
);
|
||||
expect(card.querySelector("pre")).toBeNull();
|
||||
expect(within(card).queryByRole("button")).toBeNull();
|
||||
expect(card.textContent).toContain("python3 …");
|
||||
expect(card.textContent).not.toContain("write_text");
|
||||
expect(card).toHaveFocus();
|
||||
});
|
||||
|
||||
it("is answered from its line too, and one without options can be cancelled", async () => {
|
||||
backend.entries.set("A", [
|
||||
{
|
||||
type: "permission",
|
||||
id: 3,
|
||||
title: "ls d:/project",
|
||||
title: "Run ls d:/project",
|
||||
options: [
|
||||
{ id: "once", name: "Allow once", kind: "allow_once" },
|
||||
{ id: "once", name: "Allow Once", kind: "allow_once" },
|
||||
{ id: "reject", name: "Reject", kind: "reject_once" },
|
||||
],
|
||||
chosen: null,
|
||||
@@ -453,18 +550,16 @@ describe("a pending permission request in the conversation", () => {
|
||||
backend.answers = [];
|
||||
useAgents.setState({ entries: new Map(backend.entries) });
|
||||
const { unmount } = render(<SessionEditor input={input("A")} />);
|
||||
const line = (await screen.findAllByText("ls d:/project")).find((e) =>
|
||||
e.classList.contains(styles.permissionTitle!),
|
||||
)!.parentElement!;
|
||||
const line = await screen.findByRole("region", { name: "codex wants to run a command" });
|
||||
const answers = within(line.querySelector<HTMLElement>(`.${styles.permissionAnswers}`)!).getAllByRole("button");
|
||||
expect(answers.map((b) => b.textContent)).toEqual(["Allow once", "Reject"]);
|
||||
expect(answers.map((b) => b.textContent)).toEqual(["Allow Once", "Decline"]);
|
||||
fireEvent.click(answers[0]!);
|
||||
await waitFor(() => expect(backend.answers).toEqual([["A", 3, "once"]]));
|
||||
unmount();
|
||||
|
||||
render(<SessionEditor input={input("B")} />);
|
||||
const banner = await screen.findByRole("alert", { name: "The agent asks for permission" });
|
||||
fireEvent.click(within(banner).getByRole("button", { name: "Cancel" }));
|
||||
const card = await screen.findByRole("region", { name: "Allow tool call?" });
|
||||
fireEvent.click(within(card).getByRole("button", { name: "Cancel" }));
|
||||
await waitFor(() =>
|
||||
expect(backend.answers).toEqual([
|
||||
["A", 3, "once"],
|
||||
|
||||
@@ -403,5 +403,18 @@
|
||||
"Running an action applies pending settings first.": "Running an action applies pending settings first.",
|
||||
"Could not apply settings.": "Could not apply settings.",
|
||||
"The value exceeds this setting’s limits.": "The value exceeds this setting’s limits.",
|
||||
"Log tabs opened by plugin code are not available in remote projects yet.": "Log tabs opened by plugin code are not available in remote projects yet."
|
||||
"Log tabs opened by plugin code are not available in remote projects yet.": "Log tabs opened by plugin code are not available in remote projects yet.",
|
||||
"{0} wants to run a command": "{0} wants to run a command",
|
||||
"Allow tool call?": "Allow tool call?",
|
||||
"Allow Once": "Allow Once",
|
||||
"Always Allow": "Always Allow",
|
||||
"Allow for This Session": "Allow for This Session",
|
||||
"Decline": "Decline",
|
||||
"Decline and Stop": "Decline and Stop",
|
||||
"Always Decline": "Always Decline",
|
||||
"Allowed once": "Allowed once",
|
||||
"Allowed for this session": "Allowed for this session",
|
||||
"Always allowed": "Always allowed",
|
||||
"Declined": "Declined",
|
||||
"Always declined": "Always declined"
|
||||
}
|
||||
@@ -6756,5 +6756,18 @@
|
||||
"Running an action applies pending settings first.": "运行动作前会先应用待保存的设置。",
|
||||
"Could not apply settings.": "无法应用设置。",
|
||||
"The value exceeds this setting’s limits.": "该值超出了此设置的限制。",
|
||||
"Log tabs opened by plugin code are not available in remote projects yet.": "插件代码打开的日志标签页暂不支持远程项目。"
|
||||
"Log tabs opened by plugin code are not available in remote projects yet.": "插件代码打开的日志标签页暂不支持远程项目。",
|
||||
"{0} wants to run a command": "{0} 想要运行命令",
|
||||
"Allow tool call?": "允许工具调用?",
|
||||
"Allow Once": "允许一次",
|
||||
"Always Allow": "始终允许",
|
||||
"Allow for This Session": "在此会话中允许",
|
||||
"Decline": "拒绝",
|
||||
"Decline and Stop": "拒绝并停止",
|
||||
"Always Decline": "始终拒绝",
|
||||
"Allowed once": "已允许一次",
|
||||
"Allowed for this session": "已在此会话中允许",
|
||||
"Always allowed": "已设为始终允许",
|
||||
"Declined": "已拒绝",
|
||||
"Always declined": "已设为始终拒绝"
|
||||
}
|
||||
@@ -699,6 +699,14 @@ function build(lightSrc: string, darkSrc: string | undefined, keepColours: boole
|
||||
}
|
||||
|
||||
function prepare(path: string, large: boolean): Prepared | null {
|
||||
if (path === ICONS.quote) {
|
||||
return {
|
||||
viewBox: "0 0 16 16",
|
||||
html: '<path d="M3 3v10M6.5 4.5H13M6.5 8H13M6.5 11.5H11" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>',
|
||||
hasIds: false,
|
||||
mono: true,
|
||||
};
|
||||
}
|
||||
const key = `${path}|${large ? 20 : 16}`;
|
||||
let out = prepared.get(key);
|
||||
if (out !== undefined) return out;
|
||||
|
||||
Reference in new issue
Заблокувати користувача