Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 28 additions & 13 deletions .fork/customizations.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
40 changes: 40 additions & 0 deletions apps/web/src/__fork_guards__/forkComposerShell.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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]");
});
});
4 changes: 2 additions & 2 deletions apps/web/src/__fork_guards__/forkCoolDarkerTheme.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
);
Expand Down
31 changes: 29 additions & 2 deletions apps/web/src/__fork_guards__/sidebarV2CardRows.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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("<SidebarV2StatusMark");
expect(sidebarV2).not.toContain("text-sky-600 dark:text-sky-400");
expect(sidebarV2).not.toContain("group-hover/sidebar-row:");
expect(sidebarV2.match(/<SidebarV2StatusMark/gu)?.length ?? 0).toBeGreaterThanOrEqual(2);
});

it("pulses the monitoring mark between white 50% and 20% opacity", () => {
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", () => {
Expand Down Expand Up @@ -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("<SidebarV2IdleMark />");
// 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("<SidebarV2StatusMark");
expect(sidebarV2).toContain(
"pointer-events-none flex size-[14px] shrink-0 items-center justify-center overflow-hidden",
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -212,7 +212,14 @@ describe("fork guard: sidebar-v2-row-action-hit-area", () => {
expect(sidebarV2).toContain(
"pointer-events-none flex size-[14px] shrink-0 items-center justify-center",
);
expect(sidebarV2).toContain("<SidebarV2IdleMark />");
// 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(
'<SidebarV2StatusMark status={topStatus} rainSeed={threadKey} idle="ring" />',
);
expect(sidebarV2).toContain(
'<SidebarV2StatusMark status={topStatus} rainSeed={threadKey} idle="empty" />',
);
// 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
Expand Down
34 changes: 32 additions & 2 deletions apps/web/src/components/BranchToolbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -406,13 +417,29 @@ export const BranchToolbar = memo(function BranchToolbar({
const [stripElement, setStripElement] = useState<HTMLDivElement | null>(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 (
<div ref={setStripElement} className={COMPOSER_CONTEXT_STRIP_CLASSNAME}>
{trailing}
</div>
);
}
/* fork:end fork-composer-shell */
return null;
}

return (
<div
ref={setStripElement}
data-compact={labelsOverflow ? "" : undefined}
className="chat-composer-context-strip group/composer-context -mt-4 mx-auto flex w-[calc(100%-2.75rem)] max-w-[calc(48rem-2.75rem)] items-center gap-2 ps-1 pe-2 pt-5 pb-1"
/* fork:begin fork-composer-shell — see .fork/customizations.yaml#fork-composer-shell */
className={COMPOSER_CONTEXT_STRIP_CLASSNAME}
/* fork:end fork-composer-shell */
>
{isMobile && showGitControls ? (
<MobileRunContextSelector
Expand Down Expand Up @@ -473,6 +500,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 */}
</div>
);
});
Loading
Loading