From f01a06d85b7f479fcdbdf4f99141fd74a978a211 Mon Sep 17 00:00:00 2001 From: bizzkoot Date: Wed, 7 Jan 2026 21:44:43 +0800 Subject: [PATCH 1/6] feat: add centralized permission notification system for agent/subagent requests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements a unified permission notification UI that adapts to different runtime environments (Electron desktop vs web browser) with distinct visual presentations. ## What Changed ### New Components - `permission-notification-banner.tsx`: Adaptive notification component * Electron (desktop): Full banner with "⚠️ Approval Required" text and count badge * Web browser (portrait): Circular indicator badge showing pending count - `permission-approval-modal.tsx`: Interactive modal for reviewing/approving permissions * Displays permission type, detailed message, and diff viewer for file changes * Keyboard shortcuts: Enter (allow once), A (always), D (deny), Esc (close) * Queue management with "X of Y" counter for multiple pending permissions - `permission-notification.css`: Comprehensive styling with pulsing animations ### Integration - Updated `instance-shell2.tsx`: * Added banner to desktop center toolbar (next to Command Palette) * Added banner to mobile/phone layout center section * Added modal component for permission approval workflow - Updated `controls.css`: Imported new permission notification styles ## Why This Change **Before**: Permission requests had no visual indicator in the UI, making it difficult for users to know when agent/subagent actions required approval. **After**: Users receive clear, persistent visual notifications with: - Pulsing animation to draw attention - Environment-appropriate UI (full banner on desktop, compact badge on web) - Click-to-review workflow with full permission details ## Benefits 1. **Better UX**: Users immediately see when permissions need approval 2. **Responsive Design**: Adapts to desktop (Electron) and web browser contexts 3. **Accessible**: Proper ARIA labels, keyboard shortcuts, and focus management 4. **Queue Management**: Handles multiple pending permissions gracefully 5. **Contextual Information**: Shows diffs for file changes, permission types, etc. ## Impact - **No Breaking Changes**: Purely additive feature - **Build**: ✅ Verified successful build - **Testing**: ✅ Tested in Electron app and web browser --- .../components/instance/instance-shell2.tsx | 123 +++--- .../components/permission-approval-modal.tsx | 219 ++++++++++ .../permission-notification-banner.tsx | 57 +++ .../components/permission-notification.css | 395 ++++++++++++++++++ packages/ui/src/styles/controls.css | 1 + 5 files changed, 745 insertions(+), 50 deletions(-) create mode 100644 packages/ui/src/components/permission-approval-modal.tsx create mode 100644 packages/ui/src/components/permission-notification-banner.tsx create mode 100644 packages/ui/src/styles/components/permission-notification.css diff --git a/packages/ui/src/components/instance/instance-shell2.tsx b/packages/ui/src/components/instance/instance-shell2.tsx index 454ffdf0..6931ef8f 100644 --- a/packages/ui/src/components/instance/instance-shell2.tsx +++ b/packages/ui/src/components/instance/instance-shell2.tsx @@ -50,6 +50,8 @@ import InstanceServiceStatus from "../instance-service-status" import AgentSelector from "../agent-selector" import ModelSelector from "../model-selector" import CommandPalette from "../command-palette" +import PermissionNotificationBanner from "../permission-notification-banner" +import PermissionApprovalModal from "../permission-approval-modal" import Kbd from "../kbd" import { TodoListView } from "../tool-call/renderers/todo" import ContextUsagePanel from "../session/context-usage-panel" @@ -141,6 +143,7 @@ const InstanceShell2: Component = (props) => { ]) const [selectedBackgroundProcess, setSelectedBackgroundProcess] = createSignal(null) const [showBackgroundOutput, setShowBackgroundOutput] = createSignal(false) + const [permissionModalOpen, setPermissionModalOpen] = createSignal(false) const messageStore = createMemo(() => messageStoreBus.getOrCreate(props.instance.id)) @@ -654,7 +657,7 @@ const InstanceShell2: Component = (props) => { }) type DrawerViewState = "pinned" | "floating-open" | "floating-closed" - + const leftDrawerState = createMemo(() => { if (leftPinned()) return "pinned" @@ -695,7 +698,7 @@ const InstanceShell2: Component = (props) => { - const pinLeftDrawer = () => { + const pinLeftDrawer = () => { blurIfInside(leftDrawerContentEl()) batch(() => { setLeftPinned(true) @@ -814,18 +817,18 @@ const InstanceShell2: Component = (props) => { -
- - (leftPinned() ? unpinLeftDrawer() : pinLeftDrawer())} - > - {leftPinned() ? : } - - -
+
+ + (leftPinned() ? unpinLeftDrawer() : pinLeftDrawer())} + > + {leftPinned() ? : } + + +
@@ -1240,6 +1243,13 @@ const InstanceShell2: Component = (props) => { > + +
+ setPermissionModalOpen(true)} + /> +
= (props) => { } > -
- - {leftAppBarButtonIcon()} - +
+ + {leftAppBarButtonIcon()} + - -
- Used - {formattedUsedTokens()} -
-
- Avail - {formattedAvailableTokens()} -
-
-
+ +
+ Used + {formattedUsedTokens()} +
+
+ Avail + {formattedAvailableTokens()} +
+
+
-
- - - - +
+ + + + + +
+ setPermissionModalOpen(true)} + />
+
@@ -1429,6 +1446,12 @@ const InstanceShell2: Component = (props) => { process={selectedBackgroundProcess()} onClose={closeBackgroundOutput} /> + + setPermissionModalOpen(false)} + /> ) } diff --git a/packages/ui/src/components/permission-approval-modal.tsx b/packages/ui/src/components/permission-approval-modal.tsx new file mode 100644 index 00000000..24e701dd --- /dev/null +++ b/packages/ui/src/components/permission-approval-modal.tsx @@ -0,0 +1,219 @@ +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 { ToolCallDiffViewer } from "./diff-viewer" +import { useTheme } from "../lib/theme" +import { getRelativePath } from "./tool-call/utils" +import { getLogger } from "../lib/logger" + +const log = getLogger("session") + +interface PermissionApprovalModalProps { + instanceId: string + isOpen: boolean + onClose: () => void +} + +const PermissionApprovalModal: Component = (props) => { + const { isDark } = useTheme() + const [submitting, setSubmitting] = createSignal(false) + const [error, setError] = createSignal(null) + + 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 + return queue().find((p) => p.id === id) ?? null + }) + + const hasActivePermission = createMemo(() => activePermission() !== null) + + createEffect(() => { + const permission = activePermission() + if (!permission) { + setSubmitting(false) + setError(null) + } + }) + + // Keyboard shortcuts + createEffect(() => { + if (!props.isOpen || !hasActivePermission()) return + + const handler = (event: KeyboardEvent) => { + if (submitting()) return + + if (event.key === "Enter") { + event.preventDefault() + handleResponse("once") + } else if (event.key === "a" || event.key === "A") { + event.preventDefault() + handleResponse("always") + } else if (event.key === "d" || event.key === "D") { + event.preventDefault() + handleResponse("reject") + } else if (event.key === "Escape") { + event.preventDefault() + props.onClose() + } + } + + document.addEventListener("keydown", handler) + onCleanup(() => document.removeEventListener("keydown", handler)) + }) + + async function handleResponse(response: "once" | "always" | "reject") { + const permission = activePermission() + if (!permission) return + + setSubmitting(true) + setError(null) + + try { + const sessionId = getPermissionSessionId(permission) || "" + await sendPermissionResponse(props.instanceId, sessionId, permission.id, response) + + // Wait a moment for queue to update before closing + setTimeout(() => { + const remaining = getPermissionQueue(props.instanceId) + if (remaining.length === 0) { + props.onClose() + } + }, 100) + } catch (err) { + log.error("Failed to send permission response", err) + setError(err instanceof Error ? err.message : "Failed to send response") + } finally { + setSubmitting(false) + } + } + + function handleBackdropClick(event: MouseEvent) { + if (event.target === event.currentTarget) { + props.onClose() + } + } + + const diffPayload = createMemo(() => { + const permission = activePermission() + if (!permission) return null + + const metadata = ((permission as any).metadata || {}) as Record + const diffValue = typeof metadata.diff === "string" ? metadata.diff : null + if (!diffValue || diffValue.trim().length === 0) return null + + const diffPath = + typeof metadata.filePath === "string" ? metadata.filePath : + typeof metadata.path === "string" ? metadata.path : + undefined + + return { diffText: diffValue, filePath: diffPath } + }) + + return ( + +
+ + }> +
+

