@sudodevstudio/astro-ai 0.1.0
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/LICENSE +21 -0
- package/README.md +114 -0
- package/dist/agent/agent-fallback.d.ts +50 -0
- package/dist/agent/agent-fallback.d.ts.map +1 -0
- package/dist/agent/agent-fallback.js +31 -0
- package/dist/agent/agent-fallback.js.map +1 -0
- package/dist/agent/cli-agent-fallback.d.ts +79 -0
- package/dist/agent/cli-agent-fallback.d.ts.map +1 -0
- package/dist/agent/cli-agent-fallback.js +1056 -0
- package/dist/agent/cli-agent-fallback.js.map +1 -0
- package/dist/integration/index.d.ts +28 -0
- package/dist/integration/index.d.ts.map +1 -0
- package/dist/integration/index.js +389 -0
- package/dist/integration/index.js.map +1 -0
- package/dist/resolver/astro-resolver.d.ts +28 -0
- package/dist/resolver/astro-resolver.d.ts.map +1 -0
- package/dist/resolver/astro-resolver.js +835 -0
- package/dist/resolver/astro-resolver.js.map +1 -0
- package/dist/resolver/types.d.ts +39 -0
- package/dist/resolver/types.d.ts.map +1 -0
- package/dist/resolver/types.js +2 -0
- package/dist/resolver/types.js.map +1 -0
- package/dist/shared/protocol.d.ts +150 -0
- package/dist/shared/protocol.d.ts.map +1 -0
- package/dist/shared/protocol.js +28 -0
- package/dist/shared/protocol.js.map +1 -0
- package/dist/shared/selection-context.d.ts +75 -0
- package/dist/shared/selection-context.d.ts.map +1 -0
- package/dist/shared/selection-context.js +2 -0
- package/dist/shared/selection-context.js.map +1 -0
- package/dist/shared/visual-components.d.ts +15 -0
- package/dist/shared/visual-components.d.ts.map +1 -0
- package/dist/shared/visual-components.js +2 -0
- package/dist/shared/visual-components.js.map +1 -0
- package/dist/toolbar/action-model.d.ts +20 -0
- package/dist/toolbar/action-model.d.ts.map +1 -0
- package/dist/toolbar/action-model.js +49 -0
- package/dist/toolbar/action-model.js.map +1 -0
- package/dist/toolbar/app.d.ts +3 -0
- package/dist/toolbar/app.d.ts.map +1 -0
- package/dist/toolbar/app.js +274 -0
- package/dist/toolbar/app.js.map +1 -0
- package/dist/toolbar/chat-drawer.d.ts +111 -0
- package/dist/toolbar/chat-drawer.d.ts.map +1 -0
- package/dist/toolbar/chat-drawer.js +1575 -0
- package/dist/toolbar/chat-drawer.js.map +1 -0
- package/dist/toolbar/chat-windows.d.ts +62 -0
- package/dist/toolbar/chat-windows.d.ts.map +1 -0
- package/dist/toolbar/chat-windows.js +287 -0
- package/dist/toolbar/chat-windows.js.map +1 -0
- package/dist/toolbar/contextual-actions.d.ts +24 -0
- package/dist/toolbar/contextual-actions.d.ts.map +1 -0
- package/dist/toolbar/contextual-actions.js +371 -0
- package/dist/toolbar/contextual-actions.js.map +1 -0
- package/dist/toolbar/diagnostic-actions.d.ts +22 -0
- package/dist/toolbar/diagnostic-actions.d.ts.map +1 -0
- package/dist/toolbar/diagnostic-actions.js +127 -0
- package/dist/toolbar/diagnostic-actions.js.map +1 -0
- package/dist/toolbar/layers.d.ts +26 -0
- package/dist/toolbar/layers.d.ts.map +1 -0
- package/dist/toolbar/layers.js +31 -0
- package/dist/toolbar/layers.js.map +1 -0
- package/dist/toolbar/overlay.d.ts +40 -0
- package/dist/toolbar/overlay.d.ts.map +1 -0
- package/dist/toolbar/overlay.js +619 -0
- package/dist/toolbar/overlay.js.map +1 -0
- package/dist/visual/capability-resolver.d.ts +11 -0
- package/dist/visual/capability-resolver.d.ts.map +1 -0
- package/dist/visual/capability-resolver.js +69 -0
- package/dist/visual/capability-resolver.js.map +1 -0
- package/dist/visual/command-engine.d.ts +10 -0
- package/dist/visual/command-engine.d.ts.map +1 -0
- package/dist/visual/command-engine.js +199 -0
- package/dist/visual/command-engine.js.map +1 -0
- package/dist/visual/commands.d.ts +36 -0
- package/dist/visual/commands.d.ts.map +1 -0
- package/dist/visual/commands.js +2 -0
- package/dist/visual/commands.js.map +1 -0
- package/dist/visual/patch-transactions.d.ts +57 -0
- package/dist/visual/patch-transactions.d.ts.map +1 -0
- package/dist/visual/patch-transactions.js +259 -0
- package/dist/visual/patch-transactions.js.map +1 -0
- package/dist/vite/build-ai-plugin.d.ts +11 -0
- package/dist/vite/build-ai-plugin.d.ts.map +1 -0
- package/dist/vite/build-ai-plugin.js +45 -0
- package/dist/vite/build-ai-plugin.js.map +1 -0
- package/package.json +85 -0
- package/src/agent/agent-fallback.ts +95 -0
- package/src/agent/cli-agent-fallback.ts +1382 -0
- package/src/integration/index.ts +475 -0
- package/src/resolver/astro-resolver.ts +1024 -0
- package/src/resolver/types.ts +47 -0
- package/src/shared/protocol.ts +181 -0
- package/src/shared/selection-context.ts +91 -0
- package/src/shared/visual-components.ts +16 -0
- package/src/toolbar/action-model.ts +67 -0
- package/src/toolbar/app.ts +294 -0
- package/src/toolbar/chat-drawer.ts +1742 -0
- package/src/toolbar/chat-windows.ts +331 -0
- package/src/toolbar/contextual-actions.ts +401 -0
- package/src/toolbar/diagnostic-actions.ts +151 -0
- package/src/toolbar/layers.ts +32 -0
- package/src/toolbar/overlay.ts +638 -0
- package/src/visual/capability-resolver.ts +83 -0
- package/src/visual/command-engine.ts +250 -0
- package/src/visual/commands.ts +37 -0
- package/src/visual/patch-transactions.ts +270 -0
- package/src/vite/build-ai-plugin.ts +46 -0
|
@@ -0,0 +1,1575 @@
|
|
|
1
|
+
import { EDITOR_LAYERS } from './layers.js';
|
|
2
|
+
import { createSelectionAttachment, middleTruncatePath, } from './action-model.js';
|
|
3
|
+
const TERMINAL_STATES = new Set([
|
|
4
|
+
'completion',
|
|
5
|
+
'failure',
|
|
6
|
+
'cancellation',
|
|
7
|
+
]);
|
|
8
|
+
const DRAWER_OPEN_KEY = 'astro-ai:drawer-open';
|
|
9
|
+
const DRAWER_MINIMIZED_KEY = 'astro-ai:drawer-minimized';
|
|
10
|
+
const DRAWER_CONTEXT_KEY = 'astro-ai:drawer-context';
|
|
11
|
+
const DRAWER_RUNS_KEY = 'astro-ai:drawer-runs';
|
|
12
|
+
const DRAWER_POSITION_KEY = 'astro-ai:drawer-position';
|
|
13
|
+
const DRAWER_MODE_KEY = 'astro-ai:drawer-answer-only';
|
|
14
|
+
const DRAWER_SESSION_KEYS = [
|
|
15
|
+
DRAWER_OPEN_KEY,
|
|
16
|
+
DRAWER_MINIMIZED_KEY,
|
|
17
|
+
DRAWER_CONTEXT_KEY,
|
|
18
|
+
DRAWER_RUNS_KEY,
|
|
19
|
+
DRAWER_POSITION_KEY,
|
|
20
|
+
DRAWER_MODE_KEY,
|
|
21
|
+
];
|
|
22
|
+
/** Cascade step between chat windows that have never been dragged. */
|
|
23
|
+
const WINDOW_CASCADE_STEP = 34;
|
|
24
|
+
const MAX_TITLE_LENGTH = 40;
|
|
25
|
+
const MAX_FILE_ATTACHMENTS = 5;
|
|
26
|
+
const MAX_FILE_BYTES = 256_000;
|
|
27
|
+
const MAX_TOTAL_FILE_BYTES = 512_000;
|
|
28
|
+
const MAX_IMAGE_BYTES = 5_000_000;
|
|
29
|
+
const MAX_TOTAL_IMAGE_BYTES = 10_000_000;
|
|
30
|
+
const SUPPORTED_IMAGE_TYPES = new Set(['image/png', 'image/jpeg', 'image/webp', 'image/gif']);
|
|
31
|
+
export class ChatDrawer {
|
|
32
|
+
element;
|
|
33
|
+
sessionId;
|
|
34
|
+
#callbacks;
|
|
35
|
+
#index;
|
|
36
|
+
#titleInput;
|
|
37
|
+
#body;
|
|
38
|
+
#context;
|
|
39
|
+
#messages;
|
|
40
|
+
#emptyState;
|
|
41
|
+
#composer;
|
|
42
|
+
#input;
|
|
43
|
+
#fileTray;
|
|
44
|
+
#fileInput;
|
|
45
|
+
#attach;
|
|
46
|
+
#submit;
|
|
47
|
+
#cancel;
|
|
48
|
+
#mode;
|
|
49
|
+
#providerBadge;
|
|
50
|
+
#toolbar;
|
|
51
|
+
#notice;
|
|
52
|
+
#undo;
|
|
53
|
+
#redo;
|
|
54
|
+
#collapse;
|
|
55
|
+
#connector;
|
|
56
|
+
#connectorPath;
|
|
57
|
+
#drawerResizeObserver;
|
|
58
|
+
#provider;
|
|
59
|
+
#currentSelections = [];
|
|
60
|
+
/** The exact elements behind the current selection and the attachment. */
|
|
61
|
+
#currentSelectionElements = [];
|
|
62
|
+
#attachmentElements = [];
|
|
63
|
+
#attachments = [];
|
|
64
|
+
#files = [];
|
|
65
|
+
#externalContext;
|
|
66
|
+
#locked = false;
|
|
67
|
+
#minimized = false;
|
|
68
|
+
#answerOnly = false;
|
|
69
|
+
#activeRequestId;
|
|
70
|
+
#submittedDrafts = new Map();
|
|
71
|
+
#runs = new Map();
|
|
72
|
+
#runStates = new Map();
|
|
73
|
+
#elapsedTimer;
|
|
74
|
+
#selectionAnchor;
|
|
75
|
+
#connectorFrame;
|
|
76
|
+
#position;
|
|
77
|
+
#dragOffset;
|
|
78
|
+
#resizeBounds;
|
|
79
|
+
constructor(callbacks, options) {
|
|
80
|
+
this.#callbacks = callbacks;
|
|
81
|
+
this.sessionId = options.sessionId;
|
|
82
|
+
this.#index = options.index ?? 0;
|
|
83
|
+
const persistedContext = this.#readJson(DRAWER_CONTEXT_KEY);
|
|
84
|
+
// Rendered node IDs can change after HMR or a server restart. Historical
|
|
85
|
+
// runs retain immutable attachments, but the next composer attachment must
|
|
86
|
+
// always come from a currently resolved page selection.
|
|
87
|
+
this.#attachments = [];
|
|
88
|
+
this.#externalContext = persistedContext?.externalContext;
|
|
89
|
+
this.#locked = false;
|
|
90
|
+
this.#minimized = this.#read(DRAWER_MINIMIZED_KEY) === 'true';
|
|
91
|
+
this.#answerOnly = this.#read(DRAWER_MODE_KEY) === 'true';
|
|
92
|
+
this.#position = this.#readJson(DRAWER_POSITION_KEY);
|
|
93
|
+
this.element = document.createElement('aside');
|
|
94
|
+
this.element.className = 'ai-chat-drawer';
|
|
95
|
+
this.element.setAttribute('aria-label', `Build with AI chat · ${options.title}`);
|
|
96
|
+
this.element.dataset.sessionId = options.sessionId;
|
|
97
|
+
this.element.dataset.minimized = String(this.#minimized);
|
|
98
|
+
this.element.addEventListener('pointerdown', () => this.#callbacks.onFocus?.(), true);
|
|
99
|
+
const resize = element('div', 'drawer-resize');
|
|
100
|
+
resize.title = 'Resize agent panel';
|
|
101
|
+
resize.addEventListener('pointerdown', this.#startResize);
|
|
102
|
+
const header = element('header', 'drawer-header');
|
|
103
|
+
header.title = 'Drag to move the chat window';
|
|
104
|
+
header.addEventListener('pointerdown', this.#startDrag);
|
|
105
|
+
const identity = element('div', 'drawer-identity');
|
|
106
|
+
const mark = element('span', 'agent-mark');
|
|
107
|
+
mark.textContent = '✦';
|
|
108
|
+
const titles = document.createElement('div');
|
|
109
|
+
const eyebrow = element('span', 'drawer-eyebrow');
|
|
110
|
+
eyebrow.textContent = 'Build with AI';
|
|
111
|
+
this.#titleInput = document.createElement('input');
|
|
112
|
+
this.#titleInput.className = 'drawer-title';
|
|
113
|
+
this.#titleInput.value = options.title;
|
|
114
|
+
this.#titleInput.maxLength = MAX_TITLE_LENGTH;
|
|
115
|
+
this.#titleInput.spellcheck = false;
|
|
116
|
+
this.#titleInput.title = 'Rename this chat window';
|
|
117
|
+
this.#titleInput.setAttribute('aria-label', 'Chat window name');
|
|
118
|
+
this.#titleInput.addEventListener('change', this.#commitTitle);
|
|
119
|
+
this.#titleInput.addEventListener('blur', this.#commitTitle);
|
|
120
|
+
this.#titleInput.addEventListener('keydown', (event) => {
|
|
121
|
+
if (event.key === 'Enter')
|
|
122
|
+
this.#titleInput.blur();
|
|
123
|
+
});
|
|
124
|
+
titles.append(eyebrow, this.#titleInput);
|
|
125
|
+
identity.append(mark, titles);
|
|
126
|
+
const headerActions = element('div', 'drawer-header-actions');
|
|
127
|
+
this.#providerBadge = element('span', 'provider-badge');
|
|
128
|
+
this.#providerBadge.dataset.state = 'checking';
|
|
129
|
+
this.#providerBadge.textContent = 'Checking CLI…';
|
|
130
|
+
const newWindow = iconButton('+', 'Open another chat window', () => this.#callbacks.onNewWindow?.());
|
|
131
|
+
newWindow.classList.add('new-window-button');
|
|
132
|
+
newWindow.hidden = callbacks.onNewWindow === undefined;
|
|
133
|
+
this.#collapse = iconButton('−', 'Collapse chat window', () => this.toggleCollapsed());
|
|
134
|
+
this.#collapse.classList.add('collapse-button');
|
|
135
|
+
const close = iconButton('×', 'Close this chat window', () => this.#callbacks.onClose());
|
|
136
|
+
close.classList.add('close-button');
|
|
137
|
+
headerActions.append(this.#providerBadge, newWindow, this.#collapse, close);
|
|
138
|
+
header.append(identity, headerActions);
|
|
139
|
+
this.#toolbar = element('div', 'drawer-toolbar');
|
|
140
|
+
const historyActions = element('div', 'history-actions');
|
|
141
|
+
this.#undo = button('Undo', callbacks.onUndo);
|
|
142
|
+
this.#redo = button('Redo', callbacks.onRedo);
|
|
143
|
+
this.#undo.className = 'history-button';
|
|
144
|
+
this.#redo.className = 'history-button';
|
|
145
|
+
this.#undo.disabled = true;
|
|
146
|
+
this.#redo.disabled = true;
|
|
147
|
+
historyActions.append(this.#undo, this.#redo);
|
|
148
|
+
this.#notice = element('span', 'drawer-notice');
|
|
149
|
+
this.#notice.dataset.state = 'ready';
|
|
150
|
+
this.#notice.textContent = 'Click to select · Shift-click to add · drag to marquee';
|
|
151
|
+
this.#notice.setAttribute('role', 'status');
|
|
152
|
+
this.#notice.setAttribute('aria-live', 'polite');
|
|
153
|
+
this.#toolbar.append(historyActions, this.#notice);
|
|
154
|
+
this.#body = element('div', 'drawer-body');
|
|
155
|
+
this.#context = element('div', 'chat-context');
|
|
156
|
+
this.#messages = element('div', 'chat-messages');
|
|
157
|
+
this.#messages.setAttribute('aria-live', 'polite');
|
|
158
|
+
this.#emptyState = element('div', 'chat-empty');
|
|
159
|
+
const emptyMark = element('span', 'empty-mark');
|
|
160
|
+
emptyMark.textContent = '✦';
|
|
161
|
+
const emptyTitle = document.createElement('strong');
|
|
162
|
+
emptyTitle.textContent = 'Ready when you are';
|
|
163
|
+
const emptyCopy = document.createElement('p');
|
|
164
|
+
emptyCopy.textContent = 'Ask about the project or describe a source change. Applied changes remain undoable.';
|
|
165
|
+
this.#emptyState.append(emptyMark, emptyTitle, emptyCopy);
|
|
166
|
+
this.#messages.append(this.#emptyState);
|
|
167
|
+
this.#composer = element('form', 'chat-composer');
|
|
168
|
+
const composerFrame = element('div', 'composer-frame');
|
|
169
|
+
this.#fileTray = element('div', 'file-tray');
|
|
170
|
+
this.#fileTray.hidden = true;
|
|
171
|
+
this.#fileInput = document.createElement('input');
|
|
172
|
+
this.#fileInput.type = 'file';
|
|
173
|
+
this.#fileInput.multiple = true;
|
|
174
|
+
this.#fileInput.hidden = true;
|
|
175
|
+
this.#fileInput.accept = 'image/png,image/jpeg,image/webp,image/gif,text/*,.astro,.js,.jsx,.ts,.tsx,.json,.md,.mdx,.css,.scss,.sass,.less,.html,.yaml,.yml,.toml,.xml,.svg,.env,.txt';
|
|
176
|
+
this.#fileInput.addEventListener('change', () => {
|
|
177
|
+
void this.attachFiles([...(this.#fileInput.files ?? [])]);
|
|
178
|
+
this.#fileInput.value = '';
|
|
179
|
+
});
|
|
180
|
+
this.#input = document.createElement('textarea');
|
|
181
|
+
this.#input.rows = 3;
|
|
182
|
+
this.#input.placeholder = 'Ask about the project or request a change…';
|
|
183
|
+
this.#input.setAttribute('aria-label', 'Agent instruction');
|
|
184
|
+
this.#input.addEventListener('keydown', this.#onComposerKeyDown);
|
|
185
|
+
this.#input.addEventListener('paste', this.#onPaste);
|
|
186
|
+
this.#input.addEventListener('input', () => this.#syncComposer());
|
|
187
|
+
const composerFooter = element('div', 'composer-footer');
|
|
188
|
+
const shortcut = element('span', 'shortcut-hint');
|
|
189
|
+
shortcut.textContent = 'Enter to send · Shift+Enter for new line';
|
|
190
|
+
const composerActions = element('div', 'composer-actions');
|
|
191
|
+
this.#attach = iconButton('📎', 'Attach text or code files', () => this.#fileInput.click());
|
|
192
|
+
this.#attach.classList.add('attach-button');
|
|
193
|
+
this.#mode = button('Answer only', () => {
|
|
194
|
+
this.#answerOnly = !this.#answerOnly;
|
|
195
|
+
this.#write(DRAWER_MODE_KEY, String(this.#answerOnly));
|
|
196
|
+
this.#syncComposer();
|
|
197
|
+
});
|
|
198
|
+
this.#mode.className = 'mode-button';
|
|
199
|
+
this.#mode.title = 'When enabled, the agent may inspect source but no file changes will be applied.';
|
|
200
|
+
this.#cancel = button('Cancel', () => {
|
|
201
|
+
if (this.#activeRequestId !== undefined)
|
|
202
|
+
this.#callbacks.onCancel(this.#activeRequestId);
|
|
203
|
+
});
|
|
204
|
+
this.#cancel.className = 'secondary-button';
|
|
205
|
+
this.#cancel.hidden = true;
|
|
206
|
+
this.#submit = button('Send');
|
|
207
|
+
this.#submit.className = 'send-button';
|
|
208
|
+
this.#submit.type = 'submit';
|
|
209
|
+
this.#submit.disabled = true;
|
|
210
|
+
composerActions.append(this.#attach, this.#mode, this.#cancel, this.#submit);
|
|
211
|
+
composerFooter.append(shortcut, composerActions);
|
|
212
|
+
composerFrame.append(this.#fileTray, this.#fileInput, this.#input, composerFooter);
|
|
213
|
+
composerFrame.addEventListener('dragover', (event) => {
|
|
214
|
+
if (event.dataTransfer?.types.includes('Files') !== true)
|
|
215
|
+
return;
|
|
216
|
+
event.preventDefault();
|
|
217
|
+
composerFrame.dataset.draggingFile = 'true';
|
|
218
|
+
});
|
|
219
|
+
composerFrame.addEventListener('dragleave', () => {
|
|
220
|
+
composerFrame.dataset.draggingFile = 'false';
|
|
221
|
+
});
|
|
222
|
+
composerFrame.addEventListener('drop', (event) => {
|
|
223
|
+
if (event.dataTransfer?.files === undefined)
|
|
224
|
+
return;
|
|
225
|
+
event.preventDefault();
|
|
226
|
+
composerFrame.dataset.draggingFile = 'false';
|
|
227
|
+
void this.attachFiles([...event.dataTransfer.files]);
|
|
228
|
+
});
|
|
229
|
+
this.#composer.append(composerFrame);
|
|
230
|
+
this.#composer.addEventListener('submit', this.#onSubmit);
|
|
231
|
+
this.#body.append(this.#context, this.#messages, this.#composer);
|
|
232
|
+
this.element.append(resize, header, this.#toolbar, this.#body);
|
|
233
|
+
if (this.#position !== undefined)
|
|
234
|
+
this.#applyPosition(this.#position);
|
|
235
|
+
[this.#connector, this.#connectorPath] = createSelectionConnector(options.sessionId);
|
|
236
|
+
document.documentElement.append(this.#connector);
|
|
237
|
+
// The window's own size settles after fonts and restored content land, and
|
|
238
|
+
// a taller window can push itself off screen, so re-clamp whenever it
|
|
239
|
+
// changes rather than only on the one frame after opening.
|
|
240
|
+
this.#drawerResizeObserver = new ResizeObserver(() => {
|
|
241
|
+
this.#recoverPosition();
|
|
242
|
+
this.#scheduleConnector();
|
|
243
|
+
});
|
|
244
|
+
this.#drawerResizeObserver.observe(this.element);
|
|
245
|
+
window.addEventListener('scroll', this.#scheduleConnector, true);
|
|
246
|
+
window.addEventListener('resize', this.#onViewportResize);
|
|
247
|
+
// A back/forward restore reruns no open or resize, so the window would keep
|
|
248
|
+
// whatever position the previous page left it at.
|
|
249
|
+
window.addEventListener('pageshow', this.#onViewportResize);
|
|
250
|
+
this.#renderContext();
|
|
251
|
+
this.#renderFiles();
|
|
252
|
+
this.#restoreRuns();
|
|
253
|
+
this.#syncComposer();
|
|
254
|
+
this.#setMinimized(this.#minimized, false, false);
|
|
255
|
+
}
|
|
256
|
+
get title() {
|
|
257
|
+
return this.#titleInput.value.trim() || 'Chat';
|
|
258
|
+
}
|
|
259
|
+
get minimized() {
|
|
260
|
+
return this.#minimized;
|
|
261
|
+
}
|
|
262
|
+
rename(title) {
|
|
263
|
+
this.#titleInput.value = title;
|
|
264
|
+
this.#commitTitle();
|
|
265
|
+
}
|
|
266
|
+
/** Raises this window above its siblings. */
|
|
267
|
+
setFocused(focused, zIndex) {
|
|
268
|
+
this.element.dataset.focused = String(focused);
|
|
269
|
+
if (zIndex !== undefined)
|
|
270
|
+
this.element.style.zIndex = String(zIndex);
|
|
271
|
+
}
|
|
272
|
+
/** Drops every persisted key for this window so a closed chat leaves nothing behind. */
|
|
273
|
+
clearStoredState() {
|
|
274
|
+
for (const base of DRAWER_SESSION_KEYS)
|
|
275
|
+
removeSession(this.#key(base));
|
|
276
|
+
}
|
|
277
|
+
#key(base) {
|
|
278
|
+
return `${base}:${this.sessionId}`;
|
|
279
|
+
}
|
|
280
|
+
#read(base) {
|
|
281
|
+
return readSession(this.#key(base));
|
|
282
|
+
}
|
|
283
|
+
#readJson(base) {
|
|
284
|
+
return readSessionJson(this.#key(base));
|
|
285
|
+
}
|
|
286
|
+
#write(base, value) {
|
|
287
|
+
writeSession(this.#key(base), value);
|
|
288
|
+
}
|
|
289
|
+
#commitTitle = () => {
|
|
290
|
+
const next = this.#titleInput.value.trim().slice(0, MAX_TITLE_LENGTH);
|
|
291
|
+
this.#titleInput.value = next === '' ? 'Chat' : next;
|
|
292
|
+
this.element.setAttribute('aria-label', `Build with AI chat · ${this.title}`);
|
|
293
|
+
this.#callbacks.onRename?.(this.title);
|
|
294
|
+
};
|
|
295
|
+
setProvider(provider) {
|
|
296
|
+
this.#provider = provider;
|
|
297
|
+
const name = provider.provider === 'codex'
|
|
298
|
+
? 'Codex'
|
|
299
|
+
: provider.provider === 'claude'
|
|
300
|
+
? 'Claude'
|
|
301
|
+
: 'Agent';
|
|
302
|
+
const connected = provider.available && provider.authenticated;
|
|
303
|
+
this.#providerBadge.dataset.state = connected ? 'connected' : 'offline';
|
|
304
|
+
this.#providerBadge.textContent = connected ? `${name} connected` : `${name} offline`;
|
|
305
|
+
this.#providerBadge.title = provider.message;
|
|
306
|
+
this.#syncComposer();
|
|
307
|
+
}
|
|
308
|
+
pendingRequestIds() {
|
|
309
|
+
return [...this.#runStates.values()]
|
|
310
|
+
.filter(({ status }) => status === 'running')
|
|
311
|
+
.map(({ requestId }) => requestId);
|
|
312
|
+
}
|
|
313
|
+
async attachFiles(files) {
|
|
314
|
+
let errorMessage;
|
|
315
|
+
for (const file of files) {
|
|
316
|
+
if (this.#files.length >= MAX_FILE_ATTACHMENTS) {
|
|
317
|
+
errorMessage = `Attach no more than ${MAX_FILE_ATTACHMENTS} files.`;
|
|
318
|
+
break;
|
|
319
|
+
}
|
|
320
|
+
const image = SUPPORTED_IMAGE_TYPES.has(file.type);
|
|
321
|
+
const displayName = file.name.trim() || `screenshot-${Date.now()}.png`;
|
|
322
|
+
if (file.type.startsWith('image/') && !image && file.type !== 'image/svg+xml') {
|
|
323
|
+
errorMessage = `${displayName} is not a supported screenshot format.`;
|
|
324
|
+
continue;
|
|
325
|
+
}
|
|
326
|
+
if (image && file.size > MAX_IMAGE_BYTES) {
|
|
327
|
+
errorMessage = `${displayName} exceeds the ${MAX_IMAGE_BYTES / 1_000_000} MB image limit.`;
|
|
328
|
+
continue;
|
|
329
|
+
}
|
|
330
|
+
if (!image && file.size > MAX_FILE_BYTES) {
|
|
331
|
+
errorMessage = `${file.name} exceeds the ${Math.round(MAX_FILE_BYTES / 1_000)} KB limit.`;
|
|
332
|
+
continue;
|
|
333
|
+
}
|
|
334
|
+
try {
|
|
335
|
+
if (image) {
|
|
336
|
+
if (file.arrayBuffer === undefined)
|
|
337
|
+
throw new Error('Image data is unavailable.');
|
|
338
|
+
const bytes = new Uint8Array(await file.arrayBuffer());
|
|
339
|
+
const total = this.#files
|
|
340
|
+
.filter(({ kind }) => kind === 'image')
|
|
341
|
+
.reduce((sum, attachment) => sum + attachment.size, 0) + bytes.byteLength;
|
|
342
|
+
if (bytes.byteLength > MAX_IMAGE_BYTES || total > MAX_TOTAL_IMAGE_BYTES) {
|
|
343
|
+
errorMessage = total > MAX_TOTAL_IMAGE_BYTES
|
|
344
|
+
? `Images exceed the ${MAX_TOTAL_IMAGE_BYTES / 1_000_000} MB total limit.`
|
|
345
|
+
: `${displayName} exceeds the ${MAX_IMAGE_BYTES / 1_000_000} MB image limit.`;
|
|
346
|
+
continue;
|
|
347
|
+
}
|
|
348
|
+
this.#files.push({
|
|
349
|
+
name: displayName,
|
|
350
|
+
content: base64FromBytes(bytes),
|
|
351
|
+
size: bytes.byteLength,
|
|
352
|
+
mediaType: file.type,
|
|
353
|
+
kind: 'image',
|
|
354
|
+
encoding: 'base64',
|
|
355
|
+
});
|
|
356
|
+
continue;
|
|
357
|
+
}
|
|
358
|
+
const content = await file.text();
|
|
359
|
+
const size = new TextEncoder().encode(content).byteLength;
|
|
360
|
+
const total = this.#files.reduce((sum, attachment) => sum + attachment.size, 0) + size;
|
|
361
|
+
if (content.includes('\0')) {
|
|
362
|
+
errorMessage = `${file.name} is not a supported text file.`;
|
|
363
|
+
continue;
|
|
364
|
+
}
|
|
365
|
+
if (size > MAX_FILE_BYTES || total > MAX_TOTAL_FILE_BYTES) {
|
|
366
|
+
errorMessage = total > MAX_TOTAL_FILE_BYTES
|
|
367
|
+
? `Attachments exceed the ${Math.round(MAX_TOTAL_FILE_BYTES / 1_000)} KB total limit.`
|
|
368
|
+
: `${file.name} exceeds the ${Math.round(MAX_FILE_BYTES / 1_000)} KB limit.`;
|
|
369
|
+
continue;
|
|
370
|
+
}
|
|
371
|
+
this.#files.push({
|
|
372
|
+
name: displayName,
|
|
373
|
+
content,
|
|
374
|
+
size,
|
|
375
|
+
...(file.type === '' ? {} : { mediaType: file.type }),
|
|
376
|
+
});
|
|
377
|
+
}
|
|
378
|
+
catch {
|
|
379
|
+
errorMessage = `Could not read ${file.name}.`;
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
this.#renderFiles();
|
|
383
|
+
if (errorMessage !== undefined)
|
|
384
|
+
this.setNotice(errorMessage, true);
|
|
385
|
+
}
|
|
386
|
+
#onPaste = (event) => {
|
|
387
|
+
const images = [...(event.clipboardData?.items ?? [])].flatMap((item) => {
|
|
388
|
+
if (item.kind !== 'file' || !item.type.startsWith('image/'))
|
|
389
|
+
return [];
|
|
390
|
+
const file = item.getAsFile();
|
|
391
|
+
return file === null ? [] : [file];
|
|
392
|
+
});
|
|
393
|
+
if (images.length === 0)
|
|
394
|
+
return;
|
|
395
|
+
event.preventDefault();
|
|
396
|
+
void this.attachFiles(images);
|
|
397
|
+
};
|
|
398
|
+
setCurrentSelections(contexts, elements = []) {
|
|
399
|
+
this.#currentSelections = [...contexts];
|
|
400
|
+
this.#currentSelectionElements = [...elements];
|
|
401
|
+
// An empty overlay selection can be transient (for example, when the
|
|
402
|
+
// toolbar's hover menu closes). Keep the composer attachment until the
|
|
403
|
+
// user explicitly removes it; a new source selection may still replace it.
|
|
404
|
+
if (contexts.length > 0) {
|
|
405
|
+
this.#externalContext = undefined;
|
|
406
|
+
if (!this.#locked) {
|
|
407
|
+
this.#attachments = contexts.map(createSelectionAttachment);
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
this.#renderContext();
|
|
411
|
+
}
|
|
412
|
+
setSelectionAnchor(rect) {
|
|
413
|
+
this.#selectionAnchor = rect === undefined
|
|
414
|
+
? undefined
|
|
415
|
+
: {
|
|
416
|
+
left: rect.left,
|
|
417
|
+
right: rect.right,
|
|
418
|
+
top: rect.top,
|
|
419
|
+
bottom: rect.bottom,
|
|
420
|
+
width: rect.width,
|
|
421
|
+
height: rect.height,
|
|
422
|
+
};
|
|
423
|
+
this.#scheduleConnector();
|
|
424
|
+
}
|
|
425
|
+
setHistory(history) {
|
|
426
|
+
this.#undo.disabled = !history.canUndo;
|
|
427
|
+
this.#redo.disabled = !history.canRedo;
|
|
428
|
+
this.#undo.title = history.undoLabel === undefined ? 'Nothing to undo' : `Undo ${history.undoLabel}`;
|
|
429
|
+
this.#redo.title = history.redoLabel === undefined ? 'Nothing to redo' : `Redo ${history.redoLabel}`;
|
|
430
|
+
}
|
|
431
|
+
setNotice(message, error = false) {
|
|
432
|
+
this.#notice.dataset.state = error ? 'error' : 'ready';
|
|
433
|
+
this.#notice.textContent = message;
|
|
434
|
+
}
|
|
435
|
+
openWithSelections(contexts, elements = []) {
|
|
436
|
+
this.#currentSelections = [...contexts];
|
|
437
|
+
this.#currentSelectionElements = [...elements];
|
|
438
|
+
// Choosing Ask AI on an element is an explicit attachment choice and must
|
|
439
|
+
// replace stale drawer context, even if an older attachment was locked.
|
|
440
|
+
this.#attachments = contexts.map(createSelectionAttachment);
|
|
441
|
+
this.#attachmentElements = [...elements];
|
|
442
|
+
this.#externalContext = undefined;
|
|
443
|
+
this.#locked = false;
|
|
444
|
+
this.open();
|
|
445
|
+
}
|
|
446
|
+
openWithExternalContext(context) {
|
|
447
|
+
this.#currentSelections = [];
|
|
448
|
+
this.#currentSelectionElements = [];
|
|
449
|
+
this.#attachments = [];
|
|
450
|
+
this.#attachmentElements = [];
|
|
451
|
+
this.#externalContext = { ...context };
|
|
452
|
+
this.#locked = false;
|
|
453
|
+
this.setSelectionAnchor();
|
|
454
|
+
this.open();
|
|
455
|
+
this.#input.value = context.kind === 'error'
|
|
456
|
+
? 'Fix this development error.'
|
|
457
|
+
: 'Fix this audit issue.';
|
|
458
|
+
this.#syncComposer();
|
|
459
|
+
}
|
|
460
|
+
/**
|
|
461
|
+
* Handles a client-side navigation. The page's DOM was replaced, so every
|
|
462
|
+
* node id this window held now points at an element that no longer exists,
|
|
463
|
+
* and the view transition can leave the window itself stale or hidden.
|
|
464
|
+
*/
|
|
465
|
+
handleNavigation() {
|
|
466
|
+
this.#currentSelections = [];
|
|
467
|
+
this.#currentSelectionElements = [];
|
|
468
|
+
this.#attachments = [];
|
|
469
|
+
this.#attachmentElements = [];
|
|
470
|
+
this.#locked = false;
|
|
471
|
+
this.#selectionAnchor = undefined;
|
|
472
|
+
this.#renderContext();
|
|
473
|
+
this.#scheduleConnector();
|
|
474
|
+
if (this.element.hidden)
|
|
475
|
+
return;
|
|
476
|
+
// Re-assert the window after the transition has released its snapshot.
|
|
477
|
+
requestAnimationFrame(() => this.#recoverPosition());
|
|
478
|
+
window.setTimeout(() => this.#recoverPosition(), 0);
|
|
479
|
+
}
|
|
480
|
+
open(focus = true, persist = true, expand = true) {
|
|
481
|
+
this.element.hidden = false;
|
|
482
|
+
if (persist)
|
|
483
|
+
this.#write(DRAWER_OPEN_KEY, 'true');
|
|
484
|
+
if (expand)
|
|
485
|
+
this.#setMinimized(false);
|
|
486
|
+
else
|
|
487
|
+
this.#setMinimized(this.#minimized, false);
|
|
488
|
+
this.#renderContext();
|
|
489
|
+
this.#scheduleConnector();
|
|
490
|
+
this.#scrollToLatest();
|
|
491
|
+
requestAnimationFrame(() => {
|
|
492
|
+
this.#applyCascade();
|
|
493
|
+
this.#recoverPosition();
|
|
494
|
+
if (focus)
|
|
495
|
+
this.#input.focus();
|
|
496
|
+
});
|
|
497
|
+
// The first frame after opening can measure a canvas the host has not laid
|
|
498
|
+
// out yet, which reports a 0x0 box and skips the check entirely. A second
|
|
499
|
+
// deferred pass runs once layout has certainly settled.
|
|
500
|
+
window.setTimeout(() => this.#recoverPosition(), 0);
|
|
501
|
+
}
|
|
502
|
+
/**
|
|
503
|
+
* Guarantees the window is reachable. A stored position that no longer puts
|
|
504
|
+
* it on screen is discarded outright rather than clamped: the saved value is
|
|
505
|
+
* already known bad, and the default corner is known good.
|
|
506
|
+
*/
|
|
507
|
+
#recoverPosition() {
|
|
508
|
+
if (this.element.hidden)
|
|
509
|
+
return;
|
|
510
|
+
const rect = this.element.getBoundingClientRect();
|
|
511
|
+
if (rect.width === 0 || rect.height === 0)
|
|
512
|
+
return;
|
|
513
|
+
if (this.#position !== undefined && !intersectsViewport(rect)) {
|
|
514
|
+
this.#position = undefined;
|
|
515
|
+
removeSession(this.#key(DRAWER_POSITION_KEY));
|
|
516
|
+
this.element.style.removeProperty('left');
|
|
517
|
+
this.element.style.removeProperty('top');
|
|
518
|
+
this.element.style.removeProperty('right');
|
|
519
|
+
this.element.style.removeProperty('bottom');
|
|
520
|
+
this.#scheduleConnector();
|
|
521
|
+
return;
|
|
522
|
+
}
|
|
523
|
+
this.#constrainPosition();
|
|
524
|
+
}
|
|
525
|
+
/**
|
|
526
|
+
* A window that has never been dragged starts offset from its siblings so a
|
|
527
|
+
* second chat does not open exactly on top of the first.
|
|
528
|
+
*/
|
|
529
|
+
#applyCascade() {
|
|
530
|
+
if (this.#position !== undefined || this.#index === 0 || this.element.hidden)
|
|
531
|
+
return;
|
|
532
|
+
const rect = this.element.getBoundingClientRect();
|
|
533
|
+
if (rect.width === 0 || rect.height === 0)
|
|
534
|
+
return;
|
|
535
|
+
const offset = this.#index * WINDOW_CASCADE_STEP;
|
|
536
|
+
this.#position = { left: rect.left - offset, top: rect.top + offset };
|
|
537
|
+
this.#applyPosition(this.#position);
|
|
538
|
+
}
|
|
539
|
+
hide(persist = true) {
|
|
540
|
+
this.element.hidden = true;
|
|
541
|
+
this.#scheduleConnector();
|
|
542
|
+
if (persist)
|
|
543
|
+
this.#write(DRAWER_OPEN_KEY, 'false');
|
|
544
|
+
}
|
|
545
|
+
toggleCollapsed() {
|
|
546
|
+
this.#setMinimized(!this.#minimized);
|
|
547
|
+
}
|
|
548
|
+
#setMinimized(minimized, persist = true, notify = true) {
|
|
549
|
+
const control = chatWindowCollapseControl(minimized);
|
|
550
|
+
this.#minimized = minimized;
|
|
551
|
+
this.element.dataset.minimized = String(minimized);
|
|
552
|
+
this.#toolbar.hidden = minimized;
|
|
553
|
+
this.#body.hidden = minimized;
|
|
554
|
+
this.#collapse.textContent = control.symbol;
|
|
555
|
+
this.#collapse.title = control.label;
|
|
556
|
+
this.#collapse.setAttribute('aria-label', this.#collapse.title);
|
|
557
|
+
this.#collapse.setAttribute('aria-expanded', String(control.expanded));
|
|
558
|
+
if (persist)
|
|
559
|
+
this.#write(DRAWER_MINIMIZED_KEY, String(minimized));
|
|
560
|
+
if (notify)
|
|
561
|
+
this.#callbacks.onMinimizedChange?.(minimized);
|
|
562
|
+
this.#scheduleConnector();
|
|
563
|
+
requestAnimationFrame(() => this.#recoverPosition());
|
|
564
|
+
}
|
|
565
|
+
handleAgentEvent(event) {
|
|
566
|
+
const run = this.#runs.get(event.requestId);
|
|
567
|
+
if (run === undefined)
|
|
568
|
+
return;
|
|
569
|
+
const terminal = TERMINAL_STATES.has(event.state);
|
|
570
|
+
if (!terminal)
|
|
571
|
+
this.#updateStep(run, event.state, event.message);
|
|
572
|
+
if (event.state === 'completion') {
|
|
573
|
+
const fileCount = event.transaction?.files.length ?? 0;
|
|
574
|
+
const provider = providerLabel(event.provider ?? this.#provider?.provider);
|
|
575
|
+
const resultNote = fileCount === 0
|
|
576
|
+
? 'No source files were changed.'
|
|
577
|
+
: `${fileCount} file${fileCount === 1 ? '' : 's'} updated · Undo is available.`;
|
|
578
|
+
this.#finishRun(run, 'completed', fileCount === 0 ? `${provider} response` : `${provider} changes applied`, event.response === undefined || event.response.trim() === ''
|
|
579
|
+
? resultNote
|
|
580
|
+
: `${event.response.trim()}\n\n${resultNote}`);
|
|
581
|
+
if (event.transaction?.diff !== undefined)
|
|
582
|
+
this.#renderDiff(run, event.transaction.diff);
|
|
583
|
+
}
|
|
584
|
+
else if (event.state === 'failure') {
|
|
585
|
+
this.#finishRun(run, 'failed', 'Couldn’t complete the change', event.message);
|
|
586
|
+
}
|
|
587
|
+
else if (event.state === 'cancellation') {
|
|
588
|
+
this.#finishRun(run, 'cancelled', 'Run cancelled', 'No source transaction was created.');
|
|
589
|
+
}
|
|
590
|
+
else {
|
|
591
|
+
run.title.textContent = stageTitle(event.state);
|
|
592
|
+
run.summary.textContent = event.message;
|
|
593
|
+
}
|
|
594
|
+
if (terminal) {
|
|
595
|
+
if (this.#activeRequestId === event.requestId)
|
|
596
|
+
this.#activeRequestId = undefined;
|
|
597
|
+
const submitted = this.#submittedDrafts.get(event.requestId);
|
|
598
|
+
if (event.state === 'failure' && submitted !== undefined && this.#input.value.trim() === '') {
|
|
599
|
+
this.#input.value = submitted;
|
|
600
|
+
}
|
|
601
|
+
this.#submittedDrafts.delete(event.requestId);
|
|
602
|
+
this.#syncComposer();
|
|
603
|
+
this.#syncElapsedTimer();
|
|
604
|
+
}
|
|
605
|
+
this.#persistRuns();
|
|
606
|
+
this.#scrollToLatest();
|
|
607
|
+
}
|
|
608
|
+
destroy() {
|
|
609
|
+
window.removeEventListener('pointermove', this.#resizeMove);
|
|
610
|
+
window.removeEventListener('pointerup', this.#endResize);
|
|
611
|
+
window.removeEventListener('pointermove', this.#dragMove);
|
|
612
|
+
window.removeEventListener('pointerup', this.#endDrag);
|
|
613
|
+
if (this.#elapsedTimer !== undefined)
|
|
614
|
+
window.clearInterval(this.#elapsedTimer);
|
|
615
|
+
if (this.#connectorFrame !== undefined)
|
|
616
|
+
window.cancelAnimationFrame(this.#connectorFrame);
|
|
617
|
+
this.#drawerResizeObserver.disconnect();
|
|
618
|
+
window.removeEventListener('scroll', this.#scheduleConnector, true);
|
|
619
|
+
window.removeEventListener('resize', this.#onViewportResize);
|
|
620
|
+
window.removeEventListener('pageshow', this.#onViewportResize);
|
|
621
|
+
this.#connector.remove();
|
|
622
|
+
this.element.remove();
|
|
623
|
+
}
|
|
624
|
+
#scheduleConnector = () => {
|
|
625
|
+
if (this.#connectorFrame !== undefined)
|
|
626
|
+
return;
|
|
627
|
+
this.#connectorFrame = window.requestAnimationFrame(() => {
|
|
628
|
+
this.#connectorFrame = undefined;
|
|
629
|
+
this.#renderConnector();
|
|
630
|
+
});
|
|
631
|
+
};
|
|
632
|
+
#onViewportResize = () => {
|
|
633
|
+
this.#recoverPosition();
|
|
634
|
+
this.#scheduleConnector();
|
|
635
|
+
};
|
|
636
|
+
/**
|
|
637
|
+
* Measures this window's own attached elements. The page's live selection
|
|
638
|
+
* belongs to whichever window is focused, so a window must anchor its arrow
|
|
639
|
+
* to what *it* is attached to or every window would point at the same node.
|
|
640
|
+
*/
|
|
641
|
+
#attachmentAnchor() {
|
|
642
|
+
if (this.#attachments.length === 0)
|
|
643
|
+
return undefined;
|
|
644
|
+
const live = this.#attachmentElements.filter((node) => node.isConnected);
|
|
645
|
+
const nodes = live.length > 0 ? live : this.#attachmentsById();
|
|
646
|
+
const rects = nodes
|
|
647
|
+
.map((node) => node.getBoundingClientRect())
|
|
648
|
+
.filter(({ width, height }) => width > 0 || height > 0);
|
|
649
|
+
if (rects.length === 0)
|
|
650
|
+
return undefined;
|
|
651
|
+
const left = Math.min(...rects.map((rect) => rect.left));
|
|
652
|
+
const right = Math.max(...rects.map((rect) => rect.right));
|
|
653
|
+
const top = Math.min(...rects.map((rect) => rect.top));
|
|
654
|
+
const bottom = Math.max(...rects.map((rect) => rect.bottom));
|
|
655
|
+
return { left, right, top, bottom, width: right - left, height: bottom - top };
|
|
656
|
+
}
|
|
657
|
+
/**
|
|
658
|
+
* Last-resort lookup once the attached elements are gone, such as after HMR
|
|
659
|
+
* replaced them. Ids are not unique — a node rendered in a loop repeats its
|
|
660
|
+
* id on every instance — so this takes only the first match per id. Spanning
|
|
661
|
+
* every repetition would put the arrow in the empty middle of the group.
|
|
662
|
+
*/
|
|
663
|
+
#attachmentsById() {
|
|
664
|
+
const wanted = new Set(this.#attachments.map(({ nodeId }) => nodeId));
|
|
665
|
+
const first = new Map();
|
|
666
|
+
for (const node of document.querySelectorAll('[data-astro-ai-id]')) {
|
|
667
|
+
const id = node.getAttribute('data-astro-ai-id') ?? '';
|
|
668
|
+
if (wanted.has(id) && !first.has(id))
|
|
669
|
+
first.set(id, node);
|
|
670
|
+
}
|
|
671
|
+
return [...first.values()];
|
|
672
|
+
}
|
|
673
|
+
/** Re-measures the connector after the page geometry may have changed. */
|
|
674
|
+
refreshConnector() {
|
|
675
|
+
this.#scheduleConnector();
|
|
676
|
+
}
|
|
677
|
+
#renderConnector() {
|
|
678
|
+
// Fall back to the live selection only while this window's own attachment
|
|
679
|
+
// cannot be measured, such as immediately after an HMR re-render.
|
|
680
|
+
const target = this.#attachmentAnchor() ?? this.#selectionAnchor;
|
|
681
|
+
if (target === undefined ||
|
|
682
|
+
this.element.hidden ||
|
|
683
|
+
this.element.dataset.context !== 'selection') {
|
|
684
|
+
this.#connector.style.display = 'none';
|
|
685
|
+
return;
|
|
686
|
+
}
|
|
687
|
+
const drawer = this.element.getBoundingClientRect();
|
|
688
|
+
const targetY = target.top + target.height / 2;
|
|
689
|
+
const drawerY = clamp(targetY, drawer.top + 28, drawer.bottom - 28);
|
|
690
|
+
const targetIsLeft = target.left + target.width / 2 < drawer.left + drawer.width / 2;
|
|
691
|
+
const startX = targetIsLeft ? drawer.left : drawer.right;
|
|
692
|
+
const endX = targetIsLeft ? target.right : target.left;
|
|
693
|
+
const bend = Math.max(48, Math.abs(startX - endX) * 0.42);
|
|
694
|
+
const firstControl = startX + (targetIsLeft ? -bend : bend);
|
|
695
|
+
const secondControl = endX + (targetIsLeft ? bend : -bend);
|
|
696
|
+
this.#connectorPath.setAttribute('d', `M ${startX} ${drawerY} C ${firstControl} ${drawerY}, ${secondControl} ${targetY}, ${endX} ${targetY}`);
|
|
697
|
+
this.#connector.style.display = 'block';
|
|
698
|
+
}
|
|
699
|
+
#renderContext() {
|
|
700
|
+
this.#context.replaceChildren();
|
|
701
|
+
const contextKind = this.#externalContext?.kind ?? (this.#attachments.length === 0 ? 'page' : 'selection');
|
|
702
|
+
this.element.dataset.context = contextKind;
|
|
703
|
+
this.element.dataset.attachmentState = this.#attachments.length === 0
|
|
704
|
+
? 'none'
|
|
705
|
+
: this.#locked
|
|
706
|
+
? 'locked'
|
|
707
|
+
: 'attached';
|
|
708
|
+
this.#scheduleConnector();
|
|
709
|
+
const contextCopy = element('div', 'context-copy');
|
|
710
|
+
const contextEyebrow = element('span', 'context-eyebrow');
|
|
711
|
+
contextEyebrow.textContent = this.#externalContext !== undefined
|
|
712
|
+
? `Attached ${this.#externalContext.kind}`
|
|
713
|
+
: this.#attachments.length === 0
|
|
714
|
+
? 'Page-level scope'
|
|
715
|
+
: this.#locked
|
|
716
|
+
? 'Locked edit scope'
|
|
717
|
+
: this.#attachments.length === 1
|
|
718
|
+
? 'Attached selection'
|
|
719
|
+
: 'Attached selections';
|
|
720
|
+
const label = element('div', 'context-label');
|
|
721
|
+
if (this.#externalContext !== undefined) {
|
|
722
|
+
const tag = element('span', 'tag-pill');
|
|
723
|
+
tag.textContent = this.#externalContext.kind === 'error' ? 'Error' : 'Audit';
|
|
724
|
+
const source = document.createElement('span');
|
|
725
|
+
source.textContent = this.#externalContext.file === undefined
|
|
726
|
+
? this.#externalContext.title
|
|
727
|
+
: `${middleTruncatePath(this.#externalContext.file, 34)}${this.#externalContext.line === undefined ? '' : `:${this.#externalContext.line}`}`;
|
|
728
|
+
label.append(tag, source);
|
|
729
|
+
label.title = `${this.#externalContext.title}\n${this.#externalContext.message}`;
|
|
730
|
+
}
|
|
731
|
+
else if (this.#attachments.length === 0) {
|
|
732
|
+
label.textContent = 'Entire page / project';
|
|
733
|
+
label.title = 'No element is attached to the next message.';
|
|
734
|
+
}
|
|
735
|
+
else {
|
|
736
|
+
const attachment = this.#attachments[0];
|
|
737
|
+
if (attachment === undefined)
|
|
738
|
+
return;
|
|
739
|
+
const path = attachment.source.file;
|
|
740
|
+
const tag = element('span', 'tag-pill');
|
|
741
|
+
tag.textContent = this.#attachments.length === 1
|
|
742
|
+
? attachment.label
|
|
743
|
+
: `${this.#attachments.length} elements`;
|
|
744
|
+
const source = document.createElement('span');
|
|
745
|
+
source.textContent = this.#attachments.length === 1
|
|
746
|
+
? `${middleTruncatePath(path, 34)}:${attachment.source.start.line}`
|
|
747
|
+
: this.#attachments.map(({ label: itemLabel }) => itemLabel).join(', ');
|
|
748
|
+
label.append(tag, source);
|
|
749
|
+
label.title = this.#attachments
|
|
750
|
+
.map(({ label: itemLabel, source: itemSource }) => `${itemLabel} · ${itemSource.file}:${itemSource.start.line}`)
|
|
751
|
+
.join('\n');
|
|
752
|
+
}
|
|
753
|
+
contextCopy.append(contextEyebrow, label);
|
|
754
|
+
const controls = element('div', 'context-controls');
|
|
755
|
+
if (this.#externalContext !== undefined) {
|
|
756
|
+
controls.append(iconButton('×', 'Use page-level context', () => {
|
|
757
|
+
this.#externalContext = undefined;
|
|
758
|
+
this.#renderContext();
|
|
759
|
+
}));
|
|
760
|
+
}
|
|
761
|
+
else {
|
|
762
|
+
const useCurrent = iconButton('↻', 'Replace with current selection', () => {
|
|
763
|
+
if (this.#currentSelections.length === 0 || this.#locked)
|
|
764
|
+
return;
|
|
765
|
+
this.#attachments = this.#currentSelections.map(createSelectionAttachment);
|
|
766
|
+
this.#attachmentElements = [...this.#currentSelectionElements];
|
|
767
|
+
this.#renderContext();
|
|
768
|
+
});
|
|
769
|
+
useCurrent.disabled = this.#currentSelections.length === 0 || this.#locked;
|
|
770
|
+
const lock = iconButton(this.#locked ? '●' : '○', this.#locked ? 'Unlock AI edit scope' : 'Lock AI edits to attached files', () => {
|
|
771
|
+
this.#locked = !this.#locked;
|
|
772
|
+
this.#renderContext();
|
|
773
|
+
});
|
|
774
|
+
lock.disabled = this.#attachments.length === 0;
|
|
775
|
+
lock.setAttribute('aria-pressed', String(this.#locked));
|
|
776
|
+
const remove = iconButton('×', 'Remove attachment', () => {
|
|
777
|
+
if (this.#locked)
|
|
778
|
+
return;
|
|
779
|
+
this.#attachments = [];
|
|
780
|
+
this.#attachmentElements = [];
|
|
781
|
+
this.#renderContext();
|
|
782
|
+
});
|
|
783
|
+
remove.disabled = this.#attachments.length === 0 || this.#locked;
|
|
784
|
+
controls.append(useCurrent, lock, remove);
|
|
785
|
+
}
|
|
786
|
+
this.#context.append(contextCopy, controls);
|
|
787
|
+
this.#write(DRAWER_CONTEXT_KEY, JSON.stringify({
|
|
788
|
+
...(this.#attachments.length === 0 ? {} : { attachments: this.#attachments }),
|
|
789
|
+
...(this.#externalContext === undefined ? {} : { externalContext: this.#externalContext }),
|
|
790
|
+
locked: this.#locked,
|
|
791
|
+
}));
|
|
792
|
+
}
|
|
793
|
+
#renderFiles() {
|
|
794
|
+
this.#fileTray.replaceChildren();
|
|
795
|
+
this.#fileTray.hidden = this.#files.length === 0;
|
|
796
|
+
for (const file of this.#files) {
|
|
797
|
+
const chip = element('span', 'file-chip');
|
|
798
|
+
chip.dataset.fileName = file.name;
|
|
799
|
+
chip.title = `${file.name} · ${formatFileSize(file.size)}`;
|
|
800
|
+
const icon = file.kind === 'image'
|
|
801
|
+
? document.createElement('img')
|
|
802
|
+
: element('span', 'file-icon');
|
|
803
|
+
icon.className = file.kind === 'image' ? 'file-preview' : 'file-icon';
|
|
804
|
+
if (file.kind === 'image') {
|
|
805
|
+
icon.setAttribute('alt', '');
|
|
806
|
+
icon.setAttribute('src', `data:${file.mediaType};base64,${file.content}`);
|
|
807
|
+
}
|
|
808
|
+
else {
|
|
809
|
+
icon.textContent = '▤';
|
|
810
|
+
}
|
|
811
|
+
const name = element('span', 'file-name');
|
|
812
|
+
name.textContent = file.name;
|
|
813
|
+
const remove = iconButton('×', `Remove ${file.name}`, () => {
|
|
814
|
+
this.#files = this.#files.filter((candidate) => candidate !== file);
|
|
815
|
+
this.#renderFiles();
|
|
816
|
+
});
|
|
817
|
+
remove.classList.add('file-remove');
|
|
818
|
+
chip.append(icon, name, remove);
|
|
819
|
+
this.#fileTray.append(chip);
|
|
820
|
+
}
|
|
821
|
+
}
|
|
822
|
+
#createRun(requestId, instruction, attachments, externalContext, files, startedAt = Date.now(), persist = true) {
|
|
823
|
+
this.#emptyState.hidden = true;
|
|
824
|
+
const turn = element('article', 'conversation-turn');
|
|
825
|
+
const userMessage = element('div', 'user-message');
|
|
826
|
+
const userText = document.createElement('p');
|
|
827
|
+
userText.textContent = instruction;
|
|
828
|
+
const scope = element('span', 'message-scope');
|
|
829
|
+
const sourceScope = externalContext !== undefined
|
|
830
|
+
? `${externalContext.kind === 'error' ? 'Error' : 'Audit'} · ${externalContext.file ?? externalContext.title}`
|
|
831
|
+
: attachments === undefined || attachments.length === 0
|
|
832
|
+
? 'Page-level'
|
|
833
|
+
: attachments.length === 1 && attachments[0] !== undefined
|
|
834
|
+
? `${attachments[0].label} · ${middleTruncatePath(attachments[0].source.file, 28)}:${attachments[0].source.start.line}`
|
|
835
|
+
: `${attachments.length} selected elements`;
|
|
836
|
+
const fileScope = files === undefined || files.length === 0
|
|
837
|
+
? ''
|
|
838
|
+
: ` · ${files.length} file${files.length === 1 ? '' : 's'}`;
|
|
839
|
+
scope.textContent = `${sourceScope}${fileScope}`;
|
|
840
|
+
const sourceTitle = externalContext !== undefined
|
|
841
|
+
? externalContext.message
|
|
842
|
+
: attachments === undefined || attachments.length === 0
|
|
843
|
+
? 'No source element attached'
|
|
844
|
+
: attachments.map(({ source }) => `${source.file}:${source.start.line}`).join('\n');
|
|
845
|
+
scope.title = [sourceTitle, ...(files ?? []).map(({ name, size }) => `${name} · ${formatFileSize(size)}`)]
|
|
846
|
+
.filter(Boolean)
|
|
847
|
+
.join('\n');
|
|
848
|
+
userMessage.append(userText, scope);
|
|
849
|
+
const runRoot = element('section', 'agent-run');
|
|
850
|
+
runRoot.dataset.status = 'running';
|
|
851
|
+
const header = element('div', 'run-header');
|
|
852
|
+
const icon = element('span', 'run-icon');
|
|
853
|
+
icon.setAttribute('aria-hidden', 'true');
|
|
854
|
+
const title = document.createElement('strong');
|
|
855
|
+
title.textContent = 'Starting agent';
|
|
856
|
+
const elapsed = element('span', 'run-elapsed');
|
|
857
|
+
elapsed.textContent = '0s';
|
|
858
|
+
header.append(icon, title, elapsed);
|
|
859
|
+
const steps = element('ul', 'run-steps');
|
|
860
|
+
const summary = element('div', 'run-summary');
|
|
861
|
+
summary.textContent = 'Connecting to the configured CLI…';
|
|
862
|
+
runRoot.append(header, steps, summary);
|
|
863
|
+
turn.append(userMessage, runRoot);
|
|
864
|
+
this.#messages.append(turn);
|
|
865
|
+
const run = { requestId, root: runRoot, icon, title, elapsed, steps, summary, startedAt };
|
|
866
|
+
this.#runs.set(requestId, run);
|
|
867
|
+
if (persist) {
|
|
868
|
+
this.#runStates.set(requestId, {
|
|
869
|
+
requestId,
|
|
870
|
+
instruction,
|
|
871
|
+
...(attachments === undefined || attachments.length === 0 ? {} : { attachments }),
|
|
872
|
+
...(files === undefined || files.length === 0 ? {} : { files }),
|
|
873
|
+
...(externalContext === undefined ? {} : { externalContext }),
|
|
874
|
+
startedAt,
|
|
875
|
+
status: 'running',
|
|
876
|
+
title: 'Starting agent',
|
|
877
|
+
summary: 'Connecting to the configured CLI…',
|
|
878
|
+
steps: [],
|
|
879
|
+
});
|
|
880
|
+
this.#persistRuns();
|
|
881
|
+
}
|
|
882
|
+
this.#syncElapsedTimer();
|
|
883
|
+
return run;
|
|
884
|
+
}
|
|
885
|
+
#updateStep(run, stage, message) {
|
|
886
|
+
for (const step of run.steps.querySelectorAll('.run-step')) {
|
|
887
|
+
step.dataset.status = 'complete';
|
|
888
|
+
}
|
|
889
|
+
let step = run.steps.querySelector(`[data-stage="${stage}"]`);
|
|
890
|
+
if (step === null) {
|
|
891
|
+
step = element('li', 'run-step');
|
|
892
|
+
step.dataset.stage = stage;
|
|
893
|
+
const dot = element('span', 'step-dot');
|
|
894
|
+
const copy = document.createElement('span');
|
|
895
|
+
copy.textContent = stageLabel(stage);
|
|
896
|
+
step.append(dot, copy);
|
|
897
|
+
run.steps.append(step);
|
|
898
|
+
}
|
|
899
|
+
step.dataset.status = 'active';
|
|
900
|
+
step.title = message;
|
|
901
|
+
const persisted = this.#runStates.get(run.requestId);
|
|
902
|
+
if (persisted !== undefined) {
|
|
903
|
+
const existing = persisted.steps.find((candidate) => candidate.state === stage);
|
|
904
|
+
if (existing === undefined)
|
|
905
|
+
persisted.steps.push({ state: stage, message });
|
|
906
|
+
else
|
|
907
|
+
existing.message = message;
|
|
908
|
+
persisted.title = stageTitle(stage);
|
|
909
|
+
persisted.summary = message;
|
|
910
|
+
}
|
|
911
|
+
}
|
|
912
|
+
#finishRun(run, status, title, summary) {
|
|
913
|
+
run.root.dataset.status = status;
|
|
914
|
+
run.title.textContent = title;
|
|
915
|
+
run.summary.replaceChildren(renderAgentMarkdown(summary));
|
|
916
|
+
for (const step of run.steps.querySelectorAll('.run-step')) {
|
|
917
|
+
step.dataset.status = status === 'completed' ? 'complete' : 'stopped';
|
|
918
|
+
}
|
|
919
|
+
run.elapsed.textContent = formatElapsed(Date.now() - run.startedAt);
|
|
920
|
+
const persisted = this.#runStates.get(run.requestId);
|
|
921
|
+
if (persisted !== undefined) {
|
|
922
|
+
persisted.status = status;
|
|
923
|
+
persisted.title = title;
|
|
924
|
+
persisted.summary = summary;
|
|
925
|
+
}
|
|
926
|
+
}
|
|
927
|
+
#restoreRuns() {
|
|
928
|
+
const restored = this.#readJson(DRAWER_RUNS_KEY) ?? [];
|
|
929
|
+
if (!Array.isArray(restored))
|
|
930
|
+
return;
|
|
931
|
+
for (const persisted of restored.slice(-20)) {
|
|
932
|
+
if (typeof persisted?.requestId !== 'string' ||
|
|
933
|
+
typeof persisted.instruction !== 'string' ||
|
|
934
|
+
typeof persisted.startedAt !== 'number' ||
|
|
935
|
+
!Array.isArray(persisted.steps))
|
|
936
|
+
continue;
|
|
937
|
+
this.#runStates.set(persisted.requestId, persisted);
|
|
938
|
+
const run = this.#createRun(persisted.requestId, persisted.instruction, persisted.attachments, persisted.externalContext, persisted.files, persisted.startedAt, false);
|
|
939
|
+
for (const step of persisted.steps)
|
|
940
|
+
this.#updateStep(run, step.state, step.message);
|
|
941
|
+
if (persisted.status === 'running') {
|
|
942
|
+
run.title.textContent = persisted.title;
|
|
943
|
+
run.summary.textContent = persisted.summary;
|
|
944
|
+
run.elapsed.textContent = formatElapsed(Date.now() - persisted.startedAt);
|
|
945
|
+
this.#activeRequestId = persisted.requestId;
|
|
946
|
+
this.#submittedDrafts.set(persisted.requestId, persisted.instruction);
|
|
947
|
+
}
|
|
948
|
+
else {
|
|
949
|
+
this.#finishRun(run, persisted.status, persisted.title, persisted.summary);
|
|
950
|
+
if (persisted.diff !== undefined)
|
|
951
|
+
this.#renderDiff(run, persisted.diff);
|
|
952
|
+
}
|
|
953
|
+
}
|
|
954
|
+
this.#emptyState.hidden = this.#runs.size > 0;
|
|
955
|
+
this.#syncElapsedTimer();
|
|
956
|
+
if (this.#runs.size > 0)
|
|
957
|
+
this.#scrollToLatest();
|
|
958
|
+
}
|
|
959
|
+
#renderDiff(run, diff) {
|
|
960
|
+
run.root.querySelector('.run-diff')?.remove();
|
|
961
|
+
const details = element('details', 'run-diff');
|
|
962
|
+
const label = document.createElement('summary');
|
|
963
|
+
label.textContent = 'Review source diff';
|
|
964
|
+
const code = document.createElement('code');
|
|
965
|
+
code.textContent = diff;
|
|
966
|
+
details.append(label, code);
|
|
967
|
+
run.root.append(details);
|
|
968
|
+
const persisted = this.#runStates.get(run.requestId);
|
|
969
|
+
if (persisted !== undefined)
|
|
970
|
+
persisted.diff = diff;
|
|
971
|
+
}
|
|
972
|
+
#persistRuns() {
|
|
973
|
+
this.#write(DRAWER_RUNS_KEY, JSON.stringify([...this.#runStates.values()].slice(-20)));
|
|
974
|
+
}
|
|
975
|
+
#syncComposer() {
|
|
976
|
+
const connected = this.#provider?.available === true && this.#provider.authenticated;
|
|
977
|
+
const running = this.#activeRequestId !== undefined;
|
|
978
|
+
this.#input.disabled = running || !connected;
|
|
979
|
+
this.#submit.disabled = running || !connected || this.#input.value.trim() === '';
|
|
980
|
+
this.#cancel.hidden = !running;
|
|
981
|
+
this.#mode.disabled = running;
|
|
982
|
+
this.#attach.disabled = running || !connected;
|
|
983
|
+
this.#fileInput.disabled = running || !connected;
|
|
984
|
+
this.#mode.setAttribute('aria-pressed', String(this.#answerOnly));
|
|
985
|
+
this.#mode.textContent = this.#answerOnly ? 'Answer only ✓' : 'Answer only';
|
|
986
|
+
if (!connected && this.#provider !== undefined) {
|
|
987
|
+
this.#input.placeholder = this.#provider.message;
|
|
988
|
+
}
|
|
989
|
+
else if (this.#answerOnly) {
|
|
990
|
+
this.#input.placeholder = 'Ask a question · source changes disabled…';
|
|
991
|
+
}
|
|
992
|
+
else {
|
|
993
|
+
this.#input.placeholder = 'Ask about the project or request a change…';
|
|
994
|
+
}
|
|
995
|
+
}
|
|
996
|
+
#syncElapsedTimer() {
|
|
997
|
+
const running = [...this.#runs.values()].some(({ root }) => root.dataset.status === 'running');
|
|
998
|
+
if (running && this.#elapsedTimer === undefined) {
|
|
999
|
+
this.#elapsedTimer = window.setInterval(() => {
|
|
1000
|
+
for (const run of this.#runs.values()) {
|
|
1001
|
+
if (run.root.dataset.status === 'running') {
|
|
1002
|
+
run.elapsed.textContent = formatElapsed(Date.now() - run.startedAt);
|
|
1003
|
+
}
|
|
1004
|
+
}
|
|
1005
|
+
}, 1_000);
|
|
1006
|
+
}
|
|
1007
|
+
else if (!running && this.#elapsedTimer !== undefined) {
|
|
1008
|
+
window.clearInterval(this.#elapsedTimer);
|
|
1009
|
+
this.#elapsedTimer = undefined;
|
|
1010
|
+
}
|
|
1011
|
+
}
|
|
1012
|
+
#scrollToLatest() {
|
|
1013
|
+
requestAnimationFrame(() => {
|
|
1014
|
+
this.#messages.scrollTop = this.#messages.scrollHeight;
|
|
1015
|
+
});
|
|
1016
|
+
}
|
|
1017
|
+
#onSubmit = (event) => {
|
|
1018
|
+
event.preventDefault();
|
|
1019
|
+
const instruction = this.#input.value.trim();
|
|
1020
|
+
if (instruction === '' ||
|
|
1021
|
+
this.#activeRequestId !== undefined ||
|
|
1022
|
+
this.#provider?.authenticated !== true)
|
|
1023
|
+
return;
|
|
1024
|
+
const requestId = crypto.randomUUID();
|
|
1025
|
+
const attachments = this.#attachments.length === 0
|
|
1026
|
+
? undefined
|
|
1027
|
+
: cloneAttachments(this.#attachments);
|
|
1028
|
+
const files = this.#files.length === 0
|
|
1029
|
+
? undefined
|
|
1030
|
+
: this.#files.map((file) => ({ ...file }));
|
|
1031
|
+
const fileSummaries = files?.map(({ content: _content, ...summary }) => summary);
|
|
1032
|
+
const externalContext = this.#externalContext === undefined
|
|
1033
|
+
? undefined
|
|
1034
|
+
: { ...this.#externalContext };
|
|
1035
|
+
this.#activeRequestId = requestId;
|
|
1036
|
+
this.#submittedDrafts.set(requestId, instruction);
|
|
1037
|
+
this.#createRun(requestId, instruction, attachments, externalContext, fileSummaries);
|
|
1038
|
+
this.#input.value = '';
|
|
1039
|
+
this.#files = [];
|
|
1040
|
+
this.#renderFiles();
|
|
1041
|
+
this.#syncComposer();
|
|
1042
|
+
this.#scrollToLatest();
|
|
1043
|
+
this.#callbacks.onSubmit({
|
|
1044
|
+
requestId,
|
|
1045
|
+
sessionId: this.sessionId,
|
|
1046
|
+
instruction,
|
|
1047
|
+
mode: this.#answerOnly ? 'answer' : 'auto',
|
|
1048
|
+
...(attachments === undefined ? {} : { attachments }),
|
|
1049
|
+
...(this.#locked && attachments !== undefined ? { locked: true } : {}),
|
|
1050
|
+
...(files === undefined ? {} : { files }),
|
|
1051
|
+
...(externalContext === undefined ? {} : { externalContext }),
|
|
1052
|
+
});
|
|
1053
|
+
};
|
|
1054
|
+
#onComposerKeyDown = (event) => {
|
|
1055
|
+
if (!isSendShortcut(event))
|
|
1056
|
+
return;
|
|
1057
|
+
event.preventDefault();
|
|
1058
|
+
this.#composer.requestSubmit();
|
|
1059
|
+
};
|
|
1060
|
+
#startDrag = (event) => {
|
|
1061
|
+
if (event.button !== 0)
|
|
1062
|
+
return;
|
|
1063
|
+
const target = event.target;
|
|
1064
|
+
if (target instanceof Element && target.closest('button, input, textarea, a, select') !== null) {
|
|
1065
|
+
return;
|
|
1066
|
+
}
|
|
1067
|
+
const rect = this.element.getBoundingClientRect();
|
|
1068
|
+
event.preventDefault();
|
|
1069
|
+
this.#position = { left: rect.left, top: rect.top };
|
|
1070
|
+
this.#applyPosition(this.#position);
|
|
1071
|
+
this.#dragOffset = {
|
|
1072
|
+
left: event.clientX - rect.left,
|
|
1073
|
+
top: event.clientY - rect.top,
|
|
1074
|
+
};
|
|
1075
|
+
this.element.dataset.dragging = 'true';
|
|
1076
|
+
window.addEventListener('pointermove', this.#dragMove);
|
|
1077
|
+
window.addEventListener('pointerup', this.#endDrag, { once: true });
|
|
1078
|
+
};
|
|
1079
|
+
#dragMove = (event) => {
|
|
1080
|
+
if (this.#dragOffset === undefined)
|
|
1081
|
+
return;
|
|
1082
|
+
const rect = this.element.getBoundingClientRect();
|
|
1083
|
+
const margin = isNarrowViewport() ? 0 : 8;
|
|
1084
|
+
const position = constrainChatWindowPosition({
|
|
1085
|
+
left: event.clientX - this.#dragOffset.left,
|
|
1086
|
+
top: event.clientY - this.#dragOffset.top,
|
|
1087
|
+
}, { width: rect.width, height: rect.height }, { width: window.innerWidth, height: window.innerHeight }, margin);
|
|
1088
|
+
this.#position = position;
|
|
1089
|
+
this.#applyPosition(position);
|
|
1090
|
+
this.#scheduleConnector();
|
|
1091
|
+
};
|
|
1092
|
+
#endDrag = () => {
|
|
1093
|
+
if (this.#dragOffset === undefined)
|
|
1094
|
+
return;
|
|
1095
|
+
this.#dragOffset = undefined;
|
|
1096
|
+
this.element.dataset.dragging = 'false';
|
|
1097
|
+
window.removeEventListener('pointermove', this.#dragMove);
|
|
1098
|
+
window.removeEventListener('pointerup', this.#endDrag);
|
|
1099
|
+
if (this.#position !== undefined) {
|
|
1100
|
+
this.#write(DRAWER_POSITION_KEY, JSON.stringify(this.#position));
|
|
1101
|
+
}
|
|
1102
|
+
};
|
|
1103
|
+
#startResize = (event) => {
|
|
1104
|
+
if (event.button !== 0)
|
|
1105
|
+
return;
|
|
1106
|
+
event.preventDefault();
|
|
1107
|
+
const rect = this.element.getBoundingClientRect();
|
|
1108
|
+
this.#position = { left: rect.left, top: rect.top };
|
|
1109
|
+
this.#applyPosition(this.#position);
|
|
1110
|
+
this.#resizeBounds = { right: rect.right, bottom: rect.bottom };
|
|
1111
|
+
this.element.dataset.resizing = 'true';
|
|
1112
|
+
window.addEventListener('pointermove', this.#resizeMove);
|
|
1113
|
+
window.addEventListener('pointerup', this.#endResize, { once: true });
|
|
1114
|
+
};
|
|
1115
|
+
#resizeMove = (event) => {
|
|
1116
|
+
if (this.#resizeBounds === undefined)
|
|
1117
|
+
return;
|
|
1118
|
+
if (isNarrowViewport()) {
|
|
1119
|
+
const height = clamp(this.#resizeBounds.bottom - event.clientY, 280, window.innerHeight * 0.92);
|
|
1120
|
+
this.element.style.height = `${height}px`;
|
|
1121
|
+
this.#position = {
|
|
1122
|
+
left: this.#position?.left ?? 0,
|
|
1123
|
+
top: this.#resizeBounds.bottom - height,
|
|
1124
|
+
};
|
|
1125
|
+
}
|
|
1126
|
+
else {
|
|
1127
|
+
const width = clamp(this.#resizeBounds.right - event.clientX, 360, Math.min(720, window.innerWidth * 0.8));
|
|
1128
|
+
this.element.style.width = `${width}px`;
|
|
1129
|
+
this.#position = {
|
|
1130
|
+
left: this.#resizeBounds.right - width,
|
|
1131
|
+
top: this.#position?.top ?? 8,
|
|
1132
|
+
};
|
|
1133
|
+
}
|
|
1134
|
+
if (this.#position !== undefined)
|
|
1135
|
+
this.#applyPosition(this.#position);
|
|
1136
|
+
this.#constrainPosition();
|
|
1137
|
+
this.#scheduleConnector();
|
|
1138
|
+
};
|
|
1139
|
+
#endResize = () => {
|
|
1140
|
+
this.#resizeBounds = undefined;
|
|
1141
|
+
this.element.dataset.resizing = 'false';
|
|
1142
|
+
window.removeEventListener('pointermove', this.#resizeMove);
|
|
1143
|
+
window.removeEventListener('pointerup', this.#endResize);
|
|
1144
|
+
if (this.#position !== undefined) {
|
|
1145
|
+
this.#write(DRAWER_POSITION_KEY, JSON.stringify(this.#position));
|
|
1146
|
+
}
|
|
1147
|
+
};
|
|
1148
|
+
#applyPosition(position) {
|
|
1149
|
+
this.element.style.left = `${position.left}px`;
|
|
1150
|
+
this.element.style.top = `${position.top}px`;
|
|
1151
|
+
this.element.style.right = 'auto';
|
|
1152
|
+
this.element.style.bottom = 'auto';
|
|
1153
|
+
}
|
|
1154
|
+
/**
|
|
1155
|
+
* Pulls the window back on screen. This runs for windows that were never
|
|
1156
|
+
* dragged too: a restored position, a cascade offset, or a viewport that
|
|
1157
|
+
* shrank between page loads can all leave a window off screen, and a chat
|
|
1158
|
+
* you cannot see is a chat you cannot close.
|
|
1159
|
+
*/
|
|
1160
|
+
#constrainPosition() {
|
|
1161
|
+
if (this.element.hidden)
|
|
1162
|
+
return;
|
|
1163
|
+
const rect = this.element.getBoundingClientRect();
|
|
1164
|
+
if (rect.width === 0 || rect.height === 0)
|
|
1165
|
+
return;
|
|
1166
|
+
const margin = isNarrowViewport() ? 0 : 8;
|
|
1167
|
+
const current = this.#position ?? { left: rect.left, top: rect.top };
|
|
1168
|
+
const position = constrainChatWindowPosition(current, { width: rect.width, height: rect.height }, { width: window.innerWidth, height: window.innerHeight }, margin);
|
|
1169
|
+
// An untouched window that is already on screen keeps its CSS anchoring,
|
|
1170
|
+
// so it stays pinned to the corner as the viewport changes.
|
|
1171
|
+
if (this.#position === undefined &&
|
|
1172
|
+
position.left === current.left &&
|
|
1173
|
+
position.top === current.top)
|
|
1174
|
+
return;
|
|
1175
|
+
this.#position = position;
|
|
1176
|
+
this.#applyPosition(position);
|
|
1177
|
+
this.#write(DRAWER_POSITION_KEY, JSON.stringify(position));
|
|
1178
|
+
this.#scheduleConnector();
|
|
1179
|
+
}
|
|
1180
|
+
}
|
|
1181
|
+
export function isSendShortcut(event) {
|
|
1182
|
+
return event.key === 'Enter' && !event.shiftKey && !event.isComposing;
|
|
1183
|
+
}
|
|
1184
|
+
export function chatWindowCollapseControl(minimized) {
|
|
1185
|
+
return minimized
|
|
1186
|
+
? { symbol: '✦', label: 'Expand chat window', expanded: false }
|
|
1187
|
+
: { symbol: '−', label: 'Collapse chat window', expanded: true };
|
|
1188
|
+
}
|
|
1189
|
+
export function constrainChatWindowPosition(position, size, viewport, margin = 8) {
|
|
1190
|
+
return {
|
|
1191
|
+
left: clamp(position.left, margin, Math.max(margin, viewport.width - size.width - margin)),
|
|
1192
|
+
top: clamp(position.top, margin, Math.max(margin, viewport.height - size.height - margin)),
|
|
1193
|
+
};
|
|
1194
|
+
}
|
|
1195
|
+
export function createChatDrawerStyle() {
|
|
1196
|
+
const style = document.createElement('style');
|
|
1197
|
+
style.textContent = `
|
|
1198
|
+
.ai-chat-drawer { --accent: #8b5cf6; background: #0e1117; border: 1px solid #343c49; border-radius: 14px; bottom: 16px; box-shadow: -12px 18px 60px rgb(0 0 0 / .42); box-sizing: border-box; color: #f8fafc; display: grid; font: 13px/1.45 ui-sans-serif, system-ui, sans-serif; grid-template-rows: auto auto 1fr; height: min(760px, calc(100vh - 32px)); max-height: 760px; overflow: hidden; position: fixed; right: 16px; top: 16px; width: min(460px, calc(100vw - 32px)); z-index: ${EDITOR_LAYERS.chatWindowTop}; }
|
|
1199
|
+
.ai-chat-drawer[hidden] { display: none; }
|
|
1200
|
+
.ai-chat-drawer[data-context='page'] { --accent: #c084fc; background: linear-gradient(165deg, #24143b 0%, #151324 52%, #10131a 100%); border-color: #6d4bb0; }
|
|
1201
|
+
.ai-chat-drawer[data-context='selection'] { background: linear-gradient(165deg, #111827 0%, #0e1117 42%); border-color: #6d5ac7; }
|
|
1202
|
+
.ai-chat-drawer[data-context='error'] { --accent: #fb7185; background: linear-gradient(165deg, #32151d 0%, #171116 48%, #0e1117 100%); border-color: #9f3949; }
|
|
1203
|
+
.ai-chat-drawer[data-context='audit'] { --accent: #fbbf24; background: linear-gradient(165deg, #30230f 0%, #19160f 48%, #0e1117 100%); border-color: #8a6622; }
|
|
1204
|
+
.ai-chat-drawer[data-minimized='true'] { border: 0; border-radius: 15px; bottom: auto; box-shadow: 0 12px 34px rgb(0 0 0 / .4); grid-template-rows: auto; height: 56px; min-height: 56px; overflow: visible; right: 16px; top: 16px; width: 56px; }
|
|
1205
|
+
.ai-chat-drawer[data-minimized='true'] .drawer-header { border: 0; min-height: 56px; padding: 0; }
|
|
1206
|
+
.ai-chat-drawer[data-minimized='true'] .drawer-identity, .ai-chat-drawer[data-minimized='true'] .provider-badge, .ai-chat-drawer[data-minimized='true'] .close-button { display: none; }
|
|
1207
|
+
.ai-chat-drawer[data-minimized='true'] .drawer-header-actions { display: block; height: 56px; width: 56px; }
|
|
1208
|
+
.ai-chat-drawer[data-minimized='true'] .collapse-button { background: linear-gradient(135deg, #7c3aed, #a78bfa); border: 1px solid #c4b5fd; border-radius: 15px; box-shadow: 0 0 0 4px rgb(139 92 246 / .14); color: white; font-size: 20px; height: 56px; padding: 0; position: relative; width: 56px; }
|
|
1209
|
+
.ai-chat-drawer[data-minimized='true'] .collapse-button::after { background: #22c55e; border: 2px solid #17131f; border-radius: 50%; bottom: 3px; content: ''; height: 8px; position: absolute; right: 3px; width: 8px; }
|
|
1210
|
+
.ai-chat-drawer[data-minimized='true'] .drawer-resize { display: none; }
|
|
1211
|
+
.drawer-resize { bottom: 0; cursor: ew-resize; left: -5px; position: absolute; top: 0; width: 10px; }
|
|
1212
|
+
.drawer-header { align-items: center; border-bottom: 1px solid #292f3a; cursor: grab; display: flex; justify-content: space-between; min-height: 62px; padding: 0 16px; touch-action: none; user-select: none; }
|
|
1213
|
+
.ai-chat-drawer[data-dragging='true'] .drawer-header { cursor: grabbing; }
|
|
1214
|
+
.drawer-identity, .drawer-header-actions, .history-actions, .context-label, .context-controls, .composer-actions, .run-header { align-items: center; display: flex; }
|
|
1215
|
+
.drawer-identity { gap: 10px; }
|
|
1216
|
+
.agent-mark { align-items: center; background: linear-gradient(135deg, #7c3aed, #a78bfa); border-radius: 8px; display: flex; font-size: 14px; height: 30px; justify-content: center; width: 30px; }
|
|
1217
|
+
.drawer-eyebrow { color: #929bab; display: block; font-size: 9px; font-weight: 700; letter-spacing: .11em; text-transform: uppercase; }
|
|
1218
|
+
.drawer-header h2 { font-size: 14px; line-height: 1.2; margin: 2px 0 0; }
|
|
1219
|
+
.drawer-title { background: transparent; border: 1px solid transparent; border-radius: 5px; color: inherit; font: 600 14px/1.2 inherit; margin: 1px 0 0; max-width: 190px; padding: 1px 4px; width: 100%; }
|
|
1220
|
+
.drawer-title:hover { border-color: #3a4250; }
|
|
1221
|
+
.drawer-title:focus { background: #0b0e14; border-color: var(--accent); outline: none; }
|
|
1222
|
+
.ai-chat-drawer[data-focused='false'] { opacity: .94; }
|
|
1223
|
+
.ai-chat-drawer[data-focused='false'] .drawer-header { filter: saturate(.75); }
|
|
1224
|
+
.ai-chat-drawer[data-minimized='true'] .new-window-button { display: none; }
|
|
1225
|
+
.drawer-header-actions { gap: 8px; }
|
|
1226
|
+
.provider-badge { background: #202631; border: 1px solid #343c49; border-radius: 999px; color: #aeb7c5; font-size: 10px; padding: 4px 8px; }
|
|
1227
|
+
.provider-badge[data-state='connected'] { background: #102c22; border-color: #225b45; color: #86efac; }
|
|
1228
|
+
.provider-badge[data-state='offline'] { background: #35191d; border-color: #713039; color: #fda4af; }
|
|
1229
|
+
.drawer-toolbar { align-items: center; background: #10141a; border-bottom: 1px solid #292f3a; display: flex; gap: 12px; justify-content: space-between; min-height: 42px; padding: 0 14px; }
|
|
1230
|
+
.history-actions { gap: 6px; }
|
|
1231
|
+
.history-button { font-size: 11px; padding: 5px 9px; }
|
|
1232
|
+
.drawer-notice { align-items: center; color: #9ca3af; display: flex; font-size: 10px; gap: 6px; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
1233
|
+
.drawer-notice::before { background: #22c55e; border-radius: 999px; content: ''; flex: 0 0 6px; height: 6px; width: 6px; }
|
|
1234
|
+
.drawer-notice[data-state='error'] { color: #fda4af; }
|
|
1235
|
+
.drawer-notice[data-state='error']::before { background: #ef4444; }
|
|
1236
|
+
.drawer-body { display: grid; grid-template-rows: auto minmax(0, 1fr) auto; min-height: 0; }
|
|
1237
|
+
.chat-context { align-items: center; background: #12161d; border-bottom: 1px solid #292f3a; display: flex; gap: 12px; justify-content: space-between; padding: 10px 14px; transition: background .15s, border-color .15s, box-shadow .15s; }
|
|
1238
|
+
.ai-chat-drawer[data-attachment-state='attached'] .chat-context { background: linear-gradient(90deg, rgb(8 47 73 / .94), rgb(14 116 144 / .42)); border-bottom-color: #0891b2; box-shadow: inset 4px 0 #22d3ee; }
|
|
1239
|
+
.ai-chat-drawer[data-attachment-state='attached'] .context-eyebrow { color: #67e8f9; }
|
|
1240
|
+
.ai-chat-drawer[data-attachment-state='attached'] .context-label { color: #ecfeff; }
|
|
1241
|
+
.ai-chat-drawer[data-attachment-state='attached'] .tag-pill { background: #155e75; border-color: #22d3ee; color: #ecfeff; }
|
|
1242
|
+
.ai-chat-drawer[data-attachment-state='locked'] .chat-context { background: linear-gradient(90deg, rgb(120 53 15 / .66), rgb(69 26 3 / .34)); border-bottom-color: #b45309; box-shadow: inset 3px 0 #f59e0b; }
|
|
1243
|
+
.ai-chat-drawer[data-attachment-state='locked'] .context-eyebrow { color: #fcd34d; }
|
|
1244
|
+
.ai-chat-drawer[data-attachment-state='locked'] .context-label { color: #ffedd5; }
|
|
1245
|
+
.ai-chat-drawer[data-attachment-state='locked'] .tag-pill { background: #9a3412; border-color: #f59e0b; color: #fff7ed; }
|
|
1246
|
+
.ai-chat-drawer[data-attachment-state='locked'] .context-controls [aria-pressed='true'] { background: #9a3412; border-color: #f59e0b; color: #ffedd5; }
|
|
1247
|
+
.context-copy { min-width: 0; }
|
|
1248
|
+
.context-eyebrow { color: #7f8998; display: block; font-size: 9px; font-weight: 700; letter-spacing: .08em; margin-bottom: 3px; text-transform: uppercase; }
|
|
1249
|
+
.context-label { color: #d9dce3; font: 11px/1.4 ui-monospace, SFMono-Regular, monospace; gap: 7px; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
1250
|
+
.tag-pill { background: #312e81; border: 1px solid #4f46a5; border-radius: 4px; color: #ddd6fe; font-family: ui-sans-serif, system-ui, sans-serif; font-weight: 700; padding: 2px 6px; }
|
|
1251
|
+
.context-controls { flex: 0 0 auto; gap: 4px; }
|
|
1252
|
+
.chat-messages { align-content: start; display: grid; gap: 18px; grid-auto-rows: max-content; min-height: 0; overflow: auto; overscroll-behavior: contain; padding: 20px 16px 24px; scrollbar-color: #353d4a transparent; }
|
|
1253
|
+
.chat-empty { align-items: center; align-self: center; color: #98a2b1; display: grid; justify-items: center; margin: clamp(40px, 14vh, 120px) auto 0; max-width: 280px; text-align: center; }
|
|
1254
|
+
.empty-mark { align-items: center; background: #1d2330; border: 1px solid #343d4a; border-radius: 12px; color: #c4b5fd; display: flex; font-size: 18px; height: 44px; justify-content: center; margin-bottom: 12px; width: 44px; }
|
|
1255
|
+
.chat-empty strong { color: #e5e7eb; font-size: 14px; }
|
|
1256
|
+
.chat-empty p { font-size: 12px; margin: 6px 0 0; }
|
|
1257
|
+
.conversation-turn { display: grid; gap: 10px; }
|
|
1258
|
+
.user-message { background: linear-gradient(135deg, #37308a, #312e70); border: 1px solid #5048a2; border-radius: 12px 12px 3px 12px; display: grid; gap: 7px; justify-self: end; max-width: 88%; padding: 10px 12px; }
|
|
1259
|
+
.user-message p { margin: 0; overflow-wrap: anywhere; }
|
|
1260
|
+
.message-scope { color: #c4b5fd; font: 9px/1.4 ui-monospace, SFMono-Regular, monospace; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
1261
|
+
.agent-run { background: #151a22; border: 1px solid #2d3440; border-radius: 10px; display: grid; gap: 10px; justify-self: stretch; padding: 11px 12px; }
|
|
1262
|
+
.run-header { gap: 8px; }
|
|
1263
|
+
.run-header strong { font-size: 12px; }
|
|
1264
|
+
.run-elapsed { color: #778190; font: 10px/1.4 ui-monospace, SFMono-Regular, monospace; margin-left: auto; }
|
|
1265
|
+
.run-icon { border: 2px solid #3b4350; border-radius: 50%; box-sizing: border-box; height: 15px; position: relative; width: 15px; }
|
|
1266
|
+
.agent-run[data-status='running'] .run-icon { animation: agent-spin .8s linear infinite; border-right-color: var(--accent); border-top-color: var(--accent); }
|
|
1267
|
+
.agent-run[data-status='completed'] { background: #111f19; border-color: #24513e; }
|
|
1268
|
+
.agent-run[data-status='completed'] .run-icon { background: #22c55e; border: 0; }
|
|
1269
|
+
.agent-run[data-status='completed'] .run-icon::after { color: #052e16; content: '✓'; font-size: 10px; font-weight: 900; left: 3px; position: absolute; top: 0; }
|
|
1270
|
+
.agent-run[data-status='failed'] { background: #241416; border-color: #71313a; }
|
|
1271
|
+
.agent-run[data-status='failed'] .run-icon, .agent-run[data-status='cancelled'] .run-icon { border-color: #f87171; }
|
|
1272
|
+
.run-steps { display: flex; flex-wrap: wrap; gap: 5px; list-style: none; margin: 0; padding: 0; }
|
|
1273
|
+
.run-step { align-items: center; background: #1d232c; border-radius: 999px; color: #7f8998; display: flex; font-size: 9px; gap: 5px; padding: 3px 7px; }
|
|
1274
|
+
.step-dot { background: #596270; border-radius: 50%; height: 5px; width: 5px; }
|
|
1275
|
+
.run-step[data-status='active'] { color: #ddd6fe; }
|
|
1276
|
+
.run-step[data-status='active'] .step-dot { animation: agent-pulse 1.2s ease-in-out infinite; background: #a78bfa; }
|
|
1277
|
+
.run-step[data-status='complete'] { color: #9ca3af; }
|
|
1278
|
+
.run-step[data-status='complete'] .step-dot { background: #22c55e; }
|
|
1279
|
+
.run-summary { color: #aeb7c5; font-size: 11px; margin: 0; overflow-wrap: anywhere; }
|
|
1280
|
+
.run-summary p { margin: 0 0 8px; white-space: pre-wrap; }
|
|
1281
|
+
.run-summary p:last-child { margin-bottom: 0; }
|
|
1282
|
+
.run-summary ul { margin: 4px 0 8px; padding-left: 20px; }
|
|
1283
|
+
.run-summary pre, .run-diff code { background: #090c11; border: 1px solid #303744; border-radius: 7px; color: #dbeafe; display: block; font: 10px/1.5 ui-monospace, SFMono-Regular, monospace; margin: 7px 0; max-height: 260px; overflow: auto; padding: 9px; white-space: pre; }
|
|
1284
|
+
.run-diff { border-top: 1px solid #29313b; color: #c4b5fd; font-size: 10px; margin-top: 10px; padding-top: 8px; }
|
|
1285
|
+
.run-diff summary { cursor: pointer; font-weight: 700; }
|
|
1286
|
+
.chat-composer { background: #10141a; border-top: 1px solid #292f3a; padding: 12px 14px 14px; }
|
|
1287
|
+
.composer-frame { background: #090c11; border: 1px solid #343c49; border-radius: 10px; transition: border-color .15s, box-shadow .15s; }
|
|
1288
|
+
.composer-frame:focus-within { border-color: #7567d6; box-shadow: 0 0 0 2px rgb(139 92 246 / .15); }
|
|
1289
|
+
.composer-frame[data-dragging-file='true'] { border-color: #a78bfa; box-shadow: 0 0 0 3px rgb(139 92 246 / .2); }
|
|
1290
|
+
.file-tray { display: flex; flex-wrap: wrap; gap: 6px; padding: 8px 9px 0; }
|
|
1291
|
+
.file-tray[hidden] { display: none; }
|
|
1292
|
+
.file-chip { align-items: center; background: #1b2130; border: 1px solid #384155; border-radius: 7px; color: #dbe4f0; display: flex; gap: 6px; max-width: 100%; padding: 4px 5px 4px 7px; }
|
|
1293
|
+
.file-icon { color: #a78bfa; font-size: 11px; }
|
|
1294
|
+
.file-preview { border-radius: 4px; height: 28px; object-fit: cover; width: 36px; }
|
|
1295
|
+
.file-name { font: 10px/1.3 ui-monospace, SFMono-Regular, monospace; max-width: 190px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
1296
|
+
.file-remove { border: 0; height: 20px; padding: 0; width: 20px; }
|
|
1297
|
+
textarea { background: transparent; border: 0; box-sizing: border-box; color: white; font: inherit; max-height: 28vh; min-height: 70px; outline: none; padding: 10px 11px 6px; resize: none; width: 100%; }
|
|
1298
|
+
textarea::placeholder { color: #687281; }
|
|
1299
|
+
textarea:disabled { cursor: not-allowed; opacity: .55; }
|
|
1300
|
+
.composer-footer { align-items: center; display: flex; gap: 8px; justify-content: space-between; padding: 6px 7px 7px 11px; }
|
|
1301
|
+
.shortcut-hint { color: #66707e; font-size: 9px; }
|
|
1302
|
+
.composer-actions { gap: 6px; }
|
|
1303
|
+
button { background: #252b35; border: 1px solid #3c4451; border-radius: 6px; color: #f8fafc; cursor: pointer; font: inherit; padding: 6px 9px; }
|
|
1304
|
+
button:hover:not(:disabled), button[aria-pressed='true'] { background: #3c326c; border-color: #7668c8; }
|
|
1305
|
+
button:focus-visible, textarea:focus-visible { outline: 2px solid #a78bfa; outline-offset: 2px; }
|
|
1306
|
+
button:disabled { cursor: not-allowed; opacity: .35; }
|
|
1307
|
+
.icon-button { align-items: center; display: flex; height: 28px; justify-content: center; padding: 0; width: 28px; }
|
|
1308
|
+
.send-button { background: #6d4bd1; border-color: #8061dc; font-weight: 700; }
|
|
1309
|
+
.send-button:hover:not(:disabled) { background: #7959dc; }
|
|
1310
|
+
.secondary-button { background: transparent; }
|
|
1311
|
+
.mode-button { color: #c4b5fd; white-space: nowrap; }
|
|
1312
|
+
.mode-button[aria-pressed='true'] { background: #312e81; border-color: #7c3aed; color: #ede9fe; }
|
|
1313
|
+
.attach-button { font-size: 13px; }
|
|
1314
|
+
@keyframes agent-spin { to { transform: rotate(360deg); } }
|
|
1315
|
+
@keyframes agent-pulse { 50% { opacity: .35; transform: scale(.75); } }
|
|
1316
|
+
@media (prefers-reduced-motion: reduce) { .run-icon, .step-dot { animation: none !important; } }
|
|
1317
|
+
@media (max-width: 720px) {
|
|
1318
|
+
.ai-chat-drawer { border-left: 0; border-top: 1px solid #3f4552; bottom: 0; box-shadow: 0 -14px 35px rgb(0 0 0 / .32); height: min(76vh, 680px); max-width: none; top: auto; width: 100vw !important; }
|
|
1319
|
+
.ai-chat-drawer[data-minimized='true'] { border: 0; bottom: auto; height: 56px; right: 12px; top: 12px; width: 56px !important; }
|
|
1320
|
+
.drawer-resize { cursor: ns-resize; height: 10px; left: 0; right: 0; top: -5px; width: auto; }
|
|
1321
|
+
.shortcut-hint { display: none; }
|
|
1322
|
+
}
|
|
1323
|
+
`;
|
|
1324
|
+
return style;
|
|
1325
|
+
}
|
|
1326
|
+
function cloneAttachments(attachments) {
|
|
1327
|
+
return JSON.parse(JSON.stringify(attachments));
|
|
1328
|
+
}
|
|
1329
|
+
function formatFileSize(bytes) {
|
|
1330
|
+
return bytes < 1_000 ? `${bytes} B` : `${Math.round(bytes / 1_000)} KB`;
|
|
1331
|
+
}
|
|
1332
|
+
function base64FromBytes(bytes) {
|
|
1333
|
+
let binary = '';
|
|
1334
|
+
const chunkSize = 32_768;
|
|
1335
|
+
for (let index = 0; index < bytes.length; index += chunkSize) {
|
|
1336
|
+
binary += String.fromCharCode(...bytes.subarray(index, index + chunkSize));
|
|
1337
|
+
}
|
|
1338
|
+
return window.btoa(binary);
|
|
1339
|
+
}
|
|
1340
|
+
/** Small, injection-safe markdown subset for CLI explanations. */
|
|
1341
|
+
export function parseAgentMarkdown(source) {
|
|
1342
|
+
const blocks = [];
|
|
1343
|
+
const lines = source.replaceAll('\r\n', '\n').split('\n');
|
|
1344
|
+
let paragraph = [];
|
|
1345
|
+
const flushParagraph = () => {
|
|
1346
|
+
if (paragraph.length > 0)
|
|
1347
|
+
blocks.push({ kind: 'paragraph', text: paragraph.join('\n') });
|
|
1348
|
+
paragraph = [];
|
|
1349
|
+
};
|
|
1350
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
1351
|
+
const line = lines[index] ?? '';
|
|
1352
|
+
const fence = line.match(/^```([^`]*)$/);
|
|
1353
|
+
if (fence !== null) {
|
|
1354
|
+
flushParagraph();
|
|
1355
|
+
const code = [];
|
|
1356
|
+
index += 1;
|
|
1357
|
+
while (index < lines.length && !/^```/.test(lines[index] ?? '')) {
|
|
1358
|
+
code.push(lines[index] ?? '');
|
|
1359
|
+
index += 1;
|
|
1360
|
+
}
|
|
1361
|
+
const language = fence[1]?.trim();
|
|
1362
|
+
blocks.push({ kind: 'code', text: code.join('\n'), ...(language === undefined || language === '' ? {} : { language }) });
|
|
1363
|
+
continue;
|
|
1364
|
+
}
|
|
1365
|
+
if (/^\s*[-*]\s+/.test(line)) {
|
|
1366
|
+
flushParagraph();
|
|
1367
|
+
const items = [];
|
|
1368
|
+
while (index < lines.length && /^\s*[-*]\s+/.test(lines[index] ?? '')) {
|
|
1369
|
+
items.push((lines[index] ?? '').replace(/^\s*[-*]\s+/, ''));
|
|
1370
|
+
index += 1;
|
|
1371
|
+
}
|
|
1372
|
+
index -= 1;
|
|
1373
|
+
blocks.push({ kind: 'list', items });
|
|
1374
|
+
continue;
|
|
1375
|
+
}
|
|
1376
|
+
if (line.trim() === '')
|
|
1377
|
+
flushParagraph();
|
|
1378
|
+
else
|
|
1379
|
+
paragraph.push(line);
|
|
1380
|
+
}
|
|
1381
|
+
flushParagraph();
|
|
1382
|
+
return blocks;
|
|
1383
|
+
}
|
|
1384
|
+
function renderAgentMarkdown(source) {
|
|
1385
|
+
const fragment = document.createDocumentFragment();
|
|
1386
|
+
for (const block of parseAgentMarkdown(source)) {
|
|
1387
|
+
if (block.kind === 'paragraph') {
|
|
1388
|
+
const paragraph = document.createElement('p');
|
|
1389
|
+
paragraph.textContent = block.text;
|
|
1390
|
+
fragment.append(paragraph);
|
|
1391
|
+
}
|
|
1392
|
+
else if (block.kind === 'code') {
|
|
1393
|
+
const pre = document.createElement('pre');
|
|
1394
|
+
const code = document.createElement('code');
|
|
1395
|
+
if (block.language !== undefined)
|
|
1396
|
+
code.dataset.language = block.language;
|
|
1397
|
+
code.textContent = block.text;
|
|
1398
|
+
pre.append(code);
|
|
1399
|
+
fragment.append(pre);
|
|
1400
|
+
}
|
|
1401
|
+
else {
|
|
1402
|
+
const list = document.createElement('ul');
|
|
1403
|
+
for (const item of block.items) {
|
|
1404
|
+
const row = document.createElement('li');
|
|
1405
|
+
row.textContent = item;
|
|
1406
|
+
list.append(row);
|
|
1407
|
+
}
|
|
1408
|
+
fragment.append(list);
|
|
1409
|
+
}
|
|
1410
|
+
}
|
|
1411
|
+
return fragment;
|
|
1412
|
+
}
|
|
1413
|
+
function providerLabel(provider) {
|
|
1414
|
+
if (provider === undefined || provider === '' || provider === 'none')
|
|
1415
|
+
return 'Agent';
|
|
1416
|
+
if (provider === 'codex')
|
|
1417
|
+
return 'Codex';
|
|
1418
|
+
if (provider === 'claude')
|
|
1419
|
+
return 'Claude';
|
|
1420
|
+
return provider;
|
|
1421
|
+
}
|
|
1422
|
+
function stageTitle(state) {
|
|
1423
|
+
const titles = {
|
|
1424
|
+
planning: 'Understanding your request',
|
|
1425
|
+
queued: 'Waiting for another chat window',
|
|
1426
|
+
reading: 'Preparing source context',
|
|
1427
|
+
editing: 'Working on your request',
|
|
1428
|
+
validation: 'Reviewing the result',
|
|
1429
|
+
diagnostics: 'Applying the transaction',
|
|
1430
|
+
};
|
|
1431
|
+
return titles[state] ?? 'Agent is working';
|
|
1432
|
+
}
|
|
1433
|
+
function stageLabel(state) {
|
|
1434
|
+
const labels = {
|
|
1435
|
+
planning: 'Plan',
|
|
1436
|
+
queued: 'Queued',
|
|
1437
|
+
reading: 'Prepare',
|
|
1438
|
+
editing: 'Work',
|
|
1439
|
+
validation: 'Review',
|
|
1440
|
+
diagnostics: 'Apply',
|
|
1441
|
+
};
|
|
1442
|
+
return labels[state] ?? state;
|
|
1443
|
+
}
|
|
1444
|
+
function formatElapsed(milliseconds) {
|
|
1445
|
+
const seconds = Math.max(0, Math.floor(milliseconds / 1_000));
|
|
1446
|
+
if (seconds < 60)
|
|
1447
|
+
return `${seconds}s`;
|
|
1448
|
+
return `${Math.floor(seconds / 60)}m ${seconds % 60}s`;
|
|
1449
|
+
}
|
|
1450
|
+
function createSelectionConnector(sessionId) {
|
|
1451
|
+
const namespace = 'http://www.w3.org/2000/svg';
|
|
1452
|
+
// Every window owns a connector, so the marker id must be unique per window:
|
|
1453
|
+
// duplicate ids all resolve to the first match, and the arrowheads of the
|
|
1454
|
+
// remaining windows vanish as soon as that window is closed.
|
|
1455
|
+
const markerId = `astro-ai-selection-arrow-${sessionId}`;
|
|
1456
|
+
const svg = document.createElementNS(namespace, 'svg');
|
|
1457
|
+
svg.dataset.astroAiUi = 'selection-connector';
|
|
1458
|
+
svg.setAttribute('aria-hidden', 'true');
|
|
1459
|
+
Object.assign(svg.style, {
|
|
1460
|
+
inset: '0',
|
|
1461
|
+
overflow: 'visible',
|
|
1462
|
+
pointerEvents: 'none',
|
|
1463
|
+
position: 'fixed',
|
|
1464
|
+
width: '100vw',
|
|
1465
|
+
height: '100vh',
|
|
1466
|
+
zIndex: String(EDITOR_LAYERS.selectionConnector),
|
|
1467
|
+
});
|
|
1468
|
+
const defs = document.createElementNS(namespace, 'defs');
|
|
1469
|
+
const marker = document.createElementNS(namespace, 'marker');
|
|
1470
|
+
marker.id = markerId;
|
|
1471
|
+
marker.setAttribute('markerWidth', '8');
|
|
1472
|
+
marker.setAttribute('markerHeight', '8');
|
|
1473
|
+
marker.setAttribute('refX', '7');
|
|
1474
|
+
marker.setAttribute('refY', '4');
|
|
1475
|
+
marker.setAttribute('orient', 'auto');
|
|
1476
|
+
marker.setAttribute('markerUnits', 'strokeWidth');
|
|
1477
|
+
const arrow = document.createElementNS(namespace, 'path');
|
|
1478
|
+
arrow.setAttribute('d', 'M 0 0 L 8 4 L 0 8 z');
|
|
1479
|
+
arrow.setAttribute('fill', '#a78bfa');
|
|
1480
|
+
marker.append(arrow);
|
|
1481
|
+
defs.append(marker);
|
|
1482
|
+
const path = document.createElementNS(namespace, 'path');
|
|
1483
|
+
path.setAttribute('fill', 'none');
|
|
1484
|
+
path.setAttribute('stroke', '#a78bfa');
|
|
1485
|
+
path.setAttribute('stroke-linecap', 'round');
|
|
1486
|
+
path.setAttribute('stroke-width', '2');
|
|
1487
|
+
path.setAttribute('stroke-dasharray', '5 5');
|
|
1488
|
+
path.setAttribute('marker-end', `url(#${markerId})`);
|
|
1489
|
+
path.style.filter = 'drop-shadow(0 2px 4px rgb(0 0 0 / .45))';
|
|
1490
|
+
svg.append(defs, path);
|
|
1491
|
+
svg.style.display = 'none';
|
|
1492
|
+
return [svg, path];
|
|
1493
|
+
}
|
|
1494
|
+
function element(tag, className) {
|
|
1495
|
+
const output = document.createElement(tag);
|
|
1496
|
+
output.className = className;
|
|
1497
|
+
return output;
|
|
1498
|
+
}
|
|
1499
|
+
function button(label, onClick) {
|
|
1500
|
+
const output = document.createElement('button');
|
|
1501
|
+
output.type = 'button';
|
|
1502
|
+
output.textContent = label;
|
|
1503
|
+
if (onClick !== undefined)
|
|
1504
|
+
output.addEventListener('click', onClick);
|
|
1505
|
+
return output;
|
|
1506
|
+
}
|
|
1507
|
+
function iconButton(label, title, onClick) {
|
|
1508
|
+
const output = button(label, onClick);
|
|
1509
|
+
output.className = 'icon-button';
|
|
1510
|
+
output.title = title;
|
|
1511
|
+
output.setAttribute('aria-label', title);
|
|
1512
|
+
return output;
|
|
1513
|
+
}
|
|
1514
|
+
function clamp(value, minimum, maximum) {
|
|
1515
|
+
return Math.min(maximum, Math.max(minimum, value));
|
|
1516
|
+
}
|
|
1517
|
+
/**
|
|
1518
|
+
* Never throws. This decides the margin used to pull a window back on screen,
|
|
1519
|
+
* and an exception there would leave the window stranded out of view, so a
|
|
1520
|
+
* host without matchMedia falls back to the desktop margin.
|
|
1521
|
+
*/
|
|
1522
|
+
/** True when any part of the box is inside the viewport. */
|
|
1523
|
+
export function intersectsViewport(rect, viewport = {
|
|
1524
|
+
width: window.innerWidth,
|
|
1525
|
+
height: window.innerHeight,
|
|
1526
|
+
}) {
|
|
1527
|
+
return (rect.right > 0 &&
|
|
1528
|
+
rect.bottom > 0 &&
|
|
1529
|
+
rect.left < viewport.width &&
|
|
1530
|
+
rect.top < viewport.height);
|
|
1531
|
+
}
|
|
1532
|
+
function isNarrowViewport() {
|
|
1533
|
+
try {
|
|
1534
|
+
return window.matchMedia?.('(max-width: 720px)').matches === true;
|
|
1535
|
+
}
|
|
1536
|
+
catch {
|
|
1537
|
+
return false;
|
|
1538
|
+
}
|
|
1539
|
+
}
|
|
1540
|
+
function readSession(key) {
|
|
1541
|
+
try {
|
|
1542
|
+
return sessionStorage.getItem(key);
|
|
1543
|
+
}
|
|
1544
|
+
catch {
|
|
1545
|
+
return null;
|
|
1546
|
+
}
|
|
1547
|
+
}
|
|
1548
|
+
function writeSession(key, value) {
|
|
1549
|
+
try {
|
|
1550
|
+
sessionStorage.setItem(key, value);
|
|
1551
|
+
}
|
|
1552
|
+
catch {
|
|
1553
|
+
// Session persistence is a progressive enhancement in restricted browsers.
|
|
1554
|
+
}
|
|
1555
|
+
}
|
|
1556
|
+
function removeSession(key) {
|
|
1557
|
+
try {
|
|
1558
|
+
sessionStorage.removeItem(key);
|
|
1559
|
+
}
|
|
1560
|
+
catch {
|
|
1561
|
+
// Session persistence is a progressive enhancement in restricted browsers.
|
|
1562
|
+
}
|
|
1563
|
+
}
|
|
1564
|
+
function readSessionJson(key) {
|
|
1565
|
+
const value = readSession(key);
|
|
1566
|
+
if (value === null)
|
|
1567
|
+
return undefined;
|
|
1568
|
+
try {
|
|
1569
|
+
return JSON.parse(value);
|
|
1570
|
+
}
|
|
1571
|
+
catch {
|
|
1572
|
+
return undefined;
|
|
1573
|
+
}
|
|
1574
|
+
}
|
|
1575
|
+
//# sourceMappingURL=chat-drawer.js.map
|