bingocode 1.0.1 → 1.0.3

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 (187) hide show
  1. package/bin/bingo-win.cjs +34 -3
  2. package/desktop/README.md +30 -0
  3. package/desktop/bunfig.toml +1 -0
  4. package/desktop/index.html +17 -0
  5. package/desktop/package.json +55 -0
  6. package/desktop/pnpm-lock.yaml +3832 -0
  7. package/desktop/public/app-icon.jpg +0 -0
  8. package/desktop/public/fonts/inter-latin-ext.woff2 +0 -0
  9. package/desktop/public/fonts/inter-latin.woff2 +0 -0
  10. package/desktop/public/fonts/jetbrains-mono-latin-ext.woff2 +0 -0
  11. package/desktop/public/fonts/jetbrains-mono-latin.woff2 +0 -0
  12. package/desktop/public/fonts/manrope-latin-ext.woff2 +0 -0
  13. package/desktop/public/fonts/manrope-latin.woff2 +0 -0
  14. package/desktop/public/fonts/material-symbols-outlined.woff2 +0 -0
  15. package/desktop/public/icons/bilibili.svg +1 -0
  16. package/desktop/public/icons/douyin.svg +1 -0
  17. package/desktop/public/icons/github.svg +3 -0
  18. package/desktop/public/icons/xiaohongshu.svg +1 -0
  19. package/desktop/scripts/build-macos-arm64.sh +270 -0
  20. package/desktop/scripts/build-sidecars.ts +183 -0
  21. package/desktop/scripts/build-windows-x64.ps1 +295 -0
  22. package/desktop/scripts/scan-missing-imports.ts +235 -0
  23. package/desktop/sidecars/claude-sidecar.ts +156 -0
  24. package/desktop/src/App.tsx +5 -0
  25. package/desktop/src/__tests__/agentsSettings.test.tsx +349 -0
  26. package/desktop/src/__tests__/pages.test.tsx +290 -0
  27. package/desktop/src/__tests__/skillsSettings.test.tsx +205 -0
  28. package/desktop/src/api/adapters.ts +12 -0
  29. package/desktop/src/api/agents.ts +36 -0
  30. package/desktop/src/api/cliTasks.ts +28 -0
  31. package/desktop/src/api/client.ts +63 -0
  32. package/desktop/src/api/computerUse.ts +76 -0
  33. package/desktop/src/api/filesystem.ts +30 -0
  34. package/desktop/src/api/hahaOAuth.ts +38 -0
  35. package/desktop/src/api/models.ts +28 -0
  36. package/desktop/src/api/providers.ts +63 -0
  37. package/desktop/src/api/search.ts +29 -0
  38. package/desktop/src/api/sessions.ts +56 -0
  39. package/desktop/src/api/settings.ts +20 -0
  40. package/desktop/src/api/skills.ts +19 -0
  41. package/desktop/src/api/tasks.ts +36 -0
  42. package/desktop/src/api/teams.ts +44 -0
  43. package/desktop/src/api/websocket.ts +164 -0
  44. package/desktop/src/components/chat/AskUserQuestion.tsx +268 -0
  45. package/desktop/src/components/chat/AssistantMessage.tsx +29 -0
  46. package/desktop/src/components/chat/AttachmentGallery.tsx +113 -0
  47. package/desktop/src/components/chat/ChatInput.tsx +622 -0
  48. package/desktop/src/components/chat/CodeViewer.tsx +161 -0
  49. package/desktop/src/components/chat/ComputerUsePermissionModal.test.tsx +174 -0
  50. package/desktop/src/components/chat/ComputerUsePermissionModal.tsx +311 -0
  51. package/desktop/src/components/chat/DiffViewer.tsx +157 -0
  52. package/desktop/src/components/chat/FileSearchMenu.tsx +198 -0
  53. package/desktop/src/components/chat/ImageGalleryModal.tsx +91 -0
  54. package/desktop/src/components/chat/InlineImageGallery.tsx +106 -0
  55. package/desktop/src/components/chat/InlineTaskSummary.tsx +60 -0
  56. package/desktop/src/components/chat/MermaidRenderer.test.tsx +98 -0
  57. package/desktop/src/components/chat/MermaidRenderer.tsx +361 -0
  58. package/desktop/src/components/chat/MessageActionBar.tsx +27 -0
  59. package/desktop/src/components/chat/MessageList.test.tsx +313 -0
  60. package/desktop/src/components/chat/MessageList.tsx +249 -0
  61. package/desktop/src/components/chat/PermissionDialog.tsx +262 -0
  62. package/desktop/src/components/chat/SessionTaskBar.test.tsx +99 -0
  63. package/desktop/src/components/chat/SessionTaskBar.tsx +159 -0
  64. package/desktop/src/components/chat/StreamingIndicator.tsx +41 -0
  65. package/desktop/src/components/chat/TerminalChrome.tsx +35 -0
  66. package/desktop/src/components/chat/ThinkingBlock.tsx +87 -0
  67. package/desktop/src/components/chat/ToolCallBlock.tsx +247 -0
  68. package/desktop/src/components/chat/ToolCallGroup.tsx +617 -0
  69. package/desktop/src/components/chat/ToolResultBlock.tsx +107 -0
  70. package/desktop/src/components/chat/UserMessage.tsx +38 -0
  71. package/desktop/src/components/chat/chatBlocks.test.tsx +136 -0
  72. package/desktop/src/components/chat/clipboard.ts +25 -0
  73. package/desktop/src/components/chat/composerUtils.test.ts +55 -0
  74. package/desktop/src/components/chat/composerUtils.ts +149 -0
  75. package/desktop/src/components/controls/ModelSelector.tsx +156 -0
  76. package/desktop/src/components/controls/PermissionModeSelector.tsx +229 -0
  77. package/desktop/src/components/layout/AppShell.tsx +107 -0
  78. package/desktop/src/components/layout/ContentRouter.tsx +27 -0
  79. package/desktop/src/components/layout/ProjectFilter.tsx +126 -0
  80. package/desktop/src/components/layout/Sidebar.test.tsx +158 -0
  81. package/desktop/src/components/layout/Sidebar.tsx +384 -0
  82. package/desktop/src/components/layout/StatusBar.tsx +31 -0
  83. package/desktop/src/components/layout/TabBar.test.tsx +136 -0
  84. package/desktop/src/components/layout/TabBar.tsx +318 -0
  85. package/desktop/src/components/layout/TitleBar.tsx +96 -0
  86. package/desktop/src/components/layout/WindowControls.test.tsx +69 -0
  87. package/desktop/src/components/layout/WindowControls.tsx +89 -0
  88. package/desktop/src/components/markdown/MarkdownRenderer.test.tsx +100 -0
  89. package/desktop/src/components/markdown/MarkdownRenderer.tsx +229 -0
  90. package/desktop/src/components/settings/ClaudeOfficialLogin.tsx +107 -0
  91. package/desktop/src/components/shared/Button.tsx +63 -0
  92. package/desktop/src/components/shared/CopyButton.tsx +58 -0
  93. package/desktop/src/components/shared/DirectoryPicker.tsx +316 -0
  94. package/desktop/src/components/shared/Dropdown.tsx +91 -0
  95. package/desktop/src/components/shared/Input.tsx +38 -0
  96. package/desktop/src/components/shared/Modal.tsx +65 -0
  97. package/desktop/src/components/shared/ProjectContextChip.tsx +30 -0
  98. package/desktop/src/components/shared/Spinner.tsx +30 -0
  99. package/desktop/src/components/shared/Textarea.tsx +38 -0
  100. package/desktop/src/components/shared/Toast.tsx +47 -0
  101. package/desktop/src/components/shared/UpdateChecker.tsx +90 -0
  102. package/desktop/src/components/skills/SkillDetail.test.tsx +89 -0
  103. package/desktop/src/components/skills/SkillDetail.tsx +403 -0
  104. package/desktop/src/components/skills/SkillList.tsx +254 -0
  105. package/desktop/src/components/tasks/DayOfWeekPicker.tsx +57 -0
  106. package/desktop/src/components/tasks/NewTaskModal.tsx +407 -0
  107. package/desktop/src/components/tasks/PromptEditor.tsx +74 -0
  108. package/desktop/src/components/tasks/TaskEmptyState.tsx +30 -0
  109. package/desktop/src/components/tasks/TaskList.tsx +46 -0
  110. package/desktop/src/components/tasks/TaskRow.tsx +253 -0
  111. package/desktop/src/components/tasks/TaskRunsPanel.tsx +195 -0
  112. package/desktop/src/components/teams/TeamStatusBar.tsx +147 -0
  113. package/desktop/src/config/providerPresets.ts +78 -0
  114. package/desktop/src/config/spinnerVerbs.ts +193 -0
  115. package/desktop/src/hooks/useKeyboardShortcuts.ts +60 -0
  116. package/desktop/src/i18n/index.ts +54 -0
  117. package/desktop/src/i18n/locales/en.ts +670 -0
  118. package/desktop/src/i18n/locales/zh.ts +670 -0
  119. package/desktop/src/lib/__tests__/cronDescribe.test.ts +93 -0
  120. package/desktop/src/lib/cronDescribe.ts +188 -0
  121. package/desktop/src/lib/desktopRuntime.ts +54 -0
  122. package/desktop/src/lib/parseRunOutput.ts +79 -0
  123. package/desktop/src/main.tsx +13 -0
  124. package/desktop/src/mocks/data.ts +202 -0
  125. package/desktop/src/pages/ActiveSession.test.tsx +181 -0
  126. package/desktop/src/pages/ActiveSession.tsx +219 -0
  127. package/desktop/src/pages/AdapterSettings.tsx +375 -0
  128. package/desktop/src/pages/AgentTeams.tsx +200 -0
  129. package/desktop/src/pages/ComputerUseSettings.tsx +420 -0
  130. package/desktop/src/pages/EmptySession.tsx +518 -0
  131. package/desktop/src/pages/NewTaskModal.tsx +346 -0
  132. package/desktop/src/pages/ScheduledTasks.tsx +66 -0
  133. package/desktop/src/pages/ScheduledTasksEmpty.tsx +152 -0
  134. package/desktop/src/pages/ScheduledTasksList.tsx +416 -0
  135. package/desktop/src/pages/SessionControls.tsx +460 -0
  136. package/desktop/src/pages/Settings.tsx +1448 -0
  137. package/desktop/src/pages/ToolInspection.tsx +235 -0
  138. package/desktop/src/stores/adapterStore.ts +106 -0
  139. package/desktop/src/stores/agentStore.ts +34 -0
  140. package/desktop/src/stores/chatStore.test.ts +505 -0
  141. package/desktop/src/stores/chatStore.ts +850 -0
  142. package/desktop/src/stores/cliTaskStore.ts +152 -0
  143. package/desktop/src/stores/hahaOAuthStore.test.ts +77 -0
  144. package/desktop/src/stores/hahaOAuthStore.ts +97 -0
  145. package/desktop/src/stores/providerStore.ts +101 -0
  146. package/desktop/src/stores/sessionStore.test.ts +63 -0
  147. package/desktop/src/stores/sessionStore.ts +102 -0
  148. package/desktop/src/stores/settingsStore.ts +120 -0
  149. package/desktop/src/stores/skillStore.ts +51 -0
  150. package/desktop/src/stores/tabStore.ts +169 -0
  151. package/desktop/src/stores/taskStore.ts +68 -0
  152. package/desktop/src/stores/teamStore.ts +344 -0
  153. package/desktop/src/stores/uiStore.ts +100 -0
  154. package/desktop/src/stores/updateStore.test.ts +71 -0
  155. package/desktop/src/stores/updateStore.ts +221 -0
  156. package/desktop/src/theme/globals.css +465 -0
  157. package/desktop/src/types/adapter.ts +33 -0
  158. package/desktop/src/types/chat.ts +152 -0
  159. package/desktop/src/types/cliTask.ts +24 -0
  160. package/desktop/src/types/provider.ts +62 -0
  161. package/desktop/src/types/session.ts +27 -0
  162. package/desktop/src/types/settings.ts +22 -0
  163. package/desktop/src/types/skill.ts +38 -0
  164. package/desktop/src/types/task.ts +56 -0
  165. package/desktop/src/types/team.ts +38 -0
  166. package/desktop/src-tauri/Cargo.lock +5549 -0
  167. package/desktop/src-tauri/Cargo.toml +20 -0
  168. package/desktop/src-tauri/app-icon.svg +13 -0
  169. package/desktop/src-tauri/build.rs +3 -0
  170. package/desktop/src-tauri/capabilities/default.json +106 -0
  171. package/desktop/src-tauri/icons/android/mipmap-anydpi-v26/ic_launcher.xml +5 -0
  172. package/desktop/src-tauri/icons/android/values/ic_launcher_background.xml +4 -0
  173. package/desktop/src-tauri/icons/icon.icns +0 -0
  174. package/desktop/src-tauri/icons/icon.ico +0 -0
  175. package/desktop/src-tauri/src/lib.rs +408 -0
  176. package/desktop/src-tauri/src/main.rs +6 -0
  177. package/desktop/src-tauri/tauri.conf.json +78 -0
  178. package/desktop/src-tauri/tauri.macos.conf.json +18 -0
  179. package/desktop/src-tauri/tauri.release-ci.json +5 -0
  180. package/desktop/src-tauri/tauri.windows.conf.json +16 -0
  181. package/desktop/src-tauri/windows-installer-hooks.nsh +17 -0
  182. package/desktop/tsconfig.json +25 -0
  183. package/desktop/vite.config.ts +26 -0
  184. package/desktop/vitest.config.ts +18 -0
  185. package/package.json +1 -1
  186. package/src/commands/desktop/desktop.tsx +9 -0
  187. package/src/commands/desktop/index.ts +26 -0
