feat: convert large pastes to attachments

This commit is contained in:
Patrick Buckley
2026-08-11 23:39:36 -07:00
parent 6eae1c3954
commit 998271b016
18 changed files with 925 additions and 48 deletions
+6
View File
@@ -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
+14
View File
@@ -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
View File
@@ -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__":
+253
View File
@@ -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
)
+89 -2
View File
@@ -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.
+69
View File
@@ -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
+2
View File
@@ -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",
+15 -8
View File
@@ -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>
+1
View File
@@ -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 -->
+20 -3
View File
@@ -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,
};
}
+38 -6
View File
@@ -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
+35 -1
View File
@@ -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
View File
@@ -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) {
+1
View File
@@ -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>