refactor(web): open produced files through the Host, not over HTTP

Scope decision: previews for a browser that is not on the Host machine are
not supported. With that settled, host.openPath answers the supported case
completely — a file:// document in a real browser has full page capabilities
and no reach into /api — and the HTTP serving this branch had built answered
only the unsupported one.

Removed: the /f route and its listener, the workspace-file URL shape,
ApiProxy.workspaceRootOf, ConnectionHandle.fileUrl, and the port published
into the index page.

Kept, and finished:
- the produced-files row a turn ends with, derived from mutation locations;
- the path link now reads as a link at rest, not only on hover — the reported
  "I can't open what it made" was this, sitting on a working capability;
- the Host opener prefers the default BROWSER for .html/.htm/.xhtml/.svg, so
  a developer who binds .html to an editor still gets a rendered page
  (macOS via the LaunchServices https handler, Linux via $BROWSER, every
  failure falling back to the default application).

The retired designs and their measurements stay in the Agent Note, including
why same-origin serving was unsafe and why the sandbox that fixed it broke
the pages invisibly.
This commit is contained in:
ZiyaZhang
2026-08-01 03:15:54 -07:00
parent 59bfe77fb8
commit 8fb6c2bd69
50 changed files with 317 additions and 1211 deletions

View File

@@ -218,25 +218,13 @@ describe('conversation slot inject surface', () => {
await b.runtime.dispose()
})
it('openFile (chat view face) opens a workspace file in a tab and falls back to the host opener outside it', async () => {
it('openFile (chat view face) resolves against session cwd and calls workspaces.openPath', async () => {
const b = await bench()
// A host that publishes a workspace-file port: previews come from that
// origin, which is what keeps them off the API's.
b.runtime.connection.filesPort = 4321
const open = vi.spyOn(window, 'open').mockReturnValue(null)
const { injected } = b.chatViewSurface(ROOT)
// Inside the session cwd: served on the workspace-file origin, so a browser
// anywhere on the network sees the file the agent produced.
injected.openFile('src/a.ts')
expect(open).toHaveBeenCalledWith(`http://localhost:4321/f/${ROOT}/src/a.ts`, '_blank', 'noopener,noreferrer')
expect(b.runtime.workspaces.calls.some(c => c.method === 'openPath')).toBe(false)
// Outside it there is no served URL, so the Host's own opener answers —
// resolved against the session cwd exactly as before.
injected.openFile('/etc/hosts')
await vi.waitFor(() => {
expect(b.runtime.workspaces.calls).toContainEqual({ method: 'openPath', args: ['/etc/hosts'] })
expect(b.runtime.workspaces.calls).toContainEqual({ method: 'openPath', args: ['/proj/src/a.ts'] })
})
open.mockRestore()
await b.runtime.dispose()
})

View File

@@ -136,9 +136,6 @@ async function bench(snapshot: ConversationSnapshot) {
openPath: vi.fn(async () => {}),
}
ctx.provide('workspaces', workspaces)
// The transport face the chat view reads its workspace-file URLs from.
const connection = { fileUrl: vi.fn((_s: unknown, _cwd: string | undefined, path: string) => `http://localhost:4321/f/s-1/${path}`) }
ctx.provide('connection', connection)
ctx.provide('layout', layout)
const locale = new LocaleService(ctx)
ctx.provide('locale', locale)
@@ -246,14 +243,12 @@ describe('run_code sub-calls through the real chat machinery', () => {
subCall(12, parent, 2, 'bash', { command: 'ls notes', description: 'List notes' }, 'demo.txt'),
]]])
const b = await bench(snapshotWith([codeResult(10, parent)], dispatches))
const open = vi.spyOn(window, 'open').mockReturnValue(null)
const view = mountApp(b.slots)
view.getByText('notes/demo.txt').click()
expect(b.layout.openDetails).not.toHaveBeenCalled()
await vi.waitFor(() => {
expect(open).toHaveBeenCalledWith('http://localhost:4321/f/s-1/notes/demo.txt', '_blank', 'noopener,noreferrer')
expect(b.workspaces.openPath).toHaveBeenCalledWith('notes/demo.txt')
})
open.mockRestore()
view.getByText('List notes').click()
expect(b.layout.openDetails).not.toHaveBeenCalled()
})

View File

@@ -119,17 +119,14 @@ describe('keyed toolview hole through the real machinery', () => {
await b.runtime.dispose()
})
it('file-path clicks travel owner openFile → chat inject → the served workspace URL', async () => {
it('file-path clicks travel owner openFile → chat inject → workspaces.openPath', async () => {
const b = await bench([toolResult(3, 'c1', 'read', '{"path":"src/a.ts"}')])
b.runtime.connection.filesPort = 4321
const open = vi.spyOn(window, 'open').mockReturnValue(null)
const view = b.runtime.renderRoot()
view.getByText('src/a.ts').click()
expect(b.layout.openDetails).not.toHaveBeenCalled()
await vi.waitFor(() => {
expect(open).toHaveBeenCalledWith(expect.stringContaining('/src/a.ts'), '_blank', 'noopener,noreferrer')
expect(b.runtime.workspaces.calls).toContainEqual({ method: 'openPath', args: ['src/a.ts'] })
})
open.mockRestore()
await b.runtime.dispose()
})