返回 oh-my-ppt
ChatPanel.tsx
1 import { useEffect, useMemo, useRef, useState } from 'react'
2 import {
3 Check,
4 ChevronDown,
5 FileText,
6 Image as ImageIcon,
7 Loader2,
8 Plus,
9 Send,
10 StopCircle,
11 Video,
12 X
13 } from 'lucide-react'
14 import { cn } from '@renderer/lib/utils'
15 import { useModelAction } from '@renderer/hooks/useModelAction'
16 import {
17 useGenerateStore,
18 useSessionDetailUiStore,
19 useSessionStore,
20 useToastStore
21 } from '@renderer/store'
22 import { Button } from '../../ui/Button'
23 import { ModelSplitButton } from '../../model/ModelActionButton'
24 import {
25 DropdownMenu,
26 DropdownMenuContent,
27 DropdownMenuItem,
28 DropdownMenuTrigger
29 } from '../../ui/DropdownMenu'
30 import { Textarea } from '../../ui/Input'
31 import { ScrollArea } from '../../ui/ScrollArea'
32 import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../../ui/Select'
33 import { Tooltip, TooltipContent, TooltipTrigger } from '../../ui/Tooltip'
34 import { MessageBubble } from './MessageBubble'
35 import { useT } from '@renderer/i18n'
36 import { useChatPanelController } from '../hooks/useChatPanelController'
37 import { normalizePagesForSelection } from '../shared/pageUtils'
38 import { MAX_SELECTED_PAGES } from '@shared/generation'
39
40 export function ChatPanel({ sessionId }: { sessionId: string }): React.JSX.Element {
41 const t = useT()
42 const modelAction = useModelAction()
43 const toastWarning = useToastStore((state) => state.warning)
44 const {
45 selectedPageExists,
46 selectedPageNumber,
47 isGenerating,
48 isPageEditing,
49 isDeckEditing,
50 deckEditRetry,
51 isPlanningPageEdit,
52 pendingPageEditPlan,
53 hasActivePageEditJob,
54 progress,
55 error,
56 uploadFiles,
57 chooseAssets,
58 send,
59 confirmPageEditPlan,
60 cancelPageEditPlan,
61 retryDeckEdit,
62 cancel
63 } = useChatPanelController(sessionId)
64 const messages = useSessionStore((state) => state.currentMessages)
65 const currentPages = useGenerateStore((state) => state.currentPages)
66 const chatType = useSessionDetailUiStore((state) => state.chatType)
67 const input = useSessionDetailUiStore((state) => state.input)
68 const selectedSelector = useSessionDetailUiStore((state) => state.selectedSelector)
69 const selectorLabel = useSessionDetailUiStore((state) => state.selectorLabel)
70 const elementTag = useSessionDetailUiStore((state) => state.elementTag)
71 const elementText = useSessionDetailUiStore((state) => state.elementText)
72 const selectedElementContext = useSessionDetailUiStore((state) => state.selectedElementContext)
73 const pendingAssets = useSessionDetailUiStore((state) => state.pendingAssets)
74 const assetDragActive = useSessionDetailUiStore((state) => state.assetDragActive)
75 const isUploadingAssets = useSessionDetailUiStore((state) => state.isUploadingAssets)
76 const setChatType = useSessionDetailUiStore((state) => state.setChatType)
77 const setInput = useSessionDetailUiStore((state) => state.setInput)
78 const setAssetDragActive = useSessionDetailUiStore((state) => state.setAssetDragActive)
79 const removePendingAsset = useSessionDetailUiStore((state) => state.removePendingAsset)
80 const clearSelectedElement = useSessionDetailUiStore((state) => state.clearSelectedElement)
81 const messagesEndRef = useRef<HTMLDivElement>(null)
82 const composingRef = useRef(false)
83 const [selectedMainPageIds, setSelectedMainPageIds] = useState<string[]>([])
84 const pages = useMemo(() => normalizePagesForSelection(currentPages), [currentPages])
85 const pageIds = useMemo(() => pages.map((page) => page.pageId), [pages])
86 const effectiveMainPageIds = useMemo(
87 () => selectedMainPageIds.filter((id) => pageIds.includes(id)),
88 [pageIds, selectedMainPageIds]
89 )
90
91 useEffect(() => {
92 messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' })
93 }, [messages, isGenerating])
94
95 useEffect(() => {
96 if (effectiveMainPageIds.length !== selectedMainPageIds.length) {
97 setSelectedMainPageIds(effectiveMainPageIds)
98 }
99 }, [effectiveMainPageIds, selectedMainPageIds.length])
100
101 const contextHint =
102 chatType === 'page' && selectedPageNumber
103 ? t('sessionDetail.pageContext', { pageNumber: selectedPageNumber })
104 : t('sessionDetail.mainContext')
105 const inputPlaceholder =
106 pendingAssets.length > 0
107 ? t('sessionDetail.assetPlaceholder')
108 : chatType === 'page'
109 ? t('sessionDetail.pagePlaceholder')
110 : t('sessionDetail.mainPlaceholder')
111 const displayLabel = (() => {
112 const raw = selectorLabel || selectedSelector || ''
113 const last = raw.split(/\s+/).pop() || raw
114 return last
115 })()
116 const selectorSummary = selectedSelector
117 ? [displayLabel, elementTag ? `<${elementTag}>${elementText ? ` ${elementText}` : ''}` : '']
118 .filter(Boolean)
119 .join(' · ')
120 : ''
121 const selectedElementPropertyCount =
122 Object.keys(selectedElementContext?.attributes || {}).length +
123 Object.keys(selectedElementContext?.inlineStyle || {}).length +
124 Object.keys(selectedElementContext?.computedStyle || {}).length
125 const selectedElementBounds = selectedElementContext?.bounds
126 const selectorTitle = selectedSelector
127 ? [
128 `selector: ${selectedSelector}`,
129 selectorLabel && selectorLabel !== selectedSelector ? `label: ${selectorLabel}` : '',
130 elementTag ? `element: <${elementTag}>` : '',
131 elementText ? `text: ${elementText}` : '',
132 selectedElementBounds
133 ? `bounds: x=${selectedElementBounds.x}, y=${selectedElementBounds.y}, w=${selectedElementBounds.width}, h=${selectedElementBounds.height}`
134 : ''
135 ]
136 .filter(Boolean)
137 .join('\n')
138 : undefined
139 const sendDisabled =
140 (!input.trim() && pendingAssets.length === 0) ||
141 ((selectedSelector ? 'page' : chatType) === 'page' && !selectedPageExists)
142
143 const handleSendWithModel = async (modelConfigId?: string): Promise<void> => {
144 if (sendDisabled) return
145 try {
146 if (
147 chatType === 'main' &&
148 effectiveMainPageIds.length === 0 &&
149 pageIds.length > MAX_SELECTED_PAGES
150 ) {
151 toastWarning(
152 t('sessionDetail.mainPageScopeAllLimitReached', {
153 count: pageIds.length,
154 limit: MAX_SELECTED_PAGES
155 })
156 )
157 return
158 }
159 if (
160 chatType === 'main' &&
161 effectiveMainPageIds.length > 0 &&
162 /\b(all|every|entire)\b|全部|所有|整套|全套|每一页|每页/i.test(input)
163 ) {
164 toastWarning(t('sessionDetail.mainPageScopeConflictWarning'))
165 }
166 const resolvedModelConfigId = await modelAction.ensureModelActive(modelConfigId)
167 if (!resolvedModelConfigId) return
168 const started = await send(resolvedModelConfigId, effectiveMainPageIds)
169 if (started) setSelectedMainPageIds([])
170 } catch (err) {
171 console.error('[MessagePanel] send model activation failed', err)
172 }
173 }
174
175 const handleRetryDeckEdit = async (): Promise<void> => {
176 const modelConfigId = await modelAction.ensureModelActive()
177 if (!modelConfigId) return
178 await retryDeckEdit(modelConfigId)
179 }
180
181 const toggleMainPage = (pageId: string): void => {
182 if (
183 !selectedMainPageIds.includes(pageId) &&
184 effectiveMainPageIds.length >= MAX_SELECTED_PAGES
185 ) {
186 toastWarning(t('sessionDetail.mainPageScopeLimitReached', { count: MAX_SELECTED_PAGES }))
187 return
188 }
189 setSelectedMainPageIds((current) =>
190 current.includes(pageId) ? current.filter((id) => id !== pageId) : [...current, pageId]
191 )
192 }
193
194 const selectedSingleMainPage =
195 effectiveMainPageIds.length === 1
196 ? pages.find((page) => page.pageId === effectiveMainPageIds[0])
197 : undefined
198 const mainPageScopeLabel =
199 effectiveMainPageIds.length === 0
200 ? t('sessionDetail.mainPageScopeAll')
201 : effectiveMainPageIds.length === 1
202 ? selectedSingleMainPage?.pageNumber
203 ? `P${selectedSingleMainPage.pageNumber}`
204 : effectiveMainPageIds[0]
205 : t('sessionDetail.mainPageScopeCount', { count: effectiveMainPageIds.length })
206
207 return (
208 <>
209 <div className="relative mx-2.5 mt-2.5 overflow-hidden rounded-[1.35rem] border border-[#e1d6c4]/72 bg-[#fffaf1]/78 px-3 pb-2.5 pt-3 shadow-[0_4px_12px_rgba(77,61,43,0.06)]">
210 <div className="pointer-events-none absolute -right-8 -top-10 h-28 w-28 rounded-[30%_70%_70%_30%/30%_30%_70%_70%] bg-[#c7d9b4]/12" />
211 <div className="relative flex flex-col gap-2">
212 <h3 className="text-sm font-semibold tracking-[0.04em] text-[#34402c]">
213 {t('sessionDetail.messageTitle')}
214 </h3>
215 <div className="flex items-center justify-between gap-2 text-xs text-[#6d604d]">
216 <span>{t('sessionDetail.context')}</span>
217 <Select
218 value={chatType}
219 onValueChange={(value) => setChatType(value === 'page' ? 'page' : 'main')}
220 >
221 <SelectTrigger className="h-8 w-[132px] rounded-full border-[#ded2bd]/70 bg-[#fffdf8]/82 px-3 py-1 text-xs text-[#3e4a32] shadow-none">
222 <SelectValue placeholder={t('sessionDetail.contextPlaceholder')} />
223 </SelectTrigger>
224 <SelectContent>
225 <SelectItem value="page" disabled={!selectedPageExists}>
226 {t('sessionDetail.currentPage')}
227 </SelectItem>
228 <SelectItem value="main">{t('sessionDetail.mainSession')}</SelectItem>
229 </SelectContent>
230 </Select>
231 </div>
232 </div>
233 </div>
234
235 <ScrollArea
236 data-messages-container
237 className="min-h-0 flex-1"
238 viewportClassName="px-2.5 py-2"
239 >
240 {messages.length === 0 && !isGenerating ? (
241 <div className="mt-24 flex min-h-full items-center justify-center text-sm text-[#7a6b56]">
242 {t('sessionDetail.emptyMessages')}
243 </div>
244 ) : (
245 <div className="flex min-h-full flex-col justify-end gap-2.5">
246 {messages.map((msg) => (
247 <MessageBubble key={msg.id} message={msg} />
248 ))}
249
250 {error && (
251 <div className="rounded-[1.15rem] bg-[rgba(217,124,139,0.12)] px-3 py-2 text-sm text-destructive">
252 {error}
253 </div>
254 )}
255 {(isPageEditing || isDeckEditing) && (
256 <div className="flex items-center gap-2 rounded-[1.15rem] border border-[#c7d9b4]/70 bg-[#edf5e5]/76 px-3 py-2 text-sm text-[#4f6340]">
257 <Loader2 className="h-4 w-4 shrink-0 animate-spin" />
258 <span className="min-w-0 flex-1 break-words">
259 {progress?.label || t('sessionDetail.activityProcessing')}
260 </span>
261 <span className="shrink-0 text-xs tabular-nums text-[#6d7b5d]">
262 {Math.round(progress?.progress || 0)}%
263 </span>
264 </div>
265 )}
266 {deckEditRetry && !isDeckEditing && (
267 <div className="flex items-center gap-2 rounded-lg border border-[#d8c48b]/75 bg-[#fff8df] px-3 py-2 text-sm text-[#765b18]">
268 <span className="min-w-0 flex-1 break-words">
269 {t('sessionDetail.activityPartialCompleted', {
270 count: deckEditRetry.failedPageCount
271 })}
272 </span>
273 <Button size="sm" onClick={() => void handleRetryDeckEdit()} className="h-8 px-2.5 text-xs">
274 {t('sessionDetail.activityRetryFailedPages', {
275 count: deckEditRetry.failedPageCount
276 })}
277 </Button>
278 </div>
279 )}
280 {isPlanningPageEdit && (
281 <div className="flex items-center gap-2 rounded-lg border border-[#c7d9b4]/70 bg-[#edf5e5]/76 px-3 py-2 text-sm text-[#4f6340]" aria-live="polite">
282 <Loader2 className="h-4 w-4 shrink-0 animate-spin" />
283 <span>{t('sessionDetail.pageEditPlanning')}</span>
284 </div>
285 )}
286 {pendingPageEditPlan && (
287 <section className="rounded-lg border border-[#c7d9b4]/80 bg-[#f7fbf2] px-3 py-2.5 text-sm text-[#405333]" aria-live="polite">
288 <div className="flex items-start justify-between gap-3">
289 <h4 className="font-semibold">{t('sessionDetail.pageEditPlanTitle')}</h4>
290 <span className="shrink-0 text-xs text-[#6d7b5d]">
291 {pendingPageEditPlan.targetPageNumber
292 ? t('sessionDetail.pageContext', {
293 pageNumber: pendingPageEditPlan.targetPageNumber
294 })
295 : pendingPageEditPlan.targetPageId}
296 </span>
297 </div>
298 <p className="mt-1.5 break-words leading-5">{pendingPageEditPlan.plan.summary}</p>
299 <ul className="mt-2 list-disc space-y-1 pl-4 text-xs leading-5 text-[#536847]">
300 {pendingPageEditPlan.plan.changes.map((change, index) => (
301 <li key={`${index}-${change}`}>{change}</li>
302 ))}
303 </ul>
304 <p className="mt-2 break-words text-xs leading-5 text-[#5f7150]">
305 {pendingPageEditPlan.plan.confirmationQuestion}
306 </p>
307 <div className="mt-2.5 flex justify-end gap-2">
308 <Button
309 variant="outline"
310 size="sm"
311 onClick={cancelPageEditPlan}
312 className="h-8 px-2.5 text-xs"
313 >
314 {t('sessionDetail.pageEditPlanCancel')}
315 </Button>
316 <Button
317 size="sm"
318 disabled={hasActivePageEditJob}
319 onClick={() => void confirmPageEditPlan()}
320 className="h-8 px-2.5 text-xs"
321 >
322 {t('sessionDetail.pageEditPlanConfirm')}
323 </Button>
324 </div>
325 </section>
326 )}
327 <div ref={messagesEndRef} />
328 </div>
329 )}
330 </ScrollArea>
331
332 <div
333 className={cn(
334 'mx-2.5 mb-2.5 rounded-[1.4rem] border border-[#ded2bd]/72 bg-[#fffaf1]/84 px-2.5 pb-3 pt-2 shadow-[0_12px_24px_rgba(74,59,42,0.11)] transition-colors',
335 assetDragActive && 'border-[#afc79a]/75 bg-[#f3f8ec]/88'
336 )}
337 onDragEnter={(event) => {
338 event.preventDefault()
339 if (event.dataTransfer.types.includes('Files')) setAssetDragActive(true)
340 }}
341 onDragOver={(event) => {
342 event.preventDefault()
343 if (event.dataTransfer.types.includes('Files')) setAssetDragActive(true)
344 }}
345 onDragLeave={(event) => {
346 if (!event.currentTarget.contains(event.relatedTarget as Node | null)) {
347 setAssetDragActive(false)
348 }
349 }}
350 onDrop={(event) => {
351 event.preventDefault()
352 void uploadFiles(Array.from(event.dataTransfer.files))
353 }}
354 >
355 {selectedSelector && (
356 <div className="mb-2 flex items-center gap-2 rounded-[1rem] border border-[#ded2bd]/65 bg-[#f4ebdc]/70 px-2 py-1.5">
357 <span className="shrink-0 rounded-full bg-[#dcebcf]/82 px-1.5 py-0.5 text-[10px] font-medium tracking-wide text-[#4f6340]">
358 {t('sessionDetail.selectorBadge')}
359 </span>
360 <Tooltip>
361 <TooltipTrigger asChild>
362 <span className="min-w-0 flex-1 overflow-hidden text-ellipsis whitespace-nowrap text-xs leading-5 text-[#4f5f3f]">
363 {selectorSummary}
364 </span>
365 </TooltipTrigger>
366 {selectorTitle && (
367 <TooltipContent className="whitespace-pre-wrap">{selectorTitle}</TooltipContent>
368 )}
369 </Tooltip>
370 {selectedElementPropertyCount > 0 && (
371 <span
372 className="shrink-0 rounded-full bg-[#e5ddd0]/82 px-1.5 py-0.5 text-[10px] font-medium text-[#6a5c48]"
373 title={t('sessionDetail.selectorPropertiesReady', {
374 count: selectedElementPropertyCount
375 })}
376 >
377 {t('sessionDetail.selectorPropertiesReady', {
378 count: selectedElementPropertyCount
379 })}
380 </span>
381 )}
382 <button
383 type="button"
384 onClick={clearSelectedElement}
385 className="inline-flex h-5 w-5 items-center justify-center rounded-full text-[#64735a] transition-colors hover:bg-[#d4e4c1]/78 hover:text-[#3e4a32]"
386 aria-label={t('sessionDetail.clearSelector')}
387 title={t('sessionDetail.clearSelector')}
388 >
389 <X className="h-3.5 w-3.5" />
390 </button>
391 </div>
392 )}
393 {chatType === 'main' && (
394 <div className="mb-2 flex items-center gap-2 rounded-[1rem] border border-[#ded2bd]/65 bg-[#f4ebdc]/70 px-2.5 py-2 text-xs text-[#6a5c48]">
395 <span className="min-w-0 flex-1">{t('sessionDetail.mainDeckHint')}</span>
396 <DropdownMenu>
397 <DropdownMenuTrigger asChild>
398 <button
399 type="button"
400 disabled={isGenerating || pages.length === 0}
401 className="inline-flex h-7 max-w-[116px] shrink-0 items-center gap-1.5 rounded-full border border-[#c7d9b4]/72 bg-[#fffdf8]/86 px-2 text-[11px] font-medium text-[#405333] transition-colors hover:bg-[#edf5e5] disabled:pointer-events-none disabled:opacity-45"
402 title={
403 effectiveMainPageIds.length > 0
404 ? effectiveMainPageIds.map((id) => `/${id}.html`).join('\n')
405 : t('sessionDetail.mainPageScopeAll')
406 }
407 >
408 <span className="min-w-0 overflow-hidden text-ellipsis whitespace-nowrap">
409 {mainPageScopeLabel}
410 </span>
411 <ChevronDown className="h-3.5 w-3.5 shrink-0" />
412 </button>
413 </DropdownMenuTrigger>
414 <DropdownMenuContent align="end" side="top" className="max-h-72 w-56 overflow-y-auto">
415 <DropdownMenuItem
416 onSelect={(event) => {
417 event.preventDefault()
418 setSelectedMainPageIds([])
419 }}
420 className="text-xs"
421 >
422 <Check
423 className={cn(
424 'h-3.5 w-3.5',
425 effectiveMainPageIds.length === 0 ? 'opacity-100' : 'opacity-0'
426 )}
427 />
428 {t('sessionDetail.mainPageScopeAll')}
429 </DropdownMenuItem>
430 {pages.map((page) => {
431 const checked = effectiveMainPageIds.includes(page.pageId)
432 const limitReached = !checked && effectiveMainPageIds.length >= MAX_SELECTED_PAGES
433 return (
434 <DropdownMenuItem
435 key={page.pageId}
436 onSelect={(event) => {
437 event.preventDefault()
438 toggleMainPage(page.pageId)
439 }}
440 className={cn('text-xs', limitReached && 'opacity-55')}
441 title={`/${page.pageId}.html`}
442 >
443 <Check className={cn('h-3.5 w-3.5', checked ? 'opacity-100' : 'opacity-0')} />
444 <span className="min-w-0 flex-1 overflow-hidden text-ellipsis whitespace-nowrap">
445 P{page.pageNumber} · {page.title || page.pageId}
446 </span>
447 </DropdownMenuItem>
448 )
449 })}
450 </DropdownMenuContent>
451 </DropdownMenu>
452 </div>
453 )}
454 {pendingAssets.length > 0 && (
455 <div className="mb-2 flex flex-wrap gap-1.5">
456 {pendingAssets.map((asset) => (
457 <div
458 key={asset.id}
459 className="flex max-w-full items-center gap-1.5 rounded-full border border-[#c7d9b4]/66 bg-[#e6f1dc]/76 px-2 py-1 text-[11px] text-[#405333] shadow-[0_3px_8px_rgba(93,107,77,0.06)]"
460 title={`${asset.originalName}\n${asset.relativePath}`}
461 >
462 {asset.mimeType.startsWith('video/') ? (
463 <Video className="h-3.5 w-3.5 shrink-0" />
464 ) : (
465 <ImageIcon className="h-3.5 w-3.5 shrink-0" />
466 )}
467 <span className="min-w-0 max-w-[180px] overflow-hidden text-ellipsis whitespace-nowrap">
468 {asset.originalName || asset.fileName}
469 </span>
470 <button
471 type="button"
472 onClick={() => removePendingAsset(asset.id)}
473 className="inline-flex h-4 w-4 shrink-0 items-center justify-center rounded-full text-[#657552] hover:bg-[#c8ddb2]"
474 aria-label={t('sessionDetail.removeAsset')}
475 >
476 <X className="h-3 w-3" />
477 </button>
478 </div>
479 ))}
480 </div>
481 )}
482 <Textarea
483 placeholder={inputPlaceholder}
484 value={input}
485 onChange={(event) => setInput(event.target.value)}
486 onCompositionStart={() => {
487 composingRef.current = true
488 }}
489 onCompositionEnd={() => {
490 composingRef.current = false
491 }}
492 onKeyDown={(event) => {
493 if (event.key === 'Enter' && !event.shiftKey) {
494 if (composingRef.current || event.nativeEvent.isComposing) return
495 event.preventDefault()
496 void handleSendWithModel()
497 }
498 }}
499 disabled={isGenerating}
500 rows={4}
501 className="min-h-[96px] resize-none rounded-[1.15rem] border border-[#ded2bd]/72 bg-[#fffdf8]/88 px-3 py-2 text-[13px] leading-5 text-[#3f4b35] shadow-[inset_0_1px_2px_rgba(74,59,42,0.05)] focus-visible:border-[#9bb98a] focus-visible:ring-0 focus-visible:ring-offset-0"
502 />
503 <div className="mt-2 grid grid-cols-[minmax(0,1fr)_auto] items-start gap-2">
504 <div className="flex min-w-0 items-center gap-2">
505 <DropdownMenu>
506 <DropdownMenuTrigger asChild>
507 <button
508 type="button"
509 disabled={isGenerating || isUploadingAssets}
510 className="inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-[38%_62%_44%_56%/55%_45%_55%_45%] border border-[#c7d9b4]/66 bg-[#e6f1dc]/80 text-[#526942] shadow-[0_4px_10px_rgba(93,107,77,0.09)] transition-colors hover:bg-[#d7e8c8] disabled:pointer-events-none disabled:opacity-45"
511 aria-label={t('sessionDetail.addAsset')}
512 title={t('sessionDetail.addAsset')}
513 >
514 {isUploadingAssets ? (
515 <Loader2 className="h-4 w-4 animate-spin" />
516 ) : (
517 <Plus className="h-4 w-4" />
518 )}
519 </button>
520 </DropdownMenuTrigger>
521 <DropdownMenuContent align="start" side="top" className="w-40">
522 <DropdownMenuItem onSelect={() => void chooseAssets('image')}>
523 <ImageIcon className="h-4 w-4" />
524 {t('sessionDetail.chooseImage')}
525 </DropdownMenuItem>
526 <DropdownMenuItem onSelect={() => void chooseAssets('video')}>
527 <Video className="h-4 w-4" />
528 {t('sessionDetail.chooseVideo')}
529 </DropdownMenuItem>
530 <DropdownMenuItem disabled>
531 <FileText className="h-4 w-4" />
532 {t('sessionDetail.chooseFileSoon')}
533 </DropdownMenuItem>
534 </DropdownMenuContent>
535 </DropdownMenu>
536 <div className="min-w-0 overflow-hidden text-ellipsis whitespace-nowrap text-xs leading-5 text-[#6d604d]">
537 {contextHint}
538 </div>
539 </div>
540
541 {isGenerating ? (
542 <Button
543 variant="destructive"
544 onClick={() => void cancel()}
545 size="sm"
546 className="shrink-0 whitespace-nowrap rounded-full px-3 text-xs shadow-[0_8px_18px_rgba(177,90,88,0.22)]"
547 >
548 <StopCircle className="mr-1 h-4 w-4" />
549 {isDeckEditing ? t('common.cancel') : t('sessionDetail.stop')}
550 </Button>
551 ) : (
552 <ModelSplitButton
553 modelAction={modelAction}
554 label={t('sessionDetail.send')}
555 disabled={sendDisabled}
556 icon={Send}
557 size="sm"
558 className="shrink-0 whitespace-nowrap"
559 mainClassName="h-8 px-2.5 text-xs"
560 triggerClassName="h-8 px-1.5"
561 onRun={handleSendWithModel}
562 />
563 )}
564 </div>
565 </div>
566 </>
567 )
568 }
569
569 lines Plain Text