mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-13 04:34:21 -10:00
Compare commits
1
Commits
main
..
fix/i18n-cli
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
56727e3c4b |
No files matched your search
+14
-43
@@ -71,14 +71,7 @@ async function openReply(page: Page, text: string, imageData?: string, theme?: "
|
||||
[
|
||||
task.id,
|
||||
[
|
||||
{
|
||||
type: "user",
|
||||
text: "Show Markdown",
|
||||
attachments: [],
|
||||
sentMs: 0,
|
||||
doneMs: 1,
|
||||
steered: false,
|
||||
},
|
||||
{ type: "user", text: "Show Markdown", attachments: [], sentMs: 0, doneMs: 1, steered: false },
|
||||
{ type: "agent", text },
|
||||
],
|
||||
],
|
||||
@@ -118,11 +111,7 @@ test("scrolls a wide table inside the reply", async ({ page }) => {
|
||||
const reply = await openReply(page, REPLY);
|
||||
const sizes = await reply.evaluate((el) => {
|
||||
const table = el.querySelector("table")!;
|
||||
return {
|
||||
reply: el.clientWidth,
|
||||
table: table.clientWidth,
|
||||
content: table.scrollWidth,
|
||||
};
|
||||
return { reply: el.clientWidth, table: table.clientWidth, content: table.scrollWidth };
|
||||
});
|
||||
expect(sizes.table).toBeLessThanOrEqual(sizes.reply);
|
||||
expect(sizes.content).toBeGreaterThan(sizes.table);
|
||||
@@ -159,10 +148,7 @@ test("loads local reply images, and web ones when asked", async ({ page }) => {
|
||||
let fetched = 0;
|
||||
await page.route("https://images.example.com/code.gif", (route) => {
|
||||
fetched++;
|
||||
return route.fulfill({
|
||||
contentType: "image/gif",
|
||||
body: Buffer.from(PIXEL.split(",")[1]!, "base64"),
|
||||
});
|
||||
return route.fulfill({ contentType: "image/gif", body: Buffer.from(PIXEL.split(",")[1]!, "base64") });
|
||||
});
|
||||
const reply = await openReply(
|
||||
page,
|
||||
@@ -172,9 +158,7 @@ test("loads local reply images, and web ones when asked", async ({ page }) => {
|
||||
const width = (name: string) =>
|
||||
reply.getByRole("img", { name, includeHidden: true }).evaluate((img: HTMLImageElement) => img.naturalWidth);
|
||||
await expect.poll(() => width("Local QR")).toBe(1);
|
||||
const load = reply.getByRole("button", {
|
||||
name: "Load image from images.example.com",
|
||||
});
|
||||
const load = reply.getByRole("button", { name: "Load image from images.example.com" });
|
||||
await expect(load).toBeVisible();
|
||||
expect(fetched).toBe(0);
|
||||
await load.click();
|
||||
@@ -219,10 +203,12 @@ test("keeps latest reply actions visible and quotes into the draft without shift
|
||||
await page.getByRole("button", { name: "Quote Reply", exact: true }).click();
|
||||
const draft = page.getByRole("textbox", { name: "Message", exact: true });
|
||||
await expect(draft).toBeFocused();
|
||||
// Quoted as `> ` lines, drawn as a block without their markers, an empty line to type in after it.
|
||||
const quoted = REPLY.trimEnd().split("\n");
|
||||
await expect(draft.locator(".cm-line")).toHaveText([...quoted, "", ""]);
|
||||
await expect(draft.locator(".cm-line[class*='_quoteLine_']")).toHaveCount(quoted.length);
|
||||
await expect(draft).toHaveValue(
|
||||
REPLY.trimEnd()
|
||||
.split("\n")
|
||||
.map((line) => (line ? `> ${line}` : ">"))
|
||||
.join("\n") + "\n\n",
|
||||
);
|
||||
});
|
||||
|
||||
test("copies a mouse selection with Ctrl+C and pops up no Quote button", async ({ page, context }) => {
|
||||
@@ -245,14 +231,7 @@ test("keeps chat history readable and selections free of timestamps and status l
|
||||
await page.evaluate(async (task) => {
|
||||
const path = "/src/features/agents/store.ts";
|
||||
const store = await import(path);
|
||||
const user = (text: string) => ({
|
||||
type: "user",
|
||||
text,
|
||||
attachments: [],
|
||||
sentMs: 1,
|
||||
doneMs: 5001,
|
||||
steered: false,
|
||||
});
|
||||
const user = (text: string) => ({ type: "user", text, attachments: [], sentMs: 1, doneMs: 5001, steered: false });
|
||||
store.useAgents.setState({
|
||||
entries: new Map([
|
||||
[
|
||||
@@ -347,9 +326,7 @@ test("keeps hover actions reachable when the pointer leaves the message body", a
|
||||
const copy = page.getByRole("button", { name: "Copy Markdown", exact: true });
|
||||
await reply.hover();
|
||||
const box = await copy.boundingBox();
|
||||
await page.mouse.move(box!.x + box!.width / 2, box!.y + box!.height / 2, {
|
||||
steps: 20,
|
||||
});
|
||||
await page.mouse.move(box!.x + box!.width / 2, box!.y + box!.height / 2, { steps: 20 });
|
||||
await expect(copy.locator("..")).toHaveCSS("opacity", "1");
|
||||
await copy.click();
|
||||
expect(await page.evaluate(() => navigator.clipboard.readText())).toBe("A short answer.");
|
||||
@@ -491,10 +468,7 @@ for (const theme of ["dark", "light"] as const) {
|
||||
const bar = document.getElementById(el.id.replace(/-text$/, ""))!;
|
||||
const fg = luminance(getComputedStyle(el).fill);
|
||||
const bg = luminance(getComputedStyle(bar).fill);
|
||||
return {
|
||||
text: el.textContent,
|
||||
ratio: (Math.max(fg, bg) + 0.05) / (Math.min(fg, bg) + 0.05),
|
||||
};
|
||||
return { text: el.textContent, ratio: (Math.max(fg, bg) + 0.05) / (Math.min(fg, bg) + 0.05) };
|
||||
});
|
||||
});
|
||||
expect(contrasts).toHaveLength(4);
|
||||
@@ -506,10 +480,7 @@ test("draws a diagram without reaching the hosts its configuration names", async
|
||||
const asked: string[] = [];
|
||||
await page.route(/^https:\/\/([a-z\d-]+\.)*example\.com\//, (route) => {
|
||||
asked.push(new URL(route.request().url()).pathname);
|
||||
return route.fulfill({
|
||||
contentType: "image/gif",
|
||||
body: Buffer.from(PIXEL.split(",")[1]!, "base64"),
|
||||
});
|
||||
return route.fulfill({ contentType: "image/gif", body: Buffer.from(PIXEL.split(",")[1]!, "base64") });
|
||||
});
|
||||
const at = (name: string) => `https://images.example.com/${name}`;
|
||||
const fence = (...lines: string[]) => ["```mermaid", ...lines, "```"].join("\n");
|
||||
|
||||
@@ -22,62 +22,58 @@
|
||||
inset 0 0 0 1px var(--focus-ring),
|
||||
0 0 0 1px var(--focus-ring);
|
||||
}
|
||||
/* The CodeMirror text field: the UI font, wrapping, growing with the text up to a limit. */
|
||||
.field {
|
||||
position: relative;
|
||||
min-width: 0;
|
||||
}
|
||||
.field :global(.cm-editor) {
|
||||
background: transparent;
|
||||
color: var(--fg-strong);
|
||||
font-size: var(--fs-ui);
|
||||
}
|
||||
.field :global(.cm-editor.cm-focused) {
|
||||
outline: none;
|
||||
}
|
||||
.field :global(.cm-scroller) {
|
||||
min-height: 44px;
|
||||
max-height: 280px;
|
||||
overflow-y: auto;
|
||||
font-family: inherit;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.field :global(.cm-content) {
|
||||
/* Tags under the transparent textarea: same font, metrics and padding, so the caret
|
||||
and selection of the real textarea line up with the painted text. */
|
||||
.mirror {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
overflow: hidden;
|
||||
padding: 10px 12px 2px;
|
||||
caret-color: var(--fg-strong);
|
||||
font: inherit;
|
||||
font-size: var(--fs-ui);
|
||||
line-height: 1.5;
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.field :global(.cm-line) {
|
||||
padding: 0;
|
||||
}
|
||||
/* A wide card (a ``` block) takes the text's width, its code scrolling inside, rather than
|
||||
widening the text past the box. */
|
||||
.field :global(.cm-content) {
|
||||
min-width: 0;
|
||||
}
|
||||
.field :global(.cm-placeholder) {
|
||||
color: var(--fg-faint);
|
||||
}
|
||||
.field :global(.cm-cursor) {
|
||||
border-left-color: var(--fg-strong);
|
||||
}
|
||||
.field ::selection {
|
||||
background: var(--bg-editor-selection);
|
||||
}
|
||||
/* A dragged file over the box: what dropping does. */
|
||||
.dropOverlay {
|
||||
position: absolute;
|
||||
z-index: 2;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
border-radius: inherit;
|
||||
background: color-mix(in srgb, var(--bg-popup) 82%, transparent);
|
||||
color: var(--fg-strong);
|
||||
pointer-events: none;
|
||||
-webkit-user-select: none;
|
||||
user-select: none;
|
||||
}
|
||||
.textarea {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: block;
|
||||
width: 100%;
|
||||
min-height: 44px;
|
||||
resize: none;
|
||||
border: 0;
|
||||
outline: none;
|
||||
background: transparent;
|
||||
color: transparent;
|
||||
caret-color: var(--fg-strong);
|
||||
font: inherit;
|
||||
font-size: var(--fs-ui);
|
||||
line-height: 1.5;
|
||||
padding: 10px 12px 2px;
|
||||
overflow-wrap: anywhere;
|
||||
-webkit-user-select: text;
|
||||
user-select: text;
|
||||
}
|
||||
.textarea:focus-visible {
|
||||
outline: none;
|
||||
}
|
||||
.textarea::placeholder {
|
||||
color: var(--fg-faint);
|
||||
}
|
||||
/* The textarea's own glyphs are transparent so the mirror above shows the tags; the
|
||||
selection would then paint an empty highlight. Paint the selected glyphs here instead —
|
||||
the opaque selection background covers the mirror underneath. */
|
||||
.textarea::selection {
|
||||
background: var(--bg-editor-selection);
|
||||
color: var(--fg-strong);
|
||||
}
|
||||
.bar {
|
||||
display: flex;
|
||||
@@ -351,19 +347,17 @@
|
||||
color: var(--fg-subtle);
|
||||
}
|
||||
|
||||
/* Above the text: the current file to reference, attached files and images. */
|
||||
.context {
|
||||
.images {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 8px 10px 0;
|
||||
cursor: default;
|
||||
}
|
||||
.image {
|
||||
position: relative;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: var(--radius-small);
|
||||
box-shadow: inset 0 0 0 1px var(--border);
|
||||
overflow: hidden;
|
||||
@@ -399,63 +393,21 @@
|
||||
opacity: 0;
|
||||
}
|
||||
.image:hover .removeImage,
|
||||
.attachment:hover .removeImage,
|
||||
.removeImage:focus-visible {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Attached files and the current-file suggestion: one height, like the chips in the text. */
|
||||
.attachment,
|
||||
.suggestion {
|
||||
.attachment {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
gap: 6px;
|
||||
max-width: 240px;
|
||||
height: 24px;
|
||||
padding: 0 4px 0 7px;
|
||||
border-radius: var(--radius-control);
|
||||
box-shadow: inset 0 0 0 1px var(--border);
|
||||
font-size: var(--fs-small);
|
||||
color: var(--fg-strong);
|
||||
}
|
||||
.suggestion {
|
||||
padding-right: 8px;
|
||||
border: 0;
|
||||
background: none;
|
||||
box-shadow: none;
|
||||
outline: 1px dashed var(--border-strong);
|
||||
outline-offset: -1px;
|
||||
color: var(--fg-muted);
|
||||
cursor: pointer;
|
||||
transition:
|
||||
color var(--dur-fast) var(--ease),
|
||||
background var(--dur-fast) var(--ease);
|
||||
}
|
||||
.suggestion:hover {
|
||||
background: var(--bg-hover);
|
||||
color: var(--fg-strong);
|
||||
}
|
||||
.suggestion:focus-visible {
|
||||
outline: 2px solid var(--focus-ring);
|
||||
}
|
||||
.suggestionHint {
|
||||
color: var(--fg-faint);
|
||||
}
|
||||
.removeChip {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
height: 44px;
|
||||
padding: 0 10px;
|
||||
border-radius: var(--radius-small);
|
||||
background: none;
|
||||
color: var(--fg-muted);
|
||||
cursor: pointer;
|
||||
}
|
||||
.removeChip:hover {
|
||||
background: var(--bg-hover);
|
||||
color: var(--fg-strong);
|
||||
box-shadow: inset 0 0 0 1px var(--border);
|
||||
}
|
||||
.attachmentName {
|
||||
overflow: hidden;
|
||||
@@ -464,6 +416,16 @@
|
||||
}
|
||||
|
||||
/* `@` / `/` completion; Popup keeps the list within the viewport. */
|
||||
.caretMirror {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
box-sizing: border-box;
|
||||
visibility: hidden;
|
||||
pointer-events: none;
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
.popup {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -491,30 +453,3 @@
|
||||
text-overflow: ellipsis;
|
||||
color: var(--fg-subtle);
|
||||
}
|
||||
.match {
|
||||
background: none;
|
||||
color: var(--fg-completion-match, var(--accent));
|
||||
font-weight: 600;
|
||||
}
|
||||
.popupFooter {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px 14px;
|
||||
padding: 5px 12px 6px;
|
||||
border-top: 1px solid var(--border);
|
||||
color: var(--fg-subtle);
|
||||
font-size: var(--fs-small);
|
||||
}
|
||||
.popupFooter kbd {
|
||||
display: inline-grid;
|
||||
place-items: center;
|
||||
min-width: 16px;
|
||||
height: 16px;
|
||||
margin-right: 2px;
|
||||
padding: 0 3px;
|
||||
border-radius: var(--radius-small);
|
||||
background: var(--bg-hover);
|
||||
color: var(--fg-muted);
|
||||
font-family: var(--font-keys, inherit);
|
||||
font-size: var(--fs-tiny, 11px);
|
||||
}
|
||||
+205
-669
File diff suppressed because it is too large.
Load diff
@@ -31,8 +31,7 @@
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
/* The chips' own padding spaces them. */
|
||||
gap: 2px;
|
||||
gap: 6px;
|
||||
padding: 0 2px;
|
||||
font-size: var(--fs-small);
|
||||
}
|
||||
@@ -68,6 +67,7 @@
|
||||
.docked .where {
|
||||
flex-wrap: nowrap;
|
||||
min-width: 0;
|
||||
gap: 6px;
|
||||
}
|
||||
.docked .projectPicker,
|
||||
.docked .projectChip {
|
||||
|
||||
@@ -11,6 +11,7 @@ import { Marked } from "marked";
|
||||
import DOMPurify from "dompurify";
|
||||
import { highlightInto } from "@/editor/lsp/markdown";
|
||||
import { iconNode } from "@/editor/lsp/iconMarkup";
|
||||
import { call, commands } from "@/platform/ipc";
|
||||
import { tx } from "@/platform/i18n";
|
||||
import { openExternalUrl } from "@/platform/openUrl";
|
||||
import { copyText } from "@/ui";
|
||||
@@ -19,7 +20,6 @@ import { hasMermaid, renderMermaidIn } from "@/features/viewers/markdown/mermaid
|
||||
import { subscribeTheme, themeKey } from "@/platform/themeWatch";
|
||||
import { fileHref, pathLink, type PathLink } from "./conversation";
|
||||
import { loadImages, type MessageImages } from "./markdownImages";
|
||||
import { fileExists } from "./ReferenceChip";
|
||||
import styles from "./Session.module.css";
|
||||
|
||||
const escapeHtml = (s: string) =>
|
||||
@@ -167,6 +167,20 @@ function wrapLooseText(li: Element) {
|
||||
flush();
|
||||
}
|
||||
|
||||
/** Whether a path names an existing file, per path (agents name the same files again and again). */
|
||||
const existing = new Map<string, Promise<boolean>>();
|
||||
function fileExists(path: string): Promise<boolean> {
|
||||
let known = existing.get(path);
|
||||
if (!known) {
|
||||
known = call(commands.fsProbe(path)).then(
|
||||
() => true,
|
||||
() => false,
|
||||
);
|
||||
existing.set(path, known);
|
||||
}
|
||||
return known;
|
||||
}
|
||||
|
||||
export function openPathLink(link: PathLink) {
|
||||
const selection = link.line === undefined ? undefined : { line: link.line, col: link.col ?? 0 };
|
||||
void editorService().openFile(link.path, { selection, focus: true });
|
||||
|
||||
@@ -1,262 +0,0 @@
|
||||
/* An `@file` / `/skill` reference: icon, name and line range, in the composer and in sent messages. */
|
||||
.chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
max-width: 100%;
|
||||
height: 20px;
|
||||
padding: 0 6px 0 4px;
|
||||
margin: 0 1px;
|
||||
vertical-align: -4px;
|
||||
border-radius: var(--radius-selection);
|
||||
background: color-mix(in srgb, var(--fg) 9%, transparent);
|
||||
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--fg) 14%, transparent);
|
||||
color: var(--fg-strong);
|
||||
font-size: var(--fs-small);
|
||||
line-height: 20px;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
transition:
|
||||
background var(--dur-fast) var(--ease),
|
||||
box-shadow var(--dur-fast) var(--ease);
|
||||
}
|
||||
.chip:hover {
|
||||
background: color-mix(in srgb, var(--accent) 16%, transparent);
|
||||
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--accent) 40%, transparent);
|
||||
}
|
||||
.chip:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 2px var(--focus-ring);
|
||||
}
|
||||
.chip > svg,
|
||||
.chip > img {
|
||||
flex: none;
|
||||
}
|
||||
.name {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.lines {
|
||||
flex: none;
|
||||
color: var(--fg-muted);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
/* The file is gone (renamed or deleted since it was referenced). */
|
||||
.missing {
|
||||
color: var(--fg-muted);
|
||||
background: transparent;
|
||||
box-shadow: none;
|
||||
outline: 1px dashed var(--border-strong);
|
||||
outline-offset: -1px;
|
||||
}
|
||||
.missing .name {
|
||||
text-decoration: line-through;
|
||||
}
|
||||
/* Selected together with the text around it in the composer. */
|
||||
.selected {
|
||||
background: var(--bg-editor-selection);
|
||||
}
|
||||
/* A `/command` or skill. */
|
||||
.command {
|
||||
display: inline;
|
||||
padding: 1px 3px;
|
||||
margin: 0 -3px;
|
||||
border-radius: var(--radius-selection);
|
||||
-webkit-box-decoration-break: clone;
|
||||
box-decoration-break: clone;
|
||||
color: var(--accent);
|
||||
background: color-mix(in srgb, var(--accent) 16%, transparent);
|
||||
}
|
||||
/* `@something` that names no referenced file: as typed, just marked. */
|
||||
.pending {
|
||||
color: var(--fg-strong);
|
||||
text-decoration: underline dotted color-mix(in srgb, var(--fg) 40%, transparent);
|
||||
text-underline-offset: 3px;
|
||||
}
|
||||
|
||||
/* A quoted reply or selection in a sent message: as a Markdown quote in a reply. */
|
||||
.quote {
|
||||
margin: 2px 0 6px;
|
||||
padding: 0 10px;
|
||||
border-left: 3px solid var(--border-strong);
|
||||
color: var(--fg-secondary);
|
||||
}
|
||||
|
||||
/* Hover card: the full path, and the referenced lines (or the file's first lines). */
|
||||
.card {
|
||||
position: fixed;
|
||||
z-index: 1000;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: max-content;
|
||||
max-width: min(520px, calc(100vw - 24px));
|
||||
border-radius: var(--radius-popup);
|
||||
background: var(--bg-popup);
|
||||
box-shadow:
|
||||
0 0 0 1px var(--border-popup),
|
||||
var(--shadow-popup);
|
||||
color: var(--fg);
|
||||
font-size: var(--fs-small);
|
||||
pointer-events: none;
|
||||
overflow: hidden;
|
||||
}
|
||||
.cardHead {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 10px;
|
||||
}
|
||||
.cardPath {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: var(--fg-strong);
|
||||
}
|
||||
.cardMeta {
|
||||
flex: none;
|
||||
margin-left: auto;
|
||||
padding-left: 12px;
|
||||
color: var(--fg-subtle);
|
||||
}
|
||||
.cardCode {
|
||||
display: flex;
|
||||
margin: 0;
|
||||
padding: 6px 10px 8px;
|
||||
border-top: 1px solid var(--border);
|
||||
background: var(--bg-editor-inline, var(--bg-elev));
|
||||
color: var(--fg-editor);
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--fs-small);
|
||||
line-height: 1.5;
|
||||
white-space: pre;
|
||||
overflow: hidden;
|
||||
tab-size: 4;
|
||||
}
|
||||
.cardLine {
|
||||
flex: none;
|
||||
min-width: 3ch;
|
||||
margin-right: 10px;
|
||||
color: var(--fg-line-number);
|
||||
text-align: right;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
/* A quote in the composer: its lines as one block, the `>` hidden. */
|
||||
.quoteLine {
|
||||
margin-left: 0;
|
||||
padding-left: 10px !important;
|
||||
border-left: 3px solid var(--border-strong);
|
||||
color: var(--fg-secondary);
|
||||
}
|
||||
.quoteFirst {
|
||||
margin-top: 2px;
|
||||
}
|
||||
.quoteLast {
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
/* Sent message text: lays out as the text it wraps. */
|
||||
.text {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
/* A ``` block (pasted code, terminal output): a card with a title, the line count and the code. */
|
||||
.codeCard {
|
||||
display: block;
|
||||
box-sizing: border-box;
|
||||
max-width: 100%;
|
||||
min-width: 0;
|
||||
margin: 4px 0 6px;
|
||||
border-radius: var(--radius-control);
|
||||
background: var(--bg-editor-inline, var(--bg-elev));
|
||||
box-shadow: inset 0 0 0 1px var(--border);
|
||||
white-space: normal;
|
||||
overflow: hidden;
|
||||
cursor: default;
|
||||
}
|
||||
.codeHead {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
height: 26px;
|
||||
padding: 0 4px 0 8px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
color: var(--fg-strong);
|
||||
font-size: var(--fs-small);
|
||||
}
|
||||
.codeTitle {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.codeMeta {
|
||||
flex: none;
|
||||
margin-left: auto;
|
||||
color: var(--fg-subtle);
|
||||
}
|
||||
.codeRemove,
|
||||
.codeCopy {
|
||||
display: grid;
|
||||
flex: none;
|
||||
place-items: center;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: var(--radius-small);
|
||||
background: none;
|
||||
color: var(--fg-muted);
|
||||
cursor: pointer;
|
||||
}
|
||||
.codeRemove:hover,
|
||||
.codeCopy:hover {
|
||||
background: var(--bg-hover);
|
||||
color: var(--fg-strong);
|
||||
}
|
||||
.codeBody {
|
||||
margin: 0;
|
||||
padding: 6px 10px;
|
||||
color: var(--fg-editor);
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--fs-small);
|
||||
line-height: 1.5;
|
||||
white-space: pre;
|
||||
overflow-x: auto;
|
||||
tab-size: 4;
|
||||
}
|
||||
/* More lines than shown: the last ones fade out. */
|
||||
.codeFaded {
|
||||
-webkit-mask-image: linear-gradient(to bottom, var(--fg) 55%, transparent);
|
||||
mask-image: linear-gradient(to bottom, var(--fg) 55%, transparent);
|
||||
}
|
||||
.codeMore {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 24px;
|
||||
border: 0;
|
||||
border-top: 1px solid var(--border);
|
||||
background: none;
|
||||
color: var(--link);
|
||||
font-size: var(--fs-small);
|
||||
cursor: pointer;
|
||||
}
|
||||
.codeMore:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
/* In the composer: the card opens as text on a click. */
|
||||
.codeCard[title] {
|
||||
cursor: pointer;
|
||||
}
|
||||
.codeCard[title]:hover {
|
||||
box-shadow: inset 0 0 0 1px var(--border-strong);
|
||||
}
|
||||
/* A ``` block being edited as text in the composer. */
|
||||
.codeLine {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--fs-small);
|
||||
background: var(--bg-editor-inline, var(--bg-elev));
|
||||
}
|
||||
@@ -1,401 +0,0 @@
|
||||
/**
|
||||
* `@file` / `@folder/` / `@file:10-20` references drawn as chips (icon, name, lines), in the
|
||||
* composer and in sent messages: a click opens the file at its lines (a folder is selected in
|
||||
* the Project view), hovering shows the full path and the lines.
|
||||
*/
|
||||
import { Fragment, useEffect, useRef, useState, type ReactNode } from "react";
|
||||
import clsx from "clsx";
|
||||
import { computePosition, flip, offset, shift } from "@floating-ui/react-dom";
|
||||
import { highlightInto, languageForPath } from "@/editor/lsp/markdown";
|
||||
import { iconNode } from "@/editor/lsp/iconMarkup";
|
||||
import { executeAction } from "@/platform/actions";
|
||||
import { tx, useTx } from "@/platform/i18n";
|
||||
import { call, commands } from "@/platform/ipc";
|
||||
import { basename } from "@/platform/project";
|
||||
import { copyText, fileIconName, Icon } from "@/ui";
|
||||
import { editorService } from "@/workbench/contributions";
|
||||
import {
|
||||
fenceLabel,
|
||||
fencedBlocks,
|
||||
isQuoteLine,
|
||||
linesLabel,
|
||||
messageTokens,
|
||||
parseMention,
|
||||
resolveTyped,
|
||||
type Mention,
|
||||
} from "./composerModel";
|
||||
import styles from "./ReferenceChip.module.css";
|
||||
|
||||
export { styles as chipStyles };
|
||||
|
||||
/** Whether a path names an existing file or folder, per path (the same files come up again and again). */
|
||||
const existing = new Map<string, Promise<boolean>>();
|
||||
export function fileExists(path: string): Promise<boolean> {
|
||||
let known = existing.get(path);
|
||||
if (!known) {
|
||||
known = call(commands.fsProbe(path)).then(
|
||||
() => true,
|
||||
() => false,
|
||||
);
|
||||
existing.set(path, known);
|
||||
}
|
||||
return known;
|
||||
}
|
||||
|
||||
/** Opens a referenced file at its first line, or selects a folder in the Project view. */
|
||||
export function openReference(path: string, mention: Mention) {
|
||||
if (mention.dir) {
|
||||
void executeAction("navigate.selectInProjectView", path.replace(/\/$/, ""));
|
||||
return;
|
||||
}
|
||||
const selection = mention.lines ? { line: mention.lines.from - 1, col: 0 } : undefined;
|
||||
void editorService().openFile(path, { selection, focus: true });
|
||||
}
|
||||
|
||||
const nameOf = (m: Mention) => basename(m.rel.replace(/\/$/, "")) || m.rel;
|
||||
const iconOf = (m: Mention) => (m.dir ? "folder" : fileIconName(nameOf(m), m.rel));
|
||||
|
||||
/** The chip as DOM, for the composer's editor. */
|
||||
export function chipElement(path: string, mention: Mention): HTMLElement {
|
||||
const chip = document.createElement("span");
|
||||
chip.className = styles.chip!;
|
||||
chip.dataset.refPath = path;
|
||||
chip.dataset.refRel = mention.rel;
|
||||
if (mention.lines) chip.dataset.refLines = linesLabel(mention.lines);
|
||||
chip.setAttribute("aria-label", ariaLabel(mention));
|
||||
chip.append(iconNode(iconOf(mention), 14));
|
||||
const name = document.createElement("span");
|
||||
name.className = styles.name!;
|
||||
name.textContent = nameOf(mention);
|
||||
chip.append(name);
|
||||
if (mention.lines) {
|
||||
const lines = document.createElement("span");
|
||||
lines.className = styles.lines!;
|
||||
lines.textContent = linesLabel(mention.lines);
|
||||
chip.append(lines);
|
||||
}
|
||||
if (!mention.dir)
|
||||
void fileExists(path).then((exists) => {
|
||||
if (!exists) chip.classList.add(styles.missing!);
|
||||
});
|
||||
return chip;
|
||||
}
|
||||
|
||||
function ariaLabel(m: Mention): string {
|
||||
const name = nameOf(m);
|
||||
if (m.dir) return tx`Folder reference ${name}`;
|
||||
if (!m.lines) return tx`File reference ${name}`;
|
||||
const lines = linesLabel(m.lines);
|
||||
return tx`File reference ${name}, lines ${lines}`;
|
||||
}
|
||||
|
||||
/** A reference in a sent message. */
|
||||
export function ReferenceChip({ path, mention }: { path: string; mention: Mention }) {
|
||||
const [missing, setMissing] = useState(false);
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
if (!mention.dir) void fileExists(path).then((exists) => alive && setMissing(!exists));
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, [path, mention.dir]);
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={clsx(styles.chip, missing && styles.missing)}
|
||||
data-ref-path={path}
|
||||
data-ref-rel={mention.rel}
|
||||
data-ref-lines={mention.lines ? linesLabel(mention.lines) : undefined}
|
||||
aria-label={ariaLabel(mention)}
|
||||
onClick={() => openReference(path, mention)}
|
||||
>
|
||||
<Icon name={iconOf(mention)} size={14} />
|
||||
<span className={styles.name}>{nameOf(mention)}</span>
|
||||
{mention.lines && <span className={styles.lines}>{linesLabel(mention.lines)}</span>}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Message text with its references as chips, `/commands` tagged, `>` quotes and ``` blocks as cards.
|
||||
* `cwd` is the directory references resolve in.
|
||||
*/
|
||||
export function ReferencedText({ text, cwd }: { text: string; cwd: string }) {
|
||||
const [root, setRoot] = useState<HTMLSpanElement | null>(null);
|
||||
useReferenceHover(root);
|
||||
const all = text.split("\n");
|
||||
const blocks: ({ kind: "quote" | "text"; text: string } | { kind: "code"; info: string; text: string })[] = [];
|
||||
const push = (kind: "quote" | "text", body: string) => {
|
||||
const last = blocks.at(-1);
|
||||
if (last && last.kind === kind) last.text += `\n${body}`;
|
||||
else blocks.push({ kind, text: body });
|
||||
};
|
||||
let i = 0;
|
||||
for (const fence of [...fencedBlocks(all), null]) {
|
||||
const end = fence ? fence.open : all.length;
|
||||
for (; i < end; i++) {
|
||||
const line = all[i]!;
|
||||
const quote = isQuoteLine(line);
|
||||
push(quote ? "quote" : "text", quote ? line.replace(/^> ?/, "") : line);
|
||||
}
|
||||
if (!fence) break;
|
||||
blocks.push({ kind: "code", info: fence.info, text: all.slice(fence.open + 1, fence.close).join("\n") });
|
||||
i = fence.close + 1;
|
||||
}
|
||||
return (
|
||||
<span ref={setRoot} className={styles.text}>
|
||||
{blocks.map((block, n) => {
|
||||
if (block.kind === "code") return <CodeCard key={n} info={block.info} body={block.text} />;
|
||||
if (block.kind === "quote")
|
||||
return (
|
||||
<div key={n} className={styles.quote}>
|
||||
{block.text}
|
||||
</div>
|
||||
);
|
||||
// A block ends its own line: the paragraph after it starts without an empty one.
|
||||
const body = n > 0 ? block.text.replace(/^\n/, "") : block.text;
|
||||
return <Fragment key={n}>{inline(body, cwd)}</Fragment>;
|
||||
})}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function inline(text: string, cwd: string): ReactNode[] {
|
||||
return messageTokens(text).map((token, i) => {
|
||||
if (token.kind === "text") return <Fragment key={i}>{token.value}</Fragment>;
|
||||
if (token.kind === "command")
|
||||
return (
|
||||
<span key={i} className={styles.command}>
|
||||
{token.value}
|
||||
</span>
|
||||
);
|
||||
const mention = parseMention(token.value);
|
||||
if (!mention) return <Fragment key={i}>{token.value}</Fragment>;
|
||||
return <ReferenceChip key={i} path={resolveTyped(cwd, mention.rel)} mention={mention} />;
|
||||
});
|
||||
}
|
||||
|
||||
const HOVER_DELAY = 400;
|
||||
const PREVIEW_LINES = 12;
|
||||
const MAX_PREVIEW_BYTES = 2 * 1024 * 1024;
|
||||
|
||||
/**
|
||||
* Hover cards for the chips below `root`: the full path, and the referenced lines (a file's
|
||||
* first lines without a range). Returns a disposer.
|
||||
*/
|
||||
export function referenceHover(root: HTMLElement): () => void {
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
let card: HTMLElement | null = null;
|
||||
let current: HTMLElement | null = null;
|
||||
|
||||
const hide = () => {
|
||||
clearTimeout(timer);
|
||||
card?.remove();
|
||||
card = null;
|
||||
current = null;
|
||||
};
|
||||
|
||||
const place = (anchor: HTMLElement, el: HTMLElement) =>
|
||||
void computePosition(anchor, el, {
|
||||
strategy: "fixed",
|
||||
placement: "top-start",
|
||||
middleware: [offset(6), flip(), shift({ padding: 8 })],
|
||||
}).then(({ x, y }) => {
|
||||
el.style.transform = `translate(${Math.round(x)}px, ${Math.round(y)}px)`;
|
||||
});
|
||||
|
||||
const show = (chip: HTMLElement) => {
|
||||
const path = chip.dataset.refPath!;
|
||||
const rel = chip.dataset.refRel!;
|
||||
const mention = parseMention(
|
||||
`@${rel}${chip.dataset.refLines ? `:${chip.dataset.refLines.replace("–", "-")}` : ""}`,
|
||||
);
|
||||
if (!mention) return;
|
||||
const el = document.createElement("div");
|
||||
el.className = styles.card!;
|
||||
el.setAttribute("role", "tooltip");
|
||||
const head = document.createElement("div");
|
||||
head.className = styles.cardHead!;
|
||||
head.append(iconNode(iconOf(mention), 14));
|
||||
const name = document.createElement("span");
|
||||
name.className = styles.cardPath!;
|
||||
name.textContent = rel.replace(/\/$/, "");
|
||||
head.append(name);
|
||||
const meta = document.createElement("span");
|
||||
meta.className = styles.cardMeta!;
|
||||
const lines = mention.lines && linesLabel(mention.lines);
|
||||
meta.textContent = chip.classList.contains(styles.missing!)
|
||||
? tx("File not found")
|
||||
: lines
|
||||
? tx`Lines ${lines}`
|
||||
: mention.dir
|
||||
? tx("Folder")
|
||||
: "";
|
||||
head.append(meta);
|
||||
el.append(head);
|
||||
document.body.append(el);
|
||||
card = el;
|
||||
place(chip, el);
|
||||
if (mention.dir || chip.classList.contains(styles.missing!)) return;
|
||||
void preview(path, mention).then((code) => {
|
||||
if (card !== el || !code) return;
|
||||
el.append(code);
|
||||
place(chip, el);
|
||||
});
|
||||
};
|
||||
|
||||
const over = (e: PointerEvent) => {
|
||||
const chip = (e.target as Element | null)?.closest?.<HTMLElement>("[data-ref-path]") ?? null;
|
||||
if (chip === current) return;
|
||||
hide();
|
||||
if (!chip || !root.contains(chip)) return;
|
||||
current = chip;
|
||||
timer = setTimeout(() => show(chip), HOVER_DELAY);
|
||||
};
|
||||
|
||||
root.addEventListener("pointerover", over);
|
||||
root.addEventListener("pointerleave", hide);
|
||||
root.addEventListener("pointerdown", hide, true);
|
||||
root.addEventListener("keydown", hide, true);
|
||||
root.addEventListener("scroll", hide, true);
|
||||
return () => {
|
||||
hide();
|
||||
root.removeEventListener("pointerover", over);
|
||||
root.removeEventListener("pointerleave", hide);
|
||||
root.removeEventListener("pointerdown", hide, true);
|
||||
root.removeEventListener("keydown", hide, true);
|
||||
root.removeEventListener("scroll", hide, true);
|
||||
};
|
||||
}
|
||||
|
||||
async function preview(path: string, mention: Mention): Promise<HTMLElement | null> {
|
||||
try {
|
||||
const probe = await call(commands.fsProbe(path));
|
||||
if (probe.binary || probe.size > MAX_PREVIEW_BYTES) return null;
|
||||
const all = (await call(commands.fsReadText(path))).split("\n");
|
||||
const first = mention.lines ? Math.min(mention.lines.from, all.length) : 1;
|
||||
const last = Math.min(mention.lines ? mention.lines.to : PREVIEW_LINES, first + PREVIEW_LINES - 1, all.length);
|
||||
const lines = all.slice(first - 1, last).map((l) => l.replace(/\r$/, "").replace(/\t/g, " "));
|
||||
while (lines.length && !lines.at(-1)!.trim()) lines.pop();
|
||||
if (!lines.length) return null;
|
||||
const indent = Math.min(...lines.filter((l) => l.trim()).map((l) => /^ */.exec(l)![0].length));
|
||||
const text = lines.map((l) => l.slice(indent)).join("\n");
|
||||
const code = document.createElement("pre");
|
||||
code.className = styles.cardCode!;
|
||||
const gutter = document.createElement("span");
|
||||
gutter.className = styles.cardLine!;
|
||||
gutter.textContent = lines.map((_, i) => first + i).join("\n");
|
||||
const body = document.createElement("code");
|
||||
body.textContent = text;
|
||||
code.append(gutter, body);
|
||||
await highlightInto(body, text, languageForPath(path));
|
||||
return code;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function useReferenceHover(root: HTMLElement | null) {
|
||||
const t = useTx();
|
||||
useEffect(() => (root ? referenceHover(root) : undefined), [root, t]);
|
||||
}
|
||||
|
||||
const KIND_TITLES = { terminal: "Terminal", problems: "Problems", trace: "Stack Trace", text: "Pasted Text" } as const;
|
||||
const KIND_ICONS = { terminal: "terminal", problems: "problems", trace: "exception", text: "text" } as const;
|
||||
|
||||
/** A ``` block's header: icon and title ("Terminal · zsh", "TypeScript"). */
|
||||
function cardTitle(info: string): { icon: string; title: string } {
|
||||
const { kind, label, language } = fenceLabel(info);
|
||||
if (kind !== "code") {
|
||||
const title = tx(KIND_TITLES[kind]);
|
||||
return { icon: KIND_ICONS[kind], title: label ? `${title} · ${label}` : title };
|
||||
}
|
||||
const name = language && label === language ? languageForPath(`x.${language}`) : null;
|
||||
return { icon: language ? fileIconName(`x.${language}`) : "file", title: name ?? (label || tx("Code")) };
|
||||
}
|
||||
|
||||
const lineCount = (count: number, t: typeof tx) => (count === 1 ? t("1 line") : t`${count} lines`);
|
||||
|
||||
const COMPOSER_PREVIEW_LINES = 5;
|
||||
|
||||
/**
|
||||
* A ``` block in the composer, folded to a card: its title, line count and first lines.
|
||||
* `onOpen` edits it as text; `onRemove` deletes it.
|
||||
*/
|
||||
export function codeCardElement(info: string, body: string, onOpen: () => void, onRemove: () => void): HTMLElement {
|
||||
const { icon, title } = cardTitle(info);
|
||||
const lines = body.split("\n");
|
||||
const card = document.createElement("div");
|
||||
card.className = styles.codeCard!;
|
||||
card.setAttribute("role", "group");
|
||||
card.setAttribute("aria-label", title);
|
||||
const head = document.createElement("div");
|
||||
head.className = styles.codeHead!;
|
||||
head.append(iconNode(icon, 14));
|
||||
const name = document.createElement("span");
|
||||
name.className = styles.codeTitle!;
|
||||
name.textContent = title;
|
||||
const meta = document.createElement("span");
|
||||
meta.className = styles.codeMeta!;
|
||||
meta.textContent = lineCount(lines.length, tx);
|
||||
const remove = document.createElement("button");
|
||||
remove.type = "button";
|
||||
remove.className = styles.codeRemove!;
|
||||
remove.setAttribute("aria-label", tx("Remove"));
|
||||
remove.append(iconNode("close", 10));
|
||||
remove.addEventListener("mousedown", (e) => e.preventDefault());
|
||||
remove.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
onRemove();
|
||||
});
|
||||
head.append(name, meta, remove);
|
||||
const pre = document.createElement("pre");
|
||||
pre.className = clsx(styles.codeBody, lines.length > COMPOSER_PREVIEW_LINES && styles.codeFaded);
|
||||
const shown = lines.slice(0, COMPOSER_PREVIEW_LINES).join("\n");
|
||||
pre.textContent = shown;
|
||||
const { language, kind } = fenceLabel(info);
|
||||
if (kind === "code") void highlightInto(pre, shown, language);
|
||||
card.append(head, pre);
|
||||
card.title = tx("Click to edit");
|
||||
card.addEventListener("mousedown", (e) => e.preventDefault());
|
||||
card.addEventListener("click", onOpen);
|
||||
return card;
|
||||
}
|
||||
|
||||
const MESSAGE_PREVIEW_LINES = 12;
|
||||
|
||||
/** A ``` block in a sent message: a card with its title, the code highlighted, long ones folded. */
|
||||
function CodeCard({ info, body }: { info: string; body: string }) {
|
||||
const t = useTx();
|
||||
const { icon, title } = cardTitle(info);
|
||||
const lines = body.split("\n");
|
||||
const [open, setOpen] = useState(lines.length <= MESSAGE_PREVIEW_LINES);
|
||||
const code = useRef<HTMLPreElement>(null);
|
||||
const shown = open ? body : lines.slice(0, MESSAGE_PREVIEW_LINES).join("\n");
|
||||
useEffect(() => {
|
||||
const el = code.current;
|
||||
if (!el) return;
|
||||
el.textContent = shown;
|
||||
const { language, kind } = fenceLabel(info);
|
||||
if (kind === "code") void highlightInto(el, shown, language);
|
||||
}, [shown, info]);
|
||||
const more = lines.length - MESSAGE_PREVIEW_LINES;
|
||||
return (
|
||||
<div className={styles.codeCard} role="group" aria-label={title}>
|
||||
<div className={styles.codeHead}>
|
||||
<Icon name={icon} size={14} />
|
||||
<span className={styles.codeTitle}>{title}</span>
|
||||
<span className={styles.codeMeta}>{lineCount(lines.length, t)}</span>
|
||||
<button type="button" className={styles.codeCopy} aria-label={t("Copy")} onClick={() => void copyText(body)}>
|
||||
<Icon name="copy" size={12} />
|
||||
</button>
|
||||
</div>
|
||||
<pre ref={code} className={clsx(styles.codeBody, !open && styles.codeFaded)} />
|
||||
{more > 0 && (
|
||||
<button type="button" className={styles.codeMore} onClick={() => setOpen(!open)}>
|
||||
{open ? t("Show less") : t`Show ${more} more lines`}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -50,13 +50,11 @@
|
||||
color: var(--fg-subtle);
|
||||
font-size: var(--fs-small);
|
||||
}
|
||||
/* The facts on the bar's line are as tall as its chips, so their text shares one centre line. */
|
||||
.agent {
|
||||
display: inline-flex;
|
||||
flex: 0 1 auto;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
height: 22px;
|
||||
min-width: 0;
|
||||
color: var(--fg-secondary);
|
||||
}
|
||||
@@ -71,8 +69,7 @@
|
||||
display: inline-flex;
|
||||
flex: 0 1 auto;
|
||||
align-items: center;
|
||||
/* The chips' own padding spaces them. */
|
||||
gap: 2px;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
@@ -81,10 +78,7 @@
|
||||
color: var(--fg-subtle);
|
||||
}
|
||||
.state {
|
||||
display: inline-flex;
|
||||
flex: none;
|
||||
align-items: center;
|
||||
height: 22px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
/* The changes sit above the meter, time and terminal; those keep their one row at the right. */
|
||||
@@ -102,7 +96,6 @@
|
||||
display: inline-flex;
|
||||
flex: 0 1 auto;
|
||||
align-items: center;
|
||||
height: 22px;
|
||||
gap: 8px;
|
||||
max-width: 100%;
|
||||
min-width: 0;
|
||||
@@ -141,8 +134,8 @@
|
||||
}
|
||||
.statusChip:hover,
|
||||
.statusChip[aria-expanded="true"] {
|
||||
background: var(--bg-toolbar-hover);
|
||||
color: var(--fg-strong);
|
||||
background: var(--bg-hover);
|
||||
color: var(--fg);
|
||||
}
|
||||
/* In a narrow docked session the chip is narrower than its text: the label ends in an ellipsis. */
|
||||
.statusChipLabel {
|
||||
@@ -317,14 +310,6 @@
|
||||
--session-card-width: 96%;
|
||||
}
|
||||
}
|
||||
/* A narrow docked session: rather than cut every fact to a letter or two, the bar keeps the
|
||||
agent's icon and its state; where it works is in the task's tooltip and Changes. */
|
||||
@container (max-width: 420px) {
|
||||
.where,
|
||||
.agentName {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
.messageActions {
|
||||
display: inline-flex;
|
||||
gap: 4px;
|
||||
|
||||
@@ -43,6 +43,7 @@ import {
|
||||
Icon,
|
||||
IconButton,
|
||||
Input,
|
||||
InlineTag,
|
||||
Radio,
|
||||
SearchField,
|
||||
Spinner,
|
||||
@@ -63,7 +64,6 @@ import { commitFileDiffData, openDiff, withChain, type DiffData } from "@/featur
|
||||
import { repositoryPath } from "@/features/vcs/repositoryPaths";
|
||||
import { setChangesOptions, useChangesOptions } from "./changesOptions";
|
||||
import { Composer } from "./Composer";
|
||||
import { ReferencedText } from "./ReferenceChip";
|
||||
import { selectionIn } from "./selection";
|
||||
import { SubmitButton } from "./SubmitButton";
|
||||
import {
|
||||
@@ -72,7 +72,6 @@ import {
|
||||
emptyDraft,
|
||||
limitLabel,
|
||||
messageTokens,
|
||||
parseMention,
|
||||
retryParts,
|
||||
usageLabel,
|
||||
workTime,
|
||||
@@ -352,7 +351,7 @@ function SessionStatus({
|
||||
showContextMenu(e, taskMenu([task]));
|
||||
}}
|
||||
>
|
||||
<span className={styles.agent} title={`${providerName(task.provider)} · ${task.cwd}`}>
|
||||
<span className={styles.agent} title={task.cwd}>
|
||||
<Icon name={providerIcon(task.provider)} size={13} className={styles.providerIcon} />
|
||||
<span className={styles.agentName}>
|
||||
{providerName(task.provider)}
|
||||
@@ -921,10 +920,7 @@ function Stream({ loadError, task, entries, subagent, onOpenSubagent, finding, f
|
||||
const atEnd = el.scrollHeight - el.scrollTop - el.clientHeight < 40;
|
||||
const laidOut =
|
||||
el.scrollHeight !== seenHeight.current.content || el.clientHeight !== seenHeight.current.view;
|
||||
seenHeight.current = {
|
||||
content: el.scrollHeight,
|
||||
view: el.clientHeight,
|
||||
};
|
||||
seenHeight.current = { content: el.scrollHeight, view: el.clientHeight };
|
||||
const byUser = dragging.current || performance.now() - userScrolled.current < 500 || !laidOut;
|
||||
const movedDown = el.scrollTop > position.current.top;
|
||||
const stayedAtEnd = position.current.stick && el.scrollTop === position.current.top;
|
||||
@@ -1013,6 +1009,23 @@ function Stream({ loadError, task, entries, subagent, onOpenSubagent, finding, f
|
||||
|
||||
type UserEntry = Extract<AgentEntry, { type: "user" }>;
|
||||
|
||||
/** Message text with file references and skills as tags, distinct from prose. */
|
||||
function MessageText({ text }: { text: string }) {
|
||||
return (
|
||||
<>
|
||||
{messageTokens(text).map((token, i) =>
|
||||
token.kind === "text" ? (
|
||||
<Fragment key={i}>{token.value}</Fragment>
|
||||
) : (
|
||||
<InlineTag key={i} accent={token.kind === "command"}>
|
||||
{token.value}
|
||||
</InlineTag>
|
||||
),
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/** When a message was sent, or when the turn behind a reply finished, and how long it took. */
|
||||
function MessageTime({
|
||||
sentMs,
|
||||
@@ -1417,7 +1430,7 @@ const Entry = memo(function Entry({
|
||||
<T>Task from the parent agent</T>
|
||||
</div>
|
||||
<div className={styles.user}>
|
||||
<ReferencedText text={entry.text} cwd={task.cwd} />
|
||||
<MessageText text={entry.text} />
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
@@ -1481,14 +1494,7 @@ function UserMessage({
|
||||
}
|
||||
const images = entry.images ?? [];
|
||||
// Images are listed after the files in `attachments` and shown as thumbnails instead.
|
||||
// Files mentioned in the text are chips there already.
|
||||
const mentioned = new Set(
|
||||
messageTokens(entry.text).flatMap((t) => {
|
||||
const m = t.kind === "file" ? parseMention(t.value) : null;
|
||||
return m ? [basename(m.rel.replace(/\/$/, ""))] : [];
|
||||
}),
|
||||
);
|
||||
const files = entry.attachments.slice(0, entry.attachments.length - images.length).filter((a) => !mentioned.has(a));
|
||||
const files = entry.attachments.slice(0, entry.attachments.length - images.length);
|
||||
return (
|
||||
<div className={clsx(styles.message, styles.userMessage)}>
|
||||
{/* Actions and the time sit beside the author on hover: the reply under it has the turn's duration. */}
|
||||
@@ -1535,7 +1541,7 @@ function UserMessage({
|
||||
</span>
|
||||
</div>
|
||||
<div className={styles.user}>
|
||||
<ReferencedText text={entry.text} cwd={task.cwd} />
|
||||
<MessageText text={entry.text} />
|
||||
<ImageStrip images={images} />
|
||||
{files.length > 0 && (
|
||||
<div className={styles.attachments}>
|
||||
@@ -2667,7 +2673,7 @@ function PendingPermission({ task, entry }: { task: AgentTask; entry: Permission
|
||||
if (!el?.contains(document.activeElement) && document.activeElement !== document.body) return;
|
||||
requestAnimationFrame(() => {
|
||||
if (document.activeElement === document.body || !document.activeElement?.isConnected)
|
||||
area?.querySelector<HTMLElement>(".cm-content")?.focus({ preventScroll: true });
|
||||
area?.querySelector<HTMLElement>("textarea")?.focus({ preventScroll: true });
|
||||
});
|
||||
};
|
||||
}, []);
|
||||
@@ -2946,12 +2952,7 @@ function changeRows(
|
||||
commits: readonly AgentCommit[],
|
||||
grouped: boolean,
|
||||
): ChangeRow[] {
|
||||
if (!grouped)
|
||||
return uncommitted.map((change) => ({
|
||||
key: change.path,
|
||||
type: "file" as const,
|
||||
change,
|
||||
}));
|
||||
if (!grouped) return uncommitted.map((change) => ({ key: change.path, type: "file" as const, change }));
|
||||
const roots: ChangeRow[] = [];
|
||||
if (uncommitted.length) {
|
||||
roots.push({
|
||||
@@ -2960,11 +2961,7 @@ function changeRows(
|
||||
label: "Uncommitted",
|
||||
count: uncommitted.length,
|
||||
...statsOf(uncommitted),
|
||||
children: uncommitted.map((change) => ({
|
||||
key: `uncommitted:${change.path}`,
|
||||
type: "file",
|
||||
change,
|
||||
})),
|
||||
children: uncommitted.map((change) => ({ key: `uncommitted:${change.path}`, type: "file", change })),
|
||||
});
|
||||
}
|
||||
if (commits.length) {
|
||||
@@ -3009,10 +3006,7 @@ function ChangesPane({
|
||||
onHide: () => void;
|
||||
}) {
|
||||
// The two views key their rows differently: a selection doesn't carry over.
|
||||
const [selection, setSelection] = useState<{
|
||||
grouped: boolean;
|
||||
keys: ReadonlySet<string>;
|
||||
}>(() => ({
|
||||
const [selection, setSelection] = useState<{ grouped: boolean; keys: ReadonlySet<string> }>(() => ({
|
||||
grouped,
|
||||
keys: NO_SELECTION,
|
||||
}));
|
||||
@@ -3116,10 +3110,7 @@ function ChangesPane({
|
||||
body: tx`You changed them yourself, accept them by hand: ${skippedNames}.`,
|
||||
});
|
||||
} else {
|
||||
notifications().notify({
|
||||
level: "info",
|
||||
title: tx("Changes accepted"),
|
||||
});
|
||||
notifications().notify({ level: "info", title: tx("Changes accepted") });
|
||||
}
|
||||
} catch (e) {
|
||||
notifications().notify({
|
||||
@@ -3316,10 +3307,7 @@ function uncommittedDiffData(task: AgentTask, change: AgentChange): DiffData {
|
||||
const right: DiffData["right"] =
|
||||
change.kind === "deleted"
|
||||
? { type: "text", text: "", label: "(deleted)", absent: true }
|
||||
: {
|
||||
type: "worktree",
|
||||
label: task.isolation === "worktree" ? branchLabel(task.branch) : "Working tree",
|
||||
};
|
||||
: { type: "worktree", label: task.isolation === "worktree" ? branchLabel(task.branch) : "Working tree" };
|
||||
return { path: change.path, left, right };
|
||||
}
|
||||
|
||||
@@ -3348,12 +3336,7 @@ async function openUncommittedDiff(task: AgentTask, path: string) {
|
||||
async function openCommitDiff(task: AgentTask, commit: AgentCommit, path: string) {
|
||||
const diffs = commit.files.map((file) =>
|
||||
commitFileDiffData(
|
||||
{
|
||||
id: commit.id,
|
||||
shortId: commit.shortId,
|
||||
parents: commit.parent ? [commit.parent] : [],
|
||||
repo: task.cwd,
|
||||
},
|
||||
{ id: commit.id, shortId: commit.shortId, parents: commit.parent ? [commit.parent] : [], repo: task.cwd },
|
||||
{ path: file.relPath, kind: file.kind, oldPath: file.oldPath },
|
||||
),
|
||||
);
|
||||
|
||||
@@ -19,13 +19,6 @@ import {
|
||||
recall,
|
||||
toPrompt,
|
||||
mergeMatches,
|
||||
messageTokens,
|
||||
fenced,
|
||||
fencedBlocks,
|
||||
fenceLabel,
|
||||
pastedPaths,
|
||||
looksLikeTrace,
|
||||
placesInText,
|
||||
retryParts,
|
||||
usageLabel,
|
||||
limitLabel,
|
||||
@@ -79,53 +72,6 @@ const option = (category: string, values: string[], current: string | null): Age
|
||||
via: "option",
|
||||
});
|
||||
|
||||
describe("fenced blocks", () => {
|
||||
it("finds closed blocks, a longer fence holding backticks", () => {
|
||||
const text = fenced("a ``` b\n```", "terminal zsh");
|
||||
expect(text).toBe("````terminal zsh\na ``` b\n```\n````");
|
||||
expect(fencedBlocks(["x", ...text.split("\n"), "```ts", "open"])).toEqual([
|
||||
{ open: 1, close: 4, info: "terminal zsh" },
|
||||
]);
|
||||
expect(fenceLabel("terminal zsh")).toEqual({ kind: "terminal", label: "zsh", language: null });
|
||||
expect(fenceLabel("trace")).toEqual({ kind: "trace", label: "", language: null });
|
||||
expect(fenceLabel("ts")).toEqual({ kind: "code", label: "ts", language: "ts" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("pasted traces", () => {
|
||||
const node =
|
||||
"TypeError: x is undefined\n at fetchUser (/p/src/api/client.ts:12:5)\n at node_modules/lib/x.js:3:1";
|
||||
const python =
|
||||
'Traceback (most recent call last):\n File "/p/app/main.py", line 7, in <module>\n run()\nValueError: bad';
|
||||
const rust = "thread 'main' panicked at src/main.rs:4:5:\nboom";
|
||||
it("recognises stack traces, not prose", () => {
|
||||
expect([node, python, rust].map(looksLikeTrace)).toEqual([true, true, true]);
|
||||
expect(looksLikeTrace("see src/a.ts:3\nand more")).toBe(false);
|
||||
expect(looksLikeTrace("Error: one line")).toBe(false);
|
||||
});
|
||||
it("finds the places a trace names, leaving dependencies out", () => {
|
||||
expect(placesInText(node)).toEqual([{ path: "/p/src/api/client.ts", line: 12 }]);
|
||||
expect(placesInText(python)).toEqual([{ path: "/p/app/main.py", line: 7 }]);
|
||||
expect(placesInText(rust)).toEqual([{ path: "src/main.rs", line: 4 }]);
|
||||
expect(placesInText("a.ts:1 a.ts:1 b.ts:2", 1)).toEqual([{ path: "a.ts", line: 1 }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("pastedPaths", () => {
|
||||
it("reads absolute paths with lines, quotes and file URLs, one per line", () => {
|
||||
expect(pastedPaths("/a/b.ts:10-20\n '/a/c d.ts' \nfile:///a/%E4%B8%AD.ts\n/a/dir/\n/a/e.ts:3:7")).toEqual([
|
||||
{ path: "/a/b.ts", lines: { from: 10, to: 20 } },
|
||||
{ path: "/a/c d.ts" },
|
||||
{ path: "/a/中.ts" },
|
||||
{ path: "/a/dir" },
|
||||
{ path: "/a/e.ts", lines: { from: 3, to: 3 } },
|
||||
]);
|
||||
expect(pastedPaths("C:\\work\\a.ts:4")).toEqual([{ path: "C:\\work\\a.ts", lines: { from: 4, to: 4 } }]);
|
||||
expect(pastedPaths("/a/b.ts\nnot a path")).toBeNull();
|
||||
expect(pastedPaths("see /a/b.ts")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("mergeMatches", () => {
|
||||
it("ranks folders among files by score, files first on a tie", () => {
|
||||
const merged = mergeMatches(
|
||||
@@ -140,38 +86,14 @@ describe("mergeMatches", () => {
|
||||
3,
|
||||
);
|
||||
expect(merged).toEqual([
|
||||
{ path: "/p/docs", rel: "docs", dir: true, score: 70, indices: [] },
|
||||
{
|
||||
path: "/p/src/agents.ts",
|
||||
rel: "src/agents.ts",
|
||||
dir: false,
|
||||
score: 50,
|
||||
indices: [],
|
||||
},
|
||||
{
|
||||
path: "/p/src/agents",
|
||||
rel: "src/agents",
|
||||
dir: true,
|
||||
score: 50,
|
||||
indices: [],
|
||||
},
|
||||
{ path: "/p/docs", rel: "docs", dir: true, score: 70 },
|
||||
{ path: "/p/src/agents.ts", rel: "src/agents.ts", dir: false, score: 50 },
|
||||
{ path: "/p/src/agents", rel: "src/agents", dir: true, score: 50 },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("agent composer", () => {
|
||||
it("leaves punctuation after a reference or command to the text", () => {
|
||||
expect(messageTokens("in @src/a.ts:3-8, keep @src/retry/。then /review.")).toEqual([
|
||||
{ kind: "text", value: "in " },
|
||||
{ kind: "file", value: "@src/a.ts:3-8" },
|
||||
{ kind: "text", value: ", keep " },
|
||||
{ kind: "file", value: "@src/retry/" },
|
||||
{ kind: "text", value: "。then " },
|
||||
{ kind: "command", value: "/review" },
|
||||
{ kind: "text", value: "." },
|
||||
]);
|
||||
});
|
||||
|
||||
it("completes @files and /commands anywhere at a word boundary", () => {
|
||||
expect(completionAt("fix @src/le", 11)).toEqual({
|
||||
kind: "file",
|
||||
@@ -264,11 +186,7 @@ describe("agent composer", () => {
|
||||
expect(toPrompt({ text: "explain @src/", files: [folder], images: [] }).files).toEqual(["/w/src"]);
|
||||
expect(toPrompt({ text: "explain @src/a.rs", files: [folder], images: [] }).files).toEqual([]);
|
||||
expect(canSend({ text: " ", files: [], images: [] })).toBe(false);
|
||||
const video = {
|
||||
path: "/home/me/clip.mov",
|
||||
rel: "clip.mov",
|
||||
attached: true,
|
||||
};
|
||||
const video = { path: "/home/me/clip.mov", rel: "clip.mov", attached: true };
|
||||
expect(toPrompt({ text: "what happens here?", files: [video], images: [] }).files).toEqual(["/home/me/clip.mov"]);
|
||||
expect(canSend({ text: " ", files: [video], images: [] })).toBe(true);
|
||||
});
|
||||
@@ -569,23 +487,16 @@ describe("new task defaults", () => {
|
||||
description: null,
|
||||
category: id,
|
||||
current,
|
||||
values: values.map((value) => ({
|
||||
value,
|
||||
name: value,
|
||||
description: null,
|
||||
})),
|
||||
values: values.map((value) => ({ value, name: value, description: null })),
|
||||
via: "option",
|
||||
}) as AgentConfigOption;
|
||||
const provider = {
|
||||
id: "claude",
|
||||
config: [option("model", "sonnet", ["sonnet", "opus"]), option("mode", "default", ["default", "plan"])],
|
||||
} as unknown as Parameters<typeof draftConfig>[0];
|
||||
const draft = {
|
||||
provider: "claude",
|
||||
config: {},
|
||||
isolation: "local",
|
||||
base: null,
|
||||
} as unknown as Parameters<typeof draftConfig>[1];
|
||||
const draft = { provider: "claude", config: {}, isolation: "local", base: null } as unknown as Parameters<
|
||||
typeof draftConfig
|
||||
>[1];
|
||||
const current = (d: typeof draft, defaults: Record<string, string>) =>
|
||||
draftConfig(provider, d, defaults).map((o) => o.current);
|
||||
expect(current(draft, { model: "opus", mode: "nope" })).toEqual(["opus", "default"]);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { sendKeyAction } from "./Composer";
|
||||
import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
@@ -25,13 +25,7 @@ vi.mock("@/platform/ipc", async (importOriginal) => {
|
||||
},
|
||||
};
|
||||
});
|
||||
type DragDrop = {
|
||||
payload: {
|
||||
type: string;
|
||||
paths?: string[];
|
||||
position?: { x: number; y: number };
|
||||
};
|
||||
};
|
||||
type DragDrop = { payload: { type: string; paths?: string[]; position?: { x: number; y: number } } };
|
||||
const dragDrop = new Set<(event: DragDrop) => void>();
|
||||
vi.mock("@tauri-apps/api/webview", () => ({
|
||||
getCurrentWebview: () => ({
|
||||
@@ -49,11 +43,12 @@ vi.mock("@/platform/tauriCore", async (importOriginal) => ({
|
||||
|
||||
import { isTauri } from "@/platform/ipc";
|
||||
import { useProject } from "@/platform/project";
|
||||
import { usePlatform } from "@/platform/platformInfo";
|
||||
import type { ProjectInfo } from "@/bindings";
|
||||
import { defaultTaskProject } from "./project";
|
||||
import { HomeEditor } from "./HomeEditor";
|
||||
import chipStyles from "./ReferenceChip.module.css";
|
||||
import { textOf, typeIn } from "./composerTesting";
|
||||
import composerStyles from "./Composer.module.css";
|
||||
import tagStyles from "@/ui/InlineTag.module.css";
|
||||
import { composerDraft, HOME, updateComposer, useAgents } from "./store";
|
||||
import { emptyDraft } from "./composerModel";
|
||||
|
||||
@@ -87,21 +82,14 @@ beforeEach(() => {
|
||||
vi.mocked(isTauri).mockReturnValue(true);
|
||||
useProject.setState({ project: null });
|
||||
vcsBranches.mockReset().mockResolvedValue({ status: "ok", data: [] });
|
||||
vcsBranchInfo.mockReset().mockImplementation((root: string | null) =>
|
||||
Promise.resolve({
|
||||
status: "ok",
|
||||
data: { head: root === "/project-b" ? "b-main" : "a-main" },
|
||||
}),
|
||||
);
|
||||
vcsBranchInfo
|
||||
.mockReset()
|
||||
.mockImplementation((root: string | null) =>
|
||||
Promise.resolve({ status: "ok", data: { head: root === "/project-b" ? "b-main" : "a-main" } }),
|
||||
);
|
||||
agentCommands.mockReset();
|
||||
clipboardImage.mockReset().mockResolvedValue({
|
||||
status: "ok",
|
||||
data: "data:image/png;base64,iVBORw0KGgo=",
|
||||
});
|
||||
agentCreate.mockReset().mockResolvedValue({
|
||||
status: "error",
|
||||
error: { kind: "invalid", message: "stop here" },
|
||||
});
|
||||
clipboardImage.mockReset().mockResolvedValue({ status: "ok", data: "data:image/png;base64,iVBORw0KGgo=" });
|
||||
agentCreate.mockReset().mockResolvedValue({ status: "error", error: { kind: "invalid", message: "stop here" } });
|
||||
useAgents.setState({
|
||||
providers: [claude],
|
||||
draft: { provider: "claude", config: {}, isolation: "local", base: null },
|
||||
@@ -118,11 +106,11 @@ describe("New Task composer commands", () => {
|
||||
render(<HomeEditor />);
|
||||
await waitFor(() => expect(agentCommands).toHaveBeenCalled());
|
||||
const box = screen.getByRole("textbox");
|
||||
typeIn(box, "/");
|
||||
fireEvent.change(box, { target: { value: "/" } });
|
||||
await screen.findByRole("listbox", { name: "Commands" });
|
||||
for (let i = 0; i < 29; i++) fireEvent.keyDown(box, { key: "ArrowDown" });
|
||||
fireEvent.keyDown(box, { key: "Enter" });
|
||||
expect(textOf(box)).toBe("/command-29 ");
|
||||
expect(box).toHaveValue("/command-29 ");
|
||||
});
|
||||
|
||||
it("groups commands before skills, skips headers, wraps and hides empty groups", async () => {
|
||||
@@ -133,7 +121,7 @@ describe("New Task composer commands", () => {
|
||||
render(<HomeEditor />);
|
||||
await waitFor(() => expect(agentCommands).toHaveBeenCalled());
|
||||
const box = screen.getByRole("textbox");
|
||||
typeIn(box, "/");
|
||||
fireEvent.change(box, { target: { value: "/" } });
|
||||
const list = await screen.findByRole("listbox", { name: "Commands" });
|
||||
expect(
|
||||
within(list)
|
||||
@@ -149,11 +137,11 @@ describe("New Task composer commands", () => {
|
||||
expect(within(list).getByRole("option", { selected: true })).toHaveTextContent("/review");
|
||||
fireEvent.keyDown(box, { key: "ArrowDown" });
|
||||
expect(within(list).getByRole("option", { selected: true })).toHaveTextContent("/compact");
|
||||
typeIn(box, "/tri");
|
||||
fireEvent.change(box, { target: { value: "/tri" } });
|
||||
expect(list.textContent).toBe("Skills/triage");
|
||||
fireEvent.keyDown(box, { key: "Tab" });
|
||||
expect(textOf(box)).toBe("/triage ");
|
||||
typeIn(box, "/com");
|
||||
expect(box).toHaveValue("/triage ");
|
||||
fireEvent.change(box, { target: { value: "/com" } });
|
||||
expect((await screen.findByRole("listbox", { name: "Commands" })).textContent).toBe("Commands/compact");
|
||||
});
|
||||
|
||||
@@ -166,7 +154,7 @@ describe("New Task composer commands", () => {
|
||||
expect(start.textContent).toBe("");
|
||||
expect(start).toHaveAttribute("data-tooltip", "Start");
|
||||
expect(start).toBeDisabled();
|
||||
typeIn(screen.getByRole("textbox"), "Do the task");
|
||||
fireEvent.change(screen.getByRole("textbox"), { target: { value: "Do the task" } });
|
||||
expect(start).toBeEnabled();
|
||||
fireEvent.click(start);
|
||||
const starting = screen.getByRole("button", { name: "Starting…" });
|
||||
@@ -175,12 +163,7 @@ describe("New Task composer commands", () => {
|
||||
expect(starting).toHaveAttribute("data-tooltip", "Starting…");
|
||||
fireEvent.keyDown(screen.getByRole("textbox"), { key: "Enter" });
|
||||
expect(agentCreate).toHaveBeenCalledTimes(1);
|
||||
await act(async () =>
|
||||
finish({
|
||||
status: "error",
|
||||
error: { kind: "invalid", message: "stop here" },
|
||||
}),
|
||||
);
|
||||
await act(async () => finish({ status: "error", error: { kind: "invalid", message: "stop here" } }));
|
||||
expect(screen.getByRole("button", { name: "Start" })).toBeEnabled();
|
||||
});
|
||||
|
||||
@@ -196,7 +179,7 @@ describe("New Task composer commands", () => {
|
||||
render(<HomeEditor />);
|
||||
await waitFor(() => expect(agentCommands).toHaveBeenCalledWith("claude", null));
|
||||
const box = screen.getByRole("textbox");
|
||||
typeIn(box, "/trig");
|
||||
fireEvent.change(box, { target: { value: "/trig" } });
|
||||
const list = await screen.findByRole("listbox", { name: "Commands" });
|
||||
const options = within(list).getAllByRole("option");
|
||||
expect(options.map((o) => o.textContent)).toEqual(["/triageOne pass over open issues and PRs"]);
|
||||
@@ -205,83 +188,95 @@ describe("New Task composer commands", () => {
|
||||
expect(useAgents.getState().composers.get(HOME)?.text).toBe("/triage ");
|
||||
|
||||
// Sent as typed, so the agent runs the skill.
|
||||
typeIn(box, "/triage dry-run");
|
||||
fireEvent.change(box, { target: { value: "/triage dry-run" } });
|
||||
fireEvent.keyDown(box, { key: "Enter" });
|
||||
await waitFor(() => expect(agentCreate).toHaveBeenCalled());
|
||||
expect(agentCreate.mock.calls[0]![0]).toMatchObject({
|
||||
prompt: { text: "/triage dry-run" },
|
||||
provider: "claude",
|
||||
});
|
||||
expect(agentCreate.mock.calls[0]![0]).toMatchObject({ prompt: { text: "/triage dry-run" }, provider: "claude" });
|
||||
});
|
||||
|
||||
it("tags file references and skills where they stand", async () => {
|
||||
agentCommands.mockResolvedValue({
|
||||
status: "ok",
|
||||
data: [command("triage", "One pass over issues")],
|
||||
});
|
||||
agentCommands.mockResolvedValue({ status: "ok", data: [command("triage", "One pass over issues")] });
|
||||
render(<HomeEditor />);
|
||||
await waitFor(() => expect(agentCommands).toHaveBeenCalled());
|
||||
const box = screen.getByRole("textbox");
|
||||
typeIn(box, "12 @Cargo.toml and /triage");
|
||||
expect(within(box).getByText("@Cargo.toml")).toHaveClass(chipStyles.pending!);
|
||||
expect(within(box).getByText("/triage")).toHaveClass(chipStyles.command!);
|
||||
fireEvent.change(screen.getByRole("textbox"), { target: { value: "12 @Cargo.toml and /triage" } });
|
||||
const mirror = document.querySelector(`.${composerStyles.mirror}`)!;
|
||||
expect(within(mirror as HTMLElement).getByText("@Cargo.toml")).toHaveClass(tagStyles.tag!);
|
||||
expect(within(mirror as HTMLElement).getByText("/triage")).toHaveClass(tagStyles.accent!);
|
||||
});
|
||||
|
||||
it("offers nothing the project does not have", async () => {
|
||||
agentCommands.mockResolvedValue({
|
||||
status: "ok",
|
||||
data: [command("compact", "Free up context")],
|
||||
});
|
||||
agentCommands.mockResolvedValue({ status: "ok", data: [command("compact", "Free up context")] });
|
||||
render(<HomeEditor />);
|
||||
await waitFor(() => expect(agentCommands).toHaveBeenCalled());
|
||||
const box = screen.getByRole("textbox");
|
||||
typeIn(box, "/trig");
|
||||
fireEvent.change(box, { target: { value: "/trig" } });
|
||||
expect(screen.queryByRole("listbox", { name: "Commands" })).toBeNull();
|
||||
typeIn(box, "/com");
|
||||
fireEvent.change(box, { target: { value: "/com" } });
|
||||
expect(await screen.findByRole("option")).toHaveTextContent("/compact");
|
||||
});
|
||||
|
||||
it("completes at the caret without replacing the rest of the message", async () => {
|
||||
const user = userEvent.setup();
|
||||
agentCommands.mockResolvedValue({
|
||||
status: "ok",
|
||||
data: [command("compact", "Free up context")],
|
||||
});
|
||||
agentCommands.mockResolvedValue({ status: "ok", data: [command("compact", "Free up context")] });
|
||||
render(<HomeEditor />);
|
||||
await waitFor(() => expect(agentCommands).toHaveBeenCalled());
|
||||
const box = screen.getByRole("textbox");
|
||||
box.focus();
|
||||
typeIn(box, "/com keep this context", 4);
|
||||
fireEvent.change(box, { target: { value: "/com keep this context", selectionStart: 4, selectionEnd: 4 } });
|
||||
expect(await screen.findByRole("option")).toHaveTextContent("/compact");
|
||||
expect(box).toHaveFocus();
|
||||
|
||||
await user.keyboard("{Tab}");
|
||||
expect(textOf(box)).toBe("/compact keep this context");
|
||||
expect(box).toHaveValue("/compact keep this context");
|
||||
await waitFor(() => expect(screen.queryByRole("listbox", { name: "Commands" })).toBeNull());
|
||||
expect(agentCreate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("completes a command typed after other text in place", async () => {
|
||||
agentCommands.mockResolvedValue({
|
||||
status: "ok",
|
||||
data: [command("triage", "One pass over open issues and PRs")],
|
||||
});
|
||||
agentCommands.mockResolvedValue({ status: "ok", data: [command("triage", "One pass over open issues and PRs")] });
|
||||
render(<HomeEditor />);
|
||||
await waitFor(() => expect(agentCommands).toHaveBeenCalled());
|
||||
const box = screen.getByRole("textbox");
|
||||
typeIn(box, "fix the failing build /tri");
|
||||
fireEvent.change(box, { target: { value: "fix the failing build /tri" } });
|
||||
await screen.findByRole("listbox", { name: "Commands" });
|
||||
fireEvent.keyDown(box, { key: "Enter" });
|
||||
expect(useAgents.getState().composers.get(HOME)?.text).toBe("fix the failing build /triage ");
|
||||
});
|
||||
});
|
||||
|
||||
describe("New Task composer Home / End", () => {
|
||||
const os = usePlatform.getState().os;
|
||||
afterEach(() => {
|
||||
usePlatform.setState({ os });
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("moves the caret to the line's start or end on macOS, extending the selection with Shift", () => {
|
||||
usePlatform.setState({ os: "macos" });
|
||||
const modify = vi.fn();
|
||||
vi.spyOn(window, "getSelection").mockReturnValue({ modify } as unknown as Selection);
|
||||
render(<HomeEditor />);
|
||||
const box = screen.getByRole("textbox");
|
||||
expect(fireEvent.keyDown(box, { key: "Home" })).toBe(false);
|
||||
fireEvent.keyDown(box, { key: "End", shiftKey: true });
|
||||
fireEvent.keyDown(box, { key: "End", metaKey: true });
|
||||
expect(modify.mock.calls).toEqual([
|
||||
["move", "backward", "lineboundary"],
|
||||
["extend", "forward", "lineboundary"],
|
||||
]);
|
||||
});
|
||||
|
||||
it("leaves Home / End to the text box elsewhere", () => {
|
||||
usePlatform.setState({ os: "windows" });
|
||||
render(<HomeEditor />);
|
||||
expect(fireEvent.keyDown(screen.getByRole("textbox"), { key: "Home" })).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("New Task composer paste", () => {
|
||||
it("attaches the clipboard's image when WebKitGTK pastes an empty clipboardData", async () => {
|
||||
render(<HomeEditor />);
|
||||
fireEvent.paste(screen.getByRole("textbox"), {
|
||||
clipboardData: { types: [], files: [] },
|
||||
});
|
||||
fireEvent.paste(screen.getByRole("textbox"), { clipboardData: { types: [], files: [] } });
|
||||
await waitFor(() =>
|
||||
expect(composerDraft(HOME).images).toEqual([{ mimeType: "image/png", data: "iVBORw0KGgo=", name: "image" }]),
|
||||
);
|
||||
@@ -289,9 +284,7 @@ describe("New Task composer paste", () => {
|
||||
|
||||
it("leaves a text paste to the text box", () => {
|
||||
render(<HomeEditor />);
|
||||
fireEvent.paste(screen.getByRole("textbox"), {
|
||||
clipboardData: { types: ["text/plain"], files: [] },
|
||||
});
|
||||
fireEvent.paste(screen.getByRole("textbox"), { clipboardData: { types: ["text/plain"], files: [] } });
|
||||
expect(clipboardImage).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -315,11 +308,7 @@ describe("New Task composer OS file drop", () => {
|
||||
expect(composerDraft(HOME)).toEqual({
|
||||
text: "",
|
||||
files: [
|
||||
{
|
||||
path: "/Users/me/Desktop/recording one.mov",
|
||||
rel: "recording one.mov",
|
||||
attached: true,
|
||||
},
|
||||
{ path: "/Users/me/Desktop/recording one.mov", rel: "recording one.mov", attached: true },
|
||||
{ path: "/Users/me/Desktop/huge.png", rel: "huge.png", attached: true },
|
||||
],
|
||||
images: [{ mimeType: "image/png", data: "iVBORw0KGgo=", name: "shot.png" }],
|
||||
@@ -338,11 +327,7 @@ describe("workspace task project", () => {
|
||||
name: "Workspace",
|
||||
trusted: true,
|
||||
isGit: false,
|
||||
workspace: {
|
||||
version: 1,
|
||||
name: "Workspace",
|
||||
projects: ["/project-a", "/project-b"],
|
||||
},
|
||||
workspace: { version: 1, name: "Workspace", projects: ["/project-a", "/project-b"] },
|
||||
} as ProjectInfo;
|
||||
it("defaults to the active member, last used member, then first member", () => {
|
||||
expect(defaultTaskProject(project, "/project-b/src/main.ts", "/project-a")).toBe("/project-b");
|
||||
@@ -354,13 +339,7 @@ describe("workspace task project", () => {
|
||||
vi.mocked(isTauri).mockReturnValue(false);
|
||||
useProject.setState({ project });
|
||||
agentCommands.mockResolvedValue({ status: "ok", data: [] });
|
||||
useAgents.setState({
|
||||
draft: {
|
||||
...useAgents.getState().draft,
|
||||
projectRoot: "/project-b",
|
||||
isolation,
|
||||
},
|
||||
});
|
||||
useAgents.setState({ draft: { ...useAgents.getState().draft, projectRoot: "/project-b", isolation } });
|
||||
render(<HomeEditor />);
|
||||
const selector = screen.getByRole("button", { name: "Project" });
|
||||
expect(selector).toHaveTextContent("project-b");
|
||||
@@ -369,14 +348,11 @@ describe("workspace task project", () => {
|
||||
fireEvent.click(selector);
|
||||
expect(within(screen.getByRole("listbox")).getByText("/project-a")).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole("option", { name: /project-a/ }));
|
||||
typeIn(screen.getByRole("textbox"), "Update member");
|
||||
fireEvent.change(screen.getByRole("textbox"), { target: { value: "Update member" } });
|
||||
await waitFor(() => expect(screen.getByRole("button", { name: "Start" })).toBeEnabled());
|
||||
fireEvent.keyDown(screen.getByRole("textbox"), { key: "Enter" });
|
||||
await waitFor(() => expect(agentCreate).toHaveBeenCalled());
|
||||
expect(agentCreate.mock.calls[0]![0]).toMatchObject({
|
||||
projectRoot: "/project-a",
|
||||
isolation,
|
||||
});
|
||||
expect(agentCreate.mock.calls[0]![0]).toMatchObject({ projectRoot: "/project-a", isolation });
|
||||
expect(vcsBranchInfo).toHaveBeenLastCalledWith("/project-a");
|
||||
expect(vcsBranches).toHaveBeenLastCalledWith("/project-a");
|
||||
await vi.dynamicImportSettled();
|
||||
|
||||
@@ -1,245 +0,0 @@
|
||||
/**
|
||||
* The composer's CodeMirror extensions. The text stays plain (`@src/a.rs:10-20`, `/triage`,
|
||||
* `> quote`) and is what's stored and sent; references to files of the draft are drawn as
|
||||
* chips over their text, commands tagged, and quote lines as a block without their `>`.
|
||||
*
|
||||
* A chip moves and deletes as one piece. Backspace right after one turns it back into its
|
||||
* text to edit (the completion opens on it); it folds again once the caret leaves it.
|
||||
*
|
||||
* A ``` block (pasted code or terminal output) is a card while the caret is outside it; a click
|
||||
* on the card, or the caret at its end, opens it as text.
|
||||
*/
|
||||
import { EditorState, StateEffect, StateField, type Extension, type Range, type Text } from "@codemirror/state";
|
||||
import { Decoration, EditorView, WidgetType, keymap, type DecorationSet } from "@codemirror/view";
|
||||
import { fencedBlocks, isQuoteLine, messageTokens, parseMention, type FileRef, type Mention } from "./composerModel";
|
||||
import { chipElement, chipStyles, codeCardElement, openReference } from "./ReferenceChip";
|
||||
|
||||
/** The draft's referenced files: mentions of these are chips. */
|
||||
export const setReferences = StateEffect.define<readonly FileRef[]>();
|
||||
const references = StateField.define<readonly FileRef[]>({
|
||||
create: () => [],
|
||||
update(value, tr) {
|
||||
for (const e of tr.effects) if (e.is(setReferences)) return e.value;
|
||||
return value;
|
||||
},
|
||||
});
|
||||
|
||||
interface Chip {
|
||||
from: number;
|
||||
to: number;
|
||||
path: string;
|
||||
mention: Mention;
|
||||
}
|
||||
|
||||
/** `@` and `/` tokens of the line around `pos`, in document positions. */
|
||||
function lineTokens(doc: Text, pos: number) {
|
||||
const line = doc.lineAt(pos);
|
||||
const out: {
|
||||
kind: "file" | "command";
|
||||
from: number;
|
||||
to: number;
|
||||
value: string;
|
||||
}[] = [];
|
||||
let offset = line.from;
|
||||
for (const token of messageTokens(line.text)) {
|
||||
if (token.kind !== "text")
|
||||
out.push({
|
||||
kind: token.kind,
|
||||
from: offset,
|
||||
to: offset + token.value.length,
|
||||
value: token.value,
|
||||
});
|
||||
offset += token.value.length;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const unfold = StateEffect.define<number>();
|
||||
/** Start of the chip being edited as text, while the caret stays in it. */
|
||||
const unfolded = StateField.define<number | null>({
|
||||
create: () => null,
|
||||
update(value, tr) {
|
||||
for (const e of tr.effects) if (e.is(unfold)) return e.value;
|
||||
if (value === null) return null;
|
||||
const start = tr.changes.mapPos(value, 1);
|
||||
const head = tr.state.selection.main.head;
|
||||
const token = lineTokens(tr.state.doc, start).find((t) => t.from === start && t.kind === "file");
|
||||
return token && head > token.from && head <= token.to ? start : null;
|
||||
},
|
||||
});
|
||||
|
||||
class ChipWidget extends WidgetType {
|
||||
constructor(
|
||||
readonly path: string,
|
||||
readonly mention: Mention,
|
||||
readonly text: string,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
eq(other: ChipWidget) {
|
||||
return other.path === this.path && other.text === this.text;
|
||||
}
|
||||
toDOM() {
|
||||
const chip = chipElement(this.path, this.mention);
|
||||
// Keep the editor's focus and caret; a click opens the file.
|
||||
chip.addEventListener("mousedown", (e) => e.preventDefault());
|
||||
chip.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
openReference(this.path, this.mention);
|
||||
});
|
||||
return chip;
|
||||
}
|
||||
ignoreEvent() {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
class CodeCardWidget extends WidgetType {
|
||||
constructor(
|
||||
readonly info: string,
|
||||
readonly body: string,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
eq(other: CodeCardWidget) {
|
||||
return other.info === this.info && other.body === this.body;
|
||||
}
|
||||
toDOM(view: EditorView) {
|
||||
const range = (dom: HTMLElement) => {
|
||||
const from = view.posAtDOM(dom);
|
||||
return view.state.field(painted).cards.find((c) => c.from === from) ?? null;
|
||||
};
|
||||
const card: HTMLElement = codeCardElement(
|
||||
this.info,
|
||||
this.body,
|
||||
() => {
|
||||
const at = range(card);
|
||||
if (at) view.dispatch({ selection: { anchor: at.to } });
|
||||
view.focus();
|
||||
},
|
||||
() => {
|
||||
const at = range(card);
|
||||
if (!at) return;
|
||||
const end = view.state.sliceDoc(at.to, at.to + 1) === "\n" ? at.to + 1 : at.to;
|
||||
view.dispatch({ changes: { from: at.from, to: end }, userEvent: "delete" });
|
||||
view.focus();
|
||||
},
|
||||
);
|
||||
return card;
|
||||
}
|
||||
ignoreEvent() {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
const quoteMarker = Decoration.replace({});
|
||||
const codeLine = Decoration.line({ class: chipStyles.codeLine });
|
||||
|
||||
interface Painted {
|
||||
decorations: DecorationSet;
|
||||
atomic: DecorationSet;
|
||||
chips: readonly Chip[];
|
||||
cards: readonly { from: number; to: number }[];
|
||||
}
|
||||
|
||||
function paint(state: EditorState): Painted {
|
||||
const refs = state.field(references);
|
||||
const editing = state.field(unfolded);
|
||||
const decorations: Range<Decoration>[] = [];
|
||||
const atomic: Range<Decoration>[] = [];
|
||||
const chips: Chip[] = [];
|
||||
const cards: { from: number; to: number }[] = [];
|
||||
const { doc } = state;
|
||||
const head = state.selection.main.head;
|
||||
// Line numbers (1-based) inside ``` blocks: no chips or quotes there.
|
||||
const coded = new Set<number>();
|
||||
for (const fence of fencedBlocks(doc.toString().split("\n"))) {
|
||||
const from = doc.line(fence.open + 1).from;
|
||||
const to = doc.line(fence.close + 1).to;
|
||||
for (let n = fence.open + 1; n <= fence.close + 1; n++) coded.add(n);
|
||||
if (head > from && head <= to) {
|
||||
for (let n = fence.open + 1; n <= fence.close + 1; n++) decorations.push(codeLine.range(doc.line(n).from));
|
||||
continue;
|
||||
}
|
||||
const body =
|
||||
fence.close > fence.open + 1 ? doc.sliceString(doc.line(fence.open + 2).from, doc.line(fence.close).to) : "";
|
||||
const card = Decoration.replace({ widget: new CodeCardWidget(fence.info, body), block: true });
|
||||
decorations.push(card.range(from, to));
|
||||
atomic.push(card.range(from, to));
|
||||
cards.push({ from, to });
|
||||
}
|
||||
for (let n = 1; n <= doc.lines; n++) {
|
||||
if (coded.has(n)) continue;
|
||||
const line = doc.line(n);
|
||||
if (isQuoteLine(line.text)) {
|
||||
const prev = n > 1 && isQuoteLine(doc.line(n - 1).text);
|
||||
const next = n < doc.lines && isQuoteLine(doc.line(n + 1).text);
|
||||
const cls = [chipStyles.quoteLine, !prev && chipStyles.quoteFirst, !next && chipStyles.quoteLast];
|
||||
decorations.push(Decoration.line({ class: cls.filter(Boolean).join(" ") }).range(line.from));
|
||||
const marker = line.text.startsWith("> ") ? 2 : 1;
|
||||
decorations.push(quoteMarker.range(line.from, line.from + marker));
|
||||
atomic.push(quoteMarker.range(line.from, line.from + marker));
|
||||
}
|
||||
for (const token of lineTokens(doc, line.from)) {
|
||||
if (token.kind === "command") {
|
||||
decorations.push(Decoration.mark({ class: chipStyles.command }).range(token.from, token.to));
|
||||
continue;
|
||||
}
|
||||
const mention = parseMention(token.value);
|
||||
const ref = mention && refs.find((f) => f.rel === mention.rel);
|
||||
if (!mention || !ref || editing === token.from) {
|
||||
decorations.push(Decoration.mark({ class: chipStyles.pending }).range(token.from, token.to));
|
||||
continue;
|
||||
}
|
||||
const chip = Decoration.replace({
|
||||
widget: new ChipWidget(ref.path, mention, token.value),
|
||||
});
|
||||
decorations.push(chip.range(token.from, token.to));
|
||||
atomic.push(chip.range(token.from, token.to));
|
||||
chips.push({ from: token.from, to: token.to, path: ref.path, mention });
|
||||
}
|
||||
}
|
||||
return {
|
||||
decorations: Decoration.set(decorations, true),
|
||||
atomic: Decoration.set(atomic, true),
|
||||
chips,
|
||||
cards,
|
||||
};
|
||||
}
|
||||
|
||||
const painted = StateField.define<Painted>({
|
||||
create: paint,
|
||||
update(value, tr) {
|
||||
// A ``` block opens while the caret is in it.
|
||||
const fences = tr.selection && tr.state.doc.toString().includes("```");
|
||||
const changed =
|
||||
tr.docChanged ||
|
||||
fences ||
|
||||
tr.effects.some((e) => e.is(setReferences) || e.is(unfold)) ||
|
||||
tr.startState.field(unfolded) !== tr.state.field(unfolded);
|
||||
return changed ? paint(tr.state) : value;
|
||||
},
|
||||
provide: (f) => [
|
||||
EditorView.decorations.from(f, (p) => p.decorations),
|
||||
EditorView.atomicRanges.of((view) => view.state.field(f).atomic),
|
||||
],
|
||||
});
|
||||
|
||||
/** Backspace right after a chip turns it back into its text. */
|
||||
const unfoldOnBackspace = keymap.of([
|
||||
{
|
||||
key: "Backspace",
|
||||
run(view) {
|
||||
const sel = view.state.selection.main;
|
||||
if (!sel.empty) return false;
|
||||
const chip = view.state.field(painted).chips.find((c) => c.to === sel.head);
|
||||
if (!chip) return false;
|
||||
view.dispatch({ effects: unfold.of(chip.from), userEvent: "unfold" });
|
||||
return true;
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
export function referenceExtensions(): Extension {
|
||||
return [references, unfolded, painted, unfoldOnBackspace];
|
||||
}
|
||||
@@ -1,19 +1,11 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { act, fireEvent, render, screen, within } from "@testing-library/react";
|
||||
import { copySourceOf, noteCopySource } from "@/platform/clipboardSource";
|
||||
import { EditorView } from "@codemirror/view";
|
||||
import { trackEditorCopies } from "./references";
|
||||
import { act, fireEvent, render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { caretOf, setCaret, textOf, typeChars, typeIn } from "./composerTesting";
|
||||
import chipStyles from "./ReferenceChip.module.css";
|
||||
import { FILES_DRAG, dragSession } from "@/ui";
|
||||
|
||||
const findFiles = vi.fn();
|
||||
const findDirs = vi.fn();
|
||||
const pathCompletions = vi.fn();
|
||||
const fsProbe = vi.fn();
|
||||
const fsEntryKind = vi.fn();
|
||||
const findSymbols = vi.fn();
|
||||
|
||||
vi.mock("@/platform/ipc", async (importOriginal) => {
|
||||
const real = await importOriginal<typeof import("@/platform/ipc")>();
|
||||
@@ -25,16 +17,11 @@ vi.mock("@/platform/ipc", async (importOriginal) => {
|
||||
findFiles: (query: string, limit: number) => findFiles(query, limit),
|
||||
findDirs: (query: string, limit: number) => findDirs(query, limit),
|
||||
pathCompletions: (base: string, typed: string) => pathCompletions(base, typed),
|
||||
fsProbe: (path: string) => fsProbe(path),
|
||||
fsEntryKind: (path: string) => fsEntryKind(path),
|
||||
findSymbols: (query: string, filter: string, limit: number) => findSymbols(query, filter, limit),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
import { Composer } from "./Composer";
|
||||
import { registerAgentContextSource } from "@/workbench/contributions";
|
||||
import { useEditorRegistry, type TextEditorHandle } from "@/editor/api";
|
||||
import { composerDraft, updateComposer } from "./store";
|
||||
import { emptyDraft, toPrompt } from "./composerModel";
|
||||
|
||||
@@ -54,33 +41,15 @@ function renderComposer(root = "/p") {
|
||||
onSubmit={() => {}}
|
||||
/>,
|
||||
);
|
||||
return screen.getByRole("textbox", { name: "Message" });
|
||||
return screen.getByPlaceholderText("Ask") as HTMLTextAreaElement;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
updateComposer(ID, emptyDraft());
|
||||
const match = (path: string, relPath: string, score: number) => ({
|
||||
path,
|
||||
relPath,
|
||||
score,
|
||||
indices: [],
|
||||
});
|
||||
const match = (path: string, relPath: string, score: number) => ({ path, relPath, score, indices: [] });
|
||||
findFiles.mockReset().mockReturnValue(ok([match("/p/src/agents.ts", "src/agents.ts", 40)]));
|
||||
findDirs.mockReset().mockReturnValue(ok([match("/p/src/agents", "src/agents/", 90)]));
|
||||
pathCompletions.mockReset().mockReturnValue(ok([{ name: "lib", dir: true }]));
|
||||
fsEntryKind
|
||||
.mockReset()
|
||||
.mockImplementation((path: string) =>
|
||||
path.startsWith("/p/")
|
||||
? ok(path.endsWith(".ts") ? "file" : "dir")
|
||||
: Promise.resolve({ status: "error", error: { kind: "notFound", message: "" } }),
|
||||
);
|
||||
fsProbe.mockReset().mockReturnValue(
|
||||
Promise.resolve({
|
||||
status: "error",
|
||||
error: { kind: "notFound", message: "" },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
describe("Composer dropped paths", () => {
|
||||
@@ -104,10 +73,7 @@ describe("Composer dropped paths", () => {
|
||||
try {
|
||||
act(() => {
|
||||
// Folders carry a trailing slash, as the Project view marks them.
|
||||
const drag = dragSession({
|
||||
type: FILES_DRAG,
|
||||
payload: "/p/src/a.ts\n/p/src/dir/",
|
||||
});
|
||||
const drag = dragSession({ type: FILES_DRAG, payload: "/p/src/a.ts\n/p/src/dir/" });
|
||||
expect(drag.drop(0, 0)).toBe(true);
|
||||
});
|
||||
} finally {
|
||||
@@ -117,24 +83,24 @@ describe("Composer dropped paths", () => {
|
||||
{ path: "/p/src/a.ts", rel: "src/a.ts" },
|
||||
{ path: "/p/src/dir", rel: "src/dir" },
|
||||
]);
|
||||
expect(textOf(box)).toBe("@src/a.ts @src/dir ");
|
||||
expect(box.value).toBe("@src/a.ts @src/dir ");
|
||||
});
|
||||
|
||||
it("deduplicates live references and allows a deleted reference to be dropped again", () => {
|
||||
const box = renderComposer();
|
||||
drop(box, "/p/src/a.ts\n/p/src/a.ts\n/p/src/dir/");
|
||||
drop(box, "/p/src/a.ts\n/p/src/dir/");
|
||||
expect(textOf(box)).toBe("@src/a.ts @src/dir ");
|
||||
typeIn(box, "@src/dir ");
|
||||
expect(box.value).toBe("@src/a.ts @src/dir ");
|
||||
fireEvent.change(box, { target: { value: "@src/dir " } });
|
||||
drop(box, "/p/src/a.ts");
|
||||
expect(textOf(box)).toBe("@src/dir @src/a.ts ");
|
||||
expect(box.value).toBe("@src/dir @src/a.ts ");
|
||||
expect(toPrompt(composerDraft(ID)).files).toEqual(["/p/src/dir", "/p/src/a.ts"]);
|
||||
});
|
||||
|
||||
it("maps project files and folders into the task worktree, rejecting external paths", () => {
|
||||
const box = renderComposer("/worktree");
|
||||
drop(box, "/p/src/a.ts\n/p/src/dir/\n/external/a.ts\n/p-other/a.ts\n/p/../external/b.ts\nrelative.ts");
|
||||
expect(textOf(box)).toBe("@src/a.ts @src/dir ");
|
||||
expect(box.value).toBe("@src/a.ts @src/dir ");
|
||||
expect(toPrompt(composerDraft(ID)).files).toEqual(["/worktree/src/a.ts", "/worktree/src/dir"]);
|
||||
});
|
||||
});
|
||||
@@ -144,349 +110,139 @@ describe("Composer @ folders", () => {
|
||||
findFiles.mockReturnValue(ok([]));
|
||||
findDirs.mockReturnValue(ok([]));
|
||||
const box = renderComposer();
|
||||
typeChars(box, "@");
|
||||
await userEvent.type(box, "@");
|
||||
expect(await screen.findByRole("option")).toHaveTextContent("lib/");
|
||||
expect(pathCompletions).toHaveBeenCalledWith("/p/_", "");
|
||||
fireEvent.keyDown(box, { key: "Enter" });
|
||||
expect(textOf(box)).toBe("@lib/ ");
|
||||
await userEvent.keyboard("{Enter}");
|
||||
expect(box.value).toBe("@lib/ ");
|
||||
expect(toPrompt(composerDraft(ID)).files).toEqual(["/p/lib"]);
|
||||
});
|
||||
|
||||
it("lists index folders among files and references one on Enter", async () => {
|
||||
const box = renderComposer();
|
||||
typeChars(box, "see @agen");
|
||||
await userEvent.type(box, "see @agen");
|
||||
const options = await screen.findAllByRole("option");
|
||||
expect(options.map((o) => o.textContent)).toEqual(["agents/src", "agents.tssrc"]);
|
||||
fireEvent.keyDown(box, { key: "Enter" });
|
||||
expect(textOf(box)).toBe("see @src/agents/ ");
|
||||
expect(options.map((o) => o.textContent)).toEqual(["agents/src/agents", "agents.tssrc/agents.ts"]);
|
||||
await userEvent.keyboard("{Enter}");
|
||||
expect(box.value).toBe("see @src/agents/ ");
|
||||
expect(toPrompt(composerDraft(ID)).files).toEqual(["/p/src/agents"]);
|
||||
});
|
||||
|
||||
it("highlights the matched characters of a file name", async () => {
|
||||
findFiles.mockReturnValue(
|
||||
ok([
|
||||
{
|
||||
path: "/p/src/agents.ts",
|
||||
relPath: "src/agents.ts",
|
||||
score: 40,
|
||||
indices: [4, 5, 6],
|
||||
},
|
||||
]),
|
||||
);
|
||||
findDirs.mockReturnValue(ok([]));
|
||||
const box = renderComposer();
|
||||
typeChars(box, "@age");
|
||||
const option = await screen.findByRole("option");
|
||||
expect([...option.querySelectorAll("mark")].map((m) => m.textContent)).toEqual(["age"]);
|
||||
});
|
||||
|
||||
it("keeps completing inside a folder on Tab, which drops the folder once a file is picked", async () => {
|
||||
const box = renderComposer();
|
||||
typeChars(box, "@agen");
|
||||
await userEvent.type(box, "@agen");
|
||||
await screen.findAllByRole("option");
|
||||
fireEvent.keyDown(box, { key: "Tab" });
|
||||
expect(textOf(box)).toBe("@src/agents/");
|
||||
await userEvent.keyboard("{Tab}");
|
||||
expect(box.value).toBe("@src/agents/");
|
||||
expect(toPrompt(composerDraft(ID)).files).toEqual(["/p/src/agents"]);
|
||||
typeChars(box, "x.ts ");
|
||||
await userEvent.type(box, "x.ts ");
|
||||
expect(toPrompt(composerDraft(ID)).files).toEqual([]);
|
||||
});
|
||||
|
||||
it("references a folder of a typed path", async () => {
|
||||
const box = renderComposer();
|
||||
typeChars(box, "@./");
|
||||
await userEvent.type(box, "@./");
|
||||
await screen.findByRole("option");
|
||||
fireEvent.keyDown(box, { key: "Enter" });
|
||||
expect(textOf(box)).toBe("@./lib/ ");
|
||||
await userEvent.keyboard("{Enter}");
|
||||
expect(box.value).toBe("@./lib/ ");
|
||||
expect(toPrompt(composerDraft(ID)).files).toEqual(["/p/lib"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Composer reference chips", () => {
|
||||
const chips = () => [...document.querySelectorAll<HTMLElement>("[data-ref-path]")];
|
||||
|
||||
it("shows a referenced file as a chip with its name and lines, and leaves the text as written", () => {
|
||||
describe("Composer tag editing", () => {
|
||||
it.each(["@src/ag", "/rev"])("deletes %s character by character while it is typed", async (tag) => {
|
||||
const box = renderComposer();
|
||||
act(() => updateComposer(ID, { files: [{ path: "/p/src/a.ts", rel: "src/a.ts" }] }));
|
||||
typeIn(box, "see @src/a.ts:10-20 and @other.ts ");
|
||||
expect(chips().map((c) => [c.dataset.refPath, c.textContent])).toEqual([["/p/src/a.ts", "a.ts10–20"]]);
|
||||
expect(chips()[0]).toHaveAccessibleName("File reference a.ts, lines 10–20");
|
||||
// Not referenced: marked as typed.
|
||||
expect(screen.getByText("@other.ts")).toHaveClass(chipStyles.pending!);
|
||||
expect(textOf(box)).toBe("see @src/a.ts:10-20 and @other.ts ");
|
||||
const user = userEvent.setup();
|
||||
await user.type(box, `see ${tag}`);
|
||||
await user.keyboard("{Backspace}");
|
||||
expect(box.value).toBe(`see ${tag.slice(0, -1)}`);
|
||||
await user.keyboard("{Backspace}");
|
||||
expect(box.value).toBe(`see ${tag.slice(0, -2)}`);
|
||||
});
|
||||
|
||||
it("turns a chip back into its text on Backspace, and folds it again when the caret leaves", async () => {
|
||||
it("deletes inside a folder entered with Tab character by character", async () => {
|
||||
const box = renderComposer();
|
||||
typeChars(box, "see @agen");
|
||||
const user = userEvent.setup();
|
||||
await user.type(box, "@agen");
|
||||
await screen.findAllByRole("option");
|
||||
fireEvent.keyDown(box, { key: "Enter" });
|
||||
expect(textOf(box)).toBe("see @src/agents/ ");
|
||||
expect(chips()).toHaveLength(1);
|
||||
fireEvent.keyDown(box, { key: "Backspace" });
|
||||
expect(textOf(box)).toBe("see @src/agents/");
|
||||
expect(chips()).toHaveLength(1);
|
||||
// Right after the chip: it opens as text, and the completion offers it again.
|
||||
fireEvent.keyDown(box, { key: "Backspace" });
|
||||
expect(textOf(box)).toBe("see @src/agents/");
|
||||
expect(chips()).toHaveLength(0);
|
||||
fireEvent.keyDown(box, { key: "Backspace" });
|
||||
expect(textOf(box)).toBe("see @src/agents");
|
||||
expect(chips()).toHaveLength(0);
|
||||
typeChars(box, "/");
|
||||
setCaret(box, 0);
|
||||
expect(chips()).toHaveLength(1);
|
||||
await user.keyboard("{Tab}");
|
||||
expect(box.value).toBe("@src/agents/");
|
||||
await user.keyboard("{Backspace}");
|
||||
expect(box.value).toBe("@src/agents");
|
||||
});
|
||||
|
||||
it("moves over and deletes a chip as one piece", () => {
|
||||
it("edits a completed reference character by character after its space", async () => {
|
||||
const box = renderComposer();
|
||||
act(() => updateComposer(ID, { files: [{ path: "/p/src/a.ts", rel: "src/a.ts" }] }));
|
||||
typeIn(box, "see @src/a.ts next", 4);
|
||||
fireEvent.keyDown(box, { key: "Delete" });
|
||||
expect(textOf(box)).toBe("see next");
|
||||
const user = userEvent.setup();
|
||||
await user.type(box, "see @agen");
|
||||
await screen.findAllByRole("option");
|
||||
await user.keyboard("{Enter}");
|
||||
expect(box.value).toBe("see @src/agents/ ");
|
||||
await user.keyboard("{Backspace}{Backspace}");
|
||||
expect(box.value).toBe("see @src/agents");
|
||||
});
|
||||
|
||||
it("deletes a reference that names no file character by character", () => {
|
||||
it.each(["@src/a.ts", "/review"])("edits %s after moving away and returning", async (tag) => {
|
||||
const box = renderComposer();
|
||||
typeIn(box, "see @src/a.ts next", 13);
|
||||
fireEvent.keyDown(box, { key: "Backspace" });
|
||||
expect(textOf(box)).toBe("see @src/a.t next");
|
||||
expect(caretOf(box)).toBe(12);
|
||||
const user = userEvent.setup();
|
||||
fireEvent.change(box, { target: { value: `see ${tag} next` } });
|
||||
box.focus();
|
||||
box.setSelectionRange(0, 0);
|
||||
fireEvent.select(box);
|
||||
box.blur();
|
||||
box.focus();
|
||||
box.setSelectionRange(4 + tag.length, 4 + tag.length);
|
||||
fireEvent.select(box);
|
||||
await user.keyboard("{Backspace}");
|
||||
expect(box.value).toBe(`see ${tag.slice(0, -1)} next`);
|
||||
expect(box.selectionStart).toBe(3 + tag.length);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Composer quotes", () => {
|
||||
it("draws quote lines as a block without their marker", () => {
|
||||
it.each(["@src/a.ts", "/review"])("moves within %s with the arrow keys", async (tag) => {
|
||||
const box = renderComposer();
|
||||
typeIn(box, "> first\n> second\n\nreply");
|
||||
const lines = [...box.querySelectorAll(".cm-line")];
|
||||
expect(lines.map((l) => l.textContent)).toEqual(["first", "second", "", "reply"]);
|
||||
expect(lines.map((l) => l.classList.contains(chipStyles.quoteLine!))).toEqual([true, true, false, false]);
|
||||
expect(textOf(box)).toBe("> first\n> second\n\nreply");
|
||||
const user = userEvent.setup();
|
||||
fireEvent.change(box, { target: { value: `see ${tag} next` } });
|
||||
box.focus();
|
||||
box.setSelectionRange(4, 4);
|
||||
await user.keyboard("{ArrowRight}");
|
||||
expect(box.selectionStart).toBe(5);
|
||||
await user.keyboard("{ArrowLeft}");
|
||||
expect(box.selectionStart).toBe(4);
|
||||
await user.keyboard("{Delete}");
|
||||
expect(box.value).toBe(`see ${tag.slice(1)} next`);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Composer editing", () => {
|
||||
it("offers no completion while the input method composes", () => {
|
||||
it.each(["@src/a.ts", "/review"])("selects and deletes only part of %s", async (tag) => {
|
||||
const box = renderComposer();
|
||||
const user = userEvent.setup();
|
||||
fireEvent.change(box, { target: { value: `see ${tag} next` } });
|
||||
box.focus();
|
||||
box.setSelectionRange(5, 7);
|
||||
fireEvent.select(box);
|
||||
await user.keyboard("{Backspace}");
|
||||
expect(box.value).toBe(`see ${tag.slice(0, 1)}${tag.slice(3)} next`);
|
||||
});
|
||||
|
||||
it("leaves IME keys and composition text to the input method", () => {
|
||||
const box = renderComposer();
|
||||
fireEvent.change(box, { target: { value: "@src/a.ts" } });
|
||||
const before = box.value;
|
||||
box.setSelectionRange(before.length, before.length);
|
||||
expect(fireEvent.keyDown(box, { key: "Backspace", isComposing: true })).toBe(true);
|
||||
expect(fireEvent.keyDown(box, { key: "Backspace", keyCode: 229 })).toBe(true);
|
||||
fireEvent.compositionStart(box);
|
||||
typeIn(box, "@中文");
|
||||
fireEvent.change(box, { target: { value: "@中文" } });
|
||||
expect(screen.queryByRole("listbox")).toBeNull();
|
||||
expect(fireEvent.keyDown(box, { key: "Enter" })).toBe(true);
|
||||
fireEvent.compositionEnd(box);
|
||||
expect(textOf(box)).toBe("@中文");
|
||||
expect(box.value).toBe("@中文");
|
||||
});
|
||||
|
||||
it("pastes plain text as text", async () => {
|
||||
it("pastes plain text through the native textarea", async () => {
|
||||
const box = renderComposer();
|
||||
const user = userEvent.setup();
|
||||
act(() => box.focus());
|
||||
box.focus();
|
||||
await user.paste("look @src/a.ts\nthen /review please");
|
||||
expect(textOf(box)).toBe("look @src/a.ts\nthen /review please");
|
||||
});
|
||||
|
||||
it("references a pasted path of a project file, with its lines", async () => {
|
||||
const box = renderComposer();
|
||||
const user = userEvent.setup();
|
||||
act(() => box.focus());
|
||||
await user.paste("/p/src/a.ts:3-5");
|
||||
await vi.waitFor(() => expect(textOf(box)).toBe("@src/a.ts:3-5 "));
|
||||
expect(toPrompt(composerDraft(ID)).files).toEqual(["/p/src/a.ts"]);
|
||||
await user.paste("/elsewhere/b.ts");
|
||||
expect(textOf(box)).toBe("@src/a.ts:3-5 /elsewhere/b.ts");
|
||||
});
|
||||
|
||||
it("references pasted folders, several paths and file URLs", async () => {
|
||||
const box = renderComposer();
|
||||
const user = userEvent.setup();
|
||||
act(() => box.focus());
|
||||
await user.paste("/p/src/dir\n'/p/src/b.ts'\nfile:///p/src/c.ts");
|
||||
await vi.waitFor(() => expect(textOf(box)).toBe("@src/dir/ @src/b.ts @src/c.ts "));
|
||||
expect(toPrompt(composerDraft(ID)).files).toEqual(["/p/src/dir", "/p/src/b.ts", "/p/src/c.ts"]);
|
||||
});
|
||||
|
||||
it("pastes a path that is gone, or holds a space, as text", async () => {
|
||||
const box = renderComposer();
|
||||
const user = userEvent.setup();
|
||||
act(() => box.focus());
|
||||
await user.paste("/p/my dir/a.ts");
|
||||
expect(textOf(box)).toBe("/p/my dir/a.ts");
|
||||
fsEntryKind.mockReturnValue(Promise.resolve({ status: "error", error: { kind: "notFound", message: "" } }));
|
||||
await user.paste(" /p/gone.ts");
|
||||
await vi.waitFor(() => expect(textOf(box)).toBe("/p/my dir/a.ts /p/gone.ts"));
|
||||
});
|
||||
});
|
||||
|
||||
describe("Composer current file", () => {
|
||||
it("offers the focused editor's file until the draft references it", () => {
|
||||
const view = {} as TextEditorHandle["view"];
|
||||
act(() =>
|
||||
useEditorRegistry.setState({ active: { view, ctx: { path: "/p/src/main.ts" } as TextEditorHandle["ctx"] } }),
|
||||
);
|
||||
try {
|
||||
renderComposer();
|
||||
const offer = screen.getByRole("button", { name: /main\.ts/ });
|
||||
expect(offer).toHaveTextContent("Current File");
|
||||
fireEvent.click(offer);
|
||||
expect(composerDraft(ID).text).toBe("@src/main.ts ");
|
||||
expect(toPrompt(composerDraft(ID)).files).toEqual(["/p/src/main.ts"]);
|
||||
expect(screen.queryByRole("button", { name: /main\.ts/ })).toBeNull();
|
||||
expect(document.querySelector("[data-ref-path]")).toHaveAttribute("data-ref-path", "/p/src/main.ts");
|
||||
} finally {
|
||||
act(() => useEditorRegistry.setState({ active: null }));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("Composer pasted code and terminal output", () => {
|
||||
const paste = (box: HTMLElement, text: string) =>
|
||||
act(() => {
|
||||
const event = new Event("paste", { bubbles: true, cancelable: true }) as ClipboardEvent;
|
||||
Object.defineProperty(event, "clipboardData", {
|
||||
value: { files: [], types: ["text/plain"], getData: () => text },
|
||||
});
|
||||
box.dispatchEvent(event);
|
||||
});
|
||||
|
||||
it("turns terminal output into a terminal card, and opens it as text when the caret enters it", () => {
|
||||
const box = renderComposer();
|
||||
typeIn(box, "why does this fail?");
|
||||
noteCopySource("$ pnpm test\nFAIL a.test.ts\n expected 1", { kind: "terminal", title: "zsh" });
|
||||
paste(box, "$ pnpm test\nFAIL a.test.ts\n expected 1\n");
|
||||
expect(textOf(box)).toBe("why does this fail?\n```terminal zsh\n$ pnpm test\nFAIL a.test.ts\n expected 1\n```\n");
|
||||
const card = screen.getByRole("group", { name: "Terminal · zsh" });
|
||||
expect(card).toHaveTextContent("3 lines");
|
||||
expect(card.querySelector("pre")).toHaveTextContent("$ pnpm test");
|
||||
fireEvent.click(card);
|
||||
expect(screen.queryByRole("group")).toBeNull();
|
||||
expect(box.querySelectorAll(`.${chipStyles.codeLine}`)).toHaveLength(5);
|
||||
});
|
||||
|
||||
it("removes a card with its button", () => {
|
||||
const box = renderComposer();
|
||||
typeIn(box, "```ts\nconst a = 1;\n```\nnext");
|
||||
fireEvent.click(within(screen.getByRole("group", { name: "TypeScript" })).getByRole("button", { name: "Remove" }));
|
||||
expect(textOf(box)).toBe("next");
|
||||
});
|
||||
|
||||
it("references the lines copied from an editor, and pastes a copied single line as text", () => {
|
||||
const box = renderComposer();
|
||||
noteCopySource("fn a() {\n}", { kind: "file", path: "/p/src/a.rs", from: 3, to: 4 });
|
||||
paste(box, "fn a() {\n}");
|
||||
expect(textOf(box)).toBe("@src/a.rs:3-4 ");
|
||||
expect(toPrompt(composerDraft(ID)).files).toEqual(["/p/src/a.rs"]);
|
||||
noteCopySource("fn a()", { kind: "file", path: "/p/src/a.rs", from: 3, to: 3 });
|
||||
paste(box, "fn a()");
|
||||
expect(textOf(box)).toBe("@src/a.rs:3-4 fn a()");
|
||||
});
|
||||
|
||||
it("pastes a stack trace as a card, its project lines as references after it", async () => {
|
||||
const box = renderComposer();
|
||||
const trace = "TypeError: boom\n at run (/p/src/a.ts:12:5)\n at main (/elsewhere/x.ts:3:1)";
|
||||
paste(box, trace);
|
||||
expect(screen.getByRole("group", { name: "Stack Trace" })).toHaveTextContent("3 lines");
|
||||
await vi.waitFor(() => expect(textOf(box)).toBe(`\`\`\`trace\n${trace}\n\`\`\`\n@src/a.ts:12 `));
|
||||
expect(toPrompt(composerDraft(ID)).files).toEqual(["/p/src/a.ts"]);
|
||||
});
|
||||
|
||||
it("folds long pasted text into a card", () => {
|
||||
const box = renderComposer();
|
||||
const long = Array.from({ length: 40 }, (_, i) => `row ${i}`).join("\n");
|
||||
paste(box, long);
|
||||
expect(screen.getByRole("group", { name: "Pasted Text" })).toHaveTextContent("40 lines");
|
||||
expect(textOf(box)).toBe(`\`\`\`text\n${long}\n\`\`\`\n`);
|
||||
});
|
||||
|
||||
it("pastes other text as written", () => {
|
||||
const box = renderComposer();
|
||||
noteCopySource("something else", { kind: "terminal", title: "zsh" });
|
||||
paste(box, "line one\nline two");
|
||||
expect(textOf(box)).toBe("line one\nline two");
|
||||
});
|
||||
});
|
||||
|
||||
describe("editor copies", () => {
|
||||
it("notes the copied lines of a file editor, or the caret's line when nothing is selected", () => {
|
||||
const view = new EditorView({
|
||||
doc: "one\ntwo\nthree\nfour",
|
||||
parent: document.body,
|
||||
selection: { anchor: 4, head: 14 },
|
||||
});
|
||||
const handle = { view, ctx: { path: "/p/src/a.ts" } } as TextEditorHandle;
|
||||
act(() => useEditorRegistry.setState({ editors: new Set([handle]) }));
|
||||
const stop = trackEditorCopies();
|
||||
try {
|
||||
view.contentDOM.dispatchEvent(new Event("copy", { bubbles: true }));
|
||||
expect(copySourceOf("two\nthree\n")).toEqual({ kind: "file", path: "/p/src/a.ts", from: 2, to: 3 });
|
||||
view.dispatch({ selection: { anchor: 15 } });
|
||||
view.contentDOM.dispatchEvent(new Event("copy", { bubbles: true }));
|
||||
expect(copySourceOf("four")).toEqual({ kind: "file", path: "/p/src/a.ts", from: 4, to: 4 });
|
||||
} finally {
|
||||
stop();
|
||||
view.destroy();
|
||||
act(() => useEditorRegistry.setState({ editors: new Set() }));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("Composer @ context and symbols", () => {
|
||||
it("offers what features contribute above the files and inserts it", async () => {
|
||||
const off = [
|
||||
registerAgentContextSource({
|
||||
id: "problems",
|
||||
title: "Problems",
|
||||
description: "The project's errors and warnings",
|
||||
icon: "problems",
|
||||
resolve: async () => ({ kind: "block", info: "problems", text: "src/a.ts:3:1: error: nope" }),
|
||||
}),
|
||||
registerAgentContextSource({
|
||||
id: "changes",
|
||||
title: "Changes",
|
||||
description: "Files with uncommitted changes",
|
||||
icon: "diff",
|
||||
resolve: async () => ({ kind: "files", paths: ["/p/src/a.ts", "/p/src/b.ts", "/elsewhere/c.ts"] }),
|
||||
}),
|
||||
];
|
||||
try {
|
||||
const box = renderComposer();
|
||||
typeChars(box, "fix @pro");
|
||||
const options = await screen.findAllByRole("option");
|
||||
expect(options[0]).toHaveTextContent("@problems");
|
||||
fireEvent.keyDown(box, { key: "Enter" });
|
||||
await vi.waitFor(() =>
|
||||
expect(textOf(box)).toBe("fix\n```problems\nsrc/a.ts:3:1: error: nope\n```\n@src/a.ts:3 "),
|
||||
);
|
||||
expect(screen.getByRole("group", { name: "Problems" })).toBeInTheDocument();
|
||||
typeChars(box, "@chan");
|
||||
await screen.findAllByRole("option");
|
||||
fireEvent.keyDown(box, { key: "Enter" });
|
||||
await vi.waitFor(() => expect(textOf(box)).toMatch(/@src\/a\.ts:3 @src\/a\.ts @src\/b\.ts $/));
|
||||
expect(toPrompt(composerDraft(ID)).files).toEqual(["/p/src/a.ts", "/p/src/b.ts"]);
|
||||
} finally {
|
||||
off.forEach((d) => d());
|
||||
}
|
||||
});
|
||||
|
||||
it("references a symbol's file and line with @#", async () => {
|
||||
findSymbols.mockReturnValue(
|
||||
ok([
|
||||
{
|
||||
name: "fetchUser",
|
||||
kind: "function",
|
||||
container: "client",
|
||||
path: "/p/src/api/client.ts",
|
||||
relPath: "src/api/client.ts",
|
||||
range: { start: { line: 11, col: 0 }, end: { line: 11, col: 9 } },
|
||||
score: 10,
|
||||
indices: [0, 1, 2],
|
||||
},
|
||||
]),
|
||||
);
|
||||
const box = renderComposer();
|
||||
typeChars(box, "@#fet");
|
||||
const option = await screen.findByRole("option");
|
||||
expect(option).toHaveTextContent("fetchUserclient · src/api/client.ts:12");
|
||||
expect(findSymbols).toHaveBeenCalledWith("fet", "all", 12);
|
||||
fireEvent.keyDown(box, { key: "Enter" });
|
||||
expect(textOf(box)).toBe("@src/api/client.ts:12 ");
|
||||
expect(toPrompt(composerDraft(ID)).files).toEqual(["/p/src/api/client.ts"]);
|
||||
expect(box.value).toBe("look @src/a.ts\nthen /review please");
|
||||
});
|
||||
});
|
||||
@@ -27,26 +27,19 @@ export interface ComposerDraft {
|
||||
images: AgentImage[];
|
||||
}
|
||||
|
||||
export const emptyDraft = (): ComposerDraft => ({
|
||||
text: "",
|
||||
files: [],
|
||||
images: [],
|
||||
});
|
||||
export const emptyDraft = (): ComposerDraft => ({ text: "", files: [], images: [] });
|
||||
|
||||
export type MessageToken = { kind: "text" | "file" | "command"; value: string };
|
||||
|
||||
/**
|
||||
* A message's text split into plain runs, `@file` references and `/command` skills. A
|
||||
* command is a `/word` at the start or after whitespace; a reference likewise after `@`.
|
||||
* Punctuation that ends either belongs to the text.
|
||||
*/
|
||||
export function messageTokens(text: string): MessageToken[] {
|
||||
const out: MessageToken[] = [];
|
||||
let last = 0;
|
||||
// Full-width punctuation ends one too, as Chinese text runs on without a space after it.
|
||||
for (const match of text.matchAll(/(^|\s)([@/][^\s@,。;:!?、()【】《》「」『』]+)/g)) {
|
||||
// Punctuation after it ends the sentence, not the path: "see @a.ts, then /review."
|
||||
const token = match[2]!.replace(/^(.+?)[,.;:!?)\]}>'",。;:!?、)】》」』]+$/, "$1");
|
||||
for (const match of text.matchAll(/(^|\s)([@/][^\s@]+)/g)) {
|
||||
const token = match[2]!;
|
||||
const start = match.index! + match[1]!.length;
|
||||
const command = /^\/[A-Za-z][\w:.-]*$/.test(token);
|
||||
if (token.startsWith("/") && !command) continue;
|
||||
@@ -68,12 +61,7 @@ export function completionAt(text: string, caret: number): Completion | null {
|
||||
const word = /(^|\s)([@/])([^\s@]*)$/.exec(before);
|
||||
if (!word) return null;
|
||||
const from = caret - word[3]!.length - 1;
|
||||
return {
|
||||
kind: word[2] === "/" ? "command" : "file",
|
||||
query: word[3]!,
|
||||
from,
|
||||
to: caret,
|
||||
};
|
||||
return { kind: word[2] === "/" ? "command" : "file", query: word[3]!, from, to: caret };
|
||||
}
|
||||
|
||||
/** Replaces the completion with `insert` plus a space; returns the text and the caret after it. */
|
||||
@@ -131,8 +119,6 @@ interface IndexMatch {
|
||||
path: string;
|
||||
relPath: string;
|
||||
score: number;
|
||||
/** Matched characters of `relPath`. */
|
||||
indices?: number[];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -143,170 +129,15 @@ export function mergeMatches(
|
||||
files: readonly IndexMatch[],
|
||||
dirs: readonly IndexMatch[],
|
||||
limit: number,
|
||||
): {
|
||||
path: string;
|
||||
rel: string;
|
||||
dir: boolean;
|
||||
score: number;
|
||||
indices: number[];
|
||||
}[] {
|
||||
): { path: string; rel: string; dir: boolean; score: number }[] {
|
||||
return [
|
||||
...files.map((f) => ({
|
||||
path: f.path,
|
||||
rel: f.relPath,
|
||||
dir: false,
|
||||
score: f.score,
|
||||
indices: f.indices ?? [],
|
||||
})),
|
||||
...dirs.map((d) => ({
|
||||
path: d.path,
|
||||
rel: d.relPath.replace(/\/$/, ""),
|
||||
dir: true,
|
||||
score: d.score,
|
||||
indices: d.indices ?? [],
|
||||
})),
|
||||
...files.map((f) => ({ path: f.path, rel: f.relPath, dir: false, score: f.score })),
|
||||
...dirs.map((d) => ({ path: d.path, rel: d.relPath.replace(/\/$/, ""), dir: true, score: d.score })),
|
||||
]
|
||||
.sort((a, b) => b.score - a.score || Number(a.dir) - Number(b.dir))
|
||||
.slice(0, limit);
|
||||
}
|
||||
|
||||
export interface Mention {
|
||||
/** As written after `@`; a folder keeps its trailing `/`. */
|
||||
rel: string;
|
||||
dir: boolean;
|
||||
/** 1-based lines, when the mention names a range. */
|
||||
lines?: { from: number; to: number };
|
||||
}
|
||||
|
||||
/** `@src/a.rs:10-20` → its path and lines; `null` for an empty `@`. */
|
||||
export function parseMention(token: string): Mention | null {
|
||||
const m = /^@(.+?)(?::(\d+)(?:-(\d+))?)?$/.exec(token);
|
||||
if (!m) return null;
|
||||
const rel = m[1]!;
|
||||
const from = m[2] ? Number(m[2]) : undefined;
|
||||
const lines = from === undefined ? undefined : { from, to: m[3] ? Math.max(from, Number(m[3])) : from };
|
||||
return { rel, dir: rel.endsWith("/"), ...(lines && { lines }) };
|
||||
}
|
||||
|
||||
/** "10" or "10–20". */
|
||||
export const linesLabel = (lines: { from: number; to: number }) =>
|
||||
lines.from === lines.to ? String(lines.from) : `${lines.from}–${lines.to}`;
|
||||
|
||||
/** Lines of `text` that start with `>`: a quoted reply or selection, drawn as a block. */
|
||||
export const isQuoteLine = (line: string) => /^>( |$)/.test(line);
|
||||
|
||||
export interface Fence {
|
||||
/** Line indexes (0-based) of the opening and the closing fence. */
|
||||
open: number;
|
||||
close: number;
|
||||
/** After the opening fence: a language, or `terminal <title>`. */
|
||||
info: string;
|
||||
}
|
||||
|
||||
/** The closed ``` blocks among `lines`. */
|
||||
export function fencedBlocks(lines: readonly string[]): Fence[] {
|
||||
const out: Fence[] = [];
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const open = /^(`{3,})([^`]*)$/.exec(lines[i]!);
|
||||
if (!open) continue;
|
||||
const ticks = open[1]!.length;
|
||||
const close = lines.findIndex((l, j) => j > i && new RegExp(`^\`{${ticks},}\\s*$`).test(l));
|
||||
if (close < 0) break;
|
||||
out.push({ open: i, close, info: open[2]!.trim() });
|
||||
i = close;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** `text` as a ``` block on its own lines, fenced longer than any run of backticks in it. */
|
||||
export function fenced(text: string, info: string): string {
|
||||
const longest = Math.max(2, ...[...text.matchAll(/`+/g)].map((m) => m[0].length));
|
||||
const fence = "`".repeat(longest + 1);
|
||||
return `${fence}${info}\n${text.replace(/\r\n?/g, "\n").replace(/\s+$/, "")}\n${fence}`;
|
||||
}
|
||||
|
||||
export type FenceKind = "terminal" | "problems" | "trace" | "text" | "code";
|
||||
|
||||
/**
|
||||
* A block's kind and label: `terminal zsh` → a terminal named zsh, `problems`, `trace` (an
|
||||
* error's stack), `text` (a long paste); anything else is code in that language (`ts`).
|
||||
*/
|
||||
export function fenceLabel(info: string): { kind: FenceKind; label: string; language: string | null } {
|
||||
const [first = "", ...rest] = info.split(/\s+/);
|
||||
if (first === "terminal" || first === "problems" || first === "trace" || first === "text")
|
||||
return { kind: first, label: rest.join(" "), language: null };
|
||||
return { kind: "code", label: info, language: first || null };
|
||||
}
|
||||
|
||||
/** An error's stack trace or a crash: pasted as a block, its project lines as references. */
|
||||
export function looksLikeTrace(text: string): boolean {
|
||||
if (!text.trim().includes("\n")) return false;
|
||||
return [
|
||||
/^\s+at\s+\S/m, // JavaScript, Java, C#
|
||||
/^Traceback \(most recent call last\)/m,
|
||||
/^\s*File ".+", line \d+/m,
|
||||
/panicked at /,
|
||||
/^goroutine \d+ \[/m,
|
||||
/^\s*\d+: .+\n\s+at \S+:\d+/m, // Rust backtrace
|
||||
/^(?:Uncaught |Unhandled )?[\w.$]*(?:Error|Exception)\b.*\n\s+(?:at|File|in) /m,
|
||||
].some((re) => re.test(text));
|
||||
}
|
||||
|
||||
/** Long pasted text goes in as a block rather than filling the box. */
|
||||
export const LONG_PASTE_LINES = 30;
|
||||
|
||||
/**
|
||||
* `path:line` places in output (`src/a.ts:12`, `/abs/a.go:34:5`, `(a.js:3:9)`,
|
||||
* `File "a.py", line 7`), each once, at most `limit`, dependency paths left out.
|
||||
*/
|
||||
export function placesInText(text: string, limit = 8): { path: string; line: number }[] {
|
||||
const out: { path: string; line: number }[] = [];
|
||||
const seen = new Set<string>();
|
||||
const add = (raw: string, line: string) => {
|
||||
const path = raw.replace(/^file:\/\//, "");
|
||||
if (/(^|[\\/])(node_modules|site-packages|\.cargo|\.rustup|go[\\/]pkg[\\/]mod)[\\/]/.test(path)) return;
|
||||
const key = `${path}:${line}`;
|
||||
if (seen.has(key) || out.length >= limit) return;
|
||||
seen.add(key);
|
||||
out.push({ path, line: Number(line) });
|
||||
};
|
||||
for (const m of text.matchAll(/File "([^"]+)", line (\d+)/g)) add(m[1]!, m[2]!);
|
||||
for (const m of text.matchAll(/(?:file:\/\/)?((?:[A-Za-z]:)?[\w.@+\-\\/]*[\w-]\.[A-Za-z0-9]{1,8}):(\d+)(?::\d+)?/g))
|
||||
add(m[1]!, m[2]!);
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pasted absolute paths, one per line (`/abs/a.rs`, `/abs/a.rs:10-20`, `/abs/a.rs:10:5`,
|
||||
* quoted, or `file://` URLs); `null` when any line is something else.
|
||||
*/
|
||||
export function pastedPaths(text: string): { path: string; lines?: { from: number; to: number } }[] | null {
|
||||
const rows = text
|
||||
.split(/\r?\n/)
|
||||
.map((l) => l.trim())
|
||||
.filter(Boolean);
|
||||
if (!rows.length) return null;
|
||||
const out: { path: string; lines?: { from: number; to: number } }[] = [];
|
||||
for (let row of rows) {
|
||||
row = row.replace(/^(["'])(.*)\1$/, "$2");
|
||||
if (row.startsWith("file://")) {
|
||||
try {
|
||||
row = decodeURIComponent(new URL(row).pathname).replace(/^\/([A-Za-z]:)/, "$1");
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
const m = /^((?:\/|[A-Za-z]:[\\/]).*?)(?::(\d+)(?:(?:-(\d+))|(?::\d+))?)?$/.exec(row);
|
||||
if (!m) return null;
|
||||
const from = m[2] ? Number(m[2]) : undefined;
|
||||
out.push({
|
||||
path: m[1]!.replace(/[\\/]+$/, "") || m[1]!,
|
||||
...(from !== undefined && { lines: { from, to: m[3] ? Math.max(from, Number(m[3])) : from } }),
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** `@rel` with a line range suffix when given ("@src/a.rs:10-20"). */
|
||||
export function mentionOf(rel: string, lines?: { from: number; to: number }): string {
|
||||
if (!lines) return `@${rel}`;
|
||||
@@ -325,11 +156,7 @@ export function toPrompt(draft: ComposerDraft): AgentPrompt {
|
||||
seen.add(f.path);
|
||||
return true;
|
||||
});
|
||||
return {
|
||||
text: draft.text.trim(),
|
||||
files: files.map((f) => f.path),
|
||||
images: draft.images,
|
||||
};
|
||||
return { text: draft.text.trim(), files: files.map((f) => f.path), images: draft.images };
|
||||
}
|
||||
|
||||
export const canSend = (d: ComposerDraft) => !!d.text.trim() || d.images.length > 0 || d.files.some((f) => f.attached);
|
||||
@@ -428,11 +255,7 @@ export function retryParts(r: AgentRetry, now: number): { attempt: string; wait:
|
||||
}
|
||||
|
||||
/** "167k/1M (17%)" of the context window and a tooltip with the exact counts. */
|
||||
export function usageLabel(u: AgentUsage): {
|
||||
short: string;
|
||||
percent: number;
|
||||
detail: string;
|
||||
} {
|
||||
export function usageLabel(u: AgentUsage): { short: string; percent: number; detail: string } {
|
||||
const percent = Math.min(100, Math.round((u.used / u.size) * 100));
|
||||
return {
|
||||
short: `${tokens(u.used)}/${tokens(u.size)} (${percent}%)`,
|
||||
@@ -455,18 +278,9 @@ export function limitLabel(l: AgentLimit, now: number): { short: string; percent
|
||||
const percent = Math.min(100, Math.round(l.usedPercent ?? 0));
|
||||
const name = known ? tx(known.name) : l.window;
|
||||
const used = tx`${name}: ${percent}% used`;
|
||||
if (l.resetsMs == null)
|
||||
return {
|
||||
short: `${known?.short ?? l.window} ${percent}%`,
|
||||
percent,
|
||||
detail: used,
|
||||
};
|
||||
if (l.resetsMs == null) return { short: `${known?.short ?? l.window} ${percent}%`, percent, detail: used };
|
||||
const reset = untilReset(Math.max(0, l.resetsMs - now));
|
||||
return {
|
||||
short: `${known?.short ?? l.window} ${percent}%`,
|
||||
percent,
|
||||
detail: tx`${used}, resets in ${reset}`,
|
||||
};
|
||||
return { short: `${known?.short ?? l.window} ${percent}%`, percent, detail: tx`${used}, resets in ${reset}` };
|
||||
}
|
||||
|
||||
/** `45m`, `2h 51m`, `5d 3h`. */
|
||||
@@ -481,10 +295,7 @@ function untilReset(ms: number): string {
|
||||
/** "$0.42": what the session has cost so far. */
|
||||
export function costLabel(c: AgentCost): string {
|
||||
try {
|
||||
return new Intl.NumberFormat("en-US", {
|
||||
style: "currency",
|
||||
currency: c.currency,
|
||||
}).format(c.amount ?? 0);
|
||||
return new Intl.NumberFormat("en-US", { style: "currency", currency: c.currency }).format(c.amount ?? 0);
|
||||
} catch {
|
||||
// Not an ISO 4217 code.
|
||||
return `${(c.amount ?? 0).toFixed(2)} ${c.currency}`;
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
/** Test helpers for the composer's editor (a CodeMirror view); plain text areas work too. */
|
||||
import { act, fireEvent } from "@testing-library/react";
|
||||
import { EditorView } from "@codemirror/view";
|
||||
|
||||
function viewOf(box: HTMLElement): EditorView {
|
||||
const view = EditorView.findFromDOM(box);
|
||||
if (!view) throw new Error("not a composer editor");
|
||||
return view;
|
||||
}
|
||||
|
||||
/** Replaces the composer's text as typing would, caret at `caret` (the end by default). */
|
||||
export function typeIn(box: HTMLElement, value: string, caret = value.length) {
|
||||
if (box instanceof HTMLTextAreaElement) {
|
||||
fireEvent.change(box, {
|
||||
target: { value, selectionStart: caret, selectionEnd: caret },
|
||||
});
|
||||
return;
|
||||
}
|
||||
const view = viewOf(box);
|
||||
act(() => {
|
||||
view.focus();
|
||||
view.dispatch({
|
||||
changes: { from: 0, to: view.state.doc.length, insert: value },
|
||||
selection: { anchor: caret },
|
||||
userEvent: "input.type",
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/** Types `chars` at the caret, one at a time. */
|
||||
export function typeChars(box: HTMLElement, chars: string) {
|
||||
const view = viewOf(box);
|
||||
act(() => view.focus());
|
||||
for (const ch of chars)
|
||||
act(() => {
|
||||
view.dispatch(view.state.replaceSelection(ch), {
|
||||
userEvent: "input.type",
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export const textOf = (box: HTMLElement) =>
|
||||
box instanceof HTMLTextAreaElement ? box.value : viewOf(box).state.doc.toString();
|
||||
|
||||
export const caretOf = (box: HTMLElement) => viewOf(box).state.selection.main.head;
|
||||
|
||||
export function setCaret(box: HTMLElement, anchor: number, head = anchor) {
|
||||
const view = viewOf(box);
|
||||
act(() => {
|
||||
view.focus();
|
||||
view.dispatch({ selection: { anchor, head } });
|
||||
});
|
||||
}
|
||||
@@ -9,9 +9,7 @@ import { useProject } from "@/platform/project";
|
||||
import { executeAction } from "@/platform/actions";
|
||||
import { lazyComponent } from "@/ui";
|
||||
import { isVirtualUri } from "@/platform/virtualDocs";
|
||||
import { call, commands } from "@/platform/ipc";
|
||||
import {
|
||||
registerAgentContextSource,
|
||||
registerEditorDropHandler,
|
||||
registerEditorKind,
|
||||
registerMenuGroup,
|
||||
@@ -32,7 +30,7 @@ import {
|
||||
TOOL_WINDOW,
|
||||
providerIcon,
|
||||
} from "./model";
|
||||
import { sendPaths, sendSelection, trackEditorCopies } from "./references";
|
||||
import { sendPaths, sendSelection } from "./references";
|
||||
import { focusedSession } from "./sessionFocus";
|
||||
import { closeActiveTask, closeAndNewTask, connect, disconnect, openHome, refreshProviders, useAgents } from "./store";
|
||||
import { TaskStatusMark } from "./TaskStatusMark";
|
||||
@@ -79,17 +77,6 @@ export function activate(): () => void {
|
||||
sync(useProject.getState().project?.root);
|
||||
const disposers = [
|
||||
activateSettingsPages(),
|
||||
trackEditorCopies(),
|
||||
registerAgentContextSource({
|
||||
id: "changes",
|
||||
title: "Changes",
|
||||
description: "Files with uncommitted changes",
|
||||
icon: "diff",
|
||||
resolve: async () => {
|
||||
const changed = await call(commands.vcsStatus());
|
||||
return changed.length ? { kind: "files", paths: changed.map((f) => f.path) } : null;
|
||||
},
|
||||
}),
|
||||
// The performance harness (`perf/`): long conversations to switch between and scroll.
|
||||
import.meta.env.DEV || import.meta.env.MODE === "perf"
|
||||
? registerActions([
|
||||
|
||||
@@ -3,22 +3,10 @@ import { act, fireEvent, render, screen, waitFor } from "@testing-library/react"
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import type { AgentEntry, AgentTask } from "@/bindings";
|
||||
|
||||
const ipc = vi.hoisted(() => ({
|
||||
rollback: vi.fn(),
|
||||
prompt: vi.fn(),
|
||||
cancel: vi.fn(),
|
||||
confirm: vi.fn(),
|
||||
}));
|
||||
const ipc = vi.hoisted(() => ({ rollback: vi.fn(), prompt: vi.fn(), cancel: vi.fn(), confirm: vi.fn() }));
|
||||
vi.mock("@/workbench/messages", () => ({ confirm: ipc.confirm }));
|
||||
const entries: AgentEntry[] = [
|
||||
{
|
||||
type: "user",
|
||||
text: "Original message",
|
||||
attachments: [],
|
||||
sentMs: 1,
|
||||
doneMs: 2,
|
||||
steered: false,
|
||||
},
|
||||
{ type: "user", text: "Original message", attachments: [], sentMs: 1, doneMs: 2, steered: false },
|
||||
{ type: "agent", text: "Original answer" },
|
||||
];
|
||||
|
||||
@@ -27,10 +15,7 @@ vi.mock("@/platform/ipc", async (original) => ({
|
||||
commands: {
|
||||
agentEntries: async () => ({ status: "ok", data: entries }),
|
||||
agentChanges: async () => ({ status: "ok", data: [] }),
|
||||
agentGroupedChanges: async () => ({
|
||||
status: "ok",
|
||||
data: { uncommitted: [], commits: [] },
|
||||
}),
|
||||
agentGroupedChanges: async () => ({ status: "ok", data: { uncommitted: [], commits: [] } }),
|
||||
vcsBranchInfo: async () => ({ status: "ok", data: { head: "main" } }),
|
||||
agentRollback: ipc.rollback,
|
||||
agentPrompt: ipc.prompt,
|
||||
@@ -38,13 +23,10 @@ vi.mock("@/platform/ipc", async (original) => ({
|
||||
},
|
||||
}));
|
||||
vi.mock("@tauri-apps/api/webview", () => ({
|
||||
getCurrentWebview: () => ({
|
||||
onDragDropEvent: () => Promise.resolve(() => {}),
|
||||
}),
|
||||
getCurrentWebview: () => ({ onDragDropEvent: () => Promise.resolve(() => {}) }),
|
||||
}));
|
||||
|
||||
import { SessionEditor } from "./SessionEditor";
|
||||
import { textOf, typeIn } from "./composerTesting";
|
||||
import { registerActions } from "@/platform/actions";
|
||||
import { composerDraft, retractFrom, rerunFrom, sendMessage, updateComposer, useAgents } from "./store";
|
||||
import { replyActions } from "./replyActions";
|
||||
@@ -110,9 +92,7 @@ it("keeps the transcript and running session when a fresh-session rerun is cance
|
||||
describe("retracting a turn", () => {
|
||||
it.each([0, 1])("stops before rollback and restores text/images without sending (turn %s)", async (turn) => {
|
||||
const image = { mimeType: "image/png", data: "AAA", name: "shot.png" };
|
||||
useAgents.setState({
|
||||
tasks: new Map([[task.id, { ...task, status: "running" }]]),
|
||||
});
|
||||
useAgents.setState({ tasks: new Map([[task.id, { ...task, status: "running" }]]) });
|
||||
updateComposer(task.id, { text: "Draft" });
|
||||
const running = retractFrom(task.id, turn, "Original", [image]);
|
||||
await waitFor(() => expect(ipc.cancel).toHaveBeenCalledTimes(1));
|
||||
@@ -121,20 +101,14 @@ describe("retracting a turn", () => {
|
||||
await running;
|
||||
expect(ipc.rollback).toHaveBeenCalledExactlyOnceWith(task.id, turn);
|
||||
expect(ipc.prompt).not.toHaveBeenCalled();
|
||||
expect(composerDraft(task.id)).toEqual({
|
||||
text: "Original\nDraft",
|
||||
images: [image],
|
||||
files: [],
|
||||
});
|
||||
expect(composerDraft(task.id)).toEqual({ text: "Original\nDraft", images: [image], files: [] });
|
||||
expect(useAgents.getState().focus?.key).toBe(task.id);
|
||||
});
|
||||
|
||||
it("keeps the draft and avoids rollback when the agent does not stop in time", async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
useAgents.setState({
|
||||
tasks: new Map([[task.id, { ...task, status: "running" }]]),
|
||||
});
|
||||
useAgents.setState({ tasks: new Map([[task.id, { ...task, status: "running" }]]) });
|
||||
const result = expect(retractFrom(task.id, 1, "Original")).rejects.toThrow("The agent is still stopping");
|
||||
await vi.advanceTimersByTimeAsync(15_000);
|
||||
await result;
|
||||
@@ -146,10 +120,7 @@ describe("retracting a turn", () => {
|
||||
});
|
||||
|
||||
it("recovers the original prompt when rollback fails", async () => {
|
||||
ipc.rollback.mockResolvedValue({
|
||||
status: "error",
|
||||
error: { kind: "internal", message: "Rollback failed" },
|
||||
});
|
||||
ipc.rollback.mockResolvedValue({ status: "error", error: { kind: "internal", message: "Rollback failed" } });
|
||||
await expect(retractFrom(task.id, 1, "Original")).rejects.toThrow("Rollback failed");
|
||||
expect(composerDraft(task.id).text).toBe("Original");
|
||||
expect(ipc.prompt).not.toHaveBeenCalled();
|
||||
@@ -166,9 +137,7 @@ describe("retracting a turn", () => {
|
||||
);
|
||||
render(<SessionEditor input={{ kind: "agent-session", key: task.id, data: { task: task.id } }} />);
|
||||
const user = userEvent.setup();
|
||||
const retract = screen.getAllByRole("button", {
|
||||
name: "Retract Message",
|
||||
})[0]!;
|
||||
const retract = screen.getAllByRole("button", { name: "Retract Message" })[0]!;
|
||||
await user.click(retract);
|
||||
await user.click(retract);
|
||||
expect(ipc.confirm).toHaveBeenCalledTimes(1);
|
||||
@@ -187,12 +156,8 @@ async function edit() {
|
||||
const user = userEvent.setup();
|
||||
await user.click(await screen.findByRole("button", { name: "Edit" }));
|
||||
const box = screen.getAllByRole("textbox", { name: "Message" })[0]!;
|
||||
typeIn(box, "Edited message");
|
||||
return {
|
||||
user,
|
||||
box,
|
||||
send: screen.getAllByRole("button", { name: "Send" })[0]!,
|
||||
};
|
||||
fireEvent.change(box, { target: { value: "Edited message" } });
|
||||
return { user, box, send: screen.getAllByRole("button", { name: "Send" })[0]! };
|
||||
}
|
||||
|
||||
it.each([false, true])("recovers images after failed regeneration (new draft: %s)", async (draft) => {
|
||||
@@ -203,13 +168,7 @@ it.each([false, true])("recovers images after failed regeneration (new draft: %s
|
||||
[
|
||||
task.id,
|
||||
[
|
||||
{
|
||||
type: "user",
|
||||
text: "Original message",
|
||||
attachments: [image.name],
|
||||
images: [image],
|
||||
steered: false,
|
||||
},
|
||||
{ type: "user", text: "Original message", attachments: [image.name], images: [image], steered: false },
|
||||
entries[1]!,
|
||||
],
|
||||
],
|
||||
@@ -226,16 +185,8 @@ it.each([false, true])("recovers images after failed regeneration (new draft: %s
|
||||
.run({ ctx: {}, arg: { task: task.id, turn: 0 } });
|
||||
await waitFor(() => expect(ipc.prompt).toHaveBeenCalledTimes(1));
|
||||
const files = draft ? [{ path: "/project/a.ts", rel: "a.ts" }] : [];
|
||||
if (draft)
|
||||
updateComposer(task.id, {
|
||||
text: "Another draft @a.ts",
|
||||
files,
|
||||
images: [added],
|
||||
});
|
||||
finish({
|
||||
status: "error",
|
||||
error: { kind: "internal", message: "Send failed" },
|
||||
});
|
||||
if (draft) updateComposer(task.id, { text: "Another draft @a.ts", files, images: [added] });
|
||||
finish({ status: "error", error: { kind: "internal", message: "Send failed" } });
|
||||
await running;
|
||||
const expected = {
|
||||
text: draft ? "Original message\nAnother draft @a.ts" : "Original message",
|
||||
@@ -251,14 +202,7 @@ it.each([false, true])("recovers images after failed regeneration (new draft: %s
|
||||
|
||||
describe("a sent message with images", () => {
|
||||
afterEach(() => {
|
||||
entries[0] = {
|
||||
type: "user",
|
||||
text: "Original message",
|
||||
attachments: [],
|
||||
sentMs: 1,
|
||||
doneMs: 2,
|
||||
steered: false,
|
||||
};
|
||||
entries[0] = { type: "user", text: "Original message", attachments: [], sentMs: 1, doneMs: 2, steered: false };
|
||||
});
|
||||
|
||||
it("shows the images and lists only the files as chips", async () => {
|
||||
@@ -276,14 +220,7 @@ describe("a sent message with images", () => {
|
||||
expect(img).toHaveAttribute("src", "data:image/png;base64,AAA");
|
||||
expect(screen.getByText("a.ts")).toBeInTheDocument();
|
||||
expect(screen.queryByText("shot.png")).not.toBeInTheDocument();
|
||||
entries[0] = {
|
||||
type: "user",
|
||||
text: "Original message",
|
||||
attachments: [],
|
||||
sentMs: 1,
|
||||
doneMs: 2,
|
||||
steered: false,
|
||||
};
|
||||
entries[0] = { type: "user", text: "Original message", attachments: [], sentMs: 1, doneMs: 2, steered: false };
|
||||
});
|
||||
|
||||
it("opens an image in a dialog and keeps the images when editing, minus the removed ones", async () => {
|
||||
@@ -313,14 +250,7 @@ describe("a sent message with images", () => {
|
||||
await user.click(screen.getAllByRole("button", { name: "Send" })[0]!);
|
||||
await waitFor(() => expect(ipc.prompt).toHaveBeenCalledTimes(1));
|
||||
expect(ipc.prompt.mock.calls[0]![1].images).toEqual([{ mimeType: "image/png", data: "BBB", name: "b.png" }]);
|
||||
entries[0] = {
|
||||
type: "user",
|
||||
text: "Original message",
|
||||
attachments: [],
|
||||
sentMs: 1,
|
||||
doneMs: 2,
|
||||
steered: false,
|
||||
};
|
||||
entries[0] = { type: "user", text: "Original message", attachments: [], sentMs: 1, doneMs: 2, steered: false };
|
||||
});
|
||||
});
|
||||
|
||||
@@ -330,7 +260,7 @@ describe("editing a sent agent message", () => {
|
||||
expect(send.textContent).toBe("");
|
||||
expect(send).toHaveAttribute("data-tooltip", "Send");
|
||||
expect(send).toBeEnabled();
|
||||
typeIn(box, "");
|
||||
fireEvent.change(box, { target: { value: "" } });
|
||||
expect(send).toBeDisabled();
|
||||
});
|
||||
|
||||
@@ -341,18 +271,15 @@ describe("editing a sent agent message", () => {
|
||||
fireEvent.keyDown(box, { key: "Enter", metaKey: true, isComposing: true });
|
||||
fireEvent.keyDown(box, { key: "Enter", metaKey: true, keyCode: 229 });
|
||||
expect(ipc.rollback).not.toHaveBeenCalled();
|
||||
expect(textOf(box)).toBe("Edited message");
|
||||
expect(box).toHaveValue("Edited message");
|
||||
});
|
||||
|
||||
it("keeps the draft and shows a rollback failure beside it", async () => {
|
||||
ipc.rollback.mockResolvedValue({
|
||||
status: "error",
|
||||
error: { kind: "internal", message: "Rollback failed" },
|
||||
});
|
||||
ipc.rollback.mockResolvedValue({ status: "error", error: { kind: "internal", message: "Rollback failed" } });
|
||||
const { user, box, send } = await edit();
|
||||
await user.click(send);
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent("Rollback failed");
|
||||
expect(textOf(box)).toBe("Edited message");
|
||||
expect(box).toHaveValue("Edited message");
|
||||
expect(box).toBeInTheDocument();
|
||||
expect(send).toBeEnabled();
|
||||
expect(ipc.prompt).not.toHaveBeenCalled();
|
||||
@@ -401,13 +328,8 @@ describe("editing a sent agent message", () => {
|
||||
act(() => updateComposer(task.id, { text: draft }));
|
||||
await user.click(send);
|
||||
expect(box).not.toBeInTheDocument();
|
||||
await act(async () =>
|
||||
finish({
|
||||
status: "error",
|
||||
error: { kind: "internal", message: "Send failed" },
|
||||
}),
|
||||
);
|
||||
expect(textOf(screen.getByRole("textbox", { name: "Message" }))).toBe(
|
||||
await act(async () => finish({ status: "error", error: { kind: "internal", message: "Send failed" } }));
|
||||
expect(screen.getByRole("textbox", { name: "Message" })).toHaveValue(
|
||||
draft ? `Edited message\n${draft}` : "Edited message",
|
||||
);
|
||||
expect(screen.getByRole("textbox", { name: "Message" })).toHaveFocus();
|
||||
@@ -424,9 +346,7 @@ describe("editing a sent agent message", () => {
|
||||
});
|
||||
|
||||
it("waits for a running turn to stop before rolling it back", async () => {
|
||||
useAgents.setState({
|
||||
tasks: new Map([[task.id, { ...task, status: "running" }]]),
|
||||
});
|
||||
useAgents.setState({ tasks: new Map([[task.id, { ...task, status: "running" }]]) });
|
||||
const { user, send } = await edit();
|
||||
await user.click(send);
|
||||
expect(ipc.cancel).toHaveBeenCalledWith(task.id, null);
|
||||
@@ -447,7 +367,7 @@ describe("editing a sent agent message", () => {
|
||||
expect(ipc.rollback).not.toHaveBeenCalled();
|
||||
expect(ipc.prompt).not.toHaveBeenCalled();
|
||||
await user.click(screen.getByRole("button", { name: "Edit" }));
|
||||
expect(textOf(screen.getAllByRole("textbox", { name: "Message" })[0]!)).toBe("Original message");
|
||||
expect(screen.getAllByRole("textbox", { name: "Message" })[0]).toHaveValue("Original message");
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -456,34 +376,24 @@ describe("session composer submission", () => {
|
||||
it.each(["click", "Enter"])("keeps an accessible icon-only Send action with %s submission", async (method) => {
|
||||
let finish!: (value: unknown) => void;
|
||||
ipc.prompt.mockReturnValue(new Promise((resolve) => (finish = resolve)));
|
||||
render(
|
||||
<SessionEditor
|
||||
input={{
|
||||
kind: "agent-session",
|
||||
key: task.id,
|
||||
data: { task: task.id },
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
render(<SessionEditor input={{ kind: "agent-session", key: task.id, data: { task: task.id } }} />);
|
||||
const send = screen.getByRole("button", { name: "Send" });
|
||||
expect(send.textContent).toBe("");
|
||||
expect(send).toHaveAttribute("data-tooltip", "Send");
|
||||
expect(send).toBeDisabled();
|
||||
const box = screen.getByRole("textbox", { name: "Message" });
|
||||
typeIn(box, "Follow up");
|
||||
fireEvent.change(box, { target: { value: "Follow up" } });
|
||||
expect(send).toBeEnabled();
|
||||
if (method === "click") fireEvent.click(send);
|
||||
else fireEvent.keyDown(box, { key: "Enter" });
|
||||
expect(ipc.prompt).toHaveBeenCalledExactlyOnceWith(task.id, { text: "Follow up", files: [], images: [] }, false);
|
||||
expect(send).toBeDisabled();
|
||||
await act(async () => finish({ status: "ok", data: null }));
|
||||
expect(textOf(box)).toBe("");
|
||||
expect(box).toHaveValue("");
|
||||
});
|
||||
|
||||
it("keeps Queue named and shows icon-only Stop actions while the agent works", async () => {
|
||||
useAgents.setState({
|
||||
tasks: new Map([[task.id, { ...task, status: "running" }]]),
|
||||
});
|
||||
useAgents.setState({ tasks: new Map([[task.id, { ...task, status: "running" }]]) });
|
||||
render(<SessionEditor input={{ kind: "agent-session", key: task.id, data: { task: task.id } }} />);
|
||||
const queue = screen.getByRole("button", { name: "Queue" });
|
||||
expect(queue.textContent).toBe("");
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
/** "Send to Agent": the editor selection or Project view files as `@` references. */
|
||||
import { activeEditor, useEditorRegistry } from "@/editor/api";
|
||||
import { noteCopySource } from "@/platform/clipboardSource";
|
||||
import { activeEditor } from "@/editor/api";
|
||||
import { useProject } from "@/platform/project";
|
||||
import { isVirtualUri } from "@/platform/virtualDocs";
|
||||
import { mentionOf } from "./composerModel";
|
||||
@@ -35,25 +34,3 @@ export function sendPaths(paths: readonly string[]): boolean {
|
||||
if (refs.length) addReferences(refs);
|
||||
return refs.length > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Notes the lines an editor's copy takes (the whole line when nothing is selected), so pasting
|
||||
* them into the composer references the file's lines.
|
||||
*/
|
||||
export function trackEditorCopies(): () => void {
|
||||
const onCopy = (e: ClipboardEvent) => {
|
||||
const handle = [...useEditorRegistry.getState().editors].find((h) =>
|
||||
h.view.contentDOM.contains(e.target as Node | null),
|
||||
);
|
||||
if (!handle || isVirtualUri(handle.ctx.path)) return;
|
||||
const { state } = handle.view;
|
||||
const sel = state.selection.main;
|
||||
const first = state.doc.lineAt(sel.from);
|
||||
// A selection ending at a line start does not include that line.
|
||||
const last = state.doc.lineAt(sel.to > sel.from && state.doc.lineAt(sel.to).from === sel.to ? sel.to - 1 : sel.to);
|
||||
const text = sel.empty ? first.text : state.sliceDoc(sel.from, sel.to);
|
||||
noteCopySource(text, { kind: "file", path: handle.ctx.path, from: first.number, to: last.number });
|
||||
};
|
||||
document.addEventListener("copy", onCopy, true);
|
||||
return () => document.removeEventListener("copy", onCopy, true);
|
||||
}
|
||||
@@ -15,8 +15,7 @@ import { taskActions } from "./taskActions";
|
||||
import { replyActions } from "./replyActions";
|
||||
import { focusedSession } from "./sessionFocus";
|
||||
import styles from "./Session.module.css";
|
||||
import chipStyles from "./ReferenceChip.module.css";
|
||||
import { textOf, typeIn } from "./composerTesting";
|
||||
import tagStyles from "@/ui/InlineTag.module.css";
|
||||
|
||||
const backend = vi.hoisted(() => ({
|
||||
entries: new Map<string, AgentEntry[]>(),
|
||||
@@ -49,19 +48,12 @@ const backend = vi.hoisted(() => ({
|
||||
vi.mock("@/platform/ipc", async (original) => ({
|
||||
...(await original<typeof import("@/platform/ipc")>()),
|
||||
commands: {
|
||||
fsProbe: async () => ({
|
||||
status: "ok",
|
||||
data: { size: 1, binary: false, tooLarge: false },
|
||||
}),
|
||||
agentEntries: async (id: string, sub: string | null) => {
|
||||
const key = sub ? `${id}/${sub}` : id;
|
||||
backend.fetched.push(key);
|
||||
await backend.entryWait;
|
||||
if (backend.entryError !== null)
|
||||
return {
|
||||
status: "error",
|
||||
error: { kind: "unavailable", message: backend.entryError },
|
||||
};
|
||||
return { status: "error", error: { kind: "unavailable", message: backend.entryError } };
|
||||
return { status: "ok", data: backend.entries.get(key) ?? [] };
|
||||
},
|
||||
agentChanges: async () => {
|
||||
@@ -93,22 +85,12 @@ vi.mock("@/platform/ipc", async (original) => ({
|
||||
backend.imported.push([provider, sessionId]);
|
||||
const found = backend.external.find((e) => e.provider === provider && e.sessionId === sessionId);
|
||||
if (!found) throw new Error("unknown session");
|
||||
return {
|
||||
status: "ok",
|
||||
data: {
|
||||
...task("imported"),
|
||||
id: "imported",
|
||||
title: found.title,
|
||||
provider,
|
||||
},
|
||||
};
|
||||
return { status: "ok", data: { ...task("imported"), id: "imported", title: found.title, provider } };
|
||||
},
|
||||
},
|
||||
}));
|
||||
vi.mock("@tauri-apps/api/webview", () => ({
|
||||
getCurrentWebview: () => ({
|
||||
onDragDropEvent: () => Promise.resolve(() => {}),
|
||||
}),
|
||||
getCurrentWebview: () => ({ onDragDropEvent: () => Promise.resolve(() => {}) }),
|
||||
}));
|
||||
vi.mock("@/workbench/messages", async (original) => ({
|
||||
...(await original<typeof import("@/workbench/messages")>()),
|
||||
@@ -157,11 +139,7 @@ const subagent: SubagentEntry = {
|
||||
endedMs: 1,
|
||||
cancellable: false,
|
||||
};
|
||||
const input = (id: string) => ({
|
||||
kind: SESSION_KIND,
|
||||
key: sessionKey(id),
|
||||
data: { task: id },
|
||||
});
|
||||
const input = (id: string) => ({ kind: SESSION_KIND, key: sessionKey(id), data: { task: id } });
|
||||
const stream = () => document.querySelector<HTMLDivElement>(`.${styles.stream}`)!;
|
||||
const tool = (id: string, overrides: Partial<Extract<AgentEntry, { type: "tool" }>> = {}): AgentEntry => ({
|
||||
type: "tool",
|
||||
@@ -204,10 +182,7 @@ beforeEach(() => {
|
||||
backend.imported = [];
|
||||
backend.externalError = null;
|
||||
backend.accepted = [];
|
||||
backend.acceptedOutcome = {
|
||||
accepted: ["very-long-file-name.ts"],
|
||||
skipped: [],
|
||||
};
|
||||
backend.acceptedOutcome = { accepted: ["very-long-file-name.ts"], skipped: [] };
|
||||
backend.confirmed = [];
|
||||
backend.confirmAnswer = true;
|
||||
backend.entries = new Map([
|
||||
@@ -228,9 +203,7 @@ beforeEach(() => {
|
||||
|
||||
describe("conversation rows", () => {
|
||||
it("keeps authors above messages and their folded work with matching icon-only actions", async () => {
|
||||
useProject.setState({
|
||||
settings: { agents: { foldTurns: false } } as never,
|
||||
});
|
||||
useProject.setState({ settings: { agents: { foldTurns: false } } as never });
|
||||
const user = (text: string): AgentEntry => ({
|
||||
type: "user",
|
||||
text,
|
||||
@@ -256,12 +229,8 @@ describe("conversation rows", () => {
|
||||
const author = stream().querySelectorAll(`.${styles.agentAuthor}`)[1]!;
|
||||
expect(author.nextElementSibling).toHaveClass(styles.processRun!);
|
||||
expect(author.parentElement?.nextElementSibling?.firstElementChild).toHaveClass(styles.agentMessage!);
|
||||
const quote = within(stream()).getAllByRole("button", {
|
||||
name: "Quote Reply",
|
||||
});
|
||||
const copy = within(stream()).getAllByRole("button", {
|
||||
name: "Copy Markdown",
|
||||
});
|
||||
const quote = within(stream()).getAllByRole("button", { name: "Quote Reply" });
|
||||
const copy = within(stream()).getAllByRole("button", { name: "Copy Markdown" });
|
||||
// One per turn: only a turn's final reply carries actions.
|
||||
expect(quote).toHaveLength(2);
|
||||
for (const button of [...quote, ...copy]) {
|
||||
@@ -281,18 +250,8 @@ describe("conversation rows", () => {
|
||||
tool("two"),
|
||||
{ type: "agent", text: "More detail" },
|
||||
tool("List .", { status: "running" }),
|
||||
{
|
||||
type: "permission",
|
||||
id: 1,
|
||||
title: "Run python3",
|
||||
command: "python3",
|
||||
options: [],
|
||||
chosen,
|
||||
},
|
||||
{
|
||||
type: "plan",
|
||||
items: [{ content: "Check files", status: "in_progress" }],
|
||||
},
|
||||
{ type: "permission", id: 1, title: "Run python3", command: "python3", options: [], chosen },
|
||||
{ type: "plan", items: [{ content: "Check files", status: "in_progress" }] },
|
||||
{ type: "thought", text: "Consider the result" },
|
||||
{ type: "agent", text: "Done" },
|
||||
]);
|
||||
@@ -325,18 +284,9 @@ describe("conversation rows", () => {
|
||||
);
|
||||
|
||||
it("puts the time and hover actions under a turn's final reply only", async () => {
|
||||
useProject.setState({
|
||||
settings: { agents: { foldTurns: false } } as never,
|
||||
});
|
||||
useProject.setState({ settings: { agents: { foldTurns: false } } as never });
|
||||
backend.entries.set("A", [
|
||||
{
|
||||
type: "user",
|
||||
text: "Question",
|
||||
attachments: [],
|
||||
steered: false,
|
||||
sentMs: 1000,
|
||||
doneMs: 13000,
|
||||
},
|
||||
{ type: "user", text: "Question", attachments: [], steered: false, sentMs: 1000, doneMs: 13000 },
|
||||
{ type: "agent", text: "First reply" },
|
||||
tool("read"),
|
||||
{ type: "agent", text: "Final reply" },
|
||||
@@ -399,14 +349,7 @@ describe("process folding", () => {
|
||||
describe("turn folding", () => {
|
||||
it("folds a finished turn's work under its message and keeps the reply", async () => {
|
||||
backend.entries.set("A", [
|
||||
{
|
||||
type: "user",
|
||||
text: "Question",
|
||||
attachments: [],
|
||||
steered: false,
|
||||
sentMs: 1000,
|
||||
doneMs: 13000,
|
||||
},
|
||||
{ type: "user", text: "Question", attachments: [], steered: false, sentMs: 1000, doneMs: 13000 },
|
||||
{ type: "thought", text: "think" },
|
||||
tool("one"),
|
||||
{ type: "agent", text: "Interim note" },
|
||||
@@ -428,14 +371,7 @@ describe("turn folding", () => {
|
||||
|
||||
it("leaves the turn the agent works on open", async () => {
|
||||
backend.entries.set("A", [
|
||||
{
|
||||
type: "user",
|
||||
text: "Question",
|
||||
attachments: [],
|
||||
steered: false,
|
||||
sentMs: 1000,
|
||||
doneMs: null,
|
||||
},
|
||||
{ type: "user", text: "Question", attachments: [], steered: false, sentMs: 1000, doneMs: null },
|
||||
tool("one"),
|
||||
{ type: "agent", text: "Interim note" },
|
||||
]);
|
||||
@@ -468,10 +404,7 @@ describe("opened runs", () => {
|
||||
doneMs: null,
|
||||
} as const;
|
||||
const setTask = (status: AgentTask["status"]) => ({
|
||||
tasks: new Map(useAgents.getState().tasks).set("A", {
|
||||
...useAgents.getState().tasks.get("A")!,
|
||||
status,
|
||||
}),
|
||||
tasks: new Map(useAgents.getState().tasks).set("A", { ...useAgents.getState().tasks.get("A")!, status }),
|
||||
});
|
||||
useAgents.setState({
|
||||
entries: new Map([["A", [asked, tool("one"), tool("two"), { type: "agent", text: "Interim" }] as AgentEntry[]]]),
|
||||
@@ -525,33 +458,8 @@ describe("folded content", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("message code blocks", () => {
|
||||
it("shows a terminal block as a card, folding long output", async () => {
|
||||
const output = Array.from({ length: 20 }, (_, i) => `line ${i + 1}`).join("\n");
|
||||
backend.entries.set("A", [
|
||||
{
|
||||
type: "user",
|
||||
text: `why?\n\`\`\`terminal zsh\n${output}\n\`\`\`\nthanks`,
|
||||
attachments: [],
|
||||
images: [],
|
||||
sentMs: 1,
|
||||
doneMs: 2,
|
||||
steered: false,
|
||||
},
|
||||
]);
|
||||
useAgents.setState({ entries: new Map(backend.entries) });
|
||||
render(<SessionEditor input={input("A")} />);
|
||||
const card = await within(stream()).findByRole("group", { name: "Terminal · zsh" });
|
||||
expect(card).toHaveTextContent("20 lines");
|
||||
expect(card.querySelector("pre")!.textContent!.split("\n")).toHaveLength(12);
|
||||
fireEvent.click(within(card).getByRole("button", { name: "Show 8 more lines" }));
|
||||
expect(card.querySelector("pre")!.textContent!.split("\n")).toHaveLength(20);
|
||||
expect(within(stream()).getByText(/thanks/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("message tags", () => {
|
||||
it("shows file references as chips that open the file, and skills as tags", async () => {
|
||||
it("shows file references and skills as distinct tags", async () => {
|
||||
backend.entries.set("A", [
|
||||
{
|
||||
type: "user",
|
||||
@@ -565,14 +473,8 @@ describe("message tags", () => {
|
||||
]);
|
||||
useAgents.setState({ entries: new Map(backend.entries) });
|
||||
render(<SessionEditor input={input("A")} />);
|
||||
const chip = await within(stream()).findByRole("button", {
|
||||
name: "File reference a.ts",
|
||||
});
|
||||
expect(chip).toHaveTextContent("a.ts");
|
||||
expect(chip).toHaveAttribute("data-ref-path", "/project/src/a.ts");
|
||||
expect(within(stream()).getByText("/triage")).toHaveClass(chipStyles.command!);
|
||||
// Mentioned in the text, so not listed again under it.
|
||||
expect(within(stream()).getAllByText("a.ts")).toHaveLength(1);
|
||||
expect(await screen.findByText("@src/a.ts")).toHaveClass(tagStyles.tag!);
|
||||
expect(screen.getByText("/triage")).toHaveClass(tagStyles.accent!);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -612,14 +514,8 @@ describe("scrolling", () => {
|
||||
render(<SessionEditor input={input("A")} />);
|
||||
const el = stream();
|
||||
let height = 1000;
|
||||
Object.defineProperty(el, "scrollHeight", {
|
||||
configurable: true,
|
||||
get: () => height,
|
||||
});
|
||||
Object.defineProperty(el, "clientHeight", {
|
||||
configurable: true,
|
||||
value: 400,
|
||||
});
|
||||
Object.defineProperty(el, "scrollHeight", { configurable: true, get: () => height });
|
||||
Object.defineProperty(el, "clientHeight", { configurable: true, value: 400 });
|
||||
el.scrollTop = 600;
|
||||
fireEvent.scroll(el);
|
||||
fireEvent.wheel(el, { deltaY: -200 });
|
||||
@@ -627,42 +523,16 @@ describe("scrolling", () => {
|
||||
fireEvent.scroll(el);
|
||||
expect(screen.getByRole("button", { name: "Jump to Latest" })).toBeInTheDocument();
|
||||
height = 1100;
|
||||
const sent = {
|
||||
type: "user",
|
||||
text: "Next",
|
||||
attachments: [],
|
||||
steered: false,
|
||||
sentMs: 1,
|
||||
doneMs: null,
|
||||
} as AgentEntry;
|
||||
const sent = { type: "user", text: "Next", attachments: [], steered: false, sentMs: 1, doneMs: null } as AgentEntry;
|
||||
act(() => useAgents.setState({ entries: new Map([["A", [reply, sent]]]) }));
|
||||
expect(el.scrollTop).toBe(1100);
|
||||
expect(screen.queryByRole("button", { name: "Jump to Latest" })).toBeNull();
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
name: "stays at the end below it",
|
||||
top: 300,
|
||||
bottom: 360,
|
||||
scrolled: 60,
|
||||
away: false,
|
||||
},
|
||||
{
|
||||
name: "keeps its header on screen",
|
||||
top: 50,
|
||||
bottom: 900,
|
||||
scrolled: 50,
|
||||
away: true,
|
||||
},
|
||||
{
|
||||
name: "back at the end without following",
|
||||
top: 300,
|
||||
bottom: 360,
|
||||
scrolled: 60,
|
||||
away: false,
|
||||
released: true,
|
||||
},
|
||||
{ name: "stays at the end below it", top: 300, bottom: 360, scrolled: 60, away: false },
|
||||
{ name: "keeps its header on screen", top: 50, bottom: 900, scrolled: 50, away: true },
|
||||
{ name: "back at the end without following", top: 300, bottom: 360, scrolled: 60, away: false, released: true },
|
||||
])("scrolls a section opened at the end into view: $name", async ({ top, bottom, scrolled, away, released }) => {
|
||||
backend.entries.set("A", [
|
||||
{ type: "thought", text: "Weighing it" },
|
||||
@@ -672,14 +542,8 @@ describe("scrolling", () => {
|
||||
render(<SessionEditor input={input("A")} />);
|
||||
await screen.findByText("Answer A");
|
||||
const el = stream();
|
||||
Object.defineProperty(el, "scrollHeight", {
|
||||
configurable: true,
|
||||
value: 1000,
|
||||
});
|
||||
Object.defineProperty(el, "clientHeight", {
|
||||
configurable: true,
|
||||
value: 400,
|
||||
});
|
||||
Object.defineProperty(el, "scrollHeight", { configurable: true, value: 1000 });
|
||||
Object.defineProperty(el, "clientHeight", { configurable: true, value: 400 });
|
||||
el.getBoundingClientRect = () => ({ top: 0, bottom: 400 }) as DOMRect;
|
||||
el.scrollTop = 600;
|
||||
fireEvent.scroll(el);
|
||||
@@ -711,21 +575,13 @@ describe("scrolling", () => {
|
||||
});
|
||||
|
||||
it("keeps following when the view grows taller and the browser pulls the offset back", () => {
|
||||
useAgents.setState({
|
||||
entries: new Map([["A", [{ type: "agent", text: "Initial reply" }]]]),
|
||||
});
|
||||
useAgents.setState({ entries: new Map([["A", [{ type: "agent", text: "Initial reply" }]]]) });
|
||||
render(<SessionEditor input={input("A")} />);
|
||||
const el = stream();
|
||||
let height = 1000;
|
||||
let viewport = 400;
|
||||
Object.defineProperty(el, "scrollHeight", {
|
||||
configurable: true,
|
||||
get: () => height,
|
||||
});
|
||||
Object.defineProperty(el, "clientHeight", {
|
||||
configurable: true,
|
||||
get: () => viewport,
|
||||
});
|
||||
Object.defineProperty(el, "scrollHeight", { configurable: true, get: () => height });
|
||||
Object.defineProperty(el, "clientHeight", { configurable: true, get: () => viewport });
|
||||
el.scrollTop = 600;
|
||||
fireEvent.scroll(el);
|
||||
// The composer shrank after sending: the end moved up and the offset was clamped to it.
|
||||
@@ -734,31 +590,19 @@ describe("scrolling", () => {
|
||||
fireEvent.scroll(el);
|
||||
expect(screen.queryByRole("button", { name: "Jump to Latest" })).toBeNull();
|
||||
height = 1200;
|
||||
act(() =>
|
||||
useAgents.setState({
|
||||
entries: new Map([["A", [{ type: "agent", text: "Streaming" }]]]),
|
||||
}),
|
||||
);
|
||||
act(() => useAgents.setState({ entries: new Map([["A", [{ type: "agent", text: "Streaming" }]]]) }));
|
||||
expect(el.scrollTop).toBe(1200);
|
||||
});
|
||||
|
||||
it.each(["wheel", "touch", "ArrowUp", "PageUp", "Home", "Shift+Space"])(
|
||||
"releases following before an upward %s scroll, even within the bottom threshold",
|
||||
async (method) => {
|
||||
useAgents.setState({
|
||||
entries: new Map([["A", [{ type: "agent", text: "Initial reply" }]]]),
|
||||
});
|
||||
useAgents.setState({ entries: new Map([["A", [{ type: "agent", text: "Initial reply" }]]]) });
|
||||
render(<SessionEditor input={input("A")} />);
|
||||
const el = stream();
|
||||
let height = 1000;
|
||||
Object.defineProperty(el, "scrollHeight", {
|
||||
configurable: true,
|
||||
get: () => height,
|
||||
});
|
||||
Object.defineProperty(el, "clientHeight", {
|
||||
configurable: true,
|
||||
value: 400,
|
||||
});
|
||||
Object.defineProperty(el, "scrollHeight", { configurable: true, get: () => height });
|
||||
Object.defineProperty(el, "clientHeight", { configurable: true, value: 400 });
|
||||
el.scrollTop = 600;
|
||||
fireEvent.scroll(el);
|
||||
if (method === "wheel") fireEvent.wheel(el, { deltaY: -4 });
|
||||
@@ -770,11 +614,7 @@ describe("scrolling", () => {
|
||||
if (method === "Shift+Space") fireEvent.keyDown(el, { key: " ", shiftKey: true });
|
||||
// A chunk can land before the browser dispatches the scroll event.
|
||||
height = 1004;
|
||||
act(() =>
|
||||
useAgents.setState({
|
||||
entries: new Map([["A", [{ type: "agent", text: "Streaming" }]]]),
|
||||
}),
|
||||
);
|
||||
act(() => useAgents.setState({ entries: new Map([["A", [{ type: "agent", text: "Streaming" }]]]) }));
|
||||
expect(el.scrollTop).toBe(600);
|
||||
el.scrollTop = 588;
|
||||
fireEvent.scroll(el);
|
||||
@@ -783,11 +623,7 @@ describe("scrolling", () => {
|
||||
el.scrollTop = 592;
|
||||
fireEvent.scroll(el);
|
||||
height = 1008;
|
||||
act(() =>
|
||||
useAgents.setState({
|
||||
entries: new Map([["A", [{ type: "agent", text: "More streaming" }]]]),
|
||||
}),
|
||||
);
|
||||
act(() => useAgents.setState({ entries: new Map([["A", [{ type: "agent", text: "More streaming" }]]]) }));
|
||||
expect(el.scrollTop).toBe(592);
|
||||
expect(screen.getByRole("button", { name: "Jump to Latest" })).toBeInTheDocument();
|
||||
// Scrolling down to the end resumes following.
|
||||
@@ -795,49 +631,29 @@ describe("scrolling", () => {
|
||||
el.scrollTop = 608;
|
||||
fireEvent.scroll(el);
|
||||
height = 1100;
|
||||
act(() =>
|
||||
useAgents.setState({
|
||||
entries: new Map([["A", [{ type: "agent", text: "Latest" }]]]),
|
||||
}),
|
||||
);
|
||||
act(() => useAgents.setState({ entries: new Map([["A", [{ type: "agent", text: "Latest" }]]]) }));
|
||||
expect(el.scrollTop).toBe(1100);
|
||||
},
|
||||
);
|
||||
|
||||
it("does not move the viewport while the scrollbar is held during streaming", () => {
|
||||
useAgents.setState({
|
||||
entries: new Map([["A", [{ type: "agent", text: "Initial reply" }]]]),
|
||||
});
|
||||
useAgents.setState({ entries: new Map([["A", [{ type: "agent", text: "Initial reply" }]]]) });
|
||||
render(<SessionEditor input={input("A")} />);
|
||||
const el = stream();
|
||||
let height = 1000;
|
||||
Object.defineProperty(el, "scrollHeight", {
|
||||
configurable: true,
|
||||
get: () => height,
|
||||
});
|
||||
Object.defineProperty(el, "clientHeight", {
|
||||
configurable: true,
|
||||
value: 400,
|
||||
});
|
||||
Object.defineProperty(el, "scrollHeight", { configurable: true, get: () => height });
|
||||
Object.defineProperty(el, "clientHeight", { configurable: true, value: 400 });
|
||||
el.scrollTop = 600;
|
||||
fireEvent.scroll(el);
|
||||
fireEvent.pointerDown(el);
|
||||
height = 1100;
|
||||
act(() =>
|
||||
useAgents.setState({
|
||||
entries: new Map([["A", [{ type: "agent", text: "Streaming" }]]]),
|
||||
}),
|
||||
);
|
||||
act(() => useAgents.setState({ entries: new Map([["A", [{ type: "agent", text: "Streaming" }]]]) }));
|
||||
expect(el.scrollTop).toBe(600);
|
||||
el.scrollTop = 588;
|
||||
fireEvent.scroll(el);
|
||||
fireEvent.pointerUp(window);
|
||||
height = 1200;
|
||||
act(() =>
|
||||
useAgents.setState({
|
||||
entries: new Map([["A", [{ type: "agent", text: "More streaming" }]]]),
|
||||
}),
|
||||
);
|
||||
act(() => useAgents.setState({ entries: new Map([["A", [{ type: "agent", text: "More streaming" }]]]) }));
|
||||
expect(el.scrollTop).toBe(588);
|
||||
});
|
||||
|
||||
@@ -848,25 +664,15 @@ describe("scrolling", () => {
|
||||
const el = stream();
|
||||
const output = screen.getByText("Tool output");
|
||||
let height = 1000;
|
||||
Object.defineProperty(el, "scrollHeight", {
|
||||
configurable: true,
|
||||
get: () => height,
|
||||
});
|
||||
Object.defineProperty(el, "clientHeight", {
|
||||
configurable: true,
|
||||
value: 400,
|
||||
});
|
||||
Object.defineProperty(el, "scrollHeight", { configurable: true, get: () => height });
|
||||
Object.defineProperty(el, "clientHeight", { configurable: true, value: 400 });
|
||||
output.style.overflowY = "auto";
|
||||
output.scrollTop = 100;
|
||||
el.scrollTop = 600;
|
||||
fireEvent.scroll(el);
|
||||
fireEvent.wheel(output, { deltaY: -4 });
|
||||
height = 1100;
|
||||
act(() =>
|
||||
useAgents.setState({
|
||||
entries: new Map([["A", [...transcript, { type: "agent", text: "Streaming" }]]]),
|
||||
}),
|
||||
);
|
||||
act(() => useAgents.setState({ entries: new Map([["A", [...transcript, { type: "agent", text: "Streaming" }]]]) }));
|
||||
expect(el.scrollTop).toBe(1100);
|
||||
expect(screen.queryByRole("button", { name: "Jump to Latest" })).toBeNull();
|
||||
});
|
||||
@@ -875,14 +681,8 @@ describe("scrolling", () => {
|
||||
render(<SessionEditor input={input("A")} />);
|
||||
const el = stream();
|
||||
let height = 1000;
|
||||
Object.defineProperty(el, "scrollHeight", {
|
||||
configurable: true,
|
||||
get: () => height,
|
||||
});
|
||||
Object.defineProperty(el, "clientHeight", {
|
||||
configurable: true,
|
||||
value: 400,
|
||||
});
|
||||
Object.defineProperty(el, "scrollHeight", { configurable: true, get: () => height });
|
||||
Object.defineProperty(el, "clientHeight", { configurable: true, value: 400 });
|
||||
fireEvent.wheel(el);
|
||||
el.scrollTop = 100;
|
||||
fireEvent.scroll(el);
|
||||
@@ -891,11 +691,7 @@ describe("scrolling", () => {
|
||||
act(() => useAgents.setState({ entries: new Map([["A", []]]) }));
|
||||
expect(screen.queryByRole("button", { name: "Jump to Latest" })).toBeNull();
|
||||
height = 1400;
|
||||
act(() =>
|
||||
useAgents.setState({
|
||||
entries: new Map([["A", [{ type: "agent", text: "New reply" }]]]),
|
||||
}),
|
||||
);
|
||||
act(() => useAgents.setState({ entries: new Map([["A", [{ type: "agent", text: "New reply" }]]]) }));
|
||||
expect(el.scrollTop).toBe(1400);
|
||||
expect(screen.queryByRole("button", { name: "Jump to Latest" })).toBeNull();
|
||||
});
|
||||
@@ -910,14 +706,8 @@ describe("scrolling", () => {
|
||||
</EditorShownProvider>,
|
||||
);
|
||||
const el = stream();
|
||||
Object.defineProperty(el, "scrollHeight", {
|
||||
configurable: true,
|
||||
value: 3000,
|
||||
});
|
||||
Object.defineProperty(el, "clientHeight", {
|
||||
configurable: true,
|
||||
value: 400,
|
||||
});
|
||||
Object.defineProperty(el, "scrollHeight", { configurable: true, value: 3000 });
|
||||
Object.defineProperty(el, "clientHeight", { configurable: true, value: 400 });
|
||||
// Give the conversation a new tail while the scrollbar is held.
|
||||
fireEvent.pointerDown(el);
|
||||
act(() =>
|
||||
@@ -948,14 +738,8 @@ describe("scrolling", () => {
|
||||
expect(await screen.findByText("Answer A")).toBeInTheDocument();
|
||||
const el = stream();
|
||||
let height = 1000;
|
||||
Object.defineProperty(el, "scrollHeight", {
|
||||
configurable: true,
|
||||
get: () => height,
|
||||
});
|
||||
Object.defineProperty(el, "clientHeight", {
|
||||
configurable: true,
|
||||
value: 400,
|
||||
});
|
||||
Object.defineProperty(el, "scrollHeight", { configurable: true, get: () => height });
|
||||
Object.defineProperty(el, "clientHeight", { configurable: true, value: 400 });
|
||||
el.scrollTop = 600;
|
||||
fireEvent.scroll(el);
|
||||
// The conversation grew after it was scrolled to the end, or WebKit moved the offset back
|
||||
@@ -977,14 +761,8 @@ describe("scrolling", () => {
|
||||
render(<SessionEditor input={input("A")} />);
|
||||
expect(await screen.findByText("Answer A")).toBeInTheDocument();
|
||||
const el = stream();
|
||||
Object.defineProperty(el, "scrollHeight", {
|
||||
configurable: true,
|
||||
value: 1000,
|
||||
});
|
||||
Object.defineProperty(el, "clientHeight", {
|
||||
configurable: true,
|
||||
value: 400,
|
||||
});
|
||||
Object.defineProperty(el, "scrollHeight", { configurable: true, value: 1000 });
|
||||
Object.defineProperty(el, "clientHeight", { configurable: true, value: 400 });
|
||||
el.scrollTop = 600;
|
||||
fireEvent.scroll(el);
|
||||
el.scrollTop = 0;
|
||||
@@ -997,22 +775,13 @@ describe("scrolling", () => {
|
||||
render(<SessionEditor input={input("A")} />);
|
||||
expect(await screen.findByText("Answer A")).toBeInTheDocument();
|
||||
const el = stream();
|
||||
Object.defineProperty(el, "scrollHeight", {
|
||||
configurable: true,
|
||||
value: 1000,
|
||||
});
|
||||
Object.defineProperty(el, "clientHeight", {
|
||||
configurable: true,
|
||||
value: 400,
|
||||
});
|
||||
Object.defineProperty(el, "scrollHeight", { configurable: true, value: 1000 });
|
||||
Object.defineProperty(el, "clientHeight", { configurable: true, value: 400 });
|
||||
fireEvent.wheel(el);
|
||||
el.scrollTop = 100;
|
||||
fireEvent.scroll(el);
|
||||
const jump = await screen.findByRole("button", { name: "Jump to Latest" });
|
||||
Object.defineProperty(el, "scrollTo", {
|
||||
configurable: true,
|
||||
value: vi.fn(),
|
||||
});
|
||||
Object.defineProperty(el, "scrollTo", { configurable: true, value: vi.fn() });
|
||||
fireEvent.click(jump);
|
||||
expect(el.scrollTo).toHaveBeenCalledWith({ top: 1000, behavior: "smooth" });
|
||||
expect(screen.queryByRole("button", { name: "Jump to Latest" })).toBeNull();
|
||||
@@ -1032,11 +801,7 @@ describe("tool calls", () => {
|
||||
});
|
||||
|
||||
it("folds consecutive finished tool calls into one collapsed row", async () => {
|
||||
const edit = (id: string) =>
|
||||
tool(id, {
|
||||
kind: "edit",
|
||||
diffs: [{ path: "/project/a.ts", added: 2, removed: 1 }],
|
||||
});
|
||||
const edit = (id: string) => tool(id, { kind: "edit", diffs: [{ path: "/project/a.ts", added: 2, removed: 1 }] });
|
||||
backend.entries.set("A", [edit("one"), edit("two"), { type: "agent", text: "Answer A" }]);
|
||||
useAgents.setState({ entries: new Map(backend.entries) });
|
||||
render(<SessionEditor input={input("A")} />);
|
||||
@@ -1057,11 +822,7 @@ describe("a pending question", () => {
|
||||
id: 7,
|
||||
title: question,
|
||||
options: [
|
||||
{
|
||||
id: "a",
|
||||
name: "Ship it as is and record it (Recommended)",
|
||||
kind: "allow_once",
|
||||
},
|
||||
{ id: "a", name: "Ship it as is and record it (Recommended)", kind: "allow_once" },
|
||||
{ id: "b", name: "Detect the request in code", kind: "allow_once" },
|
||||
{ id: "c", name: "Skip", kind: "reject_once" },
|
||||
],
|
||||
@@ -1072,9 +833,7 @@ describe("a pending question", () => {
|
||||
backend.answers = [];
|
||||
useAgents.setState({ entries: new Map(backend.entries) });
|
||||
render(<SessionEditor input={input("A")} />);
|
||||
const card = await screen.findByRole("region", {
|
||||
name: "Allow tool call?",
|
||||
});
|
||||
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");
|
||||
@@ -1102,13 +861,7 @@ describe("a pending AskUserQuestion", () => {
|
||||
header: answerKey,
|
||||
question: "Which?",
|
||||
multiSelect: false,
|
||||
options: [
|
||||
{
|
||||
label: index === 0 ? "One" : "Two",
|
||||
description: "",
|
||||
preview: null,
|
||||
},
|
||||
],
|
||||
options: [{ label: index === 0 ? "One" : "Two", description: "", preview: null }],
|
||||
})),
|
||||
},
|
||||
]);
|
||||
@@ -1138,16 +891,8 @@ describe("a pending AskUserQuestion", () => {
|
||||
question: "Which layout?",
|
||||
multiSelect: false,
|
||||
options: [
|
||||
{
|
||||
label: "Table (Recommended)",
|
||||
description: "One row per check",
|
||||
preview: null,
|
||||
},
|
||||
{
|
||||
label: "List",
|
||||
description: "Grouped under headings",
|
||||
preview: null,
|
||||
},
|
||||
{ label: "Table (Recommended)", description: "One row per check", preview: null },
|
||||
{ label: "List", description: "Grouped under headings", preview: null },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -1199,20 +944,11 @@ describe("a pending AskUserQuestion", () => {
|
||||
fireEvent.click(within(card).getByRole("checkbox", { name: /^Disk/ }));
|
||||
fireEvent.click(within(card).getByRole("checkbox", { name: /^Other/ }));
|
||||
expect(submit).toHaveProperty("disabled", true);
|
||||
fireEvent.change(within(card).getByPlaceholderText("Type your answer"), {
|
||||
target: { value: "GPU temps" },
|
||||
});
|
||||
fireEvent.change(within(card).getByPlaceholderText("Type your answer"), { target: { value: "GPU temps" } });
|
||||
fireEvent.click(submit);
|
||||
await waitFor(() =>
|
||||
expect(backend.answers).toEqual([
|
||||
[
|
||||
"A",
|
||||
9,
|
||||
JSON.stringify({
|
||||
"Which layout?": ["List"],
|
||||
"Which checks?": ["Disk", "GPU temps"],
|
||||
}),
|
||||
],
|
||||
["A", 9, JSON.stringify({ "Which layout?": ["List"], "Which checks?": ["Disk", "GPU temps"] })],
|
||||
]),
|
||||
);
|
||||
});
|
||||
@@ -1272,11 +1008,7 @@ describe("a pending permission request above the composer", () => {
|
||||
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-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" },
|
||||
];
|
||||
@@ -1293,20 +1025,11 @@ describe("a pending permission request above the composer", () => {
|
||||
diffs: [],
|
||||
locations: [],
|
||||
},
|
||||
{
|
||||
type: "permission",
|
||||
id: 12,
|
||||
title: `Run ${command}`,
|
||||
command,
|
||||
options,
|
||||
chosen: null,
|
||||
},
|
||||
{ type: "permission", id: 12, title: `Run ${command}`, 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",
|
||||
});
|
||||
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()).not.toContainElement(card);
|
||||
@@ -1325,58 +1048,36 @@ describe("a pending permission request above the composer", () => {
|
||||
});
|
||||
|
||||
it.each(options)("sends the original $kind option id, then records it in the conversation", async (option) => {
|
||||
const pending: AgentEntry = {
|
||||
type: "permission",
|
||||
id: 12,
|
||||
title: `Run ${command}`,
|
||||
command,
|
||||
options,
|
||||
chosen: null,
|
||||
};
|
||||
const pending: AgentEntry = { type: "permission", id: 12, title: `Run ${command}`, 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 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 }]),
|
||||
}),
|
||||
useAgents.setState({ entries: new Map(backend.entries).set("A", [{ ...pending, chosen: option.name }]) }),
|
||||
);
|
||||
expect(card).not.toBeInTheDocument();
|
||||
const record = within(stream()).getByRole("region", {
|
||||
name: "codex wants to run a command",
|
||||
});
|
||||
const record = within(stream()).getByRole("region", { name: "codex wants to run a command" });
|
||||
expect(record.querySelector("pre")).toBeNull();
|
||||
expect(within(record).queryByRole("button")).toBeNull();
|
||||
expect(record.textContent).toContain("python3 …");
|
||||
expect(record.textContent).not.toContain("write_text");
|
||||
await waitFor(() => expect(screen.getByRole("textbox", { name: "Message" })).toHaveFocus());
|
||||
await waitFor(() => expect(document.activeElement?.tagName).toBe("TEXTAREA"));
|
||||
});
|
||||
|
||||
it("is answered with a number key when the focus isn't in a text field", async () => {
|
||||
const pending: AgentEntry = {
|
||||
type: "permission",
|
||||
id: 12,
|
||||
title: `Run ${command}`,
|
||||
command,
|
||||
options,
|
||||
chosen: null,
|
||||
};
|
||||
const pending: AgentEntry = { type: "permission", id: 12, title: `Run ${command}`, command, options, chosen: null };
|
||||
backend.answers = [];
|
||||
backend.entries.set("A", [pending]);
|
||||
useAgents.setState({ entries: new Map(backend.entries) });
|
||||
render(<SessionEditor input={input("A")} />);
|
||||
await screen.findByRole("region", { name: "codex wants to run a command" });
|
||||
fireEvent.keyDown(screen.getByRole("textbox", { name: "Message" }), {
|
||||
key: "1",
|
||||
});
|
||||
fireEvent.keyDown(document.querySelector("textarea")!, { key: "1" });
|
||||
expect(backend.answers).toEqual([]);
|
||||
const elsewhere = document.body.appendChild(document.createElement("div"));
|
||||
fireEvent.keyDown(elsewhere, { key: "1" });
|
||||
@@ -1402,20 +1103,12 @@ describe("a pending permission request above the composer", () => {
|
||||
},
|
||||
]);
|
||||
backend.entries.set("B", [
|
||||
{
|
||||
type: "permission",
|
||||
id: 4,
|
||||
title: "The agent asks for permission",
|
||||
options: [],
|
||||
chosen: null,
|
||||
},
|
||||
{ type: "permission", id: 4, title: "The agent asks for permission", options: [], chosen: null },
|
||||
]);
|
||||
backend.answers = [];
|
||||
useAgents.setState({ entries: new Map(backend.entries) });
|
||||
const { unmount } = render(<SessionEditor input={input("A")} />);
|
||||
const line = await screen.findByRole("region", {
|
||||
name: "codex wants to run a command",
|
||||
});
|
||||
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", "Decline"]);
|
||||
fireEvent.click(answers[0]!);
|
||||
@@ -1423,9 +1116,7 @@ describe("a pending permission request above the composer", () => {
|
||||
unmount();
|
||||
|
||||
render(<SessionEditor input={input("B")} />);
|
||||
const card = await screen.findByRole("region", {
|
||||
name: "Allow tool call?",
|
||||
});
|
||||
const card = await screen.findByRole("region", { name: "Allow tool call?" });
|
||||
fireEvent.click(within(card).getByRole("button", { name: "Cancel" }));
|
||||
await waitFor(() =>
|
||||
expect(backend.answers).toEqual([
|
||||
@@ -1486,9 +1177,7 @@ describe("conversation loading", () => {
|
||||
});
|
||||
|
||||
it("keeps subagent failures separate and retries that subagent", async () => {
|
||||
useAgents.setState({
|
||||
entries: new Map([["A", backend.entries.get("A")!]]),
|
||||
});
|
||||
useAgents.setState({ entries: new Map([["A", backend.entries.get("A")!]]) });
|
||||
backend.entryError = "No such subagent";
|
||||
render(<SessionEditor input={input("A")} />);
|
||||
fireEvent.click(screen.getByRole("button", { name: /Explore task A/ }));
|
||||
@@ -1562,9 +1251,7 @@ describe("session state across tasks and hosts", () => {
|
||||
|
||||
it("opens a long conversation at its end and renders the earlier entries after it", async () => {
|
||||
const entries = Array.from({ length: 150 }, (_, i): AgentEntry => ({ type: "agent", text: `Reply ${i}` }));
|
||||
useAgents.setState({
|
||||
entries: new Map(useAgents.getState().entries).set("A", entries),
|
||||
});
|
||||
useAgents.setState({ entries: new Map(useAgents.getState().entries).set("A", entries) });
|
||||
render(<SessionEditor input={input("A")} />);
|
||||
expect(screen.getByText("Reply 149")).toBeInTheDocument();
|
||||
expect(await screen.findByText("Reply 0")).toBeInTheDocument();
|
||||
@@ -1613,7 +1300,7 @@ describe("session state across tasks and hosts", () => {
|
||||
const markVisible = vi.spyOn(await import("./store"), "markVisible");
|
||||
vi.spyOn(document, "hasFocus").mockReturnValue(true);
|
||||
const message = screen.getByRole("textbox", { name: "Message" });
|
||||
typeIn(message, "Draft to keep");
|
||||
fireEvent.change(message, { target: { value: "Draft to keep" } });
|
||||
const conversation = stream();
|
||||
fireEvent.click(screen.getByRole("button", { name: /1 changed file/ }));
|
||||
expect(screen.getByRole("complementary", { name: "Changes" })).toBeVisible();
|
||||
@@ -1628,7 +1315,7 @@ describe("session state across tasks and hosts", () => {
|
||||
expect(markVisible).toHaveBeenCalledWith("A");
|
||||
expect(useAgents.getState().unread.has("A")).toBe(false);
|
||||
expect(screen.getByRole("textbox", { name: "Message" })).toBe(message);
|
||||
expect(textOf(message)).toBe("Draft to keep");
|
||||
expect(message).toHaveValue("Draft to keep");
|
||||
expect(useAgents.getState().composers.get("A")?.text).toBe("Draft to keep");
|
||||
expect(JSON.parse(localStorage.getItem("openidea.agents.changesOpen.v1")!).A).toBe(true);
|
||||
});
|
||||
@@ -1654,10 +1341,7 @@ describe("session state across tasks and hosts", () => {
|
||||
fireEvent.wheel(stream());
|
||||
stream().scrollTop = stick ? height - viewport : 230;
|
||||
fireEvent.scroll(stream());
|
||||
expect(useAgents.getState().scrollPositions.get("A")).toEqual({
|
||||
top: stick ? 1600 : 230,
|
||||
stick,
|
||||
});
|
||||
expect(useAgents.getState().scrollPositions.get("A")).toEqual({ top: stick ? 1600 : 230, stick });
|
||||
view.unmount();
|
||||
viewport = 150;
|
||||
useAgents.setState({ docked: "A", sessionLocation: "toolWindow" });
|
||||
@@ -1720,39 +1404,24 @@ describe("meters in the task header", () => {
|
||||
it("shows the provider's usage limits by default and each meter as Settings › Tools › Agents › Chat allows", async () => {
|
||||
const reported: AgentTask = {
|
||||
...task("A"),
|
||||
usage: {
|
||||
used: 24_000,
|
||||
size: 200_000,
|
||||
cost: { amount: 0.42, currency: "USD" },
|
||||
},
|
||||
usage: { used: 24_000, size: 200_000, cost: { amount: 0.42, currency: "USD" } },
|
||||
};
|
||||
const limits = [
|
||||
{ window: "five_hour", usedPercent: 19, resetsMs: null },
|
||||
{ window: "seven_day", usedPercent: 9.4, resetsMs: null },
|
||||
];
|
||||
useAgents.setState({
|
||||
tasks: new Map([["A", reported]]),
|
||||
limits: new Map([["codex", limits]]),
|
||||
});
|
||||
useAgents.setState({ tasks: new Map([["A", reported]]), limits: new Map([["codex", limits]]) });
|
||||
render(<SessionEditor input={input("A")} />);
|
||||
await screen.findByText("Answer A");
|
||||
const usage = screen.getByLabelText(/^Context /);
|
||||
expect(usage).toHaveTextContent("24k/200k (12%)");
|
||||
expect(usage.querySelector(`.${styles.usageBar} span`)).toHaveStyle({
|
||||
width: "12%",
|
||||
});
|
||||
expect(usage.querySelector(`.${styles.usageBar} span`)).toHaveStyle({ width: "12%" });
|
||||
expect(screen.getByText("5h 19%")).toBeInTheDocument();
|
||||
expect(screen.getByText("7d 9%")).toBeInTheDocument();
|
||||
expect(screen.queryByText("$0.42")).toBeNull();
|
||||
act(() =>
|
||||
useProject.setState({
|
||||
settings: {
|
||||
agents: {
|
||||
showContextUsage: false,
|
||||
showUsageLimits: false,
|
||||
showCost: true,
|
||||
},
|
||||
},
|
||||
settings: { agents: { showContextUsage: false, showUsageLimits: false, showCost: true } },
|
||||
}),
|
||||
);
|
||||
expect(screen.queryByText("24k/200k (12%)")).toBeNull();
|
||||
@@ -1771,20 +1440,8 @@ describe("sessions from other apps", () => {
|
||||
|
||||
it("lists external sessions in the history popup and imports one as a task", async () => {
|
||||
backend.external = [
|
||||
{
|
||||
provider: "claude",
|
||||
sessionId: "abc",
|
||||
title: "Login fix",
|
||||
cwd: "/work/app",
|
||||
updatedMs: Date.now(),
|
||||
},
|
||||
{
|
||||
provider: "codex",
|
||||
sessionId: "thread-1",
|
||||
title: "Add tests",
|
||||
cwd: null,
|
||||
updatedMs: 0,
|
||||
},
|
||||
{ provider: "claude", sessionId: "abc", title: "Login fix", cwd: "/work/app", updatedMs: Date.now() },
|
||||
{ provider: "codex", sessionId: "thread-1", title: "Add tests", cwd: null, updatedMs: 0 },
|
||||
];
|
||||
await openHistory();
|
||||
expect(screen.getByText("Login fix")).toBeInTheDocument();
|
||||
@@ -1827,17 +1484,7 @@ describe("changes refresh", () => {
|
||||
vi.useFakeTimers();
|
||||
useChangesOptions.setState({ groupByCommit: true });
|
||||
useAgents.setState({
|
||||
tasks: new Map([
|
||||
[
|
||||
"A",
|
||||
{
|
||||
...task("A"),
|
||||
isolation: "worktree",
|
||||
cwd: "/worktrees/a",
|
||||
projectRoot: "/project",
|
||||
},
|
||||
],
|
||||
]),
|
||||
tasks: new Map([["A", { ...task("A"), isolation: "worktree", cwd: "/worktrees/a", projectRoot: "/project" }]]),
|
||||
});
|
||||
render(<SessionEditor input={input("A")} />);
|
||||
await act(async () => {});
|
||||
@@ -1931,17 +1578,12 @@ describe("grouped changes", () => {
|
||||
});
|
||||
const worktreeTask = () => {
|
||||
useChangesOptions.setState({ groupByCommit: true });
|
||||
useAgents.setState({
|
||||
tasks: new Map([["A", { ...task("A"), isolation: "worktree" }]]),
|
||||
});
|
||||
useAgents.setState({ tasks: new Map([["A", { ...task("A"), isolation: "worktree" }]]) });
|
||||
localStorage.setItem("openidea.agents.changesOpen.v1", JSON.stringify({ A: true }));
|
||||
};
|
||||
|
||||
it("puts committed files under their commit and rolls back only the uncommitted ones", async () => {
|
||||
backend.grouped = {
|
||||
uncommitted: [change("a.ts")],
|
||||
commits: [commit({ files: [change("b.ts")] })],
|
||||
};
|
||||
backend.grouped = { uncommitted: [change("a.ts")], commits: [commit({ files: [change("b.ts")] })] };
|
||||
worktreeTask();
|
||||
render(<SessionEditor input={input("A")} />);
|
||||
|
||||
@@ -1978,15 +1620,10 @@ describe("grouped changes", () => {
|
||||
});
|
||||
|
||||
it("accepts the task's committed work too, not only the uncommitted files", async () => {
|
||||
backend.grouped = {
|
||||
uncommitted: [],
|
||||
commits: [commit({ files: [change("b.ts")] })],
|
||||
};
|
||||
backend.grouped = { uncommitted: [], commits: [commit({ files: [change("b.ts")] })] };
|
||||
backend.changes = [change("b.ts")];
|
||||
worktreeTask();
|
||||
useAgents.setState({
|
||||
tasks: new Map([["A", { ...task("A"), isolation: "worktree", status: "ready" }]]),
|
||||
});
|
||||
useAgents.setState({ tasks: new Map([["A", { ...task("A"), isolation: "worktree", status: "ready" }]]) });
|
||||
render(<SessionEditor input={input("A")} />);
|
||||
await screen.findByText("Committed");
|
||||
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import { useLayoutEffect, useState, type RefObject } from "react";
|
||||
import type { VirtualElement } from "@floating-ui/react-dom";
|
||||
import styles from "./Composer.module.css";
|
||||
|
||||
const TEXT_STYLES = [
|
||||
"fontFamily",
|
||||
"fontSize",
|
||||
"fontWeight",
|
||||
"fontStyle",
|
||||
"fontVariant",
|
||||
"lineHeight",
|
||||
"letterSpacing",
|
||||
"wordSpacing",
|
||||
"textIndent",
|
||||
"textAlign",
|
||||
"textTransform",
|
||||
"tabSize",
|
||||
"paddingTop",
|
||||
"paddingRight",
|
||||
"paddingBottom",
|
||||
"paddingLeft",
|
||||
] as const;
|
||||
|
||||
/** A textarea has no text DOM to measure; mirror its wrapping and scroll offset. */
|
||||
function caretRect(textarea: HTMLTextAreaElement, caret: number): DOMRect {
|
||||
const computed = getComputedStyle(textarea);
|
||||
const mirror = document.createElement("div");
|
||||
mirror.className = styles.caretMirror ?? "";
|
||||
for (const property of TEXT_STYLES) mirror.style[property] = computed[property];
|
||||
mirror.style.width = `${textarea.clientWidth}px`;
|
||||
mirror.textContent = textarea.value.slice(0, caret);
|
||||
const marker = document.createElement("span");
|
||||
marker.textContent = textarea.value.slice(caret) || "\u200b";
|
||||
mirror.append(marker);
|
||||
document.body.append(mirror);
|
||||
try {
|
||||
const box = textarea.getBoundingClientRect();
|
||||
const lineHeight = parseFloat(computed.lineHeight) || parseFloat(computed.fontSize) * 1.5;
|
||||
return new DOMRect(
|
||||
box.left + textarea.clientLeft + marker.offsetLeft - textarea.scrollLeft,
|
||||
box.top + textarea.clientTop + marker.offsetTop - textarea.scrollTop,
|
||||
0,
|
||||
lineHeight,
|
||||
);
|
||||
} finally {
|
||||
mirror.remove();
|
||||
}
|
||||
}
|
||||
|
||||
export function useCompletionAnchor(
|
||||
ref: RefObject<HTMLTextAreaElement | null>,
|
||||
caret: number | null,
|
||||
text: string,
|
||||
): VirtualElement | null {
|
||||
const [anchor, setAnchor] = useState<VirtualElement | null>(null);
|
||||
useLayoutEffect(() => {
|
||||
const textarea = ref.current;
|
||||
if (!textarea || caret === null) {
|
||||
setAnchor(null);
|
||||
return;
|
||||
}
|
||||
const update = () =>
|
||||
setAnchor({ contextElement: textarea, getBoundingClientRect: () => caretRect(textarea, caret) });
|
||||
update();
|
||||
textarea.addEventListener("scroll", update);
|
||||
return () => textarea.removeEventListener("scroll", update);
|
||||
}, [ref, caret, text]);
|
||||
return anchor;
|
||||
}
|
||||
@@ -5,15 +5,9 @@
|
||||
*/
|
||||
import { registerActions } from "@/platform/actions";
|
||||
import { useProject } from "@/platform/project";
|
||||
import {
|
||||
registerAgentContextSource,
|
||||
registerMenuGroup,
|
||||
registerOverlay,
|
||||
registerStatusItem,
|
||||
registerToolWindow,
|
||||
} from "@/workbench/contributions";
|
||||
import { registerMenuGroup, registerOverlay, registerStatusItem, registerToolWindow } from "@/workbench/contributions";
|
||||
import { getContext } from "@/platform/context";
|
||||
import { clearProblems, installProblemsEvents, problemsReport, useProblems } from "./problemsStore";
|
||||
import { clearProblems, installProblemsEvents, useProblems } from "./problemsStore";
|
||||
import { installProblemDecorations } from "./decorations";
|
||||
import { ProblemsHeaderActions, ProblemsTabs, ProblemsView, useProblemsHeaderMenu } from "./ProblemsView";
|
||||
import { UsagesTabs, UsagesView, useUsagesHeaderMenu } from "./UsagesView";
|
||||
@@ -51,17 +45,6 @@ export function activate(): () => void {
|
||||
const disposers = [
|
||||
unsubProject,
|
||||
installProblemsEvents(),
|
||||
registerAgentContextSource({
|
||||
id: "problems",
|
||||
title: "Problems",
|
||||
description: "The project's errors and warnings",
|
||||
icon: "problems",
|
||||
resolve: async () => {
|
||||
const root = useProject.getState().project?.root;
|
||||
const text = root ? problemsReport(useProblems.getState().map, root) : null;
|
||||
return text ? { kind: "block", info: "problems", text } : null;
|
||||
},
|
||||
}),
|
||||
reportProblems(),
|
||||
installProblemDecorations(),
|
||||
installServerEvents(),
|
||||
|
||||
@@ -8,7 +8,6 @@ import {
|
||||
fileDiagnostics,
|
||||
filePaths,
|
||||
mergeDiagnostics,
|
||||
problemsReport,
|
||||
profiledProblems,
|
||||
projectCounts,
|
||||
useProblems,
|
||||
@@ -135,19 +134,3 @@ describe("filePaths", () => {
|
||||
expect(filePaths(map, "/p/none.py")).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("problemsReport", () => {
|
||||
it("lists errors before warnings with their places, leaving out hints, up to a limit", () => {
|
||||
let map = mergeDiagnostics(new Map(), "/p/src/a.ts", "tsc", [d(3, "warning", "ts"), d(0, "hint")]);
|
||||
map = mergeDiagnostics(map, "/p/src/b.ts", "tsc", [d(9, "error"), d(1, "error")]);
|
||||
expect(problemsReport(map, "/p")).toBe(
|
||||
[
|
||||
"src/b.ts:2:1: error: line 1 (tsc)",
|
||||
"src/b.ts:10:1: error: line 9 (tsc)",
|
||||
"src/a.ts:4:1: warning: line 3 (ts)",
|
||||
].join("\n"),
|
||||
);
|
||||
expect(problemsReport(map, "/p", 1)).toBe("src/b.ts:2:1: error: line 1 (tsc)\n… 2 more");
|
||||
expect(problemsReport(mergeDiagnostics(new Map(), "/p/a", "x", [d(0, "info")]), "/p")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -188,25 +188,3 @@ export function installProblemsEvents(): () => void {
|
||||
offProfile();
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Errors and warnings as `rel:line:col: severity: message (source)` lines, errors first, at most
|
||||
* `max`; null when there are none. For an agent's context.
|
||||
*/
|
||||
export function problemsReport(map: ProblemsMap, root: string, max = 60): string | null {
|
||||
const rows: { severity: Severity; text: string }[] = [];
|
||||
for (const path of map.keys()) {
|
||||
const rel = path.startsWith(`${root}/`) ? path.slice(root.length + 1) : path;
|
||||
for (const d of fileDiagnostics(map, path)) {
|
||||
if (d.severity !== "error" && d.severity !== "warning") continue;
|
||||
const at = `${rel}:${d.range.start.line + 1}:${d.range.start.col + 1}`;
|
||||
const source = d.source ? ` (${d.source})` : "";
|
||||
rows.push({ severity: d.severity, text: `${at}: ${d.severity}: ${d.message.split("\n")[0]}${source}` });
|
||||
}
|
||||
}
|
||||
if (!rows.length) return null;
|
||||
rows.sort((a, b) => Number(a.severity !== "error") - Number(b.severity !== "error"));
|
||||
const shown = rows.slice(0, max).map((r) => r.text);
|
||||
if (rows.length > max) shown.push(`… ${rows.length - max} more`);
|
||||
return shown.join("\n");
|
||||
}
|
||||
@@ -5,7 +5,6 @@
|
||||
* a terminal feeds its process).
|
||||
*/
|
||||
import { writeClipboardText } from "@/platform/clipboard";
|
||||
import { noteCopySource } from "@/platform/clipboardSource";
|
||||
import { installTerminalDrop } from "./drop";
|
||||
import { installDomRowCache } from "./domRowCache";
|
||||
import { installTerminalPaste } from "./paste";
|
||||
@@ -65,11 +64,7 @@ export function createEmbeddedTerminal(host: HTMLElement, opts: EmbeddedTerminal
|
||||
if (action.kind === "send" && e.key === "Enter") {
|
||||
if (e.type === "keydown") term.input(action.data);
|
||||
} else if (action.kind === "copy") {
|
||||
if (e.type === "keydown") {
|
||||
const text = term.getSelection();
|
||||
noteCopySource(text, { kind: "terminal", title: "Terminal" });
|
||||
void writeClipboardText(text).catch(() => undefined);
|
||||
}
|
||||
if (e.type === "keydown") void writeClipboardText(term.getSelection()).catch(() => undefined);
|
||||
} else {
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -9,7 +9,6 @@ import { useProject } from "@/platform/project";
|
||||
import { onWorkflowSettingsChange } from "@/platform/workflowSettings";
|
||||
import {
|
||||
provideEmbeddedTerminal,
|
||||
registerAgentContextSource,
|
||||
registerEditorKind,
|
||||
registerToolWindow,
|
||||
toolWindowService,
|
||||
@@ -103,16 +102,6 @@ export function activate(): () => void {
|
||||
},
|
||||
])
|
||||
: () => {},
|
||||
registerAgentContextSource({
|
||||
id: "terminal",
|
||||
title: "Terminal",
|
||||
description: "The active terminal's recent output",
|
||||
icon: "terminal",
|
||||
resolve: async () => {
|
||||
const output = terminalRuntime()?.activeSession()?.recentOutput();
|
||||
return output?.text ? { kind: "block", info: `terminal ${output.title}`.trim(), text: output.text } : null;
|
||||
},
|
||||
}),
|
||||
registerToolWindow({
|
||||
id: WINDOW,
|
||||
title: "Terminal",
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
* session survives its tool window being hidden or its pane being re-mounted.
|
||||
*/
|
||||
import { writeClipboardText } from "@/platform/clipboard";
|
||||
import { noteCopySource } from "@/platform/clipboardSource";
|
||||
import { Terminal, type IDisposable, type ILink, type ILinkHandler } from "@xterm/xterm";
|
||||
import { FitAddon } from "@xterm/addon-fit";
|
||||
import { SearchAddon, type ISearchOptions } from "@xterm/addon-search";
|
||||
@@ -779,27 +778,9 @@ export class TerminalSession {
|
||||
this.term.clearSelection();
|
||||
}
|
||||
|
||||
/** The last `max` lines down to the cursor (wrapped rows joined), for an agent's context. */
|
||||
recentOutput(max = 80): { title: string; text: string } {
|
||||
const buffer = this.term.buffer.active;
|
||||
const end = buffer.baseY + buffer.cursorY;
|
||||
const lines: string[] = [];
|
||||
for (let y = Math.max(0, end - max + 1); y <= end; y++) {
|
||||
const line = buffer.getLine(y);
|
||||
if (!line) continue;
|
||||
const text = line.translateToString(true);
|
||||
if (line.isWrapped && lines.length) lines[lines.length - 1] += text;
|
||||
else lines.push(text);
|
||||
}
|
||||
while (lines.length && !lines.at(-1)!.trim()) lines.pop();
|
||||
while (lines.length && !lines[0]!.trim()) lines.shift();
|
||||
return { title: this.snapshot.appTitle ?? this.snapshot.title, text: lines.join("\n") };
|
||||
}
|
||||
|
||||
copySelection(): boolean {
|
||||
const text = this.term.getSelection();
|
||||
if (!text) return false;
|
||||
noteCopySource(text, { kind: "terminal", title: this.snapshot.appTitle ?? this.snapshot.title });
|
||||
void writeClipboardText(text).catch(() => undefined);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
/**
|
||||
* Where the text last copied in the IDE came from (an editor's lines, a terminal), so a paste
|
||||
* of the same text can say so: the agent composer turns it into a reference or a terminal block.
|
||||
*/
|
||||
export type CopySource = { kind: "file"; path: string; from: number; to: number } | { kind: "terminal"; title: string };
|
||||
|
||||
const normalize = (text: string) => text.replace(/\r\n?/g, "\n").replace(/\s+$/, "");
|
||||
|
||||
let last: { text: string; source: CopySource } | null = null;
|
||||
|
||||
export function noteCopySource(text: string, source: CopySource) {
|
||||
const normalized = normalize(text);
|
||||
last = normalized ? { text: normalized, source } : null;
|
||||
}
|
||||
|
||||
/** The source of `text` when it is what was last copied in the IDE. */
|
||||
export function copySourceOf(text: string): CopySource | null {
|
||||
return last && last.text === normalize(text) ? last.source : null;
|
||||
}
|
||||
@@ -1507,30 +1507,6 @@
|
||||
"Archived agent tabs to keep": "Archived agent tabs to keep",
|
||||
"Beyond this many archived agent tabs, the oldest are closed.": "Beyond this many archived agent tabs, the oldest are closed.",
|
||||
"Archived ({count})": "Archived ({count})",
|
||||
"Insert": "Insert",
|
||||
"Drop to reference": "Drop to reference",
|
||||
"Reference {rel}": "Reference {rel}",
|
||||
"Folder reference {name}": "Folder reference {name}",
|
||||
"File reference {name}": "File reference {name}",
|
||||
"File reference {name}, lines {lines}": "File reference {name}, lines {lines}",
|
||||
"Lines {lines}": "Lines {lines}",
|
||||
"Open Editors": "Open Editors",
|
||||
"Enter Folder": "Enter Folder",
|
||||
"Click to edit": "Click to edit",
|
||||
"Show less": "Show less",
|
||||
"Show {more} more lines": "Show {more} more lines",
|
||||
"Terminal": "Terminal",
|
||||
"Code": "Code",
|
||||
"Copy": "Copy",
|
||||
"Nothing to add right now": "Nothing to add right now",
|
||||
"Pasted Text": "Pasted Text",
|
||||
"Files with uncommitted changes": "Files with uncommitted changes",
|
||||
"The project's errors and warnings": "The project's errors and warnings",
|
||||
"The active terminal's recent output": "The active terminal's recent output",
|
||||
"Stack Trace": "Stack Trace",
|
||||
"Context": "Context",
|
||||
"Changes": "Changes",
|
||||
"Problems": "Problems",
|
||||
"no usages": "no usages",
|
||||
"1 usage": "1 usage",
|
||||
"{n} usages": "{n} usages",
|
||||
|
||||
@@ -7904,25 +7904,6 @@
|
||||
"Archived agent tabs to keep": "保留的已归档智能体标签数",
|
||||
"Beyond this many archived agent tabs, the oldest are closed.": "已归档的智能体标签超过这个数量时,最旧的会被关闭。",
|
||||
"Archived ({count})": "归档({count})",
|
||||
"Insert": "插入",
|
||||
"Drop to reference": "松开以引用",
|
||||
"Reference {rel}": "引用 {rel}",
|
||||
"Folder reference {name}": "文件夹引用 {name}",
|
||||
"File reference {name}": "文件引用 {name}",
|
||||
"File reference {name}, lines {lines}": "文件引用 {name},第 {lines} 行",
|
||||
"Lines {lines}": "第 {lines} 行",
|
||||
"Open Editors": "已打开的编辑器",
|
||||
"Enter Folder": "进入文件夹",
|
||||
"Click to edit": "点击编辑",
|
||||
"Show less": "收起",
|
||||
"Show {more} more lines": "展开其余 {more} 行",
|
||||
"Nothing to add right now": "暂时没有可添加的内容",
|
||||
"Pasted Text": "粘贴的文本",
|
||||
"Files with uncommitted changes": "有未提交改动的文件",
|
||||
"The project's errors and warnings": "项目中的错误和警告",
|
||||
"The active terminal's recent output": "当前终端的最近输出",
|
||||
"Stack Trace": "堆栈跟踪",
|
||||
"Context": "上下文",
|
||||
"no usages": "无用法",
|
||||
"1 usage": "1 个用法",
|
||||
"{n} usages": "{n} 个用法",
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
/* The chip surface: shared with the pickers that open (Select's chip variant) and the changes
|
||||
button. Flat like the main toolbar's project and branch widgets: no frame at rest, a toolbar
|
||||
button's hover on the ones that open. */
|
||||
/* The chip surface: shared with the pickers that open (Select's chip variant) and the changes button. */
|
||||
.surface {
|
||||
box-sizing: border-box;
|
||||
height: 22px;
|
||||
border-radius: var(--radius-toolbar-button);
|
||||
background: transparent;
|
||||
color: var(--fg-secondary);
|
||||
border-radius: 11px;
|
||||
background: var(--bg-elev);
|
||||
box-shadow: inset 0 0 0 1px var(--border);
|
||||
color: var(--fg-strong);
|
||||
font-size: var(--fs-small);
|
||||
}
|
||||
/* A read-only fact: same shape and height as the pickers it sits beside. */
|
||||
@@ -17,7 +16,7 @@
|
||||
gap: 4px;
|
||||
max-width: 100%;
|
||||
min-width: 0;
|
||||
padding: 0 6px;
|
||||
padding: 0 8px;
|
||||
line-height: 1;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
/* Equal negative margins preserve native textarea wrapping and caret positions. */
|
||||
.tag {
|
||||
padding: 1px 3px;
|
||||
margin: 0 -3px;
|
||||
border-radius: var(--radius-selection);
|
||||
-webkit-box-decoration-break: clone;
|
||||
box-decoration-break: clone;
|
||||
color: var(--fg-strong);
|
||||
background: color-mix(in srgb, var(--fg) 12%, transparent);
|
||||
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--fg) 20%, transparent);
|
||||
}
|
||||
.accent {
|
||||
color: var(--accent);
|
||||
background: color-mix(in srgb, var(--accent) 20%, transparent);
|
||||
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--accent) 45%, transparent);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import type { ReactNode } from "react";
|
||||
import clsx from "clsx";
|
||||
import styles from "./InlineTag.module.css";
|
||||
|
||||
/** A text-sized tag that preserves glyph positions in mirrored inputs. */
|
||||
export function InlineTag({ children, accent = false }: { children: ReactNode; accent?: boolean }) {
|
||||
return <span className={clsx(styles.tag, accent && styles.accent)}>{children}</span>;
|
||||
}
|
||||
@@ -41,12 +41,11 @@
|
||||
/* A picker that reads as a chip beside the facts it belongs with (Chip). */
|
||||
.chip {
|
||||
composes: surface from "./Chip.module.css";
|
||||
padding: 0 4px 0 6px;
|
||||
padding: 0 4px 0 8px;
|
||||
}
|
||||
.chip:hover,
|
||||
.chip[aria-expanded="true"] {
|
||||
background: var(--bg-toolbar-hover);
|
||||
color: var(--fg-strong);
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
.chip:focus-visible {
|
||||
outline: none;
|
||||
|
||||
@@ -43,4 +43,5 @@ export { lockDragCursor, type DragCursor } from "./dragCursor";
|
||||
export { useSanitizedField } from "./useSanitizedField";
|
||||
export * from "./rowSelection";
|
||||
export * from "./LogPane";
|
||||
export * from "./InlineTag";
|
||||
export * from "./Chip";
|
||||
@@ -812,35 +812,6 @@ let commitServiceImpl: CommitService | undefined;
|
||||
export const provideCommitService = (s: CommitService | undefined) => void (commitServiceImpl = s);
|
||||
export const maybeCommitService = (): CommitService | undefined => commitServiceImpl;
|
||||
|
||||
/**
|
||||
* Context a feature offers the agent composer's `@` menu (`@problems`, `@terminal`): what
|
||||
* choosing it inserts, a ``` block (`info` names its kind: `problems`, `terminal <title>`)
|
||||
* or references to files.
|
||||
*/
|
||||
export type AgentContext = { kind: "block"; info: string; text: string } | { kind: "files"; paths: string[] };
|
||||
|
||||
export interface AgentContextSource {
|
||||
/** Typed after `@`. */
|
||||
id: string;
|
||||
/** English: shown through the text catalog. */
|
||||
title: string;
|
||||
description: string;
|
||||
icon: string;
|
||||
/** What to insert now; null when there is nothing (no problems, no terminal). */
|
||||
resolve(): Promise<AgentContext | null>;
|
||||
}
|
||||
|
||||
const agentContextSources = new Map<string, AgentContextSource>();
|
||||
|
||||
export function registerAgentContextSource(source: AgentContextSource): () => void {
|
||||
agentContextSources.set(source.id, source);
|
||||
return () => {
|
||||
if (agentContextSources.get(source.id) === source) agentContextSources.delete(source.id);
|
||||
};
|
||||
}
|
||||
|
||||
export const getAgentContextSources = (): AgentContextSource[] => [...agentContextSources.values()];
|
||||
|
||||
/** A before-commit check contributed by an extension (a plugin's `commitChecks`). */
|
||||
export interface ContributedCommitCheck {
|
||||
/** Stable key (`<plugin id>/<check id>`), stored when the check is turned off. */
|
||||
|
||||
Reference in new issue
Block a user