Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
43 commits
Select commit Hold shift + click to select a range
399aed8
feat(remote-bridge): Telegram platform adapter + pairing capture
Astro-Han Jun 16, 2026
f103ea4
feat(desktop): in-process mobile-companion bridge + Telegram connect UI
Astro-Han Jun 16, 2026
0828c15
docs(remote-bridge): platform adapter contract, transport table, roadmap
Astro-Han Jun 16, 2026
30ed81f
docs(remote-bridge): WeChat-personal is official (Tencent iLink); Wha…
Astro-Han Jun 16, 2026
34b6eda
fix(remote-bridge): drain the full Telegram pairing backlog before ca…
Astro-Han Jun 16, 2026
aab4e4e
fix(desktop): bundle remote-bridge into Electron main instead of exte…
Astro-Han Jun 16, 2026
ed822e1
fix(desktop): preserve paired userName when loading saved remote cred…
Astro-Han Jun 16, 2026
0d83ec0
fix(desktop): keep the pairing token main-owned; confirm without rese…
Astro-Han Jun 16, 2026
5871fc6
fix(desktop): make remote status switch exhaustive for lint
Astro-Han Jun 16, 2026
f37e60d
test(desktop): match vite config assertion to the remote-bridge exclude
Astro-Han Jun 16, 2026
069224b
fix(desktop): inject safeStorage into the credential store for a dete…
Astro-Han Jun 16, 2026
ec60d50
fix(app): use {{name}} placeholder syntax in remote i18n strings
Astro-Han Jun 16, 2026
f1bb52c
fix(remote-bridge): retry the telegram pairing ack so it is not replayed
Astro-Han Jun 16, 2026
120b814
fix(desktop): drop a pairing captured after cancel instead of resurre…
Astro-Han Jun 16, 2026
2045fde
fix(remote-bridge): keep the newline at a telegram split boundary
Astro-Han Jun 17, 2026
e365bd6
fix(desktop): re-assert 0o600 on credential rewrite
Astro-Han Jun 17, 2026
812b345
fix(app): disable remote connect when the bridge api is unavailable
Astro-Han Jun 17, 2026
013287c
fix(app): ignore a stale pairing resolution after cancel
Astro-Han Jun 17, 2026
22e052c
feat(app): wire remote access into the settings surface
Astro-Han Jun 17, 2026
56d55ce
feat(app): flesh out the remote access page
Astro-Han Jun 17, 2026
c9d3f9d
refactor(app): scope the Telegram pairing note to its block
Astro-Han Jun 17, 2026
2d6ca26
refactor(app): move the pairing note into the connect dialog
Astro-Han Jun 17, 2026
8803855
fix(remote-bridge): drop the backlog on telegram (re)start
Astro-Han Jun 17, 2026
fd3c6e4
fix(desktop): land the remote bridge on degraded when the build fails
Astro-Han Jun 17, 2026
781b5a7
docs(desktop): correct the bot-token IPC comments
Astro-Han Jun 17, 2026
d24b68b
fix(app): update settings e2e for the live remote access tab
Astro-Han Jun 17, 2026
92b6643
refactor(remote-bridge): extract a NaN-safe nextOffset helper
Astro-Han Jun 17, 2026
a030f1b
fix(desktop): abort the remote bridge synchronously on stop
Astro-Han Jun 17, 2026
67676a0
test(app): cover connected/degraded/waiting/disconnect in remote snap
Astro-Han Jun 17, 2026
7ed5be5
docs(remote-bridge): record the deferred native-prompt-UI optimization
Astro-Han Jun 17, 2026
34b9bce
feat(remote-bridge): localize chat copy (en/zh) with clearer prompts
Astro-Han Jun 17, 2026
eb7f8b1
feat(desktop): drive remote-bridge locale from the app UI language
Astro-Han Jun 17, 2026
4eebe63
fix(remote-bridge): preflight secure storage before pairing
Astro-Han Jun 17, 2026
fd5d6d5
fix(remote-bridge): drain backlog before getMe so pairing keeps the f…
Astro-Han Jun 17, 2026
c3c04f1
fix(remote-bridge): defer "connected" until the platform is actually …
Astro-Han Jun 17, 2026
4fcc8c3
fix(remote-bridge): gate Telegram readiness on first live poll, bound…
Astro-Han Jun 17, 2026
5afa140
refactor(app): split disconnect dialog into its own lazy module
Astro-Han Jun 17, 2026
93b4211
fix(app): defer remote connect toast until status is actually connected
Astro-Han Jun 17, 2026
08ac21c
fix(app): mask the bot token input
Astro-Han Jun 17, 2026
5265ba3
fix(remote-bridge): retry Telegram sends per-chunk, never resend deli…
Astro-Han Jun 17, 2026
9d7710b
refactor(app): inline the disconnect confirm into remote.tsx
Astro-Han Jun 17, 2026
d357ad3
fix(remote-bridge): count each platform's onReady at most once
Astro-Han Jun 18, 2026
34383aa
docs(remote-bridge): scope platform adapter README to shipped code
Astro-Han Jun 18, 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
1 change: 1 addition & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

