feat(workspace): unsent input survives switching agents, chat tabs and rooms (abilityai/trinity-enterprise#657) - #2917
Conversation
…d rooms (Abilityai/trinity-enterprise#657) Text typed into a Workspace composer died on the next click: the shell keys `PortalConversation` on `convKey` and `PortalRoom` on the room id, so every switch remounts the stage, and the composer's `input` was a ref of that stage. The text now lives in a per-person store keyed `thread:` / `room:` / `new:` — the shell's own `chatKey` scheme, so a later server-side draft would swap the source and not the keys — bound write-through by ONE composable both composers share. That single rule is what makes `send()`'s existing `input.value = ''` the clear, and every existing hand-back (a cancelled turn, a failed escalation, a failed room send) a draft again. No clear call was added to any of them. Three decisions carried the design: * the bucket is namespaced by the roster's `client_email`, not the column layout's `auth0_user` rule — that one exists because widths must resolve before first paint and it gives every portal client ONE shared bucket, which is harmless for a number and not for someone's words; * persistence is key-granular with a `storage` re-read, because the Workspace opens in its own tab (ent#456) and a whole-map write from tab B would carry a stale snapshot of tab A's keys — able to re-persist a draft A had just SENT, the one thing the AC forbids; * the mark is the word "Draft", not an icon: the pencil is already the rename affordance on these exact rows, the dot is the tab strip's activity signal and the star is the per-viewer pin. Four in-instance identity changes are one rule (`reconcileDraftOnKeyChange`), including the `thread: -> null` hop `openAgentPage` takes on the CURRENT agent before its remount, which a three-branch rule would have blanked. Edges: Reset carries the draft onto the new Main (it archives the history, not what you were typing); a closed room clears its own; explicit sign-out removes the bucket BEFORE `signOut()` nulls the identity (after it, the store's identity is already null and the clear removes nothing) while an expiry keeps it; an entry over 64k chars is session-only rather than truncated; a `new:` draft lists the provisional New chat tab, without which those words have no door at all, since the agent row lands on Main. `OverflowTabs` gains an optional per-tab `hasDraft` in all three rows and in the re-measure key. `safeStorage()` is hoisted to `utils/` — it was copied in two places that read different globals. Tests: pure rules + the store (two instances over one storage = two tabs) in node; the composable on live refs and `PortalChatRow`/`PortalChatTabs`/ `OverflowTabs` mounted in jsdom; sign-out vs expiry in `workspaceSession`; and a hermetic @smoke e2e that is the only thing executing the wiring — @smoke because CI runs `test:e2e:smoke` only, so an @Interactive spec would be a consumer nothing executes. Nine mutations, each verified red, listed in the PR. Fixes Abilityai/trinity-enterprise#657 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
AC-3 still described the planned shape — a pencil beside an `sr-only` "Draft" in a `PortalDraftMark.vue`, marked on `OverflowTabs`' per-tab `draft` field. Shipped is `components/base/DraftMark.vue` rendering the visible word, on a per-tab `hasDraft` field. The deviation is already explained in the PR body and in feature-flows/workspace-drafts.md; only this file was left at the plan's wording. `draft` was also the wrong name to leave in the requirements: it is a different, pre-existing `PortalChatTabs` prop meaning "fresh-start intent", so the plan's spelling named a live prop that means something else. merge-train: mechanical, per the merge-train note on the PR. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
merge-train: one mechanical fix pushed to this branch ( Validation came back READY — coverage is mutation-proven behavioural, not the source-text class, and every changed value has a named live consumer. One item was worth fixing before merge rather than after:
The field name was the part worth not leaving: Recorded as follow-ups, not blocking:
|
trinity-ability
left a comment
There was a problem hiding this comment.
merge-train: validated 2026-09-20. Coverage mutation-proven behavioural; every changed value has a named live consumer. One mechanical fix pushed (AC-3 doc alignment, 572cb5e). Full check set green including all six pytest seeds and e2e.
Summary
PortalConversation/PortalRoomon every switch andinputwas a ref of the stage. Drafts now outlive the switch, come back exactly as left, and are marked wherever the conversation is listed (agent row, chat tab, thread/room row, search hit).send()'s existinginput.value = ''the clear and every existing hand-back (cancelled turn, failed escalation, failed room send) a draft again — no clear call was added to any of them.chatKeyscheme (thread:/room:, plusnew:<agent>), so a later server-side draft would swap only the source.The three decisions worth reviewing
client_email, not the column layout'sresolveLayoutIdentity(auth0_user). That rule exists because widths must resolve synchronously before first paint and it gives every portal client one sharedclientbucket — harmless for a number, not for someone's words. Drafts render afterrosterLoaded, so they can use the server-reported principal. The divergence is recorded inarchitecture/workspace.mdso the next per-viewer cache doesn't invent a third rule.persistDraftre-reads the bucket, applies one key) plus astoragelistener. The Workspace opens in its own tab (ent#456); a whole-map write from tab B carries a stale snapshot of tab A's keys and could re-persist a draft A had just sent — the one thing AC-6 forbids.PortalEditableTitle) on these exact rows, the dot is the tab strip's activity signal, the star is the per-viewer pin. The issue offers "icon or short tag"; two facts sharing one shape is the failure principle 24 names.Changes
components/portal/portalDrafts.js(pure rules),stores/portalDrafts.js,composables/useComposerDraft.js,components/base/DraftMark.vue,utils/safeStorage.jsPortalConversation.vue,PortalRoom.vue— bind the composable; restore focus on a fine pointer only (a phone would get the soft keyboard over the transcript)PortalSidebar.vue,PortalChatRow.vue,PortalChatTabs.vue,OverflowTabs.vue(+ optional per-tabhasDraft),portalUtils.jsPortal.vue—hasDraftridesdecorate()besidestarred/unread; Reset carries the draft to the new Main; a failed room post hands the text backclientPortal.js— explicit sign-out clears the bucket beforesignOut()nulls the identity; expiry keeps itFour in-instance identity changes are one rule (
reconcileDraftOnKeyChange), including thethread: → nullhopopenAgentPagetakes on the current agent before its remount — a three-branch rule would have blanked the composer there.Test plan
npm run test:unit— 3200 passed / 140 files (includes the raw-color + loading-gate ratchets);npm run check:tokensOKe2e/workspace-drafts.spec.js— 5/5 against a real browser: agent round trip + focus, two drafts at once, reload survival, room round trip, tab-to-tab isolation. Hermeticpage.routemocks and@smoke, because CI runstest:e2e:smokeonly — an@interactivespec here would be a live consumer nothing executes (bug(process): tests that assert source TEXT instead of executing the path — three green-CI ejections in one train, third run running #2829)chat-tabs,code-blocks,stick-to-bottom): 9 passed, 4 skippedgetBoundingClientRectboth ways)Mutation: nine, each verified red, restored byte-identical from scratch copies —
(1) write-through watch removed (2) whitespace accepted as a draft (3) mark dropped from the mirror row (4)
hasDraftdropped from the re-measure key (5) buckets merged on identity change (6)→ nullreconcile branch dropped (7)clearBucket()moved aftersignOut()(8) each composer'suseComposerDraftcall deleted, in turn — e2e only (9)hasDraftdropped fromdecorate()— e2e only.(4) is the one worth knowing about: it stayed green at first. jsdom reports every rect as 0, so no spec executed the key — the mark would have silently stopped repacking the strip. Closed with a consumer that spies on
getBoundingClientRect, toggleshasDraftand asserts the strip re-measured, plus a negative control that an unrelated prop change does not. Written up inlearnings.md.Stated residuals
Docs
requirements/core-agent.md§5.35 · newfeature-flows/workspace-drafts.md(+ index + changelog) ·architecture/workspace.md·design-system.md(the per-tab field family and its three-rows-plus-key invariant) ·learnings.mdFixes abilityai/trinity-enterprise#657 — cross-repo, so this does not auto-close; the private issue is closed at the release cut.
🤖 Generated with Claude Code