@saadnvd1/agent-os 0.3.647 → 0.3.653
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/app/api/bus/peers/route.ts +1 -1
- package/components/Chat/ChatPanel.tsx +49 -52
- package/components/Chat/QuoteButton.tsx +24 -7
- package/components/Chat/composer/editor.test.ts +39 -2
- package/components/Chat/quote-selection.test.ts +184 -2
- package/components/Chat/quote-selection.ts +70 -0
- package/components/Chat/useChatCommands.ts +19 -3
- package/components/SessionList/OrchestratorPinRow.tsx +7 -2
- package/components/SessionList/RowParts.tsx +32 -7
- package/components/SessionList/RowRename.test.ts +102 -0
- package/components/SessionList/RowShell.tsx +9 -1
- package/components/SessionList/SessionRow.tsx +2 -1
- package/components/SessionList/SessionShelves.tsx +9 -6
- package/components/SessionList/index.tsx +15 -16
- package/components/SessionList/useSessionSwitching.ts +55 -0
- package/hooks/useKeybindings.ts +27 -0
- package/lib/agents/brief.ts +1 -1
- package/lib/bus/index.ts +77 -11
- package/lib/chat/background.ts +0 -22
- package/lib/keybindings/chord.ts +114 -0
- package/lib/keybindings/focus.ts +29 -0
- package/lib/keybindings/registry.ts +84 -0
- package/lib/sidebar/visible-order.ts +56 -0
- package/package.json +1 -1
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import "@/components/Chat/composer/test-dom";
|
|
2
|
+
import { act, createElement, useState } from "react";
|
|
3
|
+
import { createRoot, type Root } from "react-dom/client";
|
|
4
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
5
|
+
import { keepRenameFocus, RowRename } from "./RowParts";
|
|
6
|
+
|
|
7
|
+
(
|
|
8
|
+
globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }
|
|
9
|
+
).IS_REACT_ACT_ENVIRONMENT = true;
|
|
10
|
+
|
|
11
|
+
let root: Root;
|
|
12
|
+
let host: HTMLElement;
|
|
13
|
+
const input = () => host.querySelector("input")!;
|
|
14
|
+
|
|
15
|
+
beforeEach(() => {
|
|
16
|
+
vi.useFakeTimers();
|
|
17
|
+
host = document.createElement("div");
|
|
18
|
+
document.body.appendChild(host);
|
|
19
|
+
root = createRoot(host);
|
|
20
|
+
});
|
|
21
|
+
afterEach(() => {
|
|
22
|
+
act(() => root.unmount());
|
|
23
|
+
host.remove();
|
|
24
|
+
vi.useRealTimers();
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
function mount(onDone: (n: string | null) => void, initial = "old") {
|
|
28
|
+
act(() => root.render(createElement(RowRename, { initial, onDone })));
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
describe("RowRename", () => {
|
|
32
|
+
it("retries focus the menu stole, and ignores blur until it has it", () => {
|
|
33
|
+
const real = window.HTMLElement.prototype.focus;
|
|
34
|
+
let calls = 0;
|
|
35
|
+
// The first focus attempts land while the menu still traps focus.
|
|
36
|
+
vi.spyOn(window.HTMLElement.prototype, "focus").mockImplementation(
|
|
37
|
+
function (this: HTMLElement) {
|
|
38
|
+
if (++calls > 2) real.call(this);
|
|
39
|
+
}
|
|
40
|
+
);
|
|
41
|
+
const done = vi.fn();
|
|
42
|
+
mount(done);
|
|
43
|
+
act(() => input().dispatchEvent(new window.FocusEvent("blur")));
|
|
44
|
+
expect(done).not.toHaveBeenCalled();
|
|
45
|
+
act(() => vi.advanceTimersByTime(150));
|
|
46
|
+
expect(document.activeElement).toBe(input());
|
|
47
|
+
vi.restoreAllMocks();
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("keeps typed text across a parent re-render (list refresh)", () => {
|
|
51
|
+
function Parent() {
|
|
52
|
+
const [n, setN] = useState(0);
|
|
53
|
+
return createElement(
|
|
54
|
+
"div",
|
|
55
|
+
null,
|
|
56
|
+
createElement("button", { onClick: () => setN(n + 1) }),
|
|
57
|
+
createElement(RowRename, { initial: "old", onDone: () => {} })
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
act(() => root.render(createElement(Parent)));
|
|
61
|
+
const el = input();
|
|
62
|
+
act(() => {
|
|
63
|
+
const set = Object.getOwnPropertyDescriptor(
|
|
64
|
+
window.HTMLInputElement.prototype,
|
|
65
|
+
"value"
|
|
66
|
+
)!.set!;
|
|
67
|
+
set.call(el, "typed");
|
|
68
|
+
el.dispatchEvent(new window.Event("input", { bubbles: true }));
|
|
69
|
+
});
|
|
70
|
+
act(() => host.querySelector("button")!.click());
|
|
71
|
+
expect(input()).toBe(el);
|
|
72
|
+
expect(input().value).toBe("typed");
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("saves on Enter, cancels on Escape, saves on blur once focused", () => {
|
|
76
|
+
const done = vi.fn();
|
|
77
|
+
mount(done);
|
|
78
|
+
act(() => vi.advanceTimersByTime(60));
|
|
79
|
+
const key = (k: string) =>
|
|
80
|
+
act(() => {
|
|
81
|
+
input().dispatchEvent(
|
|
82
|
+
new window.KeyboardEvent("keydown", { key: k, bubbles: true })
|
|
83
|
+
);
|
|
84
|
+
});
|
|
85
|
+
key("Enter");
|
|
86
|
+
expect(done).toHaveBeenLastCalledWith("old");
|
|
87
|
+
key("Escape");
|
|
88
|
+
expect(done).toHaveBeenLastCalledWith(null);
|
|
89
|
+
act(() => input().blur());
|
|
90
|
+
expect(done).toHaveBeenCalledTimes(3);
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
describe("keepRenameFocus", () => {
|
|
95
|
+
it("stops the menu returning focus only while renaming", () => {
|
|
96
|
+
const e = { preventDefault: vi.fn() } as unknown as Event;
|
|
97
|
+
keepRenameFocus(false)(e);
|
|
98
|
+
expect(e.preventDefault).not.toHaveBeenCalled();
|
|
99
|
+
keepRenameFocus(true)(e);
|
|
100
|
+
expect(e.preventDefault).toHaveBeenCalled();
|
|
101
|
+
});
|
|
102
|
+
});
|
|
@@ -6,6 +6,7 @@ import { Button } from "@/components/ui/button";
|
|
|
6
6
|
import * as DM from "@/components/ui/dropdown-menu";
|
|
7
7
|
import * as CM from "@/components/ui/context-menu";
|
|
8
8
|
import { cn } from "@/lib/utils";
|
|
9
|
+
import { keepRenameFocus } from "./RowParts";
|
|
9
10
|
|
|
10
11
|
// The open row's accent: a thin rounded tick at the left edge, inside the
|
|
11
12
|
// row so the list's overflow can't clip it.
|
|
@@ -41,6 +42,7 @@ export function RowShell({
|
|
|
41
42
|
menu,
|
|
42
43
|
menuLabel,
|
|
43
44
|
menuButton = true,
|
|
45
|
+
renaming = false,
|
|
44
46
|
}: {
|
|
45
47
|
title: string;
|
|
46
48
|
tipKey?: string;
|
|
@@ -60,6 +62,8 @@ export function RowShell({
|
|
|
60
62
|
menuLabel: string;
|
|
61
63
|
// False keeps the right-click menu but hides the ⋯ (while selecting).
|
|
62
64
|
menuButton?: boolean;
|
|
65
|
+
// A rename input is open: closing the menu mustn't move focus off it.
|
|
66
|
+
renaming?: boolean;
|
|
63
67
|
}) {
|
|
64
68
|
const content = (
|
|
65
69
|
<div
|
|
@@ -114,6 +118,7 @@ export function RowShell({
|
|
|
114
118
|
<DM.DropdownMenuContent
|
|
115
119
|
align="end"
|
|
116
120
|
className={ROW_MENU_CONTENT}
|
|
121
|
+
onCloseAutoFocus={keepRenameFocus(renaming)}
|
|
117
122
|
onClick={(e) => e.stopPropagation()}
|
|
118
123
|
>
|
|
119
124
|
{menu("dropdown")}
|
|
@@ -126,7 +131,10 @@ export function RowShell({
|
|
|
126
131
|
return (
|
|
127
132
|
<CM.ContextMenu>
|
|
128
133
|
<CM.ContextMenuTrigger asChild>{content}</CM.ContextMenuTrigger>
|
|
129
|
-
<CM.ContextMenuContent
|
|
134
|
+
<CM.ContextMenuContent
|
|
135
|
+
className={ROW_MENU_CONTENT}
|
|
136
|
+
onCloseAutoFocus={keepRenameFocus(renaming)}
|
|
137
|
+
>
|
|
130
138
|
{menu("context")}
|
|
131
139
|
</CM.ContextMenuContent>
|
|
132
140
|
</CM.ContextMenu>
|
|
@@ -76,7 +76,7 @@ export const SessionRow = memo(function SessionRow({
|
|
|
76
76
|
);
|
|
77
77
|
|
|
78
78
|
return (
|
|
79
|
-
<div>
|
|
79
|
+
<div data-session-row={session.id}>
|
|
80
80
|
<RowShell
|
|
81
81
|
title={session.name}
|
|
82
82
|
tipKey={`session:${session.id}`}
|
|
@@ -115,6 +115,7 @@ export const SessionRow = memo(function SessionRow({
|
|
|
115
115
|
menu={menu}
|
|
116
116
|
menuLabel="Session actions"
|
|
117
117
|
menuButton={!selecting}
|
|
118
|
+
renaming={renaming}
|
|
118
119
|
>
|
|
119
120
|
{renaming ? (
|
|
120
121
|
<RowRename
|
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
doneSummary,
|
|
9
9
|
type Shelves,
|
|
10
10
|
} from "@/lib/sidebar/shelves";
|
|
11
|
+
import { shownShelves } from "@/lib/sidebar/visible-order";
|
|
11
12
|
import type { MachineGroup } from "@/lib/sidebar/machines";
|
|
12
13
|
import { sidebarUiActions } from "@/stores/sidebarUi";
|
|
13
14
|
import { cn } from "@/lib/utils";
|
|
@@ -124,8 +125,10 @@ export function SessionShelves({
|
|
|
124
125
|
snoozedCollapsed: boolean;
|
|
125
126
|
donePages: number;
|
|
126
127
|
}) {
|
|
128
|
+
const folds = { doneCollapsed, snoozedCollapsed, donePages };
|
|
127
129
|
const shown = shelves.done.slice(0, doneLimit(donePages));
|
|
128
130
|
const hidden = shelves.done.length - shown.length;
|
|
131
|
+
const visible = shownShelves(shelves, folds);
|
|
129
132
|
const pendingCards = usePendingCardSessions();
|
|
130
133
|
const doneCards = shelves.done.reduce(
|
|
131
134
|
(n, r) => n + (pendingCards.get(r.session.id) ?? 0),
|
|
@@ -210,10 +213,9 @@ export function SessionShelves({
|
|
|
210
213
|
collapsed={snoozedCollapsed}
|
|
211
214
|
onToggle={sidebarUiActions.toggleSnoozed}
|
|
212
215
|
/>
|
|
213
|
-
{
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
))}
|
|
216
|
+
{visible.snoozed.map((r) => (
|
|
217
|
+
<SessionRow key={r.session.id} row={r} muted />
|
|
218
|
+
))}
|
|
217
219
|
</section>
|
|
218
220
|
)}
|
|
219
221
|
{shelves.done.length > 0 && (
|
|
@@ -223,8 +225,9 @@ export function SessionShelves({
|
|
|
223
225
|
collapsed={doneCollapsed}
|
|
224
226
|
onToggle={sidebarUiActions.toggleDone}
|
|
225
227
|
/>
|
|
226
|
-
{
|
|
227
|
-
|
|
228
|
+
{visible.done.map((r) => (
|
|
229
|
+
<SessionRow key={r.session.id} row={r} muted />
|
|
230
|
+
))}
|
|
228
231
|
{!doneCollapsed && hidden > 0 && (
|
|
229
232
|
<button
|
|
230
233
|
type="button"
|
|
@@ -7,12 +7,13 @@ import { useDocsWorkspace } from "@/data/lumifyhub/docs";
|
|
|
7
7
|
import { useFreshStart, usePinSession } from "@/data/sessions";
|
|
8
8
|
import { useOrchestratorsQuery } from "@/data/orchestrators";
|
|
9
9
|
import { docsUiActions } from "@/stores/docsUi";
|
|
10
|
-
import
|
|
10
|
+
import { visibleSessionIds } from "@/lib/sidebar/visible-order";
|
|
11
11
|
import type { OrchestratorOverview } from "@/lib/orchestrator/overview";
|
|
12
12
|
import { useSessionListMutations } from "./hooks/useSessionListMutations";
|
|
13
13
|
import { useBulkDelete } from "./hooks/useBulkDelete";
|
|
14
14
|
import { useSidebarData } from "./useSidebarData";
|
|
15
15
|
import { useSessionTips } from "./useSessionTips";
|
|
16
|
+
import { useSessionSwitching } from "./useSessionSwitching";
|
|
16
17
|
import { RowProvider, type RowContextValue } from "./RowContext";
|
|
17
18
|
import { SidebarHeader } from "./SidebarHeader";
|
|
18
19
|
import { useAddProject } from "@/components/Projects/AddProject/useAddProject";
|
|
@@ -28,9 +29,6 @@ export type { SessionListProps } from "./SessionList.types";
|
|
|
28
29
|
// Shared, so a query still loading doesn't hand rows a new array each render.
|
|
29
30
|
const NO_ORCHESTRATORS: OrchestratorOverview[] = [];
|
|
30
31
|
|
|
31
|
-
const flatten = (rows: SidebarRow[]): string[] =>
|
|
32
|
-
rows.flatMap((r) => [r.session.id, ...flatten(r.workers)]);
|
|
33
|
-
|
|
34
32
|
// One flat list of the workspace's sessions, on shelves: Pinned, Needs
|
|
35
33
|
// you, Working, In review, Done.
|
|
36
34
|
export function SessionList({
|
|
@@ -59,19 +57,20 @@ export function SessionList({
|
|
|
59
57
|
|
|
60
58
|
const orderedIds = useMemo(
|
|
61
59
|
() =>
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
60
|
+
visibleSessionIds(shelves, data.machines, {
|
|
61
|
+
doneCollapsed: ui.doneCollapsed,
|
|
62
|
+
snoozedCollapsed: ui.snoozedCollapsed,
|
|
63
|
+
donePages: ui.donePages,
|
|
64
|
+
}),
|
|
65
|
+
[
|
|
66
|
+
shelves,
|
|
67
|
+
data.machines,
|
|
68
|
+
ui.doneCollapsed,
|
|
69
|
+
ui.snoozedCollapsed,
|
|
70
|
+
ui.donePages,
|
|
71
|
+
]
|
|
74
72
|
);
|
|
73
|
+
useSessionSwitching(orderedIds, activeSessionId, onSelect);
|
|
75
74
|
|
|
76
75
|
// Rows read their handlers through the latest render, so the context only
|
|
77
76
|
// changes when what rows draw does: a status push redraws the rows it
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { MessageSquare } from "lucide-react";
|
|
4
|
+
import { useKeybindings } from "@/hooks/useKeybindings";
|
|
5
|
+
import { usePaletteCommands } from "@/hooks/usePaletteCommands";
|
|
6
|
+
import { chordHint } from "@/lib/keybindings/chord";
|
|
7
|
+
import { fieldOwnsKey } from "@/lib/keybindings/focus";
|
|
8
|
+
import { neighbourSession } from "@/lib/sidebar/visible-order";
|
|
9
|
+
|
|
10
|
+
const PREVIOUS = "mod+up";
|
|
11
|
+
const NEXT = "mod+down";
|
|
12
|
+
|
|
13
|
+
// ⌘↑ / ⌘↓ (Ctrl off a Mac) select the row above or below the active one, as
|
|
14
|
+
// a click would, in the order the sidebar shows. Nothing happens at either end.
|
|
15
|
+
export function useSessionSwitching(
|
|
16
|
+
orderedIds: string[],
|
|
17
|
+
activeSessionId: string | undefined,
|
|
18
|
+
onSelect: (id: string) => void
|
|
19
|
+
) {
|
|
20
|
+
const step = (by: 1 | -1) => {
|
|
21
|
+
const id = neighbourSession(orderedIds, activeSessionId, by);
|
|
22
|
+
if (!id) return;
|
|
23
|
+
onSelect(id);
|
|
24
|
+
requestAnimationFrame(() =>
|
|
25
|
+
document
|
|
26
|
+
.querySelector(`[data-session-row="${CSS.escape(id)}"]`)
|
|
27
|
+
?.scrollIntoView({ block: "nearest" })
|
|
28
|
+
);
|
|
29
|
+
};
|
|
30
|
+
const free = (e: KeyboardEvent) => !fieldOwnsKey(e.target);
|
|
31
|
+
useKeybindings("session-switching", [
|
|
32
|
+
{ id: "session.previous", keys: PREVIOUS, when: free, run: () => step(-1) },
|
|
33
|
+
{ id: "session.next", keys: NEXT, when: free, run: () => step(1) },
|
|
34
|
+
]);
|
|
35
|
+
usePaletteCommands("session-switching", [
|
|
36
|
+
{
|
|
37
|
+
id: "session.previous",
|
|
38
|
+
title: "Previous session",
|
|
39
|
+
group: "Sessions",
|
|
40
|
+
hint: chordHint(PREVIOUS),
|
|
41
|
+
keywords: ["switch", "up"],
|
|
42
|
+
icon: MessageSquare,
|
|
43
|
+
run: () => step(-1),
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
id: "session.next",
|
|
47
|
+
title: "Next session",
|
|
48
|
+
group: "Sessions",
|
|
49
|
+
hint: chordHint(NEXT),
|
|
50
|
+
keywords: ["switch", "down"],
|
|
51
|
+
icon: MessageSquare,
|
|
52
|
+
run: () => step(1),
|
|
53
|
+
},
|
|
54
|
+
]);
|
|
55
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useEffect, useRef } from "react";
|
|
4
|
+
import { keybindings, type Keybinding } from "@/lib/keybindings/registry";
|
|
5
|
+
|
|
6
|
+
// Binds chords while the caller is mounted. `when` and `run` always see the
|
|
7
|
+
// latest render, so callers don't memoise them.
|
|
8
|
+
export function useKeybindings(source: string, bindings: Keybinding[]) {
|
|
9
|
+
const latest = useRef(bindings);
|
|
10
|
+
useEffect(() => {
|
|
11
|
+
latest.current = bindings;
|
|
12
|
+
});
|
|
13
|
+
const keys = bindings
|
|
14
|
+
.map((b) => `${b.id}:${b.keys}:${b.repeat ? 1 : 0}`)
|
|
15
|
+
.join("|");
|
|
16
|
+
useEffect(() => {
|
|
17
|
+
const at = (id: string) => latest.current.find((b) => b.id === id)!;
|
|
18
|
+
return keybindings.register(
|
|
19
|
+
source,
|
|
20
|
+
latest.current.map((b) => ({
|
|
21
|
+
...b,
|
|
22
|
+
when: (e) => at(b.id).when?.(e) ?? true,
|
|
23
|
+
run: () => at(b.id).run(),
|
|
24
|
+
}))
|
|
25
|
+
);
|
|
26
|
+
}, [source, keys]);
|
|
27
|
+
}
|
package/lib/agents/brief.ts
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
export const BUS_BRIEF = `You are one of several coding agents running in AgentOS. Other sessions may be working on related projects at the same time, and you can talk to them with the \`aos\` command:
|
|
3
3
|
|
|
4
4
|
- \`aos peers\`: list sessions, their project and what each is doing
|
|
5
|
-
- \`aos send <session> "<message>"\`: message a session by name (or project/name, an old name, or its id); it prints delivered, queued or FAILED
|
|
5
|
+
- \`aos send <session> "<message>"\`: message a session by name (or project/name, an old name, or its id; \`orchestrator\` is your workspace's orchestrator); it prints delivered, queued or FAILED
|
|
6
6
|
- \`aos inbox\`: read messages sent to you
|
|
7
7
|
- \`aos history <session>\`: your conversation with a session
|
|
8
8
|
- \`aos spawn <project> "<prompt>"\`: start a new agent session in a project
|
package/lib/bus/index.ts
CHANGED
|
@@ -7,7 +7,9 @@
|
|
|
7
7
|
import { db, type Session } from "../db";
|
|
8
8
|
import { getProject } from "../projects";
|
|
9
9
|
import { statusDetector } from "../status-detector";
|
|
10
|
-
import { sendChatConfirmed } from "../chat/runner";
|
|
10
|
+
import { chatState, sendChatConfirmed } from "../chat/runner";
|
|
11
|
+
import { chatActivityLine } from "../chat/activity";
|
|
12
|
+
import { getWorkspace } from "../workspaces";
|
|
11
13
|
import type { ChatOrigin, PeerMessage } from "../chat/events";
|
|
12
14
|
import { sessionRowInfo } from "../session-meta";
|
|
13
15
|
import { previousNames } from "../session-names";
|
|
@@ -38,6 +40,10 @@ export interface Peer {
|
|
|
38
40
|
hostId: string;
|
|
39
41
|
running: boolean;
|
|
40
42
|
doing: string | null;
|
|
43
|
+
// The workspace it is the orchestrator of, else null.
|
|
44
|
+
orchestratorOf: string | null;
|
|
45
|
+
// The asking session's own workspace orchestrator.
|
|
46
|
+
yours?: boolean;
|
|
41
47
|
}
|
|
42
48
|
|
|
43
49
|
function liveSessions(): Session[] {
|
|
@@ -68,19 +74,41 @@ const sessionById = (id: string | null | undefined) =>
|
|
|
68
74
|
| undefined)
|
|
69
75
|
: undefined;
|
|
70
76
|
|
|
77
|
+
// A chat has no pane: its worker is the state, and it can always take a
|
|
78
|
+
// message (queued as its next prompt), so it is never "not running".
|
|
79
|
+
async function peerState(
|
|
80
|
+
s: Session
|
|
81
|
+
): Promise<{ running: boolean; subtitle: string | null }> {
|
|
82
|
+
if (s.view === "chat") {
|
|
83
|
+
const busy = chatState(s.id) === "running";
|
|
84
|
+
return {
|
|
85
|
+
running: true,
|
|
86
|
+
subtitle: chatActivityLine(s.id) ?? (busy ? "working" : "idle"),
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
const status = statusDetector.sessionExists(s.tmux_name)
|
|
90
|
+
? await statusDetector.getStatus(s.tmux_name)
|
|
91
|
+
: "dead";
|
|
92
|
+
const info = sessionRowInfo(status, statusDetector.titleFor(s.tmux_name));
|
|
93
|
+
return { running: info.running, subtitle: info.subtitle };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const orchestratorOf = (s: Session) =>
|
|
97
|
+
s.role === "orchestrator" && s.workspace_id
|
|
98
|
+
? (getWorkspace(s.workspace_id)?.name ?? "a workspace")
|
|
99
|
+
: null;
|
|
100
|
+
|
|
71
101
|
// `callerId`: the asking session. A task its orchestrator moved here also
|
|
72
102
|
// sees that orchestrator, which it can message though it runs elsewhere.
|
|
73
103
|
export async function listPeers(callerId?: string): Promise<Peer[]> {
|
|
74
104
|
await statusDetector.refreshCache();
|
|
75
105
|
const sessions = liveSessions();
|
|
76
106
|
const named = candidates(sessions);
|
|
77
|
-
const
|
|
107
|
+
const caller = sessionById(callerId);
|
|
108
|
+
const orchestrator = heldOrchestrator(caller);
|
|
78
109
|
const listed = await Promise.all(
|
|
79
110
|
sessions.map(async (s, i) => {
|
|
80
|
-
const
|
|
81
|
-
? await statusDetector.getStatus(s.tmux_name)
|
|
82
|
-
: "dead";
|
|
83
|
-
const info = sessionRowInfo(status, statusDetector.titleFor(s.tmux_name));
|
|
111
|
+
const info = await peerState(s);
|
|
84
112
|
return {
|
|
85
113
|
id: s.id,
|
|
86
114
|
name: s.name,
|
|
@@ -90,6 +118,10 @@ export async function listPeers(callerId?: string): Promise<Peer[]> {
|
|
|
90
118
|
hostId: s.host_id,
|
|
91
119
|
running: info.running,
|
|
92
120
|
doing: info.subtitle,
|
|
121
|
+
orchestratorOf: orchestratorOf(s),
|
|
122
|
+
...(caller?.workspace_id &&
|
|
123
|
+
s.role === "orchestrator" &&
|
|
124
|
+
s.workspace_id === caller.workspace_id && { yours: true }),
|
|
93
125
|
};
|
|
94
126
|
})
|
|
95
127
|
);
|
|
@@ -105,6 +137,8 @@ export async function listPeers(callerId?: string): Promise<Peer[]> {
|
|
|
105
137
|
hostId: "remote",
|
|
106
138
|
running: true,
|
|
107
139
|
doing: `your orchestrator, on ${orchestrator.heldFrom}`,
|
|
140
|
+
orchestratorOf: null,
|
|
141
|
+
yours: true,
|
|
108
142
|
},
|
|
109
143
|
];
|
|
110
144
|
}
|
|
@@ -116,28 +150,60 @@ export function resolveHeldOrchestrator(callerId: string, ref: string) {
|
|
|
116
150
|
|
|
117
151
|
// A live session by name, old name, project/name, id or id prefix. The note
|
|
118
152
|
// says when the name meant something other than its current owner.
|
|
119
|
-
export function resolveTarget(
|
|
153
|
+
export function resolveTarget(
|
|
154
|
+
ref: string,
|
|
155
|
+
callerId?: string | null
|
|
156
|
+
): {
|
|
120
157
|
session: Session;
|
|
121
158
|
note?: string;
|
|
122
159
|
} {
|
|
123
|
-
const found = tryResolveTarget(ref);
|
|
160
|
+
const found = tryResolveTarget(ref, callerId);
|
|
124
161
|
if (!found.ok) throw new Error(found.error);
|
|
125
162
|
return found;
|
|
126
163
|
}
|
|
127
164
|
|
|
165
|
+
// "orchestrator" / "my orchestrator": the caller's own workspace orchestrator.
|
|
166
|
+
function ownOrchestrator(
|
|
167
|
+
ref: string,
|
|
168
|
+
sessions: Session[],
|
|
169
|
+
callerId?: string | null
|
|
170
|
+
): Session | undefined {
|
|
171
|
+
if (!/^(my )?orchestrator$/i.test(ref.trim())) return undefined;
|
|
172
|
+
const workspace = sessionById(callerId)?.workspace_id;
|
|
173
|
+
if (!workspace) return undefined;
|
|
174
|
+
return sessions.find(
|
|
175
|
+
(s) => s.role === "orchestrator" && s.workspace_id === workspace
|
|
176
|
+
);
|
|
177
|
+
}
|
|
178
|
+
|
|
128
179
|
function tryResolveTarget(
|
|
129
|
-
ref: string
|
|
180
|
+
ref: string,
|
|
181
|
+
callerId?: string | null
|
|
130
182
|
):
|
|
131
183
|
| { ok: true; session: Session; note?: string }
|
|
132
184
|
| { ok: false; none: boolean; error: string } {
|
|
133
185
|
const sessions = liveSessions();
|
|
186
|
+
const own = ownOrchestrator(ref, sessions, callerId);
|
|
187
|
+
if (own) return { ok: true, session: own };
|
|
134
188
|
const r = resolveRef(ref, candidates(sessions));
|
|
135
|
-
if (!r.ok)
|
|
189
|
+
if (!r.ok) {
|
|
190
|
+
const orchestrators = /^(my )?orchestrator$/i.test(ref.trim())
|
|
191
|
+
? sessions.filter((s) => s.role === "orchestrator")
|
|
192
|
+
: [];
|
|
193
|
+
if (orchestrators.length)
|
|
194
|
+
return {
|
|
195
|
+
ok: false,
|
|
196
|
+
none: true,
|
|
197
|
+
error: `You are in no workspace, so "${ref}" is ambiguous. Orchestrators: ${orchestrators
|
|
198
|
+
.map((s) => `${s.name} (id ${s.id.slice(0, 8)})`)
|
|
199
|
+
.join(", ")}`,
|
|
200
|
+
};
|
|
136
201
|
return {
|
|
137
202
|
ok: false,
|
|
138
203
|
none: r.reason === "none",
|
|
139
204
|
error: r.reason === "none" ? `${r.error}. Run: aos peers` : r.error,
|
|
140
205
|
};
|
|
206
|
+
}
|
|
141
207
|
return {
|
|
142
208
|
ok: true,
|
|
143
209
|
session: sessions.find((s) => s.id === r.id)!,
|
|
@@ -287,7 +353,7 @@ export async function sendMessage(opts: {
|
|
|
287
353
|
// A held task's message to its orchestrator is kept for the holder to pull.
|
|
288
354
|
const held = heldTarget(from, opts.to);
|
|
289
355
|
if (from && held) return storeForHolder(from, held, body);
|
|
290
|
-
const target = tryResolveTarget(opts.to);
|
|
356
|
+
const target = tryResolveTarget(opts.to, opts.fromId);
|
|
291
357
|
if (!target.ok) {
|
|
292
358
|
// A task still in the start queue has no session yet: hold it.
|
|
293
359
|
const queued = target.none ? findQueuedTarget(opts.to) : null;
|
package/lib/chat/background.ts
CHANGED
|
@@ -24,28 +24,6 @@ export function backgroundable(
|
|
|
24
24
|
);
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
-
// Ctrl+B, nothing else held, and only while there is something to move;
|
|
28
|
-
// otherwise the key is left to the page.
|
|
29
|
-
export function backgroundKey(
|
|
30
|
-
e: {
|
|
31
|
-
key: string;
|
|
32
|
-
ctrlKey: boolean;
|
|
33
|
-
metaKey: boolean;
|
|
34
|
-
altKey: boolean;
|
|
35
|
-
shiftKey: boolean;
|
|
36
|
-
repeat?: boolean;
|
|
37
|
-
isComposing?: boolean;
|
|
38
|
-
},
|
|
39
|
-
available: boolean
|
|
40
|
-
): boolean {
|
|
41
|
-
return (
|
|
42
|
-
available &&
|
|
43
|
-
e.key.toLowerCase() === "b" &&
|
|
44
|
-
e.ctrlKey &&
|
|
45
|
-
!(e.metaKey || e.altKey || e.shiftKey || e.repeat || e.isComposing)
|
|
46
|
-
);
|
|
47
|
-
}
|
|
48
|
-
|
|
49
27
|
// Errors that mean the agent never got the request.
|
|
50
28
|
const UNREACHED = new Set(["unsupported", "unavailable", "timeout"]);
|
|
51
29
|
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
export interface Chord {
|
|
2
|
+
key: string;
|
|
3
|
+
ctrl: boolean;
|
|
4
|
+
meta: boolean;
|
|
5
|
+
alt: boolean;
|
|
6
|
+
shift: boolean;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface KeyLike {
|
|
10
|
+
key: string;
|
|
11
|
+
ctrlKey: boolean;
|
|
12
|
+
metaKey: boolean;
|
|
13
|
+
altKey: boolean;
|
|
14
|
+
shiftKey: boolean;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const MODS: Record<string, "ctrl" | "meta" | "alt" | "shift"> = {
|
|
18
|
+
ctrl: "ctrl",
|
|
19
|
+
control: "ctrl",
|
|
20
|
+
cmd: "meta",
|
|
21
|
+
meta: "meta",
|
|
22
|
+
alt: "alt",
|
|
23
|
+
option: "alt",
|
|
24
|
+
shift: "shift",
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
const KEY_NAMES: Record<string, string> = {
|
|
28
|
+
esc: "escape",
|
|
29
|
+
space: " ",
|
|
30
|
+
return: "enter",
|
|
31
|
+
up: "arrowup",
|
|
32
|
+
down: "arrowdown",
|
|
33
|
+
left: "arrowleft",
|
|
34
|
+
right: "arrowright",
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
export const isMac = () =>
|
|
38
|
+
typeof navigator !== "undefined" &&
|
|
39
|
+
/mac|iphone|ipad/i.test(navigator.platform);
|
|
40
|
+
|
|
41
|
+
// "ctrl+b", "shift+tab", "meta+k". Modifiers must match exactly: ctrl+b is
|
|
42
|
+
// not cmd+b and not ctrl+shift+b. "mod" is Cmd on a Mac and Ctrl elsewhere.
|
|
43
|
+
export function parseChord(keys: string, mac: boolean = isMac()): Chord {
|
|
44
|
+
const chord: Chord = {
|
|
45
|
+
key: "",
|
|
46
|
+
ctrl: false,
|
|
47
|
+
meta: false,
|
|
48
|
+
alt: false,
|
|
49
|
+
shift: false,
|
|
50
|
+
};
|
|
51
|
+
for (const raw of keys.toLowerCase().split("+")) {
|
|
52
|
+
const part = raw.trim();
|
|
53
|
+
const mod = part === "mod" ? (mac ? "meta" : "ctrl") : MODS[part];
|
|
54
|
+
if (mod) chord[mod] = true;
|
|
55
|
+
else if (chord.key) throw new Error(`Two keys in chord "${keys}"`);
|
|
56
|
+
else chord.key = KEY_NAMES[part] ?? part;
|
|
57
|
+
}
|
|
58
|
+
if (!chord.key) throw new Error(`No key in chord "${keys}"`);
|
|
59
|
+
return chord;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function matchChord(chord: Chord, e: KeyLike): boolean {
|
|
63
|
+
return (
|
|
64
|
+
e.key.toLowerCase() === chord.key &&
|
|
65
|
+
e.ctrlKey === chord.ctrl &&
|
|
66
|
+
e.metaKey === chord.meta &&
|
|
67
|
+
e.altKey === chord.alt &&
|
|
68
|
+
e.shiftKey === chord.shift
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// "Ctrl+B" for display (the label users know from Claude Code).
|
|
73
|
+
export function formatChord(keys: string): string {
|
|
74
|
+
return keys
|
|
75
|
+
.split("+")
|
|
76
|
+
.map((p) => {
|
|
77
|
+
const t = p.trim();
|
|
78
|
+
return t.length === 1
|
|
79
|
+
? t.toUpperCase()
|
|
80
|
+
: t[0].toUpperCase() + t.slice(1).toLowerCase();
|
|
81
|
+
})
|
|
82
|
+
.join("+");
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const SYMBOLS: Record<string, string> = {
|
|
86
|
+
up: "↑",
|
|
87
|
+
down: "↓",
|
|
88
|
+
left: "←",
|
|
89
|
+
right: "→",
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
// "mod+up" as a hint: "⌘↑" on a Mac, "Ctrl+↑" elsewhere.
|
|
93
|
+
export function chordHint(keys: string, mac: boolean = isMac()): string {
|
|
94
|
+
const parts = keys.split("+").map((p) => p.trim().toLowerCase());
|
|
95
|
+
const label = (p: string) =>
|
|
96
|
+
SYMBOLS[p] ??
|
|
97
|
+
(p.length === 1 ? p.toUpperCase() : p[0].toUpperCase() + p.slice(1));
|
|
98
|
+
if (!mac)
|
|
99
|
+
return parts
|
|
100
|
+
.map((p) =>
|
|
101
|
+
p === "mod" || p === "cmd" || p === "meta" ? "Ctrl" : label(p)
|
|
102
|
+
)
|
|
103
|
+
.join("+");
|
|
104
|
+
const sym: Record<string, string> = {
|
|
105
|
+
mod: "⌘",
|
|
106
|
+
cmd: "⌘",
|
|
107
|
+
meta: "⌘",
|
|
108
|
+
ctrl: "⌃",
|
|
109
|
+
alt: "⌥",
|
|
110
|
+
option: "⌥",
|
|
111
|
+
shift: "⇧",
|
|
112
|
+
};
|
|
113
|
+
return parts.map((p) => sym[p] ?? label(p)).join("");
|
|
114
|
+
}
|