@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.
Files changed (206) hide show
  1. package/dist/src/scaffolder.d.ts +12 -1
  2. package/dist/src/scaffolder.js +40 -3
  3. package/dist/src/scaffolder.js.map +1 -1
  4. package/package.json +1 -1
  5. package/templates/assistkick-product-system/package.json +1 -1
  6. package/templates/assistkick-product-system/packages/backend/package.json +1 -0
  7. package/templates/assistkick-product-system/packages/backend/src/mcp/permission_mcp_server.ts +196 -0
  8. package/templates/assistkick-product-system/packages/backend/src/routes/agents.ts +31 -7
  9. package/templates/assistkick-product-system/packages/backend/src/routes/auth.ts +15 -12
  10. package/templates/assistkick-product-system/packages/backend/src/routes/chat_files.test.ts +95 -0
  11. package/templates/assistkick-product-system/packages/backend/src/routes/chat_files.ts +97 -0
  12. package/templates/assistkick-product-system/packages/backend/src/routes/chat_permission.ts +94 -0
  13. package/templates/assistkick-product-system/packages/backend/src/routes/chat_sessions.ts +189 -0
  14. package/templates/assistkick-product-system/packages/backend/src/routes/chat_upload.test.ts +131 -0
  15. package/templates/assistkick-product-system/packages/backend/src/routes/chat_upload.ts +94 -0
  16. package/templates/assistkick-product-system/packages/backend/src/routes/files.test.ts +12 -3
  17. package/templates/assistkick-product-system/packages/backend/src/routes/files.ts +2 -2
  18. package/templates/assistkick-product-system/packages/backend/src/routes/git.ts +391 -23
  19. package/templates/assistkick-product-system/packages/backend/src/routes/git_branches.test.ts +306 -0
  20. package/templates/assistkick-product-system/packages/backend/src/routes/git_connect.test.ts +133 -0
  21. package/templates/assistkick-product-system/packages/backend/src/routes/pipeline.ts +66 -9
  22. package/templates/assistkick-product-system/packages/backend/src/routes/preview.ts +204 -0
  23. package/templates/assistkick-product-system/packages/backend/src/routes/projects.test.ts +205 -0
  24. package/templates/assistkick-product-system/packages/backend/src/routes/projects.ts +37 -9
  25. package/templates/assistkick-product-system/packages/backend/src/routes/skills.test.ts +139 -0
  26. package/templates/assistkick-product-system/packages/backend/src/routes/skills.ts +95 -0
  27. package/templates/assistkick-product-system/packages/backend/src/routes/terminal.ts +5 -4
  28. package/templates/assistkick-product-system/packages/backend/src/routes/users.ts +4 -4
  29. package/templates/assistkick-product-system/packages/backend/src/routes/video.ts +8 -8
  30. package/templates/assistkick-product-system/packages/backend/src/routes/workflow_groups.ts +5 -5
  31. package/templates/assistkick-product-system/packages/backend/src/routes/workflows.ts +6 -6
  32. package/templates/assistkick-product-system/packages/backend/src/server.ts +107 -27
  33. package/templates/assistkick-product-system/packages/backend/src/services/agent_service.test.ts +105 -203
  34. package/templates/assistkick-product-system/packages/backend/src/services/agent_service.ts +76 -266
  35. package/templates/assistkick-product-system/packages/backend/src/services/chat_cli_bridge.test.ts +427 -0
  36. package/templates/assistkick-product-system/packages/backend/src/services/chat_cli_bridge.ts +345 -0
  37. package/templates/assistkick-product-system/packages/backend/src/services/chat_message_repository.test.ts +170 -0
  38. package/templates/assistkick-product-system/packages/backend/src/services/chat_message_repository.ts +106 -0
  39. package/templates/assistkick-product-system/packages/backend/src/services/chat_session_service.test.ts +217 -0
  40. package/templates/assistkick-product-system/packages/backend/src/services/chat_session_service.ts +188 -0
  41. package/templates/assistkick-product-system/packages/backend/src/services/chat_ws_handler.test.ts +1243 -0
  42. package/templates/assistkick-product-system/packages/backend/src/services/chat_ws_handler.ts +894 -0
  43. package/templates/assistkick-product-system/packages/backend/src/services/coherence-review.ts +3 -3
  44. package/templates/assistkick-product-system/packages/backend/src/services/dev_command_detector.test.ts +85 -0
  45. package/templates/assistkick-product-system/packages/backend/src/services/dev_command_detector.ts +54 -0
  46. package/templates/assistkick-product-system/packages/backend/src/services/email_service.ts +13 -10
  47. package/templates/assistkick-product-system/packages/backend/src/services/init.ts +11 -3
  48. package/templates/assistkick-product-system/packages/backend/src/services/invitation_service.ts +1 -1
  49. package/templates/assistkick-product-system/packages/backend/src/services/password_reset_service.ts +1 -1
  50. package/templates/assistkick-product-system/packages/backend/src/services/permission_service.test.ts +243 -0
  51. package/templates/assistkick-product-system/packages/backend/src/services/permission_service.ts +259 -0
  52. package/templates/assistkick-product-system/packages/backend/src/services/preview_server_manager.test.ts +172 -0
  53. package/templates/assistkick-product-system/packages/backend/src/services/preview_server_manager.ts +225 -0
  54. package/templates/assistkick-product-system/packages/backend/src/services/project_service.test.ts +29 -0
  55. package/templates/assistkick-product-system/packages/backend/src/services/project_service.ts +17 -0
  56. package/templates/assistkick-product-system/packages/backend/src/services/project_workspace_service.test.ts +255 -0
  57. package/templates/assistkick-product-system/packages/backend/src/services/project_workspace_service.ts +300 -25
  58. package/templates/assistkick-product-system/packages/backend/src/services/pty_session_manager.test.ts +44 -0
  59. package/templates/assistkick-product-system/packages/backend/src/services/pty_session_manager.ts +62 -7
  60. package/templates/assistkick-product-system/packages/backend/src/services/ssh_key_service.test.ts +77 -6
  61. package/templates/assistkick-product-system/packages/backend/src/services/ssh_key_service.ts +149 -14
  62. package/templates/assistkick-product-system/packages/backend/src/services/terminal_ws_handler.ts +2 -1
  63. package/templates/assistkick-product-system/packages/backend/src/services/title_generator_service.test.ts +45 -0
  64. package/templates/assistkick-product-system/packages/backend/src/services/title_generator_service.ts +157 -0
  65. package/templates/assistkick-product-system/packages/backend/src/services/tts_service.ts +4 -3
  66. package/templates/assistkick-product-system/packages/backend/src/services/video_render_service.ts +3 -3
  67. package/templates/assistkick-product-system/packages/frontend/package.json +5 -0
  68. package/templates/assistkick-product-system/packages/frontend/src/App.tsx +2 -0
  69. package/templates/assistkick-product-system/packages/frontend/src/api/client.ts +336 -5
  70. package/templates/assistkick-product-system/packages/frontend/src/components/AgentsView.tsx +192 -12
  71. package/templates/assistkick-product-system/packages/frontend/src/components/AttachmentPreviewList.tsx +98 -0
  72. package/templates/assistkick-product-system/packages/frontend/src/components/AutocompleteDropdown.tsx +65 -0
  73. package/templates/assistkick-product-system/packages/frontend/src/components/ChatAttachButton.tsx +56 -0
  74. package/templates/assistkick-product-system/packages/frontend/src/components/ChatDropZone.tsx +80 -0
  75. package/templates/assistkick-product-system/packages/frontend/src/components/ChatMessageBubble.tsx +155 -0
  76. package/templates/assistkick-product-system/packages/frontend/src/components/ChatMessageContent.tsx +182 -0
  77. package/templates/assistkick-product-system/packages/frontend/src/components/ChatMessageInput.tsx +233 -0
  78. package/templates/assistkick-product-system/packages/frontend/src/components/ChatSessionSidebar.tsx +218 -0
  79. package/templates/assistkick-product-system/packages/frontend/src/components/ChatStopButton.tsx +32 -0
  80. package/templates/assistkick-product-system/packages/frontend/src/components/ChatTodoSidebar.tsx +113 -0
  81. package/templates/assistkick-product-system/packages/frontend/src/components/ChatView.tsx +842 -0
  82. package/templates/assistkick-product-system/packages/frontend/src/components/CommitMessageModal.tsx +82 -0
  83. package/templates/assistkick-product-system/packages/frontend/src/components/DiagramOverlay.tsx +160 -0
  84. package/templates/assistkick-product-system/packages/frontend/src/components/EditorTabBar.tsx +5 -5
  85. package/templates/assistkick-product-system/packages/frontend/src/components/FileTree.tsx +9 -10
  86. package/templates/assistkick-product-system/packages/frontend/src/components/FileTreeInlineInput.tsx +5 -5
  87. package/templates/assistkick-product-system/packages/frontend/src/components/FilesView.tsx +112 -41
  88. package/templates/assistkick-product-system/packages/frontend/src/components/GraphLegend.tsx +2 -2
  89. package/templates/assistkick-product-system/packages/frontend/src/components/HighlightedText.tsx +87 -0
  90. package/templates/assistkick-product-system/packages/frontend/src/components/ImageLightbox.tsx +192 -0
  91. package/templates/assistkick-product-system/packages/frontend/src/components/KanbanView.tsx +2 -2
  92. package/templates/assistkick-product-system/packages/frontend/src/components/MentionPill.tsx +33 -0
  93. package/templates/assistkick-product-system/packages/frontend/src/components/MermaidBlock.tsx +148 -0
  94. package/templates/assistkick-product-system/packages/frontend/src/components/PermissionDialog.tsx +91 -0
  95. package/templates/assistkick-product-system/packages/frontend/src/components/PermissionModeSelector.tsx +229 -0
  96. package/templates/assistkick-product-system/packages/frontend/src/components/ProjectSelector.tsx +249 -83
  97. package/templates/assistkick-product-system/packages/frontend/src/components/QueuedMessageBubble.tsx +38 -0
  98. package/templates/assistkick-product-system/packages/frontend/src/components/SidePanel.tsx +212 -117
  99. package/templates/assistkick-product-system/packages/frontend/src/components/SystemPromptAccordion.tsx +48 -0
  100. package/templates/assistkick-product-system/packages/frontend/src/components/TaskIcon.tsx +11 -0
  101. package/templates/assistkick-product-system/packages/frontend/src/components/TerminalView.tsx +25 -9
  102. package/templates/assistkick-product-system/packages/frontend/src/components/ToolDiffView.tsx +114 -0
  103. package/templates/assistkick-product-system/packages/frontend/src/components/ToolResultCard.tsx +87 -0
  104. package/templates/assistkick-product-system/packages/frontend/src/components/ToolUseCard.tsx +149 -0
  105. package/templates/assistkick-product-system/packages/frontend/src/components/Toolbar.tsx +25 -8
  106. package/templates/assistkick-product-system/packages/frontend/src/components/UnifiedGitWidget.tsx +722 -0
  107. package/templates/assistkick-product-system/packages/frontend/src/components/workflow/GroupNode.tsx +2 -0
  108. package/templates/assistkick-product-system/packages/frontend/src/components/workflow/NodePalette.tsx +2 -1
  109. package/templates/assistkick-product-system/packages/frontend/src/components/workflow/ProgrammableNode.tsx +178 -0
  110. package/templates/assistkick-product-system/packages/frontend/src/components/workflow/WorkflowCanvas.tsx +3 -0
  111. package/templates/assistkick-product-system/packages/frontend/src/components/workflow/WorkflowMonitorModal.tsx +103 -9
  112. package/templates/assistkick-product-system/packages/frontend/src/components/workflow/monitor_nodes.tsx +26 -2
  113. package/templates/assistkick-product-system/packages/frontend/src/components/workflow/workflow_types.ts +42 -1
  114. package/templates/assistkick-product-system/packages/frontend/src/hooks/useDocumentTitle.ts +11 -0
  115. package/templates/assistkick-product-system/packages/frontend/src/hooks/useProjects.ts +1 -0
  116. package/templates/assistkick-product-system/packages/frontend/src/hooks/use_chat_stream.ts +826 -0
  117. package/templates/assistkick-product-system/packages/frontend/src/hooks/use_file_tree_cache.ts +69 -0
  118. package/templates/assistkick-product-system/packages/frontend/src/hooks/use_mention_autocomplete.ts +284 -0
  119. package/templates/assistkick-product-system/packages/frontend/src/lib/attachment_manager.test.ts +183 -0
  120. package/templates/assistkick-product-system/packages/frontend/src/lib/attachment_manager.ts +150 -0
  121. package/templates/assistkick-product-system/packages/frontend/src/lib/chat_message_helpers.test.ts +305 -0
  122. package/templates/assistkick-product-system/packages/frontend/src/lib/chat_message_helpers.ts +113 -0
  123. package/templates/assistkick-product-system/packages/frontend/src/lib/context_usage_helpers.test.ts +157 -0
  124. package/templates/assistkick-product-system/packages/frontend/src/lib/context_usage_helpers.ts +95 -0
  125. package/templates/assistkick-product-system/packages/frontend/src/lib/mermaid_helpers.test.ts +65 -0
  126. package/templates/assistkick-product-system/packages/frontend/src/lib/mermaid_helpers.ts +110 -0
  127. package/templates/assistkick-product-system/packages/frontend/src/lib/message_queue.ts +66 -0
  128. package/templates/assistkick-product-system/packages/frontend/src/lib/tool_use_summary.test.ts +124 -0
  129. package/templates/assistkick-product-system/packages/frontend/src/lib/tool_use_summary.ts +112 -0
  130. package/templates/assistkick-product-system/packages/frontend/src/routes/AgentsRoute.tsx +2 -0
  131. package/templates/assistkick-product-system/packages/frontend/src/routes/ChatRoute.tsx +8 -0
  132. package/templates/assistkick-product-system/packages/frontend/src/routes/CoherenceRoute.tsx +2 -0
  133. package/templates/assistkick-product-system/packages/frontend/src/routes/DashboardLayout.tsx +0 -4
  134. package/templates/assistkick-product-system/packages/frontend/src/routes/DesignSystemRoute.tsx +2 -0
  135. package/templates/assistkick-product-system/packages/frontend/src/routes/FilesRoute.tsx +2 -0
  136. package/templates/assistkick-product-system/packages/frontend/src/routes/GraphRoute.tsx +2 -0
  137. package/templates/assistkick-product-system/packages/frontend/src/routes/KanbanRoute.tsx +2 -0
  138. package/templates/assistkick-product-system/packages/frontend/src/routes/TerminalRoute.tsx +2 -0
  139. package/templates/assistkick-product-system/packages/frontend/src/routes/UsersRoute.tsx +2 -0
  140. package/templates/assistkick-product-system/packages/frontend/src/routes/VideographyRoute.tsx +2 -0
  141. package/templates/assistkick-product-system/packages/frontend/src/routes/WorkflowsRoute.tsx +2 -0
  142. package/templates/assistkick-product-system/packages/frontend/src/routes/accept_invitation.tsx +2 -0
  143. package/templates/assistkick-product-system/packages/frontend/src/routes/forgot_password.tsx +2 -0
  144. package/templates/assistkick-product-system/packages/frontend/src/routes/login.tsx +2 -0
  145. package/templates/assistkick-product-system/packages/frontend/src/routes/register.tsx +2 -0
  146. package/templates/assistkick-product-system/packages/frontend/src/routes/reset_password.tsx +2 -0
  147. package/templates/assistkick-product-system/packages/frontend/src/stores/useAttachmentStore.ts +66 -0
  148. package/templates/assistkick-product-system/packages/frontend/src/stores/useChatSessionStore.ts +107 -0
  149. package/templates/assistkick-product-system/packages/frontend/src/stores/useMessageQueueStore.ts +110 -0
  150. package/templates/assistkick-product-system/packages/frontend/src/stores/usePreviewStore.ts +78 -0
  151. package/templates/assistkick-product-system/packages/frontend/src/stores/useProjectStore.ts +7 -0
  152. package/templates/assistkick-product-system/packages/frontend/src/stores/useSidePanelStore.ts +6 -1
  153. package/templates/assistkick-product-system/packages/frontend/src/styles/index.css +30 -357
  154. package/templates/assistkick-product-system/packages/frontend/src/utils/parse_node_markdown.test.ts +115 -0
  155. package/templates/assistkick-product-system/packages/frontend/src/utils/parse_node_markdown.ts +91 -0
  156. package/templates/assistkick-product-system/packages/frontend/src/utils/preview_utils.test.ts +30 -0
  157. package/templates/assistkick-product-system/packages/frontend/src/utils/preview_utils.ts +3 -0
  158. package/templates/assistkick-product-system/packages/shared/db/migrations/0015_magenta_jazinda.sql +1 -0
  159. package/templates/assistkick-product-system/packages/shared/db/migrations/0016_giant_xorn.sql +1 -0
  160. package/templates/assistkick-product-system/packages/shared/db/migrations/0017_sloppy_mentor.sql +6 -0
  161. package/templates/assistkick-product-system/packages/shared/db/migrations/0018_vengeful_kabuki.sql +9 -0
  162. package/templates/assistkick-product-system/packages/shared/db/migrations/0019_careful_sentinels.sql +8 -0
  163. package/templates/assistkick-product-system/packages/shared/db/migrations/0020_clever_spot.sql +27 -0
  164. package/templates/assistkick-product-system/packages/shared/db/migrations/0021_graceful_hex.sql +1 -0
  165. package/templates/assistkick-product-system/packages/shared/db/migrations/0022_short_kingpin.sql +1 -0
  166. package/templates/assistkick-product-system/packages/shared/db/migrations/0023_ambiguous_sharon_carter.sql +1 -0
  167. package/templates/assistkick-product-system/packages/shared/db/migrations/0024_fat_unus.sql +1 -0
  168. package/templates/assistkick-product-system/packages/shared/db/migrations/meta/0015_snapshot.json +1552 -0
  169. package/templates/assistkick-product-system/packages/shared/db/migrations/meta/0016_snapshot.json +1560 -0
  170. package/templates/assistkick-product-system/packages/shared/db/migrations/meta/0017_snapshot.json +1598 -0
  171. package/templates/assistkick-product-system/packages/shared/db/migrations/meta/0018_snapshot.json +1657 -0
  172. package/templates/assistkick-product-system/packages/shared/db/migrations/meta/0019_snapshot.json +1709 -0
  173. package/templates/assistkick-product-system/packages/shared/db/migrations/meta/0020_snapshot.json +1733 -0
  174. package/templates/assistkick-product-system/packages/shared/db/migrations/meta/0021_snapshot.json +1740 -0
  175. package/templates/assistkick-product-system/packages/shared/db/migrations/meta/0022_snapshot.json +1755 -0
  176. package/templates/assistkick-product-system/packages/shared/db/migrations/meta/0023_snapshot.json +1762 -0
  177. package/templates/assistkick-product-system/packages/shared/db/migrations/meta/0024_snapshot.json +1769 -0
  178. package/templates/assistkick-product-system/packages/shared/db/migrations/meta/_journal.json +70 -0
  179. package/templates/assistkick-product-system/packages/shared/db/schema.ts +40 -1
  180. package/templates/assistkick-product-system/packages/shared/lib/claude-service.test.ts +236 -0
  181. package/templates/assistkick-product-system/packages/shared/lib/claude-service.ts +46 -5
  182. package/templates/assistkick-product-system/packages/shared/lib/git_workflow.ts +65 -39
  183. package/templates/assistkick-product-system/packages/shared/lib/programmable_node_executor.test.ts +173 -0
  184. package/templates/assistkick-product-system/packages/shared/lib/programmable_node_executor.ts +213 -0
  185. package/templates/assistkick-product-system/packages/shared/lib/validator.test.ts +70 -0
  186. package/templates/assistkick-product-system/packages/shared/lib/validator.ts +17 -1
  187. package/templates/assistkick-product-system/packages/shared/lib/workflow_engine.test.ts +803 -27
  188. package/templates/assistkick-product-system/packages/shared/lib/workflow_engine.ts +502 -68
  189. package/templates/assistkick-product-system/packages/shared/lib/workflow_orchestrator.ts +4 -4
  190. package/templates/assistkick-product-system/packages/shared/package.json +2 -1
  191. package/templates/assistkick-product-system/packages/shared/test_fixtures/hanging_stream.mjs +46 -0
  192. package/templates/assistkick-product-system/packages/shared/tools/add_node.test.ts +44 -0
  193. package/templates/assistkick-product-system/packages/shared/tools/add_node.ts +7 -0
  194. package/templates/assistkick-product-system/packages/shared/tools/remove_node.ts +2 -1
  195. package/templates/assistkick-product-system/packages/shared/tools/resolve_question.ts +2 -1
  196. package/templates/assistkick-product-system/packages/shared/tools/update_node.ts +2 -1
  197. package/templates/assistkick-product-system/tests/message_queue.test.ts +178 -0
  198. package/templates/assistkick-product-system/tests/message_queue_per_session.test.ts +143 -0
  199. package/templates/skills/assistkick-bootstrap/SKILL.md +26 -26
  200. package/templates/skills/assistkick-code-reviewer/SKILL.md +45 -46
  201. package/templates/skills/assistkick-db-explorer/SKILL.md +13 -13
  202. package/templates/skills/assistkick-debugger/SKILL.md +23 -23
  203. package/templates/skills/assistkick-developer/SKILL.md +59 -63
  204. package/templates/skills/assistkick-interview/SKILL.md +26 -26
  205. package/templates/skills/assistkick-video-composition-agent/SKILL.md +231 -0
  206. package/templates/skills/assistkick-video-script-writer/SKILL.md +136 -0
