Skip to content

fix(workspace): the chat tab strip — a visible New chat, a Main that is there, titles that are titles, and tabs that hold their width (#2579) - #2600

Merged
vybe merged 14 commits into
devfrom
AndriiPasternak31/issue-2579
Sep 8, 2026
Merged

vybe merged 14 commits into
devfrom
AndriiPasternak31/issue-2579

Conversation

@AndriiPasternak31

Copy link
Copy Markdown
Contributor

Fixes #2579

Merge order — step 1 of 7

This is step 1 of 7 of the Workspace sequence. It merges after — nothing merges before it. Everything downstream in the sequence rebases on this one, and ent#547 + #2580 follows next.

The reason it is first is mechanical, not ceremonial: this PR introduces the single adoptSession() seam, OverflowTabs.fixedWidth, and the concurrent title spawn — three things the later steps build on. Landing any of them second means resolving the same conflicts in the other direction.

What was wrong

An operator test on dev after ent#451/#473/#523 found four things wrong with the tab strip above a Workspace conversation. None of them was the sort rule, and two the issue suspected of being build lag turned out to be structural.

What was seen The actual cause
New chat produced no tab By design — the 2026-09-06 ruling ("a chat exists once its first message is sent") applied to the strip
New chat did not focus the composer The press bumps convGen, which remounts PortalConversation; its onMounted never focused, and defineExpose({ focusComposer }) had zero callers
The pinned Main tab was absent Main is minted only by the per-agent list_sessions; the Workspace lists from the cross-agent batch, which deliberately never mints. The one per-agent read — landOnAgent's repair branch — destructured { sessions } off an array (fetchSessions returns data.sessions || []), so it was always undefined and that branch never once ran
Titles showed the first message Generation was spawned as the turn returned, so the client's turn-done refresh always read the derived fallback

What changed

A fresh chat is a tab, provisionally. agentChatTabs inserts one New chat tab with thread: null directly after Main — the slot the real row takes, so adoption swaps it in place rather than making it jump. It is keyed off explicit intent (startingNewChat, ORed with the conversation's bornHere), never off "the active id is not in the list", which would label a cold deep link. bornHere is raised in the one adoptSession() seam all three adoption sites share; raising it at one site drops the tab exactly when streaming is unavailable, or for the whole round trip of starting a voice call.

New chat focuses the composer, from onMounted's else-branch — it has to happen in the remounted instance.

Main is ensured by ensureMainListed, through the per-agent read that already mints it. It is a GET that inserts, deduped in flight, capped at two attempts, and cleared at sign-out (that handler resets state in place, so client B would inherit client A's promises).

Tabs are a fixed width via an opt-in OverflowTabs.fixedWidth.

The title spawn moves to run concurrently with the turn — the root cause, fixed at the source rather than papered over. Plus a bounded client-side settle re-read as the belt, and the existing titleGenerationNotice raised into the Workspace for platform admins.

⚠️ This amends a binding design contract — please look at that specifically

design-system-contract.md line 30 and principle 10 say "never wrap or truncate". This PR amends both.

The amendment: that rule governs the set — the strip still overflows into a counted "N more" menu and never drops or wraps tabs. It does not require every label to be laid out at its intrinsic width. A strip whose labels are unbounded user and model text may opt into fixed-width tabs whose labels clamp, with the full text on hover and in the overflow menu.

Recorded in design-system-contract.md, design-system.md (with the recipe), and agent-detail-tab-overflow.md. fixedWidth defaults to false, so Agent Detail, Library and the portal rail are unchanged by construction. This wants the design authority's eyes, not a docs bullet.

Behaviour changes, stated

  1. Titles are generated from the client's opening message alone. The reply was the disambiguator for a terse opener; the existing retry attempt is the safety net that remains.
  2. A turn that fails now still titles its thread — consistent with _persist_user_turn's own ruling that a user message on record with no reply is the honest record.
  3. ensureMainListed is a GET that inserts. Visiting N agents creates N empty enterprise_portal_sessions rows. That is ent#523's stated intent, said out loud so it is not a surprise in review.

Both title changes are pinned by test so they stay decisions rather than drift.

Accepted limitations (documented, not deferred)

  1. Title health is per uvicorn worker. _title_health is a module global and prod runs --workers 2, so an admin's probe can land on a worker that ran no generation and answer unknown → no notice. Honest under-reporting; cross-worker means new Redis-shared state for a diagnostic. Recorded in workspace.md.
  2. A permanently failed list refresh leaves a real thread labelled "New chat". The list is stale in that state anyway and sessionsFailed says so honestly.

No follow-up issues filed — that was the gate's ruling.

Not frontend-only

src/backend/client_portal/service.py is touched: one moved call site, one new prompt constant, one branch. Chosen small deliberately — that file is also edited by ent#535 (step 5 of this sequence), so the rebase should be mechanical.

Verification

Re-run after merging origin/dev into the branch (da2aa6ef), because that merge touched two files this PR also touches — views/Portal.vue and tests/unit/portalVoiceMode.spec.js. The earlier numbers predated it.

  • npm run test:unit — 101 files, 2276 passed
  • Backend test_ent473_chat_titles.py + test_ent79_portal_exposure.py — 130 passed
  • npm run check:tokens — OK (11 tokens equivalent, all references resolve, dark ink ladder holds)
  • Raw-color ratchet: flat on every touched file, measured branch-vs-dev per file (OverflowTabs 22→22, PortalConversation 98→98, Portal.vue 36→36, PortalChatTabs 1→1). Deliberately not measured against raw-color-baseline.json, which is stale from 2026-07-31 and already exceeded by dozens of files this PR never touches. Loading-gate ratchet green (it runs inside test:unit).
  • /verify-local --skip-unit --skip-agent — PASS (build+import-smoke → boot → integration 70 passed / 13 skipped)
  • Live sign-off done on a real stack: e2e/workspace-chat-tabs.spec.js plus the two neighbouring tab-strip specs — 12/12 passed, zero skipped (PORTAL_TEST_AGENT=sage). The zero is the point: the geometry cases self-skip on a thin fixture, and a skip reads as green.

No schema change, no new route, no new os.getenv, no new top-level backend module — so no dual-track migration, no MCP surface, and no packaging gap to check.

Draft because Andrii flips it, not because verification is outstanding.

One open product question — found live, not asserted anywhere

Opener-only generation means a greeting opener produces refusal prose as the title: "Hi!" → 'I need more context to create an appropriate title. The ope…', "Hi! How are you?" → 'Unclear - awaiting client message content'.

_title_plan's is_greeting(opener) retry does rescue it — proved live, the same thread became 'Q3 Vendor Contracts Auto-Renewal Review' after a second turn. So the residual is only a thread that opens with a greeting and never gets a second message, which keeps the prose permanently.

Candidate fix if wanted: skip the first attempt when the opener is greeting-shaped and let the retry own it (needs client_message passed to _title_plan, which today only sees pre-turn history). Not implemented, not filed — flagging it for the reviewer's call rather than deciding it here.

🤖 Generated with Claude Code

AndriiPasternak31 and others added 14 commits September 7, 2026 22:58
…e-turn title spawn (#2579)

Rule #1 — the requirement lands before the code. §5.21 gains AC-1a (the
provisional New chat tab, a recorded reversal of the 2026-09-06 ruling for the
STRIP only), AC-1b (fixed-width tabs + the design-contract amendment), AC-2a
(New chat focuses the composer in the REMOUNTED instance), AC-8a (the admin
title-health notice under the strip, and its per-worker blind spot) and AC-8b
(the spawn moves to run concurrently with the turn; the client settle stays as
the belt). §5.23 records that the shell ensures Main is LISTED through the
per-agent read that already mints it — a GET that inserts, stated so a reviewer
is not surprised — and why landOnAgent's repair branch was dead.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0194p5A8hMhFZxRVRba93mrt
…nd it (#2579)

agentChatTabs takes an options bag: with `draft` and no real row carrying the
active id, it inserts one provisional tab — "New chat", `thread: null` —
directly AFTER Main, which is the slot the real row lands in, so adoption swaps
it in place instead of making it jump. Keyed only off the caller's explicit
intent, never off "the active id is not in the list": a cold deep link to a
thread the batch has not listed yet would otherwise wear the label. Two-arg
callers are unchanged.

The header comment records the reversal rather than leaving the next reader to
"fix" it back: the 2026-09-06 ruling still holds for the THREAD; the STRIP is
what changed, because pressing New chat and seeing nothing change is the defect.

Adds three helpers with no home yet: agentHasMain (the shell's cheap question
before spending a mint), titleSettling (the 2..4 post-turn window, Main
included) and shouldFetchTitleHealth, plus TITLE_SETTLE_DELAYS_MS — a
best-effort window, NOT a mirror of the server's tunable timeout.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0194p5A8hMhFZxRVRba93mrt
… asks for it (#2579)

Chat-tab labels are user and model text, so one long title stretched its tab
and pushed every sibling under "N more". `fixedWidth` clamps every tab —  Main
included — to FIXED_TAB_WIDTH ('w-40', one exported constant, declared in a
companion <script> block since <script setup> cannot carry a named export),
truncates the label and puts the full text on `title=`, in the button and in
the overflow-menu row.

Default false, and EVERY width-related class is gated on it, so Agent Detail,
Library and the portal rail keep their behaviour — including the native
tooltip, which unconditional would have grown one on "Overview".

Two of the gated classes are load-bearing rather than cosmetic. `inlineCount`
starts at +Infinity, so every tab renders inline before the first measure(); a
truncating label drops the button's min-content to padding, and flex's default
shrink would squeeze the visible row to ~50px per tab for a frame while the
`max-content` mirror still reports 160 — the two rows disagreeing is exactly
the parity bug the pinned-glyph comment exists about. Hence `shrink-0` on the
visible button and `overflow-hidden` on the visible nav. The mirror takes the
width class and nothing else: getBoundingClientRect returns the border box.

PortalChatTabs passes it, renders the provisional tab through `draft`, and
returns before emitting when the selected tab has no thread — today that hands
the shell a null and openThread reads `is_room` off it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0194p5A8hMhFZxRVRba93mrt
#2579)

Three sites adopted a session and each set the state by hand — streaming, the
synchronous /chat fallback, and the voice path's createSession. They now go
through one `adoptSession()`, which also raises `bornHere`: the shell clears
`startingNewChat` the instant it hears the adoption, which is before the
refreshed list arrives, so without a bridge the provisional tab vanishes for a
round trip (and, when this was the agent's only chat, so does the strip). Doing
it at one site would have dropped the tab exactly when streaming is
unavailable. `bornHere` is spent the moment a row carrying the active id
arrives; a real thread switch remounts this instance and resets it anyway.

New chat now focuses the composer. It has to happen in `onMounted`'s
else-branch, in the REMOUNTED instance — pressing New chat bumps `convGen`, so
focus set before the press is thrown away. `defineExpose`'s inline arrow, which
had no callers, becomes the named function this reuses.

The `#notice` slot under the strip is a slot rather than a prop for the reason
the `#band` slot above it is one: the shell owns every fact the line carries.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0194p5A8hMhFZxRVRba93mrt
landOnAgent's repair branch had never run. It destructured `{ sessions }` off
`store.fetchSessions`, which returns an ARRAY — so the value was always
undefined, landingThread always missed, and a first-time visitor always fell
through to a fresh chat while the Main that read had just minted server-side
never reached the screen. It now goes through `ensureMainListed`, and the
overtake guard moves BETWEEN the await and the navigation, because the ensure
spends two round trips where the old code spent one.

`ensureMainListed` is the one seam for "this agent's Main must be in the list":
Map-deduplicated in flight, called on `activeAgentName`, capped at two
attempts. The cap is not tidiness — `fetchAllSessions` never rejects, so a
resolved entry over a still-missing Main would make the miss permanent for the
session; deleting the entry on a miss lets the next visit retry, and the cap
stops that becoming a loop against the watchers refreshThreads re-fires. Both
Maps are cleared in onSignOut, which resets state IN PLACE — the OTP form is a
branch of this same component — so client B would otherwise inherit client A's
resolved promises. It is a GET that INSERTS, and says so.

The settle cycle re-reads the list on a bounded schedule after a turn in the
two-attempt window and stops as soon as the title differs. It aborts on
`sessionsFailed` (a flaky network otherwise reads as "the title never
changed"), stops without a verdict when the row is gone, and is cleared from
three sites — the next turn-done, unmount, and `watch(convKey)`, the seam that
actually means "the conversation changed". Exhausting it asks the server's
health record; it never decides on its own.

The notice is one dismissible status line under the strip, admin-only and
fetched only after a failure to settle, in semantic status-warning tokens.
`fetchTitleGenerationHealth` goes through `portalHttp`, not @/api, whose 401
handler hard-navigates to /login under /workspace — a background diagnostic
must not bounce an operator out of the conversation they are reading.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0194p5A8hMhFZxRVRba93mrt
)

The root cause of "tab titles show the first message". Generation was spawned
as the turn RETURNED, so the client's own turn-done refresh always lost the
race and read the derived fallback; the generated title then appeared only on
whatever later refresh happened to come along. In practice a chat wore its
first message as its name.

The spawn moves to immediately after `_persist_user_turn` — the fallback must
land first, because the generated write is guarded against a person's rename,
not against an empty row — and runs while the agent is working. `title_attempt`
does not move: it was already decided pre-turn, on the pre-turn row.

With no reply yet, `_generate_thread_title` picks `_TITLE_PROMPT_OPENER`. That
is a separate constant rather than the existing prompt with an empty
`<assistant_reply>` block, because an empty block in a prompt that names it
invites the model to describe the emptiness. Same rules, same
never-follow-instructions hardening, one block.

Two behaviour changes, both deliberate and both pinned: the title comes from
the opening message alone (the existing `retry` attempt is the disambiguator
that remains), and a turn that FAILS now still titles the thread — consistent
with `_persist_user_turn`'s own ruling that a user message on record with no
reply is the honest record.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0194p5A8hMhFZxRVRba93mrt
…awn (#2579)

Frontend, node-env — so the SFC rules are narrow source pins and the spec
headers say so. New: the provisional tab's exact shape (after Main, thread
null, keyed to the adopted id across the gap), the named regression (an unknown
active id WITHOUT a draft invents nothing — a cold deep link must never wear
the "New chat" label), the two-argument callers unchanged, agentHasMain,
titleSettling's 2..4 floor-and-ceiling with Main included, and the health gate.

The OverflowTabs pins use the mirror-slice idiom, not a count: "the class
appears twice" is satisfied by putting it on the mirror's More button instead
of its tab, which measures the wrong thing and is the parity bug the pin exists
to prevent. The clamp machinery is asserted on the visible half only, and the
tooltip/menu clamp are asserted GATED, so the three other consumers stay
byte-identical.

e2e/workspace-chat-tabs.spec.js is new because no node-env pin can execute the
AC's headline ask. It measures: every visible tab exactly 160px (Main
included), the label clipped with its full text on `title=`, the counted "N
more" appearing as the column narrows, and no horizontal page overflow.
Learning #1500 is the reason — the last shared tab-strip change shipped with
regex coverage only and rotted.

Backend: the spawn sits between the persist and the turn (source order, the
`ORDER MATTERS` class), exactly one spawn site carrying an empty reply, the
opener prompt chosen on a falsy reply and never an empty <assistant_reply>
block, both prompts carrying the same hardening — plus, in the file that owns
the roster/turn harness, the spawn firing before the turn runs and a FAILED
turn still titling its thread. The ent#473 attempt-sequence test keeps its
sequence and drops its reply expectation, which is what the move changes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0194p5A8hMhFZxRVRba93mrt
…nt (#2579)

architecture/workspace.md records the reversal (the strip, not the thread), the
one adoption seam and why one site is not enough, the mount focus and the
remount that forces it, ensureMainListed as a GET that inserts with a
load-bearing retry cap, the pre-turn spawn and its two behaviour changes, the
settle belt's abort conditions and three clear sites, and the notice's
per-worker blind spot as an accepted limitation.

architecture/frontend.md documents `fixedWidth` on the OverflowTabs primitive
including the first-paint squeeze the gated classes exist for.

The design contract is AMENDED, not bent, and that is called out for the design
authority rather than buried: "never wrap or truncate" governs the SET — the
strip still overflows into a counted menu — and a strip whose labels are
unbounded user or model text may opt into fixed-width tabs whose labels clamp,
with the full text on hover and in the menu. Recorded in both the binding
contract and the system of record, with the recipe and its two "watch" notes.

The flow doc gains a "four defects" table naming each cause, sections for each
fix, and the corrected diagram; its stale "now drops archived chats" line is
replaced with what the code actually does and why. Index row added.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0194p5A8hMhFZxRVRba93mrt
/update-tests found three uncovered rules and added them: the provisional tab
goes FIRST when the pair has no Main yet ("after Main" is not "index 1", and a
hardcoded 1 would bury it behind a chat), PortalChatTabs' model-value falls back
to NEW_CHAT_TAB_ID so the tab renders ACTIVE rather than as its own dead state,
and the store's health read returns `data.title_generation` — the same field the
settings panel reads, so there is one health path and not two.

/sync-feature-flows found two flows beyond the two already updated.
agent-detail-tab-overflow.md owns the OverflowTabs primitive and had no
`fixedWidth`; its props table had also drifted four props behind (dense,
moreLabel, pinned, signal all shipped in ent#451/#474/#523 and were never
listed), so adding one row would have made a half-true table look complete —
they are all listed now, and the addition is dated in the revision history.
workspace-voice-conversation.md records that the call's own adoption now runs
through adoptSession, and that this was one of the three sites which, missed,
would have dropped the entire strip while a call started from a fresh chat.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0194p5A8hMhFZxRVRba93mrt
Self-review of the settle cycle. `onConversationTurnDone` clears synchronously
but arms after an await, so two events close together — the voice path emits
`sessions-changed` right after `createSession` and again when the turn lands —
left the first cycle's timers running beside the second's, against a baseline
the second had already overwritten. Up to six list reads instead of three and a
wrong `titleAtTurnDone`. `armTitleSettle` now clears first; pinned.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0194p5A8hMhFZxRVRba93mrt
)

/review against the plan's own verification list ("send in chat A then
immediately switch to chat B — no notice may appear above B") found the gap.
`sessions-changed` fires for a thread the user has navigated away from — that
is the main way a reply legitimately arrives unseen — and a cycle armed there
kept replacing the list for 16s and could raise a notice above a different
conversation. `watch(convKey)` only catches a switch that happens AFTER arming;
this catches the one that happened before. The settle now asks the same
`shouldMarkTurnRead` question the read-marking already asks, for the same
reason. Pinned.

Also records what the same review found in the backend: since there is now
exactly one spawn site and it always passes `reply=""`, the two-block
`_TITLE_PROMPT` is unreached in production and survives on the `reply` branch
and in its tests. It is kept rather than deleted — the reply is the
disambiguator for a terse opener, and restoring an exchange-fed attempt should
be a call-site change, not a prompt rewrite — and now says so, so the next
reader does not assume both are live.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0194p5A8hMhFZxRVRba93mrt
…note (#2579)

/review findings on the branch, all three inside code #2579 introduced.

The arm's own check. `onConversationTurnDone` decides "is the person still in
this thread" synchronously and then arms after two awaits (`markRead` +
`refreshThreads`). A switch inside that window passes the caller's check and
fires `watch(convKey)` while nothing is armed yet, so the cycle was armed on a
conversation already left — the exact outcome the caller's comment claims is
prevented. `armTitleSettle` now asks the same question itself.

The seeding. `settleTick` replaces `threads.value` the way `refreshThreads`
does, so it owes the same ent#491 `seedAgentRecency`. It fills only missing
keys, so it cannot walk back a send's bump.

The note. The store's comment claimed `@/api` would bounce an operator on a 401
where `portalHttp` would not. It would not: `portalHttp`'s own 401 handler
calls `_onPlatformSessionLost`, which logs out and pushes /login. The choice of
instance stands (one credential decision, in one place); the reason recorded
for it did not.

Both code changes carry a source pin with a mutation control.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0194p5A8hMhFZxRVRba93mrt
@AndriiPasternak31 AndriiPasternak31 self-assigned this Sep 8, 2026
@AndriiPasternak31
AndriiPasternak31 marked this pull request as ready for review September 8, 2026 00:46

@dolho dolho 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.

/review — PR #2600 (AndriiPasternak31/issue-2579 → dev)

Files: 24 (+1730/−117) · Scope: CLEAN — all five ACs addressed, nothing beyond them.

Verified locally:

  • pytest tests/unit/test_ent473_chat_titles.py test_ent79_portal_exposure.py → 130 passed
  • full frontend unit suite → 101 files / 2276 passed, including loadingGateRatchet
  • raw-colour scan clean on the touched files
  • and I compiled OverflowTabs.vue through @vue/compiler-sfc to settle the one thing the tests cannot: FIXED_TAB_WIDTH resolves as literal-const and emits $setup.FIXED_TAB_WIDTH in both rows. The companion-<script> export genuinely works.

The best thing in this PR is not in the ACs:

landOnAgent used to destructure { sessions } off a value that is an array, so sessions was always undefined, landingThread always missed, and the repair branch never ran once.

That is a dead branch that had been silently shaping first-visit behaviour, found while fixing something adjacent, and named plainly rather than quietly rewritten. ensureMainListed replacing it with one seam shared by the landing path and the watcher is the right shape, and being honest that it is a GET that INSERTS — with ent#523's intent cited — is exactly the disclosure a future reader needs.

Two other calls worth recording as correct:

  • The provisional tab is keyed off draft, never off "the active id is not in the list." A cold deep link to a thread the batch has not listed yet would otherwise wear a "New chat" label over a real conversation. And onSelect returning early on !tab?.thread rather than emitting a null into openThread (which reads t.is_room) closes the obvious crash.
  • The settle cycle is cleared in three places, and watch(convKey) is the one that matters. Neither onBeforeUnmount nor the event handler fires on a thread switch, so without it a cycle armed in chat A would keep replacing threads.value for 16s under chat B. The re-check of shouldMarkTurnRead inside armTitleSettle — because the caller decides it synchronously and then arms after two awaits — is the kind of thing that is only found by asking "what happens in the window between these two lines".
  • settleTick re-runs store.seedAgentRecency because it replaces threads exactly as refreshThreads does. Easy to miss; it would have quietly unranked agents for 16 seconds at a time.

Critical

None.

Informational

[I1] The retry pass no longer sees the exchange ent#473 designed it around, and two comments read as though it does (Confidence: 7/10)
src/backend/client_portal/service.py (_TITLE_PROMPT header vs the portal_chat spawn comment)

There is now exactly one call site and it always passes reply="" — for the first attempt and the retry. So:

  • _TITLE_PROMPT's header keeps the two-block prompt because "the reply is the disambiguator for a terse opener" — but nothing reaches that branch any more, which the same comment concedes two lines earlier.
  • The portal_chat comment then says "the existing retry attempt remains the disambiguator for a terse opener" — true, but by a different mechanism: it disambiguates using the second turn's client message, not a reply.

ent#473 specified that second pass as feeding "THIS exchange — the one after a greeting or a failed first attempt is the first one with a topic in it." It now feeds a message, not an exchange. I think that is fine and probably right (the disambiguating content is usually the follow-up question, not the agent's answer), but it is a real narrowing of #473's input and it is currently recorded only as an aside inside a comment about a different constant. Worth one sentence saying so where _title_plan documents retry, so the next person changing this does not "restore" the reply on the assumption it was lost by accident.

[I2] The fixed-width AC's only render-level proof is an @interactive e2e test, which CI does not run (Confidence: 8/10)
src/frontend/tests/unit/portalChatTabsAndTitles.spec.js:285-308 vs src/frontend/e2e/workspace-chat-tabs.spec.js:116

The unit assertions are source-greps over the SFC text:

expect(s()).toMatch(/export const FIXED_TAB_WIDTH = 'w-40'/)
expect(visible).toMatch(/\$\{FIXED_TAB_WIDTH\} shrink-0/)

They pin the source, so they would pass unchanged if the binding never resolved in the template and the class never reached the DOM — which is precisely the risk a companion-<script> named export carries. The real assertion ("every visible tab is exactly one fixed width") lives in the @interactive e2e spec, and this repo's e2e job runs @smoke only, behind the ui label.

To be fair about it: the frontend unit harness has no mount capability (@vue/test-utils is not a dependency and vitest runs environment: 'node' — the constraint architecture.md already records for ent#392), so a render assertion is not available to the author, and source-greps are the best the harness allows. I confirmed by compilation that the binding does resolve, so this is not a live bug. The point is only that the AC "tabs are fixed width" is not currently covered by anything CI evaluates — worth either tagging one of those e2e cases @smoke, or saying in the flow doc that this property is verified out-of-band.

[I3] settleTick refreshes the list without chatState (Confidence: 6/10)
src/frontend/src/views/Portal.vue (settleTick)

refreshThreads fetches both and assigns both; settleTick assigns threads only. Deliberate and cheap (three extra reads per turn, not six), and chatState converges on the next real refresh. Noting it because the two functions now diverge in what "refresh the list" means, and the divergence is not stated where the second one lives — a comment on settleTick saying "list only; chatState is intentionally not re-read here" would stop someone unifying them later and doubling the traffic.

Clean

  • AC coverage: (1) provisional tab + nextTick(focusComposer) in the fresh instance, with the correct reason — convGen remounts the component so focus set before the press is discarded; (2) ensureMainListed + the Main-first comparator; (3) the admin-only titleNotice, gated by shouldFetchTitleHealth so a portal client never even asks for the admin endpoint (the #2128 lesson applied); (4) fixedWidth on both rows with truncate + title=; (5) specs and workspace-chat-tabs-and-titles.md updated.
  • fixedWidth as its own prop rather than a rider on dense — density and label-boundedness are different questions, and every width class is gated on it, so the four existing strips are byte-identical (including no stray native tooltip on "Overview").
  • The mirror/visible asymmetry is reasoned, not copied: the mirror takes the width class and nothing else because getBoundingClientRect() returns the border box and the mirror is width: max-content; the visible row needs shrink-0 + overflow-hidden because inlineCount starts at +Infinity and flex would squeeze every tab for one frame. That is the sort of detail that is wrong in every hand-rolled version of this.
  • Prompt hardening preserved: _TITLE_PROMPT_OPENER keeps the never-follow-instructions fence over author-controlled text, and dropping the empty <assistant_reply> block rather than formatting "" into it is the right call — an empty block invites the model to describe the emptiness.
  • Timer hygiene: cleared on unmount, on convKey, and on the next turn-done; mainEnsured/mainAttempts/titleHealth/titleNoticeDismissed all reset in onSignOut, because the OTP form is a branch of the same never-remounted component — the #2258 principal-bleed class, caught for the new state rather than rediscovered later.
  • Design system: semantic status tokens only in the notice, role="status" + aria-live="polite", raw-colour and loading-gate ratchets both green.

Summary

Critical: 0 · Informational: 3 · Scope: clean.

Approving. All three are comments/coverage rather than behaviour; [I1] is the one I'd most like written down, because it is the only place a real semantic change to #473 is recorded.

@vybe
vybe merged commit 6581db7 into dev Sep 8, 2026
29 checks passed
AndriiPasternak31 added a commit that referenced this pull request Sep 8, 2026
#2579 (PR #2600) landed step 1 of the Workspace sequence and conflicted in two
places.

`PortalConversation.vue::defineExpose` — dev refactored `focusComposer` from an
inline arrow into a named function and gave it a real consumer
(`nextTick(focusComposer)` on a new chat). Resolved to `{ focusComposer,
startVoiceCall }`, keeping dev's named reference and this branch's Talk-door
export. The comment claiming nothing used `focusComposer` was true when written
and is not any more, so it is corrected rather than carried forward.

`tests/unit/workspaceNewChat.spec.js` — #2579's pin asserted the exact
one-token `defineExpose({ focusComposer })`, which any sibling PR exposing a
second thing would fail. Relaxed to membership, which is what the case's own
comment says it cares about; falsified by dropping `focusComposer`, which still
turns it red.

`learnings.md` — an append-only ledger, both sides' entries kept.

Frontend unit suite after the merge: 102 files / 2315 passed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants