@pheem49/mint 1.6.4 → 1.7.1

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 (34) hide show
  1. package/Cargo.toml +1 -1
  2. package/bin/mint +0 -0
  3. package/crates/mint-cli/src/agent.rs +42 -14
  4. package/crates/mint-cli/src/main.rs +412 -2
  5. package/crates/mint-cli/src/setup.rs +173 -32
  6. package/crates/mint-core/src/api_server.rs +35 -0
  7. package/crates/mint-core/src/config.rs +2 -2
  8. package/crates/mint-core/src/memory.rs +27 -1
  9. package/crates/mint-core/src/orchestration.rs +1 -1
  10. package/crates/mint-core/src/plugins.rs +35 -0
  11. package/crates/mint-core/tests/memory_persistence.rs +28 -0
  12. package/package.json +12 -2
  13. package/src/renderer/src/agentProgress.ts +39 -0
  14. package/src/renderer/src/components/ChatPanel.tsx +308 -34
  15. package/src/renderer/src/components/DashboardSidebar.tsx +3 -3
  16. package/src/renderer/src/components/MintDashboard.tsx +71 -27
  17. package/src/renderer/src/components/ModelPanel.tsx +2 -2
  18. package/src/renderer/src/components/PicturesLibrary.tsx +5 -1
  19. package/src/renderer/src/components/SpotlightWindow.tsx +8 -8
  20. package/src/renderer/src/components/WorkspacePanel.tsx +1 -1
  21. package/src/renderer/src/css/styles.css +195 -17
  22. package/src/renderer/src/index.css +46 -0
  23. package/src/renderer/src/tauri.ts +25 -0
  24. package/src/renderer/src-web/App.tsx +16 -1
  25. package/src/renderer/src-web/agentProgress.ts +39 -0
  26. package/src/renderer/src-web/components/ChatPanel.tsx +309 -35
  27. package/src/renderer/src-web/components/DashboardSidebar.tsx +1 -1
  28. package/src/renderer/src-web/components/MintDashboard.tsx +53 -16
  29. package/src/renderer/src-web/components/PicturesLibrary.tsx +5 -1
  30. package/src/renderer/src-web/css/styles.css +242 -18
  31. package/src/renderer/src-web/index.css +46 -0
  32. package/src/renderer/src-web/tauri.ts +25 -0
  33. package/src-tauri/src/lib.rs +16 -0
  34. package/src-tauri/tauri.conf.json +1 -1
@@ -19,7 +19,22 @@ export default function App() {
19
19
 
20
20
  let content = <MintDashboard />
21
21
 
22
- if (route.startsWith('/settings')) content = <SettingsWindow />
22
+ // For web, show settings as a centered modal overlay instead of a full-page route
23
+ if (route.startsWith('/settings')) {
24
+ content = (
25
+ <>
26
+ <MintDashboard />
27
+ <div
28
+ className="settings-modal-overlay"
29
+ onClick={() => window.settingsApi?.closeSettings?.()}
30
+ >
31
+ <div className="settings-modal" onClick={(e) => e.stopPropagation()}>
32
+ <SettingsWindow />
33
+ </div>
34
+ </div>
35
+ </>
36
+ )
37
+ }
23
38
 
24
39
  return <Suspense fallback={null}>{content}</Suspense>
25
40
  }
@@ -0,0 +1,39 @@
1
+ import type { AgentProgress, InteractionMemory } from './tauri'
2
+
3
+ export function thoughtsFrom(progress: AgentProgress[]): string[] {
4
+ return progress
5
+ .filter((event) => event.type === 'Thought')
6
+ .map((event) => (event as Extract<AgentProgress, { type: 'Thought' }>).data.thought)
7
+ .filter(Boolean)
8
+ }
9
+
10
+ export function trimAgentProgress(progress: AgentProgress[], maxNonThought = 20): AgentProgress[] {
11
+ if (progress.length === 0) return progress
12
+ const keptNonThought = new Set(
13
+ progress.filter((event) => event.type !== 'Thought').slice(-maxNonThought),
14
+ )
15
+ return progress.filter((event) => event.type === 'Thought' || keptNonThought.has(event))
16
+ }
17
+
18
+ export function hasAgentToolActivity(progress: AgentProgress[]): boolean {
19
+ return progress.some((event) => event.type === 'ToolStart' || event.type === 'ToolEnd')
20
+ }
21
+
22
+ export function mergeActivitySnapshots(
23
+ current: Record<string, AgentProgress[]>,
24
+ interactions: InteractionMemory[],
25
+ ): Record<string, AgentProgress[]> {
26
+ const merged = { ...current }
27
+ for (const interaction of interactions) {
28
+ const key = String(interaction.id)
29
+ if (interaction.agentActivity?.length && !merged[key]) {
30
+ merged[key] = interaction.agentActivity
31
+ }
32
+ }
33
+ return merged
34
+ }
35
+
36
+ export function parseAgentActivity(value: unknown): AgentProgress[] | undefined {
37
+ if (!Array.isArray(value)) return undefined
38
+ return value as AgentProgress[]
39
+ }
@@ -1,4 +1,5 @@
1
1
  import { useEffect, useRef, useState, Fragment, type ChangeEvent, type ClipboardEvent, type FormEvent, type KeyboardEvent, type RefObject, type DragEvent } from 'react'
