* fix(chat): close remaining scroll and retry gaps * fix(chat): preserve portrait artifact previews * fix(chat): reconcile media task files after path changes OPEND-2543 / OPEND-2544. A media task persisted its generation-time path, so an agent move/rename left the chat card requesting a 404 path. Resolve the task file against the project's currently registered files using a bounded size+mtime identity witness; ambiguous matches fail closed rather than guessing from filenames. Retry stays gated on run-terminal state so it cannot race the agent's own provider fallback. * fix(chat): stop scoring legacy question forms as successful runs OPEND-2549. An agent emitting the legacy XML question-form shape failed JSON parsing, and the run still terminated as a success with no artifact. Detection now recognises both shapes at the daemon boundary so an unanswered form ends the run as waiting, not done. * feat(chat): align artifact cards and memory shell with the latest design Tracks PR #7170 at8015870095. Adds a --radius-2xlarge (16px) product token instead of copying raw radii, applies it to the memory shell and summary so expanded and collapsed keep one silhouette, and unifies artifact action buttons at 28px with the top-right controls (OPEND-2560). * fix(chat): register produced artifacts on the turn that made them OPEND-2550. A finished run could leave the chat rail with no artifact card because registration depended on a workspace read that had not landed yet. Registration is now driven by the turn's own file operations, so the card belongs to the turn that produced it. * docs(chat-panel): record the PR #7170 design diff and artifact version design The design-to-implementation audit against PR #7170 at8015870095, the chat artifact version-semantics design awaiting review, and the rolling issue ledger. Plane attachment binaries stay out of git; the manifest records their SHA-256 and where they came from. * fix(chat): keep agent-written next steps on turns with no file output OPEND-2497. The host gated next-step suggestions on the turn having produced a file, so a turn that only answered in prose - add-to-chat follow-ups, a wording change, a proofread - silently dropped suggestions the agent had already decided were worth offering. Delivery evidence is now named per lane: the default lane trusts the agent's own suggestions, while host-authored lanes still require a deliverable to anchor to. Failed and canceled turns stay withheld. Clicking a suggestion still only fills the composer draft. * feat(media): publish batch coordinates for sequential image generation OPEND-2195. The chat rail could only guess how many images a turn was making by counting 'media generate' in the command string, so parallel tasks drew separate one-cell rows instead of one row of N. Tasks now carry the batch they belong to, their position in it, and its size, derived from overlapping lifetimes on the same run and surface rather than a timing constant. One-at-a-time generation overlaps nothing and reports a batch of one. Also closes the OPEND-2543 gaps this exposed: a file another task resolves by its own path is off-limits, a file more than one moved task can claim is withdrawn from all of them, and kind must agree - so two tasks recording the same size and mtime can no longer both take the same image. A rename landing just after run-terminal is picked up by a bounded reconciliation on runs this pane watched go live, instead of pinning the card to the pre-rename path. * feat(chat): rebuild the upgrade card against the latest design Tracks PR #7170 at8015870095. The CTA moves out of the balance header into a bottom row beside the explanation, and the colour contract flips from green ink on black to a green surface with dark ink. Low-balance and zero-balance share one layout. Identity branching is unchanged - the card only draws the exit and calls onUpgrade. The error card takes the 16px radius through the chat seam with the product token as fallback. The reconnect counter inherits its weight rather than pinning 500: the design raised its own body weight in the same commit, so a literal 500 here would put a heavier number inside a lighter sentence. * fix(chat): treat an empty produced-file list as unknown, not as proof A turn's producedFiles is a client-side diff of project file names taken before and after the run, not a daemon verdict. It is empty whenever the turn edited a file that already existed, whenever the baseline could not be read, and whenever a settled file-list read wins the race with file-change invalidation - the last of which is OPEND-2550's own scenario. Treating empty as 'this turn produced nothing' therefore recreated the missing card it was meant to fix. Whether a turn has an artifact is now decided by its own write/edit tool rows; the produced list only contributes when it is non-empty. Also covers what was already implemented but untested: the toolbar Share and Export panels close on a second click (OPEND-2552), and artifact previews keep their aspect ratio across image, video, HTML, doc and pending forms without leaking onto execution-record thumbnails (OPEND-2547). Two assertions that passed vacuously - comparing two heights that both computed to auto, and reading a fit attribute without its computed style - now pin real values. Next-step suggestions take the design's medium weight (OPEND-2558, partial). * fix(daemon): emit each Claude tool call once, from whichever frame arrives first The stream had two paths that could emit a tool_use - the incremental content_block_stop and the assistant wrapper frame - but the guard set was named for the incremental one and only written by it, while the wrapper read it as if it meant 'already emitted'. Every tool in a recorded session came through twice. Adding a check to the wrapper alone would have been wrong in both directions: the set is never written by that path so the check could not fire, and a canceled turn where only the incremental path arrives would have lost its one emission. The set now means what its readers assume - emitted by any path - and both paths check and write it, neither waiting for the other, so a turn that produces only one of the two frames still emits exactly once. Flipping the order exposed a second defect the old sequence had hidden: the wrapper frame often carries an empty input object, which would now overwrite the real command. The wrapper prefers the delta-assembled input when that block is still open and parses, and falls back to its own when truncated. * fix(chat): make every composer send path agree on what it will send OPEND-2551. The Send button asked whether the composer had a payload, which counted quotes, while Enter asked whether the composed prompt was empty, and quotes fold into that prompt - so the two hands disagreed and the disabled button could still send. Both now read one canSend. The deeper half: four send paths existed and only one folded the quote prefix, so sending an annotation from the Mark panel cleared the chip and delivered nothing to the agent. All four now compose the outgoing prompt the same way. Also maps the design's user-bubble ground and muted action tier to chat-scoped tokens rather than literals, splits the queue grip and action icon sizes that an override layer had merged, and hides the default design router chip while leaving the underlying plugin binding intact (OPEND-2412). * feat(chat): give question forms a real color and amount control Tracks PR #7170 at8015870095. The color question was a bare native picker and the amount question a read-only slider. Presets, the system picker and a typed hex now drive one answer, an unparseable hex disables Next, and the number above the slider is editable and clamped to the contract's min/max/step. Canonical hex is six lowercase digits at one site in the parser, matching what the native input sanitizes to, so the controlled value cannot fight the DOM. Alpha and three-digit shorthand stay rejected rather than widening the protocol; a value that cannot be normalized renders as written, never rewritten. The selected counter splits at its existing translation's count slot, so each locale keeps its own word order rather than concatenating fragments. Also fixes option punctuation colliding with the card border (OPEND-2402) and the cramped option row (OPEND-2401). * fix(chat): let option descriptions wrap and give confirmed answers a surface An option row is a button, so it inherited the global button rule that forbids wrapping - the description then overflowed the card and was clipped. The earlier min-width and overflow-wrap were correct but idle: when wrapping is forbidden outright, neither is ever consulted. The option row's existing button reset now zeroes white-space alongside the other global defaults it already undoes; the global primitive keeps nowrap, which is right for genuine single-line buttons. The confirmed-answer block had no padding, ground or radius at all, so it read as bare text rather than the design's panel. All three land through the 16px token, and an answer carrying a thumbnail drops to the 12px tier - keyed on the block actually having one, since a catalog card without a preview collapses to plain text like any other answer. * feat(chat): drop the thinking scroll window and collapse finished runs Product ruled against the design here: reasoning now reads like ordinary body text - natural height, no fixed viewport, no auto-scroll, no fade mask. The mask was what made it hard to read. The gray container and the blur-in reveal stay; the scroll lifecycle is deleted rather than left dormant. The indent moves onto the container so text does not shift when a stream ends. OPEND-2557: a finished run stayed open because details' toggle event cannot say who opened it - React writing the controlled value back fires one too, and that echo latched 'the user chose this' on the frame the shell first opened, disabling the collapse for the rest of the run. An echo always reports the value we already hold; a real click always reports the opposite. OPEND-2195: media cells are placed by their batch coordinate, so parallel generation draws one row of N instead of N rows of one. Placement is all-or-nothing - a batch missing or colliding on an index falls back to arrival order rather than silently displacing half its cells. Three failing tests were outdated rather than regressions: the completion word is deliberately withheld until a run settles, every turn now states its own terminal outcome since the pinned todo card was removed, and the icon slot moved one level down. Each keeps its original intent and gained coverage. * fix(chat): let jump-to-latest and the plan pill trade places by scroll position The two share one floating slot, but the plan pill won unconditionally - and it is present for the whole of any planned run, so scrolling up during one left no way back to the bottom. Scroll position now decides which occupies the slot: scrolled up shows jump-to-latest, near the bottom shows the plan pill. The bottom padding stays keyed on whether the turn has a plan at all, not on what is currently shown - removing it mid-scroll would shrink the scroll height, shorten the distance from bottom, and oscillate. OPEND-2546: re-quoting the same passage now says so. The decision runs in a synchronous mirror rather than inside the state updater, which StrictMode invokes twice; the toast key is a counter, since two clicks inside one millisecond share a timestamp and React would keep the first dismiss timer. The steer button carries a visible label, which is what the design gives it and the only thing that separates it from send-now - the two never render together, so a different glyph on a button seen alone would say nothing. They are genuinely different actions: steer writes into the live run's stdin, send-now stops and restarts it. * fix(chat): anchor the quote bar to the visible selection, above it Two defects stacked. The bar flipped the wrong way by default: the design puts it above the selection and drops it below only when the selection is against the panel's top edge, and the repo's own mirror gallery already renders it that way - the runtime disagreed with its own gallery page. The distance came from Range.getBoundingClientRect(), which is the union of every client rect including the zero-width caret at the selection's end. A drag that overshoots by a hair puts that caret on the next block's first line - nothing looks highlighted, but the union's bottom edge has already moved down there. Anchoring now uses the first and last rects that actually have area, and centres on the one it is touching rather than on the whole span. Together that is why a selection in a dense execution list threw the bar hundreds of pixels down onto the composer, while ordinary prose was only off by one line height and read as a spacing nit. * fix(chat): put the whole turn footer on one muted ink tier The design gives that row a single rule; we had split it into feedback and copy classes, so muting only the feedback half would have left two greys side by side. Both now consume the same token the timestamp already uses, and hover changes the ground rather than the ink - it previously jumped to near-black. Selected states keep their semantic green and red; the guard that proves it is order-sensitive for the positive rating, and the constraint is written next to the rules that depend on it. The support dialog takes the design's 16px radius through the chat seam, and its quoted excerpt is corrected - a stale quote of the source is worse than none. * fix(daemon): carry the original request when a headless continue cannot resume od run continue sends only the continue instruction, on the assumption that the session holds the request it refers to. The daemon may refuse to resume for reasons the caller cannot see - a changed model, a changed cwd, a moved cursor - and then that instruction lands in a blank session that never saw the request. Callers now declare that their message is a continuation, and the daemon seeds the original request from the rejected session's own cursor when it starts fresh. The field is optional and the behavior only fires when set, so existing callers are byte-identical. The web client already handles both branches: it sends the full transcript and the latest turn separately, and the daemon picks by whether it resumed. It is deliberately left alone. * feat(chat): follow a live thinking stream, and stop the rail cutting the mark Thinking sticks to the bottom while it streams, parks when the reader scrolls up, and re-arms when they return to the bottom or reopen the drawer. It reuses the chat log's own stick-to-bottom module rather than growing a second set of predicates: intent changes only on scroll, so content growth cannot masquerade as a gesture, and the geometry baseline refreshes on every resize settle even while parked - otherwise a parked reader could never get back. The follow is one write to scrollTop; the slow stepped scroll and the fade mask stay gone, and the height cap is max-height, so short reasoning is not padded out. OPEND-2417: the rail started at a hard-coded 25px while the status mark is centred in its row, so the mark's bottom moved with row height and crossed the rail the moment a title wrapped to two lines - and this PR's 12px to 13px title bump had already eaten most of the single-line clearance. The mark now sits at a fixed offset like the plan card's, and the rail derives its start from the row's own padding, slot and gap. Single-line rows land on 25px exactly as before; the difference is that they no longer drift when an input changes. * test(chat): measure next-step weight through the real cascade The previous test injected only the component's own module, so it measured a product that does not exist: the global button rule already sets weight 500, and the declaration added to 'fix' it changed nothing. The test now injects the global reset first, in the order the app does, and pins the declaration's actual job - holding the design's value on the component itself, so it survives the day that legacy global button styling is cleaned up. * fix(chat): let a long next-step suggestion wrap instead of being cut off The suggestion is the sentence that will be sent; a reader who cannot see all of it cannot decide whether to click it. Two things were cutting it: the text layer asked for a single line, and the global button rule leaked nowrap, a fixed 36px height, a line-height of 1 and centred alignment into the row - the design's own button reset carries none of those. Both are undone at the component, leaving the global primitive alone, where nowrap is right for a genuine single-line button. The arrow now aligns to the first line rather than floating to the middle of a wrapped row, following the queue row's written precedent for the same shape. A single-line row still measures 36px, so nothing moves until a suggestion is actually long enough to wrap. * feat(chat): let a long single-choice list collapse behind more options A select with more than a handful of options now renders as a menu: common entries first, the rest behind a host-owned 'more options' toggle, capped at a scrolling height. The trigger is the option count or the presence of the new grouping fields, not the type alone - a legacy two-option select keeps the flat list it has today, and the one real product select (a hundred voices) gets the menu without the model changing anything. The toggle's own copy belongs to the host and is generic; a model's group labels still render as headings inside. The authoring rules now cap options at six or seven, ask for plain language over jargon, and put a length target on an option label with long text moving to its description - the missing constraint behind suggestions that had to be truncated. They live in the general form layer, so a mid-conversation form is covered rather than only the opening brief. Those rules and the type list exist in six hand-maintained copies across the daemon, contracts and a bundled skill, with no shared constant; a type could be dropped from any one of them and every test stayed green. A parity spec now pins all six against the renderer's own union rather than against each other, which six copies going stale together would satisfy. * feat(chat): show a tool call the moment it starts, not when it returns An in-flight call rendered nothing at all, so a run that spent fourteen minutes inside one command showed the previously finished rows and a ticking total and nothing else - which is what 'it ran forty minutes and produced nothing' was describing. Everything derivable from the call itself now appears immediately; only what genuinely needs the result waits for it. The shell's span also counts from the in-flight call, so a long stall stops reading as no progress. The row keeps its identity across both phases, so a result swaps the state of the row that is already there rather than adding a second one. Pending means the call has not returned, which is not the same as still running: a cancelled turn leaves rows pending, and they settle to a neutral mark rather than spinning forever or claiming a success or a failure that never happened. The elapsed slot is present but empty while pending, which is what keeps the chevron from shifting when the value lands. * feat(chat): give the panel the design's typographic baseline Every component measured byte-identical to the design and still read wrong, because the panel inherited the app's baseline rather than the draft's: weight 400 against 500 and 14px against 13px. Line height was already right. Both now land on the chat seam, so the panel matches the draft while the sidebar, settings and home keep the baseline they were designed against - the draft only ever covered this panel. The mono face moves with it. The design relabels the same font file from 400 to 500, which is bookkeeping rather than a new weight: with one static face the glyphs are identical either way, and the label simply has to agree with the baseline that requests it. Doing one without the other would leave every elapsed time, path and hex asking for a weight the face does not claim. * fix(prompts): tighten the new form rules and raise the charter ceiling The five rules landed as explanatory prose and pushed the slim charter past its byte ceiling, which is paid on every turn of every run. Rewritten as short imperatives they cost 644 bytes instead of 1,315. That still does not fit: the charter sat 44 bytes below the ceiling, so no amount of compression would have. The rules cannot move somewhere cheaper either - no conditional section can predict that a turn will emit a form, and the on-demand atom that carries them for OD Next is not reachable from a skill-less slim run. The ceiling moves, documented beside the constant in the format the previous raise used, to the next step rather than to just fit: 44 bytes of slack is what made the next sentence hit a wall. * fix(chat): stop our own scrolling from reading as the reader's Positioning a question form animated the log upward, and a frame of that animation looked exactly like someone scrolling up, so following stopped. On a quiet turn it healed itself - the animation finished at the bottom and re-armed - which is why it survived. While the model is still streaming it does not: the destination was fixed when the animation began and does not follow the growing content, so it lands somewhere that is no longer the bottom and the escape stands. Nobody touched anything. Positioning is now instant, which collapses the window to nothing rather than trying to recognise and ignore our own frames inside it. The two near-identical copies of this logic - one already fixed in an earlier pass, one not - are one function now, so they cannot disagree again. Recovery had the mirror problem: it demanded the layout hold perfectly still, and a stream never does, so a reader scrolling all the way back to the bottom had their gesture thrown away on the frames where content grew. Reaching the bottom while the bottom is moving away can only be a real gesture; reaching it because the content shrank underneath still is not, and stays refused. * fix(chat): stop a cancelled turn spinning forever, and give the stream its weight An image cell kept spinning after its turn was cancelled: the row knew the call had not come back but not whether anything was still working on it. It now takes the same turn-state gate the tool rows use, so tool and image rows share one vocabulary end to end - same field, same mark, same question asked of the turn. The thinking stream's body weight had to be written against the stream box rather than the paragraph class: the text inside belongs to a different CSS module, so the obvious selector would have matched nothing. The test proves the two class names differ rather than assuming it, and pins the specificity margin so a later simplification back onto the paragraph class fails loudly instead of depending on bundle order. Three fixtures were typed strictly enough to catch the new required field, and two more were asserting a shape they did not have - one carried a property that does not exist on the type at all, hidden behind a cast that is now gone. * feat(web): measure the chat panel's own runtime health Nothing measured the panel as a thing: long tasks fired without a conversation or run attached, boot timing covered the page rather than opening a conversation, and a panel that mounts and then renders nothing was invisible. Seven events now cover first paint, DOM growth, memory pressure, stream health, interaction latency, protocol anomalies and recovery. They are built to be findable, not just countable. Every event carries the run id - which is simultaneously the PostHog join key, the Langfuse trace id and the diagnostics-bundle handle - plus the conversation, project, agent, model and channel. A pressure event carries the heap trend and the breadcrumbs leading up to it rather than only the moment it broke. Timing events declare whether the measurement can be trusted, so a reading taken before stylesheets landed is excluded rather than reported as a regression. They are also built not to become the problem they measure: interaction latency filters in the browser, a run with no jank emits nothing, and anomalies dedupe per run so a render-time detection cannot end up measuring typing speed. * docs(chat-panel): record the component gap audit, scroll research and dispatch log The 24-component / 89-state audit against PR #7170, the streaming stick-to- bottom research that recommends extracting our own machine rather than adopting a library, and the rolling dispatch ledger with the product rulings that override the design draft. Attachment binaries stay out of git; the manifest records their SHA-256 and where each came from. * fix(chat): show the retry button on a failed message, and animate the audio waveform A message that failed to send offered no way to retry: the action row hides itself with opacity, and opacity does not just fail to inherit - it composites, so a child cannot opt back in. The design hides the individual controls and excludes the retry, which is what this now does, along with the pointer-events the same rule carries: the invisible copy button used to be clickable. The audio waveform never moved. The keyframes it referenced did not exist and the per-bar offset was never written, so a stalled render was the only possible outcome. Bars now stagger off their own index, honour reduced motion, and the play control sits inside the row as the design nests it rather than beside it. Plan steps take the design's explicit 400. They inherited 400 before the panel baseline moved to 500, so they were accidentally right; afterwards a not-yet-current step would have sat one notch below the current one instead of two, flattening the card's hierarchy. * feat(chat): route artifact cards by the version each kind should show An HTML card's face is the screenshot taken on the turn that made it, while clicking it opens whatever the workspace holds now. The two deliberately disagree - the card is a record of that turn, the click is a door to the current file - and the comments say so, so nobody reconciles them later. An image card is the opposite: face, click and export all read that turn's own bytes, which is what stops a later overwrite from rewriting history. With no snapshot - an old conversation, a capture that failed, no renderer, a full quota - HTML shows the live latest and an image shows the current file. Neither says anything about it: a card that announces its own failure is worse than a card that quietly shows the best thing available. A snapshot URL is only handed out once the snapshot is ready. A half-written one renders as a broken image, which is worse than the fallback it replaced. * feat(desktop): capture a first-viewport thumbnail without growing to the page The existing image export measures the document and grows the window to it, up to twenty thousand pixels, which is the long strip it is meant to produce and exactly wrong for a card cover. A capture mode now says which of the two is wanted: the thumbnail path fixes the viewport, never issues the height probe, freezes animation and transitions, tells the page motion is unwelcome before running a script that finishes what is already animating, and captures the first screen only. The export path is byte-identical - the branch happens before the code it used to share. Resource waiting learned to stop at the first viewport, because a cover that waits for assets below the fold pays for page length it will never show. The renderer reports why a capture failed rather than returning a blank or fabricated image, so a card that cannot get a cover knows to show the live file instead of a broken one. * feat(daemon): keep an immutable copy of what each turn produced A chat card read the workspace file by path, so overwriting an image rewrote every earlier card that showed it. A turn's output is now copied into a content-addressed store the moment it exists and referenced by digest, which separates three things that used to be one: where the file lives now, what it contains now, and what this turn produced. Media capture takes the provider's own buffer before the file is written, so exactness is structural rather than a re-read that could race. The general path copies at the run's terminal boundary and stats either side; drift fails the snapshot rather than installing the newer bytes, because a snapshot of the wrong version is worse than no snapshot. Where both paths cover one file the media bytes win, scoped to the run so an earlier turn's copy can never stand in for this one's. Recovery is deterministic because byte-sourced intents record their digest up front: an interrupted capture either completes from what is on disk or is marked interrupted, and a path-sourced one only completes while the source still matches. Sweeping is off until enabled; identity survives a rename and a tombstone releases the path without resurrecting the old row. * fix(chat): pick the main artifacts when a turn declares nothing Declaring was meant to be the only way a card appeared, but a turn that edits without creating declares only a quarter of the time - the instruction asks for 'a file you created this turn', and a small edit does not read as creating anything. So most edit turns showed nothing, and the earlier fix for that showed everything instead: a page, its script, its stylesheet and its images all became cards, which is the pile the marker existed to prevent. Silence now gets an answer rather than a blank or a pile. Pages and documents win; if a turn wrote none, its images stand on their own; stylesheets, scripts, icons and data files never do. An unrecognised extension counts as a deliverable, because the wrong way to fail is to quietly hide what someone just made. The instruction now says that changing a file is delivering it, and that declaring narrows rather than being the only door. Five places claimed otherwise, two of them tests that had pinned the old sentence in place. * fix(chat): put the tool rows and step interludes on the muted tier A tool row read as dark as the step title above it, so the record had no depth: the thing that happened and the thing that describes it weighed the same. The row, its elapsed time and its icon now share the muted ink the design gives them, and a row that failed keeps its own colour. Interludes between steps were meant to be smaller and lighter than the opening line, and were neither. The rule excluded any turn that had a plan - which is most of them - while the thing it was written to protect, the opening line, is already excluded by being the first child. Removing that exclusion is what makes the summaries between steps read as summaries. Thinking now sits on the same surface whether it is still going or already done; it used to gain a background while streaming and lose it once expanded, so the same passage changed shape when it finished. The class that was supposed to carry that surface had never existed, so the rule attached to it had never run - and a CSS Module proxy hands back a class name for any key, which is why no unit test could see it. * fix(chat): hang the chain on steps only, and align surfaces to their own column The vertical rail and its indent belong to things on the chain - the steps of a plan and the summaries between them. Thinking, tool rows and prose at the top level were getting both, so a passage that had nothing to do with any step still read as hanging off one. A step now says so itself, which means a block type added later stays off the chain until someone puts it there, rather than joining by accident. The grey surfaces were positioned to put their text on the column, which left the box itself jutting eight pixels past the rows around it - and since the offset came from nesting depth rather than from where the sibling rows sit, two surfaces at different depths missed their neighbours by different amounts. The box now lands on the column and the text keeps the padding the design gives it. A failed command row says it is failed rather than being inferred from having a reason attached, which is also what lets the failure colour be written the way the design writes it. * fix(chat): let the next-step button size itself the way the design does The button carried a minimum width and a horizontal padding that appear nowhere in the design, and the round that added them wrote both into a test, so the invented numbers were guarding themselves. The padding was also the wrong tier: the design's footer buttons are all small, and the small tier overrides the base padding it was copied from - which is why the button read as oversized rather than merely wide. Only the height stays, because that is the one number the design specifies there, and it explains why: a height written down does not drift when the font size moves, while one grown from padding does. That drift is exactly what happened when the panel baseline went to 13px. The shared button already supplies the padding, size and weight the design gives this tier, which the test now proves by removing that stylesheet and watching the assertions fail. * fix(daemon): stop teaching the model a protocol we retired The critique grammar kept appearing in answers, and four rounds of stripping it out never held, because the feature had two entrances and retiring it closed only one. The protocol itself stopped being injected; the atom carrying its prose stayed in every design run's stage list, telling the model to follow a tagged protocol that was no longer there - so the model wrote the tags from the description. None of the attribute names match the real protocol; every one of them traces to a sentence in that document. The body now travels with the protocol: injected together or not at all, from the decision the server already made rather than a second copy of the predicate, since a second copy is a third entrance. The document itself describes what to check rather than what to emit, and no longer forbids prose outside an envelope - which is also why a turn could finish with no closing summary at all. Stripping is fixed where it failed and extended where it was absent: a held fragment no longer gives up the moment a tag carries an attribute, thinking is stripped before it is persisted rather than only on its way to the screen, and the client covers the execution record and not just the prose beside it. A spec now asserts that the assembled prompt contains none of this grammar, so a future path that reintroduces it fails rather than reaching a user first. * fix(chat): stop the feedback panel yanking the view to the top Rating a turn scrolled the page even when the reason panel was already fully on screen: it asked for the panel's top edge to meet the viewport's top, unconditionally, so a panel sitting comfortably in the middle still dragged everything the reader was looking at. Asking for the nearest position instead leaves a visible panel exactly where it is and, when one really is off screen, moves the least amount that reveals it - the browser's own primitive rather than a hand-rolled visibility check. It was also the one programmatic scroll on the chat log still animating, which is the invariant the question-form fix wrote down: a scroll we start ourselves is instant, and anything that wants animation releases follow first. * fix(chat): keep the quote bar with the start of a long selection Dropping below the selection exists to get out of its way, which works for a phrase and stops working for a selection that fills the screen: the bar got out of the way and landed most of a screen from anything the reader was looking at, on top of the artifact preview. A selection taller than half the panel now keeps the bar at its start when it has to flip, which is where the design anchors it in the first place - flipping below is a correction for having no room above, not a change of reference point. Short selections still clear the whole passage, including a two-line one pressed against the top edge, which is the case the new rule must not capture. * test(chat): pin the thoughts icon against four ways it can silently degrade The icon is already the one the design hands over, byte for byte, so this round has no implementation to change - which is exactly the case where a spec earns its place. A name that stops resolving does not throw: it falls through to a stroke fallback with no case for this one and renders nothing, leaving a blank where the icon was. Asserting the name would not catch that, so the spec asserts the rendered path, entire rather than by fragment, since the filled and outlined variants agree on precisely the strokes a fragment would match. It also pins that the in-progress state keeps its spinning orb, so a later attempt to make the two states consistent replaces motion with a static glyph loudly instead of quietly. * fix(daemon): stop an unfinished marker from swallowing the answer after it An upstream reconnect cut a next-step marker mid-attribute, and the scanner tracking quotes paired that unclosed quote with the next tag's, so it never found an end: every closing bracket after it counted as text inside a string. The buffer grew past its limit and the overflow branch handed the whole thing to the screen, and to the database, where a refresh could not remove it. A value cannot contain a newline or a raw angle bracket, so either one proves the quote never closed. Seeing one now retires just the broken tag and keeps reading, rather than waiting for an end that cannot come; overflow discards rather than releasing. A held fragment at end of stream is discarded too, the way the artifact marker already did. A reconnect is also no longer dropped on its way to storage. It is a real upstream event that happened once, not the polling noise the rest of that list suppresses - and without it a conversation that was interrupted and rewritten reads, on reopening, as though a second answer appeared from nowhere. The qoder stream decodes with a per-handler decoder instead of converting each chunk alone, so a character split across chunks survives. * fix(chat): give the thoughts drawer one box instead of two Hovering the thoughts header lit a background that started seven pixels left of the passage underneath it - twenty-nine when nested - because the header row stretched its hover target to the shell edges while the body was pulled back to the column. Both were right on their own and wrong together. The column now belongs to the drawer, and the header inside it carries none: the same arithmetic reaches the same place, so nothing about the title or its icon moves, and the two backgrounds share an edge because they share a box. Which is what the thing is - a panel with a lid, not a row that happens to be followed by a panel. The row rule that stretches hover targets is untouched; removing it turns eight assertions red, which is the point of keeping it. * fix(chat): say a generation failed while it still might be retried by the agent A cell that failed mid-run already could not be clicked - the host withholds the callback until the turn settles, so the agent's own provider retry cannot race a manual one. But it still read 'Retry' next to a retry icon, so the only thing the reader learned was that the button was broken. It now says what actually happened, as a statement rather than a dead control, and becomes a real button once the turn is over. Over includes cancelled and failed: what the gate protects against is a second retry running beside the agent's own, and once the turn stops there is nothing to race. Wanting one image after abandoning the batch is a normal next step. The wording reuses the label the tool rows already carry in every locale rather than storing the same word a second time. * fix(web): distinguish auto-expanded folds from user-opened ones `defaultOpen` is only read on the mounting frame, so a fold that opens because its subject is running has no way back: when the subject finishes and the key has not changed, the same instance keeps the same `selfOpen` and stays sprawled open. `lifecycleOpen` follows the subject's lifecycle on every change instead of just the first — until the user touches it, after which the fold is theirs and neither direction is forced back. Telling a real click from React's own write-back is the whole problem: `<details>` fires `toggle` for both. The predicate compares values, not occurrence — an echo reports the state we already hold, a click reports the opposite — and reads it through a ref, because the memoised handler's closure would still hold the first frame's value by the time a queued echo arrives. Same predicate ExecutionShell already uses, verbatim. Opt-in: every caller that does not pass `lifecycleOpen` keeps its exact current behaviour, and controlled folds never reach it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YLzsEWJ1UAjk9WjXEozQiW * style(web): drop the step rail and restore the hover overhang The designer retired the grey vertical line that chained the steps together, so both `::before` pseudo-elements go, along with the five variables that existed only to place them and the two `position: relative` they were positioned against. Nothing is kept "in case the line comes back" — re-deriving it from a fresh measurement beats reading stale constants. The status dot's own guard survives the line that used to clear it: it was never about the rail, so its test moves to a file named after what it actually pins. The offset stays 1.5px even though the step title has since grown to 13px, which puts single-line centring 0.75px away; the assertion pins "under a pixel of drift" rather than pretending the two agree. The hover background reads slightly larger than the panel below it because the design gives them different insets, not because anything is added: the row bleeds 7px to each side and the summary pads 7px back, while the body panel sits flush at 8px. An earlier attempt zeroed the summary's leading inset to line the two up, which made them one rectangle and read as the panel itself flashing. Text lands exactly where it did. Indentation of the between-step notes keeps its 22px for now — the design justifies it twice, once independently and once by the rail — and the second half of that rationale is gone. Flagged for a ruling. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YLzsEWJ1UAjk9WjXEozQiW * feat(web): show elapsed time on rows that are still running A row that has been running for twenty-eight minutes showed nothing at all until it finished, which is exactly backwards: the number matters most while the user is waiting for it. The design leaves the slot open for this — `ToolRow` needed no code change, only data. One live end-of-span for the whole turn, `turnIsLive ? nowMs : endedAtMs`, the same sentence the shell header already uses. Steps, thinking and tool rows all read it, so none of them can settle to a different endpoint than the one they were counting toward: a step that reported "1m 2s" while running would otherwise collapse to "2.0s" the moment the turn stopped, because the settled path counts to the last timestamped event rather than to now. The live value is computed after span bookkeeping, never into it, or the silence-detection span would be identically zero. No new timer: every number is `nowMs - startedAt` off the one interval that already drives the shell header, and a test pins that rendering four live rows calls `setInterval` no more often than rendering one. Rows whose start is unknown still print nothing rather than inventing a zero. Known gap: a tool row can tick back by one frame when its result arrives stamped earlier than the last tick. Strict monotonicity needs a per-row high-water mark, which means component state; the test pins the magnitude instead so the drift cannot grow silently. Also lands the one line the fold-lifecycle change was waiting on: a running todo drawer now follows its subject's lifecycle instead of only its first frame, so it stops staying sprawled open after the step finishes. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YLzsEWJ1UAjk9WjXEozQiW * docs(chat): pin where the failure card contradicts the daemon A colleague hit a TLS certificate failure and got a card offering Retry as its primary action. The daemon had already classified it correctly and said on the wire, three separate ways, not to retry. The web side reads none of those three and re-derives the answer itself. The survey is exhaustive rather than sampled — all 67 failure reasons run through the real resolver — because the question product has to answer is not "what about certificates" but "how far apart are the two verdicts". Forty cells offer Retry against an explicit not-retryable; thirty-two of those are reachable under an opaque error code. Six more are not missing rows but opposite conclusions, which is the cheaper half to fix: no new copy is needed to stop saying the wrong thing. The blocker is structural. Nine primary actions exist and none of them is "go fix your environment", while the ladder's own comment claims to cover that rung. The error catalog already ruled on the shape twice; only the button has nowhere to land. The daemon-side test locks the classification that is already correct, so the gap cannot later be misread as a backend bug. The web-side red test stays out of the tree until the ruling lands. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YLzsEWJ1UAjk9WjXEozQiW * docs(run-errors): bring the error-UX design back into the repo The design that settles the failure-card questions has been living only in Feishu. The implementation audit's header says the source file sits on this branch, but git has never held it — the audit has been pointing at a file that does not exist for a week. That matters more than usual right now: today's survey of the failure ladder rediscovered a gap this design had already ruled on. The environment class was never an open question — S30 names the copy, the slots it interpolates, and the button order. The audit had already recorded that neither of the two actions those scenarios need exists in the codebase. Feishu stays authoritative; this copy is for reading alongside the code and for grep. The screenshots are short-lived signed links, so only their descriptions survive here. One line in S30 is now known to be stale: the daemon does classify certificate failures, and has since the detail was introduced. The web side never reads the answer. Left as written rather than edited silently, since the doc is under review. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YLzsEWJ1UAjk9WjXEozQiW * fix(chat): pin every newly sent message to the top, not just typed ones Two independent defects were splitting the behaviour in half, which is why it looked intermittent. Whether to pin was something each send site had to declare by raising a flag, and that flag was raised in exactly one place: the composer's own callback. Seven of the eleven entrances that put a new user message on screen never went through it — question-form answers, the home page's first send, annotations, anything the queue released, resume, and image retry. Pinning now follows from the transcript itself: the trailing user message changed identity, so a new turn is on screen, regardless of which button produced it. New entrances are covered by construction, which matters because no one has ever remembered to add the line. The other half is the reason it varied run to run. The pin scrolled smoothly, and nothing distinguishes our own animation from a user dragging the scrollbar — position is all either one reports. Mid-animation frames sit far outside the release tolerance, so the first one dropped the pin and froze the tail spacer; the final frame lands exactly at the bottom while the reply is still empty, which re-arms stick-to-bottom and drags the message back off the top. A reply that starts quickly grows the log past that point and the pin survives; a slow one does not. Same code, opposite outcome. Self-initiated scrolls are already required to be instant — the question-form anchor was moved off smooth for this exact reason — and this was the last one still animating. Visible change: the pin now lands on the frame instead of gliding, losing roughly 300ms of animation. That animation was the defect. Also updates the feedback-panel assertion left behind byc121d81b17, which changed that scroll to nearest/auto without carrying its test along. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YLzsEWJ1UAjk9WjXEozQiW * fix(chat): anchor the quote bar to highlighted text, not to a swallowed box The bar was not being clamped to the bottom of the panel — it was faithfully anchoring to a box that had no highlight in it at all. Selection geometry came from the whole Range's client rects, and per CSSOM that list contains the border box of every element the Range fully encloses, mixed in with the text line boxes. Dragging a selection a hair past the end of a bubble — invisible on screen, since what follows is blank — swallows the tail spacer: full width, real height, not one character. The earlier fix asked only whether a rect had area, so that box passed as the selection's last line, and the bar chased it down to the composer and centred itself on the panel. Painted text is what "the visible selection" means: no text, no highlight, no anchor. Both edges are found by walking a short way in from each end of the Range rather than over the common ancestor, which grows to the whole log during a cross-message drag and would re-measure every text node per frame. The bar also has to leave the pane. It is positioned in viewport coordinates and declares `position: fixed`, but an ancestor carrying backdrop-filter becomes the containing block for fixed descendants — so those coordinates were being read as pane-relative, shifting the bar down by the pane's top edge and letting the pane's overflow clip it. The composer is portalled out for exactly this reason and that rule says so in its own comment. Adjacent, deliberately left alone: a quote taken from the user's own message carries a null message id, because the lookup reads the attribute the assistant bubbles use. Its own red test, its own change. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YLzsEWJ1UAjk9WjXEozQiW * fix(chat): stop disabled options reacting, and say when a question was skipped Two defects in the same component. An option that cannot be chosen because the limit is reached was picking up the global `button:disabled` fill, which outranks the chip's own transparent background, and then `.qf-chip:hover` outranked that in turn — so the row carried a permanent grey pill AND still lit up under the cursor. Disabling a button stops clicks, never styling. The design draws no disabled state for options at all: one background, transparent. The repo had already fought this exact leak for the visual-direction cards and won it back with a second class; the option row never got the same treatment. Skipping was reported as "Answers sent". Skip is not a separate path — it is a submission whose skipped questions carry no value, and the text the model receives spells out `(skipped)` for each one, so nothing is being hidden from the agent. The summary was dropping valueless rows entirely, which erases what the user chose to skip and, when every question is skipped, leaves nothing at all — at which point the render falls back to a line that is false by construction, since its condition is "no answers exist". The summary now reads back what was sent. Three cases, not two: a value is read out, a submitted-but-empty question says Skipped, and a question absent from the submission stays silent rather than announcing a decision the user never made. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YLzsEWJ1UAjk9WjXEozQiW * docs(chat): record the ruling that the quote bar stays on screen The half-a-panel threshold it replaces was guessing whether the selection was too big to dodge. The question that actually decides where the bar goes is which part of the selection is on screen, and that is measurable. The threshold was also reading contaminated input: until this morning a selection could swallow a full-width spacer sitting at the bottom of the log, which made every such selection taller than half the panel by construction. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YLzsEWJ1UAjk9WjXEozQiW * test(chat): retire two assertions the D3 reversal left behind Both reds outlived their premise; neither implementation was wrong. The image row's running mark was deliberately put behind the same turn-state gate the tool rows use, so that a cancelled turn stops spinning. That change updated the tool-row fixtures in the shared primitives file and missed the image-row one — an omission, not a contract. The fixture now says whether the turn is alive, and a counter-case pins that staying silent gets the neutral mark, so the gate cannot quietly go back to spinning unconditionally. The thinking-inflation case was still written for "a call that has not come back does not get a row". Product reversed that on 2026-09-02: a call renders the moment it goes out. Grouping then follows the rule the module already states — a tool row between two stretches of reasoning makes them two stretches, so two cells. The old case also called 10s an undercount, which only held while both halves shared one cell; once split, the second cell owns that gap alone and 10s is the true figure, now asserted positively so the file cannot go green by handing out no numbers at all. The docblock that sent three agents hunting in the wrong place said a call still in flight does not get a row. It says the opposite now. D3 and B8 in the spec were still stating the retired rule too. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YLzsEWJ1UAjk9WjXEozQiW * style(web): win the option row's colours back from the global primitive The same `button:disabled` declaration that was repainting the option's background also greys its title, while the description underneath keeps its own colour — so the title read fainter than the text it was meant to head, with the opacity on top of that. It reaches a second victim as well: a chosen option in an already-submitted form is styled by a rule of equal weight and loses the same way, so answers the user actually picked were being drawn as if unavailable. Hovering a chosen option also flashed the checkmark's border, because the design wins that with two ancestors we dropped when porting; without them the rule sits below hover instead of above it. Every fix here restores a design selector rather than inventing weight. Where the previous pass had reached for `:not(:disabled)` to lift hover above the checkmark rule, that lift is undone: hover goes back to what the design writes, and the disabled case takes its own selector — the same shape the background pair already uses. The chosen state then sits strictly above both, rather than tying and depending on source order, which is how this class of leak got in the first time. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YLzsEWJ1UAjk9WjXEozQiW * feat(chat): give proxy and certificate failures the card the design specifies Five backend reasons — certificate, proxy, network, host policy, local storage — had no row on the web side at all, so a failure the daemon had named precisely arrived as "task failed" with a Retry that could not work. The design settled this months ago (S30): name the fault, name the likely cause, and lead with the action that can actually fix it. The card now says what happened and offers Settings first, with Retry kept beside it — deliberately, because upstream folds two different faults into one message and one of them really is a blip. The copy interpolates the cause rather than repeating a near-identical sentence five times, so nineteen locales translate five short phrases instead of five paragraphs. Nothing in it promises that configuring a CA will help; a test holds that line, since upstream has versions where it does not. The unclassified fallback stated as fact that nothing had named the failure. That was false for exactly the cases it was hurting. It now asks the daemon: only when the daemon both named the reason and ruled retrying useless does the card drop out of the retry rung; a genuinely unknown failure keeps today's behaviour, because most of those are one-offs. Asking whether the daemon named it has to come first, and is not decoration: the classifier's own fallback stamps `unknown` with retryable already false, so reading the verdict without that guard would strip Retry from precisely the case that deserves it. Two things this does not reach. The verdict fields are not yet carried on the SSE end frame or the persisted error event, so the new branch is inert until three call sites outside this change forward them. And `local_storage_failure` is a local disk fault wearing a sentence about corporate proxies; it is marked in the source as needing its own copy. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YLzsEWJ1UAjk9WjXEozQiW * fix(chat): anchor the quote bar to the part of the selection on screen The rule it replaces was guessing: a selection taller than half the panel was assumed too big to dodge, so the bar went back to the start. What actually decides where the bar belongs is which part of the selection the reader can see, and that is measurable — intersect each edge line with the visible area and anchor to whichever edge survives. A selection entirely on screen is untouched, down to the pixel. When the first line has scrolled above the fold the bar stops reaching for it and sits under the visible last line instead; when the last line is below the fold it falls back to the visible start. With both ends off screen there is no edge to hug, so it takes the top of the visible area and centres on the panel — the horizontal coordinates of off-screen lines are meaningless, and the top stays clear of the composer the reader is about to type into. The invariant that the bar's rendered box always lands inside the visible area was already true — edge clamping saw to that — and is now pinned across forty geometries so a future placement cannot skip the clamp. What was broken was the other half: being anywhere near the selection. This reverses an earlier call that a tall but fully visible selection should anchor to its start. That call came from a report of the bar flying most of a screen away and landing on an artifact preview, but the cause was the bar anchoring to a swallowed element box, fixed this morning. With the last line now guaranteed to be painted text that is genuinely on screen, hugging it cannot throw the bar off screen — and it is where the cursor just was. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YLzsEWJ1UAjk9WjXEozQiW * style(web): follow the draft's chosen-option weight, and stop hover erasing the tick The 600 weight on a chosen option was not a transcription slip. The draft said 600 when the panel was built, and changed it to 500 a week later along with the ink token. We picked up the colour half of that change and missed the weight, so the two halves of one edit have been out of step since. The history is now in the source, or the next reader will "fix" it back. The own-answer tick was losing to hover outright, not tying: `:hover` carries a class-level slot of its own, which puts the chosen rule a full step below. Same defect the fixed options had, one box later — the expanded row renders a different element, and the earlier pass only reached the first one. The draft wins this with two ancestors, so that is what restores it, and the guard asserts strictly-greater rather than merely checking the colour. A tie also paints correctly today, purely because of source order; the colour assertion cannot tell the two apart, and the ordering is exactly what breaks the next time someone moves a rule. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YLzsEWJ1UAjk9WjXEozQiW * test(chat): calibrate the cascade ruler against the spec The helper that decides which CSS rule wins was counting `:not()` itself as a pseudo-class. The spec says the functional pseudo contributes nothing and is replaced by the weight of its heaviest argument, so every `:hover:not(:disabled)` in the tree read a full step heavier than a browser would. `:is()` and `:has()` had the same fault, `:matches()` was not recognised at all, and `:where()` — correctly zero — was matched by a pattern that only survived one level of nesting. Pseudo-elements were being deleted outright rather than counted as elements, and the single-colon legacy spelling was counted as a pseudo-class, which is wrong in both columns at once. Selector lists were summed instead of taking the heaviest branch. A ruler that reads high hides real leaks; one that reads low invents them. Either way the readings are not evidence, and this family is where several of today's cascade fixes were judged. So the reading change was measured, not assumed: both rulers were run over every selector in the loaded sheets (313 branches move) and then over every resolved property of the question-form tree in each state (7619 readings, none move). The zero is only meaningful because a control case with a deliberate disagreement was checked first — it goes red, so the comparison can see a difference when there is one. Nothing this family asserted was resting on the fault: none of the rules it actually compares contain `:not()`. Assertions cite the spec sections they come from, and the cases the implementation does not model are named in its header rather than guessed at. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YLzsEWJ1UAjk9WjXEozQiW * docs(web): correct why the hover and disabled rules stay apart The comment claimed merging them would outrank the chosen-option rule. `:not()` carries no weight of its own, so the merged selector ties with it instead — and wins today only because of where it sits in the file. Which is the actual reason not to merge: a tie decided by source order is the trap the spec's own note forbids, and nothing goes red when someone moves a rule. The pair above ties too, but a test pins which of them wins, because a disabled button still matches `:hover` — that being the defect this pair was written to fix. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YLzsEWJ1UAjk9WjXEozQiW * feat(chat): carry the daemon's failure verdict to the error card The classifier decides two things about every failed run — whether retrying can help, and what the user should do — and the card was seeing neither. It re-derived the answer from the failure detail instead, and disagreed with the daemon in forty places. `failureAction` at least reached the status endpoint. `retryable` existed only inside the classifier: the finalize path read the classification and dropped that field on the floor, so no transport could have carried it. Both now ride the end frame, the status body, the durable run state and the persisted error event. All four, because each is somebody's only path: live listeners take the frame, a reconnect re-reads the status body, a restarted daemon restores from durable state, and reopening the conversation replays the stored event. Missing one is a failure that silently downgrades under a condition nobody reproduces. Every read and write of `retryable` tests for a boolean rather than truth, since `false` is the whole point of sending it and `x ? {x} : {}` would eat exactly that. The card needs no change — it already asks for the verdict, and the answer now arrives. Around twenty of the mismatched cells resolve from this alone; the rest have their own web-side row and need those rows corrected, which is a separate judgement about which side is right. Absence still means absence: a replayed event from before this change carries no verdict and keeps today's behaviour, pinned by an exact-shape assertion rather than a negative match, which an added optional field would have made vacuously true. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YLzsEWJ1UAjk9WjXEozQiW * test(chat): stop the cascade ruler dropping and misfiling rules Selector lists were split on a bare comma, so any list nested inside `:where()` or `:has()` came apart. The damage is not that the pieces throw — it is that they parse. `.markdown-rendered :where(p, li, …, h6) > code` becomes `li`, `td`, `h1`… , each a perfectly valid selector, so a rule meant for inline code is applied to every list item and table cell in the tree while the actual `<code>` matches nothing. One rule, misfiled and dropped at the same time, silently. Four real rules in the loaded sheets are affected. Two corrections to what was reported earlier: the parser tolerates the unclosed bracket rather than throwing, so the `:has()` case quietly loses one branch instead of failing outright; and the earlier explanation of the local rulers' error was wrong — parameterised pseudo-classes are skipped entirely, not mistaken for type selectors. The catch that hid all this now swallows only vendor pseudo-elements, which are the sole selectors that legitimately fail to match. It earned that during verification by naming a fragment that used to vanish without a word. Eight test files each carried their own copy of the specificity calculation, each with its own faults: pseudo-elements counted in the wrong column, parameterised pseudo-classes ignored, attribute values read as element names, `:where()` not zeroed, one that could not see an id at all, and one that counted every class twice. They now share the calibrated implementation, through a shape-preserving adapter so that not one assertion changes — the readings were surveyed first, and of the sixty-five selectors these files actually ask about, three move and none of them flip a verdict. The worst of the eight was never called. It has been replaced rather than deleted, so it is a correct tool rather than a loaded one. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YLzsEWJ1UAjk9WjXEozQiW * test(styles): drop the local specificity helper nothing calls Both functions were dead. Keeping a second copy of a calculation that has one calibrated implementation is how the eight divergent copies happened in the first place; a caller that needs it should reach for the shared one. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YLzsEWJ1UAjk9WjXEozQiW * docs(chat): record that artifact cards open the latest file, always The thumbnail is the snapshot; the click is not. HTML and images follow the same rule, so the versioning design's read-only snapshot tab is retired. Recorded because it was nearly built: the doc listed the tab as outstanding work and it read like a gap. It was a decision. An item a design names but no one has implemented is not automatically a debt. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YLzsEWJ1UAjk9WjXEozQiW * style(web): finish porting the tool icon rule, not half of it The draft changed size and colour in one declaration when it was refined. The port cited that exact line and brought the colour across, leaving the width at the previous draft's 14px — the comment in our stylesheet points at the line that says 16. The stroke baseline moves with it. 1.75 is in user units, so what reaches the screen is that times the viewBox scale: 1.75 × 14/24 reads 1.021, and 1.75 × 16/24 reads 1.167. The constant did not change; the multiplier did. The size is now asserted explicitly alongside the stroke, because a product of two factors lets one silently compensate for the other. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YLzsEWJ1UAjk9WjXEozQiW * test(chat): assert the thinking icon's real size, not its attribute The case asserted the svg's `width` attribute was 14 and titled itself "size unchanged". The attribute is what `Icon` writes by default; the stylesheet overrides it, so the number on screen has been 16 since the tool icon rule was finished — and this case stayed green through that, still claiming otherwise. What matters after today's ruling is that the thinking icon and the tool icons are the same size. That is structural: both render into the same leading slot, so one stylesheet rule governs both and they cannot drift. The case pins that they share the slot, that exactly one rule gives it a width, and that the width is the draft's. Counting rules by selector was wrong at first — two others target the same slot to turn it red on failure and say nothing about size. The count is over rules that actually declare a width. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YLzsEWJ1UAjk9WjXEozQiW * docs(chat): hold OPEND-2586 pending a clarified description Its stated expectation is a permanently visible Retry on any network failure, which is the thing the failure ladder was built to prevent: the design's fourth principle is that Retry appears only when it can work, and the ladder's bottom rung exists so an unclassified failure structurally cannot produce one. The report also cites a draft that specifies the permanent Retry. Which draft, and which cell, needs checking — the panel handover and the error-UX design are different documents and only the second governs this card. It also bundles two symptoms whose causes are probably unrelated: a failed turn presented as a completed reply, and a failure card that appears and then vanishes. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YLzsEWJ1UAjk9WjXEozQiW * fix(chat): say which gate stopped a blocked strategy task Answering the form and then being told the task failed reads as a contradiction: the answer is on screen, the agent replied at length, and the card says it could not continue. The verdict itself is right. In the clarification stage the contract admits only three conclusions, and the one requiring a plan contract had none, so blocked was the only honest call left. Nothing was lost when the form was submitted — the agent simply omitted the machine block that turn, and unlike the opening turn, that stage has no inference to fall back on. What was wrong is that the daemon knows exactly which gate refused and threw that away. Every structured daemon failure carries its reason on the error; this path alone raised a bare one, so the diagnostic panel showed no code, the card matcher had nothing to match, and every gate in the strategy contract rendered as the same anonymous sentence. The reason now rides along, and the sentence says what happened rather than that something did. Two things this does not fix. The wording is a draft — the error-UX design has no cell for it, and the nearest one describes an unusable reply, which this is not — and the string is still hardcoded English on this path. And the Retry on that card cannot work: the task is terminal, so continuing it answers 409 either way. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YLzsEWJ1UAjk9WjXEozQiW * test(chat): stop two assertions calling our own values the draft's Both numbers in this file were labelled as the design's and are not. The own-answer label is pinned at 600 with a comment saying the draft says 600. The delivery draft says 500; 600 is the previous draft's value. The same commit corrected the chosen option to 500 two rules away and left this one citing the old sheet. The unchosen option is pinned at 400 as "no weight declared, so 400". In the draft nothing declares a weight there either, but its baseline is 500, so what it inherits is 500. Neither value moves here — the weights across this family have to be reviewed together, and changing one in isolation fights its neighbours. What changes is what the file claims: both are recorded as our current state with the deviation named, so a later migration reads them as work to do rather than as conformance already achieved. A test that reports the status quo as the specification is worse than no test: it makes the correct fix look like the regression. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YLzsEWJ1UAjk9WjXEozQiW * feat(chat): give HTML artifact cards a frozen cover, and delete the open-snapshot path Every part of the first-viewport capture existed and nothing called it, so HTML cards still mounted a live iframe each — the thing the design set out to avoid in a long conversation. The wire lands at the run's terminal chokepoint, on the rows the capture just wrote, in the same window that already freezes image bytes. Queuing "screenshot this later" is the wrong shape, because by the time the renderer runs the page may be the next turn's, and a screenshot of the wrong bytes looks exactly like one of the right bytes. The freeze is structural instead: the entry and its local dependencies are read in one window and fingerprinted, inlined into a single self-contained document, and every fingerprint re-checked before the document is accepted — drift voids it rather than stitching two versions together. The renderer then gets that document with no base address, so a render starting minutes later has nowhere to read the workspace from. It is not forbidden from reading the latest file; there is nothing to read. The spec's fallback path — render against the live file while the entry's fingerprint holds — is documented rather than built. It hands the live endpoint back to the renderer and fingerprints only the entry, leaving dependencies free to move mid-render. Separately, `openPolicy` is removed rather than pinned to one value. After the ruling that cards always open the latest file, it carries a constant while reading as a switch — and the host dropping the click target looked like a bug whose "fix" would have built the behaviour that was just rejected, with typecheck and tests both silent. Now no value can mean "open the snapshot". The cover half is untouched and pinned: an overwritten image still shows the turn's version in the older message. Nine corrections to the design, struck through with dates and sources rather than erased: the placeholder-on-failure policy and the compat matrix that product overturned, a state that never existed, the click column, and the lineage description. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YLzsEWJ1UAjk9WjXEozQiW * feat(media): tell the user what to do when a picture or video fails The failure text printed an internal code at the user, because the prompt told the model to write one. Deleting the code alone would trade exposing our plumbing for saying nothing, which is not what was asked: a generation that fails should say enough for the user — or the agent — to decide what happens next. Two hundred-odd throw sites across dispatch and the provider bridge land on nine next steps, so the copy is grouped by what the user does, not by which code fired. A missing key and a model with no renderer are different bugs and the same sentence, because both are answered in Settings. Eight of the nine sentences come from cells the error-UX design already ruled on, minus the vendor half — the media contract forbids naming the provider or model. The ninth, for a run that cannot generate at all, has no cell and is marked as a draft for product to edit. The agent gets the same nine values as a closed set on every failure, and the contract says which two it may retry itself. Previously it had a code meant for our own triage and had to infer intent from wording. The semantics reuse the run-failure ladder rung for rung and the names line up with it. The rendering does not: a failed run is a whole turn collapsing into a card, while this is a successful turn with one missing picture, and the sentence is written by the model rather than the interface. No new locale keys: nothing renders this on the front end yet, so keys would be dead. The value now sits in the task snapshot for whoever builds that card. Costs about 600 tokens of system prompt per media-capable project. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YLzsEWJ1UAjk9WjXEozQiW * fix(strategy): name a repeated clarification even when the machine block is missing Three gates run in order and the general one is first. When a turn omits the machine block, the parser raises "runtime state missing" and the task is blocked before the gate that recognises a repeated clarification is ever reached — that gate reads the parsed state, which by definition does not exist here. So the precise code only appeared when the agent both asked again *and* declared itself properly. The same failure, written two ways, was reported under two names: the honest half got the accurate name and the sloppy half got the catch-all. The order is not changed. The evidence needed — a renderable form, and no machine block at all — is already observable where the general gate hands off, so the code is corrected there, after the repair path has declined, and using the same predicate the precise gate applies to the same shape. It replaces rather than appends, because the consumer reads only the first code. The guard that narrows it to the clarification stage is load-bearing: nine other shapes reach the same point, and without it an opening turn carrying two forms would be reported as a repeat. An existing case catches that. The verdict does not move — the stage still admits only three conclusions and the one requiring a plan contract still has none. Blocked, but finally able to say why. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YLzsEWJ1UAjk9WjXEozQiW * fix(chat): retire the prose an interrupted attempt already wrote The ticket blamed missing stable ids on text events. It is not a storage problem: the run genuinely produced the answer twice, and no idempotency key could recognise two independently generated passages as one replay. We are what made it write twice. Ordinary retry refuses to rerun a turn that has already put words on the user's screen; the post-tool resume path checks only whether a tool ran, so "called a tool, streamed half the answer, lost the connection" reopens the same assistant message and the model writes its conclusion again. Persistence appends, and nothing tells it the previous passage was superseded. A turn is only reopened when it did not close cleanly, so whatever prose it left behind is unfinished and about to be rewritten. On the second start for one message, that trailing prose is dropped before the new attempt's first delta lands — stepping over the attempt's own closing status row, and rolling the stored content back only when it matches as an exact suffix. Tool rows stay: they are already committed to the agent's session and the resume continues from them. No new field. The second `start` on one message is the existing, observable boundary, so replaying an old conversation reads exactly as before. Two things left alone deliberately. The live view still shows both passages until reload, since retiring them on screen means reaching into the web retry callback. And the strategy that decides to rerun at all is untouched: adding the seen-output guard there would stop rescuing genuinely interrupted long answers, which is a different question from cleaning up after a rerun that has already been decided. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YLzsEWJ1UAjk9WjXEozQiW * fix(chat): explain the blocked clarification instead of showing a blank failure The premise this started from was wrong, and the correction changed the fix. The English sentence was never what Chinese users saw: with no row for this reason code, the failure fell to the generic fallback, whose null message key renders the translated "the task failed" line. The English text only ever appeared in the collapsed diagnostics. So the card explained nothing at all while the user looked at their submitted answers and a complete plan. Translating the string would have changed nothing on screen; the missing piece was a row, which is the repo's own mechanism for this. The card now says the reply arrived and could not be recorded — it does not blame the user, whose answers did land — and keeps Retry, because the omission is intermittent and rerunning the same prompt usually emits the block. All four runtime-state codes share the row: to the user they are one story, and splitting them would ask product for four wordings of one sentence. The wording is marked as a draft; the error-UX design has no cell for this, and the nearest one describes an unusable reply, which this is not. The verdict is untouched, and a guard proves that is not decoration: mutating the row to a non-retry card turns four assertions red. Also repairs two assertions orphaned by the open-policy removal, which pinned a second argument that no longer exists. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YLzsEWJ1UAjk9WjXEozQiW * fix(chat): give the repeated-question failure its own card A reason code added earlier today had no row, so it landed in exactly the blank-fallback hole the neighbouring case had just been lifted out of — the same defect, freshly made, because the daemon side and the web side were worked separately and neither knew about the other. It does not share the neighbour's row. To the user that one is "the reply came back but could not be recorded"; this one is "I answered, and it asked me another question instead of carrying on". Different story, different sentence. Retry stays, but not for the reason it first appeared to. The omission is not flaky: a task admits exactly one clarification round, and a properly declared second question is refused by the identical code. Retry earns its place because it does not re-roll the turn — it opens a new task at a fresh clarification count, where asking is legal again. So the agent will likely ask the same thing, and that time it renders as an ordinary round of questions rather than a failure. The copy therefore promises a restart rather than a coin flip, and a test fails if the English ever picks up "intermittent" or "usually goes through". No cell exists for this in the error-UX design, so the wording is marked as a draft. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YLzsEWJ1UAjk9WjXEozQiW * test(chat): add the field the pending-tool change owes the history baseline Showing a tool call the moment it starts added one field to every tool row. The commit updated three suites and left this baseline behind, so it has been red since. The difference is exactly that field, seven times, all false — no block moved, merged, split or changed owner. None of the fixtures can reach the new branch: every call in them has its result, so nothing is ever pending, and the field is inert in rendering too. Only those seven lines are added. Re-recording the whole baseline would replace "what the implementation should output" with "what it currently outputs", which is how a guard quietly stops guarding — a check confirms this one still catches an unrelated structural change. Three comments were still stating the retired rule, one of them thirty lines above the field that contradicts it, and a fourth called the image row the sole exception to it. That is the shape that burned three agents today on a different red: the comment sends you to change the implementation. The spec's own note on why live terminal output is impossible listed two blockers; the first is gone with the rule, leaving only the missing incremental channel. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YLzsEWJ1UAjk9WjXEozQiW * fix(chat): stop keeping video and audio originals Product ruled these out of snapshots. They are the wrong shape for the budget: one revision of a video eats a large slice of a project's allowance, and once that is full the image snapshots in the same batch start failing — a semantic nobody ruled on squeezing out one that was ruled on. The policy comment promised removing a kind from the set needed no other change. It did. The provider-bytes hook hands its payload straight to the capture without consulting the policy, so a display-only edit would have left every generated video in the store while the cards said otherwise. The exclusion is enforced at the capture chokepoint instead, which is where the bytes actually arrive. The gate keys on whether a kind's original *is* the evidence rather than on the kind alone, because HTML also declines to store originals and a kind-only gate would have silently killed every HTML cover. A test pins that. A residual pending intent from an older build would have been re-captured by the boot reconciler, putting the bytes back on the build that just excluded them. It is retired instead, under its own code so "product excluded this" does not sit among real quota and render failures. Video cards fall back to the live file and the browser draws its own first frame — the same path an old conversation already takes. Audio was never a card at all, so every audio snapshot stored so far was bytes no surface read. Two fixtures described a record their code path cannot receive; corrected, with the gate they were standing in for now covered directly. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YLzsEWJ1UAjk9WjXEozQiW * docs(chat): track the newest design draft, not the handover one Standing rule, not a one-off retarget: when the design moves, the baseline moves with it. This retires the line in the ledger and both audits naming the handover commit as the only current baseline, and it means some cells those audits scored as implemented are deviations again — they were judged against a sheet that has since changed in five places. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YLzsEWJ1UAjk9WjXEozQiW * style(web): give the thinking panel a real grey, and settle the terminal's ink The thinking surface used a translucent fill, so it had no colour of its own — it took whatever it happened to sit on. The draft names an opaque grey and we have a token for exactly that value. The terminal's three tiers really are one ink in the draft, and that is not a flattening: the draft moved the distinctions elsewhere and says so in its own comments. Command versus output is carried by the leading `$`, which is the terminal's own convention rather than a mark we invented; an error line is marked by a red `✗` at the start rather than by reddening prose that is log content, not an error state. Success lines keep the brand green, so no semantic colour was lost. Guards now watch those two marks, because that is where a real regression would show. The gap between the command and output blocks was set on each side, so the two added up to twice the outer padding — the seam between them breathed wider than the edges. The change-count beside a written file was one shade darker than the file name next to it: the last cell the muted-ink pass missed. A fourth edit was reverted after removal verification left everything green. It widened the muted-ink ownership to the terminal for a case that cannot occur — the terminal only renders from a tool row, which only renders inside the flat shell — and the reasoning is now a comment so it does not get added back. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YLzsEWJ1UAjk9WjXEozQiW * style(web): close seven gaps against the draft, and delete two rules that never applied The memory card's summary had no hover fill. The attachment placeholder was a flat grey where the draft carries a mark — the artwork is the design's own svg, taken from the sheet's own base64 and decoded to confirm it matches the source file byte for byte, not redrawn. The fork divider had no entrance at all. Two rules were proved dead rather than argued dead. The queued row's active highlight is outranked by a later rule of equal weight that resets exactly the two properties it sets, so toggling the class changes no computed value; the scrollable class has one origin in the tree and it is not on that element. In both cases the suite was green before the deletion and green after, which is the evidence. The class name for the first is still emitted by a component outside this change and now has no rule reading it. Two radius tokens were missing from the seam, and the count of consumers falling through to the fallback had grown to three today. Both scopes get the definition, and the test enumerates consumers rather than naming them, so the next omission reports itself. Bare buttons in the panel were pinned to 400 while the baseline moved to 500, leaving every one of them a step lighter than the prose around it. The author had left a tripwire saying so; it fired on the fix and asked to be removed, which is what happened. What the draft actually inherits — weight, size and family — is asserted by value. The fork entrance follows the draft's easing rather than the repo's default, which the same stylesheet uses two hundred lines above for message entrance. Flagged in the source, not reconciled. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YLzsEWJ1UAjk9WjXEozQiW * style(web): align the question card to the current draft, value by value Every number here is read off the sheet, not derived. The rule I handed over — "the size ladder bottoms out at 12px, so the 11px cells must be wrong" — is a rule, and rules are how you end up one step away from the drawing. Following it literally would have pushed a cell the draft never specifies. Four of the five 11px cells do have a rule, and it says 12. The fifth is our own addition — the draft styles an invalid colour value with a border and no message — so it stays where it is, flagged rather than guessed at. A sixth cell the list missed sits in the same block and had been left behind by a half-finished pass. The value-answer row was keyed on "does it have a swatch". The draft's own comment says colours *and* numbers, and its example page shows a numeric answer with no swatch at all, so the key is whether the answer is a value. Weights follow the baseline that moved to 500. Two of them now agree with the global button reset, which means deleting the rule entirely would still read 500 — so the guards name which rule wins, rather than only what the number is. The colour block's two captions are one weight in the draft and a lighter one for the legend specifically. We had collapsed both into the lighter one. The slider is the newest draft's, all twelve declarations. Two things flagged rather than resolved: the draft moves the numeric field's focus ring from outline to box-shadow, which forced-colors modes drop entirely — and the slider track beside it still uses outline. And 1.35em leaves room for about two digits, while our own fixtures clamp to 100. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YLzsEWJ1UAjk9WjXEozQiW * fix(chat): let the rail and the transcript count the same messages The body drops the user message that carries form answers — a deliberate call, so the same decision is not stated twice and the machine payload never reaches the screen. The rail counted `role === 'user'` on the raw list, so it reported three where two were drawn, and clicking the extra one navigated to a message that does not exist. The fix shares the array, not a predicate. Exporting a predicate for three callers to apply is still three implementations that merely agree today. The render items are computed once and handed down, so the rail can no longer see the unfiltered transcript at all. The same mismatch had a second victim. Pinning a new turn to the top keys on the trailing user message changing identity, and after answering a form that message is the one nothing draws — so it pinned the *previous* turn's bubble and padded 328px of empty space beneath it. The vote now happens over the messages that exist in the DOM, which makes "should we pin" and "pin to what" the same question. That turn falls back to sticking to the bottom. Not for convenience: the spacer, the landing point and the release tolerance are all defined in terms of the pinned user message, and here there is none. Every other entrance sends a message that is drawn, and a counter-case pins that a real message after the form still goes to the top. Side effect worth knowing: a conversation whose only two user messages include a form answer no longer shows a rail at all, where before it showed two dots and one was dead. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YLzsEWJ1UAjk9WjXEozQiW * fix(chat): retire the steps a replacing todo snapshot dropped A snapshot that shares even one item with the previous one is treated as the same list making progress, and that path only ever appends: an item that vanished from the new snapshot is never touched, so it sits there as "not started" while the newly split steps queue up behind everything else. The pill and the transcript then disagree about which step is current — the pill reads the newest snapshot, the transcript reads insertion order — and the card contradicts itself, its header counting the new snapshot's steps while the rows below it are the union of both. Vanished steps are now retired the way a full replan already retires them: struck through, out of the count, out of the ordering, and parked next to the step they replaced so the change of plan stays legible. Surviving rows follow the newest snapshot's order. Partial overlap has no ruling of its own, so this follows D14 from the same family, and the source says exactly that at the one place a different answer would change. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YLzsEWJ1UAjk9WjXEozQiW * fix(chat): recognise a produced image by its extension, not only its kind Three predicates decide which file a turn should open, and two of them accept an extension when the kind field is absent. The media one does not — and the candidates built from the agent's write trace carry no kind at all, so every image produced that way scored zero and was skipped in silence. The extension list is copied from the daemon function that assigns the kind in the first place, because a fallback that disagrees with the field it stands in for is a second source of truth. Two consequences come with it: `.svg` is a sketch, not an image, and a root-level sketch raster is the user's own drawing rather than this turn's output — opening that would be a worse behaviour than the bug. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YLzsEWJ1UAjk9WjXEozQiW * refactor(web): delete the card skeleton nothing has ever rendered It arrived with the repository's first commit and no file has imported it since. Its own docblock aims it at a different grid than the one people keep reaching for it to solve — an investigation into the design-files panel's missing loading state found it, assumed it was the intended piece, and had to be pulled back. Its stylesheet block goes with it; the shared skeleton primitive stays, since other panels do use that. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YLzsEWJ1UAjk9WjXEozQiW * fix(chat): keep the confirmed answers where the user gave them Two completed shells stacked together with the answers below both, when the user had answered between them. Neither the cross-run fold nor the form block put them there: the block order the turn builder produces is correct — shell, form, shell — and the renderer split it by kind, drawing every shell and then every piece of prose. So this was never about how many runs a turn has. A single run that goes shell, prose, shell would have come out the same way. The turn now carries the original sequence alongside the two filtered lists, and the conclusion is grouped by position instead of joined into one string. The processing chain is unchanged, just run in two halves: the strippers filter per block and are equivalent either way, while form de-duplication is first-wins across the whole turn and so runs flat before being sliced back — a map, so the slice is exact. An ordinary one-shell turn renders byte for byte as before, because alternating and "shells then prose" are the same thing when there is one of each. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YLzsEWJ1UAjk9WjXEozQiW * fix(chat): one card for running out of credit, not two Running out mid-turn drew the generic failure card — white, four buttons — while the upgrade card the design specifies was wired only to the pre-send gate. Product ruled there is one card per state and no second white card behind it. The two states are the design's own: below five dollars reads as a warning in amber, zero reads in red. The card already splits on the balance, so nothing new was needed there — what was missing is that the failure carries no balance at all, only a code, so the wallet is read once after the turn settles and the card is lit from that. When the wallet cannot be read the card does not appear and no number is invented. That leaves a hole: the white card is gone, so the user sees nothing at all. Flagged, not papered over. Retry, export logs and contact support go with the white card, because the design's card carries one button and inventing the others is the thing we were told not to do. The cost is real: topping up now means sending the turn again rather than resuming it. The predicate reads the last assistant message rather than hooking the error callback, so the card survives a reload and does not have to be written twice for the send and reattach paths. Team members still have a path — the same button routes non-owners to a prompt they can send their admin. The error-UX design would put that on the card face instead; the two drafts are left unmerged. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YLzsEWJ1UAjk9WjXEozQiW * fix(chat): land replayed history at once instead of restreaming it Reopening a running project replays the whole run from the daemon's buffer, and that replay travelled the same path as live output: chunked per animation frame, then revealed character by character. So content the user had already read arrived again as forty separate commits, each with its own entrance, tangled with the new output still arriving. Batching alone was not enough, and the numbers say why: forty commits became one, but the reveal budget counts characters per frame regardless of where they came from, so the whole history still animated in over two seconds. Both halves are needed. The obvious shortcut — treat a large batch as history — is wrong: a non-streaming agent emits its answer in one piece, and that is exactly the case the reveal exists for. The question is where the text came from, not how much of it arrived. The transport draws no such line. The daemon writes its buffer synchronously and only then registers the client for live frames; same channel, same id sequence, same shape, no marker. What can honestly be observed is cadence, so the replay window closes on the first quiet gap, with a hard ceiling in case the model happens to be producing densely right then. The ceiling errs toward landing a moment of live output at once rather than disabling streaming. Queued updaters are the update functions themselves, so de-duplication, merging and the ordering of prose against events all survive; and both flush and cancel hand the queue over, since the message is emptied on remount and losing the queue would leave a blank screen. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YLzsEWJ1UAjk9WjXEozQiW * test(chat): type the failure-detail fixture to the contract's own union Written as a free string, a misspelt reason name would never be caught — and this file's assertions are precisely the ones that branch on it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YLzsEWJ1UAjk9WjXEozQiW * test(chat): guard the artifact wiring, and give maintenance a loop and a signal Four seams had code and no test, which is the shape where a break goes unnoticed: the media hook that captures provider bytes — the strongest capture path in the design — plus rename, both delete handlers, and the fork seeding. Three of them sit inside catch-and-warn, so deleting them left the suite green. The guards test the seam, not the layer beneath it: the media one runs a real generation over HTTP, overwrites the workspace file afterwards, and asserts the snapshot still returns the provider's bytes. Reconcile and sweep ran once at boot, so anything that stalled during a long run waited for a restart. They now share one pass with the boot path, because two implementations drift and the drift only shows in the case nobody watches. A tick that overlaps the previous one is skipped rather than queued: the pass is idempotent and rescans from scratch, while queueing builds a backlog on a slow disk and lets two passes reach different conclusions about the same grace window. Stopping drains the pass in flight — the next thing shutdown does is close the database. The interval defaults to off, because neither section names a period and the quota section shows the author does write "product decides" when that is the case. One assertion was rewritten after it survived swapping the two steps it claimed to order. It now uses a case that can tell them apart. Telemetry reports one event per turn that produced artifacts, and turns that produced none send nothing — a row of zeros from every other turn would bury the failures in rounding. The source-changed count is separate because it is the only code that means the capture window itself was wrong: at two percent of storage used, running out and forging history read identically. The payload is the return value of a pure function, unspread, so what the function's test asserts is what reaches the sink. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YLzsEWJ1UAjk9WjXEozQiW * fix(home): make the handoff surface look like the page it hands off to Jumping in from Home crossed two blank states that did not resemble each other, so the layout jumped twice before anything appeared. Every alignment here is copied from the frame that follows — the sidebar dock, the collapse control, the user bubble's structure, the centred empty state with its call-to-action row — rather than styled fresh. Nothing new was drawn, and no skeleton was needed once every position had something real to hold. The back arrow goes because the real page leaves that slot empty; one extra button there shifts the title by a notch, which is the shift the recording shows. The staged attachments appear immediately. They are already `File` objects in the browser, so an object URL draws them with no request at all — this surface still may not read anything project-scoped, and a test asserts zero project-scoped calls while they are on screen. Uploads now run four at a time, matching what the in-project composer has always done, and only inside the upload step: the working-directory handoff must still complete first, or the files land in a managed folder the user did not choose and vanish when the base directory swaps. A per-file failure also stops condemning every file queued behind it. Two things this does not reach. The long wait is the upload itself, which cannot leave this surface until the attachment handshake stops requiring server paths before the project view mounts. And the split width still falls back to the default rather than the user's saved one; both live behind ProjectView. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YLzsEWJ1UAjk9WjXEozQiW * style(web): follow the draft's reversal of the progress ink The draft turned the whole task-progress family back to strong ink — step titles, elapsed times, plan numbers, the tool rows inside a progress shell, and the summary between steps, which also returns to 13px at weight 400. Our muted-ink pass had taken it the other way; product confirmed the reversal against the sheet. The terminal block is the half that must not follow. The draft still writes it literally muted, and only the copy sitting inside a progress shell is overridden, so sharing one variable would have darkened a block the design never asked to darken. It gets its own token, and two assertions hold both ends: one that the progress family is strong, one that the terminal is not. The remaining four cells were aligned in the same pass rather than left half done, because they hang off the same variable and the same draft rule — a partial port would produce a state the sheet does not contain. Eleven assertions across three files pinned the retired direction. Four of them had quietly become vacuous: they told two things apart by colour, and the two are now the same colour, so both sides of the comparison passed. Those are re-anchored on what actually distinguishes them — a red icon, a weight, an indent — rather than given new numbers. The file that asserted "the row goes muted" is renamed for what it now guards: which layers follow the progress ink and which stay behind. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YLzsEWJ1UAjk9WjXEozQiW * style(web): align the send queue to the draft, hover states included Three of the four cells flagged for review already matched. What was actually wrong sat next to them. The drag handle lit up like a button on hover — our own fill plus a darker ink, where the draft changes the ink alone and by one step. The action buttons had the wrong fill and the wrong ink. And the row itself carried a hover background the draft does not have anywhere: a leftover from when the queue was a card, missed when the frame, the fill, the floor and the shadow were all taken out. A queue held together by dividers has no notion of a selected row. Long unbroken text could still burst the row: the clamp was there, the draft's companion rule allowing a break anywhere was not, so a URL neither wrapped nor truncated. First-row tooltips flip downward per the draft. Ours are portalled so they cannot be clipped by the scroller; what the rule earns here is that an upward bubble no longer covers the last message in the log, and the comment says so rather than repeating the draft's reason as if it were ours. The first-row highlight class goes. The draft's only treatment of that row is removing its top border, so the question the class was left open for has an answer. The guard now asserts the name is absent from the tree, and explains that reinstating the highlight means beating the later rule of equal weight rather than adding another declaration that gets silently overridden. The drag handle is not decorative: real drag events reorder the queue. One removal check passed by accident first time round — the script deleted a different rule with the same declaration elsewhere in the file. Redone by line. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YLzsEWJ1UAjk9WjXEozQiW * i18n: one word per locale for the agent Five locales each called the agent two different things, and one of them was a mistranslation rather than a style difference: Arabic used the word for customer, including on the agent picker's own label. Traditional Chinese turned out to be wider than it looked. The word we were treating as a synonym also means proxy in seven strings and agency in two — so the settings page was using one word for three different things, with the network-proxy hint and the agent picker reading identically. Those nine stay as they are; the other thirty-two join the rest. Bare English stays wherever it already is: it clusters on developer-facing surfaces while native words carry the user-facing ones, and that division reads deliberate. Turkish is written per key rather than replaced, because the word takes case suffixes — and because the same string is the word for tool in eight places and means "by way of" in five more. A blind replacement would have ruined thirteen unrelated lines. The Persian call is the weakest here and is marked as such: both forms are correct, and the choice rests on which one already holds the interface. The guard compares the set of keys containing each word against an explicit allow-list with the English source quoted per entry, so a new offender is reported by name rather than as a count, and the traditional-Chinese case also fails on a simplified character. The glossary gains the row it always said should be added when someone hit a real terminology choice, including why "agent" must not be translated back to the proxy word and which Turkish strings look like matches but are not. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YLzsEWJ1UAjk9WjXEozQiW * Revert "i18n: one word per locale for the agent" This reverts commit457f6c34e3. Nobody asked for it. It surfaced as an adjacent observation while another change was editing the locale files, and I dispatched it off my own list rather than checking whether it belonged in today's work — a hundred keys across five locales, none of which any report or acceptance criterion touches. Two genuine defects it fixed are worth keeping in mind if this is ever picked up deliberately: Arabic names the agent with the word for customer, including on the picker's own label, and Traditional Chinese uses one word for both the agent and a network proxy, side by side on the settings page. Each is fixable on its own without unifying anything. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YLzsEWJ1UAjk9WjXEozQiW * feat(chat): freeze a video card's first frame The video card drew whatever the workspace file currently holds, so overwriting the file changed the frame shown in an older message — the same defect image cards had, one medium later. The original still does not get stored: that was ruled out on capacity, and a frame is a picture, sized like the HTML cover rather than the video. ffmpeg is already a daemon runtime dependency and is already bundled on all three platforms, so nothing new ships. The browser-screenshot path was not reused — loading a video into an Electron window to grab one frame is neither cheap nor reliable, and a daemon with no renderer would never get a frame at all. A test runs without one on purpose. HTML can freeze first and render later because its self-contained document is portable evidence. A video has no equivalent short of copying the file, so extraction *is* the freeze: it runs inside the run-terminal window and checks the file's identity on both sides, discarding a frame decoded across a write. The cost is that it counts toward the turn, so it is budgeted and capped. The card gains a poster attribute and nothing else — the platform's own slot for a first frame, so no layout, ratio or control changes. Failing to extract falls back silently to today's behaviour, per the standing ruling against placeholder copy. Also fixes an existing leak the change would have spread: attaching a cover wrote the cover's own mime onto the workspace file record, so an mp4 became image/jpeg. HTML has been doing this to index.html all along, unnoticed because nothing reads that column yet. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YLzsEWJ1UAjk9WjXEozQiW * style(web): underline a clickable file reference without waiting for hover The draft moved the underline out of hover and into the resting state, and dropped the transition with it: a file name you can open now looks openable before you point at it. The name you cannot open explicitly does not get one. The draft's reason for the underline is that the thing is clickable, so putting it on a plain span would be saying something untrue — the same call as the ruling that a control which looks pressable and does nothing is worse than no control. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YLzsEWJ1UAjk9WjXEozQiW * feat(web): split create-file icon from edit, per design729fa43ce7The draft's create-file rows switched to a solid node glyph while the edit row kept the pencil. Our `toolIcon` mapped `write` and `edit` to the same pencil, so the create rows could never match. Counted in the draft itself rather than read from a commit message: `729fa43ce7` renders 4 solid create glyphs to 1 pencil edit glyph, and the previous baseline `361b78253e` was already 1 solid to 4 pencils -- the solid glyph was settled a revision earlier and this commit only finishes propagating it. Adds `FILL_ICON` alongside `STROKE_ICON` instead of making it a variant: the fill family carries no `stroke-*` at all, so a dead `stroke-width` cannot mislead the next reader into thinking it honours the 1.75 ruler. `FailIcon`/`RetryIcon` were already hand-written with exactly those three attributes and now consume it, producing byte-identical markup. The stroke-weight spec no longer excludes the new glyph -- exclusion would leave it unguarded forever. It now asks each family its own question, and pins `DESIGN_FILL_KINDS` as a membership list so both directions go red: a cell silently reverting to stroke, or a cell silently defecting to fill and escaping the 1.75 ruler. The 1.75 baseline is untouched. * fix(web): realign non-record chat text to design729fa43ce7The reported symptom -- the turn status word "已完成" rendering smaller than the body text below it -- was real, and not isolated. Recomputing the draft's own CSS over the draft's own HTML (rather than reading its prose) turned up the same shape in three places: cells that should sit on --t-lead had drifted down to the --t-cap/--t-mini rung. The size ladder itself was never wrong; both baselines share a byte-identical tokens.css and all six rungs already matched. What was wrong was which rung each cell drew from. Ten cells realigned: the turn status word (14px/500) and its tick (16x16), the confirm-card head and its title (14px) with the count stepping itself back down to 12px/600, the title dropping to 500 when the card carries options, the option rows rising to 13px on the new baseline, the visual-direction card's head inset (16px), and the reconnect row taking the draft's own #A3A3A3 -- landed as --chat-tool-ink with light and dark scopes at the same value, since the draft carries no dark override for it. Each of the twelve edits was reverted individually by exact line number and each went red on its own; none is dead weight. One revert first struck a comment line and stayed green, which is precisely why the revert pass has to be seen failing rather than assumed. question-form-latest-spec-drift.test.tsx belongs to another lane but uses .qf-chip as its measuring standard with the value pinned to the old baseline, so raising .opt made it unavoidably red. Only its calibration constant moved; a header note records why it must not be put back. * fix(web): make the thinking row read as a tool row at both depths Product ruling 2026-09-02: the thinking row is ours, not the designer's -- the draft has no cell for it -- and the requirement is simply that it be indistinguishable from a standard tool row at the same depth. It was not. Being a details.fold it drew from the foldable family rather than the tool family: 500 weight at the top level, and inside a step drawer the top-level rule stops reaching it, leaving 12px from `.fold > summary` with the weight still inheriting 500. The icon slot was skewed too -- the same defect's third landing point, missed by the earlier survey. Asserted as an equality against the neighbouring tool row rather than against literal values, so a future change to the tool row carries over instead of silently reopening the gap. An anti-vacuity case proves the reference side reads real values first: without it, two `<unset>` reads would agree and the whole comparison would pass while measuring nothing. Size goes on the row box and weight only on the title, mirroring how the tool row splits the same job -- putting weight on the summary would drag the drawer's duration text down to 400, fixing one axis by breaking another. The thinking body is untouched: the diff is pure insertion, and every selector is gated on `> summary` so it cannot reach it. * fix(web): release event streams while the tab is hidden Measured against the real runtime: the conversation page holds two permanent SSE, and a browser gives one origin six sockets. lsof shows that budget is shared across every tab of the profile, not per tab -- four EventSources in one tab plus four in another yields six established connections and two stuck in CONNECTING forever. So a parked Open Design tab takes sockets away from the one actually on screen, and requests on that page queue for 26-35s while the same URLs answer in ~1ms over curl. The shared manager already dropped its stream 30s after the tab hid; the two streams that own their own lifecycle held theirs for the life of the tab. New stream-visibility.ts carries the invariant once -- a stream nobody can see must not hold a socket -- with the same grace window so alt-tabbing does not thrash. Reopening re-fires onReady, which ProjectView already treats as its reconcile point. The run stream is deliberately excluded: dropping live agent output on a tab switch would be a worse bug than the queueing. This is not the packaged app's bug -- desktop and packaged both append Chromium's ignore-connections-limit for loopback before whenReady. It is dev, and it is self-hosted users, who are documented as opening the daemon directly in a browser where plain-text loopback never negotiates HTTP/2. Tests assert exact socket counts, never negative matchers, and the fix was verified by stubbing the binding back to a no-op and watching the same three cases fail again. * perf(web): coalesce duplicate boot reads and bound replay reattach Attribution first, because the reported numbers were not one page load. A clean cold open of the conversation page issues 71 fetch/SSE calls across 39 distinct URLs; the 158 that was reported included a Next dev hot-update firing mid-measurement, which re-ran every effect and produced a near-complete second copy of the list. StrictMode accounts for 11 of the 71 -- identified by identical stacks firing 1-6ms apart, not assumed -- so production boot is nearer 60. Two of the flagged endpoints turned out to be something else. The raw file fetched "ten times" is twice per view: one fetch for the srcDoc path plus the iframe's own URL load, a structural consequence of keeping both iframes mounted, already guarded against StrictMode by mtime-keyed in-flight tracking. Papering a cache over it would hide the architecture rather than fix it. The message id seen "twelve times" is never a GET at all -- it is twelve PUTs, one per buffered save during streaming. Four real duplicates removed via single-flight with ttl 0: join a request still on the wire, retain nothing once it settles. No caller can receive a body it did not itself trigger, so this adds no staleness surface. Two of the four are keyed by workspace identity as well as project, so a workspace switch cannot serve the previous scope's answer. The reattach fan-out was real but the obvious fix would have lost output: reattachDaemonRun resolves only when the stream ends, so a semaphore around the whole call starves live runs of everything until their predecessors finish generating. Only terminal replays -- finite event logs that settle promptly -- go through the limit, and a run the daemon still reports active bypasses the gate entirely. A 30s max-hold stops counting a wedged replay without ever dropping it. Anti-vacuity guards ride along in both specs: distinct projects must not share a single flight, a read starting after the previous settled must hit the network, all five recoverable messages must eventually reattach, and a live run must never be queued. Each single-flight was reverted individually and failed exactly its own assertion. * test(web): catch two specs up to their product rulings, drop dead rules Both failing specs were stale tests, not regressions, and both rulings were found in specs/ rather than inferred from a source comment calling itself a ruling. thoughts-scroll-cap asserted the in-flight thinking cell carries no scroll cap. The spec had not moved since 08-29; the implementation dropped `scroll={!live}` four days later. The ruling is recorded in chat-stream-scroll-research.md in the user's own words -- what was overturned was the fixed height with stepped scrolling and a fade mask, never the cap itself. tool-input-delta-dead-wiring asserted an in-flight tool call renders no row. D3 is struck through in the decision log and annotated superseded (2026-09-02, OPEND-2419): a call renders the moment it is made, regardless of outcome. Flipping the assertion could have turned the case into one that only ever nods along, so it now also carries a leak probe on the heredoc delimiter, which can only originate in the delta frames. The file still guards what it always guarded. Three dead rules removed with producer evidence rather than a grep: direct strings, template concatenation, CSS Module camelCase, clsx branches, e2e selectors, and specs anchoring the name. Two candidates survived that check. `.run-error__description` is equally unreachable from src/ but an e2e still anchors on it -- a selector the same rebuild missed, so that suite is currently broken and the fix needs an owner. And `.doc .nm` never existed in product CSS at all; the live class for that cell is `.artifact-card-doc-name`, which two specs pin. Deleting from the list as given would have taken out working code. * fix(web): bare buttons in the panel take the draft's rendered 400 The old value was `inherit`, chosen from what the draft *declares*: its global button reset sets no font-weight, which was read as "the button follows the panel's typographic baseline". That inference is wrong. Browsers style `<button>` through the `font` shorthand (Chrome: `font: 400 13.3333px Arial`), and a shorthand sets every longhand it covers -- so a button's weight does not inherit by default. Declaring nothing leaves it at the UA's 400, not at the baseline. The draft's own `.tool .fn { font: inherit }` is the counter-example: it is the only way a button climbs back to the inherited 500, and it measures 500. Measured rather than reasoned, in real Chrome over the delivered draft page: 293 buttons read 400x195 / 500x43 / 600x55. The load-bearing control is injecting a class-less button and a span into the same 500 parent and reading both at once -- button 400, span 500. One ruler, one position, two values, so this is not a constant read back everywhere. The span's 500 also shows what inheritance *would* have produced. The reset rule is byte-identical between the measured page and the current baseline729fa43ce7, so the number carries over. Scope is unchanged: `:where()` keeps this at (0,0,1) so it still loses to any class, and the size, border and padding lines -- each the result of its own earlier per-cell count -- are untouched. typography-baseline.test.ts had encoded the same bad inference, down to a failure message instructing the reader to write `inherit`. Only that one case moved. * fix(web): composer ink and plan-current ink, scoped to the panel Composer: the draft puts body, placeholder and typewriter carousel on 1.7, and the footer icon buttons on the deepest ink with hover changing only the background. Ours had 1.6 and a mid ink whose hover went *lighter* -- so the pointer made a control read as losing focus. The blocking concern was scope, and one part of it was wrong. The annotation sidebar does not consume the editor rules at all: it passes `composer={null}` and renders a bare textarea. The home hero does, so these land as `[data-chat-root]` rules -- an existing hook that ChatPane already stamps on the composer's ancestors and that neither the hero nor the sidebar carries. The hero is untouched. Specificity carries it, not source order. All three layers move together: the writing surface, the static placeholder, and the carousel that sits over the placeholder's position. They are three states of one line of text, and lifting only two leaves a 0.65px jump at the moment they swap. The hover background is also realigned, and it was not on the list -- found while editing the rule the list did name, and included because "colours 1:1" is the standing instruction. Plan: `--plan-current-text` is #353535 in the draft and has been across both baselines. The spec pinning `--chat-text-strong` turned out not to be a product ruling at all -- its anchors are the two cases above it, and this one was a same-day convenience mapping onto an existing token. It now consumes a new seam variable, declared in both scopes at the same value because the draft carries no dark override. Tooltip is deliberately not touched: there is exactly one live tooltip in the product, used by 26 files across the whole app, so its 1.2 line-height cannot move without a scope decision. The dark pill measured earlier was not it -- those are three dead `::after` blocks, all gated on `:not(.od-tooltip)` while every caller carries that class. The shared ruler now expands line-height and font-family. Before this they read back `<unset>`, which is indistinguishable from "nobody wrote it" -- so an equality between two of them passed while measuring nothing. Additive only: `resolved()` yields just the targets a caller names, and none of the 27 existing callers name these two. * feat(web): move the visual-direction countdown and reshuffle per729fa43ce7The draft relocates two controls on this card: the countdown moves into the card head's right edge, and "reshuffle" moves out of the footer into the preview's top bar, left of the grid toggle, restyled from a square ghost button into a pill. The footer slot it vacates becomes "skip". The draft changed this in three render points, not two -- the scene page carries the same card and was edited alongside the component page. On our side the countdown was in the footer at 11px, and on the visual card it never appeared at all: that footer is merged, and the merge dropped it. It is now visible there for the first time. Size, weight and ink follow the draft (12px / 400 / --text-soft). The display format does not. The draft renders `30s`; product ruled 2026-09-02 to keep `M:SS` because it reads more clearly. That deviation is annotated in the component, the stylesheet and the spec, so the next alignment pass does not quietly put `30s` back. Measured in real Chrome across five widths: `0:30` is 9.1px wider than `30s`, and the tightest case still leaves 61px before the title, which wraps rather than colliding. Behaviour is unchanged on both paths: the countdown still finalizes the same way, and skip reuses the existing handler -- the merged footer's button already called it. Both call sites are covered. The agent-authored one keeps its own shape: its selector row cannot host the footer, so adding a second skip there would put two on one screen. Collapsing them into one row means widening the delegation rule, which is a separate decision. * fix(web): the tool icon slot is 16px, matching the glyph it holds Every glyph in the execution record already matched the draft byte-for-byte -- paths, sizes, inks, stroke weights, both the tick image and the eight-layer gradient orb. The one real difference was the box around them: the draft sets the slot to 16px, we had moved only the svg inside it and left the slot at 15, so a 16px glyph sat in a 15px box and overhung half a pixel on each side. This has been in the draft since the previous baseline and was never carried over. The draft gates its rule on `:has(> svg)` to spare the leading dot and the plan ordinal. Neither lives in this slot in the product -- the dot was cut and the ordinal is its own element -- but something else does: the thinking row's running state puts an Orb canvas here, not an svg. Copying the condition would leave that state at 15 while the settled state took 16, making the left edge jump between them, which is exactly what an existing spec pins. So the rule is unconditional here, with the reasoning recorded next to it. Two reverse controls carry the change: the mark and ordinal columns must stay 15, since widening the whole leading rail would also pass the positive assertion while enlarging the tick and the orb; and the four glyph fingerprints must differ from each other. The draft supplies a third control of its own -- an empty slot in the same file measures 15px under the same ruler, which is what proves the 16 comes from `:has`. The gap between icon and text stays at 7px against the draft's 8px. That pixel was set deliberately for OPEND-2516 and a spec guards it; the draft's own three rows disagree with each other there (22 / 23 / 24), so the two accounts cannot both hold. Left for a ruling. * fix(web): the shell head keeps one size across all four run states The draft renders this header at two sizes depending on state, and the difference is not intentional: the running word lives inside `.shimmer`, which needs its own font-size for the gradient clip and sets --t-body; once the run settles the shimmer is gone and the plain text falls back to the summary's --t-mini. So the title shrinks by a pixel at the exact moment the run finishes. Measured in real Chrome on both sides: draft and ours agreed at 13px running, 12px settled. Product ruled 2026-09-02 that the four states read as one size. This is a deliberate departure from the draft, annotated as such in both the stylesheet and the spec: neither of the draft's two values is wrong on its own, product chose the consistency. Aligning it back to --t-mini would be revoking a ruling, not fixing a drift. Scoped to `.fold.flat > summary`, which reaches only the outer shell -- the flat variant has exactly one call site and every drawer inside it is a plain `.fold`. The base rule is untouched, so nested foldables stay at 12. The elapsed time on the same row declares --chat-t-cap for itself and does not follow. The four-state check is written as an equality against the running state rather than four literals, so a later change to either side reopens the gap loudly instead of silently. Also records the standing ruling on the icon-to-text gap: 7px stays. The draft says 8, but that pixel was set for OPEND-2516 with a spec guarding it, and the draft's own three rows disagree with each other (22 / 23 / 24) where ours are one column. Product kept ours. * fix(daemon): a failed render check stays out of the visible reply The user asked why the assistant was narrating "the desktop render service is unavailable, so no preview was generated this turn". The answer is that our own prompt told it to. Both live cores carried the instruction -- the classic core said to "say so in your reply and move on", the slim core to "state that clearly". Model-authored rather than host-injected, and provably so: two recorded runs report the same event in different words. A template would emit identical bytes. Nothing in daemon, web or packages concatenates such copy into message content, and there is no i18n key for it. This is not a new product rule. OPEND-2577 already settled the same bug one subsystem over, and its spec opens with almost this question -- "the sentence the user read was not a UI bug; it is what this prompt told the model to write, verbatim" -- and asserts the disappearance of "the image generation service is temporarily unavailable". The contract it set is that operational detail belongs in tool output and daemon logs, never in the visible reply. One difference is honoured. For media the user had asked for an image, so that precedent still owes them a next step. A render check is something we do on our own initiative, so the correct output is silence about the infrastructure. Only the announcement is removed. The model still reports its own static verification, and a test now guards that half specifically so a later edit cannot take it out along with the leak. The single-render budget shares those lines and is preserved -- it is a token guard, not copy. The slim charter ceiling moves one documented step; the wording was tightened twice before spending it. * fix(web): hold the artifact card's loading face until the frame paints The blank card was two things, and neither was the daemon being slow -- curl returns the file in 2.2ms and the four card frames never touched the wire after the first. The visible half is a contract violation in this component. Its own comment says the pending face is for "not loaded yet", but the code dropped it at `verified` -- the moment a HEAD probe returned, tens of milliseconds in -- and revealed a frame that would not paint for another six to sixty seconds. That gap is what the user saw. The frame now mounts and loads exactly as before, hidden, and is revealed on its own load event. Blast radius is one caller: only cards pass `pendingContent`. The two project grids pass nothing, and their CSS already hides the glyph, so holding on for them would display nothing at all -- they are unchanged. The other half is four identical HEAD probes fired at one URL, because the same artifact appears once per turn and each card probes alone. These now join a single in-flight request. Not a cache: the map holds the pending promise and drops it on settle, so a later mount always re-probes and a deleted file cannot leave a stale verdict behind. The merged probe no longer aborts either -- one card unmounting must not cancel another's, and the response is 300 bytes of headers. The 59 seconds themselves are not ours to fix. The artifact loads `https://cdn.tailwindcss.com` as a parser-blocking script, and that host answers zero bytes in 70 seconds from here, so the body never parses. Measured 7 requests after the change, down from 10-11, with the card showing the pixel-liquid face throughout rather than an empty box. The iframe timings that looked like queueing were neither queued nor re-fetched: Chrome leaves the timing segments at 0 for iframe navigations and runs the entry until the child document's load event. * test(web): pin the turn footer's time and canceled mark, fix lying comments Four reported defects, none of which turned out to need a code change -- so this adds the evidence and the guards instead. The two from the gallery README were real once and are fixed. Time now reaches both branches, and the footer wrapper is full-width rather than the 220px shrink that had nowhere to push it to; measured, the time's right edge sits flush with the row's. The canceled turn already renders a grey dot, not a greyed tick -- which is what the draft asks for, since a dot reports a state and a tick claims it passed. The other two were artefacts of how the gallery inlines CSS, and are worth recording because the same shape will recur. The "one shade too light" ink is declared on an ancestor the gallery cell does not have, so the token goes undefined and the fallback shows through; adding that ancestor back in the page snaps it to the expected value. The divider's five wrong axes are one rule that the gallery's selector picker never inlined at all, leaving a bare div's defaults. Chasing either into the product would have made real code worse to flatter a measurement. The new spec covers the reverse half the existing ones left open. Two holes surfaced while writing it: an assertion about the running state was passing because that row does not render mid-stream at all, and an existing negative assertion fell back to an empty string when its selector stopped matching -- it stayed green through a simulated rename. Both are closed. Five comments corrected, each dated to the change that outdated it: three still described a 15px icon column that is now 16, and two more contradicted code sitting a line or eighteen away. * fix(daemon): close the host-failure-narration class, not just its instances Three times now a user has read our infrastructure's status in an assistant reply -- image generation, then the desktop renderer -- so the missing piece was a guard over the class, not a third instance fix. Four more places were telling the model to relay host state the user never asked about: the research contract's stderr rule, a skill relaying provider text verbatim, and two lines in another baking our interpreter version and credential configuration into the deliverable. Each rewrite follows the wording OPEND-2577 already established. One sweep hit was overruled. "If X/Twitter is unavailable, say so" is about the subject of the research the user requested, not our plumbing, and a coverage gap in their deliverable is owed to them -- the same carve-out the previous commit made for an explicitly requested export. The daemon-side redaction changed shape twice on contact with the code. There was no log copy of the message at all, so redacting alone would have destroyed the diagnostic rather than relocating it; the added log line is now the only copy and a test pins the raw path to it. And the web analytics parser buckets export failures by matching words in this message, so flattening it to a fixed string would have silently emptied those buckets -- it redacts paths, loopback ports and pids while leaving every classifying word, with all three buckets pinned. The guard scans directory globs so a new prompt or skill is in scope the day it lands, and every hit needs a written verdict. It deliberately cannot tell the two kinds apart -- whether the user asked for the thing that failed is semantic -- so there is no verdict meaning "narrated host failure, and that's fine": excusing a real leak requires mislabelling it where a reviewer sees it. Testing the detector rather than trusting it found three holes in it: it scored zero on both sentences from the previous fix, it could not see the canonical rule it was enforcing, and substring matching let a leak be appended into its own suppression entry. * test(web): pin four record axes reported as drift, none of which were Four differences came out of the gallery's full-coverage sweep. All four were measurement artefacts, so this adds the evidence and the pins rather than a fix; the record stylesheet is byte-identical to before. The shell head's orb is 24 in the product -- the component passes it and the canvas carries it -- but the gallery has no React and reimplements the drawing script, where the box is hardcoded to 20 and the element's own data attribute ignored. Patching only the canvas size in the live page brought the head from 32 to 36, matching the draft exactly, while the two orbs that really are 20 stayed put. The step rows showing "not started" under a head reading "completed" are a fixture shape, isolated by moving one variable: the same tool calls with a closing todo snapshot mark completed, and without one mark neutral. That mapping is a deliberate ruling -- calling an unclosed step complete "says something on the agent's behalf that it never said" -- and those cells never send the closing snapshot. The thinking paragraph was two different elements paired together: the draft's opening narration against our reasoning body. Measured separately, the draft's own reasoning body matches ours to the value, and so does our narration against the draft's. The claim that a cell reproduced the registered selector gap is also wrong -- no cell in the gallery has that shape at all. The gap itself is real, proven by moving real narration into a drawer and back in a live browser, but the draft writes no rule for that position, so following it would be inventing a value on the designer's behalf. The terminal's 29px belongs to the inside-a-step column, which we have exactly; the cell that measured 7 has a fixture marking its todo complete up front, which sinks the block to the top-level column where 7 is itself a ruling. The reverse controls pin the three standing deviations -- the mark's offset, the summary gap, the stream ink -- so a later alignment pass cannot quietly undo them. * fix(web): sync the /search failure rule, and guard the pair against drift The previous commit rewrote this rule in the daemon's research contract but could not touch its twin in the composer, because seven groups were working in apps/web at the time. The two are not merely duplicated documentation: on a /search run the composer expands its copy into the outgoing user message while the daemon renders its copy into the system prompt, so both reach the model in the same turn. Since that commit the model has been receiving "keep diagnostics out of the visible reply" and "report that error" together. They were written in one commit and have been identical ever since, so the web copy now takes the daemon's wording verbatim. That also drops the provider's name, which the new wording explicitly excludes -- fixing only the stderr half would have left the leak the sentence names. The parity guard lives in e2e/tests because this is a cross-app consistency check and web must not import daemon internals, but the deciding reason is CI reach: e2e_vitest runs when either app changes, while a guard on the daemon side would not run at all for a web-only change -- exactly how this drift got in. It pins the rule's four clauses rather than its prose, since the clauses are the product decision and the wording is not. A literal check rides alongside because the semantic form has a hole the author demonstrated: appending a leak to a compliant sentence keeps all four clauses true. The escape hatch for a future platform-specific difference is written next to it. Two anti-vacuity cases are permanent residents: the judgment is fed the sentences that really did ship before the fix and must say where each one fails, and the extractor must throw rather than return an empty string when the sentence is renamed away. Also repairs the daemon spec the previous commit left red -- it still asserted the deleted wording -- and points it at the parity guard. * perf(web): remove three boot reads that could never have succeeded Cold start on a conversation page drops from 68 requests to 58-61. Attribution came from wrapping fetch and recording call stacks, which is also how two of the five briefed causes turned out to be wrong. The team catalog's three requests were not a permission race and it was not "the second one is the bug" -- all three were. The signed-in account's active workspace is personal, and the daemon answers 403 for any non-team workspace, so every one of them was guaranteed to fail before it left the browser. The catalog now declines locally, and it throws rather than returning empty so each consumer branch stays byte-identical to the 403 it replaces. A personal-to-team upgrade goes to the wire on the very next read. Analytics was not two owners missing each other's window -- measured, they already coalesce. The client drops its memo on every null result so a later opt-in can retry, which means every track() call re-reads the endpoint for the entire life of any session where analytics is off: every user who declined, and every dev build. The retry is now tied to the event that can change the answer. The design-md hook was reading /files with a bare fetch, so it could neither join the read ProjectView already had open nor collapse its own replay. Routing it through the shared reader needed two flags to keep its behaviour: authoritative reads so "no DESIGN.md" stays distinct from "could not read the directory", and a fresh read on explicit refresh so the shared 1s window does not become a cache and freeze the staleness chip -- an existing test caught precisely that. Two of the seven vela status reads are deliberately left alone. That third caller also runs immediately before POSTing a read receipt, so it is an authority question, not a display read; wiring it to the shared reader let a post-sign-in check join a request issued before sign-in and the receipt went to local storage instead of the account. Proven with a probe, then reverted, with the reason recorded at the call site. The plugin catalog is likewise untouched: collapsing identical concurrent reads there does not just remove a request, it makes the same-key ordering race unreachable that a spec explicitly pins. That is a change to the module's concurrency contract and wants a ruling. Two suites needed adapting rather than leaving broken: they drive the shared file reader through once-queued mocks and now have a second legitimate caller, so the hook is stubbed there and no assertion moved. * fix(web): resting option rows and the visual card head take the draft's weight Two of seven reported differences were real. The rest are recorded as pins so the next alignment pass does not raise them a third time. Buttons do not inherit weight -- the UA applies a `font` shorthand -- and the draft's reset only sets font-family, so its option rows rest at 400. Ours declared 500 for itself, which is how it escaped the same correction made for bare buttons earlier. Checked row by row across five cells: the ten resting rows move, and the nine the draft writes 500 for stay, matching value for value. The visual card's head hook was attached to the text-options class only, while that card renders through the picker class, so it never reached the rule that drops option-bearing card heads to 500. The draft's selector keys on the options container, which the visual variant is one of, so its head is 500 there too. Three of the reported visual-card differences were the product being right. The 4:3 preview is a ruling with its own guard -- the catalogue's 96 images are all 4:3, and a portrait card would crop 46% of each. The stacking offset is derived from the card's own width by one formula on both sides, not an independent value; a geometry assertion now fails if one moves without the other. And the four sample tints are our real per-style palette sitting behind real assets, while the draft's flat grey is the backdrop behind four placeholder JPEGs its own README marks for replacement. Two more reported weights were the brief being wrong: the draft writes 500 for the own-answer textarea and the reconnect line itself. The option row's right inset is left alone. The draft is symmetric at 5, ours carries 11 from OPEND-2402, and pushing it back measures no overflow today -- the same fix's wrapping changes cover it and the text column is identical at both values -- but reclaiming that margin is a product decision, not an alignment one. Pinned as-is, including against being collapsed back into the shorthand. Two specs had written "resting equals 500" as a fact; only those cases move, and the sibling constants the draft really does set to 500 are untouched. * feat(web): the design-system creation message is the draft's status card This card was ours to begin with, then deliberately removed in favour of a plain user bubble carrying a dictionary string, with a spec asserting its absence. Product ruled 2026-09-02 to follow the draft, so it comes back and that assertion flips. Both decisions are recorded where the assertion lives, with the draft commit and line -- this position has now been decided twice in opposite directions and the next reader should be able to see that. The width is the one place the draft cannot be copied as written: its 280px rule hangs off the gallery's cell container, which does not exist in the product, while the component itself declares a 320 max. The QA sheet measures 280 x 67.98 and reasons about the description occupying two lines, so 280 is what the designer accepted. It is pinned on the component and the unreachable 320 is not carried over -- keeping it would only suggest a ceiling that can never apply. Measured live: 280 wide, two-line description, 67.98 tall. The four literal colours become seam variables per the component contract, declared in both scopes at one value. The draft carries no dark override for any of them -- every dark block in that file is for something else -- so inventing one was not on the table. The bubble's identical black is deliberately not reused: it flips in dark mode and would drag this card along with it. The existing dictionary key is kept rather than replaced: two other callers still use it for the menu item and a first-turn title fallback, so this adds two keys across all nineteen locales and guards that the old one survives. An anti-vacuity check caught a would-be false pass. The description's weight is inherited, and the ruler only computes rules applied to the element, so comparing it directly read `<unset>` on both sides. It is now two assertions: that nobody declares a weight here (with the same ruler proving it finds one on the title), and that the seam baseline is what it inherits. The revert pass found the hole the seam layer's own documentation warns about: removing only the dark-scope declarations fails one assertion and leaves every pixel check green. * perf(web): give artifact cards their own load budget instead of no budget Artifact cards bypassed the thumbnail gate entirely, and the reason was sound but too broad: the gate suspends itself the moment a project route becomes active so background covers do not compete with the project opening, and artifact cards live on that route, so going through it left them grey forever. Having a budget and yielding to the foreground were one switch; they are now two. The exposure is real. One message can produce as many cards as it produced files -- measured up to 28 on a single message, with no cap anywhere in that path -- and `loading="lazy"` barely holds: 24 cards in a 900px viewport start 16 documents without scrolling. Cards of the same file do not save a request either, because /raw/ answers no-cache, so eight cards of one file are eight round trips whose documents each run the page's scripts and re-fetch its externals. Four rather than the grid's six: the grid's number assumes it steps aside when a project opens, and this lane never does, so it must leave more headroom for the project's own reads. Four is also two rows of the two-column layout, and above the common case -- median two cards -- so the ordinary shape never queues at all. Queued cards show the pixel-liquid face, which is what "not loaded yet" already means here, so the standing rule that a fallback stays an ordinary card face is untouched. The positive assertion pins the exact budget rather than an upper bound, since a ruler that broke and read zero would satisfy `<=`. A second case pins that all twelve sources eventually mount, so throttling cannot become dropping. A `suspendable` flag written along the way turned out to gate nothing -- flipping it left every test green -- and was removed rather than left as decoration. * chore(design): rebuild the mirror gallery and make its ruler honest The checked-in gallery differed from the code by 318 structural blocks, so anyone accepting against it was reading roughly five hundred differences that did not exist. It is regenerated, and the draft side -- previously a local artefact that never entered git, most recently served from a stale commit on a local port -- now has a build script with the draft's sha written into it, taking its sources through git rather than a path someone happened to have. Three fixes to the differ itself. A neutralising rule written for the old matrix page was overriding the draft's own weight declaration at equal specificity. The selector filter ignored linked stylesheets, so the 452KB file holding the visual-direction styles read as "the draft never wrote this" -- and it now scans the page being measured rather than fetching a second copy, because that fetch was observed 404ing silently and emptying the filter, which looked exactly like the property count improving. Numeric wildcarding lets differing durations pair up, with any surviving text difference reported in its own column so a real 2/4-versus-3/4 surfaces instead of vanishing into the unpaired lists. Two more ways the ruler lied, both found by testing it: readings drifted run to run because one cell animates, and cells measured through a crop compared a cropped element against an uncropped one. Six cells the draft added are now populated -- all six turned out to be implemented -- and two fixtures were measuring the wrong thing: the queue rows never passed a steer handler, so they rendered the fallback button rather than the one the draft draws, and the waveform fixture duplicated the draft's 28 bars into 56. The inlining guard reconciles per selector rather than per class name, because a class can arrive through a group rule while its own seven declarations never do -- which is exactly what one cell was doing. Cells missing rules now say so in their own note instead of reporting the absence as a difference. Ten further classes of stale prose are corrected, the worst being cross-references that shifted when the draft inserted cells and now pointed reviewers at the wrong one. * fix(web): artifact cards carry the project-relative path, not the basename Clicking a card for a file in a subdirectory changed the route and then silently did nothing -- no tab, no request, no error -- while its cover stayed grey forever because the frame was pointed at a 404. The cards that did work were in the project root, which was coincidence rather than partial correctness. `deriveFileOps` truncated the agent's absolute path to a basename, on a comment asserting the project file API indexes by basename. Measured against the live daemon, that is false: `provenance.md` answers 404 and `context/provenance.md` answers 200, and both `producedFiles[].name` and the file listing carry project-relative paths. The daemon was right; the truncation was ours. It only surfaces through the fallback branch, taken when a run is cancelled before `producedFiles` is populated. One truncation, five consumers, all confirmed broken: opening, the cover URL, export and share, deduplication -- which merged a root README with one under assets/ and made the second disappear entirely -- and the pairing against captured snapshots, whose labels are project-relative, so a subdirectory artifact could never find its own cover. The text row's open affordance was gated on the same key and was therefore false for every subdirectory file, so those rows had no arrow at all. The resolution reuses the judge that markdown links and record rows already share, and accepts only positive evidence: the resolved project directory as a prefix. It deliberately does not take the file listing and match by name -- a project really can hold two README.md files, and opening the wrong one is worse than opening none, which is a failure mode the record-file-open rules call out by name. Without evidence it falls back to the basename, exactly today's behaviour. Two user-visible consequences, both arguably corrections: a card for a nested file now reads `context/provenance.md`, matching the tab that opens; and same-named files in one turn now produce a card each, which is what the dedupe comment always claimed to do. Files the agent writes outside the project are still not openable. That is left alone on purpose for the same reason as above. * fix(web): the status card's radius follows our scale, and its spec says so Asked whether to copy the draft's literal 14px or use the token that its neighbouring bubble already uses, the answer was to use the token. Our shape scale runs 2/4/8/12/16 and has no 14, so copying would have meant hard-coding an off-scale value for this one card while the bubble beside it sits at 12. The stylesheet was changed without its spec, which left the suite red. The radius now has a case of its own rather than riding along in the box-model one: it is the single axis on this card that deliberately departs from the draft, and burying that in a shared assertion is how a later alignment pass ends up "fixing" it back. The draft's value is kept beside the shipped one, so a future reversal edits one line instead of re-deriving where 14 came from, and a reverse control fails if the two ever become equal. Three type errors from earlier work are also cleared. The worst was `tForLanguageTag(...) as never` -- an assertion that the value cannot exist, which type-checks where it is written and only fails at the next call site, which is exactly what happened when the gallery started using it. It is a non-null assertion now, with the reason recorded. The other two: `as const` on a fixture made an array readonly against a mutable prop, and a context helper was given null where the contract says string or undefined -- a personal workspace has no team id rather than a null one. * test(web): pin four reported defects to the commits that fixed them Three of the four are genuinely fixed and now have regression pins; the fourth is half done and says so. Each conclusion comes from putting the pre-fix implementation back and watching the case fail, not from running the suite and seeing green. The turn-boundary defect and the stacked completion states turned out to be one symptom seen twice: the answers a user gave between two runs were sorted behind both shells rather than dropped, which is why the two completion states ended up adjacent. Both halves fail together before the fix and pass together after, and a preliminary check confirms every element was on screen throughout, so this was ordering rather than omission. The todo pin covers count and order separately, because the report named both: before the fix the shell listed eleven rows against a pill reading nine, with the running step at position nine instead of six. Its fixture copies the event shape out of a real recorded TodoWrite rather than an idealised one. History replay is confirmed in the browser as well as in tests -- 2319 characters of history land in one step with nothing revealing, and the agent's subsequent output still reveals character by character. The existing spec covered the behaviour but passed the flag in directly, so the wiring that decides whether replay happens at all was never exercised; that gap is closed. The upload surface is where it stops. The waiting screen now paints the attachments immediately from local object URLs, and that half is pinned here. The wait itself is unchanged: measured on a real machine the handoff screen held for nineteen seconds, ending exactly when the last upload finished, because the attachment handshake still reads server paths synchronously on the project view's first render. The failing case for that half is deliberately not committed -- it would redden the suite for everyone before its fix exists. * perf(web): recycle artifact card frames by last-seen instead of keeping all Cards mounted a live document and never released it, so scrolling a long conversation left every frame it had ever passed alive: measured on a real machine, twenty frames climbing monotonically and still twenty after scrolling back to the top. Distance from the viewport cannot decide this, and that is measured rather than assumed. Artifact cards live inside a scrolling chat log, so the intersection is clipped by that ancestor before any root margin applies; three observers at 0, 160 and 3000px over the same cards at the same scroll positions returned identical readings. The same measurement shows the existing overscan margin does nothing for these cards -- they begin loading only once genuinely visible. So the policy is least-recently-seen: everything on screen is kept, and off-screen frames are retained eight deep. Eight is one full screen of buffer at the measured layout, which means scrolling down past a card and back never reloads it, and it sits above the ninetieth percentile session, so nine conversations in ten never evict at all. Recency also matches where the cost falls. Remounting is a full reload -- the raw route is no-cache, the page's scripts rerun, its externals refetch -- which is how a card with an unreachable CDN link stayed blank for tens of seconds. Under this policy the just-viewed card is always last to be evicted, so the cheap gesture is free and only a jump back across many turns pays, which is a wait that gesture already implies. Nothing in the frame is lost: pointer events are off, an overlay button takes every click, it is keyboard-unreachable, sandboxed without same-origin so it holds no storage, and it cannot scroll. Slot release now also reclaims settled slots, without which a recycled card would remount holding its old grant and slip past the concurrency budget entirely. Two defects in the policy surfaced from its own reverse controls rather than review: timestamps were recorded on entry, so the card watched longest was evicted first, and per-card eviction from partial snapshots over-evicted where one decision per scroll was needed. Live curve after: rises to sixteen, falls back to eight, and at every sample equals visible plus eight. A recycled card returning shows the pixel-liquid face, with no placeholder or unavailable copy. * fix(web): the feedback panel's translator prop was uncallable by type Its `t` was declared `(key: never, ...)`, which says no key may ever be passed, so the prop could not be used from the day it was written. It went unnoticed because its only external caller asserted its own translator `as never` to match -- two mistakes covering each other, with tsc satisfied at both ends. Removing the cast on the caller is what surfaced this. The vars type was wrong in the same direction: `Record<string, unknown>` is wider than the interpolator accepts, and the extra width is values that cannot be substituted into a string. Both now match the signature `useI18n` hands out and that the settings and design-browser panels already declare. * fix(web): the plan pill's bottom reserve has never actually applied The reserve was written, the class was attached, and a spec asserting that class stayed green -- but the pill still covered the last line, because the value was being erased by the cascade. A skin rule sets `padding: 18px 18px 20px` at equal specificity and later in source order, and that shorthand takes `padding-bottom` with it. What survived was `scroll-padding-bottom`, which a shorthand does not reset, leaving the rule half alive and looking implemented. Doubling the class name lifts it to (0,3,0) so it wins on specificity rather than on where it happens to sit in the import order. The skin rule is untouched; rewriting it would drag along the gap and background it also declares. The 52px is arithmetic the spec re-reads from the stylesheet rather than restating: 12px of pill offset plus its 32px height, and 8px of air. Measured in the browser on one page: removing and re-adding the class moves the bottom padding 20 to 52 and the scroll height by exactly 32, and the last row shifts 32px relative to the pill. Across a real run the padding is observed at both values as the reserve follows whether the turn has a plan at all -- deliberately not whether the pill is visible this instant, which is what stops the reserve from feeding its own scroll position. Streaming stability was checked rather than assumed: sampled every 250ms across a 32 second turn, the padding never recomputes and the last user message holds its viewport position to the pixel. The jump-to-latest threshold is unaffected: at the bottom the scroll position absorbs the same 32px, and away from it the gap grows by 32 against a floor of 320. This does not address the large blank that appears mid-run. That space comes from the tail spacer, which runs an order of magnitude larger, and collapsing it is a deliberate behaviour with its own reasoning. * fix(daemon): carry codex's patch through so its file rows show +N -M Codex file rows have shown a duration where Claude's show line counts, and a comment explained why: codex reports a path and a kind, nothing else. That is still true of `exec --json`. It stopped being true of `app-server`, which became the shipping default the same day the comment was written, and which carries the patch in a required `diff` field. The loss is not where the comment sits. `toExecItem` rebuilds each change as `{path, kind}` before handing it to the shared codex branch, so from there down the wire genuinely looks unchanged and the comment kept reading true. Narrowing the frame to match the older transport is the whole bug: same shape does not mean same information. The counting reuses the rules already in `diffStat` rather than adding a second opinion. Codex's two kinds line up with Claude's two cases, and `add` is the one that would be easy to get wrong: its `diff` is the whole file body with no prefixes, not a unified patch, so reading it as one would report every new file as +0. Matching Claude down to its trailing-newline quirk matters more than being independently right -- otherwise the same file reports two numbers depending on the agent. The patch itself never reaches storage. Tool inputs are persisted whole, so the counts are taken in the daemon and the text dropped; a spec builds a 20k-character patch and asserts the serialised event stream contains neither it nor an `@@`, and stays under 400 bytes. For comparison a Claude write on this machine persisted 47k characters of content. The rollback transport is covered rather than assumed: its recordings still produce byte-identical events, and a diff that is absent, null, or not a string leaves today's behaviour intact. The parity spec now asserts the superset in both directions, so a regression that stops emitting counts cannot hide inside the strip. Three more comments in the same file were describing the older transport, and the repo's own recorded fixture has carried a `diff` since the channel landed with nobody reading it. * fix(web): strike only what is dead, and surface why a tool failed Every step in the list was struck through, including the one with a spinner on it. The cause is not a selector reaching too far -- it is derived per row, and the condition was "abandoned or recalled", where recalled alone was enough regardless of what the row is doing now. It is also not occasional. Our own recall block instructs the agent to re-list the plan whole with finished rows still marked completed, so a turn that picks up an earlier plan recalls every row by construction. Traced through a real recording: eleven steps completed in turn one, all eleven re-emitted in turn two, all eleven struck. Codex reaches the same contradiction by a second path, where the first unfinished row is lit implicitly and can be both running and recalled at once. The condition is now "anything this turn actually touched is never struck": abandoned rows still strike, running rows never do, rows with content under them this turn do not, and what remains -- old business recalled but untouched, or opened once and closed unused -- still does. This overturns two cells of the spec's own table, which had recalled rows striking even while being worked on. Three existing cases pinned those cells; rather than delete them their sampling point moved to the row that is recalled and untouched, which is what they were really there to prove, each with a reverse control so that striking everything cannot satisfy them again. Separately, the failure reason was hardcoded to null one line after the failure itself was computed and one line before the same text was stored elsewhere, with no comment. Two designed outlets were dead as a result. It now carries the text the call returned, untrimmed beyond whitespace, and the row that displays it already truncates to one line -- so the open question is not whether to cut it but how the rest is reached, which is the same question as the button that was never wired. The compound-command label is left alone and recorded: segments are scored by verb and the highest wins, which picks the read half of `sed && rg` and names a file that read fine, while the failure came from the search. That scoring exists to name successful calls and mis-serves failing ones; fixing it needs its own spec. * fix: an empty thinking frame is a heartbeat, not an empty frame to drop Claude sends every thinking delta as an empty string -- the content never arrives, but the frames do, roughly one pair every 1.4 seconds. Yesterday's fix for a retired protocol added a stripper and dropped any frame whose stripped text was empty, which is all of them. The panel has shown minutes of blank ever since. The test for emptiness was asking the wrong question. It now asks whether the upstream sent characters at all: nothing sent is a heartbeat and goes out, characters consumed entirely by stripping is the markup case the previous fix was for and still drops. That distinction already existed in the lifecycle tracer under another name, so this moves an established rule to where it was missing rather than inventing one. The frames are also the transport's pulse. Replayed against a recorded 1150-second run through the real function, the old test left 943 frames with a 300-second silence; the new one restores 1357 and a 73-second worst case, which is what the silence detector was calibrated against. A second drop sat downstream and would have made the daemon fix invisible: the client's buffer also tested the accumulated text for truthiness, so no thinking event ever reached a message and the shell's thinking state could not light up during a live turn. It now records that a frame arrived separately from whether it carried characters, leaving the array identity untouched so a run of empty frames does not churn the message. The guard that empty deltas render no paragraph is kept, and verified by mutation rather than assumed -- the client has two such checks and removing only one still passes, so a spec asserting it needed both removed to go red. The other six thinking-delta producers are unaffected: four already gate on non-empty text, and the shared codex path is covered. * fix: recall the whole task list, and let the pill follow the agent Stopping a run mid-plan and then asking to continue produced a shrunken plan: the two finished steps vanished and only the two open ones came back. The filter that dropped them sits in the daemon, before the prompt is built, so the model never saw them -- no amount of prompt wording could have recovered what was already gone. That filter had no independent reason. It carries no note about token cost or about keeping the model on task; it was simply the literal reading of the feature's original name. What it did carry is a gate -- an empty list means render nothing -- and that gate moves to the render step, asking whether anything is still open, so a fully finished plan still produces no block and the body stays byte-identical. Two pieces of evidence say the completed rows belong there. The block itself already instructs the agent to re-list the plan whole with the finished rows still marked completed, which the filter made impossible to satisfy. And the spec's own table has a row for a step recalled after finishing in an earlier turn -- a row that can only be drawn if the agent re-emits it. Three guards against redoing that work, all placed before the list: the heading no longer promises unfinished items above rows marked completed, a sentence states plainly that the finished work exists and must not be redone, and the invitation to decide is narrowed to the open items so that not-redoing did not quietly become must-continue. The pill was reading the newest snapshot anywhere in the conversation, which is why an unrelated question could still show a step count from a turn that had nothing to do with it. It now shares the primitive the transcript card already uses, stopping at the newest assistant message: if the agent declared no list this turn, there is nothing to show. That is the same rule the recall design states -- what surfaces is whatever the agent chose to re-emit, never the client's own inference. The session-wide reader stays, with both call sites documented as the pinned-card-era lookup that is wrong for a live turn. Verified end to end after the daemon restart: the recalled list arrives with three of four rows marked completed, the agent opens by saying the first three stay done and touches none of them, and the pill and the card report the same count. * fix(web): reclaim the anchor spacer once it is the only thing on screen Sending a message anchors it to the top of the viewport, which needs a screen's worth of room beneath it that does not exist yet, so a spacer supplies it. While the anchor holds, that spacer shrinks as the reply grows. The moment the reader scrolls away it froze -- deliberately, to keep scrolling down from snapping -- and a turn that then produced a thousand pixels of text left a quarter screen of content above two hundred pixels of nothing. Distance from the bottom cannot decide when to reclaim it: a turn starts at zero and grows, measured here from zero to 981px, so any pixel threshold expires within seconds. The question is instead how much of the spacer is actually intruding on screen -- its height less the distance below -- acted on only past 52px, which is the reserve the plan pill already takes and therefore the smallest gap worth moving the page for. That quantity also says something the distance cannot: once it is positive, everything below the fold is ours rather than the reply's. Not oscillating is structural rather than tuned. A frame may move the view at most 24px, less than one trackpad notch, so the collapse can never outrun the reader's smallest deliberate scroll. And the decision is latched: a spacer is asked once whether it should go, then shrinks monotonically, so crossing the threshold repeatedly cannot toggle it. While the anchor is still held nothing changes -- that path swaps growth for shrinkage at constant height, and mixing the two is what would jolt the anchor, so this runs only after the anchor releases. Reported as jitter during a live turn and traced to the probe rather than the change: with every driver removed, two uninterrupted runs of 38 and 37 seconds show the scroll position never moving and the spacer never reversing while the content grows past 1800px. The scroll handler also stops scheduling frames for a spacer too short to act on, which is the idling that would have fed such a loop. One neighbouring guard moved with it. Its premise -- that following still works while a spacer is present -- is unchanged, but it pinned the spacer's height in the one situation this reclaims it, so it now checks following at every step instead of only at the end. * fix(web): restore the balance gate for accounts with no plan behind it An empty wallet in a personal workspace could send freely: an early return meant to spare subscribers had widened until it caught everyone. Its condition asks whether a model was selected, and one always is -- the agent's default fills in when the user picks nothing -- so every send in a personal workspace took it, and because zero is below the warn threshold it swallowed the hard block too. The judgment rotted in two steps rather than one. It began as plan tier crossed with whether that model is unlimited on the tier; one change replaced the local table with a live list and lost the tier half; the next removed the list and left only "a model is set". Each step was locally reasonable and the pair is not. The intent behind the last step is preserved exactly, and it was deliberate: the author rewrote those assertions and renamed them to say we should stop guessing whether a selected model is metered. Every case he wrote uses a paid tier -- the word "free" appears nowhere in that file -- so an account with no plan behind it was never considered. His cases are untouched and still pass. So the gate now engages only where the account is provably free. The tier is still available, through a resolver both callers already use in the same breath as this gate; only the per-model coverage is gone. When the tier cannot be read at all the request is allowed, matching what the old check did when its list was missing, so a failed read can never block someone who paid. Restoring the zero-dollar block unconditionally would have recreated the incident this rule exists to prevent -- an empty wallet is the normal state on an unlimited plan, which is what the original change was called for -- so it is restored only where correctness is demonstrable. One cell of behaviour changes for users: a personal workspace with no subscription and an empty wallet is now stopped before sending instead of failing partway through the run. Low balance on a free tier resolves to a soft warning that stays invisible, since both callers already suppress soft warnings for free tiers by an earlier ruling. * fix(daemon): read the turn boundary from every frame the CLI actually fills Claude Code 2.1.259 leaves `stop_reason` null on the assistant wrapper frame, which is the only place we read it. So `turn_end` never fired, and the per-turn state hanging off it never reset — a second turn's genuine inline HTML artifact was silently swallowed as an echo of a file the FIRST turn had written. Rather than swap one frame for another, read all three and dedupe by message id in `emitTurnEndOnce`: the wrapper frame (older CLIs and argv-compatible forks still fill it), `stream_event` -> `message_delta` (2.1.259 with --include-partial-messages), and the `result` frame (the only boundary a build without partial messages emits at all). Which one arrives is a property of the build in front of us, and the only trustworthy capability check is whether a given frame carried a value. Verified against six byte-for-byte recordings of a real 2.1.259 CLI, kept in tests/fixtures/claude-cli-recordings/ with the flags used to make them. Four corpus guards assert the recordings still have the shapes the tests depend on, so replacing them with hand-built fixtures cannot quietly vacate the suite. * feat(chat): name the file a write is targeting while its arguments stream Writing a 40KB page took the model 196 seconds, and for 140 of them the row said nothing but a running clock: `file_path` arrives in the first ~180 bytes of the arguments, but the row waits for the last byte of `content`. The daemon now scans the argument buffer it already keeps and, the moment the path is provably complete, emits `tool_input_target` — id, name, path, under 400 bytes. The client renders it as an EARLY FORM of the same call, so the verb, icon and file-name button are the existing ones; no new copy, no new branch. The arguments themselves never leave the daemon, and the row upgrades in place when the real `tool_use` lands. Measured against a real 2.1.259 CLI writing 27.6KB: the name appeared 102.8 seconds early, stayed put while the buffer grew from 240B to 27.6KB, and matched the final `file_path` exactly. Nothing half-formed is ever shown — a path whose closing quote never arrives emits nothing, and an in-flight call is not counted as a file operation, because the model has only announced an intention to write. * fix(web): show the low-balance reminder to every plan, not just three of them QA reported a Pro account at $1.79 starting a task with no warning at all (OPEND-2600). Tracing it found the card was invisible on FOUR tiers of a personal workspace, and for two different reasons. A plan-aware early return sat in FRONT of both the hard and soft branches, so any account that was not literally 'free' returned `allow` before the soft tier could be computed. Behind it, the render side filtered on a paid-plan set of exactly {plus, pro, max}, so free, `go`, and an unreadable plan were dropped even when the gate did produce a warning. Product ruling 2026-09-03: a reminder is not a block. A subscriber with an empty wallet still sends — that ruling stands — but they are still told, and so is everyone else. "They have a plan" therefore stops suppressing the reminder while remaining exactly the right reason not to HARD block: an account with no plan behind it has nothing but the wallet. The plan lookup now happens only where it still decides something, at or below zero. A wallet at $1.79 no longer makes any upstream call at all, which is both the fix and a small speedup on the send path. Spec: specs/current/chat-panel-decisions-sheet.md T37-T40. * fix(chat): make the queue's steer button interrupt the run and send The button offered to slip a queued message into the turn already running, by writing it to the agent's still-open stdin. Two measurements retired that idea. Only 2 of 27 runtimes declare `stream-json`, so 25 agents never saw the button at all. And against a real Claude Code 2.1.259, a user message written to stdin MID-TURN was simply never processed — the process stayed alive and idle for 180 seconds — while the same bytes written after the turn's `result` frame started a second turn normally. The daemon closes stdin at that same `result`, so the window we offered and the window the CLI accepts may not overlap at all. Interrupting, by contrast, works on every agent. The button now stops the running turn and sends the queued row immediately, reusing `sendQueuedChatSendNow` — which already tags the superseded run so its late terminal callbacks cannot pollute the replacement, and un-sticks preview comments left mid-apply. The hover copy says so, and the attachment carve-out is gone: attachments could not ride a steering frame, but they resend fine. The capability check went with it. There is no such thing as an agent that cannot be interrupted, so the button no longer has a disabled face to explain. Fixes OPEND-2602. * fix(chat): turn the fork icon the way the design draws it The draft's branch glyph is remix's `git-branch-line` mirrored across y=12: its third node sits at (18,18) and the branch runs DOWNWARD, where ours runs up. Verified point by point — all 92 coordinates match the reflection, the only difference being a float tail (8.417 vs 8.4175) — and it is not a stray export: all six component cells and the footnote carry the flipped path, and the prose agrees, "a divider drops in place, and below it is the new turn". The new conversation is below, so the branch grows down. Added under its own key rather than editing `git-branch-line`, which is an upstream remixicon asset: rewriting a name someone else's icon resolves through would silently change glyphs elsewhere. Three tooltips also went back to the draft's wording — 没帮助, 复制, 新开会话. The buttons themselves needed nothing: the 26x26 hit area, the corner radius, all four state colours and the 2px gap were already byte-identical. Not touched, and left for a product call: nine differences in the tooltip bubble itself (radius, padding, line-height, material, border, shadow, gap, wrapping, and the draft's 100ms fade). Those live on `.od-tooltip-layer`, a primitive shared by the whole app, so the draft cannot be applied there without deciding it governs every other surface too. * feat(chat): give the panel's icon buttons the tooltips the design asks for `data-tooltip` appeared exactly zero times anywhere under components/chat — the execution shell, tool rows, primitives, the error card, the upgrade card, reconnect and audio all shipped with `aria-label` and nothing a sighted user could see. The draft names three of them outright, and those three are now wired: the thumbnail strip's 查看大图, reconnect's 查看详情, and the error card's 联系支持. The conversation-history key was worse than missing: it carried a native `title`, the browser tooltip that appears after half a second in a style no one controls — precisely what the draft's own comment argues against. It now uses the same layer as everything else, points down as drawn, and wears the draft's outlined clock instead of a filled speech bubble. The draft also puts a new-conversation key in the panel header. Ours had the entry buried inside the history dropdown behind the word 新建, so the icon key is back, wired to the same handler, same disabled condition, same analytics. The dropdown item stays for now — a UI test locates it — so there are two doors to one room until product picks one. Nothing was changed on `.od-tooltip-layer` itself. * feat(chat): show tool work while it is happening, not after it ends Two halves of one product ruling — "show a long action as early as we can, and keep a clock running on it so people can feel where it is stuck." **ACP agents (opencode, AMR/vela, and the six others) hid every tool call for its entire life.** Rows were emitted only at the terminal frame, so across 141 recorded local runs, 855 of AMR's 2779 wall-clock seconds had a tool running with nothing on screen — and the hidden time almost exactly equals the total tool time, because a call was invisible for 100% of its span. The daemon had the first frame in hand within 2ms of the agent sending it. Bash was 58% of the loss, not file writes. Rows now open on that first frame and upgrade in place: 57.0s for a shell command, 67.2s for a write, 222.0s for a subagent task, each as ONE row. Across the corpus the path never changed under a row (0/202) and the name changed once (Task to Survey — a title-derived upgrade). **Claude's writes showed a name but no progress.** The path lands in the first ~180 bytes of the arguments while `content` still has tens of kilobytes to go, so the row sat still for minutes. The scanner already walking that buffer now also counts newlines, and the row grows: `+128 −0` on the same counting rule `diffStat` uses, so the number does not jump when the call settles (measured: last in-flight 734, settled +734). Edit and MultiEdit get the name but no count — `−M` is unknowable until `old_string` completes, and half a number is worse than none. The stopwatch needed no new machinery. It was already ticking once a second, independent of upstream frames; in-flight rows simply had no `startedAt` to measure from. Both paths now carry one fixed origin per call. Throttled twice over — 512 content characters for the write counter, 250ms plus a changed-payload gate for ACP — so a burst of identical frames cannot turn into a broadcast: 911 frames produce 350 events, and 1062 delta frames produce 55 counts. Neither is persisted; after a run the same information lives in the settled call, and storing it would give a reloaded conversation two rows for one call. Also corrects `tool-timing.ts`, whose docblock claimed codex emits `tool_use` at `item.completed`. It does not, and did not on the day that comment landed: only `web_search` does. The measurement behind it was real, the attribution was not — half of codex's shell calls are simply instant. * fix(daemon): keep a conversation readable when its artifact refs cannot be read `conversationChatArtifactRefs` carries the comment "a snapshot store problem must never make a conversation unreadable", but the guard it names sat around the ref query only — the owning-project lookup ran in front of it. That lookup reads a different table and can fail on its own, and when it did the throw travelled out of `listMessages` and the caller got no transcript at all. The failure was silent, which is why it survived: `langfuse-bridge` catches a failed message read and reports the run anyway, so a broken lookup looked like a turn that produced no output. Thirteen of its tests were red on this branch for exactly that reason. Artifact refs decorate a conversation. Losing them costs a thumbnail; losing the messages costs the conversation. Also updates the codex normalize expectation, stale since file rows started carrying `od_diff_stat`: a codex write used to show elapsed time where the same row under Claude showed `+N −M`, and counting the diff is what fixed that. Adds the missing control — no diff on the frame means no stat invented, so the older `exec --json` wire still produces byte-identical events. * feat(web): give tooltips the design's surface, and the panel its outlined icons Eight of the ten tooltip properties now match the draft byte for byte — radius, padding, background, text colour, border, shadow, line-height, and the 6px offset from the trigger. Going opaque also drops the backdrop blur, which is a straight improvement over the preview iframe: translucency was losing contrast against whatever the user's page happened to render underneath. Two are deliberately not applied. `white-space: nowrap` would break the UI, so it is guarded rather than adopted. The longest current tooltip is 202 characters (French, the single-file publish description) — roughly 1300px unbroken at 12px — and several tooltips carry unbounded user data such as a filesystem path or a document title. The draft's own comment explains the mismatch: there, a tooltip names an icon-only button in two or three characters. It never contemplated long copy, and `max-width` is why ours survives it. A reverse test now pins the wrap so nobody "finishes" this alignment later. The 100ms fade is unreachable here. Ours is a portal that mounts on show and unmounts on hide, so the node never travels from transparent to opaque and a `transition` on it would be dead CSS. Making the fade real means keeping the layer mounted — a behaviour change, not a value change. Icons: seven glyphs move to the panel-local outlined set, at the same sizes, with every call site outside the chat panel left alone. `Icon.tsx` and the remix map are untouched — the site-wide stroke convention was not adopted. Two of the seven turned out to be same-name-different-glyph rather than duplicates: the composer's palette has asymmetric dots where the status card's is solid, and the queue's remove is a different path from the tool row's delete verb. Play stays filled, because the draft draws it filled. * fix(chat): put a running clock on the image batch, and retire the D3 tests Six tests in the tool-status suite still asserted "a call with no result yet produces no row". That rule (D3) was struck a day earlier — the spec has said "a call gets a row when it is made, not when it returns" since 2026-09-02 (OPEND-2419) — and the in-flight work landed the behaviour the spec already described. Each one now states what it pins, who overturned the old rule, and which neighbouring rule was NOT overturned. B47 was not overturned. It only stopped firing here, because a shell with rows in it is no longer empty; its own guards live in `empty-shell.test.ts` and are green. Nor is the row deleted when a turn ends without it: `ToolRow.pending` already ruled that a call that never came back stays recorded and simply stops being drawn as a spinning orb. Verified by running it — the row settles to a frozen elapsed and a neutral mark, and never reaches the database at all. One of the six was a different bug entirely: a failed row now carries its reason after the name instead of the word "Failed", since `failReason` stopped being hardcoded null. The image batch row had no clock while it was generating. Not an empty slot — the in-flight form has no slot at all, and both data paths returned null (`pendingMediaBatchRow` hardcoded it; the event path demanded every task have an `endedAt` first). It now runs from the earliest task in the batch, applied AFTER the S19 merge — that merge sums settled values, and summing live ones would count the same wall-clock stretch several times over. Also fixes an assertion that could never fail: the failed-turn test asserted its record body was absent, but a failed shell is collapsed and a collapsed shell defers mounting its body, so that read was null no matter what the shell contained. It now expands first and asks the real question. * fix(chat): give the top-up card back when the upgrade card cannot draw `suppressCard` on the insufficient-balance failure means "another surface is already saying this" — that is why the reconnect line uses it. The upgrade card is not that kind of surface: it only renders once a wallet read comes back with a definite number. When the read cannot settle, nothing renders at all, and a run that died over money leaves an empty screen with no way forward. The commit that introduced the hand-off said so in its own message. So the hand-off now holds only while the surface it hands to is actually on screen. `failureCardHandedToAmrBalanceCard` names that distinction, and the white card — with Top up and Retry — comes back exactly when the wallet read settles with no number. The 2026-09-02 "one card, not two" ruling is untouched everywhere the upgrade card can draw: this is the case where it cannot. That hole was live: the P0 recovery spec had been green for five runs and went red here, because its Vela endpoint is a dead port and the read never settles. Two neighbouring fixes ride along. The balance specs clicked send the instant the button rendered, but it is disabled while the transcript loads, and a click on a disabled button is a no-op — so `waitFor` burned its full three seconds and the failure looked like a product bug. The margin was between 1ms and 5ms of load latency; they now wait for readiness. And `isPaidAmrPlan` is gone: it had no callers left, and its name answered its own question wrongly — `enterprise` is a paid plan it called unpaid — which is the exact shape of the bug that hid the low-balance card from four tiers. * feat(chat): let the tooltip fade, and leave one door to a new conversation The draft's 100ms fade could not happen here: ours was a portal that mounted on show and unmounted on hide, so the bubble never travelled from transparent to opaque. It now stays mounted and toggles opacity, with `aria-hidden` keeping the hidden bubble out of the accessibility tree — the thing an always-present tooltip node gets wrong if nobody checks. Keeping it mounted did surface one real regression, caught by an existing spec: the export-screenshot path waits exactly two frames, and two frames into a 100ms `cubic-bezier(0,0,0,1)` the bubble is still about a quarter opaque — it would have printed into the image. Dismissal by activation (click, Enter, Space) is therefore instant, and only hover-out fades. That reads better anyway: a bubble you dismissed should be gone, not dissolving. The draft's timing is kept over the repo default (200/140ms) because this is the chat panel's own draft; the curve is still a decelerate one, so the "no ease-in" rule holds. New-conversation had two doors — a header key and a dropdown item — for one action, one handler, one analytics event. The header key stays, per the draft, and the dropdown item is gone along with its now-dead CSS. Both were gated on the same handler and the same disabled condition, so no state loses the action; three UI specs move to the header key's testid, keeping the step that opens the menu so their "the menu now holds only the list" assertions stay meaningful. The word is unified inside the panel: 新会话, plus the verb the draft itself supplies at `body-components.html:1243` — "新会话从这里开始". So the family is 新会话 / 正在开始新会话… / 无法开始新会话, and one button no longer says "fork" the moment you press it. * docs(spec): record the 2026-09-03 chat-panel rulings and what they cost * feat(chat): number a forked conversation instead of calling it a fork Starting a new conversation from a reply produced "{title} fork" — the last place the word survived after the control labels were unified on 新会话. It now numbers instead: 「商品列表页」→「商品列表页 (1)」→「商品列表页 (2)」. The number is assigned by the daemon, not the client. Reading the project's conversations, picking the number and inserting all happen without an await in between, and better-sqlite3 is synchronous, so no two forks can land on the same number — a guarantee the client could not make. It also gives the CLI the same names for free: `od chat new --fork-after` used to create an untitled conversation, and now matches the UI, which the dual-track rule asks for. Only a suffix WE could have written is stripped before renumbering — one half-width space, half-width parens, one to three digits, no leading zero, at least 1. So a conversation someone named 「方案 (2024)」 keeps its year, and an older 「商品列表页 分叉」 keeps the word and becomes 「商品列表页 分叉 (1)」. Existing titles are not migrated; both shapes will coexist, which is recorded where a later reader will find it rather than left to look like a bug. Gaps are not backfilled: with (1) and (3) present the next is (4), not (2). Reusing a number resurrects a name the user just deleted, which reads as the deletion having failed. `chat.forkedConversationTitle` is gone from all 19 locales — a number needs no translation, and the daemon has no locale to translate with. * feat(chat): open a running command, and stop fighting the reader for the scrollbar The draft has said "执行中展开 → 完成收起" all along (component 11, `body-components.html:1004`), with the in-progress cell annotated "终端实时追加, 限高滚动自动贴底". We only implemented the failed half, so a command that ran for a minute showed one line and a clock while its output sat behind a fold that was not merely closed — a closed fold never mounts its body at all. Opening it exposed two things the fold had been hiding. The terminal jammed itself to the bottom on every new chunk. Scroll up to read a line and the next chunk, 250ms later, yanks you back. It now uses the same follow logic as the thinking card, which tracks the reader's INTENT rather than inferring it from position — inferring deadlocks, because following writes the position back to the bottom and the inference then reads that as "still following", so a trackpad nudge can never accumulate enough distance to escape. That logic could not see the terminal at all, which is the second thing: the box stops growing once `max-height` clamps it, and terminal output arrives as NEW CHILD ELEMENTS, which the one-time `Array.from(box.children)` snapshot never observed. The thinking card slipped past both — its children are fixed and it grows its own height. A MutationObserver closes it. Cost, measured rather than assumed: one batch of new output re-renders the terminal exactly once; five clock ticks used to re-render it six times and now render it once. A reloaded conversation with thirty collapsed command rows carrying fifteen thousand lines between them mounts zero terminal nodes. * feat(chat): give the AMR command row the same foldable, so its live output has somewhere to go Until now the two command-row shapes were split by whether the agent sent a human-readable `description` alongside the command: · Claude family (`{command, description}`) -> foldable, terminal body · AMR / ACP family (`{command}` only) -> one line, no body That split landed exactly backwards against which runtime actually streams partial output. The daemon emits `tool_in_flight` (carrying up to ACP_IN_FLIGHT_TOOL_OUTPUT_LIMIT chars of stdout-so-far) only on the ACP path; Claude's stream-json carries nothing at all between `tool_use` and `tool_result`. So the one link that really has live output was the one with no place to draw it, and the one with a place to draw it never had anything to put there. The draft never painted this state. `执行 <命令>` appears exactly once in the whole file (body-components.html:909) and that instance is settled -- static terminal glyph, a resolved `8.4s`. It does paint running single-line rows elsewhere (:1037), so the gap is specifically exec-while-running. The second clue is in the same line: that button's aria-label reads verbatim 「查看 npm run build 的输出」 -- the draft already calls this row "see the output", it just never drew what comes after "see". This fills that half, on the product's 2026-09-03 ruling to unify the two shapes. Built to be the same row, not a lookalike: the summary keeps :909 verbatim (verb + monospace command + seconds slot), the body reuses the same `div.code`, and open/close runs through the same `lifecycleOpen`, so "open while running -> close when done" and "a reader's own click wins" did not have to be written twice. Two deliberate restraints, both recorded at the call site: the command is not `elide`d (that helper preserves file extensions and would turn `wc -l a.md transcript.html` into something that reads like a different command -- truncation belongs to CSS here), and `lifecycleOpen` carries only `row.pending`, because this branch is guarded by `!row.failed` and adding `|| row.failed` there would be dead code. Cost is measured, not assumed: 500 output lines still cost 501 line nodes and a constant skeleton; the stopwatch ticking five times still re-renders the terminal zero times; and 30 collapsed rows on first paint mount zero terminal nodes. The skeleton is 14 nodes/row here versus 12 for the titled branch -- the extra two are the `<span class=file><code>` pair that sets the command in monospace. That is pinned at 14 rather than rounded up, so the next time it grows we see it. * docs(spec): settle the running-output and fork-title questions, open the failed-file one Three of the four items under 「同日未决」 got answered today; recording them with the evidence, and with the cost, so nobody later reads a ruling as drift and "fixes" it back. T47 (running tool output opens, closes when done) closes the three-way question. Worth writing down that I recommended the wrong branch here: I suggested showing a tail on the row, from intuition, and the product pulled up the draft cell that says 「执行中展开 → 完成收起」 in as many words. The unification across both command-row shapes on top of that is a spoken ruling the draft never painted -- the note says so, and says which two lines in the draft argue for it anyway. T48 numbers a forked conversation instead of calling it a fork. Half-width parens everywhere for now; the product said explicitly not to spend time on the CJK full-width question yet. T49 opens the failed-file-row expansion as in-flight rather than settled, because it is a spoken ruling against a draft cell that says otherwise -- :917 is a single line with a button. Whoever lands it has to say that at the call site. Also split the deferred items out into their own block, so "the product said leave it" stops looking like "nobody got to it". The English past-tense one is bigger than I first described it: the whole verb family reads as finished while the tool is still running. * fix(chat): a row that never came back is not a row that is still running Opening a running command row (T47) read `row.pending`, and `row.pending` means `result == null` -- "this call never came back", not "this call is running right now". Press Stop and the in-flight call never gets its `tool_result`, so `pending` stays true forever: that row stays spread open, and stays open every time the conversation is reloaded afterwards. An `npm install` with a few hundred lines of output takes the screen on its own. The fix is not to clear `pending` when the turn ends. The glyph at the head of the row is picked from it -- `row.pending ? <StatusMark …/> : <toolIcon/>` -- so clearing it paints a finished tool icon on a call that never finished. `closeRunningSegments` already wrote that rule down for the todo half of the same problem, verbatim: marking it complete is saying something on the agent's behalf that it never said. Both quantities were already in hand one line apart. The glyph reads `running ? 'running' : 'pending'`; the expansion read `row.pending` alone. Two adjacent decisions about the same fact, using two different measures -- that was the whole defect. They now share one named `lifecycleOpen` whose docblock says which measure answers which question and why failure is independent of both. Real trigger path, not a constructed one: `ExecutionShell` computes `running = shell.status === 'running' && !shell.stopped`, and on cancel `build-turn-blocks` sets `shell.stopped = true` and calls `closeRunningSegments`, which only walks todo segments. So `running` flips false while `row.pending` stays true -- exactly the shape the new tests feed. Worth recording what the probe found, because I had assumed otherwise: only `canceled` shows this on screen. `failed` and `error` collapse the shell itself, and with `deferBody` nothing inside is mounted at all -- so those two never displayed the stuck row. That is now a test with the reason attached, so nobody "completes" it into three cases and gets a selector miss that reads like a broken toggle. Twenty-six existing tests had to say `running` out loud. They were feeding a pending row with no `running` and asserting the running behaviour -- data that matched both "in flight" and "left over after Stop", with only the first asserted. Making it explicit is what the fix is about, so each file now carries the note rather than a silent prop. * feat(chat): let a failed file row open, so the error text is actually readable The failure reason is deliberately not truncated -- `build-turn-blocks` says so in as many words: a stderr can run to several hundred characters, and how short to cut it is the product's call. It was then pasted into a single line as `{verb} {file} · {reason}`, where CSS ellipsised it away. Handed over, and unreadable. Product's ruling, pointing at the failed *command* foldable: 「能下拉展开吗? 像这样」. So the file row gets the command row's treatment. Stating the basis plainly, because the draft says otherwise and the call site now says so too: :917 paints a file failure as a single line with a button and a duration; only :1018 is `<details class="fold is-fail" open>`. This is a spoken ruling against that cell, not draft fidelity. It also collapses the draft's two failure spellings, which spec S1 has had open as "are these two intentionally different?". They were: the one carrying a reason inlined it and dropped the 「失败」 mark; the one without kept the mark. Now the summary is always :917's line -- verb, filename, 「失败」, duration -- and the reason moves to the body. What is left of the difference is only whether there is any text to show: with text it opens, without it stays the single line. A foldable that cannot open is worse than a row, because the chevron lies. Nothing is lost when collapsed, because failed rows default to open through the same `lifecycleOpen` the command branch uses. The reason is on screen from the first frame; it just sits on the second line. The body reuses `Terminal` rather than growing a new box: 104px cap, its own scrollbar, sticky-bottom following. At this layer a few hundred characters of stderr and a few hundred lines of build output are the same thing -- text the agent handed back verbatim. Two of the new test's own assertions were wrong before the implementation existed, and the fixture guards caught both: `tool_result` pairs by `toolUseId`, not `id`, and zh-CN's write verb is 「新建」. Recording that because a fixture that silently produces no failed row would have made the whole integration case vacuous. * docs(spec): mark T49 landed, add T50, and close out what S1 was asking T49 shipped, and it answered S1 on the way -- the draft's two failure spellings are now one, differing only in whether there is any text to show. Flagging that we collapsed them rather than the designer answering, because S1 was filed under their name. T50 is the regression T47 introduced and what it cost to fix: reading `row.pending` for auto-expansion when that field means "never came back", not "still running". Includes the measured finding that only `canceled` surfaces it, so nobody widens the test to three cases and hits a selector miss that reads like a broken toggle. Three things move into the open list: whether a one-line reason should keep an inline copy (cost: the same sentence twice when expanded), the designer sign-off on S1, and `onShowFailure` having no caller anywhere in the repo -- left in place, not deleted. * chore(mirror): rebuild the gallery so it shows today's rows The mirror page is the only place a person can judge cell-by-cell whether we match the draft, and it is a generated artifact -- leaving it on yesterday's render means a reviewer compares against rows that no longer exist. 54 more `<details>` and the terminal box now appearing on failed file cells is exactly the two rulings that landed today: both command-row shapes fold, and a failed file row opens with the error text inside. * docs(chat): count who actually lives on the raw-command branch, and fix what I got backwards I wrote in yesterday's commit that "opencode's bash input is just `{ command }`" and that Claude's Bash "always" carries a description. Both were written from intuition. Counting the corpus -- 179 langfuse recordings plus the live vela capture in `w123-acp-inflight-frames.json`: claude (stream-json) 47 / 48 carry a description opencode, direct CLI 71 / 71 carry one codex 0 / 569 carry one AMR / ACP (vela -> opencode) none; the recorded rawInput is {"command": …, "timeout": 180000} So the opencode claim was inverted. Direct opencode always sends a description; it is the ACP hop that drops it. The same agent lands on different branches depending on which wire it comes in on, which is worth saying out loud because it is not what anyone would guess. And the branch's largest resident is codex, not AMR: 569 calls across 36 recordings, none of them with a description. That means the change did more than give AMR's live output a home -- it gave codex a body at all, and 569 calls' worth of settled output had never reached the screen. This file's own header already said "codex has no description throughout"; I read past it. Claude's "always" is also not true, just nearly: one call out of 48 arrived as a bare `{"command": "sleep 4"}`. Left as a measured ratio rather than a rule, since a rule is what went wrong here. No behaviour change -- comments and test docblocks only. * fix(chat): bring the send button back to the draft's box, and drop the border and shadow with it The draft gives the send button the same 28x28 box as every other footer icon button and only promotes it with a fill and a pill radius: .composer .bar button { width: 28px; height: 28px; … } .composer .bar button svg { width: 16px; height: 16px; } .composer .bar .send { width: 28px; height: 28px; background: var(--text-strong); color: var(--bg); border-radius: var(--radius-pill); } Product shipped 36x36 with an 18px glyph, a 1px border and a --shadow-xs. None of that came from a decision about the send button -- it is the generic `button` + `.primary` primitive geometry in `styles/primitives.css`, never reconciled against the draft. The earlier stroke-icon pass saw it, called it "another matter", and deferred. This is that matter. It is also a gap the composer row's own "one control system" pass left open: that comment names Send among the controls to unify at 28px, and then the 28px selector list does not include it. So this closes a hole rather than opening an exception. Two things worth putting on the record. The executing pill inherits this box, so it goes 36 -> 28 too. The draft never draws an executing state at all -- three `.send` instances, all resting -- so nothing there argues either way. Both states moving together is the point: leave one at 36 and pressing send would resize the control under the cursor. 13px label in a 28px pill has room. Removing the border made four `border-color` declarations on the stop states dead -- there is no border box left to color. Removed rather than left as decoration that reads like it does something. The test had to read the border and shadow out of the stylesheet text rather than from getComputedStyle: jsdom rejects `border: var(--stroke-thin) solid var(--text-strong)` outright as an invalid shorthand and reports `none` whether or not the declaration is there, which is a vacuous assertion. That trap is written down in the test. * feat(codex): put the search row on screen when the search starts, not when it returns Product red line, verbatim: 「调用前(流式传输时)就要显示在界面上并开始计时, 绝对不能调用完了才出现在界面上」. Swept all four codex tool families against it. Three already comply -- command_execution, file_change and mcp_tool_call all emit at `item.started`, and their `item.completed` handlers are guarded no-ops. `web_search` was the only one that waited: its started branch consumed the frame and emitted nothing, so the row appeared only once the search came back. The reason it was written that way is still true, and it is a trap rather than a mistake: the started frame's `query` is genuinely empty, the query IS the row (`toolTitle` and `searchPattern` both read it), and emitting a `tool_use` there would trip the `codexToolUses` guard and make `item.completed` a no-op -- locking in a blank 「搜索」 row forever. `tool_in_flight` walks around that. It is a generic contract event, not an ACP-only one: it does not touch `codexToolUses`, so the settled pair still lands with the real query, and the client retires the early row into the settled one by shared id. One row, one clock, and the term fills itself in. This is the same shape the ACP family already uses, where `tool_use` is likewise emitted at terminal. Sizing it honestly: there is no `web_search` in the 179 local recordings, so that is not evidence. The nearest measurable call of the same class is claude's `WebFetch` at 7.42s. Seconds, not milliseconds -- which is exactly the blank screen the red line is about. The clock goes in `stampToolTiming` rather than the parser, because the parser is pure and that gateway already owns "fill the timestamp if it is missing" for all 27 adapters. ACP's own `firstSeenAt` is left alone. The id has to be the one `JSON.parse` leaves after codex serialises `id` twice per frame -- the `exec-…` value, not `item_2`. Both frames resolve to the same one, which is what lets the early row retire instead of drawing a second search row. The old test asserted the opposite behaviour. Rewritten rather than deleted, carrying its original reasoning forward, since that reasoning is what dictated using `tool_in_flight` instead of `tool_use`. * test(chat): prove the codex search row survives the trip to the screen The daemon-side test only showed the event leaves the parser. That is half a chain: the event could be emitted correctly and still be ignored downstream, or drawn as two rows. This walks it to the row. Three things it pins: with only the early form on the wire there is already a row and its clock reads 4.0s, not 0 -- "a row exists" and "that row is timing" are different claims and only asserting the first would pass with a dead stopwatch; once the settled pair lands there is still exactly one row; and that row carries the real query, which the early form never had. Worth recording how the first version of this test went wrong, because it looked exactly like a broken fix: fed straight into buildTurnBlocks it drew two rows and no query. The retirement step (`dropSupersededInFlightToolUses`) runs in AssistantMessage, before buildTurnBlocks -- so the test had bypassed the very mechanism the fix relies on, and was measuring a path production never takes. The last case turns that mistake into a guard: skip the retirement step and you get two rows with an empty one first. So if anyone later calls buildTurnBlocks from somewhere new, they are told what they left out instead of shipping a duplicated row. No removal verification on this one, and it should not be claimed: it covers existing web behaviour against a new event shape, so there is no new implementation here to take away. * fix(chat): actually shrink the send button -- the earlier change lost the cascade The previous commit set `.composer-send` to 28px in chat.css and its test went green. On screen nothing moved. `styles/viewer/routines.css` sets the same button to 36px through `.app .composer-send`, which is (0,2,0) against chat.css's (0,1,0) and is also imported later in index.css -- both tiebreaks favour the file nobody edited. Measured in a real browser before touching anything: getBoundingClientRect() returned 36 x 36. Worse than a no-op, in fact -- the icon had dropped to 16px because that is a React prop rather than CSS, so the button was shipping a 16px glyph in a 36px box, matching neither the draft nor what it replaced. The test could not have caught this. It injected only chat.css, so the rule that wins was never in the document -- it excluded the defect from the measurement and then reported a pass. It now loads chat.css and routines.css in index.css order and adds `.app` to the body, and it was confirmed red at 36px before the fix, which is the only reason to trust it green now. routines.css carries a note at the winning rule saying both files govern this button, so the next person to change its geometry does not repeat this. Cost of the box change: the executing pill inherits the height, so it moves 36 -> 28 with the button. The draft has no executing state at all -- three `.send` instances, all resting -- so nothing there decides it. Moving both together is what matters, otherwise pressing send resizes the control under the cursor. Verified after the fix in the same browser: 28 x 28, 16px icon, transparent border, no shadow. * docs(spec): the upgrade card has no dismiss, and record why the ticket says otherwise OPEND-2597's acceptance text asks for "dismiss, then stop prompting for this session". The product read that back today and said it looks wrong -- it should keep showing. Recording the ruling with the reason it is not a close call. That sentence is a dialog-shaped requirement applied to a card. What ships is an inline card in the conversation flow, not a modal: ChatPane says so at the call site, on a 2026-08-26 ruling that a warning you can continue past gets a card and not a dialog, and it does not block sending. It also contradicts T41 head-on, which is the part that settles it. Hiding the card does not change the balance -- the next run still dies on money, and now nothing on screen says why. That empty screen is the exact hole T41 exists to close. And there is nothing to be spared from: the card sits in the flow and scrolls away with the content, so it was never in the way to begin with. * fix(chat): keep Add-to-chat with the selection while scrolling, instead of hiding it Reported: 「选中文本后,"添加到对话"按钮怎么一滚动就消失了? 消失不会再显示吗?」 Both halves were true. Any real scroll called hideBar(), and the only thing wired to bring it back was `selectionchange` -- which scrolling does not fire. This was a deliberate decision with a test pinning it, not a slip. It came from OPEND-2541: the bar is `position: fixed`, so an un-recomputed bar stays put and becomes a ghost pointing at nothing. Hiding it was the fix chosen then. The draft settles which behaviour is right. At729fa43ce7, components.css:3136 anchors the bar to the selection itself -- `.sel { position: relative }` + `.selbar { position: absolute }` -- so it follows content for free and can never go stale. Following is the drawn behaviour; hiding was an artefact of our `fixed` positioning that got mistaken for a rule. The ghost is still prevented, by re-anchoring every frame rather than by disappearing. Two things had to change together. The scroll handler now re-renders from the geometry it already measured instead of hiding. And concealing no longer clears `geometryRef` -- that was the second half of the bug: the handler bails on `!previous`, so the first hide made every later scroll a no-op and nothing short of a fresh selection could recover. `selectionOnScreen` keeps the half of the old behaviour that was right: once the selection is off screen the bar must go, or edge-clamping parks it on the panel rim over unrelated prose. It measures the selection's whole span, not its first rect, so a selection taller than the viewport still counts as visible when only its middle is on screen. That span logic arrived untested -- honestly self-reported -- and every existing case in this area exercises the degenerate union-rect fallback where first === last, which cannot tell min/max from either endpoint. Added two cases driving real multi-rect `getClientRects()`, and confirmed they are load-bearing: narrowing the helper to `firstRect` alone turns the tall- selection case red. Removal-verified twice over: reverting the component returns the original three failures, and stubbing `selectionOnScreen` to `true` alone reddens the hide/restore pair. The prior test asserting 「关闭,并等待下一次 selectionchange 才重新出现」 is rewritten rather than deleted, carrying its OPEND-2541 reasoning forward, and now also asserts the bar moves by exactly the scroll delta -- "still present" alone would pass for the very ghost that ticket was about. * fix(chat): three reported defects, and one my own last fix introduced Four agents worked these in parallel; I verified every claim before landing. **Media artifact cards (OPEND-2598, OPEND-2608).** One cause, and it is not about media or about retry. `produced_files_json` has exactly one writer in the whole repo -- the browser, computing it inside ProjectView's SSE `onDone` closure and PUTting it back. Leaving the project, or merely switching conversations, aborts the controller; the AbortError path returns without calling any handler, so `onDone` never runs. The daemon still promotes the row to succeeded -- publishing the terminal state while withholding the artifact association that terminal state was supposed to carry. On return the replay path refuses past the five-minute anti-loop window, so the card is unreachable for good. The three null columns are the signature: `upsertMessage` writes `producedFiles ? JSON.stringify(...) : null` and `[]` is truthy, so null means no terminal write ever happened rather than "the diff came back empty". The daemon now writes a floor at the run-terminal chokepoint: one idempotent UPDATE guarded on the column still being null. A floor, not a verdict -- the client sees the pre-turn snapshot and the daemon does not, so the client stays authoritative. It fills only `producedFiles`, leaving the delivery-verification path exactly as it was. Proven at the daemon HTTP boundary with the client deliberately never played, and end-to-end on a real runtime with real claude and nothing but curl -- on HEAD that column can only be written by a client PUT, so its presence is the proof. **Memory (OPEND-2606, OPEND-2607).** The settings page was more optimistic than the daemon: one green master toggle bound to `enabled` alone, while `chatExtractionEnabled` sat false behind a second tab -- and a failed config read painted every switch on. Both now derive from one invariant, that the screen never claims more than the daemon confirmed, with a config we could not read claiming nothing. The memory card existed and matched the draft already; what was missing was anything that produced it. The prompt describes memory the model *applied*, not memory it *wrote*, so a turn that grew the store 22 -> 25 correctly showed nothing. Extraction finishes after the turn ends, so there is no run event left to hang a card on; a bounded poll on the turn's falling edge resolves the written ids to names and emits one host-authored message, which persists and survives reload. Deliberately not a second EventSource -- the toast already holds the one connection this surface can afford. **And one of mine.** `selectionOnScreen`, from the Add-to-chat scroll fix an hour ago, decided visibility by overlapping the selection with the panel -- which silently assumed the panel is measurable. When its rect has no height, the overlap is false for every selection and the bar never appears at all. That is treating "not measured" as "measured false", and it turned up as one red in the full web suite: a scroll-following test that mocks the selection rect and not the panel's. An unmeasurable panel is no longer evidence, with the case pinned so it does not come back. Verified centrally after all four landed: root typecheck, guard, daemon build (server.ts is @ts-nocheck, so its build is the only real check), and the full web suite -- 10,232 passing, no failures. * feat(chat): a batch of visual directions is four, not six Product ruling 2026-09-04 on OPEND-2584: 「VISUAL_STYLE_BATCH_SIZE 先改成 4 吧」. The previous 6 came from a 2026-08-27 ruling about how 「换一批」 walks the catalogue. Recording at the constant why this is NOT "matching the draft", because that reading would be wrong and would come back: chat-panel-feedback.md carries an earlier and blunter ruling that names this very card -- the counts in the draft are mock data, and 「不能因为稿子是 4 张就不做「看全部」」. So four is a number the product picked again, not a spec derived from the drawing. 「换一批」 is untouched; a batch is simply two cards smaller. Six tests were pinned to six. Where a number was really about the batch, it now derives from the constant instead of being written out again -- the keep-two case asserts `BATCH_SIZE - keep.length` rather than 4. Where the number is genuinely independent, it is recomputed and the arithmetic is shown: walking 22 catalogue entries four at a time needs six batches, so 「连点四下」 became 「连点五下」. Two fixtures had to move rather than be renumbered. The stale-entry case fed a six-long previous batch; it now feeds four, keeping its shape of two retired plus two surviving. And the B53 gallery test clicked 「Premium pitch」, the catalogue's fifth entry -- inside a batch of six, outside a batch of four, so the helper simply could not find it. It now clicks the fourth, which still proves the point that matters (what is spread out is this batch, not the whole catalogue) without depending on the batch being any particular size. * fix: four reported defects, one of which no test could have caught Four agents in parallel; I verified each claim before landing. Root typecheck clean, 8,116 web tests passing. **OPEND-2597 — the upgrade CTA sent paying team-Max owners to buy what they already own.** The branch that decides where Upgrade leads reads two bits: plan tier, and whether the viewer can manage billing. The audience bit was truthful. The tier bit was not: `/api/workspace/context` hard-codes `planId: null`, because that context is assembled from vela's workspace directory rows, which carry no plan field at all — and ProjectView passed only that context to the branch, so it was the sole tier source. Every user on the project page therefore resolved to below-Max, and the auto-recharge destination was unreachable in production. Home was never affected; it already passes the projected billing summary, which is exactly what ProjectView now does too. Worth stating why the tests were no help: all of them inject `planId` directly onto the context — the one field production never populates. The suite was green against a shape that does not occur. **OPEND-2588 — a batch turn opened one tab, not all of them.** The ruling was 「打开全部产物 —— 是全部的**主要**产物」, and that qualifier turned out to matter more than the count. The ranking function does three jobs, not two: is this an artifact, which *kind* is this turn's deliverable, and which one of the equally-ranked winners. Only the third is overturned. Opening everything that passed the artifact filter would have let a plan.md take a tab beside index.html — widening the criterion, which the ruling explicitly rules out. That also answers the 16-image worry from OPEND-2571: that turn produces 16 images plus an HTML file, HTML outranks media, so it still opens exactly one tab. A flood needs a turn with 16 media files and nothing higher-ranked. No cap was invented for it. **OPEND-2585 — nineteen seconds of blank screen on batch upload.** First render snapshotted the uploaded server paths out of sessionStorage, and that key is only written after the last upload answers — so the creation gate had to hold until uploads finished, and upload duration was paint duration. The paths are genuinely needed, but by the send, not by the paint: the gate now releases once the project row is persisted, a small handoff module parks the picked files and owns their object URLs, and auto-send reads server paths at dispatch time instead of at mount. Each URL is revoked when its file's server path lands, with a sweep for failures. **OPEND-2500 — next-step guidance after a deck.** Not reproducible: a real deck generation emitted all three markers and rendered them in the design's shape. It also closed the long-open "audit five prompt paths" question — there are not five composers, there is one, and it is not conditioned on project kind. Proven observationally rather than by reading: the accepted markers had to carry that run's nonce, which only this run's prompt could have shown the model. Two agents declined work honestly and both were right to. One wrote a test, found its metric was blind — the assertion's precondition never fires in that harness — and deleted it rather than keep a green reading that proves nothing, flagging that area as unverified. The other skipped `git show HEAD:… > …` for removal verification because peers had uncommitted work in the same files, and neutered its own logic in place instead. * fix(media): associate a generated file that lands after its run has ended The floor added earlier only covers files present in the run's own filesystem diff. A media generation often is not: `od media generate` polls for 25s and exits 0 with "still running" past that, nothing awaits the in-flight promise at run end, and video or long audio routinely take longer. The bytes arrive after the snapshot. (OPEND-2609, and the half of OPEND-2608 left open.) That ordering is asserted, not assumed: the test checks the output file does not exist on disk at the instant `runStatus` flips to succeeded, and that assertion held on every run, red and green. The second finding changed the fix's shape. The client misses the late file too — `onDone` computes produced files against the daemon's artifact paths at that same instant, then persists the result, so the column ends up non-NULL and wrong, usually `[]`, which is truthy. A NULL-guarded floor could never have closed 2609, whose whole shape is a user who IS watching: the audio plays in their project pane. So this had to add rather than backfill. No linkage was invented. `media_tasks.run_id` already comes from the tool-token grant and `messages.run_id` is already on the assistant row; one select bridges them. The association is emitted by the media task, after the wait-notify so `od media wait` latency is untouched, because the task is the only party that knows the bytes landed — the terminal chokepoint by definition cannot. It stays a floor. It only ever prepends, so every client entry survives verbatim and a re-run is a no-op. And it stands aside while the run is still live: a task completing mid-run must not write the column, or the terminal floor reads it as client-owned and drops everything else that turn produced. That was a regression I introduced and the removal check caught — neutering the gate turns exactly that case red. Two guards were deleted for being redundant under the rule. A compare-and-set was a second belt over the post-await re-read; with no await between the read and the update, nothing in this single-threaded daemon can interleave, so it only ever fired when the re-read was already broken. A capacity cap was unreachable and protected against nothing — one entry per completed task. The constraint that makes the re-read sufficient is now written at the statement, so nobody adds an await there later. Known and not closed: a late file still gets no `message_artifacts` ref row. Its bytes are already frozen by the existing snapshot hook, but attaching that snapshot additively needs a different store API than `replaceMessageArtifacts`. The card renders from producedFiles, so the reported symptom is closed; the immutable-preview half is not, and deserves its own change. Also: my previous commit swept in this test's 289-line draft. This is the finished 550-line version with all four cases. * fix(prompts): tell each runtime the name of the plan tool it actually has A user asked, in as many words, 「先用 todo 进行一轮规划」. The agent planned — and wrote the seven-item plan as body text. The turn made three tool calls and none was a plan tool. So it was not disobedience; the plan went out through the wrong channel. Two sentences in our own prompts sent it there. The slim charter said: if the runtime supports task lists use one, **otherwise provide a numbered plan in your response**. That asks the model to self-assess whether it has such a tool, and hands it a sanctioned prose branch for when it decides it does not. Prose was the compliant reading. And it had no way to decide correctly, because the note naming the tool was gated on the Claude stream format. Codex received no tool name at all. The gate's own comment asserted the premise that made this look fine — that codex, opencode and the ACP agents have no such tool. They do: codex has `update_plan`, opencode has `todowrite`, and the daemon already translates both into the canonical TodoWrite the Todos card renders. The wiring was never the problem; nobody told the model the tool existed. So each runtime is now told its own tool's real name, in the sentence shape the Claude note already used. Claude's note is returned by the same constant and is byte-identical. The charter is untouched, so its size ceiling is unaffected; the cost is a couple of hundred bytes on codex and opencode runs, which previously paid nothing. A second prose escape hatch turned up next door: the bundled todo-write atom, which `ensureCoreQualityStages` puts on every pipeline that writes a design artifact, said the agent may use "TodoWrite (Claude Code) or an in-prompt list" — naming a tool codex lacks and reopening the branch, in the same prompt. It now points at whatever plan tool the session exposes. That is not the site this incident hit (the conversation carried no plugin snapshot) and the test says so, to stop it being mistaken for the root cause later. What the new test proves is narrow and stated as such: that the composed prompt names that runtime's real tool. Whether the model then calls it is not something a unit test can pin. One assertion does check wiring — for opencode, whose parser forwards the tool name unchanged, the named tool must satisfy the canonical predicate, since naming one it rejects would render the plan as an ordinary tool row: the same invisible plan, one layer down. Two runtimes were deliberately left out. mimo and the ACP family are structurally capable, but I have no verified tool name for them, and guessing from family resemblance is precisely what the Claude Code 2.1 rename punished. A recorded trace per agent would settle it. No prose-scraping fallback, and it would have been the wrong answer: it would have manufactured a card from a plan the agent never emitted, and hidden this defect instead of surfacing it. * fix(chat): stop a reloaded OD Next task from gluing its runs into one turn Three things the user saw at once after leaving for Settings and coming back: the previous turn's 「已确认」 summary sitting inside the current turn, two 「思考过程」 blocks in a row, and raw markdown. They are not one bug. **The turn-gluing is real, and the trigger is the reload.** That conversation is one OD Next task with three runs, and `strategyTaskRunIndex` has exactly one writer in the repo — the history GET. Nothing on the live SSE path sets it, so the fold that joins runs into one turn only happens once rows come back from `listMessages`, which is precisely what a remount does. Live it never folds; after Settings it does. The fold concatenates N runs' events into one flat list, and the block builder was written for one run: one `doneSeen` latch, one done key, one shell. Run 0 ends with a `<question-form>`, which trips implicit-done, so every later run's text was appended to run 0's still-open prose block and every later tool piled into run 0's shell. That single 2048-char block is the 「已确认」 summary glued to two later answers; the merged shell is the doubled 「思考过程」. The daemon already mints one `done_key` per run and sends it before any model output, so a second one in a stream *is* a run boundary — no contract change needed. At each boundary the ending run gets its conclusion lifted (run 1 emits no done marker, so its answer would otherwise stay buried in the shell) and its shell settled, then the per-run state resets. The invariant is that folding is a view-level join: the folded turn's blocks equal the runs' blocks concatenated. **The raw markdown is not this bug, and is by design.** The only raw `**` and backticks in either path are inside the execution shell, which renders plain text on purpose — the conclusion outside the shell is the part that goes through markdown. Markdown counts are conserved across the fold and no heading renders raw. That matches the user's later screenshot, where markdown was correct while 已确认 was still misplaced. If it recurs it is a separate fault. **The stray dot in 「搜索 . 14 处」 is independent and pre-existing.** A bare `ls` with no positional argument resolves to the literal `.`, and the search row prints it as the target. Present live too. Left for its own change. Also landing here, two rulings from the same session: The first thinking cell no longer reports elapsed. Its number is derived by filling the gap before it, and for the turn's first thought that gap starts at the turn start — the same anchor the shell header uses, so the two showed the same figure one line apart. Later thoughts fill a gap between tool calls and keep theirs. Suppressed at the render layer so the datum stays available to the header and drawer sums that depend on it. And an ACP turn that has produced nothing for sixty seconds now says 「等待首批输出中」 instead of an empty shell. This corrects my own brief: I sent that agent after `waiting_for_first_output`, which turns out to be emitted only by the ACP bridge — Claude never sends it, so the chain I described was not the reported problem. Rendering both cases showed the real gap: the header's clock was never missing, and 「在等什么」 is answered by 「思考中」 for Claude and by nothing at all for ACP. Gated so it can never replace 「思考中」 or revive the withdrawn mid-turn silence hint. Two translations of that key are wrong now that it has its first reader: `tr` says "first input" where it means output, and `th` reads oddly. Not fixed here. * fix(chat): render markdown inside the shell, and stop the reveal corrupting text Product overruled the split that kept shell text plain: 「谁说按纯文本画不是 bug 的?? 都要 markdown 啊」. Narration, thinking paragraphs and abandonment reasons now render markdown like the conclusion always has. The hard part was never the markdown call, it was the per-character reveal sitting on top of it. Its docblock reasoned that attaching to the last `<p>` is safe because a new paragraph is a new element that starts revealing from its own beginning — true only while the block is a flat list of paragraphs. In a markdown tree an element's identity changes as text arrives: `#` renders as a paragraph and one character later `## S` is a heading. The reveal keys its state by element, so every flip dropped what had been shown and replayed text the reader had already read. So it no longer keys on the last block at all. One stable wrapper hosts the whole tree, which is what the thinking lane already does — and is strictly more correct than before, because the reveal already walks the subtree's text nodes in document order across node boundaries. It never needed a per-paragraph host; it needed one that outlives the content. Partial syntax stays visible mid-stream — `**bo`, `**bold`, then bold. The alternative, plain while streaming and markdown once settled, is worse: it replaces every text node at once, so a whole block flashes instead of two stray asterisks. The thinking lane has always rendered this way, so a second policy would be the new inconsistency. **And the streaming test caught a bug we already ship.** `restore()` decided "React has not overwritten this node" by comparing the node's value against the truncated prefix. When a markdown token closes, React's new value often IS exactly that prefix, so the guard misfired and pasted the stale tail back: `先看一下**规` became `先看一下**规规格`, permanently, because React then believes the node is already correct and never writes again. Reproduced with the reveal and the renderer alone, no shell component involved — which is the shape the thinking lane uses today, so **this is live on main** and corrupts transcripts whenever a throttled snapshot lands with a token closing on the truncation boundary. A string comparison cannot distinguish "I truncated this" from "React just wrote it", so it stops guessing: a per-host MutationObserver reports which text nodes React rewrote since the last drain, and those are never restored. No signature change, so the thinking lane gets the fix without knowing about it. Also landing here, three carried-over defects called in by the user: A bare `ls` no longer reports the literal `.` as its search target. The draft has exactly one search row in the whole file and it holds a real term; it draws no directory listing at all, so nothing there sanctioned a placeholder. `.` was not a modest stand-in either — the row renders the pattern inside a file button, so the user saw a dot styled as something clickable. This file states the rule twice already ("don't guess a target", "don't fabricate a clickable file"); that `|| '.'` was the one place breaking it. `rg --files` without a glob had the same fabrication. Turkish said "waiting for first *input*" for a key that reports output — inverted meaning, now fixed. Thai is worse than the "awkward" it was reported as (no word for output at all, and theatrical register), but diagnosing a translation and being able to write one are different things, so it is left for a native speaker with two candidate phrasings recorded. And the OD Next path now carries the plan-tool note. My own brief named the wrong site: the early return in `composeSystemPrompt` is never reached in production, so the fix as briefed would have been dead code. It went into the per-run context block instead, which covers both composers — deliberately not the cache-stable head, where a per-runtime sentence would split the prompt-cache prefix for every task sharing a strategy version. Costs 263 bytes on codex, 0.4% of that turn's prompt assets, and nothing at all for runtimes with no verified tool name. Mirror gallery rebuilt: the shell's DOM changed from sibling paragraphs to one wrapper per narration block, so the comparison page was stale. * feat(chat): 思考中显示实时 token 计数 思考行右侧显示上下箭头 + token 读数,数字随流实时变化。取数走 Claude stream 自带的 thinking_tokens,不是估算 —— 之前仓库里那句「estimated_tokens 走不通」的结论是在看不见系统帧的前提下量的,而系统帧没进日志是因为 handleObject 把它们丢了。 显示规则按产品口径:token 有变化就显示 token,长时间不变才退回计时; 第一段 thinking 永远显示 token;刷新页面时读数不从零涨上来。 - 契约新增 thinking_tokens SSE 事件与 ExecutionShell 的 thinkingTokens 字段 - hasVisibleBrandAssistantEvent 补上 thinking_tokens / artifact_focus 两支: 只带着读数的一轮仍然是空的,它描述这轮花了多少,不是这轮说了什么 - historical-turn-baseline.json 重刷:唯一差异是壳上多了恒为 null 的 thinkingTokens 字段,结构/分张/行归属都没动 - 顺带:zh-CN/zh-TW 队列引导措辞统一为「引导」 * fix(web): 发送后先上屏再做余额预检 (OPEND-2614) 点发送到消息上屏之间只有一个 await:OpenDesign Cloud 的余额预检,而它坐在 setMessages 上面。预检对工作区身份已解析的项目会打两个并行请求,其中 /api/workspace/billing 带 freshness=authoritative,强制一次 Vela 上游读, 不吃缓存 —— 1~2 秒的空窗全在这里,不在渲染。 改成先画后判:用户消息和「进行中」占位先上屏,预检仍在落库和 POST /api/runs 之前结算。被拦截时把画出去的这一轮收回(retractPaintedTurn,三条拒绝路径共用 的同一个漏斗)并照旧进发送队列。收回用的是上屏那一刻的快照而不是按 id 过滤: 重试时画出的数组里含着原有的用户消息和保留的失败尝试,按 id 拼不回来。 两个自己引入的坑一并处理: - setChatSeed(null) 是 ChatPane 的 React key,留在闸门后面等于「先上屏, 1~2 秒后再把面板拆了重建」,会亲手制造 OPEND-2615。与上屏同批。 - 预检窗口内第一次能按到停止了。加了流式标记检查,但不收回 —— 停止本身 已经结算了这一轮,收回会删掉用户自己的消息。 OPEND-2615 不成立:已由d287845f0e修复(把钉住的平滑动画去掉)。把那一行 还原回去,chat-anchor-to-top 立刻红 4 条,其中「回复迟迟不来的那一轮」报 scrollTop 4200 vs 3988 —— 正是工单描述的「顶出屏幕」。建议引用该 commit 关单。 * fix(daemon): 机器离线时给出网络卡片和重试按钮 离线机器发起的请求死在 DNS 解析,不是 socket 被重置。 clientEnvironmentFailureDetail 认得 ECONNREFUSED / ENETUNREACH,却不认 ENOTFOUND / EAI_AGAIN / EHOSTUNREACH / getaddrinfo —— 于是这一类掉进最后的 兜底桶:AGENT_EXECUTION_FAILED、retryable:false、正文是 CLI 的生英文, 卡片上只有〔联系支持〕〔导出日志〕,没有重试。用户只能重打一遍 prompt。 run-error-catalog 的 R-054 早就记了这条(「fetch failed 无 cause → stream_error」),一直没人关。 把判据提成具名的 ENDPOINT_NEVER_REACHED_RE,并在注释里写清「解析失败」和 「连接被重置」是同一个原因的两种说法。与 agent 无关,codex/opencode 同样受益。 修后同样的输入:卡片从「任务执行失败」变成「网络环境不对/网络连不上」, 多出〔去设置〕〔重试〕,恢复端点后点重试 2 秒内重跑成功。 * test(web): 队列引导措辞守卫、问题卡描述换行、Plane 工单证据留档 * fix(codex): 让 codex 重新用上原生计划工具,并接上它的推理 token 读数 codex 0.153.0 出厂就是 `tools.update_plan.enabled = false`,于是计划变成了正文 里的散文。真机 A/B(argv 与 daemon 完全一致、同一句 prompt):不带这个开关 `turn/plan/updated` 是 0 帧,带上是 4 帧。两条传输都补上 `-c tools.update_plan.enabled=true`,无条件加,理由同 `codexReasoningSummaryArgs` —— 条件化会动到前缀缓存。 洗清一个此前的误判:失败那条 run 的 `promptTelemetry` 里逐字含有 「Your plan tool is `update_plan`」,模型被告知了名字仍然没调;而 `planToolNoteForRuntime` 从未进过任何发布版,所以「beta .7 还好的」那个包里 codex 是靠自己调的。根因在 CLI,不在我们的 prompt。 推理 token 走 `total.reasoningOutputTokens`(24 次读数 0 次回落),不是 `last` —— 后者是**每次 API 调用**的量,一轮中途会归零。normalize.ts 里把它称作 per-turn 的那句注释是错的,一并改掉。 token↔计时的切换加迟滞,两个门槛统一 20 秒(产品 2026-09-04 拍板)。同一段真实 语料下:单门限 8 秒会翻 38 次;20/20 与 45/20 都是 8 次,而 20 秒在真卡住时早 25 秒接管,所以取 20。claude 的帧密得多(p50 1.4s),20 秒对它从不触发,翻面 仍是 0 次。两个常量故意保留成两个,以后要按 runtime 拆开只需改数。 * fix(chat): 给早期工具行一个真的起点,别让秒表从「参数流完」才起算 早期行本来就有:参数 JSON 一边流,`tool-input-path-scanner` 一边扫,路径可确定 就发 `tool_input_target`,之后按 512 字符节流报行数。缺的是**时间原点** —— 这个事件不带 `startedAt`,而 `stampToolTiming` 只给 `tool_use` / `tool_in_flight` / `tool_result` 盖戳。 两个后果: - `Edit` / `MultiEdit` / `NotebookEdit` / `replace` **只发** `tool_input_target` (改动量在流中途算不出来,`tool_input_progress` 永远不发),这几类的行带着 文件名、秒表是死的。 - 归并时早行没有起点可传给落定行,落定行只好退回「参数流完那一刻」当原点, 整段流式跨度凭空消失 —— 这就是用户看到的那个 `0.1s`。 改成发 `content_block_start` 的时刻,和 `tool_input_progress` 报的是同一个 不可移动的原点。 证据不是读代码:真跑 claude 2.1.260 写了一个 27,458 字节的页面,逐帧打时间戳 再回放进真实解析器 —— 路径在 +25.5s 就可确定,落定 `tool_use` 在 +121.8s, 本可提前 96.3 秒上屏。 `w115` 那条防参数外泄的守卫把事件的键集钉死了,白名单放开 `startedAt` 的同时 加了 `typeof === 'number'`,守卫仍然证明没有参数字节逃出 daemon。 * fix(web): 升级卡念对钱包,并拆掉会把它永久静音的那颗开关 (OPEND-2597) **念错钱包。** 补查走的是 `/api/integrations/vela/wallet`,那条路由请求里没有 任何 workspace 参数 —— 它是右上角那颗账号读数。而发送前的闸门为同一笔钱专门走 `/api/workspace/billing?scope=workspace&freshness=authoritative`,两处注释都点名 不许回落到账号钱包。后果不是数字略有出入:团队钱包 $0、个人账号还有 $12.50 时, 卡会用橙色说「余额可能撑不完下一个任务」,而真相是「现在无法开始新任务」,且他 把个人钱包充满也救不了这个团队。 顺带修了 3 条钉住缺陷的旧测试:项目放在团队工作区却只 stub 账号钱包,正是生产 不会出现的形状。补的是被后端证明过的真实夹具,不是放宽断言。 **拆掉低余额「不再提醒」。** 首页那张弹窗勾选后写 `open-design:amr-low-balance-warn-optout:v1`,而项目页发送前闸门的 soft 档读的 是同一个位,读到就返回 `allow` —— 用户以为关的是首页那个弹窗,实际把项目页的 升级卡也永久静音了。与「升级卡不该有关闭态,余额条件成立就一直在」的裁决直接 冲突(产品 2026-09-04:「拆掉吧」)。 影响面限于 soft 档(硬拦从来不受这个位约束)和发送前那道闸门(跑到一半那条路 不读它)。已经写进用户机器的那个键成为死数据,不做迁移 —— 全仓无任何遥测/诊断/ 设置页在读它,而一次性清理要挑时机、要处理 localStorage 抛异常,还会留一段永远 跑不掉的死代码。 **阈值维持 $2**(产品 2026-09-04:「然后是 $2」)。工单与交付稿里的「额度 < 5 美金」作废;`chat-panel-edge-audit.md` 里那条「$2 → $5 待产品同意」随之关闭, 理由是它会放大触发面。 * feat(chat): 读数自己数上去,记录卡在 done 标记到达时就收起 **读数不再跳变。** 产品原话「token 最好也有个增长的过程,而不是直接从 100 跳到 200」。一天里这条需求改了三次方法:先是「要滚动」,看到实物后「太花哨了,自然 一点」,最后给了止损授权「实在不行就不要动画了,直接数字自增的那种就行,自增 的单位可以随机一些」。落在最后一版:**没有任何 CSS 动画**,只有数字自己往上走。 中间那版(每一位一条 0–9 字带 + transition)整个删掉了,组件、样式、测试一并 清干净,没留下名不副实的守卫。删的理由记在 `CountingNumber.tsx` 头上,免得有人 再加回来:199→200 时个位和十位要**倒着**滚过 9 格、百位正着滚 1 格,方向相反、 距离差 9 倍,而变化最频繁的恰恰是最闹的那一位 —— 这不是调参能解决的。 步长 `remaining / stepsLeft` 自适应(差 40 走大步、差 3 走小步)再乘 0.6–1.4 的 抖动,最后一步精确补齐。**永远不过冲** —— 屏幕上任何时刻都不会出现比已收到读数 更大的数。目标回落(新一段 thinking)直接落定,倒着数等于替 agent 说「它想少了」。 400ms 的预算不是凭手感:上限由上游帧率定,claude 的 `thinking_tokens` p50 是 1.4s(400ms 约占 29%),数不完就追不上真值,屏幕上就是个落后的假数。 **收起提前到 done 标记。** 判据是两个信号相与:共享契约的 `eventsHaveAuthenticatedDoneConclusion`,以及那之后有非空正文块。只用后者会让 隐式 done(`<question-form>` / `<artifact>` —— 那是 `buildTurnBlocks` 内部的 分块判断,不是产品说的 done 标记)也把壳收掉,`stream-cursor-removed` 当场红: `deferCollapsedBodies` 会在收起时把身子丢掉,正文整个从 DOM 里消失。 手动展开不受影响 —— `userToggled` 那个闩本来就靠比对开关值区分自动与手动。 顺带撤掉了收起动画那段 `::details-content` 网格:产品要的是**时机**,没要动画, 而它是这次改动里唯一动到壳身子格式化上下文的东西(`.body` 变成 `overflow:hidden` 包装下的网格项),而里面还嵌着一个限高滚动盒。零成本嫌疑, 先摘掉。 * fix(chat): 封面晚到时把产物卡换成快照,别把整个会话留在活 iframe 上 规格 §4.1 写死了:HTML / prototype / **slide** / document 的卡面只渲染 `<img>` 快照,不再挂该文件的 iframe。用户看到 deck 是活的,不是因为快照没做, 而是**输了 466 毫秒**。 真机数据:打包版里 `opendesign-seed-pitch.html` 的 `capture_state` 就是 `ready`、缩略图字节也在。但 ref 落库 17:13:05、封面 ready 17:13:06 —— 间隔 616ms,而客户端只在 run 终止后 **150ms** 重拉一次(`scheduleConversationMessage Refresh`),之后再也不拉,`cover.ts` 也不推任何东西。于是卡片在整个会话里停在 §6.4 的降级支上,直到整页刷新。 这正是规格第 505 行规定却从未实现的那半句:「pending thumbnail 不出 placeholder,直接走降级支;**后台 ready 后消息投影更新**」。 封面**故意不 await**(「一轮对话不该为了一张缩略图多等几秒」),所以补的是推送 而不是等待:daemon 在 `attachChatArtifactThumbnail` 之后发一个**瘦事件** (只有 projectId / conversationId / messageId,不带 refs),web 收到后复用现有 重拉。让 `listMessages` 仍然是「ref 是什么」的唯一权威 —— 事件重复或丢失最多 多一次 fetch,不会画错卡。降级支原样保留。 红测钉的是**时序**,不是分支:既有用例全都直接把 `artifactRefs` 当 prop 注入, 所以分支一直是对的、时序从没被测过。daemon 侧刻意在 run 已终止之后才订阅 (真实用户处境),先断言此刻 ref 还不是 ready 再放行渲染;web 侧先断言是 iframe 且没有 img,投递事件后断言换成 img 且 iframe 消失,另加一条反向控制 ——别的 projectId 的事件不得触发重拉,否则「见事件就重拉」也能蒙混过关。 顺手拦下一个真 bug:`projectEventToAgentEvent` 是一条**结尾没有守卫的收窄链**, 排除已知类型后直接假定剩下的是 `live_artifact_refresh` 并读 `evt.phase`。新事件 加进联合后会掉进去,变成一张 phase 为 undefined 的假 live-artifact 卡。 * fix(collab): 别把 daemon 已确认的个人项目当成共享只读 (OPEND-2624) `sharedReadOnly` 里的 `unknownStatusReadOnly` 是一条 fail-closed 且**没有出口** 的判据。`statusUnknown` 的条件是 `syncState === null`,而 `CollabClient.get()` 对任何非 2xx 直接 throw、`pollStatus` 吞掉异常 —— 所以 `/collab/status` 只要对 这一个项目持续失败,`syncState` 就永远停在 null。 唯一的解药 `knownUnshared` 读 `cachedTeamProjects(context)`,而那个缓存的 key 含 `role`。实测两个端点的 role 不一致:`/api/workspace/context` 给 `owner` (目录里的真实角色),而 `/api/projects/:id/workspace-scope` 是 `project-workspace-scope.ts:65` **硬编码的 `member`**。两个 key 不同 ⇒ **只要你是 workspace 的 owner/admin,这个缓存必然 miss**,历史上加过的两条 「别 fail-closed」补丁对 owner 全部失效,一次 status 失败就是永久只读: 聊天、新建、上传、编辑、导出全灭。 「为什么同工作区其他个人项目正常」也是这条解释的:缓存 key 不匹配是工作区级的, 但只有在 status 失败时才致命 —— 其他项目 status 正常返回 local_only 就没事。 改法是给判据一个出口:daemon 已经确认这个项目是 `personal` 时,让它压过一次 拿不到的 `/collab/status`。这不是新规则 —— `ProjectWorkspaceScope.visibility` 的契约注释原文就是「visibility answers whether the project itself is a private draft or shared with the team」,而 `materializePulledTeamMirror` 对每一个拉下来 的 team mirror 都写 `visibility: 'team'`,所以本机的 `personal` 严格意味着 「这台 daemon 没有任何记录说它是谁的共享项目」。daemon 的写闸读的正是这一行, 所以这是让 UI 的只读闸和执行闸用同一个权威。工作区级冻结和 materializationPending 两条保守路径原样未动。 红测的夹具把壳层 context(owner)和项目 context(member)**拆开**喂 —— 既有的 `use-project-collab.context-seed.test.tsx` 用同一个 `role: 'member'` 对象喂进去, 所以它天然看不见这个缺陷。403 响应体是从跑着的 daemon 上 curl 实测抄来的。 * fix(chat): 历史内容一挂载就是落定态,不再重播一遍逐字显示 `useCharReveal` 没有「这个宿主刚挂载」这个概念:新宿主一律算 `shown = 0`, 于是 **DOM 里已经有的东西全被当成刚到达的字符**,切成 `.rv` 片段排进动画预算。 触发路径不是边角情况,是默认的「先问后做」流程:`foldStrategyTaskTurns` 把两个 物理 run 折成一条 assistant 消息(run 0 发表单、用户答、run 1 干活);run 1 还在 跑时整条消息的 `runStatus` 是 `running`,`isAssistantMessageStreaming` 因此返回 true;这个窗口里任何一次重挂载,正文块就带着 run 0 的全部历史进场,整个重播。 刷新页面和从设置页返回(`chatSeed` 翻转导致 ChatPane 换 key)各是这条的一半。 `markHistoryReplayLanded` 永远抓不到它 —— 它只标 daemon 重推的增量,挂载时就 已经在 DOM 里的内容根本不走那条路。 判据钉在**首帧**:`.rv` 数为 0,且没有排任何一个等于 reveal 自身时长的 timer。 只断言「最终会显示完整」的测试在改动前就是绿的,钉不住任何东西。 ⚠️ 这条和 2026-08-27 的裁决(「不是真正流式的也保留一个流式效果」)在**唯一 可观测的那个形状上**正面冲突:「历史随宿主一起到达」和「非流式 agent 一次性 吐完、也随宿主一起到达」在 DOM 里逐字节相同,渲染层分不出来。这里执行较晚的 那条(挂载即落定),代价是非流式 agent 的一次性回答不再淡入。要两个都要,必须 由数据层告诉渲染层「这一段是刚到的」,那在 ProjectView 的 createBufferedTextUpdates ——已在文件头标注待拍板。 08-27 那批测试没有被静默删除:8 条「挂载时带内容、期望有 span」改写成「先挂载 再增长」,每条守的性质(预算上限、单位加厚、片段上限、不丢字符、空白不重复) 都还在测,并补了一条反向对照钉住「同一个字符串随宿主到达时现在是落定的」。 * fix(chat): 音视频生成不再画成生图,停掉的历史回合不再自称进行中 **OPEND-2625 音视频被画成生图。** `build-turn-blocks.ts` 里一行 `.filter(task => task.surface === 'image')` 把音频/视频任务**整条丢掉**, 不是「少画一行」而是导致画错:任务被丢掉后拿到空切片,退回解析命令输出, `total` 取 1,`line.mp3` 被塞进 `<img src>` —— 破图。引入者是媒体任务首次接线 那次(`bb4292e82b`),当时这一行只装得下图片。事实一直都在:daemon 的 `media_tasks` 记了 `surface`,路由逐字回传,命令行的 `--surface` 还是必填。 去掉 filter 之后三类任务混进一条全局队列会出新问题:daemon 的批本来按 surface 分组,而取批靠「连着同一个 batchId」认边界,两类批并行在飞时列表交错,游标撞到 第一条异类任务就停、该批剩下的格子永久取不到。所以不是加个 if,而是**每类一条 队列 + 各自游标** —— 纯生图会话的那条队列就是从前那份列表本身,行为逐字未变。 新增的 audio/video 文案与图标在规格里**一个字都没有**,是按工单的「预期」拟的, 待产品过目。D34 那条两形态切换的裁决没动,只在每一档里换文案/图标/单位。 **OPEND-2626 停掉的历史回合显示 Working、步骤回退成 Not started。** 终态其实是 对的:`end` 帧落盘了,`run_status` 也是 canceled,`buildTurnBlocks` 走到 `shell.stopped = true`。错在画这一层,两处各说了一句假话——壳头 `if (shell.stopped) return t('chat.record.running')` 写出「进行中」,和真在跑的 回合**逐字同一个词**;`markFor` 把 `stopped` 落回 `'pending'`,于是三个步骤全部 读成「未开始」。 为什么只有历史回合中招:唯一说实话的那句「已手动停止」在 AssistantFooter 上, 而它 `opacity: 0`,只有最后一轮或 hover 才现形(OPEND-2542)。发过后续消息之后 这一轮不再是最后一轮,那句话在鼠标划过去之前看不见 —— 屏幕上常驻的唯一陈述 就成了「Working 12m 8s」。 步骤记号的名字用「未完成 / Unfinished」而不是「已取消」,因为 `stopped` 有两个 来源:用户按了停止,以及轮次 succeeded 但 agent 收尾没再发一次清单 (`closeRunningSegments` 的原话:「我们只知道它没跑完就结束了,不知道它成没成」)。 对第二种写「已取消」是编一个没发生过的事实。实线圈区别于「从没开始」的虚线圈。 ⚠️ 这一条推翻了 `build-turn-blocks.ts` 里一条旧裁决(「手动停止不是第四态, 壳头仍写进行中」)。依据是那条裁决的前提已经不成立——它当时说「又和紧跟在下面 那行『已取消』自相矛盾」,而那行后来被 2542 改成 hover 才可见。**待产品追认。** 四条钉着旧裁决的既有用例逐条写了翻案理由后更新,没有静默改。 * fix(chat): 滑杆默认值不许越出模型自己声明的范围,轮播箭头不许盖住卡片 **OPEND-2622 声明 1–5 却提交 0。** 0 产生在 web 组件的「默认值进状态」这一条 路:`buildInitialState` 把模型给的 `defaultValue` 原样落进答案、**从不 clamp**, 而拖滑杆和敲数字两条路**都过** clamp。于是**人一旦动过控件值就合法,从头到尾 没动过的题反而是越界的**;屏幕上还同时摆着两份真相(数字框念 0、滑杆停在 1)。 序列化和解析器都是无辜的,它们如实转抄。 修的是「让模型的推荐值服从模型自己声明的范围」,用户自己写下的东西(提交历史、 恢复的草稿)一个字不动 —— 专门留了一例钉住「历史越界标量照原样念」,防止修过头。 撤掉实现复验时发现第三个调用点撤了仍全绿,补了「流式后到的题」一例才让三处都承重。 更根上一层没修:`core-slim.ts` 的 `<question-form>` 契约只列了 `range` 是合法 type,**整份契约从没提过 min/max/step**,而 §5 又要求模型给每道非视觉题一个 合理默认值 —— 等于让模型自由发挥数值、宿主照单全收。要根治得在契约层要求 range 题必须带 min/max,那是另一件事。 **OPEND-2633 轮播右箭头盖住卡片。** 我们自己错了两件: - **11px 被数了两遍**。稿子的 `.vnav { inset-inline: 11px }` 定位祖先是整张卡宽, 而我们的祖先 `.qf-visual-stage` 已经用 `margin-inline: 11px` 吃掉了这条 gutter, 里面又写一次 → 箭头往里挪 11px 扎进卡片区。讽刺的是代码注释自己写着「我们的 定位祖先已经正好是这一沓了」,然后还是按旧祖先写了 11px。 - **`z-index: 6` 是我们自己加的**,稿子的 `.vnav` 一条层叠都没写,`.vopt` 带 4/3/2/1 —— **稿子里是卡片盖箭头**,我们写 6 把关系反过来了。 改后重叠阈值从卡宽 360px 降到 338px。窄于 338px 仍会重叠,但此时按稿子的规矩 是卡片盖箭头而不是箭头切卡片。要在极窄栏彻底不碰得动 `--qf-fan-w`(我们 200px、 稿子 152px,是 2026-08-27 产品裁决横过来的),没动。 * fix(web): 接上前两条修复在 ProjectView 里的落点 这个文件同时是 `1bc480a340`(封面晚到换快照)和 `a17a22e32a`(个人项目被判只读) 的接线处,两条改动在这里没有重叠的 hunk,但**分开提交会让那两个 commit 各自 不完整** —— 前者少了收 SSE 事件后触发重拉的那一支,后者少了把 `projectVisibility` 传给 `useProjectCollab` 的那一行。合并成这一条补上,并在此 写明依赖关系,免得将来二分时把它们当成独立可回滚的点。 - 封面晚到:`handleProjectEvent` 新增分支,复用现有的会话消息重拉;配套一个 ref 破除 TDZ(重拉函数定义在 handler 之后)。 - 个人项目只读:从 `useProjectWorkspaceScope` 取 `projectVisibility` 传下去。 * test(daemon): 把「表单答完之后约束蒸发」这件事钉成红测(暂 skip,等产品裁决) OPEND-2623:用户明确写了 "Do not create or modify files",答完 Question Form 之后系统照样进生产、开始写文件,靠人手动 Stop 才没落盘。 查下来不是「prompt 里漏了一句」,而是**根本没有机制能表达「这一轮不产文件」**。 四道闸门各自单独就够让它落空: - 路由只有 `direct_edit` / `full_plan` 两个值,两条都以「必须有一个可运行入口 文件」收尾; - 计划契约里 `canonicalDeliverable` 是必填字段,「这次不产文件」序列化不出来; - 结局枚举没有「答完了、无需产出」这一档; - `plan_ready` 即自动发车,零条件。 更根上一层:决定要不要进这套编排的地方**压根不读用户说了什么**,只看项目的场景 画像。而 production 阶段实际发出去的 5,270 字节里**没有一个字是用户说的**。 产品其实有一个「不写文件」的载体(Ask 模式的 `CHAT_MODE_OVERRIDE`,措辞已写好), 但两头都断了:UI 入口被 B57 拿掉、Home 固定 design;而 OD Next 路径会把 `composeSystemPrompt` 整个换掉,那段 charter 从不组装。 六条红测分别钉住六个断点,**故意不去让它们变绿** —— 要绿必须先有产品裁决 (做成模式还是意图 / 契约要不要能表达「无交付物」/ 自动进生产要不要闸门), 自行选一种形状就是编产品规则。因此整个 describe 暂时 `.skip`,文件头写清了依据 和解封条件,CI 保持绿。 夹具字符串全部照抄打包版真机落盘的 `strategy_task_runs.final_text`。 顺带记一条:daemon 在四个调用点把 `nativeSessionResume: true` 写死,那是断言不是 校验 —— CLI 上下文被压缩后原话还在不在,从落盘数据看不出来。所以 production 阶段的模型可能连约束都不剩,这只会让结论更糟。 * fix(daemon): 一条 assistant 行只装一个 run 的流,别让后继 run 被吞进上一行 用户报「刚打开一个 project,对话里的结论重复了两遍」。不是画重了,是库里真有 两份:同一个 production run 的事件既写进了 clarification 那一行,又自己存成了 下一行。 ## 第二个写手是 web,不是 daemon - daemon 那侧 run 的 `state.json` 证明 production run 的 `assistantMessageId` 自始至终指向它自己那行,从没指向被污染的那行。 - 被污染那行第 239–1009 条里有 **341 个 `thinking_tokens`** —— 全仓 daemon 源码 搜这个词是**零命中**,唯一生产者是 web 的翻译层(还带客户端时钟的 `at`)。 - 同一条 usage,pos 4 带 `stopReason`(daemon 才加的),被污染那行里没有。 客户端为什么不换行:`consumeDaemonRun` 在任务链继续时**故意不换消息对象**, 三处 `onRunCreated` 把同一条消息重指到后继 run 并继续追加 —— 而契约注释 (`packages/contracts/src/sse/chat.ts` 约 446 行)写的是客户端应当只在**渲染时** 按 `strategyTaskRunIndex` 折叠、不该重指。 ## 现有守卫为什么没拦住 `mergeMessageWriteForDaemonBacked` 是按「**客户端声称的 runId**」判的。在真实 HTTP 边界上把五种形态各跑一遍(每种用一对全新的 daemon 托管行,互不污染): | PUT 形态 | 落盘? | |---|---| | runId=后继 run,runStatus=succeeded | 拦住 | | **runId 省略,runStatus=succeeded** | **落盘** | | **runId=本行自己的 run,runStatus=succeeded** | **落盘** | | runId=后继 run,runStatus=running | 拦住 | | runId 省略,runStatus 省略 | 拦住 | 只要 PUT 不点名后继 run、并附和本行已有的终态,整条后继流就能进来。 ## 改法 按 **payload 里装的是谁的流**判,不按客户端自称的身份判。新增 `payloadCarriesAnotherRowsRunStream()`:一条行的**第一个** done_key 才是它的 run 身份,后面的都是要拦的脏数据;再读同会话其他 assistant 行的自有 done_key 做比对。 不变式:**一条 daemon 托管的 assistant 行只装一个物理 run 的流;客户端写入无论 声称哪个 runId,都不许把兄弟行的 run 流塞进来。** 重试不受影响(重试是同一个 run、 同一个 key、同一行)。 放在服务端而不是客户端,是因为这样**连已经发出去的打包版客户端一起保护**。 ## 不是今天引入的 三个 run 的 `state.json` 里 `appVersionInfo` 是 `0.21.1-beta.7`,会话跑在 17:21–17:51, 而本分支当天那几个 commit 都晚于会话结束。更决定性的:同一个库里 **08-28 还有一例 一模一样的**(`7fd150ee`),那条记录 `appVersionInfo` 是 null,更老的包。 全库 165 条 assistant 行用五种判据各扫一遍(单行 ≥2 个 done_key / 同 key 出现在两行 / 前一条 content 以后一条结尾 / 单行 ≥2 个 run-start 帧 / 部分吞并),受害者都是这两条。 判据四多出的一条是「卡死后重试」,不是吞并 —— 它正好证明**数 start 帧会误伤重试**, 所以判据必须用 run 身份,不能数帧。 ## 两件没做 - **已损坏的两行不会自愈**,这个守卫只拦新的写入。产品 2026-09-05 拍板: 「新的不会就行,历史的先不管了」。 - **web 侧的根治没做**:任务链续跑后,客户端不该再往那条被折叠的消息里持久化, 折叠只该留在内存里给渲染用。服务端这道是最后一道闸,不是病根。 * fix(web): 从折叠回合分叉时,边界落在这一链的最后一条消息 用户在对话末尾 fork,fork 出来的会话只剩第一轮。库里坐实:原会话 position 0,1,2,3,4,两条 fork 都只有 0,1 —— 丢掉了用户填表单那条、澄清阶段的回复、 以及最终结论。 ## 根因:折叠复用了头一条消息的 id `foldStrategyTaskTurns` 把 OD Next 的 run 1..N 合进 `folded[headIndex]`,展开 `...head` 时**从没覆盖 `id`**。于是屏幕上那个气泡显示着三个 run 的正文,却挂着 **request run 的 id**。fork 按 id 切,daemon 忠实执行契约(「copy only source messages up to and including this message」),自然切在 position 1。 真实数据对得上: ``` task_run_index 0 request -> position 1 id home-auto-send-0qzyd9dzs8wn2-assistant task_run_index 1 clarification -> position 3 id 5ba87c0e-… task_run_index 2 production -> position 4 id odnext_assistant_26e1ba4b… ``` 折叠后是 `[u0, merged(id=position1 的 id), u2]`,而 u2(`[form answers…]`)被 `buildChatRenderItems` 藏起来 —— 所以屏幕上「对话末尾」就是那个合并气泡,它的 id 是 position 1 的。`slice(0, 2)` → 0,1。两条 fork 的 assistant 正文都是 1995 字, 正是 request run 那条。 ## 只影响 OD Next 的折叠链 没有 `strategyTaskRunIndex > 0` 时 `foldStrategyTaskTurns` 原样返回,普通单 run 会话点到的就是转录里那一条,不受影响。 还有一条时序:`strategyTaskRunIndex` **只由历史 GET 写入**,SSE 从不写。所以 **跑的过程中**分叉是好的,只有会话被重新拉过历史之后才出问题 —— 和现场吻合 (原会话 09-04 17:21,两次 fork 09-05 01:17,隔了 8 小时)。 CLI 的 `--fork-after <id>` 由操作者直接给、看不见折叠,不受影响。修在 UI, daemon / CLI 的契约保持「按 id 字面切」。 ## 改法 新增具名不变式 `forkBoundaryMessageIndex()`:点击的是折叠气泡时,边界要走到 **这一条逻辑回合在转录里的最后一条物理消息**,而不是它借来的那个头 id。 ## 同一处引起的第二个 bug,一并修掉 `forkFallbackMessage` 以前发的是**折叠后**的消息,正文是几个 run 拼在一起。走 「分叉点尚未持久化」那条路径时,daemon 会把这段拼接追加到已持久化的前缀之上 —— 同一段文字出现两遍。现在发的是边界那条转录消息。 ## 全库扫描 8 个库全扫(7 个打包版 namespace + 开发数据目录),受害者就是这两条 fork。 ⚠️ 扫的时候必须连 `-wal` 一起拷:`app.sqlite` 本体比现场旧 8 小时,**两条 fork 只存在于 WAL 里**。其余 6 个库根本没有 `strategy_task_runs` 表,结构上免疫。 ## 已知遗留(不在本次范围) fork 出来的消息 `run_id` 是 `undefined`(有意的,见 daemon 的 fork 播种代码), 因此永远拿不到 `strategyTaskExecutionId`。所以修好之后,fork 一个 Full Plan 回合 会得到**三个分开的气泡**而不是一个折叠回合 —— 内容完整,分组没复原。 另外同一个折叠 id 还被交给 `onSubmitQuestionForm` / `onAssistantFeedback` / `onContinueRemainingTasks`。**没有证据表明它们出错**,只是形状相同,单独跟。 * test(chat): 按 2026-09-05 的裁决更新输入区那几条判据 合并 main 时产品拍板:项目输入区取 main 的 #7635 / OPEND-2553,本分支按交付稿 `729fa43ce7` 做的那一版作废。下面三个文件里 6 条判据跟着翻面,每条都在原处写清 了「被哪条裁决作废、依据是什么」,没有静默改数字。 **w134 发送键几何** —— 28×28 / 图标 16 → **32×32 / 图标 32**。 ⚠️ 是 32,不是我先前转述的 36。`viewer/routines.css` 里 `.app .composer-send` 先声明 36px,但同一个文件更后面的规则把它压到 32 —— **只读源码会读成 36**,把 两份样式表按 `index.css` 顺序装进来量才是 32。这正是这个文件存在的理由: 2026-09-03 有一轮只改了 `chat.css` 那半边,测试绿了而真浏览器一动没动。那套 「两份样式表一起装再问 getComputedStyle」的机制原样保留,只有数字换了。 32 也和 main 自己的注释自洽:运行态「共用每一行,底.svg 就是这个盒子把箭头 拿掉」—— 发送和运行是同一个 32 方框,不是两档。 **w126 字形** —— 发送键 `ChatSendArrowIcon` → `Icon arrow-up-fill 32`;plus 菜单 的 else 分支(首页)`Icon plus` → `Icon attach`(main 把首页那颗触发器从「+」 改成了回形针)。**聊天面板那一侧全都没动**:`ChatPlusIcon`、`strokeGlyph`、 `ComposerPaletteIcon` 三条判据原样通过 —— 这也是我确认「红的是首页那一半、 不是回归」的依据。 **两条我改的是查法,不是数字**(它们的夹具其实没过时): - **w73 那颗 `+` 的类名**:main 改成了条件拼接 (`triggerLabel ? ' --labeled' : ' od-tooltip'`),按整段字面量搜源码就对不上 了。但聊天面板不传 label,**运行时拼出来仍然是 `icon-btn plus-menu__trigger od-tooltip`** —— 手搭的夹具是对的,过时的是查法。改成分别钉「基础类名还在」 和「不带标签时挂 od-tooltip 这条分支还在」。 - **w126 菜单条目的加号**:main 加了加载态,单行变多行 (`name={attachLoading ? 'spinner' : 'plus'}`)。按整段字面量匹配等于在钉排版, 改成钉真正要守的:仍是共享 Icon 的 plus、仍是 15、仍挂 `plus-menu__item-icon`。 全量:web 1,038 文件 / 10,513 测试 0 失败。 * feat(observability): 聊天区滚动被冻住时自动上报现场 把「聊天区滚不动」从**只能靠用户报障**变成**卡住那一刻自己上报**。只观测,不自愈。 ## 为什么需要它 这个缺陷已经完全刻画、但**触发条件未知**:合成器手里那份最大可滚距离被冻在某个 早期值,再也不刷新。布局是对的(`scrollHeight=2347 / clientHeight=583`),程序 赋值是对的(`scrollTop=1700` 生效、`99999` 正确夹到 1764),**只有真实输入那条路 错** —— 12 下滚轮请求 1440px 只走到 91,从 800 滚一下瞬间弹回 91。内容变三倍、 改 clientHeight、换光标位置,上限都还是 91。 `583 + 91 = 674` 不是巧合:那正是内容 674px 那一刻的正确上限,也就是说合成器的 拷贝在助手消息长到约 516px 时冻住了。唯一能治好的是销毁重建布局盒。 原始坏现场在排查过程中被销毁,复现要从头来 —— 所以先把观测埋下去。 ## 判据:JS 读不到合成器的边界,但读得到症状 写成纯函数吃三个数字,不碰 DOM: - **wheel_stall**:向下滚轮 + 按 layout 还有空间(> 24px)+ 连续 4 次、累计请求 ≥ 240px 而 `scrollTop` 一动不动 - **wheel_snap_back**:向下滚轮之后 `scrollTop` 反而倒退 ≥ 8px(就是 800 → 91 那一幕),一次即可 上报前还要确认**滚轮目标到 chat-log 之间没有任何还能滚的内层盒子** —— 这条 先前是手工排除的「滚动链」,现在由代码自己排除。 ## 上报里带什么(价值不在「发现卡住」,在把现场固定下来) - `compositor_content_px` = 观察到的天花板 + 视口 —— 这次就是 **674**,和 `layout_content_px`(2347)一比就知道冻在哪一刻 - 最近 20 条几何转变的环形缓冲,尤其**「不可滚 → 可滚」那一次**(合成器在那一刻 新建滚动节点,是头号嫌疑时机),外加 `content_px_at_scrollable_on` —— 它和 `compositor_content_px` 相等就说明「滚动节点一出生边界就是错的」 - 挂载时 vs 冻结时的层数差、祖先链的 transform/filter/contain - 是否流式、有没有 question-form 骨架屏、Chromium/Electron 版本 ## 零成本 两个监听器都是 capture + passive;**回调里一行布局都不读**(读 `scrollHeight` 会强制重排,那正是要检测的卡顿本身),几何读取全在 `requestAnimationFrame` 里、 一帧最多一次;滚动驱动的采样限到 250ms 一次(流式贴底每帧都发 scroll 事件); 层普查每个 chat-log 只跑两次(挂载时塞进 `requestIdleCallback`、冻结时);内层 滚动盒排查只在准备上报那一刻跑一次。每个元素只报一次,每会话上限 3 条。 更要紧的是**没找到聊天区时、以及全 app 其它地方滚动时的开销**,这里收紧了三处: - **滚轮监听在找到聊天区之后就从 window 搬到元素本身**。挂在 window 上是为了 「发现」,一旦发现就没必要再听全局 —— `detach()`(切会话、卸载)时才重新武装。 于是页面上别处的滚轮**一个回调都不进**。 - **scroll 的热路径是一次身份比较**。全 app 每一次滚动都会打到这个 capture 监听器上,所以不匹配就立刻返回:没有取时钟、没有分配、没有排任何东西。 - **拿不到 `requestIdleCallback` 就不做层普查**,而不是退化成内联或 `setTimeout`。 这个回调要对最多 600 个元素调 `getComputedStyle`,而它是从 scroll 处理器里 发起的 —— 退化成内联,等于在**首次滚动**时同步解析 600 个元素的样式,正好是 本模块声称不会造成的那种卡顿。`layer_count_at_attach` 因此**缺失而不是昂贵**, 这是对的取舍。 拆卸也补齐了:`detach()` 会取消在飞的 rAF 与 idle 回调、摘掉元素上的滚轮监听、 断开 `ResizeObserver`,不留悬挂引用。 ## 刻意不做自愈 `display:none → flex` 能治好它,但没加:会闪、会丢滚动位置,而且**会把我们要找的 触发条件抹掉**。要不要自愈是产品决定,标待拍板。 这条不是靠注释守的 —— 变异测试里**故意加了自愈代码**(改 display + 写 scrollTop),「never writes to the DOM」当场变红。 ## 红测 19 条,先红(模块不存在)→ 绿 → **8 个变异逐个撤掉实现再红**:判据、snap_back 分支、只报一次、到底保护、转变记录、内层滚动盒排除、切会话重新发现,以及上面 那条反向的自愈守卫。 ⚠️ 只有真机能确认的部分(报告里已标明,单测假装不了):真实冻结时 `scrollTop` 是否真的一帧不动、4 格/240px 这两个阈值对真实触控板是否合适、层普查的数字在 真实 DOM 上是否有意义、以及事件确实发得出去。 ## 已知盲点(没顺手补) - `run_id` / `conversation_id` 为空:`chat-context.ts` 的 `setChatCorrelation` 全 app 零调用(`chat-health.ts` / `chat-protocol.ts` 也都没接线),接起来是 独立一件事 - 流式状态用的是 DOM 启发式(`[data-streaming="true"]`),不是 run 生命周期,同上 - 用户停在半中间且没有自动贴底时,内容变高不发 scroll 事件,那段 `content_grew` 可能漏记 —— 要补得上 MutationObserver,流式期间是真实开销 - 只覆盖主聊天流水,思考区那个 96px 内层框不在范围内 ## 三条守卫测试 上面三处收紧各配一条,并逐条撤实现验红(一一对应,只红自己那条): - 聊天区以外的 scroll / wheel **什么都不排** - 浏览器说不出自己空闲时,挂载**不遍历子树** - 卸载时取消在飞任务并放开元素 * feat(observability): 记下滚动卡住那一刻,界面上还有谁在动 真机探针抓到一次现场,推翻了原来的模型 —— 于是这次补的不是「卡住时的快照」, 而是**亏空是从哪一次内容变化开始出现的**,以及**当时周围各个模块在干嘛**。 ## 现场怎么推翻了「一诞生就冻住」 用户客户端上的被动探针记下了一次 `wheel_snap_back`: 天花板 824 layoutMax 851 scrollHeight 1434 / clientHeight 583 合成器认为的内容高度 1407,布局是 1434,差 27px 连续几轮滚动扫描里,够不着的量**单调增长**、天花板**单调下降**: 第78轮 到851 差0 第79轮 到850 差1 第80轮 到846 差5 第81轮 到842 差9 第82轮 到839 差12 第83轮 到824 差27 不是一次性冻死,是**每次内容变化都少跟一点,亏空累积**。之前见过的天花板 91 (差 1673px)是同一机制掉到底的极端形态,不是另一种缺陷。 而且**冻结那一刻界面上什么特殊东西都没有**:没有 thinking、没有工具行、 没有 question-form、没有错误卡、没有 iframe、没有内层滚动盒,只有一条用户消息 和一条 1188px 高的助手消息。**question-form 骨架屏那条猜测就此排除。** ## 亏空账本(这次的重点) 每一次 `scrollHeight` 变化都**不过滤**地记一条,配上当次的 `layoutMax` 和**是哪个子节点长高了、长了多少**(只在内容高度真的变了的那一帧,读最后 32 个 直接子节点的 `offsetHeight`,而且是在已经读过 `scrollHeight` 的同一次 rAF 里 顺带做完,不额外触发重排)。角色分 `assistant_msg` / `user_msg` / `tail_spacer`。 每一次「向下滚轮但没能前进」记一轮天花板探测。**第一次出现 reached < layoutMax 的那一轮被永久钉住,永不被后来的挤掉** —— 上报里因此能直接指出亏空是从哪一次 内容变化开始的,而不只是「卡住时差多少」。另外记了 `tail_spacer_px`:钉顶机制的 占位块是等量置换,它归零那一刻正是内容开始真涨的时刻。 回放测试直接吃上面那串真实数字(851→850→846→842→839→824,layoutMax 恒为 851), 断言报告把 850 那一轮、内容 1434px 处认成亏空的起点。 ## 并行活动环形缓冲(64 条,满了丢最旧) 兄弟节点挂卸、底部浮层里的胶囊替换、**「回到最新」按钮点亮/熄灭**、日志与祖先链的 class/style 变动、动画与过渡的起止、流式翻转、标签页可见性、日志与其 offsetParent 的尺寸变化,以及作为锚点的「滚动节点诞生」。 上报里给三个切片:全量轨迹、**滚动节点诞生前后 ±500ms**、冻结前 2 秒,外加 `jump_active_at_attach` / `jump_first_active_ms` / `jump_active_vs_scroll_node_ms` —— 「回到最新」只可能在聊天区变可滚之后才出现,它相对滚动节点诞生的先后与毫秒差 是当前头号嫌疑,值得单独拎出来。 ## 成本 变动与动画回调里:两次 `getAttribute` + 一次枚举查表 + 写一个环形槽位。 **零布局读、零调度** —— 这条由测试钉着,往回调里塞一次 `scrollHeight` 当场变红。 帧里最多多读 32 个 `offsetHeight`,且只在内容高度真的动了时才读。 `detach()` 负责拆干净:2 个 MutationObserver、1 个额外 ResizeObserver、 4 个外壳监听器、1 个 document 监听器。 **两样判断为太贵、明说不加**(而不是偷偷塞进去):`.chat-log` 的 subtree childList —— 所以 question-form / 骨架屏**消失**不会被记(出现能通过 `anim_start:skeleton` 看到);以及逐帧轮询标记。 ## 红测 52 条,**33 处逐个撤实现验红**:环形缓冲、每个观察器与监听器、每处拆卸调用、 往变动回调里注入布局读,以及账本这边的增长配对、首次亏空的钉住与不可覆盖、 1px 下限、淘汰、连跑合并、天花板探测、不过滤的内容步、子节点归因、内容变化门、 每个 surface 的账本隔离、角色区分。 ## 只有真机能确认的部分(单测假装不了) - 任何一条活动记录**是否真的与漂移相关** —— jsdom 没有合成器,测试只能证明管路通, 永远证明不了因果。 - 时间戳精度:变动记录自己不带时间,是在微任务派发时打的戳(即造成变动那个任务的 末尾)。同一批内顺序保留、时间戳相同。`jump_active_vs_scroll_node_ms` 还继承了 滚动采样 250ms 节流的量化误差,小于 250ms 的差值只有方向意义。 - jsdom 没有 `ResizeObserver` 和 `AnimationEvent`:`log_resize` / `host_resize` 和 真实的 offsetParent 解析在这里测不到,`anim_*` 靠合成事件驱动。 ## 已知盲点 `MIN_UNREACHABLE_PX` 仍是 24,没动。所以早期漂移(差1、差5)只有在**最终真的发出 一次上报**时才会随报告一起送达;漂移到 12px 就停住的 surface 什么都不发。 要不要把这道门槛调低,是检测灵敏度的产品决定,标待拍板。 * fix(chat): 回合成功却挂红色失败卡 —— 别让服务端的沉默抹掉策略判决 用户在 dogfood 包上撞到:agent 完整回答了问题(明确要求「只输出文字、不要 创建文件」),消息上标着「已完成」,**下面同时挂着一张红色的「任务执行失败」**。 ## 不是 agent 没照做 那一轮 run 本身是成功的:`status: succeeded`、`exitCode: 0`、`error: null`, events 里零错误。被判失败的只有协议层 —— OD Next 要求每条回复带一个 Runtime State 块,而这一轮什么都不产,于是策略任务落成 `blocked`, 原因码 `od_next_protocol_runtime_state_missing`。 把 request 阶段允许的三种声明逐个喂给这条纯文字回答,**全部 blocked**: `clarification_required` 缺表单、`plan_ready` 缺 Plan Contract、 `direct_edit + completed` 缺产物。契约里没有「答完了,没有要交付的东西」 这一档 —— 那是策略层的缺口(别的 module 的 OPEND-2589),这里不碰。 ## 这里修的是显示成泛化红卡这一半 分支上**已经有**对症的卡片(`amr-guidance.ts:901` 的「回复已收到,但没能 记录下来」,19 语齐全),但它永远画不出来:约 150ms 后拉消息列表对齐时, 服务端那一行把本地判决整个盖掉了。 工单说只丢了 `runStatus`。**实际两样都丢**:daemon 落库 29 条事件(其中 14 条 是客户端从来不造的 `diagnostic`),客户端手里只有 9 条, `local.events.length > server.events.length` 不成立 → 服务端事件整体胜出 → error 事件被一起抹掉。这正好解释了为什么用户看到的是**泛化红卡**而不是 「什么都没有」:error 事件还在的话 `isRecoveredAssistantRunError` 会把面板级 报错整个吞掉、一张卡都不出。**所以只补 `runStatus` 修不好。** ## 守的是什么 不是「本地 failed 压过服务端 succeeded」—— daemon 记 succeeded 是对的, 失败的是策略任务不是 run。守的是: > 服务端那一行只带进程状态,**根本没有字段能表达策略判决**; > 它的沉默不是对本地判决的更正,不该被当成更正用。 见证收得很窄,四条同时成立才生效:本地被 daemon 终态投影盖过 `strategyTaskBlocked`、本地已判 failed、两边是**同一个 runId**、 本地有 error 事件而服务端没有。 另一个方向(让 `isRetryableAssistantTerminalFailure` 直接认 `strategyTaskBlocked`)试过并否掉:会让「blocked 但已交付」那一路凭空冒出 一张无码的泛化红卡,是回归。测试里留了这条反向对照。 ## 顺带修好 OPEND-2422 「阻断错误卡缺少重试按钮」是同一处根因的下游 —— `ChatPane.tsx:4063` 整个 动作组挂在 `runFailureUi` 上,它为空时一颗按钮都画不出来。 ## 红测 8 条,先红后绿(实现未动时 4 红)。三段实现各撤一次验红: - 撤「保留 `strategyTaskBlocked`」→ 原因码消失、连续两次对齐掉回 succeeded - 撤「保留 `runStatus`」→ 上面两条 + 整张卡变 null + 找不到重试按钮 - 撤「追加 error 事件」→ 卡出得来但退回泛化文案 没有一段撤掉仍绿。 ## 只有真机能确认的 **刷新之后专用卡不会回来** —— 刷新后客户端没有本地副本,服务端仍然只给 succeeded + 无 error 事件,一张卡都不出。泛化红卡确实消失了,但专用卡是 会话内的。要做成持久的得让 daemon 把 blocked 判决投到消息行上(契约里 `strategyTaskBlocked` / `strategyTaskBlockedText` 早就有、从来没人写)—— **那是产品规则,待拍板。** * fix(observability): 冻结上报被裁切的盒子吞掉了 —— 顺带把探针变成可问的 真机上发生过一次够不着 1493px 的滚动冻结,远超上报阈值,**PostHog 上一条 `client_chat_scroll_frozen` 都没有**。管道是活的(同一条 `reportSafetyEvent` 路径的 `client_long_task` 两百万条、`client_resource_error` 七千条都在到达), 安装也执行了(和 `installLongTaskObserver` 在同一个数组里)。 **为什么没报,是能从代码直接看出来的。** ## 裁切盒被误判成「能吃掉滚轮的内层盒」 `countAbsorbingScrollers` 判断滚轮是否被内层盒吸收时,只看 `scrollHeight - clientHeight > 1`,**从不读 `overflow`**。于是一个 `overflow: hidden`、内容被裁掉的盒子在它眼里就是「可滚内层盒」→ 抑制上报, **并且清零连击计数**。 而聊天记录里这种盒子遍地都是,是设计的一部分: - `.msg.user .user-text-txt` —— `-webkit-line-clamp: 6; overflow: hidden`, **每一条长用户消息** - 折叠代码块 `max-height: 7em; overflow: hidden` - 折叠手风琴、`.action-card`、`.question-form`、`.live-code-box`… 这和证据形状严丝合缝:唯一一次**成功上报**的现场,正是一个没有 thinking、 没有工具行、没有 question-form、没有代码块的会话 —— 没有东西可吸收。 判据改成两件事同时成立:几何上还有向下余量,**且** computed `overflowY ∈ {auto, scroll, overlay}`。顺序刻意是先几何后 computed style: 几何在刚读过 `scrollHeight` 的干净布局上取,祖先链上绝大多数盒子一步否掉, `getComputedStyle` 只有真正溢出的那几个才付费。 `overflow-x` 没有特判,**是刻意的**:CSS Overflow 3 规定 `overflow-x: auto` + `overflow-y: visible` 在计算值阶段会把 `visible` 强制成 `auto`,那个盒子**真的**是竖直滚动端口。自己去读 `overflow-x` 再推「横向专用」 反而会和浏览器行为矛盾。真正只能横滚的写法 computed 就是 `hidden`,自然排除。 同一段逻辑在诊断面 `scanAbsorbingScrollers` 里有**第二份拷贝**,一并合成一份 —— 只修门不修镜子的话,以后在真机上敲 `snapshot()` 会看到一屏被裁切的用户 消息被列成「抑制源」,运维会得出和我们一模一样的错误结论,而这个把手存在的 全部意义就是避免那个结论。 ## 运行时把手 `window.__chatScrollFreeze` 诊断模块自己不可观测,是这次查不动的直接原因 —— 没法问它「attach 了吗? 攒了多少?为什么没报?」。 - `why()` 一句话给判决:`blocked_by=inner_scroller_free` - `snapshot()` 给全部:几何(**读取那一刻的**,另存一份帧回调的陈旧拷贝以便 看漂移)、攒着的滚轮、亏空账本、并行活动轨迹、「回到最新」的时间关系、 以及**十二道闸逐条的 `{ 当前值, 需要值 }`** - `writes.enable()` 打开 `scrollTop` 写入拦截(带调用栈),**默认关**, 复用仓库已有的 `open-design:` 键约定;还原时逐字节还原原始描述符, 且全有或全无 —— 任何一个成员拒绝替换就整个回滚 **不调用时零开销**:安装只多一次对象发布和一次 `localStorage.getItem`, 没有监听器、没有定时器、没有帧、没有几何读。测试钉着这一点:调用 20 次, `addEventListener` / `setTimeout` / `requestAnimationFrame` 全部零调用。 `snapshot()` 自己是昂贵的(强制重排、走最多 400 个后代)—— 那是对的, 它只在有人在看的时候跑。 ## 阈值 24 → 8 真机那次漂移**从 1px 开始**长到 27(0→1→5→9→12→27),24 的门槛把前五轮 全丢掉;漂移到 12px 就停住的场景什么都不发。取整误差实测 ±0.5px,8 在噪声 地板一个量级之上;而且它只在「连续 4 格向下、累计请求 ≥240px 却没动」之后 才被查。 **这个阈值此前零测试覆盖** —— 现在补了三条(12px 漂移会报、1px 取整仍判 「已到底」、snap-back 仍要求真实亏空)。 ## 撤实现验红 把手侧 33 处逐个撤、全部变红;吸收判据两个方向各撤一次:换成只剩几何 (等于修复前)红 3 条,直接 `return false`(抑制机制整个没了)红 4 条 —— 后者证明反向测试真的看得见「把抑制修没了」。 ## 待拍板(没动) 连击计数在「位移 ≠ 0」时清零。用真机那串序列实测,**那一次并没有被它挡住** (第六轮单步掉 15px,走 snap-back 报了);但存在一条窄带:每轮都动 1–7px 且永不稳定时,两条上报路都不触发,天花板一路掉到用户明显够不到底也不会报。 流式持续增高时这是合理场景。「向下挪了 3px 而 1493px 够不着」算不算健康, 是产品判断。 * feat(daemon): 按原始时间线 1:1 回放一条录下来的 run 排查滚动冻结时反复撞到同一堵墙:**它只在真实使用中偶发**,225 次合成试验、 540 轮真实滚轮扫描,全是零复现。而内容增长的**真实节奏**很可能就是触发条件 的一部分 —— 合成页面永远造不出那个节奏。 打包客户端的数据目录里躺着 109 条真实 run、147,782 条事件、5.8 小时录像, 每条事件都带 `timestamp`。**这就是现成的语料,而且回放它零 token。** ## 只替换 agent 子进程,下游全部是真产品 回放挂在 daemon 自己的 run 启动器上,替换的**只有事件的来源**;每一条录下来 的记录都交给活路径同一个 `emit()` / `finish()`。持久化、到 `run.clients` 的 SSE 扇出、run 分析、终态对账、前端消费 `GET /api/runs/:id/events` —— 全部 原封不动,而且不知情。 时间从记录自己的 `timestamp` 还原(daemon 在原始 `emit()` 时的墙钟)。记录 按**单一单调原点**排程,而不是逐个间隔 sleep —— 否则几千条下来会累积定时器 自身的过冲;目标时刻已经过去的记录在同一 tick 内冲刷掉,原始那些亚毫秒突发 (41% 的间隔是 0ms)本来也是这么上线的。 ## 保真度(实测,两层) **时间线**(run 8f451ad3,756 事件 / 374 秒):756/756 帧全达,与录像的时间 误差 p25=3ms、**p50=4ms**、p75=9ms、p99=87ms、max=114ms;总跨度 374462ms vs 录像 374378ms —— **6.2 分钟累计漂移 +84ms(0.02%)**。 **内容**(run 25e62d4b,过真实 UI):回读出来的助手消息与录像**逐字节相同** ——正文 2033/2033、思考 14298/14298;工具事件对得上;`<question-form>` 在 聊天里渲染成了真实的表单。 **没有证明的**:回放与**原始**的 DOM 几何一致 —— 原始会话没人录过 `.chat-log` 的 `scrollHeight`,没有基线可比。只能声称事件时间线近乎精确、 内容完全精确。 ## 只在环境变量下武装,默认完全惰性 OD_REPLAY_EVENTS 指向一份录下来的 events.jsonl OD_REPLAY_DIR `<runId>/events.jsonl` 的目录;下一轮放哪一份写在 同级的 `.selected` 指针文件里,每条 run 现读,不缓存 —— 一个 daemon 不用重启就能换任意录像 OD_REPLAY_SPEED 墙钟倍率,默认 1 OD_REPLAY_MAX_GAP_MS 空闲间隔的钳位,默认 0 = 不钳 不设 `OD_REPLAY_EVENTS` / `OD_REPLAY_DIR` 时这段代码一行都不跑。 * fix(chat): 思考想完之后,把头一格的耗时还回来 用户原话:「即使是第一个 thinking,思考过程中不显示耗时,**但结束还是要显示的吧?**」 以及确认现象:「现在思考耗时好像只有第一次的 thinking 没显示耗时?」 ## 那一格为什么会两头落空 思考行右边只有**一个槽**,规则是「有实时 token 就写 token,没有才写耗时」。 而 2026-09-04 的裁决把**整轮头一格**的耗时压掉了,理由写在 `specs/current/chat-panel-feedback.md:876`: > 头一格没有计时可让(今天刚因「与壳头重复」收走),所以那一格**恒为 token**。 这个前提在两处塌陷: - **AMR 从来就没有实时 token** —— 全仓 `thinking_tokens` 只有 claude 和 codex 两个 生产者,ACP 那条路一个都没有(实测 25 条 amr run 出现 0 次)。「恒为 token」对它 从来不成立。 - **claude 收尾之后 token 也没了** —— tokens 只挂给还在流的那一格 (`group-thinking.ts:106` 的 `if (live)`)。 于是槽里既没有 token 也没有耗时,渲染成一个**空的 `<span>` 占着布局** (`Foldable.tsx:135` 判的是 `!= null`,而 `''` 不是 `null`)。 ## 「与壳头重复」这个理由在收尾后自己消失 跑着时壳头和头一格同起同终(`build-turn-blocks.ts:1501` 的 `isFirst` 分支 + `:1509` 的 `running ? nowMs`),确实是同一个数;收尾后壳头被 `isLast` 推到 `endedAtMs` 变成**整轮**跨度,而头一格只覆盖到第一件带时刻的事,**两个数不再相等**。 ## 改动 一行: const elapsed = muted && live ? '' : formatElapsed(elapsedMs); `live` 核实过是**逐格**的,不是整轮:`groupThinking` 只给队尾那一格置位,含义是 「模型此刻正在往**这一格**里写」。这正是要的开关 —— 它同时驱动那一格的视觉形态 (转球 +「思考中」↔ 脑图标 +「思考过程」)和 React key,所以**耗时出现在那一格 翻成完成态的同一帧**,结构上不可能在还写着「思考中」时漏出来。用整轮 `running` 反而会让两者错拍:格子已经写「思考过程」,槽却还空着好几分钟。 「还在跑的数会不会等于壳头」这条也是关的:非时刻项落在思考之后时 `closeThink` 的 `ownsGap` 会作废整段 → `elapsedMs` 为 null → **连槽都不出**,不是空槽。 ## 既有判据逐条处置(不是删,是判) 两个文件把「当年的现状」钉成了预期。逐条分:守「**进行中**不显示」的**原样保留**, 守「**收尾后**也不显示」的**更新并写明前提为何失效**。 有一条**拒绝转绿**:`amr-thinking-slot-blank.test.tsx` 里那条断言 AMR 头一格必须 有字的用例,它的夹具**是 live 的** —— 断言它非空等于断言「思考进行中显示耗时」, 和裁决直接冲突。改成守卫,并记下 AMR 缺 token 的真因在 ACP 不产 `thinking_tokens`(daemon/协议层),**在渲染层拿耗时去顶 token 的位置就是自造规则**。 ## 撤实现验红(两个方向) - 撤掉修复(`muted && live` → `muted`):**14 红**,正是「想完之后要有数」那一组 - 改成完全不压(去掉 `muted`):**5 红**,正是「进行中不显示」那些守卫 两个方向都咬得住,说明既没修漏也没修过头。`tests/components/chat/` 187 文件 / 1543 测试全绿。 ## 只有真机能确认的 1. **收尾后那两个数的实际差值没量过** —— 整条裁决建立在「不再相等」上,而这是读 代码推的。真机上典型 run 如果第一件工具调用就在开头一两秒,两个数看起来还是会 很像,产品可能仍然认为重复。 2. **AMR 可能仍然什么都不显示。** 夹具是手喂 `elapsedMs` 的;ACP 那条路上这一格的 耗时只有在后续事件带可用时刻时才存在,若 ACP 工具事件没有 `startedAt`, `ownsGap` 会作废整段、**连槽都不出**。**在对用户说「AMR 修好了」之前,必须真跑 一轮 AMR 看。** 3. 转场那一帧的观感(数字与图标同帧出现是干净还是突兀)jsdom 看不到。 4. 交付稿只画了 `thinking-live`,**没有画「思考结束」这一态** —— 视觉处理仍待产品过目。 顺带在 `chat-panel-feedback.md` 那条 2026-09-04 记录后面追加了带日期的更正, 因为那句「恒为 token」是这个前提的共享记录,留着会误导下一个人。 * fix(chat): 模型跑不起来时,把 daemon 已经算出的病因和处方露出来 用户跑 codex 撞到:壳头「运行失败 13s」,底下一张泛化卡「这次没能顺利完成」, 只有〔联系支持〕〔导出日志〕。**连是哪个模型不行都没说。** 而 daemon 那一侧其实全算出来了: error.code AGENT_EXECUTION_FAILED failureCategory model_unavailable failureDetail cli_version_incompatible failureAction switch_model retryable false 上游原句:`The 'gpt-6-astra' model requires a newer version of Codex.` ## 为什么露不出来 「模型不可用」那张卡(19 语齐全,带〔更换模型〕按钮)**挂在错误码上** —— `AMR_MODEL_UNAVAILABLE`。AMR 是我们自己的后端,错误从产生到消费都在手里, 所以能给专属码。 而 BYOK agent(codex / claude / opencode)是**第三方 CLI**,失败以 stderr 文本 和 stream error 的形状过来,只能落在泛化的 `AGENT_EXECUTION_FAILED` 上,细分 信息在 `failureDetail` 里 —— **结构上永远够不着那一行**。 这个缺口在 `amr-guidance.ts:607` 的注释里就写着,还拿 `cli_version_incompatible` 当例子。 ## 改动 `AGENT_AGNOSTIC_DETAIL_FAILURE_UI` 加 5 行,全部指向现成的 `switchModelWithGuidance`:`cli_version_incompatible` / `model_not_found` / `model_not_supported` / `model_disabled` / `local_model_not_loaded`。 **零新增 i18n key、零契约改动、零删除** —— 文案、19 语翻译、`switch-model` 按钮、 埋点全是现成的,只缺表里这几行。 ## 为什么放 agent-agnostic 那张表 原本的理由(「要在 AMR 分支之前解析」)**不完整**:六个 agent 分支条件里有五个 根本不可能和这些 detail 同时出现 —— daemon 分类器在模型分支(`:1059`)之上就 返回了 `AMR_INSUFFICIENT_BALANCE` / `AMR_TIER_UPGRADE_REQUIRED` / `*_AUTH_REQUIRED`(`:945-1055`)。对那五个,两张表行为相同。 **真正成立的理由是 Antigravity**:分类器的 `RATE_LIMITED` 分支在 `:1231`, **排在模型分支之下**,所以 `RATE_LIMITED + model_not_supported` 是真实可达的组合。 从 `DETAIL_FAILURE_UI`(在 agent 分支**之后**解析)出来,它会被 Antigravity 的 限流分支抢先、标题成「速率受限」;从 agnostic 表出来才拿到 daemon 的真实诊断。 另外两条:code 版的同族 `AMR_MODEL_UNAVAILABLE` 本来就在 `AGENT_AGNOSTIC_FAILURE_UI` 里、同一优先级层,把一张卡拆到两层会让「会不会被 agent 分支抢走」取决于 「daemon 恰好有没有结构化码」;而 `DETAIL_FAILURE_UI` 的 docblock 把自己限定为 **覆盖**一个错的或太笼统的 code 映射,这几行没有覆盖任何东西 (`AGENT_EXECUTION_FAILED` 在任何表里都没有行)。 ## 两条约束测试 - **不出重试,且与 verdict 无关** —— 故意**不传** `verdict`,因为 `retryable` / `failureAction` **还没上线到 wire 上**。不修的话这个形状会掉到解析器最后一行 拿到 retry。这样钉住的是「这一行本身就对」,不用等契约补齐。 - **AMR 那条路没被影响** —— 用 `detail: null` 作判别用例。daemon 给那个码配的 正是 `model_not_found`,而它刚进了 detail 表,**只测 detail 那一档绿了可能是 巧合**;只有 code 行能答 null 这一档。 ## 撤实现验红 三个变异,每次都还原并校验校验和:删掉 5 行 → 11 红 2 绿(那 2 绿正是反向对照 和 AMR 测试,与设计一致);改成 `retryWithGuidance` → 11 红,报的是 `expected 'retry' to be 'switch-model'`(证明不重试那几条断言独立承重); 删掉 code 表里的 `AMR_MODEL_UNAVAILABLE` → 只有 AMR 那条红、且只在 `detail=null` (证明它确实隔离了 code 行)。 ## 刻意没做的两件 **`provider_routing_error` 排除在外**,它是这个家族的第六个成员: `modelUnavailableDetail()` 把它发成 `model_unavailable`/`switch_model`,而 `upstreamDetail()` 把**同一个字符串**发成 `upstream_unavailable`/`retryable: true`。 `amr-guidance` 收到 detail 时**不带 category**,加一行会把上游那一半错标。 **要等 `failureCategory` 上线到 wire 才能收**,代码里留了 ⚠️。 **`local_model_not_loaded` 的文案只是勉强合身** —— daemon 对它同样开 `switch_model`,家族卡是诚实的路由,但它字面上的解法是「去 LM Studio 里加载一个 模型」,不是「在这儿换一个」。**没有自造新句子**(那要 19 个 locale),按文件里 既有的 `local_storage_failure` 标记方式标了待拍板。 ## 待另外开单的契约缺口(只记录,未改) 1. daemon 发的 `failureAction: "switch_model"`(`run-failure-classification.ts:1070`) **不在** `RunFailureAction` 联合类型里(`packages/contracts/src/api/chat.ts:50` 只有 `relogin | recharge | upgrade | retry | none`)。 2. `agentVersion: null` —— OD 不记录 agent CLI 版本,所以卡片上说不出「你装的是 X, 需要 Y」。解析器自己的注释说,要拿到它得在每次启动前加一次探测。 ## 只有真机能确认 这几行只决定**判决**,`resolveRunFailureUi` 是纯函数。这条 run 的 `failureDetail` 能不能活着穿过 SSE `end` 帧 → `markErrorRunFailure` → `appendErrorStatusEvent` 一路到 ChatPane 读的 props,**没有任何东西在这里证明** —— 而那条链正是 `RunFailureDaemonVerdict` 的 docblock 说「字段今天会丢」的地方。 * fix(chat): 「问完就交棒」的回合不再被判成「已停止,仍有未完成任务」 用户回答完 question-form 之后,那一回合底下冒出一行「已停止,仍有未完成任务」, 而**同一条消息的壳头写着「已完成 56s」** —— 屏幕上两个互相矛盾的状态。 ## run 从来没被停过 真机那条 run(`441ff961-…`)的末帧逐字:`status: succeeded`、`code: 0`、 `signal: null`、`error`/`errorCode`/`failureCategory` 全 null。 用户提交表单后的下一轮正常做完了 34 个产物 —— **「仍有未完成任务」连事后看都不成立。** 页脚那行只读最后一个 TodoWrite 快照(4 条全非 completed),**从不看 run 是怎么结束的**。 而这一轮以 `</question-form>` 收尾:它的计划**本来就该没做完**,那是等待,不是烂尾。 「答完才出现」是因为 `hideRunStatus` 里有 `hasPendingQuestionForm` —— 表单悬着时整行 被藏住,一提交就翻假、闸门打开。那道闸当初防的是**假的成功**(注释原话), 没人看过它另一头掉出什么。 ## 判据放在契约层,因为有三个面在判完成度 daemon 的 stamp、`db.ts` 的项目卡投影、web 的页脚 —— 而**页脚是自己从 events 重算的**, 不读 stamp。所以只改 daemon 修不好页脚,只改 web 修不好项目卡。 `turnEndedByAskingUser` **不是新产品规则**:它就是 daemon 已有的 `awaiting_input` 规则 (`listPartitionsAwaitingInput`、`run_finished.asked_user_question` 都在用 `emittedRenderableQuestionForm`)。把那个扫描器从 daemon 提到 `packages/contracts/src/api/question-form-markup.ts`,daemon 侧留 re-export 垫片, 6 个导入方和 e2e 的 parity 测试路径都不用动。 它要求的是**可渲染的闭合 `<question-form>`**,不是裸标签 —— 所以产物 HTML 里印了 这段文本不会误判。反向孪生测试逐层钉住:被引号包住的裸标签**仍然**报未完成。 ## 测试抓出了一个真 bug:文档说的收口点其实不收口 第一版把文本收拢在 `captureRunWorkCompletenessSignals` —— 那里的注释明写着 「single choke point」。**错的**:`emitGuardedTextDelta` 把守卫过的文本直接送给 `send('agent', …)`,**绕过 `emitAgentEvent`**,整个 json-event-stream / copilot / ACP 家族都走这条。全链路测试当场变红。收拢点移到 `send` 本身,并在原处留注释说明 为什么不能折在那儿。 ## 是「移动」不是「新增」 `server.ts` 里那个路由局部的 `clarifyingQuestionText` 缓冲被**挪到 run 上**, 而不是加第二个缓冲 —— 于是缺产物守卫和 `finish()` 读同一份。 ## 撤实现验红(逐处,含两道状态闸单独隔离) | 撤掉 | 变红 | |---|---| | contracts 的 `eventsEndedByAskingUser` 判据 | `does not call a clarification turn unfinished` | | daemon 的 `&& !endedByAskingUser` | `runs.test` + `chat-route.test` 各一条 | | `server.ts` 的 `askUserScanText` 累积 | `chat-route.test › does not stamp unfinished work…` | | web 的 `messageEndedByAskingUser` | 原红测 + 2 条 todos 单测 | | **只**撤 daemon 的 `status === 'succeeded'` 闸 | `does not let a rendered form erase unfinished work from a canceled run` | | **只**撤 web 的 `runStatus` 闸 | 「取消后仍报已停止」+「仍提供剩余任务」 | 取消那条守卫原本是**空的** —— `canceled` 在标签排序里本来就压过 `hasUnfinishedTodos`, 只断言标签会永远绿。加了「继续剩余任务」按钮的断言才让它非空。 ## 文案没动(产品的) `assistant.unfinishedLabel` 19 语原样。原样转述建议给产品:**一个只知道 todo 快照的 谓词,没有资格断言「终止原因」;在这一档「已停止」永远是错的。** 这次修复让 「问完交棒」不再落进这一档,但其它落进来的回合(比如 agent exit 0 直接不干了) 措辞仍然在硬说「已停止」。值得产品拍一次。 ## 只有真机能确认 - **纯 stdout 运行时**(antigravity / BYOK / deepseek TUI):它们的文本是 `stdout` 块 不是 `agent` `text_delta`,`run.askUserScanText` 会是空,**daemon 那侧维持现状 (fail-closed)**;web 侧照样修好,因为它读 `message.content`。这是那个缓冲的 既有局限,没有顺手扩大。 - 打包客户端上那条 run 的页脚真的读「已完成」、项目卡不再翻 `incomplete`。 - **「继续剩余任务」按钮在已回答的澄清回合上会消失** —— 这是把 `continuableUnfinishedTodos` 也接上判据的直接后果,判断是对的 (表单本身就是那个「继续」),但**是超出状态词的可见行为变化,值得看一眼**。 * feat(chat): 余额只剩一档提醒 —— 拿掉软弹窗,首页直接放行 产品裁决(用户 2026-09-06 原话):「**软提醒弹窗就是产品告诉我不要这个的, 只用弹那个插画的就行**」,以及首页那一档「**什么都不显示,有余额就允许运行**」。 ## 三个界面的终态 | 界面 | 余额 > $0 但 < $2 | |---|---| | 项目页 | 交付稿那张对话内 `UpgradeCard`(**本来就是**,2026-08-26 的 D-01 已裁决) | | 首页 | **什么都不显示,直接放行** | | = $0 | 不变,仍是带插画的 `AmrBalanceDialog` | 首页原来是 `AmrLowBalanceDialog` —— 一个**挡住这次发送**的居中弹窗,用户得点 〔仍要发起任务〕才继续。现在 `soft` 那个分支整段删掉,**落下去就是放行**, 不是留一个不显示但仍然挡着的空壳。原位留了注释写明「故意没有分支,别再加回来」。 ## 为什么首页至今还留着它,不是漏做 2026-08-26 的裁决 D-01(`specs/current/run-error-catalog.md:303`)逐字写着 「告警可继续的不弹窗,只有卡片;余额不足再弹窗」,但**落点栏只点了 `ProjectView.tsx` + `ChatPane`,没点 EntryShell** —— 首页从来没被那条覆盖过。 而且 `UpgradeCard.tsx` 里那句「那个的去留另记(见规格 T40)」是个**断指针**: T40 讲的是「软提醒不许拖慢运行」,和去留无关。**软弹窗的去留至今没有任何书面裁决**, 这次是第一次。断指针一并修掉(组件注释、CSS 注释、`AmrBalanceDialog`、 镜像页两处注记与两行 HTML)。 ## 测试是改判据不是删 `w116-entry-shell-low-balance-tiers.test.tsx` 保留并翻面:free / pro / go / max / null 各档 soft 都断言**不出任何弹窗**且 `onCreateProject` 被调用。另加两条: - **屏幕上一个 `role="dialog"` / `alertdialog` 都没有** ——「什么都不显示」不等于 「换一张显示」 - **反向对照**:硬拦档照旧弹 `amr-balance-dialog` 且不建项目;`checkAmrBalanceGate` 确实被调用过。**没有这两条,把整段闸门删掉也会全绿。** T40 那条红线用例(套餐读数吊死也不许卡住发送)原样保留。 ## 撤实现验红 把旧行为的形状恢复(soft 分支里等一个决策的 `await` + 一个阻断元素): **9 条里 6 条当场变红**,正好是全部 soft 用例;绿的 3 条是不走 soft 路的。 还原用字节备份 `cp`,`shasum` 前后一致。 ## 顺带落地的两条裁决记录 `specs/current/chat-panel-decisions-sheet.md` 新增「五之五、2026-09-06」: - **T53** 软弹窗去留 + 三界面终态 + 首页静默放行,引用户原话;**并写明代价** —— 首页 $0–$2 彻底静默,任务可能跑到一半停而事前无提示,**产品知情** - **T52** 阈值保持 `$2`、不跟交付稿的「< 5 美金」,依据是用户原话 「<2 就行,不用管设计稿的 5 美金」,并**明写「产品没有给出依据,这里不代为补写理由」** `amr-balance-gate.ts` 的阈值常量上方加注释,标明这是对交付稿的**有意偏离**、 指向 T52,并说明硬拦那条($0)**不是**偏离。 ## 遗留未清(已记进 T53) 死埋点 `chat_low_balance_warn_recharge` / `home_low_balance_warn_recharge` 留着: 它们在 `packages/contracts` 的 `TrackingAmrEntrySource` 联合类型里,而 `ENTRY_PAGE_BY_SOURCE` 是穷举 `Record` —— 删一个值要同时改跨包分析契约、映射表, 还要重建 contracts dist。**牵连大于收益。** 另:`docs/design/chat-mirror/mirror-exec.html` 只手改了 4 处注记,没整页重生成 —— 重生成会带出 +98/−9 的 `tokens.css` 内联漂移(别处留下的陈旧),不该裹进这次改动。 那页的 token 内联仍是陈旧的,下次正经重生成时会一并带上。 `specs/current/chat-panel-edge-audit.md` 仍把这张弹窗描述成现存组件 —— 它自己声明了是 2026-08-25 的带日期盘点稿、以 `chat-panel-next.md` 为准,不是权威源, 这次没动。 * fix(observability): 浏览器自己的滚动锚定不该被判成「滚轮倒退」 探针的 `wheel_snap_back` 会误报,触发方式毫无对抗性:**打开一条长会话 → 滚到中间 → 500ms 一格慢慢往下滚 20 格**。实测 **11 次中 5 次**误报;命中时 20 格里 19 格被判定,而日志全程能正常滚。 (对照:已稳定的长会话上 1708 格滚轮,**0 / 14** 误报,连 stall streak 都没起过。) ## 机制 1. `SCROLL_SAMPLE_MIN_INTERVAL_MS = 250` —— 探针最多每 250ms 才把滚动位置并入 `lastScrollTop`,而且节流是**丢弃**跳过的采样,不是延后。 2. 这 250ms 里,**浏览器原生 scroll anchoring** 因为视口**上方**内容变矮而把 `scrollTop` 调小(**实测一次调了 1036px**,而 `SNAP_BACK_MIN_PX` 只要 8)。 3. 下一格向下滚轮拿去和**过期的基准**比 → 判成「向下滚却倒退了」。 4. **这条路一格就报**,`FREEZE_WHEEL_COUNT = 4` 那套门槛完全保护不到它。 ## 为什么不是「报之前重读一次 scrollTop」 那条治不了:`runFrame` 里的 `geometry.scrollTop` 本来就是当帧刚读的、新鲜的; **旧的是基准**。在 rAF 里再读一次会拿到同一个值,一格都拦不下来;而放到监听器 里读又会强制重排,正好破掉这个模块的立身之本。**成本照付,一个误报都不少。** ## 改法:布局稳定性排除条件 原生 scroll anchoring **只在视口上方内容高度变了的时候**才会修正 `scrollTop` —— 所以「高度动没动」就是把浏览器的记账和用户的手分开的结构性判据。判据复用 `stick-to-bottom.ts` 里 `layoutStable` 的推理,没另造一套;**没有用时间窗** (「基准是不是 250ms 内取的」),因为那种参数离线验不了,而且落在窗口内的 修正照样会被定罪。 `lastScrollHeight` / `lastClientHeight` 和 `lastScrollTop` **绑在一起写** (收成一个 `sampled` 对象,所有 return 路径 spread 它)—— 位置和它所在的布局 必须同拍,否则下一次判定就是拿新位置比旧布局。 **`wheel_stall` 那条四格路刻意不加这个门**:流式期间内容几乎每帧都在长, 一刀切会把「一边流式一边真卡住」整类漏掉。 诊断面 `describeSnapBackRoute` 调同一个谓词并新增 `layoutStable` 字段 —— 否则运行时把手会告诉操作者「这条路已就绪」,而探针其实不会报。 ## 为什么这条比看起来重要 `MAX_REPORTS_PER_SESSION = 3`,而 `attach()` 在报满 3 次后**不再挂载**。 所以误报会**烧光预算**,真卡住时反而抓不到。撤实现验红时这一点是可测的: 去掉门之后,**第一格 anchoring 修正就把 `reported` 锁死了**,后面那次真 snap-back 根本没机会上报。 ## 撤实现验红 - 撤 snap-back 条件里的 `&& stable` → 3 红(算术层、DOM 层、以及「reflow 之后 紧接着那一格仍要报」) - 整支 snap-back 分支删掉 → 7 红(含 drift 回放那几条既有用例) - 诊断面去掉 `stable &&` → 1 红 反向测试两条(算术层 + 走完整探针链路到 PostHog payload),外加一条防修过头的 「内容一直在长时四格路照样要报 `wheel_stall`」。 ## 待拍板(没动) `MAX_REPORTS_PER_SESSION = 3` 本身合理,**不合理的是把「停止上报」和「停止观测」 绑在一起** —— `attach()` 预算耗尽后直接返回 null,于是活动轨迹、账本、 `window.__chatScrollFreeze` 的 `attached` 全部永久失效,而且**静默**,长得跟 「后来没再卡过」一模一样。建议上限只加在 `reportSafetyEvent` 那一步。 ## 顺带发现(没改) 天花板探测用的是同一个过期 `previousTop`,anchoring 修正会写进一条假的 `shortfallPx`;而 `ledger.first` **永不淘汰**,所以「drift 从哪儿开始」这个头号 字段可能已经被污染。**不建议照搬这个门去堵** —— 账本的立意是把内容变化和够不到 的距离配对,要求布局静止会把真正的首次 shortfall 一起杀掉。语义不同,单开一轮。 * feat(diagnostics): 卡住时一键把滚动现场装进诊断包 我们复现不出「聊天区滚不动」,但**同事们能撞到,而且都愿意提供数据**。所以要 一条他们自己就能走完、产出可发送文件的取证路。 ## 一个采集器,三条触发路,一个目的地 目的地是同事**已经会用**的那个 zip(`open-design-diagnostics-*.zip`),不新造流程。 `useDiagnosticsExport` 是设置页「导出诊断日志」和报错卡「导出日志」共用的那个 hook, 补一处就覆盖两个入口。 ## 但走到设置页会毁掉现场 —— 所以必须有第三条路 `App.tsx:4804` 的 `openSettings()` **永远走 `navigate({kind:'home', view:'settings'})`**, 而 `:5213` 把设置渲染成 `appMain` —— **设置是路由不是浮层**。走过去点导出, **聊天区会被卸载,冻结的那个面一起没了**。(浮层形态的代码存在,但没有任何地方路由到它。) 所以加了唯一一条**不导航**的入口:**菜单栏 Help → Export Diagnostics…**。 那条走 main → daemon,全程不碰 renderer;main 再通过 `window.__odCaptureChatScrollForensics`(`executeJavaScript`,5s 超时,失败吞掉) 反向取现场。**原生菜单不重绘页面,所以卡住时够得着**,而且没有新增任何界面元素。 另配一道**冻结时留存闩**,订阅探针已有的 `subscribeChatScrollFreeze`:探针一旦 判定冻结,整份现场当场存进 renderer 内存(30 分钟 TTL,首次冻结优先)。这样即使 之后走到设置页,证据仍在。信封里 `live` 和 `retained` 两个槽都在,**空的时候各自 说明为什么空**。 ## 现场带什么 `summary/chat-scroll-forensics.json`: - **`probe`** —— `__chatScrollFreeze.snapshot()` 原样(账本、十二道闸、活动轨迹、 层普查、祖先链合成属性) - **`composition`** —— 祖先链(由外向内)+ 全部直接子节点,每个带 computed 的 `display` / `position` / `overflow-x,y` / `transform` / `filter` / `backdrop-filter` / `contain` / `will-change` / `content-visibility` / `isolation` / `z-index` / `opacity` / 高度族 / offset 三元 / scroll 三元 / 包围盒 / 在跑的动画数与名字 - **`dom`** —— 聊天区完整 `outerHTML`(8MB 上限,截断会标出来) - **`animations`** —— `document.getAnimations()` 全量 - **`assignment`** —— **判决式的那一条**: `assignment_reached_layout_max`(JS 能到底 ⇒ 滚轮/合成器那一半坏了)/ `assignment_clamped`(滚动盒自己不让走)/ `no_room`(布局真没空间) - **`runtime`** —— Chromium 与 Electron 版本、平台、DPR、窗口/屏幕/visualViewport 尺寸、`zoomInputs`(原始输入,不猜缩放级别);daemon 那侧再盖上权威的 app 版本/渠道/打包与否 - **`writes`** —— `writes.list()`,没开拦截时**明说没开** - **`timeline`** —— 各段耗时。赋值探测永远是最大的那一段,这本身就是顺序证据 ## 赋值探测放在最后,而且必须还原 它排在 probe / runtime / scroller / composition / dom / animations **之后**, 做完就地还原,`finally` 再还原一次。`warnings` 里带一条: **「还原过的 scrollTop 不能当作滚动正常的证据」**。两条测试钉着:一条断言元素 回到 137 且 `writes` 含 4200;另一条让 setter 在**移动之后**抛错,断言 `writes === [4200, 77]`。 ## 为什么用会合点而不是请求体 两条导出路到 daemon 都是**无体的 `GET`** —— 打包版里点击走 renderer → preload → Electron main → 原生保存对话框 → daemon GET,**根本没有 renderer 能附上的 body**。所以 renderer 先 POST 到 `/api/diagnostics/chat-scroll-forensics`(仅本地、24mb 上限),daemon 在**内存里** 存最新一份,导出时排空进 `summaries`。存内存不落盘,于是它不会变成一个 `OD_DATA_DIR` 路径问题。 ## 撤实现验红 13 处逐个撤、逐个确认对应测试变红(含:赋值探测移到 DOM 采集之前、还原逻辑、 祖先链遍历、`outerHTML`、动画枚举、Help 菜单的反向调用、window 钩子的发布、 daemon 排空、空态说明结构、请求体校验)。 顺带撞上那个已知的坑:**web typecheck 绿而 `--filter daemon build` 红** (`express.json()` 推断出的 connect 类型触发 TS2742),补了显式 `RequestHandler` 标注。 ## 残留缺口 **浏览器里**没有不导航的入口,除非屏幕上正好有报错卡 —— 设置页会导航、毁掉现场, 只剩「冻结时留存」那一份,而它要求探针**真的报过**,恰恰是我们还不敢信的那一环。 补它需要在聊天区加一颗按钮,而聊天面板头部正在做 1:1 对齐重构,**没有单方面加**。 打包桌面端(同事们在的地方)由 Help 菜单完整覆盖。 * fix(amr): 余额四格矩阵按产品文档收口(T54–T58) 产品文档第四节「四、升级情况」那张四格表(付费/免费 × 个人/团队 Owner), 这次一次性对齐。五条裁决记在 `specs/current/chat-panel-decisions-sheet.md` 的「五之六」「五之七」。 ## T54 通用「升级」改跳当前环境的 console 套餐页 `<console origin>/dashboard?source=open_design&billing=plan`,不再跳写死的公开 Pricing。origin 复用现成通路(daemon `/api/integrations/vela/status` → `setRuntimeAmrConsoleOrigin`),没造第二份。 **这条同时修掉一个真缺陷**:`amrPlansUrlForProfile(_profile)` 的参数带下划线 前缀、刻意不用,所以 test / local / feature-test 的包**一律跳生产 Pricing**, 而那页选中套餐会带着 plan + interval 回**生产** Vela 直接结账。 ## T55 矩阵管个人工作区:个人版付费档余额 $0 也要硬拦 让位判据从「有套餐 ⇒ 让位」改成「**档次读不出来 ⇒ 让位**」。 `planMayFundRunOutsideWallet`(`!isFreeAmrPlan(...)`)删除,换成 `amrPlanTierUnreadable`(`resolveAmrPlan(...) == null`);`isFreeAmrPlan` 因此 没有生产消费者,一并删除 —— 留着它会以「free 还是那个轴」的名义把老逻辑招回来。 缺陷形状:`isFreeAmrPlan` 只精确匹配 `'free'`,连 `'basic'` 都算「非 free」, 于是个人版 Basic/Plus/Pro/Max 在 $0 落到 soft 档,**四格弹窗一张都不出**。 ⚠️ 推翻 T15 / R-010 / #7190「付费档余额 0 = 不限量,不拦」。 ✅ `cf00c80bd1` 的另一半原样保住:套餐读不出来时放行,由远程兜底。 ⚠️ 代价:个人版 Max 用户 $0 时不能再靠套餐额度直接开跑。**这格只有真机能确认。** ## T56 「找所有者充值」改用产品稿正式文案,删掉「复制请求」 终态只剩 标题 / 正文 / 「知道了」。原来那份是**有授权的临时文案** (§6.V 逐字写「文案由研发拟,产品复核」),这次是正式替换,不是推翻设计。 ⚠️ 代价:这一档回到单出口,能给的只有「该找谁」,不再替他把话写好。产品知情。 i18n 三个 key(`requestTemplate` / `copyCta` / `copiedCta`)从 19 个 locale 删除。 ## T57 Owner 名字拿不到时的降级文案 两个变体两个 key。**现在一定走降级那条**:契约里唯一的 owner 名是 `CollabProject.ownerDisplayName`(**项目级**,且注释自己写着 "STUB"), `WorkspaceCollabContext` 上没有工作区 owner 名。后端补上名字来源后自动生效。 ## T58 Max × Team Owner(第 3 格)出转化弹窗,Upgrade 跳自动充值 那张弹窗**和第 1 格是同一张,文案一字不差** —— 没有新文案、没有新 i18n key、 没有新组件。两格唯一差别是主按钮落点:第 1 格 `billing=plan`,第 3 格 `billing=auto-recharge`。 `AmrBalanceBlockedDialogKind` 的 `null` 那一支整个删除(留着它只会让每个调用点 继续背一条 `?? 'upgrade'` 兜底 —— 首页那条正是这么活下来的)。 ⚠️ **顺带更正一条被当成产品文案的机器描述**:此前记录引用过第 3 格 「未达到 $100.00/月的额度」—— 那不是产品文案,是飞书导出时 AI 为截图自动生成的 **图片 alt 描述**。以图为准,截图上写的和第 1 格一字不差。凡照着那句推出的结论 一律作废。 ✅ 链路另一半同日通了:vela **#1900** 已合并,`?billing=auto-recharge` 现在真的 弹出自动充值设置弹窗。我们这侧发出去的参数不用改。 ## 撤实现验红 四次,逐一对应:`amrBalanceBlockedDialog` 回到 `max → null`(红 5 条)、 弹窗里 `upgradeIntent` 落点分支(红 4)、只撤首页调用点(红 1)、 只撤项目页调用点(红 3)。 **首页此前根本没有分支矩阵覆盖** —— `EntryShell.amr-balance-branches.test.tsx` 是本次新增,那条 `?? 'upgrade'` 兜底就是这么活下来的。 ## 只有真机能确认 Max 档**在 prod 造不出来**,第 3 格今天只有单测;「弹窗真的弹出来了、按钮真的 把浏览器带到自动充值面板」需要一个真 Max 账号走一遍。 * feat(observability): 滚动冻结的复现工具链与滚轮接管(默认关) 三样东西,都服务于同一件事:**我们复现不出「聊天区滚不动」,只能靠同事的真实 现场**。所以先把「怎么留证」和「怎么回放」搭起来,不急着改产品行为。 ## 1. 滚轮接管 —— 默认关,且必须手动打开 `open-design:chat-scroll-takeover` 设成 `'1'` 并重载才生效,否则挂上去也是惰性的。 **为什么默认关是硬要求**:产品原话「**我们要观测旧的实现问题,不要影响**」。 接管会把滚轮从合成器手里接过来自己算位移 —— 那正好会**掩盖**我们要观测的那个 缺陷,让冻结不再出现,而我们既拿不到取证也说不清是修好了还是只是把时序推开了。 所以它只是一个**逃生口**(同事真卡死时能自救),不是修复,更不能默认参与。 ## 2. e2e 回放骨架 `chat-scroll-replay.ts` + `chat-scroll-recording.ts`:把真机采到的滚轮/内容变化 时间线 1:1 喂回去。 ⚠️ **`chat-scroll-wheel-reach.test.ts` 目前是 `test.skip`,这是有意的。** 回放能重放时间线,但**造不出真的冻结** —— 一个从没红过的守卫什么都不证明, 把它留在绿灯里比 skip 更糟。等真现场回来、能稳定复现了再解开。 ## 3. 取证路径的测试补齐 给已落地的 `chat-scroll-forensics.ts` 和 Help → Export Diagnostics… 补上用例。 ## 不在这条提交里 `.chat-log-viewport` 的 grid 轨道候选修法(`minmax(0,1fr)` → `100%/100%`) **故意没有提交**。它是一个**候选修法**,而验证它的那次 A/B 已作废;这个包的 用途是收同事的取证现场,掺一个候选修法进去,万一冻结不再出现,取证和结论 两头都落空。改动留在工作区,等产品拍板。 * chore: 把发布列车抬到 0.21.2 **动机是取证的可溯源性,不是走流程。** 我们连打三个 beta 给同事和 QA, 三次版本号都是 `0.21.1-beta.7`,于是「QA 报了个问题」没法对应到是哪个包。 产品原话:「不然 qa 反馈问题我都不知道是新包还是旧包」。 **为什么版本号就是全部身份**(逐条查证过): - `tools/pack/src/versioning/index.ts` 的 `readRuntimeAppVersion()` 只有两个来源 (`--app-version` 或 `apps/packaged/package.json`),没有 sha 输入口 - `apps/packaged/src/sidecars.ts` 交给 daemon 的身份信息只有 `OD_APP_VERSION` - `AppVersionInfo` / `DiagnosticsAppInfo` 都没有 commit 位 —— **诊断包里也不带** - electron-builder 没配 `buildVersion`,macOS `CFBundleVersion` == 版本号; Windows PE 第四位硬编码 0。**没有任何隐藏构建号可以当区分符** - sha 目前只进了 R2 的 `metadata.json`,而 `publish=false` 的包连这个都没有 **为什么是 0.21.2 而不是 beta 序号**:`open-design-v0.21.1` 这个 stable tag 已经存在,而仓库基线还停在 `0.21.1` —— release bot 的常规节奏本该已经抬上去了, 只是还没做。抬完顺带让 `prepare-beta.ts` 那条「基线必须严格大于最新 stable」的 判据自然成立,`force=true` 不再是必需品。 **17 个 manifest,不是 18** —— `apps/landing-page` 已在 `1d2150007b` 移出仓库。 全集由两个独立方法交叉验证(遍历所有非 node_modules 的 package.json 取 version==0.21.1;以及 `git grep '"version": "0.21.1"'`),结果一致。 workspace 互相依赖全是 `workspace:*`,`pnpm-lock.yaml` 里 `0.21.1` 出现 0 次。 ⚠️ **这次抬版本只能救一个包。** `publish=false` 永远不推进 R2 metadata,而 `prepare-beta.ts` 只在「仓库基线 == metadata 基线」时才递增 beta 序号,基线更大 时固定为 1。所以下一个包是 `0.21.2-beta.1`,**再下一个还是它**。 永久解是把已经躺在打包 job 环境里的 `RELEASE_COMMIT` 接进诊断包 —— 分支 `chore/pack-build-commit` 已备好,未合并。 * docs(specs): 落 T52–T59,并把 §6.V 里被推翻的三处同步掉 ## 决策表新增 - **五之六 T54–T57**、**五之七 T58**:余额四格矩阵的终态与代价(见上一条代码提交) - **五之八 T59**:**交付稿的标注文字与它自己渲染出来的 DOM 打架时,以 DOM 为准** ## T59 首次适用:组件 18 升级卡不显示用量条 交付稿(git ref `729fa43ce7`)自相矛盾,矛盾在两处: - `:5739` 组件 18 自己的说明写「**展示**当前用量与受影响的能力」 - `:5585` 组件 15 解释「这里不报用量」时把用量归属指给 18,写「剩余额度 + **进度条** + Upgrade」 自洽的那一半是 DOM + CSS: - `:3337` 稿子自己的 CSS 注释写「保留旧刻度样式,**当前额度卡不显示刻度**」 - `class="meter"` 在**整份稿子的 DOM 里出现 0 次** - 组件 18 那张卡的实际 DOM 是「剩余额度 $3.20」+ 一句话 + 一颗按钮,**没有条** 那条留着没删的 `.meter` 样式就是物证:曾经有过刻度,后来去掉了,样式没删、两处 说明也没跟着改。产品口述 2026-09-07:「**对的以 dom 为准**」。 ⚠️ 通则不等于可以自行发挥:发现打架**要在决策表记一条**,不能默默按 DOM 做完 就算,否则和「照着标注做」一样不可追溯。 ## §6.V 同步(它是我们自己的规格,不是产品那份不能动的文档) 三处已被后来的裁决推翻,留着就是给下一个人埋雷: 1. 「非 Max · owner 卡和弹窗都直接跳 Pricing」→ 改跳当前环境 console 的 `?billing=plan`(T54) 2. 「Max · owner ── 不弹窗」→ **和第 1 格同一张弹窗,文案一字不差**(T58) 3. 「付费档余额 0 = 不限量,不拦仍然成立」→ **已被 T55 推翻**,个人版付费档 $0 现在也硬拦;让位判据改成「档次读不出来才让位」 另补 §6.V「文案由研发拟,待产品复核」那一条的**复核结果**:改用产品正式文案、 删掉「复制请求」,于是这一档回到单出口(T56/T57)。 * test(amr): 补上 T54 漏在上一条提交外的那个断言 `ChatPane` 会话切换器里那条「AMR 档位升级」动作的用例,断言的还是**生产** Pricing URL(`https://open-design.ai/pricing/`,且 `billing` 为 null)。T54 之后 它应当跟随当前 profile 落到 console 的套餐页。 这条本该和 T54 的代码改动同一条提交,是我分拣文件时漏的 —— 单独看那条 `fix(amr): 余额四格矩阵按产品文档收口` 会是红的,记在这里以免后来人对着它二分。 顺带说明它守的正是 T54 提到的那个真缺陷:渲染 profile 是 `test`,而旧断言要求 跳生产 —— 也就是**非生产的包把人送去生产结账**。现在断言 `https://vela.powerformer.net/dashboard` + `billing=plan`。 * fix(observability): 三处让探针自己变瞎的缺陷 都不是产品缺陷,是**我们的诊断代码在损害我们自己的观测**。而现在整条链路的 用途就是靠同事的真实现场定位滚动冻结,所以它们是高优先级。 ## 一、报满 3 条之后连观测都停了(而且是静默的) `MAX_REPORTS_PER_SESSION = 3` 本身还算合理,**不合理的是把「停止上报」和 「停止观测」绑在一起** —— `attach()` 在预算耗尽后直接返回 null,于是活动轨迹、 亏空账本、`window.__chatScrollFreeze` 的 `attached` 全部永久失效,**而且长得跟 「后来没再卡过」一模一样**。同事装上包,切到第四条会话就变成瞎子。 产品原话:「**为了揪出问题,报一万条都行啊... 定位到问题后再删了呗?**」 所以**整条会话级限流删掉**,不是改成一个更大的数,也不加可配置开关或环境变量 (这是临时取证配置,定位到根因后整套探针会删掉,不要引入新变量)。 **per-surface 的一次锁保留** —— 那是去重不是限流。 顺带查证:`chat-scroll-forensics.ts` 的 `handleFreezeSignalForRetention` 有 `if (retained != null) return`,所以取证快照全会话仍只存第一份。放开上限只增加 PostHog 事件量,**不会**让取证存储无限长。 ## 二、天花板探测用过期基准,污染「drift 从哪儿开始」 和刚修的 snap-back 误报同一个病:`SCROLL_SAMPLE_MIN_INTERVAL_MS = 250` 的节流是 **丢弃**跳过的采样。原生 scroll anchoring 一修正位置,就会写进一条几百上千像素的 假 `shortfallPx`;而 **`ledger.first` 永不淘汰**,那正是「drift 从哪一次内容变化 开始」这个头号字段,**污染一次永久留着**。 **没有照搬 `layoutHeldStill`** —— 账本的立意是把内容变化和够不到的距离配对, 要求布局静止会把真正的首次 shortfall 一起杀掉(真冻结时内容往往正在长)。 新判据 `ceilingProbeAttributable` 问的不是布局静不静,而是**这一帧的落点是不是 滚轮造成的**,两条各自自洽的路: 1. **位置和基准分毫不差** → 无条件收。能改 `scrollTop` 的只有 anchoring、我们 自己的写、和滚轮;位置没动说明前两样都没发生,而滚轮明明来了。 **内容在下方长高只改天花板、不碰 `scrollTop`**,所以「内容在长」这一整类 真冻结全部保住。用精确相等而非容差,是因为 `observeWheelBatch` 判「动没动」 就是 `top !== previousTop`,两边对「没动」的定义不许分家。 2. **位置往回退了,且布局没变** → 才收。往回退的竞争解释只有 anchoring,而 **anchoring 修正必然伴随高度变化**。所以只在这条路上要 `layoutHeldStill`。 拒错一次只少一条 probe;收错一次会把一个**永不淘汰**的字段永久占掉。 ## 三、`syncFollowState` 的恒真守卫,每帧白写一次 scrollTop `if (el.scrollTop !== el.scrollHeight)` —— `scrollTop` 最大只到 `scrollHeight - clientHeight`,**永远不可能相等**,于是贴底跟随时每帧都写一次。 功能上无害,但**它会污染我们用来区分「是我们在写」还是「合成器不动」的写入记录**。 换成 `isPinnedToLogBottom()`,**写入值不动**(仍写 `scrollHeight` 交给浏览器夹取), 变的只有「要不要写这一帧」。 容差取 1px 而不是 `AT_BOTTOM_TOLERANCE_PX`(8px):那 8px 回答的是另一个问题 (「用户算不算还贴着底」),拿它当写入判据,流式期间每帧长高不到 8px 的内容会 一直攒到超过 8px 才被追上,跟随会肉眼可见地一顿一顿。 ## 三个夹具假象(缺陷三暴露出来的) `chat-scroll-preservation` / `chat-scroll-following` / `chat-anchor-to-top` 三条 在合并后变红。逐条判定结果是**夹具假象,不是行为改坏**,共同特征是断言时刻 `scrollTop > maxScrollTop()` —— 一个真实浏览器造不出来的状态。两个成因: - **setter 根本不夹取**(同 `ChatPane.streaming` 那条):断言的是「发生过一次写」, 那次写在真浏览器里会被夹回去、**一个像素都不动**。改成断言真实落点。 - **setter 夹了,但内容变矮不经过 setter**:W105 收占位块让 `scrollHeight` 一口气掉 500,而停在旧底部的 `scrollTop` 没人夹。补 `settleScrollAfterLayout()` 挂在帧回调批次之后(正是浏览器夹取发生的位置), **期望值一个没改**。 **「守卫漏了一个真实需要写的时刻」这条可能性被排除**:占位块是最后一个孩子, 它变矮 → 上限下降 → 浏览器把 `scrollTop` **往下**夹 → 结果是**更贴底**。 夹取只会把人推向底部,永远不会推离。反向情形(占位块收 500 同时内容长 200, 上限净涨 80)守卫读到 `80 > 1` 照写。 ⚠️ **一处覆盖能力的净损失,记在这里**:`chat-anchor-to-top` 的 `blankOnScreen ≤ 52` 原本确实能检出「跟随没写」(旧夹具 + 删掉写 → 红),补上 夹取之后没有了。判断是**那份能力本来就是假的** —— 真机上占位块一收,浏览器自己 就把人夹到底,写不写都成立。该用例的主断言(占位块必须收到 8)不受影响,跟随的 覆盖由另外 5 个用例扛着。 ## 撤实现验红 上限:6 条(含「报满旧上限后仍在观测」—— 实现前第 7 条报告 `undefined`)。 判据二:红测 `shortfall_first_px` 实现前 980(anchoring 假值)、实现后 380; 另有两条反证 —— 换成朴素的 `layoutHeldStill` 会红掉「内容在长时的真 drift」, 只留「位置相等」会红掉回放用例,证明两条路都不多余。 缺陷三:实现前 10 次 `syncFollowState` → 10 次无位移的写,实现后 0 次; 把跟随的写整个删掉,5 个用例仍然变红。 ## 教训 按文件名前缀窄跑不够 —— `ChatPane` 的改动,blast radius 是整个 `tests/components`(658 文件 / 6362 条,已全跑绿)。 ## 只有真机能确认 分数 `scrollTop` 下 1px 容差够不够(jsdom 无布局,几何全是塞进去的整数); 真实 Chromium 的 anchoring 是否总是同步改 `scrollTop` 和 `scrollHeight` (判据二的物理前提);放开上限后的真实事件量;跟随观感是否完全无感。 * fix(chat): 颜色块 / 思考滚动条 / 问卷被误锁三个缺陷(OPEND-2642 2643 2644) ## OPEND-2642 颜色题已确认摘要仍缺色块(回归) **根因:「已确认」摘要有两份实现,而 OPEND-2579 修的是产线永远走不到的那一份。** - `QuestionForm.tsx` 的 `AnsweredValue` → 会画色块 - `AssistantMessage.tsx` 的 `FormBlock`(历史回放块)→ 自己内联 `<b>{value}</b>`, 无色块、无 `.ab.mod-value` 关键事实:`QuestionFormView` 的 `submittedAnswers` 这个 prop **全仓库没有任何 产线调用点**(传参方全是测试)。用户看到的那块「已确认」永远是第二份。 **2579 为什么没挡住**:它的用例直接渲染 `QuestionFormView` 手工喂 `submittedAnswers` —— 走的正是那条产线到不了的路。新用例改成**从 `AssistantMessage` 进**,走 `formatFormAnswers → parseSubmittedAnswers → 摘要` 的真实回放路径。 改法是导出 `AnsweredValue` / `isShortValueAnswer`,回放块改用同一对函数, 注释里写死「值只有这一处画法」。 ## OPEND-2643 思考过程滚动条遮挡正文 **根因两层**: 1. `padding-inline-end: 4px`(注释说「留一点气口」)**从来没生效过** —— 它是 (0,3,0),被灰底容器的 `padding: var(--stream-pad)` 简写 (0,4,0)/(0,5,0) 整条盖掉,右边实际一直是 8px。 2. 8px 也不是让位:Chromium 在 macOS 默认覆盖式滚动条,不占布局、直接画在 padding box 右缘,而 `scrollbar-gutter: stable` 对覆盖式**是空转的**。 改法:`::-webkit-scrollbar { width: 8px }` 让 Blink 退出覆盖式(滚动条变成占 布局的经典滚动条,宽度从内容盒扣),加 `scrollbar-gutter: stable` 防止它中途 冒出来时正文横跳。⚠️ 注释里写死**不要**加 `scrollbar-width` / `scrollbar-color` —— Chromium 121 起它们会让 `::-webkit-scrollbar` 整体失效、**静默撤销这个修复**, 并配了一条守它的用例。 **测试能证明什么**:用共享层叠量尺量「哪条规则赢了」,包括把「4px 被简写盖掉、 实际 8px」钉成事实基线。**证明不了**:jsdom 不做布局也没有滚动条实体, 「字和滚动条不再重叠」**只有真机能确认**(需分别看 macOS 两种滚动条设置和 Windows/Linux 经典滚动条)。 ## OPEND-2644 未提交的问卷被锁住并误标「已回答」 两处缺一不可: 1. **`ChatPane.tsx` 配对放宽** —— `nextUserContentByAssistantId` 老写法只认 **物理相邻**的下一条消息,记忆卡插进来配对就断了。改成从后往前扫,把最近 一次看到的 user 正文发给它上面的助手消息。放宽配对**不会凭空造答案**: `parseSubmittedAnswers` 只认 `[form answers — <id>]` 开头且标签对得上的文本。 2. **`AssistantMessage.tsx` 判据换掉** —— 抽成具名 `questionFormAnswerable = isLastAssistant || nextUserContent === undefined`。 `isLastAssistant` 留在或的前半,是因为流式当轮里 `nextUserContent` 本来就是 空的 —— 它是判据的一个特例,不是替代品。 3. **「已回答」pill** 原来挂在 `locked` 上,而 `locked` 的三种来路里有两种跟 用户答没答过无关。现在只由真实提交答案兑现。 **只改第 2 处会把「答不了」换成「答完了看不出来」** —— 答案落在记忆卡后面, `parseSubmittedAnswers` 认不出来,永远收不成「已确认」摘要。 **意外收获**:撤掉第 1 处时,**历史里所有没答完的问卷都会被重新打开**(那些 消息的 `nextUserContent` 全配不上了)。所以第 1 处不只是让摘要收口,它同时是 「锁住」的判据来源。 ## 撤实现验红 2642:回放块改回 `<b>{value}</b>` → 2 条红。 2643:删掉 gutter + 三条 webkit 规则 → 2 条红。 2644:**分开撤,一处一次** —— 只撤第 1 处红 2 条(摘要收不成、走过去的表仍锁住), 只撤第 2 处红 2 条(表填不了、没有那句「此前回合的表单」)。 ## 上一轮我自己写错的两处,一并更正 1. `opend-2644-unanswered-form-not-marked-answered` 里有一条断言「记忆卡之后 显示『此前回合的表单』」—— **那是修复前的行为**,当时被当成了不变量。现在 改成断言它不出现,并新增一条把那句说明的正确触发条件钉住。 2. 第一版红测的判据本身是坏尺子:`formControlsEnabled()` 拿「卡片上所有 button 都没 disabled」当「表能填」,但底栏那颗「下一步」在必填项填完前本来就是灰的。 dump 出来的 DOM 是 `class="question-form"`(没有 `-locked`)+ 只有 `Next step` disabled —— **修复其实早就生效,是尺子读错了**。改成看 `question-form-locked` 类 + 排除底栏的答题控件,并把这个坑写进 helper 注释。 ## 未做 工单评论里的第二件事(同一回合两组操作栏)查清了但**没动**:那条 memory-applied 是**宿主自己补发的消息**(`ProjectView` 拿 `useMemoryWrittenCard` 的批次 `appendConversationMessage` 一条完整 assistant 消息,所以没有 run_id/run_status 却照样渲染完成状态和操作栏)。要收成「回合附属组件」得改消息编排语义 —— **这是产品裁决**,已在 Plane 上请示。 ## 只有真机能确认 记忆卡是 turn 结束后**异步轮询**出来的(不是同步落库),所以它真实出现的那一刻 问卷能否正常填写并提交、以及提交后刷新/重进项目摘要是否稳定收口,单测覆盖不到。 * docs(specs): 量清「纯问答被判失败」那条裁决的边界,并记下它盖不住的一条真缺陷 为把 beta.7 现场(task `odnext_c4ee010be6b748dc9b92984946bc10a8`)钉成可回归的 形状,写了一条红测(**故意留红、未提交**),顺着它把判定链逐跳查清了。 **结论:那条红测断言的是「还没设计的产品行为」,不是缺陷。** 实测证明「接受纯文字回答」和既有的「拒绝把 silent no-op 洗成 completed」两条 互斥 —— 放开 `deliverableValid` 要求后红测转绿、既有绿测立刻转红,而两条 fixture 除了 agent 那段散文**输入完全相同**。唯一剩下的杠杆是按散文内容猜,正是 `chat-panel-issue-log:58` 那条裁决点名禁止的(「不做关键词猜测」)。 ⚠️ **但同一个坑里挖出一条不在该裁决覆盖范围内的真缺陷**: **真干了活、却不产生新 artifact 的 Direct Edit**(删文件、改名、只读审计,或 artifact 记账漏算的编辑)—— `validateRunDeliverable` 一律给 `no_artifact`,于是走进 和纯问答一模一样的终态 blocked。**这里用户明确要的是改动,agent 也确实做了**, 和「纯问答没交付物」是两件事,**该单独立项**。 另记:同链上另外三种合法产出的同类坑、一处规范自相矛盾(`general-orchestration.md:496` 说澄清轮不输出 machine block,而 `od-next-strategy.ts:752` 说每轮都必须有)、 前端那条 succeeded→failed 重映射为何不用单独改(以及为何**不能只改前端**), 以及唯一需要翻现场持久化事件才能定死的一点。 * fix(chat): 把 .chat-log-viewport 的 grid 轨道换成确定值(滚动冻结候选修法) `grid-template: minmax(0, 1fr) / minmax(0, 1fr)` → `100% / 100%`。 **这是假设,不是已证实的修复。** 产品拍板放进包里,理由是「免得到时候阻塞上线」。 ## 假设 `1fr` 是**不确定轨道**:尺寸是剩余空间的一份,所以轨道要在拥有它的盒子之后的 另一趟里定尺寸,里面那个滚动盒再被拉伸到轨道最终定下的值。`100%` 直接从这个 viewport **已经确定**的内容盒解析(它自己的高度经 `.chat-log-wrap` 的 `flex: 1` 确定,再往上由 `.split` 的 grid 轨道确定),于是这一格第一趟就是对的,之后不再被 修订。 猜的是:滚动节点在轨道尺寸修订落地之前就建好了,而那次修订没有把绘制属性标脏。 **这条因果链没有建立。** ## 已知的缺陷形状(三次真实现场) 天花板被冻在一个早期的内容高度上(实测 91+583=674、824+583=1407、25+583=608 —— **天花板 + 视口 = 冻结那一刻的内容高度**),程序性写 `scrollTop` 仍然有效, 只有销毁并重建那个布局盒才能解除。 ## 怎么判它对不对 **读 `client_chat_scroll_frozen`**:如果带着这条规则的包仍然在报,假设就是错的, 这一行该换成下一个候选需要的写法。这也是为什么本次同时放开了探针的会话级上报 上限 —— 判据要靠事件量,不能报满三条就变瞎。 ⚠️ **代价要说清**:如果冻结从此不再出现,我们**分不开「真的修好了」和「只是把 时序推开了」**。这是产品知情后的取舍。 ## 等价性 代码注释声称「跨两种书写方向和五种布局状态,计算几何(含 used track sizes)与 `elementFromPoint` 探针逐字段与 `minmax(0, 1fr)` 相同」—— 那是**实测得来的**, 不是假设。`chat-log-viewport-definite-tracks.test.ts` 守的是 CSS 文本契约 (轨道保持确定、不退回 `1fr`/`minmax()`、`0e8bbdaa69` 的 rtl 滚动条沟槽验收 不被破坏、百分比轨道依赖的「祖先必须是确定包含块」这个前提仍然成立)。 **它证明不了修复有效** —— jsdom 不做布局,没有 used track size、没有滚动边界、 没有合成器,假装能量的测试就是假绿。假设的证伪路径是遥测事件,不是这个套件。 * chore: 抬到 0.21.3 上一个包是 `0.21.2-beta.1`。因为特性分支必须 `publish=false`,R2 的 beta metadata 永远不推进,而 `prepare-beta.ts` 在「仓库基线 > metadata 基线」时把 beta 序号 固定为 1 —— 不抬 patch 位,这个包还会叫 `0.21.2-beta.1`,和刚发出去那个撞名。 **每要一个可区分的包就得抬一次**,这是 `publish=false` 路线下唯一不碰 `release_version` 覆盖(那条会锁死 beta 发布线)的办法。永久解是把已经躺在打包 job 环境里的 `RELEASE_COMMIT` 接进诊断包 —— 分支 `chore/pack-build-commit` 已备好, 未合并;⚠️ 合之前要先补 `release-stable.yml` 那四个 build job 看不到该变量的缺口。 * docs(specs): 滚动冻结——活现场实测,候选修法当场证伪,键盘同样走不动 包 0.21.2-beta.1,两次真实冻结现场,用 Electron 主进程 inspector + `webContents.sendInputEvent` 注入真滚轮测量(JS 合成的 WheelEvent 不走这条路)。 **推翻了一条即将发布的「修复」**:`grid-template: 100%/100%` 加在活的冻结实例上, 滚轮纹丝不动。它在 `5a538991a0` 里,**建议撤**。 **把缺陷范围收紧了一格**:键盘 PageDown 同样死在 6px,而 JS `scrollTo` 能到 859.5, `scrollTop` 写入拦截全程为空。所以既不是「我们的代码在拽回去」,也不只是滚轮 —— scroll unification 之后滚轮/键盘都走 cc scroll tree,卡住的是那个 ScrollNode 的 bounds(停在 589),Blink 侧的 1442 是对的。 **它是彻底冻死不是滞后**:追加 1000px 内容,布局上限涨到 3114,滚轮仍只到 6。 **排除**:CSS 容器查询(第二次现场 DOM 里一个 artifact card 都没有)、祖先链合成边界 (全 none)、嵌套滚动盒吃滚轮(0 verdicts discarded)、残留 transform、overflow 切换、 will-change / translateZ 上下线、滚动盒自身高度微调、占位块钉 0。 **当前最可疑**:滚动盒用 ResizeObserver 观察自己、回调里又写占位块高度——一个长在会冻的 盒子上的自喂环,且 `origin/main` 没有这一条(它只观察子元素)。 **顺带记两条**:① 探针每个日志元素只报一次,同会话第二次冻结遥测拿不到; ② `display:none` 那一格不要再跑——答案早知道,唯一作用是终结现场,今天就这么浪费了一个。 * docs(specs): 滚动冻结——grid 包装盒证伪,平滑滚动可解冻而瞬时直写不能 **H1 证伪**:把 `.chat-log-viewport` 还原成 origin/main 的 flex 形状,照样冻 (wheelReached 31.5 不动,layoutMax 从 439 长到 1450)。8/31 那两次结构改动 (新造 viewport、flex→grid)全部洗清。天花板值不固定,它就是「助手消息刚开始长的 那一刻的内容高度」。 **★ 切出一条精确分界**:`scrollTop = N` 瞬时直写**不解冻**(自动探针每 12 秒做一次, 冻结纹丝不动),而 `scrollTo({behavior:'smooth'})` **解冻**(产品点「回到最新」后 量到 wheelReached 1449.5 = layoutMax)。和「JS 能到底但滚轮/键盘不行」自洽: 瞬时直写只走 Blink,动画滚动由 cc 驱动、会强制重新同步陈旧的 scroll tree。 由此得到一个**无闪屏**的止血手段:判定冻结 → 自动平滑滚动一次,先上报再自愈。 ⚠️ 附一条量测纪律:常驻注入式探针会把用户画面每 12 秒「滚到底再滚回来」,产品当场 以为是 agent 在乱操作。只读采样可常驻,注入输入必须单次手动触发。 * Revert "fix(chat): 把 .chat-log-viewport 的 grid 轨道换成确定值(滚动冻结候选修法)" This reverts commit5a538991a0. **在活的冻结现场上当场证伪了。** 2026-09-07,打包版 `0.21.2-beta.1`,通过 Electron 主进程 inspector 把 `grid-template: 100% / 100%` 加到一个正在冻结的实例上: ``` gtBefore 583px / 442px → gtAfter 583px / 442px (几何一个字段没变) before {top:6, sh:2685, ch:583, layoutMax:2102} after {top:6, sh:2685, ch:583, layoutMax:2102} 注入真滚轮 12 格 → 仍然只到 6 ``` 而且**对照是干净的**:同一次会话里,先给同一个元素写一条**与当前算出值完全相同**的 内联 `grid-template`(纯样式改动、语义零变化),滚轮同样不动 —— 所以"随便碰一下样式 就能解冻"这个替代解释也被排除了。 **同一轮还证伪了它所在的那个盒子**:把 `.chat-log-viewport` 用 `!important` 整个还原成 `origin/main` 的形状(`display:flex` + `.chat-log{flex:1 1 0%}`),照样冻 —— `wheelReached` 全程 31.5,而 `layoutMax` 从 439 长到 1450。所以 8/31 那两次结构改动 (`77859f01f7` 新造 viewport、`0e8bbdaa69` flex→grid)都不是成因。 留着它有两个坏处,都不值当: 1. 提交消息把它写成"候选修法",发出去会被当成修复; 2. 它是后续观测里一个多余的变量 —— 而这个缺陷的判据本来就要靠 `client_chat_scroll_frozen` 的事件量来读。 那条随它落地的 `chat-log-viewport-definite-tracks.test.ts` 一并删除:它守的是 "轨道必须保持确定值"这个契约,而该契约的前提(这么写能修好)已经不成立。 完整的实测记录(含九种干预的解法矩阵、键盘同样走不动、平滑滚动可解冻而瞬时直写不能) 见 `specs/current/chat-panel-issue-log-2026-08-28.md`。 * fix(diagnostics): 取证信封被全局 4mb parser 静默截断 `/api/diagnostics/chat-scroll-forensics` 的请求体被 `server.ts` 的全局 `express.json({ limit: '4mb' })` 截断了 —— 取证路由注册在它**之后**,所以路由链上 那个 24mb 的 `chatScrollForensicsBodyParser` **是死代码**。 **为什么这条致命**:信封里聊天区完整 `outerHTML` 单份上限就是 8MB,而它同时装 `live` 和 `retained` **两份**。也就是说 —— **越是长到会卡死的对话,越传不回来, 而且失败是静默的**。整条取证路就是为了拿这类现场,对它们正好是瞎的。 修法照仓库自己的先例:按路径**前置注册**到全局那行之前(`/api/library/ingest` 128mb 和 `/api/brands/:id/extract-from-html` 32mb 都是这么做的)。**全局 4mb 没动** —— 调大它等于放宽所有路由的攻击面。路由链上那个 parser 一并删掉,并写清它为什么不在 那儿:留着只会让人以为那行决定 limit,而这正是这个 bug 能潜伏下来的原因。 **红测走真实 express 挂载**(现有两条测试全绿但全瞎 —— 它们绕过挂载直接调 handler, 所以 parser 顺序错了照样绿):真 `startServer` + `node:http` 打真 socket,发 6mb。 修前**确定性 413**(`PayloadTooLargeError: request entity too large`),修后 200; 撤实现重跑**逐字复现同一条失败**。另加一条护栏钉住全局仍是 4mb,防止有人图省事调大它。 顺带在信封里补记滚轮接管开关的原始读值(`runtime.chatScrollTakeover`):读现场的人 此前无法排除「这台机器是不是把接管打开了」。刻意**不**复用 `chatScrollTakeoverFlagSet()` —— 它把「键不存在」「值像开其实没开」「storage 读不到」压成同一个 false,而这三件事 对读现场的人是三个不同的事实;读不到就明说 UNKNOWN,不猜。 * fix(chat): 上游过载的报错卡不再把 JSON 原文摊给用户 用户在打包版上撞到上游 529,失败卡里出现整段原始 JSON: `json-rpc id 4: opencode event stream: {"id":"evt_…","properties":{…},"sessionID":"ses_…"}` —— 而真正该说的那句话就埋在里面(`Our servers are currently overloaded`)。 **四层叠加**:vela CLI 把 opencode 的 `session.error` 整段 JSON 拼成**诊断串** → daemon ACP RPC 前面加 `json-rpc id 4: ` → daemon ACP session 的 `fail()` 把这一整串 当 message 发出、`code` **写死 `AGENT_EXECUTION_FAILED`** → web 没有映射命中,描述位 **回落到原文**。 **分类器其实认出来了**(实测喂截图原文:`upstream_5xx` / 可重试),**但 UI 映射表里 没有这一行** —— `amr-guidance.ts` 自己的注释早就点名说这是已知缺口。本次补上 `upstream_5xx` / `provider_high_demand` 两行,**用的是仓库已有的 S10 文案** (`fatal_rpc_error` 那档在用同一句,19 个 locale 现成)。**没有新文案、没有新 key。** 影响面参照 `docs/design/run-errors/error-ux-design.md` 的 S10:每月 11,200 次、 占失败 8.8%、2,056 台设备。 红测夹具是截图原文**逐字**(含 vela 拼的 `(event=…, session=…)` 尾巴),另有一条自检 断言那四段碎片确实在夹具里(证明量法非空转)。撤实现验红命中同一条。 **留下三个更大的口子,只列不改**(需要单独立项): ① `acp/session.ts` 那颗写死的 `AGENT_EXECUTION_FAILED` 服务**全部 9 个 ACP runtime** (amr/vibe/devin/hermes/kilo/kimi/kiro/trae-cli/reasonix),它们的上游报错一律不问 `classifyAgentServiceFailure` —— 而 json-event-stream 路和 Claude 路都问; ② 另有 22 个 failure_detail 同样没人认领,命中就摊原文; ③ `ChatPane.tsx` 里 `runFailureUi` 为 null 时直接 `: rawError`,**跨全部错误类型**, 映射表加行救不了。 ⚠️ 版本事实:打包版是从 `main` 出的,而 `main` 上**没有兜底句**,只要没命中映射就必然 摊原文;本分支有 08-27 的兜底,同样输入只会变成一句空话。**这个泄漏在 main 上更严重。** * fix(chat): 零位移的上滚手势不该关掉跟随;并加两个冻结假设的 A/B 开关 两件事共用 `ChatPane.tsx` 顶部同一个 import 块,拆开会留下「导入了但没用」的中间 提交(`noUnusedLocals` 会红),所以合在一条里。下面分开说。 ## 一、跟随被零位移手势误关(用户 2026-09-07 亲测) 复现:进一个内容很少的会话 → 往上滚几下(**滚不动**)→ 之后的流式输出**不再吸底**。 **根因是两把尺子**:wheel/touch 监听里用的是「可滚动余量**严格大于 0**」 (`scrollHeight > clientHeight`),而状态机其余部分用的是 `AT_BOTTOM_TOLERANCE_PX = 8` 的容差。手势那一侧更严,于是**它先松手** —— 而它是**事前**的(快速流式下浏览器会把 滚轮吃掉、连 scroll 事件都不发,所以必须在位移之前判定),一旦松了,后面 `nextFollowIntent` 再正确也回不来:`escaped` 是个 latch,只有「主动下滚并真的到底」 能清。 **真机数据坐实了这一档**:打包版 `0.21.2-beta.1` 上只读采样,「刚进会话」那个状态出现 **108 个样本**,几何恒定 `scrollHeight 589 / clientHeight 583` —— **余量 6px,正好落在 (0, 8] 这个出 bug 的区间**。另有 37 个样本是 `583/583`(余量 0),而余量 0 那一档**改前 就已经是好的**。所以用户说的「界面没什么内容滚不动」,真机上不是 0,是 6。 ⚠️ 那 6px 是**真实的内容溢出**(盒模型里的 padding/margin),不是高 DPI 取整噪声 —— 取整误差是零点几像素而且会抖,恒定 108 次的 6px 不是。这条写进了 docblock,因为它决定 下一个人该去查盒模型还是去查 `devicePixelRatio`。 ⚠️ 同时写明门槛的**来源**:8px 是从「挣脱 := `!isAtBottom`」推导来的,手势侧和判定侧 必须同一把尺子;6px 落在 8 以内是**验证**不是来源 —— 哪天量到 12px,该改的是别的东西, 不是把 8 调大。 修法:新增纯函数 `upwardGestureCanEscapeBottom()`,判据换成「**这一格手势在物理上有没有 可能产生一次离开底部**」。两处调用点(滚轮 `:2833`、触屏 `:2857`)换用它。 **撤实现验红两次**:换回旧判据 → 零位移那两条红;换成 `return false`(「永远不松手」的 假修法)→ **既有的**「滚轮往上拨一下就停手」「手指下拉也算停手」4 条红 —— 证明「用户 真的滑走仍要停止跟随」没有被牺牲,而且是被既有用例接住的。 **同类零位移场景走了一圈**:已在顶部继续上滚(顺手修了)、已在底部继续下滚(`deltaY>=0` 本来就 return)、`useThinkingFollow`(没挂 wheel/touch,纯位移驱动,无入口)。 **拖滚动条和键盘 PageUp/↑ 不经过这两个监听,没有零位移保护,也没人复现过 —— 留着没动。** ⚠️ **对取证面的副作用,先记一笔**:改前短会话里滚轮一响就 latch,后续流式增长**一次 `scrollTop` 写入都没有**;改后跟随保持,写入记录里会多出一串。而「是我们写回去的」vs 「合成器根本不动」正是靠这份记录区分的 —— 拿这个包复现冻结时,写入数量比旧包多**是本 提交造成的,不是新信号**。 ## 二、两个滚动冻结假设的 A/B 开关(默认全关 = 现状) 冻结还剩两个嫌疑,都改不了 CSS 就能测、必须动代码。做成运行时开关,**一个包测两个**: - `open-design:disable-chat-log-self-resize-observe` —— 摘掉「滚动盒观察自己」那条 ResizeObserver(回调里会写尾部占位块高度,构成自喂环;`origin/main` 没有这条) - `open-design:disable-msg-enter-animation` —— 停掉 `msg-enter` 入场动画(`fill: both` 会把 transform 永久留在正在增高的元素上) 照 `chat-scroll-takeover.ts` 的形状(`open-design:` 前缀 + 刷新生效 + 读不动当关), **没有另起机制**。只有字面量 `'1'` 算开。 **「默认行为不变」是证出来的,不是推的**:不设开关时滚动盒仍在观察者名单里且纯可视 高度变化仍能翻起状态;不设属性时 `.msg` 的 `animation` 仍解析成 `msg-enter … both` (喂整张展开后的 index.css,jsdom 真层叠,所以「后面某张表又盖回来」也照得出); `'true'`/`'on'`/`' 1'` 一律按关;两开关都关时 `install()` 在根节点上不留任何属性。 **打开各自会失去什么**(写在模块 docblock 里,真机 A/B 前必读): H2 → 「只改可视高度、不改内容高度」这类变化失去唯一通知源(输入框长高/软键盘/窗口竖向 缩放时不再重新贴底);内容驱动的跟随不受影响 —— 这也正是 `origin/main` 的行为。 H3 → 消息少了 200ms 淡入;且动画留下的单位 transform 消失后,`.msg` 不再是绝对定位后代 的包含块、也不再新建层叠上下文,消息内部若有错位**先怀疑这条**。 ⚠️ **滚动盒身上不止一个 ResizeObserver**:`QuoteBar.tsx` 也 observe 它。H2 只摘跟随那条, 所以开关打开后滚动盒**仍然被观察着** —— 若真机 A/B 显示冻结依旧,那条是下一个要看的。 * chore: 抬到 0.21.4 上一个包是 `0.21.2-beta.1`(0.21.3 那次在打包中途被我取消了 —— 当时刚发现取证信封会被 全局 4mb parser 静默截断,发一个取证传不回来的包没有意义)。 因为特性分支必须 `publish=false`,R2 的 beta metadata 永远不推进,而 `prepare-beta.ts` 在「仓库基线 > metadata 基线」时把 beta 序号固定为 1 —— 不抬 patch 位,这个包还会叫 `0.21.2-beta.1`,和已经发出去那个撞名。**每要一个可区分的包就得抬一次。** 永久解仍是把已经躺在打包 job 环境里的 `RELEASE_COMMIT` 接进诊断包 —— 分支 `chore/pack-build-commit` 已备好未合并;⚠️ 合之前要先补 `release-stable.yml` 那四个 build job 看不到该变量的缺口。 * docs(specs): 记 R-051 只止了血、未对齐设计稿 S10(产品拍板「先这样」) `0.21.4-beta.1` 里补的 `upstream_5xx` / `provider_high_demand` 映射,解决的是 「把 JSON 原文摊给用户」这个 P0,**用的是仓库已有的邻近文案,不是设计稿 S10 的文案**。 差四处 + 一个中间态:供应商名插值、「不是你的问题」、「已自动重试过」、〔更换模型〕 按钮,以及「自动重试 2 次都失败后才出卡、期间显示正在重试 1/2」。 完整对齐要新增 key + 19 个 locale + 供应商名的数据通路 + 接模型切换器的按钮 + 重试 计数中间态,体量远超止血本身。产品 2026-09-07 拍板先止血、对齐单独立项。 **这是对设计稿的有意偏离,记录在案以免后来人以为是漏做。** 按仓库规矩不另开 issue。 * fix(daemon): ACP 这条路从不问错误分类器,9 个 runtime 的上游报错全是裸的 `acp/session.ts` 的 `fail()` 只产出两种形态:JSON-RPC 分支给 `AGENT_EXECUTION_FAILED`, **其余所有 fail 点给裸 `{ message }` —— 连码都没有**。两条都从不调 `classifyAgentServiceFailure`,而 json-event-stream 路和 Claude 路都调。 影响面是全部 `streamFormat: 'acp-json-rpc'` 的 runtime:amr / devin / hermes / kilo / kimi / kiro / reasonix / trae-cli / vibe。 ## 先拍快照再改 44 条真实语料(现有测试 / `run-error-catalog.md` / 用户 `0.21.2-beta.1` 截图原文)冻结成 落点表,改前改后逐条 diff: - **分析轴(`failure_category` / `failure_detail` / `failure_stage` / `retryable` / `user_action`)44 条一条没动。** - 码轴动了 15 条:上游过载 → `UPSTREAM_UNAVAILABLE`;401/403 类 → `AGENT_AUTH_REQUIRED`; 429 / session limit → `RATE_LIMITED`。其中 A3 那条改前**连码都没有**。 - 没动的 29 条里有关键守卫:握手判决先盖章的两条仍是 `AGENT_CLI_SESSION_REFUSED`; `exit code 401` / `line 502` 这类噪音仍被 `STATUS_CTX` 挡住;16 条分类器返回 null 的 保持今天行为。 `evidence_level` 有 13 条从 `legacy_text` 变成 `structured_code` —— 不是巧合: `run-failure-classification.ts` 里那三个 structured-code 分支**对 ACP 一直是死代码** (码从来没送到),喂进码后才激活,而它们当初就是照着「和文本分支同判决、只差 evidenceLevel」写的。 ⚠️ **唯一一处码/detail 分歧变大的**:`insufficient balance` 码变 `RATE_LIMITED`, detail 仍是 `amr_insufficient_balance`。缓解:① AMR 自己走不到(账号分支在更前面拦截); ② 另外 8 个 runtime 的卡面会从「原文兜底」变成限流卡;③ **json-event-stream 和 Claude 两条路今天就是这个行为** —— 这是在向已评审的既有行为对齐,不是新增一类错判。 只放 `UPSTREAM_UNAVAILABLE` 一档等于自造分类器里没有的规则,故未采纳。 ## 落点与取舍 改在 `server.ts` 的 ACP 专属 `send('error')` 桥接处,**不改 `session.ts`** —— 桥接处是唯一同时喂 SSE 和 `run.error` 的点(那段 docblock 自己写着这是「两个面能一起 纠正的最后一处」),也是唯一拿得到 runtime 身份的地方,还天然避开 AMR 账号分支和 resume 抑制分支。 **刻意不读 stdout/stderr tail**(json-event-stream 路读):ACP runtime 的 stderr 是它自己 的日志(vela 会打请求 URL、provider 名、重试),拿那个决定用户看哪张卡风险太大。只看 agent 写进 JSON-RPC error frame 的东西。 分类失败时**按引用原样返回**(测试用 `toBe` 而非 `toEqual` 钉住),完全退回今天的行为; 覆盖非对象 / null / 空白 message / 循环引用 details / 已有具体码五种情形。 `message` / `retryable` / `details` 一个字节没碰,`run.error` 前后完全一致。 ## 顺带发现(未改) **ACP 的协议违规被判成「去登录」**:`session.ts:1329` 拼的 `invalid session/new response: …` 含子串 `invalid session`,而 `integrations/vela-errors.ts:183` 把它当 AMR 鉴权失败。纯协议问题 → 用户被告知去登录。 实测最小触发就是字面量 `invalid session`(单空格)。本次改动对它无影响(冻结表 C5 钉住)。 * fix(chat): 找所有者充值弹窗整个是透明的,而且还压不住输入框(OPEND-2722) 两个独立缺陷叠在同一张弹窗上,合起来的效果就是工单说的「未正常弹出」。 ## 一、缺主题接缝 —— 弹窗画不出来 `AmrOwnerTopUpDialog` 用 `createPortal` 挂到 `<body>`,但没带 `--chat-*` 接缝。 CSS 自定义属性按 **DOM 树**继承,而 `--chat-*` 只在 `[data-chat-root]` 子树里定义 (`ChatRoot.module.css` 的 `.vars`/`.root`)—— portal 出去就脱离了那棵子树,遮罩、卡片 底色、投影、圆角**全部解析失败**,文字裸浮在页面上。**它不报错**,现有按 testid 断言 存在性的测试照样绿。 同一个坑 2026-08-27 已在 `SupportDialog` 踩过并修好。修法**逐字照抄**它(`chatSeam()`, 同样三行、同样形状),没有另起机制。`inline` 仍跳过接缝,免得陈列页多出第二个 `data-chat-root`。两个调用点(`ProjectView` / `EntryShell`)都走 portal 形态,由组件层 一并覆盖,并有测试钉住。 ## 二、`z-index: 40` —— 画出来了也压不住 同屏实测:输入框固定层 45(`chat.css:1395`)、同侪模态 `.staged-preview-modal` 1200 (`chat.css:2964`)、右上角那簇真机量到 150。`SupportDialog` 当初就为此抬到 **1500**, **这张从没抬过**,而守它的测试只守 `SupportDialog` 一个文件。 抬到 1500,和同族对齐 —— 依据是 `SupportDialog` 自己的注释:1500 是为了压过同侪模态, 不是「刚好够用」的数。 守护测试一般化成 `chat-overlay-layer.test.ts`,覆盖整个 `components/chat/`, **判据写死为三条同时满足**:`position: fixed` + 覆盖全视口 + **画了背景**。第三条是关键 —— 全屏但透明的层是点击捕获器,职责就是待在东西**下面**(如 `composer-toolbox-standalone-backdrop` z=89);画了背景的才做出「我后面的东西都被压暗且失效」这个承诺,而被打破的正是它。 被排除的每一个(QuoteBar、QuotedRefs、PlanPill、AnchoredMenuShell、UpgradeCard 的 `::after`、两个 `overlayInline`)都单独钉了测试,**判据将来放宽会当场变红**。 一般化是**证明**过的:往目录里丢一个低 z-index 的假弹窗,扫描当场发现、红 5 条,再删掉验残留。 原 `support-dialog-layer.test.ts` 已被完全涵盖,删除。 ## 量法自检(这两条都踩过同一个坑) ⚠️ **jsdom 不做 `var()` 替换** —— 量背景色是**假绿**判据:变量解析对了它照样报 `rgba(0,0,0,0)`。接缝那条因此改成量「每一枚被消费的 `--chat-*` 是否解析出非空值」+ 排版基线 `font-weight`,并**显式断言背景色是不可用判据**;层级那条则只读 CSS 文本里的 字面整数,不碰任何 `var()` 依赖的计算值。两条都先证明尺子在**修复前**能读出错误值。 ## 全仓 portal 普查(干净的否定结果) 48 个 `createPortal` 逐个对照 14 个消费 `--chat-*` 的 CSS Module 和 20 个全局类: **只有 4 个 portal 带 `--chat-*` 内容,现在四个都有接缝**(composer 固定层、QuoteBar、 SupportDialog、本次)。其余用的是全局 `:root` token,portal 出去仍然有效。 ## 关联工单的结论(未改其状态) - **OPEND-2720「点击升级无法跳转 Pricing」不是缺陷**:非 owner 本就不外跳 (`ProjectView.tsx:2991-3009` 注释逐字:「任何非 owner → 不外跳:账单动作 B 会拒」, 与 §6.V 一致);owner 现在也不跳公开 Pricing 了(T54 改成 console 套餐页)。 报告人看到的「点了没反应」**正是本条缺陷** —— 弹窗弹了,只是透明的。 - **OPEND-2719 一半是本条,一半是有意设计**:入队是故意的 (`ProjectView.tsx:8175-8195` 停的是**完整载荷**,因为输入框已清空,只恢复文本会静默 丢掉暂存附件 —— 注释写着),不能拿掉;「没弹窗」那一半随本条修复消失。 ## 顺带发现(未改,需单独决定) `packages/components/src/dialog.module.css:11` —— **共享 `Dialog` 原语的遮罩是 `z-index: 100`**,同样低于 150。它有 **14 个消费者**,其中一个是 `AmrBalanceDialog` —— 本家族里给有账单权限的人看的那张。同样的缺陷低一层,但抬共享原语是 14 个消费者的决定。 另有 5 个画了背景、全屏、低于 150 的遮罩(插件市场 60、首页确认 80、自动化 80、 插件导入 100、连接器抽屉 100)。 * fix(chat): 三处让聊天区「看起来坏了」的缺陷(原文泄漏 / 导轨死区 / 进会话钉顶) 三件事共用 `ChatPane.tsx` 顶部同一个 import 块,拆开会留下「导入了但没用」的中间提交 (`noUnusedLocals` 会红),所以合在一条。下面分开说。 ## 一、用户可见面永远不再显示原始错误文本 报错卡的描述位原本在没有映射命中时**回落到 `rawError`** —— 也就是把 daemon 传来的原始 错误摊给用户。刚补过一行映射(`ca8e255550`),但**只要这条回落还在,映射表补得再全总有 漏网的**,而已知还有 22 个 failure_detail 没有映射行。 **判据从「落到哪条分支」换成「这段字是谁写的」**:任何来自一次 run 的字符串一律走兜底句, 新增 `resolveRunErrorCardDescription()`(`amr-guidance.ts`)。这样映射补不补都不影响, 结构上堵死。 摸底结论:`rawError` 共 5 个消费点,**只有 1 个会到用户可见面**(描述位);标题位永远是 key;逐条消息的红色 error 药丸和「错误详情」折叠 08-27 就已下线。 **文案一个字没新写**,复用 08-27 已有的 `chat.runError.fallbackMessage`,19 个 locale 现成。 **原文仍然可达**:`appendAssistantErrorEvent` 照落 `events.jsonl`,〔导出日志〕照打包 (`diagnostics-export.ts:95`),测试钉住每张卡都带导出与联系支持。 ⚠️ **界线写进了注释**:藏的是 **JSON-RPC 传输信封**(事件 id、`sessionID`、`properties`、 本机端口与项目路径),**不是失败本身** —— 卡照出、标题照说、按钮照给。 ⚠️ 一条既有断言被**故意翻面**并写了原因:旧判据是「来源是不是这一轮」,来源指向别的助手就 原样放行;而那个槽只有两种来源,`setRunError(err.message,…)` 装的**永远是原文**。判据收敛 成「有没有来源助手」后这条必然翻面。它原本要保护的东西(来源为空 = 我们自己写的人话)由 同文件上一条用例继续钉着。 ## 二、右边缘 20px 的隐形死区吃掉滚轮 `ChatMessageRail` 是一条 **20px 宽、整个 log viewport 高**的绝对定位覆盖层。指针落在上面时 滚轮对聊天记录**完全无效,上下两个方向都死**。而 `.chat-log` **故意没有滚动条** (`scrollbar-width: none` + `::-webkit-scrollbar{display:none}`,注释明说导轨就是替代品), 所以用户按肌肉记忆把指针停在右边缘,正好落进死区,屏幕上没有任何线索。出现条件只是 「≥2 条用户消息」。 ⚠️ **根因不是 `overscroll-behavior: contain`** —— 反证过:改成 `auto`、track 滚到底再发 滚轮,日志仍然纹丝不动。真正原因是结构性的:**导轨是 `.chat-log` 的兄弟节点,不是祖先**, 而 Chromium 沿祖先链找滚动容器。那条 CSS 是红鲱鱼,未动。 修法:新增纯模块 `rail-wheel.ts`,track 在**这个方向**上还有余量就先吃,**剩下的转发给日志**。 短对话(track 不可滚)全额转发,长对话 track 先吃、到底后余量转发 —— 比今天更符合直觉 (今天到底后那部分滚轮凭空消失)。 ⚠️ **React 18 把 `wheel` 注册成 passive**,`onWheel` 里 `preventDefault()` 是空操作,而取消 默认正是整个机制(track 是 `overflow-y:auto`,不取消就「原生滚 + 手动滚」双份位移)。 所以改用 ref 挂原生监听 + 显式 `{ passive: false }`。**这一条验成了行为而非断言**:jsdom 同样 强制 passive,把常量翻成 `true` 会让 `defaultPrevented` 变假、测试真的变红。 另需 `railWheelDeltaPx` 归一化 `deltaMode` —— 因为现在是我们自己写 `scrollTop`, Firefox 某些平台一格是 `deltaY: 3`,不归一化就是每格 3px。 顺带修 `is-retracted`:原本只改 `opacity`,于是点完跳转后**看不见却照样吃悬停和点击**。 nav 和 track 都要关(`pointer-events` 虽可继承,但 track 自己声明了 `auto`,只关 nav 不够 —— 有专门的撤实现验红证明这条不冗余)。⚠️ **解除不靠 `mouseleave`**:元素刚被设成 `none` 会不会 补发一次,规范没要求、各引擎不一;赌错就是**导轨永久卡在收起态、再也不亮**,比原缺陷更糟。 改成收起期间用 `document` 的 `pointermove` 自己判指针是否离开 nav 矩形。 ## 三、进会话有时停在顶部(用户 2026-09-07:「有时候在最底下有时候在最顶部」) ⚠️ **不是本分支引入的。** 用 144 种进会话时序的差分矩阵跑了三遍:撤掉缺陷三的守卫、 甚至撤掉本 range 内 ChatPane 的**全部四处**改动,**最终落点逐行相同**(只有写入次数不同)。 所以头号嫌疑 `isPinnedToLogBottom` 与 `upwardGestureCanEscapeBottom` **都不是真凶** —— 旧写法写的是 `el.scrollHeight`、当场被夹到 `maxTop`,新守卫跳过的正是那些**写了也不动**的帧。 **真凶**:`settledTailUserIdRef` 的三档语义里 `null` 是一句**结论** ——「这条会话我看过了, 没有用户消息」。而 `ProjectView` 在会话 id 一到手就挂 `ChatPane`(`:12914`),转录随后异步取, **id 和转录永远不可能同一 commit 到达**。那几拍里空转录被落定成 `null`,下一拍历史一次到齐, `isNewTailUserTurn(null, 尾条id)` 为真 → **一份刚读进来的旧历史被判成用户刚发的新一轮** → `releaseFollow()` + 钉顶接管。 「有时底有时顶」由此解释:钉顶那一帧若排版已落定,停在最后一条用户消息上沿(最后一轮回复短时 看着就像贴底);未落定则矩形全挤在 0,量成 0 → 停在最顶。 修法:新增 `transcriptSpeaksForConversation()`(`anchor-to-top.ts`),用同一把闸同时管 「落定」和「表决」。**没有碰跟随的写入判据**,缺陷三的修复原样保留。 同一条规矩仓库里已写过一遍 —— `conversationMessageCount` 的注释:转录没落到这条会话头上时 `messages.length` 不作数(那边症状是列表里的幻影「0 msg」)。同一个错误的另一个出口。 ⚠️ **「为什么 0.21.2 上没有」没有证出来** —— 它不在 `0.21.2 → 0.21.4` 的 diff 里。推测是本 range 内某处(观测限流放开、思考区滚动条改成占布局、问卷 `interactive` 判据放宽)动了排版 时机,把一个**本来就存在**的竞态从「多数落在底部」推到了「多数落在顶部」。**只有真机能确认。** ⚠️ **留下一个已知行为变化**:转录读取窗口内发出的那一轮,钉顶会被推迟;若那一轮恰好是该 mount 的第一次落定(首页发起 + 创建会话后立刻发送的竞态),这一轮会不钉顶、退回贴底跟随。 **请在真机上过一遍「首页发起第一轮」。** ## 时序夹具的共同要求 三条里有两条的红测都必须**能表达「判定发生在排版之前」**:几何分两拍喂,挂载时 `contentHeight === clientHeight`(对应真机 583/589)、矩形全在 0,`settleLayout()` 之后才给 终态。**一上来就给终态几何的夹具照不出这类缺陷。** ## 只有真机能确认 指针物理停在那 20px 上时 `wheel` 的 target 是不是导轨(jsdom 无命中测试,这正是症状本身); `pointer-events: none` 之后点击/悬停是否落到底下的流水;双份位移是否真的消失; 滚轮接管替换掉浏览器平滑动画后的手感;以及上面点名的「首页发起第一轮」。 * chore: 抬到 0.21.6 跳过 0.21.5 —— 那个号被实验分支 `exp/disable-overscroll-features` 占了(滚动冻结 A/B 的 处理组包 `0.21.5-beta.1`),避免同名不同物。 本包相对 `0.21.4-beta.1` 新增:ACP 错误分类接线、找所有者充值弹窗的主题接缝与层级、 以及三处聊天区缺陷(原文泄漏堵死 / 导轨 20px 死区 / 进会话钉顶)。 ⚠️ 「进会话钉顶」经差分矩阵证实**不是本分支引入的**(撤掉本 range 内 ChatPane 全部四处 改动,落点逐行相同),但它在 `0.21.4-beta.1` 上可见,本包修掉。 * docs(specs): 余额族归交付稿管,不跟新文案文档(T60);并更正 admin 角色那条错误事实 产品 2026-09-07 裁决:余额 / 额度 / 充值 / 升级这一族的权威源是**交付稿组件 18**, 不是 09-06 那份《报错文案|精简版》。原话「不能用以前那种白色报错卡……余额的一律对齐 这两个视觉稿卡片」。 ✅ 核实:我们当前就是**逐字一致**的(`chat.upgrade.balance` / `whyLow` / `whyOut`), 这一族无需改动。新文案文档给这一族的是通用话术并删掉了 `{balance}` 具体数字, 相对交付稿是倒退。 同时重新确认 2026-09-02「不存在第二张白色通用报错卡」继续有效 —— 新稿给这一格写了 标题+正文,而接棒的升级卡没有标题行(T59),没有地方放也不该放。 ⚠️ **更正一条我先前给出的错误事实**:`admin` 角色**是存在的**(`CollabMemberRole` 三档,全仓在用),我先前说「代码里没有 admin」是把余额弹窗按账单权限分的两档误当成了 角色轴。 但 admin **确实不能充值**,且是后端强制:我们 `collab.ts:480` 与 vela `packages/shared/src/workspace-context.ts:268` **逐字相同**(`readable && isOwner`), vela 服务端还在 `billing/http/routes.ts:1765` 拦。而且是有意的 —— 同处 `canManageMembers` / `canInviteMembers` / `canManageSharedResources` 全含 admin, **只有账单与自动充值排除它**。所以新稿「请联系团队管理员充值」会把用户指向一个 充不了值的人,保持 T56 的「所有者」。 * docs(specs): 升级卡改成按轮次锚定的存档件,不再是当前余额的实时读数(T61) 产品 2026-09-07 裁决:软档那张升级卡 ① 只在一轮结束后出现、运行中不出现; ② 出现后锚定在那一轮下面,第二轮运行期间不许挪走;③ 第二轮结束仍不足则另出一张; ④ 值是那一刻的存档,不随后续余额改写。 原话:「它就好像历史记录一样,存档在当时状态了,不能说我干个啥把当时的失败态搞丢了, 我往回看那一轮为啥失败了根本没有依据和想不起来啊」。 **这是语义变更**:卡片从「当前余额的实时读数」变成「这一轮为什么停下来的凭据」。 记在案是为了防后来人把「不随余额刷新」当成 bug 修回去。 ⚠️ 与 2026-09-02「升级卡各只有一张」不矛盾 —— 那条管的是同一时刻同一档不要两块 UI, 本条管的是不同轮次各自一张,两者正交。 ⚠️ 第 ④ 条体量未定:若卡片今天派生自实时钱包状态,刷新后要么消失要么显示当前余额, 两种都不满足「凭据」;真正存档可能要落进会话记录,跨 web/daemon/contracts。 ①②③ 是纯渲染层先做,④ 视调研结论单独立项。 * docs(specs): 文案对齐的三处口径——S21 保三态、S17 放放、S29 插值槽不改(T62–T64) 产品 2026-09-07 逐条裁决: - **T62 S21 保持三态**:文档一格 vs 我们三个可分辨状态(伪造角色标记 / 空输出 / 工具死循环)。抄同一句等于悄悄合并三个状态,是产品设计不是文案活。已落地: 只对最字面对应的 `outputInvalid` 采用新文案,另两个标题不动。 - **T63 S17 先放放**:我们没有这个状态,那两个 key 是死键、无任何映射引用; 真实 401/403 走的是 S04 那张卡。S17 是否 ≡ S04 留给产品。连带「被移出团队」 「客户端版本过旧」也没有分流(vela-errors 只有 3 个码,全仓不读 HTTP 状态码)。 - **T64 S29 插值槽不改**:标题已按新稿改,正文不采用——组件 22 是一行状态不是卡, 没有正文槽,要新增两个插值槽 + 改结构。计数今天已贯通到 UI,只是形态是一行。 另:S01/S02/S04 标题判定为「无待拍板项,只缺接线」(产品文案已给全,我们缺的是 标题不传插值 + `title.signInRequired` 一键服务两格要拆),已派单实施,不在此表。 * feat(chat): 报错文案按产品新稿对齐第 1 批,并给标题接上插值 权威源:产品《Open Design 报错文案|精简版》(飞书 `docx/S1Ucd1frUo7opCxGLbRcj3XTnvh`, 最后修改 2026-09-06)。抬头自陈「原文中的按钮仅供核对」,**所以本次范围只有标题和正文**。 ## 第 1 批:20 个 key × 19 语种 = 380 条 zh-CN 逐字对齐产品原文(全角标点已逐条核过),其余 18 个是它的忠实翻译。 覆盖 S09 限流、S10 上游不可用、S11 连接中断、S18 账号停用、S19 智能体退出、 S20 内容过长、S21 输出异常、S01/S02/S04 正文、S29 重连、S31 检查更新失败。 ## 给标题接上插值(这一批唯一的代码改动) `ChatPane.tsx` 原来是裸的 `t(runFailureUi.titleKey)`,**不传变量** —— 新稿 S01 标题要 「未检测到 {智能体}」,照抄会把**字面的 `{agent}` 摆到用户脸上**。 新增 `runFailureCopyVars`,标题(`:4150`)和正文(`:2242`)**同吃一份**。 刻意不给标题另造 `titleVars`:两处名的是同一条失败、同一个 agent,各取各的只会给 「标题说 Claude、正文说 Codex」留一道缝。`RunFailureUi` 一个字段没加。 ## 拆 `title.signInRequired` 一个键原本同时服务 S02(本地 agent,新稿要「{智能体} 尚未登录」)和 S04(Cloud, 新稿要「Open Design 尚未登录」),装不下两句话。拆成 `.other` / `.amr`, **旧键从 union、`types.ts`、19 个 locale 彻底删除** —— 不留「落到随便哪一边」的可能, 并有源码级守卫钉着。三个调用点各自落位:amr → `.amr`;antigravity(终端登录)→ `.other`; 通用兜底 → `.other`。 ## ⚠️ 一条自己揪出来的假绿 原本用「S02 的标题 ≠ S04 的标题」证明拆键成功。撤实现验红时发现:**AMR 的 agent 标签 恰好就是 `OpenDesign`**,共用一个键时两句仍然不相等,那条断言照样绿。补了 `expect(s04).not.toContain('OpenDesign')` 才真能红。 ## 按裁决跳过的(T60 / T62 / T63 / T64) - **余额族整族不动**(T60):权威源是交付稿组件 18,`chat.upgrade.*` 已逐字一致; 新稿给这一族的是通用话术且删掉了 `{balance}`,相对交付稿是倒退。 - **S21 保持三态**(T62):文档一格 vs 我们三个可分辨状态,抄同一句等于悄悄合并。 - **S17 先放放**(T63):我们没有这个状态,那两个键是死键,真实 401/403 走 S04 那条路。 - **S29 正文不改**(T64):组件 22 是一行状态不是卡,没有正文槽。 - **S12「已有 {N} 秒」不采纳**:与产品 2026-09-04「因重复而收掉思考区计时」冲突。 - **S07 `{retryAt}`** 保留:新稿删掉它会让 `formatModelWindowRetryAt` 整条解析链变成死码。 ## 被更新的既有用例 7 个文件。其中 `run-error-ladder.test.ts` 那条「S19 每个语种都保留 `{agent}` 插值位」 是钉**旧稿**的,新稿没有插值位 → **反转成「都不带插值位」**,守的是「半退回的旧译文 偷偷把槽加回来」,手法与 `cliSessionRefusedMessage` 的 `{version}` 守卫一致。 `mirror-exec.html` 只含本次那 3 行(重生成会带进约 145 行无关 CSS 漂移,已剔除; 手工版与重生成版的文案字符串计数完全一致,已核)。 * fix(ui): 六个全屏遮罩压不住同屏 chrome,共享弹窗原语也在其中 产品裁决 2026-09-07:「那都修掉」。 ## 缺陷 画了背景的全屏遮罩做出的承诺是「我后面的东西都被压暗且失效」。这六个都压不住: | 文件 | 选择器 | 改前 | 改后 | |---|---|---|---| | `packages/components/src/dialog.module.css` | `.backdrop` | 100 | **1500** | | `styles/home/plugin-marketplace-demo.css` | `.plugin-marketplace__modal-backdrop` | 60 | 1000 | | `styles/home/home-hero.css` | `.home-hero-confirm__backdrop` | 80 | 1000 | | `styles/home/tasks.css` | `.automation-modal-backdrop` | 80 | 1000 | | `styles/home/plugins-view.css` | `.plugins-import-modal__backdrop` | 100 | 1000 | | `styles/workspace/drawer.css` | `.connector-drawer-backdrop` | 100 | 1000 | 同屏参照:输入框固定层 45、`.workspace-tabs-chrome` 120、右上角那簇真机量到 150、 同侪模态 `.staged-preview-modal` 1200。 ## 分档不是拍的 普查了全应用 36 个全屏遮罩,发现**应用里本来就有一档「应用模态」= 1000** (`BrandPickerModal` / `FigmaImportModal` / `LibraryPreviewModal` / `LibraryUploadModal` / `NewBrandModal` 五个 `.backdrop` 都在这一档)。五个全局遮罩放进**已有的档**, 它们各自独占一屏,没有谁需要压过另一张模态,1200/1500 是越权。 **共享原语是唯一的例外,1500 是推导出来的**: - 它没有自己的屏 —— 从 `EntryShell` 和 `ProjectView` 两处渲染,而且**会嵌在别的模态里**: `NewProjectPanel` 的删除确认活在 `NewProjectModal` 的遮罩(920)里,100 时**那个确认框的 遮罩画在了它自己的父弹窗底下**。 - 数值来自同一个闸门的另一条腿:`AmrOwnerTopUpDialog` 与 `AmrBalanceDialog` 是**同一个 三元表达式的两条腿**(同一个余额闸门,只按账单权限分叉),owner 那条已在 1500。 **一个闸门的两条腿不能差两档**,否则同一个时刻会因为你是谁而长得不一样。 `Dialog` 的消费者实测是 **17 个**(不是 14 —— 三个用多行 import,单行 grep 漏了): 13 个吃原语的遮罩、3 个自己往上escalate(1700/1700/1800,未动)、4 个用 `includeChromeClassName={false}` 完全不吃。**没有任何一个是故意压低的。** ## 守护测试 新增 `tests/styles/app-scrim-layer.test.ts`,按目录与 `components/chat/` 那条分工 (各自断言扫描到零个对方的文件,谁将来放宽范围都会红)。 判据四条同时满足:`position: fixed` + 覆盖全视口 + **画了背景** + **不是 `pointer-events: none`**。第三条把「点击捕获器」排除掉(全屏但透明的层职责就是待在 东西**下面**);第四条是本条新增 —— 我的范围够得着 `app-wash.css` 的 `body::before`, 它满足前三条却是 z −1 的装饰,没有它守护测试首轮会要求把整个应用背景抬到 150 以上。 尺子**只读 CSS 文本里的字面整数** —— jsdom 不做 `var()` 替换也不实现层叠, `getComputedStyle` 在这里会空转假绿。夹具先证明它读得回 60/80/100 这些**改前的真值**。 改前 13 条红(6 个缺陷 × 地板 + 分档,加同闸门两腿同档),**其余 30 个遮罩全绿** —— 尺子会区分,不是见谁红谁。逐个还原逐个验红,每次只红它自己那几条。 ## 未动,已记录 `.connectors-panel-embedded .connector-drawer-backdrop`(z 6,`position: absolute`)是 **故意压低**的,注释写着「so it does not stack above the settings modal」。它是**另一个 选择器**,抬基线够不着它;已在守护测试里钉成有意排除。 `.project-search-backdrop`(400)在地板之上,按判据不算缺陷,但它是 920 档以下唯一 剩下的遮罩,记在这里。 * fix(chat): 余额卡被压扁 / 卡改成按轮次存档 / 撤掉等待文案 / 两个白开销类名 ⚠️ **前置说明**:这几件都要改 `ChatPane.tsx`,而那个文件的 hunk 已随上一条提交 `611ab085f7`(文案对齐)一起进去了 —— 那条消息只描述了文案。本条描述的是同一个文件 里其余几件的意图。**看 `ChatPane.tsx` 的改动请连着这两条一起读。** ## 一、余额卡只显示一半高度(QA 2026-09-07) **根因不是底部让位不够,是 flex 把它当成了泄压阀。** `.chat-log` 是定高的列向 flex 滚动容器。flex 项默认 `flex-shrink: 1`,内容超出时浏览器 **先压扁子项、压不动才溢出**。绝大多数行压不动 —— 它们 `overflow: visible`, `min-height: auto` 解析成内容撑出的最小高度(flexbox §4.5 自动最小尺寸)。 但升级卡为了裁住右上角辉光和圆角写了 `overflow: hidden`,而**非 visible 的溢出会让自动 最小尺寸变成 0** —— 它成了整根流水上唯一压得动的那一项,一个人吃下全部负空间, 收缩到只剩自己的内距,再被自己那句 `overflow: hidden` 把下半截裁掉。 真实浏览器实测(已滚到最底):卡自然高 111.5px → 实际画出 **32px**,裁掉 79.5px; 加上修复后画出 111.5px,`scrollHeight` 918 → 998,**正好把被吃掉的 80px 还回来**。 三种可能都排掉了,有数为证:**不是 padding 不够**(20px 让位一分不少在那儿); **不是被钉住**(它是流水的直接子元素,符合 T51);**不是滚动冻结** (`scrollTop` 恰好等于 `maxScrollTop`,滚到头了还是半截)。 修法 `.chat-log > * { flex-shrink: 0 }` —— **归容器不归卡**:「会被压扁」不是余额卡的 毛病,是任何在这根流水里裁自己溢出的东西都会中的招,而圆角卡本来就该裁。补在卡上 只救这一张,下一个写 `overflow: hidden` 的还会中,而且照样长得像那张卡自己的渲染 bug。 `.chat-log-tail-spacer` 和 `.chat-virtual-spacer` 早就各自写了 `flex: 0 0 auto` —— 这条只是把它们各自的补丁提成整根流水的契约。 **52px 那笔账一个字没动**:底部让位只为**浮在流水上方**的东西算(药丸与 Jump 共用同一个 绝对定位浮层位),余额卡是流水里的一行,不需要让位。四种组合实测过,卡在不在**不改变 底部空间**;`w95-reserve-vs-anchor-spacer` 钉的「地板 + 顶补恒定」未受影响。 ## 二、升级卡改成按轮次锚定的存档件(T61 ①②③) 产品原话:「它就好像历史记录一样,存档在当时状态了,不能说我干个啥把当时的失败态搞丢了」。 改前:读数写在一个与轮次无关的 state 上,渲染在 `<ChatRows>` **之后**的尾部子节点 —— 所以**运行中就会出现**,而且新一轮的消息追加在它上面、卡跟着往下跑,永远只有一张。 改后:读数带上锚点(`amrBalanceCard = { balanceUsd, anchorMessageId }` **合并成一条 state, 让「只写一半」在语法上不成立**);账本 `lowBalanceTurnCardsRef` **只增不删**,锚点那一轮 进终态才记账;卡画在那条助手消息**紧下面**,位置由 DOM 顺序保证,不做位置计算。 「一轮结束」= daemon 三个终态 `succeeded`/`failed`/`canceled`,**外加**「`runStatus` 缺席 但已落 `endedAt`」(非 daemon 模式建消息时 `runStatus` 本来就是 undefined),与 `runtime/todos.ts` 同一条。**失败和被停止都算结束** —— 那恰恰是最该留凭据的两种。 ⚠️ **一条推翻既有设计的判断,已报备**:「余额恢复后旧卡还留不留」不在产品给的四条里。 按「不能把当时的失败态搞丢」做成**留着**,这推翻了原注释「上一轮缺钱、这一轮跑通了, 卡就该下去」。 ⚠️ **T61 第 ④ 条(存档)未做**:刷新后「跑到一半死在钱上」那张卡的数字仍是**现查的当前 余额**(失败事件不带余额)。充值后回看会写成「$20.00 / 余额可能撑不完下一个任务」, 数字是今天的、句子是当时的。已单独立项。 ⚠️ 删掉了一条自己写的假绿用例(「放行时锚点也一起撤掉」撤实现仍绿),测试文件里留了 说明。 ## 三、撤掉「等待首批输出中」,继续显示「思考中」 产品口述 2026-09-07。**这是同一条设计原则被撤的第二次** —— 稿子 `error-ux-design.md:21` 第 3 条「等待要有回音」,它的第一次实现 S12「已等 411 秒」 在 2026-08-27 就被撤过(原话:「改回进行中…但背后的探测逻辑先保留,后续可能会用到, 只不过用别的展现形式」)。本次照 S12 的既有形状:**撤文案、留探测、留钉子**。 ⚠️ **状态一行没删**。`waitingForFirstOutput` 才是那一行存在的原因:ACP 那一家 (vela/devin/hermes…)在首个 token 前**一条会落行的事件都不发**,删了它屏幕会退回 **完全空白**,而不是少一句话。只删了已无读者的显示层管线。 i18n key 留着退回死键:产品说的是换展现形式不是不再发生;而且 `locales.test.ts` 有一条 专钉它翻译质量的用例(土耳其语曾写成「等待首批**输入**」,正好写反)——**留着 key 才 留得住那条测试**。 ## 四、两个零消费的类名 `is-scrollable` / `is-scrolling` 在**全仓 CSS 里没有任何规则选中**,却各自驱动一次 setState:后者由 650ms 空闲定时器管,**每次滚动手势开始一次、停手后再一次**,各触发 一次重渲染 + 一次属性改动,零渲染收益。 `is-scrolling` 整条删除;`is-scrollable` 按产品意见**改成常驻写死**(`.chat-log` 从创建 起就是滚动容器,「是不是滚动容器」不随内容变;原来那个类表达的其实是「此刻有没有超出 视口」,名字与语义对不上)。 ⚠️ **第四个产地是 grep 找不到的**:ResizeObserver 回调里**内联**了同一段逻辑(没有调 `syncScrollable`)。那才是 H2 那条 A/B 测试真正的出口。顺带收益:那段内联逻辑**在每次 resize 通知里同步读 `scrollHeight`/`clientHeight`**,即在回调内部强制 layout;现在读全部 推迟进 `scheduleFollowSync` 的 rAF —— 对 H2 怀疑的那个「自喂环」本身也是减负。 ⚠️ **换掉观测点是这件事的前提,不是附带**:那两个类是 H2 A/B 测试唯一的 DOM 出口, 直接删会把我们调查滚动冻结的一只眼睛弄瞎。新观测点改成直接量 `.chat-log` 的 `scrollTop`(RO 回调唯一且直接的产物),**零生产代码新增**,并先证明这只新眼睛看得见 缺陷(在未改动的代码上强制摘掉自观察 → 变红)才动的刀。开/关两边结论与改前逐条一致。 ## 决策记录 T60(余额族归交付稿)、T61(按轮次存档)、T62–T64(S21 保三态 / S17 放放 / S29 不改), 以及 `run-error-catalog.md` §6.Z(R-051 只止血未对齐 S10)。 * fix(chat): 那一轮的余额存进它自己的失败事件,不再每次现查(T61 ④) ⚠️ **本条同时补齐 `f31c9b9b07`** —— 那条提交在这项工作在飞时把它的一半扫了进去 (带走了红测与 ProjectView 的新函数,没带走 contracts 字段声明与 effect 接线), 导致 HEAD 自己编译不过(5 条 `Property 'amrBalanceUsd' does not exist`)。 **补齐前不要拿 `f31c9b9b07` 单独出包。** ## 缺陷 「跑到一半死在钱上」那一轮,刷新之后卡还在,**但数字是「现在的余额」** —— `ProjectView` 那个 effect 只要失败消息 id 有值就现查钱包,而刷新后这个 id 是从**已落库 的消息**重新算出来的。 充完值再回看那一轮:「剩余额度 $20.00 / 余额可能撑不完下一个任务」—— **数字是今天的、 句子是当时的**,两边对不上,比卡直接消失更误导。 产品要的是凭据(T61):「它就好像历史记录一样,存档在当时状态了」。 ## 修法:写进那条已落库的失败事件(方案 a) `PersistedAgentEvent` 的 `status` 成员加 `amrBalanceUsd?: number`。事件存的是自由 JSON blob,daemon 对 status 事件**按引用原样 push、不做字段校验**,web 读回时裸 cast —— 不用数据库迁移。 **先例就在同一个函数里**:`chat-events.ts` 的 `appendErrorStatusEvent` 本来就会把后到的 事实(`code` / `failureCategory`)合并进同一条 error 事件。客户端补写派生数据不是新惯例。 ## 为什么不是「让 daemon 判定时发出来」(方案 b) 我原本认为 b 更干净(数据在产生它的一侧被记录)。**这个判断在这个仓库里不成立**: 余额**不是 daemon 在失败时手上就有的东西** —— 它同样要去读一次,而且读的是 web 已经 钉好身份的那个钱包(团队 vs 个人,`amr-balance-gate.ts` 那段注释是拿真账号打出来的)。 代价对比:b 要改约 6 处、跨 2 个 app,因为 **daemon 自己的 SSE→落库映射是白名单** (`chat-run-messages.ts:460` 只留 `kind`/`label`/`detail`),多发一个字段不会自动落库, 要在 daemon 和 web 两处各开一次口;还要在一条**已经在失败的路**上新增一次网络往返和 一个「读不到怎么办」的失败态。**换来的覆盖面完全一样。** ## 落地要点 - `amrInsufficientBalanceFailure` 一次返回 `{ messageId, archivedBalanceUsd }`,两者读**同一条 事件** —— 不给「id 取这条、数字取那条」留缝。 - `stampAmrBalanceUsdOnFailure` **就地改那一条,长度不变,不碰 `runStatus`/`endedAt`, 已有数字就原样返回同一引用** —— 三条都是为了活过 `mergeMessageWriteForDaemonBacked`(按数组长度判缩短、按终态判回退)。 - effect 加「有存档就不问钱包」分支。顺带**减少**一次钱包读:重开历史失败会话不再查。 ## 尺子自检 + 撤实现验红 修复前基线跑,尺子读出的正是缺陷本身:`expected '20' to be '0.35'`。 第三条红测的 `[undefined, undefined]` 额外说明:那条失败消息**本来就被 PUT 了两次**, 写路是活的,缺的只是字段。 另有一条**反向对照**修复前后都绿(「没存档过的那一轮照旧现查」)—— 有它才能说 「存档过就不查」是存档起的作用,而不是这一页根本发不出那次查询。 撤实现两处、逐条对应:撤「有存档就不查」→ 前两条红、第三条仍绿;撤「写回」那一行 → 只有第三条红。 ## 两条边界 1. **「跑通了但余额低」盖不住**,刷新后仍会消失 —— 那一轮**没有 error 事件可挂**, 要盖住得给 `messages` 加顶层字段(类型化列,要 `ALTER TABLE`)。**有意不做。** 2. **失败时客户端不在场**(run 在 daemon 里跑挂而窗口已关):第一次读数发生在重开时, 存下来的就是那一刻的余额。**改前这一格每次都错,改后是错一次然后冻住** —— 不是新引入的错,但值得知道。真要修只能走 b。 ## ⚠️ 一个会骗人的验证坑,记在这里 **`pnpm --filter @open-design/web typecheck` 会假绿。** 改过 contracts dist 之后, 不带 `--force` 的 `tsc -b` 会用陈旧的 `.tsbuildinfo`,上面那 5 条错误**第一次跑一条都 没报**。判 contracts 相关改动一律用 `tsc -b --noEmit --force`。 * fix(daemon): 鉴权判定三处误判——平台凭据、协议违规、工具输出 产品裁决 2026-09-07:「跟 amr 相关的都要修」「工具输出肯定不算吧?」。 三条共同的病:**裸子串匹配**。而 `collectFailureText` 把 `error` / `agent` / `stderr` 事件全收进语料,于是任何地方冒出那几个词都能把用户送去登录页。 ## 一、R-053:平台自己的凭据坏了,却让用户去登录 `run-error-catalog.md:215` 早就在册:「平台自己的 provider 凭据坏 `upstream_provider_unauthenticated` | **被误判成用户鉴权** | 「Sign-in required」(**错**) | 用户可否自救:**不可**」。 上游原话是「Upstream provider credentials are missing or invalid」——说的是**平台的**凭据, 而裸的 `includes('unauthenticated')` 把它当成了用户的。用户重新登录一百次也没用。 改后落 `UPSTREAM_UNAVAILABLE` / `upstream_5xx`,**web 侧本来就认** (`amr-guidance.ts:1643` 与 `:1267`,19 个 locale 现成)——不需要新码、不需要新文案。 顺带把同一个 vela switch 里的兄弟 `upstream_provider_forbidden` 一并归位(它原本落在 不透明的「Task failed」上)。 ## 二、协议违规被判成「去登录」 `acp/session.ts` 报错时拼的 `invalid session/new response: …` 含子串 `invalid session`, 被当成 AMR 鉴权失败。**纯协议问题 → 告诉用户去登录。** ## 三、工具输出被判成「你要登录」(推翻冻结表 D1) `npm ERR! you are not logged in` / `gh: not authenticated` / `curl 401 authentication required` / Figma MCP token 过期 —— 用户看到「请登录」会去登 AMR,而坏的是他 shell 里 的 `gh`。**让用户做一件没用的事。** ⚠️ **D1 那行没有独立理由**,只有组标题 `adversarial: text whose service-signature may not be the real cause` —— **作者自己的自认**。真正的理由在引入它那个 PR 的正文:那次的 主张是「只动码轴、不重新审判分类器语义」,这个错判是它**之前**就有的,只是被顺手对齐了码。 准确的码指向一张不准确的卡,卡还是不准确的。 ## 判据:让它表达真正想认的东西,不用排除法 **7 个子串在 vela 仓里根本找不到出处** —— `not authenticated`、`login missing`、 `sign-in-again`、`sign-in required`、`signin required`、`expired token`、 `token has expired`(最后一个只存在于我们自己的测试夹具里)。和上一轮的 `invalid session` 是同一个物种:凭直觉写的同义词,从来没有任何真实报文长那样。**全部删除。** `authentication required` 有出处,但那是 bifrost 的 per-user MCP 凭据库 —— **另一个主体的凭据**,也删。 活下来的三条判据,都在说「这行报文的说话人是谁」: 1. **一个码是一个完整的码** —— `VELA_AUTH_CODE_PATTERN` 用边界前后瞻, `upstream_provider_unauthenticated` 和 `mcp_auth_required` 是**不同的码**,不是这些 码加了前缀。 2. **`session` 是名词还是标识符的一截** —— `AUTH_SESSION_NOUN` 让 `session/new`、 `sessionId`、`session_token_ttl` 是名字,`invalid session`、`Session expired.` 是名词。 3. **归属**:报文自己点名了凭据归属人就信它;谁都没点名,daemon 才可以补上「是 agent 的」。点名有且只有两种形态:**工具调用信封**(词表复用已有的 `isToolErrorText`)和 **程序自报名**(`gh:` / `curl:` / `npm ERR!` 这种 Unix 约定,是形态不是名单)。 ⚠️ **「按来源分层」这条路走不通,查证后放弃**:`collectFailureText` 把所有来源拍成一个 扁平字符串;而且 D1 **根本不走 stderr** —— 它是 opencode 把工具失败塞进 JSON-RPC error frame 送来的,ACP 桥接按设计只读 error frame。 ## ⚠️ 反向用例逮住两次真的过度收紧(不是演习) - `dsh: MISSING_CREDENTIAL: llm-deepseek: no API key…` —— **agent 自己穿着工具的外衣**, 和 `gh: not authenticated` 位置、标点完全一样。**只有名字能分。** 所以加了 `OWN_AGENT_COMMAND_NAMES`(36 个,每个 shipped adapter 的 id + bin)—— **是我们自己人的白名单,不是工具黑名单**:方向反过来才成立,daemon 没起过的一律算外人。 它没法在模块加载期从 `registry.ts` 派生(会成环),所以写成字面量 + **一条漂移守卫 读真实的 `SHIPPED_AGENT_DEFS` 比对,漏一个就红**。 - `Unauthorized: OAuth token has expired` —— **状态短语被当成了说话人**。严重度标签 (`Error:` / `warning:`)和状态短语一律拒绝:它们说的是「有多糟」「服务端答了什么」, 不是「谁写的」。 ## 落点与冻结表 工具鉴权失败落**既有的** `tool_error`,不需要新码 —— `mcp_auth_required` 今天就落在 那里,现在这一族归到同一个落点。web 走通用卡,**那颗「登录」按钮没了**,工具原文留在 折叠诊断区。⚠️ 放任它掉进 `process_exit/fatal_rpc_error` 会显示「模型服务商报错」—— 那是另一种甩锅,`tool_error` 严格更好。要一张专属的「某个工具没登录」卡需要新 detail + 文案 ×19,**那要产品拍板,未动**。 新建第三张冻结表 `tool-vs-agent-auth-snapshot.test.ts`(33 条),每个 `*Before` 都是**实测** 值,并把「改前分类器」反向重建出来对拍 —— 基线不是谁的一句话。 `acp-service-failure.test.ts` **只有 D1 一行变**;`balance-vs-rate-limit-snapshot.test.ts` **零变化**。 **余额分支未动**(产品说 BYOK 那边先不管)。 ## 已知残留(写进表里,未硬造判据) `Authentication required for Figma. Visit …/oauth to connect your account.` —— **什么都没自报**:没信封、没前缀,"for Figma" 是散文。生产里它包在 `mcp_auth_required` 信封里发(已覆盖),裸着出现时任何判据都只能猜。 另记一处**测试里的假引用**:`vela-errors.test.ts` 曾声称 `apps/cli` 在 `run_test.go:180` 渲染 401 —— **那个文件不存在**。事实对但形状错,已更正为真实的 `ParseAPIError` 路径。 顺带发现 `details.kind: 'amr_account'` **全仓没有任何消费者**,web 完全按 `error.code` + `failureDetail` 路由 —— 任何走 `details` 的方案都会是隐形的。 * fix(chat): 低余额那一档整档撤掉,余额 0 那张卡和弹窗都保留(T66) 产品裁决 2026-09-07。给出软档那张卡的截图后: > 「**这个要不先不要了,跟产品说了一下,不要这个了**」 追问范围后: > 「**余额为零的那个卡片要显示的,并且也要弹窗的**」 ## 终态 | 档 | 终态 | |---|---| | 余额 `> 0` 且 `< $2` | **什么都不出**,直接放行 | | 余额 `= $0` | 卡 + 弹窗都在,拦住发送(**既有行为,核实为保留而非新增**) | | 跑到一半死在钱上 | 保留(T61 的凭据) | ## 从判定层摘掉,不是从渲染层挡掉 `AMR_LOW_BALANCE_WARN_USD` **删除**(核过没有其他消费者:不在埋点、不在契约、不在任何 闸门分档,唯一读取方就是它自己那两处比较)。余额 `> 0` 现在直接 `allow` —— **没有第二条比较,这条路上一次套餐读数都不发。** 「什么都不出」因此是**判定层没有第二条线的结果**,而不是呈现层补写了一个 `null`。 把阈值调成 0 也能让卡不出现,但那会让「软档」这个概念以看不出来的方式活着。 ## ⚠️ `soft` 混装了两件事,所以是换名不是改名 - ① 余额 `> 0` 但低 —— **这次撤掉** - ② 余额 `= 0` 但硬拦让了位(**T55**:档次读不出来,由 Vela 入场兜底)—— **一个字没动** ②今天出 $0 那张卡、不拦、不弹窗。撤掉①之后 `soft` 就名不副实了,所以②改名 **`empty_not_blocked`**,并在类型和分支上写死「只在 `balance <= 0` 可达,正数永远到不了」。 ## `whyLow` 保留,19 个 locale 一个没动 它还有一条活的来源:一轮跑到一半死在钱上、而**停下来时钱包还剩一点**(T61 的凭据, 如 `$0.35`),`UpgradeCard` 按 `balanceUsd > 0` 走的正是这句。有红测钉着 (`w62-mid-run-balance-wiring.test.tsx`「余额还剩一点的那一档,念的是真实读数,不是 0」)。 ## 红测与验红 新增 `t66-low-balance-tier-retired.test.tsx`,**故意不 mock `checkAmrBalanceGate`** —— 喂钱包读数、判定用真的,否则「$1.20 该算哪一档」就掉在判据外面了。 - 先红:`$1.20:流水里不出现任何余额卡` → `expected '1.2' to be 'none'` - 撤实现①(把低余额档接回去)→ **16 条红**(t66 六条 + w116 十条) - 撤实现②(把硬拦档出卡那句 `if` 短路掉)→ **反向用例单条红**,证明它不是恒绿 **硬拦档是保留不是新增**,证据:那条反向用例「$0:卡在、弹窗也在、发送被拦」 **在改动之前就是绿的**;既有用例 `ProjectView.amr-balance-card.test.tsx:501` 也早就在。 删掉 `w116-amr-low-balance-card-tiers.test.tsx`(整份都在测「低余额卡对所有档位可见」, 那张卡没了)。它唯一还成立的红线 ——「这条路上不许多打一次套餐读数」—— 搬进了新红测, 档位扫描也作为回归护栏保留下来。 ## 受影响的既有裁决 **T52 整条作废**;T51 适用范围缩小到硬拦档;T53 前半作废、后半(首页什么都不显示) 升为全局;**T61 ①②③④ 全部保留**,只是少了一条来源;2026-09-02「各只有一张」现在 只剩「额度耗尽」那一张;OPEND-2600 整条作废。四行都加了回指。 顺手把 `chat-panel-edge-audit.md` 两处指向已删常量的过期行标掉 —— 其中一行是 「2 → 5 待产品同意」的行动项,留着会有人去改一个不存在的常量。 ## ⚠️ 一个改前就存在的自相矛盾,未动,值得产品看一眼 `empty_not_blocked` 那一档:余额 **$0**、档次读不出来、**run 照跑**,而卡上写着 「**现在无法开始新任务**」。原来的 `soft` 分支同样如此。按「保留既有行为、不自造规则」 处理,但它需要产品看真机截图后单独裁一条。 * fix(desktop): 关掉内层滚动容器的橡皮筋回弹(滚动冻结的缓解) 产品裁决 2026-09-07:「**直接关掉**」。 ## 为什么 Electron 40 → 41 把 Chromium 从 144 跳到 **146,整个跳过了 145**,而 `kOverscrollEffectOnNonRootScrollers` 的默认值**正好在 145 从 DISABLED 翻成 ENABLED** —— 已拉 `branch-heads/7559`(M144)与 `7680`(M146)的 `cc/base/features.cc` 逐字核实。 它管的是「非根滚动容器撞到滚动边界时怎么表现」:145 之前只有整页会橡皮筋回弹, 之后聊天区这类内层容器也会。 我们在追的缺陷:聊天区的滚动范围被**永久冻**在某个早期内容高度上 —— 布局全对、 JS 程序性滚动能到底,**但滚轮和键盘都到不了**(scroll unification 之后两者都走 合成器)。位置(滚动边界)、平台(macOS 弹性 overscroll)、版本窗口三样都对得上。 ## ⚠️ 这是缓解,不是根治 **因果链没有建立** —— 合成页面 89 个用例(精确复刻几何、顶部 overscroll、普通滚轮 与触控板 precise deltas、真实节奏、反复重建会话、RO 自喂环、`fill:both` 残留 transform、初始余量扫 0~100、向上格数扫 3~40)**一次都没撞出来**,而那个 harness 的量法是可信的(负对照:挂一个 `preventDefault` 的 wheel 监听,当场判成冻结)。 **判据仍然是 `client_chat_scroll_frozen` 的事件量**:带着这一行还在报,说明这条线 错了,该把这两个 feature 放回去、回到候选列表找别的。这也是为什么同一批把探针的 会话级上报上限整个删掉了 —— 判据要靠事件量,报满三条就变瞎的话这条判据立不住。 ## 代价 macOS 上**所有内层滚动区失去橡皮筋回弹**(整页仍然弹)。产品知情并选择了它 —— 相对「滚不动」这个代价可以接受。 ## 落点 `apps/desktop` 与 `apps/packaged` 两处都加,理由同旁边那条 `ignore-connections-limit` 的注释:打包外壳可能比它捆绑的 desktop 构建活得久。 必须在 `whenReady()` 之前 —— Chromium 在会话初始化时就消费这些开关。 实验分支 `exp/disable-overscroll-features`(处理组包 `0.21.5-beta.1`)随之失去意义, 其 A/B 对照不再需要跑。 * feat(chat): 下线意图澄清里的设计风格选择卡片 (OPEND-2760) 提示词侧不再让 agent 出「设计风格」这一类 question-form: direction-picker / discovery-question-form 两个 atom 的 SKILL 与 daemon/contracts 的 prompts 同步删掉该问法。前端 visual-style-catalog 与 visual-style-deck 保留但不再被澄清轮引用,组件代码加注释说明可能找回。 用户裁决:「这些代码先讲提示词干掉, 组件代码注释, 后续可能要找回」。 od-next 策略分支核对过,其澄清轮本来就不枚举问题类型,无需改动。 * fix(chat): 报错卡片统一主 CTA 为切换到 OpenDesign Cloud (OPEND-2772, OPEND-2745) 2772:BYOK/自带 CLI 的各类报错原本各出各的卡片 —— 独立的 AMR 推荐卡叠在 错误卡下面,一屏两张卡两个主按钮。现在合并成一张:主 CTA 一律是「切换到 OpenDesign Cloud」,铺到所有会由用户自己的模型服务引发的报错码。 删掉 AmrGuidance 组件与 .amr-card__* 局部样式,推荐语并入统一卡片。 2745:宿主自己写的卡片(余额、报错)此前会被当成一次 run 渲染,于是跟着 出「进行中 / 已完成」这类运行状态。新增 assistantMessageNeverHadARun 把「这条消息从来没有过 run」判出来,该行整行不渲染。 用户裁决:「2772 的『统一』是『铺到所有报错』, 主 cta 都是切换至 cloud」。 * fix(chat): 团队成员充值弹窗按设计稿重做,并补回个人档的升级入口 (OPEND-2720) 弹窗侧:AmrOwnerTopUpDialog 原本没有 banner,标题 12px 左对齐、CTA 只有 58px 宽。按设计稿重做为 480 宽卡片 + 480x205.71 出血 banner(复用既有的 /upgrade/cloud-signin-aurora.jpg,与 AmrBalanceDialog 同一张,不新增资源)、 18px 居中标题、满宽 40px 主按钮,owner 名字加粗。新增的 4 个 --chat-* 变量 亮暗两个作用域都定义。CTA 的样式覆写写成 .modal .cta,靠特异性压过共享 Button 基类,不依赖打包顺序。 升级入口侧:workspaceUpgradeUrl 此前一律要求 canManageBilling,而该权限 是 readable && role==='owner' 的团队语义。个人 workspace 只有一个成员、 钱包就是本人的,这道闸把用户自己的付款路径删掉了,弹窗弹出来却没有落点。 新增 canReachWorkspaceBillingEntrance 放在 buildWorkspacePermissions 旁边 (团队仍由该权限位把守,B 端也会服务端拒绝),弹窗分支与链接解析共用它, 两边不会再各说各话。 说明:打包版走 vela directory 分支,个人档 role 解析为 owner,因此线上 不受影响;本地 tools-dev / 自建 daemon 走「请求头即权威」分支才会踩到。 * docs(chat-panel): 同步裁决表、边界审计与运行错误清单 记录本轮四条裁决:设计风格卡片下线、报错卡片统一 CTA、宿主自写卡片不报 运行状态、个人档升级入口的权限语义。 * chore(specs): 把 Plane 工单证据附件移出仓库 18 个附件(截图 + 一个诊断 zip)共 40MB,其中 13 个单文件超过 CI 的 1MB 变更文件上限,Static gate 因此常红。这些附件的权威源本来就是 Plane, 仓库里没有任何地方按文件名引用它们 —— 只有一句话指向 manifest。 manifest.md 保留:它记着每个附件对应哪个工单、当初取到没取到, 要看原图去 Plane 拿。本地副本在 ~/Documents/od-plane-evidence/。 * fix(prompts): 把「视觉方向不是一个可以问用户的问题」补回 discovery 首层 (OPEND-2760) 2760 下线设计风格卡片时,把 `direction-cards` 从问题类型清单里删掉了, 但同时也删掉了唯一那条无条件禁令,只剩「有活跃设计体系时不许发」那条带 前提的。于是类型没了、禁令也没了 —— 模型完全可能自己即兴发一个方向选择 表单,而那正是 2760 要消灭的东西。 放在 discovery 的 core-directives 首层(「read first — these override anything later」),不放在 system.ts:那条带前提的句子在 ACTIVE_DESIGN_SYSTEM_VISUAL_DIRECTION_OVERRIDE 里,只有存在设计体系时才 入 prompt,怎么改都救不了没有设计体系的那条路径。也不放「Structured clarification」段:daemon 侧那段被 slim 变体跳过,而 slim 是生产默认值。 措辞只点行为、不点已下线的类型名 —— `question-form-visual-style-retired` 那条守卫连否定句都不许留(「一句『不要用 X』同时也在告诉模型『有个 X 可以用』」)。末句保留 `brand` 问题的豁免:它问的是「有没有品牌/参考来源」, 不是方向选择器,几条测试钉着它。 快照 15 处 totalChars 各 +429,恰好是走 discovery 层的 15 个场景; 不走该层的 5 个场景一字未变。 * fix(chat-mirror): 验收陈列页一直在拿回退字体量几何 mirror-exec.html 全文没有一条 @font-face,也不引外部样式,而 measure.mjs 用裸静态服务器托管它 —— 它声明的 "Albert Sans" 从来没被加载过,所有几何 读数都是在 PingFang SC 上量的。对稿子的 geom 差异 680,其中 61 条是这个 系统性偏差造出来的假差:22/90 格读数受影响,格 16/17/87/27/66/71 报的 「实现没对上」全部是假象(同一颗按钮被重复报了 3 次),而格 43 有一条真 差异一直被错字体正好抵消掉。 不把字体字节提交进去:同一份字节 apps/web/public/fonts/ 里已经有了,再复制 一份进 HTML 是 +423KB,还会撑破 CI 的单文件 1MB 上限。改成工具进仓库、 字节留本地: - inline-fonts.mjs 从 base.css 现场解析 @font-face 整段搬过来,只把 url() 换成同一份字节的 data: URI。描述符一个字不抄,「逐字一致」由构造 保证 —— 包括 JiduMono Pro 那个只有 Regular 一份字节却要声明成 500 的 描述符(两处只改一半就是坏的)。带 --strip 还原。 - check-fonts.mjs 量数前的前置闸。判据是差分(同一段文本带/不带该字族 的宽度必须不等)加一个反向对照(不存在的字族差分必须为 0),不用 document.fonts.check() —— 它在零 @font-face 时返回 true,真空成立, 正好放过这次要抓的坏法。 - 生成器不注字体,改为在页顶留一条「还没上字体」的横幅,并让注入块把它 藏掉 —— 「有字体」和「横幅不见了」因此是同一件事的两种表现。横幅走正常 文档流,而 geom 是格内相对坐标,动不到任何一格的读数。 页面同时带上了内容跟进:提交版是很久以前生成的,这次重新生成把 tokens.css 后来落地的那几段带了进来。 * docs(collab): 写明项目页那个 role='member' 是只读态的承重实现 它看起来像「还没接上真实角色」的占位符,实际是「创建者可写 / 非创建者只读」 的全部实现:role 钉在 member ⟹ privileged 恒 false ⟹ canMutate 退化成 selfCreated。谁把它「修好」成真实角色,工作区 owner 就对工作区里每一个项目 都可写(包括不是他建的),只读态当场消失,而且没有任何测试会红。 同一条路径也不许改成去查目录或等 /api/workspace/context:只读态在首屏渲染 路径上,先渲染错的身份再纠正同样算回退。 两处一起写,免得只改一边的人看不到另一边:生产者(project-workspace-scope) 和消费者(workspaceResourceAccess)各一段,互相点名。 真正的缺陷不在这个硬编码,而在付款入口误用了它 —— 一个声明过「我不解析身份」 的上下文被拿去判身份,导致团队 owner 在项目页被降级。那条链另行修复。 * test: 修两条 CI 红,一条是误提交的红测,一条是编出来的夹具 **od-next coordinator**:那条红测是更早一次调查里「故意留红、明说不提交」的 (07bd6d9149),被我在提交 2760 时按文件名一起扫进了501eb5640a—— 那次提交 对 od-next 只动了这一个测试文件,零源码改动,提交说明自己还写着「od-next… 无需改动」。 它断言的也不是仍然成立的不变量,而是被裁决推迟的产品行为: chat-panel-issue-log-2026-08-28.md:58「不隐藏失败卡、不做关键词猜测。未来若要 支持 Design 模式纯问答,需显式 structured intent,作为独立产品设计」。实测复核 过:放开 deliverableValid 之后这条仍然 blocked(第二道 fail-closed 闸挡着), 而邻居那条「拒绝把 silent no-op 洗成 completed」立刻转红 —— 两条 fixture 除了 agent 那段散文输入完全相同,只有靠猜文字才能分开,正是裁决点名禁止的。 改成 it.skip,正文原样保留,docblock 记下裁决和实测结论。 **e2e AMR 认证收敛**:不是 2772 波及,真凶是 ff2b46fe7f(鉴权判定三处误判)把 子串匹配换成了归因判据。而假 vela 发的「Your authentication token has expired. Please sign in again.」这句话在真实 vela 仓库里零命中 —— 是夹具编出来的。 换成实测验证过的真实报文(vela API 401 → CLI ParseAPIError →「API request failed with status 401: unauthenticated」),断言一个字没动,守卫反而更强了。 改回旧夹具能复现 CI 那条失败,因果链是量出来的。 无产品代码改动。 * fix(chat): 项目页的付款入口改读权威工作区身份 (OPEND-2720) 真机六格矩阵挂了两格,根因是一个「声明过自己不解析身份」的上下文被拿去判 身份:项目页的 workspace-scope 走本地快路径,role 恒为 member(那是「创建者 可写 / 非创建者只读」的实现,见该处注释),而余额弹窗分支、卡片 URL、弹窗 URL 三处都从它读 role。 后果: - 团队 owner 在项目页被判成 member,拿到一张没有升级按钮的「请联系团队所有者 充值」—— 他自己就是所有者(矩阵 B 格) - 个人档 max 用户,卡片跳 billing=plan、弹窗跳 billing=auto-recharge,两者 各说各话(矩阵 C 格),源码注释早就把这称作缺陷 修法是 merge never swap,不是换掉上下文:新增纯函数 workspaceBillingAuthorityContext(scoped, authoritative),只采纳 role 一位, 并用 scope 自己的 lifecycleState/memberStatus 重算权限(冻结的项目不会因为 「你是 owner」被解冻);要求权威上下文的 workspaceId / workspaceType / workspaceMemberId 三者全等且成员在职,否则原样返回 scope。属性测试遍历各种 权威候选,断言结果的工作区身份永远等于 scope 的 —— 「绝不掉回个人钱包」那道 防线的前提是身份能被换掉,而这里没有能换掉它的入口。 daemon 源码一行未改:scope 继续走零网络的本地快路径,只读态判定不受影响。 新增守卫钉住「非创建者进入别人建的项目 = 只读」这条此前零测试保护的不变量, 并断言该判定不发起任何网络请求(把 fetch 换成会抛的 spy 验的)。 顺带修好一格:个人项目在「环境里选中另一个工作区」时,弹窗以前会把人送去别的 工作区的账单页,现在钉在项目自己的工作区上。 * test(e2e): 三条 UI P0 判据跟上已裁决的行为 **余额那条不是 2772 弄红的** —— CI 时间线钉死:f8bd7d5dc0(09-04 10:44)绿, bd5ddea74e(09-04 14:48,OPEND-2597 把升级卡的回填改指 /api/workspace/billing?scope=workspace)之后,a553a7fa31(09-05 02:02)就红了, 而 2772 是 09-07 才落地的。夹具原先没给账号级钱包接口打桩,回填读失败,测试 一直在跑「钱包读不出数字就把白卡还回来」那条 fallback;2597 改指的那个接口夹具 恰好有桩($20.00),fallback 就不再触发。 「Top up」入口本身没被删(ChatPane.tsx:4349-4388),变的是什么时候画那张卡: 余额不足时白色报错卡把回合让给升级卡(T60 产品裁决「额度不足和额度耗尽,升级卡 各只有一张,不存在第二张白色通用报错卡」),充值卡只在升级卡接不住时回来。 失败那一刻的 CI 无障碍快照就是这个形状:只有 Balance left $20.00 + 一颗 Upgrade,没有白卡也没有 Cloud CTA。判据改成钉住这个已发布的形状。 **另外两条确实是 2772 弄红的**::728 / :896 那两条「不推荐 AMR」期望 0 个 Cloud CTA。T68 裁决「铺到所有报错」正是要把它铺开,所以断言翻成 1。2772 更新了 8 个单测文件却一个 e2e 文件都没更,这是漏网。同族还有 :888 那条 zh-CN 页面却 断言英文按钮名(翻转前就恒真),一并改成 zh-CN 名。 **api-empty-response 不是 flaky**:8d0b542d0a 把共享弹窗遮罩的 z-index 从 100 提到 1500,盖住了 z-index 120 的标签栏,点击卡在可操作性检查上 113 秒才超时, 报出来的 page.goto 只是超时展开后的下一个动作。补 suppressWhatsNew。 无产品代码改动。 * fix(e2e): 给导轨开合的点击加超时上界;去掉账单身份里那条死判断 **导轨点击**:`openNewProjectModal` 把 `ensureRailOpen(page).catch(() => {})` 包起来,注释写着「绝不允许它让流程失败」。但里面那个 `toggle.click()` 是裸调用, 而 playwright.config.ts 全文没设 `actionTimeout`,于是吃 Playwright 的默认值 0 (无上限)—— 点击一旦被遮挡就不会 reject 给外层 catch,而是一直挂到整条测试 超时,失败还记在超时展开后的下一个动作上,完全指不到真凶。实际发生过一次: 共享弹窗遮罩的 z-index 从 100 提到 1500 之后盖住了 z-index 120 的 chrome, 一条 UI P0 因此耗掉 120 秒。 定成 T.short(本机 3s / CI 6s,现成常量,自带 CI 缩放)。不取 T.medium 是因为 test:ui:extended 那条 lane 预算就是 10s,10s 的上界不成其为上界。点击前一行刚 断言过可见,剩下只有 stability / hit-target 轮询,而本仓库 UI 过渡预算约 200ms, 3s 有一个数量级余量。仓库里已有同形先例(app.test.ts:1005)。 **账单身份**:workspaceBillingAuthorityContext 原先要求权威上下文的 memberStatus 是 active 才采纳角色。产品目前没有「移出成员」的入口,这个条件在现实中不可达; 即便将来可达,残留影响也只是被移出的人还看得见一个升级链接,而钱的动作后端另有 强制。去掉,注释写明理由而不是留个哑口。 顺带补了一条真正承重的守卫:采纳角色不许复活已关闭的成员 —— scope 自己报 removed 时,即便采纳了 owner 角色,权限位仍为 false。它和原有的「采纳角色不许 解冻」是同一道防线的两半。两条都撤实现验过会红。 身份三位全等那条一个字没动,属性测试照旧全绿。 * test(e2e): 只读判据跟上 OPEND-2624 的裁决;八条 spec 补上更新弹窗抑制 **只读那条不是回退,是判据过期。**project-management-flows:426 断言的是 「scope 已答 personal、/collab/status 还没答 → 仍须只读」,而a17a22e32a(2026-09-04,OPEND-2624)故意改掉了这个行为:daemon 一确认 personal 就立刻 给写权限,不再吊在 status 上等 —— 修的正是「用户自己建的私人草稿被显示成 共享只读,聊天/上传/编辑/导出全灰」。那次加了 jsdom 裁决测试,却没同步改这条 e2e。它此后一直靠抢跑维持绿:抢在 React 把 scope 响应上屏之前读一次旧 DOM。 trace 显示这次预览渲染花了 126ms,抢输了,首跑和重试都输。 两个 SHA 之间这条链的产品代码逐字节相同(collab/、ChatPane、ChatComposer、 FileWorkspace、FileViewer、App、composer 全部 diff 为空),所以不可能是产品 造成的行为差异。判据反转,416/417 那两条 fail-closed 原样保留 —— 它们是真的 (scope 未落定 → workspaceContextLoading → 只读)。新写法用 not.toHaveAttribute 会一直轮询到属性消失,顺带把竞态本身也消掉了,不是换个方向再赌一次。 **更新弹窗抑制**:CI 上 daemon 把自己认成打包版(app-version.ts 的 linux 分支 判 execPath 含 /opt/,而 runner 的 node 在 /opt/hostedtoolcache/),channel 因此 解析成 stable,真去 R2 拉了发版卡片;再叠上8d0b542d0a把共享弹窗遮罩 z-index 从 100 提到 1500、盖住 z-index 120 的 chrome,点击就永久挂在可操作性检查上。 两个条件缺一不可,失败还记在超时展开后的下一个动作上,完全指不到真凶。 逐个核过「这个 spec 的路由网能不能吃掉 /api/whats-new」,四条已被自己的兜底大网 挡住(design-systems-manager / settings-media-providers / visual-entry / visual-navigation),给它们再加是噪音;真正暴露的八条已收掉。也确认了没有任何 spec 是故意要看这张卡片的。 mock-factory 里那句「fixture 也装了这条路由」原本只对 vela-status 成立,紧跟在 suppressWhatsNew 后面容易被读成两条都装 —— 点明 whats-new 不由 fixture 装, 否则下一个人会照着把这八处删掉。 daemon 那条 typecheck:ProjectWorkspaceScope 有 unbound 和 unavailable 两个 context 为 null 的变体,原代码只排除了一个。 * fix(prompts): 把设计风格选择整题下线,并修好那条永远不会红的守卫 (OPEND-2760) 前两轮只删掉了 `direction-cards` 这个类型。抓 daemon 真实写给 agent 的 stdin 字节发现,线上默认路径(slim)里还留着两处:一道 `tone` 心情多选题 (editorial / minimal / playful / tech / luxury / brutalist / human),以及 把「brand or visual style」列进澄清优先级。产品裁决原话是「把提示词里让 agent 感知到 question-form 能出设计风格的那些提示词下掉」,追加「tone 也下掉」。 **上一轮补的那句禁令从来没到过模型**:它落在 discovery 层,而 `server.ts` 默认 `promptCoreVariant: 'slim'`,`system.ts` 把整层 gated 在 `!isSlimCore` 后面。实测删掉它,默认路径抓包一个字节都没变。所以这次删掉它, 并退役 `system-prompt-api-mode.test.ts` 里那条要求提示词包含 'Do not emit a direction question-form' 的断言 —— 那条来自 #2579,当年方向 选择器还在,是搭便车的历史遗留。仓库自己的原则写着「连否定句都不许留:一句 『不要用 X』同时也在告诉模型『有个 X 可以用』」。 **守卫过去永远不可能变红。**它写的是 not.toMatch(/`tone`/),而 core-slim 是模板字符串,源码里的形态是 \`tone\`(反引号被转义),两者永不相交。修法不是 逐条补转义(那要求以后每个人都记得补),而是先把源码还原成模型真正读到的样子 再匹配,整份文件一次性摆脱这个盲区。同时发现第二类同形失效:按短语找的正则 会被行内反引号打断(`visual-style` catalog),另给一个去反引号的读法。 还有个比转义更致命的覆盖面问题:`tone` 那条以前只扫两份 discovery.ts,而那道题 真正住在 core-slim.ts —— 少扫的恰好是唯一有货的那份。现在按 PROMPT_PATHS 七条 全扫,并补上题 id / 散文点名 / 本地化文案三种形态。 证据(同一台 daemon、同一个项目、同一条请求上成对 before/after,所以别人在飞的 改动在两次抓包里都在、做差抵消):`tone` 2→0,`visual style` 1→0,字节 46,156→45,938,差 218 = 23+67+128,和三处删除逐字符对得上;完整文本 diff 只有 这三行。classic 路径另抓一对:`视觉调性` 1→0。快照 17 行 totalChars,零其它变化。 保留的两处是正面指令,不是选择器:core-slim「无设计体系时自己从 direction library 里选,不要再问用户」、directions.ts「按 brief 的 tone 挑」。 * test(e2e): 让 e2e 的 daemon 自称 development,并把更新弹窗抑制挪进共享 fixture CI 上的 daemon 会把自己认成打包版:app-version.ts 的 linux 分支判 execPath 含 /opt/,而 GitHub runner 的 node 就在 /opt/hostedtoolcache/node/…/bin/node。 channel 因此兜底成 stable,whats-new 那条真去 R2 拉了发版卡片;再叠上共享弹窗 遮罩 z-index 从 100 提到 1500 盖住 chrome,点击就永久挂在可操作性检查上。 这正好违背 app-version.ts 自己 33-37 行写的意图:「development/CI builds resolve to no card so the card never intrudes on tests」。 不碰 app-version.ts 的 /opt/ 判定(那是产品代码、影响打包版身份识别),改成在 e2e 自己起 daemon 的环境里设 OD_RELEASE_CHANNEL=development。设在 runToolsDevJson 这个唯一咽喉处 —— Playwright worker 套件、Vitest smoke 套件、 collab-cluster、artifact-render-parity 全从这里过,而且它本来就管着同一类 env (CODEX_HOME / OD_DATA_DIR / OD_MEDIA_CONFIG_DIR)。只在 e2e/lib/ 下,开发者的 pnpm tools-dev 和打包版都不受影响。常量排在 process.env 之后、extraEnv 之前: 外部 shell 值拖不进卡片,而想测发布通道的 spec 仍可自己传。 证据不是「我设了变量」:新测试直接 import harness 的 env 组装函数和 daemon 的 resolveAppVersionInfo/whatsNewSourceUrl,首条就是红基线(/opt/ 路径确实解析成 packaged 且真的拿到卡片文档);另外真起了两次 daemon —— 默认 → channel development 且 /api/whats-new 空,对照组 OD_RELEASE_CHANNEL=beta → channel beta 且拿回真实 R2 卡片。macOS 默认那次单独不能证明什么(macOS 两种情况都不算 打包版),beta 那个对照才是证明值真的穿过了整条链。 顺带查出 channel 共有六个消费者而不是两个:whats-new、installer-apply 观测 (e2e 下无状态,惰性)、langfuse 桥、诊断导出、任务观测导出,以及「关于」面板 的展示文本。前四个是遥测归因,方向一致(CI 不再自称 stable);「关于」面板是 唯一测试可见的,但读它的三条 spec 都自己打桩了 /api/version,没有任何断言依赖 daemon 解析出来的 channel。 弹窗抑制挪进 suite 的 auto fixture(auto fixture 先于 spec 的 hook 建立,想看 卡片的 spec 后注册可以盖掉)。八个 spec 里逐个加的调用保留:fixture 只绑内置 page,自己开新 page/context 的 spec 盖不到 —— 这正是 applyStandardMocks 也留着 自己那份的原因。两条防的是不同场景:设 channel 让 daemon 根本不去取卡片(CI 的失败),fixture 防的是 daemon 真给了卡片时页面还是看不见(本地恰好被认成打包 版,或机器上带着 OD_WHATS_NEW_URL —— 那个变量会直接盖过 channel 判断)。 * fix(collab): 给「这两个上下文是不是同一个人」一个名字,修掉它造成的五处缺陷 项目页的 workspace scope 走本地快路径,role 恒为 member(承重结构,是「创建者 可写/非创建者只读」的实现);壳层的 /api/workspace/context 给真实角色。两侧 context 的其余位都一样 —— 缓存 key 里的 canShareProjects/canWriteSyncedFiles 只看 memberStatus+lifecycleState 不看 role —— 所以两侧 key 有且仅有 role 一位 可能不同,真实角色是 owner/admin 才错位。这正是「为什么只有 owner 中招」。 这已经是同一根因的第 5、6、7 次,每次都是某人在某处顺手拿 role 比 key。所以 先给它一个能被测试钉住的名字:workspacePrincipalKey / isSameWorkspacePrincipal 放在 contracts,和 workspaceBillingAuthorityContext 并排(后者本来就在手写这个 比较,现在也改成走它,于是全仓只有一处定义)。docblock 列了全部七处已知实例, 「你为什么没走这个函数」在评审里答得出来。 刻意不比 memberStatus / lifecycleState:两侧描述的是**不同对象**的状态 —— scope 的 lifecycleState 是从项目 resourceState 推的,壳层报的是工作区的。 一个活跃工作区里的冻结项目,两者本来就该不一致。那是身份的状态,不是身份。 修掉的五处: - AvatarMenu:在项目页点一个被套餐锁住的模型,tooltip 写着「升级即可使用」, 点下去什么都不发生。它既要合并权威身份(团队 owner),又要改用 canReachWorkspaceBillingEntrance(个人档豁免)—— 只做一半就只修一半。 :301-303 那句「个人工作区不受影响」的注释从356c8c364f落地第一天就是错的, 一并改对。 - projects.ts:437 团队 owner 从目录卡片点开某些项目直接报「项目不存在」, 硬失败无兜底;:591 首开团队项目的渐进快车道对 owner/admin 完全死掉。 - project-display-cache:owner 在项目页改了分享状态,首页网格的快照永远不被 标脏,回首页看到旧状态 —— 唯一会给用户看错数据的一条。标脏和 patch 两个 入口都改,只改具名那个会在另外两个入口留同样的 bug。 - useProjectWorkspaceScope:bootstrap 已经付过钱的 scope 种不进去,每次开项目 多一次 GET 加一段 fail-closed 骨架。 两个夹具是生产中不可能出现的形状(scope 端点返回 role:'owner',而 daemon 那条 路由只有一个分支、永远写死 member),改成真实形状之后,两条一直在绿的既有用例 当场变红 —— 它们此前是靠假夹具过的。新的根不变量测试用 buildWorkspacePermissions 推导权限而不是手写,免得夹具再悄悄漂回去。 daemon 源码一行未改,只读态判定不受影响,不引入任何网络等待。 --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
151 KiB
ChatPanel 1:1 复刻 —— 用户反馈跟踪表
这张表的用途:会话上下文会被压缩,压缩后我会忘掉用户口头提过的问题。 所以用户反馈的每一条当场落在这里,做完一条当场回来打勾。 交接时只看这张表就够,不依赖任何人还记得当时的对话。
状态:
[ ]未做 /[~]部分做了(必须写清还差什么) /[x]做完且已验证权威源:交付稿
docs/design/chat-matrix/matrix-82.html(84 格实体)。 验收面:docs/design/chat-mirror/compare.html(逐格左右对照 + 逐属性状态章)。
A. 已完成
| # | 用户原话 / 指的地方 | 真因 | 落点 |
|---|---|---|---|
| A1 | 「这些时间怎么位置跟设计稿差那么多」 | 比对工具的属性表里根本没有 margin-left/right,而「把秒数推到最右」正是 margin-left:auto —— 整类差异从来没被量到 |
diff-cells.mjs PROPS 摊成完整盒模型 |
| A2 | 「下拉箭头位置也不对」 | .fold.flat summary > .chev 用了子代组合子,稿子是后代;且漏了 .meta |
record.module.css |
| A3 | 「这缩进感觉完全不对」(#11) | 同上一类的层叠错位 | record.module.css |
| A4 | 「这俩啥意思」(#23/#24 share/detail) | 已答复的摘要里把枚举原值直接吐出来了,没走 readable() |
QuestionForm.tsx |
| A5 | 「#41 也没对齐」 | —— | record.module.css |
| A6 | 「hover 的前后顺序不对」(#51) | 我按稿子的注释文字排(时间在最右),稿子的渲染是 时间→复制→重试。以渲染为准 | AssistantFooter |
| A7 | 「是点击行为没做还是 hover 眼睛也没做」(#55) | 两个都没做 | .msg-att-eye + 点击打开 |
| A8 | 「文档里那么多『本实现出不来这一态』,你 mock 不行吗」 | 我拿「拿不到数据」当借口 | 各态夹具补齐 |
| A9 | 「你所有的都要有截图」 | 22 格空着 | 84 格里 82 格有图(#70/#71 是你拍板不做的 Plan 卡) |
| A10 | 「正文取词你是一点没写??」 | 整个功能没实现 | QuoteBar + QuotedRefs + quote-selection.ts |
| A11 | 「那你做啊大哥!!没有就不做了?」(#78) | 同上 | SupportDialog |
| A12 | 「这个线好像也没有」(步骤连接线) | 稿子那两条 ::before 竖线我一条没搬 |
record.module.css |
| A13 | 「这出现在运行结束后」(#75/#76) | 我做成了弹窗,产品裁决是流水里的卡 | UpgradeCard |
| A14 | 「#72 队列完全不一样」 | —— | chat.css 队列行 |
| A15 | 「#9 球」 | —— | Orb |
| A16 | 「#11 重试为啥不接?」 | 生图重试按钮只画不点 | onRetryImage 接到发送路径 |
| A17 | 「不在这一轮授权内」(报错卡不抽组件) | 这是借口,不成立 | RunErrorCard 抽出 |
B. 待办(当前批)
| # | 用户原话 / 指的地方 | 现象 | 状态 |
|---|---|---|---|
| B1 | 「你间距检查了???」 | 比对工具比的是属性值,不是位置。两边属性可以条条相同而元素落在不同 y 上 —— 「间距」这类问题结构上量不到 | [x] diff-cells.mjs 加了逐位置比对(相对格根的 x/y/w/h,容差 1px),compare.html 的状态章现在两项都报。配套修了两个假因:①两张页面的栏宽不同(稿子 440 / 我们 760),百分比宽度天生对不上 —— 陈列页展位已对齐 440;②整个被推到画布外的读屏文本/提示气泡不参与比对(稿子推 -513、我们推 -29,屏幕上都不占像素)。首次量到:位置差 778 处,而逐属性只有 359 —— 用户一直说的「间距不对」正是这一半 |
| B2 | 「#90 间隔这么大」 | 工具行(读取 首页.png / tokens.css …)的行距明显大于稿子 | [~] 已定位到三类真因,还没清完:①行高:稿子的紧凑行(.tool 等)是 --lh-row: 1.5,summary 那一行反而继承 1.7 —— 两种混着用,我们有几处给反了(实测:稿 summary 20.4px / .ms 18px);②缩进:稿子有两条嵌套缩进规则(通用 33px + 扁平壳 29px),我原来只搬了 29 那条,非扁平那一路整列少缩 33px(已补);③队列动作按钮宽 22 vs 16(见 B9 的行高账)。剩余位置差 778 处,逐格清 |
| B3 | 「#21 箭头指向的都是有问题的」 | 四处:①右上切换图标字形不对(稿子是 2×2 圆点,我们是 ⊞)②右翻页箭头旁多出一块 ③「随机」没和「下一步」同行 ④左下应是「换一批」,我们是「跳过 · 你来判断」 | [x] 根因是夹具照错了组件:我拿 direction-cards(模型现给的固定四张,天生没有下一批)去照稿子那张「内置风格目录」卡(prototype 档 26 张),所以左下落回通用的「跳过 · 你来判断」、底栏也只剩「随机」。换回目录那一路后:①切换图标改用稿子的四个圆角方块(新增 grid-4;原来 grid 映射到 remix grid-line,画出来是「⊞」)②③④ 底栏合成一行 `换一批 |
| B4 | 「#40 这为啥又不对齐设计稿??」 | ① i18n key 裸露在界面上(assistant.feedbackReasonTitle / …Placeholder / …Submit)②复选框应为胶囊组 ③缺「取消」④多出设计稿没有的标题表情与 Discord 引流行 ⑤补充框应为「无框 + 一条底线」而非 textarea 方框 ⑥标题应是「哪里不对?」 |
[x] 胶囊组 / 常驻「无框+底线」补充框 / 取消+提交 / 新键 assistant.feedbackReasonTitleNegative(19 语言包已补)。裸 key 的真因是陈列页的 t 桩 (k)=>k,已换成真 zh-CN 译器。标题表情与 Discord 引流行已按最新裁决移除,不再保留设计稿之外的附加界面。原因 code 契约保持兼容,本次只收敛展示结构与样式。 |
| B5 | 「#49 间隔这么大」 | ① 气泡到「重试」行之间空了一大截 ② chat.record.retry 裸 key |
[x] 裸 key 见 B4(陈列页 t 桩)。间距的真因是结构错了:稿子的「重试」是 .msg-act .keep,长在动作行里和时间/复制并排;我另起了一行 .msg-fail 挂在气泡下面,于是那条 opacity:0 的动作行照样占 30px,气泡到「重试」之间空出一大截。改成:动作行整行常驻,只淡出非 keep 的子元素(照抄稿子的 .msg-act button:not(.keep) { opacity: 0 }),「重试」进行内 |
| B6 | 「compare 这为啥半边灰色的」(#2 格) | 对照页上格子被一块灰蒙住半边 | [x] 真因是 SupportDialog 的遮罩:inline 模式原来只跳过 portal,遮罩仍是 position: fixed,于是它铺满整张陈列页、把每一格都糊上一层灰 —— 之前所有截图都是隔着这层灰看的。稿子的 .ovl 本来就不是 fixed(带圆角内距的在流盒子),已照搬成 .overlayInline。顺带修了拍照:原来靠 captureBeyondViewport 够视口外内容,headless 会吐出没光栅化的灰块;改成逐格把视口调到这一格高度再滚进视口拍 |
| B7 | 「#50 这跟设计稿也不一样呢」 | ① chat.record.retry 裸 key(同 B5②)② 我在格子底下记了「稿子底色其实是变浅不是加深,已记待确认」—— 这又是一条尾巴,稿子怎么画就怎么做 |
[x] ①同 B5 ②不再写「待确认」:稿子的状态名说「背景加深」,它自己的 CSS 是 --bub-bg: var(--text-strong) → var(--text),白底上是变浅 —— 名字和值打架时以值为准(值才是能渲染的那一个),说明已改成陈述事实 |
| B8 | 「#65/#66 这按钮跟划线的没对齐」 | 正文取词浮条没跟着选区走:稿子里浮条居中压在被划线的那几个字上方(#66 是选区贴顶,浮条翻到下方并左对齐选区);我们的浮条固定在右侧,和划线位置无关 | [x] 陈列页原来拿整个容器的 50% 摆浮条,和划线那段无关;改成照稿子的做法 —— 高亮段 position: relative,浮条绝对定位居中在它上面。顺带修了一个真 bug:QuoteBarView 根节点是 <div>,摆进 <p> 里会被浏览器当场截断 <p>、DOM 重排后浮条整个不见 —— 已按稿子的 .selbar 改成 <span> + 显式 display: block |
| B9 | 「#73 icon 样式都不对」 | ① 左侧拖拽把手:稿子是 2×3 六点,我们是菱形四点 ② 右侧三枚:稿子是 魔法棒 / 垃圾桶 / 回弹箭头,我们是 铅笔 / 上箭头 / 垃圾桶 —— 图形和顺序都不对 ③ 我自己在格底下记过 🐛「每行错位同第 72 格,行高 34→38px,『3.5 行 = 122px』这笔账算不平」,一直没修 | [x] 手柄真因:Icon.tsx 把 grip-vertical 映射到了 remix 的 drag-move-line(四向箭头菱形),本地那段六点 case 一直是死代码 —— 去掉映射并把坐标对到稿子(cx 9/15,cy 6/12/18,r 1.5)。编辑换成稿子的魔杖(新增 magic,路径逐字节照抄),顺序改成 编辑 → 移除 → 第三颗。第三颗当时仍是「立即发送」:稿子那颗叫「引导对话」(塞进正在跑的这一轮),我们那颗会打断再发,是另一件事 —— 能力已在 B11 补齐,现在能引导时这颗就是「引导对话」,引导不了才退回「立即发送」 |
| B10 | 「#78 icon 呢」 | 报错卡三颗按钮全缺图标:稿子是 耳机(联系支持)/ 上传(导出日志)/ 循环箭头(从失败处重试);标题那枚也不一样(稿子是八角形惊叹号填充,我们是三角警告)。另外我在格底下记的「⚠️ 差异清单…②动作集合待产品定」也是尾巴 | [x] 标题图标换成稿子的实心八边形+感叹号(原来是三角警告);三颗动作补上图标:耳机(新增 headset)/ 上传(upload,与稿子同路径)/ 循环箭头(refresh,与稿子同路径) |
| B11 | (由 B9 引出)稿子队列行第三颗是「引导对话」—— 把这条塞进正在跑的那一轮 | 我们没有这条能力。daemon 侧有 mid-turn stdin 注入的底子(promptInputFormat: 'stream-json',只 Claude 有),但要端到端就得配齐 HTTP 端点 + 契约 + od 子命令(仓库的 UI/CLI 双轨规矩),不是样式改动 |
[x] 三件套一次落齐:POST /api/runs/:id/steer(契约 ChatRunSteerRequest/Response,拒绝码 RUN_STEERING_UNSUPPORTED / RUN_STEERING_CLOSED)+ 队列行第三颗按钮 + `od run steer [--message |
| B12 | (自查发现)RunErrorCard 抽出来了却没接回产品 |
产品里报错走的还是 UserActionCard(说明藏在「查看详情」折叠里),只有陈列页在用 RunErrorCard —— 那一格照的是个没人用的组件。稿子的 errb 是说明直接可见,这是行为改动不只是样式 |
[x] 产品的报错面从 UserActionCard 换成 RunErrorCard:说明直接可见(稿子的 errb 就是一句话),诊断原文仍收在折叠里(那段是给工程看的)。data-user-action-card / data-user-action-footer 两个测试与 e2e 的稳定钩子原样保留 |
| B13 | (自查发现)发送失败态在产品里没有人产生 | 契约有 ChatMessage.sendFailed、UI 画得出来,但没有任何代码把它置上;onResend 也没有上游传进来。第 49 / 50 格照的是一个还没接线的状态。接线点在 ProjectView 的 streamViaDaemon onError:当从来没建出 run(!prev.runId 且非 api 模式)时,应把用户消息标成 sendFailed 并撤掉 assistant 占位。要先有红测再动 —— 那是 run 生命周期最容易出事的分支 |
[x] daemon 在建出 runId 前失败时撤掉虚假的 assistant 占位、把原用户消息持久化为 sendFailed;重试复用同一用户消息 ID 和原上下文/附件元数据,预检未启动会恢复失败态。已有 runId 的 assistant 失败路径不变。组件接线与 ProjectView 生命周期红测均已补齐。 |
| B14 | (量位置时发现)清单之前那段叙述在产品里丢了 | 稿子第 2 格的扁平壳里第一个孩子是 <p class="think">两张图是同一套栅格…;我们渲染不出来。夹具里那条 thinking 事件是有的,build-turn-blocks 把它 pushInside 到了当时的 sink,随后被清单接管时丢掉(D36 那条 sink 规则)。要动落块规则,得先补红测 |
[x] 被落块裁决顺带解决:那段 thinking 现在稳稳在第一张壳里(第二张是清单),正是裁决要的形态 |
| B15 | (同上)每条 todo 的耗时没渲染 | 稿子每个 todo 抽屉的 summary 右侧有 .ms(如 18.2s);TodoRow 有 formatElapsed(segment.elapsedMs) 的分支,但 elapsedMs 从来没被算出来 |
[x] 新增 segSpan:事件发生时哪条 todo 在跑,这段时间就算在它头上;轮末落到 segment.elapsedMs。红测先行 |
| B16 | 「这个时间怎么跑到右边去了,然后文件名变成…了」 | 工具行里长文件名被压成 ...,耗时被甩到最右。稿子 .tool .ms 只有 flex: none,不带 auto 外距;我们的 .meta 基底一律 margin-left: auto,只在 .fold.flat summary 里清了 —— 工具行没清到 |
[x] 与 B20 同一件事(你先后指了两次),真因与修法见 B20:elideFileName 保后缀中间截断 |
| B17 | 「这个缩进不对吧?」(#6 终端块) | 稿子里代码/终端块的左缘比行文字更靠左(.fold.mod-flat > .body.mod-stack > .code { margin-inline: 22px 11px }、.fold.mod-flat .body.mod-stack .code { margin-inline: 29px 18px }、.fold.mod-flat .body.mod-stack .body.mod-stack > .code { padding-inline-start: 0 });我们的块缩进跟行文字齐平 |
[x] 我们的 .code 免缩进规则少写一个 .flat,只有 (0,4,0),压不过新加的 > * 的 (0,5,0),块跟着吃了 29px。选择器补齐 |
| B18 | 「#7 这个缩进怎么这么深啊?」 | 我今天引入的回归:补 .body.stack .body.stack > * 那半之后,嵌套抽屉的 .fold 盒子和它的 summary 各吃了一份 29px,叠成 58。稿子有一条 .fold.mod-flat .body.mod-stack .body.mod-stack > .fold { padding-inline: 0 } 专门把盒子那份清掉 —— 我没搬 |
[x] 补上稿子的 .fold.mod-flat .body.mod-stack .body.mod-stack > .fold { padding-inline: 0 } —— 缩进只由 summary 承担,盒子不再各吃一份 |
| B19 | 「#21 这个边距问题?」+「这个 +22 看看」 | 底栏在 440px 宽下撑破卡片:「换一批」被左边界切掉、「下一步」贴右边界。多出来的那颗 +22 是稿子没有的 —— 稿子右上角那枚四方块图标就是「摊开看全部」的入口,底栏只有 换一批 / 随机 / 下一步 |
[~] 边距已修(底栏丢了卡片底栏的 padding: 0 11px 8px,整行贴死左右边界)。+22 待办:按你的裁决改成「网格视图摊开全部」,然后删掉 +22 |
| B20 | 「#4 时间跑到右边、文件名变成…」 | 真因:稿子那一行在源文件里就是截好的字符串(设置页-…-f….png,后缀完整);我们靠 CSS text-overflow 截,而文件名包在 <button><code> 里对 flex 是一个原子块 —— 要么整块放下要么整块被省略号顶掉,于是只剩「…」、后缀没了、耗时被推到行尾。FileButton 的注释里我原来写着「阈值和量法等设计答复」,又是一条尾巴 |
[x] 新增纯函数 elideFileName(预算 28 = 后缀 4 + 省略号 1 + 主名 23,由稿子那一行反推),红测先行(5 条),读屏与 tooltip 给全名 |
| B21 | 「#50 重试 icon 也不对」 | 稿子那一枚是循环箭头,我放的是实心感叹号 —— 感叹号说「出事了」,这颗按钮说「再来一次」 | [x] 换成 Icon name="refresh"(和稿子同一条路径) |
| B22 | 「动画 icon 和右边『进行中』的文字上下没对齐,也没什么间距」(真实客户端) | 陈列页照不出的一类:球是 <canvas>,静态渲染时是空白,所以它的真实排布我从来没验过。真因:壳头把 .mark(15×15 状态点圆)挂给了球 —— 24 的画布被压进 15 的格子,而且 canvas 默认 inline、吃基线、底下垫出行内下沉空间。稿子另有一套 .orb(inline-flex + align-items:center + canvas{display:block} + 按尺寸档的负外边距) |
[x] 照搬稿子的 .orb 一族,Orb 补 data-orb-box,壳头改用 .orb |
| B23 | 「问句的自由填写框换成设计稿那种一条底线的」 | 意图澄清里「自己填 / 请提供内容」那个输入框现在是四边框 + 大方块;稿子是无框 + 一条底线(同第 40 格反馈面板那条 .ta 的做法) |
[x] 改成稿子的「无框 + 一条底线」(和 .qf-custom .qf-input 同一种做法),field-sizing: content 跟着内容长高 |
| B24 | 「左下角的跳过按钮样式不太对」(分步态) | 分步底栏的「跳过」字重/颜色偏重,而且贴着卡片左边界 —— 稿子是 .btn.mod-ghost.mod-sm 走 --text-muted,并且 .foot > .btn:first-child { padding-inline: 0 } 让它落在卡片的 11px 竖线上 |
[x] 两处:①.question-form-foot > button:first-child 改成 :first-of-type —— 分步态下前面有一枚倒计时 <span>,按 :first-child 匹配不到「跳过」;②倒计时不再带 flex: 1 把「跳过」挤到右边 |
| B25 | 「已回答这些问题」那张卡「怎么没变成最新的设计稿样式」 | 产品还是灰底圆角卡 + ✓ 圆圈 + 胶囊对;稿子第 23/24/25 格是绿色「已确认」+ 纯行式 标签 值,视觉方向那格还带选中的那张缩略图。AnsweredSummary 没按稿子重做 |
[x] 真因是有两份实现:QuestionForm 里的 AnsweredSummary 早就按稿子写好了,但产品历史回放走的是 AssistantMessage 里另一份 question-form-summary(灰卡 + ✓ 圆圈 + 胶囊)。已把后者改成同一套 .answered / .k / .ab / .ak / .al / .av |
| B26 | 「思考中 + 时间」那一行重复了 | 壳头已经在报「进行中」,底下的老回合状态行又报一次「思考中」。现有抑制开关 hideRunStatus = recordHasContent || hasTodoSnapshot || 报错卡 —— 壳刚建出来还没内容时三条都不成立,于是两行同时在 |
[x] 与 B31 / B36 同一条规则:运行中整行不出(壳头已经在报状态),跑完才出并报终态 |
| B27 | 「上面的 title 是啥东西?」 | <od-title>…</od-title> 原样漏进正文。真因:调用点写的 enabled: Boolean(titleGenerationRequested) —— 不请求标题就把剥离器整个关掉;而模型是按系统提示词吐这个标记的,它不知道这一轮我们要不要标题 |
[x] 语义拆开:永远剥离,只有请求过标题才上报。红测先行,并删掉了与之直接矛盾的旧规格 |
| B33 | 「这些是啥啊?」(<CRITIQUE_RUN> / <PANELIST> / <ROUND_END> 整块漏进正文) |
评审团(critique)的通信协议原样打给了用户。根因方向:apps/daemon/src/*.ts 的可见文本路径上根本没有 panel 语法的剥离器(<od-title> 有,critique 没有)—— 一旦提示词注入了面板语法而编排器没接管这条流,协议就直接进正文。要定位是哪道门失灵,得拿这一次 run 的事件流看。属 apps/daemon/src/critique/(有自己的 AGENTS.md) |
[x] 已修并在真机验过:语法搬到 @open-design/contracts(stripCritiqueGrammar)作为唯一出处 —— daemon 用它剥流(带半截缓冲,不闪),web 在 AssistantMessage 的 cleaned 里用它剥历史。旧对话不能因为「以后不会再有了」就烂在那儿:真机上那条消息原来有 16 处标记,修完 0 处,标记之间那几句人话原样留着。剥的是壳不是字(<PANELIST role="Critic" score="9.0">已完成…</PANELIST> → 「已完成…」)。红测先行,撤掉实现复验会红 |
| B28 | 「下面不是有 5 步吗?怎么后四步没显示?」 | 「执行计划 · 5 步」在,但壳里只列出了第 1 条 todo 抽屉,后 4 条没出 | [x] 真因:落块时写着「status !== pending 才推成行」,还没开始的那几条连行都不建。「说好几步」和「看得见几步」必须同一个数。红测先行 |
| B29 | 「上面已完成的怎么还在倒计时」 | 已经「已完成」的壳,秒数还跟着下面那个「进行中」的壳一起往前走(两个都显示 1m 37s 并同步递增)。跑完的壳该把秒数定死在结束那一刻 | [x] 耗时改成按壳自己算(每张壳记自己的起止),只有还在跑的那张跟着 now 走。红测先行 |
| B30 | 「这个命令没什么可读性呢」 | 工具行显示成 执行 node - <<'NODE' —— heredoc 被原样截断,读不出这条命令在干什么 |
[~] 先修了 heredoc:新增 commandHeadline,把 <<EOF 这类开启标记和其后孤立的 - 去掉(node - <<NODE → node)。更根本的健壮性正在调研(已派 subagent 捞真实语料 + 压测现有正则 + 对比开源方案) |
| B31 | 「运行中没有这个了,干掉」 | 跑的过程中底部还挂着一条「复制 + 16:57」的回合状态行;稿子里这一行是跑完之后才出的 | [x] showCompletionRow 加上 !streaming —— 回合状态行是跑完之后才出的一行 |
| B32 | 「本轮产出的文件」这块「也没这个了,变成上面卡片形式才对」 | 产物文件现在是列表行(名字 + 体积 + 打开 / 下载);应该走上面那种产物卡形式 | [x] 新增 doc 档产物卡:拿不出预览图的产出(md/txt 等)也走同一张卡的骨架,缩略图位换成「图标 + 文件名」。稿子没画这一档 —— 它每张卡都假设有缩略图 |
| B34 | (我引入的回归,你当场抓到)执行 wc -l brand-spec.md tr….html |
elideFileName 被套到了命令字符串上,末尾的 .html 被当成「后缀」保住,读起来像另一条命令 |
[x] 省略改成由调用方按语义开(FileButton 的 elide),只有真文件名开;加了回归守卫测试 |
| B35 | (我发现并口头说过,之前漏记)反馈面板的 Discord 那句是硬编码英文 | 整个面板 19 种语言都翻了,只有这句没有 i18n 键,英文用户之外都会看到一句突兀的英文 | [x] 两句都进语言包(19 个),按 {discord} 占位切开再塞链接 —— 占位在各语言里的位置不同,不能拼字符串 |
| B36 | 「这个状态你好像也丢了」(跑完后的「✓ 已完成」) | 回合状态行跑完之后该显示绿勾 + 已完成;现在因为 hideRunStatus = recordHasContent || …,只要壳里有内容就把整个状态词藏掉,于是跑完也不出 |
[x] hideRunStatus 收窄成只在报错卡那一轮让位;运行中的去重改由整行不出来解决 |
| B37 | 「compare 里看起来都实现了,为什么本地跑起来还是旧的?你的 compare 是假的吗?」 | 不是假的,但它证明的比我以为的少。①它只覆盖我喂了夹具的那条分支 —— 你截到的灰色气泡是 .msg-applied-context(消息带的上下文),我在陈列页给 UserMessageImpl 传的是 appliedContextItems={[]},这条分支从没被照过,84 格交付稿里也没有它的格子;②它是静态渲染,canvas / 逐字动画一律空白 —— 球和文字没对齐能活到今天就是因为这个。结论:绿章 ≠ 产品里长这样 |
[x] 已交付(另一位 agent,c4936bfc06):84 格之后新开「端到端 · 真实形态回归」5 格,每格喂一整条真实会话(用户消息 → 助手消息 → 回合状态行),按产品自己的祖先链渲染。覆盖 84 格照不到的分支:appliedContextItems 灰色上下文行、runContext.workspaceItems 芯片、producedFiles 三种卡混排、一轮两张壳、失败轮 + 报错卡,外加 done 密钥协议。紫色区段与 84 格明确分开,下游 build-compare.mjs / diff-cells.mjs 解不到它们 |
| B38 | 「把这个东西干掉」(消息上方那枚「⚙ 设计」小徽标) | 用户消息上方的上下文行里,那枚「设计」类型徽标要去掉;右边「正在使用 Kami Parchment Document」那行保留 | [x] 消息上方那一行不再挂会话模式徽标(组件保留,别处可能还要用) |
| B39 | 「+22 别出现了,左右切换的卡片列表直接渲染 22 个不就行了」 | 视觉方向选择器不再分页:扇形/网格直接装下整份目录(prototype 档 26 张),+22 这颗按钮删掉。稿子只画 4 张是模拟数据,不是规格(见 C 节) |
[x] 已做:QuestionForm 的 compactCards 改成整份目录都进这一沓(Array.from({length: cards.length})),分页取消,「+22」那颗按钮随之消失。offset 保留 —— 「换一批」现在是把这一沓整体转过去,不再是「换到下一页」 |
| B40 | 「卡片里没有内容只有纯色」 | 视觉方向卡的预览图没加载出来,只剩底色 —— 目录的预览图走远程资源地址,本地跑时多半 404 | [x] 已解:2026-08-26 用户自己的截图里,视觉方向卡的预览面是真图(暖白画布、墨蓝、羊皮纸那几张),不是纯色 —— 这一条随目录接管 direction-cards 一起消失了(见 D-catalog 节)。 |
原来那四张纯色卡是模型现开的 direction-cards,它没有素材、只能画占位块;现在这类题一律由内置目录接管,每张都带 R2 上的真预览图。 |
|||
排查过程中证伪的三条假设留档,免得下次再追:①目录 96 张预览图逐个探过,非 200 的 0 张;②真实客户端 new Image() 拉那个域秒回 1600×1200,不是 CSP;③CSS 画的降级骨架也都在。 |
|||
方法教训:我一度在陈列页量到「52 张图全 pending、一张不 error」差点当成 bug —— 真因是那个标签页在后台,Chrome 会推迟 loading="lazy"。量 lazy 图必须让标签页在前台。 |
|||
| ①不是资源缺失 —— 目录里全部 96 张预览图逐个探过,非 200 的 0 张(deck 25 / prototype 26 / document 11 / image 22 / video 12); | |||
②不是 CSP / 网络 —— 在真实客户端页面里 new Image() 拉 repo-assets.open-design.ai 的图,秒回 1600×1200; |
|||
③不是降级样式丢了 —— .qf-visual-preview-prototype / .qf-preview-app 那套 CSS 画的骨架都在。 |
|||
| 顺带记一条方法教训:我一度在陈列页量到「52 张图全 pending、一张不 error」,差点当成 bug —— | |||
真因是那个标签页在后台,Chrome 会推迟 loading="lazy" 的图;切到前台立刻开始加载。 |
|||
| 量 lazy 图必须让标签页在前台,否则读到的是假的「图坏了」。 | |||
| 下一步:需要真机上复现出那一屏(起一轮 discovery 让视觉方向卡出来),不能再靠代码推测。 | |||
| B41 | 「左右边距怎么还没改?你是还没截新的图是吗?」 | 我改完之后没有重新拍 compare 的图,对照页上还是旧截图 | [ ] 这一批全部改完统一重拍 |
| B42 | (落块裁决的连带,我自查发现)<done/> 两侧的正文粘成了一段 |
裁决把「没有 todo 时的正文」挪到壳外之后,过程叙述和结论都在壳外,而渲染层把相邻的 prose 合并了 —— 屏幕上是一整段。标记本该仍然分段:两者不是一回事 | [x] 被落块最终裁决顺带解决:裁决之后 done 之前的正文根本不出壳(全在卡片里),所以「done 两侧的两段壳外正文粘成一段」这个场景不再存在 —— 卡外只可能有 done 之后那一段。block-attribution-ruling.test.ts 的「开场白和结论各自成块,不粘成一段」钉住了这一点 |
| B43 | 「运行中的 todo 应该是这个动效,你怎么显示的是这个」 | 步骤级「正在跑」那颗记号:稿子是绿色带高光的球(CSS 画的 .mk.is-run + .sheen / .rim),我们放的是 thinking-orbs 的散点 canvas |
[x] 已做:StatusMark 的运行态改成稿子那颗 CSS 画的绿球(<span class="mark run"><i class="sheen"/><i class="rim"/></span>),不再是 thinking-orbs 的散点 canvas。record.module.css 里补齐了稿子的 8 层 conic-gradient + @property --orb-angle + orb-spin/orb-drift,并带 prefers-reduced-motion 降级。轮次级别用 orb、步骤级别用绿球 —— 这是稿子自己写明的分档 |
| B44 | 「为什么有两个一模一样的 todo」 | 我 B28 那条改出来的回归:清单一到 addPlan 已经把每条推成行,后面状态从 pending 推进时又推一遍;pickCurrent 的隐式点亮也推一遍 —— 同一条最多出现三次 |
[x] 行只在清单落下时推一次,后面只改状态(状态是同一个 segment 对象上的字段)。加了回归守卫:每条 todo 在 items 里最多出现一次 |
| B45 | 「计时结束了还是进行中的状态,下面几个 todo 完全没状态」 | 截图是绿球改动生效前拍的:那时「正在跑」和「没开始」都画成虚线圈,自然分不出来。现在正在跑的是稿子那颗自转绿球,没开始的仍是虚线圈 | [x] 根因不在渲染也不在数据,在去重。真机事件流里五次 TodoWrite 共用同一个 tool id item_2(这家 agent 把「计划」建模成一个反复改写的条目),而 dedupeToolUsesById 按 id 只留第一条 —— 后四次状态推进全被丢掉,UI 永远停在「四条全 pending」那一版,第一条却同时挂着 35.1s 的耗时和「未开始」的记号。落块器本身是对的(单独喂五次快照能算出四条全完成)。修法:新增 isSnapshotTool(住在 tool-kind.ts,build-turn-blocks 的 TODO_NAME_RE 并入它,只留一个出处),快照型工具不参与 id 去重 —— 它每次调用都是状态替换,不是重复投递;重放两遍也无害,落块是原地更新。红测先行(3 条,含「普通工具照旧去重」的反面守卫)。真机复验:四条全绿 + 各自耗时 8.8s / 14.7s / 11.4s |
| B46 | 「评审图这个东西干掉吧」(产品裁决) | 评审剧场(Critique Theater)已在总闸上关死:skill / project / env / phase 四层一起失效,提示词不再注入、编排器不再启动。注入源至今没查清 —— 漏出来那一轮跑的是 codex(json-event-stream,非 plain),按判据根本不该注入;而可见文本路径上没有任何 panel 语法的剥离器。代码没删(apps/daemon/src/critique/ 有自己的 AGENTS.md 和归属人),整体移除是另一个决定 |
[x] |
| B47 | 「下拉展开卡片里如果没有工具调用或任何内容,就不显示这个了」 | 空的执行记录壳(只有「已完成」一行、里面什么都没有)整个不该渲染 | [x] 已做:落块末尾按「壳里有没有东西」过滤 —— `items.length > 0 |
| B48 | 「输入框还是没跟设计稿对齐,我之前截图让你改过,你没记到文档?」 | 记了(B23),也改了 .qf-textarea,但真机上还是方框 —— 说明这一格走的不是 .qf-textarea 那条分支,或者被别的规则压住。这次用真实浏览器查 DOM,不再靠猜 |
[x] 只修了一半:上次改的是折叠「自己填」那一路(.qf-custom .qf-input),而 type: text / number / textarea 三种自由填写走的是裸 .qf-input / .qf-textarea,那条规则只写了字号,于是掉回浏览器默认的四边框 + 4px 圆角 + 灰底。真机实测确认(1px solid #dbdbdb / radius 4px / bg #fafafa)。已把「无框 + 一条底线」提到 .qf-input, .qf-textarea 这一层,select 不在内(它有原生下拉指示符,拆边框会看不出可选)。顺带删掉下方两条过期的重复聚焦规则。真机复验:border: none / border-bottom: 1px / radius 0 / 透明底 |
| B49 | 「为什么不一发送消息就立刻显示?第二轮第三轮每次都要等一会儿」 | 「进行中」那张壳要等 agent 的第一条事件才出(ensureShell 挂在事件上)。D10 写的是「空态先出来,不等任何 agent 信号」—— 发送的那一刻就该有壳 |
[x] 已做:if (turnIsLive) ensureShell() —— 轮次一开始就建壳,不等第一条事件落下来。原来要等真实事件才开壳,所以第二轮第三轮都要「等一会会儿」才出现 |
| B50 | 「不是说运行中时最下面这一行不显示吗?」 | 我只藏了状态词 + 时间,保留了复制/Fork —— 因为有一条带单号的能力 recvqacy887jsF(流式中途就能复制已出的文字)。你现在第二次明确说整行不要,按你的裁决:运行中整行不出,那条能力被产品覆盖 |
[x] 已做:showCompletionRow 前面加了 !streaming —— 回合状态行是跑完之后才出的一行,运行中整行不出(壳头已经在报状态) |
| B51 | 「这个 warning 也不要显示了」 | 橙色的 warning(「Skill descriptions were shortened…」)不该出现在对话里 |
[x] 已做:warning 这一档在对话里直接 return null(AssistantMessage.tsx:1066)。error 档不受影响,仍然出 |
| B52 | 「点击 fork 按钮后应该会有这样一条东西才对」(稿子第 38 格) | 点过「新开会话」后要原地落一条分界线,线上写新会话标题,底下一行「上文已带过来了,接着说就行」。契约里 ChatMessage.forkedInto 和 .fork-sep 样式都建好了,但没有任何代码把它置上 —— 和 B13 同一类:字段有、UI 有、没人写 |
[x] 已交付(另一位 agent,034d95867e):分叉时给源会话那条助手消息盖上 forkedInto(标题承接原会话,不是新标题),渲染成稿子第 38 格的发丝分界线 + 居中脚注。落库要新列 messages.forked_into_json(原来 PUT 上来整个字段被丢掉),已配迁移。19 个语言包补了 assistant.forkNote。红测三层(daemon HTTP round-trip / web 容器 / 渲染)。遗留:只落在源会话,新会话末尾那条 seeded 副本还没盖(daemon 建会话时重新发号,不是同一条) |
D. 产品裁决 · 落块规则(2026-08-26,用户口述)
原话:「这里有两张展开收起卡片,但是第一张卡片那边还没有产生 todo,那这部分收起的应该 只有工具调用或 thinking 啥的才对吧?然后把普通文本放到外面。当有了 todo 后, 来了第二张卡片,第一个展开收起卡片就收起,但此时之前那些普通文本还是留在外面的, 然后有了 todo 后的所有内容都收进当前正在执行中的那个 todo。」
规则拆解(实现按这个来):
- 还没有 todo 时:壳里只收 工具调用 / thinking;普通正文留在壳外。
- 第一条 TodoWrite 落下:另起第二张壳(清单壳),第一张收起。 此前已经落在壳外的正文,原地不动(不回收、不搬家)。
- 有 todo 之后:后续所有内容(工具调用、thinking、以及普通正文) 都收进当前正在进行的那条 todo。
与既有决策的关系:
- 这条收紧了 D43(原来是「done 之前的过程叙述画在壳里」)—— 现在「过程叙述」在没有 todo 的阶段属于壳外。
- thinking 不受影响:它任何阶段都在壳里(与稿子第 2 格的
<p class="think">一致)。 - 待确认的一点:第 1 阶段「普通文本放外面」指的是模型的正文输出, 不含 thinking —— 我按这个理解实现。
⚠️ 两条裁决打架(需要你确认我选得对)
- 2026-08-25(T34):「创建 todo 时,上面有文案才新建一个卡片包裹 todo」 → 只干活没说话不分张。当时给的理由是:分出来两张都写「已完成」、 耗时还是同一个数,读着像同一件事说了两遍。
- 2026-08-26(本次):「第一张卡片那边还没产生 todo,那部分收起的应该只有 工具调用或 thinking……当有了 todo 后,来了第二张卡片」 → 第一张装的是工具/thinking,照样分张。
我按新的实现(判据从「壳里有正文」换成「壳里有东西」),理由: ① 新的更晚且描述的正是你要的形态; ② 旧裁决的顾虑是「两张耗时同一个数」—— 那是 B29 这个 bug,我正在修, 修完之后第一张会定死在它自己结束的那一刻,不再和第二张同步走。
如果你要的是旧的那条(工具-only 不分张),跟我说,一行判据就能换回去。
D-final. 落块裁决(2026-08-26 最终版,推翻同日两条中间裁决)
用户原话,分两次给:
「没有 todowrite 时,所有工具调用或普通文本或者 thinking,都收拢在展开收起卡片里。 当有了 done 信号之后,输出的平台文本内容才会显示到卡片外面,但如果有工具调用啥的, 还是会收到卡片里。一旦某一个有了 todowrite,就收起之前的卡片,新开一个卡片里面 开始 todo 相关的渲染逻辑和规则。」
「如果有了 todowrite,不用第二张卡片,继续第一张卡片里继续输出就行了,因为此时肯定是 没 done 信号的…卡片外面也没文本…会出现两张连起来的卡片,不太好。」
现行三条
- done 之前:一切都在卡片里 —— 工具调用、普通正文、thinking 一律收进当前 sink (有 todo 就进当前那条 todo,没有就进壳)。
- done 之后:只有正文出卡片;之后若还有工具调用,仍然收进卡片 —— 它是过程不是回答。
- 卡片之间的边界由「卡外落过东西」决定,不由清单决定 —— 一轮正常跑完就是一张 过程卡;只有 done 之后 agent 又开新计划继续干时,才在结论下面另起一张。
被推翻的两条,以及为什么
- 「还没有 todo 时正文落壳外」:
ensureShell在循环开头就把壳压进了blocks, 于是开场白排到了整张卡之后,还和 done 之后的结论粘成一段。用户真机截图指认: 「这个话怎么感觉是在创建 todo 之前的?…那么这段话应该在展开收起的卡片之前才对呢?」 - 「一出现 TodoWrite 就收起前一张、新开一张」:两张卡之间永远不会有东西隔开 —— 卡外唯一会出现的内容是 done 之后的结论,而 TodoWrite 必然在 done 之前。所以那条规则的 产物一定是两张紧贴的卡:两个「已完成 + 秒数」的头,说的却是同一段连续过程。 它还制造过两张卡头显示同一个耗时的坏画面(thinking 事件不带时刻,前一张只能 退回轮次跨度)—— 陈列页的端到端格照出来过。
连带必须做的两件事(不做会把答案埋掉)
- 兜底提结论:整轮没发 done 时,轮末把当前 sink 里最后一段回答提到卡外。 没有这条,一个「只答话、不调工具、又没发 done」的普通回合会被整段埋进默认收起的抽屉, 用户看不到自己问题的答案。
ShellText加thinking标记:兜底不许提 thinking —— 提出来就是把 「想什么」当成「答什么」。踩过一次:整轮只有一句 thinking 时它被提到壳外、 壳空掉后整张壳被丢,「思考中」那一格直接没了。 连带:pushInside的合并判据要认「同一种文字」,thinking 和回答挨着时必须分成两段。
历史基线
historical-turn-baseline.json 已按最终裁决重刷,四处变化逐条核过(见
done-marker-key.test.ts 的注释)。其中一处是修复落进基线:旧基线里
bare-done-after-todos-closed 有一条重复的 todo 行(3 行只对应 2 条 segment),
正是用户指认的「为什么有两个一模一样的 todo」—— 上次修它时没有同步重刷基线,
于是这条测试在 HEAD 上本来就是红的。基线一旦欠着就从守护栏退化成噪音。
D-hover. 气泡 hover 不变色(2026-08-26 用户裁决)
「不要 hover 变色反正,你干掉吧」
起因:用户问「为什么我看原始的设计稿,hover 上去没反应呢?」查下来 ——
交付稿 docs/design/chat-matrix/matrix-82.html:712 确实写着
.msg-row.is-hover .bub, .msg-row:hover .bub { --bub-bg: var(--text) },
但那条在它自己那一页上是死规则:普通气泡的祖先是 .msg-me(全文 7 处),
.msg-row 全文只出现 2 处、且都写死 is-hover —— 那是两格专门摆拍 hover 态的格子
(带「重试」那两格)。也就是说在原稿上普通格子 hover 本来就没有任何反应。
摆拍那两格量出来是 #494949,和我们产品 hover 时一模一样 —— 实现没走样,是这条规则
本身该不该存在的问题。用户据此裁掉。
顺带解掉一条历史矛盾:那节的状态名写「背景加深」,值却是 #202020 → #494949,
白底上是变浅。原来按「值优先」实现并挂着待确认,现在整条删掉,矛盾一并消失。
hover 仍然有反馈:时间与复制会浮出来(.user-actions 那条 opacity: 0 → 1),
真机复验过 —— 气泡底色不动,21:43 与复制按钮照常出现。
D-footer. 回合状态行只在最后一轮出(2026-08-26 产品裁决)
「我问了一下产品同学,应该只有最后一轮底部才会显示,之前轮次不要显示,hover 也不显示」
当天改过两次,记清楚免得再来回:
- 用户先问「为什么 hover 在这一轮消息后,才会出现下面这一行? 这不是要常驻的吗?」——
查下来交付稿的
.fb确实没有任何 opacity / hover 门控,于是改成常驻; - 用户问过产品同学后回来定死:只有最后一轮出,历史轮次连 hover 都不出。以这条为准。
判据放在渲染层(AssistantMessage 的 showCompletionRow 加 !!isLast),不用 CSS 藏:
原来那份 opacity: 0 + hover 显形的门控在 styles/viewer/composio.css 和
styles/viewer/routines.css 各写了一遍,后者靠 .app 拔到 (0,2,0) 且排在 index.css
最后 —— 特异性和顺序两头都赢,只删一处不生效。直接不渲染就没有这场层叠仗。
两处 CSS 门控都已删干净,别再加回来。
代价已明说:复制 / 反馈 / 分叉这几个动作在历史轮次上因此够不着了。这是产品要的取舍;
要给历史轮次留入口得另想落点。相关测试 chat-feedback.test.tsx 里那条
「每一轮都有一组反馈控件」已按新口径改写。
红测:tests/components/AssistantMessage.completion-row-last-only.test.tsx(3 条,含
「不是最后一轮 → 整行不渲染,所以 hover 也不可能带出来」)。
D-vnav. 一沓的左右箭头距两侧 11px(不是贴边)
用户圈图反馈「左右边距啥的好像还是不对」。稿子 .opts.mod-visual .vnav 是
inset-inline: 11px,我们写的是 inset: 0 —— 箭头压在这一沓的左右边缘、和卡片描边叠在一起。
11px 和「换一批 / 随机」那一行的左右内距是同一个数,三者竖向边界因此对齐。
竖向仍由 .qf-visual-stage 承担(它的高度就是 --qf-fan-h),不照搬稿子的
bottom: 10px; height: var(--fan-h) —— 稿子那两条是因为它把 .vnav 挂在更高的 .opts 上、
要自己把盒子压回这一沓;我们的定位祖先已经正好是这一沓,再写一遍会错开 10px。
D-catalog. 内置风格目录接管 direction-cards(2026-08-26 用户裁决)
「它那个 4 个卡片数据是从设计稿里搬过来的,但设计稿是 mock 的…我们原本应该有这个 风格选择的功能,而且原本应该也有缩略图啊,我记得当初还是我传到 R2 去的,一大堆图片」 「为什么不把 tone 的内容换到 direction-cards 里?」
三件事分清楚:
- 真功能是活的:内置目录挂在 discovery 简报的
tone那道题上,宿主认到q.id === 'tone'就把模型给的纯文字选项换成目录卡。共 96 张预览图(deck 25 / prototype 26 / document 11 / image 22 / video 12),住在 R2repo-assets.open-design.ai/style-catalog/v1/—— 逐张探过,非 200 的 0 张。 - 用户截到的「四张纯色卡」不是这条路:那是模型自己现开的
direction-cards(提示词允许它在「用户明确要看视觉方向」时发)。模型现开的卡没有素材, 预览面只能画占位块。 - 一处死代码:
renderDirectionFormBody()(5 个硬编码方向、调色板色块、无图) 在apps/daemon/src/prompts/directions.ts与packages/contracts/src/prompts/directions.ts各有一份,全仓零调用点。这才是真正「从设计稿 mock 搬过来」的那份。待删。
已做:判据从「id 恰好叫 tone」放宽到「这道题在问视觉方向」——
tone(radio/checkbox)与模型开的 direction-cards 都由目录接管,前提是项目有视觉风格
上下文;没有上下文就原样渲染模型那几张。direction-cards 是单选。
为什么换掉模型的选项不会「说两件事」:答案是按 formatFormAnswers 拼成文本行
回给模型的(- 视觉方向: Content-led product),不是机器 id 契约;tone 那条路今天
就是这么替换的,已经在线上跑着。我一度把这个风险说重了,已更正。
红测:tests/components/QuestionForm.direction-cards-catalog.test.tsx(4 条,撤掉实现
复验会红 2 条)。
| 编号 | 现象 | 状态 |
|---|---|---|
| B53 | (接 B39 照出)取消分页时「View all」按钮跟着退场,于是 openGallery() 失去全部调用点 —— 带分类页签(商务/编辑/创意/极简)的画廊弹窗在正常流程里打不开了,只有输入过自定义值、点 .qf-visual-custom-summary 才够得着;visual_style_gallery_open 埋点也基本不再触发 |
[x] 弹窗整体退场,不补入口。逐格核对交付稿 #21 / #22:底栏只有 换一批 / 随机 / 下一步,「铺开」是选项区右上角 .vbar > .vswitch(aria-label="铺成网格")在 fan / grid 之间切、是内联的;全稿 84 格里唯一的 role="dialog" 是「联系支持」。那个弹窗本就是分页时代的溢出面(入口是卡片条末尾的 +N),分页撤掉后它没有存在理由;「看全部」由 [data-action="toggle-view"] 一次铺开整份目录承担。已清:Dialog 那一段、分类页签、visual_style_gallery_open / visual_style_category_tab 两个埋点、interaction_source 的 'gallery' 一档、category_id 参数、.qf-visual-dialog* 那族样式。停用的覆盖已开启并改钉收敛后的形态。没有 i18n 键变孤儿(common.close / tool.done / qf.custom* 都另有用处)。详见 §B53-1 |
| B54 | (同上照出)「随机」抽中的那张没被翻到最前面,选完还压在底下看不见。真因:VisualStylePicker 只传了 revealToken、漏了 revealValue,那个 effect 拿不到目标就 at = -1、弹回第一张。一沓只有 4 张时概率 1/4 撞对,整份目录进来才露馅 |
[x] 已补 revealValue(旁边 DirectionCardsPicker 本来就传了,只有这一处漏) |
| B55 | 「为什么我发一句你好,然后下面都有这个产物卡片?按理说只有本轮增量的文件才会有呢」 | 打招呼那一轮一个工具都没调、daemon 结算的 producedFiles 是空数组,可底下照样压出一张整块的产物预览卡(带发布 / 导出) |
| B56 | 「question-form 最底部那些按钮的样式要仔细对齐」—— 首步 1 / 5 同时出现「上一步 / 下一步」 |
上一步 在首步仍被渲染,只靠 disabled 禁用;而底栏 ghost 按稿子去掉了禁用底色、保留同一档文字颜色,导致这颗不可用按钮看起来仍可点击。交付稿首步底栏明确只有 `跳过 · 你来判断 |
| B57 | 「把这个『设计 ×』选项给下掉,首页那边也去掉;默认就是设计,不可选择」 | 首页和项目对话输入框都挂了 ComposerModePicker,虽然初值是 Design,用户仍能切到其它模式或清空选项 |
| B58 | 视觉方向答完后只显示 prototype-expressive-consumer 这类内部 id,没有设计稿里的缩略图 |
direction-cards 已由目录接管选择,但答案 normalization / summary 仍只认旧 tone 问题;当轮锁定态与历史回放还有两套映射 |
| B59 | 已下掉的固定「继续处理 / 生成产物 / 更多」又出现在成功轮次下方 | AssistantMessage 只因本轮没有 nextStepArtifactName 就把普通 default 成功轮降成 project-incomplete,绕回旧固定菜单 |
| B60 | 错误卡的「联系支持 / 导出日志」退化成裸文字,可重试卡和不可重试卡还不是同一套按钮 | 卡壳已组件化,actions 仍散在 ChatPane / ExportLogsAction;共享 Button CSS Module 热更新脱载时会被 chat button reset 覆盖,另有裸 <button> 恢复路径 |
| B61 | 图片方向卡加载慢,翻到后面的卡片才开始出图 | 首屏虽然只有 6 张,但每张都是 1600×1200 原图,合计约 11.5MP;扇形隐藏卡统一 lazy,第 5/6 张可能等翻到才请求 |
| B62 | AMR 图片任务只报 MEDIA_DISPATCH_FAILED / 原因未分类 |
本机旧 Vela 没有 media 子命令;Node 数字退出码 1 又被持久化成业务 code,agent 契约拒收后只能降级泛化错误 |
| B63 | AMR session/new 等了 35 秒后显示「智能体版本不兼容」 |
Go 的 context deadline exceeded 没进 timeout 词表,handshake 最后兜底误判成 AGENT_CLI_SESSION_REFUSED / update_cli |
B53-1 · 随弹窗一起没掉的「自己填」——要产品拍
那个弹窗里除了分类页签,还长着视觉方向题唯一的自定义输入框(allowCustom /
customLabel / customPlaceholder)。弹窗退场后这条路没了,现在按稿子实现 ——
不自造一个稿子上没有的输入位:
- 交付稿的视觉方向卡(#21 / #22)本来就没有「自己填」;稿子里的
.opt.mod-own只出现在 #19 / #20 那种文字选项上。 - 另一条视觉路径
direction-cards(DirectionCardsPicker)从来就没有自定义输入, 所以「视觉题没有 Other 出口」在产品里已经是既成状态的一半。 - 目录外的答案仍然看得见:模型给的
defaultValue/ 上一轮草稿会渲染成.qf-visual-custom-summary那句陈述(B53 之前它是一颗按钮,点开弹窗;现在降成<span>)。
冲突点:apps/daemon/src/prompts/discovery.ts:81 明写着「host 会在每道有限选项题
(含 direction-cards)上自动渲染一个 Other 出口」,并据此要求模型不要自己写
「其它 / 我来描述」这类兜底选项。视觉题这条路现在兑现不了这句承诺。
三条路,没有原文依据,不自己拍:
(a) 承认视觉题没有 Other,把 discovery.ts 那句话的适用范围从 direction-cards 摘掉;
(b) 给视觉题补一个稿子上没有的自定义位(两条路径一起补,别只补目录那条);
(c) 请设计在 #21 / #22 补一处放它。
C. 长期约束(用户反复强调过的,不是单条 bug)
- 别留尾巴:「要抽组件的就抽,要新实现的功能就实现,每个像素对齐过去」。
推论:陈列页底下不许再写「⚠️ 这条要产品/设计设定」当挡箭牌 —— 交付稿就是规格。
确有产品口径要拍的(会改埋点样本口径 / 契约枚举),写进
chat-panel-next.md的决策表并同时给出按稿子的实现,不要空着。 - 口头反馈当场落表:见本文件抬头。
- 设计稿里的数据是模拟的(2026-08-26 用户裁决):几张卡、多少条目这类数量不是规格。 稿子那张视觉方向卡只画 4 张图,产品的目录有 26 张 —— 不能因为稿子是 4 张就不做「看全部」。 设计同学不读代码,他们给的是形态;数量、分页、空态这些要按真实数据规模自己想清楚。 推论:凡是「按稿子的数量照搬」的实现,都要回头检查一遍。
E. 陈列页照不出真相的系统性原因(2026-08-26 查清,已修)
用户连续四次截图说「这个消息怎么还是这个样式的??」,而陈列页一直显示对齐 —— 不是某一格漏改,是陈列页少内联了一整层样式。
- 真相:
apps/web/src/styles/viewer/routines.css里有一整块.app …/.chat-skin …的旧聊天皮肤(451 条),index.css把它排在最后(第 31 行;chat.css 第 13、 composio 第 23、theater 第 24)。对同一个元素它既赢特异性(0,2,0 对 0,1,0) 又赢顺序 —— 产品里真正生效的是它。 - 陈列页原来一条都没内联,所以「属性对齐」的绿章有一大半是假的。
- 已做:
- 删掉把用户气泡刷成
#ededed灰底圆角的那条(.app .msg.user .user-text), 原位留注释说明这个陷阱; buildPage()新增legacySkin,按这一页实际渲染出的类名求交集只挑真会命中的 几族,<style>插在footerSkin(theater)之后,照生产的层叠顺序。
- 删掉把用户气泡刷成
- 重测:属性差 360 → 128(那 232 条是假差异,旧皮肤本来就盖成稿子的样子了)、 位置差 728 → 773、全干净 14 → 13/84。
- 修法约束:旧皮肤和稿子冲突时,删掉
routines.css里冲突的那一条, 不要在新组件上加特异性去压 —— 那是把同一场层叠战争再打一遍。 删前确认这条不服务于.chat-skin那个宿主(DesignSystemFlow的面板)。 - 位置差别逐条追:773 条里 y=617 / x=470 绝大多数是下游位移(上面多一行,底下全跟着挪)。 源头是高度 121 条 + 75 格结构对不齐,改源头。
F. 待办总表(2026-08-27 现场清点)
这一节只回答「还剩什么没做」。每条的裁决归各自权威文档,这里只给指针, 不复述决定内容 —— 复述必然漂移。
F-1 报错卡(权威:run-error-catalog.md §6)
| # | 事 | 状态 | 关键事实 |
|---|---|---|---|
| E1 | 错误码映射表要补 | ✅ 已完成并合入 —— 补了 S19 六档(process_crashed / signal_killed / terminated_unknown / exit_code / exit_nonzero / execution_failed)、account_suspended(daemon 侧同时加了分类器)、cpu_unsupported 与 AGENT_RUNTIME_DEF_INVALID 改判第 4 档。红测 run-error-ladder.test.ts / ChatPane.error-card-ladder.test.tsx / run-failure-account-suspended.test.ts,逐块撤实现复验过 |
三张表共 16 档(AGENT_AGNOSTIC_FAILURE_UI 7 + DETAIL_FAILURE_UI 3 + AGENT_AGNOSTIC_DETAIL_FAILURE_UI 6)对 32 个场景。AGENT_EXECUTION_FAILED / process_exit 两张表里都没有 → S19(每月 20,868 次、第二大类)整个落兜底 |
| E2 | 兜底不许把 rawError 摊在卡面 |
✅ 已完成,并在真机上看到了 —— 新增 chat.runError.fallbackMessage(19 语)。2026-08-27 真机:一次没有产物的运行,卡面是「这次没能顺利完成。原始报错收在下面的『查看详情』里…」,英文原文 The design run finished without producing a deliverable project file. 收进折叠区。这正是修之前会直接摊在卡面上的那一串 |
ChatPane.tsx:1609 displayError = messageKey ? t(...) : rawError —— 没命中映射表时直接显示上游原始错误串。S19 今天「标题 + 一段 stderr」就是这么来的。补 E1 时要连兜底文案一起给 |
| E3 | 〔更换模型〕落点与稿子不符 | ⚠️ 待拍板,但原来记的理由是错的 | 稿子 error-ux-design.md:130:「直接打开模型选择器,选完自动重跑」。实现落成 onOpenSettings('execution'),不自动重跑。更正:原来写着「项目页里没有模型选择器,所以只能去设置」—— 不成立。 ProjectView.tsx:11324 挂了 AvatarMenu,composer 那颗触发器点开就是内联的模型列表(avatar-model-list / avatar-model-option,2026-08-27 在真机上点开确认过)。设置里那个面板本来也是同一份选择器(见 ChatPane.tsx:3086 的注释)。所以真正待拍的只剩两件:①落到内联那个列表还是设置面板;②选完要不要自动重跑那一轮 —— 这一条是产品行为,前端不自己定 |
| E4 | 主按钮阶梯落到每一档 | ✅ 已完成 —— primaryActionForFailure(nature) 由 RunFailureNature 的三个问句(directFix/transient/localDeadEnd)推出按钮,全部调用点改走 failureCard(),仓里再没有手写 primaryAction 的字面量。原则四从此是结构保证:没声明 directFix/transient 的失败,函数根本产不出〔重试〕 |
阶梯见 §6.Z,对应关系见 §6.T。E1 补表时按阶梯算主按钮,不要逐档再挑一次 |
| E5 | 四种身份的余额分支 | ✅ 已完成 —— 四组按 §6.V 落地(判据:permissions.canManageBilling × 新增的 isMaxPlanTier),死胡同(§6.Y)一并修掉:没有账单权限的成员改看 AmrOwnerTopUpDialog(可一键复制给所有者的话),聊天流水和首页两条路都换了。真机只能验 owner/member 两轴 —— Max 档在 prod 造不出来,那一格目前只有单测 |
|
| E6 | 〔联系支持〕第 4 档提为主 | ✅ 已完成 —— 第 4 档把〔联系支持〕的 variant 提成实心主按钮,没有重排那一行(重排会动 e2e 的 274px 溢出判据)。「提为主之后视觉上读不读得像主按钮」还需要人眼看一次 |
F-2 todo 判据与召回
| # | 事 | 状态 | 关键事实 |
|---|---|---|---|
| T1 | 同一个判据有三份,口径不一 | ✅ 已完成 —— 三份合并成一个出处:contracts/run-completeness.ts 的 isTodoWriteToolName 改成大小写不敏感的正则(含 write_todos 与 MCP 前缀名),另两处改为 import 转发。红测 packages/contracts/tests/todo-tool-name.test.ts(6 条) |
|
| T2 | AMR 把 todowrite 改成 Todowrite |
✅ 已完成 —— 治本不靠下游宽容:acp/updates.ts 在进 title 启发式之前先认 todo 家族并归一成 TodoWrite。红测 apps/daemon/tests/acp-todo-tool-name.test.ts(4 条,喂 vela 真实帧形状:只有 kind 没有 name)。ACP 全套 80 条绿 |
|
| T3 | 召回通路不存在 | ✅ 已完成 —— composeChatUserRequestForAgent(唯一同时覆盖 skipTranscript 与非 skip 两条分支的钥匙孔)在每轮请求前加一段陈述事实的短块,说明这次对话还欠着什么;渲染器放 packages/contracts 免得文件系统档与 API 档漂移,措辞不点名任何工具(API 档没有工具)。没有未完成时组合出来的正文逐字节不变。判断继续/重排/忽略仍是 agent 的事,客户端不替它决定 |
历史是 buildDaemonTranscript 拼的纯文本,只取 message.content;而 content 只累加 kind==='text'(db.ts:3060),TodoWrite 是 tool_use,永远进不了。21 家无 resume 的 runtime 结构上不可能知道上一轮有过清单 |
| T4 | 客户端 previousTodos 零调用方 |
✅ 已完成 —— ChatPane 按助手消息推出「这条消息之前对话已经声明过的清单」喂给 buildTurnBlocks。只认 agent 自己重发的条目:不重发就什么都不查、什么都不画 |
contract.ts:164 定义了,没人传。它是 T3 的下半段,单独接没用 —— agent 不重发,previous.has() 根本不会被调用 |
| T5 | isStruck 的注释写反了(代码是对的) |
✅ 已完成 —— 改的是注释不是代码。新表述:划线 =「这一条不是本轮新开的活」,三种情况独立(召回 / 作废 / 本轮开出来但没干过)。ExecutionShell.tsx 里那句复制的错注释一并改掉 |
|
| T6 | 测试洞 | ✅ 已完成 —— build-turn-blocks.test.ts 补上「召回 + 本轮继续做 → 既划线又可展开」,规格里最容易误判的那一格现在钉住了 |
|
| T7 | 〔继续未完成任务〕是死按钮 | ✅ 已接上 —— 落在「还欠着活」那条消息的轮次状态行上(那一行本来就写着「已停止,还有未完成」)。本轮没声明清单时回落到带过来的那一份,所以21 家根本不发清单的 runtime 也能用 | ProjectView.tsx:8765 实现完整、一路 prop-drill 到 AssistantMessage.tsx:566,然后没有然后 —— 全仓 onContinue= 在 chat 组件里 0 命中。origin/main 上也一样死。它是 agent 不照做时唯一的用户出口,且不依赖任何 agent 能力 |
F-3 「画出来了,没接线」(权威:run-error-catalog.md §6.U)
已接:UpgradeCard / SupportDialog / support-brand-icons / PauseLine ✅
| # | 事 | 状态 | 关键事实 |
|---|---|---|---|
| W1 | Reconnect 仍未接线 |
✅ 已接线、已合入、已真机截图比对(截图在 docs/design/chat-mirror/shots-live-reconnect/)。原作者纠正了盘点里一条过期的记载:重连的 attempt/max/phase 传输层早就在发(providers/daemon.ts:291/307/327),i18n 四个 key 19 语言包也齐 —— 「拿不到 N/M」不成立,是「铺好了没人接」。互斥做成了结构保证(暂停写在重连的 else 分支里),不靠两处判据碰巧不重叠 |
|
| W2 | AudioArtifact |
阻塞 | 卡在数据层:artifactCardKind 对 .mp3 返回 null,契约里没有波形与时长。不是接线能解决的,要产品+后端立项 |
F-4b 新照出的(2026-08-27)
| # | 事 | 关键事实 |
|---|---|---|
| R9 | 重连失败那一行和通用报错卡会同时出现 | ✅ 已修复并真机确认 —— DAEMON_STREAM_DISCONNECTED 落表且 suppressCard: true,整张卡不出;判据 isReconnectOwnedFailure() 和 ProjectView.hasGenericDisconnectFailureEvent 读同一对线索。2026-08-27 真机(杀掉 web dev server 造断线):流水尾部只有那一行「连接失败 +〔重新连接〕」,上面没有任何报错卡。AGENT_CONNECTION_DROPPED(S11)刻意没碰 —— 那是 agent 到模型服务的连接,重连行接不回来 |
| O6 | 重连循环无退避 | ✅ 已修复并真机确认 —— 五次尝试之间零 setTimeout,而连接被拒的 fetch 约 1ms 就 reject,所以整个预算在同一个 tick 里烧光:①合盖子/切 Wi-Fi 这种几秒自愈的抖动被判成不可恢复;②那一行没人看得见。现在复用 lib/backoff.ts 的 BackoffController(700ms 起、×2、封顶 8s、带抖动),这条流原本是仓里唯一漏掉退避的。红测 daemon-sse-reconnect-backoff.test.ts 两条都先红过。真机实测的相邻间隔:0.4s / 1.3s / 2.1s —— 修之前这三档全在同一帧里 |
| R10 | 按〔重新连接〕会把那一行撤掉,而重连根本没起来 | ✅ 已修复并真机确认(真机照出来的,单测照不出)。handleManualReconnect 乐观地推了一条 dropped —— 可 dropped 的语义是「本地不再跟这条流了」(切会话、卸载),按重连恰恰相反。重挂有前置条件(先要拉到这一轮的运行状态),而断线时那一条也常常拉不到,于是重挂没开始、那一行却已经没了:屏幕上只剩壳头一句「运行失败」,报错卡又被 R9 按设计压掉,用户连再点一次的入口都没有。改成新的 manual-retry 信号(什么都不做);撤那一行的唯一时机仍是 reattachDaemonRun 前一行那条 dropped |
| R11 | 结局从「别的门」进来时,重连行留成残影 | ✅ 已修复并真机确认(是 R10 修完才露出来的第二层)。settled 今天只在流上发,可这一行出现的时刻正是流断了的时刻 —— 结局往往由一次会话刷新带回来。真机上看到那一行挂在一条写着「已完成」的消息下面继续说「连接失败」。补了 settledSignalFromMessages();撤不撤仍交给 nextChatReconnectView 判(failed 对已交回给人的那一行不动,否则 22-3 那颗按钮会一闪而过) |
| R12 | 被 kill -9 的运行被判成「登录过期」 |
✅ 已修复并真机确认(真机照出来的第二个)。真跑一次 kill -9:run 落库 signal: SIGKILL / exitCode: null,分类器却给 auth / stale_profile —— 因为缓冲区里碰巧留着半句 "stale local profile",而纯文本匹配排在信号判定前面 300 行。卡于是让用户去跑 /login,修一个没坏的东西。S19 是第二大失败类(20,868/月、16.3%),等于在这个量级上贴错标签。改成强制信号(SIGKILL + 崩溃信号)先认;SIGTERM/SIGINT 不动(那两个确实伴随优雅关闭与中断,文本更有信息量),超时也仍然优先(看门狗会先写自己的理由再升级到 kill)。修完同一次 kill 给出 process_exit / signal_killed,卡面是「智能体意外退出」+〔重试〕,英文原文收进「查看详情」 |
| O8 | 读数永远走不到 5/5 | 真机 trace 是 1/5 → 2/5 → 3/5 → 4/5 →(判失败)。原因是结构性的:读数记的是「已经用掉几次」,第 5 次用掉的那一刻循环条件就不成立了,于是 5/5 只存在几微秒。要让它可读只能让它停一拍,而停一拍时其实并没有在重试 —— 是「读数记已用次数还是记在飞的那一次」的口径问题。稿子把第 83 格明写成「不是独立形态」,所以这不算违反交付稿。留给产品定,不自己改口径 |
| O7 | 重连行的 ⌄ 展开箭头故意不给 | 传输层今天把三种断法(接口/超时/服务端)走同一条路径,分不出断因。摆一颗点开什么都没有的箭头比不摆更糟 |
F-4 其他已知、暂不处置
| # | 事 | 关键事实 |
|---|---|---|
| O1 | resumable 硬写 false 两处 |
查清了:两处都不是「随手写死」,是缺输入 / 刻意 fail-closed,所以不能当接线漏改去补。 ①runtimes/runs.ts:741(daemon 重启后的对账):resumableFailure 需要 liveSessionId + 实时观察到的 committedWorkSeen,而这条路是从落盘状态重建的 —— state.json 里根本没有 sessionId 字段(全文件零命中)。要让它算得出来,得先把 sessionId 与「已产生副作用」落盘,那是一件独立的事。②ProjectView.tsx:12572(跑完了没产物):这是客户端合成的 ARTIFACT_NOT_FOUND,daemon 侧那一轮是 succeeded,压根没算过 resumable;这里写 false 是不说谎。真正的缺口在上游 —— 「跑完但没交付物」该不该给〔继续运行〕是产品裁决(§6 对 ARTIFACT_NOT_FOUND 的 report_only 口径),不是前端能自己定的 |
| O2 | 团队成员余额死胡同 | 见 §6.Y。没有账单权限的成员余额耗尽时弹窗只有一颗「暂不需要」 |
| O3 | B11 未真机验 | ✅ 真机端到端验过了(2026-08-27)。三条硬证据:①端点返回 {"ok":true,"delivered":true} 且返回的 run 仍是同一个;②那个 runId 在会话里只出现 1 次 —— 没有为这条消息新建 run(这是区分「引导」与「打断重发」的判据,不是「界面有没有变」);③模型在同一轮里回了「收到,行数表…」并照做 —— 这正是原作者明说「我证明不了」的那一环(它只能用假 claude 证明帧到达 stdin)。真实 claude --input-format stream-json 确认会消费第二个 user frame |
| O4 | e2e chat-error-card-layout.test.ts |
✅ 它关心的那件事已经在真 Chrome 上量过了(文件本身仍待 CI 跑,本机不装 playwright)。用 Emulation.setDeviceMetricsOverride 逐档收窄真页面上那张 S19 卡:420px 视口 → 卡 368px,三颗动作同一行(89+89+56),不溢出;310px 视口 → 卡 258px(已低于 274px 判据),两颗次级留在一行、主按钮〔重试〕换行且仍右对齐,overflowsCard: false、页面无横向滚动、按钮文字零裁切。也就是说窄下来是优雅换行,不是溢出。截图 shots-live-errors/s19-narrow-258px.png。另外把那个文件逐条读过一遍,确认它不会「不报错地什么都不守」:①它依赖的两个钩子 chat-run-error-card 与 data-user-action-footer 在现在的 RunErrorCard 里都在,而且 ChatPane 唯一那处调用把 dataKind="run-recovery" 写死了,所以 footer 属性恒在(它是条件属性,dataKind 一旦为空整排按钮就数不到);②断言口径和我实测的一致 —— 不许横向溢出、按钮都在卡内、允许换行(它自己的注释就写着「动作行会换行,下标不再等于那一对」,所以按具体按钮比 y 而不按下标)。本机跑不了是硬限制:配置用的是 playwright 自带 Chromium(没有 channel: 'chrome'),本机没装,而装浏览器是禁止项 |
| O5 | B41 compare 截图 | ✅ 重拍并逐格重跑过了(2026-08-27),结论是一个数都没动:diff-cells.mjs 1 84 的产出和存档的 diff.json 完全一致(属性差 103、位置差 611、未配对 1036、全干净 2)。这一轮的改动是行为层的,没有移动任何像素 —— 这本身就是要的答案。顺带把这把尺子的两处噪音标定清楚了,见下面 O9 / O10,别再拿它们当缺陷追 |
| O9 | 含球的格子,位置差全是假的 | Orb 的画布尺寸是在 useEffect 里写的(canvas.style.width = box + 'px'),而陈列页是 renderToStaticMarkup —— 不跑 JS。于是稿子那边球是静态 24px,我们这边量出来 20px,summary 高度跟着 36→32,整格往下的每个元素都错 4–5px。第 1 格 73 条位置差绝大多数是这一条派生的。产品里是对的(真机 DOM 就是 width:24px;height:24px)。含球的格:1 / 9 / 12 / 13 / 71 / 82 / 83 |
| O10 | 「未配对 1036」严重虚高 | 配对靠文字内容,于是两类合法差异会让元素永远配不上:①夹具耗时不同(稿子 18.2s vs 我们 0.7s);②增删行数用的减号不同 —— 稿子是 ASCII -,我们是 U+2212 −。后者已量过:在 .delta 用的 mono 里 + / - / − 都是 7.227px,整串 +182−58 与 +182-58 同为 50.578px —— 零像素差,不用改。 这一栏只能当线索,不能当账 |
F-2b S12「等太久没动静」
| # | 事 | 状态 |
|---|---|---|
| S12 | 等待期间一句回音都没有 | ✅ 已实现并真机确认 —— 壳带上 quietMs(上一件事之后过了多久,纯函数层从已有的秒级 tick 推,组件不自己起计时器);超过 60 秒壳头换成稿子逐字的「上游响应慢,已等 N 秒」,一有东西落下来自动退回。静默起点是传输层如实登记的「最近一帧什么时候到的」(runtime/chat/upstream-activity.ts)—— 不是事件条数,也不是事件自带的时刻,理由与真机复现见 F-8 的 ②b。startedAtMs 专为「卡在首个 token」那一格服务(每月 5,547 次,事件流里一个带时刻的事都没有)。〔停止〕那一半不新加控件 —— 输入框那颗停止键一直都在。超时判定一个字没动。 红测两份,并撤掉实现复验过。真机:第 61 秒准时出现 |
F-8 用户真机连点名的一批(2026-08-27 下午)
这一批全部是用户在自己 Chrome 里边用边指出来的,不是我扫出来的。 已修的都红测先行 + 真机复验;没修的在下面 F-9,别当成已完成。
| # | 用户原话 / 现象 | 根因 | 状态 |
|---|---|---|---|
| U1 | 「音频产物外面不要套大卡片了啊,只有一个音频的横的这个就行了呀」 | 我把音频塞进了缩略图卡壳:252px 空方框 + 卡壳自带的〔导出〕浮层压住右端时长 | ✅ 音频不再是一种 artifactCardKind,单独走 audioEntries 画裸胶囊(42px),并同时排除出文本行,免得一个文件出现两次 |
| U2 | 「这个总是有事没事就出现…只有在很上面时才出现不行吗」(回到最新) | 判据是写死的 distance > 120,同一个门槛在 ChatPane 里写了四遍 |
✅ 抽成 runtime/chat/jump-to-latest.ts:门槛按视口高度算(0.75,下限 320 上限 1200),出/收两档迟滞防闪。真机实测 200px 不出、900px 才出 |
| U3 | 「hover 不是应该变成稍微亮一点点的颜色吗…而不是一整个按钮全变成白色」 | 特异性,不是哪个组件写错:裸的 button:hover:not(:disabled) 是 (0,2,1),压过组件自己的单类 hover (0,2,0) |
✅ 两份都包进 :where() 归零。⚠️那条规则有两份拷贝(apps/web/src/styles/primitives.css 和 packages/components/src/styles.css),我先只改了前者,在真机上量出来两条规则都还在、还是白的 —— 测试现在两份一起钉 |
| U4 | 「之前不是说如果 done 之前,没有任何工具调用或 thinking 或普通文案,就不出现这个了吗?」 | B47 把 failed 和 canceled 一起留了空壳。可那条理由(壳头是唯一说得出「出事了」的地方)只对 failed 成立 —— 取消那档壳头写的是「进行中」,压在底下那行「已取消」上面 |
✅ 空壳只在 failed 时保留 |
| U5 | 「为什么还会有这种错误样式?? 你的错误卡片呢??」「设计稿里哪有这种状态行」 | .status-pill.is-error 只在「这条消息正好拥有报错卡」时才藏,于是任何历史失败轮次都把上游英文原文顶着红框戳在对话里 |
✅ error 一律不出。warning / initializing 早就按同一道理去掉了,这是漏网的一档 |
U5 顺带回答了他追问的那句「难道还有错误没有梳理并包成错误卡片的????」:
有 —— retryableAssistantMessage 只认最后一条消息,所以报错卡只给最后一轮
(和「回合状态行只在最后一轮出」是同一条产品裁决)。历史失败轮次现在由壳头那句
「运行失败」承担,上游原文归卡上的「查看详情」,不再裸奔。
F-9 ⚠️ 用户已提出、尚未修的(接手请从这里开始)
N4 已经做完了,留在表里是为了把「查到哪儿、推翻了什么」写清楚。
| # | 用户原话 / 现象 | 已查到的根因 | 下一步 |
|---|---|---|---|
| N1 | 「为什么我们已完成旁边没显示耗时??」 | 根因已定位:shellElapsed() 只从事件上的时刻推跨度(shellSpan / firstStartedAt / lastEndedAt),而很多 agent 根本不给事件打时刻 —— 真机上一条 succeeded 的 run 26 个事件里只有 9 个带时刻。可 message 自己是有 startedAt 和 endedAt 的,那才是最权威的跨度,却没人用 |
给 BuildTurnInput 补 endedAtMs(startedAtMs 我做 S12 时已经加了),让 shellElapsed 在事件推不出跨度时回落到「消息自己的起止」。注意 formatShellElapsed 有 1000ms 地板,低于 1 秒一律不显示 —— 我那条 100ms 的夹具就是这么消失的,别被它误导 |
| N2 | 「thinking 也要包裹在进行中的下拉卡片里,其次 thinking 完成后就变成普通工具调用的状态,可以下拉展开看思考细节」 | 现在 thinking 落成 ShellText{thinking:true},由 SayText 画成裸段落,一轮结束就把全文摊开。设计稿是支持他这个说法的:chat-panel-next.html 的组件 3 就叫「Thinking」,原话「Thinking 那块用的是同一副壳、里面也是 .think」,而且「Thinking」和「进行中 / 已完成 / 运行失败」并列被称作分段头 |
把 thinking 段渲染成 Foldable(头= Thinking,默认收起,展开看推理),流式中那一条除外 —— 稿子里「进行中的一行」是另一样东西(.thinking-live),它「不套框、不挂耗时」,只活到第一个字落地 |
| N3 | 「你这左右/下面的间距呢? 去看看设计稿」(截图是 question-form 的输入框,红框标着左右和下方几乎没有留白) | 未查 | 拿 <question-form> 那一格去比设计稿的内边距。记住教训:必须在浏览器里量计算样式,只 diff CSS 文本会漏掉层叠反转 |
| N4 | 「这又是啥啊,不应该是一个普通工具调用的样式吗? 创建 xxx?」(截图是 写入 design-manifesto-parchment.html 底下摊着一大块 HTML 源码预览) |
✅ 已修。不在 ToolRow / build-turn-blocks —— 那两处一直是对的(Write 跑完就是「新建 <文件名> +N −0」一行)。那块源码是 AssistantMessage 在壳外另起的一张流式代码卡 LiveCodeBox:daemon 的 tool_input_delta(claude-stream.ts:619 转 claude 的 input_json_delta)→ ProjectView 按 tool id 累成字符串 → ChatPane 穿透 → 落成 live-tool 块。所以它只在流式期间存在,tool_use 一落库就换回那一行,刷新页面也不会再出现 —— 但一份大 HTML 要流几十秒,用户看到的就是那几十秒。头上那个「写入」用的还是老键 tool.write(新链路的动词是 chat.record.verb.write =「新建」),这也是截图里的字对不上新壳的原因 |
修法:整条 liveToolInput 链路删掉(ProjectView 累料 → ChatPane 穿透 → AssistantMessage 落块 → LiveCodeBox)。依据是 D3(chat-panel-next.md:413)与 B8(:754):调用没配对结果就不出行。这推翻了接入时的临时安排(chat-panel-next.md:674 第 1 条「位置不对但能力不丢」)—— 「不丢」被 N4 明确否掉。顺带省掉一处按 token 打的 setState(每个 input_json_delta 都在 setLiveToolInput,大文件是几千次 ProjectView 重渲染)。没动的是 <artifact type="text/html"> 那条流式代码卡:它防的是半截 HTML 当 markdown 正文漏出来,不是工具调用。红测 apps/web/tests/components/chat/write-live-preview.test.tsx(4 条,含终端输出的反面守卫) |
F-10 claude 为什么写不出 todo(查清了)
不是我们限制的。 apps/daemon/src/runtimes/defs/claude.ts 给的参数里没有
--allowedTools / --disallowedTools,~/.claude/settings.json 的 deny 也是空的。
直接跑一次 daemon 用的同一条命令、抓它自己的 system/init 帧:
printf '%s' '{"type":"user","message":{"role":"user","content":[{"type":"text","text":"hi"}]}}' \
| claude -p --input-format stream-json --output-format stream-json --verbose \
| python3 -c "import sys,json;[print(len(d.get('tools',[])), 'TodoWrite' in d.get('tools',[])) for d in (json.loads(l) for l in sys.stdin) if d.get('subtype')=='init']"
结果:它声明了 68 个工具,里面没有 TodoWrite。 这台机器上装的 claude 构建
(工具表里有 Workflow / CronCreate / EnterWorktree / ScheduleWakeup 这些)
就是不带它。所以落盘的 18 条真实 TodoWrite 事件全部来自 codex。
⚠️ 我先前判断错过一次:看到有 18 条就断定 claude 在瞎说 —— 那 18 条不是它的。
教训已存记忆(feedback_dont_trust_agent_self_reported_tools 需要更正:自述不可信
成立,但反证也要看清是谁的证据)。
顺带一个 daemon 侧的缺口:claude-stream.ts:380 处理 system/init 时只留了
model 和 session_id,把 tools 数组丢掉了。所以事后完全无法从落盘数据回答
「那一轮 agent 到底有哪些工具」。要查只能像上面那样重新跑一次。
F-7 真机验到哪一步(2026-08-27)
| 面 | 结果 |
|---|---|
| S29 重连三态 | ✅ 82/83 计数、84 失败 + CTA,三次不同断法都跑过,截图在 shots-live-reconnect/ |
| R9 断线不出报错卡 | ✅ 两次独立断法都确认流水里只有那一行 |
| R10 / R11 CTA 死胡同 | ✅ 修前修后都在浏览器里走过 |
| E2 兜底文案 | ✅ 一次「没有产物」的运行照出来的 |
| S12 等太久 | ✅ 第 61 秒准时出现,截图在 shots-live-s12/ |
| S19 进程被杀 | ✅ 真 kill -9,顺带照出 R12,截图在 shots-live-errors/ |
| 报错卡窄面板 | ✅ 逐档收窄量到 258px,见 O4 |
| 组件 20 · PauseLine | ✅ 看到了。它要「手动停止 + 本轮还欠着 todo」两个条件同时成立,而本机 claude 这几轮一条 TodoWrite 都没发(落盘的真实 tool_use 全来自 codex)。走执行设置把 agent 切成 Codex CLI —— 那份配置在 worktree 本地的 .od/app-config.json,和用户自己的环境隔离 —— 跑一个五步计划,等 TodoWrite 落下来再按停止。结果:chat-pause-line 在场、写着「已手动暂停任务」,重连行不在场(互斥成立)。截图 shots-live-pause/ |
| R13 | 〔继续剩余任务〕的字整个漏在按钮外面 |
F-6 套件状态(2026-08-27 收尾实测)
| 面 | 结果 |
|---|---|
pnpm typecheck |
0 错(含 e2e 那套更严的) |
pnpm guard |
exit 0 |
@open-design/web 全量 |
700 / 700 文件、7287 条全绿。开工时是 14 条红的 —— 逐条把每个 chat 源文件 checkout 回合并前的提交复跑过,同样红 14 条,所以全是先前就欠下的,不是今天弄坏的。它们钉的都是后来被用户推翻的形态(B47 空壳、回合状态行只在最后一轮、D43 正文归属、分界线落在新会话、TodoCard 已删),逐条按现行裁决改了断言并写清依据 |
@open-design/daemon 全量 |
8743 / 8750。3 条是并行压力下的抖动(codex-session-resume / media/tasks-routes 单跑即绿),1 条是分支落后 main 104 个提交导致的:chat-project-skill-critique-label 打的是 odNext strategy recipe 那套 main 上有、分支上还没有的代码;分支内改它等于修一份 104 个提交前的快照,留给 rebase/merge main 时自然消解 |
| 真机(用户自己的 Chrome + 真 daemon) | S29 三态、R9、R10、R11、E2 兜底、S12 全部现场跑过并截图 |
F-5 这一轮学到的几条(避免重犯)
- 陈列页照不出真相有两个原因,不是一个:①少内联旧皮肤(已修);②陈列页直接 import 组件,而产品根本不渲染它 —— 后者陈列页结构上照不出,6 个组件中招。
- 先查
specs/current/再查代码。chat-panel-edge-audit.md早把cancelOrigin那条(R8)写清楚了,我却直接去 grep 代码,绕一圈还漏了两个 daemon 写入点。这个目录下有 11 份chat-panel-*。 - 验真机之前先确认跑着的进程是不是新代码。我拿一个合并前就起着的 daemon 验「引导对话」,
端点根本不存在。
tsx不热更 daemon —— 合并完必须重启 daemon,web 才是热更的。 - 看状态码不看 body 会把自己骗过去。同样是 404:Express 的「没匹配到路由」返回 HTML
(
Cannot POST /api/...),而业务的「run 不存在」返回 JSON({"error":{"code":"NOT_FOUND"}})。 我第一次只看到404就断定「路由在」,方向整个反了。判据要落在能区分两种情况的那个字节上。 - UI 上「点了有反应」不等于走了你以为的那条路。第一次 steer 测试里队列确实清空、消息确实出现, 但那是队列按普通发送流走了(数据里多出一个新 run)。分辨的判据是「有没有新建 run」, 不是「界面有没有变」。
- 量任何布局之前,先确认那个 CSS Module 的规则真的在页面里。 我两次差点把「样式没加载」
报成「实现有 bug」:①杀掉 web dev server 之后第一次挂载
Reconnect,它的 CSS chunk 取不回来, 截图里 wifi 图标有几百像素高;②HMR 循环多轮之后record.module.css从document.styleSheets里整个消失了(record_规则数从 117 掉到 0),于是summary退回display: list-item, 球和文字堆成两行。判据很便宜:遍历document.styleSheets数一下那个前缀的规则条数, 0 就说明这次测量没有意义 —— 重新加载页面再量。 - 杀掉 web dev server 会让页面在服务重启后自动 reload,React 状态全丢。要观察断线态的后续,
得在服务还没回来之前把该看的都看完;想留住状态就别用这个手法,改成在页面里替换
fetch。 - agent 自报的能力不可信。 追 PauseLine 时 claude 明说「这个会话里没有 TodoWrite 工具,
检索过也确认没有」,我差点当成产品缺陷记下来。翻落盘的 run 日志:18 条真实的
TodoWritetool_use 事件躺在那儿。它就是在瞎说。问工具有没有,去数据里数,别问模型。 - 单测绿了不等于修的是同一条路。 R12 我第一版写的红测用
errorCode: 'AGENT_SIGNAL_SIGKILL', 两条红→绿、很好看;可产品根本产不出那个形状 —— 真机kill -9落的是AGENT_EXECUTION_FAILED,信号只在status.signal上。于是"修复"在单测里成立、在浏览器里 一个字没变。造夹具之前先去看一眼真实那条记录长什么样;这条已经把真机形状钉进测试文件了。
F-11 两条待用户拍板的(2026-08-27,已查清真因,未动手)
N2 thinking 折叠 —— 用户要求与设计稿相反
设计稿(组件 3「Thinking」)写了三态,原文逐字:
| 状态 | 原文 |
|---|---|
| 进行中 | 「一扇定高的推理流,可收起,不套框」(--stream-h: 96px,上下 32px 渐隐,内容自己往上走) |
| 内容开始落地 | 「这一行让位给开场白,不再占地方」 |
| 跑完 | 「收进 7·任务进度里,是几段纯文字,不再自带折叠」 |
用户要求(2026-08-27):「thinking 完成后就变成普通工具调用的状态,可以下拉展开看思考细节」 —— 即新增一层折叠,与「不再自带折叠」直接冲突。
真因已查清(两边都同意现状是 bug,只是解法不同):
ExecutionShell.tsx:52 streaming = running && shell.thinking。thinking 期间 body 挂
.stream(限高 96px);而 build-turn-blocks.ts:483 在第一段正文或第一个工具落下时
立刻 shell.thinking = false,body 从 .stream 换成 .stack(高度 auto)——
刚才那十几段推理原地全部展开。这就是用户说的「一结束全部释放出来了」。
- A(设计稿):第一个字落地时 thinking 那几段从流里撤走,不再占位;跑完后它在执行记录内,
而记录跑完自动收起(
lifecycleOpen = running || stopped)。不新增折叠层,不制造壳中壳。 - B(用户原话):跑完后包成「Thinking」折叠行,默认收起。
状态:等用户拍板。 若选 B,必须在此标注「用户裁决覆盖设计稿组件 3 状态 3」,防止后人改回。
N5 夹心正文不缩进、不接线 —— 设计稿规则被真实数据打穿
用户提问:「如果工具调用上面就是正文,是不是不用这个缩进?」
结论与用户猜测相反:工具行缩进 22px 是设计稿要的,chat-panel-next.html:1457 与我们
record.module.css:194 逐字相同 —— padding: 5px 7px 5px 29px 配 margin-inline: -7px,
29-7=22,让每条步骤的名字落在状态点右边同一条竖线上。正文默认在 0,开场白就该贴左
(设计稿:「开头那句…不动:它是整块的开场白,不在链上」)。
真正缺的是另一半。 把真实录制(tests/fixtures/chat/claude-shop.turn0.json)喂进
AssistantMessage 渲染,壳内孩子的实际序列是:
p.think │ div.tool ×4 │ details.fold ×2 │ div.tool │ details.fold ×5 │ div.tool │ … │ p.think ×6
工具行有两种 DOM:能展开的(命令、有输出)走 Foldable → details.fold;
不能展开的(读取 / 改写 / 搜索)是 ToolRow 直接返回的 div.tool(ToolRow.tsx:40 rowClass)。
而设计稿那条「夹在两步中间的正文右移 22px + 接竖线」的规则是:
.fold ~ .think:has(~ .fold) /* 设计稿 1432;我们 record.module.css:171 —— 逐字照搬 */
只认 .fold。 用户截图里那几段正文前后全是 div.tool(改写文件),一条都匹配不上,
于是既不右移也没有竖线,全部贴最左 —— 工具行孤零零缩进,看着像「工具行莫名往右挪了一格」。
连接线规则(设计稿 1409-1410)同理,也只画在 .fold 上。
设计稿 demo 里步骤大多可展开,所以照不出来;真实产品里「读取 / 改写」这类没输出的调用占多数, 正文夹在它们中间是常态。设计稿注释写的意图是「前后都还有步骤,中间这段才该接上」—— 说的是「步骤」,不是「可折叠的步骤」。
拟修(等用户点头,视觉会变):两条选择器放宽成认两种步骤形态 ——
:is(.fold, .tool) ~ .think:has(~ :is(.fold, .tool)),连接线同理。
注意:Foldable.tsx 的文件注释早写过这个坑 ——「expandable === false 时……仍然用
<details> 而不是换成 div —— 结构一变,父层那些按嵌套层数算缩进的选择器就全部错位」。
Foldable 自己守住了,但 ToolRow 的简单形态压根没走 Foldable。
F-12 真机验收挖出来的四条(2026-08-27,已修/已定性)
① claude 的推理被空 delta 吞掉(daemon,已修)
用户:「关键它 thinking 界面没有任何反应或反馈啊」。
Claude Code 发的 thinking_delta 帧里 thinking 是空串;32 个录制里 1707 条
thinking 事件、1508 条空的(88%),真正的推理只在消息结尾那个 assistant 帧的
block.thinking 里。而 claude-stream.ts:731 收到 delta 就无条件把该消息记成
「推理已经流过了」,于是消息结尾的兜底分支(:557 的 !thinkingAlreadyStreamed)
被跳过 —— 一个字都没送出去。
后果是设计稿组件 3 那扇 96px 的推理窗口对 claude 一直是空的:壳头写「思考中」、 底下什么都没有;过了 60 秒连「思考中」都被 S12 换成「上游响应慢」。
修法:只有真的带了字的 delta 才算「流过了」。红测在
apps/daemon/tests/runtimes/claude-stream-thinking.test.ts,另配两条守卫防止修成
「流式和尾部各发一遍」。
只有 claude 中招。 扫全部录制:claude 32 run / 1707 thinking / 1508 空;
codex 11 run / 0 条 thinking(它的推理走 reasoning item 另一条路)。
两家的 text_delta 都是 0 空串 —— 坏的只有 thinking 这一条通道。
② S12 的静默计时只认带时刻的事件(web,已修)
用户:「这个是真的上游响应慢吗 还是我们的什么解析 bug 啊?」
判据是两个数相等:截图上「已等 86 秒」和总耗时 1m 26s 是同一个数 ——
说明整轮没有任何一条事件被打上时刻,静默起点只能退回轮次开头。
真机 run ab4779f8:119 条 agent 事件,只有 12 条带时刻,其余 107 条是
thinking / tool_input 增量。模型一路吐字,界面报「上游响应慢」。
修法:BuildTurnInput 新增 lastEventAtMs —— 客户端自己观察到的到达时刻,
是传输事实,不依赖任何 agent 填字段;带时刻的事件仍优先(更准),它只在更晚时接手。
这条修的是全家:规格点名 qwen / deepseek / grok-build / aider / antigravity / atomcode / qoder 都不发工具事件,原来它们全会误报。
②b 第一版取到达时刻的方式是错的(web,已修)
到达时刻这个判据没问题,取它的钥匙错了:AssistantMessage 当时写的是
useMemo(() => Date.now(), [displayEvents.length]) —— 事件条数一变就重取。
可那个数在整段流式期间根本不动。真机 run 7ed15c2f-8ea0-4e55-b7e3-e463037dd868
(1357 行落盘)壳头写着「上游响应慢,已等 156 秒」的那一刻,事件流里带时刻的事
确实停了 161.6 秒(tool_result +676.1s → tool_use +837.7s),而这 161.6 秒里
落了 126 条帧,平均 0.7 秒一条。三条原因叠在一起:
tool_input_delta在providers/daemon.ts就被岔进onToolInputDelta, 不变成AgentEvent,不进message.events。它是这条 run 里 1346 条 agent 帧 中的 699 条,更是那个窗口里 126 条中的 124 条;- claude 的
thinking_delta一律空串(这条 run 414/414 条delta: ""),appendBufferedAgentDeltas的if (thinkingDelta)把空串挡掉,事件数组 连引用都不换,连useMemo(…, [events])都不会重算; - 就算带了字,连续的
text/thinking会被appendCoalescedAgentEvent合进最后一条,长度同样不涨 —— 所以这条对 plain-stream 那批也不成立。
同一份 run 里这种窗口有四个:305.2s / 87.6s / 161.6s / 70.9s,而四个窗口里的 真实最大静默只有 2.3s / 2.2s / 73.6s / 2.1s ——「上游真的慢」在这一轮里只发生过 一次,却报了四次。
修法:静默起点改由传输层如实登记 —— runtime/chat/upstream-activity.ts 按 run
记「最近一帧什么时候到的」,providers/daemon.ts 在 sawRunEvent 那一支叫它
(keepalive 注释帧不叫:那是我们自己的心跳,证不出上游在干活),
AssistantMessage 在既有的每秒 tick 上与 nowMs 同刻取一次。
登记不到就不传,让 shellQuiet 退回轮次开头 ——「卡在首个 token」那一档要的正是这个。
红测 apps/web/tests/components/chat/s12-upstream-alive.test.tsx:真实传输层 +
真实 AssistantMessage,帧形状逐字抄自上面那份落盘。正负成对(帧在落 → 不报;
真空 73.6 秒 → 报;帧回来 → 自己退回;只有 keepalive → 照报;一帧没来 → 从轮次开头算),
四条 ablation 各自只红该红的那几条。
③ codex 写文件不落行(daemon,已修)
codex 发的 item.started / item.completed 里 type: "file_change" 整条没被解析,
以 raw 丢掉 —— 所以 codex 写文件从来不出「新建 xxx.html」那一行,而同样的活
claude 会出。39 个录制里 5 个中招;顺带确认 file_change 是唯一还没解析的 raw 形状。
分支 fix/codex-file-change-rows。
④ 三条 todo 划线且不能展开 —— 不是 bug,是如实反映
用户:「为啥这里 claude 的这几个 todo 都没法展开啊?好像啥内容没有就直接下一个了?」
规格把划线定义成「这一条不是本轮新开的活」,三种情况之一是
「③ 本轮开出来但一次都没干过(已关闭且名下无内容,D35)」。核对 recalled=false,
走的就是③。
数据完全对上 —— claude 把活全在第一条 todo 底下干完,再 2 秒内把后三条从
pending 直接翻成 completed,中间从没进过 in_progress:
10:25:08 锁定 Swiss minimal → in_progress
10:27:26 ~ 10:29:28 全部 Bash(内容因此都归到这一条,4m 34s + 箭头)
10:29:31 锁定 → completed,同一秒 撰写正文 → completed
10:29:32 构建页眉 → completed
10:29:33 组装正文版式 → completed
所以「没内容 + 不能展开 + 划线」三个信号说的是同一件事:这三步没有单独做过。 要追的是 agent 为什么这么用 todo,不是前端。
顺带记一条工程判据
pnpm typecheck 绿不代表 apps/daemon 能构建 —— build 走的是另一份
带 noUncheckedIndexedAccess 的 tsconfig。这一轮合完分支后 web 全量 7351 条全绿、
根 typecheck 干净,而 tools-dev 起不来,因为 daemon build 挂了 6 条
(正则捕获组解构成 string | undefined)。改过 apps/daemon/src/** 必须单跑
pnpm --filter @open-design/daemon build。
F-13 真机端到端验收结果(2026-08-27,浏览器 + 真 daemon)
运行时:tools-dev run web --daemon-port 17456 --web-port 17573,worktree od-wt-chat-panel。
截图存于 /tmp/od-shots/。
已在真机上眼见为实
| 项 | 证据 |
|---|---|
| 产物卡出现时机 | 「你好」那一轮:正文 → 「已完成 09:28」→ 下一步建议,产物大卡不再出现 |
| 壳头耗时 | 已完成 13s / 进行中 26s / 进行中 1m 14s 均正常走秒 |
| thinking 折叠(N2) | 9 个「思考过程」折叠行,默认收起,点开读得到完整推理 |
| 表单间距(N3) | 量到左右各 11px、下划线到底栏 16px —— 与稿子逐字一致(原来是 1px / 0px) |
| 回到最新 | 短会话 canScroll:false 时按钮不出现;长会话可滚时正常出现 |
| claude todo | TaskCreate=31 → 合成 4 个 TodoWrite 快照 → 界面渲染「执行计划 · 4 步」+ 四条清单行 |
| #39 中断状态行 | 文案「已手动停止」✅ 颜色 rgb(92,92,92) = --text-muted ✅ 只有「继续剩余任务 / 从这里分叉」、无赞踩 ✅ 秒数按 W4 停在 1m 14s |
| BYOK | OpenRouter 配好后「已连接。2119 毫秒响应 — 'ok'」;真跑一轮走 byok-opencode,正常出文本与 usage |
| 错误卡 | dsh 失败时正常呈现:红色标题 + 说明 + 查看详情 + 联系支持/导出日志/重试 |
单测验过但真机未触发(如实记录)
- S12 静默计时修复:真机跑到
1m 14s时头仍是「进行中」,但那是因为工具事件本来就带时刻、 一直在刷新计时 —— 没有验到修复本身(它管的是「事件一直来但都不带时刻」)。 需要一轮 >60s 的纯 thinking 才能复现,无法按需构造。 - claude 跨回合 todo id 错位修复:这一轮
TaskUpdate=0(在它推进前就手动停了),没触发。
真机挖出的新问题
- claude 的推理对当前世代模型根本不下发 —— 对照实验:
haiku→thinking_delta=15 带内容=14,尾部 block 441 字;opus→thinking_delta=0,尾部 block 也没有。 历史录制同样双峰:5 个有内容的 run 全部是model: haiku,其余default全空。 所以 opus/sonnet 下推理窗口是空的属于上游行为,不是我们吞了。 (claude-stream.ts那条「空 delta 不算流过」的修复方向仍正确,救的是 haiku 那种「delta 空、尾部有内容」的形态,留着。) - 紧凑 chip 在 BYOK 下念错执行者(已修):
aria-label是「Claude Code · gemini-2.5-flash」, 实际跑的是byok-opencode。非紧凑那支chipPrimary早就按config.mode分了岔, 紧凑那支chipAgentLabel没有。可见文字只有模型名,所以只有读屏用户被念错。 - 错误卡承诺了原始报错却不给(已派单):卡上写「原始报错收在下面的『查看详情』里」,
展开只有
DSH_PROFILE_MISSING_RESULT和几个 id;而 stderr 上那句credentials-local: the value for "version" in ~/.dsh/.credentials.yaml must be a string已被 daemon 捕获并落盘,只是从没露出来。 - 本机 dsh 环境坏了(非 OD 问题):
~/.dsh/.credentials.yaml的 schema 与 dsh 0.1.0-rc.6 对不上(version要字符串,改完refs又要字符串)。 已还原用户文件不动,留给用户决定是否重建凭证。
daemon 全量测试判据
3 条失败:project-upload-filenames 与 run-failure-telemetry-smoke 单跑即绿(并行抖动);
chat-project-skill-critique-label 是已知的分支落后 main 导致,与本轮改动无关。
F-14 第二轮真机验收(2026-08-27 下午,合并六个 subagent 分支之后)
运行时重启过(daemon 走 tsx 不热更,不重启验的是旧代码)。截图在 /tmp/od-shots/。
滚动跟随 —— 三条全部真机验通
用户原话两条:「这个总是在不该出现的时候出现..感觉稳定性太差了」+ 「agent 在快速流式输出内容时,每次输出就会自动回到最底,整个对话框连向上滚动都不行」。
| 行为 | 实测证据 |
|---|---|
| 流式中往上滚 → 位置钉住 | 停在 scrollTop=0,30 秒流式输出期间一次都没被拽回(修复前每个 chunk 都拽回底部) |
| 滚不动 → 浮标不出 | canScroll:false 时 .chat-jump-btn 挂载但不带 -active |
| 距离不够 → 浮标不出 | 离底 363px < 门槛 406px(clamp(clientHeight×0.75, 320, 1200)),不亮;涨到 423px 才亮 |
| 点浮标 → 落底且跟随恢复 | 点击后 dist=0、浮标自收;随后内容 964→1025(+61),scrollTop 424→484(+61),离底恒为 0 |
根因(subagent 查出,值得记):跟随状态从位置推导,而跟随动作又把位置写回底部 —— 闭环。40px 的触控板轻扫还没累积过 80px 阈值,下一个 chunk 就把它拽回去了。 不是阈值调参问题,任何阈值都不成立,因为被测量的量正被它所控制的动作覆写。
顺带纠正一条我给出的错误建议:我让它考虑 overflow-anchor: none,恰恰不能关 ——
原生 scroll anchoring 正是「上方内容重排时保持阅读位置」的机制;要做的是别把它
误读成用户滚动(判据:scrollHeight 不变才算用户动作)。
未能真机验到的(如实记录)
- 真实滚轮事件穿不过 open-browser-use 的桥接(
Input.dispatchMouseEvent的mouseWheel不产生滚动)。上面「位置钉住」和「跟随恢复」都是用程序化滚动 + 真实点击验的;纯手势路径(触控板/触屏)只有单测覆盖,没有真机证据。 - Upgrade 按钮的绿墨:规则层已验(消融验红、typecheck 绿),但需要一个余额为 0 的会话才看得见,这一轮没构造出来。机制本身是早先真机量到白色坐实的。
工程判据(第二次踩,已记忆)
合入动过 packages/contracts/src/** 的分支后必须重建它的 dist,否则:
typecheck 报「类型不存在」、运行时报「不是函数」。第二次尤其骗人 ——
git 报 0 冲突、合并语义我也核对过是对的,结果 8 条测试红,差点回滚一个正确的合并。
F-15 思考是壳内的一个条目,不是壳的一种形态(2026-08-27 用户裁决)
⚠️ 本节「缩进」那一小段当天晚些时候被 §F-18 推翻,别照它改。 本节把顶层的思考那一格补到 22px(和当时的顶层工具行对齐),并把它从步骤链上 排除掉(
:not(.thoughts))。用户随后指出顶层那 22px 本身就是错的 (「todo 外面的工具调用应该也没这个缩进吧?」),交付稿也证实顶层只有第 0 列 —— 于是顶层三种行一起挪回 0,思考那一格回到链上。现行口径以 §F-18 为准。 本节其余部分(思考是壳内的一个条目、两态同一只 15px 图标槽、跑完收起、 真实数据与estimated_tokens的调查)照旧有效。
用户原话(三条,先后到达)
诶卧槽这个思考中的怎么把原本的进行中卡片给顶掉了卧槽.. 这个思考中的我理解应该是嵌入到进入中卡片呢? 它就算做一个普通的文本或者工具调用, 只不过有一个特殊的动画和样式啊, 绝不能 thinking 的时候直接把进行中或原本的东西给替换了啊!! 然后 thinking 完了之后, 怎么没像创建\编辑之类的调用那样, 把超长的内容收起来??? 全摊开了.. 太长了..
我说的思考完之后, 不是这个绿的, 就变成普通的这个搜索一样的东西, 只不过可以下拉展开, 它的缩进看是否在 todo 或者前面是否是普通文案动态决定缩进是多少, 你可以给这个加一个 brain 的 icon
思考中的时候, 最好是能有现在那个动画加思考中的文案, 然后下面文字也是要滚动的, 思考完就收起变成 toolrow, 但思考中动画啥的, 这些都是和 toolrow 所在地方同样的缩进逻辑
裁决
思考在壳里占一个条目,和工具行平级,两种形态:
| 形态 | 长什么样 | 缩进 |
|---|---|---|
| 思考中 | 球(composing)+ 扫光「思考中」+ 三个点;正文走 96px 限高窗、自己一行行往上走 |
与同位置的 ToolRow 一致 |
| 思考完 | brain 图标 + 「思考过程」一行,默认收起,点开读细节;不画绿勾 | 同上,两态之间左边缘不跳 |
壳 body 永远是 .stack;壳头永远说「进行中」。
D46 判定:规格没写错,是实现读错了
chat-panel-next.md:445 的 D46 原文是「落点仍在执行记录壳内(D29 不变),即壳内的思考正文区
限高 + 自动贴底」——「壳内的思考正文区」本来就指壳里那一段推理,不是整只壳 body。
实现(ExecutionShell.tsx 的 streaming = running && shell.thinking)把它套在了整只 body 上,
而那个值同时还关掉了分组、还挂了整只 body 的自动滚动,三件事一起作用在整张壳上。
所以 D46 不需要推翻,只需要按原文落对;本轮新增的是它没写过的部分:
思考块自己的身份(头部动画 + 文案)、与 ToolRow 对齐的缩进、跑完收起。
chat-panel-next.md 的 D46 行已加指针指向本节。
真实数据(问题二的证据,不是构造夹具)
本机 .od/runs 共 63 条真实 run(2026-08-26~27,即用户当时在跑的那个工作树),
其中 34 条带 thinking_delta,合计 15,905 帧:
| agent / model | 运行数 | thinking 帧 | 非空帧 | 推理字数 |
|---|---|---|---|---|
| amr | 1 | 12,763 | 12,231 | 42,097 |
| claude · default | 14 | 1,786 | 0 | 0 |
| claude · haiku | 4 | 132 | 132 | 3,630 |
| claude · 未记录 | 15 | 1,224 | 67 | 2,166 |
两个结论:
- claude(default)的 thinking 全是空串 —— 与仓库内真实录制夹具
claude-shop.turn0.json(247/247 空)一致。所以「思考中」这个态不能以有没有文字为前提, 否则 claude 思考几十秒界面上一点反应都没有。 - 推理文字是真的会很长 —— run
0161ef44(agent=amr)一轮 42,397 字。 把它喂进渲染实测:38,064 字铺在屏幕上(全在那条 in_progress 的 todo 抽屉里), 只有 4,333 字被顶层的「思考过程」收住。
真因:ExecutionShell 只对 shell.items 调 groupThinking,TodoRow 直接
segment.items.map(renderItem) —— 抽屉里那一摞从来没分过组。本轮有清单时推理就落进
当前 todo(build-turn-blocks 的 sink()),于是一个字都收不起来。
夹具 apps/web/tests/fixtures/chat/amr-thinking-todo.turn0.json 就是那条 run,
只把连续的 thinking delta 拼起来(buildTurnBlocks 本来就会拼,已逐字节验证落块结果相同),
其余事件原样。
estimated_tokens 走不通(查过,如实记录)
上游 claude 的空 thinking 帧带 {"thinking":"","estimated_tokens":null},曾被提议当进度量。
实测不可用:本机 100+ 条真实 run 日志里 estimated_tokens 出现 0 次 ——
claude-stream.ts 只读 delta.thinking 并发 {type:'thinking_delta', delta},
这个字段在 daemon 边界就被丢掉了,从未进入持久化事件流;代码库里也搜不到任何引用。
要用它得新开 daemon → contracts → web 一整条字段,而唯一观察到的上游取值还是 null。
本轮不做,不拿安慰剂顶替真实进度。claude 的「有反应」由思考块自己的动画承担
(球 + 扫光 + 三个点,一个字都没有也照转)。
2026-09-04 更正:上面这段结论对了一半,量错了地方 —— 现已落地。
那次量的是
thinking_delta载荷里那个estimated_tokens,对那个字段结论完全成立: 每个 thinking 块的收尾帧必然是null,而且非 null 时它是每帧增量不是累计 (partial-single-turn第二块:累计 50/150/300/450,同批 delta 报 50/100/150/150), 不开--include-partial-messages时更是一帧都不存在。漏看的是另一种帧:CLI 还单发
{"type":"system","subtype":"thinking_tokens","estimated_tokens":N,"estimated_tokens_delta":N}。 六份真实录制里 36 帧全部非空,两种 CLI 配置下都在 —— 而claude-stream.ts的handleObject认得system/init与system/status, 唯独这一种直接落到底,所以它从来没进过日志,「日志里 0 次」量到的正是这个。 语料与判据钉在apps/daemon/tests/runtimes/w134-thinking-token-count.test.ts。落地形态:思考行右边那个槽(
ThoughtsRow),一个槽、一个数, 报此刻还活着的那件事 —— token 在动就写 token,停够 8 秒就把槽让给计时; 头一格没有计时可让(今天刚因「与壳头重复」收走),所以那一格恒为 token。 不是安慰剂:这个数恰恰在正文永远不会来的那一档存在(真机 CLI 2.1.260: 3060 个计费 token、0 个字符)。球 + 扫光 + 三个点一件没动。⚠️ 同一份录制里还有第三个数
usage.output_tokens_details.thinking_tokens—— 那是结算值,块跑完才有,而且比估算值低 20–60%。屏幕上要的是块跑着时的进度, 那一刻结算值不存在;等它到了,那一格也收掉了。两个数不可互换。
2026-09-06 更正:上面「那一格恒为 token」这句话的前提塌了 —— 现已落地。
写下那句话时的默认是「头一格没有计时可让」。它在两处不成立:
· AMR 没有 token 可恒。
thinking_tokens全仓只有两个生产者 (runtimes/claude-stream.ts的 claude、agent-protocol/codex-app-server/normalize.ts的 codex);ACP 那条路(agent-protocol/acp/session.ts)一个都不发。 实测本机 beta 数据目录 25 条agentId=amr的 run,该事件出现 0 次 (同期 claude run 每轮 22–2537 次)。于是那一格两半都空:token 没有,计时被压着。 · claude 自己在收尾之后也拿不到。 token 只挂给还活着的那一格 (group-thinking.ts的if (live)),build-turn-blocks更是只发给block.thinking === true的壳。回合一结束两者同时为假,头一格退回空槽。用户 2026-09-06 逐字裁决:「即使是第一个 thinking,思考过程中不显示耗时, 但结束还是要显示的吧?」—— 思考进行中不显示耗时(维持现状),但收尾之后要显示。
这不是推翻 2026-09-04,是给它补上到期时刻。 收走计时的理由是它和壳头 「同起同终、写的是同一个事实」,而那只在这一格还在流的时候成立:一旦下一件 带时刻的事把它结账(
build-turn-blocks的stamp()→closeThink(at)), 这一格的数字就冻住,壳头那个数继续走到轮次收尾 —— 两个数当场分叉,「重复」消失。落地形态:
ThoughtsRow的const elapsed = muted && live ? '' : formatElapsed(elapsedMs)。 判据live是这一格自己的相位(groupThinking只发给结尾那一格),和同一个组件里 决定「球 + 思考中」还是「brain + 思考过程」的是同一只开关 —— 数字和形态同一帧翻面, 不会出现「已经写着思考过程、右边还空着」。token 那条规则一个字没改:两者相位不重叠(想完的那一刻
tokens已是null), 所以一格之内只切换一次、方向单向,不会在 token 和秒数之间来回闪。⚠️ 用户同一轮报的另一条「AMR 没有 token 输出计数」没有修:裁决明说进行中维持现状, 而它的真因是 ACP 不发
thinking_tokens(daemon/协议侧的缺口)。在渲染层拿耗时去顶 token 的位置等于替产品决定「AMR 的思考中改成显示秒数」,没有这条裁决不许自造。判据:
tests/components/chat/first-thoughts-no-elapsed.test.tsx(进行中仍然空白)、tests/components/chat/amr-thinking-slot-blank.test.tsx(想完之后要有数,含 AMR 那一档 维持空白的守卫)、tests/components/chat/thinking-token-count.test.tsx(token 规则未动)。
缩进:在真 Chrome 里量的(不是只 diff CSS 文本)
harness 做法:把组件真实渲染出的 DOM(CSS Module 类名去哈希)+ record.module.css
ChatRoot.module.css的 token 拼成一张静态页,起python3 -m http.server用真 Chrome 打开, 读getBoundingClientRect()与getComputedStyle。
行首图标的 x(相对壳左边缘):
| 场景 | ToolRow | 思考块(修前) | 思考块(修后) |
|---|---|---|---|
| 顶层 · 思考中 | 22 | −3 | 22 |
| 顶层 · 思考完 | 22 | 0 | 22 |
| todo 抽屉 · 思考中 | 22 | 19 | 22 |
| todo 抽屉 · 思考完 | 22 | 22 | 22 |
两处修:
.fold.flat > .body.stack > .fold.thoughts > summary { padding-inline-start: 29px }—— 顶层折叠头默认只有 7px(图标落 0),而同层div.tool是-7 + 29(图标落 22)。 只补顶层;抽屉那一层两者同为 29px,本来就齐,再补会推出去。- 思考中的球塞进和 brain 图标同一只
.icon(15px、place-items:center)。 球自带margin-inline:-3px,直接摆在 summary 里会让思考一结束整行横跳 3px。
反向对照(证明 22px 不是写死的常量):
| 场景 | 夹心正文 padding-inline-start |
竖线 |
|---|---|---|
| 工具行 → 正文 → 工具行 | 22px | 画 |
| 工具行 → 正文 → 思考块 | 22px | 画(思考块照旧当「步骤」括住正文,没退化) |
| 正文打头,前面没东西 | 0px | 不画 |
| 有清单的壳,顶层正文 | 0px | 不画 |
另外把思考块排除出步骤之间那条竖线(:not(.thoughts)):那条线画在 14.5px,
是按「折叠头贴左」算的;思考块缩到 22px 之后它会孤零零落在图标左边。工具行本来就不画这条线。
真 Chrome 验过:思考块(非末位)content:none;普通折叠(非末位)照旧
content:"" / rgb(216,216,216) / 14.5px。
已知残留(未修,列出来)
.tool的图标→文字间距是gap:8px,.fold > summary是7px,于是文字左边缘差 1px (45 vs 44)。这是.tool与所有折叠行之间既有的差,命令工具行同样如此, 不是本轮引入;改它会动到壳里每一行,另开一条。
F-16 逐字化开铺开到所有正文,滚动节奏改成跟着到达速率走(2026-08-27 用户裁决)
用户原话(逐字,两条)
这个下面滚动的文本的缩进感觉不对呢, 没对齐... 而且滚动速度是不是要动态一下啊, 现在固定的感觉不太对, ds-v4-flash thinking 输出的非常快的..
能不能 thinking 还是用有个流式输出的效果, 你找一下, 设计稿 就是后面出来的文本有个 动态模糊再逐渐清晰的效果, 包括我们所有普通文本, 都应该有这个流式输出的效果才对, 不能直接刷一下子整个出来..
如果不是真正流式的, 可能背后 daemon 还是一次性出来, 但展示的时候, 也保留一个流式的 效果, 可能完整走完这个流式输出效果 2s 左右的动画, 加速一下
设计稿里那个效果的完整规格(交付稿 1bbdce0b06,md5 28ea4c65…)
出处:1605–1631 行(CSS + 那段注释)、6686–6730 行(拆字脚本 + 注释)、3656 行(思考流的出处)。
照搬 motion-primitives 的 TextEffect(per='char')(@ibelick,21st.dev)的进场那一半:
| 项 | 值 | 稿子原话 / 位置 |
|---|---|---|
| 单字时长 | 0.4s var(--ease-out),animation-fill-mode: both |
1622–1624 行 |
| 字间错开 | --i * 0.01s = 10ms |
1624 行,--i 由拆字脚本写 |
| 起 | opacity: 0; filter: blur(10px) brightness(0%) |
1627 行 |
| 止 | opacity: 1; filter: blur(0px) brightness(100%) |
1628 行 |
| 为什么是 brightness | 「配着 blur 一起,字是从一团化不开的墨里浮出来,而不是单纯地由淡变浓」 | 1609–1610 行 |
| 只进不退 | 「这段话是这一轮交给人的结论,出来了就该一直在」 | 1611 行 |
| 拆字粒度 | 元素子节点整块算一个单位,不钻进去拆(拉丁词不能被劈开) | 6702–6706 行 |
| 空白 | 不裹 —— 包成 inline-block 会吃掉断行位置 | 1614–1616 行 / 6722 行 |
| reduced-motion | 全局 * { animation-duration: 0.01ms !important }(489–490 行)+ fill-mode: both ⇒ 直接停在末帧,是完整的一段字不是空白 |
1617–1618 行 |
不做 startOnView |
上游有这个选项,稿子试过后撤掉:观察器不回调时整段停在 opacity:0,一片空白 |
1619–1626 行 |
| 一段五十来字 | 约 0.9s 化完(0.5s 错开 + 0.4s 最后一个字) | 1607–1608 行 |
稿子把这个效果只挂在 .say 上(4494 / 4961 / 4965 行三处 data-reveal),
思考流那一段 .think 没有挂。「思考也要有」是用户这一轮新加的,不是稿子里的。
稿子没有「整段总时长」这个量。 0.4s 是单字时长、10ms 是字间隔,两者都不封顶整段。 一段 34,731 字按 10ms 排要 347 秒。用户给的 2s 是总时长目标,和稿子的 0.4s 不是一回事。
实现:预算封顶单位数,而不是压间隔
useCharReveal.ts:MAX_UNITS = (2000 - 400) / 10 + 1 = 161。
一批待化开的字先按 161 封顶,装不下就把单位加粗(一个 span 装多个字)。于是
- 错开值永远是稿子的 10ms、单字时长永远是稿子的 0.4s —— 两个原值一个没动
- 总时长
(units - 1) × 10ms + 400ms天然 ≤ 2000ms - 五十来字的一句话仍然是
49 × 10 + 400 = 890ms,和稿子那句「约 0.9s」对得上 - DOM 一次最多 161 个 span,不是一字一个;同时在开的只有
400 / 10 = 40个
多字单位用 display: inline 而不是稿子的 inline-block。 稿子那句
「inline-block 是 filter 生效的前提」在单字下成立,但一个装 216 字的 inline-block
是一整块不可断的长条,拉丁正文会被它顶出行外。真 Chrome 量过:filter 在
display: inline 上照样解析(blur(4px) brightness(0))、照样立层叠上下文,
而且 span 会正常裂成多个行框(getClientRects().length === 6)。单字单位仍走 inline-block。
铺开到哪几处
| 位置 | 挂点 | 判据 |
|---|---|---|
| 壳外正文(助手的回答) | AssistantMessage 的 proseRef |
isLastAssistant && streaming(本来就有,这一轮修好了它) |
| 思考流 | ThoughtsRow 的 bodyRef(整只 body) |
live |
| 壳内过程叙述 | SayText 的整只 .think 容器 |
这一摞的最后一个 text 条目,且这一轮在跑 |
叙述那边挂在最后一段 2026-09-03 改成整只容器。原来的写法(挂最后一段)
在纯文本下成立:新起一段时那只 <p> 上<p> 是全新元素,自己从头化开,不必交接。
壳内文字改走 markdown 之后这个前提没了 —— 块树的元素会换身份(# 再来一个字就从
<p> 变成 <h2>),每换一次 useCharReveal 按元素记的状态就丢一次,已经看过的字
被当成新字重放。挂到容器上则整块共用一份 shown,和思考流那边同一种挂法。
判据见 apps/web/tests/components/chat/say-text-markdown.test.tsx。
修前的真实基线(无头 Chrome 量的,不是推测)
| 场景 | 修前 .rv span |
修后 |
|---|---|---|
| 一次性到达 2,000 字(壳外正文) | 0(整段瞬间刷出来 —— 用户说的那一下) | 161 |
| 每帧 10 字增量(壳外正文) | 10 | 10 |
| 思考流增长 | 0(从来没接过) | 有 |
真因:useCharReveal 里 const added = !sameNode ? 0 : … —— 第一次见到一个文本节点时
一个字都不化开。原注释写着这是故意的(「整段一起闪更糟」),而有了 2s 预算之后,
一次性到达不再是「一起闪」,是 2s 内铺完,所以这条前提没了。
性能(无头 Chrome,--headless=new,120Hz,同一次会话内对照)
| 场景 | 峰值 span | 帧 p50 | 帧 p95 | >20ms | >33ms |
|---|---|---|---|---|---|
| 空转基线(什么都不动) | — | 8.3ms | 8.7ms | 0 | 0 |
| 中文 34,731 字一次性 | 161(不是 34,731) | 8.3ms | 8.7ms | 1 | 0 |
| 英文 34,731 字一次性 | 161 | 8.3ms | 8.7ms | 1 | 1 |
| 8,000 字/秒 流式到 34,731 字 | 134 | 8.3ms | 9.1ms | 0 | 0 |
| 同上 + 思考限高窗 + 自动滚 一起跑 | 134 | 8.3ms | 8.9ms | 0 | 0 |
那唯一一帧(24–41ms,跑几次在这个区间浮动)落在 React 第一次把 34,731 字写进 DOM 的那一帧上,不是化开造成的(把化开关掉那一帧照样在)。
⚠️ 只有英文照得出来的两条(中文一路测不出)
中文没有空格,而这两条都只在有空白时发生。是拿英文在无头 Chrome 上跑 34,731 字才照出来的:
-
空白被复制一份。 单字单位时空白按稿子的规矩裸着放(不进 span),而
restore()原来只querySelectorAll('[data-char-reveal]')扫 span —— 裸空白删不掉, 再把节点还原成完整值,空白就多了一份。实测收尾字数 40,600 / 34,731, 多出来的 5,869 正好是空格数。 修:Touched记一份inserted名单(span 和裸空白),按名单删。 -
span 按「词」爆掉。 按空白切单位的话,英文 34,731 字拆出 5,976 个 span (≈词数),预算白封 —— 帧 p95 从 8.7ms 掉到 83.3ms,29 帧超过 33ms,肉眼可见地卡。 修:单位不止一个字时,空白进 span。多字单位本来就走
display: inline, inline 盒子自己会裂成多个行框(真 Chrome 量过getClientRects().length === 6), 断行位置一个都不少;单字单位仍照稿子裸着放。 修后 161 个 span、p95 8.7ms、p.scrollWidth > p.clientWidth为false(不撑出列)。
两条都有测钉着(blur-stream-reveal.test.tsx §8),并各配了一条「同长度的中文文本
本来就不会出这个错」的反向对照。
空白有两条代码路径,要分开钉:多字单位(一次性大段)空白进 span;
单字单位(小增量流式,一个词一个词地来)空白裸着放在 span 外面。
第一版只测了前者 —— 消融时把 restore() 那份 inserted 名单撤掉仍然全绿,
因为多字单位那一路根本没有裸空白。补了一条「一个词一个词地流进来」的用例之后,
撤掉名单当场红。消融照出了测试的洞,不是照出了实现的洞。
动画中途量到的分布:153 个 span 里有 42 个不同的 filter 值,
从 blur(10px) brightness(0) 到 blur(0px) brightness(1) —— 与「同时在开 40 个」的预测一致。
reduced-motion 实测(Chrome --force-prefers-reduced-motion):
.rv span 0 个、文字一个不少(1218/1218)、思考窗 mask: none / 高度放开(96 → 102)
/ scrollTop 恒为 0(自动滚也停了)。三处降级一次量全。
开完就撒手:useCharReveal 到点自己 restore(),不等下一次重渲染。
Chrome 实测动画结束后 .rv span 归零、filter 集合为空、字数一个不少(306/306)。
不做这一步的话,流最后一批字落地后可能一次渲染都没有,161 个 span 会一直挂着 filter。
滚动节奏:2000 从定值降级成上界
原来 STEP_MS = 2000 / MOVE_MS = 550 是写死的,一字不差抄自交付稿的 stream()。
这两个数没写错 —— 稿子自己的注释说得很清楚(6482–6489 行):
它是【演示】:真实的 Thinking 是模型边吐边把内容顶上去,不会回到顶重来。 稿子里没有真的在吐字的模型,只能拿一段写死的推理循环滚。
2000 是配着一段不再增长的死文本挑的节奏。真机上文字一直在长,节奏必须跟着到达速率走。
planStep(growthPxPerSec, linePx):
| 量 | 值 | 理由 |
|---|---|---|
| 上界 | STEP_MS = 2000 |
交付稿原值。慢的时候一步不差还是稿子的样子 |
| 下界 | MIN_STEP_MS = 300 |
走的那一段 = 300 × 0.275 ≈ 83ms ≈ 60fps 下 5 帧,再短就不是「滑过去」是「跳一下」;剩下 217ms 是一行字站定被读到的下限 |
| 走停比 | MOVE_RATIO = 550 / 2000 |
每一档速度都保持它 —— 变的是快慢,不是「走一下停一下」这个形态 |
| 一步几行 | 1…MAX_STEP_LINES = 3 |
夹到下界还追不上时才加距离。上限 3 是因为窗口只有 96px(≈5 行),稿子拒绝「按段走」的理由是「一跳半屏就没了连续感」,3/5 已经贴着那条线 |
先试过 MIN_STEP_MS = 500:1 行/秒 到 3 行/秒 整段被夹平成同一个值,而那正好是
ds-v4-flash 所在的区间 —— 夹平等于没做动态。thinking-stream-tempo.test.ts 里
「随速率连续变」那条反向对照就是钉这件事的。
到达速率用 scrollHeight 的增量做指数平滑(RATE_TAU_MS = 1200),
换档只发生在步与步之间 —— 步进行到一半改 stepMs,缓动会当场跳一下。
真 Chrome 量的有效滚动速度(同一只窗、同一次会话):
| 到达情况 | 有效滚动速度 | 对照 |
|---|---|---|
| 内容不再增长 | 7.7 px/s | ≈ 交付稿的「一行 / 2s」(18.6px ÷ 2s = 9.3,差的是回顶折返不计正向推进) |
| 每 120ms 追加一段(模拟 ds-v4-flash) | 166.2 px/s | 21×,已贴着上限(3 行 × 18.6px ÷ 0.3s = 186 px/s) |
注意最后那一行的含义:那一档里内容以 ≈271 px/s 在长(1897px / 7s), 而节奏已经打满 —— 也就是说到了极端速率,跑马灯即使跑到最快也追不上。 这不是参数没调好,是「一步一停 + 到底回顶」这个形态本身的天花板,见下面那条待拍板。
⚠️ 待产品拍板:这个跑马灯滚动还该不该存在
没有动手,按裁决权归属留给产品。 摆出来的三条:
- 稿子自己说它是演示的替身。 6482 行:「真实的 Thinking 是模型边吐边把内容顶上去, 不会回到顶重来」。我们现在做的「到底了回到顶」正是稿子说真机不会有的那个行为。
- 它和逐字化开在说同一句话。 稿子撤掉行尾光标的理由(1633–1643 行)是 「这一屏已经有两处在说同一件事…文字本身也是一个字一个字浮出来的。第三个记号不添新信息」。 同一条推理对跑马灯同样成立:化开已经表达了「还在写」。
- 动态节奏只能缓解,不能治本。 循环回顶意味着窗口周期性地退回去展示旧文本;
跟不跟得上到达速率是另一回事。真要「跟得上」,正确形态是贴着最新内容走(日志尾巴那种),
那时节奏自动等于到达速率,不需要
planStep。
建议形态:思考期间贴底跟随最新推理,停止到达后再恢复(或直接不动)。
真要改的话 useThinkingStream 改动很小,但这是产品决定。
F-17 思考正文的左缘 + 思考过程展开限高(2026-08-27 用户裁决)
用户原话(逐字,两条)
这个下面滚动的文本的缩进感觉不对呢, 没对齐...(配图:思考中那一档,推理正文明显比头里的文字更靠左)
thought 展开应该有个最高高度, 可以滚动(配图:思考已结束那一档,展开后十几段一路铺到屏外)
缩进:真因是特异性,不是缺一条规则
§F-15 把思考从「壳的一种形态」改成「壳里的一个条目」之后,那只 96px 的窗从
壳自己的 body 挪到了 .fold.thoughts 的 body 上。于是这条:
.fold.flat[open] > summary + .body.stream { border-top: 0; padding-top: 0 }
整条失配 —— 它要求这只窗是扁平壳的直接 body。窗子换了挂点之后,层叠里剩下的是:
| 规则 | 特异性 | 结果 |
|---|---|---|
.fold .body { padding: 0 11px 11px } |
(0,2,0) | 赢 |
.fold[open] > summary + .body { padding-top: 4px } |
(0,3,0) | 再赢它 |
.stream { padding: 0 } |
(0,1,0) | 两条都打不过 |
交付稿对应的那条是 .fold.mod-flat > .body.mod-stream,(0,4,0) —— 稿子本来就够特异,
是我们这边写成了裸 .stream 才输掉的。
真 Chrome 量的(坐标相对壳 body 的内容盒左缘;harness 是把真组件挂进 vite,
连 src/index.css 整条 import 顺序一起加载,不是拼静态页):
| 场景 | 行首图标 x | 头里文字 x | 正文左缘 x | body padding |
|---|---|---|---|---|
| 思考中 · 顶层(修前) | 22 | 44 | 4 | 4px 11px 11px |
| 思考中 · 抽屉里(修前) | 22 | 44 | 4 | 4px 11px 11px |
| 思考完 · 顶层(修前) | 22 | 44 | 22 | 2px 0px |
| 思考完 · 抽屉里(修前) | 22 | 44 | 22 | 2px 0px |
也就是说 两态错开 18px:思考中的正文跟谁都不对齐,而且思考一结束整段字横跳 18px 回到 22。
两条修:
/* 特异性补到和稿子同档,并且不再要求祖先是扁平壳 */
.fold[open] > summary + .body.stream { border-top: 0; padding: 0; }
/* `.body.stream` 走和 `.body.stack` **同一条**缩进 */
.fold .body.stack :is(.body.stack, .body.stream) > * { padding-inline-start: 33px }
.fold.flat .body.stack :is(.body.stack, .body.stream) > * { padding-inline-start: 29px }
修后四档全部 正文左缘 = 行首图标 x = 22,与同层 div.tool 的图标同列。
不变量是**「正文左缘 == 这一行自己的图标列」**,不是「22」这个常量 ——
四个场景里 22 是同一个数,但它是被 -7 + 29 算出来的,反向对照见下。
反向对照(证明不是写死 22):
| 撤掉哪一条 | 思考中正文左缘 | 思考完正文左缘 |
|---|---|---|
| 不撤(基线) | 22 | 22 |
撤掉 padding 那条(退回 .fold.flat[open] …) |
33 | 22(不受影响) |
撤掉 .body.stream 的缩进(只留 33px 那半) |
26 | 22(不受影响) |
每一撤只动它该动的那个数,另一档纹丝不动。
展开限高:值来自稿子,不是我挑的
交付稿 1252 行:
.fold .body.mod-scroll { max-height: 96px; overflow-y: auto; }
3674 行那条注释把这个数说死了:「96px ≈ 5 行,和折叠块里 .mod-scroll 用的是同一个高度,
这一屏里所有『够看但不占屏』的窗口都是这一档」。.mod-scroll 在稿子的标记里
一次都没用上(只声明了规则),所以稿子给的是规则和值,没给用它的那一格。
和思考中那只窗是两回事,别合并:
思考中 .stream |
思考完 .scroll |
|
|---|---|---|
| 高度 | height: 96px 定死 |
max-height: 96px,短内容不限高 |
| 遮罩 | 上下渐隐 | 无 —— 遮罩会把正在读的那一行切掉半截 |
| 谁在滚 | 自己往上走 | 用户自己滚(overflow-y: auto) |
| 为什么 | 「它还在想」这件事本身 | 用户专程点开来读 |
⚠️ .stream 用的是 height 不是 max-height —— 读 computed style 时别被
max-height: none 误导。
Foldable 原来的 scroll?: { maxHeight: number; stickToBottom?: boolean } 全仓零消费方,
stickToBottom 也没有任何实现。改成 scroll?: boolean,高度回到 CSS(稿子就是这么放的)。
真 Chrome 量的:
| 场景 | 内容真高 | 渲染高 | max-height |
overflow-y |
出滚动条 |
|---|---|---|---|---|---|
| 思考完 · 14 段(长) | 429 | 96 | 96px | auto | 是 |
| 思考完 · 一句话(短) | 34 | 34.4 | 96px | auto | 否 ← 反向对照 |
| 思考中(不该被波及) | 96 | 96 | none | hidden | 否 ← 反向对照 |
撤掉 .scroll 之后的长内容 |
429 | 429.5 | none | auto | 否 ← 消融 |
⚠️ 96px 这个数值请产品过目
规则和值都照稿子搬的,但稿子从没把 .mod-scroll 用在「用户点开来读的一长段推理」上。
96px ≈ 5 行:治住了「铺满整屏」,但要读十几段推理时是个很小的窗口。
改它只是一个数(record.module.css 的 .fold .body.scroll),没有连带影响。
已知残留(未修)
.tool的图标→文字间距gap:8pxvs.fold > summary的7px,文字左缘差 1px(45 vs 44)。 §F-15 已记,不是这一轮引入的。
F-18 壳里只有两套列,链穿过顶层的每一格(2026-08-27 用户裁决)
用户原话(两条,先后到达)
这里衔接咋是这样的?
配图是一只跑完的壳:线沿着上面那条命令步骤的正文走下来,走到「思考过程」那一格 断掉,下面的步骤再重新起一条。
这里不是说要动态判断间距吗? 像这里如果是 todo 外面, 不应该有这个缩进? todo 外面的工具调用应该也没这个缩进吧?
配图红框圈住顶层 Thoughts 左边那个空槽 —— 它比同一只壳里的 Plan · 3 steps
右了一格。
两条是同一个病:顶层的工具行 / 思考那一格被缩进了 22px,而链的 x 又是照 「顶层贴左」写死的 14.5px —— 于是它们既比邻居右了一格,又接不上那条线。
去交付稿里核过的三件事
材料:docs/design/chat-panel-next.html(修订 1bbdce0b06,md5 28ea4c65…),
放进无头 Chrome 逐行量 getBoundingClientRect()。
1. 稿子里有没有「壳里没有清单、工具行直接平铺」的格子?没有。
.fold.mod-flat > .body.mod-stack > .tool 在稿子自己的 84 格里命中 0 处。
组件 9 / 10 / 11 / 12 那几格单独演示工具行时,外面照样套着一层 details.fold
(带 .mk 状态点的步骤),工具行住在它里面:
div.fold.mod-flat ← 壳
div.body.mod-stack
details.fold[open] ← 步骤(.mk is-ok「复刻商品列表页」)
div.body.mod-stack
div.tool ×N ← 工具行永远在这一层
所以我们那条 .fold.flat > .body.stack > .tool { padding: 5px 7px 5px 29px }
抄的值没错、位置错了。它的注释也写明了适用前提:「折叠头本身就是一个步骤时,
它下面的工具行是这个步骤的子项」。而我们的壳头永远是「进行中 / 已完成」,
从来不是一个步骤 —— 顶层的工具行没有「谁的子项」可言。
2. 顶层和步骤里面是不是两套列?是。(坐标相对壳 body 内容盒左缘)
| 稿子里的这一行 | 行首那一格 x | 宽 | 名字 x | 链 x | 链 top |
|---|---|---|---|---|---|
顶层 details.fold(步骤)的 .mk |
0 | 15 | 22 | 7.5 | 25px |
步骤里面的 div.tool 的 .ti |
22 | — | — | 不画 | — |
步骤里面的 div.tool 的 .pk(序号) |
22 | — | — | 不画 | — |
步骤里面嵌一层的 details.fold 的 .ti |
22 | — | — | 不画 | — |
夹在两步中间的 p.think |
— | — | 22 | 7.5 | 0px |
3. 那 22px 从哪来? 稿子原话:「缩进一格(状态点 15 + 间距 7 = 22)」—— 让开步骤自己那颗状态点。所以它是「步骤里面」的列,不是「工具行」的列。 用户那句「是不是绿勾占一格所以里面的东西要让开」正是这个。
裁决(规则,写成两条)
| 层 | 谁在这一层 | 行首那一格 x | 画链吗 |
|---|---|---|---|
顶层(.fold.flat > .body.stack 的直接子代) |
步骤、div.tool、思考那一格、清单抽屉 |
0 | 画(最后一格除外) |
| 抽屉 / 步骤里面 | 子工具行、子折叠块、抽屉里的思考 | 22 | 不画 |
两条推论:
- 顶层三种 DOM(
details.fold/div.tool/details.fold.thoughts)取同一个 内边距(5px 7px),思考那一格不再有专属规则 —— 有专属规则就有各走各的余地。 - 顶层正文(
p.think)照旧贴左;有清单的壳里夹在两条抽屉中间的正文也贴左 (§F-11 的裁决原样保留,只是「正文」这条现在推广成了「顶层的每一种行」)。
链的 x 不再写死
原来两处各写各的数:链那条 14.5px、夹心正文那条 7.5px。它们必须落在同一条
绝对轴上,差的 7px 只是因为两种盒子往左探出的距离不同。写成两个常量的直接后果就是
这一轮的 bug:思考那一格换了缩进,14.5 没跟着动,于是当时用 :not(.thoughts)
把它从链上摘掉,链就断在那一格。
现在只有一个表达式:
.fold.flat > .body.stack { --row-slot: 15px; --chain-x: 7.5px; --row-outdent: 0px; }
.fold.flat > .body.stack > :is(.fold, .tool) { margin-inline: -7px; --row-outdent: 7px; }
/* 两处共用 */ inset-inline-start: calc(var(--chain-x) + var(--row-outdent, 0px));
--chain-x 是 --row-slot 的一半 —— 链的中轴就是行首那一格的中轴,两个数写在一起,
哪天那一格换了宽度,线跟着走,不会悄悄偏出半格。
另外链的首尾判据从 :not(:last-of-type) / :not(:first-of-type) 换成
:has(~ :is(.fold, .tool)) / :is(.fold, .tool) ~ .fold[open]:稿子顶层清一色
details,of-type 够用;产线上顶层混着 div.tool,of-type 按标签各数各的,
一条尾随的工具行不会让前一条 details 失去 :last-of-type,链会凭空多一截或少一截。
(同一处修正夹心正文那条规则 §F-11 已经做过。)
真 Chrome 量的(改前 → 改后)
harness:apps/web/tests/components/chat/record-columns-probe.test.tsx 把真组件渲染成
一张自包含 HTML(CSS Module 类名去哈希 + 内联 tokens.css / base.css 的 :root /
ChatRoot.module.css / record.module.css),再用无头 Chrome 的 CDP 读坐标 ——
做法照 docs/design/chat-mirror/measure.mjs。坐标相对壳 body 内容盒左缘。
顶层(没有清单的壳)
| 行 | 行首 x 改前 | 改后 | 链 改前 | 改后 |
|---|---|---|---|---|
details.fold.thoughts「思考过程」 |
22 | 0 | 不画 | 7.5 |
details.fold「List project workspace」 |
0 | 0 | 7.5 | 7.5 |
div.tool「读取 a.png」 |
22 | 0 | 不画 | 7.5 |
div.tool「读取 b.png」(末位) |
22 | 0 | 不画 | 不画(末位) |
夹心 p.think |
内容 22 | 内容 22 | 7.5 | 7.5 |
清单抽屉里(反向对照 —— 一格没动)
| 行 | 行首 x 改前 | 改后 |
|---|---|---|
抽屉里的 div.tool |
22 | 22 |
抽屉里的 details.fold.thoughts |
22 | 22 |
抽屉里嵌一层的 details.fold |
22 | 22 |
顶层 0 ≠ 抽屉 22,而且各自内部三种行一致 —— 这一对不等式就是「没把两套列拉平」的 判据;只断言「顶层是 0」的话,把所有行一律改成 0 也能绿。
六条不变式的复验(全部真 Chrome)
| # | 不变式 | 证据 |
|---|---|---|
| 1 | 夹心正文对齐 | 没清单:内容 22 + 链 7.5;有清单:内容 0、不画(两种壳都量了) |
| 2 | 思考中 / 思考过程同一个 15px 图标位 | 顶层两态都是 x=0 / w=15;抽屉里两态都是 x=22 / w=15 |
| 3 | 思考正文左缘 | 四种场景(顶层 / 抽屉 × 思考中 / 思考完)都是 22,与改前一字不差 |
| 4 | 步骤折叠头贴左 7px | padding: 5px 7px 未动,量到行首 x=0 |
| 5 | 顶层 vs 抽屉两套列 | 见上面两张表:0 vs 22,各自内部一致 |
| 6 | 抽屉里的工具行仍 22 | 抽屉那两只格改前改后逐字节相同 |
消融(逐个撤掉一个机制,看恰好哪几条变红)
| 撤掉 | 变红的测试 |
|---|---|
链的选择器重新排除 .thoughts |
「思考那一格没有被排除在链外」/「思考那一格在步骤链上」 |
链退回只认 .fold + :*-of-type |
「链的选择器认两种行」/「不用 :*-of-type 判首尾」 |
| 线的 x 退回写死 14.5 / 7.5 | 「每一条线段用的是同一个表达式」 |
--chain-x 与行首那一格脱钩 |
「两种线段落在同一条绝对轴上」/「链的中轴就是行首那一格的中轴」 |
| 顶层工具行退回 22px | 「顶层:三种行取同一个内边距」/「思考那一格的行首和工具行取同一个值」 |
| 思考那一格重新挂专属 29px | 同上两条 |
| 夹心正文那段线也留 25px 缺口 | 「缺口只留给行首那一格」 |
把 record.module.css 整份还原成改前的样子,这三个文件一共 12 条变红。
被推翻的那条测试
thinking-embedded.test.tsx 里原有一条「思考那一格不画步骤之间那条竖线 ——
它是工具行,不是步骤」,断言链的选择器必须带 :not(.thoughts)。它没有被删,
改写成了相反的不变式,并在测试里留下了当初排除的理由和推翻的日期 ——
「为什么曾经排除过」比「现在没排除」更值钱:下一个看到线和图标错开的人,
要先问「是不是又有哪一行的列错了」,而不是再把它从链上摘一次。
已知残留(未修,列出来)
.tool与折叠头的 1px 差现在看得见了。.tool的图标→文字间距是gap: 8px,.fold > summary是7px,于是同一列里工具行的名字在 23、折叠头的名字在 22。 这个差 §F-15 就记着(当时是 45 vs 44),不是这一轮引入的;但这一轮把两种行 挪进了同一列,原来看不见的 1px 现在是相邻两行之间的错位,比以前显眼。 改它要动.tool的gap,会波及壳外独立出现的工具行,另开一条。.fold.flat > .body.stack > .code { margin-inline: 22px 11px }保持原样: 它是「顶层的终端块」,22 是稿子给的、和这一轮的行首列无关(终端块没有行首格)。
F-19 分叉带走结论,但已有的分叉不追溯(2026-08-27 用户裁决)
问题:fork 一个会话之后,原本成功的那一轮在界面上变成红色的「运行失败」, 而正文明明写着「已完成长篇深度文章页面」,产物卡也在。用户原话:「为什么 fork 之后, 之前成功的 run 会变成失败」。
根因不是查不到,是复制的时候就没带。 POST /api/projects/:id/conversations
把 runStatus 和 runId 一起当成「run 指针」摘掉了。但这两个不是一回事:
runId是把手 —— 重连、通知去重、SSE 续流都拿它认人,fork 出来的副本 没有自己的 run,所以摘掉是对的。runStatus是结论 —— 那一轮到底成没成功。摘掉之后前端只能猜。
前端的猜法(AssistantMessage.tsx 的 legacyTurnFailed)是「events 里有任何
一条报错的 tool_result 就判整轮失败」。那一轮里恰好有一次
desktop renderer unavailable,于是整轮被判成失败。
真机逐字段对照(用户自己库里那两条):
| 源消息 | fork 副本 | |
|---|---|---|
run_status |
succeeded |
NULL |
events_json |
16886 字节 | 16886 字节(逐字节相同) |
内容一个字节没变,只有结论没了。
修法:只带结论,不带把手。终态(succeeded / failed / canceled)跟着
复制;queued / running 仍然丢掉 —— 那是源会话此刻还在跑,带过来会永远转圈。
runId / lastRunEventId 照旧摘掉。
已有的分叉会话不追溯 —— 选 A
老 daemon 建出来的副本,run_status 已经永久是 NULL,救不回来。三个选项摆给
用户之后,用户选 A:
- A(采用):不管。新分叉正确,老的留着显示红色。零风险。
- B(否掉):放宽
legacyTurnFailed—— 轮次已结束且有正文产出时不再因单条 工具报错判失败。能一次性救回所有老行,但会同时改变所有无 runStatus 历史 消息的显示,有些真该失败的会变成「已完成」。否掉的理由:拿一个错的默认 换另一个错的默认,而且新的那个会把真正的失败藏起来,比原 bug 更糟。 - C(否掉):结论缺失时画中性的「已结束 / 状态未知」而不是红色失败。最诚实, 但是新的展示形态,要设计 + 19 个语言的文案,这一轮不做。
⚠️ 给下一个人:如果你在老的分叉会话里看到「运行失败」但正文是成功的,
那是已知的、已裁决不修的。不要去放宽 legacyTurnFailed —— 那条路 2026-08-27
被明确否掉了,理由见上面的 B。要改只能走 C,而且需要产品重新拍板。
F-20 耗时:壳头补上推理,思考那一格挂自己的数(2026-08-27 用户裁决)
用户真机四连,逐条对照真实 run 查过再动手。截图那一轮已经认出来了 ——
.od/runs/4347efff-31d5-4322-b48f-a0b6f3ad24c9,认得出来是因为清单那两条抽屉的
耗时逐字对上截图:1m 46s / 1m 11s。
① 「耗时好像没有算进 thought 的耗时」—— 成立,而且差得很多
真因:壳自己的跨度 shellSpan 只由带时刻的事件撑开。整条事件流里带时刻的只有
tool_use.startedAt 与 tool_result.completedAt;thinking 一个时刻都不带
(daemon 那边的载荷就是 { type: 'thinking_delta', delta },PersistedAgentEvent
里也只有 { kind: 'thinking'; text })。于是第一个工具调用之前和最后一个
工具结果之后的推理被整段切掉。
本机 .od/runs 里 38 条带推理的真实 run 逐条量过,壳头无一例外少报:
| run | 整轮 | 壳头(改前) | 少报 |
|---|---|---|---|
4347efff(截图那一轮) |
6m 12s | 3m 11s | 3m 0.6s |
3be1d04d |
5m 54s | 1.4s | 5m 52s |
9bbe3832 |
2m 17s | 0.4s → 屏幕上什么都不显示 | 2m 16s |
0dc3ba6a |
11m 59s | 5m 42s | 6m 17s |
46ae3c0a |
7m 5s | 1m 25s | 5m 40s |
修法是一条不变量,不是给推理另外补时间:
开这一轮的那张壳从轮次开头开始走表,收这一轮的那张壳走到轮次收尾为止。
ensureShell() 本来就是在本轮第一条事件上开的第一张壳(D10),最后一张壳一直开到
轮次终止 —— 那两个时刻是它俩自己的边界,不是借来的。单壳的一轮因此等于轮次跨度。
它同时取代了原来的 turnElapsed 兜底(那条是同一件事的特例),函数已删。
两张壳的那一轮仍然分得开:第一张拿轮次开头、最后一张拿轮次收尾,中间那道缝 谁也不领 —— T34 那张「两张卡头同一个数」的坏画面在结构上出不来。
被推翻的一条测试:shell-elapsed-turn-span.test.ts 的「事件给得出跨度时事件说了算
(它更窄,说的是这张壳不是整轮)」。前半句成立,后半句是错的:一轮只有一张壳时,
那张壳就是整轮。改写成相反的不变式,原文与推翻日期留在测试注释里。
② 顶层那三行为什么一个耗时都没有 —— 一半是 bug,一半不是
截图里顶层是 Thoughts / ToolSearch / Thoughts 三行,右边全空。分开看:
ToolSearch那一行不是 bug。 真实数据startedAt=1787828745434/completedAt=1787828745473,39ms,低于UNKNOWN_ELAPSED_BELOW_MS(100ms), 按 §2.2b「调用与结果同批到达 = 不知道,不是跑得快」本来就该不显示。那一行是对的。 顺带全量核过:624 条真实工具行里落在 (0,100ms) 这一档的是 0 条 —— 门槛没有在 误伤谁;真正没有数的 259 条是两端缺一端(tool_result没带completedAt)。- 两格
Thoughts是真 bug,见下一条。
③ 思考那一格的耗时怎么算
thinking 事件一个时刻都不带,「量它自己」这条路不存在。能观测到的只有 它填掉了哪一段空白 —— 上一件带时刻的事结束到下一件带时刻的事开始。三条边界:
- 开头那一段:上一件事不存在时,起点是轮次开头(
startedAtMs); - 收尾那一段:下一件事不存在时,终点是轮次收尾(
endedAtMs);还在跑就不结账; - 空白里不止它一个(中间落过正文 / 工具行)时:一个数都不给 —— 那段空白是几件事 分掉的,分给谁都是编。
跨事件合并(groupThinking 把连续几段收成一格):每一段各记各的空白,而相邻两段
的空白共用同一个时刻端点(隔在中间那次 TodoWrite 的 startedAt 既是前一段的
终点、也是后一段的起点),所以相加正好等于端到端跨度,不重复计、也不漏掉中间那一瞬。
一格里只要有一段算不出来,整格不给数 —— 只加算得出的那几段是偏小的假数。
正在想的时候不显示,和进行中的 todo 是同一条规矩(TodoRow 的
status === 'in_progress' 那一档)。
覆盖率:本机 76 格真实思考里 59 格(78%) 报得出数;截图那一轮 8 格全报得出,
顶层两格是 2m 34s / 4.0s,抽屉「交付前自检」里五格是
5.4s / 4.3s / 42.8s / 7.0s / 8.9s。各格相加 + 工具行相加 ≈ 壳头总数,对得上。
ThoughtsRow 原来的注释写着「不挂耗时:推理的时长在壳头的总耗时里」—— 那句话的
前提就是假的(见 ①),已改写。
④ 中止的一轮没有复制按钮
原判据只有一条:message.content.trim().length > 0。而推理走的是 events,从来不进
content(ProjectView 的 textBuffer:kind === 'text' 才 appendContent,
kind === 'thinking' 只落 events)。一轮被停在模型还在想的时候 content 就是空的。
新判据在 runtime/chat/copyable-turn.ts:回答优先,回答一个字都没有时退回推理原文。
两样都没有仍然不出按钮 —— claude 经 daemon 送出的 thinking 全是空串(真实录制 1786 帧
无一有字),给那种轮次一颗按下去复制空串的按钮比没有按钮更糟。
消融(逐个撤掉一个机制,看恰好哪几条变红)
| 撤掉 | 变红的测试 |
|---|---|
| 壳头的轮次边界 widening | shell-elapsed-includes-thinking 5 条 + shell-elapsed-turn-span 4 条 |
settleThink()(不往条目上落耗时) |
thoughts-elapsed 的 6 条正向用例(反向对照全绿) |
groupThinking 不带 elapsedMs |
上面 6 条 + thoughts-elapsed-row 2 条 |
ThoughtsRow 不把 elapsed 递给 Foldable |
thoughts-elapsed-row 2 条正向用例 |
撤掉 live ? null : 抑制 |
thoughts-elapsed-row 2 条反向对照(正向全绿) |
closeThink 的「仍是数组末尾」守卫 |
「推理和正文分掉同一段空白时,谁都不给数」 |
sumElapsed 少一段就整格作废 → 改成部分求和 |
「一格里有一段算不出来时,整格不给数」 |
copyableTurnText 不回落推理 |
stopped-turn-copy 2 条正向用例(反向对照全绿) |
范围外发现(未修,列出来)
daemonAgentPayloadToPersistedAgentEvent把tool_result.completedAt丢了。 落库那条链路只写{ kind, toolUseId, content, isError },而 web 的translateAgentEvent(直播那条)是留着的。结果:历史会话重新打开之后,每一条 工具行的耗时都没了,壳自己的跨度也退化成「第一次调用开跑 → 最后一次调用开跑」。 现有夹具amr-thinking-todo.turn0.json15 条tool_result全都没有completedAt, 正是照着落库那条链路导出来的。.od/runs/*/events.jsonl里每条tool_use都镜像了两遍(同一个id、相隔几 毫秒、两个不同的 envelope id)。消息里落库的应该是去重之后的一条(用户截图顶层只有 一行ToolSearch),但运行日志这一份会让任何直接回放它的工具看到重复行。tests/components/chat/thinking-fold.test.tsx在分支 tip 上本来就是红的 (「还在思考时不折叠」)。在od-wt-chat-panel上原样复现过,与这一轮无关。.tool与折叠头的 1px 差:§F-18 已经记着,未动。