@songsid/agend 2.2.0-beta.2 → 2.2.0-beta.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/agent-cli.js +6 -2
- package/dist/agent-cli.js.map +1 -1
- package/dist/auth-api.js +2 -0
- package/dist/auth-api.js.map +1 -1
- package/dist/backend/claude-code.d.ts +39 -2
- package/dist/backend/claude-code.js +115 -31
- package/dist/backend/claude-code.js.map +1 -1
- package/dist/backend/credential-profile.d.ts +33 -3
- package/dist/backend/credential-profile.js +107 -5
- package/dist/backend/credential-profile.js.map +1 -1
- package/dist/backend/types.d.ts +1 -1
- package/dist/cache-service.d.ts +2 -0
- package/dist/cache-service.js +2 -1
- package/dist/cache-service.js.map +1 -1
- package/dist/channel/adapters/discord.d.ts +14 -1
- package/dist/channel/adapters/discord.js +42 -0
- package/dist/channel/adapters/discord.js.map +1 -1
- package/dist/channel/adapters/telegram.d.ts +18 -3
- package/dist/channel/adapters/telegram.js +66 -10
- package/dist/channel/adapters/telegram.js.map +1 -1
- package/dist/channel/telegram-markdown.d.ts +6 -0
- package/dist/channel/telegram-markdown.js +104 -0
- package/dist/channel/telegram-markdown.js.map +1 -0
- package/dist/channel/tool-router.js +5 -4
- package/dist/channel/tool-router.js.map +1 -1
- package/dist/channel/types.d.ts +27 -4
- package/dist/config-validator.js +10 -5
- package/dist/config-validator.js.map +1 -1
- package/dist/daemon.d.ts +108 -0
- package/dist/daemon.js +432 -32
- package/dist/daemon.js.map +1 -1
- package/dist/delivery-outbox.d.ts +29 -7
- package/dist/delivery-outbox.js +134 -16
- package/dist/delivery-outbox.js.map +1 -1
- package/dist/delivery-reconciliation.d.ts +18 -1
- package/dist/delivery-reconciliation.js +67 -2
- package/dist/delivery-reconciliation.js.map +1 -1
- package/dist/fleet-context.d.ts +1 -0
- package/dist/fleet-manager.d.ts +81 -4
- package/dist/fleet-manager.js +452 -85
- package/dist/fleet-manager.js.map +1 -1
- package/dist/general-knowledge/skills/credential-profiles/SKILL.md +43 -10
- package/dist/host-memory.d.ts +10 -0
- package/dist/host-memory.js +33 -1
- package/dist/host-memory.js.map +1 -1
- package/dist/instance-lifecycle.d.ts +25 -6
- package/dist/instance-lifecycle.js +73 -26
- package/dist/instance-lifecycle.js.map +1 -1
- package/dist/locale.js +48 -18
- package/dist/locale.js.map +1 -1
- package/dist/login-controller.d.ts +20 -3
- package/dist/login-controller.js +71 -19
- package/dist/login-controller.js.map +1 -1
- package/dist/login-flows.d.ts +5 -2
- package/dist/login-flows.js +5 -4
- package/dist/login-flows.js.map +1 -1
- package/dist/memory-pressure.d.ts +13 -1
- package/dist/memory-pressure.js +50 -10
- package/dist/memory-pressure.js.map +1 -1
- package/dist/outbound-handlers.d.ts +63 -0
- package/dist/outbound-handlers.js +121 -46
- package/dist/outbound-handlers.js.map +1 -1
- package/dist/outbound-schemas.d.ts +3 -2
- package/dist/outbound-schemas.js +3 -1
- package/dist/outbound-schemas.js.map +1 -1
- package/dist/public-web-gateway.js +8 -5
- package/dist/public-web-gateway.js.map +1 -1
- package/dist/settings-api.js +10 -4
- package/dist/settings-api.js.map +1 -1
- package/dist/settings-change.js +2 -2
- package/dist/spawn-gate.js +5 -1
- package/dist/spawn-gate.js.map +1 -1
- package/dist/spawn-hold-reply.d.ts +12 -0
- package/dist/spawn-hold-reply.js +39 -0
- package/dist/spawn-hold-reply.js.map +1 -0
- package/dist/tips.js +1 -1
- package/dist/tips.js.map +1 -1
- package/dist/topic-commands.js +9 -2
- package/dist/topic-commands.js.map +1 -1
- package/dist/transcript-delta-reader.d.ts +3 -3
- package/dist/transcript-delta-reader.js +8 -8
- package/dist/transcript-delta-reader.js.map +1 -1
- package/dist/turn-resume.d.ts +2 -0
- package/dist/turn-resume.js +1 -1
- package/dist/turn-resume.js.map +1 -1
- package/dist/types.d.ts +2 -0
- package/dist/ui/chat-mentions.js +63 -0
- package/dist/ui/chat-render.js +25 -0
- package/dist/ui/chat-store.js +1 -1
- package/dist/ui/chat-strings.js +12 -12
- package/dist/ui/chat-thread.js +48 -11
- package/dist/ui/panel-chat.js +20 -9
- package/dist/ui/panel-details.js +2 -0
- package/dist/ui/panel-fleet.js +51 -22
- package/dist/ui/panel-settings.js +18 -9
- package/dist/ui/preview-panel.js +48 -14
- package/dist/ui/preview.js +5 -1
- package/dist/ui/settings-strings.js +6 -2
- package/dist/ui/settings-token.js +1 -1
- package/dist/ui/shared/app-i18n.js +35 -13
- package/dist/ui/shared/app-prefs.js +249 -0
- package/dist/ui/shared/app-shell.js +17 -4
- package/dist/ui/shared/app-store.js +5 -1
- package/dist/ui/shared/app.css +41 -7
- package/dist/ui/shared/app.js +6 -1
- package/dist/ui/shared/header-tools.js +55 -5
- package/dist/ui/shared/instance-nav.js +131 -38
- package/dist/ui/shared/panel-view.js +6 -0
- package/dist/ui/shared/theme.js +39 -7
- package/dist/ui/shared/ui-icons.js +1 -0
- package/dist/ui/shared/usage-dialog.js +13 -4
- package/dist/ui/shared/view-strings.js +26 -2
- package/dist/unrecognised-prompt.d.ts +23 -0
- package/dist/unrecognised-prompt.js +74 -0
- package/dist/unrecognised-prompt.js.map +1 -0
- package/dist/view-api.js +5 -0
- package/dist/view-api.js.map +1 -1
- package/dist/web-api.d.ts +13 -1
- package/dist/web-api.js +218 -18
- package/dist/web-api.js.map +1 -1
- package/dist/web-auth.js +3 -2
- package/dist/web-auth.js.map +1 -1
- package/dist/web-mentions.d.ts +44 -0
- package/dist/web-mentions.js +75 -0
- package/dist/web-mentions.js.map +1 -0
- package/dist/web-prefs.d.ts +63 -0
- package/dist/web-prefs.js +263 -0
- package/dist/web-prefs.js.map +1 -0
- package/dist/web-session.js +1 -1
- package/dist/web-session.js.map +1 -1
- package/package.json +1 -1
|
@@ -8,10 +8,12 @@ import { ConfirmHost } from "./ui-confirm.js";
|
|
|
8
8
|
import { t, lang, setLang, onLang } from "./app-i18n.js";
|
|
9
9
|
import { appStore, createStore, useStore } from "./app-store.js";
|
|
10
10
|
import { navStore } from "./app-nav.js";
|
|
11
|
-
import { chatPath, viewPath, settingsPath, routeKey, NEEDS_PATH, detailsPath } from "./app-route.js";
|
|
11
|
+
import { chatPath, viewPath, settingsPath, routeKey, NEEDS_PATH, detailsPath, fleetPath } from "./app-route.js";
|
|
12
12
|
import { SessionMenu } from "./app-session.js";
|
|
13
|
+
import { savePref } from "./app-prefs.js";
|
|
13
14
|
import { ErrorState, Skeleton } from "./ui-states.js";
|
|
14
15
|
import { InstanceNav, RosterNav } from "./instance-nav.js";
|
|
16
|
+
import { UsageRail } from "./header-tools.js";
|
|
15
17
|
|
|
16
18
|
/** Shell state shared with panels: the phone drawer, the collapsed sidebar, extensions a panel module adds. */
|
|
17
19
|
export const shellStore = createStore({ drawer: false, collapsed: readCollapsed(), keyboard: false, dialog: null, side: null });
|
|
@@ -104,7 +106,8 @@ export function signInHref() {
|
|
|
104
106
|
function Sidebar({ route, onNewInstance, viewOnly }) {
|
|
105
107
|
const app = useStore(appStore);
|
|
106
108
|
const shell = useStore(shellStore);
|
|
107
|
-
|
|
109
|
+
// #1675: the Fleet panel is "Fleet overview"'s (one current row); the "Fleet" row is current on an instance's Details.
|
|
110
|
+
const on = (panel) => !!route && (panel === "fleet" ? route.panel === "details" : route.panel === panel);
|
|
108
111
|
const openInstance = openInstanceOf(route);
|
|
109
112
|
// A count badge is hidden at 0; its number is also in the link's accessible name.
|
|
110
113
|
const navLink = (panel, href, icon, label, count = 0) => html`<a class=${`side-row${on(panel) ? " active" : ""}`} href=${href}
|
|
@@ -126,6 +129,11 @@ function Sidebar({ route, onNewInstance, viewOnly }) {
|
|
|
126
129
|
</div>
|
|
127
130
|
</aside>`;
|
|
128
131
|
}
|
|
132
|
+
// #1675: the whole fleet, on its Org chart (#1389), from anywhere — top left, so at the top of a phone's drawer too. The
|
|
133
|
+
// "Fleet" row under it goes to the open instance's Details (#1523 N2); this one is the Fleet panel, any of its tabs.
|
|
134
|
+
const onFleet = !!route && route.panel === "fleet";
|
|
135
|
+
const overview = html`<a class=${`side-row side-overview${onFleet ? " active" : ""}`} href=${fleetPath("org")}
|
|
136
|
+
aria-current=${onFleet ? "page" : undefined} onClick=${closeDrawer}><${Icon} name="org" /><span class="grow">${t("app.fleetOverview")}</span></a>`;
|
|
129
137
|
return html`<aside id="sidebar" class="sidebar" aria-label=${t("app.menu")}>
|
|
130
138
|
<div class="side-head">
|
|
131
139
|
<a class="brand" href="/ui" onClick=${closeDrawer}>${t("app.brand")}</a>
|
|
@@ -133,6 +141,7 @@ function Sidebar({ route, onNewInstance, viewOnly }) {
|
|
|
133
141
|
<button type="button" class="icon-btn" onClick=${onNewInstance} aria-label=${t("app.newInstance")} title=${t("app.newInstance")}><${Icon} name="plus" /></button>
|
|
134
142
|
</div>
|
|
135
143
|
<nav class="side-nav" aria-label=${t("app.menu")}>
|
|
144
|
+
${overview}
|
|
136
145
|
${navLink("needs", NEEDS_PATH, "inbox", t("app.needsNav"), needsCount)}
|
|
137
146
|
${navLink("fleet", openInstance ? detailsPath(openInstance) : "/ui/fleet", "fleet", t("app.fleet"))}
|
|
138
147
|
${navLink("view", "/view", "view", t("app.view"))}
|
|
@@ -158,12 +167,15 @@ function ViewOnlyNav({ route }) {
|
|
|
158
167
|
export function DevicePrefs({ labels = false } = {}) {
|
|
159
168
|
const [theme, setTheme] = useState(typeof AgendTheme !== "undefined" ? AgendTheme.get() : "system");
|
|
160
169
|
const [l, setL] = useState(lang());
|
|
170
|
+
// #1665: what is in force, as it changes — another device's choice arriving while this is open is shown at once.
|
|
171
|
+
useEffect(() => (typeof AgendTheme !== "undefined" && typeof AgendTheme.onChange === "function" ? AgendTheme.onChange(setTheme) : undefined), []);
|
|
172
|
+
useEffect(() => onLang(setL), []);
|
|
161
173
|
return html`<div class=${`prefs${labels ? " labelled" : ""}`}>
|
|
162
174
|
<label class="pref"><${Icon} name="sun" size=${16} /><span class=${labels ? "pref-label" : "sr-only"}>${t("app.theme")}</span>
|
|
163
|
-
<select value=${theme} aria-label=${t("app.theme")} onChange=${(e) => { setTheme(e.target.value); if (typeof AgendTheme !== "undefined") AgendTheme.set(e.target.value); }}>
|
|
175
|
+
<select value=${theme} aria-label=${t("app.theme")} onChange=${(e) => { setTheme(e.target.value); if (typeof AgendTheme !== "undefined") AgendTheme.set(e.target.value); savePref("theme", e.target.value); }}>
|
|
164
176
|
<option value="system">${t("app.themeSystem")}</option><option value="light">${t("app.themeLight")}</option><option value="dark">${t("app.themeDark")}</option></select></label>
|
|
165
177
|
<label class="pref"><${Icon} name="globe" size=${16} /><span class=${labels ? "pref-label" : "sr-only"}>${t("app.language")}</span>
|
|
166
|
-
<select value=${l} aria-label=${t("app.language")} onChange=${(e) => { setL(e.target.value); setLang(e.target.value); }}>
|
|
178
|
+
<select value=${l} aria-label=${t("app.language")} onChange=${(e) => { setL(e.target.value); setLang(e.target.value); savePref("lang", e.target.value); }}>
|
|
167
179
|
<option value="en">English</option><option value="zh-TW">中文</option></select></label>
|
|
168
180
|
</div>`;
|
|
169
181
|
}
|
|
@@ -351,6 +363,7 @@ export function Shell({ panels, onNewInstance, viewOnly = false }) {
|
|
|
351
363
|
${viewOnly ? null : html`<${OperationLine} route=${nav.route} />`}
|
|
352
364
|
<${Outlet} route=${nav.route} seq=${nav.seq} panels=${panels} />
|
|
353
365
|
</main>
|
|
366
|
+
<${UsageRail} route=${nav.route} />
|
|
354
367
|
<${BottomTabs} route=${nav.route} viewOnly=${viewOnly} />
|
|
355
368
|
${shell.dialog ? html`<${shell.dialog.Component} ...${shell.dialog.props} onClose=${closeDialog} />` : null}
|
|
356
369
|
${viewOnly || (nav.route && nav.route.panel === "settings") ? null : html`<${PendingChanges} />`}
|
|
@@ -36,7 +36,11 @@ export function applyStatus(d) {
|
|
|
36
36
|
if (i.state === "awaiting_input") awaiting[i.name] = typeof i.interaction_summary === "string" ? i.interaction_summary : "";
|
|
37
37
|
}
|
|
38
38
|
appStore.set({ ready: true, instances: d.instances, uptime: d.uptime ?? 0, exec, awaiting,
|
|
39
|
-
connections: typeof d.connections === "number" ? d.connections : null
|
|
39
|
+
connections: typeof d.connections === "number" ? d.connections : null,
|
|
40
|
+
// #1665: the host preferences' revision (app-prefs.js re-reads them when it moves).
|
|
41
|
+
prefsRev: typeof d.prefs_rev === "number" ? d.prefs_rev : null,
|
|
42
|
+
// #1685: host memory pressure slowing or holding new agents ("elevated" | "critical"), else null.
|
|
43
|
+
hostMemory: d.host_memory === "elevated" || d.host_memory === "critical" ? d.host_memory : null });
|
|
40
44
|
}
|
|
41
45
|
|
|
42
46
|
/** One "activity" event: an instance started or stopped working. */
|
package/dist/ui/shared/app.css
CHANGED
|
@@ -320,6 +320,12 @@ html:has(.shell), html:has(.shell) body { overflow: hidden; }
|
|
|
320
320
|
.msg.user .meta { justify-content: flex-end; }
|
|
321
321
|
.msg.user .body { background: var(--user-bubble); border-radius: var(--r-xl); padding: 10px var(--s-4); }
|
|
322
322
|
.msg.agent { align-self: stretch; }
|
|
323
|
+
/* #1672: a status line (another instance's message, a daemon notice): muted plain text, no bubble; a long one folds. */
|
|
324
|
+
.msg.status { align-self: stretch; font-size: var(--fs-sm); color: var(--text-3); }
|
|
325
|
+
.msg.status .st-text { white-space: pre-wrap; border-left: 2px solid var(--border); padding-left: var(--s-3); }
|
|
326
|
+
.msg.status.folded .st-text { max-height: 9em; overflow: hidden; mask-image: linear-gradient(to bottom, #000 60%, transparent); -webkit-mask-image: linear-gradient(to bottom, #000 60%, transparent); }
|
|
327
|
+
.msg.status .st-more { border: 0; background: none; color: var(--text-2); cursor: pointer; font-size: var(--fs-xs); padding: var(--s-1) 0 0 var(--s-3); }
|
|
328
|
+
.msg.status .st-more:hover { color: var(--text); }
|
|
323
329
|
.msg-tools { display: flex; gap: var(--s-1); margin-top: var(--s-1); opacity: 0; transition: opacity var(--t-fast); }
|
|
324
330
|
.msg:hover .msg-tools, .msg:focus-within .msg-tools { opacity: 1; }
|
|
325
331
|
@media (hover: none) { .msg-tools { opacity: 1; } }
|
|
@@ -589,6 +595,24 @@ html.lb-open .scroller { overflow: hidden; }
|
|
|
589
595
|
.caret.folded { transform: rotate(-90deg); }
|
|
590
596
|
.caret.flipped { transform: rotate(180deg); } /* the card's toggle: up when closed (it opens upwards) */
|
|
591
597
|
.v-inst { align-items: center; }
|
|
598
|
+
/* #1664: favorites — the star after a row (on hover or focus on a desktop, always when starred; on a phone in Edit) */
|
|
599
|
+
.inst-head { display: flex; align-items: center; justify-content: space-between; gap: var(--s-2); }
|
|
600
|
+
.inst-head .side-label { flex: 1; min-width: 0; }
|
|
601
|
+
.fav-edit { margin-right: var(--s-2); display: inline-flex; align-items: center; gap: var(--s-1); }
|
|
602
|
+
.v-li { display: flex; align-items: center; gap: 2px; }
|
|
603
|
+
.v-li > .inst { flex: 1; min-width: 0; }
|
|
604
|
+
.inst-star, .inst-move { flex: none; width: 28px; height: 28px; padding: 0; color: var(--text-3); }
|
|
605
|
+
.inst-star.on { color: var(--warn); }
|
|
606
|
+
.inst-star.on svg, .fav-mark { fill: currentColor; }
|
|
607
|
+
.fav-head { cursor: default; }
|
|
608
|
+
.fav-mark { color: var(--warn); }
|
|
609
|
+
.inst-nav:not(.fav-editing) .inst-star:not(.on) { visibility: hidden; }
|
|
610
|
+
/* As specific as the rule that hides it (and later), so hover and focus win. */
|
|
611
|
+
@media (hover: hover) { .inst-nav:not(.fav-editing) .v-li:hover .inst-star, .inst-nav:not(.fav-editing) .v-li:focus-within .inst-star { visibility: visible; } }
|
|
612
|
+
@media (hover: none) { .inst-nav:not(.fav-editing) .inst-star { display: none; } }
|
|
613
|
+
/* Edit: room for ↑ ↓ ☆ — the context and CLI marks step aside so the name keeps its width. */
|
|
614
|
+
.fav-editing .v-li .v-meta, .fav-editing .v-li .cli-icon { display: none; }
|
|
615
|
+
@media (max-width: 899px) { .fav-editing .inst-star, .fav-editing .inst-move { width: 36px; height: 36px; } }
|
|
592
616
|
.v-inst .dot { margin-top: 0; }
|
|
593
617
|
.v-meta { color: var(--text-3); font-size: var(--fs-xs); font-variant-numeric: tabular-nums; }
|
|
594
618
|
.cli-icon { width: 10px; height: 10px; flex: 0 0 10px; border-radius: 50%; border: 1px solid var(--border-strong); }
|
|
@@ -604,15 +628,16 @@ html.lb-open .scroller { overflow: hidden; }
|
|
|
604
628
|
.v-filter-box input::placeholder { color: var(--text-3); }
|
|
605
629
|
.v-filter-clear { width: 28px; height: 28px; }
|
|
606
630
|
.v-filter .note { padding: var(--s-1) var(--s-1) 0; font-variant-numeric: tabular-nums; }
|
|
607
|
-
/* alpha.2 (N1): status and CLI chips under the filter box — each a
|
|
631
|
+
/* alpha.2 (N1): status and CLI chips under the filter box — each a group of checkboxes under its header (#1700). */
|
|
608
632
|
.nav-facets { display: flex; flex-wrap: wrap; align-items: flex-start; gap: var(--s-1) var(--s-2); padding: var(--s-2) var(--s-1) 0; }
|
|
609
633
|
.nav-facet { font-size: var(--fs-xs); color: var(--text-2); }
|
|
610
|
-
|
|
611
|
-
.nav-facet
|
|
612
|
-
|
|
613
|
-
.nav-facet
|
|
614
|
-
.nav-facet
|
|
615
|
-
.nav-facet
|
|
634
|
+
/* #1700: a facet group's header — the whole row toggles its list; folded with a filter set, a short summary. */
|
|
635
|
+
.nav-facet-head { display: inline-flex; align-items: center; gap: var(--s-1); max-width: 100%; padding: 2px var(--s-2); border: 0; border-radius: var(--r-sm);
|
|
636
|
+
background: transparent; color: inherit; font: inherit; cursor: pointer; text-align: left; }
|
|
637
|
+
.nav-facet-head:hover { background: var(--hover); }
|
|
638
|
+
.nav-facet-head:focus-visible { outline: 2px solid var(--accent); }
|
|
639
|
+
.nav-facet-head .caret { color: var(--text-3); flex: none; }
|
|
640
|
+
.nav-facet-sum { min-width: 0; max-width: 12em; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--accent); }
|
|
616
641
|
.nav-facet-n { display: inline-block; min-width: 16px; padding: 0 4px; border-radius: 8px; background: var(--accent); color: var(--on-accent, #fff); text-align: center; font-variant-numeric: tabular-nums; }
|
|
617
642
|
.nav-facet-list { display: flex; flex-direction: column; gap: 2px; padding: var(--s-1) var(--s-2); }
|
|
618
643
|
.nav-chip { display: flex; align-items: center; gap: var(--s-2); cursor: pointer; color: var(--text); }
|
|
@@ -660,6 +685,12 @@ html.lb-open .scroller { overflow: hidden; }
|
|
|
660
685
|
.u-sub { color: var(--text-3); font-size: var(--fs-xs); font-variant-numeric: tabular-nums; }
|
|
661
686
|
.u-err { color: var(--warn); font-size: var(--fs-sm); }
|
|
662
687
|
.u-foot { display: flex; align-items: center; justify-content: space-between; gap: var(--s-2); }
|
|
688
|
+
/* #1673: the usage rail beside the page on a wide screen (header-tools.js UsageRail; shown only at min-width 1200px). */
|
|
689
|
+
.u-rail { width: 320px; flex-shrink: 0; display: flex; flex-direction: column; min-height: 0; border-left: 1px solid var(--border); background: var(--bg); }
|
|
690
|
+
.u-rail-head { display: flex; align-items: center; justify-content: space-between; gap: var(--s-2); padding: var(--s-3) var(--s-3) var(--s-2) var(--s-4); }
|
|
691
|
+
.u-rail-head h2 { margin: 0; font-size: var(--fs-md); font-weight: var(--fw-semibold); }
|
|
692
|
+
.u-rail-body { flex: 1; min-height: 0; overflow-y: auto; padding: 0 var(--s-3) var(--s-4); display: flex; flex-direction: column; gap: var(--s-3); }
|
|
693
|
+
@media (max-width: 1199px) { .u-rail { display: none; } }
|
|
663
694
|
@media (max-width: 899px) {
|
|
664
695
|
.hide-narrow { display: none; }
|
|
665
696
|
.v-card { padding-left: var(--s-3); padding-right: var(--s-3); }
|
|
@@ -959,3 +990,6 @@ strong.org-name { font-size: var(--fs-sm); color: var(--text); }
|
|
|
959
990
|
}
|
|
960
991
|
/* #1630: a Discord server emoji in a chat message, the size of the text around it. */
|
|
961
992
|
.md img.emoji { display: inline-block; width: 1.375em; height: 1.375em; object-fit: contain; vertical-align: -0.3em; margin: 0 0.05em; }
|
|
993
|
+
/* #1682: a mention, as Discord draws one — accent text on a tint of it; the id (or the Telegram handle) on hover. */
|
|
994
|
+
.md .mention { display: inline; padding: 0 0.2em; border-radius: var(--r-sm); color: var(--accent); background: var(--accent-dim);
|
|
995
|
+
font-weight: var(--fw-medium); white-space: nowrap; }
|
package/dist/ui/shared/app.js
CHANGED
|
@@ -5,6 +5,8 @@
|
|
|
5
5
|
// session, and so is View (panel-view.js, loaded on demand). The panels that need a session live under /ui/js/ and are
|
|
6
6
|
// reached only by the dynamic imports below, in "full" mode. A test walks the static imports over real HTTP and asserts
|
|
7
7
|
// none of them names /ui/.
|
|
8
|
+
// #1665 first: the host's preferences go into this browser's cache before any module below reads its key.
|
|
9
|
+
import { bootPrefs } from "./app-prefs.js";
|
|
8
10
|
import { html, render } from "./app-html.js";
|
|
9
11
|
import { Shell, handleKey, showDialog, closeDrawer, openDrawer, shellStore, retryable, setNewInstanceOpener } from "./app-shell.js";
|
|
10
12
|
import { installSwipe } from "./app-swipe.js";
|
|
@@ -15,11 +17,12 @@ import { chatPath } from "./app-route.js";
|
|
|
15
17
|
import { startNeedsNotifier } from "./app-needs.js";
|
|
16
18
|
import { toast } from "./ui-toast.js";
|
|
17
19
|
import { t } from "./app-i18n.js";
|
|
18
|
-
import { initTextSize } from "./header-tools.js";
|
|
20
|
+
import { initTextSize, initUsageRail } from "./header-tools.js";
|
|
19
21
|
|
|
20
22
|
const boot = document.body.dataset;
|
|
21
23
|
// #1523 N3: this device's text size, on the root before the first paint of any panel.
|
|
22
24
|
initTextSize();
|
|
25
|
+
initUsageRail();
|
|
23
26
|
// "full": a signed-in page. "view-only": an anonymous reader of /view under web.view_access: open — no stream, no
|
|
24
27
|
// session-only panel, nothing under /ui/ is ever imported or read (#1408 §3).
|
|
25
28
|
const mode = boot.mode === "view-only" ? "view-only" : "full";
|
|
@@ -79,6 +82,8 @@ if (mode === "full") {
|
|
|
79
82
|
panels.set("details", { load: () => loadDetails().then((m) => m.DetailsPanel) });
|
|
80
83
|
// #1386 §6.3: desktop notifications for new "Needs you" items, in any panel, while this signed-in page is open.
|
|
81
84
|
startNeedsNotifier({ open: (instance) => navigate(chatPath(instance)) });
|
|
85
|
+
// #1665: this device's first upload of its own choices (once), then another device's changes at each status frame.
|
|
86
|
+
bootPrefs();
|
|
82
87
|
onNewInstance = async () => {
|
|
83
88
|
closeDrawer(); // on a phone the dialog opens from the drawer: the drawer goes first
|
|
84
89
|
let m;
|
|
@@ -1,13 +1,15 @@
|
|
|
1
1
|
// #1523 N3 (§3.4, Q2 = A): the controls every page's header has, in one order on Chat, View, Details and Fleet —
|
|
2
2
|
// Aa text size, shared per device: S / M / L (View also keeps Fit, its own extra choice);
|
|
3
3
|
// ◔ AI usage, unless the fleet turned it off (web.usage_panel: false — said by the page itself, no probe read).
|
|
4
|
+
// #1673: on a wide screen it is a right rail beside the page (shown or hidden per device); ◔ toggles it. On a
|
|
5
|
+
// narrower one, ◔ opens the dialog.
|
|
4
6
|
// The size is one attribute on the root (data-text-size); CSS turns it into one custom property the chat, Details and
|
|
5
7
|
// Fleet read (#1300: no style attribute). View's terminal multiplies its fitted size by VIEW_SCALE, as its density did.
|
|
6
|
-
import { html, useState } from "./app-html.js";
|
|
8
|
+
import { html, useEffect, useState } from "./app-html.js";
|
|
7
9
|
import { t } from "./app-i18n.js";
|
|
8
10
|
import { createStore, useStore } from "./app-store.js";
|
|
9
11
|
import { Icon } from "./ui-icons.js";
|
|
10
|
-
import { UsageDialog } from "./usage-dialog.js";
|
|
12
|
+
import { UsageBody, UsageDialog } from "./usage-dialog.js";
|
|
11
13
|
|
|
12
14
|
export const TEXT_SIZE_KEY = "agend_text_size";
|
|
13
15
|
export const VIEW_FIT_KEY = "agend_view_fit";
|
|
@@ -73,16 +75,64 @@ export function usageOffered() {
|
|
|
73
75
|
try { return document.body && document.body.dataset ? document.body.dataset.usagePanel !== "0" : true; } catch { return true; }
|
|
74
76
|
}
|
|
75
77
|
|
|
78
|
+
// #1673: the rail. Wide enough for it beside the sidebar and the page: RAIL_MEDIA (followed as it changes). Shown or
|
|
79
|
+
// hidden is this device's choice (agend_usage_rail; "0" = hidden, else shown) — a layout choice, not synced (#1665).
|
|
80
|
+
export const RAIL_MEDIA = "(min-width: 1200px)";
|
|
81
|
+
export const RAIL_KEY = "agend_usage_rail";
|
|
82
|
+
/** The pages whose header has ◔ — the rail is theirs. */
|
|
83
|
+
export const RAIL_PANELS = ["chat", "view", "details", "fleet"];
|
|
84
|
+
export const usageRailStore = createStore({ open: true });
|
|
85
|
+
/** A page load: this device's choice. */
|
|
86
|
+
export function initUsageRail() { usageRailStore.set({ open: read(RAIL_KEY) !== "0" }); }
|
|
87
|
+
export function setUsageRail(open) { write(RAIL_KEY, open ? "1" : "0"); usageRailStore.set({ open: !!open }); }
|
|
88
|
+
const railQuery = () => { try { return typeof matchMedia === "function" ? matchMedia(RAIL_MEDIA) : null; } catch { return null; } };
|
|
89
|
+
/** Whether the screen is wide enough for the rail, now and as it changes. */
|
|
90
|
+
export function useRailWidth() {
|
|
91
|
+
const [wide, setWide] = useState(() => !!railQuery()?.matches);
|
|
92
|
+
useEffect(() => {
|
|
93
|
+
const q = railQuery();
|
|
94
|
+
if (!q) return undefined;
|
|
95
|
+
const on = () => setWide(!!q.matches);
|
|
96
|
+
on();
|
|
97
|
+
if (typeof q.addEventListener === "function") { q.addEventListener("change", on); return () => q.removeEventListener("change", on); }
|
|
98
|
+
if (typeof q.addListener === "function") { q.addListener(on); return () => q.removeListener(on); }
|
|
99
|
+
return undefined;
|
|
100
|
+
}, []);
|
|
101
|
+
return wide;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* The rail: beside the page on a wide screen, on the pages with ◔, while this device shows it. Its reads are its own
|
|
106
|
+
* lease: they go on across navigation and end when it is hidden, the screen narrows, or it goes.
|
|
107
|
+
*/
|
|
108
|
+
export function UsageRail({ route }) {
|
|
109
|
+
const wide = useRailWidth();
|
|
110
|
+
const { open } = useStore(usageRailStore);
|
|
111
|
+
if (!wide || !open || !route || !RAIL_PANELS.includes(route.panel) || !usageOffered()) return null;
|
|
112
|
+
return html`<aside class="u-rail" aria-label=${t("view.usage.title_plain")}>
|
|
113
|
+
<div class="u-rail-head"><h2>${t("view.usage.title")}</h2>
|
|
114
|
+
<button type="button" class="icon-btn" onClick=${() => setUsageRail(false)} aria-label=${t("app.usageHide")} title=${t("app.usageHide")}><${Icon} name="close" size=${16} /></button></div>
|
|
115
|
+
<div class="u-rail-body"><${UsageBody} owner="usage-rail" /></div>
|
|
116
|
+
</aside>`;
|
|
117
|
+
}
|
|
118
|
+
|
|
76
119
|
/**
|
|
77
|
-
* ◔: the usage dialog (usage-dialog.js). It belongs to the navigation that opened it (`navKey`, #1408 §4; #1563
|
|
120
|
+
* ◔: on a wide screen, shows or hides the rail (#1673); otherwise the usage dialog (usage-dialog.js). It belongs to the navigation that opened it (`navKey`, #1408 §4; #1563
|
|
78
121
|
* review): another route, the same route again, another tab of the panel or a language switch closes it, and its
|
|
79
122
|
* reads and timer end with it — the dialog's lease is that navigation's.
|
|
80
123
|
*/
|
|
81
124
|
export function UsageButton({ navKey }) {
|
|
82
125
|
const [openFor, setOpenFor] = useState(null);
|
|
126
|
+
const wide = useRailWidth();
|
|
127
|
+
const rail = useStore(usageRailStore);
|
|
128
|
+
// Widening ends a dialog opened on the narrower screen — closed for good, not hidden while wide: narrowing again
|
|
129
|
+
// (after the rail was hidden, or with this device's rail hidden) never brings it back (#1679 review).
|
|
130
|
+
useEffect(() => { if (wide) setOpenFor(null); }, [wide]);
|
|
83
131
|
if (!usageOffered()) return null;
|
|
84
|
-
const open = openFor !== null && openFor === navKey;
|
|
85
|
-
|
|
132
|
+
const open = !wide && openFor !== null && openFor === navKey;
|
|
133
|
+
const click = () => (wide ? setUsageRail(!rail.open) : setOpenFor(navKey));
|
|
134
|
+
return html`<button type="button" class="btn btn-ghost btn-sm hd-usage" onClick=${click} aria-label=${t("app.usage")} title=${t("app.usage")}
|
|
135
|
+
aria-pressed=${wide ? (rail.open ? "true" : "false") : undefined}>
|
|
86
136
|
<${Icon} name="chart" size=${16} /><span class="hide-narrow" aria-hidden="true">${t("app.usage")}</span></button>
|
|
87
137
|
${open ? html`<${UsageDialog} key=${navKey} owner=${navKey} onClose=${() => setOpenFor(null)} />` : null}`;
|
|
88
138
|
}
|
|
@@ -14,6 +14,7 @@ import { chatPath, detailsPath, viewPath } from "./app-route.js";
|
|
|
14
14
|
import { Icon } from "./ui-icons.js";
|
|
15
15
|
import { Skeleton } from "./ui-states.js";
|
|
16
16
|
import { viewStore } from "./view-roster-store.js";
|
|
17
|
+
import { onPrefsChanged, prefInForce, prefsWritable, savePref } from "./app-prefs.js";
|
|
17
18
|
import "./view-strings.js";
|
|
18
19
|
|
|
19
20
|
/** view.* text with named values. */
|
|
@@ -23,7 +24,6 @@ function tn(key, values = {}) {
|
|
|
23
24
|
return s;
|
|
24
25
|
}
|
|
25
26
|
const stored = (k) => { try { return localStorage.getItem(k); } catch { return null; } };
|
|
26
|
-
const store = (k, v) => { try { localStorage.setItem(k, v); } catch { /* this page only */ } };
|
|
27
27
|
/** A row's instance name: `name` (status frames) or `instance_name` (View's roster). */
|
|
28
28
|
const nameOf = (it) => (it && (it.name != null ? it.name : it.instance_name)) || "";
|
|
29
29
|
|
|
@@ -101,14 +101,24 @@ export function instanceTooltip(it) {
|
|
|
101
101
|
return lines.join("\n");
|
|
102
102
|
}
|
|
103
103
|
|
|
104
|
-
// ──
|
|
104
|
+
// ── The order (drag), folded groups and filter: host preferences since #1665 (nav.*), cached in this browser ──
|
|
105
105
|
const SIDEBAR_ORDER_KEY = "agend_view_sidebar_order"; // View's key since #999: everyone's arrangement carries over
|
|
106
106
|
const COLLAPSED_KEY = "agend_instance_collapsed";
|
|
107
107
|
const FILTER_KEY = "agend_instance_filter";
|
|
108
|
+
const FAVORITES_KEY = "agend_favorites"; // #1664
|
|
109
|
+
/**
|
|
110
|
+
* A synced sidebar preference, as the JSON its reader parses: the value in force in memory (app-prefs.js — this
|
|
111
|
+
* page's own change, else the host's; a full or blocked storage never brings an old one back), else this browser's copy.
|
|
112
|
+
*/
|
|
113
|
+
function inForce(prefKey, lsKey) {
|
|
114
|
+
const p = prefInForce(prefKey);
|
|
115
|
+
if (!p) return stored(lsKey);
|
|
116
|
+
return p.value === undefined ? null : JSON.stringify(p.value);
|
|
117
|
+
}
|
|
108
118
|
function readOrder() {
|
|
109
119
|
const groups = new Map(), insts = new Map();
|
|
110
120
|
try {
|
|
111
|
-
const rows = JSON.parse(
|
|
121
|
+
const rows = JSON.parse(inForce("nav.order", SIDEBAR_ORDER_KEY) || "[]");
|
|
112
122
|
if (Array.isArray(rows)) for (const row of rows) {
|
|
113
123
|
if (row && row.item_type === "group" && typeof row.item_name === "string") groups.set(row.item_name, Number(row.sort_index) || 0);
|
|
114
124
|
else if (row && row.item_type === "instance" && typeof row.item_name === "string") insts.set(row.item_name, Number(row.sort_index) || 0);
|
|
@@ -117,15 +127,18 @@ function readOrder() {
|
|
|
117
127
|
return { groups, insts };
|
|
118
128
|
}
|
|
119
129
|
function readCollapsed() {
|
|
120
|
-
try { const a = JSON.parse(
|
|
130
|
+
try { const a = JSON.parse(inForce("nav.collapsed", COLLAPSED_KEY) || "[]"); return new Set(Array.isArray(a) ? a.filter((x) => typeof x === "string") : []); } catch { return new Set(); }
|
|
121
131
|
}
|
|
122
132
|
const strings = (a) => (Array.isArray(a) ? a.filter((x) => typeof x === "string") : []);
|
|
123
133
|
function readFilter() {
|
|
124
134
|
try {
|
|
125
|
-
const f = JSON.parse(
|
|
135
|
+
const f = JSON.parse(inForce("nav.filter", FILTER_KEY) || "{}");
|
|
126
136
|
return { q: f && typeof f.q === "string" ? f.q : "", status: strings(f && f.status).filter((s) => STATUS_FACETS.includes(s)), cli: strings(f && f.cli) };
|
|
127
137
|
} catch { return { q: "", status: [], cli: [] }; }
|
|
128
138
|
}
|
|
139
|
+
function readFavorites() {
|
|
140
|
+
try { const a = JSON.parse(inForce("nav.favorites", FAVORITES_KEY) || "[]"); return Array.isArray(a) ? [...new Set(a.filter((x) => typeof x === "string"))] : []; } catch { return []; }
|
|
141
|
+
}
|
|
129
142
|
/** Display order: this browser's saved order first, then new groups/instances by tag and name. */
|
|
130
143
|
export function computeOrder(roster, order) {
|
|
131
144
|
const groups = new Map();
|
|
@@ -159,21 +172,58 @@ function moveIn(arr, moving, target, before) {
|
|
|
159
172
|
}
|
|
160
173
|
|
|
161
174
|
/** The list's state for the page's life: this browser's order, folded groups and filter (each also kept in storage). */
|
|
162
|
-
export const navStore = createStore({ order: readOrder(), collapsed: readCollapsed(), filter: readFilter() });
|
|
175
|
+
export const navStore = createStore({ order: readOrder(), collapsed: readCollapsed(), filter: readFilter(), favorites: readFavorites() });
|
|
163
176
|
/** Read them again from this browser's storage — what a page load does (the store above is made that way). */
|
|
164
|
-
export function loadNavPrefs() { navStore.set({ order: readOrder(), collapsed: readCollapsed(), filter: readFilter() }); }
|
|
177
|
+
export function loadNavPrefs() { navStore.set({ order: readOrder(), collapsed: readCollapsed(), filter: readFilter(), favorites: readFavorites() }); }
|
|
165
178
|
/** Change the filter ({ q?, status?, cli? }): the same on every page, and kept by this browser. */
|
|
166
179
|
export function setNavFilter(patch) {
|
|
167
180
|
const filter = { ...navStore.get().filter, ...patch };
|
|
168
181
|
navStore.set({ filter });
|
|
169
|
-
|
|
182
|
+
savePref("nav.filter", { q: filter.q, status: filter.status, cli: filter.cli });
|
|
183
|
+
}
|
|
184
|
+
function setCollapsed(collapsed) { navStore.set({ collapsed }); savePref("nav.collapsed", [...collapsed]); }
|
|
185
|
+
// Another device rearranged, folded or filtered: this page's list follows (its cache is already updated).
|
|
186
|
+
onPrefsChanged((keys) => { if (keys.has("nav.order") || keys.has("nav.collapsed") || keys.has("nav.filter") || keys.has("nav.favorites")) loadNavPrefs(); });
|
|
187
|
+
|
|
188
|
+
// ── #1664: favorites — starred instances, pinned at the top in the order they were starred (or moved) ──
|
|
189
|
+
export const MAX_FAVORITES = 50;
|
|
190
|
+
/** The favorites that are instances now (`known`: the names listed): what is shown, and the base of every change — so
|
|
191
|
+
* a deleted instance is dropped, never sent back to the host (which would refuse it). */
|
|
192
|
+
function liveFavorites(known) { return navStore.get().favorites.filter((n) => known.has(n)); }
|
|
193
|
+
function setFavorites(next) { navStore.set({ favorites: next }); savePref("nav.favorites", next); }
|
|
194
|
+
/** Star or unstar `name`. False: not done (the list is full). */
|
|
195
|
+
export function toggleFavorite(name, known) {
|
|
196
|
+
const cur = liveFavorites(known);
|
|
197
|
+
if (cur.includes(name)) { setFavorites(cur.filter((n) => n !== name)); return true; }
|
|
198
|
+
if (cur.length >= MAX_FAVORITES || !known.has(name)) return false;
|
|
199
|
+
setFavorites([...cur, name]);
|
|
200
|
+
return true;
|
|
170
201
|
}
|
|
171
|
-
|
|
202
|
+
/** Move a favorite one place up (-1) or down (+1). */
|
|
203
|
+
export function moveFavorite(name, delta, known) {
|
|
204
|
+
const cur = liveFavorites(known);
|
|
205
|
+
const from = cur.indexOf(name), to = from + delta;
|
|
206
|
+
if (from < 0 || to < 0 || to >= cur.length) return;
|
|
207
|
+
cur.splice(to, 0, cur.splice(from, 1)[0]);
|
|
208
|
+
setFavorites(cur);
|
|
209
|
+
}
|
|
210
|
+
// ── #1700: the filter's facet groups, open or folded — per device (localStorage; blocked or full storage: this page only) ──
|
|
211
|
+
const FACETS_KEY = "agend_nav_facets";
|
|
212
|
+
function readFacetsOpen() {
|
|
213
|
+
try {
|
|
214
|
+
const v = JSON.parse(stored(FACETS_KEY) || "{}");
|
|
215
|
+
return { status: !!(v && v.status === true), cli: !!(v && v.cli === true) };
|
|
216
|
+
} catch { return { status: false, cli: false }; }
|
|
217
|
+
}
|
|
218
|
+
function writeFacetsOpen(open) { try { localStorage.setItem(FACETS_KEY, JSON.stringify({ status: !!open.status, cli: !!open.cli })); } catch { /* this page only */ } }
|
|
219
|
+
|
|
220
|
+
/** The Favorites group's key among the tag groups: no tag can be it (a group name is never a control character). */
|
|
221
|
+
const FAV_GROUP = "\u0000favorites";
|
|
172
222
|
function persistOrder(groupNames, instByGroup) {
|
|
173
223
|
const rows = [];
|
|
174
224
|
groupNames.forEach((g, i) => rows.push({ item_type: "group", item_name: g, sort_index: i, group_name: null }));
|
|
175
225
|
for (const g of groupNames) (instByGroup.get(g) || []).forEach((n, j) => rows.push({ item_type: "instance", item_name: n, sort_index: j, group_name: g }));
|
|
176
|
-
|
|
226
|
+
savePref("nav.order", rows);
|
|
177
227
|
navStore.set({ order: { groups: new Map(groupNames.map((g, i) => [g, i])), insts: new Map(rows.filter((r) => r.item_type === "instance").map((r) => [r.item_name, r.sort_index])) } });
|
|
178
228
|
}
|
|
179
229
|
|
|
@@ -259,6 +309,12 @@ export function InstanceNav({ route, items, loaded = true, exec = {}, awaiting =
|
|
|
259
309
|
const filterRef = useRef(null);
|
|
260
310
|
const list = useRef(null);
|
|
261
311
|
const drag = useRef(null);
|
|
312
|
+
// #1700: which facet groups are open — this device's (screen layout, not a synced preference), read when the list mounts.
|
|
313
|
+
const [facetOpen, setFacetOpen] = useState(readFacetsOpen);
|
|
314
|
+
const toggleFacet = (key) => setFacetOpen((cur) => { const next = { ...cur, [key]: !cur[key] }; writeFacetsOpen(next); return next; });
|
|
315
|
+
// #1664: "☆ Edit" — the stars on every row (on a phone the only way to them) and ↑ ↓ on the favorites — only on the
|
|
316
|
+
// full list, as drag: in a filtered one the row above may not be the one above in the list (#1677 review).
|
|
317
|
+
const [editing, setEditing] = useState(false);
|
|
262
318
|
// Where a row goes: the view the page is in (#1523 §3.1). View → View; Details, or a Fleet tab (Q4 = A), → Details;
|
|
263
319
|
// everywhere else → Chat. The anonymous reader only ever has View.
|
|
264
320
|
const rowPath = viewOnly || (route && route.panel === "view") ? viewPath
|
|
@@ -269,8 +325,18 @@ export function InstanceNav({ route, items, loaded = true, exec = {}, awaiting =
|
|
|
269
325
|
const byName = new Map(items.map((it) => [nameOf(it), it]));
|
|
270
326
|
const facetOf = (name) => statusFacet(byName.get(name), own(exec, name) ? exec[name] : null, own(awaiting, name) ? awaiting[name] : null);
|
|
271
327
|
const { groupNames, instByGroup } = computeOrder(items, nav.order);
|
|
328
|
+
// #1664: the favorites that are instances now, as a group of their own on top; the other groups keep the rest, in
|
|
329
|
+
// their order. Changing them: a signed-in page that may write preferences (not the public link, not the anonymous
|
|
330
|
+
// reader — they still see the list).
|
|
331
|
+
const known = new Set(byName.keys());
|
|
332
|
+
const favs = nav.favorites.filter((n) => known.has(n));
|
|
333
|
+
const favSet = new Set(favs);
|
|
334
|
+
const canStar = !viewOnly && prefsWritable();
|
|
335
|
+
const shownGroups = favs.length ? [FAV_GROUP, ...groupNames] : groupNames;
|
|
336
|
+
const shownInsts = new Map([...instByGroup].map(([g, names]) => [g, names.filter((n) => !favSet.has(n))]));
|
|
337
|
+
if (favs.length) shownInsts.set(FAV_GROUP, favs);
|
|
272
338
|
const f = nav.filter;
|
|
273
|
-
const shown = filterSidebar(
|
|
339
|
+
const shown = filterSidebar(shownGroups, shownInsts, byName, f.q, backendLabel, { status: f.status, cli: f.cli, statusOf: facetOf });
|
|
274
340
|
const backends = [...new Set(items.map((it) => it.backend).filter((b) => typeof b === "string" && b))].sort((a, b) => backendLabel(a).localeCompare(backendLabel(b)));
|
|
275
341
|
// #1630: a display name two rows share would tell them apart from nothing — only then does the row keep its name too.
|
|
276
342
|
const labelCount = new Map();
|
|
@@ -281,7 +347,7 @@ export function InstanceNav({ route, items, loaded = true, exec = {}, awaiting =
|
|
|
281
347
|
// Drag to reorder: only the full list (a filtered subset would be ambiguous), groups among groups, instances within
|
|
282
348
|
// their group.
|
|
283
349
|
const dragProps = (item) => shown.active ? {} : {
|
|
284
|
-
draggable:
|
|
350
|
+
draggable: true,
|
|
285
351
|
onDragStart: (e) => { drag.current = item; e.currentTarget.classList.add("dragging"); try { e.dataTransfer.effectAllowed = "move"; e.dataTransfer.setData("text/plain", item.name); } catch { /* Firefox needs data */ } },
|
|
286
352
|
onDragEnd: (e) => { e.currentTarget.classList.remove("dragging"); drag.current = null; },
|
|
287
353
|
onDragOver: (e) => {
|
|
@@ -307,47 +373,74 @@ export function InstanceNav({ route, items, loaded = true, exec = {}, awaiting =
|
|
|
307
373
|
persistOrder(names, insts);
|
|
308
374
|
},
|
|
309
375
|
};
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
376
|
+
// #1700: a facet group (Status, CLI) folds under a real header button. Folded with a filter set, the header says so:
|
|
377
|
+
// one or two choices by name, more as "N selected" — a filter is never out of sight.
|
|
378
|
+
const chips = (key, values, label, text) => {
|
|
379
|
+
const open = !!facetOpen[key];
|
|
380
|
+
const sel = f[key];
|
|
381
|
+
const summary = !open && sel.length ? (sel.length <= 2 ? sel.map(text).join(", ") : tn("facetSelected", { n: sel.length })) : "";
|
|
382
|
+
const hint = tn(open ? "facetHide" : "facetShow", { group: label });
|
|
383
|
+
return html`<div class=${`nav-facet${open ? " open" : ""}`}>
|
|
384
|
+
<button type="button" class="nav-facet-head" aria-expanded=${open ? "true" : "false"} aria-controls=${`nav-facet-${key}`}
|
|
385
|
+
title=${summary ? `${hint} (${label} · ${summary})` : hint} onClick=${() => toggleFacet(key)}>
|
|
386
|
+
<${Icon} name="chevron" size=${12} cls=${open ? "caret" : "caret folded"} /><span class="nav-facet-label">${label}</span>
|
|
387
|
+
${summary ? html`<span class="nav-facet-sum">· ${summary}</span>` : sel.length ? html`<span class="nav-facet-n">${sel.length}</span>` : null}
|
|
388
|
+
</button>
|
|
389
|
+
${open ? html`<div id=${`nav-facet-${key}`} class="nav-facet-list" role="group" aria-label=${label}>${values.map((value) => html`<label key=${value} class="nav-chip">
|
|
390
|
+
<input type="checkbox" checked=${sel.includes(value)} onChange=${() => flip(key, value)} /><span>${text(value)}</span></label>`)}</div>` : null}
|
|
391
|
+
</div>`;
|
|
392
|
+
};
|
|
393
|
+
const row = (name, g, i, count) => {
|
|
394
|
+
const it = byName.get(name);
|
|
395
|
+
const label = labelOf(it);
|
|
396
|
+
// The instance name under a display name only when another row has the same display name.
|
|
397
|
+
const twin = label !== name && (labelCount.get(label) || 0) > 1 ? name : "";
|
|
398
|
+
const facet = facetOf(name);
|
|
399
|
+
const waiting = own(awaiting, name) ? awaiting[name] : null;
|
|
400
|
+
const on = name === active;
|
|
401
|
+
const fav = g === FAV_GROUP, starred = favSet.has(name);
|
|
402
|
+
const full = !starred && favs.length >= MAX_FAVORITES;
|
|
403
|
+
return html`<li key=${name} class="v-li"><a class=${`inst v-inst${on ? " active" : ""}`} href=${rowPath(name)} title=${instanceTooltip(it)}
|
|
404
|
+
aria-current=${on ? "page" : undefined} onClick=${onPick} ...${fav ? {} : dragProps({ type: "instance", name, group: g })}>
|
|
405
|
+
<${InstanceAvatar} it=${it} state=${dotState(it, own(exec, name) ? exec[name] : null, waiting)} />
|
|
406
|
+
<span class="inst-text"><span class="inst-name">${label}</span>
|
|
407
|
+
${twin || facet === "needs" ? html`<span class="inst-sub">${twin ? html`<span class="inst-alias inst-id">${twin}</span>` : null}
|
|
408
|
+
${facet === "needs" ? html`<span class="badge-await" title=${waiting || t("app.approxNote")}>${t("app.needsYou")}</span>` : null}</span>` : null}</span>
|
|
409
|
+
<span class="v-meta">${it.context_pct != null ? `${Math.round(it.context_pct)}%` : ""}</span>
|
|
410
|
+
<span class=${`cli-icon cli-${KNOWN_BACKENDS.has(it.backend) ? it.backend : "other"}`} title=${backendLabel(it.backend)} aria-label=${tn("tooltipBackend", { value: backendLabel(it.backend) })}></span>
|
|
411
|
+
</a>${fav && editing && canStar ? html`<button type="button" class="icon-btn inst-move" aria-label=${tn("favUp", { name: label })}
|
|
412
|
+
title=${shown.active ? tn("favFiltered") : tn("favUp", { name: label })} disabled=${shown.active || i === 0} onClick=${() => { if (!shown.active) moveFavorite(name, -1, known); }}><${Icon} name="up" size=${14} /></button><button type="button" class="icon-btn inst-move"
|
|
413
|
+
aria-label=${tn("favDown", { name: label })} title=${shown.active ? tn("favFiltered") : tn("favDown", { name: label })} disabled=${shown.active || i === count - 1}
|
|
414
|
+
onClick=${() => { if (!shown.active) moveFavorite(name, 1, known); }}><${Icon} name="down" size=${14} /></button>` : null}${canStar
|
|
415
|
+
? html`<button type="button" class=${`icon-btn inst-star${starred ? " on" : ""}`} aria-pressed=${starred ? "true" : "false"} disabled=${full}
|
|
416
|
+
aria-label=${tn(starred ? "favRemove" : "favAdd", { name: label })} title=${full ? tn("favFull", { max: MAX_FAVORITES }) : tn(starred ? "favRemove" : "favAdd", { name: label })}
|
|
417
|
+
onClick=${() => toggleFavorite(name, known)}><${Icon} name="star" size=${14} /></button>` : null}</li>`;
|
|
418
|
+
};
|
|
315
419
|
// The list scrolls; the filter under it stays in view (#999), so it sits outside the scrolling section.
|
|
316
|
-
return html`<div class
|
|
420
|
+
return html`<div class=${`view-roster inst-nav${editing && canStar ? " fav-editing" : ""}`}>
|
|
317
421
|
<div class="side-section" id="instanceList" ref=${list}>
|
|
318
|
-
<h2 class="side-label">${tn("instancesLabel")}</h2>
|
|
422
|
+
<div class="inst-head"><h2 class="side-label">${tn("instancesLabel")}</h2>
|
|
423
|
+
${canStar && items.length ? html`<button type="button" class="chip-btn fav-edit" aria-pressed=${editing ? "true" : "false"} onClick=${() => setEditing(!editing)}>
|
|
424
|
+
${editing ? tn("favDone") : html`<${Icon} name="star" size=${12} />${tn("favEdit")}`}</button>` : null}</div>
|
|
319
425
|
${!loaded ? html`<${Skeleton} lines=${4} />` : !items.length ? html`<p class="side-empty">${t("app.noInstances")}</p>` : null}
|
|
320
426
|
${shown.groups.map(({ group: g, names }) => {
|
|
427
|
+
// #1664: Favorites — always open, not dragged (↑ ↓ in Edit), named in the page's language.
|
|
428
|
+
if (g === FAV_GROUP) return html`<div key=${g} class="v-group v-fav" role="group" aria-label=${tn("favorites")}>
|
|
429
|
+
<div class="v-group-head fav-head"><${Icon} name="star" size=${14} cls="fav-mark" /><span class="grow">${tn("favorites")}</span><span class="count">${names.length}</span></div>
|
|
430
|
+
<ul class="inst-list">${names.map((name, i) => row(name, g, i, names.length))}</ul></div>`;
|
|
321
431
|
const folded = nav.collapsed.has(g);
|
|
322
432
|
return html`<div key=${g} class="v-group">
|
|
323
433
|
<button type="button" class="v-group-head" aria-expanded=${folded ? "false" : "true"} onClick=${() => toggle(g)}
|
|
324
434
|
title=${tn(folded ? "expandGroup" : "collapseGroup", { group: g })} ...${dragProps({ type: "group", name: g })}>
|
|
325
435
|
<${Icon} name="chevron" size=${14} cls=${folded ? "caret folded" : "caret"} /><span class="grow">${g}</span><span class="count">${names.length}</span></button>
|
|
326
|
-
${folded ? null : html`<ul class="inst-list">${names.map((name) =>
|
|
327
|
-
const it = byName.get(name);
|
|
328
|
-
const label = labelOf(it);
|
|
329
|
-
// The instance name under a display name only when another row has the same display name.
|
|
330
|
-
const twin = label !== name && (labelCount.get(label) || 0) > 1 ? name : "";
|
|
331
|
-
const facet = facetOf(name);
|
|
332
|
-
const waiting = own(awaiting, name) ? awaiting[name] : null;
|
|
333
|
-
const on = name === active;
|
|
334
|
-
return html`<li key=${name}><a class=${`inst v-inst${on ? " active" : ""}`} href=${rowPath(name)} title=${instanceTooltip(it)}
|
|
335
|
-
aria-current=${on ? "page" : undefined} onClick=${onPick} ...${dragProps({ type: "instance", name, group: g })}>
|
|
336
|
-
<${InstanceAvatar} it=${it} state=${dotState(it, own(exec, name) ? exec[name] : null, waiting)} />
|
|
337
|
-
<span class="inst-text"><span class="inst-name">${label}</span>
|
|
338
|
-
${twin || facet === "needs" ? html`<span class="inst-sub">${twin ? html`<span class="inst-alias inst-id">${twin}</span>` : null}
|
|
339
|
-
${facet === "needs" ? html`<span class="badge-await" title=${waiting || t("app.approxNote")}>${t("app.needsYou")}</span>` : null}</span>` : null}</span>
|
|
340
|
-
<span class="v-meta">${it.context_pct != null ? `${Math.round(it.context_pct)}%` : ""}</span>
|
|
341
|
-
<span class=${`cli-icon cli-${KNOWN_BACKENDS.has(it.backend) ? it.backend : "other"}`} title=${backendLabel(it.backend)} aria-label=${tn("tooltipBackend", { value: backendLabel(it.backend) })}></span>
|
|
342
|
-
</a></li>`;
|
|
343
|
-
})}</ul>`}
|
|
436
|
+
${folded ? null : html`<ul class="inst-list">${names.map((name, i) => row(name, g, i, names.length))}</ul>`}
|
|
344
437
|
</div>`;
|
|
345
438
|
})}
|
|
346
439
|
${loaded && shown.active && !shown.groups.length ? html`<p class="side-empty">${tn("filterNone")}</p>` : null}
|
|
347
440
|
</div>
|
|
348
441
|
<div class="v-filter">
|
|
349
442
|
<label class="v-filter-box"><${Icon} name="search" size=${14} /><span class="sr-only">${tn("filterPh")}</span>
|
|
350
|
-
<input ref=${filterRef} id="filterInput" type="text" autocomplete="off" spellcheck
|
|
443
|
+
<input ref=${filterRef} id="filterInput" type="text" autocomplete="off" spellcheck=${false} placeholder=${tn("filterPh")} value=${f.q}
|
|
351
444
|
onInput=${(e) => setNavFilter({ q: e.target.value })}
|
|
352
445
|
onKeyDown=${(e) => { if (e.key === "Escape" && f.q) { e.preventDefault(); e.stopPropagation(); setNavFilter({ q: "" }); } }} />
|
|
353
446
|
${f.q ? html`<button type="button" class="icon-btn v-filter-clear" aria-label=${tn("filterClear")} title=${tn("filterClear")}
|
|
@@ -201,7 +201,13 @@ function Terminal({ name, lease, density }) {
|
|
|
201
201
|
const term = useRef(null), pre = useRef(null);
|
|
202
202
|
const grid = useRef({ cols: 0, rows: 0, applied: 0, key: "" });
|
|
203
203
|
const [problem, setProblem] = useState(null);
|
|
204
|
+
// #1674: the text size as of this render. The pane refresh and the resize handlers below are set up once per mount
|
|
205
|
+
// and keep the fit() of that render; reading the size here, not from that render's props, keeps them on the current
|
|
206
|
+
// choice (they used to put the mount-time size back within one 800 ms refresh).
|
|
207
|
+
const dens = useRef(density);
|
|
208
|
+
dens.current = density;
|
|
204
209
|
const fit = () => {
|
|
210
|
+
const density = dens.current;
|
|
205
211
|
const g = grid.current, box = term.current, p = pre.current;
|
|
206
212
|
if (!box || !p || g.cols < 1 || g.rows < 1) return;
|
|
207
213
|
const cs = getComputedStyle(box);
|