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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 20 additions & 2 deletions frontend/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ runs lint, tests, and build for PRs into `main` and `codex/` stack branches.

- The work sidebar and searchable installed app roster
- Settings
- Dashboard, Events, and Usage
- Dashboard, Activity, and Usage
- Shared routing and fallback behavior.

Bundled app UI lives under `src/apps/<name>/`. Its module calls
Expand All @@ -35,7 +35,7 @@ Import the module one time from `src/apps/index.ts`. The shell finds the
registration and does not hardcode the app name.

The work sidebar keeps the same destinations across app pages. The Dashboard
opens at `/`. Events and Usage have shared routes. App-declared navigation appears below the page
opens at `/`. Activity and Usage have shared routes. App-declared navigation appears below the page
header. Settings opens from the bottom of the sidebar. Below 650 px, a
navigation button opens a modal drawer. Escape closes the drawer and returns
focus to the button.
Expand Down Expand Up @@ -108,6 +108,24 @@ router hooks, decode each subject component once, and let `subjectApi` encode
the HTTP path. Canonical slug replacement preserves the raw query and hash
and only runs while the owner page is visible.

## Activity

Activity at `/events` shows recorded changes to app work. Search matches recorded
work labels only. App, exact Activity type, and day filters apply to the same
HTTP history and live stream. Day boundaries use the operator timezone and the
next local midnight. The first HTTP page supplies type choices for the selected
app scope. Pagination and live updates retain those choices.

Each selected row opens a 360 px panel, or a full-width page on narrow screens.
The panel reads the exact saved artifact and uses the shared Markdown renderer.
It shows historical request and reply facts. Owner links retain the recorded run
and request round; current approval controls stay on the owner page.

Filters and selection remain in the URL when the operator leaves and returns.
Close and Escape restore row focus. New rows wait behind **New activity** while
older content or details are in use. **Pause updates** affects this feed only.
The feed shows connection state and retains its context after a failed read.

## API and live data

