fix: improve hooks settings layout and UI

This commit is contained in:
Toray Altas 2026-01-16 23:20:23 -05:00
parent a13fba4c88
commit 8bb5b61555
5 changed files with 207 additions and 73 deletions

View file

@ -2254,7 +2254,7 @@ export class ClineProvider
filePath: hook.filePath,
event: hook.event,
matcher: hook.matcher,
commandPreview: hook.command.length > 100 ? hook.command.substring(0, 97) + "..." : hook.command,
commandPreview: hook.command,
enabled: (hook.enabled ?? true) && !(snapshot?.disabledHookIds?.has(hook.id) ?? false),
source: hook.source,
timeout: hook.timeout ?? 60,

View file

@ -1,5 +1,6 @@
import React, { useCallback, useEffect, useState } from "react"
import { RefreshCw, FolderOpen, AlertTriangle, Clock, Zap, X } from "lucide-react"
import { VSCodePanels, VSCodePanelTab, VSCodePanelView } from "@vscode/webview-ui-toolkit/react"
import { useAppTranslation } from "@src/i18n/TranslationContext"
import { useExtensionState } from "@src/context/ExtensionStateContext"
import { vscode } from "@src/utils/vscode"
@ -361,73 +362,103 @@ const HookItem: React.FC<HookItemProps> = ({ hook, onToggle }) => {
{/* Expanded Content */}
{isExpanded && (
<div className="px-3 pb-3 pt-0 space-y-3 border-t border-vscode-input-border">
{/* Hook Details */}
<div className="space-y-2">
<div className="flex items-center gap-2">
<span className="text-xs text-vscode-descriptionForeground">
{t("settings:hooks.event")}:
</span>
<code className="text-sm font-mono text-vscode-textLink-foreground">{hook.event}</code>
</div>
{hook.matcher && (
<div className="flex items-center gap-2">
<span className="text-xs text-vscode-descriptionForeground">
{t("settings:hooks.matcher")}:
</span>
<code className="text-xs font-mono text-vscode-descriptionForeground">
{hook.matcher}
</code>
</div>
)}
{hook.description && (
<div>
<span className="text-xs text-vscode-descriptionForeground">
{t("settings:hooks.description")}:
</span>
<p className="text-sm text-vscode-foreground mt-1">{hook.description}</p>
</div>
)}
<div className="flex items-center gap-2">
<span className="text-xs text-vscode-descriptionForeground">
{t("settings:hooks.command")}:
</span>
<code className="text-xs font-mono bg-vscode-editor-background px-2 py-1 rounded flex-1">
{hook.commandPreview}
</code>
</div>
<div className="flex items-center gap-4 text-xs text-vscode-descriptionForeground">
{hook.shell && (
<span>
{t("settings:hooks.shell")}: <code className="font-mono">{hook.shell}</code>
</span>
)}
<span>
{t("settings:hooks.timeout")}: {hook.timeout}s
</span>
</div>
</div>
<div className="border-t border-vscode-input-border p-3">
<VSCodePanels>
<VSCodePanelTab id="config">{t("settings:hooks.tabs.config")}</VSCodePanelTab>
<VSCodePanelTab id="command">{t("settings:hooks.tabs.command")}</VSCodePanelTab>
<VSCodePanelTab id="logs">
{t("settings:hooks.tabs.logs")}
{hookLogs.length > 0 && <span className="ml-1 opacity-60">({hookLogs.length})</span>}
</VSCodePanelTab>
{/* Logs Section */}
<div className="border-t border-vscode-input-border pt-3">
<div className="flex items-center gap-2 mb-2">
<span className="text-sm font-medium">{t("settings:hooks.logs")}</span>
{hookLogs.length > 0 && (
<span className="text-xs text-vscode-descriptionForeground">({hookLogs.length})</span>
)}
</div>
{hookLogs.length === 0 ? (
<div className="text-xs text-vscode-descriptionForeground py-2">
{t("settings:hooks.noLogsForHook")}
<VSCodePanelView id="view-config">
<div className="flex flex-col gap-3 pt-3 w-full">
<div className="grid grid-cols-2 gap-4">
<div>
<span className="text-xs font-medium text-vscode-descriptionForeground block mb-1">
{t("settings:hooks.event")}
</span>
<code className="text-xs font-mono text-vscode-textLink-foreground bg-vscode-textCodeBlock-background px-1.5 py-0.5 rounded">
{hook.event}
</code>
</div>
<div>
<span className="text-xs font-medium text-vscode-descriptionForeground block mb-1">
{t("settings:hooks.timeout")}
</span>
<span className="text-xs text-vscode-foreground">{hook.timeout}s</span>
</div>
</div>
{hook.matcher && (
<div>
<span className="text-xs font-medium text-vscode-descriptionForeground block mb-1">
{t("settings:hooks.matcher")}
</span>
<div className="bg-vscode-textCodeBlock-background p-2 rounded border border-vscode-widget-border">
<ul className="list-disc list-inside text-xs font-mono text-vscode-foreground">
{hook.matcher
.split("|")
.map((m) => m.trim())
.filter(Boolean)
.map((m, i) => (
<li key={i}>{m}</li>
))}
</ul>
</div>
</div>
)}
{hook.shell && (
<div>
<span className="text-xs font-medium text-vscode-descriptionForeground block mb-1">
{t("settings:hooks.shell")}
</span>
<code className="text-xs font-mono text-vscode-foreground bg-vscode-textCodeBlock-background px-1.5 py-0.5 rounded">
{hook.shell}
</code>
</div>
)}
{hook.description && (
<div>
<span className="text-xs font-medium text-vscode-descriptionForeground block mb-1">
{t("settings:hooks.description")}
</span>
<p className="text-xs text-vscode-foreground">{hook.description}</p>
</div>
)}
</div>
) : (
<div className="space-y-2 max-h-48 overflow-y-auto">
{hookLogs.map((record, index) => (
<HookLogItem key={`${record.timestamp}-${index}`} record={record} />
))}
</VSCodePanelView>
<VSCodePanelView id="view-command">
<div className="pt-3 w-full">
<div className="bg-vscode-textCodeBlock-background p-3 rounded border border-vscode-widget-border overflow-x-auto max-h-48 overflow-y-auto">
<code
data-testid={`command-preview-${hook.id}`}
className="text-xs font-mono text-vscode-foreground whitespace-pre-wrap break-words">
{hook.commandPreview}
</code>
</div>
</div>
)}
</div>
</VSCodePanelView>
<VSCodePanelView id="view-logs">
<div className="pt-3 w-full">
{hookLogs.length === 0 ? (
<div className="text-xs text-vscode-descriptionForeground">
{t("settings:hooks.noLogsForHook")}
</div>
) : (
<div className="space-y-2 max-h-48 overflow-y-auto">
{hookLogs.map((record, index) => (
<HookLogItem key={`${record.timestamp}-${index}`} record={record} />
))}
</div>
)}
</div>
</VSCodePanelView>
</VSCodePanels>
</div>
)}
</div>
@ -484,7 +515,9 @@ const HookLogItem: React.FC<HookLogItemProps> = ({ record }) => {
{status.label}
</span>
{record.toolName && (
<code className="text-xs font-mono text-vscode-descriptionForeground truncate">
<code
data-testid="log-tool-name"
className="text-xs font-mono text-vscode-descriptionForeground break-words">
{record.toolName}
</code>
)}

View file

@ -99,6 +99,7 @@ export const sectionNames = [
"providers",
"autoApprove",
"slashCommands",
"hooks",
"browser",
"checkpoints",
"notifications",
@ -106,7 +107,6 @@ export const sectionNames = [
"terminal",
"modes",
"mcp",
"hooks",
"prompts",
"ui",
"experimental",
@ -528,12 +528,12 @@ const SettingsView = forwardRef<SettingsViewRef, SettingsViewProps>(({ onDone, t
{ id: "mcp", icon: Server },
{ id: "autoApprove", icon: CheckCheck },
{ id: "slashCommands", icon: SquareSlash },
{ id: "hooks", icon: Zap },
{ id: "browser", icon: SquareMousePointer },
{ id: "checkpoints", icon: GitBranch },
{ id: "notifications", icon: Bell },
{ id: "contextManagement", icon: Database },
{ id: "terminal", icon: SquareTerminal },
{ id: "hooks", icon: Zap },
{ id: "prompts", icon: MessageSquare },
{ id: "ui", icon: Glasses },
{ id: "experimental", icon: FlaskConical },

View file

@ -5,6 +5,25 @@ import { vi, describe, it, expect, beforeEach } from "vitest"
import { HooksSettings } from "../HooksSettings"
import type { HookInfo, HookExecutionRecord, HooksState } from "@roo-code/types"
// Mock webview-ui-toolkit components
vi.mock("@vscode/webview-ui-toolkit/react", () => ({
VSCodePanels: ({ children, ...props }: any) => (
<div data-testid="vscode-panels" {...props}>
{children}
</div>
),
VSCodePanelTab: ({ children, id, ...props }: any) => (
<div data-testid={`tab-${id}`} data-tab-id={id} {...props}>
{children}
</div>
),
VSCodePanelView: ({ children, id, ...props }: any) => (
<div data-testid={`panel-${id}`} data-panel-id={id} {...props}>
{children}
</div>
),
}))
// Mock vscode utilities
vi.mock("@src/utils/vscode", () => ({
vscode: {
@ -147,9 +166,16 @@ describe("HooksSettings", () => {
const hookHeader = screen.getByText(mockHook.id).closest("div")
fireEvent.click(hookHeader!)
// Hook details should now be visible
// Should show tabs
expect(screen.getByTestId("tab-config")).toBeInTheDocument()
expect(screen.getByTestId("tab-command")).toBeInTheDocument()
expect(screen.getByTestId("tab-logs")).toBeInTheDocument()
// Check Config tab content (visible by default usually or we can check panels exist)
expect(screen.getByText(mockHook.event)).toBeInTheDocument()
expect(screen.getByText(mockHook.matcher!)).toBeInTheDocument()
// Check Command tab content
expect(screen.getByText(mockHook.commandPreview)).toBeInTheDocument()
// Click to collapse
@ -159,7 +185,7 @@ describe("HooksSettings", () => {
expect(screen.queryByText(mockHook.event)).not.toBeInTheDocument()
})
it("shows per-hook logs in expanded view", () => {
it("shows per-hook logs in Logs tab", () => {
const mockHook: HookInfo = {
id: "hook-1",
event: "before_execute_command",
@ -192,12 +218,82 @@ describe("HooksSettings", () => {
const hookHeader = screen.getByText(mockHook.id).closest("div")
fireEvent.click(hookHeader!)
// Logs section should be visible
expect(screen.getByText("settings:hooks.logs")).toBeInTheDocument()
// Find Logs tab panel content
expect(screen.getByTestId("panel-view-logs")).toBeInTheDocument()
expect(screen.getByText(mockRecord.toolName!)).toBeInTheDocument()
expect(screen.getByText("settings:hooks.status.completed")).toBeInTheDocument()
})
it("renders command preview with wrapping enabled (no truncation)", () => {
const longCommand = "long_command_".repeat(20)
const mockHook: HookInfo = {
id: "hook-1",
event: "before_execute_command",
matcher: "git*",
commandPreview: longCommand,
enabled: true,
source: "project",
timeout: 30,
}
currentHooksState = {
enabledHooks: [mockHook],
executionHistory: [],
hasProjectHooks: false,
}
render(<HooksSettings />)
// Expand hook
const hookHeader = screen.getByText(mockHook.id).closest("div")
fireEvent.click(hookHeader!)
const commandCode = screen.getByTestId(`command-preview-${mockHook.id}`)
expect(commandCode).toHaveClass("whitespace-pre-wrap")
expect(commandCode).toHaveClass("break-words")
expect(commandCode).not.toHaveClass("truncate")
expect(commandCode).toHaveTextContent(longCommand)
expect(commandCode.textContent).not.toContain("...")
})
it("renders log items with wrapping enabled (no truncation)", () => {
const mockHook: HookInfo = {
id: "hook-1",
event: "before_execute_command",
commandPreview: "echo test",
enabled: true,
source: "global",
timeout: 30,
}
const mockRecord: HookExecutionRecord = {
timestamp: new Date().toISOString(),
hookId: "hook-1",
event: "before_execute_command",
toolName: "very_long_tool_name_that_should_not_be_truncated_" + "x".repeat(20),
exitCode: 0,
duration: 150,
timedOut: false,
blocked: false,
}
currentHooksState = {
enabledHooks: [mockHook],
executionHistory: [mockRecord],
hasProjectHooks: false,
}
render(<HooksSettings />)
// Expand hook
const hookHeader = screen.getByText(mockHook.id).closest("div")
fireEvent.click(hookHeader!)
const toolName = screen.getByTestId("log-tool-name")
expect(toolName).toHaveClass("break-words")
expect(toolName).not.toHaveClass("truncate")
})
it("filters logs per hook correctly", () => {
const mockHook1: HookInfo = {
id: "hook-1",
@ -280,7 +376,7 @@ describe("HooksSettings", () => {
fireEvent.click(hookHeader!)
// Should show no logs message
expect(screen.getByText("settings:hooks.noLogsForHook")).toBeInTheDocument()
expect(screen.getAllByText("settings:hooks.noLogsForHook")[0]).toBeInTheDocument()
})
it("shows project hooks warning when hasProjectHooks is true", () => {

View file

@ -83,6 +83,11 @@
"failed": "Failed",
"blocked": "Blocked",
"timeout": "Timeout"
},
"tabs": {
"config": "Config",
"command": "Command",
"logs": "Logs"
}
},
"about": {