@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.
- package/components/Chat/Messages.test.ts +26 -0
- package/components/Chat/composer/ComposerBody.tsx +2 -4
- package/components/SessionList/LoadGauge.test.ts +89 -0
- package/components/SessionList/LoadGauge.tsx +30 -21
- package/components/SessionList/SidebarHeader.tsx +0 -2
- package/components/SessionList/statusDot.test.ts +56 -0
- package/components/SessionList/statusDot.ts +59 -0
- package/data/health/index.ts +1 -1
- package/lib/chat/events.ts +5 -0
- package/lib/chat/files.ts +217 -21
- package/lib/chat/mention-roots.ts +80 -0
- package/lib/chat/mentions.ts +65 -21
- package/lib/chat/runner.ts +18 -3
- package/lib/chat/socket.ts +2 -0
- package/lib/chat/user-text.ts +14 -1
- package/package.json +1 -1
- package/components/Health/HealthDot.tsx +0 -35
|
@@ -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
|
-
|
|
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
|
|
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<
|
|
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
|
-
|
|
20
|
-
|
|
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
|
-
//
|
|
26
|
-
//
|
|
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
|
-
|
|
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
|
+
}
|
package/data/health/index.ts
CHANGED
package/lib/chat/events.ts
CHANGED
|
@@ -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
|
-
|
|
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:
|
|
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
|
|
102
|
+
for (const lister of listers) {
|
|
103
|
+
const newestFirst = recent && lister.recent;
|
|
76
104
|
try {
|
|
77
|
-
const stdout = await list(cwd);
|
|
78
|
-
|
|
79
|
-
|
|
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
|
-
|
|
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
|
+
}
|
package/lib/chat/mentions.ts
CHANGED
|
@@ -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
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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.
|
|
79
|
-
? a.p.
|
|
80
|
-
: a.p.
|
|
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
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
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.
|
package/lib/chat/runner.ts
CHANGED
|
@@ -60,7 +60,12 @@ import {
|
|
|
60
60
|
moveQueued,
|
|
61
61
|
queuedSessions,
|
|
62
62
|
} from "./queued";
|
|
63
|
-
import {
|
|
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
|
-
//
|
|
635
|
-
|
|
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)
|
package/lib/chat/socket.ts
CHANGED
|
@@ -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();
|
package/lib/chat/user-text.ts
CHANGED
|
@@ -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
|
-
|
|
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,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
|
-
}
|