Skip to content

Commit e15426d

Browse files
adamdotdevinanntnzrb
authored andcommitted
feat(desktop): permissions
1 parent 4d8ad64 commit e15426d

File tree

17 files changed

+586
-60
lines changed

17 files changed

+586
-60
lines changed

packages/app/src/context/global-sync.tsx

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import {
1515
type McpStatus,
1616
type LspStatus,
1717
type VcsInfo,
18+
type Permission,
1819
createOpencodeClient,
1920
} from "@opencode-ai/sdk/v2/client"
2021
import { createStore, produce, reconcile } from "solid-js/store"
@@ -44,6 +45,9 @@ type State = {
4445
todo: {
4546
[sessionID: string]: Todo[]
4647
}
48+
permission: {
49+
[sessionID: string]: Permission[]
50+
}
4751
mcp: {
4852
[name: string]: McpStatus
4953
}
@@ -78,6 +82,7 @@ function createGlobalSync() {
7882
})
7983

8084
const children: Record<string, ReturnType<typeof createStore<State>>> = {}
85+
const permissionListeners: Set<(info: { directory: string; permission: Permission }) => void> = new Set()
8186
function child(directory: string) {
8287
if (!directory) console.error("No directory provided")
8388
if (!children[directory]) {
@@ -93,6 +98,7 @@ function createGlobalSync() {
9398
session_status: {},
9499
session_diff: {},
95100
todo: {},
101+
permission: {},
96102
mcp: {},
97103
lsp: [],
98104
vcs: undefined,
@@ -163,6 +169,15 @@ function createGlobalSync() {
163169
mcp: () => sdk.mcp.status().then((x) => setStore("mcp", x.data ?? {})),
164170
lsp: () => sdk.lsp.status().then((x) => setStore("lsp", x.data ?? [])),
165171
vcs: () => sdk.vcs.get().then((x) => setStore("vcs", x.data)),
172+
permission: () =>
173+
sdk.permission.list().then((x) => {
174+
const grouped: Record<string, typeof x.data> = {}
175+
for (const perm of x.data ?? []) {
176+
grouped[perm.sessionID] = grouped[perm.sessionID] ?? []
177+
grouped[perm.sessionID]!.push(perm)
178+
}
179+
setStore("permission", grouped)
180+
}),
166181
}
167182
await Promise.all(Object.values(load).map((p) => retry(p).catch((e) => setGlobalStore("error", e))))
168183
.then(() => setStore("ready", true))
@@ -313,6 +328,46 @@ function createGlobalSync() {
313328
setStore("vcs", { branch: event.properties.branch })
314329
break
315330
}
331+
case "permission.updated": {
332+
const permissions = store.permission[event.properties.sessionID]
333+
const isNew = !permissions || !permissions.find((p) => p.id === event.properties.id)
334+
if (!permissions) {
335+
setStore("permission", event.properties.sessionID, [event.properties])
336+
} else {
337+
const result = Binary.search(permissions, event.properties.id, (p) => p.id)
338+
setStore(
339+
"permission",
340+
event.properties.sessionID,
341+
produce((draft) => {
342+
if (result.found) {
343+
draft[result.index] = event.properties
344+
return
345+
}
346+
draft.push(event.properties)
347+
}),
348+
)
349+
}
350+
if (isNew) {
351+
for (const listener of permissionListeners) {
352+
listener({ directory, permission: event.properties })
353+
}
354+
}
355+
break
356+
}
357+
case "permission.replied": {
358+
const permissions = store.permission[event.properties.sessionID]
359+
if (!permissions) break
360+
const result = Binary.search(permissions, event.properties.permissionID, (p) => p.id)
361+
if (!result.found) break
362+
setStore(
363+
"permission",
364+
event.properties.sessionID,
365+
produce((draft) => {
366+
draft.splice(result.index, 1)
367+
}),
368+
)
369+
break
370+
}
316371
}
317372
})
318373

@@ -384,6 +439,12 @@ function createGlobalSync() {
384439
project: {
385440
loadSessions,
386441
},
442+
permission: {
443+
onUpdated(listener: (info: { directory: string; permission: Permission }) => void) {
444+
permissionListeners.add(listener)
445+
return () => permissionListeners.delete(listener)
446+
},
447+
},
387448
}
388449
}
389450

packages/app/src/context/local.tsx

Lines changed: 14 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -377,17 +377,20 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
377377
}
378378

379379
const list = async (path: string) => {
380-
return sdk.client.file.list({ path: path + "/" }).then((x) => {
381-
setStore(
382-
"node",
383-
produce((draft) => {
384-
x.data!.forEach((node) => {
385-
if (node.path in draft) return
386-
draft[node.path] = node
387-
})
388-
}),
389-
)
390-
})
380+
return sdk.client.file
381+
.list({ path: path + "/" })
382+
.then((x) => {
383+
setStore(
384+
"node",
385+
produce((draft) => {
386+
x.data!.forEach((node) => {
387+
if (node.path in draft) return
388+
draft[node.path] = node
389+
})
390+
}),
391+
)
392+
})
393+
.catch(() => {})
391394
}
392395

