@polderlabs/bizar 4.3.0 → 4.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/bizar-dash/src/server/opencode-sdk.mjs +72 -0
  2. package/bizar-dash/src/server/routes/background.mjs +92 -0
  3. package/bizar-dash/src/server/routes/chat.mjs +300 -123
  4. package/bizar-dash/src/server/routes/opencode-session-detail.mjs +26 -0
  5. package/bizar-dash/src/server/routes/tasks.mjs +59 -1
  6. package/bizar-dash/src/server/task-delegator.mjs +154 -8
  7. package/bizar-dash/src/server/tasks-store.mjs +50 -2
  8. package/bizar-dash/src/web/components/background/AttachButton.tsx +96 -0
  9. package/bizar-dash/src/web/components/background/TmuxAttachCard.tsx +122 -0
  10. package/bizar-dash/src/web/components/chat/AgentNode.tsx +127 -0
  11. package/bizar-dash/src/web/components/chat/AgentTree.tsx +80 -0
  12. package/bizar-dash/src/web/components/chat/ChatComposer.tsx +76 -0
  13. package/bizar-dash/src/web/components/chat/ChatInfoPanel.tsx +144 -0
  14. package/bizar-dash/src/web/components/chat/ChatRail.tsx +387 -0
  15. package/bizar-dash/src/web/components/chat/ChatThread.tsx +28 -7
  16. package/bizar-dash/src/web/components/chat/JumpToLatest.tsx +58 -0
  17. package/bizar-dash/src/web/components/chat/MessageBlock.tsx +260 -0
  18. package/bizar-dash/src/web/components/chat/SessionRowMenu.tsx +206 -0
  19. package/bizar-dash/src/web/components/chat/StreamingIndicator.tsx +33 -5
  20. package/bizar-dash/src/web/components/chat/_legacy.ts +30 -0
  21. package/bizar-dash/src/web/components/chat/index.ts +11 -0
  22. package/bizar-dash/src/web/components/chat/useChat.ts +345 -167
  23. package/bizar-dash/src/web/components/tasks/BacklogPanel.css +109 -0
  24. package/bizar-dash/src/web/components/tasks/BacklogPanel.tsx +209 -0
  25. package/bizar-dash/src/web/styles/chat.css +1536 -133
  26. package/bizar-dash/src/web/views/BackgroundAgents.tsx +3 -0
  27. package/bizar-dash/src/web/views/Chat.tsx +147 -57
  28. package/bizar-dash/src/web/views/Tasks.tsx +23 -1
  29. package/cli/bg.mjs +94 -71
  30. package/cli/bin.mjs +36 -8
  31. package/cli/service-controller.mjs +587 -0
  32. package/cli/service-controller.test.mjs +92 -0
  33. package/cli/service.mjs +162 -14
  34. package/config/agents/baldr.md +2 -0
  35. package/config/agents/browser-harness.md +2 -0
  36. package/config/agents/forseti.md +2 -0
  37. package/config/agents/frigg.md +2 -0
  38. package/config/agents/heimdall.md +2 -0
  39. package/config/agents/hermod.md +2 -0
  40. package/config/agents/mimir.md +2 -0
  41. package/config/agents/odin.md +2 -0
  42. package/config/agents/quick.md +2 -0
  43. package/config/agents/semble-search.md +2 -0
  44. package/config/agents/thor.md +2 -0
  45. package/config/agents/tyr.md +2 -0
  46. package/config/agents/vidarr.md +2 -0
  47. package/config/agents/vor.md +2 -0
  48. package/config/opencode.json.template +1 -0
  49. package/install.sh +448 -787
  50. package/package.json +2 -2
  51. package/packages/sdk/package.json +20 -0
  52. package/packages/sdk/src/client.ts +5 -0
  53. package/packages/sdk/src/errors.ts +11 -2
  54. package/packages/sdk/src/index.ts +19 -0
  55. package/packages/sdk/src/opencode-events.ts +134 -0
  56. package/packages/sdk/src/opencode-types.ts +66 -0
  57. package/packages/sdk/src/opencode.ts +335 -0
