diff --git a/CHANGELOG.md b/CHANGELOG.md index 4eb7756b..8e0684bb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/docs/console.md b/docs/console.md index 6fca14d8..50626d11 100644 --- a/docs/console.md +++ b/docs/console.md @@ -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 diff --git a/scripts/livepass.py b/scripts/livepass.py index 587ece31..681d9b19 100755 --- a/scripts/livepass.py +++ b/scripts/livepass.py @@ -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-``. 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 = """ """ +# -------------------------------------------------------------------------- +# 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 = """ + + + + + PASTE-HTTP-BOOTING + + + + + + +
+

Native paste on plain HTTP

+

+ This passes only when a trusted paste exposes clipboard text to the + real Composer on a non-secure HTTP origin. +

+

1. Select this 2001-character fixture, then copy it normally.

+ + +

2. Focus the composer and paste normally.

+
+
Booting…
+
+ + + +""" + + # -------------------------------------------------------------------------- # 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__": diff --git a/tests/test_composer_paste_text_js.py b/tests/test_composer_paste_text_js.py new file mode 100644 index 00000000..bcd77518 --- /dev/null +++ b/tests/test_composer_paste_text_js.py @@ -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 + ) diff --git a/tests/test_interactive_pane_js.py b/tests/test_interactive_pane_js.py index b486166b..ccf553c2 100644 --- a/tests/test_interactive_pane_js.py +++ b/tests/test_interactive_pane_js.py @@ -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. diff --git a/tests/test_livepass_paste.py b/tests/test_livepass_paste.py new file mode 100644 index 00000000..0f9d4c68 --- /dev/null +++ b/tests/test_livepass_paste.py @@ -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'', 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 diff --git a/tests/test_shell_js.py b/tests/test_shell_js.py index 98e7a234..293b7537 100644 --- a/tests/test_shell_js.py +++ b/tests/test_shell_js.py @@ -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", diff --git a/turnstone/console/static/app.js b/turnstone/console/static/app.js index d41a2637..9bd03c7b 100644 --- a/turnstone/console/static/app.js +++ b/turnstone/console/static/app.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 = { diff --git a/turnstone/console/static/coordinator/coordinator.js b/turnstone/console/static/coordinator/coordinator.js index 5e056773..bf4f34ef 100644 --- a/turnstone/console/static/coordinator/coordinator.js +++ b/turnstone/console/static/coordinator/coordinator.js @@ -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: () => {}, diff --git a/turnstone/console/static/coordinator/index.html b/turnstone/console/static/coordinator/index.html index 7e1aeab0..5510a609 100644 --- a/turnstone/console/static/coordinator/index.html +++ b/turnstone/console/static/coordinator/index.html @@ -43,6 +43,7 @@ + diff --git a/turnstone/console/static/index.html b/turnstone/console/static/index.html index 82b5fa76..d61a83e4 100644 --- a/turnstone/console/static/index.html +++ b/turnstone/console/static/index.html @@ -4116,6 +4116,7 @@ + diff --git a/turnstone/shared_static/composer.js b/turnstone/shared_static/composer.js index 223edec9..8c15b706 100644 --- a/turnstone/shared_static/composer.js +++ b/turnstone/shared_static/composer.js @@ -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(); + } }); } diff --git a/turnstone/shared_static/composer_attachments.js b/turnstone/shared_static/composer_attachments.js index 303d6c09..5fb57d8e 100644 --- a/turnstone/shared_static/composer_attachments.js +++ b/turnstone/shared_static/composer_attachments.js @@ -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) { diff --git a/turnstone/shared_static/composer_paste_text.js b/turnstone/shared_static/composer_paste_text.js new file mode 100644 index 00000000..97af9fe6 --- /dev/null +++ b/turnstone/shared_static/composer_paste_text.js @@ -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, + }; +} diff --git a/turnstone/shared_static/composer_queue.js b/turnstone/shared_static/composer_queue.js index a5642184..b4a563a1 100644 --- a/turnstone/shared_static/composer_queue.js +++ b/turnstone/shared_static/composer_queue.js @@ -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 diff --git a/turnstone/shared_static/interactive.js b/turnstone/shared_static/interactive.js index aad21649..59dec0de 100644 --- a/turnstone/shared_static/interactive.js +++ b/turnstone/shared_static/interactive.js @@ -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), diff --git a/turnstone/ui/static/app.js b/turnstone/ui/static/app.js index 2a4af6bb..cf63e4eb 100644 --- a/turnstone/ui/static/app.js +++ b/turnstone/ui/static/app.js @@ -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 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) { diff --git a/turnstone/ui/static/index.html b/turnstone/ui/static/index.html index b7544b3f..89a18eba 100644 --- a/turnstone/ui/static/index.html +++ b/turnstone/ui/static/index.html @@ -664,6 +664,7 @@ +