summaryrefslogtreecommitdiffhomepage
path: root/packages/ui/src/components
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/ui/src/components
parentc523ca412747d66e0236865a4fa2481f7d50f64e (diff)
downloadopencode-21eba5f987482b4e2e75ab1c564815bd7b0613f4.tar.gz
opencode-21eba5f987482b4e2e75ab1c564815bd7b0613f4.zip
feat(desktop): permissions
Diffstat (limited to 'packages/ui/src/components')
-rw-r--r--packages/ui/src/components/basic-tool.tsx11
-rw-r--r--packages/ui/src/components/message-part.css95
-rw-r--r--packages/ui/src/components/message-part.tsx226
-rw-r--r--packages/ui/src/components/session-turn.css8
-rw-r--r--packages/ui/src/components/session-turn.tsx23
-rw-r--r--packages/ui/src/components/toast.tsx10
6 files changed, 333 insertions, 40 deletions
diff --git a/packages/ui/src/components/basic-tool.tsx b/packages/ui/src/components/basic-tool.tsx
index 28320eeb3..67720955d 100644
--- a/packages/ui/src/components/basic-tool.tsx
+++ b/packages/ui/src/components/basic-tool.tsx
@@ -1,4 +1,4 @@
-import { For, Match, Show, Switch, type JSX } from "solid-js"
+import { createEffect, createSignal, For, Match, Show, Switch, type JSX } from "solid-js"
import { Collapsible } from "./collapsible"
import { Icon, IconProps } from "./icon"
@@ -24,11 +24,18 @@ export interface BasicToolProps {
children?: JSX.Element
hideDetails?: boolean
defaultOpen?: boolean
+ forceOpen?: boolean
}
export function BasicTool(props: BasicToolProps) {
+ const [open, setOpen] = createSignal(props.defaultOpen ?? false)
+
+ createEffect(() => {
+ if (props.forceOpen) setOpen(true)
+ })
+
return (
- <Collapsible defaultOpen={props.defaultOpen}>
+ <Collapsible open={open()} onOpenChange={setOpen}>
<Collapsible.Trigger>
<div data-component="tool-trigger">
<div data-slot="basic-tool-tool-trigger-content">
diff --git a/packages/ui/src/components/message-part.css b/packages/ui/src/components/message-part.css
index 6daf1a8b5..a8a9e6a31 100644
--- a/packages/ui/src/components/message-part.css
+++ b/packages/ui/src/components/message-part.css
@@ -361,3 +361,98 @@
overflow: hidden;
}
}
+
+[data-component="tool-part-wrapper"] {
+ width: 100%;
+
+ &[data-permission="true"] {
+ position: sticky;
+ top: var(--sticky-header-height, 80px);
+ bottom: 0px;
+ z-index: 10;
+ border-radius: 6px;
+ border: none;
+ box-shadow: var(--shadow-xs-border-base);
+ background-color: var(--surface-raised-base);
+ overflow: visible;
+
+ &::before {
+ content: "";
+ position: absolute;
+ inset: -1.5px;
+ border-radius: 7.5px;
+ border: 1.5px solid transparent;
+ background:
+ linear-gradient(var(--background-base) 0 0) padding-box,
+ conic-gradient(
+ from var(--border-angle),
+ transparent 0deg,
+ transparent 270deg,
+ var(--border-warning-strong, var(--border-warning-selected)) 300deg,
+ var(--border-warning-base) 360deg
+ )
+ border-box;
+ animation: chase-border 1.5s linear infinite;
+ pointer-events: none;
+ z-index: -1;
+ }
+
+ & > *:first-child {
+ border-top-left-radius: 6px;
+ border-top-right-radius: 6px;
+ overflow: hidden;
+ }
+
+ & > *:last-child {
+ border-bottom-left-radius: 6px;
+ border-bottom-right-radius: 6px;
+ overflow: hidden;
+ }
+
+ [data-component="collapsible"] {
+ border: none;
+ }
+
+ [data-component="card"] {
+ border: none;
+ }
+ }
+}
+
+@property --border-angle {
+ syntax: "<angle>";
+ initial-value: 0deg;
+ inherits: false;
+}
+
+@keyframes chase-border {
+ from {
+ --border-angle: 0deg;
+ }
+ to {
+ --border-angle: 360deg;
+ }
+}
+
+[data-component="permission-prompt"] {
+ display: flex;
+ flex-direction: column;
+ padding: 8px 12px;
+ background-color: var(--surface-raised-strong);
+ border-radius: 0 0 6px 6px;
+
+ [data-slot="permission-message"] {
+ display: none;
+ font-family: var(--font-family-sans);
+ font-size: var(--font-size-small);
+ font-weight: var(--font-weight-medium);
+ line-height: var(--line-height-large);
+ }
+
+ [data-slot="permission-actions"] {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ justify-content: flex-end;
+ }
+}
diff --git a/packages/ui/src/components/message-part.tsx b/packages/ui/src/components/message-part.tsx
index 1424041e8..0a1518b79 100644
--- a/packages/ui/src/components/message-part.tsx
+++ b/packages/ui/src/components/message-part.tsx
@@ -1,4 +1,4 @@
-import { Component, createMemo, For, Match, Show, Switch, type JSX } from "solid-js"
+import { Component, createEffect, createMemo, createSignal, For, Match, Show, Switch, type JSX } from "solid-js"
import { Dynamic } from "solid-js/web"
import {
AssistantMessage,
@@ -16,6 +16,7 @@ import { useDiffComponent } from "../context/diff"
import { useCodeComponent } from "../context/code"
import { BasicTool } from "./basic-tool"
import { GenericTool } from "./basic-tool"
+import { Button } from "./button"
import { Card } from "./card"
import { Icon } from "./icon"
import { Checkbox } from "./checkbox"
@@ -188,11 +189,6 @@ export function getToolInfo(tool: string, input: any = {}): ToolInfo {
}
}
-function getToolPartInfo(part: ToolPart): ToolInfo {
- const input = part.state.input || {}
- return getToolInfo(part.tool, input)
-}
-
export function registerPartComponent(type: string, component: PartComponent) {
PART_MAPPING[type] = component
}
@@ -334,6 +330,7 @@ export interface ToolProps {
status?: string
hideDetails?: boolean
defaultOpen?: boolean
+ forceOpen?: boolean
}
export type ToolComponent = Component<ToolProps>
@@ -361,11 +358,35 @@ export const ToolRegistry = {
}
PART_MAPPING["tool"] = function ToolPartDisplay(props) {
+ const data = useData()
const part = props.part as ToolPart
+
+ const permission = createMemo(() => {
+ const sessionID = props.message.sessionID
+ const permissions = data.store.permission?.[sessionID] ?? []
+ return permissions.find((p) => p.callID === part.callID)
+ })
+
+ const [forceOpen, setForceOpen] = createSignal(false)
+ createEffect(() => {
+ if (permission()) setForceOpen(true)
+ })
+
+ const respond = (response: "once" | "always" | "reject") => {
+ const perm = permission()
+ if (!perm || !data.respondToPermission) return
+ data.respondToPermission({
+ sessionID: perm.sessionID,
+ permissionID: perm.id,
+ response,
+ })
+ }
+
const component = createMemo(() => {
const render = ToolRegistry.render(part.tool) ?? GenericTool
- const metadata = part.state.status === "pending" ? {} : (part.state.metadata ?? {})
- const input = part.state.status === "completed" ? part.state.input : {}
+ // @ts-expect-error
+ const metadata = part.state?.metadata ?? {}
+ const input = part.state?.input ?? {}
return (
<Switch>
@@ -399,9 +420,11 @@ PART_MAPPING["tool"] = function ToolPartDisplay(props) {
input={input}
tool={part.tool}
metadata={metadata}
- output={part.state.status === "completed" ? part.state.output : undefined}
+ // @ts-expect-error
+ output={part.state.output}
status={part.state.status}
hideDetails={props.hideDetails}
+ forceOpen={forceOpen()}
defaultOpen={props.defaultOpen}
/>
</Match>
@@ -409,7 +432,29 @@ PART_MAPPING["tool"] = function ToolPartDisplay(props) {
)
})
- return <Show when={component()}>{component()}</Show>
+ return (
+ <div data-component="tool-part-wrapper" data-permission={!!permission()}>
+ <Show when={component()}>{component()}</Show>
+ <Show when={permission()}>
+ {(perm) => (
+ <div data-component="permission-prompt">
+ <div data-slot="permission-message">{perm().title}</div>
+ <div data-slot="permission-actions">
+ <Button variant="ghost" size="small" onClick={() => respond("reject")}>
+ Deny
+ </Button>
+ <Button variant="secondary" size="small" onClick={() => respond("always")}>
+ Allow always
+ </Button>
+ <Button variant="primary" size="small" onClick={() => respond("once")}>
+ Allow once
+ </Button>
+ </div>
+ </div>
+ )}
+ </Show>
+ </div>
+ )
}
PART_MAPPING["text"] = function TextPartDisplay(props) {
@@ -564,6 +609,7 @@ ToolRegistry.register({
ToolRegistry.register({
name: "task",
render(props) {
+ const data = useData()
const summary = () =>
(props.metadata.summary ?? []) as { id: string; tool: string; state: { status: string; title?: string } }[]
@@ -571,35 +617,141 @@ ToolRegistry.register({
working: () => true,
})
+ const childSessionId = () => props.metadata.sessionId as string | undefined
+
+ const childPermission = createMemo(() => {
+ const sessionId = childSessionId()
+ if (!sessionId) return undefined
+ const permissions = data.store.permission?.[sessionId] ?? []
+ return permissions.toSorted((a, b) => a.id.localeCompare(b.id))[0]
+ })
+
+ const childToolPart = createMemo(() => {
+ const perm = childPermission()
+ if (!perm) return undefined
+ const sessionId = childSessionId()
+ if (!sessionId) return undefined
+ // Find the tool part that matches the permission's callID
+ const messages = data.store.message[sessionId] ?? []
+ for (const msg of messages) {
+ const parts = data.store.part[msg.id] ?? []
+ for (const part of parts) {
+ if (part.type === "tool" && (part as ToolPart).callID === perm.callID) {
+ return { part: part as ToolPart, message: msg }
+ }
+ }
+ }
+ return undefined
+ })
+
+ const respond = (response: "once" | "always" | "reject") => {
+ const perm = childPermission()
+ if (!perm || !data.respondToPermission) return
+ data.respondToPermission({
+ sessionID: perm.sessionID,
+ permissionID: perm.id,
+ response,
+ })
+ }
+
+ const renderChildToolPart = () => {
+ const toolData = childToolPart()
+ if (!toolData) return null
+ const { part } = toolData
+ const render = ToolRegistry.render(part.tool) ?? GenericTool
+ // @ts-expect-error
+ const metadata = part.state?.metadata ?? {}
+ const input = part.state?.input ?? {}
+ return (
+ <Dynamic
+ component={render}
+ input={input}
+ tool={part.tool}
+ metadata={metadata}
+ // @ts-expect-error
+ output={part.state.output}
+ status={part.state.status}
+ defaultOpen={true}
+ />
+ )
+ }
+
return (
- <BasicTool
- icon="task"
- defaultOpen={true}
- trigger={{
- title: `${props.input.subagent_type || props.tool} Agent`,
- titleClass: "capitalize",
- subtitle: props.input.description,
- }}
- >
- <div ref={autoScroll.scrollRef} onScroll={autoScroll.handleScroll} data-component="tool-output" data-scrollable>
- <div ref={autoScroll.contentRef} data-component="task-tools">
- <For each={summary()}>
- {(item) => {
- const info = getToolInfo(item.tool)
- return (
- <div data-slot="task-tool-item">
- <Icon name={info.icon} size="small" />
- <span data-slot="task-tool-title">{info.title}</span>
- <Show when={item.state.title}>
- <span data-slot="task-tool-subtitle">{item.state.title}</span>
- </Show>
+ <div data-component="tool-part-wrapper" data-permission={!!childPermission()}>
+ <Switch>
+ <Match when={childPermission()}>
+ {(perm) => (
+ <>
+ <Show
+ when={childToolPart()}
+ fallback={
+ <BasicTool
+ icon="task"
+ defaultOpen={true}
+ trigger={{
+ title: `${props.input.subagent_type || props.tool} Agent`,
+ titleClass: "capitalize",
+ subtitle: props.input.description,
+ }}
+ />
+ }
+ >
+ {renderChildToolPart()}
+ </Show>
+ <div data-component="permission-prompt">
+ <div data-slot="permission-message">{perm().title}</div>
+ <div data-slot="permission-actions">
+ <Button variant="ghost" size="small" onClick={() => respond("reject")}>
+ Deny
+ </Button>
+ <Button variant="secondary" size="small" onClick={() => respond("always")}>
+ Allow always
+ </Button>
+ <Button variant="primary" size="small" onClick={() => respond("once")}>
+ Allow once
+ </Button>
</div>
- )
+ </div>
+ </>
+ )}
+ </Match>
+ <Match when={true}>
+ <BasicTool
+ icon="task"
+ defaultOpen={true}
+ trigger={{
+ title: `${props.input.subagent_type || props.tool} Agent`,
+ titleClass: "capitalize",
+ subtitle: props.input.description,
}}
- </For>
- </div>
- </div>
- </BasicTool>
+ >
+ <div
+ ref={autoScroll.scrollRef}
+ onScroll={autoScroll.handleScroll}
+ data-component="tool-output"
+ data-scrollable
+ >
+ <div ref={autoScroll.contentRef} data-component="task-tools">
+ <For each={summary()}>
+ {(item) => {
+ const info = getToolInfo(item.tool)
+ return (
+ <div data-slot="task-tool-item">
+ <Icon name={info.icon} size="small" />
+ <span data-slot="task-tool-title">{info.title}</span>
+ <Show when={item.state.title}>
+ <span data-slot="task-tool-subtitle">{item.state.title}</span>
+ </Show>
+ </div>
+ )
+ }}
+ </For>
+ </div>
+ </div>
+ </BasicTool>
+ </Match>
+ </Switch>
+ </div>
)
},
})
@@ -618,7 +770,7 @@ ToolRegistry.register({
>
<div data-component="tool-output" data-scrollable>
<Markdown
- text={`\`\`\`command\n$ ${props.input.command}${props.output ? "\n\n" + props.output : ""}\n\`\`\``}
+ text={`\`\`\`command\n$ ${props.input.command ?? props.metadata.command ?? ""}${props.output ? "\n\n" + props.output : ""}\n\`\`\``}
/>
</div>
</BasicTool>
diff --git a/packages/ui/src/components/session-turn.css b/packages/ui/src/components/session-turn.css
index 63c77e5ac..1748feab9 100644
--- a/packages/ui/src/components/session-turn.css
+++ b/packages/ui/src/components/session-turn.css
@@ -357,4 +357,12 @@
margin-top: 0;
}
}
+
+ [data-slot="session-turn-permission-parts"] {
+ width: 100%;
+ min-width: 0;
+ display: flex;
+ flex-direction: column;
+ gap: 12px;
+ }
}
diff --git a/packages/ui/src/components/session-turn.tsx b/packages/ui/src/components/session-turn.tsx
index a0368b0d4..ce4845a71 100644
--- a/packages/ui/src/components/session-turn.tsx
+++ b/packages/ui/src/components/session-turn.tsx
@@ -151,6 +151,22 @@ export function SessionTurn(
return false
})
+ const permissionParts = createMemo(() => {
+ const result: { part: ToolPart; message: AssistantMessage }[] = []
+ const permissions = data.store.permission?.[props.sessionID] ?? []
+ if (!permissions.length) return result
+
+ for (const m of assistantMessages()) {
+ const msgParts = data.store.part[m.id] ?? []
+ for (const p of msgParts) {
+ if (p?.type === "tool" && permissions.some((perm) => perm.callID === (p as ToolPart).callID)) {
+ result.push({ part: p as ToolPart, message: m })
+ }
+ }
+ }
+ return result
+ })
+
const shellModePart = createMemo(() => {
const p = parts()
if (!p.every((part) => part?.type === "text" && part?.synthetic)) return
@@ -469,6 +485,13 @@ export function SessionTurn(
</Show>
</div>
</Show>
+ <Show when={!props.stepsExpanded && permissionParts().length > 0}>
+ <div data-slot="session-turn-permission-parts">
+ <For each={permissionParts()}>
+ {({ part, message }) => <Part part={part} message={message} />}
+ </For>
+ </div>
+ </Show>
{/* Summary */}
<Show when={showSummarySection()}>
<div data-slot="session-turn-summary-section">
diff --git a/packages/ui/src/components/toast.tsx b/packages/ui/src/components/toast.tsx
index c1a29cd04..7e90e9f2f 100644
--- a/packages/ui/src/components/toast.tsx
+++ b/packages/ui/src/components/toast.tsx
@@ -92,6 +92,7 @@ export type ToastVariant = "default" | "success" | "error" | "loading"
export interface ToastAction {
label: string
onClick: "dismiss" | (() => void)
+ dismissAfter?: boolean
}
export interface ToastOptions {
@@ -128,7 +129,14 @@ export function showToast(options: ToastOptions | string) {
{opts.actions!.map((action) => (
<button
data-slot="toast-action"
- onClick={typeof action.onClick === "function" ? action.onClick : () => toaster.dismiss(props.toastId)}
+ onClick={() => {
+ if (typeof action.onClick === "function") {
+ action.onClick()
+ if (action.dismissAfter) toaster.dismiss(props.toastId)
+ } else {
+ toaster.dismiss(props.toastId)
+ }
+ }}
>
{action.label}
</button>