@@ -0,0 +1,76 @@
1
+ // src/components/chat/ChatComposer.tsx — bottom composer pill (v3.22 design).
2
+ //
3
+ // Replaces FloatingComposer. Visual layout follows the design HTML:
4
+ //
5
+ // [agent chip] [model badge] [──── textarea ────] [📎] [✨] [➤]
6
+ //
7
+ // All wrapped in a single pill (chat-composer-pill) with rounded ends
8
+ // and a focus-within accent border. The legacy Composer component
9
+ // already provides the textarea, agent chip menu, attachment tags,
10
+ // and slash-command suggestion dropdown — the redesign simply nests
11
+ // the Composer inside the new pill chrome and adds the hint row
12
+ // (⏎ / ⇧⏎ / /) underneath.
13
+ //
14
+ // Props match the original FloatingComposer EXACTLY (so Chat.tsx +
15
+ // MobileChat.tsx don't need any changes).
16
+
17
+ import { useState } from 'react';
18
+ import { Composer } from './Composer';
19
+
20
+ interface Props {
21
+ agent: string;
22
+ setAgent: (a: string) => void;
23
+ model: string;
24
+ setModel: (m: string) => void;
25
+ text: string;
26
+ setText: (t: string) => void;
27
+ sending: boolean;
28
+ onSend: () => void;
29
+ attachments: string[];
30
+ setAttachments: (a: string[] | ((prev: string[]) => string[])) => void;
31
+ suggestions: Array<{ cmd: string; desc: string }>;
32
+ onPickSuggestion: (cmd: string) => void;
33
+ agents: Array<{ name: string }>;
34
+ onAttach: () => void;
35
+ /** Backward-compat — used to be page-level panel toggles. Ignored in
36
+ * the redesign (rail + info are always visible). */
37
+ sessionsOpen?: boolean;
38
+ /** Backward-compat — see sessionsOpen. */
39
+ infoOpen?: boolean;
40
+ }
41
+
42
+ export function ChatComposer(props: Props) {
43
+ const { text, sending, onSend } = props;
44
+ const [takingOff, setTakingOff] = useState(false);
45
+
46
+ // Wrap onSend with a brief takeoff animation — a small CSS hook on
47
+ // the wrapper pill. We don't animate a specific element here; the
48
+ // pill itself gets the brief `takeoff` class so the send button
49
+ // (the last child of Composer) gets the animation if the design
50
+ // wants it.
51
+ const handleSend = () => {
52
+ if (!text.trim()) return;
53
+ setTakingOff(true);
54
+ onSend();
55
+ window.setTimeout(() => setTakingOff(false), 320);
56
+ };
57
+
58
+ return (
59
+ <div className="chat-composer-wrap">
60
+ <div className={`chat-composer-pill${takingOff ? ' takeoff' : ''}`}>
61
+ <Composer {...props} onSend={handleSend} />
62
+ </div>
63
+ <div className="chat-composer-hint chat-muted">
64
+ <span>
65
+ <kbd>⏎</kbd> send
66
+ </span>
67
+ <span>
68
+ <kbd>⇧⏎</kbd> newline
69
+ </span>
70
+ <span>
71
+ <kbd>/</kbd> commands
72
+ </span>
73
+ </div>
74
+ </div>
75
+ );
76
+ }
@@ -0,0 +1,144 @@
1
+ // src/components/chat/ChatInfoPanel.tsx — right info panel (v3.22 design).
2
+ //
3
+ // Replaces InfoPanel. Layout follows the design HTML's sectioned
4
+ // info panel: Session / Project / Model / Tokens / Cost / Attached
5
+ // agents / Linked.
6
+ //
7
+ // Props match the original InfoPanel EXACTLY (so Chat.tsx doesn't need
8
+ // any changes).
9
+
10
+ import { MessageSquare, Bot, Server, Terminal } from 'lucide-react';
11
+
12
+ interface Props {
13
+ sessionId: string;
14
+ messages: Array<unknown>;
15
+ pinned: Set<number>;
16
+ agent: string;
17
+ model: string;
18
+ agents: Array<{ name: string; model?: string; mode?: string }>;
19
+ mcps: Array<{ id: string; command?: string; enabled?: boolean }>;
20
+ allCommands: Array<{ cmd: string; desc: string; mod?: string }>;
21
+ }
22
+
23
+ function formatNumber(n: number): string {
24
+ return n.toLocaleString();
25
+ }
26
+
27
+ export function ChatInfoPanel({
28
+ sessionId,
29
+ messages,
30
+ pinned,
31
+ agent,
32
+ model,
33
+ agents,
34
+ mcps,
35
+ allCommands,
36
+ }: Props) {
37
+ // Tokens + cost are stubbed — the dashboard doesn't currently
38
+ // surface these from the chat backend. Show the count of messages
39
+ // + pinned as a sensible default and leave token/cost placeholders
40
+ // clearly marked so it's obvious they're not yet wired.
41
+ const msgCount = messages?.length ?? 0;
42
+ const pinnedCount = pinned?.size ?? 0;
43
+ const tokensUsed = 0;
44
+ const tokensTotal = 128_000;
45
+ const costUsd = 0;
46
+
47
+ return (
48
+ <aside className="chat-info">
49
+ <div className="chat-info-section">
50
+ <h4>Session</h4>
51
+ <div>{sessionId || 'Live'}</div>
52
+ <div className="chat-info-mono">
53
+ {formatNumber(msgCount)} message{msgCount === 1 ? '' : 's'}
54
+ {pinnedCount > 0 && ` · ${pinnedCount} pinned`}
55
+ </div>
56
+ </div>
57
+
58
+ <div className="chat-info-section">
59
+ <h4>Agent</h4>
60
+ <div>{agent || '—'}</div>
61
+ </div>
62
+
63
+ <div className="chat-info-section">
64
+ <h4>Model</h4>
65
+ <div className="chat-mono chat-ellipsis" title={model}>
66
+ {model || '—'}
67
+ </div>
68
+ </div>
69
+
70
+ <div className="chat-info-section">
71
+ <h4>Tokens</h4>
72
+ <div className="chat-mono">
73
+ {formatNumber(tokensUsed)} / {formatNumber(tokensTotal)}
74
+ </div>
75
+ <div className="chat-info-bar" aria-hidden>
76
+ <div
77
+ className="chat-info-bar-fill"
78
+ style={{ width: `${Math.min(100, (tokensUsed / Math.max(1, tokensTotal)) * 100)}%` }}
79
+ />
80
+ </div>
81
+ </div>
82
+
83
+ <div className="chat-info-section">
84
+ <h4>Cost</h4>
85
+ <div className="chat-mono">${costUsd.toFixed(4)}</div>
86
+ </div>
87
+
88
+ <div className="chat-info-section">
89
+ <h4>
90
+ <Bot size={11} style={{ marginRight: 4, verticalAlign: -1 }} aria-hidden /> Attached
91
+ agents
92
+ </h4>
93
+ {agents?.length ? (
94
+ <div className="chat-info-agents">
95
+ {agents.map((a) => (
96
+ <span key={a.name} className="chat-info-agent">
97
+ {a.name}
98
+ </span>
99
+ ))}
100
+ </div>
101
+ ) : (
102
+ <div className="chat-info-mono">None attached</div>
103
+ )}
104
+ </div>
105
+
106
+ <div className="chat-info-section">
107
+ <h4>
108
+ <Server size={11} style={{ marginRight: 4, verticalAlign: -1 }} aria-hidden /> MCPs
109
+ </h4>
110
+ {mcps?.length ? (
111
+ <div className="chat-info-agents">
112
+ {mcps.map((m) => (
113
+ <span key={m.id} className="chat-info-agent">
114
+ {m.id}
115
+ </span>
116
+ ))}
117
+ </div>
118
+ ) : (
119
+ <div className="chat-info-mono">No MCPs configured</div>
120
+ )}
121
+ </div>
122
+
123
+ <div className="chat-info-section">
124
+ <h4>
125
+ <Terminal size={11} style={{ marginRight: 4, verticalAlign: -1 }} aria-hidden /> Slash
126
+ commands
127
+ </h4>
128
+ <div className="chat-info-mono">{allCommands?.length ?? 0} available</div>
129
+ </div>
130
+
131
+ <div className="chat-info-section">
132
+ <h4>
133
+ <MessageSquare
134
+ size={11}
135
+ style={{ marginRight: 4, verticalAlign: -1 }}
136
+ aria-hidden
137
+ />{' '}
138
+ Linked
139
+ </h4>
140
+ <div className="chat-info-mono">No links yet</div>
141
+ </div>
142
+ </aside>
143
+ );
144
+ }
@@ -0,0 +1,387 @@
1
+ // src/components/chat/ChatRail.tsx — redesigned session rail (v3.22).
2
+ //
3
+ // Replaces the original SessionList. Layout follows the design HTML:
4
+ // - sessions grouped by recency (Today / Yesterday / This week / Earlier)
5
+ // - each row carries: status dot, title (with pin star), time + unread
6
+ // badge, 3-dot menu trigger (hover-visible, top-right), and (when
7
+ // active + has children) a collapse chevron (vertical-center,
8
+ // right: 28px — to the LEFT of the 3-dot trigger so they never
9
+ // overlap — see chat.css for the exact positioning).
10
+ // - per-row 3-dot popover menu with three modes: main (rename/delete),
11
+ // rename (inline input), confirm-delete (inline confirmation).
12
+ // - active row hangs the agent orchestration tree below it (collapsed
13
+ // by default on first click per FIX #1 — the user opens it via the
14
+ // chevron).
15
+ //
16
+ // Props match the original SessionList so it can be swapped into
17
+ // Chat.tsx without any further wiring. The new props are optional and
18
+ // augment the rail with state + tree + menu behavior when supplied.
19
+
20
+ import { Fragment, useMemo, useRef, useState } from 'react';
21
+ import { Plus, ExternalLink } from 'lucide-react';
22
+ import type { ChatSession } from '../../lib/types';
23
+ import { EmptyState } from './EmptyState';
24
+ import { SessionRowMenu, type SessionMenuMode } from './SessionRowMenu';
25
+ import { SubAgentList } from './AgentTree';
26
+ import type { AgentTreeNode } from './AgentNode';
27
+
28
+ /* ------------------------------------------------------------------ *
29
+ * Display-augmented session shape. The data coming from the API is
30
+ * a bare `ChatSession`; the rail overlays optional display fields.
31
+ * ------------------------------------------------------------------ */
32
+ export type SessionState = 'idle' | 'streaming' | 'awaiting';
33
+
34
+ export interface DisplaySession extends ChatSession {
35
+ state?: SessionState;
36
+ unread?: number;
37
+ pinned?: boolean;
38
+ /** HH:MM display string; defaults derived from mtime */
39
+ time?: string;
40
+ /** Agent name shown in the row subtitle / status dot */
41
+ agent?: string;
42
+ /** Sub-agent tree for this session (root may have children). */
43
+ tree?: { root: AgentTreeNode };
44
+ }
45
+
46
+ interface Props {
47
+ sessions: DisplaySession[];
48
+ opencodeSessions?: DisplaySession[];
49
+ activeSessionId: string;
50
+ activeOpencodeSessionId: string | null;
51
+ activeProject: { name: string } | null;
52
+ creating: boolean;
53
+ onCreateSession: () => void;
54
+ onSelectSession: (id: string) => void;
55
+ onSelectOpencodeSession: (s: DisplaySession) => void;
56
+ /** Optional: rename / delete handlers. When omitted, those buttons
57
+ * in the menu fall back to no-op + toast hint. */
58
+ onRenameSession?: (id: string, title: string) => void;
59
+ onDeleteSession?: (id: string) => void;
60
+ /** Optional: group predicate. By default groups by day buckets. */
61
+ groupBy?: (sessions: DisplaySession[]) => Record<string, DisplaySession[]>;
62
+ }
63
+
64
+ const DEFAULT_GROUPS = ['Today', 'Yesterday', 'This week', 'Earlier'];
65
+
66
+ function isSameDay(a: Date, b: Date) {
67
+ return (
68
+ a.getFullYear() === b.getFullYear() &&
69
+ a.getMonth() === b.getMonth() &&
70
+ a.getDate() === b.getDate()
71
+ );
72
+ }
73
+
74
+ function dayDiff(a: Date, b: Date) {
75
+ const ms = a.getTime() - b.getTime();
76
+ return Math.floor(ms / (1000 * 60 * 60 * 24));
77
+ }
78
+
79
+ function defaultGroupBy(sessions: DisplaySession[]): Record<string, DisplaySession[]> {
80
+ const now = new Date();
81
+ const yesterday = new Date(now);
82
+ yesterday.setDate(yesterday.getDate() - 1);
83
+ const weekAgo = new Date(now);
84
+ weekAgo.setDate(weekAgo.getDate() - 7);
85
+
86
+ const groups: Record<string, DisplaySession[]> = {
87
+ Today: [],
88
+ Yesterday: [],
89
+ 'This week': [],
90
+ Earlier: [],
91
+ };
92
+
93
+ for (const s of sessions) {
94
+ const d = new Date(Number(s.mtime) || Date.now());
95
+ if (isSameDay(d, now)) groups.Today.push(s);
96
+ else if (isSameDay(d, yesterday)) groups.Yesterday.push(s);
97
+ else if (dayDiff(now, d) < 7) groups['This week'].push(s);
98
+ else groups.Earlier.push(s);
99
+ }
100
+ return groups;
101
+ }
102
+
103
+ function formatTime(mtime: number): string {
104
+ const d = new Date(Number(mtime) || Date.now());
105
+ return d.toLocaleTimeString(undefined, {
106
+ hour: '2-digit',
107
+ minute: '2-digit',
108
+ hour12: false,
109
+ });
110
+ }
111
+
112
+ function SessionStateIndicator({
113
+ state,
114
+ agent,
115
+ }: {
116
+ state: SessionState;
117
+ agent?: string;
118
+ }) {
119
+ if (state === 'streaming') {
120
+ return (
121
+ <span
122
+ className="session-state session-state-streaming"
123
+ title={`${agent || 'Odin'} is typing…`}
124
+ />
125
+ );
126
+ }
127
+ if (state === 'awaiting') {
128
+ return (
129
+ <span
130
+ className="session-state session-state-awaiting"
131
+ title="Awaiting your reply"
132
+ aria-hidden
133
+ />
134
+ );
135
+ }
136
+ if (state === 'idle') {
137
+ return <span className="session-state session-state-idle" aria-hidden />;
138
+ }
139
+ return null;
140
+ }
141
+
142
+ export function ChatRail({
143
+ sessions,
144
+ opencodeSessions = [],
145
+ activeSessionId,
146
+ activeOpencodeSessionId,
147
+ activeProject,
148
+ creating,
149
+ onCreateSession,
150
+ onSelectSession,
151
+ onSelectOpencodeSession,
152
+ onRenameSession,
153
+ onDeleteSession,
154
+ groupBy,
155
+ }: Props) {
156
+ const allSessions = useMemo<DisplaySession[]>(
157
+ () => [...sessions, ...opencodeSessions],
158
+ [sessions, opencodeSessions],
159
+ );
160
+ const sorted = useMemo(
161
+ () => [...allSessions].sort((a, b) => Number(b.mtime ?? 0) - Number(a.mtime ?? 0)),
162
+ [allSessions],
163
+ );
164
+
165
+ const grouped = useMemo(
166
+ () => (groupBy ? groupBy(sorted) : defaultGroupBy(sorted)),
167
+ [groupBy, sorted],
168
+ );
169
+
170
+ // FIX #1 — collapse-by-default for the active session's sub-agent
171
+ // tree. State is per-session-id so toggling one session doesn't
172
+ // reset the others.
173
+ const [openTree, setOpenTree] = useState<Record<string, boolean>>({});
174
+ const isTreeOpen = (id: string) => openTree[id] === true;
175
+
176
+ // Per-row menu state (which session's menu is open + which mode).
177
+ const [openMenuId, setOpenMenuId] = useState<string | null>(null);
178
+ const [menuMode, setMenuMode] = useState<SessionMenuMode>('main');
179
+ const [renameDraft, setRenameDraft] = useState('');
180
+ const [menuAnchor, setMenuAnchor] = useState<{ id: string; rect: DOMRect } | null>(null);
181
+ const rowRefs = useRef<Record<string, HTMLDivElement | null>>({});
182
+
183
+ const openMenuFor = (id: string, mode: SessionMenuMode = 'main') => {
184
+ const row = rowRefs.current[id];
185
+ if (!row) return;
186
+ const target =
187
+ mode === 'main'
188
+ ? row
189
+ : row.querySelector<HTMLElement>('.chat-rail-item-menu-trigger') ?? row;
190
+ setMenuAnchor({ id, rect: target.getBoundingClientRect() });
191
+ setOpenMenuId(id);
192
+ setMenuMode(mode);
193
+ if (mode === 'rename') {
194
+ const s = [...sessions, ...opencodeSessions].find((x) => x.id === id);
195
+ setRenameDraft(s?.title ?? s?.id ?? '');
196
+ }
197
+ };
198
+ const closeMenu = () => {
199
+ setOpenMenuId(null);
200
+ setMenuMode('main');
201
+ setRenameDraft('');
202
+ setMenuAnchor(null);
203
+ };
204
+
205
+ const handleSelect = (s: DisplaySession) => {
206
+ if (s.source === 'opencode') {
207
+ onSelectOpencodeSession(s);
208
+ return;
209
+ }
210
+ onSelectSession(s.id);
211
+ };
212
+
213
+ const handleConfirmRename = () => {
214
+ if (!menuAnchor) return;
215
+ const sid = menuAnchor.id;
216
+ const next = renameDraft.trim();
217
+ if (!next) {
218
+ closeMenu();
219
+ return;
220
+ }
221
+ onRenameSession?.(sid, next);
222
+ closeMenu();
223
+ };
224
+
225
+ const handleConfirmDelete = () => {
226
+ if (!menuAnchor) return;
227
+ onDeleteSession?.(menuAnchor.id);
228
+ closeMenu();
229
+ };
230
+
231
+ const orderedGroups: Array<[string, DisplaySession[]]> = groupBy
232
+ ? Object.entries(grouped)
233
+ : DEFAULT_GROUPS.filter((g) => grouped[g]?.length).map(
234
+ (g) => [g, grouped[g]] as [string, DisplaySession[]],
235
+ );
236
+
237
+ return (
238
+ <aside className="chat-rail">
239
+ <div className="chat-rail-head">
240
+ <button
241
+ type="button"
242
+ className="chat-new-btn"
243
+ onClick={onCreateSession}
244
+ disabled={creating || !activeProject}
245
+ title={activeProject ? 'Create new session' : 'Pick a project first'}
246
+ aria-label="Create new session"
247
+ >
248
+ <Plus size={14} aria-hidden />
249
+ <span>{creating ? 'Creating…' : 'New session'}</span>
250
+ </button>
251
+ </div>
252
+
253
+ {sorted.length === 0 ? (
254
+ <div className="chat-sessions-empty">
255
+ <EmptyState
256
+ icon={<Plus size={20} aria-hidden />}
257
+ title={activeProject ? 'No sessions yet' : 'No project'}
258
+ message={
259
+ activeProject
260
+ ? 'Create your first session to start chatting.'
261
+ : 'Pick a project in Overview to scope chat sessions.'
262
+ }
263
+ />
264
+ </div>
265
+ ) : (
266
+ <div className="chat-rail-list">
267
+ {orderedGroups.map(([group, items]) => (
268
+ <div key={group} className="chat-rail-group">
269
+ <div className="chat-rail-group-label">{group}</div>
270
+ {items.map((s) => {
271
+ const isOpencode = s.source === 'opencode';
272
+ const isActive = isOpencode
273
+ ? activeOpencodeSessionId === s.id
274
+ : activeSessionId === s.id;
275
+ const tree = s.tree;
276
+ const hasChildren = !!(tree?.root?.children && tree.root.children.length > 0);
277
+ const treeOpen = isTreeOpen(s.id);
278
+ return (
279
+ <Fragment key={isOpencode ? `oc-${s.id}` : s.id}>
280
+ <div
281
+ ref={(el) => {
282
+ rowRefs.current[s.id] = el;
283
+ }}
284
+ role="button"
285
+ tabIndex={0}
286
+ className={`chat-rail-item state-${s.state ?? 'idle'}${isActive ? ' active' : ''}`}
287
+ onClick={() => handleSelect(s)}
288
+ onKeyDown={(e) => {
289
+ if (e.key === 'Enter' || e.key === ' ') {
290
+ e.preventDefault();
291
+ handleSelect(s);
292
+ }
293
+ }}
294
+ aria-current={isActive ? 'true' : undefined}
295
+ >
296
+ <SessionStateIndicator state={s.state ?? 'idle'} agent={s.agent} />
297
+ <div className="chat-rail-item-title">
298
+ {s.pinned && (
299
+ <span className="chat-rail-pin" aria-hidden>
300
+
301
+ </span>
302
+ )}
303
+ {isOpencode && (
304
+ <ExternalLink
305
+ size={11}
306
+ style={{ color: 'var(--text-muted)', flexShrink: 0 }}
307
+ aria-hidden
308
+ />
309
+ )}
310
+ <span className="chat-ellipsis">{s.title || s.id}</span>
311
+ </div>
312
+ <div className="chat-rail-item-meta">
313
+ <span className="chat-rail-item-meta-time">
314
+ {s.time ?? formatTime(s.mtime)}
315
+ </span>
316
+ {(s.unread ?? 0) > 0 && (
317
+ <span className="chat-rail-badge">{s.unread}</span>
318
+ )}
319
+ </div>
320
+ <button
321
+ type="button"
322
+ className={`chat-rail-item-menu-trigger${openMenuId === s.id ? ' open' : ''}`}
323
+ aria-label={`Session options for ${s.title || s.id}`}
324
+ aria-haspopup="menu"
325
+ aria-expanded={openMenuId === s.id}
326
+ onClick={(e) => {
327
+ e.stopPropagation();
328
+ if (openMenuId === s.id) closeMenu();
329
+ else openMenuFor(s.id, 'main');
330
+ }}
331
+ >
332
+ <svg viewBox="0 0 14 14" aria-hidden>
333
+ <circle cx="3" cy="7" r="1.2" fill="currentColor" />
334
+ <circle cx="7" cy="7" r="1.2" fill="currentColor" />
335
+ <circle cx="11" cy="7" r="1.2" fill="currentColor" />
336
+ </svg>
337
+ </button>
338
+ {isActive && hasChildren && (
339
+ <button
340
+ type="button"
341
+ className={`chat-rail-tree-chevron${treeOpen ? ' open' : ''}`}
342
+ aria-label={treeOpen ? 'Collapse sub-agents' : 'Expand sub-agents'}
343
+ aria-expanded={treeOpen}
344
+ onClick={(e) => {
345
+ e.stopPropagation();
346
+ setOpenTree((cur) => ({ ...cur, [s.id]: !cur[s.id] }));
347
+ }}
348
+ >
349
+ <svg viewBox="0 0 10 10" aria-hidden>
350
+ <polyline points="3,1.5 7,5 3,8.5" />
351
+ </svg>
352
+ </button>
353
+ )}
354
+ </div>
355
+ {isActive && hasChildren && treeOpen && tree?.root?.children && (
356
+ <SubAgentList children={tree.root.children} variant="rail" open />
357
+ )}
358
+ </Fragment>
359
+ );
360
+ })}
361
+ </div>
362
+ ))}
363
+ </div>
364
+ )}
365
+
366
+ {openMenuId && menuAnchor && (
367
+ <SessionRowMenu
368
+ session={{
369
+ id: menuAnchor.id,
370
+ title:
371
+ [...sessions, ...opencodeSessions].find((x) => x.id === menuAnchor.id)
372
+ ?.title ?? menuAnchor.id,
373
+ }}
374
+ mode={menuMode}
375
+ anchor={menuAnchor}
376
+ renameDraft={renameDraft}
377
+ setRenameDraft={setRenameDraft}
378
+ onEdit={() => openMenuFor(openMenuId, 'rename')}
379
+ onDeleteRequest={() => openMenuFor(openMenuId, 'confirm-delete')}
380
+ onConfirmDelete={handleConfirmDelete}
381
+ onConfirmRename={handleConfirmRename}
382
+ onClose={closeMenu}
383
+ />
384
+ )}
385
+ </aside>
386
+ );
387
+ }
@@ -38,17 +38,38 @@ export function ChatThread({
38
38
  onTogglePin,
39
39
  onRegenerate,
40
40
  }: Props) {
41
- const listRef = useRef<HTMLDivElement>(null);
41
+ const innerRef = useRef<HTMLDivElement>(null);
42
42
 
43
+ // v3.22 — only auto-scroll when we own the scroll container (i.e.
44
+ // when rendered directly as the message scroller). When embedded
45
+ // inside Chat.tsx's chat-thread-scroll, the parent handles scroll.
46
+ // We detect this by checking if our closest scroll ancestor is
47
+ // ourselves; if so, auto-scroll. Otherwise, no-op.
43
48
  useEffect(() => {
44
- if (listRef.current) {
45
- listRef.current.scrollTop = listRef.current.scrollHeight;
49
+ const el = innerRef.current;
50
+ if (!el) return;
51
+ // If we ARE the scroll container (legacy / mobile path), scroll.
52
+ // If a parent .chat-thread-scroll wraps us, the parent scrolls.
53
+ const isScroller = el.scrollHeight > el.clientHeight && el.matches(':scope');
54
+ // Simpler heuristic: if no `.chat-thread-scroll` ancestor exists,
55
+ // auto-scroll. Otherwise let the parent handle it.
56
+ let p: HTMLElement | null = el.parentElement;
57
+ let wrapped = false;
58
+ while (p) {
59
+ if (p.classList.contains('chat-thread-scroll')) {
60
+ wrapped = true;
61
+ break;
62
+ }
63
+ p = p.parentElement;
46
64
  }
65
+ if (!wrapped) el.scrollTop = el.scrollHeight;
66
+ // isScroller is referenced for clarity; suppress unused warning.
67
+ void isScroller;
47
68
  }, [messages]);
48
69
 
49
70
  if (loading) {
50
71
  return (
51
- <div className="chat-thread" ref={listRef}>
72
+ <div className="chat-thread legacy" ref={innerRef}>
52
73
  <LoadingSkeleton count={3} />
53
74
  </div>
54
75
  );
@@ -56,7 +77,7 @@ export function ChatThread({
56
77
 
57
78
  if (!activeProject) {
58
79
  return (
59
- <div className="chat-thread" ref={listRef}>
80
+ <div className="chat-thread legacy" ref={innerRef}>
60
81
  <WelcomeScreen variant="no-project" projectName="" onPickSuggestion={onPickSuggestion} />
61
82
  </div>
62
83
  );
@@ -64,14 +85,14 @@ export function ChatThread({
64
85
 
65
86
  if (messages.length === 0) {
66
87
  return (
67
- <div className="chat-thread" ref={listRef}>
88
+ <div className="chat-thread legacy" ref={innerRef}>
68
89
  <WelcomeScreen variant="empty" projectName={activeProject.name} onPickSuggestion={onPickSuggestion} />
69
90
  </div>
70
91
  );
71
92
  }
72
93
 
73
94
  return (
74
- <div className="chat-thread" ref={listRef}>
95
+ <div className="chat-thread legacy" ref={innerRef}>
75
96
  {messages.map((m, i) => (
76
97
  <MessageBubble
77
98
  key={`${i}-${m.ts ?? ''}`}