Conversation
…ts box (#2640) Two defects, one report. **The layout jumped.** `Portal.vue` toggled `<main>` between `flex-1` and `sm:flex-[2_1_0%]` on `voiceCall.active` and swapped `PortalRail` for `PortalVoiceCanvas` in the same frame. Nothing transitioned, so both columns landed at their new shares in one paint, and End call jumped back. `flex-grow` is a `<number>` and therefore animatable, so the share now transitions — 300 ms ease-out on `<main>`, and the canvas column ramps its own grow 0 → 3 over the same curve. The ramp is expressed as Vue enter/leave classes rather than a class toggle because a newly inserted element has no value to transition FROM. Opacity rides the same transition so the canvas's content is not re-wrapping in view while the column is still moving. `motion-reduce:transition-none` on every transitioning element. The shares stay shares: reverting to `w-[40%]` / `w-[60%]` would animate just as well and re-open #2581, where those summed to 100% + an 18rem sidebar and the shell clipped the canvas column off the right edge. Two consequences, both deliberate: * The `v-if` / `v-else-if` chain is gone — a `<Transition>` wrapper breaks the adjacency a chain needs. The exclusivity it guaranteed by construction is now a named computed both arms read, so they cannot drift into both claiming the column. * The rail waits for the canvas to finish leaving. Vue keeps a leaving element in the DOM for its transition; without the gate the rail would mount at full fixed width beside a canvas that is still shrinking — three columns in a row sized for two, `<main>` squeezed by flex for 300 ms, a worse jump than the one being fixed. **The orb rendered squashed.** `VoiceOverlay.vue::resizeCanvas` sized the bitmap ONCE, from the `watch(canvasEl)` that fires on mount — no ResizeObserver, no window listener, no per-frame check — while the canvas is `absolute inset-0 w-full h-full`. Every later width change left CSS stretching a stale bitmap into an ellipse, and the overlay mounts in the same tick the call re-lays out the columns, so the single measurement could capture the pre-call width on its own. It now observes both: a ResizeObserver for the box moving under a stable window (the column swap, a rail drag), and a window `resize` for a devicePixelRatio change, which resizes no box and so fires no observer. The bitmap is sized at `css × dpr` (capped at 2) and the render loop draws in CSS pixels via `ctx.setTransform`, so the 45px core and the particles' fixed radii keep meaning what they meant. Resizing re-scales and never re-seeds — the particle field is seeded once and lives in a fixed space around (0,0), so the orb does not restart when the column moves. A zero-sized box is ignored rather than throwing the last good size away, and a same-size measurement does not touch the bitmap, because assigning to `canvas.width` clears the canvas. Tests: `portalVoiceLayoutMotion.spec.js` (17) — the resize contract EXECUTED against a stub canvas whose box changes (DPR scaling, the cap, a missing DPR, the zero-box and same-size guards), mutation-checked by pinning the bitmap to its first measurement, which fails exactly the "FOLLOWS a changed box" case; plus the transition classes, the reduced-motion fallback counted over every transitioning element, and the leave gate. Two existing guards in `portalVoiceMode.spec.js` / `portalRail.spec.js` were rewritten to read the shared condition instead of the retired chain — the property they protect is exclusivity, not which construct expresses it. Frontend suite: 113 files, 2532 tests, all passing; both ratchets green; vite build clean. Related to #2640 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Bd71qsYbFodvofba8P69eP
…tab title (ent#557) An agent replied while the operator was elsewhere and nothing indicated it. Most of the machinery already existed — the per-agent pill, the per-chat badge, the aggregate in the wordmark. Three things were missing. **1. A chat you have never opened could not be unread.** ent#359 defined unread relative to a read cursor and gave a cursorless thread a count of nothing, correctly: inventing a cursor at the beginning of time would have badged every historical conversation in every install on the day it shipped. What it could not foresee is ent#523, which made Main the landing place for everything an AGENT starts. A freshly minted Main has never been read by anyone, so the one case an unread badge exists for produced no badge anywhere. A cursorless thread now counts agent messages newer than the viewer's account baseline — "anything an agent has said to you since the first time you read anything here, in a chat you have never opened". The baseline is a STORED, write-once row, not a derived value, and that is the whole of the design. Both obvious derivations were tried and both fail the same test: * MAX(last_read_at) — "since you were last here" — advances every time the viewer reads anything, so an unread reply in a chat they have not opened is silently cleared by reading a DIFFERENT chat. * MIN(last_read_at) — "since the first time you read anything" — looks stable and is not: `mark_chat_read` UPDATES the row it advances, so a viewer with one chat has MIN == MAX and inherits the identical bug. MIN was implemented first and `test_reading_one_chat_does_not_silently_clear_ another` failed it, which is why the row exists. It lives in the chat-state table under a reserved kind, written on the first ever read and never moved; every read of that table excludes it, so it reaches neither the sidebar payload nor either row cap. A viewer with no baseline still counts nothing — ent#359's property preserved, and it falls out of SQL's NULL comparison rather than a second branch. **2. It only appeared when you did something.** `refreshThreads()` is event-driven — a send, a navigation, a turn finishing — so an agent-initiated reply reached the sidebar on the viewer's next action and not before, which is the same as never for someone in another tab. It now also runs on the ent#364 asks poll (20s, visibility-aware). Folded into that timer rather than given its own: the Workspace has no WebSocket a portal client is on, and since #2198 the thread half is ONE request for every agent, which is what makes it cheap enough to ride there. **3. The tab said nothing.** While anything is unread the title carries `(3) Trinity — Workspace`, returning to the plain title when everything is read and cleared when the Workspace unmounts. `utils/tabTitle.js` owns it because `document.title` now has two writers on independent schedules — the router's label and the count — and with both assigning directly the last to fire would erase the other's half. Neither assigns; both call in. The count is a PREFIX on whatever the router computed, which is what lets ent#556 change the label without touching any of this, and it leads rather than trails because browsers truncate a tab from the right. Reading clears it with no reload: `markRead` already cleared optimistically and re-decorated `threads`, so the total recomputes synchronously and the tab follows. Asks and unread stay separate (#2424) — this adds nothing that sums them. Tests: `test_ent557_unread_never_opened_chat.py` (12, against a real sqlite so the SQL is the code under test), `portalUnreadTabTitle.spec.js` (14, the format executed and the two-writer ordering), `portalUnreadLiveness.spec.js` (10). Backend portal slice 587 passed; frontend 114 files / 2539 tests; ratchets green; build clean. OSS-core, deliberately ungated, per the standing Workspace ruling restated in the issue. Related to Abilityai/trinity-enterprise#557 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Bd71qsYbFodvofba8P69eP
…QL (ent#557) `count_unread_by_session` was widened to LEFT JOIN and count a never-opened thread against the account baseline — and `service.get_chat_state` then discarded every one of those rows. It built the payload by iterating the viewer's state ROWS and reading the unread map off them, so a thread with no cursor (the entire ent#557 case: an agent replies into a freshly minted Main nobody has opened) never appeared. No badge, no per-agent pill, no wordmark total, no tab title. AC 1 and AC 2 were undelivered; the only case the change did reach was "starred but never opened", which is not the reported bug. The service now runs a second pass over the unread map and emits the thread ids the row loop did not, de-duplicated against them so one chat cannot be counted twice in the wordmark total, and `starred: false` by construction. It is bounded by the same read — `count_unread_by_session` is scoped to the caller's own `enterprise_portal_messages`, so it cannot append a chat that is not theirs. The comment where the fallback used to be argued against restoring it by citing the INNER JOIN and the `last_read_at IS NOT NULL` filter — both of which this same PR removed. It read as protection that exists and would have stopped the next reader from putting the pass back (the ent#523 entry in `learnings.md`). Replaced with what is actually true now. Tests: five cases crossing the db→service boundary, which is the boundary all twelve existing tests skip by calling `client_portal.db` directly — which is why this was invisible. `test_the_api_reports_a_chat_that_has_no_state_row` fails against the pre-fix service and passes after. Related to Abilityai/trinity-enterprise#557 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RSjVEjay9ztC1oDXXkh9uN
… step (#2640) Two review findings. 1. **Reduced motion was not instant.** Tailwind's `transition-none` emits only `transition-property: none` — the `duration-300` beside it still applies, so `transitionDuration` stays `.3s`. That is the exact property Vue's `getTransitionInfo` reads to size the fallback timer it resolves `@after-leave` on, so under `prefers-reduced-motion` nothing animated and every leave was still gated for 300ms: the canvas vanished, the right column sat empty, then the rail popped in. `motion-reduce:duration-0` on every transitioning element drives that timeout to 0. Verified against Tailwind's own output — the variant emits `transition-duration: 0s` inside the media query and after `duration-300`, so it wins. The comment on `voiceCanvasLeaving` claimed "`after-leave` fires immediately and this is never observably true", which was false as written; it now says what makes it true. Same correction in the feature flow. 2. **The rail column still steps** — accepted here, tracked at #2676. Its `<aside>` carries no width transition and it is a `shrink-0` flex sibling of `<main>`, so on call end it mounts at full width in one frame: 48px collapsed, 384px open, or the dragged `--ws-rail`, which on a wide rail is a bigger step than the 211px snap this PR removes. The honest fix is an explicitly animatable width for that column — CSS cannot transition to `auto` — and that width is owned by ent#492, not by the voice-call code. Doing it from here means either a wrapper element in the row or holding the rail mounted through a call, both of which want a browser to verify rather than the node-env source scan this suite is limited to. Recorded as a Known Limitation in the feature flow, with a test that fails if the limitation is deleted from the record or if the rail starts animating. Tests: the class-counting case now requires BOTH reduced-motion classes and asserts the ordering relation, since asserting the class string rather than the behaviour is exactly why (1) shipped green. 113 files / 2534 tests green. Fixes #2640 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RSjVEjay9ztC1oDXXkh9uN
…as (#2676) #2640 animated two of the three columns. The rail was the third and it still stepped: a bare `v-if` on a `shrink-0` flex sibling whose `<aside>` carries no width transition, so on call end it appeared at full size in one frame — 48px collapsed, 384px open, or whatever `--ws-rail` had been dragged to, which on a wide rail is a LARGER step than the 211px snap #2640 removed. The width now lives on a wrapper this view owns rather than on `PortalRail`'s own `<aside>` — the same shape the sidebar column three columns to the left already has (`shrink-0 overflow-hidden` plus an explicit `--ws-` width). `--ws-rail` is the RENDERED width (48px collapsed, the dragged width open), so one binding covers both states and agrees with the inner aside at rest. **Vue enter/leave classes, not an always-on `transition-[width]`** — the load-bearing decision. That same variable is rewritten on every `pointermove` of a rail drag, so a permanently-transitioned width would make dragging rubber-band by 300ms. Vue adds the active class only for the enter/leave window and removes it afterwards, so a drag stays instant. `!w-0` is `!`-marked for the reason the canvas's `!grow-0` is: both are single-class selectors setting the same property, so without it Tailwind's output order would pick the winner. **This retires `voiceCanvasLeaving`.** The flag existed because a rail mounting at full width beside a still-shrinking canvas put three columns in a row sized for two. A rail entering from zero width is complementary to a canvas leaving towards zero grow — the row's total is conserved at every frame — so the hazard is gone by construction rather than held off by a flag, and the two motions now overlap instead of running back to back. The reduced-motion reasoning its comment carried is not lost: it belongs to the transition classes, and the class-counting test requires both `motion-reduce` classes on every transitioning element, including the two added here. `railHasColumn` is the one shared condition, and it reads `railTabs.length` because `PortalRail`'s own root carries `v-if="tabs.length"` — with the width on a wrapper, a tabless rail would otherwise leave a full-width empty column. Measured in headless Chromium against the real built stylesheet rather than argued from the classes: mid-transition the column is at 322px of its 384 (interpolating, not stepping), it settles at 384, under `prefers-reduced-motion` it is at 384 immediately, and a drag issued after the active classes are removed lands in the same frame. The three Tailwind utilities involved are confirmed present in the built CSS — `.\!w-0{width:0!important}`, `.transition-\[width\]{transition-property:width}` and `.w-\[var\(--ws-rail\,24rem\)\]` — since an arbitrary-value miss would have made the whole thing silently inert. Accepted discontinuity: `thirdColumnResizable` is gated on `!voiceCall.active`, so the 8px resize handle still appears and disappears in one frame. It is 8px against a column that moves 384, and animating it would mean a second animated element whose only content is a 1px line. Tests: the transition scan now reads CLASS ATTRIBUTES rather than the whole file — this change's own comments name `transition-[width]` while explaining it, and a file-wide match reports the prose as an un-guarded transitioning element. Three source guards updated where the `v-if` moved (they assert the shared computed, which is the property; which element carries the condition is not), and four new cases: the ramp's classes, the static class carrying no `transition-`, the retirement of `voiceCanvasLeaving`, and the tabless-rail gate. Removing the `<Transition>` reds the first of those. 113 files / 2536 tests green; raw-colour ratchet unchanged. Fixes #2676 Related to #2640 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RSjVEjay9ztC1oDXXkh9uN
…y clear (ent#557) Found re-reviewing my own fix. Emitting cursorless threads was the right correction; it interacts badly with a cap I did not check. `service.mark_chat_read` silently no-ops when the row would be a NEW one and the viewer is at `MAX_CHAT_STATE_ROWS` (1000) — deliberately, on the stated ground that "a read marker is incidental to what the user asked for (opening a chat)". A cursorless thread is by definition a new row. So ent#557 made that no-op load-bearing. Before it, a cursorless thread produced no badge and the no-op was invisible, which is what made the justification true. After it, a capped viewer gets a badge on the wordmark total, the per-agent pill AND the browser tab title, and opening the chat cannot dismiss it — a permanent unread marker with no user action that clears it. The second pass is now gated on there being room. Not shown beats shown-and-stuck: a capped viewer degrades to exactly the ent#359 behaviour, which is the state they were in before this feature, rather than to a badge that never goes away. Three properties, each deliberate: * the gate applies to the CURSORLESS pass only. A thread that already has a row can always be marked read (`_would_create_row_past_cap` allows updating a row the caller already owns), so capping its badge would hide unread the viewer can perfectly well clear; * it fails OPEN — an unreadable count reports room, because refusing to show unread on a COUNT that could not be taken would hide real unread from every viewer on a transient DB error, and the write path is what enforces the cap; * the COUNT is paid only when the pass would emit something, i.e. never on the ordinary sidebar load where every thread already has a row. Asserted, so it stays true. Tests: the capped viewer (badge withheld, and the no-op demonstrated rather than assumed — `mark_chat_read` is called and the count does not move), the room-left case clearing to zero, a row-bearing thread still badging AT the cap, the fail-open path, and the no-COUNT-on-ordinary-load cost note. Removing the gate reds the first. 527 passed across portal / chat-state. Related to Abilityai/trinity-enterprise#557 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RSjVEjay9ztC1oDXXkh9uN
`.prose-portal` styled exactly four things — paragraphs, unordered lists, links and code — and Tailwind's preflight reset everything else, so every other element an agent can write arrived with the browser reset and nothing else: a GFM table with no rules and no padding, headings at body weight, ordered lists with their numbers gone. The element set is now covered where the render, the style and the copy handler already live (PortalMarkdown.vue), so the bubble, the room and any future surface inherit one look rather than a second one being copied out: - Tables get the platform's own anatomy — mono-caps header on chrome, 6/12 cell padding, a rule per row — as an object one step off the bubble tint, the same shape a code block already has. The scroll-viewport pair is lifted from CanvasKit (#2583): `display: block` makes the table its own horizontal viewport so a ten-column table scrolls instead of widening the bubble, and `overflow-wrap: normal` in the cells stops an inherited "break anywhere" squeezing an auto-layout table to one character per line. `width: fit-content` keeps a two-column table from drawing its border around an acre of nothing. - GFM alignment survives sanitising as the `align` attribute, but a presentational hint loses to author CSS, so the right/center cases are restated and the table carries `tabular-nums`. - Headings get a bounded ladder: `# Title` names a section of the reply, not the page, so it tops out at the design system's section size (18/650) and lands on the meta overline by h3 — three visibly distinct steps, all inside the six-size scale. - Ordered lists, list items, nested lists, blockquotes (the `ck-callout` 3px left rule, so a quoted line reads the same in a message and on a canvas) and horizontal rules. Nothing here touches the sanitiser: the treatment is CSS on platform-owned selectors, no new admitted tags, attributes or classes. Code blocks keep the #2515 behaviour — labelled bar, always-visible Copy, wrap-at-the-edge `pre`. One #2515 assertion is narrowed rather than deleted: it read "the sheet has no `overflow-x` at all", which was a claim about the `pre` (a scroller inside a chat bubble hides the end of a line behind a gesture nobody makes) written as a claim about the whole stylesheet. A table must scroll in its own viewport, so the assertion now looks at the `pre` rule it was always about. Verified in light and dark at 1100px and at 430px. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GawbQRicLvNMaPknFT8BfR
Its own commit, with the growth named, per the design-system contract — a deliberate increase is never absorbed into a feature diff. - components/portal/PortalMarkdown.vue: raw_gray 13 -> 34 (+21). Giving the Workspace transcript a typographic treatment (#2616) costs a surface, border and ink pair per element in BOTH themes — table, header row, cells, headings, blockquote, rule — and those are gray by definition ("everything else is gray"). raw_nongray stays 0; hardcoded_colors stays 0. - components/portal/PortalJumpToLatest.vue: newly recorded at 1 gray. It arrived on dev after the last freeze and had no entry. #2605's `refrozen` record is carried forward by hand: the regenerator writes the file wholesale and would have dropped the history of why the ceiling sits where it does. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GawbQRicLvNMaPknFT8BfR
…ting accessors (#2669) `get_or_create_installation_id` is a write API wearing a read API's name. Three read-shaped callers have minted durable identity by looking (#1987, the ent#190 benchmark read, the ent#545 funnel GET), each closed by a point fix, and the only static guard lived in the private repo and pinned one module. Nothing in the OSS tree, where the accessor lives, stopped a fourth. `tests/unit/test_2669_minting_accessor_callers.py` pins the caller set: every use of `get_or_create_installation_id`, `get_or_mint_sharing_id` or `get_instance_label` must sit at an allowlisted `(path, enclosing qualname, name)` carrying the reason that site needs the identity to exist. An import is a binding that feeds a per-file alias map, so an aliased or re-bound call is a hit at the call. The identity keys (`installation_id`, `telemetry_sharing_id`) are written only by their home modules. The read twin `get_installation_id` is pinned to exist. Token-prefiltered on case-folded bytes with a fail-closed sentinel for a token-bearing file that does not parse (the #1677 shape); OSS tree only, the private twin is trinity-enterprise#575. Self-tests drive every spelling family, the scope attribution and the verdict in both directions through the same scan the live tree uses; the first run caught the case-folding gap (learnings 2026-09-10). Fixes #2669 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
CodeQL read the helper's one-pass `replace(/<!--[\s\S]*?-->/g, '')` as js/incomplete-multi-character-sanitization (high): nested input (`<!--<!-- -->`) leaves a live `<!--` behind. Nothing untrusted reaches this helper — it reads a checked-in `.vue` file so the assertions are not confused by prose in comments — but the loop is the rule's prescribed fix and the stronger strip, and `hardeningGuide.spec.js` already carries the same fixpoint shape for the same reason, so this matches the established pattern rather than dismissing the alert. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Lh6WrChj2636YEy5UZmZ1w
…2616) The first attempt ran the fixpoint loop over all three comment strips chained together, and CodeQL flagged the same rule again: the query only recognises the loop as a complete sanitiser when the flagged replace's result is assigned straight back to the loop variable, which a chain of three breaks. Split out `withoutHtmlComments` so the shape matches `hardeningGuide.spec.js` exactly — the one form already proven clean on dev — and chain the JavaScript comment strips after it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Lh6WrChj2636YEy5UZmZ1w
…ever imported (#2689) `portal_auth_exchange` read the sliding-session idle window through a name the module does not import: idle_s, _ = settings_service.get_portal_session_policy() `grep -n settings_service src/backend/client_portal/router.py` returned exactly one line — the call. So every request to the ent#163 trusted-issuer seam raised `NameError` and answered 500. Present on `dev` AND on `main` (line 308 there): released, not a dev-only regression. Introduced by ent#375 (#2099), which added the idle-window read to the response. `dependencies.py` performs the identical read correctly, and for two reasons that are both load-bearing: the import is function-local because `settings_service` imports `db` and a module-level import would make a cycle, and it is wrapped in a degrade to the shipped policy because this is an auth path and a settings hiccup must not 500 it. The router copied the call and neither property. The fix reuses that reader rather than re-importing `settings_service` here. A second copy of the call would be a second chance to omit the degrade — and the degrade is the half that matters on an auth route. Importing an underscore-private name across modules follows existing practice (`from ._common import _norm_ts`, `from services.git_service import _detect_git_dir`, `from services.template_service import _is_platform_injected`). Blast radius, measured on a live stack in one minute: platform login, platform JWT, the portal OTP flow, portal session tokens, MCP keys and agent auth all answered normally; only this route was down. The failure is reachable only WITH a valid `portal_delegate` key — every other principal is refused 403 by the scope fence before reaching the line — so it is not externally probeable, and it is total for exactly the callers the route exists for. WHY EIGHT TESTS MISSED IT. `test_ent163_portal_delegated_identity.py` and `test_163_portal_delegate_scope.py` cover the mint, the access rule, scope fencing, disjoint identities and revocation — all through `service.portal_exchange(...)`. None executes the ROUTE HANDLER, and the NameError is in the handler body three lines after the service returns. Proven rather than asserted: with the broken line reinstated, only the three new tests fail and all 26 pre-existing ones still pass. `tests/unit/test_2689_portal_exchange_route_executes.py` drives the handler. It pins that the route answers at all, that `expires_in` is the real idle window (compared against `_portal_session_policy()`, not a literal, so the policy stays single-sourced), and that a raising settings backend degrades instead of 500ing. It also re-pins the two gates the route already owned — the delegate-scope 403 and the no-share 403 — which were previously asserted only against the service. An AST scan for the same shape (a module using a service singleton it never binds) across `src/backend` found no other instance. Related to #2689 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RSjVEjay9ztC1oDXXkh9uN
Self-review of the fix above. Reusing `dependencies._portal_session_policy` was right; importing it at MODULE SCOPE was not. Both module-scope forms capture at import time — a `from`-import binds the function object, `import dependencies as _deps` binds the module object — and either goes stale if `dependencies` is re-imported after this module. That is not hypothetical in this repo: `tests/unit/conftest.py` lists `dependencies` in `_POP_PREFIXES`, and an autouse fixture evicts it from `sys.modules` before AND after every unit test, deliberately, to stop cross-file stub pollution. Measured with the first version of this fix in place: a test's `import dependencies` produced a DIFFERENT module object than the one `client_portal/router.py` had captured, so patching the live module returned (111, 222) while the route went on returning the shipped 604800. With the `from`-import form the same thing happened one level tighter — the router held the original function object and never saw the patch at all. No production consequence: nothing pops modules at runtime, and the route answers correctly either way. The consequence is that the route becomes UNTESTABLE from the outside — which is exactly how the defect this PR fixes reached `main`, so shipping the fix in a form with that property would be tone-deaf. The import moves inside the handler. It resolves through `sys.modules` at call time and cannot diverge, and it is the same shape `dependencies.py` already uses one level down (function-local, for the import cycle). The late-binding guard test added alongside it is REMOVED rather than kept green by weakening it: the harness's own eviction makes that assertion unreachable here, and a test that fails for harness reasons teaches the next reader the wrong lesson. The file now says so where the guard would have been, so nobody adds a flaky one back. The mutation check still holds: reinstating the original broken line fails three of the five new tests and none of the 26 pre-existing ent#163 ones. Related to #2689 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RSjVEjay9ztC1oDXXkh9uN
#2647 (the parent this branch was stacked on) squash-merged to dev an hour ago, so its content arrived here from two directions at once: as the branch's own commits and as dev's squashed form. All five conflicts are that, and the resolution is the same in each — keep the branch, which already carries #2647's work plus #2676's changes on top. Portal.vue, three hunks: #2676 RETIRES `voiceCanvasLeaving` (a rail entering from zero width is complementary to a canvas leaving toward zero grow, so the row's total is conserved and the flag has nothing left to sequence). dev still has the flag, its two transition handlers and the old `v-if` on `PortalRail`. The branch's side is the intended end state. The three spec files are the same shape one level along: each conflict is #2676's updated assertion against the pre-#2676 one dev still holds (`v-if="railHasColumn"` on the wrapper vs `v-if="railVisible && …"` on `PortalRail`). Resolved HUNK-WISE, not file-wise. `git checkout --ours` was the first attempt and was WRONG: it takes the whole file from HEAD and so would have discarded dev's ent#556 `PortalBrand` block from the signed-out shell — #2653's work, untouched by this branch and present only in dev. Caught by diffing the resolution against dev before committing. The merged file now carries both. Frontend suite green on the result: 118 files / 2613 tests. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RSjVEjay9ztC1oDXXkh9uN
…next typed turn knows what was said (#2694) Two defects, one per side of the portal messages table. The read side — the window, not the sort. The stored order was never wrong: both writers stamp the same ISO-Z microsecond format and the read orders by it. `get_history` returned the newest 100 ROWS, and a 30-minute voice call is ~180 rows, so one call filled the window, every typed turn before it fell off, and the call block — anchored at the call's first row in the window — rendered as the head of the thread. The window is now counted in TYPED turns (`get_portal_thread_window`: the newest 100 for the endpoint, 20 for the cold replay), the spoken rows of the calls among them ride along whole, and a row ceiling (600, the newest survive) reports itself as `truncated` so the chat can say "Earlier messages in this chat aren't shown" instead of silently re-creating the symptom. Both statements order by `created_at DESC, id DESC` (the uuid tiebreak is stable, not chronological); verified on SQLite and PostgreSQL. The reply poll pays for none of it. It now reads `?limit=N` (1–50, row semantics) and finds the reply by identity — the newest typed assistant row's id — instead of by a count that a shifting window breaks; a spoken reply is never the typed one. The turn side — what the live session never heard. A resumed turn dropped the history replay (ent#358), which was right for typed turns and wrong for spoken ones: the voice provider wrote the call straight into the thread and the agent's own session was not there. A resumed turn is now prefixed with the delta — the platform-written rows since the agent's newest typed reply (`get_platform_rows_since_last_reply` → `_format_voice_delta`; the assistant cursor survives a failed typed turn). The cold replay (`_format_history_context`, rewritten) renders the same rows in the same form and the delta is never added to it, so a cold retry cannot double-send. One renderer for both: spoken rows labelled, the platform's own rows as bracketed markers (never `You:`), whitespace collapsed so a transcript line cannot forge a labelled line, and ONE total spoken budget (24k chars — a whole 30-minute call fits) trimmed oldest-first across calls with every cut named. The 12-rows-per-call counter is gone. No reply lands mid-call, in both directions: a call is refused (409) while a typed reply is in flight, and a typed turn is refused (409, unbilled, retryable) while a call is on, through a live-call marker set at call start and cleared by the bridge's close and the REST stop. Both reads fail open. Tests: tests/unit/test_2694_voice_thread_window.py (real tables + real writers), tests/unit/test_2694_voice_delta_context.py (through the real portal_chat), frontend specs for the render order and the identity-based reply picker; the ent#534 budget test and the #2320 raise-site table updated. Docs: workspace-voice-conversation.md (new section), requirements FR-3b, the workspace/execution area files, two learnings entries, a CSO diff report. Fixes #2694 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01F2QYRXnk9XT8aFreKyk6na
Conflict was `raw-color-baseline.json` alone, and only because both sides re-froze it: dev's own frontend work moved `base/fieldClasses.js` 11 -> 15 gray and recorded `portal/PortalJumpToLatest.vue`, while this branch recorded `portal/PortalMarkdown.vue` 13 -> 34. The file is generated, so it was resolved by regenerating it against the merged tree rather than by hand-picking hunks — the ratchet (`tests/unit/rawColorRatchet.spec.js`) requires the baseline to be EXACT, and a hand-merged entry that is merely plausible re-permits regressions up to a stale ceiling. Both sides' `refrozen` prose is kept: dev's block plus this branch's `_2616_note`, minus its now-stale sentence about PortalJumpToLatest, which dev has since recorded itself. Result differs from dev by exactly one entry (PortalMarkdown.vue, the change this PR makes) and from this branch by exactly one (fieldClasses.js, the change dev makes). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Lh6WrChj2636YEy5UZmZ1w
This was referenced Sep 11, 2026
Merged
Merged
6 tasks
Merged
8 tasks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Integration surface for #2690, #2685, #2677, #2684, #2697, #2648. Never merged; members merge individually once green.
Sibling collision resolved on the train only (not a dev conflict): #2697 × #2685 on
docs/memory/learnings.md(both entries kept) andtests/registry.json(rebuilt from stages: base + both sides' new entries, 239 entries, consumers pass).