@hachej/boring-ui-kit 0.4.51 → 0.4.53
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/dist/chat-react-session.d.ts +6 -0
- package/dist/chat-react-session.js +33 -6
- package/dist/chat-view.js +2 -1
- package/dist/file-tree-view.js +37 -23
- package/dist/file-tree.d.ts +12 -0
- package/dist/file-tree.js +56 -4
- package/package.json +2 -2
- package/r/agent-target.json +4 -4
- package/r/app-shell.json +11 -11
- package/r/board-viewer.json +6 -6
- package/r/button.json +1 -1
- package/r/dock.json +1 -1
- package/r/documents.json +12 -12
- package/r/drawing-viewer.json +1 -1
- package/r/feedback.json +6 -6
- package/r/file-tree.json +4 -4
- package/r/highlights.json +3 -3
- package/r/html-viewer.json +3 -3
- package/r/looks-viewer.json +2 -2
- package/r/maquette-viewer.json +1 -1
- package/r/markdown-editor.json +3 -3
- package/r/mockup-viewer.json +1 -1
- package/r/pi-ambient.json +8 -8
- package/r/pi-app-kinds.json +10 -10
- package/r/pi-app.json +10 -10
- package/r/pi-artifacts.json +2 -2
- package/r/pi-chat-approval.json +2 -2
- package/r/pi-chat-attachments.json +1 -1
- package/r/pi-chat-markdown.json +2 -2
- package/r/pi-chat-mentions.json +1 -1
- package/r/pi-chat-pickers.json +1 -1
- package/r/pi-chat-plugins.json +11 -11
- package/r/pi-chat-question.json +2 -2
- package/r/pi-chat-slash.json +2 -2
- package/r/pi-chat-subagent.json +1 -1
- package/r/pi-chat-voice.json +3 -3
- package/r/pi-chat.json +7 -7
- package/r/pi-workspace.json +3 -3
- package/r/placed-agent.json +4 -4
- package/r/provider-setup.json +1 -1
- package/r/registry.json +162 -162
- package/r/shared-chat.json +6 -6
- package/r/spaces.json +1 -1
- package/r/task-list-viewer.json +2 -2
- package/r/version-history.json +2 -2
- package/r/viewer-code.json +2 -2
- package/r/viewer-frame.json +4 -4
- package/r/viewer-html.json +4 -4
- package/r/viewer-image.json +2 -2
- package/r/viewer-markdown.json +5 -5
- package/r/viewer-pdf.json +3 -3
- package/r/viewers.json +6 -6
- package/r/windows.json +1 -1
|
@@ -74,6 +74,11 @@ export interface ChatSessionOptions<Content = unknown> extends Partial<DeriveOpt
|
|
|
74
74
|
readonly labels: ChatSessionLabels;
|
|
75
75
|
readonly actions?: {
|
|
76
76
|
readonly withdraw?: ((id: QueuedMessage['id']) => Promise<unknown>) | undefined;
|
|
77
|
+
/** Resolves a waiting question. With it, a message typed while a question waits is that question's answer. */
|
|
78
|
+
readonly answer?: ((questionId: string, answer: string) => Promise<{
|
|
79
|
+
readonly kind: string;
|
|
80
|
+
readonly reason?: string | undefined;
|
|
81
|
+
}>) | undefined;
|
|
77
82
|
} | undefined;
|
|
78
83
|
readonly toolRenderers?: Readonly<Record<string, (call: ToolCall, result: ToolResultMessage | undefined) => ChatCard<Content> | undefined>> | undefined;
|
|
79
84
|
readonly artifacts?: (NonNullable<DeriveOptions<Content>['artifacts']> & {
|
|
@@ -129,6 +134,7 @@ export declare function useChatSession<Content = unknown>(options: ChatSessionOp
|
|
|
129
134
|
queueActions: QueueActions | undefined;
|
|
130
135
|
working: boolean;
|
|
131
136
|
waitingForAnswer: boolean;
|
|
137
|
+
answersQuestion: boolean;
|
|
132
138
|
connected: boolean;
|
|
133
139
|
sendBlocked: boolean;
|
|
134
140
|
error: string | null;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
import { useEffect, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react';
|
|
3
|
-
import { derive, isFileBlock, object, queuedMessages,
|
|
3
|
+
import { derive, isFileBlock, object, queuedMessages, waitingPart } from './chat-view.js';
|
|
4
4
|
import { appendToDraft, displayName, kindMentionDraft, looksLikeMarkdown, mentionKind, mentionToken, pieces, prependToDraft, withoutKindMentions, writeKindMentionDraft } from './chat-text.js';
|
|
5
5
|
/** Headless session over the public native controller snapshot/commands. Borrows it; cleanup only aborts this hook's uploads. */
|
|
6
6
|
/**
|
|
@@ -158,11 +158,38 @@ export function useChatSession(options) {
|
|
|
158
158
|
// Working from the moment of sending: the status shows before the server has taken the message.
|
|
159
159
|
const sending = Boolean(echo?.some(item => item.state === 'sending'));
|
|
160
160
|
const working = Boolean(run) || sending;
|
|
161
|
-
const
|
|
161
|
+
const delivering = useRef(Promise.resolve());
|
|
162
|
+
const settledQuestion = useRef(undefined);
|
|
163
|
+
const waitingFor = waitingPart(derived.rows);
|
|
164
|
+
const waitingForAnswer = waitingFor !== undefined;
|
|
165
|
+
// The run waits for this answer, so a message queued behind it would never go: what the person types answers the question.
|
|
166
|
+
const question = waitingFor?.kind === 'question' ? waitingFor.questionId : undefined;
|
|
167
|
+
const answersQuestion = question !== undefined && actions?.answer !== undefined && (state.draft.text.trim() !== '' || state.draft.attachments.length > 0);
|
|
168
|
+
/**
|
|
169
|
+
* Answers the waiting question with this message. A picture cannot be part of an answer, so it follows as its own message. A question that
|
|
170
|
+
* cannot take the message (options only, too long, not started) is superseded: the run that holds it stops, or the message would wait behind it.
|
|
171
|
+
* One at a time and once per question: a second message goes after the first, as an ordinary message, and stops nothing.
|
|
172
|
+
*/
|
|
173
|
+
const deliver = (text, images, restore) => {
|
|
174
|
+
const turn = delivering.current.catch(() => { }).then(async () => {
|
|
175
|
+
if (question !== undefined && actions?.answer && settledQuestion.current !== question) {
|
|
176
|
+
const outcome = await actions.answer(question, text.trim() || images.map(image => image.name).join(', '));
|
|
177
|
+
settledQuestion.current = question;
|
|
178
|
+
if (outcome.kind === 'answered')
|
|
179
|
+
return images.length ? await controller.send('followUp', { text: '', attachments: images, restore: { text: '', attachments: images } }) ?? true : true;
|
|
180
|
+
if ((outcome.kind === 'denied' || outcome.kind === 'unknown-question') && run)
|
|
181
|
+
await controller.stop();
|
|
182
|
+
}
|
|
183
|
+
return controller.send('followUp', { text, attachments: images, restore });
|
|
184
|
+
});
|
|
185
|
+
delivering.current = turn;
|
|
186
|
+
return turn;
|
|
187
|
+
};
|
|
162
188
|
const send = () => {
|
|
163
189
|
if (sendBlocked || !current())
|
|
164
190
|
return;
|
|
165
|
-
|
|
191
|
+
// An answer is what the person typed, shown on the card as is: pending feedback stays for their next message.
|
|
192
|
+
const attach = feedback && feedback.count > 0 && !answersQuestion ? feedback.attach : undefined;
|
|
166
193
|
if (attach) {
|
|
167
194
|
// The pending feedback goes with this message: the host attaches it to the text, then the native send reads the draft.
|
|
168
195
|
act(async () => {
|
|
@@ -202,12 +229,12 @@ export function useChatSession(options) {
|
|
|
202
229
|
}
|
|
203
230
|
const typed = controller.getSnapshot().draft;
|
|
204
231
|
const words = withoutKindMentions(typed.text, mentions);
|
|
205
|
-
if (words !== typed.text) {
|
|
232
|
+
if (words !== typed.text || answersQuestion) {
|
|
206
233
|
controller.setText('');
|
|
207
234
|
controller.setAttachments([]);
|
|
208
235
|
act(async () => {
|
|
209
236
|
try {
|
|
210
|
-
await
|
|
237
|
+
await deliver(typed.text, typed.attachments, { text: words, attachments: typed.attachments });
|
|
211
238
|
}
|
|
212
239
|
catch (cause) {
|
|
213
240
|
putBack(words, typed.attachments);
|
|
@@ -398,7 +425,7 @@ export function useChatSession(options) {
|
|
|
398
425
|
};
|
|
399
426
|
const empty = connected && !state.view?.entries.length && !rows.length;
|
|
400
427
|
const loading = (state.connection.kind === 'idle' || state.connection.kind === 'connecting') && !state.view && !echo?.length;
|
|
401
|
-
return { state, derived, rows, queued, queueActions, working, waitingForAnswer, connected, sendBlocked, error, setError, report, act, current,
|
|
428
|
+
return { state, derived, rows, queued, queueActions, working, waitingForAnswer, answersQuestion, connected, sendBlocked, error, setError, report, act, current,
|
|
402
429
|
files, empty, loading, developer, natural, sending, send, putBack, upload, pasteText, picked, pending, uploading, activateMention,
|
|
403
430
|
pickMention: (path) => setPicked(value => value.includes(path) ? value : [...value, path]),
|
|
404
431
|
dismissUpload: (id) => setPending(items => items.filter(item => item.id !== id)),
|
package/dist/chat-view.js
CHANGED
|
@@ -124,8 +124,9 @@ export function derive(view, options) {
|
|
|
124
124
|
const status = (result) => result ? (result.isError ? 'failed' : 'completed') : run ? 'running' : 'unfinished';
|
|
125
125
|
function toolPart(call, key, committed) {
|
|
126
126
|
const result = results.get(call);
|
|
127
|
+
// A question that failed (stopped, refused arguments) has nothing a reader can act on, and its result is harness text with the model's arguments.
|
|
127
128
|
if (call.name === ASK_USER_TOOL)
|
|
128
|
-
return { kind: 'question', key, call, result, live: !committed, questionId: questionIds.get(call) };
|
|
129
|
+
return natural && result?.isError ? undefined : { kind: 'question', key, call, result, live: !committed, questionId: questionIds.get(call) };
|
|
129
130
|
// A call gated by `requireApproval` identifies itself: it publishes its summary under APPROVAL_DETAILS before it asks.
|
|
130
131
|
const slotDetails = result ? undefined : (Array.isArray(live?.['tools']) ? live['tools'] : []).map(object).find(slot => slot?.['callId'] === call.id)?.['details'];
|
|
131
132
|
const record = approvalRecord(result ? result.details : slotDetails);
|
package/dist/file-tree-view.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
3
3
|
import { ChevronRight, Download, File, FileCode, FileImage, FileSpreadsheet, FileText, Folder, FolderInput, FolderOpen, History, LoaderCircle, MoreHorizontal, Pencil, Search, Trash2, Upload, X } from 'lucide-react';
|
|
4
4
|
import { useEffect, useRef, useState, useSyncExternalStore } from 'react';
|
|
5
|
-
import { createFileTreeController } from './file-tree.js';
|
|
5
|
+
import { createFileTreeController, folderWritable } from './file-tree.js';
|
|
6
6
|
/** Every word of the tree, so a host can translate it. Functions receive what they name. */
|
|
7
7
|
export const defaultFileTreeLabels = {
|
|
8
8
|
region: 'Workspace files',
|
|
@@ -199,11 +199,11 @@ export function FileTreeView({ components, controller, onOpen, selectedPath, cla
|
|
|
199
199
|
return;
|
|
200
200
|
shownKey.current = refreshKey;
|
|
201
201
|
const { query, expanded } = controller.getSnapshot();
|
|
202
|
+
// Open folders are read again while searching too: their listing carries the host's word on writing in them.
|
|
202
203
|
if (query)
|
|
203
204
|
void controller.search(query);
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
void controller.refresh(directory);
|
|
205
|
+
for (const directory of ['', ...expanded])
|
|
206
|
+
void controller.refresh(directory);
|
|
207
207
|
}, [controller, refreshKey]);
|
|
208
208
|
const tree = useRef(null);
|
|
209
209
|
const fileInput = useRef(null);
|
|
@@ -269,6 +269,13 @@ export function FileTreeView({ components, controller, onOpen, selectedPath, cla
|
|
|
269
269
|
}
|
|
270
270
|
}
|
|
271
271
|
};
|
|
272
|
+
// The host's per-folder word (`FilePage.writable`): a folder it marks read-only offers no upload, retry, rename, move, delete or drop.
|
|
273
|
+
const writableIn = (folder) => folderWritable(state.directories, folder);
|
|
274
|
+
const isListedFolder = (path) => [...state.directories.values(), state.search].some(listing => listing.entries.some(entry => entry.path === path && entry.kind === 'directory'));
|
|
275
|
+
/** A row changes its folder (and a folder row its own content): both must be writable. */
|
|
276
|
+
const pathWritable = (path, folder = isListedFolder(path)) => writableIn(parentPath(path)) && (!folder || writableIn(path));
|
|
277
|
+
const entryWritable = (entry) => pathWritable(entry.path, entry.kind === 'directory');
|
|
278
|
+
const canUpload = upload && writableIn(uploadDirectory);
|
|
272
279
|
const uploadRows = new Map();
|
|
273
280
|
for (const item of state.uploads) {
|
|
274
281
|
if (item.state.kind === 'pending')
|
|
@@ -279,11 +286,14 @@ export function FileTreeView({ components, controller, onOpen, selectedPath, cla
|
|
|
279
286
|
uploadRows.set(item.path, { kind: 'failed', size: item.size,
|
|
280
287
|
reason: item.state.result.reason,
|
|
281
288
|
retryLabel: item.state.result.kind === 'unknown' ? labels.checkUpload : labels.retry,
|
|
282
|
-
|
|
289
|
+
// Checking an unconfirmed upload writes nothing; trying a refused one again writes, so a read-only folder offers only the first.
|
|
290
|
+
...(item.state.result.kind === 'unknown' || writableIn(parentPath(item.path)) ? {
|
|
291
|
+
retry: () => { void controller.retry(item.operationId).catch(error => setUploadError(error instanceof Error ? error.message : labels.uploadStatusUnavailable)); }
|
|
292
|
+
} : {}) });
|
|
283
293
|
}
|
|
284
294
|
for (const [path, failure] of localFailures)
|
|
285
295
|
uploadRows.set(path, { kind: 'failed', size: failure.file.size, reason: failure.reason,
|
|
286
|
-
retryLabel: labels.retry, retry: () => { void uploadFiles([failure.file]); } });
|
|
296
|
+
retryLabel: labels.retry, ...(writableIn(parentPath(path)) ? { retry: () => { void uploadFiles([failure.file]); } } : {}) });
|
|
287
297
|
const uploadButton = _jsxs(Button, { type: "button", variant: "secondary", "data-file-upload": true, onClick: () => fileInput.current?.click(), children: [_jsx(Upload, { "aria-hidden": "true" }), labels.upload] });
|
|
288
298
|
const mutationRows = new Map();
|
|
289
299
|
for (const item of state.mutations)
|
|
@@ -314,7 +324,8 @@ export function FileTreeView({ components, controller, onOpen, selectedPath, cla
|
|
|
314
324
|
};
|
|
315
325
|
const commitRename = (path, value) => {
|
|
316
326
|
const name = value.trim();
|
|
317
|
-
|
|
327
|
+
// The folder may have become read-only while the name was typed: nothing is sent.
|
|
328
|
+
if (!name || name === baseName(path) || !pathWritable(path)) {
|
|
318
329
|
setRenaming(undefined);
|
|
319
330
|
focus(path);
|
|
320
331
|
return;
|
|
@@ -331,7 +342,7 @@ export function FileTreeView({ components, controller, onOpen, selectedPath, cla
|
|
|
331
342
|
};
|
|
332
343
|
const moveTo = (path, folder) => {
|
|
333
344
|
setAsking(undefined);
|
|
334
|
-
if (parentPath(path) === folder)
|
|
345
|
+
if (parentPath(path) === folder || !pathWritable(path) || !writableIn(folder))
|
|
335
346
|
return;
|
|
336
347
|
void controller.move(path, folder).then(report).catch(refused(path));
|
|
337
348
|
};
|
|
@@ -339,7 +350,7 @@ export function FileTreeView({ components, controller, onOpen, selectedPath, cla
|
|
|
339
350
|
requestAnimationFrame(() => focus(path)); };
|
|
340
351
|
const draggedPath = (event) => event.dataTransfer.types.includes(DRAGGED_FILE) ? dragged ?? event.dataTransfer.getData(DRAGGED_FILE) : undefined;
|
|
341
352
|
/** A folder (or `''`, the root) accepts a dragged file that is not already in it. */
|
|
342
|
-
const dropHandlers = (folder) => can.rename ? {
|
|
353
|
+
const dropHandlers = (folder) => can.rename && writableIn(folder) ? {
|
|
343
354
|
onDragEnter: (event) => { const path = draggedPath(event); if (path === undefined || parentPath(path) === folder)
|
|
344
355
|
return; event.preventDefault(); event.stopPropagation(); setDropTarget(folder); },
|
|
345
356
|
onDragOver: (event) => { const path = draggedPath(event); if (path === undefined || parentPath(path) === folder)
|
|
@@ -355,7 +366,7 @@ export function FileTreeView({ components, controller, onOpen, selectedPath, cla
|
|
|
355
366
|
event.stopPropagation();
|
|
356
367
|
setDropTarget(undefined);
|
|
357
368
|
setDragged(undefined);
|
|
358
|
-
if (path && parentPath(path) !== folder)
|
|
369
|
+
if (path && parentPath(path) !== folder && pathWritable(path))
|
|
359
370
|
void controller.move(path, folder).then(report).catch(refused(path));
|
|
360
371
|
},
|
|
361
372
|
} : {};
|
|
@@ -389,7 +400,7 @@ export function FileTreeView({ components, controller, onOpen, selectedPath, cla
|
|
|
389
400
|
node.focus();
|
|
390
401
|
};
|
|
391
402
|
/** Files only: a provider renames and deletes regular files, and a row busy uploading or changing waits. */
|
|
392
|
-
const mutable = (entry) => !temporaryPaths.has(entry.path) && uploadRows.get(entry.path)?.kind !== 'pending' && mutationRows.get(entry.path)?.state.kind !== 'pending';
|
|
403
|
+
const mutable = (entry) => entryWritable(entry) && !temporaryPaths.has(entry.path) && uploadRows.get(entry.path)?.kind !== 'pending' && mutationRows.get(entry.path)?.state.kind !== 'pending';
|
|
393
404
|
/** A blob pointer's row shows the file it stands for, not the pointer's own name: it can be moved or deleted, not renamed in place. */
|
|
394
405
|
const renameable = (entry) => !entry.blob;
|
|
395
406
|
const keyboard = (event, entry) => {
|
|
@@ -452,7 +463,7 @@ export function FileTreeView({ components, controller, onOpen, selectedPath, cla
|
|
|
452
463
|
if (listing.kind === 'error')
|
|
453
464
|
return _jsxs("li", { role: "none", children: [_jsx("span", { role: "alert", children: listing.reason }), " ", _jsx(Button, { type: "button", variant: "ghost", onClick: () => { void (search ? controller.search(state.query) : controller.refresh(directory)); }, children: labels.retry })] });
|
|
454
465
|
return _jsxs(_Fragment, { children: [listing.entries.length === 0 && (directory || temporaryEntries.length === 0) && _jsx("li", { role: "none", className: list && !directory ? undefined : 'file-tree-status', children: list && !directory
|
|
455
|
-
? _jsx(Empty, { className: "file-tree-empty", icon: _jsx(FolderOpen, { "aria-hidden": "true" }), title: search ? labels.noMatches : labels.empty, description: search ? undefined : labels.emptyHint, children: !search &&
|
|
466
|
+
? _jsx(Empty, { className: "file-tree-empty", icon: _jsx(FolderOpen, { "aria-hidden": "true" }), title: search ? labels.noMatches : labels.empty, description: search ? undefined : labels.emptyHint, children: !search && canUpload && uploadButton })
|
|
456
467
|
: _jsx("span", { children: search ? labels.noMatches : labels.empty }) }), listing.cursor !== undefined && _jsx("li", { role: "none", children: _jsx(Button, { type: "button", variant: "ghost", onClick: () => { void (search ? controller.moreSearch() : controller.more(directory)); }, children: labels.loadMore }) })] });
|
|
457
468
|
};
|
|
458
469
|
const renderEntries = (directory, level) => _jsxs(_Fragment, { children: [(state.directories.get(directory)?.entries ?? []).map(entry => renderEntry(entry, level)), listingStatus(state.directories.get(directory), directory)] });
|
|
@@ -474,7 +485,7 @@ export function FileTreeView({ components, controller, onOpen, selectedPath, cla
|
|
|
474
485
|
const decoration = decorate?.(entry) ?? undefined;
|
|
475
486
|
const button = _jsxs(Button, { type: "button", variant: selectedPath === entry.path ? 'secondary' : 'ghost', "data-active": selectedPath === entry.path, tabIndex: -1, title: entry.path, disabled: temporary, className: "file-tree-row-button", draggable: draggable || undefined, onClick: () => { focus(entry.path); if (!folder)
|
|
476
487
|
onOpen?.(entry.path); }, children: [folder ? _jsx(ChevronRight, { "aria-hidden": "true" }) : list && _jsx("span", { className: "file-tree-chevron-space", "aria-hidden": "true" }), _jsx(Icon, { "aria-hidden": "true" }), _jsxs("span", { className: "file-tree-copy", children: [_jsx("span", { className: "file-tree-name", children: name }), list ? folder ? childCount !== undefined && _jsx("span", { "data-file-meta": true, children: childCount }) : _jsxs("span", { "data-file-meta": true, children: [kind.label, " \u00B7 ", fileSize(blob?.size ?? entry.size, locale), parent && ` · ${parent}`] }) : parent && _jsx("span", { className: "file-tree-parent", "data-file-meta": true, children: parent }), decoration?.detail && _jsx("span", { "data-file-detail": true, children: decoration.detail })] })] });
|
|
477
|
-
const editing = renaming?.path === entry.path && can.rename;
|
|
488
|
+
const editing = renaming?.path === entry.path && can.rename && entryWritable(entry);
|
|
478
489
|
const mutationFailed = mutation?.state.kind === 'settled';
|
|
479
490
|
const item = _jsxs(SidebarMenuItem, { role: "treeitem", "data-path": entry.path, "data-blob-type": blob?.type, "data-upload-state": status?.kind, "data-mutation-state": mutation ? mutationFailed ? 'failed' : 'pending' : undefined, "data-drop-target": folder && dropTarget === entry.path ? 'true' : undefined, "data-dragging": dragged === entry.path || undefined, "aria-busy": status?.kind === 'pending' || mutation?.state.kind === 'pending' || undefined, ...(folder ? dropHandlers(entry.path) : {}), ...(draggable ? {
|
|
480
491
|
onDragStart: (event) => {
|
|
@@ -507,43 +518,46 @@ export function FileTreeView({ components, controller, onOpen, selectedPath, cla
|
|
|
507
518
|
setRenaming(current => current?.path === entry.path ? undefined : current); } })] }) : folder ? _jsx(CollapsibleTrigger, { render: button }) : button, decoration?.badge && !editing && (() => {
|
|
508
519
|
const badge = { tone: decoration.badge.tone, children: decoration.badge.label, role: 'note', 'aria-label': `${name}: ${decoration.badge.label}`, title: decoration.badge.label, 'data-file-badge': '', 'data-tone': decoration.badge.tone };
|
|
509
520
|
return Badge ? _jsx(Badge, { ...badge }) : _jsx("span", { role: badge.role, "aria-label": badge['aria-label'], title: badge.title, "data-file-badge": "", "data-tone": badge.tone, children: badge.children });
|
|
510
|
-
})(), (status?.kind === 'pending' || mutation?.state.kind === 'pending') && _jsx(LoaderCircle, { "data-upload-spinner": status ? '' : undefined, "data-mutation-spinner": mutation ? '' : undefined, className: "animate-spin motion-reduce:animate-none", "aria-hidden": "true" }), status?.kind === 'failed' && _jsx(Button, { type: "button", variant: "ghost", "data-upload-retry": true, "aria-label": status.retryLabel, onClick: status.retry, children: status.retryLabel }), mutation && mutationFailed && _jsx(Button, { type: "button", variant: "ghost", size: "icon", "data-mutation-dismiss": true, "aria-label": labels.dismiss(entry.path), title: labels.dismiss(entry.path), onClick: () => controller.dismiss(mutation.operationId), children: _jsx(X, { "aria-hidden": "true" }) }), !temporary && !status && !editing && mutation?.state.kind !== 'pending' && (!folder || changeable && (can.rename || can.delete)) && _jsx(FileActions, { Actions: Actions, controller: controller, entry: entry, labels: labels, onOpen: onOpen && !folder ? () => onOpen(entry.path) : undefined, onHistory: history && !folder ? () => setHistoryPath(entry.path) : undefined, onError: setUploadError, onRename: changeable && can.rename && renameable(entry) ? () => startRename(entry.path) : undefined, onMove: changeable && can.rename && Dialog ? () => ask('move', entry.path) : undefined, onDelete: changeable && can.delete ? () => ask('delete', entry.path) : undefined })] }), status?.kind === 'failed' && _jsx("span", { "data-upload-error": true, children: status.reason }), editing && renaming?.error && _jsx("span", { "data-mutation-error": true, role: "alert", children: renaming.error }), mutation && mutationFailed && _jsx("span", { "data-mutation-error": true, children: mutationReason(mutation) }), folder && !state.query && _jsx(CollapsibleContent, { render: _jsx(SidebarMenuSub, { role: "group" }), children: renderEntries(entry.path, level + 1) })] }, entry.path);
|
|
521
|
+
})(), (status?.kind === 'pending' || mutation?.state.kind === 'pending') && _jsx(LoaderCircle, { "data-upload-spinner": status ? '' : undefined, "data-mutation-spinner": mutation ? '' : undefined, className: "animate-spin motion-reduce:animate-none", "aria-hidden": "true" }), status?.kind === 'failed' && status.retry && _jsx(Button, { type: "button", variant: "ghost", "data-upload-retry": true, "aria-label": status.retryLabel, onClick: status.retry, children: status.retryLabel }), mutation && mutationFailed && _jsx(Button, { type: "button", variant: "ghost", size: "icon", "data-mutation-dismiss": true, "aria-label": labels.dismiss(entry.path), title: labels.dismiss(entry.path), onClick: () => controller.dismiss(mutation.operationId), children: _jsx(X, { "aria-hidden": "true" }) }), !temporary && !status && !editing && mutation?.state.kind !== 'pending' && (!folder || changeable && (can.rename || can.delete)) && _jsx(FileActions, { Actions: Actions, controller: controller, entry: entry, labels: labels, onOpen: onOpen && !folder ? () => onOpen(entry.path) : undefined, onHistory: history && !folder ? () => setHistoryPath(entry.path) : undefined, onError: setUploadError, onRename: changeable && can.rename && renameable(entry) ? () => startRename(entry.path) : undefined, onMove: changeable && can.rename && Dialog ? () => ask('move', entry.path) : undefined, onDelete: changeable && can.delete ? () => ask('delete', entry.path) : undefined })] }), status?.kind === 'failed' && _jsx("span", { "data-upload-error": true, children: status.reason }), editing && renaming?.error && _jsx("span", { "data-mutation-error": true, role: "alert", children: renaming.error }), mutation && mutationFailed && _jsx("span", { "data-mutation-error": true, children: mutationReason(mutation) }), folder && !state.query && _jsx(CollapsibleContent, { render: _jsx(SidebarMenuSub, { role: "group" }), children: renderEntries(entry.path, level + 1) })] }, entry.path);
|
|
511
522
|
return folder ? _jsx(CollapsibleRoot, { render: item, open: state.expanded.has(entry.path), onOpenChange: () => { void controller.toggle(entry.path); } }, entry.path) : item;
|
|
512
523
|
};
|
|
513
524
|
const visibleListing = state.query ? state.search : state.directories.get('');
|
|
514
525
|
const paginated = visibleListing?.kind === 'ready' && visibleListing.cursor !== undefined;
|
|
515
526
|
const rootDrop = dropHandlers('');
|
|
516
527
|
const dialogPath = asking?.path ?? '';
|
|
517
|
-
const dialogIsFolder =
|
|
518
|
-
|
|
519
|
-
|
|
528
|
+
const dialogIsFolder = isListedFolder(dialogPath);
|
|
529
|
+
// A dialog opened before its folder became read-only closes, and its confirm checks again.
|
|
530
|
+
const askingAllowed = asking !== undefined && pathWritable(asking.path, dialogIsFolder);
|
|
531
|
+
const folders = asking?.kind === 'move' ? [...new Set(['', ...[...state.directories.values(), state.search].flatMap(listing => listing.entries.filter(entry => entry.kind === 'directory').map(entry => entry.path))])].filter(folder => folder !== dialogPath && !folder.startsWith(`${dialogPath}/`) && writableIn(folder)).sort() : [];
|
|
532
|
+
return _jsxs("section", { "data-boring": "file-tree", "data-variant": variant, "data-dropping": dropping || undefined, "data-drop-target": dropTarget === '' ? 'true' : undefined, className: className, "aria-label": labels.region, onDragEnter: event => { rootDrop.onDragEnter?.(event); if (canUpload && event.dataTransfer.types.includes('Files')) {
|
|
520
533
|
event.preventDefault();
|
|
521
534
|
dragDepth.current++;
|
|
522
535
|
setDropping(true);
|
|
523
|
-
} }, onDragOver: event => { rootDrop.onDragOver?.(event); if (
|
|
536
|
+
} }, onDragOver: event => { rootDrop.onDragOver?.(event); if (canUpload && event.dataTransfer.types.includes('Files')) {
|
|
524
537
|
event.preventDefault();
|
|
525
538
|
event.dataTransfer.dropEffect = 'copy';
|
|
526
|
-
} }, onDragLeave: event => { rootDrop.onDragLeave?.(event); if (
|
|
539
|
+
} }, onDragLeave: event => { rootDrop.onDragLeave?.(event); if (canUpload && event.dataTransfer.types.includes('Files')) {
|
|
527
540
|
dragDepth.current = Math.max(0, dragDepth.current - 1);
|
|
528
541
|
if (!dragDepth.current)
|
|
529
542
|
setDropping(false);
|
|
530
|
-
} }, onDrop: event => { rootDrop.onDrop?.(event); if (
|
|
543
|
+
} }, onDrop: event => { rootDrop.onDrop?.(event); if (canUpload && event.dataTransfer.types.includes('Files')) {
|
|
531
544
|
event.preventDefault();
|
|
532
545
|
dragDepth.current = 0;
|
|
533
546
|
setDropping(false);
|
|
534
547
|
void uploadFiles(Array.from(event.dataTransfer.files));
|
|
535
|
-
} }, children: [list && _jsxs("header", { className: "file-tree-header", children: [_jsx("h2", { children: title ?? labels.library }), _jsxs("span", { className: "file-tree-count", "aria-label": labels.shownItems(rows.length), children: [rows.length, paginated ? '+' : ''] }),
|
|
548
|
+
} }, children: [list && _jsxs("header", { className: "file-tree-header", children: [_jsx("h2", { children: title ?? labels.library }), _jsxs("span", { className: "file-tree-count", "aria-label": labels.shownItems(rows.length), children: [rows.length, paginated ? '+' : ''] }), canUpload && uploadButton, onClose && _jsx(Button, { type: "button", variant: "ghost", size: "icon", "data-file-close": true, "aria-label": labels.close, onClick: onClose, children: _jsx(X, { "aria-hidden": "true" }) })] }), _jsxs("div", { className: "file-tree-toolbar", children: [_jsxs("div", { className: "file-tree-search", children: [list && _jsx(Search, { "aria-hidden": "true" }), _jsx(Input, { type: "search", "aria-label": labels.search, placeholder: labels.searchPlaceholder, value: state.query, onChange: event => { void controller.search(event.currentTarget.value); } })] }), refresh && _jsx(Button, { type: "button", variant: "ghost", "aria-label": labels.refresh, onClick: () => { void (state.query ? controller.search(state.query) : controller.refresh()); }, children: labels.refresh }), !list && canUpload && uploadButton] }), canUpload && _jsx(Input, { ref: fileInput, "aria-label": labels.uploadFiles, type: "file", multiple: true, onChange: event => {
|
|
536
549
|
const files = Array.from(event.currentTarget.files ?? []);
|
|
537
550
|
event.currentTarget.value = '';
|
|
538
551
|
void uploadFiles(files);
|
|
539
552
|
} }), dropping && _jsx("div", { className: "file-tree-drop", role: "status", children: _jsx(Empty, { icon: _jsx(Upload, { "aria-hidden": "true" }), title: labels.dropFiles }) }), uploadError && _jsx("p", { role: "alert", children: uploadError }), blobUploads.size > 0 && _jsx("ul", { "aria-label": labels.uploads, className: "file-tree-blob-uploads", children: [...blobUploads].map(([key, item]) => {
|
|
540
553
|
const percent = item.progress && item.progress.total > 0 ? Math.min(100, Math.round(item.progress.loaded / item.progress.total * 100)) : null;
|
|
541
554
|
return _jsxs("li", { "data-blob-upload": item.name, "data-progress": percent ?? undefined, className: "flex items-center gap-2", children: [_jsx("span", { className: "min-w-0 flex-1 truncate", children: item.name }), Progress ? _jsx("span", { className: "w-24 shrink-0", children: _jsx(Progress, { value: percent, "aria-label": labels.uploadProgress(item.name) }) }) : _jsx("span", { role: "status", children: percent === null ? labels.uploading : `${percent}%` }), _jsx(Button, { type: "button", variant: "ghost", size: "icon", "data-blob-upload-cancel": true, "aria-label": labels.cancelUpload(item.name), title: labels.cancelUpload(item.name), onClick: () => item.abort.abort(), children: _jsx(X, { "aria-hidden": "true" }) })] }, key);
|
|
542
|
-
}) }), _jsxs(SidebarMenu, { ref: tree, role: "tree", "aria-label": labels.tree, className: "file-tree-menu", children: [state.query ? _jsxs(_Fragment, { children: [state.search.entries.map(entry => renderEntry(entry, 1)), listingStatus(state.search, '', true)] }) : renderEntries('', 1), temporaryEntries.map(entry => renderEntry(entry, 1))] }), _jsxs("ul", { "aria-label": labels.uploads, "aria-live": "polite", "aria-relevant": "additions text", className: "file-tree-announcements", children: [state.uploads.map(upload => _jsxs("li", { "data-operation": upload.operationId, children: [upload.path, ": ", upload.state.kind === 'pending' ? labels.uploading : upload.state.result.kind === 'committed' ? labels.saved : upload.state.result.reason] }, upload.operationId)), [...localFailures].map(([path, failure]) => _jsxs("li", { children: [path, ": ", failure.reason] }, path)), state.mutations.map(item => _jsxs("li", { "data-operation": item.operationId, children: [item.path, ": ", mutationReason(item)] }, item.operationId))] }), Dialog && _jsx(Dialog, { open:
|
|
555
|
+
}) }), _jsxs(SidebarMenu, { ref: tree, role: "tree", "aria-label": labels.tree, className: "file-tree-menu", children: [state.query ? _jsxs(_Fragment, { children: [state.search.entries.map(entry => renderEntry(entry, 1)), listingStatus(state.search, '', true)] }) : renderEntries('', 1), temporaryEntries.map(entry => renderEntry(entry, 1))] }), _jsxs("ul", { "aria-label": labels.uploads, "aria-live": "polite", "aria-relevant": "additions text", className: "file-tree-announcements", children: [state.uploads.map(upload => _jsxs("li", { "data-operation": upload.operationId, children: [upload.path, ": ", upload.state.kind === 'pending' ? labels.uploading : upload.state.result.kind === 'committed' ? labels.saved : upload.state.result.reason] }, upload.operationId)), [...localFailures].map(([path, failure]) => _jsxs("li", { children: [path, ": ", failure.reason] }, path)), state.mutations.map(item => _jsxs("li", { "data-operation": item.operationId, children: [item.path, ": ", mutationReason(item)] }, item.operationId))] }), Dialog && _jsx(Dialog, { open: askingAllowed, onOpenChange: open => { if (!open)
|
|
543
556
|
closeAsking(); }, title: asking?.kind === 'move' ? labels.moveTitle(baseName(dialogPath)) : labels.deleteTitle(baseName(dialogPath)), description: asking?.kind === 'move' ? labels.moveDescription : dialogIsFolder ? labels.deleteFolderDescription : labels.deleteDescription, footer: asking?.kind === 'delete' ? _jsxs(_Fragment, { children: [_jsx(Button, { type: "button", variant: "outline", "data-confirm": "cancel", onClick: closeAsking, children: labels.cancel }), _jsxs(Button, { type: "button", variant: "destructive", "data-confirm": "delete", onClick: () => {
|
|
544
557
|
const path = dialogPath;
|
|
545
558
|
setAsking(undefined);
|
|
546
|
-
|
|
559
|
+
if (pathWritable(path, dialogIsFolder))
|
|
560
|
+
void controller.remove(path).then(report).catch(refused(path));
|
|
547
561
|
}, children: [_jsx(Trash2, { "aria-hidden": "true" }), labels.delete] })] }) : _jsx(Button, { type: "button", variant: "outline", "data-confirm": "cancel", onClick: closeAsking, children: labels.cancel }), children: asking?.kind === 'move' && _jsx("ul", { className: "file-tree-destinations", "aria-label": labels.moveTitle(baseName(dialogPath)), children: folders.map(folder => _jsx("li", { children: _jsxs(Button, { type: "button", variant: "ghost", "data-destination": folder, disabled: folder === parentPath(dialogPath), onClick: () => moveTo(dialogPath, folder), children: [folder ? _jsx(Folder, { "aria-hidden": "true" }) : _jsx(FolderOpen, { "aria-hidden": "true" }), _jsx("span", { className: "file-tree-name", children: folder || labels.root })] }) }, folder)) }) }), history && historyPath !== undefined && _jsx(FileHistory, { components: components, locale: locale, controller: controller, path: historyPath, labels: labels, reload: historyKey, onClose: () => setHistoryPath(undefined) }, historyPath)] });
|
|
548
562
|
}
|
|
549
563
|
/** The message of a host refusal `{ error: { code, message } }` (or an Error's), if any. */
|
package/dist/file-tree.d.ts
CHANGED
|
@@ -1,16 +1,23 @@
|
|
|
1
1
|
import type { PublicationResult, ResourceRef } from '@hachej/boring-files';
|
|
2
2
|
import type { FileEntry, RevisionProvider, SavedRevision } from '@hachej/boring-files/revision';
|
|
3
|
+
/**
|
|
4
|
+
* `writable`: the host's word on writing in this folder (`FilePage.writable`); `false` also when the host refused the listing (403). A
|
|
5
|
+
* reload or a failed read keeps the last word, so a read-only folder never regains its controls while it is read again.
|
|
6
|
+
*/
|
|
3
7
|
export type FileListing = {
|
|
4
8
|
readonly kind: 'loading';
|
|
5
9
|
readonly entries: readonly FileEntry[];
|
|
10
|
+
readonly writable?: boolean;
|
|
6
11
|
} | {
|
|
7
12
|
readonly kind: 'ready';
|
|
8
13
|
readonly entries: readonly FileEntry[];
|
|
9
14
|
readonly cursor?: string;
|
|
15
|
+
readonly writable?: boolean;
|
|
10
16
|
} | {
|
|
11
17
|
readonly kind: 'error';
|
|
12
18
|
readonly entries: readonly FileEntry[];
|
|
13
19
|
readonly reason: string;
|
|
20
|
+
readonly writable?: boolean;
|
|
14
21
|
};
|
|
15
22
|
export interface FileUpload {
|
|
16
23
|
readonly operationId: string;
|
|
@@ -84,6 +91,11 @@ export interface FileTreeController {
|
|
|
84
91
|
readonly dismiss: (operationId: string) => void;
|
|
85
92
|
readonly dispose: () => void;
|
|
86
93
|
}
|
|
94
|
+
/**
|
|
95
|
+
* Whether the host lets the caller write in `folder` (`''` is the root): the flag of its own listing, or of the nearest listed folder
|
|
96
|
+
* above it while it is not listed. True when no listing says otherwise, so a host that sends no flag keeps every control.
|
|
97
|
+
*/
|
|
98
|
+
export declare function folderWritable(directories: ReadonlyMap<string, FileListing>, folder: string): boolean;
|
|
87
99
|
/** Browsing state belongs to one borrowed binding. Disposal fences late results; it never closes the workspace. */
|
|
88
100
|
export declare function createFileTreeController({ revisionProvider }: {
|
|
89
101
|
readonly revisionProvider: RevisionProvider;
|
package/dist/file-tree.js
CHANGED
|
@@ -1,6 +1,31 @@
|
|
|
1
1
|
import { parsePublicationResult, PublicationNotDispatchedError, publicationDigest, publicationSnapshot } from '@hachej/boring-files/publication';
|
|
2
2
|
import { randomUUID } from '@hachej/boring-files/platform';
|
|
3
3
|
const message = (error) => error instanceof Error ? error.message : 'Files could not be loaded';
|
|
4
|
+
/**
|
|
5
|
+
* What a folder listing's failure means for the tree, read by shape (a hand-built binding may throw a `RevisionError` from another copy
|
|
6
|
+
* of the package): the host's 403 makes the folder read-only, its 404 makes it gone. A `denied` without that 403 (a 409 binding
|
|
7
|
+
* mismatch, an answer for another workspace) stays an error.
|
|
8
|
+
*/
|
|
9
|
+
function refusal(error) {
|
|
10
|
+
if (!(error instanceof Error) || error.name !== 'RevisionError' || !('kind' in error))
|
|
11
|
+
return undefined;
|
|
12
|
+
if (error.kind === 'missing')
|
|
13
|
+
return 'gone';
|
|
14
|
+
return error.kind === 'denied' && 'status' in error && error.status === 403 ? 'read-only' : undefined;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Whether the host lets the caller write in `folder` (`''` is the root): the flag of its own listing, or of the nearest listed folder
|
|
18
|
+
* above it while it is not listed. True when no listing says otherwise, so a host that sends no flag keeps every control.
|
|
19
|
+
*/
|
|
20
|
+
export function folderWritable(directories, folder) {
|
|
21
|
+
for (let path = folder;; path = path.slice(0, Math.max(0, path.lastIndexOf('/')))) {
|
|
22
|
+
const listing = directories.get(path);
|
|
23
|
+
if (listing?.writable !== undefined)
|
|
24
|
+
return listing.writable;
|
|
25
|
+
if (!path)
|
|
26
|
+
return true;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
4
29
|
const empty = { kind: 'ready', entries: [] };
|
|
5
30
|
const parentOf = (path) => path.slice(0, Math.max(0, path.lastIndexOf('/')));
|
|
6
31
|
const nameOf = (path) => path.slice(path.lastIndexOf('/') + 1);
|
|
@@ -49,18 +74,30 @@ export function createFileTreeController({ revisionProvider }) {
|
|
|
49
74
|
const signal = AbortSignal.any([abort.signal, lifetime.signal]);
|
|
50
75
|
const query = state.query;
|
|
51
76
|
const set = (value) => search ? update({ ...state, search: value }) : directory(path, value);
|
|
52
|
-
|
|
77
|
+
const known = search || previous.writable === undefined ? {} : { writable: previous.writable };
|
|
78
|
+
set({ kind: 'loading', entries: previous.entries, ...known });
|
|
53
79
|
try {
|
|
54
80
|
const page = await (search ? revisionProvider.search({ query, ...(cursor ? { cursor } : {}) }, signal)
|
|
55
81
|
: revisionProvider.list({ directory: path, ...(cursor ? { cursor } : {}) }, signal));
|
|
56
82
|
if (signal.aborted || loads.get(key) !== abort)
|
|
57
83
|
return;
|
|
58
84
|
const entries = append ? [...new Map([...previous.entries, ...page.entries].map(entry => [entry.path, entry])).values()] : [...page.entries];
|
|
59
|
-
|
|
85
|
+
// A next page without the flag keeps the first page's word; a fresh read takes the host's word as it is now.
|
|
86
|
+
const writable = search ? {} : typeof page.writable === 'boolean' ? { writable: page.writable } : append ? known : {};
|
|
87
|
+
set({ kind: 'ready', entries, ...(page.cursor === undefined ? {} : { cursor: page.cursor }), ...writable });
|
|
60
88
|
}
|
|
61
89
|
catch (error) {
|
|
62
|
-
if (
|
|
63
|
-
|
|
90
|
+
if (signal.aborted || loads.get(key) !== abort)
|
|
91
|
+
return;
|
|
92
|
+
const kind = search || !path ? undefined : refusal(error);
|
|
93
|
+
// A folder the host refuses to list (403) is shown read-only and empty, with no error; one it says is not there (404) leaves the
|
|
94
|
+
// tree: its row, its listing and its open state. The root keeps its error, the workspace itself being out of reach.
|
|
95
|
+
if (kind === 'read-only')
|
|
96
|
+
set({ kind: 'ready', entries: [], writable: false });
|
|
97
|
+
else if (kind === 'gone')
|
|
98
|
+
gone(path);
|
|
99
|
+
else
|
|
100
|
+
set({ kind: 'error', entries: previous.entries, reason: message(error), ...known });
|
|
64
101
|
}
|
|
65
102
|
finally {
|
|
66
103
|
if (loads.get(key) === abort)
|
|
@@ -68,6 +105,21 @@ export function createFileTreeController({ revisionProvider }) {
|
|
|
68
105
|
}
|
|
69
106
|
}
|
|
70
107
|
const refresh = (path = '') => load(path, false);
|
|
108
|
+
/** A folder the host says is not there: removed with everything under it (listings, pending reads, search rows) and from its parent. */
|
|
109
|
+
function gone(path) {
|
|
110
|
+
const under = (item) => item === path || item.startsWith(`${path}/`);
|
|
111
|
+
for (const [key, pending] of loads)
|
|
112
|
+
if (key.startsWith('directory:') && under(key.slice('directory:'.length))) {
|
|
113
|
+
pending.abort();
|
|
114
|
+
loads.delete(key);
|
|
115
|
+
}
|
|
116
|
+
const directories = new Map([...state.directories].filter(([folder]) => !under(folder)));
|
|
117
|
+
const parent = directories.get(parentOf(path));
|
|
118
|
+
if (parent)
|
|
119
|
+
directories.set(parentOf(path), { ...parent, entries: parent.entries.filter(entry => entry.path !== path) });
|
|
120
|
+
const search = state.search.entries.some(entry => under(entry.path)) ? { ...state.search, entries: state.search.entries.filter(entry => !under(entry.path)) } : state.search;
|
|
121
|
+
update({ ...state, directories, search, expanded: new Set([...state.expanded].filter(folder => !under(folder))) });
|
|
122
|
+
}
|
|
71
123
|
function uploadState(operationId, result) {
|
|
72
124
|
const attempt = attempts.get(operationId);
|
|
73
125
|
if (!attempt)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hachej/boring-ui-kit",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.53",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"private": false,
|
|
6
6
|
"type": "module",
|
|
@@ -154,7 +154,7 @@
|
|
|
154
154
|
}
|
|
155
155
|
},
|
|
156
156
|
"peerDependencies": {
|
|
157
|
-
"@hachej/boring-files": "^0.4.
|
|
157
|
+
"@hachej/boring-files": "^0.4.53",
|
|
158
158
|
"@earendil-works/pi-durable": "~1.4.0",
|
|
159
159
|
"react": "^19.0.0",
|
|
160
160
|
"react-dom": "^19.0.0",
|
package/r/agent-target.json
CHANGED
|
@@ -4,15 +4,15 @@
|
|
|
4
4
|
"title": "Point and dictate on app data",
|
|
5
5
|
"description": "Tap explicitly marked JSON app context, dictate once and show the same app agent’s native reply without opening chat. Host-controlled presentation; no feedback report or second controller.",
|
|
6
6
|
"dependencies": [
|
|
7
|
-
"@hachej/boring-ui-kit@^0.4.
|
|
8
|
-
"@hachej/boring-feedback@^0.4.
|
|
7
|
+
"@hachej/boring-ui-kit@^0.4.53",
|
|
8
|
+
"@hachej/boring-feedback@^0.4.53",
|
|
9
9
|
"@earendil-works/pi-durable@~1.4.0",
|
|
10
10
|
"react@^19.0.0",
|
|
11
11
|
"react-dom@^19.0.0"
|
|
12
12
|
],
|
|
13
13
|
"registryDependencies": [
|
|
14
|
-
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.
|
|
15
|
-
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.
|
|
14
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.53/r/app-shell.json",
|
|
15
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.53/r/pi-chat.json"
|
|
16
16
|
],
|
|
17
17
|
"files": [
|
|
18
18
|
{
|
package/r/app-shell.json
CHANGED
|
@@ -4,21 +4,21 @@
|
|
|
4
4
|
"title": "App shell: layouts, list/detail and agent layer",
|
|
5
5
|
"description": "AppShell with three layouts. B \"workspace\" puts a ListDetail on a screen (phone: the list, then the detail as a stack step with Back; wide: a 20rem list and the detail side by side, sidebar as an icon rail). C \"chat\" wraps the app's own AgentWorkspace (untouched, passed as the nav item's render; install the pi-app item yourself) in the same sidebar, account menu and header; it takes no shell agent. A \"screen\": one screen at a time, every screen kept mounted. On a phone bottom tabs (at most four; more give three plus a « Plus » sheet) and an avatar button that opens the account sheet; on a wide screen the shadcn sidebar (icon rail, NavUser). One header per screen through useScreenHeader (title, subtitle, back, up to two actions, the rest behind « … »). The account menu holds « Améliorer l'app » (the improve agent), the app's own entries and sign out. AppSheet is the one bottom sheet (one open at a time, Back closes it). It mounts the AgentLayer: one agent's PiChat above the app (phone: a drawer with peek, half and full snap points; wide: a right panel), the improve agent in the same layer, optional push-to-talk and point mode. Also useAgentContext and readAgentContext (the screen on show travels as `context` with each submit) and useBackLayer, the one Back manager. Built on explicitly pinned Base UI drawer and dropdown-menu recipes, plus the sidebar and avatar of the host's own shadcn style.",
|
|
6
6
|
"dependencies": [
|
|
7
|
-
"@hachej/boring-feedback@^0.4.
|
|
8
|
-
"@hachej/boring-ui-kit@^0.4.
|
|
7
|
+
"@hachej/boring-feedback@^0.4.53",
|
|
8
|
+
"@hachej/boring-ui-kit@^0.4.53",
|
|
9
9
|
"@base-ui/react@^1.9.0",
|
|
10
10
|
"react@^19.0.0",
|
|
11
11
|
"react-dom@^19.0.0",
|
|
12
12
|
"lucide-react@^1.51.0"
|
|
13
13
|
],
|
|
14
14
|
"registryDependencies": [
|
|
15
|
-
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.
|
|
16
|
-
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.
|
|
17
|
-
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.
|
|
18
|
-
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.
|
|
19
|
-
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.
|
|
20
|
-
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.
|
|
21
|
-
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.
|
|
15
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.53/r/pi-chat.json",
|
|
16
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.53/r/pi-chat-plugins.json",
|
|
17
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.53/r/pi-chat-voice.json",
|
|
18
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.53/r/dock.json",
|
|
19
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.53/r/feedback.json",
|
|
20
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.53/r/utils.json",
|
|
21
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.53/r/button.json"
|
|
22
22
|
],
|
|
23
23
|
"files": [
|
|
24
24
|
{
|
|
@@ -59,7 +59,7 @@
|
|
|
59
59
|
},
|
|
60
60
|
{
|
|
61
61
|
"path": "registry/app-shell/app-shell.tsx",
|
|
62
|
-
"content": "'use client';\n\n// The app shell. Layout A (`screen`): one screen at a time. Compact: bottom tabs (at most four; more give three plus « Plus »), or with `compactNav=\"sheet\"` no bar and the sidebar as a sheet from the header. Expanded:\n// the shadcn sidebar (icon rail plus NavUser). Layout B (`workspace`): the same, with a screen made of a `ListDetail`; the sidebar starts\n// as the icon rail because the list pane is the wide navigation. Layout C (`chat`): the one nav item renders the app's `AgentWorkspace`,\n// untouched, below the same sidebar, account menu and header; there is no shell agent (the page is the chat). The shell imports nothing from pi-app: a layout C app installs the `pi-app` item itself. One header per screen\n// (`useScreenHeader`). The AgentLayer is mounted once, here.\n// Host concern: Pi has no layout; the screen on show travels as the agent's context (useAgentContext -> createPromptContext).\nimport { useEffect, useState, useSyncExternalStore } from 'react';\nimport { afterStacksBack } from '../utils/back';\nimport type { ReactNode } from 'react';\nimport { SidebarProvider } from '@/components/ui/sidebar';\nimport { Button } from '@/components/ui/button';\nimport { AccountAvatar, AccountChoices } from './account';\nimport type { AccountSpec } from './account';\nimport { AgentDockLayout, AgentLayer, AgentLayerProvider } from './agent-layer';\nimport type { AgentSpec } from './agent-layer';\nimport { NoPanelLayout } from './shell-panel';\nimport type { ShellPanel } from './shell-panel';\nimport { ScreenActiveContext, useAgentContext } from './agent-context';\nimport { useIsCompact } from './compact';\nimport { HeaderProvider, ScreenHeader } from './header';\nimport { AppSidebar, TabBar } from './nav';\nimport type { NavItem } from './nav';\nimport { AppSheet } from './app-sheet';\nimport { appShellText } from './text';\nimport { ScreenHeaderSlotContext } from '../utils/screen-header-slot';\nimport type { AppShellLocale, AppShellText } from './text';\n\nexport interface AppShellProps {\n /** A `screen` (tabs), B `workspace` (list and detail), C `chat` (the app's `AgentWorkspace`; no `agent`). */\n readonly layout: 'screen' | 'workspace' | 'chat';\n readonly title: string;\n readonly nav: readonly NavItem[];\n /** Wide-screen navigation: `hover` reveals labels over the page on hover or keyboard focus. Default: `icon` (the native sidebar toggle). */\n readonly rail?: 'icon' | 'hover';\n /** Phone navigation: `tabs` (default) is the bottom tab bar; `sheet` has no bar, the header's leading button opens the sidebar (the nav items, a screen's `sidebar` content and the account) as a sheet. */\n readonly compactNav?: 'tabs' | 'sheet';\n readonly agent?: AgentSpec;\n /** Consume agent/open/input launch parameters on mount (default). `false` leaves the URL and layer untouched. */\n readonly agentLink?: false;\n /** A panel beside the app, opened from the account menu. The product manager is `pmPanel` from the Factory's `platform-ui/pm-panel`. */\n readonly panel?: ShellPanel;\n readonly account?: AccountSpec;\n /** The language of the shell's own words and the layer's (« Se déconnecter », « Plus », « Ouvrir l’assistant »…). The agent's `chatProps.locale` wins when it sets one; else this; else English. */\n readonly locale?: AppShellLocale;\n /** The app's router owns the URL; the shell only reports the choice. */\n readonly route: { readonly id: string; readonly onChange: (id: string) => void };\n /**\n * `false`: no backdrop blur behind dialogs, sheets and drawers (blurring the page costs frames on slow phones), only their dim.\n * Default `true`. Overlays live in a portal, so the shell marks `<html data-overlay-blur=\"off\">` while it is mounted; the app's\n * own CSS drops the blur there (the rule is in registry/README.md: an item's CSS stays inside `.app-shell`).\n */\n readonly overlayBlur?: boolean;\n}\n\n/** `<html data-overlay-blur=\"off\">` while a shell asks for it. */\nfunction useOverlayBlur(blur: boolean) {\n useEffect(() => {\n if (blur || typeof document === 'undefined') return;\n const root = document.documentElement;\n root.dataset.overlayBlur = 'off';\n return () => { delete root.dataset.overlayBlur; };\n }, [blur]);\n}\n\n/** Registers what is on show for the agent. A screen's own `useAgentContext` is mounted later and wins. */\nfunction ShellContext({ name }: { readonly name: string }): null {\n useAgentContext({ name, params: {} });\n return null;\n}\n\nfunction ScreenBody({ item }: { readonly item: NavItem }): ReactNode { return item.render(); }\n\n/** Every screen stays mounted; the ones not on show are `hidden` and do not register a header or a context. */\nfunction Screens({ items, activeId, scrolls, slot }: { readonly items: readonly NavItem[]; readonly activeId: string; readonly scrolls: boolean; readonly slot: HTMLElement | null }) {\n return <div className=\"relative min-h-0 flex-1\">\n {items.map(item => <ScreenActiveContext.Provider key={item.id} value={item.id === activeId}><ScreenHeaderSlotContext.Provider value={item.id === activeId ? slot : null}>\n <div hidden={item.id !== activeId} data-testid={`screen-${item.id}`} className={scrolls ? 'h-full overflow-y-auto overscroll-contain' : 'h-full overflow-hidden'}><ScreenBody item={item} /></div>\n </ScreenHeaderSlotContext.Provider></ScreenActiveContext.Provider>)}\n </div>;\n}\n\nfunction Shell({ compactNav, layout, rail, title, nav, agent, panel, account, route, text }: { readonly compactNav: NonNullable<AppShellProps['compactNav']>; readonly rail: NonNullable<AppShellProps['rail']>; readonly layout: AppShellProps['layout']; readonly title: string; readonly nav: readonly NavItem[]; readonly agent: AgentSpec | undefined; readonly panel: ShellPanel | undefined; readonly account: AccountSpec | undefined; readonly route: AppShellProps['route']; readonly text: AppShellText }) {\n const improve = panel ? panel.label ?? text.improve : undefined;\n const Layout = panel?.Layout ?? NoPanelLayout;\n const compact = useIsCompact();\n const sheet = compact && compactNav === 'sheet';\n const [accountOpen, setAccountOpen] = useState(false);\n // The header's slot for the screen on show (an embedded AgentWorkspace puts its bar's controls there).\n const [slot, setSlot] = useState<HTMLElement | null>(null);\n const active = nav.find(item => item.id === route.id) ?? nav[0];\n if (!active) return null;\n // A nav choice waits for an open detail's stack entry to go back, so the app's router can replace the URL without that Back restoring\n // the detail's. Choosing the screen already on show changes nothing and keeps the detail.\n const choose = (id: string) => { if (id === active.id) route.onChange(id); else afterStacksBack(() => route.onChange(id)); };\n const hasAccount = Boolean(account) || improve;\n const avatar = hasAccount ? <>\n <Button variant=\"ghost\" size=\"icon\" data-testid=\"account-button\" aria-label={account?.name ?? text.account} title={account?.name ?? text.account} onClick={() => setAccountOpen(true)}><AccountAvatar account={account} /></Button>\n <AppSheet open={accountOpen} onOpenChange={setAccountOpen} title={text.account}>\n <AccountChoices account={account} improve={improve} text={text} done={() => setAccountOpen(false)} />\n </AppSheet>\n </> : null;\n // The structure is the same compact or expanded, so a resize never remounts a screen.\n return <SidebarProvider data-testid=\"app-shell\" defaultOpen={rail !== 'hover' && layout === 'screen'} className=\"app-shell h-dvh min-h-0 overflow-hidden\">\n {(!compact || sheet) && <AppSidebar rail={sheet ? 'icon' : rail} title={title} items={nav} activeId={active.id} onChange={choose} agent={agent} account={account} improve={improve} text={text} />}\n <AgentDockLayout>\n <div data-slot=\"sidebar-inset\" className=\"relative flex min-h-0 min-w-0 w-full flex-1 flex-col overflow-hidden bg-background\">\n <Layout screen={active.label} navigation={compact && !sheet ? <TabBar items={nav} activeId={active.id} onChange={choose} text={text} /> : undefined}>\n <ShellContext name={active.label} />\n <ScreenHeader fallbackTitle={active.label} compact={compact} avatar={avatar} menu={sheet} text={text} slotRef={setSlot} />\n <Screens items={nav} activeId={active.id} scrolls={layout === 'screen'} slot={slot} />\n </Layout>\n </div>\n </AgentDockLayout>\n <AgentLayer />\n </SidebarProvider>;\n}\n\nconst never = () => () => {};\n\nexport function AppShell(props: AppShellProps) {\n const { layout, rail = 'icon', compactNav = 'tabs', agent, panel, title, nav, account, route } = props;\n if (layout === 'chat' && agent) throw new Error('AppShell layout \"chat\" takes no `agent`: the page is the chat (use `panel` for the product manager)');\n const text = appShellText(agent?.chatProps?.locale ?? (panel?.locale as AppShellLocale | undefined) ?? props.locale);\n useOverlayBlur(props.overlayBlur ?? true);\n const alone = useSyncExternalStore(never, () => panel?.page?.matches() ?? false, () => false);\n if (alone && panel?.page) return <panel.page.Page config={panel.config} appName={title} locale={props.locale} />;\n return <AgentLayerProvider agent={agent} panel={panel} locale={props.locale} agentLink={props.agentLink} appName={props.title}>\n <HeaderProvider><Shell compactNav={compactNav} layout={layout} rail={rail} title={title} nav={nav} agent={agent} account={account} route={route} panel={panel} text={text} /></HeaderProvider>\n </AgentLayerProvider>;\n}\n",
|
|
62
|
+
"content": "'use client';\n\n// The app shell. Layout A (`screen`): one screen at a time. Compact: bottom tabs (at most four; more give three plus « Plus »), or with `compactNav=\"sheet\"` no bar and the sidebar as a sheet from the header. Expanded:\n// the shadcn sidebar (icon rail plus NavUser). Layout B (`workspace`): the same, with a screen made of a `ListDetail`; the sidebar starts\n// as the icon rail because the list pane is the wide navigation. Layout C (`chat`): the one nav item renders the app's `AgentWorkspace`,\n// untouched, below the same sidebar, account menu and header; there is no shell agent (the page is the chat). The shell imports nothing from pi-app: a layout C app installs the `pi-app` item itself. One header per screen\n// (`useScreenHeader`). The AgentLayer is mounted once, here.\n// Host concern: Pi has no layout; the screen on show travels as the agent's context (useAgentContext -> createPromptContext).\nimport { useEffect, useState, useSyncExternalStore } from 'react';\nimport { afterStacksBack } from '../utils/back';\nimport type { ReactNode } from 'react';\nimport { SidebarProvider } from '@/components/ui/sidebar';\nimport { Button } from '@/components/ui/button';\nimport { AccountAvatar, AccountChoices } from './account';\nimport type { AccountSpec } from './account';\nimport { AgentDockLayout, AgentLayer, AgentLayerProvider } from './agent-layer';\nimport type { AgentSpec } from './agent-layer';\nimport { NoPanelLayout } from './shell-panel';\nimport type { ShellPanel } from './shell-panel';\nimport { ScreenActiveContext, useAgentContext } from './agent-context';\nimport { useIsCompact } from './compact';\nimport { HeaderProvider, ScreenHeader } from './header';\nimport { AppSidebar, TabBar } from './nav';\nimport type { NavItem } from './nav';\nimport { AppSheet } from './app-sheet';\nimport { appShellText } from './text';\nimport { ScreenHeaderSlotContext } from '../utils/screen-header-slot';\nimport type { AppShellLocale, AppShellText } from './text';\n\nexport interface AppShellProps {\n /** A `screen` (tabs), B `workspace` (list and detail), C `chat` (the app's `AgentWorkspace`; no `agent`). */\n readonly layout: 'screen' | 'workspace' | 'chat';\n readonly title: string;\n readonly nav: readonly NavItem[];\n /** Wide-screen navigation: `hover` reveals labels over the page on hover or keyboard focus. Default: `icon` (the native sidebar toggle). */\n readonly rail?: 'icon' | 'hover';\n /** Phone navigation: `tabs` (default) is the bottom tab bar; `sheet` has no bar, the header's leading button opens the sidebar (the nav items, a screen's `sidebar` content and the account) as a sheet. */\n readonly compactNav?: 'tabs' | 'sheet';\n readonly agent?: AgentSpec;\n /** Consume agent/open/input launch parameters on mount (default). `false` leaves the URL and layer untouched. */\n readonly agentLink?: false;\n /** A panel beside the app, opened from the account menu. The product manager is `pmPanel` from the Factory's `platform-ui/pm-panel`. */\n readonly panel?: ShellPanel;\n readonly account?: AccountSpec;\n /** The language of the shell's own words and the layer's (« Se déconnecter », « Plus », « Ouvrir l’assistant »…). The agent's `chatProps.locale` wins when it sets one; else this; else English. */\n readonly locale?: AppShellLocale;\n /** The app's router owns the URL; the shell only reports the choice. */\n readonly route: { readonly id: string; readonly onChange: (id: string) => void };\n /**\n * `false`: no backdrop blur behind dialogs, sheets and drawers (blurring the page costs frames on slow phones), only their dim.\n * Default `true`. Overlays live in a portal, so the shell marks `<html data-overlay-blur=\"off\">` while it is mounted; the app's\n * own CSS drops the blur there (the rule is in registry/README.md: an item's CSS stays inside `.app-shell`).\n */\n readonly overlayBlur?: boolean;\n}\n\n/** `<html data-overlay-blur=\"off\">` while a shell asks for it. */\nfunction useOverlayBlur(blur: boolean) {\n useEffect(() => {\n if (blur || typeof document === 'undefined') return;\n const root = document.documentElement;\n root.dataset.overlayBlur = 'off';\n return () => { delete root.dataset.overlayBlur; };\n }, [blur]);\n}\n\n/** Registers what is on show for the agent. A screen's own `useAgentContext` is mounted later and wins. */\nfunction ShellContext({ name }: { readonly name: string }): null {\n useAgentContext({ name, params: {} });\n return null;\n}\n\nfunction ScreenBody({ item }: { readonly item: NavItem }): ReactNode { return item.render(); }\n\n/** Every screen stays mounted; the ones not on show are `hidden` and do not register a header or a context. */\nfunction Screens({ items, activeId, scrolls, slot }: { readonly items: readonly NavItem[]; readonly activeId: string; readonly scrolls: boolean; readonly slot: HTMLElement | null }) {\n return <div className=\"relative min-h-0 flex-1\">\n {items.map(item => <ScreenActiveContext.Provider key={item.id} value={item.id === activeId}><ScreenHeaderSlotContext.Provider value={item.id === activeId ? slot : null}>\n <div hidden={item.id !== activeId} data-testid={`screen-${item.id}`} className={scrolls ? 'h-full overflow-y-auto overscroll-contain' : 'h-full overflow-hidden'}><ScreenBody item={item} /></div>\n </ScreenHeaderSlotContext.Provider></ScreenActiveContext.Provider>)}\n </div>;\n}\n\nfunction Shell({ compactNav, layout, rail, title, nav, agent, panel, account, route, text }: { readonly compactNav: NonNullable<AppShellProps['compactNav']>; readonly rail: NonNullable<AppShellProps['rail']>; readonly layout: AppShellProps['layout']; readonly title: string; readonly nav: readonly NavItem[]; readonly agent: AgentSpec | undefined; readonly panel: ShellPanel | undefined; readonly account: AccountSpec | undefined; readonly route: AppShellProps['route']; readonly text: AppShellText }) {\n const improve = panel && panel.menu !== false ? panel.label ?? text.improve : undefined;\n const Layout = panel?.Layout ?? NoPanelLayout;\n const compact = useIsCompact();\n const sheet = compact && compactNav === 'sheet';\n const [accountOpen, setAccountOpen] = useState(false);\n // The header's slot for the screen on show (an embedded AgentWorkspace puts its bar's controls there).\n const [slot, setSlot] = useState<HTMLElement | null>(null);\n const active = nav.find(item => item.id === route.id) ?? nav[0];\n if (!active) return null;\n // A nav choice waits for an open detail's stack entry to go back, so the app's router can replace the URL without that Back restoring\n // the detail's. Choosing the screen already on show changes nothing and keeps the detail.\n const choose = (id: string) => { if (id === active.id) route.onChange(id); else afterStacksBack(() => route.onChange(id)); };\n const hasAccount = Boolean(account) || improve;\n const avatar = hasAccount ? <>\n <Button variant=\"ghost\" size=\"icon\" data-testid=\"account-button\" aria-label={account?.name ?? text.account} title={account?.name ?? text.account} onClick={() => setAccountOpen(true)}><AccountAvatar account={account} /></Button>\n <AppSheet open={accountOpen} onOpenChange={setAccountOpen} title={text.account}>\n <AccountChoices account={account} improve={improve} text={text} done={() => setAccountOpen(false)} />\n </AppSheet>\n </> : null;\n // The structure is the same compact or expanded, so a resize never remounts a screen.\n return <SidebarProvider data-testid=\"app-shell\" defaultOpen={rail !== 'hover' && layout === 'screen'} className=\"app-shell h-dvh min-h-0 overflow-hidden\">\n {(!compact || sheet) && <AppSidebar rail={sheet ? 'icon' : rail} title={title} items={nav} activeId={active.id} onChange={choose} agent={agent} account={account} improve={improve} text={text} />}\n <AgentDockLayout>\n <div data-slot=\"sidebar-inset\" className=\"relative flex min-h-0 min-w-0 w-full flex-1 flex-col overflow-hidden bg-background\">\n <Layout screen={active.label} navigation={compact && !sheet ? <TabBar items={nav} activeId={active.id} onChange={choose} text={text} /> : undefined}>\n <ShellContext name={active.label} />\n <ScreenHeader fallbackTitle={active.label} compact={compact} avatar={avatar} menu={sheet} text={text} slotRef={setSlot} />\n <Screens items={nav} activeId={active.id} scrolls={layout === 'screen'} slot={slot} />\n </Layout>\n </div>\n </AgentDockLayout>\n <AgentLayer />\n </SidebarProvider>;\n}\n\nconst never = () => () => {};\n\nexport function AppShell(props: AppShellProps) {\n const { layout, rail = 'icon', compactNav = 'tabs', agent, panel, title, nav, account, route } = props;\n if (layout === 'chat' && agent) throw new Error('AppShell layout \"chat\" takes no `agent`: the page is the chat (use `panel` for the product manager)');\n const text = appShellText(agent?.chatProps?.locale ?? (panel?.locale as AppShellLocale | undefined) ?? props.locale);\n useOverlayBlur(props.overlayBlur ?? true);\n const alone = useSyncExternalStore(never, () => panel?.page?.matches() ?? false, () => false);\n if (alone && panel?.page) return <panel.page.Page config={panel.config} appName={title} locale={props.locale} />;\n return <AgentLayerProvider agent={agent} panel={panel} locale={props.locale} agentLink={props.agentLink} appName={props.title}>\n <HeaderProvider><Shell compactNav={compactNav} layout={layout} rail={rail} title={title} nav={nav} agent={agent} account={account} route={route} panel={panel} text={text} /></HeaderProvider>\n </AgentLayerProvider>;\n}\n",
|
|
63
63
|
"type": "registry:component",
|
|
64
64
|
"target": "components/app-shell/app-shell.tsx"
|
|
65
65
|
},
|
|
@@ -107,7 +107,7 @@
|
|
|
107
107
|
},
|
|
108
108
|
{
|
|
109
109
|
"path": "registry/app-shell/shell-panel.tsx",
|
|
110
|
-
"content": "'use client';\n\n// The shell's panel place: a second agent or tool beside the app, opened from the account menu. The shell knows only this shape, so an app\n// plugs in what it wants (the product manager is `pmPanel` from the Factory's `platform-ui/pm-panel`) and an app without one installs nothing more.\nimport type { ComponentType, ReactNode } from 'react';\n\nexport interface ShellPanelProviderProps {\n /** `ShellPanel.config`, as the panel wrote it. */\n readonly config: unknown;\n readonly open: boolean;\n readonly close: () => void;\n readonly appName?: string | undefined;\n readonly locale?: string | undefined;\n /** The panel took the whole screen, or gave it back; `collapse` is what Back runs. */\n readonly onFull?: ((full: boolean, collapse: () => void) => void) | undefined;\n readonly children: ReactNode;\n}\n\nexport interface ShellPanelLayoutProps {\n readonly children: ReactNode;\n /** The phone tab bar. The layout hides it while the panel covers the page. */\n readonly navigation?: ReactNode;\n /** The label of the screen on show. */\n readonly screen?: string;\n}\n\nexport interface ShellPanelPageProps {\n readonly config: unknown;\n readonly appName: string;\n readonly locale?: string | undefined;\n}\n\n/** Keep `Provider`, `Layout` and `page.Page` the same across renders: a new component remounts the app. */\nexport interface ShellPanel {\n /** Passed back to `Provider` and `page.Page`. */\n readonly config: unknown;\n /** Wraps the whole shell, so the panel's state outlives its closing. */\n readonly Provider: ComponentType<ShellPanelProviderProps>;\n /** Wraps the screen and draws the panel beside it. */\n readonly Layout: ComponentType<ShellPanelLayoutProps>;\n /** The account menu's row and the layer's title. Default: the shell's « Améliorer l'app ». */\n readonly label?: string;\n /** The panel's language, for the shell's own words when the app has no agent to say it. */\n readonly locale?: string | undefined;\n /** Changes when the panel's conversation changes; a launch link's focus waits on it. */\n readonly identity?: unknown;\n /** The address that shows only the panel, without the shell. */\n readonly page?: { readonly matches: () => boolean; readonly Page: ComponentType<ShellPanelPageProps> };\n}\n\n/** The screen when the app has no panel. */\nexport function NoPanelLayout({ children, navigation }: ShellPanelLayoutProps) {\n return <div className=\"relative flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden\">\n <div className=\"flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden\">{children}</div>\n <div className=\"shrink-0 md:hidden\">{navigation}</div>\n </div>;\n}\n",
|
|
110
|
+
"content": "'use client';\n\n// The shell's panel place: a second agent or tool beside the app, opened from the account menu. The shell knows only this shape, so an app\n// plugs in what it wants (the product manager is `pmPanel` from the Factory's `platform-ui/pm-panel`) and an app without one installs nothing more.\nimport type { ComponentType, ReactNode } from 'react';\n\nexport interface ShellPanelProviderProps {\n /** `ShellPanel.config`, as the panel wrote it. */\n readonly config: unknown;\n readonly open: boolean;\n readonly close: () => void;\n readonly appName?: string | undefined;\n readonly locale?: string | undefined;\n /** The panel took the whole screen, or gave it back; `collapse` is what Back runs. */\n readonly onFull?: ((full: boolean, collapse: () => void) => void) | undefined;\n readonly children: ReactNode;\n}\n\nexport interface ShellPanelLayoutProps {\n readonly children: ReactNode;\n /** The phone tab bar. The layout hides it while the panel covers the page. */\n readonly navigation?: ReactNode;\n /** The label of the screen on show. */\n readonly screen?: string;\n}\n\nexport interface ShellPanelPageProps {\n readonly config: unknown;\n readonly appName: string;\n readonly locale?: string | undefined;\n}\n\n/** Keep `Provider`, `Layout` and `page.Page` the same across renders: a new component remounts the app. */\nexport interface ShellPanel {\n /** Passed back to `Provider` and `page.Page`. */\n readonly config: unknown;\n /** Wraps the whole shell, so the panel's state outlives its closing. */\n readonly Provider: ComponentType<ShellPanelProviderProps>;\n /** Wraps the screen and draws the panel beside it. */\n readonly Layout: ComponentType<ShellPanelLayoutProps>;\n /** The account menu's row and the layer's title. Default: the shell's « Améliorer l'app ». */\n readonly label?: string;\n /** `false`: no account menu row for the panel, which still mounts (a host that already shows the panel, around a framed app). Default: the row. */\n readonly menu?: false;\n /** The panel's language, for the shell's own words when the app has no agent to say it. */\n readonly locale?: string | undefined;\n /** Changes when the panel's conversation changes; a launch link's focus waits on it. */\n readonly identity?: unknown;\n /** The address that shows only the panel, without the shell. */\n readonly page?: { readonly matches: () => boolean; readonly Page: ComponentType<ShellPanelPageProps> };\n}\n\n/** The screen when the app has no panel. */\nexport function NoPanelLayout({ children, navigation }: ShellPanelLayoutProps) {\n return <div className=\"relative flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden\">\n <div className=\"flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden\">{children}</div>\n <div className=\"shrink-0 md:hidden\">{navigation}</div>\n </div>;\n}\n",
|
|
111
111
|
"type": "registry:component",
|
|
112
112
|
"target": "components/app-shell/shell-panel.tsx"
|
|
113
113
|
},
|