diff --git a/.fork/customizations.yaml b/.fork/customizations.yaml index f2935c2031cd..51ba1ecb9535 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,9 @@ files: - apps/web/src/custom/ComposerShell.tsx - apps/web/src/custom/ComposerShell.css + - apps/web/src/custom/ComposerMonitoringPill.tsx + - apps/web/src/custom/composerContextStrip.tsx + - apps/web/src/custom/StopSquareIcon.tsx - apps/web/src/custom/composerModelSlotCompact.ts - apps/web/src/theme.custom.css - apps/web/src/overrides/components/composerFooterLayout.ts @@ -1694,8 +1709,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..c7bf04178e1d 100644 --- a/apps/web/src/__fork_guards__/forkComposerShell.test.ts +++ b/apps/web/src/__fork_guards__/forkComposerShell.test.ts @@ -562,4 +562,44 @@ 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 strip = readSibling("../custom/composerContextStrip.tsx"); + const branchToolbar = readSibling("../components/BranchToolbar.tsx"); + expect(pill).toContain("export function ComposerBackgroundLivenessPill"); + expect(pill).toContain('readonly kind: "monitoring"'); + expect(pill).toContain('readonly kind: "working"'); + expect(pill).toContain("SidebarV2WorkingRain"); + expect(pill).toContain("SidebarV2MonitoringMark"); + expect(pill).toContain("StopSquareIcon"); + expect(pill).toContain('role="status"'); + expect(pill).toContain("data-fork-liveness-mark"); + expect(pill).toContain("data-fork-monitoring-pill"); + expect(pill).toContain("data-fork-monitoring-stop"); + expect(pill).toContain("Stop background work"); + expect(strip).toContain("resolveComposerLivenessPillProps"); + expect(strip).toContain("COMPOSER_CONTEXT_STRIP_CLASSNAME"); + expect(strip).toContain("renderComposerLivenessStripFallback"); + expect(branchToolbar).toContain("trailing?: ReactNode"); + expect(branchToolbar).toContain("{trailing ?? null}"); + expect(branchToolbar).toContain("COMPOSER_CONTEXT_STRIP_CLASSNAME"); + expect(chatView).toContain("resolveComposerLivenessPillProps"); + expect(chatView).toContain("renderComposerLivenessPill"); + expect(chatView).toContain("trailing: composerLivenessPill"); + expect(chatView).not.toContain("backgroundLivenessBannerItem"); + expect(chatView).not.toContain('"Monitoring in the background"'); + expect(chatView).not.toContain('"Background work running"'); + // Pill chrome lives with fork-composer-shell, not theme.custom.css sprawl. + expect(shellCss).toContain("[data-fork-monitoring-pill]"); + expect(shellCss).toContain("button[data-fork-monitoring-stop]"); + expect(shellCss).toContain("[data-fork-liveness-mark]"); + expect(shellCss).toMatch( + /button\[data-fork-monitoring-stop\]\s*\{[^}]*width:\s*24px[^}]*height:\s*24px/u, + ); + expect(shellCss).toMatch( + /button\[data-fork-monitoring-stop\]:disabled\s*\{[^}]*background:\s*transparent/u, + ); + expect(theme).not.toContain("[data-fork-monitoring-pill]"); + }); }); diff --git a/apps/web/src/__fork_guards__/forkCoolDarkerTheme.test.ts b/apps/web/src/__fork_guards__/forkCoolDarkerTheme.test.ts index 3e65f990837e..b4cc28f49885 100644 --- a/apps/web/src/__fork_guards__/forkCoolDarkerTheme.test.ts +++ b/apps/web/src/__fork_guards__/forkCoolDarkerTheme.test.ts @@ -138,8 +138,8 @@ describe("fork guard: fork-cool-darker-theme", () => { 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..8cfb8ba4108d 100644 --- a/apps/web/src/__fork_guards__/sidebarV2CardRows.test.ts +++ b/apps/web/src/__fork_guards__/sidebarV2CardRows.test.ts @@ -38,8 +38,32 @@ 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 via the shared mark renderer, not a + // sky text label — and both card and slim use the same fixed 14px slot. + expect(sidebarV2).toContain(" { + const indicator = readSibling("../custom/SidebarV2StatusIndicator.tsx"); + expect(indicator).toContain("export function SidebarV2MonitoringMark"); + expect(indicator).toContain("export function SidebarV2StatusMark"); + expect(indicator).toContain("data-fork-monitoring-pulse"); + expect(theme).toContain("@keyframes sidebar-v2-monitoring-pulse"); + expect(theme).toMatch( + /\.dark \[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", () => { @@ -199,7 +223,10 @@ describe("fork guard: sidebar-v2-card-rows", () => { // share one edge. 14px slot: at 16px the rain read as hanging below the // title. overflow-hidden is load-bearing — the native grid is taller. expect(typeof SidebarV2IdleMark).toBe("function"); - expect(sidebarV2).toContain(""); + // Card and slim both go through SidebarV2StatusMark; idle="ring" is the + // card's hollow-ring path so the leading column is never empty. + expect(sidebarV2).toContain('idle="ring"'); + expect(sidebarV2).toContain(" { expect(sidebarV2).toContain( "pointer-events-none flex size-[14px] shrink-0 items-center justify-center", ); - expect(sidebarV2).toContain(""); + // Idle and live marks alike render through the shared status-mark switch, + // card rows drawing the idle ring and slim rows leaving the slot empty. + expect(sidebarV2).toContain( + '', + ); + expect(sidebarV2).toContain( + '', + ); // The trailing cell still fades elapsed on a working row when the hover // actions will replace it; that decoration must stay out of the hit path // for the same reason status used to. The fade class is gated separately diff --git a/apps/web/src/components/BranchToolbar.tsx b/apps/web/src/components/BranchToolbar.tsx index 16a65a1b444d..8dbe76aec37a 100644 --- a/apps/web/src/components/BranchToolbar.tsx +++ b/apps/web/src/components/BranchToolbar.tsx @@ -10,6 +10,10 @@ import { MonitorIcon, } from "lucide-react"; import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react"; +/* fork:begin fork-composer-shell — see .fork/customizations.yaml#fork-composer-shell */ +import type { ReactNode } from "react"; +import { COMPOSER_CONTEXT_STRIP_CLASSNAME } from "~/custom/composerContextStrip"; +/* fork:end fork-composer-shell */ import { useComposerDraftStore, type DraftId } from "../composerDraftStore"; import { useProject, useThread, useThreadShellsForProjectRefs } from "../state/entities"; @@ -57,6 +61,10 @@ interface BranchToolbarProps { onComposerFocusRequest?: () => 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 +340,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), @@ -406,13 +417,29 @@ export const BranchToolbar = memo(function BranchToolbar({ const [stripElement, setStripElement] = useState(null); const labelsOverflow = useLabelsOverflow(stripElement); - if (!hasActiveThread || !activeProject) return null; + if (!hasActiveThread || !activeProject) { + /* fork:begin fork-composer-shell — see .fork/customizations.yaml#fork-composer-shell */ + // Trailing (liveness stop) must still mount while the thread shell is live + // but useThread has not resolved yet — otherwise the only stop affordance + // vanishes during detail loading. + if (trailing) { + return ( +
+ {trailing} +
+ ); + } + /* fork:end fork-composer-shell */ + return null; + } return (
{isMobile && showGitControls ? ( ) : 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..d4878fdcf24d 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -179,6 +179,13 @@ 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 { + renderComposerLivenessPill, + renderComposerLivenessStripFallback, + resolveComposerLivenessPillProps, +} from "~/custom/composerContextStrip"; +/* fork:end fork-composer-shell */ import { useBrowserHistoryStore } from "~/browserHistoryStore"; import { getProviderModelCapabilities, resolveSelectableProvider } from "../providerModels"; import { NO_PROVIDER_MODEL_SELECTION } from "../providerInstances"; @@ -4330,78 +4337,64 @@ function ChatViewContent(props: ChatViewProps) { switchGitRef, updateThreadMetadata, ]); + /* fork:begin fork-composer-shell — see .fork/customizations.yaml#fork-composer-shell */ // 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); useEffect(() => { - // "Stopping..." holds until the liveness clears; the interrupt command - // returning only means the request was accepted. - if (activeBackgroundLiveness === null) { - setIsStoppingBackgroundWork(false); - } + // Clear when liveness ends or flips kind (monitoring→working) so a failed + // stop-everything cannot latch "Stopping..." across a new live state. + setIsStoppingBackgroundWork(false); }, [activeBackgroundLiveness]); useEffect(() => { // Per-thread state: switching threads while A's stop is pending must not // disable B's Stop button (review finding). setIsStoppingBackgroundWork(false); }, [activeThreadId]); - const handleStopBackgroundWork = useCallback(async () => { + const handleStopBackgroundWork = useCallback(() => { if (!activeThread) return; setIsStoppingBackgroundWork(true); - const result = await interruptThreadTurn({ + void interruptThreadTurn({ environmentId, input: buildThreadTurnInterruptInput(activeThread), - }); - if (result._tag === "Failure") { - // Every failure clears the pending state — an interrupted command - // never reached the server, so liveness would hold "Stopping..." - // forever. Only real failures toast. - setIsStoppingBackgroundWork(false); - if (!isAtomCommandInterrupted(result)) { - const error = squashAtomCommandFailure(result); - setThreadError( - activeThread.id, - error instanceof Error ? error.message : "Failed to stop background work.", - ); + }).then((result) => { + if (result._tag === "Failure") { + // Every failure clears the pending state — an interrupted command + // never reached the server, so liveness would hold "Stopping..." + // forever. Only real failures toast. + setIsStoppingBackgroundWork(false); + if (!isAtomCommandInterrupted(result)) { + const error = squashAtomCommandFailure(result); + setThreadError( + activeThread.id, + error instanceof Error ? error.message : "Failed to stop background work.", + ); + } } - } + }); }, [activeThread, environmentId, interruptThreadTurn, setThreadError]); - const backgroundLivenessBannerItem = useMemo(() => { - if (activeBackgroundLiveness === null || !activeThread) { + // Working fleets and monitoring both leave the banner stack and ride the + // context strip as a pill beside the branch chip. One derived node — ChatView + // only wires props; assembly lives in custom/composerContextStrip. + const composerLivenessPill = useMemo(() => { + if (!activeThread) { return null; } - const working = activeBackgroundLiveness === "working"; - const liveCount = agentPanelModel.liveCount; - return { - id: `background-liveness:${activeThread.id}`, - variant: "default", - icon: ( -