Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
20230be
feat(remote-bridge): platform-agnostic multi-channel supervisor
Astro-Han Jun 19, 2026
4c49ed9
feat(app): multi-channel remote IPC contract
Astro-Han Jun 19, 2026
5e27525
feat(desktop-electron): main-process multi-channel bridge runtime
Astro-Han Jun 19, 2026
26464f9
feat(app): relocate remote control to its own sidebar surface
Astro-Han Jun 19, 2026
d7858b2
fix(desktop-electron): clear bridge state and credentials on disconnect
Astro-Han Jun 19, 2026
1ed6482
fix(remote): validate the bot token before the bind hint
Astro-Han Jun 19, 2026
b18a990
test(desktop-electron): cover multi-channel runtime isolation
Astro-Han Jun 19, 2026
a868b48
fix(app): show a checking step before the bind hint
Astro-Han Jun 19, 2026
cc06285
fix(remote-bridge): drop the abort listener when start settles
Astro-Han Jun 19, 2026
c35d6ae
fix(desktop-electron): stop the bridge before deleting its state
Astro-Han Jun 19, 2026
4dbe114
fix(app): don't let a stale status snapshot overwrite a live update
Astro-Han Jun 19, 2026
53f5026
fix(app): start pairing for the dialog's platform, not a hardcoded one
Astro-Han Jun 19, 2026
6d2541e
fix(app): keep the disconnect dialog open when revoking fails
Astro-Han Jun 19, 2026
949af8e
feat(app): give the pairing wait a "listening" pulse
Astro-Han Jun 19, 2026
b91247a
fix(remote-bridge): re-check abort after getMe before signaling bind
Astro-Han Jun 19, 2026
baaf6e0
fix(app): swallow a rejected status snapshot and guard post-unsubscribe
Astro-Han Jun 19, 2026
762a68a
fix(desktop-electron): don't emit cancelled for a superseded pairing
Astro-Han Jun 19, 2026
44dc3ab
docs(remote-bridge): tag the shape diagram fence as text (MD040)
Astro-Han Jun 19, 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
9 changes: 3 additions & 6 deletions packages/app/e2e/settings/settings-shell.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,14 +14,11 @@ test("@smoke settings shell shows the migrated nav and switches pages", async ({
await expect(settings.getByRole("tab", { name: "General" })).toHaveAttribute("aria-selected", "true")
await expect(settings.locator('[data-action="settings-language"]')).toBeVisible()

// Currently 7 tabs: General / Shortcuts / Models / Integrations / Remote access / Worktrees / Memory
for (const name of ["General", "Shortcuts", "Models", "Integrations", "Remote access", "Worktrees", "Memory"]) {
// Currently 6 tabs: General / Shortcuts / Models / Integrations / Worktrees / Memory.
// (Remote control moved out of settings to its own top-level surface — see remote-surface.snap.ts.)
for (const name of ["General", "Shortcuts", "Models", "Integrations", "Worktrees", "Memory"]) {
await expect(settings.getByRole("tab", { name })).toBeVisible()
}
// Remote access is a real page now: clicking it renders the Telegram connection row.
await settings.getByRole("tab", { name: "Remote access" }).click()
await expect(settings.getByRole("button", { name: "Connect" })).toBeVisible()
await expect(settings.getByText("Telegram", { exact: true })).toBeVisible()

// Models page = providers + models stacked: both blocks render
await settings.getByRole("tab", { name: "Models" }).click()
Expand Down
119 changes: 119 additions & 0 deletions packages/app/e2e/snap/remote-surface.snap.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
import { test } from "../fixtures"
import { openSidebar } from "../actions"
import { composeGrid, snapOutputPath, type Shot } from "./_compose"

test.use({ viewport: { width: 1440, height: 900 }, deviceScaleFactor: 2 })

// Remote control surface + connect flow. snap runs in web Chromium, which has no
// Electron preload, so window.api.remote is injected here as a stub: a mutable
// per-channel status the test drives via __remote.set(...), and a pairing-event
// emitter (__remote.emit) that walks the connect dialog through bind -> captured.
// One run snapshots every state the user sees — the page (disconnected / connected
// / degraded) and the connect dialog (Telegram token + message bind, captured
// confirm, disconnect). The IPC behind that API is covered by desktop-electron +
// remote-bridge unit tests.
test("remote-surface", async ({ page, project }) => {
test.setTimeout(180_000)

await page.addInitScript(() => {
let status: Record<string, unknown> = { channels: [] }
const statusListeners = new Set<(s: unknown) => void>()
const pairingListeners = new Set<(e: unknown) => void>()
;(window as any).api = {
...(window as any).api,
remote: {
getStatus: () => Promise.resolve(status),
startPairing: () => Promise.resolve(),
cancelPairing: () => Promise.resolve(),
confirmPairing: () => Promise.resolve(),
disconnect: () => Promise.resolve(),
onStatus: (cb: (s: unknown) => void) => {
statusListeners.add(cb)
return () => statusListeners.delete(cb)
},
onPairing: (cb: (e: unknown) => void) => {
pairingListeners.add(cb)
return () => pairingListeners.delete(cb)
},
},
}
;(window as any).__remote = {
set: (s: Record<string, unknown>) => {
status = s
statusListeners.forEach((cb) => cb(status))
},
emit: (event: unknown) => pairingListeners.forEach((cb) => cb(event)),
}
})

await project.open()
await openSidebar(page)
await page.locator('[data-action="pawwork-remote-open"]').click()
const surface = page.locator('[data-component="remote-page"]')
await surface.waitFor({ state: "visible", timeout: 30_000 })

const shots: Shot[] = []

// 1) Disconnected page — Telegram offers Connect.
await surface.locator('[data-action="remote-connect-telegram"]').waitFor({ state: "visible", timeout: 30_000 })
shots.push({ name: "disconnected", buf: await surface.screenshot() })

// 2) Telegram — token step, then the message bind.
await surface.locator('[data-action="remote-connect-telegram"]').click()
let dialog = page.getByRole("dialog")
await dialog.waitFor({ state: "visible", timeout: 30_000 })
shots.push({ name: "telegram-token", buf: await dialog.screenshot() })
await dialog.getByRole("textbox").first().fill("8403172:AAExampleBotTokenForPreview")
await dialog.getByRole("button", { name: "Continue" }).click()
// Continue shows a "checking" step; the backend emits awaitingBind once the token
// is validated. The stub has no backend, so drive that event to reach the bind step.
await page.evaluate(() => (window as any).__remote.emit({ phase: "awaitingBind", platform: "telegram", hint: "message" }))
await dialog.getByText("Message the bot").waitFor({ state: "visible", timeout: 30_000 })
shots.push({ name: "telegram-bind", buf: await dialog.screenshot() })

// 3) Captured → confirm (shared across platforms).
await page.evaluate(() =>
(window as any).__remote.emit({ phase: "captured", platform: "telegram", identity: { id: "8403172", name: "yuhan" } }),
)
await dialog.getByText("Allow this connection?").waitFor({ state: "visible", timeout: 30_000 })
shots.push({ name: "confirm", buf: await dialog.screenshot() })
await dialog.getByRole("button", { name: "Close" }).click()
await dialog.waitFor({ state: "hidden", timeout: 30_000 })

// 4) Connected page — Telegram green + paired identity.
await page.evaluate(() =>
(window as any).__remote.set({
channels: [{ platform: "telegram", state: "connected", identity: { id: "8403172", name: "yuhan" }, error: null }],
}),
)
await surface.getByText("Connected", { exact: true }).waitFor({ state: "visible", timeout: 30_000 })
shots.push({ name: "connected", buf: await surface.screenshot() })

// 5) Disconnect confirm — only reachable while connected.
await surface.locator('[data-action="remote-disconnect-telegram"]').click()
const disconnectDialog = page.getByRole("dialog")
await disconnectDialog.waitFor({ state: "visible", timeout: 30_000 })
shots.push({ name: "disconnect", buf: await disconnectDialog.screenshot() })
await disconnectDialog.getByRole("button", { name: "Cancel" }).click()
await disconnectDialog.waitFor({ state: "hidden", timeout: 30_000 })

// 6) Degraded page — red status rule + error detail.
await page.evaluate(() =>
(window as any).__remote.set({
channels: [
{
platform: "telegram",
state: "degraded",
identity: { id: "8403172", name: "yuhan" },
error: "Lost connection to Telegram",
},
],
}),
)
await surface.getByText("Lost connection to Telegram").first().waitFor({ state: "visible", timeout: 30_000 })
shots.push({ name: "degraded", buf: await surface.screenshot() })

const out = snapOutputPath("remote-surface")
await composeGrid(shots, out)
process.stdout.write(`\n[snap] remote-surface grid -> ${out}\n\n`)
})
117 changes: 0 additions & 117 deletions packages/app/e2e/snap/settings-remote.snap.ts

This file was deleted.

2 changes: 2 additions & 0 deletions packages/app/src/app.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
import "@/index.css"
import { I18nProvider } from "@opencode-ai/ui/context"
import { DialogProvider } from "@opencode-ai/ui/context/dialog"
Expand Down Expand Up @@ -49,6 +49,7 @@
import DirectoryLayout from "@/pages/directory-layout"
import Layout from "@/pages/layout"
import AutomationsRoute from "@/pages/automations/automations-route"
import RemoteRoute from "@/pages/remote/remote-route"
import SettingsRoute from "@/pages/settings/settings-route"
import SkillsRoute from "@/pages/skills/skills-route"
import { ErrorPage } from "./pages/error"
Expand Down Expand Up @@ -439,6 +440,7 @@
<Route path="/settings" component={SettingsRoute} />
<Route path="/automations" component={AutomationsRoute} />
<Route path="/skills" component={SkillsRoute} />
<Route path="/remote" component={RemoteRoute} />
<Route path="/:dir" component={DirectoryLayout}>
<Route path="/" component={SessionIndexRoute} />
<Route path="/session/:id?" component={SessionRoute} />
Expand Down
Loading
Loading