+
- {/*
{`${ctx().percentage ?? 0}%`} */}
)}
diff --git a/packages/app/src/context/global-sync.tsx b/packages/app/src/context/global-sync.tsx
index 59704abda2b..da0f59ecb06 100644
--- a/packages/app/src/context/global-sync.tsx
+++ b/packages/app/src/context/global-sync.tsx
@@ -130,15 +130,16 @@ function createGlobalSync() {
.list({ directory })
.then((x) => {
const fourHoursAgo = Date.now() - 4 * 60 * 60 * 1000
- const data = Array.isArray(x.data) ? x.data : []
+const data = Array.isArray(x.data) ? x.data : []
const nonArchived = data
+ .filter((s) => !!s?.id)
+ .filter((s) => !s.time?.archived)
.slice()
- .filter((s) => s?.id && !s.time?.archived)
.sort((a, b) => a.id.localeCompare(b.id))
// Include up to the limit, plus any updated in the last 4 hours
const sessions = nonArchived.filter((s, i) => {
if (i < store.limit) return true
- const updated = new Date(s.time.updated).getTime()
+ const updated = new Date(s.time?.updated ?? s.time?.created).getTime()
return updated > fourHoursAgo
})
setStore("session", reconcile(sessions, { key: "id" }))
@@ -185,9 +186,9 @@ function createGlobalSync() {
permission: () =>
sdk.permission.list().then((x) => {
const grouped: Record
= {}
- const data = Array.isArray(x.data) ? x.data : []
+const data = Array.isArray(x.data) ? x.data : []
for (const perm of data) {
- if (!perm?.id || !perm?.sessionID) continue
+ if (!perm?.id || !perm.sessionID) continue
const existing = grouped[perm.sessionID]
if (existing) {
existing.push(perm)
@@ -206,7 +207,10 @@ function createGlobalSync() {
"permission",
sessionID,
reconcile(
- permissions.slice().sort((a, b) => a.id.localeCompare(b.id)),
+ permissions
+ .filter((p) => !!p?.id)
+ .slice()
+ .sort((a, b) => a.id.localeCompare(b.id)),
{ key: "id" },
),
)
@@ -463,13 +467,13 @@ function createGlobalSync() {
),
retry(() =>
globalSDK.client.project.list().then(async (x) => {
- const data = Array.isArray(x.data) ? x.data : []
- setGlobalStore(
- "project",
- data
- .filter((p) => p?.id && !p.worktree?.includes("opencode-test"))
- .sort((a, b) => a.id.localeCompare(b.id)),
- )
+const data = Array.isArray(x.data) ? x.data : []
+ const projects = data
+ .filter((p) => !!p?.id)
+ .filter((p) => !!p.worktree && !p.worktree.includes("opencode-test"))
+ .slice()
+ .sort((a, b) => a.id.localeCompare(b.id))
+ setGlobalStore("project", projects)
}),
),
retry(() =>
diff --git a/packages/app/src/context/permission.tsx b/packages/app/src/context/permission.tsx
index 6d7b335ad96..a0ad1ee05b6 100644
--- a/packages/app/src/context/permission.tsx
+++ b/packages/app/src/context/permission.tsx
@@ -1,17 +1,17 @@
-import { createEffect, createRoot, onCleanup } from "solid-js"
+import { createMemo, onCleanup } from "solid-js"
import { createStore } from "solid-js/store"
import { createSimpleContext } from "@opencode-ai/ui/context"
import type { Permission } from "@opencode-ai/sdk/v2/client"
import { persisted } from "@/utils/persist"
-
-type PermissionsBySession = {
- [sessionID: string]: Permission[]
-}
+import { useGlobalSDK } from "@/context/global-sdk"
+import { useGlobalSync } from "./global-sync"
+import { useParams } from "@solidjs/router"
type PermissionRespondFn = (input: {
sessionID: string
permissionID: string
response: "once" | "always" | "reject"
+ directory?: string
}) => void
const AUTO_ACCEPT_TYPES = new Set(["edit", "write"])
@@ -22,109 +22,102 @@ function shouldAutoAccept(perm: Permission) {
export const { use: usePermission, provider: PermissionProvider } = createSimpleContext({
name: "Permission",
- init: (props: { permissions: PermissionsBySession; onRespond: PermissionRespondFn }) => {
+ init: () => {
+ const params = useParams()
+ const globalSDK = useGlobalSDK()
+ const globalSync = useGlobalSync()
+
+ const permissionsEnabled = createMemo(() => {
+ if (!params.dir) return false
+ const [store] = globalSync.child(params.dir)
+ return store.config.permission !== undefined
+ })
+
const [store, setStore, _, ready] = persisted(
- "permission.v1",
+ "permission.v3",
createStore({
autoAcceptEdits: {} as Record,
}),
)
const responded = new Set()
- const watches = new Map void>()
-
- function respond(perm: Permission) {
- if (responded.has(perm.id)) return
- responded.add(perm.id)
- props.onRespond({
- sessionID: perm.sessionID,
- permissionID: perm.id,
- response: "once",
+
+ const respond: PermissionRespondFn = (input) => {
+ globalSDK.client.permission.respond(input).catch(() => {
+ responded.delete(input.permissionID)
})
}
- function watch(sessionID: string) {
- if (watches.has(sessionID)) return
-
- const dispose = createRoot((dispose) => {
- createEffect(() => {
- if (!store.autoAcceptEdits[sessionID]) return
-
- const permissions = props.permissions[sessionID] ?? []
- permissions.length
-
- for (const perm of permissions) {
- if (!shouldAutoAccept(perm)) continue
- respond(perm)
- }
- })
-
- return dispose
+ function respondOnce(permission: Permission, directory?: string) {
+ if (responded.has(permission.id)) return
+ responded.add(permission.id)
+ respond({
+ sessionID: permission.sessionID,
+ permissionID: permission.id,
+ response: "once",
+ directory,
})
-
- watches.set(sessionID, dispose)
}
- function unwatch(sessionID: string) {
- const dispose = watches.get(sessionID)
- if (!dispose) return
- dispose()
- watches.delete(sessionID)
+ function isAutoAccepting(sessionID: string) {
+ return store.autoAcceptEdits[sessionID] ?? false
}
- createEffect(() => {
- if (!ready()) return
+ const unsubscribe = globalSDK.event.listen((e) => {
+ const event = e.details
+ if (event?.type !== "permission.updated") return
- for (const sessionID in store.autoAcceptEdits) {
- if (!store.autoAcceptEdits[sessionID]) continue
- watch(sessionID)
- }
- })
+ const perm = event.properties
+ if (!isAutoAccepting(perm.sessionID)) return
+ if (!shouldAutoAccept(perm)) return
- onCleanup(() => {
- for (const dispose of watches.values()) dispose()
- watches.clear()
+ respondOnce(perm, e.name)
})
+ onCleanup(unsubscribe)
- function enable(sessionID: string) {
+ function enable(sessionID: string, directory: string) {
setStore("autoAcceptEdits", sessionID, true)
- watch(sessionID)
- const permissions = props.permissions[sessionID] ?? []
- for (const perm of permissions) {
- if (!shouldAutoAccept(perm)) continue
- respond(perm)
- }
+ globalSDK.client.permission
+ .list({ directory })
+ .then((x) => {
+ for (const perm of x.data ?? []) {
+ if (!perm?.id) continue
+ if (perm.sessionID !== sessionID) continue
+ if (!shouldAutoAccept(perm)) continue
+ respondOnce(perm, directory)
+ }
+ })
+ .catch(() => undefined)
}
function disable(sessionID: string) {
setStore("autoAcceptEdits", sessionID, false)
- unwatch(sessionID)
}
return {
- get permissions() {
- return props.permissions
- },
- respond: props.onRespond,
- isAutoAccepting(sessionID: string) {
- return store.autoAcceptEdits[sessionID] ?? false
+ ready,
+ respond,
+ autoResponds(permission: Permission) {
+ return isAutoAccepting(permission.sessionID) && shouldAutoAccept(permission)
},
- toggleAutoAccept(sessionID: string) {
- if (store.autoAcceptEdits[sessionID]) {
+ isAutoAccepting,
+ toggleAutoAccept(sessionID: string, directory: string) {
+ if (isAutoAccepting(sessionID)) {
disable(sessionID)
return
}
- enable(sessionID)
+ enable(sessionID, directory)
},
- enableAutoAccept(sessionID: string) {
- if (store.autoAcceptEdits[sessionID]) return
- enable(sessionID)
+ enableAutoAccept(sessionID: string, directory: string) {
+ if (isAutoAccepting(sessionID)) return
+ enable(sessionID, directory)
},
disableAutoAccept(sessionID: string) {
disable(sessionID)
},
+ permissionsEnabled,
}
},
})
diff --git a/packages/app/src/context/sync.tsx b/packages/app/src/context/sync.tsx
index b225bdd6be9..60aea7ca947 100644
--- a/packages/app/src/context/sync.tsx
+++ b/packages/app/src/context/sync.tsx
@@ -81,7 +81,10 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
const result = Binary.search(messages, input.messageID, (m) => m.id)
messages.splice(result.index, 0, message)
}
- draft.part[input.messageID] = input.parts.slice().sort((a, b) => a.id.localeCompare(b.id))
+ draft.part[input.messageID] = input.parts
+ .filter((p) => !!p?.id)
+ .slice()
+ .sort((a, b) => a.id.localeCompare(b.id))
}),
)
},
@@ -113,6 +116,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
reconcile(
(messages.data ?? [])
.map((x) => x.info)
+ .filter((m) => !!m?.id)
.slice()
.sort((a, b) => a.id.localeCompare(b.id)),
{ key: "id" },
@@ -120,11 +124,15 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
)
for (const message of messages.data ?? []) {
+ if (!message?.info?.id) continue
setStore(
"part",
message.info.id,
reconcile(
- message.parts.slice().sort((a, b) => a.id.localeCompare(b.id)),
+ message.parts
+ .filter((p) => !!p?.id)
+ .slice()
+ .sort((a, b) => a.id.localeCompare(b.id)),
{ key: "id" },
),
)
@@ -137,6 +145,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
setStore("limit", (x) => x + count)
await sdk.client.session.list().then((x) => {
const sessions = (x.data ?? [])
+ .filter((s) => !!s?.id)
.slice()
.filter((s) => !s.time.archived)
.sort((a, b) => a.id.localeCompare(b.id))
diff --git a/packages/app/src/pages/directory-layout.tsx b/packages/app/src/pages/directory-layout.tsx
index 473dcd8e10d..6aac3c3843b 100644
--- a/packages/app/src/pages/directory-layout.tsx
+++ b/packages/app/src/pages/directory-layout.tsx
@@ -3,7 +3,7 @@ import { useParams } from "@solidjs/router"
import { SDKProvider, useSDK } from "@/context/sdk"
import { SyncProvider, useSync } from "@/context/sync"
import { LocalProvider } from "@/context/local"
-import { PermissionProvider } from "@/context/permission"
+
import { base64Decode } from "@opencode-ai/util/encode"
import { DataProvider } from "@opencode-ai/ui/context"
import { iife } from "@opencode-ai/util/iife"
@@ -27,11 +27,9 @@ export default function Layout(props: ParentProps) {
}) => sdk.client.permission.respond(input)
return (
-
-
- {props.children}
-
-
+
+ {props.children}
+
)
})}
diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx
index 86d00d1b498..7777faf4138 100644
--- a/packages/app/src/pages/layout.tsx
+++ b/packages/app/src/pages/layout.tsx
@@ -13,7 +13,7 @@ import { ResizeHandle } from "@opencode-ai/ui/resize-handle"
import { Button } from "@opencode-ai/ui/button"
import { Icon } from "@opencode-ai/ui/icon"
import { IconButton } from "@opencode-ai/ui/icon-button"
-import { Tooltip } from "@opencode-ai/ui/tooltip"
+import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip"
import { Collapsible } from "@opencode-ai/ui/collapsible"
import { DiffChanges } from "@opencode-ai/ui/diff-changes"
import { Spinner } from "@opencode-ai/ui/spinner"
@@ -36,6 +36,7 @@ import { useProviders } from "@/hooks/use-providers"
import { showToast, Toast, toaster } from "@opencode-ai/ui/toast"
import { useGlobalSDK } from "@/context/global-sdk"
import { useNotification } from "@/context/notification"
+import { usePermission } from "@/context/permission"
import { Binary } from "@opencode-ai/util/binary"
import { PullToRefresh } from "@/components/pull-to-refresh"
@@ -89,6 +90,7 @@ export default function Layout(props: ParentProps) {
const platform = usePlatform()
const server = useServer()
const notification = useNotification()
+ const permission = usePermission()
const navigate = useNavigate()
const providers = useProviders()
const dialog = useDialog()
@@ -167,28 +169,41 @@ export default function Layout(props: ParentProps) {
const otherSessions = createMemo(() => sessions().filter((s) => s.id !== currentSessionId()))
onMount(() => {
- const seenSessions = new Set()
const toastBySession = new Map()
+ const alertedAtBySession = new Map()
+ const permissionAlertCooldownMs = 5000
+
const unsub = globalSDK.event.listen((e) => {
if (e.details?.type !== "permission.updated") return
const directory = e.name
- const permission = e.details.properties
- const currentDir = params.dir ? base64Decode(params.dir) : undefined
- const currentSession = params.id
+ const perm = e.details.properties
+ if (permission.autoResponds(perm)) return
+
+ const sessionKey = `${directory}:${perm.sessionID}`
const [store] = globalSync.child(directory)
- const session = store.session.find((s) => s.id === permission.sessionID)
+ const session = store.session.find((s) => s.id === perm.sessionID)
+
const sessionTitle = session?.title ?? "New session"
const projectName = getFilename(directory)
const description = `${sessionTitle} in ${projectName} needs permission`
- const href = `/${base64Encode(directory)}/session/${permission.sessionID}`
+ const href = `/${base64Encode(directory)}/session/${perm.sessionID}`
+
+ const now = Date.now()
+ const lastAlerted = alertedAtBySession.get(sessionKey) ?? 0
+ if (now - lastAlerted < permissionAlertCooldownMs) return
+ alertedAtBySession.set(sessionKey, now)
+
void platform.notify("Permission required", description, href)
- if (directory === currentDir && permission.sessionID === currentSession) return
+ const currentDir = params.dir ? base64Decode(params.dir) : undefined
+ const currentSession = params.id
+ if (directory === currentDir && perm.sessionID === currentSession) return
if (directory === currentDir && session?.parentID === currentSession) return
- const sessionKey = `${directory}:${permission.sessionID}`
- if (seenSessions.has(sessionKey)) return
- seenSessions.add(sessionKey)
+ const existingToastId = toastBySession.get(sessionKey)
+ if (existingToastId !== undefined) {
+ toaster.dismiss(existingToastId)
+ }
const toastId = showToast({
persistent: true,
@@ -221,7 +236,7 @@ export default function Layout(props: ParentProps) {
if (toastId !== undefined) {
toaster.dismiss(toastId)
toastBySession.delete(sessionKey)
- seenSessions.delete(sessionKey)
+ alertedAtBySession.delete(sessionKey)
}
const [store] = globalSync.child(currentDir)
const childSessions = store.session.filter((s) => s.parentID === currentSession)
@@ -231,7 +246,7 @@ export default function Layout(props: ParentProps) {
if (childToastId !== undefined) {
toaster.dismiss(childToastId)
toastBySession.delete(childKey)
- seenSessions.delete(childKey)
+ alertedAtBySession.delete(childKey)
}
}
})
@@ -725,6 +740,7 @@ export default function Layout(props: ParentProps) {
>
archiveSession(props.session)} />
+