@grackle-ai/web-components 0.107.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.rush/temp/3ae72563f781afd72723475938136f113846603e.untar.log +10 -0
- package/.rush/temp/bc1d5bf9201ce71abeaeaddd096deb9b0805d703.untar.log +10 -0
- package/.rush/temp/operation/_phase_build/all.log +18 -0
- package/.rush/temp/operation/_phase_build/log-chunks.jsonl +18 -0
- package/.rush/temp/operation/_phase_build/state.json +3 -0
- package/.rush/temp/operation/_phase_test/all.log +121 -0
- package/.rush/temp/operation/_phase_test/log-chunks.jsonl +121 -0
- package/.rush/temp/operation/_phase_test/state.json +3 -0
- package/.rush/temp/shrinkwrap-deps.json +938 -0
- package/.storybook/main.ts +22 -0
- package/.storybook/preview.tsx +30 -0
- package/config/rig.json +4 -0
- package/config/rush-project.json +12 -0
- package/dist/index.css +1 -0
- package/dist/index.js +39221 -0
- package/eslint.config.cjs +5 -0
- package/package.json +83 -0
- package/rush-logs/web-components._phase_build.cache.log +4 -0
- package/rush-logs/web-components._phase_test.cache.log +4 -0
- package/src/components/chat/ChatInput.module.scss +81 -0
- package/src/components/chat/ChatInput.stories.tsx +91 -0
- package/src/components/chat/ChatInput.tsx +168 -0
- package/src/components/chat/index.ts +6 -0
- package/src/components/dag/DagView.module.scss +149 -0
- package/src/components/dag/DagView.stories.tsx +125 -0
- package/src/components/dag/DagView.tsx +109 -0
- package/src/components/dag/TaskNode.stories.tsx +133 -0
- package/src/components/dag/TaskNode.tsx +40 -0
- package/src/components/dag/useDagLayout.ts +139 -0
- package/src/components/display/Breadcrumbs.module.scss +71 -0
- package/src/components/display/Breadcrumbs.stories.tsx +80 -0
- package/src/components/display/Breadcrumbs.tsx +46 -0
- package/src/components/display/Button.module.scss +110 -0
- package/src/components/display/Button.stories.tsx +88 -0
- package/src/components/display/Button.tsx +40 -0
- package/src/components/display/ConfirmDialog.module.scss +67 -0
- package/src/components/display/ConfirmDialog.stories.tsx +81 -0
- package/src/components/display/ConfirmDialog.tsx +88 -0
- package/src/components/display/CopyButton.module.scss +41 -0
- package/src/components/display/CopyButton.stories.tsx +78 -0
- package/src/components/display/CopyButton.tsx +64 -0
- package/src/components/display/DemoBanner.module.scss +37 -0
- package/src/components/display/DemoBanner.stories.tsx +40 -0
- package/src/components/display/DemoBanner.tsx +23 -0
- package/src/components/display/EventHoverRow.module.scss +102 -0
- package/src/components/display/EventHoverRow.stories.tsx +99 -0
- package/src/components/display/EventHoverRow.tsx +154 -0
- package/src/components/display/EventRenderer.module.scss +272 -0
- package/src/components/display/EventRenderer.stories.tsx +186 -0
- package/src/components/display/EventRenderer.tsx +271 -0
- package/src/components/display/EventStream.module.scss +93 -0
- package/src/components/display/EventStream.stories.tsx +249 -0
- package/src/components/display/EventStream.tsx +369 -0
- package/src/components/display/FloatingActionBar.module.scss +107 -0
- package/src/components/display/FloatingActionBar.stories.tsx +122 -0
- package/src/components/display/FloatingActionBar.tsx +119 -0
- package/src/components/display/SessionAttemptSelector.module.scss +50 -0
- package/src/components/display/SessionAttemptSelector.stories.tsx +78 -0
- package/src/components/display/SessionAttemptSelector.tsx +49 -0
- package/src/components/display/SessionPicker.module.scss +200 -0
- package/src/components/display/SessionPicker.stories.tsx +169 -0
- package/src/components/display/SessionPicker.tsx +214 -0
- package/src/components/display/Skeleton.module.scss +58 -0
- package/src/components/display/Skeleton.stories.tsx +94 -0
- package/src/components/display/Skeleton.tsx +127 -0
- package/src/components/display/Spinner.module.scss +41 -0
- package/src/components/display/Spinner.stories.tsx +66 -0
- package/src/components/display/Spinner.tsx +32 -0
- package/src/components/display/SplashScreen.module.scss +20 -0
- package/src/components/display/SplashScreen.stories.tsx +26 -0
- package/src/components/display/SplashScreen.tsx +16 -0
- package/src/components/display/SplitButton.module.scss +166 -0
- package/src/components/display/SplitButton.stories.tsx +95 -0
- package/src/components/display/SplitButton.tsx +128 -0
- package/src/components/display/Tooltip.module.scss +84 -0
- package/src/components/display/Tooltip.stories.tsx +240 -0
- package/src/components/display/Tooltip.tsx +184 -0
- package/src/components/display/extractText.test.tsx +48 -0
- package/src/components/display/index.ts +20 -0
- package/src/components/editable/EditableCheckbox.stories.tsx +54 -0
- package/src/components/editable/EditableCheckbox.tsx +39 -0
- package/src/components/editable/EditableField.module.scss +135 -0
- package/src/components/editable/EditableSelect.tsx +164 -0
- package/src/components/editable/EditableTextArea.stories.tsx +50 -0
- package/src/components/editable/EditableTextArea.tsx +148 -0
- package/src/components/editable/EditableTextField.stories.tsx +62 -0
- package/src/components/editable/EditableTextField.tsx +153 -0
- package/src/components/editable/EnvironmentSelect.module.scss +17 -0
- package/src/components/editable/EnvironmentSelect.stories.tsx +61 -0
- package/src/components/editable/EnvironmentSelect.tsx +87 -0
- package/src/components/editable/index.ts +13 -0
- package/src/components/editable/useEditableField.test.tsx +233 -0
- package/src/components/editable/useEditableField.ts +173 -0
- package/src/components/index.ts +20 -0
- package/src/components/knowledge/KnowledgeDetailPanel.module.scss +162 -0
- package/src/components/knowledge/KnowledgeDetailPanel.stories.tsx +208 -0
- package/src/components/knowledge/KnowledgeDetailPanel.tsx +122 -0
- package/src/components/knowledge/KnowledgeGraph.module.scss +110 -0
- package/src/components/knowledge/KnowledgeGraph.stories.tsx +180 -0
- package/src/components/knowledge/KnowledgeGraph.tsx +455 -0
- package/src/components/knowledge/KnowledgeNav.module.scss +130 -0
- package/src/components/knowledge/KnowledgeNav.stories.tsx +108 -0
- package/src/components/knowledge/KnowledgeNav.tsx +138 -0
- package/src/components/knowledge/index.ts +3 -0
- package/src/components/layout/AppNav.module.scss +82 -0
- package/src/components/layout/AppNav.stories.tsx +115 -0
- package/src/components/layout/AppNav.tsx +133 -0
- package/src/components/layout/BottomStatusBar.module.scss +58 -0
- package/src/components/layout/BottomStatusBar.stories.tsx +35 -0
- package/src/components/layout/BottomStatusBar.tsx +206 -0
- package/src/components/layout/Sidebar.module.scss +60 -0
- package/src/components/layout/Sidebar.stories.tsx +46 -0
- package/src/components/layout/Sidebar.tsx +84 -0
- package/src/components/layout/StatusBar.module.scss +108 -0
- package/src/components/layout/StatusBar.stories.tsx +119 -0
- package/src/components/layout/StatusBar.tsx +70 -0
- package/src/components/layout/index.ts +9 -0
- package/src/components/lists/EnvironmentNav.module.scss +118 -0
- package/src/components/lists/EnvironmentNav.stories.tsx +121 -0
- package/src/components/lists/EnvironmentNav.tsx +133 -0
- package/src/components/lists/FindingsNav.module.scss +126 -0
- package/src/components/lists/FindingsNav.tsx +146 -0
- package/src/components/lists/TaskList.module.scss +206 -0
- package/src/components/lists/TaskList.stories.tsx +401 -0
- package/src/components/lists/TaskList.tsx +509 -0
- package/src/components/lists/index.ts +6 -0
- package/src/components/lists/listHelpers.tsx +130 -0
- package/src/components/notifications/Callout.module.scss +83 -0
- package/src/components/notifications/Callout.stories.tsx +81 -0
- package/src/components/notifications/Callout.tsx +64 -0
- package/src/components/notifications/Toast.module.scss +86 -0
- package/src/components/notifications/Toast.stories.tsx +71 -0
- package/src/components/notifications/Toast.tsx +51 -0
- package/src/components/notifications/ToastContainer.module.scss +23 -0
- package/src/components/notifications/ToastContainer.stories.tsx +66 -0
- package/src/components/notifications/ToastContainer.tsx +29 -0
- package/src/components/notifications/UpdateBanner.stories.tsx +77 -0
- package/src/components/notifications/UpdateBanner.test.tsx +64 -0
- package/src/components/notifications/UpdateBanner.tsx +44 -0
- package/src/components/notifications/index.ts +8 -0
- package/src/components/panels/AboutPanel.stories.tsx +70 -0
- package/src/components/panels/AboutPanel.tsx +66 -0
- package/src/components/panels/AppearancePanel.stories.tsx +45 -0
- package/src/components/panels/AppearancePanel.tsx +97 -0
- package/src/components/panels/CredentialProvidersPanel.stories.tsx +62 -0
- package/src/components/panels/CredentialProvidersPanel.tsx +111 -0
- package/src/components/panels/EnvironmentEditPanel.module.scss +170 -0
- package/src/components/panels/EnvironmentEditPanel.stories.tsx +206 -0
- package/src/components/panels/EnvironmentEditPanel.tsx +785 -0
- package/src/components/panels/FindingsPanel.module.scss +94 -0
- package/src/components/panels/FindingsPanel.stories.tsx +109 -0
- package/src/components/panels/FindingsPanel.tsx +76 -0
- package/src/components/panels/KeyboardShortcutsPanel.module.scss +65 -0
- package/src/components/panels/KeyboardShortcutsPanel.stories.tsx +40 -0
- package/src/components/panels/KeyboardShortcutsPanel.tsx +104 -0
- package/src/components/panels/PluginsPanel.tsx +77 -0
- package/src/components/panels/SettingsPanel.module.scss +336 -0
- package/src/components/panels/TaskActionButtons.module.scss +22 -0
- package/src/components/panels/TaskActionButtons.stories.tsx +125 -0
- package/src/components/panels/TaskActionButtons.tsx +87 -0
- package/src/components/panels/TaskEditPanel.module.scss +202 -0
- package/src/components/panels/TaskEditPanel.stories.tsx +75 -0
- package/src/components/panels/TaskEditPanel.tsx +328 -0
- package/src/components/panels/TaskOverviewPanel.module.scss +236 -0
- package/src/components/panels/TaskOverviewPanel.stories.tsx +219 -0
- package/src/components/panels/TaskOverviewPanel.tsx +270 -0
- package/src/components/panels/TokensPanel.stories.tsx +131 -0
- package/src/components/panels/TokensPanel.tsx +143 -0
- package/src/components/panels/WorkpadPanel.module.scss +39 -0
- package/src/components/panels/WorkpadPanel.stories.tsx +56 -0
- package/src/components/panels/WorkpadPanel.tsx +63 -0
- package/src/components/panels/index.ts +13 -0
- package/src/components/personas/McpToolSelector.module.scss +109 -0
- package/src/components/personas/McpToolSelector.stories.tsx +129 -0
- package/src/components/personas/McpToolSelector.tsx +180 -0
- package/src/components/personas/PersonaManager.module.scss +233 -0
- package/src/components/personas/PersonaManager.stories.tsx +139 -0
- package/src/components/personas/PersonaManager.tsx +122 -0
- package/src/components/schedules/ScheduleManager.module.scss +98 -0
- package/src/components/schedules/ScheduleManager.stories.tsx +78 -0
- package/src/components/schedules/ScheduleManager.tsx +160 -0
- package/src/components/settings/SettingsNav.module.scss +82 -0
- package/src/components/settings/SettingsNav.stories.tsx +83 -0
- package/src/components/settings/SettingsNav.tsx +104 -0
- package/src/components/streams/StreamDetailPanel.module.scss +206 -0
- package/src/components/streams/StreamDetailPanel.stories.tsx +132 -0
- package/src/components/streams/StreamDetailPanel.tsx +119 -0
- package/src/components/streams/StreamList.module.scss +92 -0
- package/src/components/streams/StreamList.stories.tsx +99 -0
- package/src/components/streams/StreamList.tsx +114 -0
- package/src/components/streams/index.ts +10 -0
- package/src/components/tools/AgentToolCard.module.scss +118 -0
- package/src/components/tools/AgentToolCard.stories.tsx +304 -0
- package/src/components/tools/AgentToolCard.tsx +247 -0
- package/src/components/tools/FileEditCard.stories.tsx +138 -0
- package/src/components/tools/FileEditCard.tsx +160 -0
- package/src/components/tools/FileReadCard.stories.tsx +120 -0
- package/src/components/tools/FileReadCard.tsx +106 -0
- package/src/components/tools/FindingCard.stories.tsx +124 -0
- package/src/components/tools/FindingCard.tsx +178 -0
- package/src/components/tools/GenericToolCard.stories.tsx +80 -0
- package/src/components/tools/GenericToolCard.tsx +111 -0
- package/src/components/tools/IpcCard.stories.tsx +129 -0
- package/src/components/tools/IpcCard.tsx +178 -0
- package/src/components/tools/KnowledgeCard.stories.tsx +112 -0
- package/src/components/tools/KnowledgeCard.tsx +165 -0
- package/src/components/tools/MetadataCard.stories.tsx +32 -0
- package/src/components/tools/MetadataCard.tsx +39 -0
- package/src/components/tools/SearchCard.stories.tsx +74 -0
- package/src/components/tools/SearchCard.tsx +86 -0
- package/src/components/tools/ShellCard.stories.tsx +112 -0
- package/src/components/tools/ShellCard.tsx +106 -0
- package/src/components/tools/TaskCard.stories.tsx +123 -0
- package/src/components/tools/TaskCard.tsx +203 -0
- package/src/components/tools/TodoCard.module.scss +131 -0
- package/src/components/tools/TodoCard.stories.tsx +202 -0
- package/src/components/tools/TodoCard.tsx +200 -0
- package/src/components/tools/ToolCard.stories.tsx +177 -0
- package/src/components/tools/ToolCard.tsx +60 -0
- package/src/components/tools/ToolCardProps.ts +20 -0
- package/src/components/tools/ToolSearchCard.stories.tsx +81 -0
- package/src/components/tools/ToolSearchCard.tsx +86 -0
- package/src/components/tools/WorkpadCard.stories.tsx +106 -0
- package/src/components/tools/WorkpadCard.tsx +125 -0
- package/src/components/tools/classifyTool.test.ts +44 -0
- package/src/components/tools/classifyTool.ts +134 -0
- package/src/components/tools/parseDiff.ts +95 -0
- package/src/components/tools/parseShellOutput.ts +28 -0
- package/src/components/tools/toolCardHelpers.test.ts +53 -0
- package/src/components/tools/toolCards.module.scss +234 -0
- package/src/components/workspace/WorkspaceBoard.module.scss +238 -0
- package/src/components/workspace/WorkspaceBoard.stories.tsx +240 -0
- package/src/components/workspace/WorkspaceBoard.tsx +232 -0
- package/src/components/workspace/WorkspaceFormFields.module.scss +79 -0
- package/src/components/workspace/WorkspaceFormFields.stories.tsx +133 -0
- package/src/components/workspace/WorkspaceFormFields.tsx +185 -0
- package/src/context/GrackleContext.ts +28 -0
- package/src/context/GrackleContextTypes.ts +64 -0
- package/src/context/SidebarContext.tsx +53 -0
- package/src/context/ThemeContext.tsx +21 -0
- package/src/context/ToastContext.tsx +56 -0
- package/src/hooks/types.ts +864 -0
- package/src/hooks/useEventSelection.test.ts +204 -0
- package/src/hooks/useEventSelection.ts +158 -0
- package/src/hooks/useSmartScroll.ts +151 -0
- package/src/hooks/useTheme.ts +228 -0
- package/src/index.ts +210 -0
- package/src/mocks/MockGrackleProvider.tsx +1397 -0
- package/src/mocks/mockData.ts +1966 -0
- package/src/mocks/mockKnowledgeData.ts +294 -0
- package/src/scss.d.ts +12 -0
- package/src/styles/global.scss +244 -0
- package/src/styles/mixins.scss +278 -0
- package/src/styles/prism-theme.scss +148 -0
- package/src/styles/theme.scss +1102 -0
- package/src/test-utils/storybook-decorators.tsx +50 -0
- package/src/test-utils/storybook-helpers.ts +262 -0
- package/src/themes.ts +142 -0
- package/src/utils/boardColumns.ts +141 -0
- package/src/utils/breadcrumbs.test.ts +285 -0
- package/src/utils/breadcrumbs.ts +222 -0
- package/src/utils/dashboard.test.ts +156 -0
- package/src/utils/dashboard.ts +195 -0
- package/src/utils/eventContent.test.ts +353 -0
- package/src/utils/eventContent.ts +209 -0
- package/src/utils/findingCategory.ts +33 -0
- package/src/utils/format.ts +27 -0
- package/src/utils/iconSize.ts +18 -0
- package/src/utils/navigation.ts +205 -0
- package/src/utils/route-config.test.ts +128 -0
- package/src/utils/scrollUtils.test.ts +65 -0
- package/src/utils/scrollUtils.ts +49 -0
- package/src/utils/sessionEvents.test.ts +302 -0
- package/src/utils/sessionEvents.ts +233 -0
- package/src/utils/taskStatus.tsx +137 -0
- package/src/utils/time.ts +92 -0
- package/tsconfig.json +8 -0
- package/vite.config.ts +20 -0
- package/vitest.config.ts +10 -0
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import { useState, type JSX } from "react";
|
|
2
|
+
import { ChevronRight, Pencil } from "lucide-react";
|
|
3
|
+
import type { ToolCardProps } from "./ToolCardProps.js";
|
|
4
|
+
import { parseUnifiedDiff, diffFromOldNew, diffStats, type DiffLine } from "./parseDiff.js";
|
|
5
|
+
import { ICON_SM, ICON_MD } from "../../utils/iconSize.js";
|
|
6
|
+
import styles from "./toolCards.module.scss";
|
|
7
|
+
|
|
8
|
+
/** Extracts file path from edit tool args (handles `file_path`, `path` variants). */
|
|
9
|
+
function getFilePath(args: unknown): string {
|
|
10
|
+
if (args === null || args === undefined || typeof args !== "object") {
|
|
11
|
+
return "";
|
|
12
|
+
}
|
|
13
|
+
const a = args as Record<string, unknown>;
|
|
14
|
+
return (typeof a.file_path === "string" && a.file_path)
|
|
15
|
+
|| (typeof a.path === "string" && a.path)
|
|
16
|
+
|| "";
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Extracts old/new string pair from args (handles Claude Code and Copilot field names). */
|
|
20
|
+
function getOldNew(args: unknown): { oldStr: string; newStr: string } | undefined {
|
|
21
|
+
if (args === undefined || typeof args !== "object" || args === null) {
|
|
22
|
+
return undefined;
|
|
23
|
+
}
|
|
24
|
+
const a = args as Record<string, unknown>;
|
|
25
|
+
const oldStr: string | undefined = (typeof a.old_string === "string" ? a.old_string : undefined)
|
|
26
|
+
?? (typeof a.old_str === "string" ? a.old_str : undefined);
|
|
27
|
+
const newStr: string | undefined = (typeof a.new_string === "string" ? a.new_string : undefined)
|
|
28
|
+
?? (typeof a.new_str === "string" ? a.new_str : undefined);
|
|
29
|
+
if (oldStr !== undefined && newStr !== undefined) {
|
|
30
|
+
return { oldStr, newStr };
|
|
31
|
+
}
|
|
32
|
+
return undefined;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Extracts the basename from a file path. */
|
|
36
|
+
function basename(filePath: string): string {
|
|
37
|
+
const parts = filePath.split(/[/\\]/);
|
|
38
|
+
return parts[parts.length - 1] || filePath;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Resolves diff lines from available data sources.
|
|
43
|
+
*
|
|
44
|
+
* Priority: detailedResult (unified diff) > args old/new strings > null.
|
|
45
|
+
*/
|
|
46
|
+
function resolveDiff(args: unknown, detailedResult?: string): DiffLine[] | undefined {
|
|
47
|
+
// 1. Try detailedResult as unified diff
|
|
48
|
+
if (detailedResult) {
|
|
49
|
+
// Copilot embeds diff in a JSON object sometimes
|
|
50
|
+
let diffText = detailedResult;
|
|
51
|
+
// Only attempt JSON parse if it looks like a JSON object (avoids throwing
|
|
52
|
+
// on unified diff strings which are the common case)
|
|
53
|
+
if (detailedResult.trimStart().startsWith("{")) {
|
|
54
|
+
try {
|
|
55
|
+
const parsed = JSON.parse(detailedResult) as Record<string, unknown>;
|
|
56
|
+
if (typeof parsed.detailedContent === "string") {
|
|
57
|
+
diffText = parsed.detailedContent;
|
|
58
|
+
}
|
|
59
|
+
} catch { /* not valid JSON despite looking like one — use as-is */ }
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if (diffText.includes("@@") || diffText.startsWith("diff ")) {
|
|
63
|
+
const lines = parseUnifiedDiff(diffText);
|
|
64
|
+
if (lines.length > 0) {
|
|
65
|
+
return lines;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// 2. Try old/new string pair from args
|
|
71
|
+
const oldNew = getOldNew(args);
|
|
72
|
+
if (oldNew) {
|
|
73
|
+
return diffFromOldNew(oldNew.oldStr, oldNew.newStr);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return undefined;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Number of diff lines shown when collapsed. */
|
|
80
|
+
const PREVIEW_LINES: number = 5;
|
|
81
|
+
|
|
82
|
+
/** Renders a file edit tool call with a unified diff view. */
|
|
83
|
+
export function FileEditCard({ tool, args, result, isError, detailedResult }: ToolCardProps): JSX.Element {
|
|
84
|
+
const [expanded, setExpanded] = useState(false);
|
|
85
|
+
const filePath = getFilePath(args);
|
|
86
|
+
const name = basename(filePath);
|
|
87
|
+
const inProgress = result === undefined;
|
|
88
|
+
|
|
89
|
+
const diffLines = resolveDiff(args, detailedResult);
|
|
90
|
+
const stats = diffLines ? diffStats(diffLines) : null;
|
|
91
|
+
const hasMore = (diffLines?.length ?? 0) > PREVIEW_LINES;
|
|
92
|
+
const displayLines = expanded ? diffLines : diffLines?.slice(0, PREVIEW_LINES);
|
|
93
|
+
|
|
94
|
+
return (
|
|
95
|
+
<div
|
|
96
|
+
className={`${styles.card} ${isError ? styles.cardRed : styles.cardOrange} ${inProgress ? styles.inProgress : ""}`}
|
|
97
|
+
data-testid="tool-card-file-edit"
|
|
98
|
+
>
|
|
99
|
+
<div className={styles.header}>
|
|
100
|
+
<span className={styles.icon}><Pencil size={ICON_MD} /></span>
|
|
101
|
+
<span className={styles.toolName} style={{ color: "var(--accent-yellow)" }}>{tool}</span>
|
|
102
|
+
{name && (
|
|
103
|
+
<span className={styles.fileName} title={filePath}>{name}</span>
|
|
104
|
+
)}
|
|
105
|
+
{stats && (
|
|
106
|
+
<>
|
|
107
|
+
<span className={styles.spacer} />
|
|
108
|
+
<span className={styles.badge} data-testid="tool-card-diff-stats">
|
|
109
|
+
<span style={{ color: "var(--accent-green)" }}>+{stats.added}</span>
|
|
110
|
+
{" "}
|
|
111
|
+
<span style={{ color: "var(--accent-red)" }}>−{stats.removed}</span>
|
|
112
|
+
</span>
|
|
113
|
+
</>
|
|
114
|
+
)}
|
|
115
|
+
</div>
|
|
116
|
+
|
|
117
|
+
{isError && result && (
|
|
118
|
+
<pre className={styles.pre} data-testid="tool-card-error">
|
|
119
|
+
{result}
|
|
120
|
+
</pre>
|
|
121
|
+
)}
|
|
122
|
+
|
|
123
|
+
{!isError && displayLines && displayLines.length > 0 && (
|
|
124
|
+
<>
|
|
125
|
+
<pre className={styles.pre} data-testid="tool-card-diff">
|
|
126
|
+
{displayLines.map((line, i) => {
|
|
127
|
+
let lineClass = styles.diffContext;
|
|
128
|
+
if (line.type === "add") { lineClass = styles.diffAdd; }
|
|
129
|
+
if (line.type === "remove") { lineClass = styles.diffRemove; }
|
|
130
|
+
if (line.type === "header") { lineClass = styles.diffHeader; }
|
|
131
|
+
return (
|
|
132
|
+
<span key={i} className={`${styles.diffLine} ${lineClass}`}>
|
|
133
|
+
{line.content}
|
|
134
|
+
</span>
|
|
135
|
+
);
|
|
136
|
+
})}
|
|
137
|
+
</pre>
|
|
138
|
+
{hasMore && (
|
|
139
|
+
<button
|
|
140
|
+
type="button"
|
|
141
|
+
className={styles.bodyToggle}
|
|
142
|
+
onClick={() => { setExpanded((v) => !v); }}
|
|
143
|
+
aria-expanded={expanded}
|
|
144
|
+
data-testid="tool-card-toggle"
|
|
145
|
+
>
|
|
146
|
+
<span className={`${styles.chevron} ${expanded ? styles.chevronExpanded : ""}`} aria-hidden="true"><ChevronRight size={ICON_SM} /></span>
|
|
147
|
+
{expanded ? "collapse" : `${(diffLines?.length ?? 0) - PREVIEW_LINES} more lines`}
|
|
148
|
+
</button>
|
|
149
|
+
)}
|
|
150
|
+
</>
|
|
151
|
+
)}
|
|
152
|
+
|
|
153
|
+
{!isError && !diffLines && !inProgress && result && (
|
|
154
|
+
<pre className={styles.pre} data-testid="tool-card-content">
|
|
155
|
+
{result}
|
|
156
|
+
</pre>
|
|
157
|
+
)}
|
|
158
|
+
</div>
|
|
159
|
+
);
|
|
160
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from "@storybook/react";
|
|
2
|
+
import { expect, userEvent } from "@storybook/test";
|
|
3
|
+
import { FileReadCard } from "./FileReadCard.js";
|
|
4
|
+
|
|
5
|
+
const meta: Meta<typeof FileReadCard> = {
|
|
6
|
+
component: FileReadCard,
|
|
7
|
+
title: "Grackle/Tools/FileReadCard",
|
|
8
|
+
tags: ["autodocs"],
|
|
9
|
+
};
|
|
10
|
+
export default meta;
|
|
11
|
+
type Story = StoryObj<typeof meta>;
|
|
12
|
+
|
|
13
|
+
const SAMPLE_FILE: string = `import jwt from "jsonwebtoken";
|
|
14
|
+
import type { Request, Response, NextFunction } from "express";
|
|
15
|
+
|
|
16
|
+
const JWT_SECRET = process.env.JWT_SECRET || "change-me";
|
|
17
|
+
|
|
18
|
+
export function verifyToken(req: Request, res: Response, next: NextFunction): void {
|
|
19
|
+
const header = req.headers.authorization;
|
|
20
|
+
if (!header?.startsWith("Bearer ")) {
|
|
21
|
+
res.status(401).json({ error: "Missing token" });
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
try {
|
|
25
|
+
const decoded = jwt.verify(header.slice(7), JWT_SECRET);
|
|
26
|
+
req.user = decoded;
|
|
27
|
+
next();
|
|
28
|
+
} catch {
|
|
29
|
+
res.status(403).json({ error: "Invalid token" });
|
|
30
|
+
}
|
|
31
|
+
}`;
|
|
32
|
+
|
|
33
|
+
export const WithContent: Story = {
|
|
34
|
+
args: {
|
|
35
|
+
tool: "Read",
|
|
36
|
+
args: { file_path: "/src/middleware/auth.ts" },
|
|
37
|
+
result: SAMPLE_FILE,
|
|
38
|
+
},
|
|
39
|
+
play: async ({ canvas }) => {
|
|
40
|
+
await expect(canvas.getByTestId("tool-card-file-read")).toBeInTheDocument();
|
|
41
|
+
await expect(canvas.getByText("auth.ts")).toBeInTheDocument();
|
|
42
|
+
await expect(canvas.getByText("19 lines")).toBeInTheDocument();
|
|
43
|
+
// Should show toggle for >5 lines
|
|
44
|
+
await expect(canvas.getByTestId("tool-card-toggle")).toBeInTheDocument();
|
|
45
|
+
},
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
export const ExpandCollapse: Story = {
|
|
49
|
+
args: {
|
|
50
|
+
tool: "Read",
|
|
51
|
+
args: { file_path: "/src/middleware/auth.ts" },
|
|
52
|
+
result: SAMPLE_FILE,
|
|
53
|
+
},
|
|
54
|
+
play: async ({ canvas }) => {
|
|
55
|
+
// Initially collapsed - line 10 content should not be visible
|
|
56
|
+
const content = canvas.getByTestId("tool-card-content");
|
|
57
|
+
await expect(content.textContent).not.toContain("const decoded");
|
|
58
|
+
|
|
59
|
+
// Click toggle to expand
|
|
60
|
+
const toggle = canvas.getByTestId("tool-card-toggle");
|
|
61
|
+
await userEvent.click(toggle);
|
|
62
|
+
|
|
63
|
+
// Now line 13 content should be visible
|
|
64
|
+
await expect(content.textContent).toContain("const decoded");
|
|
65
|
+
|
|
66
|
+
// Click again to collapse
|
|
67
|
+
await userEvent.click(toggle);
|
|
68
|
+
await expect(content.textContent).not.toContain("const decoded");
|
|
69
|
+
},
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
export const ShortFile: Story = {
|
|
73
|
+
args: {
|
|
74
|
+
tool: "view",
|
|
75
|
+
args: { path: "C:\\Users\\nickp\\src\\config.json" },
|
|
76
|
+
result: '{\n "port": 3000\n}',
|
|
77
|
+
},
|
|
78
|
+
play: async ({ canvas }) => {
|
|
79
|
+
await expect(canvas.getByText("config.json")).toBeInTheDocument();
|
|
80
|
+
await expect(canvas.getByText("3 lines")).toBeInTheDocument();
|
|
81
|
+
// No toggle for <=5 lines
|
|
82
|
+
await expect(canvas.queryByTestId("tool-card-toggle")).not.toBeInTheDocument();
|
|
83
|
+
},
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
export const InProgress: Story = {
|
|
87
|
+
args: {
|
|
88
|
+
tool: "Read",
|
|
89
|
+
args: { file_path: "/src/index.ts" },
|
|
90
|
+
// No result - still loading
|
|
91
|
+
},
|
|
92
|
+
play: async ({ canvas }) => {
|
|
93
|
+
const card = canvas.getByTestId("tool-card-file-read");
|
|
94
|
+
await expect(card.className).toContain("inProgress");
|
|
95
|
+
await expect(canvas.getByText("index.ts")).toBeInTheDocument();
|
|
96
|
+
await expect(canvas.queryByTestId("tool-card-content")).not.toBeInTheDocument();
|
|
97
|
+
},
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
export const ErrorResult: Story = {
|
|
101
|
+
args: {
|
|
102
|
+
tool: "Read",
|
|
103
|
+
args: { file_path: "/nonexistent/file.ts" },
|
|
104
|
+
result: "Error: ENOENT: no such file or directory",
|
|
105
|
+
isError: true,
|
|
106
|
+
},
|
|
107
|
+
play: async ({ canvas }) => {
|
|
108
|
+
const card = canvas.getByTestId("tool-card-file-read");
|
|
109
|
+
await expect(card.className).toContain("cardRed");
|
|
110
|
+
await expect(canvas.getByTestId("tool-card-error")).toBeInTheDocument();
|
|
111
|
+
},
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
export const EmptyFile: Story = {
|
|
115
|
+
args: {
|
|
116
|
+
tool: "Read",
|
|
117
|
+
args: { file_path: "/src/empty.ts" },
|
|
118
|
+
result: "",
|
|
119
|
+
},
|
|
120
|
+
};
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { useState, type JSX, type ReactNode } from "react";
|
|
2
|
+
import { ChevronRight, FilePen, FileText } from "lucide-react";
|
|
3
|
+
import type { ToolCardProps } from "./ToolCardProps.js";
|
|
4
|
+
import { ICON_SM, ICON_MD } from "../../utils/iconSize.js";
|
|
5
|
+
import styles from "./toolCards.module.scss";
|
|
6
|
+
|
|
7
|
+
/** Extracts the file path from tool args (handles both `file_path` and `path` variants). */
|
|
8
|
+
function getFilePath(args: unknown): string {
|
|
9
|
+
if (args === null || args === undefined || typeof args !== "object") {
|
|
10
|
+
return "";
|
|
11
|
+
}
|
|
12
|
+
const a = args as Record<string, unknown>;
|
|
13
|
+
if (typeof a.file_path === "string") {
|
|
14
|
+
return a.file_path;
|
|
15
|
+
}
|
|
16
|
+
if (typeof a.path === "string") {
|
|
17
|
+
return a.path;
|
|
18
|
+
}
|
|
19
|
+
return "";
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Extracts the basename from a file path (handles both / and \ separators). */
|
|
23
|
+
function basename(filePath: string): string {
|
|
24
|
+
const parts = filePath.split(/[/\\]/);
|
|
25
|
+
return parts[parts.length - 1] || filePath;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Number of preview lines shown when collapsed. */
|
|
29
|
+
const PREVIEW_LINES: number = 5;
|
|
30
|
+
|
|
31
|
+
/** Extra props for FileReadCard to support write variant styling. */
|
|
32
|
+
interface FileReadCardProps extends ToolCardProps {
|
|
33
|
+
/** When true, uses green accent and write icon instead of blue/read. */
|
|
34
|
+
writeVariant?: boolean;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Renders a file read/write tool call with syntax-highlighted content preview. */
|
|
38
|
+
export function FileReadCard({ tool, args, result, isError, writeVariant }: FileReadCardProps): JSX.Element {
|
|
39
|
+
const [expanded, setExpanded] = useState(false);
|
|
40
|
+
const filePath = getFilePath(args);
|
|
41
|
+
const name = basename(filePath);
|
|
42
|
+
const inProgress = result === undefined;
|
|
43
|
+
|
|
44
|
+
const accentClass: string = isError ? styles.cardRed : (writeVariant ? styles.cardGreen : styles.cardBlue);
|
|
45
|
+
const accentColor: string = writeVariant ? "var(--accent-green)" : "var(--accent-blue)";
|
|
46
|
+
const icon: ReactNode = writeVariant ? <FilePen size={ICON_MD} /> : <FileText size={ICON_MD} />;
|
|
47
|
+
const testId: string = writeVariant ? "tool-card-file-write" : "tool-card-file-read";
|
|
48
|
+
|
|
49
|
+
const lines = result?.split("\n") ?? [];
|
|
50
|
+
const hasMore = lines.length > PREVIEW_LINES;
|
|
51
|
+
const displayLines = expanded ? lines : lines.slice(0, PREVIEW_LINES);
|
|
52
|
+
|
|
53
|
+
return (
|
|
54
|
+
<div
|
|
55
|
+
className={`${styles.card} ${accentClass} ${inProgress ? styles.inProgress : ""}`}
|
|
56
|
+
data-testid={testId}
|
|
57
|
+
>
|
|
58
|
+
<div className={styles.header}>
|
|
59
|
+
<span className={styles.icon}>{icon}</span>
|
|
60
|
+
<span className={styles.toolName} style={{ color: accentColor }}>{tool}</span>
|
|
61
|
+
{name && (
|
|
62
|
+
<span className={styles.fileName} title={filePath}>{name}</span>
|
|
63
|
+
)}
|
|
64
|
+
{!inProgress && lines.length > 0 && (
|
|
65
|
+
<>
|
|
66
|
+
<span className={styles.spacer} />
|
|
67
|
+
<span className={styles.badge}>{lines.length} lines</span>
|
|
68
|
+
</>
|
|
69
|
+
)}
|
|
70
|
+
</div>
|
|
71
|
+
|
|
72
|
+
{isError && result && (
|
|
73
|
+
<pre className={styles.pre} data-testid="tool-card-error">
|
|
74
|
+
{result}
|
|
75
|
+
</pre>
|
|
76
|
+
)}
|
|
77
|
+
|
|
78
|
+
{!isError && !inProgress && lines.length > 0 && (
|
|
79
|
+
<>
|
|
80
|
+
<pre className={styles.pre} data-testid="tool-card-content">
|
|
81
|
+
{displayLines.map((line, i) => (
|
|
82
|
+
<span key={i} className={styles.diffLine}>
|
|
83
|
+
<span style={{ color: "var(--text-tertiary)", userSelect: "none", marginRight: "var(--space-sm)", display: "inline-block", width: "3ch", textAlign: "right" }}>
|
|
84
|
+
{i + 1}
|
|
85
|
+
</span>
|
|
86
|
+
{line}
|
|
87
|
+
</span>
|
|
88
|
+
))}
|
|
89
|
+
</pre>
|
|
90
|
+
{hasMore && (
|
|
91
|
+
<button
|
|
92
|
+
type="button"
|
|
93
|
+
className={styles.bodyToggle}
|
|
94
|
+
onClick={() => { setExpanded((v) => !v); }}
|
|
95
|
+
aria-expanded={expanded}
|
|
96
|
+
data-testid="tool-card-toggle"
|
|
97
|
+
>
|
|
98
|
+
<span className={`${styles.chevron} ${expanded ? styles.chevronExpanded : ""}`} aria-hidden="true"><ChevronRight size={ICON_SM} /></span>
|
|
99
|
+
{expanded ? "collapse" : `${lines.length - PREVIEW_LINES} more lines`}
|
|
100
|
+
</button>
|
|
101
|
+
)}
|
|
102
|
+
</>
|
|
103
|
+
)}
|
|
104
|
+
</div>
|
|
105
|
+
);
|
|
106
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from "@storybook/react";
|
|
2
|
+
import { expect } from "@storybook/test";
|
|
3
|
+
import { FindingCard } from "./FindingCard.js";
|
|
4
|
+
|
|
5
|
+
const meta: Meta<typeof FindingCard> = {
|
|
6
|
+
component: FindingCard,
|
|
7
|
+
title: "Tools/FindingCard",
|
|
8
|
+
};
|
|
9
|
+
export default meta;
|
|
10
|
+
type Story = StoryObj<typeof FindingCard>;
|
|
11
|
+
|
|
12
|
+
export const PostInProgress: Story = {
|
|
13
|
+
name: "finding_post - in progress",
|
|
14
|
+
args: {
|
|
15
|
+
tool: "mcp__grackle__finding_post",
|
|
16
|
+
args: {
|
|
17
|
+
title: "Auth middleware stores tokens insecurely",
|
|
18
|
+
category: "bug",
|
|
19
|
+
tags: ["security", "auth"],
|
|
20
|
+
},
|
|
21
|
+
},
|
|
22
|
+
play: async ({ canvas }) => {
|
|
23
|
+
await expect(canvas.getByTestId("tool-card-finding")).toBeInTheDocument();
|
|
24
|
+
await expect(canvas.getByTestId("tool-card-finding-title")).toHaveTextContent("Auth middleware");
|
|
25
|
+
await expect(canvas.getByTestId("tool-card-finding-category")).toHaveTextContent("bug");
|
|
26
|
+
},
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export const PostCompleted: Story = {
|
|
30
|
+
name: "finding_post - completed",
|
|
31
|
+
args: {
|
|
32
|
+
tool: "mcp__grackle__finding_post",
|
|
33
|
+
args: {
|
|
34
|
+
title: "Qdrant catalog naming convention",
|
|
35
|
+
category: "insight",
|
|
36
|
+
tags: ["search", "worktree", "qdrant"],
|
|
37
|
+
},
|
|
38
|
+
result: JSON.stringify({
|
|
39
|
+
id: "589f1e83",
|
|
40
|
+
workspaceId: "default",
|
|
41
|
+
category: "insight",
|
|
42
|
+
title: "Qdrant catalog naming convention",
|
|
43
|
+
content: "The qdrant-search MCP server indexes the codebase under the catalog name \"grackle\". All worktrees share the same codebase, so every semantic search call must pass catalog: \"grackle\".",
|
|
44
|
+
tags: ["search", "worktree", "qdrant"],
|
|
45
|
+
createdAt: "2026-03-28 03:49:15",
|
|
46
|
+
}),
|
|
47
|
+
},
|
|
48
|
+
play: async ({ canvas }) => {
|
|
49
|
+
await expect(canvas.getByTestId("tool-card-finding")).toBeInTheDocument();
|
|
50
|
+
await expect(canvas.getByTestId("tool-card-finding-category")).toHaveTextContent("insight");
|
|
51
|
+
await expect(canvas.getByTestId("tool-card-finding-tags")).toBeInTheDocument();
|
|
52
|
+
await expect(canvas.getByTestId("tool-card-finding-content")).toBeInTheDocument();
|
|
53
|
+
},
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
export const PostCopilotFormat: Story = {
|
|
57
|
+
name: "finding_post - Copilot tool name",
|
|
58
|
+
args: {
|
|
59
|
+
tool: "grackle-finding_post",
|
|
60
|
+
args: {
|
|
61
|
+
title: "Rush worktree usage",
|
|
62
|
+
category: "insight",
|
|
63
|
+
tags: ["workflow"],
|
|
64
|
+
},
|
|
65
|
+
result: JSON.stringify({
|
|
66
|
+
id: "e7091ea6",
|
|
67
|
+
category: "insight",
|
|
68
|
+
title: "Rush worktree usage",
|
|
69
|
+
content: "This codebase uses Rush worktrees for all feature development.",
|
|
70
|
+
tags: ["workflow"],
|
|
71
|
+
createdAt: "2026-03-28 03:53:07",
|
|
72
|
+
}),
|
|
73
|
+
},
|
|
74
|
+
play: async ({ canvas }) => {
|
|
75
|
+
await expect(canvas.getByTestId("tool-card-finding")).toBeInTheDocument();
|
|
76
|
+
// Should show bare tool name, not the full prefixed name
|
|
77
|
+
await expect(canvas.getByText("finding_post")).toBeInTheDocument();
|
|
78
|
+
},
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
export const ListWithResults: Story = {
|
|
82
|
+
name: "finding_list - multiple results",
|
|
83
|
+
args: {
|
|
84
|
+
tool: "mcp__grackle__finding_list",
|
|
85
|
+
args: { limit: 20 },
|
|
86
|
+
result: JSON.stringify([
|
|
87
|
+
{ id: "f1", category: "insight", title: "Qdrant catalog naming" },
|
|
88
|
+
{ id: "f2", category: "bug", title: "Auth token storage issue" },
|
|
89
|
+
{ id: "f3", category: "decision", title: "Use ConnectRPC over ws-bridge" },
|
|
90
|
+
]),
|
|
91
|
+
},
|
|
92
|
+
play: async ({ canvas }) => {
|
|
93
|
+
await expect(canvas.getByTestId("tool-card-finding")).toBeInTheDocument();
|
|
94
|
+
await expect(canvas.getByTestId("tool-card-finding-count")).toHaveTextContent("3 findings");
|
|
95
|
+
await expect(canvas.getByTestId("tool-card-finding-list")).toBeInTheDocument();
|
|
96
|
+
},
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
export const ListEmpty: Story = {
|
|
100
|
+
name: "finding_list - no results",
|
|
101
|
+
args: {
|
|
102
|
+
tool: "mcp__grackle__finding_list",
|
|
103
|
+
args: { category: "bug" },
|
|
104
|
+
result: JSON.stringify([]),
|
|
105
|
+
},
|
|
106
|
+
play: async ({ canvas }) => {
|
|
107
|
+
await expect(canvas.getByTestId("tool-card-finding")).toBeInTheDocument();
|
|
108
|
+
await expect(canvas.getByTestId("tool-card-finding-count")).toHaveTextContent("0 findings");
|
|
109
|
+
},
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
export const ErrorState: Story = {
|
|
113
|
+
name: "finding_post - error",
|
|
114
|
+
args: {
|
|
115
|
+
tool: "mcp__grackle__finding_post",
|
|
116
|
+
args: { title: "Test finding" },
|
|
117
|
+
result: "gRPC error [Internal]: database connection failed",
|
|
118
|
+
isError: true,
|
|
119
|
+
},
|
|
120
|
+
play: async ({ canvas }) => {
|
|
121
|
+
await expect(canvas.getByTestId("tool-card-finding")).toBeInTheDocument();
|
|
122
|
+
await expect(canvas.getByTestId("tool-card-error")).toBeInTheDocument();
|
|
123
|
+
},
|
|
124
|
+
};
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import { useState, type JSX } from "react";
|
|
2
|
+
import type { ToolCardProps } from "./ToolCardProps.js";
|
|
3
|
+
import { extractBareName } from "./classifyTool.js";
|
|
4
|
+
import styles from "./toolCards.module.scss";
|
|
5
|
+
|
|
6
|
+
/** Shape of a single finding in MCP results. */
|
|
7
|
+
interface Finding {
|
|
8
|
+
id?: string;
|
|
9
|
+
title?: string;
|
|
10
|
+
category?: string;
|
|
11
|
+
content?: string;
|
|
12
|
+
tags?: string[];
|
|
13
|
+
createdAt?: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Extracts finding-relevant fields from tool args. */
|
|
17
|
+
function getArgs(args: unknown): { title?: string; category?: string; tags?: string[] } {
|
|
18
|
+
if (args === null || args === undefined || typeof args !== "object") {
|
|
19
|
+
return {};
|
|
20
|
+
}
|
|
21
|
+
const a = args as Record<string, unknown>;
|
|
22
|
+
return {
|
|
23
|
+
title: typeof a.title === "string" ? a.title : undefined,
|
|
24
|
+
category: typeof a.category === "string" ? a.category : undefined,
|
|
25
|
+
tags: Array.isArray(a.tags) ? (a.tags as string[]) : undefined,
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Parses MCP result JSON into a finding or array of findings. */
|
|
30
|
+
function parseResult(result: string | undefined): { single?: Finding; list?: Finding[] } {
|
|
31
|
+
if (!result) {
|
|
32
|
+
return {};
|
|
33
|
+
}
|
|
34
|
+
try {
|
|
35
|
+
const parsed: unknown = JSON.parse(result);
|
|
36
|
+
if (Array.isArray(parsed)) {
|
|
37
|
+
return { list: (parsed as unknown[]).filter((v): v is Finding => v !== null && typeof v === "object") };
|
|
38
|
+
}
|
|
39
|
+
if (typeof parsed === "object" && parsed !== null) {
|
|
40
|
+
return { single: parsed as Finding };
|
|
41
|
+
}
|
|
42
|
+
} catch { /* fall through */ }
|
|
43
|
+
return {};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Number of items shown when collapsed. */
|
|
47
|
+
const PREVIEW_COUNT: number = 5;
|
|
48
|
+
|
|
49
|
+
/** Number of content lines shown when collapsed. */
|
|
50
|
+
const PREVIEW_LINES: number = 5;
|
|
51
|
+
|
|
52
|
+
/** Renders a finding tool call (finding_post, finding_list) with structured display. */
|
|
53
|
+
export function FindingCard({ tool, args, result, isError }: ToolCardProps): JSX.Element {
|
|
54
|
+
const [expanded, setExpanded] = useState(false);
|
|
55
|
+
const bareName = extractBareName(tool);
|
|
56
|
+
const argData = getArgs(args);
|
|
57
|
+
const inProgress = result === undefined;
|
|
58
|
+
const { single, list } = parseResult(result);
|
|
59
|
+
|
|
60
|
+
// Determine title to show in header
|
|
61
|
+
const displayTitle = single?.title ?? argData.title;
|
|
62
|
+
// Only show category badge for single findings, not when displaying a list
|
|
63
|
+
const displayCategory = list ? undefined : (single?.category ?? argData.category);
|
|
64
|
+
const displayTags = list ? undefined : (single?.tags ?? argData.tags);
|
|
65
|
+
|
|
66
|
+
return (
|
|
67
|
+
<div
|
|
68
|
+
className={`${styles.card} ${isError ? styles.cardRed : styles.cardPurple} ${inProgress ? styles.inProgress : ""}`}
|
|
69
|
+
data-testid="tool-card-finding"
|
|
70
|
+
>
|
|
71
|
+
<div className={styles.header}>
|
|
72
|
+
<span className={styles.icon} aria-hidden="true">💡</span>
|
|
73
|
+
<span className={styles.toolName} style={{ color: "var(--accent-purple, #a78bfa)" }}>
|
|
74
|
+
{bareName}
|
|
75
|
+
</span>
|
|
76
|
+
{displayTitle && (
|
|
77
|
+
<span className={styles.fileName} data-testid="tool-card-finding-title">
|
|
78
|
+
"{displayTitle}"
|
|
79
|
+
</span>
|
|
80
|
+
)}
|
|
81
|
+
{displayCategory && (
|
|
82
|
+
<>
|
|
83
|
+
<span className={styles.spacer} />
|
|
84
|
+
<span className={styles.badge} data-testid="tool-card-finding-category">
|
|
85
|
+
{displayCategory}
|
|
86
|
+
</span>
|
|
87
|
+
</>
|
|
88
|
+
)}
|
|
89
|
+
{list && !displayCategory && (
|
|
90
|
+
<>
|
|
91
|
+
<span className={styles.spacer} />
|
|
92
|
+
<span className={styles.badge} data-testid="tool-card-finding-count">
|
|
93
|
+
{list.length} {list.length === 1 ? "finding" : "findings"}
|
|
94
|
+
</span>
|
|
95
|
+
</>
|
|
96
|
+
)}
|
|
97
|
+
</div>
|
|
98
|
+
|
|
99
|
+
{/* Tags */}
|
|
100
|
+
{displayTags && displayTags.length > 0 && (
|
|
101
|
+
<div className={styles.pre} style={{ padding: "4px 8px", whiteSpace: "normal" }} data-testid="tool-card-finding-tags">
|
|
102
|
+
{displayTags.map((tag, i) => (
|
|
103
|
+
<span key={i} style={{ display: "inline-block", marginRight: "6px", opacity: 0.7, fontSize: "0.85em" }}>
|
|
104
|
+
#{tag}
|
|
105
|
+
</span>
|
|
106
|
+
))}
|
|
107
|
+
</div>
|
|
108
|
+
)}
|
|
109
|
+
|
|
110
|
+
{/* In-progress: show args summary */}
|
|
111
|
+
{inProgress && !displayTitle && args !== null && args !== undefined && (
|
|
112
|
+
<pre className={styles.pre} data-testid="tool-card-args">
|
|
113
|
+
{JSON.stringify(args, null, 2)}
|
|
114
|
+
</pre>
|
|
115
|
+
)}
|
|
116
|
+
|
|
117
|
+
{/* Error */}
|
|
118
|
+
{isError && result && (
|
|
119
|
+
<pre className={styles.pre} data-testid="tool-card-error">
|
|
120
|
+
{result}
|
|
121
|
+
</pre>
|
|
122
|
+
)}
|
|
123
|
+
|
|
124
|
+
{/* Single finding result: show content */}
|
|
125
|
+
{!isError && single?.content && (
|
|
126
|
+
<>
|
|
127
|
+
{(() => {
|
|
128
|
+
const lines = single.content.split("\n");
|
|
129
|
+
const hasMore = lines.length > PREVIEW_LINES;
|
|
130
|
+
const displayContent = expanded ? single.content : lines.slice(0, PREVIEW_LINES).join("\n");
|
|
131
|
+
return (
|
|
132
|
+
<>
|
|
133
|
+
<pre className={styles.pre} data-testid="tool-card-finding-content">
|
|
134
|
+
{displayContent}
|
|
135
|
+
</pre>
|
|
136
|
+
{hasMore && (
|
|
137
|
+
<button
|
|
138
|
+
type="button"
|
|
139
|
+
className={styles.bodyToggle}
|
|
140
|
+
onClick={() => { setExpanded((v) => !v); }}
|
|
141
|
+
aria-expanded={expanded}
|
|
142
|
+
data-testid="tool-card-toggle"
|
|
143
|
+
>
|
|
144
|
+
<span className={`${styles.chevron} ${expanded ? styles.chevronExpanded : ""}`}>▸</span>
|
|
145
|
+
{expanded ? "collapse" : `${lines.length - PREVIEW_LINES} more lines`}
|
|
146
|
+
</button>
|
|
147
|
+
)}
|
|
148
|
+
</>
|
|
149
|
+
);
|
|
150
|
+
})()}
|
|
151
|
+
</>
|
|
152
|
+
)}
|
|
153
|
+
|
|
154
|
+
{/* List result: show compact finding titles */}
|
|
155
|
+
{!isError && list && list.length > 0 && (
|
|
156
|
+
<>
|
|
157
|
+
<pre className={styles.pre} data-testid="tool-card-finding-list">
|
|
158
|
+
{(expanded ? list : list.slice(0, PREVIEW_COUNT)).map((f, i) => (
|
|
159
|
+
`${f.category ? `[${f.category}] ` : ""}${f.title ?? f.id ?? `Finding ${i + 1}`}`
|
|
160
|
+
)).join("\n")}
|
|
161
|
+
</pre>
|
|
162
|
+
{list.length > PREVIEW_COUNT && (
|
|
163
|
+
<button
|
|
164
|
+
type="button"
|
|
165
|
+
className={styles.bodyToggle}
|
|
166
|
+
onClick={() => { setExpanded((v) => !v); }}
|
|
167
|
+
aria-expanded={expanded}
|
|
168
|
+
data-testid="tool-card-toggle"
|
|
169
|
+
>
|
|
170
|
+
<span className={`${styles.chevron} ${expanded ? styles.chevronExpanded : ""}`}>▸</span>
|
|
171
|
+
{expanded ? "collapse" : `${list.length - PREVIEW_COUNT} more findings`}
|
|
172
|
+
</button>
|
|
173
|
+
)}
|
|
174
|
+
</>
|
|
175
|
+
)}
|
|
176
|
+
</div>
|
|
177
|
+
);
|
|
178
|
+
}
|