groove-dev 0.27.55 → 0.27.57

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 (42) hide show
  1. package/ai-chat/CHAT_MASTER_PLAN.md +184 -0
  2. package/node_modules/@groove-dev/cli/package.json +1 -1
  3. package/node_modules/@groove-dev/daemon/package.json +1 -1
  4. package/node_modules/@groove-dev/daemon/src/api.js +169 -0
  5. package/node_modules/@groove-dev/daemon/src/conversations.js +423 -0
  6. package/node_modules/@groove-dev/daemon/src/index.js +2 -0
  7. package/node_modules/@groove-dev/gui/dist/assets/index-C5WTeZO4.css +1 -0
  8. package/node_modules/@groove-dev/gui/dist/assets/{index-De-OWmBX.js → index-X58BAjGp.js} +1752 -1745
  9. package/node_modules/@groove-dev/gui/dist/index.html +2 -2
  10. package/node_modules/@groove-dev/gui/package.json +1 -1
  11. package/node_modules/@groove-dev/gui/src/app.jsx +2 -0
  12. package/node_modules/@groove-dev/gui/src/components/chat/chat-header.jsx +138 -0
  13. package/node_modules/@groove-dev/gui/src/components/chat/chat-input.jsx +112 -0
  14. package/node_modules/@groove-dev/gui/src/components/chat/chat-messages.jsx +347 -0
  15. package/node_modules/@groove-dev/gui/src/components/chat/chat-view.jsx +165 -0
  16. package/node_modules/@groove-dev/gui/src/components/chat/conversation-list.jsx +154 -0
  17. package/node_modules/@groove-dev/gui/src/components/chat/model-picker.jsx +143 -0
  18. package/node_modules/@groove-dev/gui/src/components/layout/activity-bar.jsx +2 -1
  19. package/node_modules/@groove-dev/gui/src/stores/groove.js +220 -0
  20. package/node_modules/@groove-dev/gui/src/views/chat.jsx +6 -0
  21. package/package.json +1 -1
  22. package/packages/cli/package.json +1 -1
  23. package/packages/daemon/package.json +1 -1
  24. package/packages/daemon/src/api.js +169 -0
  25. package/packages/daemon/src/conversations.js +423 -0
  26. package/packages/daemon/src/index.js +2 -0
  27. package/packages/gui/dist/assets/index-C5WTeZO4.css +1 -0
  28. package/packages/gui/dist/assets/{index-De-OWmBX.js → index-X58BAjGp.js} +1752 -1745
  29. package/packages/gui/dist/index.html +2 -2
  30. package/packages/gui/package.json +1 -1
  31. package/packages/gui/src/app.jsx +2 -0
  32. package/packages/gui/src/components/chat/chat-header.jsx +138 -0
  33. package/packages/gui/src/components/chat/chat-input.jsx +112 -0
  34. package/packages/gui/src/components/chat/chat-messages.jsx +347 -0
  35. package/packages/gui/src/components/chat/chat-view.jsx +165 -0
  36. package/packages/gui/src/components/chat/conversation-list.jsx +154 -0
  37. package/packages/gui/src/components/chat/model-picker.jsx +143 -0
  38. package/packages/gui/src/components/layout/activity-bar.jsx +2 -1
  39. package/packages/gui/src/stores/groove.js +220 -0
  40. package/packages/gui/src/views/chat.jsx +6 -0
  41. package/node_modules/@groove-dev/gui/dist/assets/index-CyVj0fHl.css +0 -1
  42. package/packages/gui/dist/assets/index-CyVj0fHl.css +0 -1
@@ -6,12 +6,12 @@
6
6
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
7
7
  <link rel="icon" type="image/png" href="/favicon.png" />
8
8
  <title>Groove GUI</title>
9
- <script type="module" crossorigin src="/assets/index-De-OWmBX.js"></script>
9
+ <script type="module" crossorigin src="/assets/index-X58BAjGp.js"></script>
10
10
  <link rel="modulepreload" crossorigin href="/assets/vendor-C0HXlhrU.js">
11
11
  <link rel="modulepreload" crossorigin href="/assets/reactflow-BQPfi37R.js">
12
12
  <link rel="modulepreload" crossorigin href="/assets/codemirror-BBL3i_JW.js">