@@ -0,0 +1,622 @@
1
+ import { useState, useRef, useEffect, useCallback, useMemo } from 'react'
2
+ import { useTranslation } from '../../i18n'
3
+ import { useChatStore } from '../../stores/chatStore'
4
+ import { useTabStore } from '../../stores/tabStore'
5
+ import { useSessionStore } from '../../stores/sessionStore'
6
+ import { useTeamStore } from '../../stores/teamStore'
7
+ import { sessionsApi } from '../../api/sessions'
8
+ import { PermissionModeSelector } from '../controls/PermissionModeSelector'
9
+ import { ModelSelector } from '../controls/ModelSelector'
10
+ import type { AttachmentRef } from '../../types/chat'
11
+ import { AttachmentGallery } from './AttachmentGallery'
12
+ import { ProjectContextChip } from '../shared/ProjectContextChip'
13
+ import { DirectoryPicker } from '../shared/DirectoryPicker'
14
+ import { FileSearchMenu, type FileSearchMenuHandle } from './FileSearchMenu'
15
+ import {
16
+ FALLBACK_SLASH_COMMANDS,
17
+ findSlashTrigger,
18
+ mergeSlashCommands,
19
+ replaceSlashToken,
20
+ } from './composerUtils'
21
+
22
+ type GitInfo = { branch: string | null; repoName: string | null; workDir: string; changedFiles: number }
23
+
24
+ type Attachment = {
25
+ id: string
26
+ name: string
27
+ type: 'image' | 'file'
28
+ mimeType?: string
29
+ previewUrl?: string
30
+ data?: string
31
+ }
32
+
33
+ type ChatInputProps = {
34
+ variant?: 'default' | 'hero'
35
+ }
36
+
37
+ export function ChatInput({ variant = 'default' }: ChatInputProps) {
38
+ const t = useTranslation()
39
+ const [input, setInput] = useState('')
40
+ const [attachments, setAttachments] = useState<Attachment[]>([])
41
+ const [plusMenuOpen, setPlusMenuOpen] = useState(false)
42
+ const [slashMenuOpen, setSlashMenuOpen] = useState(false)
43
+ const [fileSearchOpen, setFileSearchOpen] = useState(false)
44
+ const [atFilter, setAtFilter] = useState('')
45
+ const [atCursorPos, setAtCursorPos] = useState(-1)
46
+ const [slashFilter, setSlashFilter] = useState('')
47
+ const [slashSelectedIndex, setSlashSelectedIndex] = useState(0)
48
+ const composingRef = useRef(false)
49
+ const textareaRef = useRef<HTMLTextAreaElement>(null)
50
+ const fileInputRef = useRef<HTMLInputElement>(null)
51
+ const plusMenuRef = useRef<HTMLDivElement>(null)
52
+ const slashMenuRef = useRef<HTMLDivElement>(null)
53
+ const fileSearchRef = useRef<FileSearchMenuHandle>(null)
54
+ const slashItemRefs = useRef<(HTMLButtonElement | null)[]>([])
55
+ const { sendMessage, stopGeneration } = useChatStore()
56
+ const activeTabId = useTabStore((s) => s.activeTabId)
57
+ const sessionState = useChatStore((s) => activeTabId ? s.sessions[activeTabId] : undefined)
58
+ const chatState = sessionState?.chatState ?? 'idle'
59
+ const slashCommands = sessionState?.slashCommands ?? []
60
+ const activeSession = useSessionStore((state) => activeTabId ? state.sessions.find((session) => session.id === activeTabId) ?? null : null)
61
+ const memberInfo = useTeamStore((s) => activeTabId ? s.getMemberBySessionId(activeTabId) : null)
62
+ const [gitInfo, setGitInfo] = useState<GitInfo | null>(null)
63
+ const hasMessages = useChatStore((s) => activeTabId ? (s.sessions[activeTabId]?.messages?.length ?? 0) > 0 : false)
64
+
65
+ const isMemberSession = !!memberInfo
66
+ const isActive = chatState !== 'idle'
67
+ const isWorkspaceMissing = activeSession?.workDirExists === false
68
+ const canSubmit = !isWorkspaceMissing && (input.trim().length > 0 || (!isMemberSession && attachments.length > 0))
69
+ const isHeroComposer = variant === 'hero' && !isMemberSession
70
+
71
+ useEffect(() => {
72
+ textareaRef.current?.focus()
73
+ }, [isActive])
74
+
75
+ useEffect(() => {
76
+ if (!activeTabId) {
77
+ setGitInfo(null)
78
+ return
79
+ }
80
+ if (isMemberSession) {
81
+ setGitInfo(null)
82
+ return
83
+ }
84
+ sessionsApi.getGitInfo(activeTabId).then(setGitInfo).catch(() => setGitInfo(null))
85
+ }, [activeTabId, isMemberSession])
86
+
87
+ useEffect(() => {
88
+ if (!isMemberSession) return
89
+ setAttachments([])
90
+ setPlusMenuOpen(false)
91
+ setSlashMenuOpen(false)
92
+ setFileSearchOpen(false)
93
+ }, [isMemberSession, activeTabId])
94
+
95
+ useEffect(() => {
96
+ const el = textareaRef.current
97
+ if (!el) return
98
+ el.style.height = 'auto'
99
+ el.style.height = `${Math.min(el.scrollHeight, 200)}px`
100
+ }, [input])
101
+
102
+ useEffect(() => {
103
+ if (!plusMenuOpen) return
104
+ const handleClick = (event: MouseEvent) => {
105
+ if (plusMenuRef.current && !plusMenuRef.current.contains(event.target as Node)) {
106
+ setPlusMenuOpen(false)
107
+ }
108
+ }
109
+ document.addEventListener('mousedown', handleClick)
110
+ return () => document.removeEventListener('mousedown', handleClick)
111
+ }, [plusMenuOpen])
112
+
113
+ useEffect(() => {
114
+ if (!slashMenuOpen) return
115
+ const handleClick = (event: MouseEvent) => {
116
+ if (
117
+ slashMenuRef.current &&
118
+ !slashMenuRef.current.contains(event.target as Node) &&
119
+ textareaRef.current &&
120
+ !textareaRef.current.contains(event.target as Node)
121
+ ) {
122
+ setSlashMenuOpen(false)
123
+ }
124
+ }
125
+ document.addEventListener('mousedown', handleClick)
126
+ return () => document.removeEventListener('mousedown', handleClick)
127
+ }, [slashMenuOpen])
128
+
129
+ useEffect(() => {
130
+ if (!fileSearchOpen) return
131
+ const handleClick = (event: MouseEvent) => {
132
+ const menu = document.getElementById('file-search-menu')
133
+ if (
134
+ menu &&
135
+ !menu.contains(event.target as Node) &&
136
+ textareaRef.current &&
137
+ !textareaRef.current.contains(event.target as Node)
138
+ ) {
139
+ setFileSearchOpen(false)
140
+ }
141
+ }
142
+ document.addEventListener('mousedown', handleClick)
143
+ return () => document.removeEventListener('mousedown', handleClick)
144
+ }, [fileSearchOpen])
145
+
146
+ const filteredCommands = useMemo(() => {
147
+ const source = mergeSlashCommands(slashCommands, FALLBACK_SLASH_COMMANDS)
148
+ if (!slashFilter) return source
149
+ const lower = slashFilter.toLowerCase()
150
+ return source.filter((command) => (
151
+ command.name.toLowerCase().includes(lower) ||
152
+ command.description.toLowerCase().includes(lower)
153
+ ))
154
+ }, [slashCommands, slashFilter])
155
+
156
+ useEffect(() => {
157
+ setSlashSelectedIndex(0)
158
+ }, [slashFilter])
159
+
160
+ useEffect(() => {
161
+ if (slashMenuOpen && slashItemRefs.current[slashSelectedIndex]) {
162
+ slashItemRefs.current[slashSelectedIndex]?.scrollIntoView({ block: 'nearest' })
163
+ }
164
+ }, [slashMenuOpen, slashSelectedIndex])
165
+
166
+ const detectSlashTrigger = useCallback((value: string, cursorPos: number) => {
167
+ const token = findSlashTrigger(value, cursorPos)
168
+ if (!token) {
169
+ setSlashMenuOpen(false)
170
+ return
171
+ }
172
+
173
+ setFileSearchOpen(false)
174
+ setSlashFilter(token.filter)
175
+ setSlashMenuOpen(true)
176
+ }, [])
177
+
178
+ // Detect @ trigger (file search)
179
+ const detectAtTrigger = useCallback((value: string, cursorPos: number) => {
180
+ const textBeforeCursor = value.slice(0, cursorPos)
181
+ let pos = -1
182
+
183
+ for (let i = textBeforeCursor.length - 1; i >= 0; i--) {
184
+ const ch = textBeforeCursor[i]!
185
+ if (ch === '@') {
186
+ if (i === 0 || /\s/.test(textBeforeCursor[i - 1]!)) {
187
+ pos = i
188
+ break
189
+ }
190
+ break
191
+ }
192
+ if (/\s/.test(ch)) {
193
+ break
194
+ }
195
+ }
196
+
197
+ if (pos < 0) {
198
+ setFileSearchOpen(false)
199
+ setAtFilter('')
200
+ setAtCursorPos(-1)
201
+ return
202
+ }
203
+
204
+ // Extract filter text after @
205
+ const filter = textBeforeCursor.slice(pos + 1)
206
+ setAtFilter(filter)
207
+ setAtCursorPos(cursorPos)
208
+ setSlashMenuOpen(false)
209
+ setFileSearchOpen(true)
210
+ }, [])
211
+
212
+ const handleInputChange = (event: React.ChangeEvent<HTMLTextAreaElement>) => {
213
+ const value = event.target.value
214
+ if (isMemberSession) {
215
+ setInput(value)
216
+ return
217
+ }
218
+ const cursorPos = event.target.selectionStart ?? value.length
219
+ setInput(value)
220
+ detectSlashTrigger(value, cursorPos)
221
+ detectAtTrigger(value, cursorPos)
222
+ }
223
+
224
+ const selectSlashCommand = useCallback((command: string) => {
225
+ const el = textareaRef.current
226
+ if (!el) return
227
+ const cursorPos = el.selectionStart ?? input.length
228
+ const replacement = replaceSlashToken(input, cursorPos, command)
229
+ setInput(replacement.value)
230
+ setSlashMenuOpen(false)
231
+ requestAnimationFrame(() => {
232
+ el.focus()
233
+ el.setSelectionRange(replacement.cursorPos, replacement.cursorPos)
234
+ })
235
+ }, [input])
236
+
237
+ const handleSubmit = () => {
238
+ const text = input.trim()
239
+ if ((!text && (!attachments.length || isMemberSession)) || isWorkspaceMissing) return
240
+
241
+ const attachmentPayload: AttachmentRef[] = attachments.map((attachment) => ({
242
+ type: attachment.type,
243
+ name: attachment.name,
244
+ data: attachment.data,
245
+ mimeType: attachment.mimeType,
246
+ }))
247
+
248
+ sendMessage(activeTabId!, text, attachmentPayload)
249
+ setInput('')
250
+ setAttachments([])
251
+ setPlusMenuOpen(false)
252
+ setSlashMenuOpen(false)
253
+ setFileSearchOpen(false)
254
+ }
255
+
256
+ const handleKeyDown = (event: React.KeyboardEvent) => {
257
+ // Ignore key events during IME composition (e.g. Chinese input method)
258
+ if (composingRef.current || event.nativeEvent.isComposing || event.keyCode === 229) return
259
+
260
+ // Route file search navigation keys to FileSearchMenu
261
+ if (fileSearchOpen) {
262
+ const key = event.key
263
+ if (key === 'ArrowDown' || key === 'ArrowUp' || key === 'Enter' || key === 'Tab' || key === 'Escape') {
264
+ event.preventDefault()
265
+ if (key === 'Escape') {
266
+ setFileSearchOpen(false)
267
+ setAtFilter('')
268
+ setAtCursorPos(-1)
269
+ return
270
+ }
271
+ fileSearchRef.current?.handleKeyDown(event.nativeEvent)
272
+ return
273
+ }
274
+ // Other keys (typing) should go to the textarea - let it propagate
275
+ return
276
+ }
277
+
278
+ if (slashMenuOpen && filteredCommands.length > 0) {
279
+ if (event.key === 'ArrowDown') {
280
+ event.preventDefault()
281
+ setSlashSelectedIndex((prev) => (prev + 1) % filteredCommands.length)
282
+ return
283
+ }
284
+ if (event.key === 'ArrowUp') {
285
+ event.preventDefault()
286
+ setSlashSelectedIndex((prev) => (prev - 1 + filteredCommands.length) % filteredCommands.length)
287
+ return
288
+ }
289
+ if (event.key === 'Enter' || event.key === 'Tab') {
290
+ event.preventDefault()
291
+ const selected = filteredCommands[slashSelectedIndex]
292
+ if (selected) selectSlashCommand(selected.name)
293
+ return
294
+ }
295
+ if (event.key === 'Escape') {
296
+ event.preventDefault()
297
+ setSlashMenuOpen(false)
298
+ return
299
+ }
300
+ }
301
+
302
+ if (event.key === 'Enter' && !event.shiftKey) {
303
+ event.preventDefault()
304
+ handleSubmit()
305
+ }
306
+ }
307
+
308
+ const handlePaste = (event: React.ClipboardEvent) => {
309
+ if (isMemberSession) return
310
+ const items = event.clipboardData?.items
311
+ if (!items) return
312
+
313
+ let hasImage = false
314
+ for (let i = 0; i < items.length; i += 1) {
315
+ const item = items[i]
316
+ if (!item || !item.type.startsWith('image/')) continue
317
+
318
+ hasImage = true
319
+ event.preventDefault()
320
+ const file = item.getAsFile()
321
+ if (!file) continue
322
+
323
+ const id = `att-${Date.now()}-${Math.random().toString(36).slice(2)}`
324
+ const reader = new FileReader()
325
+ reader.onload = () => {
326
+ setAttachments((prev) => [
327
+ ...prev,
328
+ {
329
+ id,
330
+ name: `pasted-image-${Date.now()}.png`,
331
+ type: 'image',
332
+ mimeType: file.type || 'image/png',
333
+ previewUrl: reader.result as string,
334
+ data: reader.result as string,
335
+ },
336
+ ])
337
+ }
338
+ reader.readAsDataURL(file)
339
+ }
340
+
341
+ if (!hasImage) return
342
+ }
343
+
344
+ const handleFileSelect = (event: React.ChangeEvent<HTMLInputElement>) => {
345
+ if (isMemberSession) return
346
+ const files = event.target.files
347
+ if (!files) return
348
+
349
+ Array.from(files).forEach((file) => {
350
+ const id = `att-${Date.now()}-${Math.random().toString(36).slice(2)}`
351
+ const isImage = file.type.startsWith('image/')
352
+ const reader = new FileReader()
353
+ reader.onload = () => {
354
+ setAttachments((prev) => [
355
+ ...prev,
356
+ {
357
+ id,
358
+ name: file.name,
359
+ type: isImage ? 'image' : 'file',
360
+ mimeType: file.type || undefined,
361
+ previewUrl: isImage ? (reader.result as string) : undefined,
362
+ data: reader.result as string,
363
+ },
364
+ ])
365
+ }
366
+ reader.readAsDataURL(file)
367
+ })
368
+
369
+ event.target.value = ''
370
+ }
371
+
372
+ const handleDrop = (event: React.DragEvent) => {
373
+ event.preventDefault()
374
+ if (isMemberSession) return
375
+ const files = event.dataTransfer.files
376
+ if (files.length > 0) {
377
+ const fakeEvent = { target: { files } } as React.ChangeEvent<HTMLInputElement>
378
+ handleFileSelect(fakeEvent)
379
+ }
380
+ }
381
+
382
+ const removeAttachment = (id: string) => {
383
+ setAttachments((prev) => prev.filter((attachment) => attachment.id !== id))
384
+ }
385
+
386
+ const insertSlashCommand = () => {
387
+ if (isMemberSession) return
388
+ const el = textareaRef.current
389
+ const cursorPos = el?.selectionStart ?? input.length
390
+ const replacement = replaceSlashToken(input, cursorPos, '', { trailingSpace: false })
391
+ setInput(replacement.value)
392
+ setPlusMenuOpen(false)
393
+ setSlashFilter('')
394
+ setSlashMenuOpen(true)
395
+ requestAnimationFrame(() => {
396
+ textareaRef.current?.focus()
397
+ textareaRef.current?.setSelectionRange(replacement.cursorPos, replacement.cursorPos)
398
+ })
399
+ }
400
+
401
+ const composerPlaceholder =
402
+ isHeroComposer
403
+ ? t('empty.placeholder')
404
+ : isWorkspaceMissing
405
+ ? t('chat.placeholderMissing')
406
+ : isMemberSession
407
+ ? t('teams.memberPlaceholder')
408
+ : t('chat.placeholder')
409
+
410
+ const addFilesLabel = isHeroComposer ? t('empty.addFiles') : t('chat.addFiles')
411
+ const slashCommandsLabel = isHeroComposer ? t('empty.slashCommands') : t('chat.slashCommands')
412
+
413
+ return (
414
+ <div className={isHeroComposer ? 'bg-[var(--color-surface)] px-8 pb-4' : 'bg-[var(--color-surface)] px-4 py-4'}>
415
+ <div className={isHeroComposer ? 'mx-auto flex w-full max-w-3xl flex-col gap-2' : 'mx-auto max-w-[860px]'}>
416
+ <div
417
+ className={isHeroComposer
418
+ ? 'glass-panel relative flex flex-col gap-3 rounded-xl p-4 transition-colors'
419
+ : 'glass-panel relative rounded-xl p-4 transition-colors'}
420
+ onDragOver={(event) => event.preventDefault()}
421
+ onDrop={handleDrop}
422
+ >
423
+ {!isMemberSession && fileSearchOpen && (
424
+ <FileSearchMenu
425
+ ref={fileSearchRef}
426
+ cwd={gitInfo?.workDir || activeSession?.workDir || ''}
427
+ filter={atFilter}
428
+ onSelect={(_path, name) => {
429
+ if (atCursorPos >= 0) {
430
+ // Insert name at cursor position, replacing filter text
431
+ const newValue = `${input.slice(0, atCursorPos)}${name}${input.slice(atCursorPos)}`
432
+ const newCursorPos = atCursorPos + name.length
433
+ setInput(newValue)
434
+ setFileSearchOpen(false)
435
+ setAtFilter('')
436
+ setAtCursorPos(-1)
437
+ void textareaRef.current?.focus()
438
+ requestAnimationFrame(() => {
439
+ textareaRef.current?.setSelectionRange(newCursorPos, newCursorPos)
440
+ })
441
+ }
442
+ }}
443
+ />
444
+ )}
445
+
446
+ {!isMemberSession && slashMenuOpen && filteredCommands.length > 0 && (
447
+ <div
448
+ ref={slashMenuRef}
449
+ className="absolute bottom-full left-0 right-0 z-50 mb-2 overflow-hidden rounded-xl border border-[var(--color-border)] bg-[var(--color-surface-container-lowest)] shadow-[var(--shadow-dropdown)]"
450
+ >
451
+ <div className="max-h-[300px] overflow-y-auto py-1">
452
+ {filteredCommands.map((command, index) => (
453
+ <button
454
+ key={command.name}
455
+ ref={(el) => { slashItemRefs.current[index] = el }}
456
+ onClick={() => selectSlashCommand(command.name)}
457
+ onMouseEnter={() => setSlashSelectedIndex(index)}
458
+ className={`flex w-full items-center gap-3 px-4 py-2.5 text-left transition-colors ${
459
+ index === slashSelectedIndex
460
+ ? 'bg-[var(--color-surface-hover)]'
461
+ : 'hover:bg-[var(--color-surface-hover)]'
462
+ }`}
463
+ >
464
+ <span className="shrink-0 text-sm font-semibold text-[var(--color-text-primary)]">
465
+ /{command.name}
466
+ </span>
467
+ <span className="min-w-0 flex-1 truncate text-xs text-[var(--color-text-tertiary)]">
468
+ {command.description}
469
+ </span>
470
+ </button>
471
+ ))}
472
+ </div>
473
+ <div className="flex items-center gap-1.5 border-t border-[var(--color-border)] px-4 py-2 text-xs text-[var(--color-text-tertiary)]">
474
+ <kbd className="rounded border border-[var(--color-border)] bg-[var(--color-surface-container-low)] px-1.5 py-0.5 font-mono text-[10px]">Up/Down</kbd>
475
+ <span>{t('chat.navigate')}</span>
476
+ <kbd className="ml-2 rounded border border-[var(--color-border)] bg-[var(--color-surface-container-low)] px-1.5 py-0.5 font-mono text-[10px]">Enter</kbd>
477
+ <span>{t('chat.select')}</span>
478
+ <kbd className="ml-2 rounded border border-[var(--color-border)] bg-[var(--color-surface-container-low)] px-1.5 py-0.5 font-mono text-[10px]">Esc</kbd>
479
+ <span>{t('chat.dismiss')}</span>
480
+ </div>
481
+ </div>
482
+ )}
483
+
484
+ {attachments.length > 0 && (
485
+ isHeroComposer ? (
486
+ <AttachmentGallery attachments={attachments} variant="composer" onRemove={removeAttachment} />
487
+ ) : (
488
+ <div className="px-3 pt-3">
489
+ <AttachmentGallery attachments={attachments} variant="composer" onRemove={removeAttachment} />
490
+ </div>
491
+ )
492
+ )}
493
+
494
+ {isHeroComposer ? (
495
+ <div className="flex items-start gap-3">
496
+ <textarea
497
+ ref={textareaRef}
498
+ value={input}
499
+ onChange={handleInputChange}
500
+ onKeyDown={handleKeyDown}
501
+ onCompositionStart={() => { composingRef.current = true }}
502
+ onCompositionEnd={() => { composingRef.current = false }}
503
+ onPaste={handlePaste}
504
+ placeholder={composerPlaceholder}
505
+ disabled={isWorkspaceMissing}
506
+ rows={2}
507
+ className="flex-1 resize-none border-none bg-transparent py-2 leading-relaxed text-[var(--color-text-primary)] outline-none placeholder:text-[var(--color-text-tertiary)] disabled:opacity-50"
508
+ />
509
+ </div>
510
+ ) : (
511
+ <textarea
512
+ ref={textareaRef}
513
+ value={input}
514
+ onChange={handleInputChange}
515
+ onKeyDown={handleKeyDown}
516
+ onCompositionStart={() => { composingRef.current = true }}
517
+ onCompositionEnd={() => { composingRef.current = false }}
518
+ onPaste={handlePaste}
519
+ placeholder={composerPlaceholder}
520
+ disabled={isWorkspaceMissing}
521
+ rows={1}
522
+ className="w-full resize-none bg-transparent py-2 pb-12 text-sm leading-relaxed text-[var(--color-text-primary)] outline-none placeholder:text-[var(--color-text-tertiary)] disabled:opacity-50"
523
+ />
524
+ )}
525
+
526
+ <div className={isHeroComposer
527
+ ? 'flex items-center justify-between border-t border-[var(--color-border-separator)] pt-3'
528
+ : 'absolute bottom-0 left-0 right-0 flex items-center justify-between border-t border-[var(--color-border-separator)] px-3 py-3'}>
529
+ <div className="flex items-center gap-2">
530
+ {!isMemberSession && (
531
+ <>
532
+ <div ref={plusMenuRef} className="relative">
533
+ <button
534
+ onClick={() => setPlusMenuOpen((value) => !value)}
535
+ aria-label="Open composer tools"
536
+ className="rounded-[var(--radius-md)] p-1.5 text-[var(--color-text-secondary)] transition-colors hover:bg-[var(--color-surface-hover)]"
537
+ >
538
+ <span className="material-symbols-outlined text-[18px]">add</span>
539
+ </button>
540
+
541
+ {plusMenuOpen && (
542
+ <div className="absolute bottom-full left-0 z-50 mb-2 w-[240px] rounded-xl border border-[var(--color-border)] bg-[var(--color-surface-container-lowest)] py-1 shadow-[var(--shadow-dropdown)]">
543
+ <button
544
+ onClick={() => {
545
+ fileInputRef.current?.click()
546
+ setPlusMenuOpen(false)
547
+ }}
548
+ className="flex w-full items-center gap-3 px-4 py-2.5 text-left transition-colors hover:bg-[var(--color-surface-hover)]"
549
+ >
550
+ <span className="material-symbols-outlined text-[18px] text-[var(--color-text-secondary)]">attach_file</span>
551
+ <span className="text-sm text-[var(--color-text-primary)]">{addFilesLabel}</span>
552
+ </button>
553
+ <button
554
+ onClick={insertSlashCommand}
555
+ className="flex w-full items-center gap-3 px-4 py-2.5 text-left transition-colors hover:bg-[var(--color-surface-hover)]"
556
+ >
557
+ <span className="w-[24px] text-center text-[18px] font-bold text-[var(--color-text-secondary)]">/</span>
558
+ <span className="text-sm text-[var(--color-text-primary)]">{slashCommandsLabel}</span>
559
+ </button>
560
+ </div>
561
+ )}
562
+ </div>
563
+
564
+ <PermissionModeSelector />
565
+ </>
566
+ )}
567
+ </div>
568
+
569
+ <div className="flex items-center gap-2">
570
+ {!isMemberSession && <ModelSelector />}
571
+ <button
572
+ onClick={!isMemberSession && isActive ? () => stopGeneration(activeTabId!) : handleSubmit}
573
+ disabled={!isMemberSession && isActive ? false : !canSubmit}
574
+ title={!isMemberSession && isActive ? t('chat.stopTitle') : undefined}
575
+ className={`flex w-[112px] items-center justify-center gap-1 rounded-lg px-3 py-1.5 text-xs font-semibold transition-all hover:brightness-105 disabled:opacity-30 ${
576
+ !isMemberSession && isActive
577
+ ? 'bg-[var(--color-error-container)] text-[var(--color-on-error-container)]'
578
+ : 'bg-[image:var(--gradient-btn-primary)] text-[var(--color-btn-primary-fg)] shadow-[var(--shadow-button-primary)]'
579
+ }`}
580
+ >
581
+ <span className="material-symbols-outlined text-[14px]">
582
+ {!isMemberSession && isActive ? 'stop' : 'arrow_forward'}
583
+ </span>
584
+ {!isMemberSession && isActive ? t('common.stop') : isMemberSession ? t('common.send') : t('common.run')}
585
+ </button>
586
+ </div>
587
+ </div>
588
+ </div>
589
+
590
+ <input ref={fileInputRef} type="file" multiple className="hidden" onChange={handleFileSelect} />
591
+
592
+ {!isMemberSession && (
593
+ <div className="mt-3 px-1">
594
+ {hasMessages ? (
595
+ <ProjectContextChip
596
+ workDir={gitInfo?.workDir || activeSession?.workDir}
597
+ repoName={gitInfo?.repoName || null}
598
+ branch={gitInfo?.branch || null}
599
+ />
600
+ ) : (
601
+ <DirectoryPicker
602
+ value={gitInfo?.workDir || activeSession?.workDir || ''}
603
+ onChange={async (newWorkDir) => {
604
+ if (!activeTabId) return
605
+ const oldId = activeTabId
606
+ const { deleteSession, createSession } = useSessionStore.getState()
607
+ const { replaceTabSession } = useTabStore.getState()
608
+ const { disconnectSession, connectToSession } = useChatStore.getState()
609
+ const newId = await createSession(newWorkDir)
610
+ disconnectSession(oldId)
611
+ replaceTabSession(oldId, newId)
612
+ connectToSession(newId)
613
+ deleteSession(oldId).catch(() => {})
614
+ }}
615
+ />
616
+ )}
617
+ </div>
618
+ )}
619
+ </div>
620
+ </div>
621
+ )
622
+ }