393396
const searchFiles = (query: string) => sdk.client.find.files({ query, dirs: "false" }).then((x) => x.data!)

packages/app/src/pages/directory-layout.tsx

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { createMemo, Show, type ParentProps } from "solid-js"
22
import { useParams } from "@solidjs/router"
3-
import { SDKProvider } from "@/context/sdk"
3+
import { SDKProvider, useSDK } from "@/context/sdk"
44
import { SyncProvider, useSync } from "@/context/sync"
55
import { LocalProvider } from "@/context/local"
66
import { base64Decode } from "@opencode-ai/util/encode"
@@ -18,8 +18,15 @@ export default function Layout(props: ParentProps) {
1818
<SyncProvider>
1919
{iife(() => {
2020
const sync = useSync()
21+
const sdk = useSDK()
2122
return (
22-
<DataProvider data={sync.data} directory={directory()}>
23+
<DataProvider
24+
data={sync.data}
25+
directory={directory()}
26+
onPermissionRespond={(input) => {
27+
sdk.client.permission.respond(input)
28+
}}
29+
>
2330
<LocalProvider>{props.children}</LocalProvider>
2431
</DataProvider>
2532
)

packages/app/src/pages/layout.tsx

Lines changed: 49 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,39 @@ export default function Layout(props: ParentProps) {
117117
}
118118
})
119119

120+
onMount(() => {
121+
const unsub = globalSync.permission.onUpdated(({ directory, permission }) => {
122+
const currentDir = params.dir ? base64Decode(params.dir) : undefined
123+
const currentSession = params.id
124+
if (directory === currentDir && permission.sessionID === currentSession) return
125+
const [store] = globalSync.child(directory)
126+
const session = store.session.find((s) => s.id === permission.sessionID)
127+
if (directory === currentDir && session?.parentID === currentSession) return
128+
const sessionTitle = session?.title ?? "New session"
129+
const projectName = getFilename(directory)
130+
showToast({
131+
persistent: true,
132+
icon: "checklist",
133+
title: "Permission required",
134+
description: `${sessionTitle} in ${projectName} needs permission`,
135+
actions: [
136+
{
137+
label: "Go to session",
138+
onClick: () => {
139+
navigate(`/${base64Encode(directory)}/session/${permission.sessionID}`)
140+
},
141+
dismissAfter: true,
142+
},
143+
{
144+
label: "Dismiss",
145+
onClick: "dismiss",
146+
},
147+
],
148+
})
149+
})
150+
onCleanup(unsub)
151+
})
152+
120153
function sortSessions(a: Session, b: Session) {
121154
const now = Date.now()
122155
const oneMinuteAgo = now - 60 * 1000
@@ -454,8 +487,20 @@ export default function Layout(props: ParentProps) {
454487
const updated = createMemo(() => DateTime.fromMillis(props.session.time.updated))
455488
const notifications = createMemo(() => notification.session.unseen(props.session.id))
456489
const hasError = createMemo(() => notifications().some((n) => n.type === "error"))
490+
const hasPermissions = createMemo(() => {
491+
const store = globalSync.child(props.project.worktree)[0]
492+
const permissions = store.permission?.[props.session.id] ?? []
493+
if (permissions.length > 0) return true
494+
const childSessions = store.session.filter((s) => s.parentID === props.session.id)
495+
for (const child of childSessions) {
496+
const childPermissions = store.permission?.[child.id] ?? []
497+
if (childPermissions.length > 0) return true
498+
}
499+
return false
500+
})
457501
const isWorking = createMemo(() => {
458502
if (props.session.id === params.id) return false
503+
if (hasPermissions()) return false
459504
const status = globalSync.child(props.project.worktree)[0].session_status[props.session.id]
460505
return status?.type === "busy" || status?.type === "retry"
461506
})
@@ -486,6 +531,9 @@ export default function Layout(props: ParentProps) {
486531
<Match when={isWorking()}>
487532
<Spinner class="size-2.5 mr-0.5" />
488533
</Match>
534+
<Match when={hasPermissions()}>
535+
<div class="size-1.5 mr-1.5 rounded-full bg-surface-warning-strong" />
536+
</Match>
489537
<Match when={hasError()}>
490538
<div class="size-1.5 mr-1.5 rounded-full bg-text-diff-delete-base" />
491539
</Match>
@@ -587,7 +635,7 @@ export default function Layout(props: ParentProps) {
587635
<DropdownMenu.Portal>
588636
<DropdownMenu.Content>
589637
<DropdownMenu.Item onSelect={() => closeProject(props.project.worktree)}>
590-
<DropdownMenu.ItemLabel>Close Project</DropdownMenu.ItemLabel>
638+
<DropdownMenu.ItemLabel>Close project</DropdownMenu.ItemLabel>
591639
</DropdownMenu.Item>
592640
</DropdownMenu.Content>
593641
</DropdownMenu.Portal>

packages/opencode/src/permission/index.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,17 @@ export namespace Permission {
8686
return state().pending
8787
}
8888

89+
export function list() {
90+
const { pending } = state()
91+
const result: Info[] = []
92+
for (const items of Object.values(pending)) {
93+
for (const item of Object.values(items)) {
94+
result.push(item.info)
95+
}
96+
}
97+
return result.sort((a, b) => a.id.localeCompare(b.id))
98+
}
99+
89100
export async function ask(input: {
90101
type: Info["type"]
91102
title: Info["title"]

packages/opencode/src/server/server.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1532,6 +1532,28 @@ export namespace Server {
15321532
return c.json(true)
15331533
},
15341534
)
1535+
.get(
1536+
"/permission",
1537+
describeRoute({
1538+
summary: "List pending permissions",
1539+
description: "Get all pending permission requests across all sessions.",
1540+
operationId: "permission.list",
1541+
responses: {
1542+
200: {
1543+
description: "List of pending permissions",
1544+
content: {
1545+
"application/json": {
1546+
schema: resolver(Permission.Info.array()),
1547+
},
1548+
},
1549+
},
1550+
},
1551+
}),
1552+
async (c) => {
1553+
const permissions = Permission.list()
1554+
return c.json(permissions)
1555+
},
1556+
)
15351557
.get(
15361558
"/command",
15371559
describeRoute({

packages/sdk/js/src/v2/gen/sdk.gen.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ import type {
5454
PartUpdateErrors,
5555
PartUpdateResponses,
5656
PathGetResponses,
57+
PermissionListResponses,
5758
PermissionRespondErrors,
5859
PermissionRespondResponses,
5960
ProjectCurrentResponses,
@@ -1618,6 +1619,25 @@ export class Permission extends HeyApiClient {
16181619
},
16191620
})
16201621
}
1622+
1623+
/**
1624+
* List pending permissions
1625+
*
1626+
* Get all pending permission requests across all sessions.
1627+
*/
1628+
public list<ThrowOnError extends boolean = false>(
1629+
parameters?: {
1630+
directory?: string
1631+
},
1632+
options?: Options<never, ThrowOnError>,
1633+
) {
1634+
const params = buildClientParams([parameters], [{ args: [{ in: "query", key: "directory" }] }])
1635+
return (options?.client ?? this.client).get<PermissionListResponses, unknown, ThrowOnError>({
1636+
url: "/permission",
1637+
...options,
1638+
...params,
1639+
})
1640+
}
16211641
}
16221642

16231643
export class Command extends HeyApiClient {

packages/sdk/js/src/v2/gen/types.gen.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3356,6 +3356,24 @@ export type PermissionRespondResponses = {
33563356

33573357
export type PermissionRespondResponse = PermissionRespondResponses[keyof PermissionRespondResponses]
33583358

3359+
export type PermissionListData = {
3360+
body?: never
3361+
path?: never
3362+
query?: {
3363+
directory?: string
3364+
}
3365+
url: "/permission"
3366+
}
3367+
3368+
export type PermissionListResponses = {
3369+
/**
3370+
* List of pending permissions
3371+
*/
3372+
200: Array<Permission>
3373+
}
3374+
3375+
export type PermissionListResponse = PermissionListResponses[keyof PermissionListResponses]
3376+
33593377
export type CommandListData = {
33603378
body?: never
33613379
path?: never

packages/sdk/openapi.json

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2879,6 +2879,43 @@
28792879
]
28802880
}
28812881
},
2882+
"/permission": {
2883+
"get": {
2884+
"operationId": "permission.list",
2885+
"parameters": [
2886+
{
2887+
"in": "query",
2888+
"name": "directory",
2889+
"schema": {
2890+
"type": "string"
2891+
}
2892+
}
2893+
],
2894+
"summary": "List pending permissions",
2895+
"description": "Get all pending permission requests across all sessions.",
2896+
"responses": {
2897+
"200": {
2898+
"description": "List of pending permissions",
2899+
"content": {
2900+
"application/json": {
2901+
"schema": {
2902+
"type": "array",
2903+
"items": {
2904+
"$ref": "#/components/schemas/Permission"
2905+
}
2906+
}
2907+
}
2908+
}
2909+
}
2910+
},
2911+
"x-codeSamples": [
2912+
{
2913+
"lang": "js",
2914+
"source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.permission.list({\n ...\n})"
2915+
}
2916+
]
2917+
}
2918+
},
28822919
"/command": {
28832920
"get": {
28842921
"operationId": "command.list",

0 commit comments

Comments
 (0)