@saadnvd1/agent-os 0.3.457 → 0.3.460
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/README.md +42 -5
- package/app/api/fresh-start/route.ts +19 -0
- package/app/api/workspaces/[id]/orchestrator/rules/[ruleId]/route.ts +60 -0
- package/app/api/workspaces/[id]/orchestrator/rules/presence.ts +29 -0
- package/app/api/workspaces/[id]/orchestrator/rules/route.ts +36 -0
- package/app/api/workspaces/[id]/route.ts +8 -1
- package/app/globals.css +10 -0
- package/components/Chat/ChatPanel.tsx +87 -4
- package/components/Chat/FindBar.tsx +113 -0
- package/components/Chat/Tools.tsx +1 -1
- package/components/Chat/find-highlight.test.ts +56 -0
- package/components/Chat/find-highlight.ts +78 -0
- package/components/Chat/rowState.tsx +44 -4
- package/components/Chat/stick-to-bottom.ts +7 -1
- package/components/Chat/useFind.ts +253 -0
- package/components/Pane/MobileTabBar.tsx +18 -0
- package/components/Pane/index.tsx +6 -0
- package/components/Workspaces/FreshStartForm.tsx +128 -0
- package/components/Workspaces/RulesForm.tsx +166 -0
- package/components/Workspaces/WorkspaceSettings.tsx +13 -1
- package/components/Workspaces/fresh-start.ts +19 -0
- package/data/chat/useChat.ts +29 -0
- package/data/fresh-start/index.ts +34 -0
- package/data/orchestrators/rules.ts +80 -0
- package/data/push/topics.ts +4 -0
- package/data/workspaces/index.ts +6 -1
- package/lib/chat/demo.ts +1 -0
- package/lib/chat/events.ts +7 -1
- package/lib/chat/find.ts +278 -0
- package/lib/chat/refresh-limits.ts +5 -0
- package/lib/chat/refresh.ts +82 -0
- package/lib/chat/runner.ts +11 -2
- package/lib/chat/search.ts +117 -0
- package/lib/chat/socket.ts +12 -0
- package/lib/chat/worker/extras.ts +11 -1
- package/lib/chat/worker/host.ts +70 -5
- package/lib/db/changes.ts +3 -0
- package/lib/db/migrations.ts +69 -0
- package/lib/db/types.ts +9 -0
- package/lib/orchestrator/brief.ts +16 -3
- package/lib/orchestrator/conditions.ts +10 -3
- package/lib/orchestrator/digest.ts +339 -0
- package/lib/orchestrator/pr-wake.ts +385 -0
- package/lib/orchestrator/presence-binding.ts +5 -0
- package/lib/orchestrator/rules.ts +124 -0
- package/lib/orchestrator/serve.ts +11 -0
- package/lib/orchestrator/tool-names.ts +2 -0
- package/lib/orchestrator/tool-schemas.ts +10 -0
- package/lib/orchestrator/tools.ts +4 -0
- package/lib/orchestrator/watcher.ts +6 -2
- package/lib/orchestrator/worker.ts +5 -0
- package/lib/security/presence.ts +2 -1
- package/lib/tasks/gh.ts +2 -0
- package/lib/tasks/pr-activity.ts +295 -0
- package/lib/tasks/state.ts +9 -1
- package/lib/workspaces.ts +8 -2
- package/package.json +1 -1
- package/styles/themes.css +5 -0
|
@@ -1,11 +1,44 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
createContext,
|
|
5
|
+
useCallback,
|
|
6
|
+
useContext,
|
|
7
|
+
useEffect,
|
|
8
|
+
useState,
|
|
9
|
+
} from "react";
|
|
4
10
|
|
|
5
11
|
// State a timeline row keeps across being scrolled out and back: the list
|
|
6
12
|
// unmounts rows it doesn't show, so an opened step or a sized artifact would
|
|
7
13
|
// otherwise start over each time it comes back. One store per conversation.
|
|
8
|
-
|
|
14
|
+
// Rows on screen hear a change made from outside them (find opening a step).
|
|
15
|
+
export class RowStore {
|
|
16
|
+
private values = new Map<string, unknown>();
|
|
17
|
+
private listeners = new Map<string, Set<() => void>>();
|
|
18
|
+
|
|
19
|
+
has(key: string) {
|
|
20
|
+
return this.values.has(key);
|
|
21
|
+
}
|
|
22
|
+
get(key: string) {
|
|
23
|
+
return this.values.get(key);
|
|
24
|
+
}
|
|
25
|
+
set(key: string, value: unknown) {
|
|
26
|
+
if (this.values.get(key) === value && this.values.has(key)) return;
|
|
27
|
+
this.values.set(key, value);
|
|
28
|
+
for (const fn of this.listeners.get(key) ?? []) fn();
|
|
29
|
+
}
|
|
30
|
+
subscribe(key: string, fn: () => void): () => void {
|
|
31
|
+
let set = this.listeners.get(key);
|
|
32
|
+
if (!set) this.listeners.set(key, (set = new Set()));
|
|
33
|
+
set.add(fn);
|
|
34
|
+
return () => {
|
|
35
|
+
set.delete(fn);
|
|
36
|
+
if (!set.size) this.listeners.delete(key);
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export const RowState = createContext<RowStore | null>(null);
|
|
9
42
|
|
|
10
43
|
export function useRowState<T>(
|
|
11
44
|
key: string,
|
|
@@ -15,10 +48,17 @@ export function useRowState<T>(
|
|
|
15
48
|
const [value, setValue] = useState<T>(() =>
|
|
16
49
|
store?.has(key) ? (store.get(key) as T) : initial
|
|
17
50
|
);
|
|
51
|
+
useEffect(
|
|
52
|
+
() =>
|
|
53
|
+
store?.subscribe(key, () => {
|
|
54
|
+
if (store.has(key)) setValue(store.get(key) as T);
|
|
55
|
+
}),
|
|
56
|
+
[store, key]
|
|
57
|
+
);
|
|
18
58
|
const set = useCallback(
|
|
19
59
|
(next: T) => {
|
|
20
|
-
store
|
|
21
|
-
setValue(next);
|
|
60
|
+
if (store) store.set(key, next);
|
|
61
|
+
else setValue(next);
|
|
22
62
|
},
|
|
23
63
|
[store, key]
|
|
24
64
|
);
|
|
@@ -206,6 +206,11 @@ export function stickToBottom(
|
|
|
206
206
|
return {
|
|
207
207
|
isStuck: () => stuck,
|
|
208
208
|
pin,
|
|
209
|
+
/** Stops following, for a jump up the conversation (find). */
|
|
210
|
+
release() {
|
|
211
|
+
set(false);
|
|
212
|
+
pickAnchors();
|
|
213
|
+
},
|
|
209
214
|
/** Back to the end, following again. */
|
|
210
215
|
toBottom() {
|
|
211
216
|
set(true);
|
|
@@ -271,8 +276,9 @@ export function useStickToBottom(
|
|
|
271
276
|
}, [latest]);
|
|
272
277
|
|
|
273
278
|
const toBottom = useCallback(() => ctl.current?.toBottom(), []);
|
|
279
|
+
const release = useCallback(() => ctl.current?.release(), []);
|
|
274
280
|
|
|
275
281
|
const stuck = away === null;
|
|
276
282
|
const unread = !stuck && away.seen !== latest;
|
|
277
|
-
return { stuck, unread, toBottom };
|
|
283
|
+
return { stuck, unread, toBottom, release };
|
|
278
284
|
}
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
4
|
+
import type { ChatItem } from "@/lib/chat/events";
|
|
5
|
+
import {
|
|
6
|
+
flattenHits,
|
|
7
|
+
joinPage,
|
|
8
|
+
locate,
|
|
9
|
+
MIN_QUERY,
|
|
10
|
+
stepIndex,
|
|
11
|
+
nextHits,
|
|
12
|
+
type FindResults as Results,
|
|
13
|
+
type FindTarget,
|
|
14
|
+
type SearchResult,
|
|
15
|
+
} from "@/lib/chat/find";
|
|
16
|
+
import { findRanges, paint, unpaint } from "./find-highlight";
|
|
17
|
+
import type { RowStore } from "./rowState";
|
|
18
|
+
|
|
19
|
+
// Asks the chat showing a session to open its find bar (the phone's header).
|
|
20
|
+
export const FIND_EVENT = "agentos:find-in-chat";
|
|
21
|
+
export const openFind = (sessionId: string) =>
|
|
22
|
+
window.dispatchEvent(new CustomEvent(FIND_EVENT, { detail: sessionId }));
|
|
23
|
+
|
|
24
|
+
// ⌘F on a Mac, Ctrl+F elsewhere.
|
|
25
|
+
export function isFindKey(e: {
|
|
26
|
+
key: string;
|
|
27
|
+
metaKey: boolean;
|
|
28
|
+
ctrlKey: boolean;
|
|
29
|
+
altKey: boolean;
|
|
30
|
+
shiftKey: boolean;
|
|
31
|
+
}): boolean {
|
|
32
|
+
const mac =
|
|
33
|
+
typeof navigator !== "undefined" &&
|
|
34
|
+
/Mac|iPhone|iPad/.test(navigator.platform);
|
|
35
|
+
const mod = mac ? e.metaKey && !e.ctrlKey : e.ctrlKey && !e.metaKey;
|
|
36
|
+
return mod && !e.altKey && !e.shiftKey && e.key.toLowerCase() === "f";
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// How long to wait for a match's row to render (and a tool's output to load).
|
|
40
|
+
const SETTLE_MS = 2000;
|
|
41
|
+
// How long a placed match is held in view while the list settles around it
|
|
42
|
+
// (rows measured, older pages landing), unless the reader scrolls first.
|
|
43
|
+
const HOLD_MS = 800;
|
|
44
|
+
const DEBOUNCE_MS = 150;
|
|
45
|
+
|
|
46
|
+
// A match being shown, and the block it was found in.
|
|
47
|
+
type Pinned = FindTarget & { block: string };
|
|
48
|
+
|
|
49
|
+
export type FindStatus = "idle" | "searching" | "done" | "unavailable";
|
|
50
|
+
|
|
51
|
+
export interface FindDeps {
|
|
52
|
+
search: (query: string, before?: number) => Promise<SearchResult | null>;
|
|
53
|
+
items: ChatItem[];
|
|
54
|
+
hasMore: boolean;
|
|
55
|
+
loadingOlder: boolean;
|
|
56
|
+
loadOlder: () => void;
|
|
57
|
+
rows: RowStore;
|
|
58
|
+
// Scrolls the list to a timeline block, and its scroll container.
|
|
59
|
+
scrollToBlock: (block: string) => Promise<void>;
|
|
60
|
+
scroller: () => HTMLElement | null;
|
|
61
|
+
// Stops following the end of the conversation.
|
|
62
|
+
release: () => void;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const blockEl = (root: HTMLElement, block: string) =>
|
|
66
|
+
root.querySelector<HTMLElement>(`[data-block="${CSS.escape(block)}"]`);
|
|
67
|
+
|
|
68
|
+
// The element showing an item: its own row inside a block, or the block.
|
|
69
|
+
function itemEl(root: HTMLElement, block: string, id: string) {
|
|
70
|
+
const b = blockEl(root, block);
|
|
71
|
+
return b?.querySelector<HTMLElement>(`[data-item="${CSS.escape(id)}"]`) ?? b;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Scrolls `root` so `rect` sits a third of the way down, if it's out of view.
|
|
75
|
+
function bringIntoView(root: HTMLElement, rect: DOMRect) {
|
|
76
|
+
const box = root.getBoundingClientRect();
|
|
77
|
+
if (rect.top >= box.top + 8 && rect.bottom <= box.bottom - 8) return;
|
|
78
|
+
root.scrollTop += rect.top - box.top - box.height / 3;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Find in a conversation: searches the stored conversation on the server,
|
|
83
|
+
* walks its results newest first, and brings each one on screen (loading
|
|
84
|
+
* older pages, opening folded rows) with its text highlighted.
|
|
85
|
+
*/
|
|
86
|
+
export function useFind(deps: FindDeps) {
|
|
87
|
+
const [open, setOpen] = useState(false);
|
|
88
|
+
const [query, setQuery] = useState("");
|
|
89
|
+
const [results, setResults] = useState<Results | null>(null);
|
|
90
|
+
const [index, setIndex] = useState(0);
|
|
91
|
+
const [status, setStatus] = useState<FindStatus>("idle");
|
|
92
|
+
const d = useRef(deps);
|
|
93
|
+
useEffect(() => {
|
|
94
|
+
d.current = deps;
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
// Each search answers only if it's still the latest one asked.
|
|
98
|
+
const asked = useRef(0);
|
|
99
|
+
useEffect(() => {
|
|
100
|
+
const q = query.trim();
|
|
101
|
+
const id = ++asked.current;
|
|
102
|
+
if (!open || q.length < MIN_QUERY) {
|
|
103
|
+
setResults(null);
|
|
104
|
+
setStatus("idle");
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
setStatus("searching");
|
|
108
|
+
const timer = setTimeout(async () => {
|
|
109
|
+
const search = d.current.search;
|
|
110
|
+
const first = await search(q);
|
|
111
|
+
const more =
|
|
112
|
+
first && !first.hits.length && first.hasMore
|
|
113
|
+
? await nextHits(search, q, first.cursor)
|
|
114
|
+
: null;
|
|
115
|
+
if (id !== asked.current) return;
|
|
116
|
+
setStatus(first ? "done" : "unavailable");
|
|
117
|
+
const r = first && { query: q, ...first };
|
|
118
|
+
setResults(r && more ? joinPage(r, more) : r);
|
|
119
|
+
setIndex(0);
|
|
120
|
+
}, DEBOUNCE_MS);
|
|
121
|
+
return () => clearTimeout(timer);
|
|
122
|
+
}, [open, query]);
|
|
123
|
+
|
|
124
|
+
const targets = useMemo(
|
|
125
|
+
() => (results ? flattenHits(results.hits) : []),
|
|
126
|
+
[results]
|
|
127
|
+
);
|
|
128
|
+
const target: FindTarget | undefined = targets[index];
|
|
129
|
+
|
|
130
|
+
const go = useCallback(
|
|
131
|
+
async (dir: 1 | -1) => {
|
|
132
|
+
const r = results;
|
|
133
|
+
if (!r || !targets.length) return;
|
|
134
|
+
const next = stepIndex(index, dir, targets.length, r.hasMore);
|
|
135
|
+
if (next !== "more") return setIndex(next);
|
|
136
|
+
const id = asked.current;
|
|
137
|
+
const page = await nextHits(d.current.search, r.query, r.cursor);
|
|
138
|
+
if (id !== asked.current || !page) return;
|
|
139
|
+
setResults(joinPage(r, page));
|
|
140
|
+
setIndex(page.hits.length ? targets.length : 0);
|
|
141
|
+
},
|
|
142
|
+
[results, targets, index]
|
|
143
|
+
);
|
|
144
|
+
|
|
145
|
+
// Going to a match: older pages load until it's there, then its rows open
|
|
146
|
+
// and it scrolls into view. Once per match, so new output arriving doesn't
|
|
147
|
+
// pull the reader back to it.
|
|
148
|
+
const shown = useRef<FindTarget | null>(null);
|
|
149
|
+
const [pinned, setPinned] = useState<Pinned | null>(null);
|
|
150
|
+
const { items, hasMore, loadingOlder } = deps;
|
|
151
|
+
useEffect(() => {
|
|
152
|
+
if (!open || !target || shown.current === target) return;
|
|
153
|
+
const { loadOlder, rows, release, scrollToBlock } = d.current;
|
|
154
|
+
const where = locate(items, target.id);
|
|
155
|
+
if (!where) {
|
|
156
|
+
if (!hasMore) shown.current = target;
|
|
157
|
+
else if (!loadingOlder) loadOlder();
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
shown.current = target;
|
|
161
|
+
for (const key of where.open) rows.set(key, true);
|
|
162
|
+
release();
|
|
163
|
+
const pin = () =>
|
|
164
|
+
shown.current === target && setPinned({ ...target, block: where.block });
|
|
165
|
+
scrollToBlock(where.block).then(pin, pin);
|
|
166
|
+
}, [open, target, items, hasMore, loadingOlder]);
|
|
167
|
+
|
|
168
|
+
// Paints every match on screen, and the current one apart; the current one
|
|
169
|
+
// is scrolled to once its row (and a tool's loaded output) shows it.
|
|
170
|
+
const [owner] = useState(() => ({}));
|
|
171
|
+
useEffect(() => {
|
|
172
|
+
const root = d.current.scroller();
|
|
173
|
+
const q = results?.query;
|
|
174
|
+
if (!open || !root || !q) return unpaint(owner);
|
|
175
|
+
const current = pinned;
|
|
176
|
+
let frame = 0;
|
|
177
|
+
let placedAt = current ? 0 : -1;
|
|
178
|
+
let near = false;
|
|
179
|
+
let moved = false;
|
|
180
|
+
const started = Date.now();
|
|
181
|
+
const holding = () =>
|
|
182
|
+
placedAt > 0 && !moved && Date.now() - placedAt < HOLD_MS;
|
|
183
|
+
const draw = () => {
|
|
184
|
+
frame = 0;
|
|
185
|
+
const all = findRanges(root, q);
|
|
186
|
+
let mark: Range | null = null;
|
|
187
|
+
const el = current && itemEl(root, current.block, current.id);
|
|
188
|
+
if (el) {
|
|
189
|
+
const own = findRanges(el, q);
|
|
190
|
+
const exact = own.length > current.n;
|
|
191
|
+
mark = own[Math.min(current.n, own.length - 1)] ?? null;
|
|
192
|
+
if (!placedAt && (exact || Date.now() - started > SETTLE_MS)) {
|
|
193
|
+
bringIntoView(root, (mark ?? el).getBoundingClientRect());
|
|
194
|
+
placedAt = Date.now();
|
|
195
|
+
} else if (holding()) {
|
|
196
|
+
bringIntoView(root, (mark ?? el).getBoundingClientRect());
|
|
197
|
+
} else if (!placedAt && !near) {
|
|
198
|
+
// Its row is here and the rest is loading (a tool's output).
|
|
199
|
+
bringIntoView(root, el.getBoundingClientRect());
|
|
200
|
+
near = true;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
paint(owner, all, mark);
|
|
204
|
+
if ((!placedAt && Date.now() - started < SETTLE_MS * 2) || holding())
|
|
205
|
+
schedule();
|
|
206
|
+
};
|
|
207
|
+
const schedule = () => {
|
|
208
|
+
frame ||= requestAnimationFrame(draw);
|
|
209
|
+
};
|
|
210
|
+
const watch = new MutationObserver(schedule);
|
|
211
|
+
watch.observe(root, {
|
|
212
|
+
childList: true,
|
|
213
|
+
subtree: true,
|
|
214
|
+
characterData: true,
|
|
215
|
+
});
|
|
216
|
+
const letGo = () => {
|
|
217
|
+
moved = true;
|
|
218
|
+
};
|
|
219
|
+
const input = ["wheel", "touchstart", "keydown", "pointerdown"] as const;
|
|
220
|
+
for (const type of input) root.addEventListener(type, letGo);
|
|
221
|
+
root.addEventListener("scroll", schedule, { passive: true });
|
|
222
|
+
schedule();
|
|
223
|
+
return () => {
|
|
224
|
+
cancelAnimationFrame(frame);
|
|
225
|
+
watch.disconnect();
|
|
226
|
+
root.removeEventListener("scroll", schedule);
|
|
227
|
+
for (const type of input) root.removeEventListener(type, letGo);
|
|
228
|
+
unpaint(owner);
|
|
229
|
+
};
|
|
230
|
+
}, [open, results, pinned, owner]);
|
|
231
|
+
|
|
232
|
+
const close = useCallback(() => {
|
|
233
|
+
setOpen(false);
|
|
234
|
+
setPinned(null);
|
|
235
|
+
shown.current = null;
|
|
236
|
+
}, []);
|
|
237
|
+
|
|
238
|
+
return {
|
|
239
|
+
open,
|
|
240
|
+
show: useCallback(() => setOpen(true), []),
|
|
241
|
+
close,
|
|
242
|
+
query,
|
|
243
|
+
setQuery,
|
|
244
|
+
status,
|
|
245
|
+
// 1-based, newest first; 0 with nothing found.
|
|
246
|
+
position: target ? index + 1 : 0,
|
|
247
|
+
// A capped count is a floor: never fewer than the matches walked so far.
|
|
248
|
+
total: Math.max(results?.total ?? 0, targets.length),
|
|
249
|
+
capped: results?.capped ?? false,
|
|
250
|
+
next: useCallback(() => go(1), [go]),
|
|
251
|
+
previous: useCallback(() => go(-1), [go]),
|
|
252
|
+
};
|
|
253
|
+
}
|
|
@@ -23,6 +23,7 @@ import {
|
|
|
23
23
|
Plus,
|
|
24
24
|
Search,
|
|
25
25
|
Terminal as TerminalIcon,
|
|
26
|
+
TextSearch,
|
|
26
27
|
Users,
|
|
27
28
|
} from "lucide-react";
|
|
28
29
|
import { cn } from "@/lib/utils";
|
|
@@ -80,6 +81,8 @@ interface MobileTabBarProps {
|
|
|
80
81
|
onMenuClick?: () => void;
|
|
81
82
|
onViewModeChange: (mode: ViewMode) => void;
|
|
82
83
|
onSelectSession?: (sessionId: string) => void;
|
|
84
|
+
// Opens find in the chat on show (there's no ⌘F on a phone).
|
|
85
|
+
onFind?: () => void;
|
|
83
86
|
}
|
|
84
87
|
|
|
85
88
|
export function MobileTabBar({
|
|
@@ -93,6 +96,7 @@ export function MobileTabBar({
|
|
|
93
96
|
onMenuClick,
|
|
94
97
|
onViewModeChange,
|
|
95
98
|
onSelectSession,
|
|
99
|
+
onFind,
|
|
96
100
|
}: MobileTabBarProps) {
|
|
97
101
|
// Find current session index and calculate prev/next
|
|
98
102
|
const currentIndex = session
|
|
@@ -261,6 +265,20 @@ export function MobileTabBar({
|
|
|
261
265
|
</button>
|
|
262
266
|
</div>
|
|
263
267
|
|
|
268
|
+
{onFind && (
|
|
269
|
+
<Button
|
|
270
|
+
variant="ghost"
|
|
271
|
+
size="icon-sm"
|
|
272
|
+
aria-label="Find in conversation"
|
|
273
|
+
onClick={(e) => {
|
|
274
|
+
e.stopPropagation();
|
|
275
|
+
onFind();
|
|
276
|
+
}}
|
|
277
|
+
className="-my-1.5 h-11 w-11 shrink-0"
|
|
278
|
+
>
|
|
279
|
+
<TextSearch className="h-4 w-4" />
|
|
280
|
+
</Button>
|
|
281
|
+
)}
|
|
264
282
|
<Button
|
|
265
283
|
variant="ghost"
|
|
266
284
|
size="icon-sm"
|
|
@@ -13,6 +13,7 @@ import { cn } from "@/lib/utils";
|
|
|
13
13
|
import { ConductorPanel } from "@/components/ConductorPanel";
|
|
14
14
|
import { useFileEditor } from "@/hooks/useFileEditor";
|
|
15
15
|
import { MobileTabBar } from "./MobileTabBar";
|
|
16
|
+
import { openFind } from "@/components/Chat/useFind";
|
|
16
17
|
import { DesktopTabBar } from "./DesktopTabBar";
|
|
17
18
|
import {
|
|
18
19
|
TerminalSkeleton,
|
|
@@ -402,6 +403,11 @@ export const Pane = memo(function Pane({
|
|
|
402
403
|
onMenuClick={onMenuClick}
|
|
403
404
|
onViewModeChange={setViewMode}
|
|
404
405
|
onSelectSession={onSelectSession}
|
|
406
|
+
onFind={
|
|
407
|
+
activeTab && viewMode === "terminal" && chatSessionFor(activeTab)
|
|
408
|
+
? () => openFind(activeTab.sessionId as string)
|
|
409
|
+
: undefined
|
|
410
|
+
}
|
|
405
411
|
/>
|
|
406
412
|
) : (
|
|
407
413
|
<DesktopTabBar
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useState } from "react";
|
|
4
|
+
import { toast } from "sonner";
|
|
5
|
+
import { Button } from "@/components/ui/button";
|
|
6
|
+
import { Input } from "@/components/ui/input";
|
|
7
|
+
import { Switch } from "@/components/ui/switch";
|
|
8
|
+
import type { RefreshSettings } from "@/lib/chat/refresh";
|
|
9
|
+
import { useFreshStartQuery, useUpdateFreshStart } from "@/data/fresh-start";
|
|
10
|
+
import { parseFreshStart } from "./fresh-start";
|
|
11
|
+
|
|
12
|
+
// When a chat's agent starts a fresh conversation instead of carrying a
|
|
13
|
+
// long one: app-wide, one threshold for orchestrators and one for tasks.
|
|
14
|
+
export function FreshStartForm() {
|
|
15
|
+
const { data, isPending, isError, error } = useFreshStartQuery();
|
|
16
|
+
if (isPending)
|
|
17
|
+
return <div className="bg-muted/40 h-24 animate-pulse rounded-lg" />;
|
|
18
|
+
if (isError)
|
|
19
|
+
return <p className="text-destructive text-sm">{error.message}</p>;
|
|
20
|
+
return (
|
|
21
|
+
<Form key={`${data.orchestratorTokens}:${data.taskTokens}`} saved={data} />
|
|
22
|
+
);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function Form({ saved }: { saved: RefreshSettings }) {
|
|
26
|
+
const update = useUpdateFreshStart();
|
|
27
|
+
const orchestrator = useThreshold(saved.orchestratorTokens, 200);
|
|
28
|
+
const task = useThreshold(saved.taskTokens, 200);
|
|
29
|
+
const error = orchestrator.error ?? task.error;
|
|
30
|
+
const dirty =
|
|
31
|
+
!error &&
|
|
32
|
+
(orchestrator.tokens !== saved.orchestratorTokens ||
|
|
33
|
+
task.tokens !== saved.taskTokens);
|
|
34
|
+
|
|
35
|
+
return (
|
|
36
|
+
<form
|
|
37
|
+
onSubmit={(e) => {
|
|
38
|
+
e.preventDefault();
|
|
39
|
+
if (error) return;
|
|
40
|
+
update.mutate(
|
|
41
|
+
{ orchestratorTokens: orchestrator.tokens, taskTokens: task.tokens },
|
|
42
|
+
{
|
|
43
|
+
onSuccess: () => toast.success("Fresh starts saved"),
|
|
44
|
+
onError: (err) => toast.error(err.message),
|
|
45
|
+
}
|
|
46
|
+
);
|
|
47
|
+
}}
|
|
48
|
+
className="bg-muted/40 space-y-3 rounded-lg px-3 py-3"
|
|
49
|
+
>
|
|
50
|
+
<h3 className="text-sm font-medium">Fresh starts</h3>
|
|
51
|
+
<p className="text-muted-foreground text-sm">
|
|
52
|
+
When a conversation's context passes the threshold, its agent
|
|
53
|
+
starts a new one at the next turn boundary, with its brief. An
|
|
54
|
+
orchestrator also gets its rules and the workspace's current state,
|
|
55
|
+
so nothing is lost. A task keeps only its brief and its branch.
|
|
56
|
+
</p>
|
|
57
|
+
<ThresholdRow label="Orchestrators" field={orchestrator} />
|
|
58
|
+
<ThresholdRow label="Tasks" field={task} />
|
|
59
|
+
{error && <p className="text-destructive text-xs">{error}</p>}
|
|
60
|
+
<div className="flex justify-end">
|
|
61
|
+
<Button
|
|
62
|
+
type="submit"
|
|
63
|
+
size="sm"
|
|
64
|
+
className="h-11 sm:h-8"
|
|
65
|
+
disabled={!!error || !dirty || update.isPending}
|
|
66
|
+
>
|
|
67
|
+
Save
|
|
68
|
+
</Button>
|
|
69
|
+
</div>
|
|
70
|
+
</form>
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function useThreshold(tokens: number, fallbackK: number) {
|
|
75
|
+
const [on, setOn] = useState(tokens > 0);
|
|
76
|
+
const [text, setText] = useState(
|
|
77
|
+
String(tokens > 0 ? tokens / 1000 : fallbackK)
|
|
78
|
+
);
|
|
79
|
+
const parsed = parseFreshStart(on, text);
|
|
80
|
+
return {
|
|
81
|
+
on,
|
|
82
|
+
setOn,
|
|
83
|
+
text,
|
|
84
|
+
setText,
|
|
85
|
+
tokens: "tokens" in parsed ? parsed.tokens : tokens,
|
|
86
|
+
error: "error" in parsed ? parsed.error : null,
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function ThresholdRow({
|
|
91
|
+
label,
|
|
92
|
+
field,
|
|
93
|
+
}: {
|
|
94
|
+
label: string;
|
|
95
|
+
field: ReturnType<typeof useThreshold>;
|
|
96
|
+
}) {
|
|
97
|
+
return (
|
|
98
|
+
<div className="space-y-2">
|
|
99
|
+
<label className="flex min-h-11 items-center justify-between gap-3 text-sm md:min-h-9">
|
|
100
|
+
{label}
|
|
101
|
+
<Switch
|
|
102
|
+
checked={field.on}
|
|
103
|
+
onCheckedChange={field.setOn}
|
|
104
|
+
aria-label={`Fresh starts for ${label.toLowerCase()}`}
|
|
105
|
+
/>
|
|
106
|
+
</label>
|
|
107
|
+
{field.on && (
|
|
108
|
+
<label className="flex items-center justify-between gap-3 text-sm">
|
|
109
|
+
<span className="text-muted-foreground">At</span>
|
|
110
|
+
<span className="flex items-center gap-2">
|
|
111
|
+
<Input
|
|
112
|
+
type="number"
|
|
113
|
+
inputMode="numeric"
|
|
114
|
+
min={50}
|
|
115
|
+
step={10}
|
|
116
|
+
value={field.text}
|
|
117
|
+
onChange={(e) => field.setText(e.target.value)}
|
|
118
|
+
aria-label={`${label} threshold, thousands of tokens`}
|
|
119
|
+
aria-invalid={!!field.error}
|
|
120
|
+
className="h-11 w-24 text-base sm:h-9 md:text-sm"
|
|
121
|
+
/>
|
|
122
|
+
<span className="text-muted-foreground">k tokens</span>
|
|
123
|
+
</span>
|
|
124
|
+
</label>
|
|
125
|
+
)}
|
|
126
|
+
</div>
|
|
127
|
+
);
|
|
128
|
+
}
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useState } from "react";
|
|
4
|
+
import { Check, Pencil, Trash2, X } from "lucide-react";
|
|
5
|
+
import { toast } from "sonner";
|
|
6
|
+
import { Button } from "@/components/ui/button";
|
|
7
|
+
import { Input } from "@/components/ui/input";
|
|
8
|
+
import type { RuleRow } from "@/lib/orchestrator/rules";
|
|
9
|
+
import {
|
|
10
|
+
useAddRule,
|
|
11
|
+
useRemoveRule,
|
|
12
|
+
useRulesQuery,
|
|
13
|
+
useUpdateRule,
|
|
14
|
+
} from "@/data/orchestrators/rules";
|
|
15
|
+
|
|
16
|
+
const MAX_CHARS = 300;
|
|
17
|
+
|
|
18
|
+
// The orchestrator's standing rules for one workspace: what it keeps
|
|
19
|
+
// knowing across fresh starts. It adds them itself too; Saad edits here.
|
|
20
|
+
export function RulesForm({ workspaceId }: { workspaceId: string }) {
|
|
21
|
+
const { data: rules, isPending, isError, error } = useRulesQuery(workspaceId);
|
|
22
|
+
const add = useAddRule(workspaceId);
|
|
23
|
+
const [text, setText] = useState("");
|
|
24
|
+
|
|
25
|
+
return (
|
|
26
|
+
<div className="space-y-2">
|
|
27
|
+
<div className="text-sm">Orchestrator rules</div>
|
|
28
|
+
{isPending ? (
|
|
29
|
+
<div className="bg-muted/60 h-11 animate-pulse rounded-md" />
|
|
30
|
+
) : isError ? (
|
|
31
|
+
<p className="text-destructive text-xs">{error.message}</p>
|
|
32
|
+
) : rules.length ? (
|
|
33
|
+
<ul className="space-y-1">
|
|
34
|
+
{rules.map((r) => (
|
|
35
|
+
<RuleItem key={`${r.id}:${r.text}`} rule={r} />
|
|
36
|
+
))}
|
|
37
|
+
</ul>
|
|
38
|
+
) : (
|
|
39
|
+
<p className="text-muted-foreground text-xs">
|
|
40
|
+
None yet. The orchestrator keeps one when you tell it something
|
|
41
|
+
standing, like a PR never to merge.
|
|
42
|
+
</p>
|
|
43
|
+
)}
|
|
44
|
+
<form
|
|
45
|
+
onSubmit={(e) => {
|
|
46
|
+
e.preventDefault();
|
|
47
|
+
if (!text.trim()) return;
|
|
48
|
+
add.mutate(text, {
|
|
49
|
+
onSuccess: () => setText(""),
|
|
50
|
+
onError: (err) => toast.error(err.message),
|
|
51
|
+
});
|
|
52
|
+
}}
|
|
53
|
+
className="flex items-center gap-2"
|
|
54
|
+
>
|
|
55
|
+
<Input
|
|
56
|
+
value={text}
|
|
57
|
+
maxLength={MAX_CHARS}
|
|
58
|
+
onChange={(e) => setText(e.target.value)}
|
|
59
|
+
placeholder="A PR never to merge, a URL, a house rule"
|
|
60
|
+
aria-label="New orchestrator rule"
|
|
61
|
+
className="h-11 text-base sm:h-9 md:text-sm"
|
|
62
|
+
/>
|
|
63
|
+
<Button
|
|
64
|
+
type="submit"
|
|
65
|
+
size="sm"
|
|
66
|
+
className="h-11 sm:h-8"
|
|
67
|
+
disabled={!text.trim() || add.isPending}
|
|
68
|
+
>
|
|
69
|
+
Add
|
|
70
|
+
</Button>
|
|
71
|
+
</form>
|
|
72
|
+
</div>
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function RuleItem({ rule }: { rule: RuleRow }) {
|
|
77
|
+
const update = useUpdateRule(rule.workspace_id);
|
|
78
|
+
const remove = useRemoveRule(rule.workspace_id);
|
|
79
|
+
const [editing, setEditing] = useState(false);
|
|
80
|
+
const [text, setText] = useState(rule.text);
|
|
81
|
+
|
|
82
|
+
if (editing)
|
|
83
|
+
return (
|
|
84
|
+
<li>
|
|
85
|
+
<form
|
|
86
|
+
onSubmit={(e) => {
|
|
87
|
+
e.preventDefault();
|
|
88
|
+
update.mutate(
|
|
89
|
+
{ id: rule.id, text },
|
|
90
|
+
{
|
|
91
|
+
onSuccess: () => setEditing(false),
|
|
92
|
+
onError: (err) => toast.error(err.message),
|
|
93
|
+
}
|
|
94
|
+
);
|
|
95
|
+
}}
|
|
96
|
+
className="flex items-center gap-1"
|
|
97
|
+
>
|
|
98
|
+
<Input
|
|
99
|
+
autoFocus
|
|
100
|
+
value={text}
|
|
101
|
+
maxLength={MAX_CHARS}
|
|
102
|
+
onChange={(e) => setText(e.target.value)}
|
|
103
|
+
aria-label={`Rule ${rule.id}`}
|
|
104
|
+
className="h-11 text-base sm:h-9 md:text-sm"
|
|
105
|
+
/>
|
|
106
|
+
<Button
|
|
107
|
+
type="submit"
|
|
108
|
+
variant="ghost"
|
|
109
|
+
size="icon"
|
|
110
|
+
className="size-11 shrink-0 sm:size-8"
|
|
111
|
+
disabled={!text.trim() || update.isPending}
|
|
112
|
+
aria-label="Save rule"
|
|
113
|
+
>
|
|
114
|
+
<Check className="size-4" />
|
|
115
|
+
</Button>
|
|
116
|
+
<Button
|
|
117
|
+
type="button"
|
|
118
|
+
variant="ghost"
|
|
119
|
+
size="icon"
|
|
120
|
+
className="size-11 shrink-0 sm:size-8"
|
|
121
|
+
onClick={() => {
|
|
122
|
+
setText(rule.text);
|
|
123
|
+
setEditing(false);
|
|
124
|
+
}}
|
|
125
|
+
aria-label="Cancel"
|
|
126
|
+
>
|
|
127
|
+
<X className="size-4" />
|
|
128
|
+
</Button>
|
|
129
|
+
</form>
|
|
130
|
+
</li>
|
|
131
|
+
);
|
|
132
|
+
|
|
133
|
+
return (
|
|
134
|
+
<li className="bg-background/60 flex items-start gap-1 rounded-md py-1 pr-1 pl-2.5">
|
|
135
|
+
<div className="min-w-0 flex-1 py-1.5">
|
|
136
|
+
<p className="text-sm break-words">{rule.text}</p>
|
|
137
|
+
<p className="text-muted-foreground text-xs">
|
|
138
|
+
{rule.added_by === "saad" ? "You" : "Orchestrator"}
|
|
139
|
+
</p>
|
|
140
|
+
</div>
|
|
141
|
+
<Button
|
|
142
|
+
variant="ghost"
|
|
143
|
+
size="icon"
|
|
144
|
+
className="size-11 shrink-0 sm:size-8"
|
|
145
|
+
onClick={() => setEditing(true)}
|
|
146
|
+
aria-label={`Edit rule: ${rule.text}`}
|
|
147
|
+
>
|
|
148
|
+
<Pencil className="size-4" />
|
|
149
|
+
</Button>
|
|
150
|
+
<Button
|
|
151
|
+
variant="ghost"
|
|
152
|
+
size="icon"
|
|
153
|
+
className="size-11 shrink-0 sm:size-8"
|
|
154
|
+
disabled={remove.isPending}
|
|
155
|
+
onClick={() =>
|
|
156
|
+
remove.mutate(rule.id, {
|
|
157
|
+
onError: (err) => toast.error(err.message),
|
|
158
|
+
})
|
|
159
|
+
}
|
|
160
|
+
aria-label={`Remove rule: ${rule.text}`}
|
|
161
|
+
>
|
|
162
|
+
<Trash2 className="size-4" />
|
|
163
|
+
</Button>
|
|
164
|
+
</li>
|
|
165
|
+
);
|
|
166
|
+
}
|