13 changes: 7 additions & 6 deletions packages/app/e2e/settings/settings-shell.spec.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
import { test, expect } from "../fixtures"
import { closeSettingsPanel, openSettings } from "../actions"

// Foundation lock for the settings route (nav in the sidebar slot, content in the main slot),
// migrating the existing pages in place (remote / integrations hidden until ready).
// Foundation lock for the settings route (nav in the sidebar slot, content in the main slot).
test("@smoke settings shell shows the migrated nav and switches pages", async ({ page, gotoSession }) => {
await gotoSession()

Expand All @@ -15,12 +14,14 @@ 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 6 tabs: General / Shortcuts / Models / Integrations / Worktrees / Memory
for (const name of ["General", "Shortcuts", "Models", "Integrations", "Worktrees", "Memory"]) {
// Currently 7 tabs: General / Shortcuts / Models / Integrations / Remote access / Worktrees / Memory
for (const name of ["General", "Shortcuts", "Models", "Integrations", "Remote access", "Worktrees", "Memory"]) {
await expect(settings.getByRole("tab", { name })).toBeVisible()
}
// Remote access stays hidden until its page is ready
await expect(settings.getByRole("tab", { name: "Remote access" })).toHaveCount(0)
// 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
117 changes: 117 additions & 0 deletions packages/app/e2e/snap/settings-remote.snap.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
import { test } from "../fixtures"
import { openSettings } from "../actions"
import { composeGrid, snapOutputPath, type Shot } from "./_compose"

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

// Remote access settings page + connect flow. snap runs in web Chromium, which has
// no Electron preload, so window.api.remote is injected here as a stub. The stub
// holds a mutable status the test drives via window.__remote.set(...), and parks
// startPairing() until window.__remote.capture(...) — so one run can snapshot every
// state the user sees: the page (disconnected / connected / degraded) and the
// connect-flow dialog (token / waiting / confirm / disconnect). The IPC behaviour
// behind that API is covered by unit tests in desktop-electron and remote-bridge.
test("settings-remote", async ({ page, project }) => {
test.setTimeout(180_000)

await page.addInitScript(() => {
let status = { state: "disconnected", platform: null, identity: null, error: null } as Record<string, unknown>
const listeners = new Set<(s: unknown) => void>()
let resolvePairing: ((v: unknown) => void) | undefined
;(window as any).api = {
...(window as any).api,
remote: {
getStatus: () => Promise.resolve(status),
// Stays pending so the dialog parks on the "waiting" step until the test
// releases it via __remote.capture().
startPairing: () => new Promise((resolve) => (resolvePairing = resolve)),
cancelPairing: () => Promise.resolve(),
confirmPairing: () => Promise.resolve(),
disconnect: () => Promise.resolve(),
onStatus: (cb: (s: unknown) => void) => {
listeners.add(cb)
return () => listeners.delete(cb)
},
},
}
;(window as any).__remote = {
set: (s: Record<string, unknown>) => {
status = s
listeners.forEach((cb) => cb(status))
},
capture: (sender: unknown) => resolvePairing?.(sender),
}
})

await project.open()
const settings = await openSettings(page)
await settings.getByRole("tab", { name: "Remote access" }).click()

const shots: Shot[] = []

// 1) Disconnected page.
const connect = settings.getByRole("button", { name: "Connect" }).first()
await connect.waitFor({ state: "visible", timeout: 30_000 })
shots.push({ name: "disconnected", buf: await settings.screenshot() })

// 2) Connect dialog — paste the bot token.
await connect.click()
const dialog = page.getByRole("dialog")
await dialog.waitFor({ state: "visible", timeout: 30_000 })
shots.push({ name: "token", buf: await dialog.screenshot() })

// 3) Waiting — token accepted, awaiting the first message (startPairing pending).
await dialog.getByRole("textbox").first().fill("8403172:AAExampleBotTokenForPreview")
await dialog.getByRole("button", { name: "Continue" }).click()
await dialog.getByText("Send your bot a message").waitFor({ state: "visible", timeout: 30_000 })
shots.push({ name: "waiting", buf: await dialog.screenshot() })

// 4) Confirm — the captured sender awaits approval.
await page.evaluate(() =>
(window as any).__remote.capture({ userId: "8403172", userName: "yuhan", botUsername: "my_pawwork_bot" }),
)
await dialog.getByText("Allow this account?").waitFor({ state: "visible", timeout: 30_000 })
shots.push({ name: "confirm", buf: await dialog.screenshot() })

// Close the connect dialog via its Close (X) control. The confirm step's Cancel
// only steps back to the token step (it doesn't close), and the pairing flow
// keeps Escape from tearing the dialog down — so drive the explicit X.
await dialog.getByRole("button", { name: "Close" }).click()
await dialog.waitFor({ state: "hidden", timeout: 30_000 })

// 5) Connected page — green status rule + paired identity.
await page.evaluate(() =>
(window as any).__remote.set({
state: "connected",
platform: "telegram",
identity: { userId: "8403172", userName: "yuhan" },
error: null,
}),
)
await settings.getByText("Connected", { exact: true }).waitFor({ state: "visible", timeout: 30_000 })
shots.push({ name: "connected", buf: await settings.screenshot() })

// 6) Disconnect confirm dialog — only reachable while connected.
await settings.getByRole("button", { name: "Disconnect" }).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 })

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

const out = snapOutputPath("settings-remote")
await composeGrid(shots, out)
process.stdout.write(`\n[snap] settings-remote grid -> ${out}\n\n`)
})
3 changes: 2 additions & 1 deletion 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 @@ -45,7 +45,7 @@
import { TerminalProvider } from "@/context/terminal"
import { AppStartupPending } from "@/components/app-startup-pending"
import { AboutModal } from "@/components/about-modal"
import type { AboutInfo, RendererDiagnosticInput, RendererDiagnosticsExportResult, WebSearchStatus } from "@/desktop-api-contract"
import type { AboutInfo, RemoteBridge, RendererDiagnosticInput, RendererDiagnosticsExportResult, WebSearchStatus } from "@/desktop-api-contract"
import DirectoryLayout from "@/pages/directory-layout"
import Layout from "@/pages/layout"
import AutomationsRoute from "@/pages/automations/automations-route"
Expand Down Expand Up @@ -114,6 +114,7 @@
webSearchStatus?: () => Promise<WebSearchStatus>
saveExaApiKey?: (key: string) => Promise<WebSearchStatus>
removeExaApiKey?: () => Promise<WebSearchStatus>
remote?: RemoteBridge
}
}
}
Expand Down
157 changes: 157 additions & 0 deletions packages/app/src/components/dialog-connect-remote.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
import { Button } from "@opencode-ai/ui/button"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { Dialog } from "@opencode-ai/ui/dialog"
import { Icon } from "@opencode-ai/ui/icon"
import { Spinner } from "@opencode-ai/ui/spinner"
import { TextField } from "@opencode-ai/ui/text-field"
import { Match, onCleanup, Switch } from "solid-js"
import { createStore } from "solid-js/store"
import type { RemotePairingResult } from "@/desktop-api-contract"
import { useLanguage } from "@/context/language"

