@pheem49/mint 1.6.3 → 1.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. package/Cargo.lock +3 -3
  2. package/Cargo.toml +1 -1
  3. package/README.md +82 -21
  4. package/assets/Mint_CLI.png +0 -0
  5. package/assets/Mint_Desktop.png +0 -0
  6. package/assets/Mint_Web.png +0 -0
  7. package/assets/Mint_WorkSpace.png +0 -0
  8. package/assets/Setting.png +0 -0
  9. package/assets/bridges.svg +4 -0
  10. package/assets/chat.svg +3 -0
  11. package/assets/code.svg +4 -0
  12. package/assets/features.svg +3 -0
  13. package/assets/imagegen.svg +5 -0
  14. package/assets/live2d.svg +6 -0
  15. package/assets/memory.svg +5 -0
  16. package/assets/screencapture.svg +4 -0
  17. package/assets/setup.svg +3 -0
  18. package/assets/tools.svg +4 -0
  19. package/bin/mint +0 -0
  20. package/crates/mint-cli/src/agent.rs +47 -15
  21. package/crates/mint-cli/src/main.rs +534 -7
  22. package/crates/mint-cli/src/onboard.rs +134 -1
  23. package/crates/mint-cli/src/setup.rs +173 -32
  24. package/crates/mint-core/src/api_server.rs +147 -3
  25. package/crates/mint-core/src/config.rs +22 -3
  26. package/crates/mint-core/src/image_gen.rs +895 -0
  27. package/crates/mint-core/src/lib.rs +4 -0
  28. package/crates/mint-core/src/memory.rs +27 -1
  29. package/crates/mint-core/src/orchestration.rs +1 -1
  30. package/crates/mint-core/src/plugins.rs +32 -0
  31. package/crates/mint-core/tests/memory_persistence.rs +28 -0
  32. package/package.json +1 -1
  33. package/src/bin/index.js +0 -0
  34. package/src/renderer/src/agentProgress.ts +39 -0
  35. package/src/renderer/src/components/ChatPanel.tsx +547 -47
  36. package/src/renderer/src/components/DashboardSidebar.tsx +12 -4
  37. package/src/renderer/src/components/ImageStudioPanel.tsx +638 -0
  38. package/src/renderer/src/components/MintDashboard.tsx +84 -31
  39. package/src/renderer/src/components/ModelPanel.tsx +2 -2
  40. package/src/renderer/src/components/PicturesLibrary.tsx +5 -1
  41. package/src/renderer/src/components/SettingsWindow.tsx +29 -20
  42. package/src/renderer/src/components/SpotlightWindow.tsx +8 -8
  43. package/src/renderer/src/components/WorkspacePanel.tsx +1 -1
  44. package/src/renderer/src/css/image-studio.css +722 -0
  45. package/src/renderer/src/css/settings.css +13 -3
  46. package/src/renderer/src/css/styles.css +245 -43
  47. package/src/renderer/src/index.css +152 -0
  48. package/src/renderer/src/tauri.ts +148 -0
  49. package/src/renderer/src-web/App.tsx +16 -1
  50. package/src/renderer/src-web/agentProgress.ts +39 -0
  51. package/src/renderer/src-web/components/ChatPanel.tsx +568 -46
  52. package/src/renderer/src-web/components/DashboardSidebar.tsx +10 -2
  53. package/src/renderer/src-web/components/ImageStudioPanel.tsx +638 -0
  54. package/src/renderer/src-web/components/MintDashboard.tsx +65 -19
  55. package/src/renderer/src-web/components/PicturesLibrary.tsx +5 -1
  56. package/src/renderer/src-web/components/SettingsWindow.tsx +29 -20
  57. package/src/renderer/src-web/css/image-studio.css +722 -0
  58. package/src/renderer/src-web/css/settings.css +14 -4
  59. package/src/renderer/src-web/css/styles.css +292 -44
  60. package/src/renderer/src-web/index.css +152 -0
  61. package/src/renderer/src-web/tauri.ts +149 -0
  62. package/src-tauri/src/lib.rs +65 -5
  63. package/src-tauri/tauri.conf.json +1 -1
@@ -1,4 +1,5 @@
1
1
  import { useEffect, useRef, useState, Fragment, type ChangeEvent, type ClipboardEvent, type DragEvent, type FormEvent, type KeyboardEvent, type RefObject } from 'react'
