@bibobonking/opensol 3.0.2 → 3.1.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 (72) hide show
  1. package/.github/workflows/build.yml +60 -8
  2. package/CHANGELOG.md +18 -0
  3. package/README.md +15 -12
  4. package/desktop/README.md +39 -0
  5. package/desktop/loading/index.html +36 -0
  6. package/desktop/src-tauri/Cargo.lock +4301 -0
  7. package/desktop/src-tauri/Cargo.toml +23 -0
  8. package/desktop/src-tauri/binaries/.gitkeep +0 -0
  9. package/desktop/src-tauri/build.rs +3 -0
  10. package/desktop/src-tauri/capabilities/default.json +7 -0
  11. package/desktop/src-tauri/icons/128x128.png +0 -0
  12. package/desktop/src-tauri/icons/128x128@2x.png +0 -0
  13. package/desktop/src-tauri/icons/32x32.png +0 -0
  14. package/desktop/src-tauri/icons/64x64.png +0 -0
  15. package/desktop/src-tauri/icons/Square107x107Logo.png +0 -0
  16. package/desktop/src-tauri/icons/Square142x142Logo.png +0 -0
  17. package/desktop/src-tauri/icons/Square150x150Logo.png +0 -0
  18. package/desktop/src-tauri/icons/Square284x284Logo.png +0 -0
  19. package/desktop/src-tauri/icons/Square30x30Logo.png +0 -0
  20. package/desktop/src-tauri/icons/Square310x310Logo.png +0 -0
  21. package/desktop/src-tauri/icons/Square44x44Logo.png +0 -0
  22. package/desktop/src-tauri/icons/Square71x71Logo.png +0 -0
  23. package/desktop/src-tauri/icons/Square89x89Logo.png +0 -0
  24. package/desktop/src-tauri/icons/StoreLogo.png +0 -0
  25. package/desktop/src-tauri/icons/icon.icns +0 -0
  26. package/desktop/src-tauri/icons/icon.ico +0 -0
  27. package/desktop/src-tauri/icons/icon.png +0 -0
  28. package/desktop/src-tauri/src/lib.rs +210 -0
  29. package/desktop/src-tauri/src/main.rs +6 -0
  30. package/desktop/src-tauri/tauri.conf.json +48 -0
  31. package/dist/assets/index-DdI1_iPD.css +1 -0
  32. package/dist/assets/index-egG5Bgfo.js +312 -0
  33. package/dist/index.html +2 -2
  34. package/package.json +2 -1
  35. package/server/daemon.mjs +64 -2
  36. package/server/lib/mcp.mjs +532 -0
  37. package/server/lib/mcp.test.mjs +46 -0
  38. package/server/lib/store.mjs +22 -0
  39. package/server/lib/tools.mjs +59 -1
  40. package/src/agent/tools.ts +6 -5
  41. package/src/components/Composer.tsx +22 -38
  42. package/src/components/DiffView.tsx +98 -0
  43. package/src/components/McpTab.tsx +337 -0
  44. package/src/components/MessageItem.tsx +62 -13
  45. package/src/components/ModelDeck.tsx +652 -0
  46. package/src/components/SessionsTab.tsx +206 -0
  47. package/src/components/SettingsModal.tsx +106 -69
  48. package/src/components/Sidebar.tsx +78 -24
  49. package/src/components/ToolCallCard.tsx +29 -7
  50. package/src/components/WorkspacePanel.tsx +16 -1
  51. package/src/core/kernel.ts +2 -0
  52. package/src/core/mcp-tools.ts +63 -0
  53. package/src/core/plugins/tools/fs.ts +24 -3
  54. package/src/core/services/agent-loop.ts +3 -1
  55. package/src/core/services/approval.ts +2 -1
  56. package/src/core/services/titles.ts +135 -0
  57. package/src/core/services/tools.ts +11 -8
  58. package/src/core/types.ts +4 -0
  59. package/src/index.css +97 -1
  60. package/src/lib/daemon.ts +29 -0
  61. package/src/lib/diff.ts +62 -0
  62. package/src/lib/permissions.ts +6 -6
  63. package/src/store/chats.ts +9 -1
  64. package/src/store/mcp.ts +67 -0
  65. package/src/store/settings.ts +9 -6
  66. package/src/store/ui.ts +13 -0
  67. package/src/store/workspace.ts +26 -0
  68. package/src/types.ts +51 -2
  69. package/tsconfig.tsbuildinfo +1 -1
  70. package/dist/assets/index-BmN7pBji.css +0 -1
  71. package/dist/assets/index-DgNDjkOz.js +0 -301
  72. package/src/components/ModelPicker.tsx +0 -487
