From 092ecf0b4d6f47a279fb15d2c051352584aabdac Mon Sep 17 00:00:00 2001 From: Sawyer Date: Tue, 29 Sep 2026 22:16:20 -0700 Subject: [PATCH 1/2] feat(web): lean full-width composer and thread measure (CL-9563) --- apps/web/src/app.css | 151 ++++++++++++++++++++---------- apps/web/src/chat/composer.tsx | 162 ++++++++++++++++++++++----------- apps/web/src/chat/styles.css | 123 +++++++++++++------------ 3 files changed, 275 insertions(+), 161 deletions(-) diff --git a/apps/web/src/app.css b/apps/web/src/app.css index 7180f16d5..a63157b7e 100644 --- a/apps/web/src/app.css +++ b/apps/web/src/app.css @@ -18,7 +18,8 @@ (injected at runtime, unrelated to this stylesheet); this just stops react-ui's override from fighting it. Higher specificity than that rule wins the cascade on its own, so this is safe regardless of load order. */ -[data-sonner-toaster][data-x-position="right"] [data-sonner-toast].corbits-toast { +[data-sonner-toaster][data-x-position="right"] + [data-sonner-toast].corbits-toast { left: auto !important; transform: none !important; } @@ -39,7 +40,8 @@ html { /* Inputs in side drawers already have a visible control boundary. Color that edge on focus so the focus treatment reads as one control, not a second outline outside it. */ -[data-slot="dialog-content"][data-side="right"] [data-slot="input"]:focus-visible { +[data-slot="dialog-content"][data-side="right"] + [data-slot="input"]:focus-visible { outline: none; border-color: var(--accent-rail, var(--primary)); } @@ -112,7 +114,9 @@ select:disabled, Scoped to the sidebar so stage content keeps library defaults. */ .shell-sidebar button:not(:disabled):not([aria-disabled="true"]), .shell-sidebar [data-slot="button"]:not(:disabled):not([aria-disabled="true"]), -.shell-sidebar [data-slot="sidebar-item-row"] button:not(:disabled):not([aria-disabled="true"]), +.shell-sidebar + [data-slot="sidebar-item-row"] + button:not(:disabled):not([aria-disabled="true"]), .shell-sidebar .shell-ch-row:not(:disabled):not([aria-disabled="true"]) { cursor: pointer; } @@ -195,7 +199,10 @@ select:disabled, /* Shared dialog craft — layout/scroll/chrome on react-ui data slots only. Feature forms and dialog body content keep their own styles. */ [data-slot="dialog-content"][data-side="center"] { - width: min(calc(100vw - (var(--dialog-gutter) * 2)), var(--dialog-content-max-width)); + width: min( + calc(100vw - (var(--dialog-gutter) * 2)), + var(--dialog-content-max-width) + ); max-height: var(--dialog-content-max-height); overflow: hidden; } @@ -854,7 +861,8 @@ select:disabled, align-items: center; gap: 0.5rem; padding: 0.45rem 0.7rem; - border-bottom: 1px solid color-mix(in srgb, var(--foreground) 10%, transparent); + border-bottom: 1px solid + color-mix(in srgb, var(--foreground) 10%, transparent); } .shell-routine-new { @@ -1028,7 +1036,8 @@ select:disabled, justify-content: space-between; gap: 0.5rem; padding-bottom: 0.55rem; - border-bottom: 1px solid color-mix(in srgb, var(--foreground) 10%, transparent); + border-bottom: 1px solid + color-mix(in srgb, var(--foreground) 10%, transparent); } .shell-canvas-pane-heading { @@ -1656,7 +1665,9 @@ select:disabled, } .shell-sidebar .shell-panel-search [data-slot="input"]::placeholder, -.shell-sidebar [data-slot="sidebar-panel-body"] [data-slot="input"]::placeholder { +.shell-sidebar + [data-slot="sidebar-panel-body"] + [data-slot="input"]::placeholder { color: var(--shell-panel-faint); } @@ -1672,7 +1683,9 @@ select:disabled, box-shadow: none; } -.shell-sidebar [data-slot="sidebar-panel-body"] [data-slot="input"]:focus-visible { +.shell-sidebar + [data-slot="sidebar-panel-body"] + [data-slot="input"]:focus-visible { outline: none; border-color: var(--foreground); box-shadow: 0 0 0 1px var(--foreground); @@ -1703,14 +1716,19 @@ select:disabled, left edge. Selector doubles as the kill for react-ui's bg-primary/10 selected wash. */ .shell-sidebar [data-slot="sidebar-item-row"] > div.bg-primary\/10, -.shell-sidebar [data-slot="sidebar-item-row"] > div:has(button[aria-current="true"]) { +.shell-sidebar + [data-slot="sidebar-item-row"] + > div:has(button[aria-current="true"]) { background-color: var(--background); color: var(--shell-accent); box-shadow: inset 2px 0 0 0 var(--shell-accent); } .shell-sidebar [data-slot="sidebar-item-row"] > div.bg-primary\/10 > button, -.shell-sidebar [data-slot="sidebar-item-row"] > div:has(button[aria-current="true"]) > button { +.shell-sidebar + [data-slot="sidebar-item-row"] + > div:has(button[aria-current="true"]) + > button { font-weight: 600; } @@ -1748,7 +1766,12 @@ select:disabled, } /* Mock .count: the trailing meta slot when it is plain text. */ -.shell-sidebar [data-slot="sidebar-item-row"] > div > button > span + span:last-child { +.shell-sidebar + [data-slot="sidebar-item-row"] + > div + > button + > span + + span:last-child { font-size: 0.72rem; color: var(--shell-panel-faint); } @@ -2254,7 +2277,8 @@ select:disabled, attention-seeking animation, fully off under reduced motion. DESIGN.md's motion ceiling is 300ms — stays at 280ms. */ .onboarding-phase { - animation: onboarding-phase-in var(--duration-large, 300ms) var(--ease-out, ease) both; + animation: onboarding-phase-in var(--duration-large, 300ms) + var(--ease-out, ease) both; } /* The wait phases drop the usual title/subtitle/form stack for the shared @@ -2536,7 +2560,8 @@ select:disabled, .insights-grid > .insights-panel { border: 0; - border-bottom: 1px solid color-mix(in srgb, var(--foreground) 10%, transparent); + border-bottom: 1px solid + color-mix(in srgb, var(--foreground) 10%, transparent); } .insights-grid > .insights-panel:last-child { @@ -2834,7 +2859,11 @@ tr.insights-row-clickable:hover { } /* The current row's copy follows the row's accent ink. */ -.shell-sidebar [data-slot="sidebar-item-row"] button[aria-current="true"] .panel-row-copy strong { +.shell-sidebar + [data-slot="sidebar-item-row"] + button[aria-current="true"] + .panel-row-copy + strong { color: inherit; } @@ -3719,7 +3748,9 @@ tr.insights-row-clickable:hover { list-style: none; } /* A stopped schedule stays listed, dimmed, since only a new one revives it. */ -.workbench-info-schedule-list > li[data-stopped="true"] .workbench-info-cell-primary { +.workbench-info-schedule-list + > li[data-stopped="true"] + .workbench-info-cell-primary { color: var(--muted-foreground); text-decoration: line-through; } @@ -3822,67 +3853,87 @@ tr.insights-row-clickable:hover { color: var(--destructive); } -/* Shared composer: transcript-matched text field on the left, a grouped - action rail (Send only — no turn cancellation exists yet) on the right. */ -.chat-composer { +/* Shared composer: one lean full-width row (+, text, voice, send). */ +.chat-composer-box { display: flex; align-items: flex-end; - gap: 0.5rem; - width: 100%; - padding: 0.5rem 0.75rem; - border: 1px solid var(--border); - border-radius: var(--radius-lg, 0.75rem); - background: var(--card, var(--background)); -} -.chat-composer:focus-within { - border-color: var(--ring, var(--primary)); + gap: 4px; + padding: 6px; + background: var(--surface); + border-radius: 22px; + box-shadow: var(--floating); + transition: box-shadow 150ms ease-out; +} +.chat-composer-box:focus-within { + box-shadow: + 0 0 0 1px var(--line-strong), + 0 6px 20px -6px rgb(0 0 0 / 18%); } .chat-composer-input { flex: 1; min-width: 0; + min-height: 32px; + max-height: 180px; + display: block; resize: none; border: 0; background: transparent; - padding: 0.35rem 0; + padding: 5px 4px; font: inherit; - font-size: max(var(--text-base), 16px); - font-weight: var(--font-weight-medium); + font-size: 15px; line-height: 1.5; - color: var(--foreground); + color: var(--ink); } .chat-composer-input:focus { outline: none; } .chat-composer-input::placeholder { - color: var(--muted-foreground); + color: var(--ink-3); } .chat-composer-input:disabled { cursor: not-allowed; opacity: 0.6; } -.chat-composer-rail { - display: flex; - align-items: center; - gap: 0.25rem; - flex-shrink: 0; - padding-bottom: 0.35rem; -} +.chat-composer-btn, .chat-composer-send { - display: inline-flex; - align-items: center; - justify-content: center; - width: 2rem; - height: 2rem; + display: grid; + place-items: center; + flex: 0 0 32px; + width: 32px; + height: 32px; + padding: 0; border: 0; - border-radius: 999px; - background: var(--primary); - color: var(--primary-foreground); + border-radius: 50%; cursor: pointer; } -.chat-composer-send:disabled { +.chat-composer-btn { + background: transparent; + color: var(--ink-2); +} +.chat-composer-voice { + background: var(--hover); + color: var(--ink); +} +.chat-composer-btn:hover:not(:disabled) { + background: var(--press); +} +.chat-composer-btn:disabled { cursor: not-allowed; - background: var(--muted); - color: var(--muted-foreground); + opacity: 0.4; +} +.chat-composer-send { + background: var(--ink); + color: var(--action); + transition: + transform 160ms var(--ease-out), + opacity 150ms ease; +} +.chat-composer-send:disabled { + cursor: default; + opacity: 0.2; +} +.chat-composer-send:not(:disabled):active { + transform: scale(0.92); } .chat-composer-send-spinner { animation: chat-composer-spin 0.8s linear infinite; diff --git a/apps/web/src/chat/composer.tsx b/apps/web/src/chat/composer.tsx index b15030484..85b1f615c 100644 --- a/apps/web/src/chat/composer.tsx +++ b/apps/web/src/chat/composer.tsx @@ -1,10 +1,15 @@ // No Stop action: neither `threads-api.ts` nor the hub API expose a way to // cancel a running turn yet. import { useCommandPaletteNavigation } from "@corbits/react-ui"; -import { ArrowUp, CircleNotch } from "@/lib/icons"; -import { useRef, useState } from "react"; +import { ArrowUp, CircleNotch, Microphone, Plus } from "@/lib/icons"; +import { useLayoutEffect, useRef, useState } from "react"; -import { activeMention, applyMention, matchMentionQuery, type ActiveMention } from "./mentions"; +import { + activeMention, + applyMention, + matchMentionQuery, + type ActiveMention, +} from "./mentions"; export type ComposerMention = { readonly id: string; @@ -19,6 +24,8 @@ export function Composer({ disabled, mentionables = [], onSend, + onAdd, + onVoice, }: { readonly placeholder: string; readonly busy: boolean; @@ -26,14 +33,27 @@ export function Composer({ /** Agents an `@` token can address; empty disables the popover. */ readonly mentionables?: readonly ComposerMention[]; readonly onSend: (text: string) => void; + /** Opens the attach / add actions; the `+` is hidden without it. */ + readonly onAdd?: () => void; + /** Starts voice mode; the mic is hidden without it. */ + readonly onVoice?: () => void; }) { const [text, setText] = useState(""); const [mention, setMention] = useState(null); const inputRef = useRef(null); const canSend = text.trim() !== "" && !busy && !disabled; - const matches = mention === null ? [] : matchMentionQuery(mention.query, mentionables); + const matches = + mention === null ? [] : matchMentionQuery(mention.query, mentionables); const open = mention !== null && matches.length > 0; + // Grow with the text up to the CSS max-height. + useLayoutEffect(() => { + const input = inputRef.current; + if (input === null) return; + input.style.height = "auto"; + input.style.height = `${input.scrollHeight}px`; + }, [text]); + const send = () => { const trimmed = text.trim(); if (trimmed === "" || busy || disabled) return; @@ -71,14 +91,20 @@ export function Composer({ return (
{open ? ( -
    +
      {matches.map((candidate) => (
    • ))}
    ) : null} -