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
11 changes: 6 additions & 5 deletions src/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -465,14 +465,15 @@ async function startApp(): Promise<void> {
// companion-design.md) but OFF until the user enables it from the Remote
// panel: construction allocates no sockets, no manager subscriptions, no
// secret I/O. The phone bundle comes from `npm run client:build`
// (out/remote-client, sibling of electron-vite's out/main); when it isn't
// built the server serves a placeholder page and the WS API still works,
// so a dev install degrades visibly instead of failing.
const remoteClientDist = join(app.getAppPath(), 'out', 'remote-client')
// (out/remote-client, sibling of electron-vite's out/main). The path is
// passed UNCONDITIONALLY — RemoteServer existence-checks it per request —
// so building the bundle after launch takes effect on the next page load
// instead of requiring an app restart (an existsSync gate here was the
// first thing real-world testing tripped on).
remoteController = new RemoteController({
manager,
journal: appRunJournal,
clientDistDir: existsSync(remoteClientDist) ? remoteClientDist : null,
clientDistDir: join(app.getAppPath(), 'out', 'remote-client'),
// Tunnel binary resolution — bundled artifact first (packaged app),
// then the third_party dev cache (populated by `npm run
// runtime:fetch:cloudflared`; copy-packaged-resources only runs on
Expand Down
38 changes: 32 additions & 6 deletions src/main/remote/RemoteServer.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { EventEmitter } from 'node:events'
import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'node:http'
import { createReadStream, existsSync } from 'node:fs'
import { extname, join, normalize } from 'node:path'
import { extname, join, normalize, sep } from 'node:path'
import type { Duplex } from 'node:stream'

import { WebSocketServer, type WebSocket } from 'ws'
Expand Down Expand Up @@ -286,15 +286,27 @@ export class RemoteServer extends EventEmitter {

private serveClient(pathname: string, res: ServerResponse): void {
const dist = this.deps.clientDistDir
if (!dist) {
res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' })
// Per-REQUEST existence check, not construction-time: clientDistDir is
// captured when the app boots, but in dev the bundle often gets built
// AFTER launch (`npm run client:build`). Gating at construction meant
// the placeholder persisted until a full app restart — the first thing
// real-world testing tripped on. Reading the filesystem per page load
// is negligible next to serving the files themselves, and it makes
// "build, then just reload the phone page" work with zero restarts.
if (!dist || !existsSync(join(dist, 'index.html'))) {
res.writeHead(200, {
'content-type': 'text/html; charset=utf-8',
'cache-control': 'no-cache',
})
res.end(
'<!doctype html><meta name="viewport" content="width=device-width, initial-scale=1">' +
'<title>Agent Code Remote</title>' +
'<body style="font-family:system-ui;padding:2rem;background:#111;color:#eee">' +
'<h1>Agent Code Remote</h1>' +
'<p>The remote client bundle is not built in this install. ' +
'The WebSocket API at <code>/ws</code> is live.</p>',
'<p>The phone client bundle is not built yet. On the desktop, run:</p>' +
'<pre style="background:#222;padding:1rem;border-radius:8px">npm run client:build</pre>' +
'<p>then reload this page — no app restart needed. ' +
'The WebSocket API at <code>/ws</code> is already live.</p>',
)
return
}
Expand All @@ -309,15 +321,29 @@ export class RemoteServer extends EventEmitter {
// survive a reload on the phone.
const index = join(dist, 'index.html')
if (existsSync(index)) {
res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' })
res.writeHead(200, {
'content-type': 'text/html; charset=utf-8',
'cache-control': 'no-cache',
})
createReadStream(index).pipe(res)
} else {
res.writeHead(404).end()
}
return
}
// Cache discipline (learned the hard way — a phone kept serving a
// stale page through a rebuild AND a server restart because we sent no
// headers, and mobile browsers cache heuristically-forever without
// them): the HTML entry must revalidate on every load (`no-cache` =
// cached but always conditional), while Vite's content-hashed assets
// are immutable by construction and can cache for a year — a new build
// changes the hash, and the fresh index.html points at it.
const isHashedAsset = filePath.includes(`${sep}assets${sep}`)
res.writeHead(200, {
'content-type': STATIC_CONTENT_TYPES[extname(filePath)] ?? 'application/octet-stream',
'cache-control': isHashedAsset
? 'public, max-age=31536000, immutable'
: 'no-cache',
})
createReadStream(filePath).pipe(res)
}
Expand Down
18 changes: 17 additions & 1 deletion src/remote-client/src/transcript/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,13 @@ export type SessionTranscript = {
* the jsonl/semantic channels. Dropping it entirely was a review
* finding: those states rendered as a blank feed. */
screenText: string
/** Why the last backfill attempt failed, or null. Shown by SessionView
* when the TUI-text fallback is on screen — a silent fallback is
* indistinguishable from the pre-semantic-rendering UI, which cost a
* real debugging session ("still just dumping the raw terminal?") when
* the phone was actually talking to an outdated backend. Benign
* no-transcript-yet failures are not recorded. */
historyError: string | null
exited: boolean
hasOlderHistory: boolean
loadingOlderHistory: boolean
Expand Down Expand Up @@ -110,6 +117,7 @@ function emptyTranscript(): SessionTranscript {
conditions: null,
workingStatus: null,
screenText: '',
historyError: null,
exited: false,
hasOlderHistory: false,
loadingOlderHistory: false,
Expand Down Expand Up @@ -231,7 +239,14 @@ export class TranscriptStore {
if (!result.ok) {
// "No transcript yet" is normal for a brand-new session — live frames
// will populate the feed and their arrival retries the backfill.
this.mutate(sessionId, t => ({ ...t, loadingOlderHistory: false }))
// Anything ELSE (unknown message type on an old backend, disk error,
// timeout) is surfaced so the fallback view can explain itself.
const benign = /no transcript/i.test(result.error)
this.mutate(sessionId, t => ({
...t,
loadingOlderHistory: false,
historyError: benign ? null : result.error,
}))
return
}
if (this.chunkFileConflicts(state, result.chunk.file)) {
Expand All @@ -254,6 +269,7 @@ export class TranscriptStore {
this.mutate(sessionId, t => ({
...t,
loadingOlderHistory: false,
historyError: null,
// Desktop guard (history.ts): a chunk with more history but NO usable
// marker cannot be paged — advertising the affordance would render a
// permanently dead control.
Expand Down
6 changes: 6 additions & 0 deletions src/remote-client/src/ui/SessionView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,12 @@ export function SessionView({
!transcript.semanticTurn &&
transcript.screenText ? (
<div className="screen">
{transcript.historyError && (
<div className="working" style={{ color: 'var(--danger)' }}>
Rich transcript unavailable ({transcript.historyError}) — showing
raw terminal. The desktop app may need an update/restart.
</div>
)}
<pre className="terminal">{transcript.screenText}</pre>
</div>
) : (
Expand Down