diff --git a/.fork/customizations.yaml b/.fork/customizations.yaml index 6d127536fb8c5..e9fad854aded0 100644 --- a/.fork/customizations.yaml +++ b/.fork/customizations.yaml @@ -1122,141 +1122,84 @@ - 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 + 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 + 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 (24px 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 — with its + hue selected by a typed fork-owned token map: 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 composer's effort trigger joins its trait labels + with a plain space ("High 1M") while Settings keeps 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, + model, and effort controls remain gated during a pending approval and while + the mobile composer is collapsed; the read-only context meter may remain. + The inline primary-action cluster is suppressed whenever the mobile + pending-answer cluster is visible, so only one answer action renders. + + 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/custom/ComposerShell.tsx + - apps/web/src/custom/ComposerShell.css - 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, 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 # 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 + # Passes the composer-only label separator through to TraitsPicker. + - apps/web/src/components/chat/composerProviderState.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/.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 0000000000000..8b523e3c6299d Binary files /dev/null and b/.fork/notes/pr-41/composer-after.png differ diff --git a/.fork/notes/pr-41/composer-before.png b/.fork/notes/pr-41/composer-before.png new file mode 100644 index 0000000000000..97f38e205d961 Binary files /dev/null and b/.fork/notes/pr-41/composer-before.png differ diff --git a/apps/web/src/__fork_guards__/forkComposerShell.test.ts b/apps/web/src/__fork_guards__/forkComposerShell.test.ts index 0ef871e6b806d..584dea0364867 100644 --- a/apps/web/src/__fork_guards__/forkComposerShell.test.ts +++ b/apps/web/src/__fork_guards__/forkComposerShell.test.ts @@ -1,26 +1,19 @@ // @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 { createElement } from "react"; +import { renderToStaticMarkup } from "react-dom/server"; 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"; + ComposerPromptRow, + ComposerShell, + getRuntimeModeChipStyle, + resolveComposerShellVisibility, +} from "../custom/ComposerShell"; +import { FORK_MARKER_ATTRIBUTE, FORK_MARKER_VALUE } from "../custom/forkMarker"; import { cssRules } from "./cssRules"; function readSibling(relativePath: string): string { @@ -28,443 +21,263 @@ function readSibling(relativePath: string): string { } const MARKER = `:root[${FORK_MARKER_ATTRIBUTE}="${FORK_MARKER_VALUE}"]`; - const theme = readSibling("../theme.custom.css"); +const shellCss = readSibling("../custom/ComposerShell.css"); +const styles = `${theme}\n${shellCss}`; +const rules = cssRules(styles); 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"); + +function shellMarkup(input: { approvalPending?: boolean; collapsedMobile?: boolean } = {}) { + return renderToStaticMarkup( + createElement( + ComposerShell, + { + approvalPending: input.approvalPending ?? false, + collapsedMobile: input.collapsedMobile ?? false, + context: createElement("span", { "data-test-context": true }), + mobilePendingActionsVisible: false, + modeControls: createElement("button", { "data-test-mode": true }), + modelControls: createElement("button", { "data-test-model": true }), + readoutControls: createElement("span", { "data-test-readout": true }), + }, + createElement("div", { "data-test-surface": true }), + ), + ); +} + +function promptMarkup(input: { approvalPending?: boolean; mobilePendingActionsVisible?: boolean }) { + return renderToStaticMarkup( + createElement( + ComposerPromptRow, + { + action: createElement("button", { "data-test-primary-action": true }), + approvalPending: input.approvalPending ?? false, + mobilePendingActionsVisible: input.mobilePendingActionsVisible ?? false, + }, + createElement("div", { "data-test-editor": true }), + ), + ); +} 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"', - ); - expect(controlRow).toContain('data-fork-composer-control-row="true"'); + it("orders context, surface, and controls in the fork-owned shell", () => { + const markup = shellMarkup(); + const context = markup.indexOf("data-test-context"); + const surface = markup.indexOf("data-test-surface"); + const controls = markup.indexOf("data-fork-composer-control-row"); + + expect(context).toBeGreaterThanOrEqual(0); + expect(context).toBeLessThan(surface); + expect(surface).toBeLessThan(controls); }); - 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("gates every interactive control during approvals but keeps readouts", () => { + expect( + resolveComposerShellVisibility({ + approvalPending: true, + collapsedMobile: false, + mobilePendingActionsVisible: false, + }), + ).toEqual({ showInlinePrimaryAction: false, showInteractiveControls: false }); - 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); + const markup = shellMarkup({ approvalPending: true }); + expect(markup).not.toContain("data-test-mode"); + expect(markup).not.toContain("data-test-model"); + expect(markup).toContain("data-test-readout"); }); - 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("hides model and mode controls under the collapsed mobile composer", () => { + const markup = shellMarkup({ collapsedMobile: true }); + expect(markup).not.toContain("data-test-mode"); + expect(markup).not.toContain("data-test-model"); + expect(markup).toContain("data-test-context"); + expect(markup).toContain("data-test-readout"); }); - 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("renders only one primary-action cluster for a mobile pending answer", () => { + expect(promptMarkup({})).toContain("data-test-primary-action"); + expect(promptMarkup({ mobilePendingActionsVisible: true })).not.toContain( + "data-test-primary-action", + ); + expect(promptMarkup({ approvalPending: true })).not.toContain("data-test-primary-action"); }); - 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); + it("keeps one base shell without the removed density machinery", () => { + expect(chatComposer).not.toContain("composerDensity"); + expect(chatComposer).not.toContain("data-fork-composer-density"); + expect(chatComposer).not.toContain("useComposerPromptWrapLatch"); + expect(styles).not.toContain("data-fork-composer-density"); - 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, + const collapsed = rules.find((rule) => + rule.selector.includes('[data-chat-composer-mobile-collapsed="true"]'), ); + expect(collapsed?.selector).toContain("[data-fork-composer-surface]"); + expect(collapsed?.body).toMatch(/border-radius:\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("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(" { - // 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 composer styling scoped to the fork marker", () => { + const composerRules = rules.filter( + (rule) => + rule.selector.includes("[data-fork-composer") || + rule.selector.includes("chat-composer-glass") || + rule.selector.includes("chat-composer-context-strip"), ); - 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])"); + expect(composerRules.length).toBeGreaterThan(0); + for (const rule of composerRules) { + expect(rule.selector, `unscoped composer rule: ${rule.selector}`).toContain(MARKER); } }); - 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("paints the surface by name and restores drag-over feedback", () => { + expect(chatComposer).toContain('data-fork-composer-surface="true"'); + expect(chatComposer).toContain("data-fork-composer-drag-over="); + expect( + rules.filter((rule) => /\[data-fork-composer-box\]\s*>\s*div/u.test(rule.selector)), + ).toHaveLength(0); - 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); + const surface = rules.find( + (rule) => + rule.selector.includes("[data-fork-composer-surface]") && rule.body.includes("box-shadow"), + ); + expect(surface?.body).toMatch(/box-shadow:\s*inset 0 0 0 1px var\(--fork-composer-border\)/u); + const drag = rules.find((rule) => rule.selector.includes("[data-fork-composer-drag-over]")); + expect(drag?.body).toMatch(/background:/u); + expect(drag?.body).toMatch(/outline:\s*2px solid/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)); - + it("moves the desktop scrollport to the prompt wrapper and clips its placeholder", () => { + const isDesktop = (rule: (typeof rules)[number]) => + rule.atRules.some((atRule) => /@media\s*\(\s*width\s*>=\s*40rem\s*\)/u.test(atRule)); const scrollport = rules.find( (rule) => - inDesktopLineBoxMedia(rule) && + isDesktop(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); + 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) && + isDesktop(rule) && rule.selector.includes("[data-fork-composer-prompt]") && rule.selector.includes('[data-testid="composer-editor"]') && !/~\s*div/u.test(rule.selector), ); - 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(editor?.body).toMatch(/max-height:\s*none/u); + expect(editor?.body).toMatch(/overflow-y:\s*hidden/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( + const placeholder = rules.find( (rule) => - inDesktopLineBoxMedia(rule) && - rule.selector.includes("[data-fork-composer-prompt]") && - /\[data-testid="composer-editor"\]\s*~\s*div/u.test(rule.selector), + isDesktop(rule) && + /\[data-testid="composer-editor"\]\s*~\s*div/u.test(rule.selector) && + rule.body.includes("overflow"), ); - expect(siblings, "desktop placeholder clip rule missing or unscoped").toBeDefined(); - expect(siblings, "the unclamp and clip rules must stay distinct").not.toBe(editor); - expect(siblings!.body).toMatch(/overflow:\s*clip/u); - - // An imperative attribute toggle is the failure mode this replaces — see - // .fork/notes/FORK-CUSTOMIZATION-DECISIONS.md#fork-composer-shell. - expect(theme).not.toContain("data-composer-prompt-scrollable"); - expect(chatComposer).not.toContain("data-composer-prompt-scrollable"); + expect(placeholder?.body).toMatch(/overflow:\s*clip/u); + expect(styles).not.toMatch(/\[data-testid="composer-editor"\]\s*\+\s*div/u); }); - it("squares the send and stop buttons, flattens release send, and reddens stop", () => { - 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, - ); - 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, - ); - 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, - ); - 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, + it("keeps the prompt type, one-line minimum, and placeholder treatment", () => { + const editorMin = rules.find( + (rule) => + rule.selector.includes('[data-testid="composer-editor"]') && + rule.body.includes("min-height"), ); - expect(nextWrapLatch({ latched: false, measuredWrapped: false, isPromptEmpty: false })).toBe( - false, + expect(editorMin?.body).toMatch(/min-height:\s*0/u); + const promptType = rules.find( + (rule) => + rule.selector.includes('[data-testid="composer-editor"]') && + rule.body.includes("font-size"), ); - }); - - 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, + expect(promptType?.body).toMatch(/font-size:\s*14px/u); + expect(promptType?.body).toMatch(/line-height:\s*23px/u); + const placeholderColor = rules.find( + (rule) => + /\[data-testid="composer-editor"\]\s*~\s*div/u.test(rule.selector) && + rule.body.includes("color"), ); + expect(placeholderColor?.body).toMatch(/color:\s*var\(--muted-foreground\)/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 ghost geometry on named interactive groups only", () => { + const geometry = rules.find( (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(); + rule.selector.includes("[data-fork-composer-model-controls]") && + rule.body.includes("height: 20px"), + ); + expect(geometry?.selector).toContain('[data-fork-composer-control-row-slot="left"]'); + expect(geometry?.selector).not.toContain("data-fork-composer-status"); + expect(geometry?.selector).not.toContain("data-fork-composer-action"); + expect(geometry?.body).toMatch(/border-radius:\s*4px/u); + expect(geometry?.body).toMatch(/font-size:\s*12px/u); }); - 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* { + const modeRule = rules.find( + (rule) => + rule.selector.endsWith("[data-fork-composer-mode-chip]") && + rule.body.includes("height: 20px"), + ); + expect(modeRule?.body).toMatch(/border-radius:\s*4px/u); + expect(modeRule?.body).toMatch(/background:\s*var\(--fork-mode-bg\)/u); + const tokens = [ + getRuntimeModeChipStyle("auto"), + getRuntimeModeChipStyle("full-access"), + getRuntimeModeChipStyle("auto-accept-edits"), + getRuntimeModeChipStyle("approval-required"), + ]; + expect(new Set(tokens.map((token) => token["--fork-mode-bg"])).size).toBe(4); + expect(new Set(tokens.map((token) => token["--fork-mode-fg-dark"])).size).toBe(4); }); - it("keeps the pill restyle off the primary CTAs", () => { - // 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("keeps the provider icon hidden in the composer only", () => { + const iconRules = rules.filter((rule) => + rule.selector.includes("[data-chat-provider-model-picker]"), ); - 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, - ); + expect(iconRules.length).toBeGreaterThan(0); + for (const rule of iconRules) { + expect(rule.selector).toContain("[data-fork-composer-control-row]"); + expect(rule.selector).toContain(":not([data-base-ui-tooltip-trigger])"); } }); - 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]"), + it("keeps context chips at 24px and the meter outside ghost geometry", () => { + const context = rules.find( + (rule) => + rule.selector.includes("[data-fork-composer-context-row]") && + rule.body.includes("height: 24px"), ); - expect(dragRule, "no drag-over rule").toBeDefined(); - expect(dragRule!.body).toMatch(/outline:\s*2px solid/u); - expect(dragRule!.body).toMatch(/background:/u); + expect(context?.body).toMatch(/border-radius:\s*4px/u); + expect(context?.body).toMatch(/padding-inline:\s*4px/u); + const meter = rules.find((rule) => + rule.selector.endsWith("[data-fork-composer-status] button"), + ); + expect(meter?.body).toMatch(/width:\s*24px/u); + expect(meter?.body).toMatch(/height:\s*24px/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( + it("hides only separators in the left mode slot", () => { + const separatorRules = rules.filter( (rule) => rule.selector.includes('[data-slot="separator"]') && rule.selector.includes("fork-composer-control-row"), @@ -475,54 +288,37 @@ describe("fork guard: fork-composer-shell", () => { } }); - 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), + it("keeps primary actions out of ghost sizing", () => { + expect(primaryActions).toContain('data-fork-composer-action="send"'); + expect(primaryActions).toContain('data-fork-composer-action="stop"'); + const action = rules.find( + (rule) => + rule.selector.endsWith("[data-fork-composer-action]") && rule.body.includes("width: 24px"), ); - 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"); + expect(action?.body).toMatch(/height:\s*24px/u); + expect(action?.body).toMatch(/border-radius:\s*8px/u); + for (const rule of rules.filter((candidate) => candidate.body.includes("height: 20px"))) { + expect(rule.selector).not.toMatch( + /data-chat-composer-(inline-actions|mobile-pending-actions)/u, + ); + } }); - 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); + it("switches off the stitched glass shell and flattens the context strip", () => { + for (const selector of [ + ".chat-composer-glass-shell::before", + ".chat-composer-glass-host::after", + ".chat-composer-context-strip::before", + ]) { + const rule = rules.find((candidate) => candidate.selector.includes(selector)); + expect(rule?.body).toMatch(/display:\s*none/u); + } + const strip = rules.find( + (rule) => + rule.selector.includes("[data-fork-composer-context-row]") && + rule.selector.includes(".chat-composer-context-strip"), + ); + expect(strip?.body).toMatch(/margin:\s*0/u); + expect(strip?.body).toMatch(/gap:\s*4px/u); }); }); diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index f42c4cdbab8ea..79a58a79f12ad 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -5879,43 +5879,39 @@ function ChatViewContent(props: ChatViewProps) { )} - {/* Input bar — centered hero while a draft has no messages, docked at the bottom otherwise */} + {/* 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} @@ -5950,7 +5946,6 @@ function ChatViewContent(props: ChatViewProps) { forceExpandedOnMobile={forceExpandedMobileComposer && isDraftHeroState} projectSelectionRequired={isLocalDraftThread && activeProject === null} /* fork:begin fork-composer-shell — see .fork/customizations.yaml#fork-composer-shell */ - isDraftHero={isDraftHeroState} {...(composerContextStrip ? { contextStrip: composerContextStrip } : {})} @@ -6043,7 +6038,7 @@ function ChatViewContent(props: ChatViewProps) { key={`${activeThreadKey}:${activePreviewMiniPlayer.tabId}`} threadRef={activeThreadRef} tabId={activePreviewMiniPlayer.tabId} - bottomInset={isDraftHeroState ? 0 : composerOverlayHeight} + bottomInset={composerOverlayHeight} /> ) : null} diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 5ae8b3e07622b..2d577231e6074 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -86,10 +86,11 @@ import { } from "../composerFooterLayout"; import { type ComposerPromptEditorHandle, ComposerPromptEditor } from "../ComposerPromptEditor"; /* fork:begin fork-composer-shell — see .fork/customizations.yaml#fork-composer-shell */ -import { scopedThreadKey } from "@t3tools/client-runtime/environment"; -import { ComposerControlRow } from "../../custom/ComposerControlRow"; -import { resolveComposerDensity } from "../../custom/composerDensity"; -import { useComposerPromptWrapLatch } from "../../custom/useComposerPromptWrapLatch"; +import { + ComposerPromptRow, + ComposerRuntimeModeTrigger, + ComposerShell, +} from "../../custom/ComposerShell"; /* fork:end fork-composer-shell */ import { ProviderModelPicker } from "./ProviderModelPicker"; import { type ComposerCommandItem, ComposerCommandMenu } from "./ComposerCommandMenu"; @@ -99,7 +100,9 @@ import { ComposerPrimaryActions } from "./ComposerPrimaryActions"; import { ComposerPendingApprovalPanel } from "./ComposerPendingApprovalPanel"; import { ComposerPendingUserInputPanel } from "./ComposerPendingUserInputPanel"; import { ComposerPlanFollowUpBanner } from "./ComposerPlanFollowUpBanner"; -import { ComposerControl, ComposerControlIcon, ComposerSelectControl } from "./ComposerControl"; +/* fork:begin fork-composer-shell — see .fork/customizations.yaml#fork-composer-shell */ +import { ComposerControl } from "./ComposerControl"; +/* fork:end fork-composer-shell */ import { resolveComposerMenuActiveItemId } from "./composerMenuHighlight"; import { searchSlashCommandItems } from "./composerSlashCommandSearch"; import { @@ -175,11 +178,9 @@ import { Button } from "../ui/button"; import { Select, SelectItem, SelectPopup, SelectValue } from "../ui/select"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; import { toastManager } from "../ui/toast"; +/* fork:begin fork-composer-shell — see .fork/customizations.yaml#fork-composer-shell */ import { - BotIcon, CircleAlertIcon, - ListTodoIcon, - PencilRulerIcon, type LucideIcon, LockIcon, LockOpenIcon, @@ -187,6 +188,7 @@ import { SparklesIcon, XIcon, } from "lucide-react"; +/* fork:end fork-composer-shell */ import { proposedPlanTitle } from "../../proposedPlan"; import { getProviderDisplayName, getProviderInteractionModeToggle } from "../../providerModels"; import { @@ -291,7 +293,6 @@ const ComposerFooterModeControls = memo(function ComposerFooterModeControls(prop onTogglePlanSidebar: () => 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 +320,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 +340,15 @@ const ComposerFooterModeControls = memo(function ComposerFooterModeControls(prop onValueChange={(value) => props.onRuntimeModeChange(value!)} > } + render={ + /* fork:begin fork-composer-shell — see .fork/customizations.yaml#fork-composer-shell */ + + /* fork:end fork-composer-shell */ + } > - {runtimeModeOption.label} @@ -394,11 +397,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 +527,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 +648,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 +995,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,23 +1163,8 @@ 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"; - /* fork:end fork-composer-shell */ - const composerFooterHasWideActions = showPlanFollowUpPrompt || activePendingProgress !== null; const showPlanSidebarToggle = Boolean(activePlan || sidebarProposedPlan || planSidebarOpen); const composerFooterActionLayoutKey = useMemo(() => { @@ -1271,6 +1241,9 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) modelOptions: composerModelOptions?.[selectedInstanceId], prompt, onPromptChange: setPromptFromTraits, + /* fork:begin fork-composer-shell — see .fork/customizations.yaml#fork-composer-shell */ + triggerLabelSeparator: " ", + /* fork:end fork-composer-shell */ }); const pendingPrimaryAction = useMemo( () => @@ -2700,9 +2673,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) ); /* fork:begin fork-composer-shell — see .fork/customizations.yaml#fork-composer-shell */ - // The footer's three clusters, split by where the designs put them: the model - // and effort pills live inside the box beside the send button, everything - // else moves to the control row underneath it. + // The footer's three clusters, split by where the designs put them. const composerModelControls = noProviderAvailable ? null : ( <> - {/* 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} - -
- - - {isStashMenuOpen && !composerMenuOpen && !isComposerApprovalState && ( - - setIsStashMenuOpen(false)} - /> - - )} - - {composerMenuOpen && !isComposerApprovalState && ( - - - - )} + ) : null} - {!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 */} -
-
{/* fork:end fork-composer-shell */} @@ -3301,99 +3272,26 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) />
) : null} -
- {/* fork:begin fork-composer-shell — see .fork/customizations.yaml#fork-composer-shell */} - {/* Slim shell: the pills and the send button ride inline with the - prompt rather than sitting on their own row beneath it. */} - {/* No `&& !activePendingApproval` here: an approval sets - hasComposerHeader, which resolves density to tall, so the - right conjunct can never be false when the left is true. */} - {isComposerSlim ? ( -
-
- {composerModelControls} -
- {composerPrimaryActionSlot} -
- ) : null} + {/* fork:begin fork-composer-shell — see .fork/customizations.yaml#fork-composer-shell */} + + {/* fork:end fork-composer-shell */} - {/* fork:end fork-composer-shell */} - - {/* Bottom toolbar */} - {/* fork:begin fork-composer-shell — see .fork/customizations.yaml#fork-composer-shell */} - {/* The slim shell already rendered the pills and the send button - inline with the prompt. Skipping the row outright rather than - hiding it keeps a second data-chat-composer-actions="right" out of - the DOM — and `cn()` is tailwind-merge, so a conditional "hidden" - here would lose to the base "flex" that follows it anyway. An - approval always resolves to the tall shell, so that branch is - still reachable. */} - {composerDensity !== "tall" ? null : activePendingApproval ? ( - /* fork:end fork-composer-shell */ -
- -
- ) : ( -
0 && "pt-2", - isComposerFooterCompact ? "gap-1.5" : "gap-2 sm:gap-0", - showMobilePendingAnswerActions && "hidden sm:flex", - )} - > - {/* fork:begin fork-composer-shell — see .fork/customizations.yaml#fork-composer-shell */} -
- {composerModelControls} + {/* fork:begin fork-composer-shell — see .fork/customizations.yaml#fork-composer-shell */} + {activePendingApproval ? ( +
+
- - {/* Right side: send / stop button */} - {composerPrimaryActionSlot} - {/* fork:end fork-composer-shell */} -
- )} + ) : null} + {/* 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: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 a9f910ec0642d..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,7 +436,12 @@ export function buildTraitsTriggerDisplay(input: { if (labels.length === 0 && hasFastMode) { return { label: fastModeEnabled ? "Fast" : "Normal", showFastModeIcon: false }; } - return { label: labels.join(" · "), showFastModeIcon: fastModeEnabled }; + 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({ @@ -443,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); @@ -473,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 2138348f6ff44..0000000000000 --- a/apps/web/src/custom/ComposerControlRow.tsx +++ /dev/null @@ -1,52 +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; -} - -/** - * The composer's control row, below the box rather than inside it. - * - * The designs put the run controls and the worktree/branch pair on one line - * outside the composer, 4px under it, inset to the box's own 16px padding so - * the two read as one column. Upstream splits these across two places — mode - * controls inside the box's footer, BranchToolbar as a separate strip stitched - * to the box's underside by a shared glass outline — so this joins them and - * `theme.custom.css` unpicks the stitching. - */ -export const ComposerControlRow = memo(function ComposerControlRow({ - left, - right, - className, -}: ComposerControlRowProps) { - return ( -
-
- {left} -
- {right ? ( -
- {right} -
- ) : null} -
- ); -}); diff --git a/apps/web/src/custom/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/custom/composerDensity.ts b/apps/web/src/custom/composerDensity.ts deleted file mode 100644 index 2b50022b38035..0000000000000 --- a/apps/web/src/custom/composerDensity.ts +++ /dev/null @@ -1,114 +0,0 @@ -/** - * Composer density — which shell the chat composer wears. - * - * `tall` stacks the prompt over its own control row, in a 20px-radius box. - * `slim` runs the prompt inline with the model pills and the send button, - * in a 12px-radius box half the height. - * `collapsed` is upstream's mobile tap target, which the fork restyles but does - * not lay out. - * - * The new-chat screen is always tall; a started thread starts slim and grows - * into tall the moment the prompt no longer fits on one line. Everything else - * here is a state the designs do not draw, resolved to tall because the slim - * shell is a single 24px row with nowhere to put approval actions or a pending - * question's Next/Submit pair. - * - * `collapsed` is a distinct value rather than a flavour of `slim` on purpose. - * Folding it into `slim` meant the DOM advertised - * `data-fork-composer-density="slim"` on a composer the slim layout was not - * applied to, so every call site had to re-exclude the collapsed case by hand - * and two things named "slim" disagreed about what they meant. - */ -export type ComposerDensity = "slim" | "tall" | "collapsed"; - -export interface ComposerDensityInput { - /** The new-chat hero: a local draft with no messages yet. Always tall. */ - isDraftHero: boolean; - /** - * The prompt has wrapped past its first line. This must be latched by the - * caller — see `nextWrapLatch` — because the two shells give the editor - * different widths and an unlatched measurement oscillates. - */ - isPromptWrapped: boolean; - /** - * The box is showing an approval request, a pending user-input question, or a - * plan follow-up banner. These own the box's internal layout and carry their - * own action rows, so the inline shell cannot host them. - */ - hasComposerHeader: boolean; - /** The composer is collapsed to a single tap target on mobile. */ - isCollapsedMobile: boolean; - /** - * A phone-width viewport. The slim shell is desktop-only: at 375px the model - * pill and the send button leave the flex-1 editor about 90-150px, and the - * placeholder — an absolutely positioned overlay outside the editor's scroll - * box — wraps to several lines and paints straight through the 48px box with - * nothing to clip it. Worse, it is stable: the overlay never changes the - * observed editor height, so the wrap latch cannot rescue it. - */ - isNarrowViewport: boolean; -} - -export function resolveComposerDensity(input: ComposerDensityInput): ComposerDensity { - if (input.isCollapsedMobile) { - return "collapsed"; - } - if ( - input.isNarrowViewport || - input.isDraftHero || - input.hasComposerHeader || - input.isPromptWrapped - ) { - return "tall"; - } - return "slim"; -} - -/** - * The prompt editor's line box at the fork's desktop composer type (14px/16px). - * Only a fallback: the prompt is 16px on mobile to keep iOS from zooming on - * focus, so the live check reads the element's own computed line-height. - */ -export const COMPOSER_PROMPT_LINE_HEIGHT_PX = 16; - -/** - * Wrapped means taller than one line plus half a line of slack. The slack - * absorbs sub-pixel line-box rounding, which would otherwise flap the composer - * between densities on a single line of text. - */ -export function isPromptHeightWrapped( - promptHeightPx: number, - lineHeightPx: number = COMPOSER_PROMPT_LINE_HEIGHT_PX, -): boolean { - const lineHeight = - Number.isFinite(lineHeightPx) && lineHeightPx > 0 - ? lineHeightPx - : COMPOSER_PROMPT_LINE_HEIGHT_PX; - return promptHeightPx > lineHeight * 1.5; -} - -/** - * The wrap latch, and the reason it has to exist. - * - * The two shells do not give the prompt the same width: slim shares its line - * with the pills and the send button, leaving the editor roughly 460px at the - * composer's 768px max, while tall hands it the full ~736px. So a prompt - * between those two widths wraps in slim and fits in tall — and a density - * derived straight from the measurement flips to tall, un-wraps, flips back to - * slim, re-wraps, forever, pinning the main thread. - * - * Latching breaks the cycle: the wrap only ever turns on from a measurement, - * and only an empty prompt turns it off. That also reads better than the - * alternative, since a composer that snapped back to one line mid-sentence - * would move the caret out from under the user. - */ -export function nextWrapLatch(input: { - latched: boolean; - measuredWrapped: boolean; - isPromptEmpty: boolean; -}): boolean { - if (input.isPromptEmpty) { - return false; - } - return input.latched || input.measuredWrapped; -} diff --git a/apps/web/src/custom/useComposerPromptWrapLatch.ts b/apps/web/src/custom/useComposerPromptWrapLatch.ts deleted file mode 100644 index 9ba92397fa564..0000000000000 --- a/apps/web/src/custom/useComposerPromptWrapLatch.ts +++ /dev/null @@ -1,98 +0,0 @@ -import { useEffect, useState } from "react"; - -import { isPromptHeightWrapped, nextWrapLatch } from "./composerDensity"; - -export interface ComposerPromptWrapLatch { - /** Latched: the prompt has wrapped and has not been cleared since. */ - isPromptWrapped: boolean; - /** Ref callback for the element wrapping the prompt editor. */ - attachPromptElement: (element: HTMLDivElement | null) => void; -} - -/** - * Watches the prompt editor and reports whether it has wrapped past one line — - * the signal that grows the slim shell into the tall one. - * - * This is the impure half of the density decision, and it lives here rather - * than inline in ChatComposer for two reasons: it is fork-authored behaviour - * that upstream has no counterpart for, and keeping it beside the pure - * `nextWrapLatch` it drives means the whole rule reads in one place. - * - * Two things about it are load-bearing: - * - * The measurement is observed, not derived. Lexical reflows the prompt without - * a React render, so a height read during render is stale by construction. - * - * The result is latched, and the caller must not un-latch it. The two shells - * give the prompt different widths — roughly 460px slim against 736px tall — so - * a prompt between them un-wraps the instant the flip lands, re-wraps when it - * flips back, and oscillates until it pins the main thread. - */ -export function useComposerPromptWrapLatch( - prompt: string, - /** - * The draft the prompt belongs to. ChatComposer is not keyed by thread, so it - * persists across thread and draft switches and so does this latch — and the - * latch only ever clears on an *empty* prompt. Without this, leaving a thread - * whose prompt had wrapped and arriving at one with a short saved draft would - * render the new thread in the tall shell and strand it there until the user - * emptied the field. "Only an empty prompt turns it off" is a rule about a - * single composing session; a different thread is a different session. - */ - draftKey: string, -): ComposerPromptWrapLatch { - const [isPromptWrapped, setIsPromptWrapped] = useState(false); - const [promptElement, setPromptElement] = useState(null); - - useEffect(() => { - const editable = promptElement?.querySelector('[data-testid="composer-editor"]'); - if (!editable) { - setIsPromptWrapped(false); - return; - } - const measure = (heightPx: number) => { - const lineHeight = Number.parseFloat(window.getComputedStyle(editable).lineHeight); - const measuredWrapped = isPromptHeightWrapped(heightPx, lineHeight); - setIsPromptWrapped((latched) => - nextWrapLatch({ - latched, - measuredWrapped, - isPromptEmpty: editable.textContent?.trim().length === 0, - }), - ); - }; - const observer = new ResizeObserver((entries) => { - const entry = entries[0]; - if (!entry) return; - measure(entry.contentRect.height); - }); - // No priming read. `observe()` delivers an initial observation for a - // rendered element, so a manual call was always redundant — and it was the - // only path using a padding-box metric (`clientHeight`) against a threshold - // calibrated for the observer's content-box `contentRect`. The editor picks - // up `max-sm:pb-11` in the mobile pending-answer state, where those 44px - // would have latched the wrap on an empty prompt, and a latched value can - // never be lowered by the observer's correct readings afterwards. - observer.observe(editable); - return () => { - observer.disconnect(); - }; - }, [promptElement]); - - // Releasing the latch cannot be left to the observer alone: clearing a - // one-line prompt in the tall shell changes no height, so no resize fires and - // the composer would stay tall for the rest of the thread. - useEffect(() => { - if (prompt.trim().length === 0) { - setIsPromptWrapped(false); - } - }, [prompt]); - - // A new draft is a new composing session, so the latch starts clean whether - // or not the incoming prompt happens to be empty. - useEffect(() => { - setIsPromptWrapped(false); - }, [draftKey]); - - return { isPromptWrapped, attachPromptElement: setPromptElement }; -} diff --git a/apps/web/src/theme.custom.css b/apps/web/src/theme.custom.css index 474ddf97f6c1d..d2b55cc7116c9 100644 --- a/apps/web/src/theme.custom.css +++ b/apps/web/src/theme.custom.css @@ -302,8 +302,7 @@ } :root[data-fork="noahhendrickson-t3code"] { - /* 12px slim, 20px tall — the two radii the designs draw. */ - --fork-composer-radius: 20px; + --fork-composer-radius: 8px; --fork-composer-bg: var(--card); --fork-composer-border: var(--border); --fork-composer-border-focus: var(--ring); @@ -312,7 +311,9 @@ :root[data-fork="noahhendrickson-t3code"].dark { --fork-composer-bg: rgb(41 41 41 / 80%); --fork-composer-border: rgb(255 255 255 / 8%); - --fork-composer-border-focus: #595959; + /* A step above the resting hairline, a step below the old #595959 — the + focus recolour should register, not glow. */ + --fork-composer-border-focus: #474747; } /* The frame div is upstream's 1px gradient-border trick: a p-px shim whose @@ -324,13 +325,10 @@ background: none; } -/* Both short shells take the tighter radius. `collapsed` is listed explicitly - rather than folded into `slim`: it is upstream's mobile tap target, which the - fork restyles but does not lay out, and conflating the two is what previously - had the DOM reporting a slim composer that the slim layout never touched. */ +/* Mobile's collapsed tap target remains a distinct functional state. */ :root[data-fork="noahhendrickson-t3code"] - :is([data-fork-composer-density="slim"], [data-fork-composer-density="collapsed"]) { - --fork-composer-radius: 12px; + [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] @@ -390,14 +388,22 @@ box-shadow: inset 0 0 0 1px var(--fork-composer-border-focus); } -/* The prompt sits on a 16px line box at 14px type. Upstream's min-height holds - the editor open at ~3 lines, which would make the slim shell impossible and - leave the tall one hollow; one line is the drawn height and it grows from - there. 16px is kept below sm so iOS does not zoom the viewport on focus. */ +/* Upstream's min-height holds the editor open at ~3 lines instead of the + compact base row; one line is the drawn height and it grows from there. */ :root[data-fork="noahhendrickson-t3code"] [data-fork-composer-box] [data-testid="composer-editor"] { min-height: 0; } +/* The drawn placeholder is full muted-foreground, not upstream's /35 tint. The + sibling selector only ever matches Lexical's placeholder (and its empty + spacer, which has no text) — typed text lives inside the editor itself. */ +:root[data-fork="noahhendrickson-t3code"] + [data-fork-composer-box] + [data-testid="composer-editor"] + ~ div { + color: var(--muted-foreground); +} + /* The placeholder has to move with the prompt, or the two disagree about both size and leading and the caret sits a couple of pixels off the text it is supposed to be sitting in front of. @@ -418,12 +424,12 @@ :root[data-fork="noahhendrickson-t3code"] [data-fork-composer-box] :is([data-testid="composer-editor"], [data-testid="composer-editor"] ~ div) { + /* The design's body/md: 14px Geist on a 23px line. The 24px action is + what sets the 40px base row (8 + 24 + 8); the one-line 23px prompt sits + centred beside it, and each wrapped line adds 23px. Below sm the type + stays 16px so iOS does not zoom on focus. */ font-size: 14px; - /* Held at 16px rather than scaled with the type. The line box is what the - drawn box heights are built from — 96px tall, 48px slim — so letting it - grow to match 14px type would push the tall box to 100. Tight for a - wrapped prompt; the geometry is the thing being protected. */ - line-height: 16px; + line-height: 23px; } :root[data-fork="noahhendrickson-t3code"] [data-fork-composer-prompt] { @@ -464,57 +470,45 @@ } } -/* The in-box pills and the control row below it share one 24px ghost treatment: - 12px medium type, a 12px glyph, and a full pill. The designs draw this type at - 10px; 12px is the fork's own call, and it matches the prompt beside it. - - Scoped to `[data-fork-composer-pills]` rather than to the footer and the - inline-actions row wholesale. Those containers also hold the primary action - slot, and `ComposerPrimaryActions` renders real CTAs into it: the h-9 - Implement/Refine split button and the pending-question Previous / Next / - Submit answers buttons. Because this stylesheet is unlayered it beat their - Tailwind utilities outright — the split button came apart into two 24px pills - with a broken seam, the CTAs collapsed to 24px, and the `.dark` colour rule - below painted their labels #a6a6a6 on a filled primary background. Both - states are ordinary, not edge cases. - - Send and stop are still excluded by name rather than out-specified by a later - rule. `:is()` takes the highest specificity among its arguments, so this - selector lands at (0,4,0) and a plain `[data-fork-composer-action]` rule at - (0,3,0) would quietly lose its 6px radius back to the pill. */ +/* 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). 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]) { - height: 24px; - min-height: 24px; + :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; - border-radius: 999px; + padding-inline: 8px; + border-radius: 4px; font-size: 12px; font-weight: 500; line-height: 16px; } :root[data-fork="noahhendrickson-t3code"] - :is([data-fork-composer-pills], [data-fork-composer-control-row]) - :is(button, [data-slot="button"]):not([data-fork-composer-action]) + :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]) { + :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; } -/* Send and stop are the one exception to the pill rule: 24px squares at a 6px +/* 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] { width: 24px; height: 24px; min-height: 24px; - border-radius: 6px; + border-radius: 8px; } /* Flat send (tone="flat"): in dark mode, a send button without stage-channel @@ -535,9 +529,8 @@ } /* The model pill is the provider's name and a caret, nothing else. Upstream - leads it with a ProviderInstanceIcon, which made sense when the pill sat in a - crowded footer and had to identify itself; inside the box it is the only - named control, so the icon is redundant with the word beside it. + leads it with a ProviderInstanceIcon, but this is the only named model + control on the lower row, so the icon is redundant with the word beside it. Scoped to the composer: ProviderModelPicker is also used in Settings, where the icon still does the work of distinguishing one configured instance from @@ -549,7 +542,7 @@ the model name becomes the first child — and without this guard the rule would hide the name instead, leaving a pill with nothing but a caret. */ :root[data-fork="noahhendrickson-t3code"] - [data-fork-composer-box] + [data-fork-composer-control-row] [data-chat-provider-model-picker] > span:first-child > span:first-child:not([data-base-ui-tooltip-trigger]) { @@ -577,12 +570,120 @@ cleared the stitched outline. Inside the control row it is just the row's right-hand group, so the wrapper flattens to nothing. */ :root[data-fork="noahhendrickson-t3code"] - [data-fork-composer-control-row-slot="right"] + [data-fork-composer-context-row] .chat-composer-context-strip { width: auto; max-width: none; margin: 0; padding: 0; + /* The drawn 4px between the workspace and branch chips, replacing the + strip's own gap-2. */ + gap: 4px; +} + +/* The context chips are the filled 20px, 6px-radius controls above the + surface. The design draws both on the same selected-surface fill with no + hairline. Light mode falls back to upstream's accent; the drawn values are + dark-only. */ +:root[data-fork="noahhendrickson-t3code"] { + --fork-context-chip-bg: var(--accent); + --fork-context-chip-bg-hover: var(--muted); +} + +:root[data-fork="noahhendrickson-t3code"].dark { + --fork-context-chip-bg: #2a2a2a; + --fork-context-chip-bg-hover: #303030; +} + +:root[data-fork="noahhendrickson-t3code"] + [data-fork-composer-context-row] + :is(button, [data-slot="button"]) { + /* The Auto chip's container at the drawn 4px inline padding, four pixels + taller (Noey's call): 24px, 4px radius. */ + height: 24px; + min-height: 24px; + gap: 6px; + padding-inline: 4px; + border-radius: 4px; + /* 12px, matching the control row's Auto/Build type — Noey's bump over the + drawn 10px. */ + font-size: 12px; + font-weight: 500; + line-height: 16px; + background: var(--fork-context-chip-bg); +} + +:root[data-fork="noahhendrickson-t3code"] + [data-fork-composer-context-row] + :is(button, [data-slot="button"]):is(:hover, [data-pressed], [data-popup-open]) { + background: var(--fork-context-chip-bg-hover); +} + +:root[data-fork="noahhendrickson-t3code"].dark + [data-fork-composer-context-row] + :is(button, [data-slot="button"]) { + color: #a6a6a6; +} + +/* The workspace chip's folder glyph carries more empty space in its 12px + frame than the branch glyph, so at equal box padding its left edge reads + wider. Two fewer pixels on the start side optically matches the pair. + Equal specificity with the shared chip rule; source order wins. */ +:root[data-fork="noahhendrickson-t3code"] + [data-fork-composer-context-row] + [data-slot="select-trigger"] { + padding-inline-start: 2px; +} + +/* The workspace select keeps its popup but drops the trigger caret — the drawn + chip is icon + label only. The branch chip's caret is its own Lucide glyph, + not a select icon, so it stays. */ +:root[data-fork="noahhendrickson-t3code"] + [data-fork-composer-context-row] + [data-slot="select-icon"] { + display: none; +} + +:root[data-fork="noahhendrickson-t3code"] + [data-fork-composer-context-row] + :is(button, [data-slot="button"]) + svg { + width: 12px; + height: 12px; +} + +:root[data-fork="noahhendrickson-t3code"] [data-fork-composer-control-row-slot="right"] { + gap: 0; +} + +/* Context usage is the circular readout immediately before model: a 24px + button around the ring's own 20px span. The ring's circle only inks ~70% of + its frame, so squeezing the span to the drawn 16px leaves a ~11px ring that + reads broken next to the 20px controls; the natural art lands at the drawn + visual weight. The control-geometry and glyph rules above exclude this + button by name because they out-rank anything written here. */ +/* Flex, not block: as a plain div the inline-flex button sits on the text + baseline, and the phantom descender space below it shoves the ring above + the row's centreline. */ +:root[data-fork="noahhendrickson-t3code"] [data-fork-composer-status] { + display: flex; + align-items: center; +} + +:root[data-fork="noahhendrickson-t3code"] [data-fork-composer-status] button { + width: 24px; + height: 24px; + min-height: 24px; + padding: 0; + /* Its hover fill squares off like every other control's. */ + border-radius: 4px; +} + +/* Down from the span's natural 20px: the ring inks ~70% of its frame, so + 16px lands a ~11px visible circle beside the 20px controls. */ +:root[data-fork="noahhendrickson-t3code"] [data-fork-composer-status] button > span { + width: 16px; + height: 16px; } /* Sidebar V2 status palette, keyed to the fork marker so an unmarked build