@robota-sdk/agent-ui-web 0.0.0-stage → 3.0.0-beta.101
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 +557 -0
- package/LICENSE +661 -0
- package/README.md +19 -2
- package/dist/browser/index.d.ts +736 -0
- package/dist/browser/index.d.ts.map +1 -0
- package/dist/browser/index.js +8525 -0
- package/dist/browser/index.js.map +1 -0
- package/dist/node/index.cjs +8575 -0
- package/dist/node/index.d.cts +736 -0
- package/dist/node/index.d.cts.map +1 -0
- package/dist/node/index.d.ts +736 -0
- package/dist/node/index.d.ts.map +1 -0
- package/dist/node/index.js +8525 -0
- package/dist/node/index.js.map +1 -0
- package/docs/README.md +62 -0
- package/docs/SPEC.md +130 -0
- package/package.json +99 -3
- package/src/app-view/bridge.ts +217 -0
- package/src/app-view/call-tool-result.ts +58 -0
- package/src/app-view/host-context.ts +37 -0
- package/src/app-view/types.ts +12 -0
- 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/AppView.tsx +147 -0
- package/src/components/Brand.tsx +33 -0
- package/src/components/Composer.tsx +685 -0
- package/src/components/ConversationView.tsx +789 -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 +762 -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 +482 -0
- package/src/components/SessionSurfaceChrome.tsx +319 -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 +95 -0
- package/src/hooks/session-client-types.ts +344 -0
- package/src/hooks/ui-intent-state.ts +74 -0
- package/src/hooks/use-agent-switcher-state.ts +125 -0
- package/src/hooks/use-app-view-requests.ts +178 -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 +1051 -0
- package/src/index.ts +72 -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,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
|
+
}
|