Compare commits
6
Commits
vivace
...
staccato-2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e9c9e1c647 | ||
|
|
4167b21fe9 | ||
|
|
f36b7808a7 | ||
|
|
50b862b1fa | ||
|
|
4668cf8ece | ||
|
|
638155de03 |
@@ -824,7 +824,9 @@ describe("structured connection: turn/composer gating", () => {
|
|||||||
expect(send.disabled).toBe(false);
|
expect(send.disabled).toBe(false);
|
||||||
expect(sendStructuredPrompt).toHaveBeenCalledTimes(1);
|
expect(sendStructuredPrompt).toHaveBeenCalledTimes(1);
|
||||||
expect(sendStructuredPrompt).toHaveBeenCalledWith("s1", "only once");
|
expect(sendStructuredPrompt).toHaveBeenCalledWith("s1", "only once");
|
||||||
expect(conn.el.querySelector(".structured-queue")?.textContent).toBe("1 message queued");
|
const queued = conn.el.querySelectorAll(".st-row-queued");
|
||||||
|
expect(queued).toHaveLength(1);
|
||||||
|
expect(queued[0].textContent).toBe("queued next");
|
||||||
conn.dispose();
|
conn.dispose();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -844,7 +846,7 @@ describe("structured connection: turn/composer gating", () => {
|
|||||||
submit("second");
|
submit("second");
|
||||||
submit("third");
|
submit("third");
|
||||||
expect(sendStructuredPrompt).toHaveBeenCalledTimes(1);
|
expect(sendStructuredPrompt).toHaveBeenCalledTimes(1);
|
||||||
expect(conn.el.querySelector(".structured-queue")?.textContent).toBe("2 messages queued");
|
expect(conn.el.querySelectorAll(".st-row-queued")).toHaveLength(2);
|
||||||
|
|
||||||
await Promise.resolve().then(() => Promise.resolve());
|
await Promise.resolve().then(() => Promise.resolve());
|
||||||
ws.emit({ type: "reduced_state", state: idleState });
|
ws.emit({ type: "reduced_state", state: idleState });
|
||||||
@@ -853,7 +855,7 @@ describe("structured connection: turn/composer gating", () => {
|
|||||||
await Promise.resolve().then(() => Promise.resolve());
|
await Promise.resolve().then(() => Promise.resolve());
|
||||||
ws.emit({ type: "reduced_state", state: idleState });
|
ws.emit({ type: "reduced_state", state: idleState });
|
||||||
expect(sendStructuredPrompt).toHaveBeenNthCalledWith(3, "s1", "third");
|
expect(sendStructuredPrompt).toHaveBeenNthCalledWith(3, "s1", "third");
|
||||||
expect((conn.el.querySelector(".structured-queue") as HTMLElement).hidden).toBe(true);
|
expect(conn.el.querySelectorAll(".st-row-queued")).toHaveLength(0);
|
||||||
conn.dispose();
|
conn.dispose();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
+70
-10
@@ -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)">
|
||||||
@@ -117,7 +117,6 @@ export function createStructuredConnection(
|
|||||||
<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-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>
|
<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>
|
</button>
|
||||||
<span class="structured-queue" role="status" aria-live="polite" hidden></span>
|
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -139,7 +138,6 @@ export function createStructuredConnection(
|
|||||||
const sendBtn = el.querySelector(".structured-send") as HTMLButtonElement;
|
const sendBtn = el.querySelector(".structured-send") as HTMLButtonElement;
|
||||||
const sendLabel = sendBtn.querySelector("span") as HTMLSpanElement;
|
const sendLabel = sendBtn.querySelector("span") as HTMLSpanElement;
|
||||||
const cancelBtn = el.querySelector(".structured-cancel") as HTMLButtonElement;
|
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 attachBtn = el.querySelector(".structured-attach") as HTMLButtonElement;
|
||||||
const fileInput = el.querySelector(".structured-file-input") as HTMLInputElement;
|
const fileInput = el.querySelector(".structured-file-input") as HTMLInputElement;
|
||||||
const attachmentEl = el.querySelector(".structured-attachments") as HTMLDivElement;
|
const attachmentEl = el.querySelector(".structured-attachments") as HTMLDivElement;
|
||||||
@@ -253,6 +251,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 +383,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 +402,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;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -692,6 +728,22 @@ export function createStructuredConnection(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function renderQueuedPrompts(): void {
|
||||||
|
for (const old of rowsEl.querySelectorAll(".st-row-queued")) old.remove();
|
||||||
|
for (const queued of promptQueue) {
|
||||||
|
const wrap = document.createElement("div");
|
||||||
|
wrap.className = "st-row st-row-queued";
|
||||||
|
const body = document.createElement("div");
|
||||||
|
body.className = "st-body";
|
||||||
|
const line = document.createElement("div");
|
||||||
|
line.className = "st-user-text";
|
||||||
|
line.textContent = queued.text;
|
||||||
|
body.appendChild(line);
|
||||||
|
wrap.appendChild(body);
|
||||||
|
rowsEl.appendChild(wrap);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function renderComposerState(): void {
|
function renderComposerState(): void {
|
||||||
const projectedActive = state.turn.responding || state.turn.thinking;
|
const projectedActive = state.turn.responding || state.turn.thinking;
|
||||||
input.disabled = false;
|
input.disabled = false;
|
||||||
@@ -702,10 +754,7 @@ export function createStructuredConnection(
|
|||||||
sendBtn.hidden = false;
|
sendBtn.hidden = false;
|
||||||
sendLabel.textContent = "Send";
|
sendLabel.textContent = "Send";
|
||||||
cancelBtn.hidden = !projectedActive;
|
cancelBtn.hidden = !projectedActive;
|
||||||
queueEl.hidden = promptQueue.length === 0;
|
renderQueuedPrompts();
|
||||||
queueEl.textContent = promptQueue.length === 1
|
|
||||||
? "1 message queued"
|
|
||||||
: `${promptQueue.length} messages queued`;
|
|
||||||
renderActivity();
|
renderActivity();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -717,6 +766,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;
|
||||||
@@ -739,6 +789,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 {
|
||||||
@@ -985,6 +1040,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 }) =>
|
||||||
@@ -1224,6 +1282,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);
|
||||||
|
|||||||
+25
-14
@@ -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;
|
||||||
}
|
}
|
||||||
@@ -2377,11 +2375,36 @@ input.leaf-rename:focus {
|
|||||||
background: var(--surface-2);
|
background: var(--surface-2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Queued messages: optimistic user bubbles, not yet sent. */
|
||||||
|
.st-row-queued {
|
||||||
|
justify-content: flex-end;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.st-row-queued .st-body {
|
||||||
|
flex: 0 1 auto;
|
||||||
|
max-width: min(86%, 72ch);
|
||||||
|
padding: var(--sp-2) var(--sp-3);
|
||||||
|
border-radius: var(--r-2);
|
||||||
|
background: var(--surface-3);
|
||||||
|
opacity: 0.45;
|
||||||
|
filter: blur(0.5px);
|
||||||
|
}
|
||||||
|
|
||||||
.st-user-text {
|
.st-user-text {
|
||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
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);
|
||||||
@@ -3114,18 +3137,6 @@ input.leaf-rename:focus {
|
|||||||
transition: border-color var(--dur-fast) var(--ease-out);
|
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 {
|
.structured-composer:focus-within {
|
||||||
border-top-color: var(--border);
|
border-top-color: var(--border);
|
||||||
|
|||||||
Reference in New Issue
Block a user