feat: UI許可システムの実装

- permissionModeを設定可能に変更(bypassPermissions, default, acceptEdits, plan)
- defaultモード時のUI許可ダイアログを実装
  - canUseTool callbackによるプログラマティックな許可処理
  - SSEを使用したリアルタイム通信
  - 許可/拒否の選択UI
- PermissionDialog機能
  - 大きなダイアログサイズ(max-w-4xl, max-h-[80vh])
  - パラメータの折りたたみ表示
  - 各パラメータのコピーボタン
  - 長いテキストのスクロール対応
  - レスポンシブデザイン対応
This commit is contained in:
dobachi
2025-09-24 01:37:53 +09:00
parent a35cba7a21
commit b7e9947efb
13 changed files with 541 additions and 11 deletions

View File

@@ -12,7 +12,9 @@ import {
import Link from "next/link";
import type { FC } from "react";
import { useEffect, useRef, useState } from "react";
import { PermissionDialog } from "@/components/PermissionDialog";
import { Button } from "@/components/ui/button";
import { usePermissionRequests } from "@/hooks/usePermissionRequests";
import { useTaskNotifications } from "@/hooks/useTaskNotifications";
import { Badge } from "../../../../../../components/ui/badge";
import { honoClient } from "../../../../../../lib/api/client";
@@ -50,6 +52,8 @@ export const SessionPageContent: FC<{
});
const { isRunningTask, isPausedTask } = useAliveTask(sessionId);
const { currentPermissionRequest, isDialogOpen, onPermissionResponse } =
usePermissionRequests();
// Set up task completion notifications
useTaskNotifications(isRunningTask);
@@ -226,6 +230,13 @@ export const SessionPageContent: FC<{
isOpen={isDiffModalOpen}
onOpenChange={setIsDiffModalOpen}
/>
{/* Permission Dialog */}
<PermissionDialog
permissionRequest={currentPermissionRequest}
isOpen={isDialogOpen}
onResponse={onPermissionResponse}
/>
</div>
);
};

View File

