@songsid/agend 2.3.0-beta.1 → 2.3.0-beta.3
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 +5 -5
- package/dist/agent-cli.js.map +1 -1
- package/dist/agent-file-read.d.ts +11 -6
- package/dist/agent-file-read.js +32 -17
- package/dist/agent-file-read.js.map +1 -1
- package/dist/agent-whats-new.js +10 -13
- package/dist/agent-whats-new.js.map +1 -1
- package/dist/auth-api.js +10 -5
- package/dist/auth-api.js.map +1 -1
- package/dist/backend/claude-code.d.ts +2 -2
- package/dist/backend/claude-code.js +10 -5
- package/dist/backend/claude-code.js.map +1 -1
- package/dist/backend/types.d.ts +13 -0
- package/dist/backend/types.js +10 -0
- package/dist/backend/types.js.map +1 -1
- package/dist/cache-analysis.d.ts +47 -1
- package/dist/cache-analysis.js +68 -8
- package/dist/cache-analysis.js.map +1 -1
- package/dist/cache-report-tool.d.ts +17 -0
- package/dist/cache-report-tool.js +40 -0
- package/dist/cache-report-tool.js.map +1 -0
- package/dist/cache-service.d.ts +15 -0
- package/dist/cache-service.js +26 -1
- package/dist/cache-service.js.map +1 -1
- package/dist/channel/mcp-tools.js +9 -3
- package/dist/channel/mcp-tools.js.map +1 -1
- package/dist/classic-channel-manager.d.ts +8 -3
- package/dist/classic-channel-manager.js +10 -3
- package/dist/classic-channel-manager.js.map +1 -1
- package/dist/cli.js +7 -3
- package/dist/cli.js.map +1 -1
- package/dist/config-validator.js +5 -1
- package/dist/config-validator.js.map +1 -1
- package/dist/daemon.js +5 -5
- package/dist/daemon.js.map +1 -1
- package/dist/delivery-outbox.js +5 -0
- package/dist/delivery-outbox.js.map +1 -1
- package/dist/fleet-manager.d.ts +71 -3
- package/dist/fleet-manager.js +256 -45
- package/dist/fleet-manager.js.map +1 -1
- package/dist/fleet-traffic.d.ts +38 -0
- package/dist/fleet-traffic.js +96 -0
- package/dist/fleet-traffic.js.map +1 -0
- package/dist/general-knowledge/skills/credential-profiles/SKILL.md +20 -5
- package/dist/general-knowledge/skills/multi-channel/SKILL.md +1 -1
- package/dist/general-knowledge/skills/web-appearance/SKILL.md +149 -5
- package/dist/general-knowledge/skills/web-dashboard/SKILL.md +398 -0
- package/dist/locale.js +6 -4
- package/dist/locale.js.map +1 -1
- package/dist/memory-hogs.d.ts +27 -0
- package/dist/memory-hogs.js +124 -0
- package/dist/memory-hogs.js.map +1 -0
- package/dist/outbound-handlers.d.ts +4 -0
- package/dist/outbound-handlers.js +74 -1
- package/dist/outbound-handlers.js.map +1 -1
- package/dist/outbound-schemas.d.ts +98 -0
- package/dist/outbound-schemas.js +56 -0
- package/dist/outbound-schemas.js.map +1 -1
- package/dist/public-web-gateway.js +1 -1
- package/dist/public-web-gateway.js.map +1 -1
- package/dist/scheduler/scheduler.d.ts +12 -0
- package/dist/scheduler/scheduler.js +83 -10
- package/dist/scheduler/scheduler.js.map +1 -1
- package/dist/service-installer.d.ts +2 -1
- package/dist/service-installer.js +18 -3
- package/dist/service-installer.js.map +1 -1
- package/dist/tmux-manager.d.ts +5 -0
- package/dist/tmux-manager.js +16 -0
- package/dist/tmux-manager.js.map +1 -1
- package/dist/tool-permissions.d.ts +5 -0
- package/dist/tool-permissions.js +15 -2
- package/dist/tool-permissions.js.map +1 -1
- package/dist/types.d.ts +1 -1
- package/dist/ui/app-effects.js +33 -9
- package/dist/ui/chat-effects.js +62 -4
- package/dist/ui/chat-strings.js +40 -4
- package/dist/ui/chat-thread.js +13 -5
- package/dist/ui/effect-trial-panel.js +19 -16
- package/dist/ui/fleet-cache.js +23 -1
- package/dist/ui/fleet-canvas-layout.js +111 -0
- package/dist/ui/fleet-canvas.js +489 -0
- package/dist/ui/fleet-org.js +27 -12
- package/dist/ui/fleet-traffic.js +224 -0
- package/dist/ui/panel-chat.js +8 -2
- package/dist/ui/panel-details.js +2 -1
- package/dist/ui/panel-fleet.js +17 -5
- package/dist/ui/panel-settings.js +3 -2
- package/dist/ui/preview.js +3 -2
- package/dist/ui/settings-appearance.js +34 -18
- package/dist/ui/settings-dialogs.js +3 -6
- package/dist/ui/settings-effects.js +4 -3
- package/dist/ui/settings-learn.js +25 -0
- package/dist/ui/settings-model.js +6 -2
- package/dist/ui/settings-mods.js +4 -3
- package/dist/ui/settings-owner-effects.js +8 -8
- package/dist/ui/settings-strings.js +34 -38
- package/dist/ui/shared/app-i18n.js +2 -2
- package/dist/ui/shared/app-prefs.js +1 -1
- package/dist/ui/shared/app-shell.js +23 -1
- package/dist/ui/shared/app-store.js +12 -1
- package/dist/ui/shared/app-stream.js +2 -1
- package/dist/ui/shared/app.css +163 -12
- package/dist/ui/shared/effect-app-runtime.js +1 -1
- package/dist/ui/shared/effect-frame.js +10 -5
- package/dist/ui/shared/effect-geometry.js +24 -9
- package/dist/ui/shared/effect-policy.js +2 -1
- package/dist/ui/shared/effect-registry.js +71 -17
- package/dist/ui/shared/effect-trial-shadow.js +62 -0
- package/dist/ui/shared/effect-trial-stage.js +15 -9
- package/dist/ui/shared/effect-trial.css +40 -0
- package/dist/ui/shared/identity-card.js +5 -2
- package/dist/ui/shared/identity-color.js +3 -3
- package/dist/ui/shared/instance-nav.js +35 -9
- package/dist/ui/shared/panel-view.js +2 -2
- package/dist/ui/shared/usage-dialog.js +14 -2
- package/dist/ui/shared/view-strings.js +22 -2
- package/dist/ui/signin.html +1 -1
- package/dist/usage/providers.d.ts +3 -0
- package/dist/usage/providers.js +4 -3
- package/dist/usage/providers.js.map +1 -1
- package/dist/view-api.d.ts +21 -0
- package/dist/view-api.js +28 -0
- package/dist/view-api.js.map +1 -1
- package/dist/web-api.d.ts +5 -6
- package/dist/web-api.js +60 -11
- package/dist/web-api.js.map +1 -1
- package/dist/web-appearance-view.d.ts +10 -0
- package/dist/web-appearance-view.js +77 -8
- package/dist/web-appearance-view.js.map +1 -1
- package/dist/web-asset-parts.d.ts +3 -8
- package/dist/web-asset-parts.js.map +1 -1
- package/dist/web-auth.js +1 -1
- package/dist/web-auth.js.map +1 -1
- package/dist/web-effect-service.js +3 -1
- package/dist/web-effect-service.js.map +1 -1
- package/dist/web-effect-tools.js +4 -1
- package/dist/web-effect-tools.js.map +1 -1
- package/dist/web-effect-trials.d.ts +4 -4
- package/dist/web-effect-trials.js +6 -6
- package/dist/web-effect-trials.js.map +1 -1
- package/dist/web-effect-upload.d.ts +2 -4
- package/dist/web-effect-upload.js +3 -8
- package/dist/web-effect-upload.js.map +1 -1
- package/dist/web-effect.d.ts +14 -0
- package/dist/web-effect.js +21 -0
- package/dist/web-effect.js.map +1 -1
- package/dist/web-guest-access.d.ts +67 -0
- package/dist/web-guest-access.js +126 -0
- package/dist/web-guest-access.js.map +1 -0
- package/dist/web-identity-palette.d.ts +1 -1
- package/dist/web-identity-palette.js +2 -2
- package/dist/web-identity-palette.js.map +1 -1
- package/dist/web-mod-agent.js +5 -3
- package/dist/web-mod-agent.js.map +1 -1
- package/dist/web-mod-bundle.d.ts +5 -0
- package/dist/web-mod-bundle.js +6 -0
- package/dist/web-mod-bundle.js.map +1 -1
- package/dist/web-mod-store.d.ts +5 -0
- package/dist/web-mod-store.js +8 -1
- package/dist/web-mod-store.js.map +1 -1
- package/dist/web-prefs.d.ts +8 -0
- package/dist/web-prefs.js +9 -0
- package/dist/web-prefs.js.map +1 -1
- package/dist/web-preview.d.ts +6 -2
- package/dist/web-preview.js +4 -2
- package/dist/web-preview.js.map +1 -1
- package/dist/web-session.d.ts +15 -7
- package/dist/web-session.js +25 -8
- package/dist/web-session.js.map +1 -1
- package/dist/web-settings-tools.d.ts +69 -0
- package/dist/web-settings-tools.js +247 -0
- package/dist/web-settings-tools.js.map +1 -0
- package/package.json +1 -1
- package/templates/systemd.service.ejs +8 -0
|
@@ -0,0 +1,489 @@
|
|
|
1
|
+
// #1918: Fleet → Org chart as a canvas — the same structure as the list (fleet-org.js: General, the teams fleet.yaml
|
|
2
|
+
// defines, their members, the instances in no team), drawn as cards in team boxes you can pan, zoom and pinch. No
|
|
3
|
+
// library: SVG with attributes and classes (never a style attribute, #1300); the view's transform is an SVG attribute
|
|
4
|
+
// set on one group, so panning re-renders nothing.
|
|
5
|
+
//
|
|
6
|
+
// - Layout: fixed slots, no layout engine. General on top; teams in fleet.yaml's order, packed into rows that wrap at
|
|
7
|
+
// a width; members in a two- or three-column grid in their box. Each team's slot is sized as if expanded, so folding
|
|
8
|
+
// a team or searching never moves another one. Tree lines: a stem from General, a bus over each row, a trunk at the
|
|
9
|
+
// left joining the rows, one drop per team — never a line per member (the box says who is in it). A phone packs
|
|
10
|
+
// narrower (one team a row, at most two columns), so General and the first team open readable.
|
|
11
|
+
// - Per device (localStorage; blocked storage: this page only): Canvas or List, and which teams are folded. The view
|
|
12
|
+
// itself is not kept: it opens on General and the first row (#1918's user decision 2) — on a desktop as wide as the
|
|
13
|
+
// panel allows (no larger than life), on a phone no smaller than readable. 0 / Fit shows the whole fleet.
|
|
14
|
+
// - Presentation only, like the list: a card opens that instance's chat; nothing is edited here (#1689 is how teams
|
|
15
|
+
// change). The live state is the app store's (fleet-org.js buildOrgView), so the canvas moves with the stream.
|
|
16
|
+
// - Live traffic (fleet-traffic.js): the messages between instances, drawn between the team boxes and the cards (never
|
|
17
|
+
// hidden under a box, never across a name). Live on or off per device; the list says it in words ("Sending: …").
|
|
18
|
+
import { html, useEffect, useLayoutEffect, useMemo, useRef, useState } from "/assets/app-html.js";
|
|
19
|
+
import { appStore } from "/assets/app-store.js";
|
|
20
|
+
import { t, register } from "/assets/app-i18n.js";
|
|
21
|
+
import { chatPath } from "/assets/app-route.js";
|
|
22
|
+
import { navigate } from "/assets/app-nav.js";
|
|
23
|
+
import { Empty, Skeleton } from "/assets/ui-states.js";
|
|
24
|
+
import { Icon } from "/assets/ui-icons.js";
|
|
25
|
+
import { avatarOf, backendLabel, firstCharacter, namesOf } from "/assets/instance-nav.js";
|
|
26
|
+
import { identityClasses, identityOf, paintIdentity, useIdentityColors } from "/assets/identity-color.js";
|
|
27
|
+
import { buildOrgView, OrgChart, OrgSummary } from "./fleet-org.js";
|
|
28
|
+
import { OC, REST_KEY, SUM_LINE, canvasLayout, openingRegion, summaryRows } from "./fleet-canvas-layout.js";
|
|
29
|
+
import { createTrafficStore, FAR_K, POLL_MS, sendingBy, trafficEdges } from "./fleet-traffic.js";
|
|
30
|
+
|
|
31
|
+
register("orgc", {
|
|
32
|
+
en: {
|
|
33
|
+
view: "View", canvas: "Canvas", list: "List", find: "Find an instance", findShort: "Find", found: "{0} found", foundOne: "1 found", noMatch: "No match",
|
|
34
|
+
zoomIn: "Zoom in", zoomOut: "Zoom out", fit: "Fit to screen",
|
|
35
|
+
label: "Org chart canvas. Tab or the arrow keys move between instances, Enter opens a chat, + and − zoom, 0 fits the fleet.",
|
|
36
|
+
expand: "Expand", collapse: "Collapse", members: "{0} members", member: "1 member", inFolded: "{0} found · expand", noMembers: "No members",
|
|
37
|
+
openChat: "Open chat", minimap: "Overview: click to move there",
|
|
38
|
+
live: "Live", liveOn: "Hide live traffic", liveOff: "Show live traffic", lgTask: "delegate_task", lgSend: "send_to_instance",
|
|
39
|
+
lgReport: "report_result", lgBroadcast: "broadcast", lgQueued: "queued", lgFading: "delivered", lgTeam: "between teams", legend: "Key",
|
|
40
|
+
},
|
|
41
|
+
"zh-TW": {
|
|
42
|
+
view: "檢視", canvas: "畫布", list: "清單", find: "尋找 instance", findShort: "尋找", found: "找到 {0} 個", foundOne: "找到 1 個", noMatch: "沒有符合的",
|
|
43
|
+
zoomIn: "放大", zoomOut: "縮小", fit: "符合畫面",
|
|
44
|
+
label: "組織圖畫布。用 Tab 或方向鍵在 instance 之間移動,Enter 開啟聊天,+ 和 − 縮放,0 顯示整個 fleet。",
|
|
45
|
+
expand: "展開", collapse: "收合", members: "{0} 名成員", member: "1 名成員", inFolded: "找到 {0} 個 · 展開", noMembers: "沒有成員",
|
|
46
|
+
openChat: "開啟聊天", minimap: "總覽:點一下移到那裡",
|
|
47
|
+
live: "即時", liveOn: "隱藏即時往來", liveOff: "顯示即時往來", lgTask: "delegate_task", lgSend: "send_to_instance",
|
|
48
|
+
lgReport: "report_result", lgBroadcast: "broadcast", lgQueued: "排隊中", lgFading: "已送達", lgTeam: "team 之間", legend: "圖例",
|
|
49
|
+
},
|
|
50
|
+
});
|
|
51
|
+
const tc = (k, ...v) => t(`orgc.${k}`, ...v);
|
|
52
|
+
|
|
53
|
+
// ── Layout: fleet-canvas-layout.js (pure; the traffic overlay routes through the same lanes) ──
|
|
54
|
+
|
|
55
|
+
export { OC, REST_KEY, SUM_LINE, canvasLayout, openingRegion, summaryRows };
|
|
56
|
+
|
|
57
|
+
// ── The view: pan, zoom, fit (pure helpers; the component applies them) ──
|
|
58
|
+
|
|
59
|
+
export const K_MIN = 0.2, K_MAX = 2.5;
|
|
60
|
+
const clampK = (k) => Math.min(K_MAX, Math.max(K_MIN, k));
|
|
61
|
+
/**
|
|
62
|
+
* The view that shows region `r` in a `size` viewport: as large as fits (at most `maxK`), centred across, from the top.
|
|
63
|
+
* `minK`: never smaller than this (a phone's opening); a region that is then wider than the viewport starts at its left.
|
|
64
|
+
* `widthOnly`: fit the width, let the height run on (the opening: the rest is below).
|
|
65
|
+
*/
|
|
66
|
+
export function fitView(r, size, { margin = 24, minK = K_MIN, maxK = 1.25, widthOnly = false } = {}) {
|
|
67
|
+
const across = (size.w - 2 * margin) / r.w, down = widthOnly ? Infinity : (size.h - 2 * margin) / r.h;
|
|
68
|
+
const k = clampK(Math.max(minK, Math.min(across, down, maxK)));
|
|
69
|
+
const x = r.w * k > size.w - 2 * margin ? margin - r.x * k : size.w / 2 - (r.x + r.w / 2) * k;
|
|
70
|
+
return { k, x, y: margin - r.y * k };
|
|
71
|
+
}
|
|
72
|
+
/** A pinch step: the canvas point under the old midpoint goes to the new midpoint, the zoom scaled by the spans' ratio. */
|
|
73
|
+
export function pinchView(v, from, to) {
|
|
74
|
+
const k = clampK(v.k * (to.dist / from.dist));
|
|
75
|
+
const wx = (from.mid.x - v.x) / v.k, wy = (from.mid.y - v.y) / v.k;
|
|
76
|
+
return { k, x: to.mid.x - wx * k, y: to.mid.y - wy * k };
|
|
77
|
+
}
|
|
78
|
+
/** Zoom by `f` about the viewport point (cx, cy): the canvas point under it stays under it. */
|
|
79
|
+
export function zoomAt(v, f, cx, cy) {
|
|
80
|
+
const k = clampK(v.k * f);
|
|
81
|
+
return { k, x: cx - (cx - v.x) * (k / v.k), y: cy - (cy - v.y) * (k / v.k) };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// ── Per device ──
|
|
85
|
+
|
|
86
|
+
const MODE_KEY = "agend_org_mode", FOLD_KEY = "agend_org_folded", LIVE_KEY = "agend_org_live", KEY_KEY = "agend_org_key";
|
|
87
|
+
function readMode() { try { return localStorage.getItem(MODE_KEY) === "list" ? "list" : "canvas"; } catch { return "canvas"; } }
|
|
88
|
+
function readLive() { try { return localStorage.getItem(LIVE_KEY) !== "0"; } catch { return true; } }
|
|
89
|
+
function readFolded() {
|
|
90
|
+
try { const a = JSON.parse(localStorage.getItem(FOLD_KEY) || "[]"); return new Set(Array.isArray(a) ? a.filter((x) => typeof x === "string").slice(0, 500) : []); } catch { return new Set(); }
|
|
91
|
+
}
|
|
92
|
+
function save(key, value) { try { localStorage.setItem(key, value); } catch { /* this page only */ } }
|
|
93
|
+
|
|
94
|
+
// ── Drawing ──
|
|
95
|
+
|
|
96
|
+
const STATE_LABEL = (s) => t(`org.${s}`);
|
|
97
|
+
// #2047: SVG text does not wrap or ellipsize, so names are shortened before they are drawn. A budget counts Latin-width
|
|
98
|
+
// characters: a CJK or fullwidth character draws about 1.6 of them. A grapheme (an emoji sequence, a letter with its
|
|
99
|
+
// marks) is never split; an emoji sequence a font cannot join is drawn as its parts, so each pictograph counts 2.
|
|
100
|
+
const WIDE = /[\u1100-\u115f\u2e80-\ua4cf\uac00-\ud7a3\uf900-\ufaff\ufe30-\ufe4f\uff00-\uff60\uffe0-\uffe6\u{20000}-\u{3fffd}]/u;
|
|
101
|
+
const PICTO = /\p{Extended_Pictographic}|\p{Regional_Indicator}/u;
|
|
102
|
+
const charWidth = (g) => {
|
|
103
|
+
const cps = Array.from(g), pictos = cps.filter((c) => PICTO.test(c)).length;
|
|
104
|
+
return pictos ? 2 * pictos : cps.some((c) => WIDE.test(c)) ? 1.6 : 1;
|
|
105
|
+
};
|
|
106
|
+
const SEGMENTER = new Intl.Segmenter(undefined, { granularity: "grapheme" });
|
|
107
|
+
export function clipText(s, n) {
|
|
108
|
+
const chars = Array.from(SEGMENTER.segment(String(s)), (x) => x.segment);
|
|
109
|
+
if (chars.reduce((w, g) => w + charWidth(g), 0) <= n) return chars.join("");
|
|
110
|
+
let used = 1, out = ""; // the ellipsis takes one
|
|
111
|
+
for (const g of chars) { if (used + charWidth(g) > n) break; used += charWidth(g); out += g; }
|
|
112
|
+
return `${out}…`;
|
|
113
|
+
}
|
|
114
|
+
const clip = clipText;
|
|
115
|
+
const needle = (q) => String(q || "").trim().toLowerCase();
|
|
116
|
+
const hits = (node, q) => !!q && (node.name.toLowerCase().includes(q) || (!!node.i && typeof node.i.display_name === "string" && node.i.display_name.toLowerCase().includes(q)));
|
|
117
|
+
|
|
118
|
+
function Face({ it, name, cx, cy, r }) {
|
|
119
|
+
const a = it ? avatarOf(it) : { kind: "initial", text: firstCharacter(name) };
|
|
120
|
+
const [broken, setBroken] = useState("");
|
|
121
|
+
const src = a.kind !== "initial" && a.src && broken !== a.src ? a.src : "";
|
|
122
|
+
const text = a.kind === "emoji" && a.text ? a.text : a.kind === "initial" ? a.text : firstCharacter(it ? namesOf(it).primary : name);
|
|
123
|
+
return html`<g class=${`oc-face${a.kind === "emoji" ? " emoji" : ""}`} aria-hidden="true">
|
|
124
|
+
<circle class="oc-av" cx=${cx} cy=${cy} r=${r} />
|
|
125
|
+
${src ? html`<image href=${src} x=${cx - r} y=${cy - r} width=${r * 2} height=${r * 2} preserveAspectRatio="xMidYMid slice"
|
|
126
|
+
clip-path=${a.kind === "image" ? "url(#oc-clip-av)" : undefined} onError=${() => setBroken(src)} />`
|
|
127
|
+
: html`<text class="oc-av-t" x=${cx} y=${cy + 4} text-anchor="middle">${text}</text>`}
|
|
128
|
+
</g>`;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function Card({ c, lead, cls, onOpen }) {
|
|
132
|
+
const n = c.node, it = n.i;
|
|
133
|
+
const names = it ? namesOf(it) : { primary: n.name, secondary: "" };
|
|
134
|
+
const id = it ? identityOf(it) : null;
|
|
135
|
+
const sub = it ? `${STATE_LABEL(n.state)} · ${backendLabel(it.backend)}` : STATE_LABEL(n.state);
|
|
136
|
+
const full = `${names.primary}${names.secondary ? ` (${names.secondary})` : ""} — ${sub}`;
|
|
137
|
+
const cy = c.h / 2;
|
|
138
|
+
return html`<g class=${`oc-node st-${n.state}${lead ? " lead" : ""}${cls ? ` ${cls}` : ""} ${identityClasses(id)}`} ref=${(el) => paintIdentity(el, id)}
|
|
139
|
+
transform=${`translate(${c.x} ${c.y})`} data-name=${n.name} data-key=${c.key} tabindex="0" role=${it ? "link" : "img"}
|
|
140
|
+
aria-label=${it ? `${full}. ${tc("openChat")}` : full} onClick=${it ? () => onOpen(n.name) : undefined}>
|
|
141
|
+
<title>${full}</title>
|
|
142
|
+
<rect class="oc-card" width=${c.w} height=${c.h} rx="10" />
|
|
143
|
+
<rect class="oc-idbar" x="0" y="8" width="4" height=${c.h - 16} rx="2" />
|
|
144
|
+
<${Face} it=${it} name=${n.name} cx=${26} cy=${cy} r=${16} />
|
|
145
|
+
<circle class=${`oc-dot st-${n.state}`} cx=${38} cy=${cy + 12} r=${5} />
|
|
146
|
+
<text class="oc-name" x=${50} y=${cy - 4}>${clip(names.primary, lead ? 20 : 15)}</text>
|
|
147
|
+
<text class="oc-sub" x=${50} y=${cy + 13}><tspan class=${`oc-st st-${n.state}`}>${clip(sub, lead ? 27 : 20)}</tspan></text>
|
|
148
|
+
</g>`;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function TeamBox({ b, q, onFold }) {
|
|
152
|
+
const name = b.name === null ? t("org.unteamed") : b.name;
|
|
153
|
+
const found = q ? b.nodes.filter((n) => hits(n, q)).length : 0;
|
|
154
|
+
const count = b.nodes.length === 1 ? tc("member") : tc("members", b.nodes.length);
|
|
155
|
+
const toggle = (e) => { if (e.type === "click" || e.key === "Enter" || e.key === " ") { e.preventDefault(); e.stopPropagation(); onFold(b.key); } };
|
|
156
|
+
return html`<g class=${`oc-team${b.loose ? " loose" : ""}${q && !found ? " dim" : ""}`} data-team=${b.key}>
|
|
157
|
+
<rect class=${`oc-box${b.folded && found ? " match" : ""}`} x=${b.x} y=${b.y} width=${b.w} height=${b.h} rx="12" />
|
|
158
|
+
<rect class="oc-head" x=${b.x + 1} y=${b.y + 1} width=${b.w - 2} height=${OC.HEAD - 2} rx="11" />
|
|
159
|
+
<g class=${`oc-toggle${b.nodes.length ? "" : " empty"}`} tabindex=${b.nodes.length ? "0" : undefined} role=${b.nodes.length ? "button" : undefined}
|
|
160
|
+
aria-expanded=${b.nodes.length ? String(!b.folded) : undefined} aria-label=${`${name}, ${count}.${b.nodes.length ? ` ${b.folded ? tc("expand") : tc("collapse")}.` : ""}`}
|
|
161
|
+
onClick=${b.nodes.length ? toggle : undefined} onKeyDown=${b.nodes.length ? toggle : undefined}>
|
|
162
|
+
<rect class="oc-hit" x=${b.x} y=${b.y} width=${b.w} height=${OC.HEAD} />
|
|
163
|
+
${b.nodes.length ? html`<text class="oc-caret" x=${b.x + 12} y=${b.y + 22}>${b.folded ? "▸" : "▾"}</text>` : null}
|
|
164
|
+
<text class="oc-team-name" x=${b.x + (b.nodes.length ? 28 : 14)} y=${b.y + 22}>${clip(name, Math.max(8, Math.floor((b.w - 90) / 7.5)))}</text>
|
|
165
|
+
<text class="oc-team-meta" x=${b.x + b.w - 12} y=${b.y + 22} text-anchor="end">${b.folded && found ? tc("inFolded", found) : String(b.nodes.length)}</text>
|
|
166
|
+
</g>
|
|
167
|
+
${!b.nodes.length ? html`<text class="oc-team-sum" x=${b.x + 14} y=${b.y + OC.HEAD + 18}>${tc("noMembers")}</text>` : null}
|
|
168
|
+
${b.folded ? b.summary.flatMap((row, i) => row.map((it) => html`<g key=${it.s}>
|
|
169
|
+
<circle class=${`oc-dot st-${it.s}`} cx=${b.x + it.dx + 5} cy=${b.y + OC.HEAD + 14 + i * SUM_LINE} r="5" />
|
|
170
|
+
<text class="oc-team-sum" x=${b.x + it.dx + 14} y=${b.y + OC.HEAD + 18 + i * SUM_LINE}>${it.label}</text></g>`)) : null}
|
|
171
|
+
</g>`;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** Every card, in the order Tab walks them (General, then each team's members in reading order). */
|
|
175
|
+
const cardsOf = (L) => [...L.tops, ...L.boxes.flatMap((b) => b.cells)];
|
|
176
|
+
|
|
177
|
+
const reducedMotion = () => typeof matchMedia === "function" && matchMedia("(prefers-reduced-motion: reduce)").matches;
|
|
178
|
+
|
|
179
|
+
/** The traffic lines: dotted while queued, a dash and a pulse while delivering, fading once delivered; ×N on shared lines. */
|
|
180
|
+
function TrafficLayer({ edges, q }) {
|
|
181
|
+
const still = reducedMotion();
|
|
182
|
+
return html`<g class="oc-flows" aria-hidden="true">${edges.map((e) => {
|
|
183
|
+
const dim = q && !e.names.some((n) => n.toLowerCase().includes(q));
|
|
184
|
+
return html`<g key=${e.key} class=${`oc-fl${dim ? " dim" : ""}`}>
|
|
185
|
+
<path class="oc-flow-halo" d=${e.d} />
|
|
186
|
+
<path class=${`oc-flow h-${e.hue} ph-${e.phase}`} d=${e.d} marker-start=${`url(#oc-src-${e.phase === "fading" ? "fading" : e.hue})`} marker-end=${`url(#oc-ah-${e.phase === "fading" ? "fading" : e.hue})`} />
|
|
187
|
+
${e.phase === "live" && !still ? html`<circle class=${`oc-pulse h-${e.hue}`} r="4"><animateMotion dur=${`${Math.max(1.6, e.pts.length * 0.3)}s`} repeatCount="indefinite" path=${e.d} /></circle>` : null}
|
|
188
|
+
${e.badge ? html`<g class="oc-badge" transform=${`translate(${e.badge.x - 14} ${e.badge.y - 10})`}><rect width="28" height="20" rx="10" /><text x="14" y="14" text-anchor="middle">${`×${e.n}`}</text></g>` : null}
|
|
189
|
+
</g>`;
|
|
190
|
+
})}</g>`;
|
|
191
|
+
}
|
|
192
|
+
const ARROWS = ["task", "send", "report", "broadcast", "team", "fading"];
|
|
193
|
+
|
|
194
|
+
/** The key to the lines: folded until the person opens it (open, it covers a corner of the canvas); kept per device. */
|
|
195
|
+
function Legend() {
|
|
196
|
+
const [open] = useState(() => { try { return localStorage.getItem(KEY_KEY) === "1"; } catch { return false; } });
|
|
197
|
+
const row = (cls, k) => html`<span class="oc-lg"><i class=${`oc-sw ${cls}`} aria-hidden="true"></i>${tc(k)}</span>`;
|
|
198
|
+
return html`<details class="oc-legend" open=${open} onToggle=${(e) => save(KEY_KEY, e.currentTarget.open ? "1" : "0")}><summary>${tc("legend")}</summary>
|
|
199
|
+
<div class="oc-lg-rows">${row("h-task", "lgTask")}${row("h-send", "lgSend")}${row("h-report", "lgReport")}${row("h-broadcast", "lgBroadcast")}
|
|
200
|
+
${row("ph-queued", "lgQueued")}${row("ph-fading", "lgFading")}</div></details>`;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* The page's traffic while the canvas (or its list) is open and Live is on: the snapshot on opening; then the stream's
|
|
205
|
+
* items — or, with no live stream (the public link, or a stream that fell back to polling), the snapshot every
|
|
206
|
+
* POLL_MS while the page is shown; read again when the stream comes back. Everything is the lease's (#1408 §4).
|
|
207
|
+
*/
|
|
208
|
+
function useTraffic(lease, on) {
|
|
209
|
+
const store = useMemo(createTrafficStore, []);
|
|
210
|
+
const [rev, setRev] = useState(0);
|
|
211
|
+
useEffect(() => {
|
|
212
|
+
if (!on || !lease) return undefined;
|
|
213
|
+
const bump = () => setRev((n) => n + 1);
|
|
214
|
+
const read = async () => {
|
|
215
|
+
try {
|
|
216
|
+
const r = await lease.fetch("/ui/traffic", { method: "GET", headers: { "Content-Type": "application/json" } });
|
|
217
|
+
if (!lease.current() || !r.ok) return;
|
|
218
|
+
const d = await r.json();
|
|
219
|
+
if (lease.current() && store.load(d)) bump();
|
|
220
|
+
} catch { /* the next poll, or the stream, brings it */ }
|
|
221
|
+
};
|
|
222
|
+
void read();
|
|
223
|
+
const sub = appStore.get().streamOn;
|
|
224
|
+
// No live stream to bring traffic: the public link (poll transport), or a stream that fell back to /ui/poll.
|
|
225
|
+
const polled = () => globalThis.document?.body?.dataset?.webTransport === "poll" || appStore.get().connection === "polling";
|
|
226
|
+
const offs = [];
|
|
227
|
+
if (sub) {
|
|
228
|
+
offs.push(lease.hold(sub("traffic", (item) => { if (store.apply(item)) bump(); })));
|
|
229
|
+
offs.push(lease.hold(sub("connection", (c) => { if (c === "live") void read(); })));
|
|
230
|
+
}
|
|
231
|
+
const hs = [
|
|
232
|
+
lease.interval(() => { if (polled() && !globalThis.document?.hidden) void read(); }, POLL_MS),
|
|
233
|
+
lease.interval(() => { if (store.sweep()) bump(); }, 1000),
|
|
234
|
+
];
|
|
235
|
+
return () => { for (const off of offs) off(); for (const h of hs) lease.clear(h); };
|
|
236
|
+
}, [lease, on]);
|
|
237
|
+
return useMemo(() => (on ? store.items() : []), [rev, on]);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/** The canvas body: `view` buildOrgView's answer; `folded` the folded teams' keys; `q` the search needle; `traffic` the live items (null: Live is off). */
|
|
241
|
+
function Canvas({ view, folded, onFold, q, jump, traffic }) {
|
|
242
|
+
const [narrow] = useState(() => typeof matchMedia === "function" && matchMedia("(max-width: 640px)").matches);
|
|
243
|
+
const [far, setFar] = useState(false);
|
|
244
|
+
const L = useMemo(() => canvasLayout(view, folded, { narrow, label: (s, n) => `${n} ${STATE_LABEL(s)}` }), [view, folded, narrow]);
|
|
245
|
+
const svgRef = useRef(null), worldRef = useRef(null), miniRef = useRef(null), miniViewRef = useRef(null);
|
|
246
|
+
const vp = useRef(null);
|
|
247
|
+
const drag = useRef({ pts: new Map(), moved: 0, span: null, swallow: false });
|
|
248
|
+
const size = () => {
|
|
249
|
+
const r = svgRef.current ? svgRef.current.getBoundingClientRect() : null;
|
|
250
|
+
return r && r.width > 0 && r.height > 0 ? { w: r.width, h: r.height } : { w: 960, h: 600 };
|
|
251
|
+
};
|
|
252
|
+
const apply = () => {
|
|
253
|
+
const v = vp.current;
|
|
254
|
+
if (!v || !worldRef.current) return;
|
|
255
|
+
worldRef.current.setAttribute("transform", `translate(${v.x} ${v.y}) scale(${v.k})`);
|
|
256
|
+
const s = size(), m = miniViewRef.current;
|
|
257
|
+
if (m) for (const [k, val] of [["x", -v.x / v.k], ["y", -v.y / v.k], ["width", s.w / v.k], ["height", s.h / v.k]]) m.setAttribute(k, String(val));
|
|
258
|
+
};
|
|
259
|
+
// Zoomed out past FAR_K, traffic is drawn per team pair: a re-render only when that changes.
|
|
260
|
+
const set = (v) => { vp.current = v; apply(); if ((v.k < FAR_K) !== far) setFar(v.k < FAR_K); };
|
|
261
|
+
const whole = () => ({ x: OC.TRUNK - 20, y: 0, w: Math.max(1, L.width - OC.TRUNK + 40), h: Math.max(1, L.height + 10) });
|
|
262
|
+
const fit = () => set(fitView(whole(), size()));
|
|
263
|
+
const zoomBy = (f) => { const s = size(); set(zoomAt(vp.current, f, s.w / 2, s.h / 2)); };
|
|
264
|
+
/** Bring a card into view (keyboard focus, a search's Enter): centred, at a readable size when `zoom`. */
|
|
265
|
+
const show = (c, zoom) => {
|
|
266
|
+
const s = size(), v = vp.current;
|
|
267
|
+
const k = zoom ? Math.max(v.k, 1) : v.k;
|
|
268
|
+
const sx = c.x * k + v.x, sy = c.y * k + v.y;
|
|
269
|
+
if (!zoom && sx >= 8 && sy >= 8 && sx + c.w * k <= s.w - 8 && sy + c.h * k <= s.h - 8) return;
|
|
270
|
+
set({ k, x: s.w / 2 - (c.x + c.w / 2) * k, y: s.h / 2 - (c.y + c.h / 2) * k });
|
|
271
|
+
};
|
|
272
|
+
|
|
273
|
+
// The opening view: General and the first row — a desktop as wide as fits, no larger than life; a phone no smaller
|
|
274
|
+
// than readable.
|
|
275
|
+
useLayoutEffect(() => {
|
|
276
|
+
vp.current = narrow ? fitView(openingRegion(L), size(), { margin: 12, minK: 0.75 }) : fitView(whole(), size(), { widthOnly: true, maxK: 1, minK: 0.6 });
|
|
277
|
+
setFar(vp.current.k < FAR_K);
|
|
278
|
+
}, []);
|
|
279
|
+
const edges = useMemo(() => (traffic ? trafficEdges(traffic, L, { far }) : []), [traffic, L, far]);
|
|
280
|
+
// Preact owns the markup, not the transform: put it back after every render.
|
|
281
|
+
useLayoutEffect(apply);
|
|
282
|
+
// A search's Enter: the first match, unfolded (by the panel) and then brought into view — once. It waits for the
|
|
283
|
+
// render that has its card (the unfold); a later render (the stream, a fold, a new search text) keeps the person's view.
|
|
284
|
+
const pendingJump = useRef(null);
|
|
285
|
+
useLayoutEffect(() => { pendingJump.current = jump; }, [jump]);
|
|
286
|
+
useLayoutEffect(() => {
|
|
287
|
+
const j = pendingJump.current;
|
|
288
|
+
if (!j) return;
|
|
289
|
+
const c = cardsOf(L).find((x) => x.node.name === j.name);
|
|
290
|
+
if (c) { pendingJump.current = null; show(c, true); return; }
|
|
291
|
+
if (!L.boxes.some((b) => b.folded && b.nodes.some((n) => n.name === j.name))) pendingJump.current = null; // gone: nothing to wait for
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
const open = (name) => navigate(chatPath(name));
|
|
295
|
+
// The click that ends a drag or a pinch opens nothing and folds nothing.
|
|
296
|
+
const onClickCapture = (e) => { if (drag.current.swallow) { e.preventDefault(); e.stopPropagation(); } };
|
|
297
|
+
const onWheel = (e) => {
|
|
298
|
+
e.preventDefault();
|
|
299
|
+
const r = svgRef.current.getBoundingClientRect();
|
|
300
|
+
set(zoomAt(vp.current, Math.exp(-e.deltaY * 0.0015), e.clientX - r.left, e.clientY - r.top));
|
|
301
|
+
};
|
|
302
|
+
// One pointer pans (after a few pixels, so a tap still opens a card); two pinch: the canvas point under their old
|
|
303
|
+
// midpoint goes to the new one, scaled by how their span changed (so a two-finger move pans too). The pointer is
|
|
304
|
+
// captured only once it drags: a capture at once would send the tap's click to the canvas, not the card.
|
|
305
|
+
const spanOf = () => {
|
|
306
|
+
const [a, b] = [...drag.current.pts.values()], r = svgRef.current.getBoundingClientRect();
|
|
307
|
+
return { mid: { x: (a.x + b.x) / 2 - r.left, y: (a.y + b.y) / 2 - r.top }, dist: Math.hypot(a.x - b.x, a.y - b.y) };
|
|
308
|
+
};
|
|
309
|
+
const onPointerDown = (e) => {
|
|
310
|
+
if (e.pointerType === "mouse" && e.button !== 0) return;
|
|
311
|
+
const d = drag.current;
|
|
312
|
+
d.pts.set(e.pointerId, { x: e.clientX, y: e.clientY });
|
|
313
|
+
if (d.pts.size === 1) { d.moved = 0; d.swallow = false; d.span = null; }
|
|
314
|
+
else if (d.pts.size === 2) { d.swallow = true; d.span = spanOf(); }
|
|
315
|
+
};
|
|
316
|
+
const onPointerMove = (e) => {
|
|
317
|
+
const d = drag.current, prev = d.pts.get(e.pointerId);
|
|
318
|
+
if (!prev) return;
|
|
319
|
+
d.pts.set(e.pointerId, { x: e.clientX, y: e.clientY });
|
|
320
|
+
if (d.pts.size === 1) {
|
|
321
|
+
d.moved += Math.abs(e.clientX - prev.x) + Math.abs(e.clientY - prev.y);
|
|
322
|
+
if (d.moved < 5) return;
|
|
323
|
+
if (!d.swallow) { d.swallow = true; try { svgRef.current.setPointerCapture(e.pointerId); } catch { /* gone already */ } }
|
|
324
|
+
set({ ...vp.current, x: vp.current.x + e.clientX - prev.x, y: vp.current.y + e.clientY - prev.y });
|
|
325
|
+
return;
|
|
326
|
+
}
|
|
327
|
+
if (d.pts.size !== 2) return;
|
|
328
|
+
d.swallow = true;
|
|
329
|
+
const next = spanOf();
|
|
330
|
+
if (d.span && d.span.dist > 0 && next.dist > 0) set(pinchView(vp.current, d.span, next));
|
|
331
|
+
d.span = next;
|
|
332
|
+
};
|
|
333
|
+
const onPointerUp = (e) => {
|
|
334
|
+
const d = drag.current;
|
|
335
|
+
d.pts.delete(e.pointerId);
|
|
336
|
+
// Back to one finger: it pans on at once (the pinch already swallows the click); two again: a new span.
|
|
337
|
+
d.span = d.pts.size === 2 ? spanOf() : null;
|
|
338
|
+
if (d.pts.size === 1) d.moved = 5;
|
|
339
|
+
// The click that ends a drag comes after this: let it be swallowed, then forget.
|
|
340
|
+
if (d.swallow && !d.pts.size) setTimeout(() => { if (!d.pts.size) d.swallow = false; }, 0);
|
|
341
|
+
};
|
|
342
|
+
const onKeyDown = (e) => {
|
|
343
|
+
if (e.altKey || e.ctrlKey || e.metaKey) return;
|
|
344
|
+
if (e.key === "+" || e.key === "=") { e.preventDefault(); zoomBy(1.25); return; }
|
|
345
|
+
if (e.key === "-" || e.key === "_") { e.preventDefault(); zoomBy(0.8); return; }
|
|
346
|
+
if (e.key === "0") { e.preventDefault(); fit(); return; }
|
|
347
|
+
const dir = { ArrowLeft: [-1, 0], ArrowRight: [1, 0], ArrowUp: [0, -1], ArrowDown: [0, 1] }[e.key];
|
|
348
|
+
const card = e.target && e.target.closest ? e.target.closest(".oc-node") : null;
|
|
349
|
+
if (card && (e.key === "Enter" || e.key === " ")) {
|
|
350
|
+
e.preventDefault();
|
|
351
|
+
const c = cardsOf(L).find((x) => x.key === card.getAttribute("data-key"));
|
|
352
|
+
if (c && c.node.i) open(c.node.name);
|
|
353
|
+
return;
|
|
354
|
+
}
|
|
355
|
+
if (!dir) return;
|
|
356
|
+
e.preventDefault();
|
|
357
|
+
if (!card) { set({ ...vp.current, x: vp.current.x - dir[0] * 80, y: vp.current.y - dir[1] * 80 }); return; }
|
|
358
|
+
const next = nearest(cardsOf(L), card.getAttribute("data-key"), dir);
|
|
359
|
+
if (next) focusCard(svgRef.current, next.key);
|
|
360
|
+
};
|
|
361
|
+
const onFocusIn = (e) => {
|
|
362
|
+
const card = e.target && e.target.closest ? e.target.closest(".oc-node") : null;
|
|
363
|
+
const c = card ? cardsOf(L).find((x) => x.key === card.getAttribute("data-key")) : null;
|
|
364
|
+
if (c) show(c, false);
|
|
365
|
+
};
|
|
366
|
+
const onMini = (e) => {
|
|
367
|
+
const m = miniRef.current, r = m.getBoundingClientRect(), box = miniBox(L);
|
|
368
|
+
if (!r.width || !r.height) return;
|
|
369
|
+
const scale = Math.min(r.width / box.w, r.height / box.h);
|
|
370
|
+
const wx = box.x + (e.clientX - r.left - (r.width - box.w * scale) / 2) / scale, wy = box.y + (e.clientY - r.top - (r.height - box.h * scale) / 2) / scale;
|
|
371
|
+
const s = size(), v = vp.current;
|
|
372
|
+
set({ k: v.k, x: s.w / 2 - wx * v.k, y: s.h / 2 - wy * v.k });
|
|
373
|
+
};
|
|
374
|
+
|
|
375
|
+
const box = miniBox(L);
|
|
376
|
+
return html`<div class="oc-stage">
|
|
377
|
+
<svg ref=${svgRef} class="oc-svg" role="application" tabindex="0" aria-label=${tc("label")} onWheel=${onWheel} onPointerDown=${onPointerDown}
|
|
378
|
+
onPointerMove=${onPointerMove} onPointerUp=${onPointerUp} onPointerCancel=${onPointerUp} onKeyDown=${onKeyDown} onFocusIn=${onFocusIn} onClickCapture=${onClickCapture}>
|
|
379
|
+
<defs><clipPath id="oc-clip-av" clipPathUnits="objectBoundingBox"><circle cx="0.5" cy="0.5" r="0.5" /></clipPath>
|
|
380
|
+
${ARROWS.map((a) => html`<marker key=${a} id=${`oc-ah-${a}`} viewBox="0 0 10 10" refX="8" refY="5" markerWidth="7" markerHeight="7" orient="auto">
|
|
381
|
+
<path d="M0 0 L10 5 L0 10 z" class=${`oc-ah h-${a}`} /></marker>`)}
|
|
382
|
+
${ARROWS.map((a) => html`<marker key=${`s-${a}`} id=${`oc-src-${a}`} viewBox="0 0 10 10" refX="5" refY="5" markerWidth="5" markerHeight="5">
|
|
383
|
+
<circle cx="5" cy="5" r="4" class=${`oc-ah h-${a}`} /></marker>`)}</defs>
|
|
384
|
+
<g ref=${worldRef} class="oc-world">
|
|
385
|
+
<g class="oc-tree">${L.tree.map((d, i) => html`<path key=${i} d=${d} />`)}</g>
|
|
386
|
+
<g class="oc-teams">${L.boxes.map((b) => html`<${TeamBox} key=${b.key} b=${b} q=${q} onFold=${onFold} />`)}</g>
|
|
387
|
+
<${TrafficLayer} edges=${edges} q=${q} />
|
|
388
|
+
<g class="oc-cards">
|
|
389
|
+
${L.tops.map((c) => html`<${Card} key=${c.key} c=${c} lead=${true} cls=${q ? (hits(c.node, q) ? "match" : "dim") : ""} onOpen=${open} />`)}
|
|
390
|
+
${L.boxes.flatMap((b) => b.cells.map((c) => html`<${Card} key=${c.key} c=${c} lead=${false} cls=${q ? (hits(c.node, q) ? "match" : "dim") : ""} onOpen=${open} />`))}
|
|
391
|
+
</g>
|
|
392
|
+
</g>
|
|
393
|
+
</svg>
|
|
394
|
+
${traffic ? html`<${Legend} />` : null}
|
|
395
|
+
<div class="oc-zoom" role="group" aria-label=${tc("view")}>
|
|
396
|
+
<button type="button" class="icon-btn" aria-label=${tc("zoomIn")} title=${tc("zoomIn")} onClick=${() => zoomBy(1.25)}><${Icon} name="plus" size=${16} /></button>
|
|
397
|
+
<button type="button" class="icon-btn" aria-label=${tc("zoomOut")} title=${tc("zoomOut")} onClick=${() => zoomBy(0.8)}><span aria-hidden="true">−</span></button>
|
|
398
|
+
<button type="button" class="icon-btn" aria-label=${tc("fit")} title=${tc("fit")} onClick=${fit}><${Icon} name="org" size=${16} /></button>
|
|
399
|
+
</div>
|
|
400
|
+
<svg ref=${miniRef} class="oc-mini" viewBox=${`${box.x} ${box.y} ${box.w} ${box.h}`} aria-hidden="true" onClick=${onMini}>
|
|
401
|
+
<title>${tc("minimap")}</title>
|
|
402
|
+
${L.tops.map((c) => html`<rect key=${c.key} class="oc-mini-top" x=${c.x} y=${c.y} width=${c.w} height=${c.h} rx="10" />`)}
|
|
403
|
+
${L.boxes.map((b) => html`<rect key=${b.key} class=${`oc-mini-team${b.nodes.some((n) => n.state === "needs") ? " needs" : ""}${q && b.nodes.some((n) => hits(n, q)) ? " match" : ""}`}
|
|
404
|
+
x=${b.x} y=${b.y} width=${b.w} height=${b.h} rx="12" />`)}
|
|
405
|
+
${q ? cardsOf(L).filter((c) => hits(c.node, q)).map((c) => html`<rect key=${`m:${c.key}`} class="oc-mini-hit" x=${c.x} y=${c.y} width=${c.w} height=${c.h} rx="8" />`) : null}
|
|
406
|
+
<rect ref=${miniViewRef} class="oc-mini-view" />
|
|
407
|
+
</svg>
|
|
408
|
+
</div>`;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
const miniBox = (L) => ({ x: OC.TRUNK - 30, y: 0, w: Math.max(1, L.width - OC.TRUNK + 60), h: Math.max(1, L.height + 20) });
|
|
412
|
+
|
|
413
|
+
/** The card nearest the one with `key` in direction `dir` ([dx, dy]); null when there is none that way. */
|
|
414
|
+
export function nearest(cards, key, dir) {
|
|
415
|
+
const from = cards.find((c) => c.key === key);
|
|
416
|
+
if (!from) return null;
|
|
417
|
+
const fx = from.x + from.w / 2, fy = from.y + from.h / 2;
|
|
418
|
+
let best = null, bestScore = Infinity;
|
|
419
|
+
for (const c of cards) {
|
|
420
|
+
if (c === from) continue;
|
|
421
|
+
const dx = c.x + c.w / 2 - fx, dy = c.y + c.h / 2 - fy;
|
|
422
|
+
const along = dx * dir[0] + dy * dir[1];
|
|
423
|
+
if (along <= 1) continue;
|
|
424
|
+
const across = Math.abs(dx * dir[1]) + Math.abs(dy * dir[0]);
|
|
425
|
+
const score = along + across * 2;
|
|
426
|
+
if (score < bestScore) { best = c; bestScore = score; }
|
|
427
|
+
}
|
|
428
|
+
return best;
|
|
429
|
+
}
|
|
430
|
+
function focusCard(svg, key) {
|
|
431
|
+
const el = svg && [...svg.querySelectorAll(".oc-node")].find((n) => n.getAttribute("data-key") === key);
|
|
432
|
+
if (el && typeof el.focus === "function") el.focus();
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
/**
|
|
436
|
+
* The tab's body (the Fleet panel's loader passes /ui/org's answer and the app store): Canvas or List, chosen per
|
|
437
|
+
* device; the list is fleet-org.js's chart, which is also what a screen reader is pointed to.
|
|
438
|
+
*/
|
|
439
|
+
export function OrgCanvas({ org, app, lease }) {
|
|
440
|
+
useIdentityColors();
|
|
441
|
+
const [mode, setMode] = useState(readMode);
|
|
442
|
+
const [live, setLive] = useState(readLive);
|
|
443
|
+
const traffic = useTraffic(lease, live);
|
|
444
|
+
const sending = useMemo(() => sendingBy(traffic), [traffic]);
|
|
445
|
+
const [folded, setFolded] = useState(readFolded);
|
|
446
|
+
const [query, setQuery] = useState("");
|
|
447
|
+
const [jump, setJump] = useState(null);
|
|
448
|
+
const view = useMemo(() => buildOrgView(org, app), [org, app]);
|
|
449
|
+
useEffect(() => { save(MODE_KEY, mode); }, [mode]);
|
|
450
|
+
useEffect(() => { save(LIVE_KEY, live ? "1" : "0"); }, [live]);
|
|
451
|
+
if (!app.ready) return html`<${Skeleton} lines=${4} />`;
|
|
452
|
+
if (!view.total) return html`<${Empty} icon="team" title=${t("org.empty")} hint=${t("org.emptyHint")} />`;
|
|
453
|
+
const q = needle(query);
|
|
454
|
+
const all = [...view.top, ...view.teams.flatMap((tm) => tm.nodes), ...view.rest.nodes];
|
|
455
|
+
const found = q ? new Set(all.filter((n) => hits(n, q)).map((n) => n.name)).size : 0;
|
|
456
|
+
const onFold = (key) => setFolded((cur) => {
|
|
457
|
+
const next = new Set(cur);
|
|
458
|
+
if (next.has(key)) next.delete(key); else next.add(key);
|
|
459
|
+
save(FOLD_KEY, JSON.stringify([...next]));
|
|
460
|
+
return next;
|
|
461
|
+
});
|
|
462
|
+
const onFind = (e) => {
|
|
463
|
+
if (e.key !== "Enter" || !q) return;
|
|
464
|
+
e.preventDefault();
|
|
465
|
+
const first = all.find((n) => hits(n, q));
|
|
466
|
+
if (!first) return;
|
|
467
|
+
const groups = [...view.teams.map((tm) => [tm.name, tm.nodes]), [REST_KEY, view.rest.nodes]];
|
|
468
|
+
const holder = view.top.includes(first) ? null : groups.find(([, nodes]) => nodes.includes(first));
|
|
469
|
+
if (holder && folded.has(holder[0])) onFold(holder[0]);
|
|
470
|
+
setJump({ name: first.name, at: Date.now() });
|
|
471
|
+
};
|
|
472
|
+
const modeBtn = (m) => html`<button type="button" class=${`btn btn-sm${mode === m ? " btn-primary" : ""}`} aria-pressed=${mode === m ? "true" : "false"}
|
|
473
|
+
onClick=${() => setMode(m)}>${tc(m)}</button>`;
|
|
474
|
+
return html`<div class=${`org oc oc-${mode}`}>
|
|
475
|
+
<div class="list-head org-head oc-bar">
|
|
476
|
+
<span>${view.total === 1 ? t("org.instance") : t("org.instances", view.total)}</span><${OrgSummary} counts=${view.counts} />
|
|
477
|
+
<div class="oc-tools">
|
|
478
|
+
${mode === "canvas" ? html`<label class="s-search oc-find"><${Icon} name="search" size=${14} /><span class="sr-only">${tc("find")}</span>
|
|
479
|
+
<input type="search" value=${query} placeholder=${tc("findShort")} autocomplete="off" onInput=${(e) => setQuery(e.target.value)} onKeyDown=${onFind} />
|
|
480
|
+
${q ? html`<span class="oc-found" role="status">${found ? (found === 1 ? tc("foundOne") : tc("found", found)) : tc("noMatch")}</span>` : null}</label>` : null}
|
|
481
|
+
<button type="button" class=${`btn btn-sm oc-live${live ? " on" : ""}`} aria-pressed=${live ? "true" : "false"} title=${live ? tc("liveOn") : tc("liveOff")}
|
|
482
|
+
onClick=${() => setLive((v) => !v)}><span class="oc-live-dot" aria-hidden="true"></span>${tc("live")}</button>
|
|
483
|
+
<div class="seg-inline" role="group" aria-label=${tc("view")}>${modeBtn("canvas")}${modeBtn("list")}</div>
|
|
484
|
+
</div>
|
|
485
|
+
</div>
|
|
486
|
+
${mode === "list" ? html`<${OrgChart} org=${org} app=${app} bare=${true} sending=${sending} />`
|
|
487
|
+
: html`<${Canvas} view=${view} folded=${folded} onFold=${onFold} q=${q} jump=${jump} traffic=${live ? traffic : null} />`}
|
|
488
|
+
</div>`;
|
|
489
|
+
}
|
package/dist/ui/fleet-org.js
CHANGED
|
@@ -11,6 +11,7 @@ import { t, register } from "/assets/app-i18n.js";
|
|
|
11
11
|
import { chatPath } from "/assets/app-route.js";
|
|
12
12
|
import { Empty, Skeleton } from "/assets/ui-states.js";
|
|
13
13
|
import { Icon } from "/assets/ui-icons.js";
|
|
14
|
+
import { modelText } from "/assets/instance-nav.js";
|
|
14
15
|
|
|
15
16
|
register("org", {
|
|
16
17
|
en: {
|
|
@@ -19,6 +20,7 @@ register("org", {
|
|
|
19
20
|
missing: "Not in the fleet", thread_discord: "Discord thread", thread_telegram: "Telegram topic", openChat: "Open chat",
|
|
20
21
|
classic: "ClassicBot room", empty: "No instances yet", emptyHint: "Instances and teams show here once the fleet has them.",
|
|
21
22
|
noTeams: "No teams yet — define them in Fleet → Teams to group the chart.", newTab: "(opens in a new tab)",
|
|
23
|
+
sending: "Sending:", queuedNote: "(queued)",
|
|
22
24
|
},
|
|
23
25
|
"zh-TW": {
|
|
24
26
|
title: "組織圖", general: "General", unteamed: "未加入 team", members: "{0} 名成員", member: "1 名成員", instances: "{0} 個 instance", instance: "1 個 instance",
|
|
@@ -26,6 +28,7 @@ register("org", {
|
|
|
26
28
|
missing: "不在 fleet 中", thread_discord: "Discord 討論串", thread_telegram: "Telegram topic", openChat: "開啟聊天",
|
|
27
29
|
classic: "ClassicBot 頻道", empty: "還沒有 instance", emptyHint: "fleet 有 instance 和 team 後會顯示在這裡。",
|
|
28
30
|
noTeams: "還沒有 team——到 Fleet → Teams 定義後,組織圖會依 team 分組。", newTab: "(在新分頁開啟)",
|
|
31
|
+
sending: "傳送中:", queuedNote: "(排隊中)",
|
|
29
32
|
},
|
|
30
33
|
});
|
|
31
34
|
const to = (k, ...v) => t(`org.${k}`, ...v);
|
|
@@ -83,15 +86,22 @@ export function buildOrgView(org, app) {
|
|
|
83
86
|
return { top: general.map(node), teams, rest: { nodes: rest, counts: counts(rest) }, total: all.length, counts: counts(all) };
|
|
84
87
|
}
|
|
85
88
|
|
|
86
|
-
|
|
89
|
+
/** The states in a group, most pressing first, with how many are in each (the canvas's bar shows it too). */
|
|
90
|
+
export function OrgSummary({ counts }) {
|
|
87
91
|
return html`<ul class="org-counts">${counts.map(([s, n]) => html`<li key=${s} class=${`org-count st-${s}`}>
|
|
88
92
|
<span class=${`dot ${DOT[s]}`} aria-hidden="true"></span>${n} ${to(s)}</li>`)}</ul>`;
|
|
89
93
|
}
|
|
90
94
|
|
|
91
|
-
|
|
95
|
+
/** #1918: what an instance is sending now, in words — the canvas's live traffic for the list (and a screen reader). */
|
|
96
|
+
const KIND_WORD = { task: "delegate_task", report: "report_result", query: "request_information", update: "send_to_instance", send: "send_to_instance", broadcast: "broadcast" };
|
|
97
|
+
function Sending({ list }) {
|
|
98
|
+
return html`<p class="org-sending"><span class="muted">${to("sending")}</span> ${list.map((s, i) => html`<span key=${s.key} class=${`org-send k-${s.kind}`}>${i ? ", " : ""}${KIND_WORD[s.kind] || s.kind} → ${s.to}${s.queued ? ` ${to("queuedNote")}` : ""}</span>`)}</p>`;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function Node({ n, sending }) {
|
|
92
102
|
const alias = n.i && typeof n.i.display_name === "string" && n.i.display_name.trim() && n.i.display_name.trim() !== n.name ? n.i.display_name.trim() : "";
|
|
93
103
|
const thread = n.info.thread;
|
|
94
|
-
const model = n.i && n.i.model ? n.i
|
|
104
|
+
const model = n.i && n.i.model ? modelText(n.i) : "";
|
|
95
105
|
return html`<li class=${`org-node st-${n.state}`}>
|
|
96
106
|
<div class="org-node-head">
|
|
97
107
|
<span class=${`dot ${DOT[n.state]}`} aria-hidden="true"></span>
|
|
@@ -100,6 +110,7 @@ function Node({ n }) {
|
|
|
100
110
|
<span class=${`org-state st-${n.state}`}>${to(n.state)}</span>
|
|
101
111
|
</div>
|
|
102
112
|
${n.info.description ? html`<p class="org-desc" title=${n.info.description}>${n.info.description}</p>` : null}
|
|
113
|
+
${sending && sending.length ? html`<${Sending} list=${sending} />` : null}
|
|
103
114
|
${n.i ? html`<div class="org-meta">${n.i.backend ? html`<span class="org-chip">${n.i.backend}</span>` : null}
|
|
104
115
|
${model ? html`<span class="org-model mono" title=${model}>${model}</span>` : null}
|
|
105
116
|
${n.info.classic ? html`<span class="org-chip">${to("classic")}</span>` : null}</div>` : null}
|
|
@@ -111,32 +122,36 @@ function Node({ n }) {
|
|
|
111
122
|
</li>`;
|
|
112
123
|
}
|
|
113
124
|
|
|
114
|
-
function Team({ id, name, description, nodes, counts, icon }) {
|
|
125
|
+
function Team({ id, name, description, nodes, counts, icon, sending }) {
|
|
115
126
|
return html`<section class="org-team" aria-labelledby=${id}>
|
|
116
127
|
<header class="org-team-head">
|
|
117
128
|
<h3 id=${id}><${Icon} name=${icon} size=${16} /><span>${name}</span></h3>
|
|
118
129
|
${description ? html`<p class="org-team-desc">${description}</p>` : null}
|
|
119
|
-
<div class="org-team-sub"><span class="muted">${count("member", "members", nodes.length)}</span><${
|
|
130
|
+
<div class="org-team-sub"><span class="muted">${count("member", "members", nodes.length)}</span><${OrgSummary} counts=${counts} /></div>
|
|
120
131
|
</header>
|
|
121
|
-
<ul class="org-nodes">${nodes.map((n) => html`<${Node} key=${n.name} n=${n} />`)}</ul>
|
|
132
|
+
<ul class="org-nodes">${nodes.map((n) => html`<${Node} key=${n.name} n=${n} sending=${sending && sending.get(n.name)} />`)}</ul>
|
|
122
133
|
</section>`;
|
|
123
134
|
}
|
|
124
135
|
|
|
125
|
-
/**
|
|
126
|
-
|
|
136
|
+
/**
|
|
137
|
+
* The chart as a list. `org`: what /ui/org answered (the Fleet panel's loader); `app`: the app store. #1918: `bare` when
|
|
138
|
+
* the canvas's bar (fleet-canvas.js) already shows the count and the states; `sending` what each instance is sending
|
|
139
|
+
* now (name → list), null when Live is off.
|
|
140
|
+
*/
|
|
141
|
+
export function OrgChart({ org, app, bare = false, sending = null }) {
|
|
127
142
|
if (!app.ready) return html`<${Skeleton} lines=${4} />`;
|
|
128
143
|
const view = buildOrgView(org, app);
|
|
129
144
|
if (!view.total) return html`<${Empty} icon="team" title=${to("empty")} hint=${to("emptyHint")} />`;
|
|
130
145
|
return html`<div class="org">
|
|
131
|
-
|
|
146
|
+
${bare ? null : html`<div class="list-head org-head"><span>${count("instance", "instances", view.total)}</span><${OrgSummary} counts=${view.counts} /></div>`}
|
|
132
147
|
${view.top.length ? html`<section class="org-top" aria-labelledby="org-general">
|
|
133
148
|
<h3 id="org-general" class="sr-only">${to("general")}</h3>
|
|
134
|
-
<ul class="org-nodes">${view.top.map((n) => html`<${Node} key=${n.name} n=${n} />`)}</ul>
|
|
149
|
+
<ul class="org-nodes">${view.top.map((n) => html`<${Node} key=${n.name} n=${n} sending=${sending && sending.get(n.name)} />`)}</ul>
|
|
135
150
|
</section>` : null}
|
|
136
151
|
${view.teams.length ? null : html`<p class="note org-note">${to("noTeams")}</p>`}
|
|
137
152
|
<div class="org-teams">
|
|
138
|
-
${view.teams.map((team, k) => html`<${Team} key=${team.name} id=${`org-team-${k}`} icon="team" ...${team} />`)}
|
|
139
|
-
${view.rest.nodes.length ? html`<${Team} id="org-rest" icon="fleet" name=${to("unteamed")} description="" ...${view.rest} />` : null}
|
|
153
|
+
${view.teams.map((team, k) => html`<${Team} key=${team.name} id=${`org-team-${k}`} icon="team" sending=${sending} ...${team} />`)}
|
|
154
|
+
${view.rest.nodes.length ? html`<${Team} id="org-rest" icon="fleet" name=${to("unteamed")} description="" sending=${sending} ...${view.rest} />` : null}
|
|
140
155
|
</div>
|
|
141
156
|
</div>`;
|
|
142
157
|
}
|