@saadnvd1/agent-os 0.3.627 → 0.3.630

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.
@@ -93,3 +93,29 @@ describe("a sent message not in the chat yet", () => {
93
93
  expect(html).not.toContain("Not sent");
94
94
  });
95
95
  });
96
+
97
+ describe("UserMessage code blocks", () => {
98
+ it("renders a fenced ts block with highlighted tokens, queued or sent", async () => {
99
+ const { PrismAsyncLight } = await import("react-syntax-highlighter");
100
+ const ts = (
101
+ await import("react-syntax-highlighter/dist/esm/languages/prism/typescript")
102
+ ).default;
103
+ await (
104
+ PrismAsyncLight as unknown as { preload: () => Promise<void> }
105
+ ).preload();
106
+ PrismAsyncLight.registerLanguage("typescript", ts);
107
+ const user = {
108
+ id: "u1",
109
+ kind: "user",
110
+ text: "try:\n\n```ts\nconst x: number = 1;\n```\n\nthanks",
111
+ } as never;
112
+ for (const pending of [undefined, { status: "sending" } as never]) {
113
+ const html = renderToStaticMarkup(
114
+ createElement(UserMessage, { item: user, pending })
115
+ );
116
+ expect(html).toMatch(/style="color:[^"]*">const<\/span>/);
117
+ expect(html).toContain("number");
118
+ expect(html).not.toContain("```");
119
+ }
120
+ });
121
+ });
@@ -16,6 +16,7 @@ import {
16
16
  type TextAttachment,
17
17
  } from "@/lib/chat/paste";
18
18
  import { insertCommand, rankCommands, slashQuery } from "@/lib/chat/commands";
19
+ import { mentionOption } from "@/lib/chat/mentions";
19
20
  import { cn } from "@/lib/utils";
20
21
  import { loadDraft, useSaveDraft } from "../useDraft";
21
22
  import { ComposerMenu, type MenuOption } from "../ComposerMenu";