2
+ import { hasAgentToolActivity, thoughtsFrom } from '../agentProgress'
2
3
  import {
3
4
  type AgentProgress,
4
5
  type ChatResponse,
@@ -327,6 +328,8 @@ interface ChatPanelProps {
327
328
  streamedResponse: ChatResponse | null
328
329
  agentProgress: AgentProgress[]
329
330
  agentActivitySnapshots: Record<string, AgentProgress[]>
331
+ thinkingExpanded: Record<string, boolean>
332
+ onThinkingExpandedChange: (key: string, open: boolean) => void
330
333
  message: string
331
334
  imageAttachments: Array<{ dataUri: string; name: string; previewDataUri?: string }>
332
335
  documentName: string
@@ -418,6 +421,11 @@ function ChatCodeBlock({ code, language }: { code: string; language: string; key
418
421
  )
419
422
  }
420
423
 
424
+ const isTableLine = (line: string): boolean => {
425
+ const trimmed = line.trim()
426
+ return trimmed.startsWith('|') && trimmed.endsWith('|') && trimmed.length > 1
427
+ }
428
+
421
429
  function renderFormattedMessage(text: string) {
422
430
  const displayText = readableAssistantText(text)
423
431
  if (!displayText) return null
@@ -446,6 +454,113 @@ function renderFormattedMessage(text: string) {
446
454
  })
447
455
  }
448
456
 
457
+ const renderHtmlTable = (tableLines: string[], key: string) => {
458
+ const rows: string[][] = []
459
+ for (const line of tableLines) {
460
+ const trimmed = line.trim()
461
+ if (!trimmed) continue
462
+ const content = trimmed.slice(1, -1)
463
+ const contentEscaped = content.replace(/\\\|/g, '\u0000')
464
+ const cols = contentEscaped.split('|').map(s => s.replace(/\u0000/g, '|').trim())
465
+ rows.push(cols)
466
+ }
467
+
468
+ if (rows.length === 0) return null
469
+
470
+ let hasSeparator = false
471
+ let separatorIdx = -1
472
+ for (let i = 0; i < rows.length; i++) {
473
+ const row = rows[i]
474
+ if (row.length > 0 && row.every(col => /^[-\s:]+$/.test(col))) {
475
+ hasSeparator = true
476
+ separatorIdx = i
477
+ break
478
+ }
479
+ }
480
+
481
+ let headerRow: string[] | null = null
482
+ const dataRows: string[][] = []
483
+
484
+ if (hasSeparator) {
485
+ if (separatorIdx > 0) {
486
+ headerRow = rows[separatorIdx - 1]
487
+ for (let i = 0; i < rows.length; i++) {
488
+ if (i !== separatorIdx && i !== separatorIdx - 1) {
489
+ dataRows.push(rows[i])
490
+ }
491
+ }
492
+ } else {
493
+ for (let i = 0; i < rows.length; i++) {
494
+ if (i !== separatorIdx) {
495
+ dataRows.push(rows[i])
496
+ }
497
+ }
498
+ }
499
+ } else {
500
+ headerRow = rows[0]
501
+ for (let i = 1; i < rows.length; i++) {
502
+ dataRows.push(rows[i])
503
+ }
504
+ }
505
+
506
+ return (
507
+ <div key={key} className="chat-table-container" style={{
508
+ overflowX: 'auto',
509
+ margin: '14px 0',
510
+ width: '100%',
511
+ borderRadius: '8px',
512
+ border: '1px solid rgba(255, 255, 255, 0.12)',
513
+ background: 'rgba(30, 41, 59, 0.35)',
514
+ boxShadow: '0 4px 12px rgba(0, 0, 0, 0.15)',
515
+ }}>
516
+ <table className="chat-table" style={{
517
+ width: '100%',
518
+ borderCollapse: 'collapse',
519
+ fontSize: '0.86rem',
520
+ textAlign: 'left',
521
+ lineHeight: '1.5',
522
+ }}>
523
+ {headerRow && (
524
+ <thead>
525
+ <tr style={{
526
+ background: 'rgba(255, 255, 255, 0.04)',
527
+ borderBottom: '2px solid rgba(255, 255, 255, 0.15)',
528
+ }}>
529
+ {headerRow.map((col, idx) => (
530
+ <th key={`th-${idx}`} style={{
531
+ padding: '12px 16px',
532
+ fontWeight: 700,
533
+ color: 'var(--accent, #38bdf8)',
534
+ fontFamily: 'ui-sans-serif, system-ui, sans-serif',
535
+ }}>
536
+ {formatInline(col)}
537
+ </th>
538
+ ))}
539
+ </tr>
540
+ </thead>
541
+ )}
542
+ <tbody>
543
+ {dataRows.map((row, rIdx) => (
544
+ <tr key={`tr-${rIdx}`} style={{
545
+ background: rIdx % 2 === 1 ? 'rgba(255, 255, 255, 0.015)' : 'transparent',
546
+ borderBottom: rIdx < dataRows.length - 1 ? '1px solid rgba(255, 255, 255, 0.08)' : 'none',
547
+ }}>
548
+ {row.map((col, cIdx) => (
549
+ <td key={`td-${cIdx}`} style={{
550
+ padding: '12px 16px',
551
+ color: 'var(--text-main, #e2e8f0)',
552
+ }}>
553
+ {formatInline(col)}
554
+ </td>
555
+ ))}
556
+ </tr>
557
+ ))}
558
+ </tbody>
559
+ </table>
560
+ </div>
561
+ )
562
+ }
563
+
449
564
  const lines = displayText.split('\n')
450
565
  const items: any[] = []
451
566
 
@@ -453,10 +568,24 @@ function renderFormattedMessage(text: string) {
453
568
  let codeBlockLang = ''
454
569
  let codeBlockLines: string[] = []
455
570
 
571
+ let inTable = false
572
+ let tableLines: string[] = []
573
+
574
+ const flushTable = (index: number) => {
575
+ if (tableLines.length > 0) {
576
+ items.push(renderHtmlTable(tableLines, `table-${index}`))
577
+ tableLines = []
578
+ inTable = false
579
+ }
580
+ }
581
+
456
582
  for (let i = 0; i < lines.length; i++) {
457
583
  const line = lines[i]
458
584
 
459
585
  if (line.trim().startsWith('```')) {
586
+ if (inTable) {
587
+ flushTable(i)
588
+ }
460
589
  if (inCodeBlock) {
461
590
  const codeText = codeBlockLines.join('\n')
462
591
  items.push(
@@ -480,35 +609,60 @@ function renderFormattedMessage(text: string) {
480
609
  continue
481
610
  }
482
611
 
483
- const headerMatch = line.match(/^(#{1,6})\s+(.*)$/)
484
- if (headerMatch) {
485
- const level = headerMatch[1].length
486
- const content = headerMatch[2]
487
-
488
- const style = {
489
- fontWeight: 'bold',
490
- display: 'block',
491
- marginTop: level === 1 ? '16px' : level === 2 ? '14px' : '10px',
492
- marginBottom: '6px',
493
- fontSize: level === 1 ? '1.25em' : level === 2 ? '1.15em' : '1.05em',
494
- color: 'var(--text-main)',
612
+ if (isTableLine(line)) {
613
+ inTable = true
614
+ tableLines.push(line)
615
+ } else {
616
+ if (inTable) {
617
+ flushTable(i)
495
618
  }
496
619
 
497
- items.push(
498
- <span key={`line-${i}`} style={style}>
499
- {formatInline(content)}
500
- </span>
501
- )
502
- } else {
503
- items.push(
504
- <Fragment key={`line-${i}`}>
505
- {formatInline(line)}
506
- {i < lines.length - 1 && '\n'}
507
- </Fragment>
508
- )
620
+ const headerMatch = line.match(/^(#{1,6})\s+(.*)$/)
621
+ if (headerMatch) {
622
+ const level = headerMatch[1].length
623
+ const content = headerMatch[2]
624
+
625
+ const style = {
626
+ fontWeight: 'bold',
627
+ display: 'block',
628
+ marginTop: level === 1 ? '16px' : level === 2 ? '14px' : '10px',
629
+ marginBottom: '6px',
630
+ fontSize: level === 1 ? '1.25em' : level === 2 ? '1.15em' : '1.05em',
631
+ color: 'var(--text-main)',
632
+ }
633
+
634
+ items.push(
635
+ <span key={`line-${i}`} style={style}>
636
+ {formatInline(content)}
637
+ </span>
638
+ )
639
+ } else {
640
+ const listMatch = line.match(/^(\s*)([-*+])\s+(.*)$/)
641
+ if (listMatch) {
642
+ const indent = listMatch[1]
643
+ const content = listMatch[3]
644
+ items.push(
645
+ <Fragment key={`line-${i}`}>
646
+ {indent}• {formatInline(content)}
647
+ {i < lines.length - 1 && '\n'}
648
+ </Fragment>
649
+ )
650
+ } else {
651
+ items.push(
652
+ <Fragment key={`line-${i}`}>
653
+ {formatInline(line)}
654
+ {i < lines.length - 1 && '\n'}
655
+ </Fragment>
656
+ )
657
+ }
658
+ }
509
659
  }
510
660
  }
511
661
 
662
+ if (inTable) {
663
+ flushTable(lines.length)
664
+ }
665
+
512
666
  if (inCodeBlock && codeBlockLines.length > 0) {
513
667
  const codeText = codeBlockLines.join('\n')
514
668
  items.push(
@@ -523,6 +677,98 @@ function renderFormattedMessage(text: string) {
523
677
  return items
524
678
  }
525
679
 
680
+ const THINKING_LABELS = {
681
+ live: 'Thinking…',
682
+ completed: (count: number) => `Thinking process (${count} steps)`,
683
+ step: (index: number) => `Step ${index}`,
684
+ emptyHint: 'Model didn\'t send thinking steps this time',
685
+ } as const
686
+
687
+ function ThinkingBlock({
688
+ thoughts,
689
+ isLive = false,
690
+ blockKey,
691
+ expanded,
692
+ onExpandedChange,
693
+ showEmptyHint = false,
694
+ }: {
695
+ thoughts: string[]
696
+ isLive?: boolean
697
+ blockKey: string
698
+ expanded?: boolean
699
+ onExpandedChange?: (key: string, open: boolean) => void
700
+ showEmptyHint?: boolean
701
+ }) {
702
+ const contentRef = useRef<HTMLDivElement>(null)
703
+ const isControlled = expanded !== undefined && Boolean(onExpandedChange)
704
+ const [localOpen, setLocalOpen] = useState(isLive)
705
+ const isOpen = isControlled ? Boolean(expanded) : localOpen
706
+
707
+ const setOpen = (open: boolean) => {
708
+ if (isControlled && onExpandedChange) onExpandedChange(blockKey, open)
709
+ else setLocalOpen(open)
710
+ }
711
+
712
+ useEffect(() => {
713
+ if (isLive && !isControlled) setLocalOpen(true)
714
+ }, [isLive, isControlled])
715
+
716
+ useEffect(() => {
717
+ if (!isLive || !isOpen || !contentRef.current) return
718
+ contentRef.current.scrollTop = contentRef.current.scrollHeight
719
+ }, [thoughts, isLive, isOpen])
720
+
721
+ if (thoughts.length === 0 && !showEmptyHint) return null
722
+
723
+ if (thoughts.length === 0) {
724
+ return (
725
+ <div className="thinking-block thinking-block-empty-state" data-live={isLive ? 'true' : 'false'}>
726
+ <span className="thinking-block-empty">{THINKING_LABELS.emptyHint}</span>
727
+ </div>
728
+ )
729
+ }
730
+
731
+ return (
732
+ <div className={`thinking-block${isLive ? ' is-live' : ''}`}>
733
+ <button
734
+ type="button"
735
+ className="thinking-block-header"
736
+ onClick={() => setOpen(!isOpen)}
737
+ aria-expanded={isOpen}
738
+ >
739
+ <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" style={{ flexShrink: 0 }}>
740
+ <path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3" />
741
+ <line x1="12" y1="17" x2="12.01" y2="17" />
742
+ <circle cx="12" cy="12" r="10" />
743
+ </svg>
744
+ <span className="thinking-block-label">
745
+ {isLive ? THINKING_LABELS.live : THINKING_LABELS.completed(thoughts.length)}
746
+ </span>
747
+ {isLive && <span className="thinking-block-live-dot" aria-hidden="true" />}
748
+ <span className={`thinking-block-chevron${isOpen ? ' is-open' : ''}`} aria-hidden="true">
749
+ <svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
750
+ <polyline points="9 18 15 12 9 6" />
751
+ </svg>
752
+ </span>
753
+ </button>
754
+ {isOpen && (
755
+ <div className="thinking-block-body">
756
+ <div className="thinking-block-content" ref={contentRef}>
757
+ {thoughts.map((thought, index) => (
758
+ <div className="thinking-block-step" key={`${blockKey}-${index}`}>
759
+ {thoughts.length > 1 && (
760
+ <div className="thinking-block-step-label">{THINKING_LABELS.step(index + 1)}</div>
761
+ )}
762
+ <div className="thinking-block-step-body">{renderFormattedMessage(thought)}</div>
763
+ </div>
764
+ ))}
765
+ </div>
766
+ </div>
767
+ )}
768
+ </div>
769
+ )
770
+ }
771
+
526
772
  function readableAssistantText(text: string) {
527
773
  if (typeof text !== 'string') return ''
528
774
  const trimmed = text.trim()
@@ -692,6 +938,8 @@ export default function ChatPanel({
692
938
  streamedResponse,
693
939
  agentProgress,
694
940
  agentActivitySnapshots,
941
+ thinkingExpanded,
942
+ onThinkingExpandedChange,
695
943
  message,
696
944
  imageAttachments,
697
945
  documentName,
@@ -821,7 +1069,7 @@ export default function ChatPanel({
821
1069
  const speakingRef = useRef<string | null>(null)
822
1070
  const restartTimerRef = useRef<number | null>(null)
823
1071
  const voiceStatus = speakingText ? 'speaking' : (sending || voiceAwaitingResponse) ? 'thinking' : isRecording ? 'listening' : voiceMode ? 'ready' : 'off'
824
- const voiceStatusLabel = voiceStatus === 'speaking' ? 'กำลังตอบ' : voiceStatus === 'thinking' ? 'กำลังคิด' : voiceStatus === 'listening' ? 'กำลังฟัง' : 'พร้อมฟัง'
1072
+ const voiceStatusLabel = voiceStatus === 'speaking' ? 'Speaking' : voiceStatus === 'thinking' ? 'Thinking' : voiceStatus === 'listening' ? 'Listening' : 'Ready'
825
1073
 
826
1074
  const clearRestartTimer = () => {
827
1075
  if (restartTimerRef.current === null) return
@@ -943,7 +1191,7 @@ export default function ChatPanel({
943
1191
  }
944
1192
  const startAudioRecording = async (autoSend: boolean) => {
945
1193
  if (!navigator.mediaDevices?.getUserMedia || typeof MediaRecorder === 'undefined') {
946
- alert('ขออภัย ระบบนี้ไม่สามารถเข้าถึงไมค์หรืออัดเสียงในเบราว์เซอร์นี้ได้')
1194
+ alert("Sorry, this system cannot access the microphone or record audio in this browser")
947
1195
  voiceModeRef.current = false
948
1196
  setVoiceMode(false)
949
1197
  return
@@ -966,7 +1214,7 @@ export default function ChatPanel({
966
1214
  mediaStreamRef.current = stream
967
1215
  mediaRecorderRef.current = recorder
968
1216
  audioContextRef.current = audioContext
969
- setVoiceTranscript('กำลังฟังเสียงจากไมค์')
1217
+ setVoiceTranscript('Listening to microphone...')
970
1218
 
971
1219
  recorder.ondataavailable = (event) => {
972
1220
  if (event.data.size > 0) chunks.push(event.data)
@@ -981,13 +1229,13 @@ export default function ChatPanel({
981
1229
  setIsRecording(false)
982
1230
  if (!autoSend || !voiceModeRef.current || chunks.length === 0) return
983
1231
  if (!heardVoice) {
984
- setVoiceTranscript('ยังไม่ได้ยินเสียงพูด')
1232
+ setVoiceTranscript('No speech detected')
985
1233
  scheduleVoiceListen(700)
986
1234
  return
987
1235
  }
988
1236
  const blob = new Blob(chunks, { type: recorder.mimeType || 'audio/webm' })
989
1237
  const audioDataUri = await blobToDataUri(blob)
990
- setVoiceTranscript('ส่งเสียงให้ AI แล้ว')
1238
+ setVoiceTranscript('Sent audio to AI')
991
1239
  voiceAwaitingResponseRef.current = true
992
1240
  setVoiceAwaitingResponse(true)
993
1241
  onSendVoiceMessage('', audioDataUri)
@@ -1011,7 +1259,7 @@ export default function ChatPanel({
1011
1259
  if (peak > 12) {
1012
1260
  heardVoice = true
1013
1261
  quietSince = 0
1014
- setVoiceTranscript('กำลังฟัง...')
1262
+ setVoiceTranscript('Listening...')
1015
1263
  } else if (heardVoice) {
1016
1264
  quietSince = quietSince || now
1017
1265
  }
@@ -1026,7 +1274,7 @@ export default function ChatPanel({
1026
1274
  setIsRecording(false)
1027
1275
  voiceModeRef.current = false
1028
1276
  setVoiceMode(false)
1029
- alert('เปิดไมค์ไม่สำเร็จ กรุณาตรวจสิทธิ์ microphone ของเบราว์เซอร์')
1277
+ alert('Failed to open microphone. Please check microphone permissions in your browser')
1030
1278
  }
1031
1279
  }
1032
1280
  const startRecognition = (autoSend: boolean) => {
@@ -1544,8 +1792,8 @@ export default function ChatPanel({
1544
1792
  }}
1545
1793
  >
1546
1794
  <div style={{ fontSize: '3.5rem', marginBottom: '16px' }}>🖼️</div>
1547
- <div style={{ fontSize: '1.25rem', fontWeight: 'bold', letterSpacing: '0.5px' }}>วางไฟล์เพื่อแนบข้อมูล</div>
1548
- <div style={{ fontSize: '0.85rem', color: '#94a3b8', marginTop: '8px' }}>รองรับรูปภาพ (PNG, JPEG, WebP, GIF) และไฟล์ PDF</div>
1795
+ <div style={{ fontSize: '1.25rem', fontWeight: 'bold', letterSpacing: '0.5px' }}>Drag files to attach data</div>
1796
+ <div style={{ fontSize: '0.85rem', color: '#94a3b8', marginTop: '8px' }}>Supports images (PNG, JPEG, WebP, GIF) and PDF files</div>
1549
1797
  </div>
1550
1798
  )}
1551
1799
 
@@ -1568,6 +1816,19 @@ export default function ChatPanel({
1568
1816
  <div className="bubble-wrapper">
1569
1817
  {renderCompletedActivity(interaction)}
1570
1818
  {renderFileChanges(interaction)}
1819
+ {(() => {
1820
+ const progress = agentActivitySnapshots[String(interaction.id)] ?? interaction.agentActivity ?? []
1821
+ const thoughts = thoughtsFrom(progress)
1822
+ return (
1823
+ <ThinkingBlock
1824
+ blockKey={String(interaction.id)}
1825
+ thoughts={thoughts}
1826
+ expanded={thinkingExpanded[String(interaction.id)] ?? false}
1827
+ onExpandedChange={onThinkingExpandedChange}
1828
+ showEmptyHint={hasAgentToolActivity(progress) && thoughts.length === 0}
1829
+ />
1830
+ )
1831
+ })()}
1571
1832
  <div className="message-bubble" style={{ whiteSpace: 'pre-wrap' }}>{renderFormattedMessage(interaction.aiText)}</div>
1572
1833
  <div className="message-time" style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
1573
1834
  <button className="provider-badge">{interaction.provider} • {interaction.model}</button>
@@ -1589,7 +1850,7 @@ export default function ChatPanel({
1589
1850
  opacity: 0.7,
1590
1851
  transition: 'all 0.2s',
1591
1852
  }}
1592
- title={speakingText === interaction.aiText ? "หยุดอ่านออกเสียง" : "อ่านออกเสียง"}
1853
+ title={speakingText === interaction.aiText ? "Stop reading" : "Read aloud"}
1593
1854
  >
1594
1855
  {renderSpeakerIcon(speakingText === interaction.aiText)}
1595
1856
  </button>
@@ -1618,6 +1879,19 @@ export default function ChatPanel({
1618
1879
  {renderActiveFileChanges()}
1619
1880
  <div className="message ai-message thinking-message">
1620
1881
  <div className="bubble-wrapper">
1882
+ <ThinkingBlock
1883
+ blockKey="live"
1884
+ thoughts={thoughtsFrom(agentProgress)}
1885
+ isLive={true}
1886
+ expanded={thinkingExpanded.live ?? true}
1887
+ onExpandedChange={onThinkingExpandedChange}
1888
+ showEmptyHint={
1889
+ agentMode
1890
+ && hasAgentToolActivity(agentProgress)
1891
+ && thoughtsFrom(agentProgress).length === 0
1892
+ && !streamedReply
1893
+ }
1894
+ />
1621
1895
  <div className="message-bubble">
1622
1896
  <span>
1623
1897
  {streamedReply ? (
@@ -1663,7 +1937,7 @@ export default function ChatPanel({
1663
1937
  opacity: 0.7,
1664
1938
  transition: 'all 0.2s',
1665
1939
  }}
1666
- title={speakingText === streamedReply ? "หยุดอ่านออกเสียง" : "อ่านออกเสียง"}
1940
+ title={speakingText === streamedReply ? "Stop reading" : "Read aloud"}
1667
1941
  >
1668
1942
  {renderSpeakerIcon(speakingText === streamedReply)}
1669
1943
  </button>
@@ -56,7 +56,7 @@ export default function DashboardSidebar({
56
56
  <div
57
57
  className="sidebar-brand clickable"
58
58
  onClick={onToggleSidebar}
59
- title={sidebarCollapsed ? "ขยายแถบด้านข้าง" : "ยุบแถบด้านข้าง"}
59
+ title={sidebarCollapsed ? "Expand sidebar" : "Collapse sidebar"}
60
60
  role="button"
61
61
  tabIndex={0}
62
62
  onKeyDown={(event) => {