diff --git a/packages/ui/src/components/permission-approval-modal.tsx b/packages/ui/src/components/permission-approval-modal.tsx index 24e701dd..d514cfca 100644 --- a/packages/ui/src/components/permission-approval-modal.tsx +++ b/packages/ui/src/components/permission-approval-modal.tsx @@ -1,10 +1,12 @@ import { Show, createSignal, createMemo, createEffect, onCleanup, type Component } from "solid-js" import type { PermissionRequestLike } from "../types/permission" -import { getPermissionSessionId, getPermissionKind, getPermissionDisplayTitle } from "../types/permission" -import { getPermissionQueue, activePermissionId, sendPermissionResponse } from "../stores/instances" +import { getPermissionSessionId, getPermissionKind, getPermissionDisplayTitle, getPermissionMessageId, getPermissionCallId } from "../types/permission" +import { getPermissionQueue, activePermissionId, sendPermissionResponse, setActivePermissionIdForInstance } from "../stores/instances" +import { setActiveSession } from "../stores/session-state" +import { messageStoreBus } from "../stores/message-v2/bus" import { ToolCallDiffViewer } from "./diff-viewer" import { useTheme } from "../lib/theme" -import { getRelativePath } from "./tool-call/utils" +import { getRelativePath, getToolIcon, getToolName } from "./tool-call/utils" import { getLogger } from "../lib/logger" const log = getLogger("session") @@ -22,7 +24,7 @@ const PermissionApprovalModal: Component = (props) const queue = createMemo(() => getPermissionQueue(props.instanceId)) const activePermId = createMemo(() => activePermissionId().get(props.instanceId) ?? null) - + const activePermission = createMemo((): PermissionRequestLike | null => { const id = activePermId() if (!id) return null @@ -31,6 +33,92 @@ const PermissionApprovalModal: Component = (props) const hasActivePermission = createMemo(() => activePermission() !== null) + // Current position in queue + const currentIndex = createMemo(() => { + const perm = activePermission() + if (!perm) return -1 + return queue().findIndex((p) => p.id === perm.id) + }) + + const hasPrev = createMemo(() => currentIndex() > 0) + const hasNext = createMemo(() => currentIndex() < queue().length - 1) + + // Extract tool details - try to get actual tool name from message store first + const toolInfo = createMemo(() => { + const permission = activePermission() + if (!permission) return null + + const metadata = ((permission as any).metadata || {}) as Record + let toolName = "unknown" + + // BEST METHOD: Try to get the actual tool from the linked message part + // This is how the inline chat gets it (via toolPart.tool) + const messageId = getPermissionMessageId(permission) + const callId = getPermissionCallId(permission) + + if (messageId) { + const store = messageStoreBus.getInstance(props.instanceId) + if (store) { + const record = store.getMessage(messageId) + if (record) { + // Search through parts for the tool call matching this permission + for (const partId of record.partIds) { + const partRecord = record.parts[partId] + if (!partRecord?.data || partRecord.data.type !== "tool") continue + + const part = partRecord.data as any + // Match by callId if available + const partCallId = part.callID ?? part.callId ?? part.toolCallID ?? part.toolCallId + if (callId && partCallId === callId && part.tool) { + toolName = part.tool + break + } + // If no callId match, just use the first tool part's name + if (!callId && part.tool) { + toolName = part.tool + break + } + } + } + } + } + + // Fallback: Check metadata fields + if (toolName === "unknown") { + const metaToolName = (metadata.toolName as string) || (metadata.tool as string) || (metadata.action as string) + if (metaToolName) { + toolName = metaToolName.replace(/^opencode_/, "").toLowerCase() + } + } + + // Fallback: Check permission kind for embedded action words + if (toolName === "unknown") { + const kind = getPermissionKind(permission).toLowerCase() + if (kind.includes("read")) toolName = "read" + else if (kind.includes("write")) toolName = "write" + else if (kind.includes("edit")) toolName = "edit" + else if (kind.includes("shell") || kind.includes("bash") || kind.includes("command")) toolName = "bash" + else if (kind.includes("patch")) toolName = "patch" + } + + const command = metadata.command as string | undefined + const filePath = (metadata.filePath as string) || (metadata.path as string) || undefined + const input = metadata.input as Record | undefined + + return { + toolName, + icon: getToolIcon(toolName), + displayName: getToolName(toolName), + command, + filePath, + input + } + }) + + // Check if we can navigate to session + const sessionId = createMemo(() => getPermissionSessionId(activePermission())) + const canGoToSession = createMemo(() => !!sessionId()) + createEffect(() => { const permission = activePermission() if (!permission) { @@ -45,7 +133,7 @@ const PermissionApprovalModal: Component = (props) const handler = (event: KeyboardEvent) => { if (submitting()) return - + if (event.key === "Enter") { event.preventDefault() handleResponse("once") @@ -58,6 +146,12 @@ const PermissionApprovalModal: Component = (props) } else if (event.key === "Escape") { event.preventDefault() props.onClose() + } else if (event.key === "ArrowLeft" && hasPrev()) { + event.preventDefault() + navigatePrev() + } else if (event.key === "ArrowRight" && hasNext()) { + event.preventDefault() + navigateNext() } } @@ -65,6 +159,34 @@ const PermissionApprovalModal: Component = (props) onCleanup(() => document.removeEventListener("keydown", handler)) }) + function navigatePrev() { + const idx = currentIndex() + if (idx > 0) { + const prevPerm = queue()[idx - 1] + if (prevPerm) { + setActivePermissionIdForInstance(props.instanceId, prevPerm.id) + } + } + } + + function navigateNext() { + const idx = currentIndex() + if (idx < queue().length - 1) { + const nextPerm = queue()[idx + 1] + if (nextPerm) { + setActivePermissionIdForInstance(props.instanceId, nextPerm.id) + } + } + } + + function handleGoToSession() { + const sid = sessionId() + if (sid) { + setActiveSession(props.instanceId, sid) + props.onClose() + } + } + async function handleResponse(response: "once" | "always" | "reject") { const permission = activePermission() if (!permission) return @@ -73,9 +195,9 @@ const PermissionApprovalModal: Component = (props) setError(null) try { - const sessionId = getPermissionSessionId(permission) || "" - await sendPermissionResponse(props.instanceId, sessionId, permission.id, response) - + const sid = getPermissionSessionId(permission) || "" + await sendPermissionResponse(props.instanceId, sid, permission.id, response) + // Wait a moment for queue to update before closing setTimeout(() => { const remaining = getPermissionQueue(props.instanceId) @@ -105,10 +227,10 @@ const PermissionApprovalModal: Component = (props) const diffValue = typeof metadata.diff === "string" ? metadata.diff : null if (!diffValue || diffValue.trim().length === 0) return null - const diffPath = + const diffPath = typeof metadata.filePath === "string" ? metadata.filePath : - typeof metadata.path === "string" ? metadata.path : - undefined + typeof metadata.path === "string" ? metadata.path : + undefined return { diffText: diffValue, filePath: diffPath } }) @@ -122,25 +244,74 @@ const PermissionApprovalModal: Component = (props)

No pending permissions

}> + {/* Header */}
-

- Permission Required -

- 1}> - - {queue().indexOf(activePermission()!) + 1} of {queue().length} - - +
+

