Skip to content

fix(bridge): persist master/friend role across project switches#38

Merged
A-Souhei merged 6 commits intomainfrom
feat/session-info-panel-and-circuit-bird-theme
Mar 13, 2026
Merged

fix(bridge): persist master/friend role across project switches#38
A-Souhei merged 6 commits intomainfrom
feat/session-info-panel-and-circuit-bird-theme

Conversation

@A-Souhei
Copy link
Owner

Summary

  • Root cause: SessionInfoPanel maintained a local bridgeInfo store (reset on every component mount) with a createEffect that only re-fetched when sdk.directory changed. On navigating away and back to the same project, the component remounted but SolidJS saw no directory change → effect didn't re-run → role stuck at null.
  • Fix: Removed the local store entirely. The panel now reads directly from bridge.state — the global singleton from BridgeProvider, mounted above the router, kept fresh by a 5-second background poller. It is never reset by component mount/unmount cycles.
  • Also included: correct base64+URI directory decoding in bridge.tsx, selfRole/selfNodeID fields added to GET /bridge/info response so clients get the server-authoritative role (not a broken heuristic), navigation-aware polling via custom opencode-navigate event.

Files changed

File Change
packages/app/src/pages/session/session-info-panel.tsx Remove local store; read from bridge.state
packages/app/src/context/bridge.tsx Fix dir decoding, nav-aware polling, use selfRole
packages/app/src/components/session/session-header.tsx Use bridge.state.role / bridge.state.sessionID
packages/app/src/pages/session/dialog-become-friend.tsx Add onSuccess callback prop
packages/opencode/src/bridge/index.ts Export sessionID()
packages/opencode/src/server/routes/bridge.ts Include selfRole + selfNodeID in /bridge/info

- session-info-panel.tsx: added isMaster signal for immediate UI feedback after becomeMaster/leaveBridge
- bridge.tsx: fixed bridge poll to include x-opencode-directory header so server reads from correct instance
- Added profile list/switch endpoints and UI integration
- Enhanced session info panel with bridge controls and session ID display
- Add DialogBecomeFriend modal component for joining bridge as friend by entering master session ID
- Add "Become Friend" button in Bridge section of session info panel
- Implement logic fixes:
  - Guard "Become Friend" button with `!bridge.role` condition to prevent conflicts
  - Disable button when session ID or directory is missing
  - Use trailing-slash safe slug computation with filter(Boolean) in both components
- Track friend state independently and update Leave button visibility accordingly
…rived functions

Replaced local createSignal state (isMaster, isFriend) with derived functions
that read directly from bridge.role, ensuring bridge status reflects actual
server state per directory and fixing stale UI when switching projects.
…f local store

The panel was maintaining a redundant local `bridgeInfo` store with its
own `createEffect` fetch. On remount (navigating away and back to the
same project), the local store reset to null and SolidJS did not
re-run the effect because `sdk.directory` hadn't changed value —
leaving the bridge role stuck at null.

Fix: remove the local store entirely. The panel now reads directly from
`bridge.state` (the singleton from `useBridge()`), kept fresh by the
5-second background poller in `bridge.tsx` which is mounted above the
router and is never destroyed on navigation.

Also included in this commit (from previous sessions):
- bridge.tsx: correct directory decoding (base64+URI), navigation-aware
  polling with opencode-navigate custom event, selfRole from server
- bridge/index.ts: export sessionID()
- server/routes/bridge.ts: include selfRole + selfNodeID in GET /info
- session-header.tsx: use bridge.state.role / bridge.state.sessionID
- dialog-become-friend.tsx: add onSuccess callback prop
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR makes bridge role/session identity persistent across in-app navigation by moving bridge state into a global provider with polling, and updates both server and client to use server-authoritative bridge role. It also adds profile switching endpoints + UI wiring, plus introduces a new “Circuit Bird” theme.

Changes:

  • Add a global BridgeProvider with navigation-aware polling; update session UI to read from bridge.state rather than a component-local store.
  • Extend GET /bridge/info response with selfRole/selfNodeID and add a new /profile route group (list/active/switch) with client bootstrap + UI selector.
  • Add new Circuit Bird theme assets and register them as defaults.

Reviewed changes

Copilot reviewed 21 out of 21 changed files in this pull request and generated 16 comments.

Show a summary per file
File Description
packages/ui/src/theme/themes/circuit-bird.json Adds a new built-in desktop theme definition.
packages/ui/src/theme/default-themes.ts Registers the new “circuit-bird” theme in the default theme map.
packages/opencode/src/server/server.ts Mounts new /profile routes on the server.
packages/opencode/src/server/routes/profile.ts Introduces profile list/active/switch endpoints backed by VuHitra settings.
packages/opencode/src/server/routes/bridge.ts Extends /bridge/info payload with server-authoritative self role/session identity.
packages/opencode/src/project/profiles.ts Ensures default profile is always present in profile listings.
packages/opencode/src/cli/cmd/web.ts Attempts to raise inotify watch limit before starting the web server.
packages/opencode/src/bridge/index.ts Exposes Bridge.sessionID() accessor for API responses.
packages/app/src/pages/session/session-info-panel.tsx New session side panel UI; bridge actions + memory/biblion/indexer status display.
packages/app/src/pages/session/dialog-become-friend.tsx Adds onSuccess callback and performs “become friend” request.
packages/app/src/pages/session.tsx Mounts the new session info side panel with resize handle.
packages/app/src/context/layout.tsx Adds layout state + actions for toggling/resizing the session info panel.
packages/app/src/context/global-sync/types.ts Adds global sync fields for memory/biblion/indexer status + profiles.
packages/app/src/context/global-sync/event-reducer.ts Handles new status/profile events into the store.
packages/app/src/context/global-sync/bootstrap.ts Bootstraps memory/biblion/indexer status and fetches profile list/active.
packages/app/src/context/global-sync.tsx Passes server URL into directory bootstrap for profile fetches.
packages/app/src/context/bridge.tsx New global bridge store with polling + navigation detection.
packages/app/src/components/session/session-header.tsx Adds bridge “master” indicator/copy button + session info panel toggle button.
packages/app/src/components/prompt-input.tsx Adds profile selector UI and profile switch request.
packages/app/src/app.tsx Mounts BridgeProvider above the router so state persists across navigations.
.opencode/themes/circuit-bird.json Adds a theme file for the .opencode theme format.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

