diff --git a/.fork/customizations.yaml b/.fork/customizations.yaml index c4ed30a9028..a602771646b 100644 --- a/.fork/customizations.yaml +++ b/.fork/customizations.yaml @@ -81,3 +81,53 @@ - apps/web/src/main.tsx verify: - apps/web/src/__fork_guards__/forkMarker.test.ts + +- id: phosphor-duotone-icons + intent: > + The app's iconography is Phosphor at the duotone weight, not lucide. + Rather than rewriting ~89 import sites across the hottest upstream files + (ChatView, SidebarV2, ChatComposer), "lucide-react" is aliased to a + fork-owned shim that re-exports Phosphor under lucide's export names. Call + sites keep importing "lucide-react" verbatim, so upstream can add, move, or + delete icon usage freely and this fork inherits it. Glyphs with no enclosed + area (carets, checks, arrows, spinners) are pinned to the bold weight, + since duotone is indistinguishable there and reads too light against + lucide's 2px stroke. The shim also reproduces lucide's `lucide lucide-` + SVG classes, because upstream tests assert them to identify which icon + rendered — honouring the contract keeps those tests passing unmodified + instead of forcing Tier 4 edits into upstream test files. + tier: 1 + files: + - apps/web/src/custom/icons/lucide-phosphor.tsx + shadows: [] + watch: + - apps/web/vite.config.ts + - apps/web/tsconfig.json + verify: + - apps/web/src/__fork_guards__/phosphorIcons.test.ts + +- id: sidebar-v2-card-rows + intent: > + Sidebar V2 thread rows are restyled as two-line cards: title first, an + 11px metadata line under it, 16px radius instead of 6px, and the status + text labels ("Working", "Approval") replaced by a single 16px mark in a + fixed trailing slot. The mark's form carries the state — falling pixels + while the agent runs, a static dot once it stops, a clock for woke — and + the hue only reinforces it, which is the vocabulary the phanttom Ghostty + sidebar uses. Working takes emerald from that design rather than the sky + the mobile Live Activity still uses; the divergence is deliberate and + mobile has not been migrated. The status palette and the rain keyframes + live in the Tier 1 stylesheet; only the six --color-sidebar-v2-* utility + registrations sit inline in index.css, fenced as + fork:sidebar-v2-status-palette, because Tailwind reads utility names from + the @theme block alone and will not see them anywhere else. + tier: 4 + files: + - apps/web/src/custom/SidebarV2StatusIndicator.tsx + - apps/web/src/theme.custom.css + shadows: [] + watch: + - apps/web/src/components/SidebarV2.tsx + - apps/web/src/index.css + verify: + - apps/web/src/__fork_guards__/sidebarV2Rain.test.ts diff --git a/apps/web/package.json b/apps/web/package.json index 5a1579a478b..5ee8efb582e 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -24,6 +24,7 @@ "@formkit/auto-animate": "^0.9.0", "@legendapp/list": "3.2.0", "@lexical/react": "^0.41.0", + "@phosphor-icons/react": "^2.1.10", "@pierre/diffs": "catalog:", "@pierre/trees": "1.0.0-beta.4", "@t3tools/client-runtime": "workspace:*", diff --git a/apps/web/src/__fork_guards__/phosphorIcons.test.ts b/apps/web/src/__fork_guards__/phosphorIcons.test.ts new file mode 100644 index 00000000000..1fd38e9160e --- /dev/null +++ b/apps/web/src/__fork_guards__/phosphorIcons.test.ts @@ -0,0 +1,164 @@ +// @effect-diagnostics nodeBuiltinImport:off +/** + * Fork guard — see `.fork/README.md` §4b and + * `.fork/customizations.yaml#phosphor-duotone-icons`. + * + * The icon swap is invisible at every call site: upstream code still imports + * from "lucide-react", and only the two alias entries redirect that to the + * Phosphor shim. That is exactly the §4 failure mode — drop either alias in a + * sync and the app silently renders lucide again, with no conflict and no + * error. These tests turn that into a red one. + */ + +import * as NodeFS from "node:fs"; +import * as NodePath from "node:path"; +import * as NodeURL from "node:url"; +import { createElement } from "react"; +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it } from "vite-plus/test"; +import { XIcon } from "~/custom/icons/lucide-phosphor"; + +const webRoot = NodePath.resolve(NodeURL.fileURLToPath(new URL(".", import.meta.url)), "../.."); +const repoRoot = NodePath.resolve(webRoot, "../.."); +const SHIM_PATH = "src/custom/icons/lucide-phosphor.tsx"; + +function read(relativePath: string): string { + return NodeFS.readFileSync(NodePath.join(webRoot, relativePath), "utf8"); +} + +function sourceFiles(dir: string): string[] { + if (!NodeFS.existsSync(dir)) return []; + return NodeFS.readdirSync(dir, { withFileTypes: true }).flatMap((entry) => { + const full = NodePath.join(dir, entry.name); + if (entry.name === "node_modules" || entry.name === "dist") return []; + if (entry.isDirectory()) return sourceFiles(full); + return entry.isFile() && /\.tsx?$/.test(entry.name) ? [full] : []; + }); +} + +/** Everything the Vite alias applies to. The alias rewrites the whole bundle + graph, not just `apps/web/src`, so a `lucide-react` import that appears in a + workspace package is aliased too — and would otherwise go unguarded. */ +function aliasedSourceFiles(): string[] { + return [ + ...sourceFiles(NodePath.join(webRoot, "src")), + ...sourceFiles(NodePath.join(repoRoot, "packages")), + ]; +} + +/** Named bindings pulled from "lucide-react" across the aliased graph. */ +function importedLucideNames(): string[] { + const names = new Set(); + for (const file of aliasedSourceFiles()) { + const source = NodeFS.readFileSync(file, "utf8"); + for (const match of source.matchAll( + /import\s+(?:type\s+)?\{([^}]*)\}\s+from\s+"lucide-react"/g, + )) { + for (const binding of (match[1] ?? "").split(",")) { + const name = binding + .trim() + .replace(/^type\s+/, "") + .split(/\s+as\s+/)[0] + ?.trim(); + if (name) names.add(name); + } + } + } + return [...names]; +} + +describe("fork guard: phosphor-duotone-icons", () => { + it("keeps the bundler alias pointing lucide-react at the shim", () => { + const config = read("vite.config.ts"); + expect(config).toContain('"lucide-react": NodeURL.fileURLToPath('); + expect(config).toContain(SHIM_PATH); + }); + + it("keeps the TypeScript path mapping in step with the bundler alias", () => { + const tsconfig = read("tsconfig.json"); + expect(tsconfig).toContain(`"lucide-react": ["./${SHIM_PATH}"]`); + }); + + it("exports every lucide binding the app imports", () => { + const shim = read(SHIM_PATH); + const exported = new Set([ + ...[...shim.matchAll(/export const (\w+)/g)].map((m) => m[1]), + ...[...shim.matchAll(/export type (\w+)/g)].map((m) => m[1]), + ]); + // A missing name means an upstream sync introduced a lucide icon the shim + // has no mapping for. Fix: add a line to the table in the shim. + expect(importedLucideNames().filter((name) => !exported.has(name))).toEqual([]); + }); + + it("renders Phosphor rather than lucide, defaulting to the duotone weight", () => { + const shim = read(SHIM_PATH); + expect(shim).toContain('from "@phosphor-icons/react"'); + expect(shim).toContain('"duotone"'); + // The shim must not re-import the package it stands in for — that would + // alias lucide-react to itself and recurse. + expect(shim).not.toMatch(/^\s*(?:import|export)\b[^\n]*from "lucide-react"/m); + }); + + it("stamps lucide's class names so upstream icon assertions still pass", () => { + const shim = read(SHIM_PATH); + // Upstream identifies icons in tests by these classes (e.g. `lucide-x` in + // MessagesTimeline.test.tsx). Drop them and those tests fail with a diff + // that points at upstream code rather than at this shim. + expect(shim).toContain("`lucide lucide-${lucideName}`"); + // Every entry in the table has to carry a name, or its icon renders + // unclassed while the rest stay covered. + expect([...shim.matchAll(/export const \w+ = icon\((.)/g)].filter((m) => m[1] !== '"')).toEqual( + [], + ); + }); + + it("keeps lucide-react installed only as the alias target's public name", () => { + const pkg = JSON.parse(read("package.json")) as { + dependencies?: Record; + }; + expect(pkg.dependencies?.["@phosphor-icons/react"]).toBeTruthy(); + // lucide-react stays a dependency on purpose: it is the name every upstream + // import site resolves through, and dropping it would break `tsc` for any + // tool that does not read the fork's path mapping. Nothing renders from it. + expect(pkg.dependencies?.["lucide-react"]).toBeTruthy(); + }); + + it("catches import forms the named-binding scan would miss", () => { + // The whole point of this guard is catching upstream drift, and a sync can + // introduce `import Lucide from`, `import * as Lucide from`, or a dynamic + // `import("lucide-react")` — none of which the `{ … }` scan above sees, so + // a missing shim export would slip through to runtime. + const offenders: string[] = []; + // This file names all three forms in prose, and the shim is the alias + // target rather than a consumer of it. + const selfPath = NodeURL.fileURLToPath(import.meta.url); + for (const file of aliasedSourceFiles()) { + if (file === selfPath) continue; + if (file.endsWith(SHIM_PATH.replaceAll("/", NodePath.sep))) continue; + const source = NodeFS.readFileSync(file, "utf8"); + const hasDefaultOrNamespace = + /import\s+(?:\*\s+as\s+\w+|\w+)\s*(?:,\s*\{[^}]*\})?\s*from\s*"lucide-react"/.test(source); + const hasDynamic = /import\(\s*"lucide-react"\s*\)/.test(source); + const hasRequire = /require\(\s*"lucide-react"\s*\)/.test(source); + if (hasDefaultOrNamespace || hasDynamic || hasRequire) { + offenders.push(NodePath.relative(repoRoot, file)); + } + } + // Fix: convert the import to named bindings, which the shim exports, or add + // the missing shape to the shim. + expect(offenders).toEqual([]); + }); + + it("actually renders lucide's class contract, not just declares it", () => { + // Every other assertion here string-matches the shim's source, so a shim + // that compiles but renders wrong would pass them all. Render one icon for + // real and read the class off the SVG. + const html = renderToStaticMarkup(createElement(XIcon)); + expect(html).toContain(" ({ + percent: Number(match[1]), + opacity: Number(match[2]), + })); +} + +/** Where this row's drop head sits at a given point in the loop. The whole + decomposition rests on this: one clock per column advances `head` linearly + across the span, so a fixed row only ever sees `head - row`. */ +function headOffset(percent: number, row: number): number { + return (percent / 100) * RAIN_SPAN - 1.5 - row; +} + +/** What the browser actually paints between two stops: `linear` timing on an + opacity keyframe is a straight line, so this is the shipped curve. */ +function interpolate(stops: Stop[], percent: number): number { + for (let index = 1; index < stops.length; index++) { + const previous = stops[index - 1]; + const current = stops[index]; + if (previous === undefined || current === undefined) break; + if (percent <= current.percent) { + const span = current.percent - previous.percent; + if (span === 0) return current.opacity; + const t = (percent - previous.percent) / span; + return previous.opacity + (current.opacity - previous.opacity) * t; + } + } + return stops.at(-1)?.opacity ?? 0; +} + +describe("fork guard: sidebar-v2 rain keyframes", () => { + const css = read("src/theme.custom.css"); + + it("declares one keyframe per grid row", () => { + for (let row = 0; row < ROWS; row++) { + expect(css).toContain(`@keyframes sidebar-v2-rain-${row} {`); + } + // A sixth would mean the grid grew without the component following. + expect(css).not.toContain(`@keyframes sidebar-v2-rain-${ROWS} {`); + }); + + it("keeps every class the component names backed by a keyframe", () => { + expect(RAIN_ANIMATION_CLASS).toHaveLength(ROWS); + for (const className of RAIN_ANIMATION_CLASS) { + // `\w` would match the `_` separating name from timing, so spell the + // keyframe-name charset out instead. + const name = /animate-\[([a-z0-9-]+)_/.exec(className)?.[1]; + expect(name).toBeTruthy(); + expect(css).toContain(`@keyframes ${name} {`); + } + }); + + it("re-derives every stop from the Swift alpha curve", () => { + const drifted: string[] = []; + let samples = 0; + for (let row = 0; row < ROWS; row++) { + for (const stop of keyframeStops(css, row)) { + samples++; + const expected = rainAlpha(headOffset(stop.percent, row)); + if (Math.abs(expected - stop.opacity) > ROUNDING_TOLERANCE) { + drifted.push( + `rain-${row} @ ${stop.percent}%: css ${stop.opacity}, curve ${expected.toFixed(6)}`, + ); + } + } + } + expect(drifted).toEqual([]); + // Guards against the regex silently matching nothing and passing vacuously. + expect(samples).toBeGreaterThan(150); + }); + + it("peaks exactly where the drop head crosses each row", () => { + for (let row = 0; row < ROWS; row++) { + const stops = keyframeStops(css, row); + const peak = stops.find((stop) => stop.opacity === 1); + expect(peak, `rain-${row} has no full-opacity stop`).toBeTruthy(); + // head = p*SPAN - 1.5 - row = 0 → p = (row + 1.5) / SPAN + expect(peak?.percent).toBeCloseTo(((row + 1.5) / RAIN_SPAN) * 100, 1); + } + }); + + it("runs a full loop, monotonically, from 0% to 100%", () => { + for (let row = 0; row < ROWS; row++) { + const stops = keyframeStops(css, row); + expect(stops[0]?.percent).toBe(0); + expect(stops.at(-1)?.percent).toBe(100); + const percents = stops.map((stop) => stop.percent); + expect(percents).toEqual([...percents].sort((a, b) => a - b)); + expect(new Set(percents).size).toBe(percents.length); + } + }); + + it("samples the curve unclamped, unlike the still frame", () => { + // dropAlpha applies Swift's 0.02 cutoff; the table must not, or CSS would + // interpolate into a visible step where the tail should just fade out. + // If this ever flips, the tail of every column gains a hard edge. + const tail = keyframeStops(css, 0).filter((stop) => stop.opacity > 0 && stop.opacity < 0.02); + expect(tail.length).toBeGreaterThan(0); + expect(dropAlpha(4.9)).toBe(0); + expect(rainAlpha(4.9)).toBeGreaterThan(0); + }); + + it("tracks the reference curve once the browser interpolates between stops", () => { + // Stop-for-stop fidelity is not the whole story: what ships is the linear + // interpolation between them, and a table could sample the curve perfectly + // yet still cut the corner off a peak. This pins the shipped motion. + let sum = 0; + let count = 0; + let max = 0; + for (let row = 0; row < ROWS; row++) { + const stops = keyframeStops(css, row); + for (let step = 0; step <= 4000; step++) { + const percent = (step / 4000) * 100; + const error = Math.abs(interpolate(stops, percent) - rainAlpha(headOffset(percent, row))); + sum += error; + count++; + max = Math.max(max, error); + } + } + // Measured 1.08e-3 mean / 0.0339 max; the worst point is the steep rise + // into row 0's peak. Loosening either bound means the motion changed. + expect(sum / count).toBeLessThan(1.5e-3); + expect(max).toBeLessThan(0.035); + }); + + it("keeps the keyframes out of the @theme block Tailwind prunes", () => { + // Tailwind v4 drops `@keyframes` declared inside `@theme` unless it finds a + // generated `animation` declaration naming them. Living in the fork's own + // stylesheet takes that failure mode off the table entirely — and keeps the + // 560-line table out of index.css, the second-highest-churn file in the repo. + expect(read("src/index.css")).not.toContain("sidebar-v2-rain"); + }); +}); + +describe("fork guard: sidebar-v2 rain phase offsets", () => { + it("is deterministic per thread key", () => { + expect(rainOffsetSeconds("thread-abc")).toBe(rainOffsetSeconds("thread-abc")); + }); + + it("stays inside one loop's worth of rewind", () => { + for (const seed of ["", "a", "thread-1", "🙂", "x".repeat(200)]) { + const offset = rainOffsetSeconds(seed); + expect(Number.isFinite(offset)).toBe(true); + expect(offset).toBeGreaterThanOrEqual(0); + expect(offset).toBeLessThan(32); + } + }); + + it("avalanches sequential ids apart", () => { + // The reason the murmur3 finalizer is there: plain FNV leaves keys that + // differ only in the last character within a second of each other, and the + // rows then read as one synchronised block. Sequential ids are exactly the + // shape real thread keys take. + const offsets = Array.from({ length: 12 }, (_, index) => rainOffsetSeconds(`thread-${index}`)); + for (let i = 0; i < offsets.length; i++) { + for (let j = i + 1; j < offsets.length; j++) { + expect(Math.abs((offsets[i] ?? 0) - (offsets[j] ?? 0))).toBeGreaterThan(0.25); + } + } + }); +}); diff --git a/apps/web/src/components/SidebarV2.tsx b/apps/web/src/components/SidebarV2.tsx index 8c5891ebe7e..e3f04905d3d 100644 --- a/apps/web/src/components/SidebarV2.tsx +++ b/apps/web/src/components/SidebarV2.tsx @@ -19,8 +19,6 @@ import { CheckIcon, ChevronDownIcon, CircleAlertIcon, - CircleCheckIcon, - CircleDashedIcon, ClockIcon, CopyIcon, FolderIcon, @@ -129,6 +127,12 @@ import { } from "./Sidebar.snooze"; import { ProjectFavicon } from "./ProjectFavicon"; import { ProviderInstanceIcon } from "./chat/ProviderInstanceIcon"; +import { + SidebarV2StatusDot, + SidebarV2WokeMark, + SidebarV2WorkingRain, + type SidebarV2DotTone, +} from "~/custom/SidebarV2StatusIndicator"; import { getTriggerDisplayModelLabel } from "./chat/providerIconUtils"; import { deriveProviderInstanceEntries, type ProviderInstanceEntry } from "../providerInstances"; import { primaryServerProvidersAtom } from "../state/server"; @@ -431,57 +435,50 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { const lastVisitedDate = lastVisitedAt === undefined ? null : parseTimestampDate(lastVisitedAt); const wokeAtDate = props.wokeAt === null ? null : parseTimestampDate(props.wokeAt); const isWoke = wokeAtDate !== null && (lastVisitedDate === null || lastVisitedDate < wokeAtDate); - // In-flight rows (working, or waiting on approval/input) fade as a whole: - // there is nothing for the user to do yet, so prominence is reserved for - // rows that need a human — done (unread), read-but-unsettled, failed, and - // freshly woken. The status label keeps its hue, so waiting rows stay - // findable. In-flight rows recede the same as read-ready ones (inbox-zero: - // working threads aren't your problem yet) — only the colored status label - // stands out. - const isInFlight = status === "working" || status === "approval" || status === "input"; + // Receding is now about who owns the next move, not about whether a session + // happens to be attached. Working and read-ready rows recede (inbox-zero: + // a running agent isn't your problem yet); approval and input no longer do, + // because they are blocked ON YOU. That split is the whole point of the two + // drawn specimens — the approval row carries brighter metadata (65%) than + // the working one (45%) despite both being "in flight". const shouldRecede = - (status === "ready" || isInFlight) && !isUnread && !isWoke && !props.isActive && !isSelected; - // Status hues follow the system-wide convention set by sidebar v1 and the - // mobile Live Activity/widgets (amber approval, indigo input, sky working) - // so a thread reads the same color everywhere it surfaces. - const topStatus = + (status === "ready" || status === "working") && + !isUnread && + !isWoke && + !props.isActive && + !isSelected; + // Approval stays amber and input stays indigo, matching sidebar v1 and the + // mobile Live Activity/widgets. Working does NOT: v2 takes the emerald from + // the phanttom Ghostty sidebar this design is ported from, so a working + // thread reads green on web and still sky on mobile + // (`apps/mobile/src/features/threads/thread-list-v2-items.tsx`). Migrating + // mobile is a separate call; the divergence is deliberate, not an oversight. + // Working and done share that emerald on purpose — the mark's *form* + // separates them (falling pixels vs a static dot), not its hue. + // Only two of these were drawn (working, approval); the rest are extended + // from the same vocabulary. `rain` = the agent is moving, `dot` = it + // stopped and the row is waiting on something, `woke` keeps its own glyph. + // Labels are no longer painted — they survive only as the accessible name, + // since the mark itself is aria-hidden. + // + // Discriminated on `mark` so the dot branch cannot be handed the `working` + // tone, which has no dot rendering. + const topStatus: + | { label: string; tone: "working"; mark: "rain" } + | { label: string; tone: SidebarV2DotTone; mark: "dot" | "woke" } + | null = status === "working" - ? { - label: "Working", - icon: "working" as const, - className: - "animate-sidebar-working-text text-sky-600 motion-reduce:animate-none dark:text-sky-400", - } + ? { label: "Working", tone: "working", mark: "rain" } : status === "approval" - ? { - label: "Approval", - icon: null, - className: "text-amber-700 dark:text-amber-300", - } + ? { label: "Needs approval", tone: "approval", mark: "dot" } : status === "input" - ? { - label: "Input", - icon: null, - className: "text-indigo-600 dark:text-indigo-300", - } + ? { label: "Needs input", tone: "input", mark: "dot" } : status === "failed" - ? { - label: "Failed", - icon: null, - className: "text-red-700 dark:text-red-300", - } + ? { label: "Failed", tone: "failed", mark: "dot" } : isWoke - ? { - label: "Woke", - icon: "woke" as const, - className: "text-amber-700 dark:text-amber-300", - } + ? { label: "Woke from snooze", tone: "approval", mark: "woke" } : isUnread - ? { - label: "Done", - icon: "done" as const, - className: "text-emerald-700 dark:text-emerald-300", - } + ? { label: "Done", tone: "done", mark: "dot" } : null; const gitCwd = thread.worktreePath ?? props.projectCwd; @@ -653,19 +650,24 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { // like elevated cards while settled threads were plain rows, leaving neither // a useful hierarchy nor a reliable hover cue. Status now lives in the row // content; surface is reserved for interaction (hover, multi-select, route). + // Working rows carry a resting fill (the design's rgba(255,255,255,0.08)) + // rather than the blanket opacity fade the whole in-flight group used to + // get: a running agent is the one thing on screen that is alive, and a lit + // surface says that without dimming the text you still need to read. Because + // that fill lands on the same value as the hover fill, working rows hover up + // to the active fill so the affordance survives. + const isWorkingSurface = status === "working" && !props.isActive && !isSelected; const rowSurfaceClassName = cn( - "group/v2-row relative w-full cursor-pointer overflow-hidden rounded-md text-left outline-none select-none", + "group/v2-row relative w-full cursor-pointer overflow-hidden rounded-2xl text-left outline-none select-none", props.isActive ? "bg-sidebar-row-active text-sidebar-foreground" : isSelected ? "bg-sidebar-row-selected text-sidebar-foreground" - : shouldRecede - ? "text-sidebar-muted-foreground/75 hover:bg-sidebar-row-hover hover:text-sidebar-foreground" - : "bg-transparent text-sidebar-foreground hover:bg-sidebar-row-hover", - isInFlight && - !props.isActive && - !isSelected && - "opacity-70 transition-opacity hover:opacity-100", + : isWorkingSurface + ? "bg-sidebar-row-working text-sidebar-foreground hover:bg-sidebar-row-active" + : shouldRecede + ? "text-sidebar-muted-foreground/75 hover:bg-sidebar-row-hover hover:text-sidebar-foreground" + : "bg-transparent text-sidebar-foreground hover:bg-sidebar-row-hover", ); const title = isRenaming ? ( @@ -684,18 +686,16 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { ) : ( } > -
-
- - {props.projectTitle ? ( + {/* Two rows, not three: the title leads (it is the only thing you + actually scan for) and the project name drops into the metadata + line beside the branch. The favicon is gone — at 282px it cost a + slot to repeat what the project name already says. */} +
+ {/* 18px, not the 16px the metadata line uses: that is the exact + height of the working rain's 4x5 grid, and cropping it would + clip the bottom row of drops. */} +
+ {title} + - {props.projectTitle} - - ) : ( - - )} - - {topStatus ? ( - + {/* The mark is aria-hidden, so the label lives on as the + accessible name. Keeping it off the ticking duration + stops screen readers announcing every second. */} + + {topStatus.label} + + {topStatus.mark === "rain" ? ( + + ) : topStatus.mark === "woke" ? ( + + ) : ( + )} - > - {topStatus.icon === "working" ? ( - - ) : topStatus.icon === "done" ? ( - - ) : topStatus.icon === "woke" ? ( - - ) : null} - {/* The label alone is the live region: a role="status" - wrapper around the ticking duration would make - screen readers announce every second. */} - {topStatus.label} {status === "working" ? ( - + ) : null} - + ) : ( - threadTimeLabel(thread) + + {threadTimeLabel(thread)} + )} {props.settlementSupported || showSnoozeButton ? ( @@ -933,20 +923,34 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { type="button" aria-label="Settle thread" onClick={handleSettleClick} - className="inline-flex cursor-pointer items-center gap-1 rounded-md bg-transparent px-2 text-xs text-muted-foreground hover:text-foreground" + className="inline-flex cursor-pointer items-center rounded-md bg-transparent px-2 text-xs text-muted-foreground hover:text-foreground" > + {/* Icon-only in v2: at 282px the "Settle" text pushed the + hover actions over the title, which now shares their + line. `aria-label` carries the name. */} - Settle ) : null} ) : null}
-
{title}
-
+ {/* 65% for rows that want you, 45% for rows that are merely busy — + the one hierarchy the two drawn specimens differ on. */} +
+ {props.projectTitle ? ( + {props.projectTitle} + ) : null} {thread.branch ? ( - {thread.branch} + + + {thread.branch} + ) : ( )} @@ -959,11 +963,11 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { ) : null} {isRemote ? ( - + ) : null} {driverKind ? ( @@ -971,7 +975,7 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { ) : null} diff --git a/apps/web/src/custom/SidebarV2StatusIndicator.tsx b/apps/web/src/custom/SidebarV2StatusIndicator.tsx new file mode 100644 index 00000000000..8589fbedfdd --- /dev/null +++ b/apps/web/src/custom/SidebarV2StatusIndicator.tsx @@ -0,0 +1,216 @@ +// Imported through the shim's own path rather than the `lucide-react` alias. +// This file is fork-owned, so there is no upstream import site to preserve — +// the alias exists to keep *upstream's* imports untouched, not this one. +import { AlarmClockIcon } from "./icons/lucide-phosphor"; +import type { CSSProperties } from "react"; +import { cn } from "~/lib/utils"; + +/** The right-hand slot of a Sidebar V2 card row. Every status resolves to one + mark so the column stays optically aligned no matter which state a row is + in — the *form* carries the meaning (falling pixels mean the agent is + running; a static dot means it stopped and wants something), and the hue + only reinforces it. This is the same vocabulary the phanttom Ghostty fork + uses in its sidebar: rain while working, 8px dots otherwise. Text labels + ("Working", "Approval") are gone — at 282px wide the label crowded out the + branch name, and the row already says what it is through color plus the + duration readout. + + Known limit, accepted (WCAG 1.4.1): form only separates rain / dot / clock, + so the four settled states — done, approval, input, failed — are one 8px dot + apart and differ by hue alone. Screen readers get the `role="status"` label + SidebarV2 renders alongside the mark; sighted users with a color vision + deficiency do not, and done/failed is the pair that collapses first. If that + ever needs fixing, vary the dot's fill rather than its shape (ring for + approval, hollow for input) — it keeps the all-circles vocabulary this is + ported from, and `SidebarV2WokeMark` below is precedent for breaking dot + uniformity when a state genuinely needs it. */ +export type SidebarV2StatusTone = "working" | "done" | "approval" | "input" | "failed"; + +/** The tones a *dot* can carry. `working` is excluded by construction: a working + row always draws the rain, so a working dot is unreachable — the type is what + keeps that true as `topStatus` in SidebarV2 grows new branches. */ +export type SidebarV2DotTone = Exclude; + +const TONE_COLOR_CLASS: Record = { + done: "bg-sidebar-v2-status-done", + approval: "bg-sidebar-v2-status-approval", + input: "bg-sidebar-v2-status-input", + failed: "bg-sidebar-v2-status-failed", +}; + +// Geometry transcribed from PixelSparkleView: a 4x5 grid on a 3.8px pitch with +// 2.85px cells rounded at 28% of their size. That lands the box at 14.25x18.05, +// which is why the card's first line is 18px rather than the 16px the rest of +// the row uses. +const ROWS = 5; +const COLS = 4; +const PITCH = 3.8; +const CELL = 2.85; + +/** Per-column clock, straight off the Swift constants: `speed` and `phase` come + from its `frac(sin(n) * 43758.5453)` hash, one fall takes `(rows + 3) / speed` + seconds, and `phase` becomes a negative delay. This is what keeps the four + columns permanently out of step with each other. */ +const COLUMNS: ReadonlyArray<{ speed: number; phase: number }> = [ + { speed: 2.5, phase: 0 }, + { speed: 4.504345, phase: 1.961008 }, + { speed: 3.80266, phase: 2.768793 }, + { speed: 4.367242, phase: 4.657913 }, +]; + +export const RAIN_SPAN = ROWS + 3; +const SPAN = RAIN_SPAN; + +/** The alpha curve every drop follows: a bright head, an exponential trail + above it, a sharp falloff below. + + `rainAlpha` is the bare curve — this is what the generated keyframes in + `theme.custom.css` sample. `dropAlpha` adds Swift's "discard anything under + 0.02" cutoff, which only applies to the still frame: CSS interpolates + between stops, so clamping the table would put a visible step in the tail + where the native view just fades out. Exported for + `__fork_guards__/sidebarV2Rain.test.ts`, which re-derives the table. */ +export function rainAlpha(dy: number): number { + return dy >= 0 ? Math.exp(-dy * 0.8) : Math.exp(dy * 8); +} + +export function dropAlpha(dy: number): number { + const alpha = rainAlpha(dy); + return alpha >= 0.02 ? alpha : 0; +} + +/** Seconds to rewind this row's rain by. Native phanttom drives every tab from + one shared wall clock, so its working tabs all fall in lockstep; here a + sidebar full of working threads would read as a single blinking block, so + each row gets its own offset. Hashing the thread key rather than randomizing + is what makes the spread deterministic: the same thread always draws the same + offset, so a re-render cannot reshuffle the row's phase. (A *remount* still + restarts the CSS delay clock, so a row that scrolls out and back re-phases + regardless — the hash guarantees rows differ from each other, not that any + one row is continuous across remount.) Every column moves + by the same absolute amount, which preserves their relationship to each + other: the row looks exactly like one that started working moments earlier. */ +export function rainOffsetSeconds(seed: string): number { + let hash = 0x811c9dc5; + for (let index = 0; index < seed.length; index++) { + hash ^= seed.charCodeAt(index); + hash = Math.imul(hash, 0x01000193); + } + // FNV alone leaves the high bits barely moved between keys that differ only + // in their last character, which is exactly what sequential thread ids look + // like — the offsets then land within a second of each other and the rows + // still read as synchronised. The murmur3 finalizer avalanches them apart. + hash ^= hash >>> 16; + hash = Math.imul(hash, 0x85ebca6b); + hash ^= hash >>> 13; + hash = Math.imul(hash, 0xc2b2ae35); + hash ^= hash >>> 16; + return ((hash >>> 0) / 0x1_0000_0000) * 32; +} + +/** One profile per row, spelled out in full because Tailwind only sees class + names it can find literally in the source — a template built from the row + index would never get generated. (The keyframes themselves are safe either + way: they live in `theme.custom.css`, outside the `@theme` block Tailwind + prunes, so an unreferenced one survives.) Duration and delay ride in as inline + styles; keeping the name in a class is what lets `motion-reduce:animate-none` + still win, since a class rule cannot override an inline `animation-name`. */ +export const RAIN_ANIMATION_CLASS = [ + "animate-[sidebar-v2-rain-0_linear_infinite]", + "animate-[sidebar-v2-rain-1_linear_infinite]", + "animate-[sidebar-v2-rain-2_linear_infinite]", + "animate-[sidebar-v2-rain-3_linear_infinite]", + "animate-[sidebar-v2-rain-4_linear_infinite]", +] as const; + +const WIDTH = (COLS - 1) * PITCH + CELL; +const HEIGHT = (ROWS - 1) * PITCH + CELL; + +/** The grid is fixed and never reorders, so the twenty drops are resolved once + at module load — position, clock and keyframe per cell. Only the per-row + time offset is left to compute at render. */ +const CELLS = COLUMNS.flatMap((column, col) => + Array.from({ length: ROWS }, (_, row) => ({ + id: `${col}:${row}`, + x: col * PITCH, + y: row * PITCH, + row, + speed: column.speed, + phase: column.phase, + duration: SPAN / column.speed, + animationClass: RAIN_ANIMATION_CLASS[row], + })), +); + +/** Drawn as SVG rather than positioned elements on purpose. A 2.85px box on a + 3.8px pitch never lands on a device-pixel boundary, and browsers snap + element backgrounds to whole pixels — so the twenty cells would paint at + visibly different widths depending on each one's subpixel offset. SVG keeps + the fractional geometry and antialiases it, which is what the native Canvas + does too, so every drop stays exactly square. */ +export function SidebarV2WorkingRain({ seed }: { seed: string }) { + const offset = rainOffsetSeconds(seed); + return ( + + {CELLS.map((cell) => ( + + ))} + + ); +} + +/** The blocked/settled counterpart to the rain: one 8px dot centered in the + same 16px box the provider icons use, so the trailing edge of every row + lines up whether the mark is a dot, a clock, or the grid. */ +export function SidebarV2StatusDot({ tone }: { tone: SidebarV2DotTone }) { + return ( + + + + ); +} + +/** Woke keeps a glyph rather than a dot: it is a *modifier* on an otherwise + settled row, not one of the five statuses, and an amber dot would be + indistinguishable from Approval — the one state it must never be confused + with, since Approval is blocking and Woke is not. */ +export function SidebarV2WokeMark() { + return ( + + + + ); +} diff --git a/apps/web/src/custom/icons/lucide-phosphor.tsx b/apps/web/src/custom/icons/lucide-phosphor.tsx new file mode 100644 index 00000000000..ff4a4077143 --- /dev/null +++ b/apps/web/src/custom/icons/lucide-phosphor.tsx @@ -0,0 +1,352 @@ +/** + * fork: lucide-react -> Phosphor duotone. + * + * `lucide-react` is aliased to this module in `apps/web/vite.config.ts` and + * `apps/web/tsconfig.json`, so all ~89 upstream import sites keep saying + * `from "lucide-react"` and transparently render Phosphor instead. That keeps + * this a Tier 1 customization (.fork/README.md S3): one additive file plus two + * low-churn config edits, instead of a rewrite of every hot component. + * + * Every icon defaults to the `duotone` weight. Glyphs with no enclosed area + * (carets, checks, arrows, spinners) render identically in every weight, so + * those are pinned to `bold` to keep the optical weight of lucide's 2px stroke. + * + * Adding an icon: if an upstream sync introduces a lucide import this module + * does not export, the build fails naming that icon. Add a line to the table. + * + * Bundle cost, measured on the production build (main chunk, 2026-07-25) by + * building twice with only the two alias entries flipped: + * + * phosphor 3,542,642 raw / 1,050,150 gzip + * lucide 3,564,063 raw / 1,059,636 gzip + * delta -21,421 raw / -9,486 gzip + * + * So the swap is slightly *cheaper*, not more expensive — worth recording, + * because the naive read says otherwise: each Phosphor icon module is a single + * `new Map` carrying all six weights, so the four weights this shim never uses + * cannot be tree-shaken out of an icon that is imported. That overhead is real + * (~341 KB raw across the icons in use) and is simply outweighed by lucide + * shipping more bytes overall. Re-measure if the icon count moves materially. + * + * See .fork/customizations.yaml#phosphor-duotone-icons + */ +import { + Alarm as PhAlarm, + Archive as PhArchive, + ArrowCircleUp as PhArrowCircleUp, + ArrowClockwise as PhArrowClockwise, + ArrowCounterClockwise as PhArrowCounterClockwise, + ArrowDown as PhArrowDown, + ArrowElbowDownLeft as PhArrowElbowDownLeft, + ArrowElbowLeftUp as PhArrowElbowLeftUp, + ArrowLeft as PhArrowLeft, + ArrowRight as PhArrowRight, + ArrowSquareOut as PhArrowSquareOut, + ArrowUUpLeft as PhArrowUUpLeft, + ArrowUp as PhArrowUp, + ArrowsClockwise as PhArrowsClockwise, + ArrowsDownUp as PhArrowsDownUp, + ArrowsInLineVertical as PhArrowsInLineVertical, + ArrowsInSimple as PhArrowsInSimple, + ArrowsOutSimple as PhArrowsOutSimple, + BellSlash as PhBellSlash, + BoxArrowUp as PhBoxArrowUp, + Broadcast as PhBroadcast, + Bug as PhBug, + Camera as PhCamera, + CaretDown as PhCaretDown, + CaretLeft as PhCaretLeft, + CaretRight as PhCaretRight, + CaretUp as PhCaretUp, + CaretUpDown as PhCaretUpDown, + ChatCircle as PhChatCircle, + ChatText as PhChatText, + Check as PhCheck, + CheckCircle as PhCheckCircle, + CircleNotch as PhCircleNotch, + ClipboardText as PhClipboardText, + Clock as PhClock, + ClockCounterClockwise as PhClockCounterClockwise, + Cloud as PhCloud, + CloudArrowUp as PhCloudArrowUp, + Code as PhCode, + Columns as PhColumns, + Copy as PhCopy, + Cursor as PhCursor, + CursorClick as PhCursorClick, + DeviceMobile as PhDeviceMobile, + DotsThree as PhDotsThree, + DotsThreeVertical as PhDotsThreeVertical, + DownloadSimple as PhDownloadSimple, + Eye as PhEye, + EyeSlash as PhEyeSlash, + Eyedropper as PhEyedropper, + File as PhFile, + FileCode as PhFileCode, + Files as PhFiles, + Flask as PhFlask, + Folder as PhFolder, + FolderDashed as PhFolderDashed, + FolderOpen as PhFolderOpen, + FolderPlus as PhFolderPlus, + Gear as PhGear, + GearSix as PhGearSix, + GitBranch as PhGitBranch, + GitCommit as PhGitCommit, + GitDiff as PhGitDiff, + GitFork as PhGitFork, + GitPullRequest as PhGitPullRequest, + Globe as PhGlobe, + GlobeHemisphereWest as PhGlobeHemisphereWest, + Hammer as PhHammer, + HardDrives as PhHardDrives, + Info as PhInfo, + Keyboard as PhKeyboard, + Layout as PhLayout, + Lightning as PhLightning, + Link as PhLink, + LinkSimple as PhLinkSimple, + ListChecks as PhListChecks, + Lock as PhLock, + LockOpen as PhLockOpen, + MagnifyingGlass as PhMagnifyingGlass, + Minus as PhMinus, + Monitor as PhMonitor, + NotePencil as PhNotePencil, + PaintBrush as PhPaintBrush, + Paragraph as PhParagraph, + PencilRuler as PhPencilRuler, + PencilSimpleLine as PhPencilSimpleLine, + Play as PhPlay, + PlugsConnected as PhPlugsConnected, + Plus as PhPlus, + QrCode as PhQrCode, + Robot as PhRobot, + Rows as PhRows, + Selection as PhSelection, + ShippingContainer as PhShippingContainer, + Sidebar as PhSidebar, + SidebarSimple as PhSidebarSimple, + SignIn as PhSignIn, + Sparkle as PhSparkle, + SquareSplitHorizontal as PhSquareSplitHorizontal, + SquareSplitVertical as PhSquareSplitVertical, + Star as PhStar, + Terminal as PhTerminal, + TerminalWindow as PhTerminalWindow, + Trash as PhTrash, + TreeStructure as PhTreeStructure, + Warning as PhWarning, + WarningCircle as PhWarningCircle, + WifiSlash as PhWifiSlash, + Wrench as PhWrench, + X as PhX, + XCircle as PhXCircle, + type Icon as PhosphorIcon, + type IconProps, + type IconWeight, +} from "@phosphor-icons/react"; +import type { FC, SVGProps } from "react"; + +/** + * Phosphor's own `IconProps` declares its optionals without `| undefined`, + * which this repo's `exactOptionalPropertyTypes` treats as "may not be passed + * explicitly as undefined". Upstream assigns lucide icons into slots typed + * `React.FC>` (see `~/components/Icons.tsx`), so the + * shim's props must stay assignable from plain SVG props. + */ +type LucideCompatProps = SVGProps & { + alt?: string | undefined; + size?: string | number | undefined; + weight?: IconWeight | undefined; + mirrored?: boolean | undefined; +}; + +/** + * Upstream writes `React.ComponentProps` and annotates icon + * slots as `LucideIcon`, so both names have to keep resolving. + */ +export type LucideIcon = FC; +export type LucideProps = LucideCompatProps; + +/** + * Pins a default weight while still letting a caller override it per usage. + * Phosphor forwards unknown props to the ``, so upstream's `strokeWidth` + * and Tailwind `size-*` classes pass through untouched — Phosphor renders + * fills, so a stray `strokeWidth` is inert rather than wrong. + * + * `lucideName` reproduces the `lucide lucide-` classes lucide stamps on + * every icon (see its `createLucideIcon`). Nothing in the app styles off them, + * but upstream tests assert them to identify which icon rendered — e.g. + * `MessagesTimeline.test.tsx` expects `lucide-x` on a failed tool call. Keeping + * the class contract here means those tests, and future ones like them, pass + * unmodified: the swap stays a Tier 1 customization instead of leaking edits + * into upstream test files. Names are the lucide export kebab-cased, which is + * exact for canonical icons; lucide's deprecated aliases (`MoreVertical` -> + * `ellipsis-vertical`) get the alias name rather than the canonical one. + */ +function icon(lucideName: string, Base: PhosphorIcon, weight: IconWeight): LucideIcon { + const lucideClasses = `lucide lucide-${lucideName}`; + const Wrapped: LucideIcon = ({ weight: override, className, ...props }) => ( + // Cast bridges the `exactOptionalPropertyTypes` gap described above; the + // shapes are structurally identical apart from explicit-undefined. + + ); + Wrapped.displayName = `Duotone(${Base.displayName ?? "Icon"})`; + return Wrapped; +} + +export const ChevronDownIcon = icon("chevron-down", PhCaretDown, "bold"); +export const ChevronUpIcon = icon("chevron-up", PhCaretUp, "bold"); +export const ChevronLeftIcon = icon("chevron-left", PhCaretLeft, "bold"); +export const ChevronRightIcon = icon("chevron-right", PhCaretRight, "bold"); +export const ChevronRight = icon("chevron-right", PhCaretRight, "bold"); +export const ChevronsUpDownIcon = icon("chevrons-up-down", PhCaretUpDown, "bold"); +export const ChevronsDownUpIcon = icon("chevrons-down-up", PhArrowsInLineVertical, "bold"); +export const ChevronsLeftRightEllipsisIcon = icon( + "chevrons-left-right-ellipsis", + PhPlugsConnected, + "duotone", +); +export const XIcon = icon("x", PhX, "bold"); +export const X = icon("x", PhX, "bold"); +export const CircleXIcon = icon("circle-x", PhXCircle, "duotone"); +export const CheckIcon = icon("check", PhCheck, "bold"); +export const CircleCheckIcon = icon("circle-check", PhCheckCircle, "duotone"); +export const CheckCircle2Icon = icon("check-circle-2", PhCheckCircle, "duotone"); +export const PlusIcon = icon("plus", PhPlus, "bold"); +export const Plus = icon("plus", PhPlus, "bold"); +export const MinusIcon = icon("minus", PhMinus, "bold"); +export const Minus = icon("minus", PhMinus, "bold"); +export const ArrowUpIcon = icon("arrow-up", PhArrowUp, "bold"); +export const ArrowDownIcon = icon("arrow-down", PhArrowDown, "bold"); +export const ArrowLeftIcon = icon("arrow-left", PhArrowLeft, "bold"); +export const ArrowLeft = icon("arrow-left", PhArrowLeft, "bold"); +export const ArrowRightIcon = icon("arrow-right", PhArrowRight, "bold"); +export const ArrowRight = icon("arrow-right", PhArrowRight, "bold"); +export const ArrowUpDownIcon = icon("arrow-up-down", PhArrowsDownUp, "bold"); +export const ArrowUpCircleIcon = icon("arrow-up-circle", PhArrowCircleUp, "duotone"); +export const CornerLeftUpIcon = icon("corner-left-up", PhArrowElbowLeftUp, "bold"); +export const Undo2Icon = icon("undo-2", PhArrowUUpLeft, "bold"); +export const RotateCcwIcon = icon("rotate-ccw", PhArrowCounterClockwise, "bold"); +export const RotateCcw = icon("rotate-ccw", PhArrowCounterClockwise, "bold"); +export const RotateCwIcon = icon("rotate-cw", PhArrowClockwise, "bold"); +export const RotateCw = icon("rotate-cw", PhArrowClockwise, "bold"); +export const RefreshCwIcon = icon("refresh-cw", PhArrowsClockwise, "bold"); +export const RefreshCw = icon("refresh-cw", PhArrowsClockwise, "bold"); +export const HistoryIcon = icon("history", PhClockCounterClockwise, "duotone"); +export const ClockIcon = icon("clock", PhClock, "duotone"); +export const AlarmClockIcon = icon("alarm-clock", PhAlarm, "duotone"); +export const AlarmClockOffIcon = icon("alarm-clock-off", PhBellSlash, "duotone"); +export const TriangleAlertIcon = icon("triangle-alert", PhWarning, "duotone"); +export const AlertTriangleIcon = icon("alert-triangle", PhWarning, "duotone"); +export const CircleAlertIcon = icon("circle-alert", PhWarningCircle, "duotone"); +export const InfoIcon = icon("info", PhInfo, "duotone"); +export const BugIcon = icon("bug", PhBug, "duotone"); +export const LoaderIcon = icon("loader", PhCircleNotch, "bold"); +export const LoaderCircleIcon = icon("loader-circle", PhCircleNotch, "bold"); +export const LoaderCircle = icon("loader-circle", PhCircleNotch, "bold"); +export const Loader2Icon = icon("loader-2", PhCircleNotch, "bold"); +export const FileIcon = icon("file", PhFile, "duotone"); +export const Files = icon("files", PhFiles, "duotone"); +export const FileDiff = icon("file-diff", PhGitDiff, "duotone"); +export const FileDiffIcon = icon("file-diff", PhGitDiff, "duotone"); +export const FileJsonIcon = icon("file-json", PhFileCode, "duotone"); +export const ClipboardList = icon("clipboard-list", PhClipboardText, "duotone"); +export const CopyIcon = icon("copy", PhCopy, "duotone"); +export const ArchiveIcon = icon("archive", PhArchive, "duotone"); +export const ArchiveX = icon("archive-x", PhBoxArrowUp, "duotone"); +export const Trash2Icon = icon("trash-2", PhTrash, "duotone"); +export const Trash2 = icon("trash-2", PhTrash, "duotone"); +export const FolderIcon = icon("folder", PhFolder, "duotone"); +export const FolderClosedIcon = icon("folder-closed", PhFolder, "duotone"); +export const FolderOpenIcon = icon("folder-open", PhFolderOpen, "duotone"); +export const FolderPlusIcon = icon("folder-plus", PhFolderPlus, "duotone"); +export const FolderGitIcon = icon("folder-git", PhFolderDashed, "duotone"); +export const FolderGit2Icon = icon("folder-git-2", PhFolderDashed, "duotone"); +export const FolderTree = icon("folder-tree", PhTreeStructure, "duotone"); +export const GitBranchIcon = icon("git-branch", PhGitBranch, "duotone"); +export const GitBranchPlusIcon = icon("git-branch-plus", PhGitFork, "duotone"); +export const GitCommitIcon = icon("git-commit", PhGitCommit, "duotone"); +export const GitPullRequestIcon = icon("git-pull-request", PhGitPullRequest, "duotone"); +export const TerminalIcon = icon("terminal", PhTerminal, "duotone"); +export const TerminalSquare = icon("terminal-square", PhTerminalWindow, "duotone"); +export const Code2 = icon("code-2", PhCode, "duotone"); +export const HammerIcon = icon("hammer", PhHammer, "duotone"); +export const WrenchIcon = icon("wrench", PhWrench, "duotone"); +export const FlaskConicalIcon = icon("flask-conical", PhFlask, "duotone"); +export const ContainerIcon = icon("container", PhShippingContainer, "duotone"); +export const ServerIcon = icon("server", PhHardDrives, "duotone"); +export const CloudIcon = icon("cloud", PhCloud, "duotone"); +export const CloudUploadIcon = icon("cloud-upload", PhCloudArrowUp, "duotone"); +export const GlobeIcon = icon("globe", PhGlobe, "duotone"); +export const Globe = icon("globe", PhGlobe, "duotone"); +export const Globe2 = icon("globe-2", PhGlobeHemisphereWest, "duotone"); +export const Globe2Icon = icon("globe-2", PhGlobeHemisphereWest, "duotone"); +export const WifiOffIcon = icon("wifi-off", PhWifiSlash, "duotone"); +export const RadioTower = icon("radio-tower", PhBroadcast, "duotone"); +export const QrCodeIcon = icon("qr-code", PhQrCode, "duotone"); +export const KeyboardIcon = icon("keyboard", PhKeyboard, "duotone"); +export const MonitorIcon = icon("monitor", PhMonitor, "duotone"); +export const SmartphoneIcon = icon("smartphone", PhDeviceMobile, "duotone"); +export const BotIcon = icon("bot", PhRobot, "duotone"); +export const SparklesIcon = icon("sparkles", PhSparkle, "duotone"); +export const ZapIcon = icon("zap", PhLightning, "duotone"); +export const StarIcon = icon("star", PhStar, "duotone"); +export const LockIcon = icon("lock", PhLock, "duotone"); +export const LockOpenIcon = icon("lock-open", PhLockOpen, "duotone"); +export const LogInIcon = icon("log-in", PhSignIn, "duotone"); +export const EyeIcon = icon("eye", PhEye, "duotone"); +export const Eye = icon("eye", PhEye, "duotone"); +export const EyeOffIcon = icon("eye-off", PhEyeSlash, "duotone"); +export const SearchIcon = icon("search", PhMagnifyingGlass, "duotone"); +export const Search = icon("search", PhMagnifyingGlass, "duotone"); +export const SettingsIcon = icon("settings", PhGear, "duotone"); +export const Settings2Icon = icon("settings-2", PhGearSix, "duotone"); +export const DownloadIcon = icon("download", PhDownloadSimple, "bold"); +export const ExternalLinkIcon = icon("external-link", PhArrowSquareOut, "duotone"); +export const ExternalLink = icon("external-link", PhArrowSquareOut, "duotone"); +export const LinkIcon = icon("link", PhLink, "duotone"); +export const Link2 = icon("link-2", PhLinkSimple, "duotone"); +export const Link2Icon = icon("link-2", PhLinkSimple, "duotone"); +export const MessageSquareIcon = icon("message-square", PhChatText, "duotone"); +export const MessageCircle = icon("message-circle", PhChatCircle, "duotone"); +export const MessageCircleIcon = icon("message-circle", PhChatCircle, "duotone"); +export const EllipsisIcon = icon("ellipsis", PhDotsThree, "bold"); +export const MoreVertical = icon("more-vertical", PhDotsThreeVertical, "bold"); +export const PlayIcon = icon("play", PhPlay, "duotone"); +export const ListChecksIcon = icon("list-checks", PhListChecks, "duotone"); +export const ListTodoIcon = icon("list-todo", PhListChecks, "duotone"); +export const Camera = icon("camera", PhCamera, "duotone"); +export const PipetteIcon = icon("pipette", PhEyedropper, "duotone"); +export const PaintbrushIcon = icon("paintbrush", PhPaintBrush, "duotone"); +export const Paintbrush = icon("paintbrush", PhPaintBrush, "duotone"); +export const PencilRulerIcon = icon("pencil-ruler", PhPencilRuler, "duotone"); +export const PenLineIcon = icon("pen-line", PhPencilSimpleLine, "duotone"); +export const PenLine = icon("pen-line", PhPencilSimpleLine, "duotone"); +export const SquarePenIcon = icon("square-pen", PhNotePencil, "duotone"); +export const Frame = icon("frame", PhSelection, "duotone"); +export const MousePointerClick = icon("mouse-pointer-click", PhCursorClick, "duotone"); +export const MousePointerClickIcon = icon("mouse-pointer-click", PhCursorClick, "duotone"); +export const MousePointer2 = icon("mouse-pointer-2", PhCursor, "duotone"); +export const Maximize2Icon = icon("maximize-2", PhArrowsOutSimple, "bold"); +export const Minimize2Icon = icon("minimize-2", PhArrowsInSimple, "bold"); +export const PanelLeftIcon = icon("panel-left", PhSidebar, "duotone"); +export const PanelLeftCloseIcon = icon("panel-left-close", PhSidebarSimple, "duotone"); +export const PanelRightIcon = icon("panel-right", PhSidebar, "duotone"); +export const PanelBottomIcon = icon("panel-bottom", PhLayout, "duotone"); +export const Columns2Icon = icon("columns-2", PhColumns, "duotone"); +export const Rows3Icon = icon("rows-3", PhRows, "duotone"); +export const SquareSplitHorizontal = icon( + "square-split-horizontal", + PhSquareSplitHorizontal, + "duotone", +); +export const SquareSplitVertical = icon("square-split-vertical", PhSquareSplitVertical, "duotone"); +export const TextWrapIcon = icon("text-wrap", PhArrowElbowDownLeft, "bold"); +export const WrapTextIcon = icon("wrap-text", PhArrowElbowDownLeft, "bold"); +export const PilcrowIcon = icon("pilcrow", PhParagraph, "duotone"); diff --git a/apps/web/src/index.css b/apps/web/src/index.css index 29dd99b8e6d..8e7fa443e8f 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -160,6 +160,17 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil --color-sidebar-row-hover: var(--sidebar-row-hover); --color-sidebar-row-active: var(--sidebar-row-active); --color-sidebar-row-selected: var(--sidebar-row-selected); + /* fork:begin sidebar-v2-status-palette — see .fork/customizations.yaml#sidebar-v2-card-rows + Utility names only. Tailwind reads `@theme` to decide which `bg-*`/`fill-*` + classes exist, so these six registrations cannot live in the fork's own + stylesheet. The values they resolve to do — see `theme.custom.css`. */ + --color-sidebar-row-working: var(--sidebar-row-working); + --color-sidebar-v2-status-working: var(--sidebar-v2-status-working); + --color-sidebar-v2-status-done: var(--sidebar-v2-status-done); + --color-sidebar-v2-status-approval: var(--sidebar-v2-status-approval); + --color-sidebar-v2-status-input: var(--sidebar-v2-status-input); + --color-sidebar-v2-status-failed: var(--sidebar-v2-status-failed); + /* fork:end sidebar-v2-status-palette */ --color-sidebar-border: var(--sidebar-border); --radius-sm: calc(var(--radius) - 4px); --radius-md: calc(var(--radius) - 2px); diff --git a/apps/web/src/theme.custom.css b/apps/web/src/theme.custom.css index e25f9205fcd..74301d378c6 100644 --- a/apps/web/src/theme.custom.css +++ b/apps/web/src/theme.custom.css @@ -5,9 +5,629 @@ * here win ties against upstream at equal specificity. Keep every rule scoped * under the fork marker attribute (set by `custom/forkMarker.ts`) so nothing * in this file can leak into an unmarked, pure-upstream build. + * + * `@keyframes` are the one exception: they are global by definition and cannot + * be scoped to a selector. The fork-owned ones are prefixed `sidebar-v2-rain-*` + * so the names cannot collide with upstream's. + * + * The Tailwind-facing half of the Sidebar V2 palette — the six + * `--color-sidebar-v2-*` registrations — necessarily stays in `index.css`'s + * `@theme` block, since that is the only place Tailwind reads utility names + * from. It is fenced there as `fork:sidebar-v2-status-palette`. Everything the + * cascade can carry lives here instead. */ -:root[data-fork="noahhendrickson-t3code"] { - /* No visual deviation yet. The first real theme change lands here; larger - structural UI changes go through `src/overrides/` instead. */ +/* Sidebar V2 status palette, keyed to the fork marker so an unmarked build + falls back to upstream's variables. The design's hues are tuned for a black + panel; on the light panel the same values are near-invisible at 8px, so each + one drops to the darkest shade that still reads as the same color family. + + `working` and `done` deliberately share a hue: the mark's *form* is what + separates them (falling pixels vs a static dot), matching the phanttom + Ghostty sidebar this is ported from. See `custom/SidebarV2StatusIndicator`. + + Note this diverges from the mobile app, which still uses sky for working + (`apps/mobile/src/features/threads/thread-list-v2-items.tsx`, + `apps/mobile/src/widgets/AgentActivity.tsx`). Web leads here; mobile has not + been migrated. + + Declared at the root as well as on the panel, mirroring what upstream does + for its own sidebar primitives in `index.css` ("including portaled mobile + sheets and settings navigation outside the app sidebar"). A row rendered + through a portal sits outside `[data-sidebar-version]`, and without the root + copy its dot would resolve to a transparent color. */ +:root[data-fork="noahhendrickson-t3code"], +:root[data-fork="noahhendrickson-t3code"] [data-sidebar-version="v2"] { + --sidebar-row-working: color-mix(in srgb, var(--foreground) 5%, transparent); + --sidebar-v2-status-working: var(--color-emerald-600); + --sidebar-v2-status-done: var(--color-emerald-600); + --sidebar-v2-status-approval: var(--color-amber-500); + --sidebar-v2-status-input: var(--color-indigo-600); + --sidebar-v2-status-failed: var(--color-red-600); +} + +/* `.dark` is toggled on the document element (see `hooks/useTheme.ts`), which + is the same node the fork marker lands on — hence the compound selector + rather than a descendant one. */ +:root[data-fork="noahhendrickson-t3code"].dark, +:root[data-fork="noahhendrickson-t3code"].dark [data-sidebar-version="v2"] { + /* Matches the rgba(255,255,255,0.08) fill on the working specimen. It lands + on the same value as --sidebar-row-hover, so working rows hover to + --sidebar-row-active instead — see rowSurfaceClassName in SidebarV2. */ + --sidebar-row-working: color-mix(in srgb, var(--foreground) 8%, transparent); + --sidebar-v2-status-working: #24fe8a; + --sidebar-v2-status-done: #24fe8a; + --sidebar-v2-status-approval: #ffcd59; + --sidebar-v2-status-input: #8b9cff; + --sidebar-v2-status-failed: #ff6b60; +} + +/* Pixel rain: the "working" indicator ported from the phanttom Ghostty fork + (macos/Sources/Features/Terminal/Sidebar/SidebarView.swift, PixelSparkleView). + Four columns of drops fall through a 5-row grid, each column with its own + speed and phase; the drop head is bright with an exponential trail above + it and a sharp falloff below. + + Swift redraws every frame from `head = (t*speed + phase) mod 8 - 1.5` and + `alpha = dy >= 0 ? exp(-0.8*dy) : exp(8*dy)`. Every cell in a column shares + one head, so the same motion decomposes into five fixed alpha profiles (one + per row) driven by one clock per column — so there is no per-frame JS at all. + (Opacity on an SVG *child* is not promoted to its own compositor layer, so + the SVG still repaints each frame; `content-visibility: auto` on the row is + what keeps offscreen rows from paying for it.) + + GENERATED, do not hand-edit: `__fork_guards__/sidebarV2Rain.test.ts` + re-derives this whole table from the Swift constants and fails if it drifts. + Interpolating these stops tracks the reference curve to 1.08e-3 mean and + 0.034 max absolute opacity (100k samples); the worst point is the steep rise + into row 0's peak, not the loop seam. The guard test asserts both bounds. */ +@keyframes sidebar-v2-rain-0 { + 0% { + opacity: 0; + } + 4% { + opacity: 0; + } + 7.5% { + opacity: 0.001; + } + 8% { + opacity: 0.001; + } + 8.44% { + opacity: 0.001; + } + 9.37% { + opacity: 0.002; + } + 10.31% { + opacity: 0.005; + } + 11.25% { + opacity: 0.008; + } + 12% { + opacity: 0.013; + } + 12.19% { + opacity: 0.015; + } + 13.12% { + opacity: 0.027; + } + 14.06% { + opacity: 0.05; + } + 15% { + opacity: 0.091; + } + 15.94% { + opacity: 0.166; + } + 16% { + opacity: 0.172; + } + 16.87% { + opacity: 0.3; + } + 17.81% { + opacity: 0.548; + } + 18.75% { + opacity: 1; + } + 19.69% { + opacity: 0.942; + } + 20% { + opacity: 0.923; + } + 20.62% { + opacity: 0.887; + } + 21.56% { + opacity: 0.835; + } + 22.5% { + opacity: 0.787; + } + 24% { + opacity: 0.715; + } + 28% { + opacity: 0.553; + } + 32% { + opacity: 0.428; + } + 36% { + opacity: 0.332; + } + 40% { + opacity: 0.257; + } + 44% { + opacity: 0.199; + } + 48% { + opacity: 0.154; + } + 52% { + opacity: 0.119; + } + 56% { + opacity: 0.092; + } + 60% { + opacity: 0.071; + } + 64% { + opacity: 0.055; + } + 68% { + opacity: 0.043; + } + 72% { + opacity: 0.033; + } + 76% { + opacity: 0.026; + } + 80% { + opacity: 0.02; + } + 84% { + opacity: 0.015; + } + 88% { + opacity: 0.012; + } + 92% { + opacity: 0.009; + } + 96% { + opacity: 0.007; + } + 100% { + opacity: 0.006; + } +} +@keyframes sidebar-v2-rain-1 { + 0% { + opacity: 0; + } + 16% { + opacity: 0; + } + 20% { + opacity: 0.001; + } + 20.94% { + opacity: 0.001; + } + 21.88% { + opacity: 0.002; + } + 22.81% { + opacity: 0.005; + } + 23.75% { + opacity: 0.008; + } + 24% { + opacity: 0.01; + } + 24.69% { + opacity: 0.015; + } + 25.62% { + opacity: 0.027; + } + 26.56% { + opacity: 0.05; + } + 27.5% { + opacity: 0.091; + } + 28% { + opacity: 0.125; + } + 28.44% { + opacity: 0.166; + } + 29.37% { + opacity: 0.3; + } + 30.31% { + opacity: 0.548; + } + 31.25% { + opacity: 1; + } + 32% { + opacity: 0.953; + } + 32.19% { + opacity: 0.942; + } + 33.12% { + opacity: 0.887; + } + 34.06% { + opacity: 0.835; + } + 35% { + opacity: 0.787; + } + 36% { + opacity: 0.738; + } + 40% { + opacity: 0.571; + } + 44% { + opacity: 0.442; + } + 48% { + opacity: 0.342; + } + 52% { + opacity: 0.265; + } + 56% { + opacity: 0.205; + } + 60% { + opacity: 0.159; + } + 64% { + opacity: 0.123; + } + 68% { + opacity: 0.095; + } + 72% { + opacity: 0.074; + } + 76% { + opacity: 0.057; + } + 80% { + opacity: 0.044; + } + 84% { + opacity: 0.034; + } + 88% { + opacity: 0.026; + } + 92% { + opacity: 0.02; + } + 96% { + opacity: 0.016; + } + 100% { + opacity: 0.012; + } +} +@keyframes sidebar-v2-rain-2 { + 0% { + opacity: 0; + } + 28% { + opacity: 0; + } + 32% { + opacity: 0.001; + } + 32.5% { + opacity: 0.001; + } + 33.44% { + opacity: 0.001; + } + 34.38% { + opacity: 0.002; + } + 35.31% { + opacity: 0.005; + } + 36% { + opacity: 0.007; + } + 36.25% { + opacity: 0.008; + } + 37.19% { + opacity: 0.015; + } + 38.12% { + opacity: 0.027; + } + 39.06% { + opacity: 0.05; + } + 40% { + opacity: 0.091; + } + 40.94% { + opacity: 0.166; + } + 41.87% { + opacity: 0.3; + } + 42.81% { + opacity: 0.548; + } + 43.75% { + opacity: 1; + } + 44% { + opacity: 0.984; + } + 44.69% { + opacity: 0.942; + } + 45.62% { + opacity: 0.887; + } + 46.56% { + opacity: 0.835; + } + 47.5% { + opacity: 0.787; + } + 48% { + opacity: 0.762; + } + 52% { + opacity: 0.59; + } + 56% { + opacity: 0.457; + } + 60% { + opacity: 0.353; + } + 64% { + opacity: 0.274; + } + 68% { + opacity: 0.212; + } + 72% { + opacity: 0.164; + } + 76% { + opacity: 0.127; + } + 80% { + opacity: 0.098; + } + 84% { + opacity: 0.076; + } + 88% { + opacity: 0.059; + } + 92% { + opacity: 0.046; + } + 96% { + opacity: 0.035; + } + 100% { + opacity: 0.027; + } +} +@keyframes sidebar-v2-rain-3 { + 0% { + opacity: 0; + } + 44% { + opacity: 0; + } + 45% { + opacity: 0.001; + } + 45.94% { + opacity: 0.001; + } + 46.88% { + opacity: 0.002; + } + 47.81% { + opacity: 0.005; + } + 48% { + opacity: 0.005; + } + 48.75% { + opacity: 0.008; + } + 49.69% { + opacity: 0.015; + } + 50.62% { + opacity: 0.027; + } + 51.56% { + opacity: 0.05; + } + 52% { + opacity: 0.066; + } + 52.5% { + opacity: 0.091; + } + 53.44% { + opacity: 0.166; + } + 54.37% { + opacity: 0.3; + } + 55.31% { + opacity: 0.548; + } + 56% { + opacity: 0.852; + } + 56.25% { + opacity: 1; + } + 57.19% { + opacity: 0.942; + } + 58.13% { + opacity: 0.887; + } + 59.06% { + opacity: 0.835; + } + 60% { + opacity: 0.787; + } + 64% { + opacity: 0.609; + } + 68% { + opacity: 0.471; + } + 72% { + opacity: 0.365; + } + 76% { + opacity: 0.283; + } + 80% { + opacity: 0.219; + } + 84% { + opacity: 0.169; + } + 88% { + opacity: 0.131; + } + 92% { + opacity: 0.101; + } + 96% { + opacity: 0.079; + } + 100% { + opacity: 0.061; + } +} +@keyframes sidebar-v2-rain-4 { + 0% { + opacity: 0; + } + 56% { + opacity: 0; + } + 57.5% { + opacity: 0.001; + } + 58.44% { + opacity: 0.001; + } + 59.38% { + opacity: 0.002; + } + 60% { + opacity: 0.004; + } + 60.31% { + opacity: 0.005; + } + 61.25% { + opacity: 0.008; + } + 62.19% { + opacity: 0.015; + } + 63.12% { + opacity: 0.027; + } + 64% { + opacity: 0.048; + } + 64.06% { + opacity: 0.05; + } + 65% { + opacity: 0.091; + } + 65.94% { + opacity: 0.166; + } + 66.87% { + opacity: 0.3; + } + 67.81% { + opacity: 0.548; + } + 68% { + opacity: 0.619; + } + 68.75% { + opacity: 1; + } + 69.69% { + opacity: 0.942; + } + 70.63% { + opacity: 0.887; + } + 71.56% { + opacity: 0.835; + } + 72% { + opacity: 0.812; + } + 72.5% { + opacity: 0.787; + } + 76% { + opacity: 0.629; + } + 80% { + opacity: 0.487; + } + 84% { + opacity: 0.377; + } + 88% { + opacity: 0.292; + } + 92% { + opacity: 0.226; + } + 96% { + opacity: 0.175; + } + 100% { + opacity: 0.135; + } } diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index 1a89dbd83e2..59aa344fb8f 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -14,7 +14,11 @@ // TS falls back to `./src/*` when no override exists. // See `.fork/README.md` §3. "~/*": ["./src/overrides/*", "./src/*"], - "~upstream/*": ["./src/*"] + "~upstream/*": ["./src/*"], + // Fork-only: mirrors the `lucide-react` alias in `vite.config.ts` so + // TypeScript sees the same Phosphor duotone shim the bundler loads. + // See `.fork/customizations.yaml#phosphor-duotone-icons`. + "lucide-react": ["./src/custom/icons/lucide-phosphor.tsx"] }, "plugins": [ { diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts index b9dda41ef85..c1e38113789 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -144,6 +144,16 @@ export default defineConfig(() => { resolve: { tsconfigPaths: true, dedupe: ["react", "react-dom"], + alias: { + // Fork-only: every `lucide-react` import resolves to the Phosphor + // duotone shim instead. Mirrored in `tsconfig.json` so types agree. + // Anchoring the swap here keeps all ~89 upstream import sites + // untouched. See `.fork/README.md` §3 and + // `.fork/customizations.yaml#phosphor-duotone-icons`. + "lucide-react": NodeURL.fileURLToPath( + new URL("./src/custom/icons/lucide-phosphor.tsx", import.meta.url), + ), + }, }, experimental: { bundledDev, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bea636d3807..78ab34d63ce 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -547,6 +547,9 @@ importers: '@lexical/react': specifier: ^0.41.0 version: 0.41.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(yjs@13.6.31) + '@phosphor-icons/react': + specifier: ^2.1.10 + version: 2.1.10(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@pierre/diffs': specifier: 'catalog:' version: 1.3.0-beta.5(patch_hash=7cb6da88544119adda056b2f46f43956f99326227732da0b345081e285a6c53a)(@shikijs/themes@4.3.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) @@ -3554,6 +3557,13 @@ packages: resolution: {integrity: sha512-ODOov0sGMJMf3jPonOkgGqPknTsu+DdQ7kD++gz8aI+aFMOMHFbWAA2taqXXVTdP+OTOQR/znGvSpmkeI0WTYQ==} engines: {node: '>=14.18.0'} + '@phosphor-icons/react@2.1.10': + resolution: {integrity: sha512-vt8Tvq8GLjheAZZYa+YG/pW7HDbov8El/MANW8pOAz4eGxrwhnbfrQZq0Cp4q8zBEu8NIhHdnr+r8thnfRSNYA==} + engines: {node: '>=10'} + peerDependencies: + react: '>= 16.8' + react-dom: '>= 16.8' + '@pierre/diffs@1.3.0-beta.5': resolution: {integrity: sha512-d7449IY6Phcg9LCRLbPxhsxn6Bv4KoaP/vPyZtGu2uR1SFsSJPQcRoPf8lzyobNGKD0GZGuhgHW5LrOlilFo7w==} peerDependencies: @@ -13452,6 +13462,11 @@ snapshots: tslib: 2.8.1 webcrypto-core: 1.9.2 + '@phosphor-icons/react@2.1.10(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + dependencies: + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) + '@pierre/diffs@1.3.0-beta.5(patch_hash=7cb6da88544119adda056b2f46f43956f99326227732da0b345081e285a6c53a)(@shikijs/themes@4.2.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': dependencies: '@pierre/theme': 1.0.3