Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
193580a
spike: read-only Claude web bridge extension
suleimansh Jul 26, 2026
04999dc
spike: report why extraction failed, and prove the parser offline
suleimansh Jul 26, 2026
40cbec2
spike: look at <code> without a <pre>, and cross shadow roots and frames
suleimansh Jul 26, 2026
39aad6e
spike: brace-match the choice block instead of guessing its indentation
suleimansh Jul 26, 2026
d122bb1
spike: prefer the agent's real question over our own protocol spec
suleimansh Jul 26, 2026
e08a682
feat(the-framework): accept a cloud session's question over a guarded…
suleimansh Jul 26, 2026
aea9d60
feat(the-framework): put the browser bridge behind an opt-in preference
suleimansh Jul 26, 2026
9371fc4
feat(dashboard): show the question a cloud session is parked on
suleimansh Jul 26, 2026
51095cc
feat: post the parked question from the extension to the daemon
suleimansh Jul 26, 2026
604b660
fix: say on the page whether the bridge report actually landed
suleimansh Jul 26, 2026
72e2126
Merge remote-tracking branch 'origin/main' into feat/1237-bridge-ques…
suleimansh Jul 26, 2026
71d38e9
feat(the-framework): publish which cloud sessions the bridge should w…
suleimansh Jul 26, 2026
4ef4ef8
fix: have the options test prove the read path, not just auth
suleimansh Jul 26, 2026
8641d16
fix: say why no tab opened, and offer to try right now
suleimansh Jul 26, 2026
660b65f
fix: check the host grants before blaming the token
suleimansh Jul 26, 2026
4e361a9
fix: one closed tab no longer blacklists every session, and show the …
suleimansh Jul 26, 2026
897f271
feat: mirror what a Claude web session is saying into the run view
suleimansh Jul 26, 2026
3c01a83
fix: report the transcript half on the panel too
suleimansh Jul 26, 2026
c335592
fix: show the running version on the panel, and stop tabs piling up
suleimansh Jul 26, 2026
fd87ffb
fix: mirror the page when there are no article blocks to split on
suleimansh Jul 26, 2026
f95f680
feat: let the injected page script report itself to the daemon
suleimansh Jul 26, 2026
2d87f45
fix: mirror the conversation, not the whole application
suleimansh Jul 26, 2026
6b4748f
Merge remote-tracking branch 'origin/main' into feat/1237-bridge-ques…
suleimansh Jul 26, 2026
efe7573
feat: answer a parked Claude web question from the dashboard (#1237)
suleimansh Jul 26, 2026
a15076c
fix: wait for the composer before failing a delivery (#1237)
suleimansh Jul 26, 2026
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/bridge-answer-back.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@gemstack/the-framework': minor
---

A parked Claude web question can now be answered from the dashboard. The run view renders the bridge's question as real choices: pick one, confirm the send, and the browser extension types that label into the session's composer and submits it. The pick is deliberately narrow and reversible: the daemon only queues a label of the currently parked question (never free text), the send is a second explicit click, a queued pick can be withdrawn until the extension collects it over the new `GET /_bridge/answer`, and the extension reports what its delivery did back over `POST /_bridge/answered`, so the dashboard shows queued, sent, or failed with the reason. The transcript mirror also now keeps the newest text when a page exceeds the mirror cap, instead of sending the rendered system prompt and cutting off the session's actual activity.
5 changes: 5 additions & 0 deletions .changeset/bridge-auto-open.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@gemstack/the-framework': minor
---

The browser bridge now publishes which cloud sessions are worth watching, at `GET /_bridge/sessions`, so the extension can open a pinned background tab for each instead of only working while somebody happens to be looking at claude.ai. Recency is the whole filter and has to be: a web run ends at its hand-off, so every one of them reads `done` whether its session is parked on a question or finished an hour ago, and there is no read-back that would say which. Capped at three, twelve hours back, newest first.
5 changes: 5 additions & 0 deletions .changeset/bridge-transcript.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@gemstack/the-framework': minor
---

The browser bridge now mirrors what a Claude web session is saying, not just the question it is parked on. The extension reads the transcript from the session page and posts what changed to `POST /_bridge/events`, keyed by each message's position so the same message arriving on every DOM change replaces rather than repeats. The run view renders it under the cloud notice. It is a mirror of another product's page rather than a run log of our own: no tool calls, no timings, and nothing arrives while the tab is closed.
5 changes: 5 additions & 0 deletions .changeset/cloud-bridge-question.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@gemstack/the-framework': minor
---

A Claude web run can now show the question its cloud session is parked on, instead of leaving it stranded on claude.ai. A browser extension running in the user's own session reports the question to a new opt-in `/_bridge/question` endpoint, and the run view renders it. The question is keyed by cloud session id, which a web run already carries, so it lands on the right run even though the run itself ended at the hand-off. Off unless the `bridge` preference is set, and the route authenticates with its own bearer token rather than the non-loopback guard, since it is the one route meant to be reached from another origin. The first slice was read only; answering from the dashboard ships alongside in this release.
63 changes: 63 additions & 0 deletions packages/framework-dashboard/components/BridgeSettings.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import { useEffect, useState } from 'react'
import { onBridgeToken } from '../server/reads.telefunc.js'
import { Button } from './ui/button.js'
import { CopyButton } from './ui/copy-button.js'

/**
* Setting up the browser bridge (#1237).
*
* This exists because the feature was unusable without it: turning the bridge on meant editing
* `~/.the-framework.json` by hand, and getting the token meant copying a field out of the same
* file. Neither is something to ask of anyone, and a token nobody can find is a feature nobody
* can enable.
*
* The token is revealed on request rather than rendered outright. Not because showing it is
* dangerous here (anyone who can load this page can already start runs on this machine) but
* because a secret sitting permanently on screen is a secret in every screen recording and
* screenshot, and this dashboard is demoed.
*/
export function BridgeSettings({ enabled, onChange }: { enabled: boolean; onChange: (next: boolean) => void }) {
const [token, setToken] = useState<string | null>(null)
const [shown, setShown] = useState(false)

useEffect(() => {
if (!enabled) {
setToken(null)
setShown(false)
return
}
let live = true
void onBridgeToken()
.then(next => {
if (live) setToken(next)
})
.catch(() => {})
return () => {
live = false
}
}, [enabled])

if (!enabled) return null
return (
<div className="mt-2 space-y-2 rounded-md border border-border bg-muted/30 p-3 text-xs">
<p className="text-muted-foreground">
Install the browser extension, open its options, and paste this token. It reports the question a Claude web
session is parked on, so it shows up here instead of only on claude.ai.
</p>
{token === null ? (
// A restart is genuinely required: the token is read when the daemon starts.
<p className="text-muted-foreground">Restart the dashboard to generate the token.</p>
) : (
<div className="flex flex-wrap items-center gap-2">
<code className="min-w-0 flex-1 truncate rounded bg-muted px-2 py-1 font-mono">
{shown ? token : '•'.repeat(24)}
</code>
<Button variant="outline" size="sm" onClick={() => setShown(v => !v)}>
{shown ? 'Hide' : 'Reveal'}
</Button>
<CopyButton text={token} label="Copy the bridge token" />
</div>
)}
</div>
)
}
138 changes: 134 additions & 4 deletions packages/framework-dashboard/components/CloudRunNotice.test.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,43 @@
import { afterEach, describe, expect, test } from 'vitest'
import { cleanup, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, test, vi } from 'vitest'
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import type { FrameworkEvent } from '@gemstack/the-framework'
import { CloudRunNotice } from './CloudRunNotice.js'

afterEach(cleanup)
// The component reads the bridge over telefunc, and an unmocked telefunc module dies in the
// browser test environment with `assertIsNotBrowser`, which reads as a telefunc bug and is not.
const onBridgeQuestion = vi.fn(async () => null as unknown)
const onBridgeEvents = vi.fn(async () => [] as unknown)
const onBridgeAnswer = vi.fn(async () => null as unknown)
vi.mock('../server/reads.telefunc.js', () => ({ onBridgeQuestion, onBridgeEvents, onBridgeAnswer }))
const sendBridgeAnswer = vi.fn(async () => ({ ok: true }) as { ok: boolean; error?: string })
const sendBridgeAnswerCancel = vi.fn(async () => undefined)
vi.mock('../server/control.telefunc.js', () => ({ sendBridgeAnswer, sendBridgeAnswerCancel }))

const { CloudRunNotice } = await import('./CloudRunNotice.js')

afterEach(() => {
cleanup()
onBridgeQuestion.mockReset()
onBridgeQuestion.mockResolvedValue(null)
onBridgeEvents.mockReset()
onBridgeEvents.mockResolvedValue([])
onBridgeAnswer.mockReset()
onBridgeAnswer.mockResolvedValue(null)
sendBridgeAnswer.mockReset()
sendBridgeAnswer.mockResolvedValue({ ok: true })
sendBridgeAnswerCancel.mockReset()
})

const URL = 'https://claude.ai/code/session_01ABCdefGHIjklMNO?from=cli&m=0'
const handOff = (url = URL): FrameworkEvent => ({ kind: 'driver', event: { type: 'action', label: `cloud ${url}` } })

const QUESTION = {
sessionId: 'session_01ABCdefGHIjklMNO',
title: 'Where should the tickets page live?',
options: [{ label: 'One page, both routes', detail: 'lists every project' }, { label: 'Cross-project only' }],
recommended: 'One page, both routes',
receivedAt: '2026-07-26T18:00:00.000Z',
}

describe('CloudRunNotice (#610)', () => {
test('says the session is still being created before the hand-off lands', () => {
render(<CloudRunNotice target="web" events={[]} />)
Expand Down Expand Up @@ -43,3 +73,103 @@ describe('CloudRunNotice (#610)', () => {
expect(render(<CloudRunNotice events={[]} />).container.firstChild).toBeNull()
})
})

describe('the parked question the bridge reports (#1237)', () => {
test('shows the question and its options once the bridge has one', async () => {
onBridgeQuestion.mockResolvedValue(QUESTION)
render(<CloudRunNotice target="web" events={[handOff()]} />)
await waitFor(() => expect(screen.getByText(QUESTION.title)).toBeTruthy())
expect(screen.getByText('One page, both routes')).toBeTruthy()
expect(screen.getByText('Cross-project only')).toBeTruthy()
expect(screen.getByText(/recommended/i)).toBeTruthy()
})

test('is asked for by cloud session id, which is what the bridge can see', async () => {
onBridgeQuestion.mockResolvedValue(QUESTION)
render(<CloudRunNotice target="web" events={[handOff()]} />)
await waitFor(() => expect(onBridgeQuestion).toHaveBeenCalledWith('session_01ABCdefGHIjklMNO'))
})

test('keeps the link out as the manual way to answer', async () => {
onBridgeQuestion.mockResolvedValue(QUESTION)
render(<CloudRunNotice target="web" events={[handOff()]} />)
const link = await screen.findByRole('link', { name: /Answer it in the session/i })
expect(link.getAttribute('href')).toBe(URL)
})

test('shows nothing extra when no question is parked, so an ordinary run is unchanged', async () => {
render(<CloudRunNotice target="web" events={[handOff()]} />)
await waitFor(() => expect(onBridgeQuestion).toHaveBeenCalled())
expect(screen.queryByRole('link', { name: /Answer it in the session/i })).toBeNull()
})

test('never asks before the hand-off lands: there is no session to ask about', () => {
render(<CloudRunNotice target="web" events={[]} />)
expect(onBridgeQuestion).not.toHaveBeenCalled()
})
})

describe('answering from the dashboard (#1237)', () => {
const ANSWER = { id: 'a1', sessionId: 'session_01ABCdefGHIjklMNO', label: 'Cross-project only', queuedAt: '', state: 'queued' as const }

test('a pick has to be confirmed before anything is sent', async () => {
onBridgeQuestion.mockResolvedValue(QUESTION)
render(<CloudRunNotice target="web" events={[handOff()]} />)
const send = await screen.findByRole('button', { name: /Pick an answer/i })
expect((send as HTMLButtonElement).disabled).toBe(true)
fireEvent.click(screen.getByRole('button', { name: /Cross-project only/i }))
const confirm = screen.getByRole('button', { name: /Send .Cross-project only./i })
expect(sendBridgeAnswer).not.toHaveBeenCalled()
fireEvent.click(confirm)
await waitFor(() => expect(sendBridgeAnswer).toHaveBeenCalledWith('session_01ABCdefGHIjklMNO', 'Cross-project only'))
})

test('a queued answer shows as on its way, and can still be withdrawn', async () => {
onBridgeQuestion.mockResolvedValue(QUESTION)
onBridgeAnswer.mockResolvedValue(ANSWER)
render(<CloudRunNotice target="web" events={[handOff()]} />)
await waitFor(() => expect(screen.getByText(/Sending .Cross-project only./i)).toBeTruthy())
// The question card yields to the sending state, so a second pick cannot race the first.
expect(screen.queryByText(QUESTION.title)).toBeNull()
fireEvent.click(screen.getByRole('button', { name: /Cancel/i }))
await waitFor(() => expect(sendBridgeAnswerCancel).toHaveBeenCalledWith('session_01ABCdefGHIjklMNO'))
})

test('a delivered answer reads as answered, with the question gone', async () => {
onBridgeAnswer.mockResolvedValue({ ...ANSWER, state: 'sent' })
render(<CloudRunNotice target="web" events={[handOff()]} />)
await waitFor(() => expect(screen.getByText(/Answered .Cross-project only./i)).toBeTruthy())
expect(screen.queryByRole('button', { name: /Cancel/i })).toBeNull()
})

test('a failed delivery says so and offers the question again', async () => {
onBridgeQuestion.mockResolvedValue(QUESTION)
onBridgeAnswer.mockResolvedValue({ ...ANSWER, state: 'failed', note: 'no composer on the page' })
render(<CloudRunNotice target="web" events={[handOff()]} />)
await waitFor(() => expect(screen.getByText(/failed: no composer on the page/i)).toBeTruthy())
expect(screen.getByText(QUESTION.title)).toBeTruthy()
})
})

describe('the transcript the bridge scrapes (#1237)', () => {
test('renders what the session has said, in order', async () => {
onBridgeEvents.mockResolvedValue([
{ sessionId: 'session_01ABCdefGHIjklMNO', seq: 0, role: 'agent', text: 'Reading the repo', receivedAt: '' },
{ sessionId: 'session_01ABCdefGHIjklMNO', seq: 1, role: 'agent', text: 'Found three packages', receivedAt: '' },
])
render(<CloudRunNotice target="web" events={[handOff()]} />)
await waitFor(() => expect(screen.getByText('Reading the repo')).toBeTruthy())
expect(screen.getByText('Found three packages')).toBeTruthy()
})

test('shows nothing when the bridge has scraped nothing, so a run with no tab is unchanged', async () => {
render(<CloudRunNotice target="web" events={[handOff()]} />)
await waitFor(() => expect(onBridgeEvents).toHaveBeenCalled())
expect(screen.queryByText(/Reading the repo/)).toBeNull()
})

test('never asks before the hand-off, since there is no session to ask about', () => {
render(<CloudRunNotice target="web" events={[]} />)
expect(onBridgeEvents).not.toHaveBeenCalled()
})
})
Loading
Loading