Skip to content

feat(workspace): unsent input survives switching agents, chat tabs and rooms (abilityai/trinity-enterprise#657) - #2917

Merged
vybe merged 2 commits into
devfrom
feature/657-workspace-drafts
Sep 20, 2026
Merged

vybe merged 2 commits into
devfrom
feature/657-workspace-drafts

Conversation

@vybe

@vybe vybe commented Sep 20, 2026

Copy link
Copy Markdown
Contributor

Summary

  • Text typed into a Workspace composer used to die on the next click — the shell remounts PortalConversation / PortalRoom on every switch and input was 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).
  • The composer IS the draft. One write-through composable binds both composers, which makes send()'s existing input.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.
  • Frontend only: no endpoint, no schema, no API, no MCP surface. Keys are the shell's own chatKey scheme (thread: / room:, plus new:<agent>), so a later server-side draft would swap only the source.

The three decisions worth reviewing

  1. Identity = the roster's client_email, not the column layout's resolveLayoutIdentity (auth0_user). That rule exists because widths must resolve synchronously before first paint and it gives every portal client one shared client bucket — harmless for a number, not for someone's words. Drafts render after rosterLoaded, so they can use the server-reported principal. The divergence is recorded in architecture/workspace.md so the next per-viewer cache doesn't invent a third rule.
  2. Persistence is key-granular (persistDraft re-reads the bucket, applies one key) plus a storage listener. 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.
  3. The mark is the word "Draft", not an icon — a deviation from the approved plan, made after looking at the live render. The pencil is already the rename affordance (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

Area Files
New components/portal/portalDrafts.js (pure rules), stores/portalDrafts.js, composables/useComposerDraft.js, components/base/DraftMark.vue, utils/safeStorage.js
Composers PortalConversation.vue, PortalRoom.vue — bind the composable; restore focus on a fine pointer only (a phone would get the soft keyboard over the transcript)
Lists PortalSidebar.vue, PortalChatRow.vue, PortalChatTabs.vue, OverflowTabs.vue (+ optional per-tab hasDraft), portalUtils.js
Shell Portal.vue — hasDraft rides decorate() beside starred/unread; Reset carries the draft to the new Main; a failed room post hands the text back
Session clientPortal.js — explicit sign-out clears the bucket before signOut() nulls the identity; expiry keeps it

Four in-instance identity changes are one rule (reconcileDraftOnKeyChange), including the thread: → null hop openAgentPage takes 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:tokens OK
  • e2e/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. Hermetic page.route mocks and @smoke, because CI runs test:e2e:smoke only — an @interactive spec 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)
  • No regression in neighbouring workspace e2e (chat-tabs, code-blocks, stick-to-bottom): 9 passed, 4 skipped
  • Looked at in the browser in both themes at 1280×800: the mark reads quietly in each, and row/tab geometry is byte-identical with and without it (serialized every button's getBoundingClientRect both 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) hasDraft dropped from the re-measure key (5) buckets merged on identity change (6) → null reconcile branch dropped (7) clearBucket() moved after signOut() (8) each composer's useComposerDraft call deleted, in turn — e2e only (9) hasDraft dropped from decorate() — 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, toggles hasDraft and asserts the strip re-measured, plus a negative control that an unrelated prop change does not. Written up in learnings.md.

Stated residuals

  • The same conversation edited in two tabs is last-write-per-key (the cross-key case is closed).
  • Attachments stay session-only — the issue allows this if retaining them isn't cheap, and it isn't: the chips carry upload state and server-side inbox ids.
  • Draft text rests in the clear in the browser profile, under an email-named key — the same boundary that already holds the portal token and the platform JWT. Explicit sign-out removes it.
  • The search-result row's mark has no automated coverage: it is a one-line store read in a template, the sidebar has no mount harness, and the e2e doesn't mock the search endpoint.
  • The caret restores to the end of the text, not to where it was.

Docs

requirements/core-agent.md §5.35 · new feature-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.md

Fixes 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

…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>
@vybe vybe added the ui PR touches the frontend UI — triggers Playwright e2e tests label Sep 20, 2026
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>
@vybe

vybe commented Sep 20, 2026

Copy link
Copy Markdown
Contributor Author

merge-train: one mechanical fix pushed to this branch (572cb5ec0).

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:

docs/memory/requirements/core-agent.md AC-3 still described the planned mark — "one small, quiet pencil beside an sr-only Draft (PortalDraftMark.vue)", marked via 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 correctly in the PR body and in feature-flows/workspace-drafts.md; this file was simply left at the plan's wording.

The field name was the part worth not leaving: draft is a different, pre-existing PortalChatTabs prop meaning "fresh-start intent", so the requirements named a live prop that means something else. I folded the feature-flow doc's own reasoning (pencil = rename, dot = activity, star = pin — principle 24) into the AC so it reads as a decision rather than an omission.

Recorded as follow-ups, not blocking:

  • The three wiring seams (useComposerDraft in both composers, decorate()'s hasDraft) are green under unit mutation and covered only by frontend-e2e, which its own workflow header calls advisory, not a required merge gate. It ran and passed here. A future edit to those files could inert the feature while satisfying every required check.
  • No debounce on the persist path — every keystroke does a full getItem/parse/stringify/setItem over the bucket. Latent, bounded by MAX_DRAFTS/MAX_PERSISTED_CHARS.
  • DraftMark's title prop is bound but never passed by any caller; usePortalDraftsStore().has() is consumed only by tests.

Fixes abilityai/trinity-enterprise#657 is cross-tracker — it will close the issue but will not relabel it, so I'll set status-in-dev there by hand after merge.

@trinity-ability trinity-ability left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@vybe
vybe merged commit 50e9939 into dev Sep 20, 2026
29 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ui PR touches the frontend UI — triggers Playwright e2e tests

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants