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 (
+ {/* 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 ? (
+
+ {/* 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 */
+ {/* 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 (
) : (
-