// Connect flow for the mobile companion. Reuses the connect-provider dialog
// SHAPE — a small state machine inside a Dialog — but its own backend (the
// main-process bridge over window.api.remote), since this connects a chat bot,
// not an LLM provider. Pairing is paste-token → message-the-bot → approve.
export function DialogConnectRemote(props: { onApproved?: () => void }) {
const language = useLanguage()
const dialog = useDialog()

const [store, setStore] = createStore({
step: "token" as "token" | "waiting" | "confirm",
token: "",
captured: undefined as RemotePairingResult | undefined,
error: undefined as string | undefined,
busy: false,
})

// Guard against late async resolutions after the dialog is gone, and stop the
// bot's capture poll when the dialog closes mid-pairing.
const alive = { value: true }
// Monotonic pairing-attempt id: cancelling (or restarting) pairing bumps it, so
// a startPairing() that resolves just after the user cancels is ignored instead
// of flipping the dialog back to the confirm step.
let attempt = 0
onCleanup(() => {
alive.value = false
void window.api?.remote?.cancelPairing()
})

const remote = () => window.api?.remote

async function startPairing(event?: Event) {
event?.preventDefault()
const api = remote()
const token = store.token.trim()
if (!api || token === "" || store.busy) return
const mine = ++attempt
setStore({ step: "waiting", error: undefined })
try {
const captured = await api.startPairing(token)
if (!alive.value || mine !== attempt) return
// null = cancelled before a sender arrived; fall back to the token step.
if (!captured) return setStore({ step: "token" })
setStore({ step: "confirm", captured })
Comment thread
coderabbitai[bot] marked this conversation as resolved.
} catch (err) {
if (!alive.value || mine !== attempt) return
setStore({ step: "token", error: errorMessage(err) })
}
}

async function allow() {
const api = remote()
const captured = store.captured
if (!api || !captured || store.busy) return
setStore("busy", true)
try {
// The main process holds the token + captured identity from startPairing;
// confirm just approves it — we never resend the secret.
await api.confirmPairing()
// The bridge is starting but not yet serving. Hand the success signal to the
// page, which fires the toast only when status actually reaches "connected"
// (and never when a 409 ends in "degraded") — so we don't claim "connected"
// here, a step before it is true. Page-side, so it survives this close().
props.onApproved?.()
if (!alive.value) return
dialog.close()
} catch (err) {
if (!alive.value) return
setStore({ busy: false, step: "token", error: errorMessage(err) })
}
}

function backToToken() {
attempt++ // invalidate any in-flight startPairing so its late resolve is dropped
void remote()?.cancelPairing()
setStore({ step: "token", captured: undefined })
}

return (
<Dialog title={language.t("settings.remote.connect.title")} fit class="w-full max-w-[460px] mx-auto">
<div class="px-6 pt-2 pb-6 flex flex-col gap-5">
<Switch>
<Match when={store.step === "token"}>
<form onSubmit={startPairing} class="flex flex-col gap-4">
<TextField
autofocus
type="password"
label={language.t("settings.remote.connect.token.label")}
placeholder={language.t("settings.remote.connect.token.placeholder")}
name="token"
value={store.token}
onChange={(value) => setStore("token", value)}
validationState={store.error ? "invalid" : undefined}
error={store.error}
/>
<p class="text-small text-fg-weak">{language.t("settings.remote.connect.token.help")}</p>
<div class="flex justify-end">
<Button type="submit" variant="primary" disabled={store.token.trim() === ""}>
{language.t("common.continue")}
</Button>
</div>
</form>
</Match>

<Match when={store.step === "waiting"}>
<div class="flex flex-col gap-3">
<div class="flex items-center gap-2 text-body text-fg-strong">
<Spinner />
<span>{language.t("settings.remote.connect.waiting.title")}</span>
</div>
<p class="text-body text-fg-weak">{language.t("settings.remote.connect.waiting.body")}</p>
<div class="flex justify-end">
<Button variant="secondary" onClick={backToToken}>
{language.t("common.cancel")}
</Button>
</div>
</div>
</Match>

<Match when={store.step === "confirm"}>
<div class="flex flex-col gap-4">
<div class="flex items-center gap-2 text-body text-fg-strong">
<Icon name="circle-check" class="text-icon-success-base" />
<span>{language.t("settings.remote.connect.confirm.title")}</span>
</div>
<p class="text-body text-fg-weak">
{language.t("settings.remote.connect.confirm.body", { name: store.captured?.userName ?? "" })}
</p>
<div class="flex justify-end gap-2">
<Button variant="secondary" onClick={backToToken} disabled={store.busy}>
{language.t("common.cancel")}
</Button>
<Button variant="primary" onClick={allow} disabled={store.busy}>
{language.t("settings.remote.connect.action.allow")}
</Button>
</div>
</div>
</Match>
</Switch>
</div>
</Dialog>
)
}

