feat: add structured session picker
This commit is contained in:
@@ -12,6 +12,7 @@ import type {
|
||||
RecentDirs,
|
||||
Session,
|
||||
StructuredApprovalDecision,
|
||||
StructuredConfigRequest,
|
||||
StructuredPromptRequest,
|
||||
Uuid,
|
||||
} from "./types.js";
|
||||
@@ -93,6 +94,21 @@ export async function killSession(id: Uuid, force = false): Promise<void> {
|
||||
);
|
||||
}
|
||||
|
||||
/** Change one option advertised by a live ACP session. */
|
||||
export async function setStructuredConfig(
|
||||
id: Uuid,
|
||||
req: StructuredConfigRequest,
|
||||
): Promise<void> {
|
||||
await jsonOrError(
|
||||
await fetch(routes.structuredConfig(id), {
|
||||
method: "POST",
|
||||
credentials: "same-origin",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(req),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/** Report whether this client's pane is the one currently showing `id` — not
|
||||
* merely highlighted in a sidebar. The daemon uses this to clear an
|
||||
* `AwaitingInput` glow once the pane has been looked at long enough
|
||||
|
||||
@@ -52,6 +52,11 @@ export function structuredCancel(id: Uuid): string {
|
||||
return `${session(id)}/structured/cancel`;
|
||||
}
|
||||
|
||||
/** `POST /api/sessions/:id/structured/config` — change an ACP session option. */
|
||||
export function structuredConfig(id: Uuid): string {
|
||||
return `${session(id)}/structured/config`;
|
||||
}
|
||||
|
||||
/** `POST /api/sessions/:id/structured/approvals/:nonce` — answer a permission
|
||||
* request. */
|
||||
export function structuredApproval(id: Uuid, nonce: string): string {
|
||||
|
||||
@@ -8,10 +8,16 @@ vi.mock("./api.js", async () => {
|
||||
sendStructuredPrompt: vi.fn().mockResolvedValue(undefined),
|
||||
cancelStructuredTurn: vi.fn().mockResolvedValue(undefined),
|
||||
resolveStructuredApproval: vi.fn().mockResolvedValue(undefined),
|
||||
setStructuredConfig: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
});
|
||||
|
||||
import { cancelStructuredTurn, resolveStructuredApproval, sendStructuredPrompt } from "./api.js";
|
||||
import {
|
||||
cancelStructuredTurn,
|
||||
resolveStructuredApproval,
|
||||
sendStructuredPrompt,
|
||||
setStructuredConfig,
|
||||
} from "./api.js";
|
||||
import { TOGGLE_DRAWER_EVENT } from "./drawer.js";
|
||||
import type { CatalogStore } from "./events.js";
|
||||
import { createStructuredConnection, type StructuredSocketFactory } from "./structured.js";
|
||||
@@ -142,6 +148,53 @@ describe("structured connection: data flow", () => {
|
||||
conn.dispose();
|
||||
});
|
||||
|
||||
it("renders advertised session settings and sends changed values", () => {
|
||||
const ws = new FakeSocket();
|
||||
const conn = mount({ sockets: [ws] });
|
||||
ws.open();
|
||||
ws.emit({
|
||||
type: "reduced_state",
|
||||
state: {
|
||||
...idleState,
|
||||
config_options: {
|
||||
configOptions: [
|
||||
{
|
||||
id: "model",
|
||||
name: "Model",
|
||||
category: "model",
|
||||
type: "select",
|
||||
currentValue: "opus",
|
||||
options: [
|
||||
{ value: "opus", name: "Opus" },
|
||||
{ value: "sonnet", name: "Sonnet" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "effort",
|
||||
name: "Effort",
|
||||
category: "thought_level",
|
||||
type: "select",
|
||||
currentValue: "high",
|
||||
options: [{ value: "high", name: "High" }],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(conn.el.querySelector(".structured-config-summary")?.textContent).toBe("Opus · High");
|
||||
(conn.el.querySelector(".structured-config-trigger") as HTMLButtonElement).click();
|
||||
const selects = conn.el.querySelectorAll<HTMLSelectElement>(".structured-config-select");
|
||||
expect(selects).toHaveLength(2);
|
||||
selects[0].value = "sonnet";
|
||||
selects[0].dispatchEvent(new Event("change"));
|
||||
expect(setStructuredConfig).toHaveBeenCalledWith("s1", {
|
||||
config_id: "model",
|
||||
value: "sonnet",
|
||||
});
|
||||
conn.dispose();
|
||||
});
|
||||
|
||||
it("applies plan append, patch, and remove without disturbing surrounding rows", () => {
|
||||
const ws = new FakeSocket();
|
||||
const conn = mount({ sockets: [ws] });
|
||||
|
||||
+120
-8
@@ -10,6 +10,7 @@ import {
|
||||
cancelStructuredTurn,
|
||||
resolveStructuredApproval,
|
||||
sendStructuredPrompt,
|
||||
setStructuredConfig,
|
||||
structuredWsUrl,
|
||||
} from "./api.js";
|
||||
import { requestToggleDrawer } from "./drawer.js";
|
||||
@@ -19,6 +20,8 @@ import type { PaneConnection } from "./pane.js";
|
||||
import { INITIAL_RECONNECT_MS, MAX_RECONNECT_MS } from "./reconnect.js";
|
||||
import { buildToolGroup, buildTranscriptRow } from "./structuredRender.js";
|
||||
import type {
|
||||
AcpSessionConfigChoice,
|
||||
AcpSessionConfigOption,
|
||||
ReducedState,
|
||||
StructuredApprovalDecision,
|
||||
StructuredEvent,
|
||||
@@ -69,7 +72,11 @@ export function createStructuredConnection(
|
||||
<header class="attach-header">
|
||||
<button type="button" class="btn btn-ghost btn-icon sessions-btn" aria-label="Show sessions" title="Show sessions">☰</button>
|
||||
<span class="attach-title" title="${paneTitleAttr(sessionId, info)}">${paneTitleHtml(sessionId, info)}</span>
|
||||
<span class="st-pills"></span>
|
||||
<div class="structured-config" hidden>
|
||||
<button type="button" class="structured-config-trigger" aria-haspopup="true"
|
||||
aria-expanded="false"><span class="structured-config-summary">Session settings</span><span class="structured-config-caret" aria-hidden="true"></span></button>
|
||||
<div class="structured-config-menu" role="group" aria-label="Session settings" hidden></div>
|
||||
</div>
|
||||
<span class="attach-state" data-state="connecting">connecting…</span>
|
||||
<button class="btn btn-ghost btn-icon disconnect" type="button" aria-label="Disconnect" title="Disconnect this session">⏻</button>
|
||||
</header>
|
||||
@@ -94,7 +101,10 @@ export function createStructuredConnection(
|
||||
`;
|
||||
|
||||
const stateEl = el.querySelector(".attach-state") as HTMLSpanElement;
|
||||
const pillsEl = el.querySelector(".st-pills") as HTMLSpanElement;
|
||||
const configEl = el.querySelector(".structured-config") as HTMLDivElement;
|
||||
const configTrigger = el.querySelector(".structured-config-trigger") as HTMLButtonElement;
|
||||
const configSummary = el.querySelector(".structured-config-summary") as HTMLSpanElement;
|
||||
const configMenu = el.querySelector(".structured-config-menu") as HTMLDivElement;
|
||||
const disconnectBtn = el.querySelector(".disconnect") as HTMLButtonElement;
|
||||
const scrollEl = el.querySelector(".structured-scroll") as HTMLDivElement;
|
||||
const rowsEl = el.querySelector(".structured-rows") as HTMLDivElement;
|
||||
@@ -128,6 +138,7 @@ export function createStructuredConnection(
|
||||
let state: ReducedState = { turn: idleTurn() };
|
||||
let pendingApprovalRowId: string | null = null;
|
||||
let availableCommands: AvailableCommand[] = [];
|
||||
let configBusy = false;
|
||||
let filteredCommands: AvailableCommand[] = [];
|
||||
let activeCommandIndex = -1;
|
||||
let submissionGeneration = 0;
|
||||
@@ -364,11 +375,90 @@ export function createStructuredConnection(
|
||||
});
|
||||
}
|
||||
|
||||
function renderPills(): void {
|
||||
const parts: string[] = [];
|
||||
if (state.model) parts.push(state.model);
|
||||
if (state.mode) parts.push(state.mode);
|
||||
pillsEl.textContent = parts.join(" · ");
|
||||
function sessionConfigOptions(): AcpSessionConfigOption[] {
|
||||
return state.config_options?.configOptions ?? [];
|
||||
}
|
||||
|
||||
function configChoices(option: AcpSessionConfigOption): AcpSessionConfigChoice[] {
|
||||
return (option.options ?? []).flatMap((choice) =>
|
||||
"options" in choice ? choice.options : [choice],
|
||||
);
|
||||
}
|
||||
|
||||
function currentConfigLabel(option: AcpSessionConfigOption): string {
|
||||
if (typeof option.currentValue === "boolean") return option.currentValue ? "On" : "Off";
|
||||
return configChoices(option).find((choice) => choice.value === option.currentValue)?.name
|
||||
?? option.currentValue;
|
||||
}
|
||||
|
||||
async function changeConfig(option: AcpSessionConfigOption, value: string | boolean): Promise<void> {
|
||||
configBusy = true;
|
||||
renderConfig();
|
||||
clearError();
|
||||
try {
|
||||
await setStructuredConfig(sessionId, { config_id: option.id, value });
|
||||
} catch (error) {
|
||||
showError(error instanceof Error ? error.message : "Could not change the session setting");
|
||||
} finally {
|
||||
configBusy = false;
|
||||
renderConfig();
|
||||
}
|
||||
}
|
||||
|
||||
function renderConfig(): void {
|
||||
const options = sessionConfigOptions();
|
||||
configEl.hidden = options.length === 0;
|
||||
if (options.length === 0) {
|
||||
configMenu.hidden = true;
|
||||
configTrigger.setAttribute("aria-expanded", "false");
|
||||
return;
|
||||
}
|
||||
const ordered = [...options].sort((a, b) => {
|
||||
const rank = (category?: string | null) =>
|
||||
["model", "thought_level", "mode", "model_config"].indexOf(category ?? "");
|
||||
const aRank = rank(a.category);
|
||||
const bRank = rank(b.category);
|
||||
return (aRank < 0 ? 99 : aRank) - (bRank < 0 ? 99 : bRank);
|
||||
});
|
||||
configSummary.textContent = ordered.map(currentConfigLabel).join(" · ");
|
||||
configTrigger.title = ordered.map((option) => `${option.name}: ${currentConfigLabel(option)}`).join("\n");
|
||||
configTrigger.disabled = configBusy;
|
||||
configMenu.replaceChildren();
|
||||
for (const option of ordered) {
|
||||
const row = document.createElement("label");
|
||||
row.className = "structured-config-row";
|
||||
const name = document.createElement("span");
|
||||
name.className = "structured-config-name";
|
||||
name.textContent = option.name;
|
||||
row.append(name);
|
||||
if (option.type === "select") {
|
||||
const select = document.createElement("select");
|
||||
select.className = "structured-config-select";
|
||||
select.disabled = configBusy;
|
||||
for (const choice of configChoices(option)) {
|
||||
const item = document.createElement("option");
|
||||
item.value = choice.value;
|
||||
item.textContent = choice.name;
|
||||
item.title = choice.description ?? "";
|
||||
select.append(item);
|
||||
}
|
||||
select.value = String(option.currentValue);
|
||||
select.addEventListener("change", () => void changeConfig(option, select.value));
|
||||
row.append(select);
|
||||
} else if (option.type === "boolean") {
|
||||
const toggle = document.createElement("input");
|
||||
toggle.type = "checkbox";
|
||||
toggle.className = "structured-config-toggle";
|
||||
toggle.checked = option.currentValue === true;
|
||||
toggle.disabled = configBusy;
|
||||
toggle.addEventListener("change", () => void changeConfig(option, toggle.checked));
|
||||
row.append(toggle);
|
||||
} else {
|
||||
continue;
|
||||
}
|
||||
if (option.description) row.title = option.description;
|
||||
configMenu.append(row);
|
||||
}
|
||||
}
|
||||
|
||||
function renderComposerState(): void {
|
||||
@@ -403,10 +493,31 @@ export function createStructuredConnection(
|
||||
if (prev !== null || newPendingId !== null) rebuildTranscript();
|
||||
});
|
||||
}
|
||||
renderPills();
|
||||
renderConfig();
|
||||
renderComposerState();
|
||||
}
|
||||
|
||||
function closeConfigMenu(): void {
|
||||
configMenu.hidden = true;
|
||||
configTrigger.setAttribute("aria-expanded", "false");
|
||||
}
|
||||
|
||||
configTrigger.addEventListener("click", () => {
|
||||
const opening = configMenu.hidden;
|
||||
configMenu.hidden = !opening;
|
||||
configTrigger.setAttribute("aria-expanded", String(opening));
|
||||
});
|
||||
const closeConfigOnOutsidePress = (event: PointerEvent) => {
|
||||
if (!configEl.contains(event.target as Node)) closeConfigMenu();
|
||||
};
|
||||
document.addEventListener("pointerdown", closeConfigOnOutsidePress);
|
||||
configEl.addEventListener("keydown", (event) => {
|
||||
if (event.key === "Escape") {
|
||||
closeConfigMenu();
|
||||
configTrigger.focus();
|
||||
}
|
||||
});
|
||||
|
||||
// ---- error banner ----
|
||||
let errorEl: HTMLElement | null = null;
|
||||
function showError(message: string): void {
|
||||
@@ -783,6 +894,7 @@ export function createStructuredConnection(
|
||||
expandedGroupIds.clear();
|
||||
scrollEl.removeEventListener("scroll", onTranscriptScroll);
|
||||
scrollEl.removeEventListener("click", onTranscriptClick, true);
|
||||
document.removeEventListener("pointerdown", closeConfigOnOutsidePress);
|
||||
el.remove();
|
||||
},
|
||||
};
|
||||
|
||||
+102
-3
@@ -2173,12 +2173,111 @@ input.leaf-rename:focus {
|
||||
only lifted, card-like element on the page; everything else is either a
|
||||
tinted block (user), plain text (agent), or a thin bordered row (tool). */
|
||||
|
||||
.st-pills {
|
||||
font-size: var(--fs-11);
|
||||
.structured-config {
|
||||
position: relative;
|
||||
flex: 0 1 300px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.structured-config-trigger {
|
||||
width: 100%;
|
||||
height: 26px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-2);
|
||||
padding: 0 var(--sp-2);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--r-2);
|
||||
background: var(--surface-2);
|
||||
color: var(--muted);
|
||||
white-space: nowrap;
|
||||
font: 500 var(--fs-11) / 1 var(--font-sans);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.structured-config-trigger:hover,
|
||||
.structured-config-trigger[aria-expanded="true"] {
|
||||
color: var(--ink);
|
||||
border-color: var(--border-strong);
|
||||
background: var(--surface-3);
|
||||
}
|
||||
|
||||
.structured-config-trigger:focus-visible,
|
||||
.structured-config-select:focus-visible,
|
||||
.structured-config-toggle:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.structured-config-summary {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.structured-config-caret {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
flex: 0 0 auto;
|
||||
border-right: 1.5px solid currentColor;
|
||||
border-bottom: 1.5px solid currentColor;
|
||||
transform: translateY(-1px) rotate(45deg);
|
||||
}
|
||||
|
||||
.structured-config-trigger[aria-expanded="true"] .structured-config-caret {
|
||||
transform: translateY(2px) rotate(225deg);
|
||||
}
|
||||
|
||||
.structured-config-menu {
|
||||
position: absolute;
|
||||
z-index: 20;
|
||||
top: calc(100% + var(--sp-2));
|
||||
right: 0;
|
||||
width: min(320px, 86vw);
|
||||
padding: var(--sp-2);
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: var(--r-2);
|
||||
background: var(--surface);
|
||||
box-shadow: var(--shadow-2);
|
||||
}
|
||||
|
||||
.structured-config-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(80px, 1fr) minmax(0, 1.6fr);
|
||||
align-items: center;
|
||||
gap: var(--sp-3);
|
||||
min-height: 34px;
|
||||
padding: var(--sp-1) var(--sp-2);
|
||||
color: var(--ink);
|
||||
font-size: var(--fs-12);
|
||||
}
|
||||
|
||||
.structured-config-row + .structured-config-row {
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.structured-config-name {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.structured-config-select {
|
||||
min-width: 0;
|
||||
height: 28px;
|
||||
padding: 0 var(--sp-2);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--r-1);
|
||||
background: var(--surface-2);
|
||||
color: var(--ink);
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.structured-config-toggle {
|
||||
justify-self: end;
|
||||
width: 17px;
|
||||
height: 17px;
|
||||
accent-color: var(--accent);
|
||||
}
|
||||
|
||||
.structured-body {
|
||||
|
||||
@@ -436,6 +436,7 @@ export interface ReducedState {
|
||||
context?: AcpUsage | null;
|
||||
model?: string | null;
|
||||
mode?: string | null;
|
||||
config_options?: { configOptions: AcpSessionConfigOption[] } | null;
|
||||
}
|
||||
|
||||
/** Server messages on `WS /api/structured/:id`. */
|
||||
@@ -449,6 +450,33 @@ export interface StructuredPromptRequest {
|
||||
prompt: string;
|
||||
}
|
||||
|
||||
export interface StructuredConfigRequest {
|
||||
config_id: string;
|
||||
value: string | boolean;
|
||||
}
|
||||
|
||||
export interface AcpSessionConfigChoice {
|
||||
value: string;
|
||||
name: string;
|
||||
description?: string | null;
|
||||
}
|
||||
|
||||
export interface AcpSessionConfigGroup {
|
||||
group: string;
|
||||
name: string;
|
||||
options: AcpSessionConfigChoice[];
|
||||
}
|
||||
|
||||
export interface AcpSessionConfigOption {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string | null;
|
||||
category?: string | null;
|
||||
type: "select" | "boolean" | string;
|
||||
currentValue: string | boolean;
|
||||
options?: Array<AcpSessionConfigChoice | AcpSessionConfigGroup>;
|
||||
}
|
||||
|
||||
/** `POST /api/sessions/:id/structured/approvals/:nonce` body — a bare JSON
|
||||
* string (a bracketless Rust enum), not `{decision: ...}`. */
|
||||
export type StructuredApprovalDecision = "allow_once" | "allow_for_session" | "deny";
|
||||
|
||||
Reference in New Issue
Block a user