summaryrefslogtreecommitdiffhomepage
path: root/packages/app/src
diff options
context:
space:
mode:
authorAdam <[email protected]>2025-12-27 05:16:39 -0600
committerAdam <[email protected]>2025-12-27 14:43:42 -0600
commit21eba5f987482b4e2e75ab1c564815bd7b0613f4 (patch)
tree2d8cad03e54baa29d83e1e835a7ef2e64d3897e4 /packages/app/src
parentc523ca412747d66e0236865a4fa2481f7d50f64e (diff)
downloadopencode-21eba5f987482b4e2e75ab1c564815bd7b0613f4.tar.gz
opencode-21eba5f987482b4e2e75ab1c564815bd7b0613f4.zip
feat(desktop): permissions
Diffstat (limited to 'packages/app/src')
-rw-r--r--packages/app/src/context/global-sync.tsx61
-rw-r--r--packages/app/src/context/local.tsx25
-rw-r--r--packages/app/src/pages/directory-layout.tsx11
-rw-r--r--packages/app/src/pages/layout.tsx50
4 files changed, 133 insertions, 14 deletions
diff --git a/packages/app/src/context/global-sync.tsx b/packages/app/src/context/global-sync.tsx
index c51901eb2..50c8a9d1c 100644
--- a/packages/app/src/context/global-sync.tsx
+++ b/packages/app/src/context/global-sync.tsx
@@ -15,6 +15,7 @@ import {
type McpStatus,
type LspStatus,
type VcsInfo,
+ type Permission,
createOpencodeClient,
} from "@opencode-ai/sdk/v2/client"
import { createStore, produce, reconcile } from "solid-js/store"
@@ -44,6 +45,9 @@ type State = {
todo: {
[sessionID: string]: Todo[]
}
+ permission: {
+ [sessionID: string]: Permission[]
+ }
mcp: {
[name: string]: McpStatus
}
@@ -78,6 +82,7 @@ function createGlobalSync() {
})
const children: Record<string, ReturnType<typeof createStore<State>>> = {}
+ const permissionListeners: Set<(info: { directory: string; permission: Permission }) => void> = new Set()
function child(directory: string) {
if (!directory) console.error("No directory provided")
if (!children[directory]) {
@@ -93,6 +98,7 @@ function createGlobalSync() {
session_status: {},
session_diff: {},
todo: {},
+ permission: {},
mcp: {},
lsp: [],
vcs: undefined,
@@ -163,6 +169,15 @@ function createGlobalSync() {
mcp: () => sdk.mcp.status().then((x) => setStore("mcp", x.data ?? {})),
lsp: () => sdk.lsp.status().then((x) => setStore("lsp", x.data ?? [])),
vcs: () => sdk.vcs.get().then((x) => setStore("vcs", x.data)),
+ permission: () =>
+ sdk.permission.list().then((x) => {
+ const grouped: Record<string, typeof x.data> = {}
+ for (const perm of x.data ?? []) {
+ grouped[perm.sessionID] = grouped[perm.sessionID] ?? []
+ grouped[perm.sessionID]!.push(perm)
+ }
+ setStore("permission", grouped)
+ }),
}
await Promise.all(Object.values(load).map((p) => retry(p).catch((e) => setGlobalStore("error", e))))
.then(() => setStore("ready", true))
@@ -313,6 +328,46 @@ function createGlobalSync() {
setStore("vcs", { branch: event.properties.branch })
break
}
+ case "permission.updated": {
+ const permissions = store.permission[event.properties.sessionID]
+ const isNew = !permissions || !permissions.find((p) => p.id === event.properties.id)
+ if (!permissions) {
+ setStore("permission", event.properties.sessionID, [event.properties])
+ } else {
+ const result = Binary.search(permissions, event.properties.id, (p) => p.id)
+ setStore(
+ "permission",
+ event.properties.sessionID,
+ produce((draft) => {
+ if (result.found) {
+ draft[result.index] = event.properties
+ return
+ }
+ draft.push(event.properties)
+ }),
+ )
+ }
+ if (isNew) {
+ for (const listener of permissionListeners) {
+ listener({ directory, permission: event.properties })
+ }
+ }
+ break
+ }
+ case "permission.replied": {
+ const permissions = store.permission[event.properties.sessionID]
+ if (!permissions) break
+ const result = Binary.search(permissions, event.properties.permissionID, (p) => p.id)
+ if (!result.found) break
+ setStore(
+ "permission",
+ event.properties.sessionID,
+ produce((draft) => {
+ draft.splice(result.index, 1)
+ }),
+ )
+ break
+ }
}
})
@@ -384,6 +439,12 @@ function createGlobalSync() {
project: {
loadSessions,
},
+ permission: {
+ onUpdated(listener: (info: { directory: string; permission: Permission }) => void) {
+ permissionListeners.add(listener)
+ return () => permissionListeners.delete(listener)
+ },
+ },
}
}
diff --git a/packages/app/src/context/local.tsx b/packages/app/src/context/local.tsx
index 600a0e4b1..49217b82b 100644
--- a/packages/app/src/context/local.tsx
+++ b/packages/app/src/context/local.tsx
@@ -377,17 +377,20 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
}
const list = async (path: string) => {
- return sdk.client.file.list({ path: path + "/" }).then((x) => {
- setStore(
- "node",
- produce((draft) => {
- x.data!.forEach((node) => {
- if (node.path in draft) return
- draft[node.path] = node
- })
- }),
- )
- })
+ return sdk.client.file
+ .list({ path: path + "/" })
+ .then((x) => {
+ setStore(
+ "node",
+ produce((draft) => {
+ x.data!.forEach((node) => {
+ if (node.path in draft) return
+ draft[node.path] = node
+ })
+ }),
+ )
+ })
+ .catch(() => {})
}
const searchFiles = (query: string) => sdk.client.find.files({ query, dirs: "false" }).then((x) => x.data!)
diff --git a/packages/app/src/pages/directory-layout.tsx b/packages/app/src/pages/directory-layout.tsx
index c909a373d..04f90bdcb 100644
--- a/packages/app/src/pages/directory-layout.tsx
+++ b/packages/app/src/pages/directory-layout.tsx
@@ -1,6 +1,6 @@
import { createMemo, Show, type ParentProps } from "solid-js"
import { useParams } from "@solidjs/router"
-import { SDKProvider } from "@/context/sdk"
+import { SDKProvider, useSDK } from "@/context/sdk"
import { SyncProvider, useSync } from "@/context/sync"
import { LocalProvider } from "@/context/local"
import { base64Decode } from "@opencode-ai/util/encode"
@@ -18,8 +18,15 @@ export default function Layout(props: ParentProps) {
<SyncProvider>
{iife(() => {
const sync = useSync()
+ const sdk = useSDK()
return (
- <DataProvider data={sync.data} directory={directory()}>
+ <DataProvider
+ data={sync.data}
+ directory={directory()}
+ onPermissionRespond={(input) => {
+ sdk.client.permission.respond(input)
+ }}
+ >
<LocalProvider>{props.children}</LocalProvider>
</DataProvider>
)
diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx
index 5efba6d99..538a3b840 100644
--- a/packages/app/src/pages/layout.tsx
+++ b/packages/app/src/pages/layout.tsx
@@ -117,6 +117,39 @@ export default function Layout(props: ParentProps) {
}
})
+ onMount(() => {
+ const unsub = globalSync.permission.onUpdated(({ directory, permission }) => {
+ const currentDir = params.dir ? base64Decode(params.dir) : undefined
+ const currentSession = params.id
+ if (directory === currentDir && permission.sessionID === currentSession) return
+ const [store] = globalSync.child(directory)
+ const session = store.session.find((s) => s.id === permission.sessionID)
+ if (directory === currentDir && session?.parentID === currentSession) return
+ const sessionTitle = session?.title ?? "New session"
+ const projectName = getFilename(directory)
+ showToast({
+ persistent: true,
+ icon: "checklist",
+ title: "Permission required",
+ description: `${sessionTitle} in ${projectName} needs permission`,
+ actions: [
+ {
+ label: "Go to session",
+ onClick: () => {
+ navigate(`/${base64Encode(directory)}/session/${permission.sessionID}`)
+ },
+ dismissAfter: true,
+ },
+ {
+ label: "Dismiss",
+ onClick: "dismiss",
+ },
+ ],
+ })
+ })
+ onCleanup(unsub)
+ })
+
function sortSessions(a: Session, b: Session) {
const now = Date.now()
const oneMinuteAgo = now - 60 * 1000
@@ -454,8 +487,20 @@ export default function Layout(props: ParentProps) {
const updated = createMemo(() => DateTime.fromMillis(props.session.time.updated))
const notifications = createMemo(() => notification.session.unseen(props.session.id))
const hasError = createMemo(() => notifications().some((n) => n.type === "error"))
+ const hasPermissions = createMemo(() => {
+ const store = globalSync.child(props.project.worktree)[0]
+ const permissions = store.permission?.[props.session.id] ?? []
+ if (permissions.length > 0) return true
+ const childSessions = store.session.filter((s) => s.parentID === props.session.id)
+ for (const child of childSessions) {
+ const childPermissions = store.permission?.[child.id] ?? []
+ if (childPermissions.length > 0) return true
+ }
+ return false
+ })
const isWorking = createMemo(() => {
if (props.session.id === params.id) return false
+ if (hasPermissions()) return false
const status = globalSync.child(props.project.worktree)[0].session_status[props.session.id]
return status?.type === "busy" || status?.type === "retry"
})
@@ -486,6 +531,9 @@ export default function Layout(props: ParentProps) {
<Match when={isWorking()}>
<Spinner class="size-2.5 mr-0.5" />
</Match>
+ <Match when={hasPermissions()}>
+ <div class="size-1.5 mr-1.5 rounded-full bg-surface-warning-strong" />
+ </Match>
<Match when={hasError()}>
<div class="size-1.5 mr-1.5 rounded-full bg-text-diff-delete-base" />
</Match>
@@ -587,7 +635,7 @@ export default function Layout(props: ParentProps) {
<DropdownMenu.Portal>
<DropdownMenu.Content>
<DropdownMenu.Item onSelect={() => closeProject(props.project.worktree)}>
- <DropdownMenu.ItemLabel>Close Project</DropdownMenu.ItemLabel>
+ <DropdownMenu.ItemLabel>Close project</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Portal>