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.
- package/bin/bingo-win.cjs +34 -3
- package/desktop/README.md +30 -0
- package/desktop/bunfig.toml +1 -0
- package/desktop/index.html +17 -0
- package/desktop/package.json +55 -0
- package/desktop/pnpm-lock.yaml +3832 -0
- package/desktop/public/app-icon.jpg +0 -0
- package/desktop/public/fonts/inter-latin-ext.woff2 +0 -0
- package/desktop/public/fonts/inter-latin.woff2 +0 -0
- package/desktop/public/fonts/jetbrains-mono-latin-ext.woff2 +0 -0
- package/desktop/public/fonts/jetbrains-mono-latin.woff2 +0 -0
- package/desktop/public/fonts/manrope-latin-ext.woff2 +0 -0
- package/desktop/public/fonts/manrope-latin.woff2 +0 -0
- package/desktop/public/fonts/material-symbols-outlined.woff2 +0 -0
- package/desktop/public/icons/bilibili.svg +1 -0
- package/desktop/public/icons/douyin.svg +1 -0
- package/desktop/public/icons/github.svg +3 -0
- package/desktop/public/icons/xiaohongshu.svg +1 -0
- package/desktop/scripts/build-macos-arm64.sh +270 -0
- package/desktop/scripts/build-sidecars.ts +183 -0
- package/desktop/scripts/build-windows-x64.ps1 +295 -0
- package/desktop/scripts/scan-missing-imports.ts +235 -0
- package/desktop/sidecars/claude-sidecar.ts +156 -0
- package/desktop/src/App.tsx +5 -0
- package/desktop/src/__tests__/agentsSettings.test.tsx +349 -0
- package/desktop/src/__tests__/pages.test.tsx +290 -0
- package/desktop/src/__tests__/skillsSettings.test.tsx +205 -0
- package/desktop/src/api/adapters.ts +12 -0
- package/desktop/src/api/agents.ts +36 -0
- package/desktop/src/api/cliTasks.ts +28 -0
- package/desktop/src/api/client.ts +63 -0
- package/desktop/src/api/computerUse.ts +76 -0
- package/desktop/src/api/filesystem.ts +30 -0
- package/desktop/src/api/hahaOAuth.ts +38 -0
- package/desktop/src/api/models.ts +28 -0
- package/desktop/src/api/providers.ts +63 -0
- package/desktop/src/api/search.ts +29 -0
- package/desktop/src/api/sessions.ts +56 -0
- package/desktop/src/api/settings.ts +20 -0
- package/desktop/src/api/skills.ts +19 -0
- package/desktop/src/api/tasks.ts +36 -0
- package/desktop/src/api/teams.ts +44 -0
- package/desktop/src/api/websocket.ts +164 -0
- package/desktop/src/components/chat/AskUserQuestion.tsx +268 -0
- package/desktop/src/components/chat/AssistantMessage.tsx +29 -0
- package/desktop/src/components/chat/AttachmentGallery.tsx +113 -0
- package/desktop/src/components/chat/ChatInput.tsx +622 -0
- package/desktop/src/components/chat/CodeViewer.tsx +161 -0
- package/desktop/src/components/chat/ComputerUsePermissionModal.test.tsx +174 -0
- package/desktop/src/components/chat/ComputerUsePermissionModal.tsx +311 -0
- package/desktop/src/components/chat/DiffViewer.tsx +157 -0
- package/desktop/src/components/chat/FileSearchMenu.tsx +198 -0
- package/desktop/src/components/chat/ImageGalleryModal.tsx +91 -0
- package/desktop/src/components/chat/InlineImageGallery.tsx +106 -0
- package/desktop/src/components/chat/InlineTaskSummary.tsx +60 -0
- package/desktop/src/components/chat/MermaidRenderer.test.tsx +98 -0
- package/desktop/src/components/chat/MermaidRenderer.tsx +361 -0
- package/desktop/src/components/chat/MessageActionBar.tsx +27 -0
- package/desktop/src/components/chat/MessageList.test.tsx +313 -0
- package/desktop/src/components/chat/MessageList.tsx +249 -0
- package/desktop/src/components/chat/PermissionDialog.tsx +262 -0
- package/desktop/src/components/chat/SessionTaskBar.test.tsx +99 -0
- package/desktop/src/components/chat/SessionTaskBar.tsx +159 -0
- package/desktop/src/components/chat/StreamingIndicator.tsx +41 -0
- package/desktop/src/components/chat/TerminalChrome.tsx +35 -0
- package/desktop/src/components/chat/ThinkingBlock.tsx +87 -0
- package/desktop/src/components/chat/ToolCallBlock.tsx +247 -0
- package/desktop/src/components/chat/ToolCallGroup.tsx +617 -0
- package/desktop/src/components/chat/ToolResultBlock.tsx +107 -0
- package/desktop/src/components/chat/UserMessage.tsx +38 -0
- package/desktop/src/components/chat/chatBlocks.test.tsx +136 -0
- package/desktop/src/components/chat/clipboard.ts +25 -0
- package/desktop/src/components/chat/composerUtils.test.ts +55 -0
- package/desktop/src/components/chat/composerUtils.ts +149 -0
- package/desktop/src/components/controls/ModelSelector.tsx +156 -0
- package/desktop/src/components/controls/PermissionModeSelector.tsx +229 -0
- package/desktop/src/components/layout/AppShell.tsx +107 -0
- package/desktop/src/components/layout/ContentRouter.tsx +27 -0
- package/desktop/src/components/layout/ProjectFilter.tsx +126 -0
- package/desktop/src/components/layout/Sidebar.test.tsx +158 -0
- package/desktop/src/components/layout/Sidebar.tsx +384 -0
- package/desktop/src/components/layout/StatusBar.tsx +31 -0
- package/desktop/src/components/layout/TabBar.test.tsx +136 -0
- package/desktop/src/components/layout/TabBar.tsx +318 -0
- package/desktop/src/components/layout/TitleBar.tsx +96 -0
- package/desktop/src/components/layout/WindowControls.test.tsx +69 -0
- package/desktop/src/components/layout/WindowControls.tsx +89 -0
- package/desktop/src/components/markdown/MarkdownRenderer.test.tsx +100 -0
- package/desktop/src/components/markdown/MarkdownRenderer.tsx +229 -0
- package/desktop/src/components/settings/ClaudeOfficialLogin.tsx +107 -0
- package/desktop/src/components/shared/Button.tsx +63 -0
- package/desktop/src/components/shared/CopyButton.tsx +58 -0
- package/desktop/src/components/shared/DirectoryPicker.tsx +316 -0
- package/desktop/src/components/shared/Dropdown.tsx +91 -0
- package/desktop/src/components/shared/Input.tsx +38 -0
- package/desktop/src/components/shared/Modal.tsx +65 -0
- package/desktop/src/components/shared/ProjectContextChip.tsx +30 -0
- package/desktop/src/components/shared/Spinner.tsx +30 -0
- package/desktop/src/components/shared/Textarea.tsx +38 -0
- package/desktop/src/components/shared/Toast.tsx +47 -0
- package/desktop/src/components/shared/UpdateChecker.tsx +90 -0
- package/desktop/src/components/skills/SkillDetail.test.tsx +89 -0
- package/desktop/src/components/skills/SkillDetail.tsx +403 -0
- package/desktop/src/components/skills/SkillList.tsx +254 -0
- package/desktop/src/components/tasks/DayOfWeekPicker.tsx +57 -0
- package/desktop/src/components/tasks/NewTaskModal.tsx +407 -0
- package/desktop/src/components/tasks/PromptEditor.tsx +74 -0
- package/desktop/src/components/tasks/TaskEmptyState.tsx +30 -0
- package/desktop/src/components/tasks/TaskList.tsx +46 -0
- package/desktop/src/components/tasks/TaskRow.tsx +253 -0
- package/desktop/src/components/tasks/TaskRunsPanel.tsx +195 -0
- package/desktop/src/components/teams/TeamStatusBar.tsx +147 -0
- package/desktop/src/config/providerPresets.ts +78 -0
- package/desktop/src/config/spinnerVerbs.ts +193 -0
- package/desktop/src/hooks/useKeyboardShortcuts.ts +60 -0
- package/desktop/src/i18n/index.ts +54 -0
- package/desktop/src/i18n/locales/en.ts +670 -0
- package/desktop/src/i18n/locales/zh.ts +670 -0
- package/desktop/src/lib/__tests__/cronDescribe.test.ts +93 -0
- package/desktop/src/lib/cronDescribe.ts +188 -0
- package/desktop/src/lib/desktopRuntime.ts +54 -0
- package/desktop/src/lib/parseRunOutput.ts +79 -0
- package/desktop/src/main.tsx +13 -0
- package/desktop/src/mocks/data.ts +202 -0
- package/desktop/src/pages/ActiveSession.test.tsx +181 -0
- package/desktop/src/pages/ActiveSession.tsx +219 -0
- package/desktop/src/pages/AdapterSettings.tsx +375 -0
- package/desktop/src/pages/AgentTeams.tsx +200 -0
- package/desktop/src/pages/ComputerUseSettings.tsx +420 -0
- package/desktop/src/pages/EmptySession.tsx +518 -0
- package/desktop/src/pages/NewTaskModal.tsx +346 -0
- package/desktop/src/pages/ScheduledTasks.tsx +66 -0
- package/desktop/src/pages/ScheduledTasksEmpty.tsx +152 -0
- package/desktop/src/pages/ScheduledTasksList.tsx +416 -0
- package/desktop/src/pages/SessionControls.tsx +460 -0
- package/desktop/src/pages/Settings.tsx +1448 -0
- package/desktop/src/pages/ToolInspection.tsx +235 -0
- package/desktop/src/stores/adapterStore.ts +106 -0
- package/desktop/src/stores/agentStore.ts +34 -0
- package/desktop/src/stores/chatStore.test.ts +505 -0
- package/desktop/src/stores/chatStore.ts +850 -0
- package/desktop/src/stores/cliTaskStore.ts +152 -0
- package/desktop/src/stores/hahaOAuthStore.test.ts +77 -0
- package/desktop/src/stores/hahaOAuthStore.ts +97 -0
- package/desktop/src/stores/providerStore.ts +101 -0
- package/desktop/src/stores/sessionStore.test.ts +63 -0
- package/desktop/src/stores/sessionStore.ts +102 -0
- package/desktop/src/stores/settingsStore.ts +120 -0
- package/desktop/src/stores/skillStore.ts +51 -0
- package/desktop/src/stores/tabStore.ts +169 -0
- package/desktop/src/stores/taskStore.ts +68 -0
- package/desktop/src/stores/teamStore.ts +344 -0
- package/desktop/src/stores/uiStore.ts +100 -0
- package/desktop/src/stores/updateStore.test.ts +71 -0
- package/desktop/src/stores/updateStore.ts +221 -0
- package/desktop/src/theme/globals.css +465 -0
- package/desktop/src/types/adapter.ts +33 -0
- package/desktop/src/types/chat.ts +152 -0
- package/desktop/src/types/cliTask.ts +24 -0
- package/desktop/src/types/provider.ts +62 -0
- package/desktop/src/types/session.ts +27 -0
- package/desktop/src/types/settings.ts +22 -0
- package/desktop/src/types/skill.ts +38 -0
- package/desktop/src/types/task.ts +56 -0
- package/desktop/src/types/team.ts +38 -0
- package/desktop/src-tauri/Cargo.lock +5549 -0
- package/desktop/src-tauri/Cargo.toml +20 -0
- package/desktop/src-tauri/app-icon.svg +13 -0
- package/desktop/src-tauri/build.rs +3 -0
- package/desktop/src-tauri/capabilities/default.json +106 -0
- package/desktop/src-tauri/icons/android/mipmap-anydpi-v26/ic_launcher.xml +5 -0
- package/desktop/src-tauri/icons/android/values/ic_launcher_background.xml +4 -0
- package/desktop/src-tauri/icons/icon.icns +0 -0
- package/desktop/src-tauri/icons/icon.ico +0 -0
- package/desktop/src-tauri/src/lib.rs +408 -0
- package/desktop/src-tauri/src/main.rs +6 -0
- package/desktop/src-tauri/tauri.conf.json +78 -0
- package/desktop/src-tauri/tauri.macos.conf.json +18 -0
- package/desktop/src-tauri/tauri.release-ci.json +5 -0
- package/desktop/src-tauri/tauri.windows.conf.json +16 -0
- package/desktop/src-tauri/windows-installer-hooks.nsh +17 -0
- package/desktop/tsconfig.json +25 -0
- package/desktop/vite.config.ts +26 -0
- package/desktop/vitest.config.ts +18 -0
- package/package.json +1 -1
- package/src/commands/desktop/desktop.tsx +9 -0
- package/src/commands/desktop/index.ts +26 -0
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
import { useRef, useState, useEffect, useCallback } from 'react'
|
|
2
|
+
import { useTabStore, type Tab } from '../../stores/tabStore'
|
|
3
|
+
import { useChatStore } from '../../stores/chatStore'
|
|
4
|
+
import { useTranslation } from '../../i18n'
|
|
5
|
+
import { WindowControls, showWindowControls } from './WindowControls'
|
|
6
|
+
|
|
7
|
+
const TAB_WIDTH = 180
|
|
8
|
+
|
|
9
|
+
export function TabBar() {
|
|
10
|
+
const tabs = useTabStore((s) => s.tabs)
|
|
11
|
+
const activeTabId = useTabStore((s) => s.activeTabId)
|
|
12
|
+
const setActiveTab = useTabStore((s) => s.setActiveTab)
|
|
13
|
+
const closeTab = useTabStore((s) => s.closeTab)
|
|
14
|
+
const disconnectSession = useChatStore((s) => s.disconnectSession)
|
|
15
|
+
|
|
16
|
+
const moveTab = useTabStore((s) => s.moveTab)
|
|
17
|
+
const scrollRef = useRef<HTMLDivElement>(null)
|
|
18
|
+
const [canScrollLeft, setCanScrollLeft] = useState(false)
|
|
19
|
+
const [canScrollRight, setCanScrollRight] = useState(false)
|
|
20
|
+
const [contextMenu, setContextMenu] = useState<{ sessionId: string; x: number; y: number } | null>(null)
|
|
21
|
+
const [closingTabId, setClosingTabId] = useState<string | null>(null)
|
|
22
|
+
const [dragOverIndex, setDragOverIndex] = useState<number | null>(null)
|
|
23
|
+
const dragIndexRef = useRef<number | null>(null)
|
|
24
|
+
const t = useTranslation()
|
|
25
|
+
|
|
26
|
+
const updateScrollState = useCallback(() => {
|
|
27
|
+
const el = scrollRef.current
|
|
28
|
+
if (!el) return
|
|
29
|
+
setCanScrollLeft(el.scrollLeft > 0)
|
|
30
|
+
setCanScrollRight(el.scrollLeft + el.clientWidth < el.scrollWidth - 1)
|
|
31
|
+
}, [])
|
|
32
|
+
|
|
33
|
+
useEffect(() => {
|
|
34
|
+
updateScrollState()
|
|
35
|
+
const el = scrollRef.current
|
|
36
|
+
if (!el) return
|
|
37
|
+
el.addEventListener('scroll', updateScrollState)
|
|
38
|
+
const ro = new ResizeObserver(updateScrollState)
|
|
39
|
+
ro.observe(el)
|
|
40
|
+
return () => {
|
|
41
|
+
el.removeEventListener('scroll', updateScrollState)
|
|
42
|
+
ro.disconnect()
|
|
43
|
+
}
|
|
44
|
+
}, [updateScrollState, tabs.length])
|
|
45
|
+
|
|
46
|
+
useEffect(() => {
|
|
47
|
+
if (!contextMenu) return
|
|
48
|
+
const close = () => setContextMenu(null)
|
|
49
|
+
document.addEventListener('click', close)
|
|
50
|
+
return () => document.removeEventListener('click', close)
|
|
51
|
+
}, [contextMenu])
|
|
52
|
+
|
|
53
|
+
const scroll = (direction: 'left' | 'right') => {
|
|
54
|
+
const el = scrollRef.current
|
|
55
|
+
if (!el) return
|
|
56
|
+
el.scrollBy({ left: direction === 'left' ? -TAB_WIDTH : TAB_WIDTH, behavior: 'smooth' })
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const handleClose = (sessionId: string) => {
|
|
60
|
+
// Special tabs can always be closed directly
|
|
61
|
+
const tab = tabs.find((t) => t.sessionId === sessionId)
|
|
62
|
+
if (tab && tab.type !== 'session') {
|
|
63
|
+
closeTab(sessionId)
|
|
64
|
+
return
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const sessionState = useChatStore.getState().sessions[sessionId]
|
|
68
|
+
const isRunning = sessionState && sessionState.chatState !== 'idle'
|
|
69
|
+
|
|
70
|
+
if (isRunning) {
|
|
71
|
+
setClosingTabId(sessionId)
|
|
72
|
+
return
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
disconnectSession(sessionId)
|
|
76
|
+
closeTab(sessionId)
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const handleContextMenu = (e: React.MouseEvent, sessionId: string) => {
|
|
80
|
+
e.preventDefault()
|
|
81
|
+
setContextMenu({ sessionId, x: e.clientX, y: e.clientY })
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const handleCloseOthers = (sessionId: string) => {
|
|
85
|
+
setContextMenu(null)
|
|
86
|
+
const otherIds = tabs.filter((t) => t.sessionId !== sessionId).map((t) => t.sessionId)
|
|
87
|
+
for (const id of otherIds) {
|
|
88
|
+
disconnectSession(id)
|
|
89
|
+
closeTab(id)
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const handleCloseLeft = (sessionId: string) => {
|
|
94
|
+
setContextMenu(null)
|
|
95
|
+
const idx = tabs.findIndex((t) => t.sessionId === sessionId)
|
|
96
|
+
const leftIds = tabs.slice(0, idx).map((t) => t.sessionId)
|
|
97
|
+
for (const id of leftIds) {
|
|
98
|
+
disconnectSession(id)
|
|
99
|
+
closeTab(id)
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const handleCloseRight = (sessionId: string) => {
|
|
104
|
+
setContextMenu(null)
|
|
105
|
+
const idx = tabs.findIndex((t) => t.sessionId === sessionId)
|
|
106
|
+
const rightIds = tabs.slice(idx + 1).map((t) => t.sessionId)
|
|
107
|
+
for (const id of rightIds) {
|
|
108
|
+
disconnectSession(id)
|
|
109
|
+
closeTab(id)
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const handleCloseAll = () => {
|
|
114
|
+
setContextMenu(null)
|
|
115
|
+
const allIds = tabs.map((t) => t.sessionId)
|
|
116
|
+
for (const id of allIds) {
|
|
117
|
+
disconnectSession(id)
|
|
118
|
+
closeTab(id)
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const handleDragStart = (index: number) => {
|
|
123
|
+
dragIndexRef.current = index
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const handleDragOver = (e: React.DragEvent, index: number) => {
|
|
127
|
+
e.preventDefault()
|
|
128
|
+
if (dragIndexRef.current === null || dragIndexRef.current === index) {
|
|
129
|
+
setDragOverIndex(null)
|
|
130
|
+
return
|
|
131
|
+
}
|
|
132
|
+
setDragOverIndex(index)
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const handleDrop = (index: number) => {
|
|
136
|
+
if (dragIndexRef.current !== null && dragIndexRef.current !== index) {
|
|
137
|
+
moveTab(dragIndexRef.current, index)
|
|
138
|
+
}
|
|
139
|
+
dragIndexRef.current = null
|
|
140
|
+
setDragOverIndex(null)
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const handleDragEnd = () => {
|
|
144
|
+
dragIndexRef.current = null
|
|
145
|
+
setDragOverIndex(null)
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
if (tabs.length === 0 && !showWindowControls) return null
|
|
149
|
+
|
|
150
|
+
return (
|
|
151
|
+
<div
|
|
152
|
+
data-testid="tab-bar"
|
|
153
|
+
data-tauri-drag-region
|
|
154
|
+
className="flex items-stretch bg-[var(--color-surface-container)] min-h-[37px] select-none border-b border-[var(--color-border)]"
|
|
155
|
+
>
|
|
156
|
+
|
|
157
|
+
{canScrollLeft && (
|
|
158
|
+
<button onClick={() => scroll('left')} className="flex-shrink-0 w-7 h-[37px] flex items-center justify-center text-[var(--color-text-tertiary)] hover:text-[var(--color-text-primary)] hover:bg-[var(--color-surface-hover)]">
|
|
159
|
+
<span className="material-symbols-outlined text-[16px]">chevron_left</span>
|
|
160
|
+
</button>
|
|
161
|
+
)}
|
|
162
|
+
|
|
163
|
+
<div ref={scrollRef} className="flex-1 flex items-stretch overflow-x-hidden" onDragOver={(e) => e.preventDefault()}>
|
|
164
|
+
{tabs.map((tab, index) => (
|
|
165
|
+
<TabItem
|
|
166
|
+
key={tab.sessionId}
|
|
167
|
+
tab={tab}
|
|
168
|
+
isActive={tab.sessionId === activeTabId}
|
|
169
|
+
isDragOver={dragOverIndex === index}
|
|
170
|
+
onClick={() => setActiveTab(tab.sessionId)}
|
|
171
|
+
onClose={() => handleClose(tab.sessionId)}
|
|
172
|
+
onContextMenu={(e) => handleContextMenu(e, tab.sessionId)}
|
|
173
|
+
onDragStart={() => handleDragStart(index)}
|
|
174
|
+
onDragOver={(e) => handleDragOver(e, index)}
|
|
175
|
+
onDrop={() => handleDrop(index)}
|
|
176
|
+
onDragEnd={handleDragEnd}
|
|
177
|
+
/>
|
|
178
|
+
))}
|
|
179
|
+
</div>
|
|
180
|
+
|
|
181
|
+
{canScrollRight && (
|
|
182
|
+
<button onClick={() => scroll('right')} className="flex-shrink-0 w-7 h-[37px] flex items-center justify-center text-[var(--color-text-tertiary)] hover:text-[var(--color-text-primary)] hover:bg-[var(--color-surface-hover)]">
|
|
183
|
+
<span className="material-symbols-outlined text-[16px]">chevron_right</span>
|
|
184
|
+
</button>
|
|
185
|
+
)}
|
|
186
|
+
|
|
187
|
+
<WindowControls />
|
|
188
|
+
|
|
189
|
+
{contextMenu && (
|
|
190
|
+
<div
|
|
191
|
+
className="fixed z-50 bg-[var(--color-surface)] border border-[var(--color-border)] rounded-[var(--radius-md)] py-1 min-w-[160px]"
|
|
192
|
+
style={{ left: contextMenu.x, top: contextMenu.y, boxShadow: 'var(--shadow-dropdown)' }}
|
|
193
|
+
>
|
|
194
|
+
<button
|
|
195
|
+
onClick={() => { handleClose(contextMenu.sessionId); setContextMenu(null) }}
|
|
196
|
+
className="w-full px-3 py-1.5 text-xs text-left text-[var(--color-text-primary)] hover:bg-[var(--color-surface-hover)]"
|
|
197
|
+
>
|
|
198
|
+
{t('tabs.close')}
|
|
199
|
+
</button>
|
|
200
|
+
<button
|
|
201
|
+
onClick={() => handleCloseOthers(contextMenu.sessionId)}
|
|
202
|
+
className="w-full px-3 py-1.5 text-xs text-left text-[var(--color-text-primary)] hover:bg-[var(--color-surface-hover)]"
|
|
203
|
+
>
|
|
204
|
+
{t('tabs.closeOthers')}
|
|
205
|
+
</button>
|
|
206
|
+
<button
|
|
207
|
+
onClick={() => handleCloseLeft(contextMenu.sessionId)}
|
|
208
|
+
className="w-full px-3 py-1.5 text-xs text-left text-[var(--color-text-primary)] hover:bg-[var(--color-surface-hover)]"
|
|
209
|
+
>
|
|
210
|
+
{t('tabs.closeLeft')}
|
|
211
|
+
</button>
|
|
212
|
+
<button
|
|
213
|
+
onClick={() => handleCloseRight(contextMenu.sessionId)}
|
|
214
|
+
className="w-full px-3 py-1.5 text-xs text-left text-[var(--color-text-primary)] hover:bg-[var(--color-surface-hover)]"
|
|
215
|
+
>
|
|
216
|
+
{t('tabs.closeRight')}
|
|
217
|
+
</button>
|
|
218
|
+
<div className="my-1 border-t border-[var(--color-border)]" />
|
|
219
|
+
<button
|
|
220
|
+
onClick={handleCloseAll}
|
|
221
|
+
className="w-full px-3 py-1.5 text-xs text-left text-[var(--color-text-primary)] hover:bg-[var(--color-surface-hover)]"
|
|
222
|
+
>
|
|
223
|
+
{t('tabs.closeAll')}
|
|
224
|
+
</button>
|
|
225
|
+
</div>
|
|
226
|
+
)}
|
|
227
|
+
|
|
228
|
+
{closingTabId && (
|
|
229
|
+
<div className="fixed inset-0 z-[100] flex items-center justify-center bg-black/30">
|
|
230
|
+
<div className="bg-[var(--color-surface)] rounded-xl border border-[var(--color-border)] p-6 max-w-sm w-full mx-4" style={{ boxShadow: 'var(--shadow-dropdown)' }}>
|
|
231
|
+
<h3 className="text-sm font-semibold text-[var(--color-text-primary)] mb-2">{t('tabs.closeConfirmTitle')}</h3>
|
|
232
|
+
<p className="text-xs text-[var(--color-text-secondary)] mb-4">{t('tabs.closeConfirmMessage')}</p>
|
|
233
|
+
<div className="flex justify-end gap-2">
|
|
234
|
+
<button onClick={() => setClosingTabId(null)} className="px-3 py-1.5 text-xs rounded-lg border border-[var(--color-border)] text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-hover)]">
|
|
235
|
+
{t('common.cancel')}
|
|
236
|
+
</button>
|
|
237
|
+
<button
|
|
238
|
+
onClick={() => { closeTab(closingTabId); setClosingTabId(null) }}
|
|
239
|
+
className="px-3 py-1.5 text-xs rounded-lg border border-[var(--color-border)] text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-hover)]"
|
|
240
|
+
>
|
|
241
|
+
{t('tabs.closeConfirmKeep')}
|
|
242
|
+
</button>
|
|
243
|
+
<button
|
|
244
|
+
onClick={() => {
|
|
245
|
+
useChatStore.getState().stopGeneration(closingTabId)
|
|
246
|
+
disconnectSession(closingTabId)
|
|
247
|
+
closeTab(closingTabId)
|
|
248
|
+
setClosingTabId(null)
|
|
249
|
+
}}
|
|
250
|
+
className="px-3 py-1.5 text-xs rounded-lg bg-[var(--color-brand)] text-white hover:opacity-90"
|
|
251
|
+
>
|
|
252
|
+
{t('tabs.closeConfirmStop')}
|
|
253
|
+
</button>
|
|
254
|
+
</div>
|
|
255
|
+
</div>
|
|
256
|
+
</div>
|
|
257
|
+
)}
|
|
258
|
+
</div>
|
|
259
|
+
)
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function TabItem({ tab, isActive, isDragOver, onClick, onClose, onContextMenu, onDragStart, onDragOver, onDrop, onDragEnd }: {
|
|
263
|
+
tab: Tab
|
|
264
|
+
isActive: boolean
|
|
265
|
+
isDragOver: boolean
|
|
266
|
+
onClick: () => void
|
|
267
|
+
onClose: () => void
|
|
268
|
+
onContextMenu: (e: React.MouseEvent) => void
|
|
269
|
+
onDragStart: () => void
|
|
270
|
+
onDragOver: (e: React.DragEvent) => void
|
|
271
|
+
onDrop: () => void
|
|
272
|
+
onDragEnd: () => void
|
|
273
|
+
}) {
|
|
274
|
+
return (
|
|
275
|
+
<div
|
|
276
|
+
draggable
|
|
277
|
+
onClick={onClick}
|
|
278
|
+
onContextMenu={onContextMenu}
|
|
279
|
+
onDragStart={onDragStart}
|
|
280
|
+
onDragOver={onDragOver}
|
|
281
|
+
onDrop={onDrop}
|
|
282
|
+
onDragEnd={onDragEnd}
|
|
283
|
+
className={`
|
|
284
|
+
flex-shrink-0 flex items-center gap-1.5 px-3 min-h-[37px] cursor-pointer group transition-colors relative
|
|
285
|
+
${isActive
|
|
286
|
+
? 'bg-[var(--color-surface)]'
|
|
287
|
+
: 'bg-transparent hover:bg-[var(--color-surface-hover)]'
|
|
288
|
+
}
|
|
289
|
+
${isDragOver ? 'before:absolute before:left-0 before:top-[6px] before:bottom-[6px] before:w-[2px] before:bg-[var(--color-brand)] before:rounded-full' : ''}
|
|
290
|
+
`}
|
|
291
|
+
style={{ width: TAB_WIDTH, maxWidth: TAB_WIDTH }}
|
|
292
|
+
>
|
|
293
|
+
{tab.type === 'session' && tab.status === 'running' && (
|
|
294
|
+
<span className="w-1.5 h-1.5 rounded-full bg-[var(--color-success)] animate-pulse flex-shrink-0" />
|
|
295
|
+
)}
|
|
296
|
+
{tab.type === 'session' && tab.status === 'error' && (
|
|
297
|
+
<span className="w-1.5 h-1.5 rounded-full bg-[var(--color-error)] flex-shrink-0" />
|
|
298
|
+
)}
|
|
299
|
+
{tab.type === 'settings' && (
|
|
300
|
+
<span className="material-symbols-outlined text-[14px] flex-shrink-0 text-[var(--color-text-tertiary)]">settings</span>
|
|
301
|
+
)}
|
|
302
|
+
{tab.type === 'scheduled' && (
|
|
303
|
+
<span className="material-symbols-outlined text-[14px] flex-shrink-0 text-[var(--color-text-tertiary)]">schedule</span>
|
|
304
|
+
)}
|
|
305
|
+
|
|
306
|
+
<span className={`flex-1 truncate text-xs ${isActive ? 'text-[var(--color-text-primary)] font-medium' : 'text-[var(--color-text-secondary)]'}`}>
|
|
307
|
+
{tab.title || 'Untitled'}
|
|
308
|
+
</span>
|
|
309
|
+
|
|
310
|
+
<button
|
|
311
|
+
onClick={(e) => { e.stopPropagation(); onClose() }}
|
|
312
|
+
className="flex-shrink-0 w-4 h-4 flex items-center justify-center rounded opacity-0 group-hover:opacity-100 hover:bg-[var(--color-surface-hover)] transition-opacity text-[var(--color-text-tertiary)] hover:text-[var(--color-text-primary)]"
|
|
313
|
+
>
|
|
314
|
+
<span className="material-symbols-outlined text-[14px]">close</span>
|
|
315
|
+
</button>
|
|
316
|
+
</div>
|
|
317
|
+
)
|
|
318
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { useUIStore } from '../../stores/uiStore'
|
|
2
|
+
import { useTranslation } from '../../i18n'
|
|
3
|
+
|
|
4
|
+
export function TitleBar() {
|
|
5
|
+
const { activeView, setActiveView } = useUIStore()
|
|
6
|
+
const t = useTranslation()
|
|
7
|
+
|
|
8
|
+
return (
|
|
9
|
+
<div
|
|
10
|
+
className="h-[var(--titlebar-height)] flex items-center border-b border-[var(--color-border)] bg-[var(--color-surface)] select-none"
|
|
11
|
+
data-tauri-drag-region
|
|
12
|
+
>
|
|
13
|
+
{/* macOS traffic light spacer */}
|
|
14
|
+
<div className="w-[78px] flex-shrink-0" data-tauri-drag-region />
|
|
15
|
+
|
|
16
|
+
{/* Logo */}
|
|
17
|
+
<div className="flex items-center gap-2 mr-4" data-tauri-drag-region>
|
|
18
|
+
<span className="text-xs font-bold tracking-wider text-[var(--color-brand)] uppercase">Claude Code Companion</span>
|
|
19
|
+
</div>
|
|
20
|
+
|
|
21
|
+
{/* Navigation arrows */}
|
|
22
|
+
<div className="flex items-center gap-1 mr-4">
|
|
23
|
+
<button className="p-1 rounded-[var(--radius-md)] text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-hover)] hover:text-[var(--color-text-primary)] transition-colors">
|
|
24
|
+
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
25
|
+
<path d="M15 18l-6-6 6-6" />
|
|
26
|
+
</svg>
|
|
27
|
+
</button>
|
|
28
|
+
<button className="p-1 rounded-[var(--radius-md)] text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-hover)] hover:text-[var(--color-text-primary)] transition-colors">
|
|
29
|
+
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
30
|
+
<path d="M9 18l6-6-6-6" />
|
|
31
|
+
</svg>
|
|
32
|
+
</button>
|
|
33
|
+
</div>
|
|
34
|
+
|
|
35
|
+
{/* Center tabs */}
|
|
36
|
+
<div className="flex-1 flex items-center justify-center gap-1" data-tauri-drag-region>
|
|
37
|
+
<TabButton
|
|
38
|
+
active={activeView === 'code'}
|
|
39
|
+
onClick={() => setActiveView('code')}
|
|
40
|
+
icon="code"
|
|
41
|
+
>
|
|
42
|
+
{t('titlebar.code')}
|
|
43
|
+
</TabButton>
|
|
44
|
+
<TabButton
|
|
45
|
+
active={activeView === 'terminal'}
|
|
46
|
+
onClick={() => setActiveView('terminal')}
|
|
47
|
+
icon="terminal"
|
|
48
|
+
>
|
|
49
|
+
{t('titlebar.terminal')}
|
|
50
|
+
</TabButton>
|
|
51
|
+
<TabButton
|
|
52
|
+
active={activeView === 'history'}
|
|
53
|
+
onClick={() => setActiveView('history')}
|
|
54
|
+
icon="history"
|
|
55
|
+
>
|
|
56
|
+
{t('titlebar.history')}
|
|
57
|
+
</TabButton>
|
|
58
|
+
</div>
|
|
59
|
+
|
|
60
|
+
{/* Right: Settings */}
|
|
61
|
+
<div className="flex items-center gap-2 mr-4">
|
|
62
|
+
<button className="p-1.5 rounded-[var(--radius-md)] text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-hover)] hover:text-[var(--color-text-primary)] transition-colors">
|
|
63
|
+
<span className="material-symbols-outlined text-[18px]">settings</span>
|
|
64
|
+
</button>
|
|
65
|
+
</div>
|
|
66
|
+
</div>
|
|
67
|
+
)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function TabButton({
|
|
71
|
+
active,
|
|
72
|
+
onClick,
|
|
73
|
+
icon,
|
|
74
|
+
children,
|
|
75
|
+
}: {
|
|
76
|
+
active: boolean
|
|
77
|
+
onClick: () => void
|
|
78
|
+
icon: string
|
|
79
|
+
children: React.ReactNode
|
|
80
|
+
}) {
|
|
81
|
+
return (
|
|
82
|
+
<button
|
|
83
|
+
onClick={onClick}
|
|
84
|
+
className={`
|
|
85
|
+
flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium rounded-[var(--radius-md)] transition-colors duration-200
|
|
86
|
+
${active
|
|
87
|
+
? 'bg-[var(--color-surface-selected)] text-[var(--color-text-primary)]'
|
|
88
|
+
: 'text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)]'
|
|
89
|
+
}
|
|
90
|
+
`}
|
|
91
|
+
>
|
|
92
|
+
<span className="material-symbols-outlined text-[16px]">{icon}</span>
|
|
93
|
+
{children}
|
|
94
|
+
</button>
|
|
95
|
+
)
|
|
96
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
2
|
+
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
|
3
|
+
import '@testing-library/jest-dom'
|
|
4
|
+
|
|
5
|
+
const minimize = vi.fn().mockResolvedValue(undefined)
|
|
6
|
+
const toggleMaximize = vi.fn().mockResolvedValue(undefined)
|
|
7
|
+
const close = vi.fn().mockResolvedValue(undefined)
|
|
8
|
+
const isMaximized = vi.fn().mockResolvedValue(false)
|
|
9
|
+
const onResized = vi.fn().mockResolvedValue(() => {})
|
|
10
|
+
|
|
11
|
+
vi.mock('@tauri-apps/api/window', () => ({
|
|
12
|
+
getCurrentWindow: () => ({
|
|
13
|
+
minimize,
|
|
14
|
+
toggleMaximize,
|
|
15
|
+
close,
|
|
16
|
+
isMaximized,
|
|
17
|
+
onResized,
|
|
18
|
+
}),
|
|
19
|
+
}))
|
|
20
|
+
|
|
21
|
+
describe('WindowControls', () => {
|
|
22
|
+
const originalPlatform = navigator.platform
|
|
23
|
+
|
|
24
|
+
beforeEach(async () => {
|
|
25
|
+
minimize.mockClear()
|
|
26
|
+
toggleMaximize.mockClear()
|
|
27
|
+
close.mockClear()
|
|
28
|
+
isMaximized.mockClear()
|
|
29
|
+
onResized.mockClear()
|
|
30
|
+
|
|
31
|
+
Object.defineProperty(window, '__TAURI_INTERNALS__', {
|
|
32
|
+
configurable: true,
|
|
33
|
+
value: {},
|
|
34
|
+
})
|
|
35
|
+
Object.defineProperty(navigator, 'platform', {
|
|
36
|
+
configurable: true,
|
|
37
|
+
value: 'Win32',
|
|
38
|
+
})
|
|
39
|
+
vi.resetModules()
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
afterEach(() => {
|
|
43
|
+
Reflect.deleteProperty(window, '__TAURI_INTERNALS__')
|
|
44
|
+
Object.defineProperty(navigator, 'platform', {
|
|
45
|
+
configurable: true,
|
|
46
|
+
value: originalPlatform,
|
|
47
|
+
})
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
it('invokes Tauri window APIs for custom controls on Windows', async () => {
|
|
51
|
+
const { WindowControls } = await import('./WindowControls')
|
|
52
|
+
|
|
53
|
+
render(<WindowControls />)
|
|
54
|
+
|
|
55
|
+
await waitFor(() => {
|
|
56
|
+
expect(screen.getByRole('button', { name: 'Minimize window' })).toBeInTheDocument()
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
fireEvent.click(screen.getByRole('button', { name: 'Minimize window' }))
|
|
60
|
+
fireEvent.click(screen.getByRole('button', { name: 'Maximize window' }))
|
|
61
|
+
fireEvent.click(screen.getByRole('button', { name: 'Close window' }))
|
|
62
|
+
|
|
63
|
+
await waitFor(() => {
|
|
64
|
+
expect(minimize).toHaveBeenCalledTimes(1)
|
|
65
|
+
expect(toggleMaximize).toHaveBeenCalledTimes(1)
|
|
66
|
+
expect(close).toHaveBeenCalledTimes(1)
|
|
67
|
+
})
|
|
68
|
+
})
|
|
69
|
+
})
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { useState, useEffect } from 'react'
|
|
2
|
+
|
|
3
|
+
const isTauri = typeof window !== 'undefined' && ('__TAURI_INTERNALS__' in window || '__TAURI__' in window)
|
|
4
|
+
const isWindows = typeof navigator !== 'undefined' && /Win/.test(navigator.platform)
|
|
5
|
+
|
|
6
|
+
/** Whether to render custom window controls (Windows + Tauri only) */
|
|
7
|
+
export const showWindowControls = isTauri && isWindows
|
|
8
|
+
|
|
9
|
+
export function WindowControls() {
|
|
10
|
+
const [maximized, setMaximized] = useState(false)
|
|
11
|
+
const [win, setWin] = useState<{
|
|
12
|
+
minimize: () => Promise<void>
|
|
13
|
+
toggleMaximize: () => Promise<void>
|
|
14
|
+
close: () => Promise<void>
|
|
15
|
+
isMaximized: () => Promise<boolean>
|
|
16
|
+
onResized: (handler: () => void) => Promise<() => void>
|
|
17
|
+
} | null>(null)
|
|
18
|
+
|
|
19
|
+
useEffect(() => {
|
|
20
|
+
if (!showWindowControls) return
|
|
21
|
+
let unlisten: (() => void) | undefined
|
|
22
|
+
|
|
23
|
+
import('@tauri-apps/api/window')
|
|
24
|
+
.then(async ({ getCurrentWindow }) => {
|
|
25
|
+
const w = getCurrentWindow()
|
|
26
|
+
setWin(w as any)
|
|
27
|
+
setMaximized(await w.isMaximized())
|
|
28
|
+
unlisten = await w.onResized(async () => {
|
|
29
|
+
setMaximized(await w.isMaximized())
|
|
30
|
+
})
|
|
31
|
+
})
|
|
32
|
+
.catch(() => {})
|
|
33
|
+
|
|
34
|
+
return () => { unlisten?.() }
|
|
35
|
+
}, [])
|
|
36
|
+
|
|
37
|
+
const runWindowAction = (action: () => Promise<void>) => {
|
|
38
|
+
void action().catch((error) => {
|
|
39
|
+
console.error('Window control action failed', error)
|
|
40
|
+
})
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
if (!showWindowControls || !win) return null
|
|
44
|
+
|
|
45
|
+
return (
|
|
46
|
+
<div data-testid="window-controls" className="flex items-stretch flex-shrink-0 -my-px">
|
|
47
|
+
{/* Minimize */}
|
|
48
|
+
<button
|
|
49
|
+
onClick={() => runWindowAction(() => win.minimize())}
|
|
50
|
+
aria-label="Minimize window"
|
|
51
|
+
className="w-[46px] h-full flex items-center justify-center text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-hover)] transition-colors"
|
|
52
|
+
>
|
|
53
|
+
<svg width="10" height="1" viewBox="0 0 10 1">
|
|
54
|
+
<rect width="10" height="1" fill="currentColor" />
|
|
55
|
+
</svg>
|
|
56
|
+
</button>
|
|
57
|
+
|
|
58
|
+
{/* Maximize / Restore */}
|
|
59
|
+
<button
|
|
60
|
+
onClick={() => runWindowAction(() => win.toggleMaximize())}
|
|
61
|
+
aria-label={maximized ? 'Restore window' : 'Maximize window'}
|
|
62
|
+
className="w-[46px] h-full flex items-center justify-center text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-hover)] transition-colors"
|
|
63
|
+
>
|
|
64
|
+
{maximized ? (
|
|
65
|
+
<svg width="10" height="10" viewBox="0 0 10 10" fill="none" stroke="currentColor" strokeWidth="1">
|
|
66
|
+
<rect x="0" y="3" width="7" height="7" />
|
|
67
|
+
<polyline points="3,3 3,0 10,0 10,7 7,7" />
|
|
68
|
+
</svg>
|
|
69
|
+
) : (
|
|
70
|
+
<svg width="10" height="10" viewBox="0 0 10 10" fill="none" stroke="currentColor" strokeWidth="1">
|
|
71
|
+
<rect x="0.5" y="0.5" width="9" height="9" />
|
|
72
|
+
</svg>
|
|
73
|
+
)}
|
|
74
|
+
</button>
|
|
75
|
+
|
|
76
|
+
{/* Close */}
|
|
77
|
+
<button
|
|
78
|
+
onClick={() => runWindowAction(() => win.close())}
|
|
79
|
+
aria-label="Close window"
|
|
80
|
+
className="w-[46px] h-full flex items-center justify-center text-[var(--color-text-secondary)] hover:bg-[var(--color-window-close-hover)] hover:text-white transition-colors"
|
|
81
|
+
>
|
|
82
|
+
<svg width="10" height="10" viewBox="0 0 10 10" fill="none" stroke="currentColor" strokeWidth="1.2">
|
|
83
|
+
<line x1="0" y1="0" x2="10" y2="10" />
|
|
84
|
+
<line x1="10" y1="0" x2="0" y2="10" />
|
|
85
|
+
</svg>
|
|
86
|
+
</button>
|
|
87
|
+
</div>
|
|
88
|
+
)
|
|
89
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest'
|
|
2
|
+
import { render, screen } from '@testing-library/react'
|
|
3
|
+
import '@testing-library/jest-dom'
|
|
4
|
+
|
|
5
|
+
vi.mock('../chat/CodeViewer', () => ({
|
|
6
|
+
CodeViewer: ({ code, language }: { code: string; language?: string }) => (
|
|
7
|
+
<div data-testid="code-viewer" data-language={language ?? ''}>
|
|
8
|
+
{code}
|
|
9
|
+
</div>
|
|
10
|
+
),
|
|
11
|
+
}))
|
|
12
|
+
|
|
13
|
+
vi.mock('../chat/MermaidRenderer', () => ({
|
|
14
|
+
MermaidRenderer: ({ code }: { code: string }) => (
|
|
15
|
+
<div data-testid="mermaid-renderer">{code}</div>
|
|
16
|
+
),
|
|
17
|
+
}))
|
|
18
|
+
|
|
19
|
+
import { MarkdownRenderer } from './MarkdownRenderer'
|
|
20
|
+
|
|
21
|
+
describe('MarkdownRenderer', () => {
|
|
22
|
+
it('applies document prose classes and custom width classes', () => {
|
|
23
|
+
const { container } = render(
|
|
24
|
+
<MarkdownRenderer
|
|
25
|
+
content={'# Skill Title\n\nReadable paragraph text.'}
|
|
26
|
+
variant="document"
|
|
27
|
+
className="mx-auto max-w-[72ch]"
|
|
28
|
+
/>,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
const root = container.firstChild as HTMLDivElement
|
|
32
|
+
expect(root).toBeInTheDocument()
|
|
33
|
+
expect(root.className).toContain('prose-p:text-[15px]')
|
|
34
|
+
expect(root.className).toContain('prose-h2:border-b')
|
|
35
|
+
expect(root.className).toContain('mx-auto')
|
|
36
|
+
expect(root.className).toContain('max-w-[72ch]')
|
|
37
|
+
expect(screen.getByText('Skill Title')).toBeInTheDocument()
|
|
38
|
+
expect(screen.getByText('Readable paragraph text.')).toBeInTheDocument()
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
it('keeps default variant free of document-only typography classes', () => {
|
|
42
|
+
const { container } = render(
|
|
43
|
+
<MarkdownRenderer content={'## Default Heading\n\nBody copy.'} />,
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
const root = container.firstChild as HTMLDivElement
|
|
47
|
+
expect(root).toBeInTheDocument()
|
|
48
|
+
expect(root.className).not.toContain('prose-p:text-[15px]')
|
|
49
|
+
expect(root.className).not.toContain('prose-h2:border-b')
|
|
50
|
+
expect(screen.getByText('Default Heading')).toBeInTheDocument()
|
|
51
|
+
expect(screen.getByText('Body copy.')).toBeInTheDocument()
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
it('renders mermaid fenced blocks with the Mermaid renderer', () => {
|
|
55
|
+
render(<MarkdownRenderer content={'```mermaid\ngraph TB\nA-->B\n```'} />)
|
|
56
|
+
|
|
57
|
+
expect(screen.getByTestId('mermaid-renderer')).toHaveTextContent(
|
|
58
|
+
/graph TB\s+A-->B/,
|
|
59
|
+
)
|
|
60
|
+
expect(screen.queryByTestId('code-viewer')).not.toBeInTheDocument()
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
it('detects mermaid diagrams even when the fence has no language tag', () => {
|
|
64
|
+
render(<MarkdownRenderer content={'```\ngraph TB\nA-->B\n```'} />)
|
|
65
|
+
|
|
66
|
+
expect(screen.getByTestId('mermaid-renderer')).toHaveTextContent(
|
|
67
|
+
/graph TB\s+A-->B/,
|
|
68
|
+
)
|
|
69
|
+
expect(screen.queryByTestId('code-viewer')).not.toBeInTheDocument()
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
it('keeps non-mermaid code fences in the normal code viewer', () => {
|
|
73
|
+
render(<MarkdownRenderer content={'```ts\nconst value = 1\n```'} />)
|
|
74
|
+
|
|
75
|
+
expect(screen.getByTestId('code-viewer')).toHaveAttribute(
|
|
76
|
+
'data-language',
|
|
77
|
+
'ts',
|
|
78
|
+
)
|
|
79
|
+
expect(screen.queryByTestId('mermaid-renderer')).not.toBeInTheDocument()
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
it('wraps markdown tables for horizontal overflow handling', () => {
|
|
83
|
+
const { container } = render(
|
|
84
|
+
<MarkdownRenderer
|
|
85
|
+
content={'| Name | Value |\n| --- | --- |\n| `index.html` | Ready |'}
|
|
86
|
+
/>,
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
expect(container.querySelector('.md-table-wrap')).toBeInTheDocument()
|
|
90
|
+
expect(screen.getByText('index.html')).toBeInTheDocument()
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
it('opens markdown links in a new tab safely', () => {
|
|
94
|
+
render(<MarkdownRenderer content={'[OpenAI](https://openai.com)'} />)
|
|
95
|
+
|
|
96
|
+
const link = screen.getByRole('link', { name: 'OpenAI' })
|
|
97
|
+
expect(link).toHaveAttribute('target', '_blank')
|
|
98
|
+
expect(link).toHaveAttribute('rel', expect.stringContaining('noopener'))
|
|
99
|
+
})
|
|
100
|
+
})
|