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
19 changes: 19 additions & 0 deletions console/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -315,6 +315,25 @@ interface ConfigFormProps {
The form is render-level only: dirty tracking, validation, save/reset stay
host-owned. You draw the fields and call `onChange`.

### `host.chat.registerSessionChip({ id, render })`

A small per-session status chip in the chat header's right cluster,
rendered for every open session. Your component receives:

```ts
interface SessionChipProps {
sessionId: string
modelId?: string // resolved model id, when known
contextWindow?: number // model context window (tokens), from the catalog
}
```

Duplicate ids: last registration wins. The id `context` is special: while a
`context` chip is registered, the console hides its built-in estimate-based
context meter — a worker with real per-turn numbers owns the surface. Chips
fetch their own data over `host.iii`; the host passes identity only.
Feature-detect on older consoles: `host.chat?.registerSessionChip`.

### The rest of `host`

| Surface | What it is |
Expand Down
35 changes: 31 additions & 4 deletions console/web/src/components/chat/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ import {
summaryLabel,
} from '@/lib/pdf-attachments'
import { newMessageId } from '@/lib/session-id'
import { useExtSessionChips } from '@/lib/ui-slots'
import { cn } from '@/lib/utils'
import { fetchDefaultWorkingDir, validateWorkspaceDir } from '@/lib/working-dir'
import {
Expand Down Expand Up @@ -582,6 +583,29 @@ export function ChatView({
return match?.contextWindow
}, [modelOptions, effectiveModel])

/* Injected session chips (the `chat` extension slot), rendered in the
* header's right cluster where the built-in context meter sits. A chip
* with id `context` supersedes the estimate-based ContextUsage meter —
* workers with real per-turn numbers own the surface. */
const extSessionChips = useExtSessionChips()
const sessionChips = useMemo(() => {
if (extSessionChips.length === 0) return null
return extSessionChips.map((chip) => {
const Chip = chip.render
return (
<Chip
key={chip.id}
sessionId={conversation.id}
modelId={effectiveModel ?? undefined}
contextWindow={contextWindow}
/>
)
})
}, [extSessionChips, conversation.id, effectiveModel, contextWindow])
const hasInjectedContextChip = extSessionChips.some(
(chip) => chip.id === 'context',
)

/* Shared live region: SR announcements for auto-accept, stop-reason
* notices, and compaction markers route through this hook. Sighted
* users see the same messages in the transcript; visually-impaired
Expand Down Expand Up @@ -1657,10 +1681,13 @@ export function ChatView({
)}
</div>
<div className="flex items-center gap-3 font-mono text-[11px] uppercase tracking-[0.06em] flex-shrink-0">
<ContextUsage
messages={conversation.messages}
contextWindow={contextWindow}
/>
{sessionChips}
{hasInjectedContextChip ? null : (
<ContextUsage
messages={conversation.messages}
contextWindow={contextWindow}
/>
)}
<ExportSessionButton
conversation={conversation}
onExported={(filename) =>
Expand Down
19 changes: 19 additions & 0 deletions console/web/src/lib/ui-loader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,13 @@ import {
registerExtConfigForm,
registerExtPage,
registerExtRenderer,
registerExtSessionChip,
} from '@/lib/ui-slots'
import type {
ConfigFormProps,
ConsoleApi,
Host,
SessionChipProps,
SetupFn,
UiAssetKind,
UiAssetRef,
Expand Down Expand Up @@ -140,6 +142,23 @@ function makeHost(
)
},
},
chat: {
registerSessionChip(chip) {
const Chip = chip.render
return track(
registerExtSessionChip({
...chip,
scope,
path,
render: (props: SessionChipProps) => (
<ScopedExtension scope={scope} path={path}>
<Chip {...props} />
</ScopedExtension>
),
}),
)
},
},
}
}

Expand Down
42 changes: 42 additions & 0 deletions console/web/src/lib/ui-slots.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
/**
* The session-chip slot's registry semantics — the piece worker chips
* depend on without being able to see it: last registration wins per id,
* unregistering restores what it shadowed, and removal is idempotent.
*/

import { describe, expect, it } from 'vitest'
import type { RegisteredSessionChip } from './ui-slots'
import { getExtSessionChips, registerExtSessionChip } from './ui-slots'

function chip(id: string, path: string): RegisteredSessionChip {
return { id, path, scope: path.split('/')[0], render: () => null }
}

describe('session chip slot', () => {
it('registers in order and dedupes by id, last registration winning', () => {
const offA = registerExtSessionChip(chip('context', 'harness/page.js'))
const offB = registerExtSessionChip(chip('cost', 'llm-budget/page.js'))
const offC = registerExtSessionChip(chip('context', 'other/page.js'))

const chips = getExtSessionChips()
expect(chips.map((c) => c.id)).toEqual(['context', 'cost'])
expect(chips.find((c) => c.id === 'context')?.path).toBe('other/page.js')

offA()
offB()
offC()
expect(getExtSessionChips()).toEqual([])
})

it('restores the shadowed chip when the override unregisters', () => {
const offA = registerExtSessionChip(chip('context', 'harness/page.js'))
const offB = registerExtSessionChip(chip('context', 'other/page.js'))

offB()
expect(getExtSessionChips().map((c) => c.path)).toEqual(['harness/page.js'])

offA()
offA()
expect(getExtSessionChips()).toEqual([])
})
})
51 changes: 50 additions & 1 deletion console/web/src/lib/ui-slots.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,12 @@
* hooks.
*/

import { useSyncExternalStore } from 'react'
import { useMemo, useSyncExternalStore } from 'react'
import type {
ConfigFormProps,
FunctionTriggerRenderer,
PageRegistration,
SessionChipRegistration,
} from '@/types/injectable-ui'

export interface RegisteredPage extends PageRegistration {
Expand All @@ -35,6 +36,11 @@ export interface RegisteredConfigForm {
path: string
}

export interface RegisteredSessionChip extends SessionChipRegistration {
scope: string
path: string
}

interface Store<T> {
subscribe(listener: () => void): () => void
get(): readonly T[]
Expand Down Expand Up @@ -73,6 +79,7 @@ function createStore<T>(): Store<T> {
const pagesStore = createStore<RegisteredPage>()
const renderersStore = createStore<RegisteredRenderer>()
const configFormsStore = createStore<RegisteredConfigForm>()
const sessionChipsStore = createStore<RegisteredSessionChip>()

/**
* Register an extension page. Duplicate `id`: last registration wins in
Expand Down Expand Up @@ -108,6 +115,20 @@ export function registerExtConfigForm(entry: RegisteredConfigForm): () => void {
return configFormsStore.add(entry)
}

/** Duplicate chip id: last registration wins in `useExtSessionChips`. */
export function registerExtSessionChip(
entry: RegisteredSessionChip,
): () => void {
const duplicate = sessionChipsStore.get().find((c) => c.id === entry.id)
if (duplicate) {
console.warn(
`[iii-ui] duplicate session chip id '${entry.id}' — ` +
`'${entry.path}' overrides '${duplicate.path}'`,
)
}
return sessionChipsStore.add(entry)
}

export function getExtPages(): readonly RegisteredPage[] {
return pagesStore.get()
}
Expand Down Expand Up @@ -135,6 +156,34 @@ export function useExtRenderers(): readonly RegisteredRenderer[] {
)
}

function dedupeSessionChips(
chips: readonly RegisteredSessionChip[],
): readonly RegisteredSessionChip[] {
const byId = new Map<string, RegisteredSessionChip>()
for (const chip of chips) byId.set(chip.id, chip)
return [...byId.values()]
}

/** Session chips deduplicated by id — last registration wins. */
export function getExtSessionChips(): readonly RegisteredSessionChip[] {
return dedupeSessionChips(sessionChipsStore.get())
}

/**
* Session chips in registration order, deduplicated by id (last
* registration wins, matching the pages slot). Memoized on the store
* snapshot so consumers get a stable array between registrations —
* ChatView renders per streamed token, and its chip memo must hold.
*/
export function useExtSessionChips(): readonly RegisteredSessionChip[] {
const chips = useSyncExternalStore(
sessionChipsStore.subscribe,
sessionChipsStore.get,
() => EMPTY,
)
return useMemo(() => dedupeSessionChips(chips), [chips])
}

/** The injected form override for one configuration id (last wins). */
export function useExtConfigForm(
configurationId: string,
Expand Down
74 changes: 74 additions & 0 deletions console/web/src/stories/playground/SessionChips.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import type { Meta, StoryObj } from '@storybook/react-vite'
import { useEffect, useState } from 'react'
import { registerExtSessionChip } from '@/lib/ui-slots'
import type { SessionChipProps } from '@/types/injectable-ui'
import { PlaygroundHarness } from './harness'
import { findScenario } from './scenarios'

/**
* The `chat` extension slot: what an injected session chip looks like in
* the chat header's right cluster. The demo chip carries id `context`, so
* it also demonstrates the supersede rule — the built-in estimate-based
* ContextUsage meter hides while the chip is registered.
*/

function DemoContextChip({ contextWindow }: SessionChipProps) {
const window = contextWindow ?? 200_000
const used = Math.round(window * 0.62)
const pct = Math.round((used / window) * 100)
return (
<span
className="flex items-center gap-1.5 font-mono text-[11px] uppercase tracking-[0.06em] text-ink-faint"
title={`${used.toLocaleString()} / ${window.toLocaleString()} tokens (${pct}%)`}
>
<span>ctx</span>
<span className="relative h-[6px] w-14 overflow-hidden border border-rule bg-rule-2">
<span
className="absolute inset-y-0 left-0 bg-accent"
style={{ width: `${pct}%` }}
/>
</span>
<span className="tabular-nums text-ink">{pct}%</span>
</span>
)
}

function WithDemoChip({ children }: { children: React.ReactElement }) {
const [registered, setRegistered] = useState(false)
useEffect(() => {
const off = registerExtSessionChip({
id: 'context',
scope: 'storybook',
path: 'storybook/demo.js',
render: DemoContextChip,
})
setRegistered(true)
return off
}, [])
return registered ? children : null
}

const meta = {
title: 'Playground/SessionChips',
parameters: { layout: 'fullscreen' },
} satisfies Meta

export default meta
type Story = StoryObj

export const InjectedContextChip: Story = {
name: 'injected context chip in the header',
render: () => {
const scenario = findScenario('multi-function-agent')
if (!scenario) throw new Error('missing playground scenario')
return (
<WithDemoChip>
<PlaygroundHarness
backend={scenario.backend}
label={scenario.label}
preferredMode={scenario.preferredMode}
/>
</WithDemoChip>
)
},
}
28 changes: 28 additions & 0 deletions console/web/src/types/injectable-ui.ts
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,31 @@ export interface ConfigFormProps {
focusField?: readonly string[]
}

/**
* Props a session chip receives from the chat host. Chips fetch their own
* data through `host.iii`; the host only identifies the session and what
* it already knows about the resolved model.
*/
export interface SessionChipProps {
sessionId: string
/** Resolved model id for the session, when known. */
modelId?: string
/** Context window (tokens) of the resolved model, from the catalog. */
contextWindow?: number
}

/**
* A per-session status chip rendered in the chat header's right cluster
* (the `chat` slot). Duplicate `id`: last registration wins — and some
* ids also supersede a built-in affordance (`context` replaces the
* host's estimate-based context meter).
*/
export interface SessionChipRegistration {
/** kebab-case, e.g. `context`; convention `<worker>-<name>` otherwise. */
id: string
render: React.ComponentType<SessionChipProps>
}

/**
* What `setup(host)` receives. Every registrar returns an unregister fn AND
* is auto-tracked: the loader runs all of them on dispose.
Expand All @@ -143,6 +168,9 @@ export interface Host {
component: React.ComponentType<ConfigFormProps>,
): () => void
}
chat: {
registerSessionChip(chip: SessionChipRegistration): () => void
}
}

/** The ONLY required export of a script asset. */
Expand Down
Loading
Loading