2
+ import { hasAgentToolActivity, thoughtsFrom } from '../agentProgress'
2
3
  import {
3
4
  type AgentProgress,
4
5
  type ChatResponse,
@@ -260,6 +261,64 @@ function renderApprovalDetails(approval: any): ApprovalDetails {
260
261
  return { title: 'Unknown Action', body: JSON.stringify(approval, null, 2), reason: 'Requires approval to proceed.', isDangerous: false }
261
262
  }
262
263
 
264
+ function renderDiff(diffText: string) {
265
+ if (!diffText) return null
266
+ const lines = diffText.split('\n')
267
+ return (
268
+ <div style={{
269
+ background: '#0b0f19',
270
+ borderRadius: '6px',
271
+ padding: '8px',
272
+ border: '1px solid rgba(255, 255, 255, 0.08)',
273
+ overflowX: 'auto',
274
+ maxHeight: '400px',
275
+ fontFamily: 'monospace',
276
+ fontSize: '0.74rem',
277
+ lineHeight: '1.4',
278
+ }}>
279
+ {lines.map((line, idx) => {
280
+ let style: any = {
281
+ whiteSpace: 'pre-wrap',
282
+ padding: '2px 6px',
283
+ }
284
+
285
+ if (line.startsWith('@@') || line.startsWith('---') || line.startsWith('+++')) {
286
+ style = {
287
+ ...style,
288
+ color: '#64748b',
289
+ fontWeight: 'bold',
290
+ }
291
+ } else if (line.startsWith('+')) {
292
+ style = {
293
+ ...style,
294
+ background: 'rgba(16, 185, 129, 0.12)',
295
+ borderLeft: '3px solid #10b981',
296
+ color: '#a7f3d0'
297
+ }
298
+ } else if (line.startsWith('-')) {
299
+ style = {
300
+ ...style,
301
+ background: 'rgba(239, 68, 68, 0.12)',
302
+ borderLeft: '3px solid #ef4444',
303
+ color: '#fca5a5'
304
+ }
305
+ } else {
306
+ style = {
307
+ ...style,
308
+ color: '#e2e8f0',
309
+ }
310
+ }
311
+
312
+ return (
313
+ <div key={idx} style={style}>
314
+ {line}
315
+ </div>
316
+ )
317
+ })}
318
+ </div>
319
+ )
320
+ }
321
+
263
322
  interface ChatPanelProps {
264
323
  interactions: any[]
265
324
  sending: boolean
@@ -269,6 +328,8 @@ interface ChatPanelProps {
269
328
  streamedResponse: ChatResponse | null
270
329
  agentProgress: AgentProgress[]
271
330
  agentActivitySnapshots: Record<string, AgentProgress[]>
331
+ thinkingExpanded: Record<string, boolean>
332
+ onThinkingExpandedChange: (key: string, open: boolean) => void
272
333
  message: string
273
334
  imageAttachments: Array<{ dataUri: string; name: string; previewDataUri?: string }>
274
335
  documentName: string
@@ -299,55 +360,414 @@ interface ChatPanelProps {
299
360
  onSetModel: (model: string) => void
300
361
  }
301
362
 
363
+ function ChatCodeBlock({ code, language }: { code: string; language: string; key?: any }) {
364
+ const [copied, setCopied] = useState(false)
365
+
366
+ const handleCopy = async () => {
367
+ try {
368
+ await navigator.clipboard.writeText(code)
369
+ setCopied(true)
370
+ setTimeout(() => setCopied(false), 2000)
371
+ } catch (err) {
372
+ console.error('Failed to copy code: ', err)
373
+ }
374
+ }
375
+
376
+ const handleDownload = () => {
377
+ try {
378
+ const blob = new Blob([code], { type: 'text/plain;charset=utf-8' })
379
+ const url = URL.createObjectURL(blob)
380
+ const link = document.createElement('a')
381
+ link.href = url
382
+ link.download = `code.${language === 'plaintext' ? 'txt' : language}`
383
+ link.click()
384
+ URL.revokeObjectURL(url)
385
+ } catch (err) {
386
+ console.error('Failed to download code: ', err)
387
+ }
388
+ }
389
+
390
+ const displayLang = language.charAt(0).toUpperCase() + language.slice(1).toLowerCase()
391
+
392
+ return (
393
+ <div className="chat-code-block-container" style={{ whiteSpace: 'normal' }}>
394
+ <div className="chat-code-block-header">
395
+ <span className="chat-code-block-lang">{displayLang}</span>
396
+ <div className="chat-code-block-actions">
397
+ <button type="button" onClick={handleDownload} title="Download code" className="chat-code-action-btn">
398
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
399
+ <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
400
+ <polyline points="7 10 12 15 17 10" />
401
+ <line x1="12" y1="15" x2="12" y2="3" />
402
+ </svg>
403
+ </button>
404
+ <button type="button" onClick={handleCopy} title="Copy code" className="chat-code-action-btn">
405
+ {copied ? (
406
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="var(--accent)" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round">
407
+ <polyline points="20 6 9 17 4 12" />
408
+ </svg>
409
+ ) : (
410
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
411
+ <rect x="9" y="9" width="13" height="13" rx="2" ry="2" />
412
+ <path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
413
+ </svg>
414
+ )}
415
+ </button>
416
+ </div>
417
+ </div>
418
+ <pre className="chat-code-block-body">
419
+ <code>{code}</code>
420
+ </pre>
421
+ </div>
422
+ )
423
+ }
424
+
425
+ const isTableLine = (line: string): boolean => {
426
+ const trimmed = line.trim()
427
+ return trimmed.startsWith('|') && trimmed.endsWith('|') && trimmed.length > 1
428
+ }
429
+
302
430
  function renderFormattedMessage(text: string) {
303
431
  const displayText = readableAssistantText(text)
304
432
  if (!displayText) return null
305
-
306
- const lines = displayText.split('\n')
307
- return lines.map((line, lineIndex) => {
308
- const headerMatch = line.match(/^(#{1,6})\s+(.*)$/)
309
-
310
- const formatInline = (str: string) => {
311
- const parts = str.split(/\*\*([\s\S]*?)\*\*/g)
312
- return parts.map((part, partIndex) => {
313
- if (partIndex % 2 === 1) {
433
+
434
+ const formatInline = (str: string) => {
435
+ const codeParts = str.split(/`([\s\S]*?)`/g)
436
+ return codeParts.map((codePart, codeIndex) => {
437
+ if (codeIndex % 2 === 1) {
438
+ return (
439
+ <code key={`code-${codeIndex}`} className="chat-inline-code">
440
+ {codePart}
441
+ </code>
442
+ )
443
+ }
444
+ const boldParts = codePart.split(/\*\*([\s\S]*?)\*\*/g)
445
+ return boldParts.map((boldPart, boldIndex) => {
446
+ if (boldIndex % 2 === 1) {
314
447
  return (
315
- <strong key={partIndex} className="chat-bold-highlight">
316
- {part}
448
+ <strong key={`bold-${boldIndex}`} className="chat-bold-highlight">
449
+ {boldPart}
317
450
  </strong>
318
451
  )
319
452
  }
320
- return part
453
+ return boldPart
321
454
  })
455
+ })
456
+ }
457
+
458
+ const renderHtmlTable = (tableLines: string[], key: string) => {
459
+ const rows: string[][] = []
460
+ for (const line of tableLines) {
461
+ const trimmed = line.trim()
462
+ if (!trimmed) continue
463
+ const content = trimmed.slice(1, -1)
464
+ const contentEscaped = content.replace(/\\\|/g, '\u0000')
465
+ const cols = contentEscaped.split('|').map(s => s.replace(/\u0000/g, '|').trim())
466
+ rows.push(cols)
322
467
  }
323
468
 
324
- if (headerMatch) {
325
- const level = headerMatch[1].length
326
- const content = headerMatch[2]
327
-
328
- const style = {
329
- fontWeight: 'bold',
330
- display: 'block',
331
- marginTop: level === 1 ? '16px' : level === 2 ? '14px' : '10px',
332
- marginBottom: '6px',
333
- fontSize: level === 1 ? '1.25em' : level === 2 ? '1.15em' : '1.05em',
334
- color: 'var(--text-main)'
469
+ if (rows.length === 0) return null
470
+
471
+ let hasSeparator = false
472
+ let separatorIdx = -1
473
+ for (let i = 0; i < rows.length; i++) {
474
+ const row = rows[i]
475
+ if (row.length > 0 && row.every(col => /^[-\s:]+$/.test(col))) {
476
+ hasSeparator = true
477
+ separatorIdx = i
478
+ break
479
+ }
480
+ }
481
+
482
+ let headerRow: string[] | null = null
483
+ const dataRows: string[][] = []
484
+
485
+ if (hasSeparator) {
486
+ if (separatorIdx > 0) {
487
+ headerRow = rows[separatorIdx - 1]
488
+ for (let i = 0; i < rows.length; i++) {
489
+ if (i !== separatorIdx && i !== separatorIdx - 1) {
490
+ dataRows.push(rows[i])
491
+ }
492
+ }
493
+ } else {
494
+ for (let i = 0; i < rows.length; i++) {
495
+ if (i !== separatorIdx) {
496
+ dataRows.push(rows[i])
497
+ }
498
+ }
499
+ }
500
+ } else {
501
+ headerRow = rows[0]
502
+ for (let i = 1; i < rows.length; i++) {
503
+ dataRows.push(rows[i])
335
504
  }
336
-
337
- return (
338
- <span key={lineIndex} style={style}>
339
- {formatInline(content)}
340
- </span>
341
- )
342
505
  }
343
506
 
344
507
  return (
345
- <Fragment key={lineIndex}>
346
- {formatInline(line)}
347
- {lineIndex < lines.length - 1 && '\n'}
348
- </Fragment>
508
+ <div key={key} className="chat-table-container" style={{
509
+ overflowX: 'auto',
510
+ margin: '14px 0',
511
+ width: '100%',
512
+ borderRadius: '8px',
513
+ border: '1px solid rgba(255, 255, 255, 0.12)',
514
+ background: 'rgba(30, 41, 59, 0.35)',
515
+ boxShadow: '0 4px 12px rgba(0, 0, 0, 0.15)',
516
+ }}>
517
+ <table className="chat-table" style={{
518
+ width: '100%',
519
+ borderCollapse: 'collapse',
520
+ fontSize: '0.86rem',
521
+ textAlign: 'left',
522
+ lineHeight: '1.5',
523
+ }}>
524
+ {headerRow && (
525
+ <thead>
526
+ <tr style={{
527
+ background: 'rgba(255, 255, 255, 0.04)',
528
+ borderBottom: '2px solid rgba(255, 255, 255, 0.15)',
529
+ }}>
530
+ {headerRow.map((col, idx) => (
531
+ <th key={`th-${idx}`} style={{
532
+ padding: '12px 16px',
533
+ fontWeight: 700,
534
+ color: 'var(--accent, #38bdf8)',
535
+ fontFamily: 'ui-sans-serif, system-ui, sans-serif',
536
+ }}>
537
+ {formatInline(col)}
538
+ </th>
539
+ ))}
540
+ </tr>
541
+ </thead>
542
+ )}
543
+ <tbody>
544
+ {dataRows.map((row, rIdx) => (
545
+ <tr key={`tr-${rIdx}`} style={{
546
+ background: rIdx % 2 === 1 ? 'rgba(255, 255, 255, 0.015)' : 'transparent',
547
+ borderBottom: rIdx < dataRows.length - 1 ? '1px solid rgba(255, 255, 255, 0.08)' : 'none',
548
+ }}>
549
+ {row.map((col, cIdx) => (
550
+ <td key={`td-${cIdx}`} style={{
551
+ padding: '12px 16px',
552
+ color: 'var(--text-main, #e2e8f0)',
553
+ }}>
554
+ {formatInline(col)}
555
+ </td>
556
+ ))}
557
+ </tr>
558
+ ))}
559
+ </tbody>
560
+ </table>
561
+ </div>
349
562
  )
350
- })
563
+ }
564
+
565
+ const lines = displayText.split('\n')
566
+ const items: any[] = []
567
+
568
+ let inCodeBlock = false
569
+ let codeBlockLang = ''
570
+ let codeBlockLines: string[] = []
571
+
572
+ let inTable = false
573
+ let tableLines: string[] = []
574
+
575
+ const flushTable = (index: number) => {
576
+ if (tableLines.length > 0) {
577
+ items.push(renderHtmlTable(tableLines, `table-${index}`))
578
+ tableLines = []
579
+ inTable = false
580
+ }
581
+ }
582
+
583
+ for (let i = 0; i < lines.length; i++) {
584
+ const line = lines[i]
585
+
586
+ if (line.trim().startsWith('```')) {
587
+ if (inTable) {
588
+ flushTable(i)
589
+ }
590
+ if (inCodeBlock) {
591
+ const codeText = codeBlockLines.join('\n')
592
+ items.push(
593
+ <ChatCodeBlock
594
+ key={`code-block-${i}`}
595
+ code={codeText}
596
+ language={codeBlockLang}
597
+ />
598
+ )
599
+ inCodeBlock = false
600
+ codeBlockLines = []
601
+ } else {
602
+ inCodeBlock = true
603
+ codeBlockLang = line.trim().slice(3).trim() || 'plaintext'
604
+ }
605
+ continue
606
+ }
607
+
608
+ if (inCodeBlock) {
609
+ codeBlockLines.push(line)
610
+ continue
611
+ }
612
+
613
+ if (isTableLine(line)) {
614
+ inTable = true
615
+ tableLines.push(line)
616
+ } else {
617
+ if (inTable) {
618
+ flushTable(i)
619
+ }
620
+
621
+ const headerMatch = line.match(/^(#{1,6})\s+(.*)$/)
622
+ if (headerMatch) {
623
+ const level = headerMatch[1].length
624
+ const content = headerMatch[2]
625
+
626
+ const style = {
627
+ fontWeight: 'bold',
628
+ display: 'block',
629
+ marginTop: level === 1 ? '16px' : level === 2 ? '14px' : '10px',
630
+ marginBottom: '6px',
631
+ fontSize: level === 1 ? '1.25em' : level === 2 ? '1.15em' : '1.05em',
632
+ color: 'var(--text-main)',
633
+ }
634
+
635
+ items.push(
636
+ <span key={`line-${i}`} style={style}>
637
+ {formatInline(content)}
638
+ </span>
639
+ )
640
+ } else {
641
+ const listMatch = line.match(/^(\s*)([-*+])\s+(.*)$/)
642
+ if (listMatch) {
643
+ const indent = listMatch[1]
644
+ const content = listMatch[3]
645
+ items.push(
646
+ <Fragment key={`line-${i}`}>
647
+ {indent}• {formatInline(content)}
648
+ {i < lines.length - 1 && '\n'}
649
+ </Fragment>
650
+ )
651
+ } else {
652
+ items.push(
653
+ <Fragment key={`line-${i}`}>
654
+ {formatInline(line)}
655
+ {i < lines.length - 1 && '\n'}
656
+ </Fragment>
657
+ )
658
+ }
659
+ }
660
+ }
661
+ }
662
+
663
+ if (inTable) {
664
+ flushTable(lines.length)
665
+ }
666
+
667
+ if (inCodeBlock && codeBlockLines.length > 0) {
668
+ const codeText = codeBlockLines.join('\n')
669
+ items.push(
670
+ <ChatCodeBlock
671
+ key={`code-block-end`}
672
+ code={codeText}
673
+ language={codeBlockLang}
674
+ />
675
+ )
676
+ }
677
+
678
+ return items
679
+ }
680
+
681
+ const THINKING_LABELS = {
682
+ live: 'Thinking…',
683
+ completed: (count: number) => `Thinking process (${count} steps)`,
684
+ step: (index: number) => `Step ${index}`,
685
+ emptyHint: 'Model didn\'t send thinking steps this time',
686
+ } as const
687
+
688
+ function ThinkingBlock({
689
+ thoughts,
690
+ isLive = false,
691
+ blockKey,
692
+ expanded,
693
+ onExpandedChange,
694
+ showEmptyHint = false,
695
+ }: {
696
+ thoughts: string[]
697
+ isLive?: boolean
698
+ blockKey: string
699
+ expanded?: boolean
700
+ onExpandedChange?: (key: string, open: boolean) => void
701
+ showEmptyHint?: boolean
702
+ }) {
703
+ const contentRef = useRef<HTMLDivElement>(null)
704
+ const isControlled = expanded !== undefined && Boolean(onExpandedChange)
705
+ const [localOpen, setLocalOpen] = useState(isLive)
706
+ const isOpen = isControlled ? Boolean(expanded) : localOpen
707
+
708
+ const setOpen = (open: boolean) => {
709
+ if (isControlled && onExpandedChange) onExpandedChange(blockKey, open)
710
+ else setLocalOpen(open)
711
+ }
712
+
713
+ useEffect(() => {
714
+ if (isLive && !isControlled) setLocalOpen(true)
715
+ }, [isLive, isControlled])
716
+
717
+ useEffect(() => {
718
+ if (!isLive || !isOpen || !contentRef.current) return
719
+ contentRef.current.scrollTop = contentRef.current.scrollHeight
720
+ }, [thoughts, isLive, isOpen])
721
+
722
+ if (thoughts.length === 0 && !showEmptyHint) return null
723
+
724
+ if (thoughts.length === 0) {
725
+ return (
726
+ <div className="thinking-block thinking-block-empty-state" data-live={isLive ? 'true' : 'false'}>
727
+ <span className="thinking-block-empty">{THINKING_LABELS.emptyHint}</span>
728
+ </div>
729
+ )
730
+ }
731
+
732
+ return (
733
+ <div className={`thinking-block${isLive ? ' is-live' : ''}`}>
734
+ <button
735
+ type="button"
736
+ className="thinking-block-header"
737
+ onClick={() => setOpen(!isOpen)}
738
+ aria-expanded={isOpen}
739
+ >
740
+ <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" style={{ flexShrink: 0 }}>
741
+ <path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3" />
742
+ <line x1="12" y1="17" x2="12.01" y2="17" />
743
+ <circle cx="12" cy="12" r="10" />
744
+ </svg>
745
+ <span className="thinking-block-label">
746
+ {isLive ? THINKING_LABELS.live : THINKING_LABELS.completed(thoughts.length)}
747
+ </span>
748
+ {isLive && <span className="thinking-block-live-dot" aria-hidden="true" />}
749
+ <span className={`thinking-block-chevron${isOpen ? ' is-open' : ''}`} aria-hidden="true">
750
+ <svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
751
+ <polyline points="9 18 15 12 9 6" />
752
+ </svg>
753
+ </span>
754
+ </button>
755
+ {isOpen && (
756
+ <div className="thinking-block-body">
757
+ <div className="thinking-block-content" ref={contentRef}>
758
+ {thoughts.map((thought, index) => (
759
+ <div className="thinking-block-step" key={`${blockKey}-${index}`}>
760
+ {thoughts.length > 1 && (
761
+ <div className="thinking-block-step-label">{THINKING_LABELS.step(index + 1)}</div>
762
+ )}
763
+ <div className="thinking-block-step-body">{renderFormattedMessage(thought)}</div>
764
+ </div>
765
+ ))}
766
+ </div>
767
+ </div>
768
+ )}
769
+ </div>
770
+ )
351
771
  }
352
772
 
353
773
  function readableAssistantText(text: string) {
@@ -519,6 +939,8 @@ export default function ChatPanel({
519
939
  streamedResponse,
520
940
  agentProgress,
521
941
  agentActivitySnapshots,
942
+ thinkingExpanded,
943
+ onThinkingExpandedChange,
522
944
  message,
523
945
  imageAttachments,
524
946
  documentName,
@@ -554,6 +976,24 @@ export default function ChatPanel({
554
976
  const [openReviewIds, setOpenReviewIds] = useState<Record<string, boolean>>({})
555
977
  const [openFileDiffs, setOpenFileDiffs] = useState<Record<string, boolean>>({})
556
978
  const [toolMenuOpen, setToolMenuOpen] = useState(false)
979
+ const [elapsedSeconds, setElapsedSeconds] = useState(0)
980
+
981
+ useEffect(() => {
982
+ if (!sending) {
983
+ setElapsedSeconds(0)
984
+ return
985
+ }
986
+
987
+ const startTime = Date.now()
988
+ setElapsedSeconds(0)
989
+
990
+ const timer = setInterval(() => {
991
+ const elapsed = Math.round((Date.now() - startTime) / 1000)
992
+ setElapsedSeconds(elapsed)
993
+ }, 1000)
994
+
995
+ return () => clearInterval(timer)
996
+ }, [sending])
557
997
  const [isRecording, setIsRecording] = useState(false)
558
998
  const [voiceMode, setVoiceMode] = useState(false)
559
999
  const [voiceTranscript, setVoiceTranscript] = useState('')
@@ -578,7 +1018,7 @@ export default function ChatPanel({
578
1018
  const canSubmit = Boolean(message.trim() || imageAttachments.length > 0 || documentName)
579
1019
  const sendingImageMarkers = Array.from({ length: sendingImageCount }, (_, index) => `[Image #${index + 1}]`).join(' ')
580
1020
  const voiceStatus = speakingText ? 'speaking' : (sending || voiceAwaitingResponse) ? 'thinking' : isRecording ? 'listening' : voiceMode ? 'ready' : 'off'
581
- const voiceStatusLabel = voiceStatus === 'speaking' ? 'กำลังตอบ' : voiceStatus === 'thinking' ? 'กำลังคิด' : voiceStatus === 'listening' ? 'กำลังฟัง' : 'พร้อมฟัง'
1021
+ const voiceStatusLabel = voiceStatus === 'speaking' ? 'Speaking' : voiceStatus === 'thinking' ? 'Thinking' : voiceStatus === 'listening' ? 'Listening' : 'Ready'
582
1022
 
583
1023
  const getAvailableModels = (provider: string) => {
584
1024
  switch (provider) {
@@ -762,7 +1202,7 @@ export default function ChatPanel({
762
1202
  }
763
1203
  const startAudioRecording = async (autoSend: boolean) => {
764
1204
  if (!navigator.mediaDevices?.getUserMedia || typeof MediaRecorder === 'undefined') {
765
- alert('ขออภัย ระบบนี้ไม่สามารถเข้าถึงไมค์หรืออัดเสียงใน WebView นี้ได้')
1205
+ alert('Sorry, this system cannot access the microphone or record audio in this WebView')
766
1206
  voiceModeRef.current = false
767
1207
  setVoiceMode(false)
768
1208
  return
@@ -785,7 +1225,7 @@ export default function ChatPanel({
785
1225
  mediaStreamRef.current = stream
786
1226
  mediaRecorderRef.current = recorder
787
1227
  audioContextRef.current = audioContext
788
- setVoiceTranscript('กำลังฟังเสียงจากไมค์')
1228
+ setVoiceTranscript('Listening to microphone...')
789
1229
 
790
1230
  recorder.ondataavailable = (event) => {
791
1231
  if (event.data.size > 0) chunks.push(event.data)
@@ -800,13 +1240,13 @@ export default function ChatPanel({
800
1240
  setIsRecording(false)
801
1241
  if (!autoSend || !voiceModeRef.current || chunks.length === 0) return
802
1242
  if (!heardVoice) {
803
- setVoiceTranscript('ยังไม่ได้ยินเสียงพูด')
1243
+ setVoiceTranscript('No speech detected')
804
1244
  scheduleVoiceListen(700)
805
1245
  return
806
1246
  }
807
1247
  const blob = new Blob(chunks, { type: recorder.mimeType || 'audio/webm' })
808
1248
  const audioDataUri = await blobToDataUri(blob)
809
- setVoiceTranscript('ส่งเสียงให้ AI แล้ว')
1249
+ setVoiceTranscript('Sent audio to AI')
810
1250
  voiceAwaitingResponseRef.current = true
811
1251
  setVoiceAwaitingResponse(true)
812
1252
  onSendVoiceMessage('', audioDataUri)
@@ -830,7 +1270,7 @@ export default function ChatPanel({
830
1270
  if (peak > 12) {
831
1271
  heardVoice = true
832
1272
  quietSince = 0
833
- setVoiceTranscript('กำลังฟัง...')
1273
+ setVoiceTranscript('Listening...')
834
1274
  } else if (heardVoice) {
835
1275
  quietSince = quietSince || now
836
1276
  }
@@ -845,7 +1285,7 @@ export default function ChatPanel({
845
1285
  setIsRecording(false)
846
1286
  voiceModeRef.current = false
847
1287
  setVoiceMode(false)
848
- alert('เปิดไมค์ไม่สำเร็จ กรุณาตรวจสิทธิ์ microphone ของแอป')
1288
+ alert('Failed to open microphone. Please check microphone permissions for this app')
849
1289
  }
850
1290
  }
851
1291
  const startRecognition = (autoSend: boolean) => {
@@ -1280,6 +1720,19 @@ export default function ChatPanel({
1280
1720
  <div className="bubble-wrapper">
1281
1721
  {renderCompletedActivity(interaction)}
1282
1722
  {renderFileChanges(interaction)}
1723
+ {(() => {
1724
+ const progress = agentActivitySnapshots[String(interaction.id)] ?? interaction.agentActivity ?? []
1725
+ const thoughts = thoughtsFrom(progress)
1726
+ return (
1727
+ <ThinkingBlock
1728
+ blockKey={String(interaction.id)}
1729
+ thoughts={thoughts}
1730
+ expanded={thinkingExpanded[String(interaction.id)] ?? false}
1731
+ onExpandedChange={onThinkingExpandedChange}
1732
+ showEmptyHint={hasAgentToolActivity(progress) && thoughts.length === 0}
1733
+ />
1734
+ )
1735
+ })()}
1283
1736
  <div className="message-bubble" style={{ whiteSpace: 'pre-wrap' }}>{renderFormattedMessage(interaction.aiText)}</div>
1284
1737
  <div className="message-time" style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
1285
1738
  <button className="provider-badge">{interaction.provider} • {interaction.model}</button>
@@ -1289,7 +1742,7 @@ export default function ChatPanel({
1289
1742
  type="button"
1290
1743
  className={`tts-btn ${speakingText === interaction.aiText ? 'is-speaking' : ''}`}
1291
1744
  onClick={() => speak(interaction.aiText)}
1292
- title={speakingText === interaction.aiText ? 'หยุดอ่านออกเสียง' : 'อ่านออกเสียง'}
1745
+ title={speakingText === interaction.aiText ? 'Stop reading' : 'Read aloud'}
1293
1746
  >
1294
1747
  {renderSpeakerIcon(speakingText === interaction.aiText)}
1295
1748
  </button>
@@ -1318,7 +1771,43 @@ export default function ChatPanel({
1318
1771
  {renderActiveFileChanges()}
1319
1772
  <div className="message ai-message thinking-message">
1320
1773
  <div className="bubble-wrapper">
1321
- <div className="message-bubble"><span>{streamedReply ? renderFormattedMessage(streamedReply) : 'Thinking...'}</span></div>
1774
+ <ThinkingBlock
1775
+ blockKey="live"
1776
+ thoughts={thoughtsFrom(agentProgress)}
1777
+ isLive={true}
1778
+ expanded={thinkingExpanded.live ?? true}
1779
+ onExpandedChange={onThinkingExpandedChange}
1780
+ showEmptyHint={
1781
+ agentMode
1782
+ && hasAgentToolActivity(agentProgress)
1783
+ && thoughtsFrom(agentProgress).length === 0
1784
+ && !streamedReply
1785
+ }
1786
+ />
1787
+ <div className="message-bubble">
1788
+ <span>
1789
+ {streamedReply ? (
1790
+ renderFormattedMessage(streamedReply)
1791
+ ) : (
1792
+ <div style={{ display: 'flex', alignItems: 'center', gap: '8px', color: 'var(--text-soft, #94a3b8)' }}>
1793
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round" style={{ display: 'inline-block', flexShrink: 0 }}>
1794
+ <circle cx="12" cy="12" r="10" stroke="rgba(255, 255, 255, 0.12)" />
1795
+ <path d="M12 2a10 10 0 0 1 10 10" stroke="var(--accent)" strokeLinecap="round">
1796
+ <animateTransform
1797
+ attributeName="transform"
1798
+ type="rotate"
1799
+ from="0 12 12"
1800
+ to="360 12 12"
1801
+ dur="0.9s"
1802
+ repeatCount="indefinite"
1803
+ />
1804
+ </path>
1805
+ </svg>
1806
+ <span>Thinking for {elapsedSeconds}s</span>
1807
+ </div>
1808
+ )}
1809
+ </span>
1810
+ </div>
1322
1811
  {streamedResponse && (
1323
1812
  <div className="message-time" style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
1324
1813
  <button className="provider-badge">{badge(streamedResponse.provider, streamedResponse.model)}</button>
@@ -1328,7 +1817,7 @@ export default function ChatPanel({
1328
1817
  type="button"
1329
1818
  className={`tts-btn ${speakingText === streamedReply ? 'is-speaking' : ''}`}
1330
1819
  onClick={() => speak(streamedReply)}
1331
- title={speakingText === streamedReply ? 'หยุดอ่านออกเสียง' : 'อ่านออกเสียง'}
1820
+ title={speakingText === streamedReply ? 'Stop reading' : 'Read aloud'}
1332
1821
  >
1333
1822
  {renderSpeakerIcon(speakingText === streamedReply)}
1334
1823
  </button>
@@ -1342,14 +1831,25 @@ export default function ChatPanel({
1342
1831
 
1343
1832
  {pendingApproval && (() => {
1344
1833
  const details = renderApprovalDetails(pendingApproval.approval)
1834
+ const writeFile = pendingApproval.approval?.WriteFile
1835
+ const applyPatch = pendingApproval.approval?.ApplyPatch
1836
+ const diffText = writeFile?.diff || applyPatch?.diff
1837
+
1345
1838
  return (
1346
1839
  <div className="message ai-message" style={{ width: '100%' }}>
1347
1840
  <div className="bubble-wrapper" style={{ width: '100%' }}>
1348
1841
  <div className="action-card approval-card" data-tier={details.isDangerous ? 'dangerous' : undefined} style={{ width: '100%' }}>
1349
- <div className="approval-card-content">
1842
+ <div className="approval-card-content" style={{ width: '100%' }}>
1350
1843
  <div className="approval-card-title">{details.title}</div>
1351
1844
  <div className="approval-card-body" style={{ whiteSpace: 'pre-wrap', fontFamily: 'monospace' }}>{details.body}</div>
1352
- {details.reason && <div className="approval-card-reason" style={{ whiteSpace: 'pre-wrap', fontFamily: 'monospace' }}>{details.reason}</div>}
1845
+ {diffText ? (
1846
+ <div className="approval-card-diff-container" style={{ marginTop: '8px', width: '100%' }}>
1847
+ <div style={{ fontSize: '0.8rem', fontWeight: 600, color: 'var(--text-soft, #94a3b8)', marginBottom: '4px' }}>Diff:</div>
1848
+ {renderDiff(diffText)}
1849
+ </div>
1850
+ ) : (
1851
+ details.reason && <div className="approval-card-reason" style={{ whiteSpace: 'pre-wrap', fontFamily: 'monospace' }}>{details.reason}</div>
1852
+ )}
1353
1853
  </div>
1354
1854
  <div className="approval-card-actions">
1355
1855
  <button type="button" className="approval-btn approval-btn-approve" onClick={() => onApproval(true)}>Approve</button>
@@ -1569,7 +2069,7 @@ export default function ChatPanel({
1569
2069
  className={`${isRecording ? 'is-recording' : ''} ${voiceMode ? 'voice-mode-active' : ''}`}
1570
2070
  type="button"
1571
2071
  onClick={toggleRecording}
1572
- title={voiceMode ? 'ปิดโหมดสนทนาเสียง' : 'เปิดโหมดสนทนาเสียง'}
2072
+ title={voiceMode ? 'Disable voice conversation' : 'Enable voice conversation'}
1573
2073
  style={{ display: 'inline-flex', alignItems: 'center', justifyContent: 'center' }}
1574
2074
  >
1575
2075
  {isRecording ? (