Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b9f8f02e11 |
@@ -824,9 +824,7 @@ 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");
|
||||||
const queued = conn.el.querySelectorAll(".st-row-queued");
|
expect(conn.el.querySelector(".structured-queue")?.textContent).toBe("1 message queued");
|
||||||
expect(queued).toHaveLength(1);
|
|
||||||
expect(queued[0].textContent).toBe("queued next");
|
|
||||||
conn.dispose();
|
conn.dispose();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -846,7 +844,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.querySelectorAll(".st-row-queued")).toHaveLength(2);
|
expect(conn.el.querySelector(".structured-queue")?.textContent).toBe("2 messages queued");
|
||||||
|
|
||||||
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 });
|
||||||
@@ -855,7 +853,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.querySelectorAll(".st-row-queued")).toHaveLength(0);
|
expect((conn.el.querySelector(".structured-queue") as HTMLElement).hidden).toBe(true);
|
||||||
conn.dispose();
|
conn.dispose();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
+6
-60
@@ -117,6 +117,7 @@ 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>
|
||||||
@@ -138,6 +139,7 @@ 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;
|
||||||
@@ -251,8 +253,6 @@ 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,9 +383,6 @@ 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,
|
||||||
@@ -402,39 +399,6 @@ 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -728,22 +692,6 @@ 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;
|
||||||
@@ -754,7 +702,10 @@ export function createStructuredConnection(
|
|||||||
sendBtn.hidden = false;
|
sendBtn.hidden = false;
|
||||||
sendLabel.textContent = "Send";
|
sendLabel.textContent = "Send";
|
||||||
cancelBtn.hidden = !projectedActive;
|
cancelBtn.hidden = !projectedActive;
|
||||||
renderQueuedPrompts();
|
queueEl.hidden = promptQueue.length === 0;
|
||||||
|
queueEl.textContent = promptQueue.length === 1
|
||||||
|
? "1 message queued"
|
||||||
|
: `${promptQueue.length} messages queued`;
|
||||||
renderActivity();
|
renderActivity();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1040,9 +991,6 @@ 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 }) =>
|
||||||
@@ -1282,8 +1230,6 @@ 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);
|
||||||
|
|||||||
@@ -315,8 +315,8 @@ describe("buildTranscriptRow: tool diffs", () => {
|
|||||||
(el.querySelector(".st-tool-head") as HTMLButtonElement).click();
|
(el.querySelector(".st-tool-head") as HTMLButtonElement).click();
|
||||||
const diff = el.querySelector(".st-diff") as HTMLElement;
|
const diff = el.querySelector(".st-diff") as HTMLElement;
|
||||||
expect(diff).not.toBeNull();
|
expect(diff).not.toBeNull();
|
||||||
expect(diff.querySelector(".st-ln-add")!.textContent).toBe("c");
|
expect(diff.querySelector(".st-ln-add")!.textContent).toBe("+ c");
|
||||||
expect(diff.querySelector(".st-ln-del")!.textContent).toBe("b");
|
expect(diff.querySelector(".st-ln-del")!.textContent).toBe("- b");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("gives the diff its own horizontally-scrolling container", () => {
|
it("gives the diff its own horizontally-scrolling container", () => {
|
||||||
|
|||||||
@@ -223,15 +223,16 @@ function buildDiffBlock(diff: { path: string; oldText?: string | null; newText:
|
|||||||
wrap.appendChild(label);
|
wrap.appendChild(label);
|
||||||
const scroller = el("div", "st-diff");
|
const scroller = el("div", "st-diff");
|
||||||
const pre = document.createElement("pre");
|
const pre = document.createElement("pre");
|
||||||
for (const line of computeDiffLines(diff.oldText, diff.newText)) {
|
const lines = computeDiffLines(diff.oldText, diff.newText);
|
||||||
|
const prefixes: Record<string, string> = { add: "+ ", del: "- ", ctx: " ", hunk: "", eof: "" };
|
||||||
|
for (const line of lines) {
|
||||||
const span = document.createElement("span");
|
const span = document.createElement("span");
|
||||||
span.className = `st-ln st-ln-${line.kind}`;
|
span.className = `st-ln st-ln-${line.kind}`;
|
||||||
span.textContent = line.text;
|
span.textContent = (prefixes[line.kind] ?? "") + line.text;
|
||||||
pre.appendChild(span);
|
pre.appendChild(span);
|
||||||
pre.appendChild(document.createTextNode("\n"));
|
|
||||||
}
|
}
|
||||||
scroller.appendChild(pre);
|
scroller.appendChild(pre);
|
||||||
makeCopyable(scroller, pre.textContent ?? "", "diff");
|
makeCopyable(scroller, lines.map((l) => (prefixes[l.kind] ?? "") + l.text).join("\n"), "diff");
|
||||||
wrap.appendChild(scroller);
|
wrap.appendChild(scroller);
|
||||||
return wrap;
|
return wrap;
|
||||||
}
|
}
|
||||||
|
|||||||
+13
-25
@@ -2375,36 +2375,11 @@ 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);
|
||||||
@@ -2990,6 +2965,7 @@ input.leaf-rename:focus {
|
|||||||
display: block;
|
display: block;
|
||||||
white-space: pre;
|
white-space: pre;
|
||||||
padding: 0 var(--sp-3);
|
padding: 0 var(--sp-3);
|
||||||
|
line-height: 1.4;
|
||||||
}
|
}
|
||||||
|
|
||||||
.st-ln-add {
|
.st-ln-add {
|
||||||
@@ -3137,6 +3113,18 @@ 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