Shared requests use `src/api/client.ts`. The event feed and transcript
Expand Down
8 changes: 4 additions & 4 deletions frontend/src/App.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -136,9 +136,9 @@ describe('command center navigation', () => {
expect(within(pages).getByRole('link', { name: 'History' }).getAttribute('aria-current')).toBe(
'page',
)
fireEvent.click(screen.getByRole('link', { name: 'Events' }))
fireEvent.click(screen.getByRole('link', { name: 'Activity' }))
await screen.findByRole('heading', { name: 'Events feed' })
expect(screen.getByRole('link', { name: 'Events' }).getAttribute('aria-current')).toBe('page')
expect(screen.getByRole('link', { name: 'Activity' }).getAttribute('aria-current')).toBe('page')
await act(async () => {
window.history.back()
})
Expand Down Expand Up @@ -166,7 +166,7 @@ describe('command center navigation', () => {
})
const apps = screen.getByRole('navigation', { name: 'Apps' })
expect(within(apps).getAllByRole('link')).toHaveLength(1)
expect(screen.getByRole('link', { name: 'Events' })).toBeTruthy()
expect(screen.getByRole('link', { name: 'Activity' })).toBeTruthy()
fireEvent.change(screen.getByRole('textbox', { name: 'Find an app' }), {
target: { value: 'missing' },
})
Expand All @@ -193,7 +193,7 @@ describe('command center navigation', () => {
it('leaves a shared page with Escape to where the operator came from', async () => {
renderApp('/notes')
await screen.findByRole('heading', { name: 'Notes home' })
fireEvent.click(screen.getByRole('link', { name: 'Events' }))
fireEvent.click(screen.getByRole('link', { name: 'Activity' }))
await screen.findByRole('heading', { name: 'Events feed' })
await act(async () => {
fireEvent.keyDown(window, { key: 'Escape' })
Expand Down
6 changes: 3 additions & 3 deletions frontend/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ import { registerInstalledApps } from './apps/installed'
import { appHome, appLabel, appOwning, getAppUI, registeredApps } from './apps/registry'

const ROUTER_BASE = import.meta.env.BASE_URL.replace(/\/$/, '')
const SHELL_PAGES: Record<string, string> = { '/': 'Dashboard', '/events': 'Events', '/usage': 'Usage' }
const SHELL_PAGES: Record<string, string> = { '/': 'Dashboard', '/events': 'Activity', '/usage': 'Usage' }

export function App({ account }: { account: Account }) {
const unsavedFormRef = useRef<UnsavedForm | null>(null)
Expand Down Expand Up @@ -239,7 +239,7 @@ function AppShell({

useEffect(() => {
function onKey(event: KeyboardEvent) {
if (hidden || document.querySelector('dialog[open]')) return
if (hidden || event.defaultPrevented || document.querySelector('dialog[open]')) return
const meta = event.metaKey || event.ctrlKey
if (meta && event.key.toLowerCase() === 'k' && defaultApp) {
event.preventDefault()
Expand Down Expand Up @@ -297,7 +297,7 @@ function AppShell({
aria-current={location === '/events' ? 'page' : undefined}
>
<Activity size={17} aria-hidden="true" />
Events
Activity
</Link>
<Link
href="/usage"
Expand Down
81 changes: 81 additions & 0 deletions frontend/src/activity.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
.activity-page { font: 15px/1.5 var(--font-sans); }
.activity-page > .page-shell-body { overflow: hidden; }
.activity-layout { display: flex; min-width: 0; min-height: 0; flex: 1; }
.activity-list { flex: 1; min-width: 0; overflow-y: auto; padding: 28px; scrollbar-color: var(--border-loud) transparent; }
.activity-header { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; margin-bottom: 24px; }
.activity-header h1 { margin: 0; font-size: 26px; line-height: 34px; font-weight: 600; }
.activity-header p { margin: 8px 0 0; color: var(--text-mid); }
.activity-connection { display: flex; align-items: center; gap: 4px; font-size: 13px; }
.activity-connection span { display: inline-flex; align-items: center; gap: 6px; }
.activity-live svg { color: var(--outcome-merged); }
.activity-page button, .activity-destinations a { min-height: 44px; }
.activity-connection button, .activity-filter-footer button:last-child { width: 44px; display: grid; place-items: center; }
.activity-filters { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 12px; padding-bottom: 16px; border-bottom: 1px solid var(--border-loud); }
.activity-filters :is(input, select) { min-width: 0; max-width: 100%; min-height: 44px; padding: 8px 12px; border: 1px solid var(--border-loud); border-radius: 5px; background: var(--surface); color: var(--text); font: inherit; color-scheme: dark; }
.activity-search { display: flex; align-items: center; min-width: 0; position: relative; }
.activity-search svg { position: absolute; left: 12px; color: var(--text-dim); pointer-events: none; }
.activity-search input { width: 100%; padding-left: 38px; }
.activity-search input::placeholder { color: var(--text-dim); opacity: 1; }
.activity-dates { display: flex; gap: 8px; grid-column: 1 / -1; }
.activity-dates label { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 4px; color: var(--text-mid); font-size: 13px; }
.activity-dates input { width: 100%; font-size: 15px; }
.activity-filter-footer { grid-column: 1 / -1; display: flex; align-items: center; gap: 12px; color: var(--text-dim); font-size: 13px; flex-wrap: wrap; }
.activity-filter-footer span { margin-right: auto; }
.activity-order { display: flex; justify-content: space-between; gap: 16px; color: var(--text-dim); font-size: 13px; padding: 20px 0 8px; }
.activity-rows { list-style: none; padding: 0; margin: 0; }
.activity-row { display: grid; grid-template-columns: 16px minmax(0, 1fr) auto 15px; gap: 12px; width: 100%; text-align: left; padding: 20px 8px; border-top: 1px solid var(--border); transition: background-color 140ms ease; }
.activity-row:hover { background: var(--surface); }
.activity-row[aria-pressed="true"] { background: var(--surface-2); }
.activity-row[aria-pressed="true"] strong, .activity-row[aria-pressed="true"] .activity-row-glyph { color: var(--accent); }
.activity-row-glyph { margin-top: 4px; color: var(--text-dim); }
.activity-row.event-kind-failed .activity-row-glyph { color: var(--outcome-failed); }
.activity-row-body { display: flex; flex-direction: column; gap: 5px; min-width: 0; overflow-wrap: anywhere; }
.activity-row strong { font-weight: 600; }
.activity-context { font-size: 14px; color: var(--text-mid); display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
.activity-row time { font: 12px/1.8 var(--font-mono); color: var(--text-mid); white-space: nowrap; }
.activity-row > svg:last-child { margin-top: 5px; color: var(--text-dim); }
.activity-new, .activity-older { width: 100%; margin-top: 12px; border: 1px solid var(--border-loud); border-radius: 5px; color: var(--accent); }
.activity-new { position: sticky; top: 0; z-index: 1; background: var(--surface-2); }
.activity-detail { position: relative; flex: 0 0 360px; width: 360px; min-width: 0; overflow-y: auto; padding: 28px 24px; border-left: 1px solid var(--border-loud); background: var(--surface); overflow-wrap: anywhere; scrollbar-color: var(--border-loud) transparent; }
.activity-detail h2 { font-size: 21px; line-height: 1.4; font-weight: 600; margin: 0 28px 28px 0; }
.activity-close { position: absolute; right: 10px; top: 20px; width: 44px; display: grid; place-items: center; color: var(--text-dim); }
.activity-work-label { color: var(--text-mid); margin-bottom: 24px; }
.activity-detail h3 { font-size: 16px; font-weight: 600; margin: 24px 0 12px; }
.activity-result .markdown-content { font: inherit; color: var(--text); }
.activity-result .markdown-content h2 { margin: 24px 0 10px; padding-top: 16px; border-top: 1px solid var(--border-loud); font-size: 16px; }
.activity-detail pre { white-space: pre-wrap; overflow-wrap: anywhere; font: 13px/1.6 var(--font-mono); max-width: 100%; }
.activity-prose { white-space: pre-wrap; }
.activity-muted { color: var(--text-mid); font-size: 14px; }
.activity-destinations { display: flex; gap: 12px; align-items: center; flex-wrap: wrap; margin-top: 32px; }
.activity-destinations a { display: inline-flex; gap: 8px; align-items: center; color: var(--accent); text-decoration: none; }
.activity-destinations .activity-primary { border: 1px solid var(--border-loud); border-radius: 5px; padding: 8px 12px; color: var(--text); background: var(--surface-2); }
.activity-destinations a:hover { text-decoration: underline; text-underline-offset: 4px; }
.activity-source { margin-top: 28px; color: var(--text-mid); font-size: 13px; }
.activity-source time { font-family: var(--font-mono); }
.activity-source details { margin-top: 16px; }
.activity-source summary { min-height: 44px; padding: 12px 0; cursor: pointer; }
.activity-source dl { margin: 0; }
.activity-source dt { margin-top: 10px; color: var(--text-dim); }
.activity-source dd { margin: 4px 0 0; font-family: var(--font-mono); }
.activity-message { padding: 24px 0; }
.activity-message h2 { font-size: 18px; }
.activity-message button, .activity-detail p button { color: var(--accent); text-decoration: underline; text-underline-offset: 4px; }
.activity-page :is(button, input, select, a, summary):focus-visible { outline: 2px solid var(--accent); outline-offset: 3px; }
.activity-page button:disabled { opacity: .55; cursor: wait; }
.activity-page ::selection { background: var(--border-loud); color: var(--text); }
@media (max-width: 1099px) {
.activity-selected .activity-list { display: none; }
.activity-detail { flex: 1; width: 100%; border-left: 0; }
}
@media (max-width: 649px) {
.activity-list, .activity-detail { padding: 24px 20px; }
.activity-header { flex-wrap: wrap; }
.activity-filters { grid-template-columns: minmax(0, 1fr); }
.activity-filters :is(input, select) { font-size: 16px; }
.activity-row { gap: 8px; grid-template-columns: 16px minmax(0, 1fr) auto; padding-block: 20px; }
.activity-row > svg:last-child { display: none; }
.activity-row time { font-size: 11px; }
}
@media (prefers-reduced-motion: reduce) {
.activity-row { transition: none; }
}
11 changes: 9 additions & 2 deletions frontend/src/api/sse.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ export interface UseSSEOptions {
handlers: Record<string, Handler>
/** Called when EventSource fires `error`. Read live via ref. */
onError?: (event: Event) => void
/** Called when the connection opens or reconnects. */
onOpen?: () => void
/** Hook does nothing while disabled — used to gate by route mount state. */
enabled?: boolean
}
Expand All @@ -28,17 +30,19 @@ export interface UseSSEOptions {
* EventSource reconnects are expensive and re-trigger the backend's "first tick
* emits full state" path, which would otherwise create a churn loop.
*/
export function useSSE(url: string, { handlers, onError, enabled = true }: UseSSEOptions): void {
export function useSSE(url: string, { handlers, onError, onOpen, enabled = true }: UseSSEOptions): void {
const handlersRef = useRef(handlers)
const onErrorRef = useRef(onError)
const onOpenRef = useRef(onOpen)

// Keep the refs current without affecting the connection-managing effect.
useEffect(() => {
handlersRef.current = handlers
}, [handlers])
useEffect(() => {
onErrorRef.current = onError
}, [onError])
onOpenRef.current = onOpen
}, [onError, onOpen])

useEffect(() => {
if (!enabled || !url) return undefined
Expand Down Expand Up @@ -80,12 +84,15 @@ export function useSSE(url: string, { handlers, onError, enabled = true }: UseSS
})
}
source.addEventListener('error', errorListener)
const openListener = () => onOpenRef.current?.()
source.addEventListener('open', openListener)

return () => {
for (const [eventType, listener] of registered) {
source.removeEventListener(eventType, listener)
}
source.removeEventListener('error', errorListener)
source.removeEventListener('open', openListener)
source.close()
}
}, [url, enabled])
Expand Down
2 changes: 2 additions & 0 deletions frontend/src/api/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,8 @@ export interface AskQuestion {
// artifact right here; "external" points at the PR/ticket.
export interface InputRequest {
presentation: 'in_app' | 'external'
label?: string
url?: string
controls?: string[]
questions?: AskQuestion[]
artifact_id?: string | null
Expand Down
4 changes: 2 additions & 2 deletions frontend/src/components/SettingsPages.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1164,12 +1164,12 @@ describe('canonical app settings', () => {
stubFetch(false)
renderSettings('/apps/field_notes/settings')
fireEvent.change(await screen.findByLabelText('Notebook'), { target: { value: 'travel' } })
fireEvent.click(screen.getByRole('link', { name: 'Events' }))
fireEvent.click(screen.getByRole('link', { name: 'Activity' }))
const dialog = screen.getByRole('dialog', { name: 'Save your changes?' })
fireEvent.click(within(dialog).getByRole('button', { name: 'Stay' }))
expect(window.location.pathname).toBe('/apps/field_notes/settings')
expect((screen.getByLabelText('Notebook') as HTMLInputElement).value).toBe('travel')
fireEvent.click(screen.getByRole('link', { name: 'Events' }))
fireEvent.click(screen.getByRole('link', { name: 'Activity' }))
fireEvent.click(
within(screen.getByRole('dialog', { name: 'Save your changes?' })).getByRole('button', {
name: 'Discard',
Expand Down
27 changes: 22 additions & 5 deletions frontend/src/lib/feed.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest'

import { eventLine } from './feed'
import { activityDay, activityTypeLabel, eventLine } from './feed'
// Software Factory's own registration, not a stand-in: its subjectPath is what makes a row navigate.
import '../apps/software_factory/ui'
import type { FeedItem } from '../api/types'
Expand All @@ -22,13 +22,13 @@ describe('eventLine', () => {
it('names the workflow and what it did', () => {
const line = eventLine(event({ kind: 'workflow.running', workflow: 'software_factory.build' }))

expect(line.label).toBe('build response received')
expect(line.label).toBe('Build response received')
expect(line.source).toBe('build')
})

it('calls a parked run waiting on you', () => {
it('names a recorded input request', () => {
expect(eventLine(event({ kind: 'workflow.parked', workflow: 'software_factory.build' })).label).toBe(
'build waiting on you',
'Build input requested',
)
})

Expand Down Expand Up @@ -68,7 +68,7 @@ describe('eventLine', () => {
}),
)

expect(line.label).toBe('profile response received')
expect(line.label).toBe('Profile response received')
expect(line.subject).toBe('acme/widget')
expect(line.path).toBeUndefined()
})
Expand Down Expand Up @@ -109,3 +109,20 @@ it.each([
])('gives %s readable words without an app formatter', (kind, label) => {
expect(eventLine(event({ kind, app: 'field_notes' })).label).toBe(label)
})

it.each([
['2026-03-29', 'Europe/Madrid', '2026-03-28T23:00:00.000Z', '2026-03-29T22:00:00.000Z'],
['2026-10-25', 'Europe/Madrid', '2026-10-24T22:00:00.000Z', '2026-10-25T23:00:00.000Z'],
['2026-03-08', 'America/New_York', '2026-03-08T05:00:00.000Z', '2026-03-09T04:00:00.000Z'],
['2026-09-09', 'Asia/Kolkata', '2026-09-08T18:30:00.000Z', '2026-09-09T18:30:00.000Z'],
])('uses both local midnights for %s in %s', (date, timezone, start, until) => {
expect(activityDay(date, timezone)).toBe(start)
expect(activityDay(date, timezone, true)).toBe(until)
})

it('uses shared type words and registered Factory topic labels', () => {
expect(activityTypeLabel('workflow.scheduled', 'software_factory')).toBe('Queued')
expect(activityTypeLabel('workflow.parked')).toBe('Input requested')
expect(activityTypeLabel('review.completed')).toBe('Review completed')
expect(activityTypeLabel('unknown.topic_name')).toBe('Unknown topic name')
})
Loading