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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 5 additions & 9 deletions console/web/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,6 @@ import { ExtPage } from '@/pages/Ext'
import { Github } from '@/pages/Github'
import { TracesV2 } from '@/pages/TracesV2'
import { Workers } from '@/pages/Workers'
import { Worktrees } from '@/pages/Worktrees'

export function App() {
const [theme, setTheme] = useTheme()
Expand Down Expand Up @@ -109,8 +108,6 @@ export function App() {
<Configuration theme={theme} onThemeChange={setTheme} />
) : view === 'workers' ? (
<Workers />
) : view === 'worktrees' ? (
<Worktrees />
) : view === 'browser' ? (
<Browser />
) : view === 'github' ? (
Expand Down Expand Up @@ -153,19 +150,18 @@ 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.
// direct #/browser hit still lands on that page's install notice.
// `memoryAvailable` stays on the context for the chat composer's bank
// selector; the memory page is now injected UI (nav entry via `extPages`),
// so it is no longer a first-party nav option here.
const { worktreeAvailable, browserAvailable, githubAvailable } =
// selector; the memory page is now injected UI, so it is no longer a
// first-party nav option here.
const { browserAvailable, 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, githubAvailable),
...buildViewOptions(browserAvailable, githubAvailable),
...extPages.map((page) => ({
value: extNavValue(page),
label: page.title,
Expand Down
19 changes: 19 additions & 0 deletions console/web/src/hooks/use-hash-route.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { describe, expect, it } from 'vitest'
import {
extPageFromHash,
hashForWorkersConfiguration,
normalizeExtHash,
normalizeWorkersConfigurationHash,
workersConfigurationRouteFromHash,
} from './use-hash-route'
Expand Down Expand Up @@ -58,3 +60,20 @@ describe('workers configuration hash helpers', () => {
})
})
})

describe('migrated-page hash redirects', () => {
it('rewrites legacy first-party hashes to their injected #/ext/<id> 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')
})
})
47 changes: 34 additions & 13 deletions console/web/src/hooks/use-hash-route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,16 +3,16 @@ 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`.
// `ext` is the injectable-UI prefix: worker-contributed pages route at
// `#/ext/<page-id>` — 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/<page-id>` — 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/<id>` — see MIGRATED_ROUTES.
export type View =
| 'configuration'
| 'traces'
| 'workers'
| 'worktrees'
| 'browser'
| 'github'
| 'ext'
Expand Down Expand Up @@ -82,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/<id>` form.
const hash = normalizeExtHash(rawHash)
if (hash === '' || hash === '#' || hash === '#/' || hash === '#/traces') {
return 'traces'
}
Expand All @@ -95,9 +97,6 @@ function routeFromHash(hash: string): View | null {
if (hash === '#/workers' || hash.startsWith('#/workers/')) {
return 'workers'
}
if (hash === '#/worktrees') {
return 'worktrees'
}
if (hash === '#/github') {
return 'github'
}
Expand Down Expand Up @@ -131,8 +130,6 @@ function hashFor(view: View): string {
return '#/traces'
case 'workers':
return '#/workers'
case 'worktrees':
return '#/worktrees'
case 'browser':
return '#/browser'
case 'github':
Expand All @@ -156,6 +153,11 @@ export function useHashRoute(): [View, (next: View) => void] {
viewRef.current = view

useEffect(() => {
// Rewrite a legacy migrated hash (`#/worktrees`, `#/memory`) to its
// `#/ext/<id>` 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)
Expand Down Expand Up @@ -201,21 +203,40 @@ 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/<id>` route instead of
* falling back to `traces`. Add an entry when a page moves to injected UI.
*/
const MIGRATED_ROUTES: Record<string, string> = {
'#/worktrees': 'worktree',
'#/memory': 'memory',
}

/**
* Rewrite a migrated first-party hash to its `#/ext/<id>` 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/<page-id>`) and survive reloads.
*/
export function useExtPageRoute(): string | null {
const [selected, setSelected] = useState<string | null>(() => {
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()
Expand Down
91 changes: 1 addition & 90 deletions console/web/src/hooks/use-worktree-events.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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<ReturnType<typeof getIiiClient>>
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 }
}
17 changes: 4 additions & 13 deletions console/web/src/lib/nav-options.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,41 +3,32 @@ import { buildViewOptions } from './nav-options'

describe('buildViewOptions', () => {
it('hides the optional-worker entries while their workers are absent', () => {
expect(buildViewOptions(false, false, false).map((o) => o.value)).toEqual([
expect(buildViewOptions(false, false).map((o) => o.value)).toEqual([
'traces',
'workers',
])
})

it('appends the worktrees entry when the worker is present', () => {
expect(buildViewOptions(true, false, false).map((o) => o.value)).toEqual([
'traces',
'workers',
'worktrees',
])
})

it('appends the browser entry when the worker is present', () => {
expect(buildViewOptions(false, true, false).map((o) => o.value)).toEqual([
expect(buildViewOptions(true, false).map((o) => o.value)).toEqual([
'traces',
'workers',
'browser',
])
})

it('appends the github entry when the worker is present', () => {
expect(buildViewOptions(false, false, true).map((o) => o.value)).toEqual([
expect(buildViewOptions(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).map((o) => o.value)).toEqual([
expect(buildViewOptions(true, true).map((o) => o.value)).toEqual([
'traces',
'workers',
'worktrees',
'browser',
'github',
])
Expand Down
4 changes: 0 additions & 4 deletions console/web/src/lib/nav-options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,17 +7,13 @@ import type { View } from '@/hooks/use-hash-route'
* notice).
*/
export function buildViewOptions(
worktreeAvailable: boolean,
browserAvailable: boolean,
githubAvailable: boolean,
): { value: View; label: string }[] {
const options: { value: View; label: string }[] = [
{ value: 'traces', label: 'traces' },
{ value: 'workers', label: 'workers' },
]
if (worktreeAvailable) {
options.push({ value: 'worktrees', label: 'worktrees' })
}
if (browserAvailable) {
options.push({ value: 'browser', label: 'browser' })
}
Expand Down

This file was deleted.

Loading
Loading