@@ -0,0 +1,195 @@
"use client";
import { ChevronDown, ChevronRight, Copy } from "lucide-react";
import { useState } from "react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import type {
PermissionRequest,
PermissionResponse,
} from "@/types/permissions";
interface PermissionDialogProps {
permissionRequest: PermissionRequest | null;
isOpen: boolean;
onResponse: (response: PermissionResponse) => void;
}
export const PermissionDialog = ({
permissionRequest,
isOpen,
onResponse,
}: PermissionDialogProps) => {
const [isResponding, setIsResponding] = useState(false);
const [isParametersExpanded, setIsParametersExpanded] = useState(false);
if (!permissionRequest) return null;
const handleResponse = async (decision: "allow" | "deny") => {
setIsResponding(true);
const response: PermissionResponse = {
permissionRequestId: permissionRequest.id,
decision,
};
try {
await onResponse(response);
} finally {
setIsResponding(false);
}
};
const formatValue = (value: unknown): string => {
if (value === null) return "null";
if (value === undefined) return "undefined";
if (typeof value === "boolean") return value.toString();
if (typeof value === "number") return value.toString();
if (typeof value === "string") return value;
return JSON.stringify(value, null, 2);
};
const copyToClipboard = async (text: string) => {
try {
await navigator.clipboard.writeText(text);
} catch (error) {
console.error("Failed to copy to clipboard:", error);
}
};
const renderParameterValue = (key: string, value: unknown) => {
const formattedValue = formatValue(value);
const isLong = formattedValue.length > 100;
return (
<div key={key} className="border rounded-lg p-3 space-y-2">
<div className="flex items-center justify-between">
<span className="font-medium text-sm">{key}</span>
<Button
variant="ghost"
size="sm"
onClick={() => copyToClipboard(formattedValue)}
className="h-6 w-6 p-0"
>
<Copy className="h-3 w-3" />
</Button>
</div>
<div
className={`text-xs font-mono bg-muted p-2 rounded ${isLong ? "max-h-32 overflow-y-auto" : ""}`}
>
<pre className="whitespace-pre-wrap break-words">
{formattedValue}
</pre>
</div>
</div>
);
};
const parameterEntries = Object.entries(permissionRequest.toolInput);
const hasParameters = parameterEntries.length > 0;
return (
<Dialog open={isOpen} onOpenChange={() => !isResponding}>
<DialogContent className="max-w-4xl max-h-[80vh] overflow-hidden flex flex-col">
<DialogHeader className="flex-shrink-0">
<DialogTitle className="flex items-center gap-2">
<span className="text-orange-600"></span>
Claude Code Permission Request
</DialogTitle>
<DialogDescription>
Claude Code wants to execute the following tool and needs your
permission.
</DialogDescription>
</DialogHeader>
<div className="flex-1 overflow-y-auto space-y-4 pr-2">
{/* Tool Information */}
<div className="rounded-lg border p-4 space-y-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Badge variant="secondary" className="text-sm">
{permissionRequest.toolName}
</Badge>
</div>
<span className="text-xs text-muted-foreground">
{new Date(permissionRequest.timestamp).toLocaleTimeString()}
</span>
</div>
</div>
{/* Parameters Section */}
{hasParameters && (
<div className="rounded-lg border">
<Collapsible
open={isParametersExpanded}
onOpenChange={setIsParametersExpanded}
>
<CollapsibleTrigger asChild>
<div className="flex items-center justify-between p-4 hover:bg-muted/50 cursor-pointer">
<div className="flex items-center gap-2">
<span className="font-medium">Tool Parameters</span>
<Badge variant="outline" className="text-xs">
{parameterEntries.length} parameter
{parameterEntries.length !== 1 ? "s" : ""}
</Badge>
</div>
{isParametersExpanded ? (
<ChevronDown className="h-4 w-4" />
) : (
<ChevronRight className="h-4 w-4" />
)}
</div>
</CollapsibleTrigger>
<CollapsibleContent className="px-4 pb-4">
<div className="space-y-3">
{parameterEntries.map(([key, value]) =>
renderParameterValue(key, value),
)}
</div>
</CollapsibleContent>
</Collapsible>
</div>
)}
{!hasParameters && (
<div className="rounded-lg border p-4 text-center text-muted-foreground">
This tool has no parameters.
</div>
)}
</div>
{/* Action Buttons */}
<div className="flex-shrink-0 flex gap-3 justify-end pt-4 border-t">
<Button
variant="outline"
onClick={() => handleResponse("deny")}
disabled={isResponding}
className="min-w-20"
>
Deny
</Button>
<Button
onClick={() => handleResponse("allow")}
disabled={isResponding}
className="min-w-20"
>
Allow
</Button>
</div>
</DialogContent>
</Dialog>
);
};

View File

