@songsid/agend 2.3.0-beta.19 → 2.3.0-beta.20

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.
Files changed (81) hide show
  1. package/dist/agent-cli-instructions.md +4 -0
  2. package/dist/agent-cli.js +25 -0
  3. package/dist/agent-cli.js.map +1 -1
  4. package/dist/channel/mcp-tools.js +2 -1
  5. package/dist/channel/mcp-tools.js.map +1 -1
  6. package/dist/config-validator.js +19 -1
  7. package/dist/config-validator.js.map +1 -1
  8. package/dist/cross-instance-notice.d.ts +7 -0
  9. package/dist/cross-instance-notice.js +10 -0
  10. package/dist/cross-instance-notice.js.map +1 -1
  11. package/dist/daemon.js +4 -0
  12. package/dist/daemon.js.map +1 -1
  13. package/dist/delivery-outbox.js +5 -0
  14. package/dist/delivery-outbox.js.map +1 -1
  15. package/dist/fleet-manager.d.ts +26 -0
  16. package/dist/fleet-manager.js +115 -14
  17. package/dist/fleet-manager.js.map +1 -1
  18. package/dist/general-knowledge/skills/scheduling/SKILL.md +4 -0
  19. package/dist/general-knowledge/skills/web-dashboard/SKILL.md +25 -1
  20. package/dist/instance-config-impact.js +4 -0
  21. package/dist/instance-config-impact.js.map +1 -1
  22. package/dist/log-steps.d.ts +1 -0
  23. package/dist/log-steps.js +15 -0
  24. package/dist/log-steps.js.map +1 -1
  25. package/dist/outbound-handlers.d.ts +3 -0
  26. package/dist/outbound-handlers.js +20 -4
  27. package/dist/outbound-handlers.js.map +1 -1
  28. package/dist/outbound-schemas.d.ts +33 -0
  29. package/dist/outbound-schemas.js +25 -8
  30. package/dist/outbound-schemas.js.map +1 -1
  31. package/dist/public-web-gateway.js +5 -2
  32. package/dist/public-web-gateway.js.map +1 -1
  33. package/dist/scheduler/db.d.ts +40 -13
  34. package/dist/scheduler/db.js +123 -89
  35. package/dist/scheduler/db.js.map +1 -1
  36. package/dist/scheduler/task-board-text.d.ts +26 -0
  37. package/dist/scheduler/task-board-text.js +135 -0
  38. package/dist/scheduler/task-board-text.js.map +1 -0
  39. package/dist/scheduler/task-board.d.ts +650 -0
  40. package/dist/scheduler/task-board.js +1938 -0
  41. package/dist/scheduler/task-board.js.map +1 -0
  42. package/dist/scheduler/types.d.ts +6 -1
  43. package/dist/search/search-docs.d.ts +58 -0
  44. package/dist/search/search-docs.js +63 -0
  45. package/dist/search/search-docs.js.map +1 -0
  46. package/dist/search/search-index.d.ts +141 -0
  47. package/dist/search/search-index.js +440 -0
  48. package/dist/search/search-index.js.map +1 -0
  49. package/dist/search/search-store.d.ts +122 -0
  50. package/dist/search/search-store.js +236 -0
  51. package/dist/search/search-store.js.map +1 -0
  52. package/dist/search/search-worker.d.ts +1 -0
  53. package/dist/search/search-worker.js +163 -0
  54. package/dist/search/search-worker.js.map +1 -0
  55. package/dist/task-board-tool.d.ts +25 -0
  56. package/dist/task-board-tool.js +154 -0
  57. package/dist/task-board-tool.js.map +1 -0
  58. package/dist/task-board-waker.d.ts +44 -0
  59. package/dist/task-board-waker.js +128 -0
  60. package/dist/task-board-waker.js.map +1 -0
  61. package/dist/types.d.ts +15 -0
  62. package/dist/ui/fleet-task-card.js +436 -0
  63. package/dist/ui/fleet-task-common.js +448 -0
  64. package/dist/ui/fleet-tasks.js +535 -0
  65. package/dist/ui/panel-chat.js +8 -0
  66. package/dist/ui/panel-fleet.js +21 -65
  67. package/dist/ui/search-dialog.js +349 -0
  68. package/dist/ui/shared/app-i18n.js +3 -0
  69. package/dist/ui/shared/app-shell.js +43 -1
  70. package/dist/ui/shared/app-stream.js +2 -1
  71. package/dist/ui/shared/app.css +270 -2
  72. package/dist/ui/shared/app.js +3 -1
  73. package/dist/ui/shared/ui-toast.js +16 -3
  74. package/dist/web-api.d.ts +14 -4
  75. package/dist/web-api.js +408 -10
  76. package/dist/web-api.js.map +1 -1
  77. package/dist/web-chat-history.d.ts +2 -0
  78. package/dist/web-chat-history.js +2 -0
  79. package/dist/web-chat-history.js.map +1 -1
  80. package/dist/workflow-templates/default.md +3 -0
  81. package/package.json +1 -1
@@ -1,5 +1,5 @@
1
- // #1408 step 1: the Fleet panel (/ui/fleet[/<tab>]) — tasks, schedules, teams and the fleet's config — and the
2
- // "New instance" dialog. Behind the session gate (/ui/js/), loaded the first time it is opened.
1
+ // #1408 step 1: the Fleet panel (/ui/fleet[/<tab>]) — tasks (the task board: fleet-tasks.js), schedules, teams and the
2
+ // fleet's config — and the "New instance" dialog. Behind the session gate (/ui/js/), loaded the first time it is opened.
3
3
  //
4
4
  // Opening a tab reads its list: a person's navigation, so it counts as use, as the old tab click did (#1374 — no timer
5
5
  // here re-reads anything). Every read is under the navigation's lease: a list that arrives after the person moved on
@@ -23,6 +23,7 @@ import { labelOf } from "/assets/instance-nav.js";
23
23
  import { CacheReport } from "./fleet-cache.js";
24
24
  import { UsageTab } from "./fleet-usage.js";
25
25
  import { BackendSelect, ModelSelect } from "./settings-dialogs.js";
26
+ import { TaskBoardTab } from "./fleet-tasks.js";
26
27
 
27
28
  /** #2015: one of the largest processes as text: "python3 18.1 GB (dev-a)". */