@@ -189,10 +190,7 @@ export function ComposerBody({
189
190
  : mentions.files.map((f) => ({
190
191
  key: `${f.dir ? "d" : "f"}:${f.path}`,
191
192
  // The name first, so two long paths never read the same.
192
- label: `${f.path.slice(f.path.lastIndexOf("/") + 1)}${f.dir ? "/" : ""}`,
193
- hint: f.path.includes("/")
194
- ? f.path.slice(0, f.path.lastIndexOf("/"))
195
- : undefined,
193
+ ...mentionOption(f),
196
194
  icon: f.dir ? (
197
195
  <Folder className="text-muted-foreground h-3.5 w-3.5 shrink-0" />
198
196
  ) : (
@@ -0,0 +1,89 @@
1
+ import "@/components/Chat/composer/test-dom";
2
+ import { act, createElement } from "react";
3
+ import { createRoot, type Root } from "react-dom/client";
4
+ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
5
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
6
+ import { healthKeys } from "@/data/health";
7
+ import { loadKeys } from "@/data/load";
8
+ import type { LoadView } from "@/lib/load/monitor";
9
+ import { settingsUi } from "@/stores/settingsUi";
10
+ import { LoadGauge } from "./LoadGauge";
11
+
12
+ (
13
+ globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }
14
+ ).IS_REACT_ACT_ENVIRONMENT = true;
15
+
16
+ let host: HTMLElement;
17
+ let root: Root;
18
+ beforeEach(() => {
19
+ host = document.createElement("div");
20
+ document.body.append(host);
21
+ root = createRoot(host);
22
+ settingsUi.open = false;
23
+ settingsUi.section = null;
24
+ vi.stubGlobal("fetch", () => new Promise(() => {}));
25
+ });
26
+ afterEach(() => {
27
+ act(() => root.unmount());
28
+ host.remove();
29
+ vi.unstubAllGlobals();
30
+ });
31
+
32
+ const view = (level: LoadView["level"]): LoadView => ({
33
+ level,
34
+ load1: 1,
35
+ cores: 8,
36
+ memUsedPct: 30,
37
+ pressure: "normal",
38
+ top: [],
39
+ unattributed: [],
40
+ sessions: {},
41
+ });
42
+
43
+ function render(
44
+ level: LoadView["level"],
45
+ health: { open: number; needsYou: number }
46
+ ) {
47
+ const qc = new QueryClient({
48
+ defaultOptions: { queries: { retry: false, staleTime: Infinity } },
49
+ });
50
+ qc.setQueryData(loadKeys.all, view(level));
51
+ qc.setQueryData(healthKeys.count(), health);
52
+ act(() =>
53
+ root.render(
54
+ createElement(
55
+ QueryClientProvider,
56
+ { client: qc },
57
+ createElement(LoadGauge)
58
+ )
59
+ )
60
+ );
61
+ }
62
+
63
+ describe("LoadGauge", () => {
64
+ it("renders one dot, never a separate health dot", () => {
65
+ render("green", { open: 1, needsYou: 0 });
66
+ expect(host.querySelectorAll("button")).toHaveLength(1);
67
+ expect(host.querySelector("[data-health-dot]")).toBeNull();
68
+ });
69
+
70
+ it("opens Settings → Health when an issue is open", () => {
71
+ render("green", { open: 1, needsYou: 0 });
72
+ const b = host.querySelector("button")!;
73
+ expect(b.dataset.statusDot).toBe("amber");
74
+ expect(b.getAttribute("aria-label")).toBe(
75
+ "Load normal · 1 health issue open"
76
+ );
77
+ act(() => b.click());
78
+ expect(settingsUi.open).toBe(true);
79
+ expect(settingsUi.section).toBe("health");
80
+ });
81
+
82
+ it("keeps the load popover when nothing is open", () => {
83
+ render("red", { open: 0, needsYou: 0 });
84
+ const b = host.querySelector("button")!;
85
+ expect(b.dataset.statusDot).toBe("red");
86
+ expect(b.dataset.opens).toBe("load");
87
+ expect(b.getAttribute("aria-haspopup")).toBe("menu");
88
+ });
89
+ });
@@ -6,42 +6,51 @@ import {
6
6
  DropdownMenuTrigger,
7
7
  } from "@/components/ui/dropdown-menu";
8
8
  import { Button } from "@/components/ui/button";
9
+ import { useHealthCount } from "@/data/health";
9
10
  import { usageLabel, useMachineLoad } from "@/data/load";
10
- import type { LoadView } from "@/lib/load/monitor";
11
+ import { settingsUiActions } from "@/stores/settingsUi";
12
+ import { loadSummary, statusDot, type DotLevel } from "./statusDot";
11
13
  import { cn } from "@/lib/utils";
12
14
 
13
- const DOT: Record<LoadView["level"], string> = {
15
+ const DOT: Record<DotLevel, string> = {
14
16
  green: "bg-green-500",
15
17
  amber: "bg-amber-500",
16
18
  red: "bg-destructive",
17
19
  };
18
20
 
19
- // "Load 4.2 on 10 cores · memory normal"
20
- export function loadSummary(load: LoadView): string {
21
- const memory = load.pressure ?? `${load.memUsedPct}% used`;
22
- return `Load ${load.load1} on ${load.cores} cores · memory ${memory}`;
23
- }
21
+ const BUTTON_CLASS =
22
+ "-ml-1.5 h-11 w-11 shrink-0 md:ml-0 md:h-8 md:w-6 [@media(hover:none)]:md:-ml-1.5 [@media(hover:none)]:md:h-11 [@media(hover:none)]:md:w-11";
24
23
 
25
- // The machine's load as a dot beside the workspace's name; tap it for the
26
- // numbers and the sessions using the most CPU.
24
+ // One dot beside the workspace's name for the machine's load and the health
25
+ // watch, whichever is worse. With a health issue open a tap goes to Settings
26
+ // → Health; otherwise it shows the load numbers and the busiest sessions.
27
27
  export function LoadGauge() {
28
28
  const load = useMachineLoad();
29
- if (!load) return null;
29
+ const { data: health } = useHealthCount();
30
+ const dot = statusDot(load, health);
31
+ if (!dot) return null;
32
+ const button = (
33
+ <Button
34
+ variant="ghost"
35
+ size="icon-sm"
36
+ aria-label={dot.label}
37
+ title={dot.title}
38
+ data-status-dot={dot.level}
39
+ data-opens={dot.opensHealth ? "health" : "load"}
40
+ onClick={
41
+ dot.opensHealth ? () => settingsUiActions.open("health") : undefined
42
+ }
43
+ className={BUTTON_CLASS}
44
+ >
45
+ <span className={cn("h-2 w-2 rounded-full", DOT[dot.level])} />
46
+ </Button>
47
+ );
48
+ if (dot.opensHealth || !load) return button;
30
49
  const summary = loadSummary(load);
31
50
  const top = load.top.filter((s) => s.cores >= 0.1);
32
51
  return (
33
52
  <DropdownMenu>
34
- <DropdownMenuTrigger asChild>
35
- <Button
36
- variant="ghost"
37
- size="icon-sm"
38
- aria-label={`Machine load ${load.level}. ${summary}`}
39
- title={summary}
40
- className="-ml-1.5 h-11 w-11 shrink-0 md:ml-0 md:h-8 md:w-6 [@media(hover:none)]:md:-ml-1.5 [@media(hover:none)]:md:h-11 [@media(hover:none)]:md:w-11"
41
- >
42
- <span className={cn("h-2 w-2 rounded-full", DOT[load.level])} />
43
- </Button>
44
- </DropdownMenuTrigger>
53
+ <DropdownMenuTrigger asChild>{button}</DropdownMenuTrigger>
45
54
  <DropdownMenuContent align="end" className="w-72 p-3">
46
55
  <p className="text-sm font-medium">{summary}</p>
47
56
  {load.level !== "green" && (
@@ -39,7 +39,6 @@ import { settingsUiActions } from "@/stores/settingsUi";
39
39
  import { usageUiActions } from "@/stores/usageUi";
40
40
  import { WorkspaceSwitcher } from "./WorkspaceSwitcher";
41
41
  import { LoadGauge } from "./LoadGauge";
42
- import { HealthDot } from "@/components/Health/HealthDot";
43
42
  import { AwayToggle } from "@/components/Away/AwayToggle";
44
43
  import { NoApprovalsBadge } from "@/components/Approvals/NoApprovalsBadge";
45
44
  import { useSchedulesGlance } from "./useSchedulesGlance";
@@ -72,7 +71,6 @@ export function SidebarHeader(props: SidebarHeaderProps) {
72
71
  current={props.workspace}
73
72
  />
74
73
  <LoadGauge />
75
- <HealthDot />
76
74
  </div>
77
75
  <div className="ml-auto flex shrink-0 items-center gap-0.5">
78
76
  <NoApprovalsBadge />
@@ -0,0 +1,56 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import type { LoadView } from "@/lib/load/monitor";
3
+ import { statusDot } from "./statusDot";
4
+
5
+ const load = (level: LoadView["level"]): LoadView => ({
6
+ level,
7
+ load1: 4.2,
8
+ cores: 10,
9
+ memUsedPct: 40,
10
+ pressure: "normal",
11
+ top: [],
12
+ unattributed: [],
13
+ sessions: {},
14
+ });
15
+ const none = { open: 0, needsYou: 0 };
16
+
17
+ describe("statusDot", () => {
18
+ it("is green and opens the load popover when all is well", () => {
19
+ const d = statusDot(load("green"), none)!;
20
+ expect(d).toMatchObject({
21
+ level: "green",
22
+ label: "Load normal",
23
+ opensHealth: false,
24
+ healthLine: null,
25
+ });
26
+ });
27
+ it("follows load alone", () => {
28
+ expect(statusDot(load("amber"), none)!.level).toBe("amber");
29
+ expect(statusDot(load("red"), none)!.level).toBe("red");
30
+ });
31
+ it("goes amber for an open health issue and opens Health", () => {
32
+ const d = statusDot(load("green"), { open: 1, needsYou: 0 })!;
33
+ expect(d.level).toBe("amber");
34
+ expect(d.label).toBe("Load normal · 1 health issue open");
35
+ expect(d.title).toContain("Load 4.2 on 10 cores");
36
+ expect(d.opensHealth).toBe(true);
37
+ });
38
+ it("goes red when a health issue needs Saad", () => {
39
+ const d = statusDot(load("green"), { open: 2, needsYou: 2 })!;
40
+ expect(d.level).toBe("red");
41
+ expect(d.label).toBe("Load normal · 2 health issues need you");
42
+ expect(statusDot(load("amber"), { open: 1, needsYou: 1 })!.level).toBe(
43
+ "red"
44
+ );
45
+ });
46
+ it("takes the worse of the two", () => {
47
+ expect(statusDot(load("red"), { open: 1, needsYou: 0 })!.level).toBe("red");
48
+ });
49
+ it("shows health alone before load arrives, nothing with neither", () => {
50
+ expect(statusDot(null, { open: 2, needsYou: 0 })!.label).toBe(
51
+ "2 health issues open"
52
+ );
53
+ expect(statusDot(null, none)).toBeNull();
54
+ expect(statusDot(null, undefined)).toBeNull();
55
+ });
56
+ });
@@ -0,0 +1,59 @@
1
+ import type { LoadView } from "@/lib/load/monitor";
2
+
3
+ export type DotLevel = "green" | "amber" | "red";
4
+ export interface HealthCount {
5
+ open: number;
6
+ needsYou: number;
7
+ }
8
+
9
+ export interface StatusDot {
10
+ level: DotLevel;
11
+ label: string;
12
+ title: string;
13
+ healthLine: string | null;
14
+ opensHealth: boolean;
15
+ }
16
+
17
+ const WORD: Record<LoadView["level"], string> = {
18
+ green: "normal",
19
+ amber: "busy",
20
+ red: "high",
21
+ };
22
+
23
+ // "Load 4.2 on 10 cores · memory normal"
24
+ export function loadSummary(load: LoadView): string {
25
+ const memory = load.pressure ?? `${load.memUsedPct}% used`;
26
+ return `Load ${load.load1} on ${load.cores} cores · memory ${memory}`;
27
+ }
28
+
29
+ export function healthLine(h: HealthCount | undefined): string | null {
30
+ if (!h) return null;
31
+ if (h.needsYou > 0)
32
+ return `${h.needsYou} health issue${h.needsYou === 1 ? " needs" : "s need"} you`;
33
+ if (h.open > 0)
34
+ return `${h.open} health issue${h.open === 1 ? "" : "s"} open`;
35
+ return null;
36
+ }
37
+
38
+ // One dot for the machine load and the health watch: the worse of the two.
39
+ export function statusDot(
40
+ load: LoadView | null,
41
+ health: HealthCount | undefined
42
+ ): StatusDot | null {
43
+ const line = healthLine(health);
44
+ if (!load && !line) return null;
45
+ const level: DotLevel =
46
+ load?.level === "red" || (health?.needsYou ?? 0) > 0
47
+ ? "red"
48
+ : load?.level === "amber" || (health?.open ?? 0) > 0
49
+ ? "amber"
50
+ : "green";
51
+ const join = (parts: (string | null)[]) => parts.filter(Boolean).join(" · ");
52
+ return {
53
+ level,
54
+ label: join([load ? `Load ${WORD[load.level]}` : null, line]),
55
+ title: join([load ? loadSummary(load) : null, line]),
56
+ healthLine: line,
57
+ opensHealth: (health?.open ?? 0) > 0,
58
+ };
59
+ }
@@ -34,7 +34,7 @@ export function useHealthQuery() {
34
34
  });
35
35
  }
36
36
 
37
- // What the sidebar dot needs, and nothing more.
37
+ // What the sidebar status dot needs, and nothing more.
38
38
  export function useHealthCount() {
39
39
  return useQuery({
40
40
  queryKey: healthKeys.count(),
@@ -273,6 +273,11 @@ export interface QueuedMessage {
273
273
  export interface FileSuggestion {
274
274
  path: string;
275
275
  dir: boolean;
276
+ // An orchestrator's picker searches several folders: `path` is then
277
+ // absolute, `source` names the folder it came from ("HQ", a project) and
278
+ // `rel` is the path inside it.
279
+ source?: string;
280
+ rel?: string;
276
281
  }
277
282
 
278
283
  // Waiting: a turn is paused on the reader (an approval or a question).
package/lib/chat/files.ts CHANGED
@@ -1,12 +1,18 @@
1
1
  import { execFile } from "child_process";
2
+ import fs from "fs";
3
+ import path from "path";
2
4
  import { promisify } from "util";
3
5
  import { git } from "../git-exec";
6
+ import { isSecretPath } from "../secret-paths";
4
7
  import type { FileSuggestion } from "./events";
5
8
  import {
9
+ ahead,
6
10
  indexPaths,
7
11
  rankPaths,
12
+ scanPaths,
8
13
  withFolders,
9
14
  type IndexedPath,
15
+ type Scored,
10
16
  } from "./mentions";
11
17
 
12
18
  const execFileAsync = promisify(execFile);
@@ -26,13 +32,23 @@ let sweeper: ReturnType<typeof setInterval> | null = null;
26
32
  export function sweepFileIndexes(now = Date.now()): void {
27
33
  for (const [cwd, hit] of cache)
28
34
  if (now - hit.usedAt > IDLE_MS) cache.delete(cwd);
29
- if (!cache.size && sweeper) {
35
+ for (const [key, hit] of roots)
36
+ if (now - hit.usedAt > IDLE_MS) roots.delete(key);
37
+ for (const [key, hit] of narrowing)
38
+ if (now - hit.at > NARROW_MS) narrowing.delete(key);
39
+ if (!cache.size && !roots.size && !narrowing.size && sweeper) {
30
40
  clearInterval(sweeper);
31
41
  sweeper = null;
32
42
  }
33
43
  }
34
44
 
35
- export const fileIndexCount = () => cache.size;
45
+ export const fileIndexCount = () => cache.size + roots.size;
46
+
47
+ function keepSweeping() {
48
+ if (sweeper) return;
49
+ sweeper = setInterval(() => sweepFileIndexes(), 60_000);
50
+ sweeper.unref?.();
51
+ }
36
52
 
37
53
  const LIST_TIMEOUT_MS = 5000;
38
54
 
@@ -40,21 +56,26 @@ export interface Lister {
40
56
  // The program it needs.
41
57
  cmd: string;
42
58
  list: (cwd: string) => Promise<string>;
59
+ // The same, newest first, where the program can.
60
+ recent?: (cwd: string) => Promise<string>;
43
61
  }
44
62
 
63
+ const rgFiles = async (cwd: string, extra: string[]) =>
64
+ (
65
+ await execFileAsync(
66
+ "rg",
67
+ ["--files", "--hidden", "-g", "!.git", ...extra],
68
+ { cwd, maxBuffer: 64 * 1024 * 1024, timeout: LIST_TIMEOUT_MS }
69
+ )
70
+ ).stdout;
71
+
45
72
  // Ways to list a folder's files with ignored ones left out: ripgrep, or
46
73
  // git where ripgrep isn't installed.
47
74
  export const LISTERS: Lister[] = [
48
75
  {
49
76
  cmd: "rg",
50
- list: async (cwd) =>
51
- (
52
- await execFileAsync("rg", ["--files", "--hidden", "-g", "!.git"], {
53
- cwd,
54
- maxBuffer: 64 * 1024 * 1024,
55
- timeout: LIST_TIMEOUT_MS,
56
- })
57
- ).stdout,
77
+ list: (cwd) => rgFiles(cwd, []),
78
+ recent: (cwd) => rgFiles(cwd, ["--sortr", "modified"]),
58
79
  },
59
80
  {
60
81
  cmd: "git",
@@ -66,18 +87,30 @@ export const LISTERS: Lister[] = [
66
87
  },
67
88
  ];
68
89
 
90
+ // How many of the newest files get their modified time read: enough for a
91
+ // "what did I just touch" list, few enough to cost milliseconds.
92
+ const RECENT_STATS = 200;
93
+
69
94
  // The folder's files, for @mentions when no agent is running to match them
70
- // itself. Empty when neither tool can list it.
95
+ // itself. Empty when neither tool can list it. Secret files (env files, keys)
96
+ // are left out. With `recent`, the newest files carry their modified time.
71
97
  export async function listFiles(
72
98
  cwd: string,
73
- listers = LISTERS
99
+ listers = LISTERS,
100
+ recent = false
74
101
  ): Promise<IndexedPath[]> {
75
- for (const { list } of listers) {
102
+ for (const lister of listers) {
103
+ const newestFirst = recent && lister.recent;
76
104
  try {
77
- const stdout = await list(cwd);
78
- return indexPaths(
79
- withFolders(stdout.split("\n").filter(Boolean).slice(0, MAX_FILES))
80
- );
105
+ const stdout = await (newestFirst ? lister.recent! : lister.list)(cwd);
106
+ const paths = stdout
107
+ .split("\n")
108
+ .filter((p) => p && !isSecretPath(p))
109
+ .slice(0, MAX_FILES);
110
+ const indexed = indexPaths(withFolders(paths));
111
+ if (newestFirst)
112
+ stamp(indexed, await modifiedTimes(cwd, paths.slice(0, RECENT_STATS)));
113
+ return indexed;
81
114
  } catch {
82
115
  // Not installed, or not a repository: try the next.
83
116
  }
@@ -85,6 +118,38 @@ export async function listFiles(
85
118
  return [];
86
119
  }
87
120
 
121
+ // Modified times of these files, and of the folders above them (the newest
122
+ // thing inside).
123
+ async function modifiedTimes(
124
+ cwd: string,
125
+ paths: string[]
126
+ ): Promise<Map<string, number>> {
127
+ const times = new Map<string, number>();
128
+ await Promise.all(
129
+ paths.map(async (p) => {
130
+ let mtime: number;
131
+ try {
132
+ mtime = (await fs.promises.stat(path.join(cwd, p))).mtimeMs;
133
+ } catch {
134
+ return;
135
+ }
136
+ times.set(p, mtime);
137
+ for (let i = p.lastIndexOf("/"); i > 0; i = p.lastIndexOf("/", i - 1)) {
138
+ const dir = p.slice(0, i);
139
+ if ((times.get(dir) ?? 0) < mtime) times.set(dir, mtime);
140
+ }
141
+ })
142
+ );
143
+ return times;
144
+ }
145
+
146
+ function stamp(indexed: IndexedPath[], times: Map<string, number>) {
147
+ for (const p of indexed) {
148
+ const mtime = times.get(p.file.path);
149
+ if (mtime !== undefined) p.mtime = mtime;
150
+ }
151
+ }
152
+
88
153
  export async function fallbackFileSuggestions(
89
154
  cwd: string,
90
155
  query: string
@@ -96,9 +161,140 @@ export async function fallbackFileSuggestions(
96
161
  cache.set(cwd, hit);
97
162
  }
98
163
  hit.usedAt = now;
99
- if (!sweeper) {
100
- sweeper = setInterval(() => sweepFileIndexes(), 60_000);
101
- sweeper.unref?.();
102
- }
164
+ keepSweeping();
103
165
  return rankPaths(query, await hit.files);
104
166
  }
167
+
168
+ // ---- An orchestrator's "@": HQ and the project repositories ----
169
+
170
+ // An orchestrator works through tools, not in a folder, so its picker
171
+ // searches the places the workspace lives in instead.
172
+ export interface MentionRoot {
173
+ dir: string;
174
+ // "HQ", a project's name: what the menu shows beside the path.
175
+ label: string;
176
+ // HQ wins ties with the repositories.
177
+ hq: boolean;
178
+ }
179
+
180
+ const ROOT_TTL_MS = 60_000;
181
+ // A cold "@" answers from the folders listed by now rather than wait for
182
+ // the slowest repository.
183
+ const READY_MS = 120;
184
+ const HQ_BOOST = 5;
185
+ // A query's matches are kept this long for the next keystroke to narrow.
186
+ const NARROW_MS = 30_000;
187
+
188
+ interface RootEntry {
189
+ at: number;
190
+ usedAt: number;
191
+ // The last finished listing, answered from while the next is built.
192
+ list: IndexedPath[] | null;
193
+ building: Promise<IndexedPath[]> | null;
194
+ }
195
+ const roots = new Map<string, RootEntry>();
196
+
197
+ // The roots' files as the menu shows them: absolute paths with their
198
+ // source, each root's own folder first, so "@agent" finds the repository.
199
+ async function buildRoot(root: MentionRoot): Promise<IndexedPath[]> {
200
+ const listed = await listFiles(root.dir, LISTERS, true);
201
+ const self: IndexedPath = {
202
+ file: { path: root.dir, dir: true, source: root.label, rel: "" },
203
+ lower: root.label.toLowerCase(),
204
+ name: root.label.toLowerCase(),
205
+ depth: 0,
206
+ };
207
+ return [
208
+ self,
209
+ ...listed.map((p) => ({
210
+ ...p,
211
+ file: {
212
+ path: path.join(root.dir, p.file.path),
213
+ dir: p.file.dir,
214
+ source: root.label,
215
+ rel: p.file.path,
216
+ },
217
+ })),
218
+ ];
219
+ }
220
+
221
+ function rootEntry(root: MentionRoot): RootEntry {
222
+ const key = `${root.label}\0${root.dir}`;
223
+ const now = Date.now();
224
+ let hit = roots.get(key);
225
+ if (!hit) {
226
+ hit = { at: now, usedAt: now, list: null, building: null };
227
+ roots.set(key, hit);
228
+ }
229
+ hit.usedAt = now;
230
+ if (!hit.building && (!hit.list || now - hit.at > ROOT_TTL_MS)) {
231
+ const entry = hit;
232
+ entry.building = buildRoot(root)
233
+ .catch(() => [] as IndexedPath[])
234
+ .then((list) => {
235
+ entry.list = list;
236
+ entry.at = Date.now();
237
+ entry.building = null;
238
+ return list;
239
+ });
240
+ }
241
+ keepSweeping();
242
+ return hit;
243
+ }
244
+
245
+ // Starts listing the roots, so the first "@" finds them built.
246
+ export function warmFileIndexes(rootList: MentionRoot[]): void {
247
+ for (const root of rootList) rootEntry(root);
248
+ }
249
+
250
+ // What the last query kept per root, for the next to narrow.
251
+ const narrowing = new Map<
252
+ string,
253
+ { query: string; at: number; kept: Map<IndexedPath[], IndexedPath[]> }
254
+ >();
255
+
256
+ export async function multiRootFileSuggestions(
257
+ rootList: MentionRoot[],
258
+ query: string,
259
+ // Whose keystrokes these are: what one query matched narrows the next.
260
+ key = "",
261
+ stats?: { scanned: number }
262
+ ): Promise<FileSuggestion[]> {
263
+ const entries = rootList.map(rootEntry);
264
+ const pending = entries.flatMap((e) =>
265
+ e.list || !e.building ? [] : [e.building]
266
+ );
267
+ if (pending.length) {
268
+ let timer: ReturnType<typeof setTimeout> | undefined;
269
+ await Promise.race([
270
+ Promise.all(pending),
271
+ new Promise((resolve) => (timer = setTimeout(resolve, READY_MS))),
272
+ ]);
273
+ clearTimeout(timer);
274
+ }
275
+
276
+ const q = query.toLowerCase();
277
+ const prev = key ? narrowing.get(key) : undefined;
278
+ const narrower = !!prev && prev.query !== "" && q.startsWith(prev.query);
279
+ const kept = new Map<IndexedPath[], IndexedPath[]>();
280
+ const tops: Scored[] = [];
281
+ entries.forEach((entry, i) => {
282
+ const list = entry.list;
283
+ if (!list) return;
284
+ const from = (narrower && prev!.kept.get(list)) || list;
285
+ const scan = scanPaths(q, from, {
286
+ keep: q !== "",
287
+ boost: rootList[i].hq ? HQ_BOOST : 0,
288
+ });
289
+ if (stats) stats.scanned += scan.scanned;
290
+ if (scan.matches) kept.set(list, scan.matches);
291
+ tops.push(...scan.top);
292
+ });
293
+ if (key && q) narrowing.set(key, { query: q, at: Date.now(), kept });
294
+ else if (key) narrowing.delete(key);
295
+ keepSweeping();
296
+ return tops
297
+ .sort((a, b) => (ahead(a, b) ? -1 : ahead(b, a) ? 1 : 0))
298
+ .slice(0, 15)
299
+ .map((x) => x.p.file);
300
+ }
@@ -0,0 +1,80 @@
1
+ import fs from "fs";
2
+ import os from "os";
3
+ import path from "path";
4
+ import { db } from "../db";
5
+ import { getHq } from "../session-logs/hq";
6
+ import { expand, repoPaths, resolved } from "../session-logs/paths";
7
+ import type { MentionRoot } from "./files";
8
+
9
+ // HQ and seven repositories: more than a workspace has, and a bound on
10
+ // what an "@" indexes.
11
+ const MAX_ROOTS = 8;
12
+
13
+ function isFolder(dir: string): boolean {
14
+ try {
15
+ return fs.statSync(dir).isDirectory();
16
+ } catch {
17
+ return false;
18
+ }
19
+ }
20
+
21
+ // Where an orchestrator's "@" looks: the workspace's HQ, then each local
22
+ // project's repositories. Null for any other session, whose picker keeps
23
+ // searching its own folder. The orchestrator's scratch folder holds its own
24
+ // working notes, not what's pointed at, so it's searched only when nothing
25
+ // else is there.
26
+ export function mentionRoots(sessionId: string): MentionRoot[] | null {
27
+ const session = db
28
+ .prepare(
29
+ `SELECT role, workspace_id, working_directory FROM sessions WHERE id = ?`
30
+ )
31
+ .get(sessionId) as
32
+ | {
33
+ role: string | null;
34
+ workspace_id: string | null;
35
+ working_directory: string;
36
+ }
37
+ | undefined;
38
+ if (session?.role !== "orchestrator") return null;
39
+
40
+ // Never the home folder or the whole disk: a project pointed there would
41
+ // list everything under it, dotfiles and keys included.
42
+ const refused = new Set([
43
+ path.parse(os.homedir()).root,
44
+ resolved(os.homedir()),
45
+ ]);
46
+ const found: MentionRoot[] = [];
47
+ const add = (dir: string, label: string, hq: boolean, needsGit = false) => {
48
+ if (found.length >= MAX_ROOTS || refused.has(dir) || !isFolder(dir)) return;
49
+ if (needsGit && !fs.existsSync(path.join(dir, ".git"))) return;
50
+ if (found.some((r) => r.dir === dir)) return;
51
+ const taken = found.some((r) => r.label === label);
52
+ found.push({
53
+ dir,
54
+ label: taken ? `${label}/${path.basename(dir)}` : label,
55
+ hq,
56
+ });
57
+ };
58
+
59
+ const workspaceId = session.workspace_id;
60
+ if (workspaceId) {
61
+ const hq = getHq(workspaceId);
62
+ if (hq) add(resolved(expand(hq.folder)), "HQ", true, true);
63
+ const projects = db
64
+ .prepare(
65
+ `SELECT id, name FROM projects
66
+ WHERE workspace_id = ? AND host_id = 'local' AND is_uncategorized = 0
67
+ ORDER BY sort_order, name`
68
+ )
69
+ .all(workspaceId) as { id: string; name: string }[];
70
+ for (const project of projects)
71
+ repoPaths(project.id).forEach((dir, i) =>
72
+ add(dir, i === 0 ? project.name : path.basename(dir), false)
73
+ );
74
+ }
75
+ if (!found.length) {
76
+ const scratch = resolved(expand(session.working_directory));
77
+ return [{ dir: scratch, label: "scratch", hq: false }];
78
+ }
79
+ return found;
80
+ }
@@ -37,6 +37,9 @@ export interface IndexedPath {
37
37
  lower: string;
38
38
  name: string;
39
39
  depth: number;
40
+ // Modified time in ms, where the index knows it: newer ranks first among
41
+ // equal matches.
42
+ mtime?: number;
40
43
  }
41
44
 
42
45
  export function indexPaths(files: FileSuggestion[]): IndexedPath[] {
@@ -68,16 +71,48 @@ function score(q: string, p: IndexedPath): number {
68
71
  return 10;
69
72
  }
70
73
 
71
- // Ahead of b: a better score, then a shallower path, then alphabetical.
72
- const ahead = (
73
- a: { s: number; p: IndexedPath },
74
- b: { s: number; p: IndexedPath }
75
- ) =>
74
+ // Ahead of b: a better score, then the more recently modified, then a
75
+ // shallower path, then alphabetical.
76
+ export interface Scored {
77
+ s: number;
78
+ p: IndexedPath;
79
+ }
80
+ export const ahead = (a: Scored, b: Scored) =>
76
81
  a.s !== b.s
77
82
  ? a.s > b.s
78
- : a.p.depth !== b.p.depth
79
- ? a.p.depth < b.p.depth
80
- : a.p.lower < b.p.lower;
83
+ : (a.p.mtime ?? 0) !== (b.p.mtime ?? 0)
84
+ ? (a.p.mtime ?? 0) > (b.p.mtime ?? 0)
85
+ : a.p.depth !== b.p.depth
86
+ ? a.p.depth < b.p.depth
87
+ : a.p.lower < b.p.lower;
88
+
89
+ // One pass over an index: the best `limit` matches, best first, how many
90
+ // entries were looked at, and (when asked) every entry that matched, so the
91
+ // next keystroke of the same query can look at those alone: whatever
92
+ // matches "fil" also matches "file". `boost` raises every score of a folder
93
+ // that should win ties, not for an empty query where recency is the point.
94
+ export function scanPaths(
95
+ query: string,
96
+ indexed: IndexedPath[],
97
+ opts: { limit?: number; keep?: boolean; boost?: number } = {}
98
+ ): { top: Scored[]; matches: IndexedPath[] | null; scanned: number } {
99
+ const limit = opts.limit ?? 15;
100
+ const boost = query ? (opts.boost ?? 0) : 0;
101
+ const top: Scored[] = [];
102
+ const matches: IndexedPath[] | null = opts.keep ? [] : null;
103
+ for (const p of indexed) {
104
+ const raw = score(query, p);
105
+ if (raw === 0) continue;
106
+ matches?.push(p);
107
+ const x = { s: raw + boost, p };
108
+ if (top.length === limit && !ahead(x, top[top.length - 1])) continue;
109
+ let i = top.length;
110
+ while (i > 0 && ahead(x, top[i - 1])) i--;
111
+ top.splice(i, 0, x);
112
+ if (top.length > limit) top.pop();
113
+ }
114
+ return { top, matches, scanned: indexed.length };
115
+ }
81
116
 
82
117
  // Best matches first, then shorter paths; for when the agent can't be asked.
83
118
  // Keeps only the top few as it goes: a folder can hold tens of thousands of
@@ -87,23 +122,32 @@ export function rankPaths(
87
122
  files: FileSuggestion[] | IndexedPath[],
88
123
  limit = 15
89
124
  ): FileSuggestion[] {
90
- const q = query.toLowerCase();
91
125
  const indexed =
92
126
  files.length && "lower" in files[0]
93
127
  ? (files as IndexedPath[])
94
128
  : indexPaths(files as FileSuggestion[]);
95
- const top: { s: number; p: IndexedPath }[] = [];
96
- for (const p of indexed) {
97
- const s = score(q, p);
98
- if (s === 0) continue;
99
- const x = { s, p };
100
- if (top.length === limit && !ahead(x, top[top.length - 1])) continue;
101
- let i = top.length;
102
- while (i > 0 && ahead(x, top[i - 1])) i--;
103
- top.splice(i, 0, x);
104
- if (top.length > limit) top.pop();
105
- }
106
- return top.map((x) => x.p.file);
129
+ return scanPaths(query.toLowerCase(), indexed, { limit }).top.map(
130
+ (x) => x.p.file
131
+ );
132
+ }
133
+
134
+ // How a suggestion reads in the "@" menu: its name, and where it lives. An
135
+ // orchestrator's results lead the hint with their source ("HQ · docs").
136
+ export function mentionOption(f: FileSuggestion): {
137
+ label: string;
138
+ hint?: string;
139
+ } {
140
+ const label = `${f.path.slice(f.path.lastIndexOf("/") + 1)}${f.dir ? "/" : ""}`;
141
+ if (f.source === undefined)
142
+ return {
143
+ label,
144
+ hint: f.path.includes("/")
145
+ ? f.path.slice(0, f.path.lastIndexOf("/"))
146
+ : undefined,
147
+ };
148
+ const rel = f.rel ?? "";
149
+ const parent = rel.includes("/") ? rel.slice(0, rel.lastIndexOf("/")) : "";
150
+ return { label, hint: parent ? `${f.source} · ${parent}` : f.source };
107
151
  }
108
152
 
109
153
  // Every folder a file list implies, alongside the files themselves.
@@ -60,7 +60,12 @@ import {
60
60
  moveQueued,
61
61
  queuedSessions,
62
62
  } from "./queued";
63
- import { fallbackFileSuggestions } from "./files";
63
+ import {
64
+ fallbackFileSuggestions,
65
+ multiRootFileSuggestions,
66
+ warmFileIndexes,
67
+ } from "./files";
68
+ import { mentionRoots } from "./mention-roots";
64
69
  import { REQUESTED_FRESH, releaseConversation } from "./refresh";
65
70
  import { holdsQueue, settingUp } from "../sessions/setup-progress";
66
71
  import { firstMessageId, launchPending } from "../tasks/launch-gate";
@@ -631,12 +636,22 @@ export async function sendQueuedNow(
631
636
  live.worker.command({ type: "send_now", id, during });
632
637
  }
633
638
 
634
- // Files and folders for an @mention: the agent's own matching when its
635
- // worker is up and has an answer, ripgrep over the folder otherwise.
639
+ // An orchestrator's "@" index starts building when its chat opens.
640
+ export function warmChatFileIndexes(sessionId: string): void {
641
+ const roots = mentionRoots(sessionId);
642
+ if (roots) warmFileIndexes(roots);
643
+ }
644
+
645
+ // Files and folders for an @mention: an orchestrator's come from its HQ and
646
+ // project repositories (the agent only sees its scratch folder); any other
647
+ // session's from the agent's own matching when its worker is up and has an
648
+ // answer, ripgrep over the folder otherwise.
636
649
  export async function chatFileSuggestions(
637
650
  sessionId: string,
638
651
  query: string
639
652
  ): Promise<FileSuggestion[]> {
653
+ const roots = mentionRoots(sessionId);
654
+ if (roots) return multiRootFileSuggestions(roots, query, sessionId);
640
655
  const live = registry.live.get(sessionId);
641
656
  const fromAgent = live?.canQueue
642
657
  ? await live.worker.fileSuggestions(query)
@@ -11,6 +11,7 @@ import { db } from "../db";
11
11
  import {
12
12
  carryOutPlan,
13
13
  chatFileSuggestions,
14
+ warmChatFileIndexes,
14
15
  chatHistory,
15
16
  backgroundChatTask,
16
17
  chatTaskOutput,
@@ -113,6 +114,7 @@ export function serveChatSocket(
113
114
  }
114
115
  try {
115
116
  unwatch = watchChat(sessionId, reply);
117
+ if (!demo) warmChatFileIndexes(sessionId);
116
118
  } catch (err) {
117
119
  fail(err);
118
120
  if (!closed) ws.close();
@@ -44,7 +44,20 @@ export function splitUserText(text: string): UserTextPart[] {
44
44
  if (code !== null)
45
45
  parts.push({ kind: "code", code: code.join("\n"), language });
46
46
  flushProse();
47
- return parts;
47
+ // A block carries its own margin: the line breaks around it would show as
48
+ // blank lines on top of that, in a bubble that keeps its line breaks.
49
+ return parts
50
+ .map((p, i) =>
51
+ p.kind === "text"
52
+ ? {
53
+ ...p,
54
+ text: p.text
55
+ .replace(parts[i - 1]?.kind === "code" ? /^\n+/ : /^$/, "")
56
+ .replace(parts[i + 1]?.kind === "code" ? /\n+$/ : /^$/, ""),
57
+ }
58
+ : p
59
+ )
60
+ .filter((p) => p.kind === "code" || p.text !== "");
48
61
  }
49
62
 
50
63
  // Backtick spans inside prose, split out so they can be set in monospace.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@saadnvd1/agent-os",
3
- "version": "0.3.627",
3
+ "version": "0.3.630",
4
4
  "description": "Self-hosted web UI for managing Claude Code sessions",
5
5
  "bin": {
6
6
  "agent-os": "scripts/agent-os"
@@ -1,35 +0,0 @@
1
- "use client";
2
-
3
- import { Button } from "@/components/ui/button";
4
- import { useHealthCount } from "@/data/health";
5
- import { settingsUiActions } from "@/stores/settingsUi";
6
- import { cn } from "@/lib/utils";
7
-
8
- // Beside the load gauge: shown only while something is open. Amber for an
9
- // anomaly, red when it needs the person. A tap opens Settings → Health.
10
- export function HealthDot() {
11
- const { data } = useHealthCount();
12
- if (!data || data.open === 0) return null;
13
- const urgent = data.needsYou > 0;
14
- const label = urgent
15
- ? `${data.needsYou} health issue${data.needsYou === 1 ? "" : "s"} need you`
16
- : `${data.open} health issue${data.open === 1 ? "" : "s"} open`;
17
- return (
18
- <Button
19
- variant="ghost"
20
- size="icon-sm"
21
- aria-label={label}
22
- title={label}
23
- data-health-dot={urgent ? "red" : "amber"}
24
- onClick={() => settingsUiActions.open("health")}
25
- className="h-11 w-11 shrink-0 md:h-8 md:w-6 [@media(hover:none)]:md:h-11 [@media(hover:none)]:md:w-11"
26
- >
27
- <span
28
- className={cn(
29
- "h-2 w-2 rounded-sm",
30
- urgent ? "bg-destructive" : "bg-amber-500"
31
- )}
32
- />
33
- </Button>
34
- );
35
- }