From 5d16d41619cd8937dced99273c6f3f93d9e68b37 Mon Sep 17 00:00:00 2001 From: Sawyer Date: Wed, 30 Sep 2026 14:30:21 -0700 Subject: [PATCH 1/2] fix(web): the bench thread, composer, pill and sidebar toggle match the mockup (CL-9668) Left-aligned timeline with avatar, role and time heads, day divider and creation line, composer + menu, idle/working pill, and a sidebar toggle with Cmd/Ctrl+B. --- apps/web/src/app.css | 257 -------------------------- apps/web/src/bench/bench-pill.css | 87 +++++++++ apps/web/src/bench/bench-pill.tsx | 25 +-- apps/web/src/chat/composer.css | 151 +++++++++++++++ apps/web/src/chat/composer.tsx | 105 ++++++++++- apps/web/src/chat/day-divider.tsx | 19 ++ apps/web/src/chat/styles.css | 10 +- apps/web/src/chat/thread.css | 181 ++++++++++++++++++ apps/web/src/lib/icons/index.tsx | 1 + apps/web/src/pages/workbench-page.tsx | 103 +++++++++-- apps/web/src/shell/app-shell.tsx | 19 +- 11 files changed, 653 insertions(+), 305 deletions(-) create mode 100644 apps/web/src/bench/bench-pill.css create mode 100644 apps/web/src/chat/composer.css create mode 100644 apps/web/src/chat/day-divider.tsx create mode 100644 apps/web/src/chat/thread.css diff --git a/apps/web/src/app.css b/apps/web/src/app.css index ce876aae5..1ba6ca163 100644 --- a/apps/web/src/app.css +++ b/apps/web/src/app.css @@ -1820,182 +1820,6 @@ tr.insights-row-clickable:hover { color: var(--muted-foreground); } -/* A workbench transcript above, composer below. */ -.chat-thread-messages { - display: flex; - flex-direction: column; - gap: 1rem; - padding-bottom: 1rem; -} -.chat-thread-message { - display: flex; - gap: 0.75rem; - align-items: flex-start; -} -.chat-thread-body { - min-width: 0; - /* 16px minimum: keeps the transcript readable and matches the composer - below, and avoids mobile Safari's zoom-on-focus threshold. */ - font-size: max(var(--text-base), 16px); - font-weight: var(--font-weight-medium); - line-height: 1.55; -} -.chat-thread-message[data-author="me"] { - justify-content: flex-end; -} -.chat-thread-message[data-author="me"] .chat-thread-body { - display: flex; - flex-direction: column; - align-items: flex-end; - max-width: min(76ch, 78%); -} -.chat-thread-own-bubble { - background: var(--surface-sunk); - box-shadow: inset 0 0 0 1px var(--border); - padding: 0.625rem 0.875rem; - border-radius: 1rem 1rem 0.25rem 1rem; - overflow-wrap: anywhere; -} -.chat-thread-head { - display: flex; - align-items: baseline; - gap: 0.5rem; - margin-bottom: 0.125rem; -} -.chat-thread-head b { - font-size: 0.875rem; - font-weight: 800; -} -.chat-thread-head time { - font-size: 0.75rem; - color: var(--muted-foreground); - font-variant-numeric: tabular-nums; -} -.chat-system-line { - margin: 0 0 0.5rem 2.75rem; - font-size: 0.8125rem; - line-height: 1.5; - color: var(--muted-foreground); -} -.chat-system-line b { - font-weight: 700; -} -.chat-thread-error { - margin: 0.5rem 0; - font-size: 0.82rem; - color: var(--destructive); -} - -/* Shared composer: one lean full-width row (+, text, voice, send). */ -.chat-composer-box { - display: flex; - align-items: flex-end; - 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: 5px 4px; - font: inherit; - font-size: 15px; - line-height: 1.5; - color: var(--ink); -} -.chat-composer-input:focus { - outline: none; -} -.chat-composer-input::placeholder { - color: var(--ink-3); - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} -.chat-composer-input:disabled { - cursor: not-allowed; - opacity: 0.6; -} -.chat-composer-btn, -.chat-composer-send { - display: grid; - place-items: center; - flex: 0 0 32px; - width: 32px; - height: 32px; - padding: 0; - border: 0; - border-radius: 50%; - cursor: pointer; -} -.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; - 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; -} -@keyframes chat-composer-spin { - from { - transform: rotate(0deg); - } - to { - transform: rotate(360deg); - } -} -@media (prefers-reduced-motion: reduce) { - .chat-composer-send-spinner { - animation-duration: 1e-6ms; - } -} - -/* A workbench workbench: a left info column (activity, artifacts, approvals, - participants), the running chat in the main column, and an optional - sub-thread panel on the right. */ -.workbench-layout { - display: flex; - min-height: 0; - flex: 1; -} .wb { display: grid; grid-template-columns: minmax(0, 1fr) 0px; @@ -2011,46 +1835,6 @@ tr.insights-row-clickable:hover { position: relative; min-width: 0; } -.bench-pill { - position: absolute; - top: 10px; - left: 50%; - transform: translateX(-50%); - z-index: 20; - display: inline-flex; - align-items: center; - gap: 8px; - height: 36px; - max-width: min(440px, calc(100% - 120px)); - padding: 0 10px 0 6px; - border: 0; - border-radius: var(--r-full); - background: color-mix(in srgb, var(--surface) 88%, transparent); - backdrop-filter: blur(12px); - box-shadow: var(--floating); - font-size: 13.5px; - color: var(--ink); - cursor: pointer; - transition: - box-shadow 150ms ease-out, - transform 160ms var(--ease-out); -} -.bench-pill:hover { - box-shadow: var(--overlay); -} -.bench-pill:active { - transform: translateX(-50%) scale(0.97); -} -.bench-pill b { - font-weight: 800; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} -.bench-pill-status { - color: var(--ink-3); - white-space: nowrap; -} .drawer-cell { min-width: 0; overflow: hidden; @@ -2266,34 +2050,12 @@ a.drawer-li:hover, pointer-events: auto; } } -@media (max-width: 640px) { - .bench-pill-status { - display: none; - } -} @media (prefers-reduced-motion: reduce) { .wb, .drawer { transition: opacity 150ms ease-out; } } -.workbench-main { - display: flex; - flex-direction: column; - flex: 1; - min-width: 0; - min-height: 0; -} -.workbench-main-scroll { - flex: 1; - min-height: 0; - overflow-y: auto; - padding-top: 72px; -} -.workbench-main-composer { - flex-shrink: 0; - padding: 0.75rem 1rem; -} .workbench-participants { display: flex; flex-wrap: wrap; @@ -2309,25 +2071,6 @@ a.drawer-li:hover, align-items: center; gap: 0.35rem; } -.workbench-replies-link { - display: block; - margin-top: 0.35rem; - padding: 0; - border: 0; - background: none; - font-size: 0.78rem; - color: var(--muted-foreground); - cursor: pointer; - opacity: 0; - transition: opacity 120ms ease-out; -} -.chat-thread-message:hover .workbench-replies-link, -.workbench-replies-link:focus-visible { - opacity: 1; -} -.workbench-replies-link:hover { - color: var(--foreground); -} .workbench-subthread { display: flex; flex-direction: column; diff --git a/apps/web/src/bench/bench-pill.css b/apps/web/src/bench/bench-pill.css new file mode 100644 index 000000000..1370206b6 --- /dev/null +++ b/apps/web/src/bench/bench-pill.css @@ -0,0 +1,87 @@ +.bench-pill { + position: absolute; + top: 10px; + left: 50%; + transform: translateX(-50%); + z-index: 20; + display: inline-flex; + align-items: center; + gap: 8px; + height: 36px; + max-width: min(440px, calc(100% - 120px)); + padding: 0 10px 0 6px; + border: 0; + border-radius: var(--r-full); + background: color-mix(in srgb, var(--surface) 88%, transparent); + backdrop-filter: blur(12px); + box-shadow: var(--floating); + font-size: 13.5px; + color: var(--ink); + cursor: pointer; + transition: + box-shadow 150ms ease-out, + transform 160ms var(--ease-out); +} +.bench-pill:hover { + box-shadow: var(--overlay); +} +.bench-pill:active { + transform: translateX(-50%) scale(0.97); +} +.bench-pill b { + font-weight: 800; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.bench-pill-status { + color: var(--ink-3); + white-space: nowrap; +} +@media (max-width: 640px) { + .bench-pill-status { + display: none; + } +} + +.bench-pill .pulse { + position: relative; + flex: 0 0 8px; + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--action); +} +.bench-pill .pulse::after { + content: ""; + position: absolute; + inset: -3px; + border-radius: 50%; + border: 1.5px solid var(--action); + opacity: 0; + animation: bench-pill-pulse 1.8s var(--ease-out) infinite; +} +@keyframes bench-pill-pulse { + from { + transform: scale(0.6); + opacity: 0.8; + } + to { + transform: scale(1.4); + opacity: 0; + } +} +@media (prefers-reduced-motion: reduce) { + .bench-pill .pulse::after { + animation: none; + } +} + +.bench-pill-worker { + font-size: 12.5px; + color: var(--ink-3); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + min-width: 0; +} diff --git a/apps/web/src/bench/bench-pill.tsx b/apps/web/src/bench/bench-pill.tsx index def8001f0..1983bcc50 100644 --- a/apps/web/src/bench/bench-pill.tsx +++ b/apps/web/src/bench/bench-pill.tsx @@ -2,17 +2,10 @@ import { CaretDown } from "@/lib/icons"; import { IdentityAvatar } from "@/chat/avatar"; import { Skeleton } from "@corbits/react-ui"; import type { WorkerStatus } from "../worker-status"; +import "./bench-pill.css"; -function statusLine(status: WorkerStatus, workerName: string | undefined): string { - if (status.text === "Working…") { - return workerName === undefined ? "Working…" : `${workerName} is working…`; - } - const text = status.text === "Live" ? "Idle" : status.text; - return workerName === undefined ? text : `${workerName} · ${text}`; -} - -/** Frosted pill centered over the thread: the bench's worker, its name and - * live status. Toggles the bench drawer. */ +/** Frosted pill centered over the thread: the bench's worker and name, plus + * what the worker is doing while a turn is open. Toggles the bench drawer. */ export function BenchPill({ benchName, worker, @@ -29,6 +22,7 @@ export function BenchPill({ readonly open: boolean; readonly onToggle: () => void; }) { + const working = status.tone === "working"; return ( ); diff --git a/apps/web/src/chat/composer.css b/apps/web/src/chat/composer.css new file mode 100644 index 000000000..ace34ac02 --- /dev/null +++ b/apps/web/src/chat/composer.css @@ -0,0 +1,151 @@ +/* Shared composer: one lean full-width row (+, text, voice, send). */ +.chat-composer-box { + display: flex; + align-items: flex-end; + 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: 5px 4px; + font: inherit; + font-size: 15px; + line-height: 1.5; + color: var(--ink); +} +.chat-composer-input:focus { + outline: none; +} +.chat-composer-input::placeholder { + color: var(--ink-3); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.chat-composer-input:disabled { + cursor: not-allowed; + opacity: 0.6; +} +.chat-composer-btn, +.chat-composer-send { + display: grid; + place-items: center; + flex: 0 0 32px; + width: 32px; + height: 32px; + padding: 0; + border: 0; + border-radius: 50%; + cursor: pointer; +} +.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; + 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; +} +@keyframes chat-composer-spin { + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } +} +@media (prefers-reduced-motion: reduce) { + .chat-composer-send-spinner { + animation-duration: 1e-6ms; + } +} + +.chat-composer-plus { + position: relative; + flex: 0 0 32px; +} +.chat-composer-menu { + position: absolute; + left: 0; + bottom: calc(100% + 8px); + z-index: 40; + min-width: 220px; + padding: 4px; + background: var(--surface); + border-radius: var(--r-lg); + box-shadow: var(--floating); + transform-origin: bottom left; + animation: chat-composer-menu-in 160ms var(--ease-out); +} +.chat-composer-menu-item { + display: flex; + align-items: center; + gap: 10px; + width: 100%; + height: 34px; + padding: 0 10px; + border: 0; + border-radius: var(--r-md); + background: transparent; + font: inherit; + font-size: 13.5px; + text-align: left; + color: var(--ink); + cursor: pointer; +} +.chat-composer-menu-item svg { + color: var(--ink-3); +} +.chat-composer-menu-item:hover:not(:disabled) { + background: var(--hover); +} +.chat-composer-menu-item:disabled { + cursor: not-allowed; + opacity: 0.5; +} +@keyframes chat-composer-menu-in { + from { + opacity: 0; + transform: scale(0.97); + } +} diff --git a/apps/web/src/chat/composer.tsx b/apps/web/src/chat/composer.tsx index 081731d49..caf182fe6 100644 --- a/apps/web/src/chat/composer.tsx +++ b/apps/web/src/chat/composer.tsx @@ -1,9 +1,10 @@ // 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, AudioLines, CircleNotch, Plus } from "@/lib/icons"; -import { useLayoutEffect, useRef, useState } from "react"; +import { ArrowUp, AtSign, AudioLines, CircleNotch, FlowArrow, Paperclip, Plus } from "@/lib/icons"; +import { useEffect, useLayoutEffect, useRef, useState } from "react"; +import "./composer.css"; import { activeMention, applyMention, matchMentionQuery, type ActiveMention } from "./mentions"; export type ComposerMention = { @@ -19,7 +20,8 @@ export function Composer({ disabled, mentionables = [], onSend, - onAdd, + onAttach, + onRunWorkflow, onVoice, }: { readonly placeholder: string; @@ -28,12 +30,14 @@ 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; + /** The `+` menu's actions; one with no handler stays disabled. */ + readonly onAttach?: () => void; + readonly onRunWorkflow?: () => void; /** Starts voice mode; the mic is hidden without it. */ readonly onVoice?: () => void; }) { const [text, setText] = useState(""); + const [menuOpen, setMenuOpen] = useState(false); const [mention, setMention] = useState(null); const inputRef = useRef(null); const canSend = text.trim() !== "" && !busy && !disabled; @@ -48,6 +52,21 @@ export function Composer({ input.style.height = `${input.scrollHeight}px`; }, [text]); + // Closes on any press outside the menu. + useEffect(() => { + if (!menuOpen) return; + const close = (event: MouseEvent) => { + if ( + !(event.target instanceof Element) || + event.target.closest(".chat-composer-plus") === null + ) { + setMenuOpen(false); + } + }; + document.addEventListener("mousedown", close); + return () => document.removeEventListener("mousedown", close); + }, [menuOpen]); + const send = () => { const trimmed = text.trim(); if (trimmed === "" || busy || disabled) return; @@ -77,6 +96,18 @@ export function Composer({ onClose: () => setMention(null), }); + const insertMentionTrigger = () => { + const input = inputRef.current; + const next = text === "" || text.endsWith(" ") ? `${text}@` : `${text} @`; + setText(next); + requestAnimationFrame(() => { + if (input === null) return; + input.focus(); + input.setSelectionRange(next.length, next.length); + syncMention(input); + }); + }; + const syncMention = (input: HTMLTextAreaElement) => { if (mentionables.length === 0) return; setMention(activeMention(input.value, input.selectionStart) ?? null); @@ -110,18 +141,76 @@ export function Composer({ ) : null}
- {onAdd === undefined ? null : ( +
- )} + {menuOpen ? ( +
{ + if (event.key === "Escape") setMenuOpen(false); + }} + > + + + +
+ ) : null} +