mirror of
https://github.com/turnstonelabs/turnstone.git
synced 2026-08-12 23:12:23 -06:00
feat: convert large pastes to attachments
This commit is contained in:
@@ -18,6 +18,12 @@ Earlier stable lines (`stable/1.6`, `stable/1.5`) are frozen.
|
||||
|
||||
### Added
|
||||
|
||||
- **Large plain-text pastes become attachments.** Pasting text longer than the
|
||||
fixed 2,000-character threshold stages `pasted-text.txt` across all five
|
||||
attachment-capable create/send composers. Clipboard files retain priority,
|
||||
text above the 512 KiB upload ceiling stays inline, identical synthesized
|
||||
pastes collapse to one chip, and rejected attachment sends keep the staged
|
||||
message and files so they can be corrected or retried.
|
||||
- **`server_parses_reasoning` model capability.** Declare it on a model
|
||||
definition whose backend segregates reasoning into its own channel (a
|
||||
vLLM launched with a reasoning parser, a commercial provider): the
|
||||
|
||||
@@ -429,6 +429,20 @@ Files require a non-empty initial task so the first turn consumes the staged
|
||||
attachments. The console shell does not currently expose a fork action; use the
|
||||
node's standalone workstream UI or the create API's `resume_ws` field.
|
||||
|
||||
### Large pasted text
|
||||
|
||||
Browser composers turn plain text longer than 2,000 Unicode code points into a
|
||||
`text/plain` attachment named `pasted-text.txt`. A paste exactly at the
|
||||
threshold stays inline. This applies to the interactive and coordinator send
|
||||
boxes, the console home launcher, and the node dashboard and new-workstream
|
||||
composers.
|
||||
|
||||
Clipboard files take priority over clipboard text. Text larger than the 512 KiB
|
||||
attachment ceiling also stays inline, so the browser does not discard it before
|
||||
a rejected upload. Attachments require a companion message and cannot be sent
|
||||
as live-turn interjections; a busy composer preserves its message and chips for
|
||||
an idle retry.
|
||||
|
||||
### Saved and filtered sessions
|
||||
|
||||
Saved coordinator and interactive sessions share one list with kind and persona
|
||||
|
||||
+189
-2
@@ -74,6 +74,21 @@ Attachments harness (/attachments/livepass.html): the composer attachment
|
||||
thumbnail crop/size, the native audio-control fit at the constrained
|
||||
height, the snippet contrast, and how a long filename behaves at the
|
||||
340px chip cap.
|
||||
Paste-over-HTTP harness (/paste/livepass.html): the REAL Composer's
|
||||
large-text paste path, exercised with a browser-generated, trusted paste
|
||||
event on an explicitly non-secure HTTP origin. No
|
||||
``navigator.clipboard`` stub, synthetic ``ClipboardEvent``, or
|
||||
secure-context override is involved. The page requires
|
||||
``window.isSecureContext === false``, ``event.isTrusted``, a ``text/plain``
|
||||
clipboard item, canceled inline insertion, and an exact
|
||||
``pasted-text.txt`` File round-trip before stamping ``PASTE-HTTP-READY``.
|
||||
It fails closed as ``PASTE-HTTP-FAILED-<reason>``. Serve beyond loopback,
|
||||
open the page by a LAN address (localhost and 127.0.0.1 are treated as
|
||||
trustworthy origins by browsers), then use the browser's normal Copy and
|
||||
Paste commands:
|
||||
|
||||
python3 scripts/livepass.py --serve 8950 --bind 0.0.0.0
|
||||
|
||||
Task-agent harness (/taskagent/livepass.html): the task_agent card — a task
|
||||
agent's sub-tool steps nested under its conversation row, driven through the
|
||||
REAL InteractivePane.handleEvent (parent tool_pending/tool_info -> child
|
||||
@@ -1031,6 +1046,165 @@ ATTACH_TEMPLATE = """<!doctype html>
|
||||
"""
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Native paste-over-HTTP harness. Unlike the copy-affordance harness, this
|
||||
# must never stub clipboard access or dispatch a script-created paste event:
|
||||
# its purpose is to prove that the production ClipboardEvent path still sees
|
||||
# user-agent clipboard data on a non-secure origin.
|
||||
# --------------------------------------------------------------------------
|
||||
PASTE_TEMPLATE = """<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>PASTE-HTTP-BOOTING</title>
|
||||
<link rel="stylesheet" href="shared/base.css" />
|
||||
<link rel="stylesheet" href="shared/ui-base.css" />
|
||||
<link rel="stylesheet" href="shared/chat.css" />
|
||||
<style>
|
||||
body {
|
||||
margin: 0; padding: 32px;
|
||||
background: var(--bg); color: var(--fg);
|
||||
font-family: var(--font-sans, system-ui, sans-serif);
|
||||
}
|
||||
main { width: min(760px, 100%); margin: 0 auto; }
|
||||
#paste-source {
|
||||
box-sizing: border-box; width: 100%; height: 80px;
|
||||
}
|
||||
#composer-mount, #probe-status { margin-top: 16px; }
|
||||
#probe-status { white-space: pre-wrap; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<h1>Native paste on plain HTTP</h1>
|
||||
<p>
|
||||
This passes only when a trusted paste exposes clipboard text to the
|
||||
real Composer on a non-secure HTTP origin.
|
||||
</p>
|
||||
<p>1. Select this 2001-character fixture, then copy it normally.</p>
|
||||
<textarea id="paste-source" aria-label="Paste test source"></textarea>
|
||||
<button id="select-source" type="button">Select source text</button>
|
||||
<p>2. Focus the composer and paste normally.</p>
|
||||
<div id="composer-mount"></div>
|
||||
<pre id="probe-status" role="status" aria-live="polite">Booting…</pre>
|
||||
</main>
|
||||
<script type="module">
|
||||
import { Composer } from "./shared/composer.js";
|
||||
import { PASTE_ATTACHMENT_CHARS } from "./shared/composer_paste_text.js";
|
||||
|
||||
const prefix = "TURNSTONE-PASTE-HTTP:";
|
||||
const seedText =
|
||||
prefix + "x".repeat(PASTE_ATTACHMENT_CHARS + 1 - prefix.length);
|
||||
const source = document.getElementById("paste-source");
|
||||
const status = document.getElementById("probe-status");
|
||||
source.value = seedText;
|
||||
|
||||
let copyTrusted = false;
|
||||
let paste = null;
|
||||
let attachment = null;
|
||||
let fileSettled = false;
|
||||
let failed = false;
|
||||
|
||||
function paint() {
|
||||
status.textContent = document.title + "\\n" + JSON.stringify({
|
||||
origin: location.origin,
|
||||
secureContext: window.isSecureContext,
|
||||
copyTrusted: copyTrusted,
|
||||
paste: paste,
|
||||
attachment: attachment,
|
||||
}, null, 2);
|
||||
}
|
||||
|
||||
function fail(reason) {
|
||||
if (failed) return;
|
||||
failed = true;
|
||||
document.title = "PASTE-HTTP-FAILED-" + reason;
|
||||
paint();
|
||||
}
|
||||
|
||||
function finish() {
|
||||
if (failed || !paste || !fileSettled) return;
|
||||
const checks = [
|
||||
["copy-untrusted", copyTrusted],
|
||||
["paste-untrusted", paste.trusted],
|
||||
["no-clipboard-data", paste.hasClipboardData],
|
||||
["no-text-plain", paste.hasPlainText],
|
||||
["paste-not-canceled", paste.defaultPrevented],
|
||||
["attach-count", attachment.calls === 1],
|
||||
["filename", attachment.name === "pasted-text.txt"],
|
||||
["mime", attachment.type === "text/plain"],
|
||||
["size", attachment.size === new Blob([seedText]).size],
|
||||
["content", attachment.contentMatches],
|
||||
["inline-insert", paste.inputValue === ""],
|
||||
];
|
||||
const firstFailure = checks.find(function (entry) { return !entry[1]; });
|
||||
if (firstFailure) return fail(firstFailure[0]);
|
||||
document.title = "PASTE-HTTP-READY";
|
||||
paint();
|
||||
}
|
||||
|
||||
document.addEventListener("copy", function (event) {
|
||||
copyTrusted = event.isTrusted;
|
||||
paint();
|
||||
}, true);
|
||||
|
||||
document.addEventListener("paste", function (event) {
|
||||
const observed = {
|
||||
trusted: event.isTrusted,
|
||||
hasClipboardData: !!event.clipboardData,
|
||||
hasPlainText: !!event.clipboardData &&
|
||||
Array.from(event.clipboardData.types || []).includes("text/plain"),
|
||||
};
|
||||
setTimeout(function () {
|
||||
paste = Object.assign(observed, {
|
||||
defaultPrevented: event.defaultPrevented,
|
||||
inputValue: composer.inputEl.value,
|
||||
});
|
||||
if (!attachment) fail("no-attachment");
|
||||
else finish();
|
||||
}, 0);
|
||||
});
|
||||
|
||||
const composer = new Composer(document.getElementById("composer-mount"), {
|
||||
onSend: function () {},
|
||||
placeholder: "Paste the copied fixture here…",
|
||||
attachments: {
|
||||
onAttach: function (file) {
|
||||
attachment = {
|
||||
calls: attachment ? attachment.calls + 1 : 1,
|
||||
name: file.name,
|
||||
type: file.type,
|
||||
size: file.size,
|
||||
contentMatches: false,
|
||||
};
|
||||
file.text().then(function (text) {
|
||||
attachment.contentMatches = text === seedText;
|
||||
fileSettled = true;
|
||||
finish();
|
||||
}, function () { fail("file-read"); });
|
||||
return true;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
document.getElementById("select-source").addEventListener("click", function () {
|
||||
source.focus();
|
||||
source.select();
|
||||
});
|
||||
|
||||
if (location.protocol !== "http:") fail("not-http");
|
||||
else if (window.isSecureContext) fail("secure-context");
|
||||
else {
|
||||
document.title = "PASTE-HTTP-WAITING";
|
||||
paint();
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Task-agent harness — the task_agent card: a task agent's sub-tool steps
|
||||
# nested under its conversation row. Driven through the REAL
|
||||
@@ -1958,6 +2132,12 @@ def build(out: Path) -> None:
|
||||
(att / "livepass.html").write_text(ATTACH_TEMPLATE, encoding="utf-8")
|
||||
print(f"{att}/livepass.html — composer chips + message attachment pills")
|
||||
|
||||
paste = out / "paste"
|
||||
paste.mkdir(parents=True, exist_ok=True)
|
||||
symlink(paste / "shared", ROOT / "turnstone/shared_static")
|
||||
(paste / "livepass.html").write_text(PASTE_TEMPLATE, encoding="utf-8")
|
||||
print(f"{paste}/livepass.html — trusted native paste on insecure HTTP")
|
||||
|
||||
ta = out / "taskagent"
|
||||
ta.mkdir(parents=True, exist_ok=True)
|
||||
symlink(ta / "shared", ROOT / "turnstone/shared_static")
|
||||
@@ -2218,6 +2398,12 @@ def main() -> None:
|
||||
ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
|
||||
ap.add_argument("--out", type=Path, default=Path("/tmp/livepass"))
|
||||
ap.add_argument("--serve", type=int, metavar="PORT")
|
||||
ap.add_argument(
|
||||
"--bind",
|
||||
default="127.0.0.1",
|
||||
metavar="HOST",
|
||||
help="listen address for --serve (use 0.0.0.0 for a manual insecure-origin pass)",
|
||||
)
|
||||
ap.add_argument("--perf", action="store_true", help="run the perf baseline and exit")
|
||||
ap.add_argument(
|
||||
"--perf-n",
|
||||
@@ -2235,8 +2421,9 @@ def main() -> None:
|
||||
import functools
|
||||
|
||||
handler = functools.partial(_HarnessHandler, directory=str(args.out))
|
||||
print(f"serving {args.out} on http://localhost:{args.serve}/ — Ctrl+C stops")
|
||||
http.server.ThreadingHTTPServer(("127.0.0.1", args.serve), handler).serve_forever()
|
||||
display_host = "localhost" if args.bind == "127.0.0.1" else args.bind
|
||||
print(f"serving {args.out} on http://{display_host}:{args.serve}/ — Ctrl+C stops")
|
||||
http.server.ThreadingHTTPServer((args.bind, args.serve), handler).serve_forever()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -0,0 +1,253 @@
|
||||
"""Behavior and wiring tests for large-paste attachment conversion."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
_ROOT = Path(__file__).resolve().parent.parent
|
||||
_HELPER = _ROOT / "turnstone/shared_static/composer_paste_text.js"
|
||||
_COMPOSER = _ROOT / "turnstone/shared_static/composer.js"
|
||||
_ATTACHMENTS = _ROOT / "turnstone/shared_static/composer_attachments.js"
|
||||
_INTERACTIVE = _ROOT / "turnstone/shared_static/interactive.js"
|
||||
_UI_APP = _ROOT / "turnstone/ui/static/app.js"
|
||||
_CONSOLE_APP = _ROOT / "turnstone/console/static/app.js"
|
||||
_COORDINATOR = _ROOT / "turnstone/console/static/coordinator/coordinator.js"
|
||||
|
||||
|
||||
def test_paste_text_helper_behavior(tmp_path: Path) -> None:
|
||||
"""Execute the real ESM and pin its fixed threshold, byte cap, and dedup."""
|
||||
if shutil.which("node") is None:
|
||||
pytest.skip("node binary not available on PATH")
|
||||
|
||||
module = tmp_path / "composer_paste_text.mjs"
|
||||
module.write_text(_HELPER.read_text(encoding="utf-8"), encoding="utf-8")
|
||||
script = tmp_path / "paste_harness.mjs"
|
||||
module_url = json.dumps(module.as_uri())
|
||||
script.write_text(
|
||||
f"const moduleUrl = {module_url};\n"
|
||||
+ r"""
|
||||
import { Blob, File } from "node:buffer";
|
||||
globalThis.Blob = Blob;
|
||||
globalThis.File = File;
|
||||
globalThis.window = globalThis;
|
||||
|
||||
function check(condition, message) {
|
||||
if (!condition) throw new Error(message);
|
||||
}
|
||||
|
||||
const paste = await import(moduleUrl);
|
||||
check(paste.PASTE_ATTACHMENT_CHARS === 2000, "fixed threshold drifted");
|
||||
check(
|
||||
paste.pasteTextToFile("x".repeat(1999)) === null,
|
||||
"below-threshold text converted",
|
||||
);
|
||||
const exactText = "x".repeat(2000);
|
||||
const exact = paste.pasteTextToFile(exactText);
|
||||
check(exact === null, "exact-threshold text converted");
|
||||
const convertedText = "x".repeat(2001);
|
||||
const converted = paste.pasteTextToFile(convertedText);
|
||||
check(converted instanceof File, "above-threshold text did not convert");
|
||||
check(converted.name === "pasted-text.txt", "filename drifted");
|
||||
check(converted.type === "text/plain", "MIME drifted");
|
||||
check(converted.size === 2001, "byte size drifted");
|
||||
check(
|
||||
(await converted.text()) === convertedText,
|
||||
"file content did not round-trip",
|
||||
);
|
||||
check(
|
||||
paste.pasteTextToFile("") === null,
|
||||
"empty text converted",
|
||||
);
|
||||
check(
|
||||
paste.pasteTextToFile("😀".repeat(2000)) === null,
|
||||
"UTF-16 code units were counted as characters",
|
||||
);
|
||||
check(
|
||||
paste.pasteTextToFile("😀".repeat(2001)) instanceof File,
|
||||
"Unicode code points above the threshold did not convert",
|
||||
);
|
||||
const cjkAtCap = "界".repeat(Math.floor(paste.TEXT_ATTACHMENT_MAX_BYTES / 3));
|
||||
check(
|
||||
paste.pasteTextToFile(cjkAtCap) instanceof File,
|
||||
"text within the byte ceiling did not convert",
|
||||
);
|
||||
check(
|
||||
paste.pasteTextToFile(cjkAtCap + "界") === null,
|
||||
"multibyte text escaped the byte ceiling",
|
||||
);
|
||||
|
||||
const sameText = "same".repeat(501);
|
||||
const sameA = paste.pasteTextToFile(sameText);
|
||||
const sameB = paste.pasteTextToFile(sameText);
|
||||
const different = paste.pasteTextToFile("size".repeat(501));
|
||||
check(
|
||||
paste.isDuplicatePastedTextFile(sameB, [sameA]),
|
||||
"identical synthesized pastes did not deduplicate",
|
||||
);
|
||||
check(
|
||||
!paste.isDuplicatePastedTextFile(different, [sameA]),
|
||||
"different same-size pastes were deduplicated",
|
||||
);
|
||||
check(
|
||||
!paste.isDuplicatePastedTextFile(
|
||||
new File([sameText], "ordinary.txt", { type: "text/plain" }),
|
||||
[sameA],
|
||||
),
|
||||
"ordinary user files entered synthesized-paste dedup",
|
||||
);
|
||||
""",
|
||||
encoding="utf-8",
|
||||
)
|
||||
proc = subprocess.run(
|
||||
["node", str(script)],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=15,
|
||||
)
|
||||
assert proc.returncode == 0, f"paste harness failed:\n{proc.stderr}\n{proc.stdout}"
|
||||
|
||||
|
||||
def test_attachment_snapshot_reports_in_flight_upload(tmp_path: Path) -> None:
|
||||
"""A synthesized paste cannot disappear from a send-time snapshot while
|
||||
its immediate upload is still resolving."""
|
||||
if shutil.which("node") is None:
|
||||
pytest.skip("node binary not available on PATH")
|
||||
|
||||
script = tmp_path / "attachment_snapshot_harness.mjs"
|
||||
module_url = json.dumps(_ATTACHMENTS.as_uri())
|
||||
script.write_text(
|
||||
f"const moduleUrl = {module_url};\n"
|
||||
+ r"""
|
||||
import { File } from "node:buffer";
|
||||
globalThis.File = File;
|
||||
globalThis.window = globalThis;
|
||||
|
||||
function fakeElement() {
|
||||
return {
|
||||
children: [],
|
||||
dataset: {},
|
||||
appendChild(child) {
|
||||
this.children.push(child);
|
||||
return child;
|
||||
},
|
||||
addEventListener() {},
|
||||
querySelector() { return null; },
|
||||
setAttribute() {},
|
||||
remove() {},
|
||||
};
|
||||
}
|
||||
globalThis.document = { createElement: () => fakeElement() };
|
||||
|
||||
let resolveUpload;
|
||||
const uploadResponse = new Promise((resolve) => { resolveUpload = resolve; });
|
||||
const attachmentsModule = await import(moduleUrl);
|
||||
const controller = attachmentsModule.createAttachmentController({
|
||||
chipsEl: fakeElement(),
|
||||
getWsId: () => "ws-1",
|
||||
authFetch: () => uploadResponse,
|
||||
});
|
||||
|
||||
let snap = controller.snapshot();
|
||||
if (snap.uploading || snap.attachment_ids.length)
|
||||
throw new Error("empty controller reported an upload");
|
||||
|
||||
controller.upload(new File(["large paste"], "pasted-text.txt", {
|
||||
type: "text/plain",
|
||||
}));
|
||||
snap = controller.snapshot();
|
||||
if (!snap.uploading)
|
||||
throw new Error("in-flight placeholder was omitted from snapshot state");
|
||||
if (snap.attachments.length || snap.attachment_ids.length)
|
||||
throw new Error("placeholder escaped into stable attachment arrays");
|
||||
|
||||
resolveUpload({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: () => Promise.resolve({
|
||||
attachment_id: "attachment-1",
|
||||
filename: "pasted-text.txt",
|
||||
size_bytes: 11,
|
||||
mime_type: "text/plain",
|
||||
kind: "text",
|
||||
}),
|
||||
});
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
snap = controller.snapshot();
|
||||
if (snap.uploading)
|
||||
throw new Error("settled upload remained marked in flight");
|
||||
if (snap.attachment_ids.join(",") !== "attachment-1")
|
||||
throw new Error("settled upload was not sendable: " + snap.attachment_ids);
|
||||
""",
|
||||
encoding="utf-8",
|
||||
)
|
||||
proc = subprocess.run(
|
||||
["node", str(script)],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=15,
|
||||
)
|
||||
assert proc.returncode == 0, (
|
||||
f"attachment snapshot harness failed:\n{proc.stderr}\n{proc.stdout}"
|
||||
)
|
||||
|
||||
|
||||
def test_paste_text_wiring_guard_rails() -> None:
|
||||
"""Guard every surface around the behavior-tested shared helper."""
|
||||
helper = _HELPER.read_text(encoding="utf-8")
|
||||
composer = _COMPOSER.read_text(encoding="utf-8")
|
||||
attachments = _ATTACHMENTS.read_text(encoding="utf-8")
|
||||
interactive = _INTERACTIVE.read_text(encoding="utf-8")
|
||||
ui_app = _UI_APP.read_text(encoding="utf-8")
|
||||
console_app = _CONSOLE_APP.read_text(encoding="utf-8")
|
||||
coordinator = _COORDINATOR.read_text(encoding="utf-8")
|
||||
|
||||
assert "window.TurnstonePasteText" in helper
|
||||
assert "PASTE_ATTACHMENT_CHARS = 2000" in helper
|
||||
assert "paste_attachment_chars" not in helper
|
||||
assert "loadPasteThresholdChars" not in helper
|
||||
assert 'from "./composer_paste_text.js"' in composer
|
||||
assert "pasteTextToFile(text" in composer
|
||||
assert "2000" not in composer, "the threshold belongs in the shared helper"
|
||||
assert composer.index("if (uploaded > 0)") < composer.index("pasteTextToFile(text")
|
||||
assert "if (accepted !== false) e.preventDefault();" in composer
|
||||
assert "if (pending.has(info.attachment_id))" in attachments
|
||||
assert "pending.delete(placeholderId);" in attachments
|
||||
assert "uploading: uploading" in attachments
|
||||
|
||||
assert "function _handleComposerPaste(" in ui_app
|
||||
assert "if (files.length > 0)" in ui_app
|
||||
assert "if (!textFile || addFiles([textFile]) === false) return false;" in ui_app
|
||||
assert "_handleComposerPaste(event, _newWsAddFiles)" in ui_app
|
||||
assert "_handleComposerPaste(e, _addDashboardFiles)" in ui_app
|
||||
assert "initEl.onpaste" in ui_app
|
||||
assert ui_app.count("Add a message to send with this attachment.") == 2
|
||||
assert "_loadPasteAttachmentSetting" not in ui_app
|
||||
|
||||
assert "return _homeStageFile(file);" in console_app
|
||||
assert "isDuplicatePastedTextFile(file, _homeStagedFiles)" in console_app
|
||||
assert "Add a message to send with this attachment." in console_app
|
||||
assert "_loadPasteAttachmentSetting" not in console_app
|
||||
|
||||
for pane in (interactive, coordinator):
|
||||
assert "Add a message to send with this attachment." in pane
|
||||
assert "Attachments can't be sent while the assistant is working." in pane
|
||||
assert "if (snap.uploading)" in pane
|
||||
assert "Wait for attachments to finish uploading before sending." in pane
|
||||
assert "!attachments.isEmpty()" in pane or "!this.attachments.isEmpty()" in pane
|
||||
assert "loadPasteThresholdChars" not in coordinator
|
||||
|
||||
for page in (
|
||||
_ROOT / "turnstone/ui/static/index.html",
|
||||
_ROOT / "turnstone/console/static/index.html",
|
||||
_ROOT / "turnstone/console/static/coordinator/index.html",
|
||||
):
|
||||
body = page.read_text(encoding="utf-8")
|
||||
assert "/shared/composer_paste_text.js" in body, page
|
||||
assert body.index("/shared/composer_paste_text.js") < body.index("/shared/composer.js"), (
|
||||
page
|
||||
)
|
||||
@@ -1209,8 +1209,8 @@ def test_deferred_send_settle_protocol_pins() -> None:
|
||||
assert "deferred: !!data.deferred" in composer_queue
|
||||
assert "attachedCount: (data.attached_ids || []).length" in composer_queue
|
||||
assert "ctx.busyIsOptimistic()" in composer_queue
|
||||
assert composer_queue.count("ctx.optimisticEl.remove()") >= 2, (
|
||||
"both the retro-convert and queue_full arms must clear the optimistic bubble"
|
||||
assert composer_queue.count("ctx.optimisticEl.remove()") >= 3, (
|
||||
"retro-convert, queue_full, and attachments_busy must clear false optimistic bubbles"
|
||||
)
|
||||
# The missed-edge settle: a non-deferred chip binding onto an
|
||||
# already-idle pane missed its only sweep — the post-bind promote
|
||||
@@ -1233,6 +1233,10 @@ def test_deferred_send_settle_protocol_pins() -> None:
|
||||
assert "settleSendResponse(" not in src, f"{name}: must not bypass the fetch stage"
|
||||
assert "busyIsOptimistic" in src, name
|
||||
assert "paneIsBusy" in src, f"{name}: the missed-edge settle needs the live flag"
|
||||
assert "mergeRejectedComposerText" in src, f"{name}: refused text must be restored"
|
||||
assert src.count("restoreInput:") == 2, (
|
||||
f"{name}: composer send and edit-resend both need refusal restoration"
|
||||
)
|
||||
assert 'setBusy(true, "optimistic")' in src, f"{name}: optimistic flip must stamp"
|
||||
assert "parsePriority(" in src, f"{name}: shared !!! parse"
|
||||
assert 'case "message_dispatched"' in src, f"{name}: settle event not consumed"
|
||||
@@ -1332,6 +1336,89 @@ console.log("settle matrix OK");
|
||||
assert proc.returncode == 0, f"settle harness failed:\n{proc.stderr}\n{proc.stdout}"
|
||||
|
||||
|
||||
def test_stale_idle_refusals_restore_input(tmp_path) -> None:
|
||||
"""A stale local idle state must not render either busy refusal as
|
||||
delivered or discard its companion text. Text entered during the POST is
|
||||
retained after the rejected text, and an SSE idle that already arrived
|
||||
prevents the old optimistic busy state from being reasserted."""
|
||||
import shutil
|
||||
import subprocess
|
||||
|
||||
if shutil.which("node") is None:
|
||||
pytest.skip("node binary not available on PATH")
|
||||
helper = _ROOT / "turnstone/shared_static/composer_queue.js"
|
||||
script = tmp_path / "attachments_busy_harness.mjs"
|
||||
script.write_text(
|
||||
rf"""const {{ mergeRejectedComposerText, settleSendResponse }} =
|
||||
await import("file://{helper}");
|
||||
|
||||
function run(status, optimisticBusy) {{
|
||||
const calls = [];
|
||||
let composerValue = "typed during request";
|
||||
const optimisticEl = {{
|
||||
isConnected: true,
|
||||
dataset: {{}},
|
||||
remove: () => calls.push("remove-optimistic"),
|
||||
}};
|
||||
settleSendResponse(
|
||||
{{ remove: () => calls.push("remove-queued") }},
|
||||
{{ status }},
|
||||
{{
|
||||
queuedEl: null,
|
||||
optimisticEl,
|
||||
isBusy: false,
|
||||
setBusy: (value) => calls.push("busy:" + value),
|
||||
busyIsOptimistic: () => optimisticBusy,
|
||||
paneIsBusy: () => optimisticBusy,
|
||||
restoreInput: () => {{
|
||||
composerValue = mergeRejectedComposerText("rejected", composerValue);
|
||||
calls.push("restore");
|
||||
}},
|
||||
renderError: () => calls.push("error"),
|
||||
consumeAttachments: () => calls.push("consume"),
|
||||
}},
|
||||
);
|
||||
return {{ calls, composerValue }};
|
||||
}}
|
||||
|
||||
for (const [status, expectedBusy] of [
|
||||
["attachments_busy", "busy:true"],
|
||||
["cross_user_interjection", "busy:false"],
|
||||
["queue_full", "busy:false"],
|
||||
]) {{
|
||||
let result = run(status, true);
|
||||
if (result.composerValue !== "rejected\ntyped during request")
|
||||
throw new Error(status + " companion/current text merge drifted: " + result.composerValue);
|
||||
for (const call of ["remove-optimistic", "restore", expectedBusy, "error"]) {{
|
||||
if (!result.calls.includes(call))
|
||||
throw new Error(status + " missing stale-idle settlement " + call + ": " + result.calls);
|
||||
}}
|
||||
if (result.calls.includes("consume") || result.calls.includes("remove-queued"))
|
||||
throw new Error(status + " attachments/chip state was consumed: " + result.calls);
|
||||
|
||||
result = run(status, false);
|
||||
if (result.calls.some((call) => call.startsWith("busy:")))
|
||||
throw new Error(status + " overwrote a raced SSE state: " + result.calls);
|
||||
}}
|
||||
if (
|
||||
mergeRejectedComposerText("rejected", "rejected\nlater") !==
|
||||
"rejected\nrejected\nlater"
|
||||
)
|
||||
throw new Error("independently typed matching text was discarded");
|
||||
if (mergeRejectedComposerText("rejected", "") !== "rejected")
|
||||
throw new Error("empty composer did not restore rejected text");
|
||||
""",
|
||||
encoding="utf-8",
|
||||
)
|
||||
proc = subprocess.run(
|
||||
["node", str(script)],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=15,
|
||||
)
|
||||
assert proc.returncode == 0, f"attachments_busy harness failed:\n{proc.stderr}\n{proc.stdout}"
|
||||
|
||||
|
||||
def test_accepted_tool_event_recorded_only_when_painted() -> None:
|
||||
"""An unpainted accepted tool_result must stay replayable.
|
||||
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
"""Regression guards for the native paste-over-HTTP livepass."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
_ROOT = Path(__file__).resolve().parent.parent
|
||||
_SCRIPT = _ROOT / "scripts/livepass.py"
|
||||
|
||||
|
||||
def _load_livepass() -> Any:
|
||||
spec = importlib.util.spec_from_file_location("livepass_paste_script", _SCRIPT)
|
||||
assert spec is not None and spec.loader is not None
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
def test_paste_livepass_builds_real_clipboard_event_path(tmp_path: Path) -> None:
|
||||
livepass = _load_livepass()
|
||||
livepass.build(tmp_path)
|
||||
|
||||
page_path = tmp_path / "paste/livepass.html"
|
||||
page = page_path.read_text(encoding="utf-8")
|
||||
assert (tmp_path / "paste/shared").resolve() == _ROOT / "turnstone/shared_static"
|
||||
assert 'import { Composer } from "./shared/composer.js";' in page
|
||||
assert "PASTE_ATTACHMENT_CHARS" in page
|
||||
assert "new Composer(" in page
|
||||
assert "event.isTrusted" in page
|
||||
assert "event.clipboardData" in page
|
||||
assert "window.isSecureContext" in page
|
||||
assert 'attachment.name === "pasted-text.txt"' in page
|
||||
assert 'attachment.type === "text/plain"' in page
|
||||
assert 'document.title = "PASTE-HTTP-READY"' in page
|
||||
assert 'document.title = "PASTE-HTTP-FAILED-" + reason' in page
|
||||
|
||||
# These shortcuts would make the harness green without exercising the
|
||||
# browser's native clipboard event and therefore invalidate its purpose.
|
||||
assert "navigator.clipboard" not in page
|
||||
assert "new ClipboardEvent" not in page
|
||||
assert ".dispatchEvent(" not in page
|
||||
assert "__pasteProbe" not in page
|
||||
|
||||
|
||||
def test_generated_paste_module_is_valid_javascript(tmp_path: Path) -> None:
|
||||
if shutil.which("node") is None:
|
||||
pytest.skip("node binary not available on PATH")
|
||||
livepass = _load_livepass()
|
||||
livepass.build(tmp_path)
|
||||
page = (tmp_path / "paste/livepass.html").read_text(encoding="utf-8")
|
||||
match = re.search(r'<script type="module">\s*(.*?)\s*</script>', page, re.S)
|
||||
assert match is not None
|
||||
module = tmp_path / "paste_livepass.mjs"
|
||||
module.write_text(match.group(1), encoding="utf-8")
|
||||
|
||||
result = subprocess.run(
|
||||
["node", "--check", str(module)],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=15,
|
||||
)
|
||||
assert result.returncode == 0, result.stderr
|
||||
@@ -46,6 +46,7 @@ _ESM_BUNDLES = [
|
||||
_SHARED / "auth.js",
|
||||
_SHARED / "renderer.js",
|
||||
_SHARED / "status_bar.js",
|
||||
_SHARED / "composer_paste_text.js",
|
||||
_SHARED / "composer.js",
|
||||
_SHARED / "composer_attachments.js",
|
||||
_SHARED / "composer_queue.js",
|
||||
@@ -73,6 +74,7 @@ _ESM_NO_VAR_BUNDLES = [
|
||||
_SHARED / "toast.js",
|
||||
_SHARED / "kb.js",
|
||||
_SHARED / "auth.js",
|
||||
_SHARED / "composer_paste_text.js",
|
||||
_SHARED / "interactive.js",
|
||||
_SHARED / "conversation.js",
|
||||
_SHARED / "preview.js",
|
||||
|
||||
@@ -1491,12 +1491,20 @@ function _homeRenderChips() {
|
||||
}
|
||||
|
||||
function _homeStageFile(file) {
|
||||
if (!file) return;
|
||||
if (!file) return false;
|
||||
const paste = window.TurnstonePasteText;
|
||||
if (
|
||||
paste &&
|
||||
paste.isDuplicatePastedTextFile &&
|
||||
paste.isDuplicatePastedTextFile(file, _homeStagedFiles)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
if (_homeStagedFiles.length >= _HOME_MAX_FILES) {
|
||||
_homeShowError(
|
||||
"At most " + _HOME_MAX_FILES + " attachments per coordinator",
|
||||
);
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
if (!_homeIsAttachmentAllowed(file)) {
|
||||
_homeShowError(
|
||||
@@ -1504,17 +1512,18 @@ function _homeStageFile(file) {
|
||||
file.name +
|
||||
" (allowed: png/jpeg/gif/webp images, text)",
|
||||
);
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
const isImage = (file.type || "").indexOf("image/") === 0;
|
||||
const cap = isImage ? _HOME_IMAGE_CAP : _HOME_TEXT_CAP;
|
||||
if (file.size > cap) {
|
||||
_homeShowError(file.name + " exceeds the " + _homeFormatSize(cap) + " cap");
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
_homeShowError("");
|
||||
_homeStagedFiles.push(file);
|
||||
_homeRenderChips();
|
||||
return true;
|
||||
}
|
||||
|
||||
function _homeClearStagedFiles() {
|
||||
@@ -1807,7 +1816,7 @@ function _mountHomeCoordComposer() {
|
||||
},
|
||||
attachments: {
|
||||
onAttach: function (file) {
|
||||
_homeStageFile(file);
|
||||
return _homeStageFile(file);
|
||||
},
|
||||
},
|
||||
dragDrop: { targetEl: mount, dropClass: "home-coord-drop" },
|
||||
@@ -1962,9 +1971,7 @@ function submitHomeCoord(textFromComposer) {
|
||||
// pending storage rows until the GC sweep. Require text whenever
|
||||
// attachments are staged so the first turn always picks them up.
|
||||
if (files.length > 0 && !(task || "").trim()) {
|
||||
_homeShowError(
|
||||
"Add a task message — attachments need an initial turn to dispatch on.",
|
||||
);
|
||||
_homeShowError("Add a message to send with this attachment.");
|
||||
return;
|
||||
}
|
||||
const shared = {
|
||||
|
||||
@@ -50,6 +50,7 @@ import {
|
||||
acceptUserTurnEvent,
|
||||
clientSendMaySettleForViewer,
|
||||
createQueueController,
|
||||
mergeRejectedComposerText,
|
||||
mintClientSendId,
|
||||
parsePriority,
|
||||
postAndSettleSend,
|
||||
@@ -2656,9 +2657,34 @@ function createCoordinatorPane(root, wsId, opts) {
|
||||
function coordSend() {
|
||||
const text = composer.value;
|
||||
const trimmed = (text || "").trim();
|
||||
if (!trimmed) return false;
|
||||
if (!trimmed) {
|
||||
if (!attachments.isEmpty()) {
|
||||
appendText("info", "Add a message to send with this attachment.", {
|
||||
label: "info",
|
||||
});
|
||||
}
|
||||
return false;
|
||||
}
|
||||
// The live-turn interjection queue is text-only. Keep the input and chips
|
||||
// intact instead of optimistically clearing them before attachments_busy.
|
||||
if (busy && !attachments.isEmpty()) {
|
||||
appendText(
|
||||
"info",
|
||||
"Attachments can't be sent while the assistant is working. Wait for it to finish, then send again.",
|
||||
{ label: "info" },
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
const snap = attachments.snapshot();
|
||||
if (snap.uploading) {
|
||||
appendText(
|
||||
"info",
|
||||
"Wait for attachments to finish uploading before sending.",
|
||||
{ label: "info" },
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
let queuedEl = null;
|
||||
let optimisticEl = null;
|
||||
@@ -2734,6 +2760,9 @@ function createCoordinatorPane(root, wsId, opts) {
|
||||
setBusy: (b) => setBusy(b),
|
||||
busyIsOptimistic: () => busy && busySource === "optimistic",
|
||||
paneIsBusy: () => busy,
|
||||
restoreInput: () => {
|
||||
composer.value = mergeRejectedComposerText(trimmed, composer.value);
|
||||
},
|
||||
renderError: (msg) => appendText("error", msg, { label: "error" }),
|
||||
consumeAttachments: (attached, droppedIds) =>
|
||||
attachments.consume(attached, droppedIds),
|
||||
@@ -4278,6 +4307,12 @@ function createCoordinatorPane(root, wsId, opts) {
|
||||
setBusy: (value) => setBusy(value),
|
||||
busyIsOptimistic: () => busy && busySource === "optimistic",
|
||||
paneIsBusy: () => busy,
|
||||
restoreInput: () => {
|
||||
composer.value = mergeRejectedComposerText(
|
||||
editText,
|
||||
composer.value,
|
||||
);
|
||||
},
|
||||
renderError: (message) =>
|
||||
appendText("error", message, { label: "error" }),
|
||||
consumeAttachments: () => {},
|
||||
|
||||
@@ -43,6 +43,7 @@
|
||||
<script type="module" src="/shared/toast.js"></script>
|
||||
<script type="module" src="/shared/auth.js"></script>
|
||||
<script type="module" src="/shared/kb.js"></script>
|
||||
<script type="module" src="/shared/composer_paste_text.js"></script>
|
||||
<script type="module" src="/shared/composer.js"></script>
|
||||
<script type="module" src="/shared/composer_attachments.js"></script>
|
||||
<script type="module" src="/shared/composer_queue.js"></script>
|
||||
|
||||
@@ -4116,6 +4116,7 @@
|
||||
<script type="module" src="/shared/models.js"></script>
|
||||
<script type="module" src="/shared/skills.js"></script>
|
||||
<script type="module" src="/shared/project_creator.js"></script>
|
||||
<script type="module" src="/shared/composer_paste_text.js"></script>
|
||||
<script type="module" src="/shared/composer.js"></script>
|
||||
<!-- coordinator-pane deps (step 4): the controller builds its chrome + uses
|
||||
the shared composer/renderer stack; load before the shell module -->
|
||||
|
||||
@@ -12,7 +12,8 @@
|
||||
* Event surface (caller-provided callbacks):
|
||||
* onSend(text) — Enter / Send-button — required
|
||||
* onStop() — Stop button click — required iff stopBtn=true
|
||||
* onAttach(file) — file picker change OR paste image OR drop
|
||||
* onAttach(file) — file picker change OR paste image/text OR drop;
|
||||
* synchronous false preserves native text paste
|
||||
*
|
||||
* Imperative API on the returned instance:
|
||||
* value — current textarea value (getter / setter)
|
||||
@@ -34,6 +35,8 @@
|
||||
* whole pane) — broader than the composer itself so users can drop
|
||||
* anywhere onto the pane to attach.
|
||||
*/
|
||||
import { pasteTextToFile } from "./composer_paste_text.js";
|
||||
|
||||
var ATTACH_DEFAULT_ACCEPT =
|
||||
"image/png,image/jpeg,image/gif,image/webp,application/pdf,text/*," +
|
||||
"audio/wav,audio/mpeg,audio/ogg,audio/flac,audio/mp4,audio/aac,audio/webm," +
|
||||
@@ -62,7 +65,7 @@ function makeButton(opts) {
|
||||
* @param {Object} opts — configuration:
|
||||
* onSend: (text) => void
|
||||
* onStop: () => void
|
||||
* attachments: { onAttach: (file) => void, accept?: string } | null
|
||||
* attachments: { onAttach: (file) => void|false, accept?: string } | null
|
||||
* stopBtn: boolean (default false)
|
||||
* queueWhileBusy: boolean (default false) — busy state shows "Queue"
|
||||
* instead of disabling the send button
|
||||
@@ -484,7 +487,21 @@ Composer.prototype._wireEvents = function () {
|
||||
}
|
||||
}
|
||||
}
|
||||
if (uploaded > 0) e.preventDefault();
|
||||
if (uploaded > 0) {
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
var text = e.clipboardData
|
||||
? e.clipboardData.getData("text/plain")
|
||||
: "";
|
||||
var textFile = pasteTextToFile(text);
|
||||
if (textFile) {
|
||||
// A synchronous false lets pre-create staging reject safely (for
|
||||
// example, its file-count cap) and preserve the native inline paste.
|
||||
// Immediate-upload controllers return undefined and remain accepted.
|
||||
var accepted = opts.attachments.onAttach(textFile);
|
||||
if (accepted !== false) e.preventDefault();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -23,9 +23,10 @@
|
||||
* clearChips() — drop all chips + map entries (no DELETE).
|
||||
* rehydrate() — pull the server-side pending list (page
|
||||
* reload / tab switch).
|
||||
* snapshot() — {attachments, attachment_ids} of stable
|
||||
* chips only (skips in-flight placeholders),
|
||||
* ready to feed into a /send body.
|
||||
* snapshot() — {attachments, attachment_ids, uploading}; the
|
||||
* arrays contain stable chips only, while the flag
|
||||
* lets senders refuse before an in-flight
|
||||
* placeholder is silently omitted.
|
||||
* consume(attached_ids,
|
||||
* dropped_ids?) — strip chips for ids the server reserved;
|
||||
* surface a toast if any were dropped.
|
||||
@@ -327,6 +328,14 @@ export function createAttachmentController(opts) {
|
||||
// user dismissed would attach an untracked element (not in the
|
||||
// map, so coordSend wouldn't include it) and confuse them.
|
||||
if (!pending.has(placeholderId)) return;
|
||||
// Content-addressed uploads are idempotent. If this response resolves to
|
||||
// an attachment already represented by another chip, discard only this
|
||||
// upload's placeholder so identical pastes visibly deduplicate too.
|
||||
if (pending.has(info.attachment_id)) {
|
||||
_removeChipDom(placeholderId);
|
||||
pending.delete(placeholderId);
|
||||
return;
|
||||
}
|
||||
_replaceMapKey(pending, placeholderId, info.attachment_id, info);
|
||||
|
||||
var chip = _findChip(placeholderId);
|
||||
@@ -454,13 +463,20 @@ export function createAttachmentController(opts) {
|
||||
function snapshot() {
|
||||
var attachments = [];
|
||||
var ids = [];
|
||||
var uploading = false;
|
||||
pending.forEach(function (info, id) {
|
||||
if (info && !info.uploading) {
|
||||
attachments.push(info);
|
||||
ids.push(id);
|
||||
} else if (info && info.uploading) {
|
||||
uploading = true;
|
||||
}
|
||||
});
|
||||
return { attachments: attachments, attachment_ids: ids };
|
||||
return {
|
||||
attachments: attachments,
|
||||
attachment_ids: ids,
|
||||
uploading: uploading,
|
||||
};
|
||||
}
|
||||
|
||||
function consume(attachedIds, droppedIds) {
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
/* composer_paste_text.js — shared large-paste attachment policy.
|
||||
*
|
||||
* DOM-free so the decision can be exercised directly under Node. Paste event
|
||||
* handlers stay surface-owned: they must preserve each composer's existing
|
||||
* file-first staging path and call preventDefault() only after this helper
|
||||
* returns a File.
|
||||
*/
|
||||
|
||||
export const PASTE_ATTACHMENT_CHARS = 2000;
|
||||
// Keep the byte ceiling aligned with core/attachments.py:TEXT_DOC_SIZE_CAP.
|
||||
export const TEXT_ATTACHMENT_MAX_BYTES = 512 * 1024;
|
||||
|
||||
const PASTED_TEXT_FILENAME = "pasted-text.txt";
|
||||
const PASTED_TEXT_MIME = "text/plain";
|
||||
const pastedTextSources = new WeakMap();
|
||||
|
||||
function hasMoreThanCharacters(text, thresholdChars) {
|
||||
let count = 0;
|
||||
// String iteration counts Unicode code points, matching Python's len()
|
||||
// more closely than UTF-16 String.length (which counts emoji twice).
|
||||
for (const _character of text) {
|
||||
count += 1;
|
||||
if (count > thresholdChars) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function pasteTextToFile(text) {
|
||||
if (typeof text !== "string" || text.length === 0) return null;
|
||||
if (!hasMoreThanCharacters(text, PASTE_ATTACHMENT_CHARS)) return null;
|
||||
|
||||
// Blob.size is the UTF-8 byte count browsers use for the synthesized File.
|
||||
// Check it before the caller suppresses the native paste: over-cap text must
|
||||
// remain inline instead of becoming a guaranteed 413 with no textarea copy.
|
||||
if (new Blob([text]).size > TEXT_ATTACHMENT_MAX_BYTES) return null;
|
||||
|
||||
const file = new File([text], PASTED_TEXT_FILENAME, {
|
||||
type: PASTED_TEXT_MIME,
|
||||
});
|
||||
pastedTextSources.set(file, text);
|
||||
return file;
|
||||
}
|
||||
|
||||
export function isDuplicatePastedTextFile(file, existingFiles) {
|
||||
const source = pastedTextSources.get(file);
|
||||
if (source === undefined) return false;
|
||||
for (const existing of existingFiles || []) {
|
||||
if (pastedTextSources.get(existing) === source) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Classic node/console bundles consume the same module at boot/event time.
|
||||
if (typeof window !== "undefined") {
|
||||
window.TurnstonePasteText = {
|
||||
isDuplicatePastedTextFile,
|
||||
pasteTextToFile,
|
||||
};
|
||||
}
|
||||
@@ -507,6 +507,18 @@ export function parsePriority(text) {
|
||||
return { displayText: text, priority: "notice" };
|
||||
}
|
||||
|
||||
// Restore a server-refused send without overwriting text entered during the
|
||||
// POST round-trip. The rejected message stays first (its original chronology).
|
||||
// Settlement is one-shot, so never infer duplicate restoration from content:
|
||||
// the user may independently type the same text while the request is pending.
|
||||
export function mergeRejectedComposerText(rejectedText, currentText) {
|
||||
var rejected = rejectedText == null ? "" : String(rejectedText);
|
||||
var current = currentText == null ? "" : String(currentText);
|
||||
if (!rejected) return current;
|
||||
if (!current) return rejected;
|
||||
return rejected + "\n" + current;
|
||||
}
|
||||
|
||||
// Mint one opaque browser correlation token. This is deliberately not a
|
||||
// delivery/idempotency key; the server may accept the same value on multiple
|
||||
// distinct turns, whose event ids remain authoritative.
|
||||
@@ -678,6 +690,8 @@ export function acceptUserTurnEvent(evt, host) {
|
||||
// has since asserted it (see the panes' busySource stamp)
|
||||
// paneIsBusy(): the pane's LIVE busy flag (not the send-time snapshot)
|
||||
// — drives the missed-edge settle below
|
||||
// restoreInput(): restore rejected companion text without overwriting input
|
||||
// entered during the POST round-trip
|
||||
// renderError(msg): pane error row
|
||||
// consumeAttachments(attached_ids, dropped_ids): composer chip sync
|
||||
//
|
||||
@@ -693,11 +707,15 @@ export function acceptUserTurnEvent(evt, host) {
|
||||
// post-bind settle promotes it (see the inline contract).
|
||||
// queue_full — the send was NEVER accepted (interjection cap, deferred-
|
||||
// list saturation, or drain-spawn failure): remove the optimistic
|
||||
// bubble too — leaving it renders loss as delivery — and restore busy
|
||||
// under the same guard (no worker and no drain may exist to ever emit
|
||||
// a state event; leaving busy strands the composer in Stop mode).
|
||||
// busy / attachments_busy / cross_user_interjection / unknown-ok —
|
||||
// the panes' historical shapes, verbatim.
|
||||
// bubble too — leaving it renders loss as delivery — restore the input,
|
||||
// and restore busy under the same guard (no worker and no drain may exist
|
||||
// to ever emit a state event; leaving busy strands the composer in Stop
|
||||
// mode).
|
||||
// attachments_busy / cross_user_interjection — remove the false sent bubble,
|
||||
// restore the companion text, and preserve attachment chips. The former
|
||||
// proves server busy; the latter can also be a retained-input refusal with
|
||||
// no worker, so it clears only this send's still-optimistic busy stamp.
|
||||
// busy / unknown-ok — historical behavior.
|
||||
export function settleSendResponse(queue, data, ctx) {
|
||||
// Normalize a null / non-object 2xx body once, here at the shared
|
||||
// chokepoint, so neither pane's call site has to guard it (interactive
|
||||
@@ -787,11 +805,19 @@ export function settleSendResponse(queue, data, ctx) {
|
||||
ctx.optimisticEl.remove();
|
||||
if (ctx.busyIsOptimistic()) ctx.setBusy(false);
|
||||
}
|
||||
if (typeof ctx.restoreInput === "function") ctx.restoreInput();
|
||||
ctx.renderError("Message queue full. Please wait.");
|
||||
return;
|
||||
}
|
||||
if (status === "attachments_busy") {
|
||||
if (ctx.queuedEl) queue.remove(ctx.queuedEl);
|
||||
if (ctx.optimisticEl && ctx.optimisticEl.isConnected)
|
||||
ctx.optimisticEl.remove();
|
||||
if (typeof ctx.restoreInput === "function") ctx.restoreInput();
|
||||
// The server just proved a worker owns the slot. Replace this send's
|
||||
// optimistic source stamp with a server stamp; an idle SSE that already
|
||||
// arrived would have cleared it and therefore fails this guard.
|
||||
if (ctx.busyIsOptimistic()) ctx.setBusy(true);
|
||||
ctx.renderError(
|
||||
"Attachments can't be sent while the assistant is working. " +
|
||||
"Send a text-only message now, or wait and resend with attachments.",
|
||||
@@ -800,12 +826,18 @@ export function settleSendResponse(queue, data, ctx) {
|
||||
}
|
||||
if (status === "cross_user_interjection") {
|
||||
if (ctx.queuedEl) queue.remove(ctx.queuedEl);
|
||||
if (ctx.optimisticEl && ctx.optimisticEl.isConnected)
|
||||
ctx.optimisticEl.remove();
|
||||
if (typeof ctx.restoreInput === "function") ctx.restoreInput();
|
||||
// A 409 can also come from retained foreign queued input with no live
|
||||
// worker, so it does not prove busy. Undo only this send's optimistic
|
||||
// stamp; a newer SSE state remains authoritative in either direction.
|
||||
if (ctx.busyIsOptimistic()) ctx.setBusy(false);
|
||||
ctx.renderError(
|
||||
data.error ||
|
||||
"Another participant's turn is in progress. Wait for it to " +
|
||||
"finish, then send your message.",
|
||||
);
|
||||
if (!ctx.isBusy) ctx.setBusy(false);
|
||||
return;
|
||||
}
|
||||
// Unknown / "ok" status (e.g. the stale-busy race: the client
|
||||
|
||||
@@ -52,6 +52,7 @@ import {
|
||||
acceptUserTurnEvent,
|
||||
clientSendMaySettleForViewer,
|
||||
createQueueController,
|
||||
mergeRejectedComposerText,
|
||||
mintClientSendId,
|
||||
parsePriority,
|
||||
postAndSettleSend,
|
||||
@@ -2887,6 +2888,12 @@ class Pane {
|
||||
busyIsOptimistic: () =>
|
||||
this.busy && this.busySource === "optimistic",
|
||||
paneIsBusy: () => this.busy,
|
||||
restoreInput: () => {
|
||||
this.composer.value = mergeRejectedComposerText(
|
||||
editText,
|
||||
this.composer.value,
|
||||
);
|
||||
},
|
||||
renderError: (message) => this.addErrorMessage(message),
|
||||
consumeAttachments: () => {},
|
||||
},
|
||||
@@ -4708,7 +4715,12 @@ class Pane {
|
||||
|
||||
sendMessage() {
|
||||
const text = this.inputEl.value.trim();
|
||||
if (!text) return;
|
||||
if (!text) {
|
||||
if (!this.attachments.isEmpty()) {
|
||||
this.addInfoMessage("Add a message to send with this attachment.");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (text.startsWith("/")) {
|
||||
if (this.busy) {
|
||||
@@ -4798,11 +4810,27 @@ class Pane {
|
||||
return;
|
||||
}
|
||||
|
||||
// Attachments cannot ride the live turn's text-only interjection queue.
|
||||
// Refuse before the optimistic clear so the user's companion text and
|
||||
// staged chips remain ready to send once the worker is idle.
|
||||
if (this.busy && !this.attachments.isEmpty()) {
|
||||
this.addInfoMessage(
|
||||
"Attachments can't be sent while the assistant is working. Wait for it to finish, then send again.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const isBusy = this.busy;
|
||||
let queuedEl = null;
|
||||
let optimisticEl = null;
|
||||
const clientSendId = mintClientSendId();
|
||||
const snap = this.attachments.snapshot();
|
||||
if (snap.uploading) {
|
||||
this.addInfoMessage(
|
||||
"Wait for attachments to finish uploading before sending.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Display-only strip of the !!! prefix (the server re-parses it
|
||||
// authoritatively); shared parse so the settle helper's retro-convert
|
||||
@@ -4874,6 +4902,12 @@ class Pane {
|
||||
setBusy: (b) => this.setBusy(b),
|
||||
busyIsOptimistic: () => this.busy && this.busySource === "optimistic",
|
||||
paneIsBusy: () => this.busy,
|
||||
restoreInput: () => {
|
||||
this.composer.value = mergeRejectedComposerText(
|
||||
text,
|
||||
this.composer.value,
|
||||
);
|
||||
},
|
||||
renderError: (msg) => this.addErrorMessage(msg),
|
||||
consumeAttachments: (attached, droppedIds) =>
|
||||
this.attachments.consume(attached, droppedIds),
|
||||
|
||||
+77
-21
@@ -329,6 +329,35 @@ function _isAttachmentAllowed(file) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// File clipboard items preserve their existing priority. Only when none were
|
||||
// staged do we consider synthesizing a text attachment through the shared
|
||||
// module bridge. Returns true when the native paste was handled.
|
||||
function _handleComposerPaste(event, addFiles) {
|
||||
if (!event.clipboardData) return false;
|
||||
const items = event.clipboardData.items || [];
|
||||
const files = [];
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
if (items[i].kind === "file") {
|
||||
const file = items[i].getAsFile();
|
||||
if (file) files.push(file);
|
||||
}
|
||||
}
|
||||
if (files.length > 0) {
|
||||
event.preventDefault();
|
||||
addFiles(files);
|
||||
return true;
|
||||
}
|
||||
|
||||
const paste = window.TurnstonePasteText;
|
||||
if (!paste || !paste.pasteTextToFile) return false;
|
||||
const textFile = paste.pasteTextToFile(
|
||||
event.clipboardData.getData("text/plain"),
|
||||
);
|
||||
if (!textFile || addFiles([textFile]) === false) return false;
|
||||
event.preventDefault();
|
||||
return true;
|
||||
}
|
||||
|
||||
// In-dialog error strip (sh-alert). Empty message clears + hides; a set
|
||||
// message also scrolls into view — the alert sits at the top of the
|
||||
// scrollable body while the submit lives in the pinned foot.
|
||||
@@ -344,13 +373,23 @@ function _newWsError(msg) {
|
||||
}
|
||||
|
||||
function _newWsAddFiles(files) {
|
||||
let handled = false;
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
const f = files[i];
|
||||
const paste = window.TurnstonePasteText;
|
||||
if (
|
||||
paste &&
|
||||
paste.isDuplicatePastedTextFile &&
|
||||
paste.isDuplicatePastedTextFile(f, _newWsStagedFiles)
|
||||
) {
|
||||
handled = true;
|
||||
continue;
|
||||
}
|
||||
if (_newWsStagedFiles.length >= _NEW_WS_MAX_FILES) {
|
||||
_newWsError(
|
||||
"At most " + _NEW_WS_MAX_FILES + " attachments per workstream",
|
||||
);
|
||||
return;
|
||||
return handled;
|
||||
}
|
||||
if (!_isAttachmentAllowed(f)) {
|
||||
_newWsError(
|
||||
@@ -358,18 +397,20 @@ function _newWsAddFiles(files) {
|
||||
f.name +
|
||||
" (allowed: png/jpeg/gif/webp images, text)",
|
||||
);
|
||||
return;
|
||||
return handled;
|
||||
}
|
||||
const isImage = (f.type || "").indexOf("image/") === 0;
|
||||
const cap = isImage ? _NEW_WS_IMAGE_CAP : _NEW_WS_TEXT_CAP;
|
||||
if (f.size > cap) {
|
||||
_newWsError(f.name + " exceeds the " + _formatAttachSize(cap) + " cap");
|
||||
return;
|
||||
return handled;
|
||||
}
|
||||
_newWsStagedFiles.push(f);
|
||||
handled = true;
|
||||
}
|
||||
_newWsError("");
|
||||
_newWsRenderChips();
|
||||
return handled;
|
||||
}
|
||||
|
||||
function newWorkstream() {
|
||||
@@ -465,7 +506,14 @@ function showNewWsModal(forkFromWsId) {
|
||||
|
||||
document.getElementById("new-ws-name").value = "";
|
||||
const initEl = document.getElementById("new-ws-initial-message");
|
||||
if (initEl) initEl.value = "";
|
||||
if (initEl) {
|
||||
initEl.value = "";
|
||||
initEl.onpaste = function (event) {
|
||||
// Forks inherit history and intentionally have no attachment lane.
|
||||
if (_forkFromWsId) return;
|
||||
_handleComposerPaste(event, _newWsAddFiles);
|
||||
};
|
||||
}
|
||||
_newWsError("");
|
||||
|
||||
// Reset attachment staging. Forks don't carry attachments —
|
||||
@@ -827,6 +875,11 @@ function submitNewWs() {
|
||||
const persona = personaEl ? personaEl.value : "";
|
||||
const initEl = document.getElementById("new-ws-initial-message");
|
||||
const initial_message = initEl ? initEl.value.trim() : "";
|
||||
const staged = _forkFromWsId ? [] : _newWsStagedFiles.slice();
|
||||
if (staged.length > 0 && !initial_message) {
|
||||
_newWsError("Add a message to send with this attachment.");
|
||||
return;
|
||||
}
|
||||
if (name) body.name = name;
|
||||
// Forks DELIBERATELY inherit their source's model + judge: the selects are
|
||||
// hidden for a fork (showNewWsModal) and never sent here — matching the
|
||||
@@ -855,7 +908,6 @@ function submitNewWs() {
|
||||
window.TurnstoneHatch.setBusy(dlg, true);
|
||||
|
||||
let fetchOpts;
|
||||
const staged = _forkFromWsId ? [] : _newWsStagedFiles.slice();
|
||||
if (staged.length > 0) {
|
||||
const form = new FormData();
|
||||
form.append("meta", JSON.stringify(body));
|
||||
@@ -1567,13 +1619,23 @@ function _renderDashboardChips() {
|
||||
}
|
||||
|
||||
function _addDashboardFiles(files) {
|
||||
let handled = false;
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
const f = files[i];
|
||||
const paste = window.TurnstonePasteText;
|
||||
if (
|
||||
paste &&
|
||||
paste.isDuplicatePastedTextFile &&
|
||||
paste.isDuplicatePastedTextFile(f, _dashboardStagedFiles)
|
||||
) {
|
||||
handled = true;
|
||||
continue;
|
||||
}
|
||||
if (_dashboardStagedFiles.length >= _DASH_MAX_FILES) {
|
||||
_dashboardError(
|
||||
"At most " + _DASH_MAX_FILES + " attachments per workstream",
|
||||
);
|
||||
return;
|
||||
return handled;
|
||||
}
|
||||
// Drag-drop bypasses the <input accept="..."> filter, so re-check
|
||||
// against the server's allowlist before the upload roundtrip.
|
||||
@@ -1583,7 +1645,7 @@ function _addDashboardFiles(files) {
|
||||
f.name +
|
||||
" (allowed: png/jpeg/gif/webp images, text)",
|
||||
);
|
||||
return;
|
||||
return handled;
|
||||
}
|
||||
const isImage = (f.type || "").indexOf("image/") === 0;
|
||||
const cap = isImage ? _DASH_IMAGE_CAP : _DASH_TEXT_CAP;
|
||||
@@ -1591,12 +1653,14 @@ function _addDashboardFiles(files) {
|
||||
_dashboardError(
|
||||
f.name + " exceeds the " + _formatAttachSize(cap) + " cap",
|
||||
);
|
||||
return;
|
||||
return handled;
|
||||
}
|
||||
_dashboardStagedFiles.push(f);
|
||||
handled = true;
|
||||
}
|
||||
_renderDashboardChips();
|
||||
_refreshDashboardSubmitLabel();
|
||||
return handled;
|
||||
}
|
||||
|
||||
let _dashboardErrorTimer = null;
|
||||
@@ -1772,6 +1836,10 @@ function dashboardSubmit() {
|
||||
const btn = document.getElementById("dashboard-submit-btn");
|
||||
const text = input.value.trim();
|
||||
const staged = _dashboardStagedFiles.slice();
|
||||
if (staged.length > 0 && !text) {
|
||||
_dashboardError("Add a message to send with this attachment.");
|
||||
return;
|
||||
}
|
||||
|
||||
const body = {};
|
||||
const model = document.getElementById("dashboard-model").value.trim();
|
||||
@@ -2131,19 +2199,7 @@ function _announce(text) {
|
||||
});
|
||||
input.addEventListener("input", _refreshDashboardSubmitLabel);
|
||||
input.addEventListener("paste", function (e) {
|
||||
if (!e.clipboardData) return;
|
||||
const items = e.clipboardData.items || [];
|
||||
const pasted = [];
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
if (items[i].kind === "file") {
|
||||
const f = items[i].getAsFile();
|
||||
if (f) pasted.push(f);
|
||||
}
|
||||
}
|
||||
if (pasted.length) {
|
||||
e.preventDefault();
|
||||
_addDashboardFiles(pasted);
|
||||
}
|
||||
_handleComposerPaste(e, _addDashboardFiles);
|
||||
});
|
||||
|
||||
if (attachBtn && attachInput) {
|
||||
|
||||
@@ -664,6 +664,7 @@
|
||||
<script type="module" src="/shared/models.js"></script>
|
||||
<script type="module" src="/shared/skills.js"></script>
|
||||
<script type="module" src="/shared/project_creator.js"></script>
|
||||
<script type="module" src="/shared/composer_paste_text.js"></script>
|
||||
<script type="module" src="/shared/composer.js"></script>
|
||||
<script type="module" src="/shared/composer_attachments.js"></script>
|
||||
<script type="module" src="/shared/composer_queue.js"></script>
|
||||
|
||||
Reference in New Issue
Block a user