You can also share your feedback on Copilot code review. Take the survey.

Comment on lines +82 to +85
const res = await fetch(`${sdk.url}/bridge/leave`, {
method: "POST",
headers: { "x-opencode-directory": dir },
})
Comment on lines +47 to +59
const res = await fetch(`${sdk.url}/bridge/set-master`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"x-opencode-directory": dir,
},
body: JSON.stringify({
sessionID: id,
slug: title,
title,
directory: dir,
}),
})
Comment on lines +122 to +131
const res = await fetch(`${globalSDK.url}/profile/switch`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"x-opencode-directory": dir,
},
body: JSON.stringify({ name, directory: dir }),
})
if (!res.ok) throw new Error(await res.text())
showToast({ variant: "success", title: `Switched to profile "${name}"` })
onClick={() => view().sessionPanel.toggle()}
aria-label="Session Info"
aria-expanded={view().sessionPanel.opened()}
aria-controls="session-info-panel"
UI.println(UI.Style.TEXT_WARNING_BOLD + "! " + "OPENCODE_SERVER_PASSWORD is not set; server is unsecured.")
}
const opts = await resolveNetworkOptions(args)
await Bun.write("/proc/sys/fs/inotify/max_user_watches", "524288\n").catch(() => {})
Comment on lines +119 to +129
async function switchProfile(name: string) {
const dir = sync.data.path.directory
try {
const res = await fetch(`${globalSDK.url}/profile/switch`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"x-opencode-directory": dir,
},
body: JSON.stringify({ name, directory: dir }),
})
Comment on lines +164 to +183
fetch(`${input.url}/profile/list?directory=${encodeURIComponent(input.directory)}`)
.then((r) => {
if (!r.ok) throw new Error(`profile/list ${r.status}`)
return r.json()
})
.then((profiles) => {
if (!Array.isArray(profiles) || profiles.some((p) => typeof p !== "string")) return
input.setStore("profiles", profiles)
})
.catch((e) => console.error("[profile/list] fetch failed", e)),
fetch(`${input.url}/profile/active?directory=${encodeURIComponent(input.directory)}`)
.then((r) => {
if (!r.ok) throw new Error(`profile/active ${r.status}`)
return r.json()
})
.then((active) => {
if (typeof active !== "string") return
input.setStore("active_profile", active)
})
.catch((e) => console.error("[profile/active] fetch failed", e)),
Comment on lines +547 to +565
<Show when={bridge.state.role === "master"}>
<Button
variant="ghost"
class="titlebar-icon w-8 h-6 p-0 box-border"
title="Bridge master · click to copy session ID"
aria-label="Bridge master · click to copy session ID"
onClick={() =>
navigator.clipboard
.writeText(bridge.state.sessionID ?? "")
.then(() =>
showToast({
variant: "success",
icon: "circle-check",
title: "Copied bridge session ID",
description: bridge.state.sessionID ?? "",
}),
)
.catch((err: unknown) => showRequestError(language, err))
}
Comment on lines 31 to +52
describeRoute({
summary: "Get bridge info",
operationId: "bridge.info",
responses: {
200: {
description: "Current bridge info or null if not active",
content: {
"application/json": {
schema: resolver(Bridge.Info.nullable()),
},
},
},
},
}),
async (c) => {
return c.json(Bridge.info())
const info = Bridge.info()
if (!info) return c.json(null)
return c.json({
...info,
selfRole: Bridge.role(),
selfNodeID: Bridge.sessionID(),
})
Comment on lines +65 to +67
await VuHitraSettings.setActiveProfile(name, directory).catch((err: unknown) => {
throw err
})
- Add Unicode-safe auth headers (TextEncoder btoa) to all raw fetch
  calls in bridge.tsx and session-info-panel.tsx
- Clear state when /bridge/info returns null (prevent stale state)
- Fix /bridge/leave to send Content-Type: application/json + body {}
- Remove broken aria-controls from session panel toggle button
- Guard copy button visibility with bridge.state.sessionID truthiness
- Add selfRole/selfNodeID to GET /bridge/info OpenAPI schema
- Use defensive const-sid guard in click handlers instead of ! assertion
- Chain .then()/.catch() on clipboard.writeText promise
@A-Souhei A-Souhei merged commit e5443a1 into main Mar 13, 2026
2 checks passed
@A-Souhei A-Souhei deleted the feat/session-info-panel-and-circuit-bird-theme branch March 13, 2026 07:30
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants