diff --git a/packages/app/src/components/session/session-header.tsx b/packages/app/src/components/session/session-header.tsx index 495b32340580..d2a9b5b611cc 100644 --- a/packages/app/src/components/session/session-header.tsx +++ b/packages/app/src/components/session/session-header.tsx @@ -12,6 +12,7 @@ import { createEffect, createMemo, For, onCleanup, Show } from "solid-js" import { createStore } from "solid-js/store" import { Portal } from "solid-js/web" import { useCommand } from "@/context/command" +import { useGlobalSDK } from "@/context/global-sdk" import { useLanguage } from "@/context/language" import { useLayout } from "@/context/layout" import { usePlatform } from "@/context/platform" @@ -23,6 +24,7 @@ import { useSessionLayout } from "@/pages/session/session-layout" import { messageAgentColor } from "@/utils/agent" import { decode64 } from "@/utils/base64" import { Persist, persisted } from "@/utils/persist" +import { resolveSessionPluginUI } from "@/utils/plugin-ui" import { StatusPopover } from "../status-popover" const OPEN_APPS = [ @@ -131,6 +133,7 @@ const showRequestError = (language: ReturnType, err: unknown export function SessionHeader() { const layout = useLayout() const command = useCommand() + const globalSDK = useGlobalSDK() const server = useServer() const platform = usePlatform() const language = useLanguage() @@ -139,6 +142,8 @@ export function SessionHeader() { const { params, view } = useSessionLayout() const projectDirectory = createMemo(() => decode64(params.dir) ?? "") + const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`) + const tabs = createMemo(() => layout.tabs(sessionKey)) const project = createMemo(() => { const directory = projectDirectory() if (!directory) return @@ -151,6 +156,8 @@ export function SessionHeader() { }) const hotkey = createMemo(() => command.keybind("file.open")) const os = createMemo(() => detectOS(platform)) + const pluginUI = createMemo(() => resolveSessionPluginUI(sync.data.config, projectDirectory(), globalSDK.url)) + const webButtons = createMemo(() => pluginUI().buttons) const [exists, setExists] = createStore>>({ finder: true, @@ -262,6 +269,11 @@ export function SessionHeader() { .catch((err: unknown) => showRequestError(language, err)) } + const openWebTab = (tab: string) => { + view().reviewPanel.open() + tabs().open(tab) + } + const centerMount = createMemo(() => document.getElementById("opencode-titlebar-center")) const rightMount = createMemo(() => document.getElementById("opencode-titlebar-right")) @@ -418,6 +430,18 @@ export function SessionHeader() { + + {(button) => ( + + )} + sdk.directory) + const encodedDirectory = createMemo(() => { + const value = sdk.directory + if (!value) return "" + const isNonASCII = /[^\x00-\x7F]/.test(value) + return isNonASCII ? encodeURIComponent(value) : value + }) const path = createPathHelpers(scope) const tabs = layout.tabs(() => `${params.dir}${params.id ? "/" + params.id : ""}`) @@ -200,6 +206,55 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({ () => [], ) + const write = async (input: { path: string; content: string }) => { + const file = path.normalize(input.path) + if (!file) throw new Error("Invalid path") + + const url = new URL("/file/content", sdk.url) + url.searchParams.set("path", file) + + const response = await fetch(url, { + method: "PUT", + headers: { + "content-type": "application/json", + "x-opencode-directory": encodedDirectory(), + }, + body: JSON.stringify({ content: input.content }), + }) + + if (!response.ok) { + const message = await response.text().catch(() => "") + throw new Error(message || `Failed to write ${file}`) + } + + const result = (await response.json().catch(() => ({ path: file, hash: "" }))) as { + path: string + hash: string + } + + const next = { + type: "text", + content: input.content, + } as const + + setStore( + "file", + file, + produce((draft) => { + draft.path = file + draft.name = getFilename(file) + draft.loaded = true + draft.loading = false + draft.error = undefined + draft.content = next + }), + ) + + touchFileContent(file, approxBytes(next)) + evictContent(new Set([file])) + return result + } + const stop = sdk.event.listen((e) => { invalidateFromWatcher(e.details, { normalize: path.normalize, @@ -267,6 +322,7 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({ }, get, load, + write, scrollTop, scrollLeft, setScrollTop, diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index 917de35b1f20..10ece6f28276 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -30,6 +30,7 @@ import { checksum } from "@opencode-ai/util/encode" import { useSearchParams } from "@solidjs/router" import { NewSessionView, SessionHeader } from "@/components/session" import { useComments } from "@/context/comments" +import { useGlobalSDK } from "@/context/global-sdk" import { getSessionPrefetch, SESSION_PREFETCH_TTL } from "@/context/global-sync/session-prefetch" import { useGlobalSync } from "@/context/global-sync" import { useLanguage } from "@/context/language" @@ -58,6 +59,7 @@ import { useSessionCommands } from "@/pages/session/use-session-commands" import { useSessionHashScroll } from "@/pages/session/use-session-hash-scroll" import { Identifier } from "@/utils/id" import { Persist, persisted } from "@/utils/persist" +import { resolveSessionPluginUI } from "@/utils/plugin-ui" import { extractPromptFromParts } from "@/utils/prompt" import { same } from "@/utils/same" import { formatServerError } from "@/utils/server-errors" @@ -313,6 +315,7 @@ function createSessionHistoryWindow(input: SessionHistoryWindowInput) { } export default function Page() { + const globalSDK = useGlobalSDK() const globalSync = useGlobalSync() const layout = useLayout() const local = useLocal() @@ -429,12 +432,16 @@ export default function Page() { const reviewCount = createMemo(() => Math.max(info()?.summary?.files ?? 0, diffs().length)) const hasReview = createMemo(() => reviewCount() > 0) const reviewTab = createMemo(() => isDesktop()) + const pluginUI = createMemo(() => resolveSessionPluginUI(sync.data.config, sdk.directory, globalSDK.url)) + const webTabs = createMemo(() => pluginUI().tabs) + const webSet = createMemo(() => new Set(webTabs().map((tab) => tab.tab))) const tabState = createSessionTabs({ tabs, pathFromTab: file.pathFromTab, normalizeTab, review: reviewTab, hasReview, + isExtraTab: (tab) => webSet().has(tab), }) const contextOpen = tabState.contextOpen const openedTabs = tabState.openedTabs @@ -1865,6 +1872,8 @@ export default function Page() { focusReviewDiff={focusReviewDiff} reviewSnap={ui.reviewSnap} size={size} + webTabs={webTabs} + directory={sdk.directory} /> diff --git a/packages/app/src/pages/session/helpers.ts b/packages/app/src/pages/session/helpers.ts index 7e2c1ccf7b38..b1657a9d508e 100644 --- a/packages/app/src/pages/session/helpers.ts +++ b/packages/app/src/pages/session/helpers.ts @@ -15,6 +15,7 @@ type TabsInput = { normalizeTab: (tab: string) => string review?: Accessor hasReview?: Accessor + isExtraTab?: (tab: string) => boolean } export const getSessionKey = (dir: string | undefined, id: string | undefined) => `${dir ?? ""}${id ? `/${id}` : ""}` @@ -22,6 +23,7 @@ export const getSessionKey = (dir: string | undefined, id: string | undefined) = export const createSessionTabs = (input: TabsInput) => { const review = input.review ?? (() => false) const hasReview = input.hasReview ?? (() => false) + const isExtra = input.isExtraTab ?? (() => false) const contextOpen = createMemo(() => input.tabs().active() === "context" || input.tabs().all().includes("context")) const openedTabs = createMemo( () => { @@ -31,6 +33,8 @@ export const createSessionTabs = (input: TabsInput) => { .all() .flatMap((tab) => { if (tab === "context" || tab === "review") return [] + if (isExtra(tab)) return [] + if (!input.pathFromTab(tab)) return [] const value = input.pathFromTab(tab) ? input.normalizeTab(tab) : tab if (seen.has(value)) return [] seen.add(value) @@ -44,10 +48,16 @@ export const createSessionTabs = (input: TabsInput) => { const active = input.tabs().active() if (active === "context") return active if (active === "review" && review()) return active + if (active && isExtra(active)) return active if (active && input.pathFromTab(active)) return input.normalizeTab(active) const first = openedTabs()[0] if (first) return first + const extra = input + .tabs() + .all() + .find((tab) => isExtra(tab)) + if (extra) return extra if (contextOpen()) return "context" if (review() && hasReview()) return "review" return "empty" diff --git a/packages/app/src/pages/session/session-side-panel.tsx b/packages/app/src/pages/session/session-side-panel.tsx index c07942627f9b..6ba67d1baeda 100644 --- a/packages/app/src/pages/session/session-side-panel.tsx +++ b/packages/app/src/pages/session/session-side-panel.tsx @@ -24,6 +24,20 @@ import { FileTabContent } from "@/pages/session/file-tabs" import { createOpenSessionFileTab, createSessionTabs, getTabReorderIndex, type Sizing } from "@/pages/session/helpers" import { setSessionHandoff } from "@/pages/session/handoff" import { useSessionLayout } from "@/pages/session/session-layout" +import type { PluginWebTab } from "@/utils/plugin-ui" +import { hashText, inScopes, isAllowedOrigin, normalizeBridgePath } from "@/pages/session/webview-bridge" + +type WebviewRequest = { + type: "opencode.bridge.request" + requestId: string + action: "file.read" | "file.write" + token: string + payload?: { + path?: string + content?: string + expectedHash?: string + } +} export function SessionSidePanel(props: { reviewPanel: () => JSX.Element @@ -31,6 +45,8 @@ export function SessionSidePanel(props: { focusReviewDiff: (path: string) => void reviewSnap: boolean size: Sizing + webTabs: () => PluginWebTab[] + directory: string }) { const layout = useLayout() const sync = useSync() @@ -39,6 +55,164 @@ export function SessionSidePanel(props: { const command = useCommand() const dialog = useDialog() const { params, sessionKey, tabs, view } = useSessionLayout() + const frames = new Map() + const tokens = new Map() + + const isRequest = (value: unknown): value is WebviewRequest => { + if (!value || typeof value !== "object") return false + if (!("type" in value) || value.type !== "opencode.bridge.request") return false + if (!("requestId" in value) || typeof value.requestId !== "string") return false + if (!("action" in value)) return false + if (value.action !== "file.read" && value.action !== "file.write") return false + if (!("token" in value) || typeof value.token !== "string") return false + return true + } + + const tabsByKey = createMemo(() => { + const map = new Map() + for (const tab of props.webTabs()) map.set(tab.tab, tab) + return map + }) + + const webSet = createMemo(() => new Set(props.webTabs().map((tab) => tab.tab))) + + const tokenFor = (tab: string) => { + const current = tokens.get(tab) + if (current) return current + const next = globalThis.crypto?.randomUUID?.() ?? `${Date.now()}-${Math.random().toString(16).slice(2)}` + tokens.set(tab, next) + return next + } + + const postResponse = (input: { + source: MessageEventSource | null + requestId: string + ok: boolean + payload?: unknown + error?: string + }) => { + const target = input.source + if (!target || typeof (target as Window).postMessage !== "function") return + ;(target as Window).postMessage( + { + type: "opencode.bridge.response", + requestId: input.requestId, + ok: input.ok, + payload: input.payload, + error: input.error, + }, + "*", + ) + } + + const resolveTabBySource = (source: MessageEventSource | null) => { + for (const [tab, frame] of frames.entries()) { + if (frame.contentWindow !== source) continue + return tab + } + } + + const postReady = (tab: string) => { + const frame = frames.get(tab) + const cfg = tabsByKey().get(tab) + if (!frame || !cfg || !frame.contentWindow) return + frame.contentWindow.postMessage( + { + type: "opencode.bridge.ready", + token: tokenFor(tab), + }, + "*", + ) + } + + createEffect(() => { + const handler = (event: MessageEvent) => { + if (!isRequest(event.data)) return + const tab = resolveTabBySource(event.source) + if (!tab) return + const cfg = tabsByKey().get(tab) + if (!cfg) return + if (!isAllowedOrigin(event.origin, cfg.origins)) return + if (event.data.token !== tokenFor(tab)) return + + const run = async () => { + const raw = event.data.payload?.path + const path = normalizeBridgePath(props.directory, typeof raw === "string" ? raw : "") + if (!path) { + postResponse({ source: event.source, requestId: event.data.requestId, ok: false, error: "Invalid path" }) + return + } + + if (event.data.action === "file.read") { + if (!inScopes(path, cfg.permissions.file.read)) { + postResponse({ source: event.source, requestId: event.data.requestId, ok: false, error: "Read blocked" }) + return + } + await file.load(path) + const state = file.get(path) + const content = state?.content + if (!content || content.type !== "text") { + postResponse({ source: event.source, requestId: event.data.requestId, ok: false, error: "File unreadable" }) + return + } + const hash = await hashText(content.content) + postResponse({ + source: event.source, + requestId: event.data.requestId, + ok: true, + payload: { + path, + content: content.content, + hash, + }, + }) + return + } + + if (!inScopes(path, cfg.permissions.file.write)) { + postResponse({ source: event.source, requestId: event.data.requestId, ok: false, error: "Write blocked" }) + return + } + + const next = event.data.payload?.content + if (typeof next !== "string") { + postResponse({ source: event.source, requestId: event.data.requestId, ok: false, error: "Invalid content" }) + return + } + + const expected = event.data.payload?.expectedHash + if (expected) { + await file.load(path) + const state = file.get(path) + const current = state?.content + if (!current || current.type !== "text") { + postResponse({ source: event.source, requestId: event.data.requestId, ok: false, error: "File unreadable" }) + return + } + const hash = await hashText(current.content) + if (hash !== expected) { + postResponse({ source: event.source, requestId: event.data.requestId, ok: false, error: "Hash mismatch" }) + return + } + } + + const wrote = await file.write({ path, content: next }) + postResponse({ source: event.source, requestId: event.data.requestId, ok: true, payload: wrote }) + } + + void run().catch((err) => { + postResponse({ + source: event.source, + requestId: event.data.requestId, + ok: false, + error: err instanceof Error ? err.message : String(err), + }) + }) + } + + window.addEventListener("message", handler) + onCleanup(() => window.removeEventListener("message", handler)) + }) const isDesktop = createMediaQuery("(min-width: 768px)") @@ -130,12 +304,23 @@ export function SessionSidePanel(props: { setActive: tabs().setActive, }) + const openPanelTab = (value: string) => { + if (webSet().has(value)) { + tabs().open(value) + tabs().setActive(value) + openReviewPanel() + return + } + openTab(value) + } + const tabState = createSessionTabs({ tabs, pathFromTab: file.pathFromTab, normalizeTab, review: reviewTab, hasReview, + isExtraTab: (tab) => webSet().has(tab), }) const contextOpen = tabState.contextOpen const openedTabs = tabState.openedTabs @@ -232,7 +417,7 @@ export function SessionSidePanel(props: { > - +
{ @@ -278,6 +463,15 @@ export function SessionSidePanel(props: {
+ + {(tab) => ( + +
+
{tab.title}
+
+
+ )} +
{(tab) => } @@ -333,6 +527,28 @@ export function SessionSidePanel(props: { + + {(tab) => ( + + +
+