Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
60e57fde31 | ||
|
|
bc0fd6ee60 | ||
|
|
ed5a088299 | ||
|
|
9c77965c51 | ||
|
|
49df4c2e9e | ||
|
|
65d7186e1c | ||
|
|
47654aa434 |
+4
-3
@@ -663,8 +663,9 @@ export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): S
|
|||||||
applyNav();
|
applyNav();
|
||||||
};
|
};
|
||||||
|
|
||||||
// Hover previews the row under the pointer (docs/PREVIEW.md). Touch is left
|
// Pointer hover previews only while Ctrl is held — plain hover was too
|
||||||
// out: there is no hover there, and a tap already opens the session.
|
// disruptive when sweeping the sidebar. Keyboard walk (j/k) still previews
|
||||||
|
// unconditionally via applyNav.
|
||||||
let hovered: string | null = null;
|
let hovered: string | null = null;
|
||||||
const setHovered = (id: string | null) => {
|
const setHovered = (id: string | null) => {
|
||||||
if (hovered === id) return;
|
if (hovered === id) return;
|
||||||
@@ -672,7 +673,7 @@ export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): S
|
|||||||
opts.onPreview?.(id);
|
opts.onPreview?.(id);
|
||||||
};
|
};
|
||||||
treeHost.addEventListener("pointerover", (e) => {
|
treeHost.addEventListener("pointerover", (e) => {
|
||||||
if (e.pointerType === "touch") return;
|
if (e.pointerType === "touch" || !e.ctrlKey) return;
|
||||||
const row = (e.target as HTMLElement | null)?.closest<HTMLElement>(".tree-leaf");
|
const row = (e.target as HTMLElement | null)?.closest<HTMLElement>(".tree-leaf");
|
||||||
setHovered(row?.dataset.sessionId ?? null);
|
setHovered(row?.dataset.sessionId ?? null);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -182,26 +182,32 @@ describe("sidebar preview requests (docs/PREVIEW.md)", () => {
|
|||||||
expect(seen.at(-1)).toBe(null);
|
expect(seen.at(-1)).toBe(null);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("asks for a preview of the hovered card, and clears it on the way out", () => {
|
it("ctrl+hover previews a card, plain hover does not", () => {
|
||||||
const seen: (string | null)[] = [];
|
const seen: (string | null)[] = [];
|
||||||
const { el } = twoFolders({ onPreview: (id) => seen.push(id) });
|
const { el } = twoFolders({ onPreview: (id) => seen.push(id) });
|
||||||
const card = el.querySelectorAll<HTMLElement>(".tree-leaf")[0];
|
const card = el.querySelectorAll<HTMLElement>(".tree-leaf")[0];
|
||||||
|
|
||||||
|
// Plain hover — no preview.
|
||||||
card.dispatchEvent(new PointerEvent("pointerover", { bubbles: true, pointerType: "mouse" }));
|
card.dispatchEvent(new PointerEvent("pointerover", { bubbles: true, pointerType: "mouse" }));
|
||||||
|
expect(seen).toEqual([]);
|
||||||
|
|
||||||
|
// Ctrl+hover — preview fires.
|
||||||
|
card.dispatchEvent(new PointerEvent("pointerover", { bubbles: true, pointerType: "mouse", ctrlKey: true }));
|
||||||
expect(seen).toEqual([A]);
|
expect(seen).toEqual([A]);
|
||||||
// Moving within the same card is not a new preview.
|
|
||||||
card.dispatchEvent(new PointerEvent("pointerover", { bubbles: true, pointerType: "mouse" }));
|
// Deduplicated while still on the same card.
|
||||||
|
card.dispatchEvent(new PointerEvent("pointerover", { bubbles: true, pointerType: "mouse", ctrlKey: true }));
|
||||||
expect(seen).toHaveLength(1);
|
expect(seen).toHaveLength(1);
|
||||||
|
|
||||||
tree(el).dispatchEvent(new PointerEvent("pointerleave", { pointerType: "mouse" }));
|
tree(el).dispatchEvent(new PointerEvent("pointerleave", { pointerType: "mouse" }));
|
||||||
expect(seen.at(-1)).toBe(null);
|
expect(seen.at(-1)).toBe(null);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("ignores touch, where a tap already opens the session", () => {
|
it("ignores touch even with ctrl", () => {
|
||||||
const seen: (string | null)[] = [];
|
const seen: (string | null)[] = [];
|
||||||
const { el } = twoFolders({ onPreview: (id) => seen.push(id) });
|
const { el } = twoFolders({ onPreview: (id) => seen.push(id) });
|
||||||
const card = el.querySelectorAll<HTMLElement>(".tree-leaf")[0];
|
const card = el.querySelectorAll<HTMLElement>(".tree-leaf")[0];
|
||||||
card.dispatchEvent(new PointerEvent("pointerover", { bubbles: true, pointerType: "touch" }));
|
card.dispatchEvent(new PointerEvent("pointerover", { bubbles: true, pointerType: "touch", ctrlKey: true }));
|
||||||
expect(seen).toEqual([]);
|
expect(seen).toEqual([]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+53
-24
@@ -11,7 +11,7 @@ import { attachDirAutocomplete } from "./dirAutocomplete.js";
|
|||||||
import { parentDir } from "./paths.js";
|
import { parentDir } from "./paths.js";
|
||||||
import { filterProjects, groupProjects, isStale, sortProjects } from "./projectList.js";
|
import { filterProjects, groupProjects, isStale, sortProjects } from "./projectList.js";
|
||||||
import type { ProjectGroup } from "./projectList.js";
|
import type { ProjectGroup } from "./projectList.js";
|
||||||
import { splitCommand } from "./toolIcon.js";
|
import { splitCommand, toolIconSvg, TOOL_LABELS } from "./toolIcon.js";
|
||||||
import { abbreviateCwd } from "./types.js";
|
import { abbreviateCwd } from "./types.js";
|
||||||
import type { BranchInfo, CreateSessionRequest, Project, Session, SpawnPreset } from "./types.js";
|
import type { BranchInfo, CreateSessionRequest, Project, Session, SpawnPreset } from "./types.js";
|
||||||
|
|
||||||
@@ -140,6 +140,12 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
|
|||||||
const form = document.createElement("form");
|
const form = document.createElement("form");
|
||||||
form.className = "spawn-form";
|
form.className = "spawn-form";
|
||||||
form.innerHTML = `
|
form.innerHTML = `
|
||||||
|
<div class="field">
|
||||||
|
<span class="field-label">Harness</span>
|
||||||
|
<div class="harness-grid cmd-tiles"></div>
|
||||||
|
<input class="raw-input cmd-raw" name="command_raw" type="text" hidden
|
||||||
|
placeholder="bash -l" autocomplete="off" />
|
||||||
|
</div>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<span class="field-label">Project</span>
|
<span class="field-label">Project</span>
|
||||||
<input class="raw-input project-filter" type="text" placeholder="Filter projects…"
|
<input class="raw-input project-filter" type="text" placeholder="Filter projects…"
|
||||||
@@ -151,12 +157,6 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
|
|||||||
placeholder="/home/user/project" inputmode="text" autocomplete="off" />
|
placeholder="/home/user/project" inputmode="text" autocomplete="off" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="field">
|
|
||||||
<span class="field-label">Command</span>
|
|
||||||
<div class="tile-row cmd-tiles"></div>
|
|
||||||
<input class="raw-input cmd-raw" name="command_raw" type="text" hidden
|
|
||||||
placeholder="bash -l" autocomplete="off" />
|
|
||||||
</div>
|
|
||||||
<div class="advanced">
|
<div class="advanced">
|
||||||
<button type="button" class="tree-folder-header wt-toggle" aria-expanded="false">
|
<button type="button" class="tree-folder-header wt-toggle" aria-expanded="false">
|
||||||
<span class="chevron">▾</span>
|
<span class="chevron">▾</span>
|
||||||
@@ -348,37 +348,69 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
|
|||||||
command = preset!.command;
|
command = preset!.command;
|
||||||
}
|
}
|
||||||
updateStructuredAvailability();
|
updateStructuredAvailability();
|
||||||
for (const tile of cmdTiles.querySelectorAll(".tile")) {
|
for (const card of cmdTiles.querySelectorAll(".harness-card")) {
|
||||||
tile.classList.toggle("is-active", (tile as HTMLElement).dataset.id === id);
|
card.classList.toggle("is-active", (card as HTMLElement).dataset.id === id);
|
||||||
}
|
}
|
||||||
emit();
|
emit();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Re-highlight the operator's already-picked tile after `renderCommandTiles`
|
/** Re-highlight the operator's already-picked card after `renderCommandTiles`
|
||||||
* rebuilds the row from scratch (new tiles carry no `is-active` class) —
|
* rebuilds the grid from scratch (new cards carry no `is-active` class) —
|
||||||
* without touching `command`, since that was already settled. */
|
* without touching `command`, since that was already settled. */
|
||||||
function reapplyActiveTile(): void {
|
function reapplyActiveTile(): void {
|
||||||
for (const tile of cmdTiles.querySelectorAll(".tile")) {
|
for (const card of cmdTiles.querySelectorAll(".harness-card")) {
|
||||||
tile.classList.toggle("is-active", (tile as HTMLElement).dataset.id === selectedPresetId);
|
card.classList.toggle("is-active", (card as HTMLElement).dataset.id === selectedPresetId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Resolve a preset id to a tool icon id. The preset's command is the
|
||||||
|
* source of truth — `splitCommand` extracts the tool the same way the
|
||||||
|
* sidebar does. For the "Custom…" preset (empty command) there is no
|
||||||
|
* tool to show, and a terminal prompt stands in. */
|
||||||
|
function presetToolId(p: SpawnPreset): string | null {
|
||||||
|
if (!p.command) return null;
|
||||||
|
return splitCommand(p.command).tool;
|
||||||
|
}
|
||||||
|
|
||||||
function renderCommandTiles(): void {
|
function renderCommandTiles(): void {
|
||||||
cmdTiles.replaceChildren();
|
cmdTiles.replaceChildren();
|
||||||
for (const p of presets) {
|
for (const p of presets) {
|
||||||
const tile = document.createElement("button");
|
const isCustom = p.command === "";
|
||||||
tile.type = "button";
|
const card = document.createElement("button");
|
||||||
tile.className = "tile";
|
card.type = "button";
|
||||||
tile.dataset.id = p.id;
|
card.className = isCustom ? "harness-card harness-custom" : "harness-card";
|
||||||
tile.innerHTML = `<span class="tile-label">${escapeHtml(p.label)}</span>`;
|
card.dataset.id = p.id;
|
||||||
tile.addEventListener("click", () => {
|
const toolId = presetToolId(p);
|
||||||
|
const iconHtml = isCustom ? customIcon() : toolId ? harnessIcon(toolId) : harnessIcon("shell");
|
||||||
|
const label = TOOL_LABELS[toolId ?? ""] ?? p.label;
|
||||||
|
card.innerHTML =
|
||||||
|
`<span class="harness-icon">${iconHtml}</span>` +
|
||||||
|
`<span class="harness-label">${escapeHtml(isCustom ? "Custom\u2026" : label)}</span>`;
|
||||||
|
card.addEventListener("click", () => {
|
||||||
commandTouched = true;
|
commandTouched = true;
|
||||||
selectPreset(p.id);
|
selectPreset(p.id);
|
||||||
});
|
});
|
||||||
cmdTiles.appendChild(tile);
|
cmdTiles.appendChild(card);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Render a harness card icon at card size (24x24). Re-uses the SVG bodies
|
||||||
|
* from toolIcon.ts but at a larger viewport so the marks breathe. */
|
||||||
|
function harnessIcon(toolId: string): string {
|
||||||
|
// toolIconSvg renders at 13x13 with viewBox 0 0 16 16 — we want the
|
||||||
|
// same shape at 24x24 for the card context.
|
||||||
|
const small = toolIconSvg(toolId);
|
||||||
|
if (!small) return "";
|
||||||
|
return small
|
||||||
|
.replace(/width="13"/g, 'width="24"')
|
||||||
|
.replace(/height="13"/g, 'height="24"');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** An ellipsis mark for the "Custom…" escape hatch. */
|
||||||
|
function customIcon(): string {
|
||||||
|
return `<svg class="tool-icon" viewBox="0 0 16 16" width="24" height="24" role="img" aria-label="Custom command"><title>Custom command</title><g fill="currentColor"><circle cx="4" cy="8" r="1.3"/><circle cx="8" cy="8" r="1.3"/><circle cx="12" cy="8" r="1.3"/></g></svg>`;
|
||||||
|
}
|
||||||
|
|
||||||
// Set once the operator picks a tile or types a custom command — guards
|
// Set once the operator picks a tile or types a custom command — guards
|
||||||
// `applyInitialCommand()` the same way `nameTouched` guards a late name
|
// `applyInitialCommand()` the same way `nameTouched` guards a late name
|
||||||
// batch: a late `/api/info` resolution must never overwrite a field the
|
// batch: a late `/api/info` resolution must never overwrite a field the
|
||||||
@@ -648,16 +680,13 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
|
|||||||
box.type = "button";
|
box.type = "button";
|
||||||
box.className = `project-box${isStale(project, new Date()) ? " is-stale" : ""}`;
|
box.className = `project-box${isStale(project, new Date()) ? " is-stale" : ""}`;
|
||||||
box.dataset.cwd = project.path;
|
box.dataset.cwd = project.path;
|
||||||
const trees = worktreesOf(group).length;
|
|
||||||
// The parent directory only — the last segment is the name line above it.
|
// The parent directory only — the last segment is the name line above it.
|
||||||
// Unabbreviated beyond `~`, since the stem is what tells two projects
|
// Unabbreviated beyond `~`, since the stem is what tells two projects
|
||||||
// sharing a basename apart (#95).
|
// sharing a basename apart (#95).
|
||||||
const parent = abbreviateCwd(parentDir(project.path), homeDir);
|
const parent = abbreviateCwd(parentDir(project.path), homeDir);
|
||||||
const meta = trees > 0 ? `${trees} worktree${trees === 1 ? "" : "s"}` : "";
|
|
||||||
box.innerHTML = `
|
box.innerHTML = `
|
||||||
<span class="project-name">${escapeHtml(project.name)}</span>
|
<span class="project-name">${escapeHtml(project.name)}</span>
|
||||||
<span class="project-path">${escapeHtml(parent)}</span>
|
<span class="project-path">${escapeHtml(parent)}</span>`;
|
||||||
<span class="project-meta">${escapeHtml(meta)}</span>`;
|
|
||||||
box.addEventListener("click", () => selectProject(group));
|
box.addEventListener("click", () => selectProject(group));
|
||||||
return box;
|
return box;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -139,7 +139,7 @@ describe("spawnForm Enter-to-submit (#97)", () => {
|
|||||||
const form = await mount([MAESTRO]);
|
const form = await mount([MAESTRO]);
|
||||||
box(form, MAESTRO.path).click();
|
box(form, MAESTRO.path).click();
|
||||||
await drain();
|
await drain();
|
||||||
form.querySelector<HTMLButtonElement>('.cmd-tiles .tile[data-id="custom"]')!.click();
|
form.querySelector<HTMLButtonElement>('.cmd-tiles .harness-card[data-id="custom"]')!.click();
|
||||||
const raw = form.querySelector<HTMLInputElement>(".cmd-raw")!;
|
const raw = form.querySelector<HTMLInputElement>(".cmd-raw")!;
|
||||||
raw.value = "bash -l";
|
raw.value = "bash -l";
|
||||||
raw.dispatchEvent(new Event("input"));
|
raw.dispatchEvent(new Event("input"));
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ describe("spawnForm preset selection vs. late /api/info (#76)", () => {
|
|||||||
await drain();
|
await drain();
|
||||||
|
|
||||||
// Fallback presets are up; the operator picks "zsh" before /api/info lands.
|
// Fallback presets are up; the operator picks "zsh" before /api/info lands.
|
||||||
const zshTile = [...form.querySelectorAll<HTMLButtonElement>(".tile")].find(
|
const zshTile = [...form.querySelectorAll<HTMLButtonElement>(".harness-card")].find(
|
||||||
(t) => t.dataset.id === "zsh",
|
(t) => t.dataset.id === "zsh",
|
||||||
)!;
|
)!;
|
||||||
zshTile.click();
|
zshTile.click();
|
||||||
@@ -88,7 +88,7 @@ describe("spawnForm preset selection vs. late /api/info (#76)", () => {
|
|||||||
document.body.appendChild(form);
|
document.body.appendChild(form);
|
||||||
await drain();
|
await drain();
|
||||||
|
|
||||||
const customTile = [...form.querySelectorAll<HTMLButtonElement>(".tile")].find(
|
const customTile = [...form.querySelectorAll<HTMLButtonElement>(".harness-card")].find(
|
||||||
(t) => t.dataset.id === "custom",
|
(t) => t.dataset.id === "custom",
|
||||||
)!;
|
)!;
|
||||||
customTile.click();
|
customTile.click();
|
||||||
@@ -116,7 +116,7 @@ describe("spawnForm preset selection vs. late /api/info (#76)", () => {
|
|||||||
|
|
||||||
// Untouched: the refined preset list is still free to settle the command.
|
// Untouched: the refined preset list is still free to settle the command.
|
||||||
expect(values?.command).toBe("claude");
|
expect(values?.command).toBe("claude");
|
||||||
const fishTile = [...form.querySelectorAll<HTMLButtonElement>(".tile")].find(
|
const fishTile = [...form.querySelectorAll<HTMLButtonElement>(".harness-card")].find(
|
||||||
(t) => t.dataset.id === "fish",
|
(t) => t.dataset.id === "fish",
|
||||||
);
|
);
|
||||||
expect(fishTile).toBeDefined();
|
expect(fishTile).toBeDefined();
|
||||||
|
|||||||
@@ -52,14 +52,14 @@ describe("spawn modal", () => {
|
|||||||
expect(host.querySelector(".form-actions .cancel")).toBeTruthy();
|
expect(host.querySelector(".form-actions .cancel")).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("opens with the project filter focused, ahead of the command tiles", async () => {
|
it("opens with the project filter focused, harness before project", async () => {
|
||||||
openSpawnModal({ onSuccess: () => {} });
|
openSpawnModal({ onSuccess: () => {} });
|
||||||
await flush();
|
await flush();
|
||||||
|
|
||||||
const form = modal()!.querySelector("form.spawn-form")!;
|
const form = modal()!.querySelector("form.spawn-form")!;
|
||||||
expect(document.activeElement).toBe(form.querySelector(".project-filter"));
|
expect(document.activeElement).toBe(form.querySelector(".project-filter"));
|
||||||
const fields = [...form.querySelectorAll(".field .field-label")].map((el) => el.textContent);
|
const fields = [...form.querySelectorAll(".field .field-label")].map((el) => el.textContent);
|
||||||
expect(fields.slice(0, 2)).toEqual(["Project", "Command"]);
|
expect(fields.slice(0, 2)).toEqual(["Harness", "Project"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("titles a seeded open as a clone", async () => {
|
it("titles a seeded open as a clone", async () => {
|
||||||
|
|||||||
@@ -933,7 +933,7 @@ describe("structured connection: approvals", () => {
|
|||||||
conn.dispose();
|
conn.dispose();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("removes the buttons once the approval resolves, even with no accompanying transcript delta", () => {
|
it("hides a standalone resolved approval entirely when no tool group absorbs it", () => {
|
||||||
const ws = new FakeSocket();
|
const ws = new FakeSocket();
|
||||||
const conn = mount({ sockets: [ws] });
|
const conn = mount({ sockets: [ws] });
|
||||||
ws.open();
|
ws.open();
|
||||||
@@ -949,7 +949,68 @@ describe("structured connection: approvals", () => {
|
|||||||
// Resolved on another client: state clears with no transcript delta at all.
|
// Resolved on another client: state clears with no transcript delta at all.
|
||||||
ws.emit({ type: "reduced_state", state: idleState });
|
ws.emit({ type: "reduced_state", state: idleState });
|
||||||
expect(conn.el.querySelector(".st-approval-acts")).toBeNull();
|
expect(conn.el.querySelector(".st-approval-acts")).toBeNull();
|
||||||
expect(conn.el.querySelector(".st-approval-resolved")).not.toBeNull();
|
// Standalone resolved approval is hidden — no card, no compact line.
|
||||||
|
expect(conn.el.querySelector(".st-row-approval")).toBeNull();
|
||||||
|
conn.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("absorbs a resolved approval into the tool group as a compact line", () => {
|
||||||
|
const ws = new FakeSocket();
|
||||||
|
const conn = mount({ sockets: [ws] });
|
||||||
|
ws.open();
|
||||||
|
ws.emit({
|
||||||
|
type: "transcript_snapshot",
|
||||||
|
snapshot: {
|
||||||
|
cursor: 0,
|
||||||
|
rows: [
|
||||||
|
row("user:1", "user", { content: { text: "go" } }),
|
||||||
|
toolRow("tool:1", "first"),
|
||||||
|
row("approval:1", "approval", { toolCall: { title: "Write file.ts" }, options: allApprovalOptions }),
|
||||||
|
toolRow("tool:2", "second"),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
ws.emit({ type: "reduced_state", state: idleState });
|
||||||
|
// 2+ tools in the turn → tool group exists; resolved approval is inside it.
|
||||||
|
const group = conn.el.querySelector(".st-tool-group");
|
||||||
|
expect(group).not.toBeNull();
|
||||||
|
const compact = group!.querySelector(".st-approval-compact");
|
||||||
|
expect(compact).not.toBeNull();
|
||||||
|
expect(compact!.textContent).toContain("Write file.ts");
|
||||||
|
expect(compact!.textContent).toContain("approved");
|
||||||
|
// No full-card approval anywhere on the transcript.
|
||||||
|
expect(conn.el.querySelector(".st-approval")).toBeNull();
|
||||||
|
conn.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps a pending approval standalone even when a tool group exists in the same turn", () => {
|
||||||
|
const ws = new FakeSocket();
|
||||||
|
const conn = mount({ sockets: [ws] });
|
||||||
|
ws.open();
|
||||||
|
ws.emit({
|
||||||
|
type: "transcript_snapshot",
|
||||||
|
snapshot: {
|
||||||
|
cursor: 0,
|
||||||
|
rows: [
|
||||||
|
row("user:1", "user", { content: { text: "go" } }),
|
||||||
|
toolRow("tool:1", "first"),
|
||||||
|
row("approval:1", "approval", { toolCall: { title: "Write file.ts" }, options: allApprovalOptions }),
|
||||||
|
toolRow("tool:2", "second"),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
ws.emit({
|
||||||
|
type: "reduced_state",
|
||||||
|
state: { ...idleState, pending_approval: { nonce: "n1", request: { sessionId: "s1", toolCall: {}, options: allApprovalOptions } } },
|
||||||
|
});
|
||||||
|
// Pending approval stays outside the tool group as a full card.
|
||||||
|
const approvalCard = conn.el.querySelector(".st-approval");
|
||||||
|
expect(approvalCard).not.toBeNull();
|
||||||
|
expect(conn.el.querySelectorAll(".st-approval-acts button").length).toBeGreaterThan(0);
|
||||||
|
// It should not be inside the tool group.
|
||||||
|
const group = conn.el.querySelector(".st-tool-group");
|
||||||
|
expect(group).not.toBeNull();
|
||||||
|
expect(group!.querySelector(".st-approval")).toBeNull();
|
||||||
conn.dispose();
|
conn.dispose();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
+37
-16
@@ -402,13 +402,15 @@ export function createStructuredConnection(
|
|||||||
return el;
|
return el;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Group tool rows (and intermediate agent text between them) by turn.
|
/** Group tool rows (and intermediate agent text / resolved approvals between
|
||||||
* An agent_text/agent_thought row is "intermediate" when a tool row follows
|
* them) by turn. An agent_text/agent_thought row is "intermediate" when a
|
||||||
* it in the same turn — it gets absorbed into the group so the collapsed
|
* tool row follows it in the same turn — it gets absorbed into the group so
|
||||||
* block preserves chronological order. The *last* agent text with no
|
* the collapsed block preserves chronological order. Resolved approval rows
|
||||||
* subsequent tool stays standalone (the prominent final answer).
|
* (not the currently pending one) are absorbed the same way. The *last*
|
||||||
* Returns groups keyed by `turn:<userId>`, each with `toolCount` (for the
|
* agent text with no subsequent tool stays standalone (the prominent final
|
||||||
* header label) and `rows` (tools + intermediate text, in order). */
|
* answer). Returns groups keyed by `turn:<userId>`, each with `toolCount`
|
||||||
|
* (for the header label) and `rows` (tools + intermediate text/approvals,
|
||||||
|
* in order). */
|
||||||
function activityGroupsByTurn(): Map<string, { toolCount: number; rows: TranscriptRow[] }> {
|
function activityGroupsByTurn(): Map<string, { toolCount: number; rows: TranscriptRow[] }> {
|
||||||
// First: count tools per turn to know which turns qualify for grouping.
|
// First: count tools per turn to know which turns qualify for grouping.
|
||||||
const toolCounts = new Map<string, number>();
|
const toolCounts = new Map<string, number>();
|
||||||
@@ -422,33 +424,35 @@ export function createStructuredConnection(
|
|||||||
// Second: build groups with interleaved text for turns with 2+ tools.
|
// Second: build groups with interleaved text for turns with 2+ tools.
|
||||||
const groups = new Map<string, { toolCount: number; rows: TranscriptRow[] }>();
|
const groups = new Map<string, { toolCount: number; rows: TranscriptRow[] }>();
|
||||||
turnId = "before-first-message";
|
turnId = "before-first-message";
|
||||||
// Pending text rows that might be intermediate (not yet confirmed by a
|
// Pending rows that might be intermediate (not yet confirmed by a
|
||||||
// following tool). If no tool follows, they stay standalone.
|
// following tool). If no tool follows, they stay standalone.
|
||||||
let pendingText: TranscriptRow[] = [];
|
let pending: TranscriptRow[] = [];
|
||||||
let currentGid = `turn:${turnId}`;
|
let currentGid = `turn:${turnId}`;
|
||||||
for (const row of rows) {
|
for (const row of rows) {
|
||||||
if (row.kind === "user") {
|
if (row.kind === "user") {
|
||||||
turnId = row.id;
|
turnId = row.id;
|
||||||
currentGid = `turn:${turnId}`;
|
currentGid = `turn:${turnId}`;
|
||||||
pendingText = [];
|
pending = [];
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const count = toolCounts.get(currentGid) ?? 0;
|
const count = toolCounts.get(currentGid) ?? 0;
|
||||||
if (count < 2) continue;
|
if (count < 2) continue;
|
||||||
if (row.kind === "agent_text" || row.kind === "agent_thought") {
|
const isIntermediateText = row.kind === "agent_text" || row.kind === "agent_thought";
|
||||||
pendingText.push(row);
|
const isResolvedApproval = row.kind === "approval" && row.id !== pendingApprovalRowId;
|
||||||
|
if (isIntermediateText || isResolvedApproval) {
|
||||||
|
pending.push(row);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (row.kind === "tool_call" || row.kind === "tool_diff") {
|
if (row.kind === "tool_call" || row.kind === "tool_diff") {
|
||||||
const group = groups.get(currentGid) ?? { toolCount: count, rows: [] };
|
const group = groups.get(currentGid) ?? { toolCount: count, rows: [] };
|
||||||
// Flush any pending text — a tool follows, so it's intermediate.
|
// Flush any pending rows — a tool follows, so they're intermediate.
|
||||||
group.rows.push(...pendingText);
|
group.rows.push(...pending);
|
||||||
pendingText = [];
|
pending = [];
|
||||||
group.rows.push(row);
|
group.rows.push(row);
|
||||||
groups.set(currentGid, group);
|
groups.set(currentGid, group);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// pendingText at end of a turn = final text, stays outside (not flushed).
|
// pending at end of a turn = final text/approvals, stays outside (not flushed).
|
||||||
return groups;
|
return groups;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -500,6 +504,10 @@ export function createStructuredConnection(
|
|||||||
children.push(groupEl);
|
children.push(groupEl);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
// Resolved approvals not absorbed into a tool group are hidden — the
|
||||||
|
// tool row itself already identifies the action, so a standalone
|
||||||
|
// "answered" card adds nothing. Pending approvals always render.
|
||||||
|
if (row.kind === "approval" && row.id !== pendingApprovalRowId) continue;
|
||||||
const rowEl = buildRowElement(row);
|
const rowEl = buildRowElement(row);
|
||||||
if (!rowEl) continue;
|
if (!rowEl) continue;
|
||||||
rowEls.set(row.id, rowEl);
|
rowEls.set(row.id, rowEl);
|
||||||
@@ -551,6 +559,19 @@ export function createStructuredConnection(
|
|||||||
rows.push(delta.row);
|
rows.push(delta.row);
|
||||||
} else {
|
} else {
|
||||||
rows[i] = delta.row;
|
rows[i] = delta.row;
|
||||||
|
// Surgical DOM swap: replace just this row's element instead of
|
||||||
|
// tearing down and rebuilding the entire transcript, which causes
|
||||||
|
// visible flashing and breaks text selection.
|
||||||
|
const oldEl = rowEls.get(delta.row.id);
|
||||||
|
if (oldEl) {
|
||||||
|
const newEl = buildRowElement(delta.row);
|
||||||
|
if (newEl) {
|
||||||
|
oldEl.replaceWith(newEl);
|
||||||
|
rowEls.set(delta.row.id, newEl);
|
||||||
|
}
|
||||||
|
syncAvailableCommands();
|
||||||
|
return;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
const i = rowIndex.get(delta.id);
|
const i = rowIndex.get(delta.id);
|
||||||
|
|||||||
@@ -368,13 +368,14 @@ describe("buildTranscriptRow: approval", () => {
|
|||||||
expect(el.querySelector(".st-approval-path")!.textContent).toBe("/etc/x");
|
expect(el.querySelector(".st-approval-path")!.textContent).toBe("/etc/x");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("hides the buttons and shows an answered note once resolved", () => {
|
it("renders a compact one-liner once resolved", () => {
|
||||||
const el = buildTranscriptRow(approvalRow(allThreeOptions), {
|
const el = buildTranscriptRow(approvalRow(allThreeOptions), {
|
||||||
pending: false,
|
pending: false,
|
||||||
onDecide: () => Promise.resolve(),
|
onDecide: () => Promise.resolve(),
|
||||||
});
|
});
|
||||||
expect(el.querySelector(".st-approval-acts")).toBeNull();
|
expect(el.querySelector(".st-approval-acts")).toBeNull();
|
||||||
expect(el.querySelector(".st-approval-resolved")!.textContent).toBe("answered");
|
expect(el.classList.contains("st-approval-compact")).toBe(true);
|
||||||
|
expect(el.textContent).toContain("approved");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("invokes onDecide with the clicked decision and disables the buttons", () => {
|
it("invokes onDecide with the clicked decision and disables the buttons", () => {
|
||||||
|
|||||||
+41
-37
@@ -704,6 +704,16 @@ function offeredDecisions(options: AcpPermissionOption[]): StructuredApprovalDec
|
|||||||
function buildApprovalRow(row: TranscriptRow, approval: ApprovalRowState): HTMLElement {
|
function buildApprovalRow(row: TranscriptRow, approval: ApprovalRowState): HTMLElement {
|
||||||
const request = (row.data ?? {}) as { toolCall?: AcpToolCallLike; options?: AcpPermissionOption[] };
|
const request = (row.data ?? {}) as { toolCall?: AcpToolCallLike; options?: AcpPermissionOption[] };
|
||||||
const toolCall = request.toolCall ?? {};
|
const toolCall = request.toolCall ?? {};
|
||||||
|
|
||||||
|
// Resolved approvals render as a compact one-liner inside a tool group
|
||||||
|
// (or are hidden entirely when standalone — see rebuildTranscript).
|
||||||
|
if (!approval.pending) {
|
||||||
|
const wrap = el("div", "st-row st-row-approval st-approval-compact");
|
||||||
|
const label = toolCall.title ?? toolKindLabel(toolCall.kind);
|
||||||
|
wrap.textContent = `${label} — approved`;
|
||||||
|
return wrap;
|
||||||
|
}
|
||||||
|
|
||||||
const wrap = el("div", "st-row st-row-approval");
|
const wrap = el("div", "st-row st-row-approval");
|
||||||
const card = el("div", "st-approval");
|
const card = el("div", "st-approval");
|
||||||
wrap.appendChild(card);
|
wrap.appendChild(card);
|
||||||
@@ -723,45 +733,39 @@ function buildApprovalRow(row: TranscriptRow, approval: ApprovalRowState): HTMLE
|
|||||||
card.appendChild(pathEl);
|
card.appendChild(pathEl);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (approval.pending) {
|
const acts = el("div", "st-approval-acts");
|
||||||
const acts = el("div", "st-approval-acts");
|
const mk = (label: string, decision: StructuredApprovalDecision, cls: string) => {
|
||||||
const mk = (label: string, decision: StructuredApprovalDecision, cls: string) => {
|
const btn = document.createElement("button");
|
||||||
const btn = document.createElement("button");
|
btn.type = "button";
|
||||||
btn.type = "button";
|
btn.className = cls ? `act ${cls}` : "act";
|
||||||
btn.className = cls ? `act ${cls}` : "act";
|
btn.textContent = label;
|
||||||
btn.textContent = label;
|
btn.addEventListener("click", () => {
|
||||||
btn.addEventListener("click", () => {
|
const buttons = [...acts.querySelectorAll<HTMLButtonElement>("button")];
|
||||||
const buttons = [...acts.querySelectorAll<HTMLButtonElement>("button")];
|
buttons.forEach((b) => (b.disabled = true));
|
||||||
buttons.forEach((b) => (b.disabled = true));
|
approval.onDecide(decision).catch(() => {
|
||||||
approval.onDecide(decision).catch(() => {
|
// Rejects only when this approval is still pending (see
|
||||||
// Rejects only when this approval is still pending (see
|
// ApprovalRowState) — restore the controls so the operator can
|
||||||
// ApprovalRowState) — restore the controls so the operator can
|
// retry; the error itself is surfaced by the connection.
|
||||||
// retry; the error itself is surfaced by the connection.
|
buttons.forEach((b) => (b.disabled = false));
|
||||||
buttons.forEach((b) => (b.disabled = false));
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
return btn;
|
});
|
||||||
};
|
return btn;
|
||||||
const offered = offeredDecisions(request.options ?? []);
|
};
|
||||||
const specs: [StructuredApprovalDecision, string, string][] = [
|
const offered = offeredDecisions(request.options ?? []);
|
||||||
["allow_once", "Allow once", "primary"],
|
const specs: [StructuredApprovalDecision, string, string][] = [
|
||||||
["allow_for_session", "Allow for session", ""],
|
["allow_once", "Allow once", "primary"],
|
||||||
["deny", "Deny", "deny"],
|
["allow_for_session", "Allow for session", ""],
|
||||||
];
|
["deny", "Deny", "deny"],
|
||||||
for (const [decision, label, cls] of specs) {
|
];
|
||||||
if (offered.includes(decision)) acts.appendChild(mk(label, decision, cls));
|
for (const [decision, label, cls] of specs) {
|
||||||
}
|
if (offered.includes(decision)) acts.appendChild(mk(label, decision, cls));
|
||||||
if (acts.children.length > 0) {
|
}
|
||||||
card.appendChild(acts);
|
if (acts.children.length > 0) {
|
||||||
} else {
|
card.appendChild(acts);
|
||||||
const none = el("div", "st-approval-resolved");
|
|
||||||
none.textContent = "no decision offered";
|
|
||||||
card.appendChild(none);
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
const resolved = el("div", "st-approval-resolved");
|
const none = el("div", "st-approval-resolved");
|
||||||
resolved.textContent = "answered";
|
none.textContent = "no decision offered";
|
||||||
card.appendChild(resolved);
|
card.appendChild(none);
|
||||||
}
|
}
|
||||||
|
|
||||||
return wrap;
|
return wrap;
|
||||||
|
|||||||
+56
-42
@@ -3040,6 +3040,16 @@ input.leaf-rename:focus {
|
|||||||
color: var(--faint);
|
color: var(--faint);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* compact resolved approval inside a tool group — same muted treatment
|
||||||
|
as intermediate agent text so the audit trail is preserved without
|
||||||
|
drawing attention to itself. */
|
||||||
|
.st-approval-compact {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: var(--fs-12);
|
||||||
|
padding: var(--sp-1) var(--sp-2);
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
.act {
|
.act {
|
||||||
appearance: none;
|
appearance: none;
|
||||||
font: inherit;
|
font: inherit;
|
||||||
@@ -3651,61 +3661,78 @@ input.leaf-rename:focus {
|
|||||||
color: var(--ink);
|
color: var(--ink);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* One-click tiles: host row + command row. */
|
/* Harness cards: the first decision in the spawn form — which tool runs. */
|
||||||
.tile-row {
|
.harness-grid {
|
||||||
display: flex;
|
display: grid;
|
||||||
flex-wrap: wrap;
|
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
|
||||||
gap: var(--sp-2);
|
gap: var(--sp-2);
|
||||||
}
|
}
|
||||||
|
|
||||||
.tile {
|
.harness-card {
|
||||||
flex: 1 1 auto;
|
|
||||||
min-width: 96px;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 2px;
|
align-items: center;
|
||||||
align-items: flex-start;
|
gap: var(--sp-2);
|
||||||
padding: var(--sp-2) var(--sp-3);
|
padding: var(--sp-3) var(--sp-2);
|
||||||
background: var(--surface-2);
|
background: var(--surface-2);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: var(--r-2);
|
border-radius: var(--r-2);
|
||||||
color: var(--ink);
|
color: var(--ink);
|
||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
text-align: left;
|
text-align: center;
|
||||||
transition:
|
transition:
|
||||||
border-color var(--dur-fast) var(--ease-out),
|
border-color var(--dur-fast) var(--ease-out),
|
||||||
background var(--dur-fast) var(--ease-out);
|
background var(--dur-fast) var(--ease-out);
|
||||||
}
|
}
|
||||||
|
|
||||||
.tile:hover {
|
.harness-card:hover {
|
||||||
border-color: var(--border-strong);
|
border-color: var(--border-strong);
|
||||||
|
background: var(--surface-3);
|
||||||
}
|
}
|
||||||
|
|
||||||
.tile.is-active {
|
.harness-card.is-active {
|
||||||
border-color: var(--accent);
|
border-color: var(--accent);
|
||||||
background: var(--accent-tint);
|
background: var(--accent-tint);
|
||||||
}
|
}
|
||||||
|
|
||||||
.tile-label {
|
.harness-icon {
|
||||||
font-size: var(--fs-14);
|
display: flex;
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tile-sub {
|
|
||||||
font-size: var(--fs-12);
|
|
||||||
color: var(--muted);
|
|
||||||
font-family: var(--font-mono);
|
|
||||||
}
|
|
||||||
|
|
||||||
.tile-more {
|
|
||||||
flex: 0 0 auto;
|
|
||||||
min-width: 0;
|
|
||||||
justify-content: center;
|
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.harness-card.is-active .harness-icon {
|
||||||
|
color: var(--ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
.harness-icon .tool-icon {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.harness-label {
|
||||||
|
font-size: var(--fs-13);
|
||||||
|
font-weight: 600;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The "Custom…" card is an escape hatch, not a peer tool — dashed border
|
||||||
|
and subdued color separate it from the named harnesses. */
|
||||||
|
.harness-custom {
|
||||||
|
border-style: dashed;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.harness-custom .harness-label {
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
/* Working-directory picker (#95). Two steps: a grid of project boxes, then the
|
/* Working-directory picker (#95). Two steps: a grid of project boxes, then the
|
||||||
picked project's worktree choices. Stale beyond 7 days dims but stays
|
picked project's worktree choices. Stale beyond 7 days dims but stays
|
||||||
selectable. */
|
selectable. */
|
||||||
@@ -3732,7 +3759,7 @@ input.leaf-rename:focus {
|
|||||||
|
|
||||||
.picker.projects {
|
.picker.projects {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fill, minmax(190px, 1fr));
|
grid-template-columns: repeat(2, 1fr);
|
||||||
align-content: start;
|
align-content: start;
|
||||||
gap: var(--sp-2);
|
gap: var(--sp-2);
|
||||||
max-height: 300px;
|
max-height: 300px;
|
||||||
@@ -3788,19 +3815,6 @@ input.leaf-rename:focus {
|
|||||||
overflow-wrap: anywhere;
|
overflow-wrap: anywhere;
|
||||||
}
|
}
|
||||||
|
|
||||||
.project-meta {
|
|
||||||
font-family: var(--font-mono);
|
|
||||||
font-size: var(--fs-12);
|
|
||||||
color: var(--faint);
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.project-meta:empty {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.project-other {
|
.project-other {
|
||||||
align-self: flex-start;
|
align-self: flex-start;
|
||||||
padding: 4px 0;
|
padding: 4px 0;
|
||||||
|
|||||||
Reference in New Issue
Block a user