diff options
| author | Adam <[email protected]> | 2025-12-27 05:16:39 -0600 |
|---|---|---|
| committer | Adam <[email protected]> | 2025-12-27 14:43:42 -0600 |
| commit | 21eba5f987482b4e2e75ab1c564815bd7b0613f4 (patch) | |
| tree | 2d8cad03e54baa29d83e1e835a7ef2e64d3897e4 /packages/ui/src/components | |
| parent | c523ca412747d66e0236865a4fa2481f7d50f64e (diff) | |
| download | opencode-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.tsx | 11 | ||||
| -rw-r--r-- | packages/ui/src/components/message-part.css | 95 | ||||
| -rw-r--r-- | packages/ui/src/components/message-part.tsx | 226 | ||||
| -rw-r--r-- | packages/ui/src/components/session-turn.css | 8 | ||||
| -rw-r--r-- | packages/ui/src/components/session-turn.tsx | 23 | ||||
| -rw-r--r-- | packages/ui/src/components/toast.tsx | 10 |
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> |
