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: diff --git a/packages/ui/src/components/instance/instance-shell2.tsx b/packages/ui/src/components/instance/instance-shell2.tsx index ddd87821..2f4e1929 100644 --- a/packages/ui/src/components/instance/instance-shell2.tsx +++ b/packages/ui/src/components/instance/instance-shell2.tsx @@ -49,6 +49,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" @@ -140,6 +142,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)) @@ -651,7 +654,7 @@ const InstanceShell2: Component = (props) => { }) type DrawerViewState = "pinned" | "floating-open" | "floating-closed" - + const leftDrawerState = createMemo(() => { if (leftPinned()) return "pinned" @@ -692,7 +695,7 @@ const InstanceShell2: Component = (props) => { - const pinLeftDrawer = () => { + const pinLeftDrawer = () => { blurIfInside(leftDrawerContentEl()) batch(() => { setLeftPinned(true) @@ -811,18 +814,18 @@ const InstanceShell2: Component = (props) => { -
- - (leftPinned() ? unpinLeftDrawer() : pinLeftDrawer())} - > - {leftPinned() ? : } - - -
+
+ + (leftPinned() ? unpinLeftDrawer() : pinLeftDrawer())} + > + {leftPinned() ? : } + + +
@@ -1219,6 +1222,10 @@ const InstanceShell2: Component = (props) => {
+ setPermissionModalOpen(true)} + />
= (props) => { } > -
- - {leftAppBarButtonIcon()} - +
+ + {leftAppBarButtonIcon()} + - -
- Used - {formattedUsedTokens()} -
-
- Avail - {formattedAvailableTokens()} -
-
-
+ +
+ Used + {formattedUsedTokens()} +
+
+ Avail + {formattedAvailableTokens()} +
+
+
-
- - - - -
+
+ setPermissionModalOpen(true)} + /> + + + + + + +
@@ -1426,6 +1441,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..33970125 --- /dev/null +++ b/packages/ui/src/components/permission-approval-modal.tsx @@ -0,0 +1,251 @@ +import { For, Show, createMemo, createSignal, createEffect, onCleanup, type Component } from "solid-js" +import type { PermissionRequestLike } from "../types/permission" +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 ToolCall from "./tool-call" + +interface PermissionApprovalModalProps { + instanceId: string + isOpen: boolean + 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 [loadingSession, setLoadingSession] = createSignal(null) + + const queue = createMemo(() => getPermissionQueue(props.instanceId)) + const activePermId = createMemo(() => activePermissionId().get(props.instanceId) ?? 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 hasPermissions = createMemo(() => queue().length > 0) + + const closeOnEscape = (event: KeyboardEvent) => { + if (event.key === "Escape") { + event.preventDefault() + props.onClose() + } + } + + createEffect(() => { + if (!props.isOpen) return + document.addEventListener("keydown", closeOnEscape) + 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() + } + } + + async function handleLoadSession(sessionId: string) { + if (!sessionId) return + setLoadingSession(sessionId) + try { + await loadMessages(props.instanceId, sessionId) + } finally { + setLoadingSession((current) => (current === sessionId ? null : current)) + } + } + + function handleGoToSession(sessionId: string) { + if (!sessionId) return + setActiveSession(props.instanceId, sessionId) + props.onClose() + } + + return ( + +
+ + +
+
+ + + ) +} + +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..17e04907 --- /dev/null +++ b/packages/ui/src/components/permission-notification-banner.tsx @@ -0,0 +1,36 @@ +import { Show, createMemo, type Component } from "solid-js" +import { ShieldAlert } from "lucide-solid" +import { getPermissionQueueLength } from "../stores/instances" + +interface PermissionNotificationBannerProps { + instanceId: string + onClick: () => void +} + +const PermissionNotificationBanner: Component = (props) => { + const queueLength = createMemo(() => getPermissionQueueLength(props.instanceId)) + const hasPermissions = createMemo(() => queueLength() > 0) + const label = createMemo(() => { + const count = queueLength() + return `${count} permission${count === 1 ? "" : "s"} pending approval` + }) + + return ( + + + + ) +} + +export default PermissionNotificationBanner 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/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 new file mode 100644 index 00000000..ccffd2c5 --- /dev/null +++ b/packages/ui/src/styles/components/permission-notification.css @@ -0,0 +1,237 @@ +/* Central permission UI (toolbar + modal). + Kept intentionally small; reuse existing tokens. */ + +.permission-center-trigger { + display: inline-flex; + align-items: center; + 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: background-color 0.15s ease, border-color 0.15s ease, transform 0.15s ease; +} + +.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-center-icon { + width: 1rem; + height: 1rem; +} + +.permission-center-count { + line-height: 1; +} + +.permission-center-modal-backdrop { + position: fixed; + 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); +} + +.permission-center-modal { + width: min(900px, 100%); + max-height: 90vh; + display: flex; + flex-direction: column; + 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; +} + +.permission-center-modal-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-md); + padding: var(--space-md); + border-bottom: 1px solid var(--border-base); +} + +.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; +} + +.permission-center-modal-count { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 1.5rem; + height: 1.5rem; + 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); +} + +.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); +} + +.permission-center-item-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-md); + padding: var(--space-sm) var(--space-md); + border-bottom: 1px solid var(--border-base); + background: var(--surface-base); +} + +.permission-center-item-heading { + display: flex; + align-items: center; + 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); + font-weight: var(--font-weight-semibold); +} + +.permission-center-item-chip { + font-size: var(--font-size-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-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); + border: 1px solid var(--border-base); + background: var(--surface-secondary); + color: var(--text-primary); + font-size: var(--font-size-xs); + cursor: pointer; +} + +.permission-center-item-action:hover { + background: var(--surface-hover); +} + +.permission-center-item-action:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +.permission-center-fallback { + padding: var(--space-md); +} + +.permission-center-fallback-title code { + font-family: var(--font-family-mono); + font-size: var(--font-size-sm); + color: var(--text-primary); +} + +.permission-center-fallback-hint { + margin-top: var(--space-sm); + color: var(--text-secondary); + 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; + } + + .permission-center-modal { + width: 100vw; + height: 100vh; + max-height: none; + border-radius: 0; + } +} \ No newline at end of file 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";