@nelsonlongxiang/dsh-open-a2a-net 0.5.13

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.
@@ -0,0 +1,239 @@
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ /**
3
+ * A2A network control: a footer action opening this host's network panel.
4
+ * Probes the host's session-node state route at registration time (the seat
5
+ * renders only on a host that serves it). The panel has three sections:
6
+ * session nodes (live rows show title, recent-activity excerpt, and team with
7
+ * a join/leave toggle and open the session on row click — the wake action on
8
+ * cold rows opens the session through the standard flow), the peer fleet
9
+ * (tracked URLs with quality scores), and the recent routing activity ring
10
+ * (inbound and outbound outcomes). While open, the panel polls so listings
11
+ * visibly follow each turn.
12
+ */
13
+ import { useCallback, useEffect, useRef, useState } from 'react';
14
+ import clsx from 'clsx';
15
+ import { Button, IconGlobeOutline14 } from '@deepseek-ai/dsh-client-ui-primitives';
16
+ import css from './A2aControl.module.css';
17
+ /** Fetch the host's network state; undefined means the route is absent. */
18
+ async function fetchState() {
19
+ const response = await fetch('/__dsh_a2a/state', { cache: 'no-store' });
20
+ if (!response.ok)
21
+ return undefined;
22
+ const body = await response.json();
23
+ if (body.nodes !== true || !Array.isArray(body.sessions))
24
+ return undefined;
25
+ return {
26
+ sessions: body.sessions,
27
+ groups: Array.isArray(body.groups) ? body.groups : [],
28
+ peers: Array.isArray(body.peers) ? body.peers : [],
29
+ activity: Array.isArray(body.activity) ? body.activity : [],
30
+ inFlight: Array.isArray(body.inFlight) ? body.inFlight : [],
31
+ remote: Array.isArray(body.remote) ? body.remote : [],
32
+ ...(typeof body.version === 'string' ? { version: body.version } : {}),
33
+ };
34
+ }
35
+ /** Compact relative time for an activity row. */
36
+ function relativeTime(ts) {
37
+ const seconds = Math.max(0, Math.round((Date.now() - ts) / 1000));
38
+ if (seconds < 60)
39
+ return `${String(seconds)}s`;
40
+ const minutes = Math.round(seconds / 60);
41
+ if (minutes < 60)
42
+ return `${String(minutes)}m`;
43
+ return `${String(Math.round(minutes / 60))}h`;
44
+ }
45
+ /**
46
+ * One session row: the facts block opens the session, the toggle joins or
47
+ * leaves, and the tag button opens the group picker (assign / create /
48
+ * clear).
49
+ */
50
+ function SessionRow({ row, t, listById, openSession, busy, onToggle, pickerFor, setPickerFor, groups, newGroupName, setNewGroupName, onAssign }) {
51
+ const cold = row.live === false;
52
+ const name = cold ? (listById[row.id]?.displayTitle ?? row.label) : (row.name ?? row.label);
53
+ const description = cold ? t('a2a.cold') : (row.description ?? '');
54
+ const pickerOpen = pickerFor === row.id;
55
+ return (_jsxs("div", { className: clsx(css.row, row.joined && css.joined), children: [_jsxs("button", { type: "button", className: css.facts, title: cold ? t('a2a.wake') : name, onClick: () => { openSession(row.id); }, children: [_jsxs("span", { className: css.nameRow, children: [_jsx("span", { className: clsx(css.stateDot, cold ? 'cold' : 'live', row.joined && 'joined'), "aria-hidden": true }), _jsx("span", { className: css.name, children: name }), row.group !== undefined ? _jsx("span", { className: css.groupTag, children: row.group }) : null] }), _jsx("span", { className: css.description, children: description }), _jsx("span", { className: css.team, children: row.team })] }), _jsx("button", { type: "button", className: css.groupButton, title: t('a2a.group'), "aria-label": t('a2a.group'), onClick: () => { setPickerFor(pickerOpen ? null : row.id); }, children: "#" }), cold
56
+ ? (_jsx(Button, { variant: "outline", size: "sm", disabled: busy === row.id, onClick: () => { onToggle(row); }, children: t('a2a.leave') }))
57
+ : (_jsx(Button, { variant: row.joined ? 'outline' : 'primary', size: "sm", disabled: busy === row.id, onClick: () => { onToggle(row); }, children: row.joined ? t('a2a.leave') : t('a2a.join') })), pickerOpen && (_jsxs("div", { className: css.groupPicker, children: [_jsx("button", { type: "button", className: css.groupOption, onClick: () => { onAssign(row.id, ''); }, children: t('a2a.groupClear') }), groups.map((group) => (_jsx("button", { type: "button", className: clsx(css.groupOption, row.group === group && css.groupOptionActive), onClick: () => { onAssign(row.id, group); }, children: group }, group))), _jsxs("div", { className: css.groupNew, children: [_jsx("input", { className: css.groupInput, value: newGroupName, placeholder: t('a2a.groupNew'), maxLength: 40, onChange: (event) => { setNewGroupName(event.target.value); }, onKeyDown: (event) => {
58
+ if (event.key === 'Enter' && newGroupName.trim() !== '')
59
+ onAssign(row.id, newGroupName.trim());
60
+ } }), _jsx(Button, { variant: "primary", size: "sm", disabled: newGroupName.trim() === '', onClick: () => { if (newGroupName.trim() !== '')
61
+ onAssign(row.id, newGroupName.trim()); }, children: t('a2a.groupNewGo') })] })] }))] }));
62
+ }
63
+ /**
64
+ * Render the network toggle and its network panel.
65
+ * @param props - composed slot props.
66
+ * @returns the control element tree.
67
+ */
68
+ export function A2aControl({ wide, t, useSessions, openSession }) {
69
+ const [open, setOpen] = useState(false);
70
+ const [state, setState] = useState({ sessions: [], groups: [], peers: [], activity: [], inFlight: [], remote: [] });
71
+ const [busy, setBusy] = useState(null);
72
+ const [seenActivity, setSeenActivity] = useState(0);
73
+ // Panel-local search: filters session rows by name/team/description.
74
+ const [search, setSearch] = useState('');
75
+ // Group UI: the row whose group picker is open, and collapsed groups.
76
+ const [pickerFor, setPickerFor] = useState(null);
77
+ const [newGroupName, setNewGroupName] = useState('');
78
+ const [collapsed, setCollapsed] = useState(new Set());
79
+ const stopped = useRef(false);
80
+ useEffect(() => () => { stopped.current = true; }, []);
81
+ const wrap = useRef(null);
82
+ // Cold rows carry no host-side facts; the client's own session list holds
83
+ // the durable title for exactly those sessions.
84
+ const listById = useSessions(state2 => state2.byId);
85
+ // The popover anchors to the trigger's viewport rect: the sidebar column
86
+ // clips its descendants (the collapse slide), so an in-column absolute
87
+ // popover is cut at the column edge whenever the column is narrower than
88
+ // the popover; fixed positioning escapes the clip at rest (the ui-settings
89
+ // panel precedent). The left edge clamps so a narrow viewport never hides
90
+ // the popover's right side.
91
+ const [anchor, setAnchor] = useState(null);
92
+ // The unread badge counts inbound activity entries that arrived while the
93
+ // panel was closed; opening the panel clears it.
94
+ const unread = Math.max(0, state.activity.filter(entry => entry.dir === 'in').length - seenActivity);
95
+ const toggleOpen = () => {
96
+ if (open) {
97
+ setOpen(false);
98
+ return;
99
+ }
100
+ const rect = wrap.current?.getBoundingClientRect();
101
+ if (rect !== undefined) {
102
+ setAnchor({
103
+ left: Math.max(8, Math.min(rect.left, window.innerWidth - 336)),
104
+ bottom: window.innerHeight - rect.top + 8,
105
+ });
106
+ }
107
+ setSeenActivity(state.activity.filter(entry => entry.dir === 'in').length);
108
+ setOpen(true);
109
+ };
110
+ const refresh = useCallback(() => {
111
+ void fetchState().then((next) => {
112
+ if (!stopped.current && next !== undefined)
113
+ setState(next);
114
+ }).catch(() => { });
115
+ }, []);
116
+ useEffect(() => {
117
+ if (open)
118
+ refresh();
119
+ }, [open, refresh]);
120
+ useEffect(() => {
121
+ // The unread badge lives on the closed trigger, so the state must move
122
+ // even while the panel is closed — a slow poll keeps it fresh; the open
123
+ // panel's faster poll below takes over while visible.
124
+ const slow = setInterval(refresh, 10_000);
125
+ refresh();
126
+ return () => { clearInterval(slow); };
127
+ }, [refresh]);
128
+ useEffect(() => {
129
+ if (!open)
130
+ return;
131
+ // While the panel is open, titles, activity excerpts, and the routing
132
+ // ring keep moving; poll so the listing visibly follows.
133
+ const poll = setInterval(refresh, 2_000);
134
+ return () => { clearInterval(poll); };
135
+ }, [open, refresh]);
136
+ useEffect(() => {
137
+ if (!open)
138
+ return;
139
+ const onPointerDown = (event) => {
140
+ if (event.target.closest(`.${css.wrap}`) === null)
141
+ setOpen(false);
142
+ };
143
+ document.addEventListener('pointerdown', onPointerDown);
144
+ return () => { document.removeEventListener('pointerdown', onPointerDown); };
145
+ }, [open]);
146
+ /** Join or leave one session, then refresh the listing. */
147
+ const toggle = (row) => {
148
+ setBusy(row.id);
149
+ void fetch(`/__dsh_a2a/${row.joined ? 'leave' : 'join'}`, {
150
+ method: 'POST',
151
+ headers: { 'Content-Type': 'application/json' },
152
+ body: JSON.stringify({ id: row.id }),
153
+ })
154
+ .then(() => { refresh(); })
155
+ .catch(() => { })
156
+ .finally(() => { if (!stopped.current)
157
+ setBusy(null); });
158
+ };
159
+ /** Assign one session to a group ('' clears), then refresh. */
160
+ const assign = (id, name) => {
161
+ void fetch('/__dsh_a2a/groups', {
162
+ method: 'POST',
163
+ headers: { 'Content-Type': 'application/json' },
164
+ body: JSON.stringify({ action: 'assign', id, name }),
165
+ })
166
+ .then(() => { setPickerFor(null); setNewGroupName(''); refresh(); })
167
+ .catch(() => { });
168
+ };
169
+ const { sessions, groups, peers, activity, inFlight, remote } = state;
170
+ // Search filters by name, team, or description (case-insensitive).
171
+ const needle = search.trim().toLowerCase();
172
+ const matches = (row) => {
173
+ if (needle === '')
174
+ return true;
175
+ const name = row.live === false ? (listById[row.id]?.displayTitle ?? row.label) : (row.name ?? row.label);
176
+ return name.toLowerCase().includes(needle)
177
+ || row.team.toLowerCase().includes(needle)
178
+ || (row.description ?? '').toLowerCase().includes(needle);
179
+ };
180
+ const visible = sessions.filter(matches);
181
+ const ungrouped = visible.filter(row => row.group === undefined);
182
+ const grouped = groups
183
+ .map(name => ({ name, rows: visible.filter(row => row.group === name) }))
184
+ .filter(group => group.rows.length > 0);
185
+ return (_jsxs("div", { ref: wrap, className: clsx(css.wrap, wide ? css.wide : css.rail), children: [_jsx(Button, { variant: "ghost", size: "sm", title: t('a2a.label'), "aria-label": t('a2a.label'), "aria-expanded": open, onClick: () => { toggleOpen(); }, children: _jsxs("span", { className: css.trigger, children: [_jsx(IconGlobeOutline14, {}), wide ? _jsx("span", { children: t('a2a.label') }) : null, unread > 0 ? _jsx("span", { className: css.unreadBadge, "aria-label": String(unread), children: unread > 9 ? '9+' : String(unread) }) : null] }) }), open
186
+ ? (_jsxs("div", { className: css.popover, role: "dialog", "aria-label": t('a2a.title'), style: anchor === null ? undefined : { left: `${String(anchor.left)}px`, bottom: `${String(anchor.bottom)}px` }, children: [_jsxs("div", { className: css.title, children: [t('a2a.title'), state.version !== undefined ? _jsxs("span", { className: css.versionTag, title: state.version, children: ["v", state.version] }) : null] }), _jsx("input", { className: css.searchInput, type: "search", value: search, placeholder: t('a2a.search'), "aria-label": t('a2a.search'), onChange: (event) => { setSearch(event.target.value); } }), sessions.length === 0
187
+ ? _jsx("div", { className: css.empty, children: t('a2a.empty') })
188
+ : visible.length === 0
189
+ ? _jsx("div", { className: css.empty, children: t('a2a.searchEmpty') })
190
+ : (_jsxs(_Fragment, { children: [ungrouped.length > 0 && (_jsxs("div", { className: css.sessionGroup, children: [_jsxs("button", { type: "button", className: css.groupHead, onClick: () => {
191
+ setCollapsed((current) => {
192
+ const next = new Set(current);
193
+ if (next.has(''))
194
+ next.delete('');
195
+ else
196
+ next.add('');
197
+ return next;
198
+ });
199
+ }, children: [_jsx("span", { className: clsx(css.groupChevron, collapsed.has('') && css.groupChevronClosed), "aria-hidden": true, children: "\u25BE" }), t('a2a.groupDefault'), " \u00B7 ", String(ungrouped.length)] }), !collapsed.has('') && ungrouped.map((row) => (_jsx(SessionRow, { row: row, t: t, listById: listById, openSession: openSession, busy: busy, onToggle: toggle, pickerFor: pickerFor, setPickerFor: setPickerFor, groups: groups, newGroupName: newGroupName, setNewGroupName: setNewGroupName, onAssign: assign }, row.id)))] })), grouped.map(({ name, rows }) => {
200
+ const isCollapsed = collapsed.has(name);
201
+ return (_jsxs("div", { className: css.sessionGroup, children: [_jsxs("button", { type: "button", className: css.groupHead, onClick: () => {
202
+ setCollapsed((current) => {
203
+ const next = new Set(current);
204
+ if (next.has(name))
205
+ next.delete(name);
206
+ else
207
+ next.add(name);
208
+ return next;
209
+ });
210
+ }, children: [_jsx("span", { className: clsx(css.groupChevron, isCollapsed && css.groupChevronClosed), "aria-hidden": true, children: "\u25BE" }), name, " \u00B7 ", String(rows.length)] }), !isCollapsed && rows.map((row) => (_jsx(SessionRow, { row: row, t: t, listById: listById, openSession: openSession, busy: busy, onToggle: toggle, pickerFor: pickerFor, setPickerFor: setPickerFor, groups: groups, newGroupName: newGroupName, setNewGroupName: setNewGroupName, onAssign: assign }, row.id)))] }, name));
211
+ })] })), _jsx("div", { className: css.note, children: t('a2a.note') }), (needle === '' ? remote : remote.filter(row => row.team.toLowerCase().includes(needle) || row.name.toLowerCase().includes(needle) || (row.origin ?? '').toLowerCase().includes(needle) || (row.workspace ?? '').toLowerCase().includes(needle))).length > 0 && (_jsxs(_Fragment, { children: [_jsx("div", { className: css.sectionTitle, children: t('a2a.remote') }), Object.entries((needle === '' ? remote : remote.filter(row => row.team.toLowerCase().includes(needle) || row.name.toLowerCase().includes(needle) || (row.origin ?? '').toLowerCase().includes(needle) || (row.workspace ?? '').toLowerCase().includes(needle)))
212
+ .reduce((byOrigin, row) => {
213
+ const key = row.origin ?? '';
214
+ byOrigin[key] = [...(byOrigin[key] ?? []), row];
215
+ return byOrigin;
216
+ }, {})).map(([origin, rows]) => (_jsxs("div", { className: css.sessionGroup, children: [_jsxs("button", { type: "button", className: css.groupHead, onClick: () => {
217
+ setCollapsed((current) => {
218
+ const next = new Set(current);
219
+ if (next.has('remote:' + origin))
220
+ next.delete('remote:' + origin);
221
+ else
222
+ next.add('remote:' + origin);
223
+ return next;
224
+ });
225
+ }, children: [_jsx("span", { className: clsx(css.groupChevron, collapsed.has('remote:' + origin) && css.groupChevronClosed), "aria-hidden": true, children: "\u25BE" }), origin === '' ? t('a2a.remoteUnknownOrigin') : origin, " \u00B7 ", String(rows.length)] }), !collapsed.has('remote:' + origin) && rows.map((row) => (_jsx("div", { className: css.row, title: row.workspace ?? undefined, children: _jsxs("div", { className: css.facts, children: [_jsxs("span", { className: css.nameRow, children: [_jsx("span", { className: css.stateDot, "data-peer": true, "aria-hidden": true }), _jsx("span", { className: css.name, children: row.name === '' ? row.team : row.name }), row.workspace !== undefined ? _jsx("span", { className: css.groupTag, children: row.workspace }) : null] }), _jsx("span", { className: css.team, children: row.team })] }) }, row.team)))] }, origin)))] })), _jsx("div", { className: css.sectionTitle, children: t('a2a.peers') }), peers.length === 0
226
+ ? _jsx("div", { className: css.empty, children: t('a2a.peersEmpty') })
227
+ : (_jsx("div", { className: css.peerGrid, children: peers.map((peer) => (_jsxs("span", { className: css.peerChip, title: peer.url, children: [_jsx("span", { className: css.peerHost, children: peer.url.replace(/^https?:\/\//, '').replace(/\/.*$/, '') }), typeof peer.score === 'number' ? _jsx("span", { className: css.peerScore, children: String(Math.round(peer.score)) }) : null] }, peer.url))) })), _jsx("div", { className: css.sectionTitle, children: t('a2a.activity') }), inFlight.length > 0 && (_jsx("div", { className: css.inFlightList, "aria-label": t('a2a.inFlight'), children: inFlight.map((route) => (_jsxs("div", { className: clsx(css.inFlightRow, Date.now() - route.startedAt > 120_000 && css.inFlightStale), title: Date.now() - route.startedAt > 120_000 ? t('a2a.inFlightStale') : undefined, children: [_jsx("span", { className: css.inFlightPulse, "aria-hidden": true }), _jsx("span", { className: clsx(css.activityDir, 'out'), children: '→' }), _jsx("span", { className: css.activityTeam, title: route.team, children: route.team }), _jsx("span", { className: css.activityPeer, children: route.peer === 'local' ? '' : route.peer.replace(/^https?:\/\//, '').replace(/\/.*$/, '') }), _jsx("span", { className: css.activityTime, children: relativeTime(route.startedAt) })] }, `${route.team}-${String(route.startedAt)}`))) })), activity.length === 0
228
+ ? _jsx("div", { className: css.empty, children: t('a2a.activityEmpty') })
229
+ : (_jsx("div", { className: css.activityList, children: [...activity].reverse().slice(0, 10).map((entry, index) => {
230
+ // A same-host session team resolves to its session row;
231
+ // clicking the activity line jumps there (the linkage
232
+ // into an ongoing collaboration).
233
+ const target = sessions.find(row => row.team === entry.team);
234
+ const fresh = Date.now() - entry.ts < 5_000;
235
+ return (_jsxs("div", { className: clsx(css.activityRow, fresh && css.fresh), "data-ok": entry.ok, "data-jump": target !== undefined, title: target !== undefined ? t('a2a.jump') : undefined, onClick: () => { if (target !== undefined)
236
+ openSession(target.id); }, role: target !== undefined ? 'button' : undefined, children: [_jsx("span", { className: clsx(css.activityDir, entry.dir), children: entry.dir === 'in' ? '←' : '→' }), _jsx("span", { className: css.activityTeam, title: entry.team, children: entry.team }), _jsx("span", { className: css.activityPeer, title: entry.peer, children: entry.peer === '' ? '' : entry.peer.replace(/^https?:\/\//, '').replace(/\/.*$/, '') }), _jsx("span", { className: css.activityTime, children: relativeTime(entry.ts) })] }, `${String(entry.ts)}-${String(index)}`));
237
+ }) }))] }))
238
+ : null] }));
239
+ }
@@ -0,0 +1,35 @@
1
+ import { A2aControl } from "./A2aControl.js";
2
+ import { en, zh } from "./locales.js";
3
+ /** Dictionary namespace owned by this plugin (network control copy). */
4
+ const NS = 'a2aNet';
5
+ /** Services required by the browser half: the slot system, the sessions
6
+ * flow used to wake cold rows, and the locale dictionaries. */
7
+ export const inject = ['slots', 'sessions', 'locale'];
8
+ /** Registers the network control's dictionaries and its sidebar seat.
9
+ * @param ctx - Client root context.
10
+ */
11
+ export function apply(ctx) {
12
+ ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'a2a-net: dictionaries');
13
+ // The network control renders only on a host that serves the session-node
14
+ // state route (a2a host half + session nodes composed); the probe keeps
15
+ // the seat quiet everywhere else.
16
+ void fetch('/__dsh_a2a/state', { cache: 'no-store' })
17
+ .then(response => (response.ok ? response.json() : undefined))
18
+ .then((info) => {
19
+ if (info?.nodes !== true)
20
+ return;
21
+ ctx.slots.inject('sidebar.footer.action', () => ctx.slots.register({
22
+ name: 'sidebar.footer.action',
23
+ id: 'a2a',
24
+ order: 10,
25
+ locale: NS,
26
+ // Wake = the standard session-open flow: the host materializes the
27
+ // session's agent and the a2a host half's persisted intent remounts
28
+ // the node, so the cold row flips live on the next poll.
29
+ inject: () => ({
30
+ openSession: (id) => { ctx.sessions.open(id); },
31
+ }),
32
+ }, A2aControl));
33
+ })
34
+ .catch(() => { });
35
+ }
@@ -0,0 +1,55 @@
1
+ /** Locale dictionaries for the A2A network sidebar control (namespace `a2aNet`). */
2
+ /** Simplified Chinese dictionary. */
3
+ export const zh = {
4
+ 'a2a.label': 'A2A 网络',
5
+ 'a2a.title': 'A2A 网络面板',
6
+ 'a2a.empty': '没有可加入的会话',
7
+ 'a2a.join': '加入',
8
+ 'a2a.leave': '退出',
9
+ 'a2a.cold': '未加载(重启后待唤醒)',
10
+ 'a2a.wake': '唤醒',
11
+ 'a2a.note': '只有加入的会话才会被网络中的其他节点发现;点击会话行可跳转。',
12
+ 'a2a.remote': '远程节点(按 host 分组)',
13
+ 'a2a.remoteUnknownOrigin': '未知来源',
14
+ 'a2a.peers': '对等节点',
15
+ 'a2a.peersEmpty': '尚无已知对等节点(配 peers 种子入网)',
16
+ 'a2a.activity': '路由活动',
17
+ 'a2a.activityEmpty': '暂无路由记录',
18
+ 'a2a.inFlight': '进行中的路由',
19
+ 'a2a.inFlightStale': '等待回执超时(对端处理慢或回执丢失);180 秒后自动解除并按已送达处理',
20
+ 'a2a.jump': '点击跳转到该会话',
21
+ 'a2a.search': '搜索会话(名称 / team / 摘要)',
22
+ 'a2a.searchEmpty': '没有匹配的会话',
23
+ 'a2a.group': '设置分组',
24
+ 'a2a.groupDefault': '未分组',
25
+ 'a2a.groupClear': '清除分组',
26
+ 'a2a.groupNew': '新建分组…',
27
+ 'a2a.groupNewGo': '创建并归入',
28
+ };
29
+ /** English dictionary. */
30
+ export const en = {
31
+ 'a2a.label': 'A2A network',
32
+ 'a2a.title': 'A2A network panel',
33
+ 'a2a.empty': 'No joinable sessions',
34
+ 'a2a.join': 'Join',
35
+ 'a2a.leave': 'Leave',
36
+ 'a2a.cold': 'not loaded (waiting to wake after a restart)',
37
+ 'a2a.wake': 'Wake',
38
+ 'a2a.note': 'Only joined sessions are discoverable by other nodes; click a row to open the session.',
39
+ 'a2a.remote': 'Remote teams (by host)',
40
+ 'a2a.remoteUnknownOrigin': 'Unknown origin',
41
+ 'a2a.peers': 'Peers',
42
+ 'a2a.peersEmpty': 'No known peers yet (set peers seeds to join the mesh)',
43
+ 'a2a.activity': 'Routing activity',
44
+ 'a2a.activityEmpty': 'No routes yet',
45
+ 'a2a.inFlight': 'Routes in flight',
46
+ 'a2a.inFlightStale': 'Reply wait past 120s (slow or lost receipt); auto-releases as delivered at 180s',
47
+ 'a2a.jump': 'Click to open this session',
48
+ 'a2a.search': 'Search sessions (name / team / excerpt)',
49
+ 'a2a.searchEmpty': 'No sessions match',
50
+ 'a2a.group': 'Set group',
51
+ 'a2a.groupDefault': 'Ungrouped',
52
+ 'a2a.groupClear': 'Clear group',
53
+ 'a2a.groupNew': 'New group…',
54
+ 'a2a.groupNewGo': 'Create & assign',
55
+ };