@@ -32,6 +32,7 @@ export const SettingsControls: FC<SettingsControlsProps> = ({
}: SettingsControlsProps) => {
const checkboxId = useId();
const enterKeyBehaviorId = useId();
const permissionModeId = useId();
const { config, updateConfig } = useConfig();
const queryClient = useQueryClient();
@@ -74,6 +75,19 @@ export const SettingsControls: FC<SettingsControlsProps> = ({
await onConfigChanged();
};
const handlePermissionModeChange = async (value: string) => {
const newConfig = {
...config,
permissionMode: value as
| "acceptEdits"
| "bypassPermissions"
| "default"
| "plan",
};
updateConfig(newConfig);
await onConfigChanged();
};
return (
<div className={`space-y-4 ${className}`}>
<div className="flex items-center space-x-2">
@@ -148,6 +162,41 @@ export const SettingsControls: FC<SettingsControlsProps> = ({
</p>
)}
</div>
<div className="space-y-2">
{showLabels && (
<label
htmlFor={permissionModeId}
className="text-sm font-medium leading-none"
>
Permission Mode
</label>
)}
<Select
value={config?.permissionMode || "default"}
onValueChange={handlePermissionModeChange}
>
<SelectTrigger id={permissionModeId} className="w-full">
<SelectValue placeholder="Select permission mode" />
</SelectTrigger>
<SelectContent>
<SelectItem value="default">Default (Ask permission)</SelectItem>
<SelectItem value="acceptEdits">
Accept Edits (Auto-approve file edits)
</SelectItem>
<SelectItem value="bypassPermissions">
Bypass Permissions (No prompts)
</SelectItem>
<SelectItem value="plan">Plan Mode (Planning only)</SelectItem>
</SelectContent>
</Select>
{showDescriptions && (
<p className="text-xs text-muted-foreground mt-1">
Control how Claude Code handles permission requests for file
operations
</p>
)}
</div>
</div>
);
};

View File

@@ -0,0 +1,54 @@
"use client";
import { useCallback, useState } from "react";
import { useServerEventListener } from "@/lib/sse/hook/useServerEventListener";
import type {
PermissionRequest,
PermissionResponse,
} from "@/types/permissions";
export const usePermissionRequests = () => {
const [currentPermissionRequest, setCurrentPermissionRequest] =
useState<PermissionRequest | null>(null);
const [isDialogOpen, setIsDialogOpen] = useState(false);
// Listen for permission requests from the server
useServerEventListener("permission_requested", (data) => {
if (data.permissionRequest) {
setCurrentPermissionRequest(data.permissionRequest);
setIsDialogOpen(true);
}
});
const handlePermissionResponse = useCallback(
async (response: PermissionResponse) => {
try {
const apiResponse = await fetch("/api/tasks/permission-response", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(response),
});
if (!apiResponse.ok) {
throw new Error("Failed to send permission response");
}
// Close the dialog
setIsDialogOpen(false);
setCurrentPermissionRequest(null);
} catch (error) {
console.error("Error sending permission response:", error);
// TODO: Show error toast to user
}
},
[],
);
return {
currentPermissionRequest,
isDialogOpen,
onPermissionResponse: handlePermissionResponse,
};
};

View File

@@ -7,6 +7,10 @@ export const configSchema = z.object({
.enum(["shift-enter-send", "enter-send"])
.optional()
.default("shift-enter-send"),
permissionMode: z
.enum(["acceptEdits", "bypassPermissions", "default", "plan"])
.optional()
.default("default"),
});
export type Config = z.infer<typeof configSchema>;

View File

