diff --git a/.fork/customizations.yaml b/.fork/customizations.yaml index e84bdd542e460..9fc0dd1d3c001 100644 --- a/.fork/customizations.yaml +++ b/.fork/customizations.yaml @@ -364,6 +364,14 @@ those imports to make a conflict go away, because nothing in the fork's copy references them any more. + Both rows carry a trailing inset of --app-scrollbar-width. They sit in a + container with the same 8px padding as the thread list below, so their + content edges ought to agree — but the list is a scroll container and its + scrollbar takes real layout width, leaving every card short by exactly that + much and the trailing button overhanging them. The inset reads the token + rather than restating 6px, so the alignment follows upstream if the + scrollbar is ever retuned. + The brand moves to the header's trailing edge, where it stops competing with the toggle for the corner the eye starts at. The search and project rows are 36px and 12px rather than 32px and 14px so they read as chrome @@ -426,6 +434,10 @@ - apps/web/src/components/SidebarStageBackdrop.test.tsx - apps/web/src/components/SidebarV2.tsx - apps/web/src/components/sidebar/SidebarChrome.tsx + # Defines --app-scrollbar-width, which the chrome rows' trailing inset reads + # to stay flush with the thread cards. Retiring or renaming that token + # silently un-aligns the two. + - apps/web/src/index.css verify: - apps/web/src/__fork_guards__/forkSidebarChrome.test.ts @@ -646,3 +658,165 @@ - vite.config.ts verify: - apps/web/src/__fork_guards__/forkLintCleanliness.test.ts + +- id: fork-composer-shell + intent: > + The chat composer is a discrete bordered box with its run controls floating + on a line underneath it, and it comes in two sizes. The new-chat screen gets + the tall shell: the prompt on its own line above a row carrying the model + and effort pills on the left and send on the right, in a 20px box. A started + thread gets the slim shell: one 48px line, prompt inline with those same + pills and send, in a 12px box. Slim grows into tall the moment the prompt + wraps past its first line, and never shrinks back within a turn the user is + still typing — the wrap is observed on the editor element, because Lexical + reflows without a React render. + Which controls sit where is the whole point, so it is worth stating plainly. + Inside the box: the model pill and the effort pill, and the send/stop + button. Everything else is on the control row below it — runtime mode + ("Auto"), interaction mode ("Build"), the plan-sidebar toggle, the context + meter, the preparing-worktree line, the no-provider badge, and, at the + row's right end, the worktree/branch pair. Upstream splits that last pair + off into BranchToolbar rendered as a sibling of the composer; here it is + passed into ChatComposer as contextStrip and rendered as the control row's + right-hand group, which is why ChatView no longer renders it directly. + The largest thing this replaces is not a layout but a paint. Upstream draws + composer and branch strip as one continuous "glass shell": a single frosted + ::before and a single 1px ::after, clipped by a shape() path that joins the + two through a notch, so they read as one vessel. The designs draw them + apart. Both pseudo-elements are switched off under the fork marker and the + box paints its own fill and border instead. The backdrop blur moves with it + rather than being dropped — the drawn fill is 80% opaque and only works over + a blurred backdrop. Focus is drawn by recolouring that border rather than + adding a ring, so the box keeps its 1px weight in both states and nothing + reflows when the caret lands. + Upstream's min-h-17.5 on the prompt editor is removed for the same reason: + it holds the editor open at roughly three lines, which makes the slim shell + geometrically impossible and leaves the tall one hollow. The prompt is + 14px/16px at >=sm; below sm it stays 16px, because anything smaller makes + iOS zoom the viewport on focus. The designs draw the prompt at 12px — 14 is + the fork's call, matching the pills' own bump away from the drawn 10px. + The prompt and its placeholder are set by one rule and must stay that way. + They are two separate elements stacked exactly on top of each other, so any + disagreement about size or leading shows up as a caret sitting beside the + text it is supposed to be inside, and as text that changes size on the first + keystroke. The selector uses a general sibling combinator: Lexical renders + the placeholder as the editor's third child with an empty spacer between + them, so `+ div` silently misses it — which is exactly how that bug got in + the first time. + The line box stays 16px regardless of the type size, because the drawn box + heights are derived from it (96 tall, 48 slim). Scaling leading with the + font would push the tall box off its drawn height. + Density has three values, not two. `collapsed` is upstream's mobile tap + target, which the fork restyles but does not lay out, and it is deliberately + not a flavour of `slim`: folding it in put + data-fork-composer-density="slim" on a composer the slim layout was never + applied to, forced every call site to re-exclude the collapsed case by hand, + and left two things named "slim" disagreeing about what they meant. + Both short shells still take the 12px radius, which is why the CSS lists + them together rather than relying on one standing in for the other. + The wrap observer and its latch state live in custom/, beside the pure rule + they drive, rather than inline in ChatComposer. The split version put half a + rule in the file least able to absorb fences and made the latch reachable + only through a regex over the source. + The latch clears on a draft change as well as on an empty prompt. + ChatComposer carries no key in ChatView, so it survives thread switches and + so does the latch; without the draft reset, leaving a thread whose prompt + had wrapped and arriving at one with a short saved draft rendered the new + thread tall and stranded it there, because the prompt was non-empty and the + observer only ever latches on. "Only an empty prompt turns it off" is a rule + about one composing session, not about the component's whole lifetime. + contextStrip is passed as a memoised element, and that is correctness + rather than tuning. ChatComposer is memo'd and every other prop it takes is + a stable reference or a primitive, so an inline at the call + site is the single new object identity per render and defeats the memo + outright — on a ~2900-line component that re-renders throughout a streaming + turn. onStartFromOriginChange became a useCallback for the same reason: it + feeds that memo and was a bare arrow. + The box's fill and hairline are selected through data-fork-composer-surface, + not through `[data-fork-composer-box] > div`. The positional form held only + because the frame has exactly one child today; upstream adding a drop + overlay or a banner inside it would paint a second frosted card with its own + hairline, and every guard here would stay green, because they assert on the + text of the stylesheet rather than on what it matches. That gap is the + general weakness of this guard style and is worth remembering for the next + one. + The context meter and the worktree status line are pinned shrink-0 on the + control row. That row's left slot is overflow-x-auto with its scrollbar + hidden, so anything allowed to be squeezed out of it vanishes with no + affordance saying so. The mode controls may scroll — they have a compact + menu fallback — but the meter is a readout people watch to decide when to + compact, and silently hiding it is a different failure from crowding it. + The slim shell is desktop-only. At phone widths the model pill and the send + button leave the flex-1 editor 90-150px, and the placeholder — an absolutely + positioned overlay outside the editor's scroll box — wraps to several lines + and paints straight through the 48px box with nothing to clip it. It is also + stable rather than transient: the overlay never changes the observed editor + height, so the wrap latch cannot rescue it. A narrow viewport therefore + resolves tall regardless of everything else. + The control row renders in every density, including collapsed; only its left + half is conditional. Upstream showed BranchToolbar while the composer was + collapsed, gated on showComposerContextStrip alone and with its own mobile + selector, so env mode and branch were switchable without focusing the + composer and raising the keyboard. It also unmounted the whole footer during + a pending approval, which is why the run controls are gated on that here — + they moved out of the box, so they would otherwise stay live and let the + user flip modes for a run whose approval is unresolved. BranchToolbar is + deliberately not gated on approval; upstream showed it too. + Three rules had to be narrowed after they were found reaching further than + intended, and the common cause is worth stating once: this stylesheet is + unlayered while Tailwind v4 utilities sit in @layer utilities, so a fork + rule beats them regardless of specificity. The pill restyle is scoped to + data-fork-composer-pills rather than the footer wholesale, because the + footer also holds ComposerPrimaryActions' real CTAs — the Implement/Refine + split button and the pending-question Previous/Next/Submit set — which it + was squashing to 24px pills and, in dark, painting #a6a6a6 on a filled + primary background. The separator rule is scoped to the left slot, because + BranchToolbar draws its own separator in the right one. And the drag-over + state is repainted explicitly, because upstream signals it with + bg-accent/45 plus a ring on the very element whose background and + box-shadow this file pins — the ring being a box-shadow too, so a drag over + the composer had no feedback at all. + States the designs do not draw resolve to tall, because the slim shell is a + single 24px line with nowhere to put a second row of controls: an approval + request, a pending user-input question, and the plan follow-up banner all + own the box's internals and carry their own action rows. Attachments + deliberately do not force tall — that was considered and declined — so + image, annotation, review-comment and element-context cards render as a + full-width band above the inline row rather than as flex items inside it. + The pills are 12px, where the designs draw them at 10px. That is a + deliberate divergence, not a transcription slip: 10px reads as fine print + against a 12px prompt sitting an inch away, and these are controls the user + is meant to read at a glance. The guard pins 12px so a later reconciliation + against Figma does not quietly "correct" it back. + ComposerFooterPrimaryActions keeps rendering the context meter and the + worktree status line for any caller that wants them; the fork passes + showStatusAdornments={false} and renders both on the control row instead, so + the behaviour is relocated rather than dropped. At compact widths the effort + pill folds into CompactComposerControlsMenu exactly as upstream does, which + leaves the box carrying the model pill alone — intended, not a gap. + tier: 4 + files: + - apps/web/src/custom/ComposerControlRow.tsx + - apps/web/src/custom/composerDensity.ts + - apps/web/src/custom/useComposerPromptWrapLatch.ts + - apps/web/src/theme.custom.css + shadows: [] + watch: + # Carries the density derivation, the two shells, the control row, and the + # contextStrip prop. + - apps/web/src/components/chat/ChatComposer.tsx + # Passes BranchToolbar in as contextStrip instead of rendering it as a + # sibling of the composer. + - apps/web/src/components/ChatView.tsx + # data-fork-composer-action on the send and stop buttons; the whole 24px + # square restyle hangs off it. + - apps/web/src/components/chat/ComposerPrimaryActions.tsx + # The glass-shell pseudo-elements and .chat-composer-context-strip this + # customization switches off. An upstream rework of that paint would leave + # the fork disabling rules that no longer exist. + - apps/web/src/index.css + # min-h-17.5 on the prompt editor, and the placeholder that has to track the + # prompt's type ramp. + - apps/web/src/components/ComposerPromptEditor.tsx + verify: + - apps/web/src/__fork_guards__/forkComposerShell.test.ts diff --git a/apps/web/src/__fork_guards__/forkComposerShell.test.ts b/apps/web/src/__fork_guards__/forkComposerShell.test.ts new file mode 100644 index 0000000000000..540593c739687 --- /dev/null +++ b/apps/web/src/__fork_guards__/forkComposerShell.test.ts @@ -0,0 +1,419 @@ +// @effect-diagnostics nodeBuiltinImport:off +/** + * Fork guard — see `.fork/README.md` §4b and + * `.fork/customizations.yaml#fork-composer-shell`. + * + * The composer restyle spans three upstream files and one stylesheet, and the + * halves fail independently: the JSX can keep its fences while the CSS stops + * matching, or the CSS can survive a rebase that quietly drops the attribute it + * hangs off. So this checks both ends of each seam — the hooks in the TSX, and + * the rules that consume them — plus the one piece of behaviour that is pure + * logic (which shell a given composer state wears). + */ + +import * as NodeFS from "node:fs"; +import * as NodeURL from "node:url"; +import { describe, expect, it } from "vite-plus/test"; + +import { FORK_MARKER_ATTRIBUTE, FORK_MARKER_VALUE } from "../custom/forkMarker"; +import { + resolveComposerDensity, + isPromptHeightWrapped, + nextWrapLatch, +} from "../custom/composerDensity"; +import { cssRules } from "./cssRules"; + +function readSibling(relativePath: string): string { + return NodeFS.readFileSync(NodeURL.fileURLToPath(new URL(relativePath, import.meta.url)), "utf8"); +} + +const MARKER = `:root[${FORK_MARKER_ATTRIBUTE}="${FORK_MARKER_VALUE}"]`; + +const theme = readSibling("../theme.custom.css"); +const chatComposer = readSibling("../components/chat/ChatComposer.tsx"); +const chatView = readSibling("../components/ChatView.tsx"); +const primaryActions = readSibling("../components/chat/ComposerPrimaryActions.tsx"); +const controlRow = readSibling("../custom/ComposerControlRow.tsx"); + +describe("fork guard: fork-composer-shell", () => { + it("keeps the box, send and stop hooks the stylesheet hangs off", () => { + // Every visual decision below is keyed to one of these four attributes. Lose + // one in a rebase and the CSS still compiles, still ships, and matches + // nothing. + expect(chatComposer, "ChatComposer lost data-fork-composer-box").toContain( + 'data-fork-composer-box="true"', + ); + expect(chatComposer, "ChatComposer lost the density attribute").toContain( + "data-fork-composer-density={composerDensity}", + ); + expect(primaryActions, "send button lost its fork hook").toContain( + 'data-fork-composer-action="send"', + ); + expect(primaryActions, "stop button lost its fork hook").toContain( + 'data-fork-composer-action="stop"', + ); + expect(controlRow).toContain('data-fork-composer-control-row="true"'); + }); + + it("styles the composer only under the fork marker", () => { + // An unmarked or pure-upstream build must render upstream's glass shell + // untouched, so every composer rule has to be scoped to the marker. + const composerRules = cssRules(theme).filter( + (rule) => + rule.selector.includes("[data-fork-composer") || + rule.selector.includes("chat-composer-glass") || + rule.selector.includes("chat-composer-context-strip"), + ); + expect(composerRules.length).toBeGreaterThan(0); + for (const rule of composerRules) { + expect(rule.selector, `unscoped composer rule: ${rule.selector}`).toContain(MARKER); + } + }); + + it("switches off upstream's stitched glass shell", () => { + // The designs draw a discrete box with a free-floating control row. Leaving + // either painted pseudo-element on restores the joined vessel the fork + // replaced, outlining the control row along with the box. + expect(theme).toMatch(/\.chat-composer-glass-shell::before[\s\S]{0,240}display:\s*none/u); + expect(theme).toMatch(/\.chat-composer-glass-host::after[\s\S]{0,240}display:\s*none/u); + expect(theme).toMatch(/\.chat-composer-context-strip::before[\s\S]{0,240}display:\s*none/u); + }); + + it("draws both radii and moves focus onto the border", () => { + expect(theme).toMatch(/--fork-composer-radius:\s*20px/u); + expect(theme).toMatch( + /\[data-fork-composer-density="slim"\][\s\S]{0,200}--fork-composer-radius:\s*12px/u, + ); + // Both short shells take it; `collapsed` stopped reporting itself as slim. + expect(theme).toMatch( + /\[data-fork-composer-density="collapsed"\][\s\S]{0,200}--fork-composer-radius:\s*12px/u, + ); + // Inset shadow rather than a border, so the stroke does not add 2px to a + // box the designs measure at 96 including it. + expect(theme).toMatch(/box-shadow:\s*inset 0 0 0 1px var\(--fork-composer-border\)/u); + expect(theme).toMatch( + /:focus-within[\s\S]{0,200}box-shadow:\s*inset 0 0 0 1px var\(--fork-composer-border-focus\)/u, + ); + }); + + it("takes the drawn colours in dark and defers to upstream in light", () => { + // Dark-only design, same as the rest of the fork's surface work. + expect(theme).toMatch(/\.dark\s*\{[^}]*--fork-composer-bg:\s*rgb\(41 41 41 \/ 80%\)/u); + expect(theme).toMatch(/\.dark\s*\{[^}]*--fork-composer-border:\s*rgb\(255 255 255 \/ 8%\)/u); + expect(theme).toMatch(/\.dark\s*\{[^}]*--fork-composer-border-focus:\s*#595959/u); + expect(theme).toMatch(/--fork-composer-bg:\s*var\(--card\)/u); + expect(theme).toMatch(/--fork-composer-border:\s*var\(--border\)/u); + }); + + it("pins both divergences from Figma, on the rules that carry them", () => { + // Two deliberate divergences, and both need pinning: someone reconciling + // this stylesheet against the design would otherwise "correct" either one + // and think they were fixing a typo. + // + // Routed through cssRules rather than a bare toMatch. The previous version + // asserted /font-size:\s*12px;\s*\n\s*font-weight:\s*500/ against the whole + // file, which was satisfied by any such pair anywhere in ~1000 lines and + // depended on where `vp fmt` happened to break the line. Anchoring to the + // selector is what makes it a guard rather than a coincidence detector. + const rules = cssRules(theme); + + const pillRule = rules.find( + (rule) => + rule.selector.includes("[data-fork-composer-control-row]") && + rule.body.includes("font-size"), + ); + expect(pillRule, "no rule sets the pill type").toBeDefined(); + expect(pillRule!.body, "pills are drawn at 10px; the fork uses 12px").toMatch( + /font-size:\s*12px/u, + ); + + // The prompt was previously unpinned entirely — the placeholder test checks + // the combinator, not the size the combinator exists to deliver, so a + // revert to Figma's 12px passed every assertion in this file. + const promptRule = rules.find( + (rule) => + rule.selector.includes('[data-testid="composer-editor"]') && + rule.body.includes("font-size"), + ); + expect(promptRule, "no rule sets the prompt type").toBeDefined(); + expect(promptRule!.body, "prompt is drawn at 12px; the fork uses 14px").toMatch( + /font-size:\s*14px/u, + ); + // The more consequential half: the manifest states outright that the 96/48 + // box heights are derived from this line box, so it does not scale with the + // font. + expect(promptRule!.body, "the 96/48 heights are derived from this").toMatch( + /line-height:\s*16px/u, + ); + }); + + it("drops the provider icon from the model pill, in the composer only", () => { + // ProviderModelPicker is shared with Settings, where the icon still + // distinguishes one configured instance from another. Unscoping this rule + // would strip it there too. + const iconRules = cssRules(theme).filter((rule) => + rule.selector.includes("[data-chat-provider-model-picker]"), + ); + expect(iconRules.length).toBeGreaterThan(0); + for (const rule of iconRules) { + expect(rule.selector, `unscoped model-pill rule: ${rule.selector}`).toContain( + "[data-fork-composer-box]", + ); + // The icon carries no attributes; the model name is tooltip-wrapped. Drop + // this guard and an upstream that removes the icon leaves the rule hiding + // the model name instead, for a pill of pure caret. + expect(rule.selector).toContain(":not([data-base-ui-tooltip-trigger])"); + } + }); + + it("resizes the placeholder with the prompt, via a general sibling combinator", () => { + // Lexical renders the placeholder as the editor's third child, with an + // empty zero-height div between them. `+ div` matches the spacer and leaves + // the placeholder at upstream's 14px/22.75px, which reads as a caret + // misaligned against the text it sits in front of. Regressing this to `+` + // looks like a tidy-up and is not one. + expect(theme).toMatch(/\[data-testid="composer-editor"\]\s*~\s*div/u); + expect(theme).not.toMatch(/\[data-testid="composer-editor"\]\s*\+\s*div/u); + }); + + it("frees the prompt from upstream's three-line minimum", () => { + // Upstream holds the editor open at min-h-17.5. That makes the slim shell + // geometrically impossible and leaves the tall one hollow. + expect(theme).toMatch(/\[data-testid="composer-editor"\][\s\S]{0,160}min-height:\s*0/u); + }); + + it("squares the send and stop buttons and reddens stop", () => { + expect(theme).toMatch(/\[data-fork-composer-action\]\s*\{[\s\S]{0,200}border-radius:\s*6px/u); + expect(theme).toMatch( + /\[data-fork-composer-action="stop"\][\s\S]{0,120}background:\s*#ea3150/u, + ); + }); + + it("routes the branch strip through the composer's control row", () => { + // The worktree/branch pair is the control row's right-hand group. If a sync + // restores BranchToolbar as a sibling of the composer, the row loses its + // right half and the strip reappears underneath, stitched or not. + expect(chatView, "ChatView stopped passing contextStrip").toContain( + "contextStrip: composerContextStrip", + ); + expect(chatView).toContain(" { + const base = { + isDraftHero: false, + isPromptWrapped: false, + hasComposerHeader: false, + isCollapsedMobile: false, + isNarrowViewport: false, + }; + expect(resolveComposerDensity({ ...base, isDraftHero: true })).toBe("tall"); + expect(resolveComposerDensity(base)).toBe("slim"); + }); + + it("grows the slim shell into the tall one once the prompt wraps", () => { + const base = { + isDraftHero: false, + isPromptWrapped: false, + hasComposerHeader: false, + isCollapsedMobile: false, + isNarrowViewport: false, + }; + expect(resolveComposerDensity({ ...base, isPromptWrapped: true })).toBe("tall"); + // A panel that owns the box's internals — approval, pending question, plan + // banner — has nowhere to put its action row in a single 24px line. + expect(resolveComposerDensity({ ...base, hasComposerHeader: true })).toBe("tall"); + }); + + it("gives the collapsed mobile composer its own density, not a flavour of slim", () => { + // Returning "slim" here put `data-fork-composer-density="slim"` on a + // composer the slim layout was never applied to, so every call site had to + // re-exclude the collapsed case by hand and two things named "slim" + // disagreed. It outranks every other input. + expect( + resolveComposerDensity({ + isDraftHero: true, + isPromptWrapped: true, + hasComposerHeader: true, + isCollapsedMobile: true, + isNarrowViewport: true, + }), + ).toBe("collapsed"); + // ...which is what lets the call sites read as plain equality. + expect(chatComposer).toContain('const isComposerSlim = composerDensity === "slim"'); + }); + + it("latches the wrap so the two shell widths cannot oscillate", () => { + // The tall shell is ~736px where slim is ~460px, so a prompt between those + // widths un-wraps the moment the flip lands. Deriving density straight from + // the measurement makes that a loop that pins the main thread. Once true, + // only an empty prompt clears it. + expect(nextWrapLatch({ latched: false, measuredWrapped: true, isPromptEmpty: false })).toBe( + true, + ); + expect(nextWrapLatch({ latched: true, measuredWrapped: false, isPromptEmpty: false })).toBe( + true, + ); + expect(nextWrapLatch({ latched: true, measuredWrapped: false, isPromptEmpty: true })).toBe( + false, + ); + expect(nextWrapLatch({ latched: false, measuredWrapped: false, isPromptEmpty: false })).toBe( + false, + ); + }); + + it("releases the latch on an emptied prompt without waiting for a resize", () => { + // Clearing a one-line prompt in the tall shell changes no height, so the + // ResizeObserver never fires and the effect is the only thing that resets. + const latchHook = readSibling("../custom/useComposerPromptWrapLatch.ts"); + expect(latchHook).toMatch( + /prompt\.trim\(\)\.length === 0\s*\)\s*\{\s*setIsPromptWrapped\(false\)/u, + ); + }); + + it("never uses the slim shell on a phone", () => { + // At 375px the model pill and send button leave the flex-1 editor ~90-150px, + // and the placeholder — an absolute overlay outside the editor's scroll box + // — wraps to several lines and paints straight through the 48px box. It is + // also stable: the overlay never changes the observed editor height, so the + // wrap latch cannot rescue it. + expect( + resolveComposerDensity({ + isDraftHero: false, + isPromptWrapped: false, + hasComposerHeader: false, + isCollapsedMobile: false, + isNarrowViewport: true, + }), + ).toBe("tall"); + }); + + it("keeps the branch controls reachable while collapsed, and gates modes on approval", () => { + // Two upstream behaviours the control row has to preserve. Upstream showed + // BranchToolbar while collapsed (gated on showComposerContextStrip alone), + // so env mode and branch were switchable without raising the keyboard; and + // it unmounted the whole footer during an approval, so the mode toggles + // could not be flipped for a run whose approval was unresolved. + expect(chatComposer, "the control row must render in every density").not.toMatch( + /composerDensity === "collapsed" \? null : \(\s* { + // ComposerPrimaryActions renders real buttons into the same containers: + // the h-9 Implement/Refine split button and the pending-question + // Previous/Next/Submit set. This stylesheet is unlayered, so it beat their + // Tailwind utilities outright — squashing them to 24px pills and, in dark, + // painting their labels #a6a6a6 on a filled primary background. + expect(chatComposer).toContain('data-fork-composer-pills="true"'); + const pillRules = cssRules(theme).filter( + (rule) => /height:\s*24px/u.test(rule.body) && rule.selector.includes("button"), + ); + expect(pillRules.length).toBeGreaterThan(0); + for (const rule of pillRules) { + expect(rule.selector, `pill rule reaches the whole footer: ${rule.selector}`).not.toMatch( + /\[data-chat-composer-(footer|inline-actions)\]/u, + ); + } + }); + + it("repaints the drag-over state the surface rule would otherwise swallow", () => { + // Upstream signals it with bg-accent/45 + ring-1 on this element. Tailwind + // v4 utilities live in @layer utilities and this stylesheet is unlayered, + // so both the tint and the ring — itself a box-shadow — lost to the pinned + // background and hairline, leaving a drag with no feedback at all. + expect(chatComposer).toContain("data-fork-composer-drag-over="); + // Outline, asserted by name. A box-shadow declaration on this element was + // measured in the browser not to land — the tint from the same rule did — + // so reverting to one would silently return the composer to a drag state + // advertised by a 4% fill and nothing else. + const dragRule = cssRules(theme).find((rule) => + rule.selector.includes("[data-fork-composer-drag-over]"), + ); + expect(dragRule, "no drag-over rule").toBeDefined(); + expect(dragRule!.body).toMatch(/outline:\s*2px solid/u); + expect(dragRule!.body).toMatch(/background:/u); + }); + + it("hides only the left slot's separators, not BranchToolbar's own", () => { + // BranchToolbar draws a separator between the environment picker and the + // env-mode/branch pair on multi-environment projects. A row-wide rule hid + // it and merged two groups the design keeps apart. + const separatorRules = cssRules(theme).filter( + (rule) => + rule.selector.includes('[data-slot="separator"]') && + rule.selector.includes("fork-composer-control-row"), + ); + expect(separatorRules.length).toBeGreaterThan(0); + for (const rule of separatorRules) { + expect(rule.selector).toContain('[data-fork-composer-control-row-slot="left"]'); + } + }); + + it("clears the latch when the draft changes, not just when the prompt empties", () => { + // ChatComposer is not keyed by thread, so it survives thread switches and + // so does the latch. Leaving a wrapped prompt and arriving at a thread whose + // saved draft is a short one-liner would otherwise render the new thread in + // the tall shell and strand it there — the prompt is non-empty, so the + // release effect never fires, and the observer only ever latches on. + const latchHook = readSibling("../custom/useComposerPromptWrapLatch.ts"); + expect(latchHook).toMatch(/setIsPromptWrapped\(false\);\s*\n\s*\}, \[draftKey\]\)/u); + expect(chatComposer).toContain("useComposerPromptWrapLatch("); + expect(chatComposer).toContain("scopedThreadKey(composerDraftTarget)"); + }); + + it("paints the surface by name, so an added sibling cannot inherit the box", () => { + // This was `[data-fork-composer-box] > div`, which held only because the + // frame has exactly one child today. Upstream adding a drop overlay or a + // banner inside it would paint a second frosted card with its own hairline, + // and every text-matching assertion here would stay green. + expect(chatComposer).toContain('data-fork-composer-surface="true"'); + const positional = cssRules(theme).filter((rule) => + /\[data-fork-composer-box\]\s*>\s*div/u.test(rule.selector), + ); + expect(positional, "composer paint is coupled to child position").toHaveLength(0); + }); + + it("keeps the wrap observer in fork-owned code, not in the hot upstream file", () => { + // The pure half of the density rule was always fork-owned; leaving the + // observer and the latch state inline in a 2.9k-line upstream file split + // one rule across two places and added fences to the file least able to + // absorb them. + const latchHook = readSibling("../custom/useComposerPromptWrapLatch.ts"); + expect(latchHook).toContain("ResizeObserver"); + expect(chatComposer).toMatch(/useComposerPromptWrapLatch\(\s*\n?\s*prompt,/u); + // Named helpers rather than "ResizeObserver": ChatComposer legitimately + // runs three observers of its own, for footer compactness and menu + // positioning, so the broad check cannot tell the fork's from upstream's. + expect(chatComposer).not.toContain("nextWrapLatch"); + expect(chatComposer).not.toContain("isPromptHeightWrapped"); + }); + + it("does not flap densities on sub-pixel line-box rounding", () => { + // One line, measured a shade over its nominal box. Treating that as a wrap + // would toggle the shell on every keystroke. + expect(isPromptHeightWrapped(16, 16)).toBe(false); + expect(isPromptHeightWrapped(16.5, 16)).toBe(false); + expect(isPromptHeightWrapped(32, 16)).toBe(true); + // A missing or unparseable computed line-height falls back to the desktop + // line box rather than reporting every prompt as wrapped. + expect(isPromptHeightWrapped(16, Number.NaN)).toBe(false); + expect(isPromptHeightWrapped(64, Number.NaN)).toBe(true); + }); +}); diff --git a/apps/web/src/__fork_guards__/forkSidebarChrome.test.ts b/apps/web/src/__fork_guards__/forkSidebarChrome.test.ts index f37f7bf19c4e0..fdf0c46591060 100644 --- a/apps/web/src/__fork_guards__/forkSidebarChrome.test.ts +++ b/apps/web/src/__fork_guards__/forkSidebarChrome.test.ts @@ -156,6 +156,20 @@ describe("fork guard: fork-sidebar-chrome", () => { expect(sidebarV2).not.toContain('data-testid="command-palette-trigger"'); }); + it("insets the chrome rows by the thread list's scrollbar", () => { + // The rows and the list sit in sibling containers with identical padding, + // but the list is a scroll container and its scrollbar takes real layout + // width — so without this the trailing button overhangs every thread card + // by exactly the scrollbar. Read from the token, never written as 6px, so + // the alignment survives upstream retuning the scrollbar. + const rows = readSibling("../custom/SidebarV2ChromeRows.tsx"); + expect(rows).toContain("pe-[var(--app-scrollbar-width)]"); + const upstreamCss = readSibling("../index.css"); + expect(upstreamCss, "the token the inset reads from is gone").toMatch( + /--app-scrollbar-width:\s*\d/u, + ); + }); + it("puts the brand on the header's trailing edge", () => { expect(chrome).toMatch(/sidebar-brand[^"]*ml-auto/u); expect(chrome).not.toContain("ml-[var(--workspace-titlebar-content-left)]"); diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index ab1256cddb34f..25ad55acf68b6 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -5460,21 +5460,38 @@ function ChatViewContent(props: ChatViewProps) { ], ); - const onStartFromOriginChange = (nextStartFromOrigin: boolean) => { - if (canOverrideServerThreadEnvMode && activeThread) { - setPendingServerThreadStartFromOriginByThreadId((current) => - current[activeThread.id] === nextStartFromOrigin - ? current - : { ...current, [activeThread.id]: nextStartFromOrigin }, - ); - return; - } - if (isLocalDraftThread) { - setDraftThreadContext(composerDraftTarget, { - startFromOrigin: nextStartFromOrigin, - }); - } - }; + /* fork:begin fork-composer-shell — see .fork/customizations.yaml#fork-composer-shell */ + // useCallback, where upstream had a bare arrow. This feeds the memoised + // contextStrip element below, and a fresh identity here would invalidate that + // memo on every render — which is the whole thing it exists to prevent. + const onStartFromOriginChange = useCallback( + (nextStartFromOrigin: boolean) => { + /* fork:end fork-composer-shell */ + if (canOverrideServerThreadEnvMode && activeThread) { + setPendingServerThreadStartFromOriginByThreadId((current) => + current[activeThread.id] === nextStartFromOrigin + ? current + : { ...current, [activeThread.id]: nextStartFromOrigin }, + ); + return; + } + if (isLocalDraftThread) { + setDraftThreadContext(composerDraftTarget, { + startFromOrigin: nextStartFromOrigin, + }); + } + /* fork:begin fork-composer-shell — see .fork/customizations.yaml#fork-composer-shell */ + }, + [ + canOverrideServerThreadEnvMode, + activeThread, + isLocalDraftThread, + composerDraftTarget, + setPendingServerThreadStartFromOriginByThreadId, + setDraftThreadContext, + ], + ); + /* fork:end fork-composer-shell */ const onExpandTimelineImage = useCallback((preview: ExpandedImagePreview) => { setExpandedImage(preview); @@ -5605,6 +5622,65 @@ function ChatViewContent(props: ChatViewProps) { ) : null ) : null; + /* fork:begin fork-composer-shell — see .fork/customizations.yaml#fork-composer-shell */ + /** + * The worktree/branch strip, as an element rather than a render site. + * + * This is memoised because it has to be, not as a micro-optimisation. + * `ChatComposer` is `memo`'d, and every other prop it receives is a stable + * reference or a primitive — so an inline `` here would be the + * single new object identity per render, defeating that memo entirely on a + * ~2900-line component that re-renders throughout a streaming turn. + */ + const composerContextStrip = useMemo( + () => + showComposerContextStrip && activeThread ? ( + + ) : null, + [ + showComposerContextStrip, + activeThread, + routeKind, + draftId, + onEnvModeChange, + startFromOrigin, + onStartFromOriginChange, + canOverrideServerThreadEnvMode, + envMode, + activeThreadBranch, + setPendingServerThreadBranch, + envLocked, + scheduleComposerFocus, + canCheckoutPullRequestIntoThread, + openPullRequestDialog, + hasMultipleEnvironments, + onEnvironmentChange, + logicalProjectEnvironments, + ], + ); + /* fork:end fork-composer-shell */ + return (
{rightPanelOpen && !shouldUsePlanSidebarSheet ? panelLayoutControls : null} @@ -5796,6 +5872,12 @@ function ChatViewContent(props: ChatViewProps) { isLocalDraftThread={isLocalDraftThread} forceExpandedOnMobile={forceExpandedMobileComposer && isDraftHeroState} projectSelectionRequired={isLocalDraftThread && activeProject === null} + /* fork:begin fork-composer-shell — see .fork/customizations.yaml#fork-composer-shell */ + isDraftHero={isDraftHeroState} + {...(composerContextStrip + ? { contextStrip: composerContextStrip } + : {})} + /* fork:end fork-composer-shell */ phase={phase} isConnecting={isConnecting} isSendBusy={isSendBusy} @@ -5861,42 +5943,13 @@ function ChatViewContent(props: ChatViewProps) { />
-
-
- {showComposerContextStrip && ( -
- -
- )} -
-
+ {/* fork:begin fork-composer-shell — see .fork/customizations.yaml#fork-composer-shell */} + {/* Upstream renders the BranchToolbar strip here, wrapped in + a min-h-0 / data-terminal-open pair. The strip is now the + composer's contextStrip prop, and the wrapper went with + it — nothing read data-terminal-open, so keeping the + shell would have left two empty divs in the tree. */} + {/* fork:end fork-composer-shell */}
; - activeThreadProviderDisplayName: string | null; isPreparingWorktree: boolean; pendingAction: { questionIndex: number; @@ -423,17 +427,17 @@ const ComposerFooterPrimaryActions = memo(function ComposerFooterPrimaryActions( onInterrupt: () => void; onImplementPlanInNewThread: () => void; }) { + /* fork:begin fork-composer-shell — see .fork/customizations.yaml#fork-composer-shell */ + /* The context meter and the "Preparing worktree..." line used to render here, + behind a showStatusAdornments flag the fork added and then always passed + false. This component is module-private with one call site, so the flag had + no reachable true branch — machinery justified by a caller that cannot + exist. Both now render once, on the control row, where the designs put + them. Deleted rather than kept, because the duplicate had already drifted: + only one copy had grown shrink-0. */ + /* fork:end fork-composer-shell */ return ( <> - {props.activeContextWindow ? ( - - ) : null} - {props.isPreparingWorktree ? ( - Preparing worktree... - ) : null} + { + setIsComposerModelPickerOpen(open); + }} + getModelDisabledReason={getModelDisabledReason} + onInstanceModelChange={onProviderModelSelect} + /> + {/* At compact widths the traits fold into CompactComposerControlsMenu on + the control row, so the box carries the model pill alone. */} + {!isComposerFooterCompact && providerTraitsPicker ? providerTraitsPicker : null} + + ); + + const composerRunControls = ( + <> + {noProviderAvailable ? ( + + ) : null} + {isComposerFooterCompact ? ( + + ) : ( + + )} + {/* Both are pinned shrink-0. The control row's left slot is + overflow-x-auto with its scrollbar hidden, so anything allowed to be + squeezed out of it disappears with no affordance saying so. The mode + controls can scroll — they have a compact menu fallback and the user + knows they asked for them. The context meter is a status readout + people watch to decide when to compact; it must not silently vanish. */} + {activeContextWindow ? ( +
+ +
+ ) : null} + {isPreparingWorktree ? ( + + Preparing worktree... + + ) : null} + + ); + + const composerPrimaryActionSlot = ( +
+ 0} + isSendBusy={isSendBusy} + isConnecting={isConnecting} + isEnvironmentUnavailable={ + environmentUnavailable !== null || noProviderAvailable || projectSelectionRequired + } + isPreparingWorktree={isPreparingWorktree} + hasSendableContent={composerSendState.hasSendableContent} + preserveComposerFocusOnPointerDown={isMobileViewport} + onPreviousPendingQuestion={onPreviousActivePendingUserInputQuestion} + onInterrupt={handleInterruptPrimaryAction} + onImplementPlanInNewThread={handleImplementPlanInNewThreadPrimaryAction} + /> +
+ ); + /* fork:end fork-composer-shell */ + // Render // ------------------------------------------------------------------ return ( @@ -2201,6 +2365,10 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) data-chat-composer-form="true" >
@@ -2543,77 +2730,109 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
)} -
- - {showMobilePendingAnswerActions ? ( + {/* fork:begin fork-composer-shell — see .fork/customizations.yaml#fork-composer-shell */} +
+
+ {/* fork:end fork-composer-shell */} + + {showMobilePendingAnswerActions ? ( +
+ +
+ ) : null} +
+ {/* fork:begin fork-composer-shell — see .fork/customizations.yaml#fork-composer-shell */} + {/* Slim shell: the pills and the send button ride inline with the + prompt rather than sitting on their own row beneath it. */} + {/* No `&& !activePendingApproval` here: an approval sets + hasComposerHeader, which resolves density to tall, so the + right conjunct can never be false when the left is true. */} + {isComposerSlim ? (
- +
+ {composerModelControls} +
+ {composerPrimaryActionSlot}
) : null}
+ {/* fork:end fork-composer-shell */}
{/* Bottom toolbar */} - {isComposerCollapsedMobile ? null : activePendingApproval ? ( + {/* fork:begin fork-composer-shell — see .fork/customizations.yaml#fork-composer-shell */} + {/* The slim shell already rendered the pills and the send button + inline with the prompt. Skipping the row outright rather than + hiding it keeps a second data-chat-composer-actions="right" out of + the DOM — and `cn()` is tailwind-merge, so a conditional "hidden" + here would lose to the base "flex" that follows it anyway. An + approval always resolves to the tall shell, so that branch is + still reachable. */} + {composerDensity !== "tall" ? null : activePendingApproval ? ( + /* fork:end fork-composer-shell */
0 && "pt-2", isComposerFooterCompact ? "gap-1.5" : "gap-2 sm:gap-0", showMobilePendingAnswerActions && "hidden sm:flex", )} > -
- {noProviderAvailable ? ( - - ) : ( - { - setIsComposerModelPickerOpen(open); - }} - getModelDisabledReason={getModelDisabledReason} - onInstanceModelChange={onProviderModelSelect} - /> - )} - - {isComposerFooterCompact ? ( - - ) : ( - <> - {providerTraitsPicker ? ( - <> - - {providerTraitsPicker} - - ) : null} - - - )} -
- - {/* Right side: send / stop button */} + {/* fork:begin fork-composer-shell — see .fork/customizations.yaml#fork-composer-shell */}
- 0} - isSendBusy={isSendBusy} - isConnecting={isConnecting} - isEnvironmentUnavailable={ - environmentUnavailable !== null || - noProviderAvailable || - projectSelectionRequired - } - isPreparingWorktree={isPreparingWorktree} - hasSendableContent={composerSendState.hasSendableContent} - preserveComposerFocusOnPointerDown={isMobileViewport} - onPreviousPendingQuestion={onPreviousActivePendingUserInputQuestion} - onInterrupt={handleInterruptPrimaryAction} - onImplementPlanInNewThread={handleImplementPlanInNewThreadPrimaryAction} - /> + {composerModelControls}
+ + {/* Right side: send / stop button */} + {composerPrimaryActionSlot} + {/* fork:end fork-composer-shell */}
)}
+ {/* fork:begin fork-composer-shell — see .fork/customizations.yaml#fork-composer-shell */} + {/* The control row: run controls and the worktree/branch pair on one line + under the box, replacing both the in-box mode controls and the + separately-stitched context strip. + + The row renders in every state, including collapsed — only its left + half is conditional. Two upstream behaviours depend on that: + + Collapsed on mobile, upstream still rendered BranchToolbar (gated on + showComposerContextStrip alone, with its own MobileRunContextSelector + branch), so env mode and branch were switchable without focusing the + composer and raising the keyboard. Dropping the whole row took that + away. The mode controls stay hidden, which is what upstream did — its + footer was unmounted while collapsed. + + During a pending approval, upstream replaced the entire footer with + ComposerPendingApprovalActions, unmounting the runtime-mode, Build/Plan + and plan-sidebar toggles. The in-box footer still swaps, but these + moved out here, so they need the same gate — otherwise the user can + flip modes for a run whose approval is still unresolved. BranchToolbar + is deliberately not gated on approval; upstream showed it too. */} + + {/* fork:end fork-composer-shell */} ); }); diff --git a/apps/web/src/components/chat/ComposerPrimaryActions.tsx b/apps/web/src/components/chat/ComposerPrimaryActions.tsx index 3e664e226b757..afeab513f604e 100644 --- a/apps/web/src/components/chat/ComposerPrimaryActions.tsx +++ b/apps/web/src/components/chat/ComposerPrimaryActions.tsx @@ -129,6 +129,9 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({ return ( ); diff --git a/apps/web/src/custom/ComposerControlRow.tsx b/apps/web/src/custom/ComposerControlRow.tsx new file mode 100644 index 0000000000000..2138348f6ff44 --- /dev/null +++ b/apps/web/src/custom/ComposerControlRow.tsx @@ -0,0 +1,52 @@ +import { memo, type ReactNode } from "react"; +import { cn } from "~/lib/utils"; + +interface ComposerControlRowProps { + /** Run controls that belong to the message: runtime mode, plan mode, plan sidebar. */ + left: ReactNode; + /** + * Where the message runs: the worktree/branch context strip. Absent on a + * project with no git repo, which leaves the row left-aligned rather than + * collapsing it — the left controls still apply. + */ + right?: ReactNode; + className?: string; +} + +/** + * The composer's control row, below the box rather than inside it. + * + * The designs put the run controls and the worktree/branch pair on one line + * outside the composer, 4px under it, inset to the box's own 16px padding so + * the two read as one column. Upstream splits these across two places — mode + * controls inside the box's footer, BranchToolbar as a separate strip stitched + * to the box's underside by a shared glass outline — so this joins them and + * `theme.custom.css` unpicks the stitching. + */ +export const ComposerControlRow = memo(function ComposerControlRow({ + left, + right, + className, +}: ComposerControlRowProps) { + return ( +
+
+ {left} +
+ {right ? ( +
+ {right} +
+ ) : null} +
+ ); +}); diff --git a/apps/web/src/custom/SidebarV2ChromeRows.tsx b/apps/web/src/custom/SidebarV2ChromeRows.tsx index d23e29f5a8e29..fef1baad5f871 100644 --- a/apps/web/src/custom/SidebarV2ChromeRows.tsx +++ b/apps/web/src/custom/SidebarV2ChromeRows.tsx @@ -48,7 +48,19 @@ export interface SidebarV2ChromeProjectGroup { readonly workspaceRoot: string; } -const CONTROL_ROW = "flex h-9 items-center gap-1"; +/** The trailing inset is the thread list's scrollbar, borrowed. + * + * These rows and the thread list below them sit in sibling containers with + * identical 8px padding, so by rights their content edges agree. They do not: + * the list is a scroll container, its scrollbar takes real layout width, and + * every card ends up 6px short of where these rows end. The trailing button + * then overhangs the cards by exactly that much. + * + * Read from `--app-scrollbar-width` rather than written as 6px, so the two + * stay aligned if upstream ever retunes the scrollbar. Padding rather than a + * narrower width so the row's own background, if it ever gains one, still + * spans the full column. */ +const CONTROL_ROW = "flex h-9 items-center gap-1 pe-[var(--app-scrollbar-width)]"; const TRAILING_BUTTON = "relative size-8 shrink-0 justify-center rounded-md border-0 bg-transparent p-0 text-sidebar-muted-foreground hover:bg-sidebar-row-hover hover:text-sidebar-foreground focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-sidebar"; /** Coarse-pointer hit expansion — upstream's trick, kept: the visual button is diff --git a/apps/web/src/custom/composerDensity.ts b/apps/web/src/custom/composerDensity.ts new file mode 100644 index 0000000000000..2b50022b38035 --- /dev/null +++ b/apps/web/src/custom/composerDensity.ts @@ -0,0 +1,114 @@ +/** + * Composer density — which shell the chat composer wears. + * + * `tall` stacks the prompt over its own control row, in a 20px-radius box. + * `slim` runs the prompt inline with the model pills and the send button, + * in a 12px-radius box half the height. + * `collapsed` is upstream's mobile tap target, which the fork restyles but does + * not lay out. + * + * The new-chat screen is always tall; a started thread starts slim and grows + * into tall the moment the prompt no longer fits on one line. Everything else + * here is a state the designs do not draw, resolved to tall because the slim + * shell is a single 24px row with nowhere to put approval actions or a pending + * question's Next/Submit pair. + * + * `collapsed` is a distinct value rather than a flavour of `slim` on purpose. + * Folding it into `slim` meant the DOM advertised + * `data-fork-composer-density="slim"` on a composer the slim layout was not + * applied to, so every call site had to re-exclude the collapsed case by hand + * and two things named "slim" disagreed about what they meant. + */ +export type ComposerDensity = "slim" | "tall" | "collapsed"; + +export interface ComposerDensityInput { + /** The new-chat hero: a local draft with no messages yet. Always tall. */ + isDraftHero: boolean; + /** + * The prompt has wrapped past its first line. This must be latched by the + * caller — see `nextWrapLatch` — because the two shells give the editor + * different widths and an unlatched measurement oscillates. + */ + isPromptWrapped: boolean; + /** + * The box is showing an approval request, a pending user-input question, or a + * plan follow-up banner. These own the box's internal layout and carry their + * own action rows, so the inline shell cannot host them. + */ + hasComposerHeader: boolean; + /** The composer is collapsed to a single tap target on mobile. */ + isCollapsedMobile: boolean; + /** + * A phone-width viewport. The slim shell is desktop-only: at 375px the model + * pill and the send button leave the flex-1 editor about 90-150px, and the + * placeholder — an absolutely positioned overlay outside the editor's scroll + * box — wraps to several lines and paints straight through the 48px box with + * nothing to clip it. Worse, it is stable: the overlay never changes the + * observed editor height, so the wrap latch cannot rescue it. + */ + isNarrowViewport: boolean; +} + +export function resolveComposerDensity(input: ComposerDensityInput): ComposerDensity { + if (input.isCollapsedMobile) { + return "collapsed"; + } + if ( + input.isNarrowViewport || + input.isDraftHero || + input.hasComposerHeader || + input.isPromptWrapped + ) { + return "tall"; + } + return "slim"; +} + +/** + * The prompt editor's line box at the fork's desktop composer type (14px/16px). + * Only a fallback: the prompt is 16px on mobile to keep iOS from zooming on + * focus, so the live check reads the element's own computed line-height. + */ +export const COMPOSER_PROMPT_LINE_HEIGHT_PX = 16; + +/** + * Wrapped means taller than one line plus half a line of slack. The slack + * absorbs sub-pixel line-box rounding, which would otherwise flap the composer + * between densities on a single line of text. + */ +export function isPromptHeightWrapped( + promptHeightPx: number, + lineHeightPx: number = COMPOSER_PROMPT_LINE_HEIGHT_PX, +): boolean { + const lineHeight = + Number.isFinite(lineHeightPx) && lineHeightPx > 0 + ? lineHeightPx + : COMPOSER_PROMPT_LINE_HEIGHT_PX; + return promptHeightPx > lineHeight * 1.5; +} + +/** + * The wrap latch, and the reason it has to exist. + * + * The two shells do not give the prompt the same width: slim shares its line + * with the pills and the send button, leaving the editor roughly 460px at the + * composer's 768px max, while tall hands it the full ~736px. So a prompt + * between those two widths wraps in slim and fits in tall — and a density + * derived straight from the measurement flips to tall, un-wraps, flips back to + * slim, re-wraps, forever, pinning the main thread. + * + * Latching breaks the cycle: the wrap only ever turns on from a measurement, + * and only an empty prompt turns it off. That also reads better than the + * alternative, since a composer that snapped back to one line mid-sentence + * would move the caret out from under the user. + */ +export function nextWrapLatch(input: { + latched: boolean; + measuredWrapped: boolean; + isPromptEmpty: boolean; +}): boolean { + if (input.isPromptEmpty) { + return false; + } + return input.latched || input.measuredWrapped; +} diff --git a/apps/web/src/custom/useComposerPromptWrapLatch.ts b/apps/web/src/custom/useComposerPromptWrapLatch.ts new file mode 100644 index 0000000000000..9ba92397fa564 --- /dev/null +++ b/apps/web/src/custom/useComposerPromptWrapLatch.ts @@ -0,0 +1,98 @@ +import { useEffect, useState } from "react"; + +import { isPromptHeightWrapped, nextWrapLatch } from "./composerDensity"; + +export interface ComposerPromptWrapLatch { + /** Latched: the prompt has wrapped and has not been cleared since. */ + isPromptWrapped: boolean; + /** Ref callback for the element wrapping the prompt editor. */ + attachPromptElement: (element: HTMLDivElement | null) => void; +} + +/** + * Watches the prompt editor and reports whether it has wrapped past one line — + * the signal that grows the slim shell into the tall one. + * + * This is the impure half of the density decision, and it lives here rather + * than inline in ChatComposer for two reasons: it is fork-authored behaviour + * that upstream has no counterpart for, and keeping it beside the pure + * `nextWrapLatch` it drives means the whole rule reads in one place. + * + * Two things about it are load-bearing: + * + * The measurement is observed, not derived. Lexical reflows the prompt without + * a React render, so a height read during render is stale by construction. + * + * The result is latched, and the caller must not un-latch it. The two shells + * give the prompt different widths — roughly 460px slim against 736px tall — so + * a prompt between them un-wraps the instant the flip lands, re-wraps when it + * flips back, and oscillates until it pins the main thread. + */ +export function useComposerPromptWrapLatch( + prompt: string, + /** + * The draft the prompt belongs to. ChatComposer is not keyed by thread, so it + * persists across thread and draft switches and so does this latch — and the + * latch only ever clears on an *empty* prompt. Without this, leaving a thread + * whose prompt had wrapped and arriving at one with a short saved draft would + * render the new thread in the tall shell and strand it there until the user + * emptied the field. "Only an empty prompt turns it off" is a rule about a + * single composing session; a different thread is a different session. + */ + draftKey: string, +): ComposerPromptWrapLatch { + const [isPromptWrapped, setIsPromptWrapped] = useState(false); + const [promptElement, setPromptElement] = useState(null); + + useEffect(() => { + const editable = promptElement?.querySelector('[data-testid="composer-editor"]'); + if (!editable) { + setIsPromptWrapped(false); + return; + } + const measure = (heightPx: number) => { + const lineHeight = Number.parseFloat(window.getComputedStyle(editable).lineHeight); + const measuredWrapped = isPromptHeightWrapped(heightPx, lineHeight); + setIsPromptWrapped((latched) => + nextWrapLatch({ + latched, + measuredWrapped, + isPromptEmpty: editable.textContent?.trim().length === 0, + }), + ); + }; + const observer = new ResizeObserver((entries) => { + const entry = entries[0]; + if (!entry) return; + measure(entry.contentRect.height); + }); + // No priming read. `observe()` delivers an initial observation for a + // rendered element, so a manual call was always redundant — and it was the + // only path using a padding-box metric (`clientHeight`) against a threshold + // calibrated for the observer's content-box `contentRect`. The editor picks + // up `max-sm:pb-11` in the mobile pending-answer state, where those 44px + // would have latched the wrap on an empty prompt, and a latched value can + // never be lowered by the observer's correct readings afterwards. + observer.observe(editable); + return () => { + observer.disconnect(); + }; + }, [promptElement]); + + // Releasing the latch cannot be left to the observer alone: clearing a + // one-line prompt in the tall shell changes no height, so no resize fires and + // the composer would stay tall for the rest of the thread. + useEffect(() => { + if (prompt.trim().length === 0) { + setIsPromptWrapped(false); + } + }, [prompt]); + + // A new draft is a new composing session, so the latch starts clean whether + // or not the incoming prompt happens to be empty. + useEffect(() => { + setIsPromptWrapped(false); + }, [draftKey]); + + return { isPromptWrapped, attachPromptElement: setPromptElement }; +} diff --git a/apps/web/src/theme.custom.css b/apps/web/src/theme.custom.css index 068f9a14db45a..56f52cb4f6a8e 100644 --- a/apps/web/src/theme.custom.css +++ b/apps/web/src/theme.custom.css @@ -237,6 +237,258 @@ --fork-pill-hover: #2d2e2e; } +/* --------------------------------------------------------------------------- + Chat composer shell — see `.fork/customizations.yaml#fork-composer-shell`. + + The designs draw the composer as a discrete bordered box with its run + controls floating on their own line 4px underneath. Upstream draws something + quite different: one "glass shell" whose ::before paints a single frosted + layer and whose ::after traces a single outline, clipped by a shape() path so + the composer and the branch strip below it read as one continuous vessel + joined by a notch. That is the thing being replaced, so the shell's two + painted pseudo-elements are switched off here and the box paints itself. + + The blur is not dropped with them — the drawn fill is 80% opaque, which only + works over a blurred backdrop — it just moves onto the box. + + Everything below is dark-scoped: the designs are dark-only, and an unmarked + or light build keeps upstream's shell untouched. */ +:root[data-fork="noahhendrickson-t3code"] .chat-composer-glass-shell::before, +:root[data-fork="noahhendrickson-t3code"] .chat-composer-glass-host::after, +:root[data-fork="noahhendrickson-t3code"] .chat-composer-context-strip::before { + display: none; +} + +:root[data-fork="noahhendrickson-t3code"] .chat-composer-glass-host { + box-shadow: none; +} + +:root[data-fork="noahhendrickson-t3code"] { + /* 12px slim, 20px tall — the two radii the designs draw. */ + --fork-composer-radius: 20px; + --fork-composer-bg: var(--card); + --fork-composer-border: var(--border); + --fork-composer-border-focus: var(--ring); +} + +:root[data-fork="noahhendrickson-t3code"].dark { + --fork-composer-bg: rgb(41 41 41 / 80%); + --fork-composer-border: rgb(255 255 255 / 8%); + --fork-composer-border-focus: #595959; +} + +/* The frame div is upstream's 1px gradient-border trick: a p-px shim whose + child surface paints the fill. The fork wants a plain border, so the shim + collapses and the surface takes the border directly. */ +:root[data-fork="noahhendrickson-t3code"] [data-fork-composer-box] { + padding: 0; + border-radius: var(--fork-composer-radius); + background: none; +} + +/* Both short shells take the tighter radius. `collapsed` is listed explicitly + rather than folded into `slim`: it is upstream's mobile tap target, which the + fork restyles but does not lay out, and conflating the two is what previously + had the DOM reporting a slim composer that the slim layout never touched. */ +:root[data-fork="noahhendrickson-t3code"] + :is([data-fork-composer-density="slim"], [data-fork-composer-density="collapsed"]) { + --fork-composer-radius: 12px; +} + +/* Selected by name, not by position. This used to be `[data-fork-composer-box] + > div`, which worked only because the frame happens to have exactly one child + today. A Tier-4 rule whose whole job is surviving upstream syncs cannot be + coupled to a child count: upstream adding a drop overlay or a banner inside + the frame would give it a second frosted card with its own hairline, and + every guard here would stay green, because they assert on the text of this + stylesheet rather than on what it matches. + + The hairline is an inset shadow, not a border, and that is a geometry + decision rather than a stylistic one. Figma draws the stroke inside the + frame, so the tall box measures 96px *including* it; a real border sits + outside the padding box and would push the same content to 98. An inset + shadow paints in the same place without joining the layout. */ +:root[data-fork="noahhendrickson-t3code"] [data-fork-composer-surface] { + border-radius: inherit; + background: var(--fork-composer-bg); + box-shadow: inset 0 0 0 1px var(--fork-composer-border); + transition: + box-shadow 200ms, + border-radius 200ms; +} + +/* The blur is dark-only, matching the fill it exists for. In light mode + --fork-composer-bg resolves to an opaque --card, so the filter would allocate + a compositing layer to blur something nothing can see through. */ +:root[data-fork="noahhendrickson-t3code"].dark [data-fork-composer-surface] { + -webkit-backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturation)); + backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturation)); +} + +/* The drag-over state, restored. Upstream paints it with `bg-accent/45 ring-1 + ring-primary/70` on this element; both are swallowed here, because this + stylesheet is unlayered and Tailwind v4 utilities sit in `@layer utilities`, + which loses to unlayered rules whatever their specificity — and the ring is + a box-shadow, i.e. the exact property the hairline occupies. Repainting it + here keeps the drop target legible and keeps the hairline in the same + shadow, so the box does not gain a second one. */ +:root[data-fork="noahhendrickson-t3code"] [data-fork-composer-drag-over] { + background: var(--accent); + /* Outline, not box-shadow, and that is empirical rather than stylistic. The + tint above lands from this same rule while a box-shadow declaration beside + it measurably does not — something on this element composes that property + in a way the fork's rule loses to, single- or multi-shadow alike, so a drag + advertised itself with a 4% fill and nothing else. `outline` is a property + neither upstream nor this stylesheet otherwise sets here, it follows the + border-radius, and it does not occupy the slot the hairline needs. */ + outline: 2px solid var(--ring); + outline-offset: -2px; +} + +/* Focus recolours that same hairline rather than adding a ring, so the box + keeps its 1px weight in both states and nothing reflows when the caret + lands. */ +:root[data-fork="noahhendrickson-t3code"] [data-fork-composer-surface]:focus-within { + box-shadow: inset 0 0 0 1px var(--fork-composer-border-focus); +} + +/* The prompt sits on a 16px line box at 14px type. Upstream's min-height holds + the editor open at ~3 lines, which would make the slim shell impossible and + leave the tall one hollow; one line is the drawn height and it grows from + there. 16px is kept below sm so iOS does not zoom the viewport on focus. */ +:root[data-fork="noahhendrickson-t3code"] [data-fork-composer-box] [data-testid="composer-editor"] { + min-height: 0; +} + +/* The placeholder has to move with the prompt, or the two disagree about both + size and leading and the caret sits a couple of pixels off the text it is + supposed to be sitting in front of. + + A general sibling combinator, not an adjacent one: Lexical renders the + placeholder as the editor's *third* child, with an empty zero-height div + between them, so `+ div` matches the spacer and never reaches the + placeholder. `~ div` catches both; the spacer has no text to resize. */ +@media (width >= 40rem) { + :root[data-fork="noahhendrickson-t3code"] + [data-fork-composer-box] + :is([data-testid="composer-editor"], [data-testid="composer-editor"] ~ div) { + font-size: 14px; + /* Held at 16px rather than scaled with the type. The line box is what the + drawn box heights are built from — 96px tall, 48px slim — so letting it + grow to match 14px type would push the tall box to 100. Tight for a + wrapped prompt; the geometry is the thing being protected. */ + line-height: 16px; + } +} + +/* The in-box pills and the control row below it share one 24px ghost treatment: + 12px medium type, a 12px glyph, and a full pill. The designs draw this type at + 10px; 12px is the fork's own call, and it matches the prompt beside it. + + Scoped to `[data-fork-composer-pills]` rather than to the footer and the + inline-actions row wholesale. Those containers also hold the primary action + slot, and `ComposerPrimaryActions` renders real CTAs into it: the h-9 + Implement/Refine split button and the pending-question Previous / Next / + Submit answers buttons. Because this stylesheet is unlayered it beat their + Tailwind utilities outright — the split button came apart into two 24px pills + with a broken seam, the CTAs collapsed to 24px, and the `.dark` colour rule + below painted their labels #a6a6a6 on a filled primary background. Both + states are ordinary, not edge cases. + + Send and stop are still excluded by name rather than out-specified by a later + rule. `:is()` takes the highest specificity among its arguments, so this + selector lands at (0,4,0) and a plain `[data-fork-composer-action]` rule at + (0,3,0) would quietly lose its 6px radius back to the pill. */ +:root[data-fork="noahhendrickson-t3code"] + :is([data-fork-composer-pills], [data-fork-composer-control-row]) + :is(button, [data-slot="button"]):not([data-fork-composer-action]) { + height: 24px; + min-height: 24px; + gap: 6px; + border-radius: 999px; + font-size: 12px; + font-weight: 500; + line-height: 16px; +} + +:root[data-fork="noahhendrickson-t3code"] + :is([data-fork-composer-pills], [data-fork-composer-control-row]) + :is(button, [data-slot="button"]):not([data-fork-composer-action]) + svg { + width: 12px; + height: 12px; +} + +:root[data-fork="noahhendrickson-t3code"].dark + :is([data-fork-composer-pills], [data-fork-composer-control-row]) + :is(button, [data-slot="button"]):not([data-fork-composer-action]) { + color: #a6a6a6; +} + +/* Send and stop are the one exception to the pill rule: 24px squares at a 6px + radius, drawn as the only filled controls in the composer. */ +:root[data-fork="noahhendrickson-t3code"] [data-fork-composer-action] { + width: 24px; + height: 24px; + min-height: 24px; + border-radius: 6px; +} + +:root[data-fork="noahhendrickson-t3code"].dark [data-fork-composer-action="stop"] { + background: #ea3150; +} + +/* The model pill is the provider's name and a caret, nothing else. Upstream + leads it with a ProviderInstanceIcon, which made sense when the pill sat in a + crowded footer and had to identify itself; inside the box it is the only + named control, so the icon is redundant with the word beside it. + + Scoped to the composer: ProviderModelPicker is also used in Settings, where + the icon still does the work of distinguishing one configured instance from + another. + + The `:not()` is load-bearing rather than decorative. The icon wrapper is the + label group's first child and carries no attributes of its own, while the + model name is wrapped in a tooltip trigger. If upstream ever drops the icon, + the model name becomes the first child — and without this guard the rule + would hide the name instead, leaving a pill with nothing but a caret. */ +:root[data-fork="noahhendrickson-t3code"] + [data-fork-composer-box] + [data-chat-provider-model-picker] + > span:first-child + > span:first-child:not([data-base-ui-tooltip-trigger]) { + display: none; +} + +/* The mode controls carry leading and interleaved vertical rules, which earned + their keep when they trailed the model picker inside the box and needed to be + told apart from it. On the control row they are the first thing in the line, + so the leading rule divides the controls from nothing at all. The designs + separate these with spacing alone. + + Scoped to the left slot, which is the only place that reasoning holds. + BranchToolbar sits in the right slot and draws its own separator between the + environment picker and the env-mode/branch pair on multi-environment + projects; hiding that one merged two groups the design keeps apart. */ +:root[data-fork="noahhendrickson-t3code"] + [data-fork-composer-control-row-slot="left"] + [data-slot="separator"] { + display: none; +} + +/* BranchToolbar still ships the wrapper that hung it off the composer's + underside — a negative top margin, a fixed inset width, and the padding that + cleared the stitched outline. Inside the control row it is just the row's + right-hand group, so the wrapper flattens to nothing. */ +:root[data-fork="noahhendrickson-t3code"] + [data-fork-composer-control-row-slot="right"] + .chat-composer-context-strip { + width: auto; + max-width: none; + margin: 0; + padding: 0; +} + /* Sidebar V2 status palette, keyed to the fork marker so an unmarked build falls back to upstream's variables. The design's hues are tuned for a black panel; on the light panel the same values are near-invisible at 8px, so each