From 024b59c49a2ca8a45b3972c4ef359ed9ddae77c7 Mon Sep 17 00:00:00 2001 From: noah Date: Tue, 11 Aug 2026 23:53:23 -0400 Subject: [PATCH 1/3] feat(web): move background liveness into composer pills MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the Monitoring sidebar label and composer banner stack alerts with compact status marks — a duty-cycled pulse for monitoring, rain for working — and tone down Cool Darker's selected thread fill slightly. Co-authored-by: Cursor --- .fork/customizations.yaml | 39 +++-- .../__fork_guards__/forkComposerShell.test.ts | 26 +++ .../forkCoolDarkerTheme.test.ts | 4 +- .../__fork_guards__/sidebarV2CardRows.test.ts | 23 ++- apps/web/src/components/BranchToolbar.tsx | 12 +- apps/web/src/components/ChatView.tsx | 151 +++++++++--------- apps/web/src/components/Sidebar.tsx | 34 ++-- .../web/src/custom/ComposerMonitoringPill.tsx | 73 +++++++++ .../src/custom/SidebarV2StatusIndicator.tsx | 13 ++ apps/web/src/theme.custom.css | 128 ++++++++++++++- apps/web/src/theme.custom.palettes.css | 4 +- 11 files changed, 393 insertions(+), 114 deletions(-) create mode 100644 apps/web/src/custom/ComposerMonitoringPill.tsx diff --git a/.fork/customizations.yaml b/.fork/customizations.yaml index f2935c2031cd..67218a36c588 100644 --- a/.fork/customizations.yaml +++ b/.fork/customizations.yaml @@ -396,16 +396,21 @@ brightness still encodes unread-ness — the card delegates unread to the Done dot. - Status text labels ("Working", "Approval") are replaced by a single 14px - mark in a fixed leading slot on the title line, and the slot is never empty - — idle draws a hollow ring rather than falling back to a variable-width - relative-time string, so the title text and the rows below share one left - edge. Repo and meta rows indent 24px (the mark's 14 plus the title gap's - 10) so they align under the prompt rather than under the mark. The mark's - form carries the state — falling pixels while the agent runs, a static - dot once it stops, a clock for woke, a ring for idle — and the hue only - reinforces it. Working is emerald, deliberately diverging from the sky - the mobile Live Activity still uses; mobile has not been migrated. + Status text labels ("Working", "Approval", "Monitoring") are replaced by a + single 14px mark in a fixed leading slot on the title line, and the slot is + never empty — idle draws a hollow ring rather than falling back to a + variable-width relative-time string, so the title text and the rows below + share one left edge. Repo and meta rows indent 24px (the mark's 14 plus + the title gap's 10) so they align under the prompt rather than under the + mark. The mark's form carries the state — falling pixels while the agent + runs, a static dot once it stops, a slow white opacity breath (50% → 20%) + while monitoring, a clock for woke, a ring for idle — and the hue only + reinforces it. The monitoring breath is duty-cycled and stepped (same + compositor contract as upstream ghost-pulse / status-pulse): long holds + with a short steps(4) ramp, opacity-only, contain:paint, no will-change — + so it does not repaint every vsync on high-refresh displays. Working is + emerald, deliberately diverging from the sky the mobile Live Activity + still uses; mobile has not been migrated. Working duration and the hover actions stay on the title line's trailing cell. Status no longer shares that cell, so the opacity-crossfade hit-path @@ -1582,7 +1587,14 @@ worktree, PR, and branch chips sit above the vessel as filled chips sharing one selected-surface fill with no hairline, 8px apart (matching the context row's pb-2 under the pills — including the nested PR+branch - pair that upstream keeps at gap-1). Upstream's strip children use flex-1 + pair that upstream keeps at gap-1). When a settled turn still has + background liveness (monitoring watch or working fleets), a pill (leading + mark + label + small stop square) sits immediately after the branch pill + via BranchToolbar's optional trailing slot — not in the ComposerBannerStack. + Working uses the sidebar pixel-rain mark; monitoring uses the duty-cycled + white pulse. Labels stay short for the chip: Monitoring, N agent(s), or + Working. + Upstream's strip children use flex-1 and the branch's justify-end / md:ml-auto, which opens a large empty gap between checkout and branch at narrower widths while the checkout label truncates; the fork forces those direct flex children to flex 0 1 auto, @@ -1685,6 +1697,7 @@ files: - apps/web/src/custom/ComposerShell.tsx - apps/web/src/custom/ComposerShell.css + - apps/web/src/custom/ComposerMonitoringPill.tsx - apps/web/src/custom/composerModelSlotCompact.ts - apps/web/src/theme.custom.css - apps/web/src/overrides/components/composerFooterLayout.ts @@ -1694,8 +1707,8 @@ # Carries the base row, the context/control rows, and contextStrip prop. - apps/web/src/components/chat/ChatComposer.tsx # Docks the composer for new drafts, centers the draft greeting on its own - # layer, passes BranchToolbar as contextStrip, and keeps that strip mounted - # when the thread's worktree directory is missing. + # layer, passes BranchToolbar as contextStrip (with monitoring trailing), + # and keeps that strip mounted when the thread's worktree directory is missing. - apps/web/src/components/ChatView.tsx # data-fork-composer-action on the send and stop buttons; the whole 24px # square restyle hangs off it. Send also carries diff --git a/apps/web/src/__fork_guards__/forkComposerShell.test.ts b/apps/web/src/__fork_guards__/forkComposerShell.test.ts index 57425174a23b..1f52e6bf5f82 100644 --- a/apps/web/src/__fork_guards__/forkComposerShell.test.ts +++ b/apps/web/src/__fork_guards__/forkComposerShell.test.ts @@ -562,4 +562,30 @@ describe("fork guard: fork-composer-shell", () => { // own 6px gap and the mobile slot's 48% cap. expect(everyChild?.body).not.toMatch(/gap:|max-width:/u); }); + + it("moves background liveness off the banner stack onto a context-strip pill with stop", () => { + const pill = readSibling("../custom/ComposerMonitoringPill.tsx"); + const branchToolbar = readSibling("../components/BranchToolbar.tsx"); + expect(pill).toContain("export const ComposerBackgroundLivenessPill"); + expect(pill).toContain("kind: ComposerBackgroundLivenessKind"); + expect(pill).toContain('"monitoring"'); + expect(pill).toContain('"working"'); + expect(pill).toContain("SidebarV2WorkingRain"); + expect(pill).toContain("data-fork-liveness-mark"); + expect(pill).toContain("data-fork-monitoring-pill"); + expect(pill).toContain("data-fork-monitoring-stop"); + expect(pill).toContain("data-fork-monitoring-pulse"); + expect(pill).toContain("Stop background work"); + expect(branchToolbar).toContain("trailing?: ReactNode"); + expect(branchToolbar).toContain("{trailing ?? null}"); + expect(chatView).toContain(" { it("states Cool Darker row fills as opaque values", () => { const panel = ruleBodyFor(themeRules, DARKER_PANEL); expect(panel).toContain("--sidebar-row-hover: #2e3336"); - expect(panel).toContain("--sidebar-row-active: #353a3d"); - expect(panel).toContain("--sidebar-row-selected: #353a3d"); + expect(panel).toContain("--sidebar-row-active: #32373a"); + expect(panel).toContain("--sidebar-row-selected: #32373a"); expect(panel).not.toMatch( /--sidebar-row-(?:hover|active|selected):[^;]*(?:color-mix|--alpha)/u, ); diff --git a/apps/web/src/__fork_guards__/sidebarV2CardRows.test.ts b/apps/web/src/__fork_guards__/sidebarV2CardRows.test.ts index 4a862e2623dc..190e404b90e8 100644 --- a/apps/web/src/__fork_guards__/sidebarV2CardRows.test.ts +++ b/apps/web/src/__fork_guards__/sidebarV2CardRows.test.ts @@ -38,10 +38,31 @@ describe("fork guard: sidebar-v2-card-rows", () => { /status === "ready" \|\| status === "working" \|\| status === "monitoring"/u, ); expect(sidebarV2).toContain('? { label: "Monitoring", mark: "monitoring" }'); - expect(sidebarV2).toContain("text-sky-600 dark:text-sky-400"); + // Monitoring is a leading pulsing dot, not a sky text label. + expect(sidebarV2).toContain(" { + const indicator = readSibling("../custom/SidebarV2StatusIndicator.tsx"); + expect(indicator).toContain("export function SidebarV2MonitoringMark"); + expect(indicator).toContain("data-fork-monitoring-pulse"); + expect(theme).toContain("@keyframes sidebar-v2-monitoring-pulse"); + expect(theme).toMatch( + /\[data-fork-monitoring-pulse\]\s*\{[^}]*opacity:\s*0\.5[^}]*contain:\s*paint[^}]*animation:\s*sidebar-v2-monitoring-pulse/u, + ); + // Duty-cycled + stepped (ghost-pulse / status-pulse): holds at each pole + // with a short steps() ramp, not ease-in-out every vsync. + expect(theme).toMatch( + /@keyframes sidebar-v2-monitoring-pulse\s*\{[\s\S]*?opacity:\s*0\.5[\s\S]*?steps\(4\)[\s\S]*?opacity:\s*0\.2[\s\S]*?steps\(4\)/u, + ); + expect(theme).not.toMatch( + /sidebar-v2-monitoring-pulse[^;]*ease-in-out|sidebar-v2-monitoring-pulse[^;]*alternate/u, + ); + expect(theme).not.toMatch(/\[data-fork-monitoring-pulse\]\s*\{[^}]*will-change/u); + }); + it("keeps the card's lower two lines in the fork-owned component", () => { expect(typeof SidebarV2ThreadCardMeta).toBe("function"); expect(sidebarV2).toContain(" void; availableEnvironments?: readonly EnvironmentOption[]; onEnvironmentChange?: (environmentId: EnvironmentId) => void; + /* fork:begin fork-composer-shell — see .fork/customizations.yaml#fork-composer-shell */ + /** Optional chip after the branch pill (e.g. monitoring stop). */ + trailing?: ReactNode; + /* fork:end fork-composer-shell */ } interface MobileRunContextSelectorProps { @@ -332,6 +336,9 @@ export const BranchToolbar = memo(function BranchToolbar({ onComposerFocusRequest, availableEnvironments, onEnvironmentChange, + /* fork:begin fork-composer-shell — see .fork/customizations.yaml#fork-composer-shell */ + trailing, + /* fork:end fork-composer-shell */ }: BranchToolbarProps) { const threadRef = useMemo( () => scopeThreadRef(environmentId, threadId), @@ -473,6 +480,9 @@ export const BranchToolbar = memo(function BranchToolbar({ {...(onComposerFocusRequest ? { onComposerFocusRequest } : {})} /> ) : null} + {/* fork:begin fork-composer-shell — see .fork/customizations.yaml#fork-composer-shell */} + {trailing ?? null} + {/* fork:end fork-composer-shell */} ); }); diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 27afcc8d24a5..cb81124fbbad 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -179,6 +179,9 @@ import { newDraftId, newMessageId, newThreadId } from "~/lib/utils"; /* fork:begin fork-design-mode — see .fork/customizations.yaml#fork-design-mode */ import { forkDesignChanges } from "~/custom/designMode/designChangeDraftStore"; /* fork:end fork-design-mode */ +/* fork:begin fork-composer-shell — see .fork/customizations.yaml#fork-composer-shell */ +import { ComposerBackgroundLivenessPill } from "~/custom/ComposerMonitoringPill"; +/* fork:end fork-composer-shell */ import { useBrowserHistoryStore } from "~/browserHistoryStore"; import { getProviderModelCapabilities, resolveSelectableProvider } from "../providerModels"; import { NO_PROVIDER_MODEL_SELECTION } from "../providerInstances"; @@ -4331,10 +4334,11 @@ function ChatViewContent(props: ChatViewProps) { updateThreadMetadata, ]); // Background work (subagent fleets, workflow runs, watch loops) can outlive - // the turn; once it settles, the composer stop button is gone, so this - // banner is the only visible stop affordance. Stop routes through the - // stop-everything interrupt: it kills every live background task before - // interrupting, and works by session, so no active turn is needed. + // the turn; once it settles, the composer stop button is gone, so the + // context-strip liveness pill is the remaining stop affordance. Stop routes + // through the stop-everything interrupt: it kills every live background + // task before interrupting, and works by session, so no active turn is + // needed. const activeBackgroundLiveness = !isWorking && activeThread ? (activeThreadShell?.backgroundLiveness ?? null) : null; const [isStoppingBackgroundWork, setIsStoppingBackgroundWork] = useState(false); @@ -4371,37 +4375,29 @@ function ChatViewContent(props: ChatViewProps) { } } }, [activeThread, environmentId, interruptThreadTurn, setThreadError]); - const backgroundLivenessBannerItem = useMemo(() => { - if (activeBackgroundLiveness === null || !activeThread) { + /* fork:begin fork-composer-shell — see .fork/customizations.yaml#fork-composer-shell */ + // Working fleets and monitoring both leave the banner stack and ride the + // context strip as a pill beside the branch chip. + const showComposerLivenessPill = + activeBackgroundLiveness === "monitoring" || activeBackgroundLiveness === "working"; + const composerLivenessPill = useMemo(() => { + if ( + !activeThread || + (activeBackgroundLiveness !== "monitoring" && activeBackgroundLiveness !== "working") + ) { return null; } - const working = activeBackgroundLiveness === "working"; - const liveCount = agentPanelModel.liveCount; - return { - id: `background-liveness:${activeThread.id}`, - variant: "default", - icon: ( -