@robota-sdk/agent-ui-web 0.0.0-stage → 3.0.0-beta.93

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 (75) hide show
  1. package/CHANGELOG.md +400 -0
  2. package/LICENSE +661 -0
  3. package/README.md +19 -2
  4. package/dist/browser/index.d.ts +715 -0
  5. package/dist/browser/index.d.ts.map +1 -0
  6. package/dist/browser/index.js +7923 -0
  7. package/dist/browser/index.js.map +1 -0
  8. package/dist/node/index.cjs +7973 -0
  9. package/dist/node/index.d.cts +715 -0
  10. package/dist/node/index.d.cts.map +1 -0
  11. package/dist/node/index.d.ts +715 -0
  12. package/dist/node/index.d.ts.map +1 -0
  13. package/dist/node/index.js +7923 -0
  14. package/dist/node/index.js.map +1 -0
  15. package/docs/README.md +62 -0
  16. package/docs/SPEC.md +124 -0
  17. package/package.json +99 -3
  18. package/src/client/runtime-host.ts +84 -0
  19. package/src/client/ws-session-client.ts +131 -0
  20. package/src/clipboard.ts +81 -0
  21. package/src/components/AgentActivityPanel.tsx +371 -0
  22. package/src/components/AgentSwitcherSheet.tsx +93 -0
  23. package/src/components/Brand.tsx +33 -0
  24. package/src/components/Composer.tsx +685 -0
  25. package/src/components/ConversationView.tsx +772 -0
  26. package/src/components/Dialog.tsx +230 -0
  27. package/src/components/DiffLines.tsx +81 -0
  28. package/src/components/ExecutionDetailSheet.tsx +142 -0
  29. package/src/components/HelpSheet.tsx +104 -0
  30. package/src/components/PermissionPrompt.tsx +661 -0
  31. package/src/components/PersonalUsageDashboard.tsx +39 -0
  32. package/src/components/PersonalUsageDashboardDetails.tsx +368 -0
  33. package/src/components/PersonalUsageDashboardSections.tsx +250 -0
  34. package/src/components/PopupMenu.tsx +166 -0
  35. package/src/components/ProjectPanel.tsx +268 -0
  36. package/src/components/SessionMonitor.tsx +180 -0
  37. package/src/components/SessionSidebar.tsx +429 -0
  38. package/src/components/SessionSurface.tsx +455 -0
  39. package/src/components/SessionSurfaceChrome.tsx +316 -0
  40. package/src/components/SettingsFields.tsx +159 -0
  41. package/src/components/SettingsGeneralSection.tsx +49 -0
  42. package/src/components/SettingsMcpSection.tsx +157 -0
  43. package/src/components/SettingsPermissionsSection.tsx +137 -0
  44. package/src/components/SettingsPluginsSection.tsx +165 -0
  45. package/src/components/SettingsProvidersSection.tsx +211 -0
  46. package/src/components/SettingsScreen.tsx +340 -0
  47. package/src/components/StatusControls.tsx +294 -0
  48. package/src/components/composer-attachments.ts +246 -0
  49. package/src/components/personal-usage-dashboard-types.ts +26 -0
  50. package/src/driver-labels.ts +88 -0
  51. package/src/execution-entry-status.ts +55 -0
  52. package/src/goal-view.ts +19 -0
  53. package/src/hooks/command-menu.ts +65 -0
  54. package/src/hooks/excluded-commands.ts +52 -0
  55. package/src/hooks/prompt-state.ts +83 -0
  56. package/src/hooks/server-message-handling.ts +92 -0
  57. package/src/hooks/session-client-types.ts +329 -0
  58. package/src/hooks/ui-intent-state.ts +74 -0
  59. package/src/hooks/use-agent-switcher-state.ts +125 -0
  60. package/src/hooks/use-execution-detail.ts +118 -0
  61. package/src/hooks/use-media-query.ts +27 -0
  62. package/src/hooks/use-model-list.ts +45 -0
  63. package/src/hooks/use-personal-usage.ts +138 -0
  64. package/src/hooks/use-project-panel.ts +117 -0
  65. package/src/hooks/use-schedules-state.ts +92 -0
  66. package/src/hooks/use-session-directory.ts +202 -0
  67. package/src/hooks/use-settings-state.ts +115 -0
  68. package/src/hooks/useSessionClient.ts +991 -0
  69. package/src/index.ts +66 -0
  70. package/src/product-identity.tsx +26 -0
  71. package/src/schedule-view.ts +92 -0
  72. package/src/styles/surface.css +231 -0
  73. package/src/styles/surface.css.d.mts +1 -0
  74. package/src/styles/theme.css +38 -0
  75. package/src/styles/theme.css.d.mts +1 -0
