@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.
Files changed (58) hide show
  1. package/README.md +42 -5
  2. package/app/api/fresh-start/route.ts +19 -0
  3. package/app/api/workspaces/[id]/orchestrator/rules/[ruleId]/route.ts +60 -0
  4. package/app/api/workspaces/[id]/orchestrator/rules/presence.ts +29 -0
  5. package/app/api/workspaces/[id]/orchestrator/rules/route.ts +36 -0
  6. package/app/api/workspaces/[id]/route.ts +8 -1
  7. package/app/globals.css +10 -0
  8. package/components/Chat/ChatPanel.tsx +87 -4
  9. package/components/Chat/FindBar.tsx +113 -0
  10. package/components/Chat/Tools.tsx +1 -1
  11. package/components/Chat/find-highlight.test.ts +56 -0
  12. package/components/Chat/find-highlight.ts +78 -0
  13. package/components/Chat/rowState.tsx +44 -4
  14. package/components/Chat/stick-to-bottom.ts +7 -1
  15. package/components/Chat/useFind.ts +253 -0
  16. package/components/Pane/MobileTabBar.tsx +18 -0
  17. package/components/Pane/index.tsx +6 -0
  18. package/components/Workspaces/FreshStartForm.tsx +128 -0
  19. package/components/Workspaces/RulesForm.tsx +166 -0
  20. package/components/Workspaces/WorkspaceSettings.tsx +13 -1
  21. package/components/Workspaces/fresh-start.ts +19 -0
  22. package/data/chat/useChat.ts +29 -0
  23. package/data/fresh-start/index.ts +34 -0
  24. package/data/orchestrators/rules.ts +80 -0
  25. package/data/push/topics.ts +4 -0
  26. package/data/workspaces/index.ts +6 -1
  27. package/lib/chat/demo.ts +1 -0
  28. package/lib/chat/events.ts +7 -1
  29. package/lib/chat/find.ts +278 -0
  30. package/lib/chat/refresh-limits.ts +5 -0
  31. package/lib/chat/refresh.ts +82 -0
  32. package/lib/chat/runner.ts +11 -2
  33. package/lib/chat/search.ts +117 -0
  34. package/lib/chat/socket.ts +12 -0
  35. package/lib/chat/worker/extras.ts +11 -1
  36. package/lib/chat/worker/host.ts +70 -5
  37. package/lib/db/changes.ts +3 -0
  38. package/lib/db/migrations.ts +69 -0
  39. package/lib/db/types.ts +9 -0
  40. package/lib/orchestrator/brief.ts +16 -3
  41. package/lib/orchestrator/conditions.ts +10 -3
  42. package/lib/orchestrator/digest.ts +339 -0
  43. package/lib/orchestrator/pr-wake.ts +385 -0
  44. package/lib/orchestrator/presence-binding.ts +5 -0
  45. package/lib/orchestrator/rules.ts +124 -0
  46. package/lib/orchestrator/serve.ts +11 -0
  47. package/lib/orchestrator/tool-names.ts +2 -0
  48. package/lib/orchestrator/tool-schemas.ts +10 -0
  49. package/lib/orchestrator/tools.ts +4 -0
  50. package/lib/orchestrator/watcher.ts +6 -2
  51. package/lib/orchestrator/worker.ts +5 -0
  52. package/lib/security/presence.ts +2 -1
  53. package/lib/tasks/gh.ts +2 -0
  54. package/lib/tasks/pr-activity.ts +295 -0
  55. package/lib/tasks/state.ts +9 -1
  56. package/lib/workspaces.ts +8 -2
  57. package/package.json +1 -1
  58. package/styles/themes.css +5 -0
@@ -1,11 +1,44 @@
1
1
  "use client";
2
2
 
3
- import { createContext, useCallback, useContext, useState } from "react";
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
- export const RowState = createContext<Map<string, unknown> | null>(null);
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?.set(key, next);
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&apos;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&apos;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
+ }