Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6ca2ab1707 | ||
|
|
b0b0160ccb | ||
|
|
c4cd8a691e | ||
|
|
0381a571f2 | ||
|
|
8f3d47c49e | ||
|
|
ba0c54759b | ||
|
|
78912e7925 | ||
|
|
fef9be6c01 | ||
|
|
5387b29049 | ||
|
|
df51aa7be9 | ||
|
|
43bfda5a60 |
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(git add:*)",
|
||||
"Bash(git commit:*)",
|
||||
"Bash(git fetch:*)",
|
||||
"Bash(git merge:*)",
|
||||
"Bash(npx tsc:*)",
|
||||
"Bash(npm run build:*)",
|
||||
"Bash(npm install:*)",
|
||||
"Bash(./node_modules/.bin/tsc:*)",
|
||||
"Bash(npm test:*)",
|
||||
"Bash(cargo check:*)",
|
||||
"Bash(cargo clippy:*)"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -2130,11 +2130,13 @@ mod tests {
|
||||
id: "claude".into(),
|
||||
label: "Claude".into(),
|
||||
command: "claude".into(),
|
||||
auto_approve: false,
|
||||
},
|
||||
SpawnPreset {
|
||||
id: "codex".into(),
|
||||
label: "Codex".into(),
|
||||
command: "codex".into(),
|
||||
auto_approve: false,
|
||||
},
|
||||
]);
|
||||
assert!(draft.structured_view);
|
||||
|
||||
+13
-5
@@ -190,9 +190,13 @@ choices, not daemon ones.
|
||||
from the transcript; the TUI may show the current plan in its status line,
|
||||
while the web client deliberately has no stats or plan rail.
|
||||
- The web client groups two or more tool rows after the same `User` row into
|
||||
one collapsed turn activity block. Opening it reveals independently
|
||||
expandable tool calls, including any raw command, input, output, and diff
|
||||
content the adapter supplied.
|
||||
one collapsed turn activity block. `AgentText` and `AgentThought` rows that
|
||||
appear *between* tool rows in the same turn are absorbed into the group
|
||||
(intermediate narration); the last `AgentText` with no subsequent tool stays
|
||||
outside as the prominent final answer. The header still reads
|
||||
"N tool calls" (counting only tool rows). Opening the group reveals tools
|
||||
and intermediate text in chronological order, with the text rendered in a
|
||||
subdued style.
|
||||
- The web client's user, agent, thought, tool-detail, and notice text blocks
|
||||
copy their complete source text when clicked or keyboard-activated.
|
||||
- The web client consumes `available_commands_update` notices as the live
|
||||
@@ -346,11 +350,15 @@ thing a callback can be attributed to, so at most one turn may ever be
|
||||
in flight, and a cancelled turn must fully settle before the next prompt is
|
||||
allowed to claim the daemon's idea of "the active turn."
|
||||
|
||||
- A prompt is appended as a durable `User` row before it claims the turn and
|
||||
- Web clients may collect a visible FIFO of operator messages while a turn is
|
||||
active. They submit each queued message only after the projected turn becomes
|
||||
idle; the queue is client-local and unsent messages do not survive a reload.
|
||||
- A submitted prompt is appended as a durable `User` row before it claims the turn and
|
||||
dispatches to ACP, so the transcript never depends on an adapter echoing the
|
||||
operator's input. If that append fails, the prompt is not dispatched. While a
|
||||
turn is active, a second prompt gets `409 turn_in_flight` rather than
|
||||
queuing — one active turn per session, never a hidden queue of prompts.
|
||||
queuing in the daemon — one active turn per session, never a hidden
|
||||
server-side queue of prompts.
|
||||
- Cancel sends `session/cancel`, clears any pending approval as `Cancelled`,
|
||||
and ends the turn's client-visible lifecycle immediately. If the cancelled
|
||||
turn was still *dispatched* to ACP (waiting on its response), the daemon
|
||||
|
||||
+78
-14
@@ -134,7 +134,7 @@ describe("structured connection: data flow", () => {
|
||||
} });
|
||||
input.dispatchEvent(paste);
|
||||
conn.el.querySelector("form")!.dispatchEvent(new Event("submit", { cancelable: true }));
|
||||
await vi.waitFor(() => expect(input.disabled).toBe(false));
|
||||
await vi.waitFor(() => expect(conn.el.textContent).toContain("Upload failed"));
|
||||
expect(input.value).toBe("Read this");
|
||||
expect(sendStructuredPrompt).not.toHaveBeenCalled();
|
||||
const remove = conn.el.querySelector<HTMLButtonElement>('[aria-label="Remove notes.txt"]')!;
|
||||
@@ -413,6 +413,39 @@ describe("structured connection: data flow", () => {
|
||||
conn.dispose();
|
||||
});
|
||||
|
||||
it("absorbs intermediate agent text into tool group, keeps final text outside", () => {
|
||||
const ws = new FakeSocket();
|
||||
const conn = mount({ sockets: [ws] });
|
||||
ws.open();
|
||||
ws.emit({
|
||||
type: "transcript_snapshot",
|
||||
snapshot: {
|
||||
cursor: 8,
|
||||
rows: [
|
||||
row("user:1", "user", { content: { type: "text", text: "do it" } }),
|
||||
row("agent:1", "agent_text", { content: { type: "text", text: "Let me check" } }),
|
||||
toolRow("tool:1", "one"),
|
||||
toolRow("tool:2", "two"),
|
||||
row("agent:2", "agent_text", { content: { type: "text", text: "Found issues, fixing" } }),
|
||||
toolRow("tool:3", "three"),
|
||||
toolRow("tool:4", "four"),
|
||||
row("agent:3", "agent_text", { content: { type: "text", text: "All done" } }),
|
||||
],
|
||||
},
|
||||
});
|
||||
const groups = conn.el.querySelectorAll<HTMLElement>(".st-tool-group");
|
||||
expect(groups).toHaveLength(1);
|
||||
// Header counts only tool rows, not intermediate text.
|
||||
expect(groups[0].querySelector(".st-tool-group-head")!.textContent).toContain("4 tool calls");
|
||||
// Intermediate text (agent:2) is inside the group.
|
||||
expect(conn.el.querySelector('[data-row-id="agent:2"]')?.closest(".st-tool-group")).not.toBeNull();
|
||||
// First text (agent:1) is outside — it precedes all tools.
|
||||
expect(conn.el.querySelector('[data-row-id="agent:1"]')?.closest(".st-tool-group")).toBeNull();
|
||||
// Final text (agent:3) is outside — no tool follows it.
|
||||
expect(conn.el.querySelector('[data-row-id="agent:3"]')?.closest(".st-tool-group")).toBeNull();
|
||||
conn.dispose();
|
||||
});
|
||||
|
||||
it("keeps an expanded running tool open without emitting a click when patched", () => {
|
||||
const ws = new FakeSocket();
|
||||
const conn = mount({ sockets: [ws] });
|
||||
@@ -660,7 +693,7 @@ describe("structured connection: turn/composer gating", () => {
|
||||
conn.dispose();
|
||||
});
|
||||
|
||||
it("disables the composer while a turn is active and re-enables when idle", () => {
|
||||
it("keeps the composer available while a turn is active", () => {
|
||||
const ws = new FakeSocket();
|
||||
const conn = mount({ sockets: [ws] });
|
||||
ws.open();
|
||||
@@ -668,13 +701,13 @@ describe("structured connection: turn/composer gating", () => {
|
||||
const input = conn.el.querySelector(".structured-input") as HTMLTextAreaElement;
|
||||
const cancel = conn.el.querySelector(".structured-cancel") as HTMLButtonElement;
|
||||
ws.emit({ type: "reduced_state", state: { turn: { responding: true, thinking: false, active_tool_calls: [] } } });
|
||||
expect(input.disabled).toBe(true);
|
||||
expect(input.disabled).toBe(false);
|
||||
expect(cancel.hidden).toBe(false);
|
||||
expect((conn.el.querySelector(".structured-send") as HTMLButtonElement).hidden).toBe(true);
|
||||
expect((conn.el.querySelector(".structured-send span") as HTMLElement).textContent).toBe("Send");
|
||||
ws.emit({ type: "reduced_state", state: idleState });
|
||||
expect(input.disabled).toBe(false);
|
||||
expect(cancel.hidden).toBe(true);
|
||||
expect((conn.el.querySelector(".structured-send") as HTMLButtonElement).hidden).toBe(false);
|
||||
expect((conn.el.querySelector(".structured-send span") as HTMLElement).textContent).toBe("Send");
|
||||
conn.dispose();
|
||||
});
|
||||
|
||||
@@ -737,7 +770,7 @@ describe("structured connection: turn/composer gating", () => {
|
||||
conn.dispose();
|
||||
});
|
||||
|
||||
it("disables immediately and dispatches only once before begin-turn is projected", () => {
|
||||
it("queues a second message before begin-turn is projected", () => {
|
||||
vi.mocked(sendStructuredPrompt).mockImplementationOnce(() => new Promise(() => {}));
|
||||
const ws = new FakeSocket();
|
||||
const conn = mount({ sockets: [ws] });
|
||||
@@ -750,12 +783,43 @@ describe("structured connection: turn/composer gating", () => {
|
||||
input.value = "only once";
|
||||
|
||||
form.dispatchEvent(new SubmitEvent("submit", { bubbles: true, cancelable: true }));
|
||||
input.value = "queued next";
|
||||
form.dispatchEvent(new SubmitEvent("submit", { bubbles: true, cancelable: true }));
|
||||
|
||||
expect(input.disabled).toBe(true);
|
||||
expect(send.disabled).toBe(true);
|
||||
expect(input.disabled).toBe(false);
|
||||
expect(send.disabled).toBe(false);
|
||||
expect(sendStructuredPrompt).toHaveBeenCalledTimes(1);
|
||||
expect(sendStructuredPrompt).toHaveBeenCalledWith("s1", "only once");
|
||||
expect(conn.el.querySelector(".structured-queue")?.textContent).toBe("1 message queued");
|
||||
conn.dispose();
|
||||
});
|
||||
|
||||
it("dispatches queued messages in order as turns become idle", async () => {
|
||||
const ws = new FakeSocket();
|
||||
const conn = mount({ sockets: [ws] });
|
||||
ws.open();
|
||||
ws.emit({ type: "reduced_state", state: idleState });
|
||||
const input = conn.el.querySelector(".structured-input") as HTMLTextAreaElement;
|
||||
const submit = (text: string) => {
|
||||
input.value = text;
|
||||
input.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true, cancelable: true }));
|
||||
};
|
||||
|
||||
submit("first");
|
||||
ws.emit({ type: "reduced_state", state: { turn: { responding: true, thinking: false, active_tool_calls: [] } } });
|
||||
submit("second");
|
||||
submit("third");
|
||||
expect(sendStructuredPrompt).toHaveBeenCalledTimes(1);
|
||||
expect(conn.el.querySelector(".structured-queue")?.textContent).toBe("2 messages queued");
|
||||
|
||||
await Promise.resolve().then(() => Promise.resolve());
|
||||
ws.emit({ type: "reduced_state", state: idleState });
|
||||
expect(sendStructuredPrompt).toHaveBeenNthCalledWith(2, "s1", "second");
|
||||
ws.emit({ type: "reduced_state", state: { turn: { responding: true, thinking: false, active_tool_calls: [] } } });
|
||||
await Promise.resolve().then(() => Promise.resolve());
|
||||
ws.emit({ type: "reduced_state", state: idleState });
|
||||
expect(sendStructuredPrompt).toHaveBeenNthCalledWith(3, "s1", "third");
|
||||
expect((conn.el.querySelector(".structured-queue") as HTMLElement).hidden).toBe(true);
|
||||
conn.dispose();
|
||||
});
|
||||
|
||||
@@ -807,7 +871,7 @@ describe("structured connection: turn/composer gating", () => {
|
||||
|
||||
expect(sendStructuredPrompt).toHaveBeenCalledTimes(2);
|
||||
expect(input.value).toBe("");
|
||||
expect(input.disabled).toBe(true);
|
||||
expect(input.disabled).toBe(false);
|
||||
expect(conn.el.textContent).not.toContain("stale failure");
|
||||
conn.dispose();
|
||||
});
|
||||
@@ -1126,7 +1190,7 @@ describe("structured connection: reconnection/error/loading", () => {
|
||||
second.open();
|
||||
second.emit({ type: "transcript_snapshot", snapshot: { cursor: 0, rows: [] } });
|
||||
second.emit({ type: "reduced_state", state: idleState });
|
||||
expect(input.disabled).toBe(true);
|
||||
expect(input.disabled).toBe(false);
|
||||
|
||||
second.emit({ type: "reduced_state", state: { turn: { responding: true, thinking: false, active_tool_calls: [] } } });
|
||||
second.emit({ type: "reduced_state", state: idleState });
|
||||
@@ -1178,12 +1242,12 @@ describe("structured connection: reconnection/error/loading", () => {
|
||||
second.open();
|
||||
second.emit({ type: "transcript_snapshot", snapshot: { cursor: 0, rows: [] } });
|
||||
second.emit({ type: "reduced_state", state: idleState });
|
||||
expect(input.disabled).toBe(true);
|
||||
expect(input.disabled).toBe(false);
|
||||
|
||||
accept();
|
||||
await Promise.resolve().then(() => Promise.resolve());
|
||||
|
||||
expect(input.disabled).toBe(true);
|
||||
expect(input.disabled).toBe(false);
|
||||
expect(second.closed).toBe(true);
|
||||
|
||||
third.open();
|
||||
@@ -1584,7 +1648,7 @@ describe("structured connection: pane lifecycle", () => {
|
||||
conn.dispose();
|
||||
});
|
||||
|
||||
it("focusTerminal focuses the composer only when it is enabled", () => {
|
||||
it("focusTerminal keeps the composer reachable during an active turn", () => {
|
||||
const ws = new FakeSocket();
|
||||
const conn = mount({ sockets: [ws] });
|
||||
ws.open();
|
||||
@@ -1592,7 +1656,7 @@ describe("structured connection: pane lifecycle", () => {
|
||||
ws.emit({ type: "reduced_state", state: { turn: { responding: true, thinking: false, active_tool_calls: [] } } });
|
||||
conn.focusTerminal();
|
||||
const input = conn.el.querySelector(".structured-input") as HTMLTextAreaElement;
|
||||
expect(document.activeElement).not.toBe(input);
|
||||
expect(document.activeElement).toBe(input);
|
||||
ws.emit({ type: "reduced_state", state: idleState });
|
||||
conn.focusTerminal();
|
||||
expect(document.activeElement).toBe(input);
|
||||
|
||||
+117
-36
@@ -113,7 +113,11 @@ export function createStructuredConnection(
|
||||
<button type="submit" class="btn btn-primary structured-send"><span>Send</span>
|
||||
<svg viewBox="0 0 20 20" aria-hidden="true"><path d="m7 5 5 5-5 5M12 10H3"/></svg>
|
||||
</button>
|
||||
<button type="button" class="btn structured-cancel" hidden>Cancel turn</button>
|
||||
<button type="button" class="btn btn-icon structured-cancel" hidden aria-label="Cancel turn" title="Cancel turn">
|
||||
<svg class="structured-cancel-spinner" viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="10" fill="none" stroke="currentColor" stroke-width="2" stroke-dasharray="50 14" stroke-linecap="round"/></svg>
|
||||
<svg class="structured-cancel-stop" viewBox="0 0 24 24" aria-hidden="true"><rect x="6" y="6" width="12" height="12" rx="2" fill="currentColor"/></svg>
|
||||
</button>
|
||||
<span class="structured-queue" role="status" aria-live="polite" hidden></span>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
@@ -133,11 +137,14 @@ export function createStructuredConnection(
|
||||
const commandMenu = el.querySelector(".structured-command-menu") as HTMLDivElement;
|
||||
const input = el.querySelector(".structured-input") as HTMLTextAreaElement;
|
||||
const sendBtn = el.querySelector(".structured-send") as HTMLButtonElement;
|
||||
const sendLabel = sendBtn.querySelector("span") as HTMLSpanElement;
|
||||
const cancelBtn = el.querySelector(".structured-cancel") as HTMLButtonElement;
|
||||
const queueEl = el.querySelector(".structured-queue") as HTMLSpanElement;
|
||||
const attachBtn = el.querySelector(".structured-attach") as HTMLButtonElement;
|
||||
const fileInput = el.querySelector(".structured-file-input") as HTMLInputElement;
|
||||
const attachmentEl = el.querySelector(".structured-attachments") as HTMLDivElement;
|
||||
type Attachment = { file: File; path?: string };
|
||||
type QueuedPrompt = { text: string; attachments: Attachment[] };
|
||||
let attachments: Attachment[] = [];
|
||||
let uploading = false;
|
||||
let pasteCount = 0;
|
||||
@@ -155,7 +162,7 @@ export function createStructuredConnection(
|
||||
remove.type = "button";
|
||||
remove.textContent = "×";
|
||||
remove.setAttribute("aria-label", `Remove ${attachment.file.name}`);
|
||||
remove.disabled = input.disabled;
|
||||
remove.disabled = uploading;
|
||||
remove.addEventListener("click", () => {
|
||||
attachments = attachments.filter((item) => item !== attachment);
|
||||
renderAttachments();
|
||||
@@ -166,7 +173,7 @@ export function createStructuredConnection(
|
||||
}
|
||||
|
||||
function addFiles(files: File[]): void {
|
||||
if (input.disabled || disposed) return;
|
||||
if (uploading || disposed) return;
|
||||
for (const file of files) {
|
||||
if (file.size > 20 * 1024 * 1024) {
|
||||
showError(`${file.name} exceeds 20 MiB. Choose a smaller file.`);
|
||||
@@ -193,7 +200,7 @@ export function createStructuredConnection(
|
||||
});
|
||||
input.addEventListener("paste", (event) => {
|
||||
const clipboard = event.clipboardData;
|
||||
if (!clipboard || input.disabled) return;
|
||||
if (!clipboard || uploading) return;
|
||||
if (clipboard.files.length) {
|
||||
event.preventDefault();
|
||||
addFiles(Array.from(clipboard.files));
|
||||
@@ -244,6 +251,8 @@ export function createStructuredConnection(
|
||||
let disposed = false;
|
||||
let optimisticTurnStartedAt: number | null = null;
|
||||
let activityTimer: number | null = null;
|
||||
const promptQueue: QueuedPrompt[] = [];
|
||||
let promptRequestPending = false;
|
||||
|
||||
// Within 64px, wheel and touch imprecision should still count as following.
|
||||
const FOLLOW_BOTTOM_THRESHOLD_PX = 64;
|
||||
@@ -393,17 +402,53 @@ export function createStructuredConnection(
|
||||
return el;
|
||||
}
|
||||
|
||||
function toolGroupsByTurn(): Map<string, TranscriptRow[]> {
|
||||
const groups = new Map<string, TranscriptRow[]>();
|
||||
/** Group tool rows (and intermediate agent text between them) by turn.
|
||||
* An agent_text/agent_thought row is "intermediate" when a tool row follows
|
||||
* it in the same turn — it gets absorbed into the group so the collapsed
|
||||
* block preserves chronological order. The *last* agent text with no
|
||||
* subsequent tool stays standalone (the prominent final answer).
|
||||
* Returns groups keyed by `turn:<userId>`, each with `toolCount` (for the
|
||||
* header label) and `rows` (tools + intermediate text, in order). */
|
||||
function activityGroupsByTurn(): Map<string, { toolCount: number; rows: TranscriptRow[] }> {
|
||||
// First: count tools per turn to know which turns qualify for grouping.
|
||||
const toolCounts = new Map<string, number>();
|
||||
let turnId = "before-first-message";
|
||||
for (const row of rows) {
|
||||
if (row.kind === "user") turnId = row.id;
|
||||
if (row.kind !== "tool_call" && row.kind !== "tool_diff") continue;
|
||||
const id = `turn:${turnId}`;
|
||||
const group = groups.get(id) ?? [];
|
||||
group.push(row);
|
||||
groups.set(id, group);
|
||||
const gid = `turn:${turnId}`;
|
||||
toolCounts.set(gid, (toolCounts.get(gid) ?? 0) + 1);
|
||||
}
|
||||
// Second: build groups with interleaved text for turns with 2+ tools.
|
||||
const groups = new Map<string, { toolCount: number; rows: TranscriptRow[] }>();
|
||||
turnId = "before-first-message";
|
||||
// Pending text rows that might be intermediate (not yet confirmed by a
|
||||
// following tool). If no tool follows, they stay standalone.
|
||||
let pendingText: TranscriptRow[] = [];
|
||||
let currentGid = `turn:${turnId}`;
|
||||
for (const row of rows) {
|
||||
if (row.kind === "user") {
|
||||
turnId = row.id;
|
||||
currentGid = `turn:${turnId}`;
|
||||
pendingText = [];
|
||||
continue;
|
||||
}
|
||||
const count = toolCounts.get(currentGid) ?? 0;
|
||||
if (count < 2) continue;
|
||||
if (row.kind === "agent_text" || row.kind === "agent_thought") {
|
||||
pendingText.push(row);
|
||||
continue;
|
||||
}
|
||||
if (row.kind === "tool_call" || row.kind === "tool_diff") {
|
||||
const group = groups.get(currentGid) ?? { toolCount: count, rows: [] };
|
||||
// Flush any pending text — a tool follows, so it's intermediate.
|
||||
group.rows.push(...pendingText);
|
||||
pendingText = [];
|
||||
group.rows.push(row);
|
||||
groups.set(currentGid, group);
|
||||
}
|
||||
}
|
||||
// pendingText at end of a turn = final text, stays outside (not flushed).
|
||||
return groups;
|
||||
}
|
||||
|
||||
@@ -413,9 +458,9 @@ export function createStructuredConnection(
|
||||
const focusedGroupId = active?.closest<HTMLElement>("[data-tool-group-id]")?.dataset.toolGroupId;
|
||||
const restoreToolFocus = active?.classList.contains("st-tool-head") ?? false;
|
||||
const restoreGroupFocus = active?.classList.contains("st-tool-group-head") ?? false;
|
||||
const groups = toolGroupsByTurn();
|
||||
const groups = activityGroupsByTurn();
|
||||
const groupedRowIds = new Set<string>();
|
||||
const liveGroupIds = new Set([...groups].filter(([, tools]) => tools.length > 1).map(([id]) => id));
|
||||
const liveGroupIds = new Set([...groups.keys()]);
|
||||
for (const id of expandedGroupIds) if (!liveGroupIds.has(id)) expandedGroupIds.delete(id);
|
||||
|
||||
rowEls.clear();
|
||||
@@ -425,28 +470,30 @@ export function createStructuredConnection(
|
||||
for (const row of rows) {
|
||||
if (row.kind === "user") turnId = row.id;
|
||||
const groupId = `turn:${turnId}`;
|
||||
const groupRows = groups.get(groupId) ?? [];
|
||||
if ((row.kind === "tool_call" || row.kind === "tool_diff") && groupRows.length > 1) {
|
||||
if (groupedRowIds.has(row.id)) continue;
|
||||
const toolEls = groupRows.flatMap((toolRow) => {
|
||||
groupedRowIds.add(toolRow.id);
|
||||
const toolEl = buildRowElement(toolRow);
|
||||
if (!toolEl) return [];
|
||||
rowEls.set(toolRow.id, toolEl);
|
||||
return [toolEl];
|
||||
const group = groups.get(groupId);
|
||||
if (group && groupedRowIds.has(row.id)) continue;
|
||||
if (group && (row.kind === "tool_call" || row.kind === "tool_diff") && !groupedRowIds.has(row.id)) {
|
||||
// First ungrouped tool in this turn — render the whole activity group.
|
||||
const memberEls = group.rows.flatMap((memberRow) => {
|
||||
groupedRowIds.add(memberRow.id);
|
||||
const memberEl = buildRowElement(memberRow);
|
||||
if (!memberEl) return [];
|
||||
rowEls.set(memberRow.id, memberEl);
|
||||
return [memberEl];
|
||||
});
|
||||
const groupEl = buildToolGroup(
|
||||
groupId,
|
||||
groupRows,
|
||||
toolEls,
|
||||
group.rows,
|
||||
memberEls,
|
||||
expandedGroupIds.has(groupId),
|
||||
(expanded) => {
|
||||
if (expanded) expandedGroupIds.add(groupId);
|
||||
else expandedGroupIds.delete(groupId);
|
||||
settleDisclosureLayout();
|
||||
},
|
||||
group.toolCount,
|
||||
);
|
||||
if (groupRows.some((toolRow) => enteringRowIds.has(toolRow.id))) {
|
||||
if (group.rows.some((r) => enteringRowIds.has(r.id))) {
|
||||
groupEl.classList.add("st-row-enter");
|
||||
}
|
||||
groupEls.set(groupId, groupEl);
|
||||
@@ -606,16 +653,19 @@ export function createStructuredConnection(
|
||||
}
|
||||
|
||||
function renderComposerState(): void {
|
||||
const active = uploading || state.turn.responding || state.turn.thinking || awaitingBeginGeneration !== null;
|
||||
const projectedActive = state.turn.responding || state.turn.thinking;
|
||||
input.disabled = active;
|
||||
attachBtn.disabled = active;
|
||||
fileInput.disabled = active;
|
||||
input.disabled = false;
|
||||
attachBtn.disabled = uploading;
|
||||
fileInput.disabled = uploading;
|
||||
renderAttachments();
|
||||
sendBtn.disabled = active;
|
||||
sendBtn.hidden = projectedActive;
|
||||
sendBtn.disabled = false;
|
||||
sendBtn.hidden = false;
|
||||
sendLabel.textContent = "Send";
|
||||
cancelBtn.hidden = !projectedActive;
|
||||
if (active) closeCommandMenu();
|
||||
queueEl.hidden = promptQueue.length === 0;
|
||||
queueEl.textContent = promptQueue.length === 1
|
||||
? "1 message queued"
|
||||
: `${promptQueue.length} messages queued`;
|
||||
renderActivity();
|
||||
}
|
||||
|
||||
@@ -677,6 +727,7 @@ export function createStructuredConnection(
|
||||
}
|
||||
renderConfig();
|
||||
renderComposerState();
|
||||
dispatchQueuedPrompt();
|
||||
}
|
||||
|
||||
function closeConfigMenu(): void {
|
||||
@@ -876,17 +927,14 @@ export function createStructuredConnection(
|
||||
if (!commandMenu.contains(document.activeElement)) closeCommandMenu();
|
||||
}, 0);
|
||||
});
|
||||
composerForm.addEventListener("submit", (e) => {
|
||||
e.preventDefault();
|
||||
const text = input.value;
|
||||
if ((!text.trim() && attachments.length === 0) || input.disabled) return;
|
||||
const submittedAttachments = attachments;
|
||||
function dispatchPrompt(text: string, submittedAttachments: Attachment[] = []): void {
|
||||
uploading = submittedAttachments.length > 0;
|
||||
const generation = ++submissionGeneration;
|
||||
const submissionSocketGeneration = socketGeneration;
|
||||
awaitingBeginGeneration = generation;
|
||||
optimisticTurnStartedAt = Date.now();
|
||||
acceptedSubmission = null;
|
||||
promptRequestPending = true;
|
||||
input.value = "";
|
||||
autoResize();
|
||||
clearError();
|
||||
@@ -903,6 +951,7 @@ export function createStructuredConnection(
|
||||
};
|
||||
void send().then(
|
||||
() => {
|
||||
promptRequestPending = false;
|
||||
if (disposed || generation !== submissionGeneration) return;
|
||||
uploading = false;
|
||||
if (attachments === submittedAttachments) {
|
||||
@@ -916,11 +965,17 @@ export function createStructuredConnection(
|
||||
// A replacement socket may have captured idle before the POST claimed
|
||||
// the prompt. Reconnect again so its snapshot cannot predate the 204.
|
||||
if (acceptanceSocketGeneration > submissionSocketGeneration) reconnectForReconciliation();
|
||||
renderComposerState();
|
||||
dispatchQueuedPrompt();
|
||||
},
|
||||
(e: unknown) => {
|
||||
promptRequestPending = false;
|
||||
uploading = false;
|
||||
if (disposed || generation !== submissionGeneration) return;
|
||||
if (awaitingBeginGeneration === generation) awaitingBeginGeneration = null;
|
||||
if (attachments.length === 0 && submittedAttachments.length > 0) {
|
||||
attachments = submittedAttachments;
|
||||
}
|
||||
if (input.value === "") {
|
||||
input.value = text;
|
||||
autoResize();
|
||||
@@ -929,6 +984,32 @@ export function createStructuredConnection(
|
||||
renderComposerState();
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function dispatchQueuedPrompt(): void {
|
||||
const active = state.turn.responding || state.turn.thinking || awaitingBeginGeneration !== null;
|
||||
if (disposed || active || promptRequestPending || promptQueue.length === 0) return;
|
||||
const next = promptQueue.shift()!;
|
||||
dispatchPrompt(next.text, next.attachments);
|
||||
}
|
||||
|
||||
composerForm.addEventListener("submit", (e) => {
|
||||
e.preventDefault();
|
||||
const text = input.value;
|
||||
if ((!text.trim() && attachments.length === 0) || uploading) return;
|
||||
const submittedAttachments = attachments;
|
||||
const active = state.turn.responding || state.turn.thinking || awaitingBeginGeneration !== null;
|
||||
if (active || promptQueue.length > 0) {
|
||||
promptQueue.push({ text, attachments: submittedAttachments });
|
||||
attachments = [];
|
||||
input.value = "";
|
||||
autoResize();
|
||||
clearError();
|
||||
renderComposerState();
|
||||
dispatchQueuedPrompt();
|
||||
return;
|
||||
}
|
||||
dispatchPrompt(text, submittedAttachments);
|
||||
});
|
||||
cancelBtn.addEventListener("click", () => {
|
||||
clearError();
|
||||
|
||||
@@ -581,7 +581,9 @@ export function buildToolGroup(
|
||||
children: HTMLElement[],
|
||||
expanded: boolean,
|
||||
onExpandedChange?: (expanded: boolean) => void,
|
||||
toolCount?: number,
|
||||
): HTMLElement {
|
||||
const count = toolCount ?? rows.length;
|
||||
const wrap = el("div", "st-tool-group");
|
||||
wrap.dataset.toolGroupId = id;
|
||||
const head = document.createElement("button");
|
||||
@@ -593,7 +595,7 @@ export function buildToolGroup(
|
||||
glyph.textContent = "›";
|
||||
glyph.setAttribute("aria-hidden", "true");
|
||||
const title = el("span", "st-tool-group-title");
|
||||
title.textContent = `${rows.length} tool calls`;
|
||||
title.textContent = `${count} tool calls`;
|
||||
head.append(glyph, title);
|
||||
|
||||
const body = el("div", "st-tool-group-body st-disclosure");
|
||||
|
||||
+58
-2
@@ -2290,6 +2290,9 @@ input.leaf-rename:focus {
|
||||
.structured-main {
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
max-width: 820px;
|
||||
margin-inline: auto;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1 1 auto;
|
||||
@@ -2895,6 +2898,26 @@ input.leaf-rename:focus {
|
||||
border-radius: var(--r-1);
|
||||
}
|
||||
|
||||
/* intermediate agent text inside a tool group — subdued so the final
|
||||
answer outside the group stays the prominent one. */
|
||||
.st-tool-group-body .st-row-agent {
|
||||
color: var(--muted);
|
||||
font-size: var(--fs-12);
|
||||
padding: var(--sp-1) var(--sp-2);
|
||||
}
|
||||
|
||||
.st-tool-group-body .st-row-agent .st-body > :first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.st-tool-group-body .st-row-agent .st-body > :last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.st-tool-group-body .st-row-thought {
|
||||
padding: var(--sp-1) var(--sp-2);
|
||||
}
|
||||
|
||||
.st-tool-group-body[hidden] {
|
||||
display: none;
|
||||
}
|
||||
@@ -3070,6 +3093,7 @@ input.leaf-rename:focus {
|
||||
hairline that doubles as the focus indicator. Inner metrics line the draft
|
||||
up with the transcript's own 12px gutter. */
|
||||
.structured-composer {
|
||||
position: relative;
|
||||
flex: 0 0 auto;
|
||||
padding: var(--sp-1) var(--sp-1) max(var(--sp-1), env(safe-area-inset-bottom));
|
||||
border-top: 1px solid var(--border);
|
||||
@@ -3080,8 +3104,21 @@ input.leaf-rename:focus {
|
||||
transition: border-color var(--dur-fast) var(--ease-out);
|
||||
}
|
||||
|
||||
.structured-queue {
|
||||
position: absolute;
|
||||
right: var(--sp-2);
|
||||
bottom: calc(100% + var(--sp-1));
|
||||
padding: 3px var(--sp-2);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--r-pill);
|
||||
background: var(--surface);
|
||||
color: var(--muted);
|
||||
font-size: var(--fs-11);
|
||||
box-shadow: var(--shadow-1);
|
||||
}
|
||||
|
||||
.structured-composer:focus-within {
|
||||
border-top-color: var(--accent);
|
||||
border-top-color: var(--border);
|
||||
}
|
||||
|
||||
.structured-input-wrap {
|
||||
@@ -3197,7 +3234,9 @@ input.leaf-rename:focus {
|
||||
}
|
||||
|
||||
.structured-composer .structured-cancel {
|
||||
padding: 0 var(--sp-3);
|
||||
position: relative;
|
||||
width: 40px;
|
||||
padding: 0;
|
||||
border-color: color-mix(in oklab, var(--danger) 35%, var(--border));
|
||||
color: var(--danger);
|
||||
}
|
||||
@@ -3206,6 +3245,23 @@ input.leaf-rename:focus {
|
||||
background: var(--danger-tint);
|
||||
}
|
||||
|
||||
.structured-cancel-stop {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.structured-cancel-spinner {
|
||||
position: absolute;
|
||||
inset: 4px;
|
||||
width: auto;
|
||||
height: auto;
|
||||
animation: cancel-spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes cancel-spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
@media (max-width: 520px) {
|
||||
/* One row cannot hold a draft plus three controls at phone width — the
|
||||
draft gets a line of its own and the controls drop below it. */
|
||||
|
||||
Reference in New Issue
Block a user