@robota-sdk/agent-ui-web 0.0.0-stage → 3.0.0-beta.94
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/CHANGELOG.md +409 -0
- package/LICENSE +661 -0
- package/README.md +19 -2
- package/dist/browser/index.d.ts +715 -0
- package/dist/browser/index.d.ts.map +1 -0
- package/dist/browser/index.js +7923 -0
- package/dist/browser/index.js.map +1 -0
- package/dist/node/index.cjs +7973 -0
- package/dist/node/index.d.cts +715 -0
- package/dist/node/index.d.cts.map +1 -0
- package/dist/node/index.d.ts +715 -0
- package/dist/node/index.d.ts.map +1 -0
- package/dist/node/index.js +7923 -0
- package/dist/node/index.js.map +1 -0
- package/docs/README.md +62 -0
- package/docs/SPEC.md +124 -0
- package/package.json +99 -3
- package/src/client/runtime-host.ts +84 -0
- package/src/client/ws-session-client.ts +131 -0
- package/src/clipboard.ts +81 -0
- package/src/components/AgentActivityPanel.tsx +371 -0
- package/src/components/AgentSwitcherSheet.tsx +93 -0
- package/src/components/Brand.tsx +33 -0
- package/src/components/Composer.tsx +685 -0
- package/src/components/ConversationView.tsx +772 -0
- package/src/components/Dialog.tsx +230 -0
- package/src/components/DiffLines.tsx +81 -0
- package/src/components/ExecutionDetailSheet.tsx +142 -0
- package/src/components/HelpSheet.tsx +104 -0
- package/src/components/PermissionPrompt.tsx +661 -0
- package/src/components/PersonalUsageDashboard.tsx +39 -0
- package/src/components/PersonalUsageDashboardDetails.tsx +368 -0
- package/src/components/PersonalUsageDashboardSections.tsx +250 -0
- package/src/components/PopupMenu.tsx +166 -0
- package/src/components/ProjectPanel.tsx +268 -0
- package/src/components/SessionMonitor.tsx +180 -0
- package/src/components/SessionSidebar.tsx +429 -0
- package/src/components/SessionSurface.tsx +455 -0
- package/src/components/SessionSurfaceChrome.tsx +316 -0
- package/src/components/SettingsFields.tsx +159 -0
- package/src/components/SettingsGeneralSection.tsx +49 -0
- package/src/components/SettingsMcpSection.tsx +157 -0
- package/src/components/SettingsPermissionsSection.tsx +137 -0
- package/src/components/SettingsPluginsSection.tsx +165 -0
- package/src/components/SettingsProvidersSection.tsx +211 -0
- package/src/components/SettingsScreen.tsx +340 -0
- package/src/components/StatusControls.tsx +294 -0
- package/src/components/composer-attachments.ts +246 -0
- package/src/components/personal-usage-dashboard-types.ts +26 -0
- package/src/driver-labels.ts +88 -0
- package/src/execution-entry-status.ts +55 -0
- package/src/goal-view.ts +19 -0
- package/src/hooks/command-menu.ts +65 -0
- package/src/hooks/excluded-commands.ts +52 -0
- package/src/hooks/prompt-state.ts +83 -0
- package/src/hooks/server-message-handling.ts +92 -0
- package/src/hooks/session-client-types.ts +329 -0
- package/src/hooks/ui-intent-state.ts +74 -0
- package/src/hooks/use-agent-switcher-state.ts +125 -0
- package/src/hooks/use-execution-detail.ts +118 -0
- package/src/hooks/use-media-query.ts +27 -0
- package/src/hooks/use-model-list.ts +45 -0
- package/src/hooks/use-personal-usage.ts +138 -0
- package/src/hooks/use-project-panel.ts +117 -0
- package/src/hooks/use-schedules-state.ts +92 -0
- package/src/hooks/use-session-directory.ts +202 -0
- package/src/hooks/use-settings-state.ts +115 -0
- package/src/hooks/useSessionClient.ts +991 -0
- package/src/index.ts +66 -0
- package/src/product-identity.tsx +26 -0
- package/src/schedule-view.ts +92 -0
- package/src/styles/surface.css +231 -0
- package/src/styles/surface.css.d.mts +1 -0
- package/src/styles/theme.css +38 -0
- package/src/styles/theme.css.d.mts +1 -0
|
@@ -0,0 +1,429 @@
|
|
|
1
|
+
import {
|
|
2
|
+
MoreHorizontal,
|
|
3
|
+
PanelLeftClose,
|
|
4
|
+
PanelLeftOpen,
|
|
5
|
+
Pencil,
|
|
6
|
+
Settings,
|
|
7
|
+
SquarePen,
|
|
8
|
+
Trash2,
|
|
9
|
+
} from 'lucide-react';
|
|
10
|
+
import { useEffect, useRef, useState } from 'react';
|
|
11
|
+
|
|
12
|
+
import { ConfirmDialog } from './Dialog.js';
|
|
13
|
+
|
|
14
|
+
import type { IWsSessionState } from '../hooks/useSessionClient.js';
|
|
15
|
+
|
|
16
|
+
type TListedSession = NonNullable<IWsSessionState['sessionListing']>['sessions'][number];
|
|
17
|
+
|
|
18
|
+
const MINUTE = 60_000;
|
|
19
|
+
const HOUR = 60 * MINUTE;
|
|
20
|
+
const DAY = 24 * HOUR;
|
|
21
|
+
|
|
22
|
+
/** "just now", "5m ago", "3h ago", "2d ago", then the date. */
|
|
23
|
+
export function formatUpdatedAt(updatedAt: string, now: number = Date.now()): string {
|
|
24
|
+
const at = Date.parse(updatedAt);
|
|
25
|
+
if (Number.isNaN(at)) return '';
|
|
26
|
+
const elapsed = Math.max(0, now - at);
|
|
27
|
+
if (elapsed < MINUTE) return 'just now';
|
|
28
|
+
if (elapsed < HOUR) return `${Math.floor(elapsed / MINUTE)}m ago`;
|
|
29
|
+
if (elapsed < DAY) return `${Math.floor(elapsed / HOUR)}h ago`;
|
|
30
|
+
if (elapsed < 7 * DAY) return `${Math.floor(elapsed / DAY)}d ago`;
|
|
31
|
+
return new Date(at).toISOString().slice(0, 10);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* A session's title (#3289 §1): its name, else its stable first-message title, else "New session".
|
|
36
|
+
* Never `preview` — that field is the raw latest reply, which is what changes every turn and is what
|
|
37
|
+
* this title exists to stop showing.
|
|
38
|
+
*/
|
|
39
|
+
export function sessionTitle(session: TListedSession): string {
|
|
40
|
+
const name = session.name?.trim();
|
|
41
|
+
if (name) return name;
|
|
42
|
+
const title = session.title?.trim();
|
|
43
|
+
if (title) return title;
|
|
44
|
+
return 'New session';
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* The clients on a row besides this surface: its own row counts this surface among them. Null when
|
|
49
|
+
* the host does not count clients, or no one else is there.
|
|
50
|
+
*/
|
|
51
|
+
export function otherClients(session: TListedSession, isCurrent: boolean): number | null {
|
|
52
|
+
if (session.clients === undefined) return null;
|
|
53
|
+
const others = session.clients - (isCurrent ? 1 : 0);
|
|
54
|
+
return others >= 1 ? others : null;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Where a row's context menu opens: the point the mouse or the "More" button gave it. */
|
|
58
|
+
interface IMenuAnchor {
|
|
59
|
+
readonly sessionId: string;
|
|
60
|
+
readonly x: number;
|
|
61
|
+
readonly y: number;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* #3189 — this workspace's sessions on the left of the conversation, as in Claude Code Desktop:
|
|
66
|
+
* start a new one, or click another to make it current. Rows the host could not read are listed,
|
|
67
|
+
* disabled, so a damaged session never looks deleted. A refused switch comes back as a notice.
|
|
68
|
+
* A host that keeps several sessions live marks the rows running now and counts who else is on them.
|
|
69
|
+
*
|
|
70
|
+
* #3289 §1 — each row also carries a "More" menu (or right-click) with Rename (inline) and Delete
|
|
71
|
+
* (confirmed). Rename on the current row reuses the `/rename` command, which also updates the live
|
|
72
|
+
* session's own name and title bar; rename on any other row writes the stored record directly.
|
|
73
|
+
*/
|
|
74
|
+
export function SessionSidebar({
|
|
75
|
+
state,
|
|
76
|
+
brand,
|
|
77
|
+
className,
|
|
78
|
+
}: {
|
|
79
|
+
state: IWsSessionState;
|
|
80
|
+
/** The app's name, shown at the top of the sidebar while it is open. */
|
|
81
|
+
brand?: React.ReactNode;
|
|
82
|
+
className?: string;
|
|
83
|
+
}): React.ReactElement {
|
|
84
|
+
const listing = state.sessionListing ?? null;
|
|
85
|
+
const current = listing?.currentSessionId ?? null;
|
|
86
|
+
const unreadable = listing?.unreadableSessionIds ?? [];
|
|
87
|
+
const failed = state.sessionsError?.code === 'list_failed' ? state.sessionsError.message : null;
|
|
88
|
+
// #3282 §2 (part 2): session switching is disabled while disconnected, like the status chips and
|
|
89
|
+
// the `/` command menu — a switch could not reach the host either.
|
|
90
|
+
const connected = state.status === 'connected';
|
|
91
|
+
|
|
92
|
+
const [menu, setMenu] = useState<IMenuAnchor | null>(null);
|
|
93
|
+
const [renamingId, setRenamingId] = useState<string | null>(null);
|
|
94
|
+
const [renameValue, setRenameValue] = useState('');
|
|
95
|
+
const [confirmDeleteId, setConfirmDeleteId] = useState<string | null>(null);
|
|
96
|
+
const menuRef = useRef<HTMLDivElement | null>(null);
|
|
97
|
+
// Each row's own "More" button, kept live across re-renders so the delete dialog can send focus
|
|
98
|
+
// back to the exact control that opened it — even though the menu item that was actually clicked
|
|
99
|
+
// is gone by the time the dialog mounts (see `Dialog`'s `restoreFocusTo`).
|
|
100
|
+
const moreButtonRefs = useRef(new Map<string, HTMLButtonElement>());
|
|
101
|
+
|
|
102
|
+
// A menu closes on Escape or a click outside it — the usual context-menu contract.
|
|
103
|
+
useEffect(() => {
|
|
104
|
+
if (menu === null) return undefined;
|
|
105
|
+
const onKeyDown = (event: KeyboardEvent): void => {
|
|
106
|
+
if (event.key === 'Escape') setMenu(null);
|
|
107
|
+
};
|
|
108
|
+
const onPointerDown = (event: MouseEvent): void => {
|
|
109
|
+
if (menuRef.current && !menuRef.current.contains(event.target as Node)) setMenu(null);
|
|
110
|
+
};
|
|
111
|
+
document.addEventListener('keydown', onKeyDown);
|
|
112
|
+
document.addEventListener('mousedown', onPointerDown);
|
|
113
|
+
return () => {
|
|
114
|
+
document.removeEventListener('keydown', onKeyDown);
|
|
115
|
+
document.removeEventListener('mousedown', onPointerDown);
|
|
116
|
+
};
|
|
117
|
+
}, [menu]);
|
|
118
|
+
|
|
119
|
+
// A keyboard user who opened the menu lands on its first item, not nowhere.
|
|
120
|
+
useEffect(() => {
|
|
121
|
+
if (menu === null) return;
|
|
122
|
+
menuRef.current?.querySelector<HTMLElement>('[role="menuitem"]')?.focus();
|
|
123
|
+
}, [menu]);
|
|
124
|
+
|
|
125
|
+
const openMenuAt = (sessionId: string, x: number, y: number): void => setMenu({ sessionId, x, y });
|
|
126
|
+
|
|
127
|
+
const startRename = (session: TListedSession): void => {
|
|
128
|
+
setMenu(null);
|
|
129
|
+
setRenamingId(session.id);
|
|
130
|
+
setRenameValue(sessionTitle(session));
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
const saveRename = (session: TListedSession): void => {
|
|
134
|
+
const name = renameValue.trim();
|
|
135
|
+
setRenamingId(null);
|
|
136
|
+
if (name.length === 0 || name === sessionTitle(session)) return;
|
|
137
|
+
if (session.id === current) {
|
|
138
|
+
// The current session's own rename path: it also updates the live name and title bar.
|
|
139
|
+
state.send({ type: 'command', name: 'rename', args: name });
|
|
140
|
+
} else {
|
|
141
|
+
state.renameSessionInList?.(session.id, name);
|
|
142
|
+
}
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
const rows = listing?.sessions ?? [];
|
|
146
|
+
const confirmingSession = rows.find((row) => row.id === confirmDeleteId) ?? null;
|
|
147
|
+
|
|
148
|
+
return (
|
|
149
|
+
<aside
|
|
150
|
+
aria-label="Sessions"
|
|
151
|
+
className={`agent-ui flex w-[272px] flex-shrink-0 flex-col overflow-hidden bg-sidebar ${className ?? ''}`}
|
|
152
|
+
>
|
|
153
|
+
<div className="flex h-12 flex-shrink-0 items-center gap-2 px-4">
|
|
154
|
+
{brand}
|
|
155
|
+
<button
|
|
156
|
+
type="button"
|
|
157
|
+
aria-label="Hide sessions"
|
|
158
|
+
title="Hide sessions"
|
|
159
|
+
onClick={() => state.setSessionSidebarOpen?.(false)}
|
|
160
|
+
className="ml-auto rounded-md p-1.5 text-muted-foreground hover:bg-hover hover:text-foreground"
|
|
161
|
+
>
|
|
162
|
+
<PanelLeftClose size={17} strokeWidth={1.75} />
|
|
163
|
+
</button>
|
|
164
|
+
</div>
|
|
165
|
+
|
|
166
|
+
<div className="flex-shrink-0 px-2.5 pt-1">
|
|
167
|
+
<button
|
|
168
|
+
type="button"
|
|
169
|
+
disabled={!connected}
|
|
170
|
+
title={connected ? undefined : 'Reconnecting…'}
|
|
171
|
+
onClick={() => state.newSession?.()}
|
|
172
|
+
className="flex w-full items-center gap-2.5 rounded-lg px-2.5 py-2 text-[14px] font-medium text-foreground hover:bg-hover disabled:cursor-not-allowed disabled:opacity-60 disabled:hover:bg-transparent"
|
|
173
|
+
>
|
|
174
|
+
<SquarePen size={16} strokeWidth={1.75} className="text-muted-foreground" />
|
|
175
|
+
New session
|
|
176
|
+
</button>
|
|
177
|
+
</div>
|
|
178
|
+
|
|
179
|
+
<div className="flex-1 overflow-y-auto px-2.5 pb-3 pt-4">
|
|
180
|
+
<h2 className="px-2.5 pb-1.5 text-[12.5px] font-medium text-subtle">Sessions</h2>
|
|
181
|
+
{failed !== null ? (
|
|
182
|
+
<p className="px-2.5 py-1 text-[13px] leading-snug text-destructive">{failed}</p>
|
|
183
|
+
) : null}
|
|
184
|
+
{listing === null && failed === null ? (
|
|
185
|
+
<p className="px-2.5 py-1 text-[13px] text-subtle">Loading sessions…</p>
|
|
186
|
+
) : null}
|
|
187
|
+
<ul className="space-y-px">
|
|
188
|
+
{rows.map((session) => {
|
|
189
|
+
const isCurrent = session.id === current;
|
|
190
|
+
const others = otherClients(session, isCurrent);
|
|
191
|
+
const isRenaming = renamingId === session.id;
|
|
192
|
+
// #3289 §3 review: the explicit `aria-label` below replaces name-from-content outright, so
|
|
193
|
+
// the live dot's and "N other(s)" span's own text no longer reaches the accessible name —
|
|
194
|
+
// folded back in here as a description instead, alongside the relative time.
|
|
195
|
+
const statusParts = [
|
|
196
|
+
session.live === true ? 'Live' : null,
|
|
197
|
+
others !== null ? `${others} ${others === 1 ? 'other' : 'others'}` : null,
|
|
198
|
+
].filter((part): part is string => part !== null);
|
|
199
|
+
const statusId = `session-status-${session.id}`;
|
|
200
|
+
const updatedId = `session-updated-${session.id}`;
|
|
201
|
+
const describedBy =
|
|
202
|
+
statusParts.length > 0 ? `${updatedId} ${statusId}` : updatedId;
|
|
203
|
+
return (
|
|
204
|
+
<li key={session.id} className="group relative">
|
|
205
|
+
{isRenaming ? (
|
|
206
|
+
<input
|
|
207
|
+
autoFocus
|
|
208
|
+
aria-label={`Rename ${sessionTitle(session)}`}
|
|
209
|
+
value={renameValue}
|
|
210
|
+
onChange={(event) => setRenameValue(event.target.value)}
|
|
211
|
+
onBlur={() => saveRename(session)}
|
|
212
|
+
onKeyDown={(event) => {
|
|
213
|
+
if (event.key === 'Enter') {
|
|
214
|
+
event.preventDefault();
|
|
215
|
+
saveRename(session);
|
|
216
|
+
} else if (event.key === 'Escape') {
|
|
217
|
+
event.preventDefault();
|
|
218
|
+
setRenamingId(null);
|
|
219
|
+
}
|
|
220
|
+
}}
|
|
221
|
+
className="w-full rounded-lg border border-accent bg-raised px-2.5 py-2 text-[14px] text-foreground outline-none"
|
|
222
|
+
/>
|
|
223
|
+
) : (
|
|
224
|
+
<button
|
|
225
|
+
type="button"
|
|
226
|
+
aria-current={isCurrent ? 'true' : undefined}
|
|
227
|
+
// #3289 §3: an explicit name + description — the row's own visible text has no
|
|
228
|
+
// whitespace between its parts, so an unlabelled button reads as one run-together
|
|
229
|
+
// string ("Title validation fixjust now") to a screen reader.
|
|
230
|
+
aria-label={sessionTitle(session)}
|
|
231
|
+
aria-describedby={describedBy}
|
|
232
|
+
// #3282 §2 (part 2): while disconnected, the tooltip says why instead of previewing.
|
|
233
|
+
title={connected ? session.preview || session.id : 'Reconnecting…'}
|
|
234
|
+
disabled={!connected}
|
|
235
|
+
onClick={() => {
|
|
236
|
+
if (!isCurrent) state.switchSession?.(session.id);
|
|
237
|
+
}}
|
|
238
|
+
onContextMenu={(event) => {
|
|
239
|
+
event.preventDefault();
|
|
240
|
+
openMenuAt(session.id, event.clientX, event.clientY);
|
|
241
|
+
}}
|
|
242
|
+
className={`w-full rounded-lg py-2 pl-2.5 pr-8 text-left transition-colors disabled:cursor-not-allowed disabled:opacity-60 ${
|
|
243
|
+
isCurrent ? 'bg-raised' : 'hover:bg-hover'
|
|
244
|
+
}`}
|
|
245
|
+
>
|
|
246
|
+
<span
|
|
247
|
+
className={`block truncate text-[14px] leading-snug ${
|
|
248
|
+
isCurrent ? 'font-medium text-foreground' : 'text-foreground/85'
|
|
249
|
+
}`}
|
|
250
|
+
>
|
|
251
|
+
{sessionTitle(session)}
|
|
252
|
+
</span>
|
|
253
|
+
<span className="mt-0.5 flex items-center gap-1.5 text-[12.5px] tabular-nums text-subtle">
|
|
254
|
+
{session.live === true ? (
|
|
255
|
+
<span
|
|
256
|
+
title="Live in the host"
|
|
257
|
+
className="h-1.5 w-1.5 flex-shrink-0 rounded-full bg-accent"
|
|
258
|
+
>
|
|
259
|
+
<span className="sr-only">live</span>
|
|
260
|
+
</span>
|
|
261
|
+
) : null}
|
|
262
|
+
<span id={updatedId}>{formatUpdatedAt(session.updatedAt)}</span>
|
|
263
|
+
{others !== null ? (
|
|
264
|
+
<>
|
|
265
|
+
<span aria-hidden="true">·</span>
|
|
266
|
+
<span className="text-muted-foreground">
|
|
267
|
+
{others} {others === 1 ? 'other' : 'others'}
|
|
268
|
+
</span>
|
|
269
|
+
</>
|
|
270
|
+
) : null}
|
|
271
|
+
{statusParts.length > 0 ? (
|
|
272
|
+
<span id={statusId} className="sr-only">
|
|
273
|
+
{statusParts.join(', ')}
|
|
274
|
+
</span>
|
|
275
|
+
) : null}
|
|
276
|
+
</span>
|
|
277
|
+
</button>
|
|
278
|
+
)}
|
|
279
|
+
{!isRenaming ? (
|
|
280
|
+
<button
|
|
281
|
+
type="button"
|
|
282
|
+
ref={(el) => {
|
|
283
|
+
if (el) moreButtonRefs.current.set(session.id, el);
|
|
284
|
+
else moreButtonRefs.current.delete(session.id);
|
|
285
|
+
}}
|
|
286
|
+
aria-label={`More for ${sessionTitle(session)}`}
|
|
287
|
+
aria-haspopup="menu"
|
|
288
|
+
aria-expanded={menu?.sessionId === session.id}
|
|
289
|
+
onClick={(event) => {
|
|
290
|
+
const rect = event.currentTarget.getBoundingClientRect();
|
|
291
|
+
openMenuAt(session.id, rect.left, rect.bottom);
|
|
292
|
+
}}
|
|
293
|
+
className="absolute right-1 top-1/2 -translate-y-1/2 rounded-md p-1 text-muted-foreground opacity-0 hover:bg-hover hover:text-foreground focus-visible:opacity-100 group-hover:opacity-100"
|
|
294
|
+
>
|
|
295
|
+
<MoreHorizontal size={15} strokeWidth={1.75} />
|
|
296
|
+
</button>
|
|
297
|
+
) : null}
|
|
298
|
+
</li>
|
|
299
|
+
);
|
|
300
|
+
})}
|
|
301
|
+
{unreadable.length > 0 && (
|
|
302
|
+
// Listed, never dropped, so an unreadable session does not look deleted — but as one
|
|
303
|
+
// folded line: a store can hold many old records, and each is nothing to click.
|
|
304
|
+
<li>
|
|
305
|
+
<details className="px-2.5 py-2 text-[12.5px] text-subtle">
|
|
306
|
+
<summary className="cursor-pointer text-muted-foreground hover:text-foreground">
|
|
307
|
+
{unreadable.length} older {unreadable.length === 1 ? 'session' : 'sessions'} in
|
|
308
|
+
this folder can't be opened
|
|
309
|
+
</summary>
|
|
310
|
+
<ul className="mt-1.5 space-y-0.5 font-mono text-[11.5px]">
|
|
311
|
+
{unreadable.map((id) => (
|
|
312
|
+
<li key={id} className="truncate">
|
|
313
|
+
{id}
|
|
314
|
+
</li>
|
|
315
|
+
))}
|
|
316
|
+
</ul>
|
|
317
|
+
</details>
|
|
318
|
+
</li>
|
|
319
|
+
)}
|
|
320
|
+
</ul>
|
|
321
|
+
</div>
|
|
322
|
+
|
|
323
|
+
<div className="flex-shrink-0 border-t border-border/60 px-2.5 py-2">
|
|
324
|
+
<button
|
|
325
|
+
type="button"
|
|
326
|
+
aria-label="Settings"
|
|
327
|
+
title="Settings"
|
|
328
|
+
onClick={() => state.openSettings()}
|
|
329
|
+
className="flex w-full items-center gap-2.5 rounded-lg px-2.5 py-2 text-[13.5px] text-muted-foreground hover:bg-hover hover:text-foreground"
|
|
330
|
+
>
|
|
331
|
+
<Settings size={16} strokeWidth={1.75} />
|
|
332
|
+
Settings
|
|
333
|
+
</button>
|
|
334
|
+
</div>
|
|
335
|
+
|
|
336
|
+
{menu !== null
|
|
337
|
+
? (() => {
|
|
338
|
+
const session = rows.find((row) => row.id === menu.sessionId);
|
|
339
|
+
if (!session) return null;
|
|
340
|
+
return (
|
|
341
|
+
<div
|
|
342
|
+
ref={menuRef}
|
|
343
|
+
role="menu"
|
|
344
|
+
aria-label={`Actions for ${sessionTitle(session)}`}
|
|
345
|
+
style={{ position: 'fixed', left: menu.x, top: menu.y }}
|
|
346
|
+
className="gui-rise z-40 min-w-[140px] overflow-hidden rounded-lg bg-popover py-1 text-popover-foreground shadow-xl shadow-black/30"
|
|
347
|
+
>
|
|
348
|
+
<button
|
|
349
|
+
type="button"
|
|
350
|
+
role="menuitem"
|
|
351
|
+
onClick={() => startRename(session)}
|
|
352
|
+
className="flex w-full items-center gap-2 px-3 py-1.5 text-left text-[13.5px] hover:bg-hover"
|
|
353
|
+
>
|
|
354
|
+
<Pencil size={14} strokeWidth={1.75} />
|
|
355
|
+
Rename
|
|
356
|
+
</button>
|
|
357
|
+
<button
|
|
358
|
+
type="button"
|
|
359
|
+
role="menuitem"
|
|
360
|
+
onClick={() => {
|
|
361
|
+
setMenu(null);
|
|
362
|
+
setConfirmDeleteId(session.id);
|
|
363
|
+
}}
|
|
364
|
+
className="flex w-full items-center gap-2 px-3 py-1.5 text-left text-[13.5px] text-destructive hover:bg-hover"
|
|
365
|
+
>
|
|
366
|
+
<Trash2 size={14} strokeWidth={1.75} />
|
|
367
|
+
Delete…
|
|
368
|
+
</button>
|
|
369
|
+
</div>
|
|
370
|
+
);
|
|
371
|
+
})()
|
|
372
|
+
: null}
|
|
373
|
+
|
|
374
|
+
{confirmingSession !== null ? (
|
|
375
|
+
<ConfirmDialog
|
|
376
|
+
open
|
|
377
|
+
title={`Delete "${sessionTitle(confirmingSession)}"?`}
|
|
378
|
+
body="This removes its conversation from this computer."
|
|
379
|
+
confirmLabel="Delete"
|
|
380
|
+
destructive
|
|
381
|
+
restoreFocusTo={moreButtonRefs.current.get(confirmingSession.id) ?? null}
|
|
382
|
+
onCancel={() => setConfirmDeleteId(null)}
|
|
383
|
+
onConfirm={() => {
|
|
384
|
+
state.deleteSession?.(confirmingSession.id);
|
|
385
|
+
setConfirmDeleteId(null);
|
|
386
|
+
}}
|
|
387
|
+
/>
|
|
388
|
+
) : null}
|
|
389
|
+
</aside>
|
|
390
|
+
);
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/** The collapsed sidebar: a narrow rail that opens it again. */
|
|
394
|
+
export function SessionSidebarRail({ state }: { state: IWsSessionState }): React.ReactElement {
|
|
395
|
+
// #3282 §2 (part 2): starting a session is disabled while disconnected, matching the open sidebar.
|
|
396
|
+
const connected = state.status === 'connected';
|
|
397
|
+
return (
|
|
398
|
+
<div className="flex w-12 flex-shrink-0 flex-col items-center gap-1 bg-sidebar py-2.5">
|
|
399
|
+
<button
|
|
400
|
+
type="button"
|
|
401
|
+
aria-label="Show sessions"
|
|
402
|
+
title="Show sessions"
|
|
403
|
+
onClick={() => state.setSessionSidebarOpen?.(true)}
|
|
404
|
+
className="rounded-md p-1.5 text-muted-foreground hover:bg-hover hover:text-foreground"
|
|
405
|
+
>
|
|
406
|
+
<PanelLeftOpen size={17} strokeWidth={1.75} />
|
|
407
|
+
</button>
|
|
408
|
+
<button
|
|
409
|
+
type="button"
|
|
410
|
+
aria-label="New session"
|
|
411
|
+
title={connected ? 'New session' : 'Reconnecting…'}
|
|
412
|
+
disabled={!connected}
|
|
413
|
+
onClick={() => state.newSession?.()}
|
|
414
|
+
className="rounded-md p-1.5 text-muted-foreground hover:bg-hover hover:text-foreground disabled:cursor-not-allowed disabled:opacity-60 disabled:hover:bg-transparent"
|
|
415
|
+
>
|
|
416
|
+
<SquarePen size={16} strokeWidth={1.75} />
|
|
417
|
+
</button>
|
|
418
|
+
<button
|
|
419
|
+
type="button"
|
|
420
|
+
aria-label="Settings"
|
|
421
|
+
title="Settings"
|
|
422
|
+
onClick={() => state.openSettings()}
|
|
423
|
+
className="mt-auto rounded-md p-1.5 text-muted-foreground hover:bg-hover hover:text-foreground"
|
|
424
|
+
>
|
|
425
|
+
<Settings size={16} strokeWidth={1.75} />
|
|
426
|
+
</button>
|
|
427
|
+
</div>
|
|
428
|
+
);
|
|
429
|
+
}
|