diff --git a/src/components/message-item.tsx b/src/components/message-item.tsx index cc6e1fc0..79f83f05 100644 --- a/src/components/message-item.tsx +++ b/src/components/message-item.tsx @@ -6,6 +6,8 @@ import MessagePart from "./message-part" interface MessageItemProps { message: Message messageInfo?: MessageInfo + instanceId: string + sessionId: string isQueued?: boolean parts?: ClientPart[] onRevert?: (messageId: string) => void @@ -139,7 +141,14 @@ export default function MessageItem(props: MessageItemProps) { - {(part) => } + {(part) => ( + + )} diff --git a/src/components/message-part.tsx b/src/components/message-part.tsx index d9e63f7e..22354617 100644 --- a/src/components/message-part.tsx +++ b/src/components/message-part.tsx @@ -11,6 +11,8 @@ type ToolCallPart = Extract interface MessagePartProps { part: ClientPart messageType?: "user" | "assistant" + instanceId: string + sessionId: string } export default function MessagePart(props: MessagePartProps) { const { isDark } = useTheme() @@ -71,7 +73,12 @@ export default function MessagePart(props: MessagePartProps) { - + diff --git a/src/components/message-stream.tsx b/src/components/message-stream.tsx index 3da7b987..07394c7c 100644 --- a/src/components/message-stream.tsx +++ b/src/components/message-stream.tsx @@ -615,6 +615,8 @@ export default function MessageStream(props: MessageStreamProps) { ) diff --git a/src/components/session-list.tsx b/src/components/session-list.tsx index 0c72b9e1..d1513fed 100644 --- a/src/components/session-list.tsx +++ b/src/components/session-list.tsx @@ -208,9 +208,13 @@ const SessionList: Component = (props) => { const title = () => session()?.title || "Untitled" const status = () => getSessionStatus(props.instanceId, rowProps.sessionId) const statusLabel = () => formatSessionStatus(status()) + const pendingPermission = () => Boolean(session()?.pendingPermission) + const statusClassName = () => (pendingPermission() ? "session-permission" : `session-${status()}`) + const statusText = () => (pendingPermission() ? "Needs Permission" : statusLabel()) return ( -
+
+
- + - {statusLabel()} + {statusText()}
= (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 ( = (props) => { onFork={handleFork} /> - - {(permission) => ( -
-
-
-
- - - -
-
-
-
- Permission Required - {permission().type} -
-
- {permission().title} -
-
- Enter - Accept once - a - Accept always - d - Deny -
-
-
-
- )} -
- @@ -81,6 +82,8 @@ interface ToolCallProps { messageId?: string messageVersion?: number partVersion?: number + instanceId: string + sessionId: string } function getToolIcon(tool: string): string { @@ -183,11 +186,22 @@ export default function ToolCall(props: ToolCallProps) { const toolCallId = () => props.toolCallId || props.toolCall?.id || "" const expanded = () => isToolCallExpanded(toolCallId()) const [initializedId, setInitializedId] = createSignal(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(null) + + let scrollContainerRef: HTMLDivElement | undefined + let toolCallRootRef: HTMLDivElement | undefined + + const handleScrollRendered = () => { - let scrollContainerRef: HTMLDivElement | undefined - - const handleScrollRendered = () => { const id = toolCallId() if (!id || !scrollContainerRef) return restoreScrollState(id, scrollContainerRef) @@ -221,6 +235,23 @@ export default function ToolCall(props: ToolCallProps) { 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 createEffect(() => { 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 status = props.toolCall?.state?.status || "" switch (status) { @@ -266,6 +325,11 @@ export default function ToolCall(props: ToolCallProps) { return `tool-call-status-${status}` } + const combinedStatusClass = () => { + const base = statusClass() + return pendingPermission() ? `${base} tool-call-awaiting-permission` : base + } + function toggle() { 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 state = props.toolCall?.state || {} if (state.status !== "completed") return "Plan" @@ -424,10 +506,11 @@ export default function ToolCall(props: ToolCallProps) { 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 toolbarLabel = relativePath ? `Diff · ${relativePath}` : "Diff" - const cacheKey = makeRenderCacheKey(toolCallId(), props.messageId, props.messageVersion, props.partVersion) + const toolbarLabel = options?.label || (relativePath ? `Diff · ${relativePath}` : "Diff") + 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 themeKey = isDark() ? "dark" : "light" @@ -453,15 +536,21 @@ export default function ToolCall(props: ToolCallProps) { // Cache will be updated by the diff viewer component itself // We'll capture HTML from the rendered component } - handleScrollRendered() + if (!options?.disableScrollTracking) { + handleScrollRendered() + } } return (
initializeScrollContainer(element)} - onScroll={(event) => updateScrollState(toolCallId(), event.currentTarget)} + ref={(element) => { + if (options?.disableScrollTracking) return + initializeScrollContainer(element) + }} + onScroll={options?.disableScrollTracking ? undefined : (event) => updateScrollState(toolCallId(), event.currentTarget)} > +
{toolbarLabel}
@@ -802,11 +891,103 @@ export default function ToolCall(props: ToolCallProps) { return null } + const renderPermissionBlock = () => { + const permission = permissionDetails() + if (!permission) return null + const active = isPermissionActive() + const metadata = (permission.metadata ?? {}) as Record + 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 ( +
+
+ {active ? "Permission Required" : "Permission Queued"} + {permission.type} +
+
+
+ {permission.title} +
+ + {(payload) => ( +
+ {renderDiffTool(payload(), { + cacheKeySuffix: "::permission", + disableScrollTracking: true, + label: payload().filePath ? `Requested diff · ${getRelativePath(payload().filePath || "")}` : "Requested diff", + })} +
+ )} +
+ Waiting for earlier permission responses.

} + > +
+
+ + + +
+
+ Enter + Allow once + A + Always allow + D + Deny +
+
+ +
{permissionError()}
+
+
+
+
+ ) + } + const toolName = () => props.toolCall?.tool || "" const status = () => props.toolCall?.state?.status || "" return ( -
+
{ + toolCallRootRef = element || undefined + }} + class={`tool-call ${combinedStatusClass()}`} + >