@@ -0,0 +1,230 @@
1
+ 'use client';
2
+
3
+ import React, { useEffect, useId, useRef } from 'react';
4
+
5
+ /**
6
+ * The shared modal-shell primitive (#3282 §4a, coordinated with #3282 §2's model/mode controls,
7
+ * which also need a confirmation dialog). Whichever of the two lands first defines this file; the
8
+ * other rebases onto it rather than shipping a second copy.
9
+ *
10
+ * `role="dialog"` (or `"alertdialog"`, see `role` below), `aria-modal`, a labelled title, focus moved
11
+ * inside on open, a focus trap while open, Esc to close, and focus restored to whatever had it before
12
+ * the dialog opened. A backdrop click closes the dialog UNLESS `destructive` is set — a destructive
13
+ * dialog (e.g. "Remove this rule?") is dismissed only by an explicit choice, never an accidental
14
+ * outside click.
15
+ */
16
+ export interface IDialogProps {
17
+ open: boolean;
18
+ /** Called on Esc, a backdrop click (non-destructive only), or an explicit close control. */
19
+ onClose: () => void;
20
+ title: string;
21
+ children: React.ReactNode;
22
+ /** Disables backdrop-click-to-close for a dialog whose only correct exits are explicit buttons. */
23
+ destructive?: boolean;
24
+ /** Overrides the panel's own classes; the backdrop/overlay classes are never overridden. */
25
+ panelClassName?: string;
26
+ /** Id of an element describing the dialog, wired to `aria-describedby`. */
27
+ describedById?: string;
28
+ /**
29
+ * Overrides what focus returns to when the dialog closes (#3289 §1). Default behavior captures
30
+ * `document.activeElement` when the dialog opens — which fails for a caller whose opener element
31
+ * is removed from the DOM in the SAME update that opens this dialog (e.g. a menu item whose menu
32
+ * closes as this dialog opens): by the time this dialog's open-effect runs, the browser has already
33
+ * moved focus to `document.body`. Such a caller passes the stable element focus should return to.
34
+ */
35
+ restoreFocusTo?: HTMLElement | null;
36
+ /**
37
+ * #3282 §4e: `'alertdialog'` for a dialog that interrupts to demand a decision before anything else
38
+ * can happen (a destructive confirmation) — WAI-ARIA's distinction from a dialog the user can set
39
+ * aside. Default `'dialog'`.
40
+ */
41
+ role?: 'dialog' | 'alertdialog';
42
+ }
43
+
44
+ const FOCUSABLE_SELECTOR =
45
+ 'a[href], button:not([disabled]), textarea:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])';
46
+
47
+ /**
48
+ * Every currently-open `Dialog`, oldest first. All instances share one `document` keydown listener
49
+ * target, so listeners fire in REGISTRATION order regardless of nesting — the outer dialog's
50
+ * listener (registered first, when it opened) always runs before a nested `ConfirmDialog`'s
51
+ * (registered later, when the user triggered it). `stopImmediatePropagation` on the outer listener
52
+ * therefore cannot defer to the inner one; it fires first and would win. Instead, each instance
53
+ * checks this stack and only acts when it is the LAST (topmost) entry — an outer dialog that isn't
54
+ * topmost no-ops on the keydown and lets the still-registered inner listener act.
55
+ */
56
+ let openDialogStack: symbol[] = [];
57
+
58
+ export function Dialog({
59
+ open,
60
+ onClose,
61
+ title,
62
+ children,
63
+ destructive = false,
64
+ panelClassName,
65
+ describedById,
66
+ restoreFocusTo,
67
+ role = 'dialog',
68
+ }: IDialogProps): React.ReactElement | null {
69
+ const panelRef = useRef<HTMLDivElement>(null);
70
+ const previouslyFocused = useRef<HTMLElement | null>(null);
71
+ const titleId = useId();
72
+ const dialogToken = useRef<symbol>(Symbol('dialog'));
73
+
74
+ // Move focus in on open, and restore it to the opener once the dialog goes away. `restoreFocusTo`,
75
+ // given, overrides the ambient `document.activeElement` this would otherwise capture.
76
+ useEffect(() => {
77
+ if (!open) return undefined;
78
+ previouslyFocused.current = restoreFocusTo ?? (document.activeElement as HTMLElement | null);
79
+ const panel = panelRef.current;
80
+ const firstFocusable = panel?.querySelector<HTMLElement>(FOCUSABLE_SELECTOR);
81
+ (firstFocusable ?? panel)?.focus();
82
+ return () => {
83
+ previouslyFocused.current?.focus?.();
84
+ };
85
+ }, [open]);
86
+
87
+ // Tracks this dialog's membership on the shared open-dialog stack. Kept separate from the
88
+ // keydown-listener effect below (which also depends on `onClose`) so that a caller passing a
89
+ // fresh `onClose` identity on every render can never re-push this dialog and wrongly promote it
90
+ // to topmost — membership changes only when `open` itself flips.
91
+ useEffect(() => {
92
+ if (!open) return undefined;
93
+ const token = dialogToken.current;
94
+ openDialogStack.push(token);
95
+ return () => {
96
+ openDialogStack = openDialogStack.filter((entry) => entry !== token);
97
+ };
98
+ }, [open]);
99
+
100
+ // Esc closes; Tab traps focus inside the panel while it is open. Both no-op unless this dialog
101
+ // is the topmost open one (see `openDialogStack` above).
102
+ useEffect(() => {
103
+ if (!open) return undefined;
104
+ const token = dialogToken.current;
105
+ const isTopmost = (): boolean => openDialogStack[openDialogStack.length - 1] === token;
106
+ const onKeyDown = (event: KeyboardEvent): void => {
107
+ if (!isTopmost()) return;
108
+ if (event.key === 'Escape') {
109
+ event.stopImmediatePropagation();
110
+ onClose();
111
+ return;
112
+ }
113
+ if (event.key !== 'Tab') return;
114
+ const panel = panelRef.current;
115
+ if (!panel) return;
116
+ const focusable = Array.from(panel.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR));
117
+ if (focusable.length === 0) return;
118
+ const first = focusable[0];
119
+ const last = focusable[focusable.length - 1];
120
+ if (event.shiftKey && document.activeElement === first) {
121
+ event.preventDefault();
122
+ last?.focus();
123
+ } else if (!event.shiftKey && document.activeElement === last) {
124
+ event.preventDefault();
125
+ first?.focus();
126
+ }
127
+ };
128
+ document.addEventListener('keydown', onKeyDown, true);
129
+ return () => document.removeEventListener('keydown', onKeyDown, true);
130
+ }, [open, onClose]);
131
+
132
+ if (!open) return null;
133
+
134
+ return (
135
+ <div
136
+ className="agent-ui fixed inset-0 z-50 flex items-center justify-center bg-black/55 p-4 backdrop-blur-[2px]"
137
+ onMouseDown={(event) => {
138
+ if (destructive) return;
139
+ if (event.target === event.currentTarget) onClose();
140
+ }}
141
+ >
142
+ <div
143
+ ref={panelRef}
144
+ role={role}
145
+ aria-modal="true"
146
+ aria-labelledby={titleId}
147
+ {...(describedById ? { 'aria-describedby': describedById } : {})}
148
+ tabIndex={-1}
149
+ className={
150
+ panelClassName ??
151
+ 'flex max-h-[85vh] w-full max-w-lg flex-col rounded-2xl bg-card text-card-foreground shadow-[0_8px_30px_-12px_rgb(0_0_0/0.45)] focus:outline-none'
152
+ }
153
+ >
154
+ <h2 id={titleId} className="sr-only">
155
+ {title}
156
+ </h2>
157
+ {children}
158
+ </div>
159
+ </div>
160
+ );
161
+ }
162
+
163
+ const BUTTON =
164
+ 'inline-flex items-center gap-2 rounded-lg px-3.5 py-1.5 text-[14px] font-medium transition-colors';
165
+ const PRIMARY_BUTTON = `${BUTTON} bg-primary text-primary-foreground hover:opacity-90`;
166
+ const SECONDARY_BUTTON = `${BUTTON} bg-raised text-foreground hover:bg-hover`;
167
+ const DESTRUCTIVE_BUTTON = `${BUTTON} bg-destructive text-destructive-foreground hover:opacity-90`;
168
+
169
+ export interface IConfirmDialogProps {
170
+ open: boolean;
171
+ title: string;
172
+ body: React.ReactNode;
173
+ confirmLabel: string;
174
+ cancelLabel?: string;
175
+ /** Styles the confirm button as destructive; the dialog itself also stops closing on a backdrop click. */
176
+ destructive?: boolean;
177
+ onConfirm: () => void;
178
+ onCancel: () => void;
179
+ /** Forwarded to `Dialog` — see its doc comment. */
180
+ restoreFocusTo?: HTMLElement | null;
181
+ }
182
+
183
+ /**
184
+ * A title, a body, Cancel focused by default, and a confirm button that can be styled destructive.
185
+ * #3282 §4e: `destructive` also picks the dialog's ARIA role — `"alertdialog"` when it is (an action
186
+ * that demands a decision before anything else, e.g. deleting a session), `"dialog"` otherwise.
187
+ */
188
+ export function ConfirmDialog({
189
+ open,
190
+ title,
191
+ body,
192
+ confirmLabel,
193
+ cancelLabel = 'Cancel',
194
+ destructive = false,
195
+ onConfirm,
196
+ onCancel,
197
+ restoreFocusTo,
198
+ }: IConfirmDialogProps): React.ReactElement | null {
199
+ const bodyId = useId();
200
+ return (
201
+ <Dialog
202
+ open={open}
203
+ onClose={onCancel}
204
+ title={title}
205
+ destructive={destructive}
206
+ role={destructive ? 'alertdialog' : 'dialog'}
207
+ describedById={bodyId}
208
+ restoreFocusTo={restoreFocusTo}
209
+ panelClassName="w-full max-w-sm rounded-2xl bg-card p-5 text-card-foreground shadow-[0_8px_30px_-12px_rgb(0_0_0/0.45)] focus:outline-none"
210
+ >
211
+ <p className="text-[15px] font-medium text-foreground">{title}</p>
212
+ <p id={bodyId} className="mt-2 text-[13.5px] text-muted-foreground">
213
+ {body}
214
+ </p>
215
+ <div className="mt-5 flex justify-end gap-2">
216
+ {/* Cancel is the FIRST focusable element in the panel, so Dialog's open-focus lands here. */}
217
+ <button type="button" className={SECONDARY_BUTTON} onClick={onCancel}>
218
+ {cancelLabel}
219
+ </button>
220
+ <button
221
+ type="button"
222
+ className={destructive ? DESTRUCTIVE_BUTTON : PRIMARY_BUTTON}
223
+ onClick={onConfirm}
224
+ >
225
+ {confirmLabel}
226
+ </button>
227
+ </div>
228
+ </Dialog>
229
+ );
230
+ }
@@ -0,0 +1,81 @@
1
+ 'use client';
2
+
3
+ import React, { useState } from 'react';
4
+
5
+ import type { IDiffLine } from '@robota-sdk/agent-interface-session';
6
+
7
+ /**
8
+ * #3288: renders a unified diff already built server-side (the ONE diff builder, in
9
+ * `interactive-session-streaming.ts`) — a tool row's expanded result and the Edit/Write permission
10
+ * prompt both show the SAME diff through this, so approving a change and reviewing it afterwards
11
+ * look identical. This component never computes a diff itself, only displays one.
12
+ */
13
+
14
+ const DIFF_LINE_STYLE: Record<IDiffLine['type'], string> = {
15
+ add: 'bg-accent/10 text-foreground',
16
+ remove: 'bg-destructive/10 text-foreground',
17
+ context: 'text-muted-foreground',
18
+ hunk: 'text-subtle',
19
+ };
20
+ const DIFF_LINE_PREFIX: Record<IDiffLine['type'], string> = {
21
+ add: '+ ',
22
+ remove: '- ',
23
+ context: ' ',
24
+ hunk: '',
25
+ };
26
+
27
+ // #3288 review SHOULD 3: the server already caps each diff side at 500 lines — this is a second,
28
+ // independent fold so a diff that size never renders into the DOM at once either.
29
+ const DIFF_FOLD_LINES = 40;
30
+ const DIFF_EXPANDED_CAP_LINES = 500;
31
+
32
+ function DiffLine({ line }: { line: IDiffLine }): React.ReactElement {
33
+ return (
34
+ <div className={`whitespace-pre ${DIFF_LINE_STYLE[line.type]}`}>
35
+ {`${DIFF_LINE_PREFIX[line.type]}${line.text}`}
36
+ </div>
37
+ );
38
+ }
39
+
40
+ export function DiffLines({ diffLines }: { diffLines: readonly IDiffLine[] }): React.ReactElement {
41
+ const [open, setOpen] = useState(false);
42
+ if (diffLines.length <= DIFF_FOLD_LINES) {
43
+ return (
44
+ <pre className="overflow-x-auto rounded-lg bg-sidebar px-3 py-2 font-mono text-[12.5px] leading-relaxed">
45
+ {diffLines.map((line, index) => (
46
+ // A diff's line order IS its identity — index is a stable, correct key here.
47
+ <DiffLine key={index} line={line} />
48
+ ))}
49
+ </pre>
50
+ );
51
+ }
52
+
53
+ const capped = diffLines.slice(0, DIFF_EXPANDED_CAP_LINES);
54
+ const shown = open ? capped : diffLines.slice(0, DIFF_FOLD_LINES);
55
+ // Expanding reveals up to DIFF_EXPANDED_CAP_LINES, not the whole diff — the label must promise
56
+ // only what "Show more" actually shows, or a capped Edit (each side capped at 500 on the server)
57
+ // can claim more lines than clicking through ever produces.
58
+ const revealedByExpanding = Math.min(diffLines.length, DIFF_EXPANDED_CAP_LINES) - DIFF_FOLD_LINES;
59
+ return (
60
+ <div className="flex flex-col gap-1">
61
+ <pre className="overflow-x-auto rounded-lg bg-sidebar px-3 py-2 font-mono text-[12.5px] leading-relaxed">
62
+ {shown.map((line, index) => (
63
+ // A diff's line order IS its identity — index is a stable, correct key here.
64
+ <DiffLine key={index} line={line} />
65
+ ))}
66
+ </pre>
67
+ {open && diffLines.length > DIFF_EXPANDED_CAP_LINES && (
68
+ <p className="text-[12px] text-subtle">
69
+ {diffLines.length - DIFF_EXPANDED_CAP_LINES} more lines not shown
70
+ </p>
71
+ )}
72
+ <button
73
+ type="button"
74
+ onClick={() => setOpen((value) => !value)}
75
+ className="self-start text-[12.5px] text-accent hover:underline"
76
+ >
77
+ {open ? 'Show less' : `Show more (${revealedByExpanding} more lines)`}
78
+ </button>
79
+ </div>
80
+ );
81
+ }
@@ -0,0 +1,142 @@
1
+ import { X } from 'lucide-react';
2
+ import React from 'react';
3
+
4
+ import { describeExecutionEntryStatus } from '../execution-entry-status.js';
5
+
6
+ import type { TExecutionDetailStatus } from '../hooks/use-execution-detail.js';
7
+ import type {
8
+ IExecutionDetailRecord,
9
+ IExecutionWorkspaceEntry,
10
+ } from '@robota-sdk/agent-interface-execution';
11
+
12
+ const STATUS_TAG_TONE: Record<string, string> = {
13
+ warning: 'bg-warning/15 text-warning',
14
+ destructive: 'bg-destructive/12 text-destructive',
15
+ muted: 'bg-subtle/15 text-subtle',
16
+ };
17
+
18
+ const RECORD_KIND_LABEL: Record<IExecutionDetailRecord['kind'], string> = {
19
+ message: '',
20
+ tool_activity: 'Tool',
21
+ process_output: 'Output',
22
+ progress: 'Progress',
23
+ result: 'Result',
24
+ error: 'Error',
25
+ group_summary: 'Summary',
26
+ };
27
+
28
+ /**
29
+ * #3288 §1: opening an Agents panel entry — status, what it was asked (one line, from its
30
+ * headline), its transcript so far (paginated via `onLoadMore`) and, once it has one, its result.
31
+ * `entry === null` renders nothing; the caller owns showing/hiding this over the panel.
32
+ */
33
+ export function ExecutionDetailSheet({
34
+ entry,
35
+ status,
36
+ records,
37
+ error,
38
+ complete,
39
+ onClose,
40
+ onLoadMore,
41
+ onStop,
42
+ }: {
43
+ entry: IExecutionWorkspaceEntry | null;
44
+ status: TExecutionDetailStatus;
45
+ records: readonly IExecutionDetailRecord[];
46
+ error: string | null;
47
+ complete: boolean;
48
+ onClose: () => void;
49
+ onLoadMore: () => void;
50
+ onStop?: () => void;
51
+ }): React.ReactElement | null {
52
+ if (!entry) return null;
53
+ const described = describeExecutionEntryStatus(entry);
54
+ const canStop = onStop !== undefined && entry.controls.includes('cancel');
55
+ const askedLine = entry.headline?.text ?? entry.preview;
56
+
57
+ return (
58
+ <div
59
+ role="dialog"
60
+ aria-label={entry.title}
61
+ onKeyDown={(event) => {
62
+ if (event.key === 'Escape') {
63
+ event.preventDefault();
64
+ onClose();
65
+ }
66
+ }}
67
+ className="agent-ui absolute inset-y-0 right-0 z-30 flex w-96 flex-col overflow-hidden border-l border-subtle/20 bg-background shadow-2xl shadow-black/40"
68
+ >
69
+ <div className="flex flex-shrink-0 items-center gap-2 border-b border-subtle/15 px-4 py-3">
70
+ <span className="min-w-0 flex-1 truncate text-[14px] font-medium text-foreground">
71
+ {entry.title}
72
+ </span>
73
+ {described && (
74
+ <span
75
+ className={`flex-shrink-0 rounded-md px-1.5 py-px text-[12px] ${STATUS_TAG_TONE[described.tone]}`}
76
+ >
77
+ {described.label}
78
+ </span>
79
+ )}
80
+ {canStop && (
81
+ <button
82
+ type="button"
83
+ aria-label={`Stop ${entry.title}`}
84
+ onClick={onStop}
85
+ className="flex-shrink-0 rounded-md px-1.5 py-px text-[12px] text-subtle hover:bg-destructive/10 hover:text-destructive"
86
+ >
87
+ Stop
88
+ </button>
89
+ )}
90
+ <button
91
+ type="button"
92
+ aria-label="Close"
93
+ aria-keyshortcuts="Escape"
94
+ onClick={onClose}
95
+ className="flex-shrink-0 rounded-md p-1 text-subtle hover:bg-card hover:text-foreground"
96
+ >
97
+ <X size={16} strokeWidth={1.9} aria-hidden="true" />
98
+ </button>
99
+ </div>
100
+
101
+ {askedLine && (
102
+ <p className="flex-shrink-0 border-b border-subtle/15 px-4 py-2.5 text-[13px] leading-snug text-muted-foreground">
103
+ {askedLine}
104
+ </p>
105
+ )}
106
+ {entry.deniedToolCalls !== undefined && entry.deniedToolCalls > 0 && (
107
+ <p className="flex-shrink-0 px-4 pt-2 text-[12px] text-warning">
108
+ {entry.deniedToolCalls} tool {entry.deniedToolCalls === 1 ? 'call was' : 'calls were'} refused.
109
+ </p>
110
+ )}
111
+
112
+ <div className="flex-1 space-y-2 overflow-y-auto px-4 py-3">
113
+ {records.map((record) => (
114
+ <div key={record.id} className="text-[13px] leading-relaxed text-foreground">
115
+ {RECORD_KIND_LABEL[record.kind] && (
116
+ <span className="mr-1.5 text-[11px] font-medium uppercase tracking-wide text-subtle">
117
+ {RECORD_KIND_LABEL[record.kind]}
118
+ </span>
119
+ )}
120
+ <span className="whitespace-pre-wrap break-words">{record.text}</span>
121
+ </div>
122
+ ))}
123
+ {status === 'loading' && records.length === 0 && (
124
+ <p className="text-[13px] text-subtle">Loading…</p>
125
+ )}
126
+ {error && <p className="text-[13px] text-destructive">{error}</p>}
127
+ {!complete && status !== 'loading' && (
128
+ <button
129
+ type="button"
130
+ onClick={onLoadMore}
131
+ className="text-[12px] text-subtle underline hover:text-foreground"
132
+ >
133
+ Load more
134
+ </button>
135
+ )}
136
+ {!complete && status === 'loading' && records.length > 0 && (
137
+ <p className="text-[12px] text-subtle">Loading more…</p>
138
+ )}
139
+ </div>
140
+ </div>
141
+ );
142
+ }
@@ -0,0 +1,104 @@
1
+ 'use client';
2
+
3
+ import { X } from 'lucide-react';
4
+
5
+ import { commandMenuFor } from '../hooks/command-menu.js';
6
+ import { Dialog } from './Dialog.js';
7
+
8
+ import type { TCommandCatalog } from '../hooks/session-client-types.js';
9
+
10
+ /** One keyboard shortcut this surface answers — #3282 §4e. */
11
+ interface IShortcut {
12
+ readonly keys: string;
13
+ readonly description: string;
14
+ }
15
+
16
+ const SHORTCUTS: readonly IShortcut[] = [
17
+ { keys: '⌘,', description: 'Open Settings' },
18
+ { keys: 'Esc', description: 'Close a dialog or menu, or stop the current run' },
19
+ { keys: 'Shift+Tab', description: 'Answer a pending question with the keyboard' },
20
+ { keys: 'Enter', description: 'Send the message' },
21
+ { keys: 'Shift+Enter', description: 'Start a new line' },
22
+ ];
23
+
24
+ /**
25
+ * The GUI's Help sheet (#3282 §4e), a `Dialog` opened by `/help` — client-only: `useSessionClient.ts`'s
26
+ * `send` catches `/help` before it ever reaches the session and opens this instead, so there is no
27
+ * round trip and nothing terminal-style to replace it with. It lists the same commands and skills the
28
+ * `/` menu offers (`commandMenuFor`, unfiltered by any query — an excluded command has no row here
29
+ * either, exactly like the menu), grouped the same way, plus the keyboard shortcuts this surface
30
+ * answers. The TUI keeps its own text `/help` output; this sheet is the GUI's alone.
31
+ */
32
+ export function HelpSheet({
33
+ open,
34
+ onClose,
35
+ catalog,
36
+ }: {
37
+ open: boolean;
38
+ onClose: () => void;
39
+ catalog: TCommandCatalog | null;
40
+ }): React.ReactElement | null {
41
+ const items = commandMenuFor(catalog, '/') ?? [];
42
+ const commands = items.filter((item) => item.kind === 'command');
43
+ const skills = items.filter((item) => item.kind === 'skill');
44
+
45
+ return (
46
+ <Dialog
47
+ open={open}
48
+ onClose={onClose}
49
+ title="Help"
50
+ panelClassName="flex h-[min(600px,85vh)] w-full max-w-lg flex-col overflow-hidden rounded-2xl bg-card text-card-foreground shadow-[0_8px_30px_-12px_rgb(0_0_0/0.45)] focus:outline-none"
51
+ >
52
+ <div className="flex h-12 flex-shrink-0 items-center gap-1 border-b border-border px-3">
53
+ <h2 className="px-1.5 text-[15px] font-semibold text-foreground">Help</h2>
54
+ <button
55
+ type="button"
56
+ aria-label="Close Help"
57
+ onClick={onClose}
58
+ className="ml-auto rounded-md p-1.5 text-muted-foreground hover:bg-hover hover:text-foreground"
59
+ >
60
+ <X size={17} strokeWidth={1.75} />
61
+ </button>
62
+ </div>
63
+
64
+ <div className="min-h-0 flex-1 overflow-y-auto px-5 py-4">
65
+ <section aria-label="Shortcuts">
66
+ <h3 className="text-[11px] font-semibold uppercase tracking-wide text-subtle">Shortcuts</h3>
67
+ <dl className="mt-2 mb-5 space-y-1.5">
68
+ {SHORTCUTS.map((shortcut) => (
69
+ <div key={shortcut.keys} className="flex items-baseline gap-3 text-[13.5px]">
70
+ <dt className="w-24 flex-shrink-0 font-mono text-foreground">{shortcut.keys}</dt>
71
+ <dd className="min-w-0 flex-1 text-muted-foreground">{shortcut.description}</dd>
72
+ </div>
73
+ ))}
74
+ </dl>
75
+ </section>
76
+
77
+ <CommandGroup label="Commands" items={commands} />
78
+ {skills.length > 0 && <CommandGroup label="Skills" items={skills} />}
79
+ </div>
80
+ </Dialog>
81
+ );
82
+ }
83
+
84
+ function CommandGroup({
85
+ label,
86
+ items,
87
+ }: {
88
+ label: 'Commands' | 'Skills';
89
+ items: readonly { name: string; description: string }[];
90
+ }): React.ReactElement {
91
+ return (
92
+ <section aria-label={label} className="mb-5 last:mb-0">
93
+ <h3 className="text-[11px] font-semibold uppercase tracking-wide text-subtle">{label}</h3>
94
+ <ul className="mt-2 space-y-1.5">
95
+ {items.map((item) => (
96
+ <li key={item.name} className="flex items-baseline gap-3 text-[13.5px]">
97
+ <span className="w-24 flex-shrink-0 truncate font-mono text-foreground">/{item.name}</span>
98
+ <span className="min-w-0 flex-1 text-muted-foreground">{item.description}</span>
99
+ </li>
100
+ ))}
101
+ </ul>
102
+ </section>
103
+ );
104
+ }