@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.
- package/bizar-dash/src/server/opencode-sdk.mjs +72 -0
- package/bizar-dash/src/server/routes/background.mjs +92 -0
- package/bizar-dash/src/server/routes/chat.mjs +300 -123
- package/bizar-dash/src/server/routes/opencode-session-detail.mjs +26 -0
- package/bizar-dash/src/server/routes/tasks.mjs +59 -1
- package/bizar-dash/src/server/task-delegator.mjs +154 -8
- package/bizar-dash/src/server/tasks-store.mjs +50 -2
- package/bizar-dash/src/web/components/background/AttachButton.tsx +96 -0
- package/bizar-dash/src/web/components/background/TmuxAttachCard.tsx +122 -0
- package/bizar-dash/src/web/components/chat/AgentNode.tsx +127 -0
- package/bizar-dash/src/web/components/chat/AgentTree.tsx +80 -0
- package/bizar-dash/src/web/components/chat/ChatComposer.tsx +76 -0
- package/bizar-dash/src/web/components/chat/ChatInfoPanel.tsx +144 -0
- package/bizar-dash/src/web/components/chat/ChatRail.tsx +387 -0
- package/bizar-dash/src/web/components/chat/ChatThread.tsx +28 -7
- package/bizar-dash/src/web/components/chat/JumpToLatest.tsx +58 -0
- package/bizar-dash/src/web/components/chat/MessageBlock.tsx +260 -0
- package/bizar-dash/src/web/components/chat/SessionRowMenu.tsx +206 -0
- package/bizar-dash/src/web/components/chat/StreamingIndicator.tsx +33 -5
- package/bizar-dash/src/web/components/chat/_legacy.ts +30 -0
- package/bizar-dash/src/web/components/chat/index.ts +11 -0
- package/bizar-dash/src/web/components/chat/useChat.ts +345 -167
- package/bizar-dash/src/web/components/tasks/BacklogPanel.css +109 -0
- package/bizar-dash/src/web/components/tasks/BacklogPanel.tsx +209 -0
- package/bizar-dash/src/web/styles/chat.css +1536 -133
- package/bizar-dash/src/web/views/BackgroundAgents.tsx +3 -0
- package/bizar-dash/src/web/views/Chat.tsx +147 -57
- package/bizar-dash/src/web/views/Tasks.tsx +23 -1
- package/cli/bg.mjs +94 -71
- package/cli/bin.mjs +36 -8
- package/cli/service-controller.mjs +587 -0
- package/cli/service-controller.test.mjs +92 -0
- package/cli/service.mjs +162 -14
- package/config/agents/baldr.md +2 -0
- package/config/agents/browser-harness.md +2 -0
- package/config/agents/forseti.md +2 -0
- package/config/agents/frigg.md +2 -0
- package/config/agents/heimdall.md +2 -0
- package/config/agents/hermod.md +2 -0
- package/config/agents/mimir.md +2 -0
- package/config/agents/odin.md +2 -0
- package/config/agents/quick.md +2 -0
- package/config/agents/semble-search.md +2 -0
- package/config/agents/thor.md +2 -0
- package/config/agents/tyr.md +2 -0
- package/config/agents/vidarr.md +2 -0
- package/config/agents/vor.md +2 -0
- package/config/opencode.json.template +1 -0
- package/install.sh +448 -787
- package/package.json +2 -2
- package/packages/sdk/package.json +20 -0
- package/packages/sdk/src/client.ts +5 -0
- package/packages/sdk/src/errors.ts +11 -2
- package/packages/sdk/src/index.ts +19 -0
- package/packages/sdk/src/opencode-events.ts +134 -0
- package/packages/sdk/src/opencode-types.ts +66 -0
- 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
|
|
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
|
-
|
|
45
|
-
|
|
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={
|
|
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={
|
|
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={
|
|
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={
|
|
95
|
+
<div className="chat-thread legacy" ref={innerRef}>
|
|
75
96
|
{messages.map((m, i) => (
|
|
76
97
|
<MessageBubble
|
|
77
98
|
key={`${i}-${m.ts ?? ''}`}
|