@bibobonking/opensol 0.0.0-stage → 3.0.2

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 (127) hide show
  1. package/.github/workflows/build.yml +25 -0
  2. package/README.md +74 -2
  3. package/dist/assets/index-BmN7pBji.css +1 -0
  4. package/dist/assets/index-DgNDjkOz.js +301 -0
  5. package/dist/favicon.svg +15 -0
  6. package/dist/index.html +55 -0
  7. package/dist/preview-host.html +33 -0
  8. package/index.html +54 -0
  9. package/package.json +43 -4
  10. package/postcss.config.js +6 -0
  11. package/public/favicon.svg +15 -0
  12. package/public/preview-host.html +33 -0
  13. package/server/README.md +75 -0
  14. package/server/daemon.mjs +1082 -0
  15. package/server/lib/checkpoints.mjs +124 -0
  16. package/server/lib/checkpoints.test.mjs +110 -0
  17. package/server/lib/deploy.mjs +436 -0
  18. package/server/lib/http.mjs +237 -0
  19. package/server/lib/plugins.mjs +321 -0
  20. package/server/lib/plugins.test.mjs +140 -0
  21. package/server/lib/providers.mjs +594 -0
  22. package/server/lib/store.mjs +491 -0
  23. package/server/lib/tools.mjs +990 -0
  24. package/server/opensol.mjs +202 -0
  25. package/server/package.json +42 -0
  26. package/shared/plugin-builder.test.mjs +43 -0
  27. package/shared/plugin-package.d.mts +34 -0
  28. package/shared/plugin-package.mjs +197 -0
  29. package/shared/plugin-package.test.mjs +94 -0
  30. package/src/App.tsx +152 -0
  31. package/src/agent/adapters/anthropic.ts +233 -0
  32. package/src/agent/adapters/gemini.ts +225 -0
  33. package/src/agent/adapters/openai-responses.ts +215 -0
  34. package/src/agent/adapters/openai.ts +200 -0
  35. package/src/agent/adapters/shared.ts +96 -0
  36. package/src/agent/approval.ts +20 -0
  37. package/src/agent/compact.ts +234 -0
  38. package/src/agent/index.ts +36 -0
  39. package/src/agent/model.ts +20 -0
  40. package/src/agent/prompt.ts +107 -0
  41. package/src/agent/queue.test.mjs +111 -0
  42. package/src/agent/queue.ts +84 -0
  43. package/src/agent/subagent.ts +109 -0
  44. package/src/agent/tools.ts +541 -0
  45. package/src/components/ChatView.tsx +403 -0
  46. package/src/components/CommandPalette.tsx +416 -0
  47. package/src/components/Composer.tsx +678 -0
  48. package/src/components/ContextBar.tsx +58 -0
  49. package/src/components/DeployModal.tsx +512 -0
  50. package/src/components/ErrorBoundary.tsx +75 -0
  51. package/src/components/HomeDashboard.tsx +189 -0
  52. package/src/components/Logo.tsx +41 -0
  53. package/src/components/Markdown.tsx +232 -0
  54. package/src/components/MessageItem.tsx +655 -0
  55. package/src/components/ModelPicker.tsx +487 -0
  56. package/src/components/PixleSol.tsx +243 -0
  57. package/src/components/PluginBuilder.tsx +285 -0
  58. package/src/components/PluginManager.tsx +341 -0
  59. package/src/components/PreviewPane.tsx +166 -0
  60. package/src/components/SettingsModal.tsx +2052 -0
  61. package/src/components/Sidebar.tsx +504 -0
  62. package/src/components/Splash.tsx +104 -0
  63. package/src/components/ToolCallCard.tsx +177 -0
  64. package/src/components/TopBar.tsx +182 -0
  65. package/src/components/TrajectoryView.tsx +178 -0
  66. package/src/components/WorkspacePanel.tsx +710 -0
  67. package/src/components/ui.tsx +311 -0
  68. package/src/core/kernel.ts +45 -0
  69. package/src/core/plugins/tools/fs.ts +219 -0
  70. package/src/core/plugins/tools/git.ts +83 -0
  71. package/src/core/plugins/tools/grep.ts +98 -0
  72. package/src/core/plugins/tools/shell.ts +88 -0
  73. package/src/core/plugins/tools/subagent.ts +36 -0
  74. package/src/core/plugins/tools/web.ts +97 -0
  75. package/src/core/services/agent-loop.ts +417 -0
  76. package/src/core/services/approval.ts +66 -0
  77. package/src/core/services/compaction.ts +45 -0
  78. package/src/core/services/llm.ts +32 -0
  79. package/src/core/services/sessions.ts +47 -0
  80. package/src/core/services/subagents.ts +15 -0
  81. package/src/core/services/system-prompt.ts +177 -0
  82. package/src/core/services/tools.ts +135 -0
  83. package/src/core/services/ui.ts +109 -0
  84. package/src/core/types.ts +139 -0
  85. package/src/data/providers.ts +125 -0
  86. package/src/data/skills.ts +68 -0
  87. package/src/index.css +776 -0
  88. package/src/lib/boot.ts +94 -0
  89. package/src/lib/context.ts +75 -0
  90. package/src/lib/conversation.test.mjs +64 -0
  91. package/src/lib/conversation.ts +59 -0
  92. package/src/lib/daemon.ts +475 -0
  93. package/src/lib/hooks.ts +33 -0
  94. package/src/lib/permissions.ts +60 -0
  95. package/src/lib/pixlesol.test.mjs +51 -0
  96. package/src/lib/pixlesol.ts +55 -0
  97. package/src/lib/plugin-folder.test.mjs +145 -0
  98. package/src/lib/plugin-folder.ts +96 -0
  99. package/src/lib/reasoning.ts +87 -0
  100. package/src/lib/sse.ts +75 -0
  101. package/src/lib/themes.test.mjs +72 -0
  102. package/src/lib/themes.ts +52 -0
  103. package/src/lib/usage.test.mjs +72 -0
  104. package/src/lib/usage.ts +155 -0
  105. package/src/lib/utils.ts +73 -0
  106. package/src/main.tsx +16 -0
  107. package/src/plugins/api.test.mjs +77 -0
  108. package/src/plugins/api.ts +113 -0
  109. package/src/plugins/bridge.d.mts +1 -0
  110. package/src/plugins/bridge.mjs +26 -0
  111. package/src/plugins/bridge.test.mjs +17 -0
  112. package/src/plugins/index.ts +52 -0
  113. package/src/plugins/preview.test.mjs +18 -0
  114. package/src/plugins/preview.ts +34 -0
  115. package/src/plugins/runtime.ts +194 -0
  116. package/src/plugins/types.ts +40 -0
  117. package/src/store/chats.ts +278 -0
  118. package/src/store/models.ts +120 -0
  119. package/src/store/settings.ts +445 -0
  120. package/src/store/ui.ts +132 -0
  121. package/src/store/workspace.ts +299 -0
  122. package/src/types.ts +337 -0
  123. package/src/vite-env.d.ts +1 -0
  124. package/tailwind.config.js +83 -0
  125. package/tsconfig.json +22 -0
  126. package/tsconfig.tsbuildinfo +1 -0
  127. package/vite.config.ts +19 -0
