mirror of
https://github.com/anomalyco/opencode.git
synced 2026-09-11 09:05:07 +00:00
fix(app): deliver browser focus events and pin attachments to their shell tab
This commit is contained in:
parent
89a6eb4e18
commit
e592d90e98
3 changed files with 48 additions and 27 deletions
|
|
@ -1,4 +1,4 @@
|
|||
import { batch, createEffect, createMemo, onCleanup } from "solid-js"
|
||||
import { batch, createEffect, createMemo, getOwner, onCleanup, runWithOwner } from "solid-js"
|
||||
import { createStore, reconcile } from "solid-js/store"
|
||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||
import type { Browser } from "@opencode-ai/plugin-browser/rpc"
|
||||
|
|
@ -7,7 +7,7 @@ import type { BrowserPaneCommand, BrowserPaneRegistration, BrowserPaneState } fr
|
|||
import { usePlatform } from "@/runtime/platform/platform"
|
||||
import type { useServer } from "@/runtime/server/current"
|
||||
import { useSettings } from "@/settings/model"
|
||||
import { sessionIDHasOpenTab, useTabs } from "@/shell/tabs/tabs"
|
||||
import { findSessionTab, tabKey, useTabs } from "@/shell/tabs/tabs"
|
||||
|
||||
type Server = ReturnType<typeof useServer>
|
||||
|
||||
|
|
@ -15,13 +15,13 @@ export type BrowserAttachment = {
|
|||
registration?: BrowserPaneRegistration
|
||||
browser: BrowserPaneState
|
||||
error?: string
|
||||
/** Latest desktop focus request; a new object per event so route models can react to each one. */
|
||||
focus?: { tabID: Browser.TabID }
|
||||
}
|
||||
|
||||
type Live = {
|
||||
server: Server
|
||||
sessionID: string
|
||||
/** Shell tab that owns this attachment once seen; it may route to a child session later. */
|
||||
tab?: string
|
||||
registration?: BrowserPaneRegistration
|
||||
retry?: ReturnType<typeof setTimeout>
|
||||
attempts: number
|
||||
|
|
@ -38,10 +38,12 @@ export const { use: useBrowserAttachments, provider: BrowserAttachmentsProvider
|
|||
const settings = useSettings()
|
||||
const language = useLanguage()
|
||||
const shellTabs = useTabs()
|
||||
const owner = getOwner()
|
||||
const [store, setStore] = createStore<Record<string, BrowserAttachment | undefined>>({})
|
||||
// Servers whose plugin lacks the browser RPC; sessions on them stop retrying.
|
||||
const [unsupported, setUnsupported] = createStore<Record<string, true | undefined>>({})
|
||||
const live = new Map<string, Live>()
|
||||
const focus = new Map<string, Set<(tabID: Browser.TabID) => void>>()
|
||||
const key = (server: Server, sessionID: string) => `${server.key}\n${sessionID}`
|
||||
const enabled = createMemo(
|
||||
() => !!platform.browserPane && settings.ready() && settings.general.experimentalBrowser(),
|
||||
|
|
@ -58,8 +60,12 @@ export const { use: useBrowserAttachments, provider: BrowserAttachmentsProvider
|
|||
Object.keys(store).forEach((id) => {
|
||||
const entry = live.get(id)
|
||||
if (!entry) return
|
||||
if (on && !entry.server.health?.incompatible && sessionIDHasOpenTab(tabs, entry.server.key, entry.sessionID))
|
||||
return
|
||||
// Tabs hydrate asynchronously, so the owner is learned when first seen rather than required up
|
||||
// front. A tab keeps owning the attachment while it exists, even after routing back to its parent.
|
||||
const current = findSessionTab(tabs, entry.server.key, entry.sessionID)
|
||||
if (current) entry.tab = tabKey(current)
|
||||
const owned = entry.tab === undefined || tabs.some((tab) => tabKey(tab) === entry.tab)
|
||||
if (on && owned && !entry.server.health?.incompatible) return
|
||||
close(id)
|
||||
})
|
||||
})
|
||||
|
|
@ -71,16 +77,22 @@ export const { use: useBrowserAttachments, provider: BrowserAttachmentsProvider
|
|||
state: (server: Server, sessionID: string) => store[key(server, sessionID)],
|
||||
attach(server: Server, sessionID: string) {
|
||||
const id = key(server, sessionID)
|
||||
const existing = live.get(id)
|
||||
// A restarted sidecar arrives as a new connection under the same key; retries must use it.
|
||||
if (existing) {
|
||||
existing.server = server
|
||||
return
|
||||
}
|
||||
const pane = platform.browserPane
|
||||
if (live.has(id) || !pane || !enabled() || unsupported[server.key] || server.health?.incompatible) return
|
||||
if (!pane || !enabled() || unsupported[server.key] || server.health?.incompatible) return
|
||||
const entry: Live = { server, sessionID, attempts: 0, dispose: () => undefined }
|
||||
live.set(id, entry)
|
||||
setStore(id, { browser: null })
|
||||
const register = () => {
|
||||
if (entry.registration || live.get(id) !== entry) return
|
||||
const registration = pane.register({ sessionID, endpoint: server.conn.http }, (event) => {
|
||||
const registration = pane.register({ sessionID, endpoint: entry.server.conn.http }, (event) => {
|
||||
if (live.get(id) !== entry) return
|
||||
if (event.type === "focus") return setStore(id, "focus", { tabID: event.tabID })
|
||||
if (event.type === "focus") return focus.get(id)?.forEach((listener) => listener(event.tabID))
|
||||
if (event.error === "browser.pane.unsupported") {
|
||||
setUnsupported(server.key, true)
|
||||
return close(id)
|
||||
|
|
@ -112,18 +124,32 @@ export const { use: useBrowserAttachments, provider: BrowserAttachmentsProvider
|
|||
entry.registration = registration
|
||||
setStore(id, { registration, browser: null, error: undefined })
|
||||
}
|
||||
// A new session appears in the UI before its server-side creation finishes.
|
||||
// A new session appears in the UI before its server-side creation finishes. The listener
|
||||
// belongs to this provider, not to the route effect that happened to call attach().
|
||||
const data = server.ctx.data
|
||||
const unsubscribe = data.on("session.created", (event) => {
|
||||
if (event.data.sessionID === sessionID) register()
|
||||
})
|
||||
const unsubscribe = runWithOwner(owner, () =>
|
||||
data.on("session.created", (event) => {
|
||||
if (event.data.sessionID === sessionID) register()
|
||||
}),
|
||||
)
|
||||
if (!data.session.creating(sessionID)) register()
|
||||
entry.dispose = () => {
|
||||
unsubscribe()
|
||||
unsubscribe?.()
|
||||
clearTimeout(entry.retry)
|
||||
entry.registration?.close()
|
||||
}
|
||||
},
|
||||
/** Desktop focus requests for a mounted session route; nothing is replayed to routes mounted later. */
|
||||
onFocus(server: Server, sessionID: string, listener: (tabID: Browser.TabID) => void) {
|
||||
const id = key(server, sessionID)
|
||||
const listeners = focus.get(id) ?? new Set()
|
||||
listeners.add(listener)
|
||||
focus.set(id, listeners)
|
||||
return () => {
|
||||
listeners.delete(listener)
|
||||
if (!listeners.size) focus.delete(id)
|
||||
}
|
||||
},
|
||||
command(server: Server, sessionID: string, command: BrowserPaneCommand) {
|
||||
const registration = live.get(key(server, sessionID))?.registration
|
||||
if (!registration) return Promise.reject(new Error("browser.pane.unavailable"))
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { batch, createEffect, createMemo, on } from "solid-js"
|
||||
import { batch, createEffect, createMemo, on, onCleanup } from "solid-js"
|
||||
import type { Browser } from "@opencode-ai/plugin-browser/rpc"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
|
|
@ -49,7 +49,9 @@ export function createSessionBrowser(session: SessionModel) {
|
|||
}
|
||||
createEffect(() => {
|
||||
const sessionID = session.identity.sessionID()
|
||||
if (sessionID && attachments.enabled()) attachments.attach(server, sessionID)
|
||||
if (!sessionID) return
|
||||
if (attachments.enabled()) attachments.attach(server, sessionID)
|
||||
onCleanup(attachments.onFocus(server, sessionID, focus))
|
||||
})
|
||||
createEffect(
|
||||
on(
|
||||
|
|
@ -94,14 +96,6 @@ export function createSessionBrowser(session: SessionModel) {
|
|||
},
|
||||
),
|
||||
)
|
||||
createEffect(
|
||||
on(
|
||||
() => attachment()?.focus,
|
||||
(request) => request && focus(request.tabID),
|
||||
{ defer: true },
|
||||
),
|
||||
)
|
||||
|
||||
return {
|
||||
available,
|
||||
attached,
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { createMemo, Show } from "solid-js"
|
||||
import { children, createMemo, Show } from "solid-js"
|
||||
import type { JSX } from "solid-js"
|
||||
import { useSortable } from "@dnd-kit/solid/sortable"
|
||||
import { Keybind } from "@opencode-ai/ui/keybind"
|
||||
|
|
@ -33,6 +33,7 @@ export function SortableTab(props: {
|
|||
},
|
||||
})
|
||||
const path = createMemo(() => file.pathFromTab(props.tab))
|
||||
const custom = children(() => props.children)
|
||||
const content = createMemo(() => {
|
||||
const value = path()
|
||||
if (!value) return
|
||||
|
|
@ -69,8 +70,8 @@ export function SortableTab(props: {
|
|||
}
|
||||
hideCloseButton
|
||||
>
|
||||
<Show when={props.children} fallback={<Show when={content()}>{(value) => value()}</Show>}>
|
||||
{props.children}
|
||||
<Show when={custom()} fallback={<Show when={content()}>{(value) => value()}</Show>}>
|
||||
{custom()}
|
||||
</Show>
|
||||
</Tabs.Trigger>
|
||||
</div>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue