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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/dashboard-agent-views-441.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@gemstack/framework": minor
---

Let the agent push ad-hoc markdown views into the dashboard right rail. A `show-markdown` block in a turn (non-blocking, unlike a choice gate) becomes a `view` event that renders as a first-class Views tab in the right rail, with a sticky top-nav to jump between views. Re-showing the same title updates that view in place.
38 changes: 26 additions & 12 deletions packages/framework-dashboard/components/RightRail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,30 +3,42 @@ import type { ChoiceRequest } from '@gemstack/framework'
import { DocsPanel } from './DocsPanel.js'
import { ProjectLogPanel } from './ProjectLogPanel.js'
import { ChoicesRail } from './ChoicesRail.js'
import { ViewsRail } from './ViewsRail.js'
import type { AgentView } from '../lib/live-state.js'
import { Badge } from './ui/badge.js'
import { Button } from './ui/button.js'
import { cn } from '../lib/utils.js'

type Tab = 'choices' | 'docs' | 'log'
type Tab = 'choices' | 'views' | 'docs' | 'log'

// The right sidebar (#314 third rail): the interactive choice gates the run parks on
// (#440), the surfaced docs (PLAN/TODO), and the committed project log. Docs/log are
// Telefunc-backed reads of the selected project; the choices come from the live event
// stream, passed down from the shell. The rail jumps to Choices whenever a gate opens so
// the decision is in view.
export function RightRail({ projectId, choices }: { projectId: string | null; choices: ChoiceRequest[] }) {
// (#440), the ad-hoc markdown views the agent pushes (#441), the surfaced docs (PLAN/TODO),
// and the committed project log. Choices/views come from the live event stream, passed
// down from the shell; docs/log are Telefunc-backed reads of the selected project. The rail
// jumps to whatever the run most wants seen: a choice gate first, else a fresh view.
export function RightRail({
projectId,
choices,
views,
}: {
projectId: string | null
choices: ChoiceRequest[]
views: AgentView[]
}) {
const [tab, setTab] = useState<Tab>('docs')
const hasChoices = choices.length > 0
const hasViews = views.length > 0

// A gate opening pulls the rail to Choices; when the last one clears, fall back to Docs.
// Pull the rail to the most urgent surface: a choice gate over a view over the docs.
useEffect(() => {
setTab(hasChoices ? 'choices' : 'docs')
}, [hasChoices])
setTab(hasChoices ? 'choices' : hasViews ? 'views' : 'docs')
}, [hasChoices, hasViews])

if (!projectId) return null

const tabs: Tab[] = hasChoices ? ['choices', 'docs', 'log'] : ['docs', 'log']
const label = (t: Tab) => (t === 'choices' ? 'Choices' : t === 'docs' ? 'Docs' : 'Project log')
const tabs: Tab[] = [...(hasChoices ? ['choices' as const] : []), ...(hasViews ? ['views' as const] : []), 'docs', 'log']
const label = (t: Tab) => (t === 'choices' ? 'Choices' : t === 'views' ? 'Views' : t === 'docs' ? 'Docs' : 'Log')
const count = (t: Tab) => (t === 'choices' ? choices.length : t === 'views' ? views.length : 0)

return (
<aside className="flex w-80 shrink-0 flex-col border-l border-border">
Expand All @@ -40,13 +52,15 @@ export function RightRail({ projectId, choices }: { projectId: string | null; ch
onClick={() => setTab(t)}
>
{label(t)}
{t === 'choices' && <Badge className="border-primary/40 text-primary">{choices.length}</Badge>}
{count(t) > 0 && <Badge className="border-primary/40 text-primary">{count(t)}</Badge>}
</Button>
))}
</div>
<div className="flex min-h-0 flex-1 flex-col">
{tab === 'choices' && hasChoices ? (
<ChoicesRail projectId={projectId} choices={choices} />
) : tab === 'views' && hasViews ? (
<ViewsRail views={views} />
) : tab === 'log' ? (
<ProjectLogPanel projectId={projectId} />
) : (
Expand Down
45 changes: 45 additions & 0 deletions packages/framework-dashboard/components/ViewsRail.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { useRef, useState } from 'react'
import type { AgentView } from '../lib/live-state.js'
import { Markdown } from './Markdown.js'
import { cn } from '../lib/utils.js'

// The agent-views rail (#441, part of #314): the ad-hoc markdown the agent pushed to the
// side panel via showMarkdown() (a plan, a summary, a writeup), each a first-class view
// with a sticky top-nav to jump between them. Unlike the choice gates it never blocks the
// run; views arrive over the live event stream and update in place when re-shown.
export function ViewsRail({ views }: { views: AgentView[] }) {
const [active, setActive] = useState(0)

// A view can vanish (a new run truncates the stream); keep the selection in range.
const current = views[Math.min(active, views.length - 1)]

const scroller = useRef<HTMLDivElement>(null)
if (!current) return <p className="p-4 text-sm text-muted-foreground">No views yet.</p>

return (
<div className="flex min-h-0 flex-1 flex-col">
{views.length > 1 && (
<nav className="sticky top-0 z-10 flex flex-wrap gap-1 border-b border-border bg-background/95 p-2 backdrop-blur">
{views.map((v, i) => (
<button
key={v.id}
type="button"
onClick={() => setActive(i)}
title={v.title}
className={cn(
'max-w-[10rem] truncate rounded px-2 py-0.5 text-xs',
i === active ? 'bg-accent text-accent-foreground' : 'bg-muted text-muted-foreground hover:text-foreground',
)}
>
{v.title}
</button>
))}
</nav>
)}
<div ref={scroller} className="min-h-0 flex-1 overflow-y-auto p-4">
<h2 className="mb-2 text-sm font-semibold">{current.title}</h2>
<Markdown text={current.markdown} />
</div>
</div>
)
}
20 changes: 20 additions & 0 deletions packages/framework-dashboard/lib/live-state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,26 @@ export function pendingChoice(events: readonly FrameworkEvent[]): ChoiceRequest
return pendingChoices(events).at(-1) ?? null
}

/** One ad-hoc markdown view the agent pushed to the right rail (#441). */
export interface AgentView {
id: string
title: string
markdown: string
}

/**
* Every markdown view the agent has shown this run (#441), in first-seen order. A `view`
* event with an id already seen updates it in place (the agent re-showed the same title),
* so the rail keeps one entry per view rather than stacking duplicates.
*/
export function agentViews(events: readonly FrameworkEvent[]): AgentView[] {
const byId = new Map<string, AgentView>()
for (const event of events) {
if (event.kind === 'view') byId.set(event.id, { id: event.id, title: event.title, markdown: event.markdown })
}
return [...byId.values()]
}

/**
* Whether the run is still going, i.e. worth showing a Stop button. A run ends with a
* single `end` event; until one arrives (and once anything has streamed) it is live.
Expand Down
5 changes: 3 additions & 2 deletions packages/framework-dashboard/pages/index/+Page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { RightRail } from '../../components/RightRail.js'
import { RelayView } from '../../components/RelayView.js'
import { Badge } from '../../components/ui/badge.js'
import { useLiveEvents } from '../../lib/use-live-events.js'
import { pendingChoices } from '../../lib/live-state.js'
import { pendingChoices, agentViews } from '../../lib/live-state.js'

// The dashboard shell (#405 phase 2): Projects | Runs | main (live event stream or a
// past-run replay) | Docs/Log rail. Everything over the wire is Telefunc — the
Expand All @@ -27,6 +27,7 @@ export default function Page() {
// (#440) read one shared Telefunc Channel. Hooks run before the relay early return below.
const events = useLiveEvents(projectId)
const choices = projectId ? pendingChoices(events) : []
const views = projectId ? agentViews(events) : []

// On the relay (#426), the URL carries `?run=<id>` and there is no local registry or
// files — show that one run read-only. `window` is absent during prerender (ssr:false),
Expand All @@ -48,7 +49,7 @@ export default function Page() {
<main className="flex min-w-0 flex-1 flex-col">
{projectId && runId ? <RunReplay projectId={projectId} runId={runId} /> : <EventStream projectId={projectId} events={events} />}
</main>
<RightRail projectId={projectId} choices={choices} />
<RightRail projectId={projectId} choices={choices} views={views} />
</div>
</div>
)
Expand Down
9 changes: 9 additions & 0 deletions packages/framework/src/events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,13 @@ export type FrameworkEvent =
| { kind: 'preview'; url: string; command: string }
/** A framework-level log line. */
| { kind: 'log'; message: string }
/**
* An ad-hoc markdown view the agent pushed to show the user (#441), e.g. a plan,
* a summary, or a diff writeup. Non-blocking (unlike a `choice`): the dashboard
* renders it as a view in the right rail. `id` is stable per title, so re-showing
* the same view updates it in place rather than stacking a duplicate.
*/
| { kind: 'view'; id: string; title: string; markdown: string }
/**
* Cumulative token + cost usage for the run so far (#322). Emitted after each
* agent turn that reports usage; the dashboard renders a live spend readout and
Expand Down Expand Up @@ -156,6 +163,8 @@ export function formatFrameworkEvent(event: FrameworkEvent): string {
return `▶ your app is running at ${event.url}`
case 'log':
return ` ${event.message}`
case 'view':
return `▶ view: ${event.title}`
case 'usage':
return ` spend: $${event.costUsd.toFixed(4)}${
event.budgetUsd ? ` / $${event.budgetUsd}` : ''
Expand Down
9 changes: 8 additions & 1 deletion packages/framework/src/prompt-run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import type { Driver, DriverEvent, DriverSession } from './driver/index.js'
import { hasSessionIdPlaceholder, resolveSessionLink, type ChoicePick, type ChoiceRequest, type FrameworkEvent } from './events.js'
import { resolveAwaitGate } from './run.js'
import { renderSystemPrompt, systemPromptBlock, type EcoOptions, type TfContext } from './system-prompt.js'
import { AWAIT_PROTOCOL, PLAN_DECLINED_MESSAGE, isDeclinedConfirmation, parseAwaitGate } from './turn-gate.js'
import { AWAIT_PROTOCOL, PLAN_DECLINED_MESSAGE, isDeclinedConfirmation, parseAwaitGate, parseMarkdownViews } from './turn-gate.js'
import { UsageMeter } from './usage.js'

/**
Expand Down Expand Up @@ -134,8 +134,14 @@ export async function runPrompt(opts: RunPromptOptions): Promise<RunPromptResult
onEvent: onDriverEvent,
})

// Non-blocking markdown views the agent showed this turn (#441), pushed to the rail.
const showViews = (text: string): void => {
for (const view of parseMarkdownViews(text)) emit({ kind: 'view', ...view })
}

try {
let turn = await session.prompt(firstPrompt, { signal: runSignal })
showViews(turn.text)
let gate = parseAwaitGate(turn.text)
for (let round = 0; round < MAX_AWAIT_ROUNDS && gate; round++) {
const answer = await resolveAwaitGate(gate, round, { requestChoice: opts.requestChoice, emit, signal: runSignal })
Expand All @@ -150,6 +156,7 @@ export async function runPrompt(opts: RunPromptOptions): Promise<RunPromptResult
`You paused to ask: "${gate.title}". The user chose: ${answer}. Continue with that decision, and do not ask again unless a genuinely new choice comes up.`,
{ signal: runSignal },
)
showViews(turn.text)
gate = parseAwaitGate(turn.text)
}
// The agent kept asking past the limit: finish with the latest turn rather than loop.
Expand Down
8 changes: 7 additions & 1 deletion packages/framework/src/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ import { snapshotWorkspace } from './sandbox.js'
import type { Driver, DriverEvent, DriverSession } from './driver/index.js'
import { memoryFraming, type LoadedMemory } from './memory.js'
import { systemPromptBlock, type EcoOptions, type TfContext } from './system-prompt.js'
import { AWAIT_PROTOCOL, CONFIRM_APPROVED, CONFIRM_DECLINED, PLAN_DECLINED_MESSAGE, isDeclinedConfirmation, parseAwaitGate, type ParsedAwaitGate } from './turn-gate.js'
import { AWAIT_PROTOCOL, CONFIRM_APPROVED, CONFIRM_DECLINED, PLAN_DECLINED_MESSAGE, isDeclinedConfirmation, parseAwaitGate, parseMarkdownViews, type ParsedAwaitGate } from './turn-gate.js'
// Value import from todo-loop.js is a benign cycle: todo-loop.js only calls
// run.js's hoisted function declarations (requestChoices / resolveAwaitGate).
import { runTodoLoop, type TodoLoopResult } from './todo-loop.js'
Expand Down Expand Up @@ -703,7 +703,12 @@ function agentAwaitGate(
): (ctx: BuildContext) => Promise<SupervisorRun> {
return async ctx => {
const { requestChoice, emit } = deps
// Non-blocking markdown views the agent showed this turn (#441), pushed to the rail.
const showViews = (text: string): void => {
for (const view of parseMarkdownViews(text)) emit({ kind: 'view', ...view })
}
let run = await base(ctx)
showViews(run.text)
if (!requestChoice) return run

for (let round = 0; round < MAX_AWAIT_ROUNDS; round++) {
Expand All @@ -719,6 +724,7 @@ function agentAwaitGate(
}
emit({ kind: 'log', message: `Continuing with your choice: ${answer}` })
run = await continueAfterChoice(session, ctx, gate.title, answer)
showViews(run.text)
}
// The agent kept asking past the limit: proceed with the latest turn rather than loop.
emit({ kind: 'log', message: 'Proceeding with the build (await limit reached).' })
Expand Down
30 changes: 29 additions & 1 deletion packages/framework/src/turn-gate.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import assert from 'node:assert/strict'
import { test } from 'node:test'
import { isDeclinedConfirmation, parseAwaitGate, parseChoicesGate, parseConfirmationGate, parseMultiSelectGate } from './turn-gate.js'
import { isDeclinedConfirmation, parseAwaitGate, parseChoicesGate, parseConfirmationGate, parseMarkdownViews, parseMultiSelectGate } from './turn-gate.js'

const block = (json: string): string => 'Here are the options.\n```await-choices\n' + json + '\n```'
const multiBlock = (json: string): string => 'Pick some.\n```await-multiselect\n' + json + '\n```'
Expand Down Expand Up @@ -120,3 +120,31 @@ test('parseAwaitGate discriminates choices vs multiselect, later block wins (#33
const both = parseAwaitGate(block('{ "options": [{ "label": "x" }] }') + '\n' + multiBlock('{ "options": [{ "label": "y" }] }'))
assert.equal(both?.kind, 'multi')
})

test('parseMarkdownViews returns [] when the turn has no show-markdown block (#441)', () => {
assert.deepEqual(parseMarkdownViews('Built the app, nothing to show.'), [])
})

test('parseMarkdownViews parses a titled block, stripping the heading (#441)', () => {
const views = parseMarkdownViews('Here is the plan.\n```show-markdown\n# Deployment plan\n## Steps\n- do X\n```')
assert.deepEqual(views, [{ id: 'deployment-plan', title: 'Deployment plan', markdown: '## Steps\n- do X' }])
})

test('parseMarkdownViews falls back to Note when the block has no heading (#441)', () => {
const views = parseMarkdownViews('```show-markdown\njust some body text\n```')
assert.deepEqual(views, [{ id: 'note', title: 'Note', markdown: 'just some body text' }])
})

test('parseMarkdownViews collects several blocks and keeps the later of a repeated title (#441)', () => {
const views = parseMarkdownViews(
'```show-markdown\n# Plan\nfirst\n```\ntext\n```show-markdown\n# Summary\ndone\n```\n```show-markdown\n# Plan\nupdated\n```',
)
assert.deepEqual(views, [
{ id: 'plan', title: 'Plan', markdown: 'updated' },
{ id: 'summary', title: 'Summary', markdown: 'done' },
])
})

test('parseMarkdownViews skips a blank block (#441)', () => {
assert.deepEqual(parseMarkdownViews('```show-markdown\n# Empty\n```'), [])
})
51 changes: 51 additions & 0 deletions packages/framework/src/turn-gate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,14 @@ export const AWAIT_PROTOCOL = [
'{ "title": "<what to approve>", "file": "PLAN_<slug>.agent.md" }',
'```',
'The framework shows it, waits for the user, and re-prompts you with their answer. Do not continue past it on your own.',
'',
'## Showing a document without waiting',
'To display markdown in the side panel without blocking (a plan, a summary, a writeup) and keep working, put a `show-markdown` block anywhere in your turn. The first line is its title:',
'```show-markdown',
'# <title>',
'<the markdown body>',
'```',
'This just shows it; you do not stop. Re-emit the same title to update that view in place.',
].join('\n')

/** A single-select choice an agent stopped to ask, parsed from an `await-choices` block (#337). */
Expand Down Expand Up @@ -74,6 +82,49 @@ export function isDeclinedConfirmation(gate: ParsedAwaitGate, answer: string): b
return gate.kind === 'confirm' && answer === CONFIRM_DECLINED
}