28
29
  export function memoryHogText(p) {
@@ -39,13 +40,11 @@ register("fleet", {
39
40
  hostMemory_top: "Largest: {0}", hostMemory_ownerFleet: "the fleet", hostMemory_ownerTmux: "tmux", hostMemory_ownerOther: "not an instance",
40
41
  title: "Fleet", tasks: "Tasks", schedules: "Schedules", teams: "Teams", org: "Org chart", cache: "Cache", usage: "Usage", config: "Config",
41
42
  docs: "Docs", docsNewTab: "Docs (opens in a new tab)",
42
- newTask: "New task", newSchedule: "New schedule", newTeam: "New team",
43
- noTasks: "No tasks yet", noTasksHint: "Create one to coordinate fleet work.", noSchedules: "No schedules yet", noSchedulesHint: "Automate recurring work with cron.",
44
- noTeams: "No teams yet", noTeamsHint: "Group instances for coordinated work.", countTasks: "{0} tasks", countSchedules: "{0} schedules", countTeams: "{0} teams", countTask: "1 task", countSchedule: "1 schedule", countTeam: "1 team",
45
- claim: "Claim", done: "Done", delete: "Delete", on: "on", off: "off", cancel: "Cancel", create: "Create", creating: "Creating…", save: "Save changes",
46
- taskTitle: "Title", taskDesc: "Description", priority: "Priority", assignee: "Assignee", unassigned: "Unassigned",
47
- pNormal: "Normal", pLow: "Low", pHigh: "High", pUrgent: "Urgent",
48
- cron: "Cron expression", message: "Message", target: "Target instance", label: "Label", teamName: "Team name", members: "Members",
43
+ newSchedule: "New schedule", newTeam: "New team",
44
+ noSchedules: "No schedules yet", noSchedulesHint: "Automate recurring work with cron.",
45
+ noTeams: "No teams yet", noTeamsHint: "Group instances for coordinated work.", countSchedules: "{0} schedules", countTeams: "{0} teams", countSchedule: "1 schedule", countTeam: "1 team",
46
+ delete: "Delete", on: "on", off: "off", cancel: "Cancel", create: "Create", creating: "Creating…", save: "Save changes",
47
+ taskDesc: "Description", cron: "Cron expression", message: "Message", target: "Target instance", label: "Label", teamName: "Team name", members: "Members",
49
48
  // #1724: schedules in words; Edit; once or repeating
50
49
  schedOnce: "Once · {0}", schedNext: "next {0}", schedRetrying: "retrying", schedDisabled: "disabled", schedSilent: "silent", schedTz: "Timezone: {0}",
51
50
  editSchedule: "Edit schedule", edit: "Edit", scheduleSaved: "Schedule saved", schedRepeat: "Repeat", schedOnceMode: "Once",
@@ -53,10 +52,10 @@ register("fleet", {
53
52
  schedTzBad: "Unknown timezone: {0}",
54
53
  cronEveryMinute: "Every minute", cronEveryMinutes: "Every {0} minutes", cronEveryHours: "Every {0} hours", cronDaily: "Daily {0}",
55
54
  cronWeekdays: "Weekdays {0}", cronDays: "{0} {1}", cronMonthly: "Day {0} of each month {1}", dayNames: "Sun,Mon,Tue,Wed,Thu,Fri,Sat", dayJoin: ", ",
56
- titleRequired: "Title required", scheduleFieldsRequired: "Cron, message, and target required", teamFieldsRequired: "Name and at least one member required",
57
- deleteSchedule: "Delete this schedule?", deleteTeam: "Delete team “{0}”?", taskCreated: "Task created", scheduleCreated: "Schedule created", scheduleDeleted: "Schedule deleted",
55
+ scheduleFieldsRequired: "Cron, message, and target required", teamFieldsRequired: "Name and at least one member required",
56
+ deleteSchedule: "Delete this schedule?", deleteTeam: "Delete team “{0}”?", scheduleCreated: "Schedule created", scheduleDeleted: "Schedule deleted",
58
57
  teamCreated: "Team created", teamDeleted: "Team deleted", teamUpdated: "Team updated", edit: "Edit", editTeam: "Edit team",
59
- teamNoRename: "Renaming isn't supported — create a new team and delete this one.", taskDone: "Task updated", configSaved: "Config saved", configRestart: " (restart the fleet to apply channel changes)",
58
+ teamNoRename: "Renaming isn't supported — create a new team and delete this one.", configSaved: "Config saved", configRestart: " (restart the fleet to apply channel changes)",
60
59
  channel: "Channel", type: "Type", tokenEnv: "Bot token env", groupId: "Group ID", show: "Show", hide: "Hide", access: "Access control", mode: "Mode", locked: "Locked", open: "Open",
61
60
  allowedUsers: "Allowed users", allowedUsersNote: "Comma-separated user IDs", defaults: "Defaults", backend: "Backend", roots: "Project roots", addRoot: "Add root", removeRoot: "Remove",
62
61
  saveNote: "Saving rewrites fleet.yaml (its comments are lost).",
@@ -71,23 +70,21 @@ register("fleet", {
71
70
  hostMemory_top: "最大:{0}", hostMemory_ownerFleet: "fleet 本身", hostMemory_ownerTmux: "tmux", hostMemory_ownerOther: "不屬於任何 instance",
72
71
  title: "Fleet", tasks: "Tasks", schedules: "排程", teams: "Teams", org: "組織圖", cache: "快取", usage: "用量", config: "設定",
73
72
  docs: "文件", docsNewTab: "文件(在新分頁開啟)",
74
- newTask: "新增 task", newSchedule: "新增排程", newTeam: "新增 team",
75
- noTasks: "還沒有 task", noTasksHint: "建立一個來協調 fleet 的工作。", noSchedules: "還沒有排程", noSchedulesHint: "用 cron 自動執行例行工作。",
76
- noTeams: "還沒有 team", noTeamsHint: "把 instance 分組一起工作。", countTasks: "{0} 個 task", countSchedules: "{0} 個排程", countTeams: "{0} 個 team", countTask: "1 個 task", countSchedule: "1 個排程", countTeam: "1 個 team",
77
- claim: "認領", done: "完成", delete: "刪除", on: "開", off: "關", cancel: "取消", create: "建立", creating: "建立中…", save: "儲存變更",
78
- taskTitle: "標題", taskDesc: "說明", priority: "優先順序", assignee: "負責人", unassigned: "未指派",
79
- pNormal: "一般", pLow: "低", pHigh: "高", pUrgent: "緊急",
80
- cron: "Cron 表示式", message: "訊息", target: "目標 instance", label: "標籤", teamName: "Team 名稱", members: "成員",
73
+ newSchedule: "新增排程", newTeam: "新增 team",
74
+ noSchedules: "還沒有排程", noSchedulesHint: "用 cron 自動執行例行工作。",
75
+ noTeams: "還沒有 team", noTeamsHint: "把 instance 分組一起工作。", countSchedules: "{0} 個排程", countTeams: "{0} 個 team", countSchedule: "1 個排程", countTeam: "1 個 team",
76
+ delete: "刪除", on: "開", off: "關", cancel: "取消", create: "建立", creating: "建立中…", save: "儲存變更",
77
+ taskDesc: "說明", cron: "Cron 表示式", message: "訊息", target: "目標 instance", label: "標籤", teamName: "Team 名稱", members: "成員",
81
78
  schedOnce: "一次 · {0}", schedNext: "下次 {0}", schedRetrying: "重試中", schedDisabled: "已停用", schedSilent: "靜默", schedTz: "時區:{0}",
82
79
  editSchedule: "編輯排程", edit: "編輯", scheduleSaved: "已儲存排程", schedRepeat: "重複", schedOnceMode: "一次",
83
80
  schedWhen: "日期與時間", schedTimezone: "時區", schedEnabled: "啟用", schedWhenIn: "以 {0} 時間", schedWhenRequired: "請選擇日期與時間",
84
81
  schedTzBad: "不認得的時區:{0}",
85
82
  cronEveryMinute: "每分鐘", cronEveryMinutes: "每 {0} 分鐘", cronEveryHours: "每 {0} 小時", cronDaily: "每天 {0}",
86
83
  cronWeekdays: "每週一到五 {0}", cronDays: "每週{0} {1}", cronMonthly: "每月 {0} 日 {1}", dayNames: "日,一,二,三,四,五,六", dayJoin: "、",
87
- titleRequired: "必須填寫標題", scheduleFieldsRequired: "必須填寫 Cron、訊息與目標 instance", teamFieldsRequired: "必須填寫名稱並選擇至少一名成員",
88
- deleteSchedule: "確定刪除此排程嗎?", deleteTeam: "確定刪除 team「{0}」嗎?", taskCreated: "Task 已建立", scheduleCreated: "排程已建立", scheduleDeleted: "排程已刪除",
84
+ scheduleFieldsRequired: "必須填寫 Cron、訊息與目標 instance", teamFieldsRequired: "必須填寫名稱並選擇至少一名成員",
85
+ deleteSchedule: "確定刪除此排程嗎?", deleteTeam: "確定刪除 team「{0}」嗎?", scheduleCreated: "排程已建立", scheduleDeleted: "排程已刪除",
89
86
  teamCreated: "Team 已建立", teamDeleted: "Team 已刪除", teamUpdated: "Team 已更新", edit: "編輯", editTeam: "編輯 team",
90
- teamNoRename: "不支援重新命名:請建立新的 team,再刪除這個。", taskDone: "Task 已更新", configSaved: "設定已儲存", configRestart: "(請重新啟動 fleet 以套用頻道變更)",
87
+ teamNoRename: "不支援重新命名:請建立新的 team,再刪除這個。", configSaved: "設定已儲存", configRestart: "(請重新啟動 fleet 以套用頻道變更)",
91
88
  channel: "頻道", type: "類型", tokenEnv: "Bot token 環境變數", groupId: "Group ID", show: "顯示", hide: "隱藏", access: "存取控制", mode: "模式", locked: "鎖定", open: "開放",
92
89
  allowedUsers: "允許的使用者", allowedUsersNote: "以逗號分隔的使用者 ID", defaults: "預設值", backend: "Backend", roots: "專案根目錄", addRoot: "新增根目錄", removeRoot: "移除",
93
90
  saveNote: "儲存會改寫 fleet.yaml(其中的註解會遺失)。",
@@ -143,7 +140,7 @@ export function FleetPanel({ route, navKey }) {
143
140
  const tab = route.tab;
144
141
  useEffect(() => { setTitle(`${t("fleet.title")} · ${t(`fleet.${tab}`)}`); }, [tab, navKey]);
145
142
  useEffect(() => { rememberFleetTab(tab); }, [tab]); // #2183: the sidebar's and the phone's Fleet reopen it
146
- const Body = { tasks: Tasks, schedules: Schedules, teams: Teams, org: Org, cache: Cache, usage: UsageTab, config: Config }[tab] || Tasks;
143
+ const Body = { tasks: TaskBoardTab, schedules: Schedules, teams: Teams, org: Org, cache: Cache, usage: UsageTab, config: Config }[tab] || TaskBoardTab;
147
144
  // #1685: host memory pressure slowing or holding new agents — shown here, where macOS's routine "elevated" is not a chat notice.
148
145
  const { hostMemory, hostMemoryTop } = useStore(appStore);
149
146
  // #2015: and which processes hold the most, and whose — text only (never chat, #1712).
@@ -153,7 +150,7 @@ export function FleetPanel({ route, navKey }) {
153
150
  <${PanelHeader} title=${t("fleet.title")} sub=${sub}><${DocsLink} /><${HeaderTools} navKey=${navKey} /></${PanelHeader}>
154
151
  <nav data-trust="panel-navigation" class="seg" aria-label=${t("fleet.title")}>${FLEET_TABS.map(k => html`<a key=${k} href=${fleetPath(k)} class=${`seg-item${k === tab ? " active" : ""}`}
155
152
  aria-current=${k === tab ? "page" : undefined}><${Icon} name=${ICONS[k]} size=${16} /><span>${t(`fleet.${k}`)}</span></a>`)}</nav>
156
- <div data-trust="panel-content" class="panel-body"><div class=${`col${tab === "org" || tab === "cache" || tab === "usage" ? " col-wide" : ""}`}><${Body} lease=${lease} /></div></div>
153
+ <div data-trust="panel-content" class="panel-body"><div class=${`col${tab === "tasks" || tab === "org" || tab === "cache" || tab === "usage" ? " col-wide" : ""}`}><${Body} lease=${lease} /></div></div>
157
154
  </div>`;
158
155
  }
159
156
 
@@ -172,28 +169,6 @@ function Loaded({ state, children }) {
172
169
  return children(state.data);
173
170
  }
174
171
 
175
- function Tasks({ lease }) {
176
- const [version, setVersion] = useState(0);
177
- const [creating, setCreating] = useState(false);
178
- const state = useLoad(lease, "/ui/tasks", version);
179
- async function act(id, action) {
180
- const r = await api("POST", `/ui/tasks/${encodeURIComponent(id)}`, { action });
181
- if (r.error) toast(r.error, false); else { toast(t("fleet.taskDone")); setVersion(v => v + 1); }
182
- }
183
- return html`<${Loaded} state=${state}>${(d) => {
184
- const tasks = d.tasks || [];
185
- return html`<${ListHead} count=${tasks.length === 1 ? t("fleet.countTask") : t("fleet.countTasks", tasks.length)} label=${t("fleet.newTask")} action=${{ onClick: () => setCreating(true) }} />
186
- ${tasks.length ? html`<ul class="rows">${tasks.map(x => html`<li key=${x.id} class="row-item">
187
- <span class=${`pill ${x.status}`}>${x.status}</span><span class="grow">${x.title}</span>
188
- ${x.assignee ? html`<a class="link" href=${chatPath(x.assignee)}>${x.assignee}</a>` : null}
189
- ${x.status === "open" ? html`<button type="button" class="btn btn-ghost btn-sm" onClick=${() => act(x.id, "claim")}>${t("fleet.claim")}</button>` : null}
190
- ${x.status === "claimed" ? html`<button type="button" class="btn btn-ghost btn-sm" onClick=${() => act(x.id, "complete")}>${t("fleet.done")}</button>` : null}
191
- </li>`)}</ul>` : html`<${Empty} icon="tasks" title=${t("fleet.noTasks")} hint=${t("fleet.noTasksHint")} />`}
192
- `;
193
- }}</${Loaded}>
194
- ${creating ? html`<${CreateTask} onClose=${() => setCreating(false)} onDone=${() => setVersion(v => v + 1)} />` : null}`;
195
- }
196
-
197
172
  /** #1724: what a row says about when it runs — words for a cron, "Once · <time>" for a one-shot; never empty or "null". */
198
173
  export function scheduleWhen(x) {
199
174
  if (x && x.cron) return cronDesc(x.cron);
@@ -384,25 +359,6 @@ export function FormDialog({ title, onClose, submit, children, ready = true }) {
384
359
  }
385
360
  const field = (label, input) => html`<label class="field"><span>${label}</span>${input}</label>`;
386
361
 
387
- function CreateTask({ onClose, onDone }) {
388
- const { instances } = useStore(appStore);
389
- const [f, setF] = useState({ title: "", desc: "", priority: "normal", assignee: "" });
390
- const set = (k) => (e) => setF({ ...f, [k]: e.target.value });
391
- const submit = { path: "/ui/tasks", done: t("fleet.taskCreated"), after: onDone, collect: () => {
392
- if (!f.title.trim()) { toast(t("fleet.titleRequired"), false); return null; }
393
- const body = { title: f.title.trim(), priority: f.priority };
394
- if (f.desc.trim()) body.description = f.desc.trim();
395
- if (f.assignee) body.assignee = f.assignee;
396
- return body;
397
- } };
398
- return html`<${FormDialog} title=${t("fleet.newTask")} onClose=${onClose} submit=${submit}>
399
- ${field(`${t("fleet.taskTitle")} *`, html`<input value=${f.title} onInput=${set("title")} required />`)}
400
- ${field(t("fleet.taskDesc"), html`<input value=${f.desc} onInput=${set("desc")} />`)}
401
- ${field(t("fleet.priority"), html`<select value=${f.priority} onChange=${set("priority")}><option value="normal">${t("fleet.pNormal")}</option><option value="low">${t("fleet.pLow")}</option><option value="high">${t("fleet.pHigh")}</option><option value="urgent">${t("fleet.pUrgent")}</option></select>`)}
402
- ${field(t("fleet.assignee"), html`<select value=${f.assignee} onChange=${set("assignee")}><option value="">${t("fleet.unassigned")}</option>${instances.map(i => html`<option key=${i.name} value=${i.name}>${i.name}</option>`)}</select>`)}
403
- </${FormDialog}>`;
404
- }
405
-
406
362
  /**
407
363
  * #1724: a wall-clock date and time ("YYYY-MM-DDTHH:MM", a datetime-local value) read in `tz`, as the ISO time with
408
364
  * offset the scheduler takes (`at`); null when either cannot be read. DST-safe: the offset is sampled at that instant.
@@ -0,0 +1,349 @@
1
+ // #2293: global search (Ctrl+K / ⌘K) — chats, messages between agents and Task Board tasks, as the fleet's index has
2
+ // them (GET /ui/search; an admin's only). A Spotlight-style dialog: the query on top, results under it grouped by agent,
3
+ // ↑↓ and Enter, Esc or a click outside to close; filters folded under one toggle. A chat row still in the agent's
4
+ // history opens there, at the message (#m=); anything else — a chat row that has left the history, a message between
5
+ // agents, a task — opens here as the index's copy (GET /ui/search/context). Text only: no innerHTML, no style attribute.
6
+ import { html, useEffect, useLayoutEffect, useMemo, useRef, useState } from "/assets/app-html.js";
7
+ import { t, register } from "/assets/app-i18n.js";
8
+ import { appStore, useStore } from "/assets/app-store.js";
9
+ import { navigate } from "/assets/app-nav.js";
10
+ import { chatPath, fleetPath } from "/assets/app-route.js";
11
+ import { namesOf, namesStore } from "/assets/instance-nav.js";
12
+ import { Icon } from "/assets/ui-icons.js";
13
+
14
+ register("search", {
15
+ en: {
16
+ label: "Search", placeholder: "Search chats, messages and tasks…", close: "Close", filters: "Filters", filtersOn: "Filters ({0})",
17
+ hint: "Find who you asked about something: a word or two from the chat, a message between agents or a task. Short words (1–2 characters) work too.",
18
+ recent: "Recent searches", clearRecent: "Clear",
19
+ agent: "Agent", anyAgent: "All agents", team: "Team", anyTeam: "All teams", time: "Time", anyTime: "Any time",
20
+ day: "24 hours", week: "7 days", month: "30 days", sources: "In", mine: "Only mine",
21
+ src_chat: "Chats", src_xmsg: "Agent messages", src_task: "Tasks",
22
+ badge_chat: "Chat", badge_xmsg: "Message", badge_task: "Task",
23
+ searching: "Searching…", none: "Nothing found", noneFiltered: "Nothing found with these filters",
24
+ building: "Still indexing — some older results may be missing.",
25
+ limited: "Words of 1–2 characters alone search the newest 5,000 entries only — add a longer word to search everything.",
26
+ unavailable: "Search is off or not available (web.search in fleet.yaml).", busy: "Search is busy — try again in a moment.",
27
+ signedOut: "Signed out — sign in again to search.", failed: "Search failed — try again.",
28
+ keys: "↑↓ move · Enter open · Esc close", results: "Results", back: "Results",
29
+ gone: "This message is no longer in the chat's history — this is the copy kept by search.",
30
+ openChat: "Open chat", openChatOf: "Open {0}'s chat", openTasks: "Open the Task Board", loadingCtx: "Loading…",
31
+ notFound: "No longer found — it may have been removed.", from: "{0} → {1}", groupCount: "{0} results",
32
+ },
33
+ "zh-TW": {
34
+ label: "搜尋", placeholder: "搜尋聊天、訊息與任務…", close: "關閉", filters: "篩選", filtersOn: "篩選({0})",
35
+ hint: "找回某件事當初交給誰:輸入聊天、agent 之間訊息或任務裡的一兩個詞。1–2 個字的短詞也可以。",
36
+ recent: "最近的搜尋", clearRecent: "清除",
37
+ agent: "Agent", anyAgent: "所有 agent", team: "團隊", anyTeam: "所有團隊", time: "時間", anyTime: "不限時間",
38
+ day: "24 小時", week: "7 天", month: "30 天", sources: "範圍", mine: "只看我的",
39
+ src_chat: "聊天", src_xmsg: "Agent 訊息", src_task: "任務",
40
+ badge_chat: "聊天", badge_xmsg: "訊息", badge_task: "任務",
41
+ searching: "搜尋中…", none: "找不到結果", noneFiltered: "這些篩選條件下找不到結果",
42
+ building: "索引仍在建立中,較舊的結果可能還沒出現。",
43
+ limited: "只有 1–2 個字的短詞時,只會搜尋最新的 5,000 筆;加上較長的詞即可搜尋全部。",
44
+ unavailable: "搜尋已關閉或無法使用(fleet.yaml 的 web.search)。", busy: "搜尋忙碌中,請稍後再試。",
45
+ signedOut: "已登出,請重新登入後再搜尋。", failed: "搜尋失敗,請再試一次。",
46
+ keys: "↑↓ 移動 · Enter 開啟 · Esc 關閉", results: "結果", back: "結果",
47
+ gone: "這則訊息已不在聊天紀錄中,以下是搜尋保留的副本。",
48
+ openChat: "開啟聊天", openChatOf: "開啟 {0} 的聊天", openTasks: "開啟 Task Board", loadingCtx: "載入中…",
49
+ notFound: "已找不到,可能已被移除。", from: "{0} → {1}", groupCount: "{0} 筆結果",
50
+ },
51
+ });
52
+
53
+ export const DEBOUNCE_MS = 200;
54
+ export const RECENT_KEY = "agend_search_recent";
55
+ export const RECENT_MAX = 8;
56
+ const SOURCES = ["chat", "xmsg", "task"];
57
+ const SINCE = { any: 0, day: 24 * 3600_000, week: 7 * 24 * 3600_000, month: 30 * 24 * 3600_000 };
58
+
59
+ export function readRecent() {
60
+ try { const v = JSON.parse(localStorage.getItem(RECENT_KEY) || "[]"); return Array.isArray(v) ? v.filter(x => typeof x === "string" && x.trim()).slice(0, RECENT_MAX) : []; } catch { return []; }
61
+ }
62
+ export function rememberSearch(q) {
63
+ const s = (q || "").trim();
64
+ if (!s) return;
65
+ try { localStorage.setItem(RECENT_KEY, JSON.stringify([s, ...readRecent().filter(x => x !== s)].slice(0, RECENT_MAX))); } catch { /* this page only */ }
66
+ }
67
+ function forgetRecent() { try { localStorage.removeItem(RECENT_KEY); } catch { /* nothing kept */ } }
68
+
69
+ /** The request's query string for the query and filters (null: nothing to search). */
70
+ export function searchUrl(q, f, now = Date.now()) {
71
+ const query = (q || "").trim();
72
+ if (!query) return null;
73
+ const p = new URLSearchParams({ q: query, limit: "60" });
74
+ if (f.instance) p.append("instance", f.instance);
75
+ if (f.team) p.set("team", f.team);
76
+ if (f.sources && f.sources.length && f.sources.length < SOURCES.length) for (const s of f.sources) p.append("source", s);
77
+ if (SINCE[f.since]) p.set("since", String(now - SINCE[f.since]));
78
+ if (f.mine) p.set("mine", "1");
79
+ return `/ui/search?${p.toString()}`;
80
+ }
81
+ /** Hits grouped by agent, in the order the newest of each appears; `index` is each hit's place in the flat list. */
82
+ export function groupHits(hits) {
83
+ const groups = new Map();
84
+ hits.forEach((hit) => {
85
+ if (!groups.has(hit.instance)) groups.set(hit.instance, []);
86
+ groups.get(hit.instance).push(hit);
87
+ });
88
+ let i = 0;
89
+ return [...groups].map(([instance, list]) => ({ instance, hits: list.map(hit => ({ hit, index: i++ })) }));
90
+ }
91
+ /** The flat order ↑↓ moves through: the groups', top to bottom. */
92
+ export const flatHits = (groups) => groups.flatMap(g => g.hits.map(x => x.hit));
93
+ /** A snippet as text segments, the matched ones marked (ranges from the server, clipped to the text). */
94
+ export function snippetSegments(text, marks) {
95
+ const s = String(text || ""), out = [];
96
+ let at = 0;
97
+ for (const m of Array.isArray(marks) ? marks : []) {
98
+ const a = Math.max(at, Math.min(s.length, Number(m && m[0]) || 0)), b = Math.max(a, Math.min(s.length, Number(m && m[1]) || 0));
99
+ if (a > at) out.push({ text: s.slice(at, a) });
100
+ if (b > a) out.push({ text: s.slice(a, b), mark: true });
101
+ at = b;
102
+ }
103
+ if (at < s.length) out.push({ text: s.slice(at) });
104
+ return out;
105
+ }
106
+ /** The query's words marked in `text` (case-insensitive, every occurrence; overlapping ones merged) — for the copy view. */
107
+ export function markTerms(text, q) {
108
+ const s = String(text || ""), lower = s.toLowerCase();
109
+ const terms = [...new Set(String(q || "").toLowerCase().split(/\s+/).filter(Boolean))].slice(0, 8);
110
+ const ranges = [];
111
+ for (const term of terms) for (let at = lower.indexOf(term); at !== -1 && ranges.length < 500; at = lower.indexOf(term, at + term.length)) ranges.push([at, at + term.length]);
112
+ ranges.sort((a, b) => a[0] - b[0]);
113
+ const merged = [];
114
+ for (const r of ranges) { const last = merged[merged.length - 1]; if (last && r[0] <= last[1]) last[1] = Math.max(last[1], r[1]); else merged.push([...r]); }
115
+ return snippetSegments(s, merged);
116
+ }
117
+ /** "14:05" today, "10/19 14:05" this year, "2025/10/19" before. */
118
+ export function when(ms, now = Date.now()) {
119
+ const d = new Date(ms), n = new Date(now);
120
+ if (Number.isNaN(d.getTime())) return "";
121
+ const hm = `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`;
122
+ if (d.toDateString() === n.toDateString()) return hm;
123
+ return d.getFullYear() === n.getFullYear() ? `${d.getMonth() + 1}/${d.getDate()} ${hm}` : `${d.getFullYear()}/${d.getMonth() + 1}/${d.getDate()}`;
124
+ }
125
+ /** A chat hit's message key in its agent's chat (#m=<boot>-<id>), or null. */
126
+ export function messageKeyOf(hit) {
127
+ const boot = hit && hit.ref && hit.ref.boot, id = hit && hit.ref && hit.ref.id;
128
+ return typeof boot === "string" && /^[0-9a-f]{1,32}$/.test(boot) && Number.isSafeInteger(id) && id >= 0 ? `${boot}-${id}` : null;
129
+ }
130
+ /**
131
+ * Open an agent's chat at a message: the route, then the address's #m= — the chat reads it as it mounts, and (already
132
+ * open) on the hashchange this sends.
133
+ */
134
+ export function jumpToMessage(instance, key, w = window) {
135
+ const path = chatPath(instance);
136
+ navigate(path);
137
+ if (!key) return;
138
+ w.history.replaceState(null, "", `${path}#m=${key}`);
139
+ w.dispatchEvent(new Event("hashchange"));
140
+ }
141
+
142
+ const errorText = (status) => (status === 404 ? t("search.unavailable") : status === 503 ? t("search.busy") : status === 401 ? t("search.signedOut") : t("search.failed"));
143
+
144
+ export function SearchDialog({ preset = {}, onClose }) {
145
+ const ref = useRef(null), input = useRef(null), list = useRef(null);
146
+ const app = useStore(appStore);
147
+ const names = useStore(namesStore);
148
+ const [q, setQ] = useState(preset.q || "");
149
+ const [f, setF] = useState({ instance: preset.instance || "", team: "", since: "any", sources: [...SOURCES], mine: false });
150
+ const [showFilters, setShowFilters] = useState(!!preset.instance);
151
+ const [teams, setTeams] = useState(null);
152
+ const [res, setRes] = useState({ status: "idle" });
153
+ const [active, setActive] = useState(0);
154
+ const [detail, setDetail] = useState(null);
155
+ const [recent, setRecent] = useState(readRecent);
156
+ const seq = useRef(0);
157
+
158
+ // The native modal: focus trapped, the page inert, Esc → cancel (closed through the parent), a click on the backdrop.
159
+ useLayoutEffect(() => {
160
+ const d = ref.current;
161
+ const opener = typeof document !== "undefined" ? document.activeElement : null;
162
+ if (d && typeof d.showModal === "function" && !d.open) d.showModal();
163
+ input.current && input.current.focus();
164
+ return () => {
165
+ if (d && d.open && typeof d.close === "function") d.close();
166
+ if (opener && typeof opener.focus === "function" && document.contains?.(opener)) opener.focus();
167
+ };
168
+ }, []);
169
+ useEffect(() => {
170
+ const d = ref.current;
171
+ if (!d) return undefined;
172
+ const onCancel = (e) => { e.preventDefault(); onClose(); };
173
+ const onClick = (e) => { if (e.target === d) onClose(); };
174
+ d.addEventListener("cancel", onCancel);
175
+ d.addEventListener("click", onClick);
176
+ return () => { d.removeEventListener("cancel", onCancel); d.removeEventListener("click", onClick); };
177
+ }, [onClose]);
178
+
179
+ // The query, DEBOUNCE_MS after the last change; an answer to an older one is dropped.
180
+ useEffect(() => {
181
+ const url = searchUrl(q, f);
182
+ const mine = ++seq.current;
183
+ if (!url) { setRes({ status: "idle" }); return undefined; }
184
+ setRes(r => ({ ...r, status: "loading" }));
185
+ const ac = typeof AbortController === "function" ? new AbortController() : null;
186
+ const timer = setTimeout(async () => {
187
+ try {
188
+ const r = await fetch(url, { headers: { Accept: "application/json" }, ...(ac ? { signal: ac.signal } : {}) });
189
+ if (mine !== seq.current) return;
190
+ if (!r.ok) { setRes({ status: "error", error: errorText(r.status) }); return; }
191
+ const body = await r.json();
192
+ if (mine !== seq.current) return;
193
+ setRes({ status: "ok", hits: Array.isArray(body.hits) ? body.hits : [], limited: !!body.limited, building: !!body.building });
194
+ setActive(0);
195
+ } catch (err) {
196
+ if (mine === seq.current && !(err && err.name === "AbortError")) setRes({ status: "error", error: t("search.failed") });
197
+ }
198
+ }, DEBOUNCE_MS);
199
+ return () => { clearTimeout(timer); if (ac) ac.abort(); };
200
+ }, [q, f]);
201
+
202
+ // Teams, once the filters are first shown.
203
+ useEffect(() => {
204
+ if (!showFilters || teams) return;
205
+ fetch("/ui/teams", { headers: { Accept: "application/json" } }).then(r => (r.ok ? r.json() : { teams: {} })).then(
206
+ (b) => setTeams(Object.keys((b && b.teams) || {}).sort()), () => setTeams([]));
207
+ }, [showFilters, teams]);
208
+
209
+ const byName = useMemo(() => new Map((app.instances || []).map(it => [it.name ?? it.instance_name, it])), [app.instances]);
210
+ const label = (name) => (byName.has(name) ? namesOf(byName.get(name), names.primary).primary : name);
211
+ // While a new query runs, the last answer stays (dimmed) rather than flashing empty.
212
+ const groups = useMemo(() => groupHits(res.hits || []), [res]);
213
+ const flat = useMemo(() => flatHits(groups), [groups]);
214
+ const activeFilters = (f.instance ? 1 : 0) + (f.team ? 1 : 0) + (f.since !== "any" ? 1 : 0) + (f.sources.length < SOURCES.length ? 1 : 0) + (f.mine ? 1 : 0);
215
+
216
+ // The active row stays in view.
217
+ useEffect(() => {
218
+ const el = list.current && list.current.querySelector(`#search-opt-${active}`);
219
+ if (el && typeof el.scrollIntoView === "function") el.scrollIntoView({ block: "nearest" });
220
+ }, [active, res]);
221
+
222
+ const open = (hit) => {
223
+ if (!hit) return;
224
+ rememberSearch(q); setRecent(readRecent());
225
+ const key = hit.source === "chat" ? messageKeyOf(hit) : null;
226
+ if (key && hit.live) { onClose(); jumpToMessage(hit.instance, key); return; }
227
+ setDetail({ hit, status: "loading" });
228
+ // Any failure — the request, or a body that is not JSON — ends as an error, never "Loading…" for good.
229
+ fetch(`/ui/search/context?key=${encodeURIComponent(hit.key)}`, { headers: { Accept: "application/json" } })
230
+ .then(async (r) => {
231
+ if (!r.ok) { setDetail(d => (d && d.hit === hit ? { hit, status: "error", error: r.status === 404 ? t("search.notFound") : errorText(r.status) } : d)); return; }
232
+ const body = await r.json();
233
+ setDetail(d => (d && d.hit === hit ? { hit, status: "ok", ctx: body } : d));
234
+ })
235
+ .catch(() => setDetail(d => (d && d.hit === hit ? { hit, status: "error", error: t("search.failed") } : d)));
236
+ };
237
+ const onKey = (e) => {
238
+ if (e.isComposing) return;
239
+ if ((e.ctrlKey || e.metaKey) && (e.key === "k" || e.key === "K")) { e.preventDefault(); input.current && input.current.select(); return; }
240
+ // ↑↓ and Enter are the query box's: in the filters a select or a chip keeps its own keys.
241
+ if (detail || e.target !== input.current) return;
242
+ if (e.key === "ArrowDown") { e.preventDefault(); setActive(i => (flat.length ? (i + 1) % flat.length : 0)); }
243
+ else if (e.key === "ArrowUp") { e.preventDefault(); setActive(i => (flat.length ? (i - 1 + flat.length) % flat.length : 0)); }
244
+ else if (e.key === "Enter") { e.preventDefault(); if (flat.length) open(flat[Math.min(active, flat.length - 1)]); else rememberSearch(q); }
245
+ };
246
+ const setFilter = (patch) => setF(x => ({ ...x, ...patch }));
247
+ const toggleSource = (s) => setF(x => {
248
+ const has = x.sources.includes(s);
249
+ const next = has ? x.sources.filter(y => y !== s) : [...x.sources, s];
250
+ return { ...x, sources: next.length ? SOURCES.filter(y => next.includes(y)) : x.sources }; // never none
251
+ });
252
+ const activeId = flat.length && !detail ? `search-opt-${Math.min(active, flat.length - 1)}` : undefined;
253
+
254
+ return html`<dialog data-trust="dialog" ref=${ref} class="dlg search-dlg" aria-label=${t("search.label")} onKeyDown=${onKey}>
255
+ <div class="search-box">
256
+ <div class="search-head">
257
+ <${Icon} name="search" />
258
+ <input ref=${input} class="search-input" type="search" value=${q} placeholder=${t("search.placeholder")} aria-label=${t("search.label")}
259
+ role="combobox" aria-expanded=${flat.length && !detail ? "true" : "false"} aria-controls="search-results" aria-autocomplete="list"
260
+ aria-activedescendant=${activeId} autocomplete="off" spellcheck=${false} enterkeyhint="search"
261
+ onInput=${(e) => { setQ(e.target.value); setDetail(null); }} />
262
+ <button type="button" class=${`chip-btn search-filters-toggle${showFilters ? " on" : ""}`} aria-expanded=${showFilters ? "true" : "false"} aria-controls="search-filters"
263
+ onClick=${() => setShowFilters(v => !v)}><${Icon} name="sliders" size=${14} />${activeFilters ? t("search.filtersOn", activeFilters) : t("search.filters")}</button>
264
+ <button type="button" class="icon-btn" aria-label=${t("search.close")} title=${t("search.close")} onClick=${onClose}><${Icon} name="close" /></button>
265
+ </div>
266
+ ${showFilters ? html`<div id="search-filters" class="search-filters" role="group" aria-label=${t("search.filters")}>
267
+ <label class="search-field"><span>${t("search.agent")}</span>
268
+ <select value=${f.instance} onChange=${(e) => setFilter({ instance: e.target.value })}>
269
+ <option value="">${t("search.anyAgent")}</option>
270
+ ${[...byName.keys()].sort().map(n => html`<option value=${n}>${label(n)}</option>`)}
271
+ ${f.instance && !byName.has(f.instance) ? html`<option value=${f.instance}>${f.instance}</option>` : null}
272
+ </select></label>
273
+ ${teams && teams.length ? html`<label class="search-field"><span>${t("search.team")}</span>
274
+ <select value=${f.team} onChange=${(e) => setFilter({ team: e.target.value })}>
275
+ <option value="">${t("search.anyTeam")}</option>${teams.map(n => html`<option value=${n}>${n}</option>`)}</select></label>` : null}
276
+ <div class="search-chips" role="group" aria-label=${t("search.time")}>
277
+ ${["any", "day", "week", "month"].map(k => html`<button type="button" class=${`chip-btn${f.since === k ? " on" : ""}`} aria-pressed=${f.since === k ? "true" : "false"}
278
+ onClick=${() => setFilter({ since: k })}>${k === "any" ? t("search.anyTime") : t(`search.${k}`)}</button>`)}
279
+ </div>
280
+ <div class="search-chips" role="group" aria-label=${t("search.sources")}>
281
+ ${SOURCES.map(s => html`<button type="button" class=${`chip-btn${f.sources.includes(s) ? " on" : ""}`} aria-pressed=${f.sources.includes(s) ? "true" : "false"}
282
+ onClick=${() => toggleSource(s)}>${t(`search.src_${s}`)}</button>`)}
283
+ <label class="search-mine"><input type="checkbox" checked=${f.mine} onChange=${(e) => setFilter({ mine: e.target.checked })} />${t("search.mine")}</label>
284
+ </div>
285
+ </div>` : null}
286
+ <div class="search-body" ref=${list}>
287
+ ${detail ? html`<${Detail} detail=${detail} q=${q} label=${label} onBack=${() => { setDetail(null); input.current && input.current.focus(); }} onClose=${onClose} />`
288
+ : html`<${Results} q=${q} res=${res} groups=${groups} active=${active} setActive=${setActive} open=${open} label=${label} filtered=${activeFilters > 0}
289
+ recent=${recent} pick=${(s) => { setQ(s); input.current && input.current.focus(); }} clearRecent=${() => { forgetRecent(); setRecent([]); }} />`}
290
+ </div>
291
+ <div class="search-foot" aria-hidden="true">${t("search.keys")}</div>
292
+ </div></dialog>`;
293
+ }
294
+
295
+ function Results({ q, res, groups, active, setActive, open, label, filtered, recent, pick, clearRecent }) {
296
+ if (!q.trim()) {
297
+ return html`<div class="search-empty"><p class="search-hint">${t("search.hint")}</p>
298
+ ${recent.length ? html`<div class="search-recent"><div class="search-recent-head"><span>${t("search.recent")}</span>
299
+ <button type="button" class="chip-btn" onClick=${clearRecent}>${t("search.clearRecent")}</button></div>
300
+ <ul>${recent.map(s => html`<li><button type="button" class="search-recent-item" onClick=${() => pick(s)}><${Icon} name="clock" size=${14} /><span>${s}</span></button></li>`)}</ul></div>` : null}</div>`;
301
+ }
302
+ if (res.status === "error") return html`<p class="search-status bad" role="alert">${res.error}</p>`;
303
+ const hits = res.hits || [];
304
+ return html`
305
+ ${res.building ? html`<p class="search-note" role="status">${t("search.building")}</p>` : null}
306
+ ${res.limited ? html`<p class="search-note" role="status">${t("search.limited")}</p>` : null}
307
+ ${res.status === "loading" && !hits.length ? html`<p class="search-status" role="status">${t("search.searching")}</p>` : null}
308
+ ${res.status === "ok" && !hits.length ? html`<p class="search-status" role="status">${filtered ? t("search.noneFiltered") : t("search.none")}</p>` : null}
309
+ <div id="search-results" role="listbox" aria-label=${t("search.results")} class=${`search-results${res.status === "loading" ? " stale" : ""}`}>
310
+ ${groups.map(g => html`<div role="group" class="search-group" aria-label=${label(g.instance)}>
311
+ <div class="search-group-head" aria-hidden="true"><span>${label(g.instance)}</span><span class="search-count">${t("search.groupCount", g.hits.length)}</span></div>
312
+ ${g.hits.map(({ hit, index }) => html`<${Row} hit=${hit} index=${index} on=${index === active} label=${label} onHover=${() => setActive(index)} onOpen=${() => open(hit)} />`)}
313
+ </div>`)}
314
+ </div>`;
315
+ }
316
+
317
+ function Row({ hit, index, on, label, onHover, onOpen }) {
318
+ const who = hit.source === "xmsg" ? t("search.from", label(hit.instance), label(hit.peer || "")) : hit.source === "task" ? (hit.role || "") : hit.sender || "";
319
+ return html`<div id=${`search-opt-${index}`} role="option" aria-selected=${on ? "true" : "false"} class=${`search-row${on ? " on" : ""}`}
320
+ onMouseMove=${on ? undefined : onHover} onClick=${onOpen}>
321
+ <div class="search-row-top"><span class=${`search-badge ${hit.source}`}>${t(`search.badge_${hit.source}`)}</span>
322
+ <span class="search-who">${who}${hit.source === "xmsg" && hit.role && hit.role !== "message" ? html` <span class="search-kind">${hit.role}</span>` : null}</span>
323
+ <time class="search-time" datetime=${Number.isFinite(hit.ts) ? new Date(hit.ts).toISOString() : undefined}>${when(hit.ts)}</time></div>
324
+ <div class="search-snippet">${snippetSegments(hit.snippet, hit.marks).map(s => (s.mark ? html`<mark>${s.text}</mark>` : s.text))}</div>
325
+ </div>`;
326
+ }
327
+
328
+ function Detail({ detail, q, label, onBack, onClose }) {
329
+ const { hit } = detail;
330
+ const go = (path, key) => { onClose(); if (key !== undefined) jumpToMessage(hit.instance, key); else navigate(path); };
331
+ const head = html`<div class="search-detail-head"><button type="button" class="chip-btn" onClick=${onBack}><${Icon} name="back" size=${14} />${t("search.back")}</button>
332
+ <span class=${`search-badge ${hit.source}`}>${t(`search.badge_${hit.source}`)}</span><span class="search-who">${hit.source === "xmsg" ? t("search.from", label(hit.instance), label(hit.peer || "")) : label(hit.instance)}</span>
333
+ <time class="search-time">${when(hit.ts)}</time></div>`;
334
+ if (detail.status === "loading") return html`<div class="search-detail">${head}<p class="search-status" role="status">${t("search.loadingCtx")}</p></div>`;
335
+ if (detail.status === "error") return html`<div class="search-detail">${head}<p class="search-status bad" role="alert">${detail.error}</p></div>`;
336
+ const ctx = detail.ctx || {};
337
+ const doc = ctx.doc || {};
338
+ const line = (d, here) => html`<li class=${`search-ctx-line${here ? " here" : ""}`}><div class="search-row-top"><span class="search-who">${d.sender || ""}</span><time class="search-time">${when(d.ts)}</time></div>
339
+ <div class="search-ctx-text">${markTerms(d.text, q).map(x => (x.mark ? html`<mark>${x.text}</mark>` : x.text))}</div></li>`;
340
+ let actions;
341
+ if (hit.source === "chat") actions = html`<button type="button" class="btn btn-primary" onClick=${() => go(null, messageKeyOf(hit))}>${t("search.openChat")}</button>`;
342
+ else if (hit.source === "xmsg") actions = html`<button type="button" class="btn" onClick=${() => go(chatPath(hit.instance))}>${t("search.openChatOf", label(hit.instance))}</button>
343
+ ${hit.peer ? html`<button type="button" class="btn" onClick=${() => go(chatPath(hit.peer))}>${t("search.openChatOf", label(hit.peer))}</button>` : null}`;
344
+ else actions = html`<button type="button" class="btn btn-primary" onClick=${() => go(fleetPath("tasks"))}>${t("search.openTasks")}</button>`;
345
+ return html`<div class="search-detail">${head}
346
+ ${hit.source === "chat" ? html`<p class="search-note">${t("search.gone")}</p>` : null}
347
+ <ol class="search-ctx">${(ctx.before || []).map(d => line(d, false))}${line(doc, true)}${(ctx.after || []).map(d => line(d, false))}</ol>
348
+ <div class="search-detail-actions">${actions}</div></div>`;
349
+ }
@@ -90,6 +90,8 @@ register("app", {
90
90
  pendingSent: "Sent for confirmation — a fleet admin confirms it in chat.",
91
91
  pendingSelfSent: "Confirmed by your account (this browser is web-verified) — applying.",
92
92
  needsNav: "Needs you", needsTab: "Needs you", needsCount: "{0} waiting",
93
+ // #2293: global search — chats, cross-instance messages and tasks (not the sidebar's filter of agents).
94
+ search: "Search", searchTitle: "Search chats, messages between agents and tasks ({0})",
93
95
  confirmTitle: "Please confirm", confirmOk: "Continue", cancel: "Cancel",
94
96
  // The server's own wording (src/locale.ts needs.reason.*), so the web says what Discord says.
95
97
  needs_reason_hang: "Not responding", needs_reason_exited: "Exited", needs_reason_assist: "Waiting at its terminal", needs_reason_permission: "Permission needed", needs_reason_dangerous_command: "Dangerous command waiting", needs_reason_login: "Sign-in needed", needs_reason_dialog: "Waiting at a dialog", needs_reason_terminal_input: "May be waiting for input", needs_reason_auth_paused: "Paused: sign-in needed", needs_reason_crashed: "Crashed", needs_reason_delivery_uncertain: "Could not confirm delivery", needs_reason_delivery_failed: "Delivery failed",
@@ -125,6 +127,7 @@ register("app", {
125
127
  pendingSent: "已送出確認——由 fleet 管理員在聊天中確認。",
126
128
  pendingSelfSent: "已由你的帳號確認(這個瀏覽器已通過網頁驗證)——套用中。",
127
129
  needsNav: "等你處理", needsTab: "待處理", needsCount: "{0} 件待處理",
130
+ search: "搜尋", searchTitle: "搜尋聊天、agent 之間的訊息與任務({0})",
128
131
  confirmTitle: "請確認", confirmOk: "繼續", cancel: "取消",
129
132
  needs_reason_hang: "沒有回應", needs_reason_exited: "已結束", needs_reason_assist: "在終端機上等待", needs_reason_permission: "需要權限", needs_reason_dangerous_command: "危險指令待確認", needs_reason_login: "需要登入", needs_reason_dialog: "停在對話框", needs_reason_terminal_input: "可能在等輸入", needs_reason_auth_paused: "已暫停:需要登入", needs_reason_crashed: "已崩潰", needs_reason_delivery_uncertain: "無法確認是否送達", needs_reason_delivery_failed: "傳送失敗",
130
133
  },
@@ -9,6 +9,7 @@ import { ColorDialogHost } from "./identity-color.js";
9
9
  import { t, lang, setLang, onLang } from "./app-i18n.js";
10
10
  import { appStore, createStore, guestBadge, guestPage, useStore } from "./app-store.js";
11
11
  import { navStore } from "./app-nav.js";
12
+ import { toast } from "./ui-toast.js";
12
13
  import { chatPath, viewPath, settingsPath, routeKey, NEEDS_PATH, fleetEntryPath } from "./app-route.js";
13
14
  import { SessionMenu } from "./app-session.js";
14
15
  import { onPrefsChanged, savePref } from "./app-prefs.js";
@@ -63,6 +64,43 @@ export function useCanCreate() {
63
64
  export function showDialog(Component, props = {}) { shellStore.set({ dialog: { Component, props } }); }
64
65
  export function closeDialog() { shellStore.set({ dialog: null }); }
65
66
 
67
+ // ── #2293: global search — an admin's. The dialog lives behind the session gate (/ui/js/search-dialog.js) and loads the
68
+ // first time it opens; the sidebar row, the header's button and the shortcuts only open it. ──
69
+ export const isMacLike = () => typeof navigator !== "undefined" && /Mac|iPhone|iPad|iPod/.test(navigator.platform || navigator.userAgent || "");
70
+ /** What the row and tooltips show: ⌘K on a Mac, Ctrl K elsewhere. */
71
+ export const searchShortcut = () => (isMacLike() ? "⌘K" : "Ctrl K");
72
+ /**
73
+ * Whether this page offers search: an admin's page reached directly. A guest's never, and never over the public link —
74
+ * the server refuses it there (#2298 review), so no entry, no shortcut, and Ctrl+F stays the browser's.
75
+ */
76
+ export function searchOffered(admin) {
77
+ const onLink = typeof document !== "undefined" && !!document.body && document.body.dataset.publicLink === "1";
78
+ return !!admin && !guestPage() && !onLink;
79
+ }
80
+ let searchModule = null;
81
+ /** Open search; `preset.instance`: narrowed to that agent (Ctrl+F in its chat). */
82
+ export function openSearch(preset = {}) {
83
+ closeDrawer();
84
+ (searchModule ??= import("/ui/js/search-dialog.js")).then(
85
+ (m) => showDialog(m.SearchDialog, { preset }),
86
+ () => { searchModule = null; toast(t("app.loadFailed"), false); });
87
+ }
88
+ /**
89
+ * Ctrl+K (⌘K) anywhere opens search; Ctrl+F (⌘F) in an agent's chat opens it narrowed to that agent — elsewhere the
90
+ * browser's own find stays. Not while another dialog is open (search, open already, has its input focused).
91
+ */
92
+ export function handleSearchKey(e) {
93
+ if (!searchOffered(true)) return;
94
+ if (e.isComposing || e.altKey || e.shiftKey || !(isMacLike() ? e.metaKey : e.ctrlKey)) return;
95
+ const key = (e.key || "").toLowerCase();
96
+ if (key !== "k" && key !== "f") return;
97
+ if (typeof document !== "undefined" && document.querySelector("dialog[open]")) return;
98
+ const route = navStore.get().route;
99
+ if (key === "f" && !(route && route.panel === "chat" && route.instance)) return;
100
+ e.preventDefault();
101
+ openSearch(key === "f" ? { instance: route.instance } : {});
102
+ }
103
+
66
104
  function readCollapsed() { try { return localStorage.getItem("agend_sidebar") === "hidden"; } catch { return false; } }
67
105
  const narrow = () => typeof matchMedia === "function" && matchMedia("(max-width: 899px)").matches;
68
106
 
@@ -320,6 +358,8 @@ function Sidebar({ route, onNewInstance, viewOnly }) {
320
358
  <nav data-trust="navigation" class="side-nav" aria-label=${t("app.menu")}>
321
359
  ${overview}
322
360
  ${navLink("needs", NEEDS_PATH, "inbox", t("app.needsNav"), needsCount)}
361
+ ${searchOffered(canCreate) ? html`<button type="button" class="side-row side-search" onClick=${() => openSearch()} title=${t("app.searchTitle", searchShortcut())}
362
+ aria-keyshortcuts=${isMacLike() ? "Meta+K" : "Control+K"}><${Icon} name="search" /><span class="grow">${t("app.search")}</span><kbd class="kbd-hint" aria-hidden="true">${searchShortcut()}</kbd></button>` : null}
323
363
  ${canCreate && onNewInstance ? html`<button type="button" class="side-row side-new" onClick=${onNewInstance}><${Icon} name="plus" /><span class="grow">${t("app.newAgent")}</span></button>` : null}
324
364
  </nav>
325
365
  ${shell.side ? html`<${shell.side.Component} />` : html`<${InstanceNav} route=${route} items=${app.instances} loaded=${app.ready !== false}
@@ -394,11 +434,13 @@ function PrefsPopover() {
394
434
  */
395
435
  export function PanelHeader({ title, sub, children, headingRef, nav, notice }) {
396
436
  const shell = useStore(shellStore);
397
- const canCreate = useCanCreate() && !!newInstanceOpener;
437
+ const admin = useCanCreate();
438
+ const canCreate = admin && !!newInstanceOpener;
398
439
  return html`<header data-trust="header" class=${`panel-head${shell.collapsed ? " collapsed" : ""}`}>
399
440
  <button type="button" id="sbOpen" class="icon-btn sb-open" onClick=${() => (narrow() ? openDrawer() : toggleSidebar())}
400
441
  aria-label=${narrow() ? t("app.openMenu") : t("app.expand")} title=${narrow() ? t("app.openMenu") : t("app.expand")} aria-controls="sidebar" aria-expanded=${shell.drawer ? "true" : "false"}><${Icon} name="menu" /></button>
401
442
  ${canCreate && shell.collapsed ? html`<button type="button" class="icon-btn sb-new" onClick=${requestNewInstance} aria-label=${t("app.newAgent")} title=${t("app.newAgent")}><${Icon} name="plus" /></button>` : null}
443
+ ${searchOffered(admin) ? html`<button type="button" class="icon-btn sb-search" onClick=${() => openSearch()} aria-label=${t("app.search")} title=${t("app.searchTitle", searchShortcut())}><${Icon} name="search" /></button>` : null}
402
444
  <div class="panel-title"><h1 ref=${headingRef} tabindex="-1">${title}</h1>${sub ? html`<div class="panel-sub">${sub}</div>` : null}</div>
403
445
  ${nav || null}
404
446
  <div class="panel-actions">${children}</div>