Merge remote-tracking branch 'origin/dev' into feat/centralized-permission-notifications

This commit is contained in:
Shantur Rathore
2026-01-08 20:09:42 +00:00
9 changed files with 388 additions and 75 deletions

View File

@@ -1,5 +1,5 @@
import { Dialog } from "@kobalte/core/dialog"
import { Component, Show, createEffect } from "solid-js"
import { Component, Show, createEffect, createSignal } from "solid-js"
import { alertDialogState, dismissAlertDialog } from "../stores/alerts"
import type { AlertVariant, AlertDialogState } from "../stores/alerts"
@@ -27,8 +27,9 @@ const variantAccent: Record<AlertVariant, { badgeBg: string; badgeBorder: string
},
}
function dismiss(confirmed: boolean, payload?: AlertDialogState | null) {
function dismiss(confirmed: boolean, payload?: AlertDialogState | null, promptValue?: string) {
const current = payload ?? alertDialogState()
if (current?.type === "confirm") {
if (confirmed) {
current.onConfirm?.()
@@ -36,7 +37,23 @@ function dismiss(confirmed: boolean, payload?: AlertDialogState | null) {
current.onCancel?.()
}
current.resolve?.(confirmed)
} else if (confirmed) {
dismissAlertDialog()
return
}
if (current?.type === "prompt") {
if (confirmed) {
current.onConfirm?.()
current.resolvePrompt?.(promptValue ?? "")
} else {
current.onCancel?.()
current.resolvePrompt?.(null)
}
dismissAlertDialog()
return
}
if (confirmed) {
current?.onConfirm?.()
}
dismissAlertDialog()
@@ -60,9 +77,12 @@ const AlertDialog: Component = () => {
const accent = variantAccent[variant]
const title = payload.title || accent.fallbackTitle
const isConfirm = payload.type === "confirm"
const confirmLabel = payload.confirmLabel || (isConfirm ? "Confirm" : "OK")
const isPrompt = payload.type === "prompt"
const confirmLabel = payload.confirmLabel || (isConfirm ? "Confirm" : isPrompt ? "Run" : "OK")
const cancelLabel = payload.cancelLabel || "Cancel"
const [inputValue, setInputValue] = createSignal(payload.inputDefaultValue ?? "")
return (
<Dialog
open
@@ -98,27 +118,47 @@ const AlertDialog: Component = () => {
</div>
</div>
<div class="mt-6 flex justify-end gap-3">
{isConfirm && (
<button
type="button"
class="button-secondary"
onClick={() => dismiss(false, payload)}
>
{cancelLabel}
</button>
)}
<button
type="button"
class="button-primary"
ref={(el) => {
primaryButtonRef = el
}}
onClick={() => dismiss(true, payload)}
>
{confirmLabel}
</button>
</div>
<Show when={isPrompt}>
<div class="mt-4">
<label class="text-xs font-medium text-muted uppercase tracking-wide">
{payload.inputLabel || "Arguments"}
</label>
<input
class="modal-search-input mt-2"
value={inputValue()}
placeholder={payload.inputPlaceholder || ""}
onInput={(e) => setInputValue(e.currentTarget.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault()
dismiss(true, payload, inputValue())
}
}}
/>
</div>
</Show>
<div class="mt-6 flex justify-end gap-3">
{(isConfirm || isPrompt) && (
<button
type="button"
class="button-secondary"
onClick={() => dismiss(false, payload)}
>
{cancelLabel}
</button>
)}
<button
type="button"
class="button-primary"
ref={(el) => {
primaryButtonRef = el
}}
onClick={() => dismiss(true, payload, inputValue())}
>
{confirmLabel}
</button>
</div>
</Dialog.Content>
</div>
</Dialog.Portal>

View File

@@ -39,8 +39,7 @@ import {
import { keyboardRegistry, type KeyboardShortcut } from "../../lib/keyboard-registry"
import { messageStoreBus } from "../../stores/message-v2/bus"
import { clearSessionRenderCache } from "../message-block"
import { buildCustomCommandEntries } from "../../lib/command-utils"
import { getCommands as getInstanceCommands } from "../../stores/commands"
import { isOpen as isCommandPaletteOpen, hideCommandPalette, showCommandPalette } from "../../stores/command-palette"
import SessionList from "../session-list"
import KeyboardHint from "../keyboard-hint"
@@ -376,9 +375,7 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
}
}
const customCommands = createMemo(() => buildCustomCommandEntries(props.instance.id, getInstanceCommands(props.instance.id)))
const instancePaletteCommands = createMemo(() => [...props.paletteCommands(), ...customCommands()])
const instancePaletteCommands = createMemo(() => props.paletteCommands())
const paletteOpen = createMemo(() => isCommandPaletteOpen(props.instance.id))
const keyboardShortcuts = createMemo(() =>

View File

@@ -5,7 +5,7 @@ import type { ClientPart } from "../types/message"
import type { MessageRecord } from "../stores/message-v2/types"
import { buildRecordDisplayData } from "../stores/message-v2/record-display-cache"
import { getToolIcon } from "./tool-call/utils"
import { User as UserIcon, Bot as BotIcon, FoldVertical } from "lucide-solid"
import { User as UserIcon, Bot as BotIcon, FoldVertical, ShieldAlert } from "lucide-solid"
export type TimelineSegmentType = "user" | "assistant" | "tool" | "compaction"
@@ -17,6 +17,7 @@ export interface TimelineSegment {
tooltip: string
shortLabel?: string
variant?: "auto" | "manual"
toolPartIds?: string[]
}
interface MessageTimelineProps {
@@ -47,6 +48,7 @@ interface PendingSegment {
toolTitles: string[]
toolTypeLabels: string[]
toolIcons: string[]
toolPartIds: string[]
hasPrimaryText: boolean
}
@@ -179,6 +181,7 @@ export function buildTimelineSegments(instanceId: string, record: MessageRecord)
label,
tooltip,
shortLabel,
toolPartIds: isToolSegment ? pending.toolPartIds : undefined,
})
segmentIndex += 1
pending = null
@@ -187,7 +190,7 @@ export function buildTimelineSegments(instanceId: string, record: MessageRecord)
const ensureSegment = (type: TimelineSegmentType): PendingSegment => {
if (!pending || pending.type !== type) {
flushPending()
pending = { type, texts: [], reasoningTexts: [], toolTitles: [], toolTypeLabels: [], toolIcons: [], hasPrimaryText: type !== "assistant" }
pending = { type, texts: [], reasoningTexts: [], toolTitles: [], toolTypeLabels: [], toolIcons: [], toolPartIds: [], hasPrimaryText: type !== "assistant" }
}
return pending!
}
@@ -204,6 +207,9 @@ export function buildTimelineSegments(instanceId: string, record: MessageRecord)
target.toolTitles.push(getToolTitle(toolPart))
target.toolTypeLabels.push(getToolTypeLabel(toolPart))
target.toolIcons.push(getToolIcon(typeof toolPart.tool === "string" ? toolPart.tool : "tool"))
if (typeof toolPart.id === "string" && toolPart.id.length > 0) {
target.toolPartIds.push(toolPart.id)
}
continue
}
@@ -359,9 +365,25 @@ const MessageTimeline: Component<MessageTimelineProps> = (props) => {
{(segment) => {
onCleanup(() => buttonRefs.delete(segment.id))
const isActive = () => props.activeMessageId === segment.messageId
const isHidden = () => segment.type === "tool" && !(showTools() || isActive())
const hasActivePermission = () => {
if (segment.type !== "tool") return false
const partIds = segment.toolPartIds ?? []
if (partIds.length === 0) return false
for (const partId of partIds) {
const permissionState = store().getPermissionState(segment.messageId, partId)
if (permissionState?.active) return true
}
return false
}
const isHidden = () => segment.type === "tool" && !(showTools() || isActive() || hasActivePermission())
const shortLabelContent = () => {
if (segment.type === "tool") {
if (hasActivePermission()) {
return <ShieldAlert class="message-timeline-icon" aria-hidden="true" />
}
return segment.shortLabel ?? getToolIcon("tool")
}
if (segment.type === "compaction") {
@@ -378,7 +400,7 @@ const MessageTimeline: Component<MessageTimelineProps> = (props) => {
ref={(el) => registerButtonRef(segment.id, el)}
type="button"
data-variant={segment.variant}
class={`message-timeline-segment message-timeline-${segment.type} ${segment.type === "compaction" ? `message-timeline-compaction-${segment.variant ?? "manual"}` : ""} ${isActive() ? "message-timeline-segment-active" : ""} ${isHidden() ? "message-timeline-segment-hidden" : ""}`}
class={`message-timeline-segment message-timeline-${segment.type} ${hasActivePermission() ? "message-timeline-segment-permission" : ""} ${segment.type === "compaction" ? `message-timeline-compaction-${segment.variant ?? "manual"}` : ""} ${isActive() ? "message-timeline-segment-active" : ""} ${isHidden() ? "message-timeline-segment-hidden" : ""}`}
aria-current={isActive() ? "true" : undefined}
aria-hidden={isHidden() ? "true" : undefined}

View File

@@ -9,7 +9,8 @@ import type { Attachment } from "../types/attachment"
import type { Agent } from "../types/session"
import Kbd from "./kbd"
import { getActiveInstance } from "../stores/instances"
import { agents, getSessionDraftPrompt, setSessionDraftPrompt, clearSessionDraftPrompt } from "../stores/sessions"
import { agents, getSessionDraftPrompt, setSessionDraftPrompt, clearSessionDraftPrompt, executeCustomCommand } from "../stores/sessions"
import { getCommands } from "../stores/commands"
import { showAlertDialog } from "../stores/alerts"
import { getLogger } from "../lib/logger"
const log = getLogger("actions")
@@ -36,6 +37,7 @@ export default function PromptInput(props: PromptInputProps) {
const [historyDraft, setHistoryDraft] = createSignal<string | null>(null)
const [, setIsFocused] = createSignal(false)
const [showPicker, setShowPicker] = createSignal(false)
const [pickerMode, setPickerMode] = createSignal<"mention" | "command">("mention")
const [searchQuery, setSearchQuery] = createSignal("")
const [atPosition, setAtPosition] = createSignal<number | null>(null)
const [isDragging, setIsDragging] = createSignal(false)
@@ -560,14 +562,28 @@ export default function PromptInput(props: PromptInputProps) {
const currentAttachments = attachments()
if (props.disabled || (!text && currentAttachments.length === 0)) return
const resolvedPrompt = resolvePastedPlaceholders(text, currentAttachments)
const isShellMode = mode() === "shell"
// Slash command routing (match OpenCode TUI): only run if the command exists.
const isSlashCandidate = !isShellMode && text.startsWith("/")
const firstSpace = isSlashCandidate ? text.indexOf(" ") : -1
const commandToken = isSlashCandidate ? (firstSpace === -1 ? text : text.slice(0, firstSpace)) : ""
const commandName = isSlashCandidate ? commandToken.slice(1) : ""
const commandArgs = isSlashCandidate ? (firstSpace === -1 ? "" : text.slice(firstSpace + 1).trimStart()) : ""
const isKnownSlashCommand =
isSlashCandidate &&
commandName.length > 0 &&
getCommands(props.instanceId).some((cmd) => cmd.name === commandName)
const resolvedPrompt = isKnownSlashCommand ? text : resolvePastedPlaceholders(text, currentAttachments)
const historyEntry = resolvedPrompt
const refreshHistory = async () => {
try {
await addToHistory(props.instanceFolder, resolvedPrompt)
await addToHistory(props.instanceFolder, historyEntry)
setHistory((prev) => {
const next = [resolvedPrompt, ...prev]
const next = [historyEntry, ...prev]
if (next.length > HISTORY_LIMIT) {
next.length = HISTORY_LIMIT
}
@@ -580,12 +596,25 @@ export default function PromptInput(props: PromptInputProps) {
}
clearPrompt()
clearAttachments(props.instanceId, props.sessionId)
setIgnoredAtPositions(new Set<number>())
setPasteCount(0)
setImageCount(0)
// Ignore attachments for slash commands, but keep them for next prompt.
if (!isKnownSlashCommand) {
clearAttachments(props.instanceId, props.sessionId)
setPasteCount(0)
setImageCount(0)
setIgnoredAtPositions(new Set<number>())
} else {
syncAttachmentCounters("", currentAttachments)
setIgnoredAtPositions(new Set<number>())
}
setHistoryDraft(null)
if (isKnownSlashCommand) {
// Record attempted slash commands even if execution fails.
void refreshHistory()
}
try {
if (isShellMode) {
if (props.onRunShell) {
@@ -593,10 +622,14 @@ export default function PromptInput(props: PromptInputProps) {
} else {
await props.onSend(resolvedPrompt, [])
}
} else if (isKnownSlashCommand) {
await executeCustomCommand(props.instanceId, props.sessionId, commandName, commandArgs)
} else {
await props.onSend(resolvedPrompt, currentAttachments)
}
void refreshHistory()
if (!isKnownSlashCommand) {
void refreshHistory()
}
} catch (error) {
log.error("Failed to send message:", error)
showAlertDialog("Failed to send message", {
@@ -677,11 +710,27 @@ export default function PromptInput(props: PromptInputProps) {
setHistoryDraft(null)
const cursorPos = target.selectionStart
// Slash command picker (only when editing the command token: "/<query>")
if (value.startsWith("/") && cursorPos >= 1) {
const firstWhitespaceIndex = value.slice(1).search(/\s/)
const tokenEnd = firstWhitespaceIndex === -1 ? value.length : firstWhitespaceIndex + 1
if (cursorPos <= tokenEnd) {
setPickerMode("command")
setAtPosition(0)
setSearchQuery(value.substring(1, cursorPos))
setShowPicker(true)
return
}
}
const textBeforeCursor = value.substring(0, cursorPos)
const lastAtIndex = textBeforeCursor.lastIndexOf("@")
const previousAtPosition = atPosition()
if (lastAtIndex === -1) {
setIgnoredAtPositions(new Set<number>())
} else if (previousAtPosition !== null && lastAtIndex !== previousAtPosition) {
@@ -698,6 +747,7 @@ export default function PromptInput(props: PromptInputProps) {
if (!hasSpace && cursorPos === lastAtIndex + textAfterAt.length + 1) {
if (!ignoredAtPositions().has(lastAtIndex)) {
setPickerMode("mention")
setAtPosition(lastAtIndex)
setSearchQuery(textAfterAt)
setShowPicker(true)
@@ -716,9 +766,30 @@ export default function PromptInput(props: PromptInputProps) {
| {
type: "file"
file: { path: string; relativePath?: string; isGitFile: boolean; isDirectory?: boolean }
},
}
| { type: "command"; command: { name: string; description?: string } },
) {
if (item.type === "agent") {
if (item.type === "command") {
const name = item.command.name
const currentPrompt = prompt()
const afterSlash = currentPrompt.slice(1)
const firstWhitespaceIndex = afterSlash.search(/\s/)
const tokenEnd = firstWhitespaceIndex === -1 ? currentPrompt.length : firstWhitespaceIndex + 1
const before = ""
const after = currentPrompt.substring(tokenEnd)
const newPrompt = before + `/${name} ` + after
setPrompt(newPrompt)
setTimeout(() => {
if (textareaRef) {
const newCursorPos = `/${name} `.length
textareaRef.setSelectionRange(newCursorPos, newCursorPos)
textareaRef.focus()
}
}, 0)
} else if (item.type === "agent") {
const agentName = item.agent.name
const existingAttachments = attachments()
const alreadyAttached = existingAttachments.some(
@@ -822,7 +893,7 @@ export default function PromptInput(props: PromptInputProps) {
function handlePickerClose() {
const pos = atPosition()
if (pos !== null) {
if (pickerMode() === "mention" && pos !== null) {
setIgnoredAtPositions((prev) => new Set(prev).add(pos))
}
setShowPicker(false)
@@ -959,6 +1030,7 @@ export default function PromptInput(props: PromptInputProps) {
}
const shellHint = () => (mode() === "shell" ? { key: "Esc", text: "to exit shell mode" } : { key: "!", text: "for shell mode" })
const commandHint = () => ({ key: "/", text: "for commands" })
const shouldShowOverlay = () => prompt().length === 0
@@ -981,9 +1053,11 @@ export default function PromptInput(props: PromptInputProps) {
<Show when={showPicker() && instance()}>
<UnifiedPicker
open={showPicker()}
mode={pickerMode()}
onClose={handlePickerClose}
onSelect={handlePickerSelect}
agents={instanceAgents()}
commands={getCommands(props.instanceId)}
instanceClient={instance()!.client}
searchQuery={searchQuery()}
textareaRef={textareaRef}
@@ -1149,6 +1223,11 @@ export default function PromptInput(props: PromptInputProps) {
<span class="prompt-overlay-text">
<Kbd>{shellHint().key}</Kbd> {shellHint().text}
</span>
<Show when={mode() !== "shell"}>
<span class="prompt-overlay-text">
<Kbd>{commandHint().key}</Kbd> {commandHint().text}
</span>
</Show>
<Show when={mode() === "shell"}>
<span class="prompt-overlay-shell-active">Shell mode active</span>
</Show>

View File

@@ -1,5 +1,6 @@
import { Component, createSignal, createEffect, For, Show, onCleanup } from "solid-js"
import { Component, createSignal, createEffect, createMemo, For, Show, onCleanup } from "solid-js"
import type { Agent } from "../types/session"
import type { Command as SDKCommand } from "@opencode-ai/sdk/v2"
import type { OpencodeClient } from "@opencode-ai/sdk/v2/client"
import { serverApi } from "../lib/api-client"
import { getLogger } from "../lib/logger"
@@ -67,13 +68,18 @@ function mapEntriesToFileItems(entries: { path: string; type: "file" | "director
})
}
type PickerItem = { type: "agent"; agent: Agent } | { type: "file"; file: FileItem }
type PickerItem =
| { type: "agent"; agent: Agent }
| { type: "file"; file: FileItem }
| { type: "command"; command: SDKCommand }
interface UnifiedPickerProps {
open: boolean
mode?: "mention" | "command"
onSelect: (item: PickerItem) => void
onClose: () => void
agents: Agent[]
commands?: SDKCommand[]
instanceClient: OpencodeClient | null
searchQuery: string
textareaRef?: HTMLTextAreaElement
@@ -81,6 +87,8 @@ interface UnifiedPickerProps {
}
const UnifiedPicker: Component<UnifiedPickerProps> = (props) => {
const mode = () => props.mode ?? "mention"
const [files, setFiles] = createSignal<FileItem[]>([])
const [filteredAgents, setFilteredAgents] = createSignal<Agent[]>([])
const [selectedIndex, setSelectedIndex] = createSignal(0)
@@ -246,6 +254,11 @@ const UnifiedPicker: Component<UnifiedPickerProps> = (props) => {
return
}
if (mode() !== "mention") {
// Command mode doesn't use file snapshots.
return
}
const workspaceChanged = lastWorkspaceId !== props.workspaceId
const queryChanged = lastQuery !== props.searchQuery
@@ -262,6 +275,7 @@ const UnifiedPicker: Component<UnifiedPickerProps> = (props) => {
createEffect(() => {
if (!props.open) return
if (mode() !== "mention") return
const query = props.searchQuery.toLowerCase()
const filtered = query
@@ -275,8 +289,25 @@ const UnifiedPicker: Component<UnifiedPickerProps> = (props) => {
setFilteredAgents(filtered)
})
const filteredCommands = createMemo(() => {
if (mode() !== "command") return []
const q = props.searchQuery.trim().toLowerCase()
const source = props.commands ?? []
if (!q) return source
return source.filter((cmd) => {
const nameMatch = cmd.name.toLowerCase().includes(q)
const descMatch = (cmd.description ?? "").toLowerCase().includes(q)
return nameMatch || descMatch
})
})
const allItems = (): PickerItem[] => {
const items: PickerItem[] = []
if (mode() === "command") {
filteredCommands().forEach((command) => items.push({ type: "command", command }))
return items
}
filteredAgents().forEach((agent) => items.push({ type: "agent", agent }))
files().forEach((file) => items.push({ type: "file", file }))
return items
@@ -329,9 +360,10 @@ const UnifiedPicker: Component<UnifiedPickerProps> = (props) => {
}
})
const commandCount = () => filteredCommands().length
const agentCount = () => filteredAgents().length
const fileCount = () => files().length
const isLoading = () => loadingState() !== "idle"
const isLoading = () => mode() === "mention" && loadingState() !== "idle"
const loadingMessage = () => {
if (loadingState() === "search") {
return "Searching..."
@@ -351,7 +383,9 @@ const UnifiedPicker: Component<UnifiedPickerProps> = (props) => {
>
<div class="dropdown-header">
<div class="dropdown-header-title">
Select Agent or File
<Show when={mode() === "command"} fallback={"Select Agent or File"}>
Select Command
</Show>
<Show when={isLoading()}>
<span class="ml-2">{loadingMessage()}</span>
</Show>
@@ -359,11 +393,41 @@ const UnifiedPicker: Component<UnifiedPickerProps> = (props) => {
</div>
<div ref={scrollContainerRef} class="dropdown-content max-h-60">
<Show when={agentCount() === 0 && fileCount() === 0}>
<Show when={(mode() === "command" ? commandCount() === 0 : agentCount() === 0 && fileCount() === 0)}>
<div class="dropdown-empty">No results found</div>
</Show>
<Show when={agentCount() > 0}>
<Show when={mode() === "command" && commandCount() > 0}>
<div class="dropdown-section-header">COMMANDS</div>
<For each={filteredCommands()}>
{(command) => {
const itemIndex = allItems().findIndex((item) => item.type === "command" && item.command.name === command.name)
return (
<div
class={`dropdown-item ${itemIndex === selectedIndex() ? "dropdown-item-highlight" : ""}`}
data-picker-selected={itemIndex === selectedIndex()}
onClick={() => handleSelect({ type: "command", command })}
>
<div class="flex items-start gap-2">
<svg class="dropdown-icon-accent h-4 w-4 mt-0.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16" />
</svg>
<div class="flex-1">
<div class="text-sm font-medium">/{command.name}</div>
<Show when={command.description}>
<div class="mt-0.5 text-xs" style="color: var(--text-muted)">
{(command.description ?? "").length > 80 ? (command.description ?? "").slice(0, 80) + "..." : command.description}
</div>
</Show>
</div>
</div>
</div>
)
}}
</For>
</Show>
<Show when={mode() === "mention" && agentCount() > 0}>
<div class="dropdown-section-header">
AGENTS
</div>
@@ -418,7 +482,7 @@ const UnifiedPicker: Component<UnifiedPickerProps> = (props) => {
</For>
</Show>
<Show when={fileCount() > 0}>
<Show when={mode() === "mention" && fileCount() > 0}>
<div class="dropdown-section-header">
FILES
</div>