@@ -0,0 +1,177 @@
1
+ import { useEffect, useState } from 'react'
2
+ import { ArrowRight } from 'lucide-react'
3
+ import type { ToolMessage, ToolName } from '../types'
4
+ import { toolLabel, toolSummary } from '../agent/tools'
5
+ import { resolveApproval } from '../agent/approval'
6
+ import { useUi } from '../store/ui'
7
+ import { cn } from '../lib/utils'
8
+
9
+ function statusMeta(status: ToolMessage['status']) {
10
+ switch (status) {
11
+ case 'awaiting':
12
+ return { label: 'Needs approval', className: 'text-warning', dot: 'bg-warning' }
13
+ case 'running':
14
+ return { label: 'Running', className: 'text-accent', dot: 'bg-accent' }
15
+ case 'done':
16
+ return { label: 'Done', className: 'text-success', dot: 'bg-success' }
17
+ case 'denied':
18
+ return { label: 'Denied', className: 'text-muted', dot: 'bg-faint' }
19
+ default:
20
+ return { label: 'Failed', className: 'text-danger', dot: 'bg-danger' }
21
+ }
22
+ }
23
+
24
+ function durationLabel(ms?: number) {
25
+ if (ms === undefined) return ''
26
+ return ms < 1000 ? `${ms}ms` : `${(ms / 1000).toFixed(1)}s`
27
+ }
28
+
29
+ function approvalCopy(name: ToolName) {
30
+ switch (name) {
31
+ case 'shell':
32
+ return 'OpenSol wants to run this command in the open workspace.'
33
+ case 'write_file':
34
+ return 'OpenSol wants to create or overwrite this file on disk.'
35
+ case 'edit_file':
36
+ case 'rewrite_file':
37
+ return 'OpenSol wants to change this file on disk.'
38
+ case 'git':
39
+ return 'OpenSol wants to change the Git state of this repository.'
40
+ case 'subagent':
41
+ return 'OpenSol wants to start a read-only subagent to investigate.'
42
+ case 'web_search':
43
+ return 'OpenSol wants to search the web.'
44
+ case 'read_url':
45
+ return 'OpenSol wants to read this public page.'
46
+ default:
47
+ return 'OpenSol wants to use this tool.'
48
+ }
49
+ }
50
+
51
+ export function ToolCallCard({ message, compact = false }: { message: ToolMessage; compact?: boolean }) {
52
+ const [open, setOpen] = useState((!compact && message.status === 'awaiting') || message.status === 'error')
53
+ const [copied, setCopied] = useState(false)
54
+ const notify = useUi((state) => state.notify)
55
+ const status = statusMeta(message.status)
56
+ const summary = toolSummary(message.call.name, message.call.args)
57
+ const args = message.call.args
58
+ const command = typeof args.command === 'string' ? args.command : ''
59
+ const content =
60
+ typeof args.content === 'string'
61
+ ? args.content
62
+ : typeof args.newText === 'string'
63
+ ? args.newText
64
+ : typeof args.oldText === 'string'
65
+ ? args.oldText
66
+ : ''
67
+
68
+ useEffect(() => {
69
+ if (message.status === 'awaiting' || message.status === 'error') setOpen(true)
70
+ }, [message.status])
71
+
72
+ const copyOutput = async () => {
73
+ try {
74
+ await navigator.clipboard.writeText(message.result ?? '')
75
+ setCopied(true)
76
+ window.setTimeout(() => setCopied(false), 1600)
77
+ } catch {
78
+ notify('Clipboard access was blocked by the browser.', 'error')
79
+ }
80
+ }
81
+
82
+ const trailing =
83
+ message.status === 'done' && message.durationMs !== undefined
84
+ ? durationLabel(message.durationMs)
85
+ : message.status === 'done'
86
+ ? ''
87
+ : status.label.toLowerCase()
88
+
89
+ return (
90
+ <div className="text-[12.5px]">
91
+ <button
92
+ type="button"
93
+ onClick={() => setOpen((value) => !value)}
94
+ className="group/row flex w-full min-w-0 items-baseline gap-1.5 py-[3px] text-left"
95
+ aria-expanded={open}
96
+ >
97
+ <ArrowRight
98
+ size={12}
99
+ strokeWidth={2}
100
+ className={cn(
101
+ 'relative top-[1px] shrink-0 self-center text-faint transition-transform duration-200 ease-out group-hover/row:text-ink',
102
+ open && 'rotate-90',
103
+ )}
104
+ />
105
+ <span className="shrink-0 font-medium text-ink">{toolLabel(message.call.name)}</span>
106
+ <span className="min-w-0 flex-1 truncate font-mono text-[11.5px] text-muted">{summary || '—'}</span>
107
+ {trailing && (
108
+ <span className={cn('shrink-0 text-[11px]', message.status === 'running' ? 'text-shimmer' : message.status === 'done' ? 'text-faint' : status.className)}>
109
+ {trailing}
110
+ </span>
111
+ )}
112
+ </button>
113
+
114
+ {message.status === 'awaiting' && (
115
+ <div className="animate-fade py-1 pl-[18px]">
116
+ <p className="text-[12px] leading-relaxed text-warning">{approvalCopy(message.call.name)}</p>
117
+ <p className="mt-1 flex items-center gap-4 text-[12px] font-medium">
118
+ <button
119
+ type="button"
120
+ onClick={() => resolveApproval(message.call.id, true)}
121
+ className="text-ink underline decoration-ink/30 underline-offset-[3px] transition-colors hover:decoration-ink"
122
+ >
123
+ Allow once →
124
+ </button>
125
+ <button
126
+ type="button"
127
+ onClick={() => resolveApproval(message.call.id, false)}
128
+ className="text-muted transition-colors hover:text-ink"
129
+ >
130
+ Deny
131
+ </button>
132
+ </p>
133
+ </div>
134
+ )}
135
+
136
+ {open && (
137
+ <div className="animate-fade space-y-2 pb-1.5 pl-[18px] pt-0.5">
138
+ {command && (
139
+ <pre className="thin-scroll max-h-40 overflow-auto whitespace-pre-wrap break-words font-mono text-[11.5px] leading-relaxed text-ink">
140
+ $ {command}
141
+ </pre>
142
+ )}
143
+ {content && (
144
+ <pre className="thin-scroll max-h-48 overflow-auto whitespace-pre-wrap break-words font-mono text-[11px] leading-relaxed text-muted">
145
+ {content.length > 4000 ? `${content.slice(0, 4000)}\n… ${content.length - 4000} more characters` : content}
146
+ </pre>
147
+ )}
148
+
149
+ {message.result !== undefined && (
150
+ <div>
151
+ <p className="mb-1 flex items-center gap-3 text-[11px] text-faint">
152
+ <span>{message.status === 'error' ? 'Error' : 'Output'}</span>
153
+ <button type="button" onClick={copyOutput} className="transition-colors hover:text-ink">
154
+ {copied ? 'Copied' : 'Copy'}
155
+ </button>
156
+ </p>
157
+ <pre
158
+ className={cn(
159
+ 'thin-scroll max-h-72 overflow-auto whitespace-pre-wrap break-words font-mono text-[11.5px] leading-[1.65]',
160
+ message.status === 'error' ? 'text-danger' : 'text-muted',
161
+ )}
162
+ >
163
+ {message.result || '(no output)'}
164
+ </pre>
165
+ </div>
166
+ )}
167
+
168
+ {message.result === undefined && !command && !content && message.status !== 'awaiting' && (
169
+ <p className="text-[11.5px] text-muted">
170
+ {message.status === 'running' ? 'Working…' : 'No result was recorded.'}
171
+ </p>
172
+ )}
173
+ </div>
174
+ )}
175
+ </div>
176
+ )
177
+ }
@@ -0,0 +1,182 @@
1
+ import {
2
+ FolderOpen,
3
+ ListChecks,
4
+ Menu,
5
+ Moon,
6
+ PanelLeftOpen,
7
+ Rocket,
8
+ Search,
9
+ Settings,
10
+ Sparkles,
11
+ Sun,
12
+ } from 'lucide-react'
13
+ import type { ChatSession } from '../types'
14
+ import { useSettings } from '../store/settings'
15
+ import { useUi } from '../store/ui'
16
+ import { useWorkspace } from '../store/workspace'
17
+ import { useActiveModel } from '../store/models'
18
+ import { cn } from '../lib/utils'
19
+ import { themeIsDark } from '../lib/themes'
20
+ import { LogoMark } from './Logo'
21
+ import { ContextBar } from './ContextBar'
22
+
23
+ export type ChatViewMode = 'chat' | 'trajectory'
24
+
25
+ const isApple = typeof navigator !== 'undefined' && /Mac|iPhone|iPad/.test(navigator.platform)
26
+
27
+ export function TopBar({
28
+ session,
29
+ view,
30
+ onViewChange,
31
+ }: {
32
+ session: ChatSession | null
33
+ view: ChatViewMode
34
+ onViewChange: (view: ChatViewMode) => void
35
+ }) {
36
+ const settings = useSettings()
37
+ const update = useSettings((state) => state.update)
38
+ const sidebarCollapsed = useUi((state) => state.sidebarCollapsed)
39
+ const toggleSidebar = useUi((state) => state.toggleSidebar)
40
+ const setMobileOpen = useUi((state) => state.setMobileSidebarOpen)
41
+ const setPaletteOpen = useUi((state) => state.setPaletteOpen)
42
+ const openSettings = useUi((state) => state.openSettings)
43
+ const workspaceOpen = useUi((state) => state.workspaceOpen)
44
+ const setWorkspaceOpen = useUi((state) => state.setWorkspaceOpen)
45
+ const setDeployOpen = useUi((state) => state.setDeployOpen)
46
+ const workspace = useWorkspace((state) => state.info)
47
+ const activeModel = useActiveModel()
48
+
49
+ // Go through the theme registry so Night Owl, Claude Code, Solar and plugin themes count as dark.
50
+ const isDark = themeIsDark(
51
+ settings.theme,
52
+ typeof window !== 'undefined' && window.matchMedia('(prefers-color-scheme: dark)').matches,
53
+ )
54
+ const hasConversation = Boolean(session && session.messages.length > 0)
55
+ const turns = session?.messages.filter((message) => message.role === 'user').length ?? 0
56
+
57
+ return (
58
+ <header className="relative flex h-12 shrink-0 items-center gap-2 border-b border-line/60 bg-panel/80 px-3 backdrop-blur-xl sm:px-4">
59
+ <button
60
+ type="button"
61
+ className="icon-button lg:hidden"
62
+ onClick={() => setMobileOpen(true)}
63
+ aria-label="Open menu"
64
+ >
65
+ <Menu size={18} />
66
+ </button>
67
+
68
+ {sidebarCollapsed && (
69
+ <button
70
+ type="button"
71
+ className="icon-button hidden lg:inline-flex"
72
+ onClick={toggleSidebar}
73
+ title="Expand sidebar"
74
+ aria-label="Expand sidebar"
75
+ >
76
+ <PanelLeftOpen size={17} />
77
+ </button>
78
+ )}
79
+
80
+ <div className="flex min-w-0 flex-1 items-center gap-2">
81
+ {!session && <LogoMark size={19} className="lg:hidden" />}
82
+ {workspace.available && (
83
+ <>
84
+ <span className="hidden max-w-[120px] shrink-0 truncate text-[13px] text-faint md:inline">{workspace.name}</span>
85
+ <span className="hidden text-faint/50 md:inline">/</span>
86
+ </>
87
+ )}
88
+ <h1 className="min-w-0 truncate text-[13.5px] font-medium tracking-[-0.01em] text-ink">
89
+ {session?.title ?? 'New conversation'}
90
+ </h1>
91
+ {hasConversation && (
92
+ <span className="hidden shrink-0 items-center gap-1 rounded-md border border-line bg-canvas px-1.5 py-0.5 text-[10.5px] font-medium text-muted sm:inline-flex">
93
+ {settings.mode === 'plan' ? <ListChecks size={11} /> : <Sparkles size={11} />}
94
+ {settings.mode === 'plan' ? 'Plan mode' : 'Build mode'}
95
+ </span>
96
+ )}
97
+ {hasConversation && (
98
+ <span className="hidden shrink-0 text-[11.5px] tabular-nums text-faint md:inline">
99
+ {turns} turn{turns === 1 ? '' : 's'}
100
+ </span>
101
+ )}
102
+ </div>
103
+
104
+ {hasConversation && (
105
+ <div className="absolute left-1/2 hidden -translate-x-1/2 items-center rounded-lg bg-raised p-0.5 md:inline-flex">
106
+ {(['chat', 'trajectory'] as const).map((value) => (
107
+ <button
108
+ key={value}
109
+ type="button"
110
+ onClick={() => onViewChange(value)}
111
+ aria-pressed={view === value}
112
+ className={cn(
113
+ 'h-6 rounded-md px-3 text-[11.5px] font-medium capitalize transition-all duration-200',
114
+ view === value ? 'bg-panel text-ink shadow-pill' : 'text-muted hover:text-ink',
115
+ )}
116
+ >
117
+ {value}
118
+ </button>
119
+ ))}
120
+ </div>
121
+ )}
122
+
123
+ <div className="flex shrink-0 items-center gap-0.5">
124
+ {hasConversation && (
125
+ <div className="mr-3 hidden sm:block">
126
+ <ContextBar messages={session?.messages ?? []} model={activeModel} sessionId={session?.id ?? null} />
127
+ </div>
128
+ )}
129
+ <button
130
+ type="button"
131
+ onClick={() => setWorkspaceOpen(!workspaceOpen)}
132
+ title={workspace.available ? workspace.root : 'Choose a workspace folder'}
133
+ className={cn(
134
+ 'mr-1 hidden min-w-0 items-center gap-1.5 rounded-lg border px-2.5 py-1 text-[12px] transition-all duration-150 sm:inline-flex',
135
+ workspaceOpen
136
+ ? 'border-accent/30 bg-accent-soft text-accent'
137
+ : 'border-line text-muted hover:bg-raised/60 hover:text-ink',
138
+ )}
139
+ >
140
+ <FolderOpen size={13} className="shrink-0" />
141
+ <span className="max-w-[140px] truncate">{workspace.available ? workspace.name : 'No folder'}</span>
142
+ </button>
143
+ <button
144
+ type="button"
145
+ className="icon-button hidden sm:inline-flex"
146
+ onClick={() => setDeployOpen(true)}
147
+ title="Deploy the open workspace or another project"
148
+ aria-label="Deploy"
149
+ >
150
+ <Rocket size={16} strokeWidth={1.7} />
151
+ </button>
152
+ <button
153
+ type="button"
154
+ className="icon-button"
155
+ onClick={() => setPaletteOpen(true)}
156
+ title={`Search and commands (${isApple ? '⌘' : 'Ctrl'} K)`}
157
+ aria-label="Open search and commands"
158
+ >
159
+ <Search size={16} />
160
+ </button>
161
+ <button
162
+ type="button"
163
+ className="icon-button hidden sm:inline-flex"
164
+ onClick={() => update({ theme: isDark ? 'light' : 'dark' })}
165
+ title={isDark ? 'Switch to light theme' : 'Switch to dark theme'}
166
+ aria-label="Toggle theme"
167
+ >
168
+ {isDark ? <Sun size={16} /> : <Moon size={16} />}
169
+ </button>
170
+ <button
171
+ type="button"
172
+ className="icon-button"
173
+ onClick={() => openSettings('general')}
174
+ title="Settings"
175
+ aria-label="Open settings"
176
+ >
177
+ <Settings size={16} />
178
+ </button>
179
+ </div>
180
+ </header>
181
+ )
182
+ }
@@ -0,0 +1,178 @@
1
+ import { useMemo } from 'react'
2
+ import {
3
+ AlertCircle,
4
+ Check,
5
+ ChevronRight,
6
+ FileText,
7
+ SquareTerminal,
8
+ UserRound,
9
+ Wrench,
10
+ } from 'lucide-react'
11
+ import type { ChatSession, Message } from '../types'
12
+ import { toolLabel, toolSummary } from '../agent/tools'
13
+ import { cn } from '../lib/utils'
14
+ import { LogoMark } from './Logo'
15
+
16
+ function timeOf(timestamp: number) {
17
+ return new Date(timestamp).toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' })
18
+ }
19
+
20
+ function preview(text: string, length = 180) {
21
+ const clean = text.replace(/\s+/g, ' ').trim()
22
+ return clean.length > length ? `${clean.slice(0, length)}…` : clean
23
+ }
24
+
25
+ function statusTone(message: Extract<Message, { role: 'tool' }>) {
26
+ switch (message.status) {
27
+ case 'running':
28
+ return { label: 'Running', className: 'text-accent', dot: 'bg-accent animate-pulse' }
29
+ case 'awaiting':
30
+ return { label: 'Needs approval', className: 'text-warning', dot: 'bg-warning' }
31
+ case 'done':
32
+ return { label: 'Done', className: 'text-success', dot: 'bg-success' }
33
+ case 'denied':
34
+ return { label: 'Denied', className: 'text-muted', dot: 'bg-faint' }
35
+ default:
36
+ return { label: 'Failed', className: 'text-danger', dot: 'bg-danger' }
37
+ }
38
+ }
39
+
40
+ export function TrajectoryView({ session }: { session: ChatSession }) {
41
+ const events = useMemo(() => session.messages, [session.messages])
42
+ const toolCount = events.filter((message) => message.role === 'tool').length
43
+
44
+ if (events.length === 0) {
45
+ return (
46
+ <div className="flex h-full items-center justify-center px-6">
47
+ <div className="text-center">
48
+ <Wrench size={20} className="mx-auto text-faint" />
49
+ <p className="mt-2 text-[13px] font-medium text-ink">No trajectory yet</p>
50
+ <p className="mt-1 text-[11.5px] text-muted">Send a message to see every step here.</p>
51
+ </div>
52
+ </div>
53
+ )
54
+ }
55
+
56
+ return (
57
+ <div className="thin-scroll h-full overflow-y-auto">
58
+ <div className="mx-auto w-full max-w-[800px] px-4 pb-12 pt-7 sm:px-6">
59
+ <div className="mb-5 flex items-center justify-between gap-3 border-b border-line pb-3">
60
+ <div>
61
+ <p className="text-[13px] font-medium text-ink">Trajectory</p>
62
+ <p className="mt-0.5 text-[11px] text-muted">
63
+ {events.length} step{events.length === 1 ? '' : 's'} · {toolCount} tool call{toolCount === 1 ? '' : 's'}
64
+ </p>
65
+ </div>
66
+ <span className="rounded-full border border-line px-2 py-0.5 text-[10px] text-faint">Read-only view</span>
67
+ </div>
68
+
69
+ <div className="relative">
70
+ <span className="absolute bottom-3 left-[13px] top-3 w-px bg-line" aria-hidden="true" />
71
+ <div className="space-y-4">
72
+ {events.map((message) => {
73
+ if (message.role === 'user') {
74
+ return (
75
+ <div key={message.id} className="relative flex gap-3">
76
+ <span className="relative z-10 flex h-7 w-7 shrink-0 items-center justify-center rounded-full border border-line bg-panel text-muted">
77
+ <UserRound size={13} />
78
+ </span>
79
+ <div className="min-w-0 flex-1 rounded-xl border border-line bg-panel px-3 py-2">
80
+ <div className="flex items-center justify-between gap-2">
81
+ <span className="text-[11.5px] font-medium text-ink">You</span>
82
+ <span className="text-[9.5px] tabular-nums text-faint">{timeOf(message.createdAt)}</span>
83
+ </div>
84
+ <p className="mt-1 line-clamp-3 text-[12px] leading-relaxed text-muted">{message.content || 'Attachment only'}</p>
85
+ </div>
86
+ </div>
87
+ )
88
+ }
89
+
90
+ if (message.role === 'assistant') {
91
+ const hasText = Boolean(message.content.trim() || message.reasoning.trim())
92
+ return (
93
+ <div key={message.id} className="relative flex gap-3">
94
+ <span className="relative z-10 flex h-7 w-7 shrink-0 items-center justify-center rounded-full border border-line bg-panel">
95
+ <LogoMark size={15} />
96
+ </span>
97
+ <div className="min-w-0 flex-1 rounded-xl border border-line bg-panel px-3 py-2">
98
+ <div className="flex flex-wrap items-center justify-between gap-2">
99
+ <span className="flex min-w-0 items-center gap-1.5 text-[11.5px] font-medium text-ink">
100
+ OpenSol
101
+ {message.model && (
102
+ <span className="truncate text-[10px] font-normal text-faint">{message.model}</span>
103
+ )}
104
+ </span>
105
+ <span className="flex items-center gap-1.5 text-[9.5px] tabular-nums text-faint">
106
+ {message.stopped && <span className="text-muted">Stopped</span>}
107
+ {timeOf(message.createdAt)}
108
+ </span>
109
+ </div>
110
+ {hasText ? (
111
+ <p className="mt-1 line-clamp-4 whitespace-pre-wrap text-[12px] leading-relaxed text-muted">
112
+ {preview(message.content || message.reasoning)}
113
+ </p>
114
+ ) : (
115
+ <p className="mt-1 text-[11px] italic text-faint">Model turn with no text.</p>
116
+ )}
117
+ {message.error && (
118
+ <p className="mt-1.5 flex items-start gap-1.5 text-[11px] leading-relaxed text-danger">
119
+ <AlertCircle size={11} className="mt-0.5 shrink-0" />
120
+ {message.error}
121
+ </p>
122
+ )}
123
+ {message.toolCalls.length > 0 && (
124
+ <p className="mt-1.5 text-[10px] text-faint">
125
+ {message.toolCalls.length} tool call{message.toolCalls.length === 1 ? '' : 's'} in this round
126
+ </p>
127
+ )}
128
+ </div>
129
+ </div>
130
+ )
131
+ }
132
+
133
+ const status = statusTone(message)
134
+ const isShell = message.call.name === 'shell'
135
+ const isFile = ['read_file', 'write_file', 'edit_file', 'rewrite_file'].includes(message.call.name)
136
+ const Icon = isShell ? SquareTerminal : isFile ? FileText : Wrench
137
+ return (
138
+ <div key={message.id} className="relative flex gap-3">
139
+ <span className="relative z-10 flex h-7 w-7 shrink-0 items-center justify-center rounded-full border border-line bg-panel text-muted">
140
+ <Icon size={12} />
141
+ </span>
142
+ <div className="min-w-0 flex-1 rounded-xl border border-line/80 bg-raised/25 px-3 py-2">
143
+ <div className="flex flex-wrap items-center gap-2">
144
+ <span className="text-[11.5px] font-medium text-ink">{toolLabel(message.call.name)}</span>
145
+ <span className="min-w-0 flex-1 truncate font-mono text-[10.5px] text-muted">
146
+ {toolSummary(message.call.name, message.call.args) || '—'}
147
+ </span>
148
+ <span className={cn('flex shrink-0 items-center gap-1 text-[10px] font-medium', status.className)}>
149
+ {message.status === 'done' ? (
150
+ <Check size={10} />
151
+ ) : (
152
+ <span className={cn('h-1.5 w-1.5 rounded-full', status.dot)} />
153
+ )}
154
+ {status.label}
155
+ {message.durationMs !== undefined && message.status === 'done'
156
+ ? ` · ${message.durationMs < 1000 ? `${message.durationMs}ms` : `${(message.durationMs / 1000).toFixed(1)}s`}`
157
+ : ''}
158
+ </span>
159
+ </div>
160
+ {message.result && (
161
+ <p className="mt-1.5 line-clamp-2 whitespace-pre-wrap break-words font-mono text-[10.5px] leading-relaxed text-faint">
162
+ {preview(message.result, 160)}
163
+ </p>
164
+ )}
165
+ </div>
166
+ </div>
167
+ )
168
+ })}
169
+ </div>
170
+ </div>
171
+
172
+ <p className="mt-6 flex items-center justify-center gap-1 text-[10px] text-faint">
173
+ End of trajectory <ChevronRight size={10} />
174
+ </p>
175
+ </div>
176
+ </div>
177
+ )
178
+ }