+ Permission Required +

+ 1}> + + {currentIndex() + 1} of {queue().length} + + +
+
+ + + + +
+ {/* Body - scrollable */}
+ {/* Permission type badge */}
{getPermissionKind(activePermission())}
+ + {/* Tool details section */} + + {(info) => ( +
+
+ 🔧 + Tool Call + {info().toolName} + + {getRelativePath(info().filePath!)} + +
+ +
+ {info().command} +
+
+
+ )} +
+ + {/* Permission message */}
{getPermissionDisplayTitle(activePermission())}
+ {/* Diff viewer */} {(payload) => (
@@ -153,7 +324,7 @@ const PermissionApprovalModal: Component = (props) filePath={payload().filePath} theme={isDark() ? "dark" : "light"} mode="split" - onRendered={() => {}} + onRendered={() => { }} />
@@ -167,7 +338,33 @@ const PermissionApprovalModal: Component = (props) + {/* Footer - sticky */} diff --git a/packages/ui/src/stores/instances.ts b/packages/ui/src/stores/instances.ts index 282b945b..adaa4a8e 100644 --- a/packages/ui/src/stores/instances.ts +++ b/packages/ui/src/stores/instances.ts @@ -562,6 +562,14 @@ function clearPermissionQueue(instanceId: string): void { +function setActivePermissionIdForInstance(instanceId: string, permissionId: string): void { + setActivePermissionId((prev) => { + const next = new Map(prev) + next.set(instanceId, permissionId) + return next + }) +} + async function sendPermissionResponse( instanceId: string, sessionId: string, @@ -656,6 +664,7 @@ export { removePermissionFromQueue, clearPermissionQueue, sendPermissionResponse, + setActivePermissionIdForInstance, disconnectedInstance, acknowledgeDisconnectedInstance, fetchLspStatus, diff --git a/packages/ui/src/styles/components/permission-notification.css b/packages/ui/src/styles/components/permission-notification.css index 912c3398..44da6a5b 100644 --- a/packages/ui/src/styles/components/permission-notification.css +++ b/packages/ui/src/styles/components/permission-notification.css @@ -55,9 +55,12 @@ } @keyframes permission-pulse { - 0%, 100% { + + 0%, + 100% { box-shadow: 0 2px 4px color-mix(in srgb, var(--status-warning) 30%, transparent); } + 50% { box-shadow: 0 2px 8px color-mix(in srgb, var(--status-warning) 60%, transparent), 0 0 16px color-mix(in srgb, var(--status-warning) 30%, transparent); } @@ -113,9 +116,12 @@ } @keyframes web-permission-pulse { - 0%, 100% { + + 0%, + 100% { box-shadow: 0 0 0 0 color-mix(in srgb, var(--status-warning) 40%, transparent); } + 50% { box-shadow: 0 0 0 4px color-mix(in srgb, var(--status-warning) 20%, transparent); } @@ -151,6 +157,7 @@ from { opacity: 0; } + to { opacity: 1; } @@ -174,6 +181,7 @@ opacity: 0; transform: translateY(-20px) scale(0.95); } + to { opacity: 1; transform: translateY(0) scale(1); @@ -390,6 +398,302 @@ box-shadow: inset 0 -2px 0 color-mix(in srgb, #000 10%, transparent); } -/* Dark theme adjustments - already handled by CSS tokens above */ -/* The token system automatically applies correct colors for dark mode via - :root @media (prefers-color-scheme: dark) definitions */ +/* Enhanced Header Layout */ +.permission-modal-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: var(--space-lg); + border-bottom: 1px solid var(--border-base); + gap: var(--space-md); + flex-wrap: wrap; +} + +.permission-modal-header-left { + display: flex; + align-items: center; + gap: var(--space-md); + flex: 1; + min-width: 0; +} + +.permission-modal-header-actions { + display: flex; + align-items: center; + gap: var(--space-sm); + flex-shrink: 0; +} + +.permission-modal-go-to-session { + display: inline-flex; + align-items: center; + gap: var(--space-xs); + padding: var(--space-xs) var(--space-sm); + background: var(--surface-secondary); + color: var(--text-secondary); + border: 1px solid var(--border-base); + border-radius: var(--radius-sm); + font-size: var(--font-size-xs); + font-weight: var(--font-weight-medium); + cursor: pointer; + transition: all 0.15s ease; + white-space: nowrap; +} + +.permission-modal-go-to-session:hover { + background: var(--surface-base); + color: var(--text-primary); + border-color: var(--accent-primary); +} + +.permission-modal-close { + display: inline-flex; + align-items: center; + justify-content: center; + width: 1.75rem; + height: 1.75rem; + padding: 0; + background: transparent; + color: var(--text-secondary); + border: none; + border-radius: var(--radius-sm); + font-size: var(--font-size-md); + cursor: pointer; + transition: all 0.15s ease; +} + +.permission-modal-close:hover { + background: var(--surface-secondary); + color: var(--text-primary); +} + +/* Tool Details Section */ +.permission-modal-tool-details { + display: flex; + flex-direction: column; + gap: var(--space-sm); + padding: var(--space-md); + background: var(--surface-secondary); + border: 1px solid var(--border-base); + border-radius: var(--radius-md); +} + +.permission-modal-tool-header { + display: flex; + align-items: center; + gap: var(--space-sm); + flex-wrap: wrap; +} + +.permission-modal-tool-icon { + font-size: 1.25rem; + line-height: 1; +} + +.permission-modal-tool-name { + font-size: var(--font-size-md); + font-weight: var(--font-weight-semibold); + color: var(--text-primary); +} + +.permission-modal-tool-path { + font-family: var(--font-family-mono); + font-size: var(--font-size-xs); + color: var(--text-secondary); + background: var(--surface-base); + padding: var(--space-xs) var(--space-sm); + border-radius: var(--radius-xs); +} + +.permission-modal-tool-command { + padding: var(--space-sm); + background: var(--surface-base); + border: 1px solid var(--border-base); + border-radius: var(--radius-sm); + overflow-x: auto; +} + +.permission-modal-tool-command code { + font-family: var(--font-family-mono); + font-size: var(--font-size-sm); + color: var(--text-primary); + white-space: pre-wrap; + word-break: break-all; +} + +/* Queue Navigation */ +.permission-modal-nav { + display: flex; + justify-content: center; + gap: var(--space-md); + padding-bottom: var(--space-md); +} + +.permission-modal-nav-button { + padding: var(--space-xs) var(--space-md); + background: var(--surface-secondary); + color: var(--text-secondary); + border: 1px solid var(--border-base); + border-radius: var(--radius-sm); + font-size: var(--font-size-sm); + font-weight: var(--font-weight-medium); + cursor: pointer; + transition: all 0.15s ease; +} + +.permission-modal-nav-button:hover:not(:disabled) { + background: var(--surface-base); + color: var(--text-primary); + border-color: var(--accent-primary); +} + +.permission-modal-nav-button:disabled { + opacity: 0.4; + cursor: not-allowed; +} + +.permission-modal-tool-badge { + display: inline-flex; + align-items: center; + padding: var(--space-xs) var(--space-sm); + background: var(--surface-base); + border: 1px solid var(--border-base); + border-radius: var(--radius-sm); + font-family: var(--font-family-mono); + font-size: var(--font-size-sm); + font-weight: var(--font-weight-medium); + color: var(--text-primary); +} + +/* ===== RESPONSIVE STYLES ===== */ + +/* Portrait phone - near full screen with safe margins */ +@media (max-width: 480px) and (orientation: portrait) { + .permission-approval-modal { + width: 95vw; + max-height: 90vh; + max-height: calc(90vh - env(safe-area-inset-top) - env(safe-area-inset-bottom)); + border-radius: var(--radius-md); + margin: 5vh auto; + } + + .permission-approval-modal-backdrop { + padding: var(--space-md); + } + + .permission-modal-body { + flex: 1; + overflow-y: auto; + -webkit-overflow-scrolling: touch; + max-height: 50vh; + } + + .permission-modal-buttons { + flex-direction: column; + gap: var(--space-sm); + } + + .permission-modal-button { + min-height: 48px; + font-size: var(--font-size-md); + } + + .permission-modal-shortcuts { + display: none; + } + + .permission-modal-footer { + flex-shrink: 0; + position: sticky; + bottom: 0; + background: var(--surface-secondary); + border-top: 1px solid var(--border-base); + } + + .permission-modal-go-to-session { + font-size: var(--font-size-xs); + padding: var(--space-sm) var(--space-md); + } + + .permission-modal-diff-viewer { + max-height: 40vh; + } +} + +/* Landscape phone */ +@media (max-width: 812px) and (orientation: landscape) { + .permission-approval-modal { + width: 95vw; + max-height: 95vh; + margin: auto; + } + + .permission-modal-body { + overflow-y: auto; + max-height: 50vh; + -webkit-overflow-scrolling: touch; + } + + .permission-modal-buttons { + flex-direction: row; + flex-wrap: nowrap; + } + + .permission-modal-button { + min-height: 44px; + padding: var(--space-sm) var(--space-md); + font-size: var(--font-size-sm); + } + + .permission-modal-shortcuts { + display: none; + } + + .permission-modal-diff-viewer { + max-height: 30vh; + } + + .permission-modal-footer { + flex-shrink: 0; + } +} + +/* Small tablets portrait */ +@media (min-width: 481px) and (max-width: 768px) and (orientation: portrait) { + .permission-approval-modal { + width: 95vw; + max-height: 90vh; + } + + .permission-modal-body { + overflow-y: auto; + max-height: 60vh; + } + + .permission-modal-button { + min-height: 44px; + } +} + +/* Touch device optimizations */ +@media (hover: none) and (pointer: coarse) { + .permission-modal-button { + min-height: 44px; + } + + .permission-modal-nav-button { + min-height: 40px; + padding: var(--space-sm) var(--space-lg); + } + + .permission-modal-go-to-session { + min-height: 36px; + padding: var(--space-sm) var(--space-md); + } + + .permission-modal-close { + width: 2.5rem; + height: 2.5rem; + font-size: var(--font-size-lg); + } +} \ No newline at end of file