Inline permission approvals in tool calls
This commit is contained in:
@@ -6,6 +6,8 @@ import MessagePart from "./message-part"
|
|||||||
interface MessageItemProps {
|
interface MessageItemProps {
|
||||||
message: Message
|
message: Message
|
||||||
messageInfo?: MessageInfo
|
messageInfo?: MessageInfo
|
||||||
|
instanceId: string
|
||||||
|
sessionId: string
|
||||||
isQueued?: boolean
|
isQueued?: boolean
|
||||||
parts?: ClientPart[]
|
parts?: ClientPart[]
|
||||||
onRevert?: (messageId: string) => void
|
onRevert?: (messageId: string) => void
|
||||||
@@ -139,7 +141,14 @@ export default function MessageItem(props: MessageItemProps) {
|
|||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
<For each={messageParts()}>{(part) => <MessagePart part={part} messageType={props.message.type} />}</For>
|
<For each={messageParts()}>{(part) => (
|
||||||
|
<MessagePart
|
||||||
|
part={part}
|
||||||
|
messageType={props.message.type}
|
||||||
|
instanceId={props.instanceId}
|
||||||
|
sessionId={props.sessionId}
|
||||||
|
/>
|
||||||
|
)}</For>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Show when={props.message.status === "sending"}>
|
<Show when={props.message.status === "sending"}>
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ type ToolCallPart = Extract<ClientPart, { type: "tool" }>
|
|||||||
interface MessagePartProps {
|
interface MessagePartProps {
|
||||||
part: ClientPart
|
part: ClientPart
|
||||||
messageType?: "user" | "assistant"
|
messageType?: "user" | "assistant"
|
||||||
|
instanceId: string
|
||||||
|
sessionId: string
|
||||||
}
|
}
|
||||||
export default function MessagePart(props: MessagePartProps) {
|
export default function MessagePart(props: MessagePartProps) {
|
||||||
const { isDark } = useTheme()
|
const { isDark } = useTheme()
|
||||||
@@ -71,7 +73,12 @@ export default function MessagePart(props: MessagePartProps) {
|
|||||||
</Match>
|
</Match>
|
||||||
|
|
||||||
<Match when={partType() === "tool"}>
|
<Match when={partType() === "tool"}>
|
||||||
<ToolCall toolCall={props.part as ToolCallPart} toolCallId={props.part?.id} />
|
<ToolCall
|
||||||
|
toolCall={props.part as ToolCallPart}
|
||||||
|
toolCallId={props.part?.id}
|
||||||
|
instanceId={props.instanceId}
|
||||||
|
sessionId={props.sessionId}
|
||||||
|
/>
|
||||||
</Match>
|
</Match>
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -615,6 +615,8 @@ export default function MessageStream(props: MessageStreamProps) {
|
|||||||
<MessageItem
|
<MessageItem
|
||||||
message={item.message}
|
message={item.message}
|
||||||
messageInfo={item.messageInfo}
|
messageInfo={item.messageInfo}
|
||||||
|
instanceId={props.instanceId}
|
||||||
|
sessionId={props.sessionId}
|
||||||
isQueued={item.isQueued}
|
isQueued={item.isQueued}
|
||||||
parts={item.combinedParts}
|
parts={item.combinedParts}
|
||||||
onRevert={props.onRevert}
|
onRevert={props.onRevert}
|
||||||
@@ -665,6 +667,8 @@ export default function MessageStream(props: MessageStreamProps) {
|
|||||||
messageId={item.messageId}
|
messageId={item.messageId}
|
||||||
messageVersion={item.messageVersion}
|
messageVersion={item.messageVersion}
|
||||||
partVersion={item.partVersion}
|
partVersion={item.partVersion}
|
||||||
|
instanceId={props.instanceId}
|
||||||
|
sessionId={props.sessionId}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -208,9 +208,13 @@ const SessionList: Component<SessionListProps> = (props) => {
|
|||||||
const title = () => session()?.title || "Untitled"
|
const title = () => session()?.title || "Untitled"
|
||||||
const status = () => getSessionStatus(props.instanceId, rowProps.sessionId)
|
const status = () => getSessionStatus(props.instanceId, rowProps.sessionId)
|
||||||
const statusLabel = () => formatSessionStatus(status())
|
const statusLabel = () => formatSessionStatus(status())
|
||||||
|
const pendingPermission = () => Boolean(session()?.pendingPermission)
|
||||||
|
const statusClassName = () => (pendingPermission() ? "session-permission" : `session-${status()}`)
|
||||||
|
const statusText = () => (pendingPermission() ? "Needs Permission" : statusLabel())
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class="session-list-item group">
|
<div class="session-list-item group">
|
||||||
|
|
||||||
<button
|
<button
|
||||||
class={`session-item-base ${isActive() ? "session-item-active" : "session-item-inactive"}`}
|
class={`session-item-base ${isActive() ? "session-item-active" : "session-item-inactive"}`}
|
||||||
onClick={() => selectSession(rowProps.sessionId)}
|
onClick={() => selectSession(rowProps.sessionId)}
|
||||||
@@ -239,9 +243,9 @@ const SessionList: Component<SessionListProps> = (props) => {
|
|||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
<div class="session-item-row session-item-meta">
|
<div class="session-item-row session-item-meta">
|
||||||
<span class={`status-indicator session-status session-status-list session-${status()}`}>
|
<span class={`status-indicator session-status session-status-list ${statusClassName()}`}>
|
||||||
<span class="status-dot" />
|
<span class="status-dot" />
|
||||||
{statusLabel()}
|
{statusText()}
|
||||||
</span>
|
</span>
|
||||||
<div class="session-item-actions">
|
<div class="session-item-actions">
|
||||||
<span
|
<span
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import type { Attachment } from "../../types/attachment"
|
|||||||
import type { ClientPart } from "../../types/message"
|
import type { ClientPart } from "../../types/message"
|
||||||
import MessageStream from "../message-stream"
|
import MessageStream from "../message-stream"
|
||||||
import PromptInput from "../prompt-input"
|
import PromptInput from "../prompt-input"
|
||||||
import { instances, getActivePermission, sendPermissionResponse } from "../../stores/instances"
|
import { instances } from "../../stores/instances"
|
||||||
import { loadMessages, sendMessage, forkSession, isSessionMessagesLoading, setActiveParentSession, setActiveSession } from "../../stores/sessions"
|
import { loadMessages, sendMessage, forkSession, isSessionMessagesLoading, setActiveParentSession, setActiveSession } from "../../stores/sessions"
|
||||||
|
|
||||||
interface SessionViewProps {
|
interface SessionViewProps {
|
||||||
@@ -106,39 +106,6 @@ export const SessionView: Component<SessionViewProps> = (props) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const activePermission = createMemo(() => getActivePermission(props.instanceId))
|
|
||||||
|
|
||||||
async function handlePermissionResponse(response: "once" | "always" | "reject") {
|
|
||||||
const permission = activePermission()
|
|
||||||
if (!permission) return
|
|
||||||
|
|
||||||
try {
|
|
||||||
await sendPermissionResponse(props.instanceId, props.sessionId, permission.id, response)
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to send permission response:", error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
createEffect(() => {
|
|
||||||
const permission = activePermission()
|
|
||||||
if (!permission) return
|
|
||||||
|
|
||||||
const handleKeyDown = (event: KeyboardEvent) => {
|
|
||||||
if (event.key === "Enter") {
|
|
||||||
event.preventDefault()
|
|
||||||
handlePermissionResponse("once")
|
|
||||||
} else if (event.key === "a" || event.key === "A") {
|
|
||||||
event.preventDefault()
|
|
||||||
handlePermissionResponse("always")
|
|
||||||
} else if (event.key === "d" || event.key === "D") {
|
|
||||||
event.preventDefault()
|
|
||||||
handlePermissionResponse("reject")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
document.addEventListener("keydown", handleKeyDown)
|
|
||||||
onCleanup(() => document.removeEventListener("keydown", handleKeyDown))
|
|
||||||
})
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Show
|
<Show
|
||||||
@@ -162,39 +129,6 @@ export const SessionView: Component<SessionViewProps> = (props) => {
|
|||||||
onFork={handleFork}
|
onFork={handleFork}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Show when={activePermission()}>
|
|
||||||
{(permission) => (
|
|
||||||
<div class="permission-dialog border-2 border-[var(--status-warning)] bg-surface-secondary p-4 mx-4 mb-4 rounded-lg shadow-lg">
|
|
||||||
<div class="flex items-start gap-3">
|
|
||||||
<div class="flex-shrink-0">
|
|
||||||
<div class="w-6 h-6 bg-[var(--status-warning)] rounded-full flex items-center justify-center">
|
|
||||||
<svg class="w-4 h-4 text-[var(--text-inverted)]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L3.732 16.5c-.77.833.192 2.5 1.732 2.5z" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="flex-1">
|
|
||||||
<div class="mb-2">
|
|
||||||
<span class="font-semibold text-primary">Permission Required</span>
|
|
||||||
<span class="ml-2 font-mono text-sm bg-surface-secondary border border-base rounded px-1.5 py-0.5">{permission().type}</span>
|
|
||||||
</div>
|
|
||||||
<div class="bg-surface-code p-3 rounded border mb-3">
|
|
||||||
<code class="text-sm text-primary">{permission().title}</code>
|
|
||||||
</div>
|
|
||||||
<div class="flex gap-2 text-sm">
|
|
||||||
<kbd class="kbd">Enter</kbd>
|
|
||||||
<span class="text-muted">Accept once</span>
|
|
||||||
<kbd class="kbd ml-4">a</kbd>
|
|
||||||
<span class="text-muted">Accept always</span>
|
|
||||||
<kbd class="kbd ml-4">d</kbd>
|
|
||||||
<span class="text-muted">Deny</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Show>
|
|
||||||
|
|
||||||
<PromptInput
|
<PromptInput
|
||||||
instanceId={props.instanceId}
|
instanceId={props.instanceId}
|
||||||
instanceFolder={props.instanceFolder}
|
instanceFolder={props.instanceFolder}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { createSignal, Show, For, createEffect, onCleanup } from "solid-js"
|
import { createSignal, Show, For, createEffect, createMemo, onCleanup } from "solid-js"
|
||||||
import { isToolCallExpanded, toggleToolCallExpanded, setToolCallExpanded } from "../stores/tool-call-state"
|
import { isToolCallExpanded, toggleToolCallExpanded, setToolCallExpanded } from "../stores/tool-call-state"
|
||||||
import { Markdown } from "./markdown"
|
import { Markdown } from "./markdown"
|
||||||
import { ToolCallDiffViewer } from "./diff-viewer"
|
import { ToolCallDiffViewer } from "./diff-viewer"
|
||||||
@@ -8,6 +8,7 @@ import { isRenderableDiffText } from "../lib/diff-utils"
|
|||||||
import { getToolRenderCache, setToolRenderCache } from "../lib/tool-render-cache"
|
import { getToolRenderCache, setToolRenderCache } from "../lib/tool-render-cache"
|
||||||
import { useConfig } from "../stores/preferences"
|
import { useConfig } from "../stores/preferences"
|
||||||
import type { DiffViewMode } from "../stores/preferences"
|
import type { DiffViewMode } from "../stores/preferences"
|
||||||
|
import { sendPermissionResponse } from "../stores/instances"
|
||||||
import type { TextPart, SDKPart, ClientPart } from "../types/message"
|
import type { TextPart, SDKPart, ClientPart } from "../types/message"
|
||||||
|
|
||||||
type ToolCallPart = Extract<ClientPart, { type: "tool" }>
|
type ToolCallPart = Extract<ClientPart, { type: "tool" }>
|
||||||
@@ -81,6 +82,8 @@ interface ToolCallProps {
|
|||||||
messageId?: string
|
messageId?: string
|
||||||
messageVersion?: number
|
messageVersion?: number
|
||||||
partVersion?: number
|
partVersion?: number
|
||||||
|
instanceId: string
|
||||||
|
sessionId: string
|
||||||
}
|
}
|
||||||
|
|
||||||
function getToolIcon(tool: string): string {
|
function getToolIcon(tool: string): string {
|
||||||
@@ -183,11 +186,22 @@ export default function ToolCall(props: ToolCallProps) {
|
|||||||
const toolCallId = () => props.toolCallId || props.toolCall?.id || ""
|
const toolCallId = () => props.toolCallId || props.toolCall?.id || ""
|
||||||
const expanded = () => isToolCallExpanded(toolCallId())
|
const expanded = () => isToolCallExpanded(toolCallId())
|
||||||
const [initializedId, setInitializedId] = createSignal<string | null>(null)
|
const [initializedId, setInitializedId] = createSignal<string | null>(null)
|
||||||
|
const pendingPermission = createMemo(() => props.toolCall.pendingPermission)
|
||||||
|
const permissionDetails = createMemo(() => pendingPermission()?.permission)
|
||||||
|
const isPermissionActive = createMemo(() => pendingPermission()?.active === true)
|
||||||
|
const activePermissionKey = createMemo(() => {
|
||||||
|
const permission = permissionDetails()
|
||||||
|
return permission && isPermissionActive() ? permission.id : ""
|
||||||
|
})
|
||||||
|
const [permissionSubmitting, setPermissionSubmitting] = createSignal(false)
|
||||||
|
const [permissionError, setPermissionError] = createSignal<string | null>(null)
|
||||||
|
|
||||||
|
|
||||||
|
let scrollContainerRef: HTMLDivElement | undefined
|
||||||
|
let toolCallRootRef: HTMLDivElement | undefined
|
||||||
|
|
||||||
|
const handleScrollRendered = () => {
|
||||||
|
|
||||||
let scrollContainerRef: HTMLDivElement | undefined
|
|
||||||
|
|
||||||
const handleScrollRendered = () => {
|
|
||||||
const id = toolCallId()
|
const id = toolCallId()
|
||||||
if (!id || !scrollContainerRef) return
|
if (!id || !scrollContainerRef) return
|
||||||
restoreScrollState(id, scrollContainerRef)
|
restoreScrollState(id, scrollContainerRef)
|
||||||
@@ -221,6 +235,23 @@ export default function ToolCall(props: ToolCallProps) {
|
|||||||
setInitializedId(id)
|
setInitializedId(id)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
if (!pendingPermission()) return
|
||||||
|
const id = toolCallId()
|
||||||
|
if (!id) return
|
||||||
|
setToolCallExpanded(id, true)
|
||||||
|
})
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
const permission = permissionDetails()
|
||||||
|
if (!permission) {
|
||||||
|
setPermissionSubmitting(false)
|
||||||
|
setPermissionError(null)
|
||||||
|
} else {
|
||||||
|
setPermissionError(null)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
// Cleanup cache entry when component unmounts or toolCallId changes
|
// Cleanup cache entry when component unmounts or toolCallId changes
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
const id = toolCallId()
|
const id = toolCallId()
|
||||||
@@ -245,6 +276,34 @@ export default function ToolCall(props: ToolCallProps) {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
const activeKey = activePermissionKey()
|
||||||
|
if (!activeKey) return
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
toolCallRootRef?.scrollIntoView({ block: "center", behavior: "smooth" })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
const activeKey = activePermissionKey()
|
||||||
|
if (!activeKey) return
|
||||||
|
const handler = (event: KeyboardEvent) => {
|
||||||
|
if (event.key === "Enter") {
|
||||||
|
event.preventDefault()
|
||||||
|
handlePermissionResponse("once")
|
||||||
|
} else if (event.key === "a" || event.key === "A") {
|
||||||
|
event.preventDefault()
|
||||||
|
handlePermissionResponse("always")
|
||||||
|
} else if (event.key === "d" || event.key === "D") {
|
||||||
|
event.preventDefault()
|
||||||
|
handlePermissionResponse("reject")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
document.addEventListener("keydown", handler)
|
||||||
|
onCleanup(() => document.removeEventListener("keydown", handler))
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
const statusIcon = () => {
|
const statusIcon = () => {
|
||||||
const status = props.toolCall?.state?.status || ""
|
const status = props.toolCall?.state?.status || ""
|
||||||
switch (status) {
|
switch (status) {
|
||||||
@@ -266,6 +325,11 @@ export default function ToolCall(props: ToolCallProps) {
|
|||||||
return `tool-call-status-${status}`
|
return `tool-call-status-${status}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const combinedStatusClass = () => {
|
||||||
|
const base = statusClass()
|
||||||
|
return pendingPermission() ? `${base} tool-call-awaiting-permission` : base
|
||||||
|
}
|
||||||
|
|
||||||
function toggle() {
|
function toggle() {
|
||||||
toggleToolCallExpanded(toolCallId())
|
toggleToolCallExpanded(toolCallId())
|
||||||
}
|
}
|
||||||
@@ -301,6 +365,24 @@ export default function ToolCall(props: ToolCallProps) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handlePermissionResponse(response: "once" | "always" | "reject") {
|
||||||
|
const permission = permissionDetails()
|
||||||
|
if (!permission || !isPermissionActive()) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setPermissionSubmitting(true)
|
||||||
|
setPermissionError(null)
|
||||||
|
try {
|
||||||
|
const sessionId = permission.sessionID || props.sessionId
|
||||||
|
await sendPermissionResponse(props.instanceId, sessionId, permission.id, response)
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to send permission response:", error)
|
||||||
|
setPermissionError(error instanceof Error ? error.message : "Unable to update permission")
|
||||||
|
} finally {
|
||||||
|
setPermissionSubmitting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const getTodoTitle = () => {
|
const getTodoTitle = () => {
|
||||||
const state = props.toolCall?.state || {}
|
const state = props.toolCall?.state || {}
|
||||||
if (state.status !== "completed") return "Plan"
|
if (state.status !== "completed") return "Plan"
|
||||||
@@ -424,10 +506,11 @@ export default function ToolCall(props: ToolCallProps) {
|
|||||||
return renderMarkdownTool(toolName, state)
|
return renderMarkdownTool(toolName, state)
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderDiffTool(payload: DiffPayload) {
|
function renderDiffTool(payload: DiffPayload, options?: { cacheKeySuffix?: string; disableScrollTracking?: boolean; label?: string }) {
|
||||||
const relativePath = payload.filePath ? getRelativePath(payload.filePath) : ""
|
const relativePath = payload.filePath ? getRelativePath(payload.filePath) : ""
|
||||||
const toolbarLabel = relativePath ? `Diff · ${relativePath}` : "Diff"
|
const toolbarLabel = options?.label || (relativePath ? `Diff · ${relativePath}` : "Diff")
|
||||||
const cacheKey = makeRenderCacheKey(toolCallId(), props.messageId, props.messageVersion, props.partVersion)
|
const cacheKeyBase = makeRenderCacheKey(toolCallId(), props.messageId, props.messageVersion, props.partVersion)
|
||||||
|
const cacheKey = options?.cacheKeySuffix ? `${cacheKeyBase}${options.cacheKeySuffix}` : cacheKeyBase
|
||||||
const diffMode = () => (preferences().diffViewMode || "split") as DiffViewMode
|
const diffMode = () => (preferences().diffViewMode || "split") as DiffViewMode
|
||||||
const themeKey = isDark() ? "dark" : "light"
|
const themeKey = isDark() ? "dark" : "light"
|
||||||
|
|
||||||
@@ -453,15 +536,21 @@ export default function ToolCall(props: ToolCallProps) {
|
|||||||
// Cache will be updated by the diff viewer component itself
|
// Cache will be updated by the diff viewer component itself
|
||||||
// We'll capture HTML from the rendered component
|
// We'll capture HTML from the rendered component
|
||||||
}
|
}
|
||||||
handleScrollRendered()
|
if (!options?.disableScrollTracking) {
|
||||||
|
handleScrollRendered()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
class="message-text tool-call-markdown tool-call-markdown-large tool-call-diff-shell"
|
class="message-text tool-call-markdown tool-call-markdown-large tool-call-diff-shell"
|
||||||
ref={(element) => initializeScrollContainer(element)}
|
ref={(element) => {
|
||||||
onScroll={(event) => updateScrollState(toolCallId(), event.currentTarget)}
|
if (options?.disableScrollTracking) return
|
||||||
|
initializeScrollContainer(element)
|
||||||
|
}}
|
||||||
|
onScroll={options?.disableScrollTracking ? undefined : (event) => updateScrollState(toolCallId(), event.currentTarget)}
|
||||||
>
|
>
|
||||||
|
|
||||||
<div class="tool-call-diff-toolbar" role="group" aria-label="Diff view mode">
|
<div class="tool-call-diff-toolbar" role="group" aria-label="Diff view mode">
|
||||||
<span class="tool-call-diff-toolbar-label">{toolbarLabel}</span>
|
<span class="tool-call-diff-toolbar-label">{toolbarLabel}</span>
|
||||||
<div class="tool-call-diff-toggle">
|
<div class="tool-call-diff-toggle">
|
||||||
@@ -802,11 +891,103 @@ export default function ToolCall(props: ToolCallProps) {
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const renderPermissionBlock = () => {
|
||||||
|
const permission = permissionDetails()
|
||||||
|
if (!permission) return null
|
||||||
|
const active = isPermissionActive()
|
||||||
|
const metadata = (permission.metadata ?? {}) as Record<string, unknown>
|
||||||
|
const diffValue = typeof metadata.diff === "string" ? (metadata.diff as string) : null
|
||||||
|
const diffPathRaw = (() => {
|
||||||
|
if (typeof metadata.filePath === "string") {
|
||||||
|
return metadata.filePath as string
|
||||||
|
}
|
||||||
|
if (typeof metadata.path === "string") {
|
||||||
|
return metadata.path as string
|
||||||
|
}
|
||||||
|
return undefined
|
||||||
|
})()
|
||||||
|
const diffPayload = diffValue && diffValue.trim().length > 0 ? { diffText: diffValue, filePath: diffPathRaw } : null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div class={`tool-call-permission ${active ? "tool-call-permission-active" : "tool-call-permission-queued"}`}>
|
||||||
|
<div class="tool-call-permission-header">
|
||||||
|
<span class="tool-call-permission-label">{active ? "Permission Required" : "Permission Queued"}</span>
|
||||||
|
<span class="tool-call-permission-type">{permission.type}</span>
|
||||||
|
</div>
|
||||||
|
<div class="tool-call-permission-body">
|
||||||
|
<div class="tool-call-permission-title">
|
||||||
|
<code>{permission.title}</code>
|
||||||
|
</div>
|
||||||
|
<Show when={diffPayload}>
|
||||||
|
{(payload) => (
|
||||||
|
<div class="tool-call-permission-diff">
|
||||||
|
{renderDiffTool(payload(), {
|
||||||
|
cacheKeySuffix: "::permission",
|
||||||
|
disableScrollTracking: true,
|
||||||
|
label: payload().filePath ? `Requested diff · ${getRelativePath(payload().filePath || "")}` : "Requested diff",
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Show>
|
||||||
|
<Show
|
||||||
|
when={active}
|
||||||
|
fallback={<p class="tool-call-permission-queued-text">Waiting for earlier permission responses.</p>}
|
||||||
|
>
|
||||||
|
<div class="tool-call-permission-actions">
|
||||||
|
<div class="tool-call-permission-buttons">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="tool-call-permission-button"
|
||||||
|
disabled={permissionSubmitting()}
|
||||||
|
onClick={() => handlePermissionResponse("once")}
|
||||||
|
>
|
||||||
|
Allow Once
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="tool-call-permission-button"
|
||||||
|
disabled={permissionSubmitting()}
|
||||||
|
onClick={() => handlePermissionResponse("always")}
|
||||||
|
>
|
||||||
|
Always Allow
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="tool-call-permission-button"
|
||||||
|
disabled={permissionSubmitting()}
|
||||||
|
onClick={() => handlePermissionResponse("reject")}
|
||||||
|
>
|
||||||
|
Deny
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="tool-call-permission-shortcuts">
|
||||||
|
<kbd class="kbd">Enter</kbd>
|
||||||
|
<span>Allow once</span>
|
||||||
|
<kbd class="kbd">A</kbd>
|
||||||
|
<span>Always allow</span>
|
||||||
|
<kbd class="kbd">D</kbd>
|
||||||
|
<span>Deny</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Show when={permissionError()}>
|
||||||
|
<div class="tool-call-permission-error">{permissionError()}</div>
|
||||||
|
</Show>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
const toolName = () => props.toolCall?.tool || ""
|
const toolName = () => props.toolCall?.tool || ""
|
||||||
const status = () => props.toolCall?.state?.status || ""
|
const status = () => props.toolCall?.state?.status || ""
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class={`tool-call ${statusClass()}`}>
|
<div
|
||||||
|
ref={(element) => {
|
||||||
|
toolCallRootRef = element || undefined
|
||||||
|
}}
|
||||||
|
class={`tool-call ${combinedStatusClass()}`}
|
||||||
|
>
|
||||||
<button class="tool-call-header" onClick={toggle} aria-expanded={expanded()}>
|
<button class="tool-call-header" onClick={toggle} aria-expanded={expanded()}>
|
||||||
<span class="tool-call-icon">{expanded() ? "▼" : "▶"}</span>
|
<span class="tool-call-icon">{expanded() ? "▼" : "▶"}</span>
|
||||||
<span class="tool-call-emoji">{getToolIcon(toolName())}</span>
|
<span class="tool-call-emoji">{getToolIcon(toolName())}</span>
|
||||||
@@ -819,7 +1000,9 @@ export default function ToolCall(props: ToolCallProps) {
|
|||||||
{renderToolBody()}
|
{renderToolBody()}
|
||||||
{renderError()}
|
{renderError()}
|
||||||
|
|
||||||
<Show when={status() === "pending"}>
|
{renderPermissionBlock()}
|
||||||
|
|
||||||
|
<Show when={status() === "pending" && !pendingPermission()}>
|
||||||
<div class="tool-call-pending-message">
|
<div class="tool-call-pending-message">
|
||||||
<span class="spinner-small"></span>
|
<span class="spinner-small"></span>
|
||||||
<span>Waiting for permission...</span>
|
<span>Waiting for permission...</span>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { createSignal } from "solid-js"
|
import { createSignal } from "solid-js"
|
||||||
import type { Instance, LogEntry } from "../types/instance"
|
import type { Instance, LogEntry } from "../types/instance"
|
||||||
import type { Permission } from "@opencode-ai/sdk"
|
import type { Permission } from "@opencode-ai/sdk"
|
||||||
|
import type { ClientPart, Message } from "../types/message"
|
||||||
import { sdkManager } from "../lib/sdk-manager"
|
import { sdkManager } from "../lib/sdk-manager"
|
||||||
import { sseManager } from "../lib/sse-manager"
|
import { sseManager } from "../lib/sse-manager"
|
||||||
import {
|
import {
|
||||||
@@ -12,6 +13,8 @@ import {
|
|||||||
} from "./sessions"
|
} from "./sessions"
|
||||||
import { fetchCommands, clearCommands } from "./commands"
|
import { fetchCommands, clearCommands } from "./commands"
|
||||||
import { preferences, updateLastUsedBinary } from "./preferences"
|
import { preferences, updateLastUsedBinary } from "./preferences"
|
||||||
|
import { computeDisplayParts } from "./session-messages"
|
||||||
|
import { withSession, setSessionPendingPermission } from "./session-state"
|
||||||
import { setHasInstances } from "./ui"
|
import { setHasInstances } from "./ui"
|
||||||
|
|
||||||
const [instances, setInstances] = createSignal<Map<string, Instance>>(new Map())
|
const [instances, setInstances] = createSignal<Map<string, Instance>>(new Map())
|
||||||
@@ -22,6 +25,7 @@ const [logStreamingState, setLogStreamingState] = createSignal<Map<string, boole
|
|||||||
// Permission queue management per instance
|
// Permission queue management per instance
|
||||||
const [permissionQueues, setPermissionQueues] = createSignal<Map<string, Permission[]>>(new Map())
|
const [permissionQueues, setPermissionQueues] = createSignal<Map<string, Permission[]>>(new Map())
|
||||||
const [activePermissionId, setActivePermissionId] = createSignal<Map<string, string | null>>(new Map())
|
const [activePermissionId, setActivePermissionId] = createSignal<Map<string, string | null>>(new Map())
|
||||||
|
const permissionSessionCounts = new Map<string, Map<string, number>>()
|
||||||
interface DisconnectedInstanceInfo {
|
interface DisconnectedInstanceInfo {
|
||||||
id: string
|
id: string
|
||||||
folder: string
|
folder: string
|
||||||
@@ -141,6 +145,7 @@ function removeInstance(id: string) {
|
|||||||
|
|
||||||
removeLogContainer(id)
|
removeLogContainer(id)
|
||||||
clearCommands(id)
|
clearCommands(id)
|
||||||
|
clearPermissionQueue(id)
|
||||||
|
|
||||||
if (activeInstanceId() === id) {
|
if (activeInstanceId() === id) {
|
||||||
setActiveInstanceId(nextActiveId)
|
setActiveInstanceId(nextActiveId)
|
||||||
@@ -260,30 +265,73 @@ function clearLogs(id: string) {
|
|||||||
|
|
||||||
// Permission management functions
|
// Permission management functions
|
||||||
function getPermissionQueue(instanceId: string): Permission[] {
|
function getPermissionQueue(instanceId: string): Permission[] {
|
||||||
return permissionQueues().get(instanceId) ?? []
|
const queue = permissionQueues().get(instanceId)
|
||||||
|
if (!queue) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
return queue
|
||||||
}
|
}
|
||||||
|
|
||||||
function getPermissionQueueLength(instanceId: string): number {
|
function getPermissionQueueLength(instanceId: string): number {
|
||||||
return getPermissionQueue(instanceId).length
|
return getPermissionQueue(instanceId).length
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function incrementSessionPendingCount(instanceId: string, sessionId: string): void {
|
||||||
|
let sessionCounts = permissionSessionCounts.get(instanceId)
|
||||||
|
if (!sessionCounts) {
|
||||||
|
sessionCounts = new Map()
|
||||||
|
permissionSessionCounts.set(instanceId, sessionCounts)
|
||||||
|
}
|
||||||
|
const current = sessionCounts.get(sessionId) ?? 0
|
||||||
|
sessionCounts.set(sessionId, current + 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
function decrementSessionPendingCount(instanceId: string, sessionId: string): number {
|
||||||
|
const sessionCounts = permissionSessionCounts.get(instanceId)
|
||||||
|
if (!sessionCounts) return 0
|
||||||
|
const current = sessionCounts.get(sessionId) ?? 0
|
||||||
|
if (current <= 1) {
|
||||||
|
sessionCounts.delete(sessionId)
|
||||||
|
if (sessionCounts.size === 0) {
|
||||||
|
permissionSessionCounts.delete(instanceId)
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
const nextValue = current - 1
|
||||||
|
sessionCounts.set(sessionId, nextValue)
|
||||||
|
return nextValue
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearSessionPendingCounts(instanceId: string): void {
|
||||||
|
const sessionCounts = permissionSessionCounts.get(instanceId)
|
||||||
|
if (!sessionCounts) return
|
||||||
|
for (const sessionId of sessionCounts.keys()) {
|
||||||
|
setSessionPendingPermission(instanceId, sessionId, false)
|
||||||
|
}
|
||||||
|
permissionSessionCounts.delete(instanceId)
|
||||||
|
}
|
||||||
|
|
||||||
function addPermissionToQueue(instanceId: string, permission: Permission): void {
|
function addPermissionToQueue(instanceId: string, permission: Permission): void {
|
||||||
|
let inserted = false
|
||||||
|
|
||||||
setPermissionQueues((prev) => {
|
setPermissionQueues((prev) => {
|
||||||
const next = new Map(prev)
|
const next = new Map(prev)
|
||||||
const queue = next.get(instanceId) ?? []
|
const queue = next.get(instanceId) ?? []
|
||||||
|
|
||||||
// Check if permission already exists
|
if (queue.some((p) => p.id === permission.id)) {
|
||||||
if (queue.some(p => p.id === permission.id)) {
|
return next
|
||||||
return next // Don't add duplicate
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add to queue and sort by creation time to maintain order
|
|
||||||
const updatedQueue = [...queue, permission].sort((a, b) => a.time.created - b.time.created)
|
const updatedQueue = [...queue, permission].sort((a, b) => a.time.created - b.time.created)
|
||||||
next.set(instanceId, updatedQueue)
|
next.set(instanceId, updatedQueue)
|
||||||
|
inserted = true
|
||||||
return next
|
return next
|
||||||
})
|
})
|
||||||
|
|
||||||
// Set as active if no active permission
|
if (!inserted) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
setActivePermissionId((prev) => {
|
setActivePermissionId((prev) => {
|
||||||
const next = new Map(prev)
|
const next = new Map(prev)
|
||||||
if (!next.get(instanceId)) {
|
if (!next.get(instanceId)) {
|
||||||
@@ -291,6 +339,13 @@ function addPermissionToQueue(instanceId: string, permission: Permission): void
|
|||||||
}
|
}
|
||||||
return next
|
return next
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const sessionId = getPermissionSessionId(permission)
|
||||||
|
incrementSessionPendingCount(instanceId, sessionId)
|
||||||
|
setSessionPendingPermission(instanceId, sessionId, true)
|
||||||
|
|
||||||
|
const isActive = getActivePermission(instanceId)?.id === permission.id
|
||||||
|
attachPermissionToToolPart(instanceId, permission, isActive)
|
||||||
}
|
}
|
||||||
|
|
||||||
function getActivePermission(instanceId: string): Permission | null {
|
function getActivePermission(instanceId: string): Permission | null {
|
||||||
@@ -302,30 +357,53 @@ function getActivePermission(instanceId: string): Permission | null {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function removePermissionFromQueue(instanceId: string, permissionId: string): void {
|
function removePermissionFromQueue(instanceId: string, permissionId: string): void {
|
||||||
let updatedQueue: Permission[] = []
|
let removedPermission: Permission | null = null
|
||||||
|
|
||||||
setPermissionQueues((prev) => {
|
setPermissionQueues((prev) => {
|
||||||
const next = new Map(prev)
|
const next = new Map(prev)
|
||||||
const queue = next.get(instanceId) ?? []
|
const queue = next.get(instanceId) ?? []
|
||||||
updatedQueue = queue.filter(p => p.id !== permissionId)
|
const filtered: Permission[] = []
|
||||||
if (updatedQueue.length > 0) {
|
|
||||||
next.set(instanceId, updatedQueue)
|
for (const item of queue) {
|
||||||
|
if (item.id === permissionId) {
|
||||||
|
removedPermission = item
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
filtered.push(item)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filtered.length > 0) {
|
||||||
|
next.set(instanceId, filtered)
|
||||||
} else {
|
} else {
|
||||||
next.delete(instanceId)
|
next.delete(instanceId)
|
||||||
}
|
}
|
||||||
return next
|
return next
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const updatedQueue = getPermissionQueue(instanceId)
|
||||||
|
|
||||||
setActivePermissionId((prev) => {
|
setActivePermissionId((prev) => {
|
||||||
const next = new Map(prev)
|
const next = new Map(prev)
|
||||||
const activeId = next.get(instanceId)
|
const activeId = next.get(instanceId)
|
||||||
if (activeId === permissionId) {
|
if (activeId === permissionId) {
|
||||||
// Set the next permission in queue as active, or null if queue is empty
|
const nextPermission = updatedQueue.length > 0 ? (updatedQueue[0] as Permission) : null
|
||||||
const nextPermission = updatedQueue.length > 0 ? updatedQueue[0] : null
|
|
||||||
next.set(instanceId, nextPermission?.id ?? null)
|
next.set(instanceId, nextPermission?.id ?? null)
|
||||||
}
|
}
|
||||||
return next
|
return next
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const removed = removedPermission
|
||||||
|
if (removed) {
|
||||||
|
clearPermissionFromToolPart(instanceId, removed)
|
||||||
|
const removedSessionId = getPermissionSessionId(removed)
|
||||||
|
const remaining = decrementSessionPendingCount(instanceId, removedSessionId)
|
||||||
|
setSessionPendingPermission(instanceId, removedSessionId, remaining > 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextActivePermission = getActivePermission(instanceId)
|
||||||
|
if (nextActivePermission) {
|
||||||
|
attachPermissionToToolPart(instanceId, nextActivePermission, true)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function clearPermissionQueue(instanceId: string): void {
|
function clearPermissionQueue(instanceId: string): void {
|
||||||
@@ -339,6 +417,95 @@ function clearPermissionQueue(instanceId: string): void {
|
|||||||
next.delete(instanceId)
|
next.delete(instanceId)
|
||||||
return next
|
return next
|
||||||
})
|
})
|
||||||
|
clearSessionPendingCounts(instanceId)
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPermissionSessionId(permission: Permission): string {
|
||||||
|
return (permission as any).sessionID
|
||||||
|
}
|
||||||
|
|
||||||
|
function findToolPartForPermission(message: Message, permission: Permission): ClientPart | null {
|
||||||
|
const expectedCallId = permission.callID
|
||||||
|
for (const part of message.parts) {
|
||||||
|
if (part.type !== "tool") continue
|
||||||
|
const toolCallId = (part as any).callID
|
||||||
|
if (expectedCallId) {
|
||||||
|
if (toolCallId === expectedCallId) {
|
||||||
|
return part as ClientPart
|
||||||
|
}
|
||||||
|
if (!toolCallId && (part.id === expectedCallId || part.messageID === permission.messageID)) {
|
||||||
|
return part as ClientPart
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((toolCallId && toolCallId === permission.id) || part.id === permission.id || part.messageID === permission.messageID) {
|
||||||
|
return part as ClientPart
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
function mutateToolPartPermission(
|
||||||
|
instanceId: string,
|
||||||
|
permission: Permission,
|
||||||
|
mutator: (part: ClientPart, message: Message) => boolean,
|
||||||
|
): void {
|
||||||
|
const permissionSessionId = getPermissionSessionId(permission)
|
||||||
|
withSession(instanceId, permissionSessionId, (session) => {
|
||||||
|
const message = session.messages.find((msg) => msg.id === permission.messageID)
|
||||||
|
if (!message) return
|
||||||
|
const targetPart = findToolPartForPermission(message, permission)
|
||||||
|
if (!targetPart) return
|
||||||
|
|
||||||
|
const changed = mutator(targetPart, message)
|
||||||
|
if (!changed) return
|
||||||
|
|
||||||
|
const nextPartVersion = typeof targetPart.version === "number" ? targetPart.version + 1 : 1
|
||||||
|
targetPart.version = nextPartVersion
|
||||||
|
message.version = (message.version ?? 0) + 1
|
||||||
|
message.displayParts = computeDisplayParts(message, preferences().showThinkingBlocks)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function attachPermissionToToolPart(instanceId: string, permission: Permission, active: boolean): void {
|
||||||
|
mutateToolPartPermission(instanceId, permission, (part) => {
|
||||||
|
const existing = part.pendingPermission
|
||||||
|
if (existing && existing.permission.id === permission.id && existing.active === active) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
part.pendingPermission = { permission, active }
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearPermissionFromToolPart(instanceId: string, permission: Permission): void {
|
||||||
|
mutateToolPartPermission(instanceId, permission, (part) => {
|
||||||
|
if (!part.pendingPermission || part.pendingPermission.permission.id !== permission.id) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
delete part.pendingPermission
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function refreshPermissionsForSession(instanceId: string, sessionId: string): void {
|
||||||
|
const queue = getPermissionQueue(instanceId)
|
||||||
|
if (queue.length === 0) {
|
||||||
|
setSessionPendingPermission(instanceId, sessionId, false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const activeId = activePermissionId().get(instanceId)
|
||||||
|
|
||||||
|
for (const permission of queue) {
|
||||||
|
if (getPermissionSessionId(permission) !== sessionId) continue
|
||||||
|
const isActive = permission.id === activeId
|
||||||
|
attachPermissionToToolPart(instanceId, permission, isActive)
|
||||||
|
}
|
||||||
|
|
||||||
|
const pendingCount = permissionSessionCounts.get(instanceId)?.get(sessionId) ?? 0
|
||||||
|
setSessionPendingPermission(instanceId, sessionId, pendingCount > 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function sendPermissionResponse(
|
async function sendPermissionResponse(
|
||||||
@@ -422,6 +589,7 @@ export {
|
|||||||
getActivePermission,
|
getActivePermission,
|
||||||
removePermissionFromQueue,
|
removePermissionFromQueue,
|
||||||
clearPermissionQueue,
|
clearPermissionQueue,
|
||||||
|
refreshPermissionsForSession,
|
||||||
sendPermissionResponse,
|
sendPermissionResponse,
|
||||||
disconnectedInstance,
|
disconnectedInstance,
|
||||||
acknowledgeDisconnectedInstance,
|
acknowledgeDisconnectedInstance,
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { Session } from "../types/session"
|
import type { Session } from "../types/session"
|
||||||
import type { Message } from "../types/message"
|
import type { Message } from "../types/message"
|
||||||
|
|
||||||
import { instances } from "./instances"
|
import { instances, refreshPermissionsForSession } from "./instances"
|
||||||
import { preferences, setAgentModelPreference } from "./preferences"
|
import { preferences, setAgentModelPreference } from "./preferences"
|
||||||
import { setSessionCompactionState } from "./session-compaction"
|
import { setSessionCompactionState } from "./session-compaction"
|
||||||
import {
|
import {
|
||||||
@@ -608,9 +608,11 @@ async function loadMessages(instanceId: string, sessionId: string, force = false
|
|||||||
return next
|
return next
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
updateSessionInfo(instanceId, sessionId)
|
||||||
|
refreshPermissionsForSession(instanceId, sessionId)
|
||||||
|
}
|
||||||
|
|
||||||
updateSessionInfo(instanceId, sessionId)
|
|
||||||
}
|
|
||||||
|
|
||||||
export {
|
export {
|
||||||
createSession,
|
createSession,
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import type {
|
|||||||
|
|
||||||
import { showToastNotification, ToastVariant } from "../lib/notifications"
|
import { showToastNotification, ToastVariant } from "../lib/notifications"
|
||||||
import { preferences } from "./preferences"
|
import { preferences } from "./preferences"
|
||||||
import { instances, addPermissionToQueue, removePermissionFromQueue } from "./instances"
|
import { instances, addPermissionToQueue, removePermissionFromQueue, refreshPermissionsForSession } from "./instances"
|
||||||
import {
|
import {
|
||||||
sessions,
|
sessions,
|
||||||
setSessions,
|
setSessions,
|
||||||
@@ -211,6 +211,7 @@ function handleMessageUpdate(instanceId: string, event: MessageUpdateEvent | Mes
|
|||||||
})
|
})
|
||||||
|
|
||||||
updateSessionInfo(instanceId, part.sessionID)
|
updateSessionInfo(instanceId, part.sessionID)
|
||||||
|
refreshPermissionsForSession(instanceId, part.sessionID)
|
||||||
} else if (event.type === "message.updated") {
|
} else if (event.type === "message.updated") {
|
||||||
const info = event.properties?.info
|
const info = event.properties?.info
|
||||||
if (!info) return
|
if (!info) return
|
||||||
@@ -308,6 +309,7 @@ function handleMessageUpdate(instanceId: string, event: MessageUpdateEvent | Mes
|
|||||||
})
|
})
|
||||||
|
|
||||||
updateSessionInfo(instanceId, info.sessionID)
|
updateSessionInfo(instanceId, info.sessionID)
|
||||||
|
refreshPermissionsForSession(instanceId, info.sessionID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -127,6 +127,13 @@ function setSessionCompactionState(instanceId: string, sessionId: string, isComp
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function setSessionPendingPermission(instanceId: string, sessionId: string, pending: boolean): void {
|
||||||
|
withSession(instanceId, sessionId, (session) => {
|
||||||
|
if (session.pendingPermission === pending) return
|
||||||
|
session.pendingPermission = pending
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
function setActiveSession(instanceId: string, sessionId: string): void {
|
function setActiveSession(instanceId: string, sessionId: string): void {
|
||||||
setActiveSessionId((prev) => {
|
setActiveSessionId((prev) => {
|
||||||
const next = new Map(prev)
|
const next = new Map(prev)
|
||||||
@@ -237,7 +244,9 @@ export {
|
|||||||
pruneDraftPrompts,
|
pruneDraftPrompts,
|
||||||
withSession,
|
withSession,
|
||||||
setSessionCompactionState,
|
setSessionCompactionState,
|
||||||
|
setSessionPendingPermission,
|
||||||
setActiveSession,
|
setActiveSession,
|
||||||
|
|
||||||
setActiveParentSession,
|
setActiveParentSession,
|
||||||
clearActiveParentSession,
|
clearActiveParentSession,
|
||||||
getActiveSession,
|
getActiveSession,
|
||||||
|
|||||||
@@ -237,16 +237,128 @@
|
|||||||
line-height: var(--line-height-tight);
|
line-height: var(--line-height-tight);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.tool-call-awaiting-permission {
|
||||||
|
border-left-color: var(--status-warning);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-call-permission {
|
||||||
|
@apply flex flex-col gap-3;
|
||||||
|
border: 2px solid var(--status-warning);
|
||||||
|
border-radius: 0;
|
||||||
|
margin: 0;
|
||||||
|
padding: 1rem 1.25rem;
|
||||||
|
background-color: var(--message-tool-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-call-permission-header {
|
||||||
|
@apply flex items-center justify-between gap-3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-call-permission-label {
|
||||||
|
@apply font-semibold text-sm;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-call-permission-type {
|
||||||
|
font-family: var(--font-family-mono);
|
||||||
|
font-size: 12px;
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
border: 1px solid var(--border-base);
|
||||||
|
background-color: var(--surface-code);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-call-permission-title code {
|
||||||
|
display: block;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
background-color: var(--surface-code);
|
||||||
|
border: 1px solid var(--border-base);
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-call-permission-actions {
|
||||||
|
@apply flex items-center justify-between gap-3 flex-wrap;
|
||||||
|
margin-top: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-call-permission-buttons {
|
||||||
|
@apply flex flex-wrap gap-2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-call-permission-button {
|
||||||
|
background-color: var(--surface-base);
|
||||||
|
border: 1px solid var(--status-warning);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
padding: 0.4rem 1.05rem;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: var(--font-weight-medium);
|
||||||
|
line-height: 1.15;
|
||||||
|
transition: transform 0.15s ease, color 0.15s ease, border-color 0.15s ease, background-color 0.15s ease;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: 1.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-call-permission-button:hover:not(:disabled) {
|
||||||
|
background-color: var(--surface-hover);
|
||||||
|
border-color: var(--status-warning);
|
||||||
|
color: var(--status-warning);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-call-permission-button:active:not(:disabled) {
|
||||||
|
transform: scale(0.97);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-call-permission-button:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-call-permission-shortcuts {
|
||||||
|
@apply flex items-center gap-2 text-xs text-muted;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-call-permission-shortcuts .kbd {
|
||||||
|
margin-right: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-call-permission-queued-text {
|
||||||
|
@apply text-sm text-muted;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-call-permission-error {
|
||||||
|
@apply text-sm;
|
||||||
|
color: var(--status-error);
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-call-permission-diff {
|
||||||
|
margin-top: 1rem;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-call-permission-diff .tool-call-diff-shell {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.tool-call-diff-viewer .diff-line-old-num,
|
.tool-call-diff-viewer .diff-line-old-num,
|
||||||
.tool-call-diff-viewer .diff-line-new-num,
|
.tool-call-diff-viewer .diff-line-new-num,
|
||||||
.tool-call-diff-viewer .diff-line-num {
|
.tool-call-diff-viewer .diff-line-num {
|
||||||
width: auto !important;
|
color: var(--text-muted);
|
||||||
min-width: 4ch;
|
font-size: var(--font-size-xs);
|
||||||
padding-left: 0.5rem;
|
|
||||||
padding-right: 0.5rem;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.tool-call-markdown .markdown-code-block {
|
||||||
|
margin: 0;
|
||||||
|
border-radius: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
.tool-call-markdown .markdown-code-block {
|
.tool-call-markdown .markdown-code-block {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
border: none;
|
border: none;
|
||||||
|
|||||||
@@ -194,6 +194,11 @@
|
|||||||
--session-status-dot: var(--session-status-idle-fg);
|
--session-status-dot: var(--session-status-idle-fg);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.status-indicator.session-status.session-permission {
|
||||||
|
color: var(--session-status-permission-fg);
|
||||||
|
--session-status-dot: var(--session-status-permission-fg);
|
||||||
|
}
|
||||||
|
|
||||||
.status-indicator.session-status .status-dot {
|
.status-indicator.session-status .status-dot {
|
||||||
background-color: var(--session-status-dot);
|
background-color: var(--session-status-dot);
|
||||||
}
|
}
|
||||||
@@ -215,6 +220,10 @@
|
|||||||
background-color: var(--session-status-idle-bg);
|
background-color: var(--session-status-idle-bg);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.status-indicator.session-status.session-permission.session-status-list {
|
||||||
|
background-color: var(--session-status-permission-bg);
|
||||||
|
}
|
||||||
|
|
||||||
.status-indicator.session-status-list {
|
.status-indicator.session-status-list {
|
||||||
font-size: 0.65rem;
|
font-size: 0.65rem;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
|
|||||||
@@ -246,6 +246,11 @@ session-sidebar-controls .selector-trigger-primary {
|
|||||||
--session-status-dot: var(--session-status-idle-fg);
|
--session-status-dot: var(--session-status-idle-fg);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.status-indicator.session-status.session-permission {
|
||||||
|
color: var(--session-status-permission-fg);
|
||||||
|
--session-status-dot: var(--session-status-permission-fg);
|
||||||
|
}
|
||||||
|
|
||||||
.status-indicator.session-status .status-dot {
|
.status-indicator.session-status .status-dot {
|
||||||
background-color: var(--session-status-dot);
|
background-color: var(--session-status-dot);
|
||||||
}
|
}
|
||||||
@@ -267,6 +272,10 @@ session-sidebar-controls .selector-trigger-primary {
|
|||||||
background-color: var(--session-status-idle-bg);
|
background-color: var(--session-status-idle-bg);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.status-indicator.session-status.session-permission.session-status-list {
|
||||||
|
background-color: var(--session-status-permission-bg);
|
||||||
|
}
|
||||||
|
|
||||||
.status-indicator.session-status-list {
|
.status-indicator.session-status-list {
|
||||||
font-size: 0.65rem;
|
font-size: 0.65rem;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
|
|||||||
@@ -42,6 +42,8 @@
|
|||||||
--session-status-compacting-bg: rgba(109, 40, 217, 0.18);
|
--session-status-compacting-bg: rgba(109, 40, 217, 0.18);
|
||||||
--session-status-idle-fg: #15803d;
|
--session-status-idle-fg: #15803d;
|
||||||
--session-status-idle-bg: rgba(22, 163, 74, 0.16);
|
--session-status-idle-bg: rgba(22, 163, 74, 0.16);
|
||||||
|
--session-status-permission-fg: #c2410c;
|
||||||
|
--session-status-permission-bg: rgba(251, 191, 36, 0.25);
|
||||||
--list-item-highlight-bg: rgba(0, 102, 255, 0.1);
|
--list-item-highlight-bg: rgba(0, 102, 255, 0.1);
|
||||||
--list-item-highlight-border: rgba(0, 102, 255, 0.25);
|
--list-item-highlight-border: rgba(0, 102, 255, 0.25);
|
||||||
--attachment-chip-bg: rgba(0, 102, 255, 0.1);
|
--attachment-chip-bg: rgba(0, 102, 255, 0.1);
|
||||||
@@ -188,6 +190,8 @@
|
|||||||
--session-status-compacting-bg: rgba(192, 132, 252, 0.28);
|
--session-status-compacting-bg: rgba(192, 132, 252, 0.28);
|
||||||
--session-status-idle-fg: #4ade80;
|
--session-status-idle-fg: #4ade80;
|
||||||
--session-status-idle-bg: rgba(74, 222, 128, 0.22);
|
--session-status-idle-bg: rgba(74, 222, 128, 0.22);
|
||||||
|
--session-status-permission-fg: #fbbf24;
|
||||||
|
--session-status-permission-bg: rgba(251, 191, 36, 0.35);
|
||||||
--list-item-highlight-bg: rgba(0, 128, 255, 0.2);
|
--list-item-highlight-bg: rgba(0, 128, 255, 0.2);
|
||||||
--list-item-highlight-border: rgba(0, 128, 255, 0.4);
|
--list-item-highlight-border: rgba(0, 128, 255, 0.4);
|
||||||
--attachment-chip-bg: rgba(0, 128, 255, 0.1);
|
--attachment-chip-bg: rgba(0, 128, 255, 0.1);
|
||||||
@@ -338,6 +342,8 @@
|
|||||||
--session-status-compacting-bg: rgba(192, 132, 252, 0.28);
|
--session-status-compacting-bg: rgba(192, 132, 252, 0.28);
|
||||||
--session-status-idle-fg: #4ade80;
|
--session-status-idle-fg: #4ade80;
|
||||||
--session-status-idle-bg: rgba(74, 222, 128, 0.22);
|
--session-status-idle-bg: rgba(74, 222, 128, 0.22);
|
||||||
|
--session-status-permission-fg: #fbbf24;
|
||||||
|
--session-status-permission-bg: rgba(251, 191, 36, 0.35);
|
||||||
--list-item-highlight-bg: rgba(0, 128, 255, 0.2);
|
--list-item-highlight-bg: rgba(0, 128, 255, 0.2);
|
||||||
--list-item-highlight-border: rgba(0, 128, 255, 0.4);
|
--list-item-highlight-border: rgba(0, 128, 255, 0.4);
|
||||||
--attachment-chip-bg: rgba(0, 128, 255, 0.1);
|
--attachment-chip-bg: rgba(0, 128, 255, 0.1);
|
||||||
|
|||||||
@@ -5,7 +5,8 @@ import type {
|
|||||||
EventMessagePartUpdated as MessagePartUpdatedEvent,
|
EventMessagePartUpdated as MessagePartUpdatedEvent,
|
||||||
EventMessagePartRemoved as MessagePartRemovedEvent,
|
EventMessagePartRemoved as MessagePartRemovedEvent,
|
||||||
Part as SDKPart,
|
Part as SDKPart,
|
||||||
Message as SDKMessage
|
Message as SDKMessage,
|
||||||
|
Permission,
|
||||||
} from "@opencode-ai/sdk"
|
} from "@opencode-ai/sdk"
|
||||||
|
|
||||||
// Re-export for other modules
|
// Re-export for other modules
|
||||||
@@ -25,6 +26,11 @@ export interface RenderCache {
|
|||||||
mode?: string
|
mode?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface PendingPermissionState {
|
||||||
|
permission: Permission
|
||||||
|
active: boolean
|
||||||
|
}
|
||||||
|
|
||||||
// Client-specific part extensions (using intersection type since SDKPart is a union)
|
// Client-specific part extensions (using intersection type since SDKPart is a union)
|
||||||
export type ClientPart = SDKPart & {
|
export type ClientPart = SDKPart & {
|
||||||
sessionID?: string
|
sessionID?: string
|
||||||
@@ -32,6 +38,7 @@ export type ClientPart = SDKPart & {
|
|||||||
synthetic?: boolean
|
synthetic?: boolean
|
||||||
version?: number
|
version?: number
|
||||||
renderCache?: RenderCache
|
renderCache?: RenderCache
|
||||||
|
pendingPermission?: PendingPermissionState
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MessageDisplayParts {
|
export interface MessageDisplayParts {
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ export interface Session extends Omit<import("@opencode-ai/sdk").Session, 'proje
|
|||||||
messages: Message[] // Client-specific field
|
messages: Message[] // Client-specific field
|
||||||
messagesInfo: Map<string, MessageInfo> // Client-specific field
|
messagesInfo: Map<string, MessageInfo> // Client-specific field
|
||||||
version: string // Include version from SDK Session
|
version: string // Include version from SDK Session
|
||||||
|
pendingPermission?: boolean // Indicates if session is waiting on user permission
|
||||||
}
|
}
|
||||||
|
|
||||||
// Adapter function to convert SDK Session to client Session
|
// Adapter function to convert SDK Session to client Session
|
||||||
|
|||||||
Reference in New Issue
Block a user