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
Conversation
…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
…applied (#2579) Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0194p5A8hMhFZxRVRba93mrt
dolho
left a comment
There was a problem hiding this comment.
/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.vuethrough@vue/compiler-sfcto settle the one thing the tests cannot:FIXED_TAB_WIDTHresolves asliteral-constand emits$setup.FIXED_TAB_WIDTHin both rows. The companion-<script>export genuinely works.
The best thing in this PR is not in the ACs:
landOnAgentused to destructure{ sessions }off a value that is an array, sosessionswas alwaysundefined,landingThreadalways 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. AndonSelectreturning early on!tab?.threadrather than emitting a null intoopenThread(which readst.is_room) closes the obvious crash. - The settle cycle is cleared in three places, and
watch(convKey)is the one that matters. NeitheronBeforeUnmountnor the event handler fires on a thread switch, so without it a cycle armed in chat A would keep replacingthreads.valuefor 16s under chat B. The re-check ofshouldMarkTurnReadinsidearmTitleSettle— 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". settleTickre-runsstore.seedAgentRecencybecause it replacesthreadsexactly asrefreshThreadsdoes. 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_chatcomment then says "the existingretryattempt 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 —convGenremounts the component so focus set before the press is discarded; (2)ensureMainListed+ the Main-first comparator; (3) the admin-onlytitleNotice, gated byshouldFetchTitleHealthso a portal client never even asks for the admin endpoint (the #2128 lesson applied); (4)fixedWidthon both rows withtruncate+title=; (5) specs andworkspace-chat-tabs-and-titles.mdupdated. fixedWidthas its own prop rather than a rider ondense— 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 iswidth: max-content; the visible row needsshrink-0+overflow-hiddenbecauseinlineCountstarts at+Infinityand 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_OPENERkeeps 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/titleNoticeDismissedall reset inonSignOut, 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.
#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>
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
devafter 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.convGen, which remountsPortalConversation; itsonMountednever focused, anddefineExpose({ focusComposer })had zero callerslist_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 (fetchSessionsreturnsdata.sessions || []), so it was alwaysundefinedand that branch never once ranWhat changed
A fresh chat is a tab, provisionally.
agentChatTabsinserts oneNew chattab withthread: nulldirectly 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'sbornHere), never off "the active id is not in the list", which would label a cold deep link.bornHereis raised in the oneadoptSession()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
titleGenerationNoticeraised into the Workspace for platform admins.design-system-contract.mdline 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), andagent-detail-tab-overflow.md.fixedWidthdefaults 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
retryattempt is the safety net that remains._persist_user_turn's own ruling that a user message on record with no reply is the honest record.ensureMainListedis a GET that inserts. Visiting N agents creates N emptyenterprise_portal_sessionsrows. 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)
_title_healthis a module global and prod runs--workers 2, so an admin's probe can land on a worker that ran no generation and answerunknown→ no notice. Honest under-reporting; cross-worker means new Redis-shared state for a diagnostic. Recorded inworkspace.md.sessionsFailedsays so honestly.No follow-up issues filed — that was the gate's ruling.
Not frontend-only
src/backend/client_portal/service.pyis 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/devinto the branch (da2aa6ef), because that merge touched two files this PR also touches —views/Portal.vueandtests/unit/portalVoiceMode.spec.js. The earlier numbers predated it.npm run test:unit— 101 files, 2276 passedtest_ent473_chat_titles.py+test_ent79_portal_exposure.py— 130 passednpm run check:tokens— OK (11 tokens equivalent, all references resolve, dark ink ladder holds)devper file (OverflowTabs22→22,PortalConversation98→98,Portal.vue36→36,PortalChatTabs1→1). Deliberately not measured againstraw-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 insidetest:unit)./verify-local --skip-unit --skip-agent— PASS (build+import-smoke → boot → integration 70 passed / 13 skipped)e2e/workspace-chat-tabs.spec.jsplus 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'sis_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
firstattempt when the opener is greeting-shaped and let the retry own it (needsclient_messagepassed 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