@assistkick/create 1.9.0 → 1.11.0
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/dist/src/scaffolder.d.ts +12 -1
- package/dist/src/scaffolder.js +40 -3
- package/dist/src/scaffolder.js.map +1 -1
- package/package.json +1 -1
- package/templates/assistkick-product-system/package.json +1 -1
- package/templates/assistkick-product-system/packages/backend/package.json +1 -0
- package/templates/assistkick-product-system/packages/backend/src/mcp/permission_mcp_server.ts +196 -0
- package/templates/assistkick-product-system/packages/backend/src/routes/agents.ts +31 -7
- package/templates/assistkick-product-system/packages/backend/src/routes/auth.ts +15 -12
- package/templates/assistkick-product-system/packages/backend/src/routes/chat_files.test.ts +95 -0
- package/templates/assistkick-product-system/packages/backend/src/routes/chat_files.ts +97 -0
- package/templates/assistkick-product-system/packages/backend/src/routes/chat_permission.ts +94 -0
- package/templates/assistkick-product-system/packages/backend/src/routes/chat_sessions.ts +189 -0
- package/templates/assistkick-product-system/packages/backend/src/routes/chat_upload.test.ts +131 -0
- package/templates/assistkick-product-system/packages/backend/src/routes/chat_upload.ts +94 -0
- package/templates/assistkick-product-system/packages/backend/src/routes/files.test.ts +12 -3
- package/templates/assistkick-product-system/packages/backend/src/routes/files.ts +2 -2
- package/templates/assistkick-product-system/packages/backend/src/routes/git.ts +391 -23
- package/templates/assistkick-product-system/packages/backend/src/routes/git_branches.test.ts +306 -0
- package/templates/assistkick-product-system/packages/backend/src/routes/git_connect.test.ts +133 -0
- package/templates/assistkick-product-system/packages/backend/src/routes/pipeline.ts +66 -9
- package/templates/assistkick-product-system/packages/backend/src/routes/preview.ts +204 -0
- package/templates/assistkick-product-system/packages/backend/src/routes/projects.test.ts +205 -0
- package/templates/assistkick-product-system/packages/backend/src/routes/projects.ts +37 -9
- package/templates/assistkick-product-system/packages/backend/src/routes/skills.test.ts +139 -0
- package/templates/assistkick-product-system/packages/backend/src/routes/skills.ts +95 -0
- package/templates/assistkick-product-system/packages/backend/src/routes/terminal.ts +5 -4
- package/templates/assistkick-product-system/packages/backend/src/routes/users.ts +4 -4
- package/templates/assistkick-product-system/packages/backend/src/routes/video.ts +8 -8
- package/templates/assistkick-product-system/packages/backend/src/routes/workflow_groups.ts +5 -5
- package/templates/assistkick-product-system/packages/backend/src/routes/workflows.ts +6 -6
- package/templates/assistkick-product-system/packages/backend/src/server.ts +107 -27
- package/templates/assistkick-product-system/packages/backend/src/services/agent_service.test.ts +105 -203
- package/templates/assistkick-product-system/packages/backend/src/services/agent_service.ts +76 -266
- package/templates/assistkick-product-system/packages/backend/src/services/chat_cli_bridge.test.ts +427 -0
- package/templates/assistkick-product-system/packages/backend/src/services/chat_cli_bridge.ts +345 -0
- package/templates/assistkick-product-system/packages/backend/src/services/chat_message_repository.test.ts +170 -0
- package/templates/assistkick-product-system/packages/backend/src/services/chat_message_repository.ts +106 -0
- package/templates/assistkick-product-system/packages/backend/src/services/chat_session_service.test.ts +217 -0
- package/templates/assistkick-product-system/packages/backend/src/services/chat_session_service.ts +188 -0
- package/templates/assistkick-product-system/packages/backend/src/services/chat_ws_handler.test.ts +1243 -0
- package/templates/assistkick-product-system/packages/backend/src/services/chat_ws_handler.ts +894 -0
- package/templates/assistkick-product-system/packages/backend/src/services/coherence-review.ts +3 -3
- package/templates/assistkick-product-system/packages/backend/src/services/dev_command_detector.test.ts +85 -0
- package/templates/assistkick-product-system/packages/backend/src/services/dev_command_detector.ts +54 -0
- package/templates/assistkick-product-system/packages/backend/src/services/email_service.ts +13 -10
- package/templates/assistkick-product-system/packages/backend/src/services/init.ts +11 -3
- package/templates/assistkick-product-system/packages/backend/src/services/invitation_service.ts +1 -1
- package/templates/assistkick-product-system/packages/backend/src/services/password_reset_service.ts +1 -1
- package/templates/assistkick-product-system/packages/backend/src/services/permission_service.test.ts +243 -0
- package/templates/assistkick-product-system/packages/backend/src/services/permission_service.ts +259 -0
- package/templates/assistkick-product-system/packages/backend/src/services/preview_server_manager.test.ts +172 -0
- package/templates/assistkick-product-system/packages/backend/src/services/preview_server_manager.ts +225 -0
- package/templates/assistkick-product-system/packages/backend/src/services/project_service.test.ts +29 -0
- package/templates/assistkick-product-system/packages/backend/src/services/project_service.ts +17 -0
- package/templates/assistkick-product-system/packages/backend/src/services/project_workspace_service.test.ts +255 -0
- package/templates/assistkick-product-system/packages/backend/src/services/project_workspace_service.ts +300 -25
- package/templates/assistkick-product-system/packages/backend/src/services/pty_session_manager.test.ts +44 -0
- package/templates/assistkick-product-system/packages/backend/src/services/pty_session_manager.ts +62 -7
- package/templates/assistkick-product-system/packages/backend/src/services/ssh_key_service.test.ts +77 -6
- package/templates/assistkick-product-system/packages/backend/src/services/ssh_key_service.ts +149 -14
- package/templates/assistkick-product-system/packages/backend/src/services/terminal_ws_handler.ts +2 -1
- package/templates/assistkick-product-system/packages/backend/src/services/title_generator_service.test.ts +45 -0
- package/templates/assistkick-product-system/packages/backend/src/services/title_generator_service.ts +157 -0
- package/templates/assistkick-product-system/packages/backend/src/services/tts_service.ts +4 -3
- package/templates/assistkick-product-system/packages/backend/src/services/video_render_service.ts +3 -3
- package/templates/assistkick-product-system/packages/frontend/package.json +5 -0
- package/templates/assistkick-product-system/packages/frontend/src/App.tsx +2 -0
- package/templates/assistkick-product-system/packages/frontend/src/api/client.ts +336 -5
- package/templates/assistkick-product-system/packages/frontend/src/components/AgentsView.tsx +192 -12
- package/templates/assistkick-product-system/packages/frontend/src/components/AttachmentPreviewList.tsx +98 -0
- package/templates/assistkick-product-system/packages/frontend/src/components/AutocompleteDropdown.tsx +65 -0
- package/templates/assistkick-product-system/packages/frontend/src/components/ChatAttachButton.tsx +56 -0
- package/templates/assistkick-product-system/packages/frontend/src/components/ChatDropZone.tsx +80 -0
- package/templates/assistkick-product-system/packages/frontend/src/components/ChatMessageBubble.tsx +155 -0
- package/templates/assistkick-product-system/packages/frontend/src/components/ChatMessageContent.tsx +182 -0
- package/templates/assistkick-product-system/packages/frontend/src/components/ChatMessageInput.tsx +233 -0
- package/templates/assistkick-product-system/packages/frontend/src/components/ChatSessionSidebar.tsx +218 -0
- package/templates/assistkick-product-system/packages/frontend/src/components/ChatStopButton.tsx +32 -0
- package/templates/assistkick-product-system/packages/frontend/src/components/ChatTodoSidebar.tsx +113 -0
- package/templates/assistkick-product-system/packages/frontend/src/components/ChatView.tsx +842 -0
- package/templates/assistkick-product-system/packages/frontend/src/components/CommitMessageModal.tsx +82 -0
- package/templates/assistkick-product-system/packages/frontend/src/components/DiagramOverlay.tsx +160 -0
- package/templates/assistkick-product-system/packages/frontend/src/components/EditorTabBar.tsx +5 -5
- package/templates/assistkick-product-system/packages/frontend/src/components/FileTree.tsx +9 -10
- package/templates/assistkick-product-system/packages/frontend/src/components/FileTreeInlineInput.tsx +5 -5
- package/templates/assistkick-product-system/packages/frontend/src/components/FilesView.tsx +112 -41
- package/templates/assistkick-product-system/packages/frontend/src/components/GraphLegend.tsx +2 -2
- package/templates/assistkick-product-system/packages/frontend/src/components/HighlightedText.tsx +87 -0
- package/templates/assistkick-product-system/packages/frontend/src/components/ImageLightbox.tsx +192 -0
- package/templates/assistkick-product-system/packages/frontend/src/components/KanbanView.tsx +2 -2
- package/templates/assistkick-product-system/packages/frontend/src/components/MentionPill.tsx +33 -0
- package/templates/assistkick-product-system/packages/frontend/src/components/MermaidBlock.tsx +148 -0
- package/templates/assistkick-product-system/packages/frontend/src/components/PermissionDialog.tsx +91 -0
- package/templates/assistkick-product-system/packages/frontend/src/components/PermissionModeSelector.tsx +229 -0
- package/templates/assistkick-product-system/packages/frontend/src/components/ProjectSelector.tsx +249 -83
- package/templates/assistkick-product-system/packages/frontend/src/components/QueuedMessageBubble.tsx +38 -0
- package/templates/assistkick-product-system/packages/frontend/src/components/SidePanel.tsx +212 -117
- package/templates/assistkick-product-system/packages/frontend/src/components/SystemPromptAccordion.tsx +48 -0
- package/templates/assistkick-product-system/packages/frontend/src/components/TaskIcon.tsx +11 -0
- package/templates/assistkick-product-system/packages/frontend/src/components/TerminalView.tsx +25 -9
- package/templates/assistkick-product-system/packages/frontend/src/components/ToolDiffView.tsx +114 -0
- package/templates/assistkick-product-system/packages/frontend/src/components/ToolResultCard.tsx +87 -0
- package/templates/assistkick-product-system/packages/frontend/src/components/ToolUseCard.tsx +149 -0
- package/templates/assistkick-product-system/packages/frontend/src/components/Toolbar.tsx +25 -8
- package/templates/assistkick-product-system/packages/frontend/src/components/UnifiedGitWidget.tsx +722 -0
- package/templates/assistkick-product-system/packages/frontend/src/components/workflow/GroupNode.tsx +2 -0
- package/templates/assistkick-product-system/packages/frontend/src/components/workflow/NodePalette.tsx +2 -1
- package/templates/assistkick-product-system/packages/frontend/src/components/workflow/ProgrammableNode.tsx +178 -0
- package/templates/assistkick-product-system/packages/frontend/src/components/workflow/WorkflowCanvas.tsx +3 -0
- package/templates/assistkick-product-system/packages/frontend/src/components/workflow/WorkflowMonitorModal.tsx +103 -9
- package/templates/assistkick-product-system/packages/frontend/src/components/workflow/monitor_nodes.tsx +26 -2
- package/templates/assistkick-product-system/packages/frontend/src/components/workflow/workflow_types.ts +42 -1
- package/templates/assistkick-product-system/packages/frontend/src/hooks/useDocumentTitle.ts +11 -0
- package/templates/assistkick-product-system/packages/frontend/src/hooks/useProjects.ts +1 -0
- package/templates/assistkick-product-system/packages/frontend/src/hooks/use_chat_stream.ts +826 -0
- package/templates/assistkick-product-system/packages/frontend/src/hooks/use_file_tree_cache.ts +69 -0
- package/templates/assistkick-product-system/packages/frontend/src/hooks/use_mention_autocomplete.ts +284 -0
- package/templates/assistkick-product-system/packages/frontend/src/lib/attachment_manager.test.ts +183 -0
- package/templates/assistkick-product-system/packages/frontend/src/lib/attachment_manager.ts +150 -0
- package/templates/assistkick-product-system/packages/frontend/src/lib/chat_message_helpers.test.ts +305 -0
- package/templates/assistkick-product-system/packages/frontend/src/lib/chat_message_helpers.ts +113 -0
- package/templates/assistkick-product-system/packages/frontend/src/lib/context_usage_helpers.test.ts +157 -0
- package/templates/assistkick-product-system/packages/frontend/src/lib/context_usage_helpers.ts +95 -0
- package/templates/assistkick-product-system/packages/frontend/src/lib/mermaid_helpers.test.ts +65 -0
- package/templates/assistkick-product-system/packages/frontend/src/lib/mermaid_helpers.ts +110 -0
- package/templates/assistkick-product-system/packages/frontend/src/lib/message_queue.ts +66 -0
- package/templates/assistkick-product-system/packages/frontend/src/lib/tool_use_summary.test.ts +124 -0
- package/templates/assistkick-product-system/packages/frontend/src/lib/tool_use_summary.ts +112 -0
- package/templates/assistkick-product-system/packages/frontend/src/routes/AgentsRoute.tsx +2 -0
- package/templates/assistkick-product-system/packages/frontend/src/routes/ChatRoute.tsx +8 -0
- package/templates/assistkick-product-system/packages/frontend/src/routes/CoherenceRoute.tsx +2 -0
- package/templates/assistkick-product-system/packages/frontend/src/routes/DashboardLayout.tsx +0 -4
- package/templates/assistkick-product-system/packages/frontend/src/routes/DesignSystemRoute.tsx +2 -0
- package/templates/assistkick-product-system/packages/frontend/src/routes/FilesRoute.tsx +2 -0
- package/templates/assistkick-product-system/packages/frontend/src/routes/GraphRoute.tsx +2 -0
- package/templates/assistkick-product-system/packages/frontend/src/routes/KanbanRoute.tsx +2 -0
- package/templates/assistkick-product-system/packages/frontend/src/routes/TerminalRoute.tsx +2 -0
- package/templates/assistkick-product-system/packages/frontend/src/routes/UsersRoute.tsx +2 -0
- package/templates/assistkick-product-system/packages/frontend/src/routes/VideographyRoute.tsx +2 -0
- package/templates/assistkick-product-system/packages/frontend/src/routes/WorkflowsRoute.tsx +2 -0
- package/templates/assistkick-product-system/packages/frontend/src/routes/accept_invitation.tsx +2 -0
- package/templates/assistkick-product-system/packages/frontend/src/routes/forgot_password.tsx +2 -0
- package/templates/assistkick-product-system/packages/frontend/src/routes/login.tsx +2 -0
- package/templates/assistkick-product-system/packages/frontend/src/routes/register.tsx +2 -0
- package/templates/assistkick-product-system/packages/frontend/src/routes/reset_password.tsx +2 -0
- package/templates/assistkick-product-system/packages/frontend/src/stores/useAttachmentStore.ts +66 -0
- package/templates/assistkick-product-system/packages/frontend/src/stores/useChatSessionStore.ts +107 -0
- package/templates/assistkick-product-system/packages/frontend/src/stores/useMessageQueueStore.ts +110 -0
- package/templates/assistkick-product-system/packages/frontend/src/stores/usePreviewStore.ts +78 -0
- package/templates/assistkick-product-system/packages/frontend/src/stores/useProjectStore.ts +7 -0
- package/templates/assistkick-product-system/packages/frontend/src/stores/useSidePanelStore.ts +6 -1
- package/templates/assistkick-product-system/packages/frontend/src/styles/index.css +30 -357
- package/templates/assistkick-product-system/packages/frontend/src/utils/parse_node_markdown.test.ts +115 -0
- package/templates/assistkick-product-system/packages/frontend/src/utils/parse_node_markdown.ts +91 -0
- package/templates/assistkick-product-system/packages/frontend/src/utils/preview_utils.test.ts +30 -0
- package/templates/assistkick-product-system/packages/frontend/src/utils/preview_utils.ts +3 -0
- package/templates/assistkick-product-system/packages/shared/db/migrations/0015_magenta_jazinda.sql +1 -0
- package/templates/assistkick-product-system/packages/shared/db/migrations/0016_giant_xorn.sql +1 -0
- package/templates/assistkick-product-system/packages/shared/db/migrations/0017_sloppy_mentor.sql +6 -0
- package/templates/assistkick-product-system/packages/shared/db/migrations/0018_vengeful_kabuki.sql +9 -0
- package/templates/assistkick-product-system/packages/shared/db/migrations/0019_careful_sentinels.sql +8 -0
- package/templates/assistkick-product-system/packages/shared/db/migrations/0020_clever_spot.sql +27 -0
- package/templates/assistkick-product-system/packages/shared/db/migrations/0021_graceful_hex.sql +1 -0
- package/templates/assistkick-product-system/packages/shared/db/migrations/0022_short_kingpin.sql +1 -0
- package/templates/assistkick-product-system/packages/shared/db/migrations/0023_ambiguous_sharon_carter.sql +1 -0
- package/templates/assistkick-product-system/packages/shared/db/migrations/0024_fat_unus.sql +1 -0
- package/templates/assistkick-product-system/packages/shared/db/migrations/meta/0015_snapshot.json +1552 -0
- package/templates/assistkick-product-system/packages/shared/db/migrations/meta/0016_snapshot.json +1560 -0
- package/templates/assistkick-product-system/packages/shared/db/migrations/meta/0017_snapshot.json +1598 -0
- package/templates/assistkick-product-system/packages/shared/db/migrations/meta/0018_snapshot.json +1657 -0
- package/templates/assistkick-product-system/packages/shared/db/migrations/meta/0019_snapshot.json +1709 -0
- package/templates/assistkick-product-system/packages/shared/db/migrations/meta/0020_snapshot.json +1733 -0
- package/templates/assistkick-product-system/packages/shared/db/migrations/meta/0021_snapshot.json +1740 -0
- package/templates/assistkick-product-system/packages/shared/db/migrations/meta/0022_snapshot.json +1755 -0
- package/templates/assistkick-product-system/packages/shared/db/migrations/meta/0023_snapshot.json +1762 -0
- package/templates/assistkick-product-system/packages/shared/db/migrations/meta/0024_snapshot.json +1769 -0
- package/templates/assistkick-product-system/packages/shared/db/migrations/meta/_journal.json +70 -0
- package/templates/assistkick-product-system/packages/shared/db/schema.ts +40 -1
- package/templates/assistkick-product-system/packages/shared/lib/claude-service.test.ts +236 -0
- package/templates/assistkick-product-system/packages/shared/lib/claude-service.ts +46 -5
- package/templates/assistkick-product-system/packages/shared/lib/git_workflow.ts +65 -39
- package/templates/assistkick-product-system/packages/shared/lib/programmable_node_executor.test.ts +173 -0
- package/templates/assistkick-product-system/packages/shared/lib/programmable_node_executor.ts +213 -0
- package/templates/assistkick-product-system/packages/shared/lib/validator.test.ts +70 -0
- package/templates/assistkick-product-system/packages/shared/lib/validator.ts +17 -1
- package/templates/assistkick-product-system/packages/shared/lib/workflow_engine.test.ts +803 -27
- package/templates/assistkick-product-system/packages/shared/lib/workflow_engine.ts +502 -68
- package/templates/assistkick-product-system/packages/shared/lib/workflow_orchestrator.ts +4 -4
- package/templates/assistkick-product-system/packages/shared/package.json +2 -1
- package/templates/assistkick-product-system/packages/shared/test_fixtures/hanging_stream.mjs +46 -0
- package/templates/assistkick-product-system/packages/shared/tools/add_node.test.ts +44 -0
- package/templates/assistkick-product-system/packages/shared/tools/add_node.ts +7 -0
- package/templates/assistkick-product-system/packages/shared/tools/remove_node.ts +2 -1
- package/templates/assistkick-product-system/packages/shared/tools/resolve_question.ts +2 -1
- package/templates/assistkick-product-system/packages/shared/tools/update_node.ts +2 -1
- package/templates/assistkick-product-system/tests/message_queue.test.ts +178 -0
- package/templates/assistkick-product-system/tests/message_queue_per_session.test.ts +143 -0
- package/templates/skills/assistkick-bootstrap/SKILL.md +26 -26
- package/templates/skills/assistkick-code-reviewer/SKILL.md +45 -46
- package/templates/skills/assistkick-db-explorer/SKILL.md +13 -13
- package/templates/skills/assistkick-debugger/SKILL.md +23 -23
- package/templates/skills/assistkick-developer/SKILL.md +59 -63
- package/templates/skills/assistkick-interview/SKILL.md +26 -26
- package/templates/skills/assistkick-video-composition-agent/SKILL.md +231 -0
- package/templates/skills/assistkick-video-script-writer/SKILL.md +136 -0
package/templates/assistkick-product-system/packages/frontend/src/hooks/use_file_tree_cache.ts
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* useFileTreeCache — fetches project file tree, flattens to paths, caches client-side.
|
|
3
|
+
* Re-fetches on window focus regain.
|
|
4
|
+
* Excludes common library/build directories for autocomplete relevance.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { useState, useEffect, useCallback, useRef } from 'react';
|
|
8
|
+
import { apiClient } from '../api/client';
|
|
9
|
+
|
|
10
|
+
interface TreeEntry {
|
|
11
|
+
name: string;
|
|
12
|
+
path: string;
|
|
13
|
+
type: 'file' | 'directory';
|
|
14
|
+
children?: TreeEntry[];
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const EXCLUDED_DIRS = new Set([
|
|
18
|
+
'node_modules', '.git', '__pycache__', '.venv', 'venv',
|
|
19
|
+
'dist', 'build', '.next', '.nuxt', 'target', 'vendor',
|
|
20
|
+
'.tox', 'eggs', '.mypy_cache', '.pytest_cache',
|
|
21
|
+
'.cache', 'coverage', '.nyc_output',
|
|
22
|
+
]);
|
|
23
|
+
|
|
24
|
+
const flattenTree = (entries: TreeEntry[]): string[] => {
|
|
25
|
+
const paths: string[] = [];
|
|
26
|
+
|
|
27
|
+
for (const entry of entries) {
|
|
28
|
+
if (EXCLUDED_DIRS.has(entry.name)) continue;
|
|
29
|
+
|
|
30
|
+
paths.push(entry.path);
|
|
31
|
+
|
|
32
|
+
if (entry.type === 'directory' && entry.children) {
|
|
33
|
+
paths.push(...flattenTree(entry.children));
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return paths;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
export const useFileTreeCache = (projectId: string | null): string[] => {
|
|
41
|
+
const [filePaths, setFilePaths] = useState<string[]>([]);
|
|
42
|
+
const lastProjectIdRef = useRef<string | null>(null);
|
|
43
|
+
|
|
44
|
+
const fetchTree = useCallback(async () => {
|
|
45
|
+
if (!projectId) return;
|
|
46
|
+
try {
|
|
47
|
+
const { tree } = await apiClient.fetchFileTree(projectId);
|
|
48
|
+
setFilePaths(flattenTree(tree));
|
|
49
|
+
} catch {
|
|
50
|
+
// Silently fail — file tree is optional for autocomplete
|
|
51
|
+
}
|
|
52
|
+
}, [projectId]);
|
|
53
|
+
|
|
54
|
+
// Fetch when projectId changes
|
|
55
|
+
useEffect(() => {
|
|
56
|
+
if (projectId === lastProjectIdRef.current) return;
|
|
57
|
+
lastProjectIdRef.current = projectId;
|
|
58
|
+
fetchTree();
|
|
59
|
+
}, [projectId, fetchTree]);
|
|
60
|
+
|
|
61
|
+
// Re-fetch on window focus regain
|
|
62
|
+
useEffect(() => {
|
|
63
|
+
const handleFocus = () => fetchTree();
|
|
64
|
+
window.addEventListener('focus', handleFocus);
|
|
65
|
+
return () => window.removeEventListener('focus', handleFocus);
|
|
66
|
+
}, [fetchTree]);
|
|
67
|
+
|
|
68
|
+
return filePaths;
|
|
69
|
+
};
|
package/templates/assistkick-product-system/packages/frontend/src/hooks/use_mention_autocomplete.ts
ADDED
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* useMentionAutocomplete — manages autocomplete trigger detection, item filtering,
|
|
3
|
+
* keyboard navigation, and mention parsing for the chat input.
|
|
4
|
+
*
|
|
5
|
+
* Supports:
|
|
6
|
+
* - @ trigger anywhere for file path autocomplete
|
|
7
|
+
* - / trigger at input start for skill autocomplete
|
|
8
|
+
* - Fuzzy matching with substring fallback
|
|
9
|
+
* - Keyboard navigation (arrow keys, Enter/Tab, Escape)
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { useState, useCallback, useMemo, useRef } from 'react';
|
|
13
|
+
import type { SkillInfo } from '../api/client';
|
|
14
|
+
|
|
15
|
+
export interface AutocompleteItem {
|
|
16
|
+
label: string;
|
|
17
|
+
value: string;
|
|
18
|
+
description?: string;
|
|
19
|
+
type: 'file' | 'skill';
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface Mention {
|
|
23
|
+
type: 'file' | 'skill';
|
|
24
|
+
value: string;
|
|
25
|
+
displayValue: string;
|
|
26
|
+
startIndex: number;
|
|
27
|
+
endIndex: number;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
interface TriggerInfo {
|
|
31
|
+
type: 'file' | 'skill';
|
|
32
|
+
query: string;
|
|
33
|
+
triggerIndex: number;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const MAX_DROPDOWN_ITEMS = 50;
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Fuzzy match: checks if all characters of the query appear in order
|
|
40
|
+
* in the candidate string (case-insensitive).
|
|
41
|
+
*/
|
|
42
|
+
const fuzzyMatch = (query: string, candidate: string): boolean => {
|
|
43
|
+
const lq = query.toLowerCase();
|
|
44
|
+
const lc = candidate.toLowerCase();
|
|
45
|
+
let qi = 0;
|
|
46
|
+
for (let ci = 0; ci < lc.length && qi < lq.length; ci++) {
|
|
47
|
+
if (lc[ci] === lq[qi]) qi++;
|
|
48
|
+
}
|
|
49
|
+
return qi === lq.length;
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Detect autocomplete trigger from text and cursor position.
|
|
54
|
+
* / at position 0 triggers skill autocomplete.
|
|
55
|
+
* @ preceded by whitespace or at start triggers file autocomplete.
|
|
56
|
+
*/
|
|
57
|
+
const detectTrigger = (text: string, cursorPos: number): TriggerInfo | null => {
|
|
58
|
+
if (cursorPos === 0) return null;
|
|
59
|
+
const beforeCursor = text.slice(0, cursorPos);
|
|
60
|
+
|
|
61
|
+
// / at start of input — only if no whitespace yet (still typing skill name)
|
|
62
|
+
if (beforeCursor.startsWith('/')) {
|
|
63
|
+
const afterSlash = beforeCursor.slice(1);
|
|
64
|
+
if (!/\s/.test(afterSlash)) {
|
|
65
|
+
return { type: 'skill', query: afterSlash, triggerIndex: 0 };
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// @ trigger: find the last @ before cursor that's preceded by whitespace or at start
|
|
70
|
+
const lastAtIndex = beforeCursor.lastIndexOf('@');
|
|
71
|
+
if (lastAtIndex >= 0) {
|
|
72
|
+
if (lastAtIndex === 0 || /\s/.test(beforeCursor[lastAtIndex - 1])) {
|
|
73
|
+
const query = beforeCursor.slice(lastAtIndex + 1);
|
|
74
|
+
if (!/\s/.test(query)) {
|
|
75
|
+
return { type: 'file', query, triggerIndex: lastAtIndex };
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return null;
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Parse completed mentions from the text value.
|
|
85
|
+
* A mention is valid when the path/skill name matches known values.
|
|
86
|
+
*/
|
|
87
|
+
const parseMentions = (text: string, filePathSet: Set<string>, skillFolderNameSet: Set<string>): Mention[] => {
|
|
88
|
+
const mentions: Mention[] = [];
|
|
89
|
+
|
|
90
|
+
// /skill at start
|
|
91
|
+
if (text.startsWith('/')) {
|
|
92
|
+
const match = text.match(/^\/(\S+)/);
|
|
93
|
+
if (match && skillFolderNameSet.has(match[1])) {
|
|
94
|
+
mentions.push({
|
|
95
|
+
type: 'skill',
|
|
96
|
+
value: match[1],
|
|
97
|
+
displayValue: match[1],
|
|
98
|
+
startIndex: 0,
|
|
99
|
+
endIndex: match[0].length,
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// @file mentions — must be preceded by whitespace or at start
|
|
105
|
+
const atRegex = /(?:^|\s)@(\S+)/g;
|
|
106
|
+
let m;
|
|
107
|
+
while ((m = atRegex.exec(text)) !== null) {
|
|
108
|
+
const path = m[1];
|
|
109
|
+
if (filePathSet.has(path)) {
|
|
110
|
+
const atIndex = text.indexOf('@', m.index);
|
|
111
|
+
mentions.push({
|
|
112
|
+
type: 'file',
|
|
113
|
+
value: path,
|
|
114
|
+
displayValue: path,
|
|
115
|
+
startIndex: atIndex,
|
|
116
|
+
endIndex: atIndex + path.length + 1,
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
return mentions;
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Filter and sort candidates using fuzzy match with substring fallback.
|
|
126
|
+
*/
|
|
127
|
+
const filterCandidates = (candidates: AutocompleteItem[], query: string): AutocompleteItem[] => {
|
|
128
|
+
if (!query) return candidates.slice(0, MAX_DROPDOWN_ITEMS);
|
|
129
|
+
|
|
130
|
+
const fuzzyResults = candidates.filter(c => fuzzyMatch(query, c.label));
|
|
131
|
+
if (fuzzyResults.length > 0) return fuzzyResults.slice(0, MAX_DROPDOWN_ITEMS);
|
|
132
|
+
|
|
133
|
+
const lower = query.toLowerCase();
|
|
134
|
+
return candidates.filter(c => c.label.toLowerCase().includes(lower)).slice(0, MAX_DROPDOWN_ITEMS);
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
interface UseMentionAutocompleteOptions {
|
|
138
|
+
filePaths: string[];
|
|
139
|
+
skills: SkillInfo[];
|
|
140
|
+
value: string;
|
|
141
|
+
onChange: (value: string) => void;
|
|
142
|
+
textareaRef: React.RefObject<HTMLTextAreaElement | null>;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export const useMentionAutocomplete = ({
|
|
146
|
+
filePaths,
|
|
147
|
+
skills,
|
|
148
|
+
value,
|
|
149
|
+
onChange,
|
|
150
|
+
textareaRef,
|
|
151
|
+
}: UseMentionAutocompleteOptions) => {
|
|
152
|
+
const [trigger, setTrigger] = useState<TriggerInfo | null>(null);
|
|
153
|
+
const [selectedIndex, setSelectedIndex] = useState(0);
|
|
154
|
+
const dismissedAtRef = useRef<number | null>(null);
|
|
155
|
+
|
|
156
|
+
const filePathSet = useMemo(() => new Set(filePaths), [filePaths]);
|
|
157
|
+
const skillFolderNameSet = useMemo(() => new Set(skills.map(s => s.folderName)), [skills]);
|
|
158
|
+
|
|
159
|
+
// Build autocomplete items from trigger
|
|
160
|
+
const items = useMemo((): AutocompleteItem[] => {
|
|
161
|
+
if (!trigger) return [];
|
|
162
|
+
|
|
163
|
+
if (trigger.type === 'file') {
|
|
164
|
+
const candidates = filePaths.map(p => ({
|
|
165
|
+
label: p,
|
|
166
|
+
value: `@${p}`,
|
|
167
|
+
type: 'file' as const,
|
|
168
|
+
}));
|
|
169
|
+
return filterCandidates(candidates, trigger.query);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
if (trigger.type === 'skill') {
|
|
173
|
+
const candidates = skills.map(s => ({
|
|
174
|
+
label: s.folderName,
|
|
175
|
+
value: `/${s.folderName}`,
|
|
176
|
+
description: s.description,
|
|
177
|
+
type: 'skill' as const,
|
|
178
|
+
}));
|
|
179
|
+
return filterCandidates(candidates, trigger.query);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
return [];
|
|
183
|
+
}, [trigger, filePaths, skills]);
|
|
184
|
+
|
|
185
|
+
// Parse current mentions
|
|
186
|
+
const mentions = useMemo(
|
|
187
|
+
() => parseMentions(value, filePathSet, skillFolderNameSet),
|
|
188
|
+
[value, filePathSet, skillFolderNameSet],
|
|
189
|
+
);
|
|
190
|
+
|
|
191
|
+
const showDropdown = trigger !== null && items.length > 0;
|
|
192
|
+
|
|
193
|
+
// Called when text or cursor position changes
|
|
194
|
+
const onTextChange = useCallback((text: string, cursorPos: number) => {
|
|
195
|
+
const newTrigger = detectTrigger(text, cursorPos);
|
|
196
|
+
|
|
197
|
+
if (newTrigger) {
|
|
198
|
+
// If this trigger was dismissed and query hasn't changed, keep it dismissed
|
|
199
|
+
if (dismissedAtRef.current === newTrigger.triggerIndex) {
|
|
200
|
+
setTrigger(null);
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
// New trigger or query changed — clear dismissed state
|
|
204
|
+
dismissedAtRef.current = null;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
setTrigger(newTrigger);
|
|
208
|
+
if (newTrigger) setSelectedIndex(0);
|
|
209
|
+
}, []);
|
|
210
|
+
|
|
211
|
+
// Handle autocomplete item selection
|
|
212
|
+
const selectItem = useCallback((item: AutocompleteItem) => {
|
|
213
|
+
if (!trigger || !textareaRef.current) return;
|
|
214
|
+
|
|
215
|
+
const textarea = textareaRef.current;
|
|
216
|
+
const cursorPos = textarea.selectionStart;
|
|
217
|
+
const before = value.slice(0, trigger.triggerIndex);
|
|
218
|
+
const afterCursor = value.slice(cursorPos);
|
|
219
|
+
const insertText = item.value + ' ';
|
|
220
|
+
const newValue = before + insertText + afterCursor;
|
|
221
|
+
|
|
222
|
+
onChange(newValue);
|
|
223
|
+
setTrigger(null);
|
|
224
|
+
dismissedAtRef.current = null;
|
|
225
|
+
|
|
226
|
+
// Position cursor after inserted text
|
|
227
|
+
const newCursorPos = trigger.triggerIndex + insertText.length;
|
|
228
|
+
requestAnimationFrame(() => {
|
|
229
|
+
textarea.selectionStart = newCursorPos;
|
|
230
|
+
textarea.selectionEnd = newCursorPos;
|
|
231
|
+
textarea.focus();
|
|
232
|
+
});
|
|
233
|
+
}, [trigger, value, onChange, textareaRef]);
|
|
234
|
+
|
|
235
|
+
// Handle keyboard events for dropdown navigation
|
|
236
|
+
const onKeyDown = useCallback((e: React.KeyboardEvent<HTMLTextAreaElement>): boolean => {
|
|
237
|
+
if (!showDropdown) return false;
|
|
238
|
+
|
|
239
|
+
switch (e.key) {
|
|
240
|
+
case 'ArrowDown':
|
|
241
|
+
e.preventDefault();
|
|
242
|
+
setSelectedIndex(i => (i + 1) % items.length);
|
|
243
|
+
return true;
|
|
244
|
+
case 'ArrowUp':
|
|
245
|
+
e.preventDefault();
|
|
246
|
+
setSelectedIndex(i => (i - 1 + items.length) % items.length);
|
|
247
|
+
return true;
|
|
248
|
+
case 'Enter':
|
|
249
|
+
case 'Tab':
|
|
250
|
+
e.preventDefault();
|
|
251
|
+
if (items[selectedIndex]) {
|
|
252
|
+
selectItem(items[selectedIndex]);
|
|
253
|
+
}
|
|
254
|
+
return true;
|
|
255
|
+
case 'Escape':
|
|
256
|
+
e.preventDefault();
|
|
257
|
+
dismissedAtRef.current = trigger?.triggerIndex ?? null;
|
|
258
|
+
setTrigger(null);
|
|
259
|
+
return true;
|
|
260
|
+
default:
|
|
261
|
+
return false;
|
|
262
|
+
}
|
|
263
|
+
}, [showDropdown, items, selectedIndex, selectItem, trigger]);
|
|
264
|
+
|
|
265
|
+
// Remove a mention from the text
|
|
266
|
+
const removeMention = useCallback((mention: Mention) => {
|
|
267
|
+
const before = value.slice(0, mention.startIndex);
|
|
268
|
+
const after = value.slice(mention.endIndex);
|
|
269
|
+
const newValue = (before + after.replace(/^ /, '')).trimStart();
|
|
270
|
+
onChange(newValue);
|
|
271
|
+
}, [value, onChange]);
|
|
272
|
+
|
|
273
|
+
return {
|
|
274
|
+
showDropdown,
|
|
275
|
+
dropdownType: trigger?.type ?? 'file',
|
|
276
|
+
items,
|
|
277
|
+
selectedIndex,
|
|
278
|
+
mentions,
|
|
279
|
+
onTextChange,
|
|
280
|
+
onKeyDown,
|
|
281
|
+
selectItem,
|
|
282
|
+
removeMention,
|
|
283
|
+
};
|
|
284
|
+
};
|
package/templates/assistkick-product-system/packages/frontend/src/lib/attachment_manager.test.ts
ADDED
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import { describe, it, beforeEach } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { AttachmentManager } from './attachment_manager.ts';
|
|
4
|
+
|
|
5
|
+
describe('AttachmentManager', () => {
|
|
6
|
+
let mgr: AttachmentManager;
|
|
7
|
+
|
|
8
|
+
beforeEach(() => {
|
|
9
|
+
mgr = new AttachmentManager();
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
describe('addUpload', () => {
|
|
13
|
+
it('adds an upload attachment with uploading=true', () => {
|
|
14
|
+
const file = { name: 'photo.png', size: 1024, type: 'image/png' } as File;
|
|
15
|
+
const att = mgr.addUpload(file, 'data:image/png;base64,abc');
|
|
16
|
+
|
|
17
|
+
assert.equal(att.name, 'photo.png');
|
|
18
|
+
assert.equal(att.size, 1024);
|
|
19
|
+
assert.equal(att.mimeType, 'image/png');
|
|
20
|
+
assert.equal(att.source, 'upload');
|
|
21
|
+
assert.equal(att.preview, 'data:image/png;base64,abc');
|
|
22
|
+
assert.equal(att.uploading, true);
|
|
23
|
+
assert.equal(att.path, '');
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('assigns unique IDs', () => {
|
|
27
|
+
const f1 = { name: 'a.txt', size: 10, type: 'text/plain' } as File;
|
|
28
|
+
const f2 = { name: 'b.txt', size: 20, type: 'text/plain' } as File;
|
|
29
|
+
const a1 = mgr.addUpload(f1, null);
|
|
30
|
+
const a2 = mgr.addUpload(f2, null);
|
|
31
|
+
assert.notEqual(a1.id, a2.id);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('defaults mimeType when file.type is empty', () => {
|
|
35
|
+
const file = { name: 'data.bin', size: 100, type: '' } as File;
|
|
36
|
+
const att = mgr.addUpload(file, null);
|
|
37
|
+
assert.equal(att.mimeType, 'application/octet-stream');
|
|
38
|
+
});
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
describe('addWorkspaceFile', () => {
|
|
42
|
+
it('adds a workspace file reference with uploading=false', () => {
|
|
43
|
+
const att = mgr.addWorkspaceFile('src/app.ts', 'app.ts', 2048, 'text/typescript');
|
|
44
|
+
|
|
45
|
+
assert.equal(att.name, 'app.ts');
|
|
46
|
+
assert.equal(att.path, 'src/app.ts');
|
|
47
|
+
assert.equal(att.size, 2048);
|
|
48
|
+
assert.equal(att.source, 'workspace');
|
|
49
|
+
assert.equal(att.uploading, false);
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
describe('markUploaded', () => {
|
|
54
|
+
it('sets path and uploading=false for the given attachment', () => {
|
|
55
|
+
const file = { name: 'doc.pdf', size: 500, type: 'application/pdf' } as File;
|
|
56
|
+
const att = mgr.addUpload(file, null);
|
|
57
|
+
|
|
58
|
+
const result = mgr.markUploaded(att.id, '.chat-uploads/123_doc.pdf');
|
|
59
|
+
assert.equal(result, true);
|
|
60
|
+
|
|
61
|
+
const updated = mgr.getAttachments().find((a) => a.id === att.id);
|
|
62
|
+
assert.equal(updated?.path, '.chat-uploads/123_doc.pdf');
|
|
63
|
+
assert.equal(updated?.uploading, false);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('returns false for unknown id', () => {
|
|
67
|
+
assert.equal(mgr.markUploaded('nonexistent', '/path'), false);
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
describe('markUploadFailed', () => {
|
|
72
|
+
it('removes the attachment', () => {
|
|
73
|
+
const file = { name: 'fail.txt', size: 10, type: 'text/plain' } as File;
|
|
74
|
+
const att = mgr.addUpload(file, null);
|
|
75
|
+
assert.equal(mgr.getCount(), 1);
|
|
76
|
+
|
|
77
|
+
mgr.markUploadFailed(att.id);
|
|
78
|
+
assert.equal(mgr.getCount(), 0);
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
describe('remove', () => {
|
|
83
|
+
it('removes an attachment by id', () => {
|
|
84
|
+
const file = { name: 'a.txt', size: 10, type: 'text/plain' } as File;
|
|
85
|
+
const att = mgr.addUpload(file, null);
|
|
86
|
+
assert.equal(mgr.getCount(), 1);
|
|
87
|
+
|
|
88
|
+
const result = mgr.remove(att.id);
|
|
89
|
+
assert.equal(result, true);
|
|
90
|
+
assert.equal(mgr.getCount(), 0);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('returns false for unknown id', () => {
|
|
94
|
+
assert.equal(mgr.remove('nonexistent'), false);
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
describe('clear', () => {
|
|
99
|
+
it('removes all attachments', () => {
|
|
100
|
+
mgr.addUpload({ name: 'a.txt', size: 10, type: 'text/plain' } as File, null);
|
|
101
|
+
mgr.addUpload({ name: 'b.txt', size: 20, type: 'text/plain' } as File, null);
|
|
102
|
+
assert.equal(mgr.getCount(), 2);
|
|
103
|
+
|
|
104
|
+
mgr.clear();
|
|
105
|
+
assert.equal(mgr.getCount(), 0);
|
|
106
|
+
});
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
describe('getReadyPaths', () => {
|
|
110
|
+
it('returns only paths of non-uploading attachments', () => {
|
|
111
|
+
const f1 = { name: 'uploading.txt', size: 10, type: 'text/plain' } as File;
|
|
112
|
+
mgr.addUpload(f1, null); // uploading=true, path=''
|
|
113
|
+
|
|
114
|
+
mgr.addWorkspaceFile('src/ready.ts', 'ready.ts', 100, 'text/typescript');
|
|
115
|
+
|
|
116
|
+
const f2 = { name: 'done.txt', size: 50, type: 'text/plain' } as File;
|
|
117
|
+
const att2 = mgr.addUpload(f2, null);
|
|
118
|
+
mgr.markUploaded(att2.id, '.chat-uploads/done.txt');
|
|
119
|
+
|
|
120
|
+
const paths = mgr.getReadyPaths();
|
|
121
|
+
assert.deepEqual(paths, ['src/ready.ts', '.chat-uploads/done.txt']);
|
|
122
|
+
});
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
describe('hasUploading', () => {
|
|
126
|
+
it('returns true when any attachment is uploading', () => {
|
|
127
|
+
mgr.addUpload({ name: 'a.txt', size: 10, type: 'text/plain' } as File, null);
|
|
128
|
+
assert.equal(mgr.hasUploading(), true);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it('returns false when no attachments are uploading', () => {
|
|
132
|
+
mgr.addWorkspaceFile('a.ts', 'a.ts', 100, 'text/typescript');
|
|
133
|
+
assert.equal(mgr.hasUploading(), false);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it('returns false when empty', () => {
|
|
137
|
+
assert.equal(mgr.hasUploading(), false);
|
|
138
|
+
});
|
|
139
|
+
});
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
describe('AttachmentManager static methods', () => {
|
|
143
|
+
describe('isImage', () => {
|
|
144
|
+
it('returns true for image MIME types', () => {
|
|
145
|
+
assert.equal(AttachmentManager.isImage('image/png'), true);
|
|
146
|
+
assert.equal(AttachmentManager.isImage('image/jpeg'), true);
|
|
147
|
+
assert.equal(AttachmentManager.isImage('image/gif'), true);
|
|
148
|
+
assert.equal(AttachmentManager.isImage('image/webp'), true);
|
|
149
|
+
assert.equal(AttachmentManager.isImage('image/svg+xml'), true);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it('returns false for non-image MIME types', () => {
|
|
153
|
+
assert.equal(AttachmentManager.isImage('text/plain'), false);
|
|
154
|
+
assert.equal(AttachmentManager.isImage('application/pdf'), false);
|
|
155
|
+
assert.equal(AttachmentManager.isImage('video/mp4'), false);
|
|
156
|
+
});
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
describe('formatMessageWithAttachments', () => {
|
|
160
|
+
it('returns message unchanged when no attachments', () => {
|
|
161
|
+
const result = AttachmentManager.formatMessageWithAttachments('Hello', []);
|
|
162
|
+
assert.equal(result, 'Hello');
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it('appends file list to message', () => {
|
|
166
|
+
const result = AttachmentManager.formatMessageWithAttachments('Fix the bug', [
|
|
167
|
+
'src/app.ts',
|
|
168
|
+
'docs/screenshot.png',
|
|
169
|
+
]);
|
|
170
|
+
assert.equal(
|
|
171
|
+
result,
|
|
172
|
+
'Fix the bug\n\nAttached files:\n- src/app.ts\n- docs/screenshot.png',
|
|
173
|
+
);
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it('handles single attachment', () => {
|
|
177
|
+
const result = AttachmentManager.formatMessageWithAttachments('Look at this', [
|
|
178
|
+
'image.png',
|
|
179
|
+
]);
|
|
180
|
+
assert.equal(result, 'Look at this\n\nAttached files:\n- image.png');
|
|
181
|
+
});
|
|
182
|
+
});
|
|
183
|
+
});
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AttachmentManager — pure logic for managing file attachments in Chat v2.
|
|
3
|
+
*
|
|
4
|
+
* Tracks files attached to a chat message before it is sent.
|
|
5
|
+
* Supports two sources:
|
|
6
|
+
* - 'upload': files dropped/selected from the user's computer (need uploading)
|
|
7
|
+
* - 'workspace': files referenced from the project workspace (path-only)
|
|
8
|
+
*
|
|
9
|
+
* This class is framework-agnostic — the Zustand store wraps it for React.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
export type AttachmentSource = 'upload' | 'workspace';
|
|
13
|
+
|
|
14
|
+
export interface Attachment {
|
|
15
|
+
id: string;
|
|
16
|
+
name: string;
|
|
17
|
+
/** Relative path within the project workspace (set after upload for 'upload' source) */
|
|
18
|
+
path: string;
|
|
19
|
+
size: number;
|
|
20
|
+
mimeType: string;
|
|
21
|
+
source: AttachmentSource;
|
|
22
|
+
/** Data URL preview for images (generated client-side) */
|
|
23
|
+
preview: string | null;
|
|
24
|
+
/** Whether this attachment is still being uploaded */
|
|
25
|
+
uploading: boolean;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const IMAGE_MIME_TYPES = new Set([
|
|
29
|
+
'image/png',
|
|
30
|
+
'image/jpeg',
|
|
31
|
+
'image/gif',
|
|
32
|
+
'image/webp',
|
|
33
|
+
'image/svg+xml',
|
|
34
|
+
'image/bmp',
|
|
35
|
+
]);
|
|
36
|
+
|
|
37
|
+
export class AttachmentManager {
|
|
38
|
+
private attachments: Attachment[] = [];
|
|
39
|
+
private idCounter = 0;
|
|
40
|
+
|
|
41
|
+
private nextId = (): string => {
|
|
42
|
+
return `att_${++this.idCounter}_${Date.now()}`;
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
getAttachments = (): readonly Attachment[] => {
|
|
46
|
+
return this.attachments;
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
getCount = (): number => {
|
|
50
|
+
return this.attachments.length;
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
hasUploading = (): boolean => {
|
|
54
|
+
return this.attachments.some((a) => a.uploading);
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Add a file attachment from the user's computer.
|
|
59
|
+
* Returns the attachment with uploading=true; caller must update path after upload.
|
|
60
|
+
*/
|
|
61
|
+
addUpload = (file: File, preview: string | null): Attachment => {
|
|
62
|
+
const att: Attachment = {
|
|
63
|
+
id: this.nextId(),
|
|
64
|
+
name: file.name,
|
|
65
|
+
path: '',
|
|
66
|
+
size: file.size,
|
|
67
|
+
mimeType: file.type || 'application/octet-stream',
|
|
68
|
+
source: 'upload',
|
|
69
|
+
preview,
|
|
70
|
+
uploading: true,
|
|
71
|
+
};
|
|
72
|
+
this.attachments = [...this.attachments, att];
|
|
73
|
+
return att;
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Add a workspace file reference (no upload needed).
|
|
78
|
+
*/
|
|
79
|
+
addWorkspaceFile = (path: string, name: string, size: number, mimeType: string): Attachment => {
|
|
80
|
+
const att: Attachment = {
|
|
81
|
+
id: this.nextId(),
|
|
82
|
+
name,
|
|
83
|
+
path,
|
|
84
|
+
size,
|
|
85
|
+
mimeType: mimeType || 'application/octet-stream',
|
|
86
|
+
source: 'workspace',
|
|
87
|
+
preview: null,
|
|
88
|
+
uploading: false,
|
|
89
|
+
};
|
|
90
|
+
this.attachments = [...this.attachments, att];
|
|
91
|
+
return att;
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Mark an attachment as uploaded and set its workspace path.
|
|
96
|
+
*/
|
|
97
|
+
markUploaded = (id: string, path: string): boolean => {
|
|
98
|
+
const idx = this.attachments.findIndex((a) => a.id === id);
|
|
99
|
+
if (idx === -1) return false;
|
|
100
|
+
this.attachments = this.attachments.map((a) =>
|
|
101
|
+
a.id === id ? { ...a, path, uploading: false } : a,
|
|
102
|
+
);
|
|
103
|
+
return true;
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Mark an attachment upload as failed and remove it.
|
|
108
|
+
*/
|
|
109
|
+
markUploadFailed = (id: string): boolean => {
|
|
110
|
+
return this.remove(id);
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
remove = (id: string): boolean => {
|
|
114
|
+
const len = this.attachments.length;
|
|
115
|
+
this.attachments = this.attachments.filter((a) => a.id !== id);
|
|
116
|
+
return this.attachments.length < len;
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
clear = (): void => {
|
|
120
|
+
this.attachments = [];
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Get all attachment paths for inclusion in the CLI prompt.
|
|
125
|
+
* Only returns attachments that have been fully uploaded.
|
|
126
|
+
*/
|
|
127
|
+
getReadyPaths = (): string[] => {
|
|
128
|
+
return this.attachments
|
|
129
|
+
.filter((a) => !a.uploading && a.path)
|
|
130
|
+
.map((a) => a.path);
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Check if a MIME type is an image type.
|
|
135
|
+
*/
|
|
136
|
+
static isImage = (mimeType: string): boolean => {
|
|
137
|
+
return IMAGE_MIME_TYPES.has(mimeType);
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Format attachment references for inclusion in a CLI prompt message.
|
|
142
|
+
* Appends a section listing attached file paths.
|
|
143
|
+
*/
|
|
144
|
+
static formatMessageWithAttachments = (message: string, paths: string[]): string => {
|
|
145
|
+
if (paths.length === 0) return message;
|
|
146
|
+
|
|
147
|
+
const fileList = paths.map((p) => `- ${p}`).join('\n');
|
|
148
|
+
return `${message}\n\nAttached files:\n${fileList}`;
|
|
149
|
+
};
|
|
150
|
+
}
|