Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f36b7808a7 | ||
|
|
50b862b1fa | ||
|
|
4668cf8ece | ||
|
|
638155de03 | ||
|
|
1c0fff5507 | ||
|
|
10c9db6d87 |
@@ -193,10 +193,13 @@ choices, not daemon ones.
|
|||||||
one collapsed turn activity block. `AgentText` and `AgentThought` rows that
|
one collapsed turn activity block. `AgentText` and `AgentThought` rows that
|
||||||
appear *between* tool rows in the same turn are absorbed into the group
|
appear *between* tool rows in the same turn are absorbed into the group
|
||||||
(intermediate narration); the last `AgentText` with no subsequent tool stays
|
(intermediate narration); the last `AgentText` with no subsequent tool stays
|
||||||
outside as the prominent final answer. The header still reads
|
outside as the prominent final answer. While a turn is still active
|
||||||
"N tool calls" (counting only tool rows). Opening the group reveals tools
|
(responding or thinking), the most recent intermediate `AgentText` or
|
||||||
and intermediate text in chronological order, with the text rendered in a
|
`AgentThought` in the group is kept outside as visible narration — replaced
|
||||||
subdued style.
|
by the next text row when one arrives, and absorbed into the group when the
|
||||||
|
turn ends. 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
|
- The web client's user, agent, thought, tool-detail, and notice text blocks
|
||||||
copy their complete source text when clicked or keyboard-activated.
|
copy their complete source text when clicked or keyboard-activated.
|
||||||
- The web client consumes `available_commands_update` notices as the live
|
- The web client consumes `available_commands_update` notices as the live
|
||||||
|
|||||||
@@ -446,6 +446,40 @@ describe("structured connection: data flow", () => {
|
|||||||
conn.dispose();
|
conn.dispose();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("keeps last intermediate text outside group while turn is active", () => {
|
||||||
|
const ws = new FakeSocket();
|
||||||
|
const conn = mount({ sockets: [ws] });
|
||||||
|
ws.open();
|
||||||
|
const respondingState: ReducedState = {
|
||||||
|
turn: { responding: true, thinking: false, active_tool_calls: [] },
|
||||||
|
};
|
||||||
|
ws.emit({
|
||||||
|
type: "transcript_snapshot",
|
||||||
|
snapshot: {
|
||||||
|
cursor: 6,
|
||||||
|
rows: [
|
||||||
|
row("user:1", "user", { content: { type: "text", text: "do it" } }),
|
||||||
|
toolRow("tool:1", "one"),
|
||||||
|
row("agent:1", "agent_text", { content: { type: "text", text: "Now fixing" } }),
|
||||||
|
toolRow("tool:2", "two"),
|
||||||
|
row("agent:2", "agent_text", { content: { type: "text", text: "Found more" } }),
|
||||||
|
toolRow("tool:3", "three"),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
ws.emit({ type: "reduced_state", state: respondingState });
|
||||||
|
// While the turn is active, the last intermediate text (agent:2) stays
|
||||||
|
// outside the collapsed group so it's visible as current narration.
|
||||||
|
expect(conn.el.querySelector('[data-row-id="agent:2"]')?.closest(".st-tool-group")).toBeNull();
|
||||||
|
// Earlier intermediate text (agent:1) is still inside the group.
|
||||||
|
expect(conn.el.querySelector('[data-row-id="agent:1"]')?.closest(".st-tool-group")).not.toBeNull();
|
||||||
|
// When the turn ends, all intermediate text is absorbed.
|
||||||
|
ws.emit({ type: "reduced_state", state: idleState });
|
||||||
|
expect(conn.el.querySelector('[data-row-id="agent:2"]')?.closest(".st-tool-group")).not.toBeNull();
|
||||||
|
expect(conn.el.querySelector('[data-row-id="agent:1"]')?.closest(".st-tool-group")).not.toBeNull();
|
||||||
|
conn.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
it("keeps an expanded running tool open without emitting a click when patched", () => {
|
it("keeps an expanded running tool open without emitting a click when patched", () => {
|
||||||
const ws = new FakeSocket();
|
const ws = new FakeSocket();
|
||||||
const conn = mount({ sockets: [ws] });
|
const conn = mount({ sockets: [ws] });
|
||||||
|
|||||||
+77
-7
@@ -85,10 +85,10 @@ export function createStructuredConnection(
|
|||||||
<div class="structured-main">
|
<div class="structured-main">
|
||||||
<div class="structured-scroll">
|
<div class="structured-scroll">
|
||||||
<div class="structured-rows"></div>
|
<div class="structured-rows"></div>
|
||||||
</div>
|
<div class="structured-activity" role="status" aria-live="polite" hidden>
|
||||||
<div class="structured-activity" role="status" aria-live="polite" hidden>
|
<span class="structured-activity-pulse" aria-hidden="true"></span>
|
||||||
<span class="structured-activity-pulse" aria-hidden="true"></span>
|
<span class="structured-activity-text">Thinking</span>
|
||||||
<span class="structured-activity-text">Thinking</span>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<form class="structured-composer">
|
<form class="structured-composer">
|
||||||
<button type="button" class="btn btn-ghost btn-icon structured-attach" aria-label="Attach files" title="Attach files (up to 20 MiB each)">
|
<button type="button" class="btn btn-ghost btn-icon structured-attach" aria-label="Attach files" title="Attach files (up to 20 MiB each)">
|
||||||
@@ -253,6 +253,8 @@ export function createStructuredConnection(
|
|||||||
let activityTimer: number | null = null;
|
let activityTimer: number | null = null;
|
||||||
const promptQueue: QueuedPrompt[] = [];
|
const promptQueue: QueuedPrompt[] = [];
|
||||||
let promptRequestPending = false;
|
let promptRequestPending = false;
|
||||||
|
/** Server path → object URL for uploaded images, so user bubbles can show previews. */
|
||||||
|
const imageObjectUrls = new Map<string, string>();
|
||||||
|
|
||||||
// Within 64px, wheel and touch imprecision should still count as following.
|
// Within 64px, wheel and touch imprecision should still count as following.
|
||||||
const FOLLOW_BOTTOM_THRESHOLD_PX = 64;
|
const FOLLOW_BOTTOM_THRESHOLD_PX = 64;
|
||||||
@@ -383,6 +385,9 @@ export function createStructuredConnection(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Pattern matching `Attached file "name": "path"` references appended by dispatchPrompt. */
|
||||||
|
const attachedFileRe = /Attached file "(?:[^"\\]|\\.)*": "((?:[^"\\]|\\.)*)"/g;
|
||||||
|
|
||||||
function buildRowElement(row: TranscriptRow): HTMLElement | null {
|
function buildRowElement(row: TranscriptRow): HTMLElement | null {
|
||||||
const el = buildTranscriptRow(
|
const el = buildTranscriptRow(
|
||||||
row,
|
row,
|
||||||
@@ -399,6 +404,39 @@ export function createStructuredConnection(
|
|||||||
if (el.querySelector(".st-tool-head")?.getAttribute("aria-expanded") !== "true") {
|
if (el.querySelector(".st-tool-head")?.getAttribute("aria-expanded") !== "true") {
|
||||||
expandedRowIds.delete(row.id);
|
expandedRowIds.delete(row.id);
|
||||||
}
|
}
|
||||||
|
// Inject image previews into user message bubbles and strip the path text.
|
||||||
|
if (row.kind === "user" && imageObjectUrls.size > 0) {
|
||||||
|
const data = row.data as { content?: { type: string; text?: string } } | undefined;
|
||||||
|
const text = data?.content?.type === "text" ? data.content.text ?? "" : "";
|
||||||
|
const body = el.querySelector(".st-body");
|
||||||
|
if (body && text) {
|
||||||
|
const images: string[] = [];
|
||||||
|
attachedFileRe.lastIndex = 0;
|
||||||
|
let match: RegExpExecArray | null;
|
||||||
|
while ((match = attachedFileRe.exec(text)) !== null) {
|
||||||
|
const serverPath = JSON.parse(`"${match[1]}"`);
|
||||||
|
const objectUrl = imageObjectUrls.get(serverPath);
|
||||||
|
if (objectUrl) images.push(objectUrl);
|
||||||
|
}
|
||||||
|
if (images.length > 0) {
|
||||||
|
// Strip attachment reference lines from the displayed text.
|
||||||
|
const userText = body.querySelector(".st-user-text");
|
||||||
|
if (userText) {
|
||||||
|
const cleaned = text.replace(/\n\nAttached file "[^"]*": "[^"]*"/g, "").trim();
|
||||||
|
userText.textContent = cleaned;
|
||||||
|
if (!cleaned) userText.remove();
|
||||||
|
}
|
||||||
|
for (const src of images) {
|
||||||
|
const img = document.createElement("img");
|
||||||
|
img.className = "st-user-image";
|
||||||
|
img.src = src;
|
||||||
|
img.alt = "Uploaded image";
|
||||||
|
img.loading = "lazy";
|
||||||
|
body.appendChild(img);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
return el;
|
return el;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -453,6 +491,25 @@ export function createStructuredConnection(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
// pending at end of a turn = final text/approvals, stays outside (not flushed).
|
// pending at end of a turn = final text/approvals, stays outside (not flushed).
|
||||||
|
|
||||||
|
// While the turn is still active, keep the last intermediate text/thought
|
||||||
|
// row outside the collapsed group so it's visible as the agent's current
|
||||||
|
// narration. It gets absorbed on the next rebuild once newer content
|
||||||
|
// arrives or the turn ends.
|
||||||
|
const turnActive = state.turn.responding || state.turn.thinking;
|
||||||
|
if (turnActive) {
|
||||||
|
const lastGroup = groups.get(currentGid);
|
||||||
|
if (lastGroup) {
|
||||||
|
for (let i = lastGroup.rows.length - 1; i >= 0; i--) {
|
||||||
|
const kind = lastGroup.rows[i].kind;
|
||||||
|
if (kind === "agent_text" || kind === "agent_thought") {
|
||||||
|
lastGroup.rows.splice(i, 1);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return groups;
|
return groups;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -698,6 +755,7 @@ export function createStructuredConnection(
|
|||||||
|
|
||||||
function renderActivity(): void {
|
function renderActivity(): void {
|
||||||
const active = state.turn.responding || state.turn.thinking || awaitingBeginGeneration !== null;
|
const active = state.turn.responding || state.turn.thinking || awaitingBeginGeneration !== null;
|
||||||
|
const wasHidden = activityEl.hidden;
|
||||||
activityEl.hidden = !active;
|
activityEl.hidden = !active;
|
||||||
if (!active) {
|
if (!active) {
|
||||||
optimisticTurnStartedAt = null;
|
optimisticTurnStartedAt = null;
|
||||||
@@ -720,6 +778,11 @@ export function createStructuredConnection(
|
|||||||
}
|
}
|
||||||
activityText.textContent = parts.join(" · ");
|
activityText.textContent = parts.join(" · ");
|
||||||
if (activityTimer === null) activityTimer = window.setInterval(renderActivity, 1_000);
|
if (activityTimer === null) activityTimer = window.setInterval(renderActivity, 1_000);
|
||||||
|
// Activity lives inside the scroll area; nudge scroll when it first appears.
|
||||||
|
if (wasHidden && followingBottom) {
|
||||||
|
beginTranscriptLayout(true);
|
||||||
|
finishTranscriptLayout();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function reconcileAwaitingBegin(): void {
|
function reconcileAwaitingBegin(): void {
|
||||||
@@ -735,15 +798,17 @@ export function createStructuredConnection(
|
|||||||
}
|
}
|
||||||
|
|
||||||
function applyState(next: ReducedState, connectionGeneration: number): void {
|
function applyState(next: ReducedState, connectionGeneration: number): void {
|
||||||
|
const wasTurnActive = state.turn.responding || state.turn.thinking;
|
||||||
state = next;
|
state = next;
|
||||||
stateSocketGeneration = connectionGeneration;
|
stateSocketGeneration = connectionGeneration;
|
||||||
reconcileAwaitingBegin();
|
reconcileAwaitingBegin();
|
||||||
|
const isTurnActive = state.turn.responding || state.turn.thinking;
|
||||||
const newPendingId = state.pending_approval ? lastRowIdOfKind("approval") : null;
|
const newPendingId = state.pending_approval ? lastRowIdOfKind("approval") : null;
|
||||||
if (newPendingId !== pendingApprovalRowId) {
|
const needsRebuild = newPendingId !== pendingApprovalRowId || wasTurnActive !== isTurnActive;
|
||||||
|
if (needsRebuild) {
|
||||||
updateTranscriptLayout(() => {
|
updateTranscriptLayout(() => {
|
||||||
const prev = pendingApprovalRowId;
|
|
||||||
pendingApprovalRowId = newPendingId;
|
pendingApprovalRowId = newPendingId;
|
||||||
if (prev !== null || newPendingId !== null) rebuildTranscript();
|
rebuildTranscript();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
renderConfig();
|
renderConfig();
|
||||||
@@ -964,6 +1029,9 @@ export function createStructuredConnection(
|
|||||||
for (const attachment of submittedAttachments) {
|
for (const attachment of submittedAttachments) {
|
||||||
if (disposed) return;
|
if (disposed) return;
|
||||||
if (!attachment.path) attachment.path = (await uploadStructuredFile(sessionId, attachment.file)).path;
|
if (!attachment.path) attachment.path = (await uploadStructuredFile(sessionId, attachment.file)).path;
|
||||||
|
if (attachment.path && attachment.file.type.startsWith("image/")) {
|
||||||
|
imageObjectUrls.set(attachment.path, URL.createObjectURL(attachment.file));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (disposed) return;
|
if (disposed) return;
|
||||||
const references = submittedAttachments.map(({ file, path }) =>
|
const references = submittedAttachments.map(({ file, path }) =>
|
||||||
@@ -1203,6 +1271,8 @@ export function createStructuredConnection(
|
|||||||
closeCurrentSocket();
|
closeCurrentSocket();
|
||||||
expandedRowIds.clear();
|
expandedRowIds.clear();
|
||||||
expandedGroupIds.clear();
|
expandedGroupIds.clear();
|
||||||
|
for (const url of imageObjectUrls.values()) URL.revokeObjectURL(url);
|
||||||
|
imageObjectUrls.clear();
|
||||||
scrollEl.removeEventListener("scroll", onTranscriptScroll);
|
scrollEl.removeEventListener("scroll", onTranscriptScroll);
|
||||||
scrollEl.removeEventListener("click", onTranscriptClick, true);
|
scrollEl.removeEventListener("click", onTranscriptClick, true);
|
||||||
scrollEl.removeEventListener("wheel", stopSmoothFollow);
|
scrollEl.removeEventListener("wheel", stopSmoothFollow);
|
||||||
|
|||||||
+9
-2
@@ -2318,9 +2318,7 @@ input.leaf-rename:focus {
|
|||||||
gap: var(--sp-2);
|
gap: var(--sp-2);
|
||||||
min-height: 30px;
|
min-height: 30px;
|
||||||
padding: var(--sp-1) var(--sp-3);
|
padding: var(--sp-1) var(--sp-3);
|
||||||
border-top: 1px solid var(--border);
|
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
background: var(--surface-2);
|
|
||||||
font-size: var(--fs-11);
|
font-size: var(--fs-11);
|
||||||
font-variant-numeric: tabular-nums;
|
font-variant-numeric: tabular-nums;
|
||||||
}
|
}
|
||||||
@@ -2382,6 +2380,15 @@ input.leaf-rename:focus {
|
|||||||
overflow-wrap: anywhere;
|
overflow-wrap: anywhere;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.st-user-image {
|
||||||
|
display: block;
|
||||||
|
max-width: min(100%, 480px);
|
||||||
|
max-height: 360px;
|
||||||
|
margin-top: var(--sp-2);
|
||||||
|
border-radius: var(--r-1);
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
|
||||||
/* agent prose: no card, no background — plain text is the point */
|
/* agent prose: no card, no background — plain text is the point */
|
||||||
.st-row-agent .st-body p {
|
.st-row-agent .st-body p {
|
||||||
margin: 0 0 var(--sp-2);
|
margin: 0 0 var(--sp-2);
|
||||||
|
|||||||
Reference in New Issue
Block a user