@@ -0,0 +1,206 @@
1
+ import { useMemo, useState } from 'react'
2
+ import { Check, MessagesSquare, Pencil, Plus, Sparkles, Trash2 } from 'lucide-react'
3
+ import { useChats } from '../store/chats'
4
+ import { useRuntime, useUi } from '../store/ui'
5
+ import { useWorkspace } from '../store/workspace'
6
+ import { stopRun } from '../agent'
7
+ import { kernel } from '../core/kernel'
8
+ import { cn, formatRelativeTime } from '../lib/utils'
9
+ import type { ChatSession } from '../types'
10
+
11
+ function messageCount(session: ChatSession) {
12
+ return session.messages.filter((message) => message.role === 'user').length
13
+ }
14
+
15
+ function editedFiles(session: ChatSession) {
16
+ const files = new Set<string>()
17
+ for (const message of session.messages) {
18
+ if (message.role !== 'tool' || message.status !== 'done') continue
19
+ if (!['write_file', 'edit_file', 'rewrite_file'].includes(message.call.name)) continue
20
+ const path = message.call.args.path
21
+ if (typeof path === 'string') files.add(path)
22
+ }
23
+ return files.size
24
+ }
25
+
26
+ /** Sessions that belong to the open workspace, with quick rename / AI title / delete. */
27
+ export function SessionsTab() {
28
+ const sessions = useChats((state) => state.sessions)
29
+ const activeId = useChats((state) => state.activeId)
30
+ const setActive = useChats((state) => state.setActive)
31
+ const newChat = useChats((state) => state.newChat)
32
+ const renameSession = useChats((state) => state.renameSession)
33
+ const deleteSession = useChats((state) => state.deleteSession)
34
+ const root = useWorkspace((state) => state.info.root)
35
+ const streaming = useRuntime((state) => state.streaming)
36
+ const titling = useRuntime((state) => state.titling)
37
+ const askConfirm = useUi((state) => state.askConfirm)
38
+ const notify = useUi((state) => state.notify)
39
+ const focusComposer = useUi((state) => state.focusComposer)
40
+
41
+ const [scope, setScope] = useState<'workspace' | 'all'>('workspace')
42
+ const [editingId, setEditingId] = useState<string | null>(null)
43
+ const [draft, setDraft] = useState('')
44
+
45
+ const shown = useMemo(() => {
46
+ const list = scope === 'all' ? sessions : sessions.filter((session) => session.workspace === root)
47
+ return [...list].sort((a, b) => b.updatedAt - a.updatedAt)
48
+ }, [sessions, scope, root])
49
+ const untagged = sessions.filter((session) => !session.workspace).length
50
+
51
+ const commit = () => {
52
+ if (editingId && draft.trim()) renameSession(editingId, draft)
53
+ setEditingId(null)
54
+ }
55
+
56
+ const remove = async (session: ChatSession) => {
57
+ const confirmed = await askConfirm({
58
+ title: 'Delete this session?',
59
+ body: `“${session.title}” and its messages will be removed from this computer. This cannot be undone.`,
60
+ confirmLabel: 'Delete session',
61
+ tone: 'danger',
62
+ })
63
+ if (!confirmed) return
64
+ stopRun(session.id)
65
+ deleteSession(session.id)
66
+ }
67
+
68
+ const aiName = async (id: string) => {
69
+ const title = await kernel.titles?.generate(id, { force: true })
70
+ if (!title) notify('The model could not suggest a title right now.', 'error')
71
+ }
72
+
73
+ return (
74
+ <div className="flex min-h-0 flex-1 flex-col">
75
+ <div className="flex shrink-0 items-center gap-2 border-b border-line px-3 py-2.5">
76
+ <div className="relative flex rounded-full bg-raised p-0.5 text-[10.5px] font-medium">
77
+ {(['workspace', 'all'] as const).map((item) => (
78
+ <button
79
+ key={item}
80
+ type="button"
81
+ onClick={() => setScope(item)}
82
+ className={cn(
83
+ 'relative z-10 rounded-full px-2.5 py-1 transition-colors',
84
+ scope === item ? 'bg-panel text-ink shadow-pill' : 'text-muted hover:text-ink',
85
+ )}
86
+ >
87
+ {item === 'workspace' ? 'This workspace' : `All · ${sessions.length}`}
88
+ </button>
89
+ ))}
90
+ </div>
91
+ <button
92
+ type="button"
93
+ onClick={() => {
94
+ newChat()
95
+ focusComposer()
96
+ }}
97
+ className="ml-auto inline-flex h-7 items-center gap-1 rounded-full bg-ink px-2.5 text-[10.5px] font-medium text-panel transition-transform active:scale-95"
98
+ >
99
+ <Plus size={12} /> New
100
+ </button>
101
+ </div>
102
+
103
+ <div className="thin-scroll min-h-0 flex-1 overflow-y-auto p-2">
104
+ {shown.length === 0 ? (
105
+ <div className="px-4 py-10 text-center">
106
+ <MessagesSquare size={22} className="mx-auto text-faint" />
107
+ <p className="mt-2 text-[12px] font-medium text-ink">No sessions here yet</p>
108
+ <p className="mt-1 text-[10.5px] leading-relaxed text-faint">
109
+ {scope === 'workspace' && untagged > 0
110
+ ? `${untagged} older session${untagged === 1 ? '' : 's'} were started before workspaces were tracked. Switch to “All” to see them.`
111
+ : 'Start a conversation and it shows up here.'}
112
+ </p>
113
+ </div>
114
+ ) : (
115
+ shown.map((session, index) => {
116
+ const running = Boolean(streaming[session.id])
117
+ const edits = editedFiles(session)
118
+ return (
119
+ <div
120
+ key={session.id}
121
+ style={{ animationDelay: `${Math.min(index * 30, 300)}ms` }}
122
+ className={cn(
123
+ 'session-card group/session animate-slideleft mb-1.5 rounded-xl border px-3 py-2.5 transition-all duration-200',
124
+ activeId === session.id ? 'border-accent/40 bg-accent-soft/60' : 'border-transparent hover:border-line hover:bg-raised/60',
125
+ )}
126
+ >
127
+ {editingId === session.id ? (
128
+ <input
129
+ autoFocus
130
+ value={draft}
131
+ onChange={(event) => setDraft(event.target.value)}
132
+ onBlur={commit}
133
+ onKeyDown={(event) => {
134
+ if (event.key === 'Enter') commit()
135
+ if (event.key === 'Escape') setEditingId(null)
136
+ }}
137
+ className="h-7 w-full rounded-md border border-accent/50 bg-panel px-2 text-[12px] text-ink outline-none"
138
+ aria-label="Rename session"
139
+ />
140
+ ) : (
141
+ <button type="button" onClick={() => setActive(session.id)} className="block w-full text-left">
142
+ <span className="flex items-center gap-2">
143
+ {activeId === session.id && <Check size={12} className="shrink-0 text-accent" />}
144
+ <span className={cn('min-w-0 flex-1 truncate text-[12.5px] font-medium text-ink', titling[session.id] && 'text-shimmer')}>
145
+ {session.title}
146
+ </span>
147
+ {running && (
148
+ <span className="flex shrink-0 items-center gap-1 text-[10px] font-medium text-accent">
149
+ <span className="h-1.5 w-1.5 animate-breathe rounded-full bg-accent" /> running
150
+ </span>
151
+ )}
152
+ </span>
153
+ <span className="mt-1 flex flex-wrap items-center gap-x-2 gap-y-0.5 text-[10px] text-faint">
154
+ <span>{formatRelativeTime(session.updatedAt)}</span>
155
+ <span>· {messageCount(session)} turn{messageCount(session) === 1 ? '' : 's'}</span>
156
+ {edits > 0 && <span>· {edits} file{edits === 1 ? '' : 's'} edited</span>}
157
+ {session.model && <span className="max-w-[120px] truncate font-mono">· {session.model}</span>}
158
+ {session.titleSource === 'ai' && (
159
+ <span className="inline-flex items-center gap-0.5 text-accent/80">
160
+ <Sparkles size={9} /> AI title
161
+ </span>
162
+ )}
163
+ </span>
164
+ </button>
165
+ )}
166
+ <div className="mt-1.5 flex items-center gap-0.5 opacity-0 transition-opacity group-hover/session:opacity-100 focus-within:opacity-100">
167
+ <button
168
+ type="button"
169
+ className="icon-button h-6 w-6"
170
+ onClick={() => {
171
+ setEditingId(session.id)
172
+ setDraft(session.title)
173
+ }}
174
+ title="Rename"
175
+ aria-label="Rename session"
176
+ >
177
+ <Pencil size={11} />
178
+ </button>
179
+ <button
180
+ type="button"
181
+ className="icon-button h-6 w-6"
182
+ onClick={() => void aiName(session.id)}
183
+ disabled={Boolean(titling[session.id]) || session.messages.length === 0}
184
+ title="Let the model name this session"
185
+ aria-label="Name session with AI"
186
+ >
187
+ <Sparkles size={11} />
188
+ </button>
189
+ <button
190
+ type="button"
191
+ className="icon-button h-6 w-6 hover:!text-danger"
192
+ onClick={() => void remove(session)}
193
+ title="Delete"
194
+ aria-label="Delete session"
195
+ >
196
+ <Trash2 size={11} />
197
+ </button>
198
+ </div>
199
+ </div>
200
+ )
201
+ })
202
+ )}
203
+ </div>
204
+ </div>
205
+ )
206
+ }
@@ -39,7 +39,7 @@ import {
39
39
  Wrench,
40
40
  X,
41
41
  } from 'lucide-react'
42
- import type { DeployProviderId, Effort, ModelInfo, ProviderId, SearchProvider, Skill, ToolName } from '../types'
42
+ import type { DeployProviderId, Effort, ModelInfo, ProviderId, SearchProvider, Skill, BuiltinToolName } from '../types'
43
43
  import type { LucideIcon } from 'lucide-react'
44
44
  import { EFFORT_LABELS, PROVIDERS, PROVIDER_MAP } from '../data/providers'
45
45
  import { STARTER_SKILL } from '../data/skills'
@@ -61,6 +61,7 @@ import { PERMISSION_MODES, permissionMode, permissionTools } from '../lib/permis
61
61
  import { cn, formatBytes, readFileAsText } from '../lib/utils'
62
62
  import { LogoMark } from './Logo'
63
63
  import { PluginManager } from './PluginManager'
64
+ import { McpTab } from './McpTab'
64
65
  import { pluginRuntime } from '../plugins'
65
66
  import { Modal, Segmented, Spinner, Switch } from './ui'
66
67
 
@@ -71,6 +72,7 @@ const TABS = [
71
72
  { id: 'search', label: 'Search', icon: Globe2, description: 'Give the agent current web results with your own key.' },
72
73
  { id: 'skills', label: 'Skills', icon: Sparkles, description: 'Instructions added to every request when enabled.' },
73
74
  { id: 'plugins', label: 'Plugins', icon: Package, description: 'Install local plugin packages, review what they ask for, and run them once you trust the code.' },
75
+ { id: 'mcp', label: 'MCP', icon: Link2, description: 'Connect Model Context Protocol servers and choose which of their tools the agent may call.' },
74
76
  { id: 'deploy', label: 'Deploy', icon: Rocket, description: 'Hosting credentials and command-line tools.' },
75
77
  { id: 'appearance', label: 'Appearance', icon: Palette, description: 'Theme and interface preferences.' },
76
78
  { id: 'about', label: 'About', icon: Info, description: 'Local storage, history and reset controls.' },
@@ -100,15 +102,18 @@ function SettingsSection({
100
102
  action?: ReactNode
101
103
  }) {
102
104
  return (
103
- <section className="border-b border-line px-5 py-5 last:border-b-0 sm:px-7">
105
+ <section className="settings-card mx-5 my-4 sm:mx-8">
104
106
  <div className="flex items-start justify-between gap-3">
105
107
  <div className="min-w-0">
106
- <h3 className="font-display text-[14px] font-semibold tracking-[-0.01em] text-ink">{title}</h3>
107
- {description && <p className="mt-1 max-w-[620px] text-[11px] leading-relaxed text-muted">{description}</p>}
108
+ <h3 className="flex items-center gap-2 font-display text-[14px] font-semibold tracking-[-0.01em] text-ink">
109
+ <span className="h-3.5 w-[3px] rounded-full bg-accent" />
110
+ {title}
111
+ </h3>
112
+ {description && <p className="mt-1.5 max-w-[620px] text-[11.5px] leading-relaxed text-muted">{description}</p>}
108
113
  </div>
109
114
  {action && <div className="shrink-0">{action}</div>}
110
115
  </div>
111
- <div className="mt-3.5">{children}</div>
116
+ <div className="mt-4">{children}</div>
112
117
  </section>
113
118
  )
114
119
  }
@@ -636,7 +641,7 @@ function ModelsTab() {
636
641
  )
637
642
  }