function errorMessage(err: unknown): string {
return err instanceof Error ? err.message : String(err)
}
36 changes: 36 additions & 0 deletions packages/app/src/desktop-api-contract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,3 +77,39 @@ export type WebSearchStatus = {
needsAttention: boolean
quotaExceeded: boolean
}

/** Mobile-companion connection state, as the renderer sees it. */
export type RemoteState = "disconnected" | "connecting" | "connected" | "degraded"

/** Masked status of the mobile-companion bridge — never includes the bot token. */
export type RemoteStatus = {
state: RemoteState
platform: "telegram" | null
identity: { userId: string; userName: string } | null
error: string | null
}

/** The sender captured during pairing, for the user to approve before connecting. */
export type RemotePairingResult = {
userId: string
userName: string
botUsername?: string
}

/**
* Control surface for the mobile-companion bridge (connect a phone chat app to
* this desktop's agent). Desktop/Electron only. Pairing is two steps: start
* (paste token, then message the bot from your phone — resolves with the
* captured sender, or null if cancelled) then confirm (approve that identity).
* The token crosses this boundary only on `startPairing`; the main process holds
* it from there, so `confirmPairing` approves the captured identity with no args
* and never resends the secret.
*/
export type RemoteBridge = {
getStatus(): Promise<RemoteStatus>
startPairing(token: string): Promise<RemotePairingResult | null>
cancelPairing(): Promise<void>
confirmPairing(): Promise<void>
disconnect(): Promise<void>
onStatus(handler: (status: RemoteStatus) => void): () => void
}
4 changes: 4 additions & 0 deletions packages/app/src/desktop-api.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
export { buildDesktopContext, desktopWindowTitle, type DesktopContext } from "./utils/desktop-context"
export type {
AboutInfo,
RemoteBridge,
RemotePairingResult,
RemoteState,
RemoteStatus,
RendererDiagnosticInput,
RendererDiagnosticsExportResult,
RendererErrorDetails,
Expand Down
Loading
Loading