From bcb47d59f565fca5c7462553bed6f6a079ac52b6 Mon Sep 17 00:00:00 2001 From: Rohit Ghumare Date: Tue, 28 Jul 2026 10:21:19 +0100 Subject: [PATCH 1/6] feat(github,console): ship the github page as injectable console UI Move the github page from console-native to the github worker's ui/, following the injectable-console-ui SOP (state/database precedent). The page now ships into every running console at runtime as #/ext/github and appears in the nav whenever the github worker is connected, via the same ext-pages mechanism the database and iii-directory workers already use. github/ui/: - page.tsx setup(host) registers the github page (owner/name input plus the pull-requests, issues, runs, releases, and search panels). Panels read github::* over host.iii.trigger and unwrap the worker's { value } envelope; function ids and payloads are kept verbatim from the console wrappers. Components are named imports from @iii-dev/console-ui; icons are hand-drawn inline SVG (no lucide-react); the segmented control is a local ModeToggle. - styles.css scopes every rule under [data-iii-ui="github"] and colors from the console design tokens. - build.mjs / package.json / tsconfig.json esbuild scaffold with the five shared specifiers external (react family + @iii-dev/console-ui). github worker (Rust): - src/ui.rs registers the two assets through the iii-console-ui crate (ConsoleUi::new("github")), embedded via include_str! and called from main.rs after the github::* functions register. - build.rs builds ui/ (respects SKIP_UI_BUILD=1); iii-console-ui path dep added to Cargo.toml; the client is Arc-wrapped for the UI watcher. console cleanup: - delete src/pages/Github/, lib/github.ts, and use-github-status (the presence hook only served the page). - remove the github nav entry, hash route, and githubAvailable plumbing from App.tsx, nav-options, use-hash-route, and conversations-context. - chat function-call renderers are untouched (none exist on this branch). --- console/web/src/App.tsx | 18 +- .../web/src/hooks/use-github-status.test.ts | 19 -- console/web/src/hooks/use-github-status.ts | 43 --- console/web/src/hooks/use-hash-route.ts | 6 - console/web/src/lib/conversations-context.tsx | 11 - console/web/src/lib/github.test.ts | 194 ------------ console/web/src/lib/nav-options.test.ts | 47 +-- console/web/src/lib/nav-options.ts | 4 - console/web/src/pages/Github/index.tsx | 148 --------- github/Cargo.lock | 13 + github/Cargo.toml | 1 + github/build.rs | 172 +++++++++++ github/src/lib.rs | 1 + github/src/main.rs | 11 +- github/src/ui.rs | 77 +++++ github/ui/build.mjs | 37 +++ github/ui/package.json | 19 ++ github/ui/page.tsx | 28 ++ .../ui/src/page}/IssuesPanel.tsx | 46 +-- github/ui/src/page/ModeToggle.tsx | 50 ++++ .../ui/src/page}/PanelShell.tsx | 21 +- .../ui/src/page}/PrsPanel.tsx | 86 +++--- .../ui/src/page}/ReleasesPanel.tsx | 32 +- .../ui/src/page}/RunsPanel.tsx | 39 ++- .../ui/src/page}/SearchPanel.tsx | 71 +++-- .../ui/src/page/github-data.ts | 75 +++-- github/ui/src/page/icons.tsx | 133 +++++++++ github/ui/src/page/index.tsx | 113 +++++++ .../ui/src/page/useGithubRead.ts | 13 +- github/ui/styles.css | 280 ++++++++++++++++++ github/ui/tsconfig.json | 14 + pnpm-lock.yaml | 25 +- pnpm-workspace.yaml | 1 + 33 files changed, 1199 insertions(+), 649 deletions(-) delete mode 100644 console/web/src/hooks/use-github-status.test.ts delete mode 100644 console/web/src/hooks/use-github-status.ts delete mode 100644 console/web/src/lib/github.test.ts delete mode 100644 console/web/src/pages/Github/index.tsx create mode 100644 github/build.rs create mode 100644 github/src/ui.rs create mode 100644 github/ui/build.mjs create mode 100644 github/ui/package.json create mode 100644 github/ui/page.tsx rename {console/web/src/pages/Github/components => github/ui/src/page}/IssuesPanel.tsx (61%) create mode 100644 github/ui/src/page/ModeToggle.tsx rename {console/web/src/pages/Github/components => github/ui/src/page}/PanelShell.tsx (55%) rename {console/web/src/pages/Github/components => github/ui/src/page}/PrsPanel.tsx (64%) rename {console/web/src/pages/Github/components => github/ui/src/page}/ReleasesPanel.tsx (53%) rename {console/web/src/pages/Github/components => github/ui/src/page}/RunsPanel.tsx (60%) rename {console/web/src/pages/Github/components => github/ui/src/page}/SearchPanel.tsx (64%) rename console/web/src/lib/github.ts => github/ui/src/page/github-data.ts (82%) create mode 100644 github/ui/src/page/icons.tsx create mode 100644 github/ui/src/page/index.tsx rename console/web/src/pages/Github/hooks/useGithubQuery.ts => github/ui/src/page/useGithubRead.ts (82%) create mode 100644 github/ui/styles.css create mode 100644 github/ui/tsconfig.json diff --git a/console/web/src/App.tsx b/console/web/src/App.tsx index 1d636583e..9ec58b9ff 100644 --- a/console/web/src/App.tsx +++ b/console/web/src/App.tsx @@ -29,7 +29,6 @@ import { cn } from '@/lib/utils' import { Browser } from '@/pages/Browser' import { Configuration } from '@/pages/Configuration' import { ExtPage } from '@/pages/Ext' -import { Github } from '@/pages/Github' import { Memory } from '@/pages/Memory' import { TracesV2 } from '@/pages/TracesV2' import { Workers } from '@/pages/Workers' @@ -116,8 +115,6 @@ export function App() { ) : view === 'memory' ? ( - ) : view === 'github' ? ( - ) : view === 'ext' ? ( ) : ( @@ -158,23 +155,14 @@ function Header({ // 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() + const { worktreeAvailable, browserAvailable, memoryAvailable } = + 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(worktreeAvailable, browserAvailable, memoryAvailable), ...extPages.map((page) => ({ value: extNavValue(page), label: page.title, diff --git a/console/web/src/hooks/use-github-status.test.ts b/console/web/src/hooks/use-github-status.test.ts deleted file mode 100644 index 4c285cbe3..000000000 --- a/console/web/src/hooks/use-github-status.test.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { - GITHUB_WATCH_FN, - GITHUB_WORKER_NAME, - isGithubAvailable, -} from './use-github-status' - -describe('github presence probe wiring', () => { - it('probes the github worker with its own watch handler id', () => { - expect(GITHUB_WORKER_NAME).toBe('github') - expect(GITHUB_WATCH_FN).toBe('console::github-watch') - }) - - it('gates on both presence and the initial probe settling', () => { - expect(isGithubAvailable({ present: true, loading: false })).toBe(true) - expect(isGithubAvailable({ present: true, loading: true })).toBe(false) - expect(isGithubAvailable({ present: false, loading: false })).toBe(false) - }) -}) diff --git a/console/web/src/hooks/use-github-status.ts b/console/web/src/hooks/use-github-status.ts deleted file mode 100644 index b9c013b0c..000000000 --- a/console/web/src/hooks/use-github-status.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { - isWorkerPresent, - useWorkerPresence, - type WorkerPresence, -} from './use-worker-presence' - -/** - * Presence probe for the `github` worker. It wraps the GitHub CLI (`gh`) as - * typed `github::*` functions (pull requests, issues, Actions runs, releases, - * search). It is OPTIONAL, so the console gates the Github page on its - * presence rather than rendering controls that would call functions that - * don't exist. Thin wrapper over the generic worker-presence probe. - */ - -/** Engine worker name for the github worker. */ -const GITHUB_WORKER_NAME = 'github' -/** Base id for the browser-local handler bound to the `worker` trigger. */ -const GITHUB_WATCH_FN = 'console::github-watch' - -export type GithubStatus = WorkerPresence - -/** - * @param enabled - only run against the real backend; pass `false` for the - * mock/Storybook backend (treats github as present so its UI shows in - * isolation). - */ -export function useGithubStatus(enabled: boolean): GithubStatus { - return useWorkerPresence({ - workerName: GITHUB_WORKER_NAME, - watchFnId: GITHUB_WATCH_FN, - enabled, - }) -} - -/** - * Whether the github worker's functions are registered and safe to trigger. - * False during the initial presence probe and while the worker is absent. - */ -export function isGithubAvailable(status: GithubStatus): boolean { - return isWorkerPresent(status) -} - -export { GITHUB_WATCH_FN, GITHUB_WORKER_NAME } diff --git a/console/web/src/hooks/use-hash-route.ts b/console/web/src/hooks/use-hash-route.ts index 143e08951..7373b990f 100644 --- a/console/web/src/hooks/use-hash-route.ts +++ b/console/web/src/hooks/use-hash-route.ts @@ -15,7 +15,6 @@ export type View = | 'worktrees' | 'browser' | 'memory' - | 'github' | 'ext' export interface WorkersConfigurationRoute { @@ -102,9 +101,6 @@ function routeFromHash(hash: string): View | null { if (hash === '#/memory') { return 'memory' } - if (hash === '#/github') { - return 'github' - } if (hash === '#/browser' || hash.startsWith('#/browser/')) { return 'browser' } @@ -141,8 +137,6 @@ function hashFor(view: View): string { return '#/browser' case 'memory': return '#/memory' - case 'github': - return '#/github' case 'configuration': return '#/configuration' // `ext` needs a page id; navigation to a specific extension page goes diff --git a/console/web/src/lib/conversations-context.tsx b/console/web/src/lib/conversations-context.tsx index 1e3f722ae..e805a96a9 100644 --- a/console/web/src/lib/conversations-context.tsx +++ b/console/web/src/lib/conversations-context.tsx @@ -17,7 +17,6 @@ import { type ConversationsApi, useConversations, } from '@/hooks/use-conversations' -import { isGithubAvailable, useGithubStatus } from '@/hooks/use-github-status' import { type HarnessStatus, isHarnessAvailable, @@ -93,12 +92,6 @@ interface ConversationsContextValue extends ConversationsApi { * backend. */ memoryAvailable: boolean - /** - * Whether the optional `github` worker is connected — gates the Github - * page nav entry and its `github::*` RPC. Only meaningful on the real - * backend. - */ - githubAvailable: boolean } const ConversationsContext = createContext( @@ -133,9 +126,6 @@ export function ConversationsProvider({ const memoryAvailable = isMemoryAvailable( useMemoryStatus(backend.id === 'real'), ) - const githubAvailable = isGithubAvailable( - useGithubStatus(backend.id === 'real'), - ) const { modelOptions, catalogKeys, @@ -185,7 +175,6 @@ export function ConversationsProvider({ worktreeAvailable, browserAvailable, memoryAvailable, - githubAvailable, } return ( diff --git a/console/web/src/lib/github.test.ts b/console/web/src/lib/github.test.ts deleted file mode 100644 index c7384134a..000000000 --- a/console/web/src/lib/github.test.ts +++ /dev/null @@ -1,194 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { - parseIssues, - parsePrChecks, - parsePrs, - parseReleases, - parseRuns, - parseSearchItems, - releaseUrl, - timeAgoIso, - unwrapValue, -} from './github' - -/** - * Parser contract against the github worker's wire shapes. The payloads - * mirror the worker's `--json` field sets (github/tests/golden/schemas/*) - * and its live output; a worker-side field-set change should surface here. - */ - -const PR = { - number: 529, - title: '(MOT-4106) feat(github): GitHub CLI (gh) as an iii worker', - state: 'OPEN', - url: 'https://github.com/iii-hq/workers/pull/529', - author: { id: 'U_x', is_bot: false, login: 'andersonleal', name: '' }, - headRefName: 'feat/gh-worker', - baseRefName: 'main', - isDraft: false, - labels: [ - { color: 'D6393F', description: '', id: 'LA_x', name: 'needs-triage' }, - ], - createdAt: '2026-07-18T08:18:49Z', - updatedAt: '2026-07-18T09:00:00Z', -} - -describe('unwrapValue', () => { - it('unwraps the ValueResponse envelope and tolerates junk', () => { - expect(unwrapValue({ value: [1, 2] })).toEqual([1, 2]) - expect(unwrapValue({ value: null })).toBeNull() - expect(unwrapValue('nope')).toBeNull() - expect(unwrapValue(undefined)).toBeNull() - }) -}) - -describe('parsePrs', () => { - it('parses the pr list wire shape, extra fields ignored', () => { - const prs = parsePrs([PR]) - expect(prs).toHaveLength(1) - expect(prs[0]?.number).toBe(529) - expect(prs[0]?.author?.login).toBe('andersonleal') - expect(prs[0]?.labels?.[0]?.name).toBe('needs-triage') - }) - - it('drops invalid rows instead of failing the whole list', () => { - expect(parsePrs([PR, { number: 'not-a-number' }, null])).toHaveLength(1) - }) - - it('returns empty for a non-array (null value, error payloads)', () => { - expect(parsePrs(null)).toEqual([]) - expect(parsePrs({})).toEqual([]) - }) -}) - -describe('parsePrChecks', () => { - it('parses the checks rollup rows', () => { - const checks = parsePrChecks([ - { - bucket: 'skipping', - completedAt: '2026-07-18T08:18:53Z', - description: '', - link: 'https://github.com/iii-hq/workers/actions/runs/1/job/2', - name: 'close-no-help-wanted', - startedAt: '2026-07-18T08:19:01Z', - state: 'SKIPPED', - workflow: 'PR Triaging', - }, - ]) - expect(checks).toHaveLength(1) - expect(checks[0]?.bucket).toBe('skipping') - expect(checks[0]?.workflow).toBe('PR Triaging') - }) -}) - -describe('parseIssues / parseRuns / parseReleases', () => { - it('parses an issue row', () => { - const issues = parseIssues([ - { - number: 7, - title: 'bug', - state: 'OPEN', - url: 'https://github.com/o/r/issues/7', - author: { login: 'octocat' }, - labels: [], - assignees: [], - milestone: null, - createdAt: '2026-07-01T00:00:00Z', - updatedAt: '2026-07-02T00:00:00Z', - }, - ]) - expect(issues[0]?.number).toBe(7) - }) - - it('parses a run row (nullable conclusion while in progress)', () => { - const runs = parseRuns([ - { - databaseId: 29646046993, - number: 12, - displayTitle: '(MOT-4106) feat(github): ...', - name: 'CI', - workflowName: 'CI', - headBranch: 'feat/gh-worker', - headSha: 'abc123', - event: 'pull_request', - status: 'in_progress', - conclusion: null, - attempt: 1, - createdAt: '2026-07-18T13:20:00Z', - startedAt: '2026-07-18T13:20:05Z', - updatedAt: '2026-07-18T13:21:00Z', - url: 'https://github.com/iii-hq/workers/actions/runs/29646046993', - }, - ]) - expect(runs[0]?.databaseId).toBe(29646046993) - expect(runs[0]?.conclusion).toBeNull() - }) - - it('parses a release row and builds its url', () => { - const releases = parseReleases([ - { - tagName: 'fp/v0.2.0', - name: 'fp 0.2.0', - isDraft: false, - isLatest: true, - isPrerelease: false, - createdAt: '2026-07-16T00:00:00Z', - publishedAt: '2026-07-16T00:05:00Z', - }, - ]) - expect(releases[0]?.tagName).toBe('fp/v0.2.0') - expect(releaseUrl('iii-hq/workers', 'fp/v0.2.0')).toBe( - 'https://github.com/iii-hq/workers/releases/tag/fp%2Fv0.2.0', - ) - }) -}) - -describe('parseSearchItems', () => { - it('parses each kind with its own row shape', () => { - const repos = parseSearchItems('repos', [ - { - fullName: 'cli/cli', - url: 'https://github.com/cli/cli', - language: 'Go', - }, - ]) - expect(repos.kind).toBe('repos') - expect(repos.items).toHaveLength(1) - - const prs = parseSearchItems('prs', [ - { - number: 1, - title: 't', - state: 'OPEN', - url: 'u', - repository: { name: 'workers', nameWithOwner: 'iii-hq/workers' }, - isDraft: true, - }, - ]) - expect(prs.kind).toBe('prs') - expect(prs.items).toHaveLength(1) - - const code = parseSearchItems('code', [ - { path: 'src/gh.rs', repository: { nameWithOwner: 'iii-hq/workers' } }, - ]) - expect(code.kind).toBe('code') - // discriminant narrowing: `items` is only GithubSearchCode[] inside the guard - if (code.kind !== 'code') throw new Error('unreachable') - expect(code.items[0]?.path).toBe('src/gh.rs') - }) -}) - -describe('timeAgoIso', () => { - const now = Date.parse('2026-07-18T12:00:00Z') - it('formats compact relative times', () => { - expect(timeAgoIso('2026-07-18T11:59:30Z', now)).toBe('30s ago') - expect(timeAgoIso('2026-07-18T11:30:00Z', now)).toBe('30m ago') - expect(timeAgoIso('2026-07-18T09:00:00Z', now)).toBe('3h ago') - expect(timeAgoIso('2026-07-10T12:00:00Z', now)).toBe('8d ago') - }) - it('is empty for missing or malformed timestamps', () => { - expect(timeAgoIso(undefined, now)).toBe('') - expect(timeAgoIso(null, now)).toBe('') - expect(timeAgoIso('not-a-date', now)).toBe('') - }) -}) diff --git a/console/web/src/lib/nav-options.test.ts b/console/web/src/lib/nav-options.test.ts index cbe8430a2..19052c17e 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']) + expect(buildViewOptions(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(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, false).map((o) => o.value), - ).toEqual(['traces', 'workers', 'browser']) + expect(buildViewOptions(false, true, 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']) - }) - - 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', + 'memory', + ]) }) 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', + 'worktrees', + 'browser', + 'memory', + ]) }) }) diff --git a/console/web/src/lib/nav-options.ts b/console/web/src/lib/nav-options.ts index fac2aecf2..63c369e0b 100644 --- a/console/web/src/lib/nav-options.ts +++ b/console/web/src/lib/nav-options.ts @@ -10,7 +10,6 @@ export function buildViewOptions( worktreeAvailable: boolean, browserAvailable: boolean, memoryAvailable: boolean, - githubAvailable: boolean, ): { value: View; label: string }[] { const options: { value: View; label: string }[] = [ { value: 'traces', label: 'traces' }, @@ -25,8 +24,5 @@ export function buildViewOptions( if (memoryAvailable) { options.push({ value: 'memory', label: 'memory' }) } - if (githubAvailable) { - options.push({ value: 'github', label: 'github' }) - } return options } diff --git a/console/web/src/pages/Github/index.tsx b/console/web/src/pages/Github/index.tsx deleted file mode 100644 index 08d380a28..000000000 --- a/console/web/src/pages/Github/index.tsx +++ /dev/null @@ -1,148 +0,0 @@ -import { AlertCircle, FolderGit2, RefreshCw } from 'lucide-react' -import { useState } from 'react' -import { Button } from '@/components/ui/Button' -import { EmptyState } from '@/components/ui/EmptyState' -import { Input } from '@/components/ui/Input' -import { ModeToggle } from '@/components/ui/ModeToggle' -import { StatusPanel } from '@/components/ui/StatusPanel' -import { isGithubAvailable, useGithubStatus } from '@/hooks/use-github-status' -import { useConversationsCtx } from '@/lib/conversations-context' -import { loadGithubRepo, saveGithubRepo } from '@/lib/github' -import { IssuesPanel } from './components/IssuesPanel' -import { PrsPanel } from './components/PrsPanel' -import { ReleasesPanel } from './components/ReleasesPanel' -import { RunsPanel } from './components/RunsPanel' -import { SearchPanel } from './components/SearchPanel' - -/** - * The github worker's read surface: pull requests (with CI check rollups), - * issues, Actions runs, and releases for a chosen repo, plus org-wide - * search. Data loads on demand (repo commit / panel switch / refresh) — - * no polling, GitHub-side state isn't engine-event-driven and gh rate - * limits are real. The nav entry is gated on worker presence; a direct - * #/github hit with the worker absent lands on the install notice below. - * Read-only on purpose: mutations stay in agent flows behind the - * approval gate. - */ - -type Panel = 'prs' | 'issues' | 'runs' | 'releases' | 'search' - -const PANEL_OPTIONS: { value: Panel; label: string }[] = [ - { value: 'prs', label: 'pull requests' }, - { value: 'issues', label: 'issues' }, - { value: 'runs', label: 'runs' }, - { value: 'releases', label: 'releases' }, - { value: 'search', label: 'search' }, -] - -export function Github() { - const { backend } = useConversationsCtx() - const status = useGithubStatus(backend.id === 'real') - const available = isGithubAvailable(status) - - const [panel, setPanel] = useState('prs') - const [repoInput, setRepoInput] = useState(loadGithubRepo) - const [repo, setRepo] = useState(loadGithubRepo) - const [bump, setBump] = useState(0) - - const commitRepo = () => { - const next = repoInput.trim() - setRepo(next) - saveGithubRepo(next) - } - - const needsRepo = panel !== 'search' - - return ( -
-
-
-

- github -

-

- {available - ? repo || 'no repository selected' - : 'worker not connected'} -

-
- {available ? ( - - ) : null} -
- - {available ? ( -
- {needsRepo ? ( - { - if (e.key === 'Enter') commitRepo() - }} - placeholder="owner/name" - preserveCase - aria-label="repository" - className="w-64 max-w-full" - /> - ) : null} - - value={panel} - onChange={setPanel} - options={PANEL_OPTIONS} - /> -
- ) : null} - -
- {!available ? ( - status.loading ? ( -

- checking for the github worker... -

- ) : ( - } - headline="github worker not installed" - detail="this page needs the optional github worker (and the gh CLI on its host). run: iii worker add github" - /> - ) - ) : needsRepo && repo === '' ? ( - - ) : ( - // Remount on repo/panel/refresh so every fetch hook restarts clean. -
- {panel === 'prs' ? ( - - ) : panel === 'issues' ? ( - - ) : panel === 'runs' ? ( - - ) : panel === 'releases' ? ( - - ) : ( - - )} -
- )} -
-
- ) -} diff --git a/github/Cargo.lock b/github/Cargo.lock index 1a4ce0cfb..2e15ec30e 100644 --- a/github/Cargo.lock +++ b/github/Cargo.lock @@ -428,6 +428,7 @@ version = "0.2.1" dependencies = [ "anyhow", "clap", + "iii-console-ui", "iii-sdk", "libc", "schemars", @@ -664,6 +665,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" diff --git a/github/Cargo.toml b/github/Cargo.toml index 4c4f9bbad..65f7a2701 100644 --- a/github/Cargo.toml +++ b/github/Cargo.toml @@ -18,6 +18,7 @@ path = "src/lib.rs" [dependencies] iii-sdk = "=0.21.6" +iii-console-ui = { path = "../crates/console-ui" } # Must stay on the same schemars major as iii-sdk so the derived schemas line up. schemars = "0.8" tokio = { version = "1", features = [ diff --git a/github/build.rs b/github/build.rs new file mode 100644 index 000000000..7cc469490 --- /dev/null +++ b/github/build.rs @@ -0,0 +1,172 @@ +//! Build script for the `github` worker. +//! +//! 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() { + // `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/github/src/lib.rs b/github/src/lib.rs index 384b3ae7a..0b93ec3bb 100644 --- a/github/src/lib.rs +++ b/github/src/lib.rs @@ -8,3 +8,4 @@ pub mod config; pub mod configuration; pub mod functions; pub mod gh; +pub mod ui; diff --git a/github/src/main.rs b/github/src/main.rs index cfe098ef9..5949705db 100644 --- a/github/src/main.rs +++ b/github/src/main.rs @@ -40,7 +40,9 @@ async fn main() -> Result<()> { let cli = Cli::parse(); tracing::info!(url = %cli.url, seed_config = %cli.config, "connecting to III engine"); - let iii = register_worker( + // Arc-wrapped for `ui::register` (the console-ui crate clones the client + // into its hot-reload watcher task); everything else auto-derefs. + let iii = Arc::new(register_worker( &cli.url, InitOptions { metadata: Some(WorkerMetadata { @@ -55,7 +57,7 @@ async fn main() -> Result<()> { }), ..InitOptions::default() }, - ); + )); // Seed from config.yaml when present; a missing file means defaults. let seed = match Config::load(&cli.config) { @@ -89,6 +91,11 @@ async fn main() -> Result<()> { configuration::reconcile(&iii, &cell).await; register_all(&iii, &cell); + + // Injectable console UI — after the github::* functions so the console can + // attribute the assets. + github::ui::register(&iii); + tracing::info!("github worker ready: 30 typed functions + github::exec + github::api"); wait_for_shutdown_signal().await?; diff --git a/github/src/ui.rs b/github/src/ui.rs new file mode 100644 index 000000000..f92cd7fa6 --- /dev/null +++ b/github/src/ui.rs @@ -0,0 +1,77 @@ +//! Injectable console UI for the github 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: +//! +//! - `github/page.js` (`console:script`) — the `#/ext/github` page: an +//! owner/name field plus the pull-requests / issues / runs / releases / +//! search panels, each reading the live worker over `github::*` and +//! unwrapping its `{ value }` envelope. Read-only; mutations stay in agent +//! flows behind the approval gate. +//! - `github/styles.css` (`console:style`) — the stylesheet, every rule +//! scoped under `[data-iii-ui="github"]`; the console mounts it as a +//! `` and link-swaps it on change, styles-before-scripts on boot. +//! +//! The registration machinery (content function `github::ui-content`, one +//! Message-path trigger per asset, `III_GITHUB_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_GITHUB_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 = "github/page.js"; +pub const STYLES_PATH: &str = "github/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("github") + .script(PAGE_PATH, PAGE_JS) + .style(STYLES_PATH, STYLES_CSS) +} + +/// Register the github worker's console UI. Call after the `github::*` +/// 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=github]). + assert!( + STYLES_CSS.contains(r#"[data-iii-ui="github"]"#) + || STYLES_CSS.contains("[data-iii-ui=github]"), + "built styles.css must be scoped under the worker's data-iii-ui attribute" + ); + } +} diff --git a/github/ui/build.mjs b/github/ui/build.mjs new file mode 100644 index 000000000..f86593e23 --- /dev/null +++ b/github/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_GITHUB_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/github/ui/package.json b/github/ui/package.json new file mode 100644 index 000000000..e6fe72978 --- /dev/null +++ b/github/ui/package.json @@ -0,0 +1,19 @@ +{ + "name": "@iii-workers/github-ui", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "build": "tsc --noEmit && node build.mjs", + "watch": "node build.mjs --watch" + }, + "dependencies": { + "@iii-dev/console-ui": "workspace:*", + "zod": "^4.0.0" + }, + "devDependencies": { + "@types/react": "^19.2.14", + "esbuild": "^0.25.0", + "typescript": "^5.9.2" + } +} diff --git a/github/ui/page.tsx b/github/ui/page.tsx new file mode 100644 index 000000000..d4307e77a --- /dev/null +++ b/github/ui/page.tsx @@ -0,0 +1,28 @@ +/** + * Entry for the github 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 github/styles.css — + * the console mounts and link-swaps it, styles-before-scripts on boot. + * + * `setup(host)` registers one contribution: + * - src/page/ — the `#/ext/github` browser: owner/name input plus the + * pull-requests / issues / runs / releases / search panels, each reading + * the live github worker over `github::*` and unwrapping its `{ value }` + * envelope. Read-only on purpose: mutations stay in agent flows behind + * the approval gate. + * + * Registrations go through `host` so the loader disposes them on hot + * reload / worker disconnect. + */ + +import type { Host } from '@iii-dev/console-ui' +import { GithubPage } from './src/page' + +export default function setup(host: Host) { + host.pages.register({ + id: 'github', + title: 'github', + render: () => , + }) +} diff --git a/console/web/src/pages/Github/components/IssuesPanel.tsx b/github/ui/src/page/IssuesPanel.tsx similarity index 61% rename from console/web/src/pages/Github/components/IssuesPanel.tsx rename to github/ui/src/page/IssuesPanel.tsx index c576e2c87..2061c12fd 100644 --- a/console/web/src/pages/Github/components/IssuesPanel.tsx +++ b/github/ui/src/page/IssuesPanel.tsx @@ -1,20 +1,22 @@ -import { CircleDot } from 'lucide-react' +import { Badge, type Host } from '@iii-dev/console-ui' import { useCallback, useState } from 'react' -import { Badge } from '@/components/ui/Badge' -import { ModeToggle } from '@/components/ui/ModeToggle' import { type GithubIssue, ISSUE_STATE_FILTERS, type IssueStateFilter, listIssues, timeAgoIso, -} from '@/lib/github' -import { useGithubQuery } from '../hooks/useGithubQuery' +} from './github-data' +import { CircleDot, type IconProps } from './icons' +import { ModeToggle } from './ModeToggle' import { PanelShell } from './PanelShell' +import { useGithubRead } from './useGithubRead' const STATE_OPTIONS: { value: IssueStateFilter; label: string }[] = ISSUE_STATE_FILTERS.map((value) => ({ value, label: value })) +const IssueIcon = (p: IconProps) => + function issueBadge(issue: GithubIssue): { label: string variant: 'accent' | 'default' @@ -25,59 +27,59 @@ function issueBadge(issue: GithubIssue): { } interface IssuesPanelProps { + host: Host repo: string enabled: boolean } -export function IssuesPanel({ repo, enabled }: IssuesPanelProps) { +export function IssuesPanel({ host, repo, enabled }: IssuesPanelProps) { const [state, setState] = useState('open') - const fetcher = useCallback(() => listIssues(repo, state), [repo, state]) - const { data, loading, error } = useGithubQuery(enabled, fetcher) + const fetcher = useCallback( + () => listIssues(host, repo, state), + [host, repo, state], + ) + const { data, loading, error } = useGithubRead(enabled, fetcher) const issues = data ?? [] return ( -
+
value={state} onChange={setState} options={STATE_OPTIONS} + aria-label="issue state" />
-
    +
      {issues.map((issue) => { const badge = issueBadge(issue) return ( -
    • - - #{issue.number} - +
    • + #{issue.number} {issue.title} - + {issue.labels?.length ? ( - + {issue.labels.map((l) => l.name).join(', ')} ) : null} - + {issue.author?.login ?? ''} {issue.updatedAt ? ` · ${timeAgoIso(issue.updatedAt)}` : ''} diff --git a/github/ui/src/page/ModeToggle.tsx b/github/ui/src/page/ModeToggle.tsx new file mode 100644 index 000000000..ea049a746 --- /dev/null +++ b/github/ui/src/page/ModeToggle.tsx @@ -0,0 +1,50 @@ +/** + * Segmented control for view / filter switching — a self-contained port of the + * console's `ModeToggle` (tabs variant). `@iii-dev/console-ui` exports no + * segmented control, so the handful of styles live in styles.css scoped under + * `[data-iii-ui="github"]`. Semantic `tablist` so screen readers announce the + * options as tabs. + */ + +import type { ReactNode } from 'react' + +export interface ModeToggleOption { + value: T + label: ReactNode + title?: string +} + +interface ModeToggleProps { + value: T + onChange: (next: T) => void + options: ModeToggleOption[] + 'aria-label'?: string +} + +export function ModeToggle({ + value, + onChange, + options, + 'aria-label': ariaLabel, +}: ModeToggleProps) { + return ( +
      + {options.map((opt) => { + const active = opt.value === value + return ( + + ) + })} +
      + ) +} diff --git a/console/web/src/pages/Github/components/PanelShell.tsx b/github/ui/src/page/PanelShell.tsx similarity index 55% rename from console/web/src/pages/Github/components/PanelShell.tsx rename to github/ui/src/page/PanelShell.tsx index d3a7aa391..6120692b1 100644 --- a/console/web/src/pages/Github/components/PanelShell.tsx +++ b/github/ui/src/page/PanelShell.tsx @@ -1,22 +1,21 @@ -import { AlertCircle, type LucideIcon } from 'lucide-react' +import { EmptyState, StatusPanel } from '@iii-dev/console-ui' import type { ReactNode } from 'react' -import { EmptyState } from '@/components/ui/EmptyState' -import { StatusPanel } from '@/components/ui/StatusPanel' +import { AlertCircle, type IconProps } from './icons' interface PanelShellProps { loading: boolean error: string | null empty: boolean - emptyIcon: LucideIcon + emptyIcon: (props: IconProps) => ReactNode emptyTitle: string emptyDescription: string children: ReactNode } /** - * Shared error / first-load / empty scaffolding for the Github panels. - * Worker errors carry gh's own stderr (auth failures, 404s), so the alert - * detail is already the actionable message. + * Shared error / first-load / empty scaffolding for the github panels. Worker + * errors carry gh's own stderr (auth failures, 404s), so the alert detail is + * already the actionable message. */ export function PanelShell({ loading, @@ -31,18 +30,14 @@ export function PanelShell({ return ( } + icon={} headline="github call failed" detail={error} /> ) } if (loading && empty) { - return ( -

      - loading... -

      - ) + return

      loading…

      } if (empty) { return ( diff --git a/console/web/src/pages/Github/components/PrsPanel.tsx b/github/ui/src/page/PrsPanel.tsx similarity index 64% rename from console/web/src/pages/Github/components/PrsPanel.tsx rename to github/ui/src/page/PrsPanel.tsx index 81616c245..47a532a7f 100644 --- a/console/web/src/pages/Github/components/PrsPanel.tsx +++ b/github/ui/src/page/PrsPanel.tsx @@ -1,7 +1,5 @@ -import { GitPullRequest } from 'lucide-react' +import { Badge, type Host } from '@iii-dev/console-ui' import { useCallback, useState } from 'react' -import { Badge } from '@/components/ui/Badge' -import { ModeToggle } from '@/components/ui/ModeToggle' import { type GithubPr, type GithubPrCheck, @@ -10,13 +8,17 @@ import { PR_STATE_FILTERS, type PrStateFilter, timeAgoIso, -} from '@/lib/github' -import { useGithubQuery } from '../hooks/useGithubQuery' +} from './github-data' +import { GitPullRequest, type IconProps } from './icons' +import { ModeToggle } from './ModeToggle' import { PanelShell } from './PanelShell' +import { useGithubRead } from './useGithubRead' const STATE_OPTIONS: { value: PrStateFilter; label: string }[] = PR_STATE_FILTERS.map((value) => ({ value, label: value })) +const PrIcon = (p: IconProps) => + type BadgeVariant = 'default' | 'warn' | 'alert' | 'accent' function prBadge(pr: GithubPr): { label: string; variant: BadgeVariant } { @@ -43,45 +45,47 @@ function checkVariant(bucket: string): BadgeVariant { } interface PrsPanelProps { + host: Host repo: string enabled: boolean } /** Pull requests for the selected repo; a row expands its CI check rollup. */ -export function PrsPanel({ repo, enabled }: PrsPanelProps) { +export function PrsPanel({ host, repo, enabled }: PrsPanelProps) { const [state, setState] = useState('open') const [expanded, setExpanded] = useState(null) - const fetcher = useCallback(() => listPrs(repo, state), [repo, state]) - const { data, loading, error } = useGithubQuery(enabled, fetcher) + const fetcher = useCallback( + () => listPrs(host, repo, state), + [host, repo, state], + ) + const { data, loading, error } = useGithubRead(enabled, fetcher) const prs = data ?? [] return ( -
      +
      value={state} onChange={setState} options={STATE_OPTIONS} + aria-label="pull request state" />
      -
        +
          {prs.map((pr) => { const badge = prBadge(pr) const isExpanded = expanded === pr.number return ( -
        • -
          +
        • +
          @@ -99,18 +103,18 @@ export function PrsPanel({ repo, enabled }: PrsPanelProps) { href={pr.url} target="_blank" rel="noreferrer" - className="flex-1 min-w-0 truncate font-mono text-[13px] text-ink hover:text-accent transition-colors" + className="gh-row-title" > {pr.title} - + {pr.author?.login ?? ''} {pr.updatedAt ? ` · ${timeAgoIso(pr.updatedAt)}` : ''} {badge.label}
          {isExpanded ? ( - + ) : null}
        • ) @@ -121,44 +125,46 @@ export function PrsPanel({ repo, enabled }: PrsPanelProps) { ) } -function ChecksInline({ repo, number }: { repo: string; number: number }) { - const fetcher = useCallback(() => listPrChecks(repo, number), [repo, number]) - const { data, loading, error } = useGithubQuery(true, fetcher) +function ChecksInline({ + host, + repo, + number, +}: { + host: Host + repo: string + number: number +}) { + const fetcher = useCallback( + () => listPrChecks(host, repo, number), + [host, repo, number], + ) + const { data, loading, error } = useGithubRead(true, fetcher) const checks = data ?? [] return ( -
          +
          {error ? ( -

          {error}

          +

          {error}

          ) : loading && checks.length === 0 ? ( -

          - loading checks... -

          +

          loading checks…

          ) : checks.length === 0 ? ( -

          - no checks reported -

          +

          no checks reported

          ) : ( -
            +
              {checks.map((check) => ( -
            • +
            • {check.bucket} {check.link ? ( {checkLabel(check)} ) : ( - - {checkLabel(check)} - + {checkLabel(check)} )}
            • ))} diff --git a/console/web/src/pages/Github/components/ReleasesPanel.tsx b/github/ui/src/page/ReleasesPanel.tsx similarity index 53% rename from console/web/src/pages/Github/components/ReleasesPanel.tsx rename to github/ui/src/page/ReleasesPanel.tsx index 906973b8c..7bc54cf41 100644 --- a/console/web/src/pages/Github/components/ReleasesPanel.tsx +++ b/github/ui/src/page/ReleasesPanel.tsx @@ -1,18 +1,21 @@ -import { Tag } from 'lucide-react' +import { Badge, type Host } from '@iii-dev/console-ui' import { useCallback } from 'react' -import { Badge } from '@/components/ui/Badge' -import { listReleases, releaseUrl, timeAgoIso } from '@/lib/github' -import { useGithubQuery } from '../hooks/useGithubQuery' +import { listReleases, releaseUrl, timeAgoIso } from './github-data' +import { type IconProps, Tag } from './icons' import { PanelShell } from './PanelShell' +import { useGithubRead } from './useGithubRead' + +const ReleaseIcon = (p: IconProps) => interface ReleasesPanelProps { + host: Host repo: string enabled: boolean } -export function ReleasesPanel({ repo, enabled }: ReleasesPanelProps) { - const fetcher = useCallback(() => listReleases(repo), [repo]) - const { data, loading, error } = useGithubQuery(enabled, fetcher) +export function ReleasesPanel({ host, repo, enabled }: ReleasesPanelProps) { + const fetcher = useCallback(() => listReleases(host, repo), [host, repo]) + const { data, loading, error } = useGithubRead(enabled, fetcher) const releases = data ?? [] return ( @@ -20,28 +23,25 @@ export function ReleasesPanel({ repo, enabled }: ReleasesPanelProps) { loading={loading} error={error} empty={releases.length === 0} - emptyIcon={Tag} + emptyIcon={ReleaseIcon} emptyTitle="no releases" emptyDescription={`no releases in ${repo}`} > -
                +
                  {releases.map((release) => ( -
                • +
                • {release.tagName} - + {release.name ?? ''} - + {timeAgoIso(release.publishedAt ?? release.createdAt)} {release.isLatest ? latest : null} diff --git a/console/web/src/pages/Github/components/RunsPanel.tsx b/github/ui/src/page/RunsPanel.tsx similarity index 60% rename from console/web/src/pages/Github/components/RunsPanel.tsx rename to github/ui/src/page/RunsPanel.tsx index 11545f084..8f6affb0c 100644 --- a/console/web/src/pages/Github/components/RunsPanel.tsx +++ b/github/ui/src/page/RunsPanel.tsx @@ -1,9 +1,11 @@ -import { Workflow } from 'lucide-react' +import { Badge, type Host } from '@iii-dev/console-ui' import { useCallback } from 'react' -import { Badge } from '@/components/ui/Badge' -import { type GithubRun, listRuns, timeAgoIso } from '@/lib/github' -import { useGithubQuery } from '../hooks/useGithubQuery' +import { type GithubRun, listRuns, timeAgoIso } from './github-data' +import { type IconProps, Workflow } from './icons' import { PanelShell } from './PanelShell' +import { useGithubRead } from './useGithubRead' + +const RunIcon = (p: IconProps) => type BadgeVariant = 'default' | 'warn' | 'alert' | 'accent' @@ -25,14 +27,15 @@ function runBadge(run: GithubRun): { label: string; variant: BadgeVariant } { } interface RunsPanelProps { + host: Host repo: string enabled: boolean } /** GitHub Actions runs, newest first (worker default order from gh). */ -export function RunsPanel({ repo, enabled }: RunsPanelProps) { - const fetcher = useCallback(() => listRuns(repo), [repo]) - const { data, loading, error } = useGithubQuery(enabled, fetcher) +export function RunsPanel({ host, repo, enabled }: RunsPanelProps) { + const fetcher = useCallback(() => listRuns(host, repo), [host, repo]) + const { data, loading, error } = useGithubRead(enabled, fetcher) const runs = data ?? [] return ( @@ -40,11 +43,11 @@ export function RunsPanel({ repo, enabled }: RunsPanelProps) { loading={loading} error={error} empty={runs.length === 0} - emptyIcon={Workflow} + emptyIcon={RunIcon} emptyTitle="no workflow runs" emptyDescription={`no recent actions runs in ${repo}`} > -
                    +
                      {runs.map((run) => { const badge = runBadge(run) const meta = [ @@ -54,28 +57,22 @@ export function RunsPanel({ repo, enabled }: RunsPanelProps) { ] .filter(Boolean) .join(' · ') + const title = run.displayTitle ?? run.name ?? String(run.databaseId) return ( -
                    • +
                    • {run.url ? ( - {run.displayTitle ?? run.name ?? String(run.databaseId)} + {title} ) : ( - - {run.displayTitle ?? run.name ?? String(run.databaseId)} - + {title} )} - - {meta} - + {meta} {badge.label}
                    • ) diff --git a/console/web/src/pages/Github/components/SearchPanel.tsx b/github/ui/src/page/SearchPanel.tsx similarity index 64% rename from console/web/src/pages/Github/components/SearchPanel.tsx rename to github/ui/src/page/SearchPanel.tsx index 66769536a..d9a7c3b46 100644 --- a/console/web/src/pages/Github/components/SearchPanel.tsx +++ b/github/ui/src/page/SearchPanel.tsx @@ -1,16 +1,15 @@ -import { Search } from 'lucide-react' +import { Badge, type Host, Input } from '@iii-dev/console-ui' import { useCallback, useState } from 'react' -import { Badge } from '@/components/ui/Badge' -import { Input } from '@/components/ui/Input' -import { ModeToggle } from '@/components/ui/ModeToggle' import { type GithubSearchItems, type SearchKind, searchGithub, timeAgoIso, -} from '@/lib/github' -import { useGithubQuery } from '../hooks/useGithubQuery' +} from './github-data' +import { type IconProps, Search } from './icons' +import { ModeToggle } from './ModeToggle' import { PanelShell } from './PanelShell' +import { useGithubRead } from './useGithubRead' const KIND_OPTIONS: { value: SearchKind; label: string }[] = [ { value: 'repos', label: 'repos' }, @@ -19,7 +18,10 @@ const KIND_OPTIONS: { value: SearchKind; label: string }[] = [ { value: 'code', label: 'code' }, ] +const SearchIcon = (p: IconProps) => + interface SearchPanelProps { + host: Host enabled: boolean } @@ -27,18 +29,21 @@ interface SearchPanelProps { * Org-wide GitHub search. Repo scoping happens inside the query itself * (`repo:owner/name ...`), so this panel ignores the page's repo field. */ -export function SearchPanel({ enabled }: SearchPanelProps) { +export function SearchPanel({ host, enabled }: SearchPanelProps) { const [kind, setKind] = useState('repos') const [queryInput, setQueryInput] = useState('') const [query, setQuery] = useState('') - const fetcher = useCallback(() => searchGithub(kind, query), [kind, query]) + const fetcher = useCallback( + () => searchGithub(host, kind, query), + [host, kind, query], + ) const active = enabled && query !== '' - const { data, loading, error } = useGithubQuery(active, fetcher) + const { data, loading, error } = useGithubRead(active, fetcher) return ( -
                      -
                      +
                      +
                      value={kind} onChange={setKind} options={KIND_OPTIONS} + aria-label="search kind" />
                      {query === '' ? ( -

                      +

                      type a query and press enter — github search syntax, qualifiers included

                      @@ -66,7 +72,7 @@ export function SearchPanel({ enabled }: SearchPanelProps) { loading={loading} error={error} empty={isEmpty(data)} - emptyIcon={Search} + emptyIcon={SearchIcon} emptyTitle="no results" emptyDescription={`nothing matched "${query}" in ${kind}`} > @@ -84,24 +90,21 @@ function isEmpty(results: GithubSearchItems | null): boolean { function SearchResults({ results }: { results: GithubSearchItems }) { if (results.kind === 'repos') { return ( -
                        +
                          {results.items.map((repo) => ( -
                        • +
                        • {repo.fullName} - + {repo.description ?? ''} - + {[ repo.language ?? undefined, repo.stargazersCount != null @@ -119,27 +122,25 @@ function SearchResults({ results }: { results: GithubSearchItems }) { } if (results.kind === 'code') { return ( -
                            +
                              {results.items.map((hit) => (
                            • {hit.url ? ( {hit.path} ) : ( - - {hit.path} - + {hit.path} )} - + {hit.repository?.nameWithOwner ?? ''}
                            • @@ -148,24 +149,22 @@ function SearchResults({ results }: { results: GithubSearchItems }) { ) } return ( -
                                +
                                  {results.items.map((item) => (
                                • - - #{item.number} - + #{item.number} {item.title} - + {[ item.repository?.nameWithOwner ?? undefined, item.author?.login ?? undefined, diff --git a/console/web/src/lib/github.ts b/github/ui/src/page/github-data.ts similarity index 82% rename from console/web/src/lib/github.ts rename to github/ui/src/page/github-data.ts index 975e7571d..c42c5aabc 100644 --- a/console/web/src/lib/github.ts +++ b/github/ui/src/page/github-data.ts @@ -1,18 +1,21 @@ -import { z } from 'zod' -import { getIiiClient } from '@/lib/iii-client' - /** - * Typed wrappers over the optional `github` worker (the gh CLI as - * `github::*` functions): the read-only list/search surface the Github page - * renders. Every wrapper unwraps the worker's `{ value }` envelope and - * parses rows with a tolerant zod schema (unknown fields ignored, invalid - * rows dropped). Callers gate on presence (`use-github-status`). + * Read-only RPC surface for the injected github page: typed wrappers over the + * optional `github` worker (the gh CLI as `github::*` functions) — the + * list/search surface the page renders. Every wrapper unwraps the worker's + * `{ value }` envelope and parses rows with a tolerant zod schema (unknown + * fields ignored, invalid rows dropped). + * + * Ported from the console's `lib/github.ts`: the function ids, payload shapes, + * and zod parsing are verbatim; only the transport changed — every call now + * takes the tab's `host` and routes through `host.iii.trigger(...)` instead of + * a console-internal iii client. * * Mutating `github::*` functions are deliberately NOT wrapped here — writes * stay in agent flows where the approval gate reviews them. */ -export const GITHUB_WORKER_NAME = 'github' +import type { Host } from '@iii-dev/console-ui' +import { z } from 'zod' export const GITHUB_PR_LIST_FN = 'github::pr::list' export const GITHUB_PR_CHECKS_FN = 'github::pr::checks' @@ -163,7 +166,7 @@ const searchCodeSchema = z.object({ }) export type GithubSearchCode = z.infer -// ---- parsing (exported for tests; wrappers below are thin) ---- +// ---- parsing ---- const valueEnvelopeSchema = z.object({ value: z.unknown() }) @@ -181,19 +184,19 @@ function parseArray(value: unknown, schema: z.ZodType): T[] { }) } -export function parsePrs(value: unknown): GithubPr[] { +function parsePrs(value: unknown): GithubPr[] { return parseArray(value, prSchema) } -export function parsePrChecks(value: unknown): GithubPrCheck[] { +function parsePrChecks(value: unknown): GithubPrCheck[] { return parseArray(value, prCheckSchema) } -export function parseIssues(value: unknown): GithubIssue[] { +function parseIssues(value: unknown): GithubIssue[] { return parseArray(value, issueSchema) } -export function parseRuns(value: unknown): GithubRun[] { +function parseRuns(value: unknown): GithubRun[] { return parseArray(value, runSchema) } -export function parseReleases(value: unknown): GithubRelease[] { +function parseReleases(value: unknown): GithubRelease[] { return parseArray(value, releaseSchema) } @@ -203,10 +206,7 @@ export type GithubSearchItems = | { kind: 'prs'; items: GithubSearchIssue[] } | { kind: 'code'; items: GithubSearchCode[] } -export function parseSearchItems( - kind: SearchKind, - value: unknown, -): GithubSearchItems { +function parseSearchItems(kind: SearchKind, value: unknown): GithubSearchItems { switch (kind) { case 'repos': return { kind, items: parseArray(value, searchRepoSchema) } @@ -224,38 +224,45 @@ export function parseSearchItems( const LIST_LIMIT = 30 async function triggerValue( + host: Host, fnId: string, payload: Record, ): Promise { - const client = await getIiiClient() - const res = await client.trigger(fnId, payload) + const res = await host.iii.trigger(fnId, payload) return unwrapValue(res) } export async function listPrs( + host: Host, repo: string, state: PrStateFilter, ): Promise { return parsePrs( - await triggerValue(GITHUB_PR_LIST_FN, { repo, state, limit: LIST_LIMIT }), + await triggerValue(host, GITHUB_PR_LIST_FN, { + repo, + state, + limit: LIST_LIMIT, + }), ) } export async function listPrChecks( + host: Host, repo: string, number: number, ): Promise { return parsePrChecks( - await triggerValue(GITHUB_PR_CHECKS_FN, { repo, number }), + await triggerValue(host, GITHUB_PR_CHECKS_FN, { repo, number }), ) } export async function listIssues( + host: Host, repo: string, state: IssueStateFilter, ): Promise { return parseIssues( - await triggerValue(GITHUB_ISSUE_LIST_FN, { + await triggerValue(host, GITHUB_ISSUE_LIST_FN, { repo, state, limit: LIST_LIMIT, @@ -263,25 +270,35 @@ export async function listIssues( ) } -export async function listRuns(repo: string): Promise { +export async function listRuns(host: Host, repo: string): Promise { return parseRuns( - await triggerValue(GITHUB_RUN_LIST_FN, { repo, limit: LIST_LIMIT }), + await triggerValue(host, GITHUB_RUN_LIST_FN, { repo, limit: LIST_LIMIT }), ) } -export async function listReleases(repo: string): Promise { +export async function listReleases( + host: Host, + repo: string, +): Promise { return parseReleases( - await triggerValue(GITHUB_RELEASE_LIST_FN, { repo, limit: LIST_LIMIT }), + await triggerValue(host, GITHUB_RELEASE_LIST_FN, { + repo, + limit: LIST_LIMIT, + }), ) } export async function searchGithub( + host: Host, kind: SearchKind, query: string, ): Promise { return parseSearchItems( kind, - await triggerValue(GITHUB_SEARCH_FNS[kind], { query, limit: LIST_LIMIT }), + await triggerValue(host, GITHUB_SEARCH_FNS[kind], { + query, + limit: LIST_LIMIT, + }), ) } diff --git a/github/ui/src/page/icons.tsx b/github/ui/src/page/icons.tsx new file mode 100644 index 000000000..db80e027b --- /dev/null +++ b/github/ui/src/page/icons.tsx @@ -0,0 +1,133 @@ +/** + * Small inline SVG icon set — the injected page can't pull in lucide-react + * (nothing is bundled but zod), so the handful of glyphs the ported components + * used are hand-drawn here. Every icon inherits `currentColor` and takes a + * numeric `size` (Tailwind `w-*`/`h-*` classes don't apply in injected UI). + * `className` is accepted so components like the console's `EmptyState`, which + * pass one, type-check. + */ + +import type { CSSProperties, ReactNode } from 'react' + +export interface IconProps { + size?: number + className?: string + style?: CSSProperties + 'aria-hidden'?: boolean + 'aria-label'?: string +} + +function Svg({ + size = 14, + children, + className, + style, + 'aria-hidden': ariaHidden, + 'aria-label': ariaLabel, + ...rest +}: IconProps & { children: ReactNode }) { + // Decorative by default. A caller passing aria-label opts into a labeled, + // non-hidden icon (role=img so it reads as a graphic); an explicit + // aria-hidden always wins. + const labeled = ariaLabel != null + return ( + + {children} + + ) +} + +export function RefreshCw(props: IconProps) { + return ( + + + + + + + ) +} + +export function AlertCircle(props: IconProps) { + return ( + + + + + ) +} + +export function FolderGit2(props: IconProps) { + return ( + + + + + + + ) +} + +export function GitPullRequest(props: IconProps) { + return ( + + + + + + + + ) +} + +export function CircleDot(props: IconProps) { + return ( + + + + + ) +} + +export function Workflow(props: IconProps) { + return ( + + + + + + ) +} + +export function Tag(props: IconProps) { + return ( + + + + + ) +} + +export function Search(props: IconProps) { + return ( + + + + + ) +} diff --git a/github/ui/src/page/index.tsx b/github/ui/src/page/index.tsx new file mode 100644 index 000000000..fad447475 --- /dev/null +++ b/github/ui/src/page/index.tsx @@ -0,0 +1,113 @@ +/** + * The github page (#/ext/github): the github worker's read surface — pull + * requests (with CI check rollups), issues, Actions runs, and releases for a + * chosen repo, plus org-wide search. Data loads on demand (repo commit / panel + * switch / refresh) — no polling; GitHub-side state isn't engine-event-driven + * and gh rate limits are real. Read-only on purpose: mutations stay in agent + * flows behind the approval gate. + * + * The host only mounts this page when the github worker is connected, so there + * is no presence gate here; a failed `github::*` call surfaces per panel as the + * "github call failed" alert. + */ + +import { Button, EmptyState, type Host, Input } from '@iii-dev/console-ui' +import { useState } from 'react' +import { loadGithubRepo, saveGithubRepo } from './github-data' +import { IssuesPanel } from './IssuesPanel' +import { FolderGit2, type IconProps, RefreshCw } from './icons' +import { ModeToggle } from './ModeToggle' +import { PrsPanel } from './PrsPanel' +import { ReleasesPanel } from './ReleasesPanel' +import { RunsPanel } from './RunsPanel' +import { SearchPanel } from './SearchPanel' + +type Panel = 'prs' | 'issues' | 'runs' | 'releases' | 'search' + +const PANEL_OPTIONS: { value: Panel; label: string }[] = [ + { value: 'prs', label: 'pull requests' }, + { value: 'issues', label: 'issues' }, + { value: 'runs', label: 'runs' }, + { value: 'releases', label: 'releases' }, + { value: 'search', label: 'search' }, +] + +const RepoIcon = (p: IconProps) => + +export function GithubPage({ host }: { host: Host }) { + const [panel, setPanel] = useState('prs') + const [repoInput, setRepoInput] = useState(loadGithubRepo) + const [repo, setRepo] = useState(loadGithubRepo) + const [bump, setBump] = useState(0) + + const commitRepo = () => { + const next = repoInput.trim() + setRepo(next) + saveGithubRepo(next) + } + + const needsRepo = panel !== 'search' + + return ( +
                                  +
                                  +
                                  +
                                  github
                                  +
                                  {repo || 'no repository selected'}
                                  +
                                  + +
                                  + +
                                  + {needsRepo ? ( + { + if (e.key === 'Enter') commitRepo() + }} + placeholder="owner/name" + preserveCase + aria-label="repository" + /> + ) : null} + + value={panel} + onChange={setPanel} + options={PANEL_OPTIONS} + aria-label="github panel" + /> +
                                  + +
                                  + {needsRepo && repo === '' ? ( + + ) : ( + // Remount on repo/panel/refresh so every fetch hook restarts clean. +
                                  + {panel === 'prs' ? ( + + ) : panel === 'issues' ? ( + + ) : panel === 'runs' ? ( + + ) : panel === 'releases' ? ( + + ) : ( + + )} +
                                  + )} +
                                  +
                                  + ) +} diff --git a/console/web/src/pages/Github/hooks/useGithubQuery.ts b/github/ui/src/page/useGithubRead.ts similarity index 82% rename from console/web/src/pages/Github/hooks/useGithubQuery.ts rename to github/ui/src/page/useGithubRead.ts index f6c34f10e..3005371f8 100644 --- a/console/web/src/pages/Github/hooks/useGithubQuery.ts +++ b/github/ui/src/page/useGithubRead.ts @@ -4,21 +4,21 @@ import { useCallback, useEffect, useState } from 'react' * One in-flight github read: run `fetcher` while `enabled`, re-run when the * fetcher identity changes (callers memo it on repo/filter deps) or on * `refresh`. No polling and no live trigger bindings on purpose — the data - * changes on GitHub's side, not the engine's, so the page refreshes on - * demand instead of hammering the gh rate limit. + * changes on GitHub's side, not the engine's, so the page refreshes on demand + * instead of hammering the gh rate limit. */ -export interface GithubQuery { +export interface GithubRead { data: T | null loading: boolean error: string | null refresh: () => void } -export function useGithubQuery( +export function useGithubRead( enabled: boolean, fetcher: () => Promise, -): GithubQuery { +): GithubRead { const [data, setData] = useState(null) const [loading, setLoading] = useState(enabled) const [error, setError] = useState(null) @@ -26,7 +26,8 @@ export function useGithubQuery( const refresh = useCallback(() => setToken((t) => t + 1), []) - // biome-ignore lint/correctness/useExhaustiveDependencies: token is a re-run token (bumped by manual refresh), not read by the effect body + // `token` is a re-run token (bumped by manual refresh), not read by the + // effect body — it only needs to be in the dependency list. useEffect(() => { if (!enabled) { setData(null) diff --git a/github/ui/styles.css b/github/ui/styles.css new file mode 100644 index 000000000..45728bc1d --- /dev/null +++ b/github/ui/styles.css @@ -0,0 +1,280 @@ +/* + * Styles for the github worker's injected console UI. Shipped as its own + * `console:style` asset (github/styles.css) — the console mounts it as a + * and link-swaps it on change. + * + * EVERY rule is scoped under [data-iii-ui="github"] — 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 gh-ui- prefix. Colors come from the console's design tokens + * so light/dark theming is free. + */ + +[data-iii-ui="github"] .gh-page { + font-family: var(--font-mono, ui-monospace, monospace); + color: var(--color-ink); + padding: 20px 24px; + box-sizing: border-box; +} +[data-iii-ui="github"] .gh-page *, +[data-iii-ui="github"] .gh-page *::before, +[data-iii-ui="github"] .gh-page *::after { + box-sizing: border-box; +} + +/* --- header ---------------------------------------------------------- */ +[data-iii-ui="github"] .gh-head { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 12px; + padding-bottom: 12px; + border-bottom: 1px solid var(--color-rule); +} +[data-iii-ui="github"] .gh-title { + font-size: 16px; + font-weight: 600; + letter-spacing: -0.01em; + text-transform: lowercase; + color: var(--color-ink); +} +[data-iii-ui="github"] .gh-sub { + font-size: 12px; + color: var(--color-ink-faint); + margin-top: 2px; + text-transform: lowercase; + word-break: break-all; +} + +/* --- controls row ---------------------------------------------------- */ +[data-iii-ui="github"] .gh-controls { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 12px; + padding: 12px 0; + border-bottom: 1px solid var(--color-rule); +} +[data-iii-ui="github"] .gh-repo-input { + width: 16rem; + max-width: 100%; +} +[data-iii-ui="github"] .gh-search-bar { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 12px; +} +[data-iii-ui="github"] .gh-search-input { + width: 20rem; + max-width: 100%; +} + +/* --- mode toggle (segmented control) --------------------------------- */ +[data-iii-ui="github"] .gh-modes { + display: inline-flex; + border: 1px solid var(--color-rule); + padding: 2px; +} +[data-iii-ui="github"] .gh-mode { + appearance: none; + background: transparent; + border: 0; + color: var(--color-ink-faint); + font: inherit; + font-size: 13px; + text-transform: lowercase; + padding: 4px 12px; + cursor: pointer; + transition: color 0.12s, background 0.12s; +} +[data-iii-ui="github"] .gh-mode:hover { + color: var(--color-ink); +} +[data-iii-ui="github"] .gh-mode.active { + background: var(--color-ink); + color: var(--color-bg); +} + +/* --- body / panels --------------------------------------------------- */ +[data-iii-ui="github"] .gh-body { + padding-top: 16px; +} +[data-iii-ui="github"] .gh-panel { + display: flex; + flex-direction: column; + gap: 12px; +} + +[data-iii-ui="github"] .gh-msg { + font-size: 12px; + color: var(--color-ink-faint); + text-transform: lowercase; +} +[data-iii-ui="github"] .gh-pulse { + color: var(--color-ink-ghost); + animation: gh-ui-pulse 1.6s ease-in-out infinite; +} +@keyframes gh-ui-pulse { + 0%, + 100% { + opacity: 1; + } + 50% { + opacity: 0.45; + } +} + +/* --- list rows ------------------------------------------------------- */ +[data-iii-ui="github"] .gh-list { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; +} +[data-iii-ui="github"] .gh-row-outer { + border-bottom: 1px solid var(--color-rule); +} +[data-iii-ui="github"] .gh-row-outer:last-child { + border-bottom: 0; +} +[data-iii-ui="github"] .gh-row { + display: flex; + align-items: center; + gap: 12px; + padding: 8px 0; +} +[data-iii-ui="github"] .gh-list > .gh-row { + border-bottom: 1px solid var(--color-rule); +} +[data-iii-ui="github"] .gh-list > .gh-row:last-child { + border-bottom: 0; +} + +[data-iii-ui="github"] .gh-num { + font-size: 11px; + color: var(--color-ink-faint); + width: 3.5rem; + flex-shrink: 0; +} +[data-iii-ui="github"] .gh-num-btn { + appearance: none; + background: transparent; + border: 0; + font: inherit; + text-align: left; + cursor: pointer; +} +[data-iii-ui="github"] .gh-num-btn:hover { + color: var(--color-ink); +} + +[data-iii-ui="github"] .gh-row-title { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 13px; + color: var(--color-ink); + text-decoration: none; + transition: color 0.12s; +} +[data-iii-ui="github"] a.gh-row-title:hover { + color: var(--color-accent); +} +[data-iii-ui="github"] .gh-row-title-quiet { + font-size: 12px; + color: var(--color-ink-faint); +} + +[data-iii-ui="github"] .gh-row-tag { + font-size: 13px; + color: var(--color-ink); + text-decoration: none; + flex-shrink: 0; + transition: color 0.12s; +} +[data-iii-ui="github"] a.gh-row-tag:hover { + color: var(--color-accent); +} + +[data-iii-ui="github"] .gh-row-meta { + font-size: 11px; + color: var(--color-ink-faint); + text-transform: lowercase; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + flex-shrink: 0; +} +[data-iii-ui="github"] .gh-row-meta-group { + display: flex; + align-items: center; + gap: 8px; +} +[data-iii-ui="github"] .gh-labels { + max-width: 10rem; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +[data-iii-ui="github"] .gh-trunc-44 { + max-width: 11rem; +} +[data-iii-ui="github"] .gh-trunc-64 { + max-width: 16rem; +} +[data-iii-ui="github"] .gh-trunc-72 { + max-width: 18rem; +} + +/* --- pr checks rollup ------------------------------------------------ */ +[data-iii-ui="github"] .gh-checks { + padding: 0 0 8px 3.5rem; +} +[data-iii-ui="github"] .gh-checks-list { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 4px; +} +[data-iii-ui="github"] .gh-check { + display: flex; + align-items: center; + gap: 8px; + min-width: 0; +} +[data-iii-ui="github"] .gh-check-label { + font-size: 11px; + color: var(--color-ink-faint); + text-decoration: none; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +[data-iii-ui="github"] a.gh-check-label.link:hover { + color: var(--color-accent); +} +[data-iii-ui="github"] .gh-checks-msg { + font-size: 11px; + color: var(--color-ink-faint); + text-transform: lowercase; +} +[data-iii-ui="github"] .gh-checks-msg.alert { + color: var(--color-alert); + text-transform: none; +} +[data-iii-ui="github"] .gh-checks-msg.ghost { + color: var(--color-ink-ghost); +} + +@media (max-width: 720px) { + [data-iii-ui="github"] .gh-row-meta { + display: none; + } +} diff --git a/github/ui/tsconfig.json b/github/ui/tsconfig.json new file mode 100644 index 000000000..e5ac60540 --- /dev/null +++ b/github/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"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 84faee36f..a9cffb346 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -179,13 +179,29 @@ 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 + 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 + + github/ui: dependencies: '@iii-dev/console-ui': specifier: workspace:* version: link:../../packages/console-ui zod: - specifier: ^4.4.3 + specifier: ^4.0.0 version: 4.4.3 devDependencies: '@types/react': @@ -198,11 +214,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 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 83176624d..f06391dbd 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -13,6 +13,7 @@ packages: - eval/ui - state/ui - iii-directory/ui + - github/ui # pnpm ≥11 blocks dependency postinstall scripts by default; esbuild's # installs its platform binary. From 3f5ea3da6404b2bc4a4b245df3164b242ef111bb Mon Sep 17 00:00:00 2001 From: Rohit Ghumare Date: Tue, 28 Jul 2026 11:10:22 +0100 Subject: [PATCH 2/6] feat(github): activity feed page + github::called events (replace browsing) --- github/Cargo.lock | 1 + github/Cargo.toml | 1 + github/build.rs | 3 + github/src/events.rs | 569 +++++++++++++++++++++++++++ github/src/functions/mod.rs | 164 ++++++-- github/src/functions/passthrough.rs | 66 ++-- github/src/lib.rs | 1 + github/src/main.rs | 5 +- github/src/ui.rs | 11 +- github/ui/page.tsx | 10 +- github/ui/src/page/IssuesPanel.tsx | 95 ----- github/ui/src/page/ModeToggle.tsx | 50 --- github/ui/src/page/PanelShell.tsx | 52 --- github/ui/src/page/PrsPanel.tsx | 183 --------- github/ui/src/page/ReleasesPanel.tsx | 55 --- github/ui/src/page/RunsPanel.tsx | 83 ---- github/ui/src/page/SearchPanel.tsx | 185 --------- github/ui/src/page/github-data.ts | 352 ----------------- github/ui/src/page/icons.tsx | 8 + github/ui/src/page/index.tsx | 307 ++++++++++----- github/ui/src/page/useGithubRead.ts | 59 --- github/ui/styles.css | 266 ++++--------- 22 files changed, 1063 insertions(+), 1463 deletions(-) create mode 100644 github/src/events.rs delete mode 100644 github/ui/src/page/IssuesPanel.tsx delete mode 100644 github/ui/src/page/ModeToggle.tsx delete mode 100644 github/ui/src/page/PanelShell.tsx delete mode 100644 github/ui/src/page/PrsPanel.tsx delete mode 100644 github/ui/src/page/ReleasesPanel.tsx delete mode 100644 github/ui/src/page/RunsPanel.tsx delete mode 100644 github/ui/src/page/SearchPanel.tsx delete mode 100644 github/ui/src/page/github-data.ts delete mode 100644 github/ui/src/page/useGithubRead.ts diff --git a/github/Cargo.lock b/github/Cargo.lock index 2e15ec30e..92fbcb585 100644 --- a/github/Cargo.lock +++ b/github/Cargo.lock @@ -427,6 +427,7 @@ name = "github" version = "0.2.1" dependencies = [ "anyhow", + "async-trait", "clap", "iii-console-ui", "iii-sdk", diff --git a/github/Cargo.toml b/github/Cargo.toml index 65f7a2701..fc8063af8 100644 --- a/github/Cargo.toml +++ b/github/Cargo.toml @@ -34,6 +34,7 @@ serde = { version = "1", features = ["derive"] } serde_json = "1" serde_yaml = "0.9" anyhow = "1" +async-trait = "0.1" tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["fmt", "env-filter"] } clap = { version = "4", features = ["derive", "env"] } diff --git a/github/build.rs b/github/build.rs index 7cc469490..56c9b8ccb 100644 --- a/github/build.rs +++ b/github/build.rs @@ -22,6 +22,9 @@ fn main() { // 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"); + // build.rs branches on SKIP_UI_BUILD below; declare it so toggling the var + // re-runs this script (and refreshes the embedded assets accordingly). + println!("cargo:rerun-if-env-changed=SKIP_UI_BUILD"); let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")); let ui_dir = manifest_dir.join("ui"); diff --git a/github/src/events.rs b/github/src/events.rs new file mode 100644 index 000000000..1168974b9 --- /dev/null +++ b/github/src/events.rs @@ -0,0 +1,569 @@ +//! The `github::called` trigger type this worker emits, the subscriber +//! registry behind it, and the summarizers that turn a call's argv + result +//! into the short human strings the console activity feed renders. +//! +//! After every `github::*` function runs, [`run_and_emit`] fires a +//! fire-and-forget `github::called` event (`TriggerAction::Void`) carrying the +//! call and a bounded result summary. Subscribers (the console page) bind a +//! trigger instance of the type with the standard two-step pattern; the engine +//! routes each registration through [`CalledTriggerHandler`], which stashes the +//! subscriber in [`SubscriberSet`]. Emission is best-effort: a slow or absent +//! subscriber never blocks or fails the real call — the mirror of the +//! `iii-directory` / `browser` worker fan-out. + +use std::collections::HashMap; +use std::future::Future; +use std::sync::{Arc, Mutex}; + +use async_trait::async_trait; +use iii_sdk::errors::Error; +use iii_sdk::protocol::TriggerRequest; +use iii_sdk::trigger::{TriggerConfig, TriggerHandler}; +use iii_sdk::{IIIClient, RegisterTriggerType, TriggerAction}; +use serde::Serialize; +use serde_json::{Map, Value}; + +/// The trigger type the console subscribes to for the activity feed. +pub const CALLED: &str = "github::called"; + +/// Result/args summaries are bounded so a huge diff or body can never bloat the +/// event past what the feed row needs. +const MAX_SUMMARY: usize = 512; +const MAX_ARGS: usize = 256; + +/// One `github::called` event: the call that ran plus a short, human result +/// summary. Serialized as the trigger payload; the console renders it directly. +#[derive(Debug, Clone, Serialize)] +pub struct CalledEvent { + /// The `github::*` function id that ran. + pub function_id: String, + /// One-line echo of the salient input (repo, number, query), bodies and the + /// `--json` field list stripped. + pub args_summary: String, + /// `owner/name` when the call named one, else null. + pub repo: Option, + /// True when the function returned Ok. + pub ok: bool, + /// Wall-clock duration of the wrapped handler, in ms. + pub duration_ms: u64, + /// Short human string derived from the result (e.g. "3 pull requests"). + pub result_summary: String, + /// RFC 3339 UTC timestamp of completion. + pub timestamp: String, +} + +/// Thread-safe subscriber registry keyed by trigger-instance id. Cloned into +/// the [`CalledTriggerHandler`] (mutates on register/unregister) and the +/// [`CalledEmitter`] fan-out (iterates read-only). +#[derive(Clone, Default)] +pub struct SubscriberSet { + inner: Arc>>, +} + +impl SubscriberSet { + pub fn new() -> Self { + Self::default() + } + + fn insert(&self, id: String, function_id: String) { + self.lock().insert(id, function_id); + } + + fn remove(&self, id: &str) { + self.lock().remove(id); + } + + /// Snapshot of the current subscriber `function_id`s so the mutex is never + /// held across an await. + pub fn function_ids(&self) -> Vec { + self.lock().values().cloned().collect() + } + + fn lock(&self) -> std::sync::MutexGuard<'_, HashMap> { + self.inner.lock().unwrap_or_else(|p| p.into_inner()) + } +} + +struct CalledTriggerHandler { + subscribers: SubscriberSet, +} + +#[async_trait] +impl TriggerHandler for CalledTriggerHandler { + async fn register_trigger(&self, config: TriggerConfig) -> Result<(), Error> { + tracing::info!( + trigger_type = CALLED, + id = %config.id, + function_id = %config.function_id, + "trigger subscription registered" + ); + self.subscribers.insert(config.id, config.function_id); + Ok(()) + } + + async fn unregister_trigger(&self, config: TriggerConfig) -> Result<(), Error> { + tracing::info!(trigger_type = CALLED, id = %config.id, "trigger subscription unregistered"); + self.subscribers.remove(&config.id); + Ok(()) + } +} + +/// Fires `github::called` events to every current subscriber. Holds the client +/// and the shared subscriber set; cheap to clone into each function handler. +#[derive(Clone)] +pub struct CalledEmitter { + iii: Arc, + subscribers: SubscriberSet, +} + +impl CalledEmitter { + pub fn new(iii: Arc, subscribers: SubscriberSet) -> Self { + Self { iii, subscribers } + } + + /// Fan `event` out to every subscriber with `TriggerAction::Void` + /// (fire-and-forget). No subscribers ⇒ no work. Delivery failures are + /// logged and swallowed — the real call already returned. + pub async fn emit(&self, event: CalledEvent) { + let targets = self.subscribers.function_ids(); + if targets.is_empty() { + return; + } + let payload = match serde_json::to_value(&event) { + Ok(v) => v, + Err(e) => { + tracing::warn!(error = %e, "github::called payload failed to serialize"); + return; + } + }; + for function_id in targets { + let res = self + .iii + .trigger(TriggerRequest { + function_id: function_id.clone(), + payload: payload.clone(), + action: Some(TriggerAction::Void), + timeout_ms: None, + }) + .await; + if let Err(e) = res { + tracing::warn!(function_id = %function_id, error = %e, "github::called fan-out failed"); + } + } + } +} + +/// Register the `github::called` trigger type with the engine and return the +/// emitter wired to its subscriber set. Call before registering the functions +/// so the emitter is ready to thread through them. +pub fn register_called_trigger(iii: &Arc) -> CalledEmitter { + let subscribers = SubscriberSet::new(); + let _ = iii.register_trigger_type(RegisterTriggerType::new( + CALLED, + "Fires after each github function runs; carries the call + a short result summary.", + CalledTriggerHandler { + subscribers: subscribers.clone(), + }, + )); + tracing::info!(trigger_type = CALLED, "registered trigger type"); + CalledEmitter::new(iii.clone(), subscribers) +} + +/// Run `fut` (the real handler), measure it, then emit a `github::called` +/// event. The wrapped handler's result is returned unchanged; the emit never +/// affects it (fire-and-forget, log-and-ignore on failure). +pub async fn run_and_emit( + emitter: &CalledEmitter, + function_id: &'static str, + args_summary: String, + repo: Option, + fut: Fut, +) -> Result +where + Resp: Serialize, + Fut: Future>, +{ + let started = std::time::Instant::now(); + let result = fut.await; + let duration_ms = started.elapsed().as_millis() as u64; + let (ok, result_summary) = match &result { + Ok(resp) => { + let value = serde_json::to_value(resp).unwrap_or(Value::Null); + (true, summarize(function_id, &value)) + } + Err(e) => (false, truncate(&e.to_string(), MAX_SUMMARY)), + }; + emitter + .emit(CalledEvent { + function_id: function_id.to_string(), + args_summary, + repo, + ok, + duration_ms, + result_summary, + timestamp: now_rfc3339(), + }) + .await; + result +} + +/// A SHORT human summary of a function's serialized result. Dispatches on the +/// response envelope (`{ value }` / `{ output }` / `{ diff }` / a `GhOutcome`) +/// and falls back to a bounded first-line/bytes description. +pub fn summarize(function_id: &str, result: &Value) -> String { + let raw = match result { + Value::Object(m) if m.contains_key("output") => { + let out = m.get("output").and_then(Value::as_str).unwrap_or(""); + let line = first_line(out); + if line.is_empty() { + "ok".to_string() + } else { + line + } + } + Value::Object(m) if m.contains_key("diff") => { + let diff = m.get("diff").and_then(Value::as_str).unwrap_or(""); + let truncated = m.get("truncated").and_then(Value::as_bool).unwrap_or(false); + let mut s = format!("{} diff", human_bytes(diff.len())); + if truncated { + s.push_str(", truncated"); + } + s + } + Value::Object(m) if m.contains_key("value") => { + summarize_value(function_id, m.get("value").unwrap_or(&Value::Null)) + } + Value::Object(m) if m.contains_key("exit_code") || m.contains_key("stdout") => { + summarize_outcome(m) + } + _ => first_line(&result.to_string()), + }; + truncate(&raw, MAX_SUMMARY) +} + +/// Count arrays (with a function-derived noun), name a single object, else a +/// short description of the `{ value }` payload. +fn summarize_value(function_id: &str, value: &Value) -> String { + match value { + Value::Array(items) => { + format!( + "{} {}", + items.len(), + pluralize(noun_for(function_id), items.len()) + ) + } + Value::Null => "no data".to_string(), + Value::Object(_) => format!("1 {}", noun_for(function_id)), + other => first_line(&other.to_string()), + } +} + +/// `github::exec`'s `GhOutcome`: exit code + captured stdout size, or the +/// timeout/kill state. +fn summarize_outcome(m: &Map) -> String { + let timed_out = m.get("timed_out").and_then(Value::as_bool).unwrap_or(false); + if timed_out { + return "timed out".to_string(); + } + let bytes = m.get("stdout").and_then(Value::as_str).map_or(0, str::len); + match m.get("exit_code").and_then(Value::as_i64) { + Some(code) => format!("exit {code}, {} out", human_bytes(bytes)), + None => "killed".to_string(), + } +} + +/// The singular noun a list/search function returns, for count summaries. +fn noun_for(function_id: &str) -> &'static str { + if function_id.contains("::pr::") || function_id.ends_with("::prs") { + "pull request" + } else if function_id.contains("issue") { + "issue" + } else if function_id.contains("run") { + "run" + } else if function_id.contains("release") { + "release" + } else if function_id.contains("repo") { + "repository" + } else if function_id.contains("workflow") { + "workflow" + } else if function_id.ends_with("::code") { + "code result" + } else { + "result" + } +} + +fn pluralize(noun: &str, n: usize) -> String { + if n == 1 { + return noun.to_string(); + } + match noun.strip_suffix('y') { + Some(stem) => format!("{stem}ies"), + None => format!("{noun}s"), + } +} + +/// A one-line echo of the salient gh input: the subcommand words, `-R` repo, +/// number, and query flags. The `--json` field list is dropped (plumbing) and +/// body/field values are redacted — gh keeps the auth token in the env, never +/// in argv, so there is nothing secret in the words themselves. +pub fn summarize_args(argv: &[String]) -> String { + let mut out: Vec = Vec::new(); + let mut iter = argv.iter(); + while let Some(tok) = iter.next() { + match tok.as_str() { + // Plumbing noise: drop the flag and its (long) field list. + "--json" => { + iter.next(); + } + // Keep the field key, redact its value (may carry user secrets). + "-f" | "-F" | "--field" | "--raw-field" => { + out.push(tok.clone()); + if let Some(v) = iter.next() { + let key = v.split('=').next().unwrap_or(""); + out.push(format!("{key}=…")); + } + } + // Bodies/inputs are long free text — echo the flag, redact the value. + "--body" | "--body-file" | "--input" => { + out.push(tok.clone()); + if iter.next().is_some() { + out.push("…".to_string()); + } + } + _ => out.push(tok.clone()), + } + } + truncate(&out.join(" "), MAX_ARGS) +} + +/// `owner/name` from a gh argv (`-R` / `--repo`), if present. +pub fn repo_from_args(argv: &[String]) -> Option { + let mut iter = argv.iter(); + while let Some(tok) = iter.next() { + if tok == "-R" || tok == "--repo" { + return iter.next().cloned(); + } + } + None +} + +fn first_line(s: &str) -> String { + s.lines() + .map(str::trim) + .find(|l| !l.is_empty()) + .unwrap_or("") + .to_string() +} + +fn human_bytes(n: usize) -> String { + if n < 1024 { + format!("{n} B") + } else if n < 1024 * 1024 { + format!("{:.1} KB", n as f64 / 1024.0) + } else { + format!("{:.1} MB", n as f64 / (1024.0 * 1024.0)) + } +} + +/// Char-bounded truncation with an ellipsis; keeps the result ≤ `max` chars. +fn truncate(s: &str, max: usize) -> String { + if s.chars().count() <= max { + return s.to_string(); + } + let mut out: String = s.chars().take(max.saturating_sub(1)).collect(); + out.push('…'); + out +} + +/// RFC 3339 UTC timestamp for "now" — no chrono dependency; the calendar math +/// is Howard Hinnant's `civil_from_days`. +fn now_rfc3339() -> String { + let secs = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs() as i64) + .unwrap_or(0); + let (y, mo, d, h, mi, s) = civil_from_epoch(secs); + format!("{y:04}-{mo:02}-{d:02}T{h:02}:{mi:02}:{s:02}Z") +} + +fn civil_from_epoch(secs: i64) -> (i64, u32, u32, u32, u32, u32) { + let days = secs.div_euclid(86_400); + let rem = secs.rem_euclid(86_400); + let h = (rem / 3600) as u32; + let mi = ((rem % 3600) / 60) as u32; + let s = (rem % 60) as u32; + + let z = days + 719_468; + let era = if z >= 0 { z } else { z - 146_096 } / 146_097; + let doe = z - era * 146_097; + let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; + let y = yoe + era * 400; + let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); + let mp = (5 * doy + 2) / 153; + let d = (doy - (153 * mp + 2) / 5 + 1) as u32; + let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32; + let year = if m <= 2 { y + 1 } else { y }; + (year, m, d, h, mi, s) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn summarize_counts_list_results_with_a_noun() { + assert_eq!( + summarize("github::pr::list", &json!({ "value": [1, 2, 3] })), + "3 pull requests" + ); + assert_eq!( + summarize("github::repo::list", &json!({ "value": [{}] })), + "1 repository" + ); + assert_eq!( + summarize("github::search::issues", &json!({ "value": [] })), + "0 issues" + ); + } + + #[test] + fn summarize_names_a_single_object() { + assert_eq!( + summarize("github::pr::view", &json!({ "value": { "number": 7 } })), + "1 pull request" + ); + } + + #[test] + fn summarize_uses_first_line_for_text_output() { + assert_eq!( + summarize( + "github::issue::create", + &json!({ "output": "https://github.com/o/r/issues/42\n" }) + ), + "https://github.com/o/r/issues/42" + ); + assert_eq!( + summarize("github::pr::edit", &json!({ "output": "" })), + "ok" + ); + } + + #[test] + fn summarize_describes_diff_and_outcome() { + assert_eq!( + summarize( + "github::pr::diff", + &json!({ "diff": "abcd", "truncated": true }) + ), + "4 B diff, truncated" + ); + assert_eq!( + summarize( + "github::exec", + &json!({ "stdout": "hi", "exit_code": 0, "timed_out": false }) + ), + "exit 0, 2 B out" + ); + } + + #[test] + fn summarize_is_bounded() { + let big = "x".repeat(5000); + let s = summarize("github::pr::edit", &json!({ "output": big })); + assert!(s.chars().count() <= MAX_SUMMARY); + } + + #[test] + fn args_summary_redacts_bodies_and_drops_json_fields() { + let argv: Vec = [ + "pr", + "list", + "-R", + "o/r", + "--json", + "number,title", + "--state", + "open", + ] + .iter() + .map(|s| s.to_string()) + .collect(); + assert_eq!(summarize_args(&argv), "pr list -R o/r --state open"); + + let create: Vec = [ + "pr", + "create", + "-R", + "o/r", + "--title", + "t", + "--body", + "secret body", + ] + .iter() + .map(|s| s.to_string()) + .collect(); + assert_eq!( + summarize_args(&create), + "pr create -R o/r --title t --body …" + ); + } + + #[test] + fn repo_extracted_from_argv() { + let argv: Vec = ["pr", "list", "-R", "iii-hq/workers"] + .iter() + .map(|s| s.to_string()) + .collect(); + assert_eq!(repo_from_args(&argv), Some("iii-hq/workers".to_string())); + assert_eq!(repo_from_args(&["api".into(), "rate_limit".into()]), None); + } + + #[test] + fn rfc3339_matches_known_epochs() { + assert_eq!(civil_from_epoch(0), (1970, 1, 1, 0, 0, 0)); + assert_eq!(civil_from_epoch(1_700_000_000), (2023, 11, 14, 22, 13, 20)); + } + + #[tokio::test] + async fn emitting_does_not_change_the_wrapped_return() { + #[derive(Serialize, PartialEq, Debug)] + struct Out { + output: String, + } + // No subscribers ⇒ emit is a no-op and never touches the client. + let emitter = CalledEmitter::new( + Arc::new(IIIClient::new("ws://127.0.0.1:1")), + SubscriberSet::new(), + ); + + let ok = run_and_emit( + &emitter, + "github::pr::create", + "pr create".into(), + None, + async { + Ok::<_, Error>(Out { + output: "hello".to_string(), + }) + }, + ) + .await; + assert_eq!( + ok.unwrap(), + Out { + output: "hello".to_string() + } + ); + + let err = + run_and_emit::(&emitter, "github::pr::create", String::new(), None, async { + Err(Error::Handler("boom".to_string())) + }) + .await; + assert!(matches!(err, Err(Error::Handler(m)) if m == "boom")); + } +} diff --git a/github/src/functions/mod.rs b/github/src/functions/mod.rs index 15e59eb6e..aaeb1785f 100644 --- a/github/src/functions/mod.rs +++ b/github/src/functions/mod.rs @@ -17,6 +17,7 @@ use serde::Serialize; use serde_json::Value; use crate::configuration::ConfigCell; +use crate::events::{self, CalledEmitter}; use crate::gh::{self, GhError, GhOutcome}; /// Exit codes that mean "the gh operation succeeded" for most commands. @@ -91,10 +92,13 @@ fn parse_stdout(id: &str, out: &GhOutcome) -> Result { } /// Register a curated wrapper whose stdout is `--json` output: typed request -/// → pure argv builder → bounded gh run → parsed `ValueResponse`. +/// → pure argv builder → bounded gh run → parsed `ValueResponse`. Every call +/// emits a fire-and-forget `github::called` event via [`events::run_and_emit`]; +/// the wrapped behavior and response are unchanged. fn register_json( iii: &IIIClient, cell: &ConfigCell, + emitter: &CalledEmitter, id: &'static str, desc: &'static str, ok_codes: &'static [i32], @@ -103,18 +107,26 @@ fn register_json( Req: serde::de::DeserializeOwned + JsonSchema + Send + 'static, { let cell = cell.clone(); + let emitter = emitter.clone(); iii.register_function( id, RegisterFunction::new_async(move |req: Req| { let cell = cell.clone(); + let emitter = emitter.clone(); async move { - let cfg = cell.read().await.clone(); - let out = gh::run(&cfg, &build(&req), None, None) - .await - .map_err(Error::from)?; - let out = expect_ok(id, out, ok_codes)?; - let value = parse_stdout(id, &out)?; - Ok::<_, Error>(ValueResponse { value }) + let args = build(&req); + let args_summary = events::summarize_args(&args); + let repo = events::repo_from_args(&args); + events::run_and_emit(&emitter, id, args_summary, repo, async move { + let cfg = cell.read().await.clone(); + let out = gh::run(&cfg, &args, None, None) + .await + .map_err(Error::from)?; + let out = expect_ok(id, out, ok_codes)?; + let value = parse_stdout(id, &out)?; + Ok::<_, Error>(ValueResponse { value }) + }) + .await } }) .description(desc), @@ -126,6 +138,7 @@ fn register_json( fn register_text( iii: &IIIClient, cell: &ConfigCell, + emitter: &CalledEmitter, id: &'static str, desc: &'static str, build: fn(&Req) -> Vec, @@ -133,19 +146,27 @@ fn register_text( Req: serde::de::DeserializeOwned + JsonSchema + Send + 'static, { let cell = cell.clone(); + let emitter = emitter.clone(); iii.register_function( id, RegisterFunction::new_async(move |req: Req| { let cell = cell.clone(); + let emitter = emitter.clone(); async move { - let cfg = cell.read().await.clone(); - let out = gh::run(&cfg, &build(&req), None, None) - .await - .map_err(Error::from)?; - let out = expect_ok(id, out, OK)?; - Ok::<_, Error>(TextResponse { - output: out.stdout.trim().to_string(), + let args = build(&req); + let args_summary = events::summarize_args(&args); + let repo = events::repo_from_args(&args); + events::run_and_emit(&emitter, id, args_summary, repo, async move { + let cfg = cell.read().await.clone(); + let out = gh::run(&cfg, &args, None, None) + .await + .map_err(Error::from)?; + let out = expect_ok(id, out, OK)?; + Ok::<_, Error>(TextResponse { + output: out.stdout.trim().to_string(), + }) }) + .await } }) .description(desc), @@ -155,22 +176,30 @@ fn register_text( /// `github::pr::diff` is hand-registered: its response is the raw diff, with /// the truncation flag surfaced instead of erroring (a cut-off diff is still /// useful, unlike cut-off JSON). -fn register_diff(iii: &IIIClient, cell: &ConfigCell) { +fn register_diff(iii: &IIIClient, cell: &ConfigCell, emitter: &CalledEmitter) { let cell = cell.clone(); + let emitter = emitter.clone(); iii.register_function( pr::DIFF_ID, RegisterFunction::new_async(move |req: pr::DiffRequest| { let cell = cell.clone(); + let emitter = emitter.clone(); async move { - let cfg = cell.read().await.clone(); - let out = gh::run(&cfg, &pr::diff_args(&req), None, None) - .await - .map_err(Error::from)?; - let out = expect_ok(pr::DIFF_ID, out, OK)?; - Ok::<_, Error>(DiffResponse { - truncated: out.stdout_truncated, - diff: out.stdout, + let args = pr::diff_args(&req); + let args_summary = events::summarize_args(&args); + let repo = events::repo_from_args(&args); + events::run_and_emit(&emitter, pr::DIFF_ID, args_summary, repo, async move { + let cfg = cell.read().await.clone(); + let out = gh::run(&cfg, &args, None, None) + .await + .map_err(Error::from)?; + let out = expect_ok(pr::DIFF_ID, out, OK)?; + Ok::<_, Error>(DiffResponse { + truncated: out.stdout_truncated, + diff: out.stdout, + }) }) + .await } }) .description(pr::DIFF_DESC), @@ -178,24 +207,72 @@ fn register_diff(iii: &IIIClient, cell: &ConfigCell) { } /// Register every `github::*` function. Keep in lockstep with [`catalog`]. -pub fn register_all(iii: &IIIClient, cell: &ConfigCell) { - register_json(iii, cell, pr::LIST_ID, pr::LIST_DESC, OK, pr::list_args); - register_json(iii, cell, pr::VIEW_ID, pr::VIEW_DESC, OK, pr::view_args); - register_text(iii, cell, pr::CREATE_ID, pr::CREATE_DESC, pr::create_args); - register_text(iii, cell, pr::EDIT_ID, pr::EDIT_DESC, pr::edit_args); - register_text(iii, cell, pr::MERGE_ID, pr::MERGE_DESC, pr::merge_args); +/// `emitter` is threaded into each register helper so every call fans out a +/// `github::called` event without hand-editing the individual handlers. +pub fn register_all(iii: &IIIClient, cell: &ConfigCell, emitter: &CalledEmitter) { + register_json( + iii, + cell, + emitter, + pr::LIST_ID, + pr::LIST_DESC, + OK, + pr::list_args, + ); + register_json( + iii, + cell, + emitter, + pr::VIEW_ID, + pr::VIEW_DESC, + OK, + pr::view_args, + ); register_text( iii, cell, + emitter, + pr::CREATE_ID, + pr::CREATE_DESC, + pr::create_args, + ); + register_text( + iii, + cell, + emitter, + pr::EDIT_ID, + pr::EDIT_DESC, + pr::edit_args, + ); + register_text( + iii, + cell, + emitter, + pr::MERGE_ID, + pr::MERGE_DESC, + pr::merge_args, + ); + register_text( + iii, + cell, + emitter, pr::COMMENT_ID, pr::COMMENT_DESC, pr::comment_args, ); - register_text(iii, cell, pr::REVIEW_ID, pr::REVIEW_DESC, pr::review_args); - register_diff(iii, cell); + register_text( + iii, + cell, + emitter, + pr::REVIEW_ID, + pr::REVIEW_DESC, + pr::review_args, + ); + register_diff(iii, cell, emitter); register_json( iii, cell, + emitter, pr::CHECKS_ID, pr::CHECKS_DESC, CHECKS_OK, @@ -205,6 +282,7 @@ pub fn register_all(iii: &IIIClient, cell: &ConfigCell) { register_json( iii, cell, + emitter, issue::LIST_ID, issue::LIST_DESC, OK, @@ -213,6 +291,7 @@ pub fn register_all(iii: &IIIClient, cell: &ConfigCell) { register_json( iii, cell, + emitter, issue::VIEW_ID, issue::VIEW_DESC, OK, @@ -221,6 +300,7 @@ pub fn register_all(iii: &IIIClient, cell: &ConfigCell) { register_text( iii, cell, + emitter, issue::CREATE_ID, issue::CREATE_DESC, issue::create_args, @@ -228,6 +308,7 @@ pub fn register_all(iii: &IIIClient, cell: &ConfigCell) { register_text( iii, cell, + emitter, issue::EDIT_ID, issue::EDIT_DESC, issue::edit_args, @@ -235,6 +316,7 @@ pub fn register_all(iii: &IIIClient, cell: &ConfigCell) { register_text( iii, cell, + emitter, issue::COMMENT_ID, issue::COMMENT_DESC, issue::comment_args, @@ -242,6 +324,7 @@ pub fn register_all(iii: &IIIClient, cell: &ConfigCell) { register_text( iii, cell, + emitter, issue::CLOSE_ID, issue::CLOSE_DESC, issue::close_args, @@ -250,6 +333,7 @@ pub fn register_all(iii: &IIIClient, cell: &ConfigCell) { register_json( iii, cell, + emitter, repo::VIEW_ID, repo::VIEW_DESC, OK, @@ -258,6 +342,7 @@ pub fn register_all(iii: &IIIClient, cell: &ConfigCell) { register_json( iii, cell, + emitter, repo::LIST_ID, repo::LIST_DESC, OK, @@ -267,6 +352,7 @@ pub fn register_all(iii: &IIIClient, cell: &ConfigCell) { register_json( iii, cell, + emitter, actions::RUN_LIST_ID, actions::RUN_LIST_DESC, OK, @@ -275,6 +361,7 @@ pub fn register_all(iii: &IIIClient, cell: &ConfigCell) { register_json( iii, cell, + emitter, actions::RUN_VIEW_ID, actions::RUN_VIEW_DESC, OK, @@ -283,6 +370,7 @@ pub fn register_all(iii: &IIIClient, cell: &ConfigCell) { register_text( iii, cell, + emitter, actions::RUN_RERUN_ID, actions::RUN_RERUN_DESC, actions::run_rerun_args, @@ -290,6 +378,7 @@ pub fn register_all(iii: &IIIClient, cell: &ConfigCell) { register_text( iii, cell, + emitter, actions::RUN_CANCEL_ID, actions::RUN_CANCEL_DESC, actions::run_cancel_args, @@ -297,6 +386,7 @@ pub fn register_all(iii: &IIIClient, cell: &ConfigCell) { register_json( iii, cell, + emitter, actions::WORKFLOW_LIST_ID, actions::WORKFLOW_LIST_DESC, OK, @@ -305,6 +395,7 @@ pub fn register_all(iii: &IIIClient, cell: &ConfigCell) { register_text( iii, cell, + emitter, actions::WORKFLOW_RUN_ID, actions::WORKFLOW_RUN_DESC, actions::workflow_run_args, @@ -313,6 +404,7 @@ pub fn register_all(iii: &IIIClient, cell: &ConfigCell) { register_json( iii, cell, + emitter, release::LIST_ID, release::LIST_DESC, OK, @@ -321,6 +413,7 @@ pub fn register_all(iii: &IIIClient, cell: &ConfigCell) { register_json( iii, cell, + emitter, release::VIEW_ID, release::VIEW_DESC, OK, @@ -329,6 +422,7 @@ pub fn register_all(iii: &IIIClient, cell: &ConfigCell) { register_text( iii, cell, + emitter, release::CREATE_ID, release::CREATE_DESC, release::create_args, @@ -337,6 +431,7 @@ pub fn register_all(iii: &IIIClient, cell: &ConfigCell) { register_json( iii, cell, + emitter, search::REPOS_ID, search::REPOS_DESC, OK, @@ -345,6 +440,7 @@ pub fn register_all(iii: &IIIClient, cell: &ConfigCell) { register_json( iii, cell, + emitter, search::ISSUES_ID, search::ISSUES_DESC, OK, @@ -353,6 +449,7 @@ pub fn register_all(iii: &IIIClient, cell: &ConfigCell) { register_json( iii, cell, + emitter, search::PRS_ID, search::PRS_DESC, OK, @@ -361,13 +458,14 @@ pub fn register_all(iii: &IIIClient, cell: &ConfigCell) { register_json( iii, cell, + emitter, search::CODE_ID, search::CODE_DESC, OK, search::code_args, ); - passthrough::register(iii, cell); + passthrough::register(iii, cell, emitter); } // ---- argv-building helpers (pure; shared by the group modules) ---- diff --git a/github/src/functions/passthrough.rs b/github/src/functions/passthrough.rs index 1ea171c76..44d6c5313 100644 --- a/github/src/functions/passthrough.rs +++ b/github/src/functions/passthrough.rs @@ -9,6 +9,7 @@ use serde_json::Value; use super::{argv, expect_ok, push_opt, ValueResponse, OK}; use crate::configuration::ConfigCell; +use crate::events::{self, CalledEmitter}; use crate::gh; pub const EXEC_ID: &str = "github::exec"; @@ -69,49 +70,64 @@ pub fn api_args(r: &ApiRequest) -> Vec { a } -pub(crate) fn register(iii: &IIIClient, cell: &ConfigCell) { +pub(crate) fn register(iii: &IIIClient, cell: &ConfigCell, emitter: &CalledEmitter) { let c = cell.clone(); + let em = emitter.clone(); iii.register_function( EXEC_ID, RegisterFunction::new_async(move |req: ExecRequest| { let cell = c.clone(); + let emitter = em.clone(); async move { - let cfg = cell.read().await.clone(); - gh::run(&cfg, &req.args, req.stdin, req.timeout_ms) - .await - .map_err(Error::from) + let args_summary = events::summarize_args(&req.args); + let repo = events::repo_from_args(&req.args); + events::run_and_emit(&emitter, EXEC_ID, args_summary, repo, async move { + let cfg = cell.read().await.clone(); + gh::run(&cfg, &req.args, req.stdin, req.timeout_ms) + .await + .map_err(Error::from) + }) + .await } }) .description(EXEC_DESC), ); let c = cell.clone(); + let em = emitter.clone(); iii.register_function( API_ID, RegisterFunction::new_async(move |req: ApiRequest| { let cell = c.clone(); + let emitter = em.clone(); async move { - let cfg = cell.read().await.clone(); - let stdin = match &req.body { - Some(v) => Some(serde_json::to_string(v).map_err(|e| { - Error::Handler(format!("github::api: body does not serialize: {e}")) - })?), - None => None, - }; - let out = gh::run(&cfg, &api_args(&req), stdin, req.timeout_ms) - .await - .map_err(Error::from)?; - let out = expect_ok(API_ID, out, OK)?; - if out.stdout_truncated { - return Err(Error::Handler( - "github::api: response exceeded max_output_bytes and was truncated; \ - narrow it with jq, paginate less, or raise the cap" - .to_string(), - )); - } - Ok::<_, Error>(ValueResponse { - value: parse_api_stdout(&out.stdout), + let args = api_args(&req); + let args_summary = events::summarize_args(&args); + let repo = events::repo_from_args(&args); + events::run_and_emit(&emitter, API_ID, args_summary, repo, async move { + let cfg = cell.read().await.clone(); + let stdin = match &req.body { + Some(v) => Some(serde_json::to_string(v).map_err(|e| { + Error::Handler(format!("github::api: body does not serialize: {e}")) + })?), + None => None, + }; + let out = gh::run(&cfg, &args, stdin, req.timeout_ms) + .await + .map_err(Error::from)?; + let out = expect_ok(API_ID, out, OK)?; + if out.stdout_truncated { + return Err(Error::Handler( + "github::api: response exceeded max_output_bytes and was truncated; \ + narrow it with jq, paginate less, or raise the cap" + .to_string(), + )); + } + Ok::<_, Error>(ValueResponse { + value: parse_api_stdout(&out.stdout), + }) }) + .await } }) .description(API_DESC), diff --git a/github/src/lib.rs b/github/src/lib.rs index 0b93ec3bb..f1e627034 100644 --- a/github/src/lib.rs +++ b/github/src/lib.rs @@ -6,6 +6,7 @@ pub mod config; pub mod configuration; +pub mod events; pub mod functions; pub mod gh; pub mod ui; diff --git a/github/src/main.rs b/github/src/main.rs index 5949705db..b7a8f4776 100644 --- a/github/src/main.rs +++ b/github/src/main.rs @@ -90,7 +90,10 @@ async fn main() -> Result<()> { } configuration::reconcile(&iii, &cell).await; - register_all(&iii, &cell); + // Register the `github::called` trigger type first so the emitter threaded + // through every function has a live subscriber set to fan out to. + let called = github::events::register_called_trigger(&iii); + register_all(&iii, &cell, &called); // Injectable console UI — after the github::* functions so the console can // attribute the assets. diff --git a/github/src/ui.rs b/github/src/ui.rs index f92cd7fa6..0a2f8b265 100644 --- a/github/src/ui.rs +++ b/github/src/ui.rs @@ -4,11 +4,12 @@ //! //! Ships two assets into any running console: //! -//! - `github/page.js` (`console:script`) — the `#/ext/github` page: an -//! owner/name field plus the pull-requests / issues / runs / releases / -//! search panels, each reading the live worker over `github::*` and -//! unwrapping its `{ value }` envelope. Read-only; mutations stay in agent -//! flows behind the approval gate. +//! - `github/page.js` (`console:script`) — the `#/ext/github` page: a live +//! ACTIVITY feed of what the agent does with the github worker. It binds a +//! tab-scoped `github::called` trigger (the type the worker registers in +//! `events.rs`) and renders each call — function id, arg echo, ok/error, +//! duration, and a short result summary. Read-only; a passive observer of +//! the bus, it invokes nothing. //! - `github/styles.css` (`console:style`) — the stylesheet, every rule //! scoped under `[data-iii-ui="github"]`; the console mounts it as a //! `` and link-swaps it on change, styles-before-scripts on boot. diff --git a/github/ui/page.tsx b/github/ui/page.tsx index d4307e77a..61f2dea94 100644 --- a/github/ui/page.tsx +++ b/github/ui/page.tsx @@ -6,11 +6,11 @@ * the console mounts and link-swaps it, styles-before-scripts on boot. * * `setup(host)` registers one contribution: - * - src/page/ — the `#/ext/github` browser: owner/name input plus the - * pull-requests / issues / runs / releases / search panels, each reading - * the live github worker over `github::*` and unwrapping its `{ value }` - * envelope. Read-only on purpose: mutations stay in agent flows behind - * the approval gate. + * - src/page/ — the `#/ext/github` ACTIVITY feed: a tab-scoped subscription + * to the worker's `github::called` trigger type that renders each github + * call as it finishes (function id, arg echo, ok/error + duration, and a + * short result summary). Read-only: the page observes the bus, it invokes + * nothing. * * Registrations go through `host` so the loader disposes them on hot * reload / worker disconnect. diff --git a/github/ui/src/page/IssuesPanel.tsx b/github/ui/src/page/IssuesPanel.tsx deleted file mode 100644 index 2061c12fd..000000000 --- a/github/ui/src/page/IssuesPanel.tsx +++ /dev/null @@ -1,95 +0,0 @@ -import { Badge, type Host } from '@iii-dev/console-ui' -import { useCallback, useState } from 'react' -import { - type GithubIssue, - ISSUE_STATE_FILTERS, - type IssueStateFilter, - listIssues, - timeAgoIso, -} from './github-data' -import { CircleDot, type IconProps } from './icons' -import { ModeToggle } from './ModeToggle' -import { PanelShell } from './PanelShell' -import { useGithubRead } from './useGithubRead' - -const STATE_OPTIONS: { value: IssueStateFilter; label: string }[] = - ISSUE_STATE_FILTERS.map((value) => ({ value, label: value })) - -const IssueIcon = (p: IconProps) => - -function issueBadge(issue: GithubIssue): { - label: string - variant: 'accent' | 'default' -} { - return issue.state.toLowerCase() === 'open' - ? { label: 'open', variant: 'accent' } - : { label: 'closed', variant: 'default' } -} - -interface IssuesPanelProps { - host: Host - repo: string - enabled: boolean -} - -export function IssuesPanel({ host, repo, enabled }: IssuesPanelProps) { - const [state, setState] = useState('open') - const fetcher = useCallback( - () => listIssues(host, repo, state), - [host, repo, state], - ) - const { data, loading, error } = useGithubRead(enabled, fetcher) - const issues = data ?? [] - - return ( -
                                  -
                                  - - value={state} - onChange={setState} - options={STATE_OPTIONS} - aria-label="issue state" - /> -
                                  - -
                                    - {issues.map((issue) => { - const badge = issueBadge(issue) - return ( -
                                  • - #{issue.number} - - {issue.title} - - - {issue.labels?.length ? ( - - {issue.labels.map((l) => l.name).join(', ')} - - ) : null} - - {issue.author?.login ?? ''} - {issue.updatedAt ? ` · ${timeAgoIso(issue.updatedAt)}` : ''} - - - {badge.label} -
                                  • - ) - })} -
                                  -
                                  -
                                  - ) -} diff --git a/github/ui/src/page/ModeToggle.tsx b/github/ui/src/page/ModeToggle.tsx deleted file mode 100644 index ea049a746..000000000 --- a/github/ui/src/page/ModeToggle.tsx +++ /dev/null @@ -1,50 +0,0 @@ -/** - * Segmented control for view / filter switching — a self-contained port of the - * console's `ModeToggle` (tabs variant). `@iii-dev/console-ui` exports no - * segmented control, so the handful of styles live in styles.css scoped under - * `[data-iii-ui="github"]`. Semantic `tablist` so screen readers announce the - * options as tabs. - */ - -import type { ReactNode } from 'react' - -export interface ModeToggleOption { - value: T - label: ReactNode - title?: string -} - -interface ModeToggleProps { - value: T - onChange: (next: T) => void - options: ModeToggleOption[] - 'aria-label'?: string -} - -export function ModeToggle({ - value, - onChange, - options, - 'aria-label': ariaLabel, -}: ModeToggleProps) { - return ( -
                                  - {options.map((opt) => { - const active = opt.value === value - return ( - - ) - })} -
                                  - ) -} diff --git a/github/ui/src/page/PanelShell.tsx b/github/ui/src/page/PanelShell.tsx deleted file mode 100644 index 6120692b1..000000000 --- a/github/ui/src/page/PanelShell.tsx +++ /dev/null @@ -1,52 +0,0 @@ -import { EmptyState, StatusPanel } from '@iii-dev/console-ui' -import type { ReactNode } from 'react' -import { AlertCircle, type IconProps } from './icons' - -interface PanelShellProps { - loading: boolean - error: string | null - empty: boolean - emptyIcon: (props: IconProps) => ReactNode - emptyTitle: string - emptyDescription: string - children: ReactNode -} - -/** - * Shared error / first-load / empty scaffolding for the github panels. Worker - * errors carry gh's own stderr (auth failures, 404s), so the alert detail is - * already the actionable message. - */ -export function PanelShell({ - loading, - error, - empty, - emptyIcon, - emptyTitle, - emptyDescription, - children, -}: PanelShellProps) { - if (error) { - return ( - } - headline="github call failed" - detail={error} - /> - ) - } - if (loading && empty) { - return

                                  loading…

                                  - } - if (empty) { - return ( - - ) - } - return <>{children} -} diff --git a/github/ui/src/page/PrsPanel.tsx b/github/ui/src/page/PrsPanel.tsx deleted file mode 100644 index 47a532a7f..000000000 --- a/github/ui/src/page/PrsPanel.tsx +++ /dev/null @@ -1,183 +0,0 @@ -import { Badge, type Host } from '@iii-dev/console-ui' -import { useCallback, useState } from 'react' -import { - type GithubPr, - type GithubPrCheck, - listPrChecks, - listPrs, - PR_STATE_FILTERS, - type PrStateFilter, - timeAgoIso, -} from './github-data' -import { GitPullRequest, type IconProps } from './icons' -import { ModeToggle } from './ModeToggle' -import { PanelShell } from './PanelShell' -import { useGithubRead } from './useGithubRead' - -const STATE_OPTIONS: { value: PrStateFilter; label: string }[] = - PR_STATE_FILTERS.map((value) => ({ value, label: value })) - -const PrIcon = (p: IconProps) => - -type BadgeVariant = 'default' | 'warn' | 'alert' | 'accent' - -function prBadge(pr: GithubPr): { label: string; variant: BadgeVariant } { - const state = pr.state.toLowerCase() - if (pr.isDraft && state === 'open') - return { label: 'draft', variant: 'default' } - if (state === 'open') return { label: 'open', variant: 'accent' } - if (state === 'merged') return { label: 'merged', variant: 'default' } - if (state === 'closed') return { label: 'closed', variant: 'alert' } - return { label: state, variant: 'default' } -} - -function checkVariant(bucket: string): BadgeVariant { - switch (bucket) { - case 'pass': - return 'accent' - case 'fail': - return 'alert' - case 'pending': - return 'warn' - default: - return 'default' - } -} - -interface PrsPanelProps { - host: Host - repo: string - enabled: boolean -} - -/** Pull requests for the selected repo; a row expands its CI check rollup. */ -export function PrsPanel({ host, repo, enabled }: PrsPanelProps) { - const [state, setState] = useState('open') - const [expanded, setExpanded] = useState(null) - const fetcher = useCallback( - () => listPrs(host, repo, state), - [host, repo, state], - ) - const { data, loading, error } = useGithubRead(enabled, fetcher) - const prs = data ?? [] - - return ( -
                                  -
                                  - - value={state} - onChange={setState} - options={STATE_OPTIONS} - aria-label="pull request state" - /> -
                                  - -
                                    - {prs.map((pr) => { - const badge = prBadge(pr) - const isExpanded = expanded === pr.number - return ( -
                                  • -
                                    - - - {pr.title} - - - {pr.author?.login ?? ''} - {pr.updatedAt ? ` · ${timeAgoIso(pr.updatedAt)}` : ''} - - {badge.label} -
                                    - {isExpanded ? ( - - ) : null} -
                                  • - ) - })} -
                                  -
                                  -
                                  - ) -} - -function ChecksInline({ - host, - repo, - number, -}: { - host: Host - repo: string - number: number -}) { - const fetcher = useCallback( - () => listPrChecks(host, repo, number), - [host, repo, number], - ) - const { data, loading, error } = useGithubRead(true, fetcher) - const checks = data ?? [] - - return ( -
                                  - {error ? ( -

                                  {error}

                                  - ) : loading && checks.length === 0 ? ( -

                                  loading checks…

                                  - ) : checks.length === 0 ? ( -

                                  no checks reported

                                  - ) : ( -
                                    - {checks.map((check) => ( -
                                  • - {check.bucket} - {check.link ? ( - - {checkLabel(check)} - - ) : ( - {checkLabel(check)} - )} -
                                  • - ))} -
                                  - )} -
                                  - ) -} - -function checkLabel(check: GithubPrCheck): string { - return check.workflow ? `${check.workflow} / ${check.name}` : check.name -} - -function checkKey(check: GithubPrCheck): string { - return `${check.workflow ?? ''}/${check.name}/${check.startedAt ?? ''}` -} diff --git a/github/ui/src/page/ReleasesPanel.tsx b/github/ui/src/page/ReleasesPanel.tsx deleted file mode 100644 index 7bc54cf41..000000000 --- a/github/ui/src/page/ReleasesPanel.tsx +++ /dev/null @@ -1,55 +0,0 @@ -import { Badge, type Host } from '@iii-dev/console-ui' -import { useCallback } from 'react' -import { listReleases, releaseUrl, timeAgoIso } from './github-data' -import { type IconProps, Tag } from './icons' -import { PanelShell } from './PanelShell' -import { useGithubRead } from './useGithubRead' - -const ReleaseIcon = (p: IconProps) => - -interface ReleasesPanelProps { - host: Host - repo: string - enabled: boolean -} - -export function ReleasesPanel({ host, repo, enabled }: ReleasesPanelProps) { - const fetcher = useCallback(() => listReleases(host, repo), [host, repo]) - const { data, loading, error } = useGithubRead(enabled, fetcher) - const releases = data ?? [] - - return ( - -
                                    - {releases.map((release) => ( -
                                  • - - {release.tagName} - - - {release.name ?? ''} - - - {timeAgoIso(release.publishedAt ?? release.createdAt)} - - {release.isLatest ? latest : null} - {release.isDraft ? draft : null} - {release.isPrerelease ? prerelease : null} -
                                  • - ))} -
                                  -
                                  - ) -} diff --git a/github/ui/src/page/RunsPanel.tsx b/github/ui/src/page/RunsPanel.tsx deleted file mode 100644 index 8f6affb0c..000000000 --- a/github/ui/src/page/RunsPanel.tsx +++ /dev/null @@ -1,83 +0,0 @@ -import { Badge, type Host } from '@iii-dev/console-ui' -import { useCallback } from 'react' -import { type GithubRun, listRuns, timeAgoIso } from './github-data' -import { type IconProps, Workflow } from './icons' -import { PanelShell } from './PanelShell' -import { useGithubRead } from './useGithubRead' - -const RunIcon = (p: IconProps) => - -type BadgeVariant = 'default' | 'warn' | 'alert' | 'accent' - -function runBadge(run: GithubRun): { label: string; variant: BadgeVariant } { - const status = (run.status ?? '').toLowerCase() - if (status && status !== 'completed') { - return { label: status.replace(/_/g, ' '), variant: 'warn' } - } - const conclusion = (run.conclusion ?? '').toLowerCase() - if (conclusion === 'success') return { label: 'success', variant: 'accent' } - if ( - conclusion === 'failure' || - conclusion === 'timed_out' || - conclusion === 'startup_failure' - ) { - return { label: conclusion.replace(/_/g, ' '), variant: 'alert' } - } - return { label: conclusion || 'completed', variant: 'default' } -} - -interface RunsPanelProps { - host: Host - repo: string - enabled: boolean -} - -/** GitHub Actions runs, newest first (worker default order from gh). */ -export function RunsPanel({ host, repo, enabled }: RunsPanelProps) { - const fetcher = useCallback(() => listRuns(host, repo), [host, repo]) - const { data, loading, error } = useGithubRead(enabled, fetcher) - const runs = data ?? [] - - return ( - -
                                    - {runs.map((run) => { - const badge = runBadge(run) - const meta = [ - run.workflowName, - run.headBranch ?? undefined, - timeAgoIso(run.updatedAt ?? run.createdAt) || undefined, - ] - .filter(Boolean) - .join(' · ') - const title = run.displayTitle ?? run.name ?? String(run.databaseId) - return ( -
                                  • - {run.url ? ( - - {title} - - ) : ( - {title} - )} - {meta} - {badge.label} -
                                  • - ) - })} -
                                  -
                                  - ) -} diff --git a/github/ui/src/page/SearchPanel.tsx b/github/ui/src/page/SearchPanel.tsx deleted file mode 100644 index d9a7c3b46..000000000 --- a/github/ui/src/page/SearchPanel.tsx +++ /dev/null @@ -1,185 +0,0 @@ -import { Badge, type Host, Input } from '@iii-dev/console-ui' -import { useCallback, useState } from 'react' -import { - type GithubSearchItems, - type SearchKind, - searchGithub, - timeAgoIso, -} from './github-data' -import { type IconProps, Search } from './icons' -import { ModeToggle } from './ModeToggle' -import { PanelShell } from './PanelShell' -import { useGithubRead } from './useGithubRead' - -const KIND_OPTIONS: { value: SearchKind; label: string }[] = [ - { value: 'repos', label: 'repos' }, - { value: 'issues', label: 'issues' }, - { value: 'prs', label: 'prs' }, - { value: 'code', label: 'code' }, -] - -const SearchIcon = (p: IconProps) => - -interface SearchPanelProps { - host: Host - enabled: boolean -} - -/** - * Org-wide GitHub search. Repo scoping happens inside the query itself - * (`repo:owner/name ...`), so this panel ignores the page's repo field. - */ -export function SearchPanel({ host, enabled }: SearchPanelProps) { - const [kind, setKind] = useState('repos') - const [queryInput, setQueryInput] = useState('') - const [query, setQuery] = useState('') - - const fetcher = useCallback( - () => searchGithub(host, kind, query), - [host, kind, query], - ) - const active = enabled && query !== '' - const { data, loading, error } = useGithubRead(active, fetcher) - - return ( -
                                  -
                                  - { - if (e.key === 'Enter') setQuery(queryInput.trim()) - }} - placeholder='search query, e.g. "repo:iii-hq/workers is:open"' - preserveCase - aria-label="search query" - className="gh-search-input" - /> - - value={kind} - onChange={setKind} - options={KIND_OPTIONS} - aria-label="search kind" - /> -
                                  - {query === '' ? ( -

                                  - type a query and press enter — github search syntax, qualifiers - included -

                                  - ) : ( - - {data ? : null} - - )} -
                                  - ) -} - -function isEmpty(results: GithubSearchItems | null): boolean { - return !results || results.items.length === 0 -} - -function SearchResults({ results }: { results: GithubSearchItems }) { - if (results.kind === 'repos') { - return ( -
                                    - {results.items.map((repo) => ( -
                                  • - - {repo.fullName} - - - {repo.description ?? ''} - - - {[ - repo.language ?? undefined, - repo.stargazersCount != null - ? `${repo.stargazersCount}★` - : undefined, - timeAgoIso(repo.updatedAt) || undefined, - ] - .filter(Boolean) - .join(' · ')} - -
                                  • - ))} -
                                  - ) - } - if (results.kind === 'code') { - return ( -
                                    - {results.items.map((hit) => ( -
                                  • - {hit.url ? ( - - {hit.path} - - ) : ( - {hit.path} - )} - - {hit.repository?.nameWithOwner ?? ''} - -
                                  • - ))} -
                                  - ) - } - return ( -
                                    - {results.items.map((item) => ( -
                                  • - #{item.number} - - {item.title} - - - {[ - item.repository?.nameWithOwner ?? undefined, - item.author?.login ?? undefined, - timeAgoIso(item.updatedAt) || undefined, - ] - .filter(Boolean) - .join(' · ')} - - - {item.state.toLowerCase()} - -
                                  • - ))} -
                                  - ) -} diff --git a/github/ui/src/page/github-data.ts b/github/ui/src/page/github-data.ts deleted file mode 100644 index c42c5aabc..000000000 --- a/github/ui/src/page/github-data.ts +++ /dev/null @@ -1,352 +0,0 @@ -/** - * Read-only RPC surface for the injected github page: typed wrappers over the - * optional `github` worker (the gh CLI as `github::*` functions) — the - * list/search surface the page renders. Every wrapper unwraps the worker's - * `{ value }` envelope and parses rows with a tolerant zod schema (unknown - * fields ignored, invalid rows dropped). - * - * Ported from the console's `lib/github.ts`: the function ids, payload shapes, - * and zod parsing are verbatim; only the transport changed — every call now - * takes the tab's `host` and routes through `host.iii.trigger(...)` instead of - * a console-internal iii client. - * - * Mutating `github::*` functions are deliberately NOT wrapped here — writes - * stay in agent flows where the approval gate reviews them. - */ - -import type { Host } from '@iii-dev/console-ui' -import { z } from 'zod' - -export const GITHUB_PR_LIST_FN = 'github::pr::list' -export const GITHUB_PR_CHECKS_FN = 'github::pr::checks' -export const GITHUB_ISSUE_LIST_FN = 'github::issue::list' -export const GITHUB_RUN_LIST_FN = 'github::run::list' -export const GITHUB_RELEASE_LIST_FN = 'github::release::list' - -export const GITHUB_SEARCH_FNS = { - repos: 'github::search::repos', - issues: 'github::search::issues', - prs: 'github::search::prs', - code: 'github::search::code', -} as const -export type SearchKind = keyof typeof GITHUB_SEARCH_FNS - -export const PR_STATE_FILTERS = ['open', 'merged', 'closed', 'all'] as const -export type PrStateFilter = (typeof PR_STATE_FILTERS)[number] - -export const ISSUE_STATE_FILTERS = ['open', 'closed', 'all'] as const -export type IssueStateFilter = (typeof ISSUE_STATE_FILTERS)[number] - -// ---- wire schemas (loose: gh adds fields freely; rows must not vanish -// because an optional field changed shape) ---- - -const authorSchema = z - .object({ login: z.string().optional() }) - .nullable() - .optional() - -const labelSchema = z.object({ - name: z.string(), - color: z.string().optional(), -}) - -const prSchema = z.object({ - number: z.number(), - title: z.string(), - state: z.string(), - url: z.string(), - author: authorSchema, - headRefName: z.string().optional(), - baseRefName: z.string().optional(), - isDraft: z.boolean().optional(), - labels: z.array(labelSchema).optional(), - createdAt: z.string().optional(), - updatedAt: z.string().optional(), -}) -export type GithubPr = z.infer - -const prCheckSchema = z.object({ - bucket: z.string(), - name: z.string(), - state: z.string().optional(), - link: z.string().nullable().optional(), - workflow: z.string().nullable().optional(), - description: z.string().nullable().optional(), - startedAt: z.string().nullable().optional(), - completedAt: z.string().nullable().optional(), -}) -export type GithubPrCheck = z.infer - -const issueSchema = z.object({ - number: z.number(), - title: z.string(), - state: z.string(), - url: z.string(), - author: authorSchema, - labels: z.array(labelSchema).optional(), - assignees: z.array(z.object({ login: z.string().optional() })).optional(), - milestone: z.object({ title: z.string().optional() }).nullable().optional(), - createdAt: z.string().optional(), - updatedAt: z.string().optional(), -}) -export type GithubIssue = z.infer - -const runSchema = z.object({ - databaseId: z.number(), - number: z.number().optional(), - displayTitle: z.string().optional(), - name: z.string().optional(), - workflowName: z.string().optional(), - headBranch: z.string().nullable().optional(), - headSha: z.string().optional(), - event: z.string().optional(), - status: z.string().optional(), - conclusion: z.string().nullable().optional(), - attempt: z.number().optional(), - createdAt: z.string().optional(), - startedAt: z.string().optional(), - updatedAt: z.string().optional(), - url: z.string().optional(), -}) -export type GithubRun = z.infer - -const releaseSchema = z.object({ - tagName: z.string(), - name: z.string().nullable().optional(), - isDraft: z.boolean().optional(), - isLatest: z.boolean().optional(), - isPrerelease: z.boolean().optional(), - createdAt: z.string().optional(), - publishedAt: z.string().nullable().optional(), -}) -export type GithubRelease = z.infer - -const searchRepoSchema = z.object({ - fullName: z.string(), - description: z.string().nullable().optional(), - url: z.string(), - visibility: z.string().optional(), - isArchived: z.boolean().optional(), - isFork: z.boolean().optional(), - stargazersCount: z.number().optional(), - forksCount: z.number().optional(), - language: z.string().nullable().optional(), - updatedAt: z.string().optional(), -}) -export type GithubSearchRepo = z.infer - -const searchRepositoryRefSchema = z - .object({ - nameWithOwner: z.string().optional(), - name: z.string().optional(), - }) - .optional() - -const searchIssueSchema = z.object({ - number: z.number(), - title: z.string(), - state: z.string(), - url: z.string(), - repository: searchRepositoryRefSchema, - author: authorSchema, - labels: z.array(labelSchema).optional(), - commentsCount: z.number().optional(), - createdAt: z.string().optional(), - updatedAt: z.string().optional(), - isDraft: z.boolean().optional(), -}) -export type GithubSearchIssue = z.infer - -const searchCodeSchema = z.object({ - path: z.string(), - repository: searchRepositoryRefSchema, - sha: z.string().optional(), - url: z.string().optional(), - textMatches: z.array(z.unknown()).optional(), -}) -export type GithubSearchCode = z.infer - -// ---- parsing ---- - -const valueEnvelopeSchema = z.object({ value: z.unknown() }) - -/** Unwrap the worker's `ValueResponse { value }` envelope. */ -export function unwrapValue(res: unknown): unknown { - const parsed = valueEnvelopeSchema.safeParse(res) - return parsed.success ? parsed.data.value : null -} - -function parseArray(value: unknown, schema: z.ZodType): T[] { - if (!Array.isArray(value)) return [] - return value.flatMap((item) => { - const parsed = schema.safeParse(item) - return parsed.success ? [parsed.data] : [] - }) -} - -function parsePrs(value: unknown): GithubPr[] { - return parseArray(value, prSchema) -} -function parsePrChecks(value: unknown): GithubPrCheck[] { - return parseArray(value, prCheckSchema) -} -function parseIssues(value: unknown): GithubIssue[] { - return parseArray(value, issueSchema) -} -function parseRuns(value: unknown): GithubRun[] { - return parseArray(value, runSchema) -} -function parseReleases(value: unknown): GithubRelease[] { - return parseArray(value, releaseSchema) -} - -export type GithubSearchItems = - | { kind: 'repos'; items: GithubSearchRepo[] } - | { kind: 'issues'; items: GithubSearchIssue[] } - | { kind: 'prs'; items: GithubSearchIssue[] } - | { kind: 'code'; items: GithubSearchCode[] } - -function parseSearchItems(kind: SearchKind, value: unknown): GithubSearchItems { - switch (kind) { - case 'repos': - return { kind, items: parseArray(value, searchRepoSchema) } - case 'issues': - return { kind, items: parseArray(value, searchIssueSchema) } - case 'prs': - return { kind, items: parseArray(value, searchIssueSchema) } - case 'code': - return { kind, items: parseArray(value, searchCodeSchema) } - } -} - -// ---- RPC wrappers ---- - -const LIST_LIMIT = 30 - -async function triggerValue( - host: Host, - fnId: string, - payload: Record, -): Promise { - const res = await host.iii.trigger(fnId, payload) - return unwrapValue(res) -} - -export async function listPrs( - host: Host, - repo: string, - state: PrStateFilter, -): Promise { - return parsePrs( - await triggerValue(host, GITHUB_PR_LIST_FN, { - repo, - state, - limit: LIST_LIMIT, - }), - ) -} - -export async function listPrChecks( - host: Host, - repo: string, - number: number, -): Promise { - return parsePrChecks( - await triggerValue(host, GITHUB_PR_CHECKS_FN, { repo, number }), - ) -} - -export async function listIssues( - host: Host, - repo: string, - state: IssueStateFilter, -): Promise { - return parseIssues( - await triggerValue(host, GITHUB_ISSUE_LIST_FN, { - repo, - state, - limit: LIST_LIMIT, - }), - ) -} - -export async function listRuns(host: Host, repo: string): Promise { - return parseRuns( - await triggerValue(host, GITHUB_RUN_LIST_FN, { repo, limit: LIST_LIMIT }), - ) -} - -export async function listReleases( - host: Host, - repo: string, -): Promise { - return parseReleases( - await triggerValue(host, GITHUB_RELEASE_LIST_FN, { - repo, - limit: LIST_LIMIT, - }), - ) -} - -export async function searchGithub( - host: Host, - kind: SearchKind, - query: string, -): Promise { - return parseSearchItems( - kind, - await triggerValue(host, GITHUB_SEARCH_FNS[kind], { - query, - limit: LIST_LIMIT, - }), - ) -} - -// ---- small view helpers ---- - -/** The releases list payload carries no url; build the canonical one. */ -export function releaseUrl(repo: string, tagName: string): string { - return `https://github.com/${repo}/releases/tag/${encodeURIComponent(tagName)}` -} - -/** Compact relative time for gh's ISO timestamps ("3h ago"). */ -export function timeAgoIso( - iso: string | null | undefined, - now = Date.now(), -): string { - if (!iso) return '' - const t = Date.parse(iso) - if (Number.isNaN(t)) return '' - const s = Math.max(0, Math.floor((now - t) / 1000)) - if (s < 60) return `${s}s ago` - const m = Math.floor(s / 60) - if (m < 60) return `${m}m ago` - const h = Math.floor(m / 60) - if (h < 24) return `${h}h ago` - const d = Math.floor(h / 24) - if (d < 30) return `${d}d ago` - const mo = Math.floor(d / 30) - if (mo < 12) return `${mo}mo ago` - return `${Math.floor(mo / 12)}y ago` -} - -// ---- repo persistence (UI affordance only) ---- - -const GITHUB_REPO_KEY = 'iii-github-repo' - -export function loadGithubRepo(): string { - try { - return localStorage.getItem(GITHUB_REPO_KEY) ?? '' - } catch { - return '' - } -} - -export function saveGithubRepo(repo: string): void { - try { - if (repo) localStorage.setItem(GITHUB_REPO_KEY, repo) - else localStorage.removeItem(GITHUB_REPO_KEY) - } catch { - // storage unavailable; the field just won't persist - } -} diff --git a/github/ui/src/page/icons.tsx b/github/ui/src/page/icons.tsx index db80e027b..1326401df 100644 --- a/github/ui/src/page/icons.tsx +++ b/github/ui/src/page/icons.tsx @@ -131,3 +131,11 @@ export function Search(props: IconProps) { ) } + +export function Activity(props: IconProps) { + return ( + + + + ) +} diff --git a/github/ui/src/page/index.tsx b/github/ui/src/page/index.tsx index fad447475..7eb2b27c3 100644 --- a/github/ui/src/page/index.tsx +++ b/github/ui/src/page/index.tsx @@ -1,113 +1,238 @@ /** - * The github page (#/ext/github): the github worker's read surface — pull - * requests (with CI check rollups), issues, Actions runs, and releases for a - * chosen repo, plus org-wide search. Data loads on demand (repo commit / panel - * switch / refresh) — no polling; GitHub-side state isn't engine-event-driven - * and gh rate limits are real. Read-only on purpose: mutations stay in agent - * flows behind the approval gate. + * The github page (#/ext/github): a live ACTIVITY feed of what the agent does + * with the github worker. It binds a tab-scoped subscription to the worker's + * `github::called` trigger type and appends one row per call — no owner/name + * to type, no polling: the worker pushes each event as it finishes. * - * The host only mounts this page when the github worker is connected, so there - * is no presence gate here; a failed `github::*` call surfaces per panel as the - * "github call failed" alert. + * Each row shows the function id (badge), a one-line arg echo, an ok/error dot + * with the call duration, and a relative timestamp; clicking a row expands the + * short result summary the worker derived. A live/paused toggle stops appending + * new events, and clear empties the (bounded, newest-first) list. + * + * The host only mounts this page while the github worker is connected, so there + * is no presence gate; this page invokes nothing — it is a passive observer of + * the bus. */ -import { Button, EmptyState, type Host, Input } from '@iii-dev/console-ui' -import { useState } from 'react' -import { loadGithubRepo, saveGithubRepo } from './github-data' -import { IssuesPanel } from './IssuesPanel' -import { FolderGit2, type IconProps, RefreshCw } from './icons' -import { ModeToggle } from './ModeToggle' -import { PrsPanel } from './PrsPanel' -import { ReleasesPanel } from './ReleasesPanel' -import { RunsPanel } from './RunsPanel' -import { SearchPanel } from './SearchPanel' +import { Badge, Button, EmptyState, type Host, StatusDot } from '@iii-dev/console-ui' +import { useCallback, useEffect, useRef, useState } from 'react' +import { Activity, type IconProps } from './icons' -type Panel = 'prs' | 'issues' | 'runs' | 'releases' | 'search' +/** The worker's trigger type; see github/src/events.rs. */ +const CALLED_TYPE = 'github::called' +/** Per-tab handler id — the `iii::` prefix keeps per-event invocations + * span-suppressed and out of the trace feed (host.iii.on namespaces it + * `::`, which the trigger's function_id must match). */ +const EVENTS_FN = 'iii::github-ui::called' +/** Newest-first cap so a long-running session can't grow the list unbounded. */ +const MAX_ENTRIES = 200 -const PANEL_OPTIONS: { value: Panel; label: string }[] = [ - { value: 'prs', label: 'pull requests' }, - { value: 'issues', label: 'issues' }, - { value: 'runs', label: 'runs' }, - { value: 'releases', label: 'releases' }, - { value: 'search', label: 'search' }, -] +/** The `github::called` payload the worker emits (github/src/events.rs). */ +interface CalledEvent { + function_id: string + args_summary: string + repo: string | null + ok: boolean + duration_ms: number + result_summary: string + timestamp: string +} -const RepoIcon = (p: IconProps) => +interface Entry extends CalledEvent { + /** Stable react key + expansion id. */ + key: string + /** Arrival time (ms) for the relative timestamp. */ + receivedAt: number +} -export function GithubPage({ host }: { host: Host }) { - const [panel, setPanel] = useState('prs') - const [repoInput, setRepoInput] = useState(loadGithubRepo) - const [repo, setRepo] = useState(loadGithubRepo) - const [bump, setBump] = useState(0) +const ActivityIcon = (p: IconProps) => - const commitRepo = () => { - const next = repoInput.trim() - setRepo(next) - saveGithubRepo(next) - } +/** + * Register ONE tab-scoped binding to `github::called` for the page's lifetime + * and collect events into a bounded, newest-first list. Paused drops incoming + * events (checked via a ref so the subscription never re-registers). The + * binding is unregistered on unmount — hot reload disposes it with the page. + */ +function useCalledFeed(host: Host) { + const [entries, setEntries] = useState([]) + const [paused, setPaused] = useState(false) + const pausedRef = useRef(paused) + pausedRef.current = paused + const seq = useRef(0) - const needsRepo = panel !== 'search' + useEffect(() => { + const offHandler = host.iii.on(EVENTS_FN, (event) => { + if (pausedRef.current) return + if (!event || typeof event.function_id !== 'string') return + const entry: Entry = { + function_id: event.function_id, + args_summary: event.args_summary ?? '', + repo: event.repo ?? null, + ok: Boolean(event.ok), + duration_ms: Number(event.duration_ms) || 0, + result_summary: event.result_summary ?? '', + timestamp: event.timestamp ?? '', + key: `${Date.now()}-${seq.current++}`, + receivedAt: Date.now(), + } + setEntries((prev) => [entry, ...prev].slice(0, MAX_ENTRIES)) + }) + const offTrigger = host.iii.registerTrigger({ + type: CALLED_TYPE, + function_id: `${EVENTS_FN}::${host.iii.browserId}`, + config: {}, + }) + return () => { + offTrigger() + offHandler() + } + }, [host]) + + const clear = useCallback(() => setEntries([]), []) + return { entries, clear, paused, setPaused } +} + +export function GithubPage({ host }: { host: Host }) { + const { entries, clear, paused, setPaused } = useCalledFeed(host) + const [expanded, setExpanded] = useState(null) + + // Re-render on a slow tick so "Ns ago" stays fresh without per-row timers. + const [, forceTick] = useState(0) + useEffect(() => { + const id = window.setInterval(() => forceTick((n) => n + 1), 15000) + return () => window.clearInterval(id) + }, []) + const now = Date.now() return (
                                  -
                                  github
                                  -
                                  {repo || 'no repository selected'}
                                  +
                                  github activity
                                  +
                                  + {entries.length + ? `${entries.length} recent call${entries.length === 1 ? '' : 's'}` + : 'live feed of github worker calls'} +
                                  +
                                  +
                                  + +
                                  -
                                  -
                                  - {needsRepo ? ( - { - if (e.key === 'Enter') commitRepo() - }} - placeholder="owner/name" - preserveCase - aria-label="repository" - /> - ) : null} - - value={panel} - onChange={setPanel} - options={PANEL_OPTIONS} - aria-label="github panel" + {entries.length === 0 ? ( + -
                                  - -
                                  - {needsRepo && repo === '' ? ( - - ) : ( - // Remount on repo/panel/refresh so every fetch hook restarts clean. -
                                  - {panel === 'prs' ? ( - - ) : panel === 'issues' ? ( - - ) : panel === 'runs' ? ( - - ) : panel === 'releases' ? ( - - ) : ( - - )} -
                                  - )} -
                                  + ) : ( + + + {entries.map((entry) => ( + + setExpanded((cur) => (cur === entry.key ? null : entry.key)) + } + /> + ))} + +
                                  + )}
                                  ) } + +function ActivityRow({ + entry, + now, + expanded, + onToggle, +}: { + entry: Entry + now: number + expanded: boolean + onToggle: () => void +}) { + const onKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault() + onToggle() + } + } + return ( + <> + + + {entry.function_id} + + + {entry.repo ? {entry.repo} : null} + {entry.args_summary || '—'} + + + + + {entry.ok ? 'ok' : 'error'} + + {formatDuration(entry.duration_ms)} + + + {relativeTime(entry.receivedAt, now)} + + + {expanded ? ( + + +
                                  + {entry.result_summary || '(no result summary)'} +
                                  + + + ) : null} + + ) +} + +function formatDuration(ms: number): string { + if (ms < 1000) return `${Math.round(ms)}ms` + return `${(ms / 1000).toFixed(ms < 10000 ? 1 : 0)}s` +} + +function relativeTime(then: number, now: number): string { + const s = Math.max(0, Math.floor((now - then) / 1000)) + if (s < 5) return 'just now' + if (s < 60) return `${s}s ago` + const m = Math.floor(s / 60) + if (m < 60) return `${m}m ago` + const h = Math.floor(m / 60) + if (h < 24) return `${h}h ago` + return `${Math.floor(h / 24)}d ago` +} diff --git a/github/ui/src/page/useGithubRead.ts b/github/ui/src/page/useGithubRead.ts deleted file mode 100644 index 3005371f8..000000000 --- a/github/ui/src/page/useGithubRead.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { useCallback, useEffect, useState } from 'react' - -/** - * One in-flight github read: run `fetcher` while `enabled`, re-run when the - * fetcher identity changes (callers memo it on repo/filter deps) or on - * `refresh`. No polling and no live trigger bindings on purpose — the data - * changes on GitHub's side, not the engine's, so the page refreshes on demand - * instead of hammering the gh rate limit. - */ - -export interface GithubRead { - data: T | null - loading: boolean - error: string | null - refresh: () => void -} - -export function useGithubRead( - enabled: boolean, - fetcher: () => Promise, -): GithubRead { - const [data, setData] = useState(null) - const [loading, setLoading] = useState(enabled) - const [error, setError] = useState(null) - const [token, setToken] = useState(0) - - const refresh = useCallback(() => setToken((t) => t + 1), []) - - // `token` is a re-run token (bumped by manual refresh), not read by the - // effect body — it only needs to be in the dependency list. - useEffect(() => { - if (!enabled) { - setData(null) - setLoading(false) - setError(null) - return - } - setLoading(true) - let cancelled = false - void (async () => { - try { - const next = await fetcher() - if (cancelled) return - setData(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, fetcher, token]) - - return { data, loading, error, refresh } -} diff --git a/github/ui/styles.css b/github/ui/styles.css index 45728bc1d..bbbda4c9d 100644 --- a/github/ui/styles.css +++ b/github/ui/styles.css @@ -44,237 +44,125 @@ color: var(--color-ink-faint); margin-top: 2px; text-transform: lowercase; - word-break: break-all; } - -/* --- controls row ---------------------------------------------------- */ -[data-iii-ui="github"] .gh-controls { +[data-iii-ui="github"] .gh-head-actions { display: flex; - flex-wrap: wrap; align-items: center; - gap: 12px; - padding: 12px 0; - border-bottom: 1px solid var(--color-rule); -} -[data-iii-ui="github"] .gh-repo-input { - width: 16rem; - max-width: 100%; -} -[data-iii-ui="github"] .gh-search-bar { - display: flex; - flex-wrap: wrap; - align-items: center; - gap: 12px; -} -[data-iii-ui="github"] .gh-search-input { - width: 20rem; - max-width: 100%; + gap: 8px; } -/* --- mode toggle (segmented control) --------------------------------- */ -[data-iii-ui="github"] .gh-modes { - display: inline-flex; - border: 1px solid var(--color-rule); - padding: 2px; -} -[data-iii-ui="github"] .gh-mode { - appearance: none; - background: transparent; - border: 0; - color: var(--color-ink-faint); - font: inherit; - font-size: 13px; - text-transform: lowercase; - padding: 4px 12px; +/* --- activity feed --------------------------------------------------- */ +[data-iii-ui="github"] .gh-feed { + width: 100%; + margin-top: 12px; + border-collapse: collapse; + table-layout: fixed; +} +[data-iii-ui="github"] .gh-feed-row { cursor: pointer; - transition: color 0.12s, background 0.12s; + border-bottom: 1px solid var(--color-rule); + transition: background 0.12s; } -[data-iii-ui="github"] .gh-mode:hover { - color: var(--color-ink); +[data-iii-ui="github"] .gh-feed-row:hover, +[data-iii-ui="github"] .gh-feed-row.expanded { + background: var(--color-panel); } -[data-iii-ui="github"] .gh-mode.active { - background: var(--color-ink); - color: var(--color-bg); +[data-iii-ui="github"] .gh-feed-row:focus-visible { + outline: 2px solid var(--color-ring); + outline-offset: -2px; } - -/* --- body / panels --------------------------------------------------- */ -[data-iii-ui="github"] .gh-body { - padding-top: 16px; -} -[data-iii-ui="github"] .gh-panel { - display: flex; - flex-direction: column; - gap: 12px; +[data-iii-ui="github"] .gh-feed td { + padding: 8px 10px; + vertical-align: middle; } -[data-iii-ui="github"] .gh-msg { - font-size: 12px; - color: var(--color-ink-faint); - text-transform: lowercase; -} -[data-iii-ui="github"] .gh-pulse { - color: var(--color-ink-ghost); - animation: gh-ui-pulse 1.6s ease-in-out infinite; -} -@keyframes gh-ui-pulse { - 0%, - 100% { - opacity: 1; - } - 50% { - opacity: 0.45; - } +[data-iii-ui="github"] .gh-feed-fn { + width: 12rem; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; } -/* --- list rows ------------------------------------------------------- */ -[data-iii-ui="github"] .gh-list { - list-style: none; - margin: 0; - padding: 0; - display: flex; - flex-direction: column; -} -[data-iii-ui="github"] .gh-row-outer { - border-bottom: 1px solid var(--color-rule); -} -[data-iii-ui="github"] .gh-row-outer:last-child { - border-bottom: 0; -} -[data-iii-ui="github"] .gh-row { +[data-iii-ui="github"] .gh-feed-args { + min-width: 0; display: flex; - align-items: center; - gap: 12px; - padding: 8px 0; -} -[data-iii-ui="github"] .gh-list > .gh-row { - border-bottom: 1px solid var(--color-rule); -} -[data-iii-ui="github"] .gh-list > .gh-row:last-child { - border-bottom: 0; + align-items: baseline; + gap: 8px; + overflow: hidden; } - -[data-iii-ui="github"] .gh-num { +[data-iii-ui="github"] .gh-feed-repo { font-size: 11px; color: var(--color-ink-faint); - width: 3.5rem; flex-shrink: 0; } -[data-iii-ui="github"] .gh-num-btn { - appearance: none; - background: transparent; - border: 0; - font: inherit; - text-align: left; - cursor: pointer; -} -[data-iii-ui="github"] .gh-num-btn:hover { +[data-iii-ui="github"] .gh-feed-argtext { + font-size: 12px; color: var(--color-ink); -} - -[data-iii-ui="github"] .gh-row-title { - flex: 1; - min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; - font-size: 13px; - color: var(--color-ink); - text-decoration: none; - transition: color 0.12s; -} -[data-iii-ui="github"] a.gh-row-title:hover { - color: var(--color-accent); -} -[data-iii-ui="github"] .gh-row-title-quiet { - font-size: 12px; - color: var(--color-ink-faint); } -[data-iii-ui="github"] .gh-row-tag { - font-size: 13px; - color: var(--color-ink); - text-decoration: none; - flex-shrink: 0; - transition: color 0.12s; -} -[data-iii-ui="github"] a.gh-row-tag:hover { - color: var(--color-accent); -} - -[data-iii-ui="github"] .gh-row-meta { - font-size: 11px; - color: var(--color-ink-faint); - text-transform: lowercase; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - flex-shrink: 0; -} -[data-iii-ui="github"] .gh-row-meta-group { +[data-iii-ui="github"] .gh-feed-status { + width: 8rem; display: flex; align-items: center; - gap: 8px; -} -[data-iii-ui="github"] .gh-labels { - max-width: 10rem; - overflow: hidden; - text-overflow: ellipsis; + gap: 6px; + font-size: 11px; white-space: nowrap; } -[data-iii-ui="github"] .gh-trunc-44 { - max-width: 11rem; +[data-iii-ui="github"] .gh-ok { + color: var(--color-ok); + text-transform: lowercase; } -[data-iii-ui="github"] .gh-trunc-64 { - max-width: 16rem; +[data-iii-ui="github"] .gh-err { + color: var(--color-alert); + text-transform: lowercase; } -[data-iii-ui="github"] .gh-trunc-72 { - max-width: 18rem; +[data-iii-ui="github"] .gh-feed-dur { + margin-left: auto; + color: var(--color-ink-faint); } -/* --- pr checks rollup ------------------------------------------------ */ -[data-iii-ui="github"] .gh-checks { - padding: 0 0 8px 3.5rem; -} -[data-iii-ui="github"] .gh-checks-list { - list-style: none; - margin: 0; - padding: 0; - display: flex; - flex-direction: column; - gap: 4px; -} -[data-iii-ui="github"] .gh-check { - display: flex; - align-items: center; - gap: 8px; - min-width: 0; -} -[data-iii-ui="github"] .gh-check-label { +[data-iii-ui="github"] .gh-feed-time { + width: 6rem; + text-align: right; font-size: 11px; color: var(--color-ink-faint); - text-decoration: none; - overflow: hidden; - text-overflow: ellipsis; white-space: nowrap; } -[data-iii-ui="github"] a.gh-check-label.link:hover { - color: var(--color-accent); -} -[data-iii-ui="github"] .gh-checks-msg { - font-size: 11px; - color: var(--color-ink-faint); - text-transform: lowercase; + +/* --- expanded detail ------------------------------------------------- */ +[data-iii-ui="github"] .gh-feed-detail-row td { + padding: 0 10px 10px; } -[data-iii-ui="github"] .gh-checks-msg.alert { - color: var(--color-alert); - text-transform: none; +[data-iii-ui="github"] .gh-feed-detail { + font-size: 12px; + color: var(--color-ink); + background: var(--color-paper-2); + border-left: 2px solid var(--color-accent); + padding: 8px 10px; + white-space: pre-wrap; + word-break: break-word; + animation: gh-ui-reveal 0.16s ease-out; } -[data-iii-ui="github"] .gh-checks-msg.ghost { - color: var(--color-ink-ghost); + +@keyframes gh-ui-reveal { + from { + opacity: 0; + transform: translateY(-2px); + } + to { + opacity: 1; + transform: translateY(0); + } } @media (max-width: 720px) { - [data-iii-ui="github"] .gh-row-meta { + [data-iii-ui="github"] .gh-feed-time { display: none; } + [data-iii-ui="github"] .gh-feed-fn { + width: 8rem; + } } From 41d20cfdf621fe5f2f80c602ffe5ff4587ffeebd Mon Sep 17 00:00:00 2001 From: Rohit Ghumare Date: Tue, 28 Jul 2026 12:16:42 +0100 Subject: [PATCH 3/6] feat(github): show real result previews in the activity feed (per-op renderers) --- github/src/events.rs | 404 +++++++++++++++++++++++++++- github/ui/src/page/index.tsx | 43 ++- github/ui/src/page/result-views.tsx | 319 ++++++++++++++++++++++ github/ui/styles.css | 157 ++++++++++- 4 files changed, 894 insertions(+), 29 deletions(-) create mode 100644 github/ui/src/page/result-views.tsx diff --git a/github/src/events.rs b/github/src/events.rs index 1168974b9..fe9ac0c46 100644 --- a/github/src/events.rs +++ b/github/src/events.rs @@ -21,7 +21,7 @@ use iii_sdk::protocol::TriggerRequest; use iii_sdk::trigger::{TriggerConfig, TriggerHandler}; use iii_sdk::{IIIClient, RegisterTriggerType, TriggerAction}; use serde::Serialize; -use serde_json::{Map, Value}; +use serde_json::{json, Map, Value}; /// The trigger type the console subscribes to for the activity feed. pub const CALLED: &str = "github::called"; @@ -31,6 +31,22 @@ pub const CALLED: &str = "github::called"; const MAX_SUMMARY: usize = 512; const MAX_ARGS: usize = 256; +/// The result PREVIEW carried alongside the one-line summary is budgeted so the +/// event stays small no matter how big the underlying gh output was. +/// List previews keep at most this many items (with the true total recorded); +/// object/text/diff/outcome previews are byte-capped. +const PREVIEW_MAX_ITEMS: usize = 12; +const PREVIEW_MAX_BYTES: usize = 6144; +/// Per-item value cap for a projected list entry (an `author`/`repository` +/// object stays whole; a rogue long field is trimmed). +const PREVIEW_ITEM_VALUE_BYTES: usize = 512; +/// Long top-level strings on a projected object (a PR body, release notes) are +/// trimmed to this before the object is re-measured against the byte budget. +const PREVIEW_STRING_BYTES: usize = 1024; +/// Each stream of an outcome preview is capped independently so a chatty +/// stdout AND stderr together still fit the budget. +const PREVIEW_STREAM_BYTES: usize = PREVIEW_MAX_BYTES / 2; + /// One `github::called` event: the call that ran plus a short, human result /// summary. Serialized as the trigger payload; the console renders it directly. #[derive(Debug, Clone, Serialize)] @@ -46,8 +62,17 @@ pub struct CalledEvent { pub ok: bool, /// Wall-clock duration of the wrapped handler, in ms. pub duration_ms: u64, - /// Short human string derived from the result (e.g. "3 pull requests"). + /// Short human string derived from the result (e.g. "3 pull requests") — + /// the collapsed row header. pub result_summary: String, + /// Which renderer the UI should use for [`Self::result_preview`], derived + /// from the response envelope + function id: + /// `"list" | "object" | "text" | "diff" | "outcome"`. + pub kind: String, + /// The ACTUAL result, budgeted small (see the `PREVIEW_*` caps): a + /// projected + truncated slice of what the call returned so the feed shows + /// the substance, not just a count. `null` when there is nothing useful. + pub result_preview: Value, /// RFC 3339 UTC timestamp of completion. pub timestamp: String, } @@ -186,12 +211,22 @@ where let started = std::time::Instant::now(); let result = fut.await; let duration_ms = started.elapsed().as_millis() as u64; - let (ok, result_summary) = match &result { + let (ok, result_summary, kind, result_preview) = match &result { Ok(resp) => { let value = serde_json::to_value(resp).unwrap_or(Value::Null); - (true, summarize(function_id, &value)) + let (kind, preview) = preview(function_id, &value); + (true, summarize(function_id, &value), kind, preview) + } + Err(e) => { + let msg = e.to_string(); + let (text, truncated) = truncate_bytes(&msg, PREVIEW_MAX_BYTES); + ( + false, + truncate(&msg, MAX_SUMMARY), + "text".to_string(), + json!({ "text": text, "truncated": truncated }), + ) } - Err(e) => (false, truncate(&e.to_string(), MAX_SUMMARY)), }; emitter .emit(CalledEvent { @@ -201,6 +236,8 @@ where ok, duration_ms, result_summary, + kind, + result_preview, timestamp: now_rfc3339(), }) .await; @@ -241,6 +278,232 @@ pub fn summarize(function_id: &str, result: &Value) -> String { truncate(&raw, MAX_SUMMARY) } +/// The `kind` discriminator + the budgeted `result_preview` for an event. +/// Dispatches on the SAME response envelope as [`summarize`] so the UI can pick +/// a renderer without re-parsing: +/// +/// - `{ output }` → `"text"`, `{ text, truncated }` (byte-capped) +/// - `{ diff }` → `"diff"`, `{ diff, truncated }` (first ~6 KB kept) +/// - `{ value: [] }`→ `"list"`, `{ items: [projected…], total }` (first N kept) +/// - `{ value: {} }`→ `"object"`, the object whole (under budget) or top-level +/// keys projected +/// - `{ exit_code|stdout }` → `"outcome"`, `{ exit_code, stdout, stderr, … }` +/// - anything else → `"object"`, the value projected to fit the byte budget +pub fn preview(function_id: &str, result: &Value) -> (String, Value) { + match result { + Value::Object(m) if m.contains_key("output") => { + let out = m.get("output").and_then(Value::as_str).unwrap_or(""); + let (text, truncated) = truncate_bytes(out, PREVIEW_MAX_BYTES); + ( + "text".to_string(), + json!({ "text": text, "truncated": truncated }), + ) + } + Value::Object(m) if m.contains_key("diff") => { + let diff = m.get("diff").and_then(Value::as_str).unwrap_or(""); + let already = m.get("truncated").and_then(Value::as_bool).unwrap_or(false); + let (text, cut) = truncate_bytes(diff, PREVIEW_MAX_BYTES); + ( + "diff".to_string(), + json!({ "diff": text, "truncated": already || cut }), + ) + } + Value::Object(m) if m.contains_key("value") => match m.get("value").unwrap_or(&Value::Null) + { + Value::Array(items) => ("list".to_string(), preview_list(function_id, items)), + Value::Null => ("object".to_string(), Value::Null), + other => ("object".to_string(), project_object(other)), + }, + Value::Object(m) if m.contains_key("exit_code") || m.contains_key("stdout") => { + ("outcome".to_string(), preview_outcome(m)) + } + Value::Null => ("object".to_string(), Value::Null), + other => ("object".to_string(), project_object(other)), + } +} + +/// `{ items: [first N projected], total }` — keep the true length so the UI can +/// show "showing 12 of 70", and project each kept item down to its salient +/// display keys (per [`keys_for`]) so the payload stays small. +fn preview_list(function_id: &str, items: &[Value]) -> Value { + let allow = keys_for(function_id); + let kept: Vec = items + .iter() + .take(PREVIEW_MAX_ITEMS) + .map(|item| project_item(item, allow)) + .collect(); + json!({ "items": kept, "total": items.len() }) +} + +/// Project one list item to the salient keys. With an allowlist, keep exactly +/// those keys (each bounded); without one, keep top-level scalar keys and drop +/// the blobs (nested arrays/objects, long strings). +fn project_item(item: &Value, allow: &[&str]) -> Value { + let Value::Object(map) = item else { + return item.clone(); + }; + let mut out = Map::new(); + if allow.is_empty() { + for (k, v) in map { + let keep = match v { + Value::String(s) => s.len() <= PREVIEW_ITEM_VALUE_BYTES, + Value::Array(_) | Value::Object(_) => false, + _ => true, + }; + if keep { + out.insert(k.clone(), v.clone()); + } + } + } else { + for &k in allow { + if let Some(v) = map.get(k) { + out.insert(k.to_string(), bound_value(v)); + } + } + } + Value::Object(out) +} + +/// Keep a projected value, trimming a rogue long string / oversized nested +/// value so a single item can't blow the budget. Small objects (an `author`) +/// pass through whole. +fn bound_value(v: &Value) -> Value { + match v { + Value::String(s) => Value::String(truncate_bytes(s, PREVIEW_ITEM_VALUE_BYTES).0), + other if value_bytes(other) > PREVIEW_ITEM_VALUE_BYTES => { + Value::String(format!("… ({} bytes elided)", value_bytes(other))) + } + other => other.clone(), + } +} + +/// An object result: keep it whole when it fits the byte budget, else project +/// top-level keys — scalars kept, long strings trimmed, nested blobs dropped. +fn project_object(value: &Value) -> Value { + if value_bytes(value) <= PREVIEW_MAX_BYTES { + return value.clone(); + } + match value { + Value::Object(map) => { + let mut out = Map::new(); + for (k, v) in map { + match v { + Value::String(s) => { + out.insert( + k.clone(), + Value::String(truncate_bytes(s, PREVIEW_STRING_BYTES).0), + ); + } + Value::Array(_) | Value::Object(_) => {} + other => { + out.insert(k.clone(), other.clone()); + } + } + } + Value::Object(out) + } + Value::String(s) => Value::String(truncate_bytes(s, PREVIEW_MAX_BYTES).0), + other => other.clone(), + } +} + +/// A `GhOutcome` preview: exit code / kill state plus byte-capped stdout+stderr. +fn preview_outcome(m: &Map) -> Value { + let (stdout, out_cut) = truncate_bytes( + m.get("stdout").and_then(Value::as_str).unwrap_or(""), + PREVIEW_STREAM_BYTES, + ); + let (stderr, err_cut) = truncate_bytes( + m.get("stderr").and_then(Value::as_str).unwrap_or(""), + PREVIEW_STREAM_BYTES, + ); + let already = m + .get("stdout_truncated") + .and_then(Value::as_bool) + .unwrap_or(false) + || m.get("stderr_truncated") + .and_then(Value::as_bool) + .unwrap_or(false); + json!({ + "exit_code": m.get("exit_code").cloned().unwrap_or(Value::Null), + "timed_out": m.get("timed_out").and_then(Value::as_bool).unwrap_or(false), + "stdout": stdout, + "stderr": stderr, + "truncated": already || out_cut || err_cut, + }) +} + +/// The salient display keys to keep for each list-returning function, dropping +/// the noise (labels, timestamps, text-match blobs). An empty slice means +/// "no allowlist" — [`project_item`] falls back to keeping scalar keys. +fn keys_for(function_id: &str) -> &'static [&'static str] { + match function_id { + "github::pr::list" => &["number", "title", "state", "url", "author", "isDraft"], + "github::search::prs" => &[ + "number", + "title", + "state", + "url", + "repository", + "author", + "isDraft", + ], + "github::issue::list" => &["number", "title", "state", "url", "author"], + "github::search::issues" => &["number", "title", "state", "url", "repository", "author"], + "github::pr::checks" => &["name", "state", "bucket", "workflow", "link"], + "github::repo::list" => &[ + "nameWithOwner", + "name", + "description", + "url", + "stargazerCount", + "primaryLanguage", + "visibility", + ], + "github::search::repos" => &[ + "fullName", + "description", + "url", + "stargazersCount", + "language", + "visibility", + ], + "github::run::list" => &[ + "databaseId", + "displayTitle", + "workflowName", + "name", + "status", + "conclusion", + "headBranch", + "event", + "url", + ], + "github::workflow::list" => &["id", "name", "path", "state"], + "github::release::list" => &["tagName", "name", "isLatest", "isPrerelease", "isDraft"], + "github::search::code" => &["path", "repository", "sha", "url"], + _ => &[], + } +} + +/// Byte length of a value's compact JSON encoding. +fn value_bytes(v: &Value) -> usize { + serde_json::to_string(v).map(|s| s.len()).unwrap_or(0) +} + +/// Truncate `s` to at most `max` bytes on a char boundary. Returns the bounded +/// string and whether anything was dropped. +fn truncate_bytes(s: &str, max: usize) -> (String, bool) { + if s.len() <= max { + return (s.to_string(), false); + } + let mut end = max; + while end > 0 && !s.is_char_boundary(end) { + end -= 1; + } + (s[..end].to_string(), true) +} + /// Count arrays (with a function-derived noun), name a single object, else a /// short description of the `{ value }` payload. fn summarize_value(function_id: &str, value: &Value) -> String { @@ -476,6 +739,137 @@ mod tests { assert!(s.chars().count() <= MAX_SUMMARY); } + #[test] + fn preview_list_truncates_to_n_and_records_the_true_total() { + let items: Vec = (0..70) + .map(|n| json!({ "number": n, "title": format!("pr {n}") })) + .collect(); + let (kind, pv) = preview("github::pr::list", &json!({ "value": items })); + assert_eq!(kind, "list"); + assert_eq!(pv["total"], json!(70)); + assert_eq!(pv["items"].as_array().unwrap().len(), PREVIEW_MAX_ITEMS); + // First kept item survived intact. + assert_eq!(pv["items"][0]["number"], json!(0)); + } + + #[test] + fn preview_list_projects_to_the_namespace_allowlist() { + // A PR list item carries labels + timestamps the feed does not need; + // the allowlist keeps the salient keys (incl. the author object) and + // drops the rest. + let item = json!({ + "number": 7, + "title": "fix", + "state": "OPEN", + "url": "https://x/7", + "author": { "login": "octocat", "id": "abc" }, + "isDraft": false, + "labels": [{ "name": "bug" }, { "name": "p1" }], + "createdAt": "2026-01-01T00:00:00Z", + "updatedAt": "2026-01-02T00:00:00Z", + }); + let (_, pv) = preview("github::pr::list", &json!({ "value": [item] })); + let projected = &pv["items"][0]; + assert_eq!(projected["number"], json!(7)); + assert_eq!(projected["title"], json!("fix")); + assert_eq!(projected["author"]["login"], json!("octocat")); + assert!(projected.get("labels").is_none(), "labels dropped"); + assert!(projected.get("createdAt").is_none(), "timestamp dropped"); + } + + #[test] + fn preview_list_default_projection_keeps_scalars_drops_blobs() { + // github::api (contents) has no allowlist: keep scalar keys, drop the + // nested `_links` blob. + let item = json!({ + "name": "main.rs", + "path": "src/main.rs", + "type": "file", + "size": 1234, + "_links": { "self": "https://x", "git": "https://y" }, + }); + let (kind, pv) = preview("github::api", &json!({ "value": [item] })); + assert_eq!(kind, "list"); + let projected = &pv["items"][0]; + assert_eq!(projected["name"], json!("main.rs")); + assert_eq!(projected["type"], json!("file")); + assert_eq!(projected["size"], json!(1234)); + assert!(projected.get("_links").is_none(), "nested blob dropped"); + } + + #[test] + fn preview_object_kept_whole_when_small_projected_when_big() { + let small = json!({ "value": { "number": 7, "title": "ok" } }); + let (kind, pv) = preview("github::pr::view", &small); + assert_eq!(kind, "object"); + assert_eq!(pv["title"], json!("ok")); + + let big = json!({ + "value": { + "number": 7, + "body": "x".repeat(20_000), + "comments": vec![json!({ "b": "y".repeat(4000) }); 5], + } + }); + let (_, pv) = preview("github::pr::view", &big); + assert!(value_bytes(&pv) <= PREVIEW_MAX_BYTES); + assert_eq!(pv["number"], json!(7)); + assert!(pv.get("comments").is_none(), "nested blob dropped"); + assert!( + pv["body"].as_str().unwrap().len() <= PREVIEW_STRING_BYTES, + "long string trimmed" + ); + } + + #[test] + fn preview_text_and_diff_are_byte_capped() { + let (kind, pv) = preview("github::pr::edit", &json!({ "output": "x".repeat(20_000) })); + assert_eq!(kind, "text"); + assert!(pv["text"].as_str().unwrap().len() <= PREVIEW_MAX_BYTES); + assert_eq!(pv["truncated"], json!(true)); + + let (kind, pv) = preview( + "github::pr::diff", + &json!({ "diff": "+".repeat(20_000), "truncated": false }), + ); + assert_eq!(kind, "diff"); + assert!(pv["diff"].as_str().unwrap().len() <= PREVIEW_MAX_BYTES); + assert_eq!(pv["truncated"], json!(true)); + + // The wrapper's own truncation flag is preserved even when the preview + // itself fit. + let (_, pv) = preview( + "github::pr::diff", + &json!({ "diff": "small", "truncated": true }), + ); + assert_eq!(pv["truncated"], json!(true)); + } + + #[test] + fn preview_outcome_caps_each_stream_and_carries_exit() { + let (kind, pv) = preview( + "github::exec", + &json!({ + "stdout": "a".repeat(20_000), + "stderr": "boom", + "exit_code": 3, + "timed_out": false, + }), + ); + assert_eq!(kind, "outcome"); + assert_eq!(pv["exit_code"], json!(3)); + assert_eq!(pv["stderr"], json!("boom")); + assert!(pv["stdout"].as_str().unwrap().len() <= PREVIEW_STREAM_BYTES); + assert_eq!(pv["truncated"], json!(true)); + } + + #[test] + fn preview_null_value_is_object_null() { + let (kind, pv) = preview("github::pr::edit", &json!({ "value": Value::Null })); + assert_eq!(kind, "object"); + assert_eq!(pv, Value::Null); + } + #[test] fn args_summary_redacts_bodies_and_drops_json_fields() { let argv: Vec = [ diff --git a/github/ui/src/page/index.tsx b/github/ui/src/page/index.tsx index 7eb2b27c3..4b616ac44 100644 --- a/github/ui/src/page/index.tsx +++ b/github/ui/src/page/index.tsx @@ -6,17 +6,20 @@ * * Each row shows the function id (badge), a one-line arg echo, an ok/error dot * with the call duration, and a relative timestamp; clicking a row expands the - * short result summary the worker derived. A live/paused toggle stops appending - * new events, and clear empties the (bounded, newest-first) list. + * ACTUAL result the worker budgeted into the event, rendered by its `kind` + * (list / object / text / diff / outcome — see result-views.tsx and the + * `preview()` budgeter in github/src/events.rs). A live/paused toggle stops + * appending new events, and clear empties the (bounded, newest-first) list. * * The host only mounts this page while the github worker is connected, so there * is no presence gate; this page invokes nothing — it is a passive observer of * the bus. */ -import { Badge, Button, EmptyState, type Host, StatusDot } from '@iii-dev/console-ui' +import { Badge, Button, EmptyState, ErrorBoundary, type Host, StatusDot } from '@iii-dev/console-ui' import { useCallback, useEffect, useRef, useState } from 'react' import { Activity, type IconProps } from './icons' +import { ResultView } from './result-views' /** The worker's trigger type; see github/src/events.rs. */ const CALLED_TYPE = 'github::called' @@ -35,6 +38,11 @@ interface CalledEvent { ok: boolean duration_ms: number result_summary: string + /** Renderer discriminator for `result_preview`: list/object/text/diff/outcome. */ + kind: string + /** The budgeted, projected result the worker carried in the event; `null` + * when there was nothing useful. Rendered by `kind` (see result-views.tsx). */ + result_preview: unknown timestamp: string } @@ -71,6 +79,8 @@ function useCalledFeed(host: Host) { ok: Boolean(event.ok), duration_ms: Number(event.duration_ms) || 0, result_summary: event.result_summary ?? '', + kind: typeof event.kind === 'string' ? event.kind : 'object', + result_preview: event.result_preview ?? null, timestamp: event.timestamp ?? '', key: `${Date.now()}-${seq.current++}`, receivedAt: Date.now(), @@ -116,21 +126,11 @@ export function GithubPage({ host }: { host: Host }) {
                      - -
                      @@ -151,9 +151,7 @@ export function GithubPage({ host }: { host: Host }) { entry={entry} now={now} expanded={expanded === entry.key} - onToggle={() => - setExpanded((cur) => (cur === entry.key ? null : entry.key)) - } + onToggle={() => setExpanded((cur) => (cur === entry.key ? null : entry.key))} /> ))} @@ -199,9 +197,7 @@ function ActivityRow({ - - {entry.ok ? 'ok' : 'error'} - + {entry.ok ? 'ok' : 'error'} {formatDuration(entry.duration_ms)} @@ -212,7 +208,10 @@ function ActivityRow({
                      - {entry.result_summary || '(no result summary)'} + {entry.result_summary ?
                      {entry.result_summary}
                      : null} +
                      could not render this result
                      }> + +
                      diff --git a/github/ui/src/page/result-views.tsx b/github/ui/src/page/result-views.tsx new file mode 100644 index 000000000..1b1383e9a --- /dev/null +++ b/github/ui/src/page/result-views.tsx @@ -0,0 +1,319 @@ +/** + * Per-kind renderers for a `github::called` event's budgeted `result_preview` + * (see the `preview()` budgeter in github/src/events.rs). The worker tags each + * event with a `kind` discriminator so the feed can pick a renderer without + * re-parsing the payload: + * + * - `list` → a compact table of the projected items (PRs/issues as + * `#num title` + state Badge + author; api contents as `type name (size)`; + * everything else as a column table of the salient keys) + a "showing N of M" + * footer when the worker truncated the list. + * - `object` → the projected object as highlighted JSON (`JsonHighlight`). + * - `text` → a monospace block (also the shape errors arrive in). + * - `diff` → monospace with +/- line coloring off `--color-ok`/`--color-alert`. + * - `outcome` → the exit line plus byte-capped stdout/stderr streams. + * + * Every renderer degrades to a muted empty note rather than throwing, and the + * page wraps the whole thing in the console's ErrorBoundary, so a surprising + * payload can never break the feed. + */ + +import { Badge, type BadgeProps, JsonHighlight } from '@iii-dev/console-ui' +import type { ReactNode } from 'react' + +/** The renderer keys the worker emits; unknown values fall back to `object`. */ +export type PreviewKind = 'list' | 'object' | 'text' | 'diff' | 'outcome' + +/** Render one event's `result_preview` by its `kind`. `ok` tints an error + * text block (errors arrive as `kind: "text"`). */ +export function ResultView({ kind, preview, ok }: { kind: string; preview: unknown; ok: boolean }): ReactNode { + switch (kind) { + case 'list': + return + case 'text': + return + case 'diff': + return + case 'outcome': + return + default: + return + } +} + +/* ── list ──────────────────────────────────────────────────────────────── */ + +function ListView({ preview }: { preview: unknown }) { + const rec = asRecord(preview) + const items = Array.isArray(rec?.items) ? (rec.items as unknown[]) : [] + const total = typeof rec?.total === 'number' ? (rec.total as number) : items.length + if (items.length === 0) return no items + + const shape = listShape(items[0]) + return ( +
                      + + + {shape === 'generic' ? ( + + ) : ( + items.map((item, i) => ) + )} + +
                      + {items.length < total ? ( +
                      + showing {items.length} of {total} +
                      + ) : null} +
                      + ) +} + +type Shape = 'issue' | 'contents' | 'generic' + +/** Classify a list by the SHAPE of its projected items so the renderer works + * for both allowlisted and default-projected functions (see keys_for in + * events.rs). `number`+`title` ⇒ a PR/issue; `type`+`name`/`path` ⇒ api + * contents; anything else ⇒ a generic column table. */ +function listShape(item: unknown): Shape { + const r = asRecord(item) + if (!r) return 'generic' + if ('number' in r && 'title' in r) return 'issue' + if ('type' in r && ('name' in r || 'path' in r)) return 'contents' + return 'generic' +} + +function ListRow({ item, shape }: { item: unknown; shape: Shape }) { + const r = asRecord(item) + if (!r) return null + return shape === 'issue' ? : +} + +function IssueRow({ r }: { r: Record }) { + const num = r.number + const title = str(r.title) + const state = str(r.state) + const author = authorLogin(r.author) + const repo = repoName(r.repository) + const url = typeof r.url === 'string' ? r.url : undefined + const draft = r.isDraft === true + return ( + + #{num as ReactNode} + + {url ? ( + + {title} + + ) : ( + title + )} + {draft ? draft : null} + + + {state ? {state.toLowerCase()} : null} + + + {author ? @{author} : null} + {repo ? {repo} : null} + + + ) +} + +function ContentsRow({ r }: { r: Record }) { + const type = str(r.type) + const name = str(r.name) || str(r.path) + const size = typeof r.size === 'number' ? (r.size as number) : undefined + return ( + + {type ? {type} : null} + {name} + {size != null ? humanBytes(size) : ''} + + ) +} + +/** A column table for lists with no bespoke shape (repos, runs, releases, + * workflows, code search): columns are the projected scalar keys of the first + * item, values coerced to a short display string. */ +function GenericRows({ items }: { items: unknown[] }) { + const cols = scalarKeys(items[0]) + if (cols.length === 0) { + return ( + + + + + + ) + } + return ( + <> + + {cols.map((c) => ( + {c} + ))} + + {items.map((item, i) => { + const r = asRecord(item) + return ( + + {cols.map((c) => ( + + {cellText(r ? r[c] : undefined)} + + ))} + + ) + })} + + ) +} + +/* ── object / text / diff / outcome ────────────────────────────────────── */ + +function ObjectView({ preview }: { preview: unknown }) { + if (preview == null) return no result + return +} + +function TextView({ preview, error }: { preview: unknown; error: boolean }) { + const r = asRecord(preview) + const text = typeof r?.text === 'string' ? (r.text as string) : typeof preview === 'string' ? preview : '' + const truncated = r?.truncated === true + if (!text) return {error ? 'error' : 'no output'} + return ( +
                      + {text} + {truncated ? : null} +
                      + ) +} + +function DiffView({ preview }: { preview: unknown }) { + const r = asRecord(preview) + const diff = typeof r?.diff === 'string' ? (r.diff as string) : '' + const truncated = r?.truncated === true + if (!diff) return no diff + const lines = diff.split('\n') + return ( +
                      + {lines.map((line, i) => ( +
                      + {line || ' '} +
                      + ))} + {truncated ? : null} +
                      + ) +} + +function diffClass(line: string): string { + if (line.startsWith('+') && !line.startsWith('+++')) return ' gh-rv-add' + if (line.startsWith('-') && !line.startsWith('---')) return ' gh-rv-del' + if (line.startsWith('@@')) return ' gh-rv-hunk' + return '' +} + +function OutcomeView({ preview }: { preview: unknown }) { + const r = asRecord(preview) + if (!r) return no outcome + const exit = r.exit_code + const timedOut = r.timed_out === true + const stdout = typeof r.stdout === 'string' ? (r.stdout as string) : '' + const stderr = typeof r.stderr === 'string' ? (r.stderr as string) : '' + const truncated = r.truncated === true + const label = timedOut ? 'timed out' : exit == null ? 'killed' : `exit ${exit as ReactNode}` + const failed = timedOut || exit == null || (typeof exit === 'number' && exit !== 0) + return ( +
                      +
                      {label}
                      + {stdout ? ( +
                      +
                      stdout
                      +
                      {stdout}
                      +
                      + ) : null} + {stderr ? ( +
                      +
                      stderr
                      +
                      {stderr}
                      +
                      + ) : null} + {truncated ? : null} +
                      + ) +} + +/* ── small helpers ─────────────────────────────────────────────────────── */ + +function Empty({ children }: { children: ReactNode }) { + return
                      {children}
                      +} + +function TruncMark() { + return
                      … truncated
                      +} + +function asRecord(v: unknown): Record | null { + return v != null && typeof v === 'object' && !Array.isArray(v) ? (v as Record) : null +} + +function str(v: unknown): string { + return typeof v === 'string' ? v : '' +} + +/** The `login` of a projected `author`/`user` object, or a bare string author. */ +function authorLogin(a: unknown): string { + const r = asRecord(a) + if (r && typeof r.login === 'string') return r.login + return typeof a === 'string' ? a : '' +} + +/** A `repository` field can be a bare `owner/name` string or a projected + * `{ nameWithOwner | name }` object (gh search results). */ +function repoName(v: unknown): string { + if (typeof v === 'string') return v + const r = asRecord(v) + if (r) return str(r.nameWithOwner) || str(r.name) + return '' +} + +/** Scalar (non-object, non-array) keys of a projected item, in insertion + * order — the columns for the generic table. */ +function scalarKeys(item: unknown): string[] { + const r = asRecord(item) + if (!r) return [] + return Object.keys(r).filter((k) => { + const v = r[k] + return v == null || typeof v !== 'object' + }) +} + +/** Coerce a projected value to a short display string: objects collapse to a + * name-ish field, arrays join, everything else stringifies. */ +function cellText(v: unknown): string { + if (v == null) return '' + if (typeof v === 'string') return v + if (typeof v === 'number' || typeof v === 'boolean') return String(v) + if (Array.isArray(v)) return v.map(cellText).join(', ') + const r = asRecord(v) + if (r) return str(r.login) || str(r.nameWithOwner) || str(r.name) || JSON.stringify(v) + return JSON.stringify(v) +} + +/** State → Badge tone: open/merged read as active, closed as spent. */ +function stateVariant(state: string): NonNullable { + const s = state.toUpperCase() + if (s === 'OPEN' || s === 'MERGED') return 'accent' + if (s === 'CLOSED') return 'alert' + return 'default' +} + +function humanBytes(n: number): string { + if (n < 1024) return `${n} B` + if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB` + return `${(n / (1024 * 1024)).toFixed(1)} MB` +} diff --git a/github/ui/styles.css b/github/ui/styles.css index bbbda4c9d..cec9b849c 100644 --- a/github/ui/styles.css +++ b/github/ui/styles.css @@ -142,10 +142,14 @@ background: var(--color-paper-2); border-left: 2px solid var(--color-accent); padding: 8px 10px; - white-space: pre-wrap; - word-break: break-word; animation: gh-ui-reveal 0.16s ease-out; } +[data-iii-ui="github"] .gh-feed-summary { + font-size: 11px; + color: var(--color-ink-faint); + text-transform: lowercase; + margin-bottom: 8px; +} @keyframes gh-ui-reveal { from { @@ -158,6 +162,155 @@ } } +/* --- result previews (result-views.tsx) ------------------------------ */ +[data-iii-ui="github"] .gh-rv-empty { + font-size: 12px; + color: var(--color-ink-faint); + text-transform: lowercase; +} +[data-iii-ui="github"] .gh-rv-trunc { + font-size: 11px; + color: var(--color-ink-faint); + padding-top: 4px; +} + +/* list */ +[data-iii-ui="github"] .gh-rv-list { + width: 100%; + border-collapse: collapse; + table-layout: auto; +} +[data-iii-ui="github"] .gh-rv-list td, +[data-iii-ui="github"] .gh-rv-list th { + padding: 3px 8px 3px 0; + vertical-align: top; + font-size: 12px; + text-align: left; +} +[data-iii-ui="github"] .gh-rv-row:not(:last-child) td { + border-bottom: 1px solid var(--color-rule-2); +} +[data-iii-ui="github"] .gh-rv-ghead th { + font-size: 10px; + color: var(--color-ink-faint); + text-transform: lowercase; + font-weight: 600; + border-bottom: 1px solid var(--color-rule); +} +[data-iii-ui="github"] .gh-rv-num { + color: var(--color-ink-faint); + white-space: nowrap; +} +[data-iii-ui="github"] .gh-rv-title { + color: var(--color-ink); + word-break: break-word; +} +[data-iii-ui="github"] .gh-rv-title a { + color: var(--color-accent); + text-decoration: none; +} +[data-iii-ui="github"] .gh-rv-title a:hover { + text-decoration: underline; +} +[data-iii-ui="github"] .gh-rv-flag { + margin-left: 6px; + font-size: 10px; + color: var(--color-ink-faint); + text-transform: uppercase; + letter-spacing: 0.04em; +} +[data-iii-ui="github"] .gh-rv-state { + white-space: nowrap; +} +[data-iii-ui="github"] .gh-rv-author { + color: var(--color-ink-faint); + white-space: nowrap; +} +[data-iii-ui="github"] .gh-rv-repo { + margin-left: 8px; + color: var(--color-ink-ghost); +} +[data-iii-ui="github"] .gh-rv-ctype { + white-space: nowrap; +} +[data-iii-ui="github"] .gh-rv-cname { + color: var(--color-ink); + word-break: break-all; + width: 100%; +} +[data-iii-ui="github"] .gh-rv-csize { + color: var(--color-ink-faint); + white-space: nowrap; + text-align: right; +} +[data-iii-ui="github"] .gh-rv-gcell { + color: var(--color-ink); + word-break: break-word; +} +[data-iii-ui="github"] .gh-rv-more { + font-size: 11px; + color: var(--color-ink-faint); + padding-top: 6px; + text-transform: lowercase; +} + +/* text / outcome streams */ +[data-iii-ui="github"] .gh-rv-text { + font-family: var(--font-mono, ui-monospace, monospace); + font-size: 12px; + color: var(--color-ink); + white-space: pre-wrap; + word-break: break-word; + max-height: 320px; + overflow: auto; +} +[data-iii-ui="github"] .gh-rv-errtext { + color: var(--color-alert); +} +[data-iii-ui="github"] .gh-rv-outcome { + display: flex; + flex-direction: column; + gap: 8px; +} +[data-iii-ui="github"] .gh-rv-exit { + font-size: 12px; + color: var(--color-ok); + text-transform: lowercase; +} +[data-iii-ui="github"] .gh-rv-stream { + display: flex; + flex-direction: column; + gap: 2px; +} +[data-iii-ui="github"] .gh-rv-slabel { + font-size: 10px; + color: var(--color-ink-faint); + text-transform: uppercase; + letter-spacing: 0.04em; +} + +/* diff */ +[data-iii-ui="github"] .gh-rv-diff { + font-family: var(--font-mono, ui-monospace, monospace); + font-size: 12px; + line-height: 1.5; + max-height: 360px; + overflow: auto; +} +[data-iii-ui="github"] .gh-rv-dline { + white-space: pre; + color: var(--color-ink); +} +[data-iii-ui="github"] .gh-rv-add { + color: var(--color-ok); +} +[data-iii-ui="github"] .gh-rv-del { + color: var(--color-alert); +} +[data-iii-ui="github"] .gh-rv-hunk { + color: var(--color-ink-faint); +} + @media (max-width: 720px) { [data-iii-ui="github"] .gh-feed-time { display: none; From 5f89081339dd8e3cfd5666064e80c70ccb3df2a0 Mon Sep 17 00:00:00 2001 From: Rohit Ghumare Date: Tue, 28 Jul 2026 13:02:28 +0100 Subject: [PATCH 4/6] feat(github): live git-graph hero view (working-repo DAG via shell) --- github/ui/page.tsx | 14 +- github/ui/src/page/GitGraph.tsx | 459 ++++++++++++++++++++++++ github/ui/src/page/GithubPage.tsx | 33 ++ github/ui/src/page/graph-layout.test.ts | 140 ++++++++ github/ui/src/page/graph-layout.ts | 167 +++++++++ github/ui/src/page/index.tsx | 2 +- github/ui/styles.css | 170 ++++++++- 7 files changed, 976 insertions(+), 9 deletions(-) create mode 100644 github/ui/src/page/GitGraph.tsx create mode 100644 github/ui/src/page/GithubPage.tsx create mode 100644 github/ui/src/page/graph-layout.test.ts create mode 100644 github/ui/src/page/graph-layout.ts diff --git a/github/ui/page.tsx b/github/ui/page.tsx index 61f2dea94..f27b7c50f 100644 --- a/github/ui/page.tsx +++ b/github/ui/page.tsx @@ -6,18 +6,20 @@ * the console mounts and link-swaps it, styles-before-scripts on boot. * * `setup(host)` registers one contribution: - * - src/page/ — the `#/ext/github` ACTIVITY feed: a tab-scoped subscription - * to the worker's `github::called` trigger type that renders each github - * call as it finishes (function id, arg echo, ok/error + duration, and a - * short result summary). Read-only: the page observes the bus, it invokes - * nothing. + * - src/page/GithubPage — the `#/ext/github` page: two tabs over the worker's + * live bus. GRAPH (default, the hero) draws the working repo's commit DAG + * from a single `shell::exec` `git log --all`, refreshing as the agent works. + * ACTIVITY is the existing feed — a tab-scoped subscription to `github::called` + * rendering each github call as it finishes. Both are read-only observers of + * the bus (the graph additionally runs `git log` / `git show` via the shell + * worker; it never writes). * * Registrations go through `host` so the loader disposes them on hot * reload / worker disconnect. */ import type { Host } from '@iii-dev/console-ui' -import { GithubPage } from './src/page' +import { GithubPage } from './src/page/GithubPage' export default function setup(host: Host) { host.pages.register({ diff --git a/github/ui/src/page/GitGraph.tsx b/github/ui/src/page/GitGraph.tsx new file mode 100644 index 000000000..e24b2875b --- /dev/null +++ b/github/ui/src/page/GitGraph.tsx @@ -0,0 +1,459 @@ +/** + * The live git-graph — the hero view of the github page. It fetches the working + * repo's commit DAG with ONE `shell::exec` call (`git log --all …`), lays it out + * with the pure lane walker (graph-layout.ts), and draws a colored-lane commit + * graph in an SVG gutter with ref labels, subjects and authors alongside — a + * Sourcetree/gitk-style graph that refreshes live as the agent works. + * + * Live: it binds a tab-scoped subscription to the worker's `github::called` + * trigger (the same idiom the activity feed uses) and re-fetches the DAG, + * debounced, whenever a github call lands — plus a manual refresh and a + * live/paused toggle. Selecting a row opens a detail panel that runs + * `git show ` and renders it with the shared diff renderer. + * + * v1 is local-only (the shell worker's default working dir). `enrichWithGithub` + * is the typed seam for a v2 github PR/CI overlay — a no-op today. + */ + +import { + Badge, + Button, + EmptyState, + ErrorBoundary, + type Host, + Skeleton, + StatusDot, + StatusPanel, +} from '@iii-dev/console-ui' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { type Commit, type GraphEdge, type GraphLayout, layout } from './graph-layout' +import { AlertCircle, FolderGit2, RefreshCw } from './icons' +import { ResultView } from './result-views' + +/** The worker's trigger type; see github/src/events.rs. */ +const CALLED_TYPE = 'github::called' +/** Per-tab handler id for the graph's live refresh — distinct from the activity + * feed's (`iii::github-ui::called`) so the two subscriptions never collide. + * The `iii::` prefix keeps per-event invocations out of the trace feed. */ +const GRAPH_EVENTS_FN = 'iii::github-ui::graph-called' +/** Newest-first cap: one shell call, bounded output, bounded layout work. */ +const MAX_COMMITS = 300 +/** Unit separator between `git log` format fields (matches %x1f). */ +const US = '\u001f' +/** Coalesce a burst of github calls into one refetch. */ +const REFRESH_DEBOUNCE_MS = 500 + +/* geometry (px) */ +const ROW_H = 26 +const COL_W = 16 +const NODE_R = 4.5 +const PAD_X = 12 + +/** Wire shape of `shell::exec` (shell/src/functions/types.rs ExecResponse). */ +interface ExecResponse { + exit_code: number | null + stdout: string + stderr: string + stdout_truncated: boolean +} + +type Status = 'loading' | 'ready' | 'error' + +/** + * v2 seam — overlay github PR/CI status onto the local commits (map a commit + * sha to its open PR, checks conclusion, …) so the graph can badge PR/CI state. + * Typed no-op today; wired into the fetch pipeline so v2 only fills the body. + */ +export function enrichWithGithub(commits: Commit[], _host: Host): Commit[] { + // TODO(v2): fetch via github::pr::* / github::api and annotate each commit. + return commits +} + +/** Split `git log --format=%H%x1f%P%x1f%D%x1f%an%x1f%ct%x1f%s` output into commits. */ +function parseLog(stdout: string): Commit[] { + const out: Commit[] = [] + for (const line of stdout.split('\n')) { + if (!line) continue + const parts = line.split(US) + if (parts.length < 6) continue + const [hash, parentField, refField, author, timeField] = parts + // Subject can (rarely) contain the separator; rejoin the tail. + const subject = parts.slice(5).join(US) + out.push({ + hash, + parents: parentField.trim() ? parentField.trim().split(' ').filter(Boolean) : [], + refs: parseRefs(refField), + author, + time: Number(timeField) || 0, + subject, + }) + } + return out +} + +/** `%D` is a comma list: `HEAD -> main, origin/feature, tag: v1.0`. */ +function parseRefs(field: string): string[] { + return field + .split(',') + .map((s) => s.trim()) + .filter(Boolean) +} + +interface RefLabel { + kind: 'head' | 'tag' | 'branch' + name: string +} + +/** Classify raw refs into badge-able labels (HEAD pointer split out). */ +function refLabels(refs: string[]): RefLabel[] { + const labels: RefLabel[] = [] + for (const raw of refs) { + if (raw.startsWith('tag: ')) { + labels.push({ kind: 'tag', name: raw.slice(5) }) + } else if (raw.includes(' -> ')) { + const [head, branch] = raw.split(' -> ') + if (head === 'HEAD') labels.push({ kind: 'head', name: 'HEAD' }) + labels.push({ kind: 'branch', name: branch }) + } else if (raw === 'HEAD') { + labels.push({ kind: 'head', name: 'HEAD' }) + } else { + labels.push({ kind: 'branch', name: raw }) + } + } + return labels +} + +/** + * Fetch + parse the DAG, and keep it live. Binds ONE `github::called` + * subscription for the component's lifetime; each event schedules a debounced + * refetch when live. The binding + timer are torn down on unmount. + */ +function useGitGraph(host: Host) { + const [commits, setCommits] = useState([]) + const [status, setStatus] = useState('loading') + const [error, setError] = useState('') + const [live, setLive] = useState(true) + const [refreshing, setRefreshing] = useState(false) + const liveRef = useRef(live) + liveRef.current = live + const inFlight = useRef(false) + + const fetchDag = useCallback(async () => { + if (inFlight.current) return + inFlight.current = true + setRefreshing(true) + try { + const res = await host.iii.trigger('shell::exec', { + command: 'git', + args: ['log', '--all', '--format=%H%x1f%P%x1f%D%x1f%an%x1f%ct%x1f%s', `--max-count=${MAX_COMMITS}`], + }) + if (res.exit_code !== 0) { + setStatus('error') + setError(res.stderr.trim() || `git exited ${res.exit_code}`) + return + } + setCommits(enrichWithGithub(parseLog(res.stdout), host)) + setStatus('ready') + setError('') + } catch (e) { + setStatus('error') + setError(e instanceof Error ? e.message : String(e)) + } finally { + inFlight.current = false + setRefreshing(false) + } + }, [host]) + + useEffect(() => { + void fetchDag() + let timer: ReturnType | undefined + const offHandler = host.iii.on(GRAPH_EVENTS_FN, () => { + if (!liveRef.current) return + if (timer) clearTimeout(timer) + timer = setTimeout(() => void fetchDag(), REFRESH_DEBOUNCE_MS) + }) + const offTrigger = host.iii.registerTrigger({ + type: CALLED_TYPE, + function_id: `${GRAPH_EVENTS_FN}::${host.iii.browserId}`, + config: {}, + }) + return () => { + if (timer) clearTimeout(timer) + offTrigger() + offHandler() + } + }, [host, fetchDag]) + + return { commits, status, error, live, setLive, refreshing, refresh: fetchDag } +} + +export function GitGraph({ host }: { host: Host }) { + const { commits, status, error, live, setLive, refreshing, refresh } = useGitGraph(host) + const [selected, setSelected] = useState(null) + const graph = useMemo(() => layout(commits.slice(0, MAX_COMMITS)), [commits]) + + return ( +
                      +
                      +
                      +
                      git graph
                      +
                      + {status === 'ready' + ? `${commits.length} commit${commits.length === 1 ? '' : 's'} across all refs` + : status === 'error' + ? 'could not read the repository' + : 'reading the working repository'} +
                      +
                      +
                      + + +
                      +
                      + + {status === 'error' ? ( +
                      + } + headline="git log failed" + detail={error || 'the shell worker returned no output'} + /> +
                      + ) : status === 'loading' && commits.length === 0 ? ( +
                      + {Array.from({ length: 8 }, (_, i) => ( + + ))} +
                      + ) : commits.length === 0 ? ( + + ) : ( +
                      + + {selected ? setSelected(null)} /> : null} +
                      + )} +
                      + ) +} + +const GitGraphIcon = (p: { className?: string }) => + +/* ── the SVG gutter + aligned commit rows ────────────────────────────────── */ + +function GraphCanvas({ + graph, + selected, + onSelect, +}: { + graph: GraphLayout + selected: string | null + onSelect: (hash: string) => void +}) { + const gutter = PAD_X + Math.max(1, graph.columns) * COL_W + const height = graph.rows.length * ROW_H + const x = (col: number) => PAD_X + col * COL_W + const y = (row: number) => row * ROW_H + ROW_H / 2 + + return ( +
                      + + {graph.edges.map((e, i) => ( + + ))} + {graph.rows.map((r) => ( + + {r.isRefTip ? ( + + ) : null} + + + ))} + + +
                      + {graph.rows.map((r) => ( + onSelect(r.commit.hash)} + /> + ))} +
                      +
                      + ) +} + +function edgePath(e: GraphEdge, x: (c: number) => number, y: (r: number) => number): string { + const x0 = x(e.fromColumn) + const y0 = y(e.fromRow) + const x1 = x(e.toColumn) + const y1 = y(e.toRow) + if (e.fromColumn === e.toColumn) return `M ${x0} ${y0} L ${x1} ${y1}` + const t = Math.min(ROW_H, y1 - y0) + if (e.reused) { + // rejoin: straight down the child's lane, curve into the parent at the bottom + const yb = y1 - t + return `M ${x0} ${y0} L ${x0} ${yb} C ${x0} ${yb + t / 2} ${x1} ${y1 - t / 2} ${x1} ${y1}` + } + // fan-out: curve into the new lane at the top, then straight down to the parent + const yb = y0 + t + return `M ${x0} ${y0} C ${x0} ${y0 + t / 2} ${x1} ${yb - t / 2} ${x1} ${yb} L ${x1} ${y1}` +} + +function CommitRow({ + commit, + gutter, + selected, + onSelect, +}: { + commit: Commit + gutter: number + selected: boolean + onSelect: () => void +}) { + const labels = refLabels(commit.refs) + const onKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault() + onSelect() + } + } + return ( +
                      + {labels.length > 0 ? ( + + {labels.map((l, i) => ( + + {l.name} + + ))} + + ) : null} + {commit.subject || '(no message)'} + + {commit.hash.slice(0, 7)} + {commit.author} + {relTime(commit.time)} + +
                      + ) +} + +/* ── commit detail (git show → shared diff renderer) ─────────────────────── */ + +function CommitDetail({ host, hash, onClose }: { host: Host; hash: string; onClose: () => void }) { + const [text, setText] = useState('') + const [truncated, setTruncated] = useState(false) + const [status, setStatus] = useState('loading') + const [error, setError] = useState('') + + useEffect(() => { + let cancelled = false + setStatus('loading') + host.iii + .trigger('shell::exec', { + command: 'git', + args: ['show', '--stat', '--patch', '--no-color', hash], + }) + .then((res) => { + if (cancelled) return + if (res.exit_code !== 0) { + setStatus('error') + setError(res.stderr.trim() || `git exited ${res.exit_code}`) + return + } + setText(res.stdout) + setTruncated(res.stdout_truncated) + setStatus('ready') + }) + .catch((e) => { + if (cancelled) return + setStatus('error') + setError(e instanceof Error ? e.message : String(e)) + }) + return () => { + cancelled = true + } + }, [host, hash]) + + return ( +
                      +
                      + {hash.slice(0, 12)} + +
                      +
                      + {status === 'loading' ? ( + + ) : status === 'error' ? ( +
                      {error || 'could not load this commit'}
                      + ) : ( +
                      could not render this commit
                      }> + +
                      + )} +
                      +
                      + ) +} + +/* ── time ────────────────────────────────────────────────────────────────── */ + +function relTime(unixSeconds: number): string { + if (!unixSeconds) return '' + const s = Math.max(0, Math.floor(Date.now() / 1000 - unixSeconds)) + if (s < 60) return `${s}s ago` + const m = Math.floor(s / 60) + if (m < 60) return `${m}m ago` + const h = Math.floor(m / 60) + if (h < 24) return `${h}h ago` + const d = Math.floor(h / 24) + if (d < 30) return `${d}d ago` + const mo = Math.floor(d / 30) + if (mo < 12) return `${mo}mo ago` + return `${Math.floor(d / 365)}y ago` +} diff --git a/github/ui/src/page/GithubPage.tsx b/github/ui/src/page/GithubPage.tsx new file mode 100644 index 000000000..b2473aae6 --- /dev/null +++ b/github/ui/src/page/GithubPage.tsx @@ -0,0 +1,33 @@ +/** + * The github page shell (#/ext/github): two tabs over the console's shared + * Tabs primitive. + * + * - graph (default, the hero): a live commit DAG of the working repo — colored + * lanes, merge routing, ref labels — refreshing as the agent works (GitGraph). + * - activity: the existing live feed of `github::called` events, unchanged + * (ActivityFeed, from ./index). + * + * Radix Tabs unmounts the inactive tab's content, so each tab owns its own + * `github::called` subscription only while visible — the two never run at once. + */ + +import { type Host, Tabs, TabsContent, TabsList, TabsTrigger } from '@iii-dev/console-ui' +import { GitGraph } from './GitGraph' +import { ActivityFeed } from './index' + +export function GithubPage({ host }: { host: Host }) { + return ( + + + graph + activity + + + + + + + + + ) +} diff --git a/github/ui/src/page/graph-layout.test.ts b/github/ui/src/page/graph-layout.test.ts new file mode 100644 index 000000000..64042ff85 --- /dev/null +++ b/github/ui/src/page/graph-layout.test.ts @@ -0,0 +1,140 @@ +/** + * Unit tests for the pure lane-assignment layout. Run standalone: + * + * npx tsx src/page/graph-layout.test.ts + * + * No test runner and no node builtins (the UI tsconfig ships no @types/node): + * a failed assertion throws (tsx exits non-zero), a clean run logs and exits 0. + * esbuild ignores this file — it is not a build entry point. + */ + +import { type Commit, type GraphEdge, layout } from './graph-layout' + +let checks = 0 + +function assert(cond: boolean, msg: string): void { + checks++ + if (!cond) throw new Error(`assertion failed: ${msg}`) +} + +function eq(actual: T, expected: T, msg: string): void { + assert( + JSON.stringify(actual) === JSON.stringify(expected), + `${msg} — expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`, + ) +} + +function commit(hash: string, parents: string[]): Commit { + return { hash, parents, refs: [], author: 'a', time: 0, subject: hash } +} + +/** Columns in row order. */ +function columns(commits: Commit[]): number[] { + return layout(commits).rows.map((r) => r.column) +} + +/** Edges leaving a given row, sorted by target column for stable comparison. */ +function edgesFrom(edges: GraphEdge[], row: number): GraphEdge[] { + return edges.filter((e) => e.fromRow === row).sort((a, b) => a.toColumn - b.toColumn) +} + +/* ── 1. linear history: every commit on column 0 ─────────────────────────── */ +{ + // A → B → C (each the child of the next; C is the root) + const commits = [commit('A', ['B']), commit('B', ['C']), commit('C', [])] + const l = layout(commits) + eq(columns(commits), [0, 0, 0], 'linear: all columns 0') + eq(l.columns, 1, 'linear: one column wide') + assert( + l.edges.every((e) => e.fromColumn === 0 && e.toColumn === 0), + 'linear: every edge stays on column 0', + ) + eq(l.edges.length, 2, 'linear: two edges (A→B, B→C)') + console.log('ok linear') +} + +/* ── 2. one branch + merge: split to column 1, rejoin to column 0 ────────── */ +{ + // M is a merge of mainline A and feature F; both fork from branch point B. + // M (parents A, F) + // A (parent B) mainline after the fork + // F (parent B) feature commit + // B (parent C) fork point, two children (A and F) + // C (root) + const commits = [commit('M', ['A', 'F']), commit('A', ['B']), commit('F', ['B']), commit('B', ['C']), commit('C', [])] + const l = layout(commits) + eq(columns(commits), [0, 0, 1, 0, 0], 'branch+merge: F splits to column 1, rest column 0') + eq(l.columns, 2, 'branch+merge: two columns wide') + + // M fans out to two parents: mainline A on col 0, feature F on col 1. + const mEdges = edgesFrom(l.edges, 0) + eq(mEdges.length, 2, 'branch+merge: merge commit has two parent edges') + eq( + mEdges.map((e) => e.toColumn), + [0, 1], + 'branch+merge: merge edges land on columns 0 and 1', + ) + eq(mEdges[1].fromColumn, 0, 'branch+merge: feature edge leaves column 0') + assert(mEdges[1].reused === false, 'branch+merge: feature fan-out opens a fresh lane') + + // F rejoins the mainline: its parent B is already awaited on column 0. + const fEdges = edgesFrom(l.edges, 2) + eq(fEdges.length, 1, 'branch+merge: F has one parent edge') + eq(fEdges[0].fromColumn, 1, 'branch+merge: F rejoin leaves column 1') + eq(fEdges[0].toColumn, 0, 'branch+merge: F rejoin lands on column 0') + assert(fEdges[0].reused === true, 'branch+merge: F rejoin reuses the mainline lane') + console.log('ok branch+merge') +} + +/* ── 3. octopus: a 3-parent merge fans to columns 0, 1, 2 and rejoins ────── */ +{ + // O (parents P1, P2, P3) octopus merge + // P1 (parent R) + // P2 (parent R) + // P3 (parent R) + // R (root; the shared parent of all three) + const commits = [ + commit('O', ['P1', 'P2', 'P3']), + commit('P1', ['R']), + commit('P2', ['R']), + commit('P3', ['R']), + commit('R', []), + ] + const l = layout(commits) + eq(columns(commits), [0, 0, 1, 2, 0], 'octopus: three parents fan to columns 0,1,2') + eq(l.columns, 3, 'octopus: three columns wide') + + const oEdges = edgesFrom(l.edges, 0) + eq(oEdges.length, 3, 'octopus: merge has three parent edges') + eq( + oEdges.map((e) => e.toColumn), + [0, 1, 2], + 'octopus: parent edges land on columns 0, 1, 2', + ) + assert( + oEdges.every((e) => e.fromColumn === 0), + 'octopus: all fan-out edges leave column 0', + ) + + // All three P* rejoin onto R at column 0. + for (const row of [1, 2, 3]) { + const e = edgesFrom(l.edges, row) + eq(e.length, 1, `octopus: P at row ${row} has one edge`) + eq(e[0].toColumn, 0, `octopus: P at row ${row} rejoins column 0`) + } + console.log('ok octopus') +} + +/* ── 4. unknown parents (beyond the window) run off the bottom ───────────── */ +{ + // A's parent Z is not in the loaded set: the edge is marked not-known and + // targets the bottom row so the renderer runs it off-screen. + const commits = [commit('A', ['Z'])] + const l = layout(commits) + eq(l.edges.length, 1, 'window: one edge') + assert(l.edges[0].parentKnown === false, 'window: missing parent flagged not-known') + eq(l.edges[0].toRow, 1, 'window: unknown parent edge runs to the bottom row') + console.log('ok windowed history') +} + +console.log(`\nall graph-layout tests passed (${checks} assertions)`) diff --git a/github/ui/src/page/graph-layout.ts b/github/ui/src/page/graph-layout.ts new file mode 100644 index 000000000..ff457d687 --- /dev/null +++ b/github/ui/src/page/graph-layout.ts @@ -0,0 +1,167 @@ +/** + * Pure, dependency-free git-DAG lane assignment — the layout brain behind + * GitGraph.tsx. Given commits newest-first (as `git log --all` emits them), + * it assigns each commit a column (lane) and produces the parent edges the SVG + * renderer draws. No React, no DOM: unit-tested in isolation + * (graph-layout.test.ts, run with `npx tsx`). + * + * The algorithm is the standard git-log lane walk: + * + * lanes[] — one slot per active branch line; each holds the hash the lane is + * still waiting to reach (going downward), or null when free. + * + * For each commit C, in order: + * 1. column = the lowest lane already waiting for C.hash; if none waits for + * it, C is a fresh head → take the lowest free lane (or append one). + * 2. Every lane that was waiting for C.hash has now arrived: free them all. + * 3. For each parent P of C, open a lane that waits for P and emit an edge + * C→P (fromColumn = C.column, toColumn = P's lane): + * - reuse a lane ALREADY waiting for P if one exists (this is how two + * branches converge on a shared parent onto ONE column — the key to + * correct merge/rejoin routing); + * - else the FIRST parent keeps C's own column (mainline stays + * straight); + * - else (extra merge parents) take the lowest free lane / append. + * + * Reuse-first parent assignment guarantees every child that shares a parent + * routes its edge to the SAME column the parent ultimately occupies (the lowest + * lane waiting for it never moves lower while it is waited on), so edges never + * point at the wrong endpoint. + * + * Pass-through verticals need no separate structure: a lane with no commit + * between a child and its parent is exactly one edge spanning those rows, so + * drawing every edge from child row to parent row draws all the verticals too. + */ + +/** One parsed `git log` record. `time` is the committer unix time (seconds). */ +export interface Commit { + hash: string + parents: string[] + refs: string[] + author: string + time: number + subject: string +} + +export interface GraphEdge { + fromRow: number + fromColumn: number + toRow: number + toColumn: number + /** Lane-color index (mod GRAPH_COLORS) of the branch line this edge draws. */ + color: number + /** + * true → the parent lane already existed, so C's line rejoins it: bend at the + * BOTTOM (near the parent) and keep the branch color of `fromColumn`. + * false → a fresh lane opened for this parent (a fan-out / new branch): bend + * at the TOP (near the child), branch color of `toColumn`. + * Meaningless when fromColumn === toColumn (a straight mainline segment). + */ + reused: boolean + /** false when the parent falls outside the loaded window (edge runs off-bottom). */ + parentKnown: boolean +} + +export interface GraphRow { + commit: Commit + row: number + column: number + /** Node color index (mod GRAPH_COLORS) = the commit's own lane. */ + color: number + isRefTip: boolean +} + +export interface GraphLayout { + rows: GraphRow[] + edges: GraphEdge[] + /** Total column count (max column used + 1); 0 for an empty history. */ + columns: number +} + +/** Number of lane colors the palette cycles through (see styles.css --gh-g0..7). */ +export const GRAPH_COLORS = 8 + +interface RawEdge { + fromRow: number + fromColumn: number + toColumn: number + parentHash: string + reused: boolean +} + +export function layout(commits: Commit[]): GraphLayout { + const lanes: (string | null)[] = [] + const rows: GraphRow[] = [] + const rawEdges: RawEdge[] = [] + const rowByHash = new Map() + let maxColumn = -1 + + const firstFree = (): number => { + const i = lanes.indexOf(null) + return i === -1 ? lanes.length : i + } + + for (let i = 0; i < commits.length; i++) { + const c = commits[i] + rowByHash.set(c.hash, i) + + // 1. column for this commit + let col = lanes.indexOf(c.hash) + if (col === -1) col = firstFree() + + // 2. every lane waiting for this commit has arrived — free them + for (let j = 0; j < lanes.length; j++) { + if (lanes[j] === c.hash) lanes[j] = null + } + + // 3. open a lane per parent, emit an edge per parent + for (let p = 0; p < c.parents.length; p++) { + const ph = c.parents[p] + const existing = lanes.indexOf(ph) + let toColumn: number + let reused: boolean + if (existing !== -1) { + toColumn = existing + reused = true + } else if (p === 0) { + toColumn = col + lanes[col] = ph + reused = false + } else { + toColumn = firstFree() + lanes[toColumn] = ph + reused = false + } + rawEdges.push({ fromRow: i, fromColumn: col, toColumn, parentHash: ph, reused }) + if (toColumn > maxColumn) maxColumn = toColumn + } + + if (col > maxColumn) maxColumn = col + rows.push({ + commit: c, + row: i, + column: col, + color: col % GRAPH_COLORS, + isRefTip: c.refs.length > 0, + }) + } + + const total = commits.length + const edges: GraphEdge[] = rawEdges.map((e) => { + const parentKnown = rowByHash.has(e.parentHash) + // Branch lines keep their own color: a rejoin travels in `fromColumn`, a + // fan-out (and any straight segment) travels in `toColumn`. + const color = (e.reused ? e.fromColumn : e.toColumn) % GRAPH_COLORS + return { + fromRow: e.fromRow, + fromColumn: e.fromColumn, + toRow: parentKnown ? (rowByHash.get(e.parentHash) as number) : total, + toColumn: e.toColumn, + color, + reused: e.reused, + parentKnown, + } + }) + + return { rows, edges, columns: maxColumn + 1 } +} diff --git a/github/ui/src/page/index.tsx b/github/ui/src/page/index.tsx index 4b616ac44..80e382c53 100644 --- a/github/ui/src/page/index.tsx +++ b/github/ui/src/page/index.tsx @@ -102,7 +102,7 @@ function useCalledFeed(host: Host) { return { entries, clear, paused, setPaused } } -export function GithubPage({ host }: { host: Host }) { +export function ActivityFeed({ host }: { host: Host }) { const { entries, clear, paused, setPaused } = useCalledFeed(host) const [expanded, setExpanded] = useState(null) diff --git a/github/ui/styles.css b/github/ui/styles.css index cec9b849c..88f120d0b 100644 --- a/github/ui/styles.css +++ b/github/ui/styles.css @@ -10,7 +10,8 @@ * so light/dark theming is free. */ -[data-iii-ui="github"] .gh-page { +[data-iii-ui="github"] .gh-page, +[data-iii-ui="github"] .gh-graph-page { font-family: var(--font-mono, ui-monospace, monospace); color: var(--color-ink); padding: 20px 24px; @@ -18,7 +19,10 @@ } [data-iii-ui="github"] .gh-page *, [data-iii-ui="github"] .gh-page *::before, -[data-iii-ui="github"] .gh-page *::after { +[data-iii-ui="github"] .gh-page *::after, +[data-iii-ui="github"] .gh-graph-page *, +[data-iii-ui="github"] .gh-graph-page *::before, +[data-iii-ui="github"] .gh-graph-page *::after { box-sizing: border-box; } @@ -319,3 +323,165 @@ width: 8rem; } } + +/* --- tab shell (GithubPage.tsx) -------------------------------------- */ +[data-iii-ui="github"] .gh-tabs { + display: flex; + flex-direction: column; + min-height: 0; +} +[data-iii-ui="github"] .gh-tabs-list { + padding: 12px 24px 0; +} +[data-iii-ui="github"] .gh-tabs-content { + min-height: 0; +} + +/* --- git graph (GitGraph.tsx) ---------------------------------------- */ +/* + * Lane colors are semantically fixed (like syntax highlighting), not theme + * tokens: gitk/Sourcetree keep the same lane palette in both themes. The eight + * hues below read on both the light and dark console panels. The renderer sets + * a gh-lane-cN class and paints via currentColor. + */ +[data-iii-ui="github"] .gh-graph-page { + --gh-g0: #2f81f7; + --gh-g1: #3fb950; + --gh-g2: #a371f7; + --gh-g3: #db6d28; + --gh-g4: #db61a2; + --gh-g5: #1f9e94; + --gh-g6: #c9a227; + --gh-g7: #f0503c; +} +[data-iii-ui="github"] .gh-lane-c0 { color: var(--gh-g0); } +[data-iii-ui="github"] .gh-lane-c1 { color: var(--gh-g1); } +[data-iii-ui="github"] .gh-lane-c2 { color: var(--gh-g2); } +[data-iii-ui="github"] .gh-lane-c3 { color: var(--gh-g3); } +[data-iii-ui="github"] .gh-lane-c4 { color: var(--gh-g4); } +[data-iii-ui="github"] .gh-lane-c5 { color: var(--gh-g5); } +[data-iii-ui="github"] .gh-lane-c6 { color: var(--gh-g6); } +[data-iii-ui="github"] .gh-lane-c7 { color: var(--gh-g7); } + +[data-iii-ui="github"] .gh-graph-body { + padding-top: 16px; +} +[data-iii-ui="github"] .gh-graph-skeleton { + display: flex; + flex-direction: column; + gap: 8px; +} + +[data-iii-ui="github"] .gh-graph-split { + display: flex; + flex-direction: column; + min-height: 0; +} + +[data-iii-ui="github"] .gh-graph-scroll { + position: relative; + overflow: auto; + max-height: calc(100vh - 260px); + min-height: 180px; + margin-top: 12px; + border-top: 1px solid var(--color-rule); +} +[data-iii-ui="github"] .gh-graph-svg { + position: absolute; + left: 0; + top: 0; + pointer-events: none; + overflow: visible; +} +[data-iii-ui="github"] .gh-node-selected { + stroke: var(--color-accent); + stroke-width: 2px; +} + +[data-iii-ui="github"] .gh-graph-rows { + position: relative; +} +[data-iii-ui="github"] .gh-graph-row { + display: flex; + align-items: center; + gap: 10px; + cursor: pointer; + transition: background 0.1s; + overflow: hidden; +} +[data-iii-ui="github"] .gh-graph-row:hover { + background: var(--color-panel); +} +[data-iii-ui="github"] .gh-graph-row.selected { + background: var(--color-panel); + box-shadow: inset 2px 0 0 var(--color-accent); +} +[data-iii-ui="github"] .gh-graph-row:focus-visible { + outline: 2px solid var(--color-ring); + outline-offset: -2px; +} + +[data-iii-ui="github"] .gh-graph-refs { + display: inline-flex; + gap: 4px; + flex-shrink: 0; + max-width: 45%; + overflow: hidden; +} +[data-iii-ui="github"] .gh-graph-subject { + flex: 1 1 auto; + min-width: 0; + font-size: 12px; + color: var(--color-ink); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +[data-iii-ui="github"] .gh-graph-meta { + display: inline-flex; + align-items: center; + gap: 10px; + flex-shrink: 0; + font-size: 11px; + color: var(--color-ink-faint); + white-space: nowrap; +} +[data-iii-ui="github"] .gh-graph-hash { + font-family: var(--font-mono, ui-monospace, monospace); + color: var(--color-ink-ghost); +} +[data-iii-ui="github"] .gh-graph-author { + max-width: 12rem; + overflow: hidden; + text-overflow: ellipsis; +} + +/* --- commit detail (git show) ---------------------------------------- */ +[data-iii-ui="github"] .gh-graph-detail { + margin-top: 12px; + border: 1px solid var(--color-rule); + border-left: 2px solid var(--color-accent); + background: var(--color-paper-2); +} +[data-iii-ui="github"] .gh-graph-detail-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + padding: 6px 10px; + border-bottom: 1px solid var(--color-rule); +} +[data-iii-ui="github"] .gh-graph-detail-body { + padding: 8px 10px; + max-height: 34vh; + overflow: auto; +} + +@media (max-width: 720px) { + [data-iii-ui="github"] .gh-graph-author { + display: none; + } + [data-iii-ui="github"] .gh-graph-refs { + max-width: 55%; + } +} From b7b952485ed4e3ae0b3008443186b51e9a81d727 Mon Sep 17 00:00:00 2001 From: Rohit Ghumare Date: Tue, 28 Jul 2026 14:31:27 +0100 Subject: [PATCH 5/6] refactor(github): dedup graph subscription/time-format + gate emit --- github/src/events.rs | 11 +++ github/ui/src/page/GitGraph.tsx | 133 ++++++++++++++++++-------------- github/ui/src/page/events.ts | 53 +++++++++++++ github/ui/src/page/format.ts | 19 +++++ github/ui/src/page/icons.tsx | 49 ------------ github/ui/src/page/index.tsx | 78 +++++-------------- github/ui/styles.css | 9 +++ 7 files changed, 186 insertions(+), 166 deletions(-) create mode 100644 github/ui/src/page/events.ts create mode 100644 github/ui/src/page/format.ts diff --git a/github/src/events.rs b/github/src/events.rs index fe9ac0c46..486daf3f6 100644 --- a/github/src/events.rs +++ b/github/src/events.rs @@ -104,6 +104,10 @@ impl SubscriberSet { self.lock().values().cloned().collect() } + pub fn is_empty(&self) -> bool { + self.lock().is_empty() + } + fn lock(&self) -> std::sync::MutexGuard<'_, HashMap> { self.inner.lock().unwrap_or_else(|p| p.into_inner()) } @@ -146,6 +150,10 @@ impl CalledEmitter { Self { iii, subscribers } } + pub fn has_subscribers(&self) -> bool { + !self.subscribers.is_empty() + } + /// Fan `event` out to every subscriber with `TriggerAction::Void` /// (fire-and-forget). No subscribers ⇒ no work. Delivery failures are /// logged and swallowed — the real call already returned. @@ -211,6 +219,9 @@ where let started = std::time::Instant::now(); let result = fut.await; let duration_ms = started.elapsed().as_millis() as u64; + if !emitter.has_subscribers() { + return result; + } let (ok, result_summary, kind, result_preview) = match &result { Ok(resp) => { let value = serde_json::to_value(resp).unwrap_or(Value::Null); diff --git a/github/ui/src/page/GitGraph.tsx b/github/ui/src/page/GitGraph.tsx index e24b2875b..b2643be89 100644 --- a/github/ui/src/page/GitGraph.tsx +++ b/github/ui/src/page/GitGraph.tsx @@ -25,13 +25,13 @@ import { StatusDot, StatusPanel, } from '@iii-dev/console-ui' -import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { useGithubCalled } from './events' +import { formatRelative } from './format' import { type Commit, type GraphEdge, type GraphLayout, layout } from './graph-layout' import { AlertCircle, FolderGit2, RefreshCw } from './icons' import { ResultView } from './result-views' -/** The worker's trigger type; see github/src/events.rs. */ -const CALLED_TYPE = 'github::called' /** Per-tab handler id for the graph's live refresh — distinct from the activity * feed's (`iii::github-ui::called`) so the two subscriptions never collide. * The `iii::` prefix keeps per-event invocations out of the trace feed. */ @@ -104,20 +104,50 @@ interface RefLabel { name: string } -/** Classify raw refs into badge-able labels (HEAD pointer split out). */ +/** Max ref-label chars before ellipsis, so a long branch name can't wrap the + * row or crowd out the subject. */ +const REF_MAX = 22 + +function clampRef(name: string): string { + return name.length > REF_MAX ? `${name.slice(0, REF_MAX - 1)}…` : name +} + +/** + * Classify raw refs into badge-able labels: HEAD pointer split out, the + * redundant `origin/` prefix dropped, and local/remote duplicates of the same + * branch collapsed to one badge (so `main` + `origin/main` render once). Keeps + * the label list short enough that badges stay single-line. + */ function refLabels(refs: string[]): RefLabel[] { const labels: RefLabel[] = [] + const seen = new Set() + const pushBranch = (branch: string) => { + const short = branch.replace(/^origin\//, '') + const key = `b:${short}` + if (seen.has(key)) return + seen.add(key) + labels.push({ kind: 'branch', name: clampRef(short) }) + } + const pushHead = () => { + if (seen.has('HEAD')) return + seen.add('HEAD') + labels.push({ kind: 'head', name: 'HEAD' }) + } for (const raw of refs) { if (raw.startsWith('tag: ')) { - labels.push({ kind: 'tag', name: raw.slice(5) }) + const name = raw.slice(5) + const key = `t:${name}` + if (seen.has(key)) continue + seen.add(key) + labels.push({ kind: 'tag', name: clampRef(name) }) } else if (raw.includes(' -> ')) { const [head, branch] = raw.split(' -> ') - if (head === 'HEAD') labels.push({ kind: 'head', name: 'HEAD' }) - labels.push({ kind: 'branch', name: branch }) + if (head === 'HEAD') pushHead() + pushBranch(branch) } else if (raw === 'HEAD') { - labels.push({ kind: 'head', name: 'HEAD' }) + pushHead() } else { - labels.push({ kind: 'branch', name: raw }) + pushBranch(raw) } } return labels @@ -137,6 +167,7 @@ function useGitGraph(host: Host) { const liveRef = useRef(live) liveRef.current = live const inFlight = useRef(false) + const timer = useRef | undefined>(undefined) const fetchDag = useCallback(async () => { if (inFlight.current) return @@ -166,23 +197,16 @@ function useGitGraph(host: Host) { useEffect(() => { void fetchDag() - let timer: ReturnType | undefined - const offHandler = host.iii.on(GRAPH_EVENTS_FN, () => { - if (!liveRef.current) return - if (timer) clearTimeout(timer) - timer = setTimeout(() => void fetchDag(), REFRESH_DEBOUNCE_MS) - }) - const offTrigger = host.iii.registerTrigger({ - type: CALLED_TYPE, - function_id: `${GRAPH_EVENTS_FN}::${host.iii.browserId}`, - config: {}, - }) return () => { - if (timer) clearTimeout(timer) - offTrigger() - offHandler() + if (timer.current) clearTimeout(timer.current) } - }, [host, fetchDag]) + }, [fetchDag]) + + useGithubCalled(host, GRAPH_EVENTS_FN, () => { + if (!liveRef.current) return + if (timer.current) clearTimeout(timer.current) + timer.current = setTimeout(() => void fetchDag(), REFRESH_DEBOUNCE_MS) + }) return { commits, status, error, live, setLive, refreshing, refresh: fetchDag } } @@ -190,7 +214,7 @@ function useGitGraph(host: Host) { export function GitGraph({ host }: { host: Host }) { const { commits, status, error, live, setLive, refreshing, refresh } = useGitGraph(host) const [selected, setSelected] = useState(null) - const graph = useMemo(() => layout(commits.slice(0, MAX_COMMITS)), [commits]) + const graph = useMemo(() => layout(commits), [commits]) return (
                      @@ -266,19 +290,27 @@ function GraphCanvas({ const x = (col: number) => PAD_X + col * COL_W const y = (row: number) => row * ROW_H + ROW_H / 2 + // The lane edges depend only on the layout, never on selection — memoize them + // so selecting a row doesn't rebuild every ``. + const edges = useMemo( + () => + graph.edges.map((e, i) => ( + + )), + [graph], + ) + return (
                      - {graph.edges.map((e, i) => ( - - ))} + {edges} {graph.rows.map((r) => ( {r.isRefTip ? ( @@ -309,7 +341,7 @@ function GraphCanvas({ commit={r.commit} gutter={gutter} selected={selected === r.commit.hash} - onSelect={() => onSelect(r.commit.hash)} + onSelect={onSelect} /> ))}
                      @@ -334,7 +366,7 @@ function edgePath(e: GraphEdge, x: (c: number) => number, y: (r: number) => numb return `M ${x0} ${y0} C ${x0} ${y0 + t / 2} ${x1} ${yb - t / 2} ${x1} ${yb} L ${x1} ${y1}` } -function CommitRow({ +const CommitRow = memo(function CommitRow({ commit, gutter, selected, @@ -343,13 +375,13 @@ function CommitRow({ commit: Commit gutter: number selected: boolean - onSelect: () => void + onSelect: (hash: string) => void }) { const labels = refLabels(commit.refs) const onKeyDown = (e: React.KeyboardEvent) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault() - onSelect() + onSelect(commit.hash) } } return ( @@ -359,7 +391,7 @@ function CommitRow({ role="button" tabIndex={0} aria-pressed={selected} - onClick={onSelect} + onClick={() => onSelect(commit.hash)} onKeyDown={onKeyDown} > {labels.length > 0 ? ( @@ -375,11 +407,11 @@ function CommitRow({ {commit.hash.slice(0, 7)} {commit.author} - {relTime(commit.time)} + {commit.time ? formatRelative(Date.now() / 1000 - commit.time) : ''}
                      ) -} +}) /* ── commit detail (git show → shared diff renderer) ─────────────────────── */ @@ -440,20 +472,3 @@ function CommitDetail({ host, hash, onClose }: { host: Host; hash: string; onClo
                      ) } - -/* ── time ────────────────────────────────────────────────────────────────── */ - -function relTime(unixSeconds: number): string { - if (!unixSeconds) return '' - const s = Math.max(0, Math.floor(Date.now() / 1000 - unixSeconds)) - if (s < 60) return `${s}s ago` - const m = Math.floor(s / 60) - if (m < 60) return `${m}m ago` - const h = Math.floor(m / 60) - if (h < 24) return `${h}h ago` - const d = Math.floor(h / 24) - if (d < 30) return `${d}d ago` - const mo = Math.floor(d / 30) - if (mo < 12) return `${mo}mo ago` - return `${Math.floor(d / 365)}y ago` -} diff --git a/github/ui/src/page/events.ts b/github/ui/src/page/events.ts new file mode 100644 index 000000000..324592b47 --- /dev/null +++ b/github/ui/src/page/events.ts @@ -0,0 +1,53 @@ +/** + * The worker's `github::called` trigger — the one subscription both the + * activity feed (index.tsx) and the live git graph (GitGraph.tsx) bind. Each + * caller passes its own per-tab handler id so the two bindings never collide; + * the `iii::` handler prefix keeps the per-event invocations span-suppressed + * and out of the trace feed. See github/src/events.rs for the emitter side. + */ + +import type { Host } from '@iii-dev/console-ui' +import { useEffect, useRef } from 'react' + +/** The worker's trigger type; see github/src/events.rs. */ +export const CALLED_TYPE = 'github::called' + +/** The `github::called` payload the worker emits (github/src/events.rs). */ +export interface CalledEvent { + function_id: string + args_summary: string + repo: string | null + ok: boolean + duration_ms: number + result_summary: string + /** Renderer discriminator for `result_preview`: list/object/text/diff/outcome. */ + kind: string + /** The budgeted, projected result the worker carried in the event; `null` + * when there was nothing useful. Rendered by `kind` (see result-views.tsx). */ + result_preview: unknown + timestamp: string +} + +/** + * Bind ONE tab-scoped subscription to `github::called` under `handlerId` for + * the caller's lifetime and deliver each event to `onEvent`. The latest + * `onEvent` is held in a ref so a changing callback identity never re-registers + * the trigger (mirrors state/ui's `useStateEvents`). The binding is torn down + * on unmount / host change — hot reload disposes it with the page. + */ +export function useGithubCalled(host: Host, handlerId: string, onEvent: (event: CalledEvent) => void): void { + const onEventRef = useRef(onEvent) + onEventRef.current = onEvent + useEffect(() => { + const offHandler = host.iii.on(handlerId, (event) => onEventRef.current(event)) + const offTrigger = host.iii.registerTrigger({ + type: CALLED_TYPE, + function_id: `${handlerId}::${host.iii.browserId}`, + config: {}, + }) + return () => { + offTrigger() + offHandler() + } + }, [host, handlerId]) +} diff --git a/github/ui/src/page/format.ts b/github/ui/src/page/format.ts new file mode 100644 index 000000000..271a1efda --- /dev/null +++ b/github/ui/src/page/format.ts @@ -0,0 +1,19 @@ +/** + * One relative-time formatter shared by the activity feed and the git graph. + * Turns an elapsed-seconds delta into a short label, covering the union of both + * former formatters' buckets: just-now (<5s) / s / m / h / d / mo / y. + */ +export function formatRelative(deltaSeconds: number): string { + const s = Math.max(0, Math.floor(deltaSeconds)) + if (s < 5) return 'just now' + if (s < 60) return `${s}s ago` + const m = Math.floor(s / 60) + if (m < 60) return `${m}m ago` + const h = Math.floor(m / 60) + if (h < 24) return `${h}h ago` + const d = Math.floor(h / 24) + if (d < 30) return `${d}d ago` + const mo = Math.floor(d / 30) + if (mo < 12) return `${mo}mo ago` + return `${Math.floor(d / 365)}y ago` +} diff --git a/github/ui/src/page/icons.tsx b/github/ui/src/page/icons.tsx index 1326401df..a29aaba55 100644 --- a/github/ui/src/page/icons.tsx +++ b/github/ui/src/page/icons.tsx @@ -83,55 +83,6 @@ export function FolderGit2(props: IconProps) { ) } -export function GitPullRequest(props: IconProps) { - return ( - - - - - - - - ) -} - -export function CircleDot(props: IconProps) { - return ( - - - - - ) -} - -export function Workflow(props: IconProps) { - return ( - - - - - - ) -} - -export function Tag(props: IconProps) { - return ( - - - - - ) -} - -export function Search(props: IconProps) { - return ( - - - - - ) -} - export function Activity(props: IconProps) { return ( diff --git a/github/ui/src/page/index.tsx b/github/ui/src/page/index.tsx index 80e382c53..7ae5b887f 100644 --- a/github/ui/src/page/index.tsx +++ b/github/ui/src/page/index.tsx @@ -18,11 +18,11 @@ import { Badge, Button, EmptyState, ErrorBoundary, type Host, StatusDot } from '@iii-dev/console-ui' import { useCallback, useEffect, useRef, useState } from 'react' +import { type CalledEvent, useGithubCalled } from './events' +import { formatRelative } from './format' import { Activity, type IconProps } from './icons' import { ResultView } from './result-views' -/** The worker's trigger type; see github/src/events.rs. */ -const CALLED_TYPE = 'github::called' /** Per-tab handler id — the `iii::` prefix keeps per-event invocations * span-suppressed and out of the trace feed (host.iii.on namespaces it * `::`, which the trigger's function_id must match). */ @@ -30,22 +30,6 @@ const EVENTS_FN = 'iii::github-ui::called' /** Newest-first cap so a long-running session can't grow the list unbounded. */ const MAX_ENTRIES = 200 -/** The `github::called` payload the worker emits (github/src/events.rs). */ -interface CalledEvent { - function_id: string - args_summary: string - repo: string | null - ok: boolean - duration_ms: number - result_summary: string - /** Renderer discriminator for `result_preview`: list/object/text/diff/outcome. */ - kind: string - /** The budgeted, projected result the worker carried in the event; `null` - * when there was nothing useful. Rendered by `kind` (see result-views.tsx). */ - result_preview: unknown - timestamp: string -} - interface Entry extends CalledEvent { /** Stable react key + expansion id. */ key: string @@ -68,35 +52,24 @@ function useCalledFeed(host: Host) { pausedRef.current = paused const seq = useRef(0) - useEffect(() => { - const offHandler = host.iii.on(EVENTS_FN, (event) => { - if (pausedRef.current) return - if (!event || typeof event.function_id !== 'string') return - const entry: Entry = { - function_id: event.function_id, - args_summary: event.args_summary ?? '', - repo: event.repo ?? null, - ok: Boolean(event.ok), - duration_ms: Number(event.duration_ms) || 0, - result_summary: event.result_summary ?? '', - kind: typeof event.kind === 'string' ? event.kind : 'object', - result_preview: event.result_preview ?? null, - timestamp: event.timestamp ?? '', - key: `${Date.now()}-${seq.current++}`, - receivedAt: Date.now(), - } - setEntries((prev) => [entry, ...prev].slice(0, MAX_ENTRIES)) - }) - const offTrigger = host.iii.registerTrigger({ - type: CALLED_TYPE, - function_id: `${EVENTS_FN}::${host.iii.browserId}`, - config: {}, - }) - return () => { - offTrigger() - offHandler() + useGithubCalled(host, EVENTS_FN, (event) => { + if (pausedRef.current) return + if (!event || typeof event.function_id !== 'string') return + const entry: Entry = { + function_id: event.function_id, + args_summary: event.args_summary ?? '', + repo: event.repo ?? null, + ok: Boolean(event.ok), + duration_ms: Number(event.duration_ms) || 0, + result_summary: event.result_summary ?? '', + kind: typeof event.kind === 'string' ? event.kind : 'object', + result_preview: event.result_preview ?? null, + timestamp: event.timestamp ?? '', + key: `${Date.now()}-${seq.current++}`, + receivedAt: Date.now(), } - }, [host]) + setEntries((prev) => [entry, ...prev].slice(0, MAX_ENTRIES)) + }) const clear = useCallback(() => setEntries([]), []) return { entries, clear, paused, setPaused } @@ -201,7 +174,7 @@ function ActivityRow({ {formatDuration(entry.duration_ms)} - {relativeTime(entry.receivedAt, now)} + {formatRelative((now - entry.receivedAt) / 1000)} {expanded ? ( @@ -224,14 +197,3 @@ function formatDuration(ms: number): string { if (ms < 1000) return `${Math.round(ms)}ms` return `${(ms / 1000).toFixed(ms < 10000 ? 1 : 0)}s` } - -function relativeTime(then: number, now: number): string { - const s = Math.max(0, Math.floor((now - then) / 1000)) - if (s < 5) return 'just now' - if (s < 60) return `${s}s ago` - const m = Math.floor(s / 60) - if (m < 60) return `${m}m ago` - const h = Math.floor(m / 60) - if (h < 24) return `${h}h ago` - return `${Math.floor(h / 24)}d ago` -} diff --git a/github/ui/styles.css b/github/ui/styles.css index 88f120d0b..855d71da2 100644 --- a/github/ui/styles.css +++ b/github/ui/styles.css @@ -425,8 +425,17 @@ display: inline-flex; gap: 4px; flex-shrink: 0; + flex-wrap: nowrap; max-width: 45%; overflow: hidden; + white-space: nowrap; +} +[data-iii-ui="github"] .gh-graph-refs > * { + flex-shrink: 0; + max-width: 14rem; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } [data-iii-ui="github"] .gh-graph-subject { flex: 1 1 auto; From ee1a48413798c7586f78c017a167ebca34cf9578 Mon Sep 17 00:00:00 2001 From: Rohit Ghumare Date: Tue, 28 Jul 2026 15:48:26 +0100 Subject: [PATCH 6/6] fix(github): redact inline args, cap diff + list preview, a11y/css Address CodeRabbit review on PR #609: redact inline --flag=value args (body/field/input) and recognize --repo= inline; enforce an aggregate byte budget on list previews; cap DiffView rendering at 500 lines; use type-only KeyboardEvent imports; move flex off table cells to inner wrappers; replace deprecated word-break: break-word with overflow-wrap: anywhere. --- github/src/events.rs | 133 +++++++++++++++++++++++----- github/ui/src/page/GitGraph.tsx | 4 +- github/ui/src/page/index.tsx | 18 ++-- github/ui/src/page/result-views.tsx | 16 +++- github/ui/styles.css | 15 ++-- 5 files changed, 150 insertions(+), 36 deletions(-) diff --git a/github/src/events.rs b/github/src/events.rs index 486daf3f6..3e05fa884 100644 --- a/github/src/events.rs +++ b/github/src/events.rs @@ -239,19 +239,21 @@ where ) } }; - emitter - .emit(CalledEvent { - function_id: function_id.to_string(), - args_summary, - repo, - ok, - duration_ms, - result_summary, - kind, - result_preview, - timestamp: now_rfc3339(), - }) - .await; + let event = CalledEvent { + function_id: function_id.to_string(), + args_summary, + repo, + ok, + duration_ms, + result_summary, + kind, + result_preview, + timestamp: now_rfc3339(), + }; + // Detach the fan-out so a slow subscriber can never delay the real call's + // return — the event + emitter are owned by the spawned task. + let emitter = emitter.clone(); + tokio::spawn(async move { emitter.emit(event).await }); result } @@ -335,14 +337,24 @@ pub fn preview(function_id: &str, result: &Value) -> (String, Value) { /// `{ items: [first N projected], total }` — keep the true length so the UI can /// show "showing 12 of 70", and project each kept item down to its salient -/// display keys (per [`keys_for`]) so the payload stays small. +/// display keys (per [`keys_for`]) so the payload stays small. Items are added +/// until the running serialized size would exceed [`PREVIEW_MAX_BYTES`] (at most +/// [`PREVIEW_MAX_ITEMS`]), so many small-but-not-tiny rows can't bloat the event +/// past the byte budget — `total` still reports the honest count. fn preview_list(function_id: &str, items: &[Value]) -> Value { let allow = keys_for(function_id); - let kept: Vec = items - .iter() - .take(PREVIEW_MAX_ITEMS) - .map(|item| project_item(item, allow)) - .collect(); + let mut kept: Vec = Vec::new(); + let mut used = 0usize; + for item in items.iter().take(PREVIEW_MAX_ITEMS) { + let projected = project_item(item, allow); + let size = value_bytes(&projected); + // Always keep the first item; stop before any later one blows the budget. + if !kept.is_empty() && used + size > PREVIEW_MAX_BYTES { + break; + } + used += size; + kept.push(projected); + } json!({ "items": kept, "total": items.len() }) } @@ -605,12 +617,37 @@ pub fn summarize_args(argv: &[String]) -> String { out.push("…".to_string()); } } - _ => out.push(tok.clone()), + // Inline `--flag=value` forms carry the value in the same token, so + // the separate-arg arms above never see them — redact them here. + _ => { + if let Some(tok) = redact_inline(tok) { + out.push(tok); + } + } } } truncate(&out.join(" "), MAX_ARGS) } +/// Redact the value of an inline `--flag=value` token so a body/field/input can +/// never ride an inline arg into the event. Keeps the flag (and, for a field, +/// its key); `--json=` is plumbing noise and dropped whole to match the +/// separate-arg form. `None` drops the token; non-inline tokens pass through. +fn redact_inline(tok: &str) -> Option { + let Some((flag, value)) = tok.split_once('=') else { + return Some(tok.to_string()); + }; + match flag { + "--json" => None, + "--body" | "--body-file" | "--input" => Some(format!("{flag}=…")), + "--field" | "--raw-field" => { + let key = value.split('=').next().unwrap_or(""); + Some(format!("{flag}={key}=…")) + } + _ => Some(tok.to_string()), + } +} + /// `owner/name` from a gh argv (`-R` / `--repo`), if present. pub fn repo_from_args(argv: &[String]) -> Option { let mut iter = argv.iter(); @@ -618,6 +655,9 @@ pub fn repo_from_args(argv: &[String]) -> Option { if tok == "-R" || tok == "--repo" { return iter.next().cloned(); } + if let Some(repo) = tok.strip_prefix("--repo=") { + return Some(repo.to_string()); + } } None } @@ -763,6 +803,24 @@ mod tests { assert_eq!(pv["items"][0]["number"], json!(0)); } + #[test] + fn preview_list_stops_at_the_aggregate_byte_budget() { + // Each item projects under the per-field cap, but many together exceed + // PREVIEW_MAX_BYTES; the list stops adding rows past the budget while the + // true total stays honest. + let big_title = "t".repeat(600); + let items: Vec = (0..40) + .map(|n| json!({ "number": n, "title": big_title })) + .collect(); + let (kind, pv) = preview("github::pr::list", &json!({ "value": items })); + assert_eq!(kind, "list"); + assert_eq!(pv["total"], json!(40)); + let kept = pv["items"].as_array().unwrap(); + assert!(!kept.is_empty() && kept.len() < PREVIEW_MAX_ITEMS); + let bytes: usize = kept.iter().map(value_bytes).sum(); + assert!(bytes <= PREVIEW_MAX_BYTES, "aggregate {bytes} over budget"); + } + #[test] fn preview_list_projects_to_the_namespace_allowlist() { // A PR list item carries labels + timestamps the feed does not need; @@ -917,6 +975,37 @@ mod tests { ); } + #[test] + fn args_summary_redacts_inline_flag_values() { + let argv: Vec = [ + "pr", + "create", + "--repo=o/r", + "--title=t", + "--body=secret body", + "--field=token=abc123", + "--raw-field=k=v", + "--input=payload", + "--json=number,title", + ] + .iter() + .map(|s| s.to_string()) + .collect(); + let summary = summarize_args(&argv); + // Values that can carry secrets never leak. + assert!(!summary.contains("secret"), "body leaked: {summary}"); + assert!(!summary.contains("abc123"), "field leaked: {summary}"); + assert!(!summary.contains("payload"), "input leaked: {summary}"); + // Flags (and the field key) are kept, values redacted. + assert!(summary.contains("--body=…"), "{summary}"); + assert!(summary.contains("--field=token=…"), "{summary}"); + assert!(summary.contains("--input=…"), "{summary}"); + // Non-secret inline args pass through; the json field list drops. + assert!(summary.contains("--repo=o/r"), "{summary}"); + assert!(summary.contains("--title=t"), "{summary}"); + assert!(!summary.contains("--json"), "json list kept: {summary}"); + } + #[test] fn repo_extracted_from_argv() { let argv: Vec = ["pr", "list", "-R", "iii-hq/workers"] @@ -924,6 +1013,10 @@ mod tests { .map(|s| s.to_string()) .collect(); assert_eq!(repo_from_args(&argv), Some("iii-hq/workers".to_string())); + assert_eq!( + repo_from_args(&["pr".into(), "view".into(), "--repo=octo/cat".into()]), + Some("octo/cat".to_string()) + ); assert_eq!(repo_from_args(&["api".into(), "rate_limit".into()]), None); } diff --git a/github/ui/src/page/GitGraph.tsx b/github/ui/src/page/GitGraph.tsx index b2643be89..c2cc2e06a 100644 --- a/github/ui/src/page/GitGraph.tsx +++ b/github/ui/src/page/GitGraph.tsx @@ -25,7 +25,7 @@ import { StatusDot, StatusPanel, } from '@iii-dev/console-ui' -import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { type KeyboardEvent, memo, useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useGithubCalled } from './events' import { formatRelative } from './format' import { type Commit, type GraphEdge, type GraphLayout, layout } from './graph-layout' @@ -378,7 +378,7 @@ const CommitRow = memo(function CommitRow({ onSelect: (hash: string) => void }) { const labels = refLabels(commit.refs) - const onKeyDown = (e: React.KeyboardEvent) => { + const onKeyDown = (e: KeyboardEvent) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault() onSelect(commit.hash) diff --git a/github/ui/src/page/index.tsx b/github/ui/src/page/index.tsx index 7ae5b887f..aee3d2540 100644 --- a/github/ui/src/page/index.tsx +++ b/github/ui/src/page/index.tsx @@ -17,7 +17,7 @@ */ import { Badge, Button, EmptyState, ErrorBoundary, type Host, StatusDot } from '@iii-dev/console-ui' -import { useCallback, useEffect, useRef, useState } from 'react' +import { type KeyboardEvent, useCallback, useEffect, useRef, useState } from 'react' import { type CalledEvent, useGithubCalled } from './events' import { formatRelative } from './format' import { Activity, type IconProps } from './icons' @@ -145,7 +145,7 @@ function ActivityRow({ expanded: boolean onToggle: () => void }) { - const onKeyDown = (e: React.KeyboardEvent) => { + const onKeyDown = (e: KeyboardEvent) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault() onToggle() @@ -165,13 +165,17 @@ function ActivityRow({ {entry.function_id} - {entry.repo ? {entry.repo} : null} - {entry.args_summary || '—'} +
                      + {entry.repo ? {entry.repo} : null} + {entry.args_summary || '—'} +
                      - - {entry.ok ? 'ok' : 'error'} - {formatDuration(entry.duration_ms)} +
                      + + {entry.ok ? 'ok' : 'error'} + {formatDuration(entry.duration_ms)} +
                      {formatRelative((now - entry.receivedAt) / 1000)} diff --git a/github/ui/src/page/result-views.tsx b/github/ui/src/page/result-views.tsx index 1b1383e9a..6c48736df 100644 --- a/github/ui/src/page/result-views.tsx +++ b/github/ui/src/page/result-views.tsx @@ -192,20 +192,32 @@ function TextView({ preview, error }: { preview: unknown; error: boolean }) { ) } +/** Defensive cap on rendered diff lines. A `git show --stat --patch` diff (see + * GitGraph's CommitDetail) is bounded only by the shell worker's byte cap, + * which can still be thousands of lines — render at most this many so every + * caller of DiffView is protected. */ +const MAX_DIFF_LINES = 500 + function DiffView({ preview }: { preview: unknown }) { const r = asRecord(preview) const diff = typeof r?.diff === 'string' ? (r.diff as string) : '' const truncated = r?.truncated === true if (!diff) return no diff const lines = diff.split('\n') + const shown = lines.slice(0, MAX_DIFF_LINES) + const overflow = lines.length - shown.length return (
                      - {lines.map((line, i) => ( + {shown.map((line, i) => (
                      {line || ' '}
                      ))} - {truncated ? : null} + {overflow > 0 ? ( +
                      … {overflow} more lines (truncated)
                      + ) : truncated ? ( + + ) : null}
                      ) } diff --git a/github/ui/styles.css b/github/ui/styles.css index 855d71da2..c1ceadc20 100644 --- a/github/ui/styles.css +++ b/github/ui/styles.css @@ -89,6 +89,9 @@ [data-iii-ui="github"] .gh-feed-args { min-width: 0; + overflow: hidden; +} +[data-iii-ui="github"] .gh-feed-args-inner { display: flex; align-items: baseline; gap: 8px; @@ -109,11 +112,13 @@ [data-iii-ui="github"] .gh-feed-status { width: 8rem; + font-size: 11px; + white-space: nowrap; +} +[data-iii-ui="github"] .gh-feed-status-inner { display: flex; align-items: center; gap: 6px; - font-size: 11px; - white-space: nowrap; } [data-iii-ui="github"] .gh-ok { color: var(--color-ok); @@ -207,7 +212,7 @@ } [data-iii-ui="github"] .gh-rv-title { color: var(--color-ink); - word-break: break-word; + overflow-wrap: anywhere; } [data-iii-ui="github"] .gh-rv-title a { color: var(--color-accent); @@ -249,7 +254,7 @@ } [data-iii-ui="github"] .gh-rv-gcell { color: var(--color-ink); - word-break: break-word; + overflow-wrap: anywhere; } [data-iii-ui="github"] .gh-rv-more { font-size: 11px; @@ -264,7 +269,7 @@ font-size: 12px; color: var(--color-ink); white-space: pre-wrap; - word-break: break-word; + overflow-wrap: anywhere; max-height: 320px; overflow: auto; }