From 9cb2118b76865872f7ccb56f3ed654c5b5c21252 Mon Sep 17 00:00:00 2001 From: Rohit Ghumare Date: Tue, 28 Jul 2026 11:17:29 +0100 Subject: [PATCH 1/2] feat(worktree,console): ship the worktrees page as injectable console UI --- console/web/src/App.tsx | 21 +- console/web/src/hooks/use-hash-route.ts | 9 +- console/web/src/hooks/use-worktree-events.ts | 91 +--- console/web/src/lib/nav-options.test.ts | 47 ++- console/web/src/lib/nav-options.ts | 4 - .../components/WorktreeGraph.stories.tsx | 46 -- .../pages/Worktrees/hooks/useWorktreesLive.ts | 76 ---- console/web/src/pages/Worktrees/index.tsx | 133 ------ pnpm-lock.yaml | 53 ++- pnpm-workspace.yaml | 1 + worktree/Cargo.lock | 13 + worktree/Cargo.toml | 3 + worktree/build.rs | 173 ++++++++ worktree/src/lib.rs | 1 + worktree/src/main.rs | 5 + worktree/src/ui.rs | 77 ++++ worktree/ui/build.mjs | 37 ++ worktree/ui/package.json | 21 + worktree/ui/page.tsx | 27 ++ .../ui/src/page}/WorktreeDetailPanel.tsx | 99 ++--- .../ui/src/page}/WorktreeGraph.tsx | 93 ++--- worktree/ui/src/page/icons.tsx | 131 ++++++ worktree/ui/src/page/index.tsx | 101 +++++ .../ui/src/page}/layout.test.ts | 2 +- .../lib => worktree/ui/src/page}/layout.ts | 2 +- worktree/ui/src/page/useWorktreesLive.ts | 113 +++++ worktree/ui/src/page/worktree-data.ts | 173 ++++++++ worktree/ui/styles.css | 394 ++++++++++++++++++ worktree/ui/tsconfig.json | 14 + 29 files changed, 1422 insertions(+), 538 deletions(-) delete mode 100644 console/web/src/pages/Worktrees/components/WorktreeGraph.stories.tsx delete mode 100644 console/web/src/pages/Worktrees/hooks/useWorktreesLive.ts delete mode 100644 console/web/src/pages/Worktrees/index.tsx create mode 100644 worktree/src/ui.rs create mode 100644 worktree/ui/build.mjs create mode 100644 worktree/ui/package.json create mode 100644 worktree/ui/page.tsx rename {console/web/src/pages/Worktrees/components => worktree/ui/src/page}/WorktreeDetailPanel.tsx (60%) rename {console/web/src/pages/Worktrees/components => worktree/ui/src/page}/WorktreeGraph.tsx (52%) create mode 100644 worktree/ui/src/page/icons.tsx create mode 100644 worktree/ui/src/page/index.tsx rename {console/web/src/pages/Worktrees/lib => worktree/ui/src/page}/layout.test.ts (98%) rename {console/web/src/pages/Worktrees/lib => worktree/ui/src/page}/layout.ts (98%) create mode 100644 worktree/ui/src/page/useWorktreesLive.ts create mode 100644 worktree/ui/src/page/worktree-data.ts create mode 100644 worktree/ui/styles.css create mode 100644 worktree/ui/tsconfig.json diff --git a/console/web/src/App.tsx b/console/web/src/App.tsx index 1d636583e..5bb913b45 100644 --- a/console/web/src/App.tsx +++ b/console/web/src/App.tsx @@ -33,7 +33,6 @@ import { Github } from '@/pages/Github' import { Memory } from '@/pages/Memory' import { TracesV2 } from '@/pages/TracesV2' import { Workers } from '@/pages/Workers' -import { Worktrees } from '@/pages/Worktrees' export function App() { const [theme, setTheme] = useTheme() @@ -110,8 +109,6 @@ export function App() { ) : view === 'workers' ? ( - ) : view === 'worktrees' ? ( - ) : view === 'browser' ? ( ) : view === 'memory' ? ( @@ -156,25 +153,15 @@ function Header({ onOpenShortcuts, }: HeaderProps) { // Optional-worker entries appear only while their worker is present; a - // direct #/worktrees or #/browser hit still lands on that page's install - // notice. - const { - worktreeAvailable, - browserAvailable, - memoryAvailable, - githubAvailable, - } = useConversationsCtx() + // direct #/browser hit still lands on that page's install notice. + const { browserAvailable, memoryAvailable, githubAvailable } = + useConversationsCtx() // Injected pages: the runtime analogue of worker-presence gating — // presence is the script being loaded, which already tracks worker // connectedness via trigger GC. const extPages = useExtPages() const viewOptions: { value: string; label: string }[] = [ - ...buildViewOptions( - worktreeAvailable, - browserAvailable, - memoryAvailable, - githubAvailable, - ), + ...buildViewOptions(browserAvailable, memoryAvailable, githubAvailable), ...extPages.map((page) => ({ value: extNavValue(page), label: page.title, diff --git a/console/web/src/hooks/use-hash-route.ts b/console/web/src/hooks/use-hash-route.ts index 143e08951..77cc87bd6 100644 --- a/console/web/src/hooks/use-hash-route.ts +++ b/console/web/src/hooks/use-hash-route.ts @@ -3,8 +3,7 @@ import { useCallback, useEffect, useRef, useState } from 'react' // `chat` is no longer a routed view; it's always-rendered as the side dock // in App.tsx. Hash routes only pick which view fills the right pane. The // component spec sheet + streaming playground moved to Storybook, so the -// routed views are `traces`, `workers`, `worktrees`, `browser`, and -// `configuration`. +// routed views are `traces`, `workers`, `browser`, and `configuration`. // `ext` is the injectable-UI prefix: worker-contributed pages route at // `#/ext/` — deliberately outside the first-party names so an // injected page can never collide with or shadow `#/traces`, `#/workers`, …. @@ -12,7 +11,6 @@ export type View = | 'configuration' | 'traces' | 'workers' - | 'worktrees' | 'browser' | 'memory' | 'github' @@ -96,9 +94,6 @@ function routeFromHash(hash: string): View | null { if (hash === '#/workers' || hash.startsWith('#/workers/')) { return 'workers' } - if (hash === '#/worktrees') { - return 'worktrees' - } if (hash === '#/memory') { return 'memory' } @@ -135,8 +130,6 @@ function hashFor(view: View): string { return '#/traces' case 'workers': return '#/workers' - case 'worktrees': - return '#/worktrees' case 'browser': return '#/browser' case 'memory': diff --git a/console/web/src/hooks/use-worktree-events.ts b/console/web/src/hooks/use-worktree-events.ts index 105a0830c..7b1623704 100644 --- a/console/web/src/hooks/use-worktree-events.ts +++ b/console/web/src/hooks/use-worktree-events.ts @@ -1,11 +1,10 @@ -import { useEffect, useId, useRef, useState } from 'react' +import { useEffect, useId, useRef } from 'react' import { getIiiClient } from '@/lib/iii-client' import { parseLandBlockedEvent, parseLandedEvent, WORKTREE_LAND_BLOCKED_TRIGGER, WORKTREE_LANDED_TRIGGER, - WORKTREE_LIFECYCLE_TRIGGERS, type WorktreeLandBlockedEvent, type WorktreeLandedEvent, } from '@/lib/worktrees' @@ -92,91 +91,3 @@ export function useWorktreeEvents(opts: UseWorktreeEventsOptions): void { } }, [enabled, instanceId]) } - -const LIFECYCLE_FN = 'console::worktree-lifecycle' - -export interface UseWorktreeLifecycleEventsOptions { - /** Only subscribe on the real backend with the worktree worker present. */ - enabled: boolean - /** Fired for EVERY worktree lifecycle event, with its trigger type. */ - onEvent: (triggerType: string, payload: unknown) => void -} - -export interface WorktreeLifecycleSubscription { - /** - * True once all six trigger bindings registered. While false (probe in - * flight, worker absent, SDK failure) callers fall back to polling. - */ - bound: boolean -} - -/** - * Page-scoped feed of ALL six worktree lifecycle trigger types - * (created / claimed / released / removed / landed / land-blocked), for - * surfaces that re-read the registry on any change rather than reacting to - * one event shape. Same browser-local binding pattern as - * [`useWorktreeEvents`]. - */ -export function useWorktreeLifecycleEvents( - opts: UseWorktreeLifecycleEventsOptions, -): WorktreeLifecycleSubscription { - const { enabled } = opts - const onEventRef = useRef(opts.onEvent) - onEventRef.current = opts.onEvent - - const instanceId = useId().replace(/[^a-zA-Z0-9]/g, '') - const [bound, setBound] = useState(false) - - useEffect(() => { - if (!enabled) { - setBound(false) - return - } - let cancelled = false - const offs: Array<() => void> = [] - - void (async () => { - let client: Awaited> - try { - client = await getIiiClient() - } catch { - // Client startup failed; stay unbound so callers fall back to polling. - return - } - if (cancelled) return - let registered = 0 - for (const triggerType of WORKTREE_LIFECYCLE_TRIGGERS) { - const suffix = triggerType.replace(/[^a-zA-Z0-9]/g, '-') - const localFnId = `${LIFECYCLE_FN}::${suffix}::${instanceId}` - try { - offs.push( - client.on(localFnId, (payload: unknown) => { - onEventRef.current(triggerType, payload) - }), - ) - offs.push( - client.registerTrigger({ - type: triggerType, - function_id: `${localFnId}::${client.browserId}`, - config: {}, - }), - ) - registered += 1 - } catch { - // Worker absent or trigger type unregistered; drop the binding. - } - } - if (!cancelled) { - setBound(registered === WORKTREE_LIFECYCLE_TRIGGERS.length) - } - })() - - return () => { - cancelled = true - setBound(false) - for (const off of offs) off() - } - }, [enabled, instanceId]) - - return { bound } -} diff --git a/console/web/src/lib/nav-options.test.ts b/console/web/src/lib/nav-options.test.ts index cbe8430a2..668543ee1 100644 --- a/console/web/src/lib/nav-options.test.ts +++ b/console/web/src/lib/nav-options.test.ts @@ -3,38 +3,43 @@ import { buildViewOptions } from './nav-options' describe('buildViewOptions', () => { it('hides the optional-worker entries while their workers are absent', () => { - expect( - buildViewOptions(false, false, false, false).map((o) => o.value), - ).toEqual(['traces', 'workers']) - }) - - it('appends the worktrees entry when the worker is present', () => { - expect( - buildViewOptions(true, false, false, false).map((o) => o.value), - ).toEqual(['traces', 'workers', 'worktrees']) + expect(buildViewOptions(false, false, false).map((o) => o.value)).toEqual([ + 'traces', + 'workers', + ]) }) it('appends the browser entry when the worker is present', () => { - expect( - buildViewOptions(false, true, false, false).map((o) => o.value), - ).toEqual(['traces', 'workers', 'browser']) + expect(buildViewOptions(true, false, false).map((o) => o.value)).toEqual([ + 'traces', + 'workers', + 'browser', + ]) }) it('appends the memory entry when the worker is present', () => { - expect( - buildViewOptions(false, false, true, false).map((o) => o.value), - ).toEqual(['traces', 'workers', 'memory']) + expect(buildViewOptions(false, true, false).map((o) => o.value)).toEqual([ + 'traces', + 'workers', + 'memory', + ]) }) it('appends the github entry when the worker is present', () => { - expect( - buildViewOptions(false, false, false, true).map((o) => o.value), - ).toEqual(['traces', 'workers', 'github']) + expect(buildViewOptions(false, false, true).map((o) => o.value)).toEqual([ + 'traces', + 'workers', + 'github', + ]) }) it('appends every entry when all optional workers are present', () => { - expect( - buildViewOptions(true, true, true, true).map((o) => o.value), - ).toEqual(['traces', 'workers', 'worktrees', 'browser', 'memory', 'github']) + expect(buildViewOptions(true, true, true).map((o) => o.value)).toEqual([ + 'traces', + 'workers', + 'browser', + 'memory', + 'github', + ]) }) }) diff --git a/console/web/src/lib/nav-options.ts b/console/web/src/lib/nav-options.ts index fac2aecf2..05a223a0e 100644 --- a/console/web/src/lib/nav-options.ts +++ b/console/web/src/lib/nav-options.ts @@ -7,7 +7,6 @@ import type { View } from '@/hooks/use-hash-route' * notice). */ export function buildViewOptions( - worktreeAvailable: boolean, browserAvailable: boolean, memoryAvailable: boolean, githubAvailable: boolean, @@ -16,9 +15,6 @@ export function buildViewOptions( { value: 'traces', label: 'traces' }, { value: 'workers', label: 'workers' }, ] - if (worktreeAvailable) { - options.push({ value: 'worktrees', label: 'worktrees' }) - } if (browserAvailable) { options.push({ value: 'browser', label: 'browser' }) } diff --git a/console/web/src/pages/Worktrees/components/WorktreeGraph.stories.tsx b/console/web/src/pages/Worktrees/components/WorktreeGraph.stories.tsx deleted file mode 100644 index 7a72f4ffe..000000000 --- a/console/web/src/pages/Worktrees/components/WorktreeGraph.stories.tsx +++ /dev/null @@ -1,46 +0,0 @@ -import type { Meta, StoryObj } from '@storybook/react-vite' -import { useState } from 'react' -import type { WorktreeInfo } from '@/lib/worktrees' -import { worktreeGraphFixtures } from '@/stories/fixtures/worktree-fixtures' -import { WorktreeDetailPanel } from './WorktreeDetailPanel' -import { WorktreeGraph } from './WorktreeGraph' - -function GraphHarness({ worktrees }: { worktrees: WorktreeInfo[] }) { - const [selectedId, setSelectedId] = useState(null) - const selected = worktrees.find((w) => w.worktree_id === selectedId) ?? null - return ( -
-
- setSelectedId((cur) => (cur === id ? null : id))} - /> -
- {selected ? ( - setSelectedId(null)} - /> - ) : null} -
- ) -} - -const meta = { - title: 'pages/WorktreeGraph', - component: GraphHarness, -} satisfies Meta - -export default meta -type Story = StoryObj - -export const TwoReposEveryLifecycle: Story = { - name: 'two repos, every lifecycle', - args: { worktrees: worktreeGraphFixtures }, -} - -export const SingleWorktree: Story = { - name: 'single worktree', - args: { worktrees: worktreeGraphFixtures.slice(0, 1) }, -} diff --git a/console/web/src/pages/Worktrees/hooks/useWorktreesLive.ts b/console/web/src/pages/Worktrees/hooks/useWorktreesLive.ts deleted file mode 100644 index 8d288f136..000000000 --- a/console/web/src/pages/Worktrees/hooks/useWorktreesLive.ts +++ /dev/null @@ -1,76 +0,0 @@ -import { useCallback, useEffect, useState } from 'react' -import { useWorktreeLifecycleEvents } from '@/hooks/use-worktree-events' -import { listWorktrees, type WorktreeInfo } from '@/lib/worktrees' - -/** - * Live registry feed for the worktrees page: `worktree::list` with status, - * re-read on every one of the six lifecycle trigger types. While the event - * bindings are unavailable (SDK hiccup, races around worker restart) a - * modest poll keeps the graph honest — skipped entirely while the tab is - * hidden so a backgrounded console never hammers the engine. - */ - -export const WORKTREES_POLL_MS = 15_000 - -export interface WorktreesLive { - worktrees: WorktreeInfo[] - loading: boolean - error: string | null - /** True while updates arrive through the live trigger bindings. */ - live: boolean - refresh: () => void -} - -export function useWorktreesLive(enabled: boolean): WorktreesLive { - const [worktrees, setWorktrees] = useState([]) - const [loading, setLoading] = useState(enabled) - const [error, setError] = useState(null) - const [token, setToken] = useState(0) - - const refresh = useCallback(() => setToken((t) => t + 1), []) - - const { bound } = useWorktreeLifecycleEvents({ - enabled, - onEvent: refresh, - }) - - // biome-ignore lint/correctness/useExhaustiveDependencies: token is a re-run token (bumped by events, polling, and manual refresh), not read by the effect body - useEffect(() => { - if (!enabled) { - setLoading(false) - return - } - // Reset loading on every fetch — the initial load, a re-enable, and each - // token-driven refetch (poll / event / manual refresh) all go through - // here, so the indicator reflects an in-flight refresh consistently. - setLoading(true) - let cancelled = false - void (async () => { - try { - const next = await listWorktrees() - if (cancelled) return - setWorktrees(next) - setError(null) - } catch (err) { - if (cancelled) return - setError(err instanceof Error ? err.message : String(err)) - } finally { - if (!cancelled) setLoading(false) - } - })() - return () => { - cancelled = true - } - }, [enabled, token]) - - useEffect(() => { - if (!enabled || bound) return - const id = window.setInterval(() => { - if (document.hidden) return - refresh() - }, WORKTREES_POLL_MS) - return () => window.clearInterval(id) - }, [enabled, bound, refresh]) - - return { worktrees, loading, error, live: bound, refresh } -} diff --git a/console/web/src/pages/Worktrees/index.tsx b/console/web/src/pages/Worktrees/index.tsx deleted file mode 100644 index 0224182d2..000000000 --- a/console/web/src/pages/Worktrees/index.tsx +++ /dev/null @@ -1,133 +0,0 @@ -import { AlertCircle, GitBranch, RefreshCw } from 'lucide-react' -import { useMemo, useState } from 'react' -import { Button } from '@/components/ui/Button' -import { EmptyState } from '@/components/ui/EmptyState' -import { StatusDot } from '@/components/ui/StatusDot' -import { StatusPanel } from '@/components/ui/StatusPanel' -import { - isWorktreeAvailable, - useWorktreeStatus, -} from '@/hooks/use-worktree-status' -import { useConversationsCtx } from '@/lib/conversations-context' -import { cn } from '@/lib/utils' -import { WorktreeDetailPanel } from './components/WorktreeDetailPanel' -import { WorktreeGraph } from './components/WorktreeGraph' -import { useWorktreesLive } from './hooks/useWorktreesLive' - -/** - * Live worktree graph: repo nodes -> worktree nodes -> owning sessions, so - * a user running parallel worktrees sees the tree while agents work. Data - * is `worktree::list {include_status:true}`, refreshed on all six lifecycle - * trigger types (poll fallback while bindings are unavailable). The nav - * entry is gated on worker presence; a direct hash hit with the worker - * absent lands on the install notice below. - */ - -export function Worktrees() { - const { backend } = useConversationsCtx() - const status = useWorktreeStatus(backend.id === 'real') - const available = isWorktreeAvailable(status) - - const { worktrees, loading, error, live, refresh } = - useWorktreesLive(available) - - const [selectedId, setSelectedId] = useState(null) - const selected = useMemo( - () => worktrees.find((w) => w.worktree_id === selectedId) ?? null, - [worktrees, selectedId], - ) - - const countLabel = loading ? '...' : String(worktrees.length) - - return ( -
-
-
-

- worktrees -

-

- {available ? `${countLabel} managed` : 'worker not connected'} -

-
- {available ? ( -
- - - {live ? 'live' : 'polling'} - - -
- ) : null} -
- -
-
- {!available ? ( - status.loading ? ( -

- checking for the worktree worker... -

- ) : ( - } - headline="worktree worker not installed" - detail="this page needs the optional worktree worker. run: iii worker add worktree" - /> - ) - ) : error ? ( - } - headline="failed to load worktrees" - detail={error} - /> - ) : !loading && worktrees.length === 0 ? ( - - ) : ( - - setSelectedId((cur) => (cur === id ? null : id)) - } - /> - )} -
- {selected ? ( - setSelectedId(null)} - /> - ) : null} -
-
- ) -} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 84faee36f..d263c3b11 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -155,7 +155,7 @@ importers: version: 6.0.3 vite: specifier: ^8.0.13 - version: 8.1.5(@types/node@25.9.5)(jiti@2.7.0) + version: 8.1.5(@types/node@25.9.5)(esbuild@0.25.12)(jiti@2.7.0) vitest: specifier: ^4.1.6 version: 4.1.10(@types/node@25.9.5)(@vitest/coverage-v8@4.1.10)(vite@8.1.5(@types/node@25.9.5)(jiti@2.7.0)) @@ -179,14 +179,11 @@ importers: specifier: ^5.9.2 version: 5.9.3 - iii-directory/ui: + eval/ui: dependencies: '@iii-dev/console-ui': specifier: workspace:* version: link:../../packages/console-ui - zod: - specifier: ^4.4.3 - version: 4.4.3 devDependencies: '@types/react': specifier: ^19.2.14 @@ -198,11 +195,14 @@ importers: specifier: ^5.9.2 version: 5.9.3 - eval/ui: + iii-directory/ui: dependencies: '@iii-dev/console-ui': specifier: workspace:* version: link:../../packages/console-ui + zod: + specifier: ^4.4.3 + version: 4.4.3 devDependencies: '@types/react': specifier: ^19.2.14 @@ -236,6 +236,28 @@ importers: specifier: ^5.9.2 version: 5.9.3 + worktree/ui: + dependencies: + '@iii-dev/console-ui': + specifier: workspace:* + version: link:../../packages/console-ui + zod: + specifier: ^4.4.3 + version: 4.4.3 + devDependencies: + '@types/react': + specifier: ^19.2.14 + version: 19.2.17 + esbuild: + specifier: ^0.25.0 + version: 0.25.12 + typescript: + specifier: ^5.9.2 + version: 5.9.3 + vitest: + specifier: ^4.1.6 + version: 4.1.10(@types/node@25.9.5)(@vitest/coverage-v8@4.1.10)(vite@8.1.5(@types/node@25.9.5)(jiti@2.7.0)) + packages: '@adobe/css-tools@4.5.0': @@ -3349,7 +3371,7 @@ snapshots: dependencies: glob: 13.0.6 react-docgen-typescript: 2.4.0(typescript@6.0.3) - vite: 8.1.5(@types/node@25.9.5)(jiti@2.7.0) + vite: 8.1.5(@types/node@25.9.5)(esbuild@0.25.12)(jiti@2.7.0) optionalDependencies: typescript: 6.0.3 @@ -4195,7 +4217,7 @@ snapshots: '@storybook/csf-plugin': 10.5.3(storybook@10.5.3(@types/react@19.2.17)(react@19.2.7))(vite@8.1.5(@types/node@25.9.5)(jiti@2.7.0)) storybook: 10.5.3(@types/react@19.2.17)(react@19.2.7) ts-dedent: 2.3.0 - vite: 8.1.5(@types/node@25.9.5)(jiti@2.7.0) + vite: 8.1.5(@types/node@25.9.5)(esbuild@0.25.12)(jiti@2.7.0) transitivePeerDependencies: - esbuild - rollup @@ -4206,7 +4228,7 @@ snapshots: storybook: 10.5.3(@types/react@19.2.17)(react@19.2.7) unplugin: 2.3.11 optionalDependencies: - vite: 8.1.5(@types/node@25.9.5)(jiti@2.7.0) + vite: 8.1.5(@types/node@25.9.5)(esbuild@0.25.12)(jiti@2.7.0) '@storybook/global@5.0.0': {} @@ -4237,7 +4259,7 @@ snapshots: resolve: 1.22.12 storybook: 10.5.3(@types/react@19.2.17)(react@19.2.7) tsconfig-paths: 4.2.0 - vite: 8.1.5(@types/node@25.9.5)(jiti@2.7.0) + vite: 8.1.5(@types/node@25.9.5)(esbuild@0.25.12)(jiti@2.7.0) optionalDependencies: typescript: 6.0.3 transitivePeerDependencies: @@ -4330,7 +4352,7 @@ snapshots: '@tailwindcss/node': 4.3.3 '@tailwindcss/oxide': 4.3.3 tailwindcss: 4.3.3 - vite: 8.1.5(@types/node@25.9.5)(jiti@2.7.0) + vite: 8.1.5(@types/node@25.9.5)(esbuild@0.25.12)(jiti@2.7.0) '@tanstack/query-core@5.101.3': {} @@ -4457,7 +4479,7 @@ snapshots: '@vitejs/plugin-react@6.0.3(vite@8.1.5(@types/node@25.9.5)(jiti@2.7.0))': dependencies: '@rolldown/pluginutils': 1.0.1 - vite: 8.1.5(@types/node@25.9.5)(jiti@2.7.0) + vite: 8.1.5(@types/node@25.9.5)(esbuild@0.25.12)(jiti@2.7.0) '@vitest/coverage-v8@4.1.10(vitest@4.1.10)': dependencies: @@ -4496,7 +4518,7 @@ snapshots: estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 8.1.5(@types/node@25.9.5)(jiti@2.7.0) + vite: 8.1.5(@types/node@25.9.5)(esbuild@0.25.12)(jiti@2.7.0) '@vitest/pretty-format@3.2.4': dependencies: @@ -5855,7 +5877,7 @@ snapshots: '@types/unist': 3.0.3 vfile-message: 4.0.3 - vite@8.1.5(@types/node@25.9.5)(jiti@2.7.0): + vite@8.1.5(@types/node@25.9.5)(esbuild@0.25.12)(jiti@2.7.0): dependencies: lightningcss: 1.33.0 picomatch: 4.0.5 @@ -5864,6 +5886,7 @@ snapshots: tinyglobby: 0.2.17 optionalDependencies: '@types/node': 25.9.5 + esbuild: 0.25.12 fsevents: 2.3.3 jiti: 2.7.0 @@ -5887,7 +5910,7 @@ snapshots: tinyexec: 1.2.4 tinyglobby: 0.2.17 tinyrainbow: 3.1.0 - vite: 8.1.5(@types/node@25.9.5)(jiti@2.7.0) + vite: 8.1.5(@types/node@25.9.5)(esbuild@0.25.12)(jiti@2.7.0) why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 25.9.5 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 83176624d..b1525cfeb 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -13,6 +13,7 @@ packages: - eval/ui - state/ui - iii-directory/ui + - worktree/ui # pnpm ≥11 blocks dependency postinstall scripts by default; esbuild's # installs its platform binary. diff --git a/worktree/Cargo.lock b/worktree/Cargo.lock index b398893d4..ae388ea00 100644 --- a/worktree/Cargo.lock +++ b/worktree/Cargo.lock @@ -669,6 +669,18 @@ dependencies = [ "icu_properties", ] +[[package]] +name = "iii-console-ui" +version = "0.1.0" +dependencies = [ + "iii-sdk", + "schemars", + "serde", + "serde_json", + "tokio", + "tracing", +] + [[package]] name = "iii-helpers" version = "0.21.6" @@ -2330,6 +2342,7 @@ dependencies = [ "clap", "dirs", "globset", + "iii-console-ui", "iii-sdk", "schemars", "serde", diff --git a/worktree/Cargo.toml b/worktree/Cargo.toml index c0c31d00b..55f4f4b0b 100644 --- a/worktree/Cargo.toml +++ b/worktree/Cargo.toml @@ -16,6 +16,9 @@ path = "src/lib.rs" [dependencies] iii-sdk = "=0.21.6" +# Worker-side injectable console UI (content function + console:script/style +# triggers + hot-reload watcher) — direct path link, never published. +iii-console-ui = { path = "../crates/console-ui" } tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync", "signal", "time", "process", "fs"] } serde = { version = "1", features = ["derive"] } serde_json = "1" diff --git a/worktree/build.rs b/worktree/build.rs index 81caa36d6..bf0b36cac 100644 --- a/worktree/build.rs +++ b/worktree/build.rs @@ -1,6 +1,179 @@ +//! Build script for the `worktree` worker. +//! +//! 1. Forwards the build-time target triple to the binary as `env!("TARGET")` +//! (used by `manifest.rs` for the registry `supported_targets` field). +//! 2. Ensures the injected console UI assets exist: `src/ui.rs` embeds +//! `ui/dist/page.js` and `ui/dist/styles.css` via `include_str!`, so if +//! either is missing or stale we run `pnpm install && pnpm build` inside +//! `ui/` first (the state worker's precedent). Set `SKIP_UI_BUILD=1` to use +//! the existing `ui/dist/` outputs as-is. + +use std::path::{Path, PathBuf}; +use std::process::Command; +use std::time::SystemTime; + fn main() { println!( "cargo:rustc-env=TARGET={}", std::env::var("TARGET").unwrap() ); + + // `dist/` itself is not listed: include_str! reads it directly, and + // listing it would rebuild-loop on our own output. + println!("cargo:rerun-if-changed=ui/page.tsx"); + println!("cargo:rerun-if-changed=ui/styles.css"); + println!("cargo:rerun-if-changed=ui/src"); + println!("cargo:rerun-if-changed=ui/build.mjs"); + println!("cargo:rerun-if-changed=ui/package.json"); + // The lockfile lives at the workers-repo root (pnpm workspace: the ui + // project links @iii-dev/console-ui from packages/console-ui). + println!("cargo:rerun-if-changed=../pnpm-lock.yaml"); + println!("cargo:rerun-if-changed=ui/tsconfig.json"); + + let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + let ui_dir = manifest_dir.join("ui"); + let dist_assets = [ + ui_dir.join("dist").join("page.js"), + ui_dir.join("dist").join("styles.css"), + ]; + + if dist_assets + .iter() + .all(|a| a.exists() && dist_is_fresh(a, &ui_dir)) + { + return; + } + + if std::env::var_os("SKIP_UI_BUILD").is_some() { + for asset in &dist_assets { + if !asset.exists() { + panic!( + "SKIP_UI_BUILD set but {} is missing — build the UI manually \ + (cd ui && pnpm install && pnpm build) or unset the env var", + asset.display() + ); + } + } + return; + } + + let pnpm = locate_pnpm(); + + let status = Command::new(&pnpm) + .args(["install"]) + .current_dir(&ui_dir) + .status() + .unwrap_or_else(|e| { + panic!( + "failed to spawn `pnpm install` in {}: {e}", + ui_dir.display() + ) + }); + if !status.success() { + panic!("`pnpm install` exited with {status} — see logs above"); + } + + let status = Command::new(&pnpm) + .args(["build"]) + .current_dir(&ui_dir) + .status() + .unwrap_or_else(|e| panic!("failed to spawn `pnpm build` in {}: {e}", ui_dir.display())); + if !status.success() { + panic!("`pnpm build` exited with {status} — see logs above"); + } + + for asset in &dist_assets { + if !asset.exists() { + panic!( + "`pnpm build` finished but {} is still missing — check the esbuild \ + output above", + asset.display() + ); + } + } +} + +/// `true` when the built asset is at least as new as every source that +/// contributes to it. Conservative: any I/O failure forces a rebuild. +fn dist_is_fresh(dist_asset: &Path, ui_dir: &Path) -> bool { + let Ok(dist_mtime) = dist_asset.metadata().and_then(|m| m.modified()) else { + return false; + }; + + let watched_files = [ + ui_dir.join("page.tsx"), + ui_dir.join("styles.css"), + ui_dir.join("build.mjs"), + ui_dir.join("package.json"), + ui_dir.join("../../pnpm-lock.yaml"), + ui_dir.join("tsconfig.json"), + ]; + for f in watched_files.iter() { + if !f.exists() { + continue; + } + let Ok(m) = f.metadata().and_then(|m| m.modified()) else { + return false; + }; + if m > dist_mtime { + return false; + } + } + + for dir in [ui_dir.join("src")] { + if dir.exists() && !subtree_older_than(&dir, dist_mtime) { + return false; + } + } + + true +} + +fn subtree_older_than(root: &Path, ceiling: SystemTime) -> bool { + let Ok(read) = std::fs::read_dir(root) else { + return false; + }; + for entry in read.flatten() { + let path = entry.path(); + let Ok(meta) = entry.metadata() else { + return false; + }; + if meta.is_dir() { + if !subtree_older_than(&path, ceiling) { + return false; + } + } else { + let Ok(m) = meta.modified() else { + return false; + }; + if m > ceiling { + return false; + } + } + } + true +} + +fn locate_pnpm() -> PathBuf { + if let Ok(explicit) = std::env::var("PNPM") { + return PathBuf::from(explicit); + } + let candidates = if cfg!(windows) { + ["pnpm.cmd", "pnpm.exe", "pnpm"].as_slice() + } else { + ["pnpm"].as_slice() + }; + let path = std::env::var_os("PATH").unwrap_or_default(); + for dir in std::env::split_paths(&path) { + for name in candidates { + let candidate = dir.join(name); + if candidate.is_file() { + return candidate; + } + } + } + panic!( + "pnpm not found on PATH — install Node + pnpm, or set SKIP_UI_BUILD=1 \ + after building the UI manually with `cd ui && pnpm install && pnpm build`" + ); } diff --git a/worktree/src/lib.rs b/worktree/src/lib.rs index 3e39bf2e5..194961e57 100644 --- a/worktree/src/lib.rs +++ b/worktree/src/lib.rs @@ -16,3 +16,4 @@ pub mod state; pub mod surface; pub mod trash; pub mod types; +pub mod ui; diff --git a/worktree/src/main.rs b/worktree/src/main.rs index ea48a78ac..05d6b6548 100644 --- a/worktree/src/main.rs +++ b/worktree/src/main.rs @@ -133,6 +133,11 @@ async fn main() -> Result<()> { }); functions::register_all(&iii, &deps); + // Injectable console UI — after the worktree::* functions so the console + // can attribute the assets. `iii` is already an Arc (the + // console-ui crate clones it into its hot-reload watcher task). + worktree::ui::register(&iii); + // Best-effort HTTP exposure for the read-only surface. The `http` // trigger type may be absent (no http worker installed); a failed // registration warns and the worker keeps serving the bus. diff --git a/worktree/src/ui.rs b/worktree/src/ui.rs new file mode 100644 index 000000000..c969ea44e --- /dev/null +++ b/worktree/src/ui.rs @@ -0,0 +1,77 @@ +//! Injectable console UI for the worktree worker +//! (iii/tech-specs/2026-07-17-injectable-ui; authoring SOP: +//! workers/docs/sops/injectable-console-ui.md). +//! +//! Ships two assets into any running console: +//! +//! - `worktree/page.js` (`console:script`) — the `#/ext/worktree` page: a +//! live repo → worktree → session topology graph with a per-worktree detail +//! panel, reading the live worker over `worktree::list` and refreshing on +//! the six lifecycle trigger types. Read-only; create / claim / land stay in +//! agent + CLI flows. +//! - `worktree/styles.css` (`console:style`) — the stylesheet, every rule +//! scoped under `[data-iii-ui="worktree"]`; the console mounts it as a +//! `` and link-swaps it on change, styles-before-scripts on boot. +//! +//! The registration machinery (content function `worktree::ui-content`, one +//! Message-path trigger per asset, `III_WORKTREE_UI_WATCH` hot-reload watcher) +//! lives in the shared `iii-console-ui` crate (path-linked from +//! `workers/crates/console-ui`); this module only names the assets and embeds +//! their bytes. +//! +//! The assets are compiled from `ui/` by esbuild (react + @iii-dev/console-ui +//! external — they resolve through the console's import map at runtime) and +//! embedded at compile time so the worker stays one self-contained binary. +//! For the dev loop, set `III_WORKTREE_UI_WATCH` to the build output directory +//! (or `1` for `ui/dist`): the worker polls both files and re-registers a +//! changed asset's trigger — every open console tab hot-swaps it. + +use std::sync::Arc; + +use iii_console_ui::ConsoleUi; +use iii_sdk::IIIClient; + +pub const PAGE_PATH: &str = "worktree/page.js"; +pub const STYLES_PATH: &str = "worktree/styles.css"; + +/// Built by `build.rs` (esbuild over `ui/`). +const PAGE_JS: &str = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/ui/dist/page.js")); +const STYLES_CSS: &str = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/ui/dist/styles.css")); + +fn console_ui() -> ConsoleUi { + ConsoleUi::new("worktree") + .script(PAGE_PATH, PAGE_JS) + .style(STYLES_PATH, STYLES_CSS) +} + +/// Register the worktree worker's console UI. Call after the `worktree::*` +/// functions are registered. +pub fn register(iii: &Arc) { + console_ui().register(iii); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn ui_builder_accepts_the_assets() { + // The builder panics on any path/kind the console would reject. + let _ = console_ui(); + } + + #[test] + fn embedded_page_is_nonempty_esm() { + assert!(PAGE_JS.contains("export"), "built page.js looks wrong"); + } + + #[test] + fn embedded_styles_are_scoped() { + // esbuild prints the attribute selector unquoted ([data-iii-ui=worktree]). + assert!( + STYLES_CSS.contains(r#"[data-iii-ui="worktree"]"#) + || STYLES_CSS.contains("[data-iii-ui=worktree]"), + "built styles.css must be scoped under the worker's data-iii-ui attribute" + ); + } +} diff --git a/worktree/ui/build.mjs b/worktree/ui/build.mjs new file mode 100644 index 000000000..5115ee96b --- /dev/null +++ b/worktree/ui/build.mjs @@ -0,0 +1,37 @@ +/** + * Build the worker's two console assets: + * + * page.tsx → dist/page.js (injected over `console:script`) + * styles.css → dist/styles.css (injected over `console:style`) + * + * The five shared specifiers stay EXTERNAL — they resolve at runtime + * through the console's import map (a bundled React copy would surface as + * a cryptic "Invalid hook call"). Everything else the page needs gets + * bundled in. `--watch` pairs with the worker's III_WORKTREE_UI_WATCH + * poller for the hot-reload dev loop. + */ + +import esbuild from 'esbuild' + +const options = { + entryPoints: ['page.tsx', 'styles.css'], + bundle: true, + format: 'esm', + jsx: 'automatic', + outdir: 'dist', + external: [ + 'react', + 'react-dom', + 'react-dom/client', + 'react/jsx-runtime', + '@iii-dev/console-ui', + ], + logLevel: 'info', +} + +if (process.argv.includes('--watch')) { + const ctx = await esbuild.context(options) + await ctx.watch() +} else { + await esbuild.build(options) +} diff --git a/worktree/ui/package.json b/worktree/ui/package.json new file mode 100644 index 000000000..c3bf01821 --- /dev/null +++ b/worktree/ui/package.json @@ -0,0 +1,21 @@ +{ + "name": "@iii-workers/worktree-ui", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "build": "tsc --noEmit && node build.mjs", + "watch": "node build.mjs --watch", + "test": "vitest run" + }, + "dependencies": { + "@iii-dev/console-ui": "workspace:*", + "zod": "^4.4.3" + }, + "devDependencies": { + "@types/react": "^19.2.14", + "esbuild": "^0.25.0", + "typescript": "^5.9.2", + "vitest": "^4.1.6" + } +} diff --git a/worktree/ui/page.tsx b/worktree/ui/page.tsx new file mode 100644 index 000000000..28553cdb1 --- /dev/null +++ b/worktree/ui/page.tsx @@ -0,0 +1,27 @@ +/** + * Entry for the worktree worker's injected console UI — compiled by esbuild + * (react + @iii-dev/console-ui external) into dist/page.js and served over + * the `console:script` trigger (see src/ui.rs). The stylesheet is its own + * asset: ../styles.css ships over `console:style` as worktree/styles.css — + * the console mounts and link-swaps it, styles-before-scripts on boot. + * + * `setup(host)` registers one contribution: + * - src/page/ — the `#/ext/worktree` page: a live repo → worktree → session + * topology graph with a per-worktree detail panel, reading the live worker + * over `worktree::list` and refreshing on the six lifecycle trigger types. + * Read-only on purpose: create / claim / land stay in agent + CLI flows. + * + * Registrations go through `host` so the loader disposes them on hot + * reload / worker disconnect. + */ + +import type { Host } from '@iii-dev/console-ui' +import { WorktreesPage } from './src/page' + +export default function setup(host: Host) { + host.pages.register({ + id: 'worktree', + title: 'worktrees', + render: () => , + }) +} diff --git a/console/web/src/pages/Worktrees/components/WorktreeDetailPanel.tsx b/worktree/ui/src/page/WorktreeDetailPanel.tsx similarity index 60% rename from console/web/src/pages/Worktrees/components/WorktreeDetailPanel.tsx rename to worktree/ui/src/page/WorktreeDetailPanel.tsx index 6a7a9a00d..c9342b39a 100644 --- a/console/web/src/pages/Worktrees/components/WorktreeDetailPanel.tsx +++ b/worktree/ui/src/page/WorktreeDetailPanel.tsx @@ -1,20 +1,22 @@ -import { Check, Copy, GitMerge, X } from 'lucide-react' +import { StatusDot } from '@iii-dev/console-ui' +import type { ReactNode } from 'react' import { useCallback, useState } from 'react' -import { StatusDot } from '@/components/ui/StatusDot' -import { cn } from '@/lib/utils' +import { Check, Copy, GitMerge, X } from './icons' import { + cn, integrationLabel, lifecycleTone, lifecycleToneClass, shortWorktreeId, type WorktreeInfo, worktreeIndicators, -} from '@/lib/worktrees' +} from './worktree-data' /** * Detail side panel for one selected worktree. Renders only fields the * worker exposes: identity, paths, base, timestamps, and the git status - * block when the list call computed one. + * block when the list call computed one. Ported from the console page; + * Tailwind utilities became scoped `wt-*` classes (see styles.css). */ interface WorktreeDetailPanelProps { @@ -22,21 +24,11 @@ interface WorktreeDetailPanelProps { onClose: () => void } -function Row({ - label, - children, -}: { - label: string - children: React.ReactNode -}) { +function Row({ label, children }: { label: string; children: ReactNode }) { return ( -
- - {label} - - - {children} - +
+ {label} + {children}
) } @@ -74,47 +66,40 @@ export function WorktreeDetailPanel({ return (
+ ) +} diff --git a/console/web/src/pages/Worktrees/lib/layout.test.ts b/worktree/ui/src/page/layout.test.ts similarity index 98% rename from console/web/src/pages/Worktrees/lib/layout.test.ts rename to worktree/ui/src/page/layout.test.ts index b173669be..9f01d992e 100644 --- a/console/web/src/pages/Worktrees/lib/layout.test.ts +++ b/worktree/ui/src/page/layout.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' -import type { WorktreeInfo } from '@/lib/worktrees' import { GRAPH, layoutWorktreeGraph, repoLabel } from './layout' +import type { WorktreeInfo } from './worktree-data' function wt( overrides: Partial & { worktree_id: string }, diff --git a/console/web/src/pages/Worktrees/lib/layout.ts b/worktree/ui/src/page/layout.ts similarity index 98% rename from console/web/src/pages/Worktrees/lib/layout.ts rename to worktree/ui/src/page/layout.ts index d9176ffc5..666a98eef 100644 --- a/console/web/src/pages/Worktrees/lib/layout.ts +++ b/worktree/ui/src/page/layout.ts @@ -1,4 +1,4 @@ -import type { WorktreeInfo } from '@/lib/worktrees' +import type { WorktreeInfo } from './worktree-data' /** * Pure layered layout for the worktree graph: one repo node column on the diff --git a/worktree/ui/src/page/useWorktreesLive.ts b/worktree/ui/src/page/useWorktreesLive.ts new file mode 100644 index 000000000..31da2f0ee --- /dev/null +++ b/worktree/ui/src/page/useWorktreesLive.ts @@ -0,0 +1,113 @@ +import type { Host } from '@iii-dev/console-ui' +import { useCallback, useEffect, useRef, useState } from 'react' +import { + listWorktrees, + WORKTREE_LIFECYCLE_TRIGGERS, + type WorktreeInfo, +} from './worktree-data' + +/** + * Live registry feed for the worktrees page: `worktree::list` with status, + * re-read on every one of the six lifecycle trigger types. Each binding is a + * tab-scoped Message-path trigger — `host.iii.registerTrigger` targeting an + * `iii::worktree-ui::::` handler (the `iii::` prefix keeps + * the per-event invocations span-suppressed and out of the trace feed), GC'd + * with the tab. While the bindings are unavailable (SDK hiccup, races around + * worker restart) a modest poll keeps the graph honest — skipped while the tab + * is hidden so a backgrounded console never hammers the engine. + */ + +export const WORKTREES_POLL_MS = 15_000 + +/** Per-tab handler prefix (host.iii namespaces it `::`). */ +const EVENTS_FN = 'iii::worktree-ui::events' + +export interface WorktreesLive { + worktrees: WorktreeInfo[] + loading: boolean + error: string | null + /** True while updates arrive through the live trigger bindings. */ + live: boolean + refresh: () => void +} + +export function useWorktreesLive(host: Host): WorktreesLive { + const [worktrees, setWorktrees] = useState([]) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + const [bound, setBound] = useState(false) + const [token, setToken] = useState(0) + + const refresh = useCallback(() => setToken((t) => t + 1), []) + const refreshRef = useRef(refresh) + refreshRef.current = refresh + + // Bind all six lifecycle trigger types; any event re-reads the registry. + useEffect(() => { + const offs: Array<() => void> = [] + let registered = 0 + for (const triggerType of WORKTREE_LIFECYCLE_TRIGGERS) { + const slug = triggerType.replace(/[^a-z0-9]+/g, '-') + const fnId = `${EVENTS_FN}::${slug}::${host.iii.browserId}` + try { + offs.push( + host.iii.on(fnId, () => { + refreshRef.current() + }), + ) + offs.push( + host.iii.registerTrigger({ + type: triggerType, + function_id: fnId, + config: {}, + }), + ) + registered += 1 + } catch { + // Worker absent or trigger type unregistered; drop the binding and + // let the poll below keep the graph fresh. + } + } + setBound(registered === WORKTREE_LIFECYCLE_TRIGGERS.length) + return () => { + setBound(false) + for (const off of offs) off() + } + }, [host]) + + // Reset loading on every fetch — the initial load and each token-driven + // refetch (poll / event / manual refresh) all go through here, so the + // indicator reflects an in-flight refresh consistently. + // biome-ignore lint/correctness/useExhaustiveDependencies: token is a re-run token (bumped by events, polling, and manual refresh), not read by the effect body + useEffect(() => { + setLoading(true) + let cancelled = false + void (async () => { + try { + const next = await listWorktrees(host) + if (cancelled) return + setWorktrees(next) + setError(null) + } catch (err) { + if (cancelled) return + setError(err instanceof Error ? err.message : String(err)) + } finally { + if (!cancelled) setLoading(false) + } + })() + return () => { + cancelled = true + } + }, [host, token]) + + useEffect(() => { + if (bound) return + const id = window.setInterval(() => { + if (document.hidden) return + refreshRef.current() + }, WORKTREES_POLL_MS) + return () => window.clearInterval(id) + }, [bound]) + + return { worktrees, loading, error, live: bound, refresh } +} diff --git a/worktree/ui/src/page/worktree-data.ts b/worktree/ui/src/page/worktree-data.ts new file mode 100644 index 000000000..eb66ec8ed --- /dev/null +++ b/worktree/ui/src/page/worktree-data.ts @@ -0,0 +1,173 @@ +/** + * Read-only RPC surface for the injected worktrees page: typed wrappers over + * the `worktree` worker's registry, plus the parsers and view helpers the + * graph and detail panel use. + * + * Ported from the console's `lib/worktrees.ts`: the function ids, the six + * lifecycle trigger types, the payload shapes, and the tolerant zod parsing + * are verbatim; only the transport changed — `listWorktrees` now takes the + * tab's `host` and routes through `host.iii.trigger(...)` instead of a + * console-internal iii client. Only the page's read surface travels here; + * the mutating wrappers (claim / release / validate) stay in the console's + * chat features, which keep their own copy. + */ + +import type { Host } from '@iii-dev/console-ui' +import { z } from 'zod' + +export const WORKTREE_LIST_FUNCTION_ID = 'worktree::list' + +export const WORKTREE_CREATED_TRIGGER = 'worktree::created' +export const WORKTREE_CLAIMED_TRIGGER = 'worktree::claimed' +export const WORKTREE_RELEASED_TRIGGER = 'worktree::released' +export const WORKTREE_REMOVED_TRIGGER = 'worktree::removed' +export const WORKTREE_LANDED_TRIGGER = 'worktree::landed' +export const WORKTREE_LAND_BLOCKED_TRIGGER = 'worktree::land-blocked' + +/** Every lifecycle trigger type the worker emits, in emission order. */ +export const WORKTREE_LIFECYCLE_TRIGGERS = [ + WORKTREE_CREATED_TRIGGER, + WORKTREE_CLAIMED_TRIGGER, + WORKTREE_RELEASED_TRIGGER, + WORKTREE_REMOVED_TRIGGER, + WORKTREE_LANDED_TRIGGER, + WORKTREE_LAND_BLOCKED_TRIGGER, +] as const + +export const WORKTREE_LIFECYCLES = [ + 'active', + 'claimed', + 'landing', + 'land-blocked', + 'orphaned', +] as const +export type WorktreeLifecycle = (typeof WORKTREE_LIFECYCLES)[number] + +const lifecycleSchema = z.enum(WORKTREE_LIFECYCLES) + +const worktreeStatusSchema = z.object({ + clean: z.boolean(), + ahead: z.number(), + behind: z.number(), + staged: z.number(), + unstaged: z.number(), + untracked: z.number(), + conflicted: z.number(), + unpushed: z.number(), + in_rebase: z.boolean(), + diffstat: z.string().optional(), + head_sha: z.string().optional(), + // v0.2 workers; optional so older workers keep parsing. + integrated: z.boolean().optional(), + integration_reason: z.string().nullable().optional(), +}) +export type WorktreeStatusInfo = z.infer + +/** + * Label for an integrated worktree: "merged upstream", with the worker's + * detection reason appended when it reports one. + */ +export function integrationLabel(status: WorktreeStatusInfo): string { + return status.integration_reason + ? `merged upstream (${status.integration_reason})` + : 'merged upstream' +} + +const worktreeInfoSchema = z.object({ + worktree_id: z.string(), + repo_path: z.string(), + repo_key: z.string().optional(), + path: z.string(), + branch: z.string(), + base_ref: z.string().optional(), + base_sha: z.string().optional(), + lifecycle: lifecycleSchema, + session_id: z.string().nullable().optional(), + // v0.2 workers; optional so older workers keep parsing. + dev_port: z.number().optional(), + created_at: z.number().optional(), + updated_at: z.number().optional(), + status: worktreeStatusSchema.nullable().optional(), +}) +export type WorktreeInfo = z.infer + +const listResultSchema = z.object({ + worktrees: z.array(z.unknown()).optional(), +}) + +export function parseWorktreeInfo(payload: unknown): WorktreeInfo | null { + const parsed = worktreeInfoSchema.safeParse(payload) + return parsed.success ? parsed.data : null +} + +/** `wt_1f2e3d4c` -> `1f2e3d4c` (the id already reads as a short hash). */ +export function shortWorktreeId(worktreeId: string): string { + return worktreeId.startsWith('wt_') ? worktreeId.slice(3) : worktreeId +} + +/** + * Badge/status tone per the console's status semantics: accent = live/running, + * warn = warning, alert = error; everything else stays ink. + */ +export function lifecycleTone( + lifecycle: WorktreeLifecycle, +): 'ink' | 'accent' | 'warn' | 'alert' { + switch (lifecycle) { + case 'landing': + return 'accent' + case 'land-blocked': + return 'alert' + case 'orphaned': + return 'warn' + default: + return 'ink' + } +} + +/** + * Static tone -> scoped class map. Injected CSS has no Tailwind, so every + * surface that tints by lifecycle tone shares this table of `wt-tone-*` + * classes (defined in styles.css) instead of a `text-${tone}` utility. + */ +export const lifecycleToneClass: Record< + ReturnType, + string +> = { + ink: 'wt-tone-ink', + accent: 'wt-tone-accent', + warn: 'wt-tone-warn', + alert: 'wt-tone-alert', +} + +export interface WorktreeIndicators { + dirty: boolean + ahead: number +} + +export function worktreeIndicators( + status: WorktreeStatusInfo | null | undefined, +): WorktreeIndicators { + if (!status) return { dirty: false, ahead: 0 } + return { dirty: !status.clean, ahead: status.ahead } +} + +/** Join truthy class names — the injected UI's stand-in for the console `cn`. */ +export function cn(...parts: Array): string { + return parts.filter(Boolean).join(' ') +} + +/** + * `worktree::list` with git status, parsed tolerantly: unknown fields are + * ignored and rows that fail the schema are dropped rather than blanking the + * whole graph. + */ +export async function listWorktrees(host: Host): Promise { + const res = await host.iii.trigger(WORKTREE_LIST_FUNCTION_ID, { + include_status: true, + }) + const parsed = listResultSchema.safeParse(res) + if (!parsed.success) return [] + return (parsed.data.worktrees ?? []) + .map(parseWorktreeInfo) + .filter((w): w is WorktreeInfo => w !== null) +} diff --git a/worktree/ui/styles.css b/worktree/ui/styles.css new file mode 100644 index 000000000..4c57ffb5d --- /dev/null +++ b/worktree/ui/styles.css @@ -0,0 +1,394 @@ +/* + * Styles for the worktree worker's injected console UI. Shipped as its own + * `console:style` asset (worktree/styles.css) — the console mounts it as a + * and link-swaps it on change. + * + * EVERY rule is scoped under [data-iii-ui="worktree"] — the wrapper the + * console mounts around every injected render. Unscoped rules would beat the + * console's layered CSS document-wide (injected sheets are unlayered). + * Keyframe names are global: keep the wt-ui- prefix. Colors come from the + * console's design tokens so light/dark theming is free. + */ + +[data-iii-ui="worktree"] .wt-page { + display: flex; + flex-direction: column; + height: 100%; + box-sizing: border-box; + font-family: var(--font-mono, ui-monospace, monospace); + color: var(--color-ink); +} +[data-iii-ui="worktree"] .wt-page *, +[data-iii-ui="worktree"] .wt-page *::before, +[data-iii-ui="worktree"] .wt-page *::after { + box-sizing: border-box; +} + +/* --- header ---------------------------------------------------------- */ +[data-iii-ui="worktree"] .wt-head { + flex-shrink: 0; + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 16px 24px; + border-bottom: 1px solid var(--color-rule); +} +[data-iii-ui="worktree"] .wt-title { + font-size: 16px; + font-weight: 600; + letter-spacing: -0.01em; + text-transform: lowercase; + color: var(--color-ink); +} +[data-iii-ui="worktree"] .wt-sub { + font-size: 12px; + color: var(--color-ink-faint); + margin-top: 2px; + text-transform: lowercase; +} +[data-iii-ui="worktree"] .wt-head-actions { + display: flex; + align-items: center; + gap: 12px; +} +[data-iii-ui="worktree"] .wt-liveness { + display: flex; + align-items: center; + gap: 6px; + font-size: 11px; + text-transform: lowercase; + color: var(--color-ink-faint); +} +[data-iii-ui="worktree"] .wt-refresh-icon { + color: var(--color-ink-faint); +} +[data-iii-ui="worktree"] .wt-refresh-icon.spin { + animation: wt-ui-spin 1s linear infinite; +} +@keyframes wt-ui-spin { + to { + transform: rotate(360deg); + } +} + +/* --- body split: canvas + detail ------------------------------------- */ +[data-iii-ui="worktree"] .wt-body { + flex: 1; + min-height: 0; + display: flex; +} +[data-iii-ui="worktree"] .wt-canvas { + flex: 1; + min-width: 0; + overflow: auto; + padding: 16px 24px; +} + +/* --- graph ----------------------------------------------------------- */ +[data-iii-ui="worktree"] .wt-graph { + position: relative; +} +[data-iii-ui="worktree"] .wt-graph-svg { + position: absolute; + inset: 0; +} +[data-iii-ui="worktree"] .wt-edge { + fill: none; + stroke: var(--color-rule); +} +[data-iii-ui="worktree"] .wt-edge.selected { + stroke: var(--color-accent); +} +[data-iii-ui="worktree"] .wt-edge-label { + fill: var(--color-ink-ghost); + font-family: var(--font-mono, ui-monospace, monospace); + font-size: 10px; + text-transform: lowercase; +} + +/* --- repo node ------------------------------------------------------- */ +[data-iii-ui="worktree"] .wt-repo { + position: absolute; + display: flex; + flex-direction: column; + justify-content: center; + gap: 2px; + padding: 0 12px; + border: 1px solid var(--color-rule); + background: var(--color-panel); +} +[data-iii-ui="worktree"] .wt-repo-name { + display: flex; + align-items: center; + gap: 6px; + font-size: 12px; + font-weight: 600; + text-transform: lowercase; + color: var(--color-ink); +} +[data-iii-ui="worktree"] .wt-repo-path { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 10px; + color: var(--color-ink-ghost); +} + +/* --- worktree node --------------------------------------------------- */ +[data-iii-ui="worktree"] .wt-node { + position: absolute; + display: flex; + flex-direction: column; + justify-content: center; + gap: 4px; + padding: 0 12px; + text-align: left; + border: 1px solid var(--color-rule); + background: var(--color-bg); + color: var(--color-ink); + font-family: inherit; + cursor: pointer; + transition: background 0.12s, border-color 0.12s; +} +[data-iii-ui="worktree"] .wt-node:hover { + background: var(--color-panel); +} +[data-iii-ui="worktree"] .wt-node.selected { + border-left: 2px solid var(--color-accent); + background: var(--color-panel); +} +[data-iii-ui="worktree"] .wt-node-branch { + display: flex; + min-width: 0; + align-items: center; + gap: 6px; + font-size: 12px; +} +[data-iii-ui="worktree"] .wt-node-id { + flex-shrink: 0; + font-size: 10px; + color: var(--color-ink-ghost); + font-variant-numeric: tabular-nums; +} +[data-iii-ui="worktree"] .wt-dirty { + flex-shrink: 0; + color: var(--color-warn); +} +[data-iii-ui="worktree"] .wt-ahead { + flex-shrink: 0; + font-size: 10px; + color: var(--color-ink-faint); + font-variant-numeric: tabular-nums; +} +[data-iii-ui="worktree"] .wt-node-meta { + display: flex; + min-width: 0; + align-items: center; + gap: 8px; + font-size: 10px; + text-transform: lowercase; +} +[data-iii-ui="worktree"] .wt-lifecycle { + display: flex; + align-items: center; + gap: 4px; +} +[data-iii-ui="worktree"] .wt-session { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + padding: 0 4px; + border: 1px solid var(--color-rule-2); + color: var(--color-ink-ghost); +} + +/* --- detail panel ---------------------------------------------------- */ +[data-iii-ui="worktree"] .wt-detail { + display: flex; + flex-direction: column; + width: 300px; + flex-shrink: 0; + overflow-y: auto; + border-left: 1px solid var(--color-rule); + background: var(--color-bg); +} +[data-iii-ui="worktree"] .wt-detail-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + padding: 10px 14px; + border-bottom: 1px solid var(--color-rule); + background: var(--color-panel); +} +[data-iii-ui="worktree"] .wt-detail-title { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 12px; + font-weight: 600; + text-transform: lowercase; + color: var(--color-ink); +} +[data-iii-ui="worktree"] .wt-detail-close { + flex-shrink: 0; + appearance: none; + background: transparent; + border: 0; + padding: 0; + cursor: pointer; + color: var(--color-ink-ghost); + transition: color 0.12s; +} +[data-iii-ui="worktree"] .wt-detail-close:hover { + color: var(--color-ink); +} +[data-iii-ui="worktree"] .wt-detail-body > * { + border-top: 1px solid var(--color-rule-2); +} +[data-iii-ui="worktree"] .wt-detail-body > *:first-child { + border-top: 0; +} + +[data-iii-ui="worktree"] .wt-row { + display: flex; + flex-direction: column; + gap: 2px; + padding: 8px 14px; +} +[data-iii-ui="worktree"] .wt-row-label { + font-size: 10px; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--color-ink-faint); +} +[data-iii-ui="worktree"] .wt-row-value { + min-width: 0; + word-break: break-all; + font-size: 12px; + color: var(--color-ink); +} +[data-iii-ui="worktree"] .wt-inline-tone { + margin-left: 8px; + display: inline-flex; + align-items: center; + gap: 4px; + font-size: 11px; + text-transform: lowercase; +} +[data-iii-ui="worktree"] .wt-path { + display: flex; + align-items: flex-start; + gap: 6px; +} +[data-iii-ui="worktree"] .wt-path-val { + min-width: 0; + flex: 1; + word-break: break-all; +} +[data-iii-ui="worktree"] .wt-copy { + margin-top: 2px; + flex-shrink: 0; + appearance: none; + background: transparent; + border: 0; + padding: 0; + cursor: pointer; + color: var(--color-ink-ghost); + transition: color 0.12s; +} +[data-iii-ui="worktree"] .wt-copy:hover { + color: var(--color-ink); +} +[data-iii-ui="worktree"] .wt-sha { + margin-left: 8px; + color: var(--color-ink-ghost); + font-variant-numeric: tabular-nums; +} +[data-iii-ui="worktree"] .wt-tabular { + font-variant-numeric: tabular-nums; +} +[data-iii-ui="worktree"] .wt-note { + margin-left: 8px; + font-size: 11px; + text-transform: lowercase; + color: var(--color-ink-ghost); +} +[data-iii-ui="worktree"] .wt-integrated { + display: inline-flex; + align-items: center; + gap: 6px; + color: var(--color-ink); +} + +[data-iii-ui="worktree"] .wt-status { + display: flex; + flex-direction: column; + gap: 4px; + padding: 8px 14px; +} +[data-iii-ui="worktree"] .wt-status-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + column-gap: 12px; + row-gap: 2px; + margin: 0; + font-size: 11px; + color: var(--color-ink); +} +[data-iii-ui="worktree"] .wt-status-dt { + color: var(--color-ink-faint); + text-transform: lowercase; +} +[data-iii-ui="worktree"] .wt-status-dd { + margin: 0; + font-variant-numeric: tabular-nums; +} +[data-iii-ui="worktree"] .wt-diffstat { + margin-top: 4px; + font-size: 11px; + color: var(--color-ink-faint); +} +[data-iii-ui="worktree"] .wt-head-sha { + font-size: 11px; + color: var(--color-ink-ghost); + font-variant-numeric: tabular-nums; +} + +/* --- shared helpers -------------------------------------------------- */ +[data-iii-ui="worktree"] .wt-truncate { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +[data-iii-ui="worktree"] .wt-ink { + color: var(--color-ink); +} +[data-iii-ui="worktree"] .wt-icon-faint { + flex-shrink: 0; + color: var(--color-ink-faint); +} +[data-iii-ui="worktree"] .wt-icon-ghost { + flex-shrink: 0; + color: var(--color-ink-ghost); +} +[data-iii-ui="worktree"] .wt-accent { + color: var(--color-accent); +} +[data-iii-ui="worktree"] .wt-tone-ink { + color: var(--color-ink-faint); +} +[data-iii-ui="worktree"] .wt-tone-accent { + color: var(--color-accent); +} +[data-iii-ui="worktree"] .wt-tone-warn { + color: var(--color-warn); +} +[data-iii-ui="worktree"] .wt-tone-alert { + color: var(--color-alert); +} diff --git a/worktree/ui/tsconfig.json b/worktree/ui/tsconfig.json new file mode 100644 index 000000000..e5ac60540 --- /dev/null +++ b/worktree/ui/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "module": "ESNext", + "moduleResolution": "bundler", + "jsx": "react-jsx", + "strict": true, + "skipLibCheck": true, + "noEmit": true, + "types": [] + }, + "include": ["page.tsx", "src"] +} From 31cc377acc3f2b1da9e2d707def932cae69f85df Mon Sep 17 00:00:00 2001 From: Rohit Ghumare Date: Tue, 28 Jul 2026 15:01:45 +0100 Subject: [PATCH 2/2] fix(console): redirect legacy migrated hashes to injected pages --- console/web/src/hooks/use-hash-route.test.ts | 19 ++++++++++ console/web/src/hooks/use-hash-route.ts | 40 +++++++++++++++++--- 2 files changed, 53 insertions(+), 6 deletions(-) diff --git a/console/web/src/hooks/use-hash-route.test.ts b/console/web/src/hooks/use-hash-route.test.ts index 465cb3487..133fd15cd 100644 --- a/console/web/src/hooks/use-hash-route.test.ts +++ b/console/web/src/hooks/use-hash-route.test.ts @@ -1,6 +1,8 @@ import { describe, expect, it } from 'vitest' import { + extPageFromHash, hashForWorkersConfiguration, + normalizeExtHash, normalizeWorkersConfigurationHash, workersConfigurationRouteFromHash, } from './use-hash-route' @@ -58,3 +60,20 @@ describe('workers configuration hash helpers', () => { }) }) }) + +describe('migrated-page hash redirects', () => { + it('rewrites legacy first-party hashes to their injected #/ext/ route', () => { + expect(normalizeExtHash('#/worktrees')).toBe('#/ext/worktree') + expect(normalizeExtHash('#/memory')).toBe('#/ext/memory') + }) + + it('resolves the injected page id from a legacy hash', () => { + expect(extPageFromHash(normalizeExtHash('#/worktrees'))).toBe('worktree') + expect(extPageFromHash(normalizeExtHash('#/memory'))).toBe('memory') + }) + + it('passes non-migrated hashes through unchanged', () => { + expect(normalizeExtHash('#/traces')).toBe('#/traces') + expect(normalizeExtHash('#/ext/database')).toBe('#/ext/database') + }) +}) diff --git a/console/web/src/hooks/use-hash-route.ts b/console/web/src/hooks/use-hash-route.ts index 847751520..d11c25d18 100644 --- a/console/web/src/hooks/use-hash-route.ts +++ b/console/web/src/hooks/use-hash-route.ts @@ -3,10 +3,12 @@ import { useCallback, useEffect, useRef, useState } from 'react' // `chat` is no longer a routed view; it's always-rendered as the side dock // in App.tsx. Hash routes only pick which view fills the right pane. The // component spec sheet + streaming playground moved to Storybook, so the -// routed views are `traces`, `workers`, `browser`, and `configuration`. -// `ext` is the injectable-UI prefix: worker-contributed pages route at -// `#/ext/` — deliberately outside the first-party names so an +// first-party routed views are `traces`, `workers`, `browser`, `github`, and +// `configuration`. `ext` is the injectable-UI prefix: worker-contributed pages +// route at `#/ext/` — deliberately outside the first-party names so an // injected page can never collide with or shadow `#/traces`, `#/workers`, …. +// Pages that migrated to injected UI (worktrees, memory) keep their old +// first-party hash working via a redirect to `#/ext/` — see MIGRATED_ROUTES. export type View = | 'configuration' | 'traces' @@ -80,7 +82,9 @@ export function normalizeWorkersConfigurationHash(hash: string): string | null { return hashForWorkersConfiguration(legacy.configurationId, legacy.fieldPath) } -function routeFromHash(hash: string): View | null { +function routeFromHash(rawHash: string): View | null { + // Migrated pages (worktrees, memory) resolve through their `#/ext/` form. + const hash = normalizeExtHash(rawHash) if (hash === '' || hash === '#' || hash === '#/' || hash === '#/traces') { return 'traces' } @@ -149,6 +153,11 @@ export function useHashRoute(): [View, (next: View) => void] { viewRef.current = view useEffect(() => { + // Rewrite a legacy migrated hash (`#/worktrees`, `#/memory`) to its + // `#/ext/` form so the URL bar matches the resolved page. The view + // state already used the normalized hash, so this is cosmetic. + const normalized = normalizeExtHash(window.location.hash) + if (normalized !== window.location.hash) replaceHash(normalized) const handle = () => { const next = routeFromHash(window.location.hash) if (next !== null && next !== viewRef.current) setView(next) @@ -194,6 +203,25 @@ export function hashForExtPage(pageId: string): string { return `${EXT_HASH_PREFIX}${encodeURIComponent(pageId)}` } +/** + * First-party hashes whose page migrated to injected UI. The old bookmark + * still works: it resolves to the worker's `#/ext/` route instead of + * falling back to `traces`. Add an entry when a page moves to injected UI. + */ +const MIGRATED_ROUTES: Record = { + '#/worktrees': 'worktree', + '#/memory': 'memory', +} + +/** + * Rewrite a migrated first-party hash to its `#/ext/` form so old deep + * links keep working; any other hash passes through unchanged. + */ +export function normalizeExtHash(hash: string): string { + const pageId = MIGRATED_ROUTES[hash] + return pageId ? hashForExtPage(pageId) : hash +} + /** * Selected extension page as a hash sub-route, so injected pages deep-link * (`#/ext/`) and survive reloads. @@ -201,14 +229,14 @@ export function hashForExtPage(pageId: string): string { export function useExtPageRoute(): string | null { const [selected, setSelected] = useState(() => { if (typeof window === 'undefined') return null - return extPageFromHash(window.location.hash) + return extPageFromHash(normalizeExtHash(window.location.hash)) }) const selectedRef = useRef(selected) selectedRef.current = selected useEffect(() => { const sync = () => { - const next = extPageFromHash(window.location.hash) + const next = extPageFromHash(normalizeExtHash(window.location.hash)) if (next !== selectedRef.current) setSelected(next) } sync()