Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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.

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
186 changes: 186 additions & 0 deletions packages/app/src/components/dialog-connect-remote.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,186 @@
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 { showToast } from "@opencode-ai/ui/toast"
import { createSignal, 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() {
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 }
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
setStore({ step: "waiting", error: undefined })
try {
const captured = await api.startPairing(token)
if (!alive.value) 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.
Resolved
} catch (err) {
if (!alive.value) 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 {
await api.confirmPairing(store.token.trim(), captured.userId, captured.userName)
if (!alive.value) return
dialog.close()
showToast({
variant: "success",
icon: "circle-check",
title: language.t("settings.remote.connect.toast.title"),
description: language.t("settings.remote.connect.toast.body"),
})
} catch (err) {
if (!alive.value) return
setStore({ busy: false, step: "token", error: errorMessage(err) })
}
}

function backToToken() {
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="text"
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>
)
}

// Irreversible confirm: disconnecting wipes the saved token, so it gets a
// two-button Dialog with a danger action, per the design system.
export function DialogDisconnectRemote() {
const language = useLanguage()
const dialog = useDialog()
const [busy, setBusy] = createSignal(false)

const handleDisconnect = async () => {
if (busy()) return
setBusy(true)
try {
await window.api?.remote?.disconnect()
dialog.close()
} finally {
setBusy(false)
}
}

return (
<Dialog title={language.t("settings.remote.disconnect.title")} fit class="w-full max-w-[420px] mx-auto">
<div class="px-6 pt-2 pb-6">
<span class="text-body text-fg-strong">{language.t("settings.remote.disconnect.body")}</span>
</div>
<div class="flex justify-end gap-2 px-6 pb-6">
<Button variant="secondary" onClick={() => dialog.close()} disabled={busy()}>
{language.t("common.cancel")}
</Button>
<Button variant="danger" onClick={handleDisconnect} disabled={busy()}>
{language.t("settings.remote.action.disconnect")}
</Button>
</div>
</Dialog>
)
}

function errorMessage(err: unknown): string {
return err instanceof Error ? err.message : String(err)
}
34 changes: 34 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,37 @@ 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 bot token is held main-only and never crosses this boundary.
*/
export type RemoteBridge = {
getStatus(): Promise<RemoteStatus>
startPairing(token: string): Promise<RemotePairingResult | null>
cancelPairing(): Promise<void>
confirmPairing(token: string, allowFrom: string, userName?: string): 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
28 changes: 27 additions & 1 deletion packages/app/src/i18n/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -958,7 +958,33 @@ export const dict = {
"settings.tab.integrations": "Integrations",
"settings.tab.memory": "Memory",
"settings.backToApp": "Back to app",
"settings.remote.placeholder": "Remote access settings will live here.",
"settings.remote.description":
"Message your agent from a chat app on your phone. Your computer connects out — no public address or server setup.",
"settings.remote.section.telegram": "Telegram",
"settings.remote.status.disconnected": "Not connected",
"settings.remote.status.connecting": "Connecting…",
"settings.remote.status.connected": "Connected",
"settings.remote.status.degraded": "Connection problem",
"settings.remote.pairedWith": "Paired with {name}",
"settings.remote.action.connect": "Connect",
"settings.remote.action.disconnect": "Disconnect",
"settings.remote.connect.title": "Connect Telegram",
"settings.remote.connect.token.label": "Bot token",
"settings.remote.connect.token.placeholder": "123456789:ABCdef…",
"settings.remote.connect.token.help": "Create a bot with @BotFather in Telegram, then paste the token it gives you.",
"settings.remote.connect.waiting.title": "Send your bot a message",
"settings.remote.connect.waiting.body":
"Open Telegram on your phone and send any message to your bot. The first sender becomes the only person who can drive your agent.",
"settings.remote.connect.confirm.title": "Allow this account?",
"settings.remote.connect.confirm.body":
"{name} will be able to send prompts and answer permission requests in your agent sessions. Only allow yourself.",
"settings.remote.connect.action.allow": "Allow",
"settings.remote.connect.error": "Could not connect: {error}",
"settings.remote.connect.toast.title": "Telegram connected",
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
"settings.remote.connect.toast.body": "You can now message your agent from Telegram.",
"settings.remote.disconnect.title": "Disconnect Telegram?",
"settings.remote.disconnect.body":
"This removes the saved bot token from this computer. You can connect again any time.",
"settings.integrations.description":
"Servers PawWork connects to, plus MCP, language servers, and plugins available to your sessions.",
"settings.integrations.empty": "None configured.",
Expand Down
24 changes: 23 additions & 1 deletion packages/app/src/i18n/zh.ts
Original file line number Diff line number Diff line change
Expand Up @@ -843,7 +843,29 @@ export const dict = {
"settings.tab.integrations": "集成",
"settings.tab.memory": "记忆",
"settings.backToApp": "返回应用",
"settings.remote.placeholder": "远程访问设置即将在此处提供。",
"settings.remote.description": "在手机的聊天软件里和你的 agent 对话。电脑主动向外连接,不需要公网地址或服务器配置。",
"settings.remote.section.telegram": "Telegram",
"settings.remote.status.disconnected": "未连接",
"settings.remote.status.connecting": "连接中…",
"settings.remote.status.connected": "已连接",
"settings.remote.status.degraded": "连接异常",
"settings.remote.pairedWith": "已配对:{name}",
"settings.remote.action.connect": "连接",
"settings.remote.action.disconnect": "断开",
"settings.remote.connect.title": "连接 Telegram",
"settings.remote.connect.token.label": "机器人 Token",
"settings.remote.connect.token.placeholder": "123456789:ABCdef…",
"settings.remote.connect.token.help": "在 Telegram 里用 @BotFather 创建一个机器人,把它给你的 Token 粘贴进来。",
"settings.remote.connect.waiting.title": "给机器人发条消息",
"settings.remote.connect.waiting.body": "在手机上打开 Telegram,给你的机器人发任意一条消息。第一个发消息的人会成为唯一能操控你 agent 的人。",
"settings.remote.connect.confirm.title": "允许这个账号?",
"settings.remote.connect.confirm.body": "{name} 将能在你的 agent 会话里发送指令、回应授权请求。只允许你自己。",
"settings.remote.connect.action.allow": "允许",
"settings.remote.connect.error": "连接失败:{error}",
"settings.remote.connect.toast.title": "Telegram 已连接",
"settings.remote.connect.toast.body": "现在可以在 Telegram 里和你的 agent 对话了。",
"settings.remote.disconnect.title": "断开 Telegram?",
"settings.remote.disconnect.body": "这会从本机删除已保存的机器人 Token。你随时可以重新连接。",
"settings.integrations.description": "爪印连接的服务器,以及当前项目可用的 MCP、语言服务器、插件。",
"settings.integrations.empty": "尚未配置。",
"connectionHealth.toast.server.title": "服务器连接异常",
Expand Down
Loading
Loading