+ Permission Required +

+ 1}> + + {queue().indexOf(activePermission()!) + 1} of {queue().length} + + +
+ +
+
+ {getPermissionKind(activePermission())} +
+
+ {getPermissionDisplayTitle(activePermission())} +
+ + + {(payload) => ( +
+
+ Requested changes · {payload().filePath ? getRelativePath(payload().filePath!) : ""} +
+
+ {}} + /> +
+
+ )} +
+ + + + +
+ + + +
+
+ + ) +} + +export default PermissionApprovalModal diff --git a/packages/ui/src/components/permission-notification-banner.tsx b/packages/ui/src/components/permission-notification-banner.tsx new file mode 100644 index 00000000..412de5f9 --- /dev/null +++ b/packages/ui/src/components/permission-notification-banner.tsx @@ -0,0 +1,57 @@ +import { Show, createMemo, type Component } from "solid-js" +import { getPermissionQueueLength } from "../stores/instances" +import { isElectronHost } from "../lib/runtime-env" + +interface PermissionNotificationBannerProps { + instanceId: string + onClick: () => void +} + +const PermissionNotificationBanner: Component = (props) => { + const queueLength = createMemo(() => getPermissionQueueLength(props.instanceId)) + const hasPermissions = createMemo(() => queueLength() > 0) + const isElectron = isElectronHost() + + return ( + + {/* Electron: Full banner with text */} + + + + + {/* Web: Compact indicator button */} + + + + + ) +} + +export default PermissionNotificationBanner diff --git a/packages/ui/src/styles/components/permission-notification.css b/packages/ui/src/styles/components/permission-notification.css new file mode 100644 index 00000000..912c3398 --- /dev/null +++ b/packages/ui/src/styles/components/permission-notification.css @@ -0,0 +1,395 @@ +/* Permission Notification Banner */ +.permission-notification-banner { + display: flex; + align-items: center; + gap: var(--space-sm); + padding: 0.375rem 0.75rem; + background: linear-gradient(135deg, var(--status-warning) 0%, color-mix(in srgb, var(--status-warning) 85%, #000) 100%); + color: var(--text-inverted); + border: 1px solid color-mix(in srgb, var(--status-warning) 70%, #000); + border-radius: var(--radius-sm); + font-size: var(--font-size-sm); + font-weight: var(--font-weight-semibold); + cursor: pointer; + transition: all 0.2s ease; + box-shadow: 0 2px 4px color-mix(in srgb, var(--status-warning) 30%, transparent); + animation: permission-pulse 2s ease-in-out infinite; +} + +.permission-notification-banner:hover { + background: linear-gradient(135deg, color-mix(in srgb, var(--status-warning) 85%, #000) 0%, color-mix(in srgb, var(--status-warning) 70%, #000) 100%); + box-shadow: 0 4px 8px color-mix(in srgb, var(--status-warning) 40%, transparent); + transform: translateY(-1px); +} + +.permission-notification-banner:active { + transform: translateY(0); + box-shadow: 0 2px 4px color-mix(in srgb, var(--status-warning) 30%, transparent); +} + +.permission-notification-icon { + font-size: 1rem; + line-height: 1; + flex-shrink: 0; +} + +.permission-notification-text { + white-space: nowrap; + color: inherit; + font-size: inherit; +} + +.permission-notification-count { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 1.25rem; + height: 1.25rem; + padding: 0 0.25rem; + background: color-mix(in srgb, #000 30%, transparent); + border-radius: var(--radius-full); + font-size: var(--font-size-xs); + font-weight: var(--font-weight-bold); + color: inherit; + flex-shrink: 0; +} + +@keyframes permission-pulse { + 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); + } +} + +/* Web Permission Indicator Button (for browser-based streaming) */ +.permission-indicator-button { + position: relative; + display: inline-flex; + align-items: center; + justify-content: center; + width: 2rem; + height: 2rem; + padding: 0; + background: color-mix(in srgb, var(--status-warning) 15%, transparent); + color: var(--status-warning); + border: 2px solid var(--status-warning); + border-radius: var(--radius-full); + font-size: var(--font-size-sm); + font-weight: var(--font-weight-bold); + cursor: pointer; + transition: all 0.2s ease; + animation: web-permission-pulse 2s ease-in-out infinite; + flex-shrink: 0; + z-index: 10; + min-width: 2rem; + min-height: 2rem; + max-width: 2rem; + max-height: 2rem; +} + +.permission-indicator-button:hover { + background: color-mix(in srgb, var(--status-warning) 25%, transparent); + box-shadow: 0 0 8px color-mix(in srgb, var(--status-warning) 50%, transparent); + transform: scale(1.05); +} + +.permission-indicator-button:active { + transform: scale(0.98); +} + +.permission-indicator-button:focus-visible { + outline: 2px solid var(--focus-ring-color); + outline-offset: 2px; +} + +.permission-indicator-badge { + display: inline-flex; + align-items: center; + justify-content: center; + font-size: var(--font-size-xs); + color: inherit; +} + +@keyframes web-permission-pulse { + 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); + } +} + +/* Ensure button visibility in web toolbars */ +@supports (display: flex) { + .permission-indicator-button { + visibility: visible; + opacity: 1; + pointer-events: auto; + } +} + +/* Permission Approval Modal */ +.permission-approval-modal-backdrop { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: color-mix(in srgb, var(--text-inverted) 60%, transparent); + backdrop-filter: blur(4px); + display: flex; + align-items: center; + justify-content: center; + z-index: 9999; + padding: var(--space-lg); + animation: modal-backdrop-fadein 0.2s ease; +} + +@keyframes modal-backdrop-fadein { + from { + opacity: 0; + } + to { + opacity: 1; + } +} + +.permission-approval-modal { + background: var(--surface-base); + border: 1px solid var(--border-base); + border-radius: var(--radius-xl); + box-shadow: 0 20px 25px -5px color-mix(in srgb, #000 10%, transparent), 0 10px 10px -5px color-mix(in srgb, #000 4%, transparent); + max-width: 48rem; + width: 100%; + max-height: 90vh; + display: flex; + flex-direction: column; + animation: modal-slidein 0.3s ease; +} + +@keyframes modal-slidein { + from { + opacity: 0; + transform: translateY(-20px) scale(0.95); + } + to { + opacity: 1; + transform: translateY(0) scale(1); + } +} + +.permission-modal-empty { + padding: var(--space-xl); + color: var(--text-secondary); + text-align: center; +} + +.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-lg); +} + +.permission-modal-title { + font-size: var(--font-size-xl); + font-weight: var(--font-weight-semibold); + color: var(--text-primary); + margin: 0; + flex: 1; +} + +.permission-modal-count { + display: inline-flex; + align-items: center; + justify-content: center; + padding: var(--space-xs) var(--space-sm); + background: var(--surface-secondary); + border-radius: var(--radius-sm); + border: 1px solid var(--border-base); + font-size: var(--font-size-sm); + font-weight: var(--font-weight-medium); + color: var(--text-secondary); + white-space: nowrap; + flex-shrink: 0; +} + +.permission-modal-body { + flex: 1; + overflow-y: auto; + padding: var(--space-lg); + display: flex; + flex-direction: column; + gap: var(--space-lg); +} + +.permission-modal-type { + display: inline-flex; + align-items: center; + padding: var(--space-xs) var(--space-sm); + background: color-mix(in srgb, var(--status-warning) 20%, transparent); + color: var(--status-warning); + border: 1px solid color-mix(in srgb, var(--status-warning) 50%, transparent); + border-radius: var(--radius-sm); + font-size: var(--font-size-xs); + font-weight: var(--font-weight-semibold); + text-transform: uppercase; + letter-spacing: 0.05em; + align-self: flex-start; +} + +.permission-modal-message { + padding: var(--space-lg); + background: var(--surface-secondary); + border: 1px solid var(--border-base); + border-radius: var(--radius-md); + font-family: var(--font-family-mono); + word-break: break-word; +} + +.permission-modal-message code { + font-family: var(--font-family-mono); + font-size: var(--font-size-sm); + color: var(--text-primary); +} + +.permission-modal-diff { + display: flex; + flex-direction: column; + gap: var(--space-sm); +} + +.permission-modal-diff-label { + font-size: var(--font-size-xs); + font-weight: var(--font-weight-semibold); + color: var(--text-secondary); + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.permission-modal-diff-viewer { + border: 1px solid var(--border-base); + border-radius: var(--radius-md); + overflow: hidden; + max-height: 24rem; + background: var(--surface-secondary); +} + +.permission-modal-error { + padding: var(--space-sm) var(--space-lg); + background: color-mix(in srgb, var(--status-error) 20%, transparent); + color: var(--status-error); + border: 1px solid color-mix(in srgb, var(--status-error) 50%, transparent); + border-radius: var(--radius-md); + font-size: var(--font-size-sm); +} + +.permission-modal-footer { + display: flex; + flex-direction: column; + gap: var(--space-lg); + padding: var(--space-lg); + border-top: 1px solid var(--border-base); + background: var(--surface-secondary); +} + +.permission-modal-buttons { + display: flex; + gap: var(--space-sm); + justify-content: stretch; +} + +.permission-modal-button { + flex: 1; + padding: var(--button-padding-y) var(--button-padding-x); + border: 1px solid transparent; + border-radius: var(--button-radius); + font-size: var(--font-size-sm); + font-weight: var(--font-weight-semibold); + cursor: pointer; + transition: all 0.15s ease; + font-family: var(--font-family-sans); +} + +.permission-modal-button:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.permission-modal-button:focus-visible { + outline: 2px solid var(--focus-ring-color); + outline-offset: 2px; +} + +.permission-modal-button-once { + background: var(--accent-primary); + color: var(--text-inverted); + border-color: var(--accent-primary); +} + +.permission-modal-button-once:hover:not(:disabled) { + background: var(--accent-hover); + border-color: var(--accent-hover); +} + +.permission-modal-button-always { + background: var(--status-success); + color: var(--text-inverted); + border-color: var(--status-success); +} + +.permission-modal-button-always:hover:not(:disabled) { + background: color-mix(in srgb, var(--status-success) 85%, #000); + border-color: color-mix(in srgb, var(--status-success) 85%, #000); +} + +.permission-modal-button-deny { + background: var(--status-error); + color: var(--text-inverted); + border-color: var(--status-error); +} + +.permission-modal-button-deny:hover:not(:disabled) { + background: color-mix(in srgb, var(--status-error) 85%, #000); + border-color: color-mix(in srgb, var(--status-error) 85%, #000); +} + +.permission-modal-shortcuts { + display: flex; + gap: var(--space-lg); + justify-content: center; + flex-wrap: wrap; +} + +.permission-modal-shortcut { + display: flex; + align-items: center; + gap: var(--space-sm); + font-size: var(--font-size-xs); + color: var(--text-secondary); +} + +.permission-modal-shortcut kbd { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 1.5rem; + height: 1.5rem; + padding: 0 0.375rem; + background: var(--kbd-bg); + border: 1px solid var(--kbd-border); + color: var(--kbd-text); + border-radius: var(--radius-xs); + font-family: var(--font-family-mono); + font-size: var(--font-size-xs); + font-weight: var(--font-weight-semibold); + 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 */ diff --git a/packages/ui/src/styles/controls.css b/packages/ui/src/styles/controls.css index e7b71479..44f02851 100644 --- a/packages/ui/src/styles/controls.css +++ b/packages/ui/src/styles/controls.css @@ -6,3 +6,4 @@ @import "./components/env-vars.css"; @import "./components/directory-browser.css"; @import "./components/remote-access.css"; +@import "./components/permission-notification.css"; From 888e365d72b48100d3a0e21f6ac51c952d68a4dc Mon Sep 17 00:00:00 2001 From: bizzkoot Date: Thu, 8 Jan 2026 06:38:59 +0800 Subject: [PATCH 2/6] feat: enhance permission modal with tool details, queue nav, session nav, and responsive design MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Modal Enhancements: - Add accurate tool name extraction from message store (same method as inline chat) - Display 'Tool Call [name]' badge (e.g., 'Tool Call read', 'Tool Call write') - Add 'Go to Session ↗' button to navigate to originating session - Add Prev/Next buttons for queue navigation with keyboard shortcuts (←/→) - Add queue counter showing current position Responsive Web Design: - Portrait phones: 90vh max-height with safe margins (avoids browser URL bar) - Landscape phones: 95vw with 50vh body scroll - Tablets: adaptive layout - Touch devices: 44-48px touch targets Technical Changes: - Import messageStoreBus for tool part lookup - Query linked part.tool via permission messageId/callId - Export setActivePermissionIdForInstance for queue navigation - Add tool badge CSS styling --- .../components/permission-approval-modal.tsx | 241 ++++++++++++-- packages/ui/src/stores/instances.ts | 9 + .../components/permission-notification.css | 314 +++++++++++++++++- 3 files changed, 538 insertions(+), 26 deletions(-) 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 From df9fc529f9a5e19aef0a60d1348d81059664c73a Mon Sep 17 00:00:00 2001 From: Shantur Rathore Date: Thu, 8 Jan 2026 20:15:09 +0000 Subject: [PATCH 3/6] feat(ui): rework permission center to reuse tool call view --- .../components/instance/instance-shell2.tsx | 22 +- .../components/permission-approval-modal.tsx | 574 +++++-------- .../permission-notification-banner.tsx | 55 +- packages/ui/src/components/prompt-input.tsx | 3 +- .../components/permission-notification.css | 768 ++++-------------- 5 files changed, 379 insertions(+), 1043 deletions(-) diff --git a/packages/ui/src/components/instance/instance-shell2.tsx b/packages/ui/src/components/instance/instance-shell2.tsx index 0579e149..2f4e1929 100644 --- a/packages/ui/src/components/instance/instance-shell2.tsx +++ b/packages/ui/src/components/instance/instance-shell2.tsx @@ -1222,6 +1222,10 @@ const InstanceShell2: Component = (props) => {
+ setPermissionModalOpen(true)} + />
= (props) => {
+ setPermissionModalOpen(true)} + />
diff --git a/packages/ui/src/components/permission-approval-modal.tsx b/packages/ui/src/components/permission-approval-modal.tsx index d514cfca..3f899322 100644 --- a/packages/ui/src/components/permission-approval-modal.tsx +++ b/packages/ui/src/components/permission-approval-modal.tsx @@ -1,15 +1,10 @@ -import { Show, createSignal, createMemo, createEffect, onCleanup, type Component } from "solid-js" +import { For, Show, createMemo, createSignal, createEffect, onCleanup, type Component } from "solid-js" import type { PermissionRequestLike } from "../types/permission" -import { getPermissionSessionId, getPermissionKind, getPermissionDisplayTitle, getPermissionMessageId, getPermissionCallId } from "../types/permission" -import { getPermissionQueue, activePermissionId, sendPermissionResponse, setActivePermissionIdForInstance } from "../stores/instances" -import { setActiveSession } from "../stores/session-state" +import { getPermissionCallId, getPermissionDisplayTitle, getPermissionKind, getPermissionMessageId, getPermissionSessionId } from "../types/permission" +import { activePermissionId, getPermissionQueue } from "../stores/instances" +import { loadMessages, setActiveSession } from "../stores/sessions" import { messageStoreBus } from "../stores/message-v2/bus" -import { ToolCallDiffViewer } from "./diff-viewer" -import { useTheme } from "../lib/theme" -import { getRelativePath, getToolIcon, getToolName } from "./tool-call/utils" -import { getLogger } from "../lib/logger" - -const log = getLogger("session") +import ToolCall from "./tool-call" interface PermissionApprovalModalProps { instanceId: string @@ -17,201 +12,113 @@ interface PermissionApprovalModalProps { onClose: () => void } +type ResolvedToolCall = { + messageId: string + sessionId: string + toolPart: Extract + messageVersion: number + partVersion: number +} + +function resolveToolCallFromPermission( + instanceId: string, + permission: PermissionRequestLike, +): ResolvedToolCall | null { + const sessionId = getPermissionSessionId(permission) + const messageId = getPermissionMessageId(permission) + if (!sessionId || !messageId) return null + + const store = messageStoreBus.getInstance(instanceId) + if (!store) return null + + const record = store.getMessage(messageId) + if (!record) return null + + const metadata = ((permission as any).metadata || {}) as Record + const directPartId = + (permission as any).partID ?? + (permission as any).partId ?? + (metadata as any).partID ?? + (metadata as any).partId ?? + undefined + + const callId = getPermissionCallId(permission) + + const findToolPart = (partId: string) => { + const partRecord = record.parts?.[partId] + const part = partRecord?.data + if (!part || part.type !== "tool") return null + return { + toolPart: part as ResolvedToolCall["toolPart"], + partVersion: partRecord.revision ?? 0, + } + } + + if (typeof directPartId === "string" && directPartId.length > 0) { + const resolved = findToolPart(directPartId) + if (resolved) { + return { + messageId, + sessionId, + toolPart: resolved.toolPart, + messageVersion: record.revision, + partVersion: resolved.partVersion, + } + } + } + + if (callId) { + for (const partId of record.partIds) { + const partRecord = record.parts?.[partId] + const part = partRecord?.data as any + if (!part || part.type !== "tool") continue + const partCallId = part.callID ?? part.callId ?? part.toolCallID ?? part.toolCallId ?? undefined + if (partCallId === callId && typeof part.id === "string" && part.id.length > 0) { + return { + messageId, + sessionId, + toolPart: part as ResolvedToolCall["toolPart"], + messageVersion: record.revision, + partVersion: partRecord.revision ?? 0, + } + } + } + } + + return null +} + const PermissionApprovalModal: Component = (props) => { - const { isDark } = useTheme() - const [submitting, setSubmitting] = createSignal(false) - const [error, setError] = createSignal(null) + const [loadingSession, setLoadingSession] = createSignal(null) 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 - return queue().find((p) => p.id === id) ?? null + const orderedQueue = createMemo(() => { + const current = queue() + const activeId = activePermId() + if (!activeId) return current + const index = current.findIndex((entry) => entry.id === activeId) + if (index <= 0) return current + const active = current[index] + if (!active) return current + return [active, ...current.slice(0, index), ...current.slice(index + 1)] }) - const hasActivePermission = createMemo(() => activePermission() !== null) + const hasPermissions = createMemo(() => queue().length > 0) - // 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) { - setSubmitting(false) - setError(null) - } - }) - - // Keyboard shortcuts - createEffect(() => { - if (!props.isOpen || !hasActivePermission()) return - - const handler = (event: KeyboardEvent) => { - if (submitting()) return - - if (event.key === "Enter") { - event.preventDefault() - handleResponse("once") - } else if (event.key === "a" || event.key === "A") { - event.preventDefault() - handleResponse("always") - } else if (event.key === "d" || event.key === "D") { - event.preventDefault() - handleResponse("reject") - } 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() - } - } - - document.addEventListener("keydown", handler) - 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) + const closeOnEscape = (event: KeyboardEvent) => { + if (event.key === "Escape") { + event.preventDefault() props.onClose() } } - async function handleResponse(response: "once" | "always" | "reject") { - const permission = activePermission() - if (!permission) return - - setSubmitting(true) - setError(null) - - try { - 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) - if (remaining.length === 0) { - props.onClose() - } - }, 100) - } catch (err) { - log.error("Failed to send permission response", err) - setError(err instanceof Error ? err.message : "Failed to send response") - } finally { - setSubmitting(false) - } - } + createEffect(() => { + if (!props.isOpen) return + document.addEventListener("keydown", closeOnEscape) + onCleanup(() => document.removeEventListener("keydown", closeOnEscape)) + }) function handleBackdropClick(event: MouseEvent) { if (event.target === event.currentTarget) { @@ -219,196 +126,115 @@ const PermissionApprovalModal: Component = (props) } } - const diffPayload = createMemo(() => { - const permission = activePermission() - if (!permission) return null + async function handleLoadSession(sessionId: string) { + if (!sessionId) return + setLoadingSession(sessionId) + try { + await loadMessages(props.instanceId, sessionId) + } finally { + setLoadingSession((current) => (current === sessionId ? null : current)) + } + } - const metadata = ((permission as any).metadata || {}) as Record - const diffValue = typeof metadata.diff === "string" ? metadata.diff : null - if (!diffValue || diffValue.trim().length === 0) return null - - const diffPath = - typeof metadata.filePath === "string" ? metadata.filePath : - typeof metadata.path === "string" ? metadata.path : - undefined - - return { diffText: diffValue, filePath: diffPath } - }) + function handleGoToSession(sessionId: string) { + if (!sessionId) return + setActiveSession(props.instanceId, sessionId) + props.onClose() + } return ( -
- - }> - {/* Header */} -
-
-

- Permission Required -

- 1}> - - {currentIndex() + 1} of {queue().length} - - -
-
- - - - -
+
+
diff --git a/packages/ui/src/components/permission-notification-banner.tsx b/packages/ui/src/components/permission-notification-banner.tsx index 412de5f9..17e04907 100644 --- a/packages/ui/src/components/permission-notification-banner.tsx +++ b/packages/ui/src/components/permission-notification-banner.tsx @@ -1,6 +1,6 @@ import { Show, createMemo, type Component } from "solid-js" +import { ShieldAlert } from "lucide-solid" import { getPermissionQueueLength } from "../stores/instances" -import { isElectronHost } from "../lib/runtime-env" interface PermissionNotificationBannerProps { instanceId: string @@ -10,46 +10,25 @@ interface PermissionNotificationBannerProps { const PermissionNotificationBanner: Component = (props) => { const queueLength = createMemo(() => getPermissionQueueLength(props.instanceId)) const hasPermissions = createMemo(() => queueLength() > 0) - const isElectron = isElectronHost() + const label = createMemo(() => { + const count = queueLength() + return `${count} permission${count === 1 ? "" : "s"} pending approval` + }) return ( - {/* Electron: Full banner with text */} - - - - - {/* Web: Compact indicator button */} - - - + ) } diff --git a/packages/ui/src/components/prompt-input.tsx b/packages/ui/src/components/prompt-input.tsx index e214e9ae..07cd5ad8 100644 --- a/packages/ui/src/components/prompt-input.tsx +++ b/packages/ui/src/components/prompt-input.tsx @@ -7,6 +7,7 @@ import { resolvePastedPlaceholders } from "../lib/prompt-placeholders" import { createFileAttachment, createTextAttachment, createAgentAttachment } from "../types/attachment" import type { Attachment } from "../types/attachment" import type { Agent } from "../types/session" +import type { Command as SDKCommand } from "@opencode-ai/sdk/v2" import Kbd from "./kbd" import { getActiveInstance } from "../stores/instances" import { agents, getSessionDraftPrompt, setSessionDraftPrompt, clearSessionDraftPrompt, executeCustomCommand } from "../stores/sessions" @@ -767,7 +768,7 @@ export default function PromptInput(props: PromptInputProps) { type: "file" file: { path: string; relativePath?: string; isGitFile: boolean; isDirectory?: boolean } } - | { type: "command"; command: { name: string; description?: string } }, + | { type: "command"; command: SDKCommand }, ) { if (item.type === "command") { const name = item.command.name diff --git a/packages/ui/src/styles/components/permission-notification.css b/packages/ui/src/styles/components/permission-notification.css index 44da6a5b..170afd74 100644 --- a/packages/ui/src/styles/components/permission-notification.css +++ b/packages/ui/src/styles/components/permission-notification.css @@ -1,699 +1,231 @@ -/* Permission Notification Banner */ -.permission-notification-banner { - display: flex; +/* Central permission UI (toolbar + modal). + Kept intentionally small; reuse existing tokens. */ + +.permission-center-trigger { + display: inline-flex; align-items: center; - gap: var(--space-sm); - padding: 0.375rem 0.75rem; - background: linear-gradient(135deg, var(--status-warning) 0%, color-mix(in srgb, var(--status-warning) 85%, #000) 100%); - color: var(--text-inverted); - border: 1px solid color-mix(in srgb, var(--status-warning) 70%, #000); - border-radius: var(--radius-sm); - font-size: var(--font-size-sm); + gap: 0.25rem; + padding: 0.25rem 0.5rem; + border-radius: 9999px; + border: 1px solid var(--session-status-permission-fg); + background-color: var(--session-status-permission-bg); + color: var(--session-status-permission-fg); + font-size: 0.75rem; font-weight: var(--font-weight-semibold); cursor: pointer; - transition: all 0.2s ease; - box-shadow: 0 2px 4px color-mix(in srgb, var(--status-warning) 30%, transparent); - animation: permission-pulse 2s ease-in-out infinite; + transition: background-color 0.15s ease, border-color 0.15s ease, transform 0.15s ease; } -.permission-notification-banner:hover { - background: linear-gradient(135deg, color-mix(in srgb, var(--status-warning) 85%, #000) 0%, color-mix(in srgb, var(--status-warning) 70%, #000) 100%); - box-shadow: 0 4px 8px color-mix(in srgb, var(--status-warning) 40%, transparent); +.permission-center-trigger:hover, +.permission-center-trigger:focus-visible { + outline: none; + background-color: color-mix(in srgb, var(--session-status-permission-bg) 70%, var(--surface-hover)); transform: translateY(-1px); } -.permission-notification-banner:active { - transform: translateY(0); - box-shadow: 0 2px 4px color-mix(in srgb, var(--status-warning) 30%, transparent); +.permission-center-icon { + width: 1rem; + height: 1rem; } -.permission-notification-icon { - font-size: 1rem; +.permission-center-count { line-height: 1; - flex-shrink: 0; } -.permission-notification-text { - white-space: nowrap; - color: inherit; - font-size: inherit; -} - -.permission-notification-count { - display: inline-flex; - align-items: center; - justify-content: center; - min-width: 1.25rem; - height: 1.25rem; - padding: 0 0.25rem; - background: color-mix(in srgb, #000 30%, transparent); - border-radius: var(--radius-full); - font-size: var(--font-size-xs); - font-weight: var(--font-weight-bold); - color: inherit; - flex-shrink: 0; -} - -@keyframes permission-pulse { - - 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); - } -} - -/* Web Permission Indicator Button (for browser-based streaming) */ -.permission-indicator-button { - position: relative; - display: inline-flex; - align-items: center; - justify-content: center; - width: 2rem; - height: 2rem; - padding: 0; - background: color-mix(in srgb, var(--status-warning) 15%, transparent); - color: var(--status-warning); - border: 2px solid var(--status-warning); - border-radius: var(--radius-full); - font-size: var(--font-size-sm); - font-weight: var(--font-weight-bold); - cursor: pointer; - transition: all 0.2s ease; - animation: web-permission-pulse 2s ease-in-out infinite; - flex-shrink: 0; - z-index: 10; - min-width: 2rem; - min-height: 2rem; - max-width: 2rem; - max-height: 2rem; -} - -.permission-indicator-button:hover { - background: color-mix(in srgb, var(--status-warning) 25%, transparent); - box-shadow: 0 0 8px color-mix(in srgb, var(--status-warning) 50%, transparent); - transform: scale(1.05); -} - -.permission-indicator-button:active { - transform: scale(0.98); -} - -.permission-indicator-button:focus-visible { - outline: 2px solid var(--focus-ring-color); - outline-offset: 2px; -} - -.permission-indicator-badge { - display: inline-flex; - align-items: center; - justify-content: center; - font-size: var(--font-size-xs); - color: inherit; -} - -@keyframes web-permission-pulse { - - 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); - } -} - -/* Ensure button visibility in web toolbars */ -@supports (display: flex) { - .permission-indicator-button { - visibility: visible; - opacity: 1; - pointer-events: auto; - } -} - -/* Permission Approval Modal */ -.permission-approval-modal-backdrop { +.permission-center-modal-backdrop { position: fixed; - top: 0; - left: 0; - right: 0; - bottom: 0; - background: color-mix(in srgb, var(--text-inverted) 60%, transparent); + inset: 0; + background: color-mix(in srgb, var(--text-inverted) 55%, transparent); backdrop-filter: blur(4px); display: flex; align-items: center; justify-content: center; z-index: 9999; padding: var(--space-lg); - animation: modal-backdrop-fadein 0.2s ease; } -@keyframes modal-backdrop-fadein { - from { - opacity: 0; - } - - to { - opacity: 1; - } -} - -.permission-approval-modal { - background: var(--surface-base); - border: 1px solid var(--border-base); - border-radius: var(--radius-xl); - box-shadow: 0 20px 25px -5px color-mix(in srgb, #000 10%, transparent), 0 10px 10px -5px color-mix(in srgb, #000 4%, transparent); - max-width: 48rem; - width: 100%; +.permission-center-modal { + width: min(900px, 100%); max-height: 90vh; display: flex; flex-direction: column; - animation: modal-slidein 0.3s ease; + border-radius: var(--radius-xl); + border: 1px solid var(--border-base); + background: var(--surface-base); + box-shadow: var(--panel-shadow, 0 12px 32px rgba(0, 0, 0, 0.25)); + overflow: hidden; } -@keyframes modal-slidein { - from { - opacity: 0; - transform: translateY(-20px) scale(0.95); - } - - to { - opacity: 1; - transform: translateY(0) scale(1); - } -} - -.permission-modal-empty { - padding: var(--space-xl); - color: var(--text-secondary); - text-align: center; -} - -.permission-modal-header { +.permission-center-modal-header { display: flex; align-items: center; justify-content: space-between; - padding: var(--space-lg); + gap: var(--space-md); + padding: var(--space-md); border-bottom: 1px solid var(--border-base); - gap: var(--space-lg); } -.permission-modal-title { - font-size: var(--font-size-xl); +.permission-center-modal-title-row { + display: flex; + align-items: center; + gap: var(--space-sm); + min-width: 0; +} + +.permission-center-modal-title { + font-size: var(--font-size-lg); font-weight: var(--font-weight-semibold); color: var(--text-primary); margin: 0; - flex: 1; } -.permission-modal-count { - display: inline-flex; - align-items: center; - justify-content: center; - padding: var(--space-xs) var(--space-sm); - background: var(--surface-secondary); - border-radius: var(--radius-sm); - border: 1px solid var(--border-base); - font-size: var(--font-size-sm); - font-weight: var(--font-weight-medium); - color: var(--text-secondary); - white-space: nowrap; - flex-shrink: 0; -} - -.permission-modal-body { - flex: 1; - overflow-y: auto; - padding: var(--space-lg); - display: flex; - flex-direction: column; - gap: var(--space-lg); -} - -.permission-modal-type { - display: inline-flex; - align-items: center; - padding: var(--space-xs) var(--space-sm); - background: color-mix(in srgb, var(--status-warning) 20%, transparent); - color: var(--status-warning); - border: 1px solid color-mix(in srgb, var(--status-warning) 50%, transparent); - border-radius: var(--radius-sm); - font-size: var(--font-size-xs); - font-weight: var(--font-weight-semibold); - text-transform: uppercase; - letter-spacing: 0.05em; - align-self: flex-start; -} - -.permission-modal-message { - padding: var(--space-lg); - background: var(--surface-secondary); - border: 1px solid var(--border-base); - border-radius: var(--radius-md); - font-family: var(--font-family-mono); - word-break: break-word; -} - -.permission-modal-message code { - font-family: var(--font-family-mono); - font-size: var(--font-size-sm); - color: var(--text-primary); -} - -.permission-modal-diff { - display: flex; - flex-direction: column; - gap: var(--space-sm); -} - -.permission-modal-diff-label { - font-size: var(--font-size-xs); - font-weight: var(--font-weight-semibold); - color: var(--text-secondary); - text-transform: uppercase; - letter-spacing: 0.05em; -} - -.permission-modal-diff-viewer { - border: 1px solid var(--border-base); - border-radius: var(--radius-md); - overflow: hidden; - max-height: 24rem; - background: var(--surface-secondary); -} - -.permission-modal-error { - padding: var(--space-sm) var(--space-lg); - background: color-mix(in srgb, var(--status-error) 20%, transparent); - color: var(--status-error); - border: 1px solid color-mix(in srgb, var(--status-error) 50%, transparent); - border-radius: var(--radius-md); - font-size: var(--font-size-sm); -} - -.permission-modal-footer { - display: flex; - flex-direction: column; - gap: var(--space-lg); - padding: var(--space-lg); - border-top: 1px solid var(--border-base); - background: var(--surface-secondary); -} - -.permission-modal-buttons { - display: flex; - gap: var(--space-sm); - justify-content: stretch; -} - -.permission-modal-button { - flex: 1; - padding: var(--button-padding-y) var(--button-padding-x); - border: 1px solid transparent; - border-radius: var(--button-radius); - font-size: var(--font-size-sm); - font-weight: var(--font-weight-semibold); - cursor: pointer; - transition: all 0.15s ease; - font-family: var(--font-family-sans); -} - -.permission-modal-button:disabled { - opacity: 0.5; - cursor: not-allowed; -} - -.permission-modal-button:focus-visible { - outline: 2px solid var(--focus-ring-color); - outline-offset: 2px; -} - -.permission-modal-button-once { - background: var(--accent-primary); - color: var(--text-inverted); - border-color: var(--accent-primary); -} - -.permission-modal-button-once:hover:not(:disabled) { - background: var(--accent-hover); - border-color: var(--accent-hover); -} - -.permission-modal-button-always { - background: var(--status-success); - color: var(--text-inverted); - border-color: var(--status-success); -} - -.permission-modal-button-always:hover:not(:disabled) { - background: color-mix(in srgb, var(--status-success) 85%, #000); - border-color: color-mix(in srgb, var(--status-success) 85%, #000); -} - -.permission-modal-button-deny { - background: var(--status-error); - color: var(--text-inverted); - border-color: var(--status-error); -} - -.permission-modal-button-deny:hover:not(:disabled) { - background: color-mix(in srgb, var(--status-error) 85%, #000); - border-color: color-mix(in srgb, var(--status-error) 85%, #000); -} - -.permission-modal-shortcuts { - display: flex; - gap: var(--space-lg); - justify-content: center; - flex-wrap: wrap; -} - -.permission-modal-shortcut { - display: flex; - align-items: center; - gap: var(--space-sm); - font-size: var(--font-size-xs); - color: var(--text-secondary); -} - -.permission-modal-shortcut kbd { +.permission-center-modal-count { display: inline-flex; align-items: center; justify-content: center; min-width: 1.5rem; height: 1.5rem; - padding: 0 0.375rem; - background: var(--kbd-bg); - border: 1px solid var(--kbd-border); - color: var(--kbd-text); - border-radius: var(--radius-xs); - font-family: var(--font-family-mono); + padding: 0 0.4rem; + border-radius: 9999px; + background: var(--session-status-permission-bg); + color: var(--session-status-permission-fg); + border: 1px solid var(--session-status-permission-fg); font-size: var(--font-size-xs); font-weight: var(--font-weight-semibold); - box-shadow: inset 0 -2px 0 color-mix(in srgb, #000 10%, transparent); } -/* Enhanced Header Layout */ -.permission-modal-header { +.permission-center-modal-close { + display: inline-flex; + align-items: center; + justify-content: center; + width: 2rem; + height: 2rem; + border-radius: var(--radius-sm); + border: 1px solid var(--border-base); + background: var(--surface-secondary); + color: var(--text-primary); + cursor: pointer; +} + +.permission-center-modal-close:hover { + background: var(--surface-hover); +} + +.permission-center-modal-body { + flex: 1; + min-height: 0; + overflow: auto; + padding: var(--space-md); +} + +.permission-center-empty { + color: var(--text-secondary); + padding: var(--space-lg); + text-align: center; +} + +.permission-center-list { + display: flex; + flex-direction: column; + gap: var(--space-md); +} + +.permission-center-item { + border: 1px solid var(--border-base); + border-radius: var(--radius-lg); + background: var(--surface-secondary); + overflow: hidden; +} + +.permission-center-item-active { + border-color: var(--session-status-permission-fg); + box-shadow: 0 0 0 1px color-mix(in srgb, var(--session-status-permission-fg) 35%, transparent); +} + +.permission-center-item-header { display: flex; align-items: center; justify-content: space-between; - padding: var(--space-lg); + gap: var(--space-md); + padding: var(--space-sm) var(--space-md); 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; +.permission-center-item-heading { + display: flex; align-items: center; - justify-content: center; - width: 1.75rem; - height: 1.75rem; - padding: 0; - background: transparent; + gap: var(--space-sm); +} + +.permission-center-item-kind { + font-size: var(--font-size-xs); + letter-spacing: 0.05em; + text-transform: uppercase; 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); +.permission-center-item-chip { 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); + font-weight: var(--font-weight-semibold); + padding: 0.1rem 0.4rem; + border-radius: 9999px; + border: 1px solid var(--session-status-permission-fg); + background: var(--session-status-permission-bg); + color: var(--session-status-permission-fg); } -.permission-modal-tool-command { - padding: var(--space-sm); - background: var(--surface-base); - border: 1px solid var(--border-base); +.permission-center-item-actions { + display: inline-flex; + align-items: center; + gap: var(--space-sm); +} + +.permission-center-item-action { + padding: 0.25rem 0.5rem; 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); + border: 1px solid var(--border-base); 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); + font-size: var(--font-size-xs); + cursor: pointer; } -.permission-modal-nav-button:disabled { - opacity: 0.4; +.permission-center-item-action:hover { + background: var(--surface-hover); +} + +.permission-center-item-action:disabled { + opacity: 0.6; 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); +.permission-center-fallback { + padding: var(--space-md); +} + +.permission-center-fallback-title code { 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; - } +.permission-center-fallback-hint { + margin-top: var(--space-sm); + color: var(--text-secondary); + font-size: var(--font-size-sm); } -/* Landscape phone */ -@media (max-width: 812px) and (orientation: landscape) { - .permission-approval-modal { - width: 95vw; - max-height: 95vh; - margin: auto; +@media (max-width: 720px) { + .permission-center-modal-backdrop { + padding: 0; } - .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; + .permission-center-modal { + width: 100vw; + height: 100vh; + max-height: none; + border-radius: 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 From e30c8b02533cb94078b3dc81ef2c7fbff6271f77 Mon Sep 17 00:00:00 2001 From: Shantur Rathore Date: Thu, 8 Jan 2026 20:25:54 +0000 Subject: [PATCH 4/6] fix(ui): auto-close permission center when queue empty --- packages/ui/src/components/permission-approval-modal.tsx | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/packages/ui/src/components/permission-approval-modal.tsx b/packages/ui/src/components/permission-approval-modal.tsx index 3f899322..33970125 100644 --- a/packages/ui/src/components/permission-approval-modal.tsx +++ b/packages/ui/src/components/permission-approval-modal.tsx @@ -120,6 +120,13 @@ const PermissionApprovalModal: Component = (props) onCleanup(() => document.removeEventListener("keydown", closeOnEscape)) }) + createEffect(() => { + if (!props.isOpen) return + if (queue().length === 0) { + props.onClose() + } + }) + function handleBackdropClick(event: MouseEvent) { if (event.target === event.currentTarget) { props.onClose() From 3dfbe2a5b2a9b8ffe76279e96a6ca35bfd9ac2e0 Mon Sep 17 00:00:00 2001 From: Shantur Rathore Date: Thu, 8 Jan 2026 20:43:11 +0000 Subject: [PATCH 5/6] docs: add Linux NVIDIA Wayland workaround for Tauri AppImage --- README.md | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/README.md b/README.md index 8ce5fda1..71798d16 100644 --- a/README.md +++ b/README.md @@ -76,6 +76,29 @@ xattr -dr com.apple.quarantine /Applications/CodeNomad.app After removing the quarantine attribute, launch the app normally. On Intel Macs you may also need to approve CodeNomad from **System Settings → Privacy & Security** the first time you run it. +### Linux (Wayland + NVIDIA): Tauri AppImage closes immediately +On some Wayland compositor + NVIDIA driver setups, WebKitGTK can fail to initialize its DMA-BUF/GBM path and the Tauri build may exit right away. + +Try running with one of these environment variables: + +```bash +# Most reliable workaround (can reduce rendering performance) +WEBKIT_DISABLE_DMABUF_RENDERER=1 codenomad + +# Alternative for some Wayland setups +__NV_DISABLE_EXPLICIT_SYNC=1 codenomad +``` + +If you're running the Tauri AppImage and want the workaround applied every time, create a tiny wrapper script on your `PATH`: + +```bash +#!/bin/bash +export WEBKIT_DISABLE_DMABUF_RENDERER=1 +exec ~/.local/share/bauh/appimage/installed/codenomad/CodeNomad-Tauri-0.4.0-linux-x64.AppImage "$@" +``` + +Upstream tracking: https://github.com/tauri-apps/tauri/issues/10702 + ## Architecture & Development CodeNomad is a monorepo split into specialized packages. If you want to contribute or build from source, check out the individual package documentation: From c7370fe7bc339b24fe65448e68c5d188fd0d77bd Mon Sep 17 00:00:00 2001 From: bizzkoot Date: Fri, 9 Jan 2026 06:02:58 +0800 Subject: [PATCH 6/6] fix(ui): resolve multi-line border issue in permission notification cards - Removed box-shadow from active permission items to eliminate double-border effect - Added CSS rule to remove borders from nested ToolCall components within permission items - Ensures consistent single-border styling aligned with existing design system --- .../src/styles/components/permission-notification.css | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/packages/ui/src/styles/components/permission-notification.css b/packages/ui/src/styles/components/permission-notification.css index 170afd74..ccffd2c5 100644 --- a/packages/ui/src/styles/components/permission-notification.css +++ b/packages/ui/src/styles/components/permission-notification.css @@ -139,7 +139,6 @@ .permission-center-item-active { border-color: var(--session-status-permission-fg); - box-shadow: 0 0 0 1px color-mix(in srgb, var(--session-status-permission-fg) 35%, transparent); } .permission-center-item-header { @@ -217,6 +216,13 @@ font-size: var(--font-size-sm); } +/* Remove border from tool-call when inside permission items to avoid double borders */ +.permission-center-item .tool-call { + border: none; + border-radius: 0; + margin: 0; +} + @media (max-width: 720px) { .permission-center-modal-backdrop { padding: 0; @@ -228,4 +234,4 @@ max-height: none; border-radius: 0; } -} +} \ No newline at end of file