@@ -5,7 +5,7 @@ import { zValidator } from "@hono/zod-validator";
import { setCookie } from "hono/cookie";
import { streamSSE } from "hono/streaming";
import { z } from "zod";
import { configSchema } from "../config/config";
import { type Config, configSchema } from "../config/config";
import { ClaudeCodeTaskController } from "../service/claude-code/ClaudeCodeTaskController";
import type { SerializableAliveTask } from "../service/claude-code/types";
import { adaptInternalEventToSSE } from "../service/events/adaptInternalEventToSSE";
@@ -26,7 +26,16 @@ import type { HonoAppType } from "./app";
import { configMiddleware } from "./middleware/config.middleware";
export const routes = (app: HonoAppType) => {
const taskController = new ClaudeCodeTaskController();
let taskController: ClaudeCodeTaskController | null = null;
const getTaskController = (config: Config) => {
if (!taskController) {
taskController = new ClaudeCodeTaskController(config);
} else {
taskController.updateConfig(config);
}
return taskController;
};
const fileWatcher = getFileWatcher();
const eventBus = getEventBus();
@@ -312,7 +321,9 @@ export const routes = (app: HonoAppType) => {
return c.json({ error: "Project path not found" }, 400);
}
const task = await taskController.startOrContinueTask(
const task = await getTaskController(
c.get("config"),
).startOrContinueTask(
{
projectId,
cwd: project.meta.projectPath,
@@ -345,7 +356,9 @@ export const routes = (app: HonoAppType) => {
return c.json({ error: "Project path not found" }, 400);
}
const task = await taskController.startOrContinueTask(
const task = await getTaskController(
c.get("config"),
).startOrContinueTask(
{
projectId,
sessionId,
@@ -364,7 +377,7 @@ export const routes = (app: HonoAppType) => {
.get("/tasks/alive", async (c) => {
return c.json({
aliveTasks: taskController.aliveTasks.map(
aliveTasks: getTaskController(c.get("config")).aliveTasks.map(
(task): SerializableAliveTask => ({
id: task.id,
status: task.status,
@@ -380,11 +393,29 @@ export const routes = (app: HonoAppType) => {
zValidator("json", z.object({ sessionId: z.string() })),
async (c) => {
const { sessionId } = c.req.valid("json");
taskController.abortTask(sessionId);
getTaskController(c.get("config")).abortTask(sessionId);
return c.json({ message: "Task aborted" });
},
)
.post(
"/tasks/permission-response",
zValidator(
"json",
z.object({
permissionRequestId: z.string(),
decision: z.enum(["allow", "deny"]),
}),
),
async (c) => {
const permissionResponse = c.req.valid("json");
getTaskController(c.get("config")).respondToPermissionRequest(
permissionResponse,
);
return c.json({ message: "Permission response received" });
},
)
.get("/sse", async (c) => {
return streamSSE(
c,

View File

@@ -2,12 +2,15 @@ import { execSync } from "node:child_process";
import { query } from "@anthropic-ai/claude-code";
import prexit from "prexit";
import { ulid } from "ulid";
import type { Config } from "../../config/config";
import { getEventBus, type IEventBus } from "../events/EventBus";
import { createMessageGenerator } from "./createMessageGenerator";
import type {
AliveClaudeCodeTask,
ClaudeCodeTask,
PendingClaudeCodeTask,
PermissionRequest,
PermissionResponse,
RunningClaudeCodeTask,
} from "./types";
@@ -15,12 +18,16 @@ export class ClaudeCodeTaskController {
private pathToClaudeCodeExecutable: string;
private tasks: ClaudeCodeTask[] = [];
private eventBus: IEventBus;
private config: Config;
private pendingPermissionRequests: Map<string, PermissionRequest> = new Map();
private permissionResponses: Map<string, PermissionResponse> = new Map();
constructor() {
constructor(config: Config) {
this.pathToClaudeCodeExecutable = execSync("which claude", {})
.toString()
.trim();
this.eventBus = getEventBus();
this.config = config;
prexit(() => {
this.aliveTasks.forEach((task) => {
@@ -29,6 +36,124 @@ export class ClaudeCodeTaskController {
});
}
public updateConfig(config: Config) {
this.config = config;
}
public respondToPermissionRequest(response: PermissionResponse) {
this.permissionResponses.set(response.permissionRequestId, response);
this.pendingPermissionRequests.delete(response.permissionRequestId);
}
private createCanUseToolCallback(taskId: string, sessionId?: string) {
return async (
toolName: string,
toolInput: Record<string, unknown>,
_options: { signal: AbortSignal },
) => {
// If not in default mode, use the configured permission mode behavior
if (this.config.permissionMode !== "default") {
// Convert Claude Code permission modes to canUseTool behaviors
if (
this.config.permissionMode === "bypassPermissions" ||
this.config.permissionMode === "acceptEdits"
) {
return {
behavior: "allow" as const,
updatedInput: toolInput,
};
} else {
// plan mode should deny actual tool execution
return {
behavior: "deny" as const,
message: "Tool execution is disabled in plan mode",
};
}
}
// Create permission request
const permissionRequest: PermissionRequest = {
id: ulid(),
taskId,
sessionId,
toolName,
toolInput,
timestamp: Date.now(),
};
// Store the request
this.pendingPermissionRequests.set(
permissionRequest.id,
permissionRequest,
);
// Emit event to notify UI
this.eventBus.emit("permissionRequested", {
permissionRequest,
});
// Wait for user response with timeout
const response = await this.waitForPermissionResponse(
permissionRequest.id,
60000,
); // 60 second timeout
if (response) {
if (response.decision === "allow") {
return {
behavior: "allow" as const,
updatedInput: toolInput,
};
} else {
return {
behavior: "deny" as const,
message: "Permission denied by user",
};
}
} else {
// Timeout - default to deny for security
this.pendingPermissionRequests.delete(permissionRequest.id);
return {
behavior: "deny" as const,
message: "Permission request timed out",
};
}
};
}
private async waitForPermissionResponse(
permissionRequestId: string,
timeoutMs: number,
): Promise<PermissionResponse | null> {
return new Promise((resolve) => {
const checkResponse = () => {
const response = this.permissionResponses.get(permissionRequestId);
if (response) {
this.permissionResponses.delete(permissionRequestId);
resolve(response);
return;
}
// Check if request was cancelled/deleted
if (!this.pendingPermissionRequests.has(permissionRequestId)) {
resolve(null);
return;
}
// Continue polling
setTimeout(checkResponse, 100);
};
// Set timeout
setTimeout(() => {
resolve(null);
}, timeoutMs);
// Start polling
checkResponse();
});
}
public get aliveTasks() {
return this.tasks.filter(
(task) => task.status === "running" || task.status === "paused",
@@ -114,7 +239,11 @@ export class ClaudeCodeTaskController {
resume: task.baseSessionId,
cwd: task.cwd,
pathToClaudeCodeExecutable: this.pathToClaudeCodeExecutable,
permissionMode: "bypassPermissions",
permissionMode: this.config.permissionMode,
canUseTool: this.createCanUseToolCallback(
task.id,
task.baseSessionId,
),
abortController: abortController,
},
})) {

View File

@@ -58,3 +58,17 @@ export type SerializableAliveTask = Pick<
AliveClaudeCodeTask,
"id" | "status" | "sessionId" | "userMessageId"
>;
export type PermissionRequest = {
id: string;
taskId: string;
sessionId?: string;
toolName: string;
toolInput: Record<string, unknown>;
timestamp: number;
};
export type PermissionResponse = {
permissionRequestId: string;
decision: "allow" | "deny";
};

View File

@@ -1,4 +1,7 @@
import type { AliveClaudeCodeTask } from "../claude-code/types";
import type {
AliveClaudeCodeTask,
PermissionRequest,
} from "../claude-code/types";
export type InternalEventDeclaration = {
// biome-ignore lint/complexity/noBannedTypes: correct type
@@ -16,4 +19,8 @@ export type InternalEventDeclaration = {
taskChanged: {
aliveTasks: AliveClaudeCodeTask[];
};
permissionRequested: {
permissionRequest: PermissionRequest;
};
};

View File

@@ -30,6 +30,7 @@ export const adaptInternalEventToSSE = (
abortController.abort();
eventBus.off("heartbeat", heartbeat);
eventBus.off("permissionRequested", permissionRequested);
cleanUp?.();
};
@@ -45,7 +46,16 @@ export const adaptInternalEventToSSE = (
});
};
const permissionRequested = (
event: InternalEventDeclaration["permissionRequested"],
) => {
stream.writeSSE("permission_requested", {
permissionRequest: event.permissionRequest,
});
};
eventBus.on("heartbeat", heartbeat);
eventBus.on("permissionRequested", permissionRequested);
stream.writeSSE("connect", {
timestamp: new Date().toISOString(),

13
src/types/permissions.ts Normal file
View File

@@ -0,0 +1,13 @@
export type PermissionRequest = {
id: string;
taskId: string;
sessionId?: string;
toolName: string;
toolInput: Record<string, unknown>;
timestamp: number;
};
export type PermissionResponse = {
permissionRequestId: string;
decision: "allow" | "deny";
};

View File

@@ -1,4 +1,7 @@
import type { AliveClaudeCodeTask } from "../server/service/claude-code/types";
import type {
AliveClaudeCodeTask,
PermissionRequest,
} from "../server/service/claude-code/types";
export type SSEEventDeclaration = {
// biome-ignore lint/complexity/noBannedTypes: correct type
@@ -19,6 +22,10 @@ export type SSEEventDeclaration = {
taskChanged: {
aliveTasks: AliveClaudeCodeTask[];
};
permission_requested: {
permissionRequest: PermissionRequest;
};
};
export type SSEEventMap = {

View File

@@ -28,6 +28,12 @@
"exactOptionalPropertyTypes": false,
"allowJs": true
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"include": [
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
"next-env.d.ts",
"./.next/types/**/*.ts"
],
"exclude": ["node_modules"]
}