3 Commits

Author SHA1 Message Date
Naiyuan Qing
6c9a59cc14 refactor(uploads): widen handleUpload contract, fix interrupted doc drift (#6035)
Follow-up to #6025 (MUL-5391), addressing the two non-blocking cleanups
raised in review.

1. `CoordinatedUploads.handleUpload` was still typed as a one-arg
   function while the real `ContentEditor` contract is `(file, uploadId)`.
   Runtime was already safe (the implementation accepts `uploadId?`), but
   the exported interface erased the second parameter at the boundary, so
   a mock or hand-rolled caller could silently drop the editor-minted id
   and mint a second one — breaking the one-id link between the document
   node and the draft record. Widened the type and documented why the id
   must be threaded through.

2. #6025 changed `normalizeStoredUploads` to DROP persisted `uploading`
   records instead of coercing them to `interrupted`, but eleven comments
   across core and views still described the old coercion. Corrected them
   to state what the code does. `interrupted` is now produced by no code
   path at all; it stays in the union and is still accepted, rendered and
   dismissable because builds before this change persisted such records.
   Marked it LEGACY at the type and in the test that pins the behaviour.

No runtime behaviour change: comments, one type widening, one test comment.

Verified: pnpm typecheck (6/6); packages/core Vitest 1133 tests;
packages/views Vitest 3178 tests; git diff --check.

Co-authored-by: multica-agent <github@multica.ai>
2026-07-29 15:17:40 +08:00
Naiyuan Qing
18adb20c14 MUL-5391: unify upload placeholder UI (#6025)
* fix(editor): an in-flight upload placeholder is never content, and is drawn once

Two defects with one cause: a placeholder for an upload in progress was both
serialised into the draft body and drawn a second time as a chip.

The document IS the persisted draft (getMarkdown -> setDraft), so serialising
an in-flight node turns it into text that outlives the upload:

  - fileCard emitted `!file[x.pdf]()`. Its own tokenizer cannot parse an empty
    href back, so the line survived reopen as dead literal text, sat next to
    the real link the write-back appended, and shipped with the comment.
  - image emitted its process-local `blob:` URL, which ContentEditor then
    scrubbed back out with a regex on every serialise.

Both renderMarkdown implementations now emit nothing while `attrs.uploading`
is set (or no URL exists). A node becomes content the moment it holds a real
URL and never before, which is strictly stronger than scrubbing after the
fact — so BLOB_IMAGE_RE / stripBlobUrls are deleted rather than extended.

Separately, ComposerUploadChips rendered every non-`uploaded` entry, including
ones whose placeholder node is right there in the editor. Every upload started
from a live mount inserts a node first (uploadAndInsertFile is the uploader's
only caller), so those chips were the same upload drawn twice, in two visual
languages, shifting layout as they appeared and vanished. useCoordinatedUploads
now exposes `orphanUploads` — the entries inherited from the persisted draft,
whose originating mount is gone and whose node died with it. That is the case
the chip strip was introduced for, and now the only one it covers.

`getMarkdown()` deliberately stays untrimmed (see its safety-net test); only
its stripBlobUrls wrapper is gone.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(editor): keep a failed upload visible after the chip/node split

Self-review catch on the previous commit: suppressing the chip for every
upload this mount started also suppressed it for FAILED ones. The document
cannot stand in for those — uploadAndInsertFile removes the placeholder node
on failure — so the outcome was left to a toast that has already gone.

The rule is not "started here" but "the document is showing it", and the
document only ever shows a live placeholder: still `uploading` AND started by
this mount. `failed` / `interrupted` always get a chip, `uploaded` never does
(the editor and AttachmentList render those), which also makes an
`orphanUploads.length` gate mean what the call sites assume.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(editor): keep the chip when the user deletes a running upload's placeholder

Code-review catch: "started by this mount" is necessary but not sufficient for
"the document is showing it". Cmd+Z right after a paste removes the placeholder
node while the upload keeps running — and gate.isBlocked keeps blocking send on
the store entry regardless of the node — so the previous filter left a dead send
button with nothing on screen explaining it.

The filter now also consults editorGate.uploading, which is the document's own
answer to "am I showing a placeholder right now" (sourced from the uploading-node
scan via onUploadingChange). Started-here AND still shown is what suppresses a
chip; either half failing brings it back.

Also drops a stale stripBlobUrls reference from the use-upload-gate docstring —
that helper no longer exists.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(editor): a failed upload leaves nothing behind

The failure chip carried no information the toast had not already given at the
moment it happened, and it could not act on it: the bytes were never persisted,
so there is nothing to retry, and the file is still on disk to re-attach. Its
only affordance was a dismiss ✕.

It cost more than that. The entry lives in the persisted draft, so it survived
reload and reopen until dismissed by hand — and `isMeaningful` counts uploads,
so a single flaky request kept an otherwise-empty draft alive for the full
30-day TTL. Uploading again did not clear it either: a new upload is a new
clientUploadId.

Failures now remove their placeholder outright instead of marking it. Both
failure paths (size check, coordinator settle) collapse into that one rule,
which also folds the paste-as-file recovery into the shared branch rather than
duplicating it.

`interrupted` keeps its chip: it is discovered a session later, when the user
no longer remembers attaching anything. `orphanUploads` still handles `failed`
because an older client may have persisted one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* refactor(editor): one upload, one node, from start to finish

The chip strip existed because the document could not answer for an upload it
was not showing. Give it that ability and the strip has no reason to exist.

Three changes make one model:

- ONE IDENTITY. The node's `uploadId` and the draft's `clientUploadId` were
  two independently minted random values, because the node is inserted before
  the handler that created the draft record runs. `uploadAndInsertFile` now
  mints the id up front and hands it to the uploader, which adopts it. Asking
  "is this upload in the document" becomes a lookup instead of an inference.

- REBUILD ON MOUNT. A placeholder is never serialised (it is not content), so
  it dies with the document that drew it and a reopened composer showed no
  trace of an upload still running. The draft record is enough to draw it
  again. Once per id per mount: a placeholder the user deleted mid-upload
  stays deleted (MUL-5181), and the guard is what stops the next store write
  from undoing that. Skipped entirely while chat pins its document to another
  draft — `uploads` follows the selected key, the document does not.

- SETTLE IN PLACE. The write-back replaces the placeholder where the user last
  saw it instead of appending the link at the end. A card promotes to an image
  when that is what arrived; the rebuild path only ever has a filename, so it
  cannot know in advance.

With that, the chips are deleted outright, along with `orphanUploads` and the
three-condition rule that approximated all of the above. `interrupted` goes
too: nothing could act on it, no surface rendered it after this change, and
`isMeaningful` counted it — one dead record kept an empty draft alive for the
full TTL. The attachment's absence from the body is the signal to re-attach.

SubmitButton's `busy` now spins rather than only greying out, so an upload
with no other on-screen trace (a composer still rebuilding, a placeholder the
user deleted) does not read as a dead control.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(editor): whoever draws a placeholder registers it, not whoever finds it

Review catch on the rebuild effect. An upload started by the current mount had
its node drawn synchronously by uploadAndInsertFile, but its id only entered
`rebuiltUploadIdsRef` once the effect ran and happened to find that node. In
between, a delete (Cmd+Z right after a paste) left the effect looking at an
unmarked `uploading` record with no node — so it drew a second one, undoing a
removal MUL-5181 says must stick, and letting the settle land an attachment
the user had taken out.

The id is now registered where it is minted: an id handed into handleUpload
means the editor already drew the node. The window is sub-frame and needs a
keystroke inside one render pass, but "whoever draws it registers it" is a
rule, where "the effect will notice in time" was a race.

The composer mocks called `onUploadFile(file)` with no id, so they were not
exercising the one-id contract at all — every mount-started upload looked
inherited to the hook. They now mint and pass one like the real handle does,
which is what lets the new regression test see the difference.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 17:08:09 +08:00
Naiyuan Qing
77b309a5ac feat(drafts): unified draft lifecycle + upload ownership inversion (MUL-5181) (#5900)
* feat(drafts): unified draft lifecycle + upload ownership inversion (MUL-5181)

Unify how every composer preserves unsent work, sends, and handles uploads.

L1 foundation (packages/core/drafts):
- createDraftStore factory + self-registering cleanup-registry replacing the
  hand-maintained WORKSPACE_SCOPED_KEYS list; register-all-drafts guarantees
  registration completeness. Fixes the confirmed cross-user draft leak
  (persistence + in-memory) on logout / workspace delete.

L3 send paradigm:
- useComposerSubmit: one await-then-render contract (lock/spin, keep-on-fail,
  clear-on-success, single-flight, submit-time upload-gate), adopted by
  comment/reply/edit, create-issue, quick-create, and chat.

Per-surface:
- Comment/Reply/Edit: attachments moved into the persisted draft.
- Create Issue: draft split into shared/manual/agent/activeMode with
  non-destructive mode switching + migration for old flat drafts.
- Chat: optimistic send converted to await-then-render (kept server-driven
  cancel restore_to_input); chat draft keys registered for cleanup.

L2 upload coordinator (ownership inversion, Linear-validated shape):
- upload-coordinator + DraftUpload placeholder: uploads owned by a module
  coordinator that outlives the component, state persisted in the draft;
  AbortController + abort-on-logout; interrupted-on-reload. Comment surface
  fully wired. Create-issue/chat upload wiring is a documented residual.

Verified: core + views typecheck clean; core 1064 + views 2928 tests pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(drafts): close three review gaps in the unified draft lifecycle (MUL-5181)

1. Logout resurrection: reset in-memory draft stores BEFORE removing their
   persisted keys — each reset is a setState and persist writes it straight
   back under the still-active slug, so the old order re-created the deleted
   keys. The issue draft store's reset is now a full reset including
   lastAssignee, which clearDraft deliberately re-seeds and would otherwise
   hand the previous user's last-picked assignee to the next login.

2. Submit gate blind spot: the composer gate now also reads the draft's
   coordinator-owned upload placeholders (hasUploadingDraft). A composer
   reopened over a still-in-flight upload could previously send past the
   editor-only gate, clearing the draft out from under the settling upload.

3. Attachment binding returns to reference-filtering: a submit binds only
   uploads the body references, so deleting an inline image really unbinds
   it. An upload that settles after its mount died gets its markdown link
   written back into the body instead — via the reopened composer's live
   editor (new ContentEditorRef.insertMarkdownAtEnd) or appended to the
   persisted draft (new appendToDraftContent) — so close-surviving files
   stay visible, deletable, and honestly bound.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(drafts): harden upload write-back delivery after independent review

Review of the previous commit (fresh-context reviewer + probe against real
@tiptap/react) found the write-back could still lose a file:

- insertMarkdownAtEnd now returns a boolean: the imperative handle exists
  from first commit but the Tiptap instance arrives in a passive effect, so
  an insert in that window (or after destroy) no-ops. Callers previously
  assumed it landed.
- Write-back is now confirmed delivery (deliverFinishedUpload): insert into
  the live editor and, on success, persist the same body as insurance
  against the debounced emit being dropped by a quick unmount; append to
  the store only when NO composer is mounted (a mounted editor's first emit
  would erase a store-only append); retry while a mounted composer's
  instance is still warming up. Every attempt re-checks the generation
  guard and the body reference.
- mountedRef flips in a layout effect: React nulls the child editor ref in
  the unmount commit, and a settle in the gap before passive cleanup saw
  "mounted" with no editor left to swap.
- uploadAndInsertFile guards editor.isDestroyed after the await: now that
  uploads outlive mounts, the swap/remove paths could dispatch against a
  destroyed EditorView and escape as an unhandled rejection.
- Tests: the reopened-composer test now asserts the editor actually
  received the insert (it previously passed with liveEditors disabled),
  plus a warming-up retry case; the mock editor mirrors isDestroyed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(drafts): roll coordinated uploads out to issue-create and chat (MUL-5181 L2)

Completes the upload-ownership layer for every composer surface. The generic
engine is extracted from the comment implementation into
editor/use-coordinated-uploads (UploadDraftBinding adapter: store-backed
accessors + registry key + body append), and use-comment-uploads becomes a
thin binding over it — behavior unchanged, all comment tests green.

Issue-create (manual + agent panels):
- shared.attachments migrates Attachment[] -> DraftUpload[]; load normalizes
  legacy bare rows to `uploaded` and coerces stale `uploading` to
  `interrupted`.
- Uploads are coordinator-owned: placeholder at pick time, survives dialog
  close, aborts on logout, chips for uploading/failed/interrupted, combined
  gate on Create and both mode-switch actions.
- Write-back targets the body of the MODE that started the upload (manual
  description vs agent prompt); mount-time prune keeps placeholders and drops
  only unreferenced `uploaded` entries.

Chat (tab + floating window):
- inputDraftAttachments migrates to DraftUpload[] with load-time
  normalization; new store ops (add/settle/fail/remove upload, append-to-
  draft) mirror the comment store.
- ChatInput adopts the engine; the upload target is snapshotted at pick time
  via resolveUploadTarget so a file dropped while the editor is pinned to a
  previous session's document files under THAT draft.
- uploadMapRef is gone — the draft's uploads are the single binding source,
  reference-filtered at send. Hosts no longer own transport: onUploadFile
  prop becomes uploadEnabled, and the controller/window drop uploadWithToast.
- commitDraft prunes only `uploaded` entries the body no longer references;
  placeholders survive keystrokes (chips are their only UI).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(drafts): harden L2 rollout after independent review

- attachmentToDraftUpload now strips the response-scoped signed download_url
  before the row is persisted (draft uploads survive restarts; a stale
  signature 403s the preview on reopen). Covers comments, issue-create, and
  chat in one place; issue-create's settle reuses the helper, and the
  Signature assertion the rollout had dropped is restored.
- chat's live-editor registry follows the LOADED draft key (reactive mirror
  of editorDraftKeyRef): a settle for draft B must not insert into an editor
  still pinned to draft A's document.
- removeUpload aborts an in-flight request before dropping its placeholder.
- issue-create hasDraft counts only uploaded/uploading entries so a failed
  remnant can't pin the sidebar draft dot forever.
- Tests: mutation-proof coverage for the two placeholder-preservation rules
  (create-issue mount prune, chat commitDraft prune) — both previously
  survived rule inversion; direct core tests for the five new chat store
  upload ops incl. persistence and signed-URL stripping; quick-create test
  gets the editor i18n namespace; dead uploadWithToast scaffolding removed
  from both modal tests; chat-input mock aligned with the real append
  semantics.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(drafts): close third-round review gaps in the upload engine

- The live-editor registry registers in a layout effect: chat's adopt swaps
  the editor's document and loaded key synchronously during commit, and a
  passive re-registration one task later left a settle window where the old
  key mapped to an editor already holding another draft's document. The
  registry key is also built only when a binding exists.
- removeUpload aborts only a request THIS surface tracks as `uploading`
  (guarded before the abort), with the comment now honest about the path
  being defensive — no current chip exposes ✕ mid-upload.
- Mutation-proof test for the loaded-key registry rule: a dead mount's
  settle for a pinned draft must insert into the editor HOLDING it, not the
  selected one (verified to fail with the registry keyed by selection).
- hasDraft upload semantics pinned by tests (uploaded/uploading count;
  failed/interrupted remnants don't pin the sidebar dot).
- Dead scaffolding dropped: identity use-file-upload mocks and a redundant
  assertion in the modal tests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(drafts): stale-submit draft guard + registry layout timing (review BLOCKED items)

Blocker 1 — a submit that outlives its composer may only consume the draft
it submitted (MUL-5181 P0). Every accepted-submit clear is now guarded:
- create-issue / quick-create snapshot the singleton draft's object identity
  at submit; a dead panel clears (and records last-assignee/mode) only if
  the draft is untouched, and never runs close/reset effects. A replaced
  draft B typed after close survives a late success of draft A.
- comment / reply / edit snapshot the per-key draft entry; a dead composer
  clears only the exact entry it submitted.
- chat snapshots the sent slot's value; a dead mount's commitInput clears
  only an unreplaced draft.
Mutation-verified tests for the create panels and comments (guard inverted
=> tests fail), plus untouched-draft control cases.

Blocker 2 — the live-editor registry is now genuinely registered in a
layout effect. The prior commit claimed this fix but a test-time
`git checkout --` discarded the unstaged engine edits before committing;
re-applied: layout registration, binding-gated registry key, and the
tracked-only abort in removeUpload. New registry timing test captures the
registry from a parent layout effect across a key switch — verified to
fail with passive registration.

Also: `multica:chat:selectedProjectId` joins the workspace-scoped cleanup
list (was leaking across logout; flagged as a pre-existing risk).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(drafts): mounted submits also clear only the draft they submitted

The stale-submit snapshot guard previously protected only dead composers;
a mounted one cleared unconditionally on success. But the editor stays
interactive during a request (Tiptap cannot toggle editable post-mount), so
text typed while draft A was in flight was wiped by A's success. The guard
is now unconditional across every surface: success consumes exactly the
submitted snapshot, and any later edit survives.

- create-issue / quick-create: the editor's pending debounce is flushed into
  the store BEFORE snapshotting (a late flush of pre-submit typing must not
  read as a mid-flight edit); a touched draft skips clear AND close/reset —
  the dialog stays open on the newer work. Untouched behavior unchanged.
- comment / reply / edit: same flush + snapshot; a touched entry keeps both
  the store draft and the editor content (edit mode stays open on it).
- chat: commitInput's value compare now applies while mounted too, and the
  editor is scrubbed only for an untouched draft.
- use-composer-submit docs no longer claim "editor locked": they state the
  real contract — send affordance locks, edits after submit survive.

Regression tests: mounted mid-flight-edit cases for manual create (incl.
"dialog must not close over draft B"), quick create, comment, and chat,
plus mounted-untouched controls.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(drafts): idempotent draft writes so a tab switch cannot resurrect a posted comment

Final-review blocker: the comment/reply visibilitychange/pagehide flush
re-writes IDENTICAL content on every tab switch, and writeDraft minted a
new entry object each call — the stale-submit guard's identity compare then
read a mid-flight tab switch as "edited during the request", kept the
posted comment's draft alive, and left Send enabled for a duplicate.

- writeDraft is now a no-op when content and uploads are unchanged (also
  kills a spurious persist write per tab switch). Regression tests: entry
  identity preserved on identical setDraft (core), and the reproduced
  tab-switch-mid-send scenario clears the posted draft (views) — verified
  to fail with the idempotence removed.
- onAccepted now flushes the editor's pending debounce before judging
  `untouched` on every surface, so typing still inside the debounce window
  counts as a mid-flight edit instead of being scrubbed.
- create-issue records last-assignee/mode from the SUBMITTED values,
  outside the untouched gate — a created issue updates the preference even
  when the dialog stays open on newer edits.
- Stale guard comments corrected in both create panels; the
  use-composer-submit docstring no longer claims project/feedback were
  migrated (they still hand-roll await-then-clear; registered debt).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-28 10:10:00 +08:00