@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,166 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { Check } from 'lucide-react';
|
|
4
|
+
import { useEffect, useId, useRef, useState } from 'react';
|
|
5
|
+
|
|
6
|
+
/** One choice in a `PopupMenu`. `kind: 'radio'` (the default) is a value the person picks between,
|
|
7
|
+
* shown with a checkmark when it is the current one; `kind: 'action'` runs something instead (e.g.
|
|
8
|
+
* "Manage providers…") and never carries a checked state. */
|
|
9
|
+
export interface IPopupMenuItem {
|
|
10
|
+
readonly key: string;
|
|
11
|
+
readonly label: string;
|
|
12
|
+
readonly description?: string;
|
|
13
|
+
readonly kind?: 'radio' | 'action';
|
|
14
|
+
readonly checked?: boolean;
|
|
15
|
+
readonly destructive?: boolean;
|
|
16
|
+
readonly onSelect: () => void;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface IPopupMenuSection {
|
|
20
|
+
readonly heading?: string;
|
|
21
|
+
readonly items: readonly IPopupMenuItem[];
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* A HIG pop-up menu (#3282 §2): `role="menu"`, its choices as `menuitemradio` with `aria-checked`
|
|
26
|
+
* (or `menuitem` for a plain action, e.g. "Manage providers…"), grouped into optional labelled
|
|
27
|
+
* sections. Arrow keys move a roving focus, Enter/Space activates the focused item, and Esc — or a
|
|
28
|
+
* click outside — closes the menu and returns focus to the trigger that opened it, so the person
|
|
29
|
+
* never loses their place. Initial focus lands on the current (checked) item, matching a native
|
|
30
|
+
* pop-up menu; nothing else is pre-highlighted beyond that same focus ring.
|
|
31
|
+
*/
|
|
32
|
+
export function PopupMenu({
|
|
33
|
+
label,
|
|
34
|
+
sections,
|
|
35
|
+
onClose,
|
|
36
|
+
triggerRef,
|
|
37
|
+
}: {
|
|
38
|
+
label: string;
|
|
39
|
+
sections: readonly IPopupMenuSection[];
|
|
40
|
+
onClose: () => void;
|
|
41
|
+
triggerRef: React.RefObject<HTMLElement | null>;
|
|
42
|
+
}): React.ReactElement {
|
|
43
|
+
const items = sections.flatMap((section) => section.items);
|
|
44
|
+
const idPrefix = useId();
|
|
45
|
+
const containerRef = useRef<HTMLDivElement>(null);
|
|
46
|
+
const itemRefs = useRef<(HTMLButtonElement | null)[]>([]);
|
|
47
|
+
const [focusedIndex, setFocusedIndex] = useState(() => {
|
|
48
|
+
const current = items.findIndex((item) => item.checked);
|
|
49
|
+
return current >= 0 ? current : 0;
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
const close = (returnFocus: boolean): void => {
|
|
53
|
+
onClose();
|
|
54
|
+
if (returnFocus) triggerRef.current?.focus();
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
useEffect(() => {
|
|
58
|
+
itemRefs.current[focusedIndex]?.focus();
|
|
59
|
+
}, [focusedIndex]);
|
|
60
|
+
|
|
61
|
+
useEffect(() => {
|
|
62
|
+
const onPointerDown = (event: MouseEvent): void => {
|
|
63
|
+
const target = event.target as Node;
|
|
64
|
+
if (containerRef.current?.contains(target)) return;
|
|
65
|
+
if (triggerRef.current?.contains(target)) return;
|
|
66
|
+
close(false);
|
|
67
|
+
};
|
|
68
|
+
document.addEventListener('mousedown', onPointerDown);
|
|
69
|
+
return () => document.removeEventListener('mousedown', onPointerDown);
|
|
70
|
+
// Registered once (empty deps): `onClose`/`triggerRef` are stable for the life of an open menu —
|
|
71
|
+
// it is remounted fresh (`{open ? <PopupMenu .../> : null}`) rather than kept open across a prop
|
|
72
|
+
// change that would replace them.
|
|
73
|
+
}, []);
|
|
74
|
+
|
|
75
|
+
const onKeyDown = (event: React.KeyboardEvent<HTMLDivElement>): void => {
|
|
76
|
+
if (event.key === 'Escape') {
|
|
77
|
+
event.preventDefault();
|
|
78
|
+
close(true);
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
|
|
82
|
+
event.preventDefault();
|
|
83
|
+
if (items.length === 0) return;
|
|
84
|
+
const step = event.key === 'ArrowDown' ? 1 : -1;
|
|
85
|
+
setFocusedIndex((index) => (index + step + items.length) % items.length);
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
if (event.key === 'Enter' || event.key === ' ') {
|
|
89
|
+
event.preventDefault();
|
|
90
|
+
const item = items[focusedIndex];
|
|
91
|
+
if (item) {
|
|
92
|
+
item.onSelect();
|
|
93
|
+
close(true);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
let cursor = -1;
|
|
99
|
+
return (
|
|
100
|
+
<div
|
|
101
|
+
ref={containerRef}
|
|
102
|
+
role="menu"
|
|
103
|
+
aria-label={label}
|
|
104
|
+
onKeyDown={onKeyDown}
|
|
105
|
+
className="gui-rise absolute bottom-full z-30 mb-2 max-h-[360px] w-64 overflow-y-auto rounded-2xl bg-popover p-1.5 shadow-2xl shadow-black/35 focus:outline-none"
|
|
106
|
+
>
|
|
107
|
+
{sections.map((section, sectionIndex) => (
|
|
108
|
+
<div key={section.heading ?? sectionIndex} role="group" aria-label={section.heading}>
|
|
109
|
+
{section.heading ? (
|
|
110
|
+
<div
|
|
111
|
+
role="presentation"
|
|
112
|
+
className="px-3 pb-1 pt-2 text-[11px] font-medium uppercase tracking-wide text-subtle"
|
|
113
|
+
>
|
|
114
|
+
{section.heading}
|
|
115
|
+
</div>
|
|
116
|
+
) : null}
|
|
117
|
+
{section.items.map((item) => {
|
|
118
|
+
cursor += 1;
|
|
119
|
+
const index = cursor;
|
|
120
|
+
const isAction = item.kind === 'action';
|
|
121
|
+
// The accessible NAME is the label alone (`aria-label`) — otherwise a screen reader would
|
|
122
|
+
// read the description as part of it (button content is used as the name by default). The
|
|
123
|
+
// description is still announced, as a `aria-describedby` description instead.
|
|
124
|
+
const descId = item.description ? `${idPrefix}-desc-${item.key}` : undefined;
|
|
125
|
+
return (
|
|
126
|
+
<button
|
|
127
|
+
key={item.key}
|
|
128
|
+
ref={(node) => {
|
|
129
|
+
itemRefs.current[index] = node;
|
|
130
|
+
}}
|
|
131
|
+
type="button"
|
|
132
|
+
role={isAction ? 'menuitem' : 'menuitemradio'}
|
|
133
|
+
aria-checked={isAction ? undefined : item.checked === true}
|
|
134
|
+
aria-label={item.label}
|
|
135
|
+
aria-describedby={descId}
|
|
136
|
+
tabIndex={index === focusedIndex ? 0 : -1}
|
|
137
|
+
onFocus={() => setFocusedIndex(index)}
|
|
138
|
+
onClick={() => {
|
|
139
|
+
item.onSelect();
|
|
140
|
+
close(true);
|
|
141
|
+
}}
|
|
142
|
+
className={`flex w-full items-start gap-2 rounded-xl px-3 py-2 text-left text-[13.5px] ${
|
|
143
|
+
item.destructive ? 'text-destructive hover:bg-destructive/10' : 'text-foreground hover:bg-hover'
|
|
144
|
+
} ${index === focusedIndex ? 'bg-hover' : ''}`}
|
|
145
|
+
>
|
|
146
|
+
{isAction ? null : (
|
|
147
|
+
<span className="mt-0.5 flex h-4 w-4 flex-shrink-0 items-center justify-center">
|
|
148
|
+
{item.checked ? <Check size={14} strokeWidth={2.25} aria-hidden="true" /> : null}
|
|
149
|
+
</span>
|
|
150
|
+
)}
|
|
151
|
+
<span className="min-w-0 flex-1">
|
|
152
|
+
<span className="block truncate">{item.label}</span>
|
|
153
|
+
{item.description ? (
|
|
154
|
+
<span id={descId} className="block truncate text-[12px] text-muted-foreground">
|
|
155
|
+
{item.description}
|
|
156
|
+
</span>
|
|
157
|
+
) : null}
|
|
158
|
+
</span>
|
|
159
|
+
</button>
|
|
160
|
+
);
|
|
161
|
+
})}
|
|
162
|
+
</div>
|
|
163
|
+
))}
|
|
164
|
+
</div>
|
|
165
|
+
);
|
|
166
|
+
}
|
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { ChevronRight, ExternalLink, GitBranch, LoaderCircle, RefreshCw } from 'lucide-react';
|
|
4
|
+
import React, { useEffect, useState } from 'react';
|
|
5
|
+
|
|
6
|
+
import { DiffLines } from './DiffLines.js';
|
|
7
|
+
|
|
8
|
+
import type {
|
|
9
|
+
IWsSessionState,
|
|
10
|
+
TProjectDiffRead,
|
|
11
|
+
TProjectMemoryRead,
|
|
12
|
+
TProjectStatusRead,
|
|
13
|
+
} from '../hooks/session-client-types.js';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* #3282 §4c — the Project panel: git status (Changes), one file's diff on click, and project memory as
|
|
17
|
+
* readable text. The owner's design principle 4 (#3277): everything the host implements is usable from
|
|
18
|
+
* the GUI through organized screens — this is git status/diff and project memory's screen. Checkpoints
|
|
19
|
+
* (rewind) are deliberately absent (see `agent-gui-web/docs/SPEC.md`'s design decisions): edit
|
|
20
|
+
* checkpoints only work on a host that can prove a write stays inside the project
|
|
21
|
+
* (`supportsWorkspaceProjectMutation`, Linux-only by design, not a switched-off setting), so there is
|
|
22
|
+
* nothing safe to show here on macOS or Windows.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
type TProjectStatusFile = Extract<TProjectStatusRead, { kind: 'status' }>['files'][number];
|
|
26
|
+
|
|
27
|
+
const STATUS_TONE: Readonly<Record<TProjectStatusFile['status'], string>> = {
|
|
28
|
+
Added: 'text-accent',
|
|
29
|
+
Modified: 'text-warning',
|
|
30
|
+
Deleted: 'text-destructive',
|
|
31
|
+
Renamed: 'text-muted-foreground',
|
|
32
|
+
Copied: 'text-muted-foreground',
|
|
33
|
+
Untracked: 'text-subtle',
|
|
34
|
+
Conflicted: 'text-destructive',
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
function StatusRow({
|
|
38
|
+
file,
|
|
39
|
+
open,
|
|
40
|
+
onOpen,
|
|
41
|
+
}: {
|
|
42
|
+
file: TProjectStatusFile;
|
|
43
|
+
open: boolean;
|
|
44
|
+
onOpen: () => void;
|
|
45
|
+
}): React.ReactElement {
|
|
46
|
+
return (
|
|
47
|
+
<button
|
|
48
|
+
type="button"
|
|
49
|
+
aria-expanded={open}
|
|
50
|
+
onClick={onOpen}
|
|
51
|
+
className="flex min-w-0 items-center gap-2 rounded-md px-1.5 py-1.5 text-left hover:bg-hover"
|
|
52
|
+
>
|
|
53
|
+
<ChevronRight
|
|
54
|
+
size={13}
|
|
55
|
+
className={`flex-shrink-0 text-subtle transition-transform ${open ? 'rotate-90' : ''}`}
|
|
56
|
+
/>
|
|
57
|
+
<span
|
|
58
|
+
className={`w-[76px] flex-shrink-0 text-[12px] font-medium ${STATUS_TONE[file.status]}`}
|
|
59
|
+
>
|
|
60
|
+
{file.status}
|
|
61
|
+
</span>
|
|
62
|
+
<span className="min-w-0 flex-1 truncate break-all font-mono text-[12.5px] text-foreground" title={file.path}>
|
|
63
|
+
{file.path}
|
|
64
|
+
</span>
|
|
65
|
+
{typeof file.added === 'number' && (
|
|
66
|
+
<span className="flex-shrink-0 font-mono text-[12px] text-accent">+{file.added}</span>
|
|
67
|
+
)}
|
|
68
|
+
{typeof file.removed === 'number' && (
|
|
69
|
+
<span className="flex-shrink-0 font-mono text-[12px] text-destructive">-{file.removed}</span>
|
|
70
|
+
)}
|
|
71
|
+
</button>
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function ChangesSection({
|
|
76
|
+
status,
|
|
77
|
+
statusState,
|
|
78
|
+
onRefresh,
|
|
79
|
+
openPath,
|
|
80
|
+
onSelectFile,
|
|
81
|
+
diff,
|
|
82
|
+
diffState,
|
|
83
|
+
diffPath,
|
|
84
|
+
}: {
|
|
85
|
+
status: TProjectStatusRead | null;
|
|
86
|
+
statusState: IWsSessionState['projectStatusState'];
|
|
87
|
+
onRefresh: () => void;
|
|
88
|
+
openPath: string | null;
|
|
89
|
+
onSelectFile: (path: string) => void;
|
|
90
|
+
diff: TProjectDiffRead | null;
|
|
91
|
+
diffState: IWsSessionState['projectDiffState'];
|
|
92
|
+
diffPath: string | null;
|
|
93
|
+
}): React.ReactElement {
|
|
94
|
+
return (
|
|
95
|
+
<div className="flex flex-col gap-1.5 rounded-xl bg-card px-2 py-2">
|
|
96
|
+
<div className="flex items-center gap-2 px-1">
|
|
97
|
+
<p className="flex-1 text-[12px] font-medium text-muted-foreground">
|
|
98
|
+
{status?.kind === 'status' && status.branch ? `Changes — ${status.branch}` : 'Changes'}
|
|
99
|
+
</p>
|
|
100
|
+
<button
|
|
101
|
+
type="button"
|
|
102
|
+
onClick={onRefresh}
|
|
103
|
+
disabled={statusState === 'loading'}
|
|
104
|
+
aria-label="Refresh"
|
|
105
|
+
className="flex items-center gap-1 rounded-md px-1.5 py-0.5 text-[12px] text-subtle hover:bg-hover hover:text-foreground disabled:opacity-60"
|
|
106
|
+
>
|
|
107
|
+
<RefreshCw size={12} className={statusState === 'loading' ? 'animate-spin' : ''} />
|
|
108
|
+
Refresh
|
|
109
|
+
</button>
|
|
110
|
+
</div>
|
|
111
|
+
|
|
112
|
+
{statusState === 'loading' && status === null && (
|
|
113
|
+
<p className="px-1 py-2 text-[13px] text-muted-foreground">Reading git status…</p>
|
|
114
|
+
)}
|
|
115
|
+
{statusState === 'error' && (
|
|
116
|
+
<p className="px-1 py-2 text-[13px] text-destructive">Could not read git status.</p>
|
|
117
|
+
)}
|
|
118
|
+
{status?.kind === 'not-a-repository' && (
|
|
119
|
+
<p className="px-1 py-2 text-[13px] text-muted-foreground">This folder is not a git repository.</p>
|
|
120
|
+
)}
|
|
121
|
+
{status?.kind === 'failed' && <p className="px-1 py-2 text-[13px] text-destructive">{status.message}</p>}
|
|
122
|
+
{status?.kind === 'status' && status.files.length === 0 && (
|
|
123
|
+
<p className="px-1 py-2 text-[13px] text-muted-foreground">No changes.</p>
|
|
124
|
+
)}
|
|
125
|
+
{status?.kind === 'status' &&
|
|
126
|
+
status.files.map((file) => (
|
|
127
|
+
<div key={file.path} className="flex flex-col">
|
|
128
|
+
<StatusRow file={file} open={openPath === file.path} onOpen={() => onSelectFile(file.path)} />
|
|
129
|
+
{openPath === file.path && (
|
|
130
|
+
<div className="pl-6">
|
|
131
|
+
{diffState === 'loading' && diffPath === file.path && (
|
|
132
|
+
<p className="py-1.5 text-[12.5px] text-muted-foreground">Reading diff…</p>
|
|
133
|
+
)}
|
|
134
|
+
{diff && diffPath === file.path && diff.kind === 'diff' && diff.diffLines.length === 0 && (
|
|
135
|
+
<p className="py-1.5 text-[12.5px] text-muted-foreground">No differences.</p>
|
|
136
|
+
)}
|
|
137
|
+
{diff && diffPath === file.path && diff.kind === 'diff' && diff.diffLines.length > 0 && (
|
|
138
|
+
<>
|
|
139
|
+
<DiffLines diffLines={diff.diffLines} />
|
|
140
|
+
{diff.truncated && (
|
|
141
|
+
<p className="py-1 text-[12px] text-subtle">The diff was too long and was cut short.</p>
|
|
142
|
+
)}
|
|
143
|
+
</>
|
|
144
|
+
)}
|
|
145
|
+
{diff && diffPath === file.path && diff.kind === 'failed' && (
|
|
146
|
+
<p className="py-1.5 text-[12.5px] text-destructive">{diff.message}</p>
|
|
147
|
+
)}
|
|
148
|
+
{diff && diffPath === file.path && diff.kind === 'outside-workspace' && (
|
|
149
|
+
<p className="py-1.5 text-[12.5px] text-destructive">
|
|
150
|
+
That path is outside the workspace.
|
|
151
|
+
</p>
|
|
152
|
+
)}
|
|
153
|
+
{diff && diffPath === file.path && diff.kind === 'not-a-repository' && (
|
|
154
|
+
<p className="py-1.5 text-[12.5px] text-muted-foreground">
|
|
155
|
+
This folder is not a git repository.
|
|
156
|
+
</p>
|
|
157
|
+
)}
|
|
158
|
+
</div>
|
|
159
|
+
)}
|
|
160
|
+
</div>
|
|
161
|
+
))}
|
|
162
|
+
{status?.kind === 'status' && status.truncated && (
|
|
163
|
+
<p className="px-1 pt-1 text-[12px] text-subtle">Showing the first files only — there are more.</p>
|
|
164
|
+
)}
|
|
165
|
+
</div>
|
|
166
|
+
);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function MemorySection({
|
|
170
|
+
memory,
|
|
171
|
+
memoryState,
|
|
172
|
+
onOpenInEditor,
|
|
173
|
+
}: {
|
|
174
|
+
memory: TProjectMemoryRead | null;
|
|
175
|
+
memoryState: IWsSessionState['projectMemoryState'];
|
|
176
|
+
onOpenInEditor?: (path: string) => void;
|
|
177
|
+
}): React.ReactElement {
|
|
178
|
+
return (
|
|
179
|
+
<div className="flex flex-col gap-1.5 rounded-xl bg-card px-2 py-2">
|
|
180
|
+
<div className="flex items-center gap-2 px-1">
|
|
181
|
+
<p className="flex-1 text-[12px] font-medium text-muted-foreground">Memory</p>
|
|
182
|
+
{memory?.kind === 'memory' && onOpenInEditor && (
|
|
183
|
+
<button
|
|
184
|
+
type="button"
|
|
185
|
+
onClick={() => onOpenInEditor(memory.path)}
|
|
186
|
+
className="flex items-center gap-1 rounded-md px-1.5 py-0.5 text-[12px] text-subtle hover:bg-hover hover:text-foreground"
|
|
187
|
+
>
|
|
188
|
+
<ExternalLink size={12} />
|
|
189
|
+
Open in editor
|
|
190
|
+
</button>
|
|
191
|
+
)}
|
|
192
|
+
</div>
|
|
193
|
+
{memoryState === 'loading' && memory === null && (
|
|
194
|
+
<p className="px-1 py-1 text-[13px] text-muted-foreground">Reading project memory…</p>
|
|
195
|
+
)}
|
|
196
|
+
{memory?.kind === 'unavailable' && (
|
|
197
|
+
<p className="px-1 py-1 text-[13px] text-muted-foreground">{memory.message}</p>
|
|
198
|
+
)}
|
|
199
|
+
{memory?.kind === 'memory' && (
|
|
200
|
+
<>
|
|
201
|
+
<pre className="max-h-64 overflow-y-auto whitespace-pre-wrap break-words rounded-lg bg-sidebar px-3 py-2 font-mono text-[12.5px] leading-relaxed text-foreground">
|
|
202
|
+
{memory.content.length > 0 ? memory.content : '(empty)'}
|
|
203
|
+
</pre>
|
|
204
|
+
{memory.truncated && (
|
|
205
|
+
<p className="px-1 pt-1 text-[12px] text-subtle">The memory was too long and was cut short.</p>
|
|
206
|
+
)}
|
|
207
|
+
</>
|
|
208
|
+
)}
|
|
209
|
+
</div>
|
|
210
|
+
);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
export function ProjectPanel({
|
|
214
|
+
state,
|
|
215
|
+
className,
|
|
216
|
+
onOpenMemoryInEditor,
|
|
217
|
+
}: {
|
|
218
|
+
state: IWsSessionState;
|
|
219
|
+
className?: string;
|
|
220
|
+
/** Present only when the host can open a file in an external editor (the desktop app). */
|
|
221
|
+
onOpenMemoryInEditor?: (path: string) => void;
|
|
222
|
+
}): React.ReactElement {
|
|
223
|
+
useEffect(() => {
|
|
224
|
+
state.requestProjectStatus();
|
|
225
|
+
state.requestProjectMemory();
|
|
226
|
+
}, [state.requestProjectStatus, state.requestProjectMemory]);
|
|
227
|
+
|
|
228
|
+
// Which row is expanded — local, and independent from `projectDiffPath` (what the LAST diff
|
|
229
|
+
// request/response concerns): closing a row never needs a request, and reopening a different one
|
|
230
|
+
// must not show the previous file's diff while its own is still loading.
|
|
231
|
+
const [openPath, setOpenPath] = useState<string | null>(null);
|
|
232
|
+
|
|
233
|
+
return (
|
|
234
|
+
<div className={`agent-ui flex flex-col overflow-hidden ${className ?? ''}`}>
|
|
235
|
+
<div className="flex h-12 flex-shrink-0 items-center gap-2 px-4">
|
|
236
|
+
<GitBranch size={15} className="text-subtle" />
|
|
237
|
+
<span className="text-[14px] font-medium text-foreground">Project</span>
|
|
238
|
+
{state.projectStatusState === 'loading' && (
|
|
239
|
+
<LoaderCircle size={13} className="ml-auto animate-spin text-subtle" aria-label="loading" />
|
|
240
|
+
)}
|
|
241
|
+
</div>
|
|
242
|
+
<div className="flex-1 space-y-2 overflow-y-auto px-3 pb-3">
|
|
243
|
+
<ChangesSection
|
|
244
|
+
status={state.projectStatus}
|
|
245
|
+
statusState={state.projectStatusState}
|
|
246
|
+
onRefresh={() => state.requestProjectStatus()}
|
|
247
|
+
openPath={openPath}
|
|
248
|
+
onSelectFile={(path) => {
|
|
249
|
+
if (openPath === path) {
|
|
250
|
+
setOpenPath(null);
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
253
|
+
setOpenPath(path);
|
|
254
|
+
state.requestProjectDiff(path);
|
|
255
|
+
}}
|
|
256
|
+
diff={state.projectDiffPath === openPath ? state.projectDiff : null}
|
|
257
|
+
diffState={state.projectDiffPath === openPath ? state.projectDiffState : 'loading'}
|
|
258
|
+
diffPath={state.projectDiffPath}
|
|
259
|
+
/>
|
|
260
|
+
<MemorySection
|
|
261
|
+
memory={state.projectMemory}
|
|
262
|
+
memoryState={state.projectMemoryState}
|
|
263
|
+
onOpenInEditor={onOpenMemoryInEditor}
|
|
264
|
+
/>
|
|
265
|
+
</div>
|
|
266
|
+
</div>
|
|
267
|
+
);
|
|
268
|
+
}
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useProductIdentity } from '../product-identity.js';
|
|
4
|
+
|
|
5
|
+
import React, { useState } from 'react';
|
|
6
|
+
|
|
7
|
+
import { useWsSession } from '../hooks/useSessionClient.js';
|
|
8
|
+
|
|
9
|
+
import { AgentActivityPanel } from './AgentActivityPanel.js';
|
|
10
|
+
import { ConversationView } from './ConversationView.js';
|
|
11
|
+
|
|
12
|
+
const STATUS_CONFIG: Record<string, { label: string; dot: string; text: string; glow: string }> = {
|
|
13
|
+
connected: {
|
|
14
|
+
label: 'Connected',
|
|
15
|
+
dot: 'bg-accent',
|
|
16
|
+
text: 'text-muted-foreground',
|
|
17
|
+
glow: 'status-glow',
|
|
18
|
+
},
|
|
19
|
+
connecting: {
|
|
20
|
+
label: 'Connecting…',
|
|
21
|
+
dot: 'bg-warning animate-pulse',
|
|
22
|
+
text: 'text-warning',
|
|
23
|
+
glow: '',
|
|
24
|
+
},
|
|
25
|
+
disconnected: { label: 'Disconnected', dot: 'bg-subtle', text: 'text-subtle', glow: '' },
|
|
26
|
+
error: { label: 'Error', dot: 'bg-destructive', text: 'text-destructive', glow: '' },
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
interface ISessionMonitorProps {
|
|
30
|
+
/** WebSocket URL. Injected by the HTTP server via <meta name="ws-url">. */
|
|
31
|
+
wsUrl: string;
|
|
32
|
+
className?: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function SessionMonitor({ wsUrl, className }: ISessionMonitorProps): React.ReactElement {
|
|
36
|
+
const { identity } = useProductIdentity();
|
|
37
|
+
const [url, setUrl] = useState(wsUrl);
|
|
38
|
+
const [inputUrl, setInputUrl] = useState(wsUrl);
|
|
39
|
+
|
|
40
|
+
const {
|
|
41
|
+
status,
|
|
42
|
+
messages,
|
|
43
|
+
activeTools,
|
|
44
|
+
streamingText,
|
|
45
|
+
isThinking,
|
|
46
|
+
executionWorkspace,
|
|
47
|
+
ownDriverId,
|
|
48
|
+
send,
|
|
49
|
+
} = useWsSession(url);
|
|
50
|
+
const cfg = STATUS_CONFIG[status] ?? STATUS_CONFIG.disconnected;
|
|
51
|
+
|
|
52
|
+
const backgroundTasks =
|
|
53
|
+
executionWorkspace?.entries.filter(
|
|
54
|
+
(e) => e.kind === 'background_task' && e.visibility !== 'collapsed',
|
|
55
|
+
) ?? [];
|
|
56
|
+
const hasAgents = backgroundTasks.length > 0;
|
|
57
|
+
|
|
58
|
+
return (
|
|
59
|
+
<div className={`agent-ui flex flex-col h-full overflow-hidden bg-background ${className ?? ''}`}>
|
|
60
|
+
{/* Header */}
|
|
61
|
+
<div className="flex h-12 flex-shrink-0 items-center gap-3 px-5">
|
|
62
|
+
<div className="flex items-center gap-2.5">
|
|
63
|
+
<span className={`h-2 w-2 rounded-full flex-shrink-0 ${cfg.dot} ${cfg.glow}`} />
|
|
64
|
+
<span className="text-[15px] font-semibold text-foreground">CLI monitor</span>
|
|
65
|
+
<span role="status" className={`text-[13px] ${cfg.text}`}>
|
|
66
|
+
{cfg.label}
|
|
67
|
+
</span>
|
|
68
|
+
</div>
|
|
69
|
+
<div className="ml-auto flex items-center gap-2">
|
|
70
|
+
<input
|
|
71
|
+
aria-label="WebSocket URL"
|
|
72
|
+
className="h-8 w-60 rounded-lg bg-raised px-3 font-mono text-[12.5px] text-foreground placeholder:text-subtle focus:outline-none"
|
|
73
|
+
value={inputUrl}
|
|
74
|
+
onChange={(e) => setInputUrl(e.target.value)}
|
|
75
|
+
onKeyDown={(e) => {
|
|
76
|
+
if (e.key === 'Enter') setUrl(inputUrl);
|
|
77
|
+
}}
|
|
78
|
+
placeholder="ws://localhost:7070"
|
|
79
|
+
/>
|
|
80
|
+
<button
|
|
81
|
+
className="h-8 rounded-lg bg-primary px-3.5 text-[13px] font-medium text-primary-foreground hover:opacity-90"
|
|
82
|
+
onClick={() => setUrl(inputUrl)}
|
|
83
|
+
>
|
|
84
|
+
Connect
|
|
85
|
+
</button>
|
|
86
|
+
</div>
|
|
87
|
+
</div>
|
|
88
|
+
|
|
89
|
+
{/* Main content: conversation + optional agent panel */}
|
|
90
|
+
<div className="flex flex-1 overflow-hidden">
|
|
91
|
+
{/* Left: conversation */}
|
|
92
|
+
<div
|
|
93
|
+
className={`flex flex-col overflow-hidden min-w-0 ${hasAgents ? 'flex-[2]' : 'flex-1'}`}
|
|
94
|
+
>
|
|
95
|
+
<div className="flex-1 overflow-hidden">
|
|
96
|
+
{status === 'connected' ? (
|
|
97
|
+
<ConversationView
|
|
98
|
+
messages={messages}
|
|
99
|
+
activeTools={activeTools}
|
|
100
|
+
streamingText={streamingText}
|
|
101
|
+
isThinking={isThinking}
|
|
102
|
+
ownDriverId={ownDriverId}
|
|
103
|
+
/>
|
|
104
|
+
) : (
|
|
105
|
+
// #3289 §3 review: this renders in the same slot as `ConversationView` (never
|
|
106
|
+
// together), so it carries this page's `main` landmark here too — nothing above this
|
|
107
|
+
// slot supplies one.
|
|
108
|
+
<main className="flex h-full items-center justify-center" aria-label="Conversation">
|
|
109
|
+
<div className="flex flex-col items-center gap-3 text-center px-8">
|
|
110
|
+
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-card">
|
|
111
|
+
<span className={`h-2.5 w-2.5 rounded-full ${cfg.dot}`} />
|
|
112
|
+
</div>
|
|
113
|
+
<p className="max-w-[320px] text-[15px] leading-relaxed text-muted-foreground">
|
|
114
|
+
{status === 'connecting'
|
|
115
|
+
? `Connecting to ${url}…`
|
|
116
|
+
: status === 'error'
|
|
117
|
+
? `Connection error — could not reach ${url}. Check the CLI is running and the URL is correct.`
|
|
118
|
+
: `Run ${identity.cliName} to start the CLI (WS transport starts automatically).`}
|
|
119
|
+
</p>
|
|
120
|
+
</div>
|
|
121
|
+
</main>
|
|
122
|
+
)}
|
|
123
|
+
</div>
|
|
124
|
+
|
|
125
|
+
<SessionInput
|
|
126
|
+
enabled={status === 'connected'}
|
|
127
|
+
onSubmit={(prompt) => send({ type: 'submit', prompt })}
|
|
128
|
+
/>
|
|
129
|
+
</div>
|
|
130
|
+
|
|
131
|
+
{/* Right: agent activity panel (conditional) */}
|
|
132
|
+
{hasAgents && <AgentActivityPanel tasks={backgroundTasks} className="flex-1 bg-sidebar" />}
|
|
133
|
+
</div>
|
|
134
|
+
</div>
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function SessionInput({
|
|
139
|
+
enabled,
|
|
140
|
+
onSubmit,
|
|
141
|
+
}: {
|
|
142
|
+
enabled: boolean;
|
|
143
|
+
onSubmit: (prompt: string) => void;
|
|
144
|
+
}): React.ReactElement {
|
|
145
|
+
const [value, setValue] = useState('');
|
|
146
|
+
|
|
147
|
+
const handleSubmit = (): void => {
|
|
148
|
+
const trimmed = value.trim();
|
|
149
|
+
if (!trimmed) return;
|
|
150
|
+
onSubmit(trimmed);
|
|
151
|
+
setValue('');
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
return (
|
|
155
|
+
<div className="mx-auto flex w-full max-w-[760px] flex-shrink-0 items-end gap-2 px-6 pb-4">
|
|
156
|
+
<textarea
|
|
157
|
+
aria-label="Message"
|
|
158
|
+
className="max-h-[160px] min-h-[48px] flex-1 resize-none rounded-[22px] bg-card px-4 py-3 text-[15px] leading-relaxed text-foreground placeholder:text-subtle [field-sizing:content] focus:outline-none disabled:opacity-60"
|
|
159
|
+
rows={1}
|
|
160
|
+
placeholder={enabled ? 'Send a message…' : 'Connect to send messages'}
|
|
161
|
+
disabled={!enabled}
|
|
162
|
+
value={value}
|
|
163
|
+
onChange={(e) => setValue(e.target.value)}
|
|
164
|
+
onKeyDown={(e) => {
|
|
165
|
+
if (e.key === 'Enter' && !e.shiftKey) {
|
|
166
|
+
e.preventDefault();
|
|
167
|
+
handleSubmit();
|
|
168
|
+
}
|
|
169
|
+
}}
|
|
170
|
+
/>
|
|
171
|
+
<button
|
|
172
|
+
className="h-12 rounded-[22px] bg-primary px-5 text-[14px] font-medium text-primary-foreground hover:opacity-90 disabled:bg-raised disabled:text-subtle"
|
|
173
|
+
disabled={!enabled || !value.trim()}
|
|
174
|
+
onClick={handleSubmit}
|
|
175
|
+
>
|
|
176
|
+
Send
|
|
177
|
+
</button>
|
|
178
|
+
</div>
|
|
179
|
+
);
|
|
180
|
+
}
|