13
13
  <link rel="modulepreload" crossorigin href="/assets/xterm--7_ns2zW.js">
14
- <link rel="stylesheet" crossorigin href="/assets/index-CyVj0fHl.css">
14
+ <link rel="stylesheet" crossorigin href="/assets/index-C5WTeZO4.css">
15
15
  </head>
16
16
  <body>
17
17
  <div id="root"></div>
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@groove-dev/gui",
3
- "version": "0.27.55",
3
+ "version": "0.27.57",
4
4
  "description": "GROOVE GUI — visual agent control plane",
5
5
  "license": "FSL-1.1-Apache-2.0",
6
6
  "type": "module",
@@ -19,6 +19,7 @@ import ModelsView from './views/models';
19
19
  import FederationView from './views/federation';
20
20
  import ToysView from './views/toys';
21
21
  import NetworkView from './views/network';
22
+ import ChatView from './views/chat';
22
23
 
23
24
  // Agent components
24
25
  import { AgentPanel } from './components/agents/agent-panel';
@@ -70,6 +71,7 @@ function ViewRouter() {
70
71
  case 'models': content = <ModelsView />; break;
71
72
  case 'federation': content = <FederationView />; break;
72
73
  case 'settings': content = <SettingsView />; break;
74
+ case 'chat': content = <ChatView />; break;
73
75
  case 'network': content = networkUnlocked ? <NetworkView /> : <AgentsView />; break;
74
76
  default: content = <AgentsView />;
75
77
  }
