Files
PaperClipAI/ui/src/components/task-chat/TaskChatComposer.test.tsx
T
scotttongandPaperclip 71f0c0c99e feat: add opt-in Coordinator workspace for chat, tasks, and decisions (#17983)
<!-- entire-trail-link-start -->
https://entire.com/gh/paperclipai/-/trails/147/changes/paperclip/147
<!-- entire-trail-link-end -->

## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - People coordinate work through conversations, tasks, and requests
for input.
> - These surfaces must share task state and preserve the context of
each decision.
> - The existing workspace makes people switch surfaces to follow that
context.
> - This pull request adds an optional Coordinator workspace with linked
conversations, tasks, documents, and decisions.
> - Each person can enable it in Settings > Experimental and return to
the classic workspace.
> - The benefit is a coherent path from a request to an assigned task
and reviewed work.

## Linked Issues or Issue Description

**Subsystem affected**
UI, server, shared contracts, and database.

**Problem or motivation**
People need a consistent way to start tasks from agent conversations,
answer questions, review work, and return to the source context.
Decisions and task state must agree across these surfaces.

**Proposed solution**
Add a personal, default-off workspace experiment. Keep one conversation
per agent. Add task proposals, compact task and decision cards,
contextual document panels, and a Needs You queue with search, filters,
sorting, grouping, and history.

**Alternatives considered**
Replacing the classic workspace for everyone would remove the opt-out
path. Keeping separate decision state in each surface would create
conflicting answers. This change uses the existing canonical task and
interaction records instead.

**Roadmap alignment**
This supports the existing agent chat, task management, and human review
capabilities. It is an experimental presentation choice. The user
requested this isolated implementation after design review. No Discord
discussion is claimed.

## What Changed

- Add per-person workspace preferences, a default-off setting, and an
additive migration.
- Add Coordinator navigation, pinned destinations, task lists, agent
switching, and contextual panels.
- Link task suggestions and creation to the source conversation. Default
new task ownership to the current agent unless the user specifies
another owner.
- Share pending questions, decision receipts, exact review targets, and
history across tasks, chat, and Needs You.
- Preserve task access, assignment authorization, revision binding, and
required output review gates.
- Add compact document links, relative timestamps, composer controls,
and reduced-motion support for task and decision transitions.
- Add component stories, regression coverage, and capability-map
documentation.

## Verification

- Final head: `4b839aae1e314bd7e8635a766f1d205ff1c03ba6`, based on
master `8bc051245f77a47bee0f8ba45896b8bfa5922e0a`. Verified October 29,
2026 at 00:10 UTC: **MERGEABLE, 56 checks passed, four conditional
skips, no pending or failed checks**. [Current CI
run](https://github.com/paperclipai/paperclip/actions/runs/40790639688)
passed without a rerun on this head.
- Fresh Greptile **5/5**, zero new comments and zero open review
threads; Superagent passed with no suspicious changes.
[Checks](https://github.com/paperclipai/paperclip/pull/17983/checks).
- Local workspace typecheck/build passed after the latest rebase. The
final account-read patch passed UI typecheck/build and all 12 related
preference/settings tests, including a delayed real-client request
across account changes. Earlier scoped checks cover review
deduplication/replay, decision deep links, cross-surface state, access
boundaries, and migration replay.
- Local database/browser rechecks after the latest rebase were limited
by exhausted macOS shared-memory IDs: `initdb` failed before database
creation. The local run passed 31 UI/snapshot tests and skipped 24
database tests; those skips and the blocked local browser start are not
claimed as passes. The independent Linux CI database and browser checks
passed. No running dev database or host setting was changed.
- Upstream migration 0403 is preserved. Preferences and durable review
retry aliases are generated as replay-safe migrations
`0404_tense_silver_sable.sql` and `0405_harsh_avengers.sql`; five newest
snapshots are retained. Every detected conflict was rebased and pushed
before waiting for CI/review, as requested.
- The PR is unmerged. The active master ruleset still requires
code-owner approval for the changed Paperclip skill; no approval or rule
change was performed.

<details>
<summary>Earlier verification failures, integration history, and
evidence</summary>

These entries preserve prior checkpoints and failures; the current
acceptance result is above.

- Focused UI and server checks from the isolated build are recorded in
the implementation plan.
- Browser checks covered the opt-in shell, navigation, task/document
links, and decision card states. No new live provider trial is claimed
for this handoff.
- Current acceptance head: `4b839aae1e314bd7e8635a766f1d205ff1c03ba6`,
rebased onto master `8bc051245` immediately after its GitHub
review-repair migration took 0403. Preferences are generated as
idempotent `0404_tense_silver_sable.sql`, preserving upstream
SQL/journal/snapshot history and five newest snapshots. GitHub reports
MERGEABLE after the immediate lease-protected push. Scott's updated
instruction is to rebase and push every conflict resolution first,
without waiting for CI or Greptile; verification follows the push.
- The final reviewed feature tree was consolidated for repeatable
single-commit rebases. The complete previous commit history remains
reachable through local recovery ref
`codex/coordinator-recovery-before-rebase-20261028` (resolved snapshot
`0e1bbdf4e`). The actual rebase completed, and its tree hash exactly
matches that resolved snapshot. The force-with-lease required the known
remote head `0e6ef7c44`, protecting against concurrent branch changes.
No PR merge or permission/runner setting change occurred. Fresh
CI/review are in progress for the current head.
- `2001132fa` received Greptile 5/5 with one nonblocking account-switch
read finding. Current head detaches path-coalesced preference GETs and
disables HTTP caching, retaining React Query's account-scoped sharing. A
delayed real-client GET regression proves account B can load its saved
choice before account A responds, and A's late response only updates A's
cache. Preference/settings tests 12/12 and UI typecheck passed; its
review thread is resolved. Fresh current-head CI/reviews are pending.
The prior CI chat shard 3 ended with “The operation was canceled” and no
assertion failure ([job
123171595631](https://github.com/paperclipai/paperclip/actions/runs/40789688333/job/123171595631),
`/tmp/pr17983-2001132-chat3.log`); no code/timeout change is attributed
to that cancellation.
- `843e823b4` reached fresh Greptile 5/5 with zero comments/open threads
and Superagent pass. All browser tests including the repaired YAML shard
passed, and 54 checks passed. General server shard 9 ended with “The
operation was canceled” and no failed assertion; its test step was
marked cancelled (job
[123164350893](https://github.com/paperclipai/paperclip/actions/runs/40787501761/job/123164350893),
log `/tmp/pr17983-843e-server9.log`). A single unchanged-head rerun was
started after the workflow completed, but master moved while it ran. A
local nested-clone check timed out in its 30-second database-startup
hook and then failed cleanup before the 20 database cases ran; this is
retained as a local verification limit
(`/tmp/pr17983-843e-nested-clone-check.log`), not claimed as a pass or
proof of the CI cancellation cause.
- Following Scott's instruction, the new conflict was rebased and pushed
immediately without waiting for that rerun. Upstream's
`0403_natural_cerise.sql` is preserved; regenerated preferences and
retry-alias migrations are `0404_tense_silver_sable.sql` and
`0405_harsh_avengers.sql`, replay-safe with five newest snapshots
retained. The YAML helper conflict combines upstream's click and header
behavior with the editor-selection guard and injected regression.
Recovery ref `codex/coordinator-recovery-843e823b4` preserves pre-rebase
work. Current head is MERGEABLE. Full workspace typecheck/build passed;
UI and snapshot checks passed 31 tests, but 24 database tests were
skipped by the local support probe, and the local browser server could
not initialize PostgreSQL. A direct disposable `initdb` diagnostic
established the host limitation: `could not create shared memory
segment: No space left on device` (`shmget`, shared-memory IDs
exhausted, not disk space). No dev database or host setting was changed.
Diagnostics: `/tmp/pr17983-2001132-initdb-probe.log`,
`/tmp/pr17983-2001132-db-probe.log`; checks:
`/tmp/pr17983-2001132-tests.log`, `/tmp/pr17983-2001132-browser.log`,
`/tmp/pr17983-2001132-types.log`, `/tmp/pr17983-2001132-build.log`.
These local skipped/blocked checks are not claimed as passes; fresh CI
on independent runners is in progress.
- `d18f683cd` reached fresh Greptile 5/5 with zero comments/open threads
and Superagent pass, but [E2E shard
7](https://github.com/paperclipai/paperclip/actions/runs/40785966243/job/123159719925)
failed parsing an Advanced YAML clipboard read beginning with “Skip to
Main Content.” The log is retained at `/tmp/pr17983-d18f-e2e7.log`.
Current head hardens the test helper to verify editor focus and
selection, clear stale clipboard content, and require the YAML header at
the start of a fresh copy before parsing. Saved-state assertions and
timeouts are unchanged. All six Advanced YAML browser journeys passed on
a disposable local instance (41.0s); the exact lost-response journey
also passed with a one-time injected surrounding-page copy, verifying
rejection/reacquisition (17.6s). The precise browser focus/clipboard
timing in CI remains unproven; the observed invalid-read path is now
guarded and deterministically exercised. Logs:
`/tmp/pr17983-yaml-clipboard-scope-browser.log`,
`/tmp/pr17983-yaml-clipboard-injected-browser.log`. Fresh current-head
CI and reviews are pending.
- `19e6dcb0b` passed Superagent and Greptile 5/5, with one nonblocking
deep-link finding. Current head preserves attention links using
`?decisionId=`: the requested pending decision is rendered even beyond
the first 40 cards, focused and scrolled into view, then only that
parameter is consumed so later refreshes do not refocus. Other URL
parameters remain. Needs You tests 10/10 and UI typecheck passed; its
review thread is resolved. The preceding post-rebase regression run
passed 45/45 tests. Fresh current-head CI/reviews are pending.
- `6d772b7cc` reached Greptile 5/5 with zero comments/open threads and
Superagent pass. While 32 checks had passed, master moved and caused a
documentation conflict in `maps/feature-map/onboarding.md`. Rebased
immediately, preserving both onboarding paragraphs. The complete
four-commit branch was pushed as `19e6dcb0b` before waiting on
CI/review, per Scott's instruction. A transient Git index lock
interrupted the first continue; the command sequence briefly pushed
intermediate `34e298c07`, then the completed rebase was immediately
pushed. All feature and security/retry fixes are present; local recovery
ref `codex/coordinator-recovery-6d772b7cc` preserves the pre-rebase
history. The only tree differences from that ref are upstream's two
smoke-test files and the combined documentation paragraph. GitHub
reports MERGEABLE. Fresh current-head verification is in progress;
prior-head review results are not current acceptance.
- `21efa3120` passed Superagent but Greptile scored 4/5 for a valid
retry-key persistence bug: an acknowledged duplicate key could reopen a
resolved review. Current head adds an additive, replay-safe
`0404_light_swarm.sql` migration for indexed, task-scoped aliases from
acknowledged retry keys to the canonical interaction. Alias registration
and retry lookup occur under the existing task lock; payload/actor
mismatch still conflicts, and each canonical review permits at most 100
aliases. Original and acknowledged keys replay the terminal result
rather than creating a new gate. Five newest migration snapshots are
retained. Service/routes tests 165/165 passed; final quality review plus
migration replay/snapshot checks 25/25 passed; server typecheck passed.
The P1 thread is resolved in code; fresh current-head reviews and CI are
pending.
- `69c2c7543` passed 55 checks and Greptile 5/5 with no open Greptile
findings, but Superagent check
[123140675812](https://github.com/paperclipai/paperclip/pull/17983/checks?check_run_id=123140675812)
correctly flagged unbounded pending quality-card creation. Current head
fixes that under the existing task-row lock: equivalent requests with
fresh idempotency keys reuse the pending card; conflicting requests for
an unresolved deliverable return the existing interaction ID; pending
cards are capped at ten per task across all reviewers. Distinct outputs
remain independent, and already-answered items in a partially pending
batch can receive a fresh review. Added real-database concurrency,
cross-card duplicate, partial-answer, and hard-cap regressions. Quality
review 22/22 tests and final server typecheck passed; earlier
service/routes 163/163 tests passed before the final partial-answer
refinement. One added assertion initially expected a service HTTP error,
but duplicate keys are rejected earlier by the existing shared schema;
the assertion now verifies that validation. Fresh current-head
CI/reviews are pending; the action-required security result is not
treated as passed.
- `c9c575e5f` completed all 56 checks successfully with four conditional
skips, Greptile 5/5, and Superagent pass. Its two P2 review findings are
fixed in the current head: independently bound quality approvals now
each remain visible in attention; changing/clearing URL-backed
assignee/status filters updates the URL and survives task navigation.
Both review threads are resolved. Regression evidence: attention service
34/34 tests passed, CoordinatorTasks 20/20 passed, UI and server
typechecks and the full workspace build passed on `69c2c7543`. The first
new UI test run caught an assertion racing React Router's transition;
waiting for the navigation before asserting passes without weakening the
navigation/remount checks. These older-head green checks are not claimed
for the new fix commit.
- Preceding `0e6ef7c44` completed all 56 checks successfully with four
conditional skips, fresh Greptile 5/5 and no open findings, and passed
Superagent. A fifth migration-metadata collision arrived before the
final Runner job finished. Those green results are preserved as
prior-head evidence, not current acceptance.
- Previous fully verified migration head:
`0e6ef7c44696a377f35e69521b93587576374bd0`, integrating master
`f8226b051` after the fourth migration-number collision. Master added
terminal GitHub-response migration 0401 while the final native check
ran; upstream 0401 SQL/journal/snapshot is preserved unchanged, and
preferences are generated as idempotent `0402_yielding_blink.sql` on
top. Exactly the five newest snapshots remain. GitHub reports MERGEABLE
after the push. All 1,155 relevant
migration/review/interaction/native-execution/remote-assets tests,
workspace typecheck/build, migration guards and whitespace checks pass.
Fresh exact-head CI/Greptile/Superagent are pending.
- Immediately preceding head `36c67cf03` completed all 56 checks
successfully with four conditional skips, fresh Greptile 5/5 and no open
findings, plus passed Superagent security. No retry was needed for that
head's CI. Master advanced and introduced only a migration-metadata
conflict while its final native check ran, so that green result does not
establish acceptance of the current integration. Four consecutive
collisions (0398, 0399, 0400, 0401) during verification are retained as
concrete integration churn; a brief pause of competing migration merges
was requested, without assuming it happened or changing any repo
setting. The PR has not been merged.
- Prior fully verified head: `36c67cf03`, on the clean `30059c0ed`
integration of master `efd65c112`. The last Greptile P2 is fixed: Needs
attention retains the participant-agent scope through authoritative
server membership queries for each pending task, including historical
participation when another agent currently owns the task. It does not
approximate participation with current assignment or enumerate every
company task. The actual view-switch regression and all 31 focused
task/routing/collection tests, UI typecheck/build, token gates and
whitespace checks pass. Greptile completed a fresh 5/5 review on this
exact head (review 19) with zero new comments and no open review
threads. CI and Superagent remain in progress; no current-head CI
failure has been reported.
- Head `30059c0ed` received Greptile 5/5 with that one nonblocking
participant finding, now fixed. Superagent passed that head.
Current-base integration restored the ephemeral RunsOn fleet after a
superseded workflow retained queued aggregate jobs: normal cancellation
was insufficient; the single force-cancel request was interrupted but
readback confirmed that obsolete run cancelled and current CI began on
the trusted fleet. No cancellation retry or runner/security/workflow
changes were made. Its partial CI was superseded by the necessary
participant fix and is not current acceptance.
- Previous compatibility/base-sync head:
`30059c0ededc0eab4e04e8299a884f36db8b292f`, integrating master
`efd65c112` and the Inbox/workspace compatibility fixes. GitHub reports
MERGEABLE after the push. Coordinator keeps Inbox standalone under More
without rewriting the instance's Combined Inbox preference; opting out
restores that preference. Saved merged-Inbox URLs resolve to their
original owner, task status presets remain usable, and canonical
repeated `workspace` parameters retain scoped, deduplicated pagination
(including Needs attention). All 192 broader
routing/Inbox/sidebar/privacy/review tests and the final 16 task-page
tests pass, as do workspace typecheck/build, token gates and whitespace
checks. The two review threads are resolved. Fresh exact-head CI,
Greptile and Superagent results are pending.
- The preceding `d5b9be5bd` remained mergeable but its CI routing gate
intentionally fell back to GitHub-hosted runners: “stacked base merge
commit does not contain the current base branch.” All 45 downstream jobs
stayed queued. Current master was merged cleanly to refresh the pinned
base; no runner/security settings or workflow logic were changed.
Greptile scored that preceding head 4/5 with the two compatibility
findings fixed here. Its automatic Superagent scan was inconclusive at
checkout; the documented retry was still pending when superseded. Those
results do not establish acceptance of this current head.
- Previous approval/migration head:
`d5b9be5bd3b8d6b0437e42bf48138377f34fa67d`, integrating master
`ba5bba86d` and the stale-output approval correction. GitHub reports
MERGEABLE after the push. The fresh P1 finding is fixed: every submitted
item now validates and locks its bound output inside the resolution
transaction, whether or not it carries a required-quality-review
binding. Real database regressions verify that an advanced document
revision or deleted attachment refuses the entire batch without
recording approvals, and current ordinary output reviews still resolve.
All 272 combined migration/review/interaction tests pass; workspace
typecheck/build passed. Fresh exact-head Greptile and CI are pending.
The automatic Superagent scan ended inconclusive at checkout; one
documented on-demand retry is pending. These are explicit verification
limits, not passed security results. The P1 thread is resolved.
- Master consumed the next migration slot three times during this
verification pass: confirmation recovery took 0398, continuation
recovery took 0399, and GitHub acknowledgement recovery took 0400. Each
upstream SQL/journal/snapshot is preserved unchanged. Preferences are
now generated as idempotent `0401_white_lenny_balinger.sql` from
upstream 0400; exactly five snapshots remain, and replay coverage
locates this migration by table content. This is recorded concurrent
integration churn, not a claim that older green results apply to the
latest base.
- Head `ed3b68da9` fixed the two history/URL findings with 26 focused
regression tests, server/UI typechecks, workspace build and token gates
passing. Greptile then scored that head 4/5 with the stale-output P1
corrected in the current head. Its partial CI is superseded, not counted
as current acceptance.
- Previous filter-fix head: `ed3b68da9`, based on the `8458d39d9` merge
of master `063cfd438`. The two fresh Greptile findings are fixed:
history outcome filters now match recorded per-item verdicts in SQL
before pagination (mixed reviews remain in both applicable outcomes),
and same-page URL assignee changes/back/forward navigation update that
filter without clearing independent filters. All 26 focused
database/navigation/collection tests, server/UI typechecks, workspace
build, token gates and whitespace checks pass. The two review threads
are resolved. Fresh exact-head CI, Greptile and Superagent results are
pending.
- The `8458d39d9` migration integration also passed all 328 targeted
migration/interaction/confirmation tests, workspace typecheck/build and
Superagent security scan. Greptile scored that head 4/5 with the two
functional gaps fixed in the current head; those prior-head checks are
not current acceptance.
- Migration integration head:
`8458d39d9721f4cb312c2390070a0609f8cddd1a`, integrating master
`063cfd43813cabf18417605fb9eefb9aacf95c8d`. GitHub reports MERGEABLE
after the push. Master advanced twice during this conflict-resolution
pass: first its confirmation-recovery migration took 0398, then its
continuation-recovery migration took 0399. Both upstream migrations and
journal entries remain unchanged; Coordinator preferences are now
generated as idempotent `0400_sad_guardian.sql` from upstream's latest
snapshot. Exactly the five newest snapshots remain. The replay
regression now locates the table migration by content instead of
hardcoding a renumbered filename.
- All 328 targeted question/review/confirmation/migration tests passed
again after the second merge. Workspace typecheck/build and fresh
exact-head CI/reviews are in progress. The preceding `0c46f46d6` passed
workspace typecheck/build and Superagent security scan, but was
superseded because master consumed migration 0399 while those checks
ran. That prior-head result is not acceptance of the current head. No
production settings or runner/security permissions were changed, and the
PR has not been merged.
- Previous integration head: `0c46f46d6f2b23417ab872ce3cb6ff2673647287`,
integrating master `287b04d72f1b9fca1412e05435c75b8302ba0710`. GitHub
reports MERGEABLE after the push. The renewed conflicts were
`0398_snapshot.json`, `_journal.json`, and the top imports in
`issue-thread-interactions.ts`. Upstream's `0398` confirmation-recovery
index and journal history are preserved unchanged; Coordinator
preferences were regenerated as idempotent `0399_bouncy_miek.sql`, with
replay coverage updated and exactly the five newest snapshots retained.
Both terminal confirmation notifications and Coordinator task
assignment/required review behavior are preserved.
- All 328 targeted interaction/review/confirmation/migration tests pass,
as do workspace typecheck, map validation and whitespace checks. The
workspace build and fresh exact-head CI/Greptile/Superagent checks are
pending. The first migration-generation attempt hit the sandbox's local
IPC restriction before generating files; the unchanged authorized local
generation completed successfully. Prior-head green checks below do not
establish acceptance of this merge.
- Previous acceptance head: `8b7dcf739ed15afc51351fdfa0e0e5fbc75ab788`,
with master `1aa3a21ac` integrated and GitHub reporting MERGEABLE.
Greptile completed a fresh 5/5 review on this exact head with zero new
actionable findings and no open review threads. The [fresh CI
workflow](https://github.com/paperclipai/paperclip/actions/runs/40752864319)
completed SUCCESS on attempt 2 at 19:40:08 UTC, including all eight E2E
shards. Final completed check set: 55 succeeded, four conditional checks
skipped, zero pending/queued/running checks, and one
neutral/inconclusive Superagent scan. The auxiliary commitperclip review
and isolated native Runner compilation/cache-reuse check both passed.
This is not a claim that Superagent produced a passed security verdict.
GitHub still reports MERGEABLE against current master
`60b8089a44b4eaa37e627adbfdf2f130bda13335`.
- Current-head E2E: all eight shards and the aggregate passed, including
the previously failing YAML browser journey. Serialized server shard 9/9
failed: the protected YAML projection test exceeded its original
15-second timeout; its late update request then reached the next test's
reset mock (the captured payload is the prior test's model/label
update). All 88 tests in that exact unchanged file passed locally in
38.03 seconds, with no timeout or assertion changes. The original
slow-request cause remains unproven. CI failure log:
`/tmp/pr17983-8b7-serialized9.log`; local recovery:
`/tmp/pr17983-adapter-yaml-repro.log`. The single bounded unchanged
shard rerun passed, and the workflow completed SUCCESS. Rerun log:
`/tmp/pr17983-8b7-serialized9-rerun.log`. The initial failure is
retained; a passing retry does not establish its original slow-request
cause.
- Greptile scored preceding head `4873704be` 5/5 with one valid P2:
opened task decision records missed company-scoped live invalidation.
The current head uses the existing company decision prefix for
resolution and effect-completion events, and resets private record
payloads on access changes. Its thread is resolved. All 107 focused
record/live-update tests, UI typecheck/build, token checks and
whitespace checks pass.
- CI on `4873704be` failed E2E shard 7/8 in the Advanced YAML
lost-creation-response journey: its clipboard helper selected page text
starting with “Skip to Main Content” instead of YAML. The exact
unchanged journey passed in a disposable local browser instance (1 test,
17.1 seconds). No assertion, timeout or production behavior was
weakened; the original failure cause remains unproven. Failure log:
`/tmp/pr17983-487-e2e7.log`; local check:
`/tmp/pr17983-agent-yaml-repro.log`. The full unchanged Advanced YAML
browser suite also passed locally (6 tests, 37.8 seconds). Fresh
current-head CI subsequently passed this journey and all other E2E
shards.
- Both the automatic and one supported on-demand Superagent scan on
`4873704be` ended neutral/inconclusive: “Pinned PR scan merge-base
checkout failed or timed out.” These do not count as a passed security
verdict. The current head's automatic scan and one documented on-demand
retry also returned the identical neutral checkout failure, with zero
annotations or further diagnostics. This is an external scanner blocker,
not a passed security verdict. No security settings or permissions were
changed.
- Recorded decision links now render and focus the full source-task
record in both Classic and Coordinator, including recorded choice,
submitted inputs, and the existing effect-result resolver. Fresh
terminal records take precedence over older open-decision cache entries.
Context and account checks keep mismatched or inaccessible records out
of the DOM; no decision-history tab was added to tasks or chat. The
shared company live-run feed now drives live-only filtering, including
ended runs and terminal tasks with stale run entries. All 213 focused
tests, UI typecheck/build, token and whitespace checks pass; the final
focus follow-up passed all 188 task-detail tests and UI typecheck. The
first new navigation test failed because its test host omitted company
context; the host was corrected before the passing run.
- Previous integration head: `375c5e89516705050d94bab99336212841b2e6e1`
merges master `1aa3a21ac` after four additional upstream commits. The
sole conflict was competing imports in `server/src/services/issues.ts`;
both quality-review policy and inbound wake-notice notifications are
preserved. The PR-specific review import now sits beside
execution-policy imports instead of the frequently changing first line.
GitHub mergeability is rechecked after the push.
- On the combined master tree, workspace typecheck/build, all 153
affected UI tests, map validation, and whitespace checks pass. Four
server suites passed 77 tests; the fifth suite's module-import hook
timed out while broad verification was running. Its unchanged isolated
run passed all 41 tests in 7.2 seconds with the original timeout. This
records recovery without claiming the original import-timeout cause is
proven. CI on `375c5e895` passed every substantive job, including all
eight E2E shards and Superagent; Greptile found the two functional gaps
fixed in the current head. A short-lived `669d90876` commit added the
main fixes before the focused navigation follow-up.
- Immediately preceding head `9d9737b49` completed all 56 checks
successfully with four conditional skips, Greptile 5/5 and no open
findings, plus a successful Superagent scan. The signoff E2E shard
passed on its single accepted rerun; its initial cancellation/timing
failure is retained below. Master moved again while those checks ran,
requiring this final integration rather than treating the prior head's
green result as current acceptance.
- Previous head `9d9737b496911a6054d0ea22d1b42b28b115e9ab` includes
master `d30c8f42b842327ee1c18c41a2efd6ba623da5cd`; that head was
subsequently conflicted by moving master. The master merge preserved
durable account-bound writes and Coordinator UX. Workspace
typecheck/build, 416 focused UI tests, and 142 affected comment-route
tests passed. A stable full UI run on preceding head `938ad1665` passed
all 9,833 tests across 836 files; its final full workspace build also
passed.
- Greptile found nested proposal project inheritance and Classic
bound-revision links on `91be74952`. Both were fixed with 142
interaction-route tests, 113 focused UI tests, and UI/server typechecks.
Its next review identified a valid draft-preservation bug in the new
Classic loading UI. Head `9d9737b49` keeps loading/error notices outside
editors and annotations while preserving dirty drafts; 115 focused
review UI tests pass, including pending/failed revision loads and
continued draft editing. All related threads are resolved. Greptile
completed a fresh 5/5 review of
`9d9737b496911a6054d0ea22d1b42b28b115e9ab` with no open findings.
- CI on `938ad1665` exposed an integration regression with the newly
merged durable startup saves: the personal preference gate blocked the
task page before its first session lookup. The guarded title editor now
mounts while that lookup is pending, with presentation-specific tools
still gated. All 216 affected component tests pass. The unchanged
five-test `disconnect-write-recovery.spec.ts` browser suite passes
against a newly launched disposable local-trusted PostgreSQL instance on
port 33219, including the exact failed startup-save journey. No live
provider, hosted authentication or production proof is claimed. The
helper-owned test server exited and the port has no listener. UI
typecheck, built-browser assets, token and whitespace checks pass.
- Initial durable-write merge tests found duplicate imports and unscoped
draft fixtures, fixed before 416 focused tests passed. A sandbox
typecheck could not create its IPC socket; the supported rerun passed.
The first revision-link test used a wrong Return to latest button
assertion, corrected before the 113-test pass. The startup E2E
reproduction initially still failed because an outer shell gate also
blocked the page; the subsequent fix passed all five cases without
changing the test or its timeouts. A focused loading-header assertion
initially used an incorrect fixture title; correcting it preceded the
216-test pass. These failures remain recorded.
- Earlier routing gates fell back to GitHub-hosted runners while master
was moving or conflicted. Superseded queued runs were cancelled, then
the `938ad1665` routing gate explicitly selected the ephemeral RunsOn
fleet. Its CI actively executed and completed most tests; only the
diagnosed startup E2E shard and aggregate failed, alongside the valid
Greptile finding. Auxiliary GitHub-hosted workflows also completed
successfully on earlier heads, so queueing alone is not an
administrative blocker. Fresh [CI on
`9d9737b49`](https://github.com/paperclipai/paperclip/actions/runs/40744012678)
is the acceptance target; the previously failing startup E2E shard now
passes. A different signoff E2E shard failed during executor
resubmission when its fixture run was cancelled as
`issue_continuation_waiting_on_review`. The exact unchanged journey
passed locally (1 test, 46 seconds), and the preceding head with
identical server/signoff code passed that CI shard. This supports a
bounded rerun but does not establish the timing failure's root cause.
After the workflow finished, GitHub accepted one rerun of that failed
shard and its dependent aggregate; both passed, completing all checks on
that head. The failure log is retained at `/tmp/pr17983-9d9737-e2e7.log`
and the local reproduction at `/tmp/pr17983-signoff-repro.log`. No
assertion or timeout was weakened.
- On the first merge, full workspace typecheck/build, all 9,622 UI
tests, 267 tests across seven affected server suites, two migration
checks, map validation, token gates, and the PR diff whitespace check
passed. Greptile then found duplicate local task-search filtering and
lost completed item-review detail. Both are fixed, with 42 passing
focused UI tests and passing UI typecheck/build.
- A subsequent Greptile review found attachment confirmations without
working links and mobile history without a visible host. Confirmation
layouts now derive the authenticated immutable-content endpoint from the
attachment ID, and Needs You uses the existing full-screen mobile sheet.
All 119 focused tests pass, including missing/stale display URLs, both
review layouts, and both mobile history entry buttons. UI
typecheck/build and token checks pass. Full workspace typecheck/build
also pass after the second master merge.
- Greptile gave head 22f69c304 a 5/5 score with one non-blocking
desktop-history reopening finding. Head 6dd52b6d3 fixed it, passed all
nine history tests, UI typecheck/build, token gates, and the full
9,679-test UI suite, then received a fresh 5/5 review with zero open
findings. These are prior-head results; review on the master-sync commit
is pending.
- The chat and workspace CI jobs on head 22f69c304 were interrupted by
runner shutdown signals and cancellation before completing their tests.
Their logs do not report failing assertions. Those interrupted jobs are
not claimed as passes. The new head starts fresh runs.
- The first merge CI had a Slack session-sync idle assertion fail after
an unexpected database read. Its exact isolated reproduction passes, and
the chat CI shard passed on head 618c5a2a3. The original failure's cause
remains undetermined. The broader local reproduction, run concurrently
with full verification, instead encountered 21 timeout failures across
its two test chunks; it is not claimed as a passing full local server
run. A concurrent local UI run had eight failures (seven timeouts and
one delayed autosave assertion). The complete UI run on the final head
passed all 9,679 tests across 830 files with two workers; no test
timeout or assertion was weakened. This establishes recovery without
claiming that every earlier timeout's cause has been proved. An earlier
intermediate UI run also overlapped the second master merge and failed
the newly added Advanced-sidebar expectation, so it is not treated as
stable-tree evidence. The newly merged UI dependencies initially
required refreshing the local install from the upstream package
manifest; the generated local lockfile update is not committed, per
repository policy.
- Superagent returned neutral/inconclusive scans on the preceding heads
because its pinned merge-base checkout failed or timed out. The
available retry endpoint returned 404. These are not successful security
verdicts. The fresh scan on `91be74952` passed (PR scan passed; no
suspicious PR changes detected). The `938ad1665` scan again returned
neutral/inconclusive with the same pinned merge-base checkout error; the
check-suite rerun endpoint returned 404. The fresh `9d9737b49` scan
completed successfully at 18:22 UTC with no suspicious changes detected.
Earlier neutral scans are not counted as successful security verdicts.
- Prior head `57902152ac3218096fca669d33ff18400a2a86ae` passed 9,618 UI
tests across 826 files, received Greptile 5/5 with no open findings, and
completed CI with 56 passes and four conditional skips. These are
historical results, not acceptance of the latest merge commit.
- The first full build found generated capability-contract drift. The
generated outputs were refreshed. Initial sandbox runs could not open
local IPC sockets; verification was rerun with local test access.
- Current master was merged. The workspace preference migration was
regenerated after the existing migration history.
- The UI run first found two outdated expectations: early custom-answer
validation and the document access error copy. Both were corrected, and
the full UI suite then passed.
- The local server run found three failures in unchanged tests. The Muse
test passes with Python 3.13 instead of the host's Python 3.9. The two
workspace tests pass when their test process uses the expected master
default branch. These environment settings do not change application
behavior or global machine settings. The unsharded server run was
stopped after those reproductions; CI supplies the full sharded server
result. No successful full local server run is claimed.
- Skill inventory generation needs an external eval checkout, which is
absent locally. The existing generator functions refreshed only the live
skill inventory and its derived files. The checked-in eval inventory was
preserved. The inventory validation gate passes.
- Greptile found a dropped MCP review requirement, missing per-item
output links, generic item-review summaries, and a history-refresh
failure. The fixes have 32 passing database-backed server tests and
focused rendered-card tests. A follow-up partial-review finding was
corrected by forwarding the canonical mutation status before settling
the card. Attachments open through the authenticated immutable-content
route in both card layouts. The final focused UI set has 117 passing
tests, including these regressions.
- Both browser shards that first failed expected a disabled-chat
message. The copy was restored, and those shards passed in CI on the
next commit. The two journeys also passed locally. One local attempt
first timed out before feature disable while waiting for the provider's
initial message; its isolated reproduction passed. That earlier timing
failure remains recorded and is not presented as an explained
infrastructure failure.
- Serialized server shard 9 first failed during pnpm setup with
ECONNRESET, before dependencies or tests ran. Its single rerun passed
setup and all tests. The build and production runtime boot passed on the
first attempt, but that run's public-install step timed out during Pi
setup. The single build-job rerun passed compilation, runtime boot, and
clean public-install verification. Those setup scripts are unchanged by
this PR. The successful retry establishes recovery; the earlier Pi
timeout's underlying cause remains undetermined. The aggregate CI gate
is green: [workflow
run](https://github.com/paperclipai/paperclip/actions/runs/40637789695).

</details>

## Risks

- This touches shared task, interaction, and completion paths.
Regression tests must cover classic and experimental behavior,
authorization, duplicate answers, and stale review targets.
- The additive preference table must migrate before the setting is used.
The setting defaults off.
- The experiment changes navigation and message presentation. Canonical
task and document records remain the source of truth.
- The initial task brief and saved plan can repeat information. This
change fixes chronology; a broader redundancy policy remains a design
decision.

## Model Used

OpenAI GPT-6 Codex with reasoning and tool-assisted code editing, tests,
and browser verification. Earlier iterations used Codex models in this
task; their exact model IDs are not available in the current context.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used
- [x] I have checked ROADMAP.md
- [x] I have searched GitHub for duplicate or related PRs
- [x] I have described the issue in-PR following the relevant issue
template
- [x] I have not referenced internal/instance-local Paperclip issues or
links
- [x] My branch name describes the change
- [x] I have run relevant tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation
- [x] I have documented risks
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open recommendations or follow-ups
- [x] I will address review comments before requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-10-28 17:20:01 -07:00

3248 lines
125 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// @vitest-environment jsdom
import { act, StrictMode, useState, type ComponentProps, type ReactElement } from "react";
import { flushSync } from "react-dom";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
buildAgentMentionHref,
buildSkillMentionHref,
} from "@paperclipai/shared";
import { parseRunnerGoalCommand, TaskChatComposer } from "./TaskChatComposer";
import { ComposerAddMenu } from "./ComposerAddMenu";
import { QuestionForm } from "./QuestionForm";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { Agent } from "@paperclipai/shared";
import { agentsApi } from "@/api/agents";
import { CoordinatorPresentation } from "@/components/coordinator/CoordinatorPresentation";
import { DRAFT_DEBOUNCE_MS } from "../../lib/composer-draft";
import {
loadDraftSubmission,
saveDraft,
saveDraftSubmission,
} from "../../lib/composer-draft";
import { CommentSubmissionUnknownError } from "../../lib/comment-submit-result";
import { queryKeys } from "../../lib/queryKeys";
import { useAccountBoundMutation } from "../../hooks/useAccountBoundMutation";
// The session lookup never answers on its own; tests set the account directly.
vi.mock("../../api/auth", async (importOriginal) => {
const actual = await importOriginal<typeof import("../../api/auth")>();
return { ...actual, authApi: { ...actual.authApi, getSession: () => new Promise(() => {}) } };
});
/**
* MDXEditor-in-jsdom weight (mirrors MarkdownEditor.test.tsx): the real editor
* is mocked with a contenteditable bridge — typing is simulated by setting
* textContent and dispatching an input event, imperative setMarkdown /
* insertMarkdown mutate the same content, and imagePlugin's config is captured
* so the inline upload handler can be exercised directly.
*/
const mdxEditorMockState = vi.hoisted(() => ({
imagePluginOptions: null as {
imageUploadHandler?: (file: File) => Promise<string>;
} | null,
}));
vi.mock("@mdxeditor/editor", async () => {
const React = await import("react");
function setForwardedRef<T>(
ref: React.ForwardedRef<T | null>,
value: T | null,
) {
if (typeof ref === "function") {
ref(value);
return;
}
if (ref) {
(ref as React.MutableRefObject<T | null>).current = value;
}
}
interface MockHandle {
setMarkdown: (value: string) => void;
insertMarkdown: (value: string) => void;
focus: (callback?: () => void) => void;
}
const MDXEditor = React.forwardRef(function MockMDXEditor(
{
markdown,
onChange,
readOnly,
contentEditableClassName,
}: {
markdown: string;
onChange?: (value: string) => void;
readOnly?: boolean;
contentEditableClassName?: string;
},
forwardedRef: React.ForwardedRef<MockHandle | null>,
) {
const editableRef = React.useRef<HTMLDivElement>(null);
const contentRef = React.useRef(markdown);
const onChangeRef = React.useRef(onChange);
onChangeRef.current = onChange;
const handle = React.useMemo<MockHandle>(
() => ({
setMarkdown: (value: string) => {
contentRef.current = value;
if (editableRef.current) editableRef.current.textContent = value;
},
insertMarkdown: (value: string) => {
const next = contentRef.current
? `${contentRef.current}${value}`
: value;
contentRef.current = next;
if (editableRef.current) editableRef.current.textContent = next;
onChangeRef.current?.(next);
},
focus: (callback?: () => void) => {
editableRef.current?.focus();
callback?.();
},
}),
[],
);
React.useEffect(() => {
if (editableRef.current && contentRef.current) {
editableRef.current.textContent = contentRef.current;
}
setForwardedRef(forwardedRef, handle);
return () => setForwardedRef(forwardedRef, null);
}, [forwardedRef, handle]);
return (
<div
ref={editableRef}
data-testid="mdx-editor"
data-content-class-name={contentEditableClassName}
contentEditable={!readOnly}
suppressContentEditableWarning
onInput={(e) => {
const next = e.currentTarget.textContent ?? "";
contentRef.current = next;
onChangeRef.current?.(next);
}}
/>
);
});
return {
CodeMirrorEditor: () => null,
MDXEditor,
codeBlockPlugin: () => ({}),
codeMirrorPlugin: () => ({}),
createRootEditorSubscription$: Symbol("createRootEditorSubscription$"),
headingsPlugin: () => ({}),
imagePlugin: (options: {
imageUploadHandler?: (file: File) => Promise<string>;
}) => {
mdxEditorMockState.imagePluginOptions = options;
return {};
},
linkDialogPlugin: () => ({}),
linkPlugin: () => ({}),
listsPlugin: () => ({}),
markdownShortcutPlugin: () => ({}),
quotePlugin: () => ({}),
realmPlugin: (plugin: unknown) => plugin,
tablePlugin: () => ({}),
thematicBreakPlugin: () => ({}),
};
});
vi.mock("../../lib/mention-deletion", () => ({
mentionDeletionPlugin: () => ({}),
}));
vi.mock("../../lib/paste-normalization", () => ({
pasteNormalizationPlugin: () => ({}),
}));
const SLASH_HREF = buildSkillMentionHref("skill-1", "deploy");
vi.mock("../../context/EditorAutocompleteContext", () => ({
useEditorAutocomplete: () => ({
slashCommands: [
{
id: "skill:skill-1",
kind: "skill",
skillId: "skill-1",
key: "deploy",
name: "Deploy",
slug: "deploy",
description: null,
href: SLASH_HREF,
aliases: ["deploy", "Deploy"],
},
],
}),
}));
let container: HTMLDivElement;
let root: Root | null = null;
let originalRangeRect: typeof Range.prototype.getBoundingClientRect;
beforeEach(() => {
localStorage.clear();
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
mdxEditorMockState.imagePluginOptions = null;
// jsdom ranges have zero-size rects; the mention menu measures the caret.
originalRangeRect = Range.prototype.getBoundingClientRect;
Range.prototype.getBoundingClientRect = () => ({
x: 32,
y: 24,
width: 12,
height: 18,
top: 24,
right: 44,
bottom: 42,
left: 32,
toJSON: () => ({}),
});
});
afterEach(() => {
flushSync(() => root?.unmount());
root = null;
container.remove();
Range.prototype.getBoundingClientRect = originalRangeRect;
window.getSelection()?.removeAllRanges();
});
function render(ui: ReactElement) {
flushSync(() => root!.render(ui));
}
async function flushAsync() {
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 0));
});
}
function editable() {
return container.querySelector<HTMLDivElement>('[data-testid="mdx-editor"]')!;
}
function sendButton() {
return container.querySelector<HTMLButtonElement>(
'[data-testid="task-chat-composer-send"]',
)!;
}
/** Simulate typing: set the contenteditable's text and fire an input event. */
function typeText(value: string) {
const el = editable();
flushSync(() => {
el.textContent = value;
el.dispatchEvent(new Event("input", { bubbles: true }));
});
}
function pressKey(
key: string,
modifiers: { metaKey?: boolean; ctrlKey?: boolean; shiftKey?: boolean } = {},
) {
flushSync(() => {
editable().dispatchEvent(
new KeyboardEvent("keydown", {
key,
...modifiers,
bubbles: true,
cancelable: true,
}),
);
});
}
function openComposerAddMenu() {
const add = container.querySelector<HTMLButtonElement>('[data-testid="task-chat-composer-add"]')!;
flushSync(() => add.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true, button: 0 })));
return add;
}
function pasteFiles(files: File[]) {
const paste = new Event("paste", { bubbles: true, cancelable: true });
Object.defineProperty(paste, "clipboardData", {
value: { files, types: ["Files"] },
});
flushSync(() => {
editable().dispatchEvent(paste);
});
return paste;
}
/** Place the caret at the end of the editable's first text node and announce it. */
async function placeCaretAtEnd() {
const textNode = editable().firstChild;
expect(textNode?.nodeType).toBe(Node.TEXT_NODE);
const selection = window.getSelection();
const range = document.createRange();
range.setStart(textNode!, textNode!.textContent!.length);
range.collapse(true);
selection?.removeAllRanges();
selection?.addRange(range);
flushSync(() => {
document.dispatchEvent(new Event("selectionchange"));
});
// Mention detection defers via requestAnimationFrame after input events.
await flushAsync();
await flushAsync();
}
function autocompleteOption(matchText: string) {
const menu = document.body.querySelector(
'[data-testid="mention-autocomplete-menu"]',
);
expect(menu).toBeTruthy();
const option = Array.from(
menu!.querySelectorAll<HTMLButtonElement>('button[type="button"]'),
).find((node) => node.textContent?.includes(matchText));
expect(option).toBeTruthy();
return option!;
}
function renderComposer(props: ComponentProps<typeof TaskChatComposer>) {
render(<TaskChatComposer {...props} />);
}
describe("TaskChatComposer", () => {
it("keeps creation images inline and prevents submission during their upload", async () => {
let resolveUpload!: (url: string) => void;
const onImageUpload = vi.fn().mockReturnValue(new Promise<string>((resolve) => { resolveUpload = resolve; }));
const creation = {
value: "Before the screenshot",
onChange: vi.fn(),
onSubmit: vi.fn().mockResolvedValue(undefined),
submitLabel: "Create task",
onSelectFiles: vi.fn(),
runSettings: null,
onRunSettingsChange: vi.fn(),
};
render(<TaskChatComposer creation={creation} workMode="standard" onImageUpload={onImageUpload} />);
const image = new File(["png"], "screen.png", { type: "image/png" });
expect(pasteFiles([image]).defaultPrevented).toBe(false);
expect(creation.onSelectFiles).not.toHaveBeenCalled();
const handler = mdxEditorMockState.imagePluginOptions!.imageUploadHandler!;
const upload = handler(image);
await flushAsync();
expect(sendButton().disabled).toBe(true);
pressKey("Enter", { metaKey: true });
expect(creation.onSubmit).not.toHaveBeenCalled();
resolveUpload("/api/assets/screen/content");
const url = await upload;
const value = `Before the screenshot\n\n![screen](${url})\n\nAfter the screenshot`;
render(<TaskChatComposer creation={{ ...creation, value }} workMode="standard" onImageUpload={onImageUpload} />);
await flushAsync();
flushSync(() => sendButton().click());
await flushAsync();
expect(creation.onSubmit).toHaveBeenCalledWith(value, "standard", null);
expect(onImageUpload).toHaveBeenCalledWith(image);
});
it("stages non-image creation paste files while images reach the editor", () => {
const creation = {
value: "Inspect these files",
onChange: vi.fn(), onSubmit: vi.fn().mockResolvedValue(undefined),
submitLabel: "Create task", onSelectFiles: vi.fn(),
runSettings: null, onRunSettingsChange: vi.fn(),
};
render(<TaskChatComposer creation={creation} workMode="standard" onImageUpload={vi.fn()} />);
const image = new File(["png"], "screen.png", { type: "image/png" });
const document = new File(["notes"], "notes.txt", { type: "text/plain" });
expect(pasteFiles([image, document]).defaultPrevented).toBe(false);
expect(creation.onSelectFiles).toHaveBeenCalledWith([document]);
expect(pasteFiles([document]).defaultPrevented).toBe(true);
});
it("settles an acknowledged submission after navigating away", async () => {
const key = "navigate-before-save";
let resolveSend!: () => void;
const onAdd = vi.fn().mockReturnValue(new Promise<void>(resolve => { resolveSend = resolve; }));
render(<TaskChatComposer onAdd={onAdd} workMode="standard" draftKey={key} />);
typeText("Please write a motto");
pressKey("Enter", { metaKey: true });
await flushAsync();
const intent = loadDraftSubmission(key)!;
expect(onAdd.mock.calls[0]?.[4]).toBe(intent.attemptId);
render(<div>Another task</div>);
resolveSend();
await flushAsync();
expect(loadDraftSubmission(key)).toBeNull();
expect(localStorage.getItem(key)).toBeNull();
render(<TaskChatComposer onAdd={onAdd} workMode="standard" draftKey={key} />);
expect(container.textContent).not.toContain("Sending when reconnected…");
expect(editable().textContent).toBe("");
});
it.each([
{ reassignment: { assigneeAgentId: "old-agent", assigneeUserId: null } },
{ runSettings: { model: "old-model" } },
{ reopen: true },
])("keeps an uncertain composite send for reconciliation without resending task changes: %j", async (changes) => {
const key = "uncertain-composite";
const attemptId = "9af8228f-0be7-45ae-a104-6fbe0af6f1d3";
saveDraft(key, "Keep the comment");
saveDraftSubmission(key, { attemptId, reviewed: false, request: { body: "Keep the comment", ...changes }, requestOwner: "user-1" });
const onAdd = vi.fn();
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
client.setQueryData(queryKeys.auth.session, { user: { id: "user-1" } });
render(<QueryClientProvider client={client}><TaskChatComposer onAdd={onAdd} workMode="standard" draftKey={key} /></QueryClientProvider>);
await flushAsync();
expect(container.textContent).toContain("We couldn’t confirm whether this message was sent.");
expect(container.textContent).not.toContain("Resend now");
expect(onAdd).not.toHaveBeenCalled();
const cancel = Array.from(container.querySelectorAll("button")).find(button => button.textContent === "Cancel")!;
flushSync(() => cancel.click());
await flushAsync();
expect(editable().textContent).toBe("Keep the comment");
expect(loadDraftSubmission(key)).toBeNull();
expect(onAdd).not.toHaveBeenCalled();
client.clear();
});
it("automatically reconciles a restored submission by its server receipt, not its text", async () => {
const key = "saved-receipt";
const attemptId = "9af8228f-0be7-45ae-a104-6fbe0af6f1d3";
saveDraft(key, "Already answered");
saveDraftSubmission(key, { attemptId, reviewed: false });
const onAdd = vi.fn();
render(<TaskChatComposer onAdd={onAdd} workMode="standard" draftKey={key}
confirmedSubmissionIds={new Set(["another-request"])} />);
expect(loadDraftSubmission(key)).not.toBeNull();
render(<TaskChatComposer onAdd={onAdd} workMode="standard" draftKey={key}
confirmedSubmissionIds={new Set([attemptId])} />);
await flushAsync();
expect(loadDraftSubmission(key)).toBeNull();
expect(editable().textContent).toBe("");
expect(onAdd).not.toHaveBeenCalled();
});
it("keeps the original key after account cancellation of an accepted initial send", async () => {
const key = "accepted-account-roundtrip";
const client = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } });
client.setQueryData(queryKeys.auth.session, { user: { id: "user-1" } });
let accepted!: () => void;
const post = vi.fn<(body: string, requestId?: string) => Promise<void>>()
.mockImplementationOnce(() => new Promise<void>(resolve => { accepted = resolve; })).mockResolvedValue(undefined);
function OwnedComposer() {
const mutation = useAccountBoundMutation({ mutationFn: ({ body, requestId }: { body: string; requestId?: string }) => post(body, requestId) });
return <TaskChatComposer draftKey={key} workMode="standard"
onAdd={(body, _reopen, _reassign, _files, requestId) => mutation.mutateAsync({ body, requestId })} />;
}
render(<QueryClientProvider client={client}><OwnedComposer /></QueryClientProvider>);
typeText("Accepted before the account changed");
pressKey("Enter", { metaKey: true });
await flushAsync();
const requestId = post.mock.calls[0][1]!;
flushSync(() => {
client.setQueryData(queryKeys.auth.session, { user: { id: "user-2" } });
client.setQueryData(queryKeys.auth.session, { user: { id: "user-1" } });
});
accepted();
await flushAsync();
expect(loadDraftSubmission(key)?.attemptId).toBe(requestId);
expect(editable().textContent).toBe("Accepted before the account changed");
const retry = Array.from(container.querySelectorAll("button")).find(button => button.textContent === "Resend now")!;
flushSync(() => retry.click());
await flushAsync();
expect(post).toHaveBeenCalledTimes(2);
expect(post.mock.calls[1][1]).toBe(requestId);
expect(loadDraftSubmission(key)).toBeNull();
client.clear();
});
it.each([false, true])("does not retry from a stale account render (returns to original=%s)", async (returns) => {
const key = "stale-account-retry";
const attemptId = "9af8228f-0be7-45ae-a104-6fbe0af6f1d3";
saveDraft(key, "Original account text");
saveDraftSubmission(key, { attemptId, reviewed: false, request: { body: "Original account text" }, requestOwner: "user-1" });
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
client.setQueryData(queryKeys.auth.session, { user: { id: "user-1" } });
const onAdd = vi.fn().mockResolvedValue(undefined);
render(<QueryClientProvider client={client}><TaskChatComposer onAdd={onAdd} workMode="standard" draftKey={key} /></QueryClientProvider>);
await flushAsync();
const resend = Array.from(container.querySelectorAll("button")).find(button => button.textContent === "Resend now")!;
flushSync(() => {
client.setQueryData(queryKeys.auth.session, { user: { id: "user-2" } });
if (returns) client.setQueryData(queryKeys.auth.session, { user: { id: "user-1" } });
resend.click();
});
await flushAsync();
expect(onAdd).not.toHaveBeenCalled();
expect(loadDraftSubmission(key)?.attemptId).toBe(attemptId);
client.clear();
});
it.each(["success", "rejection"])("settles a receipt before its in-flight resend %s without cutting the next draft", async (outcome) => {
const key = "receipt-and-resend";
const attemptId = "9af8228f-0be7-45ae-a104-6fbe0af6f1d3";
saveDraft(key, "First request\n\nKeep this next draft");
saveDraftSubmission(key, { attemptId, reviewed: false, nextDraftOffset: 15, request: { body: "First request" }, requestOwner: "user-1" });
let finish!: () => void;
let fail!: (error: Error) => void;
const onAdd = vi.fn(() => new Promise<void>((resolve, reject) => { finish = resolve; fail = reject; }));
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
client.setQueryData(queryKeys.auth.session, { user: { id: "user-1" } });
const show = (confirmedSubmissionIds = new Set<string>()) => render(<QueryClientProvider client={client}><TaskChatComposer onAdd={onAdd} workMode="standard" draftKey={key} confirmedSubmissionIds={confirmedSubmissionIds} /></QueryClientProvider>);
show(); await flushAsync();
const resend = Array.from(container.querySelectorAll("button")).find(button => button.textContent === "Resend now")!;
flushSync(() => resend.click()); await flushAsync();
expect(onAdd).toHaveBeenCalledTimes(1);
show(new Set([attemptId])); await flushAsync();
expect(editable().textContent).toBe("Keep this next draft");
if (outcome === "success") finish(); else fail(new Error("Late rejected resend"));
await flushAsync();
expect(container.textContent).not.toContain("Late rejected resend");
expect(editable().textContent).toBe("Keep this next draft");
expect(localStorage.getItem(key)).toBe("Keep this next draft");
client.clear();
});
it("preserves the next draft when an uncertain submission is later confirmed after reload", async () => {
const key = "uncertain-with-next-draft";
let rejectSend!: (error: Error) => void;
const onAdd = vi.fn().mockReturnValue(new Promise<void>((_resolve, reject) => { rejectSend = reject; }));
render(<TaskChatComposer onAdd={onAdd} workMode="standard" draftKey={key} />);
typeText("First request");
pressKey("Enter", { metaKey: true });
await flushAsync();
const attemptId = loadDraftSubmission(key)!.attemptId;
typeText("Keep this next draft");
rejectSend(new CommentSubmissionUnknownError());
await flushAsync();
render(<div>Another task</div>);
render(<TaskChatComposer onAdd={onAdd} workMode="standard" draftKey={key}
confirmedSubmissionIds={new Set([attemptId])} />);
await flushAsync();
expect(editable().textContent).toBe("Keep this next draft");
expect(localStorage.getItem(key)).toBe("Keep this next draft");
expect(loadDraftSubmission(key)).toBeNull();
expect(onAdd).toHaveBeenCalledTimes(1);
});
it.each(["acknowledgment", "receipt after reload"])("preserves a next draft across navigation before %s", async (confirmation) => {
const key = "navigate-with-next-draft";
let resolveSend!: () => void;
const onAdd = vi.fn().mockReturnValue(new Promise<void>(resolve => { resolveSend = resolve; }));
render(<TaskChatComposer onAdd={onAdd} workMode="standard" draftKey={key} />);
typeText("First request");
pressKey("Enter", { metaKey: true });
await flushAsync();
const attemptId = loadDraftSubmission(key)!.attemptId;
typeText("Keep the unsent next request");
render(<div>Another task</div>);
if (confirmation === "acknowledgment") {
resolveSend();
await flushAsync();
}
render(<TaskChatComposer onAdd={onAdd} workMode="standard" draftKey={key}
confirmedSubmissionIds={new Set([attemptId])} />);
await flushAsync();
expect(editable().textContent).toBe("Keep the unsent next request");
expect(localStorage.getItem(key)).toBe("Keep the unsent next request");
expect(loadDraftSubmission(key)).toBeNull();
expect(onAdd).toHaveBeenCalledTimes(1);
resolveSend();
await flushAsync();
expect(localStorage.getItem(key)).toBe("Keep the unsent next request");
});
it("does not copy another task's draft into a late acknowledged submission", async () => {
let resolveSend!: () => void;
const onAdd = vi.fn().mockReturnValue(new Promise<void>(resolve => { resolveSend = resolve; }));
render(<TaskChatComposer onAdd={onAdd} workMode="standard" draftKey="first-task" />);
typeText("First request");
pressKey("Enter", { metaKey: true });
await flushAsync();
render(<TaskChatComposer onAdd={onAdd} workMode="standard" draftKey="second-task" />);
typeText("Second task draft");
resolveSend();
await flushAsync();
expect(loadDraftSubmission("first-task")).toBeNull();
expect(localStorage.getItem("first-task")).toBeNull();
expect(editable().textContent).toBe("Second task draft");
});
it.each(["success", "unknown"])(
"does not overwrite another retained attempt after an older %s",
async (outcome) => {
const key = "exact-attempt-settlement";
let settle!: () => void;
const onAdd = vi.fn().mockReturnValue(
new Promise<void>((resolve, reject) => {
settle =
outcome === "success"
? resolve
: () => reject(new CommentSubmissionUnknownError());
}),
);
render(
<TaskChatComposer onAdd={onAdd} workMode="standard" draftKey={key} />,
);
typeText("Older request");
pressKey("Enter", { metaKey: true });
await flushAsync();
const newer = {
version: 1,
draftKey: key,
attemptId: "aaf8228f-0be7-45ae-a104-6fbe0af6f1d3",
reviewed: false,
};
localStorage.setItem(key, "Newer retained draft");
localStorage.setItem(`${key}:attachments:v1`, "newer receipt sentinel");
localStorage.setItem(`${key}:submission:v1`, JSON.stringify(newer));
settle();
await flushAsync();
await flushAsync();
window.dispatchEvent(new Event("beforeunload"));
expect(localStorage.getItem(`${key}:submission:v1`)).toBe(
JSON.stringify(newer),
);
expect(localStorage.getItem(key)).toBe("Newer retained draft");
expect(localStorage.getItem(`${key}:attachments:v1`)).toBe(
"newer receipt sentinel",
);
},
);
it("keeps an unconfirmed send in memory with disabled storage and resends it with the same request ID", async () => {
const storage = vi
.spyOn(Storage.prototype, "setItem")
.mockImplementation(() => {
throw new Error("Storage disabled");
});
const onAdd = vi
.fn()
.mockRejectedValueOnce(new CommentSubmissionUnknownError())
.mockResolvedValue(undefined);
try {
renderComposer({
onAdd: onAdd,
workMode: "standard",
draftKey: "storage-disabled",
});
typeText("Send this exactly once");
pressKey("Enter", { metaKey: true });
await flushAsync();
await flushAsync();
expect(sendButton().disabled).toBe(true);
expect(container.textContent).toContain("Sending when reconnected…");
expect(container.textContent).not.toContain("Discard draft");
pressKey("Enter", { metaKey: true });
await flushAsync();
expect(onAdd).toHaveBeenCalledTimes(1);
const resend = Array.from(container.querySelectorAll("button")).find(
(button) => button.textContent === "Resend now",
)!;
flushSync(() => resend.click());
await flushAsync();
await flushAsync();
expect(onAdd).toHaveBeenCalledTimes(2);
// Same body and same client request ID, so the server dedupes.
expect(onAdd.mock.calls[1]?.[0]).toBe(onAdd.mock.calls[0]?.[0]);
expect(onAdd.mock.calls[1]?.[4]).toBe(onAdd.mock.calls[0]?.[4]);
expect(container.textContent).not.toContain("Sending when reconnected…");
expect(editable().textContent).toBe("");
} finally {
storage.mockRestore();
}
});
it("gives an unconfirmed send back to the editor on Cancel without resending it", async () => {
const onAdd = vi.fn().mockRejectedValue(new CommentSubmissionUnknownError());
render(<TaskChatComposer onAdd={onAdd} workMode="standard" draftKey="cancel-pending" />);
typeText("Keep my words");
pressKey("Enter", { metaKey: true });
await flushAsync();
await flushAsync();
expect(loadDraftSubmission("cancel-pending")?.request?.body).toBe("Keep my words");
const cancel = Array.from(container.querySelectorAll("button")).find(
(button) => button.textContent === "Cancel",
)!;
flushSync(() => cancel.click());
await flushAsync();
expect(container.textContent).not.toContain("Sending when reconnected…");
expect(editable().textContent).toBe("Keep my words");
expect(loadDraftSubmission("cancel-pending")).toBeNull();
expect(sendButton().disabled).toBe(false);
expect(onAdd).toHaveBeenCalledTimes(1);
});
it("puts the text back with readable copy when a resend is rejected", async () => {
const { ApiError } = await import("../../api/client");
const onAdd = vi
.fn()
.mockRejectedValueOnce(new CommentSubmissionUnknownError())
.mockRejectedValueOnce(new ApiError("Forbidden", 403, { error: "Forbidden" }));
render(<TaskChatComposer onAdd={onAdd} workMode="standard" draftKey="rejected-resend" />);
typeText("Rejected later");
pressKey("Enter", { metaKey: true });
await flushAsync();
await flushAsync();
const resend = Array.from(container.querySelectorAll("button")).find(
(button) => button.textContent === "Resend now",
)!;
flushSync(() => resend.click());
await flushAsync();
await flushAsync();
expect(onAdd).toHaveBeenCalledTimes(2);
expect(container.textContent).not.toContain("Sending when reconnected…");
expect(editable().textContent).toBe("Rejected later");
expect(container.textContent).toContain("Forbidden");
expect(loadDraftSubmission("rejected-resend")).toBeNull();
});
it("resends a comment sent before the session loaded once the account is known", async () => {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
const onAdd = vi
.fn()
.mockRejectedValueOnce(new CommentSubmissionUnknownError())
.mockResolvedValue(undefined);
render(
<QueryClientProvider client={client}>
<TaskChatComposer onAdd={onAdd} workMode="standard" draftKey="owner-late" />
</QueryClientProvider>,
);
typeText("Sent before the session");
pressKey("Enter", { metaKey: true });
await flushAsync();
await flushAsync();
// Until the account is known the send cannot be replayed.
const resendButton = () => Array.from(container.querySelectorAll("button")).find(
(button) => button.textContent === "Resend now",
);
expect(resendButton()).toBeUndefined();
act(() => {
client.setQueryData(queryKeys.auth.session, { user: { id: "user-1" } });
});
await flushAsync();
await flushAsync();
expect(loadDraftSubmission("owner-late")?.requestOwner).toBe("user-1");
expect(container.textContent).toContain("Sending when reconnected…");
flushSync(() => resendButton()!.click());
await flushAsync();
await flushAsync();
expect(onAdd).toHaveBeenCalledTimes(2);
expect(onAdd.mock.calls[1]?.[4]).toBe(onAdd.mock.calls[0]?.[4]);
expect(container.textContent).not.toContain("Sending when reconnected…");
client.clear();
});
it("never binds an account to a stored send that has no owner", async () => {
const key = "owner-unknown-stored";
saveDraft(key, "Stored without owner");
saveDraftSubmission(key, {
attemptId: "baf8228f-0be7-45ae-a104-6fbe0af6f1d3",
reviewed: false,
request: { body: "Stored without owner" },
});
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
client.setQueryData(queryKeys.auth.session, { user: { id: "user-1" } });
const onAdd = vi.fn();
render(
<QueryClientProvider client={client}>
<TaskChatComposer onAdd={onAdd} workMode="standard" draftKey={key} />
</QueryClientProvider>,
);
await flushAsync();
expect(Array.from(container.querySelectorAll("button")).some(
(button) => button.textContent === "Resend now",
)).toBe(false);
expect(loadDraftSubmission(key)?.requestOwner).toBeUndefined();
expect(onAdd).not.toHaveBeenCalled();
client.clear();
});
it("restores an in-flight submission as uncertain only for its exact task key", async () => {
const key = "uncertain-task-one";
const attemptId = "9af8228f-0be7-45ae-a104-6fbe0af6f1d3";
saveDraft(key, "Retained unknown draft");
saveDraftSubmission(key, { attemptId, reviewed: false });
const onAdd = vi.fn();
render(
<StrictMode>
<TaskChatComposer onAdd={onAdd} workMode="standard" draftKey={key} />
</StrictMode>,
);
expect(editable().textContent).toBe("Retained unknown draft");
expect(sendButton().disabled).toBe(true);
expect(loadDraftSubmission(key)?.attemptId).toBe(attemptId);
render(
<StrictMode>
<TaskChatComposer
onAdd={onAdd}
workMode="standard"
draftKey="uncertain-task-two"
/>
</StrictMode>,
);
await flushAsync();
typeText("Separate task draft");
await flushAsync();
expect(sendButton().disabled).toBe(false);
expect(loadDraftSubmission(key)?.attemptId).toBe(attemptId);
expect(onAdd).not.toHaveBeenCalled();
});
it("retains explicit upload receipt IDs across a failed send without uploading again", async () => {
const onAdd = vi
.fn()
.mockRejectedValueOnce(new Error("try again"))
.mockResolvedValue(undefined);
const id = "9af8228f-0be7-45ae-a104-6fbe0af6f1d3";
const onAttachImage = vi.fn().mockResolvedValue({
id,
contentPath: `/api/attachments/${id}/content`,
originalFilename: "notes.txt",
});
renderComposer({
onAdd: onAdd,
workMode: "standard",
onAttachImage: onAttachImage,
});
typeText("Inspect the new file.");
pasteFiles([new File(["new bytes"], "notes.txt", { type: "text/plain" })]);
await flushAsync();
flushSync(() => sendButton().click());
await flushAsync();
expect(onAdd.mock.calls[0]?.[3]).toEqual([id]);
flushSync(() => sendButton().click());
await flushAsync();
expect(onAdd.mock.calls[1]?.[3]).toEqual([id]);
expect(onAttachImage).toHaveBeenCalledTimes(1);
});
it("keeps a failed upload and retries it only when asked", async () => {
const id = "9af8228f-0be7-45ae-a104-6fbe0af6f1d3";
const { ApiError } = await import("../../api/client");
const onAttachImage = vi
.fn()
.mockRejectedValueOnce(new ApiError("Unavailable", 503, { error: "tenant_app_unavailable" }))
.mockResolvedValue({ id, contentPath: `/api/attachments/${id}/content`, originalFilename: "notes.txt" });
render(<TaskChatComposer onAdd={vi.fn()} workMode="standard" onAttachImage={onAttachImage} />);
pasteFiles([new File(["bytes"], "notes.txt", { type: "text/plain" })]);
await flushAsync();
expect(container.textContent).toContain("Paperclip is restarting or updating");
expect(container.textContent).not.toContain("tenant_app_unavailable");
await new Promise((resolve) => setTimeout(resolve, 50));
expect(onAttachImage).toHaveBeenCalledTimes(1);
const retry = container.querySelector<HTMLButtonElement>('button[aria-label="Retry notes.txt"]')!;
flushSync(() => retry.click());
await flushAsync();
expect(onAttachImage).toHaveBeenCalledTimes(2);
expect(container.querySelector('button[aria-label="Retry notes.txt"]')).toBeNull();
expect(container.textContent).not.toContain("Paperclip is restarting or updating");
expect(sendButton().disabled).toBe(false);
});
it("holds submission while an inline image upload is pending and binds its receipt", async () => {
const onAdd = vi.fn().mockResolvedValue(undefined);
const id = "5e5f9946-c706-4785-8988-d4d6f0f499ab";
let resolveUpload!: (value: unknown) => void;
const onAttachImage = vi.fn().mockReturnValue(
new Promise((resolve) => {
resolveUpload = resolve;
}),
);
renderComposer({
onAdd: onAdd,
workMode: "standard",
onAttachImage: onAttachImage,
});
typeText("Inspect the picture.");
const handler = mdxEditorMockState.imagePluginOptions!.imageUploadHandler!;
const upload = handler(
new File(["png"], "image.png", { type: "image/png" }),
);
await flushAsync();
expect(sendButton().disabled).toBe(true);
pressKey("Enter", { metaKey: true });
expect(onAdd).not.toHaveBeenCalled();
resolveUpload({
id,
contentPath: `/api/attachments/${id}/content`,
originalFilename: "image.png",
});
const url = await upload;
typeText(`Inspect the picture.\n\n![image](${url})`);
await flushAsync();
flushSync(() => sendButton().click());
await flushAsync();
expect(onAdd.mock.calls[0]?.[3]).toEqual([id]);
});
it("does not infer binding from arbitrary pasted attachment Markdown", async () => {
const onAdd = vi.fn().mockResolvedValue(undefined);
render(<TaskChatComposer onAdd={onAdd} workMode="standard" />);
typeText(
"[old file](/api/attachments/9af8228f-0be7-45ae-a104-6fbe0af6f1d3/content)",
);
flushSync(() => sendButton().click());
await flushAsync();
expect(onAdd.mock.calls[0]?.[3]).toBeUndefined();
});
it("submits multiple retained file and image receipts but not removed selections", async () => {
const onAdd = vi.fn().mockResolvedValue(undefined);
const receipts = [
"9af8228f-0be7-45ae-a104-6fbe0af6f1d3",
"5e5f9946-c706-4785-8988-d4d6f0f499ab",
"975687b9-d594-46a1-8b9e-707de6480fd7",
"0aab5c84-8722-40f6-ae89-4b35040cedcf",
];
const onAttachImage = vi.fn().mockImplementation(async (file: File) => {
const id = receipts[onAttachImage.mock.calls.length - 1]!;
return {
id,
contentPath: `/api/attachments/${id}/content`,
originalFilename: file.name,
};
});
renderComposer({
onAdd: onAdd,
workMode: "standard",
onAttachImage: onAttachImage,
});
pasteFiles([
new File(["one"], "one.txt", { type: "text/plain" }),
new File(["two"], "two.txt", { type: "text/plain" }),
]);
await flushAsync();
const handler = mdxEditorMockState.imagePluginOptions!.imageUploadHandler!;
const retainedImage = await handler(
new File(["png"], "three.png", { type: "image/png" }),
);
await handler(new File(["png"], "removed.png", { type: "image/png" }));
typeText(`Only this picture remains ![three](${retainedImage})`);
await flushAsync();
flushSync(() =>
container
.querySelector<HTMLButtonElement>(
'button[aria-label="Remove two.txt"]',
)!
.click(),
);
flushSync(() => sendButton().click());
await flushAsync();
expect(onAdd.mock.calls[0]?.[3]).toEqual([receipts[0], receipts[2]]);
expect(onAdd.mock.calls[0]?.[0]).not.toContain("two.txt");
expect(onAdd.mock.calls[0]?.[0]).not.toContain("removed.png");
});
it("restores file and inline image receipts after a fresh composer mount", async () => {
const onAdd = vi.fn().mockResolvedValue(undefined);
const ids = [
"9af8228f-0be7-45ae-a104-6fbe0af6f1d3",
"5e5f9946-c706-4785-8988-d4d6f0f499ab",
];
const onAttachImage = vi.fn().mockImplementation(async (file: File) => {
const id = ids[onAttachImage.mock.calls.length - 1]!;
return {
id,
contentPath: `/api/attachments/${id}/content`,
originalFilename: file.name,
};
});
const props = {
onAdd,
workMode: "standard" as const,
onAttachImage,
draftKey: "receipt-reload",
};
render(<TaskChatComposer {...props} />);
pasteFiles([new File(["text"], "file.txt", { type: "text/plain" })]);
await flushAsync();
const url = await mdxEditorMockState.imagePluginOptions!
.imageUploadHandler!(
new File(["png"], "image.png", { type: "image/png" }),
);
typeText(`Inspect both files ![image](${url})`);
await flushAsync();
render(<div />);
render(<TaskChatComposer {...props} />);
await flushAsync();
expect(container.textContent).toContain("file.txt");
expect(editable().textContent).toContain(url);
flushSync(() => sendButton().click());
await flushAsync();
expect(onAdd.mock.calls[0]?.[3]).toEqual(ids);
expect(onAttachImage).toHaveBeenCalledTimes(2);
expect(localStorage.getItem("receipt-reload:attachments:v1")).toBeNull();
});
it("removes a pending inline image without a late insertion or restored receipt", async () => {
const onAdd = vi.fn().mockResolvedValue(undefined);
let resolveUpload!: (value: unknown) => void;
const onAttachImage = vi.fn().mockReturnValue(
new Promise((resolve) => {
resolveUpload = resolve;
}),
);
renderComposer({
onAdd: onAdd,
workMode: "standard",
onAttachImage: onAttachImage,
draftKey: "removed-pending",
});
typeText("Keep this text");
const upload = mdxEditorMockState.imagePluginOptions!.imageUploadHandler!(
new File(["png"], "pending.png", { type: "image/png" }),
);
const rejected = expect(upload).rejects.toThrow("Attachment was removed");
await flushAsync();
expect(container.textContent).toContain("pending.png");
expect(sendButton().disabled).toBe(true);
flushSync(() =>
container
.querySelector<HTMLButtonElement>(
'button[aria-label="Remove pending.png"]',
)!
.click(),
);
resolveUpload({
id: "9af8228f-0be7-45ae-a104-6fbe0af6f1d3",
contentPath:
"/api/attachments/9af8228f-0be7-45ae-a104-6fbe0af6f1d3/content",
});
await rejected;
await flushAsync();
expect(editable().textContent).toBe("Keep this text");
expect(localStorage.getItem("removed-pending:attachments:v1")).toBeNull();
flushSync(() => sendButton().click());
await flushAsync();
expect(onAdd.mock.calls[0]?.[3]).toBeUndefined();
});
it("adds 10px to the composer's original 8px interior padding", () => {
render(<TaskChatComposer onAdd={async () => {}} workMode="standard" />);
const composer = container.querySelector(
'[data-testid="task-chat-composer-input"]',
)?.parentElement;
expect(composer?.className).toContain("p-(--sz-18px)");
expect(composer?.className).not.toContain("p-2");
});
it("leaves an 8px token gap between the editor and action row", () => {
renderComposer({
onAdd: async () => {},
workMode: "planning",
onWorkModeChange: async () => {},
});
const actions = container.querySelector(
'[data-testid="task-chat-composer-actions"]',
);
expect(actions?.classList).toContain("mt-2");
expect(actions?.classList).not.toContain("mt-1");
});
it("renders a light card shell while preserving the borderless dark treatment", () => {
renderComposer({
onAdd: async () => {},
workMode: "planning",
onWorkModeChange: async () => {},
enableReassign: true,
currentAssigneeValue: "agent:runner",
reassignOptions: [{ id: "agent:runner", label: "Runner" }],
});
const composer = container.querySelector<HTMLElement>(".paperclip-task-chat-composer")!;
const mode = container.querySelector<HTMLElement>(
'[data-testid="task-chat-composer-mode"]',
)!;
const runner = container.querySelector<HTMLElement>(
'[data-testid="task-chat-composer-assignee"]',
)!;
expect(composer.classList).toContain("border");
expect(composer.classList).toContain("border-border");
expect(composer.classList).toContain("bg-card");
expect(composer.classList).toContain("shadow-(--shadow-task-composer)");
expect(composer.classList).toContain("dark:border-0");
expect(composer.classList).toContain("dark:bg-muted");
expect(composer.classList).toContain("dark:shadow-none");
expect(composer.className).not.toContain("focus-within:ring");
expect(mode.classList).toContain("rounded-full");
expect(mode.getAttribute("aria-label")).toBe("Remove Plan mode");
expect(runner.classList).toContain("border-0");
expect(runner.classList).not.toContain("border");
expect(runner.className).not.toContain("ring-2");
});
it("scopes the wrapping placeholder override to the task-chat composer", () => {
render(<TaskChatComposer onAdd={vi.fn()} workMode="standard" />);
expect(container.querySelector("[data-testid='task-chat-composer-input']")?.parentElement?.classList).toContain(
"paperclip-task-chat-composer",
);
});
it("uses a compact mobile editor that can grow with the message", () => {
render(<TaskChatComposer onAdd={vi.fn()} workMode="standard" mobile />);
expect(editable().dataset.contentClassName).toContain("min-h-(--sz-48px)");
expect(editable().dataset.contentClassName).toContain("max-h-(--tc-composer-editor-max-height)");
});
it("submits the trimmed body on Cmd+Enter and clears the draft", async () => {
const onAdd = vi.fn().mockResolvedValue(undefined);
render(<TaskChatComposer onAdd={onAdd} workMode="standard" />);
expect(sendButton().disabled).toBe(true);
typeText(" hello there ");
expect(sendButton().disabled).toBe(false);
pressKey("Enter", { metaKey: true });
await flushAsync();
await flushAsync();
expect(onAdd).toHaveBeenCalledWith("hello there", undefined, undefined, undefined, expect.any(String));
expect(editable().textContent).toBe("");
});
it("submits on Ctrl+Enter", async () => {
const onAdd = vi.fn().mockResolvedValue(undefined);
render(<TaskChatComposer onAdd={onAdd} workMode="standard" />);
typeText("hello");
pressKey("Enter", { ctrlKey: true });
await flushAsync();
expect(onAdd).toHaveBeenCalledWith("hello", undefined, undefined, undefined, expect.any(String));
});
it("does not submit on plain Enter or Shift+Enter (newline stays with the editor)", async () => {
const onAdd = vi.fn().mockResolvedValue(undefined);
render(<TaskChatComposer onAdd={onAdd} workMode="standard" />);
typeText("line one");
pressKey("Enter");
pressKey("Enter", { shiftKey: true });
await flushAsync();
expect(onAdd).not.toHaveBeenCalled();
expect(editable().textContent).toBe("line one");
});
it("cycles the pending mode with Shift+Tab and applies it on submit", async () => {
const onAdd = vi.fn().mockResolvedValue(undefined);
const onWorkModeChange = vi.fn().mockResolvedValue(undefined);
renderComposer({
onAdd: onAdd,
workMode: "standard",
onWorkModeChange: onWorkModeChange,
});
expect(container.querySelector('[data-testid="task-chat-composer-add"]')).not.toBeNull();
expect(container.querySelector('[data-testid="task-chat-composer-mode"]')).toBeNull();
pressKey("Tab", { shiftKey: true });
const chip = container.querySelector<HTMLButtonElement>(
'[data-testid="task-chat-composer-mode"]',
)!;
expect(chip.getAttribute("data-pending-work-mode")).toBe("planning");
expect(chip.textContent).toContain("Plan");
typeText("do the plan");
pressKey("Enter", { metaKey: true });
await flushAsync();
expect(onWorkModeChange).toHaveBeenCalledWith("planning");
expect(onAdd).toHaveBeenCalledWith("do the plan", undefined, undefined, undefined, expect.any(String));
});
it("cycles Auto, Plan, and Ask modes with Cmd+Period while focused", () => {
const onWorkModeChange = vi.fn().mockResolvedValue(undefined);
renderComposer({
onAdd: vi.fn(),
workMode: "standard",
onWorkModeChange: onWorkModeChange,
});
editable().focus();
expect(container.querySelector('[data-testid="task-chat-composer-add"]')?.getAttribute("aria-keyshortcuts")).toContain("Meta+Period");
expect(container.querySelector('[data-testid="task-chat-composer-mode"]')).toBeNull();
const cycleMode = () => {
const event = new KeyboardEvent("keydown", {
key: ".",
code: "Period",
metaKey: true,
bubbles: true,
cancelable: true,
});
flushSync(() => editable().dispatchEvent(event));
expect(event.defaultPrevented).toBe(true);
};
cycleMode();
const chip = container.querySelector<HTMLButtonElement>('[data-testid="task-chat-composer-mode"]')!;
expect(chip.getAttribute("data-pending-work-mode")).toBe("planning");
expect(chip.textContent).toContain("Plan");
cycleMode();
expect(chip.getAttribute("data-pending-work-mode")).toBe("ask");
expect(chip.textContent).toContain("Ask");
cycleMode();
expect(container.querySelector('[data-testid="task-chat-composer-mode"]')).toBeNull();
expect(onWorkModeChange).not.toHaveBeenCalled();
});
it("selects exclusive modes from the add menu and removes the active chip", () => {
render(<TaskChatComposer onAdd={vi.fn()} workMode="standard" onWorkModeChange={vi.fn()} />);
openComposerAddMenu();
expect(document.querySelector('[data-testid="composer-add-file"]')).toBeNull();
flushSync(() => (document.querySelector('[data-testid="composer-add-plan"]') as HTMLElement).click());
expect(container.querySelector('[data-testid="task-chat-composer-mode"]')?.textContent).toContain("Plan mode");
openComposerAddMenu();
flushSync(() => (document.querySelector('[data-testid="composer-add-ask"]') as HTMLElement).click());
expect(container.querySelector('[data-testid="task-chat-composer-mode"]')?.textContent).toContain("Ask mode");
flushSync(() => (container.querySelector('[data-testid="task-chat-composer-mode"]') as HTMLElement).click());
expect(container.querySelector('[data-testid="task-chat-composer-mode"]')).toBeNull();
});
it("opens a mobile Add dialog while keeping Send at the end of the footer", () => {
render(<TaskChatComposer onAdd={vi.fn()} workMode="standard" onWorkModeChange={vi.fn()}
onAttachImage={vi.fn().mockResolvedValue(undefined)} mobile enableReassign
reassignOptions={[{ id: "agent:codex", label: "Codie" }]}
currentAssigneeValue="agent:codex" />);
const actions = container.querySelector<HTMLElement>('[data-testid="task-chat-composer-actions"]')!;
expect(actions.lastElementChild?.lastElementChild).toBe(sendButton());
expect(actions.firstElementChild?.contains(sendButton())).toBe(false);
expect(actions.lastElementChild?.querySelector('[data-testid="task-chat-composer-assignee"]')).not.toBeNull();
expect(actions.querySelector('[data-testid="task-chat-composer-assignee"] [data-slot="agent-avatar"] img')).not.toBeNull();
flushSync(() => container.querySelector<HTMLButtonElement>('[data-testid="task-chat-composer-add"]')!.click());
const dialog = document.querySelector<HTMLElement>('[role="dialog"]');
expect(dialog?.textContent).toContain("Files and images");
expect(dialog?.textContent).toContain("Plan mode");
expect(dialog?.textContent).toContain("Ask mode");
flushSync(() => document.querySelector<HTMLButtonElement>('[data-testid="composer-add-plan"]')!.click());
const mode = container.querySelector<HTMLElement>('[data-testid="task-chat-composer-mode"]')!;
expect(mode.textContent).toContain("Plan mode");
expect(actions.contains(mode)).toBe(true);
expect(mode.querySelector(".sr-only")?.textContent).toBe("Plan mode");
expect(document.querySelector('[role="dialog"][data-state="open"]')).toBeNull();
});
it("opens the mobile file picker from the Add dialog", () => {
render(<TaskChatComposer onAdd={vi.fn()} workMode="standard" mobile
onAttachImage={vi.fn().mockResolvedValue(undefined)} />);
const input = container.querySelector<HTMLInputElement>('input[type="file"]')!;
const openPicker = vi.spyOn(input, "click").mockImplementation(() => {});
flushSync(() => container.querySelector<HTMLButtonElement>('[data-testid="task-chat-composer-add"]')!.click());
flushSync(() => document.querySelector<HTMLButtonElement>('[data-testid="composer-add-file"]')!.click());
expect(openPicker).toHaveBeenCalledOnce();
});
it("uses the Add dialog through the mobile shell's tablet breakpoint", () => {
const matchMedia = vi.fn((query: string) => ({
media: query,
matches: query === "(max-width: 767px)",
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
} as unknown as MediaQueryList));
vi.stubGlobal("matchMedia", matchMedia);
try {
render(<ComposerAddMenu mode="standard" onModeChange={vi.fn()} />);
flushSync(() => container.querySelector<HTMLButtonElement>('[aria-label="Add to composer"]')!.click());
expect(document.querySelector('[role="dialog"]')).not.toBeNull();
expect(matchMedia).toHaveBeenCalledWith("(max-width: 767px)");
} finally {
vi.unstubAllGlobals();
}
});
it("uses the borderless Paper controls and inverse circular send button", () => {
renderComposer({
onAdd: vi.fn(),
workMode: "standard",
onWorkModeChange: vi.fn(),
enableReassign: true,
reassignOptions: [{ id: "agent:a1", label: "Chief of Staff" }],
currentAssigneeValue: "agent:a1",
});
const add = container.querySelector<HTMLButtonElement>(
'[data-testid="task-chat-composer-add"]',
)!;
const assignee = container.querySelector<HTMLButtonElement>(
'[data-testid="task-chat-composer-assignee"]',
)!;
const send = sendButton();
expect(add.classList).not.toContain("border");
expect(add.getAttribute("aria-label")).toBe("Add to composer");
expect(container.querySelector('[data-testid="task-chat-composer-mode"]')).toBeNull();
expect(assignee.classList).toContain("border-0");
expect(assignee.classList).toContain("shadow-none");
expect(send.classList).toContain("rounded-full");
expect(send.classList).toContain("bg-foreground");
expect(send.classList).toContain("text-background");
expect(send.classList).toContain("disabled:opacity-100");
});
it("passes reopen=true when the issue resumes-to-todo and the assignee is an agent", async () => {
const onAdd = vi.fn().mockResolvedValue(undefined);
renderComposer({
onAdd: onAdd,
workMode: "standard",
issueStatus: "done",
currentAssigneeValue: "agent:a1",
});
typeText("wake up");
pressKey("Enter", { metaKey: true });
await flushAsync();
expect(onAdd).toHaveBeenCalledWith("wake up", true, undefined, undefined, expect.any(String));
});
it("shows the add menu only when at least one action is available", () => {
render(<TaskChatComposer onAdd={vi.fn()} workMode="standard" />);
expect(
container.querySelector('[data-testid="task-chat-composer-add"]'),
).toBeNull();
renderComposer({
onAdd: vi.fn(),
workMode: "standard",
onAttachImage: vi.fn().mockResolvedValue(undefined),
});
expect(
container.querySelector('[data-testid="task-chat-composer-add"]'),
).not.toBeNull();
});
it("opens the file picker from the add menu", () => {
render(<TaskChatComposer onAdd={vi.fn()} workMode="standard"
onAttachImage={vi.fn().mockResolvedValue(undefined)} />);
const input = container.querySelector<HTMLInputElement>('input[type="file"]')!;
const openPicker = vi.spyOn(input, "click").mockImplementation(() => {});
openComposerAddMenu();
flushSync(() => document.querySelector<HTMLElement>('[data-testid="composer-add-file"]')!.click());
expect(openPicker).toHaveBeenCalledOnce();
});
it("wires the editor's inline image upload to onAttachImage and returns the attachment URL", async () => {
const onAttachImage = vi.fn().mockResolvedValue({
contentPath: "/attachments/shot.png",
originalFilename: "shot.png",
});
renderComposer({
onAdd: vi.fn(),
workMode: "standard",
onAttachImage: onAttachImage,
});
const handler = mdxEditorMockState.imagePluginOptions?.imageUploadHandler;
expect(handler).toBeTypeOf("function");
const file = new File(["png-bytes"], "shot.png", { type: "image/png" });
await expect(handler!(file)).resolves.toBe("/attachments/shot.png");
expect(onAttachImage).toHaveBeenCalledWith(file);
// Inline images do not go through the attachment chip row.
expect(
container.querySelector('[data-testid="task-chat-composer-attachments"]'),
).toBeNull();
});
it("does not register the image plugin without an upload handler", () => {
render(<TaskChatComposer onAdd={vi.fn()} workMode="standard" />);
expect(mdxEditorMockState.imagePluginOptions).toBeNull();
});
it("attaches pasted non-image files to the chip row and posts a link reference", async () => {
const onAdd = vi.fn().mockResolvedValue(undefined);
const onAttachImage = vi.fn().mockResolvedValue({
contentPath: "/attachments/notes.txt",
originalFilename: "notes.txt",
});
renderComposer({
onAdd: onAdd,
workMode: "standard",
onAttachImage: onAttachImage,
});
const file = new File(["plain"], "notes.txt", { type: "text/plain" });
const paste = pasteFiles([file]);
await flushAsync();
// The all-non-image paste is swallowed before the editor sees it.
expect(paste.defaultPrevented).toBe(true);
expect(onAttachImage).toHaveBeenCalledWith(file);
const chips = container.querySelector(
'[data-testid="task-chat-composer-attachments"]',
);
expect(chips?.textContent).toContain("notes.txt");
// base/attachment chip: kind · size description and a settled state.
expect(chips?.textContent).toContain("Text · 5 B");
expect(
chips
?.querySelector('[data-slot="attachment"]')
?.getAttribute("data-state"),
).toBe("done");
// The editor stays prose-only; the reference rides along at submit time,
// and an attached chip alone is enough to enable send.
expect(editable().textContent ?? "").not.toContain("notes.txt");
const send = container.querySelector<HTMLButtonElement>(
'[data-testid="task-chat-composer-send"]',
)!;
expect(send.disabled).toBe(false);
flushSync(() => send.click());
await flushAsync();
expect(onAdd).toHaveBeenCalledWith("[notes.txt](/attachments/notes.txt)", undefined, undefined, undefined, expect.any(String));
// Chips clear once the message posts.
expect(
container.querySelector('[data-testid="task-chat-composer-attachments"]'),
).toBeNull();
});
it("appends file references after typed prose on submit", async () => {
const onAdd = vi.fn().mockResolvedValue(undefined);
const onAttachImage = vi.fn().mockResolvedValue({
contentPath: "/attachments/notes.txt",
originalFilename: "notes.txt",
});
renderComposer({
onAdd: onAdd,
workMode: "standard",
onAttachImage: onAttachImage,
});
typeText("Please review this.");
pasteFiles([new File(["plain"], "notes.txt", { type: "text/plain" })]);
await flushAsync();
const send = container.querySelector<HTMLButtonElement>(
'[data-testid="task-chat-composer-send"]',
)!;
flushSync(() => send.click());
await flushAsync();
expect(onAdd).toHaveBeenCalledWith("Please review this.\n\n[notes.txt](/attachments/notes.txt)", undefined, undefined, undefined, expect.any(String));
});
it("blocks send while a file upload is pending, then includes the file once it lands", async () => {
const onAdd = vi.fn().mockResolvedValue(undefined);
let resolveUpload!: (value: {
contentPath: string;
originalFilename: string;
}) => void;
const onAttachImage = vi.fn().mockReturnValue(
new Promise<{ contentPath: string; originalFilename: string }>(
(resolve) => {
resolveUpload = resolve;
},
),
);
renderComposer({
onAdd: onAdd,
workMode: "standard",
onAttachImage: onAttachImage,
});
typeText("Here is the file.");
pasteFiles([new File(["plain"], "notes.txt", { type: "text/plain" })]);
await flushAsync();
// Text alone would enable send, but the in-flight upload must hold it —
// otherwise the comment posts without the file the user selected.
expect(sendButton().disabled).toBe(true);
pressKey("Enter", { metaKey: true });
await flushAsync();
expect(onAdd).not.toHaveBeenCalled();
resolveUpload({
contentPath: "/attachments/notes.txt",
originalFilename: "notes.txt",
});
await flushAsync();
expect(sendButton().disabled).toBe(false);
flushSync(() => sendButton().click());
await flushAsync();
expect(onAdd).toHaveBeenCalledWith("Here is the file.\n\n[notes.txt](/attachments/notes.txt)", undefined, undefined, undefined, expect.any(String));
});
it("blocks send while a failed attachment chip remains, then sends after it is removed", async () => {
const onAdd = vi.fn().mockResolvedValue(undefined);
const onAttachImage = vi.fn().mockRejectedValue(new Error("Too large"));
renderComposer({
onAdd: onAdd,
workMode: "standard",
onAttachImage: onAttachImage,
});
typeText("Here is the file.");
pasteFiles([new File(["plain"], "notes.txt", { type: "text/plain" })]);
await flushAsync();
// Text alone would enable send, but the failed chip must hold it —
// otherwise the comment posts without the file and the error chip clears.
expect(sendButton().disabled).toBe(true);
pressKey("Enter", { metaKey: true });
await flushAsync();
expect(onAdd).not.toHaveBeenCalled();
const remove = container.querySelector<HTMLButtonElement>(
'button[aria-label="Remove notes.txt"]',
);
flushSync(() => remove!.click());
expect(sendButton().disabled).toBe(false);
flushSync(() => sendButton().click());
await flushAsync();
expect(onAdd).toHaveBeenCalledWith("Here is the file.", undefined, undefined, undefined, expect.any(String));
});
it("removes an attachment chip via its remove button", async () => {
const onAttachImage = vi.fn().mockResolvedValue({
contentPath: "/attachments/notes.txt",
originalFilename: "notes.txt",
});
renderComposer({
onAdd: vi.fn(),
workMode: "standard",
onAttachImage: onAttachImage,
});
pasteFiles([new File(["plain"], "notes.txt", { type: "text/plain" })]);
await flushAsync();
const remove = container.querySelector<HTMLButtonElement>(
'button[aria-label="Remove notes.txt"]',
);
expect(remove).not.toBeNull();
flushSync(() => remove!.click());
expect(
container.querySelector('[data-testid="task-chat-composer-attachments"]'),
).toBeNull();
});
it("shows an error-state chip when a non-image upload fails", async () => {
const onAttachImage = vi.fn().mockRejectedValue(new Error("Too large"));
renderComposer({
onAdd: vi.fn(),
workMode: "standard",
onAttachImage: onAttachImage,
});
pasteFiles([new File(["plain"], "notes.txt", { type: "text/plain" })]);
await flushAsync();
const chips = container.querySelector(
'[data-testid="task-chat-composer-attachments"]',
);
expect(
chips
?.querySelector('[data-slot="attachment"]')
?.getAttribute("data-state"),
).toBe("error");
expect(chips?.textContent).toContain("Too large");
});
it("leaves pasted images to the editor's image plugin (no chip, paste not swallowed)", async () => {
const onAttachImage = vi.fn().mockResolvedValue({
contentPath: "/attachments/notes.txt",
originalFilename: "notes.txt",
});
renderComposer({
onAdd: vi.fn(),
workMode: "standard",
onAttachImage: onAttachImage,
});
const image = new File(["png-bytes"], "shot.png", { type: "image/png" });
const text = new File(["plain"], "notes.txt", { type: "text/plain" });
const paste = pasteFiles([image, text]);
await flushAsync();
// Mixed paste: the non-image is chipped, the image flows through untouched.
expect(paste.defaultPrevented).toBe(false);
const chips = container.querySelector(
'[data-testid="task-chat-composer-attachments"]',
)!;
expect(chips.textContent).toContain("notes.txt");
expect(chips.textContent).not.toContain("shot.png");
expect(onAttachImage).toHaveBeenCalledTimes(1);
expect(onAttachImage).toHaveBeenCalledWith(text);
});
it("inserts an @-mention from the autocomplete menu and posts it", async () => {
const onAdd = vi.fn().mockResolvedValue(undefined);
renderComposer({
onAdd: onAdd,
workMode: "standard",
mentions: [
{ id: "agent:a1", kind: "agent", name: "Clippy", agentId: "a1" },
],
});
typeText("@Cl");
await placeCaretAtEnd();
const option = autocompleteOption("Clippy");
flushSync(() => {
option.dispatchEvent(
new MouseEvent("mousedown", { bubbles: true, cancelable: true }),
);
});
await flushAsync();
const expected = `[@Clippy](${buildAgentMentionHref("a1", null)}) `;
expect(editable().textContent).toBe(expected);
pressKey("Enter", { metaKey: true });
await flushAsync();
expect(onAdd).toHaveBeenCalledWith(expected.trim(), undefined, undefined, undefined, expect.any(String));
});
it("inserts a /-command from the autocomplete menu", async () => {
const onAdd = vi.fn().mockResolvedValue(undefined);
render(<TaskChatComposer onAdd={onAdd} workMode="standard" />);
typeText("/dep");
await placeCaretAtEnd();
const option = autocompleteOption("/deploy");
flushSync(() => {
option.dispatchEvent(
new MouseEvent("mousedown", { bubbles: true, cancelable: true }),
);
});
await flushAsync();
expect(editable().textContent).toBe(`[/deploy](${SLASH_HREF}) `);
});
describe("/goal action commands", () => {
const capability = {
availability: "available" as const,
verified: true,
actions: ["set", "pause", "resume", "clear"] as Array<
"set" | "pause" | "resume" | "clear"
>,
autonomousUpdates: true,
persistentAcrossResume: true,
maxObjectiveChars: 4_000,
tokenBudgetControl: true,
usageReporting: true,
};
it("matches only an exact first /goal token", () => {
expect(parseRunnerGoalCommand(" /goal Ship the feature ")).toEqual({
matched: true,
command: { action: "create", objective: "Ship the feature" },
});
expect(
parseRunnerGoalCommand(
"[/goal\u00a0](</goal Ship the feature across turns.>)",
),
).toEqual({
matched: true,
command: {
action: "create",
objective: "Ship the feature across turns.",
},
});
expect(
parseRunnerGoalCommand(
"[/go](</goal Return one concise confirmation, then complete.>)",
),
).toEqual({
matched: true,
command: {
action: "create",
objective: "Return one concise confirmation, then complete.",
},
});
expect(
parseRunnerGoalCommand("[/](/goal%20Confirm%20the%20goal%20state.)"),
).toEqual({
matched: true,
command: {
action: "create",
objective: "Confirm the goal state.",
},
});
expect(parseRunnerGoalCommand("[/goal](/goal%20pause)")).toEqual({
matched: true,
command: { action: "pause" },
});
expect(parseRunnerGoalCommand("/goal pause extra")).toEqual({
matched: true,
error: "/goal pause does not accept extra arguments.",
});
expect(parseRunnerGoalCommand("[goal](/goal Ship it)")).toEqual({
matched: false,
});
expect(parseRunnerGoalCommand("\\/goal ordinary text")).toEqual({
matched: false,
});
expect(parseRunnerGoalCommand("/goalkeeper ordinary text")).toEqual({
matched: false,
});
});
it("dispatches a goal action without posting a comment", async () => {
const onAdd = vi.fn().mockResolvedValue(undefined);
const onRunnerGoalCommand = vi.fn().mockResolvedValue(undefined);
renderComposer({
onAdd: onAdd,
workMode: "standard",
runnerGoalCapability: capability,
onRunnerGoalCommand: onRunnerGoalCommand,
});
typeText("/goal Ship the feature");
pressKey("Enter", { metaKey: true });
await flushAsync();
expect(onRunnerGoalCommand).toHaveBeenCalledWith({
action: "create",
objective: "Ship the feature",
});
expect(onAdd).not.toHaveBeenCalled();
expect(editable().textContent).toBe("");
});
it("shows Goal in the add menu only for a supported agent and prepares the command", async () => {
const onRunnerGoalCommand = vi.fn();
render(<TaskChatComposer onAdd={vi.fn()} workMode="standard"
onWorkModeChange={vi.fn()} runnerGoalCapability={capability} onRunnerGoalCommand={onRunnerGoalCommand} />);
typeText("Ship the feature");
openComposerAddMenu();
const goal = document.querySelector<HTMLElement>('[data-testid="composer-add-goal"]');
expect(goal).not.toBeNull();
flushSync(() => goal!.click());
await flushAsync();
expect(editable().textContent).toBe("/goal Ship the feature");
expect(onRunnerGoalCommand).not.toHaveBeenCalled();
render(<TaskChatComposer onAdd={vi.fn()} workMode="standard"
onWorkModeChange={vi.fn()}
runnerGoalCapability={{ ...capability, availability: "unsupported" }}
onRunnerGoalCommand={onRunnerGoalCommand} />);
openComposerAddMenu();
expect(document.querySelector('[data-testid="composer-add-goal"]')).toBeNull();
});
it("commits a pending agent reassignment before starting the goal", async () => {
const order: string[] = [];
const onAdd = vi.fn().mockResolvedValue(undefined);
const onPendingAssigneeChange = vi.fn();
const onRunnerGoalReassign = vi.fn().mockImplementation(async () => {
order.push("reassign");
});
const onRunnerGoalCommand = vi.fn().mockImplementation(async () => {
order.push("goal");
});
renderComposer({
onAdd: onAdd,
workMode: "standard",
enableReassign: true,
reassignOptions: [{ id: "agent:a1", label: "Clippy" }],
currentAssigneeValue: "",
onPendingAssigneeChange: onPendingAssigneeChange,
runnerGoalCapability: capability,
onRunnerGoalReassign: onRunnerGoalReassign,
onRunnerGoalCommand: onRunnerGoalCommand,
});
const trigger = container.querySelector<HTMLButtonElement>(
'[data-testid="task-chat-composer-assignee"]',
)!;
flushSync(() => trigger.click());
await flushAsync();
const option = [
...document.body.querySelectorAll<HTMLButtonElement>("button"),
].find((button) => button.textContent?.trim() === "Clippy");
expect(option).toBeDefined();
flushSync(() => option!.click());
await flushAsync();
openComposerAddMenu();
expect(document.querySelector('[data-testid="composer-add-goal"]')).not.toBeNull();
flushSync(() => document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true })));
typeText("/goal Ship the feature");
pressKey("Enter", { metaKey: true });
await flushAsync();
expect(onPendingAssigneeChange).toHaveBeenCalledWith("agent:a1");
expect(onRunnerGoalReassign).toHaveBeenCalledWith({
assigneeAgentId: "a1",
assigneeUserId: null,
});
expect(onRunnerGoalCommand).toHaveBeenCalledWith({
action: "create",
objective: "Ship the feature",
});
expect(order).toEqual(["reassign", "goal"]);
expect(onAdd).not.toHaveBeenCalled();
});
it("preserves unsupported goal text and shows the provider reason", async () => {
const onAdd = vi.fn().mockResolvedValue(undefined);
renderComposer({
onAdd: onAdd,
workMode: "standard",
runnerGoalCapability: {
...capability,
availability: "unsupported",
actions: [],
reason: "Unsupported by OpenCode.",
},
onRunnerGoalCommand: vi.fn(),
});
typeText("/goal Ship the feature");
pressKey("Enter", { metaKey: true });
await flushAsync();
expect(onAdd).not.toHaveBeenCalled();
expect(editable().textContent).toBe("/goal Ship the feature");
expect(container.querySelector('[role="alert"]')?.textContent).toContain(
"Unsupported by OpenCode",
);
});
});
it("shows the assignee combobox only when reassign is enabled, with the current label", () => {
render(<TaskChatComposer onAdd={vi.fn()} workMode="standard" />);
expect(
container.querySelector('[data-testid="task-chat-composer-assignee"]'),
).toBeNull();
renderComposer({
onAdd: vi.fn(),
workMode: "standard",
enableReassign: true,
reassignOptions: [
{ id: "agent:a1", label: "Clippy" },
{ id: "user:u1", label: "Sam" },
],
currentAssigneeValue: "user:u1",
});
const trigger = container.querySelector(
'[data-testid="task-chat-composer-assignee"]',
);
expect(trigger?.textContent).toContain("Sam");
});
it("shows configured agent icons in the assignee trigger and dropdown options", async () => {
renderComposer({
onAdd: vi.fn(),
workMode: "standard",
enableReassign: true,
reassignOptions: [
{ id: "agent:a1", label: "Clippy" },
{ id: "agent:a2", label: "Plain Agent" },
],
agentMap: new Map([
["a1", { icon: "rocket" }],
["a2", { icon: null }],
]),
currentAssigneeValue: "agent:a1",
});
const trigger = container.querySelector<HTMLButtonElement>(
'[data-testid="task-chat-composer-assignee"]',
)!;
expect(
trigger.querySelector('[data-assignee-trigger-icon="rocket"]'),
).not.toBeNull();
flushSync(() => {
trigger.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flushAsync();
expect(
document.querySelector('[data-assignee-option-icon="agent:a1"]'),
).not.toBeNull();
expect(
document.querySelector('[data-assignee-option-icon="agent:a2"]'),
).not.toBeNull();
expect(
document
.querySelector('[data-assignee-identity="agent:a2"]')
?.getAttribute("data-assignee-option-icon"),
).toBe("agent:a2");
});
it("uses assignment suggestions and retains a manual human choice after refresh", async () => {
const view = (suggestion: string) => <TaskChatComposer onAdd={vi.fn()} workMode="standard"
enableReassign currentAssigneeValue="user:u1" suggestedAssigneeValue={suggestion}
reassignOptions={[{ id: "agent:a1", label: "Alpha" }, { id: "agent:a2", label: "Beta" }, { id: "user:u1", label: "Me" }]}
agentMap={new Map([["a1", { icon: "bot" }], ["a2", { icon: "bot" }]])} />;
render(view("agent:a1"));
const trigger = () => container.querySelector<HTMLButtonElement>('[data-testid="task-chat-composer-assignee"]')!;
expect(trigger().textContent).toContain("Alpha");
flushSync(() => root!.render(view("agent:a2")));
expect(trigger().textContent).toContain("Beta");
flushSync(() => trigger().click());
await flushAsync();
const me = document.querySelector('[data-assignee-identity="user:u1"]')!.closest("button")!;
flushSync(() => me.click());
await flushAsync();
flushSync(() => root!.render(view("agent:a1")));
expect(trigger().textContent).toContain("Me");
});
it("shows human avatars in assignee options and after selection", async () => {
renderComposer({
onAdd: vi.fn(),
workMode: "standard",
enableReassign: true,
reassignOptions: [
{ id: "agent:a1", label: "Clippy" },
{ id: "user:u1", label: "Sam Rivera" },
],
agentMap: new Map([["a1", { icon: "rocket" }]]),
userProfileMap: new Map([["u1", { label: "Sam Rivera", image: "/sam-avatar.png" }]]),
currentAssigneeValue: "agent:a1",
});
const trigger = container.querySelector<HTMLButtonElement>(
'[data-testid="task-chat-composer-assignee"]',
)!;
flushSync(() => {
trigger.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flushAsync();
const userOptionAvatar = document.querySelector(
'[data-assignee-option-avatar="user:u1"]',
);
expect(userOptionAvatar).not.toBeNull();
const userOption = userOptionAvatar?.closest("button");
flushSync(() => {
userOption?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flushAsync();
expect(trigger.textContent).toContain("Sam Rivera");
expect(
trigger.querySelector('[data-assignee-trigger-avatar="u1"]'),
).not.toBeNull();
});
describe("draft persistence", () => {
const draftKey = "task-chat-draft:issue-1";
it("restores a saved draft on mount", () => {
localStorage.setItem(draftKey, "unsent draft");
renderComposer({
onAdd: vi.fn(),
workMode: "standard",
draftKey: draftKey,
});
expect(editable().textContent).toBe("unsent draft");
expect(sendButton().disabled).toBe(false);
});
it("preserves a restored draft through the StrictMode effect probe", () => {
localStorage.setItem(draftKey, "still here");
render(
<StrictMode>
<TaskChatComposer
onAdd={vi.fn()}
workMode="standard"
draftKey={draftKey}
/>
</StrictMode>,
);
expect(editable().textContent).toBe("still here");
expect(localStorage.getItem(draftKey)).toBe("still here");
});
it("saves after the debounce and flushes a pending value on unmount", () => {
vi.useFakeTimers();
try {
renderComposer({
onAdd: vi.fn(),
workMode: "standard",
draftKey: draftKey,
});
typeText("work in progress");
expect(localStorage.getItem(draftKey)).toBeNull();
vi.advanceTimersByTime(DRAFT_DEBOUNCE_MS);
expect(localStorage.getItem(draftKey)).toBe("work in progress");
typeText("save before leaving");
flushSync(() => root?.unmount());
root = null;
expect(localStorage.getItem(draftKey)).toBe("save before leaving");
} finally {
vi.useRealTimers();
}
});
it("flushes a pending value before page unload", () => {
vi.useFakeTimers();
try {
renderComposer({
onAdd: vi.fn(),
workMode: "standard",
draftKey: draftKey,
});
typeText("save before reload");
window.dispatchEvent(new Event("beforeunload"));
expect(localStorage.getItem(draftKey)).toBe("save before reload");
} finally {
vi.useRealTimers();
}
});
it("clears the visible composer but retains an uncertain reload draft while sending", async () => {
localStorage.setItem(draftKey, "queued message");
const onAdd = vi.fn().mockReturnValue(new Promise<void>(() => {}));
renderComposer({
onAdd: onAdd,
workMode: "standard",
draftKey: draftKey,
});
pressKey("Enter", { metaKey: true });
await flushAsync();
expect(onAdd).toHaveBeenCalledWith("queued message", undefined, undefined, undefined, expect.any(String));
expect(editable().textContent).toBe("");
expect(localStorage.getItem(draftKey)).toBe("queued message");
expect(localStorage.getItem(`${draftKey}:submission:v1`)).toContain(
'"reviewed":false',
);
});
it("keeps text entered while an earlier send is pending", async () => {
let resolveSend!: () => void;
const onAdd = vi.fn().mockReturnValue(
new Promise<void>((resolve) => {
resolveSend = resolve;
}),
);
renderComposer({
onAdd: onAdd,
workMode: "standard",
draftKey: draftKey,
});
typeText("first message");
pressKey("Enter", { metaKey: true });
await flushAsync();
typeText("next message");
resolveSend();
await flushAsync();
await flushAsync();
expect(onAdd).toHaveBeenCalledWith("first message", undefined, undefined, undefined, expect.any(String));
expect(editable().textContent).toBe("next message");
expect(localStorage.getItem(draftKey)).toBe("next message");
});
it("keeps an attachment added while an earlier send is pending", async () => {
let resolveSend!: () => void;
const onAdd = vi.fn().mockReturnValue(
new Promise<void>((resolve) => {
resolveSend = resolve;
}),
);
const onAttachImage = vi.fn().mockResolvedValue({
contentPath: "/attachments/next.txt",
originalFilename: "next.txt",
});
renderComposer({
onAdd: onAdd,
workMode: "standard",
draftKey: draftKey,
onAttachImage: onAttachImage,
});
typeText("first message");
pressKey("Enter", { metaKey: true });
await flushAsync();
pasteFiles([new File(["next"], "next.txt", { type: "text/plain" })]);
await flushAsync();
resolveSend();
await flushAsync();
await flushAsync();
expect(onAdd).toHaveBeenCalledWith("first message", undefined, undefined, undefined, expect.any(String));
expect(
container.querySelector(
'[data-testid="task-chat-composer-attachments"]',
)?.textContent,
).toContain("next.txt");
});
it("restores a failed send before text entered while it was pending", async () => {
vi.useFakeTimers();
try {
let rejectSend!: (error: Error) => void;
const onAdd = vi.fn().mockReturnValue(
new Promise<void>((_resolve, reject) => {
rejectSend = reject;
}),
);
renderComposer({
onAdd: onAdd,
workMode: "standard",
draftKey: draftKey,
});
typeText("do not lose this");
vi.advanceTimersByTime(DRAFT_DEBOUNCE_MS);
vi.useRealTimers();
pressKey("Enter", { metaKey: true });
await flushAsync();
expect(editable().textContent).toBe("");
expect(localStorage.getItem(draftKey)).toBe("do not lose this");
typeText("next draft");
rejectSend(new Error("network down"));
await flushAsync();
await flushAsync();
expect(editable().textContent).toBe("do not lose this\n\nnext draft");
expect(localStorage.getItem(draftKey)).toBe(
"do not lose this\n\nnext draft",
);
} finally {
vi.useRealTimers();
}
});
});
it("gives separate identical chat submissions separate receipt identities", async () => {
const onAdd = vi.fn().mockResolvedValue(undefined);
render(<TaskChatComposer onAdd={onAdd} conversationMode workMode="standard" />);
typeText("Same message");
await act(async () => sendButton().click());
typeText("Same message");
await act(async () => sendButton().click());
expect(onAdd.mock.calls).toHaveLength(2);
expect(onAdd.mock.calls[0][4]).toEqual(expect.any(String));
expect(onAdd.mock.calls[1][4]).not.toBe(onAdd.mock.calls[0][4]);
});
it("shows model controls only in Coordinator chat and submits the choice without reassignment", async () => {
vi.stubGlobal("ResizeObserver", class { observe() {} unobserve() {} disconnect() {} });
const catalog = vi.spyOn(agentsApi, "adapterModels").mockResolvedValue([{ id: "gpt-6-astra", label: "GPT-6 Astra" }]);
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
const agent = { id: "chat-agent", name: "Mira", adapterType: "codex_local", adapterConfig: { model: "gpt-6-sol" } } as unknown as Agent;
const agents = new Map([[agent.id, agent]]);
const onAdd = vi.fn().mockResolvedValue(undefined);
const paint = (coordinator: boolean) => render(<QueryClientProvider client={client}><CoordinatorPresentation.Provider value={coordinator}>
<TaskChatComposer onAdd={onAdd} conversationMode companyId="chat-company" workMode="standard"
currentAssigneeValue="agent:chat-agent" suggestedAssigneeValue="agent:someone-else" modelAgents={agents}
reassignOptions={[{ id: "agent:chat-agent", label: "Mira" }]} />
</CoordinatorPresentation.Provider></QueryClientProvider>);
try {
paint(false);
expect(container.querySelector('[aria-label="Select model and effort"]')).toBeNull();
paint(true);
await flushAsync();
expect(container.querySelector('[aria-label="Select assignee"]')).toBeNull();
await act(async () => container.querySelector<HTMLButtonElement>('[aria-label="Select model and effort"]')!.click());
await act(async () => document.querySelector<HTMLButtonElement>('[aria-label="Choose exact model"]')!.click());
const option = [...document.querySelectorAll<HTMLButtonElement>('button[role="option"]')].find(item => item.textContent?.startsWith("GPT-6 Astra"))!;
await act(async () => option.click());
const range = document.querySelector<HTMLInputElement>('input[aria-label="Effort"]')!;
await act(async () => {
Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")!.set!.call(range, "3");
range.dispatchEvent(new Event("change", { bubbles: true }));
range.dispatchEvent(new Event("input", { bubbles: true }));
});
typeText("Please outline the launch.");
await act(async () => sendButton().click());
expect(onAdd).toHaveBeenCalledWith("Please outline the launch.", undefined, undefined, undefined, expect.any(String), { model: "gpt-6-astra", effort: "high", fast: false });
} finally {
render(<></>); client.clear(); catalog.mockRestore(); vi.unstubAllGlobals();
}
});
describe("paused task takeover", () => {
it("allows only standalone /new to resume a paused conversation through the normal composer", async () => {
const onAdd = vi.fn().mockResolvedValue(undefined);
render(<TaskChatComposer onAdd={onAdd} conversationMode workMode="standard" pause={{ scope: "leaf" }} />);
typeText("Keep working");
expect(sendButton().disabled).toBe(true);
await act(async () => sendButton().click());
expect(onAdd).not.toHaveBeenCalled();
typeText("/new");
expect(sendButton().disabled).toBe(false);
await act(async () => sendButton().click());
expect(onAdd).toHaveBeenCalledWith("/new", undefined, undefined, undefined, expect.any(String));
});
it("preserves a typed draft and blocks sending until resume completes", async () => {
const onAdd = vi.fn();
const onResume = vi.fn();
const props = { onAdd, workMode: "standard" as const, draftKey: "paused-draft" };
act(() => root!.render(<TaskChatComposer {...props} />));
typeText("Please check mobile too.");
act(() => root!.render(<TaskChatComposer {...props} pause={{ scope: "leaf", onResume }} />));
expect(container.querySelector('[contenteditable="true"]')).toBeNull();
expect(container.querySelector('button[aria-label="Send"]')).toBeNull();
expect(container.textContent).toContain("Your draft is saved.");
act(() => container.querySelector("button")!.click());
expect(onResume).toHaveBeenCalledOnce();
expect(onAdd).not.toHaveBeenCalled();
act(() => root!.render(<TaskChatComposer {...props} pause={{ scope: "leaf", pending: true, onResume }} />));
expect(container.querySelector("button")!.disabled).toBe(true);
act(() => root!.render(<TaskChatComposer {...props} />));
expect(editable().textContent).toBe("Please check mobile too.");
await act(async () => sendButton().click());
expect(onAdd).toHaveBeenCalledWith("Please check mobile too.", undefined, undefined, undefined, expect.any(String));
});
it("takes precedence over pending questions and queued-message edits", () => {
const onSkip = vi.fn();
act(() => root!.render(<TaskChatComposer
onAdd={vi.fn()} workMode="standard"
pause={{ scope: "subtree" }}
queuedEdit={{ commentId: "queued", body: "Queued draft" }}
takeover={{ id: "question", label: "Pending input", pendingCount: 1, content: <button>Answer question</button>, onDismiss: vi.fn(), onSkip }}
/>));
expect(container.textContent).toContain("Subtree is paused.");
expect(container.textContent).not.toContain("Answer question");
expect(container.textContent).not.toContain("Skip");
expect(container.querySelector('[contenteditable="true"]')).toBeNull();
expect(container.querySelector("button")!.disabled).toBe(true);
});
});
describe("queued message editing", () => {
const draftKey = "task-chat-draft:queued-edit";
function Harness({
onSave,
stale = false,
}: {
onSave: (commentId: string, body: string) => Promise<void>;
stale?: boolean;
}) {
const [queuedEdit, setQueuedEdit] = useState<{
commentId: string;
body: string;
stale?: boolean;
} | null>({
commentId: "queued-1",
body: "Complete queued markdown",
stale,
});
return (
<TaskChatComposer
onAdd={vi.fn()}
workMode="standard"
draftKey={draftKey}
queuedEdit={queuedEdit}
onSaveQueuedEdit={onSave}
onCancelQueuedEdit={() => setQueuedEdit(null)}
/>
);
}
it("restores the existing composer draft after cancelling an edit", async () => {
localStorage.setItem(draftKey, "Unsent normal draft");
render(<Harness onSave={vi.fn().mockResolvedValue(undefined)} />);
await flushAsync();
await vi.waitFor(() => {
expect(editable().textContent).toBe("Complete queued markdown");
});
expect(localStorage.getItem(draftKey)).toBe("Unsent normal draft");
const cancel = Array.from(container.querySelectorAll("button")).find(
(button) => button.textContent?.trim() === "Cancel",
);
flushSync(() => cancel?.click());
await flushAsync();
expect(editable().textContent).toBe("Unsent normal draft");
expect(localStorage.getItem(draftKey)).toBe("Unsent normal draft");
});
it("saves through the queue callback without posting a new comment", async () => {
localStorage.setItem(draftKey, "Normal draft stays here");
const onSave = vi.fn().mockResolvedValue(undefined);
render(<Harness onSave={onSave} />);
await flushAsync();
typeText("Edited queued markdown");
pressKey("Enter", { metaKey: true });
await flushAsync();
await flushAsync();
expect(onSave).toHaveBeenCalledWith("queued-1", "Edited queued markdown");
expect(editable().textContent).toBe("Normal draft stays here");
});
it("retains edited text when the queue target rejects the save", async () => {
const onSave = vi
.fn()
.mockRejectedValue(new Error("queued_comment_stale_target"));
render(<Harness onSave={onSave} />);
await flushAsync();
typeText("Keep this replacement text");
pressKey("Enter", { metaKey: true });
await flushAsync();
await vi.waitFor(() => {
expect(editable().textContent).toBe("Keep this replacement text");
expect(container.textContent).toContain("Editing queued message");
});
});
it("offers a stale edit as a new queued message and preserves its Markdown source", async () => {
const onSave = vi.fn().mockResolvedValue(undefined);
render(<Harness onSave={onSave} stale />);
await flushAsync();
typeText(" replacement with trailing Markdown ");
expect(container.textContent).toContain("Queued message changed");
expect(sendButton().getAttribute("aria-label")).toBe(
"Queue as new message",
);
pressKey("Enter", { metaKey: true });
await flushAsync();
await flushAsync();
expect(onSave).toHaveBeenCalledWith(
"queued-1",
" replacement with trailing Markdown ",
);
});
});
describe("composer takeovers", () => {
it.each([false, true])("keeps the interaction card visible while sending a normal message (mobile=%s)", async (mobile) => {
const onAdd = vi.fn().mockResolvedValue(undefined);
renderComposer({
onAdd: onAdd,
workMode: "standard",
mobile: mobile,
takeover: {
id: "question-1",
label: "Question",
pendingCount: 1,
content: <p>Which environment?</p>,
onDismiss: vi.fn(),
onSkip: vi.fn(),
},
});
typeText("Continue investigating while I decide.");
expect(container.querySelector('[data-testid="task-chat-composer-takeover"]')?.textContent).toContain("Which environment?");
await act(async () => sendButton().click());
expect(onAdd).toHaveBeenCalledWith("Continue investigating while I decide.", undefined, undefined, undefined, expect.any(String));
expect(container.querySelector('[data-testid="task-chat-composer-takeover"]')?.textContent).toContain("Which environment?");
expect(editable().textContent).toBe("");
});
it("keeps the composer mode shortcut available beneath an open question", () => {
renderComposer({
onAdd: vi.fn(),
workMode: "standard",
onWorkModeChange: vi.fn(),
takeover: {
id: "question-1",
label: "Question",
pendingCount: 1,
content: <p>Which environment?</p>,
onDismiss: vi.fn(),
onSkip: vi.fn(),
},
});
pressKey(".", { metaKey: true });
expect(container.querySelector('[data-testid="task-chat-composer-mode"]')?.textContent).toContain("Plan");
expect(container.querySelector('[data-testid="task-chat-composer-takeover"]')).not.toBeNull();
});
it("shows a separate card above a usable editor and exposes Skip", async () => {
const onSkip = vi.fn().mockResolvedValue(undefined);
renderComposer({
onAdd: vi.fn(),
workMode: "standard",
takeover: {
id: "question-1",
label: "Deployment target",
pendingCount: 2,
content: <p>Which environment should receive this?</p>,
onDismiss: vi.fn(),
onSkip,
onShowNext: vi.fn(),
},
});
expect(
container.querySelector('[data-testid="task-chat-composer-takeover"]')
?.textContent,
).toContain("Which environment should receive this?");
const card = container.querySelector('[data-testid="task-chat-composer-takeover"]')!;
const composer = container.querySelector('.paperclip-task-chat-composer')!;
expect(card.compareDocumentPosition(composer) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
expect(container.querySelector('[data-testid="mdx-editor"]')).not.toBeNull();
expect(container.querySelector<HTMLButtonElement>('[data-testid="task-chat-composer-send"]')).not.toBeNull();
expect(container.textContent).not.toContain("Input needed");
expect(container.textContent).not.toContain("Write instead");
const skip = Array.from(
container.querySelectorAll<HTMLButtonElement>("button"),
).find((button) => button.textContent?.trim() === "Skip");
flushSync(() => skip?.click());
await flushAsync();
expect(onSkip).toHaveBeenCalledTimes(1);
});
it("restores the exact ordinary draft after Skip", async () => {
const draftKey = "task-composer-takeover-draft";
localStorage.setItem(draftKey, "Preserved draft with **markdown**");
function Harness() {
const [open, setOpen] = useState(true);
return (
<TaskChatComposer
onAdd={vi.fn()}
workMode="planning"
draftKey={draftKey}
takeover={
open
? {
id: "plan-review",
label: "Review plan",
pendingCount: 1,
content: <p>Do you accept this plan?</p>,
onDismiss: () => setOpen(false),
onSkip: () => setOpen(false),
}
: null
}
/>
);
}
render(<Harness />);
const skip = Array.from(
container.querySelectorAll<HTMLButtonElement>("button"),
).find((button) => button.textContent?.trim() === "Skip");
flushSync(() => skip?.click());
await flushAsync();
expect(editable().textContent).toBe("Preserved draft with **markdown**");
expect(container.textContent).not.toContain("Write instead");
});
it("keeps title, pending count, pagination, and dismiss on one header row", () => {
renderComposer({
onAdd: vi.fn(),
workMode: "standard",
takeover: {
id: "paged-questions",
label: "Release decisions",
pendingCount: 2,
inlineSkip: true,
content: (
<QuestionForm
id="release-decisions"
questionSet={{
schema: "paperclip.question_set.v1",
questions: [
{
id: "scope",
prompt: "Where should this ship?",
required: false,
answerMode: "single_select",
options: [{ id: "pilot", label: "Pilot" }],
},
{
id: "checks",
prompt: "Which checks matter?",
required: false,
answerMode: "multi_select",
options: [{ id: "a11y", label: "Accessibility" }],
},
],
}}
onSubmit={vi.fn()}
/>
),
onDismiss: vi.fn(),
onSkip: vi.fn(),
onShowNext: vi.fn(),
},
});
const header = container.querySelector(
'[data-testid="task-chat-composer-takeover-header"]',
);
expect(header?.textContent).toContain("Release decisions");
expect(header?.textContent).toContain("2 pending");
expect(header?.textContent).toContain("1 of 2");
expect(
header?.querySelector('button[aria-label="Dismiss Release decisions"]'),
).not.toBeNull();
expect(
header?.querySelector('[aria-label="Question pagination"]'),
).not.toBeNull();
expect(
container.querySelector(
'[data-testid="task-chat-composer-takeover-body"] [aria-label="Question pagination"]',
),
).toBeNull();
});
it("waits for the submit button on a single-select question", async () => {
const onSubmit = vi.fn();
renderComposer({
onAdd: vi.fn(),
workMode: "standard",
takeover: {
id: "opening-question",
label: "Questions",
pendingCount: 1,
inlineSkip: true,
content: (
<QuestionForm
id="first-task-opening"
questionSet={{
schema: "paperclip.question_set.v1",
submitLabel: "Continue",
questions: [
{
id: "first-task-opening",
prompt: "What would you like to do?",
required: true,
answerMode: "single_select",
options: [
{ id: "interview", label: "Interview me" },
{ id: "task", label: "I have a task in mind" },
],
},
],
}}
onSubmit={onSubmit}
/>
),
onDismiss: vi.fn(),
onSkip: vi.fn(),
},
});
const buttons = () =>
Array.from(container.querySelectorAll<HTMLButtonElement>("button"));
const interview = buttons().find((button) =>
button.textContent?.includes("Interview me"),
);
expect(interview).not.toBeUndefined();
// Picking the option only selects it: nothing is sent yet.
flushSync(() => interview?.click());
await flushAsync();
expect(interview?.getAttribute("data-selected")).toBe("true");
expect(onSubmit).not.toHaveBeenCalled();
// A required question cannot be skipped.
expect(
buttons().find((button) => button.textContent?.trim() === "Skip"),
).toBeUndefined();
// The submit button carries the card's label and does the sending.
const submit = buttons().find(
(button) => button.textContent?.trim() === "Continue",
);
expect(submit).not.toBeUndefined();
expect(submit?.disabled).toBe(false);
flushSync(() => submit?.click());
await flushAsync();
expect(onSubmit).toHaveBeenCalledTimes(1);
expect(onSubmit.mock.calls[0]?.[0]).toMatchObject({
answers: { "first-task-opening": { selectedOptionIds: ["interview"] } },
});
});
it("advances on Next, preserves answers when going back, and skips optional answers", async () => {
const onSubmit = vi.fn();
renderComposer({
onAdd: vi.fn(),
workMode: "standard",
takeover: {
id: "interview",
label: "Questions",
pendingCount: 1,
inlineSkip: true,
content: (
<QuestionForm
id="interview"
questionSet={{
schema: "paperclip.question_set.v1",
questions: [
{
id: "env",
prompt: "Where?",
required: true,
answerMode: "single_select",
options: [{ id: "staging", label: "Staging" }],
},
{
id: "when",
prompt: "When?",
required: false,
answerMode: "single_select",
options: [{ id: "today", label: "Today" }],
},
{
id: "who",
prompt: "Who?",
required: false,
answerMode: "single_select",
options: [{ id: "me", label: "Me" }],
},
],
}}
onSubmit={onSubmit}
/>
),
onDismiss: vi.fn(),
onSkip: vi.fn(),
},
});
const buttons = () =>
Array.from(container.querySelectorAll<HTMLButtonElement>("button"));
const byLabel = (label: string) =>
buttons().find((button) => button.textContent?.trim() === label);
// Page 1: required, so no Skip. Next explains a missing answer only
// after an attempt; selecting an answer still does not navigate.
expect(byLabel("Skip")).toBeUndefined();
expect(byLabel("Next")?.disabled).toBe(false);
flushSync(() => byLabel("Next")?.click());
expect(container.textContent).toContain("This question is required.");
expect(container.textContent).toContain("Where?");
flushSync(() => byLabel("Staging")?.click());
await flushAsync();
expect(container.textContent).toContain("Where?");
expect(byLabel("Next")?.disabled).toBe(false);
flushSync(() => byLabel("Next")?.click());
await flushAsync();
expect(onSubmit).not.toHaveBeenCalled();
expect(container.textContent).toContain("When?");
expect(document.activeElement?.textContent).toBe("When?");
// Page 2: answer, then Next. Go back to confirm it is saved, then skip.
flushSync(() => byLabel("Today")?.click());
await flushAsync();
flushSync(() => byLabel("Next")?.click());
await flushAsync();
expect(container.textContent).toContain("Who?");
flushSync(() => container.querySelector<HTMLButtonElement>('button[aria-label="Previous question"]')?.click());
await flushAsync();
expect(byLabel("Today")?.getAttribute("aria-checked")).toBe("true");
flushSync(() => byLabel("Skip")?.click());
await flushAsync();
expect(container.textContent).toContain("Who?");
expect(onSubmit).not.toHaveBeenCalled();
// Last page: primary reads Submit answers and sends everything.
expect(byLabel("Next")).toBeUndefined();
flushSync(() => byLabel("Me")?.click());
await flushAsync();
flushSync(() => byLabel("Submit answers")?.click());
await flushAsync();
expect(onSubmit).toHaveBeenCalledTimes(1);
const response = onSubmit.mock.calls[0]?.[0];
expect(response.answers.env).toEqual({ selectedOptionIds: ["staging"] });
expect(response.answers.when).toBeUndefined();
expect(response.answers.who).toEqual({ selectedOptionIds: ["me"] });
});
it.each(["click", "keyboard"])("records a single choice by %s without leaving the question", async (input) => {
const onSubmit = vi.fn();
render(<QuestionForm
id="selection-modes"
questionSet={{
schema: "paperclip.question_set.v1",
questions: [
{ id: "storage", prompt: "Storage?", required: true, answerMode: "single_select",
options: [{ id: "sqlite", label: "SQLite" }], customAnswer: { enabled: true } },
{ id: "features", prompt: "Features?", required: true, answerMode: "multi_select",
options: [{ id: "auth", label: "Sign in" }, { id: "search", label: "Search" }] },
{ id: "notes", prompt: "Notes?", required: false, answerMode: "text" },
],
}}
initialResponse={{ schema: "paperclip.question_response.v1", answers: { storage: { selectedOptionIds: ["sqlite"] } } }}
onSubmit={onSubmit}
/>);
const byLabel = (label: string) => Array.from(container.querySelectorAll<HTMLButtonElement>("button"))
.find((button) => button.textContent?.trim() === label)!;
// Restoring a selection does not advance. Other needs text first.
expect(container.textContent).toContain("1 of 3");
flushSync(() => byLabel("Other").click());
await flushAsync();
expect(container.textContent).toContain("1 of 3");
expect(container.querySelector('[data-testid="question-other-answer-composer"]')).not.toBeNull();
expect(byLabel("Next").disabled).toBe(false);
expect(container.querySelector('[role="alert"]')).toBeNull();
flushSync(() => {
if (input === "click") byLabel("SQLite").click();
else byLabel("SQLite").dispatchEvent(new KeyboardEvent("keydown", { key: "1", bubbles: true }));
});
await flushAsync();
// Answering re-enables Next and closes Other, but the reader stays here.
expect(container.textContent).toContain("1 of 3");
expect(container.querySelector('[data-testid="question-other-answer-composer"]')).toBeNull();
expect(byLabel("SQLite").getAttribute("aria-checked")).toBe("true");
expect(byLabel("Next").disabled).toBe(false);
flushSync(() => byLabel("Next").click());
await flushAsync();
expect(container.textContent).toContain("2 of 3");
expect(document.activeElement?.textContent).toBe("Features?");
flushSync(() => document.activeElement?.dispatchEvent(new KeyboardEvent("keydown", { key: "1", repeat: true, bubbles: true })));
expect(byLabel("Sign in").getAttribute("aria-checked")).toBe("false");
flushSync(() => byLabel("Sign in").click());
flushSync(() => byLabel("Search").click());
expect(container.textContent).toContain("2 of 3");
expect(byLabel("Sign in").getAttribute("aria-checked")).toBe("true");
expect(byLabel("Search").getAttribute("aria-checked")).toBe("true");
expect(onSubmit).not.toHaveBeenCalled();
flushSync(() => byLabel("Next").click());
await flushAsync();
expect(container.textContent).toContain("3 of 3");
flushSync(() => byLabel("Submit answers").click());
await flushAsync();
expect(onSubmit).toHaveBeenCalledExactlyOnceWith({
schema: "paperclip.question_response.v1",
answers: { storage: { selectedOptionIds: ["sqlite"] }, features: { selectedOptionIds: ["auth", "search"] } },
});
});
describe("single-choice selection stays on the page", () => {
const questionSet = {
schema: "paperclip.question_set.v1" as const,
questions: ["First", "Second", "Third"].map((prompt) => ({
id: prompt, prompt, required: true, answerMode: "single_select" as const,
options: [{ id: "yes", label: "Yes" }, { id: "no", label: "No" }],
customAnswer: { enabled: true as const },
})),
};
const form = () => (
<QuestionForm id="stationary" questionSet={questionSet} onSubmit={vi.fn()} />
);
const click = (selector: string) => act(() => {
flushSync(() => container.querySelector<HTMLButtonElement>(selector)!.click());
});
const buttonByText = (text: string) =>
Array.from(container.querySelectorAll("button")).find(
(button) => button.textContent?.trim() === text,
);
it("records the answer without navigating", () => {
render(form());
click('[role="radio"]');
expect(container.querySelector('[role="radio"]')?.getAttribute("aria-checked")).toBe("true");
expect(container.textContent).toContain("1 of 3");
expect(container.textContent).toContain("First");
});
it("lets the reader change their mind before moving on", () => {
render(form());
click('[role="radio"]');
const radios = () => Array.from(container.querySelectorAll('[role="radio"]'));
act(() => { flushSync(() => (radios()[1] as HTMLButtonElement).click()); });
expect(radios()[0]?.getAttribute("aria-checked")).toBe("false");
expect(radios()[1]?.getAttribute("aria-checked")).toBe("true");
expect(container.textContent).toContain("1 of 3");
});
it("advances only on an explicit Next", () => {
render(form());
click('[role="radio"]');
expect(container.textContent).toContain("1 of 3");
act(() => { flushSync(() => buttonByText("Next")!.click()); });
expect(container.textContent).toContain("2 of 3");
expect(document.activeElement?.textContent).toBe("Second");
});
it("keeps number-key selection on the same question", () => {
render(form());
const page = container.querySelector<HTMLElement>(".tc-question-page")!;
act(() => {
flushSync(() => page.dispatchEvent(
new KeyboardEvent("keydown", { key: "2", bubbles: true }),
));
});
expect(Array.from(container.querySelectorAll('[role="radio"]'))[1]?.getAttribute("aria-checked"))
.toBe("true");
expect(container.textContent).toContain("1 of 3");
});
// Selection now clears the form error, which it did not do on the last
// page before. A failed send has to outlive it: the answers are still
// unsent, so wiping the message would leave no sign of that at all.
it("keeps a failed send visible while the reader changes their answer", async () => {
const onSubmit = vi.fn().mockRejectedValue(new Error("Network unreachable"));
render(
<QuestionForm
id="failed-send"
questionSet={{
schema: "paperclip.question_set.v1" as const,
questions: [{
id: "only", prompt: "Only", required: true,
answerMode: "single_select" as const,
options: [{ id: "yes", label: "Yes" }, { id: "no", label: "No" }],
}],
}}
onSubmit={onSubmit}
/>,
);
const radios = () => Array.from(container.querySelectorAll<HTMLButtonElement>('[role="radio"]'));
act(() => { flushSync(() => radios()[0]!.click()); });
act(() => { flushSync(() => buttonByText("Submit answers")!.click()); });
await flushAsync();
expect(onSubmit).toHaveBeenCalledOnce();
expect(container.textContent).toContain("Network unreachable");
act(() => { flushSync(() => radios()[1]!.click()); });
expect(radios()[1]?.getAttribute("aria-checked")).toBe("true");
expect(container.textContent).toContain("Network unreachable");
});
// The other half of the same rule: the one complaint a selection does
// answer still goes away when it is answered.
it("clears a missing-answer complaint once that question is answered", async () => {
render(
<QuestionForm
id="missing-answer"
questionSet={{
schema: "paperclip.question_set.v1" as const,
questions: [
{
id: "First", prompt: "First", required: true,
answerMode: "single_select" as const,
options: [{ id: "yes", label: "Yes" }, { id: "no", label: "No" }],
},
{
id: "Second", prompt: "Second", required: false,
answerMode: "single_select" as const,
options: [{ id: "yes", label: "Yes" }, { id: "no", label: "No" }],
},
],
}}
onSubmit={vi.fn()}
/>,
);
const radios = () => Array.from(container.querySelectorAll<HTMLButtonElement>('[role="radio"]'));
// The pagination arrow browses without validating, so the reader can
// reach the end with the first question still blank. Skip on the last
// question then sends, which is where the complaint comes from.
click('[aria-label="Next question"]');
act(() => { flushSync(() => buttonByText("Skip")!.click()); });
await flushAsync();
expect(container.textContent).toContain("Question 1 needs an answer");
act(() => { flushSync(() => radios()[0]!.click()); });
expect(container.textContent).not.toContain("Question 1 needs an answer");
});
});
it("Skip on the last question submits the other answers", async () => {
const onSubmit = vi.fn();
renderComposer({
onAdd: vi.fn(),
workMode: "standard",
takeover: {
id: "optional-tail",
label: "Questions",
pendingCount: 1,
inlineSkip: true,
content: (
<QuestionForm
id="optional-tail"
questionSet={{
schema: "paperclip.question_set.v1",
questions: [
{
id: "env",
prompt: "Where?",
required: false,
answerMode: "single_select",
options: [{ id: "staging", label: "Staging" }],
},
{
id: "notes",
prompt: "Anything else?",
required: false,
answerMode: "text",
},
],
}}
onSubmit={onSubmit}
/>
),
onDismiss: vi.fn(),
onSkip: vi.fn(),
},
});
const byLabel = (label: string) =>
Array.from(
container.querySelectorAll<HTMLButtonElement>("button"),
).find((button) => button.textContent?.trim() === label);
flushSync(() => byLabel("Staging")?.click());
await flushAsync();
flushSync(() => byLabel("Next")?.click());
await flushAsync();
expect(container.textContent).toContain("Anything else?");
flushSync(() => byLabel("Skip")?.click());
await flushAsync();
expect(onSubmit).toHaveBeenCalledTimes(1);
expect(onSubmit.mock.calls[0]?.[0]).toMatchObject({
answers: { env: { selectedOptionIds: ["staging"] } },
});
});
it("Skip on the last question returns to a required question the arrows walked past", async () => {
const onSubmit = vi.fn();
renderComposer({
onAdd: vi.fn(),
workMode: "standard",
takeover: {
id: "walked-past",
label: "Questions",
pendingCount: 1,
inlineSkip: true,
content: (
<QuestionForm
id="walked-past"
questionSet={{
schema: "paperclip.question_set.v1",
questions: [
{
id: "env",
prompt: "Where?",
required: true,
answerMode: "single_select",
options: [{ id: "staging", label: "Staging" }],
},
{
id: "notes",
prompt: "Anything else?",
required: false,
answerMode: "text",
},
],
}}
onSubmit={onSubmit}
/>
),
onDismiss: vi.fn(),
onSkip: vi.fn(),
},
});
const byLabel = (label: string) =>
Array.from(
container.querySelectorAll<HTMLButtonElement>("button"),
).find((button) => button.textContent?.trim() === label);
// The pagination arrow browses past the unanswered required question.
const arrow = container.querySelector<HTMLButtonElement>(
'button[aria-label="Next question"]',
);
flushSync(() => arrow?.click());
await flushAsync();
expect(container.textContent).toContain("Anything else?");
// Skip here would send; instead the form goes back and says why.
flushSync(() => byLabel("Skip")?.click());
await flushAsync();
expect(onSubmit).not.toHaveBeenCalled();
expect(container.textContent).toContain("Where?");
expect(container.textContent).toContain(
"Question 1 needs an answer before you can send.",
);
});
it("Cancel closes the takeover and leaves the request pending", async () => {
const onSubmit = vi.fn();
const onDismiss = vi.fn();
const onSkip = vi.fn();
renderComposer({
onAdd: vi.fn(),
workMode: "standard",
takeover: {
id: "cancelable",
label: "Questions",
pendingCount: 1,
inlineSkip: true,
content: (
<QuestionForm
id="cancelable"
questionSet={{
schema: "paperclip.question_set.v1",
questions: [
{
id: "env",
prompt: "Where?",
required: true,
answerMode: "single_select",
options: [{ id: "staging", label: "Staging" }],
},
],
}}
onSubmit={onSubmit}
/>
),
onDismiss,
onSkip,
},
});
const cancel = Array.from(
container.querySelectorAll<HTMLButtonElement>("button"),
).find((button) => button.textContent?.trim() === "Cancel");
expect(cancel).not.toBeUndefined();
flushSync(() => cancel?.click());
await flushAsync();
expect(onDismiss).toHaveBeenCalledTimes(1);
expect(onSkip).not.toHaveBeenCalled();
expect(onSubmit).not.toHaveBeenCalled();
});
it("places Skip beside Submit answers for structured questions", () => {
renderComposer({
onAdd: vi.fn(),
workMode: "standard",
takeover: {
id: "structured-question",
label: "Deployment target",
pendingCount: 1,
inlineSkip: true,
content: (
<QuestionForm
id="deployment-target"
questionSet={{
schema: "paperclip.question_set.v1",
questions: [
{
id: "environment",
prompt: "Which environment should receive this?",
required: false,
answerMode: "multi_select",
options: [
{ id: "staging", label: "Staging", recommended: true },
{ id: "production", label: "Production" },
],
},
],
}}
onSubmit={vi.fn()}
/>
),
onDismiss: vi.fn(),
onSkip: vi.fn(),
},
});
const buttons = Array.from(
container.querySelectorAll<HTMLButtonElement>("button"),
);
const skip = buttons.find(
(button) => button.textContent?.trim() === "Skip",
);
const submit = buttons.find(
(button) => button.textContent?.trim() === "Submit answers",
);
expect(skip).not.toBeUndefined();
expect(submit).not.toBeUndefined();
expect(skip?.parentElement).toBe(submit?.parentElement);
expect(
buttons.filter((button) => button.textContent?.trim() === "Skip"),
).toHaveLength(1);
const takeoverBody = container.querySelector(
'[data-testid="task-chat-composer-takeover-body"]',
);
expect(takeoverBody?.className).not.toContain("max-h-");
expect(takeoverBody?.className).not.toContain("overflow-y-auto");
expect(takeoverBody?.className).not.toContain("pr-8");
const staging = buttons.find((button) =>
button.textContent?.includes("Staging"),
);
const production = buttons.find(
(button) => button.textContent?.trim() === "Production",
);
expect(staging?.getAttribute("data-recommended")).toBe("true");
expect(staging?.className.split(" ")).toContain("bg-muted/50");
expect(
production?.className
.split(" ")
.some(
(token) => token.startsWith("border") || token.startsWith("bg-"),
),
).toBe(false);
flushSync(() => staging?.click());
expect(staging?.getAttribute("data-selected")).toBe("true");
expect(staging?.className.split(" ")).toContain("bg-foreground/5");
});
it("hides Skip when the takeover already provides a request-changes path", () => {
renderComposer({
onAdd: vi.fn(),
workMode: "planning",
takeover: {
id: "plan-review",
label: "Review the proposed plan",
pendingCount: 1,
content: <p>Do you accept this plan?</p>,
onDismiss: vi.fn(),
onSkip: vi.fn(),
hideSkip: true,
},
});
expect(container.textContent).not.toContain("Skip");
expect(
container.querySelector(
'button[aria-label="Dismiss Review the proposed plan"]',
),
).not.toBeNull();
expect(
container.querySelector(
'[data-testid="task-chat-composer-takeover"] .border-t',
),
).toBeNull();
});
it("dismisses every takeover without invoking Skip", async () => {
const onDismiss = vi.fn();
const onSkip = vi.fn();
renderComposer({
onAdd: vi.fn(),
workMode: "standard",
takeover: {
id: "dismissable-question",
label: "Release questions",
pendingCount: 1,
content: <p>Which release should ship?</p>,
onDismiss,
onSkip,
},
});
flushSync(() =>
container
.querySelector<HTMLButtonElement>(
'button[aria-label="Dismiss Release questions"]',
)
?.click(),
);
await flushAsync();
expect(onDismiss).toHaveBeenCalledTimes(1);
expect(onSkip).not.toHaveBeenCalled();
});
});
});
describe("composer Stop", () => {
function stopButton() { return container.querySelector<HTMLButtonElement>('[data-testid="task-chat-composer-stop"]'); }
it("switches Stop to Send with text, whitespace back to Stop, without interrupting on keyboard submit", async () => {
const onStop = vi.fn(async () => {});
const onAdd = vi.fn(async () => {});
render(<TaskChatComposer workMode="standard" onAdd={onAdd} onStop={onStop} stopScope="subtree" />);
expect(stopButton()?.title).toBe("Stop response");
pressKey("Enter", { metaKey: true });
expect(onStop).not.toHaveBeenCalled();
expect(onAdd).not.toHaveBeenCalled();
typeText("Check mobile too.");
expect(stopButton()).toBeNull();
expect(sendButton().disabled).toBe(false);
flushSync(() => sendButton().click());
await flushAsync();
expect(onAdd).toHaveBeenCalledWith("Check mobile too.", undefined, undefined, undefined, expect.any(String));
expect(onStop).not.toHaveBeenCalled();
typeText(" \n ");
expect(stopButton()?.disabled).toBe(false);
});
it("blocks duplicate stops and preserves text typed while stopping", async () => {
let resolve!: () => void;
const onStop = vi.fn(() => new Promise<void>((done) => { resolve = done; }));
render(<TaskChatComposer workMode="standard" onAdd={vi.fn()} onStop={onStop} />);
const stop = stopButton()!;
flushSync(() => { stop.click(); stop.click(); });
expect(onStop).toHaveBeenCalledTimes(1);
expect(stopButton()?.disabled).toBe(true);
expect(stopButton()?.getAttribute("aria-label")).toBe("Stopping…");
typeText("Keep this draft.");
expect(sendButton().disabled).toBe(false);
resolve();
await flushAsync();
expect(editable().textContent).toBe("Keep this draft.");
});
it("reports failure without discarding the draft and permits retry", async () => {
const onStop = vi.fn().mockRejectedValueOnce(new Error("Unable to stop. Try again.")).mockResolvedValue(undefined);
render(<TaskChatComposer workMode="standard" onAdd={vi.fn()} onStop={onStop} />);
flushSync(() => stopButton()!.click());
await flushAsync();
expect(container.querySelector('[role="alert"]')?.textContent).toBe("Unable to stop. Try again.");
flushSync(() => stopButton()!.click());
await flushAsync();
expect(onStop).toHaveBeenCalledTimes(2);
expect(container.querySelector('[role="alert"]')).toBeNull();
});
it("retains disabled Send when idle or when stop permission is absent", () => {
render(<TaskChatComposer workMode="standard" onAdd={vi.fn()} />);
expect(stopButton()).toBeNull();
expect(sendButton().disabled).toBe(true);
render(<TaskChatComposer workMode="standard" onAdd={vi.fn()} onStop={vi.fn()} disabled />);
expect(stopButton()?.disabled).toBe(true);
});
it("never turns a queued edit's save action into Stop", () => {
render(<TaskChatComposer workMode="standard" onAdd={vi.fn()} onStop={vi.fn()} queuedEdit={{ commentId: "queued", body: "" }} onSaveQueuedEdit={vi.fn()} />);
expect(stopButton()).toBeNull();
expect(sendButton().getAttribute("aria-label")).toBe("Save queued message");
});
it.each([false, true])("keeps attachments in send mode after upload (failed=%s)", async (failed) => {
let resolve!: (value: never) => void;
let reject!: (error: Error) => void;
const onAttachImage = vi.fn(() => new Promise<never>((done, fail) => { resolve = done; reject = fail; }));
render(<TaskChatComposer workMode="standard" onAdd={vi.fn()} onStop={vi.fn()} onAttachImage={onAttachImage} />);
pasteFiles([new File(["notes"], "notes.txt", { type: "text/plain" })]);
await flushAsync();
expect(stopButton()).toBeNull();
expect(sendButton().disabled).toBe(true);
if (failed) reject(new Error("Upload failed"));
else resolve({ id: "attachment", contentPath: "/notes.txt", originalFilename: "notes.txt" } as never);
await flushAsync();
expect(stopButton()).toBeNull();
expect(sendButton().disabled).toBe(failed);
});
});