638
643
 
639
- const TOOL_ORDER: Array<{ title: string; tools: ToolName[] }> = [
644
+ const TOOL_ORDER: Array<{ title: string; tools: BuiltinToolName[] }> = [
640
645
  {
641
646
  title: 'Workspace and shell',
642
647
  tools: ['read_file', 'list_files', 'grep', 'write_file', 'edit_file', 'rewrite_file', 'shell', 'git'],
@@ -645,7 +650,7 @@ const TOOL_ORDER: Array<{ title: string; tools: ToolName[] }> = [
645
650
  { title: 'Delegation', tools: ['subagent'] },
646
651
  ]
647
652
 
648
- const TOOL_ICONS: Record<ToolName, LucideIcon> = {
653
+ const TOOL_ICONS: Record<BuiltinToolName, LucideIcon> = {
649
654
  shell: SquareTerminal,
650
655
  read_file: FileText,
651
656
  list_files: FolderTree,
@@ -659,7 +664,7 @@ const TOOL_ICONS: Record<ToolName, LucideIcon> = {
659
664
  subagent: Bot,
660
665
  }
661
666
 
662
- const TOOL_COPY: Record<ToolName, { label: string; description: string }> = {
667
+ const TOOL_COPY: Record<BuiltinToolName, { label: string; description: string }> = {
663
668
  shell: { label: 'Shell', description: 'Run commands in the workspace root. Plan mode allows read-only commands only.' },
664
669
  read_file: { label: 'Read file', description: 'Inspect a file on disk before changing it.' },
665
670
  list_files: { label: 'List files', description: 'Explore the real workspace tree.' },
@@ -1604,6 +1609,18 @@ function GeneralTab() {
1604
1609
  </select>
1605
1610
  </div>
1606
1611
 
1612
+ <div className="settings-row">
1613
+ <div className="min-w-0">
1614
+ <p className="settings-label">Let AI name sessions</p>
1615
+ <p className="settings-hint">After the first answer, the model writes a short title for the session. Names you type yourself are never replaced.</p>
1616
+ </div>
1617
+ <Switch
1618
+ checked={settings.aiSessionTitles}
1619
+ onChange={(aiSessionTitles) => update({ aiSessionTitles })}
1620
+ label="Let AI name sessions"
1621
+ />
1622
+ </div>
1623
+
1607
1624
  <div className="settings-row">
1608
1625
  <div className="min-w-0">
1609
1626
  <p className="settings-label">Show token estimates</p>
@@ -1941,13 +1958,15 @@ export function SettingsModal() {
1941
1958
  const notify = useUi((state) => state.notify)
1942
1959
  const daemon = useSettings((state) => state.daemon)
1943
1960
  const contentRef = useRef<HTMLDivElement>(null)
1961
+ const activeIndex = Math.max(0, TABS.findIndex((item) => item.id === tab))
1962
+ const current = TABS[activeIndex]
1944
1963
 
1945
1964
  useEffect(() => {
1946
1965
  if (open) contentRef.current?.scrollTo({ top: 0 })
1947
1966
  }, [open, tab])
1948
1967
 
1949
1968
  const copyConfig = async () => {
1950
- const path = daemon?.dataDir ?? '~/.maxcode'
1969
+ const path = daemon?.dataDir ?? '~/.opensol'
1951
1970
  try {
1952
1971
  await navigator.clipboard.writeText(path)
1953
1972
  notify(`Copied the data folder path: ${path}`, 'success')
@@ -1961,85 +1980,103 @@ export function SettingsModal() {
1961
1980
  open={open}
1962
1981
  onClose={close}
1963
1982
  labelledBy="settings-title"
1964
- className="h-[92dvh] p-0 sm:h-[min(88dvh,780px)] sm:w-[min(980px,94vw)]"
1983
+ className="settings-shell h-[94dvh] p-0 sm:h-[min(88dvh,780px)] sm:w-[min(1040px,96vw)] sm:!rounded-[28px]"
1965
1984
  >
1966
- <div className="flex min-h-0 flex-1 flex-col">
1967
- <div className="flex h-14 shrink-0 items-center justify-between gap-3 border-b border-line px-4 sm:px-5">
1968
- <h2 id="settings-title" className="text-[16px] font-semibold tracking-[-0.02em] text-ink">
1969
- Settings
1970
- </h2>
1971
- <div className="flex shrink-0 items-center gap-1.5">
1972
- <button
1973
- type="button"
1974
- onClick={() => void copyConfig()}
1975
- className="hidden h-8 items-center gap-1.5 rounded-lg border border-line bg-panel px-2.5 text-[11px] font-medium text-ink transition-colors hover:bg-raised sm:inline-flex"
1976
- title={daemon?.dataDir ?? '~/.maxcode'}
1977
- >
1978
- <HardDrive size={12} />
1979
- Data folder
1980
- </button>
1981
- <button type="button" className="icon-button" onClick={close} aria-label="Close settings">
1982
- <X size={16} />
1983
- </button>
1985
+ <div className="flex min-h-0 flex-1 flex-col sm:flex-row">
1986
+ <aside className="relative hidden w-[232px] shrink-0 flex-col bg-canvas sm:flex">
1987
+ <div className="settings-glow pointer-events-none absolute inset-x-0 top-0 h-48" />
1988
+ <div className="relative px-6 pb-4 pt-6">
1989
+ <p className="font-mono text-[10px] uppercase tracking-[0.24em] text-faint">OpenSol · v3.1.0</p>
1990
+ <h2 id="settings-title" className="mt-1.5 font-display text-[26px] font-semibold leading-none tracking-[-0.035em] text-ink">
1991
+ Settings
1992
+ </h2>
1984
1993
  </div>
1985
- </div>
1986
-
1987
- <div className="flex min-h-0 flex-1">
1988
- <aside className="hidden w-[200px] shrink-0 flex-col border-r border-line bg-canvas sm:flex">
1989
- <nav className="flex-1 space-y-0.5 overflow-y-auto p-2">
1990
- {TABS.map((item) => {
1991
- const Icon = item.icon
1992
- const active = tab === item.id
1993
- return (
1994
- <button
1995
- key={item.id}
1996
- type="button"
1997
- onClick={() => openSettings(item.id)}
1998
- title={item.description}
1999
- className={cn(
2000
- 'flex h-9 w-full items-center gap-2.5 rounded-lg px-2.5 text-left text-[12.5px] transition-colors',
2001
- active ? 'bg-raised font-medium text-ink' : 'text-muted hover:bg-raised/60 hover:text-ink',
2002
- )}
2003
- >
2004
- <Icon size={14} className={cn(active ? 'text-ink' : 'text-faint')} />
2005
- {item.label}
2006
- </button>
2007
- )
2008
- })}
2009
- </nav>
2010
- <div className="border-t border-line p-3">
2011
- <p className="flex items-center gap-1.5 text-[9px] text-faint">
2012
- <LockKeyhole size={10} />
2013
- Keys encrypted on this machine
2014
- </p>
2015
- <p className="mt-1 text-[9px] text-faint">OpenSol 1.0 · OpenSoft</p>
2016
- </div>
2017
- </aside>
2018
-
2019
- <div className="flex min-h-0 min-w-0 flex-1 flex-col">
2020
- <div className="thin-scroll flex shrink-0 gap-1 overflow-x-auto border-b border-line px-3 py-2 sm:hidden">
2021
- {TABS.map((item) => (
1994
+ <nav className="relative flex-1 overflow-y-auto px-3 pb-3">
1995
+ <span
1996
+ aria-hidden
1997
+ className="settings-pill absolute left-3 right-3 top-0 h-10 rounded-2xl bg-panel shadow-pill ring-1 ring-line transition-transform duration-500 ease-spring"
1998
+ style={{ transform: `translateY(${activeIndex * 42}px)` }}
1999
+ />
2000
+ {TABS.map((item, index) => {
2001
+ const Icon = item.icon
2002
+ const active = tab === item.id
2003
+ return (
2022
2004
  <button
2023
2005
  key={item.id}
2024
2006
  type="button"
2025
2007
  onClick={() => openSettings(item.id)}
2008
+ title={item.description}
2026
2009
  className={cn(
2027
- 'shrink-0 rounded-full px-2.5 py-1 text-[10.5px] font-medium transition-colors',
2028
- tab === item.id ? 'bg-ink text-panel' : 'text-muted hover:bg-raised',
2010
+ 'group/tab relative mb-[2px] flex h-10 w-full items-center gap-3 rounded-2xl px-3 text-left text-[12.5px] transition-colors duration-200',
2011
+ active ? 'font-semibold text-ink' : 'text-muted hover:text-ink',
2029
2012
  )}
2030
2013
  >
2014
+ <span className={cn('w-4 font-mono text-[9.5px] tabular-nums transition-colors', active ? 'text-accent' : 'text-faint')}>
2015
+ {String(index + 1).padStart(2, '0')}
2016
+ </span>
2017
+ <Icon size={14} className={cn('transition-transform duration-300 group-hover/tab:scale-110', active ? 'text-accent' : 'text-faint')} />
2031
2018
  {item.label}
2032
2019
  </button>
2033
- ))}
2020
+ )
2021
+ })}
2022
+ </nav>
2023
+ <div className="relative px-6 py-4">
2024
+ <p className="flex items-center gap-1.5 text-[10px] text-faint">
2025
+ <LockKeyhole size={10} /> Keys encrypted on this machine
2026
+ </p>
2027
+ </div>
2028
+ </aside>
2029
+
2030
+ <div className="relative flex min-h-0 min-w-0 flex-1 flex-col bg-panel sm:border-l sm:border-line">
2031
+ <div className="thin-scroll flex shrink-0 gap-1.5 overflow-x-auto border-b border-line px-3 py-2.5 sm:hidden">
2032
+ {TABS.map((item) => (
2033
+ <button
2034
+ key={item.id}
2035
+ type="button"
2036
+ onClick={() => openSettings(item.id)}
2037
+ className={cn(
2038
+ 'shrink-0 rounded-full px-3 py-1.5 text-[11px] font-medium transition-all duration-200',
2039
+ tab === item.id ? 'bg-ink text-panel' : 'bg-raised text-muted',
2040
+ )}
2041
+ >
2042
+ {item.label}
2043
+ </button>
2044
+ ))}
2045
+ </div>
2046
+
2047
+ <header className="flex shrink-0 items-start justify-between gap-4 px-5 pb-3 pt-5 sm:px-8 sm:pt-7">
2048
+ <div key={tab} className="settings-title-in min-w-0">
2049
+ <p className="font-mono text-[10px] uppercase tracking-[0.22em] text-accent">
2050
+ {String(activeIndex + 1).padStart(2, '0')} / {String(TABS.length).padStart(2, '0')}
2051
+ </p>
2052
+ <h3 className="mt-1 font-display text-[28px] font-semibold leading-none tracking-[-0.035em] text-ink">{current.label}</h3>
2053
+ <p className="mt-2 max-w-[560px] text-[12.5px] leading-relaxed text-muted">{current.description}</p>
2054
+ </div>
2055
+ <div className="flex shrink-0 items-center gap-1.5">
2056
+ <button
2057
+ type="button"
2058
+ onClick={() => void copyConfig()}
2059
+ className="hidden h-8 items-center gap-1.5 rounded-full border border-line bg-panel px-3 text-[11px] font-medium text-ink transition-all hover:bg-raised active:scale-95 sm:inline-flex"
2060
+ title={daemon?.dataDir ?? '~/.opensol'}
2061
+ >
2062
+ <HardDrive size={12} />
2063
+ Data folder
2064
+ </button>
2065
+ <button type="button" className="icon-button h-8 w-8 rounded-full" onClick={close} aria-label="Close settings">
2066
+ <X size={16} />
2067
+ </button>
2034
2068
  </div>
2069
+ </header>
2035
2070
 
2036
- <div ref={contentRef} className="thin-scroll min-h-0 flex-1 overflow-y-auto">
2071
+ <div ref={contentRef} className="thin-scroll min-h-0 flex-1 overflow-y-auto pb-6">
2072
+ <div key={tab} className="settings-enter">
2037
2073
  {tab === 'general' && <GeneralTab />}
2038
2074
  {tab === 'models' && <ModelsTab />}
2039
2075
  {tab === 'tools' && <ToolsTab />}
2040
2076
  {tab === 'search' && <SearchTab />}
2041
2077
  {tab === 'skills' && <SkillsTab />}
2042
2078
  {tab === 'plugins' && <PluginsTab />}
2079
+ {tab === 'mcp' && <McpTab />}
2043
2080
  {tab === 'deploy' && <DeployTab />}
2044
2081
  {tab === 'appearance' && <AppearanceTab />}
2045
2082
  {tab === 'about' && <AboutTab />}
@@ -12,6 +12,7 @@ import {
12
12
  Plus,
13
13
  Rocket,
14
14
  Search,
15
+ Plug,
15
16
  Settings,
16
17
  Sparkles,
17
18
  Trash2,
@@ -22,6 +23,7 @@ import { hasCredentials, useSettings } from '../store/settings'
22
23
  import { useRuntime, useUi } from '../store/ui'
23
24
  import { useWorkspace } from '../store/workspace'
24
25
  import { stopRun } from '../agent'
26
+ import { kernel } from '../core/kernel'
25
27
  import { getProviderName } from '../data/providers'
26
28
  import { cn, downloadText, formatRelativeTime, groupChatsByDate } from '../lib/utils'
27
29
  import { LogoLockup } from './Logo'
@@ -46,6 +48,7 @@ export function Sidebar() {
46
48
  const settings = useSettings()
47
49
  const workspace = useWorkspace((state) => state.info)
48
50
  const addWorkspace = useWorkspace((state) => state.addWorkspace)
51
+ const removeWorkspace = useWorkspace((state) => state.removeWorkspace)
49
52
  const sidebarCollapsed = useUi((state) => state.sidebarCollapsed)
50
53
  const toggleSidebar = useUi((state) => state.toggleSidebar)
51
54
  const mobileOpen = useUi((state) => state.mobileSidebarOpen)
@@ -57,6 +60,7 @@ export function Sidebar() {
57
60
  const notify = useUi((state) => state.notify)
58
61
  const focusComposer = useUi((state) => state.focusComposer)
59
62
  const streaming = useRuntime((state) => state.streaming)
63
+ const titling = useRuntime((state) => state.titling)
60
64
 
61
65
  const [query, setQuery] = useState('')
62
66
  const [menu, setMenu] = useState<ChatMenu | null>(null)
@@ -108,6 +112,28 @@ export function Sidebar() {
108
112
  }
109
113
  }
110
114
 
115
+ const deleteWorkspace = async (path: string, name: string) => {
116
+ const confirmed = await askConfirm({
117
+ title: 'Remove this workspace?',
118
+ body: `“${name}” is removed from OpenSol's list. The folder and its files stay on your disk; conversations started there are kept.`,
119
+ confirmLabel: 'Remove workspace',
120
+ tone: 'danger',
121
+ })
122
+ if (!confirmed) return
123
+ try {
124
+ await removeWorkspace(path)
125
+ notify(`Removed ${name} from your workspaces.`, 'success')
126
+ } catch (error) {
127
+ notify(error instanceof Error ? error.message : 'Could not remove that workspace.', 'error')
128
+ }
129
+ }
130
+
131
+ const aiRename = async (id: string) => {
132
+ setMenu(null)
133
+ const title = await kernel.titles?.generate(id, { force: true })
134
+ if (!title) notify('The model could not suggest a title right now.', 'error')
135
+ }
136
+
111
137
  const beginRename = (id: string, title: string) => {
112
138
  setEditingId(id)
113
139
  setDraftTitle(title)
@@ -225,6 +251,17 @@ export function Sidebar() {
225
251
  <Package size={15} strokeWidth={1.7} className="shrink-0 text-faint" />
226
252
  Plugins
227
253
  </button>
254
+ <button
255
+ type="button"
256
+ onClick={() => {
257
+ setMobileOpen(false)
258
+ openSettings('mcp')
259
+ }}
260
+ className="nav-item"
261
+ >
262
+ <Plug size={15} strokeWidth={1.7} className="shrink-0 text-faint" />
263
+ MCP
264
+ </button>
228
265
  <button
229
266
  type="button"
230
267
  onClick={() => {
@@ -286,28 +323,38 @@ export function Sidebar() {
286
323
  {workspaces.map((entry) => {
287
324
  const active = entry.path === workspace.root
288
325
  return (
289
- <button
290
- key={entry.path}
291
- type="button"
292
- onClick={() => void switchWorkspace(entry.path)}
293
- title={entry.path}
294
- className={cn(
295
- 'flex h-8 w-full items-center gap-2.5 rounded-lg px-2.5 text-left text-[13px] transition-all duration-150',
296
- active ? 'bg-raised text-ink' : 'text-muted hover:bg-raised/80 hover:text-ink',
297
- )}
298
- >
299
- <Folder size={15} strokeWidth={1.6} className={cn('shrink-0', active ? 'text-accent' : 'text-faint')} />
300
- <span className="min-w-0 flex-1 truncate">{entry.name}</span>
301
- {switching === entry.path ? (
302
- <LoaderCircle size={11} className="shrink-0 animate-spin text-faint" />
303
- ) : active ? (
304
- <Check size={11} className="shrink-0 text-accent" />
305
- ) : (
306
- <span className="shrink-0 text-[10px] tabular-nums text-faint">
307
- {formatRelativeTime(entry.lastOpened)}
308
- </span>
309
- )}
310
- </button>
326
+ <div key={entry.path} className="group/ws relative">
327
+ <button
328
+ type="button"
329
+ onClick={() => void switchWorkspace(entry.path)}
330
+ title={entry.path}
331
+ className={cn(
332
+ 'flex h-8 w-full items-center gap-2.5 rounded-lg px-2.5 pr-8 text-left text-[13px] transition-all duration-150 active:scale-[0.985]',
333
+ active ? 'bg-raised text-ink' : 'text-muted hover:bg-raised/80 hover:text-ink',
334
+ )}
335
+ >
336
+ <Folder size={15} strokeWidth={1.6} className={cn('shrink-0 transition-transform duration-200 group-hover/ws:-rotate-6 group-hover/ws:scale-110', active ? 'text-accent' : 'text-faint')} />
337
+ <span className="min-w-0 flex-1 truncate">{entry.name}</span>
338
+ {switching === entry.path ? (
339
+ <LoaderCircle size={11} className="shrink-0 animate-spin text-faint" />
340
+ ) : active ? (
341
+ <Check size={11} className="shrink-0 text-accent" />
342
+ ) : (
343
+ <span className="shrink-0 text-[10px] tabular-nums text-faint transition-opacity group-hover/ws:opacity-0">
344
+ {formatRelativeTime(entry.lastOpened)}
345
+ </span>
346
+ )}
347
+ </button>
348
+ <button
349
+ type="button"
350
+ onClick={() => void deleteWorkspace(entry.path, entry.name)}
351
+ className="absolute right-1 top-1/2 flex h-6 w-6 -translate-y-1/2 items-center justify-center rounded-md text-faint opacity-0 transition-all hover:bg-danger/10 hover:text-danger focus:opacity-100 group-hover/ws:opacity-100"
352
+ title="Remove workspace from the list"
353
+ aria-label={`Remove workspace ${entry.name}`}
354
+ >
355
+ <Trash2 size={12} />
356
+ </button>
357
+ </div>
311
358
  )
312
359
  })}
313
360
  </div>
@@ -360,7 +407,7 @@ export function Sidebar() {
360
407
  title={`${session.title} · ${formatRelativeTime(session.updatedAt)}`}
361
408
  className={cn('sidebar-item pr-9', activeId === session.id && 'sidebar-item-active')}
362
409
  >
363
- <span className="min-w-0 flex-1 truncate">{session.title}</span>
410
+ <span className={cn('min-w-0 flex-1 truncate', titling[session.id] && 'text-shimmer')}>{session.title}</span>
364
411
  {streaming[session.id] && (
365
412
  <span
366
413
  className="flex shrink-0 items-center gap-1 text-[10.5px] font-medium text-accent group-hover/item:hidden"
@@ -381,7 +428,7 @@ export function Sidebar() {
381
428
  }
382
429
  const rect = event.currentTarget.getBoundingClientRect()
383
430
  const width = 176
384
- const height = 138
431
+ const height = 166
385
432
  const x = Math.max(8, Math.min(rect.right - width, window.innerWidth - width - 8))
386
433
  const y =
387
434
  rect.bottom + height + 8 > window.innerHeight
@@ -470,6 +517,13 @@ export function Sidebar() {
470
517
  >
471
518
  <Pencil size={12} /> Rename
472
519
  </button>
520
+ <button
521
+ type="button"
522
+ onClick={() => void aiRename(session.id)}
523
+ className="flex h-7 w-full items-center gap-2 rounded-md px-2 text-left text-[11.5px] text-muted hover:bg-raised hover:text-ink"
524
+ >
525
+ <Sparkles size={12} /> Name with AI
526
+ </button>
473
527
  <button
474
528
  type="button"
475
529
  onClick={() => exportChat(session.id)}