/** A non-blocking markdown view the agent pushed via a `show-markdown` block (#441). */
export interface ParsedMarkdownView {
/** Stable id (a slug of the title), so re-showing the same title updates in place. */
id: string
/** The view's title (the first `# ` heading, or a fallback). */
title: string
/** The markdown body (the heading line removed). */
markdown: string
}

/** Slugify a title into a stable id, or `view` when it has no usable characters. */
function slugify(title: string): string {
const slug = title
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
return slug || 'view'
}

/**
* Parse every `show-markdown` block (per {@link AWAIT_PROTOCOL}) out of a turn's text
* (#441) — a non-blocking view the agent pushed to the side panel, so a turn may carry
* several and does not stop. Each block's first `# ` line is the title (the rest is the
* body); a block with no heading falls back to "Note". Blank blocks are skipped, and two
* blocks that slug to the same id keep the later one (an in-turn update). Never throws.
*/
export function parseMarkdownViews(text: string): ParsedMarkdownView[] {
const re = /```show-markdown\s+([\s\S]*?)```/g
const byId = new Map<string, ParsedMarkdownView>()
for (const m of text.matchAll(re)) {
const body = (m[1] ?? '').trim()
if (!body) continue
const lines = body.split('\n')
const heading = /^#\s+(.+)/.exec(lines[0] ?? '')
const title = heading ? heading[1]!.trim() : 'Note'
const markdown = (heading ? lines.slice(1).join('\n') : body).trim()
if (!markdown) continue
const id = slugify(title)
byId.set(id, { id, title, markdown })
}
return [...byId.values()]
}

/** Find the body + start index of the last fenced block with `tag` in `text`. */
function lastBlock(text: string, tag: string): { body: string; index: number } | undefined {
const re = new RegExp('```' + tag + '\\s+([\\s\\S]*?)```', 'g')
Expand Down
Loading