dsh-generative-ui 0.0.0 → 0.0.2
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 +90 -0
- package/cordis.patch.yml +6 -0
- package/lib/client.js +18568 -0
- package/lib/client.js.map +62 -0
- package/lib/index.js +1597 -0
- package/lib/types/client/canvas/CanvasLauncher.d.ts +6 -0
- package/lib/types/client/canvas/CanvasPanel.d.ts +88 -0
- package/lib/types/client/canvas/collect.d.ts +45 -0
- package/lib/types/client/canvas/index.d.ts +43 -0
- package/lib/types/client/canvas/mount.d.ts +30 -0
- package/lib/types/client/canvas/panel-css.d.ts +1 -0
- package/lib/types/client/canvas/read.d.ts +12 -0
- package/lib/types/client/canvas/subpages.d.ts +20 -0
- package/lib/types/client/canvas/useDismissable.d.ts +15 -0
- package/lib/types/client/index.d.ts +20 -0
- package/lib/types/client/runtime/GenUISurface.d.ts +159 -0
- package/lib/types/client/runtime/bindings.d.ts +143 -0
- package/lib/types/client/runtime/compiler.d.ts +35 -0
- package/lib/types/client/runtime/inline-fence.d.ts +23 -0
- package/lib/types/client/runtime/observe.d.ts +30 -0
- package/lib/types/client/runtime/register.d.ts +2 -0
- package/lib/types/client/runtime/registry.d.ts +7 -0
- package/lib/types/client/runtime/report-error.d.ts +17 -0
- package/lib/types/client/runtime/segments.d.ts +18 -0
- package/lib/types/client/runtime/state.d.ts +18 -0
- package/lib/types/client/runtime/uno-config.d.ts +16 -0
- package/lib/types/client/runtime/uno.d.ts +50 -0
- package/lib/types/client/session.d.ts +26 -0
- package/lib/types/contract-assets.d.ts +41 -0
- package/lib/types/contract.d.ts +56 -0
- package/lib/types/index.d.ts +255 -0
- package/lib/types/prompt.d.ts +13 -0
- package/lib/types/skill.d.ts +27 -0
- package/package.json +135 -9
- package/src/client/canvas/CanvasLauncher.tsx +52 -0
- package/src/client/canvas/CanvasPanel.tsx +238 -0
- package/src/client/canvas/collect.ts +188 -0
- package/src/client/canvas/index.ts +255 -0
- package/src/client/canvas/mount.ts +91 -0
- package/src/client/canvas/panel-css.ts +2 -0
- package/src/client/canvas/panel.css +242 -0
- package/src/client/canvas/read.ts +55 -0
- package/src/client/canvas/subpages.ts +109 -0
- package/src/client/canvas/useDismissable.ts +37 -0
- package/src/client/index.ts +217 -0
- package/src/client/runtime/GenUISurface.tsx +359 -0
- package/src/client/runtime/bindings.ts +292 -0
- package/src/client/runtime/compiler.ts +80 -0
- package/src/client/runtime/inline-fence.ts +222 -0
- package/src/client/runtime/observe.ts +65 -0
- package/src/client/runtime/register.ts +57 -0
- package/src/client/runtime/registry.ts +65 -0
- package/src/client/runtime/report-error.ts +79 -0
- package/src/client/runtime/segments.ts +116 -0
- package/src/client/runtime/state.ts +47 -0
- package/src/client/runtime/uno-config.ts +71 -0
- package/src/client/runtime/uno.ts +124 -0
- package/src/client/session.ts +46 -0
- package/src/contract-assets.ts +46 -0
- package/src/contract.ts +111 -0
- package/src/index.ts +583 -0
- package/src/prompt.ts +377 -0
- package/src/skill.ts +931 -0
- package/types/README.md +34 -0
- package/types/ai.d.ts +14 -0
- package/types/chat.d.ts +14 -0
- package/types/check.ts +39 -0
- package/types/exec.d.ts +17 -0
- package/types/fs.d.ts +17 -0
- package/types/importmap.json +10 -0
- package/types/standalone/ai.js +7 -0
- package/types/standalone/chat.js +6 -0
- package/types/standalone/exec.js +7 -0
- package/types/standalone/fs.js +18 -0
- package/types/standalone/importmap.json +10 -0
- package/types/standalone/state.js +24 -0
- package/types/standalone/web.js +7 -0
- package/types/state.d.ts +25 -0
- package/types/web.d.ts +31 -0
- package/index.js +0 -1
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Wires the canvas column: watch the transcript for canvas writes, mount the panel when
|
|
3
|
+
* there are any, unmount when there are none.
|
|
4
|
+
*/
|
|
5
|
+
import { createElement } from "react";
|
|
6
|
+
import { createRoot } from "react-dom/client";
|
|
7
|
+
import { CanvasPanel, type Canvas } from "./CanvasPanel.tsx";
|
|
8
|
+
import { CanvasLauncher } from "./CanvasLauncher.tsx";
|
|
9
|
+
import { PANEL_CSS } from "./panel-css.ts";
|
|
10
|
+
import { collectCanvases, type ToolCallView } from "./collect.ts";
|
|
11
|
+
import { listCanvasIds, readCanvasFile } from "./read.ts";
|
|
12
|
+
import { createColumn, injectStyles, whenFrameReady } from "./mount.ts";
|
|
13
|
+
import { observeTranscript, scheduleSweep } from "../runtime/observe.ts";
|
|
14
|
+
|
|
15
|
+
export type CanvasHostOptions = {
|
|
16
|
+
/** Every tool call in the current session, oldest first. */
|
|
17
|
+
calls: () => readonly ToolCallView[];
|
|
18
|
+
/** The current session's workspace directory, when it has one. */
|
|
19
|
+
cwd: () => string | undefined;
|
|
20
|
+
/** Identity of the current session; dismissals are remembered against it. */
|
|
21
|
+
sessionId: () => string;
|
|
22
|
+
/**
|
|
23
|
+
* Where a canvas's compile failure goes. Optional because a host may have no chat channel;
|
|
24
|
+
* absent, the error is still painted for the reader and simply reaches nobody else.
|
|
25
|
+
*/
|
|
26
|
+
onCardError?: (message: string, phase: string) => void;
|
|
27
|
+
/** A canvas that painted; cancels a deferred error report the next frame made untrue. */
|
|
28
|
+
onCardRendered?: () => void;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
const EMPTY: ReadonlySet<string> = new Set();
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* A call whose arguments carry executable code rather than a file operation.
|
|
35
|
+
*
|
|
36
|
+
* Matched on the argument text, not the tool name, for the same reason `collect.ts` classifies
|
|
37
|
+
* by shape: the day the host renames `run_code`, a name list stops matching and canvases
|
|
38
|
+
* silently stop appearing.
|
|
39
|
+
*
|
|
40
|
+
* It must also mention the canvases directory. All 29 opaque canvas writes in the corpus do,
|
|
41
|
+
* even the 27 whose path is built from a variable — and without that clause a session doing
|
|
42
|
+
* ordinary shell work re-lists once per command (measured: median 0, but one session hit 94).
|
|
43
|
+
*/
|
|
44
|
+
/**
|
|
45
|
+
* What a sweep would paint, as a string. Equal signatures mean nothing to do.
|
|
46
|
+
*
|
|
47
|
+
* The observer fires on every streamed token, so this is what stands between the panel and a
|
|
48
|
+
* React render per token. `code.length` rather than the code: a growing card changes length on
|
|
49
|
+
* every frame, which is exactly when it should repaint, and comparing megabytes of source per
|
|
50
|
+
* token would cost more than the render. The offerable list belongs in it too — closing the
|
|
51
|
+
* last canvas changes nothing about the visible list, and without it the launcher never paints.
|
|
52
|
+
*/
|
|
53
|
+
export const paintSignature = (canvases: readonly Canvas[], offerable: readonly string[]): string => `${canvases.map((c) => `${c.id}:${c.code.length}:${String(c.streaming)}`).join("|")}#${offerable.join(",")}`;
|
|
54
|
+
|
|
55
|
+
export const OPAQUE_WRITE = /"(?:code|command)"\s*:[\s\S]*canvases/;
|
|
56
|
+
|
|
57
|
+
export function mountCanvasHost({ calls, cwd, sessionId, onCardError, onCardRendered }: CanvasHostOptions): { dispose: () => void; show: (id: string) => void } {
|
|
58
|
+
/**
|
|
59
|
+
* Canvas bodies re-read from disk, for the ones a patch left stale.
|
|
60
|
+
*
|
|
61
|
+
* Keyed by canvas id and versioned by how many mutating calls it has seen, so a further
|
|
62
|
+
* edit invalidates the entry while repeated sweeps over the same state reuse it. Without
|
|
63
|
+
* the version the choice is between never refetching and refetching every frame.
|
|
64
|
+
*/
|
|
65
|
+
const fromFile = new Map<string, { version: number; code?: string }>();
|
|
66
|
+
const disposers: (() => void)[] = [injectStyles(PANEL_CSS)];
|
|
67
|
+
/**
|
|
68
|
+
* Canvases the reader dismissed, per session.
|
|
69
|
+
*
|
|
70
|
+
* Closing hides what is on screen without opting out of the feature — a canvas written
|
|
71
|
+
* afterwards still opens. Keyed by session because ids are only unique within one: a
|
|
72
|
+
* dismissal must not follow the reader into another conversation, and returning to a
|
|
73
|
+
* session should find it as they left it.
|
|
74
|
+
*/
|
|
75
|
+
const dismissed = new Map<string, Set<string>>();
|
|
76
|
+
/**
|
|
77
|
+
* Canvases the reader opened from the launcher that this session never wrote.
|
|
78
|
+
*
|
|
79
|
+
* A canvas outlives its session, so the launcher offers everything in the workspace —
|
|
80
|
+
* but those have no tool call to reconstruct them from, and their bodies come off disk.
|
|
81
|
+
* Keyed by session for the same reason `dismissed` is.
|
|
82
|
+
*/
|
|
83
|
+
const opened = new Map<string, Set<string>>();
|
|
84
|
+
/** Bodies of the launcher-opened canvases. Read once: nothing in this session changes them. */
|
|
85
|
+
const openedCode = new Map<string, string>();
|
|
86
|
+
/** Every canvas on disk, for the launcher. Refreshed per workspace, not per sweep. */
|
|
87
|
+
let workspaceIds: readonly string[] = [];
|
|
88
|
+
let listedFor: string | undefined;
|
|
89
|
+
/** Settled opaque calls at the time of the last listing — see the sweep for why. */
|
|
90
|
+
let listedAfter = -1;
|
|
91
|
+
// `null` until the first paint, so an empty first paint is still a paint. An empty string
|
|
92
|
+
// would collide with the signature of "no canvases" and skip it.
|
|
93
|
+
let signature: string | null = null;
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Show a canvas the reader picked, whether or not this session wrote it.
|
|
97
|
+
*
|
|
98
|
+
* Defined out here rather than inside the sweep because callers outside the panel reach
|
|
99
|
+
* it too — the transcript's file links, which would otherwise hand a `.ui4a.tsx` to the
|
|
100
|
+
* OS file opener.
|
|
101
|
+
*/
|
|
102
|
+
const show = (id: string): void => {
|
|
103
|
+
const session = sessionId();
|
|
104
|
+
dismissed.get(session)?.delete(id);
|
|
105
|
+
const showing = opened.get(session) ?? new Set<string>();
|
|
106
|
+
showing.add(id);
|
|
107
|
+
opened.set(session, showing);
|
|
108
|
+
signature = null;
|
|
109
|
+
scheduleSweep();
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
const stopWaiting = whenFrameReady((frameElement) => {
|
|
113
|
+
const column = createColumn(frameElement);
|
|
114
|
+
const root = createRoot(column.element);
|
|
115
|
+
disposers.push(() => root.unmount(), column.remove);
|
|
116
|
+
|
|
117
|
+
// `collectCanvases` walks every argument string by hand, which is what makes it correct on a
|
|
118
|
+
// half-arrived JSON prefix — and it costs 0.16ms per canvas per sweep. The sweep runs on
|
|
119
|
+
// every transcript mutation, so a session with 34 canvas calls (the corpus maximum) spent
|
|
120
|
+
// ~5ms per streamed token re-deriving a result that had not changed. Keyed on the calls'
|
|
121
|
+
// own bytes: a streamed argument grows, so its length moves on every frame that matters and
|
|
122
|
+
// on none that do not.
|
|
123
|
+
let callsKey: string | null = null;
|
|
124
|
+
let collected = collectCanvases([]);
|
|
125
|
+
|
|
126
|
+
const paint = () => {
|
|
127
|
+
const allCalls = calls();
|
|
128
|
+
const key = `${allCalls.length}:${allCalls.reduce((total, call) => total + call.argsRaw.length + (call.settled ? 1 : 0), 0)}`;
|
|
129
|
+
if (key !== callsKey) {
|
|
130
|
+
callsKey = key;
|
|
131
|
+
collected = collectCanvases(allCalls);
|
|
132
|
+
}
|
|
133
|
+
const workspace = cwd();
|
|
134
|
+
const session = sessionId();
|
|
135
|
+
const hidden = dismissed.get(session) ?? EMPTY;
|
|
136
|
+
// The workspace listing backs the launcher. Fetched once per workspace rather than per
|
|
137
|
+
// sweep — the sweep runs per streamed token, and a directory listing is not free.
|
|
138
|
+
// A call that ran arbitrary code may have written a canvas without any argument saying
|
|
139
|
+
// so: 29 canvas writes in the corpus go through `run_code`, and in 27 of them the path is
|
|
140
|
+
// built from a variable, so nothing in the arguments names the id. `collect.ts` cannot
|
|
141
|
+
// classify what the arguments do not contain — but the workspace listing already knows,
|
|
142
|
+
// it just never refreshed. Counting settled opaque calls re-lists once each time one
|
|
143
|
+
// finishes, which is a directory read per code execution and nothing per streamed token.
|
|
144
|
+
let opaque = 0;
|
|
145
|
+
for (const call of allCalls) if (call.settled && OPAQUE_WRITE.test(call.argsRaw)) opaque += 1;
|
|
146
|
+
if (workspace !== undefined && (workspace !== listedFor || opaque !== listedAfter)) {
|
|
147
|
+
listedFor = workspace;
|
|
148
|
+
listedAfter = opaque;
|
|
149
|
+
void listCanvasIds(workspace).then((ids) => {
|
|
150
|
+
workspaceIds = ids;
|
|
151
|
+
signature = null;
|
|
152
|
+
scheduleSweep();
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
const canvases: Canvas[] = collected.canvases
|
|
156
|
+
.filter((canvas) => !hidden.has(canvas.id))
|
|
157
|
+
.map((canvas) => {
|
|
158
|
+
const version = collected.stale.get(canvas.id);
|
|
159
|
+
// Not patched: the write's own arguments are the canvas, streaming included.
|
|
160
|
+
if (version === undefined) return canvas;
|
|
161
|
+
// One entry per canvas covers both states: `code` absent means a read is in
|
|
162
|
+
// flight, present means it settled. Two collections would only be two things to
|
|
163
|
+
// keep in sync.
|
|
164
|
+
const cached = fromFile.get(canvas.id);
|
|
165
|
+
if (cached?.version === version) return cached.code === undefined ? canvas : { ...canvas, code: cached.code };
|
|
166
|
+
if (workspace !== undefined) {
|
|
167
|
+
fromFile.set(canvas.id, { version });
|
|
168
|
+
void readCanvasFile(workspace, canvas.id).then((code) => {
|
|
169
|
+
// A miss keeps the entry (with no body) rather than dropping it: deleting it
|
|
170
|
+
// makes the very next sweep — one per streamed token — fire the same failing
|
|
171
|
+
// read again, forever. The version already invalidates it when a real change
|
|
172
|
+
// lands, which is the only thing that could make the answer differ.
|
|
173
|
+
if (code === null) return;
|
|
174
|
+
fromFile.set(canvas.id, { version, code });
|
|
175
|
+
// The body changed underneath the signature, so force the next sweep to paint.
|
|
176
|
+
signature = null;
|
|
177
|
+
scheduleSweep();
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
// Until it arrives, the previous body still beats an empty panel.
|
|
181
|
+
return cached?.code === undefined ? canvas : { ...canvas, code: cached.code };
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
// Canvases the reader picked out of the launcher. Their code is not in any tool call,
|
|
185
|
+
// so it comes off disk once and then stays — nothing this session does can change it.
|
|
186
|
+
for (const id of opened.get(session) ?? EMPTY) {
|
|
187
|
+
if (hidden.has(id) || canvases.some((canvas) => canvas.id === id)) continue;
|
|
188
|
+
const code = openedCode.get(id);
|
|
189
|
+
if (code !== undefined) canvases.push({ id, code, streaming: false });
|
|
190
|
+
else if (workspace !== undefined) {
|
|
191
|
+
openedCode.set(id, "");
|
|
192
|
+
void readCanvasFile(workspace, id).then((body) => {
|
|
193
|
+
if (body === null) return;
|
|
194
|
+
openedCode.set(id, body);
|
|
195
|
+
signature = null;
|
|
196
|
+
scheduleSweep();
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// What the launcher can offer: everything on disk, plus anything written this session
|
|
202
|
+
// whose file has not landed yet. Sorted so the button does not reshuffle between sweeps.
|
|
203
|
+
const offerable = [...new Set([...workspaceIds, ...collected.canvases.map((canvas) => canvas.id)])].toSorted();
|
|
204
|
+
|
|
205
|
+
const next = paintSignature(canvases, offerable);
|
|
206
|
+
if (next === signature) return;
|
|
207
|
+
signature = next;
|
|
208
|
+
if (canvases.length === 0) column.setWidth(0);
|
|
209
|
+
|
|
210
|
+
const repaint = () => {
|
|
211
|
+
signature = null;
|
|
212
|
+
scheduleSweep();
|
|
213
|
+
};
|
|
214
|
+
|
|
215
|
+
root.render(
|
|
216
|
+
canvases.length > 0
|
|
217
|
+
? createElement(CanvasPanel, {
|
|
218
|
+
canvases,
|
|
219
|
+
offerable,
|
|
220
|
+
cwd: cwd(),
|
|
221
|
+
onOpen: show,
|
|
222
|
+
onCardError,
|
|
223
|
+
onCardRendered,
|
|
224
|
+
onWidth: column.setWidth,
|
|
225
|
+
onClose: () => {
|
|
226
|
+
const hiding = dismissed.get(session) ?? new Set<string>();
|
|
227
|
+
for (const canvas of canvases) hiding.add(canvas.id);
|
|
228
|
+
dismissed.set(session, hiding);
|
|
229
|
+
// Anything opened from the launcher is closed for good rather than left to
|
|
230
|
+
// reappear on the next sweep: `dismissed` covers the ones with a tool call
|
|
231
|
+
// behind them, and this covers the ones without.
|
|
232
|
+
opened.delete(session);
|
|
233
|
+
repaint();
|
|
234
|
+
},
|
|
235
|
+
})
|
|
236
|
+
: offerable.length > 0
|
|
237
|
+
? createElement(CanvasLauncher, {
|
|
238
|
+
ids: offerable,
|
|
239
|
+
onOpen: show,
|
|
240
|
+
})
|
|
241
|
+
: null,
|
|
242
|
+
);
|
|
243
|
+
};
|
|
244
|
+
|
|
245
|
+
disposers.push(observeTranscript(paint));
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
disposers.push(stopWaiting);
|
|
249
|
+
return {
|
|
250
|
+
dispose: () => {
|
|
251
|
+
for (const dispose of disposers.toReversed()) dispose();
|
|
252
|
+
},
|
|
253
|
+
show,
|
|
254
|
+
};
|
|
255
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mounts the canvas column into the host's AppFrame.
|
|
3
|
+
*
|
|
4
|
+
* There is no additive slot for a whole column: `details` is a single-occupant slot the
|
|
5
|
+
* conversation already owns, and taking it would remove the tool-details seat declared
|
|
6
|
+
* inside it. A portal into the frame's own flex row adds a column without contending for
|
|
7
|
+
* anything — the same shape dsh-better-sidebar uses for its right sidebar.
|
|
8
|
+
*/
|
|
9
|
+
const FRAME = ".pI_x6G_frame, [class*='_frame']";
|
|
10
|
+
const PLUGIN_ID = "dsh-generative-ui";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Injects the panel stylesheet.
|
|
14
|
+
*
|
|
15
|
+
* The `data-plugin` attribute is required, not decorative: the client module loader
|
|
16
|
+
* claims every `style:not([data-plugin])` for whichever plugin is currently
|
|
17
|
+
* materializing, so an unmarked sheet gets adopted by a stranger and torn out with them.
|
|
18
|
+
*/
|
|
19
|
+
export function injectStyles(css: string): () => void {
|
|
20
|
+
const style = document.createElement("style");
|
|
21
|
+
style.setAttribute("data-plugin", PLUGIN_ID);
|
|
22
|
+
style.setAttribute("data-plugin-css", `${PLUGIN_ID}/panel`);
|
|
23
|
+
style.textContent = css;
|
|
24
|
+
document.head.append(style);
|
|
25
|
+
return () => style.remove();
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Resolves the frame element, waiting for it when the shell has not painted yet.
|
|
30
|
+
* @param onFound - called once with the container; not called if disposed first.
|
|
31
|
+
*/
|
|
32
|
+
export function whenFrameReady(onFound: (frame: HTMLElement) => void): () => void {
|
|
33
|
+
const found = document.querySelector<HTMLElement>(FRAME);
|
|
34
|
+
if (found !== null) {
|
|
35
|
+
onFound(found);
|
|
36
|
+
return () => {};
|
|
37
|
+
}
|
|
38
|
+
const observer = new MutationObserver(() => {
|
|
39
|
+
const frame = document.querySelector<HTMLElement>(FRAME);
|
|
40
|
+
if (frame === null) return;
|
|
41
|
+
observer.disconnect();
|
|
42
|
+
window.clearTimeout(timer);
|
|
43
|
+
onFound(frame);
|
|
44
|
+
});
|
|
45
|
+
observer.observe(document.body, { subtree: true, childList: true });
|
|
46
|
+
// The selector tracks a hashed class name, so a host rebuild can invalidate it. Without
|
|
47
|
+
// this the failure is a canvas panel that silently never appears; one warning turns a
|
|
48
|
+
// mystery into a starting point.
|
|
49
|
+
const timer = window.setTimeout(() => {
|
|
50
|
+
observer.disconnect();
|
|
51
|
+
console.warn(`[dsh-generative-ui] no AppFrame matched ${FRAME} — canvases cannot mount. The host's layout markup likely changed.`);
|
|
52
|
+
}, 15_000);
|
|
53
|
+
return () => {
|
|
54
|
+
observer.disconnect();
|
|
55
|
+
window.clearTimeout(timer);
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Creates the panel's host element and makes room for it.
|
|
61
|
+
*
|
|
62
|
+
* The frame's own columns are sized to fill the viewport exactly, so an extra flex child
|
|
63
|
+
* is laid out past the right edge no matter where it is inserted — present in the DOM,
|
|
64
|
+
* correctly sized, and entirely off screen (that is what the first attempt did). Instead
|
|
65
|
+
* the panel is fixed to the right edge and the frame is given matching right padding, so
|
|
66
|
+
* the conversation reflows into the remaining width and nothing overlaps.
|
|
67
|
+
*
|
|
68
|
+
* @param frame - the host's AppFrame element.
|
|
69
|
+
* @param width - the panel's current width in pixels.
|
|
70
|
+
*/
|
|
71
|
+
export function createColumn(frame: HTMLElement): {
|
|
72
|
+
element: HTMLElement;
|
|
73
|
+
setWidth: (width: number) => void;
|
|
74
|
+
remove: () => void;
|
|
75
|
+
} {
|
|
76
|
+
const element = document.createElement("div");
|
|
77
|
+
element.setAttribute("data-dgu-canvas-column", "");
|
|
78
|
+
document.body.append(element);
|
|
79
|
+
const previousPadding = frame.style.paddingRight;
|
|
80
|
+
const setWidth = (width: number) => {
|
|
81
|
+
frame.style.paddingRight = width === 0 ? previousPadding : `${width}px`;
|
|
82
|
+
};
|
|
83
|
+
return {
|
|
84
|
+
element,
|
|
85
|
+
setWidth,
|
|
86
|
+
remove: () => {
|
|
87
|
+
frame.style.paddingRight = previousPadding;
|
|
88
|
+
element.remove();
|
|
89
|
+
},
|
|
90
|
+
};
|
|
91
|
+
}
|
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
/* Generated from panel.css by scripts/build.ts — edit the .css, not this file. */
|
|
2
|
+
export const PANEL_CSS = "/*\n * The canvas column. Everything visual comes from the host's design tokens, so the panel\n * tracks theme changes for free and never introduces a second palette\n * (docs/web-styling.md: feature packages consume aliases, they do not define colors).\n */\n.dgu-panel {\n --dgu-panel-width: 420px;\n position: fixed;\n top: 0;\n right: 0;\n bottom: 0;\n z-index: 20;\n display: flex;\n flex-direction: column;\n width: var(--dgu-panel-width);\n background: var(--dsw-alias-bg-base);\n border-left: 1px solid var(--dsw-alias-border-l1);\n color: var(--dsw-alias-label-primary);\n font-family: var(--dsw-font-family);\n overflow: hidden;\n}\n\n.dgu-header {\n display: flex;\n align-items: center;\n gap: 8px;\n padding: 0 8px 0 14px;\n height: 44px;\n flex: none;\n}\n\n/* No hairline under either the title bar or the tab strip, and that is the host's own language,\n * not a preference: measured on dsh web's conversation header (`wSkVaW_header`), the computed\n * `border-bottom-color` is `rgba(0, 0, 0, 0)` on a transparent background with no box-shadow —\n * it separates itself from the scroll area with 76px of height and nothing else. Two `border-l1`\n * rules here put lines across a panel that sits inside that same frame, which reads as chrome the\n * app does not otherwise have. */\n\n.dgu-title {\n font-size: var(--dsw-font-s-14-font-size);\n line-height: var(--dsw-font-s-14-line-height);\n font-weight: var(--dsw-font-s-strong-14-font-weight);\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n}\n\n.dgu-spacer {\n flex: 1 1 auto;\n}\n\n.dgu-icon-button {\n display: inline-flex;\n align-items: center;\n justify-content: center;\n width: 28px;\n height: 28px;\n flex: none;\n border: 0;\n border-radius: 6px;\n background: transparent;\n color: var(--dsw-alias-label-secondary);\n cursor: pointer;\n}\n.dgu-icon-button:hover {\n background: var(--dsw-alias-interactive-bg-hover);\n color: var(--dsw-alias-label-primary);\n}\n.dgu-icon-button:focus-visible {\n outline: 2px solid var(--dsw-alias-state-business-primary);\n outline-offset: -2px;\n}\n\n/* Tabs only appear from the second canvas on: one canvas needs no chooser. */\n.dgu-tabs {\n display: flex;\n gap: 2px;\n padding: 6px 8px;\n flex: none;\n overflow-x: auto;\n scrollbar-width: none;\n}\n.dgu-tabs::-webkit-scrollbar {\n display: none;\n}\n\n.dgu-tab {\n padding: 4px 10px;\n border: 0;\n border-radius: 6px;\n background: transparent;\n color: var(--dsw-alias-label-secondary);\n font-family: inherit;\n font-size: var(--dsw-font-xs-13-font-size);\n line-height: var(--dsw-font-xs-13-line-height);\n white-space: nowrap;\n cursor: pointer;\n}\n.dgu-tab:hover {\n background: var(--dsw-alias-interactive-bg-hover);\n}\n.dgu-tab[aria-selected=\"true\"] {\n background: var(--dsw-specific-sidebar-nav-item-active);\n color: var(--dsw-alias-label-primary);\n}\n\n/*\n * The canvas owns this space entirely — the panel already provides the frame, so the body\n * adds no padding and no background of its own. Generated UI is told to fill it.\n */\n.dgu-body {\n flex: 1 1 auto;\n min-height: 0;\n overflow: auto;\n}\n.dgu-body > * {\n min-height: 100%;\n}\n\n.dgu-empty {\n display: flex;\n align-items: center;\n justify-content: center;\n height: 100%;\n padding: 24px;\n text-align: center;\n color: var(--dsw-alias-label-secondary);\n font-size: var(--dsw-font-xs-13-font-size);\n line-height: var(--dsw-font-xs-13-line-height);\n}\n\n/* Invisible hit strip, matching how the host draws its own sidebar boundary. */\n.dgu-resize {\n position: absolute;\n top: 0;\n bottom: 0;\n width: 8px;\n cursor: col-resize;\n z-index: 1;\n}\n.dgu-resize:hover,\n.dgu-resize[data-dragging] {\n /* The foreground token on purpose: this wants a wash that contrasts with whatever is\n behind it, and `brand-primary` tracks the body text colour in both themes. */\n background: var(--dsw-alias-brand-primary);\n opacity: 0.12;\n}\n\n/*\n * Sits where the panel's left edge was, so reopening is a click in the place it closed\n * from. Fixed like the panel because it shares the same \"outside the frame\" problem —\n * an extra flex child in the frame's row lands off screen.\n */\n.dgu-launcher-dock {\n position: fixed;\n top: 50%;\n right: 0;\n transform: translateY(-50%);\n z-index: 20;\n display: flex;\n align-items: center;\n gap: 6px;\n}\n\n.dgu-launcher {\n display: inline-flex;\n align-items: center;\n gap: 4px;\n padding: 8px 6px;\n border: 1px solid var(--dsw-alias-border-l1);\n border-right: 0;\n border-radius: 8px 0 0 8px;\n background: var(--dsw-alias-bg-layer-1);\n color: var(--dsw-alias-label-secondary);\n font-family: var(--dsw-font-family);\n font-size: var(--dsw-font-xs-13-font-size);\n line-height: var(--dsw-font-xs-13-line-height);\n cursor: pointer;\n}\n.dgu-launcher:hover {\n background: var(--dsw-alias-interactive-bg-hover);\n color: var(--dsw-alias-label-primary);\n}\n.dgu-launcher:focus-visible {\n outline: 2px solid var(--dsw-alias-state-business-primary);\n outline-offset: -2px;\n}\n\n.dgu-launcher-count {\n font-variant-numeric: tabular-nums;\n}\n\n/* Opens leftward: the dock is already against the right edge, so there is nowhere else. */\n.dgu-launcher-menu {\n display: flex;\n flex-direction: column;\n gap: 2px;\n margin: 0;\n padding: 4px;\n max-height: 60vh;\n overflow-y: auto;\n list-style: none;\n border: 1px solid var(--dsw-alias-border-l1);\n border-radius: 8px;\n /* A floating surface has to occlude: `bg-layer-1` is a tint over whatever it sits on, so\n it needs the panel's own opaque ground underneath before the tint reads as a menu. */\n background: var(--dsw-alias-bg-base);\n background-image: linear-gradient(var(--dsw-alias-bg-layer-1), var(--dsw-alias-bg-layer-1));\n box-shadow: 0 8px 24px rgb(0 0 0 / 0.24);\n}\n\n.dgu-launcher-item {\n display: block;\n width: 100%;\n padding: 5px 10px;\n border: 0;\n border-radius: 6px;\n background: transparent;\n color: var(--dsw-alias-label-primary);\n font-family: var(--dsw-font-family);\n font-size: var(--dsw-font-xs-13-font-size);\n line-height: var(--dsw-font-xs-13-line-height);\n text-align: left;\n white-space: nowrap;\n cursor: pointer;\n}\n.dgu-launcher-item:hover {\n background: var(--dsw-alias-interactive-bg-hover);\n}\n\n/* Anchors the header's canvas picker; the menu drops below the button. */\n.dgu-picker {\n position: relative;\n display: flex;\n}\n\n.dgu-picker-menu {\n position: absolute;\n top: calc(100% + 4px);\n right: 0;\n z-index: 1;\n}\n";
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* The canvas column. Everything visual comes from the host's design tokens, so the panel
|
|
3
|
+
* tracks theme changes for free and never introduces a second palette
|
|
4
|
+
* (docs/web-styling.md: feature packages consume aliases, they do not define colors).
|
|
5
|
+
*/
|
|
6
|
+
.dgu-panel {
|
|
7
|
+
--dgu-panel-width: 420px;
|
|
8
|
+
position: fixed;
|
|
9
|
+
top: 0;
|
|
10
|
+
right: 0;
|
|
11
|
+
bottom: 0;
|
|
12
|
+
z-index: 20;
|
|
13
|
+
display: flex;
|
|
14
|
+
flex-direction: column;
|
|
15
|
+
width: var(--dgu-panel-width);
|
|
16
|
+
background: var(--dsw-alias-bg-base);
|
|
17
|
+
border-left: 1px solid var(--dsw-alias-border-l1);
|
|
18
|
+
color: var(--dsw-alias-label-primary);
|
|
19
|
+
font-family: var(--dsw-font-family);
|
|
20
|
+
overflow: hidden;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
.dgu-header {
|
|
24
|
+
display: flex;
|
|
25
|
+
align-items: center;
|
|
26
|
+
gap: 8px;
|
|
27
|
+
padding: 0 8px 0 14px;
|
|
28
|
+
height: 44px;
|
|
29
|
+
flex: none;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/* No hairline under either the title bar or the tab strip, and that is the host's own language,
|
|
33
|
+
* not a preference: measured on dsh web's conversation header (`wSkVaW_header`), the computed
|
|
34
|
+
* `border-bottom-color` is `rgba(0, 0, 0, 0)` on a transparent background with no box-shadow —
|
|
35
|
+
* it separates itself from the scroll area with 76px of height and nothing else. Two `border-l1`
|
|
36
|
+
* rules here put lines across a panel that sits inside that same frame, which reads as chrome the
|
|
37
|
+
* app does not otherwise have. */
|
|
38
|
+
|
|
39
|
+
.dgu-title {
|
|
40
|
+
font-size: var(--dsw-font-s-14-font-size);
|
|
41
|
+
line-height: var(--dsw-font-s-14-line-height);
|
|
42
|
+
font-weight: var(--dsw-font-s-strong-14-font-weight);
|
|
43
|
+
white-space: nowrap;
|
|
44
|
+
overflow: hidden;
|
|
45
|
+
text-overflow: ellipsis;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
.dgu-spacer {
|
|
49
|
+
flex: 1 1 auto;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
.dgu-icon-button {
|
|
53
|
+
display: inline-flex;
|
|
54
|
+
align-items: center;
|
|
55
|
+
justify-content: center;
|
|
56
|
+
width: 28px;
|
|
57
|
+
height: 28px;
|
|
58
|
+
flex: none;
|
|
59
|
+
border: 0;
|
|
60
|
+
border-radius: 6px;
|
|
61
|
+
background: transparent;
|
|
62
|
+
color: var(--dsw-alias-label-secondary);
|
|
63
|
+
cursor: pointer;
|
|
64
|
+
}
|
|
65
|
+
.dgu-icon-button:hover {
|
|
66
|
+
background: var(--dsw-alias-interactive-bg-hover);
|
|
67
|
+
color: var(--dsw-alias-label-primary);
|
|
68
|
+
}
|
|
69
|
+
.dgu-icon-button:focus-visible {
|
|
70
|
+
outline: 2px solid var(--dsw-alias-state-business-primary);
|
|
71
|
+
outline-offset: -2px;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/* Tabs only appear from the second canvas on: one canvas needs no chooser. */
|
|
75
|
+
.dgu-tabs {
|
|
76
|
+
display: flex;
|
|
77
|
+
gap: 2px;
|
|
78
|
+
padding: 6px 8px;
|
|
79
|
+
flex: none;
|
|
80
|
+
overflow-x: auto;
|
|
81
|
+
scrollbar-width: none;
|
|
82
|
+
}
|
|
83
|
+
.dgu-tabs::-webkit-scrollbar {
|
|
84
|
+
display: none;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
.dgu-tab {
|
|
88
|
+
padding: 4px 10px;
|
|
89
|
+
border: 0;
|
|
90
|
+
border-radius: 6px;
|
|
91
|
+
background: transparent;
|
|
92
|
+
color: var(--dsw-alias-label-secondary);
|
|
93
|
+
font-family: inherit;
|
|
94
|
+
font-size: var(--dsw-font-xs-13-font-size);
|
|
95
|
+
line-height: var(--dsw-font-xs-13-line-height);
|
|
96
|
+
white-space: nowrap;
|
|
97
|
+
cursor: pointer;
|
|
98
|
+
}
|
|
99
|
+
.dgu-tab:hover {
|
|
100
|
+
background: var(--dsw-alias-interactive-bg-hover);
|
|
101
|
+
}
|
|
102
|
+
.dgu-tab[aria-selected="true"] {
|
|
103
|
+
background: var(--dsw-specific-sidebar-nav-item-active);
|
|
104
|
+
color: var(--dsw-alias-label-primary);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/*
|
|
108
|
+
* The canvas owns this space entirely — the panel already provides the frame, so the body
|
|
109
|
+
* adds no padding and no background of its own. Generated UI is told to fill it.
|
|
110
|
+
*/
|
|
111
|
+
.dgu-body {
|
|
112
|
+
flex: 1 1 auto;
|
|
113
|
+
min-height: 0;
|
|
114
|
+
overflow: auto;
|
|
115
|
+
}
|
|
116
|
+
.dgu-body > * {
|
|
117
|
+
min-height: 100%;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
.dgu-empty {
|
|
121
|
+
display: flex;
|
|
122
|
+
align-items: center;
|
|
123
|
+
justify-content: center;
|
|
124
|
+
height: 100%;
|
|
125
|
+
padding: 24px;
|
|
126
|
+
text-align: center;
|
|
127
|
+
color: var(--dsw-alias-label-secondary);
|
|
128
|
+
font-size: var(--dsw-font-xs-13-font-size);
|
|
129
|
+
line-height: var(--dsw-font-xs-13-line-height);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/* Invisible hit strip, matching how the host draws its own sidebar boundary. */
|
|
133
|
+
.dgu-resize {
|
|
134
|
+
position: absolute;
|
|
135
|
+
top: 0;
|
|
136
|
+
bottom: 0;
|
|
137
|
+
width: 8px;
|
|
138
|
+
cursor: col-resize;
|
|
139
|
+
z-index: 1;
|
|
140
|
+
}
|
|
141
|
+
.dgu-resize:hover,
|
|
142
|
+
.dgu-resize[data-dragging] {
|
|
143
|
+
/* The foreground token on purpose: this wants a wash that contrasts with whatever is
|
|
144
|
+
behind it, and `brand-primary` tracks the body text colour in both themes. */
|
|
145
|
+
background: var(--dsw-alias-brand-primary);
|
|
146
|
+
opacity: 0.12;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/*
|
|
150
|
+
* Sits where the panel's left edge was, so reopening is a click in the place it closed
|
|
151
|
+
* from. Fixed like the panel because it shares the same "outside the frame" problem —
|
|
152
|
+
* an extra flex child in the frame's row lands off screen.
|
|
153
|
+
*/
|
|
154
|
+
.dgu-launcher-dock {
|
|
155
|
+
position: fixed;
|
|
156
|
+
top: 50%;
|
|
157
|
+
right: 0;
|
|
158
|
+
transform: translateY(-50%);
|
|
159
|
+
z-index: 20;
|
|
160
|
+
display: flex;
|
|
161
|
+
align-items: center;
|
|
162
|
+
gap: 6px;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
.dgu-launcher {
|
|
166
|
+
display: inline-flex;
|
|
167
|
+
align-items: center;
|
|
168
|
+
gap: 4px;
|
|
169
|
+
padding: 8px 6px;
|
|
170
|
+
border: 1px solid var(--dsw-alias-border-l1);
|
|
171
|
+
border-right: 0;
|
|
172
|
+
border-radius: 8px 0 0 8px;
|
|
173
|
+
background: var(--dsw-alias-bg-layer-1);
|
|
174
|
+
color: var(--dsw-alias-label-secondary);
|
|
175
|
+
font-family: var(--dsw-font-family);
|
|
176
|
+
font-size: var(--dsw-font-xs-13-font-size);
|
|
177
|
+
line-height: var(--dsw-font-xs-13-line-height);
|
|
178
|
+
cursor: pointer;
|
|
179
|
+
}
|
|
180
|
+
.dgu-launcher:hover {
|
|
181
|
+
background: var(--dsw-alias-interactive-bg-hover);
|
|
182
|
+
color: var(--dsw-alias-label-primary);
|
|
183
|
+
}
|
|
184
|
+
.dgu-launcher:focus-visible {
|
|
185
|
+
outline: 2px solid var(--dsw-alias-state-business-primary);
|
|
186
|
+
outline-offset: -2px;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
.dgu-launcher-count {
|
|
190
|
+
font-variant-numeric: tabular-nums;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/* Opens leftward: the dock is already against the right edge, so there is nowhere else. */
|
|
194
|
+
.dgu-launcher-menu {
|
|
195
|
+
display: flex;
|
|
196
|
+
flex-direction: column;
|
|
197
|
+
gap: 2px;
|
|
198
|
+
margin: 0;
|
|
199
|
+
padding: 4px;
|
|
200
|
+
max-height: 60vh;
|
|
201
|
+
overflow-y: auto;
|
|
202
|
+
list-style: none;
|
|
203
|
+
border: 1px solid var(--dsw-alias-border-l1);
|
|
204
|
+
border-radius: 8px;
|
|
205
|
+
/* A floating surface has to occlude: `bg-layer-1` is a tint over whatever it sits on, so
|
|
206
|
+
it needs the panel's own opaque ground underneath before the tint reads as a menu. */
|
|
207
|
+
background: var(--dsw-alias-bg-base);
|
|
208
|
+
background-image: linear-gradient(var(--dsw-alias-bg-layer-1), var(--dsw-alias-bg-layer-1));
|
|
209
|
+
box-shadow: 0 8px 24px rgb(0 0 0 / 0.24);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
.dgu-launcher-item {
|
|
213
|
+
display: block;
|
|
214
|
+
width: 100%;
|
|
215
|
+
padding: 5px 10px;
|
|
216
|
+
border: 0;
|
|
217
|
+
border-radius: 6px;
|
|
218
|
+
background: transparent;
|
|
219
|
+
color: var(--dsw-alias-label-primary);
|
|
220
|
+
font-family: var(--dsw-font-family);
|
|
221
|
+
font-size: var(--dsw-font-xs-13-font-size);
|
|
222
|
+
line-height: var(--dsw-font-xs-13-line-height);
|
|
223
|
+
text-align: left;
|
|
224
|
+
white-space: nowrap;
|
|
225
|
+
cursor: pointer;
|
|
226
|
+
}
|
|
227
|
+
.dgu-launcher-item:hover {
|
|
228
|
+
background: var(--dsw-alias-interactive-bg-hover);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/* Anchors the header's canvas picker; the menu drops below the button. */
|
|
232
|
+
.dgu-picker {
|
|
233
|
+
position: relative;
|
|
234
|
+
display: flex;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
.dgu-picker-menu {
|
|
238
|
+
position: absolute;
|
|
239
|
+
top: calc(100% + 4px);
|
|
240
|
+
right: 0;
|
|
241
|
+
z-index: 1;
|
|
242
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reads a canvas's current file contents through the plugin's own host route.
|
|
3
|
+
*
|
|
4
|
+
* Needed because `edit`-style tool calls carry a patch rather than the file: after one,
|
|
5
|
+
* the tool-call stream no longer describes the canvas and only the file does.
|
|
6
|
+
*/
|
|
7
|
+
import { CANVAS_READ_PATH } from "../../contract-assets.ts";
|
|
8
|
+
|
|
9
|
+
/** Monotonic per-read, so no two reads of one canvas can share a cache entry. */
|
|
10
|
+
let readSerial = 0;
|
|
11
|
+
|
|
12
|
+
/** Every canvas in the workspace, including ones this session never wrote. */
|
|
13
|
+
export async function listCanvasIds(cwd: string): Promise<readonly string[]> {
|
|
14
|
+
try {
|
|
15
|
+
const response = await fetch(`${CANVAS_READ_PATH}?cwd=${encodeURIComponent(cwd)}`, { cache: "no-store" });
|
|
16
|
+
return response.ok ? ((await response.json()) as string[]) : [];
|
|
17
|
+
} catch {
|
|
18
|
+
return [];
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export async function readCanvasFile(cwd: string, id: string): Promise<string | null> {
|
|
23
|
+
// A distinct URL per read on top of `no-store`: the file is re-read precisely because it
|
|
24
|
+
// changed, so any reuse of an earlier response is guaranteed to be the wrong answer.
|
|
25
|
+
readSerial += 1;
|
|
26
|
+
const url = `${CANVAS_READ_PATH}?cwd=${encodeURIComponent(cwd)}&id=${encodeURIComponent(id)}&r=${readSerial}`;
|
|
27
|
+
try {
|
|
28
|
+
// `cache: "no-store"` on the request as well as the response: a canvas is re-read
|
|
29
|
+
// precisely because it just changed, and a cached body is the one answer that is
|
|
30
|
+
// guaranteed wrong.
|
|
31
|
+
const response = await fetch(url, { cache: "no-store" });
|
|
32
|
+
// 404 is ordinary: a canvas whose write is still streaming has no file yet.
|
|
33
|
+
return response.ok ? await response.text() : null;
|
|
34
|
+
} catch {
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Reads one of a canvas's sub-page files, named by the relative specifier as written in the
|
|
41
|
+
* canvas source. The server resolves it through the contract; anything outside this canvas's
|
|
42
|
+
* own child directory comes back 400 and reads here as null.
|
|
43
|
+
*/
|
|
44
|
+
export async function readCanvasChild(cwd: string, id: string, specifier: string, from: string): Promise<{ source: string; filename: string } | null> {
|
|
45
|
+
readSerial += 1;
|
|
46
|
+
const url = `${CANVAS_READ_PATH}?cwd=${encodeURIComponent(cwd)}&id=${encodeURIComponent(id)}&child=${encodeURIComponent(specifier)}&from=${encodeURIComponent(from)}&r=${readSerial}`;
|
|
47
|
+
try {
|
|
48
|
+
const response = await fetch(url, { cache: "no-store" });
|
|
49
|
+
if (!response.ok) return null;
|
|
50
|
+
// The server resolved the extension; the compiler needs it to pick a syntax.
|
|
51
|
+
return { source: await response.text(), filename: response.headers.get("x-ui4a-filename") ?? `${specifier}.tsx` };
|
|
52
|
+
} catch {
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
}
|