Merge pull request #6868 from Kilo-Org/mark/permission-dock-replace-pattern

refactor: show permissions in dock above chatbox instead of inline
This commit is contained in:
Mark IJbema
2026-03-10 17:04:49 +01:00
committed by GitHub
13 changed files with 312 additions and 388 deletions
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:b6cbba3354994879f21489539615bbeeb71cb944980c642d502fdab3c4500e73
size 11000
oid sha256:59b9045133b989b85e3748e4fe509b317e21761280f26b3ee46f837bccf3a1ad
size 14489
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:bcb19f99cbd4cbaf403e2224139ff47318c2618457975d4f957b481962031836
size 9735
oid sha256:ce43eb9d6415fdb082ff03ab15080219c6275f7ae1bd09689f7d020a5511347c
size 15274
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:2a225a66c7981745089a46f20841d5f214d23d905b8b31194cfba9f278a3763d
size 10965
oid sha256:cae28aa54297014cb01b26120197b3bcab0b0a250c21d90c1050de3e09ee622b
size 10743
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:f6fcd2e69976f2c9a02b24e721e96638ec20a6711d3853d0795e2913315ca0b5
size 14341
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:8ed8885168e17a44fee3cd34e32143a6514fce09c3cd143dcf3be3088164d696
size 16827
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:83076ce468f046d5a884d8abf38189718641803cc966cae255e75e238894ae72
size 30673
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:9777ee1cb00c3632e71c64d2518f86aab1e4199d52e95b36c0d6344ac042994a
size 14844
oid sha256:26b18e59c4eccd851e4cb11f62f4b7b09e0fce56e9922b1ec51a94a4d9aeae67
size 17664
@@ -4,14 +4,13 @@
* Unlike the upstream AssistantParts, this renders each read/glob/grep/list tool
* individually for maximum verbosity in the VS Code sidebar context.
*
* Permissions and questions with a tool context are rendered inline with their
* tool call rather than in the bottom dock.
* Questions with a tool context are rendered inline with their tool call.
* Permissions are rendered in the bottom dock (PermissionDock).
*/
import { Component, For, Show, createMemo } from "solid-js"
import { Dynamic } from "solid-js/web"
import { Part, PART_MAPPING, ToolRegistry } from "@kilocode/kilo-ui/message-part"
import { Button } from "@kilocode/kilo-ui/button"
import type {
AssistantMessage as SDKAssistantMessage,
Part as SDKPart,
@@ -20,24 +19,20 @@ import type {
} from "@kilocode/sdk/v2"
import { useData } from "@kilocode/kilo-ui/context/data"
import { useSession } from "../../context/session"
import { useLanguage } from "../../context/language"
import { QuestionDock } from "./QuestionDock"
// Tools that the upstream message-part renderer suppresses (returns null for).
// We render these ourselves via ToolRegistry when they have a pending permission
// or when they complete, so the user can see what the AI set up.
// We also use this set in ChatView to know NOT to block the prompt input for
// these tool permissions (since they're shown inline in the message stream).
// We render these ourselves via ToolRegistry when they complete,
// so the user can see what the AI set up.
export const UPSTREAM_SUPPRESSED_TOOLS = new Set(["todowrite", "todoread"])
function isRenderable(part: SDKPart, pendingPermissionCallIDs: Set<string>): boolean {
function isRenderable(part: SDKPart): boolean {
if (part.type === "tool") {
const tool = (part as SDKPart & { tool: string }).tool
const state = (part as SDKPart & { state: { status: string } }).state
if (UPSTREAM_SUPPRESSED_TOOLS.has(tool)) {
const callID = (part as SDKPart & { callID: string }).callID
// Show todo parts when waiting for permission (inline) or when completed (to show what happened)
return pendingPermissionCallIDs.has(callID) || state.status === "completed"
// Show todo parts only when completed (permissions are now in the dock)
return state.status === "completed"
}
if (tool === "question" && (state.status === "pending" || state.status === "running")) return false
return true
@@ -76,52 +71,29 @@ function TodoToolCard(props: { part: ToolPart }) {
export const AssistantMessage: Component<AssistantMessageProps> = (props) => {
const data = useData()
const session = useSession()
const language = useLanguage()
const id = () => session.currentSessionID()
const permissions = () => session.permissions().filter((p) => p.sessionID === id() && p.tool)
const questions = () => session.questions().filter((q) => q.sessionID === id() && q.tool)
const pendingPermissionCallIDs = createMemo(() => {
const ids = new Set<string>()
for (const p of permissions()) {
if (p.tool?.messageID === props.message.id) ids.add(p.tool.callID)
}
return ids
})
const questions = () => session.questions().filter((q) => q.sessionID === session.currentSessionID() && q.tool)
const parts = createMemo(() => {
const stored = data.store.part?.[props.message.id]
if (!stored) return []
return (stored as SDKPart[]).filter((part) => isRenderable(part, pendingPermissionCallIDs()))
return (stored as SDKPart[]).filter((part) => isRenderable(part))
})
const permissionForPart = (part: SDKPart) => {
if (part.type !== "tool") return undefined
const callID = (part as SDKPart & { callID: string }).callID
return permissions().find((p) => p.tool!.callID === callID && p.tool!.messageID === props.message.id)
}
// Questions linked to this message (rendered after the last part)
const questionForMessage = () => questions().find((q) => q.tool!.messageID === props.message.id)
const decide = (permissionId: string, response: "once" | "always" | "reject") => {
if (session.respondingPermissions().has(permissionId)) return
session.respondToPermission(permissionId, response)
}
return (
<>
<For each={parts()}>
{(part) => {
const perm = () => permissionForPart(part)
// Upstream PART_MAPPING["tool"] returns null for todowrite/todoread,
// so we detect them here and render via ToolRegistry directly.
const isUpstreamSuppressed =
part.type === "tool" && UPSTREAM_SUPPRESSED_TOOLS.has((part as SDKPart & { tool: string }).tool)
return (
<Show when={isUpstreamSuppressed || PART_MAPPING[part.type]}>
<div data-component="tool-part-wrapper" data-permission={!!perm()} data-part-type={part.type}>
<div data-component="tool-part-wrapper" data-part-type={part.type}>
<Show
when={isUpstreamSuppressed}
fallback={
@@ -135,59 +107,6 @@ export const AssistantMessage: Component<AssistantMessageProps> = (props) => {
>
<TodoToolCard part={part as unknown as ToolPart} />
</Show>
<Show when={perm()} keyed>
{(p) => {
const isTodoPerm = UPSTREAM_SUPPRESSED_TOOLS.has(p.toolName)
// For todo tools: show the friendly operation description instead of raw patterns like '*'
// For other tools: show patterns only if they're meaningful (not just '*')
const meaningfulPatterns = p.patterns.filter((pat) => pat !== "*")
return (
<div data-component="permission-prompt" onClick={(e: MouseEvent) => e.stopPropagation()}>
<Show when={!isTodoPerm && meaningfulPatterns.length > 0}>
<div class="permission-dock-patterns">
<For each={meaningfulPatterns}>
{(pattern) => <code class="permission-dock-pattern">{pattern}</code>}
</For>
</div>
</Show>
<Show when={isTodoPerm}>
<p data-slot="permission-description">
{p.toolName === "todowrite"
? language.t("settings.permissions.tool.todowrite.description")
: language.t("settings.permissions.tool.todoread.description")}
</p>
</Show>
<div data-slot="permission-actions">
<Button
variant="ghost"
size="small"
onClick={() => decide(p.id, "reject")}
disabled={session.respondingPermissions().has(p.id)}
>
{language.t("ui.permission.deny")}
</Button>
<Button
variant="secondary"
size="small"
onClick={() => decide(p.id, "always")}
disabled={session.respondingPermissions().has(p.id)}
>
{language.t("ui.permission.allowAlways")}
</Button>
<Button
variant="primary"
size="small"
onClick={() => decide(p.id, "once")}
disabled={session.respondingPermissions().has(p.id)}
>
{language.t("ui.permission.allowOnce")}
</Button>
</div>
<p data-slot="permission-hint">{language.t("ui.permission.sessionHint")}</p>
</div>
)
}}
</Show>
</div>
</Show>
)
@@ -3,20 +3,18 @@
* Main chat container that combines all chat components
*/
import { Component, For, Show, createEffect, on, onCleanup, onMount } from "solid-js"
import { Component, Show, createEffect, on, onCleanup, onMount } from "solid-js"
import { Button } from "@kilocode/kilo-ui/button"
import { Icon } from "@kilocode/kilo-ui/icon"
import { BasicTool } from "@kilocode/kilo-ui/basic-tool"
import { TaskHeader } from "./TaskHeader"
import { MessageList } from "./MessageList"
import { PromptInput } from "./PromptInput"
import { QuestionDock } from "./QuestionDock"
import { UPSTREAM_SUPPRESSED_TOOLS } from "./AssistantMessage"
import { PermissionDock } from "./PermissionDock"
import { useSession } from "../../context/session"
import { useVSCode } from "../../context/vscode"
import { useLanguage } from "../../context/language"
import { useWorktreeMode } from "../../context/worktree-mode"
import type { PermissionRequest } from "../../types/messages"
interface ChatViewProps {
onSelectSession?: (id: string) => void
@@ -41,16 +39,14 @@ export const ChatView: Component<ChatViewProps> = (props) => {
const allPermissions = () => session.permissions()
const allQuestions = () => session.questions()
// Bottom-dock permission: prefer current-session non-tool permissions,
// Bottom-dock permission: prefer current-session permissions,
// then fall back to any pending permission (including child sessions).
const questionRequest = () =>
allQuestions().find((q) => q.sessionID === id() && !q.tool) ?? allQuestions().find((q) => !q.tool) ?? allQuestions()[0]
const permissionRequest = () =>
allPermissions().find((p) => p.sessionID === id() && !p.tool) ?? allPermissions().find((p) => !p.tool) ?? allPermissions()[0]
// Only block the prompt when there's a non-inline permission or any question pending
// (todo permissions are shown inline, not in the bottom dock)
const isInlinePermission = (p: PermissionRequest) => p.tool && UPSTREAM_SUPPRESSED_TOOLS.has(p.toolName)
const blocked = () => allPermissions().some((p) => !isInlinePermission(p)) || allQuestions().length > 0
allQuestions().find((q) => q.sessionID === id() && !q.tool) ??
allQuestions().find((q) => !q.tool) ??
allQuestions()[0]
const permissionRequest = () => allPermissions().find((p) => p.sessionID === id()) ?? allPermissions()[0]
const blocked = () => allPermissions().length > 0 || allQuestions().length > 0
// When a bottom-dock permission/question disappears while the session is busy,
// the scroll container grows taller. Dispatch a custom event so MessageList can
@@ -95,60 +91,13 @@ export const ChatView: Component<ChatViewProps> = (props) => {
{(req) => <QuestionDock request={req} />}
</Show>
<Show when={permissionRequest()} keyed>
{(perm) => {
const fromChild = () => perm.sessionID !== id()
const subtitle = () => fromChild() ? `${perm.toolName} (subagent)` : perm.toolName
return (
<div data-component="tool-part-wrapper" data-permission="true">
<BasicTool
icon="checklist"
locked
defaultOpen
trigger={{
title: language.t("notification.permission.title"),
subtitle: subtitle(),
}}
>
<Show when={perm.patterns.length > 0}>
<div class="permission-dock-patterns">
<For each={perm.patterns}>
{(pattern) => <code class="permission-dock-pattern">{pattern}</code>}
</For>
</div>
</Show>
</BasicTool>
<div data-component="permission-prompt">
<div data-slot="permission-actions">
<Button
variant="ghost"
size="small"
onClick={() => decide("reject")}
disabled={session.respondingPermissions().has(perm.id)}
>
{language.t("ui.permission.deny")}
</Button>
<Button
variant="secondary"
size="small"
onClick={() => decide("always")}
disabled={session.respondingPermissions().has(perm.id)}
>
{language.t("ui.permission.allowAlways")}
</Button>
<Button
variant="primary"
size="small"
onClick={() => decide("once")}
disabled={session.respondingPermissions().has(perm.id)}
>
{language.t("ui.permission.allowOnce")}
</Button>
</div>
<p data-slot="permission-hint">{language.t("ui.permission.sessionHint")}</p>
</div>
</div>
)
}}
{(perm) => (
<PermissionDock
request={perm}
responding={session.respondingPermissions().has(perm.id)}
onDecide={decide}
/>
)}
</Show>
<Show when={hasMessages() && idle() && !blocked()}>
<div class="new-task-button-wrapper">
@@ -0,0 +1,90 @@
/**
* PermissionDock component
* Displays permission requests from the AI assistant in the dock above the prompt input.
* Uses kilo-ui's DockPrompt component for proper surface styling.
* Modeled after the app's SessionPermissionDock.
*/
import { Component, For, Show } from "solid-js"
import { Button } from "@kilocode/kilo-ui/button"
import { DockPrompt } from "@kilocode/kilo-ui/dock-prompt"
import { Icon } from "@kilocode/kilo-ui/icon"
import { useSession } from "../../context/session"
import { useLanguage } from "../../context/language"
import type { PermissionRequest } from "../../types/messages"
export const PermissionDock: Component<{
request: PermissionRequest
responding: boolean
onDecide: (response: "once" | "always" | "reject") => void
}> = (props) => {
const session = useSession()
const language = useLanguage()
const fromChild = () => props.request.sessionID !== session.currentSessionID()
const toolDescription = () => {
const key = `settings.permissions.tool.${props.request.toolName}.description`
const value = language.t(key as Parameters<typeof language.t>[0])
if (value === key) return ""
return value
}
const subtitle = () => (fromChild() ? `${props.request.toolName} (subagent)` : props.request.toolName)
return (
<DockPrompt
kind="permission"
header={
<div data-slot="permission-row" data-variant="header">
<span data-slot="permission-icon">
<Icon name="warning" size="normal" />
</span>
<div data-slot="permission-header-title">
{language.t("notification.permission.title")}
<span data-slot="permission-header-subtitle">{subtitle()}</span>
</div>
</div>
}
footer={
<>
<div />
<div data-slot="permission-footer-actions">
<Button variant="ghost" size="small" onClick={() => props.onDecide("reject")} disabled={props.responding}>
{language.t("ui.permission.deny")}
</Button>
<Button
variant="secondary"
size="small"
onClick={() => props.onDecide("always")}
disabled={props.responding}
>
{language.t("ui.permission.allowAlways")}
</Button>
<Button variant="primary" size="small" onClick={() => props.onDecide("once")} disabled={props.responding}>
{language.t("ui.permission.allowOnce")}
</Button>
</div>
</>
}
>
<Show when={toolDescription()}>
<div data-slot="permission-row">
<span data-slot="permission-spacer" aria-hidden="true" />
<div data-slot="permission-hint">{toolDescription()}</div>
</div>
</Show>
<Show when={props.request.patterns.length > 0}>
<div data-slot="permission-row">
<span data-slot="permission-spacer" aria-hidden="true" />
<div data-slot="permission-patterns">
<For each={props.request.patterns}>
{(pattern) => <code data-slot="permission-pattern">{pattern}</code>}
</For>
</div>
</div>
</Show>
</DockPrompt>
)
}
@@ -7,10 +7,9 @@
* Call registerExpandedTaskTool() once at app startup to activate.
*/
import { Component, createEffect, createMemo, For, Match, Show, Switch } from "solid-js"
import { Component, createEffect, createMemo, For, Show } from "solid-js"
import { ToolRegistry, ToolProps, getToolInfo } from "@kilocode/kilo-ui/message-part"
import { BasicTool } from "@kilocode/kilo-ui/basic-tool"
import { Button } from "@kilocode/kilo-ui/button"
import { Icon } from "@kilocode/kilo-ui/icon"
import { useData } from "@kilocode/kilo-ui/context/data"
import { useI18n } from "@kilocode/kilo-ui/context/i18n"
@@ -64,36 +63,6 @@ const TaskToolRenderer: Component<ToolProps> = (props) => {
return getSessionToolParts(data.store, id)
})
// Permission from child session
const childPermission = createMemo(() => {
const id = childSessionId()
if (!id) return undefined
const perms = data.store.permission?.[id] as unknown[]
return (perms as PermissionRequest[] | undefined)?.[0]
})
const childToolPart = createMemo(() => {
const perm = childPermission()
if (!perm || !perm.tool) return undefined
const id = childSessionId()
if (!id) return undefined
const messages = (data.store.message?.[id] as SDKMessage[] | undefined) ?? []
const msg = [...messages].reverse().find((m: SDKMessage) => m.id === perm.tool!.messageID)
if (!msg) return undefined
const parts = (data.store.part?.[msg.id] as ToolPart[] | undefined) ?? []
return parts.find((p) => p.type === "tool" && p.callID === perm.tool!.callID) as ToolPart | undefined
})
const respond = (response: "once" | "always" | "reject") => {
const perm = childPermission()
if (!perm || !data.respondToPermission) return
data.respondToPermission({
sessionID: perm.sessionID,
permissionID: perm.id,
response,
})
}
const autoScroll = createAutoScroll({
working: running,
overflowAnchor: "auto",
@@ -112,100 +81,40 @@ const TaskToolRenderer: Component<ToolProps> = (props) => {
</div>
)
// Render the child tool card that triggered a permission (shown when permission is pending)
const renderChildToolCard = () => {
const part = childToolPart()
if (!part) return null
const render = ToolRegistry.render(part.tool)
if (!render) return null
const metadata = (part.state as { metadata?: Record<string, unknown> })?.metadata ?? {}
const input = part.state?.input ?? {}
const output = (part.state as { output?: string })?.output
return render({
input: input as Record<string, unknown>,
tool: part.tool,
metadata: metadata as Record<string, unknown>,
output,
status: part.state.status,
defaultOpen: true,
})
}
return (
<div data-component="tool-part-wrapper" data-permission={!!childPermission()}>
<Switch>
{/* Branch 1: pending permission from child session */}
<Match when={childPermission()}>
<>
<Show when={childToolPart()} fallback={<BasicTool icon="task" defaultOpen trigger={trigger()} />}>
{renderChildToolCard()}
</Show>
<div data-component="permission-prompt">
<div data-slot="permission-actions">
<Button variant="ghost" size="small" onClick={() => respond("reject")}>
{i18n.t("ui.permission.deny")}
</Button>
<Button variant="secondary" size="small" onClick={() => respond("always")}>
{i18n.t("ui.permission.allowAlways")}
</Button>
<Button variant="primary" size="small" onClick={() => respond("once")}>
{i18n.t("ui.permission.allowOnce")}
</Button>
</div>
<p data-slot="permission-hint">{i18n.t("ui.permission.sessionHint")}</p>
</div>
</>
</Match>
{/* Branch 2: normal — compact list of child tool calls */}
<Match when={true}>
<BasicTool icon="task" status={props.status} trigger={trigger()} defaultOpen>
<div
ref={autoScroll.scrollRef}
onScroll={autoScroll.handleScroll}
data-component="tool-output"
data-scrollable
>
<div ref={autoScroll.contentRef} data-component="task-tools">
<For each={childToolParts()}>
{(item) => {
const info = createMemo(() => getToolInfo(item.tool, item.state?.input))
const subtitle = createMemo(() => {
if (info().subtitle) return info().subtitle
const state = item.state as { status: string; title?: string }
if (state.status === "completed" || state.status === "running") {
return state.title
}
return undefined
})
return (
<div data-slot="task-tool-item">
<Icon name={info().icon} size="small" />
<span data-slot="task-tool-title">{info().title}</span>
<Show when={subtitle()}>
<span data-slot="task-tool-subtitle">{subtitle()}</span>
</Show>
</div>
)
}}
</For>
</div>
</div>
</BasicTool>
</Match>
</Switch>
<div data-component="tool-part-wrapper">
<BasicTool icon="task" status={props.status} trigger={trigger()} defaultOpen>
<div ref={autoScroll.scrollRef} onScroll={autoScroll.handleScroll} data-component="tool-output" data-scrollable>
<div ref={autoScroll.contentRef} data-component="task-tools">
<For each={childToolParts()}>
{(item) => {
const info = createMemo(() => getToolInfo(item.tool, item.state?.input))
const subtitle = createMemo(() => {
if (info().subtitle) return info().subtitle
const state = item.state as { status: string; title?: string }
if (state.status === "completed" || state.status === "running") {
return state.title
}
return undefined
})
return (
<div data-slot="task-tool-item">
<Icon name={info().icon} size="small" />
<span data-slot="task-tool-title">{info().title}</span>
<Show when={subtitle()}>
<span data-slot="task-tool-subtitle">{subtitle()}</span>
</Show>
</div>
)
}}
</For>
</div>
</div>
</BasicTool>
</div>
)
}
// Minimal PermissionRequest shape we need
interface PermissionRequest {
id: string
sessionID: string
tool?: { messageID: string; callID: string }
metadata?: Record<string, unknown>
}
/**
* Override the upstream "task" tool registration with the v1.0.25-style renderer.
* Must be called once at app startup.
@@ -8,18 +8,10 @@
*/
import type { Meta, StoryObj } from "storybook-solidjs-vite"
import { For, Show } from "solid-js"
import { Part } from "@kilocode/kilo-ui/message-part"
import { BasicTool } from "@kilocode/kilo-ui/basic-tool"
import { Button } from "@kilocode/kilo-ui/button"
import type {
AssistantMessage as SDKAssistantMessage,
TextPart,
ToolPart,
Message as SDKMessage,
} from "@kilocode/sdk/v2"
import type { AssistantMessage as SDKAssistantMessage, TextPart, ToolPart } from "@kilocode/sdk/v2"
import { StoryProviders, defaultMockData, mockSessionValue } from "./StoryProviders"
import { AssistantMessage } from "../components/chat/AssistantMessage"
import { ChatView } from "../components/chat/ChatView"
import { registerVscodeToolOverrides } from "../components/chat/VscodeToolOverrides"
import { SessionContext } from "../context/session"
import type { PermissionRequest, QuestionRequest } from "../types/messages"
@@ -336,80 +328,120 @@ export default meta
type Story = StoryObj
// ---------------------------------------------------------------------------
// 1. Tool with inline permission (glob)
// 1. Permission dock — glob (above chatbox)
// ---------------------------------------------------------------------------
export const GlobWithPermission: Story = {
name: "Glob + Inline Permission",
name: "Permission Dock — glob above chatbox",
render: () => {
const perms = [globPermission]
const data = dataWith([globPending], perms)
const session = {
...mockSessionValue({ id: SESSION_ID, status: "busy", permissions: perms }),
messages: () => [{ id: "msg-001" }] as any[],
}
return (
<StoryProviders data={data} permissions={perms} sessionID={SESSION_ID}>
<AssistantMessage message={baseAssistantMessage} />
<StoryProviders permissions={perms} sessionID={SESSION_ID} status="busy" noPadding>
<SessionContext.Provider value={session as any}>
<div style={{ width: "100%", height: "300px", display: "flex", "flex-direction": "column" }}>
<ChatView />
</div>
</SessionContext.Provider>
</StoryProviders>
)
},
}
// ---------------------------------------------------------------------------
// 2. Tool with inline permission (bash)
// 2. Permission dock — bash (above chatbox)
// ---------------------------------------------------------------------------
export const BashWithPermission: Story = {
name: "Bash + Inline Permission",
name: "Permission Dock — bash above chatbox",
render: () => {
const perms = [bashPermission]
const data = dataWith([bashPending], perms)
const session = {
...mockSessionValue({ id: SESSION_ID, status: "busy", permissions: perms }),
messages: () => [{ id: "msg-001" }] as any[],
}
return (
<StoryProviders data={data} permissions={perms} sessionID={SESSION_ID}>
<AssistantMessage message={baseAssistantMessage} />
<StoryProviders permissions={perms} sessionID={SESSION_ID} status="busy" noPadding>
<SessionContext.Provider value={session as any}>
<div style={{ width: "100%", height: "300px", display: "flex", "flex-direction": "column" }}>
<ChatView />
</div>
</SessionContext.Provider>
</StoryProviders>
)
},
}
// ---------------------------------------------------------------------------
// 3. Permission dock (non-tool bottom prompt)
// 3. Permission dock — write with file patterns (above chatbox)
// ---------------------------------------------------------------------------
export const PermissionDock: Story = {
name: "Permission Dock",
export const PermissionDockWrite: Story = {
name: "Permission Dock — write with patterns",
render: () => {
const perm = dockPermission
const perms = [dockPermission]
const session = {
...mockSessionValue({ id: SESSION_ID, status: "busy", permissions: perms }),
messages: () => [{ id: "msg-001" }] as any[],
}
return (
<StoryProviders sessionID={SESSION_ID}>
<div data-component="tool-part-wrapper" data-permission="true">
<BasicTool
icon="checklist"
locked
defaultOpen
trigger={{
title: "Permission required",
subtitle: perm.toolName,
}}
>
<Show when={perm.patterns.length > 0}>
<div class="permission-dock-patterns">
<For each={perm.patterns}>{(pattern) => <code class="permission-dock-pattern">{pattern}</code>}</For>
</div>
</Show>
</BasicTool>
<div data-component="permission-prompt">
<div data-slot="permission-actions">
<Button variant="ghost" size="small">
Deny
</Button>
<Button variant="secondary" size="small">
Always Allow
</Button>
<Button variant="primary" size="small">
Allow Once
</Button>
</div>
<p data-slot="permission-hint">Approval applies only to the current session</p>
<StoryProviders permissions={perms} sessionID={SESSION_ID} status="busy" noPadding>
<SessionContext.Provider value={session as any}>
<div style={{ width: "100%", height: "350px", display: "flex", "flex-direction": "column" }}>
<ChatView />
</div>
</div>
</SessionContext.Provider>
</StoryProviders>
)
},
}
// ---------------------------------------------------------------------------
// 3b. Permission dock — todowrite (above chatbox)
// ---------------------------------------------------------------------------
export const PermissionDockTodo: Story = {
name: "Permission Dock — todowrite above chatbox",
render: () => {
const perms = [todoWritePermission]
const session = {
...mockSessionValue({ id: SESSION_ID, status: "busy", permissions: perms }),
messages: () => [{ id: "msg-001" }] as any[],
}
return (
<StoryProviders permissions={perms} sessionID={SESSION_ID} status="busy" noPadding>
<SessionContext.Provider value={session as any}>
<div style={{ width: "100%", height: "300px", display: "flex", "flex-direction": "column" }}>
<ChatView />
</div>
</SessionContext.Provider>
</StoryProviders>
)
},
}
// ---------------------------------------------------------------------------
// 3c. Question dock above chatbox
// ---------------------------------------------------------------------------
export const QuestionAboveChatbox: Story = {
name: "Question Dock — above chatbox",
render: () => {
const qs = [questionRequest]
const session = {
...mockSessionValue({ id: SESSION_ID, status: "busy", questions: qs }),
messages: () => [{ id: "msg-001" }] as any[],
}
return (
<StoryProviders questions={qs} sessionID={SESSION_ID} status="busy" noPadding>
<SessionContext.Provider value={session as any}>
<div style={{ width: "100%", height: "500px", display: "flex", "flex-direction": "column" }}>
<ChatView />
</div>
</SessionContext.Provider>
</StoryProviders>
)
},
@@ -550,17 +582,25 @@ export const QuestionDismissed: Story = {
}
// ---------------------------------------------------------------------------
// 10. TodoWrite with inline permission (pending — shows todo list + permission prompt)
// 10. TodoWrite with permission in dock (pending — permission shown above chatbox)
// ---------------------------------------------------------------------------
export const TodoWriteWithPermission: Story = {
name: "TodoWrite + Inline Permission",
name: "TodoWrite + Permission in Dock",
render: () => {
const perms = [todoWritePermission]
const data = dataWith([todoWritePending], perms)
const session = {
...mockSessionValue({ id: SESSION_ID, status: "busy", permissions: perms }),
messages: () => [{ id: "msg-001" }] as any[],
}
return (
<StoryProviders data={data} permissions={perms} sessionID={SESSION_ID}>
<AssistantMessage message={baseAssistantMessage} />
<StoryProviders data={data} permissions={perms} sessionID={SESSION_ID} status="busy" noPadding>
<SessionContext.Provider value={session as any}>
<div style={{ width: "100%", height: "350px", display: "flex", "flex-direction": "column" }}>
<ChatView />
</div>
</SessionContext.Provider>
</StoryProviders>
)
},
@@ -1094,56 +1094,64 @@
}
/* ============================================
Permission Dock (patterns inside BasicTool)
Permission Dock (VS Code overrides)
============================================ */
.permission-dock-patterns {
display: flex;
flex-direction: column;
gap: 4px;
max-height: 120px;
overflow-y: auto;
padding: 8px 12px;
}
.permission-dock-pattern {
font-size: 12px;
font-family: var(--vscode-editor-font-family, monospace);
color: var(--vscode-foreground);
word-break: break-all;
}
/* Permission Prompt (inline with tool cards) */
[data-component="tool-part-wrapper"][data-permission="true"] {
/* When wrapping a Part + permission, the outer wrapper provides the card border.
Remove the inner Part's tool-part-wrapper border so we don't get double borders. */
> [data-component="tool-part-wrapper"] {
border: none;
border-radius: 0;
[data-component="dock-prompt"][data-kind="permission"] {
[data-slot="permission-body"] {
padding: 10px 12px 0;
gap: 8px;
}
}
[data-component="permission-prompt"] {
border-top: 1px solid var(--border-weak-base, var(--vscode-panel-border));
padding: 8px 12px;
}
[data-slot="permission-header-title"] {
font-size: 13px;
display: flex;
align-items: baseline;
gap: 8px;
}
[data-component="permission-prompt"] [data-slot="permission-description"] {
font-size: 12px;
color: var(--text-weak, var(--vscode-descriptionForeground));
margin: 0 0 8px;
}
[data-slot="permission-header-subtitle"] {
font-size: 11px;
font-weight: var(--font-weight-regular, 400);
color: var(--text-weak, var(--vscode-descriptionForeground));
}
[data-component="permission-prompt"] [data-slot="permission-actions"] {
display: flex;
align-items: center;
gap: 6px;
}
[data-slot="permission-hint"] {
font-size: 12px;
}
[data-component="permission-prompt"] [data-slot="permission-hint"] {
font-size: 11px;
color: var(--text-weak, var(--vscode-descriptionForeground));
margin: 4px 0 0;
[data-slot="permission-patterns"] {
margin-top: 0;
margin-bottom: 0;
max-height: 120px;
code {
font-size: 12px;
font-family: var(--vscode-editor-font-family, monospace);
color: var(--vscode-foreground);
word-break: break-all;
}
}
[data-slot="permission-footer"] {
padding: 8px;
margin-top: 0;
}
[data-slot="permission-footer-actions"] > [data-component="button"][data-variant="primary"] {
color: var(--vscode-button-foreground);
background-color: var(--vscode-button-background);
border: 1px solid var(--vscode-button-border, transparent);
&:hover:not(:disabled) {
background-color: var(--vscode-button-hoverBackground);
}
}
[data-slot="permission-footer-actions"] > [data-component="button"][data-variant="ghost"] {
border: none;
box-shadow: none;
}
}
/* Dialog Confirmation */