@@ -0,0 +1,842 @@
1
+ /**
2
+ * ChatView — main Chat v2 page component.
3
+ *
4
+ * Layout: session sidebar on the left, chat message area in the center,
5
+ * message input at the bottom. Integrates the existing ChatSessionSidebar,
6
+ * useChatStream hook, message queue, file attachments, permission dialog,
7
+ * and stop/cancel functionality.
8
+ *
9
+ * New sessions are auto-created when no session exists for the project.
10
+ */
11
+
12
+ import {useCallback, useEffect, useMemo, useRef, useState} from 'react';
13
+ import type {UseChatStreamOptions, ImageBlock, FileBlock, ChatMessage} from '../hooks/use_chat_stream';
14
+ import {useChatStream} from '../hooks/use_chat_stream';
15
+ import type {ChatSession} from '../stores/useChatSessionStore';
16
+ import {useChatSessionStore} from '../stores/useChatSessionStore';
17
+ import {useProjectStore} from '../stores/useProjectStore';
18
+ import {useAttachmentStore} from '../stores/useAttachmentStore';
19
+ import {useMessageQueueStore} from '../stores/useMessageQueueStore';
20
+ import {ChatSessionSidebar} from './ChatSessionSidebar';
21
+ import {ChatMessageBubble} from './ChatMessageBubble';
22
+ import {ChatMessageInput} from './ChatMessageInput';
23
+ import {ChatStopButton} from './ChatStopButton';
24
+ import {ChatDropZone} from './ChatDropZone';
25
+ import {AttachmentPreviewList} from './AttachmentPreviewList';
26
+ import {QueuedMessageBubble} from './QueuedMessageBubble';
27
+ import {PermissionDialog} from './PermissionDialog';
28
+ import type {PermissionMode} from './PermissionModeSelector';
29
+ import {PermissionModeSelector} from './PermissionModeSelector';
30
+ import {AttachmentManager} from '../lib/attachment_manager';
31
+ import type {SkillInfo} from '../api/client';
32
+ import {apiClient} from '../api/client';
33
+ import {Check, ChevronDown, ChevronUp, Copy, Search, Settings2, X, Zap} from 'lucide-react';
34
+ import {IconButton} from './ds/IconButton';
35
+ import {formatTokenCount} from '../lib/context_usage_helpers';
36
+ import {useFileTreeCache} from '../hooks/use_file_tree_cache';
37
+ import {ChatTodoSidebar} from './ChatTodoSidebar';
38
+ import {SystemPromptAccordion} from './SystemPromptAccordion';
39
+ import {countMatches} from './HighlightedText';
40
+
41
+ interface ChatViewProps {
42
+ visible: boolean;
43
+ }
44
+
45
+ export function ChatView({ visible }: ChatViewProps) {
46
+ const selectedProjectId = useProjectStore((s) => s.selectedProjectId);
47
+ const activeSessionId = useChatSessionStore((s) => s.activeSessionId);
48
+ const sessions = useChatSessionStore((s) => s.sessions);
49
+ const selectSession = useChatSessionStore((s) => s.selectSession);
50
+ const compactAndContinue = useChatSessionStore((s) => s.compactAndContinue);
51
+
52
+ const attachments = useAttachmentStore((s) => s.attachments);
53
+ const addUpload = useAttachmentStore((s) => s.addUpload);
54
+ const markUploaded = useAttachmentStore((s) => s.markUploaded);
55
+ const markUploadFailed = useAttachmentStore((s) => s.markUploadFailed);
56
+ const removeAttachment = useAttachmentStore((s) => s.remove);
57
+ const clearAttachments = useAttachmentStore((s) => s.clear);
58
+ const getReadyPaths = useAttachmentStore((s) => s.getReadyPaths);
59
+ const hasUploading = useAttachmentStore((s) => s.hasUploading);
60
+
61
+ const queue = useMessageQueueStore((s) => s.queue);
62
+ const enqueueMessage = useMessageQueueStore((s) => s.enqueue);
63
+ const removeFromQueue = useMessageQueueStore((s) => s.remove);
64
+ const drainAll = useMessageQueueStore((s) => s.drainAll);
65
+ const setQueueActiveSession = useMessageQueueStore((s) => s.setActiveSession);
66
+
67
+ const {
68
+ connect,
69
+ disconnect,
70
+ sendMessage,
71
+ sendMergedMessages,
72
+ stopStream,
73
+ setOnStreamEnd,
74
+ loadMessages,
75
+ attachSession,
76
+ messages,
77
+ streaming,
78
+ connected,
79
+ error,
80
+ cancelledUserMessage,
81
+ clearCancelledUserMessage,
82
+ clearMessages,
83
+ restoreMessages,
84
+ pendingPermission,
85
+ respondToPermission,
86
+ contextUsage,
87
+ initContextUsage,
88
+ systemPrompt,
89
+ initSystemPrompt,
90
+ } = useChatStream();
91
+
92
+ // File tree and skills for mention autocomplete
93
+ const filePaths = useFileTreeCache(selectedProjectId);
94
+ const [skills, setSkills] = useState<SkillInfo[]>([]);
95
+
96
+ useEffect(() => {
97
+ if (!selectedProjectId) return;
98
+ apiClient.fetchSkills(selectedProjectId)
99
+ .then(({ skills: s }) => setSkills(s))
100
+ .catch(() => {});
101
+ }, [selectedProjectId]);
102
+
103
+ const [inputValue, setInputValue] = useState('');
104
+ // Per-session caches for input and context usage
105
+ const inputCacheRef = useRef<Map<string, string>>(new Map());
106
+ const contextUsageCacheRef = useRef<Map<string, import('../hooks/use_chat_stream').ContextUsage>>(new Map());
107
+ const systemPromptCacheRef = useRef<Map<string, string>>(new Map());
108
+ // Per-session messages cache — preserves in-memory streaming messages across session switches
109
+ const messagesCacheRef = useRef<Map<string, ChatMessage[]>>(new Map());
110
+ // Track which session ID was last used for streaming so we update the right cache entry
111
+ const streamingSessionRef = useRef<string | null>(null);
112
+ const [permissionMode, setPermissionMode] = useState<PermissionMode>(() => {
113
+ const saved = localStorage.getItem('chat_permission_mode');
114
+ return saved === 'skip' || saved === 'allowed_tools' ? saved : 'skip';
115
+ });
116
+ const [allowedTools, setAllowedTools] = useState<string[]>(() => {
117
+ try {
118
+ const saved = localStorage.getItem('chat_allowed_tools');
119
+ return saved ? JSON.parse(saved) : [];
120
+ } catch { return []; }
121
+ });
122
+ useEffect(() => { localStorage.setItem('chat_permission_mode', permissionMode); }, [permissionMode]);
123
+ useEffect(() => { localStorage.setItem('chat_allowed_tools', JSON.stringify(allowedTools)); }, [allowedTools]);
124
+
125
+ const [showSettings, setShowSettings] = useState(false);
126
+ const [isNewSession, setIsNewSession] = useState(true);
127
+ const [compacting, setCompacting] = useState(false);
128
+ const [copiedSessionId, setCopiedSessionId] = useState(false);
129
+ const [searchOpen, setSearchOpen] = useState(false);
130
+ const [searchQuery, setSearchQuery] = useState('');
131
+ const [activeMatchIndex, setActiveMatchIndex] = useState(0);
132
+ const searchInputRef = useRef<HTMLInputElement>(null);
133
+ const messagesEndRef = useRef<HTMLDivElement>(null);
134
+ const messagesContainerRef = useRef<HTMLDivElement>(null);
135
+ const sessionRestoredRef = useRef(false);
136
+
137
+ // --- Search: compute total matches and per-message offsets ---
138
+ const { totalMatches, messageOffsets } = useMemo(() => {
139
+ if (!searchQuery) return { totalMatches: 0, messageOffsets: [] as number[] };
140
+ let total = 0;
141
+ const offsets: number[] = [];
142
+ for (const msg of messages) {
143
+ offsets.push(total);
144
+ for (const block of msg.content) {
145
+ if (block.type === 'text') {
146
+ total += countMatches(block.text, searchQuery);
147
+ }
148
+ }
149
+ }
150
+ return { totalMatches: total, messageOffsets: offsets };
151
+ }, [messages, searchQuery]);
152
+
153
+ // Reset active match when query changes
154
+ useEffect(() => {
155
+ setActiveMatchIndex(0);
156
+ }, [searchQuery]);
157
+
158
+ // Scroll the active match into view
159
+ useEffect(() => {
160
+ if (!searchQuery || totalMatches === 0) return;
161
+ const el = messagesContainerRef.current?.querySelector(
162
+ `[data-search-match="${activeMatchIndex}"]`,
163
+ );
164
+ el?.scrollIntoView({ behavior: 'smooth', block: 'center' });
165
+ }, [activeMatchIndex, searchQuery, totalMatches]);
166
+
167
+ // Cmd+F / Ctrl+F to toggle search
168
+ useEffect(() => {
169
+ const handler = (e: KeyboardEvent) => {
170
+ if ((e.metaKey || e.ctrlKey) && e.key === 'f') {
171
+ e.preventDefault();
172
+ setSearchOpen((prev) => {
173
+ if (!prev) {
174
+ setTimeout(() => searchInputRef.current?.focus(), 0);
175
+ } else {
176
+ setSearchQuery('');
177
+ }
178
+ return !prev;
179
+ });
180
+ }
181
+ if (e.key === 'Escape' && searchOpen) {
182
+ setSearchOpen(false);
183
+ setSearchQuery('');
184
+ }
185
+ };
186
+ window.addEventListener('keydown', handler);
187
+ return () => window.removeEventListener('keydown', handler);
188
+ }, [searchOpen]);
189
+
190
+ const handleSearchNext = useCallback(() => {
191
+ if (totalMatches === 0) return;
192
+ setActiveMatchIndex((prev) => (prev + 1) % totalMatches);
193
+ }, [totalMatches]);
194
+
195
+ const handleSearchPrev = useCallback(() => {
196
+ if (totalMatches === 0) return;
197
+ setActiveMatchIndex((prev) => (prev - 1 + totalMatches) % totalMatches);
198
+ }, [totalMatches]);
199
+
200
+ const handleCloseSearch = useCallback(() => {
201
+ setSearchOpen(false);
202
+ setSearchQuery('');
203
+ }, []);
204
+
205
+ // Find active session
206
+ const activeSession = useMemo(
207
+ () => sessions.find((s) => s.id === activeSessionId) ?? null,
208
+ [sessions, activeSessionId],
209
+ );
210
+
211
+ // Whether the currently viewed session is the one that's streaming
212
+ const isActiveSessionStreaming = streaming && activeSession?.id === streamingSessionRef.current;
213
+
214
+ // Displayed context usage: use live data when viewing the streaming session, otherwise use per-session cache.
215
+ // IMPORTANT: never fall back to the hook's global contextUsage — it may hold data from a different session.
216
+ const displayedContextUsage = useMemo(() => {
217
+ if (activeSessionId && activeSessionId === streamingSessionRef.current) {
218
+ return contextUsage;
219
+ }
220
+ if (activeSessionId) {
221
+ return contextUsageCacheRef.current.get(activeSessionId) ?? null;
222
+ }
223
+ return null;
224
+ }, [contextUsage, activeSessionId]
225
+ );
226
+
227
+ // Displayed system prompt: prefer live WS data for the streaming session, fall back to per-session cache
228
+ const displayedSystemPrompt = useMemo(() => {
229
+ if (activeSessionId && activeSessionId === streamingSessionRef.current && systemPrompt) {
230
+ return systemPrompt;
231
+ }
232
+ if (activeSessionId) {
233
+ return systemPromptCacheRef.current.get(activeSessionId) ?? null;
234
+ }
235
+ return null;
236
+ }, [systemPrompt, activeSessionId]);
237
+
238
+ // Keep per-session system prompt cache in sync
239
+ const prevSystemPromptRef = useRef(systemPrompt);
240
+ useEffect(() => {
241
+ if (!systemPrompt || systemPrompt === prevSystemPromptRef.current) return;
242
+ prevSystemPromptRef.current = systemPrompt;
243
+
244
+ const streamingSid = streamingSessionRef.current;
245
+ if (streamingSid) {
246
+ systemPromptCacheRef.current.set(streamingSid, systemPrompt);
247
+ }
248
+ }, [systemPrompt]);
249
+
250
+ // Connect WebSocket when visible
251
+ useEffect(() => {
252
+ if (visible) {
253
+ connect();
254
+ }
255
+ return () => {
256
+ disconnect();
257
+ };
258
+ }, [visible, connect, disconnect]);
259
+
260
+ // Auto-restore persisted session on mount: load messages and attempt stream re-attachment
261
+ useEffect(() => {
262
+ if (sessionRestoredRef.current) return;
263
+ if (!activeSessionId || !sessions.length || !connected) return;
264
+
265
+ const session = sessions.find((s) => s.id === activeSessionId);
266
+ if (!session) {
267
+ // Persisted session no longer exists — clear it
268
+ selectSession(null);
269
+ sessionRestoredRef.current = true;
270
+ return;
271
+ }
272
+
273
+ sessionRestoredRef.current = true;
274
+ // Initialize per-session queue for the restored session
275
+ setQueueActiveSession(session.id);
276
+ // Restore system prompt from persisted session data
277
+ if (session.systemPrompt) {
278
+ initSystemPrompt(session.systemPrompt);
279
+ systemPromptCacheRef.current.set(session.id, session.systemPrompt);
280
+ }
281
+ // Restore context usage from persisted session data and populate per-session cache
282
+ if (session.contextUsageJson) {
283
+ try {
284
+ const parsed = JSON.parse(session.contextUsageJson);
285
+ initContextUsage(parsed);
286
+ contextUsageCacheRef.current.set(session.id, parsed);
287
+ } catch { /* ignore */ }
288
+ }
289
+ loadMessages(session.id).then((count) => {
290
+ // Only mark as existing session if it has messages — a brand new session
291
+ // with no messages must use --session-id (not --resume) on the first CLI spawn
292
+ setIsNewSession(count === 0);
293
+ attachSession(session.claudeSessionId);
294
+ });
295
+ }, [activeSessionId, sessions, connected, selectSession, loadMessages, attachSession, setQueueActiveSession]);
296
+
297
+ // Restore cancelled user message text into input
298
+ useEffect(() => {
299
+ if (cancelledUserMessage) {
300
+ setInputValue(cancelledUserMessage);
301
+ clearCancelledUserMessage();
302
+ }
303
+ }, [cancelledUserMessage, clearCancelledUserMessage]);
304
+
305
+ // Keep per-session context usage cache in sync with stream updates.
306
+ // If streaming session differs from viewed session, don't let it overwrite the displayed bar.
307
+ const prevContextUsageRef = useRef(contextUsage);
308
+ useEffect(() => {
309
+ if (!contextUsage || contextUsage === prevContextUsageRef.current) return;
310
+ prevContextUsageRef.current = contextUsage;
311
+
312
+ const streamingSid = streamingSessionRef.current;
313
+ if (streamingSid) {
314
+ contextUsageCacheRef.current.set(streamingSid, contextUsage);
315
+ }
316
+ }, [contextUsage]);
317
+
318
+ // Auto-scroll to bottom when new messages arrive
319
+ useEffect(() => {
320
+ messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
321
+ }, [messages, queue]);
322
+
323
+ // Keep a ref to sessions so the onStreamEnd callback can look up session details
324
+ // without stale closure issues.
325
+ const sessionsRef = useRef(sessions);
326
+ sessionsRef.current = sessions;
327
+
328
+ // Register onStreamEnd callback for queue draining.
329
+ // Drains the *streaming* session's queue (not the active session's), so messages
330
+ // enqueued for a streaming session are sent to the correct claudeSessionId even
331
+ // if the user has switched to a different session.
332
+ useEffect(() => {
333
+ setOnStreamEnd(() => {
334
+ const streamingSid = streamingSessionRef.current;
335
+ if (!streamingSid || !selectedProjectId) return;
336
+
337
+ // Invalidate cached messages for the completed session so the next
338
+ // switch loads fresh persisted messages from the database.
339
+ messagesCacheRef.current.delete(streamingSid);
340
+
341
+ const drained = drainAll(streamingSid);
342
+ if (drained.length === 0) return;
343
+
344
+ const streamingSession = sessionsRef.current.find((s) => s.id === streamingSid);
345
+ if (!streamingSession) return;
346
+
347
+ const texts = drained.map((m) => m.text);
348
+ const opts: UseChatStreamOptions = {
349
+ projectId: selectedProjectId,
350
+ sessionId: streamingSession.id,
351
+ claudeSessionId: streamingSession.claudeSessionId,
352
+ isNewSession: false,
353
+ permissionMode,
354
+ allowedTools: permissionMode === 'allowed_tools' ? allowedTools : undefined,
355
+ };
356
+ sendMergedMessages(texts, opts);
357
+ });
358
+ return () => setOnStreamEnd(null);
359
+ }, [setOnStreamEnd, drainAll, selectedProjectId, permissionMode, allowedTools, sendMergedMessages]);
360
+
361
+ // Handle session selection — save current session state, restore new one
362
+ const handleSelectSession = useCallback(
363
+ (session: ChatSession) => {
364
+ // Save current session's input, messages, context usage, and system prompt
365
+ if (activeSessionId) {
366
+ inputCacheRef.current.set(activeSessionId, inputValue);
367
+ messagesCacheRef.current.set(activeSessionId, messages);
368
+ if (contextUsage) contextUsageCacheRef.current.set(activeSessionId, contextUsage);
369
+ if (systemPrompt) systemPromptCacheRef.current.set(activeSessionId, systemPrompt);
370
+ }
371
+
372
+ clearAttachments();
373
+
374
+ // Switch the queue store to the target session (shows that session's queued messages)
375
+ setQueueActiveSession(session.id);
376
+
377
+ // Restore input for the target session
378
+ setInputValue(inputCacheRef.current.get(session.id) ?? '');
379
+
380
+ // Restore system prompt: prefer in-memory cache, fall back to persisted DB value
381
+ const cachedPrompt = systemPromptCacheRef.current.get(session.id);
382
+ if (cachedPrompt) {
383
+ initSystemPrompt(cachedPrompt);
384
+ } else if (session.systemPrompt) {
385
+ initSystemPrompt(session.systemPrompt);
386
+ systemPromptCacheRef.current.set(session.id, session.systemPrompt);
387
+ } else {
388
+ initSystemPrompt(null);
389
+ }
390
+
391
+ // Restore context usage: prefer in-memory cache (live), fall back to persisted DB value
392
+ const cachedCtx = contextUsageCacheRef.current.get(session.id);
393
+ if (cachedCtx) {
394
+ initContextUsage(cachedCtx);
395
+ } else if (session.contextUsageJson) {
396
+ try {
397
+ const parsed = JSON.parse(session.contextUsageJson);
398
+ initContextUsage(parsed);
399
+ contextUsageCacheRef.current.set(session.id, parsed);
400
+ } catch { initContextUsage(null); }
401
+ } else {
402
+ initContextUsage(null);
403
+ }
404
+
405
+ // Restore messages: prefer in-memory cache (preserves streaming state), fall back to DB
406
+ const cachedMessages = messagesCacheRef.current.get(session.id);
407
+ if (cachedMessages) {
408
+ restoreMessages(cachedMessages);
409
+ setIsNewSession(cachedMessages.length === 0);
410
+ // If this session is currently streaming, re-attach to get catch-up content
411
+ if (session.id === streamingSessionRef.current) {
412
+ attachSession(session.claudeSessionId);
413
+ }
414
+ } else {
415
+ clearMessages();
416
+ loadMessages(session.id).then((count) => {
417
+ setIsNewSession(count === 0);
418
+ // If this session is currently streaming, re-attach to get catch-up content
419
+ if (session.id === streamingSessionRef.current) {
420
+ attachSession(session.claudeSessionId);
421
+ }
422
+ });
423
+ }
424
+ },
425
+ [activeSessionId, inputValue, messages, contextUsage, systemPrompt, clearMessages, restoreMessages, clearAttachments, loadMessages, attachSession, initContextUsage, initSystemPrompt, setQueueActiveSession],
426
+ );
427
+
428
+ // Handle selecting a newly created session
429
+ const handleNewSessionCreated = useCallback(
430
+ (session: ChatSession) => {
431
+ // Save current session's state before switching
432
+ if (activeSessionId) {
433
+ inputCacheRef.current.set(activeSessionId, inputValue);
434
+ messagesCacheRef.current.set(activeSessionId, messages);
435
+ if (contextUsage) contextUsageCacheRef.current.set(activeSessionId, contextUsage);
436
+ if (systemPrompt) systemPromptCacheRef.current.set(activeSessionId, systemPrompt);
437
+ }
438
+ clearMessages();
439
+ clearAttachments();
440
+ initSystemPrompt(null);
441
+ // Switch the queue store to the new session (starts with an empty queue)
442
+ setQueueActiveSession(session.id);
443
+ setIsNewSession(true);
444
+ setInputValue('');
445
+ },
446
+ [activeSessionId, inputValue, messages, contextUsage, systemPrompt, clearMessages, clearAttachments, initSystemPrompt, setQueueActiveSession],
447
+ );
448
+
449
+ // File handling for attachments
450
+ const handleFilesSelected = useCallback(
451
+ async (files: File[]) => {
452
+ if (!selectedProjectId) return;
453
+
454
+ for (const file of files) {
455
+ const isImage = AttachmentManager.isImage(file.type);
456
+ let preview: string | null = null;
457
+ if (isImage) {
458
+ preview = await readFileAsDataUrl(file);
459
+ }
460
+ const att = addUpload(file, preview);
461
+
462
+ // Upload the file
463
+ try {
464
+ const reader = new FileReader();
465
+ const base64 = await new Promise<string>((resolve, reject) => {
466
+ reader.onload = () => {
467
+ const result = reader.result as string;
468
+ resolve(result.split(',')[1]);
469
+ };
470
+ reader.onerror = reject;
471
+ reader.readAsDataURL(file);
472
+ });
473
+ const { path } = await apiClient.uploadChatFile(selectedProjectId, file.name, base64);
474
+ markUploaded(att.id, path);
475
+ } catch {
476
+ markUploadFailed(att.id);
477
+ }
478
+ }
479
+ },
480
+ [selectedProjectId, addUpload, markUploaded, markUploadFailed],
481
+ );
482
+
483
+ // Send message
484
+ const handleSend = useCallback(() => {
485
+ const trimmed = inputValue.trim();
486
+ if (!trimmed || !activeSession || !selectedProjectId) return;
487
+
488
+ // If this session is currently streaming, enqueue instead of sending directly.
489
+ // If a *different* session is streaming, send directly — this session is idle.
490
+ if (streaming && activeSession.id === streamingSessionRef.current) {
491
+ enqueueMessage(trimmed, activeSession.id);
492
+ setInputValue('');
493
+ return;
494
+ }
495
+
496
+ const readyPaths = getReadyPaths();
497
+
498
+ // Build attachment content blocks for inline rendering and DB persistence
499
+ const attachmentBlocks: (ImageBlock | FileBlock)[] = [];
500
+ for (const att of attachments) {
501
+ if (att.uploading || !att.path) continue;
502
+ if (AttachmentManager.isImage(att.mimeType)) {
503
+ attachmentBlocks.push({ type: 'image', path: att.path, name: att.name, mimeType: att.mimeType });
504
+ } else {
505
+ attachmentBlocks.push({ type: 'file', path: att.path, name: att.name, mimeType: att.mimeType });
506
+ }
507
+ }
508
+
509
+ const opts: UseChatStreamOptions = {
510
+ projectId: selectedProjectId,
511
+ sessionId: activeSession.id,
512
+ claudeSessionId: activeSession.claudeSessionId,
513
+ isNewSession,
514
+ permissionMode,
515
+ allowedTools: permissionMode === 'allowed_tools' ? allowedTools : undefined,
516
+ attachments: readyPaths.length > 0 ? readyPaths : undefined,
517
+ attachmentBlocks: attachmentBlocks.length > 0 ? attachmentBlocks : undefined,
518
+ };
519
+
520
+ streamingSessionRef.current = activeSession.id;
521
+ sendMessage(trimmed, opts);
522
+ setInputValue('');
523
+ clearAttachments();
524
+ setIsNewSession(false);
525
+ }, [
526
+ inputValue, activeSession, selectedProjectId, streaming,
527
+ isNewSession, permissionMode, allowedTools,
528
+ sendMessage, enqueueMessage, getReadyPaths, clearAttachments,
529
+ ]);
530
+
531
+ // Stop the streaming session's CLI process, targeting the correct claudeSessionId
532
+ const handleStop = useCallback(() => {
533
+ const streamingSid = streamingSessionRef.current;
534
+ const streamingSession = streamingSid ? sessions.find((s) => s.id === streamingSid) : null;
535
+ stopStream(streamingSession?.claudeSessionId);
536
+ }, [sessions, stopStream]);
537
+
538
+ // Permission response handler
539
+ const handlePermissionResponse = useCallback(
540
+ (requestId: string, decision: import('./PermissionDialog').PermissionDecision) => {
541
+ if (!selectedProjectId) return;
542
+ respondToPermission(requestId, selectedProjectId, decision);
543
+ },
544
+ [selectedProjectId, respondToPermission],
545
+ );
546
+
547
+ // Compact and continue: server compacts messages and creates new session with continuation context
548
+ const handleCompactAndContinue = useCallback(async () => {
549
+ if (!activeSession || !selectedProjectId || streaming || compacting) return;
550
+
551
+ setCompacting(true);
552
+ try {
553
+ await compactAndContinue(activeSession.id, selectedProjectId);
554
+
555
+ // Clear UI for the new session (store already selected it)
556
+ clearMessages();
557
+ clearAttachments();
558
+ initContextUsage(null);
559
+ initSystemPrompt(null);
560
+ setIsNewSession(true);
561
+ } catch {
562
+ // Errors will show in console; user can retry
563
+ } finally {
564
+ setCompacting(false);
565
+ }
566
+ }, [
567
+ activeSession, selectedProjectId, streaming, compacting,
568
+ compactAndContinue, clearMessages, clearAttachments, initContextUsage, initSystemPrompt,
569
+ ]);
570
+
571
+ return (
572
+ <div className="flex h-full overflow-hidden">
573
+ {/* Session sidebar */}
574
+ <ChatSessionSidebar
575
+ visible={visible}
576
+ onSelectSession={handleSelectSession}
577
+ onNewSessionCreated={handleNewSessionCreated}
578
+ />
579
+
580
+ {/* Chat area */}
581
+ <ChatDropZone onFilesDropped={handleFilesSelected} disabled={!activeSession}>
582
+ <div className="flex flex-col h-full">
583
+ {/* Header bar — fixed height to prevent layout shift when search toggles */}
584
+ <div className="flex items-center justify-between px-4 border-b border-edge shrink-0 h-11">
585
+ <div className="flex items-center gap-2 min-w-0">
586
+ <span className="text-[13px] font-mono text-content truncate">
587
+ {activeSession?.name ?? 'No session selected'}
588
+ </span>
589
+ {connected && (
590
+ <span className="w-1.5 h-1.5 rounded-full bg-accent shrink-0" title="Connected" />
591
+ )}
592
+ {!connected && visible && (
593
+ <span className="w-1.5 h-1.5 rounded-full bg-error shrink-0" title="Disconnected" />
594
+ )}
595
+ {activeSession && (
596
+ <button
597
+ type="button"
598
+ className="flex items-center gap-1 text-[11px] font-mono text-content-muted hover:text-content transition-colors cursor-pointer bg-transparent border-none p-0 shrink-0"
599
+ title="Copy session ID"
600
+ onClick={() => {
601
+ navigator.clipboard.writeText(activeSession.claudeSessionId);
602
+ setCopiedSessionId(true);
603
+ setTimeout(() => setCopiedSessionId(false), 1500);
604
+ }}
605
+ >
606
+ <span className="truncate max-w-45">{activeSession.claudeSessionId}</span>
607
+ {copiedSessionId ? <Check size={12} /> : <Copy size={12} />}
608
+ </button>
609
+ )}
610
+ </div>
611
+
612
+ {/* Search bar — right side of header */}
613
+ <div className="flex items-center gap-1.5 shrink-0">
614
+ {searchOpen ? (
615
+ <div className="flex items-center gap-1.5 bg-surface-alt border border-edge rounded-lg px-2">
616
+ <Search size={13} className="text-content-muted shrink-0" />
617
+ <input
618
+ ref={searchInputRef}
619
+ type="text"
620
+ value={searchQuery}
621
+ onChange={(e) => setSearchQuery(e.target.value)}
622
+ onKeyDown={(e) => {
623
+ if (e.key === 'Enter') {
624
+ e.shiftKey ? handleSearchPrev() : handleSearchNext();
625
+ }
626
+ if (e.key === 'Escape') handleCloseSearch();
627
+ }}
628
+ placeholder="Search…"
629
+ className="w-36 bg-transparent border-none outline-none text-[12px] font-mono text-content placeholder:text-content-muted"
630
+ autoFocus
631
+ />
632
+ {searchQuery && (
633
+ <span className="text-[10px] font-mono text-content-muted whitespace-nowrap">
634
+ {totalMatches === 0
635
+ ? '0/0'
636
+ : `${activeMatchIndex + 1}/${totalMatches}`}
637
+ </span>
638
+ )}
639
+ <IconButton label="Previous match" variant="ghost" size="sm" onClick={handleSearchPrev} disabled={totalMatches === 0}>
640
+ <ChevronUp size={12} />
641
+ </IconButton>
642
+ <IconButton label="Next match" variant="ghost" size="sm" onClick={handleSearchNext} disabled={totalMatches === 0}>
643
+ <ChevronDown size={12} />
644
+ </IconButton>
645
+ <IconButton label="Close search" variant="ghost" size="sm" onClick={handleCloseSearch}>
646
+ <X size={12} />
647
+ </IconButton>
648
+ </div>
649
+ ) : (
650
+ <IconButton
651
+ label="Search messages"
652
+ variant="ghost"
653
+ size="sm"
654
+ onClick={() => {
655
+ setSearchOpen(true);
656
+ setTimeout(() => searchInputRef.current?.focus(), 0);
657
+ }}
658
+ >
659
+ <Search size={14} strokeWidth={2} />
660
+ </IconButton>
661
+ )}
662
+ </div>
663
+ </div>
664
+
665
+ {/* Messages area + optional todo sidebar */}
666
+ <div className="flex-1 flex overflow-hidden min-h-0">
667
+ <div
668
+ ref={messagesContainerRef}
669
+ className="flex-1 overflow-y-auto overflow-x-hidden px-4 py-4 min-w-0"
670
+ >
671
+ {!activeSession && (
672
+ <div className="flex items-center justify-center h-full">
673
+ <p className="text-sm font-mono text-content-muted">
674
+ Select or create a chat session to begin
675
+ </p>
676
+ </div>
677
+ )}
678
+
679
+ {activeSession && messages.length === 0 && !isActiveSessionStreaming && (
680
+ <div className="flex items-center justify-center h-full">
681
+ <p className="text-sm font-mono text-content-muted">
682
+ Start a conversation with Claude
683
+ </p>
684
+ </div>
685
+ )}
686
+
687
+ <div className="flex flex-col gap-4 min-w-0">
688
+ {displayedSystemPrompt && (
689
+ <SystemPromptAccordion prompt={displayedSystemPrompt} />
690
+ )}
691
+
692
+ {messages.map((msg, idx) => (
693
+ <ChatMessageBubble
694
+ key={msg.id}
695
+ message={msg}
696
+ searchQuery={searchQuery}
697
+ activeMatchIndex={activeMatchIndex}
698
+ matchIndexOffset={messageOffsets[idx] ?? 0}
699
+ />
700
+ ))}
701
+
702
+ {/* Queued messages */}
703
+ {queue.map((qm) => (
704
+ <QueuedMessageBubble
705
+ key={qm.id}
706
+ id={qm.id}
707
+ text={qm.text}
708
+ onRemove={removeFromQueue}
709
+ />
710
+ ))}
711
+
712
+ <div ref={messagesEndRef} />
713
+ </div>
714
+ </div>
715
+
716
+ {/* Todo sidebar — shown when tasks exist */}
717
+ <ChatTodoSidebar messages={messages} />
718
+ </div>
719
+
720
+ {/* Error banner */}
721
+ {error && (
722
+ <div className="px-4 py-2 bg-error/10 border-t border-error/30 text-error text-[12px] font-mono shrink-0">
723
+ {error}
724
+ </div>
725
+ )}
726
+
727
+ {/* Attachment previews */}
728
+ {attachments.length > 0 && (
729
+ <div className="border-t border-edge shrink-0 pt-2">
730
+ <AttachmentPreviewList attachments={attachments} onRemove={removeAttachment} />
731
+ </div>
732
+ )}
733
+
734
+ {/* Settings panel (collapsible) */}
735
+ {showSettings && (
736
+ <div className="border-t border-edge px-4 py-3 bg-surface-alt shrink-0">
737
+ <PermissionModeSelector
738
+ mode={permissionMode}
739
+ allowedTools={allowedTools}
740
+ onModeChange={setPermissionMode}
741
+ onAllowedToolsChange={setAllowedTools}
742
+ />
743
+ </div>
744
+ )}
745
+
746
+ {/* Action buttons + context usage bar */}
747
+ <div className="px-4 py-2 shrink-0 flex items-center gap-2">
748
+ <ChatStopButton streaming={isActiveSessionStreaming} onStop={handleStop} />
749
+ {activeSession && !isActiveSessionStreaming && (
750
+ <button
751
+ type="button"
752
+ className="flex items-center gap-1.5 h-7 px-2.5 rounded text-[11px] font-mono text-content-secondary bg-transparent border border-edge hover:bg-surface-raised hover:text-content transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
753
+ onClick={handleCompactAndContinue}
754
+ disabled={compacting || messages.length === 0}
755
+ >
756
+ <Zap size={12} strokeWidth={2} className={compacting ? 'animate-pulse' : ''} />
757
+ Compact
758
+ </button>
759
+ )}
760
+ <IconButton
761
+ label="Chat settings"
762
+ variant={showSettings ? 'accent' : 'ghost'}
763
+ size="sm"
764
+ onClick={() => setShowSettings((prev) => !prev)}
765
+ >
766
+ <Settings2 size={14} strokeWidth={2} />
767
+ </IconButton>
768
+ {displayedContextUsage && (() => {
769
+ const ctxWindow = displayedContextUsage.contextWindow ?? 200_000;
770
+ const AUTOCOMPACT_BUFFER = 33_000;
771
+ const usableWindow = ctxWindow - AUTOCOMPACT_BUFFER;
772
+ const bufferPct = (AUTOCOMPACT_BUFFER / ctxWindow) * 100;
773
+ const usagePct = Math.min(Math.round((displayedContextUsage.totalTokens / ctxWindow) * 100), 100);
774
+ return (
775
+ <div className="flex-1 flex items-center gap-2">
776
+ <div className="flex-1 h-1.5 bg-edge rounded-full overflow-hidden relative">
777
+ <div
778
+ className="absolute left-0 top-0 h-full bg-accent rounded-l-full transition-all duration-300"
779
+ style={{ width: `${usagePct}%` }}
780
+ />
781
+ <div
782
+ className="absolute right-0 top-0 h-full bg-error rounded-r-full"
783
+ style={{ width: `${bufferPct}%` }}
784
+ />
785
+ </div>
786
+ <span className="text-[11px] font-mono text-content-muted whitespace-nowrap">
787
+ {formatTokenCount(displayedContextUsage.totalTokens)}/{formatTokenCount(usableWindow)}
788
+ </span>
789
+ </div>
790
+ );
791
+ })()}
792
+ </div>
793
+
794
+ {/* Input area */}
795
+ <div className="px-4 py-3 border-t border-edge shrink-0">
796
+ <ChatMessageInput
797
+ value={inputValue}
798
+ onChange={setInputValue}
799
+ onSend={handleSend}
800
+ onFilesSelected={handleFilesSelected}
801
+ disabled={!activeSession || hasUploading() || !connected}
802
+ placeholder={
803
+ !connected
804
+ ? 'Disconnected \u2014 reconnecting\u2026'
805
+ : isActiveSessionStreaming
806
+ ? 'Type to queue a message\u2026'
807
+ : 'Type a message\u2026'
808
+ }
809
+ filePaths={filePaths}
810
+ skills={skills}
811
+ />
812
+ </div>
813
+ </div>
814
+ </ChatDropZone>
815
+
816
+ {/* Permission dialog overlay */}
817
+ <PermissionDialog
818
+ request={
819
+ pendingPermission
820
+ ? {
821
+ requestId: pendingPermission.requestId,
822
+ toolName: pendingPermission.toolName,
823
+ input: pendingPermission.input,
824
+ }
825
+ : null
826
+ }
827
+ onRespond={handlePermissionResponse}
828
+ />
829
+ </div>
830
+ );
831
+ }
832
+
833
+ /**
834
+ * Read a file as a data URL for image preview.
835
+ */
836
+ const readFileAsDataUrl = (file: File): Promise<string> =>
837
+ new Promise((resolve, reject) => {
838
+ const reader = new FileReader();
839
+ reader.onload = () => resolve(reader.result as string);
840
+ reader.onerror = reject;
841
+ reader.readAsDataURL(file);
842
+ });