@1agh/maude 0.56.0 → 0.58.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/README.md +5 -1
- package/apps/studio/acp/bridge.ts +385 -26
- package/apps/studio/acp/index.ts +498 -102
- package/apps/studio/acp/running.ts +97 -0
- package/apps/studio/acp/transcript.ts +64 -0
- package/apps/studio/acp/write-scope.ts +459 -0
- package/apps/studio/api.ts +69 -4
- package/apps/studio/build.ts +28 -1
- package/apps/studio/client/app.jsx +187 -39
- package/apps/studio/client/panels/ChatPanel.jsx +19 -1
- package/apps/studio/client/panels/CloudBar.jsx +72 -13
- package/apps/studio/client/panels/PermissionPrompt.jsx +146 -6
- package/apps/studio/client/panels/RepoBranchSwitcher.jsx +145 -6
- package/apps/studio/client/panels/acp-runtime.js +96 -5
- package/apps/studio/client/panels/chat-markdown.jsx +76 -5
- package/apps/studio/client/panels/file-preview.jsx +115 -0
- package/apps/studio/client/styles/3-shell-maude.css +56 -2
- package/apps/studio/client/styles/6-acp-chat.css +79 -0
- package/apps/studio/dist/client.bundle.js +1436 -1436
- package/apps/studio/dist/comment-mount.js +2 -2
- package/apps/studio/dist/styles.css +1 -1
- package/apps/studio/http.ts +59 -2
- package/apps/studio/input-router.tsx +39 -1
- package/apps/studio/server.ts +11 -0
- package/apps/studio/sync/connection-state.ts +116 -6
- package/apps/studio/sync/index.ts +50 -1
- package/apps/studio/sync/presentation.ts +273 -0
- package/apps/studio/sync/remote-docs.ts +191 -0
- package/apps/studio/sync/status.ts +4 -1
- package/apps/studio/test/acp-activity-endpoint.test.ts +183 -0
- package/apps/studio/test/acp-branch-guard.test.ts +123 -0
- package/apps/studio/test/acp-bridge-lifetime.test.ts +369 -0
- package/apps/studio/test/acp-caps-bridge.test.ts +5 -0
- package/apps/studio/test/acp-commands.test.ts +5 -0
- package/apps/studio/test/acp-elicitation-bridge.test.ts +5 -0
- package/apps/studio/test/acp-permission-prompt.test.ts +175 -1
- package/apps/studio/test/acp-permission.test.ts +18 -2
- package/apps/studio/test/acp-session-allowed-tools.test.ts +62 -14
- package/apps/studio/test/acp-usage-bridge.test.ts +5 -0
- package/apps/studio/test/acp-write-gate.test.ts +323 -0
- package/apps/studio/test/acp-write-scope.test.ts +533 -0
- package/apps/studio/test/bundle-smoke.test.ts +35 -18
- package/apps/studio/test/canvas-origin-gate.test.ts +7 -0
- package/apps/studio/test/cloud-connect-note.test.ts +135 -0
- package/apps/studio/test/cloud-endpoints.test.ts +11 -4
- package/apps/studio/test/cloud-shell-surfaces.test.ts +5 -2
- package/apps/studio/test/config-version.test.ts +88 -0
- package/apps/studio/test/fixtures/mock-acp-agent-slow.mjs +45 -0
- package/apps/studio/test/fixtures/mock-acp-agent-write.mjs +118 -0
- package/apps/studio/test/input-router.test.ts +56 -0
- package/apps/studio/test/sync-connect-honesty.test.ts +114 -0
- package/apps/studio/test/sync-connection-state.test.ts +45 -1
- package/apps/studio/test/sync-presentation.test.ts +185 -0
- package/apps/studio/test/sync-remote-docs.test.ts +171 -0
- package/apps/studio/test/whats-new.test.ts +13 -1
- package/apps/studio/whats-new.json +27 -0
- package/apps/studio/whats-new.ts +34 -10
- package/package.json +8 -8
|
@@ -123,6 +123,20 @@ export function applyUpdate(parts, toolIndex, u) {
|
|
|
123
123
|
export function createAcpConnection() {
|
|
124
124
|
let ws = null;
|
|
125
125
|
let openPromise = null;
|
|
126
|
+
// ── The re-attach seam (Addendum Task 8) ──────────────────────────────────
|
|
127
|
+
// The chat this connection speaks for, and how far its history has been
|
|
128
|
+
// hydrated. Both are set by `bindChat()` BEFORE the socket opens, because the
|
|
129
|
+
// `attach` frame that carries them is sent from `onopen`.
|
|
130
|
+
//
|
|
131
|
+
// `hydratedSeq` is a transcript LINE NUMBER, not a message count — the server
|
|
132
|
+
// stamps every `update` with the line it occupies, so the two are directly
|
|
133
|
+
// comparable. Frames at or below it are things the panel already rendered
|
|
134
|
+
// from the HTTP hydration, and dropping them is what stops a reload from
|
|
135
|
+
// showing the last few seconds twice.
|
|
136
|
+
let attachedChatId = null;
|
|
137
|
+
let hydratedSeq = 0;
|
|
138
|
+
/** True when the server said we re-joined a turn that is STILL running. */
|
|
139
|
+
const attachedListeners = new Set();
|
|
126
140
|
let turnHandler = null; // the in-flight run()'s frame sink
|
|
127
141
|
const statusListeners = new Set();
|
|
128
142
|
const status = { available: null, reason: undefined, ready: false };
|
|
@@ -240,6 +254,26 @@ export function createAcpConnection() {
|
|
|
240
254
|
}
|
|
241
255
|
|
|
242
256
|
function onFrame(frame) {
|
|
257
|
+
// ── The re-attach seam (Addendum Task 8) ────────────────────────────────
|
|
258
|
+
// Drop anything the panel already rendered from its HTTP hydration. The
|
|
259
|
+
// server replays the tail on `attach` and the live stream continues past
|
|
260
|
+
// it, so without this filter a reload mid-turn would show the overlap
|
|
261
|
+
// twice. Frames with no `seq` (every non-`update` frame) are unaffected.
|
|
262
|
+
if (typeof frame.seq === 'number') {
|
|
263
|
+
if (frame.seq <= hydratedSeq) return;
|
|
264
|
+
// Advance the marker as we consume, so a SECOND reconnect on the same
|
|
265
|
+
// page (a dropped socket, not a page reload) re-attaches from where the
|
|
266
|
+
// live stream actually got to rather than from the original hydration.
|
|
267
|
+
hydratedSeq = frame.seq;
|
|
268
|
+
}
|
|
269
|
+
if (frame.t === 'attached') {
|
|
270
|
+
for (const fn of attachedListeners) fn({ chat: frame.chat, running: !!frame.running });
|
|
271
|
+
// Re-joined a turn that is STILL running: restore the busy state, or the
|
|
272
|
+
// panel would look idle while the agent keeps working (and the composer
|
|
273
|
+
// would happily start a second, concurrent turn).
|
|
274
|
+
if (frame.running) setBusy(true);
|
|
275
|
+
return;
|
|
276
|
+
}
|
|
243
277
|
if (frame.t === 'ready') {
|
|
244
278
|
status.ready = true;
|
|
245
279
|
status.available = frame.available;
|
|
@@ -274,7 +308,17 @@ export function createAcpConnection() {
|
|
|
274
308
|
if (frame.t === 'permission-request') {
|
|
275
309
|
pendingPermissions = [
|
|
276
310
|
...pendingPermissions,
|
|
277
|
-
{
|
|
311
|
+
{
|
|
312
|
+
id: frame.id,
|
|
313
|
+
toolCall: frame.toolCall,
|
|
314
|
+
options: frame.options ?? [],
|
|
315
|
+
// feature-acp-write-path-scope — present only when the server's
|
|
316
|
+
// write-path gate declined a write tool. `{ outOfProjectWrite,
|
|
317
|
+
// resolvedPaths, scopeRoot, reason }`; absent for every ordinary
|
|
318
|
+
// permission request, so PermissionPrompt's "outside the project"
|
|
319
|
+
// copy can't fire on one.
|
|
320
|
+
scope: frame.scope ?? null,
|
|
321
|
+
},
|
|
278
322
|
];
|
|
279
323
|
emitPermissions();
|
|
280
324
|
return;
|
|
@@ -329,7 +373,28 @@ export function createAcpConnection() {
|
|
|
329
373
|
const scheme = location.protocol === 'https:' ? 'wss' : 'ws';
|
|
330
374
|
const sock = new WebSocket(`${scheme}://${location.host}${WS_PATH}`);
|
|
331
375
|
ws = sock;
|
|
332
|
-
sock.onopen = () =>
|
|
376
|
+
sock.onopen = () => {
|
|
377
|
+
// Addendum Task 8 — bind to this chat's (possibly still running) bridge
|
|
378
|
+
// and close the re-attach seam in the same frame. `hydratedSeq` is the
|
|
379
|
+
// transcript line the panel hydrated over HTTP; the server replays
|
|
380
|
+
// exactly what came after it. A reload mid-turn therefore rejoins the
|
|
381
|
+
// live stream with no hole and no doubled text.
|
|
382
|
+
//
|
|
383
|
+
// Sent unconditionally, including for a brand-new chat (seq 0, nothing
|
|
384
|
+
// to replay) — the attach is what registers the socket as a sink, so
|
|
385
|
+
// skipping it for new chats would leave them detached until the first
|
|
386
|
+
// prompt.
|
|
387
|
+
if (attachedChatId) {
|
|
388
|
+
try {
|
|
389
|
+
sock.send(
|
|
390
|
+
JSON.stringify({ t: 'attach', chat: attachedChatId, seq: hydratedSeq })
|
|
391
|
+
);
|
|
392
|
+
} catch {
|
|
393
|
+
/* the frame handlers below surface a dead socket */
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
resolve();
|
|
397
|
+
};
|
|
333
398
|
sock.onerror = () => reject(new Error('Could not reach the Claude bridge.'));
|
|
334
399
|
sock.onclose = () => {
|
|
335
400
|
ws = null;
|
|
@@ -423,13 +488,37 @@ export function createAcpConnection() {
|
|
|
423
488
|
* "resolved" frame; the turn just continues). A response for an id that's
|
|
424
489
|
* no longer pending (already timed out, or a duplicate click) is a no-op.
|
|
425
490
|
*/
|
|
491
|
+
/**
|
|
492
|
+
* Bind this connection to a chat and tell it how far the panel already
|
|
493
|
+
* hydrated that chat's transcript over HTTP (`seq` — a transcript LINE
|
|
494
|
+
* number, from the `X-Maude-Chat-Seq` response header).
|
|
495
|
+
*
|
|
496
|
+
* Must be called BEFORE the socket opens: the `attach` frame is sent from
|
|
497
|
+
* `onopen`, and it is what registers this socket as a sink for the chat's
|
|
498
|
+
* (possibly still running) server-side bridge. Calling it later still
|
|
499
|
+
* works for a subsequent reconnect, it just misses the current one.
|
|
500
|
+
*/
|
|
501
|
+
bindChat(chatId, seq = 0) {
|
|
502
|
+
attachedChatId = chatId || null;
|
|
503
|
+
hydratedSeq = Number.isFinite(seq) && seq > 0 ? Math.floor(seq) : 0;
|
|
504
|
+
},
|
|
505
|
+
|
|
506
|
+
/** Subscribe to `{ chat, running }` — fires once per successful attach.
|
|
507
|
+
* `running: true` means this socket re-joined a LIVE turn. */
|
|
508
|
+
onAttached(fn) {
|
|
509
|
+
attachedListeners.add(fn);
|
|
510
|
+
return () => attachedListeners.delete(fn);
|
|
511
|
+
},
|
|
512
|
+
|
|
426
513
|
async respondPermission(id, decision) {
|
|
427
514
|
if (!pendingPermissions.some((p) => p.id === id)) return;
|
|
428
515
|
pendingPermissions = pendingPermissions.filter((p) => p.id !== id);
|
|
429
516
|
emitPermissions();
|
|
430
517
|
try {
|
|
431
518
|
await ensureOpen();
|
|
432
|
-
|
|
519
|
+
// `chat` is echoed back from the request frame — a bridge is keyed by
|
|
520
|
+
// chat now, not by socket, so the response has to name which one.
|
|
521
|
+
ws?.send(JSON.stringify({ t: 'permission-response', id, chat: attachedChatId, decision }));
|
|
433
522
|
} catch {
|
|
434
523
|
/* socket unavailable — the bridge's own timeout will deny this request */
|
|
435
524
|
}
|
|
@@ -446,7 +535,9 @@ export function createAcpConnection() {
|
|
|
446
535
|
emitElicitations();
|
|
447
536
|
try {
|
|
448
537
|
await ensureOpen();
|
|
449
|
-
ws?.send(
|
|
538
|
+
ws?.send(
|
|
539
|
+
JSON.stringify({ t: 'elicitation-response', id, chat: attachedChatId, ...response })
|
|
540
|
+
);
|
|
450
541
|
} catch {
|
|
451
542
|
/* socket unavailable — the bridge's own timeout will decline this request */
|
|
452
543
|
}
|
|
@@ -532,7 +623,7 @@ export function createAcpConnection() {
|
|
|
532
623
|
};
|
|
533
624
|
const cancel = () => {
|
|
534
625
|
try {
|
|
535
|
-
ws?.send(JSON.stringify({ t: 'cancel' }));
|
|
626
|
+
ws?.send(JSON.stringify({ t: 'cancel', chat: attachedChatId || undefined }));
|
|
536
627
|
} catch {
|
|
537
628
|
/* socket already gone */
|
|
538
629
|
}
|
|
@@ -1,8 +1,11 @@
|
|
|
1
|
-
// Compact, XSS-safe markdown renderer for the ACP chat feed
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
1
|
+
// Compact, XSS-safe markdown renderer for the ACP chat feed (and, since
|
|
2
|
+
// feature-studio-file-preview, arbitrary repo .md files previewed in the
|
|
3
|
+
// Files tree). Builds React nodes directly (never innerHTML) so neither
|
|
4
|
+
// agent output nor untrusted file content can inject markup. Covers the
|
|
5
|
+
// subset a coding agent actually emits plus GFM pipe tables (common in repo
|
|
6
|
+
// docs): fenced code blocks, headings, bullet + numbered lists, pipe tables,
|
|
7
|
+
// bold / italic / inline code, and http(s) links. Anything else falls
|
|
8
|
+
// through as plain (pre-wrapped) text. No new dependency.
|
|
6
9
|
|
|
7
10
|
function safeHref(url) {
|
|
8
11
|
// Only allow http(s) — never javascript:/data:/etc.
|
|
@@ -47,6 +50,33 @@ function inline(text) {
|
|
|
47
50
|
return out;
|
|
48
51
|
}
|
|
49
52
|
|
|
53
|
+
// GFM pipe tables: a header row, a separator row (---/:---/---:/:---: per
|
|
54
|
+
// column), then zero or more data rows — all lines containing at least one
|
|
55
|
+
// unescaped `|`.
|
|
56
|
+
const TABLE_ROW_RE = /\|/;
|
|
57
|
+
const TABLE_SEP_CELL_RE = /^:?-+:?$/;
|
|
58
|
+
|
|
59
|
+
function splitTableRow(line) {
|
|
60
|
+
let l = line.trim();
|
|
61
|
+
if (l.startsWith('|')) l = l.slice(1);
|
|
62
|
+
if (l.endsWith('|')) l = l.slice(0, -1);
|
|
63
|
+
// Split on `|` not preceded by `\` (an escaped pipe stays literal in a cell).
|
|
64
|
+
return l.split(/(?<!\\)\|/).map((c) => c.trim().replace(/\\\|/g, '|'));
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function tableSeparatorAligns(line) {
|
|
68
|
+
const cells = splitTableRow(line);
|
|
69
|
+
if (!cells.length || !cells.every((c) => TABLE_SEP_CELL_RE.test(c))) return null;
|
|
70
|
+
return cells.map((c) => {
|
|
71
|
+
const left = c.startsWith(':');
|
|
72
|
+
const right = c.endsWith(':');
|
|
73
|
+
if (left && right) return 'center';
|
|
74
|
+
if (right) return 'right';
|
|
75
|
+
if (left) return 'left';
|
|
76
|
+
return null;
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
|
|
50
80
|
export function Markdown({ text }) {
|
|
51
81
|
const lines = String(text).split('\n');
|
|
52
82
|
const blocks = [];
|
|
@@ -108,6 +138,47 @@ export function Markdown({ text }) {
|
|
|
108
138
|
continue;
|
|
109
139
|
}
|
|
110
140
|
|
|
141
|
+
// GFM pipe table: header row + a valid separator row immediately after.
|
|
142
|
+
if (TABLE_ROW_RE.test(line) && i + 1 < lines.length) {
|
|
143
|
+
const aligns = tableSeparatorAligns(lines[i + 1]);
|
|
144
|
+
if (aligns) {
|
|
145
|
+
const header = splitTableRow(line);
|
|
146
|
+
i += 2;
|
|
147
|
+
const rows = [];
|
|
148
|
+
while (i < lines.length && lines[i].trim() !== '' && TABLE_ROW_RE.test(lines[i])) {
|
|
149
|
+
rows.push(splitTableRow(lines[i]));
|
|
150
|
+
i++;
|
|
151
|
+
}
|
|
152
|
+
blocks.push(
|
|
153
|
+
<div key={key++} className="chat-md-table-wrap">
|
|
154
|
+
<table className="chat-md-table">
|
|
155
|
+
<thead>
|
|
156
|
+
<tr>
|
|
157
|
+
{header.map((c, ci) => (
|
|
158
|
+
<th key={ci} style={aligns[ci] ? { textAlign: aligns[ci] } : undefined}>
|
|
159
|
+
{inline(c)}
|
|
160
|
+
</th>
|
|
161
|
+
))}
|
|
162
|
+
</tr>
|
|
163
|
+
</thead>
|
|
164
|
+
<tbody>
|
|
165
|
+
{rows.map((r, ri) => (
|
|
166
|
+
<tr key={ri}>
|
|
167
|
+
{header.map((_, ci) => (
|
|
168
|
+
<td key={ci} style={aligns[ci] ? { textAlign: aligns[ci] } : undefined}>
|
|
169
|
+
{r[ci] !== undefined ? inline(r[ci]) : ''}
|
|
170
|
+
</td>
|
|
171
|
+
))}
|
|
172
|
+
</tr>
|
|
173
|
+
))}
|
|
174
|
+
</tbody>
|
|
175
|
+
</table>
|
|
176
|
+
</div>
|
|
177
|
+
);
|
|
178
|
+
continue;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
111
182
|
// Blank line → paragraph separator.
|
|
112
183
|
if (line.trim() === '') {
|
|
113
184
|
i++;
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
// feature-studio-file-preview — inline preview for a non-canvas tree row
|
|
2
|
+
// (markdown / text / image / video / audio / font). Reuses the existing
|
|
3
|
+
// static byte-serving route (no new server endpoint) and the chat panel's
|
|
4
|
+
// hand-rolled Markdown renderer — no new dependency.
|
|
5
|
+
|
|
6
|
+
import { useEffect, useState } from 'react';
|
|
7
|
+
import { Markdown } from './chat-markdown.jsx';
|
|
8
|
+
|
|
9
|
+
const TEXT_PREVIEW_MAX_BYTES = 2 * 1024 * 1024; // 2 MB — beyond this, just show a size note
|
|
10
|
+
|
|
11
|
+
function basename(p) {
|
|
12
|
+
return p.split('/').pop() || p;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// Bidi-override control chars (U+202A-E, U+2066-9) let a crafted filename
|
|
16
|
+
// visually disguise its own name/extension (the classic "invoicegnp.exe"
|
|
17
|
+
// trick) wherever we render a raw path/name as text — strip them before
|
|
18
|
+
// display. Doesn't affect matching (previewKind() regexes run on the
|
|
19
|
+
// unstripped name), only what a human/AT reads.
|
|
20
|
+
export function sanitizeDisplayText(s) {
|
|
21
|
+
return String(s).replace(/[\u202a-\u202e\u2066-\u2069]/g, '');
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function useFetchedText(url, enabled) {
|
|
25
|
+
const [state, setState] = useState({ loading: true, error: null, text: '', tooLarge: false });
|
|
26
|
+
useEffect(() => {
|
|
27
|
+
if (!enabled) return;
|
|
28
|
+
let cancelled = false;
|
|
29
|
+
setState({ loading: true, error: null, text: '', tooLarge: false });
|
|
30
|
+
fetch(url)
|
|
31
|
+
.then(async (r) => {
|
|
32
|
+
if (!r.ok) throw new Error(`HTTP ${r.status}`);
|
|
33
|
+
const len = Number(r.headers.get('content-length') || 0);
|
|
34
|
+
if (len > TEXT_PREVIEW_MAX_BYTES) {
|
|
35
|
+
if (!cancelled) setState({ loading: false, error: null, text: '', tooLarge: len });
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
const text = await r.text();
|
|
39
|
+
if (!cancelled) setState({ loading: false, error: null, text, tooLarge: false });
|
|
40
|
+
})
|
|
41
|
+
.catch((err) => {
|
|
42
|
+
if (!cancelled) setState({ loading: false, error: err.message || 'Failed to load', text: '', tooLarge: false });
|
|
43
|
+
});
|
|
44
|
+
return () => {
|
|
45
|
+
cancelled = true;
|
|
46
|
+
};
|
|
47
|
+
}, [url, enabled]);
|
|
48
|
+
return state;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function TextPreview({ url, name, as }) {
|
|
52
|
+
const { loading, error, text, tooLarge } = useFetchedText(url, true);
|
|
53
|
+
if (loading) return <div className="st-file-preview-status">Loading {name}…</div>;
|
|
54
|
+
if (error) return <div className="st-file-preview-status st-file-preview-error">Couldn't load {name}: {error}</div>;
|
|
55
|
+
if (tooLarge) {
|
|
56
|
+
return (
|
|
57
|
+
<div className="st-file-preview-status">
|
|
58
|
+
{name} is {Math.round(tooLarge / 1024)} KB — too large to preview inline.
|
|
59
|
+
</div>
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
if (as === 'markdown') return <div className="st-file-preview-markdown"><Markdown text={text} /></div>;
|
|
63
|
+
return <pre className="st-file-preview-text">{text}</pre>;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// CSS-string-literal escape for a value interpolated inside url("...") —
|
|
67
|
+
// filenames can contain arbitrary characters (only "/" and NUL are forbidden
|
|
68
|
+
// on disk), so an unescaped `"` or `\` could break out of the string and
|
|
69
|
+
// inject arbitrary rules into the live app-shell stylesheet (not just the
|
|
70
|
+
// sandboxed canvas iframe). family is already alnum/hyphen-only; this covers
|
|
71
|
+
// the url() side of the same interpolation.
|
|
72
|
+
function cssStringEscape(s) {
|
|
73
|
+
// CSS input-preprocessing (CSS Syntax Module Level 3 §3.3) normalizes CR,
|
|
74
|
+
// CRLF, AND FORM FEED (\f, U+000C) to a single LF before tokenizing, so an
|
|
75
|
+
// unescaped \f terminates a double-quoted string exactly like \r/\n does —
|
|
76
|
+
// strip all three, not just the two obvious ones.
|
|
77
|
+
return String(s).replace(/[\\"]/g, (c) => `\\${c}`).replace(/[\r\n\f]/g, '');
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function FontPreview({ url, name }) {
|
|
81
|
+
const family = `st-preview-font-${name.replace(/[^a-z0-9]+/gi, '-')}`;
|
|
82
|
+
return (
|
|
83
|
+
<div className="st-file-preview-font">
|
|
84
|
+
<style>{`@font-face { font-family: "${family}"; src: url("${cssStringEscape(url)}"); }`}</style>
|
|
85
|
+
<div className="st-file-preview-font-specimen" style={{ fontFamily: family }}>
|
|
86
|
+
<div className="st-file-preview-font-lg">The quick brown fox</div>
|
|
87
|
+
<div className="st-file-preview-font-md">jumps over the lazy dog</div>
|
|
88
|
+
<div className="st-file-preview-font-sm">ABCDEFGHIJKLM abcdefghijklm 0123456789</div>
|
|
89
|
+
</div>
|
|
90
|
+
</div>
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function FilePreview({ path, kind }) {
|
|
95
|
+
if (!path || !kind) return null;
|
|
96
|
+
const url = `/${path}`;
|
|
97
|
+
const name = sanitizeDisplayText(basename(path));
|
|
98
|
+
return (
|
|
99
|
+
<div className="st-file-preview" data-kind={kind}>
|
|
100
|
+
<h2 className="st-file-preview-header">{sanitizeDisplayText(path)}</h2>
|
|
101
|
+
<div className="st-file-preview-body">
|
|
102
|
+
{kind === 'markdown' && <TextPreview url={url} name={name} as="markdown" />}
|
|
103
|
+
{kind === 'text' && <TextPreview url={url} name={name} as="text" />}
|
|
104
|
+
{kind === 'image' && (
|
|
105
|
+
<div className="st-file-preview-image-wrap">
|
|
106
|
+
<img src={url} alt={`Preview: ${name}`} className="st-file-preview-image" />
|
|
107
|
+
</div>
|
|
108
|
+
)}
|
|
109
|
+
{kind === 'video' && <video key={url} src={url} controls className="st-file-preview-media" />}
|
|
110
|
+
{kind === 'audio' && <audio key={url} src={url} controls className="st-file-preview-audio" />}
|
|
111
|
+
{kind === 'font' && <FontPreview url={url} name={name} />}
|
|
112
|
+
</div>
|
|
113
|
+
</div>
|
|
114
|
+
);
|
|
115
|
+
}
|
|
@@ -219,6 +219,10 @@
|
|
|
219
219
|
.st-sb-changes.has-changes .st-sb-changes-dot { background: var(--status-warn); }
|
|
220
220
|
.st-sb-changes.has-unpushed .st-sb-changes-dot { background: var(--accent); }
|
|
221
221
|
|
|
222
|
+
/* The running release. Ambient, not a state to act on — so it sits at the same
|
|
223
|
+
weight as a slot label rather than at .val's foreground. No new tokens. */
|
|
224
|
+
.st-sb-version .val { color: var(--fg-3); }
|
|
225
|
+
|
|
222
226
|
/* ═══ Task 4 — body row · sidebar · tree · rail ═══════════════════════════════ */
|
|
223
227
|
|
|
224
228
|
/* The body flex row replaces the legacy `.app` grid: rail | sidebar | main | rpanel. */
|
|
@@ -425,6 +429,47 @@
|
|
|
425
429
|
incomplete; auto-hides once probeSetupReadiness() reports ready. */
|
|
426
430
|
.st-empty-quick-setup { pointer-events: auto; margin-top: var(--space-2); }
|
|
427
431
|
|
|
432
|
+
/* ═══ feature-studio-file-preview — non-canvas file preview overlay ════════
|
|
433
|
+
* Layered above the (still-mounted) canvas iframe / empty state so switching
|
|
434
|
+
* back to a canvas is instant. Shell-token only, no hardcoded colors. */
|
|
435
|
+
.st-file-preview-overlay {
|
|
436
|
+
position: absolute; inset: 0; z-index: 5; display: flex; flex-direction: column;
|
|
437
|
+
background: var(--bg-1); overflow: auto;
|
|
438
|
+
}
|
|
439
|
+
.st-file-preview-overlay:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
|
|
440
|
+
.st-file-preview { display: flex; flex-direction: column; min-height: 100%; }
|
|
441
|
+
.st-file-preview-header {
|
|
442
|
+
flex: none; margin: 0; padding: var(--space-3) var(--space-4); font-family: var(--font-mono);
|
|
443
|
+
font-size: var(--type-xs); font-weight: 400; color: var(--fg-2); border-bottom: 1px solid var(--border-subtle);
|
|
444
|
+
background: var(--bg-0);
|
|
445
|
+
}
|
|
446
|
+
.st-file-preview-body { flex: 1; padding: var(--space-6); }
|
|
447
|
+
.st-file-preview-status { color: var(--fg-3); font-size: var(--type-sm); }
|
|
448
|
+
.st-file-preview-error { color: var(--status-error); }
|
|
449
|
+
.st-file-preview-text {
|
|
450
|
+
font-family: var(--font-mono); font-size: var(--type-xs); line-height: 1.6; color: var(--fg-1);
|
|
451
|
+
white-space: pre-wrap; word-break: break-word; background: var(--bg-0);
|
|
452
|
+
border: 1px solid var(--border-subtle); border-radius: var(--radius-sm); padding: var(--space-4);
|
|
453
|
+
}
|
|
454
|
+
.st-file-preview-markdown { max-width: 720px; color: var(--fg-1); }
|
|
455
|
+
.st-file-preview-image-wrap {
|
|
456
|
+
display: flex; align-items: center; justify-content: center; min-height: 200px;
|
|
457
|
+
background-image: linear-gradient(45deg, var(--bg-2) 25%, transparent 25%),
|
|
458
|
+
linear-gradient(-45deg, var(--bg-2) 25%, transparent 25%),
|
|
459
|
+
linear-gradient(45deg, transparent 75%, var(--bg-2) 75%),
|
|
460
|
+
linear-gradient(-45deg, transparent 75%, var(--bg-2) 75%);
|
|
461
|
+
background-size: 16px 16px; background-position: 0 0, 0 8px, 8px -8px, -8px 0;
|
|
462
|
+
border: 1px solid var(--border-subtle); border-radius: var(--radius-sm);
|
|
463
|
+
}
|
|
464
|
+
.st-file-preview-image { max-width: 100%; max-height: 70vh; object-fit: contain; }
|
|
465
|
+
.st-file-preview-media { max-width: 100%; max-height: 70vh; display: block; }
|
|
466
|
+
.st-file-preview-audio { width: 100%; }
|
|
467
|
+
.st-file-preview-font { display: flex; flex-direction: column; gap: var(--space-4); }
|
|
468
|
+
.st-file-preview-font-specimen { color: var(--fg-0); }
|
|
469
|
+
.st-file-preview-font-lg { font-size: 40px; line-height: 1.3; }
|
|
470
|
+
.st-file-preview-font-md { font-size: 24px; line-height: 1.4; }
|
|
471
|
+
.st-file-preview-font-sm { font-size: 14px; line-height: 1.6; color: var(--fg-2); word-break: break-all; }
|
|
472
|
+
|
|
428
473
|
/* ═══ Task 6 — right panel (Inspector / Comments) + palette + toast ═══════════ */
|
|
429
474
|
.st-rpanel {
|
|
430
475
|
background: var(--bg-1); border-left: 1px solid var(--border-default);
|
|
@@ -2240,10 +2285,19 @@ body.st-scrubbing, body.st-scrubbing * { cursor: ew-resize !important; user-sele
|
|
|
2240
2285
|
|
|
2241
2286
|
/* identity rail — sidebar footer */
|
|
2242
2287
|
.gi-rail { position: relative; flex: 0 0 auto; margin-top: auto; padding: var(--space-2) var(--space-3); border-top: 1px solid var(--border-default); display: flex; flex-direction: column; gap: var(--space-2); }
|
|
2243
|
-
|
|
2288
|
+
/* Truncation the code already assumed: CloudBar.jsx documents `title` as "the
|
|
2289
|
+
full sentence on hover (the rail truncates)" — and this rule did not exist,
|
|
2290
|
+
so a long hub-supplied project name wrapped and grew, pushing the rail into
|
|
2291
|
+
adjacent chrome. Its sibling .gi-rail-err has carried the same three
|
|
2292
|
+
properties all along. `anywhere` so a single unbreakable 60-char token
|
|
2293
|
+
cannot overflow horizontally either. */
|
|
2294
|
+
.gi-rail-hint { font-size: var(--type-xs); color: var(--fg-2); padding: var(--space-1) var(--space-1); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; overflow-wrap: anywhere; }
|
|
2244
2295
|
.gi-rail-signin { width: 100%; justify-content: center; }
|
|
2245
2296
|
.gi-rail-confirm { display: flex; align-items: center; gap: var(--space-2); flex-wrap: wrap; padding: var(--space-2); border: 1px solid var(--border-default); border-radius: var(--radius-md); background: var(--bg-2); }
|
|
2246
|
-
|
|
2297
|
+
/* …but the deep-link confirm strip is a paragraph that MUST wrap — it explains
|
|
2298
|
+
both sides of a consent decision, and one ellipsised line would hide half of
|
|
2299
|
+
what the person is agreeing to. */
|
|
2300
|
+
.gi-rail-confirm .gi-rail-hint { flex: 1 1 100%; padding: 0; white-space: normal; overflow: visible; }
|
|
2247
2301
|
.gi-rail-err { font-size: var(--type-xs); color: var(--status-error); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
2248
2302
|
.gi-avatar { border-radius: var(--radius-pill); display: grid; place-items: center; font-family: var(--font-mono); font-size: 10px; font-weight: 600; color: var(--accent-fg); background: var(--accent); flex: 0 0 auto; }
|
|
2249
2303
|
.gi-rail-account { display: flex; align-items: center; gap: var(--space-2); width: 100%; padding: var(--space-1) var(--space-2); border: 1px solid transparent; border-radius: var(--radius-md); background: transparent; color: var(--fg-0); cursor: pointer; text-align: left; }
|
|
@@ -619,6 +619,34 @@
|
|
|
619
619
|
border-radius: var(--radius-sm);
|
|
620
620
|
overflow-x: auto;
|
|
621
621
|
}
|
|
622
|
+
.chat-md-table-wrap {
|
|
623
|
+
overflow-x: auto;
|
|
624
|
+
border: 1px solid var(--border-subtle);
|
|
625
|
+
border-radius: var(--radius-sm);
|
|
626
|
+
}
|
|
627
|
+
.chat-md-table {
|
|
628
|
+
width: 100%;
|
|
629
|
+
border-collapse: collapse;
|
|
630
|
+
font-size: var(--type-xs);
|
|
631
|
+
}
|
|
632
|
+
.chat-md-table th,
|
|
633
|
+
.chat-md-table td {
|
|
634
|
+
padding: var(--space-2) var(--space-3);
|
|
635
|
+
text-align: left;
|
|
636
|
+
border-bottom: 1px solid var(--border-subtle);
|
|
637
|
+
white-space: pre-wrap;
|
|
638
|
+
}
|
|
639
|
+
.chat-md-table th {
|
|
640
|
+
background: var(--bg-2);
|
|
641
|
+
font-weight: 600;
|
|
642
|
+
color: var(--fg-0);
|
|
643
|
+
}
|
|
644
|
+
.chat-md-table td {
|
|
645
|
+
color: var(--fg-1);
|
|
646
|
+
}
|
|
647
|
+
.chat-md-table tbody tr:last-child td {
|
|
648
|
+
border-bottom: none;
|
|
649
|
+
}
|
|
622
650
|
.chat-code code {
|
|
623
651
|
font-family: var(--font-mono);
|
|
624
652
|
font-size: var(--type-xs);
|
|
@@ -880,6 +908,57 @@
|
|
|
880
908
|
gap: var(--space-2);
|
|
881
909
|
}
|
|
882
910
|
|
|
911
|
+
/* feature-acp-write-path-scope Task 4 — the out-of-project WRITE variant.
|
|
912
|
+
Distinguished from an ordinary permission card because the two ask
|
|
913
|
+
materially different questions: the ordinary one is "do you want this
|
|
914
|
+
tool to run", this one is "do you want a file written somewhere your
|
|
915
|
+
project's history stack cannot roll back". The danger tint is the whole
|
|
916
|
+
point — a card that looks identical to the routine one gets clicked
|
|
917
|
+
through at the same rate. */
|
|
918
|
+
.chat-perm--outside {
|
|
919
|
+
border-color: var(--danger, #d9534f);
|
|
920
|
+
}
|
|
921
|
+
.chat-perm--outside .chat-perm-hd b {
|
|
922
|
+
color: var(--danger, #d9534f);
|
|
923
|
+
}
|
|
924
|
+
.chat-perm-outside {
|
|
925
|
+
display: flex;
|
|
926
|
+
flex-direction: column;
|
|
927
|
+
gap: var(--space-1);
|
|
928
|
+
}
|
|
929
|
+
/* The resolved absolute paths. Monospaced + wrapping at any character: these
|
|
930
|
+
are the one string on the card the user actually has to READ, and a path
|
|
931
|
+
that overflows or ellipsizes is a path whose dangerous tail is hidden —
|
|
932
|
+
the same failure the .chat-perm-hd b clamp above exists to prevent, but
|
|
933
|
+
with the opposite fix, because here truncation is what would be unsafe. */
|
|
934
|
+
.chat-perm-paths {
|
|
935
|
+
margin: 0;
|
|
936
|
+
padding: 0;
|
|
937
|
+
list-style: none;
|
|
938
|
+
display: flex;
|
|
939
|
+
flex-direction: column;
|
|
940
|
+
gap: var(--space-1);
|
|
941
|
+
}
|
|
942
|
+
.chat-perm-path {
|
|
943
|
+
padding: var(--space-1) var(--space-2);
|
|
944
|
+
border-radius: var(--radius-xs);
|
|
945
|
+
background: var(--bg-1);
|
|
946
|
+
color: var(--fg-0);
|
|
947
|
+
font-family: var(--font-mono);
|
|
948
|
+
font-size: var(--type-xs);
|
|
949
|
+
overflow-wrap: anywhere;
|
|
950
|
+
}
|
|
951
|
+
.chat-perm-scope,
|
|
952
|
+
.chat-perm-note {
|
|
953
|
+
margin: 0;
|
|
954
|
+
font-size: var(--type-xs);
|
|
955
|
+
color: var(--fg-3);
|
|
956
|
+
}
|
|
957
|
+
.chat-perm-root {
|
|
958
|
+
font-family: var(--font-mono);
|
|
959
|
+
overflow-wrap: anywhere;
|
|
960
|
+
}
|
|
961
|
+
|
|
883
962
|
/* Form-elicitation card (feature-acp-ask-user-question — AskUserQuestion +
|
|
884
963
|
generic MCP form input). Same slot + shape as .chat-perm, mutually
|
|
885
964
|
exclusive with it — see the mount site in ChatPanel.jsx. */
|