@webship.co/workspace 0.0.0-stage → 1.0.0-alpha1
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/PLUGINS.md +249 -0
- package/README.md +379 -2
- package/WORKSPACES.md +69 -0
- package/actions/ai-items.js +293 -0
- package/actions/audio.js +48 -0
- package/actions/build-order.js +79 -0
- package/actions/checks.js +95 -0
- package/actions/claude-session.js +104 -0
- package/actions/commands-center.js +258 -0
- package/actions/composer.js +267 -0
- package/actions/ddev-addons.js +90 -0
- package/actions/ddev-mail.js +80 -0
- package/actions/ddev.js +237 -0
- package/actions/folders.js +165 -0
- package/actions/graphs.js +107 -0
- package/actions/images-svg.js +33 -0
- package/actions/images.js +145 -0
- package/actions/navigation.js +190 -0
- package/actions/node.js +222 -0
- package/actions/notes.js +55 -0
- package/actions/obsidian.js +59 -0
- package/actions/plugins.js +36 -0
- package/actions/project-make.js +28 -0
- package/actions/rag.js +202 -0
- package/actions/row-actions.js +262 -0
- package/actions/screens.js +270 -0
- package/actions/testing.js +121 -0
- package/actions/video-edit.js +54 -0
- package/ai-inventory.js +182 -0
- package/ai-items.js +202 -0
- package/assistant-cast.js +176 -0
- package/assistant-suggestions.js +91 -0
- package/assistant.js +484 -0
- package/audio-voice.js +528 -0
- package/auth-google.js +260 -0
- package/build-ai.js +52 -0
- package/build-order-page.js +191 -0
- package/build-order.js +313 -0
- package/builder-args.js +117 -0
- package/changelog.js +302 -0
- package/chat-threads.js +134 -0
- package/checks.js +105 -0
- package/claude-args.js +282 -0
- package/claude-sessions.js +129 -0
- package/command-author.js +117 -0
- package/command-block.js +20 -0
- package/commands/defaults.yml +159 -0
- package/commands-page.js +460 -0
- package/commands.js +568 -0
- package/cucumber.js +43 -0
- package/ddev-addons-page.js +124 -0
- package/ddev-addons.js +154 -0
- package/ddev-custom-commands.js +96 -0
- package/ddev-domains.js +84 -0
- package/ddev-import.js +93 -0
- package/ddev-mail-page.js +167 -0
- package/ddev-mail.js +110 -0
- package/ddev-options.js +145 -0
- package/ddev-snapshots.js +126 -0
- package/ddev.js +145 -0
- package/docs/DEVELOPER-GUIDE.md +224 -0
- package/docs/README.md +252 -0
- package/docs/REFERENCE.md +684 -0
- package/docs/USER-GUIDE.md +697 -0
- package/docs/screenshots/assistant/avatar-board.png +0 -0
- package/docs/screenshots/assistant/avatar-coffee.png +0 -0
- package/docs/screenshots/assistant/avatar-colours.png +0 -0
- package/docs/screenshots/assistant/avatar-icons.png +0 -0
- package/docs/screenshots/assistant/avatar-moves.png +0 -0
- package/docs/screenshots/assistant/avatar-pick-cookieman.png +0 -0
- package/docs/screenshots/assistant/avatar-settings-voices.png +0 -0
- package/docs/screenshots/assistant/avatar-status.png +0 -0
- package/docs/screenshots/create-card/create-card.png +0 -0
- package/docs/screenshots/create-card/start-ai.png +0 -0
- package/docs/screenshots/guide-clone-address.png +0 -0
- package/docs/screenshots/guide-clone-worktree-off.png +0 -0
- package/docs/screenshots/guide-clone-worktree-on.png +0 -0
- package/docs/screenshots/guide-ddev-menu.png +0 -0
- package/docs/screenshots/guide-domains.png +0 -0
- package/docs/screenshots/guide-home.png +0 -0
- package/docs/screenshots/guide-ide-menu.png +0 -0
- package/docs/screenshots/guide-remove-refusal.png +0 -0
- package/docs/screenshots/guide-rows-worktree.png +0 -0
- package/docs/screenshots/guide-screens.png +0 -0
- package/docs/screenshots/guide-settings.png +0 -0
- package/docs/screenshots/guide-workspace-page.png +0 -0
- package/docs/screenshots/home/card-settings.png +0 -0
- package/docs/screenshots/home/drag-reorder.png +0 -0
- package/docs/screenshots/home/edit-card.png +0 -0
- package/docs/screenshots/images/claude-code-image.png +0 -0
- package/docs/screenshots/images/edit-with-ai.png +0 -0
- package/docs/screenshots/images/gallery.png +0 -0
- package/docs/screenshots/images/generate-description.png +0 -0
- package/docs/screenshots/images/generate-plan.png +0 -0
- package/docs/screenshots/images/providers.png +0 -0
- package/docs/screenshots/images/svg-form.png +0 -0
- package/docs/screenshots/images/svg-result.png +0 -0
- package/docs/screenshots/images/upload.png +0 -0
- package/docs/screenshots/new-folder/2-form.png +0 -0
- package/docs/screenshots/new-folder/8-ddev-options.png +0 -0
- package/docs/screenshots/new-folder/files-folder.png +0 -0
- package/docs/screenshots/projects/ddev-custom-commands.png +0 -0
- package/docs/screenshots/projects/state-chip-stop.png +0 -0
- package/docs/screenshots/readme/dashboard-dark.png +0 -0
- package/docs/screenshots/readme/dashboard-light.png +0 -0
- package/docs/screenshots/readme/dev-dark.png +0 -0
- package/docs/screenshots/readme/dev-light.png +0 -0
- package/docs/screenshots/readme/docs-dark.png +0 -0
- package/docs/screenshots/readme/docs-light.png +0 -0
- package/docs/screenshots/readme/icons/ai.svg +4 -0
- package/docs/screenshots/readme/icons/code.svg +4 -0
- package/docs/screenshots/readme/icons/docs.svg +4 -0
- package/docs/screenshots/readme/icons/services.svg +4 -0
- package/docs/screenshots/readme/icons/sites.svg +4 -0
- package/docs/screenshots/readme/test-report-dark.png +0 -0
- package/docs/screenshots/readme/test-report-light.png +0 -0
- package/docs/screenshots/readme/test-scenarios.png +0 -0
- package/docs/screenshots/readme/test-steps.png +0 -0
- package/docs/screenshots/readme/videos-dark.png +0 -0
- package/docs/screenshots/readme/videos-light.png +0 -0
- package/docs/screenshots/screens-menu.png +0 -0
- package/docs/screenshots/screens-new.png +0 -0
- package/docs/screenshots/settings/auth-google-lists.png +0 -0
- package/docs/screenshots/settings/delete-1-row.png +0 -0
- package/docs/screenshots/settings/delete-2-confirm.png +0 -0
- package/docs/screenshots/settings/delete-3-type-name.png +0 -0
- package/docs/screenshots/settings/delete-4-type-yes.png +0 -0
- package/event-log.js +59 -0
- package/files-browser.js +247 -0
- package/folders.js +121 -0
- package/format.js +73 -0
- package/git-info.js +81 -0
- package/github-auth.js +62 -0
- package/graphs.js +104 -0
- package/html.js +10 -0
- package/icons.js +83 -0
- package/images-ai.js +447 -0
- package/images-create.js +226 -0
- package/images-page.js +417 -0
- package/images-records.js +39 -0
- package/images-render.js +36 -0
- package/images-svg-core.js +214 -0
- package/images-svg-sanitize.js +188 -0
- package/images-svg.js +241 -0
- package/images.js +270 -0
- package/jobs.js +430 -0
- package/linger.js +21 -0
- package/lists.js +292 -0
- package/media-info.js +136 -0
- package/navigation-page.js +264 -0
- package/navigation.js +394 -0
- package/notes.js +0 -0
- package/obsidian.js +405 -0
- package/package.json +47 -4
- package/palette-index.js +46 -0
- package/playwright.config.js +23 -0
- package/plugin-install.js +239 -0
- package/plugin-loader.js +448 -0
- package/plugin-manifest.js +83 -0
- package/plugin-settings.js +44 -0
- package/plugins/workspace-notes/index.js +59 -0
- package/plugins-page.js +103 -0
- package/project-make.js +241 -0
- package/project-name.js +210 -0
- package/public/assistant-avatar-moves.js +646 -0
- package/public/assistant-avatar-ui.js +1223 -0
- package/public/assistant-avatar.js +530 -0
- package/public/avatar/druplicon.vrm +0 -0
- package/public/css/base.css +260 -0
- package/public/css/components/ai-assistant.css +263 -0
- package/public/css/components/assistant-avatar.css +279 -0
- package/public/css/components/assistant-views.css +198 -0
- package/public/css/components/build-order.css +74 -0
- package/public/css/components/clone-as-a-git-worktree.css +30 -0
- package/public/css/components/command-palette.css +61 -0
- package/public/css/components/commands-center.css +139 -0
- package/public/css/components/confirm-dialog.css +40 -0
- package/public/css/components/create-card.css +8 -0
- package/public/css/components/ddev-menu.css +23 -0
- package/public/css/components/deep-chat.css +17 -0
- package/public/css/components/density.css +11 -0
- package/public/css/components/floating-assistant-workspace-pages.css +55 -0
- package/public/css/components/folders.css +5 -0
- package/public/css/components/full-height-left-assistant-sidebar-all-pages.css +39 -0
- package/public/css/components/full-height-right-actions-rail-pages-that-have-actions.css +267 -0
- package/public/css/components/grid-and-list.css +203 -0
- package/public/css/components/help-page.css +36 -0
- package/public/css/components/hero.css +16 -0
- package/public/css/components/images.css +70 -0
- package/public/css/components/layout-rhythm-and-spacing-polish.css +139 -0
- package/public/css/components/mic-on-any-field-that-wants-typing.css +416 -0
- package/public/css/components/modal-fit.css +113 -0
- package/public/css/components/page-width.css +23 -0
- package/public/css/components/plugins-page.css +9 -0
- package/public/css/components/project-cards.css +256 -0
- package/public/css/components/project-rows.css +390 -0
- package/public/css/components/reordering-the-workspace-cards.css +85 -0
- package/public/css/components/responsive-home-and-the-workspace-pages.css +457 -0
- package/public/css/components/running-commands-as-toasts.css +327 -0
- package/public/css/components/settings-editor-core-config-yml.css +828 -0
- package/public/css/components/short-results-as-a-toast-at-the-top.css +203 -0
- package/public/css/components/sound-player.css +84 -0
- package/public/css/components/suggest-agents-skills-commands.css +112 -0
- package/public/css/components/tab-modal.css +142 -0
- package/public/css/components/terminals.css +244 -0
- package/public/css/components/test-report-media-one-file-per-row-paged.css +144 -0
- package/public/css/components/top-toolbar-logo-breadcrumb.css +107 -0
- package/public/css/components/video-edit.css +6 -0
- package/public/css/components/web-ide-open-with.css +23 -0
- package/public/css/components/working-indicators.css +158 -0
- package/public/css/components/workspace-cards.css +79 -0
- package/public/favicon.ico +0 -0
- package/public/favicon.png +0 -0
- package/public/htmx.min.js +1 -0
- package/public/logo.png +0 -0
- package/public/media-player.js +215 -0
- package/public/mic.js +255 -0
- package/public/narration.js +110 -0
- package/public/palette.js +278 -0
- package/public/sortable.js +251 -0
- package/public/suggest.js +337 -0
- package/public/terminal.js +903 -0
- package/public/themes/default/theme.css +267 -0
- package/public/themes/ember/theme.css +180 -0
- package/public/themes/press/theme.css +290 -0
- package/public/themes/slate/theme.css +182 -0
- package/public/touch-icon.png +0 -0
- package/public/ui.js +2721 -0
- package/public/vendor/ace/LICENSE.txt +24 -0
- package/public/vendor/ace/ace.js +23 -0
- package/public/vendor/ace/ext-language_tools.js +8 -0
- package/public/vendor/ace/ext-searchbox.js +8 -0
- package/public/vendor/ace/mode-css.js +8 -0
- package/public/vendor/ace/mode-html.js +8 -0
- package/public/vendor/ace/mode-javascript.js +8 -0
- package/public/vendor/ace/mode-markdown.js +8 -0
- package/public/vendor/ace/mode-sh.js +8 -0
- package/public/vendor/ace/mode-yaml.js +8 -0
- package/public/vendor/ace/theme-textmate.js +8 -0
- package/public/vendor/ace/theme-tomorrow_night.js +8 -0
- package/public/vendor/ace/worker-yaml.js +1 -0
- package/public/vendor/deep-chat.LICENSE +21 -0
- package/public/vendor/deep-chat.bundle.js +1 -0
- package/public/vendor/fonts/dm-serif-text-LICENSE.txt +93 -0
- package/public/vendor/fonts/dm-serif-text-latin-400-italic.woff2 +0 -0
- package/public/vendor/fonts/dm-serif-text-latin-400-normal.woff2 +0 -0
- package/public/vendor/fonts/figtree-LICENSE.txt +93 -0
- package/public/vendor/fonts/figtree-latin-400-italic.woff2 +0 -0
- package/public/vendor/fonts/figtree-latin-400-normal.woff2 +0 -0
- package/public/vendor/fonts/figtree-latin-600-italic.woff2 +0 -0
- package/public/vendor/fonts/figtree-latin-600-normal.woff2 +0 -0
- package/public/vendor/fonts/jetbrains-mono-LICENSE.txt +93 -0
- package/public/vendor/fonts/jetbrains-mono-latin-400-normal.woff2 +0 -0
- package/public/vendor/fonts/ubuntu-mono-LICENSE.txt +98 -0
- package/public/vendor/fonts/ubuntu-mono-latin-wght-normal.woff2 +0 -0
- package/public/vendor/tabler-LICENSE.txt +21 -0
- package/public/vendor/tabler-sprite.svg +1 -0
- package/public/vendor/three/LICENSE +21 -0
- package/public/vendor/three/addons/loaders/GLTFLoader.js +4860 -0
- package/public/vendor/three/addons/utils/BufferGeometryUtils.js +1501 -0
- package/public/vendor/three/addons/utils/SkeletonUtils.js +496 -0
- package/public/vendor/three/three.core.min.js +6 -0
- package/public/vendor/three/three.module.min.js +6 -0
- package/public/vendor/three-vrm/LICENSE +22 -0
- package/public/vendor/three-vrm/three-vrm.module.min.js +983 -0
- package/public/vendor/uikit-icons.min.js +1 -0
- package/public/vendor/uikit.min.css +1 -0
- package/public/vendor/uikit.min.js +1 -0
- package/public/vendor/webship-icons/ui/bookmark-outlined.svg +1 -0
- package/public/vendor/webship-icons/ui/headset.svg +1 -0
- package/public/vendor/webship-icons/ui/menu.svg +1 -0
- package/public/vendor/webship-icons/ui/pause.svg +1 -0
- package/public/vendor/webship-icons/ui/play.svg +1 -0
- package/public/vendor/webship-icons/ui/share.svg +1 -0
- package/public/vendor/webship-icons/ui/sound-off.svg +1 -0
- package/public/vendor/webship-icons/ui/sound-on.svg +1 -0
- package/public/vendor/webship-icons/ui/stop.svg +1 -0
- package/public/vendor/xterm/addon-fit.js +2 -0
- package/public/vendor/xterm/addon-unicode11.js +2 -0
- package/public/vendor/xterm/addon-web-links.js +2 -0
- package/public/vendor/xterm/xterm.css +218 -0
- package/public/vendor/xterm/xterm.js +2 -0
- package/rag.js +169 -0
- package/reader.js +132 -0
- package/repo-blobs.js +66 -0
- package/report-pdf.js +337 -0
- package/row-layout.js +1440 -0
- package/screenrc +37 -0
- package/screens.js +1063 -0
- package/scripts/code-server-start.sh +190 -0
- package/scripts/images-claude-finish.js +107 -0
- package/scripts/screen-host.sh +114 -0
- package/scripts/screen-runner.js +362 -0
- package/scripts/screen-then.js +47 -0
- package/scripts/update.sh +350 -0
- package/server.js +8140 -0
- package/settings-file.js +423 -0
- package/speech.js +297 -0
- package/tab-modal.js +75 -0
- package/test-reports.js +260 -0
- package/themes.js +381 -0
- package/transcripts.js +168 -0
- package/urls.js +13 -0
- package/validate.js +18 -0
- package/version-check.js +94 -0
- package/video-edit.js +588 -0
- package/waiting.js +85 -0
- package/workspace-card-tools.js +41 -0
- package/workspace-delete.js +139 -0
- package/workspace-types.js +85 -0
- package/workspaces.js +557 -0
package/public/ui.js
ADDED
|
@@ -0,0 +1,2721 @@
|
|
|
1
|
+
/* Tiny progressive-enhancement layer on top of HTMX (no frameworks). */
|
|
2
|
+
|
|
3
|
+
// Preferences — theme, the rail, the assistant, the page width — are stored by the pre-paint script in
|
|
4
|
+
// a COOKIE on the hub domain, because localStorage is per-origin and this dashboard spans
|
|
5
|
+
// <workspace>.<hub> subdomains: kept there, the theme was a different theme on every workspace page.
|
|
6
|
+
// These two are defined in <head> so the first paint can read them; this is the guard for a page that
|
|
7
|
+
// somehow loads this file without it.
|
|
8
|
+
const wsPref = window.wsPref || ((name, fallback) => { try { return localStorage.getItem(name) ?? fallback; } catch (_) { return fallback; } });
|
|
9
|
+
const wsPrefSet = window.wsPrefSet || ((name, value) => { try { localStorage.setItem(name, value); } catch (_) { /* ignore */ } });
|
|
10
|
+
|
|
11
|
+
// Every important action — remove, delete, reset to the defaults, a command that asks before it runs — goes through
|
|
12
|
+
// one dialog: "Wait! Are you sure you want to proceed with this action?", Yes or No. The button says what it is in
|
|
13
|
+
// the dialog, so the answer is given to a named thing rather than to a button that changed its own label.
|
|
14
|
+
//
|
|
15
|
+
// It replaces a two-step arm, where the first click swapped the label for "⚠️ Sure?" and a second click within five
|
|
16
|
+
// seconds went through: a double click did the whole thing, and nothing ever said what was about to happen.
|
|
17
|
+
let pendingConfirm = null;
|
|
18
|
+
|
|
19
|
+
function runConfirmed(btn) {
|
|
20
|
+
pendingConfirm = null;
|
|
21
|
+
if (!btn) return;
|
|
22
|
+
window.htmx && window.htmx.trigger(btn, 'confirmed-remove');
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
document.addEventListener('click', (e) => {
|
|
26
|
+
const btn = e.target.closest('.arm-step');
|
|
27
|
+
if (!btn) return;
|
|
28
|
+
// Neither this click nor the dialog's may navigate: these are links as well as buttons, and a link with an empty
|
|
29
|
+
// href reloads the page, losing the toast the request answers with.
|
|
30
|
+
e.preventDefault();
|
|
31
|
+
pendingConfirm = btn;
|
|
32
|
+
// What the action is: its title where it has one (an icon-only trash can), else its own label — and what it
|
|
33
|
+
// belongs to, from the row or card it sits in, so "Delete" is never the whole of what the dialog says.
|
|
34
|
+
const label = (btn.getAttribute('title') || btn.dataset.tipHint || btn.textContent || '').replace(/\s+/g, ' ').trim();
|
|
35
|
+
const card = btn.closest('.project-row, .cc-row, #cc-command, .row-item, .editor-card, .settings-row');
|
|
36
|
+
// The card's own heading, whatever that card calls it: a project row has an h3.project-title, a
|
|
37
|
+
// command row a .cmd-title, a file row a bold span. First match wins, and never one inside the button.
|
|
38
|
+
const name = card && [...card.querySelectorAll('.project-title, .cmd-title, h1, h2, h3, .item-row-body .uk-text-bold')]
|
|
39
|
+
.find((el) => !btn.contains(el) && el.textContent.trim());
|
|
40
|
+
const belongsTo = name ? name.textContent.replace(/\s+/g, ' ').trim().slice(0, 60) : '';
|
|
41
|
+
const what = belongsTo && !label.includes(belongsTo) ? `${label} — ${belongsTo}` : label;
|
|
42
|
+
const where = document.getElementById('confirm-what');
|
|
43
|
+
if (where) where.textContent = what;
|
|
44
|
+
if (window.UIkit && document.getElementById('confirm-modal')) {
|
|
45
|
+
window.UIkit.modal('#confirm-modal').show();
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
// No UIkit on the page (a fragment rendered on its own): the browser's own dialog rather than acting unasked.
|
|
49
|
+
if (window.confirm(`Are you sure you want to proceed with this action?${what ? `\n\n${what}` : ''}`)) runConfirmed(btn);
|
|
50
|
+
else pendingConfirm = null;
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
document.addEventListener('click', (e) => {
|
|
54
|
+
if (e.target.closest('.confirm-yes')) {
|
|
55
|
+
const btn = pendingConfirm;
|
|
56
|
+
e.preventDefault();
|
|
57
|
+
if (window.UIkit) window.UIkit.modal('#confirm-modal').hide();
|
|
58
|
+
runConfirmed(btn);
|
|
59
|
+
} else if (e.target.closest('.confirm-no')) {
|
|
60
|
+
pendingConfirm = null;
|
|
61
|
+
}
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
// Closing it any other way — the X, Escape, the backdrop — is No.
|
|
65
|
+
document.addEventListener('hidden', (e) => {
|
|
66
|
+
if (e.target && e.target.id === 'confirm-modal') pendingConfirm = null;
|
|
67
|
+
}, true);
|
|
68
|
+
|
|
69
|
+
// The field microphones live in public/mic.js — they and the assistant's microphone now read the
|
|
70
|
+
// same resolved config (window.WS_SPEECH, from speech.js), so `speech_language` reaches both.
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
// The icon search. The chooser offers one library, fetched as you type: 4736 tiles
|
|
74
|
+
// rendered inline would be a multi-megabyte page, and nobody scans four thousand
|
|
75
|
+
// icons — they type.
|
|
76
|
+
let iconSearchTimer = null;
|
|
77
|
+
document.addEventListener('input', (e) => {
|
|
78
|
+
if (!e.target.closest('.icon-filter')) return;
|
|
79
|
+
// Debounced, so typing a word is one request rather than one per keystroke.
|
|
80
|
+
clearTimeout(iconSearchTimer);
|
|
81
|
+
iconSearchTimer = setTimeout(() => {
|
|
82
|
+
document.body.dispatchEvent(new CustomEvent('search-icons'));
|
|
83
|
+
}, 220);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
// Give each video the aspect ratio it actually has.
|
|
87
|
+
//
|
|
88
|
+
// The browser is the only party here that knows a recording's dimensions: the app
|
|
89
|
+
// container has no ffprobe/ffmpeg, so the server cannot emit width/height attributes,
|
|
90
|
+
// and UIKit's media reset derives `aspect-ratio` from exactly those missing attributes
|
|
91
|
+
// (see .video-player in style.css — without a stated ratio the element lays out 2px
|
|
92
|
+
// tall). CSS states 16/9 so the box is never zero; this replaces it with the real ratio
|
|
93
|
+
// as soon as the metadata arrives, so a 16:10 screencast is not letterboxed for nothing.
|
|
94
|
+
function sizeVideoPlayers(scope) {
|
|
95
|
+
(scope || document).querySelectorAll('video.video-player').forEach((v) => {
|
|
96
|
+
const apply = () => {
|
|
97
|
+
if (v.videoWidth && v.videoHeight) v.style.aspectRatio = `${v.videoWidth} / ${v.videoHeight}`;
|
|
98
|
+
};
|
|
99
|
+
if (v.readyState >= 1) apply(); // metadata already in
|
|
100
|
+
v.addEventListener('loadedmetadata', apply, { once: true }); // or when it lands
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// htmx 4 dispatches htmx:after:swap on the element that MADE the request, not on
|
|
105
|
+
// the one that was swapped — the swapped element is what htmx:after:settle names.
|
|
106
|
+
// Every listener below wants the element whose content just changed, so it asks
|
|
107
|
+
// for it once, here, rather than trusting e.target.
|
|
108
|
+
function swappedEl(e) {
|
|
109
|
+
return (e.detail && e.detail.target) || e.target;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Open the editor modal whenever something is swapped into it, and put the list back
|
|
113
|
+
// in step when it closes — a save may have renamed or changed what the list shows.
|
|
114
|
+
/**
|
|
115
|
+
* In the clone form, the "also answer at <name>-<workspace>" label has to follow
|
|
116
|
+
* the name field: the address is derived from the name, and a label showing
|
|
117
|
+
* "<name>-dev" while the field says "blog2" makes people check the wrong thing.
|
|
118
|
+
*/
|
|
119
|
+
function wireCloneAddressPreview(root) {
|
|
120
|
+
const scope = root && root.querySelector ? root : document;
|
|
121
|
+
const name = scope.querySelector('form [name="to"]');
|
|
122
|
+
const host = scope.querySelector('.clone-ws-host');
|
|
123
|
+
if (!name || !host || host.dataset.wired === '1') return;
|
|
124
|
+
host.dataset.wired = '1';
|
|
125
|
+
const ws = host.textContent.replace(/^<name>-|^<name>-/, '').replace('.ddev.site', '');
|
|
126
|
+
const paint = () => { host.textContent = `${name.value || '<name>'}-${ws}.ddev.site`; };
|
|
127
|
+
name.addEventListener('input', paint);
|
|
128
|
+
paint();
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function onEditorSwap(e) {
|
|
132
|
+
const el = swappedEl(e);
|
|
133
|
+
if (!el || !el.id) return;
|
|
134
|
+
if (el.id === 'editor-modal-body' && window.UIkit) {
|
|
135
|
+
// The dropdown the item was clicked in stays open behind — UIkit only closes a
|
|
136
|
+
// dropdown on an outside click, and opening a modal is not one. The result is a
|
|
137
|
+
// menu sitting on top of the dialog it just opened, which reads as a rendering
|
|
138
|
+
// bug. Every row menu that opens this modal has it, so it is closed here rather
|
|
139
|
+
// than by each caller.
|
|
140
|
+
document.querySelectorAll('.uk-dropdown.uk-open').forEach((d) => {
|
|
141
|
+
try { window.UIkit.dropdown(d).hide(false); } catch (_) { /* not a dropdown any more */ }
|
|
142
|
+
});
|
|
143
|
+
// Showing an open modal again is a no-op, so both events may fire.
|
|
144
|
+
window.UIkit.modal('#editor-modal').show();
|
|
145
|
+
sizeVideoPlayers(el);
|
|
146
|
+
wireCloneAddressPreview(el);
|
|
147
|
+
}
|
|
148
|
+
// A save result is short and the editor above it is tall, so scroll it into view —
|
|
149
|
+
// otherwise a successful save is invisible and reads as nothing having happened.
|
|
150
|
+
if (el.id === 'editor-result') {
|
|
151
|
+
el.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
|
|
152
|
+
// Keep the list behind the modal in step with what was just written.
|
|
153
|
+
document.body.dispatchEvent(new CustomEvent('refresh-projects'));
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
document.body.addEventListener('htmx:after:swap', onEditorSwap);
|
|
157
|
+
document.body.addEventListener('htmx:after:settle', onEditorSwap);
|
|
158
|
+
|
|
159
|
+
// The Generate voice form shows the options of the mode and engine chosen, and counts what is typed.
|
|
160
|
+
document.addEventListener('change', (e) => {
|
|
161
|
+
if (!e.target.matches || !e.target.matches('.voice-form input[name="engine"], .voice-form input[name="kind"], .voice-form select[name="voice_choice"]')) return;
|
|
162
|
+
const form = e.target.form;
|
|
163
|
+
const checked = (name) => (form.querySelector(`input[name="${name}"]:checked`) || {}).value;
|
|
164
|
+
const picker = form.querySelector('select[name="voice_choice"]');
|
|
165
|
+
const engine = picker ? picker.value.split(':')[0] : checked('engine');
|
|
166
|
+
const modeField = form.querySelector('input[name="kind"]:checked, input[type="hidden"][name="kind"]');
|
|
167
|
+
const mode = (modeField && modeField.value) || 'text';
|
|
168
|
+
form.querySelectorAll('[data-engine-pane]').forEach((el) => { el.hidden = el.dataset.enginePane !== engine; });
|
|
169
|
+
form.querySelectorAll('[data-mode-pane]').forEach((el) => { el.hidden = el.dataset.modePane !== mode; });
|
|
170
|
+
form.querySelectorAll('[data-engine-button]').forEach((el) => { el.hidden = el.dataset.engineButton !== engine || (el.dataset.modeButton || 'text') !== mode; });
|
|
171
|
+
form.querySelectorAll('[data-plan-button]').forEach((el) => { el.hidden = mode !== 'video'; });
|
|
172
|
+
const plan = document.getElementById('voice-plan');
|
|
173
|
+
if (plan) plan.innerHTML = '';
|
|
174
|
+
});
|
|
175
|
+
// A plan is only true for the form it was made from.
|
|
176
|
+
['change', 'input'].forEach((type) => document.addEventListener(type, (e) => {
|
|
177
|
+
if (!e.target.closest || !e.target.closest('.voice-form')) return;
|
|
178
|
+
const plan = document.getElementById('voice-plan');
|
|
179
|
+
if (plan) plan.innerHTML = '';
|
|
180
|
+
}));
|
|
181
|
+
document.addEventListener('input', (e) => {
|
|
182
|
+
if (!e.target.matches || !e.target.matches('[data-voice-text]')) return;
|
|
183
|
+
const n = e.target.form && e.target.form.querySelector('[data-voice-count]');
|
|
184
|
+
if (n) n.textContent = e.target.value.length;
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
// The editor modal is a dialog: named after its heading, and the page behind it is out of reach while it is
|
|
188
|
+
// open. Without that, Tab from the last button went to the page behind the overlay and a screen reader had
|
|
189
|
+
// a dialog with no name.
|
|
190
|
+
function containEditorModal(open) {
|
|
191
|
+
const modal = document.getElementById('editor-modal');
|
|
192
|
+
if (!modal) return;
|
|
193
|
+
for (const el of Array.from(document.body.children)) {
|
|
194
|
+
if (el === modal || el.contains(modal) || el.id === 'confirm-modal' || el.tagName === 'SCRIPT'
|
|
195
|
+
|| el.classList.contains('uk-notification')) continue;
|
|
196
|
+
if (open) {
|
|
197
|
+
if (!('wasInert' in el.dataset)) el.dataset.wasInert = el.inert ? '1' : '';
|
|
198
|
+
el.inert = true;
|
|
199
|
+
} else if ('wasInert' in el.dataset) {
|
|
200
|
+
el.inert = el.dataset.wasInert === '1';
|
|
201
|
+
delete el.dataset.wasInert;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
function nameEditorModal() {
|
|
206
|
+
// UIkit puts role="dialog" aria-modal="true" on the inner .uk-modal-dialog, so that is the one that is named.
|
|
207
|
+
const dialog = document.querySelector('#editor-modal .uk-modal-dialog');
|
|
208
|
+
const h = dialog && dialog.querySelector('.modal-head h3, h3, h2');
|
|
209
|
+
if (dialog && h) dialog.setAttribute('aria-label', h.textContent.replace(/\s+/g, ' ').trim());
|
|
210
|
+
}
|
|
211
|
+
document.addEventListener('shown', (e) => {
|
|
212
|
+
if (!e.target || e.target.id !== 'editor-modal') return;
|
|
213
|
+
containEditorModal(true);
|
|
214
|
+
nameEditorModal();
|
|
215
|
+
});
|
|
216
|
+
document.body.addEventListener('htmx:after:swap', (e) => {
|
|
217
|
+
if (swappedEl(e)?.id === 'editor-modal-body') nameEditorModal();
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
document.addEventListener('hidden', (e) => {
|
|
221
|
+
if (!e.target || e.target.id !== 'editor-modal') return;
|
|
222
|
+
containEditorModal(false);
|
|
223
|
+
const body = document.getElementById('editor-modal-body');
|
|
224
|
+
// Pause first: emptying the container detaches the element but a playing video can
|
|
225
|
+
// keep its audio going, which is startling when the modal has visibly closed.
|
|
226
|
+
body?.querySelectorAll('video, audio').forEach((m) => { try { m.pause(); } catch (_) { /* gone already */ } });
|
|
227
|
+
if (body) body.innerHTML = ''; // drop the editor, and any unsaved text with it
|
|
228
|
+
document.body.dispatchEvent(new CustomEvent('refresh-projects'));
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
// Show error responses instead of discarding them.
|
|
232
|
+
//
|
|
233
|
+
// HTMX only swaps a 2xx by default, so every 4xx this dashboard returns — 67 of them,
|
|
234
|
+
// plus 9 fragment ones — was silently dropped: the refused save, the 409 when a
|
|
235
|
+
// settings file changed underneath, "Unknown workspace", the bash -n syntax report.
|
|
236
|
+
// The user clicked and nothing happened, which reads as the button being broken.
|
|
237
|
+
//
|
|
238
|
+
// The server already returns a rendered message with each of these, so swap it in and
|
|
239
|
+
// let the status stay honest.
|
|
240
|
+
document.body.addEventListener('htmx:before:swap', (e) => {
|
|
241
|
+
const status = e.detail.xhr && e.detail.xhr.status;
|
|
242
|
+
if (status >= 400 && status < 600) {
|
|
243
|
+
e.detail.shouldSwap = true;
|
|
244
|
+
e.detail.isError = false; // it is a handled response, not a transport failure
|
|
245
|
+
}
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
// Copy a shown command to the clipboard. The dashboard prints commands it deliberately does not
|
|
249
|
+
// run — MCP registration writes to ~/.claude, which belongs to the person at the keyboard, not to
|
|
250
|
+
// a web request — so the useful button is "copy it", not "do it".
|
|
251
|
+
document.body.addEventListener('click', async (e) => {
|
|
252
|
+
const btn = e.target.closest('.copy-cmd');
|
|
253
|
+
if (!btn) return;
|
|
254
|
+
e.preventDefault();
|
|
255
|
+
const text = btn.dataset.copy || (btn.previousElementSibling && btn.previousElementSibling.textContent) || '';
|
|
256
|
+
try {
|
|
257
|
+
await navigator.clipboard.writeText(text.trim());
|
|
258
|
+
const was = btn.innerHTML;
|
|
259
|
+
btn.innerHTML = '<span uk-icon="icon: check; ratio: .7"></span> Copied';
|
|
260
|
+
setTimeout(() => { btn.innerHTML = was; }, 1500);
|
|
261
|
+
} catch (_) {
|
|
262
|
+
// Clipboard access needs a secure context and permission; the command is on screen either way.
|
|
263
|
+
btn.textContent = 'Select and copy it';
|
|
264
|
+
}
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
// Generate SVG: picking an example fills the prompt and the style, and both stay editable.
|
|
268
|
+
document.addEventListener('change', (e) => {
|
|
269
|
+
const sel = e.target && e.target.matches && e.target.matches('select[data-svg-example]') ? e.target : null;
|
|
270
|
+
const o = sel && sel.selectedOptions[0];
|
|
271
|
+
if (!o || !o.dataset.prompt) return;
|
|
272
|
+
const form = sel.closest('form');
|
|
273
|
+
const ta = form && form.querySelector('textarea[name="prompt"]');
|
|
274
|
+
if (ta) {
|
|
275
|
+
ta.value = o.dataset.prompt;
|
|
276
|
+
if (ta._aceEditor) ta._aceEditor.setValue(o.dataset.prompt, 1);
|
|
277
|
+
}
|
|
278
|
+
const style = form && form.querySelector('select[name="style"]');
|
|
279
|
+
if (style && o.dataset.style) style.value = o.dataset.style;
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
// An animated SVG in the viewer starts when a quarter of it is in view, and Replay starts it again: an SVG shown
|
|
283
|
+
// as an image restarts its animation when it is loaded anew.
|
|
284
|
+
function playSvg(img) {
|
|
285
|
+
const src = img.dataset.src || img.getAttribute('src');
|
|
286
|
+
img.setAttribute('src', `${src}${src.includes('?') ? '&' : '?'}play=${Date.now()}`);
|
|
287
|
+
}
|
|
288
|
+
function watchSvgMotion(root) {
|
|
289
|
+
(root || document).querySelectorAll('img[data-svg-motion]:not([data-svg-watched])').forEach((img) => {
|
|
290
|
+
img.dataset.svgWatched = '1';
|
|
291
|
+
if (!window.IntersectionObserver) return;
|
|
292
|
+
const io = new IntersectionObserver((entries) => {
|
|
293
|
+
if (!entries.some((en) => en.isIntersecting)) return;
|
|
294
|
+
io.disconnect();
|
|
295
|
+
playSvg(img);
|
|
296
|
+
}, { threshold: Number(img.dataset.threshold) || 0.25 });
|
|
297
|
+
io.observe(img);
|
|
298
|
+
});
|
|
299
|
+
}
|
|
300
|
+
document.addEventListener('DOMContentLoaded', () => watchSvgMotion());
|
|
301
|
+
document.body.addEventListener('htmx:after:settle', () => watchSvgMotion());
|
|
302
|
+
document.body.addEventListener('click', (e) => {
|
|
303
|
+
const btn = e.target.closest('[data-svg-replay]');
|
|
304
|
+
if (!btn) return;
|
|
305
|
+
const card = btn.closest('[data-img-viewer]');
|
|
306
|
+
const img = card && card.querySelector('img[data-svg-motion]');
|
|
307
|
+
if (img) playSvg(img);
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
// Opening a menu asks for what is in it NOW.
|
|
311
|
+
//
|
|
312
|
+
// The screens menu polls in the background so the row's hand and agent badge stay current, and that
|
|
313
|
+
// poll is deliberately slow — one request per project row every few seconds is what pinned this
|
|
314
|
+
// dashboard once before. Slow in the background is fine as long as OPENING the menu is not: a list of
|
|
315
|
+
// sessions is the one thing you open the menu to read, and reading a thirty-second-old one is how you
|
|
316
|
+
// end up clicking a session that has already finished.
|
|
317
|
+
//
|
|
318
|
+
// UIkit emits `beforeshow` on the dropdown, and htmx listens for `menu-opened` on the list inside it.
|
|
319
|
+
document.addEventListener('beforeshow', (e) => {
|
|
320
|
+
const list = e.target.querySelector && e.target.querySelector('[hx-trigger*="menu-opened"]');
|
|
321
|
+
if (list) list.dispatchEvent(new CustomEvent('menu-opened'));
|
|
322
|
+
}, true);
|
|
323
|
+
|
|
324
|
+
// A pick closes the menu it was made in.
|
|
325
|
+
//
|
|
326
|
+
// UIkit closes a dropdown on an OUTSIDE click, and almost nothing these menus do is one: opening a
|
|
327
|
+
// modal is not, opening a terminal window is not, and posting an action that swaps the output panel is
|
|
328
|
+
// not. So the menu stayed open, floating over the thing it had just opened — which reads as a control
|
|
329
|
+
// that did not take, and on the screens menu it sat on top of the terminal it had just started.
|
|
330
|
+
//
|
|
331
|
+
// This used to be scoped to items carrying `uk-toggle` (the modal openers), which is why the screens
|
|
332
|
+
// menu was never covered: its items carry `data-open-terminal` instead. A menu closing when you choose
|
|
333
|
+
// something from it is what a menu does, so the rule is now the general one — every link and button
|
|
334
|
+
// inside any dropdown. Inputs, selects and labels are deliberately NOT included: a dropdown that holds
|
|
335
|
+
// a filter box (the icon picker, the command picker) must stay open while it is being typed in.
|
|
336
|
+
document.body.addEventListener('click', (e) => {
|
|
337
|
+
if (!window.UIkit) return;
|
|
338
|
+
const item = e.target.closest('.uk-dropdown a, .uk-dropdown button');
|
|
339
|
+
if (!item) return;
|
|
340
|
+
const drop = item.closest('[uk-dropdown]') || item.closest('.uk-dropdown');
|
|
341
|
+
if (!drop) return;
|
|
342
|
+
// The pager and the filter inside a dropdown-based picker re-render it in place; closing on those
|
|
343
|
+
// would shut the picker on the first page turn.
|
|
344
|
+
if (item.closest('[data-keep-open]')) return;
|
|
345
|
+
try { window.UIkit.dropdown(drop).hide(0); } catch (_) { /* not a dropdown any more */ }
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
// Ace (ajaxorg/ace, BSD-3) for the files that are code: .sh, .yml, .md.
|
|
349
|
+
//
|
|
350
|
+
// It attaches to a textarea marked data-ace="<mode>" and writes every change straight
|
|
351
|
+
// back into it, so every existing form keeps posting the same field and no endpoint
|
|
352
|
+
// changed. If Ace fails to load, the textarea is simply still there and still works —
|
|
353
|
+
// which is why it replaces rather than removes it.
|
|
354
|
+
//
|
|
355
|
+
// Loaded on demand: 780KB of editor has no business on a page that is not editing.
|
|
356
|
+
|
|
357
|
+
// ---------------------------------------------------------------- mentions --
|
|
358
|
+
// One fetch per source, cached: the list changes when someone installs an agent,
|
|
359
|
+
// not between keystrokes.
|
|
360
|
+
const _mentionCache = new Map();
|
|
361
|
+
function mentionsFor(url) {
|
|
362
|
+
if (!_mentionCache.has(url)) {
|
|
363
|
+
_mentionCache.set(url, fetch(url).then((r) => r.json()).catch(() => []));
|
|
364
|
+
}
|
|
365
|
+
return _mentionCache.get(url);
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
function attachMentions(ace, editor, url) {
|
|
369
|
+
let tools;
|
|
370
|
+
try { tools = ace.require('ace/ext/language_tools'); } catch (_) { return; }
|
|
371
|
+
if (!tools) return;
|
|
372
|
+
editor.setOptions({ enableBasicAutocompletion: true, enableLiveAutocompletion: true });
|
|
373
|
+
// The same endpoint and the same rules as the composer's popup (suggest.js), so the two
|
|
374
|
+
// fields cannot offer different things: "@" for agents and skills, "/" for commands and
|
|
375
|
+
// skills. It used to be "@" only, and it inserted `@"name (kind)"` — a shape neither the
|
|
376
|
+
// CLI nor the assistant understands, so what you picked was not what you could send.
|
|
377
|
+
let items = { '@': [], '/': [] };
|
|
378
|
+
const load = (trigger) => fetch(`${url}${url.includes('?') ? '&' : '?'}trigger=${encodeURIComponent(trigger)}`,
|
|
379
|
+
{ headers: { Accept: 'application/json' } })
|
|
380
|
+
.then((r) => (r.ok ? r.json() : []))
|
|
381
|
+
.then((list) => { items[trigger] = list || []; })
|
|
382
|
+
.catch(() => { /* no completions; the editor still edits */ });
|
|
383
|
+
load('@');
|
|
384
|
+
load('/');
|
|
385
|
+
tools.addCompleter({
|
|
386
|
+
// Only after a trigger — this is a prompt field, not a code editor, and suggesting on
|
|
387
|
+
// every word would fight the typing.
|
|
388
|
+
identifierRegexps: [/[@/a-zA-Z0-9_.-]/],
|
|
389
|
+
getCompletions(ed, session, pos, prefix, callback) {
|
|
390
|
+
const line = session.getLine(pos.row).slice(0, pos.column);
|
|
391
|
+
let best = null;
|
|
392
|
+
for (const trigger of ['@', '/']) {
|
|
393
|
+
const at = line.lastIndexOf(trigger);
|
|
394
|
+
if (at === -1) continue;
|
|
395
|
+
const before = at === 0 ? '' : line[at - 1];
|
|
396
|
+
if (before && !/\s/.test(before)) continue; // an email, or a path
|
|
397
|
+
const typed = line.slice(at + 1);
|
|
398
|
+
if (/[\s"']/.test(typed)) continue; // the token is finished
|
|
399
|
+
if (trigger === '/' && at !== 0 && !/^\s*$/.test(line.slice(0, at))) continue;
|
|
400
|
+
if (!best || at > best.at) best = { trigger, at, typed: typed.toLowerCase() };
|
|
401
|
+
}
|
|
402
|
+
if (!best) return callback(null, []);
|
|
403
|
+
const hits = (items[best.trigger] || [])
|
|
404
|
+
.filter((m) => m.name.toLowerCase().includes(best.typed))
|
|
405
|
+
.slice(0, 40)
|
|
406
|
+
.map((m, _i, arr) => ({
|
|
407
|
+
// The trigger and the name — plus the kind when the name alone is ambiguous.
|
|
408
|
+
// A few items exist as both an agent and a skill and are not interchangeable.
|
|
409
|
+
caption: `${best.trigger}${m.name}${arr.filter((o) => o.name === m.name).length > 1 ? ` (${m.kind})` : ''}`,
|
|
410
|
+
value: `${best.trigger}${m.name}${arr.filter((o) => o.name === m.name).length > 1 ? ` (${m.kind})` : ''}`,
|
|
411
|
+
meta: `${m.kind} · ${m.where}`,
|
|
412
|
+
docHTML: m.description
|
|
413
|
+
? `<div style="max-width:38rem;white-space:normal">${m.description.replace(/[&<>]/g, (c) => ({ '&': '&', '<': '<', '>': '>' }[c]))}</div>`
|
|
414
|
+
: undefined,
|
|
415
|
+
score: m.where === 'this project' ? 1000 : m.where === 'installed' ? 500 : 100,
|
|
416
|
+
}));
|
|
417
|
+
callback(null, hits);
|
|
418
|
+
},
|
|
419
|
+
});
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
let acePromise = null;
|
|
423
|
+
function loadAce() {
|
|
424
|
+
if (acePromise) return acePromise;
|
|
425
|
+
acePromise = new Promise((resolve, reject) => {
|
|
426
|
+
const el = document.createElement('script');
|
|
427
|
+
el.src = '/vendor/ace/ace.js';
|
|
428
|
+
el.onload = () => {
|
|
429
|
+
window.ace.config.set('basePath', '/vendor/ace');
|
|
430
|
+
// Ace resolves worker and mode files itself from basePath; only the yaml worker
|
|
431
|
+
// is vendored, so the others must not be reached for.
|
|
432
|
+
window.ace.config.setModuleUrl('ace/mode/yaml_worker', '/vendor/ace/worker-yaml.js');
|
|
433
|
+
// The completion extension is part of loading Ace, not a race beside it:
|
|
434
|
+
// an editor that mounts first would find no completer to register with.
|
|
435
|
+
const lt = document.createElement('script');
|
|
436
|
+
lt.src = '/vendor/ace/ext-language_tools.js';
|
|
437
|
+
lt.onload = () => resolve(window.ace);
|
|
438
|
+
lt.onerror = () => resolve(window.ace); // no completions, still an editor
|
|
439
|
+
document.head.appendChild(lt);
|
|
440
|
+
};
|
|
441
|
+
el.onerror = reject;
|
|
442
|
+
document.head.appendChild(el);
|
|
443
|
+
});
|
|
444
|
+
return acePromise;
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
const ACE_MODES = { sh: 'sh', yaml: 'yaml', yml: 'yaml', markdown: 'markdown', md: 'markdown' };
|
|
448
|
+
|
|
449
|
+
function aceTheme() {
|
|
450
|
+
// settings.yml's style.editor_theme can pin the editor independently of the page:
|
|
451
|
+
// 'auto' follows the mode, 'light'/'dark' force it — a code editor is one place
|
|
452
|
+
// people often want dark even on a light page.
|
|
453
|
+
const forced = document.documentElement.dataset.editorTheme;
|
|
454
|
+
const dark = forced === 'dark' ? true
|
|
455
|
+
: forced === 'light' ? false
|
|
456
|
+
: document.documentElement.classList.contains('dark');
|
|
457
|
+
return dark ? 'ace/theme/tomorrow_night' : 'ace/theme/textmate';
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
function attachAce(root) {
|
|
461
|
+
// A swap can name a target that is not an element (a polling card replaces itself); there is nothing to attach to then.
|
|
462
|
+
if (root && typeof root.querySelectorAll !== 'function') return;
|
|
463
|
+
const fields = (root || document).querySelectorAll('textarea[data-ace]:not([data-ace-ready])');
|
|
464
|
+
if (!fields.length) return;
|
|
465
|
+
// Claim each field NOW, synchronously, before awaiting the loader — the guard above
|
|
466
|
+
// is only honest if it is set before anything can await. Both `htmx:after:swap` and
|
|
467
|
+
// `htmx:after:settle` call this for the same swap, and on the FIRST open Ace is a
|
|
468
|
+
// 780KB fetch: the two calls used to query, both match the same textarea while the
|
|
469
|
+
// load was still in flight, and both attach an editor to it. Two stacked editors, the
|
|
470
|
+
// same prompt visible twice. Never reproducible once Ace is cached, because then the
|
|
471
|
+
// loader resolves in a microtask and the first claim lands before the second event.
|
|
472
|
+
fields.forEach((ta) => { ta.dataset.aceReady = '1'; });
|
|
473
|
+
loadAce().then((ace) => {
|
|
474
|
+
fields.forEach((ta) => {
|
|
475
|
+
const mode = ACE_MODES[ta.dataset.ace] || 'sh';
|
|
476
|
+
// data-ace-single: one line (a command line) — no Enter, no gutter, and a pasted line break becomes a space.
|
|
477
|
+
// readonly: code shown on a page, to read and copy, not to type into.
|
|
478
|
+
const single = ta.dataset.aceSingle === '1';
|
|
479
|
+
const readOnly = ta.readOnly || ta.dataset.aceReadonly === '1';
|
|
480
|
+
const host = document.createElement('div');
|
|
481
|
+
host.className = `ace-host${single ? ' ace-host-single' : ''}${readOnly ? ' ace-host-readonly' : ''}`;
|
|
482
|
+
// A short field (a prompt, a few steps) gets a short editor: the 260px
|
|
483
|
+
// floor is for the file editors, not for a form row. A one-line editor sizes itself.
|
|
484
|
+
if (!single) host.style.height = `${ta.rows <= 8 ? Math.max(90, ta.rows * 19) : Math.max(260, Math.min(620, ta.rows * 19))}px`;
|
|
485
|
+
ta.parentNode.insertBefore(host, ta);
|
|
486
|
+
ta.style.display = 'none'; // kept in the DOM: it is what submits
|
|
487
|
+
|
|
488
|
+
const editor = ace.edit(host);
|
|
489
|
+
editor.session.setMode(`ace/mode/${mode}`);
|
|
490
|
+
// "@" offers the agents, skills and prompts this machine and this project
|
|
491
|
+
// actually have — the same names Claude Code answers to, so a prompt can
|
|
492
|
+
// name one without anybody remembering how it is spelled.
|
|
493
|
+
if (ta.dataset.mentions) attachMentions(ace, editor, ta.dataset.mentions);
|
|
494
|
+
editor.setTheme(aceTheme());
|
|
495
|
+
editor.session.setValue(ta.value);
|
|
496
|
+
editor.session.setUseSoftTabs(true);
|
|
497
|
+
editor.session.setTabSize(2);
|
|
498
|
+
editor.setShowPrintMargin(false);
|
|
499
|
+
editor.setOption('useWorker', mode === 'yaml' && !readOnly); // only the yaml worker is vendored
|
|
500
|
+
if (ta.placeholder && editor.$options && editor.$options.placeholder) editor.setOption('placeholder', ta.placeholder);
|
|
501
|
+
if (single) {
|
|
502
|
+
editor.setOptions({ minLines: 1, maxLines: 1, showGutter: false, highlightActiveLine: false });
|
|
503
|
+
editor.commands.addCommand({ name: 'oneLine', bindKey: { win: 'Enter|Shift-Enter', mac: 'Enter|Shift-Enter' }, exec: () => {} });
|
|
504
|
+
editor.on('paste', (ev) => { ev.text = String(ev.text).replace(/[\r\n]+/g, ' '); });
|
|
505
|
+
}
|
|
506
|
+
if (readOnly) editor.setOptions({ readOnly: true, highlightActiveLine: false, highlightGutterLine: false });
|
|
507
|
+
editor.session.on('change', () => { ta.value = editor.session.getValue(); });
|
|
508
|
+
// An editor drawn inside a closed <details> or a hidden tab has no size yet: draw it again when it gets one.
|
|
509
|
+
if (window.ResizeObserver) new ResizeObserver(() => editor.resize()).observe(host);
|
|
510
|
+
ta._aceEditor = editor;
|
|
511
|
+
});
|
|
512
|
+
}).catch(() => {
|
|
513
|
+
// No Ace: hand the fields back so the textarea is a plain textarea again and a
|
|
514
|
+
// later attempt (another swap) can still try.
|
|
515
|
+
fields.forEach((ta) => { delete ta.dataset.aceReady; });
|
|
516
|
+
});
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
// Add a row and add a button are forms in the modal, and both answer with the rows editor behind it — so the modal
|
|
520
|
+
// has nothing left to show. Closed from here rather than with an hx-on on each button, whose `event.detail.successful`
|
|
521
|
+
// does not exist in htmx 4. `after:settle` is the event that names the element that was swapped: `after:swap` reports
|
|
522
|
+
// the element that MADE the request, which here is the button inside the modal.
|
|
523
|
+
document.body.addEventListener('htmx:after:settle', (e) => {
|
|
524
|
+
if (!e.target || e.target.id !== 'row-actions-editor') return;
|
|
525
|
+
if (window.UIkit && document.querySelector('#editor-modal.uk-open')) window.UIkit.modal('#editor-modal').hide();
|
|
526
|
+
});
|
|
527
|
+
|
|
528
|
+
document.addEventListener('DOMContentLoaded', () => attachAce());
|
|
529
|
+
// Start AI's prompt gets Ace only once Start AI is ticked, so a workspace page does not load the editor for nothing.
|
|
530
|
+
document.addEventListener('change', (e) => {
|
|
531
|
+
const box = e.target && e.target.matches && e.target.matches('input[name="ai_after"]') ? e.target : null;
|
|
532
|
+
if (!box || !box.checked) return;
|
|
533
|
+
const form = box.closest('form');
|
|
534
|
+
const ta = form && form.querySelector('textarea[data-ace-on-demand]:not([data-ace])');
|
|
535
|
+
if (!ta) return;
|
|
536
|
+
ta.dataset.ace = ta.dataset.aceOnDemand;
|
|
537
|
+
attachAce(form);
|
|
538
|
+
});
|
|
539
|
+
document.body.addEventListener('htmx:after:swap', (e) => attachAce(swappedEl(e)));
|
|
540
|
+
document.body.addEventListener('htmx:after:settle', (e) => attachAce(swappedEl(e)));
|
|
541
|
+
|
|
542
|
+
// Follow the theme toggle, since the editor paints its own background.
|
|
543
|
+
document.addEventListener('click', (e) => {
|
|
544
|
+
if (!e.target.closest('.theme-toggle')) return;
|
|
545
|
+
setTimeout(() => {
|
|
546
|
+
document.querySelectorAll('textarea[data-ace-ready]').forEach((ta) => {
|
|
547
|
+
if (ta._aceEditor) ta._aceEditor.setTheme(aceTheme());
|
|
548
|
+
});
|
|
549
|
+
}, 50);
|
|
550
|
+
});
|
|
551
|
+
|
|
552
|
+
// The actions rail. Collapsed state is remembered, like the theme, because it is
|
|
553
|
+
// a layout preference and having it reset on every navigation would be noise.
|
|
554
|
+
function applyActionsCollapsed(collapsed) {
|
|
555
|
+
document.documentElement.classList.toggle('actions-collapsed', collapsed);
|
|
556
|
+
const toggle = document.querySelector('.actions-toggle');
|
|
557
|
+
if (toggle) {
|
|
558
|
+
toggle.setAttribute('aria-expanded', collapsed ? 'false' : 'true');
|
|
559
|
+
toggle.title = collapsed ? 'Expand the actions rail' : 'Collapse the actions rail';
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
// Collapsed is the default: the rail is on every page now, and the content — a
|
|
564
|
+
// project table, a diff, a job's output — wants the width more than the labels do.
|
|
565
|
+
// Only an explicit '0' (the user expanded it) keeps it open.
|
|
566
|
+
try {
|
|
567
|
+
applyActionsCollapsed(wsPref('actions-collapsed', '1') !== '0');
|
|
568
|
+
} catch (_) { applyActionsCollapsed(true); }
|
|
569
|
+
|
|
570
|
+
document.addEventListener('click', (e) => {
|
|
571
|
+
if (!e.target.closest('.actions-toggle')) return;
|
|
572
|
+
const collapsed = !document.documentElement.classList.contains('actions-collapsed');
|
|
573
|
+
applyActionsCollapsed(collapsed);
|
|
574
|
+
wsPrefSet('actions-collapsed', collapsed ? '1' : '0');
|
|
575
|
+
});
|
|
576
|
+
|
|
577
|
+
// The rail's names and its tooltip. Collapsed, a tile is a glyph: its name has to be there for a screen
|
|
578
|
+
// reader (aria-label, the visible label) and for the eye (a tooltip, at once and on keyboard focus — the
|
|
579
|
+
// native title waits about a second and never shows on focus). The sentence the title held moves to
|
|
580
|
+
// aria-description and the tooltip, so nothing is lost and nothing shows twice.
|
|
581
|
+
(function railTips() {
|
|
582
|
+
const rail = document.getElementById('actions-rail');
|
|
583
|
+
if (!rail) return;
|
|
584
|
+
rail.querySelectorAll('a.action-btn, button.action-btn').forEach((el) => {
|
|
585
|
+
if (el.classList.contains('actions-version')) return;
|
|
586
|
+
const label = (el.querySelector('.action-label') || {}).textContent;
|
|
587
|
+
if (!label || !label.trim()) return;
|
|
588
|
+
const name = label.replace(/\s+/g, ' ').trim();
|
|
589
|
+
const hint = (el.getAttribute('title') || '').replace(/\s+/g, ' ').trim();
|
|
590
|
+
el.dataset.tipLabel = name;
|
|
591
|
+
el.dataset.tipGroup = ((el.closest('.actions-group') || {}).dataset || {}).group || '';
|
|
592
|
+
if (!el.hasAttribute('aria-label')) el.setAttribute('aria-label', name);
|
|
593
|
+
if (hint && hint !== name) {
|
|
594
|
+
el.dataset.tipHint = hint;
|
|
595
|
+
el.setAttribute('aria-description', hint);
|
|
596
|
+
}
|
|
597
|
+
el.removeAttribute('title');
|
|
598
|
+
});
|
|
599
|
+
|
|
600
|
+
let tip = null;
|
|
601
|
+
let shownFor = null;
|
|
602
|
+
const hide = () => { if (tip) tip.hidden = true; shownFor = null; };
|
|
603
|
+
const show = (el) => {
|
|
604
|
+
if (window.innerWidth < 960 || !el.dataset.tipLabel) return;
|
|
605
|
+
const collapsed = document.documentElement.classList.contains('actions-collapsed');
|
|
606
|
+
const head = collapsed ? el.dataset.tipLabel : '';
|
|
607
|
+
const hint = el.dataset.tipHint || '';
|
|
608
|
+
if (!head && !hint) return;
|
|
609
|
+
if (!tip) {
|
|
610
|
+
tip = document.createElement('div');
|
|
611
|
+
tip.id = 'rail-tip';
|
|
612
|
+
tip.setAttribute('role', 'tooltip');
|
|
613
|
+
document.body.appendChild(tip);
|
|
614
|
+
}
|
|
615
|
+
tip.textContent = '';
|
|
616
|
+
if (head) { const b = document.createElement('strong'); b.textContent = head; tip.appendChild(b); }
|
|
617
|
+
if (hint) { const s = document.createElement('span'); s.textContent = hint; tip.appendChild(s); }
|
|
618
|
+
tip.hidden = false;
|
|
619
|
+
const r = el.getBoundingClientRect();
|
|
620
|
+
const t = tip.getBoundingClientRect();
|
|
621
|
+
tip.style.right = `${Math.round(window.innerWidth - r.left + 10)}px`;
|
|
622
|
+
tip.style.top = `${Math.round(Math.max(8, Math.min(window.innerHeight - t.height - 8, r.top + r.height / 2 - t.height / 2)))}px`;
|
|
623
|
+
shownFor = el;
|
|
624
|
+
};
|
|
625
|
+
const tile = (e) => e.target.closest && e.target.closest('#actions-rail a.action-btn, #actions-rail button.action-btn');
|
|
626
|
+
rail.addEventListener('mouseover', (e) => { const el = tile(e); if (el && el !== shownFor) show(el); });
|
|
627
|
+
rail.addEventListener('mouseout', (e) => { if (tile(e)) hide(); });
|
|
628
|
+
rail.addEventListener('focusin', (e) => { const el = tile(e); if (el) show(el); });
|
|
629
|
+
rail.addEventListener('focusout', hide);
|
|
630
|
+
document.addEventListener('keydown', (e) => { if (e.key === 'Escape') hide(); });
|
|
631
|
+
rail.addEventListener('scroll', hide, { passive: true });
|
|
632
|
+
|
|
633
|
+
// Whether there is more rail below the fold, so Go and Help are not simply cut off: a fade at the foot
|
|
634
|
+
// while there is, and none once the end is in view.
|
|
635
|
+
const fade = () => {
|
|
636
|
+
const more = rail.scrollHeight - rail.clientHeight - rail.scrollTop > 4;
|
|
637
|
+
rail.classList.toggle('can-scroll', more);
|
|
638
|
+
};
|
|
639
|
+
rail.addEventListener('scroll', fade, { passive: true });
|
|
640
|
+
window.addEventListener('resize', fade);
|
|
641
|
+
document.addEventListener('click', (e) => { if (e.target.closest('.actions-toggle')) setTimeout(fade, 250); });
|
|
642
|
+
fade();
|
|
643
|
+
})();
|
|
644
|
+
|
|
645
|
+
// Tabbed modals (tab-modal.js renders the markup). A tab is a button that loads its fragment into the
|
|
646
|
+
// pane; this keeps the one you are on marked, moves between tabs with the arrow keys, filters a long
|
|
647
|
+
// list, keeps #<modal>/<tab> in the address for a modal that asks for it, and sends the links inside a
|
|
648
|
+
// pane back into the pane rather than away from the page.
|
|
649
|
+
(function tabModals() {
|
|
650
|
+
const hashOf = (tm) => tm.hasAttribute('data-hash') ? tm.dataset.tabmodal : '';
|
|
651
|
+
const select = (tm, tab) => {
|
|
652
|
+
tm.querySelectorAll('.tabmodal-tab').forEach((t) => {
|
|
653
|
+
const on = t === tab;
|
|
654
|
+
t.classList.toggle('is-selected', on);
|
|
655
|
+
t.setAttribute('aria-selected', on ? 'true' : 'false');
|
|
656
|
+
t.tabIndex = on ? 0 : -1;
|
|
657
|
+
});
|
|
658
|
+
const head = tm.querySelector('.tabmodal-heading');
|
|
659
|
+
const sub = tm.querySelector('.tabmodal-sub');
|
|
660
|
+
if (head) head.textContent = tab.dataset.label || '';
|
|
661
|
+
if (sub) sub.textContent = tab.dataset.hint || '';
|
|
662
|
+
const pane = tm.querySelector('.tabmodal-pane');
|
|
663
|
+
if (pane) pane.setAttribute('aria-labelledby', tab.id);
|
|
664
|
+
const h = hashOf(tm);
|
|
665
|
+
if (h) { try { history.replaceState(null, '', `#${h}/${encodeURIComponent(tab.dataset.tab)}`); } catch (_) { /* a sandboxed frame */ } }
|
|
666
|
+
};
|
|
667
|
+
|
|
668
|
+
document.addEventListener('click', (e) => {
|
|
669
|
+
const tab = e.target.closest && e.target.closest('.tabmodal-tab');
|
|
670
|
+
if (tab) { select(tab.closest('.tabmodal'), tab); return; }
|
|
671
|
+
|
|
672
|
+
// A link in a pane that points at another page of the same section loads into the pane.
|
|
673
|
+
const a = e.target.closest && e.target.closest('.tabmodal-pane a[href^="/settings"]');
|
|
674
|
+
if (a && !e.defaultPrevented && !e.metaKey && !e.ctrlKey && !e.shiftKey && e.button === 0
|
|
675
|
+
&& a.target !== '_blank' && window.htmx) {
|
|
676
|
+
const tm = a.closest('.tabmodal');
|
|
677
|
+
e.preventDefault();
|
|
678
|
+
const u = new URL(a.getAttribute('href'), location.origin);
|
|
679
|
+
u.searchParams.set('fragment', '1');
|
|
680
|
+
window.htmx.ajax('GET', u.pathname + u.search, { target: a.closest('.tabmodal-pane'), swap: 'innerHTML' });
|
|
681
|
+
const seg = decodeURIComponent(u.pathname.replace(/^\/settings\/?/, ''));
|
|
682
|
+
const want = seg === '' ? 'overview' : seg;
|
|
683
|
+
const tab2 = [...tm.querySelectorAll('.tabmodal-tab')].find((t) => t.dataset.tab === want);
|
|
684
|
+
if (tab2) select(tm, tab2);
|
|
685
|
+
return;
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
// The rail's tile for a place that also exists as a modal: open it over the page you are on.
|
|
689
|
+
const opener = e.target.closest && e.target.closest('[data-tabmodal-open]');
|
|
690
|
+
if (opener && !e.metaKey && !e.ctrlKey && !e.shiftKey && e.button === 0 && window.htmx) {
|
|
691
|
+
e.preventDefault();
|
|
692
|
+
window.htmx.ajax('GET', opener.dataset.tabmodalOpen, { target: '#editor-modal-body', swap: 'innerHTML' });
|
|
693
|
+
}
|
|
694
|
+
});
|
|
695
|
+
|
|
696
|
+
document.addEventListener('keydown', (e) => {
|
|
697
|
+
const tab = e.target.closest && e.target.closest('.tabmodal-tab');
|
|
698
|
+
if (!tab || !['ArrowDown', 'ArrowUp', 'Home', 'End'].includes(e.key)) return;
|
|
699
|
+
const tabs = [...tab.closest('.tabmodal').querySelectorAll('.tabmodal-tab')].filter((t) => !t.hidden && t.offsetParent);
|
|
700
|
+
const i = tabs.indexOf(tab);
|
|
701
|
+
const next = e.key === 'Home' ? tabs[0] : e.key === 'End' ? tabs[tabs.length - 1]
|
|
702
|
+
: tabs[Math.min(tabs.length - 1, Math.max(0, i + (e.key === 'ArrowDown' ? 1 : -1)))];
|
|
703
|
+
if (next) { e.preventDefault(); next.focus(); }
|
|
704
|
+
});
|
|
705
|
+
|
|
706
|
+
document.addEventListener('input', (e) => {
|
|
707
|
+
const box = e.target.closest && e.target.closest('.tabmodal-filter');
|
|
708
|
+
if (!box) return;
|
|
709
|
+
const q = box.value.trim().toLowerCase();
|
|
710
|
+
const tm = box.closest('.tabmodal');
|
|
711
|
+
tm.querySelectorAll('.tabmodal-tab').forEach((t) => {
|
|
712
|
+
t.hidden = !!q && !(t.dataset.label || '').toLowerCase().includes(q);
|
|
713
|
+
});
|
|
714
|
+
tm.querySelectorAll('.tabmodal-group').forEach((g) => {
|
|
715
|
+
g.hidden = ![...g.querySelectorAll('.tabmodal-tab')].some((t) => !t.hidden);
|
|
716
|
+
});
|
|
717
|
+
});
|
|
718
|
+
|
|
719
|
+
// A link to a place: #settings/<tab> opens the modal on that tab, from any page.
|
|
720
|
+
// The tab you are on may be far down a long list: bring it into view when the modal opens.
|
|
721
|
+
document.body.addEventListener('htmx:after:swap', (e) => {
|
|
722
|
+
const t = e.target && e.target.id === 'editor-modal-body' && e.target.querySelector('.tabmodal-tab.is-selected');
|
|
723
|
+
if (t) t.scrollIntoView({ block: 'nearest' });
|
|
724
|
+
});
|
|
725
|
+
|
|
726
|
+
const openFromHash = () => {
|
|
727
|
+
const m = location.hash.match(/^#(settings)\/([^/]+)$/);
|
|
728
|
+
if (m && window.htmx) {
|
|
729
|
+
window.htmx.ajax('GET', `/fragments/settings/modal?tab=${m[2]}`, { target: '#editor-modal-body', swap: 'innerHTML' });
|
|
730
|
+
}
|
|
731
|
+
};
|
|
732
|
+
window.addEventListener('hashchange', openFromHash);
|
|
733
|
+
window.addEventListener('load', openFromHash);
|
|
734
|
+
document.addEventListener('hidden', (e) => {
|
|
735
|
+
if (e.target && e.target.id === 'editor-modal' && /^#settings\//.test(location.hash)) {
|
|
736
|
+
try { history.replaceState(null, '', location.pathname + location.search); } catch (_) { /* nothing to clear */ }
|
|
737
|
+
}
|
|
738
|
+
}, true);
|
|
739
|
+
})();
|
|
740
|
+
|
|
741
|
+
// A list's quick filter: typing narrows the cards already on screen at once (each card says what it is
|
|
742
|
+
// in data-find), while the debounced request asks the server for the wider answer. The input names
|
|
743
|
+
// the list it filters in data-list-find (a selector); the server's reply then replaces the cards, so
|
|
744
|
+
// nothing the page did not have is ever hidden by this. Plugins' lists (Jira's workitems) use it too.
|
|
745
|
+
document.addEventListener('input', (e) => {
|
|
746
|
+
const box = e.target.closest && e.target.closest('input[data-list-find]');
|
|
747
|
+
if (!box) return;
|
|
748
|
+
const words = box.value.toLowerCase().split(/\s+/).filter(Boolean);
|
|
749
|
+
let host = null;
|
|
750
|
+
try { host = document.querySelector(box.dataset.listFind || ''); } catch (_) { /* not a selector */ }
|
|
751
|
+
if (!host) return;
|
|
752
|
+
const cards = [...host.querySelectorAll('[data-find]')];
|
|
753
|
+
let shown = 0;
|
|
754
|
+
cards.forEach((c) => {
|
|
755
|
+
const hay = c.dataset.find || '';
|
|
756
|
+
const on = words.every((w) => hay.includes(w));
|
|
757
|
+
c.hidden = !on;
|
|
758
|
+
if (on) shown += 1;
|
|
759
|
+
});
|
|
760
|
+
let note = host.querySelector('.list-find-note');
|
|
761
|
+
if (!note) {
|
|
762
|
+
note = document.createElement('p');
|
|
763
|
+
note.className = 'list-find-note uk-text-meta';
|
|
764
|
+
host.prepend(note);
|
|
765
|
+
}
|
|
766
|
+
note.textContent = words.length && cards.length ? `${shown} of ${cards.length} shown here, asking for more…` : '';
|
|
767
|
+
note.hidden = !note.textContent;
|
|
768
|
+
});
|
|
769
|
+
|
|
770
|
+
// A project's row menus (DDEV, GIT, NODE, COMPOSER, UPSUN and the rest) can hold thirty or seventy entries.
|
|
771
|
+
// A long one folds its groups: the first group and "This project" stay open, the rest open on their
|
|
772
|
+
// heading; a heading that is a sentence stays a note. Every entry is one line, its full text the tooltip.
|
|
773
|
+
function foldDdevMenus(root) {
|
|
774
|
+
(root || document).querySelectorAll('.act-menu .uk-dropdown:not([data-folded])').forEach((drop) => {
|
|
775
|
+
const links = drop.querySelectorAll('li > a').length;
|
|
776
|
+
if (!links) return; // filled later by its own request, folded then
|
|
777
|
+
drop.dataset.folded = '1';
|
|
778
|
+
drop.querySelectorAll('li > a').forEach((a) => { if (!a.title) a.title = a.textContent.replace(/\s+/g, ' ').trim(); });
|
|
779
|
+
const rows = drop.querySelectorAll('li:not(.uk-nav-divider)').length;
|
|
780
|
+
if (links < 8 && rows < 12) return;
|
|
781
|
+
const heads = [...drop.querySelectorAll('.uk-dropdown-nav > .uk-nav-header')].filter((h) => h.textContent.trim().length <= 40);
|
|
782
|
+
let folds = 0;
|
|
783
|
+
heads.forEach((h) => {
|
|
784
|
+
const rows = [];
|
|
785
|
+
// A group ends at the next heading or divider; a short one is not worth a fold.
|
|
786
|
+
for (let n = h.nextElementSibling; n && !n.classList.contains('uk-nav-header') && !n.classList.contains('uk-nav-divider'); n = n.nextElementSibling) rows.push(n);
|
|
787
|
+
if (rows.length < 3) return;
|
|
788
|
+
const open = (folds === 0 && rows.length <= 6) || /this project/i.test(h.textContent);
|
|
789
|
+
folds += 1;
|
|
790
|
+
h.setAttribute('role', 'button');
|
|
791
|
+
h.tabIndex = 0;
|
|
792
|
+
h.classList.add('ddev-fold');
|
|
793
|
+
const set = (on) => { h.setAttribute('aria-expanded', on ? 'true' : 'false'); rows.forEach((r) => { r.hidden = !on; }); };
|
|
794
|
+
set(open);
|
|
795
|
+
h.addEventListener('click', (e) => { e.stopPropagation(); set(h.getAttribute('aria-expanded') !== 'true'); });
|
|
796
|
+
h.addEventListener('keydown', (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); h.click(); } });
|
|
797
|
+
});
|
|
798
|
+
});
|
|
799
|
+
}
|
|
800
|
+
document.addEventListener('DOMContentLoaded', () => foldDdevMenus());
|
|
801
|
+
document.body.addEventListener('htmx:after:swap', (e) => foldDdevMenus(swappedEl(e)));
|
|
802
|
+
// A menu that opens upwards near the top would sit under the top bar; the page moves down to show it.
|
|
803
|
+
document.addEventListener('shown', (e) => {
|
|
804
|
+
const d = e.target;
|
|
805
|
+
if (!d || !d.classList || !d.classList.contains('uk-dropdown') || !d.closest('.project-row')) return;
|
|
806
|
+
foldDdevMenus(d.closest('.act-menu') || d.parentElement);
|
|
807
|
+
const bar = document.querySelector('.toolbar, header');
|
|
808
|
+
const limit = (bar ? bar.getBoundingClientRect().bottom : 64) + 8;
|
|
809
|
+
const top = d.getBoundingClientRect().top;
|
|
810
|
+
if (top < limit) window.scrollBy({ top: top - limit, behavior: 'instant' });
|
|
811
|
+
}, true);
|
|
812
|
+
|
|
813
|
+
// A list's search box sits inside the region its own results replace, so every search threw the box
|
|
814
|
+
// away: focus fell to the page and whatever was typed while the request ran was lost. The field being
|
|
815
|
+
// typed in is remembered before a swap and given back after it, with its value and caret; if more was
|
|
816
|
+
// typed than was searched, the search runs again.
|
|
817
|
+
(function keepTypingThroughSwaps() {
|
|
818
|
+
let kept = null;
|
|
819
|
+
document.body.addEventListener('htmx:before:swap', (e) => {
|
|
820
|
+
const a = document.activeElement;
|
|
821
|
+
const ctx = (e.detail && e.detail.ctx) || {};
|
|
822
|
+
const target = ctx.target || (e.detail && e.detail.target) || e.target;
|
|
823
|
+
const into = target && target.nodeType === 1 ? target : null;
|
|
824
|
+
if (!a || !into || !into.contains(a) || !a.matches('input[name], textarea[name]')) { kept = null; return; }
|
|
825
|
+
kept = { name: a.name, value: a.value, start: a.selectionStart, end: a.selectionEnd, target: into };
|
|
826
|
+
});
|
|
827
|
+
document.body.addEventListener('htmx:after:swap', () => {
|
|
828
|
+
if (!kept) return;
|
|
829
|
+
const k = kept; kept = null;
|
|
830
|
+
const field = k.target.querySelector(`[name="${CSS.escape(k.name)}"]`);
|
|
831
|
+
if (!field) return;
|
|
832
|
+
const searched = field.value;
|
|
833
|
+
if (field.value !== k.value) field.value = k.value;
|
|
834
|
+
field.focus({ preventScroll: true });
|
|
835
|
+
try { field.setSelectionRange(k.start, k.end); } catch (_) { /* not a text field */ }
|
|
836
|
+
if (searched !== k.value) field.dispatchEvent(new KeyboardEvent('keyup', { bubbles: true, key: 'Unidentified' }));
|
|
837
|
+
});
|
|
838
|
+
})();
|
|
839
|
+
|
|
840
|
+
// A settings file: find a key, see a switch say what it is, and know when there is something unsaved.
|
|
841
|
+
document.addEventListener('input', (e) => {
|
|
842
|
+
const box = e.target.closest && e.target.closest('.settings-find');
|
|
843
|
+
if (!box) return;
|
|
844
|
+
const card = box.closest('.settings-card');
|
|
845
|
+
const words = box.value.toLowerCase().split(/\s+/).filter(Boolean);
|
|
846
|
+
let shown = 0;
|
|
847
|
+
card.querySelectorAll('.settings-form .settings-row').forEach((row) => {
|
|
848
|
+
const on = words.every((w) => row.textContent.toLowerCase().includes(w));
|
|
849
|
+
row.hidden = !on;
|
|
850
|
+
if (on) shown += 1;
|
|
851
|
+
});
|
|
852
|
+
card.querySelectorAll('.settings-form .settings-group').forEach((g) => {
|
|
853
|
+
g.hidden = !!words.length && ![...g.querySelectorAll('.settings-row')].some((r) => !r.hidden);
|
|
854
|
+
});
|
|
855
|
+
const none = card.querySelector('.settings-find-none');
|
|
856
|
+
if (none) none.hidden = shown > 0 || !words.length;
|
|
857
|
+
});
|
|
858
|
+
document.addEventListener('change', (e) => {
|
|
859
|
+
const sw = e.target.closest && e.target.closest('.settings-switch');
|
|
860
|
+
if (sw) { const st = sw.parentElement.querySelector('.settings-switch-state'); if (st) st.textContent = sw.checked ? 'On' : 'Off'; }
|
|
861
|
+
});
|
|
862
|
+
['input', 'change'].forEach((t) => document.addEventListener(t, (e) => {
|
|
863
|
+
const form = e.target.closest && e.target.closest('.settings-form');
|
|
864
|
+
// The list editor writes as it goes, so its fields are never unsaved settings.
|
|
865
|
+
if (!form || e.target.closest('.settings-find, [id^="list-editor"]')) return;
|
|
866
|
+
form.dataset.dirty = '1';
|
|
867
|
+
const d = form.querySelector('.settings-dirty');
|
|
868
|
+
if (d) d.hidden = false;
|
|
869
|
+
}));
|
|
870
|
+
document.body.addEventListener('htmx:after:request', (e) => {
|
|
871
|
+
const form = e.target && e.target.closest && e.target.closest('.settings-form');
|
|
872
|
+
if (!form || e.target.closest('[id^="list-editor"]')) return;
|
|
873
|
+
const ok = e.detail && e.detail.ctx && e.detail.ctx.response ? e.detail.ctx.response.status < 400 : true;
|
|
874
|
+
if (ok) { delete form.dataset.dirty; const d = form.querySelector('.settings-dirty'); if (d) d.hidden = true; }
|
|
875
|
+
});
|
|
876
|
+
window.addEventListener('beforeunload', (e) => {
|
|
877
|
+
if (document.querySelector('.settings-form[data-dirty]')) { e.preventDefault(); e.returnValue = ''; }
|
|
878
|
+
});
|
|
879
|
+
|
|
880
|
+
// The guide: search keeps the sections that hold every word and marks the hits; the contents follow
|
|
881
|
+
// the section you are reading.
|
|
882
|
+
(function helpPage() {
|
|
883
|
+
const page = document.querySelector('.help-layout');
|
|
884
|
+
if (!page) return;
|
|
885
|
+
const sections = [...page.querySelectorAll('.help-section')];
|
|
886
|
+
const clearMarks = () => page.querySelectorAll('mark.help-hit').forEach((m) => m.replaceWith(document.createTextNode(m.textContent)));
|
|
887
|
+
const mark = (root, word) => {
|
|
888
|
+
const walk = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
|
|
889
|
+
const hits = [];
|
|
890
|
+
for (let n = walk.nextNode(); n; n = walk.nextNode()) {
|
|
891
|
+
const i = n.nodeValue.toLowerCase().indexOf(word);
|
|
892
|
+
if (i >= 0 && !n.parentElement.closest('pre, code, mark')) hits.push([n, i]);
|
|
893
|
+
}
|
|
894
|
+
hits.forEach(([n, i]) => {
|
|
895
|
+
const r = document.createRange(); r.setStart(n, i); r.setEnd(n, i + word.length);
|
|
896
|
+
const m = document.createElement('mark'); m.className = 'help-hit'; r.surroundContents(m);
|
|
897
|
+
});
|
|
898
|
+
};
|
|
899
|
+
page.querySelector('.help-find').addEventListener('input', (e) => {
|
|
900
|
+
clearMarks();
|
|
901
|
+
page.querySelectorAll('.help-section').forEach((s) => s.normalize());
|
|
902
|
+
const words = e.target.value.toLowerCase().split(/\s+/).filter((w) => w.length > 1);
|
|
903
|
+
let shown = 0;
|
|
904
|
+
sections.forEach((s) => {
|
|
905
|
+
const on = words.every((w) => s.textContent.toLowerCase().includes(w));
|
|
906
|
+
s.hidden = !on;
|
|
907
|
+
const link = page.querySelector(`[data-help-toc="${CSS.escape(s.dataset.helpSection)}"]`);
|
|
908
|
+
if (link) link.parentElement.hidden = !on;
|
|
909
|
+
if (on) { shown += 1; if (words.length) words.forEach((w) => mark(s, w)); }
|
|
910
|
+
});
|
|
911
|
+
page.querySelector('.help-none').hidden = shown > 0;
|
|
912
|
+
});
|
|
913
|
+
if ('IntersectionObserver' in window) {
|
|
914
|
+
const io = new IntersectionObserver((entries) => {
|
|
915
|
+
entries.filter((x) => x.isIntersecting).forEach((x) => {
|
|
916
|
+
page.querySelectorAll('[data-help-toc]').forEach((a) => a.classList.toggle('is-current', a.dataset.helpToc === x.target.dataset.helpSection));
|
|
917
|
+
});
|
|
918
|
+
}, { rootMargin: '-20% 0px -70% 0px' });
|
|
919
|
+
sections.forEach((s) => io.observe(s));
|
|
920
|
+
}
|
|
921
|
+
})();
|
|
922
|
+
|
|
923
|
+
// Help for the page you are on: a "?" beside the page heading opens the guide at the part about it.
|
|
924
|
+
(function pageHelpLink() {
|
|
925
|
+
const where = document.documentElement.dataset.help;
|
|
926
|
+
const head = document.querySelector('.page-heading');
|
|
927
|
+
if (!where || !head || head.querySelector('.page-help')) return;
|
|
928
|
+
const a = document.createElement('a');
|
|
929
|
+
a.className = 'page-help';
|
|
930
|
+
a.href = `/help#${where}`;
|
|
931
|
+
a.title = 'How this page works, in the guide';
|
|
932
|
+
a.setAttribute('aria-label', 'Help for this page');
|
|
933
|
+
a.textContent = '?';
|
|
934
|
+
head.appendChild(a);
|
|
935
|
+
})();
|
|
936
|
+
|
|
937
|
+
// The guide's code blocks are what people come to copy, so each one has a Copy button.
|
|
938
|
+
document.querySelectorAll('.help-body pre').forEach((pre) => {
|
|
939
|
+
const b = document.createElement('button');
|
|
940
|
+
b.type = 'button';
|
|
941
|
+
b.className = 'help-copy';
|
|
942
|
+
b.textContent = 'Copy';
|
|
943
|
+
b.addEventListener('click', async () => {
|
|
944
|
+
try { await navigator.clipboard.writeText(pre.innerText.replace(/\nCopy$/, '')); b.textContent = 'Copied'; }
|
|
945
|
+
catch (_) { b.textContent = 'Select and copy'; }
|
|
946
|
+
setTimeout(() => { b.textContent = 'Copy'; }, 1600);
|
|
947
|
+
});
|
|
948
|
+
pre.classList.add('has-copy');
|
|
949
|
+
pre.appendChild(b);
|
|
950
|
+
});
|
|
951
|
+
|
|
952
|
+
// Starting a new conversation redraws the panel: deep-chat holds the transcript it was given, and
|
|
953
|
+
// emptying it is not something the component exposes — the page it is on is what carries it.
|
|
954
|
+
document.body.addEventListener('chat-cleared', () => { window.location.reload(); });
|
|
955
|
+
|
|
956
|
+
/* ---------------- how much room a page's content gets ------------------- */
|
|
957
|
+
|
|
958
|
+
// The pages were capped at UIKit's small container whatever room they had, which was right when the
|
|
959
|
+
// assistant was a fixed column and the rail was another: 900px was what was left. Neither is fixed any
|
|
960
|
+
// more, so the cap is a choice — and it is the content that most wants the width (a command catalog, a
|
|
961
|
+
// settings file, a terminal) that was capped hardest.
|
|
962
|
+
//
|
|
963
|
+
// Applied by the pre-paint script, like the assistant's view: this is the width of the main column, and
|
|
964
|
+
// changing it after the page is drawn moves everything on it.
|
|
965
|
+
document.addEventListener('click', (e) => {
|
|
966
|
+
const pick = e.target.closest('.page-width-pick');
|
|
967
|
+
if (!pick) return;
|
|
968
|
+
e.preventDefault();
|
|
969
|
+
const width = pick.dataset.width;
|
|
970
|
+
if (!['comfortable', 'wide', 'full'].includes(width)) return;
|
|
971
|
+
document.documentElement.dataset.pageWidth = width;
|
|
972
|
+
wsPrefSet('ws-page-width', width);
|
|
973
|
+
markCurrentViews();
|
|
974
|
+
});
|
|
975
|
+
|
|
976
|
+
// Comfortable or compact project rows. The same buttons; compact folds the caption under each one away, and
|
|
977
|
+
// shows it on hover instead. Applied before first paint, like the width.
|
|
978
|
+
document.addEventListener('click', (e) => {
|
|
979
|
+
const pick = e.target.closest('.density-pick');
|
|
980
|
+
if (!pick) return;
|
|
981
|
+
e.preventDefault();
|
|
982
|
+
const density = pick.dataset.density === 'compact' ? 'compact' : 'comfortable';
|
|
983
|
+
document.documentElement.dataset.density = density;
|
|
984
|
+
wsPrefSet('ws-density', density);
|
|
985
|
+
markCurrentViews();
|
|
986
|
+
});
|
|
987
|
+
|
|
988
|
+
document.addEventListener('click', (e) => {
|
|
989
|
+
const pick = e.target.closest('.tool-labels-pick');
|
|
990
|
+
if (!pick) return;
|
|
991
|
+
e.preventDefault();
|
|
992
|
+
const mode = pick.dataset.toolLabels === 'words' ? 'words' : 'icons';
|
|
993
|
+
document.documentElement.dataset.toolLabels = mode;
|
|
994
|
+
wsPrefSet('ws-tool-labels', mode);
|
|
995
|
+
markCurrentViews();
|
|
996
|
+
});
|
|
997
|
+
|
|
998
|
+
// The caption of a button is also its tooltip, so folding it away loses nothing for a pointer.
|
|
999
|
+
function syncRowCaptions(root) {
|
|
1000
|
+
for (const wrap of (root || document).querySelectorAll('.act-with-status')) {
|
|
1001
|
+
const cap = wrap.querySelector('.act-status');
|
|
1002
|
+
const btn = wrap.querySelector('button');
|
|
1003
|
+
if (!cap || !btn) continue;
|
|
1004
|
+
const text = cap.textContent.trim();
|
|
1005
|
+
if (text && !(btn.title || '').includes(text)) btn.title = btn.title ? `${btn.title} · ${text}` : text;
|
|
1006
|
+
}
|
|
1007
|
+
}
|
|
1008
|
+
document.addEventListener('DOMContentLoaded', () => syncRowCaptions());
|
|
1009
|
+
document.body.addEventListener('htmx:after:swap', (e) => syncRowCaptions(swappedEl(e)));
|
|
1010
|
+
|
|
1011
|
+
// The tab says when a session has stopped to ask something, so it is visible from any other tab (Vercel does
|
|
1012
|
+
// the same for a build). Only "waiting" goes in the title: a count of detached sessions would be noise, and the
|
|
1013
|
+
// page's own title is kept as it was.
|
|
1014
|
+
const BASE_TITLE = document.title;
|
|
1015
|
+
function syncTitleState() {
|
|
1016
|
+
const w = document.querySelector('.screens-count .screens-waiting');
|
|
1017
|
+
if (!w) { document.title = BASE_TITLE; return; }
|
|
1018
|
+
const n = parseInt(w.textContent.replace(/[^0-9]/g, ''), 10) || 1;
|
|
1019
|
+
document.title = `✋ ${n} waiting · ${BASE_TITLE}`;
|
|
1020
|
+
}
|
|
1021
|
+
document.body.addEventListener('htmx:after:swap', (e) => {
|
|
1022
|
+
const t = swappedEl(e);
|
|
1023
|
+
if (t && t.classList && (t.classList.contains('screens-count') || (t.closest && t.closest('.screens-count')))) syncTitleState();
|
|
1024
|
+
});
|
|
1025
|
+
// Watching the badge itself is sturdier than waiting for htmx's own event: it does not matter who rewrote it.
|
|
1026
|
+
const screensCountEl = document.querySelector('.screens-count');
|
|
1027
|
+
if (screensCountEl) new MutationObserver(syncTitleState).observe(screensCountEl, { childList: true, subtree: true, characterData: true });
|
|
1028
|
+
// The first count usually arrives before this script has run, so look once now.
|
|
1029
|
+
syncTitleState();
|
|
1030
|
+
|
|
1031
|
+
/* ---------------- the rail, arranged --------------------------------------- */
|
|
1032
|
+
|
|
1033
|
+
// Each entry in Commands → Rail posts "<group>:<id>", and dragging it moves the element without
|
|
1034
|
+
// touching that value — so a save said every entry was still where it was rendered, and hiding
|
|
1035
|
+
// something did nothing at all. Every form that sends the arrangement calls this first, from its own
|
|
1036
|
+
// hx-on::before:request: the value is rewritten from the list the entry is IN at that moment.
|
|
1037
|
+
window.navSyncGroups = function navSyncGroups() {
|
|
1038
|
+
const form = document.getElementById('nav-form');
|
|
1039
|
+
if (!form) return;
|
|
1040
|
+
for (const entry of form.querySelectorAll('.nav-entry')) {
|
|
1041
|
+
const list = entry.closest('[data-group]');
|
|
1042
|
+
const input = entry.querySelector('input[name="item"]');
|
|
1043
|
+
if (list && input) input.value = `${list.dataset.group}:${entry.dataset.id}`;
|
|
1044
|
+
}
|
|
1045
|
+
};
|
|
1046
|
+
|
|
1047
|
+
// Run it on every drop rather than when the form is sent: htmx collects a form's values when the
|
|
1048
|
+
// request is TRIGGERED, before htmx:before:request fires, so rewriting them there is already too late
|
|
1049
|
+
// — the save kept arriving with every entry still in the group it was rendered in. UIkit's sortable
|
|
1050
|
+
// says when something moved; that is the moment the values are stale.
|
|
1051
|
+
for (const ev of ['moved', 'added', 'removed', 'stop']) {
|
|
1052
|
+
document.addEventListener(ev, (e) => {
|
|
1053
|
+
if (e.target && e.target.closest && e.target.closest('#nav-form')) window.navSyncGroups();
|
|
1054
|
+
}, true);
|
|
1055
|
+
}
|
|
1056
|
+
|
|
1057
|
+
/* ---------------- grid or list -------------------------------------------- */
|
|
1058
|
+
|
|
1059
|
+
// Which layout the workspace cards and the project rows use. The state is on <html> and applied by the
|
|
1060
|
+
// pre-paint script; this only changes it, marks the chosen button, and remembers the choice through the
|
|
1061
|
+
// shared cookie so it is the same layout on every workspace.
|
|
1062
|
+
const VIEW_PREFS = {
|
|
1063
|
+
'ws-home-view': { attr: 'homeView', allowed: ['list', 'grid-1', 'grid-2', 'grid-3', 'grid-4', 'grid-5', 'grid-6'] },
|
|
1064
|
+
'ws-rows-view': { attr: 'rowsView', allowed: ['list', 'grid-1', 'grid-2', 'grid-3'] },
|
|
1065
|
+
// A folder of files: a list of names, or a grid of up to six across for a folder of pictures.
|
|
1066
|
+
'ws-files-view': { attr: 'filesView', allowed: ['list', 'grid-1', 'grid-2', 'grid-3', 'grid-4', 'grid-5', 'grid-6'] },
|
|
1067
|
+
};
|
|
1068
|
+
|
|
1069
|
+
function markCurrentViews() {
|
|
1070
|
+
for (const btn of document.querySelectorAll('.view-pick')) {
|
|
1071
|
+
const spec = VIEW_PREFS[btn.dataset.pref];
|
|
1072
|
+
if (!spec) continue;
|
|
1073
|
+
const current = document.documentElement.dataset[spec.attr] === btn.dataset.view;
|
|
1074
|
+
btn.classList.toggle('is-current', current);
|
|
1075
|
+
btn.setAttribute('aria-pressed', current ? 'true' : 'false');
|
|
1076
|
+
}
|
|
1077
|
+
for (const btn of document.querySelectorAll('.density-pick')) {
|
|
1078
|
+
const current = document.documentElement.dataset.density === btn.dataset.density;
|
|
1079
|
+
btn.classList.toggle('is-current', current);
|
|
1080
|
+
btn.setAttribute('aria-pressed', current ? 'true' : 'false');
|
|
1081
|
+
}
|
|
1082
|
+
// Words or icons: the browser's own choice, else what the style says (its --pcard-tool-label).
|
|
1083
|
+
const styleSays = (parseFloat(getComputedStyle(document.documentElement).getPropertyValue('--pcard-tool-label')) || 0) > 0 ? 'words' : 'icons';
|
|
1084
|
+
for (const btn of document.querySelectorAll('.tool-labels-pick')) {
|
|
1085
|
+
const current = (document.documentElement.dataset.toolLabels || styleSays) === btn.dataset.toolLabels;
|
|
1086
|
+
btn.classList.toggle('is-current', current);
|
|
1087
|
+
btn.setAttribute('aria-pressed', current ? 'true' : 'false');
|
|
1088
|
+
}
|
|
1089
|
+
// The page width sits in the same group and is marked the same way.
|
|
1090
|
+
for (const btn of document.querySelectorAll('.page-width-pick')) {
|
|
1091
|
+
const current = document.documentElement.dataset.pageWidth === btn.dataset.width;
|
|
1092
|
+
btn.classList.toggle('is-current', current);
|
|
1093
|
+
btn.setAttribute('aria-pressed', current ? 'true' : 'false');
|
|
1094
|
+
}
|
|
1095
|
+
}
|
|
1096
|
+
|
|
1097
|
+
document.addEventListener('click', (e) => {
|
|
1098
|
+
const btn = e.target.closest('.view-pick');
|
|
1099
|
+
if (!btn) return;
|
|
1100
|
+
e.preventDefault();
|
|
1101
|
+
const spec = VIEW_PREFS[btn.dataset.pref];
|
|
1102
|
+
if (!spec || !spec.allowed.includes(btn.dataset.view)) return;
|
|
1103
|
+
document.documentElement.dataset[spec.attr] = btn.dataset.view;
|
|
1104
|
+
wsPrefSet(btn.dataset.pref, btn.dataset.view);
|
|
1105
|
+
markCurrentViews();
|
|
1106
|
+
});
|
|
1107
|
+
|
|
1108
|
+
// The rows and the cards arrive in fragments, so the marking is redone after every swap.
|
|
1109
|
+
markCurrentViews();
|
|
1110
|
+
document.addEventListener('htmx:after:settle', markCurrentViews);
|
|
1111
|
+
|
|
1112
|
+
/* ---------------- how the assistant is shown ---------------------------- */
|
|
1113
|
+
|
|
1114
|
+
// Four views, one switch, after the pattern Drupal's own AI assistant uses: a toggle in the top
|
|
1115
|
+
// toolbar, and a menu in the panel's header holding the rest. Sidebar is the column it has always
|
|
1116
|
+
// been, Wide is that column doubled for a long answer, Floating is a launcher with the panel over
|
|
1117
|
+
// the page, and Full page is /assistant.
|
|
1118
|
+
//
|
|
1119
|
+
// The state lives on <html> (data-assistant-view, .assistant-closed) and is applied by the
|
|
1120
|
+
// pre-paint script in the head, not from here: the panel is 25rem of the layout, so deciding it in
|
|
1121
|
+
// this deferred file would draw the page one way and reflow it a frame later. This file only
|
|
1122
|
+
// CHANGES it. localStorage, like the theme and the rail — and with the same known limit, that it
|
|
1123
|
+
// does not cross the workspace subdomains.
|
|
1124
|
+
const ASSISTANT_VIEWS = ['sidebar', 'wide', 'float', 'full'];
|
|
1125
|
+
|
|
1126
|
+
function assistantView() {
|
|
1127
|
+
const v = document.documentElement.dataset.assistantView;
|
|
1128
|
+
return ASSISTANT_VIEWS.includes(v) ? v : 'sidebar';
|
|
1129
|
+
}
|
|
1130
|
+
|
|
1131
|
+
function setAssistantView(view) {
|
|
1132
|
+
if (!ASSISTANT_VIEWS.includes(view)) return;
|
|
1133
|
+
// Full page is a place, not a layout: switching to it goes there, and the panel on the page you
|
|
1134
|
+
// came from would be a second copy of the same conversation.
|
|
1135
|
+
if (view === 'full') {
|
|
1136
|
+
wsPrefSet('ws-assistant-view', view);
|
|
1137
|
+
window.location.href = '/assistant';
|
|
1138
|
+
return;
|
|
1139
|
+
}
|
|
1140
|
+
document.documentElement.dataset.assistantView = view;
|
|
1141
|
+
setAssistantOpen(true);
|
|
1142
|
+
document.dispatchEvent(new CustomEvent('assistant-view-changed'));
|
|
1143
|
+
wsPrefSet('ws-assistant-view', view);
|
|
1144
|
+
// Leaving the full-page view has to leave the page too: /assistant has no panel to become a
|
|
1145
|
+
// sidebar. Back where you came from, or home when that was somewhere else.
|
|
1146
|
+
if (window.location.pathname === '/assistant') {
|
|
1147
|
+
const from = document.referrer && new URL(document.referrer, window.location.origin);
|
|
1148
|
+
window.location.href = from && from.origin === window.location.origin && from.pathname !== '/assistant'
|
|
1149
|
+
? from.href : '/';
|
|
1150
|
+
}
|
|
1151
|
+
}
|
|
1152
|
+
|
|
1153
|
+
function setAssistantOpen(open) {
|
|
1154
|
+
document.documentElement.classList.toggle('assistant-closed', !open);
|
|
1155
|
+
const row = document.querySelector('.actions-assistant');
|
|
1156
|
+
if (row) row.setAttribute('aria-expanded', open ? 'true' : 'false');
|
|
1157
|
+
wsPrefSet('ws-assistant-open', open ? '1' : '0');
|
|
1158
|
+
}
|
|
1159
|
+
|
|
1160
|
+
document.addEventListener('click', (e) => {
|
|
1161
|
+
// Show it. In the full-page view there is no panel to show — the assistant IS a page there — so this
|
|
1162
|
+
// is the way to it; on that page it is already in front of you and there is nothing to do.
|
|
1163
|
+
if (e.target.closest('.assistant-open-pick')) {
|
|
1164
|
+
e.preventDefault();
|
|
1165
|
+
if (window.location.pathname === '/assistant') return;
|
|
1166
|
+
if (assistantView() === 'full') { window.location.href = '/assistant'; return; }
|
|
1167
|
+
setAssistantOpen(true);
|
|
1168
|
+
return;
|
|
1169
|
+
}
|
|
1170
|
+
// Wider, and back again — one button, because it is one question: how much room does this answer
|
|
1171
|
+
// need? Floating has its own size, so there it is the sidebar it widens back into.
|
|
1172
|
+
if (e.target.closest('.assistant-wider')) {
|
|
1173
|
+
e.preventDefault();
|
|
1174
|
+
setAssistantView(assistantView() === 'wide' ? 'sidebar' : 'wide');
|
|
1175
|
+
return;
|
|
1176
|
+
}
|
|
1177
|
+
const pick = e.target.closest('.assistant-view-pick');
|
|
1178
|
+
if (pick) { e.preventDefault(); setAssistantView(pick.dataset.view); return; }
|
|
1179
|
+
if (e.target.closest('.assistant-hide, .assistant-close-pick')) { e.preventDefault(); setAssistantOpen(false); return; }
|
|
1180
|
+
// The launcher in the corner: the way back in, in every view. In the full-page view there is no
|
|
1181
|
+
// panel to show, so it leads to the page instead.
|
|
1182
|
+
if (e.target.closest('.assistant-launcher')) {
|
|
1183
|
+
e.preventDefault();
|
|
1184
|
+
if (assistantView() === 'full') { window.location.href = '/assistant'; return; }
|
|
1185
|
+
setAssistantOpen(true);
|
|
1186
|
+
}
|
|
1187
|
+
}, true);
|
|
1188
|
+
|
|
1189
|
+
document.addEventListener('click', (e) => {
|
|
1190
|
+
// A pick closes the menu it was made in — a dropdown left open over the panel it just changed reads
|
|
1191
|
+
// as a control that did not take.
|
|
1192
|
+
if (e.target.closest('.assistant-dropdown a') && window.UIkit) {
|
|
1193
|
+
const dd = e.target.closest('.uk-dropdown');
|
|
1194
|
+
if (dd) window.UIkit.dropdown(dd).hide(false);
|
|
1195
|
+
}
|
|
1196
|
+
});
|
|
1197
|
+
|
|
1198
|
+
// Floating means floating: the panel can be dragged anywhere on the screen, because where it should sit
|
|
1199
|
+
// depends on what is under it — a project table, a form, a terminal — and that changes page by page.
|
|
1200
|
+
//
|
|
1201
|
+
// The header is the handle (a window's title bar is), minus its buttons: a drag that started on the
|
|
1202
|
+
// close button would close the panel on release. The position is per browser, clamped into the viewport
|
|
1203
|
+
// on every apply, so a panel dragged to the edge of a wide screen is still reachable on a narrow one.
|
|
1204
|
+
(function draggableAssistant() {
|
|
1205
|
+
const KEY = 'ws-assistant-pos';
|
|
1206
|
+
const panel = () => document.querySelector('.assistant-side');
|
|
1207
|
+
|
|
1208
|
+
const clamp = (x, y, el) => {
|
|
1209
|
+
const r = el.getBoundingClientRect();
|
|
1210
|
+
// A strip of the panel always has to stay on screen, and the header has to stay reachable.
|
|
1211
|
+
const maxX = Math.max(0, window.innerWidth - r.width);
|
|
1212
|
+
const maxY = Math.max(0, window.innerHeight - Math.min(r.height, 64));
|
|
1213
|
+
return [Math.min(Math.max(0, x), maxX), Math.min(Math.max(0, y), maxY)];
|
|
1214
|
+
};
|
|
1215
|
+
|
|
1216
|
+
const place = (x, y) => {
|
|
1217
|
+
const el = panel();
|
|
1218
|
+
if (!el) return;
|
|
1219
|
+
const [cx, cy] = clamp(x, y, el);
|
|
1220
|
+
el.style.left = `${cx}px`;
|
|
1221
|
+
el.style.top = `${cy}px`;
|
|
1222
|
+
el.style.bottom = 'auto';
|
|
1223
|
+
el.style.right = 'auto';
|
|
1224
|
+
};
|
|
1225
|
+
|
|
1226
|
+
const stored = () => {
|
|
1227
|
+
try { return JSON.parse(localStorage.getItem(KEY) || 'null'); } catch (_) { return null; }
|
|
1228
|
+
};
|
|
1229
|
+
|
|
1230
|
+
function applyStored() {
|
|
1231
|
+
const el = panel();
|
|
1232
|
+
if (!el) return;
|
|
1233
|
+
if (document.documentElement.dataset.assistantView !== 'float') {
|
|
1234
|
+
// Every other view is laid out by the stylesheet; leftover inline positions would fight it.
|
|
1235
|
+
el.style.left = el.style.top = el.style.bottom = el.style.right = '';
|
|
1236
|
+
return;
|
|
1237
|
+
}
|
|
1238
|
+
const p = stored();
|
|
1239
|
+
if (p && Number.isFinite(p.x) && Number.isFinite(p.y)) place(p.x, p.y);
|
|
1240
|
+
}
|
|
1241
|
+
|
|
1242
|
+
document.addEventListener('pointerdown', (e) => {
|
|
1243
|
+
if (document.documentElement.dataset.assistantView !== 'float') return;
|
|
1244
|
+
const head = e.target.closest('.assistant-side .assistant-head');
|
|
1245
|
+
if (!head || e.target.closest('button, a, input, select, .assistant-dropdown')) return;
|
|
1246
|
+
const el = panel();
|
|
1247
|
+
const r = el.getBoundingClientRect();
|
|
1248
|
+
const dx = e.clientX - r.left;
|
|
1249
|
+
const dy = e.clientY - r.top;
|
|
1250
|
+
let moved = false;
|
|
1251
|
+
head.setPointerCapture(e.pointerId);
|
|
1252
|
+
el.classList.add('is-dragging');
|
|
1253
|
+
|
|
1254
|
+
const move = (ev) => { moved = true; place(ev.clientX - dx, ev.clientY - dy); };
|
|
1255
|
+
const up = () => {
|
|
1256
|
+
head.removeEventListener('pointermove', move);
|
|
1257
|
+
head.removeEventListener('pointerup', up);
|
|
1258
|
+
el.classList.remove('is-dragging');
|
|
1259
|
+
if (!moved) return;
|
|
1260
|
+
const r2 = el.getBoundingClientRect();
|
|
1261
|
+
try { localStorage.setItem(KEY, JSON.stringify({ x: Math.round(r2.left), y: Math.round(r2.top) })); } catch (_) { /* ignore */ }
|
|
1262
|
+
};
|
|
1263
|
+
head.addEventListener('pointermove', move);
|
|
1264
|
+
head.addEventListener('pointerup', up);
|
|
1265
|
+
});
|
|
1266
|
+
|
|
1267
|
+
// A window that got smaller must not leave the panel off-screen, and a view change re-lays it out.
|
|
1268
|
+
window.addEventListener('resize', applyStored);
|
|
1269
|
+
document.addEventListener('assistant-view-changed', applyStored);
|
|
1270
|
+
applyStored();
|
|
1271
|
+
})();
|
|
1272
|
+
|
|
1273
|
+
/* ---------------- the AI assistant, as a deep-chat component ------------ */
|
|
1274
|
+
|
|
1275
|
+
// deep-chat is configured with properties rather than attributes: anything that is not a string
|
|
1276
|
+
// (the connect object, the style maps, the interceptor) cannot survive an attribute.
|
|
1277
|
+
(function initDeepChat() {
|
|
1278
|
+
const el = document.getElementById('ws-deep-chat');
|
|
1279
|
+
if (!el || el.dataset.wsReady) return;
|
|
1280
|
+
el.dataset.wsReady = '1';
|
|
1281
|
+
|
|
1282
|
+
const context = el.dataset.context || 'home';
|
|
1283
|
+
// What the settings file says about this panel. A missing or unreadable attribute falls back to
|
|
1284
|
+
// the same defaults the resolver uses, so the panel still works if the settings never arrive.
|
|
1285
|
+
let cfg = { voiceInput: true, submitAfterSilence: 2500, voiceOutput: true, speechRate: 1, placeholder: '' };
|
|
1286
|
+
try { cfg = { ...cfg, ...JSON.parse(el.dataset.settings || '{}') }; } catch (_) { /* keep the defaults */ }
|
|
1287
|
+
|
|
1288
|
+
el.connect = {
|
|
1289
|
+
url: '/actions/deep-chat',
|
|
1290
|
+
method: 'POST',
|
|
1291
|
+
additionalBodyProps: { context },
|
|
1292
|
+
};
|
|
1293
|
+
|
|
1294
|
+
// The conversation carries across pages. Every page draws a new deep-chat, so the transcript has
|
|
1295
|
+
// to be handed back to it: the server keeps the thread (the id is a cookie on the hub domain, the
|
|
1296
|
+
// only thing that crosses <workspace>.<hub> subdomains) and returns it in deep-chat's own history
|
|
1297
|
+
// shape. Fetched rather than rendered into the page because the panel is built in pageShell, which
|
|
1298
|
+
// does not see the request — and it is one small same-origin GET either way.
|
|
1299
|
+
//
|
|
1300
|
+
// The agent's own memory is the other half, and it is the server's: it resumes the claude session
|
|
1301
|
+
// this thread started. A transcript in front of an agent that remembers none of it reads as memory
|
|
1302
|
+
// and is not.
|
|
1303
|
+
// Suggested prompts: shown only when the saved conversation is empty.
|
|
1304
|
+
const suggestions = Array.isArray(cfg.suggestions)
|
|
1305
|
+
? cfg.suggestions.filter((s) => typeof s === 'string' && s.trim()) : [];
|
|
1306
|
+
const escapeHtml = (s) => String(s).replace(/[&<>"']/g, (c) => (
|
|
1307
|
+
{ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
|
1308
|
+
|
|
1309
|
+
// A click fills the composer and never sends (the assistant has a shell).
|
|
1310
|
+
const fillComposer = (text) => {
|
|
1311
|
+
const input = el.shadowRoot && el.shadowRoot.querySelector('#text-input');
|
|
1312
|
+
if (!input) return;
|
|
1313
|
+
const existing = (input.innerText || '').trim();
|
|
1314
|
+
input.innerText = existing ? `${existing} ${text}` : text;
|
|
1315
|
+
input.dispatchEvent(new Event('input', { bubbles: true }));
|
|
1316
|
+
input.focus();
|
|
1317
|
+
const range = document.createRange();
|
|
1318
|
+
range.selectNodeContents(input);
|
|
1319
|
+
range.collapse(false);
|
|
1320
|
+
const sel = window.getSelection();
|
|
1321
|
+
sel.removeAllRanges();
|
|
1322
|
+
sel.addRange(range);
|
|
1323
|
+
};
|
|
1324
|
+
|
|
1325
|
+
// deep-chat draws inside a shadow root, so the look and the click are attached by class here.
|
|
1326
|
+
el.htmlClassUtilities = {
|
|
1327
|
+
...(el.htmlClassUtilities || {}),
|
|
1328
|
+
'ws-suggestion': {
|
|
1329
|
+
events: { click: (event) => fillComposer((event.currentTarget || event.target).textContent.trim()) },
|
|
1330
|
+
styles: {
|
|
1331
|
+
default: {
|
|
1332
|
+
display: 'block', width: '100%', textAlign: 'left', cursor: 'pointer',
|
|
1333
|
+
margin: '0 0 0.35rem', padding: '0.45rem 0.7rem', borderRadius: '0.6rem',
|
|
1334
|
+
border: '1px solid var(--line)', background: 'var(--surface-input)', color: 'var(--text-default)',
|
|
1335
|
+
fontFamily: 'var(--font-primary)', fontSize: '0.88rem', lineHeight: '1.4',
|
|
1336
|
+
},
|
|
1337
|
+
hover: { borderColor: 'var(--brand-primary)', color: 'var(--brand-primary)' },
|
|
1338
|
+
},
|
|
1339
|
+
},
|
|
1340
|
+
};
|
|
1341
|
+
|
|
1342
|
+
// deep-chat-temporary-message: removed when the next message arrives, never saved to the thread.
|
|
1343
|
+
let suggestTries = 0;
|
|
1344
|
+
let conversationEmpty = true;
|
|
1345
|
+
const suggestOn = () => (window.wsPref ? window.wsPref('ws-suggest', null) : null) !== '0';
|
|
1346
|
+
const showSuggestions = () => {
|
|
1347
|
+
if (!suggestions.length || el.dataset.wsSuggested || !suggestOn()) return;
|
|
1348
|
+
el.dataset.wsSuggested = '1';
|
|
1349
|
+
const html = `<div class="deep-chat-temporary-message ws-suggestions">${
|
|
1350
|
+
suggestions.map((s) => `<button type="button" class="ws-suggestion">${escapeHtml(s)}</button>`).join('')}</div>`;
|
|
1351
|
+
try { el.addMessage({ role: 'ai', html }); }
|
|
1352
|
+
catch (_) {
|
|
1353
|
+
// The component may not be ready yet.
|
|
1354
|
+
delete el.dataset.wsSuggested;
|
|
1355
|
+
if ((suggestTries += 1) < 8) setTimeout(showSuggestions, 400);
|
|
1356
|
+
}
|
|
1357
|
+
};
|
|
1358
|
+
|
|
1359
|
+
const hideSuggestions = () => {
|
|
1360
|
+
const box = el.shadowRoot && el.shadowRoot.querySelector('.ws-suggestions');
|
|
1361
|
+
const outer = box && box.closest('[class*="outer-message-container"]');
|
|
1362
|
+
if (outer) outer.remove();
|
|
1363
|
+
delete el.dataset.wsSuggested;
|
|
1364
|
+
};
|
|
1365
|
+
|
|
1366
|
+
fetch('/fragments/chat/history', { headers: { Accept: 'application/json' } })
|
|
1367
|
+
.then((r) => (r.ok ? r.json() : null))
|
|
1368
|
+
.then((data) => {
|
|
1369
|
+
if (data && Array.isArray(data.messages) && data.messages.length) { conversationEmpty = false; el.history = data.messages; }
|
|
1370
|
+
else showSuggestions();
|
|
1371
|
+
})
|
|
1372
|
+
.catch(() => { showSuggestions(); });
|
|
1373
|
+
|
|
1374
|
+
// Voice summary: the checkbox in the assistant options. The choice is kept per browser; settings.yml
|
|
1375
|
+
// only sets where it starts.
|
|
1376
|
+
// The narration's voice settings, kept by reference: the avatar's settings change its volume live.
|
|
1377
|
+
const narrationParams = { ...(cfg.narration || {}) };
|
|
1378
|
+
// settings.yml never makes an ElevenLabs voice the default: that spends credits, so it is picked in the avatar's settings.
|
|
1379
|
+
if (/^elevenlabs:/i.test(String(narrationParams.voiceName || ''))) delete narrationParams.voiceName;
|
|
1380
|
+
// What someone chose in the avatar's settings: volume, speed, pitch and the voice. An empty cookie means
|
|
1381
|
+
// not chosen, never 0, which would silence the voice.
|
|
1382
|
+
const VOICE_PREFS = { volume: ['ws-voice-volume', 0, 1], rate: ['ws-voice-rate', 0.5, 2], pitch: ['ws-voice-pitch', 0, 2] };
|
|
1383
|
+
for (const [key, [name, min, max]] of Object.entries(VOICE_PREFS)) {
|
|
1384
|
+
try { const raw = window.wsPref ? window.wsPref(name, '') : ''; const v = Number(raw); if (raw !== '' && raw !== null && Number.isFinite(v) && v >= min && v <= max) narrationParams[key] = v; } catch (_) { /* default */ }
|
|
1385
|
+
}
|
|
1386
|
+
try { const vn = window.wsPref ? window.wsPref('ws-voice-name', '') : ''; if (vn) narrationParams.voiceName = vn; } catch (_) { /* best free voice */ }
|
|
1387
|
+
const narrator = window.wsNarration.createNarrator({
|
|
1388
|
+
params: narrationParams,
|
|
1389
|
+
defaultOn: cfg.voiceOutput !== false,
|
|
1390
|
+
getPref: (name) => (window.wsPref ? window.wsPref(name, null) : null),
|
|
1391
|
+
setPref: wsPrefSet,
|
|
1392
|
+
synth: window.speechSynthesis,
|
|
1393
|
+
Utterance: window.SpeechSynthesisUtterance,
|
|
1394
|
+
onSpeech: (detail) => document.dispatchEvent(new CustomEvent('ws-assistant-speech', { detail })),
|
|
1395
|
+
// An ElevenLabs voice: the server speaks (its key never comes here) and this plays the audio, at the
|
|
1396
|
+
// chosen volume and speed. The words are spread over the clip so the avatar's mouth keeps moving.
|
|
1397
|
+
// When the server cannot, the same words are spoken in the browser's voice instead.
|
|
1398
|
+
remote: (text, voiceId, params, onSpeech, fallback) => {
|
|
1399
|
+
let stopped = false; let audio = null; let url = '';
|
|
1400
|
+
const ctl = new AbortController();
|
|
1401
|
+
const words = text.split(/\s+/);
|
|
1402
|
+
const done = () => { if (url) URL.revokeObjectURL(url); url = ''; if (onSpeech) onSpeech({ type: 'end' }); };
|
|
1403
|
+
fetch('/actions/assistant/speak', { method: 'POST', signal: ctl.signal, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ voice: voiceId, text }) })
|
|
1404
|
+
.then((r) => (r.ok ? r.blob() : Promise.reject(new Error(String(r.status)))))
|
|
1405
|
+
.then((blob) => {
|
|
1406
|
+
if (stopped) return;
|
|
1407
|
+
url = URL.createObjectURL(blob);
|
|
1408
|
+
audio = new Audio(url);
|
|
1409
|
+
audio.volume = Number.isFinite(params.volume) ? params.volume : 1;
|
|
1410
|
+
audio.playbackRate = Number.isFinite(params.rate) ? params.rate : 1;
|
|
1411
|
+
let said = -1;
|
|
1412
|
+
audio.onplay = () => { if (onSpeech) onSpeech({ type: 'start' }); };
|
|
1413
|
+
audio.ontimeupdate = () => {
|
|
1414
|
+
const i = Math.floor((audio.currentTime / (audio.duration || 1)) * words.length);
|
|
1415
|
+
if (i !== said && i < words.length) { said = i; if (onSpeech) onSpeech({ type: 'word', word: words[i] }); }
|
|
1416
|
+
};
|
|
1417
|
+
audio.onended = done;
|
|
1418
|
+
audio.onerror = done;
|
|
1419
|
+
return audio.play();
|
|
1420
|
+
})
|
|
1421
|
+
.catch(() => { if (!stopped) fallback(); });
|
|
1422
|
+
return () => { stopped = true; ctl.abort(); if (audio) { audio.pause(); audio.src = ''; } if (url) { URL.revokeObjectURL(url); url = ''; } };
|
|
1423
|
+
},
|
|
1424
|
+
});
|
|
1425
|
+
window.addEventListener('pagehide', narrator.cancel);
|
|
1426
|
+
// The assistant's avatar (assistant-avatar-ui.js) mutes and unmutes this voice, and hides it.
|
|
1427
|
+
window.wsAssistantVoice = {
|
|
1428
|
+
isOn: narrator.isOn, setOn: narrator.setOn, cancel: narrator.cancel, speak: narrator.speak,
|
|
1429
|
+
volume: () => (Number.isFinite(narrationParams.volume) ? narrationParams.volume : 1),
|
|
1430
|
+
setVolume: (v) => { narrationParams.volume = Math.max(0, Math.min(1, Number(v))); try { wsPrefSet('ws-voice-volume', String(narrationParams.volume)); } catch (_) { /* not kept */ } },
|
|
1431
|
+
get: (key) => narrationParams[key],
|
|
1432
|
+
set: (key, value) => {
|
|
1433
|
+
if (key === 'voiceName') { narrationParams.voiceName = String(value || ''); try { wsPrefSet('ws-voice-name', narrationParams.voiceName); } catch (_) { /* not kept */ } return; }
|
|
1434
|
+
if (!VOICE_PREFS[key]) return;
|
|
1435
|
+
const [name, min, max] = VOICE_PREFS[key];
|
|
1436
|
+
narrationParams[key] = Math.max(min, Math.min(max, Number(value)));
|
|
1437
|
+
try { wsPrefSet(name, String(narrationParams[key])); } catch (_) { /* not kept */ }
|
|
1438
|
+
},
|
|
1439
|
+
};
|
|
1440
|
+
el.requestInterceptor = (details) => {
|
|
1441
|
+
details.body = { ...details.body, voice: narrator.isOn(), avatar: Boolean(window.wsAssistantAvatarOn && window.wsAssistantAvatarOn()), avatarName: window.wsAvatarName ? window.wsAvatarName() : '', avatarId: window.wsAvatarId ? window.wsAvatarId() : '', page: window.wsAvatarContext ? window.wsAvatarContext() : null };
|
|
1442
|
+
return details;
|
|
1443
|
+
};
|
|
1444
|
+
const playSendAnimation = () => {
|
|
1445
|
+
const btn = el.shadowRoot && el.shadowRoot.querySelector('.input-button.inside-end');
|
|
1446
|
+
if (!btn) return;
|
|
1447
|
+
btn.classList.remove('ws-sent');
|
|
1448
|
+
void btn.offsetWidth;
|
|
1449
|
+
btn.classList.add('ws-sent');
|
|
1450
|
+
setTimeout(() => btn.classList.remove('ws-sent'), 700);
|
|
1451
|
+
};
|
|
1452
|
+
// Working: from a message being sent until the answer, or an error, comes back.
|
|
1453
|
+
let workingTimer = null;
|
|
1454
|
+
const setWorking = (on, failed = false) => {
|
|
1455
|
+
clearTimeout(workingTimer);
|
|
1456
|
+
el.toggleAttribute('data-assistant-working', on);
|
|
1457
|
+
document.dispatchEvent(new CustomEvent('ws-assistant-working', { detail: { on, failed } }));
|
|
1458
|
+
document.querySelectorAll('.assistant-panel').forEach((p) => p.toggleAttribute('data-working', on));
|
|
1459
|
+
if (on) workingTimer = setTimeout(() => setWorking(false), 16 * 60 * 1000);
|
|
1460
|
+
};
|
|
1461
|
+
el.onMessage = ({ message, isHistory }) => {
|
|
1462
|
+
if (isHistory || !message) return;
|
|
1463
|
+
if (message.role === 'user') { conversationEmpty = false; narrator.cancel(); playSendAnimation(); setWorking(true); }
|
|
1464
|
+
else setWorking(false);
|
|
1465
|
+
};
|
|
1466
|
+
el.onError = () => setWorking(false, true);
|
|
1467
|
+
|
|
1468
|
+
// The reply arrives as rendered markdown plus the directives the assistant ended with. Acting on
|
|
1469
|
+
// them here keeps the interface steering that the HTMX pane had: the agent can start a project
|
|
1470
|
+
// and then take you to it.
|
|
1471
|
+
el.responseInterceptor = (response) => {
|
|
1472
|
+
const d = response && response.directive;
|
|
1473
|
+
if (d) {
|
|
1474
|
+
if (d.refresh) document.body.dispatchEvent(new CustomEvent('refresh-projects'));
|
|
1475
|
+
if (d.open) window.open(d.open, '_blank', 'noopener');
|
|
1476
|
+
if (d.navigate) setTimeout(() => { window.location.href = d.navigate; }, 600);
|
|
1477
|
+
}
|
|
1478
|
+
setWorking(false);
|
|
1479
|
+
if (response && response.html) narrator.narrate({ spoken: d && d.spoken, html: response.html });
|
|
1480
|
+
document.dispatchEvent(new CustomEvent('ws-assistant-reply', { detail: { directive: d || null, voice: narrator.isOn() } }));
|
|
1481
|
+
return response;
|
|
1482
|
+
};
|
|
1483
|
+
|
|
1484
|
+
// Voice in and out, exactly as speech.js resolved them.
|
|
1485
|
+
//
|
|
1486
|
+
// The page does not build these objects any more. Which keys may be absent is the substance of
|
|
1487
|
+
// that config — an empty `commands` phrase arms a command rather than disabling one, and
|
|
1488
|
+
// `substrings` defaults to true so "submit the form" would fire mid-sentence — and those rules
|
|
1489
|
+
// belong somewhere a test can require, not in a browser file that can only be checked by reading
|
|
1490
|
+
// it. `false` here means the microphone is removed rather than left as a button that does nothing.
|
|
1491
|
+
if (cfg.speechToText !== undefined) el.speechToText = cfg.speechToText;
|
|
1492
|
+
el.textToSpeech = false; // narration is the voice summary above; deep-chat's own never speaks an HTML reply
|
|
1493
|
+
|
|
1494
|
+
// The prompt is the main control of this panel, so it is sized like one rather than like a
|
|
1495
|
+
// single-line search box: room for a few lines of a real instruction before it scrolls.
|
|
1496
|
+
el.textInput = {
|
|
1497
|
+
// Whatever `assistant: placeholder:` says, and by default nothing visible: an empty field with
|
|
1498
|
+
// a cursor in it already says "type here", and a prompt that suggests what to ask narrows what
|
|
1499
|
+
// gets asked.
|
|
1500
|
+
//
|
|
1501
|
+
// "Nothing" cannot be spelled as an empty string. deep-chat reads this as
|
|
1502
|
+
// `placeholder.text || "Ask me anything!"`, so a blank does not remove its placeholder, it
|
|
1503
|
+
// restores deep-chat's — which is how "Ask me anything!" was showing on a panel configured to
|
|
1504
|
+
// say nothing. The text it IS given becomes the composer's aria-label as well, so with nothing
|
|
1505
|
+
// to display this carries the accessible name a textbox needs and `hidePlaceholder` below
|
|
1506
|
+
// blanks what is drawn. Configure one and it is both the visible text and the name.
|
|
1507
|
+
placeholder: { text: cfg.placeholder || `Message the ${cfg.name || 'AI Assistant'}` },
|
|
1508
|
+
styles: {
|
|
1509
|
+
container: {
|
|
1510
|
+
minHeight: '4.5rem',
|
|
1511
|
+
maxHeight: '11rem',
|
|
1512
|
+
borderRadius: '0.85rem',
|
|
1513
|
+
padding: '0.65rem 0.9rem',
|
|
1514
|
+
boxShadow: '0 1px 2px rgba(0,0,0,.06)',
|
|
1515
|
+
// Tokens, not values. deep-chat writes these as inline styles inside its shadow root,
|
|
1516
|
+
// and a custom property inherits THROUGH a shadow boundary from the host — so var()
|
|
1517
|
+
// here resolves against :root and follows the theme, both halves, with no second copy
|
|
1518
|
+
// of this object for dark and no re-render when the toggle is pressed.
|
|
1519
|
+
backgroundColor: 'var(--surface-input)',
|
|
1520
|
+
border: '1px solid var(--line)',
|
|
1521
|
+
color: 'var(--text-default)',
|
|
1522
|
+
},
|
|
1523
|
+
text: { fontSize: '1rem', lineHeight: '1.45', fontFamily: 'var(--font-primary)' },
|
|
1524
|
+
},
|
|
1525
|
+
};
|
|
1526
|
+
|
|
1527
|
+
// deep-chat's own send and microphone are role="button" with an SVG inside and no accessible name,
|
|
1528
|
+
// so a screen reader announces two unnamed buttons at the end of the composer — axe's
|
|
1529
|
+
// aria-command-name, on every page the panel is on. They live in the component's shadow root, which
|
|
1530
|
+
// is reachable but not ours to edit, so the names are set from here rather than by forking the
|
|
1531
|
+
// vendored bundle.
|
|
1532
|
+
const nameChatButtons = () => {
|
|
1533
|
+
const root = el.shadowRoot;
|
|
1534
|
+
if (!root) return;
|
|
1535
|
+
const names = [
|
|
1536
|
+
['#microphone-button', 'Dictate the message'],
|
|
1537
|
+
['.input-button.inside-end', 'Send the message'],
|
|
1538
|
+
['#submit-icon', 'Send the message'],
|
|
1539
|
+
];
|
|
1540
|
+
for (const [sel, label] of names) {
|
|
1541
|
+
for (const node of root.querySelectorAll(sel)) {
|
|
1542
|
+
if (!node.getAttribute('aria-label')) node.setAttribute('aria-label', label);
|
|
1543
|
+
}
|
|
1544
|
+
}
|
|
1545
|
+
};
|
|
1546
|
+
// The two things deep-chat insists on drawing, turned off from inside its shadow root.
|
|
1547
|
+
//
|
|
1548
|
+
// Both are there because the component treats "nothing" as "not configured" and substitutes its
|
|
1549
|
+
// own: an empty `placeholder.text` restores "Ask me anything!", and an unset `introMessage`
|
|
1550
|
+
// restores the library placeholder about connect and the demo property. So both are SET, to the
|
|
1551
|
+
// emptiest thing that still suppresses the default, and what is left to draw is hidden here.
|
|
1552
|
+
//
|
|
1553
|
+
// - The placeholder is `content: attr(deep-chat-placeholder-text)` on the empty input, coloured
|
|
1554
|
+
// by a rule of deep-chat's own, so neither an empty string nor a colour on the input turns it
|
|
1555
|
+
// off. Blanking that one pseudo-element does, and it leaves the attribute — and the aria-label
|
|
1556
|
+
// built from it — in place: silent on screen, named to a screen reader. A configured
|
|
1557
|
+
// placeholder skips this and is drawn normally.
|
|
1558
|
+
// - The intro message is an empty <span>, and an empty span in a message bubble is still a
|
|
1559
|
+
// bubble: a small blank pill at the top of every transcript, which reads as a message that
|
|
1560
|
+
// failed to load.
|
|
1561
|
+
const chatChrome = () => {
|
|
1562
|
+
const root = el.shadowRoot;
|
|
1563
|
+
if (!root || root.getElementById('ws-chat-chrome')) return;
|
|
1564
|
+
const st = document.createElement('style');
|
|
1565
|
+
st.id = 'ws-chat-chrome';
|
|
1566
|
+
st.textContent = '.deep-chat-intro { display: none !important; }'
|
|
1567
|
+
+ (cfg.placeholder ? '' : '\n#text-input:empty::before { content: "" !important; }')
|
|
1568
|
+
+ '\n:host([data-assistant-mic="off"]) #microphone-button { display: none !important; }';
|
|
1569
|
+
root.appendChild(st);
|
|
1570
|
+
};
|
|
1571
|
+
// Watched, not timed: the component builds and rebuilds those buttons whenever the composer changes,
|
|
1572
|
+
// so a handful of setTimeouts named them on one page load and missed them on the next.
|
|
1573
|
+
nameChatButtons();
|
|
1574
|
+
chatChrome();
|
|
1575
|
+
if (el.shadowRoot && window.MutationObserver) {
|
|
1576
|
+
new MutationObserver(() => { nameChatButtons(); chatChrome(); })
|
|
1577
|
+
.observe(el.shadowRoot, { childList: true, subtree: true });
|
|
1578
|
+
} else {
|
|
1579
|
+
setTimeout(() => { nameChatButtons(); chatChrome(); }, 500);
|
|
1580
|
+
}
|
|
1581
|
+
|
|
1582
|
+
// No opening message, and none of deep-chat's own either. Left unset, the component renders its
|
|
1583
|
+
// library placeholder — the one about connect, directConnection and the demo property — which is
|
|
1584
|
+
// documentation for whoever is building a chat, not for whoever is using this one. An empty
|
|
1585
|
+
// introMessage is what keeps that out without putting anything of ours in its place; the bubble
|
|
1586
|
+
// it still draws around that emptiness is hidden by `chatChrome` above.
|
|
1587
|
+
el.introMessage = { html: '<span></span>' };
|
|
1588
|
+
|
|
1589
|
+
|
|
1590
|
+
// A build is minutes of work, so the component must not give up on the request.
|
|
1591
|
+
//
|
|
1592
|
+
// The look, in the Webship design system rather than deep-chat's stock preset: the user's turn
|
|
1593
|
+
// carries the same brand gradient as the rest of the interface, and the assistant's sits on a
|
|
1594
|
+
// plain card surface so long technical replies stay readable. Every colour here is a token —
|
|
1595
|
+
// the component shipped a fixed purple gradient and a fixed near-white bubble, which meant the
|
|
1596
|
+
// panel was a different product from the page around it and stayed light after dark.
|
|
1597
|
+
el.messageStyles = {
|
|
1598
|
+
default: {
|
|
1599
|
+
shared: {
|
|
1600
|
+
bubble: {
|
|
1601
|
+
maxWidth: '92%',
|
|
1602
|
+
borderRadius: '0.85rem',
|
|
1603
|
+
padding: '0.6rem 0.85rem',
|
|
1604
|
+
marginTop: '0.35rem',
|
|
1605
|
+
marginBottom: '0.35rem',
|
|
1606
|
+
fontSize: '0.95rem',
|
|
1607
|
+
lineHeight: '1.5',
|
|
1608
|
+
fontFamily: 'var(--font-primary)',
|
|
1609
|
+
},
|
|
1610
|
+
},
|
|
1611
|
+
user: {
|
|
1612
|
+
bubble: {
|
|
1613
|
+
background: 'var(--brand-grad)',
|
|
1614
|
+
color: 'var(--text-inverse)',
|
|
1615
|
+
},
|
|
1616
|
+
},
|
|
1617
|
+
ai: {
|
|
1618
|
+
bubble: { backgroundColor: 'var(--surface-muted)', color: 'var(--text-default)' },
|
|
1619
|
+
},
|
|
1620
|
+
},
|
|
1621
|
+
// Since 2.1.0 the loading bubble is styled through its own shape, not a plain bubble object.
|
|
1622
|
+
loading: { message: { styles: { bubble: { backgroundColor: 'var(--surface-muted)' } } } },
|
|
1623
|
+
error: { bubble: { backgroundColor: 'var(--danger-soft)', color: 'var(--danger-ink)', fontSize: '0.9rem' } },
|
|
1624
|
+
};
|
|
1625
|
+
|
|
1626
|
+
// Injected into the component's shadow root, which is the only way to reach what deep-chat
|
|
1627
|
+
// renders inside itself: the typeface, and the typography of the markdown we return.
|
|
1628
|
+
//
|
|
1629
|
+
// deep-chat's own default is Inter. The dashboard is Figtree, per the brand tokens, so the
|
|
1630
|
+
// one panel a person actually reads sentences in was the one panel not in the brand face. The
|
|
1631
|
+
// wildcard is deliberate: the component builds its own buttons, toolbar and placeholder, and
|
|
1632
|
+
// none of them inherit from the page across the shadow boundary.
|
|
1633
|
+
el.auxiliaryStyle = `
|
|
1634
|
+
* { font-family: var(--font-primary); }
|
|
1635
|
+
/* deep-chat's own chrome, which its style properties do not reach: the panel it draws behind
|
|
1636
|
+
the transcript, the input shell, the two round buttons and the tooltip. Left alone they are
|
|
1637
|
+
a white panel and a lilac microphone — the component's brand, not ours, and both stayed
|
|
1638
|
+
light after dark. */
|
|
1639
|
+
#chat-view, #input { background-color: transparent !important; }
|
|
1640
|
+
#text-input-container { background-color: var(--surface-input) !important; border: 1px solid var(--line) !important; }
|
|
1641
|
+
#text-input { color: var(--text-default) !important; }
|
|
1642
|
+
#text-input[textinputplaceholderstyle] { color: var(--text-subtle) !important; }
|
|
1643
|
+
.input-button-svg { background-color: var(--surface-muted) !important; }
|
|
1644
|
+
#microphone-button { background: var(--brand-grad) !important; }
|
|
1645
|
+
#microphone-button.active { background: var(--danger) !important; }
|
|
1646
|
+
.tooltip { background-color: var(--code-bg) !important; color: var(--code-ink) !important; }
|
|
1647
|
+
.dc-reply p { margin: 0 0 .5rem; }
|
|
1648
|
+
.dc-reply p:last-child { margin-bottom: 0; }
|
|
1649
|
+
.dc-reply pre { background: var(--code-bg); color: var(--code-ink); padding: .5rem .65rem; border-radius: .5rem; overflow-x: auto; }
|
|
1650
|
+
.dc-reply code, .dc-reply pre, .dc-reply pre * { font-family: var(--font-code); font-size: .88em; }
|
|
1651
|
+
.dc-reply a { color: var(--link); }
|
|
1652
|
+
.dc-reply table { border-collapse: collapse; width: 100%; font-size: .9em; }
|
|
1653
|
+
.dc-reply th, .dc-reply td { border: 1px solid var(--line); padding: .3rem .45rem; text-align: left; }
|
|
1654
|
+
.input-button.inside-end { transition: transform .08s; }
|
|
1655
|
+
.input-button.inside-end:active { transform: scale(.92); }
|
|
1656
|
+
.input-button.inside-end.ws-sent svg { animation: ws-send-fly .6s cubic-bezier(.4, 0, .2, 1); }
|
|
1657
|
+
@keyframes ws-send-fly {
|
|
1658
|
+
0% { translate: 0 0; scale: 1; opacity: 1; }
|
|
1659
|
+
40% { translate: 10px -10px; scale: .8; opacity: 0; }
|
|
1660
|
+
41% { translate: -10px 10px; scale: .8; opacity: 0; }
|
|
1661
|
+
100% { translate: 0 0; scale: 1; opacity: 1; }
|
|
1662
|
+
}
|
|
1663
|
+
@media (prefers-reduced-motion: reduce) { .input-button.inside-end.ws-sent svg { animation: none; } }
|
|
1664
|
+
.input-button.inside-end svg { transition: opacity .15s; }
|
|
1665
|
+
:host([data-assistant-working]) .input-button.inside-end { position: relative !important; }
|
|
1666
|
+
:host([data-assistant-working]) .input-button.inside-end.disabled-button { opacity: 1; }
|
|
1667
|
+
:host([data-assistant-working]) .input-button.inside-end svg { opacity: 0; }
|
|
1668
|
+
:host([data-assistant-working]) .loading-submit-button { display: none !important; }
|
|
1669
|
+
:host([data-assistant-working]) .input-button.inside-end::after {
|
|
1670
|
+
content: ''; position: absolute; inset: 0; pointer-events: none;
|
|
1671
|
+
background-image: radial-gradient(circle, var(--text-inverse) 2.4px, transparent 2.9px), radial-gradient(circle, var(--text-inverse) 2.4px, transparent 2.9px), radial-gradient(circle, var(--text-inverse) 2.4px, transparent 2.9px);
|
|
1672
|
+
background-size: 8px 8px; background-repeat: no-repeat;
|
|
1673
|
+
background-position: calc(50% - 9px) 55%, 50% 55%, calc(50% + 9px) 55%;
|
|
1674
|
+
animation: ws-dots 1.2s ease-in-out infinite;
|
|
1675
|
+
}
|
|
1676
|
+
@keyframes ws-dots {
|
|
1677
|
+
0%, 60%, 100% { background-position: calc(50% - 9px) 55%, 50% 55%, calc(50% + 9px) 55%; }
|
|
1678
|
+
15% { background-position: calc(50% - 9px) 30%, 50% 55%, calc(50% + 9px) 55%; }
|
|
1679
|
+
30% { background-position: calc(50% - 9px) 55%, 50% 30%, calc(50% + 9px) 55%; }
|
|
1680
|
+
45% { background-position: calc(50% - 9px) 55%, 50% 55%, calc(50% + 9px) 30%; }
|
|
1681
|
+
}
|
|
1682
|
+
@media (prefers-reduced-motion: reduce) { :host([data-assistant-working]) .input-button.inside-end::after { animation: none; } }
|
|
1683
|
+
`;
|
|
1684
|
+
|
|
1685
|
+
el.requestBodyLimits = { maxMessages: 1 };
|
|
1686
|
+
|
|
1687
|
+
// Prompt mode lives in deep-chat's own input toolbar, beside the send button, the way
|
|
1688
|
+
// ai_agent_modes puts its compact selector in the message box toolbar. deep-chat builds that
|
|
1689
|
+
// toolbar itself, so the select is rendered in the page and moved in once it exists.
|
|
1690
|
+
const picker = document.querySelector('.chat-tools .command-picker');
|
|
1691
|
+
|
|
1692
|
+
// The check buttons in the assistant options. Each is kept per browser; the settings only say where it starts.
|
|
1693
|
+
const savedSwitch = (name, start) => {
|
|
1694
|
+
const v = window.wsPref ? window.wsPref(name, null) : null;
|
|
1695
|
+
return v === null || v === undefined ? start : v === '1';
|
|
1696
|
+
};
|
|
1697
|
+
const prefSwitch = (name, start, apply) => ({
|
|
1698
|
+
get: () => savedSwitch(name, start),
|
|
1699
|
+
set: (on) => { wsPrefSet(name, on ? '1' : '0'); apply(on); },
|
|
1700
|
+
init: () => apply(savedSwitch(name, start)),
|
|
1701
|
+
});
|
|
1702
|
+
const switches = {
|
|
1703
|
+
voice: {
|
|
1704
|
+
show: narrator.supported, get: narrator.isOn,
|
|
1705
|
+
set: (on) => { narrator.setOn(on); if (on) narrator.speak('Voice summary on.'); },
|
|
1706
|
+
},
|
|
1707
|
+
mic: {
|
|
1708
|
+
show: cfg.speechToText !== false,
|
|
1709
|
+
...prefSwitch('ws-mic', cfg.voiceInput !== false, (on) => (on ? el.removeAttribute('data-assistant-mic') : el.setAttribute('data-assistant-mic', 'off'))),
|
|
1710
|
+
},
|
|
1711
|
+
mode: {
|
|
1712
|
+
show: !!picker,
|
|
1713
|
+
...prefSwitch('ws-mode', true, (on) => { if (picker) picker.style.display = on ? '' : 'none'; }),
|
|
1714
|
+
},
|
|
1715
|
+
suggest: {
|
|
1716
|
+
show: suggestions.length > 0,
|
|
1717
|
+
...prefSwitch('ws-suggest', true, (on) => { if (on) { if (conversationEmpty) showSuggestions(); } else hideSuggestions(); }),
|
|
1718
|
+
init: null, // showSuggestions already reads the saved choice once the history has loaded
|
|
1719
|
+
},
|
|
1720
|
+
};
|
|
1721
|
+
document.querySelectorAll('.assistant-switch').forEach((box) => {
|
|
1722
|
+
const sw = switches[box.dataset.switch];
|
|
1723
|
+
if (!sw || !sw.show) { box.closest('li').hidden = true; return; }
|
|
1724
|
+
box.checked = sw.get();
|
|
1725
|
+
if (sw.init) sw.init();
|
|
1726
|
+
box.addEventListener('change', () => {
|
|
1727
|
+
sw.set(box.checked);
|
|
1728
|
+
document.querySelectorAll(`.assistant-switch[data-switch="${box.dataset.switch}"]`).forEach((b) => { b.checked = box.checked; });
|
|
1729
|
+
});
|
|
1730
|
+
});
|
|
1731
|
+
if (picker) {
|
|
1732
|
+
picker.addEventListener('change', () => {
|
|
1733
|
+
if (!picker.value) return;
|
|
1734
|
+
const [workspace, file] = picker.value.split('/');
|
|
1735
|
+
const input = el.shadowRoot && el.shadowRoot.querySelector('#text-input');
|
|
1736
|
+
picker.value = '';
|
|
1737
|
+
if (!input) return;
|
|
1738
|
+
const existing = (input.innerText || '').trim();
|
|
1739
|
+
const phrase = `Run ${file} in ${workspace}: `;
|
|
1740
|
+
input.innerText = existing ? `${existing} ${phrase}` : phrase;
|
|
1741
|
+
input.dispatchEvent(new Event('input', { bubbles: true }));
|
|
1742
|
+
input.focus();
|
|
1743
|
+
const range = document.createRange();
|
|
1744
|
+
range.selectNodeContents(input);
|
|
1745
|
+
range.collapse(false);
|
|
1746
|
+
const sel = window.getSelection();
|
|
1747
|
+
sel.removeAllRanges();
|
|
1748
|
+
sel.addRange(range);
|
|
1749
|
+
});
|
|
1750
|
+
|
|
1751
|
+
// Styles do not cross a shadow boundary, so the select takes its own with it.
|
|
1752
|
+
const shadowCss = `
|
|
1753
|
+
/* The prompt is a card: the text on its own line, and a toolbar under it holding the mode
|
|
1754
|
+
selector and the send button — the shape ai_agent_modes uses. deep-chat lays its input out
|
|
1755
|
+
in one row, so the row is allowed to wrap and the text box is given the whole first line. */
|
|
1756
|
+
#input {
|
|
1757
|
+
box-sizing: border-box;
|
|
1758
|
+
max-width: 100%;
|
|
1759
|
+
flex-wrap: wrap;
|
|
1760
|
+
align-items: center;
|
|
1761
|
+
row-gap: 8px;
|
|
1762
|
+
border: 1px solid var(--line);
|
|
1763
|
+
border-radius: 12px;
|
|
1764
|
+
padding: 8px 10px;
|
|
1765
|
+
background-color: var(--surface-input);
|
|
1766
|
+
}
|
|
1767
|
+
#text-input-container { flex: 1 1 100%; }
|
|
1768
|
+
#text-input {
|
|
1769
|
+
border: none !important;
|
|
1770
|
+
box-shadow: none !important;
|
|
1771
|
+
background: transparent !important;
|
|
1772
|
+
padding: 4px 2px !important;
|
|
1773
|
+
}
|
|
1774
|
+
/* deep-chat anchors its buttons absolutely inside zero-size containers, which floats them
|
|
1775
|
+
over the text area. In a toolbar they have to take part in the row, so the containers get
|
|
1776
|
+
a size and the buttons stop being positioned. */
|
|
1777
|
+
.input-button-container {
|
|
1778
|
+
order: 2;
|
|
1779
|
+
position: static;
|
|
1780
|
+
width: auto;
|
|
1781
|
+
height: auto;
|
|
1782
|
+
display: flex;
|
|
1783
|
+
align-items: center;
|
|
1784
|
+
}
|
|
1785
|
+
.input-button-container > * { position: static !important; }
|
|
1786
|
+
.input-button { position: static !important; margin: 0 !important; }
|
|
1787
|
+
/* The icons are laid out by deep-chat with their own offsets — the microphone sat 14px down
|
|
1788
|
+
and 2px outside its button. Centring them means clearing that, not just centring the box
|
|
1789
|
+
that holds them. */
|
|
1790
|
+
.input-button svg {
|
|
1791
|
+
position: static !important;
|
|
1792
|
+
margin: 0 !important;
|
|
1793
|
+
inset: auto !important;
|
|
1794
|
+
transform: none !important;
|
|
1795
|
+
display: block !important;
|
|
1796
|
+
flex: none !important;
|
|
1797
|
+
}
|
|
1798
|
+
|
|
1799
|
+
/* The send button is the assistant's own colour, the brand gradient its header and your messages
|
|
1800
|
+
carry, in a square sized to the selector beside it so the row reads as one control. Every colour in
|
|
1801
|
+
this block is a token: a custom property inherits into the shadow root, so both halves follow the
|
|
1802
|
+
theme. It used to be UIKit blue, resting on a fixed pale grey that stayed light after dark. */
|
|
1803
|
+
.input-button.inside-end {
|
|
1804
|
+
width: 34px;
|
|
1805
|
+
height: 34px;
|
|
1806
|
+
box-sizing: border-box;
|
|
1807
|
+
padding: 0 !important;
|
|
1808
|
+
border-radius: 8px;
|
|
1809
|
+
background: var(--brand-grad) !important;
|
|
1810
|
+
display: flex;
|
|
1811
|
+
align-items: center;
|
|
1812
|
+
justify-content: center;
|
|
1813
|
+
}
|
|
1814
|
+
/* deep-chat tints its icons with an inline filter, so the icon colour has to be forced the
|
|
1815
|
+
same way the background was. On the blue square the arrow is white. */
|
|
1816
|
+
.input-button.inside-end svg { width: 17px !important; height: 17px !important; filter: brightness(0) invert(1) !important; }
|
|
1817
|
+
.input-button.inside-end:hover { filter: brightness(1.08); }
|
|
1818
|
+
/* Disabled is the resting state — there is nothing to send until something is typed — so it reads
|
|
1819
|
+
as the same button, not yet ready: the gradient at half strength, the arrow still white. */
|
|
1820
|
+
.input-button.inside-end.disabled-button { opacity: .5; }
|
|
1821
|
+
.input-button.inside-end.disabled-button:hover { filter: none; }
|
|
1822
|
+
|
|
1823
|
+
/* The microphone is a button too, so it gets the same square as the send button rather than
|
|
1824
|
+
sitting next to it as a bare icon. */
|
|
1825
|
+
.input-button.outside-end {
|
|
1826
|
+
width: 34px;
|
|
1827
|
+
height: 34px;
|
|
1828
|
+
box-sizing: border-box;
|
|
1829
|
+
padding: 0 !important;
|
|
1830
|
+
margin-left: 6px !important;
|
|
1831
|
+
display: flex;
|
|
1832
|
+
align-items: center;
|
|
1833
|
+
justify-content: center;
|
|
1834
|
+
border-radius: 8px;
|
|
1835
|
+
background: var(--brand-grad) !important;
|
|
1836
|
+
}
|
|
1837
|
+
/* The microphone is a filled icon, so it is coloured rather than filtered — the same ink the
|
|
1838
|
+
rest of the interface uses, not deep-chat's green tint. */
|
|
1839
|
+
.input-button.outside-end svg { width: 17px !important; height: 17px !important; filter: none !important; }
|
|
1840
|
+
.input-button.outside-end svg, .input-button.outside-end svg * { fill: var(--text-inverse) !important; }
|
|
1841
|
+
.input-button.outside-end:hover { filter: brightness(1.08); }
|
|
1842
|
+
/* While it is listening it is the active control in the row, so it says so. */
|
|
1843
|
+
.input-button.outside-end.active-button { background: var(--danger) !important; }
|
|
1844
|
+
|
|
1845
|
+
.command-picker {
|
|
1846
|
+
order: 2;
|
|
1847
|
+
box-sizing: border-box;
|
|
1848
|
+
/* A select is as wide as its longest option unless it is told otherwise — 563px of one
|
|
1849
|
+
here, in a 358px sidebar. Zero basis with min-width:0 makes it take the space that is
|
|
1850
|
+
left instead of the space it wants. */
|
|
1851
|
+
flex: 1 1 0;
|
|
1852
|
+
width: 0;
|
|
1853
|
+
min-width: 0;
|
|
1854
|
+
height: 34px;
|
|
1855
|
+
font-size: 14px;
|
|
1856
|
+
line-height: 1;
|
|
1857
|
+
padding: 0 28px 0 10px;
|
|
1858
|
+
margin: 0 8px 0 0;
|
|
1859
|
+
border: 1px solid var(--line);
|
|
1860
|
+
border-radius: 8px;
|
|
1861
|
+
background-color: var(--surface-input);
|
|
1862
|
+
color: var(--text-default);
|
|
1863
|
+
cursor: pointer;
|
|
1864
|
+
appearance: none;
|
|
1865
|
+
background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath fill='%23555' d='M4 6l4 4 4-4z'/%3E%3C/svg%3E");
|
|
1866
|
+
background-repeat: no-repeat;
|
|
1867
|
+
background-position: right 8px center;
|
|
1868
|
+
background-size: 14px;
|
|
1869
|
+
}
|
|
1870
|
+
.command-picker:focus { outline: none; border-color: var(--link); box-shadow: 0 0 0 2px var(--focus-ring); }
|
|
1871
|
+
`;
|
|
1872
|
+
|
|
1873
|
+
// The toolbar is built after the component upgrades, so wait for the send button rather than
|
|
1874
|
+
// assuming it is there; give up quietly and leave the select where it is if it never appears.
|
|
1875
|
+
let tries = 0;
|
|
1876
|
+
const place = () => {
|
|
1877
|
+
const sr = el.shadowRoot;
|
|
1878
|
+
const submit = sr && sr.querySelector('.input-button.inside-end, .input-button.outside-end');
|
|
1879
|
+
if (!submit) {
|
|
1880
|
+
// No give-up: deep-chat can take longer than any fixed budget on a busy
|
|
1881
|
+
// machine, and a silent stop is exactly "sometimes the prompt mode is
|
|
1882
|
+
// missing". Poll fast for the first six seconds, then slowly forever —
|
|
1883
|
+
// the picker stays visible in its own row until the move happens.
|
|
1884
|
+
tries += 1;
|
|
1885
|
+
return setTimeout(place, tries < 60 ? 100 : 1000);
|
|
1886
|
+
}
|
|
1887
|
+
const style = document.createElement('style');
|
|
1888
|
+
style.textContent = shadowCss;
|
|
1889
|
+
sr.appendChild(style);
|
|
1890
|
+
// Into the input row itself, not the small container the button sits in: the selector is
|
|
1891
|
+
// sized against the row's free space, and a button container has none to give.
|
|
1892
|
+
const buttonBox = submit.closest('.input-button-container') || submit.parentElement;
|
|
1893
|
+
buttonBox.parentElement.insertBefore(picker, buttonBox);
|
|
1894
|
+
const row = document.querySelector('.chat-tools');
|
|
1895
|
+
if (row) row.remove();
|
|
1896
|
+
|
|
1897
|
+
// "@" and "/" in the composer, scoped to the page's own workspace.
|
|
1898
|
+
//
|
|
1899
|
+
// deep-chat's input is a contenteditable/textarea inside this shadow root, so the
|
|
1900
|
+
// suggester has to be handed the element directly — there is no `data-suggest`
|
|
1901
|
+
// attribute to find from the document, and querySelectorAll does not cross a
|
|
1902
|
+
// shadow boundary. The endpoint carries the workspace (and project, where the page
|
|
1903
|
+
// has one) so a project's own .claude/ items outrank the global ones, exactly as a
|
|
1904
|
+
// session started in that folder would resolve them.
|
|
1905
|
+
const suggestInput = sr.querySelector('#text-input, textarea, [contenteditable="true"]');
|
|
1906
|
+
if (suggestInput && window.wsSuggest) {
|
|
1907
|
+
const scope = document.body.dataset.suggestScope || '';
|
|
1908
|
+
window.wsSuggest.attachSuggest(suggestInput, `/fragments/ai-suggest${scope}`);
|
|
1909
|
+
}
|
|
1910
|
+
|
|
1911
|
+
// deep-chat re-renders its shadow DOM after applying async config, which
|
|
1912
|
+
// silently deletes foreign nodes — the picker survived only when that
|
|
1913
|
+
// re-render happened to run first. The JS reference outlives the detach,
|
|
1914
|
+
// so a watchdog puts the same node (and its style) back whenever it is
|
|
1915
|
+
// dropped, forever — a re-render can happen again on theme changes.
|
|
1916
|
+
setInterval(() => {
|
|
1917
|
+
if (picker.isConnected) return;
|
|
1918
|
+
const sub2 = sr.querySelector('.input-button.inside-end, .input-button.outside-end');
|
|
1919
|
+
if (!sub2) return;
|
|
1920
|
+
if (!sr.contains(style)) sr.appendChild(style);
|
|
1921
|
+
const box2 = sub2.closest('.input-button-container') || sub2.parentElement;
|
|
1922
|
+
box2.parentElement.insertBefore(picker, box2);
|
|
1923
|
+
}, 1000);
|
|
1924
|
+
// No dark copy of these styles, and nothing mirroring the theme in: every colour above is a token, and a
|
|
1925
|
+
// custom property set on <html> inherits into the shadow root, so the toggle reaches it on its own.
|
|
1926
|
+
};
|
|
1927
|
+
place();
|
|
1928
|
+
}
|
|
1929
|
+
el.errorMessages = { displayServiceErrorMessages: true };
|
|
1930
|
+
})();
|
|
1931
|
+
|
|
1932
|
+
// The actions rail is handled once, near the top of this file, under the `actions-collapsed`
|
|
1933
|
+
// key that the no-flash script in the page head also reads. A SECOND implementation used to sit
|
|
1934
|
+
// here — same class, its own `ws-actions-collapsed` key, its own listener bound straight to the
|
|
1935
|
+
// button — and the two cancelled each other out: the delegated handler toggled the class and wrote
|
|
1936
|
+
// its key, then this one read the already-toggled class and toggled it back. The rail looked
|
|
1937
|
+
// broken because every click was undone a millisecond later by the other half of the same feature.
|
|
1938
|
+
|
|
1939
|
+
// Reordering the workspaces list by dragging. The buttons stay — a drag is quick for a big move
|
|
1940
|
+
// and awkward for a single step, and neither is a substitute for the other. The order is posted
|
|
1941
|
+
// once, on drop, rather than as a run of swaps.
|
|
1942
|
+
document.addEventListener('dragstart', (e) => {
|
|
1943
|
+
const item = e.target.closest && e.target.closest('.ws-item');
|
|
1944
|
+
if (!item) return;
|
|
1945
|
+
item.classList.add('is-dragging');
|
|
1946
|
+
e.dataTransfer.effectAllowed = 'move';
|
|
1947
|
+
e.dataTransfer.setData('text/plain', item.dataset.ws || '');
|
|
1948
|
+
});
|
|
1949
|
+
|
|
1950
|
+
document.addEventListener('dragover', (e) => {
|
|
1951
|
+
const list = e.target.closest && e.target.closest('.ws-list');
|
|
1952
|
+
if (!list) return;
|
|
1953
|
+
e.preventDefault();
|
|
1954
|
+
const dragging = list.querySelector('.ws-item.is-dragging');
|
|
1955
|
+
const over = e.target.closest('.ws-item');
|
|
1956
|
+
if (!dragging || !over || over === dragging) return;
|
|
1957
|
+
const box = over.getBoundingClientRect();
|
|
1958
|
+
const after = e.clientY > box.top + box.height / 2;
|
|
1959
|
+
list.insertBefore(dragging, after ? over.nextSibling : over);
|
|
1960
|
+
});
|
|
1961
|
+
|
|
1962
|
+
document.addEventListener('dragend', (e) => {
|
|
1963
|
+
const item = e.target.closest && e.target.closest('.ws-item');
|
|
1964
|
+
if (!item) return;
|
|
1965
|
+
item.classList.remove('is-dragging');
|
|
1966
|
+
const editor = item.closest('#list-editor');
|
|
1967
|
+
const list = item.closest('.ws-list');
|
|
1968
|
+
if (!editor || !list) return;
|
|
1969
|
+
const order = [...list.querySelectorAll('.ws-item')].map((li) => li.dataset.ws).join(',');
|
|
1970
|
+
htmx.ajax('POST', '/actions/list-order', {
|
|
1971
|
+
target: '#list-editor',
|
|
1972
|
+
swap: 'outerHTML',
|
|
1973
|
+
values: { file: editor.dataset.file, key: editor.dataset.key, order },
|
|
1974
|
+
});
|
|
1975
|
+
});
|
|
1976
|
+
|
|
1977
|
+
// Interface directives from the Workspace AI Assistant (HX-Trigger events):
|
|
1978
|
+
// after the agent creates/starts/launches something, it can steer the page —
|
|
1979
|
+
// open the new site in a tab and/or navigate to the relevant workspace page.
|
|
1980
|
+
document.body.addEventListener('assistant-directive', (e) => {
|
|
1981
|
+
const d = e.detail || {};
|
|
1982
|
+
if (d.open) window.open(d.open, '_blank');
|
|
1983
|
+
if (d.navigate) setTimeout(() => { location.href = d.navigate; }, 900);
|
|
1984
|
+
});
|
|
1985
|
+
|
|
1986
|
+
// Global "system is working" indicator: count in-flight HTMX requests and
|
|
1987
|
+
// toggle html.htmx-busy, which shows the top progress bar + Working… pill.
|
|
1988
|
+
let activeRequests = 0;
|
|
1989
|
+
const isJobPoll = (e) => e.detail?.elt?.id?.startsWith('job-');
|
|
1990
|
+
document.addEventListener('htmx:before:request', (e) => {
|
|
1991
|
+
if (isJobPoll(e)) return; // 1s job polls would make the bar flicker forever
|
|
1992
|
+
activeRequests += 1;
|
|
1993
|
+
document.documentElement.classList.add('htmx-busy');
|
|
1994
|
+
});
|
|
1995
|
+
const requestDone = (e) => {
|
|
1996
|
+
if (isJobPoll(e)) return;
|
|
1997
|
+
activeRequests = Math.max(0, activeRequests - 1);
|
|
1998
|
+
if (activeRequests === 0) document.documentElement.classList.remove('htmx-busy');
|
|
1999
|
+
};
|
|
2000
|
+
document.addEventListener('htmx:after:request', requestDone);
|
|
2001
|
+
document.addEventListener('htmx:finally:request', requestDone);
|
|
2002
|
+
document.addEventListener('htmx:error', requestDone);
|
|
2003
|
+
document.addEventListener('htmx:response:error', requestDone);
|
|
2004
|
+
|
|
2005
|
+
// A counter can only ever be as honest as the events that feed it, and a pill that says the
|
|
2006
|
+
// system is working when it is not is worse than no pill: it is the one piece of chrome you
|
|
2007
|
+
// cannot dismiss. htmx marks the element it is requesting for with .htmx-request, so that is
|
|
2008
|
+
// the truth to fall back on — when nothing on the page is in flight, the count is zero,
|
|
2009
|
+
// whatever the events did or did not say.
|
|
2010
|
+
setInterval(() => {
|
|
2011
|
+
if (!document.documentElement.classList.contains('htmx-busy')) return;
|
|
2012
|
+
if (document.querySelector('.htmx-request')) return;
|
|
2013
|
+
activeRequests = 0;
|
|
2014
|
+
document.documentElement.classList.remove('htmx-busy');
|
|
2015
|
+
}, 2000);
|
|
2016
|
+
|
|
2017
|
+
// Keep live terminal boxes scrolled to the newest output line.
|
|
2018
|
+
document.addEventListener('htmx:after:swap', (e) => {
|
|
2019
|
+
const el = swappedEl(e);
|
|
2020
|
+
const pres = [];
|
|
2021
|
+
if (el.classList?.contains('terminal')) pres.push(...el.querySelectorAll('.terminal-pre'));
|
|
2022
|
+
el.querySelectorAll?.('.terminal .terminal-pre').forEach((p) => pres.push(p));
|
|
2023
|
+
pres.forEach((p) => { p.scrollTop = p.scrollHeight; });
|
|
2024
|
+
});
|
|
2025
|
+
|
|
2026
|
+
// Keep the chat log scrolled to the latest message after every HTMX swap.
|
|
2027
|
+
document.addEventListener('htmx:after:swap', (e) => {
|
|
2028
|
+
const el = swappedEl(e);
|
|
2029
|
+
const log = document.getElementById('chat-log');
|
|
2030
|
+
if (log && (el === log || log.contains(el))) {
|
|
2031
|
+
log.scrollTop = log.scrollHeight;
|
|
2032
|
+
}
|
|
2033
|
+
});
|
|
2034
|
+
|
|
2035
|
+
// Dark mode toggle (light is the default; choice persists in localStorage).
|
|
2036
|
+
document.addEventListener('click', (e) => {
|
|
2037
|
+
if (!e.target.closest('.theme-toggle')) return;
|
|
2038
|
+
const dark = document.documentElement.classList.toggle('dark');
|
|
2039
|
+
wsPrefSet('ws-theme', dark ? 'dark' : 'light');
|
|
2040
|
+
});
|
|
2041
|
+
|
|
2042
|
+
// The command picker inside the prompt: choosing an entry writes a runnable
|
|
2043
|
+
// phrase into the box and hands focus back, so the prompt stays the one place
|
|
2044
|
+
// you type. It never runs anything by itself.
|
|
2045
|
+
document.addEventListener('change', (e) => {
|
|
2046
|
+
const picker = e.target.closest('.command-picker');
|
|
2047
|
+
if (!picker || !picker.value) return;
|
|
2048
|
+
const form = picker.closest('form');
|
|
2049
|
+
const input = form && form.querySelector('input[name="message"]');
|
|
2050
|
+
if (!input) return;
|
|
2051
|
+
const [workspace, file] = picker.value.split('/');
|
|
2052
|
+
const phrase = `Run ${file} in ${workspace}: `;
|
|
2053
|
+
input.value = input.value ? `${input.value.trim()} ${phrase}` : phrase;
|
|
2054
|
+
picker.value = '';
|
|
2055
|
+
input.focus();
|
|
2056
|
+
input.setSelectionRange(input.value.length, input.value.length);
|
|
2057
|
+
});
|
|
2058
|
+
|
|
2059
|
+
// The Auto-mode confirm card. "Run it" sends the agreement back as the next turn of the
|
|
2060
|
+
// conversation (so the agent executes with its own safeguards), "Keep prompting" hands the
|
|
2061
|
+
// panel back to you.
|
|
2062
|
+
//
|
|
2063
|
+
// Two things about WHERE this card lives, both of which silently broke it when the assistant
|
|
2064
|
+
// became a deep-chat component:
|
|
2065
|
+
//
|
|
2066
|
+
// - The card is drawn INSIDE deep-chat's shadow DOM, so a click on it arrives at the
|
|
2067
|
+
// document retargeted to <deep-chat> itself: `e.target.closest('.plan-run')` is null and
|
|
2068
|
+
// the handler never ran. `composedPath()` is the path the click actually took, shadow
|
|
2069
|
+
// boundary included, and is the only way to see the button from out here.
|
|
2070
|
+
// - There is no `.chat-input` form any more — the panel owns its own input — so the turn is
|
|
2071
|
+
// sent with deep-chat's own `submitUserMessage`, which puts it in the transcript and posts
|
|
2072
|
+
// it exactly as typing it would.
|
|
2073
|
+
document.addEventListener('click', (e) => {
|
|
2074
|
+
const path = typeof e.composedPath === 'function' ? e.composedPath() : [e.target];
|
|
2075
|
+
const hit = (cls) => path.find((n) => n && n.classList && n.classList.contains(cls));
|
|
2076
|
+
const run = hit('plan-run');
|
|
2077
|
+
const keep = hit('plan-keep');
|
|
2078
|
+
if (!run && !keep) return;
|
|
2079
|
+
const chat = document.getElementById('ws-deep-chat');
|
|
2080
|
+
if (!chat) return;
|
|
2081
|
+
if (keep) {
|
|
2082
|
+
const input = chat.shadowRoot && chat.shadowRoot.querySelector('#text-input');
|
|
2083
|
+
if (input) input.focus();
|
|
2084
|
+
return;
|
|
2085
|
+
}
|
|
2086
|
+
const card = path.find((n) => n && n.classList && n.classList.contains('plan-card'));
|
|
2087
|
+
if (card) card.classList.add('plan-card-done');
|
|
2088
|
+
// Both spellings: deep-chat has taken a plain string and an object across versions, and a
|
|
2089
|
+
// card that quietly does nothing is exactly the failure this is fixing.
|
|
2090
|
+
try { chat.submitUserMessage({ text: 'Yes — run it.' }); }
|
|
2091
|
+
catch (_) { try { chat.submitUserMessage('Yes — run it.'); } catch (__) { /* nothing to do */ } }
|
|
2092
|
+
});
|
|
2093
|
+
|
|
2094
|
+
// The image picker in settings. Clicking a tile writes the filename into its field and
|
|
2095
|
+
// refreshes the preview beside it — the value has to match a file in public/ exactly, so
|
|
2096
|
+
// picking beats typing, which is why these fields have no microphone.
|
|
2097
|
+
document.body.addEventListener('click', (e) => {
|
|
2098
|
+
const close = e.target.closest('.image-choices-close');
|
|
2099
|
+
if (close) { close.closest('.image-picker').innerHTML = ''; return; }
|
|
2100
|
+
const tile = e.target.closest('[data-pick-image]');
|
|
2101
|
+
if (!tile) return;
|
|
2102
|
+
const field = document.getElementById(tile.dataset.pickTarget);
|
|
2103
|
+
if (!field) return;
|
|
2104
|
+
field.value = tile.dataset.pickImage;
|
|
2105
|
+
field.dispatchEvent(new Event('input', { bubbles: true }));
|
|
2106
|
+
const preview = field.closest('.image-field')?.querySelector('.image-preview');
|
|
2107
|
+
if (preview) preview.innerHTML = `<img src="/${tile.dataset.pickImage}" alt="">`;
|
|
2108
|
+
tile.closest('.image-picker').innerHTML = '';
|
|
2109
|
+
field.focus();
|
|
2110
|
+
});
|
|
2111
|
+
|
|
2112
|
+
// Running commands live in a toast stack.
|
|
2113
|
+
//
|
|
2114
|
+
// A job fragment arrives in whatever page area launched it, and polls by replacing ITSELF —
|
|
2115
|
+
// so moving it into the stack once is enough: every later poll lands wherever the element now
|
|
2116
|
+
// is. That is why this hooks the swap rather than changing the hx-target of the ~40 buttons
|
|
2117
|
+
// that start jobs.
|
|
2118
|
+
//
|
|
2119
|
+
// Which jobs are collapsed is kept on the STACK, not in a JS variable: the stack survives every
|
|
2120
|
+
// poll (only its children are replaced), so the state cannot be lost to a re-render or to this
|
|
2121
|
+
// file being evaluated twice.
|
|
2122
|
+
function collapsedSet() {
|
|
2123
|
+
const stack = document.getElementById('job-toasts');
|
|
2124
|
+
return new Set((stack?.dataset.collapsed || '').split(' ').filter(Boolean));
|
|
2125
|
+
}
|
|
2126
|
+
|
|
2127
|
+
function saveCollapsed(set) {
|
|
2128
|
+
const stack = document.getElementById('job-toasts');
|
|
2129
|
+
if (stack) stack.dataset.collapsed = [...set].join(' ');
|
|
2130
|
+
}
|
|
2131
|
+
|
|
2132
|
+
// A dragged size, like the collapsed state, lives on the stack rather than on the toast: the
|
|
2133
|
+
// toast element is replaced by its own poll every second, so anything stored on it is gone a
|
|
2134
|
+
// second later — which is why a resized job used to snap back to its default width.
|
|
2135
|
+
function toastSizes() {
|
|
2136
|
+
const stack = document.getElementById('job-toasts');
|
|
2137
|
+
if (!stack) return {};
|
|
2138
|
+
try { return JSON.parse(stack.dataset.sizes || '{}'); } catch (_) { return {}; }
|
|
2139
|
+
}
|
|
2140
|
+
function setToastSize(id, w, h) {
|
|
2141
|
+
const stack = document.getElementById('job-toasts');
|
|
2142
|
+
if (!stack) return;
|
|
2143
|
+
const all = toastSizes();
|
|
2144
|
+
all[id] = { w: Math.round(w), h: Math.round(h) };
|
|
2145
|
+
stack.dataset.sizes = JSON.stringify(all);
|
|
2146
|
+
}
|
|
2147
|
+
|
|
2148
|
+
// CSS `resize` only ever gives ONE handle, at the bottom right. These are the eight grips a
|
|
2149
|
+
// resizable panel is expected to have — four corners and four edges — added by hand for that
|
|
2150
|
+
// reason. Which edge stays put is fixed by the layout: the stack is bottom-anchored and
|
|
2151
|
+
// right-aligned, so a toast always grows leftwards and upwards. Dragging outward from any grip
|
|
2152
|
+
// makes it bigger, dragging inward makes it smaller.
|
|
2153
|
+
const TOAST_GRIPS = {
|
|
2154
|
+
tl: { x: -1, y: -1, cursor: 'nwse-resize', label: 'top-left corner' },
|
|
2155
|
+
t: { x: 0, y: -1, cursor: 'ns-resize', label: 'top edge' },
|
|
2156
|
+
tr: { x: 1, y: -1, cursor: 'nesw-resize', label: 'top-right corner' },
|
|
2157
|
+
r: { x: 1, y: 0, cursor: 'ew-resize', label: 'right edge' },
|
|
2158
|
+
br: { x: 1, y: 1, cursor: 'nwse-resize', label: 'bottom-right corner' },
|
|
2159
|
+
b: { x: 0, y: 1, cursor: 'ns-resize', label: 'bottom edge' },
|
|
2160
|
+
bl: { x: -1, y: 1, cursor: 'nesw-resize', label: 'bottom-left corner' },
|
|
2161
|
+
l: { x: -1, y: 0, cursor: 'ew-resize', label: 'left edge' },
|
|
2162
|
+
};
|
|
2163
|
+
function addToastGrips(toast) {
|
|
2164
|
+
if (toast.querySelector('.toast-grip')) return;
|
|
2165
|
+
Object.keys(TOAST_GRIPS).forEach((corner) => {
|
|
2166
|
+
const g = document.createElement('span');
|
|
2167
|
+
g.className = `toast-grip toast-grip-${corner}`;
|
|
2168
|
+
g.dataset.grip = corner;
|
|
2169
|
+
g.title = `Resize from the ${TOAST_GRIPS[corner].label}`;
|
|
2170
|
+
toast.appendChild(g);
|
|
2171
|
+
});
|
|
2172
|
+
}
|
|
2173
|
+
|
|
2174
|
+
// The grip lives inside the card, which is a link: a click that lands on it must not navigate.
|
|
2175
|
+
document.addEventListener('click', (e) => {
|
|
2176
|
+
const grip = e.target.closest('.ws-card-handle');
|
|
2177
|
+
if (!grip) return;
|
|
2178
|
+
e.preventDefault();
|
|
2179
|
+
e.stopPropagation();
|
|
2180
|
+
}, true);
|
|
2181
|
+
|
|
2182
|
+
document.addEventListener('pointerdown', (e) => {
|
|
2183
|
+
const grip = e.target.closest('.toast-grip');
|
|
2184
|
+
if (!grip) return;
|
|
2185
|
+
const toast = grip.closest('.job-toast');
|
|
2186
|
+
const dir = TOAST_GRIPS[grip.dataset.grip];
|
|
2187
|
+
if (!toast || !dir) return;
|
|
2188
|
+
e.preventDefault();
|
|
2189
|
+
const box = toast.getBoundingClientRect();
|
|
2190
|
+
const startX = e.clientX;
|
|
2191
|
+
const startY = e.clientY;
|
|
2192
|
+
const min = { w: 16 * 16, h: 5 * 16 }; // the CSS min-width / min-height, in px
|
|
2193
|
+
const max = { w: toast.parentElement.clientWidth, h: window.innerHeight - 96 };
|
|
2194
|
+
const clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v));
|
|
2195
|
+
// The toast replaces ITSELF every second while the job runs, so the element under the
|
|
2196
|
+
// pointer is gone within a second of grabbing it. Everything below works from the id and
|
|
2197
|
+
// re-queries the live element, and the size is written to the store on every move — which is
|
|
2198
|
+
// what makes the drag survive a poll landing in the middle of it.
|
|
2199
|
+
const id = toast.id;
|
|
2200
|
+
const move = (ev) => {
|
|
2201
|
+
// An outward drag is positive on every grip: the sign per axis comes from which side of the
|
|
2202
|
+
// toast the grip is on, so the left grips read a leftward drag as growth and the right ones
|
|
2203
|
+
// read a rightward drag the same way. A `0` axis is an edge grip — that dimension is fixed.
|
|
2204
|
+
const w = dir.x ? clamp(box.width + dir.x * (ev.clientX - startX), min.w, max.w) : box.width;
|
|
2205
|
+
const h = dir.y ? clamp(box.height + dir.y * (ev.clientY - startY), min.h, max.h) : box.height;
|
|
2206
|
+
setToastSize(id, w, h);
|
|
2207
|
+
const live = document.getElementById(id);
|
|
2208
|
+
if (live) { live.style.width = `${w}px`; live.style.height = `${h}px`; }
|
|
2209
|
+
};
|
|
2210
|
+
const up = () => {
|
|
2211
|
+
document.removeEventListener('pointermove', move);
|
|
2212
|
+
document.removeEventListener('pointerup', up);
|
|
2213
|
+
document.body.classList.remove('toast-resizing');
|
|
2214
|
+
};
|
|
2215
|
+
document.body.classList.add('toast-resizing');
|
|
2216
|
+
document.addEventListener('pointermove', move);
|
|
2217
|
+
document.addEventListener('pointerup', up);
|
|
2218
|
+
});
|
|
2219
|
+
|
|
2220
|
+
function adoptJobToasts() {
|
|
2221
|
+
const stack = document.getElementById('job-toasts');
|
|
2222
|
+
if (!stack) return;
|
|
2223
|
+
const collapsed = collapsedSet();
|
|
2224
|
+
const sizes = toastSizes();
|
|
2225
|
+
document.querySelectorAll('.job-toast').forEach((toast) => {
|
|
2226
|
+
// An inline job (a clone running in its modal) stays where it was swapped in.
|
|
2227
|
+
if (toast.classList.contains('job-inline')) return;
|
|
2228
|
+
if (toast.parentElement !== stack) stack.appendChild(toast);
|
|
2229
|
+
addToastGrips(toast);
|
|
2230
|
+
// Re-apply a size the user dragged, for the same reason the collapsed state is re-applied.
|
|
2231
|
+
const size = sizes[toast.id];
|
|
2232
|
+
if (size) { toast.style.width = `${size.w}px`; toast.style.height = `${size.h}px`; }
|
|
2233
|
+
// The server does not know what the user collapsed, and each poll rebuilds the element,
|
|
2234
|
+
// so the choice is re-applied here from the stack that remembers it.
|
|
2235
|
+
const isCollapsed = collapsed.has(toast.id);
|
|
2236
|
+
toast.classList.toggle('is-collapsed', isCollapsed);
|
|
2237
|
+
const btn = toast.querySelector('.job-toast-collapse');
|
|
2238
|
+
if (btn) btn.setAttribute('aria-expanded', isCollapsed ? 'false' : 'true');
|
|
2239
|
+
// Follow the tail while it runs, unless the user has scrolled up to read something.
|
|
2240
|
+
const pre = toast.querySelector('.terminal-pre');
|
|
2241
|
+
if (pre && !isCollapsed && toast.classList.contains('is-running') && !toast.dataset.userScrolled) {
|
|
2242
|
+
pre.scrollTop = pre.scrollHeight;
|
|
2243
|
+
}
|
|
2244
|
+
});
|
|
2245
|
+
}
|
|
2246
|
+
|
|
2247
|
+
|
|
2248
|
+
document.body.addEventListener('htmx:before:swap', (e) => {
|
|
2249
|
+
const el = swappedEl(e);
|
|
2250
|
+
if (el && el.id === 'editor-modal-body') {
|
|
2251
|
+
el.querySelectorAll('video, audio').forEach((m) => { try { m.pause(); } catch (_) { /* gone */ } });
|
|
2252
|
+
}
|
|
2253
|
+
});
|
|
2254
|
+
|
|
2255
|
+
document.addEventListener('DOMContentLoaded', adoptJobToasts);
|
|
2256
|
+
// Both events: afterSwap puts the element in place, afterSettle runs once htmx has finished
|
|
2257
|
+
// with it — re-applying at both points is what makes a collapsed job stay collapsed through
|
|
2258
|
+
// a poll.
|
|
2259
|
+
document.body.addEventListener('htmx:after:swap', adoptJobToasts);
|
|
2260
|
+
document.body.addEventListener('htmx:after:swap', adoptJobToasts);
|
|
2261
|
+
|
|
2262
|
+
document.addEventListener('click', (e) => {
|
|
2263
|
+
const collapse = e.target.closest('.job-toast-collapse');
|
|
2264
|
+
if (collapse) {
|
|
2265
|
+
const toast = collapse.closest('.job-toast');
|
|
2266
|
+
const set = collapsedSet();
|
|
2267
|
+
if (set.has(toast.id)) set.delete(toast.id);
|
|
2268
|
+
else set.add(toast.id);
|
|
2269
|
+
saveCollapsed(set);
|
|
2270
|
+
adoptJobToasts();
|
|
2271
|
+
return;
|
|
2272
|
+
}
|
|
2273
|
+
const close = e.target.closest('.job-toast-close');
|
|
2274
|
+
if (close) {
|
|
2275
|
+
const toast = close.closest('.job-toast');
|
|
2276
|
+
const set = collapsedSet();
|
|
2277
|
+
set.delete(toast.id);
|
|
2278
|
+
saveCollapsed(set);
|
|
2279
|
+
// Removing the element stops its polling; the job itself carries on server-side.
|
|
2280
|
+
toast.remove();
|
|
2281
|
+
}
|
|
2282
|
+
});
|
|
2283
|
+
|
|
2284
|
+
// Reading the output of a long job means scrolling up; do not yank it back to the tail.
|
|
2285
|
+
document.addEventListener('scroll', (e) => {
|
|
2286
|
+
const pre = e.target.closest && e.target.closest('.job-toast .terminal-pre');
|
|
2287
|
+
if (!pre) return;
|
|
2288
|
+
const toast = pre.closest('.job-toast');
|
|
2289
|
+
const atBottom = pre.scrollHeight - pre.scrollTop - pre.clientHeight < 24;
|
|
2290
|
+
if (atBottom) delete toast.dataset.userScrolled;
|
|
2291
|
+
else toast.dataset.userScrolled = '1';
|
|
2292
|
+
}, true);
|
|
2293
|
+
|
|
2294
|
+
// Short results become a toast at the top.
|
|
2295
|
+
//
|
|
2296
|
+
// A one-line "saved" or "deleted" used to render wherever the form was, which on the settings
|
|
2297
|
+
// page is below a 60-field form and off-screen — the save looked like it had done nothing. A job
|
|
2298
|
+
// keeps its own stack at the bottom: that is something you watch, this is something you read once
|
|
2299
|
+
// and forget, so these fade on their own.
|
|
2300
|
+
const FLASH_MS = { assistant: 6000, error: 12000 }; // an error is left long enough to read
|
|
2301
|
+
|
|
2302
|
+
function flashResults(root) {
|
|
2303
|
+
const stack = document.getElementById('flash-toasts');
|
|
2304
|
+
if (!stack) return;
|
|
2305
|
+
const scope = root && root.querySelectorAll ? root : document;
|
|
2306
|
+
scope.querySelectorAll('.msg:not(.terminal):not(.flash-toast)').forEach((msg) => {
|
|
2307
|
+
// Leave the chat log and anything inside the modal where they are: those are content, not
|
|
2308
|
+
// a transient result.
|
|
2309
|
+
//
|
|
2310
|
+
// Except a result that ARRIVED in the modal. A form in there (Remove, Clone, Restore, the
|
|
2311
|
+
// item forms) posts its own target, so the answer replaces the form — leaving the modal
|
|
2312
|
+
// open on a sentence about work already running, with nothing to do but close it by hand.
|
|
2313
|
+
// The server marks those `flash-now`; they come out and the modal goes with them.
|
|
2314
|
+
if (msg.closest('.chat-log') || msg.closest('#flash-toasts')) return;
|
|
2315
|
+
const inModal = msg.closest('#editor-modal');
|
|
2316
|
+
if (inModal && !msg.classList.contains('flash-now')) return;
|
|
2317
|
+
if (inModal) {
|
|
2318
|
+
try { window.UIkit.modal('#editor-modal').hide(); } catch (_) { /* modal already gone */ }
|
|
2319
|
+
}
|
|
2320
|
+
if (!msg.textContent.trim()) return;
|
|
2321
|
+
msg.classList.add('flash-toast');
|
|
2322
|
+
const isError = msg.classList.contains('error');
|
|
2323
|
+
const close = document.createElement('button');
|
|
2324
|
+
close.type = 'button';
|
|
2325
|
+
close.className = 'flash-toast-close';
|
|
2326
|
+
close.title = 'Dismiss';
|
|
2327
|
+
close.setAttribute('aria-label', 'Dismiss');
|
|
2328
|
+
close.innerHTML = '<span uk-icon="icon: close; ratio: .7"></span>';
|
|
2329
|
+
msg.appendChild(close);
|
|
2330
|
+
stack.appendChild(msg);
|
|
2331
|
+
const ms = isError ? FLASH_MS.error : FLASH_MS.assistant;
|
|
2332
|
+
// The countdown bar is drawn from this, so the animation and the timer are the same number.
|
|
2333
|
+
msg.style.setProperty('--flash-ms', `${ms}ms`);
|
|
2334
|
+
// Work that runs in a detached session changes what the page lists — a built project
|
|
2335
|
+
// gains a row, a removed one loses it — and the page that started it never hears about
|
|
2336
|
+
// it. So the toast that announced the start also asks for a reload as it leaves: by
|
|
2337
|
+
// then the row exists, and the reader has had the six seconds to read the message.
|
|
2338
|
+
const done = msg.dataset.reloadOnClose ? () => { msg.remove(); reloadForChanges(); } : () => msg.remove();
|
|
2339
|
+
let timer = setTimeout(done, ms);
|
|
2340
|
+
// Reading a long message must not race the timer.
|
|
2341
|
+
msg.addEventListener('mouseenter', () => { clearTimeout(timer); msg.classList.add('is-held'); });
|
|
2342
|
+
msg.addEventListener('mouseleave', () => {
|
|
2343
|
+
msg.classList.remove('is-held');
|
|
2344
|
+
timer = setTimeout(done, 2500);
|
|
2345
|
+
});
|
|
2346
|
+
});
|
|
2347
|
+
}
|
|
2348
|
+
|
|
2349
|
+
// A reload is not free: it discards whatever is half-typed and any menu that is open. So it
|
|
2350
|
+
// waits for a moment when it costs nothing — nobody typing, no modal, nothing in flight — and
|
|
2351
|
+
// gives up rather than fighting the user for the page. Whatever it skips, the next navigation
|
|
2352
|
+
// shows anyway.
|
|
2353
|
+
function reloadForChanges() {
|
|
2354
|
+
const held = () => {
|
|
2355
|
+
const el = document.activeElement;
|
|
2356
|
+
if (el && /^(INPUT|TEXTAREA|SELECT)$/.test(el.tagName) && el.type !== 'submit') return 'a field has focus';
|
|
2357
|
+
if (document.querySelector('#editor-modal.uk-open')) return 'the modal is open';
|
|
2358
|
+
if (document.documentElement.classList.contains('htmx-busy')) return 'a request is in flight';
|
|
2359
|
+
if (document.hidden) return 'the tab is in the background';
|
|
2360
|
+
return '';
|
|
2361
|
+
};
|
|
2362
|
+
let tries = 0;
|
|
2363
|
+
const attempt = () => {
|
|
2364
|
+
if (!held()) { location.reload(); return; }
|
|
2365
|
+
// Ten seconds of patience, then leave the page alone.
|
|
2366
|
+
if (++tries > 10) return;
|
|
2367
|
+
setTimeout(attempt, 1000);
|
|
2368
|
+
};
|
|
2369
|
+
attempt();
|
|
2370
|
+
}
|
|
2371
|
+
|
|
2372
|
+
document.body.addEventListener('htmx:after:swap', (e) => flashResults(swappedEl(e)));
|
|
2373
|
+
// An outerHTML swap replaces the element the event names, so the new message can land OUTSIDE
|
|
2374
|
+
// e.target and never reach the toast stack — which is why a saved card order sometimes showed no
|
|
2375
|
+
// confirmation at all. A document-wide pass once htmx has settled catches it; flashResults skips
|
|
2376
|
+
// anything already turned into a toast, so this cannot double up.
|
|
2377
|
+
document.body.addEventListener('htmx:after:swap', () => flashResults(document));
|
|
2378
|
+
|
|
2379
|
+
document.addEventListener('click', (e) => {
|
|
2380
|
+
const close = e.target.closest('.flash-toast-close');
|
|
2381
|
+
if (!close) return;
|
|
2382
|
+
const toast = close.closest('.flash-toast');
|
|
2383
|
+
const reload = !!toast.dataset.reloadOnClose;
|
|
2384
|
+
toast.remove();
|
|
2385
|
+
// Dismissing early is still the toast closing, so the page still catches up. Someone who
|
|
2386
|
+
// clicks the × after starting a build wants it gone, not a stale list.
|
|
2387
|
+
if (reload) reloadForChanges();
|
|
2388
|
+
});
|
|
2389
|
+
|
|
2390
|
+
// Commands → Buttons: a button carries the row it sits in. Dragged to another row, or moved with its
|
|
2391
|
+
// select, its hidden row field follows — so what is saved is what is on the screen.
|
|
2392
|
+
function syncRowButtons() {
|
|
2393
|
+
document.querySelectorAll('.row-buttons[data-row]').forEach((list) => {
|
|
2394
|
+
list.querySelectorAll(':scope > .row-button').forEach((li) => {
|
|
2395
|
+
const input = li.querySelector(':scope > .row-of');
|
|
2396
|
+
const select = li.querySelector(':scope > .row-move');
|
|
2397
|
+
if (input) input.value = list.dataset.row;
|
|
2398
|
+
if (select) select.value = list.dataset.row;
|
|
2399
|
+
});
|
|
2400
|
+
});
|
|
2401
|
+
// …and a group dragged to another column of its menu carries that column.
|
|
2402
|
+
document.querySelectorAll('.row-groups[data-col]').forEach((list) => {
|
|
2403
|
+
list.querySelectorAll(':scope > .row-group > .gcol-of').forEach((input) => {
|
|
2404
|
+
input.value = `${input.dataset.key}\t${list.dataset.col}`;
|
|
2405
|
+
});
|
|
2406
|
+
});
|
|
2407
|
+
}
|
|
2408
|
+
['moved', 'added'].forEach((name) => document.body.addEventListener(name, (e) => {
|
|
2409
|
+
if (e.target && e.target.closest && e.target.closest('.row-buttons, .row-groups')) syncRowButtons();
|
|
2410
|
+
}));
|
|
2411
|
+
// Commands → Buttons keeps your place. Every change on the Rows tab answers with the editor re-rendered,
|
|
2412
|
+
// which used to close every card and jump to the top: the open cards and the scroll position are carried over.
|
|
2413
|
+
// htmx 4 names: before:swap comes from the element that made the request (a form or button inside the
|
|
2414
|
+
// editor), after:swap names what was swapped — swappedEl() — so the place is put back only when that was the
|
|
2415
|
+
// editor itself, and forgotten after any smaller swap inside it (Definition's Check report).
|
|
2416
|
+
let rowEditorPlace = null;
|
|
2417
|
+
document.body.addEventListener('htmx:before:swap', (e) => {
|
|
2418
|
+
const from = e.target && e.target.closest ? e.target.closest('#row-actions-editor') : null;
|
|
2419
|
+
if (!from && swappedEl(e).id !== 'row-actions-editor') return;
|
|
2420
|
+
rowEditorPlace = {
|
|
2421
|
+
buttons: [...document.querySelectorAll('.row-button > details.row-button-body[open]')].map((d) => d.parentElement.dataset.button),
|
|
2422
|
+
y: window.scrollY,
|
|
2423
|
+
};
|
|
2424
|
+
});
|
|
2425
|
+
document.body.addEventListener('htmx:after:swap', (e) => {
|
|
2426
|
+
if (!rowEditorPlace) return;
|
|
2427
|
+
if (swappedEl(e).id !== 'row-actions-editor') { rowEditorPlace = null; return; }
|
|
2428
|
+
const place = rowEditorPlace;
|
|
2429
|
+
rowEditorPlace = null;
|
|
2430
|
+
place.buttons.forEach((id) => {
|
|
2431
|
+
const d = document.querySelector(`.row-button[data-button="${CSS.escape(id)}"] > details.row-button-body`);
|
|
2432
|
+
if (d) d.open = true;
|
|
2433
|
+
});
|
|
2434
|
+
window.scrollTo(0, place.y);
|
|
2435
|
+
});
|
|
2436
|
+
|
|
2437
|
+
// …marks the Rows form as changed, so its save bar can say so and the page can ask before losing it. The
|
|
2438
|
+
// add-an-action and edit fields are left out: they have their own buttons, and saving the rows ignores them.
|
|
2439
|
+
function markRowsChanged(el) {
|
|
2440
|
+
const form = el && el.closest && el.closest('#row-arrange-form');
|
|
2441
|
+
if (!form) return;
|
|
2442
|
+
if (el.name && /^(add|edit)_/.test(el.name)) return;
|
|
2443
|
+
form.dataset.dirty = '1';
|
|
2444
|
+
}
|
|
2445
|
+
['input', 'change'].forEach((name) => document.addEventListener(name, (e) => markRowsChanged(e.target)));
|
|
2446
|
+
['moved', 'added'].forEach((name) => document.body.addEventListener(name, (e) => markRowsChanged(e.target)));
|
|
2447
|
+
document.addEventListener('click', (e) => {
|
|
2448
|
+
if (e.target.closest && e.target.closest('#row-arrange-form .ws-move button')) markRowsChanged(e.target);
|
|
2449
|
+
});
|
|
2450
|
+
window.addEventListener('beforeunload', (e) => {
|
|
2451
|
+
if (!document.querySelector('#row-arrange-form[data-dirty="1"]')) return;
|
|
2452
|
+
e.preventDefault();
|
|
2453
|
+
e.returnValue = '';
|
|
2454
|
+
});
|
|
2455
|
+
|
|
2456
|
+
document.addEventListener('change', (e) => {
|
|
2457
|
+
const select = e.target && e.target.closest && e.target.closest('.row-move');
|
|
2458
|
+
if (!select) return;
|
|
2459
|
+
const li = select.closest('.row-button');
|
|
2460
|
+
const target = document.querySelector(`.row-buttons[data-row="${CSS.escape(select.value)}"]`);
|
|
2461
|
+
if (li && target && li.parentNode !== target) target.appendChild(li);
|
|
2462
|
+
syncRowButtons();
|
|
2463
|
+
});
|
|
2464
|
+
|
|
2465
|
+
/* ---------------- the New screen form ------------------------------------- */
|
|
2466
|
+
|
|
2467
|
+
// Which fieldsets belong to which kind of session. The kind is a radio now rather than a select: three
|
|
2468
|
+
// named choices with a line each read better than three words in a dropdown, and the thing being chosen
|
|
2469
|
+
// changes what the rest of the form asks.
|
|
2470
|
+
window.screenKindChanged = function screenKindChanged(form) {
|
|
2471
|
+
if (!form) return;
|
|
2472
|
+
const kind = (form.querySelector('input[name="kind"]:checked') || {}).value || 'shell';
|
|
2473
|
+
for (const el of form.querySelectorAll('[data-claude-only]')) el.hidden = kind !== 'claude';
|
|
2474
|
+
for (const el of form.querySelectorAll('[data-not-claude]')) el.hidden = kind === 'claude';
|
|
2475
|
+
for (const label of form.querySelectorAll('.screen-kind')) {
|
|
2476
|
+
label.classList.toggle('is-on', (label.querySelector('input') || {}).checked === true);
|
|
2477
|
+
}
|
|
2478
|
+
};
|
|
2479
|
+
|
|
2480
|
+
// The session name follows where it runs, until somebody types their own. It is the first thing
|
|
2481
|
+
// `screen -ls` shows and what `screen -r` takes, so the answer is almost always the place — but a name
|
|
2482
|
+
// that overwrote what was typed would be worse than no default at all.
|
|
2483
|
+
window.screenNameFollowsTarget = function screenNameFollowsTarget(form) {
|
|
2484
|
+
if (!form) return;
|
|
2485
|
+
const field = form.querySelector('input[name="session"]');
|
|
2486
|
+
const target = form.querySelector('select[name="target"]');
|
|
2487
|
+
if (!field || !target) return;
|
|
2488
|
+
// The same shape the server fills in: where it runs, then a short id so a second session in the same
|
|
2489
|
+
// place is not refused as a name that already exists. The id is kept while only the place changes, so
|
|
2490
|
+
// watching the name settle does not look like a slot machine.
|
|
2491
|
+
const tail = field.dataset.tail || (field.dataset.tail = Math.random().toString(16).slice(2, 6));
|
|
2492
|
+
const value = String(target.value || '');
|
|
2493
|
+
const derived = (value === 'root'
|
|
2494
|
+
? `workspace-${tail}`
|
|
2495
|
+
: `${value.replace(/^p:([a-z0-9_-]+):(.+)$/, '$1--$2').replace(/^ws:/, '')}--${tail}`)
|
|
2496
|
+
.toLowerCase().replace(/[^a-z0-9._-]+/g, '-').replace(/-{3,}/g, '--').slice(0, 60);
|
|
2497
|
+
const path = (target.selectedOptions[0] || {}).dataset;
|
|
2498
|
+
const line = form.querySelector('.screen-where-path code');
|
|
2499
|
+
if (line && path && path.path) line.textContent = path.path;
|
|
2500
|
+
if (field.value && field.value !== field.dataset.derived) return; // typed by hand: leave it alone
|
|
2501
|
+
field.value = derived;
|
|
2502
|
+
field.dataset.derived = derived;
|
|
2503
|
+
};
|
|
2504
|
+
|
|
2505
|
+
// A prompt template fills the prompt and brings its own flags along, so the session runs the command
|
|
2506
|
+
// that prompt file documents. The flags land on the controls that own them rather than in a text box.
|
|
2507
|
+
window.screenPromptTemplate = function screenPromptTemplate(select) {
|
|
2508
|
+
const form = select.form;
|
|
2509
|
+
if (!select.value || !form) return;
|
|
2510
|
+
const flags = (select.selectedOptions[0].dataset.flags || '').trim();
|
|
2511
|
+
const tokens = flags.split(/\s+/).filter(Boolean);
|
|
2512
|
+
for (let i = 0; i < tokens.length; i += 1) {
|
|
2513
|
+
if (!tokens[i].startsWith('--')) continue;
|
|
2514
|
+
const field = form.querySelector(`[name="arg_${tokens[i].replace(/^--/, '').replace(/[^A-Za-z0-9]+/g, '_')}"]`);
|
|
2515
|
+
if (!field) continue;
|
|
2516
|
+
const next = tokens[i + 1];
|
|
2517
|
+
if (field.type === 'checkbox') { field.checked = true; continue; }
|
|
2518
|
+
if (next && !next.startsWith('--')) { field.value = next; i += 1; }
|
|
2519
|
+
}
|
|
2520
|
+
fetch(`/fragments/prompt-text/${encodeURIComponent(select.value)}`)
|
|
2521
|
+
.then((r) => r.text())
|
|
2522
|
+
.then((t) => {
|
|
2523
|
+
const text = t.trim();
|
|
2524
|
+
const field = form.querySelector('textarea[name="command"]');
|
|
2525
|
+
if (!field) return;
|
|
2526
|
+
field.value = text;
|
|
2527
|
+
if (field._aceEditor) { field._aceEditor.session.setValue(text); field._aceEditor.focus(); } else field.focus();
|
|
2528
|
+
});
|
|
2529
|
+
};
|
|
2530
|
+
|
|
2531
|
+
// The form arrives in the editor modal, and its "where it runs" half is swapped again whenever the
|
|
2532
|
+
// workspace changes — so both the kind toggle and the name default are settled here rather than from an
|
|
2533
|
+
// inline handler on an element the swap may have replaced.
|
|
2534
|
+
document.body.addEventListener('htmx:after:settle', (e) => {
|
|
2535
|
+
const el = e.target;
|
|
2536
|
+
if (!el || !el.querySelector) return;
|
|
2537
|
+
const kinds = el.querySelector('form .screen-kinds') || (el.closest && el.closest('form') && el.matches('.screen-kinds') ? el : null);
|
|
2538
|
+
if (kinds) window.screenKindChanged(kinds.closest('form'));
|
|
2539
|
+
const where = el.id === 'screen-where' ? el : el.querySelector('#screen-where');
|
|
2540
|
+
if (where && where.closest('form')) window.screenNameFollowsTarget(where.closest('form'));
|
|
2541
|
+
});
|
|
2542
|
+
|
|
2543
|
+
// The Screens page: how many sessions across.
|
|
2544
|
+
//
|
|
2545
|
+
// One under another by DEFAULT, and that is the point of the default rather than an accident of CSS: a
|
|
2546
|
+
// session row carries its name, its state, its log path and five verbs, and two of those side by side
|
|
2547
|
+
// on a 1400px screen leaves each narrower than the buttons it holds. Two and three across are for a
|
|
2548
|
+
// machine with a dozen sessions, where the question is "which of these is waiting" rather than "what
|
|
2549
|
+
// does this one say".
|
|
2550
|
+
//
|
|
2551
|
+
// Re-applied after every swap because the list refreshes itself every 20 seconds — a class set once
|
|
2552
|
+
// would last exactly one poll. Kept per browser, like the theme and the page width.
|
|
2553
|
+
(function screensColumns() {
|
|
2554
|
+
const KEY = 'ws-screens-cols';
|
|
2555
|
+
const read = () => {
|
|
2556
|
+
try { return ['1', '2', '3'].includes(localStorage.getItem(KEY)) ? localStorage.getItem(KEY) : '1'; }
|
|
2557
|
+
catch (_) { return '1'; }
|
|
2558
|
+
};
|
|
2559
|
+
function apply() {
|
|
2560
|
+
const list = document.querySelector('.screens-list');
|
|
2561
|
+
// The modal only wears the count while it is actually holding the list: it is the one modal in the
|
|
2562
|
+
// dashboard, and an editor opened after the screens would otherwise inherit a width chosen for
|
|
2563
|
+
// three columns of session cards.
|
|
2564
|
+
const modal = document.getElementById('editor-modal');
|
|
2565
|
+
if (modal) {
|
|
2566
|
+
if (modal.contains(list)) modal.dataset.screensCols = read();
|
|
2567
|
+
else delete modal.dataset.screensCols;
|
|
2568
|
+
}
|
|
2569
|
+
if (!list) return;
|
|
2570
|
+
const cols = read();
|
|
2571
|
+
list.dataset.cols = cols;
|
|
2572
|
+
// The buttons only: the modal itself carries data-screens-cols for its width, and as a match it was
|
|
2573
|
+
// being given the primary button's class, which painted the whole overlay brand blue.
|
|
2574
|
+
document.querySelectorAll('button[data-screens-cols]').forEach((b) => {
|
|
2575
|
+
const on = b.dataset.screensCols === cols;
|
|
2576
|
+
b.classList.toggle('uk-button-primary', on);
|
|
2577
|
+
b.classList.toggle('uk-button-default', !on);
|
|
2578
|
+
b.setAttribute('aria-pressed', on ? 'true' : 'false');
|
|
2579
|
+
});
|
|
2580
|
+
}
|
|
2581
|
+
document.addEventListener('click', (e) => {
|
|
2582
|
+
const btn = e.target.closest('[data-screens-cols]');
|
|
2583
|
+
if (!btn) return;
|
|
2584
|
+
e.preventDefault();
|
|
2585
|
+
try { localStorage.setItem(KEY, btn.dataset.screensCols); } catch (_) { /* private window */ }
|
|
2586
|
+
apply();
|
|
2587
|
+
});
|
|
2588
|
+
document.addEventListener('DOMContentLoaded', apply);
|
|
2589
|
+
document.body.addEventListener('htmx:after:swap', apply);
|
|
2590
|
+
apply();
|
|
2591
|
+
})();
|
|
2592
|
+
|
|
2593
|
+
/* ---------------- the name the Build form proposes ---------------- */
|
|
2594
|
+
//
|
|
2595
|
+
// Choosing a builder fills the name field with something derived from what it builds, numbered past
|
|
2596
|
+
// what is already in that folder — `byte1`, then `byte2` — because a form that says only
|
|
2597
|
+
// `new-project-name` is how a tree ends up holding `my-drupal-site`, `my-drupal-site-pulse` and
|
|
2598
|
+
// `my-drupal-site-pulse2`.
|
|
2599
|
+
//
|
|
2600
|
+
// The one rule: a name somebody TYPED is never overwritten. The field is filled only while it is
|
|
2601
|
+
// empty, or while it still holds the exact suggestion this code last put there — so changing the
|
|
2602
|
+
// dropdown re-proposes, and changing it after typing leaves the typing alone.
|
|
2603
|
+
(function () {
|
|
2604
|
+
let lastSuggested = '';
|
|
2605
|
+
document.body.addEventListener('suggest-project-name', (e) => {
|
|
2606
|
+
const name = typeof e.detail === 'string' ? e.detail : (e.detail && e.detail.value);
|
|
2607
|
+
if (!name) return;
|
|
2608
|
+
document.querySelectorAll('input[data-suggest-name]').forEach((input) => {
|
|
2609
|
+
const now = input.value.trim();
|
|
2610
|
+
if (now !== '' && now !== lastSuggested) return; // theirs, not ours
|
|
2611
|
+
input.value = name;
|
|
2612
|
+
});
|
|
2613
|
+
lastSuggested = name;
|
|
2614
|
+
});
|
|
2615
|
+
})();
|
|
2616
|
+
|
|
2617
|
+
// Who is signed in, when the dashboard asks for Google sign-in: the name and a way out. With sign-in off
|
|
2618
|
+
// /auth/me says so and nothing is drawn.
|
|
2619
|
+
(function () {
|
|
2620
|
+
var slot = document.getElementById('auth-user');
|
|
2621
|
+
if (!slot || !window.fetch) return;
|
|
2622
|
+
fetch('/auth/me', { credentials: 'same-origin' }).then(function (r) { return r.ok ? r.json() : null; }).then(function (u) {
|
|
2623
|
+
if (!u || !u.enabled || !u.email) return;
|
|
2624
|
+
slot.textContent = '';
|
|
2625
|
+
var who = document.createElement('span');
|
|
2626
|
+
who.textContent = u.name || u.email;
|
|
2627
|
+
who.title = u.email;
|
|
2628
|
+
var out = document.createElement('a');
|
|
2629
|
+
out.href = '/auth/signout';
|
|
2630
|
+
out.textContent = 'Sign out';
|
|
2631
|
+
out.className = 'uk-margin-small-left';
|
|
2632
|
+
slot.appendChild(who);
|
|
2633
|
+
slot.appendChild(out);
|
|
2634
|
+
slot.hidden = false;
|
|
2635
|
+
}).catch(function () {});
|
|
2636
|
+
})();
|
|
2637
|
+
|
|
2638
|
+
// The image viewer: fit or actual size, and the arrow keys step through the list it was opened from.
|
|
2639
|
+
document.addEventListener('click', (e) => {
|
|
2640
|
+
const btn = e.target && e.target.closest ? e.target.closest('[data-img-zoom]') : null;
|
|
2641
|
+
if (!btn) return;
|
|
2642
|
+
const stage = btn.closest('[data-img-viewer]') && btn.closest('[data-img-viewer]').querySelector('[data-img-stage]');
|
|
2643
|
+
if (!stage) return;
|
|
2644
|
+
const fit = stage.classList.toggle('is-fit');
|
|
2645
|
+
btn.textContent = fit ? 'Actual size' : 'Fit';
|
|
2646
|
+
});
|
|
2647
|
+
document.addEventListener('keydown', (e) => {
|
|
2648
|
+
if (e.key !== 'ArrowLeft' && e.key !== 'ArrowRight') return;
|
|
2649
|
+
const viewer = document.querySelector('#editor-modal.uk-open [data-img-viewer]');
|
|
2650
|
+
if (!viewer || e.target.closest('input, textarea, select, [contenteditable]')) return;
|
|
2651
|
+
const btn = viewer.querySelector(e.key === 'ArrowLeft' ? '[data-img-prev]' : '[data-img-next]');
|
|
2652
|
+
if (!btn) return;
|
|
2653
|
+
e.preventDefault();
|
|
2654
|
+
btn.click();
|
|
2655
|
+
});
|
|
2656
|
+
|
|
2657
|
+
// Upload images: each file is its own request with its bytes as the body, then the gallery refreshes.
|
|
2658
|
+
async function uploadImages(box, files) {
|
|
2659
|
+
const log = box.querySelector('[data-img-upload-log]');
|
|
2660
|
+
const folder = (box.querySelector('[data-img-upload-folder]') || {}).value || '';
|
|
2661
|
+
for (const f of files) {
|
|
2662
|
+
const li = document.createElement('li');
|
|
2663
|
+
li.textContent = `${f.name}…`;
|
|
2664
|
+
log.appendChild(li);
|
|
2665
|
+
try {
|
|
2666
|
+
const r = await fetch(`/actions/image-upload?path=${encodeURIComponent(folder)}&name=${encodeURIComponent(f.name)}`,
|
|
2667
|
+
{ method: 'POST', body: f, headers: { 'Content-Type': f.type || 'application/octet-stream' } });
|
|
2668
|
+
li.innerHTML = await r.text();
|
|
2669
|
+
} catch (err) {
|
|
2670
|
+
li.textContent = `${f.name}: ${err.message}`;
|
|
2671
|
+
}
|
|
2672
|
+
}
|
|
2673
|
+
if (window.htmx) htmx.trigger(document.body, 'refresh-projects');
|
|
2674
|
+
}
|
|
2675
|
+
document.addEventListener('change', (e) => {
|
|
2676
|
+
const input = e.target && e.target.matches && e.target.matches('[data-img-upload-input]') ? e.target : null;
|
|
2677
|
+
if (input && input.files.length) uploadImages(input.closest('[data-img-upload]'), [...input.files]);
|
|
2678
|
+
});
|
|
2679
|
+
['dragover', 'dragleave', 'drop'].forEach((type) => document.addEventListener(type, (e) => {
|
|
2680
|
+
const zone = e.target && e.target.closest ? e.target.closest('[data-img-drop]') : null;
|
|
2681
|
+
if (!zone) return;
|
|
2682
|
+
e.preventDefault();
|
|
2683
|
+
zone.classList.toggle('is-over', type === 'dragover');
|
|
2684
|
+
if (type === 'drop' && e.dataTransfer.files.length) uploadImages(zone.closest('[data-img-upload]'), [...e.dataTransfer.files]);
|
|
2685
|
+
}));
|
|
2686
|
+
|
|
2687
|
+
// Choosing a DDEV quickstart fills the fields beside it; each can still be changed.
|
|
2688
|
+
document.addEventListener('change', (e) => {
|
|
2689
|
+
const sel = e.target && e.target.closest ? e.target.closest('select[data-ddev-preset]') : null;
|
|
2690
|
+
if (!sel) return;
|
|
2691
|
+
const o = sel.selectedOptions[0];
|
|
2692
|
+
const box = sel.closest('.ddev-options');
|
|
2693
|
+
if (!o || !o.value || !box) return;
|
|
2694
|
+
['project_type', 'php_version', 'database', 'webserver_type'].forEach((k) => {
|
|
2695
|
+
const f = box.querySelector(`[name="${k}"]`);
|
|
2696
|
+
const v = o.dataset[k.replace(/_([a-z])/g, (_, c) => c.toUpperCase())];
|
|
2697
|
+
if (f && v !== undefined) f.value = v;
|
|
2698
|
+
});
|
|
2699
|
+
const root = box.querySelector('[name="docroot"]');
|
|
2700
|
+
if (root) root.value = o.dataset.docroot === '__default' ? '' : (o.dataset.docroot || '');
|
|
2701
|
+
const omit = box.querySelector('[name="omit_db"]');
|
|
2702
|
+
if (omit) omit.checked = o.dataset.omitDb === '1';
|
|
2703
|
+
const py = box.querySelector('[name="python"]');
|
|
2704
|
+
if (py) py.checked = o.dataset.python === '1';
|
|
2705
|
+
});
|
|
2706
|
+
|
|
2707
|
+
// Deleting a workspace: Enter in a step's field answers that step instead of submitting the settings form around it.
|
|
2708
|
+
document.addEventListener('keydown', (e) => {
|
|
2709
|
+
const field = e.key === 'Enter' && e.target.closest && e.target.closest('.ws-delete-step input');
|
|
2710
|
+
if (!field) return;
|
|
2711
|
+
e.preventDefault();
|
|
2712
|
+
const go = field.closest('.ws-delete-step').querySelector('.ws-delete-go');
|
|
2713
|
+
if (go) go.click();
|
|
2714
|
+
});
|
|
2715
|
+
// Focus the step's field after the swap, and again once the confirm dialog has closed, which hands focus to the body.
|
|
2716
|
+
const focusWsDeleteStep = () => {
|
|
2717
|
+
const field = document.querySelector('.ws-delete-step input');
|
|
2718
|
+
if (field && document.activeElement !== field) field.focus();
|
|
2719
|
+
};
|
|
2720
|
+
document.body.addEventListener('htmx:after:swap', focusWsDeleteStep);
|
|
2721
|
+
document.addEventListener('hidden', (e) => { if (e.target && e.target.id === 'confirm-modal') setTimeout(focusWsDeleteStep, 0); }, true);
|