@@ -0,0 +1,138 @@
1
+ // FSL-1.1-Apache-2.0 — see LICENSE
2
+ import { useState, useRef, useEffect } from 'react';
3
+ import { Pencil, Pin, PinOff, Trash2, Hash, MoreHorizontal, Zap, Bot } from 'lucide-react';
4
+ import { useGrooveStore } from '../../stores/groove';
5
+ import { fmtNum } from '../../lib/format';
6
+ import { ModelPicker } from './model-picker';
7
+ import { Tooltip } from '../ui/tooltip';
8
+
9
+ export function ChatHeader({ conversation, model, onModelChange, onModeChange }) {
10
+ const renameConversation = useGrooveStore((s) => s.renameConversation);
11
+ const pinConversation = useGrooveStore((s) => s.pinConversation);
12
+ const deleteConversation = useGrooveStore((s) => s.deleteConversation);
13
+
14
+ const [editing, setEditing] = useState(false);
15
+ const [title, setTitle] = useState(conversation.title || '');
16
+ const [menuOpen, setMenuOpen] = useState(false);
17
+ const inputRef = useRef(null);
18
+ const menuRef = useRef(null);
19
+
20
+ useEffect(() => {
21
+ setTitle(conversation.title || '');
22
+ setEditing(false);
23
+ }, [conversation.id, conversation.title]);
24
+
25
+ useEffect(() => {
26
+ if (editing && inputRef.current) inputRef.current.focus();
27
+ }, [editing]);
28
+
29
+ useEffect(() => {
30
+ if (!menuOpen) return;
31
+ function handleClick(e) {
32
+ if (menuRef.current && !menuRef.current.contains(e.target)) setMenuOpen(false);
33
+ }
34
+ document.addEventListener('mousedown', handleClick);
35
+ return () => document.removeEventListener('mousedown', handleClick);
36
+ }, [menuOpen]);
37
+
38
+ function handleRename() {
39
+ const trimmed = title.trim();
40
+ if (trimmed && trimmed !== conversation.title) {
41
+ renameConversation(conversation.id, trimmed);
42
+ } else {
43
+ setTitle(conversation.title || '');
44
+ }
45
+ setEditing(false);
46
+ }
47
+
48
+ const agent = useGrooveStore((s) => s.agents.find((a) => a.id === conversation.agentId));
49
+ const tokens = agent?.tokensUsed || 0;
50
+ const mode = conversation.mode || 'api';
51
+
52
+ return (
53
+ <div className="h-11 flex items-center gap-3 px-4 border-b border-border bg-surface-1 flex-shrink-0">
54
+ <Hash size={14} className="text-text-4 flex-shrink-0" />
55
+
56
+ {editing ? (
57
+ <input
58
+ ref={inputRef}
59
+ value={title}
60
+ onChange={(e) => setTitle(e.target.value)}
61
+ onKeyDown={(e) => { if (e.key === 'Enter') handleRename(); if (e.key === 'Escape') { setTitle(conversation.title || ''); setEditing(false); } }}
62
+ onBlur={handleRename}
63
+ className="flex-1 min-w-0 bg-transparent text-sm font-semibold text-text-0 font-sans outline-none border-b border-accent"
64
+ maxLength={100}
65
+ />
66
+ ) : (
67
+ <button
68
+ onClick={() => setEditing(true)}
69
+ className="flex-1 min-w-0 text-left text-sm font-semibold text-text-0 font-sans truncate hover:text-accent transition-colors cursor-pointer"
70
+ >
71
+ {conversation.title || 'New Chat'}
72
+ </button>
73
+ )}
74
+
75
+ <div className="flex items-center gap-2 flex-shrink-0">
76
+ <div className="flex items-center h-7 rounded-lg bg-surface-3 border border-border-subtle p-0.5">
77
+ <Tooltip content="Lightweight — fast and cheap, no tools" side="bottom">
78
+ <button
79
+ onClick={() => onModeChange?.('api')}
80
+ className={`flex items-center gap-1 h-6 px-2 rounded-md text-2xs font-semibold font-sans transition-colors cursor-pointer ${mode === 'api' ? 'bg-accent/15 text-accent border border-accent/25' : 'text-text-3 hover:text-text-1'}`}
81
+ >
82
+ <Zap size={11} /> Chat
83
+ </button>
84
+ </Tooltip>
85
+ <Tooltip content="Full agent — tools, files, session resume" side="bottom">
86
+ <button
87
+ onClick={() => onModeChange?.('agent')}
88
+ className={`flex items-center gap-1 h-6 px-2 rounded-md text-2xs font-semibold font-sans transition-colors cursor-pointer ${mode === 'agent' ? 'bg-purple/15 text-purple border border-purple/25' : 'text-text-3 hover:text-text-1'}`}
89
+ >
90
+ <Bot size={11} /> Agent
91
+ </button>
92
+ </Tooltip>
93
+ </div>
94
+ <ModelPicker
95
+ value={model || { provider: conversation.provider, model: conversation.model }}
96
+ onChange={onModelChange}
97
+ disabled={false}
98
+ />
99
+ {tokens > 0 && (
100
+ <span className="text-2xs text-text-3 font-mono">{fmtNum(tokens)} tokens</span>
101
+ )}
102
+
103
+ <div ref={menuRef} className="relative">
104
+ <button
105
+ onClick={() => setMenuOpen(!menuOpen)}
106
+ className="w-7 h-7 flex items-center justify-center rounded-md text-text-3 hover:text-text-1 hover:bg-surface-3 transition-colors cursor-pointer"
107
+ >
108
+ <MoreHorizontal size={14} />
109
+ </button>
110
+ {menuOpen && (
111
+ <div className="absolute right-0 top-full mt-1 w-40 rounded-md border border-border bg-surface-1 shadow-xl z-50 py-1">
112
+ <button
113
+ onClick={() => { setEditing(true); setMenuOpen(false); }}
114
+ className="w-full flex items-center gap-2 px-3 py-1.5 text-sm text-text-1 hover:bg-surface-5 cursor-pointer font-sans"
115
+ >
116
+ <Pencil size={12} /> Rename
117
+ </button>
118
+ <button
119
+ onClick={() => { pinConversation(conversation.id, !conversation.pinned); setMenuOpen(false); }}
120
+ className="w-full flex items-center gap-2 px-3 py-1.5 text-sm text-text-1 hover:bg-surface-5 cursor-pointer font-sans"
121
+ >
122
+ {conversation.pinned ? <PinOff size={12} /> : <Pin size={12} />}
123
+ {conversation.pinned ? 'Unpin' : 'Pin'}
124
+ </button>
125
+ <div className="h-px my-1 bg-border-subtle" />
126
+ <button
127
+ onClick={() => { deleteConversation(conversation.id); setMenuOpen(false); }}
128
+ className="w-full flex items-center gap-2 px-3 py-1.5 text-sm text-danger hover:bg-danger/10 cursor-pointer font-sans"
129
+ >
130
+ <Trash2 size={12} /> Delete
131
+ </button>
132
+ </div>
133
+ )}
134
+ </div>
135
+ </div>
136
+ </div>
137
+ );
138
+ }
@@ -0,0 +1,112 @@
1
+ // FSL-1.1-Apache-2.0 — see LICENSE
2
+ import { useState, useRef, useEffect, useCallback } from 'react';
3
+ import { Send, Loader2, Square, Paperclip } from 'lucide-react';
4
+ import { cn } from '../../lib/cn';
5
+
6
+ export function ChatInput({ onSend, onStop, sending, streaming, disabled }) {
7
+ const [input, setInput] = useState('');
8
+ const textareaRef = useRef(null);
9
+ const fileInputRef = useRef(null);
10
+
11
+ const adjustHeight = useCallback(() => {
12
+ const el = textareaRef.current;
13
+ if (!el) return;
14
+ el.style.height = 'auto';
15
+ el.style.height = Math.min(el.scrollHeight, 200) + 'px';
16
+ }, []);
17
+
18
+ useEffect(() => {
19
+ adjustHeight();
20
+ }, [input, adjustHeight]);
21
+
22
+ useEffect(() => {
23
+ if (!disabled && textareaRef.current) textareaRef.current.focus();
24
+ }, [disabled]);
25
+
26
+ function handleSend() {
27
+ const text = input.trim();
28
+ if (!text || sending || disabled) return;
29
+ onSend(text);
30
+ setInput('');
31
+ if (textareaRef.current) {
32
+ textareaRef.current.style.height = 'auto';
33
+ }
34
+ }
35
+
36
+ function onKeyDown(e) {
37
+ if (e.key === 'Enter' && !e.shiftKey) {
38
+ e.preventDefault();
39
+ handleSend();
40
+ }
41
+ }
42
+
43
+ function handleFileSelect(e) {
44
+ const files = Array.from(e.target.files || []);
45
+ if (files.length === 0) return;
46
+ const pathList = files.map((f) => f.name).join(', ');
47
+ setInput((prev) => prev + (prev ? '\n' : '') + `[Attached: ${pathList}]`);
48
+ e.target.value = '';
49
+ }
50
+
51
+ const isActive = streaming || sending;
52
+ const canSend = input.trim() && !sending && !disabled;
53
+
54
+ return (
55
+ <div className="px-4 py-3">
56
+ <div className="flex items-end gap-2 rounded-2xl bg-surface-3/60 border border-border-subtle px-3 py-2 focus-within:border-accent/30 transition-colors">
57
+ <input
58
+ ref={fileInputRef}
59
+ type="file"
60
+ multiple
61
+ accept=".pdf,.png,.jpg,.jpeg,.gif,.svg,.csv,.txt,.md,.json,.yaml,.yml,.docx,.pptx,.xlsx"
62
+ onChange={handleFileSelect}
63
+ className="hidden"
64
+ />
65
+ <button
66
+ onClick={() => fileInputRef.current?.click()}
67
+ disabled={disabled}
68
+ className="w-8 h-8 flex items-center justify-center rounded-lg text-text-4 hover:text-text-1 hover:bg-surface-4 transition-colors cursor-pointer disabled:opacity-30 disabled:cursor-not-allowed flex-shrink-0"
69
+ title="Attach file"
70
+ >
71
+ <Paperclip size={15} />
72
+ </button>
73
+
74
+ <textarea
75
+ ref={textareaRef}
76
+ value={input}
77
+ onChange={(e) => setInput(e.target.value)}
78
+ onKeyDown={onKeyDown}
79
+ placeholder={disabled ? 'Select a model to start chatting...' : 'Send a message...'}
80
+ disabled={disabled}
81
+ rows={1}
82
+ style={{ minHeight: '36px' }}
83
+ className="flex-1 resize-none bg-transparent text-sm text-text-0 font-sans placeholder:text-text-4 focus:outline-none disabled:opacity-50 disabled:cursor-not-allowed py-1.5"
84
+ />
85
+
86
+ {isActive ? (
87
+ <button
88
+ onClick={onStop}
89
+ className="w-8 h-8 flex items-center justify-center rounded-xl bg-danger/80 text-white hover:bg-danger transition-all cursor-pointer shadow-lg shadow-danger/20 flex-shrink-0"
90
+ title="Stop generation"
91
+ >
92
+ <Square size={14} fill="currentColor" />
93
+ </button>
94
+ ) : (
95
+ <button
96
+ onClick={handleSend}
97
+ disabled={!canSend}
98
+ className={cn(
99
+ 'w-8 h-8 flex items-center justify-center rounded-xl transition-all cursor-pointer flex-shrink-0',
100
+ 'disabled:opacity-20 disabled:cursor-not-allowed',
101
+ canSend
102
+ ? 'bg-accent/15 text-accent hover:bg-accent/25 border border-accent/25'
103
+ : 'bg-surface-4 text-text-4',
104
+ )}
105
+ >
106
+ {sending ? <Loader2 size={16} className="animate-spin" /> : <Send size={16} />}
107
+ </button>
108
+ )}
109
+ </div>
110
+ </div>
111
+ );
112
+ }
@@ -0,0 +1,347 @@
1
+ // FSL-1.1-Apache-2.0 — see LICENSE
2
+ import { useRef, useEffect, useState } from 'react';
3
+ import { Copy, Check, ArrowRight, MessageCircle, Sparkles } from 'lucide-react';
4
+ import { cn } from '../../lib/cn';
5
+ import { timeAgo } from '../../lib/format';
6
+ import { ThinkingIndicator } from '../ui/thinking-indicator';
7
+
8
+ function CopyButton({ text }) {
9
+ const [copied, setCopied] = useState(false);
10
+ function handleCopy() {
11
+ navigator.clipboard.writeText(text).then(() => {
12
+ setCopied(true);
13
+ setTimeout(() => setCopied(false), 2000);
14
+ });
15
+ }
16
+ return (
17
+ <button
18
+ onClick={handleCopy}
19
+ className="flex items-center gap-1 px-2 py-1 text-2xs font-sans text-text-3 hover:text-text-1 transition-colors cursor-pointer"
20
+ >
21
+ {copied ? <Check size={12} className="text-success" /> : <Copy size={12} />}
22
+ {copied ? 'Copied' : 'Copy'}
23
+ </button>
24
+ );
25
+ }
26
+
27
+ function CodeBlock({ language, code }) {
28
+ return (
29
+ <div className="my-3 rounded-lg border border-border-subtle overflow-hidden bg-surface-0">
30
+ <div className="flex items-center justify-between px-3 py-1.5 bg-surface-3 border-b border-border-subtle">
31
+ <span className="text-2xs font-mono text-text-3">{language || 'code'}</span>
32
+ <CopyButton text={code} />
33
+ </div>
34
+ <pre className="px-4 py-3 overflow-x-auto">
35
+ <code className="text-xs font-mono text-text-1 leading-relaxed whitespace-pre">{code}</code>
36
+ </pre>
37
+ </div>
38
+ );
39
+ }
40
+
41
+ function parseMarkdown(text) {
42
+ if (!text) return [];
43
+ const lines = text.split('\n');
44
+ const blocks = [];
45
+ let i = 0;
46
+
47
+ while (i < lines.length) {
48
+ const line = lines[i];
49
+
50
+ // Fenced code block
51
+ if (line.startsWith('```')) {
52
+ const lang = line.slice(3).trim();
53
+ const codeLines = [];
54
+ i++;
55
+ while (i < lines.length && !lines[i].startsWith('```')) {
56
+ codeLines.push(lines[i]);
57
+ i++;
58
+ }
59
+ i++; // skip closing ```
60
+ blocks.push({ type: 'code', language: lang, code: codeLines.join('\n') });
61
+ continue;
62
+ }
63
+
64
+ // Heading
65
+ const headingMatch = line.match(/^(#{1,6})\s+(.+)/);
66
+ if (headingMatch) {
67
+ blocks.push({ type: 'heading', level: headingMatch[1].length, text: headingMatch[2] });
68
+ i++;
69
+ continue;
70
+ }
71
+
72
+ // Horizontal rule
73
+ if (/^(-{3,}|_{3,}|\*{3,})$/.test(line.trim())) {
74
+ blocks.push({ type: 'hr' });
75
+ i++;
76
+ continue;
77
+ }
78
+
79
+ // Blockquote
80
+ if (line.startsWith('> ')) {
81
+ const quoteLines = [line.slice(2)];
82
+ i++;
83
+ while (i < lines.length && lines[i].startsWith('> ')) {
84
+ quoteLines.push(lines[i].slice(2));
85
+ i++;
86
+ }
87
+ blocks.push({ type: 'blockquote', text: quoteLines.join('\n') });
88
+ continue;
89
+ }
90
+
91
+ // Unordered list
92
+ if (/^[-*+]\s/.test(line)) {
93
+ const items = [line.replace(/^[-*+]\s/, '')];
94
+ i++;
95
+ while (i < lines.length && /^[-*+]\s/.test(lines[i])) {
96
+ items.push(lines[i].replace(/^[-*+]\s/, ''));
97
+ i++;
98
+ }
99
+ blocks.push({ type: 'ul', items });
100
+ continue;
101
+ }
102
+
103
+ // Ordered list
104
+ if (/^\d+\.\s/.test(line)) {
105
+ const items = [line.replace(/^\d+\.\s/, '')];
106
+ i++;
107
+ while (i < lines.length && /^\d+\.\s/.test(lines[i])) {
108
+ items.push(lines[i].replace(/^\d+\.\s/, ''));
109
+ i++;
110
+ }
111
+ blocks.push({ type: 'ol', items });
112
+ continue;
113
+ }
114
+
115
+ // Table
116
+ if (line.includes('|') && i + 1 < lines.length && /^\|?\s*[-:]+/.test(lines[i + 1])) {
117
+ const headerCells = line.split('|').map((c) => c.trim()).filter(Boolean);
118
+ i += 2; // skip header + separator
119
+ const rows = [];
120
+ while (i < lines.length && lines[i].includes('|')) {
121
+ rows.push(lines[i].split('|').map((c) => c.trim()).filter(Boolean));
122
+ i++;
123
+ }
124
+ blocks.push({ type: 'table', headers: headerCells, rows });
125
+ continue;
126
+ }
127
+
128
+ // Empty line
129
+ if (line.trim() === '') {
130
+ i++;
131
+ continue;
132
+ }
133
+
134
+ // Paragraph — collect consecutive non-empty lines
135
+ const paraLines = [line];
136
+ i++;
137
+ while (i < lines.length && lines[i].trim() !== '' && !lines[i].startsWith('```') && !lines[i].startsWith('#') && !/^[-*+]\s/.test(lines[i]) && !/^\d+\.\s/.test(lines[i]) && !lines[i].startsWith('> ') && !/^(-{3,}|_{3,}|\*{3,})$/.test(lines[i].trim())) {
138
+ paraLines.push(lines[i]);
139
+ i++;
140
+ }
141
+ blocks.push({ type: 'paragraph', text: paraLines.join('\n') });
142
+ }
143
+
144
+ return blocks;
145
+ }
146
+
147
+ function InlineMarkdown({ text }) {
148
+ if (!text) return null;
149
+ const parts = text.split(/(```[\s\S]*?```|`[^`]+`|\*\*[^*]+\*\*|\*[^*]+\*|~~[^~]+~~|\[([^\]]+)\]\(([^)]+)\))/g);
150
+ return (
151
+ <>
152
+ {parts.map((part, i) => {
153
+ if (!part) return null;
154
+ if (part.startsWith('`') && part.endsWith('`') && !part.startsWith('``')) {
155
+ return <code key={i} className="px-1.5 py-0.5 rounded bg-surface-0 text-xs font-mono text-accent">{part.slice(1, -1)}</code>;
156
+ }
157
+ if (part.startsWith('**') && part.endsWith('**')) {
158
+ return <strong key={i} className="font-semibold text-text-0">{part.slice(2, -2)}</strong>;
159
+ }
160
+ if (part.startsWith('*') && part.endsWith('*') && !part.startsWith('**')) {
161
+ return <em key={i} className="italic">{part.slice(1, -1)}</em>;
162
+ }
163
+ if (part.startsWith('~~') && part.endsWith('~~')) {
164
+ return <del key={i} className="line-through text-text-3">{part.slice(2, -2)}</del>;
165
+ }
166
+ const linkMatch = part.match(/^\[([^\]]+)\]\(([^)]+)\)$/);
167
+ if (linkMatch) {
168
+ return <a key={i} href={linkMatch[2]} target="_blank" rel="noopener noreferrer" className="text-accent hover:underline">{linkMatch[1]}</a>;
169
+ }
170
+ return <span key={i}>{part}</span>;
171
+ })}
172
+ </>
173
+ );
174
+ }
175
+
176
+ function RenderedMarkdown({ text }) {
177
+ const blocks = parseMarkdown(text);
178
+ return (
179
+ <div className="space-y-2">
180
+ {blocks.map((block, i) => {
181
+ switch (block.type) {
182
+ case 'code':
183
+ return <CodeBlock key={i} language={block.language} code={block.code} />;
184
+ case 'heading': {
185
+ const sizes = ['text-lg font-bold', 'text-base font-bold', 'text-sm font-semibold', 'text-sm font-semibold', 'text-xs font-semibold', 'text-xs font-semibold'];
186
+ return <div key={i} className={cn(sizes[block.level - 1] || sizes[0], 'text-text-0 font-sans mt-3 mb-1')}><InlineMarkdown text={block.text} /></div>;
187
+ }
188
+ case 'hr':
189
+ return <hr key={i} className="border-border-subtle my-3" />;
190
+ case 'blockquote':
191
+ return (
192
+ <div key={i} className="border-l-2 border-accent/40 pl-3 py-1 text-sm text-text-2 italic font-sans">
193
+ <InlineMarkdown text={block.text} />
194
+ </div>
195
+ );
196
+ case 'ul':
197
+ return (
198
+ <ul key={i} className="list-disc list-inside space-y-0.5 text-sm text-text-1 font-sans">
199
+ {block.items.map((item, j) => <li key={j}><InlineMarkdown text={item} /></li>)}
200
+ </ul>
201
+ );
202
+ case 'ol':
203
+ return (
204
+ <ol key={i} className="list-decimal list-inside space-y-0.5 text-sm text-text-1 font-sans">
205
+ {block.items.map((item, j) => <li key={j}><InlineMarkdown text={item} /></li>)}
206
+ </ol>
207
+ );
208
+ case 'table':
209
+ return (
210
+ <div key={i} className="overflow-x-auto my-2">
211
+ <table className="text-xs font-sans border-collapse w-full">
212
+ <thead>
213
+ <tr className="border-b border-border">
214
+ {block.headers.map((h, j) => (
215
+ <th key={j} className="px-3 py-1.5 text-left font-semibold text-text-0">{h}</th>
216
+ ))}
217
+ </tr>
218
+ </thead>
219
+ <tbody>
220
+ {block.rows.map((row, j) => (
221
+ <tr key={j} className="border-b border-border-subtle">
222
+ {row.map((cell, k) => (
223
+ <td key={k} className="px-3 py-1.5 text-text-1"><InlineMarkdown text={cell} /></td>
224
+ ))}
225
+ </tr>
226
+ ))}
227
+ </tbody>
228
+ </table>
229
+ </div>
230
+ );
231
+ case 'paragraph':
232
+ return <p key={i} className="text-sm text-text-1 font-sans leading-relaxed whitespace-pre-wrap break-words"><InlineMarkdown text={block.text} /></p>;
233
+ default:
234
+ return null;
235
+ }
236
+ })}
237
+ </div>
238
+ );
239
+ }
240
+
241
+ function UserMessage({ msg }) {
242
+ return (
243
+ <div className="flex justify-end">
244
+ <div className="max-w-[75%]">
245
+ <div className="px-4 py-3 rounded-2xl rounded-br-md bg-accent/10 border border-accent/15">
246
+ <p className="text-sm text-text-0 font-sans whitespace-pre-wrap break-words leading-relaxed">{msg.text}</p>
247
+ </div>
248
+ <div className="text-2xs text-text-4 font-sans mt-1 text-right">{timeAgo(msg.timestamp)}</div>
249
+ </div>
250
+ </div>
251
+ );
252
+ }
253
+
254
+ function AssistantMessage({ msg, model }) {
255
+ return (
256
+ <div className="flex gap-3">
257
+ <div className="w-7 h-7 rounded-full bg-surface-4 border border-border-subtle flex items-center justify-center flex-shrink-0 mt-0.5">
258
+ <Sparkles size={13} className="text-accent" />
259
+ </div>
260
+ <div className="flex-1 min-w-0 max-w-[85%]">
261
+ {model && <div className="text-2xs text-text-3 font-sans mb-1 font-medium">{model}</div>}
262
+ <div className="px-4 py-3 rounded-2xl rounded-bl-md bg-surface-4 border border-border-subtle">
263
+ <RenderedMarkdown text={msg.text} />
264
+ </div>
265
+ <div className="text-2xs text-text-4 font-sans mt-1">{timeAgo(msg.timestamp)}</div>
266
+ </div>
267
+ </div>
268
+ );
269
+ }
270
+
271
+ function SystemMessage({ msg }) {
272
+ return (
273
+ <div className="flex justify-center py-1">
274
+ <div className="flex items-center gap-1.5 px-3 py-1 rounded-full bg-surface-4/50">
275
+ <ArrowRight size={10} className="text-text-4" />
276
+ <span className="text-2xs text-text-3 font-sans">{msg.text}</span>
277
+ </div>
278
+ </div>
279
+ );
280
+ }
281
+
282
+ function StreamingCursor() {
283
+ return (
284
+ <span className="inline-block w-2 h-4 bg-accent/60 ml-0.5 animate-pulse rounded-sm" />
285
+ );
286
+ }
287
+
288
+ function WelcomeMessage() {
289
+ return (
290
+ <div className="flex flex-col items-center justify-center h-full text-center py-16">
291
+ <div className="w-16 h-16 rounded-full bg-accent/8 flex items-center justify-center mb-5">
292
+ <MessageCircle size={28} className="text-accent" />
293
+ </div>
294
+ <h2 className="text-xl font-bold text-text-0 font-sans mb-2">Start a conversation</h2>
295
+ <p className="text-sm text-text-2 font-sans max-w-sm leading-relaxed">
296
+ Send a message to begin. Your conversation history is saved locally and syncs across sessions.
297
+ </p>
298
+ </div>
299
+ );
300
+ }
301
+
302
+ export function ChatMessages({ messages, isStreaming, model }) {
303
+ const scrollRef = useRef(null);
304
+ const isAtBottomRef = useRef(true);
305
+
306
+ useEffect(() => {
307
+ const el = scrollRef.current;
308
+ if (!el) return;
309
+ function handleScroll() {
310
+ isAtBottomRef.current = el.scrollHeight - el.scrollTop - el.clientHeight < 50;
311
+ }
312
+ el.addEventListener('scroll', handleScroll);
313
+ return () => el.removeEventListener('scroll', handleScroll);
314
+ }, []);
315
+
316
+ useEffect(() => {
317
+ if (isAtBottomRef.current && scrollRef.current) {
318
+ scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
319
+ }
320
+ }, [messages?.length, isStreaming]);
321
+
322
+ if (!messages || messages.length === 0) {
323
+ return (
324
+ <div ref={scrollRef} className="flex-1 overflow-y-auto">
325
+ <WelcomeMessage />
326
+ </div>
327
+ );
328
+ }
329
+
330
+ return (
331
+ <div ref={scrollRef} className="flex-1 overflow-y-auto px-6 py-5 space-y-5">
332
+ {messages.map((msg, i) => {
333
+ if (msg.from === 'user') return <UserMessage key={i} msg={msg} />;
334
+ if (msg.from === 'system') return <SystemMessage key={i} msg={msg} />;
335
+ return <AssistantMessage key={i} msg={msg} model={model} />;
336
+ })}
337
+ {isStreaming && (
338
+ <div className="flex gap-3">
339
+ <div className="w-7 h-7 rounded-full bg-surface-4 border border-border-subtle flex items-center justify-center flex-shrink-0">
340
+ <Sparkles size={13} className="text-accent" />
341
+ </div>
342
+ <ThinkingIndicator className="py-1" />
343
+ </div>
344
+ )}
345
+ </div>
346
+ );
347
+ }