From 602a12c8442273088ba0630d8064ae68110d7b3e Mon Sep 17 00:00:00 2001 From: noah Date: Fri, 31 Jul 2026 13:31:15 -0400 Subject: [PATCH 1/4] feat(web): redesign chat composer to the new Figma layout The composer now matches the updated t3-fork Figma (node 71-4121) plus Noey's live-review adjustments: filled 24px context chips with the Auto chip's 4px-radius container, a 40px prompt surface with body/md type and a full muted-foreground placeholder, a 20px control row with per-mode accent chips (auto green, full access yellow, auto-accept edits purple, supervised red), text-only mode toggles, square 4px hover fills, a centred context-window ring, and the send button pinned top-right as the prompt wraps. The old tall/slim density machinery and wrap latch are deleted; the effort trigger drops upstream's dot separator. Co-Authored-By: Claude Fable 5 --- .fork/customizations.yaml | 177 ++---- .../__fork_guards__/forkComposerShell.test.ts | 581 ++++-------------- apps/web/src/components/ChatView.tsx | 9 +- apps/web/src/components/chat/ChatComposer.tsx | 215 ++----- apps/web/src/components/chat/TraitsPicker.tsx | 6 +- apps/web/src/custom/ComposerControlRow.tsx | 32 +- apps/web/src/custom/composerDensity.ts | 114 ---- .../src/custom/useComposerPromptWrapLatch.ts | 98 --- apps/web/src/theme.custom.css | 267 +++++++- 9 files changed, 512 insertions(+), 987 deletions(-) delete mode 100644 apps/web/src/custom/composerDensity.ts delete mode 100644 apps/web/src/custom/useComposerPromptWrapLatch.ts diff --git a/.fork/customizations.yaml b/.fork/customizations.yaml index 6d127536fb8c5..68332caf6af32 100644 --- a/.fork/customizations.yaml +++ b/.fork/customizations.yaml @@ -1122,141 +1122,74 @@ - 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 composing - session. The wrap is observed on the editor element (Lexical reflows - without a React render) and latched; the latch clears on an empty prompt - and on a draft change — ChatComposer carries no key in ChatView, so it and - the latch survive thread switches, and "only an empty prompt turns it off" - is a rule about one composing session, not the component's lifetime. - Which controls sit where is the whole point. Inside the box: the model - pill, the effort pill, and the send/stop button. In dark mode a send - button without stage-channel art — any stage label that is not Dev/Nightly - — is flat pure white with a black icon - (data-fork-composer-send-tone="flat"); Dev and Nightly keep the - stage-channel fill, and light builds keep upstream's fill. 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 renders that last pair as BranchToolbar, a sibling of the - composer; here it is passed into ChatComposer as contextStrip, which is - why ChatView no longer renders it directly. contextStrip is a memoised - element and onStartFromOriginChange a useCallback, and that is correctness - rather than tuning: ChatComposer is memo'd, every other prop is a stable - reference or primitive, and an inline element at the call site defeats the - memo on a ~2900-line component that re-renders throughout a streaming turn. - Upstream's continuous "glass shell" paint — one frosted ::before and one - 1px ::after joining composer and branch strip through a shape() notch — is - switched off under the fork marker; the box paints its own fill and border. - The backdrop blur moves with it rather than being dropped, because 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 nothing - reflows when the caret lands. - Upstream's min-h-17.5 on the prompt editor is removed: 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 on - focus. 14 is a deliberate divergence from the drawn 12, and the pills are - 12px where the designs draw 10 — the guard pins both so a later - reconciliation against Figma does not quietly "correct" them back. - Desktop moves the max-height scrollport onto data-fork-composer-prompt - rather than the contenteditable: the tight 14/16 line box lets Geist ink - inflate scrollHeight on the editor, so upstream's max-h + overflow-y:auto - there painted a phantom thumb on a single line. The wrapper owns the - 12.5rem cap (upstream max-h-50); the editor stays unclamped and clips its - ink. The editor's siblings — Lexical's placeholder and spacer — carry - overflow clip on both axes (clip, not hidden, so the pure-paint - placeholder never becomes a scroll box; both axes, so an unbreakable - approval-detail placeholder can neither eat its own 16px line with a - horizontal scrollbar nor propagate one onto the wrapper), because the - placeholder's overhanging line box otherwise reaches the wrapper's - scrollable overflow through its abspos box and paints the thumb on an - empty prompt only. Scoped to the same >=40rem media as the - line box — below that the prompt stays on leading-relaxed and upstream's - editor scroll is left alone. - The prompt and its placeholder are set by one rule and must stay that way: - they are two elements stacked exactly on top of each other, and any - disagreement about size or leading puts the caret beside the text it - belongs inside. The selector uses a general sibling combinator, because - Lexical renders the placeholder as the editor's third child with an empty - spacer between them, so `+ div` silently misses it. The line box stays - 16px regardless of type size, because the drawn box heights (96 tall, 48 - slim) are derived from it. - 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`. Both short shells take the 12px - radius, which is why the CSS lists them together. The wrap observer and - its latch live in custom/, beside the pure rule they drive. - The box's fill and hairline are selected through - data-fork-composer-surface, not a positional child selector — the - positional form held only because the frame has exactly one child today. - Three rules are deliberately narrow, and the common cause matters for any - future rule: 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 (the footer also - holds ComposerPrimaryActions' real CTAs), the separator rule to the left - slot (BranchToolbar draws its own in the right one), and the drag-over - state is repainted explicitly (upstream signals it with a background and a - ring — a box-shadow — on the very element whose background and box-shadow - this file pins). - The context meter and the worktree status line are pinned shrink-0 on the - control row: its left slot is overflow-x-auto with the scrollbar hidden, - the mode controls may scroll because they have a compact menu fallback, - but the meter is a readout people watch to decide when to compact and must - not vanish silently. - The slim shell is desktop-only: at phone widths the placeholder — an - absolutely positioned overlay outside the editor's scroll box — wraps and - paints straight through the 48px box, and stably so (the overlay never - changes the observed editor height, so the latch cannot rescue it). A - narrow viewport resolves tall regardless. On desktop slim the default - empty-state hint is a shorter string that still names @, $, and /; tall - keeps the long form. States the designs do not draw also resolve tall: 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 — attachment cards render as a - full-width band above the inline row. - The control row renders in every density, including collapsed; only its - left half is conditional. The run controls are gated on a pending approval - — they moved out of the box, which upstream unmounted wholesale during - approval, so they would otherwise stay live for a run whose approval is - unresolved. BranchToolbar is deliberately not gated on approval; upstream - showed it too, including while collapsed, so env mode and branch stay - switchable without raising the keyboard. - ComposerFooterPrimaryActions keeps rendering the meter and worktree status - for any caller that wants them; the fork passes showStatusAdornments=false - and renders both on the control row, so the behaviour is relocated rather - than dropped. At compact widths the effort pill folds into - CompactComposerControlsMenu exactly as upstream does, leaving the box with - the model pill alone — intended, not a gap. - Design history — the latch extraction, the collapsed/slim naming, the - `+ div` placeholder bug, the rule-narrowing incidents, and the known - weakness of text-of-stylesheet guards — is in - .fork/notes/FORK-CUSTOMIZATION-DECISIONS.md. + The chat composer is a discrete 8px bordered surface with one compact base + layout in every thread state, including a brand-new draft. ChatView always + docks it to the bottom instead of centering it as a new-chat hero. The base + row is 40px tall: 8px padding around a one-line prompt and the 24px + send/stop button. The prompt and action remain in that same row as text + wraps — the action pinned top-right, per the design's tall frame — so the + surface grows naturally with the editor; there is no tall/slim density + switch, resize observer, or wrap latch. Upstream's collapsed mobile tap + target remains a separate functional state. + + Which controls sit where is the main design contract. BranchToolbar's + worktree and branch chips sit above the surface as filled chips sharing + one selected-surface fill with no hairline, 4px apart, on the Auto chip's + container at the drawn 4px inline padding (20px tall, 4px radius) at 12px + type matching the control row's ghost pills (Noey's finish over the drawn + 6px-radius, 10px-type chips), and the workspace chip's trigger caret is + dropped. Only the prompt and primary + action sit inside the base row. Runtime and interaction modes sit + below-left; context usage, model, and effort sit below-right in a 24px + control row of 20px, 4px-radius controls (Noey's radius over the drawn + full-pill ghosts, so hover fills square off). The runtime mode renders as + the design's 20px accent chip — label only, no icon or caret — whose + data-fork-composer-mode-chip value is the mode itself, each with its own + hue: auto green, full access yellow, auto-accept edits purple, supervised + red. The interaction and plan-sidebar toggles are text-only ghost chips, + the model and effort selects share the row's 12px type (Noey's call over + the drawn 10px), and the effort trigger joins its trait labels with a + plain space ("High 1M"), not upstream's " · " separator. The branch strip is still passed into ChatComposer as + the memoised contextStrip element, preserving ChatComposer's memo boundary + and keeping branch controls reachable while mobile is collapsed. Runtime + controls remain gated during a pending approval. + + Upstream's stitched glass-shell paint is disabled under the fork marker. + The prompt surface keeps its own translucent fill, inset hairline, + focus-border recolour, backdrop blur, and explicit drag-over outline. + BranchToolbar's stitched-strip spacing is flattened in the named context + row. Send and stop remain 24px square; dark builds without stage-channel + art use a flat white send button with a black icon, while Dev and Nightly + retain their channel fill. Existing project components supply all icons and + controls; no Figma-exported assets are committed. + + Type follows the design system's dark tokens: the prompt is body/md — 14px + on a 23px line at >=40rem with a full muted-foreground placeholder (16px + mobile type stays, avoiding iOS focus zoom) — and the ghost controls and + mode chip are 12px medium on 16px with 12px glyphs. The prompt wrapper + owns the 12.5rem scroll cap so a one-line Lexical editor never paints a + phantom scrollbar, and the placeholder/spacer overflow containment remains + in place. Attachment and pending-state content may add height above the + base row, but ordinary composer height changes only when prompt content + wraps. Pill styling stays narrowly scoped so pending-state primary actions + retain their upstream geometry. 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. + # Carries the base row, the context/control rows, and contextStrip prop. - apps/web/src/components/chat/ChatComposer.tsx - # Passes BranchToolbar in as contextStrip instead of rendering it as a - # sibling of the composer. + # Docks the composer for new drafts and passes BranchToolbar as contextStrip. - apps/web/src/components/ChatView.tsx # data-fork-composer-action on the send and stop buttons; the whole 24px # square restyle hangs off it. Send also carries # data-fork-composer-send-tone ("channel" with stage art, "flat" # otherwise) for the dark-mode white/black fill. - apps/web/src/components/chat/ComposerPrimaryActions.tsx + # The effort trigger's label join: a plain space instead of " · ". + - apps/web/src/components/chat/TraitsPicker.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. diff --git a/apps/web/src/__fork_guards__/forkComposerShell.test.ts b/apps/web/src/__fork_guards__/forkComposerShell.test.ts index 0ef871e6b806d..c4029471c2a7b 100644 --- a/apps/web/src/__fork_guards__/forkComposerShell.test.ts +++ b/apps/web/src/__fork_guards__/forkComposerShell.test.ts @@ -1,26 +1,11 @@ // @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). - */ +/** Fork guard — see `.fork/customizations.yaml#fork-composer-shell`. */ 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 { @@ -28,501 +13,197 @@ function readSibling(relativePath: string): string { } 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"); +const controlRows = readSibling("../custom/ComposerControlRow.tsx"); +const traitsPicker = readSibling("../components/chat/TraitsPicker.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 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(chatComposer, "ChatComposer lost the prompt scrollport hook").toContain( - 'data-fork-composer-prompt="true"', - ); - 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"', + it("keeps one base composer that grows with its editor", () => { + expect(chatComposer).toContain( + 'const composerDensity = isComposerCollapsedMobile ? "collapsed" : "base"', + ); + expect(chatComposer).not.toContain('composerDensity === "slim"'); + expect(chatComposer).not.toContain('composerDensity === "tall"'); + expect(chatComposer).not.toContain("useComposerPromptWrapLatch"); + expect(chatComposer).toContain('"relative p-2"'); + expect(chatComposer).toContain('className="flex min-w-0 items-center gap-6"'); + // The action stays pinned top-right while the prompt wraps. + expect(chatComposer).toContain('className="flex shrink-0 items-center self-start"'); + expect(theme).toMatch(/--fork-composer-radius:\s*8px/u); + expect(theme).toMatch( + /\[data-fork-composer-action\]\s*\{[\s\S]{0,180}width:\s*24px[\s\S]{0,180}height:\s*24px/u, ); - expect(controlRow).toContain('data-fork-composer-control-row="true"'); + expect(theme).toMatch(/\[data-testid="composer-editor"\][\s\S]{0,120}min-height:\s*0/u); }); - 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"), + it("docks the composer at the bottom for empty and started threads", () => { + expect(chatView).toContain( + 'className="pointer-events-none absolute inset-x-0 bottom-0 z-20 pt-1.5 sm:pt-2"', ); - 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); + expect(chatView).not.toMatch(/isDraftHeroState\s*\?\s*"pointer-events-none absolute inset-0/u); + expect(chatView).not.toContain("isDraftHero={isDraftHeroState}"); }); - 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("orders context, prompt, and controls like the design", () => { + expect(controlRows).toContain('data-fork-composer-context-row="true"'); + expect(controlRows).toContain('data-fork-composer-control-row="true"'); + expect(chatComposer).toContain("{contextStrip}"); + expect(chatComposer).toContain('data-fork-composer-box="true"'); + expect(chatComposer).toMatch( + / { - // 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); + const contextIndex = chatComposer.indexOf(""); + const boxIndex = chatComposer.indexOf('data-fork-composer-box="true"'); + const controlsIndex = chatComposer.lastIndexOf(" { - // 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, + it("keeps only prompt and primary action inside the base row", () => { + expect(chatComposer).toContain('data-fork-composer-prompt="true"'); + expect(chatComposer).toContain('data-chat-composer-inline-actions="true"'); + expect(chatComposer).toMatch( + /data-chat-composer-inline-actions="true"[\s\S]{0,240}\{composerPrimaryActionSlot\}/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, + expect(chatComposer).toContain('data-fork-composer-pills="true"'); + expect(chatComposer).toMatch( + /const composerModelAndStatusControls[\s\S]{0,800}\{composerModelControls\}/u, ); + expect(chatComposer).toContain('"Ask anything,"'); }); - 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]"), + it("keeps context usage before model and effort on the right", () => { + const statusControls = chatComposer.match( + /const composerModelAndStatusControls = \([\s\S]*?\n \);/u, + )?.[0]; + expect(statusControls).toBeDefined(); + expect(statusControls!.indexOf(" { - // 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("moves the desktop prompt scrollport onto the wrapper, not the editor", () => { - // The 14/16 line box lets Geist ink inflate scrollHeight on the - // contenteditable, so upstream's max-h + overflow-y:auto there paints a - // phantom thumb. The wrapper owns the cap; the editor clips ink and stays - // unclamped. Scoped to the same >=40rem media as the line box — below that - // upstream's editor scroll behaviour is correct and must stay alone. - // - // Routed through cssRules (with atRules) rather than a bare @media…[\s\S] - // regex: theme.custom.css has one media query today, so a prefix match was - // satisfied by any placement within ~1200 chars of it — including after the - // block's closing brace. Hoisting these rules out of the media stayed green. - expect(chatComposer).toContain('data-fork-composer-prompt="true"'); - const rules = cssRules(theme); - const inDesktopLineBoxMedia = (rule: (typeof rules)[number]) => - rule.atRules.some((at) => /@media\s*\(\s*width\s*>=\s*40rem\s*\)/u.test(at)); - - const scrollport = rules.find( - (rule) => - inDesktopLineBoxMedia(rule) && - rule.selector.includes("[data-fork-composer-prompt]") && - !rule.selector.includes('[data-testid="composer-editor"]'), - ); - expect(scrollport, "desktop prompt scrollport rule missing or unscoped").toBeDefined(); - expect(scrollport!.body).toMatch(/max-height:\s*12\.5rem/u); - expect(scrollport!.body).toMatch(/overflow-y:\s*auto/u); - - // The `~ div` exclusion is load-bearing: the sibling clip rule below - // satisfies the other three predicates too, so without it this `find` - // resolves to the unclamp rule only because that rule happens to sit - // earlier in the file. Reorder the two blocks and `max-height: none` - // starts interrogating the sibling rule and fails for a reason unrelated - // to the invariant — the same class of wrong-thing-satisfied-it bug the - // @media prefix match already cost this file. - const editor = rules.find( - (rule) => - inDesktopLineBoxMedia(rule) && - rule.selector.includes("[data-fork-composer-prompt]") && - rule.selector.includes('[data-testid="composer-editor"]') && - !/~\s*div/u.test(rule.selector), + expect(chatComposer).toContain('data-fork-composer-status="true"'); + // The meter keeps its 24px button and natural ring art, exempt by name + // from the row's control-geometry and glyph rules. + expect(theme).toMatch( + /\[data-fork-composer-status\]\s+button\s*\{[^}]*width:\s*24px[^}]*height:\s*24px/u, ); - expect(editor, "desktop editor unclamp rule missing or unscoped").toBeDefined(); - expect(editor!.body).toMatch(/max-height:\s*none/u); - expect(editor!.body).toMatch(/overflow-y:\s*hidden/u); + expect(theme).toMatch(/:not\(\[data-fork-composer-status\] button\)/u); + }); - // The editor's clip does not cover its siblings: the placeholder's line - // box overhangs its inset-0 box by a pixel at 14/16, and abspos overflow - // propagates to the wrapper's scrollable overflow — a thumb that appears - // only while the prompt is empty. Clip specifically, not hidden: hidden - // computes the other axis's visible to auto, and an unbreakable - // approval-detail placeholder would then hand the 16px line to a - // horizontal scrollbar. Both axes, because a visible x would propagate - // that same text sideways into the wrapper's auto overflow-x instead. - // (The editor tolerates hidden only because pre-wrap + wrap-break-word - // forecloses horizontal overflow.) - const siblings = rules.find( - (rule) => - inDesktopLineBoxMedia(rule) && - rule.selector.includes("[data-fork-composer-prompt]") && - /\[data-testid="composer-editor"\]\s*~\s*div/u.test(rule.selector), + it("keeps context controls reachable while mobile is collapsed", () => { + expect(chatComposer).toMatch(/\{contextStrip \? /u); + expect(chatComposer).not.toMatch( + /composerDensity === "collapsed" \? null : \(\s* { - expect(theme).toMatch(/\[data-fork-composer-action\]\s*\{[\s\S]{0,200}border-radius:\s*6px/u); - // Flat (no stage art) is pure white / black icon in dark mode only; light - // and unmarked builds keep upstream's fill, so the rule must stay anchored - // on the dark-scoped marker prefix. Dev/Nightly keep tone=channel and the - // stage art; do not collapse them. - expect(primaryActions).toMatch( - /data-fork-composer-send-tone=\{\s*stageBackdropVariant\s*\?\s*"channel"\s*:\s*"flat"\s*\}/u, - ); + it("draws the compact control geometry", () => { + // Context chips: the Auto chip's container four pixels taller — 24px, 4px + // radius — on the shared fill. expect(theme).toMatch( - /:root\[data-fork="noahhendrickson-t3code"\]\.dark\s+\[data-fork-composer-action="send"\]\[data-fork-composer-send-tone="flat"\][\s\S]{0,200}background:\s*#ffffff/u, + /\[data-fork-composer-context-row\][\s\S]{0,160}:is\(button, \[data-slot="button"\]\)[\s\S]{0,480}height:\s*24px[\s\S]{0,240}border-radius:\s*4px/u, ); expect(theme).toMatch( - /:root\[data-fork="noahhendrickson-t3code"\]\.dark\s+\[data-fork-composer-action="send"\]\[data-fork-composer-send-tone="flat"\][\s\S]{0,200}color:\s*#000000/u, + /:is\(\[data-fork-composer-pills\], \[data-fork-composer-control-row\]\)[\s\S]{0,240}height:\s*20px/u, ); + // Every row control shares the 4px radius, so hover fills square off. expect(theme).toMatch( - /\[data-fork-composer-action="stop"\][\s\S]{0,120}background:\s*#ea3150/u, + /:is\(\[data-fork-composer-pills\], \[data-fork-composer-control-row\]\)[\s\S]{0,420}border-radius:\s*4px/u, ); + // The effort label joins traits with a space, not upstream's " · ". + expect(traitsPicker).toContain('labels.join(" ")'); + expect(traitsPicker).not.toContain('labels.join(" · ")'); + expect(theme).toMatch(/\[data-fork-composer-action\][\s\S]{0,200}border-radius:\s*8px/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", + it("keeps the composer type ramp", () => { + // Prompt: the design's body/md, 14px on a 23px line at >=40rem, with a + // full muted-foreground placeholder; ghost controls at 12px medium. + expect(theme).toMatch( + /\[data-testid="composer-editor"\], \[data-testid="composer-editor"\] ~ div\)[\s\S]{0,420}font-size:\s*14px;\s*\n\s*line-height:\s*23px/u, ); - 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, + it("draws the runtime mode as the design's accent chip", () => { + // The attribute value is the mode, and each mode carries its own hue. + expect(chatComposer).toContain("data-fork-composer-mode-chip={props.runtimeMode}"); + expect(theme).toMatch(/\[data-fork-composer-mode-chip="full-access"\]/u); + expect(theme).toMatch(/\[data-fork-composer-mode-chip="auto-accept-edits"\]/u); + expect(theme).toMatch(/\[data-fork-composer-mode-chip="approval-required"\]/u); + expect(theme).toMatch( + /\[data-fork-composer-mode-chip\]\s*\{[\s\S]{0,120}height:\s*20px[\s\S]{0,200}border-radius:\s*4px/u, ); - expect(nextWrapLatch({ latched: false, measuredWrapped: false, isPromptEmpty: false })).toBe( - false, + // The chip is exempt from the 24px ghost pill treatment by name. + expect(theme).toMatch(/:not\(\s*\[data-fork-composer-mode-chip\]\s*\)/u); + // Label only: the trigger's select caret is switched off. + expect(theme).toMatch( + /\[data-fork-composer-mode-chip\]\s*\n?\s*\[data-slot="select-icon"\]\s*\{\s*display:\s*none/u, ); }); - 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("fills the context chips", () => { + // One shared fill for both chips, no hairline — Noey's finish over the + // drawn two-tier pair. + expect(theme).toMatch(/--fork-context-chip-bg:\s*#2a2a2a/u); + expect(theme).not.toContain("data-fork-context-chip"); + // The workspace chip keeps its popup but not its trigger caret. + expect(theme).toMatch( + /\[data-fork-composer-context-row\]\s*\n?\s*\[data-slot="select-icon"\]\s*\{\s*display:\s*none/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("shortens the empty-state hint in the slim shell instead of truncating it", () => { - // The absolute placeholder is invisible to the wrap latch (empty prompt ⇒ - // latch false; placeholder only exists when empty). Truncating the 64-char - // default hint would eat `$use skills` / `/ commands`, which have no other - // discovery surface — so slim uses a shorter string that still names all - // three affordances, and tall keeps the long one. - expect(chatComposer).toMatch( - /isComposerSlim\s*\?\s*"Ask anything, @tag, \$skills, \/ commands"/u, - ); - expect(chatComposer).toContain( - "Ask anything, @tag files/folders, $use skills, or / for commands", - ); - // Truncation was considered and declined: no ellipsis rule on the - // placeholder sibling, so a sync that reintroduces one is a regression. - const rules = cssRules(theme); - const placeholderTruncate = rules.find( + it("keeps the fork styling scoped", () => { + const composerRules = cssRules(theme).filter( (rule) => - rule.selector.includes('[data-testid="composer-editor"]') && - rule.selector.includes("~") && - (rule.body.includes("text-overflow") || rule.body.includes("ellipsis")), - ); - expect(placeholderTruncate, "slim placeholder must not truncate").toBeUndefined(); - }); - - 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"), + it("switches off upstream's stitched glass shell", () => { + 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); + expect(theme).toMatch( + /\[data-fork-composer-context-row\][\s\S]{0,120}\.chat-composer-context-strip\s*\{[\s\S]{0,180}margin:\s*0/u, ); - 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. + it("keeps paint and interaction hooks paired with their rules", () => { + expect(chatComposer).toContain('data-fork-composer-surface="true"'); 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. + expect(primaryActions).toContain('data-fork-composer-action="send"'); + expect(primaryActions).toContain('data-fork-composer-action="stop"'); + expect(theme).toMatch(/box-shadow:\s*inset 0 0 0 1px var\(--fork-composer-border\)/u); 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); + expect(dragRule?.body).toMatch(/outline:\s*2px solid/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"), + it("keeps pill sizing off pending-state primary actions", () => { + const pillRules = cssRules(theme).filter( + (rule) => /height:\s*24px/u.test(rule.body) && rule.selector.includes("button"), ); - expect(separatorRules.length).toBeGreaterThan(0); - for (const rule of separatorRules) { - expect(rule.selector).toContain('[data-fork-composer-control-row-slot="left"]'); + expect(pillRules.length).toBeGreaterThan(0); + for (const rule of pillRules) { + expect(rule.selector).not.toMatch(/\[data-chat-composer-(footer|inline-actions)\]/u); } }); - - 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/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index f42c4cdbab8ea..52b2d8a046e99 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -5879,15 +5879,11 @@ function ChatViewContent(props: ChatViewProps) { )} - {/* Input bar — centered hero while a draft has no messages, docked at the bottom otherwise */} + {/* Input bar — always docked at the bottom, including an empty draft. */}
void; }) { const runtimeModeOption = runtimeModeConfig[props.runtimeMode]; - const RuntimeModeIcon = runtimeModeOption.icon; const interactionModeTooltip = props.interactionMode === "plan" ? "Plan mode — click to return to normal build mode" @@ -319,14 +316,10 @@ const ComposerFooterModeControls = memo(function ComposerFooterModeControls(prop /> } > - {props.interactionMode === "plan" ? ( - - ) : ( - - )} - - {props.interactionMode === "plan" ? "Plan" : "Build"} - + {/* fork:begin fork-composer-shell — see .fork/customizations.yaml#fork-composer-shell */} + {/* Text-only, per the designs: the label is the whole control. */} + {props.interactionMode === "plan" ? "Plan" : "Build"} + {/* fork:end fork-composer-shell */} {interactionModeTooltip} @@ -343,9 +336,20 @@ const ComposerFooterModeControls = memo(function ComposerFooterModeControls(prop onValueChange={(value) => props.onRuntimeModeChange(value!)} > } + render={ + /* fork:begin fork-composer-shell — see .fork/customizations.yaml#fork-composer-shell */ + // The designs draw the runtime mode as an accent chip with the + // label alone — no icon, no caret. The attribute carries the + // whole restyle and its value picks the mode's accent color; + // the popup keeps its icons and descriptions. + + /* fork:end fork-composer-shell */ + } > - {runtimeModeOption.label} @@ -394,11 +398,9 @@ const ComposerFooterModeControls = memo(function ComposerFooterModeControls(prop /> } > - - {props.planSidebarLabel} + {/* fork:begin fork-composer-shell — see .fork/customizations.yaml#fork-composer-shell */} + {props.planSidebarLabel} + {/* fork:end fork-composer-shell */} {planSidebarTooltip} @@ -526,11 +528,9 @@ export interface ChatComposerProps { forceExpandedOnMobile: boolean; projectSelectionRequired: boolean; /* fork:begin fork-composer-shell — see .fork/customizations.yaml#fork-composer-shell */ - /** The new-chat hero. Pins the composer to the tall shell. */ - isDraftHero?: boolean; /** - * Worktree/branch controls, rendered into the composer's own control row - * instead of as a separate strip below it. + * Worktree/branch controls, rendered above the prompt surface instead of as + * a separate stitched strip below it. */ contextStrip?: ReactNode; /* fork:end fork-composer-shell */ @@ -649,7 +649,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) forceExpandedOnMobile, projectSelectionRequired, /* fork:begin fork-composer-shell — see .fork/customizations.yaml#fork-composer-shell */ - isDraftHero = false, contextStrip, /* fork:end fork-composer-shell */ phase, @@ -997,19 +996,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) const isMobileViewport = useMediaQuery("max-sm"); const isComposerCollapsedMobile = isMobileViewport && !forceExpandedOnMobile && !isComposerFocused; - /* fork:begin fork-composer-shell — see .fork/customizations.yaml#fork-composer-shell */ - const { isPromptWrapped, attachPromptElement } = useComposerPromptWrapLatch( - prompt, - // ChatComposer is not keyed by thread, so the latch has to be told when the - // draft underneath it changed. Same discrimination composerDraftStore's own - // private composerTargetKey uses: a DraftId is already a string, a real - // thread needs its environment folded in. - typeof composerDraftTarget === "string" - ? composerDraftTarget - : scopedThreadKey(composerDraftTarget), - ); - /* fork:end fork-composer-shell */ - // ------------------------------------------------------------------ // Refs // ------------------------------------------------------------------ @@ -1178,21 +1164,10 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) const isComposerApprovalState = activePendingApproval !== null; const activePendingUserInput = pendingUserInputs[0] ?? null; - const hasComposerHeader = - isComposerApprovalState || - pendingUserInputs.length > 0 || - (showPlanFollowUpPrompt && activeProposedPlan !== null); const showCollapsedMobilePromptRow = isComposerCollapsedMobile && !isComposerApprovalState && pendingUserInputs.length === 0; /* fork:begin fork-composer-shell — see .fork/customizations.yaml#fork-composer-shell */ - const composerDensity = resolveComposerDensity({ - isDraftHero, - isPromptWrapped, - hasComposerHeader, - isCollapsedMobile: isComposerCollapsedMobile, - isNarrowViewport: isMobileViewport, - }); - const isComposerSlim = composerDensity === "slim"; + const composerDensity = isComposerCollapsedMobile ? "collapsed" : "base"; /* fork:end fork-composer-shell */ const composerFooterHasWideActions = showPlanFollowUpPrompt || activePendingProgress !== null; @@ -2725,13 +2700,12 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) getModelDisabledReason={getModelDisabledReason} onInstanceModelChange={onProviderModelSelect} /> - {/* At compact widths the traits fold into CompactComposerControlsMenu on - the control row, so the box carries the model pill alone. */} + {/* At compact widths the traits fold into CompactComposerControlsMenu. */} {!isComposerFooterCompact && providerTraitsPicker ? providerTraitsPicker : null} ); - const composerRunControls = ( + const composerModeControls = ( <> {noProviderAvailable ? ( + {activePendingProgress?.activeQuestion?.multiSelect ? ( + + ) : null} +
+
- ) : showPlanFollowUpPrompt && activeProposedPlan ? ( -
- -
- ) : null)} + ) : null} - {isComposerCollapsedMobile && activePendingApproval ? ( -
- -
- -
-
- ) : isComposerCollapsedMobile && pendingUserInputs.length > 0 ? ( -
- -
-
+ - {activePendingProgress?.activeQuestion?.multiSelect ? ( - +
+ +
-
- ) : null} - - {showCollapsedMobilePromptRow ? ( -
- - -
- ) : null} + ) : null} -
- - - {isStashMenuOpen && !composerMenuOpen && !isComposerApprovalState && ( - - setIsStashMenuOpen(false)} - /> - - )} - - {composerMenuOpen && !isComposerApprovalState && ( - - - - )} - - {!isComposerCollapsedMobile && - !isComposerApprovalState && - pendingUserInputs.length === 0 && - composerPreviewAnnotations.length > 0 && ( - - removeComposerDraftPreviewAnnotation(composerDraftTarget, annotationId) - } - onExpandImage={(imageId) => { - const preview = buildExpandedImagePreview(composerImages, imageId); - if (preview) onExpandImage(preview); - }} - className="mb-3" - /> +
+ - {!isComposerCollapsedMobile && - !isComposerApprovalState && - pendingUserInputs.length === 0 && - composerReviewComments.length > 0 && ( - - removeComposerDraftReviewComment(composerDraftTarget, commentId) - } - className="mb-3" - /> + {isStashMenuOpen && !composerMenuOpen && !isComposerApprovalState && ( + + setIsStashMenuOpen(false)} + /> + )} - {!isComposerCollapsedMobile && - !isComposerApprovalState && - pendingUserInputs.length === 0 && - composerElementContexts.length > 0 && ( - - removeComposerDraftElementContext(composerDraftTarget, contextId) - } - className="mb-3" - /> + {composerMenuOpen && !isComposerApprovalState && ( + + + )} - {!isComposerCollapsedMobile && - !isComposerApprovalState && - pendingUserInputs.length === 0 && - composerImages.some( - (image) => - !composerPreviewAnnotations.some((annotation) => annotation.id === image.id), - ) && ( -
- {composerImages - .filter( - (image) => - !composerPreviewAnnotations.some( - (annotation) => annotation.id === image.id, - ), - ) - .map((image) => ( -
- {image.previewUrl ? ( - - ) : ( -
- {image.name} -
- )} - {nonPersistedComposerImageIdSet.has(image.id) && ( - - - - - } - /> - - Draft attachment could not be saved locally and may be lost on - navigation. - - - )} - -
- ))} -
- )} + {image.previewUrl ? ( + + ) : ( +
+ {image.name} +
+ )} + {nonPersistedComposerImageIdSet.has(image.id) && ( + + + + + } + /> + + Draft attachment could not be saved locally and may be lost on + navigation. + + + )} + +
+ ))} +
+ )} - {/* fork:begin fork-composer-shell — see .fork/customizations.yaml#fork-composer-shell */} - {/* items-center + self-start on the action: a one-line prompt sits - centred beside the 24px button (the drawn 40px base row), and as - the prompt wraps the button stays pinned top-right, as in the - tall frame of the designs. */} -
-
+ {/* fork:begin fork-composer-shell — see .fork/customizations.yaml#fork-composer-shell */} + {/* fork:end fork-composer-shell */}
) : null} -
- {/* fork:begin fork-composer-shell — see .fork/customizations.yaml#fork-composer-shell */} - {!activePendingApproval ? ( -
- {composerPrimaryActionSlot} -
- ) : null} + {/* fork:begin fork-composer-shell — see .fork/customizations.yaml#fork-composer-shell */} + + {/* fork:end fork-composer-shell */} + + {/* fork:begin fork-composer-shell — see .fork/customizations.yaml#fork-composer-shell */} + {activePendingApproval ? ( +
+ +
+ ) : null} {/* fork:end fork-composer-shell */} - - {/* fork:begin fork-composer-shell — see .fork/customizations.yaml#fork-composer-shell */} - {activePendingApproval ? ( -
- -
- ) : null} - {/* fork:end fork-composer-shell */} - - {/* fork:begin fork-composer-shell — see .fork/customizations.yaml#fork-composer-shell */} - {/* Modes sit below-left; context usage, model and effort sit below-right. */} - + {/* fork:begin fork-composer-shell — see .fork/customizations.yaml#fork-composer-shell */} + {/* fork:end fork-composer-shell */} ); diff --git a/apps/web/src/components/chat/TraitsPicker.test.ts b/apps/web/src/components/chat/TraitsPicker.test.ts index b457f515ff77e..b8df696295d6f 100644 --- a/apps/web/src/components/chat/TraitsPicker.test.ts +++ b/apps/web/src/components/chat/TraitsPicker.test.ts @@ -92,6 +92,18 @@ describe("buildTraitsTriggerDisplay", () => { }); }); + it("allows the composer to request its compact label separator", () => { + expect( + buildTraitsTriggerDisplay({ + provider: CODEX, + descriptors: [EFFORT, CONTEXT_WINDOW], + primarySelectDescriptorId: "reasoningEffort", + ultrathinkPromptControlled: false, + labelSeparator: " ", + }), + ).toEqual({ label: "High 1M", showFastModeIcon: false }); + }); + it("falls back to a text label when fast mode is the only trait", () => { expect(display([fastModeDescriptor(true)])).toEqual({ label: "Fast", diff --git a/apps/web/src/components/chat/TraitsPicker.tsx b/apps/web/src/components/chat/TraitsPicker.tsx index e8a96a9617743..3449270f1358c 100644 --- a/apps/web/src/components/chat/TraitsPicker.tsx +++ b/apps/web/src/components/chat/TraitsPicker.tsx @@ -216,6 +216,10 @@ export interface TraitsMenuContentProps { allowPromptInjectedEffort?: boolean; triggerVariant?: VariantProps["variant"]; triggerClassName?: string; + /* fork:begin fork-composer-shell — see .fork/customizations.yaml#fork-composer-shell */ + /** Composer-only label treatment; Settings keeps the legible shared default. */ + triggerLabelSeparator?: string; + /* fork:end fork-composer-shell */ } export const TraitsMenuContent = memo(function TraitsMenuContentImpl({ @@ -389,6 +393,9 @@ export function buildTraitsTriggerDisplay(input: { descriptors: ReadonlyArray; primarySelectDescriptorId: string | null; ultrathinkPromptControlled: boolean; + /* fork:begin fork-composer-shell — see .fork/customizations.yaml#fork-composer-shell */ + labelSeparator?: string; + /* fork:end fork-composer-shell */ }): { label: string; showFastModeIcon: boolean } { let hasFastMode = false; let fastModeEnabled = false; @@ -429,11 +436,12 @@ export function buildTraitsTriggerDisplay(input: { if (labels.length === 0 && hasFastMode) { return { label: fastModeEnabled ? "Fast" : "Normal", showFastModeIcon: false }; } - /* fork:begin fork-composer-shell — see .fork/customizations.yaml#fork-composer-shell */ - /* A plain space, not upstream's " · ": the composer's effort chip reads - "High 1M". Settings uses the same trigger and stays consistent. */ - return { label: labels.join(" "), showFastModeIcon: fastModeEnabled }; - /* fork:end fork-composer-shell */ + return { + /* fork:begin fork-composer-shell — see .fork/customizations.yaml#fork-composer-shell */ + label: labels.join(input.labelSeparator ?? " · "), + /* fork:end fork-composer-shell */ + showFastModeIcon: fastModeEnabled, + }; } export const TraitsPicker = memo(function TraitsPicker({ @@ -447,6 +455,9 @@ export const TraitsPicker = memo(function TraitsPicker({ allowPromptInjectedEffort = true, triggerVariant, triggerClassName, + /* fork:begin fork-composer-shell — see .fork/customizations.yaml#fork-composer-shell */ + triggerLabelSeparator, + /* fork:end fork-composer-shell */ ...persistence }: TraitsMenuContentProps & TraitsPersistence) { const [isMenuOpen, setIsMenuOpen] = useState(false); @@ -477,6 +488,9 @@ export const TraitsPicker = memo(function TraitsPicker({ descriptors, primarySelectDescriptorId: primarySelectDescriptor?.id ?? null, ultrathinkPromptControlled, + /* fork:begin fork-composer-shell — see .fork/customizations.yaml#fork-composer-shell */ + ...(triggerLabelSeparator !== undefined ? { labelSeparator: triggerLabelSeparator } : {}), + /* fork:end fork-composer-shell */ }); const fastModeIcon = showFastModeIcon ? ( <> diff --git a/apps/web/src/components/chat/composerProviderState.tsx b/apps/web/src/components/chat/composerProviderState.tsx index 1349e2509b7b5..2acb047897d1d 100644 --- a/apps/web/src/components/chat/composerProviderState.tsx +++ b/apps/web/src/components/chat/composerProviderState.tsx @@ -46,6 +46,9 @@ type TraitsRenderInput = { modelOptions: ReadonlyArray | undefined; prompt: string; onPromptChange: (prompt: string) => void; + /* fork:begin fork-composer-shell — see .fork/customizations.yaml#fork-composer-shell */ + triggerLabelSeparator?: string; + /* fork:end fork-composer-shell */ }; export function getComposerPromptInjectionState(prompt: string): ComposerPromptInjectionState { @@ -94,6 +97,9 @@ function renderTraitsControl( modelOptions, prompt, onPromptChange, + /* fork:begin fork-composer-shell — see .fork/customizations.yaml#fork-composer-shell */ + triggerLabelSeparator, + /* fork:end fork-composer-shell */ } = input; const hasTarget = threadRef !== undefined || draftId !== undefined; if ( @@ -113,6 +119,7 @@ function renderTraitsControl( modelOptions={modelOptions} prompt={prompt} onPromptChange={onPromptChange} + {...(triggerLabelSeparator !== undefined ? { triggerLabelSeparator } : {})} /> ); } diff --git a/apps/web/src/custom/ComposerControlRow.tsx b/apps/web/src/custom/ComposerControlRow.tsx deleted file mode 100644 index 80b4b6b1b828a..0000000000000 --- a/apps/web/src/custom/ComposerControlRow.tsx +++ /dev/null @@ -1,66 +0,0 @@ -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; -} - -interface ComposerContextRowProps { - children: ReactNode; - className?: string; -} - -/** Repository and worktree controls that sit above the prompt surface. */ -export const ComposerContextRow = memo(function ComposerContextRow({ - children, - className, -}: ComposerContextRowProps) { - return ( -
- {children} -
- ); -}); - -/** - * The composer's control row, below the prompt surface. Run modes stay left; - * context usage, model and effort stay right. - */ -export const ComposerControlRow = memo(function ComposerControlRow({ - left, - right, - className, -}: ComposerControlRowProps) { - return ( -
-
- {left} -
- {right ? ( -
- {right} -
- ) : null} -
- ); -}); diff --git a/apps/web/src/custom/ComposerShell.css b/apps/web/src/custom/ComposerShell.css new file mode 100644 index 0000000000000..0012f6eddf6ea --- /dev/null +++ b/apps/web/src/custom/ComposerShell.css @@ -0,0 +1,31 @@ +:root[data-fork="noahhendrickson-t3code"] [data-fork-composer-mode-chip] { + height: 20px; + min-height: 20px; + gap: 6px; + padding-inline: 8px; + border-radius: 4px; + font-size: 12px; + font-weight: 500; + line-height: 16px; + background: var(--fork-mode-bg); + color: var(--fork-mode-fg); +} + +:root[data-fork="noahhendrickson-t3code"].dark [data-fork-composer-mode-chip] { + color: var(--fork-mode-fg-dark); +} + +:root[data-fork="noahhendrickson-t3code"] + [data-fork-composer-mode-chip]:is(:hover, [data-pressed], [data-popup-open]) { + background: var(--fork-mode-bg-hover); + color: var(--fork-mode-fg); +} + +:root[data-fork="noahhendrickson-t3code"].dark + [data-fork-composer-mode-chip]:is(:hover, [data-pressed], [data-popup-open]) { + color: var(--fork-mode-fg-dark); +} + +:root[data-fork="noahhendrickson-t3code"] [data-fork-composer-mode-chip] [data-slot="select-icon"] { + display: none; +} diff --git a/apps/web/src/custom/ComposerShell.tsx b/apps/web/src/custom/ComposerShell.tsx new file mode 100644 index 0000000000000..44142bb879772 --- /dev/null +++ b/apps/web/src/custom/ComposerShell.tsx @@ -0,0 +1,195 @@ +import type { RuntimeMode } from "@t3tools/contracts"; +import { memo, type ComponentProps, type CSSProperties, type ReactNode } from "react"; + +import { ComposerSelectControl } from "../components/chat/ComposerControl"; +import { cn } from "~/lib/utils"; + +import "./ComposerShell.css"; + +type ComposerShellVisibilityInput = { + approvalPending: boolean; + collapsedMobile: boolean; + mobilePendingActionsVisible: boolean; +}; + +export function resolveComposerShellVisibility({ + approvalPending, + collapsedMobile, + mobilePendingActionsVisible, +}: ComposerShellVisibilityInput) { + return { + showInlinePrimaryAction: !approvalPending && !mobilePendingActionsVisible, + showInteractiveControls: !approvalPending && !collapsedMobile, + }; +} + +type ComposerShellProps = ComposerShellVisibilityInput & { + children?: ReactNode; + context?: ReactNode; + modeControls: ReactNode; + modelControls: ReactNode; + readoutControls: ReactNode; +}; + +/** Owns the fork's context → surface → controls composition and state gates. */ +export const ComposerShell = memo(function ComposerShell({ + approvalPending, + children, + collapsedMobile, + context, + mobilePendingActionsVisible, + modeControls, + modelControls, + readoutControls, +}: ComposerShellProps) { + const { showInteractiveControls } = resolveComposerShellVisibility({ + approvalPending, + collapsedMobile, + mobilePendingActionsVisible, + }); + const left = showInteractiveControls ? modeControls : null; + const right = + readoutControls || (showInteractiveControls && modelControls) ? ( + <> + {readoutControls} + {showInteractiveControls ? ( +
+ {modelControls} +
+ ) : null} + + ) : null; + + return ( + <> + {context ? ( +
+ {context} +
+ ) : null} + {children} + {left || right ? ( +
+
+ {left} +
+ {right ? ( +
+ {right} +
+ ) : null} +
+ ) : null} + + ); +}); + +type ComposerPromptRowProps = Pick< + ComposerShellVisibilityInput, + "approvalPending" | "mobilePendingActionsVisible" +> & { + action: ReactNode; + children?: ReactNode; +}; + +/** Keeps the prompt and its single primary-action cluster in the drawn base row. */ +export const ComposerPromptRow = memo(function ComposerPromptRow({ + action, + approvalPending, + children, + mobilePendingActionsVisible, +}: ComposerPromptRowProps) { + const { showInlinePrimaryAction } = resolveComposerShellVisibility({ + approvalPending, + collapsedMobile: false, + mobilePendingActionsVisible, + }); + + return ( +
+
+ {children} +
+ {showInlinePrimaryAction ? ( +
+ {action} +
+ ) : null} +
+ ); +}); + +type RuntimeModeChipStyle = CSSProperties & { + "--fork-mode-bg": string; + "--fork-mode-bg-hover": string; + "--fork-mode-fg": string; + "--fork-mode-fg-dark": string; +}; + +const runtimeModeChipStyles = { + auto: { + "--fork-mode-bg": "rgb(18 255 89 / 16%)", + "--fork-mode-bg-hover": "rgb(18 255 89 / 24%)", + "--fork-mode-fg": "var(--color-emerald-700)", + "--fork-mode-fg-dark": "#00ff88", + }, + "full-access": { + "--fork-mode-bg": "rgb(255 205 89 / 16%)", + "--fork-mode-bg-hover": "rgb(255 205 89 / 24%)", + "--fork-mode-fg": "var(--color-amber-700)", + "--fork-mode-fg-dark": "#ffcd59", + }, + "auto-accept-edits": { + "--fork-mode-bg": "rgb(192 132 252 / 16%)", + "--fork-mode-bg-hover": "rgb(192 132 252 / 24%)", + "--fork-mode-fg": "var(--color-violet-700)", + "--fork-mode-fg-dark": "#c084fc", + }, + "approval-required": { + "--fork-mode-bg": "rgb(255 107 96 / 16%)", + "--fork-mode-bg-hover": "rgb(255 107 96 / 24%)", + "--fork-mode-fg": "var(--color-red-700)", + "--fork-mode-fg-dark": "#ff6b60", + }, +} satisfies Record; + +export function getRuntimeModeChipStyle(runtimeMode: RuntimeMode): RuntimeModeChipStyle { + return runtimeModeChipStyles[runtimeMode]; +} + +type ComposerRuntimeModeTriggerProps = Omit< + ComponentProps, + "style" +> & { + runtimeMode: RuntimeMode; + style?: CSSProperties; +}; + +/** The runtime select trigger with its mode-specific accent tokens. */ +export function ComposerRuntimeModeTrigger({ + className, + runtimeMode, + style, + ...props +}: ComposerRuntimeModeTriggerProps) { + return ( + + ); +} diff --git a/apps/web/src/theme.custom.css b/apps/web/src/theme.custom.css index c4b068be842c0..d2b55cc7116c9 100644 --- a/apps/web/src/theme.custom.css +++ b/apps/web/src/theme.custom.css @@ -326,8 +326,9 @@ } /* Mobile's collapsed tap target remains a distinct functional state. */ -:root[data-fork="noahhendrickson-t3code"] [data-fork-composer-density="collapsed"] { - --fork-composer-radius: 12px; +:root[data-fork="noahhendrickson-t3code"] + [data-chat-composer-mobile-collapsed="true"][data-fork-composer-surface] { + border-radius: 12px; } /* Selected by name, not by position. This used to be `[data-fork-composer-box] @@ -469,31 +470,14 @@ } } -/* The control row's ghost buttons: 20px tall, 12px medium type on a 16px +/* The control row's ghost controls: 20px tall, 12px medium type on a 16px line, a 12px glyph, 8px inline padding, at a 4px radius (Noey's call over - the drawn full pill, so hover fills paint 4px corners like every other - chip). The runtime mode chip is excluded by name: it is the one - accent-styled control on the row and gets its own treatment below. - - 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. */ + the drawn full pill). The named mode and model groups contain only ghost + controls; the mode chip is the single exception and owns its styling in + `custom/ComposerShell.css`. */ :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]):not( - [data-fork-composer-mode-chip] - ):not([data-fork-composer-status] button) { + :is([data-fork-composer-model-controls], [data-fork-composer-control-row-slot="left"]) + :is(button, [data-slot="button"]):not([data-fork-composer-mode-chip]) { height: 20px; min-height: 20px; gap: 6px; @@ -505,101 +489,19 @@ } :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]):not( - [data-fork-composer-mode-chip] - ):not([data-fork-composer-status] button) + :is([data-fork-composer-model-controls], [data-fork-composer-control-row-slot="left"]) + :is(button, [data-slot="button"]):not([data-fork-composer-mode-chip]) 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]):not( - [data-fork-composer-mode-chip] - ) { + :is([data-fork-composer-model-controls], [data-fork-composer-control-row-slot="left"]) + :is(button, [data-slot="button"]):not([data-fork-composer-mode-chip]) { color: #a6a6a6; } -/* The runtime mode chip: the one accent control on the row. The designs draw - it as a 20px, 4px-radius chip carrying the label alone on an accent wash — - no icon, no caret (the select popup still opens on click and keeps both). - The attribute value is the runtime mode, and each mode gets its own hue: - auto green (the drawn state), full access yellow, auto-accept edits purple, - supervised red. Dark mode uses neon label text over a 16% wash of the same - hue; light mode keeps the wash and drops each label to its dark Tailwind - shade so it stays readable. Hover deepens the wash rather than falling - back to the ghost variant's gray. */ -:root[data-fork="noahhendrickson-t3code"] { - --fork-composer-mode-chip-bg: rgb(18 255 89 / 16%); - --fork-composer-mode-chip-bg-hover: rgb(18 255 89 / 24%); - --fork-composer-mode-chip-fg: var(--color-emerald-700); -} - -:root[data-fork="noahhendrickson-t3code"].dark { - --fork-composer-mode-chip-fg: #00ff88; -} - -:root[data-fork="noahhendrickson-t3code"] [data-fork-composer-mode-chip="full-access"] { - --fork-composer-mode-chip-bg: rgb(255 205 89 / 16%); - --fork-composer-mode-chip-bg-hover: rgb(255 205 89 / 24%); - --fork-composer-mode-chip-fg: var(--color-amber-700); -} - -:root[data-fork="noahhendrickson-t3code"].dark [data-fork-composer-mode-chip="full-access"] { - --fork-composer-mode-chip-fg: #ffcd59; -} - -:root[data-fork="noahhendrickson-t3code"] [data-fork-composer-mode-chip="auto-accept-edits"] { - --fork-composer-mode-chip-bg: rgb(192 132 252 / 16%); - --fork-composer-mode-chip-bg-hover: rgb(192 132 252 / 24%); - --fork-composer-mode-chip-fg: var(--color-violet-700); -} - -:root[data-fork="noahhendrickson-t3code"].dark [data-fork-composer-mode-chip="auto-accept-edits"] { - --fork-composer-mode-chip-fg: #c084fc; -} - -:root[data-fork="noahhendrickson-t3code"] [data-fork-composer-mode-chip="approval-required"] { - --fork-composer-mode-chip-bg: rgb(255 107 96 / 16%); - --fork-composer-mode-chip-bg-hover: rgb(255 107 96 / 24%); - --fork-composer-mode-chip-fg: var(--color-red-700); -} - -:root[data-fork="noahhendrickson-t3code"].dark [data-fork-composer-mode-chip="approval-required"] { - --fork-composer-mode-chip-fg: #ff6b60; -} - -:root[data-fork="noahhendrickson-t3code"] - [data-fork-composer-control-row] - [data-fork-composer-mode-chip] { - height: 20px; - min-height: 20px; - gap: 6px; - padding-inline: 8px; - border-radius: 4px; - font-size: 12px; - font-weight: 500; - line-height: 16px; - background: var(--fork-composer-mode-chip-bg); - color: var(--fork-composer-mode-chip-fg); -} - -:root[data-fork="noahhendrickson-t3code"] - [data-fork-composer-control-row] - [data-fork-composer-mode-chip]:is(:hover, [data-pressed], [data-popup-open]) { - background: var(--fork-composer-mode-chip-bg-hover); - color: var(--fork-composer-mode-chip-fg); -} - -:root[data-fork="noahhendrickson-t3code"] - [data-fork-composer-control-row] - [data-fork-composer-mode-chip] - [data-slot="select-icon"] { - display: none; -} - /* Send and stop are the one exception to the pill rule: 24px squares at an 8px radius, drawn as the only filled controls in the composer. */ :root[data-fork="noahhendrickson-t3code"] [data-fork-composer-action] { From dce41aaf973e2895fb5db451a79820309c2f96e1 Mon Sep 17 00:00:00 2001 From: noah Date: Fri, 31 Jul 2026 17:18:36 -0400 Subject: [PATCH 3/4] fix(web): keep draft greeting centered above docked composer Empty-draft hero text was still stacked just above the input after the composer was docked. Render the greeting as its own centered layer. Co-authored-by: Cursor --- .fork/customizations.yaml | 7 ++- .../__fork_guards__/forkComposerShell.test.ts | 12 ++++- apps/web/src/components/ChatView.tsx | 46 +++++++++---------- 3 files changed, 39 insertions(+), 26 deletions(-) diff --git a/.fork/customizations.yaml b/.fork/customizations.yaml index 6cd45e30601d1..e9fad854aded0 100644 --- a/.fork/customizations.yaml +++ b/.fork/customizations.yaml @@ -1124,7 +1124,9 @@ intent: > The chat composer is a discrete 8px bordered surface with one compact base layout in every thread state, including a brand-new draft. ChatView always - docks it to the bottom instead of centering it as a new-chat hero. The base + docks it to the bottom instead of centering it as a new-chat hero. The + empty-draft greeting ("What should we build…") is a separate absolute layer + centered in the chat column, not stacked above the docked input. The base row is 40px tall: 8px padding around a one-line prompt and the 24px send/stop button. The prompt and action remain in that same row as text wraps — the action pinned top-right, per the design's tall frame — so the @@ -1186,7 +1188,8 @@ watch: # Carries the base row, the context/control rows, and contextStrip prop. - apps/web/src/components/chat/ChatComposer.tsx - # Docks the composer for new drafts and passes BranchToolbar as contextStrip. + # Docks the composer for new drafts, centers the draft greeting on its own + # layer, and passes BranchToolbar as contextStrip. - apps/web/src/components/ChatView.tsx # data-fork-composer-action on the send and stop buttons; the whole 24px # square restyle hangs off it. Send also carries diff --git a/apps/web/src/__fork_guards__/forkComposerShell.test.ts b/apps/web/src/__fork_guards__/forkComposerShell.test.ts index 5bcd018e73ace..584dea0364867 100644 --- a/apps/web/src/__fork_guards__/forkComposerShell.test.ts +++ b/apps/web/src/__fork_guards__/forkComposerShell.test.ts @@ -117,12 +117,22 @@ describe("fork guard: fork-composer-shell", () => { expect(collapsed?.body).toMatch(/border-radius:\s*12px/u); }); - it("docks the composer at the bottom for empty and started threads", () => { + it("docks the composer at the bottom and centers the draft greeting independently", () => { expect(chatView).toContain( 'className="pointer-events-none absolute inset-x-0 bottom-0 z-20 pt-1.5 sm:pt-2"', ); expect(chatView).not.toMatch(/isDraftHeroState\s*\?\s*"pointer-events-none absolute inset-0/u); expect(chatView).not.toContain("isDraftHero={isDraftHeroState}"); + // Greeting is its own centered layer; it must not ride bottom-full above the input. + expect(chatView).toContain( + 'className="pointer-events-none absolute inset-0 z-10 flex items-center"', + ); + const headlineIdx = chatView.indexOf(" { diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 52b2d8a046e99..79a58a79f12ad 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -5879,6 +5879,27 @@ function ChatViewContent(props: ChatViewProps) { )} + {/* Draft greeting — centered in the chat column; composer stays docked below. */} + {isDraftHeroState ? ( +
+
+ +
+
+ ) : null} + {/* Input bar — always docked at the bottom, including an empty draft. */}
- {isDraftHeroState ? ( -
-
- -
- -
- ) : ( - - )} + {threadSyncPhase && !activeEnvironmentUnavailable ? ( ) : null} @@ -6038,7 +6038,7 @@ function ChatViewContent(props: ChatViewProps) { key={`${activeThreadKey}:${activePreviewMiniPlayer.tabId}`} threadRef={activeThreadRef} tabId={activePreviewMiniPlayer.tabId} - bottomInset={isDraftHeroState ? 0 : composerOverlayHeight} + bottomInset={composerOverlayHeight} /> ) : null} From 3b56807a6527d59f128e176183aa722f50f8f239 Mon Sep 17 00:00:00 2001 From: noah Date: Sat, 1 Aug 2026 00:49:24 -0400 Subject: [PATCH 4/4] docs(fork): add PR 41 composer screenshots --- .fork/notes/pr-41/README.md | 7 +++++++ .fork/notes/pr-41/composer-after.png | Bin 0 -> 25438 bytes .fork/notes/pr-41/composer-before.png | Bin 0 -> 33000 bytes 3 files changed, 7 insertions(+) create mode 100644 .fork/notes/pr-41/README.md create mode 100644 .fork/notes/pr-41/composer-after.png create mode 100644 .fork/notes/pr-41/composer-before.png diff --git a/.fork/notes/pr-41/README.md b/.fork/notes/pr-41/README.md new file mode 100644 index 0000000000000..a2bff1e39394a --- /dev/null +++ b/.fork/notes/pr-41/README.md @@ -0,0 +1,7 @@ +# PR #41 composer screenshots + +Before/after captures for the visual checklist on +[`NoahHendrickson/t3code#41`](https://github.com/NoahHendrickson/t3code/pull/41). + +- `composer-before.png` — centered card with secondary controls below +- `composer-after.png` — bottom-docked composer with context above and settings below diff --git a/.fork/notes/pr-41/composer-after.png b/.fork/notes/pr-41/composer-after.png new file mode 100644 index 0000000000000000000000000000000000000000..8b523e3c6299d76377735cad23dbeea929aa50e4 GIT binary patch literal 25438 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⩔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 Bo5}zH literal 0 HcmV?d00001 diff --git a/.fork/notes/pr-41/composer-before.png b/.fork/notes/pr-41/composer-before.png new file mode 100644 index 0000000000000000000000000000000000000000..97f38e205d961be43d9ae3c320245f07765744b9 GIT binary patch literal 33000 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